removal and better style

This commit is contained in:
2026-06-07 16:13:11 +02:00
parent 86fb4f260b
commit 728df1d8ba
20 changed files with 447 additions and 1567 deletions

View File

@@ -2,13 +2,9 @@ package click.kamil.springstatemachineexporter;
import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
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.CodebaseContext;
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;
@@ -27,7 +23,6 @@ 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", "StateMachineFactory", "StateMachineModelFactory");

View File

@@ -1,15 +1,19 @@
package click.kamil.springstatemachineexporter.ast.app;
import click.kamil.springstatemachineexporter.ast.app.domain.Action;
import click.kamil.springstatemachineexporter.ast.app.domain.Guard;
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
import click.kamil.springstatemachineexporter.ast.app.domain.TransitionType;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import java.util.stream.Collectors;
public class AstTransitionParser {
private static final Set<String> SUPPORTED_TRANSITION_TYPES = Set.of(
"withExternal", "withInternal", "withLocal", "withFork", "withJoin", "withChoice", "withJunction"
);
private static final Set<String> SUPPORTED_TRANSITION_METHOD_NAMES = Arrays.stream(TransitionType.values())
.map(TransitionType::getMethodName)
.collect(Collectors.toSet());
public static List<Transition> parseTransitions(MethodDeclaration method) {
if (method == null) return Collections.emptyList();
@@ -30,7 +34,7 @@ public class AstTransitionParser {
return false;
}
List<MethodInvocation> chain = collectMethodInvocationChain(node);
return chain.stream().anyMatch(mi -> SUPPORTED_TRANSITION_TYPES.contains(mi.getName().getIdentifier()));
return chain.stream().anyMatch(mi -> SUPPORTED_TRANSITION_METHOD_NAMES.contains(mi.getName().getIdentifier()));
}
});
@@ -88,18 +92,17 @@ public class AstTransitionParser {
// Step 3: Parse each segment
for (int i = 0; i < segments.size(); i++) {
List<MethodInvocation> segment = segments.get(i);
boolean isChoice = segment.stream().anyMatch(mi -> "withChoice".equals(mi.getName().getIdentifier()));
boolean isJunction = segment.stream().anyMatch(mi -> "withJunction".equals(mi.getName().getIdentifier()));
Optional<TransitionType> segmentType = segment.stream()
.map(mi -> TransitionType.fromMethodName(mi.getName().getIdentifier()))
.flatMap(Optional::stream)
.findFirst();
// If it's a continuation and we are at the first segment, we should be careful
boolean isFirstSegmentOfContinuation = isContinuation && i == 0;
if (isChoice || isJunction) {
List<Transition> choiceTransitions = parseChoiceOrJunctionTransitions(segment, isChoice ? "withChoice" : "withJunction", argsMap);
if (isFirstSegmentOfContinuation) {
// For choice continuations, the first transition often has an empty source (inherited from receiver)
// We keep them as standard choice parsing handles it, but we ensure we don't introduce new start states
}
if (segmentType.isPresent() && (segmentType.get() == TransitionType.CHOICE || segmentType.get() == TransitionType.JUNCTION)) {
List<Transition> choiceTransitions = parseChoiceOrJunctionTransitions(segment, segmentType.get(), argsMap);
transitions.addAll(choiceTransitions);
} else {
Transition t = parseStandardTransition(segment, argsMap);
@@ -118,7 +121,7 @@ public class AstTransitionParser {
return transitions;
}
private static List<Transition> parseChoiceOrJunctionTransitions(List<MethodInvocation> segment, String type, Map<String, Expression> argsMap) {
private static List<Transition> parseChoiceOrJunctionTransitions(List<MethodInvocation> segment, TransitionType type, Map<String, Expression> argsMap) {
List<Transition> transitions = new ArrayList<>();
String sourceState = null;
int orderCounter = 0;
@@ -174,8 +177,9 @@ public class AstTransitionParser {
String methodName = call.getName().getIdentifier();
List<?> args = call.arguments();
if (SUPPORTED_TRANSITION_TYPES.contains(methodName)) {
t.setType(methodName);
Optional<TransitionType> type = TransitionType.fromMethodName(methodName);
if (type.isPresent()) {
t.setType(type.get());
continue;
}
if (args.isEmpty()) {
@@ -211,16 +215,14 @@ public class AstTransitionParser {
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()));
t.setGuard(Guard.of(quotedExpr.toStringWithoutQuotes(), 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()));
t.getActions().add(Action.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression())));
}
}

View File

@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.ast.app;
import lombok.Getter;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.StringLiteral;
@Getter
public class QuotedExpression {
@@ -13,6 +14,9 @@ public class QuotedExpression {
public String toStringWithoutQuotes() {
if (expression == null) return null;
if (expression instanceof StringLiteral sl) {
return sl.getLiteralValue();
}
return stripQuotes(expression.toString());
}

View File

@@ -1,15 +1,19 @@
package click.kamil.springstatemachineexporter.ast.app;
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
import click.kamil.springstatemachineexporter.ast.app.domain.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.Getter;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import java.util.stream.Collectors;
public class StateMachineAggregator {
private final CodebaseContext context;
private enum AnalysisGoal { TRANSITIONS, STATES }
public StateMachineAggregator(CodebaseContext context) {
this.context = context;
}
@@ -76,18 +80,35 @@ public class StateMachineAggregator {
} else if (isTransitionChainContinuation(mi, argsMap)) {
transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi, convertToExpressionMap(argsMap), true));
} else if (isForEach(mi)) {
transitions.addAll(parseForEach(mi, searchStartClass, visitedMethods, argsMap, true, null, null));
transitions.addAll(parseForEach(mi, searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
} else {
// Check if it's a call on a lambda parameter or a variable that resolved to a lambda
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
Expression lambdaReceiver = mi.getExpression();
if (lambdaReceiver instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof LambdaExpression lambda) {
transitions.addAll(parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, true, null, null));
transitions.addAll(parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
}
}
MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass);
if (calledMethod == null) {
// Try to resolve method in another class if it's a static call or on a known type
Expression miReceiver = mi.getExpression();
if (miReceiver instanceof SimpleName sn) {
TypeDeclaration otherTd = context.getTypeDeclaration(sn.getIdentifier(), (CompilationUnit) searchStartClass.getRoot());
if (otherTd != null) {
calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), otherTd);
if (calledMethod != null) {
// For calls to other classes, we switch the "searchStartClass" to that class
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, otherTd, visitedMethods, nextArgsMap));
return transitions; // Handled
}
}
}
}
if (calledMethod != null) {
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, searchStartClass, visitedMethods, nextArgsMap));
@@ -101,7 +122,7 @@ public class StateMachineAggregator {
return mi.getName().getIdentifier().equals("forEach");
}
private List<Transition> parseLambdaInvocation(LambdaExpression lambda, List<?> arguments, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, boolean isTransitions, Set<String> initialStates, Set<String> endStates) {
private List<Transition> parseLambdaInvocation(LambdaExpression lambda, List<?> arguments, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
Map<String, Object> lambdaArgsMap = new HashMap<>(argsMap);
List<?> params = lambda.parameters();
for (int i = 0; i < params.size() && i < arguments.size(); i++) {
@@ -113,7 +134,7 @@ public class StateMachineAggregator {
}
}
if (isTransitions) {
if (goal == AnalysisGoal.TRANSITIONS) {
if (lambda.getBody() instanceof Block block) return parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr) return parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap);
} else {
@@ -123,7 +144,7 @@ public class StateMachineAggregator {
return Collections.emptyList();
}
private List<Transition> parseForEach(MethodInvocation forEachMi, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, boolean isTransitions, Set<String> initialStates, Set<String> endStates) {
private List<Transition> parseForEach(MethodInvocation forEachMi, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
List<Transition> transitions = new ArrayList<>();
Expression receiver = forEachMi.getExpression();
List<Expression> elements = unrollCollection(receiver, argsMap);
@@ -136,7 +157,7 @@ public class StateMachineAggregator {
Map<String, Object> lambdaArgsMap = new HashMap<>(argsMap);
if (!paramName.isEmpty()) lambdaArgsMap.put(paramName, element);
if (isTransitions) {
if (goal == AnalysisGoal.TRANSITIONS) {
if (lambda.getBody() instanceof Block block) transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap));
else if (lambda.getBody() instanceof Expression lambdaExpr) transitions.addAll(parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap));
} else {
@@ -148,7 +169,7 @@ public class StateMachineAggregator {
return transitions;
}
private List<Transition> parseEnhancedForLoop(EnhancedForStatement efs, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, boolean isTransitions, Set<String> initialStates, Set<String> endStates) {
private List<Transition> parseEnhancedForLoop(EnhancedForStatement efs, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
List<Transition> transitions = new ArrayList<>();
List<Expression> elements = unrollCollection(efs.getExpression(), argsMap);
String paramName = efs.getParameter().getName().getIdentifier();
@@ -157,7 +178,7 @@ public class StateMachineAggregator {
Map<String, Object> loopArgsMap = new HashMap<>(argsMap);
loopArgsMap.put(paramName, element);
if (isTransitions) {
if (goal == AnalysisGoal.TRANSITIONS) {
if (efs.getBody() instanceof Block block) transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, loopArgsMap));
else if (efs.getBody() instanceof ExpressionStatement es) transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, loopArgsMap));
} else {
@@ -217,7 +238,7 @@ public class StateMachineAggregator {
}
}
}
else if (stmt instanceof EnhancedForStatement efs) transitions.addAll(parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, true, null, null));
else if (stmt instanceof EnhancedForStatement efs) transitions.addAll(parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
else if (stmt instanceof TryStatement ts) transitions.addAll(parseTransitionsInBlock(ts.getBody(), searchStartClass, visitedMethods, argsMap));
}
return transitions;
@@ -238,7 +259,7 @@ public class StateMachineAggregator {
}
}
}
else if (stmt instanceof EnhancedForStatement efs) parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, false, initialStates, endStates);
else if (stmt instanceof EnhancedForStatement efs) parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
else if (stmt instanceof TryStatement ts) parseStatesInBlock(ts.getBody(), searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
@@ -246,14 +267,14 @@ public class StateMachineAggregator {
private void parseStatesInExpression(Expression expr, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (expr instanceof MethodInvocation mi) {
if (isWithStatesChain(mi, argsMap)) parseStateChain(mi, initialStates, endStates, argsMap);
else if (isForEach(mi)) parseForEach(mi, searchStartClass, visitedMethods, argsMap, false, initialStates, endStates);
else if (isForEach(mi)) parseForEach(mi, searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
else {
// Check if it's a call on a lambda parameter or a variable that resolved to a lambda
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof LambdaExpression lambda) {
parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, false, initialStates, endStates);
parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
}
}
@@ -322,7 +343,7 @@ public class StateMachineAggregator {
private boolean isTransitionChainEntry(MethodInvocation mi) {
String name = mi.getName().getIdentifier();
if (Set.of("withExternal", "withInternal", "withLocal", "withFork", "withJoin", "withChoice", "withJunction").contains(name)) return true;
if (TransitionType.fromMethodName(name).isPresent()) return true;
Expression receiver = mi.getExpression();
if (receiver instanceof MethodInvocation next) return isTransitionChainEntry(next);
return false;

View File

@@ -1,192 +0,0 @@
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
}
}
}
}
}

View File

@@ -1,102 +0,0 @@
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;
}
}
}

View File

@@ -0,0 +1,7 @@
package click.kamil.springstatemachineexporter.ast.app.domain;
public record Action(String expression, boolean isLambda) {
public static Action of(String expression, boolean isLambda) {
return expression != null ? new Action(expression, isLambda) : null;
}
}

View File

@@ -0,0 +1,7 @@
package click.kamil.springstatemachineexporter.ast.app.domain;
public record Guard(String expression, boolean isLambda) {
public static Guard of(String expression, boolean isLambda) {
return expression != null ? new Guard(expression, isLambda) : null;
}
}

View File

@@ -11,16 +11,13 @@ import java.util.List;
@Getter
@Setter
public class Transition {
private String type; // withExternal, withChoice, withInternal, etc.
private TransitionType type;
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 Guard guard;
private List<Action> actions = new ArrayList<>();
private Integer order = null; // optional order for withChoice or withJunction
}

View File

@@ -0,0 +1,27 @@
package click.kamil.springstatemachineexporter.ast.app.domain;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.Arrays;
import java.util.Optional;
@Getter
@RequiredArgsConstructor
public enum TransitionType {
EXTERNAL("withExternal"),
INTERNAL("withInternal"),
LOCAL("withLocal"),
FORK("withFork"),
JOIN("withJoin"),
CHOICE("withChoice"),
JUNCTION("withJunction");
private final String methodName;
public static Optional<TransitionType> fromMethodName(String methodName) {
return Arrays.stream(values())
.filter(t -> t.methodName.equals(methodName))
.findFirst();
}
}

View File

@@ -1,75 +0,0 @@
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)) {
// Also check for @Bean annotation
boolean hasBeanAnnotation = false;
for (Object modifier : node.modifiers()) {
if (modifier instanceof Annotation annotation) {
String annotationName = annotation.getTypeName().getFullyQualifiedName();
if ("Bean".equals(annotationName) || "org.springframework.context.annotation.Bean".equals(annotationName)) {
hasBeanAnnotation = true;
break;
}
}
}
if (hasBeanAnnotation) {
found.set(true);
return false; // stop visiting
}
}
return true;
}
});
return found.get();
}
}

View File

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.ast.out;
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
import click.kamil.springstatemachineexporter.ast.app.domain.TransitionType;
import java.util.*;
@@ -29,7 +30,7 @@ public class Dot implements StateMachineExporter {
int colorIndex = 0;
for (Transition t : transitions) {
if ("withChoice".equals(t.getType()) && t.getSourceStates() != null) {
if (t.getType() == TransitionType.CHOICE && t.getSourceStates() != null) {
for (String source : t.getSourceStates()) {
String simplified = simplify(source);
statesWithChoice.add(simplified);
@@ -90,7 +91,7 @@ public class Dot implements StateMachineExporter {
for (Transition t : transitions) {
if (t.getSourceStates() == null || t.getSourceStates().isEmpty()) continue;
boolean allowNoTarget = "withJoin".equals(t.getType()) || "withFork".equals(t.getType());
boolean allowNoTarget = t.getType() == TransitionType.JOIN || t.getType() == TransitionType.FORK;
List<String> targets = t.getTargetStates();
if (targets == null || targets.isEmpty()) {
@@ -108,7 +109,7 @@ public class Dot implements StateMachineExporter {
String target = simplify(rawTarget);
String edgeSource = source;
if ("withChoice".equals(t.getType()) && statesWithChoice.contains(source)) {
if (t.getType() == TransitionType.CHOICE && statesWithChoice.contains(source)) {
edgeSource = includeChoiceStates ? source + "_choice" : source;
}
@@ -120,10 +121,10 @@ public class Dot implements StateMachineExporter {
}
// Guard
if (t.getGuard() != null && !t.getGuard().isBlank()) {
if (t.getGuard() != null && !t.getGuard().expression().isBlank()) {
if (!label.isEmpty()) label.append(" ");
String guardText = useLambdaGuards && t.isLambdaGuard() ? "λ"
: escapeDotString(t.getGuard().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim());
String guardText = useLambdaGuards && t.getGuard().isLambda() ? "λ"
: escapeDotString(t.getGuard().expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim());
label.append("[").append(guardText).append("]");
}
@@ -132,16 +133,15 @@ public class Dot implements StateMachineExporter {
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));
var action = t.getActions().get(i);
actionBuilder.append(useLambdaGuards && action.isLambda() ? "λ" : escapeDotString(action.expression()));
}
if (!label.isEmpty()) label.append(" / ");
label.append(actionBuilder);
}
// Order
if ((t.getType().equals("withChoice") || t.getType().equals("withJunction")) && t.getOrder() != null) {
if ((t.getType() == TransitionType.CHOICE || t.getType() == TransitionType.JUNCTION) && t.getOrder() != null) {
if (!label.isEmpty()) label.append(" ");
label.append("(order=").append(t.getOrder()).append(")");
}
@@ -153,36 +153,41 @@ public class Dot implements StateMachineExporter {
}
// 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");
if (t.getType() != null) {
switch (t.getType()) {
case INTERNAL:
appendEdgeAttr(edgeAttrs, "style", "dashed");
appendEdgeAttr(edgeAttrs, "color", "gray");
break;
case LOCAL:
appendEdgeAttr(edgeAttrs, "style", "dotted");
appendEdgeAttr(edgeAttrs, "color", "darkgray");
break;
case CHOICE:
String choiceColor = choiceColorMap.getOrDefault(source, "blue");
appendEdgeAttr(edgeAttrs, "style", "solid");
appendEdgeAttr(edgeAttrs, "color", choiceColor);
break;
case JUNCTION:
appendEdgeAttr(edgeAttrs, "style", "dotted");
appendEdgeAttr(edgeAttrs, "color", "purple");
break;
case FORK:
appendEdgeAttr(edgeAttrs, "style", "bold");
appendEdgeAttr(edgeAttrs, "color", "green");
break;
case JOIN:
appendEdgeAttr(edgeAttrs, "style", "bold");
appendEdgeAttr(edgeAttrs, "color", "darkorange");
break;
case EXTERNAL:
default:
appendEdgeAttr(edgeAttrs, "style", "solid");
appendEdgeAttr(edgeAttrs, "color", "black");
}
} else {
appendEdgeAttr(edgeAttrs, "style", "solid");
appendEdgeAttr(edgeAttrs, "color", "black");
}
sb.append(" ").append(edgeSource).append(" -> ").append(target);

View File

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.ast.out;
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
import click.kamil.springstatemachineexporter.ast.app.domain.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.StringUtils;
import java.util.*;
@@ -11,15 +12,15 @@ 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) {
if (t.getType() == null) return "000000";
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";
case CHOICE -> choiceStateColorMap.getOrDefault(source, "000000");
case EXTERNAL -> "1E90FF";
case INTERNAL -> "32CD32";
case LOCAL -> "FFA500";
case JUNCTION -> "FF69B4";
case JOIN -> "8A2BE2";
case FORK -> "20B2AA";
};
}
private final String LAMBDA = "λ";
@@ -45,7 +46,7 @@ public class PlantUml implements StateMachineExporter {
// 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())) {
if (t.getType() == TransitionType.CHOICE) {
for (String source : t.getSourceStates()) {
statesWithChoice.add(simplify(source));
}
@@ -79,7 +80,7 @@ public class PlantUml implements StateMachineExporter {
for (Transition t : transitions) {
if (t.getSourceStates() == null || t.getSourceStates().isEmpty()) continue;
boolean allowNoTarget = "withJoin".equals(t.getType()) || "withFork".equals(t.getType());
boolean allowNoTarget = t.getType() == TransitionType.JOIN || t.getType() == TransitionType.FORK;
List<String> targets;
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
@@ -99,7 +100,7 @@ public class PlantUml implements StateMachineExporter {
String target = simplify(rawTarget);
String transitionSource;
if (includeChoiceStates) {
transitionSource = "withChoice".equals(t.getType()) && statesWithChoice.contains(source)
transitionSource = t.getType() == TransitionType.CHOICE && statesWithChoice.contains(source)
? source + "_choice"
: source;
} else {
@@ -134,14 +135,13 @@ public class PlantUml implements StateMachineExporter {
}
private String getGuardText(Transition t, boolean useLambdaGuards) {
String guard = t.getGuard();
if (StringUtils.isBlank(guard)) {
if (t.getGuard() == null || StringUtils.isBlank(t.getGuard().expression())) {
return "";
}
if (useLambdaGuards && t.isLambdaGuard()) {
if (useLambdaGuards && t.getGuard().isLambda()) {
return LAMBDA;
}
return guard
return t.getGuard().expression()
.replaceAll("[\\n\\r]+", " ")
.replaceAll("\\s{2,}", " ")
.trim();
@@ -152,14 +152,8 @@ public class PlantUml implements StateMachineExporter {
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))
return t.getActions().stream()
.map(action -> (useLambdaGuards && action.isLambda()) ? LAMBDA : action.expression())
.collect(Collectors.joining(", "));
}
@@ -185,7 +179,7 @@ public class PlantUml implements StateMachineExporter {
}
});
Optional.ofNullable(t.getOrder())
.filter(order -> List.of("withChoice", "withJunction").contains(t.getType()))
.filter(order -> t.getType() == TransitionType.CHOICE || t.getType() == TransitionType.JUNCTION)
.map(order -> "(order=" + order + ")")
.ifPresent(parts::add);

View File

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.ast.out;
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
import click.kamil.springstatemachineexporter.ast.app.domain.TransitionType;
import java.util.HashSet;
import java.util.List;
@@ -40,7 +41,7 @@ public class Scxml implements StateMachineExporter {
for (Transition t : transitions) {
if (t.getSourceStates() == null || !t.getSourceStates().contains(state)) continue;
boolean allowNoTarget = "withJoin".equals(t.getType()) || "withFork".equals(t.getType());
boolean allowNoTarget = t.getType() == TransitionType.JOIN || t.getType() == TransitionType.FORK;
List<String> targets;
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
@@ -74,11 +75,11 @@ public class Scxml implements StateMachineExporter {
}
private static String getGuardText(Transition t, boolean useLambdaGuards) {
if (t.getGuard() == null || t.getGuard().isBlank()) return "";
if (useLambdaGuards && t.isLambdaGuard()) {
if (t.getGuard() == null || t.getGuard().expression().isBlank()) return "";
if (useLambdaGuards && t.getGuard().isLambda()) {
return LAMBDA;
}
return t.getGuard().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim();
return t.getGuard().expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim();
}
private static String escapeXml(String s) {
@@ -102,7 +103,7 @@ public class Scxml implements StateMachineExporter {
}
// Choice/junction order → XML comment
if (("withChoice".equals(t.getType()) || "withJunction".equals(t.getType())) && t.getOrder() != null) {
if ((t.getType() == TransitionType.CHOICE || t.getType() == TransitionType.JUNCTION) && t.getOrder() != null) {
sb.append(">\n");
sb.append(" <!-- order=").append(t.getOrder()).append(" -->\n");
sb.append(" </transition>\n");