From d6754b7464f2e1b33eeb9e239045f26d0a3b74b9 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Wed, 15 Jul 2026 08:40:02 +0200 Subject: [PATCH] Unify getter and path-binding resolution through anchored dataflow. Route chained getters, path bindings, and setter-before-getter reads through JdtDataFlowModel instead of parallel mini-interpreters, with regression tests covering interface hops, anonymous classes, and anchored vs detached AST resolution. Co-authored-by: Cursor --- .../pipeline/LibraryUnwrapRegistry.java | 12 + .../pipeline/ReactiveExpressionSupport.java | 110 +++-- .../analysis/resolver/ConstantResolver.java | 15 +- .../service/AbstractCallGraphEngine.java | 117 +++-- .../analysis/service/CallSiteMatcher.java | 225 ++++++++++ .../analysis/service/ConstantExtractor.java | 70 ++- .../service/FunctionalInterfaceTypes.java | 33 +- .../service/GenericEventDetector.java | 67 ++- .../analysis/service/JdtCallGraphEngine.java | 19 +- .../service/PathBindingEvaluator.java | 198 ++------- .../PathBindingExpressionResolver.java | 221 ++++++++++ .../analysis/service/VariableTracer.java | 201 +++++---- .../ast/common/AstUtils.java | 153 ++++++- .../ast/common/CodebaseContext.java | 26 ++ .../ast/common/JdtDataFlowModel.java | 412 ++++++++++++++++-- .../pipeline/PipelineRefactorTest.java | 2 + .../resolver/ConstantResolverTest.java | 5 +- .../analysis/service/CallSiteMatcherTest.java | 100 +++++ .../DataFlowGetterEnablesMoreTest.java | 277 ++++++++++++ ...tionalInterfaceDataFlowRegressionTest.java | 167 ++++++- .../service/FunctionalInterfaceTypesTest.java | 18 + .../PathBindingExpressionResolverTest.java | 71 +++ 22 files changed, 2011 insertions(+), 508 deletions(-) create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteMatcher.java create mode 100644 state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolver.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteMatcherTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DataFlowGetterEnablesMoreTest.java create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolverTest.java diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java index dddbbad..5f9c2cd 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/LibraryUnwrapRegistry.java @@ -14,6 +14,10 @@ public final class LibraryUnwrapRegistry { private static final Set UNWRAP_METHOD_NAMES = Set.of( "of", "ofEntries", "asList", "entry", "just", "withPayload", "success"); + private static final Set REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success"); + + private static final Set REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap"); + private static final String[] STOP_UNWRAP_PREFIXES = { "map", "parse", "convert", "to", "resolve", "build", "create", "from", "transform", "translate", "derive", "determine", "calculate", "decode", @@ -31,6 +35,14 @@ public final class LibraryUnwrapRegistry { return methodName != null && UNWRAP_METHOD_NAMES.contains(methodName); } + public static boolean isReactiveFactoryMethod(String methodName) { + return methodName != null && REACTIVE_FACTORY_METHODS.contains(methodName); + } + + public static boolean isReactiveTransformMethod(String methodName) { + return methodName != null && REACTIVE_TRANSFORM_METHODS.contains(methodName); + } + public static boolean shouldStopUnwrap(String methodName) { if (methodName == null) { return true; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupport.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupport.java index fa1a96e..8ef7ef5 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupport.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/pipeline/ReactiveExpressionSupport.java @@ -1,6 +1,7 @@ package click.kamil.springstatemachineexporter.analysis.pipeline; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.ast.common.AstUtils; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import org.eclipse.jdt.core.dom.Block; import org.eclipse.jdt.core.dom.Expression; @@ -10,16 +11,11 @@ import org.eclipse.jdt.core.dom.ReturnStatement; import org.eclipse.jdt.core.dom.SimpleName; import org.eclipse.jdt.core.dom.ASTNode; -import java.util.Set; - /** * Extracts terminal payload literals from reactive factory chains ({@code Mono.just}, nested transforms). */ public final class ReactiveExpressionSupport { - private static final Set REACTIVE_FACTORY_METHODS = Set.of("just", "withPayload", "success"); - private static final Set REACTIVE_TRANSFORM_METHODS = Set.of("map", "flatMap", "switchMap", "concatMap"); - private ReactiveExpressionSupport() { } @@ -27,6 +23,45 @@ public final class ReactiveExpressionSupport { return extractPayload(expression, null, constantResolver, context); } + /** + * Peels {@code just}/{@code withPayload}/{@code success} wrappers on an invocation chain and returns + * the payload {@link Expression} for dataflow analysis. + */ + public static Expression peelFactoryPayloadExpression(Expression expression) { + if (!(expression instanceof MethodInvocation mi)) { + return null; + } + MethodInvocation current = mi; + while (current != null) { + String methodName = current.getName().getIdentifier(); + if (LibraryUnwrapRegistry.isReactiveFactoryMethod(methodName) && !current.arguments().isEmpty()) { + return MethodInvocationUnwrapper.peelExpression((Expression) current.arguments().get(0)); + } + Expression receiver = current.getExpression(); + if (receiver instanceof MethodInvocation nextMi) { + current = nextMi; + } else { + break; + } + } + return null; + } + + public static Expression lambdaBodyExpression(LambdaExpression lambda) { + if (lambda.getBody() instanceof Expression bodyExpression) { + return bodyExpression; + } + if (lambda.getBody() instanceof Block block) { + for (Object statement : block.statements()) { + if (statement instanceof ReturnStatement returnStatement + && returnStatement.getExpression() != null) { + return returnStatement.getExpression(); + } + } + } + return null; + } + /** * Rewrites {@code p.getEvent()} inside a reactive transform lambda to {@code payload.getEvent()} * when {@code p} is fed by {@code Mono.just(payload)} (or a chained reactive source). @@ -70,7 +105,7 @@ public final class ReactiveExpressionSupport { String methodFqn, ConstantResolver constantResolver, CodebaseContext context) { - MethodInvocation getter = findMethodInvocationInMethod(methodFqn, expressionText, context); + MethodInvocation getter = AstUtils.findMethodInvocationInMethod(methodFqn, expressionText, context); if (getter == null) { return null; } @@ -81,37 +116,6 @@ public final class ReactiveExpressionSupport { return null; } - private static MethodInvocation findMethodInvocationInMethod( - String methodFqn, - String expressionText, - CodebaseContext context) { - if (methodFqn == null || expressionText == null || !methodFqn.contains(".")) { - return null; - } - String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); - String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); - org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration(className); - if (td == null) { - return null; - } - org.eclipse.jdt.core.dom.MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); - if (md == null || md.getBody() == null) { - return null; - } - final MethodInvocation[] match = new MethodInvocation[1]; - md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() { - @Override - public boolean visit(MethodInvocation node) { - if (expressionText.equals(node.toString())) { - match[0] = node; - return false; - } - return super.visit(node); - } - }); - return match[0]; - } - private static LambdaExpression findEnclosingLambda(ASTNode node) { ASTNode current = node.getParent(); while (current != null) { @@ -127,7 +131,7 @@ public final class ReactiveExpressionSupport { ASTNode current = node.getParent(); while (current != null) { if (current instanceof MethodInvocation mi - && REACTIVE_TRANSFORM_METHODS.contains(mi.getName().getIdentifier())) { + && LibraryUnwrapRegistry.isReactiveTransformMethod(mi.getName().getIdentifier())) { return mi; } current = current.getParent(); @@ -145,14 +149,14 @@ public final class ReactiveExpressionSupport { } if (expression instanceof MethodInvocation mi) { String methodName = mi.getName().getIdentifier(); - if (REACTIVE_TRANSFORM_METHODS.contains(methodName) && !mi.arguments().isEmpty()) { + if (LibraryUnwrapRegistry.isReactiveTransformMethod(methodName) && !mi.arguments().isEmpty()) { String fromArgument = extractTransformArgumentPayload( (Expression) mi.arguments().get(0), mi.getExpression(), constantResolver, context); if (fromArgument != null) { return fromArgument; } } - if (REACTIVE_FACTORY_METHODS.contains(methodName) && !mi.arguments().isEmpty()) { + if (LibraryUnwrapRegistry.isReactiveFactoryMethod(methodName) && !mi.arguments().isEmpty()) { Expression arg = (Expression) mi.arguments().get(0); if (constantResolver != null && context != null) { String resolved = constantResolver.resolve(arg, context); @@ -197,7 +201,7 @@ public final class ReactiveExpressionSupport { if (!(bodyExpression instanceof MethodInvocation factoryCall)) { return null; } - if (!REACTIVE_FACTORY_METHODS.contains(factoryCall.getName().getIdentifier()) + if (!LibraryUnwrapRegistry.isReactiveFactoryMethod(factoryCall.getName().getIdentifier()) || factoryCall.arguments().isEmpty()) { return null; } @@ -237,7 +241,7 @@ public final class ReactiveExpressionSupport { return null; } if (transformReceiver instanceof MethodInvocation receiverTransform - && REACTIVE_TRANSFORM_METHODS.contains(receiverTransform.getName().getIdentifier()) + && LibraryUnwrapRegistry.isReactiveTransformMethod(receiverTransform.getName().getIdentifier()) && !receiverTransform.arguments().isEmpty() && receiverTransform.arguments().get(0) instanceof LambdaExpression feederLambda && feederLambda != lambda) { @@ -287,29 +291,13 @@ public final class ReactiveExpressionSupport { } private static Expression peelJustArgument(Expression expression) { - if (expression instanceof MethodInvocation mi - && REACTIVE_FACTORY_METHODS.contains(mi.getName().getIdentifier()) - && !mi.arguments().isEmpty()) { - return (Expression) mi.arguments().get(0); + Expression peeled = peelFactoryPayloadExpression(expression); + if (peeled != null) { + return peeled; } if (expression instanceof MethodInvocation mi && mi.getExpression() != null) { return peelJustArgument(mi.getExpression()); } return null; } - - private static Expression lambdaBodyExpression(LambdaExpression lambda) { - if (lambda.getBody() instanceof Expression bodyExpression) { - return bodyExpression; - } - if (lambda.getBody() instanceof Block block) { - for (Object statement : block.statements()) { - if (statement instanceof ReturnStatement returnStatement - && returnStatement.getExpression() != null) { - return returnStatement.getExpression(); - } - } - } - return null; - } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java index 3b25eab..258ea3f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolver.java @@ -1,7 +1,6 @@ package click.kamil.springstatemachineexporter.analysis.resolver; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; -import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel; import lombok.extern.slf4j.Slf4j; import org.eclipse.jdt.core.dom.*; @@ -850,7 +849,7 @@ public class ConstantResolver { } } - for (String classFqn : classesFromCurrentCallPath()) { + for (String classFqn : classesFromCurrentCallPath(context)) { TypeDeclaration pathTd = context.getTypeDeclaration(classFqn); if (pathTd != null) { String result = resolveFieldInType(pathTd, sn.getIdentifier(), classFqn, context, visited); @@ -922,7 +921,7 @@ public class ConstantResolver { if (td != null) { return td; } - String entryClass = getCurrentCallPathEntryClass(); + String entryClass = getCurrentCallPathEntryClass(context); if (entryClass == null) { return null; } @@ -934,15 +933,15 @@ public class ConstantResolver { } private TypeDeclaration typeFromCurrentCallPath(CodebaseContext context) { - List classes = classesFromCurrentCallPath(); + List classes = classesFromCurrentCallPath(context); if (classes.isEmpty()) { return null; } return context.getTypeDeclaration(classes.get(0)); } - private List classesFromCurrentCallPath() { - List path = JdtDataFlowModel.getCurrentPath(); + private List classesFromCurrentCallPath(CodebaseContext context) { + List path = context.getAnalysisCallPath(); if (path == null || path.isEmpty()) { return List.of(); } @@ -960,8 +959,8 @@ public class ConstantResolver { return classes; } - private String getCurrentCallPathEntryClass() { - List classes = classesFromCurrentCallPath(); + private String getCurrentCallPathEntryClass(CodebaseContext context) { + List classes = classesFromCurrentCallPath(context); return classes.isEmpty() ? null : classes.get(classes.size() - 1); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 3aa2ee4..276b69b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -37,36 +37,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { protected Map> graph; private ASTNode parseExpressionString(String expr) { - if (expr == null) return null; - return parsedNodeCache.computeIfAbsent(expr, e -> { - ASTParser parser = ASTParser.newParser(AST.JLS17); - Map options = org.eclipse.jdt.core.JavaCore.getOptions(); - org.eclipse.jdt.core.JavaCore.setComplianceOptions(org.eclipse.jdt.core.JavaCore.VERSION_17, options); - parser.setCompilerOptions(options); - parser.setKind(ASTParser.K_EXPRESSION); - parser.setSource(e.toCharArray()); - try { - ASTNode node = parser.createAST(null); - if (node instanceof org.eclipse.jdt.core.dom.CompilationUnit) { - ASTParser fallbackParser = ASTParser.newParser(AST.JLS17); - fallbackParser.setCompilerOptions(options); - fallbackParser.setKind(ASTParser.K_COMPILATION_UNIT); - fallbackParser.setSource(("class A { Object o = " + e + "; }").toCharArray()); - org.eclipse.jdt.core.dom.CompilationUnit cu = (org.eclipse.jdt.core.dom.CompilationUnit) fallbackParser.createAST(null); - if (!cu.types().isEmpty()) { - org.eclipse.jdt.core.dom.TypeDeclaration td = (org.eclipse.jdt.core.dom.TypeDeclaration) cu.types().get(0); - if (!td.bodyDeclarations().isEmpty() && td.bodyDeclarations().get(0) instanceof org.eclipse.jdt.core.dom.FieldDeclaration fd) { - if (!fd.fragments().isEmpty() && fd.fragments().get(0) instanceof org.eclipse.jdt.core.dom.VariableDeclarationFragment vdf) { - return vdf.getInitializer(); - } - } - } - } - return node; - } catch (Exception ex) { - return null; - } - }); + if (expr == null) { + return null; + } + return parsedNodeCache.computeIfAbsent(expr, click.kamil.springstatemachineexporter.ast.common.AstUtils::parseExpression); } @FunctionalInterface @@ -258,7 +232,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { Map> callGraph, String[] finalParamNameRef, Map initialBindings) { - click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(path); + click.kamil.springstatemachineexporter.ast.common.CodebaseContext contextRef = context; + contextRef.setAnalysisCallPath(path); final boolean debug = log.isDebugEnabled(); try { String event = tp.getEvent(); @@ -1171,7 +1146,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return tp; } finally { - click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath(); + context.clearAnalysisCallPath(); } } @@ -1207,6 +1182,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } } } + if (expr instanceof LambdaExpression) { + String lambdaText = expr.toString(); + String val = constantResolver.resolve(expr, context); + return val != null ? val : lambdaText; + } } if (expr instanceof ExpressionMethodReference emr) { @@ -1854,6 +1834,47 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } protected List evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List path, Map> callGraph) { + String expressionText = click.kamil.springstatemachineexporter.ast.common.AstUtils.combineExpressionText( + resolvedValue, methodSuffix); + List searchMethods = buildGetterSearchMethods(resolvedValue, entryMethod, path); + + List constants = resolveGetterConstantsViaDataflow(expressionText, searchMethods, path, callGraph, entryMethod); + if (!constants.isEmpty()) { + return constants; + } + + for (String scopeMethod : searchMethods) { + String remapped = ReactiveExpressionSupport.remapLambdaParameterGetterInMethod( + expressionText, scopeMethod, constantResolver, context); + if (remapped != null && !remapped.equals(expressionText)) { + constants = resolveGetterConstantsViaDataflow(remapped, searchMethods, path, callGraph, entryMethod); + if (!constants.isEmpty()) { + return constants; + } + } + } + return resolveGetterConstantsViaAccessorPipeline(resolvedValue, methodSuffix, entryMethod, path); + } + + private List resolveGetterConstantsViaDataflow( + String expressionText, + List searchMethods, + List path, + Map> callGraph, + String entryMethod) { + Expression anchored = click.kamil.springstatemachineexporter.ast.common.AstUtils.findExpressionInMethods( + searchMethods, expressionText, context); + if (anchored != null) { + List constants = new ArrayList<>(); + addConstantsFromTracedExpression(anchored, constants, path, callGraph, entryMethod); + if (!constants.isEmpty()) { + return constants.stream().distinct().toList(); + } + } + return variableTracer.resolveConstantsFromSourceText(expressionText, searchMethods, path); + } + + private List resolveGetterConstantsViaAccessorPipeline(String resolvedValue, String methodSuffix, String entryMethod, List path) { ASTNode exprNode = parseExpressionString(resolvedValue); while (exprNode instanceof ParenthesizedExpression pe) { exprNode = pe.getExpression(); @@ -1954,7 +1975,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { mi.toString(), scopeMethod, constantResolver, context); } if (remapped != null) { - return evaluateGetterOnReceiver(remapped, "", entryMethod, path, callGraph); + return evaluateGetterOnReceiver(remapped, "", entryMethod, path, null); } } @@ -2057,6 +2078,35 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return null; } + private List buildGetterSearchMethods(String resolvedValue, String entryMethod, List path) { + java.util.LinkedHashSet methods = new java.util.LinkedHashSet<>(); + methods.add(resolveScopeMethodForResolvedValue(resolvedValue, entryMethod, path)); + if (path != null) { + methods.addAll(path); + } + if (entryMethod != null) { + methods.add(entryMethod); + } + return List.copyOf(methods); + } + + private String resolveScopeMethodForResolvedValue(String resolvedValue, String entryMethod, List path) { + ASTNode node = parseExpressionString(resolvedValue); + while (node instanceof ParenthesizedExpression pe) { + node = pe.getExpression(); + } + if (node instanceof Expression expression) { + Expression root = expression; + while (root instanceof MethodInvocation mi) { + root = mi.getExpression(); + } + if (root instanceof SimpleName sn) { + return resolveScopeMethodForVariable(sn.getIdentifier(), entryMethod, path); + } + } + return entryMethod; + } + protected Expression findVariableInitializer(String methodFqn, String varName) { int lastDot = methodFqn.lastIndexOf('.'); if (lastDot < 0) return null; @@ -3180,6 +3230,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (tp.isExternal() && hasConcrete) { narrowed.removeIf(pe -> pe != null && pe.startsWith(" pe != null && (pe.equals("*>") || pe.equals(".*>"))); return narrowed; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteMatcher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteMatcher.java new file mode 100644 index 0000000..5d4a385 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteMatcher.java @@ -0,0 +1,225 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.IMethodBinding; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.TypeDeclaration; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Matches {@link CallEdge}s to concrete {@link MethodInvocation}s in caller source when + * multiple call sites target the same method. + */ +public final class CallSiteMatcher { + + private static final Pattern ENUM_LITERAL = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*\\.[A-Z_][A-Z0-9_]*"); + + private CallSiteMatcher() { + } + + public static CallEdge selectEdge( + String caller, + String target, + List edges, + Map bindings) { + if (edges == null || edges.isEmpty()) { + return null; + } + List matching = new ArrayList<>(); + for (CallEdge edge : edges) { + if (edge.getTargetMethod().equals(target) || heuristicTargetMatch(edge.getTargetMethod(), target)) { + matching.add(edge); + } + } + if (matching.isEmpty()) { + return null; + } + if (matching.size() == 1) { + return matching.get(0); + } + + List constraintFiltered = matching; + if (bindings != null && !bindings.isEmpty()) { + List compatible = new ArrayList<>(); + for (CallEdge edge : matching) { + String constraint = edge.getConstraint(); + if (constraint == null + || constraint.isBlank() + || BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, bindings)) { + compatible.add(edge); + } + } + if (!compatible.isEmpty()) { + constraintFiltered = compatible; + } + } + if (constraintFiltered.size() == 1) { + return constraintFiltered.get(0); + } + + if (bindings != null && !bindings.isEmpty()) { + for (CallEdge edge : constraintFiltered) { + if (edge.getArguments() == null) { + continue; + } + for (String edgeArg : edge.getArguments()) { + for (String bindingValue : bindings.values()) { + if (edgeArg == null || bindingValue == null) { + continue; + } + if (edgeArg.contains(bindingValue) || bindingValue.contains(edgeArg)) { + return edge; + } + } + } + } + } + + for (CallEdge edge : constraintFiltered) { + if (findMatchingInvocation(caller, target, edge, null) != null) { + return edge; + } + } + return constraintFiltered.get(0); + } + + public static MethodInvocation findMatchingInvocation( + String caller, + String callee, + CallEdge edge, + CodebaseContext context) { + List invocations = findCallInvocations(caller, callee, context); + if (invocations.isEmpty()) { + return null; + } + if (edge == null || invocations.size() == 1) { + return invocations.get(0); + } + for (MethodInvocation invocation : invocations) { + if (invocationMatchesEdge(invocation, edge)) { + return invocation; + } + } + return null; + } + + public static Expression findCallSiteArgumentExpression( + String caller, + String callee, + int paramIndex, + CallEdge edge, + CodebaseContext context) { + MethodInvocation invocation = findMatchingInvocation(caller, callee, edge, context); + if (invocation == null) { + return null; + } + if (paramIndex < 0 || paramIndex >= invocation.arguments().size()) { + return null; + } + return (Expression) invocation.arguments().get(paramIndex); + } + + static List findCallInvocations(String caller, String callee, CodebaseContext context) { + if (caller == null || callee == null || !caller.contains(".") || !callee.contains(".") || context == null) { + return List.of(); + } + String callerClass = caller.substring(0, caller.lastIndexOf('.')); + String callerMethod = caller.substring(caller.lastIndexOf('.') + 1); + String calleeMethod = callee.substring(callee.lastIndexOf('.') + 1); + String calleeClass = callee.substring(0, callee.lastIndexOf('.')); + TypeDeclaration callerType = context.getTypeDeclaration(callerClass); + if (callerType == null) { + return List.of(); + } + MethodDeclaration method = context.findMethodDeclaration(callerType, callerMethod, true); + if (method == null || method.getBody() == null) { + return List.of(); + } + + List invocations = new ArrayList<>(); + method.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (!calleeMethod.equals(node.getName().getIdentifier())) { + return super.visit(node); + } + IMethodBinding binding = node.resolveMethodBinding(); + if (binding != null && binding.getDeclaringClass() != null) { + String declaringFqn = binding.getDeclaringClass().getErasure().getQualifiedName(); + if (!declaringFqn.equals(calleeClass) && !declaringFqn.endsWith("." + calleeClass)) { + String calleeSimple = calleeClass.substring(calleeClass.lastIndexOf('.') + 1); + if (!declaringFqn.endsWith("." + calleeSimple)) { + return super.visit(node); + } + } + } + invocations.add(node); + return super.visit(node); + } + }); + return invocations; + } + + static boolean invocationMatchesEdge(MethodInvocation invocation, CallEdge edge) { + if (edge == null || edge.getArguments() == null) { + return true; + } + List astArgs = invocation.arguments(); + List edgeArgs = edge.getArguments(); + int count = Math.min(astArgs.size(), edgeArgs.size()); + if (count == 0) { + return edgeArgs.isEmpty(); + } + int matched = 0; + for (int i = 0; i < count; i++) { + if (argumentTokensMatch(((Expression) astArgs.get(i)).toString(), edgeArgs.get(i))) { + matched++; + } + } + return matched == count; + } + + static boolean argumentTokensMatch(String astArg, String edgeArg) { + if (astArg == null || edgeArg == null) { + return false; + } + String left = normalizeArgument(astArg); + String right = normalizeArgument(edgeArg); + if (left.equals(right)) { + return true; + } + String leftEnum = extractEnumLiteral(left); + String rightEnum = extractEnumLiteral(right); + return leftEnum != null && leftEnum.equals(rightEnum); + } + + private static String normalizeArgument(String value) { + return value.replaceAll("\\s+", ""); + } + + private static String extractEnumLiteral(String value) { + var matcher = ENUM_LITERAL.matcher(value); + return matcher.find() ? matcher.group() : null; + } + + private static boolean heuristicTargetMatch(String edgeTarget, String pathTarget) { + if (edgeTarget == null || pathTarget == null) { + return false; + } + if (edgeTarget.equals(pathTarget)) { + return true; + } + String edgeSimple = edgeTarget.substring(edgeTarget.lastIndexOf('.') + 1); + String pathSimple = pathTarget.substring(pathTarget.lastIndexOf('.') + 1); + return edgeSimple.equals(pathSimple); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java index 610d136..dead27d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ConstantExtractor.java @@ -78,6 +78,18 @@ public class ConstantExtractor { extractConstantsFromArgument((Expression) expObj, constants); } } else if (expr instanceof MethodInvocation mi) { + if (variableTracer != null) { + int sizeBefore = constants.size(); + for (Expression def : variableTracer.traceVariableAll(mi)) { + if (def != null && !def.toString().equals(mi.toString())) { + extractConstantsFromExpression(def, constants); + } + } + if (constants.size() > sizeBefore) { + return; + } + } + String methodName = mi.getName().getIdentifier(); if (log.isTraceEnabled()) { @@ -99,42 +111,20 @@ public class ConstantExtractor { } return; } - - if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event")) && mi.getExpression() instanceof SimpleName sn) { - String varName = sn.getIdentifier(); - String propName = methodName.startsWith("get") ? methodName.substring(3) : methodName; - - Block block = findEnclosingBlock(mi); - if (block != null) { - for (Object stmtObj : block.statements()) { - if (stmtObj == mi.getParent() || stmtObj == mi) break; - if (stmtObj instanceof ExpressionStatement es) { - if (es.getExpression() instanceof MethodInvocation setterMi) { - if (setterMi.getName().getIdentifier().equalsIgnoreCase("set" + propName) || setterMi.getName().getIdentifier().equalsIgnoreCase(propName)) { - if (setterMi.getExpression() instanceof SimpleName setterSn && setterSn.getIdentifier().equals(varName)) { - if (!setterMi.arguments().isEmpty()) { - extractConstantsFromExpression((Expression) setterMi.arguments().get(0), constants); - return; - } - } - } - } else if (es.getExpression() instanceof Assignment assignment) { - if (assignment.getLeftHandSide() instanceof FieldAccess fa) { - if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) { - if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) { - extractConstantsFromExpression(assignment.getRightHandSide(), constants); - return; - } - } - } else if (assignment.getLeftHandSide() instanceof QualifiedName qqn) { - if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) { - if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) { - extractConstantsFromExpression(assignment.getRightHandSide(), constants); - return; - } - } - } - } + + if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event")) + && mi.getExpression() instanceof SimpleName sn + && variableTracer != null) { + org.eclipse.jdt.core.dom.MethodDeclaration md = + click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingMethod(mi); + TypeDeclaration td = findEnclosingType(mi); + if (md != null && td != null) { + String methodFqn = context.getFqn(td) + "." + md.getName().getIdentifier(); + Expression setterArg = variableTracer.traceLocalSetter(methodFqn, sn.getIdentifier(), methodName); + if (setterArg != null) { + extractConstantsFromExpression(setterArg, constants); + if (!constants.isEmpty()) { + return; } } } @@ -678,14 +668,6 @@ public class ConstantExtractor { } } - private Block findEnclosingBlock(ASTNode node) { - ASTNode parent = node.getParent(); - while (parent != null && !(parent instanceof Block)) { - parent = parent.getParent(); - } - return (Block) parent; - } - private TypeDeclaration findEnclosingType(ASTNode node) { return click.kamil.springstatemachineexporter.ast.common.AstUtils.findEnclosingType(node); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypes.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypes.java index 3d886e9..c3a68e8 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypes.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypes.java @@ -1,14 +1,38 @@ package click.kamil.springstatemachineexporter.analysis.service; +import java.util.Set; + /** * Recognizes JDK functional interface parameters used as delayed event providers - * ({@code Supplier.get()}, etc.) in call-graph argument tracing. + * ({@code Supplier.get()}, {@code Runnable.run()}, {@code Consumer.accept()}, etc.) + * in call-graph argument tracing. */ public final class FunctionalInterfaceTypes { + private static final Set SAM_METHOD_NAMES = Set.of( + "run", "get", "call", "accept", "apply", "test"); + private FunctionalInterfaceTypes() { } + public static boolean isSamMethodName(String methodName) { + return methodName != null && SAM_METHOD_NAMES.contains(methodName); + } + + /** + * Whether {@code typeName.methodName} is a single-abstract-method invocation on a functional interface + * (e.g. {@code java.lang.Runnable.run}, {@code java.util.function.Consumer.accept}). + */ + public static boolean isFunctionalSamMethod(String methodFqn) { + if (methodFqn == null || !methodFqn.contains(".")) { + return false; + } + int lastDot = methodFqn.lastIndexOf('.'); + String methodName = methodFqn.substring(lastDot + 1); + String typeName = methodFqn.substring(0, lastDot); + return isSamMethodName(methodName) && isFunctionalInterface(typeName); + } + public static boolean isFunctionalInterface(String typeName) { if (typeName == null || typeName.isBlank()) { return false; @@ -17,8 +41,11 @@ public final class FunctionalInterfaceTypes { if (simple.contains("<")) { simple = simple.substring(0, simple.indexOf('<')); } - return "Supplier".equals(simple) || "Function".equals(simple) || "Callable".equals(simple) - || "Runnable".equals(simple); + return switch (simple) { + case "Runnable", "Supplier", "Function", "Callable", "Consumer", "BiConsumer", "Predicate", "BiFunction" -> + true; + default -> simple.contains("Supplier") || simple.contains("Function") || simple.contains("Callable"); + }; } public static boolean isLambdaArgument(String argValue) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index e72905d..fecb93a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -130,16 +130,10 @@ public class GenericEventDetector { return reactivePayload; } if (receiver == null) return null; - if (receiver instanceof MethodInvocation mi) { - String mName = mi.getName().getIdentifier(); - if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) { - Expression arg = (Expression) mi.arguments().get(0); - String resolved = constantResolver.resolve(arg, context); - return resolved != null ? resolved : arg.toString(); - } - if (mi.getExpression() != null) { - return extractEventFromReceiver(mi.getExpression()); - } + Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(receiver); + if (factoryPayload != null) { + String resolved = constantResolver.resolve(factoryPayload, context); + return resolved != null ? resolved : factoryPayload.toString(); } if (receiver instanceof SimpleName sn) { return sn.getIdentifier(); @@ -804,36 +798,33 @@ public class GenericEventDetector { if (!(expr instanceof MethodInvocation mi)) return null; - // Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build() + Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi); + if (factoryPayload != null) { + String extracted = extractEventFromMessageBuilder(factoryPayload); + if (extracted != null) { + return extracted; + } + String resolved = constantResolver.resolve(factoryPayload, context); + if (resolved != null) { + return resolved; + } + Expression payloadExpr = factoryPayload; + if (payloadExpr instanceof CastExpression ce) { + payloadExpr = ce.getExpression(); + } + if (payloadExpr instanceof SimpleName sn) { + String traced = extractEventFromMessageBuilder(payloadExpr); + if (traced != null) { + return traced; + } + return sn.getIdentifier(); + } + return payloadExpr.toString(); + } + MethodInvocation current = mi; while (current != null) { - String name = current.getName().getIdentifier(); - if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) { - Expression payloadExpr = (Expression) current.arguments().get(0); - - // If it's Mono.just(msg), where msg is a variable - if ("just".equals(name)) { - String extracted = extractEventFromMessageBuilder(payloadExpr); - if (extracted != null) return extracted; - } - - String resolved = constantResolver.resolve(payloadExpr, context); - if (resolved != null) return resolved; - - // If not a constant, it might be a parameter like 'event' - if (payloadExpr instanceof CastExpression ce) { - payloadExpr = ce.getExpression(); - } - - if (payloadExpr instanceof SimpleName sn) { - String extracted = extractEventFromMessageBuilder(payloadExpr); - if (extracted != null) { - return extracted; - } - return sn.getIdentifier(); // Fall back to returning the parameter name - } - return payloadExpr.toString(); - } else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) { + if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) { // If the event is obtained by calling a method on a provider/supplier parameter, // return the provider's name so the call-graph engine can trace the lambda argument String traced = extractEventFromMessageBuilder(sn); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java index b162927..ee9aabf 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java @@ -1,5 +1,6 @@ package click.kamil.springstatemachineexporter.analysis.service; +import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import lombok.extern.slf4j.Slf4j; @@ -139,21 +140,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { } private Expression unwrapMessageBuilder(Expression expr) { - if (expr instanceof MethodInvocation mi) { - MethodInvocation current = mi; - while (current != null) { - String name = current.getName().getIdentifier(); - if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) { - return (Expression) current.arguments().get(0); - } - Expression receiver = current.getExpression(); - if (receiver instanceof MethodInvocation nextMi) { - current = nextMi; - } else { - current = null; - } - } - } - return expr; + Expression peeled = ReactiveExpressionSupport.peelFactoryPayloadExpression(expr); + return peeled != null ? peeled : expr; } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java index 925bde4..607fd5f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java @@ -4,7 +4,6 @@ import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; -import org.eclipse.jdt.core.dom.*; import java.util.HashMap; import java.util.List; @@ -21,6 +20,7 @@ public class PathBindingEvaluator { private final VariableTracer variableTracer; private final ConstantResolver constantResolver; private final TypeResolver typeResolver; + private final PathBindingExpressionResolver expressionResolver; private final Map> traceBindingsCache = new HashMap<>(); public PathBindingEvaluator( @@ -28,10 +28,20 @@ public class PathBindingEvaluator { VariableTracer variableTracer, ConstantResolver constantResolver, TypeResolver typeResolver) { + this(context, variableTracer, constantResolver, typeResolver, new PathBindingExpressionResolver(context, variableTracer, constantResolver)); + } + + PathBindingEvaluator( + CodebaseContext context, + VariableTracer variableTracer, + ConstantResolver constantResolver, + TypeResolver typeResolver, + PathBindingExpressionResolver expressionResolver) { this.context = context; this.variableTracer = variableTracer; this.constantResolver = constantResolver; this.typeResolver = typeResolver; + this.expressionResolver = expressionResolver; } /** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */ @@ -55,17 +65,22 @@ public class PathBindingEvaluator { private Map computeTraceBindings( List path, Map> callGraph, CallGraphPathFinder pathFinder) { - Map bindings = new HashMap<>(); - for (int i = 0; i < path.size() - 1; i++) { - String caller = path.get(i); - String target = path.get(i + 1); - CallEdge edge = findEdge(caller, target, callGraph, pathFinder); - if (edge == null) { - continue; + context.setAnalysisCallPath(path); + try { + Map bindings = new HashMap<>(); + for (int i = 0; i < path.size() - 1; i++) { + String caller = path.get(i); + String target = path.get(i + 1); + CallEdge edge = findEdge(caller, target, callGraph, pathFinder); + if (edge == null) { + continue; + } + enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings); } - enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings); + return bindings; + } finally { + context.clearAnalysisCallPath(); } - return bindings; } private static String pathCacheKey(List path) { @@ -143,20 +158,24 @@ public class PathBindingEvaluator { Map> callGraph, CallGraphPathFinder pathFinder, Map bindings) { - if (VariableTracer.isRunnableRunMethod(target) && pathIndex >= 2) { + if (FunctionalInterfaceTypes.isFunctionalSamMethod(target) && pathIndex >= 2) { int paramIndex = variableTracer.findFunctionalParameterIndex(caller); if (paramIndex < 0) { paramIndex = 0; } + String functionalCaller = path.get(pathIndex - 2); + CallEdge functionalEdge = CallSiteMatcher.selectEdge( + functionalCaller, caller, callGraph.get(functionalCaller), bindings); List constants = variableTracer.resolveConstantsFromCallSiteArgument( - path.get(pathIndex - 2), caller, paramIndex); + functionalCaller, caller, paramIndex, functionalEdge); for (String constant : constants) { if (constant != null && !constant.isBlank()) { bindings.putIfAbsent(constant, constant); } } } - Map paramValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, pathIndex); + Map paramValues = variableTracer.buildParameterValuesMap( + caller, target, callGraph, path, pathIndex, bindings); mergeResolvedBindings(bindings, paramValues, caller); List targetParams = typeResolver.getParameterNames(target); @@ -168,7 +187,7 @@ public class PathBindingEvaluator { String paramName = targetParams.get(i); if (typeResolver != null && FunctionalInterfaceTypes.isFunctionalInterface(typeResolver.getParameterType(target, i))) { - List constants = variableTracer.resolveConstantsFromCallSiteArgument(caller, target, i); + List constants = variableTracer.resolveConstantsFromCallSiteArgument(caller, target, i, edge); if (constants.size() == 1) { bindings.put(paramName, constants.get(0)); continue; @@ -218,7 +237,7 @@ public class PathBindingEvaluator { if (traced.startsWith("\"") || isEnumLikeConstant(traced)) { return traced; } - String fromCall = resolveMethodCallFromSource(callerFqn, rawValue, bindings); + String fromCall = expressionResolver.resolveMethodCallFromSource(callerFqn, rawValue, bindings); if (fromCall != null) { return fromCall; } @@ -229,155 +248,6 @@ public class PathBindingEvaluator { return rawValue; } - private String resolveMethodCallFromSource(String callerFqn, String varName, Map bindings) { - MethodDeclaration md = findMethodDeclaration(callerFqn); - if (md == null || md.getBody() == null) { - return null; - } - final Expression[] initializer = new Expression[1]; - md.getBody().accept(new ASTVisitor() { - @Override - public boolean visit(VariableDeclarationFragment node) { - if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { - initializer[0] = node.getInitializer(); - } - return super.visit(node); - } - }); - if (initializer[0] == null) { - return null; - } - return evaluateExpressionValue(initializer[0], callerFqn, bindings); - } - - private String evaluateExpressionValue(Expression expr, String callerFqn, Map bindings) { - if (expr instanceof StringLiteral sl) { - return "\"" + sl.getLiteralValue() + "\""; - } - if (expr instanceof QualifiedName qn) { - return qn.getFullyQualifiedName(); - } - if (expr instanceof SimpleName sn) { - return bindings.getOrDefault(sn.getIdentifier(), sn.getIdentifier()); - } - if (expr instanceof MethodInvocation mi) { - return evaluateMethodInvocationReturn(mi, callerFqn, bindings); - } - if (expr instanceof SwitchExpression se) { - return constantResolver.evaluateSwitchWithParams(se, bindings, context); - } - String resolved = constantResolver.resolve(expr, context); - return resolved; - } - - private String evaluateMethodInvocationReturn(MethodInvocation mi, String callerFqn, Map bindings) { - String ownerFqn = resolveMethodOwnerFqn(mi, callerFqn); - if (ownerFqn == null) { - return null; - } - String methodName = mi.getName().getIdentifier(); - TypeDeclaration owner = context.getTypeDeclaration(ownerFqn); - if (owner == null) { - return null; - } - MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true); - if (md == null || md.getBody() == null) { - return null; - } - - Map callBindings = new HashMap<>(bindings); - for (int i = 0; i < md.parameters().size() && i < mi.arguments().size(); i++) { - SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i); - String paramName = param.getName().getIdentifier(); - String argValue = evaluateExpressionValue((Expression) mi.arguments().get(i), callerFqn, callBindings); - if (argValue != null) { - callBindings.put(paramName, argValue); - } - } - - final String[] result = new String[1]; - md.getBody().accept(new ASTVisitor() { - @Override - public boolean visit(ReturnStatement node) { - if (node.getExpression() instanceof SwitchExpression se) { - result[0] = constantResolver.evaluateSwitchWithParams(se, callBindings, context); - } else if (node.getExpression() != null) { - result[0] = evaluateExpressionValue(node.getExpression(), ownerFqn + "." + methodName, callBindings); - } - return super.visit(node); - } - }); - return result[0]; - } - - private String resolveFieldTypeFqn(String callerFqn, String fieldName) { - if (callerFqn == null || !callerFqn.contains(".")) { - return null; - } - String className = callerFqn.substring(0, callerFqn.lastIndexOf('.')); - TypeDeclaration td = context.getTypeDeclaration(className); - if (td == null) { - return null; - } - for (FieldDeclaration field : td.getFields()) { - for (Object fragmentObj : field.fragments()) { - VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj; - if (fragment.getName().getIdentifier().equals(fieldName)) { - IVariableBinding binding = fragment.resolveBinding(); - if (binding != null && binding.getType() != null) { - return binding.getType().getErasure().getQualifiedName(); - } - String simpleType = field.getType().toString(); - TypeDeclaration resolved = context.getTypeDeclaration(simpleType); - if (resolved != null) { - return context.getFqn(resolved); - } - if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) { - String pkg = cu.getPackage().getName().getFullyQualifiedName(); - resolved = context.getTypeDeclaration(pkg + "." + simpleType); - if (resolved != null) { - return context.getFqn(resolved); - } - } - return simpleType; - } - } - } - return null; - } - - private String resolveMethodOwnerFqn(MethodInvocation mi, String callerFqn) { - IMethodBinding methodBinding = mi.resolveMethodBinding(); - if (methodBinding != null && methodBinding.getDeclaringClass() != null) { - return methodBinding.getDeclaringClass().getErasure().getQualifiedName(); - } - Expression receiver = mi.getExpression(); - if (receiver instanceof SimpleName sn) { - return resolveFieldTypeFqn(callerFqn, sn.getIdentifier()); - } - if (receiver instanceof FieldAccess fa) { - IVariableBinding fieldBinding = fa.resolveFieldBinding(); - if (fieldBinding != null && fieldBinding.getType() != null) { - return fieldBinding.getType().getErasure().getQualifiedName(); - } - return resolveFieldTypeFqn(callerFqn, fa.getName().getIdentifier()); - } - return null; - } - - private MethodDeclaration findMethodDeclaration(String methodFqn) { - if (methodFqn == null || !methodFqn.contains(".")) { - return null; - } - String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); - String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); - TypeDeclaration td = context.getTypeDeclaration(className); - if (td == null) { - return null; - } - return context.findMethodDeclaration(td, methodName, true); - } - private static String normalizeLiteral(String value) { if (value != null && value.startsWith("\"") && value.endsWith("\"")) { return value.substring(1, value.length() - 1); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolver.java new file mode 100644 index 0000000..e92c328 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolver.java @@ -0,0 +1,221 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.ast.common.AstUtils; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.*; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Resolves expression values under path-binding context using the dataflow pipeline + * ({@link VariableTracer} / {@link click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel}) + * instead of a parallel AST mini-interpreter. + */ +public class PathBindingExpressionResolver { + + private final CodebaseContext context; + private final VariableTracer variableTracer; + private final ConstantResolver constantResolver; + + public PathBindingExpressionResolver( + CodebaseContext context, + VariableTracer variableTracer, + ConstantResolver constantResolver) { + this.context = context; + this.variableTracer = variableTracer; + this.constantResolver = constantResolver; + } + + public String resolveMethodCallFromSource(String callerFqn, String varName, Map bindings) { + MethodDeclaration md = findMethodDeclaration(callerFqn); + if (md == null || md.getBody() == null) { + return null; + } + final Expression[] initializer = new Expression[1]; + md.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationFragment node) { + if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { + initializer[0] = node.getInitializer(); + } + return super.visit(node); + } + }); + if (initializer[0] == null) { + return null; + } + return resolve(initializer[0], callerFqn, bindings); + } + + public String resolve(Expression expr, String callerFqn, Map bindings) { + if (expr == null) { + return null; + } + Map effectiveBindings = bindings == null ? Map.of() : new HashMap<>(bindings); + context.setAnalysisNamedBindings(effectiveBindings); + try { + return resolveInternal(expr, callerFqn, effectiveBindings); + } finally { + context.clearAnalysisNamedBindings(); + } + } + + private String resolveInternal(Expression expr, String callerFqn, Map bindings) { + if (expr instanceof StringLiteral sl) { + return "\"" + sl.getLiteralValue() + "\""; + } + if (expr instanceof QualifiedName qn) { + return qn.getFullyQualifiedName(); + } + if (expr instanceof SimpleName sn) { + return bindings.getOrDefault(sn.getIdentifier(), sn.getIdentifier()); + } + if (expr instanceof SwitchExpression se) { + return constantResolver.evaluateSwitchWithParams(se, bindings, context); + } + if (expr instanceof MethodInvocation mi) { + return resolveMethodInvocationReturn(mi, callerFqn, bindings); + } + return resolveViaDataflow(expr); + } + + private String resolveMethodInvocationReturn(MethodInvocation mi, String callerFqn, Map bindings) { + String ownerFqn = resolveMethodOwnerFqn(mi, callerFqn); + if (ownerFqn == null) { + return null; + } + String methodName = mi.getName().getIdentifier(); + TypeDeclaration owner = context.getTypeDeclaration(ownerFqn); + if (owner == null) { + return null; + } + MethodDeclaration md = context.findMethodDeclaration(owner, methodName, true); + if (md == null) { + return null; + } + + Map callBindings = new HashMap<>(bindings); + for (int i = 0; i < md.parameters().size() && i < mi.arguments().size(); i++) { + SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i); + String paramName = param.getName().getIdentifier(); + String argValue = resolveInternal((Expression) mi.arguments().get(i), callerFqn, callBindings); + if (argValue != null) { + callBindings.put(paramName, argValue); + } + } + + context.setAnalysisNamedBindings(callBindings); + try { + return resolveViaDataflow(mi); + } finally { + context.setAnalysisNamedBindings(bindings); + } + } + + private String resolveViaDataflow(Expression expr) { + Set constants = new LinkedHashSet<>(); + for (Expression def : variableTracer.traceVariableAll(expr)) { + String val = constantResolver.resolve(def, context); + if (val != null && !val.isBlank()) { + constants.add(val); + } + } + if (constants.size() == 1) { + return formatResolvedConstant(constants.iterator().next()); + } + if (constants.isEmpty()) { + String direct = constantResolver.resolve(expr, context); + return direct != null ? formatResolvedConstant(direct) : null; + } + return null; + } + + private static String formatResolvedConstant(String value) { + if (value == null) { + return null; + } + if (value.startsWith("ENUM_SET:") + || value.startsWith("")) { + return value; + } + if (value.startsWith("\"") || AstUtils.isEnumLikeConstantName(value)) { + return value; + } + return "\"" + value + "\""; + } + + private String resolveMethodOwnerFqn(MethodInvocation mi, String callerFqn) { + IMethodBinding methodBinding = mi.resolveMethodBinding(); + if (methodBinding != null && methodBinding.getDeclaringClass() != null) { + return methodBinding.getDeclaringClass().getErasure().getQualifiedName(); + } + Expression receiver = mi.getExpression(); + if (receiver instanceof SimpleName sn) { + return resolveFieldTypeFqn(callerFqn, sn.getIdentifier()); + } + if (receiver instanceof FieldAccess fa) { + IVariableBinding fieldBinding = fa.resolveFieldBinding(); + if (fieldBinding != null && fieldBinding.getType() != null) { + return fieldBinding.getType().getErasure().getQualifiedName(); + } + return resolveFieldTypeFqn(callerFqn, fa.getName().getIdentifier()); + } + return null; + } + + private String resolveFieldTypeFqn(String callerFqn, String fieldName) { + if (callerFqn == null || !callerFqn.contains(".")) { + return null; + } + String className = callerFqn.substring(0, callerFqn.lastIndexOf('.')); + TypeDeclaration td = context.getTypeDeclaration(className); + if (td == null) { + return null; + } + for (FieldDeclaration field : td.getFields()) { + for (Object fragmentObj : field.fragments()) { + VariableDeclarationFragment fragment = (VariableDeclarationFragment) fragmentObj; + if (fragment.getName().getIdentifier().equals(fieldName)) { + IVariableBinding binding = fragment.resolveBinding(); + if (binding != null && binding.getType() != null) { + return binding.getType().getErasure().getQualifiedName(); + } + String simpleType = field.getType().toString(); + TypeDeclaration resolved = context.getTypeDeclaration(simpleType); + if (resolved != null) { + return context.getFqn(resolved); + } + if (td.getRoot() instanceof CompilationUnit cu && cu.getPackage() != null) { + String pkg = cu.getPackage().getName().getFullyQualifiedName(); + resolved = context.getTypeDeclaration(pkg + "." + simpleType); + if (resolved != null) { + return context.getFqn(resolved); + } + } + return simpleType; + } + } + } + return null; + } + + private MethodDeclaration findMethodDeclaration(String methodFqn) { + if (methodFqn == null || !methodFqn.contains(".")) { + return null; + } + String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); + TypeDeclaration td = context.getTypeDeclaration(className); + if (td == null) { + return null; + } + return context.findMethodDeclaration(td, methodName, true); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java index cf12532..4860a44 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java @@ -9,6 +9,7 @@ import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver import click.kamil.springstatemachineexporter.analysis.index.AccessorNaming; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector; +import click.kamil.springstatemachineexporter.ast.common.AstUtils; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel; import org.eclipse.jdt.core.dom.*; @@ -481,7 +482,33 @@ public class VariableTracer { private record BranchAssignment(Expression expression, String constraint) {} public Map buildParameterValuesMap(String caller, String target, Map> callGraph, List path, int pathIndex) { + return buildParameterValuesMap(caller, target, callGraph, path, pathIndex, Map.of()); + } + + public Map buildParameterValuesMap( + String caller, + String target, + Map> callGraph, + List path, + int pathIndex, + Map bindings) { Map paramValues = new HashMap<>(); + context.setAnalysisCallPath(path); + try { + return buildParameterValuesMapInternal(caller, target, callGraph, path, pathIndex, bindings, paramValues); + } finally { + context.clearAnalysisCallPath(); + } + } + + private Map buildParameterValuesMapInternal( + String caller, + String target, + Map> callGraph, + List path, + int pathIndex, + Map bindings, + Map paramValues) { List edges = callGraph.get(caller); boolean hasEdge = false; @@ -514,45 +541,51 @@ public class VariableTracer { return paramValues; } - // We also want to support heuristic matches using a helper if isHeuristicMatch is not directly available, but let's check - // We can check if name equals or if it resolves to target - for (CallEdge edge : edges) { - if (edge.getTargetMethod().equals(target) || isHeuristicMatch(edge.getTargetMethod(), target)) { - List args = edge.getArguments(); - if (args == null || args.isEmpty()) break; + CallEdge selectedEdge = CallSiteMatcher.selectEdge(caller, target, edges, bindings); + if (selectedEdge == null) { + return paramValues; + } - int lastDot = target.lastIndexOf('.'); - if (lastDot < 0) break; - String className = target.substring(0, lastDot); - String methodName = target.substring(lastDot + 1); + List args = selectedEdge.getArguments(); + if (args == null || args.isEmpty()) { + return paramValues; + } - TypeDeclaration td = context.getTypeDeclaration(className); - if (td == null) break; + int lastDot = target.lastIndexOf('.'); + if (lastDot < 0) { + return paramValues; + } + String className = target.substring(0, lastDot); + String methodName = target.substring(lastDot + 1); - MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); - if (md == null) break; + TypeDeclaration td = context.getTypeDeclaration(className); + if (td == null) { + return paramValues; + } - List paramNames = new ArrayList<>(); - for (Object paramObj : md.parameters()) { - SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj; - paramNames.add(param.getName().getIdentifier()); + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + if (md == null) { + return paramValues; + } + + List paramNames = new ArrayList<>(); + for (Object paramObj : md.parameters()) { + SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj; + paramNames.add(param.getName().getIdentifier()); + } + + int minSize = Math.min(paramNames.size(), args.size()); + for (int j = 0; j < minSize; j++) { + String argValue = args.get(j); + String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph); + if (typeResolver != null + && FunctionalInterfaceTypes.isFunctionalInterface(typeResolver.getParameterType(target, j))) { + List constants = resolveConstantsFromCallSiteArgument(caller, target, j, selectedEdge); + if (constants.size() == 1) { + resolvedArgValue = constants.get(0); } - - int minSize = Math.min(paramNames.size(), args.size()); - for (int j = 0; j < minSize; j++) { - String argValue = args.get(j); - String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph); - if (typeResolver != null - && FunctionalInterfaceTypes.isFunctionalInterface(typeResolver.getParameterType(target, j))) { - List constants = resolveConstantsFromCallSiteArgument(caller, target, j); - if (constants.size() == 1) { - resolvedArgValue = constants.get(0); - } - } - paramValues.put(paramNames.get(j), resolvedArgValue); - } - break; } + paramValues.put(paramNames.get(j), resolvedArgValue); } return paramValues; } @@ -716,11 +749,17 @@ public class VariableTracer { * Used for {@code Supplier}, {@code Runnable}, and similar functional parameters. */ public List resolveConstantsFromCallSiteArgument(String caller, String callee, int paramIndex) { + return resolveConstantsFromCallSiteArgument(caller, callee, paramIndex, null); + } + + public List resolveConstantsFromCallSiteArgument( + String caller, String callee, int paramIndex, CallEdge edge) { if (constantExtractor == null) { return List.of(); } List constants = new ArrayList<>(); - Expression sourceArg = findCallSiteArgumentExpression(caller, callee, paramIndex); + Expression sourceArg = CallSiteMatcher.findCallSiteArgumentExpression( + caller, callee, paramIndex, edge, context); if (sourceArg != null) { resolveConstantsFromExpressionViaDataflow(sourceArg, constants); } @@ -743,77 +782,47 @@ public class VariableTracer { return -1; } - public static boolean isRunnableRunMethod(String methodFqn) { - return "java.lang.Runnable.run".equals(methodFqn) || "Runnable.run".equals(methodFqn); + public static boolean isFunctionalSamMethod(String methodFqn) { + return FunctionalInterfaceTypes.isFunctionalSamMethod(methodFqn); + } + + public void resolveConstantsViaDataflow(Expression expression, List constants) { + resolveConstantsFromExpressionViaDataflow(expression, constants); + } + + public List resolveConstantsFromSourceText( + String expressionText, List searchMethods, List callPath) { + if (expressionText == null || expressionText.isBlank()) { + return List.of(); + } + context.setAnalysisCallPath(callPath); + try { + Expression anchored = AstUtils.findExpressionInMethods(searchMethods, expressionText, context); + Expression expr = anchored != null ? anchored : AstUtils.parseExpression(expressionText); + if (expr == null) { + return List.of(); + } + List constants = new ArrayList<>(); + resolveConstantsViaDataflow(expr, constants); + return constants.stream() + .filter(c -> c != null && !c.isBlank()) + .distinct() + .toList(); + } finally { + context.clearAnalysisCallPath(); + } } private void resolveConstantsFromExpressionViaDataflow(Expression expression, List constants) { - Expression effective = unwrapLambdaBody(expression); - if (effective != expression) { - resolveConstantsFromExpressionViaDataflow(effective, constants); - if (!constants.isEmpty()) { - return; - } - } - for (Expression def : dataFlowModel.getReachingDefinitions(effective)) { + for (Expression def : dataFlowModel.getReachingDefinitions(expression)) { constantExtractor.extractConstantsFromExpression(def, constants); } if (constants.isEmpty()) { - constantExtractor.extractConstantsFromExpression(effective, constants); + constantExtractor.extractConstantsFromExpression(expression, constants); } } - private static Expression unwrapLambdaBody(Expression expression) { - if (!(expression instanceof LambdaExpression lambda)) { - return expression; - } - ASTNode body = lambda.getBody(); - if (body instanceof Expression bodyExpr) { - return bodyExpr; - } - return expression; - } - Expression findCallSiteArgumentExpression(String caller, String callee, int paramIndex) { - if (caller == null || callee == null || paramIndex < 0 - || !caller.contains(".") || !callee.contains(".")) { - return null; - } - String callerClass = caller.substring(0, caller.lastIndexOf('.')); - String callerMethod = caller.substring(caller.lastIndexOf('.') + 1); - String calleeMethod = callee.substring(callee.lastIndexOf('.') + 1); - TypeDeclaration callerType = context.getTypeDeclaration(callerClass); - if (callerType == null) { - return null; - } - MethodDeclaration method = context.findMethodDeclaration(callerType, callerMethod, true); - if (method == null || method.getBody() == null) { - return null; - } - final Expression[] found = new Expression[1]; - method.getBody().accept(new ASTVisitor() { - @Override - public boolean visit(MethodInvocation node) { - if (found[0] != null || !calleeMethod.equals(node.getName().getIdentifier())) { - return super.visit(node); - } - IMethodBinding binding = node.resolveMethodBinding(); - if (binding != null && binding.getDeclaringClass() != null) { - String declaringFqn = binding.getDeclaringClass().getErasure().getQualifiedName(); - String calleeClass = callee.substring(0, callee.lastIndexOf('.')); - if (!declaringFqn.equals(calleeClass) && !declaringFqn.endsWith("." + calleeClass)) { - String calleeSimple = calleeClass.substring(calleeClass.lastIndexOf('.') + 1); - if (!declaringFqn.endsWith("." + calleeSimple)) { - return super.visit(node); - } - } - } - if (paramIndex < node.arguments().size()) { - found[0] = (Expression) node.arguments().get(paramIndex); - } - return super.visit(node); - } - }); - return found[0]; + return CallSiteMatcher.findCallSiteArgumentExpression(caller, callee, paramIndex, null, context); } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/AstUtils.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/AstUtils.java index d30e5bd..cf8fa3e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/AstUtils.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/AstUtils.java @@ -1,8 +1,15 @@ package click.kamil.springstatemachineexporter.ast.common; +import org.eclipse.jdt.core.JavaCore; +import org.eclipse.jdt.core.dom.AST; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.ASTParser; import org.eclipse.jdt.core.dom.Annotation; import org.eclipse.jdt.core.dom.ArrayType; -import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.FieldDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; import org.eclipse.jdt.core.dom.MemberValuePair; import org.eclipse.jdt.core.dom.MethodDeclaration; import org.eclipse.jdt.core.dom.Name; @@ -17,6 +24,9 @@ import org.eclipse.jdt.core.dom.SimpleType; import org.eclipse.jdt.core.dom.Type; import org.eclipse.jdt.core.dom.TypeDeclaration; +import java.util.List; +import java.util.Map; + public final class AstUtils { private AstUtils() { } @@ -263,4 +273,145 @@ public final class AstUtils { combined.append(')'); return combined.toString(); } + + public static Expression parseExpression(String snippet) { + if (snippet == null || snippet.isBlank()) { + return null; + } + ASTParser parser = ASTParser.newParser(AST.JLS17); + Map options = JavaCore.getOptions(); + JavaCore.setComplianceOptions(JavaCore.VERSION_17, options); + parser.setCompilerOptions(options); + parser.setKind(ASTParser.K_EXPRESSION); + parser.setSource(snippet.toCharArray()); + try { + ASTNode node = parser.createAST(null); + if (node instanceof Expression expression) { + return expression; + } + if (node instanceof CompilationUnit) { + ASTParser fallbackParser = ASTParser.newParser(AST.JLS17); + fallbackParser.setCompilerOptions(options); + fallbackParser.setKind(ASTParser.K_COMPILATION_UNIT); + fallbackParser.setSource(("class A { Object o = " + snippet + "; }").toCharArray()); + CompilationUnit cu = (CompilationUnit) fallbackParser.createAST(null); + if (!cu.types().isEmpty() && cu.types().get(0) instanceof TypeDeclaration td) { + if (!td.bodyDeclarations().isEmpty() && td.bodyDeclarations().get(0) instanceof FieldDeclaration fd) { + if (!fd.fragments().isEmpty() && fd.fragments().get(0) instanceof VariableDeclarationFragment vdf) { + return vdf.getInitializer(); + } + } + } + } + } catch (RuntimeException ignored) { + return null; + } + return null; + } + + public static Expression findExpressionInMethod(String methodFqn, String expressionText, CodebaseContext context) { + if (methodFqn == null || expressionText == null || !methodFqn.contains(".") || context == null) { + return null; + } + String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); + TypeDeclaration td = context.getTypeDeclaration(className); + if (td == null) { + return null; + } + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + if (md == null || md.getBody() == null) { + return null; + } + final Expression[] match = new Expression[1]; + md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(org.eclipse.jdt.core.dom.MethodInvocation node) { + if (expressionText.equals(node.toString())) { + match[0] = node; + return false; + } + return super.visit(node); + } + + @Override + public boolean visit(org.eclipse.jdt.core.dom.SuperMethodInvocation node) { + if (expressionText.equals(node.toString())) { + match[0] = node; + return false; + } + return super.visit(node); + } + + @Override + public boolean visit(QualifiedName node) { + if (expressionText.equals(node.toString())) { + match[0] = node; + return false; + } + return super.visit(node); + } + + @Override + public boolean visit(org.eclipse.jdt.core.dom.SimpleName node) { + if (expressionText.equals(node.getIdentifier())) { + match[0] = node; + return false; + } + return super.visit(node); + } + }); + return match[0]; + } + + public static org.eclipse.jdt.core.dom.MethodInvocation findMethodInvocationInMethod( + String methodFqn, String expressionText, CodebaseContext context) { + Expression found = findExpressionInMethod(methodFqn, expressionText, context); + return found instanceof org.eclipse.jdt.core.dom.MethodInvocation mi ? mi : null; + } + + public static Expression findExpressionInMethods( + List methodFqns, String expressionText, CodebaseContext context) { + if (methodFqns == null || expressionText == null) { + return null; + } + for (String methodFqn : methodFqns) { + Expression found = findExpressionInMethod(methodFqn, expressionText, context); + if (found != null) { + return found; + } + } + return null; + } + + public static String combineExpressionText(String base, String suffix) { + if (suffix == null || suffix.isEmpty()) { + return base; + } + return base + suffix; + } + + public static String toParseSnippet(String boundValue) { + if (boundValue == null || boundValue.isBlank()) { + return boundValue; + } + if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) { + return boundValue; + } + if (isEnumLikeConstantName(boundValue)) { + return boundValue; + } + return "\"" + boundValue.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + public static boolean isEnumLikeConstantName(String value) { + if (value == null || value.isEmpty()) { + return false; + } + int dot = value.lastIndexOf('.'); + if (dot <= 0 || dot >= value.length() - 1) { + return false; + } + return Character.isUpperCase(value.charAt(dot + 1)); + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index 678faf1..5128880 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -177,6 +177,32 @@ public class CodebaseContext { private String[] classpath = new String[0]; private String[] sourcepath = new String[0]; private boolean resolveBindings = false; + private transient List analysisCallPath; + private transient Map analysisNamedBindings; + + public void setAnalysisCallPath(List path) { + this.analysisCallPath = path; + } + + public List getAnalysisCallPath() { + return analysisCallPath; + } + + public void clearAnalysisCallPath() { + this.analysisCallPath = null; + } + + public void setAnalysisNamedBindings(Map bindings) { + this.analysisNamedBindings = bindings; + } + + public Map getAnalysisNamedBindings() { + return analysisNamedBindings; + } + + public void clearAnalysisNamedBindings() { + this.analysisNamedBindings = null; + } public Map> getProperties() { return Collections.unmodifiableMap(allProperties); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java index 81a1557..656728f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java @@ -2,6 +2,8 @@ package click.kamil.springstatemachineexporter.ast.common; import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining; import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; +import click.kamil.springstatemachineexporter.analysis.pipeline.LibraryUnwrapRegistry; +import click.kamil.springstatemachineexporter.analysis.pipeline.ReactiveExpressionSupport; import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; import org.eclipse.jdt.core.dom.*; import java.util.*; @@ -15,20 +17,6 @@ public class JdtDataFlowModel implements DataFlowModel { private final Map rdCache = new HashMap<>(); private final Map> objectFieldBindings = new HashMap<>(); - private static final ThreadLocal> CURRENT_PATH = new ThreadLocal<>(); - - public static void setCurrentPath(List path) { - CURRENT_PATH.set(path); - } - - public static List getCurrentPath() { - return CURRENT_PATH.get(); - } - - public static void clearCurrentPath() { - CURRENT_PATH.remove(); - } - public JdtDataFlowModel(CodebaseContext context) { this.context = context; } @@ -64,6 +52,13 @@ public class JdtDataFlowModel implements DataFlowModel { return List.of(); } + if (expr instanceof LambdaExpression lambda) { + List resolved = resolveFunctionalBoundExpression( + lambda, visited, paramBindings, instanceFieldBindings, depth); + visited.remove(expr); + return resolved; + } + // 1. Handle Ternary / Conditional Expression if (expr instanceof ConditionalExpression ce) { String condVal = resolveValue(ce.getExpression(), context); @@ -111,6 +106,18 @@ public class JdtDataFlowModel implements DataFlowModel { } } + Map namedBindings = context.getAnalysisNamedBindings(); + if (namedBindings != null && namedBindings.containsKey(sn.getIdentifier())) { + String boundValue = namedBindings.get(sn.getIdentifier()); + Expression synthetic = AstUtils.parseExpression(AstUtils.toParseSnippet(boundValue)); + if (synthetic != null) { + List resolved = getReachingDefinitions( + synthetic, visited, paramBindings, instanceFieldBindings, depth + 1); + visited.remove(expr); + return resolved; + } + } + // Fallback: Local Reaching Definitions MethodDeclaration md = findEnclosingMethod(sn); ReachingDefinitions rd = getReachingDefinitionsForMethod(md); @@ -193,22 +200,51 @@ public class JdtDataFlowModel implements DataFlowModel { // 4. Handle Method Invocations (Static Factory methods / Transforming Getters) if (expr instanceof MethodInvocation mi) { - String mName = mi.getName().getIdentifier(); - if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) { - List resolved = getReachingDefinitions((Expression) mi.arguments().get(0), visited, paramBindings, instanceFieldBindings, depth + 1); + Expression factoryPayload = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi); + if (factoryPayload != null) { + List resolved = getReachingDefinitions( + factoryPayload, visited, paramBindings, instanceFieldBindings, depth + 1); visited.remove(expr); return resolved; } - if ("run".equals(mName) && mi.arguments().isEmpty() && isRunnableReceiver(mi.getExpression())) { - List functional = resolveFunctionalInterfaceInvocation( - mi, visited, paramBindings, instanceFieldBindings, depth); - if (!functional.isEmpty()) { - visited.remove(expr); - return functional; + String mName = mi.getName().getIdentifier(); + if (LibraryUnwrapRegistry.isReactiveTransformMethod(mName) && !mi.arguments().isEmpty()) { + Expression arg = (Expression) mi.arguments().get(0); + if (arg instanceof LambdaExpression lambda && lambda.parameters().size() == 1) { + Expression source = ReactiveExpressionSupport.peelFactoryPayloadExpression(mi.getExpression()); + if (source != null) { + Object parameter = lambda.parameters().get(0); + if (parameter instanceof SingleVariableDeclaration svd) { + IVariableBinding paramBinding = svd.resolveBinding(); + if (paramBinding != null) { + Map transformBindings = new HashMap<>(paramBindings); + transformBindings.put(paramBinding, source); + Expression bodyExpression = ReactiveExpressionSupport.lambdaBodyExpression(lambda); + if (bodyExpression != null) { + Expression nestedFactory = ReactiveExpressionSupport.peelFactoryPayloadExpression(bodyExpression); + if (nestedFactory != null) { + List resolved = getReachingDefinitions( + nestedFactory, visited, transformBindings, instanceFieldBindings, depth + 1); + if (!resolved.isEmpty()) { + visited.remove(expr); + return resolved; + } + } + List resolved = getReachingDefinitions( + bodyExpression, visited, transformBindings, instanceFieldBindings, depth + 1); + if (!resolved.isEmpty()) { + visited.remove(expr); + return resolved; + } + } + } + } + } } } - if ("get".equals(mName) && mi.arguments().isEmpty() && isSupplierLikeReceiver(mi.getExpression())) { + + if (isFunctionalSamInvocation(mi)) { List functional = resolveFunctionalInterfaceInvocation( mi, visited, paramBindings, instanceFieldBindings, depth); if (!functional.isEmpty()) { @@ -372,6 +408,11 @@ public class JdtDataFlowModel implements DataFlowModel { receiverDefs = getReachingDefinitions(receiver, visited, paramBindings, instanceFieldBindings, depth + 1); accessor = AccessorInlining.lookupAccessor(context, mi, receiverDefs); if (accessor.isEmpty()) { + List anonymous = inlineAnonymousFromReceiverDefs( + methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth); + if (!anonymous.isEmpty()) { + return anonymous; + } return List.of(); } } @@ -400,6 +441,11 @@ public class JdtDataFlowModel implements DataFlowModel { } if (accessor.isEmpty()) { + List anonymous = inlineAnonymousFromReceiverDefs( + methodName, receiverDefs, visited, paramBindings, instanceFieldBindings, depth); + if (!anonymous.isEmpty()) { + return anonymous; + } return List.of(); } if (!accessor.get().isGetter()) { @@ -643,11 +689,253 @@ public class JdtDataFlowModel implements DataFlowModel { if (fieldValue != null) { results.addAll(getReachingDefinitions(fieldValue, visited, paramBindings, fieldBindings, depth + 1)); } + if (results.isEmpty() && cic.getAnonymousClassDeclaration() != null) { + results.addAll(inlineAnonymousGetterReturns( + cic, accessor.methodName(), visited, paramBindings, fieldBindings, depth)); + } + } else { + TypeDeclaration receiverType = resolveExpressionTypeDeclaration( + candidate, paramBindings, instanceFieldBindings); + if (receiverType != null) { + Map typeFieldBindings = + buildTypeDefaultFieldBindings(receiverType); + if (receiver instanceof SimpleName sn && candidate == sn) { + applyFlowSensitiveMutationsForLocalReceiver( + sn, mi, accessor, typeFieldBindings, paramBindings, visited, depth); + } + Expression fieldValue = readFieldValue(accessor, typeFieldBindings, mi); + if (fieldValue != null) { + results.addAll(getReachingDefinitions( + fieldValue, visited, paramBindings, typeFieldBindings, depth + 1)); + } + if (results.isEmpty()) { + results.addAll(inlineGetterMethodReturns( + accessor, receiverType, visited, paramBindings, typeFieldBindings, depth)); + } + if (results.isEmpty() && receiverType.isInterface()) { + results.addAll(inlineConcreteAccessorFromReceiverDefs( + accessor, receiverDefs, visited, paramBindings, typeFieldBindings, depth)); + } + } } } return results; } + private List inlineAnonymousGetterReturns( + ClassInstanceCreation cic, + String methodName, + Set visited, + Map paramBindings, + Map fieldBindings, + int depth) { + if (cic.getAnonymousClassDeclaration() == null || methodName == null) { + return List.of(); + } + for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) { + if (declObj instanceof MethodDeclaration methodDeclaration + && methodName.equals(methodDeclaration.getName().getIdentifier()) + && methodDeclaration.getBody() != null) { + List results = new ArrayList<>(); + for (ReturnStatement rs : findReturnStatements(methodDeclaration.getBody())) { + if (rs.getExpression() != null) { + results.addAll(getReachingDefinitions( + rs.getExpression(), visited, paramBindings, fieldBindings, depth + 1)); + } + } + return results; + } + } + return List.of(); + } + + private List inlineConcreteAccessorFromReceiverDefs( + AccessorSummary accessor, + List receiverDefs, + Set visited, + Map paramBindings, + Map fieldBindings, + int depth) { + List results = new ArrayList<>(); + for (Expression receiverDef : receiverDefs) { + String concreteFqn = concreteReceiverTypeFqn(receiverDef); + if (concreteFqn == null || concreteFqn.equals(accessor.ownerFqn())) { + continue; + } + TypeDeclaration concreteType = context.getTypeDeclaration(concreteFqn); + if (concreteType == null) { + continue; + } + Map concreteBindings = buildTypeDefaultFieldBindings(concreteType); + if (receiverDef instanceof ClassInstanceCreation cic) { + concreteBindings = getOrCreateFieldBindings(cic, paramBindings, fieldBindings); + } + Expression fieldValue = readFieldValue(accessor, concreteBindings, null); + if (fieldValue != null) { + results.addAll(getReachingDefinitions( + fieldValue, visited, paramBindings, concreteBindings, depth + 1)); + } + if (results.isEmpty()) { + results.addAll(inlineGetterMethodReturns( + accessor, concreteType, visited, paramBindings, concreteBindings, depth)); + } + } + return results; + } + + private List inlineAnonymousFromReceiverDefs( + String methodName, + List receiverDefs, + Set visited, + Map paramBindings, + Map instanceFieldBindings, + int depth) { + if (receiverDefs == null || methodName == null) { + return List.of(); + } + for (Expression receiverDef : receiverDefs) { + if (receiverDef instanceof ClassInstanceCreation cic) { + List inlined = inlineAnonymousGetterReturns( + cic, methodName, visited, paramBindings, instanceFieldBindings, depth); + if (!inlined.isEmpty()) { + return inlined; + } + } + } + return List.of(); + } + + private void applyFlowSensitiveMutationsForLocalReceiver( + SimpleName receiverName, + MethodInvocation useSite, + AccessorSummary accessor, + Map fieldBindings, + Map paramBindings, + Set visited, + int depth) { + MethodDeclaration enclosingMethod = findEnclosingMethod(useSite); + if (enclosingMethod == null) { + return; + } + ASTNode defNode = findVariableDefinitionNode(enclosingMethod, receiverName.getIdentifier()); + if (defNode instanceof SingleVariableDeclaration) { + String setterName = "set" + Character.toUpperCase(accessor.fieldName().charAt(0)) + + accessor.fieldName().substring(1); + Expression setterArg = findLocalSetterArgument( + enclosingMethod, + receiverName.getIdentifier(), + accessor.methodName(), + setterName, + accessor.fieldName()); + if (setterArg != null) { + IVariableBinding fieldBinding = findFieldVariableBinding( + accessor.declaringFqn(), accessor.fieldName(), useSite); + if (fieldBinding != null) { + List resolved = getReachingDefinitions( + setterArg, visited, paramBindings, fieldBindings, depth + 1); + fieldBindings.put(fieldBinding, resolved.isEmpty() ? setterArg : resolved.get(0)); + } + } + return; + } + if (defNode != null) { + applyIntermediateMutations( + receiverName, defNode, useSite, enclosingMethod, fieldBindings, paramBindings, visited, depth); + } + } + + private ASTNode findVariableDefinitionNode(MethodDeclaration methodDeclaration, String varName) { + for (Object paramObj : methodDeclaration.parameters()) { + if (paramObj instanceof SingleVariableDeclaration svd + && varName.equals(svd.getName().getIdentifier())) { + return svd; + } + } + if (methodDeclaration.getBody() == null) { + return null; + } + final ASTNode[] found = new ASTNode[1]; + methodDeclaration.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationStatement node) { + for (Object fragmentObj : node.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment + && varName.equals(fragment.getName().getIdentifier())) { + found[0] = node; + return false; + } + } + return super.visit(node); + } + }); + return found[0]; + } + + private List inlineGetterMethodReturns( + AccessorSummary accessor, + TypeDeclaration ownerType, + Set visited, + Map paramBindings, + Map fieldBindings, + int depth) { + MethodDeclaration getter = context.findMethodDeclaration(ownerType, accessor.methodName(), true); + if (getter == null || getter.getBody() == null) { + return List.of(); + } + List results = new ArrayList<>(); + for (ReturnStatement rs : findReturnStatements(getter.getBody())) { + if (rs.getExpression() != null) { + results.addAll(getReachingDefinitions( + rs.getExpression(), visited, paramBindings, fieldBindings, depth + 1)); + } + } + return results; + } + + private Map buildTypeDefaultFieldBindings(TypeDeclaration typeDeclaration) { + Map fieldBindings = new HashMap<>(); + if (typeDeclaration == null) { + return fieldBindings; + } + for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) { + for (Object fragmentObj : fieldDeclaration.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment + && fragment.getInitializer() != null) { + IVariableBinding binding = fragment.resolveBinding(); + if (binding != null) { + fieldBindings.put(binding, fragment.getInitializer()); + } + } + } + } + return fieldBindings; + } + + private TypeDeclaration resolveExpressionTypeDeclaration( + Expression expr, + Map paramBindings, + Map instanceFieldBindings) { + if (expr == null) { + return null; + } + ITypeBinding typeBinding = expr.resolveTypeBinding(); + if (typeBinding == null) { + IVariableBinding variableBinding = getVariableBinding(expr); + if (variableBinding != null && variableBinding.getType() != null) { + typeBinding = variableBinding.getType(); + } + } + if (typeBinding == null) { + return null; + } + String fqn = typeBinding.getErasure().getQualifiedName(); + TypeDeclaration td = context.getTypeDeclaration(fqn); + if (td == null && expr.getRoot() instanceof CompilationUnit cu) { + td = context.getTypeDeclaration(fqn, cu); + } + return td; + } + private void applyFlowSensitiveMutationsBeforeUse( SimpleName receiverName, MethodInvocation useSite, @@ -1130,7 +1418,7 @@ public class JdtDataFlowModel implements DataFlowModel { if (declaringClassFqn != null && !declaringClassFqn.isEmpty()) { // Find all known implementation subclasses in the codebase List implClassFqns = context.getImplementations(declaringClassFqn); - List currentPath = CURRENT_PATH.get(); + List currentPath = activeAnalysisPath(); if (currentPath != null && implClassFqns != null && !implClassFqns.isEmpty()) { Set contextTypes = getCompatibleContextTypes(currentPath, declaringClassFqn); List filteredImpls = new ArrayList<>(); @@ -1188,6 +1476,10 @@ public class JdtDataFlowModel implements DataFlowModel { return targets; } + private List activeAnalysisPath() { + return context.getAnalysisCallPath(); + } + private boolean isExpressionWrapping(Expression outer, Expression inner) { Expression current = outer; while (current != null) { @@ -1380,6 +1672,9 @@ public class JdtDataFlowModel implements DataFlowModel { if (cfg == null) return; ControlFlowGraph.CfgNode defCfgNode = findCfgNode(cfg, defNode); + if (defCfgNode == null && defNode instanceof SingleVariableDeclaration) { + defCfgNode = cfg.getEntryNode(); + } ControlFlowGraph.CfgNode useCfgNode = findCfgNode(cfg, useNode); if (defCfgNode != null && useCfgNode != null) { @@ -1483,7 +1778,15 @@ public class JdtDataFlowModel implements DataFlowModel { return findMethodDeclarationInType(mb.getDeclaringClass(), mb); } - private static boolean isSupplierLikeReceiver(Expression receiver) { + private static boolean isFunctionalSamInvocation(MethodInvocation mi) { + String methodName = mi.getName().getIdentifier(); + if (!isKnownSamMethodName(methodName)) { + return false; + } + if ("get".equals(methodName) && !mi.arguments().isEmpty()) { + return false; + } + Expression receiver = mi.getExpression(); if (receiver == null) { return false; } @@ -1491,23 +1794,27 @@ public class JdtDataFlowModel implements DataFlowModel { if (typeBinding == null) { return false; } - String fqn = typeBinding.getErasure().getQualifiedName(); - if (fqn == null) { - return false; - } - return fqn.contains("Supplier") || fqn.contains("Function") || fqn.contains("Callable"); + ITypeBinding erasure = typeBinding.getErasure(); + return isKnownFunctionalInterfaceName(erasure.getQualifiedName()); } - private static boolean isRunnableReceiver(Expression receiver) { - if (receiver == null) { + private static boolean isKnownSamMethodName(String methodName) { + return switch (methodName) { + case "run", "get", "call", "accept", "apply", "test" -> true; + default -> false; + }; + } + + private static boolean isKnownFunctionalInterfaceName(String fqn) { + if (fqn == null || fqn.isBlank()) { return false; } - ITypeBinding typeBinding = receiver.resolveTypeBinding(); - if (typeBinding == null) { - return false; - } - String fqn = typeBinding.getErasure().getQualifiedName(); - return "java.lang.Runnable".equals(fqn) || fqn.endsWith(".Runnable"); + String simple = fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn; + return switch (simple) { + case "Runnable", "Supplier", "Function", "Callable", "Consumer", "BiConsumer", "Predicate", "BiFunction" -> + true; + default -> fqn.contains("Supplier") || fqn.contains("Function") || fqn.contains("Callable"); + }; } private List resolveFunctionalInterfaceInvocation( @@ -1545,6 +1852,11 @@ public class JdtDataFlowModel implements DataFlowModel { if (bound instanceof LambdaExpression lambda) { ASTNode body = lambda.getBody(); if (body instanceof Expression bodyExpr) { + List sideEffectArgs = collectVoidCallArgumentDefinitions( + bodyExpr, visited, paramBindings, instanceFieldBindings, depth); + if (!sideEffectArgs.isEmpty()) { + return sideEffectArgs; + } return getReachingDefinitions(bodyExpr, visited, paramBindings, instanceFieldBindings, depth); } if (body instanceof Block block) { @@ -1556,6 +1868,28 @@ public class JdtDataFlowModel implements DataFlowModel { return getReachingDefinitions(bound, visited, paramBindings, instanceFieldBindings, depth); } + private List collectVoidCallArgumentDefinitions( + Expression bodyExpr, + Set visited, + Map paramBindings, + Map instanceFieldBindings, + int depth) { + if (!(bodyExpr instanceof MethodInvocation mi) || !isVoidMethodInvocation(mi)) { + return List.of(); + } + List results = new ArrayList<>(); + for (Object argObj : mi.arguments()) { + results.addAll(getReachingDefinitions( + (Expression) argObj, visited, paramBindings, instanceFieldBindings, depth + 1)); + } + return results; + } + + private static boolean isVoidMethodInvocation(MethodInvocation mi) { + IMethodBinding binding = mi.resolveMethodBinding(); + return binding != null && binding.getReturnType() != null && "void".equals(binding.getReturnType().getName()); + } + private void collectSideEffectExpressions( Block body, List results, diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/PipelineRefactorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/PipelineRefactorTest.java index c2f56b1..e1b6bc8 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/PipelineRefactorTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/pipeline/PipelineRefactorTest.java @@ -91,6 +91,8 @@ class PipelineRefactorTest { void shouldRecognizeReactiveReceiversAndFactoryMethods() { assertThat(LibraryUnwrapRegistry.isUnwrapReceiverType("Mono")).isTrue(); assertThat(LibraryUnwrapRegistry.isUnwrapMethodName("just")).isTrue(); + assertThat(LibraryUnwrapRegistry.isReactiveFactoryMethod("withPayload")).isTrue(); + assertThat(LibraryUnwrapRegistry.isReactiveTransformMethod("flatMap")).isTrue(); assertThat(LibraryUnwrapRegistry.shouldStopUnwrap("buildPayload")).isTrue(); assertThat(LibraryUnwrapRegistry.isEventSetterMethodName("eventType")).isTrue(); } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java index 9df1b8e..1a95c86 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstantResolverTest.java @@ -647,8 +647,7 @@ public class ConstantResolverTest { MethodInvocation getCall = (MethodInvocation) ((VariableDeclarationFragment) fd.fragments().get(0)).getInitializer(); ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null); - click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath( - List.of("com.example.ApiController.dispatch")); + context.setAnalysisCallPath(List.of("com.example.ApiController.dispatch")); try { ConstantResolver resolver = new ConstantResolver(); SimpleName routesName = (SimpleName) getCall.getExpression(); @@ -660,7 +659,7 @@ public class ConstantResolverTest { extractor.extractConstantsFromExpression(getCall, constants); assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP"); } finally { - click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath(); + context.clearAnalysisCallPath(); } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteMatcherTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteMatcherTest.java new file mode 100644 index 0000000..bd24783 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteMatcherTest.java @@ -0,0 +1,100 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.LambdaExpression; +import org.eclipse.jdt.core.dom.MethodInvocation; +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.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class CallSiteMatcherTest { + + @Test + void shouldMatchCallSiteArgumentToSpecificEdgeWhenMultipleCallsExist(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("App.java"), """ + package com.example; + import java.util.function.Supplier; + public enum OrderCommand { PAY, SHIP } + public class OrderController { + private final OrderService service; + public OrderController(OrderService service) { this.service = service; } + public void both() { + service.fireEvent(() -> OrderCommand.PAY); + service.fireEvent(() -> OrderCommand.SHIP); + } + } + class OrderService { + void fireEvent(Supplier provider) {} + } + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + CallEdge payEdge = new CallEdge( + "com.example.OrderService.fireEvent", + List.of("() -> OrderCommand.PAY")); + CallEdge shipEdge = new CallEdge( + "com.example.OrderService.fireEvent", + List.of("() -> OrderCommand.SHIP")); + + MethodInvocation payInvocation = CallSiteMatcher.findMatchingInvocation( + "com.example.OrderController.both", + "com.example.OrderService.fireEvent", + payEdge, + context); + MethodInvocation shipInvocation = CallSiteMatcher.findMatchingInvocation( + "com.example.OrderController.both", + "com.example.OrderService.fireEvent", + shipEdge, + context); + + assertThat(payInvocation).isNotNull(); + assertThat(shipInvocation).isNotNull(); + assertThat(payInvocation).isNotSameAs(shipInvocation); + assertThat(payInvocation.arguments().get(0)).isInstanceOf(LambdaExpression.class); + assertThat(shipInvocation.arguments().get(0)).isInstanceOf(LambdaExpression.class); + + VariableTracer tracer = new VariableTracer(context, context.getConstantResolver()); + tracer.setTypeResolver(new TypeResolver(context)); + tracer.setConstantExtractor(new ConstantExtractor(context, context.getConstantResolver(), null)); + + assertThat(tracer.resolveConstantsFromCallSiteArgument( + "com.example.OrderController.both", + "com.example.OrderService.fireEvent", + 0, + payEdge)).containsExactly("OrderCommand.PAY"); + assertThat(tracer.resolveConstantsFromCallSiteArgument( + "com.example.OrderController.both", + "com.example.OrderService.fireEvent", + 0, + shipEdge)).containsExactly("OrderCommand.SHIP"); + } + + @Test + void shouldSelectEdgeUsingPathBindings(@TempDir Path tempDir) throws IOException { + CallEdge payEdge = new CallEdge( + "com.example.OrderService.fireEvent", + List.of("() -> OrderCommand.PAY")); + CallEdge shipEdge = new CallEdge( + "com.example.OrderService.fireEvent", + List.of("() -> OrderCommand.SHIP")); + + CallEdge selected = CallSiteMatcher.selectEdge( + "com.example.OrderController.both", + "com.example.OrderService.fireEvent", + List.of(payEdge, shipEdge), + Map.of("event", "OrderCommand.SHIP")); + + assertThat(selected).isSameAs(shipEdge); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DataFlowGetterEnablesMoreTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DataFlowGetterEnablesMoreTest.java new file mode 100644 index 0000000..0e93efe --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/DataFlowGetterEnablesMoreTest.java @@ -0,0 +1,277 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.ast.common.AstUtils; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.TypeDeclaration; +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.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Proves {@link JdtDataFlowModel} + anchored source AST resolves getter chains that detached + * re-parsing cannot, without relying on the call-graph accessor fallback tier. + */ +class DataFlowGetterEnablesMoreTest { + + private CodebaseContext context; + private VariableTracer variableTracer; + private ConstantExtractor constantExtractor; + private JdtDataFlowModel dataFlowModel; + + @BeforeEach + void setUp(@TempDir Path tempDir) throws IOException { + context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + ConstantResolver constantResolver = context.getConstantResolver(); + variableTracer = new VariableTracer(context, constantResolver); + constantExtractor = new ConstantExtractor(context, constantResolver, null); + variableTracer.setConstantExtractor(constantExtractor); + dataFlowModel = new JdtDataFlowModel(context); + } + + @Test + void shouldResolveChainedParameterGetterViaAnchoredDataflow(@TempDir Path tempDir) throws IOException { + scan(tempDir, """ + package com.example; + public class OrderController { + public void processOrderEvent(EventWrapper wrapper) { + wrapper.getEvent().getType(); + } + } + class EventWrapper { + public RichEvent getEvent() { return new RichEvent(); } + } + class RichEvent { + public OrderEvents getType() { return OrderEvents.PAY; } + } + enum OrderEvents { PAY } + """); + + String methodFqn = "com.example.OrderController.processOrderEvent"; + String expressionText = "wrapper.getEvent().getType()"; + + assertThat(resolveViaDataflow(expressionText, methodFqn)) + .anyMatch(value -> value.contains("PAY")); + + assertThat(resolveViaDetachedParse(expressionText)) + .isEmpty(); + } + + @Test + void shouldResolveSuperGetterFieldViaAnchoredDataflow(@TempDir Path tempDir) throws IOException { + scan(tempDir, """ + package com.example; + class ChildController extends ParentController { + void process() { + super.getEvent(); + } + } + class ParentController { + private OrderEvent event = OrderEvent.PAY; + OrderEvent getEvent() { return event; } + } + enum OrderEvent { PAY, SHIP } + """); + + String methodFqn = "com.example.ChildController.process"; + String expressionText = "super.getEvent()"; + + assertThat(resolveViaDataflow(expressionText, methodFqn)) + .anyMatch(value -> value.contains("PAY")); + } + + @Test + void shouldResolveDeepFieldBackedChainViaAnchoredDataflow(@TempDir Path tempDir) throws IOException { + scan(tempDir, """ + package com.example; + class CentralDispatcher { + void fire(Order order) { + order.getPayload().getEvent().getCode(); + } + } + class Order { + private Payload payload = new Payload(); + Payload getPayload() { return payload; } + } + class Payload { + private Event event = new Event(); + Event getEvent() { return event; } + } + class Event { + private OrderEvent code = OrderEvent.PAY; + OrderEvent getCode() { return code; } + } + enum OrderEvent { PAY, SHIP } + """); + + String methodFqn = "com.example.CentralDispatcher.fire"; + String expressionText = "order.getPayload().getEvent().getCode()"; + + assertThat(resolveViaDataflow(expressionText, methodFqn)) + .anyMatch(value -> value.contains("PAY")); + } + + @Test + void shouldResolveInterfaceMiddleHopViaAnchoredDataflow(@TempDir Path tempDir) throws IOException { + scan(tempDir, """ + package com.example; + class ApiController { + Outer outer; + void pay() { + outer.getInner().getPayload().getEvent(); + } + } + interface Inner { + Payload getPayload(); + } + class InnerImpl implements Inner { + private Payload payload = new Payload(); + public Payload getPayload() { return payload; } + } + class Outer { + public Inner getInner() { return new InnerImpl(); } + } + class Payload { + private OrderEvent event = OrderEvent.PAY; + public OrderEvent getEvent() { return event; } + } + enum OrderEvent { PAY, SHIP } + """); + + String methodFqn = "com.example.ApiController.pay"; + String expressionText = "outer.getInner().getPayload().getEvent()"; + + assertThat(resolveViaDataflow(expressionText, methodFqn)) + .anyMatch(value -> value.contains("PAY")); + } + + @Test + void shouldResolveAnonymousClassGetterViaAnchoredDataflow(@TempDir Path tempDir) throws IOException { + scan(tempDir, """ + package com.example; + class Runner { + void run() { + Handler handler = new Handler() { + public OrderEvent getEvent() { return OrderEvent.PAY; } + }; + handler.getEvent(); + } + } + interface Handler { + OrderEvent getEvent(); + } + enum OrderEvent { PAY, SHIP } + """); + + String methodFqn = "com.example.Runner.run"; + String expressionText = "handler.getEvent()"; + + assertThat(resolveViaDataflow(expressionText, methodFqn)) + .anyMatch(value -> value.contains("PAY")); + } + + @Test + void shouldResolveSetterBeforeGetterOnParameterViaDataflow(@TempDir Path tempDir) throws IOException { + scan(tempDir, """ + package com.example; + class Runner { + static class Payload { + private String event = "DEFAULT"; + public void setEvent(String event) { this.event = event; } + public String getEvent() { return event; } + } + void run(Payload payload) { + payload.setEvent("PAY"); + payload.getEvent(); + } + } + """); + + String methodFqn = "com.example.Runner.run"; + String expressionText = "payload.getEvent()"; + + assertThat(resolveViaDataflow(expressionText, methodFqn)) + .anyMatch(value -> value.contains("PAY")); + } + + @Test + void shouldResolveChainedGetterViaJdtDataFlowModelOnAnchoredAst(@TempDir Path tempDir) throws IOException { + scan(tempDir, """ + package com.example; + public class Runner { + public void run(EventWrapper wrapper) { + OrderEvents value = wrapper.getEvent().getType(); + } + } + class EventWrapper { + public RichEvent getEvent() { return new RichEvent(); } + } + class RichEvent { + public OrderEvents getType() { return OrderEvents.PAY; } + } + enum OrderEvents { PAY } + """); + + MethodInvocation chain = findMethodInvocation( + "com.example.Runner", "run", "wrapper.getEvent().getType()"); + assertThat(chain).isNotNull(); + + String resolved = dataFlowModel.resolveValue(chain, context); + assertThat(resolved).contains("PAY"); + + Expression orphan = AstUtils.parseExpression("wrapper.getEvent().getType()"); + assertThat(dataFlowModel.resolveValue(orphan, context)) + .isEqualTo("wrapper.getEvent().getType()"); + } + + private List resolveViaDataflow(String expressionText, String methodFqn) { + List searchMethods = List.of(methodFqn); + List callPath = List.of(methodFqn); + return variableTracer.resolveConstantsFromSourceText(expressionText, searchMethods, callPath); + } + + private List resolveViaDetachedParse(String expressionText) { + Expression orphan = AstUtils.parseExpression(expressionText); + if (orphan == null) { + return List.of(); + } + List constants = new ArrayList<>(); + for (Expression def : dataFlowModel.getReachingDefinitions(orphan)) { + if (def != null && !def.toString().equals(orphan.toString())) { + constantExtractor.extractConstantsFromExpression(def, constants); + } + } + return constants; + } + + private MethodInvocation findMethodInvocation(String classFqn, String methodName, String expressionText) { + Expression found = AstUtils.findExpressionInMethod(classFqn + "." + methodName, expressionText, context); + return found instanceof MethodInvocation mi ? mi : null; + } + + private void scan(Path tempDir, String source) throws IOException { + Files.writeString(tempDir.resolve("App.java"), source); + context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + ConstantResolver constantResolver = context.getConstantResolver(); + variableTracer = new VariableTracer(context, constantResolver); + constantExtractor = new ConstantExtractor(context, constantResolver, null); + variableTracer.setConstantExtractor(constantExtractor); + dataFlowModel = new JdtDataFlowModel(context); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceDataFlowRegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceDataFlowRegressionTest.java index c425507..b89ca6e 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceDataFlowRegressionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceDataFlowRegressionTest.java @@ -31,8 +31,10 @@ import static org.assertj.core.api.Assertions.assertThat; /** * Regression suite for functional-interface event resolution ({@code Supplier.get()}, - * {@code Runnable.run()}, side-effect lambdas). Must stay on the dataflow pipeline: - * {@link JdtDataFlowModel} → {@link VariableTracer} → {@link PathBindingEvaluator} → call graph. + * {@code Runnable.run()}, {@code Consumer.accept()}, {@code Callable.call()}, side-effect lambdas). + * Must stay on the dataflow pipeline: + * {@link JdtDataFlowModel} → {@link VariableTracer} → {@link PathBindingEvaluator} + * → {@link PathBindingExpressionResolver} → call graph. * Do not reintroduce a parallel {@code CallSiteArgumentResolver} bridge. */ class FunctionalInterfaceDataFlowRegressionTest { @@ -110,6 +112,87 @@ class FunctionalInterfaceDataFlowRegressionTest { Expression initializer = findInitializer(context, "com.example.Runner", "run", "value"); assertThat(model.resolveValue(initializer, context)).isEqualTo("X"); } + + @Test + void shouldResolveEnumFromCallableCallOnLocalLambda(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scan(tempDir, """ + package com.example; + import java.util.concurrent.Callable; + public enum OrderCommand { PAY, SHIP } + public class Hooks { + OrderCommand read() throws Exception { + Callable callable = () -> OrderCommand.PAY; + return callable.call(); + } + } + """); + + MethodInvocation call = findReturnMethodInvocation(context, "com.example.Hooks", "read"); + assertThat(call.getName().getIdentifier()).isEqualTo("call"); + + DataFlowModel model = new JdtDataFlowModel(context); + List constants = new ArrayList<>(); + for (Expression def : model.getReachingDefinitions(call)) { + new ConstantExtractor(context, context.getConstantResolver(), null) + .extractConstantsFromExpression(def, constants); + } + assertThat(constants).anyMatch(c -> c.contains("PAY")); + } + + @Test + void shouldResolveEnumFromConsumerAcceptSideEffectLambda(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scan(tempDir, """ + package com.example; + import java.util.function.Consumer; + public enum OrderCommand { PAY, SHIP } + public class Hooks { + void run() { + Consumer handler = ignored -> sendEvent(OrderCommand.PAY); + handler.accept("trigger"); + } + void sendEvent(OrderCommand event) {} + } + """); + + MethodInvocation acceptCall = findSideEffectCall(context, "com.example.Hooks", "run"); + assertThat(acceptCall.getName().getIdentifier()).isEqualTo("accept"); + + DataFlowModel model = new JdtDataFlowModel(context); + List constants = new ArrayList<>(); + for (Expression def : model.getReachingDefinitions(acceptCall)) { + new ConstantExtractor(context, context.getConstantResolver(), null) + .extractConstantsFromExpression(def, constants); + } + assertThat(constants).anyMatch(c -> c.contains("PAY")); + } + + @Test + void shouldResolveEnumThroughReactiveTransformLambdaBinding(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scan(tempDir, """ + package com.example; + public enum OrderCommand { PAY, SHIP } + public class Hooks { + OrderCommand read() { + return Mono.just(OrderCommand.PAY).map(x -> x); + } + } + class Mono { + static Mono just(Object o) { return new Mono(); } + OrderCommand map(java.util.function.Function fn) { return OrderCommand.PAY; } + } + """); + + MethodInvocation mapCall = findReturnMethodInvocation(context, "com.example.Hooks", "read"); + assertThat(mapCall.getName().getIdentifier()).isEqualTo("map"); + + DataFlowModel model = new JdtDataFlowModel(context); + List constants = new ArrayList<>(); + for (Expression def : model.getReachingDefinitions(mapCall)) { + new ConstantExtractor(context, context.getConstantResolver(), null) + .extractConstantsFromExpression(def, constants); + } + assertThat(constants).anyMatch(c -> c.contains("PAY")); + } } @Nested @@ -282,6 +365,43 @@ class FunctionalInterfaceDataFlowRegressionTest { assertSinglePayEvent(chains(project, "com.example.service.AbstractOrderService", "sendEvent")); } + @Test + void shouldLinkConsumerAcceptSideEffectLambda(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scan(tempDir, """ + package com.example; + import java.util.function.Consumer; + public enum OrderCommand { PAY, SHIP } + public class OrderService { + private final EventDispatcher dispatcher = new EventDispatcher(); + public void processOrder() { + dispatcher.dispatch("order", ignored -> fireEvent()); + } + public void fireEvent() { sendEvent(OrderCommand.PAY); } + void sendEvent(OrderCommand event) {} + } + class EventDispatcher { + public void dispatch(String order, Consumer callback) { + callback.accept("trigger"); + } + } + """); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + List chains = engine.findChains( + List.of(EntryPoint.builder() + .className("com.example.OrderService") + .methodName("processOrder") + .build()), + List.of(TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("sendEvent") + .event("event") + .build())); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactly("OrderCommand.PAY"); + } + @Test void shouldReachFireSiteThroughRunnableRunHop(@TempDir Path tempDir) throws IOException { RunnableProject project = RunnableProject.withExecutor(tempDir); @@ -504,4 +624,47 @@ class FunctionalInterfaceDataFlowRegressionTest { } } } + + @Nested + class GetterChainDataFlowLayer { + + @Test + void shouldResolveChainedGetterThroughDataflowBeforeAccessorFallback(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scan(tempDir, """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(EventWrapper wrapper) { + service.updateOrderState(wrapper.getEvent().getType()); + } + } + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + class EventWrapper { + public RichEvent getEvent() { return new RichEvent(); } + } + class RichEvent { + public OrderEvents getType() { return OrderEvents.PAY; } + } + enum OrderEvents { PAY } + """); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + List chains = engine.findChains( + List.of(EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build()), + List.of(TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build())); + + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("OrderEvents.PAY"); + } + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypesTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypesTest.java index b6afad5..0bb2c15 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypesTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypesTest.java @@ -12,6 +12,24 @@ class FunctionalInterfaceTypesTest { .isTrue(); } + @Test + void shouldRecognizeConsumerSamMethod() { + assertThat(FunctionalInterfaceTypes.isFunctionalSamMethod("java.util.function.Consumer.accept")) + .isTrue(); + } + + @Test + void shouldRecognizeRunnableSamMethod() { + assertThat(FunctionalInterfaceTypes.isFunctionalSamMethod("java.lang.Runnable.run")) + .isTrue(); + } + + @Test + void shouldRejectMapGetAsSamMethod() { + assertThat(FunctionalInterfaceTypes.isFunctionalSamMethod("java.util.Map.get")) + .isFalse(); + } + @Test void shouldAcceptLambdaIntoSupplierParameter() { assertThat(FunctionalInterfaceTypes.isProvablyResolvedCallSiteArgument( diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolverTest.java new file mode 100644 index 0000000..674986b --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingExpressionResolverTest.java @@ -0,0 +1,71 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PathBindingExpressionResolverTest { + + private CodebaseContext context; + private PathBindingExpressionResolver resolver; + + @BeforeEach + void setUp(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + enum DomainCommand { ORDER_PAY, ORDER_SHIP } + class CommandGateway { + private final StringCommandMapper mapper = new StringCommandMapper(); + void executeViaMapper(String commandKey) { + DomainCommand command = mapper.fromString(commandKey); + dispatch(command); + } + void dispatch(DomainCommand command) {} + } + class StringCommandMapper { + DomainCommand fromString(String commandKey) { + return switch (commandKey) { + case "order.pay" -> DomainCommand.ORDER_PAY; + case "order.ship" -> DomainCommand.ORDER_SHIP; + default -> throw new IllegalArgumentException(commandKey); + }; + } + } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + VariableTracer variableTracer = new VariableTracer(context, context.getConstantResolver()); + resolver = new PathBindingExpressionResolver(context, variableTracer, context.getConstantResolver()); + } + + @Test + void shouldResolveMapperFromStringViaDataflowWithNamedBindings() { + String resolved = resolver.resolveMethodCallFromSource( + "com.example.CommandGateway.executeViaMapper", + "command", + Map.of("commandKey", "order.pay")); + + assertThat(resolved).isEqualTo("DomainCommand.ORDER_PAY"); + } + + @Test + void shouldResolveAlternateSwitchArmViaDataflowNamedBindings() { + String resolved = resolver.resolveMethodCallFromSource( + "com.example.CommandGateway.executeViaMapper", + "command", + Map.of("commandKey", "order.ship")); + + assertThat(resolved).isEqualTo("DomainCommand.ORDER_SHIP"); + } +}