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 84168a3..a6c1329 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 @@ -720,6 +720,54 @@ public final class EntryPointBindingExpander { if (block == null) { continue; } + LinkedHashSet mapBuilderLocals = new LinkedHashSet<>(); + LinkedHashSet linkedLocals = new LinkedHashSet<>(); + block.accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationStatement node) { + for (Object fragmentObj : node.fragments()) { + if (!(fragmentObj instanceof VariableDeclarationFragment fragment)) { + continue; + } + if (isPlainHashMapCreation(fragment.getInitializer())) { + mapBuilderLocals.add(fragment.getName().getIdentifier()); + } + } + return super.visit(node); + } + + @Override + public boolean visit(Assignment node) { + if (invalid[0] || node.getOperator() != Assignment.Operator.ASSIGN) { + return super.visit(node); + } + String assignedField = assignmentTargetName(node.getLeftHandSide()); + if (!fieldName.equals(assignedField)) { + return super.visit(node); + } + Expression right = node.getRightHandSide(); + Map direct = extractMapOfLiteralEntries(right, typeDecl, context); + if (direct != null) { + entries.putAll(direct); + foundPut[0] = true; + return super.visit(node); + } + Map doubleBrace = extractDoubleBraceMapPutEntries(right, typeDecl, context); + if (doubleBrace != null) { + entries.putAll(doubleBrace); + foundPut[0] = true; + return super.visit(node); + } + if (right instanceof SimpleName localName + && mapBuilderLocals.contains(localName.getIdentifier())) { + linkedLocals.add(localName.getIdentifier()); + } + return super.visit(node); + } + }); + LinkedHashSet putReceivers = new LinkedHashSet<>(); + putReceivers.add(fieldName); + putReceivers.addAll(linkedLocals); block.accept(new ASTVisitor() { @Override public boolean visit(MethodInvocation node) { @@ -727,7 +775,7 @@ public final class EntryPointBindingExpander { || node.arguments().size() != 2) { return super.visit(node); } - if (!mapReceiverReferencesField(node.getExpression(), fieldName)) { + if (!mapReceiverReferencesAnyField(node.getExpression(), putReceivers)) { return super.visit(node); } String key = resolveCompileTimeStringKey( @@ -748,6 +796,48 @@ public final class EntryPointBindingExpander { return entries; } + private static String assignmentTargetName(Expression expression) { + if (expression instanceof SimpleName simpleName) { + return simpleName.getIdentifier(); + } + if (expression instanceof FieldAccess fieldAccess) { + return fieldAccess.getName().getIdentifier(); + } + return null; + } + + private static boolean isPlainHashMapCreation(Expression expression) { + if (!(expression instanceof ClassInstanceCreation creation)) { + return false; + } + if (creation.getAnonymousClassDeclaration() != null) { + return false; + } + org.eclipse.jdt.core.dom.ITypeBinding typeBinding = creation.resolveTypeBinding(); + if (typeBinding != null) { + String qualifiedName = typeBinding.getErasure().getQualifiedName(); + return qualifiedName.endsWith("HashMap") || qualifiedName.endsWith("LinkedHashMap"); + } + String typeName = creation.getType().toString(); + return typeName.contains("HashMap") || typeName.contains("LinkedHashMap"); + } + + private static boolean mapReceiverReferencesAnyField(Expression receiver, Set fieldNames) { + if (receiver == null) { + return false; + } + if (receiver instanceof SimpleName simpleName) { + return fieldNames.contains(simpleName.getIdentifier()); + } + if (receiver instanceof FieldAccess fieldAccess) { + return fieldNames.contains(fieldAccess.getName().getIdentifier()); + } + if (receiver instanceof QualifiedName qualifiedName) { + return fieldNames.contains(qualifiedName.getName().getIdentifier()); + } + return false; + } + private static Map extractDoubleBraceMapPutEntries( Expression initializer, TypeDeclaration declaringType, @@ -809,19 +899,6 @@ public final class EntryPointBindingExpander { return false; } - private static boolean mapReceiverReferencesField(Expression receiver, String fieldName) { - if (receiver instanceof SimpleName simpleName) { - return fieldName.equals(simpleName.getIdentifier()); - } - if (receiver instanceof FieldAccess fieldAccess) { - return fieldName.equals(fieldAccess.getName().getIdentifier()); - } - if (receiver instanceof QualifiedName qualifiedName) { - return fieldName.equals(qualifiedName.getName().getIdentifier()); - } - return false; - } - 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 0154a7c..13b9455 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 @@ -3208,4 +3208,225 @@ class EntryPointBindingExpanderTest { assertThat(variants).isEmpty(); } + + @Test + void shouldExpandCommandKeyFromStaticBlockLocalVariableMapPutEntries(@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; + static { + Map routes = new HashMap<>(); + routes.put("order.pay", OrderEvent.PAY); + routes.put("order.ship", OrderEvent.SHIP); + ROUTES = 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).extracting(map -> map.get("commandKey")) + .containsExactlyInAnyOrder("order.pay", "order.ship"); + } + + @Test + void shouldExpandCommandKeyFromStaticBlockMapOfAssignment(@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 = 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 shouldNotExpandCommandKeyFromStaticBlockLocalVariableWithUnlinkedPuts(@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; + static { + Map routes = new HashMap<>(); + routes.put("order.pay", OrderEvent.PAY); + routes.put("order.ship", OrderEvent.SHIP); + ROUTES = Map.of("order.other", 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).extracting(map -> map.get("commandKey")) + .containsExactly("order.other"); + } + + @Test + void shouldNotExpandCommandKeyFromStaticBlockLocalVariableWithComputedPutKeys(@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; + static { + Map routes = new HashMap<>(); + routes.put(prefix("order.pay"), OrderEvent.PAY); + routes.put("order.ship", OrderEvent.SHIP); + ROUTES = routes; + } + static String prefix(String value) { + return value; + } + 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(); + } }