diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java index 81bd62d..be23d27 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java @@ -92,7 +92,7 @@ public class Main { if (!methodsWithReturnType.isEmpty()) { for (MethodDeclaration m : methodsWithReturnType) { System.out.println("Found method returning StateMachine in: " + javaFile.toAbsolutePath()); - List transitions = AstTransitionParser.parseTransitions2(m); + List transitions = AstTransitionParser.parseTransitions(m); Set startStatesTransitions = TransitionStateUtils.findStartStates(transitions); Set endStatesTransitions = TransitionStateUtils.findEndStates(transitions); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParser.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParser.java index 8acac39..28b2587 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParser.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParser.java @@ -3,55 +3,36 @@ 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; +import java.util.*; public class AstTransitionParser { + private static final Set SUPPORTED_TRANSITION_TYPES = Set.of( "withExternal", "withInternal", "withLocal", "withFork", "withJoin", "withChoice", "withJunction" ); public static List parseTransitions(MethodDeclaration method) { - List 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 parseTransitions2(MethodDeclaration method) { + if (method == null) return Collections.emptyList(); List 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) { + method.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (isEntryPoint(node)) { + transitions.addAll(parseTransitionsFromExpression(node, new HashMap<>())); + } + return super.visit(node); + } - // recursively collect all method invocations in this chain - List 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 afterConfigure = chain.subList(i + 1, chain.size()); - transitions.addAll(parseTransitionsFromExpression(afterConfigure.getLast())); - break; - } - } - } - } - }); + private boolean isEntryPoint(MethodInvocation node) { + // Check if this call is part of a larger chain already handled + if (node.getParent() instanceof MethodInvocation) { + return false; + } + List chain = collectMethodInvocationChain(node); + return chain.stream().anyMatch(mi -> SUPPORTED_TRANSITION_TYPES.contains(mi.getName().getIdentifier())); + } + }); return transitions; } @@ -74,7 +55,7 @@ public class AstTransitionParser { return chain; } - public static List parseTransitionsFromExpression(MethodInvocation rootCall) { + public static List parseTransitionsFromExpression(MethodInvocation rootCall, Map argsMap) { List transitions = new ArrayList<>(); // Step 1: Unroll method chain @@ -106,16 +87,16 @@ public class AstTransitionParser { boolean isJunction = segment.stream().anyMatch(mi -> "withJunction".equals(mi.getName().getIdentifier())); if (isChoice || isJunction) { - transitions.addAll(parseChoiceOrJunctionTransitions(segment, isChoice ? "withChoice" : "withJunction")); + transitions.addAll(parseChoiceOrJunctionTransitions(segment, isChoice ? "withChoice" : "withJunction", argsMap)); } else { - transitions.add(parseStandardTransition(segment)); + transitions.add(parseStandardTransition(segment, argsMap)); } } return transitions; } - private static List parseChoiceOrJunctionTransitions(List segment, String type) { + private static List parseChoiceOrJunctionTransitions(List segment, String type, Map argsMap) { List transitions = new ArrayList<>(); String sourceState = null; int orderCounter = 0; @@ -123,7 +104,8 @@ public class AstTransitionParser { // Extract source state once for (MethodInvocation call : segment) { if ("source".equals(call.getName().getIdentifier()) && !call.arguments().isEmpty()) { - sourceState = QuotedExpression.of(call.arguments().getFirst()).toStringWithoutQuotes(); + Expression arg = resolveArg((Expression) call.arguments().get(0), argsMap); + sourceState = QuotedExpression.of(arg).toStringWithoutQuotes(); break; // only take the first occurrence } } @@ -139,18 +121,23 @@ public class AstTransitionParser { if (sourceState != null) { t.getSourceStates().add(sourceState); } - t.getTargetStates().add(QuotedExpression.of(args.get(0)).toStringWithoutQuotes()); + + Expression targetArg = resolveArg((Expression) args.get(0), argsMap); + t.getTargetStates().add(QuotedExpression.of(targetArg).toStringWithoutQuotes()); + if (args.size() > 1) { + Expression secondArg = resolveArg((Expression) args.get(1), argsMap); if ("last".equals(methodName)) { // For 'last', the second argument is an action, not a guard - parseAction(args.get(1), t); + parseAction(secondArg, t); } else { // For 'first' and 'then', the second argument is a guard - parseGuard(args.get(1), t); + parseGuard(secondArg, t); } } if (args.size() > 2) { - parseAction(args.get(2), t); + Expression thirdArg = resolveArg((Expression) args.get(2), argsMap); + parseAction(thirdArg, t); } t.setOrder(orderCounter++); transitions.add(t); @@ -159,7 +146,7 @@ public class AstTransitionParser { } - private static Transition parseStandardTransition(List segment) { + private static Transition parseStandardTransition(List segment, Map argsMap) { Transition t = new Transition(); for (MethodInvocation call : segment) { String methodName = call.getName().getIdentifier(); @@ -174,11 +161,26 @@ public class AstTransitionParser { } 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); + case "source" -> args.forEach(arg -> { + Expression resolved = resolveArg((Expression) arg, argsMap); + t.getSourceStates().add(QuotedExpression.of(resolved).toStringWithoutQuotes()); + }); + case "target" -> args.forEach(arg -> { + Expression resolved = resolveArg((Expression) arg, argsMap); + t.getTargetStates().add(QuotedExpression.of(resolved).toStringWithoutQuotes()); + }); + case "event" -> { + Expression resolved = resolveArg((Expression) args.get(0), argsMap); + t.setEvent(QuotedExpression.of(resolved).toStringWithoutQuotes()); + } + case "guard" -> { + Expression resolved = resolveArg((Expression) args.get(0), argsMap); + parseGuard(resolved, t); + } + case "action" -> { + Expression resolved = resolveArg((Expression) args.get(0), argsMap); + parseAction(resolved, t); + } } } return t; @@ -200,6 +202,14 @@ public class AstTransitionParser { } } + private static Expression resolveArg(Expression expr, Map argsMap) { + if (expr instanceof SimpleName sn) { + Expression resolved = argsMap.get(sn.getIdentifier()); + if (resolved != null) return resolved; + } + return expr; + } + public static boolean isLambdaOrAnonymous(Expression expr) { return switch (expr) { case null -> false; @@ -208,4 +218,4 @@ public class AstTransitionParser { default -> false; }; } -} \ No newline at end of file +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregator.java index 3d3209d..ffb2cf2 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregator.java @@ -15,33 +15,30 @@ public class StateMachineAggregator { public List aggregateTransitions(TypeDeclaration startClass) { List allTransitions = new ArrayList<>(); - Set visitedClasses = new HashSet<>(); - aggregateTransitionsRecursive(startClass, startClass, allTransitions, visitedClasses, new HashSet<>()); + aggregateTransitionsRecursive(startClass, startClass, allTransitions, new HashSet<>(), new HashSet<>(), new HashMap<>()); return allTransitions; } - private void aggregateTransitionsRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, List allTransitions, Set visitedClasses, Set visitedMethods) { + private void aggregateTransitionsRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, List allTransitions, Set visitedClasses, Set visitedMethods, Map argsMap) { if (currentTd == null || visitedClasses.contains(currentTd.getName().getFullyQualifiedName())) return; visitedClasses.add(currentTd.getName().getFullyQualifiedName()); - // Process this class MethodDeclaration configureMethod = findConfigureTransitionsMethod(currentTd); if (configureMethod != null) { - allTransitions.addAll(parseTransitionsWithMethodCalls(configureMethod, searchStartClass, visitedMethods)); + allTransitions.addAll(parseTransitionsWithMethodCalls(configureMethod, searchStartClass, visitedMethods, argsMap)); } - // Process superclass Type superclassType = currentTd.getSuperclassType(); if (superclassType != null) { String superclassName = getSimpleName(superclassType); TypeDeclaration superclassTd = context.getTypeDeclaration(superclassName); if (superclassTd != null) { - aggregateTransitionsRecursive(superclassTd, searchStartClass, allTransitions, visitedClasses, visitedMethods); + aggregateTransitionsRecursive(superclassTd, searchStartClass, allTransitions, visitedClasses, visitedMethods, argsMap); } } } - private List parseTransitionsWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set visitedMethods) { + private List parseTransitionsWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set visitedMethods, Map argsMap) { List transitions = new ArrayList<>(); if (visitedMethods.contains(method)) return transitions; visitedMethods.add(method); @@ -50,51 +47,190 @@ public class StateMachineAggregator { if (body == null) return transitions; for (Object stmt : body.statements()) { - Expression expr = null; if (stmt instanceof ExpressionStatement es) { - expr = es.getExpression(); + transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, argsMap)); } else if (stmt instanceof VariableDeclarationStatement vds) { for (Object fragmentObj : vds.fragments()) { if (fragmentObj instanceof VariableDeclarationFragment fragment) { - expr = fragment.getInitializer(); + transitions.addAll(parseTransitionsInExpression(fragment.getInitializer(), searchStartClass, visitedMethods, argsMap)); } } + } else if (stmt instanceof EnhancedForStatement efs) { + transitions.addAll(parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, true, null, null)); } + } + return transitions; + } - if (expr instanceof MethodInvocation mi) { - if (isTransitionChain(mi)) { - transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi)); - } else { - // Possible method call to follow - MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass); - if (calledMethod != null && isRelevantMethod(calledMethod)) { - transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, searchStartClass, visitedMethods)); - } + private List parseTransitionsInExpression(Expression expr, TypeDeclaration searchStartClass, Set visitedMethods, Map argsMap) { + List transitions = new ArrayList<>(); + if (expr instanceof MethodInvocation mi) { + if (isTransitionChain(mi)) { + transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi, convertToExpressionMap(argsMap))); + } else if (isForEach(mi)) { + transitions.addAll(parseForEach(mi, searchStartClass, visitedMethods, argsMap, true, null, null)); + } else { + MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass); + if (calledMethod != null) { + Map nextArgsMap = buildArgsMap(calledMethod, mi, argsMap); + transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, searchStartClass, visitedMethods, nextArgsMap)); } } } return transitions; } + private boolean isForEach(MethodInvocation mi) { + return mi.getName().getIdentifier().equals("forEach"); + } + + private List parseForEach(MethodInvocation forEachMi, TypeDeclaration searchStartClass, Set visitedMethods, Map argsMap, boolean isTransitions, Set initialStates, Set endStates) { + List transitions = new ArrayList<>(); + Expression receiver = forEachMi.getExpression(); + List elements = unrollCollection(receiver, argsMap); + if (elements.isEmpty() || forEachMi.arguments().isEmpty()) return transitions; + + Object lambdaObj = forEachMi.arguments().get(0); + if (lambdaObj instanceof LambdaExpression lambda) { + String paramName = getLambdaParamName(lambda); + for (Expression element : elements) { + Map lambdaArgsMap = new HashMap<>(argsMap); + if (!paramName.isEmpty()) lambdaArgsMap.put(paramName, element); + + if (isTransitions) { + if (lambda.getBody() instanceof Block block) transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap)); + else if (lambda.getBody() instanceof Expression lambdaExpr) transitions.addAll(parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap)); + } else { + if (lambda.getBody() instanceof Block block) parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap); + else if (lambda.getBody() instanceof Expression lambdaExpr) parseStatesInExpression(lambdaExpr, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap); + } + } + } + return transitions; + } + + private List parseEnhancedForLoop(EnhancedForStatement efs, TypeDeclaration searchStartClass, Set visitedMethods, Map argsMap, boolean isTransitions, Set initialStates, Set endStates) { + List transitions = new ArrayList<>(); + List elements = unrollCollection(efs.getExpression(), argsMap); + String paramName = efs.getParameter().getName().getIdentifier(); + + for (Expression element : elements) { + Map loopArgsMap = new HashMap<>(argsMap); + loopArgsMap.put(paramName, element); + + if (isTransitions) { + if (efs.getBody() instanceof Block block) transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, loopArgsMap)); + else if (efs.getBody() instanceof ExpressionStatement es) transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, loopArgsMap)); + } else { + if (efs.getBody() instanceof Block block) parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, loopArgsMap); + else if (efs.getBody() instanceof ExpressionStatement es) parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, loopArgsMap); + } + } + return transitions; + } + + private List unrollCollection(Expression expr, Map argsMap) { + if (expr instanceof MethodInvocation mi) { + String name = mi.getName().getIdentifier(); + Expression receiver = mi.getExpression(); + if ((name.equals("of") && receiver != null && (receiver.toString().equals("Stream") || receiver.toString().equals("List"))) + || (name.equals("asList") && receiver != null && receiver.toString().equals("Arrays"))) { + return (List) mi.arguments(); + } + } + if (expr instanceof SimpleName sn) { + Object resolved = argsMap.get(sn.getIdentifier()); + if (resolved instanceof List list) return (List) list; + if (resolved instanceof Expression e) return unrollCollection(e, argsMap); + } + if (expr instanceof ArrayInitializer ai) return (List) ai.expressions(); + return Collections.emptyList(); + } + + private String getLambdaParamName(LambdaExpression lambda) { + if (lambda.parameters().size() == 1) { + Object p = lambda.parameters().get(0); + if (p instanceof VariableDeclaration vp) return vp.getName().getIdentifier(); + } + return ""; + } + + private List parseTransitionsInBlock(Block block, TypeDeclaration searchStartClass, Set visitedMethods, Map argsMap) { + List transitions = new ArrayList<>(); + for (Object stmt : block.statements()) { + if (stmt instanceof ExpressionStatement es) transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, argsMap)); + else if (stmt instanceof EnhancedForStatement efs) transitions.addAll(parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, true, null, null)); + } + return transitions; + } + + private void parseStatesInBlock(Block block, TypeDeclaration searchStartClass, Set initialStates, Set endStates, Set visitedMethods, Map argsMap) { + for (Object stmt : block.statements()) { + if (stmt instanceof ExpressionStatement es) parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, argsMap); + else if (stmt instanceof EnhancedForStatement efs) parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, false, initialStates, endStates); + } + } + + private void parseStatesInExpression(Expression expr, TypeDeclaration searchStartClass, Set initialStates, Set endStates, Set visitedMethods, Map argsMap) { + if (expr instanceof MethodInvocation mi) { + if (isWithStatesChain(mi)) parseStateChain(mi, initialStates, endStates, argsMap); + else if (isForEach(mi)) parseForEach(mi, searchStartClass, visitedMethods, argsMap, false, initialStates, endStates); + else { + MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass); + if (calledMethod != null) { + Map nextArgsMap = buildArgsMap(calledMethod, mi, argsMap); + parseStatesWithMethodCalls(calledMethod, searchStartClass, initialStates, endStates, visitedMethods, nextArgsMap); + } + } + } + } + + private Map buildArgsMap(MethodDeclaration method, MethodInvocation call, Map currentArgsMap) { + Map map = new HashMap<>(); + List params = method.parameters(); + List args = call.arguments(); + + for (int i = 0; i < params.size(); i++) { + SingleVariableDeclaration param = (SingleVariableDeclaration) params.get(i); + String paramName = param.getName().getIdentifier(); + if (param.isVarargs() && i < params.size()) { + List varargList = new ArrayList<>(); + for (int j = i; j < args.size(); j++) { + varargList.add(resolveExpression((Expression) args.get(j), currentArgsMap)); + } + map.put(paramName, varargList); + } else if (i < args.size()) { + map.put(paramName, resolveExpression((Expression) args.get(i), currentArgsMap)); + } + } + return map; + } + + private Expression resolveExpression(Expression expr, Map argsMap) { + if (expr instanceof SimpleName sn) { + Object resolved = argsMap.get(sn.getIdentifier()); + if (resolved instanceof Expression e) return e; + } + return expr; + } + + private Map convertToExpressionMap(Map argsMap) { + Map map = new HashMap<>(); + for (Map.Entry entry : argsMap.entrySet()) { + if (entry.getValue() instanceof Expression e) map.put(entry.getKey(), e); + } + return map; + } + private boolean isTransitionChain(MethodInvocation mi) { String name = mi.getName().getIdentifier(); - if (Set.of("withExternal", "withInternal", "withLocal", "withFork", "withJoin", "withChoice", "withJunction").contains(name)) { - return true; - } - Expression expr = mi.getExpression(); - if (expr instanceof MethodInvocation next) { - return isTransitionChain(next); - } - return false; + if (Set.of("withExternal", "withInternal", "withLocal", "withFork", "withJoin", "withChoice", "withJunction").contains(name)) return true; + return mi.getExpression() instanceof MethodInvocation next && isTransitionChain(next); } private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td) { if (td == null) return null; - for (MethodDeclaration method : td.getMethods()) { - if (method.getName().getIdentifier().equals(methodName)) { - return method; - } - } + for (MethodDeclaration method : td.getMethods()) if (method.getName().getIdentifier().equals(methodName)) return method; Type superclassType = td.getSuperclassType(); if (superclassType != null) { TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType)); @@ -103,89 +239,58 @@ public class StateMachineAggregator { return null; } - private boolean isRelevantMethod(MethodDeclaration method) { - return true; - } + private boolean isRelevantMethod(MethodDeclaration method) { return true; } private MethodDeclaration findConfigureTransitionsMethod(TypeDeclaration td) { - for (MethodDeclaration method : td.getMethods()) { - if (isConfigureTransitionsMethod(method)) return method; - } + for (MethodDeclaration method : td.getMethods()) if (isConfigureTransitionsMethod(method)) return method; return null; } private boolean isConfigureTransitionsMethod(MethodDeclaration method) { - if (!method.getName().getIdentifier().equals("configure")) return false; - List params = method.parameters(); - if (params.size() != 1) return false; - SingleVariableDeclaration param = (SingleVariableDeclaration) params.get(0); - String typeName = param.getType().toString(); - return typeName.startsWith("StateMachineTransitionConfigurer"); + if (!method.getName().getIdentifier().equals("configure") || method.parameters().size() != 1) return false; + return ((SingleVariableDeclaration) method.parameters().get(0)).getType().toString().startsWith("StateMachineTransitionConfigurer"); } public void aggregateStates(TypeDeclaration startClass, Set initialStates, Set endStates) { - Set visitedClasses = new HashSet<>(); - aggregateStatesRecursive(startClass, startClass, initialStates, endStates, visitedClasses, new HashSet<>()); + aggregateStatesRecursive(startClass, startClass, initialStates, endStates, new HashSet<>(), new HashSet<>(), new HashMap<>()); } - private void aggregateStatesRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, Set initialStates, Set endStates, Set visitedClasses, Set visitedMethods) { + private void aggregateStatesRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, Set initialStates, Set endStates, Set visitedClasses, Set visitedMethods, Map argsMap) { if (currentTd == null || visitedClasses.contains(currentTd.getName().getFullyQualifiedName())) return; visitedClasses.add(currentTd.getName().getFullyQualifiedName()); MethodDeclaration configureMethod = findConfigureStatesMethod(currentTd); - if (configureMethod != null) { - parseStatesWithMethodCalls(configureMethod, searchStartClass, initialStates, endStates, visitedMethods); - } + if (configureMethod != null) parseStatesWithMethodCalls(configureMethod, searchStartClass, initialStates, endStates, visitedMethods, argsMap); Type superclassType = currentTd.getSuperclassType(); if (superclassType != null) { TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType)); - if (superclassTd != null) { - aggregateStatesRecursive(superclassTd, searchStartClass, initialStates, endStates, visitedClasses, visitedMethods); - } + if (superclassTd != null) aggregateStatesRecursive(superclassTd, searchStartClass, initialStates, endStates, visitedClasses, visitedMethods, argsMap); } } - private void parseStatesWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set initialStates, Set endStates, Set visitedMethods) { + private void parseStatesWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set initialStates, Set endStates, Set visitedMethods, Map argsMap) { if (visitedMethods.contains(method)) return; visitedMethods.add(method); - Block body = method.getBody(); if (body == null) return; for (Object stmt : body.statements()) { - Expression expr = null; - if (stmt instanceof ExpressionStatement es) { - expr = es.getExpression(); - } else if (stmt instanceof VariableDeclarationStatement vds) { + if (stmt instanceof ExpressionStatement es) parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, argsMap); + else if (stmt instanceof VariableDeclarationStatement vds) { for (Object fragmentObj : vds.fragments()) { - if (fragmentObj instanceof VariableDeclarationFragment fragment) { - expr = fragment.getInitializer(); - } + if (fragmentObj instanceof VariableDeclarationFragment fragment) parseStatesInExpression(fragment.getInitializer(), searchStartClass, initialStates, endStates, visitedMethods, argsMap); } - } - - if (expr instanceof MethodInvocation mi) { - if (isWithStatesChain(mi)) { - parseStateChain(mi, initialStates, endStates); - } else { - MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass); - if (calledMethod != null) { - parseStatesWithMethodCalls(calledMethod, searchStartClass, initialStates, endStates, visitedMethods); - } - } - } + } else if (stmt instanceof EnhancedForStatement efs) parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, false, initialStates, endStates); } } private boolean isWithStatesChain(MethodInvocation mi) { if (mi.getName().getIdentifier().equals("withStates")) return true; - Expression expr = mi.getExpression(); - if (expr instanceof MethodInvocation next) return isWithStatesChain(next); - return false; + return mi.getExpression() instanceof MethodInvocation next && isWithStatesChain(next); } - private void parseStateChain(MethodInvocation mi, Set initialStates, Set endStates) { + private void parseStateChain(MethodInvocation mi, Set initialStates, Set endStates, Map argsMap) { List chain = new ArrayList<>(); Expression current = mi; while (current instanceof MethodInvocation m) { @@ -194,45 +299,30 @@ public class StateMachineAggregator { } for (MethodInvocation call : chain) { String methodName = call.getName().getIdentifier(); - List args = call.arguments(); - if (args.isEmpty()) continue; - Expression arg = (Expression) args.get(0); - if ("initial".equals(methodName)) { - if (!isInsideRegionOrFork(call)) { - initialStates.add(QuotedExpression.of(arg).toStringWithoutQuotes()); - } - } else if ("end".equals(methodName)) { - endStates.add(QuotedExpression.of(arg).toStringWithoutQuotes()); - } + if (call.arguments().isEmpty()) continue; + Expression arg = resolveExpression((Expression) call.arguments().get(0), argsMap); + if ("initial".equals(methodName)) { if (!isInsideRegionOrFork(call)) initialStates.add(QuotedExpression.of(arg).toStringWithoutQuotes()); } + else if ("end".equals(methodName)) endStates.add(QuotedExpression.of(arg).toStringWithoutQuotes()); } } 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; - } + if (List.of("fork", "region").contains(parent.getName().getIdentifier())) return true; expr = parent.getExpression(); } return false; } private MethodDeclaration findConfigureStatesMethod(TypeDeclaration td) { - for (MethodDeclaration method : td.getMethods()) { - if (isConfigureStatesMethod(method)) return method; - } + for (MethodDeclaration method : td.getMethods()) if (isConfigureStatesMethod(method)) return method; return null; } private boolean isConfigureStatesMethod(MethodDeclaration method) { - if (!method.getName().getIdentifier().equals("configure")) return false; - List params = method.parameters(); - if (params.size() != 1) return false; - SingleVariableDeclaration param = (SingleVariableDeclaration) params.get(0); - String typeName = param.getType().toString(); - return typeName.startsWith("StateMachineStateConfigurer"); + if (!method.getName().getIdentifier().equals("configure") || method.parameters().size() != 1) return false; + return ((SingleVariableDeclaration) method.parameters().get(0)).getType().toString().startsWith("StateMachineStateConfigurer"); } private String getSimpleName(Type type) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java index 08fe6ac..5eb9392 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java @@ -47,6 +47,12 @@ class RegressionTest { Path.of("../state_machines/inheritance_state_machine"), Path.of("src/test/resources/golden/OneStateMachineConfiguration"), "OneStateMachineConfiguration" + ), + new TestScenario( + "Two State Machine (Extra Functions)", + Path.of("../state_machines/inheritance_extra_functions_state_machine"), + Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"), + "TwoStateMachineConfiguration" ) ); } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParserTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParserTest.java index 14dafc1..91d7b83 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParserTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/app/AstTransitionParserTest.java @@ -468,6 +468,25 @@ class AstTransitionParserTest { assertThat(transition.getIsLambdaActions()).containsExactly(true); } + @Test + void shouldParseTransitionsWithVariableDeclarations() { + String source = """ + public class TestClass { + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + ExternalTransitionConfigurer t1 = transitions.withExternal().source("S1").target("S2").event("E1"); + t1.and().withExternal().source("S2").target("S3").event("E2"); + } + } + """; + MethodDeclaration method = createMethodDeclaration(source); + + List transitions = AstTransitionParser.parseTransitions(method); + + assertThat(transitions).hasSize(2); + assertThat(transitions.get(0).getEvent()).isEqualTo("E1"); + assertThat(transitions.get(1).getEvent()).isEqualTo("E2"); + } + private MethodDeclaration createMethodDeclaration(String source) { ASTParser parser = ASTParser.newParser(AST.getJLSLatest()); parser.setKind(ASTParser.K_COMPILATION_UNIT); @@ -483,4 +502,4 @@ class AstTransitionParserTest { } return null; } -} \ No newline at end of file +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregatorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregatorTest.java new file mode 100644 index 0000000..df37bc6 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/ast/app/StateMachineAggregatorTest.java @@ -0,0 +1,282 @@ +package click.kamil.springstatemachineexporter.ast.app; + +import click.kamil.springstatemachineexporter.ast.app.domain.Transition; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class StateMachineAggregatorTest { + + @TempDir + Path tempDir; + + private CodebaseContext context; + private StateMachineAggregator aggregator; + + @BeforeEach + void setUp() { + context = new CodebaseContext(); + aggregator = new StateMachineAggregator(context); + } + + @Test + void shouldAggregateTransitionsFromInheritance() throws IOException { + String baseSource = """ + package com.example; + public abstract class BaseConfig extends StateMachineConfigurerAdapter { + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + transitions.withExternal().source("BASE_S1").target("BASE_S2").event("BASE_E1"); + } + } + """; + String childSource = """ + package com.example; + public class ChildConfig extends BaseConfig { + @Override + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + super.configure(transitions); + transitions.withExternal().source("CHILD_S1").target("CHILD_S2").event("CHILD_E1"); + } + } + """; + + writeClass("BaseConfig", baseSource); + writeClass("ChildConfig", childSource); + context.scan(tempDir); + + TypeDeclaration childTd = context.getTypeDeclaration("ChildConfig"); + List transitions = aggregator.aggregateTransitions(childTd); + + assertThat(transitions).hasSize(2); + assertThat(transitions).anyMatch(t -> t.getEvent().equals("BASE_E1")); + assertThat(transitions).anyMatch(t -> t.getEvent().equals("CHILD_E1")); + } + + @Test + void shouldFollowMethodCallsAcrossHierarchy() throws IOException { + String baseSource = """ + package com.example; + public abstract class BaseConfig extends StateMachineConfigurerAdapter { + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + addBaseTransitions(transitions); + } + protected void addBaseTransitions(StateMachineTransitionConfigurer transitions) { + transitions.withExternal().source("BASE_S1").target("BASE_S2").event("BASE_E1"); + } + } + """; + String childSource = """ + package com.example; + public class ChildConfig extends BaseConfig { + @Override + protected void addBaseTransitions(StateMachineTransitionConfigurer transitions) { + transitions.withExternal().source("OVERRIDDEN_S1").target("OVERRIDDEN_S2").event("OVERRIDDEN_E1"); + } + } + """; + + writeClass("BaseConfig", baseSource); + writeClass("ChildConfig", childSource); + context.scan(tempDir); + + TypeDeclaration childTd = context.getTypeDeclaration("ChildConfig"); + List transitions = aggregator.aggregateTransitions(childTd); + + assertThat(transitions).hasSize(1); + assertThat(transitions.get(0).getEvent()).isEqualTo("OVERRIDDEN_E1"); + } + + @Test + void shouldResolveParametersInMethodCalls() throws IOException { + String source = """ + package com.example; + public class ParamConfig extends StateMachineConfigurerAdapter { + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + addCustomTransition(transitions, "START", "END", "GO"); + } + private void addCustomTransition(StateMachineTransitionConfigurer t, String src, String tgt, String evt) { + t.withExternal().source(src).target(tgt).event(evt); + } + } + """; + + writeClass("ParamConfig", source); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("ParamConfig"); + List transitions = aggregator.aggregateTransitions(td); + + assertThat(transitions).hasSize(1); + assertThat(transitions.get(0).getSourceStates()).containsExactly("START"); + assertThat(transitions.get(0).getTargetStates()).containsExactly("END"); + assertThat(transitions.get(0).getEvent()).isEqualTo("GO"); + } + + @Test + void shouldParseStreamOfForEachTransitions() throws IOException { + String source = """ + package com.example; + import java.util.stream.Stream; + public class StreamConfig extends StateMachineConfigurerAdapter { + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + Stream.of("E1", "E2").forEach(e -> { + transitions.withExternal().source("S1").target("S2").event(e); + }); + } + } + """; + + writeClass("StreamConfig", source); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("StreamConfig"); + List transitions = aggregator.aggregateTransitions(td); + + assertThat(transitions).hasSize(2); + assertThat(transitions).anyMatch(t -> t.getEvent().equals("E1")); + assertThat(transitions).anyMatch(t -> t.getEvent().equals("E2")); + } + + @Test + void shouldAggregateStatesAcrossInheritance() throws IOException { + String baseSource = """ + package com.example; + public abstract class BaseConfig extends StateMachineConfigurerAdapter { + public void configure(StateMachineStateConfigurer states) throws Exception { + states.withStates().initial("BASE_START"); + } + } + """; + String childSource = """ + package com.example; + public class ChildConfig extends BaseConfig { + public void configure(StateMachineStateConfigurer states) throws Exception { + super.configure(states); + states.withStates().state("CHILD_STATE").end("CHILD_END"); + } + } + """; + + writeClass("BaseConfig", baseSource); + writeClass("ChildConfig", childSource); + context.scan(tempDir); + + TypeDeclaration childTd = context.getTypeDeclaration("ChildConfig"); + Set initialStates = new HashSet<>(); + Set endStates = new HashSet<>(); + aggregator.aggregateStates(childTd, initialStates, endStates); + + assertThat(initialStates).containsExactly("BASE_START"); + assertThat(endStates).containsExactly("CHILD_END"); + } + + @Test + void shouldParseStreamOfForEachStates() throws IOException { + String source = """ + package com.example; + import java.util.stream.Stream; + public class StreamStateConfig extends StateMachineConfigurerAdapter { + public void configure(StateMachineStateConfigurer states) throws Exception { + Stream.of("S1", "S2").forEach(s -> { + states.withStates().initial(s); + }); + } + } + """; + + writeClass("StreamStateConfig", source); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("StreamStateConfig"); + Set initialStates = new HashSet<>(); + Set endStates = new HashSet<>(); + aggregator.aggregateStates(td, initialStates, endStates); + + assertThat(initialStates).containsExactlyInAnyOrder("S1", "S2"); + } + + @Test + void shouldAggregateTransitionsFromDeepInheritance() throws IOException { + String grandparentSource = """ + package com.example; + public abstract class GrandparentConfig extends StateMachineConfigurerAdapter { + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + transitions.withExternal().source("GP_S1").target("GP_S2").event("GP_E1"); + } + } + """; + String parentSource = """ + package com.example; + public abstract class ParentConfig extends GrandparentConfig { + @Override + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + super.configure(transitions); + transitions.withExternal().source("P_S1").target("P_S2").event("P_E1"); + } + } + """; + String childSource = """ + package com.example; + public class ChildConfig extends ParentConfig { + @Override + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + super.configure(transitions); + transitions.withExternal().source("C_S1").target("C_S2").event("C_E1"); + } + } + """; + + writeClass("GrandparentConfig", grandparentSource); + writeClass("ParentConfig", parentSource); + writeClass("ChildConfig", childSource); + context.scan(tempDir); + + TypeDeclaration childTd = context.getTypeDeclaration("ChildConfig"); + List transitions = aggregator.aggregateTransitions(childTd); + + assertThat(transitions).hasSize(3); + assertThat(transitions).anyMatch(t -> t.getEvent().equals("GP_E1")); + assertThat(transitions).anyMatch(t -> t.getEvent().equals("P_E1")); + assertThat(transitions).anyMatch(t -> t.getEvent().equals("C_E1")); + } + + @Test + void shouldParseEnhancedForLoopTransitions() throws IOException { + String source = """ + package com.example; + import java.util.List; + public class LoopConfig extends StateMachineConfigurerAdapter { + public void configure(StateMachineTransitionConfigurer transitions) throws Exception { + for (String e : List.of("E1", "E2")) { + transitions.withExternal().source("S1").target("S2").event(e); + } + } + } + """; + + writeClass("LoopConfig", source); + context.scan(tempDir); + + TypeDeclaration td = context.getTypeDeclaration("LoopConfig"); + List transitions = aggregator.aggregateTransitions(td); + + assertThat(transitions).hasSize(2); + assertThat(transitions).anyMatch(t -> t.getEvent().equals("E1")); + assertThat(transitions).anyMatch(t -> t.getEvent().equals("E2")); + } + + private void writeClass(String name, String source) throws IOException { + Files.writeString(tempDir.resolve(name + ".java"), source); + } +} diff --git a/state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/AbstractOneStateMachineConfiguration.java b/state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/AbstractTwoStateMachineConfiguration.java similarity index 99% rename from state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/AbstractOneStateMachineConfiguration.java rename to state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/AbstractTwoStateMachineConfiguration.java index 9e68f5f..d21becb 100644 --- a/state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/AbstractOneStateMachineConfiguration.java +++ b/state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/AbstractTwoStateMachineConfiguration.java @@ -8,7 +8,7 @@ import org.springframework.statemachine.config.configurers.ExternalTransitionCon import org.springframework.statemachine.config.configurers.StateConfigurer; import org.springframework.statemachine.guard.Guard; -abstract class AbstractOneStateMachineConfiguration extends AbstractStateMachineConfiguration { +abstract class AbstractTwoStateMachineConfiguration extends AbstractStateMachineConfiguration { protected abstract void addAdditionalStates(StateConfigurer states); diff --git a/state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/OneStateMachineConfiguration.java b/state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/TwoStateMachineConfiguration.java similarity index 94% rename from state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/OneStateMachineConfiguration.java rename to state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/TwoStateMachineConfiguration.java index b7a36bc..5f96e1f 100644 --- a/state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/OneStateMachineConfiguration.java +++ b/state_machines/inheritance_extra_functions_state_machine/src/main/java/click/kamil/examples/statemachine/inheritanceextrafunctionsstate/TwoStateMachineConfiguration.java @@ -4,7 +4,7 @@ import org.springframework.statemachine.config.builders.StateMachineTransitionCo import org.springframework.statemachine.config.configurers.ExternalTransitionConfigurer; import org.springframework.statemachine.config.configurers.StateConfigurer; -class OneStateMachineConfiguration extends AbstractOneStateMachineConfiguration { +class TwoStateMachineConfiguration extends AbstractTwoStateMachineConfiguration { @Override protected void addAdditionalStates(StateConfigurer states) { states.state(States.STATE_EXTRA_1);