Compare commits
1 Commits
12183693aa
...
ai-branch-
| Author | SHA1 | Date | |
|---|---|---|---|
| bb97285906 |
@@ -20,3 +20,5 @@ include ':state_machines:enterprise_order_system'
|
|||||||
include ':state_machines:multi_module_sample:api-module'
|
include ':state_machines:multi_module_sample:api-module'
|
||||||
include ':state_machines:multi_module_sample:core-module'
|
include ':state_machines:multi_module_sample:core-module'
|
||||||
include ':state_machines:state_machine_enterprise'
|
include ':state_machines:state_machine_enterprise'
|
||||||
|
include 'state_machine_exporter_kotlin'
|
||||||
|
include ':state_machines:kotlin_simple_state_machine'
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ dependencies {
|
|||||||
// deps for parsing java AST
|
// deps for parsing java AST
|
||||||
implementation 'org.eclipse.jdt:org.eclipse.jdt.core:3.36.0'
|
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
|
// Jackson for JSON support
|
||||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
|
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
|
||||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1'
|
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1'
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
|
|
||||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
|
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
|
||||||
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
|
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
|
||||||
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
|
||||||
@@ -22,8 +21,6 @@ import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictF
|
|||||||
|
|
||||||
public class TransitionLinkerEnricher implements AnalysisEnricher {
|
public class TransitionLinkerEnricher implements AnalysisEnricher {
|
||||||
|
|
||||||
private static final ExportOptions LINK_FORMAT_OPTIONS = ExportOptions.builder().build();
|
|
||||||
|
|
||||||
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
|
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
|
||||||
private final EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
|
private final EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
|
||||||
|
|
||||||
@@ -50,16 +47,17 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
|
|
||||||
for (Transition t : stateMachineTransitions) {
|
for (Transition t : stateMachineTransitions) {
|
||||||
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
|
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
|
||||||
String smEventForLink = formatEventForLink(t.getEvent());
|
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
|
||||||
|
// Event matches
|
||||||
for (State smSourceState : t.getSourceStates()) {
|
for (State smSourceState : t.getSourceStates()) {
|
||||||
String smSourceForLink = formatSourceForLink(smSourceState);
|
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
|
||||||
String smSource = simplify(smSourceForLink);
|
String smSource = simplify(smSourceRaw);
|
||||||
if (triggerSource == null || triggerSource.equals(smSource)) {
|
if (triggerSource == null || triggerSource.equals(smSource)) {
|
||||||
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
|
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
|
||||||
MatchedTransition mt = MatchedTransition.builder()
|
MatchedTransition mt = MatchedTransition.builder()
|
||||||
.sourceState(smSourceForLink)
|
.sourceState(smSourceRaw)
|
||||||
.targetState(smSourceForLink)
|
.targetState(smSourceRaw)
|
||||||
.event(smEventForLink)
|
.event(smEventRaw)
|
||||||
.build();
|
.build();
|
||||||
if (isRoutedToCorrectMachine(chain, result.getName(), context)
|
if (isRoutedToCorrectMachine(chain, result.getName(), context)
|
||||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||||
@@ -67,11 +65,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for (State smTargetState : t.getTargetStates()) {
|
for (State smTargetState : t.getTargetStates()) {
|
||||||
String targetForLink = formatSourceForLink(smTargetState);
|
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
|
||||||
MatchedTransition mt = MatchedTransition.builder()
|
MatchedTransition mt = MatchedTransition.builder()
|
||||||
.sourceState(smSourceForLink)
|
.sourceState(smSourceRaw)
|
||||||
.targetState(targetForLink)
|
.targetState(targetRaw)
|
||||||
.event(smEventForLink)
|
.event(smEventRaw)
|
||||||
.build();
|
.build();
|
||||||
if (isRoutedToCorrectMachine(chain, result.getName(), context)
|
if (isRoutedToCorrectMachine(chain, result.getName(), context)
|
||||||
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
|
||||||
@@ -217,22 +215,6 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
|
|||||||
return simplified;
|
return simplified;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String formatSourceForLink(State state) {
|
|
||||||
if (state == null) return null;
|
|
||||||
return simplifyForLink(LINK_FORMAT_OPTIONS.formatState(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatEventForLink(click.kamil.springstatemachineexporter.model.Event event) {
|
|
||||||
if (event == null) return null;
|
|
||||||
return LINK_FORMAT_OPTIONS.formatEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String simplifyForLink(String name) {
|
|
||||||
if (name == null) return "";
|
|
||||||
if (name.contains("\"")) return name;
|
|
||||||
return name.replaceAll("[^a-zA-Z0-9_.]", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isGuardedContains(String smEvent, String triggerEvent) {
|
private boolean isGuardedContains(String smEvent, String triggerEvent) {
|
||||||
if (smEvent == null || triggerEvent == null) return false;
|
if (smEvent == null || triggerEvent == null) return false;
|
||||||
|
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
|||||||
if (triggerEvent == null || smEvent == null) return false;
|
if (triggerEvent == null || smEvent == null) return false;
|
||||||
if (triggerEvent.equals(smEvent)) return true;
|
if (triggerEvent.equals(smEvent)) return true;
|
||||||
|
|
||||||
String tConst = constantName(triggerEvent);
|
String tConst = triggerEvent.contains(".") ? triggerEvent.substring(triggerEvent.lastIndexOf('.') + 1) : triggerEvent;
|
||||||
String smConst = constantName(smEvent);
|
String smConst = smEvent.contains(".") ? smEvent.substring(smEvent.lastIndexOf('.') + 1) : smEvent;
|
||||||
|
|
||||||
if (!tConst.equals(smConst)) return false;
|
if (!tConst.equals(smConst)) return false;
|
||||||
|
|
||||||
@@ -100,46 +100,20 @@ public class StrictFqnMatchingEngine implements EventMatchingEngine {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (smEvent.contains(".")) {
|
if (eventTypeFqn != null && eventTypeFqn.contains(".") && smEvent.contains(".")) {
|
||||||
String smEnumType = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
String fullTriggerFqn = eventTypeFqn + "." + tConst;
|
||||||
|
return fullTriggerFqn.equals(smEvent);
|
||||||
if (eventTypeFqn != null) {
|
|
||||||
return enumTypesMatch(eventTypeFqn, smEnumType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (triggerEvent.contains(".")) {
|
if (triggerEvent.contains(".") && smEvent.contains(".")) {
|
||||||
String triggerEnumType = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
String tClass = triggerEvent.substring(0, triggerEvent.lastIndexOf('.'));
|
||||||
return enumTypesMatch(triggerEnumType, smEnumType);
|
String smClass = smEvent.substring(0, smEvent.lastIndexOf('.'));
|
||||||
|
String tClassSimple = tClass.contains(".") ? tClass.substring(tClass.lastIndexOf('.') + 1) : tClass;
|
||||||
|
String smClassSimple = smClass.contains(".") ? smClass.substring(smClass.lastIndexOf('.') + 1) : smClass;
|
||||||
|
return tClassSimple.equals(smClassSimple);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bare constant name (e.g. after Enum.name()) with no type context must not match every enum
|
return true;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both sides are unqualified identifiers (string/primitive events)
|
|
||||||
return !triggerEvent.contains(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String constantName(String event) {
|
|
||||||
return event.contains(".") ? event.substring(event.lastIndexOf('.') + 1) : event;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean enumTypesMatch(String type1, String type2) {
|
|
||||||
if (type1 == null || type2 == null) return false;
|
|
||||||
if (type1.equals(type2)) return true;
|
|
||||||
if (type1.endsWith("." + type2) || type2.endsWith("." + type1)) return true;
|
|
||||||
|
|
||||||
String simple1 = simpleName(type1);
|
|
||||||
String simple2 = simpleName(type2);
|
|
||||||
if (!simple1.equals(simple2)) return false;
|
|
||||||
|
|
||||||
// Same simple name only matches when at least one side is package-less (import-style reference).
|
|
||||||
// Prevents com.foo.OrderEvents from matching com.bar.OrderEvents.
|
|
||||||
return !type1.contains(".") || !type2.contains(".");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String simpleName(String name) {
|
|
||||||
return name.contains(".") ? name.substring(name.lastIndexOf('.') + 1) : name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isStringTypeOrPrimitive(String typeFqn) {
|
private boolean isStringTypeOrPrimitive(String typeFqn) {
|
||||||
|
|||||||
@@ -8,4 +8,8 @@ import java.util.List;
|
|||||||
|
|
||||||
public interface CallGraphEngine {
|
public interface CallGraphEngine {
|
||||||
List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers);
|
List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers);
|
||||||
|
|
||||||
|
default java.util.Map<String, java.util.List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> getCallGraph() {
|
||||||
|
return java.util.Collections.emptyMap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<CallGraphEngine> engines;
|
||||||
|
private final Map<String, List<CallEdge>> mergedGraph = new HashMap<>();
|
||||||
|
|
||||||
|
public CompositeCallGraphEngine(List<CallGraphEngine> engines) {
|
||||||
|
this.engines = engines;
|
||||||
|
mergeGraphs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mergeGraphs() {
|
||||||
|
for (CallGraphEngine engine : engines) {
|
||||||
|
Map<String, List<CallEdge>> graph = engine.getCallGraph();
|
||||||
|
if (graph != null) {
|
||||||
|
for (Map.Entry<String, List<CallEdge>> entry : graph.entrySet()) {
|
||||||
|
String caller = normalizeCompanionFqn(entry.getKey());
|
||||||
|
List<CallEdge> 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<String, List<CallEdge>> getCallGraph() {
|
||||||
|
return mergedGraph;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||||
|
List<CallChain> 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<List<String>> paths = findAllPaths(startMethod, targetMethod, new LinkedHashSet<>());
|
||||||
|
for (List<String> path : paths) {
|
||||||
|
chains.add(CallChain.builder()
|
||||||
|
.entryPoint(ep)
|
||||||
|
.methodChain(path)
|
||||||
|
.triggerPoint(tp)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chains;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<List<String>> findAllPaths(String start, String target, Set<String> visited) {
|
||||||
|
List<List<String>> result = new ArrayList<>();
|
||||||
|
if (start.equals(target)) {
|
||||||
|
result.add(new ArrayList<>(List.of(start)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!visited.add(start)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CallEdge> 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<String> path = new ArrayList<>(List.of(start, target));
|
||||||
|
result.add(path);
|
||||||
|
} else {
|
||||||
|
List<List<String>> subPaths = findAllPaths(neighbor, target, visited);
|
||||||
|
for (List<String> subPath : subPaths) {
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<CodebaseIntelligenceProvider> providers;
|
||||||
|
private final List<CallGraphEngine> engines;
|
||||||
|
|
||||||
|
public CompositeIntelligenceProvider(List<CodebaseIntelligenceProvider> providers, List<CallGraphEngine> engines) {
|
||||||
|
this.providers = providers;
|
||||||
|
this.engines = engines;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TriggerPoint> findTriggerPoints() {
|
||||||
|
List<TriggerPoint> result = new ArrayList<>();
|
||||||
|
for (CodebaseIntelligenceProvider provider : providers) {
|
||||||
|
result.addAll(provider.findTriggerPoints());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<EntryPoint> findEntryPoints() {
|
||||||
|
List<EntryPoint> result = new ArrayList<>();
|
||||||
|
for (CodebaseIntelligenceProvider provider : providers) {
|
||||||
|
result.addAll(provider.findEntryPoints());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||||
|
CompositeCallGraphEngine compositeEngine = new CompositeCallGraphEngine(engines);
|
||||||
|
return compositeEngine.findChains(entryPoints, triggers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, String>> resolveProperties() {
|
||||||
|
Map<String, Map<String, String>> result = new HashMap<>();
|
||||||
|
for (CodebaseIntelligenceProvider provider : providers) {
|
||||||
|
Map<String, Map<String, String>> props = provider.resolveProperties();
|
||||||
|
if (props != null) {
|
||||||
|
result.putAll(props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
|
|||||||
this.injectionAnalyzer = injectionAnalyzer;
|
this.injectionAnalyzer = injectionAnalyzer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, List<CallEdge>> getCallGraph() {
|
||||||
|
return buildCallGraph();
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
protected Map<String, List<CallEdge>> buildCallGraph() {
|
protected Map<String, List<CallEdge>> buildCallGraph() {
|
||||||
Map<String, List<CallEdge>> cached = (Map<String, List<CallEdge>>) context.getCache().get("jdtCallGraph");
|
Map<String, List<CallEdge>> cached = (Map<String, List<CallEdge>>) context.getCache().get("jdtCallGraph");
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import click.kamil.springstatemachineexporter.spi.SourceContext;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class CodebaseContext {
|
public class CodebaseContext implements SourceContext {
|
||||||
private final Map<String, CompilationUnit> classes = new HashMap<>();
|
private final Map<String, CompilationUnit> classes = new HashMap<>();
|
||||||
private final Map<String, Path> classPaths = new HashMap<>();
|
private final Map<String, Path> classPaths = new HashMap<>();
|
||||||
private final Map<String, String> simpleNameToFqn = new HashMap<>();
|
private final Map<String, String> simpleNameToFqn = new HashMap<>();
|
||||||
@@ -51,6 +52,11 @@ public class CodebaseContext {
|
|||||||
this.projectRoot = root;
|
this.projectRoot = root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Path getProjectRoot() {
|
||||||
|
return projectRoot;
|
||||||
|
}
|
||||||
|
|
||||||
public String getRelativePath(Path fullPath) {
|
public String getRelativePath(Path fullPath) {
|
||||||
if (projectRoot != null && fullPath.isAbsolute() && fullPath.startsWith(projectRoot)) {
|
if (projectRoot != null && fullPath.isAbsolute() && fullPath.startsWith(projectRoot)) {
|
||||||
return projectRoot.relativize(fullPath).toString();
|
return projectRoot.relativize(fullPath).toString();
|
||||||
@@ -88,6 +94,10 @@ public class CodebaseContext {
|
|||||||
this.classpath = classpath.toArray(new String[0]);
|
this.classpath = classpath.toArray(new String[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> getClasspath() {
|
||||||
|
return java.util.Arrays.asList(classpath);
|
||||||
|
}
|
||||||
|
|
||||||
public void setSourcepath(List<String> sourcepath) {
|
public void setSourcepath(List<String> sourcepath) {
|
||||||
this.sourcepath = sourcepath.toArray(new String[0]);
|
this.sourcepath = sourcepath.toArray(new String[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String> classpath, Set<Path> 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<String> 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<AnalysisResult> extractStateMachines(SourceContext context, boolean renderChoicesAsDiamonds) {
|
||||||
|
if (context instanceof CodebaseContext jdtContext) {
|
||||||
|
List<AnalysisResult> results = new ArrayList<>();
|
||||||
|
|
||||||
|
// 1. Find entry point classes (annotated or extending adapter)
|
||||||
|
List<TypeDeclaration> entryPoints = jdtContext.findEntryPointClasses(List.of("EnableStateMachineFactory", "EnableStateMachine"));
|
||||||
|
for (TypeDeclaration td : entryPoints) {
|
||||||
|
String className = jdtContext.getFqn(td);
|
||||||
|
StateMachineAggregator aggregator = new StateMachineAggregator(jdtContext);
|
||||||
|
List<Transition> transitions = aggregator.aggregateTransitions(td);
|
||||||
|
aggregator.aggregateStates(td);
|
||||||
|
Set<String> initialStatesAst = aggregator.getInitialStates();
|
||||||
|
Set<String> endStatesAst = aggregator.getEndStates();
|
||||||
|
|
||||||
|
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, initialStatesAst);
|
||||||
|
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst);
|
||||||
|
Set<click.kamil.springstatemachineexporter.model.State> 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<MethodDeclaration> 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<Transition> transitions = AstTransitionParser.parseTransitions(m, jdtContext);
|
||||||
|
|
||||||
|
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, null);
|
||||||
|
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, null);
|
||||||
|
Set<click.kamil.springstatemachineexporter.model.State> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
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.IOException;
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
@@ -137,8 +142,57 @@ public class ExportService {
|
|||||||
|
|
||||||
context.scan(allPaths, Collections.emptySet());
|
context.scan(allPaths, Collections.emptySet());
|
||||||
|
|
||||||
CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir);
|
// Load SPI plugins dynamically
|
||||||
exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds, flows, machineFilter, activeProfiles != null ? activeProfiles : Collections.emptyList(), eventFormat, stateFormat);
|
ServiceLoader<LanguageAnalyzerPlugin> loader = ServiceLoader.load(LanguageAnalyzerPlugin.class);
|
||||||
|
List<LanguageAnalyzerPlugin> 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<SourceContext> sourceContexts = new ArrayList<>();
|
||||||
|
List<CodebaseIntelligenceProvider> providers = new ArrayList<>();
|
||||||
|
List<CallGraphEngine> engines = new ArrayList<>();
|
||||||
|
List<AnalysisResult> 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<String> selectedFormats) throws IOException {
|
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats) throws IOException {
|
||||||
|
|||||||
@@ -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<String> classpath, Set<Path> 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<click.kamil.springstatemachineexporter.analysis.model.AnalysisResult> extractStateMachines(SourceContext context, boolean renderChoicesAsDiamonds) {
|
||||||
|
return java.util.Collections.emptyList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LibraryHint> getLibraryHints();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves application properties (e.g. from application.yml).
|
||||||
|
*/
|
||||||
|
Map<String, Map<String, String>> getProperties();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an unstructured cache map that plugins can use to share data across processing phases.
|
||||||
|
*/
|
||||||
|
Map<String, Object> getCache();
|
||||||
|
}
|
||||||
@@ -143,38 +143,7 @@ class TransitionLinkerEnricherTest {
|
|||||||
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
|
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldNotLinkAllEnumTransitionsWhenConstantNamesCollide() {
|
|
||||||
Transition orderPay = new Transition();
|
|
||||||
orderPay.setSourceStates(List.of(State.of("NEW", "NEW")));
|
|
||||||
orderPay.setTargetStates(List.of(State.of("PAID", "PAID")));
|
|
||||||
orderPay.setEvent(Event.of("PAY", "com.example.OrderEvents.PAY"));
|
|
||||||
|
|
||||||
Transition invoicePay = new Transition();
|
|
||||||
invoicePay.setSourceStates(List.of(State.of("DRAFT", "DRAFT")));
|
|
||||||
invoicePay.setTargetStates(List.of(State.of("SENT", "SENT")));
|
|
||||||
invoicePay.setEvent(Event.of("PAY", "com.example.InvoiceEvents.PAY"));
|
|
||||||
|
|
||||||
CallChain chain = CallChain.builder()
|
|
||||||
.triggerPoint(TriggerPoint.builder()
|
|
||||||
.event("OrderEvents.PAY")
|
|
||||||
.eventTypeFqn("com.example.OrderEvents")
|
|
||||||
.build())
|
|
||||||
.contextMachineId("testMachine")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
AnalysisResult result = AnalysisResult.builder()
|
|
||||||
.name("testMachine")
|
|
||||||
.transitions(List.of(orderPay, invoicePay))
|
|
||||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
enricher.enrich(result, null, null);
|
|
||||||
|
|
||||||
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
|
|
||||||
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
|
|
||||||
assertThat(updatedChain.getMatchedTransitions().get(0).getEvent()).isEqualTo("OrderEvents.PAY");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldMatchBaseEventDespiteScrapedPolyEvents() {
|
void shouldMatchBaseEventDespiteScrapedPolyEvents() {
|
||||||
|
|||||||
@@ -196,38 +196,6 @@ class StrictFqnMatchingEngineTest {
|
|||||||
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
|
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldNotMatchBareStrippedConstantToAnyEnumWithoutTypeContext() {
|
|
||||||
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
|
|
||||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
|
||||||
.event("PAY")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldNotMatchSameEnumSimpleNameFromDifferentPackages() {
|
|
||||||
Event smEvent = Event.of("PAY", "com.example.other.OrderEvents.PAY");
|
|
||||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
|
||||||
.event("OrderEvents.PAY")
|
|
||||||
.eventTypeFqn("com.example.OrderEvents")
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void shouldNotMatchPolymorphicStrippedConstantWithoutTypeContext() {
|
|
||||||
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
|
|
||||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
|
||||||
.event("getType()")
|
|
||||||
.polymorphicEvents(List.of("PAY"))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldMatchDynamicMethodCallAsVariable() {
|
void shouldMatchDynamicMethodCallAsVariable() {
|
||||||
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
|
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
|
||||||
|
|||||||
@@ -429,15 +429,11 @@
|
|||||||
clearHighlights();
|
clearHighlights();
|
||||||
const chains = metadata.metadata.callChains || [];
|
const chains = metadata.metadata.callChains || [];
|
||||||
|
|
||||||
const seen = new Set();
|
|
||||||
let steps = [];
|
let steps = [];
|
||||||
chains.forEach(c => {
|
chains.forEach(c => {
|
||||||
if (c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName) {
|
if (c.entryPoint.className === ep.className && c.entryPoint.methodName === ep.methodName) {
|
||||||
if (c.matchedTransitions && c.matchedTransitions.length > 0) {
|
if (c.matchedTransitions && c.matchedTransitions.length > 0) {
|
||||||
c.matchedTransitions.forEach(t => {
|
c.matchedTransitions.forEach(t => {
|
||||||
const key = buildLinkKey(t.sourceState, t.event);
|
|
||||||
if (!key || seen.has(key)) return;
|
|
||||||
seen.add(key);
|
|
||||||
steps.push({ event: t.event, source: t.sourceState, ambiguous: c.triggerPoint && c.triggerPoint.ambiguous });
|
steps.push({ event: t.event, source: t.sourceState, ambiguous: c.triggerPoint && c.triggerPoint.ambiguous });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -463,22 +459,22 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
steps.forEach(step => {
|
steps.forEach(step => {
|
||||||
let targetLinkKey = "";
|
let targetEvent = "";
|
||||||
|
let targetSource = "";
|
||||||
let targetAnon = "";
|
let targetAnon = "";
|
||||||
let isAmbiguous = false;
|
let isAmbiguous = false;
|
||||||
|
|
||||||
if (typeof step === 'object') {
|
if (typeof step === 'object') {
|
||||||
targetLinkKey = buildLinkKey(step.source, step.event);
|
targetEvent = normalize(step.event);
|
||||||
|
targetSource = normalize(step.source);
|
||||||
isAmbiguous = step.ambiguous;
|
isAmbiguous = step.ambiguous;
|
||||||
} else if (step.includes("->")) {
|
} else if (step.includes("->")) {
|
||||||
const parts = step.split("->");
|
const parts = step.split("->");
|
||||||
targetAnon = "#link_anon_" + normalize(parts[0]) + "_" + normalize(parts[1]);
|
targetAnon = "#link_anon_" + normalize(parts[0]) + "_" + normalize(parts[1]);
|
||||||
} else {
|
} else {
|
||||||
targetLinkKey = "__" + normalize(formatForLinkId(step));
|
targetEvent = normalize(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetLinkKey && !targetAnon) return;
|
|
||||||
|
|
||||||
svg.querySelectorAll('a').forEach(link => {
|
svg.querySelectorAll('a').forEach(link => {
|
||||||
const href = link.getAttribute('xlink:href') || link.getAttribute('href');
|
const href = link.getAttribute('xlink:href') || link.getAttribute('href');
|
||||||
if (!href) return;
|
if (!href) return;
|
||||||
@@ -486,11 +482,14 @@
|
|||||||
let matches = false;
|
let matches = false;
|
||||||
if (targetAnon) {
|
if (targetAnon) {
|
||||||
matches = (href === targetAnon);
|
matches = (href === targetAnon);
|
||||||
} else if (targetLinkKey.includes("__")) {
|
} else if (targetEvent) {
|
||||||
matches = (href === "#link_" + targetLinkKey);
|
if (targetSource) {
|
||||||
|
// Exact match
|
||||||
|
matches = (href === "#link_" + targetSource + "__" + targetEvent);
|
||||||
} else {
|
} else {
|
||||||
// Business-flow step: event name only, match that event from any source
|
// Match all sources for this event
|
||||||
matches = href.startsWith("#link_") && href.endsWith(targetLinkKey);
|
matches = href.startsWith("#link_") && href.endsWith("__" + targetEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches) {
|
if (matches) {
|
||||||
@@ -571,7 +570,8 @@
|
|||||||
if (!t.event) return false;
|
if (!t.event) return false;
|
||||||
const evStr = t.event.fullIdentifier || t.event.rawName || t.event;
|
const evStr = t.event.fullIdentifier || t.event.rawName || t.event;
|
||||||
const sourceStr = t.sourceStates && t.sourceStates.length > 0 ? (t.sourceStates[0].fullIdentifier || t.sourceStates[0].rawName) : "";
|
const sourceStr = t.sourceStates && t.sourceStates.length > 0 ? (t.sourceStates[0].fullIdentifier || t.sourceStates[0].rawName) : "";
|
||||||
return buildLinkKey(sourceStr, evStr) === linkId;
|
const expectedLinkId = normalize(sourceStr) + "__" + normalize(evStr);
|
||||||
|
return expectedLinkId === linkId;
|
||||||
});
|
});
|
||||||
if (metaTrans && metaTrans.event) {
|
if (metaTrans && metaTrans.event) {
|
||||||
cleanEventName = metaTrans.event.fullIdentifier || metaTrans.event.rawName || metaTrans.event;
|
cleanEventName = metaTrans.event.fullIdentifier || metaTrans.event.rawName || metaTrans.event;
|
||||||
@@ -602,37 +602,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatForLinkId(identifier) {
|
|
||||||
if (!identifier) return "";
|
|
||||||
const s = String(identifier);
|
|
||||||
const lastDot = s.lastIndexOf('.');
|
|
||||||
if (lastDot <= 0) return s;
|
|
||||||
const prevDot = s.lastIndexOf('.', lastDot - 1);
|
|
||||||
return prevDot > 0 ? s.substring(prevDot + 1) : s;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(s) {
|
function normalize(s) {
|
||||||
if (!s) return "";
|
if (!s) return "";
|
||||||
return s.replace(/[^a-zA-Z0-9]/g, '_');
|
return s.replace(/[^a-zA-Z0-9]/g, '_');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLinkKey(source, event) {
|
|
||||||
if (!event) return "";
|
|
||||||
const formattedEvent = formatForLinkId(event);
|
|
||||||
if (!source) return "__" + normalize(formattedEvent);
|
|
||||||
return normalize(formatForLinkId(source)) + "__" + normalize(formattedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventsMatchForLink(a, b) {
|
|
||||||
return normalize(formatForLinkId(a)) === normalize(formatForLinkId(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTooltip(name, trans) {
|
function createTooltip(name, trans) {
|
||||||
const chains = (metadata.metadata.callChains || []).filter(c => {
|
const chains = (metadata.metadata.callChains || []).filter(c => {
|
||||||
if (!c.matchedTransitions || c.matchedTransitions.length === 0) return false;
|
if (!c.matchedTransitions || c.matchedTransitions.length === 0) return false;
|
||||||
return c.matchedTransitions.some(t => {
|
return c.matchedTransitions.some(t => {
|
||||||
const cEventStr = typeof t.event === 'object' ? (t.event.fullIdentifier || t.event.rawName || t.event) : t.event;
|
const cEventStr = typeof t.event === 'object' ? (t.event.fullIdentifier || t.event.rawName || t.event) : t.event;
|
||||||
return eventsMatchForLink(cEventStr, name);
|
return normalize(cEventStr) === normalize(name);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ public class HtmlExporterTest {
|
|||||||
|
|
||||||
String html = exporter.export(result, ExportOptions.builder().build());
|
String html = exporter.export(result, ExportOptions.builder().build());
|
||||||
|
|
||||||
assertThat(html).contains("function formatForLinkId(identifier)");
|
// Verify that the template gracefully unwraps the Event JSON object for interactive hovers in attachInteractivity
|
||||||
assertThat(html).contains("function buildLinkKey(source, event)");
|
assertThat(html).contains("const evStr = t.event.fullIdentifier || t.event.rawName || t.event;");
|
||||||
|
|
||||||
|
// Verify that the template gracefully unwraps the Event JSON object when filtering call chains in createTooltip
|
||||||
|
assertThat(html).contains("const cEventStr = typeof t.event === 'object' ? (t.event.fullIdentifier || t.event.rawName || t.event) : t.event;");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
state_machine_exporter_kotlin/build.gradle
Normal file
36
state_machine_exporter_kotlin/build.gradle
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -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<String, List<CallEdge>> 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<? extends CallableDescriptor> 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<String> 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<String> 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<String, List<CallEdge>> getCallGraph() {
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||||
|
List<CallChain> chains = new ArrayList<>();
|
||||||
|
for (EntryPoint ep : entryPoints) {
|
||||||
|
String startMethod = ep.getClassName() + "." + ep.getMethodName();
|
||||||
|
for (TriggerPoint tp : triggers) {
|
||||||
|
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
|
||||||
|
List<List<String>> paths = findAllPaths(startMethod, targetMethod, new LinkedHashSet<>());
|
||||||
|
for (List<String> path : paths) {
|
||||||
|
chains.add(CallChain.builder()
|
||||||
|
.entryPoint(ep)
|
||||||
|
.methodChain(path)
|
||||||
|
.triggerPoint(tp)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chains;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<List<String>> findAllPaths(String start, String target, Set<String> visited) {
|
||||||
|
List<List<String>> result = new ArrayList<>();
|
||||||
|
if (start.equals(target)) {
|
||||||
|
result.add(new ArrayList<>(List.of(start)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!visited.add(start)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CallEdge> 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<String> path = new ArrayList<>(List.of(start, target));
|
||||||
|
result.add(path);
|
||||||
|
} else {
|
||||||
|
List<List<String>> subPaths = findAllPaths(neighbor, target, visited);
|
||||||
|
for (List<String> subPath : subPaths) {
|
||||||
|
List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TriggerPoint> findTriggerPoints() {
|
||||||
|
List<TriggerPoint> 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<? extends org.jetbrains.kotlin.descriptors.CallableDescriptor> 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<EntryPoint> findEntryPoints() {
|
||||||
|
List<EntryPoint> 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<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||||
|
KotlinCallGraphEngine callGraphEngine = new KotlinCallGraphEngine(context);
|
||||||
|
return callGraphEngine.findChains(entryPoints, triggers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, String>> 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<EntryPoint.Parameter> extractParameters(KtNamedFunction function, BindingContext bindingContext) {
|
||||||
|
List<EntryPoint.Parameter> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Path> 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<String> classpath, Set<Path> 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<AnalysisResult> extractStateMachines(SourceContext context, boolean renderChoicesAsDiamonds) {
|
||||||
|
if (context instanceof KotlinSourceContext ktContext) {
|
||||||
|
return KotlinStateMachineExtractor.extract(ktContext, renderChoicesAsDiamonds);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Expected KotlinSourceContext");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<? extends CallableDescriptor> 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<? extends CallableDescriptor> 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<String> 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<String> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Object> cache = new HashMap<>();
|
||||||
|
private final List<KtFile> ktFiles = new ArrayList<>();
|
||||||
|
private KotlinCoreEnvironment environment;
|
||||||
|
private BindingContext bindingContext;
|
||||||
|
|
||||||
|
private final Map<String, List<String>> implementations = new HashMap<>();
|
||||||
|
private final Map<String, String> superclasses = new HashMap<>();
|
||||||
|
|
||||||
|
public KotlinSourceContext(Path projectRoot, List<String> classpath) {
|
||||||
|
this.projectRoot = projectRoot;
|
||||||
|
initEnvironment(classpath);
|
||||||
|
parseFiles();
|
||||||
|
analyze();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initEnvironment(List<String> 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<Path> walk = Files.walk(projectRoot)) {
|
||||||
|
List<Path> 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<KtFile> getKtFiles() {
|
||||||
|
return ktFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public KotlinCoreEnvironment getEnvironment() {
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BindingContext getBindingContext() {
|
||||||
|
return bindingContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, List<String>> getImplementations() {
|
||||||
|
return implementations;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> 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<LibraryHint> getLibraryHints() {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, String>> getProperties() {
|
||||||
|
return Collections.emptyMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> getCache() {
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AnalysisResult> extract(KotlinSourceContext context, boolean renderChoicesAsDiamonds) {
|
||||||
|
List<AnalysisResult> 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<State> states = new LinkedHashSet<>();
|
||||||
|
List<Transition> transitions = new ArrayList<>();
|
||||||
|
Set<String> startStates = new LinkedHashSet<>();
|
||||||
|
Set<String> 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<State> states, List<Transition> transitions,
|
||||||
|
Set<String> startStates, Set<String> endStates) {
|
||||||
|
BindingContext bindingContext = context.getBindingContext();
|
||||||
|
Map<String, KtExpression> 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<State> states, Set<String> 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<String> 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<Transition> transitions, Map<String, KtExpression> varMap) {
|
||||||
|
BindingContext bindingContext = context.getBindingContext();
|
||||||
|
List<String> sources = new ArrayList<>();
|
||||||
|
List<String> targets = new ArrayList<>();
|
||||||
|
List<Guard> guards = new ArrayList<>();
|
||||||
|
List<Action> actions = new ArrayList<>();
|
||||||
|
String event = null;
|
||||||
|
TransitionType type = TransitionType.EXTERNAL;
|
||||||
|
List<ChoiceBranch> 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<Transition> 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<Transition> 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<String, KtExpression> 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<? extends org.jetbrains.kotlin.descriptors.CallableDescriptor> 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<KtExpression> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<EntryPoint> entryPoints = provider.findEntryPoints();
|
||||||
|
List<TriggerPoint> 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<CallChain> 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<EntryPoint> entryPoints = provider.findEntryPoints();
|
||||||
|
List<TriggerPoint> triggers = provider.findTriggerPoints();
|
||||||
|
|
||||||
|
assertThat(entryPoints).hasSize(1);
|
||||||
|
assertThat(triggers).hasSize(1);
|
||||||
|
|
||||||
|
List<CallChain> 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<EntryPoint> entryPoints = compositeProvider.findEntryPoints();
|
||||||
|
List<TriggerPoint> triggers = compositeProvider.findTriggerPoints();
|
||||||
|
|
||||||
|
assertThat(entryPoints).isNotEmpty();
|
||||||
|
assertThat(triggers).isNotEmpty();
|
||||||
|
|
||||||
|
List<CallChain> 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<S, E>\n" +
|
||||||
|
"\n" +
|
||||||
|
"@EnableStateMachine\n" +
|
||||||
|
"class MyConfig : StateMachineConfigurerAdapter<MyStates, String>() {\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<click.kamil.springstatemachineexporter.analysis.model.AnalysisResult> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>) {\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<EntryPoint> 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<TriggerPoint> 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\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<KtClass> ktClassRef = new AtomicReference<>();
|
||||||
|
AtomicReference<KtNamedFunction> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>() {\n" +
|
||||||
|
" override fun configure(transitions: StateMachineTransitionConfigurer<String, String>) {\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<AnalysisResult> 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<String, String>() {\n" +
|
||||||
|
" override fun configure(transitions: StateMachineTransitionConfigurer<String, String>) {\n" +
|
||||||
|
" transitions\n" +
|
||||||
|
" .withExternal()\n" +
|
||||||
|
" .source(\"S1\").target(\"S2\")\n" +
|
||||||
|
" .guard(myHelperGuard())\n" +
|
||||||
|
" .action(::myHelperAction)\n" +
|
||||||
|
" }\n" +
|
||||||
|
"\n" +
|
||||||
|
" fun myHelperGuard(): Guard<String, String> {\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<AnalysisResult> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>() {\n" +
|
||||||
|
" override fun configure(transitions: StateMachineTransitionConfigurer<String, String>) {\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<AnalysisResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>() {\n" +
|
||||||
|
" override fun configure(states: StateMachineStateConfigurer<String, String>) {\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<AnalysisResult> results = KotlinStateMachineExtractor.extract(context, false);
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
AnalysisResult result = results.get(0);
|
||||||
|
assertThat(result.getStates()).extracting("rawName").contains("S1", "S2", "S3");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>() {\n" +
|
||||||
|
" override fun configure(transitions: StateMachineTransitionConfigurer<String, String>) {\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<AnalysisResult> 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<String, String>() {\n" +
|
||||||
|
" override fun configure(transitions: StateMachineTransitionConfigurer<String, String>) {\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<AnalysisResult> 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<String, String>() {\n" +
|
||||||
|
" override fun configure(transitions: StateMachineTransitionConfigurer<String, String>) {\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<AnalysisResult> 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<String, String>() {\n" +
|
||||||
|
" override fun configure(transitions: StateMachineTransitionConfigurer<String, String>) {\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<AnalysisResult> 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<String, String>() {\n" +
|
||||||
|
" companion object {\n" +
|
||||||
|
" const val STATE_ONE = \"S1\"\n" +
|
||||||
|
" val STATE_TWO = \"S2\"\n" +
|
||||||
|
" }\n" +
|
||||||
|
"\n" +
|
||||||
|
" override fun configure(transitions: StateMachineTransitionConfigurer<String, String>) {\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<AnalysisResult> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
17
state_machines/kotlin_simple_state_machine/build.gradle.kts
Normal file
17
state_machines/kotlin_simple_state_machine/build.gradle.kts
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -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<OrderState, OrderEvent>() {
|
||||||
|
|
||||||
|
override fun configure(states: StateMachineStateConfigurer<OrderState, OrderEvent>) {
|
||||||
|
states
|
||||||
|
.withStates()
|
||||||
|
.initial(OrderState.NEW)
|
||||||
|
.states(setOf(OrderState.NEW, OrderState.PROCESSING, OrderState.SHIPPED, OrderState.COMPLETED))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configure(transitions: StateMachineTransitionConfigurer<OrderState, OrderEvent>) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user