HintFileParser.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.internal;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.eclipse.jdt.core.dom.ASTNode;
import org.sandbox.jdt.triggerpattern.api.EmbeddedJavaBlock;
import org.sandbox.jdt.triggerpattern.api.GuardExpression;
import org.sandbox.jdt.triggerpattern.api.HintFile;
import org.sandbox.jdt.triggerpattern.api.ImportDirective;
import org.sandbox.jdt.triggerpattern.api.Pattern;
import org.sandbox.jdt.triggerpattern.api.PatternKind;
import org.sandbox.jdt.triggerpattern.api.RewriteAlternative;
import org.sandbox.jdt.triggerpattern.api.Severity;
import org.sandbox.jdt.triggerpattern.api.TransformationRule;
/**
* Parser for {@code .sandbox-hint} and NetBeans {@code .hint} files.
*
* <p>Reads a text file containing transformation rules and produces a {@link HintFile}
* data model. Supports comments, metadata directives, simple rules, guarded rules,
* multi-rewrite rules, and foreach expansion.</p>
*
* <p>Import directives are automatically inferred from fully qualified names (FQNs)
* in source and replacement patterns. No explicit import directives are needed.</p>
*
* <h2>NetBeans compatibility</h2>
* <ul>
* <li>{@code <? ?>} embedded Java code blocks are extracted and stored as
* {@link org.sandbox.jdt.triggerpattern.api.EmbeddedJavaBlock} entries
* in the {@link HintFile}. These blocks can contain custom guard functions,
* fix functions, imports, or helper methods.</li>
* <li>Metadata directives support both {@code <!key: value>} (sandbox format)
* and {@code <!key="value">} (NetBeans format).</li>
* </ul>
*
* <h2>File format</h2>
* <pre>
* // Line comments
* /* Block comments * /
*
* // Embedded Java code blocks (extracted)
* <? import java.util.*; ?>
*
* <!id: my.rule.id>
* <!description: Descriptive text>
* <!description="NetBeans style metadata">
* <!severity: warning>
* <!minJavaVersion: 11>
* <!tags: performance, modernization>
* <!include: other.hint.id>
*
* // Foreach expansion: define a variable with key-value pairs
* // Rules using ${VAR} and ${VAR_CONSTANT} are expanded for each entry
* <!foreach CHARSET: "UTF-8" -> UTF_8, "ISO-8859-1" -> ISO_8859_1>
*
* $s.getBytes("${CHARSET}") :: sourceVersionGE(7)
* => $s.getBytes(java.nio.charset.StandardCharsets.${CHARSET_CONSTANT})
* ;;
*
* // Simple rule with FQN-based imports
* org.junit.Assert.assertEquals($expected, $actual)
* => org.junit.jupiter.api.Assertions.assertEquals($expected, $actual)
* ;;
*
* // Rule with guard
* source_pattern :: guard_expression
* => replacement_pattern
* ;;
*
* // Multi-rewrite rule
* source_pattern :: guard_expression
* => replacement1 :: condition1
* => replacement2 :: otherwise
* ;;
*
* // Hint-only (no rewrite)
* "Warning message":
* source_pattern :: guard_expression
* ;;
* </pre>
*
* @since 1.3.2
*/
public final class HintFileParser {
private static final Logger LOGGER = Logger.getLogger(HintFileParser.class.getName());
/**
* Set of recognized value-less directive names (directives without ':' or '=').
*/
private static final Set<String> VALUELESS_DIRECTIVES = Set.of("caseInsensitive"); //$NON-NLS-1$
private record GuardSplit(String patternText, String guardText) {
boolean hasGuard() { return guardText != null; }
}
private final GuardExpressionParser guardParser = new GuardExpressionParser();
/**
* Foreach variable definitions: variable name → ordered map of key→value pairs.
* <p>Example: {@code <!foreach CHARSET: "UTF-8" -> UTF_8, "ISO-8859-1" -> ISO_8859_1>}
* creates a mapping {@code CHARSET → {"UTF-8" → "UTF_8", "ISO-8859-1" → "ISO_8859_1"}}.</p>
*/
private final Map<String, Map<String, String>> foreachVariables = new HashMap<>();
/**
* Map variable definitions: map name → ordered map of key→value pairs.
* <p>Example: {@code <!map charsetNames: "UTF-8" => "StandardCharsets.UTF_8", "US-ASCII" => "StandardCharsets.US_ASCII">}
* creates a mapping {@code charsetNames → {"UTF-8" → "StandardCharsets.UTF_8", "US-ASCII" → "StandardCharsets.US_ASCII"}}.</p>
* <p>In rules, {@code #{charsetNames}} is replaced with the key and the corresponding value
* replaces {@code #{charsetNames_VALUE}}.</p>
*/
private final Map<String, Map<String, String>> mapVariables = new HashMap<>();
/**
* Parses a {@code .sandbox-hint} file from a string.
*
* @param content the file content
* @return the parsed hint file
* @throws HintParseException if the content cannot be parsed
*/
public HintFile parse(String content) throws HintParseException {
if (content == null || content.isBlank()) {
throw new HintParseException("Hint file content is empty", 0); //$NON-NLS-1$
}
try {
return parse(new StringReader(content));
} catch (IOException e) {
throw new HintParseException("I/O error reading hint file: " + e.getMessage(), 0); //$NON-NLS-1$
}
}
/**
* Parses a {@code .sandbox-hint} file from a reader.
*
* @param reader the reader to read from
* @return the parsed hint file
* @throws HintParseException if the content cannot be parsed
* @throws IOException if an I/O error occurs
*/
public HintFile parse(Reader reader) throws HintParseException, IOException {
HintFile hintFile = new HintFile();
foreachVariables.clear();
mapVariables.clear();
List<String> lines = readAndStripComments(reader, hintFile);
int i = 0;
while (i < lines.size()) {
String line = lines.get(i).trim();
if (line.isEmpty()) {
i++;
continue;
}
// Metadata directive: <!key: value> (may span multiple lines)
if (line.startsWith("<!")) { //$NON-NLS-1$
StringBuilder metaBuilder = new StringBuilder(line);
int metaStart = i;
boolean foundClosingBracket = line.endsWith(">"); //$NON-NLS-1$
while (!foundClosingBracket && i + 1 < lines.size()) {
i++;
String nextLine = lines.get(i).trim();
metaBuilder.append(' ').append(nextLine);
if (nextLine.endsWith(">")) { //$NON-NLS-1$
foundClosingBracket = true;
}
}
parseMetadata(hintFile, metaBuilder.toString().trim(), metaStart + 1);
i++;
continue;
}
// Rule: collect lines until ;;
i = parseRule(hintFile, lines, i);
}
return hintFile;
}
/**
* Reads all lines from a reader, stripping comments and extracting {@code <? ?>} blocks.
*
* <p>{@code <? ?>} blocks contain embedded Java code. Previously these were skipped;
* now they are extracted and stored as {@link EmbeddedJavaBlock} entries in the
* {@link HintFile} for compilation and execution.</p>
*/
private List<String> readAndStripComments(Reader reader, HintFile hintFile) throws IOException {
List<String> result = new ArrayList<>();
boolean inBlockComment = false;
boolean inCustomCodeBlock = false;
int customCodeStartLine = 0;
int customCodeStartOffset = 0;
StringBuilder customCodeBuilder = null;
// Read entire content to compute actual line offsets (handles CRLF vs LF)
String fullContent;
try (BufferedReader br = new BufferedReader(reader)) {
StringBuilder contentBuilder = new StringBuilder();
char[] buffer = new char[4096];
int read;
while ((read = br.read(buffer)) != -1) {
contentBuilder.append(buffer, 0, read);
}
fullContent = contentBuilder.toString();
}
// Split into lines preserving delimiter info for offset computation
List<String> rawLines = new ArrayList<>();
List<Integer> lineStartOffsets = new ArrayList<>();
int pos = 0;
while (pos <= fullContent.length()) {
lineStartOffsets.add(pos);
int nextLF = fullContent.indexOf('\n', pos);
int nextCR = fullContent.indexOf('\r', pos);
int lineEnd;
int nextLineStart;
if (nextLF == -1 && nextCR == -1) {
// Last line without trailing newline
rawLines.add(fullContent.substring(pos));
break;
} else if (nextCR >= 0 && (nextLF == -1 || nextCR < nextLF)) {
lineEnd = nextCR;
// CRLF or just CR
nextLineStart = (nextCR + 1 < fullContent.length() && fullContent.charAt(nextCR + 1) == '\n')
? nextCR + 2 : nextCR + 1;
} else {
lineEnd = nextLF;
nextLineStart = nextLF + 1;
}
rawLines.add(fullContent.substring(pos, lineEnd));
pos = nextLineStart;
}
for (int lineIdx = 0; lineIdx < rawLines.size(); lineIdx++) {
String rawLine = rawLines.get(lineIdx);
int lineNumber = lineIdx + 1;
int lineStartOffset = lineStartOffsets.get(lineIdx);
// Handle <? ?> custom code blocks (continuation)
if (inCustomCodeBlock) {
if (rawLine.contains("?>")) { //$NON-NLS-1$
int endIdx = rawLine.indexOf("?>"); //$NON-NLS-1$
customCodeBuilder.append(rawLine, 0, endIdx);
int blockEndOffset = lineStartOffset + endIdx + 2;
hintFile.addEmbeddedJavaBlock(new EmbeddedJavaBlock(
customCodeBuilder.toString(),
customCodeStartLine, lineNumber,
customCodeStartOffset, blockEndOffset));
LOGGER.log(Level.FINE, "Extracted embedded Java block (multi-line, lines {0}-{1})", //$NON-NLS-1$
new Object[] { customCodeStartLine, lineNumber });
inCustomCodeBlock = false;
customCodeBuilder = null;
rawLine = rawLine.substring(endIdx + 2);
} else {
customCodeBuilder.append(rawLine).append('\n');
result.add(""); //$NON-NLS-1$
continue;
}
}
// Check for start of <? ?> block(s) on this line
int searchFrom = 0;
while (!inBlockComment) {
int startIdx = rawLine.indexOf("<?", searchFrom); //$NON-NLS-1$
if (startIdx < 0) {
break;
}
int endIdx = rawLine.indexOf("?>", startIdx + 2); //$NON-NLS-1$
if (endIdx >= 0) {
// Single-line <? ?> block
String javaSource = rawLine.substring(startIdx + 2, endIdx);
// Skip fix function references: <?identifier?> is kept as-is
// (a simple Java identifier without whitespace, semicolons, or braces).
// These are replacement references like => <?customFix?> and should
// NOT be extracted as embedded Java blocks.
if (isFixFunctionReference(javaSource)) {
// Advance past this ?> and continue scanning for further blocks
searchFrom = endIdx + 2;
continue;
}
int blockStartOffset = lineStartOffset + startIdx;
int blockEndOffset = lineStartOffset + endIdx + 2;
hintFile.addEmbeddedJavaBlock(new EmbeddedJavaBlock(
javaSource, lineNumber, lineNumber,
blockStartOffset, blockEndOffset));
LOGGER.log(Level.FINE, "Extracted embedded Java block (single line {0})", lineNumber); //$NON-NLS-1$
rawLine = rawLine.substring(0, startIdx) + rawLine.substring(endIdx + 2);
searchFrom = startIdx; // Reset: content at startIdx changed
// Continue loop to check for further blocks on the same line
} else {
// Multi-line <? ?> block
customCodeStartLine = lineNumber;
customCodeStartOffset = lineStartOffset + startIdx;
customCodeBuilder = new StringBuilder();
customCodeBuilder.append(rawLine.substring(startIdx + 2)).append('\n');
LOGGER.log(Level.FINE, "Start of embedded Java block (multi-line, line {0})", lineNumber); //$NON-NLS-1$
inCustomCodeBlock = true;
rawLine = rawLine.substring(0, startIdx);
break;
}
}
if (inBlockComment) {
int endIdx = rawLine.indexOf("*/"); //$NON-NLS-1$
if (endIdx >= 0) {
inBlockComment = false;
rawLine = rawLine.substring(endIdx + 2);
} else {
result.add(""); //$NON-NLS-1$
continue;
}
}
// Process the line: strip line comments and start of block comments
StringBuilder sb = new StringBuilder();
int len = rawLine.length();
for (int c = 0; c < len; c++) {
char ch = rawLine.charAt(c);
if (c + 1 < len) {
if (ch == '/' && rawLine.charAt(c + 1) == '/') {
break; // Line comment
}
if (ch == '/' && rawLine.charAt(c + 1) == '*') {
int endIdx = rawLine.indexOf("*/", c + 2); //$NON-NLS-1$
if (endIdx >= 0) {
c = endIdx + 1;
continue;
}
inBlockComment = true;
break;
}
}
sb.append(ch);
}
result.add(sb.toString());
}
return result;
}
/**
* Parses a metadata directive line.
*
* <p>Supports both {@code <!key: value>} (sandbox format) and
* {@code <!key="value">} (NetBeans format) syntaxes.</p>
*/
private void parseMetadata(HintFile hintFile, String line, int lineNumber) throws HintParseException {
// <!key: value> or <!key="value">
if (!line.endsWith(">")) { //$NON-NLS-1$
throw new HintParseException("Invalid metadata directive (missing '>'): " + line, lineNumber); //$NON-NLS-1$
}
String inner = line.substring(2, line.length() - 1).trim();
// Prefer colon format for backward compatibility; fall back to equals format
int colonIdx = inner.indexOf(':');
int equalsIdx = inner.indexOf('=');
String key;
String value;
if (colonIdx >= 0 && !inner.substring(0, colonIdx).contains("=")) { //$NON-NLS-1$
// Sandbox format: key: value (preferred for backward compatibility)
key = inner.substring(0, colonIdx).trim();
value = inner.substring(colonIdx + 1).trim();
} else if (equalsIdx > 0) {
// NetBeans format: key="value" or key=value
key = inner.substring(0, equalsIdx).trim();
value = inner.substring(equalsIdx + 1).trim();
// Strip surrounding quotes if present
if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) { //$NON-NLS-1$ //$NON-NLS-2$
value = value.substring(1, value.length() - 1);
}
} else {
// Check for known value-less directives (e.g., <!caseInsensitive>)
if (VALUELESS_DIRECTIVES.contains(inner.trim())) {
key = inner.trim();
value = ""; //$NON-NLS-1$
} else {
throw new HintParseException("Invalid metadata directive (missing ':' or '='): " + line, lineNumber); //$NON-NLS-1$
}
}
switch (key) {
case "id": //$NON-NLS-1$
hintFile.setId(value);
break;
case "description": //$NON-NLS-1$
hintFile.setDescription(value);
break;
case "severity": //$NON-NLS-1$
hintFile.setSeverity(value);
break;
case "minJavaVersion": //$NON-NLS-1$
try {
hintFile.setMinJavaVersion(Integer.parseInt(value));
} catch (NumberFormatException e) {
throw new HintParseException("Invalid minJavaVersion: " + value, lineNumber); //$NON-NLS-1$
}
break;
case "tags": //$NON-NLS-1$
hintFile.setTags(Arrays.asList(value.split("\\s*,\\s*"))); //$NON-NLS-1$
break;
case "include": //$NON-NLS-1$
hintFile.addInclude(value);
break;
case "caseInsensitive": //$NON-NLS-1$
hintFile.setCaseInsensitive(true);
break;
case "suppressWarnings": //$NON-NLS-1$
for (String sw : value.split("\\s*,\\s*")) { //$NON-NLS-1$
hintFile.addSuppressWarnings(sw);
}
break;
case "treeKind": //$NON-NLS-1$
parseTreeKindDirective(hintFile, value, lineNumber);
break;
default:
// Check for foreach directive: <!foreach VARNAME: key1 -> val1, key2 -> val2>
if (key.startsWith("foreach ")) { //$NON-NLS-1$
parseForeachDirective(key, value, lineNumber);
}
// Check for map directive: <!map MAPNAME: "key1" => "val1", "key2" => "val2">
else if (key.startsWith("map ")) { //$NON-NLS-1$
parseMapDirective(key, value, lineNumber);
}
// Unknown metadata key is ignored for forward compatibility
break;
}
}
/**
* AST node type name to {@link ASTNode} node type constant mapping.
* Populated lazily via reflection on first use.
*/
private static volatile Map<String, Integer> astNodeTypeMap;
/**
* Returns the AST node type constant map, initializing it lazily.
*/
private static Map<String, Integer> getAstNodeTypeMap() {
if (astNodeTypeMap == null) {
synchronized (HintFileParser.class) {
if (astNodeTypeMap == null) {
astNodeTypeMap = buildAstNodeTypeMap();
}
}
}
return astNodeTypeMap;
}
/**
* Builds a map from AST node type names (e.g., "METHOD_DECLARATION") to
* their integer constants using reflection on {@link ASTNode}.
*/
private static Map<String, Integer> buildAstNodeTypeMap() {
Map<String, Integer> map = new HashMap<>();
for (java.lang.reflect.Field field : ASTNode.class.getDeclaredFields()) {
if (field.getType() == int.class
&& java.lang.reflect.Modifier.isPublic(field.getModifiers())
&& java.lang.reflect.Modifier.isStatic(field.getModifiers())
&& java.lang.reflect.Modifier.isFinal(field.getModifiers())) {
try {
map.put(field.getName(), field.getInt(null));
} catch (IllegalAccessException e) {
LOGGER.log(Level.FINE, "Cannot access ASTNode field: " + field.getName(), e); //$NON-NLS-1$
}
}
}
return map;
}
/**
* Parses a {@code <!treeKind:>} directive and sets the node type filter.
*
* <p>Syntax: {@code <!treeKind: METHOD_DECLARATION, IF_STATEMENT>}</p>
*
* @param hintFile the hint file to update
* @param value the comma-separated list of AST node type names
* @param lineNumber the line number for error reporting
*/
private void parseTreeKindDirective(HintFile hintFile, String value, int lineNumber) throws HintParseException {
if (value == null || value.isBlank()) {
throw new HintParseException("treeKind directive requires at least one node type", lineNumber); //$NON-NLS-1$
}
Map<String, Integer> typeMap = getAstNodeTypeMap();
List<Integer> nodeTypes = new ArrayList<>();
for (String name : value.split("\\s*,\\s*")) { //$NON-NLS-1$
String trimmed = name.trim();
if (trimmed.isEmpty()) {
continue;
}
Integer nodeType = typeMap.get(trimmed);
if (nodeType == null) {
throw new HintParseException("Unknown AST node type: " + trimmed, lineNumber); //$NON-NLS-1$
}
nodeTypes.add(nodeType);
}
if (nodeTypes.isEmpty()) {
throw new HintParseException("treeKind directive requires at least one node type", lineNumber); //$NON-NLS-1$
}
hintFile.setTreeKindNodeTypes(nodeTypes);
}
/**
* Parses a {@code <!foreach>} directive and stores the variable mapping.
*
* <p>Syntax: {@code <!foreach VARNAME: "key1" -> val1, "key2" -> val2, ...>}</p>
* <p>The variable can then be used in rules as {@code ${VARNAME}} (expands to the key)
* and {@code ${VARNAME_CONSTANT}} (expands to the value).</p>
*
* @param key the directive key (e.g., "foreach CHARSET")
* @param value the directive value (e.g., {@code "UTF-8" -> UTF_8, "ISO-8859-1" -> ISO_8859_1})
* @param lineNumber the line number for error reporting
*/
private void parseForeachDirective(String key, String value, int lineNumber) throws HintParseException {
String varName = key.substring("foreach ".length()).trim(); //$NON-NLS-1$
if (varName.isEmpty()) {
throw new HintParseException("foreach directive requires a variable name", lineNumber); //$NON-NLS-1$
}
Map<String, String> mappings = new LinkedHashMap<>();
List<String> entries = splitForeachEntries(value);
for (String entry : entries) {
entry = entry.trim();
if (entry.isEmpty()) {
continue;
}
int arrowIdx = entry.indexOf("->"); //$NON-NLS-1$
if (arrowIdx < 0) {
throw new HintParseException(
"foreach entry must use 'key -> value' syntax: " + entry, lineNumber); //$NON-NLS-1$
}
String entryKey = entry.substring(0, arrowIdx).trim();
String entryValue = entry.substring(arrowIdx + 2).trim();
// Strip quotes from key if present
if (entryKey.startsWith("\"") && entryKey.endsWith("\"")) { //$NON-NLS-1$ //$NON-NLS-2$
entryKey = entryKey.substring(1, entryKey.length() - 1);
}
mappings.put(entryKey, entryValue);
}
if (mappings.isEmpty()) {
throw new HintParseException("foreach directive has no entries", lineNumber); //$NON-NLS-1$
}
foreachVariables.put(varName, mappings);
}
/**
* Parses a {@code <!map>} directive and stores the variable mapping.
*
* <p>Syntax: {@code <!map MAPNAME: "key1" => "val1", "key2" => "val2", ...>}</p>
* <p>The map can then be used in rules as {@code #{MAPNAME}} (expands to the key)
* and {@code #{MAPNAME_VALUE}} (expands to the value). Rules containing map
* references are expanded for each entry in the map, similar to {@code <!foreach>}.</p>
*
* <p>The {@code =>} separator distinguishes map entries from foreach's {@code ->}.</p>
*
* @param key the directive key (e.g., "map charsetNames")
* @param value the directive value (e.g., {@code "UTF-8" => "StandardCharsets.UTF_8", ...})
* @param lineNumber the line number for error reporting
* @since 1.4.2
*/
private void parseMapDirective(String key, String value, int lineNumber) throws HintParseException {
String mapName = key.substring("map ".length()).trim(); //$NON-NLS-1$
if (mapName.isEmpty()) {
throw new HintParseException("map directive requires a name", lineNumber); //$NON-NLS-1$
}
Map<String, String> mappings = new LinkedHashMap<>();
List<String> entries = splitForeachEntries(value); // reuse comma-respecting split
for (String entry : entries) {
entry = entry.trim();
if (entry.isEmpty()) {
continue;
}
int arrowIdx = entry.indexOf("=>"); //$NON-NLS-1$
if (arrowIdx < 0) {
throw new HintParseException(
"map entry must use 'key => value' syntax: " + entry, lineNumber); //$NON-NLS-1$
}
String entryKey = entry.substring(0, arrowIdx).trim();
String entryValue = entry.substring(arrowIdx + 2).trim();
// Strip quotes from key and value if present
if (entryKey.startsWith("\"") && entryKey.endsWith("\"")) { //$NON-NLS-1$ //$NON-NLS-2$
entryKey = entryKey.substring(1, entryKey.length() - 1);
}
if (entryValue.startsWith("\"") && entryValue.endsWith("\"")) { //$NON-NLS-1$ //$NON-NLS-2$
entryValue = entryValue.substring(1, entryValue.length() - 1);
}
mappings.put(entryKey, entryValue);
}
if (mappings.isEmpty()) {
throw new HintParseException("map directive has no entries", lineNumber); //$NON-NLS-1$
}
mapVariables.put(mapName, mappings);
}
/**
* Splits foreach entries at commas, respecting quoted strings.
* Commas inside double quotes are not treated as entry separators.
*
* @param value the foreach value string (e.g., {@code "UTF-8" -> UTF_8, "ISO-8859-1" -> ISO_8859_1})
* @return list of entry strings
*/
private List<String> splitForeachEntries(String value) {
List<String> entries = new ArrayList<>();
boolean inQuotes = false;
int start = 0;
for (int c = 0; c < value.length(); c++) {
char ch = value.charAt(c);
if (ch == '"') {
inQuotes = !inQuotes;
} else if (ch == ',' && !inQuotes) {
entries.add(value.substring(start, c).trim());
start = c + 1;
}
}
// Add last entry
String last = value.substring(start).trim();
if (!last.isEmpty()) {
entries.add(last);
}
return entries;
}
/**
* Parses a rule block starting at the given line index.
*
* @return the next line index after the rule
*/
private int parseRule(HintFile hintFile, List<String> lines, int startIndex) throws HintParseException {
// Collect all lines until ;;
List<String> ruleLines = new ArrayList<>();
int i = startIndex;
boolean foundTerminator = false;
while (i < lines.size()) {
String line = lines.get(i).trim();
i++;
if (line.isEmpty()) {
continue;
}
if (";;".equals(line)) { //$NON-NLS-1$
foundTerminator = true;
break;
}
ruleLines.add(line);
}
if (ruleLines.isEmpty()) {
return i;
}
if (!foundTerminator) {
throw new HintParseException(
"Rule starting at line " + (startIndex + 1) + " is missing ';;' terminator", //$NON-NLS-1$ //$NON-NLS-2$
startIndex + 1);
}
// Check if the rule uses foreach variables — expand if so
String foreachVar = findForeachVariable(ruleLines);
if (foreachVar != null) {
expandForeachRule(hintFile, ruleLines, foreachVar, startIndex);
} else {
// Check if the rule uses map variables — expand if so
String mapVar = findMapVariable(ruleLines);
if (mapVar != null) {
expandMapRule(hintFile, ruleLines, mapVar, startIndex);
} else {
buildRule(hintFile, ruleLines, startIndex);
}
}
return i;
}
/**
* Checks if any rule line contains a {@code ${VAR}} reference to a defined foreach variable.
*
* @return the variable name if found, or {@code null}
*/
private String findForeachVariable(List<String> ruleLines) {
for (String line : ruleLines) {
for (String varName : foreachVariables.keySet()) {
if (line.contains("${" + varName + "}") || line.contains("${" + varName + "_CONSTANT}")) { //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
return varName;
}
}
}
return null;
}
/**
* Expands a rule template for each entry in the foreach variable's mapping.
*
* <p>{@code ${VAR}} is replaced with the key (e.g., {@code "UTF-8"})
* and {@code ${VAR_CONSTANT}} is replaced with the value (e.g., {@code UTF_8}).</p>
*/
private void expandForeachRule(HintFile hintFile, List<String> ruleLines,
String varName, int startIndex) throws HintParseException {
Map<String, String> mappings = foreachVariables.get(varName);
String keyPlaceholder = "${" + varName + "}"; //$NON-NLS-1$ //$NON-NLS-2$
String valuePlaceholder = "${" + varName + "_CONSTANT}"; //$NON-NLS-1$ //$NON-NLS-2$
for (Map.Entry<String, String> entry : mappings.entrySet()) {
List<String> expandedLines = new ArrayList<>();
for (String line : ruleLines) {
String expanded = line.replace(keyPlaceholder, entry.getKey())
.replace(valuePlaceholder, entry.getValue());
expandedLines.add(expanded);
}
buildRule(hintFile, expandedLines, startIndex);
}
}
/**
* Checks if any rule line contains a {@code #{MAP}} reference to a defined map variable.
*
* @return the map name if found, or {@code null}
* @since 1.4.2
*/
private String findMapVariable(List<String> ruleLines) {
for (String line : ruleLines) {
for (String mapName : mapVariables.keySet()) {
if (line.contains("#{" + mapName + "}") || line.contains("#{" + mapName + "_VALUE}")) { //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
return mapName;
}
}
}
return null;
}
/**
* Expands a rule template for each entry in the map variable's mapping.
*
* <p>{@code #{MAP}} is replaced with the key and
* {@code #{MAP_VALUE}} is replaced with the value.</p>
*
* @since 1.4.2
*/
private void expandMapRule(HintFile hintFile, List<String> ruleLines,
String mapName, int startIndex) throws HintParseException {
Map<String, String> mappings = mapVariables.get(mapName);
String keyPlaceholder = "#{" + mapName + "}"; //$NON-NLS-1$ //$NON-NLS-2$
String valuePlaceholder = "#{" + mapName + "_VALUE}"; //$NON-NLS-1$ //$NON-NLS-2$
for (Map.Entry<String, String> entry : mappings.entrySet()) {
List<String> expandedLines = new ArrayList<>();
for (String line : ruleLines) {
String expanded = line.replace(keyPlaceholder, entry.getKey())
.replace(valuePlaceholder, entry.getValue());
expandedLines.add(expanded);
}
buildRule(hintFile, expandedLines, startIndex);
}
}
/**
* Builds a single transformation rule from the given rule lines and adds it to the hint file.
*/
private void buildRule(HintFile hintFile, List<String> ruleLines, int startIndex) throws HintParseException {
// Parse the rule lines
String description = null;
String ruleId = null;
Severity ruleSeverity = null;
String sourcePatternText;
GuardExpression sourceGuard = null;
List<RewriteAlternative> alternatives = new ArrayList<>();
int ruleLineIdx = 0;
// Check for per-rule metadata annotations: @id:, @severity:
while (ruleLineIdx < ruleLines.size()) {
String metaLine = ruleLines.get(ruleLineIdx).trim();
if (metaLine.startsWith("@id:")) { //$NON-NLS-1$
ruleId = metaLine.substring(4).trim();
if (ruleId.isBlank()) {
throw new HintParseException("Per-rule id must not be blank", startIndex + ruleLineIdx + 1); //$NON-NLS-1$
}
ruleLineIdx++;
} else if (metaLine.startsWith("@severity:")) { //$NON-NLS-1$
String severityStr = metaLine.substring(10).trim();
try {
ruleSeverity = Severity.valueOf(severityStr.toUpperCase(java.util.Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new HintParseException("Invalid per-rule severity: " + severityStr, startIndex + ruleLineIdx + 1); //$NON-NLS-1$
}
ruleLineIdx++;
} else {
break;
}
}
if (ruleLineIdx >= ruleLines.size()) {
throw new HintParseException("Rule has metadata annotations but no pattern", startIndex + 1); //$NON-NLS-1$
}
// Check for description prefix: "text":
String firstLine = ruleLines.get(ruleLineIdx);
if (firstLine.startsWith("\"") && firstLine.endsWith("\":")) { //$NON-NLS-1$ //$NON-NLS-2$
description = firstLine.substring(1, firstLine.length() - 2);
ruleLineIdx++;
if (ruleLineIdx >= ruleLines.size()) {
throw new HintParseException("Rule has description but no pattern", startIndex + 1); //$NON-NLS-1$
}
firstLine = ruleLines.get(ruleLineIdx);
}
// Parse source pattern line (may have :: guard)
GuardSplit sourceAndGuard = splitGuard(firstLine);
sourcePatternText = sourceAndGuard.patternText().trim();
if (sourceAndGuard.hasGuard()) {
sourceGuard = guardParser.parse(sourceAndGuard.guardText().trim());
}
ruleLineIdx++;
// Check for continuation lines with guard expressions (start with ::)
while (ruleLineIdx < ruleLines.size()) {
String nextLine = ruleLines.get(ruleLineIdx).trim();
if (!nextLine.startsWith("::")) { //$NON-NLS-1$
break;
}
String guardText = nextLine.substring(2).trim();
if (sourceGuard != null) {
// Combine with existing guard using AND
sourceGuard = new GuardExpression.And(
sourceGuard, guardParser.parse(guardText));
} else {
sourceGuard = guardParser.parse(guardText);
}
ruleLineIdx++;
}
// Parse rewrite alternatives (lines starting with =>)
while (ruleLineIdx < ruleLines.size()) {
String altLine = ruleLines.get(ruleLineIdx);
if (!altLine.startsWith("=>")) { //$NON-NLS-1$
// Might be continuation of source pattern - for now, error
throw new HintParseException(
"Expected '=>' or ';;' but found: " + altLine, //$NON-NLS-1$
startIndex + ruleLineIdx + 1);
}
String altContent = altLine.substring(2).trim();
// Handle => on its own line: read replacement from the next line(s)
if (altContent.isEmpty() && ruleLineIdx + 1 < ruleLines.size()) {
ruleLineIdx++;
altContent = ruleLines.get(ruleLineIdx).trim();
}
// Accumulate multiline replacement: continuation lines that don't
// start with '=>' are part of the current replacement text.
// This enables NetBeans-compatible multiline expressions.
StringBuilder altContentBuilder = new StringBuilder(altContent);
while (ruleLineIdx + 1 < ruleLines.size()) {
String nextLine = ruleLines.get(ruleLineIdx + 1);
if (nextLine.startsWith("=>")) { //$NON-NLS-1$
break; // Next alternative — stop accumulating
}
ruleLineIdx++;
altContentBuilder.append('\n').append(nextLine.trim());
}
altContent = altContentBuilder.toString();
if (altContent.isEmpty()) {
throw new HintParseException(
"Missing replacement after '=>'", //$NON-NLS-1$
startIndex + ruleLineIdx + 1);
}
GuardSplit altAndGuard = splitGuard(altContent);
String replacementPattern = altAndGuard.patternText().trim();
GuardExpression altGuard = null;
String embeddedFixName = null;
// Check for embedded fix function reference: => <?fixName?>
// Use the same validation as isFixFunctionReference() to distinguish
// fix refs (<?identifier?>) from embedded Java blocks (<? code ?>)
if (replacementPattern.startsWith("<?") && replacementPattern.endsWith("?>")) { //$NON-NLS-1$ //$NON-NLS-2$
String inner = replacementPattern.substring(2, replacementPattern.length() - 2);
String trimmedInner = inner.trim();
if (!trimmedInner.isEmpty() && inner.equals(trimmedInner)
&& isValidJavaIdentifier(trimmedInner)) {
embeddedFixName = trimmedInner;
}
}
if (altAndGuard.hasGuard()) {
String guardText = altAndGuard.guardText().trim();
if ("otherwise".equals(guardText)) { //$NON-NLS-1$
altGuard = null; // otherwise = unconditional
} else {
altGuard = guardParser.parse(guardText);
}
}
alternatives.add(new RewriteAlternative(replacementPattern, altGuard, embeddedFixName));
ruleLineIdx++;
}
// Determine PatternKind from the source pattern text
PatternKind kind = inferPatternKind(sourcePatternText);
Pattern sourcePattern = Pattern.of(sourcePatternText, kind);
// FQN-based import inference: automatically derive imports from
// fully qualified names in source and replacement patterns
ImportDirective currentImports = new ImportDirective();
if (!alternatives.isEmpty()) {
List<String> replacementTexts = new ArrayList<>();
for (RewriteAlternative alt : alternatives) {
replacementTexts.add(alt.replacementPattern());
}
currentImports = ImportDirective.inferFromFqnPatterns(sourcePatternText, replacementTexts);
}
TransformationRule rule = new TransformationRule(
ruleId, description, sourcePattern, sourceGuard, alternatives,
currentImports.isEmpty() ? null : currentImports, ruleSeverity);
hintFile.addRule(rule);
}
/**
* Splits a line into pattern text and guard expression at the {@code ::} separator.
*
* @param line the line to split
* @return a {@link GuardSplit} with the pattern text and optional guard text
*/
private GuardSplit splitGuard(String line) {
// Find :: that is not inside parentheses or quotes
int depth = 0;
boolean inQuote = false;
for (int c = 0; c < line.length() - 1; c++) {
char ch = line.charAt(c);
if (ch == '"') {
inQuote = !inQuote;
} else if (!inQuote) {
if (ch == '(') {
depth++;
} else if (ch == ')') {
depth--;
} else if (depth == 0 && ch == ':' && line.charAt(c + 1) == ':') {
return new GuardSplit(
line.substring(0, c),
line.substring(c + 2));
}
}
}
return new GuardSplit(line, null);
}
/**
* Infers the {@link PatternKind} from the source pattern text.
*
* <p>Heuristics:</p>
* <ul>
* <li>Starts with {@code @} → ANNOTATION</li>
* <li>Starts with {@code import } → IMPORT</li>
* <li>Starts with {@code new } → CONSTRUCTOR</li>
* <li>Starts with {@code {}} → BLOCK</li>
* <li>Looks like a method declaration (return type + name + parens) → METHOD_DECLARATION</li>
* <li>Contains name + parens → METHOD_CALL</li>
* <li>Ends with {@code ;} → STATEMENT</li>
* <li>Default → EXPRESSION</li>
* </ul>
*/
private PatternKind inferPatternKind(String patternText) {
String trimmed = patternText.trim();
if (trimmed.startsWith("@")) { //$NON-NLS-1$
return PatternKind.ANNOTATION;
}
if (trimmed.startsWith("import ")) { //$NON-NLS-1$
return PatternKind.IMPORT;
}
if (trimmed.startsWith("new ")) { //$NON-NLS-1$
return PatternKind.CONSTRUCTOR;
}
if (trimmed.startsWith("{")) { //$NON-NLS-1$
return PatternKind.BLOCK;
}
// Method declaration: starts with a return type keyword followed by name and parens
// e.g., "void $name($params$)", "String getName()", "int $name()"
if (looksLikeMethodDeclaration(trimmed)) {
return PatternKind.METHOD_DECLARATION;
}
// Method call: contains '(' and '.' or is a simple name with parens
if (trimmed.contains("(") && trimmed.contains(")") && !trimmed.endsWith(";")) { //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
return PatternKind.METHOD_CALL;
}
// Variable declaration: "$Type $var = $init;" or "$Type $var;"
// Must be checked before generic STATEMENT to distinguish declarations
if (looksLikeDeclaration(trimmed)) {
return PatternKind.DECLARATION;
}
if (trimmed.endsWith(";")) { //$NON-NLS-1$
return PatternKind.STATEMENT;
}
return PatternKind.EXPRESSION;
}
/**
* Checks if a pattern looks like a method declaration.
*
* <p>A method declaration pattern has the form:
* {@code [modifiers] returnType name(params)} where returnType is a Java
* type keyword or identifier, name is an identifier or placeholder, and
* params may include placeholders.</p>
*
* <p>Examples that match:</p>
* <ul>
* <li>{@code void $name($params$)}</li>
* <li>{@code void dispose()}</li>
* <li>{@code String getName()}</li>
* <li>{@code public void setUp()}</li>
* <li>{@code int $name()}</li>
* </ul>
*/
private static boolean looksLikeMethodDeclaration(String trimmed) {
// Must contain parens
if (!trimmed.contains("(") || !trimmed.contains(")")) { //$NON-NLS-1$ //$NON-NLS-2$
return false;
}
// Must not contain '.' (method calls have receiver.method())
// Method declarations don't have dots in the signature part before '('
String beforeParens = trimmed.substring(0, trimmed.indexOf('(')).trim();
if (beforeParens.contains(".")) { //$NON-NLS-1$
return false;
}
// Split into space-separated tokens before '('
String[] tokens = beforeParens.split("\\s+"); //$NON-NLS-1$
// Need at least 2 tokens: return type and method name
// e.g., "void $name" or "public void setUp"
if (tokens.length < 2) {
return false;
}
// Check if any token is a primitive type or 'void' — strong indicator
for (String token : tokens) {
if (RETURN_TYPE_KEYWORDS.contains(token)) {
return true;
}
}
// Heuristic: if the second-to-last token looks like a type (starts with uppercase
// or is a placeholder) and the last token looks like a name, treat as declaration
String possibleType = tokens[tokens.length - 2];
if (Character.isUpperCase(possibleType.charAt(0)) || possibleType.startsWith("$")) { //$NON-NLS-1$
return true;
}
return false;
}
/**
* Checks if a pattern looks like a variable declaration statement.
*
* <p>A declaration pattern has the form {@code $Type $var = $init;}
* or {@code $Type $var;} where $Type is a type placeholder or FQN,
* and $var is a variable name placeholder.</p>
*
* <p>Examples that match:</p>
* <ul>
* <li>{@code $Type $var = $init;}</li>
* <li>{@code $Type $var;}</li>
* </ul>
*
* @param trimmed the trimmed pattern text
* @return {@code true} if the pattern looks like a variable declaration
* @since 1.3.12
*/
private static boolean looksLikeDeclaration(String trimmed) {
// Must end with semicolon
if (!trimmed.endsWith(";")) { //$NON-NLS-1$
return false;
}
// Must NOT contain parens (those are method calls/declarations)
if (trimmed.contains("(") || trimmed.contains(")")) { //$NON-NLS-1$ //$NON-NLS-2$
return false;
}
// Check for "= " indicating an initializer
// Pattern: "$Type $var = $init;" or "$Type $var;"
String withoutSemicolon = trimmed.substring(0, trimmed.length() - 1).trim();
String declarationPart;
if (withoutSemicolon.contains("=")) { //$NON-NLS-1$
declarationPart = withoutSemicolon.substring(0, withoutSemicolon.indexOf('=')).trim();
} else {
declarationPart = withoutSemicolon;
}
// Must have exactly 2 space-separated tokens: type and name
String[] tokens = declarationPart.split("\\s+"); //$NON-NLS-1$
if (tokens.length != 2) {
return false;
}
// First token must be a type: placeholder ($Type), FQN, or uppercase identifier
String typeToken = tokens[0];
// Second token must be a variable name: placeholder ($var) or lowercase identifier
String nameToken = tokens[1];
boolean typeIsPlaceholder = typeToken.startsWith("$"); //$NON-NLS-1$
boolean typeIsUppercase = !typeToken.isEmpty() && Character.isUpperCase(typeToken.charAt(0));
boolean typeIsFqn = typeToken.contains("."); //$NON-NLS-1$
boolean nameIsPlaceholder = nameToken.startsWith("$"); //$NON-NLS-1$
return (typeIsPlaceholder || typeIsUppercase || typeIsFqn)
&& (nameIsPlaceholder || !nameToken.isEmpty());
}
/**
* Java primitive types and void — used to detect method declaration patterns.
*/
private static final Set<String> RETURN_TYPE_KEYWORDS = Set.of(
"void", "int", "long", "short", "byte", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$
"float", "double", "boolean", "char" //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
);
/**
* Checks whether the content inside {@code <? ?>} is a fix function reference
* (a simple Java identifier) rather than actual embedded Java code.
*
* <p>Fix function references like {@code <?customFix?>} contain only a single
* identifier with no leading/trailing whitespace between the delimiters.
* They are used in replacement patterns like {@code => <?customFix?>}.</p>
*
* <p>Note: {@code <? code ?>} (with spaces after {@code <?}) is treated as
* embedded Java code, not a fix function reference.</p>
*
* @param content the text between {@code <?} and {@code ?>}
* @return {@code true} if the content is a fix function reference
*/
private static boolean isFixFunctionReference(String content) {
if (content.isEmpty()) {
return false;
}
// Fix function references must not have leading/trailing whitespace
// This distinguishes <?fixName?> from <? code ?> (embedded Java)
if (Character.isWhitespace(content.charAt(0))
|| Character.isWhitespace(content.charAt(content.length() - 1))) {
return false;
}
return isValidJavaIdentifier(content);
}
/**
* Checks whether the given string is a valid Java identifier.
*
* @param text the text to check
* @return {@code true} if the text is a valid Java identifier
*/
static boolean isValidJavaIdentifier(String text) {
if (text == null || text.isEmpty()) {
return false;
}
if (!Character.isJavaIdentifierStart(text.charAt(0))) {
return false;
}
for (int i = 1; i < text.length(); i++) {
if (!Character.isJavaIdentifierPart(text.charAt(i))) {
return false;
}
}
return true;
}
/**
* Exception thrown when parsing a {@code .sandbox-hint} file fails.
*/
public static class HintParseException extends Exception {
private static final long serialVersionUID = 1L;
private final int lineNumber;
/**
* Creates a new parse exception.
*
* @param message the error message
* @param lineNumber the line number where the error occurred
*/
public HintParseException(String message, int lineNumber) {
super(message + " (line " + lineNumber + ")"); //$NON-NLS-1$ //$NON-NLS-2$
this.lineNumber = lineNumber;
}
/**
* Returns the line number where the error occurred.
*
* @return the line number
*/
public int getLineNumber() {
return lineNumber;
}
}
}