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 9ba0b83..6413435 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 @@ -971,18 +971,31 @@ public final class EntryPointBindingExpander { TypeDeclaration defaultDeclaringType, CompilationUnit compilationUnit, CodebaseContext context) { + return extractCompileTimeMapFromStaticMethodCall( + expression, defaultDeclaringType, compilationUnit, context, new LinkedHashSet<>()); + } + + private static Map extractCompileTimeMapFromStaticMethodCall( + Expression expression, + TypeDeclaration defaultDeclaringType, + CompilationUnit compilationUnit, + CodebaseContext context, + Set visitingMethods) { if (!(expression instanceof MethodInvocation invocation)) { return null; } org.eclipse.jdt.core.dom.IMethodBinding binding = invocation.resolveMethodBinding(); - if (binding == null || !Modifier.isStatic(binding.getModifiers())) { - return null; - } - TypeDeclaration declaringType = null; - if (binding.getDeclaringClass() != null) { - declaringType = context.getTypeDeclaration(binding.getDeclaringClass().getQualifiedName()); - } + String methodName = invocation.getName().getIdentifier(); Expression receiver = invocation.getExpression(); + TypeDeclaration declaringType = null; + if (binding != null) { + if (!Modifier.isStatic(binding.getModifiers())) { + return null; + } + if (binding.getDeclaringClass() != null) { + declaringType = context.getTypeDeclaration(binding.getDeclaringClass().getQualifiedName()); + } + } if (declaringType == null && receiver != null) { declaringType = resolveHelperDeclaringType(receiver, compilationUnit, context); } @@ -992,9 +1005,14 @@ public final class EntryPointBindingExpander { if (declaringType == null) { return null; } - MethodDeclaration method = context.findMethodDeclaration( - declaringType, invocation.getName().getIdentifier(), true); - if (method == null || method.getBody() == null) { + MethodDeclaration method = context.findMethodDeclaration(declaringType, methodName, true); + if (method == null || method.getBody() == null || !isStaticMethod(method)) { + return null; + } + String methodKey = binding != null && binding.getKey() != null + ? binding.getKey() + : context.getFqn(declaringType) + "#" + methodName; + if (!visitingMethods.add(methodKey)) { return null; } Expression returnExpression = extractSingleReturnExpression(method.getBody()); @@ -1005,7 +1023,24 @@ public final class EntryPointBindingExpander { if (direct != null) { return direct; } - return extractDoubleBraceMapPutEntries(returnExpression, declaringType, context); + Map doubleBrace = extractDoubleBraceMapPutEntries(returnExpression, declaringType, context); + if (doubleBrace != null) { + return doubleBrace; + } + if (returnExpression instanceof MethodInvocation) { + return extractCompileTimeMapFromStaticMethodCall( + returnExpression, declaringType, compilationUnit, context, visitingMethods); + } + return resolveCompileTimeMapEntries(returnExpression, declaringType, compilationUnit, context); + } + + private static boolean isStaticMethod(MethodDeclaration method) { + for (Object modifierObj : method.modifiers()) { + if (modifierObj instanceof Modifier modifier && modifier.isStatic()) { + return true; + } + } + return false; } private static Expression extractSingleReturnExpression(Block body) { 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 fb839e1..3ba4843 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 @@ -4321,4 +4321,275 @@ class EntryPointBindingExpanderTest { assertThat(variants).isEmpty(); } + + @Test + void shouldExpandCommandKeyFromNestedStaticFactoryDelegation(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Map; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + private static final Map ROUTES = routes(); + static Map routes() { + return seed(); + } + static Map seed() { + return 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 shouldExpandCommandKeyFromStaticFactoryReturningStaticFieldMap(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Map; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + private static final Map ROUTES = routes(); + private static final Map SEED = Map.of( + "order.pay", OrderEvent.PAY, + "order.ship", OrderEvent.SHIP + ); + static Map routes() { + return SEED; + } + 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 shouldExpandCommandKeyFromStaticFactoryReturningMapOfEntries(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Map; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + private static final Map ROUTES = routes(); + static Map routes() { + return Map.ofEntries( + Map.entry("order.pay", OrderEvent.PAY), + Map.entry("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 shouldNotExpandCommandKeyFromStaticBlockLoopPopulation(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.HashMap; + import java.util.List; + import java.util.Map; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + private static final Map ROUTES; + static { + ROUTES = new HashMap<>(); + for (String key : List.of("order.pay", "order.ship")) { + ROUTES.put(key, OrderEvent.PAY); + } + } + 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).isEmpty(); + } + + @Test + void shouldNotExpandCommandKeyFromRecursiveStaticFactory(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Map; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + private static final Map ROUTES = routes(); + static Map routes() { + return routes(); + } + 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).isEmpty(); + } } 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 6bf4d99..cdd6988 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 @@ -729,4 +729,81 @@ class MultiModulePathBindingExpanderTest { assertThat(variants).extracting(map -> map.get("commandKey")) .containsExactlyInAnyOrder("order.pay", "order.ship"); } + + @Test + void shouldExpandCommandKeyFromCrossModuleStaticFactoryReturningMapOf(@TempDir Path tempDir) throws Exception { + Path root = tempDir.resolve("order-system"); + 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/routes")); + Files.writeString(apiModule.resolve("build.gradle"), ""); + Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """ + package com.example.routes; + import java.util.Map; + public class CommandRoutes { + public static Map seed() { + return Map.of( + "order.pay", "OrderEvent.PAY", + "order.ship", "OrderEvent.SHIP"); + } + } + """); + + 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/GenericCommandController.java"), """ + package com.example; + import com.example.routes.CommandRoutes; + import java.util.Map; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + private static final Map ROUTES = CommandRoutes.seed(); + void trigger(String commandKey) { + OrderEvent event = OrderEvent.valueOf(ROUTES.get(commandKey)); + StateMachine sm = new StateMachine(); + sm.sendEvent(event); + } + } + enum OrderEvent { PAY, SHIP } + class StateMachine { void sendEvent(OrderEvent event) {} } + """); + + 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.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, callGraph); + + assertThat(variants).extracting(map -> map.get("commandKey")) + .containsExactlyInAnyOrder("order.pay", "order.ship"); + } }