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 5f6f2f3..2062109 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 @@ -603,22 +603,70 @@ public final class EntryPointBindingExpander { String helperName = helperInvocation.getName().getIdentifier(); CompilationUnit compilationUnit = ownerMethod.getRoot() instanceof CompilationUnit cu ? cu : null; Expression receiver = helperInvocation.getExpression(); - TypeDeclaration declaringType; if (receiver == null) { - ASTNode parent = ownerMethod.getParent(); - if (!(parent instanceof TypeDeclaration typeDeclaration)) { - return null; - } - declaringType = typeDeclaration; - } else { - declaringType = resolveHelperDeclaringType(receiver, compilationUnit, context); - if (declaringType == null) { - return null; + MethodDeclaration ownerHelper = findInstanceHelperOnOwnerType(ownerMethod, helperName, context); + if (ownerHelper != null) { + return ownerHelper; } + return findStaticImportHelper(helperName, compilationUnit, context); + } + TypeDeclaration declaringType = resolveHelperDeclaringType(receiver, compilationUnit, context); + if (declaringType == null) { + return null; } return context.findMethodDeclaration(declaringType, helperName, true); } + private static MethodDeclaration findInstanceHelperOnOwnerType( + MethodDeclaration ownerMethod, + String helperName, + CodebaseContext context) { + ASTNode parent = ownerMethod.getParent(); + if (!(parent instanceof TypeDeclaration typeDeclaration)) { + return null; + } + return context.findMethodDeclaration(typeDeclaration, helperName, true); + } + + private static MethodDeclaration findStaticImportHelper( + String helperName, + CompilationUnit compilationUnit, + CodebaseContext context) { + if (compilationUnit == null || helperName == null || helperName.isBlank()) { + return null; + } + for (Object importObject : compilationUnit.imports()) { + if (!(importObject instanceof ImportDeclaration importDeclaration) || !importDeclaration.isStatic()) { + continue; + } + String importedName = importDeclaration.getName().getFullyQualifiedName(); + if (importDeclaration.isOnDemand()) { + MethodDeclaration helper = findHelperOnType(importedName, helperName, context); + if (helper != null) { + return helper; + } + continue; + } + if (!importedName.endsWith("." + helperName)) { + continue; + } + String className = importedName.substring(0, importedName.lastIndexOf('.')); + return findHelperOnType(className, helperName, context); + } + return null; + } + + private static MethodDeclaration findHelperOnType( + String typeName, + String helperName, + CodebaseContext context) { + TypeDeclaration typeDeclaration = context.getTypeDeclaration(typeName); + if (typeDeclaration == null) { + return null; + } + return context.findMethodDeclaration(typeDeclaration, helperName, true); + } + private static TypeDeclaration resolveHelperDeclaringType( Expression receiver, CompilationUnit compilationUnit, 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 134f41a..47962af 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 @@ -687,4 +687,206 @@ class EntryPointBindingExpanderTest { assertThat(variants).extracting(map -> map.get("commandKey")) .containsExactlyInAnyOrder("order.pay", "order.ship"); } + + @Test + void shouldExpandEventWhenParameterIsPassedThroughImportedCrossPackageHelper(@TempDir Path tempDir) throws Exception { + Files.createDirectories(tempDir.resolve("com/example/util")); + Files.writeString(tempDir.resolve("com/example/util/EventNormalizer.java"), """ + package com.example.util; + public class EventNormalizer { + public static String normalize(String raw) { + return raw.toUpperCase(); + } + } + """); + Files.writeString(tempDir.resolve("com/example/OrderGateway.java"), """ + package com.example; + import com.example.util.EventNormalizer; + public class OrderController { + OrderGateway gateway; + public void transition(String event) { + gateway.trigger(event); + } + } + class OrderGateway { + void trigger(String event) { + OrderEvent.valueOf(EventNormalizer.normalize(event)); + } + } + enum OrderEvent { PAY, SHIP } + """); + + 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.OrderController") + .methodName("transition") + .name("POST /api/order/{event}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("event")) + .containsExactlyInAnyOrder("PAY", "SHIP"); + } + + @Test + void shouldNotExpandWhenSingleArgHelperReturnsConstantInsteadOfForwarding(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class OrderController { + OrderGateway gateway; + public void transition(String event) { + gateway.trigger(event); + } + } + class OrderGateway { + void trigger(String event) { + OrderEvent.valueOf(spoof(event)); + } + String spoof(String raw) { + return "PAY"; + } + } + 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.OrderController") + .methodName("transition") + .name("POST /api/order/{event}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).isEmpty(); + } + + @Test + void shouldExpandEventWhenParameterIsPassedThroughStaticImportHelper(@TempDir Path tempDir) throws Exception { + Files.createDirectories(tempDir.resolve("com/example/util")); + Files.writeString(tempDir.resolve("com/example/util/EventNormalizer.java"), """ + package com.example.util; + public class EventNormalizer { + public static String normalize(String raw) { + return raw.toUpperCase(); + } + } + """); + Files.writeString(tempDir.resolve("com/example/OrderGateway.java"), """ + package com.example; + import static com.example.util.EventNormalizer.normalize; + public class OrderController { + OrderGateway gateway; + public void transition(String event) { + gateway.trigger(event); + } + } + class OrderGateway { + void trigger(String event) { + OrderEvent.valueOf(normalize(event)); + } + } + enum OrderEvent { PAY, SHIP } + """); + + 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.OrderController") + .methodName("transition") + .name("POST /api/order/{event}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("event")) + .containsExactlyInAnyOrder("PAY", "SHIP"); + } + + @Test + void shouldExpandEventWhenParameterIsPassedThroughChainedSingleArgHelpers(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class OrderController { + OrderGateway gateway; + public void transition(String event) { + gateway.trigger(event); + } + } + class OrderGateway { + void trigger(String event) { + OrderEvent.valueOf(toUpperCase(trim(event))); + } + String trim(String raw) { + return raw.trim(); + } + String toUpperCase(String raw) { + return raw.toUpperCase(); + } + } + 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.OrderController") + .methodName("transition") + .name("POST /api/order/{event}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("event")) + .containsExactlyInAnyOrder("PAY", "SHIP"); + } }