diff --git a/settings.gradle b/settings.gradle index 212252e..32d2178 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,3 +20,5 @@ include ':state_machines:enterprise_order_system' include ':state_machines:multi_module_sample:api-module' include ':state_machines:multi_module_sample:core-module' include ':state_machines:state_machine_enterprise' +include 'state_machine_exporter_kotlin' +include ':state_machines:kotlin_simple_state_machine' diff --git a/state_machine_exporter/build.gradle b/state_machine_exporter/build.gradle index d335c9e..f50cca1 100644 --- a/state_machine_exporter/build.gradle +++ b/state_machine_exporter/build.gradle @@ -32,6 +32,9 @@ dependencies { // deps for parsing java AST implementation 'org.eclipse.jdt:org.eclipse.jdt.core:3.36.0' + // Kotlin plugin integration via ServiceLoader + runtimeOnly project(':state_machine_exporter_kotlin') + // Jackson for JSON support implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1' diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphEngine.java index 1817fa6..9e34099 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphEngine.java @@ -8,4 +8,8 @@ import java.util.List; public interface CallGraphEngine { List findChains(List entryPoints, List triggers); + + default java.util.Map> getCallGraph() { + return java.util.Collections.emptyMap(); + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CompositeCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CompositeCallGraphEngine.java new file mode 100644 index 0000000..3e3ec83 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CompositeCallGraphEngine.java @@ -0,0 +1,118 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; + +import java.util.*; + +public class CompositeCallGraphEngine implements CallGraphEngine { + private final List engines; + private final Map> mergedGraph = new HashMap<>(); + + public CompositeCallGraphEngine(List engines) { + this.engines = engines; + mergeGraphs(); + } + + private void mergeGraphs() { + for (CallGraphEngine engine : engines) { + Map> graph = engine.getCallGraph(); + if (graph != null) { + for (Map.Entry> entry : graph.entrySet()) { + String caller = normalizeCompanionFqn(entry.getKey()); + List edges = mergedGraph.computeIfAbsent(caller, k -> new ArrayList<>()); + for (CallEdge edge : entry.getValue()) { + String target = normalizeCompanionFqn(edge.getTargetMethod()); + edges.add(new CallEdge(target, edge.getArguments(), edge.getReceiver())); + } + } + } + } + } + + private String normalizeCompanionFqn(String fqn) { + if (fqn == null) return null; + return fqn.replace(".Companion.", "."); + } + + @Override + public Map> getCallGraph() { + return mergedGraph; + } + + @Override + public List findChains(List entryPoints, List triggers) { + List chains = new ArrayList<>(); + for (EntryPoint ep : entryPoints) { + String startMethod = normalizeCompanionFqn(ep.getClassName() + "." + ep.getMethodName()); + for (TriggerPoint tp : triggers) { + String targetMethod = normalizeCompanionFqn(tp.getClassName() + "." + tp.getMethodName()); + List> paths = findAllPaths(startMethod, targetMethod, new LinkedHashSet<>()); + for (List path : paths) { + chains.add(CallChain.builder() + .entryPoint(ep) + .methodChain(path) + .triggerPoint(tp) + .build()); + } + } + } + return chains; + } + + private List> findAllPaths(String start, String target, Set visited) { + List> result = new ArrayList<>(); + if (start.equals(target)) { + result.add(new ArrayList<>(List.of(start))); + return result; + } + if (!visited.add(start)) { + return result; + } + + List neighbors = mergedGraph.get(start); + if (neighbors != null) { + for (CallEdge edge : neighbors) { + String neighbor = edge.getTargetMethod(); + if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") || + neighbor.startsWith("jakarta.") || neighbor.startsWith("org.springframework.")) { + continue; + } + + if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) { + List path = new ArrayList<>(List.of(start, target)); + result.add(path); + } else { + List> subPaths = findAllPaths(neighbor, target, visited); + for (List subPath : subPaths) { + List path = new ArrayList<>(); + path.add(start); + path.addAll(subPath); + result.add(path); + } + } + } + } + visited.remove(start); + return result; + } + + private boolean isHeuristicMatch(String neighbor, String target) { + if (neighbor == null || target == null) return false; + if (neighbor.equals(target)) return true; + if (neighbor.contains(".") && target.contains(".")) { + String methodNeighbor = neighbor.substring(neighbor.lastIndexOf('.') + 1); + String methodTarget = target.substring(target.lastIndexOf('.') + 1); + if (methodNeighbor.equals(methodTarget)) { + String classNeighbor = neighbor.substring(0, neighbor.lastIndexOf('.')); + String classTarget = target.substring(0, target.lastIndexOf('.')); + String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor; + String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget; + return simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget); + } + } + return false; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CompositeIntelligenceProvider.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CompositeIntelligenceProvider.java new file mode 100644 index 0000000..ee89f4c --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CompositeIntelligenceProvider.java @@ -0,0 +1,53 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; + +import java.util.*; + +public class CompositeIntelligenceProvider implements CodebaseIntelligenceProvider { + private final List providers; + private final List engines; + + public CompositeIntelligenceProvider(List providers, List engines) { + this.providers = providers; + this.engines = engines; + } + + @Override + public List findTriggerPoints() { + List result = new ArrayList<>(); + for (CodebaseIntelligenceProvider provider : providers) { + result.addAll(provider.findTriggerPoints()); + } + return result; + } + + @Override + public List findEntryPoints() { + List result = new ArrayList<>(); + for (CodebaseIntelligenceProvider provider : providers) { + result.addAll(provider.findEntryPoints()); + } + return result; + } + + @Override + public List findCallChains(List entryPoints, List triggers) { + CompositeCallGraphEngine compositeEngine = new CompositeCallGraphEngine(engines); + return compositeEngine.findChains(entryPoints, triggers); + } + + @Override + public Map> resolveProperties() { + Map> result = new HashMap<>(); + for (CodebaseIntelligenceProvider provider : providers) { + Map> props = provider.resolveProperties(); + if (props != null) { + result.putAll(props); + } + } + return result; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java index ad8eb26..612962f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java @@ -24,6 +24,11 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { this.injectionAnalyzer = injectionAnalyzer; } + @Override + public Map> getCallGraph() { + return buildCallGraph(); + } + @SuppressWarnings("unchecked") protected Map> buildCallGraph() { Map> cached = (Map>) context.getCache().get("jdtCallGraph"); 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 55143b9..03fa7ac 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 @@ -22,9 +22,10 @@ import java.util.List; import java.util.Map; import java.util.Set; import lombok.extern.slf4j.Slf4j; +import click.kamil.springstatemachineexporter.spi.SourceContext; @Slf4j -public class CodebaseContext { +public class CodebaseContext implements SourceContext { private final Map classes = new HashMap<>(); private final Map classPaths = new HashMap<>(); private final Map simpleNameToFqn = new HashMap<>(); @@ -51,6 +52,11 @@ public class CodebaseContext { this.projectRoot = root; } + @Override + public Path getProjectRoot() { + return projectRoot; + } + public String getRelativePath(Path fullPath) { if (projectRoot != null && fullPath.isAbsolute() && fullPath.startsWith(projectRoot)) { return projectRoot.relativize(fullPath).toString(); @@ -88,6 +94,10 @@ public class CodebaseContext { this.classpath = classpath.toArray(new String[0]); } + public List getClasspath() { + return java.util.Arrays.asList(classpath); + } + public void setSourcepath(List sourcepath) { this.sourcepath = sourcepath.toArray(new String[0]); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/plugin/java/JavaLanguageAnalyzerPlugin.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/plugin/java/JavaLanguageAnalyzerPlugin.java new file mode 100644 index 0000000..baa6b9d --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/plugin/java/JavaLanguageAnalyzerPlugin.java @@ -0,0 +1,147 @@ +package click.kamil.springstatemachineexporter.plugin.java; + +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine; +import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; +import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine; +import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider; +import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser; +import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator; +import click.kamil.springstatemachineexporter.ast.app.TransitionStateUtils; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.model.Transition; +import click.kamil.springstatemachineexporter.spi.LanguageAnalyzerPlugin; +import click.kamil.springstatemachineexporter.spi.SourceContext; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.TypeDeclaration; + +import java.nio.file.Path; +import java.util.*; + +public class JavaLanguageAnalyzerPlugin implements LanguageAnalyzerPlugin { + + @Override + public boolean supports(Path projectRoot) { + return true; + } + + @Override + public SourceContext parseProject(Path projectRoot, List classpath, Set sourcePaths) { + CodebaseContext context = new CodebaseContext(); + context.setProjectRoot(projectRoot); + if (classpath != null && !classpath.isEmpty()) { + context.setClasspath(classpath); + } + + Path hintsFile = projectRoot.resolve("hints.json"); + if (!java.nio.file.Files.exists(hintsFile)) { + hintsFile = projectRoot.resolve("src/main/resources/hints.json"); + } + if (java.nio.file.Files.exists(hintsFile)) { + try { + context.loadLibraryHints(hintsFile); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + } + + List sp = sourcePaths.stream() + .map(p -> p.resolve("src/main/java")) + .filter(p -> java.nio.file.Files.exists(p)) + .map(Path::toString) + .toList(); + if (sp.isEmpty()) { + sp = sourcePaths.stream().map(Path::toString).toList(); + } + context.setSourcepath(sp); + + context.setResolveBindings(true); + try { + context.scan(sourcePaths, Collections.emptySet()); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + return context; + } + + @Override + public CallGraphEngine createCallGraphEngine(SourceContext context) { + if (context instanceof CodebaseContext jdtContext) { + return new JdtCallGraphEngine(jdtContext, null); + } + throw new IllegalArgumentException("Expected CodebaseContext"); + } + + @Override + public CodebaseIntelligenceProvider createIntelligenceProvider(SourceContext context) { + if (context instanceof CodebaseContext jdtContext) { + return new JdtIntelligenceProvider(jdtContext, jdtContext.getProjectRoot()); + } + throw new IllegalArgumentException("Expected CodebaseContext"); + } + + @Override + public List extractStateMachines(SourceContext context, boolean renderChoicesAsDiamonds) { + if (context instanceof CodebaseContext jdtContext) { + List results = new ArrayList<>(); + + // 1. Find entry point classes (annotated or extending adapter) + List entryPoints = jdtContext.findEntryPointClasses(List.of("EnableStateMachineFactory", "EnableStateMachine")); + for (TypeDeclaration td : entryPoints) { + String className = jdtContext.getFqn(td); + StateMachineAggregator aggregator = new StateMachineAggregator(jdtContext); + List transitions = aggregator.aggregateTransitions(td); + aggregator.aggregateStates(td); + Set initialStatesAst = aggregator.getInitialStates(); + Set endStatesAst = aggregator.getEndStates(); + + Set startStates = TransitionStateUtils.findStartStates(transitions, initialStatesAst); + Set endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst); + Set allStates = TransitionStateUtils.findAllStates(transitions, initialStatesAst, endStatesAst); + + if (allStates.isEmpty() && transitions.isEmpty()) { + continue; + } + + results.add(AnalysisResult.builder() + .name(className) + .states(allStates) + .transitions(transitions) + .startStates(startStates) + .endStates(endStates) + .renderChoicesAsDiamonds(renderChoicesAsDiamonds) + .build()); + } + + // 2. Find methods returning StateMachine beans + List beanMethods = jdtContext.findBeanMethodsReturning(Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")); + for (MethodDeclaration m : beanMethods) { + TypeDeclaration parentClass = (TypeDeclaration) m.getParent(); + String parentFqn = jdtContext.getFqn(parentClass); + String methodName = m.getName().getIdentifier(); + String uniqueName = parentFqn + "#" + methodName; + + List transitions = AstTransitionParser.parseTransitions(m, jdtContext); + + Set startStates = TransitionStateUtils.findStartStates(transitions, null); + Set endStates = TransitionStateUtils.findEndStates(transitions, null); + Set allStates = TransitionStateUtils.findAllStates(transitions, null, null); + + if (allStates.isEmpty() && transitions.isEmpty()) { + continue; + } + + results.add(AnalysisResult.builder() + .name(uniqueName) + .states(allStates) + .transitions(transitions) + .startStates(startStates) + .endStates(endStates) + .renderChoicesAsDiamonds(renderChoicesAsDiamonds) + .build()); + } + return results; + } + return Collections.emptyList(); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java index 0225dcc..86353b5 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java @@ -20,6 +20,11 @@ import click.kamil.springstatemachineexporter.exporter.StateMachineExporter; import lombok.extern.slf4j.Slf4j; import org.eclipse.jdt.core.dom.MethodDeclaration; import org.eclipse.jdt.core.dom.TypeDeclaration; +import click.kamil.springstatemachineexporter.spi.LanguageAnalyzerPlugin; +import click.kamil.springstatemachineexporter.spi.SourceContext; +import click.kamil.springstatemachineexporter.analysis.service.CompositeIntelligenceProvider; +import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine; +import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine; import java.io.IOException; import java.io.PrintWriter; @@ -137,8 +142,57 @@ public class ExportService { context.scan(allPaths, Collections.emptySet()); - CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir); - exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds, flows, machineFilter, activeProfiles != null ? activeProfiles : Collections.emptyList(), eventFormat, stateFormat); + // Load SPI plugins dynamically + ServiceLoader loader = ServiceLoader.load(LanguageAnalyzerPlugin.class); + List plugins = new ArrayList<>(); + for (LanguageAnalyzerPlugin plugin : loader) { + if (plugin.supports(inputDir)) { + plugins.add(plugin); + } + } + if (plugins.isEmpty()) { + plugins.add(new click.kamil.springstatemachineexporter.plugin.java.JavaLanguageAnalyzerPlugin()); + } + + List sourceContexts = new ArrayList<>(); + List providers = new ArrayList<>(); + List engines = new ArrayList<>(); + List extractedResults = new ArrayList<>(); + + for (LanguageAnalyzerPlugin plugin : plugins) { + SourceContext sc = plugin.parseProject(projectRoot, context.getClasspath(), allPaths); + sourceContexts.add(sc); + providers.add(plugin.createIntelligenceProvider(sc)); + engines.add(plugin.createCallGraphEngine(sc)); + extractedResults.addAll(plugin.extractStateMachines(sc, renderChoicesAsDiamonds)); + } + + CodebaseContext codebaseContext = context; + CodebaseIntelligenceProvider unifiedIntelligence; + if (plugins.size() == 1) { + unifiedIntelligence = providers.get(0); + if (sourceContexts.get(0) instanceof CodebaseContext jdtCtx) { + codebaseContext = jdtCtx; + } + } else { + unifiedIntelligence = new CompositeIntelligenceProvider(providers, engines); + } + + // Process all extracted state machines + for (AnalysisResult result : extractedResults) { + String name = result.getName(); + if (machineFilter != null && !name.contains(machineFilter)) { + continue; + } + + if (result.getFlows() == null || result.getFlows().isEmpty()) { + result.setFlows(flows); + } + + enrichmentService.enrich(result, codebaseContext, unifiedIntelligence); + resolveProperties(result, activeProfiles); + generateOutputs(outputDir, result, selectedFormats, eventFormat, stateFormat); + } } public void runJsonExporter(Path jsonFile, Path outputDir, List selectedFormats) throws IOException { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/spi/LanguageAnalyzerPlugin.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/spi/LanguageAnalyzerPlugin.java new file mode 100644 index 0000000..6011ca4 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/spi/LanguageAnalyzerPlugin.java @@ -0,0 +1,43 @@ +package click.kamil.springstatemachineexporter.spi; + +import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine; +import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; + +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +/** + * Service Provider Interface (SPI) for introducing support for different languages (Java, Kotlin, etc.). + */ +public interface LanguageAnalyzerPlugin { + + /** + * Determines if this plugin is capable of handling the provided project root. + * Often checks for file extensions (.kt, .java) within the root. + */ + boolean supports(Path projectRoot); + + /** + * Parses the codebase and sets up a SourceContext loaded with the language's AST structures. + */ + SourceContext parseProject(Path projectRoot, List classpath, Set sourcePaths); + + /** + * Creates a CallGraphEngine specialized for this language's syntax and paradigms. + */ + CallGraphEngine createCallGraphEngine(SourceContext context); + + /** + * Creates a CodebaseIntelligenceProvider specialized for this language to discover + * Spring State Machine configurations, entry points, and transitions. + */ + CodebaseIntelligenceProvider createIntelligenceProvider(SourceContext context); + + /** + * Finds and extracts all Spring State Machine models (configurations and beans) from the project. + */ + default List extractStateMachines(SourceContext context, boolean renderChoicesAsDiamonds) { + return java.util.Collections.emptyList(); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/spi/SourceContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/spi/SourceContext.java new file mode 100644 index 0000000..ded6abc --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/spi/SourceContext.java @@ -0,0 +1,43 @@ +package click.kamil.springstatemachineexporter.spi; + +import click.kamil.springstatemachineexporter.analysis.model.LibraryHint; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +/** + * An abstraction over a parsed source codebase (Java, Kotlin, etc.). + * Provides access to common metadata required by analysis engines. + */ +public interface SourceContext { + /** + * Gets the root directory of the scanned project. + */ + Path getProjectRoot(); + + /** + * Relativizes an absolute path against the project root. + */ + String getRelativePath(Path fullPath); + + /** + * Returns a relative file path for a given fully qualified class/type name. + */ + String getRelativePath(String fqn); + + /** + * Retrieves any loaded library hints (e.g. from hints.json). + */ + List getLibraryHints(); + + /** + * Retrieves application properties (e.g. from application.yml). + */ + Map> getProperties(); + + /** + * Gets an unstructured cache map that plugins can use to share data across processing phases. + */ + Map getCache(); +} diff --git a/state_machine_exporter_kotlin/build.gradle b/state_machine_exporter_kotlin/build.gradle new file mode 100644 index 0000000..5dba7a1 --- /dev/null +++ b/state_machine_exporter_kotlin/build.gradle @@ -0,0 +1,36 @@ +plugins { + id 'java' +} + +group = 'click.kamil.springstatemachineexporter' +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':state_machine_exporter') + + // Kotlin Compiler Embeddable for PSI/AST parsing + implementation 'org.jetbrains.kotlin:kotlin-compiler-embeddable:1.9.23' + implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.23' + + compileOnly 'org.projectlombok:lombok:1.18.46' + annotationProcessor 'org.projectlombok:lombok:1.18.46' + + testImplementation 'org.junit.jupiter:junit-jupiter-api:6.1.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:6.1.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:6.1.0' + testImplementation 'org.assertj:assertj-core:3.27.7' +} + +test { + useJUnitPlatform() +} diff --git a/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinCallGraphEngine.java b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinCallGraphEngine.java new file mode 100644 index 0000000..c4dd0cd --- /dev/null +++ b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinCallGraphEngine.java @@ -0,0 +1,170 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine; +import org.jetbrains.kotlin.psi.*; +import org.jetbrains.kotlin.resolve.calls.util.CallUtilKt; +import org.jetbrains.kotlin.resolve.calls.model.ResolvedCall; +import org.jetbrains.kotlin.descriptors.CallableDescriptor; + +import java.util.*; + +public class KotlinCallGraphEngine implements CallGraphEngine { + + private final KotlinSourceContext context; + private final Map> graph = new HashMap<>(); + + public KotlinCallGraphEngine(KotlinSourceContext context) { + this.context = context; + buildGraph(); + } + + private void buildGraph() { + if (context.getBindingContext() == null) { + return; + } + for (KtFile ktFile : context.getKtFiles()) { + ktFile.accept(new KtTreeVisitorVoid() { + private String currentClassFqn = null; + private String currentFunctionFqn = null; + + @Override + public void visitClassOrObject(KtClassOrObject ktClass) { + String oldClassFqn = currentClassFqn; + currentClassFqn = KotlinResolutionUtils.getClassFqn(ktClass, context.getBindingContext()); + super.visitClassOrObject(ktClass); + currentClassFqn = oldClassFqn; + } + + @Override + public void visitNamedFunction(KtNamedFunction function) { + String oldFunctionFqn = currentFunctionFqn; + currentFunctionFqn = KotlinResolutionUtils.getFunctionFqn(function, context.getBindingContext()); + super.visitNamedFunction(function); + currentFunctionFqn = oldFunctionFqn; + } + + @Override + public void visitCallExpression(KtCallExpression expression) { + if (currentFunctionFqn != null) { + ResolvedCall resolvedCall = + CallUtilKt.getResolvedCall(expression, context.getBindingContext()); + if (resolvedCall != null) { + CallableDescriptor descriptor = resolvedCall.getResultingDescriptor(); + if (descriptor != null) { + String targetFqn = KotlinResolutionUtils.getCallableFqn(descriptor); + if (targetFqn != null) { + List args = new ArrayList<>(); + for (ValueArgument valArg : expression.getValueArguments()) { + KtExpression argExpr = valArg.getArgumentExpression(); + if (argExpr != null) { + args.add(KotlinResolutionUtils.resolveArgument(argExpr, context.getBindingContext())); + } + } + CallEdge edge = new CallEdge(targetFqn, args, "unknown"); + graph.computeIfAbsent(currentFunctionFqn, k -> new ArrayList<>()).add(edge); + + // Polymorphic/Inheritance mapping: if target class has subclasses, register implementor edges + if (targetFqn.contains(".")) { + String className = targetFqn.substring(0, targetFqn.lastIndexOf('.')); + String methodName = targetFqn.substring(targetFqn.lastIndexOf('.') + 1); + List implementations = context.getImplementations().get(className); + if (implementations != null) { + for (String impl : implementations) { + CallEdge implEdge = new CallEdge(impl + "." + methodName, args, "unknown"); + graph.computeIfAbsent(currentFunctionFqn, k -> new ArrayList<>()).add(implEdge); + } + } + } + } + } + } + } + super.visitCallExpression(expression); + } + }); + } + } + + @Override + public Map> getCallGraph() { + return graph; + } + + @Override + public List findChains(List entryPoints, List triggers) { + List chains = new ArrayList<>(); + for (EntryPoint ep : entryPoints) { + String startMethod = ep.getClassName() + "." + ep.getMethodName(); + for (TriggerPoint tp : triggers) { + String targetMethod = tp.getClassName() + "." + tp.getMethodName(); + List> paths = findAllPaths(startMethod, targetMethod, new LinkedHashSet<>()); + for (List path : paths) { + chains.add(CallChain.builder() + .entryPoint(ep) + .methodChain(path) + .triggerPoint(tp) + .build()); + } + } + } + return chains; + } + + private List> findAllPaths(String start, String target, Set visited) { + List> result = new ArrayList<>(); + if (start.equals(target)) { + result.add(new ArrayList<>(List.of(start))); + return result; + } + if (!visited.add(start)) { + return result; + } + + List neighbors = graph.get(start); + if (neighbors != null) { + for (CallEdge edge : neighbors) { + String neighbor = edge.getTargetMethod(); + if (neighbor.startsWith("java.") || neighbor.startsWith("javax.") || + neighbor.startsWith("jakarta.") || neighbor.startsWith("org.springframework.")) { + continue; + } + + if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) { + List path = new ArrayList<>(List.of(start, target)); + result.add(path); + } else { + List> subPaths = findAllPaths(neighbor, target, visited); + for (List subPath : subPaths) { + List path = new ArrayList<>(); + path.add(start); + path.addAll(subPath); + result.add(path); + } + } + } + } + visited.remove(start); + return result; + } + + private boolean isHeuristicMatch(String neighbor, String target) { + if (neighbor == null || target == null) return false; + if (neighbor.equals(target)) return true; + if (neighbor.contains(".") && target.contains(".")) { + String methodNeighbor = neighbor.substring(neighbor.lastIndexOf('.') + 1); + String methodTarget = target.substring(target.lastIndexOf('.') + 1); + if (methodNeighbor.equals(methodTarget)) { + String classNeighbor = neighbor.substring(0, neighbor.lastIndexOf('.')); + String classTarget = target.substring(0, target.lastIndexOf('.')); + String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor; + String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget; + return simpleClassNeighbor.equalsIgnoreCase(simpleClassTarget); + } + } + return false; + } +} diff --git a/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinIntelligenceProvider.java b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinIntelligenceProvider.java new file mode 100644 index 0000000..6ee3756 --- /dev/null +++ b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinIntelligenceProvider.java @@ -0,0 +1,478 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; +import org.jetbrains.kotlin.psi.*; +import org.jetbrains.kotlin.resolve.BindingContext; +import org.jetbrains.kotlin.resolve.calls.util.CallUtilKt; +import org.jetbrains.kotlin.resolve.calls.model.ResolvedCall; +import org.jetbrains.kotlin.com.intellij.psi.PsiElement; +import org.jetbrains.kotlin.com.intellij.psi.PsiFile; + +import java.util.*; + +public class KotlinIntelligenceProvider implements CodebaseIntelligenceProvider { + + private final KotlinSourceContext context; + + public KotlinIntelligenceProvider(KotlinSourceContext context) { + this.context = context; + } + + @Override + public List findTriggerPoints() { + List triggers = new ArrayList<>(); + BindingContext bindingContext = context.getBindingContext(); + if (bindingContext == null) { + return triggers; + } + for (KtFile ktFile : context.getKtFiles()) { + ktFile.accept(new KtTreeVisitorVoid() { + private String currentClassFqn = null; + private String currentFunctionFqn = null; + + @Override + public void visitClassOrObject(KtClassOrObject ktClass) { + String oldClassFqn = currentClassFqn; + currentClassFqn = KotlinResolutionUtils.getClassFqn(ktClass, bindingContext); + super.visitClassOrObject(ktClass); + currentClassFqn = oldClassFqn; + } + + @Override + public void visitNamedFunction(KtNamedFunction function) { + String oldFunctionFqn = currentFunctionFqn; + currentFunctionFqn = KotlinResolutionUtils.getFunctionFqn(function, bindingContext); + super.visitNamedFunction(function); + currentFunctionFqn = oldFunctionFqn; + } + + @Override + public void visitCallExpression(KtCallExpression expression) { + KtExpression callee = expression.getCalleeExpression(); + if (callee instanceof KtNameReferenceExpression) { + String name = ((KtNameReferenceExpression) callee).getReferencedName(); + if ("sendEvent".equals(name) || "sendEventReactively".equals(name) || + "sendEvents".equals(name) || "sendEventCollect".equals(name) || + "sendEventMono".equals(name)) { + + String eventName = "UNKNOWN_EVENT"; + if (!expression.getValueArguments().isEmpty()) { + KtExpression arg = expression.getValueArguments().get(0).getArgumentExpression(); + if (arg != null) { + eventName = KotlinResolutionUtils.resolveArgument(arg, bindingContext); + } + } + + KtExpression receiver = null; + if (expression.getParent() instanceof KtDotQualifiedExpression dotExpr) { + if (dotExpr.getSelectorExpression() == expression) { + receiver = dotExpr.getReceiverExpression(); + } + } + + String stateType = null; + String eventType = null; + if (receiver != null) { + org.jetbrains.kotlin.types.KotlinType receiverType = bindingContext.getType(receiver); + if (receiverType != null && receiverType.getArguments().size() >= 2) { + stateType = receiverType.getArguments().get(0).getType().toString(); + eventType = receiverType.getArguments().get(1).getType().toString(); + } + } + + if (stateType == null || eventType == null) { + ResolvedCall resolvedCall = + CallUtilKt.getResolvedCall(expression, bindingContext); + if (resolvedCall != null) { + org.jetbrains.kotlin.resolve.scopes.receivers.ReceiverValue dispatchReceiver = resolvedCall.getDispatchReceiver(); + if (dispatchReceiver != null) { + org.jetbrains.kotlin.types.KotlinType receiverType = dispatchReceiver.getType(); + if (receiverType != null && receiverType.getArguments().size() >= 2) { + stateType = receiverType.getArguments().get(0).getType().toString(); + eventType = receiverType.getArguments().get(1).getType().toString(); + } + } + } + } + + boolean external = false; + if (receiver instanceof KtNameReferenceExpression refExpr) { + org.jetbrains.kotlin.descriptors.DeclarationDescriptor descriptor = + bindingContext.get(BindingContext.REFERENCE_TARGET, refExpr); + if (descriptor instanceof org.jetbrains.kotlin.descriptors.ValueParameterDescriptor) { + external = true; + } + } + + triggers.add(TriggerPoint.builder() + .event(eventName) + .sourceState(extractSourceState(expression)) + .className(currentClassFqn) + .methodName(currentFunctionFqn != null && currentFunctionFqn.contains(".") + ? currentFunctionFqn.substring(currentFunctionFqn.lastIndexOf('.') + 1) + : currentFunctionFqn) + .sourceFile(getRelativePathForElement(expression, context)) + .lineNumber(getLineNumber(expression)) + .stateTypeFqn(stateType) + .eventTypeFqn(eventType) + .external(external) + .constraint(findConditionConstraint(expression)) + .build()); + } + } + super.visitCallExpression(expression); + } + }); + } + return triggers; + } + + @Override + public List findEntryPoints() { + List entryPoints = new ArrayList<>(); + BindingContext bindingContext = context.getBindingContext(); + if (bindingContext == null) { + return entryPoints; + } + for (KtFile ktFile : context.getKtFiles()) { + ktFile.accept(new KtTreeVisitorVoid() { + private String currentClassFqn = null; + private String controllerBasePath = ""; + + @Override + public void visitClassOrObject(KtClassOrObject ktClass) { + String oldClassFqn = currentClassFqn; + String oldBasePath = controllerBasePath; + + currentClassFqn = KotlinResolutionUtils.getClassFqn(ktClass, bindingContext); + controllerBasePath = getControllerPath(ktClass, bindingContext); + + super.visitClassOrObject(ktClass); + + currentClassFqn = oldClassFqn; + controllerBasePath = oldBasePath; + } + + @Override + public void visitNamedFunction(KtNamedFunction function) { + super.visitNamedFunction(function); + if (currentClassFqn == null || function.getName() == null) { + return; + } + + RestMapping rest = getRestMapping(function, controllerBasePath, bindingContext); + if (rest != null) { + entryPoints.add(EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name(rest.verb + " " + rest.path) + .className(currentClassFqn) + .methodName(function.getName()) + .sourceFile(getRelativePathForElement(function, context)) + .metadata(Map.of("path", rest.path, "verb", rest.verb)) + .parameters(extractParameters(function, bindingContext)) + .build()); + } + + MessageMapping msg = getMessageMapping(function, bindingContext); + if (msg != null) { + entryPoints.add(EntryPoint.builder() + .type(msg.type) + .name(msg.protocol + ": " + msg.destination) + .className(currentClassFqn) + .methodName(function.getName()) + .sourceFile(getRelativePathForElement(function, context)) + .metadata(Map.of("destination", msg.destination, "protocol", msg.protocol)) + .parameters(extractParameters(function, bindingContext)) + .build()); + } + } + }); + } + return entryPoints; + } + + @Override + public List findCallChains(List entryPoints, List triggers) { + KotlinCallGraphEngine callGraphEngine = new KotlinCallGraphEngine(context); + return callGraphEngine.findChains(entryPoints, triggers); + } + + @Override + public Map> resolveProperties() { + return Collections.emptyMap(); + } + + private static String getAnnotationValue(KtAnnotationEntry annotation, String name, BindingContext bindingContext) { + for (ValueArgument arg : annotation.getValueArguments()) { + if (arg instanceof KtValueArgument valueArg) { + if (valueArg.getArgumentName() != null && valueArg.getArgumentName().getAsName() != null) { + String argName = valueArg.getArgumentName().getAsName().asString(); + if (name.equals(argName)) { + return resolveAnnotationValueExpr(valueArg.getArgumentExpression(), bindingContext); + } + } else if ("value".equals(name)) { + return resolveAnnotationValueExpr(valueArg.getArgumentExpression(), bindingContext); + } + } + } + return null; + } + + private static String resolveAnnotationValueExpr(KtExpression expr, BindingContext bindingContext) { + if (expr == null) return null; + if (expr instanceof KtCollectionLiteralExpression colExpr) { + if (!colExpr.getInnerExpressions().isEmpty()) { + return KotlinResolutionUtils.resolveArgument(colExpr.getInnerExpressions().get(0), bindingContext); + } + } + return KotlinResolutionUtils.resolveArgument(expr, bindingContext); + } + + private static String getControllerPath(KtClassOrObject controller, BindingContext bindingContext) { + for (KtAnnotationEntry ann : controller.getAnnotationEntries()) { + String name = ann.getShortName() != null ? ann.getShortName().asString() : ""; + if (name.equals("RequestMapping") || name.equals("Path")) { + String path = getAnnotationValue(ann, "value", bindingContext); + if (path == null) path = getAnnotationValue(ann, "path", bindingContext); + if (path != null) return path; + } + } + return ""; + } + + private static class RestMapping { + String verb; + String path; + RestMapping(String verb, String path) { + this.verb = verb; + this.path = path; + } + } + + private static RestMapping getRestMapping(KtNamedFunction function, String basePath, BindingContext bindingContext) { + for (KtAnnotationEntry ann : function.getAnnotationEntries()) { + String name = ann.getShortName() != null ? ann.getShortName().asString() : ""; + String verb = null; + if (name.equals("GetMapping") || name.equals("GET")) verb = "GET"; + else if (name.equals("PostMapping") || name.equals("POST")) verb = "POST"; + else if (name.equals("PutMapping") || name.equals("PUT")) verb = "PUT"; + else if (name.equals("DeleteMapping") || name.equals("DELETE")) verb = "DELETE"; + else if (name.equals("PatchMapping") || name.equals("PATCH")) verb = "PATCH"; + else if (name.equals("RequestMapping")) { + verb = getAnnotationValue(ann, "method", bindingContext); + if (verb == null) verb = "GET"; + } + + if (verb != null) { + String subPath = getAnnotationValue(ann, "value", bindingContext); + if (subPath == null) subPath = getAnnotationValue(ann, "path", bindingContext); + if (subPath == null) subPath = ""; + + String path = combinePaths(basePath, subPath); + return new RestMapping(verb, path); + } + } + return null; + } + + private static String combinePaths(String base, String sub) { + if (base == null) base = ""; + if (sub == null) sub = ""; + if (!base.startsWith("/")) base = "/" + base; + if (base.endsWith("/")) base = base.substring(0, base.length() - 1); + if (!sub.startsWith("/") && !sub.isEmpty()) sub = "/" + sub; + return base + sub; + } + + private static class MessageMapping { + EntryPoint.Type type; + String destination; + String protocol; + MessageMapping(EntryPoint.Type type, String destination, String protocol) { + this.type = type; + this.destination = destination; + this.protocol = protocol; + } + } + + private static MessageMapping getMessageMapping(KtNamedFunction function, BindingContext bindingContext) { + for (KtAnnotationEntry ann : function.getAnnotationEntries()) { + String name = ann.getShortName() != null ? ann.getShortName().asString() : ""; + if (name.equals("KafkaListener")) { + String topics = getAnnotationValue(ann, "topics", bindingContext); + if (topics == null) topics = getAnnotationValue(ann, "value", bindingContext); + if (topics == null) topics = "unknown"; + return new MessageMapping(EntryPoint.Type.KAFKA, topics, "KAFKA"); + } else if (name.equals("RabbitListener")) { + String queues = getAnnotationValue(ann, "queues", bindingContext); + if (queues == null) queues = getAnnotationValue(ann, "value", bindingContext); + if (queues == null) queues = "unknown"; + return new MessageMapping(EntryPoint.Type.RABBIT, queues, "RABBIT"); + } else if (name.equals("JmsListener")) { + String dest = getAnnotationValue(ann, "destination", bindingContext); + if (dest == null) dest = getAnnotationValue(ann, "value", bindingContext); + if (dest == null) dest = "unknown"; + return new MessageMapping(EntryPoint.Type.JMS, dest, "JMS"); + } else if (name.equals("SqsListener")) { + String dest = getAnnotationValue(ann, "value", bindingContext); + if (dest == null) dest = getAnnotationValue(ann, "queueNames", bindingContext); + if (dest == null) dest = "unknown"; + return new MessageMapping(EntryPoint.Type.SQS, dest, "SQS"); + } else if (name.equals("SnsListener")) { + String dest = getAnnotationValue(ann, "value", bindingContext); + if (dest == null) dest = getAnnotationValue(ann, "topicNames", bindingContext); + if (dest == null) dest = "unknown"; + return new MessageMapping(EntryPoint.Type.SNS, dest, "SNS"); + } + } + return null; + } + + private static List extractParameters(KtNamedFunction function, BindingContext bindingContext) { + List params = new ArrayList<>(); + for (KtParameter param : function.getValueParameters()) { + String name = param.getName(); + String typeStr = "unknown"; + org.jetbrains.kotlin.types.KotlinType type = bindingContext.get(BindingContext.TYPE, param.getTypeReference()); + if (type != null) { + typeStr = type.toString(); + } else if (param.getTypeReference() != null) { + typeStr = param.getTypeReference().getText(); + } + + List annotations = new ArrayList<>(); + for (KtAnnotationEntry ann : param.getAnnotationEntries()) { + if (ann.getShortName() != null) { + annotations.add(ann.getShortName().asString()); + } + } + + params.add(EntryPoint.Parameter.builder() + .name(name) + .type(typeStr) + .annotations(annotations) + .build()); + } + return params; + } + + private static String getRelativePathForElement(PsiElement element, KotlinSourceContext context) { + if (element == null) return null; + PsiFile file = element.getContainingFile(); + if (file instanceof KtFile ktFile) { + if (ktFile.getVirtualFile() != null) { + String pathStr = ktFile.getVirtualFile().getPath(); + if (pathStr != null) { + return context.getRelativePath(java.nio.file.Path.of(pathStr)); + } + } + return ktFile.getName(); + } + return null; + } + + private static int getLineNumber(PsiElement element) { + if (element == null) return 0; + String text = element.getContainingFile().getText(); + int offset = element.getTextRange().getStartOffset(); + int line = 1; + for (int i = 0; i < offset && i < text.length(); i++) { + if (text.charAt(i) == '\n') { + line++; + } + } + return line; + } + + private static String extractSourceState(PsiElement element) { + PsiElement current = element; + while (current != null && !(current instanceof KtNamedFunction)) { + PsiElement parent = current.getParent(); + + if (parent instanceof KtIfExpression ifExpr) { + if (ifExpr.getCondition() != current) { + String state = extractStateFromExpression(ifExpr.getCondition()); + if (state != null) return state; + } + } + + if (parent instanceof KtWhenEntry whenEntry) { + if (whenEntry.getConditions().length > 0) { + KtWhenCondition cond = whenEntry.getConditions()[0]; + if (cond instanceof KtWhenConditionWithExpression condExpr) { + String state = getSimpleNameString(condExpr.getExpression()); + if (state != null) return state; + } + } + } + + current = parent; + } + return null; + } + + private static String extractStateFromExpression(KtExpression expr) { + if (expr instanceof KtBinaryExpression binaryExpr) { + String op = binaryExpr.getOperationReference().getReferencedName(); + if ("==".equals(op) || "!=".equals(op)) { + KtExpression left = binaryExpr.getLeft(); + KtExpression right = binaryExpr.getRight(); + if (left != null && right != null) { + if (left instanceof org.jetbrains.kotlin.psi.KtConstantExpression || + left instanceof KtStringTemplateExpression || + left instanceof KtNameReferenceExpression || + left instanceof KtDotQualifiedExpression) { + return getSimpleNameString(left); + } + if (right instanceof org.jetbrains.kotlin.psi.KtConstantExpression || + right instanceof KtStringTemplateExpression || + right instanceof KtNameReferenceExpression || + right instanceof KtDotQualifiedExpression) { + return getSimpleNameString(right); + } + } + } + } + return null; + } + + private static String getSimpleNameString(KtExpression expr) { + if (expr instanceof KtNameReferenceExpression refExpr) { + return refExpr.getReferencedName(); + } + if (expr instanceof KtDotQualifiedExpression dotExpr) { + KtExpression selector = dotExpr.getSelectorExpression(); + if (selector instanceof KtNameReferenceExpression selectorRef) { + return selectorRef.getReferencedName(); + } + return dotExpr.getText(); + } + if (expr instanceof KtStringTemplateExpression stringTemplate) { + return stringTemplate.getText().replace("\"", ""); + } + return expr.getText(); + } + + private static String findConditionConstraint(PsiElement element) { + PsiElement current = element; + while (current != null && !(current instanceof KtNamedFunction)) { + PsiElement parent = current.getParent(); + if (parent instanceof KtIfExpression ifExpr) { + if (ifExpr.getCondition() != current) { + KtExpression cond = ifExpr.getCondition(); + if (cond != null) { + String condText = cond.getText(); + if (!condText.contains("state") && !condText.contains("State")) { + return condText; + } + } + } + } + current = parent; + } + return null; + } +} diff --git a/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinLanguageAnalyzerPlugin.java b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinLanguageAnalyzerPlugin.java new file mode 100644 index 0000000..44072f6 --- /dev/null +++ b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinLanguageAnalyzerPlugin.java @@ -0,0 +1,56 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine; +import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; +import click.kamil.springstatemachineexporter.spi.LanguageAnalyzerPlugin; +import click.kamil.springstatemachineexporter.spi.SourceContext; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +public class KotlinLanguageAnalyzerPlugin implements LanguageAnalyzerPlugin { + + @Override + public boolean supports(Path projectRoot) { + if (projectRoot == null) return false; + try (Stream walk = Files.walk(projectRoot, 5)) { // Check top 5 levels + return walk.anyMatch(p -> p.toString().endsWith(".kt")); + } catch (Exception e) { + return false; + } + } + + @Override + public SourceContext parseProject(Path projectRoot, List classpath, Set sourcePaths) { + KotlinSourceContext context = new KotlinSourceContext(projectRoot, classpath); + return context; + } + + @Override + public CallGraphEngine createCallGraphEngine(SourceContext context) { + if (context instanceof KotlinSourceContext ktContext) { + return new KotlinCallGraphEngine(ktContext); + } + throw new IllegalArgumentException("Expected KotlinSourceContext"); + } + + @Override + public CodebaseIntelligenceProvider createIntelligenceProvider(SourceContext context) { + if (context instanceof KotlinSourceContext ktContext) { + return new KotlinIntelligenceProvider(ktContext); + } + throw new IllegalArgumentException("Expected KotlinSourceContext"); + } + + @Override + public List extractStateMachines(SourceContext context, boolean renderChoicesAsDiamonds) { + if (context instanceof KotlinSourceContext ktContext) { + return KotlinStateMachineExtractor.extract(ktContext, renderChoicesAsDiamonds); + } + throw new IllegalArgumentException("Expected KotlinSourceContext"); + } +} diff --git a/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinResolutionUtils.java b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinResolutionUtils.java new file mode 100644 index 0000000..a177fba --- /dev/null +++ b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinResolutionUtils.java @@ -0,0 +1,140 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import org.jetbrains.kotlin.descriptors.ClassDescriptor; +import org.jetbrains.kotlin.descriptors.DeclarationDescriptor; +import org.jetbrains.kotlin.descriptors.FunctionDescriptor; +import org.jetbrains.kotlin.descriptors.CallableDescriptor; +import org.jetbrains.kotlin.psi.KtClassOrObject; +import org.jetbrains.kotlin.psi.KtNamedFunction; +import org.jetbrains.kotlin.psi.KtExpression; +import org.jetbrains.kotlin.psi.KtStringTemplateExpression; +import org.jetbrains.kotlin.psi.KtNameReferenceExpression; +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression; +import org.jetbrains.kotlin.resolve.BindingContext; +import org.jetbrains.kotlin.resolve.DescriptorUtils; +import org.jetbrains.kotlin.resolve.calls.util.CallUtilKt; +import org.jetbrains.kotlin.resolve.calls.model.ResolvedCall; +import org.jetbrains.kotlin.psi.KtProperty; +import org.jetbrains.kotlin.com.intellij.psi.PsiElement; + +public class KotlinResolutionUtils { + + public static String getClassFqn(KtClassOrObject ktClass, BindingContext bindingContext) { + if (bindingContext != null) { + ClassDescriptor descriptor = bindingContext.get(BindingContext.CLASS, ktClass); + if (descriptor != null) { + return DescriptorUtils.getFqNameSafe(descriptor).asString(); + } + } + return ktClass.getFqName() != null ? ktClass.getFqName().asString() : ktClass.getName(); + } + + public static String getFunctionFqn(KtNamedFunction function, BindingContext bindingContext) { + if (bindingContext != null) { + FunctionDescriptor descriptor = bindingContext.get(BindingContext.FUNCTION, function); + if (descriptor != null) { + return getCallableFqn(descriptor); + } + } + return function.getName(); + } + + public static String getCallableFqn(CallableDescriptor descriptor) { + if (descriptor == null) return null; + DeclarationDescriptor containingDeclaration = descriptor.getContainingDeclaration(); + if (containingDeclaration instanceof ClassDescriptor classDescriptor) { + return DescriptorUtils.getFqNameSafe(classDescriptor).asString() + "." + descriptor.getName().asString(); + } + return DescriptorUtils.getFqNameSafe(descriptor).asString(); + } + + public static String resolveArgument(KtExpression expr, BindingContext bindingContext) { + if (expr == null) return "null"; + + if (expr instanceof KtStringTemplateExpression stringTemplate) { + return stringTemplate.getText().replace("\"", ""); + } + + if (expr instanceof KtNameReferenceExpression refExpr) { + ResolvedCall resolvedCall = CallUtilKt.getResolvedCall(refExpr, bindingContext); + if (resolvedCall != null) { + CallableDescriptor descriptor = resolvedCall.getResultingDescriptor(); + if (descriptor != null) { + // 1. Try compile-time constant value + if (descriptor instanceof org.jetbrains.kotlin.descriptors.VariableDescriptor varDescriptor) { + org.jetbrains.kotlin.resolve.constants.ConstantValue initializer = varDescriptor.getCompileTimeInitializer(); + if (initializer != null) { + Object val = initializer.getValue(); + if (val != null) return val.toString(); + } + } + // 2. Try property initializer expression + PsiElement decl = + org.jetbrains.kotlin.resolve.DescriptorToSourceUtils.descriptorToDeclaration(descriptor); + if (decl instanceof KtProperty property) { + KtExpression initializer = property.getInitializer(); + if (initializer != null) { + return resolveArgument(initializer, bindingContext); + } + } + // Fallback to FQN + return DescriptorUtils.getFqNameSafe(descriptor).asString(); + } + } + return refExpr.getReferencedName(); + } + + if (expr instanceof KtDotQualifiedExpression dotExpr) { + ResolvedCall resolvedCall = CallUtilKt.getResolvedCall(dotExpr, bindingContext); + if (resolvedCall != null) { + CallableDescriptor descriptor = resolvedCall.getResultingDescriptor(); + if (descriptor != null) { + // 1. Try compile-time constant value + if (descriptor instanceof org.jetbrains.kotlin.descriptors.VariableDescriptor varDescriptor) { + org.jetbrains.kotlin.resolve.constants.ConstantValue initializer = varDescriptor.getCompileTimeInitializer(); + if (initializer != null) { + Object val = initializer.getValue(); + if (val != null) return val.toString(); + } + } + // 2. Try property initializer expression + PsiElement decl = + org.jetbrains.kotlin.resolve.DescriptorToSourceUtils.descriptorToDeclaration(descriptor); + if (decl instanceof KtProperty property) { + KtExpression initializer = property.getInitializer(); + if (initializer != null) { + return resolveArgument(initializer, bindingContext); + } + } + // Fallback to FQN + return DescriptorUtils.getFqNameSafe(descriptor).asString(); + } + } + return dotExpr.getText(); + } + + return expr.getText(); + } + + public static java.util.List resolveArgumentsList(KtExpression expr, BindingContext bindingContext) { + if (expr instanceof org.jetbrains.kotlin.psi.KtCallExpression callExpr) { + KtExpression callee = callExpr.getCalleeExpression(); + if (callee != null) { + String name = callee.getText(); + if (name.equals("setOf") || name.equals("listOf") || name.equals("arrayListOf") || + name.equals("mutableSetOf") || name.equals("mutableListOf") || name.equals("hashSetOf") || + name.equals("enumSetOf")) { + java.util.List result = new java.util.ArrayList<>(); + for (org.jetbrains.kotlin.psi.ValueArgument arg : callExpr.getValueArguments()) { + KtExpression innerExpr = arg.getArgumentExpression(); + if (innerExpr != null) { + result.add(resolveArgument(innerExpr, bindingContext)); + } + } + return result; + } + } + } + return java.util.Collections.singletonList(resolveArgument(expr, bindingContext)); + } +} diff --git a/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinSourceContext.java b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinSourceContext.java new file mode 100644 index 0000000..46fd721 --- /dev/null +++ b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinSourceContext.java @@ -0,0 +1,209 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.LibraryHint; +import click.kamil.springstatemachineexporter.spi.SourceContext; +import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles; +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment; +import org.jetbrains.kotlin.cli.jvm.compiler.CliBindingTrace; +import org.jetbrains.kotlin.cli.jvm.compiler.TopDownAnalyzerFacadeForJVM; +import org.jetbrains.kotlin.resolve.BindingContext; +import org.jetbrains.kotlin.analyzer.AnalysisResult; +import org.jetbrains.kotlin.com.intellij.openapi.Disposable; +import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer; +import org.jetbrains.kotlin.config.CompilerConfiguration; +import org.jetbrains.kotlin.config.CommonConfigurationKeys; +import org.jetbrains.kotlin.cli.jvm.config.JvmContentRootsKt; +import org.jetbrains.kotlin.psi.KtFile; +import org.jetbrains.kotlin.psi.KtPsiFactory; +import org.jetbrains.kotlin.psi.KtClassOrObject; +import org.jetbrains.kotlin.psi.KtSuperTypeListEntry; +import org.jetbrains.kotlin.psi.KtTypeReference; +import org.jetbrains.kotlin.types.KotlinType; +import org.jetbrains.kotlin.descriptors.ClassifierDescriptor; +import org.jetbrains.kotlin.resolve.DescriptorUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class KotlinSourceContext implements SourceContext { + private final Path projectRoot; + private final Map cache = new HashMap<>(); + private final List ktFiles = new ArrayList<>(); + private KotlinCoreEnvironment environment; + private BindingContext bindingContext; + + private final Map> implementations = new HashMap<>(); + private final Map superclasses = new HashMap<>(); + + public KotlinSourceContext(Path projectRoot, List classpath) { + this.projectRoot = projectRoot; + initEnvironment(classpath); + parseFiles(); + analyze(); + } + + private void initEnvironment(List classpath) { + Disposable disposable = Disposer.newDisposable(); + CompilerConfiguration configuration = new CompilerConfiguration(); + + configuration.put(CommonConfigurationKeys.MODULE_NAME, "spring-state-machine-exporter"); + configuration.put(org.jetbrains.kotlin.cli.common.CLIConfigurationKeys.MESSAGE_COLLECTOR_KEY, + org.jetbrains.kotlin.cli.common.messages.MessageCollector.Companion.getNONE()); + + if (classpath != null) { + for (String cp : classpath) { + JvmContentRootsKt.addJvmClasspathRoot(configuration, new File(cp)); + } + } + + // Include the kotlin-stdlib and kotlin-reflect dependencies explicitly + File stdlib = getJarForClass(kotlin.Unit.class); + if (stdlib != null && stdlib.exists()) { + JvmContentRootsKt.addJvmClasspathRoot(configuration, stdlib); + } + File reflect = getJarForClass(kotlin.reflect.KClass.class); + if (reflect != null && reflect.exists()) { + JvmContentRootsKt.addJvmClasspathRoot(configuration, reflect); + } + + environment = KotlinCoreEnvironment.createForProduction( + disposable, configuration, EnvironmentConfigFiles.JVM_CONFIG_FILES); + } + + private static File getJarForClass(Class clazz) { + try { + String path = clazz.getProtectionDomain().getCodeSource().getLocation().toURI().getPath(); + return new File(path); + } catch (Exception e) { + return null; + } + } + + private void parseFiles() { + if (projectRoot == null || !Files.exists(projectRoot)) return; + + KtPsiFactory psiFactory = new KtPsiFactory(environment.getProject()); + + try (Stream walk = Files.walk(projectRoot)) { + List ktPaths = walk + .filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".kt")) + .collect(Collectors.toList()); + + for (Path p : ktPaths) { + try { + String content = Files.readString(p); + KtFile ktFile = psiFactory.createFile(p.getFileName().toString(), content); + ktFiles.add(ktFile); + } catch (IOException e) { + System.err.println("Failed to read " + p); + } + } + } catch (IOException e) { + System.err.println("Failed to walk project root " + projectRoot); + } + } + + private void analyze() { + if (ktFiles.isEmpty()) { + return; + } + CliBindingTrace trace = new CliBindingTrace(); + AnalysisResult result = TopDownAnalyzerFacadeForJVM.analyzeFilesWithJavaIntegration( + environment.getProject(), + ktFiles, + trace, + environment.getConfiguration(), + (scope) -> environment.createPackagePartProvider(scope) + ); + this.bindingContext = result.getBindingContext(); + buildSupertypesMap(); + } + + private void buildSupertypesMap() { + if (bindingContext == null) return; + for (KtFile ktFile : ktFiles) { + ktFile.accept(new org.jetbrains.kotlin.psi.KtTreeVisitorVoid() { + @Override + public void visitClassOrObject(KtClassOrObject ktClass) { + super.visitClassOrObject(ktClass); + String classFqn = KotlinResolutionUtils.getClassFqn(ktClass, bindingContext); + if (classFqn == null) return; + + for (KtSuperTypeListEntry entry : ktClass.getSuperTypeListEntries()) { + KtTypeReference typeRef = entry.getTypeReference(); + if (typeRef != null) { + KotlinType kotlinType = bindingContext.get(BindingContext.TYPE, typeRef); + if (kotlinType != null) { + ClassifierDescriptor classifier = kotlinType.getConstructor().getDeclarationDescriptor(); + if (classifier != null) { + String superFqn = DescriptorUtils.getFqNameSafe(classifier).asString(); + superclasses.put(classFqn, superFqn); + implementations.computeIfAbsent(superFqn, k -> new ArrayList<>()).add(classFqn); + } + } + } + } + } + }); + } + } + + public List getKtFiles() { + return ktFiles; + } + + public KotlinCoreEnvironment getEnvironment() { + return environment; + } + + public BindingContext getBindingContext() { + return bindingContext; + } + + public Map> getImplementations() { + return implementations; + } + + public Map getSuperclasses() { + return superclasses; + } + + @Override + public Path getProjectRoot() { + return projectRoot; + } + + @Override + public String getRelativePath(Path fullPath) { + if (projectRoot != null && fullPath.isAbsolute() && fullPath.startsWith(projectRoot)) { + return projectRoot.relativize(fullPath).toString(); + } + return fullPath.getFileName().toString(); + } + + @Override + public String getRelativePath(String fqn) { + return fqn; // Simplified + } + + @Override + public List getLibraryHints() { + return Collections.emptyList(); + } + + @Override + public Map> getProperties() { + return Collections.emptyMap(); + } + + @Override + public Map getCache() { + return cache; + } +} diff --git a/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractor.java b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractor.java new file mode 100644 index 0000000..490121c --- /dev/null +++ b/state_machine_exporter_kotlin/src/main/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractor.java @@ -0,0 +1,583 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.model.Transition; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.TransitionType; +import click.kamil.springstatemachineexporter.model.Action; +import click.kamil.springstatemachineexporter.model.Guard; +import org.jetbrains.kotlin.psi.*; +import org.jetbrains.kotlin.resolve.BindingContext; +import org.jetbrains.kotlin.com.intellij.psi.PsiElement; +import org.jetbrains.kotlin.resolve.calls.util.CallUtilKt; +import org.jetbrains.kotlin.resolve.calls.model.ResolvedCall; +import org.jetbrains.kotlin.resolve.DescriptorToSourceUtils; + +import java.util.*; + +public class KotlinStateMachineExtractor { + + public static List extract(KotlinSourceContext context, boolean renderChoicesAsDiamonds) { + List results = new ArrayList<>(); + BindingContext bindingContext = context.getBindingContext(); + if (bindingContext == null) return results; + + for (KtFile ktFile : context.getKtFiles()) { + ktFile.accept(new KtTreeVisitorVoid() { + @Override + public void visitClassOrObject(KtClassOrObject ktClass) { + super.visitClassOrObject(ktClass); + + boolean isConfig = false; + for (KtAnnotationEntry annotation : ktClass.getAnnotationEntries()) { + String name = annotation.getShortName() != null ? annotation.getShortName().asString() : ""; + if ("EnableStateMachine".equals(name) || "EnableStateMachineFactory".equals(name)) { + isConfig = true; + } + } + for (KtSuperTypeListEntry entry : ktClass.getSuperTypeListEntries()) { + String text = entry.getText(); + if (text.contains("StateMachineConfigurerAdapter") || text.contains("EnumStateMachineConfigurerAdapter")) { + isConfig = true; + } + } + + if (isConfig) { + String classFqn = KotlinResolutionUtils.getClassFqn(ktClass, bindingContext); + + Set states = new LinkedHashSet<>(); + List transitions = new ArrayList<>(); + Set startStates = new LinkedHashSet<>(); + Set endStates = new LinkedHashSet<>(); + + for (KtDeclaration decl : ktClass.getDeclarations()) { + if (decl instanceof KtNamedFunction function) { + String funcName = function.getName(); + if ("configure".equals(funcName)) { + parseConfigureFunction(function, context, states, transitions, startStates, endStates); + } + } + } + + if (!states.isEmpty() || !transitions.isEmpty()) { + if (startStates.isEmpty() && !states.isEmpty()) { + for (State s : states) { + // With the new State record, rawName is the first field + startStates.add(s.rawName()); + break; + } + } + + results.add(AnalysisResult.builder() + .name(classFqn) + .states(states) + .transitions(transitions) + .startStates(startStates) + .endStates(endStates) + .renderChoicesAsDiamonds(renderChoicesAsDiamonds) + .build()); + } + } + } + }); + } + return results; + } + + private static void parseConfigureFunction(KtNamedFunction function, KotlinSourceContext context, + Set states, List transitions, + Set startStates, Set endStates) { + BindingContext bindingContext = context.getBindingContext(); + Map varMap = new HashMap<>(); + function.accept(new KtTreeVisitorVoid() { + @Override + public void visitProperty(KtProperty property) { + super.visitProperty(property); + if (property.getName() != null && property.getInitializer() != null) { + varMap.put(property.getName(), property.getInitializer()); + } + } + }); + + function.accept(new KtTreeVisitorVoid() { + @Override + public void visitDotQualifiedExpression(KtDotQualifiedExpression expression) { + super.visitDotQualifiedExpression(expression); + if (!(expression.getParent() instanceof KtDotQualifiedExpression)) { + checkExpression(expression); + } + } + + @Override + public void visitCallExpression(KtCallExpression expression) { + super.visitCallExpression(expression); + if (!(expression.getParent() instanceof KtDotQualifiedExpression)) { + checkExpression(expression); + } + } + + private void checkExpression(KtExpression expression) { + String text = expression.getText(); + if (text.contains("initial") || text.contains("state") || text.contains("states")) { + parseStateCalls(expression, bindingContext, states, startStates); + } + + if (text.contains("withExternal") || text.contains("withInternal") || text.contains("withLocal") || + text.contains("withChoice") || text.contains("withJunction") || + text.contains("withFork") || text.contains("withJoin") || + text.contains("source") || text.contains("sources") || + text.contains("target") || text.contains("targets") || + text.contains("event") || text.contains("timer") || + text.contains("first") || text.contains("then") || text.contains("last")) { + parseTransitionChain(expression, context, transitions, varMap); + } + } + }); + } + + private static void parseStateCalls(KtExpression expression, BindingContext bindingContext, + Set states, Set startStates) { + KtExpression current = expression; + while (current instanceof KtDotQualifiedExpression dotExpr) { + if (dotExpr.getSelectorExpression() instanceof KtCallExpression call) { + String name = getCallName(call); + if ("initial".equals(name)) { + String stateVal = getFirstArgAsString(call, bindingContext); + if (stateVal != null) { + states.add(new State(stateVal, stateVal)); + startStates.add(stateVal); + } + } else if ("state".equals(name) || "states".equals(name)) { + for (ValueArgument arg : call.getValueArguments()) { + KtExpression argExpr = arg.getArgumentExpression(); + if (argExpr != null) { + List stateVals = KotlinResolutionUtils.resolveArgumentsList(argExpr, bindingContext); + for (String stateVal : stateVals) { + states.add(new State(stateVal, stateVal)); + } + } + } + } + } + current = dotExpr.getReceiverExpression(); + } + if (current instanceof KtCallExpression call) { + String name = getCallName(call); + if ("initial".equals(name)) { + String stateVal = getFirstArgAsString(call, bindingContext); + if (stateVal != null) { + states.add(new State(stateVal, stateVal)); + startStates.add(stateVal); + } + } + } + } + + private static class ChoiceBranch { + String branchType; + String target; + Guard guard; + Action action; + } + + private static void parseTransitionChain(KtExpression expression, KotlinSourceContext context, + List transitions, Map varMap) { + BindingContext bindingContext = context.getBindingContext(); + List sources = new ArrayList<>(); + List targets = new ArrayList<>(); + List guards = new ArrayList<>(); + List actions = new ArrayList<>(); + String event = null; + TransitionType type = TransitionType.EXTERNAL; + List choiceBranches = new ArrayList<>(); + + KtExpression current = expression; + while (current != null) { + current = resolveExpression(current, varMap); + KtCallExpression call = null; + if (current instanceof KtDotQualifiedExpression dotExpr) { + if (dotExpr.getSelectorExpression() instanceof KtCallExpression selectorCall) { + call = selectorCall; + } + current = dotExpr.getReceiverExpression(); + } else if (current instanceof KtCallExpression callExpr) { + call = callExpr; + current = null; + } else { + break; + } + + if (call != null) { + String name = getCallName(call); + if ("source".equals(name) || "sources".equals(name)) { + for (ValueArgument arg : call.getValueArguments()) { + sources.addAll(KotlinResolutionUtils.resolveArgumentsList(arg.getArgumentExpression(), bindingContext)); + } + } else if ("target".equals(name) || "targets".equals(name)) { + for (ValueArgument arg : call.getValueArguments()) { + targets.addAll(KotlinResolutionUtils.resolveArgumentsList(arg.getArgumentExpression(), bindingContext)); + } + } else if ("event".equals(name)) { + event = getFirstArgAsString(call, bindingContext); + } else if ("timer".equals(name) || "timerOnce".equals(name)) { + KtExpression argExpr = getArgAsExpression(call, 0, "period", "once"); + if (argExpr != null) { + event = name + "(" + argExpr.getText() + ")"; + } + } else if ("guard".equals(name) || "guardExpression".equals(name)) { + KtExpression expr = getArgAsExpression(call, 0, "guard", "guardExpression"); + if (expr != null) guards.add(createGuard(expr, bindingContext, context)); + } else if ("guards".equals(name)) { + for (int i = 0; i < call.getValueArguments().size(); i++) { + KtExpression expr = getArgAsExpression(call, i, "guard"); + if (expr != null) guards.add(createGuard(expr, bindingContext, context)); + } + } else if ("action".equals(name)) { + KtExpression expr = getArgAsExpression(call, 0, "action"); + if (expr != null) actions.add(createAction(expr, bindingContext, context)); + } else if ("actions".equals(name)) { + for (int i = 0; i < call.getValueArguments().size(); i++) { + KtExpression expr = getArgAsExpression(call, i, "action"); + if (expr != null) actions.add(createAction(expr, bindingContext, context)); + } + } else if ("first".equals(name) || "then".equals(name) || "last".equals(name)) { + ChoiceBranch b = new ChoiceBranch(); + b.branchType = name; + KtExpression targetExpr = getArgAsExpression(call, 0, "target", "state"); + b.target = targetExpr != null ? KotlinResolutionUtils.resolveArgument(targetExpr, bindingContext) : null; + + if ("last".equals(name)) { + KtExpression actionExpr = getArgAsExpression(call, 1, "action"); + if (actionExpr != null) { + b.action = createAction(actionExpr, bindingContext, context); + } + } else { + KtExpression guardExpr = getArgAsExpression(call, 1, "guard"); + if (guardExpr != null) { + b.guard = createGuard(guardExpr, bindingContext, context); + } + KtExpression actionExpr = getArgAsExpression(call, 2, "action"); + if (actionExpr != null) { + b.action = createAction(actionExpr, bindingContext, context); + } + } + choiceBranches.add(b); + } else if ("withChoice".equals(name) || "withJunction".equals(name)) { + type = "withChoice".equals(name) ? TransitionType.CHOICE : TransitionType.JUNCTION; + int order = 0; + java.util.Collections.reverse(choiceBranches); + List temp = new ArrayList<>(); + for (ChoiceBranch b : choiceBranches) { + Transition t = new Transition(); + t.setType(type); + for (String src : sources) { + t.getSourceStates().add(new State(src, src)); + } + if (b.target != null) { + t.getTargetStates().add(new State(b.target, b.target)); + } + if (b.guard != null) t.getGuards().add(b.guard); + if (b.action != null) t.getActions().add(b.action); + t.setOrder(order++); + temp.add(t); + } + transitions.addAll(0, temp); + + sources.clear(); + targets.clear(); + guards.clear(); + actions.clear(); + event = null; + choiceBranches.clear(); + type = TransitionType.EXTERNAL; + } else if ("withExternal".equals(name) || "withInternal".equals(name) || "withLocal".equals(name) || + "withFork".equals(name) || "withJoin".equals(name)) { + + if ("withInternal".equals(name)) { + type = TransitionType.INTERNAL; + } else if ("withLocal".equals(name)) { + type = TransitionType.LOCAL; + } else if ("withFork".equals(name)) { + type = TransitionType.FORK; + } else if ("withJoin".equals(name)) { + type = TransitionType.JOIN; + } else { + type = TransitionType.EXTERNAL; + } + + if (!sources.isEmpty() || !targets.isEmpty()) { + Transition t = new Transition(); + t.setType(type); + for (String src : sources) { + t.getSourceStates().add(new State(src, src)); + } + for (String tgt : targets) { + t.getTargetStates().add(new State(tgt, tgt)); + } + if (event != null) { + t.setEvent(new Event(event, event)); + } + t.getGuards().addAll(guards); + t.getActions().addAll(actions); + transitions.add(0, t); + } + + sources.clear(); + targets.clear(); + guards.clear(); + actions.clear(); + event = null; + choiceBranches.clear(); + type = TransitionType.EXTERNAL; + } + } + } + + if (!sources.isEmpty() || !targets.isEmpty() || !choiceBranches.isEmpty()) { + if (!choiceBranches.isEmpty()) { + int order = 0; + java.util.Collections.reverse(choiceBranches); + List temp = new ArrayList<>(); + for (ChoiceBranch b : choiceBranches) { + Transition t = new Transition(); + t.setType(type); + for (String src : sources) { + t.getSourceStates().add(new State(src, src)); + } + if (b.target != null) { + t.getTargetStates().add(new State(b.target, b.target)); + } + if (b.guard != null) t.getGuards().add(b.guard); + if (b.action != null) t.getActions().add(b.action); + t.setOrder(order++); + temp.add(t); + } + transitions.addAll(0, temp); + } else { + Transition t = new Transition(); + t.setType(type); + for (String src : sources) { + t.getSourceStates().add(new State(src, src)); + } + for (String tgt : targets) { + t.getTargetStates().add(new State(tgt, tgt)); + } + if (event != null) { + t.setEvent(new Event(event, event)); + } + t.getGuards().addAll(guards); + t.getActions().addAll(actions); + transitions.add(0, t); + } + } + } + + private static String getCallName(KtCallExpression call) { + KtExpression callee = call.getCalleeExpression(); + return callee != null ? callee.getText() : ""; + } + + private static String getFirstArgAsString(KtCallExpression call, BindingContext bindingContext) { + KtExpression argExpr = getArgAsExpression(call, 0, "event", "state", "target", "source"); + if (argExpr != null) { + return KotlinResolutionUtils.resolveArgument(argExpr, bindingContext); + } + return null; + } + + private static KtExpression getArgAsExpression(KtCallExpression call, int index, String... paramNames) { + for (ValueArgument arg : call.getValueArguments()) { + if (arg instanceof KtValueArgument valueArg) { + if (valueArg.getArgumentName() != null && valueArg.getArgumentName().getAsName() != null) { + String argName = valueArg.getArgumentName().getAsName().asString(); + for (String name : paramNames) { + if (name.equals(argName)) { + return valueArg.getArgumentExpression(); + } + } + } + } + } + + if (call.getValueArguments().size() > index) { + ValueArgument arg = call.getValueArguments().get(index); + if (arg instanceof KtValueArgument valueArg) { + if (valueArg.getArgumentName() == null) { + return valueArg.getArgumentExpression(); + } + } + } + + if (!call.getLambdaArguments().isEmpty()) { + for (String name : paramNames) { + if ("guard".equals(name) || "action".equals(name) || "listener".equals(name)) { + return call.getLambdaArguments().get(0).getArgumentExpression(); + } + } + } + + return null; + } + + private static KtExpression resolveExpression(KtExpression expr, Map varMap) { + if (expr instanceof KtNameReferenceExpression refExpr) { + String name = refExpr.getReferencedName(); + if (varMap.containsKey(name)) { + return resolveExpression(varMap.get(name), varMap); + } + } + return expr; + } + + private static int getLineNumber(PsiElement element) { + if (element == null) return 0; + String text = element.getContainingFile().getText(); + int offset = element.getTextRange().getStartOffset(); + int line = 1; + for (int i = 0; i < offset && i < text.length(); i++) { + if (text.charAt(i) == '\n') { + line++; + } + } + return line; + } + + private static String getEnclosingClassFqn(PsiElement element, BindingContext bindingContext) { + PsiElement current = element; + while (current != null) { + if (current instanceof KtClassOrObject ktClass) { + return KotlinResolutionUtils.getClassFqn(ktClass, bindingContext); + } + current = current.getParent(); + } + return null; + } + + private static String getRelativePathForElement(PsiElement element, KotlinSourceContext context) { + if (element == null) return null; + org.jetbrains.kotlin.com.intellij.psi.PsiFile file = element.getContainingFile(); + if (file instanceof KtFile ktFile) { + if (ktFile.getVirtualFile() != null) { + String pathStr = ktFile.getVirtualFile().getPath(); + if (pathStr != null) { + return context.getRelativePath(java.nio.file.Path.of(pathStr)); + } + } + return ktFile.getName(); + } + return null; + } + + private static String extractInternalLogic(KtExpression expr, BindingContext bindingContext) { + if (expr == null) return null; + + if (expr instanceof KtLambdaExpression) { + return expr.getText(); + } + + if (expr instanceof KtNamedFunction) { + return expr.getText(); + } + + if (expr instanceof KtCallableReferenceExpression callableRef) { + org.jetbrains.kotlin.psi.KtSimpleNameExpression refName = callableRef.getCallableReference(); + if (refName != null) { + org.jetbrains.kotlin.descriptors.DeclarationDescriptor descriptor = + bindingContext.get(BindingContext.REFERENCE_TARGET, refName); + if (descriptor != null) { + org.jetbrains.kotlin.com.intellij.psi.PsiElement decl = + org.jetbrains.kotlin.resolve.DescriptorToSourceUtils.descriptorToDeclaration(descriptor); + if (decl instanceof KtNamedFunction helperFunc) { + return helperFunc.getText(); + } + } + } + } + + if (expr instanceof KtCallExpression callExpr) { + ResolvedCall resolvedCall = + org.jetbrains.kotlin.resolve.calls.util.CallUtilKt.getResolvedCall(callExpr, bindingContext); + if (resolvedCall != null) { + org.jetbrains.kotlin.descriptors.CallableDescriptor descriptor = resolvedCall.getResultingDescriptor(); + if (descriptor != null) { + org.jetbrains.kotlin.com.intellij.psi.PsiElement decl = + org.jetbrains.kotlin.resolve.DescriptorToSourceUtils.descriptorToDeclaration(descriptor); + if (decl instanceof KtNamedFunction helperFunc) { + KtExpression body = helperFunc.getBodyExpression(); + if (body != null) { + if (!helperFunc.hasBlockBody()) { + return body.getText(); + } else if (body instanceof KtBlockExpression block) { + List statements = block.getStatements(); + if (statements.size() == 1 && statements.get(0) instanceof KtReturnExpression returnExpr) { + KtExpression retVal = returnExpr.getReturnedExpression(); + if (retVal != null) { + return retVal.getText(); + } + } + } + } + return helperFunc.getText(); + } + } + } + } + + if (expr instanceof KtNameReferenceExpression refExpr) { + org.jetbrains.kotlin.descriptors.DeclarationDescriptor descriptor = + bindingContext.get(BindingContext.REFERENCE_TARGET, refExpr); + if (descriptor != null) { + org.jetbrains.kotlin.com.intellij.psi.PsiElement decl = + org.jetbrains.kotlin.resolve.DescriptorToSourceUtils.descriptorToDeclaration(descriptor); + if (decl instanceof KtProperty property) { + KtExpression initializer = property.getInitializer(); + if (initializer != null) { + return extractInternalLogic(initializer, bindingContext); + } + } + if (decl instanceof KtClassOrObject ktClass) { + for (KtDeclaration childDecl : ktClass.getDeclarations()) { + if (childDecl instanceof KtNamedFunction func) { + String name = func.getName(); + if ("evaluate".equals(name) || "execute".equals(name)) { + return func.getText(); + } + } + } + return ktClass.getText(); + } + } + } + + return null; + } + + private static boolean isLambdaOrAnonymous(KtExpression expr) { + if (expr == null) return false; + if (expr instanceof KtLambdaExpression) return true; + if (expr instanceof KtNamedFunction namedFunc) { + return namedFunc.getName() == null; + } + if (expr instanceof KtObjectLiteralExpression) return true; + return false; + } + + private static Guard createGuard(KtExpression expr, BindingContext bindingContext, KotlinSourceContext context) { + boolean isLambda = isLambdaOrAnonymous(expr); + String internalLogic = extractInternalLogic(expr, bindingContext); + int lineNumber = getLineNumber(expr); + String fqn = getEnclosingClassFqn(expr, bindingContext); + String sourceFile = getRelativePathForElement(expr, context); + return Guard.of(expr.getText(), isLambda, internalLogic, lineNumber, fqn, sourceFile); + } + + private static Action createAction(KtExpression expr, BindingContext bindingContext, KotlinSourceContext context) { + boolean isLambda = isLambdaOrAnonymous(expr); + String internalLogic = extractInternalLogic(expr, bindingContext); + int lineNumber = getLineNumber(expr); + String fqn = getEnclosingClassFqn(expr, bindingContext); + String sourceFile = getRelativePathForElement(expr, context); + return Action.of(expr.getText(), isLambda, internalLogic, lineNumber, fqn, sourceFile); + } +} diff --git a/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinCallGraphEngineTest.java b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinCallGraphEngineTest.java new file mode 100644 index 0000000..26b8a8c --- /dev/null +++ b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinCallGraphEngineTest.java @@ -0,0 +1,231 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +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 java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class KotlinCallGraphEngineTest { + + @TempDir + Path tempDir; + + @Test + public void testCallChainResolution() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "annotation class GetMapping\n" + + "\n" + + "class Controller(val service: MyService) {\n" + + " @GetMapping\n" + + " fun handleRequest() {\n" + + " service.process()\n" + + " }\n" + + "}\n" + + "\n" + + "class MyService {\n" + + " fun process() {\n" + + " sendEvent(\"START\")\n" + + " }\n" + + " \n" + + " fun sendEvent(event: String) {}\n" + + "}\n"; + + Path ktFile = tempDir.resolve("App.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + KotlinIntelligenceProvider provider = new KotlinIntelligenceProvider(context); + + List entryPoints = provider.findEntryPoints(); + List triggers = provider.findTriggerPoints(); + + assertThat(entryPoints).hasSize(1); + assertThat(entryPoints.get(0).getName()).isEqualTo("click.kamil.test.Controller.handleRequest"); + assertThat(entryPoints.get(0).getClassName()).isEqualTo("click.kamil.test.Controller"); + assertThat(entryPoints.get(0).getMethodName()).isEqualTo("handleRequest"); + + assertThat(triggers).hasSize(1); + assertThat(triggers.get(0).getEvent()).isEqualTo("START"); + assertThat(triggers.get(0).getClassName()).isEqualTo("click.kamil.test.MyService"); + assertThat(triggers.get(0).getMethodName()).isEqualTo("process"); + + List chains = provider.findCallChains(entryPoints, triggers); + assertThat(chains).hasSize(1); + + CallChain chain = chains.get(0); + assertThat(chain.getEntryPoint()).isEqualTo(entryPoints.get(0)); + assertThat(chain.getTriggerPoint()).isEqualTo(triggers.get(0)); + assertThat(chain.getMethodChain()).containsExactly( + "click.kamil.test.Controller.handleRequest", + "click.kamil.test.MyService.process" + ); + } + + @Test + public void testPolymorphicCallChainResolution() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "annotation class GetMapping\n" + + "\n" + + "interface BaseService {\n" + + " fun process()\n" + + "}\n" + + "\n" + + "class Controller(val service: BaseService) {\n" + + " @GetMapping\n" + + " fun handleRequest() {\n" + + " service.process()\n" + + " }\n" + + "}\n" + + "\n" + + "class MyServiceImpl : BaseService {\n" + + " override fun process() {\n" + + " sendEvent(\"START\")\n" + + " }\n" + + " \n" + + " fun sendEvent(event: String) {}\n" + + "}\n"; + + Path ktFile = tempDir.resolve("AppPolymorphic.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + KotlinIntelligenceProvider provider = new KotlinIntelligenceProvider(context); + + List entryPoints = provider.findEntryPoints(); + List triggers = provider.findTriggerPoints(); + + assertThat(entryPoints).hasSize(1); + assertThat(triggers).hasSize(1); + + List chains = provider.findCallChains(entryPoints, triggers); + assertThat(chains).hasSize(1); + + CallChain chain = chains.get(0); + assertThat(chain.getMethodChain()).containsExactly( + "click.kamil.test.Controller.handleRequest", + "click.kamil.test.MyServiceImpl.process" + ); + } + + @Test + public void testMixedLanguageCallChainResolution() throws IOException { + String ktCode = "package click.kamil.demo\n" + + "\n" + + "class KotlinService {\n" + + " fun process() {\n" + + " sendEvent(\"MIXED_EVENT\")\n" + + " }\n" + + " fun sendEvent(event: String) {}\n" + + "}\n"; + Path ktFile = tempDir.resolve("KotlinService.kt"); + Files.writeString(ktFile, ktCode); + + String javaCode = "package click.kamil.demo;\n" + + "\n" + + "@org.springframework.web.bind.annotation.RestController\n" + + "public class JavaController {\n" + + " private final KotlinService service;\n" + + " public JavaController(KotlinService service) {\n" + + " this.service = service;\n" + + " }\n" + + " @org.springframework.web.bind.annotation.GetMapping\n" + + " public void handleRequest() {\n" + + " service.process();\n" + + " }\n" + + "}\n"; + String annCode = "package org.springframework.web.bind.annotation;\n" + + "public @interface GetMapping {}\n"; + String rcAnnCode = "package org.springframework.web.bind.annotation;\n" + + "public @interface RestController {}\n"; + + Files.createDirectories(tempDir.resolve("src/main/java/click/kamil/demo")); + Files.createDirectories(tempDir.resolve("src/main/java/org/springframework/web/bind/annotation")); + Files.writeString(tempDir.resolve("src/main/java/click/kamil/demo/JavaController.java"), javaCode); + Files.writeString(tempDir.resolve("src/main/java/org/springframework/web/bind/annotation/GetMapping.java"), annCode); + Files.writeString(tempDir.resolve("src/main/java/org/springframework/web/bind/annotation/RestController.java"), rcAnnCode); + + KotlinSourceContext ktContext = new KotlinSourceContext(tempDir, Collections.emptyList()); + KotlinIntelligenceProvider ktProvider = new KotlinIntelligenceProvider(ktContext); + + click.kamil.springstatemachineexporter.plugin.java.JavaLanguageAnalyzerPlugin javaPlugin = + new click.kamil.springstatemachineexporter.plugin.java.JavaLanguageAnalyzerPlugin(); + click.kamil.springstatemachineexporter.spi.SourceContext javaContext = + javaPlugin.parseProject(tempDir, Collections.emptyList(), java.util.Set.of(tempDir)); + click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider javaProvider = + javaPlugin.createIntelligenceProvider(javaContext); + + var providers = List.of(ktProvider, javaProvider); + var engines = List.of(new KotlinCallGraphEngine(ktContext), javaPlugin.createCallGraphEngine(javaContext)); + var compositeProvider = new click.kamil.springstatemachineexporter.analysis.service.CompositeIntelligenceProvider(providers, engines); + + List entryPoints = compositeProvider.findEntryPoints(); + List triggers = compositeProvider.findTriggerPoints(); + + assertThat(entryPoints).isNotEmpty(); + assertThat(triggers).isNotEmpty(); + + List chains = compositeProvider.findCallChains(entryPoints, triggers); + assertThat(chains).hasSize(1); + } + + @Test + public void testSealedHierarchyStateExtraction() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "annotation class EnableStateMachine\n" + + "\n" + + "sealed class MyStates {\n" + + " object STATE_A : MyStates()\n" + + " object STATE_B : MyStates()\n" + + "}\n" + + "\n" + + "class StateConfigurer {\n" + + " fun withStates(): StateConfigurer = this\n" + + " fun initial(s: String): StateConfigurer = this\n" + + " fun state(s: String): StateConfigurer = this\n" + + "}\n" + + "\n" + + "class TransitionConfigurer {\n" + + " fun withExternal(): TransitionConfigurer = this\n" + + " fun source(s: String): TransitionConfigurer = this\n" + + " fun target(s: String): TransitionConfigurer = this\n" + + " fun event(e: String): TransitionConfigurer = this\n" + + "}\n" + + "\n" + + "class StateMachineConfigurerAdapter\n" + + "\n" + + "@EnableStateMachine\n" + + "class MyConfig : StateMachineConfigurerAdapter() {\n" + + " fun configure(states: StateConfigurer) {\n" + + " states.withStates().initial(\"STATE_A\").state(\"STATE_B\")\n" + + " }\n" + + " fun configure(transitions: TransitionConfigurer) {\n" + + " transitions.withExternal().source(\"STATE_A\").target(\"STATE_B\").event(\"EVENT_X\")\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("SealedStates.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + List results = + new KotlinLanguageAnalyzerPlugin().extractStateMachines(context, true); + + assertThat(results).isNotEmpty(); + click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result = results.get(0); + assertThat(result.getStates()).extracting("rawName").containsExactlyInAnyOrder("STATE_A", "STATE_B"); + assertThat(result.getTransitions()).hasSize(1); + assertThat(result.getTransitions().get(0).getEvent().rawName()).isEqualTo("EVENT_X"); + } +} diff --git a/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinIntelligenceProviderTest.java b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinIntelligenceProviderTest.java new file mode 100644 index 0000000..be5f03c --- /dev/null +++ b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinIntelligenceProviderTest.java @@ -0,0 +1,106 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +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 java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class KotlinIntelligenceProviderTest { + + @TempDir + Path tempDir; + + @Test + public void testFindEntryPointsAndTriggerPoints() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "import org.springframework.web.bind.annotation.RestController\n" + + "import org.springframework.web.bind.annotation.RequestMapping\n" + + "import org.springframework.web.bind.annotation.PostMapping\n" + + "import org.springframework.web.bind.annotation.RequestBody\n" + + "import org.springframework.kafka.annotation.KafkaListener\n" + + "import org.springframework.statemachine.StateMachine\n" + + "\n" + + "@RestController\n" + + "@RequestMapping(\"/api/orders\")\n" + + "class OrderController {\n" + + "\n" + + " @PostMapping(\"/create\")\n" + + " fun createOrder(@RequestBody orderId: String, sm: StateMachine) {\n" + + " val userRole = \"admin\"\n" + + " if (userRole == \"admin\") {\n" + + " val state = \"PENDING\"\n" + + " if (state == \"PENDING\") {\n" + + " sm.sendEvent(\"PAY\")\n" + + " }\n" + + " }\n" + + " }\n" + + "\n" + + " @KafkaListener(topics = [\"order-events\"])\n" + + " fun onOrderEvent(message: String) {\n" + + " println(message)\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("OrderController.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + KotlinIntelligenceProvider provider = new KotlinIntelligenceProvider(context); + + // 1. Entry Points assertions + List entryPoints = provider.findEntryPoints(); + assertThat(entryPoints).hasSize(2); + + // REST Entry Point + EntryPoint restEp = entryPoints.stream() + .filter(ep -> ep.getType() == EntryPoint.Type.REST) + .findFirst().orElseThrow(); + assertThat(restEp.getName()).isEqualTo("POST /api/orders/create"); + assertThat(restEp.getClassName()).isEqualTo("click.kamil.test.OrderController"); + assertThat(restEp.getMethodName()).isEqualTo("createOrder"); + assertThat(restEp.getSourceFile()).isEqualTo("OrderController.kt"); + assertThat(restEp.getMetadata().get("path")).isEqualTo("/api/orders/create"); + assertThat(restEp.getMetadata().get("verb")).isEqualTo("POST"); + assertThat(restEp.getParameters()).hasSize(2); + assertThat(restEp.getParameters().get(0).getName()).isEqualTo("orderId"); + assertThat(restEp.getParameters().get(0).getType()).contains("String"); + assertThat(restEp.getParameters().get(0).getAnnotations()).contains("RequestBody"); + + // Messaging Entry Point + EntryPoint msgEp = entryPoints.stream() + .filter(ep -> ep.getType() == EntryPoint.Type.KAFKA) + .findFirst().orElseThrow(); + assertThat(msgEp.getName()).isEqualTo("KAFKA: order-events"); + assertThat(msgEp.getClassName()).isEqualTo("click.kamil.test.OrderController"); + assertThat(msgEp.getMethodName()).isEqualTo("onOrderEvent"); + assertThat(msgEp.getSourceFile()).isEqualTo("OrderController.kt"); + assertThat(msgEp.getMetadata().get("destination")).isEqualTo("order-events"); + assertThat(msgEp.getParameters()).hasSize(1); + assertThat(msgEp.getParameters().get(0).getName()).isEqualTo("message"); + + // 2. Trigger Points assertions + List triggers = provider.findTriggerPoints(); + assertThat(triggers).hasSize(1); + + TriggerPoint trigger = triggers.get(0); + assertThat(trigger.getEvent()).isEqualTo("PAY"); + assertThat(trigger.getSourceState()).isEqualTo("PENDING"); + assertThat(trigger.getClassName()).isEqualTo("click.kamil.test.OrderController"); + assertThat(trigger.getMethodName()).isEqualTo("createOrder"); + assertThat(trigger.getSourceFile()).isEqualTo("OrderController.kt"); + assertThat(trigger.getLineNumber()).isEqualTo(20); + assertThat(trigger.getStateTypeFqn()).contains("String"); + assertThat(trigger.getEventTypeFqn()).contains("String"); + assertThat(trigger.isExternal()).isTrue(); + assertThat(trigger.getConstraint()).isEqualTo("userRole == \"admin\""); + } +} diff --git a/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinSemanticResolutionTest.java b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinSemanticResolutionTest.java new file mode 100644 index 0000000..14dba98 --- /dev/null +++ b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinSemanticResolutionTest.java @@ -0,0 +1,84 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import org.jetbrains.kotlin.psi.KtClass; +import org.jetbrains.kotlin.psi.KtFile; +import org.jetbrains.kotlin.psi.KtNamedFunction; +import org.jetbrains.kotlin.psi.KtTreeVisitorVoid; +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 java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +public class KotlinSemanticResolutionTest { + + @TempDir + Path tempDir; + + @Test + public void testBindingContextResolution() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "class MyTestClass {\n" + + " fun process(event: String) {\n" + + " println(event)\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("MyTestClass.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + assertThat(context.getKtFiles()).hasSize(1); + + KtFile parsedFile = context.getKtFiles().get(0); + AtomicReference ktClassRef = new AtomicReference<>(); + AtomicReference ktFunctionRef = new AtomicReference<>(); + + parsedFile.accept(new KtTreeVisitorVoid() { + @Override + public void visitClass(KtClass klass) { + ktClassRef.set(klass); + super.visitClass(klass); + } + + @Override + public void visitNamedFunction(KtNamedFunction function) { + ktFunctionRef.set(function); + super.visitNamedFunction(function); + } + }); + + assertThat(ktClassRef.get()).isNotNull(); + assertThat(ktFunctionRef.get()).isNotNull(); + + String classFqn = KotlinResolutionUtils.getClassFqn(ktClassRef.get(), context.getBindingContext()); + String functionFqn = KotlinResolutionUtils.getFunctionFqn(ktFunctionRef.get(), context.getBindingContext()); + + assertThat(classFqn).isEqualTo("click.kamil.test.MyTestClass"); + assertThat(functionFqn).isEqualTo("click.kamil.test.MyTestClass.process"); + } + + @Test + public void testSupertypeResolution() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "interface BaseService\n" + + "class MyServiceImpl : BaseService\n"; + + Path ktFile = tempDir.resolve("Supertypes.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + + assertThat(context.getSuperclasses()).containsEntry("click.kamil.test.MyServiceImpl", "click.kamil.test.BaseService"); + assertThat(context.getImplementations().get("click.kamil.test.BaseService")) + .containsExactly("click.kamil.test.MyServiceImpl"); + } +} diff --git a/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorActionGuardTest.java b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorActionGuardTest.java new file mode 100644 index 0000000..e8947bc --- /dev/null +++ b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorActionGuardTest.java @@ -0,0 +1,129 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +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 java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class KotlinStateMachineExtractorActionGuardTest { + + @TempDir + Path tempDir; + + @Test + public void testActionsGuardsTimers() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "import org.springframework.statemachine.config.EnableStateMachine\n" + + "import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter\n" + + "import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer\n" + + "\n" + + "@EnableStateMachine\n" + + "class MyStateMachine : EnumStateMachineConfigurerAdapter() {\n" + + " override fun configure(transitions: StateMachineTransitionConfigurer) {\n" + + " transitions\n" + + " .withExternal()\n" + + " .source(\"S1\").target(\"S2\")\n" + + " .guard { true }\n" + + " .action { println(\"S1 to S2\") }\n" + + " .and()\n" + + " .withExternal()\n" + + " .source(\"S2\").target(\"S3\")\n" + + " .timer(1000)\n" + + " .guardExpression(\"true\")\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("MyStateMachine.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + List results = KotlinStateMachineExtractor.extract(context, false); + + assertThat(results).hasSize(1); + AnalysisResult result = results.get(0); + + var transitions = result.getTransitions(); + assertThat(transitions).hasSize(2); + + var t1 = transitions.stream().filter(t -> t.getSourceStates().get(0).rawName().equals("S1")).findFirst().get(); + assertThat(t1.getGuards()).hasSize(1); + assertThat(t1.getGuards().get(0).expression()).isEqualTo("{ true }"); + assertThat(t1.getGuards().get(0).isLambda()).isTrue(); + assertThat(t1.getActions()).hasSize(1); + assertThat(t1.getActions().get(0).expression()).isEqualTo("{ println(\"S1 to S2\") }"); + assertThat(t1.getActions().get(0).isLambda()).isTrue(); + + var t2 = transitions.stream().filter(t -> t.getSourceStates().get(0).rawName().equals("S2")).findFirst().get(); + assertThat(t2.getEvent().rawName()).isEqualTo("timer(1000)"); + assertThat(t2.getGuards()).hasSize(1); + assertThat(t2.getGuards().get(0).expression()).isEqualTo("\"true\""); + } + + @Test + public void testRichMetadataResolution() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "import org.springframework.statemachine.config.EnableStateMachine\n" + + "import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter\n" + + "import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer\n" + + "import org.springframework.statemachine.guard.Guard\n" + + "\n" + + "@EnableStateMachine\n" + + "class MyStateMachine : EnumStateMachineConfigurerAdapter() {\n" + + " override fun configure(transitions: StateMachineTransitionConfigurer) {\n" + + " transitions\n" + + " .withExternal()\n" + + " .source(\"S1\").target(\"S2\")\n" + + " .guard(myHelperGuard())\n" + + " .action(::myHelperAction)\n" + + " }\n" + + "\n" + + " fun myHelperGuard(): Guard {\n" + + " return Guard { true }\n" + + " }\n" + + "\n" + + " fun myHelperAction(context: Any) {\n" + + " println(\"action executed\")\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("MyStateMachine.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + List results = KotlinStateMachineExtractor.extract(context, false); + + assertThat(results).hasSize(1); + AnalysisResult result = results.get(0); + + var transitions = result.getTransitions(); + assertThat(transitions).hasSize(1); + var t1 = transitions.get(0); + + // Guard assertions + assertThat(t1.getGuards()).hasSize(1); + var guard = t1.getGuards().get(0); + assertThat(guard.expression()).isEqualTo("myHelperGuard()"); + assertThat(guard.internalLogic()).contains("Guard { true }"); + assertThat(guard.lineNumber()).isEqualTo(14); + assertThat(guard.className()).isEqualTo("click.kamil.test.MyStateMachine"); + assertThat(guard.sourceFile()).isEqualTo("MyStateMachine.kt"); + + // Action assertions + assertThat(t1.getActions()).hasSize(1); + var action = t1.getActions().get(0); + assertThat(action.expression()).isEqualTo("::myHelperAction"); + assertThat(action.internalLogic()).contains("println(\"action executed\")"); + assertThat(action.lineNumber()).isEqualTo(15); + assertThat(action.className()).isEqualTo("click.kamil.test.MyStateMachine"); + assertThat(action.sourceFile()).isEqualTo("MyStateMachine.kt"); + } +} diff --git a/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorPseudoStatesTest.java b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorPseudoStatesTest.java new file mode 100644 index 0000000..83fe574 --- /dev/null +++ b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorPseudoStatesTest.java @@ -0,0 +1,73 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.model.TransitionType; +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 java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class KotlinStateMachineExtractorPseudoStatesTest { + + @TempDir + Path tempDir; + + @Test + public void testPseudoStatesExtraction() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "import org.springframework.statemachine.config.EnableStateMachine\n" + + "import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter\n" + + "import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer\n" + + "\n" + + "@EnableStateMachine\n" + + "class MyStateMachine : EnumStateMachineConfigurerAdapter() {\n" + + " override fun configure(transitions: StateMachineTransitionConfigurer) {\n" + + " transitions\n" + + " .withChoice()\n" + + " .source(\"S1\")\n" + + " .first(\"S2\", { true })\n" + + " .then(\"S3\", { false })\n" + + " .last(\"S4\")\n" + + " .and()\n" + + " .withFork()\n" + + " .source(\"S5\")\n" + + " .targets(\"S6\", \"S7\")\n" + + " .and()\n" + + " .withJoin()\n" + + " .sources(\"S8\", \"S9\")\n" + + " .target(\"S10\")\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("MyStateMachine.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + List results = KotlinStateMachineExtractor.extract(context, false); + System.out.println("RESULTS: " + results); + + assertThat(results).hasSize(1); + AnalysisResult result = results.get(0); + + // Choice should create 3 transitions (first, then, last) + long choiceCount = result.getTransitions().stream().filter(t -> t.getType() == TransitionType.CHOICE).count(); + assertThat(choiceCount).isEqualTo(3); + + // Fork should create 1 transition with 1 source and 2 targets + var forkTransition = result.getTransitions().stream().filter(t -> t.getType() == TransitionType.FORK).findFirst().get(); + assertThat(forkTransition.getSourceStates()).hasSize(1); + assertThat(forkTransition.getTargetStates()).hasSize(2); + + // Join should create 1 transition with 2 sources and 1 target + var joinTransition = result.getTransitions().stream().filter(t -> t.getType() == TransitionType.JOIN).findFirst().get(); + assertThat(joinTransition.getSourceStates()).hasSize(2); + assertThat(joinTransition.getTargetStates()).hasSize(1); + } +} diff --git a/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorSetOfTest.java b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorSetOfTest.java new file mode 100644 index 0000000..c16921b --- /dev/null +++ b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorSetOfTest.java @@ -0,0 +1,48 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +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 java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class KotlinStateMachineExtractorSetOfTest { + + @TempDir + Path tempDir; + + @Test + public void testStatesWithSetOf() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "import org.springframework.statemachine.config.EnableStateMachine\n" + + "import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter\n" + + "import org.springframework.statemachine.config.builders.StateMachineStateConfigurer\n" + + "\n" + + "@EnableStateMachine\n" + + "class MyStateMachine : EnumStateMachineConfigurerAdapter() {\n" + + " override fun configure(states: StateMachineStateConfigurer) {\n" + + " states\n" + + " .withStates()\n" + + " .initial(\"S1\")\n" + + " .states(setOf(\"S2\", \"S3\"))\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("MyStateMachine.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + List results = KotlinStateMachineExtractor.extract(context, false); + + assertThat(results).hasSize(1); + AnalysisResult result = results.get(0); + assertThat(result.getStates()).extracting("rawName").contains("S1", "S2", "S3"); + } +} diff --git a/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorTest.java b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorTest.java new file mode 100644 index 0000000..19e4f40 --- /dev/null +++ b/state_machine_exporter_kotlin/src/test/java/click/kamil/springstatemachineexporter/plugin/kotlin/KotlinStateMachineExtractorTest.java @@ -0,0 +1,196 @@ +package click.kamil.springstatemachineexporter.plugin.kotlin; + +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +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 java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class KotlinStateMachineExtractorTest { + + @TempDir + Path tempDir; + + @Test + public void testMultipleTransitionsWithAnd() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "import org.springframework.statemachine.config.EnableStateMachine\n" + + "import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter\n" + + "import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer\n" + + "\n" + + "@EnableStateMachine\n" + + "class MyStateMachine : EnumStateMachineConfigurerAdapter() {\n" + + " override fun configure(transitions: StateMachineTransitionConfigurer) {\n" + + " transitions\n" + + " .withExternal()\n" + + " .source(\"S1\").target(\"S2\").event(\"E1\")\n" + + " .and()\n" + + " .withExternal()\n" + + " .source(\"S2\").target(\"S3\").event(\"E2\")\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("MyStateMachine.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + List results = KotlinStateMachineExtractor.extract(context, false); + + assertThat(results).hasSize(1); + AnalysisResult result = results.get(0); + assertThat(result.getTransitions()).hasSize(2); + } + + @Test + public void testSplitVariableChains() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "import org.springframework.statemachine.config.EnableStateMachine\n" + + "import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter\n" + + "import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer\n" + + "\n" + + "@EnableStateMachine\n" + + "class MyStateMachine : EnumStateMachineConfigurerAdapter() {\n" + + " override fun configure(transitions: StateMachineTransitionConfigurer) {\n" + + " val ext = transitions.withExternal()\n" + + " ext.source(\"S1\").target(\"S2\").event(\"E1\")\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("MyStateMachine.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + List results = KotlinStateMachineExtractor.extract(context, false); + + assertThat(results).hasSize(1); + AnalysisResult result = results.get(0); + assertThat(result.getTransitions()).hasSize(1); + var transition = result.getTransitions().get(0); + assertThat(transition.getSourceStates().get(0).rawName()).isEqualTo("S1"); + assertThat(transition.getTargetStates().get(0).rawName()).isEqualTo("S2"); + } + + @Test + public void testChoiceOrdering() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "import org.springframework.statemachine.config.EnableStateMachine\n" + + "import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter\n" + + "import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer\n" + + "\n" + + "@EnableStateMachine\n" + + "class MyStateMachine : EnumStateMachineConfigurerAdapter() {\n" + + " override fun configure(transitions: StateMachineTransitionConfigurer) {\n" + + " transitions.withChoice()\n" + + " .source(\"S1\")\n" + + " .first(\"S2\", { true })\n" + + " .then(\"S3\", { false })\n" + + " .last(\"S4\")\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("MyStateMachine.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + List results = KotlinStateMachineExtractor.extract(context, false); + + assertThat(results).hasSize(1); + AnalysisResult result = results.get(0); + assertThat(result.getTransitions()).hasSize(3); + + assertThat(result.getTransitions().get(0).getTargetStates().get(0).rawName()).isEqualTo("S2"); + assertThat(result.getTransitions().get(0).getOrder()).isEqualTo(0); + + assertThat(result.getTransitions().get(1).getTargetStates().get(0).rawName()).isEqualTo("S3"); + assertThat(result.getTransitions().get(1).getOrder()).isEqualTo(1); + + assertThat(result.getTransitions().get(2).getTargetStates().get(0).rawName()).isEqualTo("S4"); + assertThat(result.getTransitions().get(2).getOrder()).isEqualTo(2); + } + + @Test + public void testNamedArguments() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "import org.springframework.statemachine.config.EnableStateMachine\n" + + "import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter\n" + + "import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer\n" + + "\n" + + "@EnableStateMachine\n" + + "class MyStateMachine : EnumStateMachineConfigurerAdapter() {\n" + + " override fun configure(transitions: StateMachineTransitionConfigurer) {\n" + + " transitions.withChoice()\n" + + " .source(\"S1\")\n" + + " .first(guard = { true }, target = \"S2\")\n" + + " .last(target = \"S3\")\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("MyStateMachine.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + List results = KotlinStateMachineExtractor.extract(context, false); + + assertThat(results).hasSize(1); + AnalysisResult result = results.get(0); + assertThat(result.getTransitions()).hasSize(2); + + assertThat(result.getTransitions().get(0).getTargetStates().get(0).rawName()).isEqualTo("S2"); + assertThat(result.getTransitions().get(1).getTargetStates().get(0).rawName()).isEqualTo("S3"); + } + + @Test + public void testConstantResolution() throws IOException { + String code = "package click.kamil.test\n" + + "\n" + + "import org.springframework.statemachine.config.EnableStateMachine\n" + + "import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter\n" + + "import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer\n" + + "\n" + + "const val TOP_LEVEL_STATE = \"S3\"\n" + + "\n" + + "@EnableStateMachine\n" + + "class MyStateMachine : EnumStateMachineConfigurerAdapter() {\n" + + " companion object {\n" + + " const val STATE_ONE = \"S1\"\n" + + " val STATE_TWO = \"S2\"\n" + + " }\n" + + "\n" + + " override fun configure(transitions: StateMachineTransitionConfigurer) {\n" + + " transitions.withExternal()\n" + + " .source(STATE_ONE).target(STATE_TWO).event(\"E1\")\n" + + " .and()\n" + + " .withExternal()\n" + + " .source(STATE_TWO).target(TOP_LEVEL_STATE).event(\"E2\")\n" + + " }\n" + + "}\n"; + + Path ktFile = tempDir.resolve("MyStateMachine.kt"); + Files.writeString(ktFile, code); + + KotlinSourceContext context = new KotlinSourceContext(tempDir, Collections.emptyList()); + List results = KotlinStateMachineExtractor.extract(context, false); + + assertThat(results).hasSize(1); + AnalysisResult result = results.get(0); + assertThat(result.getTransitions()).hasSize(2); + + var t1 = result.getTransitions().get(0); + assertThat(t1.getSourceStates().get(0).rawName()).isEqualTo("S1"); + assertThat(t1.getTargetStates().get(0).rawName()).isEqualTo("S2"); + + var t2 = result.getTransitions().get(1); + assertThat(t2.getSourceStates().get(0).rawName()).isEqualTo("S2"); + assertThat(t2.getTargetStates().get(0).rawName()).isEqualTo("S3"); + } +} diff --git a/state_machines/kotlin_simple_state_machine/build.gradle.kts b/state_machines/kotlin_simple_state_machine/build.gradle.kts new file mode 100644 index 0000000..ff4abbf --- /dev/null +++ b/state_machines/kotlin_simple_state_machine/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + kotlin("jvm") version "1.9.22" + id("org.springframework.boot") version "3.2.3" + id("io.spring.dependency-management") version "1.1.4" +} + +group = "click.kamil" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.springframework.statemachine:spring-statemachine-core:3.2.1") + implementation("org.jetbrains.kotlin:kotlin-reflect") +} diff --git a/state_machines/kotlin_simple_state_machine/src/main/kotlin/click/kamil/OrderStateMachine.kt b/state_machines/kotlin_simple_state_machine/src/main/kotlin/click/kamil/OrderStateMachine.kt new file mode 100644 index 0000000..ef96835 --- /dev/null +++ b/state_machines/kotlin_simple_state_machine/src/main/kotlin/click/kamil/OrderStateMachine.kt @@ -0,0 +1,37 @@ +package click.kamil + +import org.springframework.statemachine.config.EnableStateMachine +import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter +import org.springframework.statemachine.config.builders.StateMachineStateConfigurer +import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer + +enum class OrderState { NEW, PROCESSING, SHIPPED, COMPLETED } +enum class OrderEvent { PAY, SHIP, DELIVER } + +@EnableStateMachine +class OrderStateMachine : EnumStateMachineConfigurerAdapter() { + + override fun configure(states: StateMachineStateConfigurer) { + states + .withStates() + .initial(OrderState.NEW) + .states(setOf(OrderState.NEW, OrderState.PROCESSING, OrderState.SHIPPED, OrderState.COMPLETED)) + } + + override fun configure(transitions: StateMachineTransitionConfigurer) { + transitions + .withExternal() + .source(OrderState.NEW).target(OrderState.PROCESSING) + .event(OrderEvent.PAY) + .action { println("Payment received") } + .and() + .withExternal() + .source(OrderState.PROCESSING).target(OrderState.SHIPPED) + .event(OrderEvent.SHIP) + .guard { true } + .and() + .withExternal() + .source(OrderState.SHIPPED).target(OrderState.COMPLETED) + .event(OrderEvent.DELIVER) + } +}