Support Objects.equals for path binding and branch constraints.
Expand machineType routing when dispatchers use java.util.Objects.equals with helper-wrapped parameters, and evaluate those compound constraints without breaking existing receiver-equals forms. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -133,6 +133,7 @@ public final class BooleanConstraintEvaluator {
|
||||
String argumentEquals = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
+ literalPattern + "[\"']?\\s*\\)";
|
||||
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
|
||||
expr = replaceObjectsEqualsForVariable(expr, varName, cleanValue, "true");
|
||||
expr = expr.replaceAll(argumentEquals, "true");
|
||||
|
||||
Set<String> allLiterals = extractStringLiterals(expr);
|
||||
@@ -144,11 +145,86 @@ public final class BooleanConstraintEvaluator {
|
||||
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
+ otherLiteral + "[\"']?\\s*\\)";
|
||||
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
|
||||
expr = replaceObjectsEqualsForVariable(expr, varName, literal, "false");
|
||||
expr = expr.replaceAll(otherArgument, "false");
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
private static String replaceObjectsEqualsForVariable(
|
||||
String expr, String varName, String literalValue, String replacement) {
|
||||
Pattern head = Pattern.compile("(?is)(?:java\\.util\\.)?Objects\\.equals\\s*\\(");
|
||||
Matcher matcher = head.matcher(expr);
|
||||
StringBuilder result = new StringBuilder();
|
||||
int cursor = 0;
|
||||
while (matcher.find()) {
|
||||
int matchStart = matcher.start();
|
||||
int openParen = matcher.end() - 1;
|
||||
int closeParen = findMatchingCloseParen(expr, openParen);
|
||||
if (closeParen < 0) {
|
||||
break;
|
||||
}
|
||||
String[] args = splitTopLevelComma(expr.substring(openParen + 1, closeParen));
|
||||
result.append(expr, cursor, matchStart);
|
||||
if (args != null
|
||||
&& args.length == 2
|
||||
&& objectsEqualsArgumentMatches(args[0], args[1], varName, literalValue)) {
|
||||
result.append(replacement);
|
||||
} else {
|
||||
result.append(expr, matchStart, closeParen + 1);
|
||||
}
|
||||
cursor = closeParen + 1;
|
||||
}
|
||||
result.append(expr.substring(cursor));
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static boolean objectsEqualsArgumentMatches(
|
||||
String left, String right, String varName, String literalValue) {
|
||||
String leftLiteral = stripConstraintLiteral(left);
|
||||
String rightLiteral = stripConstraintLiteral(right);
|
||||
if (leftLiteral != null
|
||||
&& leftLiteral.equalsIgnoreCase(literalValue)
|
||||
&& constraintArgumentReferencesVariable(right, varName)) {
|
||||
return true;
|
||||
}
|
||||
return rightLiteral != null
|
||||
&& rightLiteral.equalsIgnoreCase(literalValue)
|
||||
&& constraintArgumentReferencesVariable(left, varName);
|
||||
}
|
||||
|
||||
private static String stripConstraintLiteral(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.length() >= 2
|
||||
&& ((trimmed.startsWith("\"") && trimmed.endsWith("\""))
|
||||
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
||||
return trimmed.substring(1, trimmed.length() - 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean constraintArgumentReferencesVariable(String argument, String varName) {
|
||||
return argument != null && argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*");
|
||||
}
|
||||
|
||||
private static String[] splitTopLevelComma(String args) {
|
||||
int depth = 0;
|
||||
for (int i = 0; i < args.length(); i++) {
|
||||
char c = args.charAt(i);
|
||||
if (c == '(') {
|
||||
depth++;
|
||||
} else if (c == ')') {
|
||||
depth--;
|
||||
} else if (c == ',' && depth == 0) {
|
||||
return new String[] {args.substring(0, i), args.substring(i + 1)};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String replaceReceiverEqualsForVariable(
|
||||
String expr, String varName, String literalValue, String replacement) {
|
||||
Pattern head = Pattern.compile(
|
||||
|
||||
@@ -851,6 +851,10 @@ public final class EntryPointBindingExpander {
|
||||
String paramName,
|
||||
MethodDeclaration ownerMethod,
|
||||
CodebaseContext context) {
|
||||
String fromObjects = extractObjectsEqualsLiteral(expression, paramName, ownerMethod, context);
|
||||
if (fromObjects != null) {
|
||||
return fromObjects;
|
||||
}
|
||||
if (!(expression instanceof MethodInvocation invocation)) {
|
||||
return null;
|
||||
}
|
||||
@@ -889,6 +893,64 @@ public final class EntryPointBindingExpander {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String extractObjectsEqualsLiteral(
|
||||
Expression expression,
|
||||
String paramName,
|
||||
MethodDeclaration ownerMethod,
|
||||
CodebaseContext context) {
|
||||
if (!(expression instanceof MethodInvocation invocation)) {
|
||||
return null;
|
||||
}
|
||||
if (!isObjectsEqualsInvocation(invocation)) {
|
||||
return null;
|
||||
}
|
||||
if (invocation.arguments().size() != 2) {
|
||||
return null;
|
||||
}
|
||||
Expression left = (Expression) invocation.arguments().get(0);
|
||||
Expression right = (Expression) invocation.arguments().get(1);
|
||||
if (left instanceof StringLiteral stringLiteral
|
||||
&& parameterExpressionReferencesBinding(right, paramName, ownerMethod, context)) {
|
||||
return stringLiteral.getLiteralValue();
|
||||
}
|
||||
if (right instanceof StringLiteral stringLiteral
|
||||
&& parameterExpressionReferencesBinding(left, paramName, ownerMethod, context)) {
|
||||
return stringLiteral.getLiteralValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isObjectsEqualsInvocation(MethodInvocation invocation) {
|
||||
if (!"equals".equals(invocation.getName().getIdentifier())) {
|
||||
return false;
|
||||
}
|
||||
Expression receiver = invocation.getExpression();
|
||||
if (receiver == null) {
|
||||
return false;
|
||||
}
|
||||
if (receiver instanceof SimpleName simpleName) {
|
||||
return "Objects".equals(simpleName.getIdentifier());
|
||||
}
|
||||
if (receiver instanceof QualifiedName qualifiedName) {
|
||||
String fqn = qualifiedName.getFullyQualifiedName();
|
||||
return "java.util.Objects".equals(fqn) || fqn.endsWith(".Objects");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean parameterExpressionReferencesBinding(
|
||||
Expression expression,
|
||||
String paramName,
|
||||
MethodDeclaration ownerMethod,
|
||||
CodebaseContext context) {
|
||||
if (expression instanceof SimpleName simpleName) {
|
||||
return paramName.equals(simpleName.getIdentifier());
|
||||
}
|
||||
return ownerMethod != null
|
||||
&& context != null
|
||||
&& switchSelectorReferencesParameter(expression, paramName, ownerMethod, context);
|
||||
}
|
||||
|
||||
private static boolean tracksBindingName(MethodDeclaration method, String name) {
|
||||
if (hasParameter(method, name)) {
|
||||
return true;
|
||||
|
||||
@@ -62,4 +62,17 @@ class BooleanConstraintEvaluatorBindingsTest {
|
||||
"\"DOCUMENT\".equalsIgnoreCase(normalizeType(machineType))",
|
||||
Map.of("machineType", "DOCUMENT"))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEvaluateObjectsEqualsWithHelperWrappedParameter() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"java.util.Objects.equals(\"ORDER\", normalizeType(machineType))",
|
||||
Map.of("machineType", "ORDER"))).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"java.util.Objects.equals(\"ORDER\", normalizeType(machineType))",
|
||||
Map.of("machineType", "DOCUMENT"))).isFalse();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||
"java.util.Objects.equals(normalizeType(machineType), \"DOCUMENT\")",
|
||||
Map.of("machineType", "DOCUMENT"))).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,4 +963,132 @@ class EntryPointBindingExpanderTest {
|
||||
.toList())
|
||||
.containsExactlyInAnyOrder("SUBMIT", "APPROVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpandMachineTypeFromArgumentEqualsOnSingleArgHelper(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class MachineController {
|
||||
StateMachineDispatcher dispatcher;
|
||||
public void transition(String machineType, String event) {
|
||||
dispatcher.dispatch(machineType, event);
|
||||
}
|
||||
}
|
||||
class StateMachineDispatcher {
|
||||
void dispatch(String machineType, String eventString) {
|
||||
if (normalizeType(machineType).equalsIgnoreCase("ORDER")) {
|
||||
fireOrder(eventString);
|
||||
} else if (normalizeType(machineType).equalsIgnoreCase("DOCUMENT")) {
|
||||
fireDocument(eventString);
|
||||
}
|
||||
}
|
||||
String normalizeType(String raw) {
|
||||
return raw.trim();
|
||||
}
|
||||
void fireOrder(String eventString) {
|
||||
OrderEvent.valueOf(eventString.toUpperCase());
|
||||
}
|
||||
void fireDocument(String eventString) {
|
||||
DocumentEvent.valueOf(eventString.toUpperCase());
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
enum DocumentEvent { SUBMIT, APPROVE }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph = engine.buildCallGraph();
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||
.parameters(List.of(
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("machineType")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build(),
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
|
||||
|
||||
assertThat(variants.stream().map(v -> v.get("machineType")).distinct())
|
||||
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpandMachineTypeFromObjectsEqualsOnSingleArgHelper(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class MachineController {
|
||||
StateMachineDispatcher dispatcher;
|
||||
public void transition(String machineType, String event) {
|
||||
dispatcher.dispatch(machineType, event);
|
||||
}
|
||||
}
|
||||
class StateMachineDispatcher {
|
||||
void dispatch(String machineType, String eventString) {
|
||||
if (java.util.Objects.equals("ORDER", normalizeType(machineType))) {
|
||||
fireOrder(eventString);
|
||||
} else if (java.util.Objects.equals("DOCUMENT", normalizeType(machineType))) {
|
||||
fireDocument(eventString);
|
||||
}
|
||||
}
|
||||
String normalizeType(String raw) {
|
||||
return raw.trim();
|
||||
}
|
||||
void fireOrder(String eventString) {
|
||||
OrderEvent.valueOf(eventString.toUpperCase());
|
||||
}
|
||||
void fireDocument(String eventString) {
|
||||
DocumentEvent.valueOf(eventString.toUpperCase());
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
enum DocumentEvent { SUBMIT, APPROVE }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> graph = engine.buildCallGraph();
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||
.parameters(List.of(
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("machineType")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build(),
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
|
||||
|
||||
assertThat(variants.stream().map(v -> v.get("machineType")).distinct())
|
||||
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +180,89 @@ class MultiModulePathBindingExpanderTest {
|
||||
assertThat(variants).extracting(map -> map.get("event"))
|
||||
.containsExactlyInAnyOrder("PAY", "SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpandMachineTypeFromIfEqualsOnSiblingModuleHelper(@TempDir Path tempDir) throws Exception {
|
||||
Path root = tempDir.resolve("enterprise-style");
|
||||
Files.createDirectories(root);
|
||||
Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'");
|
||||
|
||||
Path apiModule = root.resolve("api-module");
|
||||
Files.createDirectories(apiModule.resolve("src/main/java/com/example/util"));
|
||||
Files.writeString(apiModule.resolve("build.gradle"), "");
|
||||
Files.writeString(apiModule.resolve("src/main/java/com/example/util/MachineTypeNormalizer.java"), """
|
||||
package com.example.util;
|
||||
public class MachineTypeNormalizer {
|
||||
public static String normalize(String raw) {
|
||||
return raw.trim();
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Path coreModule = root.resolve("core-module");
|
||||
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
|
||||
Files.writeString(coreModule.resolve("build.gradle"),
|
||||
"dependencies { implementation project(':api-module') }");
|
||||
Files.writeString(coreModule.resolve("src/main/java/com/example/MachineController.java"), """
|
||||
package com.example;
|
||||
import com.example.util.MachineTypeNormalizer;
|
||||
public class MachineController {
|
||||
StateMachineDispatcher dispatcher;
|
||||
public void transition(String machineType, String event) {
|
||||
dispatcher.dispatch(machineType, event);
|
||||
}
|
||||
}
|
||||
class StateMachineDispatcher {
|
||||
void dispatch(String machineType, String eventString) {
|
||||
if ("ORDER".equalsIgnoreCase(MachineTypeNormalizer.normalize(machineType))) {
|
||||
OrderEvent.valueOf(eventString.toUpperCase());
|
||||
} else if ("DOCUMENT".equalsIgnoreCase(MachineTypeNormalizer.normalize(machineType))) {
|
||||
DocumentEvent.valueOf(eventString.toUpperCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
enum DocumentEvent { SUBMIT, APPROVE }
|
||||
""");
|
||||
|
||||
SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver();
|
||||
ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule);
|
||||
Set<Path> scanPaths = graph.resolveScanPaths(coreModule, false);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(scanPaths, Collections.emptySet());
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
|
||||
engine.buildCallGraph();
|
||||
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.MachineController")
|
||||
.methodName("transition")
|
||||
.name("POST /api/machine/{machineType}/transition/{event}")
|
||||
.parameters(List.of(
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("machineType")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build(),
|
||||
EntryPoint.Parameter.builder()
|
||||
.name("event")
|
||||
.type("String")
|
||||
.annotations(List.of("PathVariable"))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants =
|
||||
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph);
|
||||
|
||||
assertThat(variants.stream().map(v -> v.get("machineType")).distinct())
|
||||
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
|
||||
assertThat(variants.stream()
|
||||
.filter(v -> "ORDER".equals(v.get("machineType")))
|
||||
.map(v -> v.get("event"))
|
||||
.toList())
|
||||
.containsExactlyInAnyOrder("PAY", "SHIP");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user