diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java index 07d1d25..9aa5c27 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java @@ -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 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 extractEqualityVariables(String constraint) { Set vars = new HashSet<>(); Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*=="); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java index 2062109..009b52c 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java @@ -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 cases) { + private static void collectCasesFromIfChain( + IfStatement ifStatement, + String paramName, + Set 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; } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java index 96e3b84..b1b5b85 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java @@ -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(); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java index 47962af..f368910 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java @@ -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> 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> 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"); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java index 04c015c..714a24f 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java @@ -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"), """ + com.exampleorder-maven + """); + + Path apiModule = root.resolve("api-module"); + Files.createDirectories(apiModule.resolve("src/main/java/com/example/util")); + Files.writeString(apiModule.resolve("pom.xml"), """ + com.exampleapi-module + """); + 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"), """ + + com.example + core-module + + + com.example + api-module + 1.0 + + + + """); + 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 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> 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> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph); + + assertThat(variants).extracting(map -> map.get("event")) + .containsExactlyInAnyOrder("PAY", "SHIP"); + } }