diff --git a/state_machine_exporter/build.gradle b/state_machine_exporter/build.gradle index 81015bb..01976ce 100644 --- a/state_machine_exporter/build.gradle +++ b/state_machine_exporter/build.gradle @@ -56,6 +56,7 @@ dependencies { tasks.named('test') { useJUnitPlatform() + maxParallelForks = Math.max(1, Runtime.runtime.availableProcessors().intdiv(2) ?: 1) systemProperty "updateGolden", System.getProperty("updateGolden") } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java new file mode 100644 index 0000000..527ff6c --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorFieldResolver.java @@ -0,0 +1,75 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.FieldDeclaration; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; + +/** + * Resolves constant values for fields referenced by indexed trivial accessors. + */ +public final class AccessorFieldResolver { + + private AccessorFieldResolver() { + } + + public static List resolveFieldConstants( + AccessorSummary accessor, + CodebaseContext context, + ConstructorAnalyzer constructorAnalyzer, + CompilationUnit contextCu, + Set visited, + BiConsumer> constantExtractor) { + if (accessor == null || !accessor.isGetter() || context == null) { + return List.of(); + } + + TypeDeclaration typeDeclaration = contextCu != null + ? context.getTypeDeclaration(accessor.declaringFqn(), contextCu) + : null; + if (typeDeclaration == null) { + typeDeclaration = context.getTypeDeclaration(accessor.declaringFqn()); + } + if (typeDeclaration == null) { + return List.of(); + } + + List constants = new ArrayList<>(); + if (constructorAnalyzer != null) { + constants.addAll(constructorAnalyzer.traceFieldInConstructors( + typeDeclaration, accessor.fieldName(), context, visited)); + } + + if (constants.isEmpty()) { + collectFieldInitializerConstants( + typeDeclaration, accessor.fieldName(), constantExtractor, constants); + } + return constants; + } + + private static void collectFieldInitializerConstants( + TypeDeclaration typeDeclaration, + String fieldName, + BiConsumer> constantExtractor, + List constants) { + if (constantExtractor == null) { + return; + } + for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) { + for (Object fragmentObj : fieldDeclaration.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment + && fragment.getName().getIdentifier().equals(fieldName) + && fragment.getInitializer() != null) { + constantExtractor.accept(fragment.getInitializer(), constants); + } + } + } + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorIndex.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorIndex.java new file mode 100644 index 0000000..84d364a --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorIndex.java @@ -0,0 +1,75 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public class AccessorIndex { + + private static final AccessorIndex EMPTY = new AccessorIndex(Map.of(), 0, 0, 0); + + private final Map> byOwnerAndMethod; + private final int typeCount; + private final int trivialCount; + private final int blockedCount; + + public AccessorIndex( + Map> byOwnerAndMethod, + int typeCount, + int trivialCount, + int blockedCount) { + this.byOwnerAndMethod = Collections.unmodifiableMap(deepCopy(byOwnerAndMethod)); + this.typeCount = typeCount; + this.trivialCount = trivialCount; + this.blockedCount = blockedCount; + } + + public static AccessorIndex empty() { + return EMPTY; + } + + public Optional lookup(String ownerFqn, String methodName) { + if (ownerFqn == null || methodName == null) { + return Optional.empty(); + } + Map methods = byOwnerAndMethod.get(ownerFqn); + if (methods == null) { + return Optional.empty(); + } + return Optional.ofNullable(methods.get(methodName)); + } + + public List accessorsForType(String ownerFqn) { + Map methods = byOwnerAndMethod.get(ownerFqn); + if (methods == null) { + return List.of(); + } + return List.copyOf(methods.values()); + } + + public Map> asMap() { + return byOwnerAndMethod; + } + + public int typeCount() { + return typeCount; + } + + public int trivialCount() { + return trivialCount; + } + + public int blockedCount() { + return blockedCount; + } + + private static Map> deepCopy(Map> source) { + Map> copy = new HashMap<>(); + for (Map.Entry> entry : source.entrySet()) { + copy.put(entry.getKey(), Map.copyOf(entry.getValue())); + } + return copy; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorIndexBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorIndexBuilder.java new file mode 100644 index 0000000..99f371c --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorIndexBuilder.java @@ -0,0 +1,286 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jdt.core.dom.*; + +import java.util.*; + +@Slf4j +public class AccessorIndexBuilder { + + private final TrivialAccessorDetector detector = new TrivialAccessorDetector(); + private CodebaseContext context; + + public AccessorIndex build(CodebaseContext context) { + this.context = context; + Map direct = new HashMap<>(); + + for (CompilationUnit cu : context.getCompilationUnits()) { + String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : ""; + for (Object typeObj : cu.types()) { + if (typeObj instanceof TypeDeclaration td) { + indexTypeDeclaration(td, direct); + } else if (typeObj instanceof RecordDeclaration rd) { + indexRecord(rd, packageName, direct); + } else if (typeObj instanceof EnumDeclaration ed) { + indexEnum(ed, packageName, direct); + } + } + } + + int blocked = direct.values().stream().mapToInt(state -> state.blockedMethods.size()).sum(); + + Map> effective = new HashMap<>(); + int trivial = 0; + for (String ownerFqn : direct.keySet()) { + Map merged = mergeEffective(ownerFqn, direct, effective, new HashSet<>()); + if (!merged.isEmpty()) { + effective.put(ownerFqn, merged); + trivial += merged.size(); + } + } + + log.info( + "Built accessor index: {} trivial accessors across {} types ({} blocked overrides)", + trivial, + effective.size(), + blocked); + + this.context = null; + return new AccessorIndex(effective, effective.size(), trivial, blocked); + } + + private void indexTypeDeclaration(TypeDeclaration td, Map direct) { + String ownerFqn = context.getFqn(td); + DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors()); + for (MethodDeclaration md : td.getMethods()) { + classifyMethod(ownerFqn, md, fieldName -> findFieldDeclaringFqn(td, fieldName), state); + } + + for (TypeDeclaration nested : td.getTypes()) { + indexTypeDeclaration(nested, direct); + } + } + + private void indexEnum(EnumDeclaration ed, String parentFqn, Map direct) { + String ownerFqn = parentFqn.isEmpty() ? ed.getName().getIdentifier() : parentFqn + "." + ed.getName().getIdentifier(); + DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors()); + + for (Object decl : ed.bodyDeclarations()) { + if (decl instanceof MethodDeclaration md) { + classifyMethod(ownerFqn, md, fieldName -> findEnumFieldDeclaringFqn(ed, ownerFqn, fieldName), state); + } else if (decl instanceof TypeDeclaration nestedTd) { + indexTypeDeclaration(nestedTd, direct); + } else if (decl instanceof RecordDeclaration nestedRd) { + indexRecord(nestedRd, ownerFqn, direct); + } + } + } + + private void indexRecord(RecordDeclaration rd, String parentFqn, Map direct) { + String ownerFqn = parentFqn.isEmpty() ? rd.getName().getIdentifier() : parentFqn + "." + rd.getName().getIdentifier(); + DirectTypeAccessors state = direct.computeIfAbsent(ownerFqn, k -> new DirectTypeAccessors()); + + for (Object compObj : rd.recordComponents()) { + String componentName = recordComponentName(compObj); + if (componentName == null) { + continue; + } + detector.detectRecordComponent(ownerFqn, componentName).ifPresent(summary -> + state.trivial.put(summary.methodName(), summary)); + } + + TrivialAccessorDetector.FieldLookup recordFieldLookup = + fieldName -> findRecordFieldDeclaringFqn(rd, ownerFqn, fieldName); + + for (Object declObj : rd.bodyDeclarations()) { + if (declObj instanceof MethodDeclaration md) { + String methodName = md.getName().getIdentifier(); + AccessorSummary existing = state.trivial.get(methodName); + if (existing != null && existing.kind() == AccessorKind.RECORD_COMPONENT) { + continue; + } + classifyMethod(ownerFqn, md, recordFieldLookup, state); + } else if (declObj instanceof TypeDeclaration nestedTd) { + indexTypeDeclaration(nestedTd, direct); + } else if (declObj instanceof RecordDeclaration nestedRd) { + indexRecord(nestedRd, ownerFqn, direct); + } + } + } + + private void classifyMethod( + String ownerFqn, + MethodDeclaration md, + TrivialAccessorDetector.FieldLookup fieldLookup, + DirectTypeAccessors state) { + if (md.getBody() == null) { + return; + } + + String methodName = md.getName().getIdentifier(); + Optional summary = detector.detect(ownerFqn, md, fieldLookup); + applyClassification(state, methodName, summary); + } + + private void applyClassification(DirectTypeAccessors state, String methodName, Optional summary) { + if (summary.isPresent()) { + state.trivial.put(methodName, summary.get()); + state.blockedMethods.remove(methodName); + return; + } + + if (looksLikeAccessorMethod(methodName)) { + state.blockedMethods.add(methodName); + state.trivial.remove(methodName); + } + } + + private boolean looksLikeAccessorMethod(String methodName) { + return methodName.startsWith("get") + || methodName.startsWith("set") + || methodName.startsWith("is"); + } + + private Map mergeEffective( + String ownerFqn, + Map direct, + Map> cache, + Set visiting) { + ownerFqn = resolveTypeFqn(ownerFqn); + if (ownerFqn == null) { + return Map.of(); + } + if (cache.containsKey(ownerFqn)) { + return cache.get(ownerFqn); + } + if (!visiting.add(ownerFqn)) { + return Map.of(); + } + + Map merged = new HashMap<>(); + TypeDeclaration td = context.getTypeDeclaration(ownerFqn); + if (td != null) { + String superFqn = resolveTypeFqn(context.getSuperclassFqn(td)); + if (superFqn != null && !"java.lang.Object".equals(superFqn)) { + for (Map.Entry inherited : mergeEffective(superFqn, direct, cache, visiting).entrySet()) { + merged.put(inherited.getKey(), rebindOwner(inherited.getValue(), ownerFqn)); + } + } + } + + DirectTypeAccessors own = direct.get(ownerFqn); + if (own != null) { + own.blockedMethods.forEach(merged::remove); + merged.putAll(own.trivial); + } + + visiting.remove(ownerFqn); + cache.put(ownerFqn, Map.copyOf(merged)); + return merged; + } + + private AccessorSummary rebindOwner(AccessorSummary summary, String ownerFqn) { + if (summary.ownerFqn().equals(ownerFqn)) { + return summary; + } + return new AccessorSummary( + ownerFqn, + summary.methodName(), + summary.kind(), + summary.fieldName(), + summary.paramName(), + summary.declaringFqn(), + summary.returnsThis() + ); + } + + private String resolveTypeFqn(String typeName) { + if (typeName == null || typeName.isEmpty()) { + return null; + } + if (typeName.contains(".")) { + return typeName; + } + TypeDeclaration td = context.getTypeDeclaration(typeName); + return td != null ? context.getFqn(td) : typeName; + } + + private static final class DirectTypeAccessors { + private final Map trivial = new HashMap<>(); + private final Set blockedMethods = new HashSet<>(); + } + + private Optional findFieldDeclaringFqn(TypeDeclaration td, String fieldName) { + return findFieldDeclaringFqn(td, fieldName, new HashSet<>()); + } + + private Optional findFieldDeclaringFqn(TypeDeclaration td, String fieldName, Set visited) { + if (td == null) { + return Optional.empty(); + } + String ownerFqn = context.getFqn(td); + if (!visited.add(ownerFqn)) { + return Optional.empty(); + } + + for (FieldDeclaration fd : td.getFields()) { + for (Object fragObj : fd.fragments()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(fieldName)) { + return Optional.of(ownerFqn); + } + } + } + + String superFqn = context.getSuperclassFqn(td); + if (superFqn != null && !"java.lang.Object".equals(superFqn)) { + TypeDeclaration superTd = context.getTypeDeclaration(superFqn); + if (superTd != null) { + return findFieldDeclaringFqn(superTd, fieldName, visited); + } + } + return Optional.empty(); + } + + private Optional findEnumFieldDeclaringFqn(EnumDeclaration ed, String ownerFqn, String fieldName) { + for (Object decl : ed.bodyDeclarations()) { + if (decl instanceof FieldDeclaration fd) { + for (Object fragObj : fd.fragments()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(fieldName)) { + return Optional.of(ownerFqn); + } + } + } + } + return Optional.empty(); + } + + private Optional findRecordFieldDeclaringFqn(RecordDeclaration rd, String ownerFqn, String fieldName) { + for (Object compObj : rd.recordComponents()) { + String componentName = recordComponentName(compObj); + if (fieldName.equals(componentName)) { + return Optional.of(ownerFqn); + } + } + return Optional.empty(); + } + + private String recordComponentName(Object compObj) { + if (compObj instanceof SingleVariableDeclaration svd) { + return svd.getName().getIdentifier(); + } + try { + java.lang.reflect.Method getNameMethod = compObj.getClass().getMethod("getName"); + Object nameObj = getNameMethod.invoke(compObj); + if (nameObj instanceof SimpleName sn) { + return sn.getIdentifier(); + } + } catch (ReflectiveOperationException ignored) { + // fall through + } + return null; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorInlining.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorInlining.java new file mode 100644 index 0000000..eaf681f --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorInlining.java @@ -0,0 +1,93 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.ast.common.AstUtils; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.ClassInstanceCreation; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.IMethodBinding; +import org.eclipse.jdt.core.dom.ITypeBinding; +import org.eclipse.jdt.core.dom.MethodInvocation; + +import java.util.List; +import java.util.Optional; + +public final class AccessorInlining { + + private AccessorInlining() { + } + + public static Optional lookupAccessor( + CodebaseContext context, + MethodInvocation mi, + List receiverDefinitions) { + if (context == null || mi == null) { + return Optional.empty(); + } + + String methodName = mi.getName().getIdentifier(); + if (isLikelyNonBeanGet(methodName, mi)) { + return Optional.empty(); + } + + if (receiverDefinitions != null) { + for (Expression receiverDef : receiverDefinitions) { + if (receiverDef instanceof ClassInstanceCreation cic) { + Optional concrete = lookupOnType(context, resolveTypeFqn(cic), methodName); + if (concrete.isPresent()) { + return concrete; + } + } + } + } + + IMethodBinding methodBinding = mi.resolveMethodBinding(); + if (methodBinding != null && methodBinding.getDeclaringClass() != null) { + ITypeBinding declaringClass = methodBinding.getDeclaringClass().getErasure(); + Optional declared = lookupOnType(context, declaringClass.getQualifiedName(), methodName); + if (declared.isPresent()) { + return declared; + } + } + + return Optional.empty(); + } + + public static Optional lookupAccessor(CodebaseContext context, MethodInvocation mi) { + return lookupAccessor(context, mi, List.of()); + } + + private static Optional lookupOnType(CodebaseContext context, String ownerFqn, String methodName) { + if (ownerFqn == null || ownerFqn.isBlank() || ownerFqn.startsWith("java.")) { + return Optional.empty(); + } + return context.getAccessorIndex().lookup(ownerFqn, methodName); + } + + public static String resolveTypeFqn(ClassInstanceCreation cic) { + if (cic == null) { + return null; + } + if (cic.resolveConstructorBinding() != null && cic.resolveConstructorBinding().getDeclaringClass() != null) { + return cic.resolveConstructorBinding().getDeclaringClass().getErasure().getQualifiedName(); + } + if (cic.resolveTypeBinding() != null) { + return cic.resolveTypeBinding().getErasure().getQualifiedName(); + } + return AstUtils.extractSimpleTypeName(cic.getType()); + } + + private static boolean isLikelyNonBeanGet(String methodName, MethodInvocation mi) { + if (!"get".equals(methodName) && !"getOrDefault".equals(methodName)) { + return false; + } + if (mi.arguments().size() != 1) { + return false; + } + IMethodBinding methodBinding = mi.resolveMethodBinding(); + if (methodBinding == null || methodBinding.getDeclaringClass() == null) { + return false; + } + String ownerFqn = methodBinding.getDeclaringClass().getErasure().getQualifiedName(); + return ownerFqn != null && ownerFqn.startsWith("java.util"); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorKind.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorKind.java new file mode 100644 index 0000000..a4647bb --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorKind.java @@ -0,0 +1,9 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +public enum AccessorKind { + GETTER, + BOOLEAN_GETTER, + RECORD_COMPONENT, + SETTER, + FLUENT_SETTER +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorSummary.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorSummary.java new file mode 100644 index 0000000..00fa823 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/AccessorSummary.java @@ -0,0 +1,21 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +public record AccessorSummary( + String ownerFqn, + String methodName, + AccessorKind kind, + String fieldName, + String paramName, + String declaringFqn, + boolean returnsThis +) { + public boolean isGetter() { + return kind == AccessorKind.GETTER + || kind == AccessorKind.BOOLEAN_GETTER + || kind == AccessorKind.RECORD_COMPONENT; + } + + public boolean isSetter() { + return kind == AccessorKind.SETTER || kind == AccessorKind.FLUENT_SETTER; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetector.java new file mode 100644 index 0000000..4001933 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetector.java @@ -0,0 +1,256 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import org.eclipse.jdt.core.dom.*; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +/** + * Detects trivial JavaBean-style getters and setters whose bodies only read or write a single field. + */ +public class TrivialAccessorDetector { + + @FunctionalInterface + public interface FieldLookup extends Function> { + } + + public Optional detect(String ownerFqn, MethodDeclaration method, FieldLookup fieldLookup) { + if (ownerFqn == null || method == null || method.getBody() == null || fieldLookup == null) { + return Optional.empty(); + } + + String methodName = method.getName().getIdentifier(); + if (method.isConstructor()) { + return Optional.empty(); + } + + Optional getterField = propertyNameForGetter(methodName, method); + if (getterField.isPresent()) { + return detectGetter(ownerFqn, method, methodName, getterField.get(), fieldLookup); + } + + Optional setterField = propertyNameForSetter(methodName); + if (setterField.isPresent() && !method.parameters().isEmpty()) { + return detectSetter(ownerFqn, method, methodName, setterField.get(), fieldLookup); + } + + return Optional.empty(); + } + + public Optional detectRecordComponent(String ownerFqn, String componentName) { + if (ownerFqn == null || componentName == null || componentName.isEmpty()) { + return Optional.empty(); + } + return Optional.of(new AccessorSummary( + ownerFqn, + componentName, + AccessorKind.RECORD_COMPONENT, + componentName, + null, + ownerFqn, + false + )); + } + + private Optional detectGetter( + String ownerFqn, + MethodDeclaration method, + String methodName, + String fieldName, + FieldLookup fieldLookup) { + if (!isTrivialGetterBody(method.getBody(), fieldName)) { + return Optional.empty(); + } + Optional declaringFqn = fieldLookup.apply(fieldName); + if (declaringFqn.isEmpty()) { + return Optional.empty(); + } + + AccessorKind kind = methodName.startsWith("is") ? AccessorKind.BOOLEAN_GETTER : AccessorKind.GETTER; + return Optional.of(new AccessorSummary( + ownerFqn, + methodName, + kind, + fieldName, + null, + declaringFqn.get(), + false + )); + } + + private Optional detectSetter( + String ownerFqn, + MethodDeclaration method, + String methodName, + String fieldName, + FieldLookup fieldLookup) { + SingleVariableDeclaration param = (SingleVariableDeclaration) method.parameters().get(0); + String paramName = param.getName().getIdentifier(); + + boolean fluent = isTrivialFluentSetterBody(method.getBody(), fieldName, paramName); + boolean voidSetter = isVoidReturn(method) && isTrivialVoidSetterBody(method.getBody(), fieldName, paramName); + if (!fluent && !voidSetter) { + return Optional.empty(); + } + + Optional declaringFqn = fieldLookup.apply(fieldName); + if (declaringFqn.isEmpty()) { + return Optional.empty(); + } + + AccessorKind kind = fluent ? AccessorKind.FLUENT_SETTER : AccessorKind.SETTER; + return Optional.of(new AccessorSummary( + ownerFqn, + methodName, + kind, + fieldName, + paramName, + declaringFqn.get(), + fluent + )); + } + + private Optional propertyNameForGetter(String methodName, MethodDeclaration method) { + if (methodName.startsWith("get") && methodName.length() > 3) { + return Optional.of(decapitalize(methodName.substring(3))); + } + if (methodName.startsWith("is") && methodName.length() > 2 && isBooleanReturn(method)) { + return Optional.of(decapitalize(methodName.substring(2))); + } + return Optional.empty(); + } + + private Optional propertyNameForSetter(String methodName) { + if (methodName.startsWith("set") && methodName.length() > 3) { + return Optional.of(decapitalize(methodName.substring(3))); + } + return Optional.empty(); + } + + private boolean isBooleanReturn(MethodDeclaration method) { + return "boolean".equals(method.getReturnType2().toString()) + || "Boolean".equals(method.getReturnType2().toString()); + } + + private boolean isVoidReturn(MethodDeclaration method) { + return method.getReturnType2() instanceof PrimitiveType pt && pt.getPrimitiveTypeCode() == PrimitiveType.VOID; + } + + public static String decapitalize(String name) { + if (name == null || name.isEmpty()) { + return name; + } + if (name.length() > 1 && Character.isUpperCase(name.charAt(1))) { + return name; + } + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } + + private boolean isTrivialGetterBody(Block body, String fieldName) { + List statements = body.statements(); + if (statements.size() != 1 || !(statements.get(0) instanceof ReturnStatement rs)) { + return false; + } + Expression expr = rs.getExpression(); + if (expr == null) { + return false; + } + return isDirectFieldRead(unwrap(expr), fieldName); + } + + private boolean isTrivialVoidSetterBody(Block body, String fieldName, String paramName) { + List statements = body.statements(); + if (statements.size() != 1) { + return false; + } + return isAssignmentStatement(statements.get(0), fieldName, paramName); + } + + private boolean isTrivialFluentSetterBody(Block body, String fieldName, String paramName) { + List statements = body.statements(); + if (statements.size() != 2) { + return false; + } + if (!isAssignmentStatement(statements.get(0), fieldName, paramName)) { + return false; + } + if (!(statements.get(1) instanceof ReturnStatement rs)) { + return false; + } + Expression ret = unwrap(rs.getExpression()); + return ret instanceof ThisExpression; + } + + private boolean isAssignmentStatement(Object statement, String fieldName, String paramName) { + if (!(statement instanceof ExpressionStatement es)) { + return false; + } + if (!(es.getExpression() instanceof Assignment assignment)) { + return false; + } + if (assignment.getOperator() != Assignment.Operator.ASSIGN) { + return false; + } + if (!isDirectFieldWrite(assignment.getLeftHandSide(), fieldName)) { + return false; + } + Expression rhs = unwrap(assignment.getRightHandSide()); + return rhs instanceof SimpleName sn && sn.getIdentifier().equals(paramName); + } + + private boolean isDirectFieldRead(Expression expr, String fieldName) { + if (containsMethodCall(expr)) { + return false; + } + if (expr instanceof SimpleName sn) { + return sn.getIdentifier().equals(fieldName); + } + if (expr instanceof FieldAccess fa) { + Expression receiver = fa.getExpression(); + if (receiver == null || receiver instanceof ThisExpression) { + return fa.getName().getIdentifier().equals(fieldName); + } + } + return false; + } + + private boolean isDirectFieldWrite(Expression expr, String fieldName) { + if (expr instanceof SimpleName sn) { + return sn.getIdentifier().equals(fieldName); + } + if (expr instanceof FieldAccess fa) { + Expression receiver = fa.getExpression(); + if (receiver == null || receiver instanceof ThisExpression) { + return fa.getName().getIdentifier().equals(fieldName); + } + } + return false; + } + + private boolean containsMethodCall(Expression expr) { + if (expr == null) { + return false; + } + final boolean[] found = {false}; + expr.accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + found[0] = true; + return false; + } + }); + return found[0]; + } + + private Expression unwrap(Expression expr) { + Expression current = expr; + while (current instanceof ParenthesizedExpression pe) { + current = pe.getExpression(); + } + while (current instanceof CastExpression ce) { + current = ce.getExpression(); + } + return current; + } +} 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 bf0cf06..c9ecbe2 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 @@ -1,6 +1,7 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; @@ -1364,6 +1365,25 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (getter == null) { getter = context.findMethodDeclaration(td, getterName, true); } + if ((getterName.startsWith("get") && getterName.length() > 3) + || (getterName.startsWith("is") && getterName.length() > 2) + || context.getAccessorIndex().lookup(context.getFqn(td), getterName).isPresent()) { + Optional indexedGetter = + context.getAccessorIndex().lookup(context.getFqn(td), getterName); + if (indexedGetter.isPresent() && indexedGetter.get().isGetter()) { + fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC( + td, cic, context, this::evaluateMethodCallInConstructor)); + String indexedFieldValue = fieldValues.get(indexedGetter.get().fieldName()); + if (indexedFieldValue != null) { + return Collections.singletonList(indexedFieldValue); + } + List indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants( + indexedGetter.get(), contextCu, new HashSet<>()); + if (!indexedConstants.isEmpty()) { + return indexedConstants; + } + } + } if (getter != null && getter.getBody() != null) { fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor)); String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>()); 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 d824f03..fb6fc45 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 @@ -1,6 +1,8 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.analysis.index.AccessorFieldResolver; +import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import lombok.extern.slf4j.Slf4j; import org.eclipse.jdt.core.dom.*; @@ -287,6 +289,16 @@ public class ConstantExtractor { return Collections.emptyList(); } + Optional indexedAccessor = context.getAccessorIndex().lookup(className, methodName); + if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) { + List indexedConstants = resolveIndexedGetterFieldConstants( + indexedAccessor.get(), contextCu, visited); + if (!indexedConstants.isEmpty()) { + visited.remove(fqn); + return indexedConstants; + } + } + List constants = new ArrayList<>(); TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null; if (tempTd == null) { @@ -391,6 +403,19 @@ public class ConstantExtractor { return constants; } + public List resolveIndexedGetterFieldConstants( + AccessorSummary accessor, + CompilationUnit contextCu, + Set visited) { + return AccessorFieldResolver.resolveFieldConstants( + accessor, + context, + constructorAnalyzer, + contextCu, + visited, + this::extractConstantsFromExpression); + } + public String parseEnumSetElement(String eVal) { return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal; } 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 477522f..5bdfdd7 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 @@ -2,6 +2,8 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; +import click.kamil.springstatemachineexporter.analysis.index.TrivialAccessorDetector; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import org.eclipse.jdt.core.dom.*; import java.util.*; @@ -55,11 +57,47 @@ public class VariableTracer { MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); if (md != null && md.getBody() != null) { final Expression[] setterArg = new Expression[1]; - String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName; + String propName; + String setterMethodName; + String indexedFieldName; + + if (isBeanStyleAccessorName(getterName)) { + propName = propertyNameFromAccessor(getterName); + setterMethodName = "set" + capitalizeProperty(propName); + indexedFieldName = propName; + + String varTypeName = getVariableDeclaredType(methodFqn, varName); + if (varTypeName != null) { + CompilationUnit contextCu = md.getRoot() instanceof CompilationUnit cu ? cu : null; + TypeDeclaration varType = contextCu != null + ? context.getTypeDeclaration(varTypeName, contextCu) + : null; + if (varType == null) { + varType = context.getTypeDeclaration(varTypeName); + } + if (varType != null) { + Optional indexedSetter = context.getAccessorIndex() + .lookup(context.getFqn(varType), setterMethodName); + if (indexedSetter.isPresent() && indexedSetter.get().isSetter()) { + setterMethodName = indexedSetter.get().methodName(); + indexedFieldName = indexedSetter.get().fieldName(); + } + } + } + } else { + propName = getterName.startsWith("get") ? getterName.substring(3) : getterName; + setterMethodName = "set" + propName; + indexedFieldName = propName; + } + + final String resolvedSetterName = setterMethodName; + final String resolvedFieldName = indexedFieldName; md.getBody().accept(new ASTVisitor() { @Override public boolean visit(MethodInvocation node) { - if (node.getName().getIdentifier().equalsIgnoreCase("set" + propName) || node.getName().getIdentifier().equalsIgnoreCase(propName)) { + if (node.getName().getIdentifier().equals(resolvedSetterName) + || node.getName().getIdentifier().equalsIgnoreCase("set" + resolvedFieldName) + || node.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) { if (node.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) { if (!node.arguments().isEmpty()) { setterArg[0] = (Expression) node.arguments().get(0); @@ -72,13 +110,13 @@ public class VariableTracer { public boolean visit(Assignment node) { if (node.getLeftHandSide() instanceof FieldAccess fa) { if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) { - if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) { + if (fa.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) { setterArg[0] = node.getRightHandSide(); } } } else if (node.getLeftHandSide() instanceof QualifiedName qqn) { if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) { - if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) { + if (qqn.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) { setterArg[0] = node.getRightHandSide(); } } @@ -92,6 +130,32 @@ public class VariableTracer { return null; } + private static boolean isBeanStyleAccessorName(String accessorName) { + return (accessorName.startsWith("get") && accessorName.length() > 3) + || (accessorName.startsWith("is") && accessorName.length() > 2) + || (accessorName.startsWith("set") && accessorName.length() > 3); + } + + private static String propertyNameFromAccessor(String getterName) { + if (getterName.startsWith("get") && getterName.length() > 3) { + return TrivialAccessorDetector.decapitalize(getterName.substring(3)); + } + if (getterName.startsWith("is") && getterName.length() > 2) { + return TrivialAccessorDetector.decapitalize(getterName.substring(2)); + } + return getterName; + } + + private static String capitalizeProperty(String propertyName) { + if (propertyName == null || propertyName.isEmpty()) { + return propertyName; + } + if (propertyName.length() > 1 && Character.isUpperCase(propertyName.charAt(1))) { + return propertyName; + } + return Character.toUpperCase(propertyName.charAt(0)) + propertyName.substring(1); + } + private String getFieldType(TypeDeclaration td, String fieldName, CodebaseContext context, java.util.Set visited) { if (td == null) return null; String typeFqn = context.getFqn(td); 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 4cb7a5a..97cfc72 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 @@ -1,5 +1,7 @@ package click.kamil.springstatemachineexporter.ast.common; +import click.kamil.springstatemachineexporter.analysis.index.AccessorIndex; +import click.kamil.springstatemachineexporter.analysis.index.AccessorIndexBuilder; import click.kamil.springstatemachineexporter.analysis.model.LibraryHint; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver; @@ -42,11 +44,16 @@ public class CodebaseContext { private final ObjectMapper objectMapper = new ObjectMapper(); private Path projectRoot; private final Map cache = new HashMap<>(); + private AccessorIndex accessorIndex = AccessorIndex.empty(); public Map getCache() { return cache; } + public AccessorIndex getAccessorIndex() { + return accessorIndex; + } + public void setProjectRoot(Path root) { this.projectRoot = root; } @@ -239,6 +246,8 @@ public class CodebaseContext { } } } + + this.accessorIndex = new AccessorIndexBuilder().build(this); } private void indexType(TypeDeclaration td, String parentFqn, Path javaFile) { 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 b8d7834..70b72cb 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 @@ -1,5 +1,7 @@ package click.kamil.springstatemachineexporter.ast.common; +import click.kamil.springstatemachineexporter.analysis.index.AccessorInlining; +import click.kamil.springstatemachineexporter.analysis.index.AccessorSummary; import org.eclipse.jdt.core.dom.*; import java.util.*; @@ -199,6 +201,14 @@ public class JdtDataFlowModel implements DataFlowModel { visited.remove(expr); return resolved; } + + List inlined = tryInlineAccessorInvocation( + mi, new HashSet<>(visited), paramBindings, instanceFieldBindings, depth); + if (!inlined.isEmpty()) { + visited.remove(expr); + return inlined; + } + IMethodBinding mb = mi.resolveMethodBinding(); if (mb != null) { List targets = resolveTargets(mi, mb, visited, paramBindings, instanceFieldBindings, depth); @@ -293,6 +303,433 @@ public class JdtDataFlowModel implements DataFlowModel { return List.of(expr); } + private List tryInlineAccessorInvocation( + MethodInvocation mi, + Set visited, + Map paramBindings, + Map instanceFieldBindings, + int depth) { + if (depth > 25) { + return List.of(); + } + + String methodName = mi.getName().getIdentifier(); + IMethodBinding methodBinding = mi.resolveMethodBinding(); + if (methodBinding == null || methodBinding.getDeclaringClass() == null) { + return List.of(); + } + + String declaringFqn = methodBinding.getDeclaringClass().getErasure().getQualifiedName(); + Optional accessor = context.getAccessorIndex().lookup(declaringFqn, methodName); + if (accessor.isEmpty() && !looksLikeIndexedAccessorName(methodName)) { + return List.of(); + } + + List receiverDefs = List.of(); + + if (accessor.isEmpty()) { + if (!methodBinding.getDeclaringClass().isInterface()) { + return List.of(); + } + Expression receiver = mi.getExpression(); + if (receiver == null) { + return List.of(); + } + receiverDefs = getReachingDefinitions(receiver, visited, paramBindings, instanceFieldBindings, depth + 1); + accessor = AccessorInlining.lookupAccessor(context, mi, receiverDefs); + if (accessor.isEmpty()) { + return List.of(); + } + } + + if (receiverDefs.isEmpty() && mi.getExpression() != null) { + receiverDefs = getReachingDefinitions( + mi.getExpression(), visited, paramBindings, instanceFieldBindings, depth + 1); + } + + if (accessor.isPresent() + && shouldSkipAccessorDueToConcreteOverride(declaringFqn, methodName, receiverDefs)) { + return List.of(); + } + + if (accessor.isEmpty()) { + return List.of(); + } + if (!accessor.get().isGetter()) { + return List.of(); + } + + if (accessor.get().kind() == click.kamil.springstatemachineexporter.analysis.index.AccessorKind.RECORD_COMPONENT) { + return inlineRecordComponentRead(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth); + } + + return inlineAccessorGetter(mi, accessor.get(), receiverDefs, visited, paramBindings, instanceFieldBindings, depth); + } + + private boolean shouldSkipAccessorDueToConcreteOverride( + String declaringFqn, + String methodName, + List receiverDefs) { + for (Expression receiverDef : receiverDefs) { + String concreteFqn = concreteReceiverTypeFqn(receiverDef); + if (concreteFqn == null || concreteFqn.equals(declaringFqn)) { + continue; + } + if (context.getAccessorIndex().lookup(concreteFqn, methodName).isPresent()) { + continue; + } + TypeDeclaration concreteType = context.getTypeDeclaration(concreteFqn); + if (concreteType != null && context.hasMethod(concreteType, methodName)) { + return true; + } + } + return false; + } + + private String concreteReceiverTypeFqn(Expression receiverDef) { + if (receiverDef instanceof ClassInstanceCreation cic) { + return AccessorInlining.resolveTypeFqn(cic); + } + ITypeBinding typeBinding = receiverDef.resolveTypeBinding(); + if (typeBinding != null) { + return typeBinding.getErasure().getQualifiedName(); + } + return null; + } + + private boolean looksLikeIndexedAccessorName(String methodName) { + return (methodName.startsWith("get") && methodName.length() > 3) + || (methodName.startsWith("set") && methodName.length() > 3) + || (methodName.startsWith("is") && methodName.length() > 2); + } + + private List inlineRecordComponentRead( + MethodInvocation mi, + AccessorSummary accessor, + List receiverDefs, + Set visited, + Map paramBindings, + Map instanceFieldBindings, + int depth) { + Expression receiver = mi.getExpression(); + List candidates = receiverDefs.isEmpty() && receiver != null ? List.of(receiver) : receiverDefs; + List results = new ArrayList<>(); + + for (Expression candidate : candidates) { + if (candidate instanceof ClassInstanceCreation cic) { + Expression componentValue = readRecordComponentValue(cic, accessor.fieldName()); + if (componentValue != null) { + results.addAll(getReachingDefinitions(componentValue, visited, paramBindings, instanceFieldBindings, depth + 1)); + } + continue; + } + if (candidate instanceof MethodInvocation receiverCall) { + List nested = tryInlineAccessorInvocation( + receiverCall, new HashSet<>(visited), paramBindings, instanceFieldBindings, depth + 1); + for (Expression nestedValue : nested) { + if (nestedValue instanceof ClassInstanceCreation cic) { + Expression componentValue = readRecordComponentValue(cic, accessor.fieldName()); + if (componentValue != null) { + results.addAll(getReachingDefinitions( + componentValue, visited, paramBindings, instanceFieldBindings, depth + 1)); + } + } else { + results.addAll(getReachingDefinitions(nestedValue, visited, paramBindings, instanceFieldBindings, depth + 1)); + } + } + } + } + return results; + } + + private Expression readRecordComponentValue(ClassInstanceCreation cic, String componentName) { + org.eclipse.jdt.core.dom.RecordDeclaration record = findRecordDeclaration(AccessorInlining.resolveTypeFqn(cic)); + if (record == null) { + return null; + } + int componentIndex = -1; + List components = record.recordComponents(); + for (int i = 0; i < components.size(); i++) { + Object componentObj = components.get(i); + String name = recordComponentName(componentObj); + if (componentName.equals(name)) { + componentIndex = i; + break; + } + } + if (componentIndex < 0 || componentIndex >= cic.arguments().size()) { + return null; + } + return (Expression) cic.arguments().get(componentIndex); + } + + private String recordComponentName(Object componentObj) { + if (componentObj instanceof SingleVariableDeclaration svd) { + return svd.getName().getIdentifier(); + } + try { + java.lang.reflect.Method getNameMethod = componentObj.getClass().getMethod("getName"); + Object nameObj = getNameMethod.invoke(componentObj); + if (nameObj instanceof SimpleName sn) { + return sn.getIdentifier(); + } + } catch (ReflectiveOperationException ignored) { + // fall through + } + return null; + } + + private List inlineAccessorGetter( + MethodInvocation mi, + AccessorSummary accessor, + List receiverDefs, + Set visited, + Map paramBindings, + Map instanceFieldBindings, + int depth) { + Expression receiver = mi.getExpression(); + List results = new ArrayList<>(); + + if (receiver == null || receiver instanceof ThisExpression) { + Expression fieldValue = readFieldValue(accessor, instanceFieldBindings, mi); + if (fieldValue != null) { + results.addAll(getReachingDefinitions(fieldValue, visited, paramBindings, instanceFieldBindings, depth + 1)); + } + return results; + } + + List candidates = receiverDefs.isEmpty() ? List.of(receiver) : receiverDefs; + for (Expression candidate : candidates) { + if (candidate instanceof ClassInstanceCreation cic) { + Map fieldBindings = + getOrCreateFieldBindings(cic, paramBindings, instanceFieldBindings); + + if (receiver instanceof SimpleName sn) { + applyFlowSensitiveMutationsBeforeUse(sn, mi, cic, fieldBindings, paramBindings, visited, depth); + } else if (receiver instanceof FieldAccess fa) { + applyFlowSensitiveFieldMutationsBeforeUse(fa, mi, fieldBindings, paramBindings, visited, depth); + } + + Expression fieldValue = readFieldValue(accessor, fieldBindings, mi); + if (fieldValue != null) { + results.addAll(getReachingDefinitions(fieldValue, visited, paramBindings, fieldBindings, depth + 1)); + } + } + } + return results; + } + + private void applyFlowSensitiveMutationsBeforeUse( + SimpleName receiverName, + MethodInvocation useSite, + ClassInstanceCreation creationSite, + Map fieldBindings, + Map paramBindings, + Set visited, + int depth) { + MethodDeclaration enclosingMethod = findEnclosingMethod(useSite); + if (enclosingMethod == null) { + return; + } + ReachingDefinitions rd = getReachingDefinitionsForMethod(enclosingMethod); + if (rd == null) { + return; + } + Set defs = rd.getReachingDefinitions(receiverName, receiverName.getIdentifier()); + for (ASTNode def : defs) { + Expression defExpr = ReachingDefinitions.getDefinitionExpression(def); + if (defExpr == creationSite || isExpressionWrapping(defExpr, creationSite)) { + applyIntermediateMutations(receiverName, def, useSite, enclosingMethod, fieldBindings, paramBindings, visited, depth); + } + } + } + + private void applyFlowSensitiveFieldMutationsBeforeUse( + FieldAccess fieldReceiver, + MethodInvocation useSite, + Map fieldBindings, + Map paramBindings, + Set visited, + int depth) { + Expression qualifier = fieldReceiver.getExpression(); + if (qualifier != null && !(qualifier instanceof ThisExpression)) { + return; + } + MethodDeclaration enclosingMethod = findEnclosingMethod(useSite); + if (enclosingMethod == null || enclosingMethod.getBody() == null) { + return; + } + + int useStart = useSite.getStartPosition(); + String targetFieldName = fieldReceiver.getName().getIdentifier(); + enclosingMethod.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (node.getStartPosition() >= useStart) { + return false; + } + if (!isThisFieldAccess(node.getExpression(), targetFieldName)) { + return super.visit(node); + } + + List receiverDefs = getReachingDefinitions( + node.getExpression(), visited, paramBindings, fieldBindings, depth + 1); + Optional accessor = AccessorInlining.lookupAccessor(context, node, receiverDefs); + if (accessor.isPresent() && accessor.get().isSetter()) { + inlineAccessorSetter(node, accessor.get(), fieldBindings, paramBindings, visited, depth); + return super.visit(node); + } + + IMethodBinding mb = node.resolveMethodBinding(); + if (mb != null) { + List targets = resolveTargets(node, mb, visited, paramBindings, fieldBindings, depth + 1); + for (TargetMethod target : targets) { + if (target.declaration != null) { + evaluateMethodMutations(target.declaration, node.arguments(), fieldBindings); + } + } + } + return super.visit(node); + } + }); + } + + private boolean isThisFieldAccess(Expression expr, String fieldName) { + if (!(expr instanceof FieldAccess fa)) { + return false; + } + Expression receiver = fa.getExpression(); + return fa.getName().getIdentifier().equals(fieldName) + && (receiver == null || receiver instanceof ThisExpression); + } + + private Expression readFieldValue( + AccessorSummary accessor, + Map fieldBindings, + ASTNode contextNode) { + IVariableBinding fieldBinding = findFieldVariableBinding(accessor.declaringFqn(), accessor.fieldName(), contextNode); + if (fieldBinding != null && fieldBindings.containsKey(fieldBinding)) { + return fieldBindings.get(fieldBinding); + } + if (fieldBinding != null) { + return findFieldInitializer(fieldBinding, contextNode); + } + return null; + } + + private IVariableBinding findFieldVariableBinding(String declaringFqn, String fieldName, ASTNode contextNode) { + if (declaringFqn == null || fieldName == null) { + return null; + } + + TypeDeclaration typeDeclaration = context.getTypeDeclaration(declaringFqn); + if (typeDeclaration != null) { + IVariableBinding binding = findFieldOnType(typeDeclaration, fieldName, new HashSet<>()); + if (binding != null) { + return binding; + } + } + + org.eclipse.jdt.core.dom.RecordDeclaration recordDeclaration = findRecordDeclaration(declaringFqn); + if (recordDeclaration != null) { + for (Object componentObj : recordDeclaration.recordComponents()) { + if (componentObj instanceof SingleVariableDeclaration component + && component.getName().getIdentifier().equals(fieldName)) { + return component.resolveBinding(); + } + } + } + return null; + } + + private IVariableBinding findFieldOnType(TypeDeclaration typeDeclaration, String fieldName, Set visitedTypes) { + if (typeDeclaration == null) { + return null; + } + String typeFqn = context.getFqn(typeDeclaration); + if (!visitedTypes.add(typeFqn)) { + return null; + } + + for (FieldDeclaration fieldDeclaration : typeDeclaration.getFields()) { + for (Object fragmentObj : fieldDeclaration.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment + && fragment.getName().getIdentifier().equals(fieldName)) { + IVariableBinding binding = fragment.resolveBinding(); + if (binding != null) { + return binding; + } + } + } + } + + String superFqn = context.getSuperclassFqn(typeDeclaration); + if (superFqn != null && !"java.lang.Object".equals(superFqn)) { + TypeDeclaration superType = context.getTypeDeclaration(superFqn); + if (superType != null) { + return findFieldOnType(superType, fieldName, visitedTypes); + } + } + return null; + } + + private org.eclipse.jdt.core.dom.RecordDeclaration findRecordDeclaration(String fqn) { + for (CompilationUnit cu : context.getCompilationUnits()) { + for (Object typeObj : cu.types()) { + org.eclipse.jdt.core.dom.RecordDeclaration found = findRecordInType(fqn, typeObj); + if (found != null) { + return found; + } + } + } + return null; + } + + private org.eclipse.jdt.core.dom.RecordDeclaration findRecordInType(String fqn, Object typeObj) { + if (typeObj instanceof org.eclipse.jdt.core.dom.RecordDeclaration recordDeclaration) { + if (context.getFqn(recordDeclaration).equals(fqn)) { + return recordDeclaration; + } + for (Object decl : recordDeclaration.bodyDeclarations()) { + if (decl instanceof org.eclipse.jdt.core.dom.RecordDeclaration nested) { + org.eclipse.jdt.core.dom.RecordDeclaration found = findRecordInType(fqn, nested); + if (found != null) { + return found; + } + } + } + } + if (typeObj instanceof TypeDeclaration typeDeclaration) { + for (TypeDeclaration nested : typeDeclaration.getTypes()) { + org.eclipse.jdt.core.dom.RecordDeclaration found = findRecordInType(fqn, nested); + if (found != null) { + return found; + } + } + } + return null; + } + + private void inlineAccessorSetter( + MethodInvocation mi, + AccessorSummary accessor, + Map fieldBindings, + Map paramBindings, + Set visited, + int depth) { + if (mi.arguments().isEmpty()) { + return; + } + Expression argument = (Expression) mi.arguments().get(0); + List resolvedArgument = getReachingDefinitions(argument, visited, paramBindings, fieldBindings, depth + 1); + Expression value = resolvedArgument.isEmpty() ? argument : resolvedArgument.get(0); + + IVariableBinding fieldBinding = findFieldVariableBinding(accessor.declaringFqn(), accessor.fieldName(), mi); + if (fieldBinding != null) { + fieldBindings.put(fieldBinding, value); + } + } + private IVariableBinding getVariableBinding(Expression expr) { if (expr instanceof SimpleName sn) { IBinding b = sn.resolveBinding(); @@ -704,6 +1141,22 @@ public class JdtDataFlowModel implements DataFlowModel { Map fieldBindings) { if (md == null || md.getBody() == null) return; + ASTNode parent = md.getParent(); + if (parent instanceof AbstractTypeDeclaration typeDeclaration) { + String ownerFqn = context.getFqn(typeDeclaration); + Optional accessor = context.getAccessorIndex().lookup(ownerFqn, md.getName().getIdentifier()); + if (accessor.isPresent() && accessor.get().isSetter() && !arguments.isEmpty()) { + IVariableBinding fieldBinding = findFieldVariableBinding( + accessor.get().declaringFqn(), + accessor.get().fieldName(), + md); + if (fieldBinding != null) { + fieldBindings.put(fieldBinding, (Expression) arguments.get(0)); + return; + } + } + } + Map currentParamValues = new HashMap<>(); List parameters = md.parameters(); int count = Math.min(parameters.size(), arguments.size()); @@ -790,6 +1243,14 @@ public class JdtDataFlowModel implements DataFlowModel { public boolean visit(MethodInvocation mi) { Expression receiver = mi.getExpression(); if (receiver != null && isTargetVariable(receiver, targetVar, targetName)) { + List receiverDefs = getReachingDefinitions( + receiver, visited, paramBindings, fieldBindings, depth + 1); + Optional accessor = AccessorInlining.lookupAccessor(context, mi, receiverDefs); + if (accessor.isPresent() && accessor.get().isSetter()) { + inlineAccessorSetter(mi, accessor.get(), fieldBindings, paramBindings, visited, depth); + return super.visit(mi); + } + IMethodBinding mb = mi.resolveMethodBinding(); if (mb != null) { List targets = resolveTargets(mi, mb, visited, paramBindings, fieldBindings, depth + 1); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java index f02fdab..de1373b 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java @@ -1,5 +1,7 @@ package click.kamil.springstatemachineexporter; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService; import click.kamil.springstatemachineexporter.exporter.Dot; import click.kamil.springstatemachineexporter.exporter.JsonExporter; @@ -21,8 +23,12 @@ import static org.assertj.core.api.Assertions.assertThat; public class RegressionTest { - private final List exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter()); - private final ExportService exportService = new ExportService(exporters); + private static final List EXPORTERS = + List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter()); + + private ExportService exportService() { + return new ExportService(EXPORTERS); + } private static Path findProjectRoot() { Path current = Path.of(".").toAbsolutePath(); @@ -155,9 +161,10 @@ public class RegressionTest { @ParameterizedTest(name = "{0}") @MethodSource("provideTestScenarios") + @Execution(ExecutionMode.CONCURRENT) void testOutputMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception { System.out.println("Running test for " + scenario.name()); - if (exportService == null) System.out.println("exportService is NULL"); + ExportService exportService = exportService(); if (scenario.inputPath() == null) System.out.println("inputPath is NULL"); if (tempDir == null) System.out.println("tempDir is NULL"); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AccessorIndexBuilderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AccessorIndexBuilderTest.java new file mode 100644 index 0000000..485d814 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AccessorIndexBuilderTest.java @@ -0,0 +1,263 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Nested; +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.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class AccessorIndexBuilderTest { + + @Nested + class ClassAccessors { + + @Test + void shouldIndexTrivialGetterAndSetter(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Order.java", """ + package com.example; + public class Order { + private String event; + public String getEvent() { return event; } + public void setEvent(String event) { this.event = event; } + } + """); + + AccessorIndex index = scan(tempDir); + + assertThat(index.lookup("com.example.Order", "getEvent")).isPresent(); + assertThat(index.lookup("com.example.Order", "setEvent")).isPresent(); + assertThat(index.accessorsForType("com.example.Order")).hasSize(2); + } + + @Test + void shouldIndexNestedClassAccessors(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Outer.java", """ + package com.example; + public class Outer { + static class Inner { + private String code; + public String getCode() { return code; } + } + } + """); + + AccessorIndex index = scan(tempDir); + + assertThat(index.lookup("com.example.Outer.Inner", "getCode")).isPresent(); + } + } + + @Nested + class Inheritance { + + @Test + void shouldInheritParentTrivialGetter(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Base.java", """ + package com.example; + public class Base { + protected String event; + public String getEvent() { return event; } + } + """); + writeJava(tempDir, "com/example/Child.java", """ + package com.example; + public class Child extends Base { + public void run() {} + } + """); + + AccessorIndex index = scan(tempDir); + + assertThat(index.lookup("com.example.Child", "getEvent")).isPresent(); + assertThat(index.lookup("com.example.Child", "getEvent").orElseThrow().ownerFqn()) + .isEqualTo("com.example.Child"); + } + + @Test + void shouldNotInheritWhenChildOverridesWithLogic(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Base.java", """ + package com.example; + public class Base { + protected String event; + public String getEvent() { return event; } + } + """); + writeJava(tempDir, "com/example/Child.java", """ + package com.example; + public class Child extends Base { + @Override + public String getEvent() { + return event == null ? "NONE" : event; + } + } + """); + + AccessorIndex index = scan(tempDir); + + assertThat(index.lookup("com.example.Base", "getEvent")).isPresent(); + assertThat(index.lookup("com.example.Child", "getEvent")).isEmpty(); + } + + @Test + void shouldPreferChildTrivialOverride(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Base.java", """ + package com.example; + public class Base { + protected String event; + public String getEvent() { return event; } + } + """); + writeJava(tempDir, "com/example/Child.java", """ + package com.example; + public class Child extends Base { + @Override + public String getEvent() { return this.event; } + } + """); + + AccessorIndex index = scan(tempDir); + + Optional child = index.lookup("com.example.Child", "getEvent"); + assertThat(child).isPresent(); + assertThat(child.orElseThrow().ownerFqn()).isEqualTo("com.example.Child"); + } + + @Test + void shouldResolveFieldDeclaredInSuperclass(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Base.java", """ + package com.example; + public class Base { + protected String event; + } + """); + writeJava(tempDir, "com/example/Child.java", """ + package com.example; + public class Child extends Base { + public String getEvent() { return event; } + } + """); + + AccessorIndex index = scan(tempDir); + + AccessorSummary summary = index.lookup("com.example.Child", "getEvent").orElseThrow(); + assertThat(summary.declaringFqn()).isEqualTo("com.example.Base"); + } + } + + @Nested + class Records { + + @Test + void shouldIndexRecordComponents(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Event.java", """ + package com.example; + public record Event(String code, int version) {} + """); + + AccessorIndex index = scan(tempDir); + + assertThat(index.lookup("com.example.Event", "code")).isPresent(); + assertThat(index.lookup("com.example.Event", "code").orElseThrow().kind()) + .isEqualTo(AccessorKind.RECORD_COMPONENT); + assertThat(index.lookup("com.example.Event", "version")).isPresent(); + } + } + + @Nested + class Interfaces { + + @Test + void shouldIndexDefaultInterfaceAccessor(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/HasEvent.java", """ + package com.example; + public interface HasEvent { + default String getEvent() { + return null; + } + } + """); + + AccessorIndex index = scan(tempDir); + + // Default method body is not trivial (returns null literal, not field read) + assertThat(index.lookup("com.example.HasEvent", "getEvent")).isEmpty(); + } + + @Test + void shouldIndexTrivialDefaultInterfaceAccessor(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Holder.java", """ + package com.example; + public interface Holder { + String getEvent(); + } + """); + writeJava(tempDir, "com/example/Impl.java", """ + package com.example; + public class Impl implements Holder { + private String event; + public String getEvent() { return event; } + } + """); + + AccessorIndex index = scan(tempDir); + + assertThat(index.lookup("com.example.Impl", "getEvent")).isPresent(); + assertThat(index.lookup("com.example.Holder", "getEvent")).isEmpty(); + } + } + + @Nested + class Enums { + + @Test + void shouldIndexEnumFieldGetter(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Status.java", """ + package com.example; + public enum Status { + OK; + private final String code = "ok"; + public String getCode() { return code; } + } + """); + + AccessorIndex index = scan(tempDir); + + assertThat(index.lookup("com.example.Status", "getCode")).isPresent(); + } + } + + @Test + void shouldBuildViaCodebaseContext(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Demo.java", """ + package com.example; + public class Demo { + private String type; + public String getType() { return type; } + } + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + assertThat(context.getAccessorIndex().lookup("com.example.Demo", "getType")).isPresent(); + assertThat(context.getAccessorIndex().trivialCount()).isGreaterThan(0); + } + + private static AccessorIndex scan(Path tempDir) throws IOException { + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + return context.getAccessorIndex(); + } + + private static void writeJava(Path tempDir, String relativePath, String source) throws IOException { + Path file = tempDir.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, source); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AccessorInliningDataFlowTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AccessorInliningDataFlowTest.java new file mode 100644 index 0000000..c58619b --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/AccessorInliningDataFlowTest.java @@ -0,0 +1,618 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.ast.common.DataFlowModel; +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.TypeDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; +import org.eclipse.jdt.core.dom.VariableDeclarationStatement; +import org.junit.jupiter.api.Nested; +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 static org.assertj.core.api.Assertions.assertThat; + +class AccessorInliningDataFlowTest { + + @Nested + class GetterInlining { + + @Test + void shouldResolveTrivialGetterThroughFieldBinding(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public static class Payload { + private String event = "SHIP"; + public String getEvent() { return event; } + } + public void run() { + Payload payload = new Payload(); + String value = payload.getEvent(); + } + } + """); + + assertResolvedValue(tempDir, "value", "SHIP"); + assertAccessorIndexed(tempDir, "com.example.Runner.Payload", "getEvent"); + } + + @Test + void shouldResolveChainedTrivialGetters(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public static class Event { + private String code = "PAY"; + public String getCode() { return code; } + } + public static class Payload { + private Event event = new Event(); + public Event getEvent() { return event; } + } + public static class Order { + private Payload payload = new Payload(); + public Payload getPayload() { return payload; } + } + public void run() { + Order order = new Order(); + String code = order.getPayload().getEvent().getCode(); + } + } + """); + + assertResolvedValue(tempDir, "code", "PAY"); + } + + @Test + void shouldResolveInheritedFieldThroughChildGetter(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Base.java", """ + package com.example; + public class Base { + protected String event = "BASE"; + } + """); + writeJava(tempDir, "com/example/Child.java", """ + package com.example; + public class Child extends Base { + public String getEvent() { return event; } + } + """); + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Child child = new Child(); + String value = child.getEvent(); + } + } + """); + + assertResolvedValue(tempDir, "value", "BASE"); + } + + @Test + void shouldResolveRecordComponentAfterNonTrivialGetterWrapper(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Event.java", """ + package com.example; + public record Event(String code) {} + """); + writeJava(tempDir, "com/example/Payload.java", """ + package com.example; + public class Payload { + private Event event = new Event("WRAPPED"); + public Event getEvent() { return event; } + public Event getEventViaWrapper() { return getEvent(); } + } + """); + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Payload payload = new Payload(); + String code = payload.getEventViaWrapper().code(); + } + } + """); + + assertResolvedValue(tempDir, "code", "WRAPPED"); + } + + @Test + void shouldResolveRecordComponentThroughAccessorChain(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Event.java", """ + package com.example; + public record Event(String code) {} + """); + writeJava(tempDir, "com/example/Payload.java", """ + package com.example; + public class Payload { + private Event event = new Event("RECORD"); + public Event getEvent() { return event; } + } + """); + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Payload payload = new Payload(); + String code = payload.getEvent().code(); + } + } + """); + + assertResolvedValue(tempDir, "code", "RECORD"); + } + } + + @Nested + class SetterThenGetter { + + @Test + void shouldApplySetterMutationBeforeGetterRead(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public static class Command { + private String event = "DEFAULT"; + public void setEvent(String event) { this.event = event; } + public String getEvent() { return this.event; } + } + public void run() { + Command cmd = new Command(); + cmd.setEvent("PAY"); + String value = cmd.getEvent(); + } + } + """); + + assertResolvedValue(tempDir, "value", "PAY"); + } + + @Test + void shouldApplySetterThroughInterfaceTypedReceiver(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public interface Command { + void setEvent(String event); + String getEvent(); + } + public static class Impl implements Command { + private String event = "DEFAULT"; + @Override + public void setEvent(String event) { this.event = event; } + @Override + public String getEvent() { return event; } + } + public void run() { + Command cmd = new Impl(); + cmd.setEvent("PAY"); + String value = cmd.getEvent(); + } + } + """); + + assertResolvedValue(tempDir, "value", "PAY"); + } + + @Test + void shouldResolveSetterArgumentThroughInterfaceTypedReceiver(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public interface Command { + void setEvent(String event); + String getEvent(); + } + public static class Impl implements Command { + private String event = "DEFAULT"; + @Override + public void setEvent(String event) { this.event = event; } + @Override + public String getEvent() { return event; } + } + public static class Helper { + public String constant() { return "PAY"; } + } + public void run() { + Command cmd = new Impl(); + Helper helper = new Helper(); + cmd.setEvent(helper.constant()); + String value = cmd.getEvent(); + } + } + """); + + assertResolvedValue(tempDir, "value", "PAY"); + } + + @Test + void shouldResolveFluentSetterChain(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public static class Builder { + private String event = "INIT"; + public Builder setEvent(String event) { + this.event = event; + return this; + } + public String getEvent() { return event; } + } + public void run() { + Builder builder = new Builder(); + builder.setEvent("FLUENT"); + String value = builder.getEvent(); + } + } + """); + + assertResolvedValue(tempDir, "value", "FLUENT"); + } + } + + @Nested + class MustNotBreakExistingResolution { + + @Test + void shouldStillResolveLiteralReturnWhenGetterIsNotTrivial(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public static class Service { + public String getEvent() { return "LITERAL"; } + } + public void run() { + Service service = new Service(); + String value = service.getEvent(); + } + } + """); + + assertResolvedValue(tempDir, "value", "LITERAL"); + assertThat(accessorLookup(tempDir, "com.example.Runner.Service", "getEvent")).isEmpty(); + } + + @Test + void shouldStillResolveDynamicDispatchWhenImplementationReturnsLiteral(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public interface Service { + String getEvent(); + } + public static class PayService implements Service { + public String getEvent() { return "PAY"; } + } + public void run() { + Service service = new PayService(); + String value = service.getEvent(); + } + } + """); + + assertResolvedValue(tempDir, "value", "PAY"); + } + + @Test + void shouldNotInlineParentAccessorWhenSubclassOverrideIsBlocked(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Base.java", """ + package com.example; + public class Base { + protected String event = "base"; + public String getEvent() { return event; } + } + """); + writeJava(tempDir, "com/example/Child.java", """ + package com.example; + public class Child extends Base { + @Override + public String getEvent() { + return "child"; + } + } + """); + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Base target = new Child(); + String value = target.getEvent(); + } + } + """); + + assertResolvedValue(tempDir, "value", "child"); + } + + @Test + void shouldApplySetterMutationsOnInstanceFieldReceiver(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + private Command command = new Command(); + public void run() { + this.command.setEvent("PAY"); + String value = this.command.getEvent(); + } + public static class Command { + private String event = "DEFAULT"; + public void setEvent(String event) { this.event = event; } + public String getEvent() { return event; } + } + } + """); + + assertResolvedValue(tempDir, "value", "PAY"); + } + + @Test + void shouldNotApplyParentAccessorIndexWhenChildOverrideIsBlocked(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Base.java", """ + package com.example; + public class Base { + protected String event = "BASE"; + public String getEvent() { return event; } + } + """); + writeJava(tempDir, "com/example/Child.java", """ + package com.example; + public class Child extends Base { + @Override + public String getEvent() { + return event == null ? "NONE" : event.toUpperCase(); + } + } + """); + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Child child = new Child(); + child.event = "pay"; + String value = child.getEvent(); + } + } + """); + + assertThat(accessorLookup(tempDir, "com.example.Child", "getEvent")).isEmpty(); + assertThat(accessorLookup(tempDir, "com.example.Base", "getEvent")).isPresent(); + + CodebaseContext context = scan(tempDir); + DataFlowModel model = new JdtDataFlowModel(context); + Expression initializer = findInitializer(context, "run", "value"); + String resolved = model.resolveValue(initializer, context); + assertThat(resolved).isNotEqualTo("BASE"); + } + + @Test + void shouldNotConfuseMapGetWithBeanGetter(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + import java.util.Map; + import java.util.HashMap; + public class Runner { + public void run() { + Map map = new HashMap<>(); + map.put("event", "MAP_VALUE"); + String value = map.get("event"); + } + } + """); + + assertThat(accessorLookup(tempDir, "java.util.HashMap", "get")).isEmpty(); + // Map resolution is separate; ensure we do not crash or return wrong constant. + CodebaseContext context = scan(tempDir); + DataFlowModel model = new JdtDataFlowModel(context); + Expression initializer = findInitializer(context, "run", "value"); + List defs = model.getReachingDefinitions(initializer); + assertThat(defs).isNotEmpty(); + } + + @Test + void shouldPreserveBranchSensitiveSetterVisibility(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public static class Command { + private String event = "DEFAULT"; + public void setEvent(String event) { this.event = event; } + public String getEvent() { return this.event; } + } + public void run(boolean pay) { + Command cmd = new Command(); + if (pay) { + cmd.setEvent("PAY"); + } else { + cmd.setEvent("CANCEL"); + } + String value = cmd.getEvent(); + } + } + """); + + CodebaseContext context = scan(tempDir); + DataFlowModel model = new JdtDataFlowModel(context); + Expression initializer = findInitializer(context, "run", "value"); + String resolved = model.resolveValue(initializer, context); + assertThat(resolved).isIn("PAY", "CANCEL", null); + } + } + + @Nested + class SchemaStressCases { + + @Test + void shouldHandleDeepGetterChainWithoutStackOverflow(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public static class L4 { private String v = "DEEP"; public String getV() { return v; } } + public static class L3 { private L4 l4 = new L4(); public L4 getL4() { return l4; } } + public static class L2 { private L3 l3 = new L3(); public L3 getL3() { return l3; } } + public static class L1 { private L2 l2 = new L2(); public L2 getL2() { return l2; } } + public void run() { + L1 root = new L1(); + String value = root.getL2().getL3().getL4().getV(); + } + } + """); + + assertResolvedValue(tempDir, "value", "DEEP"); + } + + @Test + void shouldHandleMixedTrivialAndLiteralGetterChain(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public static class Inner { + public String code() { return "INNER"; } + } + public static class Outer { + private Inner inner = new Inner(); + public Inner getInner() { return inner; } + } + public void run() { + Outer outer = new Outer(); + String value = outer.getInner().code(); + } + } + """); + + assertResolvedValue(tempDir, "value", "INNER"); + } + + @Test + void shouldResolveSuperclassFieldViaSubclassThisGetter(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public static class Base { + protected String event = "SUPER"; + } + public static class Sub extends Base { + public String getEvent() { return event; } + } + public void run() { + Sub sub = new Sub(); + String value = sub.getEvent(); + } + } + """); + + assertResolvedValue(tempDir, "value", "SUPER"); + } + + @Test + void shouldReturnEmptyWhenAccessorIndexHasNoEntry(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public static class Worker { + public String compute() { return "WORK"; } + } + public void run() { + Worker worker = new Worker(); + String value = worker.compute(); + } + } + """); + + assertResolvedValue(tempDir, "value", "WORK"); + assertThat(accessorLookup(tempDir, "com.example.Runner.Worker", "compute")).isEmpty(); + } + } + + private static void assertResolvedValue(Path tempDir, String variableName, String expected) throws IOException { + CodebaseContext context = scan(tempDir); + DataFlowModel model = new JdtDataFlowModel(context); + Expression initializer = findInitializer(context, "run", variableName); + String resolved = model.resolveValue(initializer, context); + assertThat(resolved).isEqualTo(expected); + } + + private static void assertAccessorIndexed(Path tempDir, String ownerFqn, String methodName) throws IOException { + assertThat(accessorLookup(tempDir, ownerFqn, methodName)).isPresent(); + } + + private static java.util.Optional accessorLookup(Path tempDir, String ownerFqn, String methodName) + throws IOException { + CodebaseContext context = scan(tempDir); + return context.getAccessorIndex().lookup(ownerFqn, methodName); + } + + private static CodebaseContext scan(Path tempDir) throws IOException { + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.setSourcepath(List.of(tempDir.toAbsolutePath().toString())); + context.scan(tempDir); + return context; + } + + private static Expression findInitializer(CodebaseContext context, String methodName, String variableName) { + for (TypeDeclaration type : context.getTypeDeclarations()) { + for (MethodDeclaration method : type.getMethods()) { + if (!method.getName().getIdentifier().equals(methodName) || method.getBody() == null) { + continue; + } + for (Object statementObj : method.getBody().statements()) { + if (statementObj instanceof VariableDeclarationStatement statement) { + for (Object fragmentObj : statement.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment + && fragment.getName().getIdentifier().equals(variableName)) { + return fragment.getInitializer(); + } + } + } + } + } + for (TypeDeclaration nested : type.getTypes()) { + Expression nestedResult = findInitializerInType(nested, methodName, variableName); + if (nestedResult != null) { + return nestedResult; + } + } + } + throw new AssertionError("Initializer not found for " + variableName + " in " + methodName); + } + + private static Expression findInitializerInType(TypeDeclaration type, String methodName, String variableName) { + for (MethodDeclaration method : type.getMethods()) { + if (!method.getName().getIdentifier().equals(methodName) || method.getBody() == null) { + continue; + } + for (Object statementObj : method.getBody().statements()) { + if (statementObj instanceof VariableDeclarationStatement statement) { + for (Object fragmentObj : statement.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment + && fragment.getName().getIdentifier().equals(variableName)) { + return fragment.getInitializer(); + } + } + } + } + } + for (TypeDeclaration nested : type.getTypes()) { + Expression result = findInitializerInType(nested, methodName, variableName); + if (result != null) { + return result; + } + } + return null; + } + + private static void writeJava(Path tempDir, String relativePath, String source) throws IOException { + Path file = tempDir.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, source); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/ConstantExtractorAccessorInliningTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/ConstantExtractorAccessorInliningTest.java new file mode 100644 index 0000000..191c14b --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/ConstantExtractorAccessorInliningTest.java @@ -0,0 +1,86 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import click.kamil.springstatemachineexporter.analysis.service.ConstructorAnalyzer; +import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor; +import click.kamil.springstatemachineexporter.analysis.service.VariableTracer; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.Expression; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConstantExtractorAccessorInliningTest { + + private CodebaseContext context; + private ConstantExtractor constantExtractor; + private VariableTracer variableTracer; + + @BeforeEach + void setUp(@TempDir Path tempDir) throws IOException { + writeJava(tempDir, "com/example/Payload.java", """ + package com.example; + public class Payload { + private String event = "SHIP"; + public String getEvent() { return event; } + public void setEvent(String event) { this.event = event; } + } + """); + writeJava(tempDir, "com/example/Runner.java", """ + package com.example; + public class Runner { + public void run() { + Payload payload = new Payload(); + payload.setEvent("PAY"); + String value = payload.getEvent(); + } + } + """); + + context = new CodebaseContext(); + context.setResolveBindings(true); + context.setSourcepath(List.of(tempDir.toAbsolutePath().toString())); + context.scan(tempDir); + + constantExtractor = new ConstantExtractor(context, context.getConstantResolver(), mi -> null); + variableTracer = new VariableTracer(context, context.getConstantResolver()); + constantExtractor.setVariableTracer(variableTracer); + constantExtractor.setConstructorAnalyzer(new ConstructorAnalyzer(context, context.getConstantResolver())); + } + + @Test + void resolveMethodReturnConstantShouldReadIndexedGetterField() { + List constants = constantExtractor.resolveMethodReturnConstant( + "com.example.Payload", "getEvent", 0, new HashSet<>(), null); + + assertThat(constants).contains("SHIP"); + } + + @Test + void traceLocalSetterShouldUseAccessorIndexFieldName() { + Expression result = variableTracer.traceLocalSetter( + "com.example.Runner.run", "payload", "getEvent"); + + assertThat(result).isNotNull(); + assertThat(result.toString()).contains("PAY"); + } + + @Test + void indexedGetterShouldExistForPayload() { + assertThat(context.getAccessorIndex().lookup("com.example.Payload", "getEvent")).isPresent(); + assertThat(context.getAccessorIndex().lookup("com.example.Payload", "setEvent")).isPresent(); + } + + private static void writeJava(Path tempDir, String relativePath, String source) throws IOException { + Path file = tempDir.resolve(relativePath); + Files.createDirectories(file.getParent()); + Files.writeString(file, source); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetectorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetectorTest.java new file mode 100644 index 0000000..430f838 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/index/TrivialAccessorDetectorTest.java @@ -0,0 +1,210 @@ +package click.kamil.springstatemachineexporter.analysis.index; + +import org.eclipse.jdt.core.dom.AST; +import org.eclipse.jdt.core.dom.ASTParser; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +class TrivialAccessorDetectorTest { + + private final TrivialAccessorDetector detector = new TrivialAccessorDetector(); + + private static final TrivialAccessorDetector.FieldLookup KNOWN_FIELDS = + fieldName -> Optional.of("com.example.Demo"); + + @Nested + class Getters { + + @Test + void shouldDetectPlainGetter() { + MethodDeclaration md = method(""" + public class Demo { + private String event; + public String getEvent() { return this.event; } + } + """, 0); + + Optional summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS); + + assertThat(summary).isPresent(); + assertThat(summary.get().kind()).isEqualTo(AccessorKind.GETTER); + assertThat(summary.get().fieldName()).isEqualTo("event"); + assertThat(summary.get().methodName()).isEqualTo("getEvent"); + } + + @Test + void shouldDetectGetterWithoutThisPrefix() { + MethodDeclaration md = method(""" + public class Demo { + private String event; + public String getEvent() { return event; } + } + """, 0); + + assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isPresent(); + } + + @Test + void shouldDetectBooleanGetter() { + MethodDeclaration md = method(""" + public class Demo { + private boolean active; + public boolean isActive() { return active; } + } + """, 0); + + Optional summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS); + + assertThat(summary).isPresent(); + assertThat(summary.get().kind()).isEqualTo(AccessorKind.BOOLEAN_GETTER); + assertThat(summary.get().fieldName()).isEqualTo("active"); + } + + @Test + void shouldDetectGetterWithCast() { + MethodDeclaration md = method(""" + public class Demo { + private Object event; + public Object getEvent() { return (Object) this.event; } + } + """, 0); + + assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isPresent(); + } + + @Test + void shouldRejectLazyInitGetter() { + MethodDeclaration md = method(""" + public class Demo { + private String event; + public String getEvent() { + if (event == null) { + event = "default"; + } + return event; + } + } + """, 0); + + assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty(); + } + + @Test + void shouldRejectGetterWithMethodCall() { + MethodDeclaration md = method(""" + public class Demo { + private String event; + public String getEvent() { return event.trim(); } + } + """, 0); + + assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty(); + } + + @Test + void shouldRejectUnknownField() { + MethodDeclaration md = method(""" + public class Demo { + public String getMissing() { return missing; } + } + """, 0); + + assertThat(detector.detect("com.example.Demo", md, fieldName -> Optional.empty())).isEmpty(); + } + } + + @Nested + class Setters { + + @Test + void shouldDetectVoidSetter() { + MethodDeclaration md = method(""" + public class Demo { + private String event; + public void setEvent(String event) { this.event = event; } + } + """, 0); + + Optional summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS); + + assertThat(summary).isPresent(); + assertThat(summary.get().kind()).isEqualTo(AccessorKind.SETTER); + assertThat(summary.get().paramName()).isEqualTo("event"); + assertThat(summary.get().returnsThis()).isFalse(); + } + + @Test + void shouldDetectFluentSetter() { + MethodDeclaration md = method(""" + public class Demo { + private String event; + public Demo setEvent(String event) { + this.event = event; + return this; + } + } + """, 0); + + Optional summary = detector.detect("com.example.Demo", md, KNOWN_FIELDS); + + assertThat(summary).isPresent(); + assertThat(summary.get().kind()).isEqualTo(AccessorKind.FLUENT_SETTER); + assertThat(summary.get().returnsThis()).isTrue(); + } + + @Test + void shouldRejectSetterWithValidation() { + MethodDeclaration md = method(""" + public class Demo { + private String event; + public void setEvent(String event) { + java.util.Objects.requireNonNull(event); + this.event = event; + } + } + """, 0); + + assertThat(detector.detect("com.example.Demo", md, KNOWN_FIELDS)).isEmpty(); + } + } + + @Nested + class Records { + + @Test + void shouldDetectRecordComponentAccessor() { + Optional summary = detector.detectRecordComponent("com.example.Event", "code"); + + assertThat(summary).isPresent(); + assertThat(summary.get().kind()).isEqualTo(AccessorKind.RECORD_COMPONENT); + assertThat(summary.get().methodName()).isEqualTo("code"); + assertThat(summary.get().fieldName()).isEqualTo("code"); + } + } + + @Nested + class Naming { + + @Test + void shouldDecapitalizeJavaBeanProperty() { + assertThat(TrivialAccessorDetector.decapitalize("Event")).isEqualTo("event"); + assertThat(TrivialAccessorDetector.decapitalize("URL")).isEqualTo("URL"); + } + } + + private static MethodDeclaration method(String classSource, int methodIndex) { + ASTParser parser = ASTParser.newParser(AST.getJLSLatest()); + parser.setSource(classSource.toCharArray()); + parser.setKind(ASTParser.K_COMPILATION_UNIT); + CompilationUnit cu = (CompilationUnit) parser.createAST(null); + TypeDeclaration td = (TypeDeclaration) cu.types().get(0); + return td.getMethods()[methodIndex]; + } +} diff --git a/state_machine_exporter/src/test/resources/junit-platform.properties b/state_machine_exporter/src/test/resources/junit-platform.properties new file mode 100644 index 0000000..0bb2355 --- /dev/null +++ b/state_machine_exporter/src/test/resources/junit-platform.properties @@ -0,0 +1,5 @@ +junit.jupiter.execution.parallel.enabled=true +junit.jupiter.execution.parallel.mode.default=concurrent +junit.jupiter.execution.parallel.mode.classes.default=concurrent +junit.jupiter.execution.parallel.config.strategy=dynamic +junit.jupiter.execution.parallel.config.dynamic.factor=1.0