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("\"")) {
|
||||
cleanValue = cleanValue.substring(1, cleanValue.length() - 1);
|
||||
}
|
||||
|
||||
String literalPattern = Pattern.quote(cleanValue);
|
||||
String receiverEquals = "(?i)[\"']?" + literalPattern + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*"
|
||||
+ Pattern.quote(varName) + "\\s*\\)";
|
||||
String argumentEquals = "(?i)" + Pattern.quote(varName) + "\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
String varToken = Pattern.quote(varName);
|
||||
String argumentEquals = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
+ literalPattern + "[\"']?\\s*\\)";
|
||||
expr = expr.replaceAll(receiverEquals, "true");
|
||||
expr = replaceReceiverEqualsForVariable(expr, varName, cleanValue, "true");
|
||||
expr = expr.replaceAll(argumentEquals, "true");
|
||||
|
||||
Set<String> allLiterals = extractStringLiterals(expr);
|
||||
@@ -141,16 +141,58 @@ public final class BooleanConstraintEvaluator {
|
||||
continue;
|
||||
}
|
||||
String otherLiteral = Pattern.quote(literal);
|
||||
String otherReceiver = "(?i)[\"']?" + otherLiteral + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*"
|
||||
+ Pattern.quote(varName) + "\\s*\\)";
|
||||
String otherArgument = "(?i)" + Pattern.quote(varName) + "\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
String otherArgument = "(?i)" + varToken + "\\s*\\.\\s*(?:equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?"
|
||||
+ otherLiteral + "[\"']?\\s*\\)";
|
||||
expr = expr.replaceAll(otherReceiver, "false");
|
||||
expr = replaceReceiverEqualsForVariable(expr, varName, literal, "false");
|
||||
expr = expr.replaceAll(otherArgument, "false");
|
||||
}
|
||||
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) {
|
||||
Set<String> vars = new HashSet<>();
|
||||
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");
|
||||
|
||||
@@ -759,7 +759,7 @@ public final class EntryPointBindingExpander {
|
||||
|
||||
@Override
|
||||
public boolean visit(IfStatement node) {
|
||||
collectCasesFromIfChain(node, paramName, cases);
|
||||
collectCasesFromIfChain(node, paramName, cases, method, context);
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
@@ -801,10 +801,16 @@ public final class EntryPointBindingExpander {
|
||||
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;
|
||||
while (current != null) {
|
||||
String literal = extractEqualsLiteralForParameter(current.getExpression(), paramName);
|
||||
String literal = extractEqualsLiteralForParameter(
|
||||
current.getExpression(), paramName, ownerMethod, context);
|
||||
if (literal != null) {
|
||||
cases.add(literal);
|
||||
}
|
||||
@@ -813,7 +819,8 @@ public final class EntryPointBindingExpander {
|
||||
current = nestedIf;
|
||||
} else {
|
||||
if (elseStatement != null) {
|
||||
String elseLiteral = extractEqualsLiteralForParameterFromStatement(elseStatement, paramName);
|
||||
String elseLiteral = extractEqualsLiteralForParameterFromStatement(
|
||||
elseStatement, paramName, ownerMethod, context);
|
||||
if (elseLiteral != null) {
|
||||
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) {
|
||||
return extractEqualsLiteralForParameter(ifStatement.getExpression(), paramName);
|
||||
return extractEqualsLiteralForParameter(
|
||||
ifStatement.getExpression(), paramName, ownerMethod, context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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)) {
|
||||
return null;
|
||||
}
|
||||
@@ -849,11 +869,23 @@ public final class EntryPointBindingExpander {
|
||||
&& paramName.equals(simpleName.getIdentifier())) {
|
||||
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
|
||||
&& paramName.equals(simpleName.getIdentifier())
|
||||
&& argument instanceof StringLiteral paramLiteral) {
|
||||
return paramLiteral.getLiteralValue();
|
||||
}
|
||||
if (ownerMethod != null
|
||||
&& context != null
|
||||
&& switchSelectorReferencesParameter(receiver, paramName, ownerMethod, context)
|
||||
&& argument instanceof StringLiteral paramLiteral) {
|
||||
return paramLiteral.getLiteralValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,4 +49,17 @@ class BooleanConstraintEvaluatorBindingsTest {
|
||||
"command == ORDER_PAY",
|
||||
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"))
|
||||
.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;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
@@ -94,4 +94,90 @@ class MultiModulePathBindingExpanderTest {
|
||||
entryPoint, Map.of("event", "PAY")).getName())
|
||||
.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