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 9aa5c27..f131e63 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 @@ -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 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( 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 009b52c..d2c439d 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 @@ -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; 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 b1b5b85..2d54109 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 @@ -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(); + } } 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 f368910..a565ec3 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 @@ -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> 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"); + } + + @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> 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"); + } } 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 714a24f..455d985 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 @@ -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 scanPaths = graph.resolveScanPaths(coreModule, false); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(scanPaths, Collections.emptySet()); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> 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> 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"); + } }