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.Expression;
import org.eclipse.jdt.core.dom.FieldAccess;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.ThisExpression;
/**
* Shared utility for detecting structural modifications on a collection.
*
* <p>Used by both {@link PreconditionsChecker} and
* {@link JdtLoopExtractor.LoopBodyAnalyzer} to ensure consistent detection
* of collection modifications that block loop-to-stream conversions.</p>
*
* <p><b>Supported Receivers:</b></p>
* <ul>
* <li>Simple names: {@code list.remove(x)}</li>
* <li>Field access: {@code this.list.remove(x)}</li>
* <li>Method invocation (getter pattern): {@code getList().remove(x)}</li>
* </ul>
*
* <p><b>Method Invocation Heuristic:</b> For method invocations, matches getter
* method names against collection names. For example, {@code getList().add(x)}
* is detected when iterating over {@code list}. Supports common getter patterns:
* {@code getXxx()}, {@code fetchXxx()}, {@code retrieveXxx()}, etc.</p>
*
* <p><b>Limitation:</b> Does not detect modifications via array access
* ({@code arrays[0].clear()}). This is an intentional conservative limitation.</p>
*
* @see <a href="https://github.com/carstenartur/sandbox/issues/670">Issue #670</a>
* @since 1.0.0
*/
public final class CollectionModificationDetector {
/**
* Method names that represent structural modifications on a collection.
* Calling any of these on the iterated collection causes
* ConcurrentModificationException with fail-fast iterators.
*
* <p>Includes:</p>
* <ul>
* <li>Collection methods: add, remove, clear, addAll, removeAll, retainAll, removeIf, replaceAll, sort</li>
* <li>List methods: set</li>
* <li>Map methods: put, putAll, putIfAbsent, compute, computeIfAbsent, computeIfPresent, merge, replace, replaceAll</li>
* </ul>
*/
private static final Set<String> MODIFYING_METHODS = Set.of(
// Collection/List methods
"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$
// Map methods
"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 CollectionModificationDetector() {
// utility class
}
/**
* Checks if a method invocation is a structural modification on a named collection.
*
* <p>Detects calls to structural modification methods on the given collection variable.
* Supports simple names ({@code list.remove(x)}), field access
* ({@code this.list.remove(x)}), and method invocation receivers
* ({@code getList().remove(x)}).</p>
*
* @param methodInv the method invocation to check
* @param collectionName the name of the iterated collection variable
* @return {@code true} if this is a structural modification on the named collection
*/
public static boolean isModification(MethodInvocation methodInv, String collectionName) {
Expression receiver = methodInv.getExpression();
// Check for simple name receiver: list.remove(x)
if (receiver instanceof SimpleName receiverName) {
if (collectionName.equals(receiverName.getIdentifier())) {
String methodName = methodInv.getName().getIdentifier();
return MODIFYING_METHODS.contains(methodName);
}
}
// Check for field access receiver: this.list.remove(x)
if (receiver instanceof FieldAccess fieldAccess) {
Expression fieldExpression = fieldAccess.getExpression();
// Check if it's "this.fieldName"
if (fieldExpression instanceof ThisExpression) {
SimpleName fieldName = fieldAccess.getName();
if (collectionName.equals(fieldName.getIdentifier())) {
String methodName = methodInv.getName().getIdentifier();
return MODIFYING_METHODS.contains(methodName);
}
}
}
// Check for method invocation receiver: getList().remove(x)
if (receiver instanceof MethodInvocation getterInvocation) {
if (matchesGetterPattern(getterInvocation, collectionName)) {
String methodName = methodInv.getName().getIdentifier();
return MODIFYING_METHODS.contains(methodName);
}
}
return false;
}
/**
* Common getter method prefixes used in heuristic matching.
*/
private static final String[] GETTER_PREFIXES = {
"get", "fetch", "retrieve", "obtain" //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
};
/**
* Checks if a method invocation matches a getter pattern for the given collection name.
*
* <p>Matches common getter patterns like:</p>
* <ul>
* <li>{@code getList()} → {@code list}</li>
* <li>{@code fetchItems()} → {@code items}</li>
* <li>{@code retrieveMap()} → {@code map}</li>
* </ul>
*
* @param methodInv the method invocation to check
* @param collectionName the expected collection name
* @return {@code true} if the method name matches a getter pattern for the collection
*/
private static boolean matchesGetterPattern(MethodInvocation methodInv, String collectionName) {
// Only consider no-arg methods (simple getters)
if (!methodInv.arguments().isEmpty()) {
return false;
}
String methodName = methodInv.getName().getIdentifier();
for (String prefix : GETTER_PREFIXES) {
if (methodName.startsWith(prefix) && methodName.length() > prefix.length()) {
// Extract the property name after the prefix (e.g., "List" from "getList")
String propertyName = methodName.substring(prefix.length());
// Convert first char to lowercase to get the expected variable name
String expectedName = Character.toLowerCase(propertyName.charAt(0)) +
propertyName.substring(1);
if (collectionName.equals(expectedName)) {
return true;
}
}
}
return false;
}
}