HintFileFixCore.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.triggerpattern.cleanup;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.CatchClause;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.IVariableBinding;
import org.eclipse.jdt.core.dom.MarkerAnnotation;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.StringLiteral;
import org.eclipse.jdt.core.dom.TryStatement;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
import org.eclipse.jdt.core.dom.rewrite.ListRewrite;
import org.eclipse.jdt.internal.corext.dom.ASTNodes;
import org.eclipse.jdt.internal.corext.fix.CompilationUnitRewriteOperationsFixCore.CompilationUnitRewriteOperation;
import org.eclipse.jdt.internal.corext.refactoring.structure.ImportRemover;
import org.eclipse.jdt.internal.corext.fix.LinkedProposalModelCore;
import org.eclipse.jdt.internal.corext.refactoring.structure.CompilationUnitRewrite;
import org.eclipse.text.edits.TextEditGroup;
import org.sandbox.jdt.internal.corext.util.TypeWideningAnalyzer;
import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor;
import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor.TransformationResult;
import org.sandbox.jdt.triggerpattern.api.EmbeddedJavaBlock;
import org.sandbox.jdt.triggerpattern.api.HintFile;
import org.sandbox.jdt.triggerpattern.api.ImportDirective;
import org.sandbox.jdt.triggerpattern.api.TransformationRule;
import org.sandbox.jdt.triggerpattern.eclipse.HintFinding;
import org.sandbox.jdt.triggerpattern.internal.EmbeddedFixExecutor;
import org.sandbox.jdt.triggerpattern.internal.EmbeddedGuardRegistrar;
import org.sandbox.jdt.triggerpattern.internal.EmbeddedJavaCompiler;
import org.sandbox.jdt.triggerpattern.internal.EmbeddedJavaCompiler.CompilationResult;
import org.sandbox.jdt.triggerpattern.internal.GuardRegistry;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser;
import org.sandbox.jdt.triggerpattern.internal.HintFileRegistry;
/**
* Fix core that creates rewrite operations from {@code .sandbox-hint} files.
*
* <p>This bridges the gap between the {@code .sandbox-hint} DSL and the Eclipse
* CleanUp framework by using {@link BatchTransformationProcessor} to find matches
* and creating {@link CompilationUnitRewriteOperation} instances for each match
* that has a replacement.</p>
*
* @since 1.3.5
*/
public class HintFileFixCore {
/**
* Finds all hint-file-based cleanup operations for the given compilation unit.
*
* <p>Loads all registered {@code .sandbox-hint} files from the
* {@link HintFileRegistry}, processes them with {@link BatchTransformationProcessor},
* and creates rewrite operations for each match with a replacement.
* Hint-only results (rules without replacement) are silently discarded.
* Use the four-parameter overload to collect hint-only findings.</p>
*
* @param compilationUnit the compilation unit to search
* @param operations the set to add found operations to
* @param enabledBundles set of enabled bundled hint file IDs; project-level
* hint files (with {@code "project:"} prefix) are always included
* regardless of this parameter
*/
public static void findOperations(CompilationUnit compilationUnit,
Set<CompilationUnitRewriteOperation> operations,
Set<String> enabledBundles) {
findOperations(compilationUnit, operations, enabledBundles, null);
}
/**
* Finds all hint-file-based cleanup operations for the given compilation unit,
* collecting hint-only results into the provided findings list.
*
* <p>Rules with a replacement produce rewrite operations. Rules without a
* replacement (hint-only) produce {@link HintFinding} entries that can be
* reported as problem markers.</p>
*
* @param compilationUnit the compilation unit to search
* @param operations the set to add found rewrite operations to
* @param enabledBundles set of enabled bundled hint file IDs
* @param findings list to collect hint-only findings into; may be {@code null}
* to discard hint-only results (backward compatible)
*/
public static void findOperations(CompilationUnit compilationUnit,
Set<CompilationUnitRewriteOperation> operations,
Set<String> enabledBundles,
List<HintFinding> findings) {
// Ensure built-in guard functions (sourceVersionGE, etc.) are registered
GuardRegistry.getInstance();
HintFileRegistry registry = HintFileRegistry.getInstance();
// Ensure bundled libraries are loaded
registry.loadBundledLibraries(HintFileFixCore.class.getClassLoader());
// Load extension-point contributed hint files (encoding, junit5, etc.)
registry.loadFromExtensions();
// Load project-level .sandbox-hint files if available
if (compilationUnit.getJavaElement() != null
&& compilationUnit.getJavaElement().getJavaProject() != null) {
org.eclipse.core.resources.IProject project = compilationUnit.getJavaElement()
.getJavaProject().getProject();
registry.loadProjectHintFiles(project);
}
for (Map.Entry<String, HintFile> entry : registry.getAllHintFiles().entrySet()) {
String hintFileId = entry.getKey();
HintFile hintFile = entry.getValue();
// Filter by enabled bundles (project-level files are always included)
if (enabledBundles != null && !hintFileId.startsWith("project:") //$NON-NLS-1$
&& !enabledBundles.contains(hintFileId)) {
continue;
}
// Register guard and fix functions from <? ?> blocks
registerEmbeddedFunctions(hintFile, hintFileId);
List<TransformationRule> resolvedRules = registry.resolveIncludes(hintFile);
BatchTransformationProcessor processor = new BatchTransformationProcessor(hintFile, resolvedRules);
List<TransformationResult> results = processor.process(compilationUnit);
for (TransformationResult result : results) {
if (result.hasReplacement()) {
if (isNonWidenableDeclaration(result, compilationUnit)) {
continue;
}
operations.add(new HintFileRewriteOperation(result));
} else if (findings != null) {
findings.add(HintFinding.fromTransformationResult(result, compilationUnit));
}
}
}
}
/**
* Finds hint-file-based cleanup operations for a specific bundle,
* tracking processed nodes to avoid duplicate processing with imperative helpers.
*
* <p>This method loads a single hint file bundle by ID from the
* {@link HintFileRegistry} and creates rewrite operations for matches.
* Matched nodes are added to {@code nodesprocessed} so that imperative
* helpers can skip them.</p>
*
* @param compilationUnit the compilation unit to search
* @param bundleId the hint file bundle ID to load (e.g., {@code "encoding"})
* @param operations the set to add found operations to
* @param nodesprocessed set of already-processed AST nodes; matched nodes
* are added to this set to prevent double-processing
* @since 1.3.7
*/
public static void findOperationsForBundle(CompilationUnit compilationUnit,
String bundleId, Set<CompilationUnitRewriteOperation> operations,
Set<ASTNode> nodesprocessed) {
findOperationsForBundle(compilationUnit, bundleId, operations, nodesprocessed, null);
}
/**
* Finds hint-file-based cleanup operations for a specific bundle,
* tracking processed nodes and passing compiler options for guard evaluation.
*
* <p>This overload accepts compiler options that are passed to guard
* evaluation. This enables mode-dependent DSL rules via the
* {@code sandbox.cleanup.mode} option.</p>
*
* @param compilationUnit the compilation unit to search
* @param bundleId the hint file bundle ID to load (e.g., {@code "encoding"})
* @param operations the set to add found operations to
* @param nodesprocessed set of already-processed AST nodes; matched nodes
* are added to this set to prevent double-processing
* @param compilerOptions compiler options for guard context; may contain
* {@code sandbox.cleanup.mode} for mode-dependent rules (may be {@code null})
* @since 1.3.8
*/
public static void findOperationsForBundle(CompilationUnit compilationUnit,
String bundleId, Set<CompilationUnitRewriteOperation> operations,
Set<ASTNode> nodesprocessed, Map<String, String> compilerOptions) {
// Ensure built-in guard functions (sourceVersionGE, etc.) are registered
GuardRegistry.getInstance();
HintFileRegistry registry = HintFileRegistry.getInstance();
// Ensure bundled libraries are loaded
registry.loadBundledLibraries(HintFileFixCore.class.getClassLoader());
// Load extension-point contributed hint files (encoding, junit5, etc.)
registry.loadFromExtensions();
// Load project-level .sandbox-hint files if available
if (compilationUnit.getJavaElement() != null
&& compilationUnit.getJavaElement().getJavaProject() != null) {
org.eclipse.core.resources.IProject project = compilationUnit.getJavaElement()
.getJavaProject().getProject();
registry.loadProjectHintFiles(project);
}
HintFile hintFile = registry.getHintFile(bundleId);
if (hintFile == null) {
return;
}
// Register guard and fix functions from <? ?> blocks
registerEmbeddedFunctions(hintFile, bundleId);
List<TransformationRule> resolvedRules = registry.resolveIncludes(hintFile);
BatchTransformationProcessor processor = new BatchTransformationProcessor(hintFile, resolvedRules);
List<TransformationResult> results = processor.process(compilationUnit, compilerOptions);
for (TransformationResult result : results) {
if (result.hasReplacement()) {
if (isNonWidenableDeclaration(result, compilationUnit)) {
continue;
}
ASTNode matchedNode = result.match().getMatchedNode();
if (matchedNode != null) {
nodesprocessed.add(matchedNode);
}
operations.add(new HintFileRewriteOperation(result));
}
}
}
/**
* Loads a hint file from a string and finds operations.
*
* @param compilationUnit the compilation unit to search
* @param hintFileContent the hint file content
* @param operations the set to add found operations to
*/
public static void findOperationsFromContent(CompilationUnit compilationUnit,
String hintFileContent, Set<CompilationUnitRewriteOperation> operations) {
// Ensure built-in guard functions (sourceVersionGE, etc.) are registered
GuardRegistry.getInstance();
try {
HintFileParser parser = new HintFileParser();
HintFile hintFile = parser.parse(hintFileContent);
BatchTransformationProcessor processor = new BatchTransformationProcessor(hintFile);
List<TransformationResult> results = processor.process(compilationUnit);
for (TransformationResult result : results) {
if (result.hasReplacement()) {
if (isNonWidenableDeclaration(result, compilationUnit)) {
continue;
}
operations.add(new HintFileRewriteOperation(result));
}
}
} catch (HintFileParser.HintParseException e) {
// Skip invalid hint files
}
}
/**
* Registers guard and fix functions from embedded Java blocks in a hint file.
*
* <p>Compiles each non-blank {@code <? ?>} block and registers its guard
* methods in the {@link GuardRegistry} and fix methods in the
* {@link EmbeddedFixExecutor}.</p>
*
* @param hintFile the hint file containing embedded Java blocks
* @param hintFileId the hint file ID for tracking
* @since 1.5.0
*/
private static void registerEmbeddedFunctions(HintFile hintFile, String hintFileId) {
// Unregister previously registered guards/fixes for this hint file
// to avoid stale entries when a hint file is reloaded after editing
EmbeddedGuardRegistrar.unregisterGuards(hintFileId);
EmbeddedFixExecutor.unregisterFixes(hintFileId);
for (EmbeddedJavaBlock block : hintFile.getEmbeddedJavaBlocks()) {
if (block.getSource().isBlank()) {
continue;
}
CompilationResult compResult = EmbeddedJavaCompiler.compile(block, hintFileId);
if (!compResult.hasErrors()) {
EmbeddedGuardRegistrar.registerGuards(compResult, hintFileId);
EmbeddedFixExecutor.registerFixes(compResult, hintFileId);
}
}
}
/**
* Checks if a transformation result is a DECLARATION pattern with {@code $widestType}
* that cannot actually be widened. When widening IS possible, the widest type FQN
* is stored in the match's extra data for later retrieval by
* {@code handleDeclarationRewrite}.
*
* <p>This filtering ensures that rewrite operations are only created when a real
* type change will be produced — avoiding entering {@code rewriteAST} without a
* change to apply.</p>
*
* @param result the transformation result to check
* @param compilationUnit the compilation unit for type widening analysis
* @return {@code true} if this is a $widestType declaration that cannot be widened
* (should be skipped); {@code false} otherwise (proceed normally)
* @since 1.3.12
*/
private static boolean isNonWidenableDeclaration(TransformationResult result,
CompilationUnit compilationUnit) {
String replacement = result.replacement();
if (replacement == null || !replacement.contains("$widestType")) { //$NON-NLS-1$
return false; // Not a $widestType rule — proceed normally
}
ASTNode matchedNode = result.match().getMatchedNode();
if (!(matchedNode instanceof VariableDeclarationStatement declStmt)) {
return false; // Not a declaration — proceed normally
}
if (declStmt.fragments().size() != 1) {
return true; // Multi-fragment — cannot widen
}
VariableDeclarationFragment fragment =
(VariableDeclarationFragment) declStmt.fragments().get(0);
IVariableBinding varBinding = fragment.resolveBinding();
if (varBinding == null) {
return true; // No binding — cannot analyze
}
Map<String, TypeWideningAnalyzer.TypeWideningResult> analysisResults =
TypeWideningAnalyzer.analyzeCompilationUnit(compilationUnit);
TypeWideningAnalyzer.TypeWideningResult wideningResult =
analysisResults.get(varBinding.getKey());
if (wideningResult == null || !wideningResult.canWiden()) {
return true; // No widening possible — skip
}
// Widening IS possible — store the FQN for handleDeclarationRewrite
result.match().putExtraData("__widestType__", //$NON-NLS-1$
wideningResult.getWidestType().getQualifiedName());
return false; // Proceed — create the operation
}
/**
* Generic rewrite operation that replaces matched AST nodes with the
* replacement text from a {@code .sandbox-hint} rule.
*
* <p>This operation handles text-based replacement by parsing the replacement
* text as an expression and replacing the matched AST node. It also handles
* import management via {@link ImportDirective}.</p>
*/
private static class HintFileRewriteOperation extends CompilationUnitRewriteOperation {
private final TransformationResult result;
public HintFileRewriteOperation(TransformationResult result) {
this.result = result;
}
@Override
public void rewriteAST(CompilationUnitRewrite cuRewrite, LinkedProposalModelCore linkedModel) {
ASTRewrite rewrite = cuRewrite.getASTRewrite();
AST ast = cuRewrite.getRoot().getAST();
String description = result.description() != null
? result.description()
: "Apply hint file rule"; //$NON-NLS-1$
TextEditGroup group = createTextEditGroup(description, cuRewrite);
ASTNode matchedNode = result.match().getMatchedNode();
String replacement = result.replacement();
if (matchedNode == null || replacement == null) {
return;
}
// Check for embedded fix function reference: <?fixName?>
// These are dispatched to EmbeddedFixExecutor instead of text-based replacement.
// Currently fix functions are stubs (Phase 1.4 MVP) — log and skip rewrite.
String fixName = extractEmbeddedFixName(replacement);
if (fixName != null) {
if (EmbeddedFixExecutor.hasFix(fixName)) {
EmbeddedFixExecutor.execute(fixName);
}
return;
}
// Handle import directives
if (result.hasImportDirective()) {
ImportDirective imports = result.importDirective();
ImportRewrite importRewrite = cuRewrite.getImportRewrite();
for (String addImport : imports.getAddImports()) {
importRewrite.addImport(addImport);
}
// Use ImportRemover instead of directly calling importRewrite.removeImport().
// Direct removal is unsafe because the same type may still be referenced
// by other nodes in the compilation unit that are NOT being transformed.
// ImportRemover scans the AST and only removes the import when ALL
// references to the type are gone (see eclipse-jdt/eclipse.jdt.ui#121).
if (!imports.getRemoveImports().isEmpty()) {
ImportRemover remover = cuRewrite.getImportRemover();
remover.registerRemovedNode(matchedNode);
remover.applyRemoves(importRewrite);
}
for (String addStatic : imports.getAddStaticImports()) {
int lastDot = addStatic.lastIndexOf('.');
if (lastDot > 0) {
String type = addStatic.substring(0, lastDot);
String member = addStatic.substring(lastDot + 1);
importRewrite.addStaticImport(type, member, false);
}
}
for (String removeStatic : imports.getRemoveStaticImports()) {
int lastDot = removeStatic.lastIndexOf('.');
if (lastDot > 0) {
String type = removeStatic.substring(0, lastDot);
String member = removeStatic.substring(lastDot + 1);
importRewrite.removeStaticImport(type + "." + member); //$NON-NLS-1$
}
}
// Handle replaceStaticImport directives
// Note: This may execute multiple times for the same imports when multiple
// rules match in the same file. ImportRewrite handles duplicate add/remove
// operations gracefully, so this is an acceptable tradeoff for simplicity.
for (Map.Entry<String, String> entry : imports.getReplaceStaticImports().entrySet()) {
String oldType = entry.getKey();
String newType = entry.getValue();
// Find existing static imports from oldType and replace with newType
CompilationUnit cu = cuRewrite.getRoot();
for (Object importObj : cu.imports()) {
org.eclipse.jdt.core.dom.ImportDeclaration importDecl =
(org.eclipse.jdt.core.dom.ImportDeclaration) importObj;
if (importDecl.isStatic()) {
String importName = importDecl.getName().getFullyQualifiedName();
if (importDecl.isOnDemand() && importName.equals(oldType)) {
// Wildcard: import static org.junit.Assert.* → import static org.junit.jupiter.api.Assertions.*
importRewrite.removeStaticImport(oldType + ".*"); //$NON-NLS-1$
importRewrite.addStaticImport(newType, "*", false); //$NON-NLS-1$
} else if (importName.startsWith(oldType + ".")) { //$NON-NLS-1$
// Specific: import static org.junit.Assert.assertEquals → ...Assertions.assertEquals
String member = importName.substring(oldType.length() + 1);
importRewrite.removeStaticImport(importName);
importRewrite.addStaticImport(newType, member, false);
}
}
}
}
}
// Parse replacement as an expression and replace the matched node
if (matchedNode instanceof Expression) {
// Shorten FQNs to simple names (imports are already added above)
String shortenedReplacement = shortenFqns(replacement, result);
// Try to parse the replacement as an expression
org.eclipse.jdt.core.dom.ASTParser parser = org.eclipse.jdt.core.dom.ASTParser.newParser(AST.getJLSLatest());
parser.setKind(org.eclipse.jdt.core.dom.ASTParser.K_EXPRESSION);
parser.setSource(shortenedReplacement.toCharArray());
ASTNode newNode = parser.createAST(null);
if (newNode instanceof Expression) {
ASTNode copy = ASTNode.copySubtree(ast, newNode);
int oldCount = countStringLiterals(matchedNode);
int newCount = countStringLiterals(copy);
// Auto-detect: did we change a String charset argument to a Charset type?
TypeChangeInfo typeChange = TypeChangeDetector.detectCharsetTypeChange(
matchedNode, replacement);
// Check if the matched node is inside a try body that will be unwrapped
// after removing the checked exception. If so, we must handle the
// replacement AND the try-unwrap atomically to avoid conflicts between
// ASTNodes.replaceAndRemoveNLS (statement-level replacement) and
// createMoveTarget (used by simplifyEmptyTryStatement).
boolean tryAlreadyHandled = false;
if (typeChange != null && oldCount > newCount) {
tryAlreadyHandled = replaceAndUnwrapTryIfNeeded(
matchedNode, copy, shortenedReplacement,
typeChange, rewrite, cuRewrite, group);
}
if (!tryAlreadyHandled) {
if (oldCount > newCount) {
try {
ASTNodes.replaceAndRemoveNLS(rewrite, matchedNode, copy, group, cuRewrite);
} catch (CoreException e) {
// Fall back to plain replace if NLS removal fails
rewrite.replace(matchedNode, copy, group);
}
} else {
rewrite.replace(matchedNode, copy, group);
}
if (typeChange != null) {
ExceptionCleanupHelper.removeCheckedException(
matchedNode,
typeChange.exceptionFQN(),
typeChange.exceptionSimpleName(),
group, rewrite, cuRewrite.getImportRemover());
}
}
}
} else if (matchedNode instanceof Annotation) {
// Handle annotation replacement (e.g., @Before → @BeforeEach)
String annotationName = replacement.trim();
if (annotationName.startsWith("@")) { //$NON-NLS-1$
annotationName = annotationName.substring(1);
}
Annotation newAnnotation;
if (matchedNode instanceof SingleMemberAnnotation oldSingleMember) {
// Preserve the value: @Ignore("reason") → @Disabled("reason")
SingleMemberAnnotation newSingleMember = ast.newSingleMemberAnnotation();
newSingleMember.setTypeName(ast.newName(annotationName));
newSingleMember.setValue((Expression) ASTNode.copySubtree(ast, oldSingleMember.getValue()));
newAnnotation = newSingleMember;
} else if (matchedNode instanceof NormalAnnotation oldNormal) {
// Preserve member-value pairs: @Test(expected=X.class) → @Test(expected=X.class)
NormalAnnotation newNormal = ast.newNormalAnnotation();
newNormal.setTypeName(ast.newName(annotationName));
for (Object obj : oldNormal.values()) {
MemberValuePair oldPair = (MemberValuePair) obj;
MemberValuePair newPair = ast.newMemberValuePair();
newPair.setName(ast.newSimpleName(oldPair.getName().getIdentifier()));
newPair.setValue((Expression) ASTNode.copySubtree(ast, oldPair.getValue()));
newNormal.values().add(newPair);
}
newAnnotation = newNormal;
} else {
// MarkerAnnotation: @Before → @BeforeEach
MarkerAnnotation newMarker = ast.newMarkerAnnotation();
newMarker.setTypeName(ast.newName(annotationName));
newAnnotation = newMarker;
}
rewrite.replace(matchedNode, newAnnotation, group);
} else if (matchedNode instanceof org.eclipse.jdt.core.dom.MethodDeclaration) {
// Handle METHOD_DECLARATION → METHOD_DECLARATION rewrite.
// When the replacement is also a method declaration (e.g.,
// "@Test void $name($params$)"), diff the annotations between
// source and replacement patterns and add the missing ones.
handleMethodDeclarationRewrite(matchedNode, replacement, ast,
rewrite, cuRewrite, group);
} else if (matchedNode instanceof VariableDeclarationStatement declStmt) {
// Handle DECLARATION rewrite with $widestType support.
// When the replacement contains $widestType($var), replace the
// declaration type with the widest type computed by canWidenType guard.
handleDeclarationRewrite(declStmt, replacement, ast, rewrite, cuRewrite, group);
}
}
/**
* Returns the simple name of an annotation (without package prefix).
*/
private static String getAnnotationSimpleName(Annotation annotation) {
if (annotation instanceof MarkerAnnotation marker) {
return marker.getTypeName().toString();
} else if (annotation instanceof SingleMemberAnnotation single) {
return single.getTypeName().toString();
} else if (annotation instanceof NormalAnnotation normal) {
return normal.getTypeName().toString();
}
return ""; //$NON-NLS-1$
}
/**
* Handles DECLARATION rewrite with {@code $widestType} support.
*
* <p>The widest type FQN was pre-computed by {@link #isNonWidenableDeclaration}
* and stored in the match's extra data under {@code "__widestType__"}.
* This method is only called when widening IS possible, so the FQN is
* guaranteed to be present.</p>
*
* @param declStmt the matched VariableDeclarationStatement
* @param replacement the replacement text
* @param ast the AST factory
* @param rewrite the AST rewriter
* @param cuRewrite the compilation unit rewrite context
* @param group the text edit group
* @since 1.3.12
*/
private void handleDeclarationRewrite(VariableDeclarationStatement declStmt,
String replacement, AST ast, ASTRewrite rewrite,
CompilationUnitRewrite cuRewrite, TextEditGroup group) {
// Check if replacement uses $widestType function
if (replacement == null || !replacement.contains("$widestType")) { //$NON-NLS-1$
return;
}
// Retrieve the widest type FQN pre-computed by isNonWidenableDeclaration
Object widestTypeFqn = result.match().getExtraData("__widestType__"); //$NON-NLS-1$
if (!(widestTypeFqn instanceof String fqn) || fqn.isEmpty()) {
return;
}
// Add import for the new type and get the simple name
String simpleName = cuRewrite.getImportRewrite().addImport(fqn);
// Create the new type node
Type newType;
if (simpleName.contains(".")) { //$NON-NLS-1$
// Qualified name: split and create QualifiedName
newType = ast.newSimpleType(ast.newName(simpleName.split("\\."))); //$NON-NLS-1$
} else {
newType = ast.newSimpleType(ast.newSimpleName(simpleName));
}
// Replace the type in the variable declaration statement
rewrite.replace(declStmt.getType(), newType, group);
}
/**
* Handles METHOD_DECLARATION → METHOD_DECLARATION rewrite using natural syntax.
*
* <p>When the replacement is a method declaration with additional annotations
* (e.g., {@code @Test void $name($params$)}), this method diffs the annotations
* between the source pattern and the replacement pattern, then adds any
* annotations that are in the replacement but not in the source.</p>
*
* <p>This is <b>idempotent</b>: annotations already present on the actual
* matched method are not added again.</p>
*
* <p>This approach is <b>NetBeans-compatible</b>: the replacement is just
* valid Java code (a method declaration with annotations), not a custom directive.</p>
*
* @param matchedNode the matched MethodDeclaration AST node
* @param replacement the replacement text (e.g., {@code "@Test void $name($params$)"})
* @param ast the AST factory
* @param rewrite the AST rewriter
* @param cuRewrite the compilation unit rewrite context
* @param group the text edit group
*/
@SuppressWarnings("unchecked")
private void handleMethodDeclarationRewrite(ASTNode matchedNode, String replacement,
AST ast, ASTRewrite rewrite, CompilationUnitRewrite cuRewrite,
TextEditGroup group) {
if (!(matchedNode instanceof org.eclipse.jdt.core.dom.MethodDeclaration methodDecl)) {
return;
}
// Parse the replacement as a method declaration to extract annotations
org.eclipse.jdt.core.dom.MethodDeclaration replacementMethod =
parseReplacementAsMethodDeclaration(replacement);
if (replacementMethod == null) {
return;
}
// Collect annotation simple names from the replacement method
java.util.Set<String> replacementAnnotationNames = new java.util.LinkedHashSet<>();
java.util.Map<String, String> annotationFqnMap = new java.util.LinkedHashMap<>();
for (Object modifier : replacementMethod.modifiers()) {
if (modifier instanceof Annotation ann) {
String simpleName = getAnnotationSimpleName(ann);
replacementAnnotationNames.add(simpleName);
// Try to extract FQN from the replacement text for import management
String fqn = extractAnnotationFqnFromText(replacement, simpleName);
if (fqn != null) {
annotationFqnMap.put(simpleName, fqn);
}
}
}
// Collect annotation simple names from the source pattern (pattern text, not actual code)
String sourcePatternText = result.rule() != null
? result.rule().sourcePattern().getValue() : ""; //$NON-NLS-1$
java.util.Set<String> sourceAnnotationNames = new java.util.LinkedHashSet<>();
org.eclipse.jdt.core.dom.MethodDeclaration sourceMethod =
parseReplacementAsMethodDeclaration(sourcePatternText);
if (sourceMethod != null) {
for (Object modifier : sourceMethod.modifiers()) {
if (modifier instanceof Annotation ann) {
sourceAnnotationNames.add(getAnnotationSimpleName(ann));
}
}
}
// Annotations to add = in replacement but not in source pattern
java.util.Set<String> annotationsToAdd = new java.util.LinkedHashSet<>(replacementAnnotationNames);
annotationsToAdd.removeAll(sourceAnnotationNames);
if (annotationsToAdd.isEmpty()) {
return;
}
// Collect existing annotation simple names on the actual matched method
java.util.Set<String> existingAnnotationNames = new java.util.LinkedHashSet<>();
for (Object modifier : methodDecl.modifiers()) {
if (modifier instanceof Annotation ann) {
existingAnnotationNames.add(getAnnotationSimpleName(ann));
}
}
// Add each missing annotation (idempotent: skip if already present)
ListRewrite modifiersRewrite = rewrite.getListRewrite(
methodDecl, org.eclipse.jdt.core.dom.MethodDeclaration.MODIFIERS2_PROPERTY);
ImportRewrite importRewrite = cuRewrite.getImportRewrite();
boolean changed = false;
for (String annotationName : annotationsToAdd) {
if (existingAnnotationNames.contains(annotationName)) {
continue; // Already present on the actual method — skip
}
// Use createStringPlaceholder so the annotation appears on its own
// line above the method declaration (proper formatting).
ASTNode newAnnotation = rewrite.createStringPlaceholder(
"@" + annotationName + "\n", ASTNode.MARKER_ANNOTATION); //$NON-NLS-1$ //$NON-NLS-2$
modifiersRewrite.insertFirst(newAnnotation, group);
changed = true;
// Add import for the FQN if available
String fqn = annotationFqnMap.get(annotationName);
if (fqn != null) {
importRewrite.addImport(fqn);
}
}
// If nothing changed (all annotations already present), skip
if (!changed) {
return;
}
}
/**
* Parses a string as a method declaration by wrapping it in a class context.
*
* @param methodSnippet the method snippet (e.g., {@code "@Test void $name($params$)"})
* @return the parsed MethodDeclaration, or {@code null} if parsing fails
*/
private static org.eclipse.jdt.core.dom.MethodDeclaration parseReplacementAsMethodDeclaration(
String methodSnippet) {
if (methodSnippet == null || methodSnippet.isBlank()) {
return null;
}
String normalized = methodSnippet.trim();
// Add empty body if not present
if (!normalized.endsWith("}") && !normalized.endsWith(";")) { //$NON-NLS-1$ //$NON-NLS-2$
normalized = normalized + " {}"; //$NON-NLS-1$
}
// Handle multi-placeholder parameters for valid Java syntax
normalized = normalized.replaceAll(
"\\(\\s*\\$([a-zA-Z_][a-zA-Z0-9_]*)\\$\\s*\\)", //$NON-NLS-1$
"(Object... \\$$1\\$)"); //$NON-NLS-1$
// Handle single placeholders as method names (replace $name with _name for parsing)
// We just need the annotations, so the method body doesn't matter
String source = "class _Pattern { " + normalized + " }"; //$NON-NLS-1$ //$NON-NLS-2$
org.eclipse.jdt.core.dom.ASTParser parser = org.eclipse.jdt.core.dom.ASTParser
.newParser(AST.getJLSLatest());
parser.setSource(source.toCharArray());
parser.setKind(org.eclipse.jdt.core.dom.ASTParser.K_COMPILATION_UNIT);
parser.setCompilerOptions(org.eclipse.jdt.core.JavaCore.getOptions());
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
if (cu.types().isEmpty()) {
return null;
}
org.eclipse.jdt.core.dom.TypeDeclaration typeDecl =
(org.eclipse.jdt.core.dom.TypeDeclaration) cu.types().get(0);
if (typeDecl.getMethods().length == 0) {
return null;
}
return typeDecl.getMethods()[0];
}
/**
* Extracts the FQN for an annotation from the replacement text.
* For example, from {@code "@org.junit.jupiter.api.Test void $name($params$)"},
* extracts {@code "org.junit.jupiter.api.Test"} for annotation simple name {@code "Test"}.
*
* @param replacementText the full replacement text
* @param simpleName the annotation simple name to look for
* @return the FQN (without {@code @}), or {@code null} if not found
*/
private static String extractAnnotationFqnFromText(String replacementText, String simpleName) {
// Look for @pkg.sub.AnnotationName pattern in the replacement text
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile(
"@((?:[a-z][a-z0-9_]*\\.)+)" + java.util.regex.Pattern.quote(simpleName) + "\\b") //$NON-NLS-1$ //$NON-NLS-2$
.matcher(replacementText);
if (matcher.find()) {
// Return the full FQN without @
return matcher.group(1) + simpleName;
}
return null;
}
/**
* Shortens fully qualified names in the replacement text to simple names.
*
* <p>Uses the already-inferred {@link ImportDirective#getAddImports()} to know
* which FQNs to shorten. For example, {@code java.nio.charset.StandardCharsets.UTF_8}
* becomes {@code StandardCharsets.UTF_8} since the import for
* {@code java.nio.charset.StandardCharsets} is already being added.</p>
*
* <p>FQNs are processed longest-first to avoid partial-match issues.</p>
*
* @param replacement the raw replacement text containing FQNs
* @param result the transformation result with import directives
* @return the replacement text with FQNs shortened to simple names
*/
private static String shortenFqns(String replacement, TransformationResult result) {
if (!result.hasImportDirective()) {
return replacement;
}
List<String> addImports = result.importDirective().getAddImports();
if (addImports.isEmpty()) {
return replacement;
}
// Sort by length (longest first) to avoid partial replacement issues
List<String> sorted = new java.util.ArrayList<>(addImports);
sorted.sort((a, b) -> Integer.compare(b.length(), a.length()));
String shortened = replacement;
for (String fqn : sorted) {
int lastDot = fqn.lastIndexOf('.');
if (lastDot > 0) {
String simpleName = fqn.substring(lastDot + 1);
shortened = shortened.replace(fqn, simpleName);
}
}
return shortened;
}
/**
* Counts the number of {@link StringLiteral} nodes in the given AST subtree.
*
* <p>This is used to determine whether {@code replaceAndRemoveNLS} should be
* used instead of a plain {@code rewrite.replace()}. When a DSL rule replaces
* a string literal with a non-string expression (e.g., {@code "UTF-8"} →
* {@code StandardCharsets.UTF_8}), the count decreases and the associated
* {@code //$NON-NLS-n$} comment must be removed. When the count stays the
* same or increases (e.g., zero-arg expansion adding a new argument),
* existing NLS comments must be preserved.</p>
*
* @param node the AST node to inspect
* @return the number of {@code StringLiteral} nodes found
*/
private static int countStringLiterals(ASTNode node) {
int[] count = { 0 };
node.accept(new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(StringLiteral literal) {
count[0]++;
return false;
}
});
return count[0];
}
/**
* Extracts the embedded fix function name from a replacement text ({@code <?fixName?>}).
*
* <p>A valid embedded fix reference has the form {@code <?identifier?>} where
* the identifier has no leading/trailing whitespace and is a valid Java identifier.</p>
*
* @return the fix function name, or {@code null} if the replacement is not a valid fix reference
*/
private static String extractEmbeddedFixName(String replacement) {
if (replacement == null || !replacement.startsWith("<?") || !replacement.endsWith("?>")) { //$NON-NLS-1$ //$NON-NLS-2$
return null;
}
String inner = replacement.substring(2, replacement.length() - 2);
String trimmed = inner.trim();
if (trimmed.isEmpty() || !inner.equals(trimmed)) {
return null;
}
for (int i = 0; i < trimmed.length(); i++) {
char ch = trimmed.charAt(i);
if (i == 0) {
if (!Character.isJavaIdentifierStart(ch)) {
return null;
}
} else if (!Character.isJavaIdentifierPart(ch)) {
return null;
}
}
return trimmed;
}
/**
* Pattern matching the LAST NLS comment on a line.
* Adapted from {@code AbstractExplicitEncoding}.
*/
private static final Pattern LAST_NLS_COMMENT = Pattern.compile("[ ]*\\/\\/\\$NON-NLS-[0-9]+\\$(?!.*\\/\\/\\$NON-NLS-)"); //$NON-NLS-1$
/**
* Checks whether the given statement is directly inside the body of a try statement
* that will be fully unwrapped after removing the target exception. This happens when:
* <ul>
* <li>The statement is in the try body (not a catch/finally block)</li>
* <li>The try has no resources (not try-with-resources)</li>
* <li>The try has no finally block</li>
* <li>The try has exactly one catch clause catching only the target exception</li>
* </ul>
*/
private static boolean isInsideTryBodyWithOnlyTargetExceptionCatch(ASTNode statement, String exceptionSimple) {
ASTNode parent = statement.getParent();
if (!(parent instanceof Block block)) {
return false;
}
ASTNode grandParent = block.getParent();
if (!(grandParent instanceof TryStatement tryStatement)) {
return false;
}
if (tryStatement.getBody() != block) {
return false;
}
if (!tryStatement.resources().isEmpty()) {
return false;
}
if (tryStatement.getFinally() != null) {
return false;
}
@SuppressWarnings("unchecked")
List<CatchClause> catchClauses = tryStatement.catchClauses();
if (catchClauses.size() != 1) {
return false;
}
CatchClause catchClause = catchClauses.get(0);
Type exType = catchClause.getException().getType();
if (exType instanceof SimpleType simpleType) {
return exceptionSimple.equals(simpleType.getName().toString());
}
return false;
}
/**
* If the matched node is inside a try body that will be unwrapped after exception
* removal, handles BOTH the replacement AND the try-catch unwrapping in a single
* text-based operation to avoid conflicts between {@code rewrite.replace()} on
* child nodes and {@code createMoveTarget()} on parent statements.
*
* @return {@code true} if the combined operation was performed (caller should skip
* separate replacement and exception removal), {@code false} if not applicable
*/
private static boolean replaceAndUnwrapTryIfNeeded(
ASTNode matchedNode, ASTNode copy, String shortenedReplacement,
TypeChangeInfo typeChange, ASTRewrite rewrite,
CompilationUnitRewrite cuRewrite, TextEditGroup group) {
ASTNode st = ASTNodes.getFirstAncestorOrNull(matchedNode, Statement.class);
if (st == null || !isInsideTryBodyWithOnlyTargetExceptionCatch(st, typeChange.exceptionSimpleName())) {
return false;
}
Block block = (Block) st.getParent();
TryStatement tryStatement = (TryStatement) block.getParent();
ASTNode tryParent = tryStatement.getParent();
if (!(tryParent instanceof Block parentBlock)) {
return false;
}
try {
String buffer = cuRewrite.getCu().getBuffer().getContents();
CompilationUnit cu = (CompilationUnit) st.getRoot();
String matchedSource = buffer.substring(matchedNode.getStartPosition(),
matchedNode.getStartPosition() + matchedNode.getLength());
ListRewrite parentListRewrite = rewrite.getListRewrite(parentBlock, Block.STATEMENTS_PROPERTY);
List<?> tryStatements = block.statements();
for (int i = tryStatements.size() - 1; i >= 0; i--) {
ASTNode stmt = (ASTNode) tryStatements.get(i);
int stmtStart = cu.getExtendedStartPosition(stmt);
int stmtLength = cu.getExtendedLength(stmt);
String stmtSource = buffer.substring(stmtStart, stmtStart + stmtLength);
// Remove leading whitespace
stmtSource = Pattern.compile("^[ \\t]*").matcher(stmtSource).replaceAll(""); //$NON-NLS-1$ //$NON-NLS-2$
stmtSource = Pattern.compile("\n[ \\t]*").matcher(stmtSource).replaceAll("\n"); //$NON-NLS-1$ //$NON-NLS-2$
if (stmt == st) {
// Remove last NLS comment and apply the replacement
stmtSource = LAST_NLS_COMMENT.matcher(stmtSource).replaceFirst(""); //$NON-NLS-1$
stmtSource = stmtSource.replace(matchedSource, shortenedReplacement);
}
ASTNode placeholder = rewrite.createStringPlaceholder(stmtSource, stmt.getNodeType());
parentListRewrite.insertAfter(placeholder, tryStatement, group);
}
rewrite.remove(tryStatement, group);
// Register removed nodes for import removal
cuRewrite.getImportRemover().registerRemovedNode(matchedNode);
return true;
} catch (JavaModelException e) {
// Fall back to separate handling
return false;
}
}
}
}