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 45da3e4..2acba86 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 @@ -294,6 +294,12 @@ public final class EntryPointBindingExpander { if (!stringCases.isEmpty()) { return stringCases; } + List mapCases = resolveCasesWithParameterAliases( + startMethod, paramName, context, callGraph, partialBindings, + EntryPointBindingExpander::extractStaticMapLookupCasesForBindings); + if (!mapCases.isEmpty()) { + return mapCases; + } return resolveCasesWithParameterAliases( startMethod, paramName, context, callGraph, partialBindings, EntryPointBindingExpander::extractEnumValueOfCasesForBindings); @@ -324,6 +330,14 @@ public final class EntryPointBindingExpander { return extractEnumValueOfCases(methodFqn, paramName, context, partialBindings); } + private static List extractStaticMapLookupCasesForBindings( + String methodFqn, + String paramName, + CodebaseContext context, + Map partialBindings) { + return extractStaticMapLookupCases(methodFqn, paramName, context); + } + private record ParameterAliasState(String methodFqn, String paramName) { } @@ -559,6 +573,130 @@ public final class EntryPointBindingExpander { return List.copyOf(pathValues); } + static List extractStaticMapLookupCases(String methodFqn, String paramName, CodebaseContext context) { + MethodDeclaration method = findMethodDeclaration(methodFqn, context); + if (method == null || method.getBody() == null) { + return List.of(); + } + if (!tracksBindingName(method, paramName)) { + return List.of(); + } + LinkedHashSet keys = new LinkedHashSet<>(); + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + String lookupMethod = node.getName().getIdentifier(); + if (!"get".equals(lookupMethod) && !"getOrDefault".equals(lookupMethod)) { + return super.visit(node); + } + if (node.arguments().isEmpty()) { + return super.visit(node); + } + Expression argument = (Expression) node.arguments().get(0); + if (!argumentReferencesParameter(argument, paramName, method, context)) { + return super.visit(node); + } + Map entries = resolveCompileTimeMapEntries(node.getExpression(), method, context); + if (entries != null) { + keys.addAll(entries.keySet()); + } + return super.visit(node); + } + }); + return List.copyOf(keys); + } + + private static Map resolveCompileTimeMapEntries( + Expression receiver, + MethodDeclaration ownerMethod, + CodebaseContext context) { + if (receiver == null) { + return null; + } + Map direct = extractMapOfLiteralEntries(receiver); + if (direct != null) { + return direct; + } + String fieldName = null; + TypeDeclaration declaringType = null; + if (receiver instanceof SimpleName simpleName) { + fieldName = simpleName.getIdentifier(); + declaringType = AstUtils.findEnclosingType(receiver); + } else if (receiver instanceof FieldAccess fieldAccess) { + fieldName = fieldAccess.getName().getIdentifier(); + Expression qualifier = fieldAccess.getExpression(); + if (qualifier instanceof SimpleName typeName) { + CompilationUnit compilationUnit = (CompilationUnit) ownerMethod.getRoot(); + declaringType = context.getTypeDeclaration(typeName.getIdentifier(), compilationUnit); + if (declaringType == null) { + declaringType = context.getTypeDeclaration(typeName.getIdentifier()); + } + } else if (qualifier instanceof QualifiedName qualifiedName) { + declaringType = context.getTypeDeclaration(qualifiedName.getFullyQualifiedName()); + } + } + if (fieldName == null || declaringType == null) { + return null; + } + return extractStaticFieldMapInitializer(declaringType, fieldName); + } + + private static Map extractStaticFieldMapInitializer(TypeDeclaration typeDecl, String fieldName) { + for (FieldDeclaration field : typeDecl.getFields()) { + for (Object fragmentObj : field.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment + && fieldName.equals(fragment.getName().getIdentifier())) { + Expression initializer = fragment.getInitializer(); + if (initializer == null) { + return null; + } + return extractMapOfLiteralEntries(initializer); + } + } + } + return null; + } + + private static Map extractMapOfLiteralEntries(Expression expression) { + if (!(expression instanceof MethodInvocation mapInvocation)) { + return null; + } + String factoryMethod = mapInvocation.getName().getIdentifier(); + if (!"of".equals(factoryMethod)) { + return null; + } + if (!isMapFactoryReceiver(mapInvocation.getExpression())) { + return null; + } + return extractAlternatingLiteralPairs(mapInvocation.arguments()); + } + + private static boolean isMapFactoryReceiver(Expression receiver) { + if (receiver instanceof SimpleName simpleName) { + return "Map".equals(simpleName.getIdentifier()); + } + if (receiver instanceof QualifiedName qualifiedName) { + String fqn = qualifiedName.getFullyQualifiedName(); + return fqn.endsWith(".Map"); + } + return false; + } + + private static Map extractAlternatingLiteralPairs(List arguments) { + if (arguments.size() < 2) { + return null; + } + Map entries = new LinkedHashMap<>(); + for (int i = 0; i + 1 < arguments.size(); i += 2) { + Object keyObj = arguments.get(i); + if (!(keyObj instanceof StringLiteral stringLiteral)) { + return null; + } + entries.put(stringLiteral.getLiteralValue(), "*"); + } + return entries.isEmpty() ? null : entries; + } + private static boolean isValueOfSiteCompatibleWithBindings( MethodInvocation valueOfInvocation, Map partialBindings, 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 90cfcd0..3fb493d 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 @@ -1607,4 +1607,283 @@ class EntryPointBindingExpanderTest { assertThat(variants.stream().map(v -> v.get("machineType")).distinct()) .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); } + + @Test + void shouldExpandMachineTypeFromBooleanPredicateUsingObjectsEquals(@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 (matchesOrder(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } else if (matchesDocument(machineType)) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + boolean matchesOrder(String raw) { + return java.util.Objects.equals("ORDER", raw.trim()); + } + boolean matchesDocument(String raw) { + return java.util.Objects.equals("DOCUMENT", raw.trim()); + } + } + 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 shouldExpandMachineTypeFromDirectStringSwitch(@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) { + switch (machineType) { + case "ORDER" -> OrderEvent.valueOf(eventString.toUpperCase()); + case "DOCUMENT" -> 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 shouldExpandMachineTypeFromBooleanPredicateUsingReversedObjectsEqualsOnHelper(@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 (matchesOrder(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } + } + boolean matchesOrder(String raw) { + return java.util.Objects.equals(normalize(raw), "ORDER"); + } + String normalize(String raw) { + return raw.trim(); + } + } + enum OrderEvent { PAY, SHIP } + """; + 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())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("machineType")).containsExactly("ORDER"); + } + + @Test + void shouldExpandCommandKeyFromStaticMapLookup(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + private static final java.util.Map ROUTES = java.util.Map.of( + "order.pay", OrderEvent.PAY, + "order.ship", OrderEvent.SHIP); + void trigger(String commandKey) { + StateMachine sm = new StateMachine(); + sm.sendEvent(ROUTES.get(commandKey)); + } + } + class StateMachine { void sendEvent(OrderEvent event) {} } + enum OrderEvent { PAY, SHIP } + """; + 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.GenericCommandController") + .methodName("execute") + .name("POST /api/commands/{commandKey}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("commandKey") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("commandKey")) + .containsExactlyInAnyOrder("order.pay", "order.ship"); + } + + @Test + void shouldExpandMachineTypeFromBooleanPredicateUsingObjectsEqualsOnHelper(@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 (matchesOrder(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } else if (matchesDocument(machineType)) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + boolean matchesOrder(String raw) { + return java.util.Objects.equals("ORDER", normalize(raw)); + } + boolean matchesDocument(String raw) { + return java.util.Objects.equals("DOCUMENT", normalize(raw)); + } + String normalize(String raw) { + return raw.trim(); + } + } + 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"); + } }