BindingSubstitution.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
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*******************************************************************************/
package org.sandbox.jdt.triggerpattern.internal;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.ToolFactory;
import org.eclipse.jdt.core.compiler.IScanner;
import org.eclipse.jdt.core.compiler.ITerminalSymbols;
import org.eclipse.jdt.core.compiler.InvalidInputException;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Assignment;
import org.eclipse.jdt.core.dom.CastExpression;
import org.eclipse.jdt.core.dom.ConditionalExpression;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.ForStatement;
import org.eclipse.jdt.core.dom.InfixExpression;
import org.eclipse.jdt.core.dom.InstanceofExpression;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodReference;
import org.eclipse.jdt.core.dom.PatternInstanceofExpression;
import org.eclipse.jdt.core.dom.PostfixExpression;
import org.eclipse.jdt.core.dom.PrefixExpression;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.SwitchExpression;
import org.sandbox.jdt.triggerpattern.api.Match;
/** Substitutes Java identifier tokens once, preserving literals and expression grouping. */
public final class BindingSubstitution {
private BindingSubstitution() {
}
private record ExpressionRange(int start, int end) {
}
public static String substitute(String pattern, Match match) {
if (pattern == null || match.getBindings().isEmpty()) {
return pattern;
}
IScanner scanner = ToolFactory.createScanner(false, false, false, JavaCore.VERSION_25);
scanner.setSource(pattern.toCharArray());
StringBuilder result = new StringBuilder();
List<ExpressionRange> groupedExpressions = new ArrayList<>();
int copiedUntil = 0;
try {
for (int token = scanner.getNextToken(); token != ITerminalSymbols.TokenNameEOF; token = scanner.getNextToken()) {
int start = scanner.getCurrentTokenStartPosition();
if (token != ITerminalSymbols.TokenNameIdentifier || start < copiedUntil) {
continue;
}
String name = new String(scanner.getCurrentTokenSource());
Object value = match.getBindings().get(name);
if (value == null) {
continue;
}
int end = scanner.getCurrentTokenEndPosition() + 1;
StringBuilder replacement = new StringBuilder();
int replacementOffset = result.length() + start - copiedUntil;
if (value instanceof List<?> nodes) {
if (pattern.startsWith(".length", end) && (end + 7 == pattern.length() //$NON-NLS-1$
|| !Character.isJavaIdentifierPart(pattern.charAt(end + 7)))) {
replacement.append(nodes.size());
end += 7;
} else if (end < pattern.length() && pattern.charAt(end) == '[') {
int closing = pattern.indexOf(']', end);
if (closing < 0) {
continue;
}
try {
int index = Integer.parseInt(pattern.substring(end + 1, closing));
if (index < 0) {
index += nodes.size();
}
if (index < 0 || index >= nodes.size()) {
continue;
}
appendValue(replacement, nodes.get(index), groupedExpressions, replacementOffset);
end = closing + 1;
} catch (NumberFormatException invalidIndex) {
continue;
}
} else {
boolean statements = nodes.stream().allMatch(Statement.class::isInstance);
for (Object node : nodes) {
if (!replacement.isEmpty()) {
replacement.append(statements ? "\n" : ", "); //$NON-NLS-1$ //$NON-NLS-2$
}
appendValue(replacement, node, groupedExpressions, replacementOffset);
}
if (statements) {
int semicolon = end;
while (semicolon < pattern.length() && Character.isWhitespace(pattern.charAt(semicolon))) {
semicolon++;
}
if (semicolon < pattern.length() && pattern.charAt(semicolon) == ';') {
end = semicolon + 1;
}
}
}
} else {
appendValue(replacement, value, groupedExpressions, replacementOffset);
}
result.append(pattern, copiedUntil, start).append(replacement);
copiedUntil = end;
}
} catch (InvalidInputException invalidJava) {
throw new IllegalArgumentException("Replacement is not lexically valid Java", invalidJava); //$NON-NLS-1$
}
return groupExpressionBindings(result.append(pattern, copiedUntil, pattern.length()).toString(),
groupedExpressions, match);
}
private static void appendValue(StringBuilder target, Object value, List<ExpressionRange> groupedExpressions,
int offset) {
String text = value instanceof ASTNode node ? node.toString().stripTrailing() : String.valueOf(value);
if (value instanceof Expression expression && requiresGrouping(expression)) {
groupedExpressions.add(new ExpressionRange(offset + target.length(), offset + target.length() + text.length()));
}
target.append(text);
}
private static String groupExpressionBindings(String source, List<ExpressionRange> groupedExpressions, Match match) {
if (groupedExpressions.isEmpty()) {
return source;
}
// Parse the substituted text before grouping: a parenthesized assignment or
// update is not a legal StatementExpression. Exact source ranges distinguish
// a whole statement/for-update from a binding nested inside that expression.
Set<ExpressionRange> statementExpressions = new HashSet<>();
var destination = match.getMatchedNode().getLocationInParent();
boolean statementExpressionDestination = destination == ExpressionStatement.EXPRESSION_PROPERTY
|| destination == ForStatement.INITIALIZERS_PROPERTY || destination == ForStatement.UPDATERS_PROPERTY
|| destination == LambdaExpression.BODY_PROPERTY;
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
parser.setKind(ASTParser.K_EXPRESSION);
parser.setSource(source.toCharArray());
var options = JavaCore.getOptions();
JavaCore.setComplianceOptions(JavaCore.VERSION_25, options);
parser.setCompilerOptions(options);
ASTNode context = parser.createAST(null);
if (!(context instanceof Expression) || !hasCompleteSourceRange(context, source)
|| (context.getFlags() & (ASTNode.MALFORMED | ASTNode.RECOVERED)) != 0) {
// Lambdas are expressions, not standalone statements: statement recovery
// can drop their bodies. Use statements only when the whole template
// cannot be parsed as one complete expression.
parser = ASTParser.newParser(AST.getJLSLatest());
parser.setKind(ASTParser.K_STATEMENTS);
parser.setSource(source.toCharArray());
parser.setCompilerOptions(options);
parser.setStatementsRecovery(true);
context = parser.createAST(null);
}
context.accept(new ASTVisitor() {
@Override
public void preVisit(ASTNode node) {
// A bare expression needs its original AST destination; parser recovery
// alone must not turn a nested replacement into a statement expression.
if (statementExpressionDestination && node instanceof Expression expression
&& hasCompleteSourceRange(expression, source)) {
addStatementExpression(expression);
}
}
@Override
public boolean visit(ExpressionStatement node) {
int end = node.getStartPosition() + node.getLength();
if (end > 0 && end <= source.length() && source.charAt(end - 1) == ';') {
addStatementExpression(node.getExpression());
}
return true;
}
@Override
public boolean visit(ForStatement node) {
for (Object initializer : node.initializers()) {
addStatementExpression((Expression) initializer);
}
for (Object updater : node.updaters()) {
addStatementExpression((Expression) updater);
}
return true;
}
@Override
public boolean visit(LambdaExpression node) {
if (node.getBody() instanceof Expression body) {
addStatementExpression(body);
}
return true;
}
private void addStatementExpression(Expression expression) {
if (expression instanceof Assignment || expression instanceof PostfixExpression
|| expression instanceof PrefixExpression prefix
&& (prefix.getOperator() == PrefixExpression.Operator.INCREMENT
|| prefix.getOperator() == PrefixExpression.Operator.DECREMENT)) {
statementExpressions.add(new ExpressionRange(expression.getStartPosition(),
expression.getStartPosition() + expression.getLength()));
}
}
});
StringBuilder result = new StringBuilder();
int copiedUntil = 0;
for (ExpressionRange expression : groupedExpressions) {
result.append(source, copiedUntil, expression.start());
if (statementExpressions.contains(expression)) {
result.append(source, expression.start(), expression.end());
} else {
result.append('(').append(source, expression.start(), expression.end()).append(')');
}
copiedUntil = expression.end();
}
return result.append(source, copiedUntil, source.length()).toString();
}
private static boolean hasCompleteSourceRange(ASTNode node, String source) {
int start = node.getStartPosition();
int end = start + node.getLength();
return start >= 0 && end <= source.length() && isJavaTrivia(source.substring(0, start))
&& isJavaTrivia(source.substring(end));
}
private static boolean isJavaTrivia(String text) {
IScanner scanner = ToolFactory.createScanner(false, false, false, JavaCore.VERSION_25);
scanner.setSource(text.toCharArray());
try {
return scanner.getNextToken() == ITerminalSymbols.TokenNameEOF;
} catch (InvalidInputException invalidJava) {
return false;
}
}
private static boolean requiresGrouping(Expression expression) {
return expression instanceof InfixExpression || expression instanceof ConditionalExpression
|| expression instanceof Assignment || expression instanceof CastExpression
|| expression instanceof InstanceofExpression || expression instanceof LambdaExpression
|| expression instanceof PatternInstanceofExpression
|| expression instanceof PrefixExpression || expression instanceof PostfixExpression
|| expression instanceof MethodReference || expression instanceof SwitchExpression;
}
}