StringCleanupGuards.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
*******************************************************************************/
package org.sandbox.jdt.triggerpattern.internal;
import java.util.Set;
import org.eclipse.jdt.core.dom.ASTMatcher;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.CastExpression;
import org.eclipse.jdt.core.dom.Comment;
import org.eclipse.jdt.core.dom.ConditionalExpression;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.IBinding;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import org.eclipse.jdt.core.dom.InfixExpression;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.PrefixExpression;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.StringLiteral;
import org.sandbox.jdt.triggerpattern.api.GuardContext;
/**
* Binding-dependent safety contracts for the two JDT reference String cleanups.
*
* <p>These are deliberately narrower than JDT's AST rewrite implementations:
* text replacements decline internal comments, and substring accepts stable
* local/literal receivers and passive local expressions for the start index.
* Unknown or recovered bindings never authorize a rewrite.</p>
*/
final class StringCleanupGuards {
private static final String STRING= "java.lang.String"; //$NON-NLS-1$
private static final Set<String> STRIP_METHODS= Set.of("strip", "stripLeading", "stripTrailing"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
private StringCleanupGuards() {
}
static boolean substringSafe(GuardContext context, Object... arguments) {
if (arguments.length != 0 || hasInternalComment(context)
|| !(context.getMatchedNode() instanceof MethodInvocation substring)
|| !stringMethod(context, "stringSubstringSafe", substring, "substring", "int", "int")) { //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
return false;
}
Expression receiver= unparenthesize(substring.getExpression());
Expression start= (Expression) substring.arguments().get(0);
Expression end= unparenthesize((Expression) substring.arguments().get(1));
if (!(end instanceof MethodInvocation length)
|| !stringMethod(context, "stringSubstringSafe", length, "length") //$NON-NLS-1$ //$NON-NLS-2$
|| !isStableLocalReceiver(receiver) || !isStableExpression(start)) {
return false;
}
Expression endReceiver= unparenthesize(length.getExpression());
if (receiver instanceof SimpleName name && endReceiver instanceof SimpleName endName) {
IBinding binding= name.resolveBinding();
return binding != null && binding.isEqualTo(endName.resolveBinding());
}
return endReceiver != null && receiver.subtreeMatch(new ASTMatcher(), endReceiver);
}
static boolean isBlankSafe(GuardContext context, Object... arguments) {
if (arguments.length != 0 || hasInternalComment(context)) {
return false;
}
ASTNode node= context.getMatchedNode();
if (node instanceof MethodInvocation invocation) {
if (stringMethod(context, "stringIsBlankSafe", invocation, "isEmpty")) { //$NON-NLS-1$ //$NON-NLS-2$
return stringStrip(context, invocation.getExpression());
}
if (stringMethod(context, "stringIsBlankSafe", invocation, "equals", "java.lang.Object")) { //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
Expression argument= (Expression) invocation.arguments().get(0);
return (isEmptyLiteral(invocation.getExpression()) && stringStrip(context, argument))
|| (isEmptyLiteral(argument) && stringStrip(context, invocation.getExpression()));
}
}
if (node instanceof InfixExpression infix && !infix.hasExtendedOperands()
&& isSupportedLengthComparison(infix)
&& unparenthesize(infix.getLeftOperand()) instanceof MethodInvocation length
&& stringMethod(context, "stringIsBlankSafe", length, "length")) { //$NON-NLS-1$ //$NON-NLS-2$
if (infix.getParent() instanceof LambdaExpression
&& (infix.getOperator() == InfixExpression.Operator.EQUALS
|| infix.getOperator() == InfixExpression.Operator.LESS_EQUALS
|| infix.getOperator() == InfixExpression.Operator.LESS)) {
// A bare isBlank() call would also make the lambda void-compatible.
return false;
}
return stringStrip(context, length.getExpression());
}
return false;
}
private static boolean isSupportedLengthComparison(InfixExpression infix) {
Object constant= infix.getRightOperand().resolveConstantExpressionValue();
if (Integer.valueOf(0).equals(constant)) {
return infix.getOperator() == InfixExpression.Operator.EQUALS
|| infix.getOperator() == InfixExpression.Operator.LESS_EQUALS
|| infix.getOperator() == InfixExpression.Operator.NOT_EQUALS
|| infix.getOperator() == InfixExpression.Operator.GREATER;
}
return Integer.valueOf(1).equals(constant)
&& (infix.getOperator() == InfixExpression.Operator.LESS
|| infix.getOperator() == InfixExpression.Operator.GREATER_EQUALS);
}
private static boolean stringStrip(GuardContext context, Expression expression) {
Expression unwrapped= unparenthesize(expression);
return unwrapped instanceof MethodInvocation strip
&& STRIP_METHODS.contains(strip.getName().getIdentifier())
&& stringMethod(context, "stringIsBlankSafe", strip, strip.getName().getIdentifier()); //$NON-NLS-1$
}
private static boolean stringMethod(GuardContext context, String guard,
MethodInvocation invocation, String name, String... parameterTypes) {
if (!name.equals(invocation.getName().getIdentifier())
|| invocation.arguments().size() != parameterTypes.length) {
return false;
}
IMethodBinding binding= invocation.resolveMethodBinding();
if (binding == null || binding.isRecovered() || binding.getDeclaringClass() == null
|| binding.getDeclaringClass().isRecovered()) {
context.markUnknown(guard, "Cannot resolve String." + name + " method signature"); //$NON-NLS-1$ //$NON-NLS-2$
return false;
}
if (!STRING.equals(binding.getDeclaringClass().getErasure().getQualifiedName())
|| !name.equals(binding.getName())) {
return false;
}
ITypeBinding[] parameters= binding.getParameterTypes();
if (parameters.length != parameterTypes.length) {
return false;
}
for (int i= 0; i < parameters.length; i++) {
if (parameters[i].isRecovered()) {
context.markUnknown(guard, "Cannot resolve String." + name + " parameter type"); //$NON-NLS-1$ //$NON-NLS-2$
return false;
}
if (!parameterTypes[i].equals(parameters[i].getErasure().getQualifiedName())) {
return false;
}
}
return true;
}
private static boolean isEmptyLiteral(Expression expression) {
return unparenthesize(expression) instanceof StringLiteral literal && literal.getLiteralValue().isEmpty();
}
private static boolean isStableLocalReceiver(Expression expression) {
return expression instanceof StringLiteral || expression instanceof SimpleName name && isLocal(name);
}
/** Declines calls, fields, arrays, allocations and writes across the removed read. */
private static boolean isStableExpression(Expression expression) {
Expression node= unparenthesize(expression);
if (node == null) {
return false;
}
if (node instanceof SimpleName name) {
return isLocal(name);
}
if (node.getNodeType() == ASTNode.NUMBER_LITERAL || node.getNodeType() == ASTNode.CHARACTER_LITERAL
|| node.getNodeType() == ASTNode.BOOLEAN_LITERAL) {
return true;
}
if (node instanceof InfixExpression infix) {
if (!isStableExpression(infix.getLeftOperand()) || !isStableExpression(infix.getRightOperand())) {
return false;
}
for (Object operand : infix.extendedOperands()) {
if (!isStableExpression((Expression) operand)) {
return false;
}
}
return true;
}
if (node instanceof PrefixExpression prefix) {
return prefix.getOperator() != PrefixExpression.Operator.INCREMENT
&& prefix.getOperator() != PrefixExpression.Operator.DECREMENT
&& isStableExpression(prefix.getOperand());
}
if (node instanceof ConditionalExpression conditional) {
return isStableExpression(conditional.getExpression())
&& isStableExpression(conditional.getThenExpression())
&& isStableExpression(conditional.getElseExpression());
}
return node instanceof CastExpression cast && cast.getType().isPrimitiveType()
&& isStableExpression(cast.getExpression());
}
private static boolean isLocal(SimpleName name) {
return name.resolveBinding() instanceof IVariableBinding variable && !variable.isRecovered()
&& !variable.isField() && !variable.isEnumConstant();
}
private static Expression unparenthesize(Expression expression) {
Expression result= expression;
while (result instanceof ParenthesizedExpression parenthesized) {
result= parenthesized.getExpression();
}
return result;
}
/** Text substitution cannot relocate comments inside the replaced AST range. */
private static boolean hasInternalComment(GuardContext context) {
ASTNode node= context.getMatchedNode();
if (node == null || context.getCompilationUnit() == null
|| context.getCompilationUnit().getCommentList() == null) {
return true;
}
int start= node.getStartPosition();
int end= start + node.getLength();
for (Object value : context.getCompilationUnit().getCommentList()) {
Comment comment= (Comment) value;
if (comment.getStartPosition() >= start && comment.getStartPosition() < end) {
return true;
}
}
return false;
}
}