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*[\"']?"
|
String argumentEquals = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||||
+ literalPattern + "[\"']?\\s*\\)";
|
+ literalPattern + "[\"']?\\s*\\)";
|
||||||
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
|
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
|
||||||
|
expr = replaceObjectsEqualsForVariable(expr, varName, cleanValue, "true");
|
||||||
expr = expr.replaceAll(argumentEquals, "true");
|
expr = expr.replaceAll(argumentEquals, "true");
|
||||||
|
|
||||||
Set<String> allLiterals = extractStringLiterals(expr);
|
Set<String> allLiterals = extractStringLiterals(expr);
|
||||||
@@ -144,11 +145,86 @@ public final class BooleanConstraintEvaluator {
|
|||||||
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||||
+ otherLiteral + "[\"']?\\s*\\)";
|
+ otherLiteral + "[\"']?\\s*\\)";
|
||||||
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
|
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
|
||||||
|
expr = replaceObjectsEqualsForVariable(expr, varName, literal, "false");
|
||||||
expr = expr.replaceAll(otherArgument, "false");
|
expr = expr.replaceAll(otherArgument, "false");
|
||||||
}
|
}
|
||||||
return expr;
|
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(
|
private static String replaceReceiverEqualsForVariable(
|
||||||
String expr, String varName, String literalValue, String replacement) {
|
String expr, String varName, String literalValue, String replacement) {
|
||||||
Pattern head = Pattern.compile(
|
Pattern head = Pattern.compile(
|
||||||
|
|||||||
@@ -851,6 +851,10 @@ public final class EntryPointBindingExpander {
|
|||||||
String paramName,
|
String paramName,
|
||||||
MethodDeclaration ownerMethod,
|
MethodDeclaration ownerMethod,
|
||||||
CodebaseContext context) {
|
CodebaseContext context) {
|
||||||
|
String fromObjects = extractObjectsEqualsLiteral(expression, paramName, ownerMethod, context);
|
||||||
|
if (fromObjects != null) {
|
||||||
|
return fromObjects;
|
||||||
|
}
|
||||||
if (!(expression instanceof MethodInvocation invocation)) {
|
if (!(expression instanceof MethodInvocation invocation)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -889,6 +893,64 @@ public final class EntryPointBindingExpander {
|
|||||||
return null;
|
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) {
|
private static boolean tracksBindingName(MethodDeclaration method, String name) {
|
||||||
if (hasParameter(method, name)) {
|
if (hasParameter(method, name)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -62,4 +62,17 @@ class BooleanConstraintEvaluatorBindingsTest {
|
|||||||
"\"DOCUMENT\".equalsIgnoreCase(normalizeType(machineType))",
|
"\"DOCUMENT\".equalsIgnoreCase(normalizeType(machineType))",
|
||||||
Map.of("machineType", "DOCUMENT"))).isTrue();
|
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())
|
.toList())
|
||||||
.containsExactlyInAnyOrder("SUBMIT", "APPROVE");
|
.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"))
|
assertThat(variants).extracting(map -> map.get("event"))
|
||||||
.containsExactlyInAnyOrder("PAY", "SHIP");
|
.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