diff --git a/TODO.md b/TODO.md index b3aa5e9..d3d8409 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,9 @@ 1.extract from all possible formats (ask chatgpt) -json, xml, maybe yaml \ No newline at end of file +json, xml, maybe yaml + +2. [PLAN] Support external dependencies (JARs/compiled classes): + - Enable binding resolution in ASTParser (`setResolveBindings(true)`). + - Configure ASTParser with project classpath (including JARs). + - Refactor `CodebaseContext` to handle resolved bindings, not just local AST nodes. + - Implement tests using external libraries as base classes. \ No newline at end of file diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregator.java index d10d945..7272398 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregator.java @@ -50,9 +50,12 @@ public class StateMachineAggregator { } private void aggregateTransitionsRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, List allTransitions, Set visitedClasses, Set visitedMethods, Map argsMap) { - if (currentTd == null || visitedClasses.contains(currentTd.getName().getFullyQualifiedName())) + if (currentTd == null) return; - visitedClasses.add(currentTd.getName().getFullyQualifiedName()); + String fqn = context.getFqn(currentTd); + if (visitedClasses.contains(fqn)) + return; + visitedClasses.add(fqn); for (MethodDeclaration method : currentTd.getMethods()) { if (isConfigureTransitionsMethod(method)) { @@ -233,31 +236,41 @@ public class StateMachineAggregator { } private List unrollCollection(Expression expr, Map argsMap) { - if (expr instanceof MethodInvocation mi) { - String name = mi.getName().getIdentifier(); - Expression receiver = mi.getExpression(); - if ((name.equals("of") && receiver != null && (receiver.toString().equals("Stream") || receiver.toString().equals("List"))) - || (name.equals("asList") && receiver != null && receiver.toString().equals("Arrays"))) { - if (mi.arguments().size() == 1) { - Expression arg = (Expression) mi.arguments().get(0); - if (arg instanceof SimpleName sn) { - Object resolved = argsMap.get(sn.getIdentifier()); - if (resolved instanceof List list) - return (List) list; + Set visited = new HashSet<>(); + Expression current = expr; + while (current != null) { + if (!visited.add(current)) + break; + + if (current instanceof MethodInvocation mi) { + String name = mi.getName().getIdentifier(); + Expression receiver = mi.getExpression(); + if ((name.equals("of") && receiver != null && (receiver.toString().equals("Stream") || receiver.toString().equals("List"))) + || (name.equals("asList") && receiver != null && receiver.toString().equals("Arrays"))) { + if (mi.arguments().size() == 1) { + Expression arg = (Expression) mi.arguments().get(0); + if (arg instanceof SimpleName sn) { + Object resolved = argsMap.get(sn.getIdentifier()); + if (resolved instanceof List list) + return (List) list; + } } + return (List) mi.arguments(); } - return (List) mi.arguments(); } + if (current instanceof SimpleName sn) { + Object resolved = argsMap.get(sn.getIdentifier()); + if (resolved instanceof List list) + return (List) list; + if (resolved instanceof Expression e) { + current = e; + continue; + } + } + if (current instanceof ArrayInitializer ai) + return (List) ai.expressions(); + break; } - if (expr instanceof SimpleName sn) { - Object resolved = argsMap.get(sn.getIdentifier()); - if (resolved instanceof List list) - return (List) list; - if (resolved instanceof Expression e) - return unrollCollection(e, argsMap); - } - if (expr instanceof ArrayInitializer ai) - return (List) ai.expressions(); return Collections.emptyList(); } @@ -375,15 +388,17 @@ public class StateMachineAggregator { private Object resolve(Expression expr, Map argsMap) { if (expr instanceof NullLiteral) return null; - if (expr instanceof SimpleName sn) { + Set visited = new HashSet<>(); + Object current = expr; + while (current instanceof SimpleName sn) { + if (!visited.add((SimpleName) current)) + break; Object resolved = argsMap.get(sn.getIdentifier()); - if (resolved instanceof Expression nextExpr && nextExpr != expr) { - return resolve(nextExpr, argsMap); - } - if (resolved != null) - return resolved; + if (resolved == null || resolved == current) + break; + current = resolved; } - return expr; + return current; } private Expression resolveExpression(Expression expr, Map argsMap) { @@ -403,37 +418,64 @@ public class StateMachineAggregator { } private boolean isTransitionChainEntry(MethodInvocation mi) { - String name = mi.getName().getIdentifier(); - if (TransitionType.fromMethodName(name).isPresent()) - return true; - Expression receiver = mi.getExpression(); - if (receiver instanceof MethodInvocation next) - return isTransitionChainEntry(next); + Expression current = mi; + while (current instanceof MethodInvocation call) { + String name = call.getName().getIdentifier(); + if (TransitionType.fromMethodName(name).isPresent()) + return true; + current = call.getExpression(); + } return false; } private boolean isTransitionChainContinuation(MethodInvocation mi, Map argsMap) { - String name = mi.getName().getIdentifier(); - if (Set.of("and", "source", "target", "event", "first", "then", "last").contains(name)) { - Expression receiver = mi.getExpression(); - if (receiver instanceof MethodInvocation next) - return isTransitionChainContinuation(next, argsMap) || isTransitionChainEntry(next); - Object resolved = resolve(receiver, argsMap); - return resolved instanceof MethodInvocation || resolved == null; // null means it's a parameter we track + Expression current = mi; + Set visited = new HashSet<>(); + while (current instanceof MethodInvocation call) { + if (!visited.add(current)) + break; + String name = call.getName().getIdentifier(); + if (Set.of("and", "source", "target", "event", "first", "then", "last").contains(name)) { + Expression receiver = call.getExpression(); + if (receiver instanceof MethodInvocation next) { + current = next; + continue; + } + Object resolved = resolve(receiver, argsMap); + if (resolved instanceof MethodInvocation next) { + current = next; + continue; + } + return resolved == null; // null means it's a parameter we track + } + if (TransitionType.fromMethodName(name).isPresent()) + return true; + break; } return false; } private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td) { + return findMethodInHierarchy(methodName, td, new HashSet<>()); + } + + private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td, Set visited) { if (td == null) return null; + + String fqn = context.getFqn(td); + if (visited.contains(fqn)) + return null; + visited.add(fqn); + for (MethodDeclaration method : td.getMethods()) if (method.getName().getIdentifier().equals(methodName)) return method; + Type superclassType = td.getSuperclassType(); if (superclassType != null) { - TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType)); - return findMethodInHierarchy(methodName, superclassTd); + TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), (CompilationUnit) td.getRoot()); + return findMethodInHierarchy(methodName, superclassTd, visited); } return null; } @@ -466,9 +508,12 @@ public class StateMachineAggregator { } private void aggregateStatesRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, Set initialStates, Set endStates, Set visitedClasses, Set visitedMethods, Map argsMap) { - if (currentTd == null || visitedClasses.contains(currentTd.getName().getFullyQualifiedName())) + if (currentTd == null) return; - visitedClasses.add(currentTd.getName().getFullyQualifiedName()); + String fqn = context.getFqn(currentTd); + if (visitedClasses.contains(fqn)) + return; + visitedClasses.add(fqn); for (MethodDeclaration method : currentTd.getMethods()) { if (isConfigureStatesMethod(method)) { @@ -509,14 +554,25 @@ public class StateMachineAggregator { } private boolean isWithStatesChain(MethodInvocation mi, Map argsMap) { - if (mi.getName().getIdentifier().equals("withStates")) - return true; - Expression receiver = mi.getExpression(); - if (receiver instanceof MethodInvocation next) - return isWithStatesChain(next, argsMap); - Object resolved = resolve(receiver, argsMap); - if (resolved instanceof MethodInvocation next) - return isWithStatesChain(next, argsMap); + Expression current = mi; + Set visited = new HashSet<>(); + while (current instanceof MethodInvocation call) { + if (!visited.add(current)) + break; + if (call.getName().getIdentifier().equals("withStates")) + return true; + Expression receiver = call.getExpression(); + if (receiver instanceof MethodInvocation next) { + current = next; + continue; + } + Object resolved = resolve(receiver, argsMap); + if (resolved instanceof MethodInvocation next) { + current = next; + continue; + } + break; + } return false; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index 4adc68a..6f929b1 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -20,6 +20,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -28,6 +29,7 @@ public class CodebaseContext { private final Map classes = new HashMap<>(); private final Map classPaths = new HashMap<>(); private final Map simpleNameToFqn = new HashMap<>(); + private final Set ambiguousSimpleNames = new HashSet<>(); public void scan(Path rootDir) throws IOException { List javaFiles = FileUtils.findFilesWithExtension(Set.of(rootDir), ".java"); @@ -41,7 +43,15 @@ public class CodebaseContext { String fqn = packageName.isEmpty() ? simpleName : packageName + "." + simpleName; classes.put(fqn, cu); classPaths.put(fqn, javaFile); - simpleNameToFqn.put(simpleName, fqn); + + if (simpleNameToFqn.containsKey(simpleName)) { + String existingFqn = simpleNameToFqn.get(simpleName); + if (!existingFqn.equals(fqn)) { + ambiguousSimpleNames.add(simpleName); + } + } else { + simpleNameToFqn.put(simpleName, fqn); + } } } } @@ -101,7 +111,13 @@ public class CodebaseContext { if (cu != null) return findTypeInCu(cu, name); - // If not found, it might be a simple name + // If it's a simple name, check if it's ambiguous + if (ambiguousSimpleNames.contains(name)) { + // System.err.println("Warning: Ambiguous simple name '" + name + "'. Provide FQN to resolve correctly."); + return null; // Don't return a random class if it's ambiguous + } + + // If not ambiguous, return the one we found during scan String fqn = simpleNameToFqn.get(name); if (fqn != null) { cu = classes.get(fqn); @@ -226,6 +242,16 @@ public class CodebaseContext { } public boolean extendsStateMachineConfigurerAdapter(TypeDeclaration td) { + return extendsStateMachineConfigurerAdapter(td, new HashSet<>()); + } + + private boolean extendsStateMachineConfigurerAdapter(TypeDeclaration td, Set visited) { + String fqn = getFqn(td); + if (visited.contains(fqn)) { + return false; + } + visited.add(fqn); + Set knownAdapters = Set.of("EnumStateMachineConfigurerAdapter", "StateMachineConfigurerAdapter", "StateMachineConfigurer"); CompilationUnit cu = (CompilationUnit) td.getRoot(); @@ -236,7 +262,7 @@ public class CodebaseContext { if (knownAdapters.contains(superclassName)) return true; TypeDeclaration superTd = getTypeDeclaration(superclassName, cu); - if (superTd != null && extendsStateMachineConfigurerAdapter(superTd)) + if (superTd != null && extendsStateMachineConfigurerAdapter(superTd, visited)) return true; } @@ -247,7 +273,7 @@ public class CodebaseContext { if (knownAdapters.contains(interfaceName)) return true; TypeDeclaration interfaceTd = getTypeDeclaration(interfaceName, cu); - if (interfaceTd != null && extendsStateMachineConfigurerAdapter(interfaceTd)) + if (interfaceTd != null && extendsStateMachineConfigurerAdapter(interfaceTd, visited)) return true; } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ResolutionRobustnessTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ResolutionRobustnessTest.java new file mode 100644 index 0000000..1f02cfa --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ResolutionRobustnessTest.java @@ -0,0 +1,91 @@ +package click.kamil.springstatemachineexporter; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ResolutionRobustnessTest { + + @Test + void testStackOverflowPreventionWithCyclicInheritance(@TempDir Path tempDir) throws IOException { + Path fooDir = tempDir.resolve("com/foo"); + Files.createDirectories(fooDir); + // Cyclic inheritance: Config extends Config + Files.writeString(fooDir.resolve("Config.java"), + "package com.foo;\n" + + "public class Config extends Config {\n" + + " public void configure() {}\n" + + "}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("com.foo.Config"); + assertThat(td).isNotNull(); + + // This should not throw StackOverflowError + boolean result = context.extendsStateMachineConfigurerAdapter(td); + assertThat(result).isFalse(); + } + + @Test + void testShadowingResolution(@TempDir Path tempDir) throws IOException { + // Package com.foo has a class Config + Path fooDir = tempDir.resolve("com/foo"); + Files.createDirectories(fooDir); + Files.writeString(fooDir.resolve("Config.java"), "package com.foo; public class Config {}"); + + // Package com.bar also has a class Config + Path barDir = tempDir.resolve("com/bar"); + Files.createDirectories(barDir); + Files.writeString(barDir.resolve("Config.java"), "package com.bar; public class Config {}"); + + // App class imports com.bar.Config + Path appDir = tempDir.resolve("com/app"); + Files.createDirectories(appDir); + String appSource = "package com.app;\n" + + "import com.bar.Config;\n" + + "public class App extends Config {}"; + Path appFile = appDir.resolve("App.java"); + Files.writeString(appFile, appSource); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + TypeDeclaration appTd = context.getTypeDeclaration("com.app.App"); + assertThat(appTd).isNotNull(); + + // Resolution in App.java context should pick com.bar.Config due to import + TypeDeclaration resolvedTd = context.getTypeDeclaration("Config", (CompilationUnit) appTd.getRoot()); + assertThat(context.getFqn(resolvedTd)).isEqualTo("com.bar.Config"); + } + + @Test + void testAmbiguityHandling(@TempDir Path tempDir) throws IOException { + // Two classes with same name in different packages, no imports in context + Files.createDirectories(tempDir.resolve("p1")); + Files.writeString(tempDir.resolve("p1/X.java"), "package p1; public class X {}"); + + Files.createDirectories(tempDir.resolve("p2")); + Files.writeString(tempDir.resolve("p2/X.java"), "package p2; public class X {}"); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + // Ambiguous simple name should return null to prevent incorrect resolution + TypeDeclaration td = context.getTypeDeclaration("X"); + assertThat(td).isNull(); + + // Exact FQN should still work + assertThat(context.getTypeDeclaration("p1.X")).isNotNull(); + assertThat(context.getTypeDeclaration("p2.X")).isNotNull(); + } +}