CollectionModificationDetector.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
*******************************************************************************/
package org.sandbox.jdt.internal.corext.fix.helper;
import java.util.Set;
import org.eclipse.jdt.core.dom.ASTMatcher;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.FieldAccess;
import org.eclipse.jdt.core.dom.IBinding;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SuperFieldAccess;
import org.eclipse.jdt.core.dom.ThisExpression;
/**
* Shared utility for detecting structural modifications on the source being
* iterated.
*
* <p>The production API compares Java bindings rather than spelling. It handles
* local variables, fields, repeated getter invocations, conventional no-argument
* getters for an iterated field, and map view expressions such as
* {@code map.keySet()} whose structural owner is {@code map}.</p>
*
* @see <a href="https://github.com/carstenartur/sandbox/issues/670">Issue #670</a>
* @since 1.0.0
*/
public final class CollectionModificationDetector {
private static final Set<String> MODIFYING_METHODS= Set.of(
"remove", "add", "clear", "set", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
"addAll", "removeAll", "retainAll", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
"removeIf", "replaceAll", "sort", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
"put", "putAll", "putIfAbsent", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
"compute", "computeIfAbsent", "computeIfPresent", //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
"merge", "replace"); //$NON-NLS-1$ //$NON-NLS-2$
private static final Set<String> MAP_VIEW_METHODS= Set.of(
"entrySet", "keySet", "values"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
private static final String[] GETTER_PREFIXES= {
"get", "fetch", "retrieve", "obtain" //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
};
private CollectionModificationDetector() {
// utility class
}
/**
* Checks whether an invocation structurally modifies the expression whose
* elements are being traversed.
*
* @param methodInvocation candidate mutation
* @param iteratedExpression expression from the enhanced-for or iterator source
* @return {@code true} only when the mutation receiver identifies the same source
*/
public static boolean isModification(MethodInvocation methodInvocation, Expression iteratedExpression) {
if (!MODIFYING_METHODS.contains(methodInvocation.getName().getIdentifier())) {
return false;
}
Expression receiver= methodInvocation.getExpression();
if (receiver == null || iteratedExpression == null) {
return false;
}
return referencesSameTarget(receiver, normalizeIteratedExpression(iteratedExpression));
}
/**
* Compatibility overload for existing focused detector tests. Production loop
* analysis should use {@link #isModification(MethodInvocation, Expression)}.
*
* @param methodInvocation candidate mutation
* @param collectionName simple source name
* @return whether the receiver denotes the named source
*/
public static boolean isModification(MethodInvocation methodInvocation, String collectionName) {
if (collectionName == null || !MODIFYING_METHODS.contains(methodInvocation.getName().getIdentifier())) {
return false;
}
Expression receiver= unwrap(methodInvocation.getExpression());
if (receiver instanceof SimpleName name) {
return collectionName.equals(name.getIdentifier());
}
if (receiver instanceof FieldAccess fieldAccess
&& fieldAccess.getExpression() instanceof ThisExpression) {
return collectionName.equals(fieldAccess.getName().getIdentifier());
}
if (receiver instanceof MethodInvocation getterInvocation) {
return matchesGetterPattern(getterInvocation, collectionName);
}
return false;
}
/**
* A map view iterates the backing map. Mutating that map while traversing
* {@code keySet()}, {@code values()}, or {@code entrySet()} must therefore block
* conversion as well.
*/
static Expression normalizeIteratedExpression(Expression expression) {
Expression current= unwrap(expression);
if (current instanceof MethodInvocation invocation
&& MAP_VIEW_METHODS.contains(invocation.getName().getIdentifier())
&& invocation.arguments().isEmpty()
&& invocation.getExpression() != null) {
return unwrap(invocation.getExpression());
}
return current;
}
private static boolean referencesSameTarget(Expression first, Expression second) {
Expression left= unwrap(first);
Expression right= unwrap(second);
if (matchesGetterAlias(left, right) || matchesGetterAlias(right, left)) {
return true;
}
IBinding leftBinding= resolveIdentityBinding(left);
IBinding rightBinding= resolveIdentityBinding(right);
if (leftBinding != null && rightBinding != null) {
String leftKey= bindingKey(leftBinding);
String rightKey= bindingKey(rightBinding);
return leftKey != null && leftKey.equals(rightKey);
}
if (leftBinding != null || rightBinding != null) {
return false;
}
return left.subtreeMatch(new ASTMatcher(), right);
}
private static boolean matchesGetterAlias(Expression possibleGetter, Expression possibleVariable) {
if (!(possibleGetter instanceof MethodInvocation invocation)) {
return false;
}
String variableName= variableName(possibleVariable);
return variableName != null && matchesGetterPattern(invocation, variableName);
}
private static String variableName(Expression expression) {
if (expression instanceof SimpleName name) {
return name.getIdentifier();
}
if (expression instanceof FieldAccess fieldAccess) {
return fieldAccess.getName().getIdentifier();
}
if (expression instanceof QualifiedName qualifiedName
&& qualifiedName.resolveBinding() instanceof IVariableBinding) {
return qualifiedName.getName().getIdentifier();
}
if (expression instanceof SuperFieldAccess superFieldAccess) {
return superFieldAccess.getName().getIdentifier();
}
return null;
}
private static IBinding resolveIdentityBinding(Expression expression) {
if (expression instanceof SimpleName name) {
return name.resolveBinding();
}
if (expression instanceof FieldAccess fieldAccess) {
return fieldAccess.resolveFieldBinding();
}
if (expression instanceof QualifiedName qualifiedName) {
return qualifiedName.resolveBinding();
}
if (expression instanceof SuperFieldAccess superFieldAccess) {
return superFieldAccess.resolveFieldBinding();
}
if (expression instanceof MethodInvocation invocation) {
return invocation.resolveMethodBinding();
}
return null;
}
private static String bindingKey(IBinding binding) {
if (binding instanceof IVariableBinding variableBinding) {
return variableBinding.getVariableDeclaration().getKey();
}
if (binding instanceof IMethodBinding methodBinding) {
return methodBinding.getMethodDeclaration().getKey();
}
return binding.getKey();
}
private static boolean matchesGetterPattern(MethodInvocation invocation, String collectionName) {
if (!invocation.arguments().isEmpty()) {
return false;
}
String methodName= invocation.getName().getIdentifier();
for (String prefix : GETTER_PREFIXES) {
if (methodName.startsWith(prefix) && methodName.length() > prefix.length()) {
String propertyName= methodName.substring(prefix.length());
String expectedName= Character.toLowerCase(propertyName.charAt(0)) + propertyName.substring(1);
if (collectionName.equals(expectedName)) {
return true;
}
}
}
return false;
}
private static Expression unwrap(Expression expression) {
Expression current= expression;
while (current instanceof ParenthesizedExpression parenthesized) {
current= parenthesized.getExpression();
}
return current;
}
}