move files into modules
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package click.kamil.springstatemachineexporter;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser;
|
||||
import click.kamil.springstatemachineexporter.ast.app.StateMachineStateConfigurationMethodFinder;
|
||||
import click.kamil.springstatemachineexporter.ast.app.StateMachineTransitionConfigurationMethodFinder;
|
||||
import click.kamil.springstatemachineexporter.ast.app.TransitionStateUtils;
|
||||
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
|
||||
import click.kamil.springstatemachineexporter.ast.common.FileUtils;
|
||||
import click.kamil.springstatemachineexporter.ast.in.AstFileFinder;
|
||||
import click.kamil.springstatemachineexporter.ast.out.Dot;
|
||||
import click.kamil.springstatemachineexporter.ast.out.PlantUml;
|
||||
import click.kamil.springstatemachineexporter.ast.out.Scxml;
|
||||
import click.kamil.springstatemachineexporter.ast.out.StateMachineExporter;
|
||||
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class Main {
|
||||
private static final String START_DIR = ".";
|
||||
private static final String OUTPUT_DIR = "./out";
|
||||
private static final String EXTENSION = ".java";
|
||||
private static final List<String> TARGET_ANNOTATIONS = List.of("EnableStateMachineFactory", "EnableStateMachine");
|
||||
public static final Set<String> STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine");
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
runExporter(Path.of(START_DIR), Path.of(OUTPUT_DIR));
|
||||
}
|
||||
|
||||
static void processAnnotationFoundSource(String source, Path javaFile, List<StateMachineExporter> outputs, Path outputDir) throws IOException {
|
||||
MethodDeclaration configureMethod = new StateMachineTransitionConfigurationMethodFinder(source).findConfigureMethod();
|
||||
if (configureMethod != null) {
|
||||
System.out.println("Found configure method in: " + javaFile.toAbsolutePath());
|
||||
System.out.println("Method body:\n" + configureMethod.getBody());
|
||||
|
||||
List<Transition> transitions = AstTransitionParser.parseTransitions(configureMethod);
|
||||
for (Transition t : transitions) {
|
||||
System.out.println(t);
|
||||
}
|
||||
|
||||
Set<String> startStatesTransitions = TransitionStateUtils.findStartStates(transitions);
|
||||
Set<String> endStatesTransitions = TransitionStateUtils.findEndStates(transitions);
|
||||
|
||||
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
|
||||
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor = new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
|
||||
cu.accept(visitor);
|
||||
|
||||
System.out.println("Start States Ast: " + visitor.getInitialStates());
|
||||
System.out.println("End States Ast: " + visitor.getEndStates());
|
||||
System.out.println("Start States: " + startStatesTransitions);
|
||||
System.out.println("End States: " + endStatesTransitions);
|
||||
|
||||
Set<String> startStates = new HashSet<>(startStatesTransitions);
|
||||
startStates.addAll(visitor.getInitialStates());
|
||||
|
||||
Set<String> endStates = new HashSet<>(endStatesTransitions);
|
||||
endStates.addAll(visitor.getEndStates());
|
||||
|
||||
String baseName = javaFile.getFileName().toString().replaceFirst("[.][^.]+$", "");
|
||||
generateOutputs(outputDir, baseName, transitions, startStates, endStates, outputs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void generateOutputs(Path outputDir, String baseName, List<Transition> transitions,
|
||||
Set<String> startStates, Set<String> endStates,
|
||||
List<StateMachineExporter> outputs) throws IOException {
|
||||
|
||||
Path targetDir = outputDir.resolve(baseName);
|
||||
Files.createDirectories(targetDir);
|
||||
|
||||
for (StateMachineExporter output : outputs) {
|
||||
String content = output.export(transitions, startStates, endStates, true);
|
||||
String fileName = baseName + output.getFileExtension();
|
||||
|
||||
try (PrintWriter out = new PrintWriter(targetDir.resolve(fileName).toFile())) {
|
||||
out.println(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void runExporter(Path inputDir, Path outputDir) throws IOException {
|
||||
AstFileFinder finder = new AstFileFinder();
|
||||
List<Path> javaFiles = FileUtils.findFilesWithExtension(Set.of(inputDir), EXTENSION);
|
||||
|
||||
List<StateMachineExporter> outputs = List.of(
|
||||
new PlantUml(),
|
||||
new Dot(),
|
||||
new Scxml()
|
||||
);
|
||||
|
||||
for (Path javaFile : javaFiles) {
|
||||
String source = Files.readString(javaFile);
|
||||
if (finder.hasClassWithAnnotation(source, TARGET_ANNOTATIONS)) {
|
||||
processAnnotationFoundSource(source, javaFile, outputs, outputDir);
|
||||
} else if (finder.hasFunctionReturningType(source, STATE_MACHINE_RETURN_TYPES)) {
|
||||
processReturnTypeFoundSource(source, javaFile, outputs, outputDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void processReturnTypeFoundSource(String source, Path javaFile, List<StateMachineExporter> outputs, Path outputDir) throws IOException {
|
||||
Set<MethodDeclaration> methodsWithReturnType = new StateMachineTransitionConfigurationMethodFinder(source).findMethodsWithReturnType(STATE_MACHINE_RETURN_TYPES);
|
||||
if (!methodsWithReturnType.isEmpty()) {
|
||||
for (MethodDeclaration m : methodsWithReturnType) {
|
||||
System.out.println("Found method returning StateMachine in: " + javaFile.toAbsolutePath());
|
||||
System.out.println("Method body:\n" + m.getBody());
|
||||
List<Transition> transitions = AstTransitionParser.parseTransitions2(m);
|
||||
for (Transition t : transitions) {
|
||||
System.out.println(t);
|
||||
}
|
||||
|
||||
Set<String> startStatesTransitions = TransitionStateUtils.findStartStates(transitions);
|
||||
Set<String> endStatesTransitions = TransitionStateUtils.findEndStates(transitions);
|
||||
|
||||
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
|
||||
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor = new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
|
||||
cu.accept(visitor);
|
||||
|
||||
System.out.println("Start States Ast: " + visitor.getInitialStates());
|
||||
System.out.println("End States Ast: " + visitor.getEndStates());
|
||||
System.out.println("Start States: " + startStatesTransitions);
|
||||
System.out.println("End States: " + endStatesTransitions);
|
||||
|
||||
Set<String> startStates = new HashSet<>(startStatesTransitions);
|
||||
startStates.addAll(visitor.getInitialStates());
|
||||
|
||||
Set<String> endStates = new HashSet<>(endStatesTransitions);
|
||||
endStates.addAll(visitor.getEndStates());
|
||||
|
||||
String baseName = javaFile.getFileName().toString().replaceFirst("[.][^.]+$", "");
|
||||
generateOutputs(outputDir, baseName, transitions, startStates, endStates, outputs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package click.kamil.springstatemachineexporter.ast.app;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public class AstTransitionParser {
|
||||
private static final Set<String> SUPPORTED_TRANSITION_TYPES = Set.of(
|
||||
"withExternal", "withInternal", "withLocal", "withFork", "withJoin", "withChoice", "withJunction"
|
||||
);
|
||||
|
||||
public static List<Transition> parseTransitions(MethodDeclaration method) {
|
||||
List<Transition> transitions = new ArrayList<>();
|
||||
Optional.ofNullable(method)
|
||||
.map(MethodDeclaration::getBody)
|
||||
.ifPresent(body -> {
|
||||
for (Object stmt : body.statements()) {
|
||||
if (stmt instanceof ExpressionStatement es
|
||||
&& es.getExpression() instanceof MethodInvocation mi) {
|
||||
transitions.addAll(parseTransitionsFromExpression(mi));
|
||||
}
|
||||
}
|
||||
});
|
||||
return transitions;
|
||||
}
|
||||
public static List<Transition> parseTransitions2(MethodDeclaration method) {
|
||||
List<Transition> transitions = new ArrayList<>();
|
||||
|
||||
Optional.ofNullable(method)
|
||||
.map(MethodDeclaration::getBody)
|
||||
.ifPresent(body -> {
|
||||
for (Object stmtObj : body.statements()) {
|
||||
if (stmtObj instanceof ExpressionStatement es
|
||||
&& es.getExpression() instanceof MethodInvocation mi) {
|
||||
|
||||
// recursively collect all method invocations in this chain
|
||||
List<MethodInvocation> chain = collectMethodInvocationChain(mi);
|
||||
|
||||
// Find the index of `configureTransitions` in the chain
|
||||
for (int i = 0; i < chain.size(); i++) {
|
||||
MethodInvocation current = chain.get(i);
|
||||
if (current.getName().getIdentifier().equals("configureTransitions")) {
|
||||
List<MethodInvocation> afterConfigure = chain.subList(i + 1, chain.size());
|
||||
transitions.addAll(parseTransitionsFromExpression(afterConfigure.getLast()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return transitions;
|
||||
}
|
||||
|
||||
private static List<MethodInvocation> collectMethodInvocationChain(MethodInvocation root) {
|
||||
List<MethodInvocation> chain = new ArrayList<>();
|
||||
|
||||
MethodInvocation current = root;
|
||||
while (current != null) {
|
||||
chain.add(0, current); // add to the front to preserve order
|
||||
|
||||
Expression expr = current.getExpression();
|
||||
if (expr instanceof MethodInvocation parent) {
|
||||
current = parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static List<Transition> parseTransitionsFromExpression(MethodInvocation rootCall) {
|
||||
List<Transition> transitions = new ArrayList<>();
|
||||
|
||||
// Step 1: Unroll method chain
|
||||
List<MethodInvocation> calls = new ArrayList<>();
|
||||
Expression current = rootCall;
|
||||
while (current instanceof MethodInvocation mi) {
|
||||
calls.addFirst(mi);
|
||||
current = mi.getExpression();
|
||||
}
|
||||
|
||||
// Step 2: Split by .and()
|
||||
List<List<MethodInvocation>> segments = new ArrayList<>();
|
||||
List<MethodInvocation> currentSegment = new ArrayList<>();
|
||||
for (MethodInvocation call : calls) {
|
||||
if ("and".equals(call.getName().getIdentifier())) {
|
||||
if (!currentSegment.isEmpty()) {
|
||||
segments.add(currentSegment);
|
||||
currentSegment = new ArrayList<>();
|
||||
}
|
||||
} else {
|
||||
currentSegment.add(call);
|
||||
}
|
||||
}
|
||||
if (!currentSegment.isEmpty()) segments.add(currentSegment);
|
||||
|
||||
// Step 3: Parse each segment
|
||||
for (List<MethodInvocation> segment : segments) {
|
||||
boolean isChoice = segment.stream().anyMatch(mi -> "withChoice".equals(mi.getName().getIdentifier()));
|
||||
boolean isJunction = segment.stream().anyMatch(mi -> "withJunction".equals(mi.getName().getIdentifier()));
|
||||
|
||||
if (isChoice || isJunction) {
|
||||
transitions.addAll(parseChoiceOrJunctionTransitions(segment, isChoice ? "withChoice" : "withJunction"));
|
||||
} else {
|
||||
transitions.add(parseStandardTransition(segment));
|
||||
}
|
||||
}
|
||||
|
||||
return transitions;
|
||||
}
|
||||
|
||||
private static List<Transition> parseChoiceOrJunctionTransitions(List<MethodInvocation> segment, String type) {
|
||||
List<Transition> transitions = new ArrayList<>();
|
||||
String sourceState = null;
|
||||
int orderCounter = 0;
|
||||
|
||||
// Extract source state once
|
||||
for (MethodInvocation call : segment) {
|
||||
if ("source".equals(call.getName().getIdentifier()) && !call.arguments().isEmpty()) {
|
||||
sourceState = QuotedExpression.of(call.arguments().getFirst()).toStringWithoutQuotes();
|
||||
break; // only take the first occurrence
|
||||
}
|
||||
}
|
||||
|
||||
for (MethodInvocation call : segment) {
|
||||
String methodName = call.getName().getIdentifier();
|
||||
List<?> args = call.arguments();
|
||||
if (!List.of("first", "then", "last").contains(methodName) || args.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Transition t = new Transition();
|
||||
t.setType(type);
|
||||
if (sourceState != null) {
|
||||
t.getSourceStates().add(sourceState);
|
||||
}
|
||||
t.getTargetStates().add(QuotedExpression.of(args.get(0)).toStringWithoutQuotes());
|
||||
if (args.size() > 1) {
|
||||
if ("last".equals(methodName)) {
|
||||
// For 'last', the second argument is an action, not a guard
|
||||
parseAction(args.get(1), t);
|
||||
} else {
|
||||
// For 'first' and 'then', the second argument is a guard
|
||||
parseGuard(args.get(1), t);
|
||||
}
|
||||
}
|
||||
if (args.size() > 2) {
|
||||
parseAction(args.get(2), t);
|
||||
}
|
||||
t.setOrder(orderCounter++);
|
||||
transitions.add(t);
|
||||
}
|
||||
return transitions;
|
||||
}
|
||||
|
||||
|
||||
private static Transition parseStandardTransition(List<MethodInvocation> segment) {
|
||||
Transition t = new Transition();
|
||||
for (MethodInvocation call : segment) {
|
||||
String methodName = call.getName().getIdentifier();
|
||||
List<?> args = call.arguments();
|
||||
|
||||
if (SUPPORTED_TRANSITION_TYPES.contains(methodName)) {
|
||||
t.setType(methodName);
|
||||
continue;
|
||||
}
|
||||
if (args.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (methodName) {
|
||||
case "source" -> args.forEach(arg -> t.getSourceStates().add(QuotedExpression.of(arg).toStringWithoutQuotes()));
|
||||
case "target" -> args.forEach(arg -> t.getTargetStates().add(QuotedExpression.of(arg).toStringWithoutQuotes()));
|
||||
case "event" -> t.setEvent(QuotedExpression.of(args.getFirst()).toStringWithoutQuotes());
|
||||
case "guard" -> parseGuard(args.getFirst(), t);
|
||||
case "action" -> parseAction(args.getFirst(), t);
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
private static void parseGuard(Object arg, Transition t) {
|
||||
QuotedExpression quotedExpr = QuotedExpression.of(arg);
|
||||
if (quotedExpr != null) {
|
||||
t.setGuard(quotedExpr.toStringWithoutQuotes());
|
||||
t.setLambdaGuard(isLambdaOrAnonymous(quotedExpr.getExpression()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void parseAction(Object arg, Transition t) {
|
||||
QuotedExpression quotedExpr = QuotedExpression.of(arg);
|
||||
if (quotedExpr != null) {
|
||||
t.getActions().add(quotedExpr.toStringWithoutQuotes());
|
||||
t.getIsLambdaActions().add(isLambdaOrAnonymous(quotedExpr.getExpression()));
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isLambdaOrAnonymous(Expression expr) {
|
||||
return switch (expr) {
|
||||
case null -> false;
|
||||
case LambdaExpression le -> true;
|
||||
case ClassInstanceCreation cic -> cic.getAnonymousClassDeclaration() != null;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package click.kamil.springstatemachineexporter.ast.app;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
|
||||
@Getter
|
||||
public class QuotedExpression {
|
||||
private final Expression expression;
|
||||
|
||||
private QuotedExpression(Expression expression) {
|
||||
this.expression = expression;
|
||||
}
|
||||
|
||||
public String toStringWithoutQuotes() {
|
||||
if (expression == null) return null;
|
||||
return stripQuotes(expression.toString());
|
||||
}
|
||||
|
||||
public static String stripQuotes(String s) {
|
||||
if (s == null) return null;
|
||||
return s.replaceAll("^\"|\"$", "");
|
||||
}
|
||||
|
||||
public static QuotedExpression of(Expression expression) {
|
||||
return new QuotedExpression(expression);
|
||||
}
|
||||
|
||||
public static QuotedExpression of(Object obj) {
|
||||
if (obj instanceof Expression expr) {
|
||||
return new QuotedExpression(expr);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package click.kamil.springstatemachineexporter.ast.app;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class StateMachineStateConfigurationMethodFinder {
|
||||
|
||||
public static CompilationUnit parse(String source) {
|
||||
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
|
||||
parser.setSource(source.toCharArray());
|
||||
parser.setKind(ASTParser.K_COMPILATION_UNIT);
|
||||
parser.setResolveBindings(false);
|
||||
return (CompilationUnit) parser.createAST(null);
|
||||
}
|
||||
|
||||
@Getter
|
||||
public static class StateConfigurerVisitor extends ASTVisitor {
|
||||
|
||||
private final Set<String> initialStates = new HashSet<>();
|
||||
private final Set<String> endStates = new HashSet<>();
|
||||
|
||||
private boolean visitConfigureMethod(MethodDeclaration node) {
|
||||
if (!"configure".equals(node.getName().getIdentifier())) {
|
||||
return super.visit(node);
|
||||
}
|
||||
|
||||
Block body = node.getBody();
|
||||
if (body == null) return false;
|
||||
|
||||
// Traverse statements inside configure method
|
||||
for (Object stmtObj : body.statements()) {
|
||||
if (!(stmtObj instanceof ExpressionStatement exprStmt)) continue;
|
||||
Expression expr = exprStmt.getExpression();
|
||||
if (expr instanceof MethodInvocation rootCall) {
|
||||
|
||||
// Check if this chain starts with "withStates"
|
||||
if (isWithStatesChain(rootCall)) {
|
||||
parseMethodChain(rootCall);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false; // no need to visit further inside configure
|
||||
}
|
||||
private boolean visitBuilderMethod(MethodDeclaration node) {
|
||||
Block body = node.getBody();
|
||||
if (body == null) return false;
|
||||
|
||||
// Traverse statements inside buildStateMachine method
|
||||
for (Object stmtObj : body.statements()) {
|
||||
if (!(stmtObj instanceof ExpressionStatement exprStmt)) continue;
|
||||
Expression expr = exprStmt.getExpression();
|
||||
if (expr instanceof MethodInvocation rootCall) {
|
||||
// Detect when the chain starts with "configureStates"
|
||||
// Start looping through the method chain
|
||||
findConfigureStates(rootCall);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
private void findConfigureStates(MethodInvocation methodInvocation) {
|
||||
// Start at the outermost method invocation
|
||||
MethodInvocation currentInvocation = methodInvocation;
|
||||
|
||||
while (currentInvocation != null) {
|
||||
// Check if the current method in the chain is 'configureStates'
|
||||
if ("configureStates".equals(currentInvocation.getName().getIdentifier())) {
|
||||
// Once found, call the parsing method
|
||||
parseStateMachineMethodChain(methodInvocation);
|
||||
return; // Exit once we've handled the method
|
||||
}
|
||||
|
||||
// Move to the next method invocation in the chain (moving forward)
|
||||
Expression nextExpression = currentInvocation.getExpression();
|
||||
|
||||
// Check if the next expression is another method invocation
|
||||
if (nextExpression instanceof MethodInvocation) {
|
||||
// Continue to the next method invocation
|
||||
currentInvocation = (MethodInvocation) nextExpression;
|
||||
} else {
|
||||
// No more method calls in the chain, break out of the loop
|
||||
currentInvocation = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for method chain starting with "configureStates"
|
||||
private void parseStateMachineMethodChain(MethodInvocation mi) {
|
||||
List<MethodInvocation> chain = new ArrayList<>();
|
||||
Expression current = mi;
|
||||
while (current instanceof MethodInvocation m) {
|
||||
chain.addFirst(m);
|
||||
current = m.getExpression();
|
||||
}
|
||||
|
||||
for (MethodInvocation call : chain) {
|
||||
String methodName = call.getName().getIdentifier();
|
||||
List<?> args = call.arguments();
|
||||
if (args.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Expression arg = (Expression) args.get(0);
|
||||
switch (methodName) {
|
||||
case "initial" -> {
|
||||
if (!isInsideRegionOrFork(call)) {
|
||||
initialStates.add(QuotedExpression.of(arg).toStringWithoutQuotes());
|
||||
}
|
||||
}
|
||||
case "end" -> {
|
||||
endStates.add(QuotedExpression.of(arg).toStringWithoutQuotes());
|
||||
}
|
||||
// You can also add cases for other methods if needed (e.g., stateEntry, stateExit)
|
||||
default -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(MethodDeclaration node) {
|
||||
// Visit method named "configure" only
|
||||
if ("configure".equals(node.getName().getIdentifier())) {
|
||||
return visitConfigureMethod(node);
|
||||
}
|
||||
// Handle builder method configuration (e.g., for buildStateMachine)
|
||||
if ("buildStateMachine".equals(node.getName().getIdentifier())) {
|
||||
return visitBuilderMethod(node);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
private boolean isWithStatesChain(MethodInvocation mi) {
|
||||
MethodInvocation current = mi;
|
||||
while (true) {
|
||||
Expression expr = current.getExpression();
|
||||
if ("withStates".equals(current.getName().getIdentifier())) {
|
||||
return true;
|
||||
}
|
||||
if (expr instanceof MethodInvocation next) {
|
||||
current = next;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isInsideRegionOrFork(MethodInvocation mi) {
|
||||
Expression expr = mi.getExpression();
|
||||
while (expr instanceof MethodInvocation parent) {
|
||||
String methodName = parent.getName().getIdentifier();
|
||||
if (List.of("fork", "region").contains(methodName)) {
|
||||
return true;
|
||||
}
|
||||
expr = parent.getExpression();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void parseMethodChain(MethodInvocation mi) {
|
||||
List<MethodInvocation> chain = new ArrayList<>();
|
||||
Expression current = mi;
|
||||
while (current instanceof MethodInvocation m) {
|
||||
chain.addFirst(m);
|
||||
current = m.getExpression();
|
||||
}
|
||||
|
||||
for (MethodInvocation call : chain) {
|
||||
String methodName = call.getName().getIdentifier();
|
||||
List<?> args = call.arguments();
|
||||
if (args.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
Expression arg = (Expression) args.getFirst();
|
||||
switch (methodName) {
|
||||
case "initial" -> {
|
||||
if (!isInsideRegionOrFork(call)) {
|
||||
initialStates.add(QuotedExpression.of(arg).toStringWithoutQuotes());
|
||||
}
|
||||
}
|
||||
case "end" -> endStates.add(QuotedExpression.of(arg).toStringWithoutQuotes());
|
||||
// ignore others
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package click.kamil.springstatemachineexporter.ast.app;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||
import lombok.Getter;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class StateMachineTransitionConfigurationMethodFinder {
|
||||
private final CompilationUnit cu;
|
||||
private final ConfigureMethodVisitor visitor = new ConfigureMethodVisitor();
|
||||
private static final List<String> stateMachineConfigurationAdapterSuperclasses = List.of("EnumStateMachineConfigurerAdapter", "StateMachineConfigurerAdapter");
|
||||
|
||||
public StateMachineTransitionConfigurationMethodFinder(String source) {
|
||||
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
|
||||
parser.setSource(source.toCharArray());
|
||||
parser.setKind(ASTParser.K_COMPILATION_UNIT);
|
||||
parser.setResolveBindings(false);
|
||||
|
||||
this.cu = (CompilationUnit) parser.createAST(null);
|
||||
}
|
||||
|
||||
public MethodDeclaration findConfigureMethod() {
|
||||
this.cu.accept(visitor);
|
||||
return visitor.getConfigureMethod();
|
||||
}
|
||||
|
||||
public Set<MethodDeclaration> findMethodsWithReturnType(Set<String> returnTypes) {
|
||||
Set<MethodDeclaration> matchedMethods = new HashSet<>();
|
||||
cu.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodDeclaration node) {
|
||||
Type returnType = node.getReturnType2();
|
||||
String typeName = (returnType == null) ? "void" : AstUtils.extractSimpleTypeName(returnType);
|
||||
if (returnTypes.contains(typeName)) {
|
||||
matchedMethods.add(node);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return matchedMethods;
|
||||
}
|
||||
|
||||
|
||||
@Getter
|
||||
private static class ConfigureMethodVisitor extends ASTVisitor {
|
||||
private MethodDeclaration configureMethod = null;
|
||||
|
||||
@Override
|
||||
public boolean visit(TypeDeclaration node) {
|
||||
if (!node.isInterface() && extendsStateMachineConfigurerAdapter(node)) {
|
||||
for (MethodDeclaration method : node.getMethods()) {
|
||||
if (isConfigureMethod(method)) {
|
||||
configureMethod = method;
|
||||
return false; // Found, stop visiting further
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean extendsStateMachineConfigurerAdapter(TypeDeclaration node) {
|
||||
Type superclass = node.getSuperclassType();
|
||||
if (superclass == null)
|
||||
return false;
|
||||
|
||||
if (superclass.isParameterizedType()) {
|
||||
ParameterizedType pt = (ParameterizedType) superclass;
|
||||
return stateMachineConfigurationAdapterSuperclasses.contains(pt.getType().toString());
|
||||
}
|
||||
return stateMachineConfigurationAdapterSuperclasses.contains(superclass.toString());
|
||||
}
|
||||
|
||||
private boolean isConfigureMethod(MethodDeclaration method) {
|
||||
if (!method.getName().getIdentifier().equals("configure")) return false;
|
||||
|
||||
if (!Modifier.isPublic(method.getModifiers())) return false;
|
||||
Type returnType = method.getReturnType2();
|
||||
if (returnType == null || !returnType.isPrimitiveType() || !"void".equals(returnType.toString()))
|
||||
return false;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<SingleVariableDeclaration> params = method.parameters();
|
||||
if (params.size() != 1)
|
||||
return false;
|
||||
|
||||
if (!params.getFirst().getType().toString().startsWith("StateMachineTransitionConfigurer"))
|
||||
return false;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Type> thrownExceptions = method.thrownExceptionTypes();
|
||||
for (Type excType : thrownExceptions) {
|
||||
if (excType.toString().equals("Exception")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package click.kamil.springstatemachineexporter.ast.app;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class TransitionStateUtils {
|
||||
public static Set<String> findStartStates(Collection<Transition> transitions) {
|
||||
Set<String> allTargetStates = transitions.stream()
|
||||
.flatMap(t -> normalizeStates(t.getTargetStates()))
|
||||
.collect(Collectors.toSet());
|
||||
return transitions.stream()
|
||||
.flatMap(t -> normalizeStates(t.getSourceStates()))
|
||||
.filter(state -> !allTargetStates.contains(state))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public static Set<String> findEndStates(Collection<Transition> transitions) {
|
||||
Set<String> allSourceStates = transitions.stream()
|
||||
.flatMap(t -> normalizeStates(t.getSourceStates()))
|
||||
.collect(Collectors.toSet());
|
||||
return transitions.stream()
|
||||
.flatMap(t -> normalizeStates(t.getTargetStates()))
|
||||
.filter(state -> !allSourceStates.contains(state))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private static Stream<String> normalizeStates(Collection<String> states) {
|
||||
return states.stream()
|
||||
.map(QuotedExpression::stripQuotes)
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package click.kamil.springstatemachineexporter.ast.app.domain;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@ToString
|
||||
@Getter
|
||||
@Setter
|
||||
public class Transition {
|
||||
private String type; // withExternal, withChoice, withInternal, etc.
|
||||
private List<String> sourceStates = new ArrayList<>();
|
||||
private List<String> targetStates = new ArrayList<>();
|
||||
private String event;
|
||||
|
||||
private String guard;
|
||||
private boolean isLambdaGuard = false;
|
||||
|
||||
private List<String> actions = new ArrayList<>();
|
||||
private List<Boolean> isLambdaActions = new ArrayList<>();
|
||||
|
||||
private Integer order = null; // optional order for withChoice or withJunction
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package click.kamil.springstatemachineexporter.ast.common;
|
||||
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
public final class AstUtils {
|
||||
private AstUtils() {
|
||||
}
|
||||
public static String extractSimpleTypeName(Type type) {
|
||||
if (type.isSimpleType()) {
|
||||
return ((SimpleType) type).getName().getFullyQualifiedName();
|
||||
} else if (type.isParameterizedType()) {
|
||||
Type rawType = ((ParameterizedType) type).getType();
|
||||
return extractSimpleTypeName(rawType);
|
||||
} else if (type.isQualifiedType()) {
|
||||
return ((QualifiedType) type).getName().getIdentifier();
|
||||
} else if (type.isNameQualifiedType()) {
|
||||
return ((NameQualifiedType) type).getName().getIdentifier();
|
||||
} else if (type.isArrayType()) {
|
||||
return extractSimpleTypeName(((ArrayType) type).getElementType());
|
||||
} else if (type.isPrimitiveType()) {
|
||||
return ((PrimitiveType) type).getPrimitiveTypeCode().toString();
|
||||
} else {
|
||||
return type.toString(); // fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package click.kamil.springstatemachineexporter.ast.common;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class FileUtils {
|
||||
public static List<Path> findFilesWithExtension(Set<Path> startDirs, String extension) throws RuntimeException {
|
||||
try (Stream<Path> paths = startDirs.stream()
|
||||
.flatMap(startDir -> {
|
||||
try {
|
||||
return Files.walk(startDir);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
})) {
|
||||
return paths
|
||||
.filter(p -> Files.isRegularFile(p) && p.toString().endsWith(extension))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package click.kamil.springstatemachineexporter.ast.common;
|
||||
|
||||
public final class StringUtils {
|
||||
|
||||
public static boolean isBlank(String string) {
|
||||
if (isEmpty(string)) {
|
||||
return true;
|
||||
}
|
||||
for (int i = 0; i < string.length(); i++) {
|
||||
if (!Character.isWhitespace(string.charAt(i))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean isNotBlank(String string) {
|
||||
return !isBlank(string);
|
||||
}
|
||||
|
||||
public static boolean isEmpty(String string) {
|
||||
return string == null || string.isEmpty();
|
||||
}
|
||||
|
||||
public static boolean isNotEmpty(String string) {
|
||||
return !isEmpty(string);
|
||||
}
|
||||
|
||||
public static String truncate(String string, int maxLength) {
|
||||
if (string.length() > maxLength) {
|
||||
return string.substring(0, maxLength);
|
||||
}
|
||||
return string;
|
||||
}
|
||||
|
||||
public static String truncate(String string, int maxLength, String truncationIndicator) {
|
||||
if (truncationIndicator.length() >= maxLength) {
|
||||
throw new IllegalArgumentException("maxLength must be greater than length of truncationIndicator");
|
||||
}
|
||||
if (string.length() > maxLength) {
|
||||
int remainingLength = maxLength - truncationIndicator.length();
|
||||
return string.substring(0, remainingLength) + truncationIndicator;
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
private StringUtils() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package click.kamil.springstatemachineexporter.ast.in;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.AstUtils;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public class AstFileFinder {
|
||||
private final ASTParser parser;
|
||||
|
||||
public AstFileFinder() {
|
||||
this.parser = ASTParser.newParser(AST.getJLSLatest());
|
||||
parser.setKind(ASTParser.K_COMPILATION_UNIT);
|
||||
parser.setResolveBindings(false);
|
||||
}
|
||||
|
||||
public boolean hasClassWithAnnotation(String source, List<String> annotationNames) {
|
||||
parser.setSource(source.toCharArray());
|
||||
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
|
||||
AtomicBoolean found = new AtomicBoolean(false);
|
||||
cu.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(TypeDeclaration node) {
|
||||
// skip interfaces
|
||||
if (node.isInterface()) return true;
|
||||
for (Object modifier : node.modifiers()) {
|
||||
if (modifier instanceof Annotation annotation &&
|
||||
annotationNames.contains(annotation.getTypeName().getFullyQualifiedName())) {
|
||||
found.set(true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
return found.get();
|
||||
}
|
||||
public boolean hasFunctionReturningType(String source, Set<String> targetReturnTypes) {
|
||||
parser.setSource(source.toCharArray());
|
||||
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
|
||||
AtomicBoolean found = new AtomicBoolean(false);
|
||||
|
||||
cu.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodDeclaration node) {
|
||||
Type returnType = node.getReturnType2();
|
||||
String typeName = (returnType == null) ? "void" : AstUtils.extractSimpleTypeName(returnType);
|
||||
|
||||
if (targetReturnTypes.contains(typeName)) {
|
||||
found.set(true);
|
||||
return false; // stop visiting
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return found.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package click.kamil.springstatemachineexporter.ast.out;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class Dot implements StateMachineExporter {
|
||||
|
||||
private static final String[] CHOICE_COLORS = {
|
||||
"blue", "darkgreen", "darkred", "darkorange", "darkviolet", "teal", "brown", "crimson"
|
||||
};
|
||||
|
||||
@Override
|
||||
public String export(List<Transition> transitions,
|
||||
Set<String> startStates,
|
||||
Set<String> endStates,
|
||||
boolean useLambdaGuards) {
|
||||
boolean includeChoiceStates = true;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("digraph stateMachine {\n");
|
||||
sb.append(" rankdir=LR;\n");
|
||||
sb.append(" node [shape=circle, style=filled, fillcolor=lightgray];\n\n");
|
||||
|
||||
sb.append(" start [shape=point, label=\"\"];\n");
|
||||
|
||||
// Identify choice states and assign colors
|
||||
Set<String> statesWithChoice = new HashSet<>();
|
||||
Map<String, String> choiceColorMap = new HashMap<>();
|
||||
int colorIndex = 0;
|
||||
|
||||
for (Transition t : transitions) {
|
||||
if ("withChoice".equals(t.getType()) && t.getSourceStates() != null) {
|
||||
for (String source : t.getSourceStates()) {
|
||||
String simplified = simplify(source);
|
||||
statesWithChoice.add(simplified);
|
||||
if (!choiceColorMap.containsKey(simplified)) {
|
||||
choiceColorMap.put(simplified, CHOICE_COLORS[colorIndex % CHOICE_COLORS.length]);
|
||||
colorIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all states
|
||||
Set<String> allStates = new HashSet<>();
|
||||
for (Transition t : transitions) {
|
||||
if (t.getSourceStates() != null) {
|
||||
for (String s : t.getSourceStates()) allStates.add(simplify(s));
|
||||
}
|
||||
if (t.getTargetStates() != null) {
|
||||
for (String tgt : t.getTargetStates()) allStates.add(simplify(tgt));
|
||||
}
|
||||
}
|
||||
|
||||
// Declare normal states
|
||||
for (String state : allStates) {
|
||||
if (!statesWithChoice.contains(includeChoiceStates ? state + "_choice" : state)) {
|
||||
String shape = endStates.contains(state) ? "doublecircle" : "circle";
|
||||
sb.append(" ").append(state)
|
||||
.append(" [shape=").append(shape).append(", fillcolor=lightgray];\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Declare choice pseudostates
|
||||
for (String state : statesWithChoice) {
|
||||
String choiceState = includeChoiceStates ? state + "_choice" : state;
|
||||
sb.append(" ").append(choiceState)
|
||||
.append(" [shape=diamond, label=\"\", fillcolor=lightyellow];\n");
|
||||
}
|
||||
|
||||
sb.append("\n");
|
||||
|
||||
// Start state arrows
|
||||
for (String start : startStates) {
|
||||
sb.append(" start -> ").append(simplify(start)).append(";\n");
|
||||
}
|
||||
|
||||
sb.append("\n");
|
||||
|
||||
if (includeChoiceStates) {
|
||||
// Connect normal states to their choice pseudostates
|
||||
for (String state : statesWithChoice) {
|
||||
sb.append(" ").append(state).append(" -> ").append(state).append("_choice;\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
|
||||
|
||||
// Transitions
|
||||
for (Transition t : transitions) {
|
||||
if (t.getSourceStates() == null || t.getSourceStates().isEmpty()) continue;
|
||||
|
||||
boolean allowNoTarget = "withJoin".equals(t.getType()) || "withFork".equals(t.getType());
|
||||
List<String> targets = t.getTargetStates();
|
||||
|
||||
if (targets == null || targets.isEmpty()) {
|
||||
if (!allowNoTarget) {
|
||||
targets = t.getSourceStates(); // self-loop
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (String rawSource : t.getSourceStates()) {
|
||||
String source = simplify(rawSource);
|
||||
|
||||
for (String rawTarget : targets) {
|
||||
String target = simplify(rawTarget);
|
||||
String edgeSource = source;
|
||||
|
||||
if ("withChoice".equals(t.getType()) && statesWithChoice.contains(source)) {
|
||||
edgeSource = includeChoiceStates ? source + "_choice" : source;
|
||||
}
|
||||
|
||||
// Label: event [guard] / actions
|
||||
StringBuilder label = new StringBuilder();
|
||||
|
||||
if (t.getEvent() != null && !t.getEvent().isBlank()) {
|
||||
label.append(escapeDotString(simplify(t.getEvent())));
|
||||
}
|
||||
|
||||
// Guard
|
||||
if (t.getGuard() != null && !t.getGuard().isBlank()) {
|
||||
if (!label.isEmpty()) label.append(" ");
|
||||
String guardText = useLambdaGuards && t.isLambdaGuard() ? "λ"
|
||||
: escapeDotString(t.getGuard().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim());
|
||||
label.append("[").append(guardText).append("]");
|
||||
}
|
||||
|
||||
// Actions
|
||||
if (t.getActions() != null && !t.getActions().isEmpty()) {
|
||||
StringBuilder actionBuilder = new StringBuilder();
|
||||
for (int i = 0; i < t.getActions().size(); i++) {
|
||||
if (i > 0) actionBuilder.append(", ");
|
||||
String action = t.getActions().get(i);
|
||||
boolean isLambda = i < t.getIsLambdaActions().size() && t.getIsLambdaActions().get(i);
|
||||
actionBuilder.append(useLambdaGuards && isLambda ? "λ" : escapeDotString(action));
|
||||
}
|
||||
if (!label.isEmpty()) label.append(" / ");
|
||||
label.append(actionBuilder);
|
||||
}
|
||||
|
||||
// Order
|
||||
if ((t.getType().equals("withChoice") || t.getType().equals("withJunction")) && t.getOrder() != null) {
|
||||
if (!label.isEmpty()) label.append(" ");
|
||||
label.append("(order=").append(t.getOrder()).append(")");
|
||||
}
|
||||
|
||||
// Edge attributes
|
||||
StringBuilder edgeAttrs = new StringBuilder();
|
||||
if (!label.isEmpty()) {
|
||||
appendEdgeAttr(edgeAttrs, "label", label.toString());
|
||||
}
|
||||
|
||||
// Style/color by type
|
||||
switch (t.getType()) {
|
||||
case "withInternal":
|
||||
appendEdgeAttr(edgeAttrs, "style", "dashed");
|
||||
appendEdgeAttr(edgeAttrs, "color", "gray");
|
||||
break;
|
||||
case "withLocal":
|
||||
appendEdgeAttr(edgeAttrs, "style", "dotted");
|
||||
appendEdgeAttr(edgeAttrs, "color", "darkgray");
|
||||
break;
|
||||
case "withChoice":
|
||||
String choiceColor = choiceColorMap.getOrDefault(source, "blue");
|
||||
appendEdgeAttr(edgeAttrs, "style", "solid");
|
||||
appendEdgeAttr(edgeAttrs, "color", choiceColor);
|
||||
break;
|
||||
case "withJunction":
|
||||
appendEdgeAttr(edgeAttrs, "style", "dotted");
|
||||
appendEdgeAttr(edgeAttrs, "color", "purple");
|
||||
break;
|
||||
case "withFork":
|
||||
appendEdgeAttr(edgeAttrs, "style", "bold");
|
||||
appendEdgeAttr(edgeAttrs, "color", "green");
|
||||
break;
|
||||
case "withJoin":
|
||||
appendEdgeAttr(edgeAttrs, "style", "bold");
|
||||
appendEdgeAttr(edgeAttrs, "color", "darkorange");
|
||||
break;
|
||||
case "withExternal":
|
||||
default:
|
||||
appendEdgeAttr(edgeAttrs, "style", "solid");
|
||||
appendEdgeAttr(edgeAttrs, "color", "black");
|
||||
}
|
||||
|
||||
sb.append(" ").append(edgeSource).append(" -> ").append(target);
|
||||
if (!edgeAttrs.isEmpty()) {
|
||||
sb.append(" [").append(edgeAttrs).append("]");
|
||||
}
|
||||
sb.append(";\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("}\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileExtension() {
|
||||
return ".dot";
|
||||
}
|
||||
|
||||
private String escapeDotString(String input) {
|
||||
if (input == null) return "";
|
||||
return input.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private void appendEdgeAttr(StringBuilder sb, String key, String value) {
|
||||
if (!sb.isEmpty()) sb.append(", ");
|
||||
sb.append(key).append("=\"").append(value).append("\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package click.kamil.springstatemachineexporter.ast.out;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
|
||||
import click.kamil.springstatemachineexporter.ast.common.StringUtils;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class PlantUml implements StateMachineExporter {
|
||||
// Color palette for choices
|
||||
private final List<String> choiceColors = List.of("FF6347", "4682B4", "32CD32", "FFD700", "6A5ACD", "FF69B4");
|
||||
private String getColor(Transition t, String source, Map<String, String> choiceStateColorMap) {
|
||||
return switch (t.getType()) {
|
||||
case "withChoice" -> choiceStateColorMap.getOrDefault(source, "000000");
|
||||
case "withExternal" -> "1E90FF";
|
||||
case "withInternal" -> "32CD32";
|
||||
case "withLocal" -> "FFA500";
|
||||
case "withJunction" -> "FF69B4";
|
||||
case "withJoin" -> "8A2BE2";
|
||||
case "withFork" -> "20B2AA";
|
||||
default -> "000000";
|
||||
};
|
||||
}
|
||||
private final String LAMBDA = "λ";
|
||||
|
||||
@Override
|
||||
public String export(List<Transition> transitions,
|
||||
Set<String> startStates,
|
||||
Set<String> endStates,
|
||||
boolean useLambdaGuards) {
|
||||
boolean includeChoiceStates = false;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("@startuml\n");
|
||||
sb.append("skinparam state {\n");
|
||||
sb.append(" BackgroundColor<<choice>> LightYellow\n");
|
||||
sb.append("}\n\n");
|
||||
|
||||
// 1. Print start states
|
||||
for (String start : startStates) {
|
||||
sb.append("[*] --> ").append(simplify(start)).append("\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
|
||||
// 2. Find all states that have choice transitions originating from them
|
||||
Set<String> statesWithChoice = new HashSet<>();
|
||||
for (Transition t : transitions) {
|
||||
if ("withChoice".equals(t.getType())) {
|
||||
for (String source : t.getSourceStates()) {
|
||||
statesWithChoice.add(simplify(source));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Declare choice pseudostates & connect from original state
|
||||
for (String state : statesWithChoice) {
|
||||
String choiceState = state;
|
||||
if (includeChoiceStates) {
|
||||
choiceState = state + "_choice";
|
||||
sb.append("state ").append(choiceState).append(" <<choice>>\n");
|
||||
sb.append(state).append(" --> ").append(choiceState).append("\n");
|
||||
} else {
|
||||
sb.append("state ").append(choiceState).append(" <<choice>>\n");
|
||||
}
|
||||
}
|
||||
sb.append("\n");
|
||||
|
||||
// Assign color to each choice state
|
||||
Map<String, String> choiceStateColorMap = new HashMap<>();
|
||||
int colorIndex = 0;
|
||||
for (String state : statesWithChoice) {
|
||||
choiceStateColorMap.put(state, choiceColors.get(colorIndex % choiceColors.size()));
|
||||
colorIndex++;
|
||||
}
|
||||
|
||||
Set<String> transitionLines = new HashSet<>();
|
||||
|
||||
// 4. Output transitions
|
||||
for (Transition t : transitions) {
|
||||
if (t.getSourceStates() == null || t.getSourceStates().isEmpty()) continue;
|
||||
|
||||
boolean allowNoTarget = "withJoin".equals(t.getType()) || "withFork".equals(t.getType());
|
||||
|
||||
List<String> targets;
|
||||
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
|
||||
if (!allowNoTarget) {
|
||||
targets = t.getSourceStates(); // Self-loop
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
targets = t.getTargetStates();
|
||||
}
|
||||
|
||||
for (String rawSource : t.getSourceStates()) {
|
||||
String source = simplify(rawSource);
|
||||
|
||||
for (String rawTarget : targets) {
|
||||
String target = simplify(rawTarget);
|
||||
String transitionSource;
|
||||
if (includeChoiceStates) {
|
||||
transitionSource = "withChoice".equals(t.getType()) && statesWithChoice.contains(source)
|
||||
? source + "_choice"
|
||||
: source;
|
||||
} else {
|
||||
transitionSource = source;
|
||||
}
|
||||
|
||||
String label = buildLabel(t, useLambdaGuards);
|
||||
String color = getColor(t, source, choiceStateColorMap);
|
||||
String line = transitionSource + " -[#"+color+",bold]-> " + target + (label.isEmpty() ? "" : (" : " + label));
|
||||
|
||||
if (transitionLines.add(line)) {
|
||||
sb.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("\n");
|
||||
|
||||
// 5. Mark end states
|
||||
for (String end : endStates) {
|
||||
sb.append(simplify(end)).append(" --> [*]\n");
|
||||
}
|
||||
|
||||
sb.append("@enduml\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileExtension() {
|
||||
return ".plantuml.dot";
|
||||
}
|
||||
|
||||
private String getGuardText(Transition t, boolean useLambdaGuards) {
|
||||
String guard = t.getGuard();
|
||||
if (StringUtils.isBlank(guard)) {
|
||||
return "";
|
||||
}
|
||||
if (useLambdaGuards && t.isLambdaGuard()) {
|
||||
return LAMBDA;
|
||||
}
|
||||
return guard
|
||||
.replaceAll("[\\n\\r]+", " ")
|
||||
.replaceAll("\\s{2,}", " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private String getActionsText(Transition t, boolean useLambdaGuards) {
|
||||
if (t.getActions() == null || t.getActions().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
List<String> actions = t.getActions();
|
||||
List<Boolean> isLambdaFlags = t.getIsLambdaActions();
|
||||
|
||||
// Defensive: ensure lists are same size to avoid IndexOutOfBoundsException
|
||||
int size = Math.min(actions.size(), isLambdaFlags.size());
|
||||
|
||||
return IntStream.range(0, size)
|
||||
.mapToObj(i -> (useLambdaGuards && isLambdaFlags.get(i)) ? LAMBDA : actions.get(i))
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
private String buildLabel(Transition t, boolean useLambdaGuards) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
Optional.ofNullable(t.getEvent())
|
||||
.filter(e -> !e.isBlank())
|
||||
.map(this::simplify)
|
||||
.ifPresent(parts::add);
|
||||
Optional.of(getGuardText(t, useLambdaGuards))
|
||||
.filter(g -> !g.isBlank())
|
||||
.map(g -> "[" + g + "]")
|
||||
.ifPresent(parts::add);
|
||||
Optional.of(getActionsText(t, useLambdaGuards))
|
||||
.filter(a -> !a.isBlank())
|
||||
.ifPresent(actions -> {
|
||||
if (!parts.isEmpty()) {
|
||||
int lastIndex = parts.size() - 1;
|
||||
String last = parts.get(lastIndex);
|
||||
parts.set(lastIndex, last + " / " + actions);
|
||||
} else {
|
||||
parts.add(actions);
|
||||
}
|
||||
});
|
||||
Optional.ofNullable(t.getOrder())
|
||||
.filter(order -> List.of("withChoice", "withJunction").contains(t.getType()))
|
||||
.map(order -> "(order=" + order + ")")
|
||||
.ifPresent(parts::add);
|
||||
|
||||
return String.join(" ", parts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package click.kamil.springstatemachineexporter.ast.out;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class Scxml implements StateMachineExporter {
|
||||
private static final String LAMBDA = "λ";
|
||||
|
||||
@Override
|
||||
public String export(List<Transition> transitions,
|
||||
Set<String> startStates,
|
||||
Set<String> endStates,
|
||||
boolean useLambdaGuards) {
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
sb.append("<scxml xmlns=\"http://www.w3.org/2005/07/scxml\" version=\"1.0\">\n\n");
|
||||
|
||||
// 1. Collect all states
|
||||
Set<String> allStates = new HashSet<>();
|
||||
for (Transition t : transitions) {
|
||||
if (t.getSourceStates() != null) allStates.addAll(t.getSourceStates());
|
||||
if (t.getTargetStates() != null) allStates.addAll(t.getTargetStates());
|
||||
}
|
||||
|
||||
// 2. Emit <state> elements
|
||||
for (String state : allStates) {
|
||||
String simpleState = simplify(state);
|
||||
sb.append(" <state id=\"").append(simpleState).append("\">\n");
|
||||
|
||||
if (startStates.contains(state)) {
|
||||
sb.append(" <initial>\n");
|
||||
sb.append(" <transition target=\"").append(simpleState).append("\"/>\n");
|
||||
sb.append(" </initial>\n");
|
||||
}
|
||||
|
||||
for (Transition t : transitions) {
|
||||
if (t.getSourceStates() == null || !t.getSourceStates().contains(state)) continue;
|
||||
|
||||
boolean allowNoTarget = "withJoin".equals(t.getType()) || "withFork".equals(t.getType());
|
||||
List<String> targets;
|
||||
|
||||
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
|
||||
if (!allowNoTarget) {
|
||||
targets = List.of(state); // self-loop
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
targets = t.getTargetStates();
|
||||
}
|
||||
|
||||
for (String rawTarget : targets) {
|
||||
String target = simplify(rawTarget);
|
||||
if (target.isEmpty()) continue;
|
||||
|
||||
sb.append(renderTransition(t, target, useLambdaGuards));
|
||||
}
|
||||
}
|
||||
|
||||
sb.append(" </state>\n\n");
|
||||
}
|
||||
|
||||
sb.append("</scxml>\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileExtension() {
|
||||
return ".scxml.xml";
|
||||
}
|
||||
|
||||
private static String getGuardText(Transition t, boolean useLambdaGuards) {
|
||||
if (t.getGuard() == null || t.getGuard().isBlank()) return "";
|
||||
if (useLambdaGuards && t.isLambdaGuard()) {
|
||||
return LAMBDA;
|
||||
}
|
||||
return t.getGuard().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim();
|
||||
}
|
||||
|
||||
private static String escapeXml(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace("\"", """).replace("'", "'");
|
||||
}
|
||||
|
||||
private String renderTransition(Transition t, String target, boolean useLambdaGuards) {
|
||||
StringBuilder sb = new StringBuilder(" <transition");
|
||||
|
||||
if (t.getEvent() != null && !t.getEvent().isBlank()) {
|
||||
sb.append(" event=\"").append(simplify(t.getEvent())).append("\"");
|
||||
}
|
||||
|
||||
sb.append(" target=\"").append(target).append("\"");
|
||||
|
||||
String guard = getGuardText(t, useLambdaGuards);
|
||||
if (!guard.isBlank()) {
|
||||
sb.append(" cond=\"").append(escapeXml(guard)).append("\"");
|
||||
}
|
||||
|
||||
// Choice/junction order → XML comment
|
||||
if (("withChoice".equals(t.getType()) || "withJunction".equals(t.getType())) && t.getOrder() != null) {
|
||||
sb.append(">\n");
|
||||
sb.append(" <!-- order=").append(t.getOrder()).append(" -->\n");
|
||||
sb.append(" </transition>\n");
|
||||
} else {
|
||||
sb.append("/>\n");
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package click.kamil.springstatemachineexporter.ast.out;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public interface StateMachineExporter {
|
||||
String export(List<Transition> transitions,
|
||||
Set<String> startStates,
|
||||
Set<String> endStates,
|
||||
boolean useLambdaGuards);
|
||||
|
||||
// Helper: extract last enum part
|
||||
default String simplify(String full) {
|
||||
if (full == null) return "";
|
||||
int dot = full.lastIndexOf('.');
|
||||
return dot >= 0 ? full.substring(dot + 1) : full;
|
||||
}
|
||||
String getFileExtension(); // e.g. ".dot", ".plantuml.dot", ".scxml.xml"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
spring.application.name=statemachinedemo
|
||||
Reference in New Issue
Block a user