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 d71e3e0..9ba0b83 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 @@ -638,6 +638,13 @@ public final class EntryPointBindingExpander { if (direct != null) { return direct; } + if (receiver instanceof MethodInvocation methodCall) { + Map factory = extractCompileTimeMapFromStaticMethodCall( + methodCall, literalDeclaringType, compilationUnit, context); + if (factory != null) { + return factory; + } + } String fieldName = null; TypeDeclaration declaringType = null; if (receiver instanceof FieldAccess fieldAccess) { @@ -712,6 +719,12 @@ public final class EntryPointBindingExpander { if (doubleBrace != null) { return doubleBrace; } + CompilationUnit compilationUnit = typeDecl.getRoot() instanceof CompilationUnit cu ? cu : null; + Map factory = extractCompileTimeMapFromStaticMethodCall( + initializer, typeDecl, compilationUnit, context); + if (factory != null) { + return factory; + } } return extractStaticBlockMapPutEntries(typeDecl, fieldName, context); } @@ -773,6 +786,14 @@ public final class EntryPointBindingExpander { foundPut[0] = true; return super.visit(node); } + CompilationUnit compilationUnit = block.getRoot() instanceof CompilationUnit cu ? cu : null; + Map factory = extractCompileTimeMapFromStaticMethodCall( + right, typeDecl, compilationUnit, context); + if (factory != null) { + entries.putAll(factory); + foundPut[0] = true; + return super.visit(node); + } if (right instanceof SimpleName localName && mapBuilderLocals.contains(localName.getIdentifier())) { linkedLocals.add(localName.getIdentifier()); @@ -945,6 +966,65 @@ public final class EntryPointBindingExpander { return false; } + private static Map extractCompileTimeMapFromStaticMethodCall( + Expression expression, + TypeDeclaration defaultDeclaringType, + CompilationUnit compilationUnit, + CodebaseContext context) { + 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()); + } + Expression receiver = invocation.getExpression(); + if (declaringType == null && receiver != null) { + declaringType = resolveHelperDeclaringType(receiver, compilationUnit, context); + } + if (declaringType == null) { + declaringType = defaultDeclaringType; + } + if (declaringType == null) { + return null; + } + MethodDeclaration method = context.findMethodDeclaration( + declaringType, invocation.getName().getIdentifier(), true); + if (method == null || method.getBody() == null) { + return null; + } + Expression returnExpression = extractSingleReturnExpression(method.getBody()); + if (returnExpression == null) { + return null; + } + Map direct = extractMapOfLiteralEntries(returnExpression, declaringType, context); + if (direct != null) { + return direct; + } + return extractDoubleBraceMapPutEntries(returnExpression, declaringType, context); + } + + private static Expression extractSingleReturnExpression(Block body) { + Expression[] found = {null}; + boolean[] multiple = {false}; + body.accept(new ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + if (found[0] != null) { + multiple[0] = true; + } else { + found[0] = node.getExpression(); + } + return super.visit(node); + } + }); + return multiple[0] ? null : found[0]; + } + private static Map extractMapOfLiteralEntries( Expression expression, TypeDeclaration declaringType, 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 fa64649..fb839e1 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 @@ -4044,4 +4044,281 @@ class EntryPointBindingExpanderTest { assertThat(variants).extracting(map -> map.get("commandKey")) .containsExactlyInAnyOrder("order.pay", "order.ship"); } + + @Test + void shouldExpandCommandKeyFromStaticFactoryMethodReturningMapOf(@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.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 shouldExpandCommandKeyFromCrossClassStaticFactoryMethodReturningMapOf(@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 = CommandRoutes.seed(); + void trigger(String commandKey) { + StateMachine sm = new StateMachine(); + sm.sendEvent(ROUTES.get(commandKey)); + } + } + class CommandRoutes { + static Map seed() { + return Map.of( + "order.pay", OrderEvent.PAY, + "order.ship", OrderEvent.SHIP + ); + } + } + 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 shouldExpandCommandKeyFromStaticBlockStaticFactoryAssignment(@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; + static { + ROUTES = routes(); + } + static Map routes() { + 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 shouldNotExpandCommandKeyFromStaticFactoryBuildingMapAtRuntime(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.HashMap; + 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() { + Map built = new HashMap<>(); + built.put("order.pay", OrderEvent.PAY); + built.put("order.ship", OrderEvent.SHIP); + return built; + } + 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 shouldNotExpandCommandKeyFromStaticFactoryWithMultipleReturns(@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(true); + static Map routes(boolean full) { + if (full) { + return Map.of( + "order.pay", OrderEvent.PAY, + "order.ship", OrderEvent.SHIP + ); + } + return Map.of("order.pay", 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(); + } }