Expand path bindings for if-equals on helper-wrapped parameters.
Use balanced-paren receiver-equals substitution so compound branch constraints stay correct while supporting forms like "ORDER".equalsIgnoreCase(normalizeType(machineType)). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -127,12 +127,12 @@ public final class BooleanConstraintEvaluator {
|
|||||||
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
|
if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) {
|
||||||
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
|
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
String literalPattern = Pattern.quote(cleanValue);
|
String literalPattern = Pattern.quote(cleanValue);
|
||||||
String receiverEquals = "(?i)[\"']?" + literalPattern + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*"
|
String varToken = Pattern.quote(varName);
|
||||||
+ Pattern.quote(varName) + "\\s*\\)";
|
String argumentEquals = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||||
String argumentEquals = "(?i)" + Pattern.quote(varName) + "\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
|
||||||
+ literalPattern + "[\"']?\\s*\\)";
|
+ literalPattern + "[\"']?\\s*\\)";
|
||||||
expr = expr.replaceAll(receiverEquals, "true");
|
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
|
||||||
expr = expr.replaceAll(argumentEquals, "true");
|
expr = expr.replaceAll(argumentEquals, "true");
|
||||||
|
|
||||||
Set<String> allLiterals = extractStringLiterals(expr);
|
Set<String> allLiterals = extractStringLiterals(expr);
|
||||||
@@ -141,16 +141,58 @@ public final class BooleanConstraintEvaluator {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String otherLiteral = Pattern.quote(literal);
|
String otherLiteral = Pattern.quote(literal);
|
||||||
String otherReceiver = "(?i)[\"']?" + otherLiteral + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*"
|
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||||
+ Pattern.quote(varName) + "\\s*\\)";
|
|
||||||
String otherArgument = "(?i)" + Pattern.quote(varName) + "\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
|
||||||
+ otherLiteral + "[\"']?\\s*\\)";
|
+ otherLiteral + "[\"']?\\s*\\)";
|
||||||
expr = expr.replaceAll(otherReceiver, "false");
|
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
|
||||||
expr = expr.replaceAll(otherArgument, "false");
|
expr = expr.replaceAll(otherArgument, "false");
|
||||||
}
|
}
|
||||||
return expr;
|
return expr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String replaceReceiverEqualsForVariable(
|
||||||
|
String expr, String varName, String literalValue, String replacement) {
|
||||||
|
Pattern head = Pattern.compile(
|
||||||
|
"(?is)[\"']?" + Pattern.quote(literalValue) + "[\"']?\\s*\\.\\s*(?:equalsIgnoreCase|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 argument = expr.substring(openParen + 1, closeParen).trim();
|
||||||
|
result.append(expr, cursor, matchStart);
|
||||||
|
if (argument.equals(varName)
|
||||||
|
|| argument.matches("(?s).*\\b" + Pattern.quote(varName) + "\\b.*")) {
|
||||||
|
result.append(replacement);
|
||||||
|
} else {
|
||||||
|
result.append(expr, matchStart, closeParen + 1);
|
||||||
|
}
|
||||||
|
cursor = closeParen + 1;
|
||||||
|
}
|
||||||
|
result.append(expr.substring(cursor));
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int findMatchingCloseParen(String expr, int openIdx) {
|
||||||
|
int depth = 0;
|
||||||
|
for (int i = openIdx; i < expr.length(); i++) {
|
||||||
|
char c = expr.charAt(i);
|
||||||
|
if (c == '(') {
|
||||||
|
depth++;
|
||||||
|
} else if (c == ')') {
|
||||||
|
depth--;
|
||||||
|
if (depth == 0) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
private static Set<String> extractEqualityVariables(String constraint) {
|
private static Set<String> extractEqualityVariables(String constraint) {
|
||||||
Set<String> vars = new HashSet<>();
|
Set<String> vars = new HashSet<>();
|
||||||
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");
|
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");
|
||||||
|
|||||||
@@ -759,7 +759,7 @@ public final class EntryPointBindingExpander {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean visit(IfStatement node) {
|
public boolean visit(IfStatement node) {
|
||||||
collectCasesFromIfChain(node, paramName, cases);
|
collectCasesFromIfChain(node, paramName, cases, method, context);
|
||||||
return super.visit(node);
|
return super.visit(node);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -801,10 +801,16 @@ public final class EntryPointBindingExpander {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void collectCasesFromIfChain(IfStatement ifStatement, String paramName, Set<String> cases) {
|
private static void collectCasesFromIfChain(
|
||||||
|
IfStatement ifStatement,
|
||||||
|
String paramName,
|
||||||
|
Set<String> cases,
|
||||||
|
MethodDeclaration ownerMethod,
|
||||||
|
CodebaseContext context) {
|
||||||
IfStatement current = ifStatement;
|
IfStatement current = ifStatement;
|
||||||
while (current != null) {
|
while (current != null) {
|
||||||
String literal = extractEqualsLiteralForParameter(current.getExpression(), paramName);
|
String literal = extractEqualsLiteralForParameter(
|
||||||
|
current.getExpression(), paramName, ownerMethod, context);
|
||||||
if (literal != null) {
|
if (literal != null) {
|
||||||
cases.add(literal);
|
cases.add(literal);
|
||||||
}
|
}
|
||||||
@@ -813,7 +819,8 @@ public final class EntryPointBindingExpander {
|
|||||||
current = nestedIf;
|
current = nestedIf;
|
||||||
} else {
|
} else {
|
||||||
if (elseStatement != null) {
|
if (elseStatement != null) {
|
||||||
String elseLiteral = extractEqualsLiteralForParameterFromStatement(elseStatement, paramName);
|
String elseLiteral = extractEqualsLiteralForParameterFromStatement(
|
||||||
|
elseStatement, paramName, ownerMethod, context);
|
||||||
if (elseLiteral != null) {
|
if (elseLiteral != null) {
|
||||||
cases.add(elseLiteral);
|
cases.add(elseLiteral);
|
||||||
}
|
}
|
||||||
@@ -823,14 +830,27 @@ public final class EntryPointBindingExpander {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extractEqualsLiteralForParameterFromStatement(Statement statement, String paramName) {
|
private static String extractEqualsLiteralForParameterFromStatement(
|
||||||
|
Statement statement,
|
||||||
|
String paramName,
|
||||||
|
MethodDeclaration ownerMethod,
|
||||||
|
CodebaseContext context) {
|
||||||
if (statement instanceof IfStatement ifStatement) {
|
if (statement instanceof IfStatement ifStatement) {
|
||||||
return extractEqualsLiteralForParameter(ifStatement.getExpression(), paramName);
|
return extractEqualsLiteralForParameter(
|
||||||
|
ifStatement.getExpression(), paramName, ownerMethod, context);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extractEqualsLiteralForParameter(Expression expression, String paramName) {
|
private static String extractEqualsLiteralForParameter(Expression expression, String paramName) {
|
||||||
|
return extractEqualsLiteralForParameter(expression, paramName, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String extractEqualsLiteralForParameter(
|
||||||
|
Expression expression,
|
||||||
|
String paramName,
|
||||||
|
MethodDeclaration ownerMethod,
|
||||||
|
CodebaseContext context) {
|
||||||
if (!(expression instanceof MethodInvocation invocation)) {
|
if (!(expression instanceof MethodInvocation invocation)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -849,11 +869,23 @@ public final class EntryPointBindingExpander {
|
|||||||
&& paramName.equals(simpleName.getIdentifier())) {
|
&& paramName.equals(simpleName.getIdentifier())) {
|
||||||
return stringLiteral.getLiteralValue();
|
return stringLiteral.getLiteralValue();
|
||||||
}
|
}
|
||||||
|
if (receiver instanceof StringLiteral stringLiteral
|
||||||
|
&& ownerMethod != null
|
||||||
|
&& context != null
|
||||||
|
&& switchSelectorReferencesParameter(argument, paramName, ownerMethod, context)) {
|
||||||
|
return stringLiteral.getLiteralValue();
|
||||||
|
}
|
||||||
if (receiver instanceof SimpleName simpleName
|
if (receiver instanceof SimpleName simpleName
|
||||||
&& paramName.equals(simpleName.getIdentifier())
|
&& paramName.equals(simpleName.getIdentifier())
|
||||||
&& argument instanceof StringLiteral paramLiteral) {
|
&& argument instanceof StringLiteral paramLiteral) {
|
||||||
return paramLiteral.getLiteralValue();
|
return paramLiteral.getLiteralValue();
|
||||||
}
|
}
|
||||||
|
if (ownerMethod != null
|
||||||
|
&& context != null
|
||||||
|
&& switchSelectorReferencesParameter(receiver, paramName, ownerMethod, context)
|
||||||
|
&& argument instanceof StringLiteral paramLiteral) {
|
||||||
|
return paramLiteral.getLiteralValue();
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,4 +49,17 @@ class BooleanConstraintEvaluatorBindingsTest {
|
|||||||
"command == ORDER_PAY",
|
"command == ORDER_PAY",
|
||||||
Map.of("commandKey", "order.pay"))).isFalse();
|
Map.of("commandKey", "order.pay"))).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldEvaluateEqualsIgnoreCaseWithHelperWrappedParameter() {
|
||||||
|
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||||
|
"\"ORDER\".equalsIgnoreCase(normalizeType(machineType))",
|
||||||
|
Map.of("machineType", "ORDER"))).isTrue();
|
||||||
|
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||||
|
"\"ORDER\".equalsIgnoreCase(normalizeType(machineType))",
|
||||||
|
Map.of("machineType", "DOCUMENT"))).isFalse();
|
||||||
|
assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(
|
||||||
|
"\"DOCUMENT\".equalsIgnoreCase(normalizeType(machineType))",
|
||||||
|
Map.of("machineType", "DOCUMENT"))).isTrue();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -889,4 +889,78 @@ class EntryPointBindingExpanderTest {
|
|||||||
assertThat(variants).extracting(map -> map.get("event"))
|
assertThat(variants).extracting(map -> map.get("event"))
|
||||||
.containsExactlyInAnyOrder("PAY", "SHIP");
|
.containsExactlyInAnyOrder("PAY", "SHIP");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldExpandMachineTypeFromIfEqualsOnSingleArgHelper(@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 ("ORDER".equalsIgnoreCase(normalizeType(machineType))) {
|
||||||
|
fireOrder(eventString);
|
||||||
|
} else if ("DOCUMENT".equalsIgnoreCase(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");
|
||||||
|
assertThat(variants.stream()
|
||||||
|
.filter(v -> "ORDER".equals(v.get("machineType")))
|
||||||
|
.map(v -> v.get("event"))
|
||||||
|
.toList())
|
||||||
|
.containsExactlyInAnyOrder("PAY", "SHIP");
|
||||||
|
assertThat(variants.stream()
|
||||||
|
.filter(v -> "DOCUMENT".equals(v.get("machineType")))
|
||||||
|
.map(v -> v.get("event"))
|
||||||
|
.toList())
|
||||||
|
.containsExactlyInAnyOrder("SUBMIT", "APPROVE");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import java.util.Collections;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Path binding expansion must resolve helpers declared in sibling Gradle modules without executing the build.
|
* Path binding expansion must resolve helpers declared in sibling modules without executing the build.
|
||||||
*/
|
*/
|
||||||
class MultiModulePathBindingExpanderTest {
|
class MultiModulePathBindingExpanderTest {
|
||||||
|
|
||||||
@@ -94,4 +94,90 @@ class MultiModulePathBindingExpanderTest {
|
|||||||
entryPoint, Map.of("event", "PAY")).getName())
|
entryPoint, Map.of("event", "PAY")).getName())
|
||||||
.isEqualTo("POST /api/order/PAY");
|
.isEqualTo("POST /api/order/PAY");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldExpandEventUsingHelperFromSiblingMavenModule(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path root = tempDir.resolve("order-maven");
|
||||||
|
Files.createDirectories(root.resolve(".git"));
|
||||||
|
Files.writeString(root.resolve("pom.xml"), """
|
||||||
|
<project><groupId>com.example</groupId><artifactId>order-maven</artifactId></project>
|
||||||
|
""");
|
||||||
|
|
||||||
|
Path apiModule = root.resolve("api-module");
|
||||||
|
Files.createDirectories(apiModule.resolve("src/main/java/com/example/util"));
|
||||||
|
Files.writeString(apiModule.resolve("pom.xml"), """
|
||||||
|
<project><groupId>com.example</groupId><artifactId>api-module</artifactId></project>
|
||||||
|
""");
|
||||||
|
Files.writeString(apiModule.resolve("src/main/java/com/example/util/EventNormalizer.java"), """
|
||||||
|
package com.example.util;
|
||||||
|
public class EventNormalizer {
|
||||||
|
public static String normalize(String raw) {
|
||||||
|
return raw.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
Path coreModule = root.resolve("core-module");
|
||||||
|
Files.createDirectories(coreModule.resolve("src/main/java/com/example"));
|
||||||
|
Files.writeString(coreModule.resolve("pom.xml"), """
|
||||||
|
<project>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>core-module</artifactId>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.example</groupId>
|
||||||
|
<artifactId>api-module</artifactId>
|
||||||
|
<version>1.0</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
|
""");
|
||||||
|
Files.writeString(coreModule.resolve("src/main/java/com/example/OrderGateway.java"), """
|
||||||
|
package com.example;
|
||||||
|
import com.example.util.EventNormalizer;
|
||||||
|
public class OrderController {
|
||||||
|
OrderGateway gateway;
|
||||||
|
public void transition(String event) {
|
||||||
|
gateway.trigger(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class OrderGateway {
|
||||||
|
void trigger(String event) {
|
||||||
|
OrderEvent.valueOf(EventNormalizer.normalize(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enum OrderEvent { PAY, SHIP }
|
||||||
|
""");
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
assertThat(context.getTypeDeclaration("com.example.util.EventNormalizer")).isNotNull();
|
||||||
|
|
||||||
|
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||||
|
Map<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> callGraph =
|
||||||
|
engine.buildCallGraph();
|
||||||
|
|
||||||
|
EntryPoint entryPoint = EntryPoint.builder()
|
||||||
|
.className("com.example.OrderController")
|
||||||
|
.methodName("transition")
|
||||||
|
.name("POST /api/order/{event}")
|
||||||
|
.parameters(List.of(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).extracting(map -> map.get("event"))
|
||||||
|
.containsExactlyInAnyOrder("PAY", "SHIP");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user