move files into modules

This commit is contained in:
2026-06-06 15:07:03 +02:00
parent 00161b294c
commit cc66b7517b
88 changed files with 1090 additions and 79 deletions

View File

@@ -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);
}
}
}
}

View File

@@ -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;
};
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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() {
}
}

View File

@@ -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();
}
}

View File

@@ -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("\"");
}
}

View File

@@ -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);
}
}

View File

@@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&apos;");
}
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();
}
}

View File

@@ -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"
}

View File

@@ -0,0 +1 @@
spring.application.name=statemachinedemo

View File

@@ -0,0 +1,11 @@
package click.kamil.springstatemachineexporter;
import org.junit.jupiter.api.Test;
class MainTest {
@Test
void contextLoads() {
}
}

View File

@@ -0,0 +1,75 @@
package click.kamil.springstatemachineexporter;
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 click.kamil.springstatemachineexporter.utils.TestScenario;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RegressionTest {
static List<TestScenario> provideTestScenarios() {
return List.of(
new TestScenario(
"Complex State Machine",
Path.of("../state_machines/complex_state_machine"),
Path.of("src/test/resources/golden/ComplexStateMachineConfig"),
"ComplexStateMachineConfig"
),
new TestScenario(
"Fork Join State Machine",
Path.of("../state_machines/forkjoin_state_machine"),
Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"),
"ForkJoinStateMachineConfig"
),
new TestScenario(
"Kamil Enum State Machine",
Path.of("../state_machines/enumstate_state_machine"),
Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"),
"KamilEnumStateMachineConfiguration"
),
new TestScenario(
"Simple Enum Join State Machine",
Path.of("../state_machines/simple_state_machine"),
Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"),
"SimpleEnumStateMachineConfiguration"
)
);
}
@ParameterizedTest(name = "{0}")
@MethodSource("provideTestScenarios")
void testOutputMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception {
Main.runExporter(scenario.inputPath(), tempDir);
List<StateMachineExporter> exporters = List.of(
new PlantUml(),
new Dot(),
new Scxml()
);
String baseName = scenario.baseName();
for (StateMachineExporter exporter : exporters) {
String fileName = baseName + exporter.getFileExtension();
Path actualFile = tempDir.resolve(baseName).resolve(fileName);
Path goldenFile = scenario.goldenPath().resolve(fileName);
assertThat(actualFile)
.as("File %s should exist in output", fileName)
.exists();
assertThat(actualFile)
.as("Content mismatch for %s", fileName)
.hasSameTextualContentAs(goldenFile);
}
}
}

View File

@@ -0,0 +1,486 @@
package click.kamil.springstatemachineexporter.ast.app;
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
import org.eclipse.jdt.core.dom.*;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class AstTransitionParserTest {
@Test
void shouldParseSimpleWithExternalTransition() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("CREATED").target("PAID").event("PAY");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition)
.extracting(Transition::getType)
.isEqualTo("withExternal");
assertThat(transition.getSourceStates()).containsExactly("CREATED");
assertThat(transition.getTargetStates()).containsExactly("PAID");
assertThat(transition)
.extracting(Transition::getEvent)
.isEqualTo("PAY");
}
@Test
void shouldParseTransitionWithGuard() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("CREATED").target("PAID").event("PAY").guard("amount > 0");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition)
.extracting(Transition::getGuard)
.isEqualTo("amount > 0");
assertThat(transition.isLambdaGuard()).isFalse();
}
@Test
void shouldParseTransitionWithLambdaGuard() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("CREATED").target("PAID").event("PAY").guard(context -> context.getExtendedState().getVariables().get("amount") > 0);
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition.getGuard()).contains("context -> context.getExtendedState().getVariables().get(\"amount\") > 0");
assertThat(transition.isLambdaGuard()).isTrue();
}
@Test
void shouldParseTransitionWithAction() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("CREATED").target("PAID").event("PAY").action("processPayment()");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition.getActions()).containsExactly("processPayment()");
assertThat(transition.getIsLambdaActions()).containsExactly(false);
}
@Test
void shouldParseTransitionWithLambdaAction() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("CREATED").target("PAID").event("PAY").action(context -> System.out.println("Payment processed"));
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition.getActions()).hasSize(1);
assertThat(transition.getActions().getFirst()).contains("context -> System.out.println(\"Payment processed\")");
assertThat(transition.getIsLambdaActions()).containsExactly(true);
}
@Test
void shouldParseMultipleTransitionsWithAnd() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("CREATED").target("PAID").event("PAY").and().withExternal().source("PAID").target("SHIPPED").event("SHIP");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(2);
Transition first = transitions.getFirst();
assertThat(first)
.extracting(Transition::getType)
.isEqualTo("withExternal");
assertThat(first.getSourceStates()).containsExactly("CREATED");
assertThat(first.getTargetStates()).containsExactly("PAID");
assertThat(first)
.extracting(Transition::getEvent)
.isEqualTo("PAY");
Transition second = transitions.get(1);
assertThat(second)
.extracting(Transition::getType)
.isEqualTo("withExternal");
assertThat(second.getSourceStates()).containsExactly("PAID");
assertThat(second.getTargetStates()).containsExactly("SHIPPED");
assertThat(second)
.extracting(Transition::getEvent)
.isEqualTo("SHIP");
}
@Test
void shouldParseWithChoiceTransition() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withChoice().source("PAID").first("SHIPPED", "isExpress()").then("CANCELLED", "isCancelled()").last("PENDING");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(3);
Transition first = transitions.getFirst();
assertThat(first)
.extracting(Transition::getType)
.isEqualTo("withChoice");
assertThat(first.getSourceStates()).containsExactly("PAID");
assertThat(first.getTargetStates()).containsExactly("SHIPPED");
assertThat(first)
.extracting(Transition::getGuard)
.isEqualTo("isExpress()");
assertThat(first)
.extracting(Transition::getOrder)
.isEqualTo(0);
Transition second = transitions.get(1);
assertThat(second)
.extracting(Transition::getType)
.isEqualTo("withChoice");
assertThat(second.getSourceStates()).containsExactly("PAID");
assertThat(second.getTargetStates()).containsExactly("CANCELLED");
assertThat(second)
.extracting(Transition::getGuard)
.isEqualTo("isCancelled()");
assertThat(second)
.extracting(Transition::getOrder)
.isEqualTo(1);
Transition third = transitions.get(2);
assertThat(third)
.extracting(Transition::getType)
.isEqualTo("withChoice");
assertThat(third.getSourceStates()).containsExactly("PAID");
assertThat(third.getTargetStates()).containsExactly("PENDING");
assertThat(third)
.extracting(Transition::getOrder)
.isEqualTo(2);
}
@Test
void shouldParseWithJunctionTransition() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withJunction().source("PAID").first("SHIPPED", "isExpress()").then("CANCELLED", "isCancelled()").last("PENDING");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(3);
assertThat(transitions.getFirst())
.extracting(Transition::getType)
.isEqualTo("withJunction");
assertThat(transitions.get(1))
.extracting(Transition::getType)
.isEqualTo("withJunction");
assertThat(transitions.get(2))
.extracting(Transition::getType)
.isEqualTo("withJunction");
}
@Test
void shouldParseWithInternalTransition() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withInternal().source("PAID").event("UPDATE").action("updateStatus()");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition)
.extracting(Transition::getType)
.isEqualTo("withInternal");
assertThat(transition.getSourceStates()).containsExactly("PAID");
assertThat(transition)
.extracting(Transition::getEvent)
.isEqualTo("UPDATE");
assertThat(transition.getActions()).containsExactly("updateStatus()");
}
@Test
void shouldParseWithLocalTransition() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withLocal().source("PAID").target("PROCESSING").event("PROCESS");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition)
.extracting(Transition::getType)
.isEqualTo("withLocal");
assertThat(transition.getSourceStates()).containsExactly("PAID");
assertThat(transition.getTargetStates()).containsExactly("PROCESSING");
assertThat(transition)
.extracting(Transition::getEvent)
.isEqualTo("PROCESS");
}
@Test
void shouldParseWithForkTransition() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withFork().source("PAID").target("SHIPPING").target("BILLING");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition)
.extracting(Transition::getType)
.isEqualTo("withFork");
assertThat(transition.getSourceStates()).containsExactly("PAID");
assertThat(transition.getTargetStates()).containsExactlyInAnyOrder("SHIPPING", "BILLING");
}
@Test
void shouldParseWithJoinTransition() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withJoin().source("SHIPPING").source("BILLING").target("COMPLETED");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition)
.extracting(Transition::getType)
.isEqualTo("withJoin");
assertThat(transition.getSourceStates()).containsExactlyInAnyOrder("SHIPPING", "BILLING");
assertThat(transition.getTargetStates()).containsExactly("COMPLETED");
}
@Test
void shouldReturnEmptyList_whenMethodIsNull() {
List<Transition> transitions = AstTransitionParser.parseTransitions(null);
assertThat(transitions).isEmpty();
}
@Test
void shouldReturnEmptyList_whenMethodHasNoBody() {
String source = """
public class TestClass {
public void configure() {
// Empty method
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).isEmpty();
}
@Test
void shouldReturnEmptyList_whenMethodHasNoTransitionStatements() {
String source = """
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
System.out.println("No transitions here");
int x = 5;
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).isEmpty();
}
@Test
void shouldParseTransitionWithMultipleSources() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("CREATED").source("PENDING").target("PAID").event("PAY");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition.getSourceStates()).containsExactlyInAnyOrder("CREATED", "PENDING");
assertThat(transition.getTargetStates()).containsExactly("PAID");
}
@Test
void shouldParseTransitionWithMultipleTargets() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("PAID").target("SHIPPED").target("CANCELLED").event("PROCESS");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition.getSourceStates()).containsExactly("PAID");
assertThat(transition.getTargetStates()).containsExactlyInAnyOrder("SHIPPED", "CANCELLED");
}
@Test
void shouldParseChoiceTransitionWithAction() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions
.withChoice()
.source("PAID")
.first("SHIPPED", "isExpress()", "shipExpress()")
.then("CANCELLED", "isCancelled()", "cancelOrder()")
.last("PENDING", "defaultAction()");
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(3);
assertThat(transitions.getFirst().getActions()).containsExactly("shipExpress()");
assertThat(transitions.get(1).getActions()).containsExactly("cancelOrder()");
assertThat(transitions.get(2).getActions()).containsExactly("defaultAction()");
}
@Test
void shouldDetectLambdaExpressions() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions.withExternal().source("CREATED").target("PAID").event("PAY").guard(context -> context.getExtendedState().getVariables().get("amount") > 0).action(context -> System.out.println("Payment processed"));
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition.isLambdaGuard()).isTrue();
assertThat(transition.getIsLambdaActions()).containsExactly(true);
}
@Test
void shouldDetectAnonymousClassExpressions() {
String source = """
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions
.withExternal()
.source("CREATED")
.target("PAID")
.event("PAY")
.action(new Action<String, String>() {
@Override
public void execute(StateContext<String, String> context) {
System.out.println("Payment processed");
}
});
}
}
""";
MethodDeclaration method = createMethodDeclaration(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method);
assertThat(transitions).hasSize(1);
Transition transition = transitions.getFirst();
assertThat(transition.getIsLambdaActions()).containsExactly(true);
}
private MethodDeclaration createMethodDeclaration(String source) {
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
parser.setKind(ASTParser.K_COMPILATION_UNIT);
parser.setSource(source.toCharArray());
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
if (cu.types().isEmpty()) {
return null;
}
TypeDeclaration typeDecl = (TypeDeclaration) cu.types().getFirst();
MethodDeclaration[] methods = typeDecl.getMethods();
if (methods.length > 0) {
return methods[0];
}
return null;
}
}

View File

@@ -0,0 +1,392 @@
package click.kamil.springstatemachineexporter.ast.app;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class StateMachineStateConfigurationMethodFinderTest {
@Test
void shouldParseInitialStates() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.CREATED)
.end(OrderStates.COMPLETED);
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactly("OrderStates.CREATED");
assertThat(visitor.getEndStates()).containsExactly("OrderStates.COMPLETED");
}
@Test
void shouldParseMultipleInitialStates() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.CREATED)
.initial(OrderStates.PENDING)
.end(OrderStates.COMPLETED)
.end(OrderStates.CANCELLED);
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactlyInAnyOrder("OrderStates.CREATED", "OrderStates.PENDING");
assertThat(visitor.getEndStates()).containsExactlyInAnyOrder("OrderStates.COMPLETED", "OrderStates.CANCELLED");
}
@Test
void shouldIgnoreStatesInsideFork() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.CREATED)
.fork(OrderStates.PROCESSING)
.and()
.withStates()
.parent(OrderStates.PROCESSING)
.initial(OrderStates.SHIPPING)
.initial(OrderStates.BILLING)
.end(OrderStates.COMPLETED);
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactly("OrderStates.CREATED");
assertThat(visitor.getEndStates()).containsExactly("OrderStates.COMPLETED");
}
@Test
void shouldIgnoreStatesInsideRegion() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.CREATED)
.region(OrderStates.PROCESSING)
.and()
.withStates()
.parent(OrderStates.PROCESSING)
.initial(OrderStates.SHIPPING)
.initial(OrderStates.BILLING)
.end(OrderStates.COMPLETED);
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactly("OrderStates.CREATED");
assertThat(visitor.getEndStates()).containsExactly("OrderStates.COMPLETED");
}
@Test
void shouldParseStatesWithStringLiterals() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<String, String> {
@Override
public void configure(StateMachineStateConfigurer<String, String> states) throws Exception {
states
.withStates()
.initial("CREATED")
.end("COMPLETED");
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactly("CREATED");
assertThat(visitor.getEndStates()).containsExactly("COMPLETED");
}
@Test
void shouldParseStatesWithQuotedStrings() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<String, String> {
@Override
public void configure(StateMachineStateConfigurer<String, String> states) throws Exception {
states
.withStates()
.initial("CREATED")
.end("COMPLETED");
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactly("CREATED");
assertThat(visitor.getEndStates()).containsExactly("COMPLETED");
}
@Test
void shouldIgnoreNonConfigureMethods() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
public void someOtherMethod() {
states
.withStates()
.initial(OrderStates.CREATED);
}
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.PENDING)
.end(OrderStates.COMPLETED);
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactly("OrderStates.PENDING");
assertThat(visitor.getEndStates()).containsExactly("OrderStates.COMPLETED");
}
@Test
void shouldHandleEmptyConfigureMethod() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
// Empty method
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).isEmpty();
assertThat(visitor.getEndStates()).isEmpty();
}
@Test
void shouldHandleConfigureMethodWithoutWithStates() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
System.out.println("No state configuration here");
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).isEmpty();
assertThat(visitor.getEndStates()).isEmpty();
}
@Test
void shouldParseComplexStateConfiguration() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.CREATED)
.state(OrderStates.PAID)
.state(OrderStates.SHIPPED)
.end(OrderStates.COMPLETED)
.end(OrderStates.CANCELLED)
.and()
.withStates()
.parent(OrderStates.PAID)
.initial(OrderStates.PROCESSING)
.end(OrderStates.READY);
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactlyInAnyOrder("OrderStates.CREATED", "OrderStates.PROCESSING");
assertThat(visitor.getEndStates()).containsExactlyInAnyOrder("OrderStates.COMPLETED", "OrderStates.CANCELLED", "OrderStates.READY");
}
@Test
void shouldParseStatesWithMethodCalls() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(getInitialState())
.end(getEndState());
}
private OrderStates getInitialState() {
return OrderStates.CREATED;
}
private OrderStates getEndState() {
return OrderStates.COMPLETED;
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactly("getInitialState()");
assertThat(visitor.getEndStates()).containsExactly("getEndState()");
}
@Test
void shouldHandleMultipleConfigureMethods() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.CREATED)
.end(OrderStates.COMPLETED);
}
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
// This should be ignored
states
.withStates()
.initial(OrderStates.PENDING);
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactlyInAnyOrder("OrderStates.CREATED", "OrderStates.PENDING");
assertThat(visitor.getEndStates()).containsExactly("OrderStates.COMPLETED");
}
@Test
void shouldParseStatesWithEnumValues() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.CREATED)
.state(OrderStates.PAID)
.state(OrderStates.SHIPPED)
.end(OrderStates.COMPLETED);
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactly("OrderStates.CREATED");
assertThat(visitor.getEndStates()).containsExactly("OrderStates.COMPLETED");
}
@Test
void shouldHandleNestedForkAndRegion() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states
.withStates()
.initial(OrderStates.CREATED)
.fork(OrderStates.PROCESSING)
.and()
.withStates()
.parent(OrderStates.PROCESSING)
.region(OrderStates.SHIPPING)
.and()
.withStates()
.parent(OrderStates.SHIPPING)
.initial(OrderStates.PREPARING)
.end(OrderStates.SHIPPED)
.and()
.withStates()
.parent(OrderStates.PROCESSING)
.region(OrderStates.BILLING)
.and()
.withStates()
.parent(OrderStates.BILLING)
.initial(OrderStates.CALCULATING)
.end(OrderStates.BILLED)
.and()
.withStates()
.parent(OrderStates.PROCESSING)
.end(OrderStates.COMPLETED);
}
}
""";
CompilationUnit cu = StateMachineStateConfigurationMethodFinder.parse(source);
StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor visitor =
new StateMachineStateConfigurationMethodFinder.StateConfigurerVisitor();
cu.accept(visitor);
assertThat(visitor.getInitialStates()).containsExactly("OrderStates.CREATED");
assertThat(visitor.getEndStates()).containsExactlyInAnyOrder("OrderStates.COMPLETED", "OrderStates.SHIPPED", "OrderStates.BILLED");
}
}

View File

@@ -0,0 +1,344 @@
package click.kamil.springstatemachineexporter.ast.app;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.SimpleName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class StateMachineTransitionConfigurationMethodFinderTest {
@Test
void shouldFindConfigureMethodInEnumStateMachineConfigurerAdapter() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
transitions
.withExternal()
.source(OrderStates.CREATED)
.target(OrderStates.PAID)
.event(OrderEvents.PAY);
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method)
.extracting(MethodDeclaration::getName)
.extracting(SimpleName::getIdentifier)
.isEqualTo("configure");
}
@Test
void shouldFindConfigureMethodInStateMachineConfigurerAdapter() {
String source = """
public class OrderStateMachineConfig extends StateMachineConfigurerAdapter<String, String> {
@Override
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions
.withExternal()
.source("CREATED")
.target("PAID")
.event("PAY");
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method)
.extracting(MethodDeclaration::getName)
.extracting(SimpleName::getIdentifier)
.isEqualTo("configure");
}
@Test
void shouldReturnNull_whenClassDoesNotExtendStateMachineConfigurer() {
String source = """
public class OrderStateMachineConfig {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
// This should not be found
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method).isNull();
}
@Test
void shouldReturnNull_whenClassIsInterface() {
String source = """
public interface OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception;
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method).isNull();
}
@Test
void shouldReturnNull_whenConfigureMethodIsNotPublic() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
protected void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
// This should not be found
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method).isNull();
}
@Test
void shouldReturnNull_whenConfigureMethodDoesNotReturnVoid() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public String configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
// This should not be found
return "result";
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method).isNull();
}
@Test
void shouldReturnNull_whenConfigureMethodHasWrongNumberOfParameters() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure() throws Exception {
// This should not be found
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method).isNull();
}
@Test
void shouldReturnNull_whenConfigureMethodParameterIsNotStateMachineTransitionConfigurer() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
// This should not be found
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method).isNull();
}
@Test
void shouldReturnNull_whenConfigureMethodDoesNotThrowException() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) {
// This should not be found
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method).isNull();
}
@Test
void shouldReturnNull_whenConfigureMethodThrowsDifferentException() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws RuntimeException {
// This should not be found
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method).isNull();
}
@Test
void shouldFindConfigureMethodWithMultipleExceptions() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception, RuntimeException {
transitions
.withExternal()
.source(OrderStates.CREATED)
.target(OrderStates.PAID)
.event(OrderEvents.PAY);
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method)
.extracting(MethodDeclaration::getName)
.extracting(SimpleName::getIdentifier)
.isEqualTo("configure");
}
@Test
void shouldFindConfigureMethodWithParameterizedType() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
transitions
.withExternal()
.source(OrderStates.CREATED)
.target(OrderStates.PAID)
.event(OrderEvents.PAY);
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method)
.extracting(MethodDeclaration::getName)
.extracting(SimpleName::getIdentifier)
.isEqualTo("configure");
}
@Test
void shouldReturnNull_whenMultipleConfigureMethodsExist() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
// State configuration
}
@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
// Transition configuration
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method)
.extracting(MethodDeclaration::getName)
.extracting(SimpleName::getIdentifier)
.isEqualTo("configure");
}
@Test
void shouldHandleClassWithNoMethods() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
// No methods
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method).isNull();
}
@Test
void shouldHandleClassWithOtherMethods() {
String source = """
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
public void someOtherMethod() {
// This should be ignored
}
@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
// This should be found
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method)
.extracting(MethodDeclaration::getName)
.extracting(SimpleName::getIdentifier)
.isEqualTo("configure");
}
@Test
void shouldHandleClassWithGenericParameters() {
String source = """
public class OrderStateMachineConfig<S, E> extends EnumStateMachineConfigurerAdapter<S, E> {
@Override
public void configure(StateMachineTransitionConfigurer<S, E> transitions) throws Exception {
// Generic configuration
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method)
.extracting(MethodDeclaration::getName)
.extracting(SimpleName::getIdentifier)
.isEqualTo("configure");
}
@Test
void shouldHandleClassWithAnnotations() {
String source = """
@Configuration
public class OrderStateMachineConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
@Bean
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
// Configuration with annotations
}
}
""";
StateMachineTransitionConfigurationMethodFinder finder = new StateMachineTransitionConfigurationMethodFinder(source);
MethodDeclaration method = finder.findConfigureMethod();
assertThat(method)
.extracting(MethodDeclaration::getName)
.extracting(SimpleName::getIdentifier)
.isEqualTo("configure");
}
}

View File

@@ -0,0 +1,282 @@
package click.kamil.springstatemachineexporter.ast.app;
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class TransitionStateUtilsTest {
@Test
void shouldFindStartStates_whenStatesHaveNoIncomingTransitions() {
List<Transition> transitions = List.of(
createTransition("CREATED", "PAID"),
createTransition("PAID", "SHIPPED"),
createTransition("SHIPPED", "COMPLETED")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
assertThat(startStates).containsExactly("CREATED");
}
@Test
void shouldFindStartStates_whenMultipleStatesHaveNoIncomingTransitions() {
List<Transition> transitions = List.of(
createTransition("CREATED", "PAID"),
createTransition("PENDING", "PAID"),
createTransition("PAID", "SHIPPED")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
assertThat(startStates).containsExactlyInAnyOrder("CREATED", "PENDING");
}
@Test
void shouldFindEndStates_whenStatesHaveNoOutgoingTransitions() {
List<Transition> transitions = List.of(
createTransition("CREATED", "PAID"),
createTransition("PAID", "SHIPPED"),
createTransition("PAID", "CANCELLED")
);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(endStates).containsExactlyInAnyOrder("SHIPPED", "CANCELLED");
}
@Test
void shouldFindEndStates_whenMultipleStatesHaveNoOutgoingTransitions() {
List<Transition> transitions = List.of(
createTransition("CREATED", "PAID"),
createTransition("PAID", "SHIPPED"),
createTransition("PAID", "CANCELLED"),
createTransition("PAID", "REFUNDED")
);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(endStates).containsExactlyInAnyOrder("SHIPPED", "CANCELLED", "REFUNDED");
}
@Test
void shouldHandleEmptyTransitionsList() {
Set<String> startStates = TransitionStateUtils.findStartStates(List.of());
Set<String> endStates = TransitionStateUtils.findEndStates(List.of());
assertThat(startStates).isEmpty();
assertThat(endStates).isEmpty();
}
@Test
void shouldHandleTransitionsWithQuotedStates() {
List<Transition> transitions = List.of(
createTransition("\"CREATED\"", "\"PAID\""),
createTransition("\"PAID\"", "\"SHIPPED\"")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).containsExactly("CREATED");
assertThat(endStates).containsExactly("SHIPPED");
}
@Test
void shouldHandleTransitionsWithMixedQuotedAndUnquotedStates() {
List<Transition> transitions = List.of(
createTransition("CREATED", "\"PAID\""),
createTransition("\"PAID\"", "SHIPPED")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).containsExactly("CREATED");
assertThat(endStates).containsExactly("SHIPPED");
}
@Test
void shouldHandleTransitionsWithNullStates() {
List<Transition> transitions = List.of(
createTransition(null, "PAID"),
createTransition("PAID", null)
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).isEmpty();
assertThat(endStates).isEmpty();
}
@Test
void shouldHandleTransitionsWithEmptyStates() {
List<Transition> transitions = List.of(
createTransition("", "PAID"),
createTransition("PAID", "")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).isEmpty();
assertThat(endStates).isEmpty();
}
@Test
void shouldHandleTransitionsWithMultipleSourceStates() {
Transition transition1 = createTransition(List.of("CREATED", "PENDING"), List.of("PAID"));
Transition transition2 = createTransition(List.of("PAID"), List.of("SHIPPED"));
List<Transition> transitions = List.of(transition1, transition2);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).containsExactlyInAnyOrder("CREATED", "PENDING");
assertThat(endStates).containsExactly("SHIPPED");
}
@Test
void shouldHandleTransitionsWithMultipleTargetStates() {
Transition transition1 = createTransition(
List.of("PAID"),
List.of("SHIPPED", "CANCELLED")
);
List<Transition> transitions = List.of(transition1);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).containsExactly("PAID");
assertThat(endStates).containsExactlyInAnyOrder("SHIPPED", "CANCELLED");
}
@Test
void shouldHandleCircularTransitions() {
List<Transition> transitions = List.of(
createTransition("A", "B"),
createTransition("B", "C"),
createTransition("C", "A")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).isEmpty();
assertThat(endStates).isEmpty();
}
@Test
void shouldHandleSelfLoopingTransitions() {
List<Transition> transitions = List.of(
createTransition("A", "A"),
createTransition("A", "B")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).isEmpty(); // A is both source and target, so not a start state
assertThat(endStates).containsExactly("B"); // Only B is a pure end state
}
@Test
void shouldHandleTransitionsWithWhitespaceInStates() {
List<Transition> transitions = List.of(
createTransition(" CREATED ", " PAID "),
createTransition(" PAID ", " SHIPPED ")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).containsExactly(" CREATED ");
assertThat(endStates).containsExactly(" SHIPPED ");
}
@Test
void shouldHandleTransitionsWithSpecialCharactersInStates() {
List<Transition> transitions = List.of(
createTransition("STATE_1", "STATE_2"),
createTransition("STATE_2", "STATE_3")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).containsExactly("STATE_1");
assertThat(endStates).containsExactly("STATE_3");
}
@Test
void shouldHandleTransitionsWithEnumValues() {
List<Transition> transitions = List.of(
createTransition("OrderStates.CREATED", "OrderStates.PAID"),
createTransition("OrderStates.PAID", "OrderStates.SHIPPED")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).containsExactly("OrderStates.CREATED");
assertThat(endStates).containsExactly("OrderStates.SHIPPED");
}
@Test
void shouldHandleTransitionsWithMethodCalls() {
List<Transition> transitions = List.of(
createTransition("getInitialState()", "getPaidState()"),
createTransition("getPaidState()", "getShippedState()")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).containsExactly("getInitialState()");
assertThat(endStates).containsExactly("getShippedState()");
}
@Test
void shouldHandleComplexStateMachine() {
List<Transition> transitions = List.of(
createTransition("CREATED", "PAID"),
createTransition("PAID", "SHIPPED"),
createTransition("PAID", "CANCELLED"),
createTransition("SHIPPED", "DELIVERED"),
createTransition("CANCELLED", "REFUNDED")
);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
assertThat(startStates).containsExactly("CREATED");
assertThat(endStates).containsExactlyInAnyOrder("DELIVERED", "REFUNDED");
}
private Transition createTransition(String source, String target) {
Transition transition = new Transition();
if (source != null) {
transition.getSourceStates().add(source);
}
if (target != null) {
transition.getTargetStates().add(target);
}
return transition;
}
private Transition createTransition(List<String> sources, List<String> targets) {
Transition transition = new Transition();
if (sources != null) {
transition.getSourceStates().addAll(sources);
}
if (targets != null) {
transition.getTargetStates().addAll(targets);
}
return transition;
}
}

View File

@@ -0,0 +1,293 @@
package click.kamil.springstatemachineexporter.ast.in;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
class AstFileFinderTest {
private static final String CLASS_WITH_ANNOTATION = """
@Service
public class TestService {
public void doSomething() {
// implementation
}
}
""";
private static final String CLASS_WITH_MULTIPLE_ANNOTATIONS = """
@Service
@Component
public class TestService {
public void doSomething() {
// implementation
}
}
""";
private static final String INTERFACE_WITH_ANNOTATION = """
@FunctionalInterface
public interface TestInterface {
void doSomething();
}
""";
private static final String CLASS_WITHOUT_ANNOTATION = """
public class TestService {
public void doSomething() {
// implementation
}
}
""";
private static final String CLASS_WITH_DIFFERENT_ANNOTATION = """
@Repository
public class TestRepository {
public void doSomething() {
// implementation
}
}
""";
private static final String CLASS_WITH_FULLY_QUALIFIED_ANNOTATION = """
@org.springframework.stereotype.Service
public class TestService {
public void doSomething() {
// implementation
}
}
""";
private static final String MULTIPLE_CLASSES = """
@Service
public class TestService {
public void doSomething() {
// implementation
}
}
public class AnotherClass {
// no annotation
}
""";
private static final String CLASS_WITH_ANNOTATION_AND_FIELDS = """
@Service
public class TestService {
private String name;
public void doSomething() {
// implementation
}
}
""";
private static final String EMPTY_SOURCE = "";
private static final String INVALID_JAVA_SYNTAX = """
@Service
public class TestService {
// missing closing brace
""";
@Test
void shouldReturnTrue_whenClassHasMatchingAnnotation() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service");
boolean result = finder.hasClassWithAnnotation(CLASS_WITH_ANNOTATION, annotationNames);
assertThat(result).isTrue();
}
@Test
void shouldReturnTrue_whenClassHasMultipleMatchingAnnotations() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service", "Component");
boolean result = finder.hasClassWithAnnotation(CLASS_WITH_MULTIPLE_ANNOTATIONS, annotationNames);
assertThat(result).isTrue();
}
@Test
void shouldReturnFalse_whenClassHasNoAnnotation() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service");
boolean result = finder.hasClassWithAnnotation(CLASS_WITHOUT_ANNOTATION, annotationNames);
assertThat(result).isFalse();
}
@Test
void shouldReturnFalse_whenClassHasDifferentAnnotation() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service");
boolean result = finder.hasClassWithAnnotation(CLASS_WITH_DIFFERENT_ANNOTATION, annotationNames);
assertThat(result).isFalse();
}
@Test
void shouldReturnTrue_whenClassHasFullyQualifiedAnnotation() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("org.springframework.stereotype.Service");
boolean result = finder.hasClassWithAnnotation(CLASS_WITH_FULLY_QUALIFIED_ANNOTATION, annotationNames);
assertThat(result).isTrue();
}
@Test
void shouldReturnTrue_whenMultipleClassesAndOneHasAnnotation() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service");
boolean result = finder.hasClassWithAnnotation(MULTIPLE_CLASSES, annotationNames);
assertThat(result).isTrue();
}
@Test
void shouldReturnFalse_whenInterfaceHasAnnotation() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("FunctionalInterface");
boolean result = finder.hasClassWithAnnotation(INTERFACE_WITH_ANNOTATION, annotationNames);
assertThat(result).isFalse();
}
@Test
void shouldReturnFalse_whenEmptyAnnotationList() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = Collections.emptyList();
boolean result = finder.hasClassWithAnnotation(CLASS_WITH_ANNOTATION, annotationNames);
assertThat(result).isFalse();
}
@Test
void shouldReturnFalse_whenEmptySource() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service");
boolean result = finder.hasClassWithAnnotation(EMPTY_SOURCE, annotationNames);
assertThat(result).isFalse();
}
@Test
void shouldThrowException_whenNullAnnotationList() {
AstFileFinder finder = new AstFileFinder();
// when & then
assertThatThrownBy(() ->
finder.hasClassWithAnnotation(CLASS_WITH_ANNOTATION, null)
).isInstanceOf(NullPointerException.class);
}
@Test
void shouldReturnTrue_whenClassHasAnnotationAndFields() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service");
boolean result = finder.hasClassWithAnnotation(CLASS_WITH_ANNOTATION_AND_FIELDS, annotationNames);
assertThat(result).isTrue();
}
@Test
void shouldHandleInvalidJavaSyntax() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service");
boolean result = finder.hasClassWithAnnotation(INVALID_JAVA_SYNTAX, annotationNames);
// AST parser might still create a valid AST even with syntax errors
// The result depends on how the parser handles the invalid syntax
assertThat(result).isTrue();
}
@Test
void shouldReturnTrue_whenAnnotationNameMatchesCaseInsensitive() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("service"); // lowercase
boolean result = finder.hasClassWithAnnotation(CLASS_WITH_ANNOTATION, annotationNames);
assertThat(result).isFalse(); // AST parser is case-sensitive
}
@Test
void shouldReturnTrue_whenMultipleAnnotationNamesProvided() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Component", "Service", "Repository");
boolean result = finder.hasClassWithAnnotation(CLASS_WITH_ANNOTATION, annotationNames);
assertThat(result).isTrue();
}
@Test
void shouldReturnFalse_whenNoMatchingAnnotationNames() {
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Component", "Repository", "Controller");
boolean result = finder.hasClassWithAnnotation(CLASS_WITH_ANNOTATION, annotationNames);
assertThat(result).isFalse();
}
@Test
void shouldReturnFalse_whenSourceContainsOnlyComments() {
String sourceWithComments = """
// This is a comment
/* Another comment */
/** JavaDoc comment */
""";
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service");
boolean result = finder.hasClassWithAnnotation(sourceWithComments, annotationNames);
assertThat(result).isFalse();
}
@Test
void shouldReturnFalse_whenSourceContainsOnlyPackageDeclaration() {
String sourceWithPackage = "package com.example;";
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service");
boolean result = finder.hasClassWithAnnotation(sourceWithPackage, annotationNames);
assertThat(result).isFalse();
}
@Test
void shouldReturnTrue_whenClassHasAnnotationWithParameters() {
String classWithAnnotatedParams = """
@Service("testService")
public class TestService {
public void doSomething() {
// implementation
}
}
""";
AstFileFinder finder = new AstFileFinder();
List<String> annotationNames = List.of("Service");
boolean result = finder.hasClassWithAnnotation(classWithAnnotatedParams, annotationNames);
assertThat(result).isTrue();
}
}

View File

@@ -0,0 +1,10 @@
package click.kamil.springstatemachineexporter.utils;
import java.nio.file.Path;
public record TestScenario(
String name,
Path inputPath,
Path goldenPath,
String baseName
) {}