ProjectRuleRefactoring.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer and others.
* This program and the accompanying materials are made available under the terms
* of the Eclipse Public License 2.0, https://www.eclipse.org/legal/epl-2.0/
* SPDX-License-Identifier: EPL-2.0
*******************************************************************************/
package org.sandbox.jdt.internal.ui.wizard;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.IOException;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.SubMonitor;
import org.eclipse.core.runtime.Status;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IClasspathEntry;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IPackageFragment;
import org.eclipse.jdt.core.IPackageFragmentRoot;
import org.eclipse.jdt.core.WorkingCopyOwner;
import org.eclipse.jdt.core.compiler.IProblem;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.refactoring.CompilationUnitChange;
import org.eclipse.jdt.internal.corext.fix.CompilationUnitRewriteOperationsFixCore.CompilationUnitRewriteOperation;
import org.eclipse.ltk.core.refactoring.Change;
import org.eclipse.ltk.core.refactoring.CompositeChange;
import org.eclipse.ltk.core.refactoring.Refactoring;
import org.eclipse.ltk.core.refactoring.RefactoringStatus;
import org.eclipse.text.edits.TextEditGroup;
import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor;
import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor.TransformationResult;
import org.sandbox.jdt.triggerpattern.api.HintFile;
import org.sandbox.jdt.triggerpattern.api.HintBindingPolicy;
import org.sandbox.jdt.triggerpattern.api.RuleMatchDiagnostics;
import org.sandbox.jdt.triggerpattern.api.SemanticRewritePlan;
import org.sandbox.jdt.triggerpattern.api.SemanticRewritePlanContext;
import org.sandbox.jdt.triggerpattern.cleanup.HintFileFixCore;
import org.sandbox.jdt.triggerpattern.cleanup.NlsAwareCleanUpFix;
import org.sandbox.jdt.triggerpattern.internal.GuardRegistry;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser.HintParseException;
/** Prepares an unsaved rule with the ordinary cleanup backend and project context. */
final class ProjectRuleRefactoring extends Refactoring {
private static final String NAME= "Try TriggerPattern on my project"; //$NON-NLS-1$
private static final int MAX_DIAGNOSTICS= 200;
private final IJavaProject project;
private final String dsl;
private final Map<ICompilationUnit, String> snapshots= new LinkedHashMap<>();
private final Set<ICompilationUnit> compilableUnits= new LinkedHashSet<>();
private final StringBuilder explanation= new StringBuilder();
private final List<CompilationUnitChange> preparedChanges= new ArrayList<>();
private RefactoringStatus preparationStatus;
private Map<String, String> optionsSnapshot;
private List<IClasspathEntry> classpathSnapshot;
private Map<String, String> contextSnapshot;
private boolean prepared;
ProjectRuleRefactoring(IJavaProject project, String dsl) {
this.project= project;
this.dsl= dsl;
}
@Override
public String getName() {
return NAME;
}
String getExplanation() {
return explanation.toString();
}
@Override
public RefactoringStatus checkInitialConditions(IProgressMonitor monitor) throws CoreException {
if (prepared) {
return preparationStatus;
}
try {
RefactoringStatus status= prepare(monitor);
prepared= true;
return status;
} catch (CoreException | RuntimeException exception) {
preparedChanges.forEach(Change::dispose);
preparedChanges.clear();
snapshots.clear();
compilableUnits.clear();
explanation.setLength(0);
preparationStatus= null;
optionsSnapshot= null;
classpathSnapshot= null;
contextSnapshot= null;
throw exception;
}
}
private RefactoringStatus prepare(IProgressMonitor monitor) throws CoreException {
SubMonitor progress= SubMonitor.convert(monitor, NAME, 100);
progress.checkCanceled();
preparationStatus= new RefactoringStatus();
if (project == null || !project.exists() || !project.getProject().isOpen()) {
preparationStatus.addFatalError("Select an open Java project."); //$NON-NLS-1$
return preparationStatus;
}
HintFile hint;
try {
hint= new HintFileParser().parse(dsl);
} catch (HintParseException exception) {
preparationStatus.addFatalError(exception.getMessage());
return preparationStatus;
}
// A trial can apply changes, so compatibility fallbacks cannot authorize it.
hint.setBindingPolicy(HintBindingPolicy.REQUIRED);
if (hint.getRules().isEmpty() || !hint.getIncludes().isEmpty()
|| !hint.getEmbeddedJavaBlocks().isEmpty()
|| hint.getRules().stream().flatMap(rule -> rule.alternatives().stream())
.anyMatch(alternative -> !alternative.structuredActions().isEmpty()
|| alternative.hasTextReplacement()
&& alternative.replacementPattern().contains("<?"))) { //$NON-NLS-1$
preparationStatus.addFatalError("This trial supports self-contained text rules. " //$NON-NLS-1$
+ "Resolve includes and use the coordinated cleanup for semantic plans or embedded Java."); //$NON-NLS-1$
return preparationStatus;
}
GuardRegistry.getInstance();
List<ICompilationUnit> units= new ArrayList<>();
for (IPackageFragmentRoot root : project.getPackageFragmentRoots()) {
progress.checkCanceled();
if (root.getKind() == IPackageFragmentRoot.K_SOURCE && root.getJavaProject().equals(project)) {
for (var child : root.getChildren()) {
if (child instanceof IPackageFragment fragment) {
units.addAll(List.of(fragment.getCompilationUnits()));
}
}
}
}
Map<String, String> options= project.getOptions(true);
optionsSnapshot= Map.copyOf(options);
classpathSnapshot= List.of(project.getResolvedClasspath(true));
contextSnapshot= contextFingerprint(progress);
int diagnosticCount= 0;
Map<ICompilationUnit, String> proposedSources= new LinkedHashMap<>();
SubMonitor files= progress.split(95).setWorkRemaining(units.size());
try (var scope= SemanticRewritePlanContext.install(SemanticRewritePlan.empty(), options, files::checkCanceled)) {
for (ICompilationUnit unit : units) {
files.checkCanceled();
files.subTask(unit.getElementName());
String before= unit.getSource();
snapshots.put(unit, before);
CompilationUnit ast= parse(unit, null, files.split(1));
if (hasErrors(ast)) {
preparationStatus.addWarning("Skipped file with compiler errors: " + unit.getPath()); //$NON-NLS-1$
continue;
}
compilableUnits.add(unit);
if (diagnosticCount < MAX_DIAGNOSTICS) {
var candidates= new RuleMatchDiagnostics(hint).explain(ast, before, -1, 0,
Math.min(30, MAX_DIAGNOSTICS - diagnosticCount), options, SemanticRewritePlan.empty());
for (var candidate : candidates) {
explanation.append(unit.getPath()).append('\n').append(candidate.describe()).append("\n\n"); //$NON-NLS-1$
}
diagnosticCount+= candidates.size();
}
List<TransformationResult> results= new BatchTransformationProcessor(hint)
.process(ast, options).stream().filter(TransformationResult::hasReplacement).toList();
if (overlap(results)) {
preparationStatus.addFatalError("Overlapping rule changes in " + unit.getPath() //$NON-NLS-1$
+ ". Narrow the rules and try again."); //$NON-NLS-1$
return preparationStatus;
}
Set<CompilationUnitRewriteOperation> operations= new LinkedHashSet<>();
HintFileFixCore.findOperationsFromHint(ast, hint, operations);
if (operations.isEmpty()) {
if (!results.isEmpty()) {
explanation.append(unit.getPath()).append(": backend withheld unsupported text or comments.\n"); //$NON-NLS-1$
}
continue;
}
CompilationUnitChange generated= new NlsAwareCleanUpFix(NAME, ast,
operations.toArray(CompilationUnitRewriteOperation[]::new)).createOptionalChange(files);
if (generated == null) {
explanation.append(unit.getPath()).append(": the rule is already applied or the backend withheld this change.\n"); //$NON-NLS-1$
continue;
}
String after= generated.getPreviewContent(files);
if (before.equals(after)) {
explanation.append(unit.getPath()).append(": backend withheld the replacement; " //$NON-NLS-1$
+ "unsupported syntax or comments were preserved without modification.\n"); //$NON-NLS-1$
generated.dispose();
continue;
}
// Type-check after all proposed sources are installed in the private overlay:
// a related rule may change the API that this replacement references.
// One selection per file keeps imports and exception follow-up edits together.
CompilationUnitChange coherent= new CompilationUnitChange(unit.getElementName(), unit);
coherent.setEdit(generated.getEdit().copy());
coherent.addTextEditGroup(new TextEditGroup("Complete rule changes in this file", coherent.getEdit())); //$NON-NLS-1$
preparedChanges.add(coherent);
proposedSources.put(unit, after);
generated.dispose();
}
}
if (!proposedSources.isEmpty()) {
preparationStatus.merge(validateProposedProject(proposedSources, progress.split(5)));
}
explanation.insert(0, "Language checked; project bindings and replacement compiler diagnostics checked. " //$NON-NLS-1$
+ "Behavioral equivalence and examples still require review.\n" //$NON-NLS-1$
+ "Each file is selected as a complete change, including imports and follow-up edits.\n\n"); //$NON-NLS-1$
if (diagnosticCount >= MAX_DIAGNOSTICS) {
explanation.append("Further diagnostics omitted; the diff includes all prepared changes.\n"); //$NON-NLS-1$
}
if (preparedChanges.isEmpty()) {
preparationStatus.addWarning("No applicable changes. Inspect the candidate explanations."); //$NON-NLS-1$
}
return preparationStatus;
}
/** Resolves unchanged callers against all proposed files, using private unsaved copies. */
private RefactoringStatus validateProposedProject(Map<ICompilationUnit, String> proposedSources,
IProgressMonitor monitor) throws CoreException {
RefactoringStatus status= new RefactoringStatus();
WorkingCopyOwner owner= new WorkingCopyOwner() { };
Map<ICompilationUnit, ICompilationUnit> copies= new LinkedHashMap<>();
SubMonitor progress= SubMonitor.convert(monitor, snapshots.size() * 2);
try {
for (var entry : snapshots.entrySet()) {
progress.checkCanceled();
ICompilationUnit copy= entry.getKey().getWorkingCopy(owner, progress.split(1));
copies.put(entry.getKey(), copy);
copy.getBuffer().setContents(proposedSources.getOrDefault(entry.getKey(), entry.getValue()));
}
for (var entry : copies.entrySet()) {
progress.checkCanceled();
if (compilableUnits.contains(entry.getKey())
&& hasErrors(parse(entry.getValue(), null, progress.split(1), owner))) {
status.addFatalError("The selected changes break compilation in " + entry.getKey().getPath() //$NON-NLS-1$
+ ". Keep dependent changes together."); //$NON-NLS-1$
return status;
}
}
} finally {
CoreException cleanupFailure= null;
for (ICompilationUnit copy : copies.values()) {
try {
copy.discardWorkingCopy();
} catch (CoreException exception) {
if (cleanupFailure == null) {
cleanupFailure= exception;
} else {
cleanupFailure.addSuppressed(exception);
}
}
}
if (cleanupFailure != null) {
throw cleanupFailure;
}
}
return status;
}
/** Revalidates the actual LTK selection, including disabled complete-file edit groups. */
private RefactoringStatus validateSelection(IProgressMonitor monitor) throws CoreException {
Map<ICompilationUnit, String> selected= new LinkedHashMap<>();
for (CompilationUnitChange change : preparedChanges) {
if (change.isEnabled() && List.of(change.getTextEditChangeGroups()).stream().anyMatch(group -> group.isEnabled())) {
selected.put(change.getCompilationUnit(), change.getPreviewContent(monitor));
}
}
return selected.isEmpty() ? new RefactoringStatus() : validateProposedProject(selected, monitor);
}
@Override
public RefactoringStatus checkFinalConditions(IProgressMonitor monitor) throws CoreException {
RefactoringStatus status= checkInitialConditions(monitor);
if (optionsSnapshot != null && (!optionsSnapshot.equals(project.getOptions(true))
|| !classpathSnapshot.equals(List.of(project.getResolvedClasspath(true)))
|| !contextSnapshot.equals(contextFingerprint(monitor)))) {
status.addFatalError("The project's compiler options or classpath changed. Run the trial again."); //$NON-NLS-1$
}
for (var entry : snapshots.entrySet()) {
if (!entry.getKey().exists() || !entry.getValue().equals(entry.getKey().getSource())) {
status.addFatalError("Source changed after preview: " + entry.getKey().getPath() //$NON-NLS-1$
+ ". Run the project trial again."); //$NON-NLS-1$
}
}
return status;
}
/** Includes new/deleted dependency sources and replacement jars at the same path. */
private Map<String, String> contextFingerprint(IProgressMonitor monitor) throws CoreException {
SubMonitor progress= SubMonitor.convert(monitor);
Map<String, String> state= new LinkedHashMap<>();
for (IPackageFragmentRoot root : project.getAllPackageFragmentRoots()) {
progress.checkCanceled();
IResource resource= root.getResource();
if (resource != null && resource.exists()) {
resource.accept(child -> {
progress.checkCanceled();
state.put(child.getFullPath().toString(), Long.toString(child.getModificationStamp()));
return true;
});
} else if (root.isExternal()) {
Path path= root.getPath().toFile().toPath();
try {
if (Files.isRegularFile(path)) {
state.put(path.toString(), Files.getLastModifiedTime(path) + ":" + Files.size(path)); //$NON-NLS-1$
} else if (Files.isDirectory(path)) {
try (var files= Files.walk(path)) {
var iterator= files.filter(Files::isRegularFile).iterator();
while (iterator.hasNext()) {
progress.checkCanceled();
Path entry= iterator.next();
state.put(entry.toString(), Files.getLastModifiedTime(entry) + ":" + Files.size(entry)); //$NON-NLS-1$
}
}
}
} catch (IOException exception) {
throw new CoreException(Status.error("Cannot snapshot dependency " + path, exception)); //$NON-NLS-1$
}
}
if (root.getKind() == IPackageFragmentRoot.K_SOURCE) {
for (var child : root.getChildren()) {
if (child instanceof IPackageFragment fragment) {
for (ICompilationUnit unit : fragment.getCompilationUnits()) {
progress.checkCanceled();
state.put("source:" + unit.getHandleIdentifier(), sourceFingerprint(unit.getSource())); //$NON-NLS-1$
}
}
}
}
}
return Map.copyOf(state);
}
private static String sourceFingerprint(String source) {
try {
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") //$NON-NLS-1$
.digest(source.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
@Override
public Change createChange(IProgressMonitor monitor) throws CoreException {
if (checkFinalConditions(monitor).hasFatalError()) {
throw new CoreException(Status.error("The rule trial is invalid. Rebuild the preview.")); //$NON-NLS-1$
}
return new CompositeChange(NAME, preparedChanges.toArray(Change[]::new)) {
@Override
public RefactoringStatus isValid(IProgressMonitor progress) throws CoreException {
RefactoringStatus status= super.isValid(progress);
status.merge(checkFinalConditions(progress));
if (!status.hasFatalError()) {
status.merge(validateSelection(progress));
}
return status;
}
@Override
public Change perform(IProgressMonitor progress) throws CoreException {
if (checkFinalConditions(progress).hasFatalError()) {
throw new CoreException(Status.error("Source changed after the project trial.")); //$NON-NLS-1$
}
RefactoringStatus selection= validateSelection(progress);
if (selection.hasFatalError()) {
throw new CoreException(Status.error(selection.getMessageMatchingSeverity(RefactoringStatus.FATAL)));
}
return super.perform(progress);
}
};
}
private CompilationUnit parse(ICompilationUnit unit, String changedSource, IProgressMonitor monitor) {
return parse(unit, changedSource, monitor, null);
}
private CompilationUnit parse(ICompilationUnit unit, String changedSource, IProgressMonitor monitor, WorkingCopyOwner owner) {
ASTParser parser= ASTParser.newParser(AST.getJLSLatest());
if (changedSource == null) {
parser.setSource(unit);
} else {
parser.setSource(changedSource.toCharArray());
parser.setProject(project);
parser.setUnitName(unit.getPath().toString());
}
parser.setCompilerOptions(project.getOptions(true));
if (owner != null) {
parser.setWorkingCopyOwner(owner);
}
parser.setResolveBindings(true);
parser.setBindingsRecovery(false);
return (CompilationUnit) parser.createAST(monitor);
}
private static boolean hasErrors(CompilationUnit root) {
for (IProblem problem : root.getProblems()) {
if (problem.isError()) {
return true;
}
}
return false;
}
private static boolean overlap(List<TransformationResult> results) {
var ordered= results.stream().sorted(Comparator.comparingInt(result -> result.match().getOffset())).toList();
int end= -1;
for (TransformationResult result : ordered) {
if (result.match().getOffset() < end) {
return true;
}
end= result.match().getOffset() + result.match().getLength();
}
return false;
}
}