NewRuleWizardPage.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*
* Contributors:
* Carsten Hammer - initial API and implementation
*******************************************************************************/
package org.sandbox.jdt.internal.ui.wizard;
import java.util.Optional;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.core.runtime.preferences.IEclipsePreferences;
import org.eclipse.core.runtime.preferences.InstanceScope;
import org.eclipse.jface.wizard.WizardPage;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.StyledText;
import org.eclipse.swt.dnd.Clipboard;
import org.eclipse.swt.dnd.TextTransfer;
import org.eclipse.swt.events.ModifyListener;
import org.eclipse.swt.events.SelectionAdapter;
import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.layout.GridLayout;
import org.eclipse.swt.layout.RowLayout;
import org.eclipse.swt.widgets.Button;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Group;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Text;
import org.eclipse.ui.dialogs.PreferencesUtil;
import org.sandbox.jdt.internal.ui.preferences.LlmPreferencePage;
import org.sandbox.jdt.triggerpattern.internal.DslValidator;
import org.sandbox.jdt.triggerpattern.internal.DslValidator.ValidationResult;
import org.sandbox.jdt.triggerpattern.llm.AiRuleInferenceEngine;
import org.sandbox.jdt.triggerpattern.llm.CommitEvaluation;
import org.sandbox.jdt.triggerpattern.mining.llm.EclipseLlmService;
/**
* Second wizard page – rule editor with live validation, preview, and
* optional AI-powered rule generation.
*
* <p>The user enters a source pattern, an optional guard, and a replacement
* pattern. The page validates the resulting DSL in real time and shows a
* serialized preview.</p>
*
* <p>When the LLM service is available, a “Generate with AI” button
* allows the user to infer a DSL rule from the source pattern using AI.</p>
*
* @since 1.5.0
*/
public class NewRuleWizardPage extends WizardPage {
private Text sourcePatternText;
private Text guardText;
private Text replacementText;
private StyledText previewText;
private Label validationLabel;
private Button generateAiButton;
private final DslValidator validator = new DslValidator();
private boolean customContentEntered;
private String initialSourcePattern;
private boolean autoTriggerAi;
protected NewRuleWizardPage() {
super("newRuleWizardPage"); //$NON-NLS-1$
setTitle("New Sandbox Hint File \u2014 Rule"); //$NON-NLS-1$
setDescription("Define a transformation rule with source pattern, guard, and replacement"); //$NON-NLS-1$
}
/**
* Pre-fills the source pattern field when the wizard is opened from a
* code selection. Must be called before the page is created.
*
* @param code the code snippet to use as initial source pattern
*/
public void setInitialSourcePattern(String code) {
this.initialSourcePattern = code;
}
/**
* Enables automatic AI inference when the page becomes visible.
* Must be called before the page is created.
*
* @param auto {@code true} to auto-trigger AI inference
*/
public void setAutoTriggerAi(boolean auto) {
this.autoTriggerAi = auto;
}
@Override
public void createControl(Composite parent) {
Composite container = new Composite(parent, SWT.NONE);
container.setLayout(new GridLayout(2, false));
ModifyListener modifyListener = e -> {
customContentEntered = true;
updatePreview();
};
// --- Source pattern ---
new Label(container, SWT.NONE).setText("Source &Pattern:"); //$NON-NLS-1$
sourcePatternText = new Text(container, SWT.BORDER | SWT.SINGLE);
sourcePatternText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
sourcePatternText.setMessage("$x.getBytes(\"UTF-8\")"); //$NON-NLS-1$
sourcePatternText.addModifyListener(modifyListener);
// --- Guard ---
new Label(container, SWT.NONE).setText("&Guard (optional):"); //$NON-NLS-1$
guardText = new Text(container, SWT.BORDER | SWT.SINGLE);
guardText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
guardText.setMessage("sourceVersionGE(11)"); //$NON-NLS-1$
guardText.addModifyListener(modifyListener);
// --- Replacement ---
new Label(container, SWT.NONE).setText("&Replacement:"); //$NON-NLS-1$
replacementText = new Text(container, SWT.BORDER | SWT.SINGLE);
replacementText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
replacementText.setMessage("$x.getBytes(java.nio.charset.StandardCharsets.UTF_8)"); //$NON-NLS-1$
replacementText.addModifyListener(modifyListener);
// --- AI generation button ---
createAiSection(container);
// --- Preview ---
Group previewGroup = new Group(container, SWT.NONE);
previewGroup.setText("Preview"); //$NON-NLS-1$
previewGroup.setLayout(new GridLayout(1, false));
GridData previewGroupData = new GridData(SWT.FILL, SWT.FILL, true, true, 2, 1);
previewGroup.setLayoutData(previewGroupData);
previewText = new StyledText(previewGroup, SWT.BORDER | SWT.MULTI | SWT.READ_ONLY | SWT.V_SCROLL);
GridData previewData = new GridData(SWT.FILL, SWT.FILL, true, true);
previewData.heightHint = 120;
previewText.setLayoutData(previewData);
// --- Validation label ---
validationLabel = new Label(container, SWT.NONE);
GridData valData = new GridData(SWT.FILL, SWT.CENTER, true, false, 2, 1);
validationLabel.setLayoutData(valData);
// Pre-fill from code selection if available
if (initialSourcePattern != null && !initialSourcePattern.isBlank()) {
sourcePatternText.setText(initialSourcePattern);
}
setControl(container);
setPageComplete(true);
// Auto-trigger AI inference after the page is fully rendered
if (autoTriggerAi && EclipseLlmService.getInstance().isAvailable()
&& generateAiButton != null) {
Display.getCurrent().asyncExec(this::runAiInference);
}
}
/**
* Creates the AI / manual-assist section. Shows different UI depending on
* whether the LLM service is configured.
*/
private void createAiSection(Composite parent) {
// Span 2 columns for the AI/manual section
Composite aiComposite = new Composite(parent, SWT.NONE);
aiComposite.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 2, 1));
aiComposite.setLayout(new GridLayout(3, false));
boolean aiAvailable = EclipseLlmService.getInstance().isAvailable();
if (aiAvailable) {
// === AI available: Generate button + provider info ===
generateAiButton = new Button(aiComposite, SWT.PUSH);
generateAiButton.setText("\u2728 Generate with AI"); //$NON-NLS-1$
generateAiButton.addSelectionListener(new SelectionAdapter() {
@Override
public void widgetSelected(SelectionEvent e) {
runAiInference();
}
});
Label providerInfo = new Label(aiComposite, SWT.NONE);
providerInfo.setText(getConfiguredProviderLabel());
providerInfo.setForeground(parent.getDisplay().getSystemColor(SWT.COLOR_DARK_GRAY));
Button pasteButton = new Button(aiComposite, SWT.PUSH);
pasteButton.setText("\u2398 Paste rule from clipboard"); //$NON-NLS-1$
pasteButton.addSelectionListener(new SelectionAdapter() {
@Override
public void widgetSelected(SelectionEvent e) {
pasteRuleFromClipboard();
}
});
} else {
// === AI NOT available: helpful guidance ===
Label infoIcon = new Label(aiComposite, SWT.NONE);
infoIcon.setText("\uD83D\uDCA1"); // 💡
Label infoLabel = new Label(aiComposite, SWT.WRAP);
infoLabel.setText("AI generation not configured. " //$NON-NLS-1$
+ "You can create rules manually below, " //$NON-NLS-1$
+ "use a template from page 1, or paste an existing rule."); //$NON-NLS-1$
infoLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
Composite buttonBar = new Composite(aiComposite, SWT.NONE);
buttonBar.setLayout(new RowLayout(SWT.HORIZONTAL));
Button configureButton = new Button(buttonBar, SWT.PUSH);
configureButton.setText("\u2699 Configure LLM..."); //$NON-NLS-1$
configureButton.addSelectionListener(new SelectionAdapter() {
@Override
public void widgetSelected(SelectionEvent e) {
openLlmPreferences();
}
});
Button pasteButton = new Button(buttonBar, SWT.PUSH);
pasteButton.setText("\u2398 Paste rule from clipboard"); //$NON-NLS-1$
pasteButton.addSelectionListener(new SelectionAdapter() {
@Override
public void widgetSelected(SelectionEvent e) {
pasteRuleFromClipboard();
}
});
}
}
/**
* Runs AI inference in a background job to avoid blocking the UI thread.
*/
private void runAiInference() {
String code = sourcePatternText.getText().trim();
if (code.isEmpty()) {
return;
}
generateAiButton.setEnabled(false);
validationLabel.setText("\u23F3 Generating rule with AI..."); //$NON-NLS-1$
Job job = new Job("Generating DSL rule with AI") { //$NON-NLS-1$
@Override
protected IStatus run(IProgressMonitor monitor) {
AiRuleInferenceEngine engine = EclipseLlmService.getInstance().getEngine();
String pseudoDiff = buildPseudoDiff(code);
Optional<CommitEvaluation> result = engine.inferRuleFromDiff(pseudoDiff);
Display.getDefault().asyncExec(() -> {
if (generateAiButton.isDisposed()) {
return;
}
generateAiButton.setEnabled(true);
if (result.isPresent() && result.get().dslRule() != null
&& !result.get().dslRule().isBlank()) {
fillFieldsFromInferredRule(result.get().dslRule());
} else {
validationLabel.setText("\u26A0 AI could not generate a rule"); //$NON-NLS-1$
}
});
return Status.OK_STATUS;
}
};
job.setUser(true);
job.schedule();
}
/**
* Builds an additions-only pseudo-diff from a code snippet for the AI engine.
* Aligns with the format used in {@code DslFromSelectionAssistProcessor}.
*/
private static String buildPseudoDiff(String code) {
StringBuilder sb = new StringBuilder();
sb.append("--- a/snippet.java\n"); //$NON-NLS-1$
sb.append("+++ b/snippet.java\n"); //$NON-NLS-1$
String[] lines = code.split("\n", -1); //$NON-NLS-1$
sb.append("@@ -1,0 +1,").append(lines.length).append(" @@\n"); //$NON-NLS-1$ //$NON-NLS-2$
for (String line : lines) {
sb.append('+').append(line).append('\n');
}
return sb.toString();
}
/**
* Parses the AI-generated DSL rule and fills the source/guard/replacement
* fields. Falls back to putting the entire rule in the source field.
*/
private void fillFieldsFromInferredRule(String dslRule) {
// Try to extract source, guard, and replacement from the rule text
String trimmed = dslRule.trim();
if (trimmed.endsWith(";;")) { //$NON-NLS-1$
trimmed = trimmed.substring(0, trimmed.length() - 2).trim();
}
// Remove metadata directives (lines starting with <!)
StringBuilder ruleBody = new StringBuilder();
for (String line : trimmed.split("\n")) { //$NON-NLS-1$
String stripped = line.trim();
if (!stripped.startsWith("<!") && !stripped.isEmpty()) { //$NON-NLS-1$
if (ruleBody.length() > 0) {
ruleBody.append('\n');
}
ruleBody.append(stripped);
}
}
String body = ruleBody.toString();
String[] parts = body.split("\n"); //$NON-NLS-1$
if (parts.length >= 1) {
// First line: source pattern [:: guard]
String sourceLine = parts[0].trim();
int guardIdx = sourceLine.indexOf(" :: "); //$NON-NLS-1$
if (guardIdx >= 0) {
sourcePatternText.setText(sourceLine.substring(0, guardIdx).trim());
guardText.setText(sourceLine.substring(guardIdx + 4).trim());
} else {
sourcePatternText.setText(sourceLine);
}
// Subsequent lines starting with "=>": replacement
for (int i = 1; i < parts.length; i++) {
String line = parts[i].trim();
if (line.startsWith("=> ")) { //$NON-NLS-1$
String replacement = line.substring(3).trim();
// Strip guard from replacement line
int repGuardIdx = replacement.indexOf(" :: "); //$NON-NLS-1$
if (repGuardIdx >= 0) {
replacement = replacement.substring(0, repGuardIdx).trim();
}
replacementText.setText(replacement);
break;
}
}
}
customContentEntered = true;
updatePreview();
}
/**
* Rebuilds the preview and validates the DSL snippet.
* Updates the page completion state based on validation result.
*/
private void updatePreview() {
String source = sourcePatternText.getText().trim();
if (source.isEmpty()) {
previewText.setText(""); //$NON-NLS-1$
validationLabel.setText(""); //$NON-NLS-1$
setErrorMessage(null);
setPageComplete(true);
return;
}
String preview = buildRuleBlock(source, guardText.getText().trim(),
replacementText.getText().trim());
previewText.setText(preview);
// Wrap the rule in minimal metadata so the parser is happy
String fullDsl = buildFullDslForValidation(preview);
ValidationResult result = validator.validate(fullDsl);
if (result.valid()) {
validationLabel.setText("\u2705 Valid DSL rule"); //$NON-NLS-1$
setErrorMessage(null);
setPageComplete(true);
} else {
validationLabel.setText("\u26A0 " + result.message()); //$NON-NLS-1$
setErrorMessage(result.message());
setPageComplete(false);
}
}
/**
* Builds the DSL text for a single rule.
*/
private static String buildRuleBlock(String source, String guard, String replacement) {
StringBuilder sb = new StringBuilder();
sb.append(source);
if (!guard.isEmpty()) {
sb.append(" :: ").append(guard); //$NON-NLS-1$
}
sb.append('\n');
if (!replacement.isEmpty()) {
sb.append("=> ").append(replacement).append('\n'); //$NON-NLS-1$
}
sb.append(";;"); //$NON-NLS-1$
return sb.toString();
}
/**
* Wraps a rule block with minimal metadata so the parser can validate it.
*/
private static String buildFullDslForValidation(String ruleBlock) {
return "<!id: _wizard_preview>\n" + ruleBlock + "\n"; //$NON-NLS-1$ //$NON-NLS-2$
}
// --- Helper methods for AI section ---
/**
* Returns a label like "Gemini – gemini-2.0-flash" from the current preferences.
*/
private static String getConfiguredProviderLabel() {
IEclipsePreferences prefs = InstanceScope.INSTANCE.getNode(LlmPreferencePage.PLUGIN_ID);
String provider = prefs.get(LlmPreferencePage.PREF_PROVIDER, ""); //$NON-NLS-1$
String model = prefs.get(LlmPreferencePage.PREF_MODEL_NAME, ""); //$NON-NLS-1$
if (provider.isBlank()) {
return ""; //$NON-NLS-1$
}
return model.isBlank() ? provider : provider + " \u2013 " + model; //$NON-NLS-1$
}
/**
* Opens the LLM preference page directly, then re-checks AI availability.
*/
private void openLlmPreferences() {
PreferencesUtil.createPreferenceDialogOn(
getShell(),
"org.sandbox.jdt.triggerpattern.preferences.LlmPreferencePage", //$NON-NLS-1$
null, null).open();
refreshAiAvailability();
}
/**
* Re-evaluates AI availability after preferences might have changed.
*/
private void refreshAiAvailability() {
boolean nowAvailable = EclipseLlmService.getInstance().isAvailable();
if (nowAvailable && generateAiButton == null) {
validationLabel.setText("\u2705 AI is now configured \u2014 reopen the wizard to use 'Generate with AI'"); //$NON-NLS-1$
}
}
/**
* Pastes a DSL rule from the system clipboard and fills the fields.
*/
private void pasteRuleFromClipboard() {
Clipboard clipboard = new Clipboard(Display.getCurrent());
try {
String text = (String) clipboard.getContents(TextTransfer.getInstance());
if (text != null && !text.isBlank()) {
fillFieldsFromInferredRule(text);
} else {
validationLabel.setText("\u26A0 Clipboard is empty or contains no text"); //$NON-NLS-1$
}
} finally {
clipboard.dispose();
}
}
// --- Public API for the wizard ---
/**
* Returns {@code true} when the user entered any content on this page.
*/
public boolean hasCustomContent() {
return customContentEntered && !sourcePatternText.getText().trim().isEmpty();
}
/**
* Returns the complete rule block including source, guard, and replacement.
*/
public String getFullRuleBlock() {
return "\n" + buildRuleBlock( //$NON-NLS-1$
sourcePatternText.getText().trim(),
guardText.getText().trim(),
replacementText.getText().trim()) + "\n"; //$NON-NLS-1$
}
}