DryRunReporter.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.api;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.eclipse.jdt.core.dom.CompilationUnit;
/**
* Performs dry-run analysis by finding all matches for a set of transformation
* rules without modifying any code.
*
* <p>Generates a report of all matches found, including the file, line number,
* matched code, and suggested replacement. This is useful for:</p>
* <ul>
* <li>Previewing changes before applying them</li>
* <li>Generating CSV/JSON reports of code improvement opportunities</li>
* <li>Integration with Eclipse Problem View as markers</li>
* </ul>
*
* <h2>Usage example</h2>
* <pre>
* DryRunReporter reporter = new DryRunReporter();
* List<ReportEntry> entries = reporter.analyze(cu, hintFile.getRules());
* String json = reporter.toJson(entries);
* </pre>
*
* @since 1.3.3
*/
public final class DryRunReporter {
private final TriggerPatternEngine engine;
/**
* Creates a new dry-run reporter using the default engine.
*/
public DryRunReporter() {
this.engine = new TriggerPatternEngine();
}
/**
* Creates a new dry-run reporter using the given engine.
*
* @param engine the trigger pattern engine to use for matching
*/
public DryRunReporter(TriggerPatternEngine engine) {
this.engine = engine;
}
/**
* Analyzes a compilation unit against a list of transformation rules.
*
* @param cu the compilation unit to analyze
* @param rules the transformation rules to apply
* @return list of report entries for all matches found
*/
public List<ReportEntry> analyze(CompilationUnit cu, List<TransformationRule> rules) {
return analyze(cu, rules, null);
}
/**
* Analyzes a compilation unit against a list of transformation rules
* with compiler options for guard evaluation.
*
* @param cu the compilation unit to analyze
* @param rules the transformation rules to apply
* @param compilerOptions compiler options for source version guards (may be {@code null})
* @return list of report entries for all matches found
*/
public List<ReportEntry> analyze(CompilationUnit cu, List<TransformationRule> rules,
Map<String, String> compilerOptions) {
if (cu == null || rules == null || rules.isEmpty()) {
return Collections.emptyList();
}
List<ReportEntry> entries = new ArrayList<>();
for (TransformationRule rule : rules) {
Pattern sourcePattern = rule.sourcePattern();
List<Match> matches = engine.findMatches(cu, sourcePattern);
for (Match match : matches) {
int lineNumber = cu.getLineNumber(match.getOffset());
String matchedCode = match.getMatchedNode().toString().trim();
// Determine replacement if available
String suggestedReplacement = null;
if (!rule.isHintOnly() && !rule.alternatives().isEmpty()) {
// Try to find the matching alternative using guards
if (compilerOptions != null) {
GuardContext guardCtx = GuardContext.fromMatch(match, cu, compilerOptions);
Optional<RewriteAlternative> alt = rule.findMatchingAlternative(guardCtx);
if (alt.isPresent()) {
suggestedReplacement = substitutePlaceholders(
alt.get().replacementPattern(), match.getBindings());
}
} else {
// Without compiler options, use the first alternative
suggestedReplacement = substitutePlaceholders(
rule.alternatives().get(0).replacementPattern(),
match.getBindings());
}
}
String severity = "info"; //$NON-NLS-1$
String description = rule.getDescription();
entries.add(new ReportEntry(
lineNumber,
match.getOffset(),
match.getLength(),
matchedCode,
suggestedReplacement,
description,
severity,
sourcePattern.getValue(),
rule.hasImportDirective() ? rule.getImportDirective() : null));
}
}
return entries;
}
/**
* Performs simple placeholder substitution for generating replacement previews.
* Replaces {@code $name} placeholders with the text of their bound AST nodes.
*/
private String substitutePlaceholders(String pattern, Map<String, Object> bindings) {
String result = pattern;
// Sort by key length descending to handle $args$ before $a
List<Map.Entry<String, Object>> sorted = new ArrayList<>(bindings.entrySet());
sorted.sort((a, b) -> Integer.compare(b.getKey().length(), a.getKey().length()));
for (Map.Entry<String, Object> entry : sorted) {
String placeholder = entry.getKey();
Object value = entry.getValue();
if (value instanceof org.eclipse.jdt.core.dom.ASTNode) {
result = result.replace(placeholder, value.toString().trim());
} else if (value instanceof List<?> list) {
// Support indexed access: $args$[0], $args$[-1]
result = substituteIndexedAccess(result, placeholder, list);
// Support $args$.length
result = result.replace(placeholder + ".length", String.valueOf(list.size())); //$NON-NLS-1$
// Variadic placeholder: join with ", "
StringBuilder sb = new StringBuilder();
for (int i = 0; i < list.size(); i++) {
if (i > 0) {
sb.append(", "); //$NON-NLS-1$
}
sb.append(list.get(i).toString().trim());
}
result = result.replace(placeholder, sb.toString());
}
}
return result;
}
/**
* Substitutes indexed access patterns like {@code $args$[0]}, {@code $args$[-1]}
* with the corresponding element from the variadic placeholder binding.
*
* @param text the text to substitute in
* @param placeholder the placeholder name
* @param list the bound list of values
* @return the text with indexed accesses substituted
* @since 1.4.2
*/
private static String substituteIndexedAccess(String text, String placeholder, List<?> list) {
java.util.regex.Pattern indexPattern = java.util.regex.Pattern.compile(
java.util.regex.Pattern.quote(placeholder) + "\\[(-?\\d+)\\]"); //$NON-NLS-1$
java.util.regex.Matcher m = indexPattern.matcher(text);
StringBuilder sb = new StringBuilder();
while (m.find()) {
int index = Integer.parseInt(m.group(1));
if (index < 0) {
index = list.size() + index;
}
if (index >= 0 && index < list.size()) {
m.appendReplacement(sb, java.util.regex.Matcher.quoteReplacement(list.get(index).toString().trim()));
}
}
m.appendTail(sb);
return sb.toString();
}
/**
* Converts a list of report entries to JSON format.
*
* @param entries the report entries
* @return JSON string representation
*/
public String toJson(List<ReportEntry> entries) {
StringBuilder sb = new StringBuilder();
sb.append("[\n"); //$NON-NLS-1$
for (int i = 0; i < entries.size(); i++) {
ReportEntry entry = entries.get(i);
sb.append(" {\n"); //$NON-NLS-1$
sb.append(" \"line\": ").append(entry.lineNumber()).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" \"offset\": ").append(entry.offset()).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" \"length\": ").append(entry.length()).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" \"matched\": ").append(escapeJson(entry.matchedCode())).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" \"replacement\": ") //$NON-NLS-1$
.append(entry.suggestedReplacement() != null
? escapeJson(entry.suggestedReplacement())
: "null") //$NON-NLS-1$
.append(",\n"); //$NON-NLS-1$
sb.append(" \"description\": ") //$NON-NLS-1$
.append(entry.description() != null
? escapeJson(entry.description())
: "null") //$NON-NLS-1$
.append(",\n"); //$NON-NLS-1$
sb.append(" \"severity\": ").append(escapeJson(entry.severity())).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" \"pattern\": ").append(escapeJson(entry.sourcePattern())).append("\n"); //$NON-NLS-1$ //$NON-NLS-2$
sb.append(" }"); //$NON-NLS-1$
if (i < entries.size() - 1) {
sb.append(',');
}
sb.append('\n');
}
sb.append("]"); //$NON-NLS-1$
return sb.toString();
}
/**
* Converts a list of report entries to CSV format.
*
* @param entries the report entries
* @return CSV string with header row
*/
public String toCsv(List<ReportEntry> entries) {
StringBuilder sb = new StringBuilder();
sb.append("line,offset,length,matched,replacement,description,severity,pattern\n"); //$NON-NLS-1$
for (ReportEntry entry : entries) {
sb.append(entry.lineNumber()).append(',');
sb.append(entry.offset()).append(',');
sb.append(entry.length()).append(',');
sb.append(escapeCsv(entry.matchedCode())).append(',');
sb.append(entry.suggestedReplacement() != null
? escapeCsv(entry.suggestedReplacement()) : "").append(','); //$NON-NLS-1$
sb.append(entry.description() != null
? escapeCsv(entry.description()) : "").append(','); //$NON-NLS-1$
sb.append(escapeCsv(entry.severity())).append(',');
sb.append(escapeCsv(entry.sourcePattern()));
sb.append('\n');
}
return sb.toString();
}
/**
* Escapes a string for JSON output.
* Handles common escape sequences and also escapes other control
* characters (U+0000 to U+001F) as {@code \uXXXX} for fully
* compliant JSON output.
*/
private static String escapeJson(String value) {
if (value == null) {
return "null"; //$NON-NLS-1$
}
StringBuilder sb = new StringBuilder("\""); //$NON-NLS-1$
for (char c : value.toCharArray()) {
switch (c) {
case '"':
sb.append("\\\""); //$NON-NLS-1$
break;
case '\\':
sb.append("\\\\"); //$NON-NLS-1$
break;
case '\n':
sb.append("\\n"); //$NON-NLS-1$
break;
case '\r':
sb.append("\\r"); //$NON-NLS-1$
break;
case '\t':
sb.append("\\t"); //$NON-NLS-1$
break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c)); //$NON-NLS-1$
} else {
sb.append(c);
}
break;
}
}
sb.append('"');
return sb.toString();
}
/**
* Escapes a string for CSV output.
*/
private static String escapeCsv(String value) {
if (value == null) {
return ""; //$NON-NLS-1$
}
if (value.contains(",") || value.contains("\"") || value.contains("\n")) { //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
return "\"" + value.replace("\"", "\"\"") + "\""; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
}
return value;
}
/**
* Represents a single entry in a dry-run report.
*
* @param lineNumber the line number in the source file (1-based)
* @param offset the character offset of the match
* @param length the character length of the match
* @param matchedCode the matched source code text
* @param suggestedReplacement the suggested replacement (null for hint-only)
* @param description the rule description (may be null)
* @param severity the severity level (info, warning, error)
* @param sourcePattern the source pattern that matched
* @param importDirective the import directives if any (may be null)
* @since 1.3.3
*/
public record ReportEntry(
int lineNumber,
int offset,
int length,
String matchedCode,
String suggestedReplacement,
String description,
String severity,
String sourcePattern,
ImportDirective importDirective) {
/**
* Returns {@code true} if this entry has a suggested replacement.
*
* @return {@code true} if not hint-only
*/
public boolean hasReplacement() {
return suggestedReplacement != null;
}
}
}