Index trivial accessors and inline them in dataflow, constant extraction, and call graph resolution.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -56,6 +56,7 @@ dependencies {
|
|||||||
|
|
||||||
tasks.named('test') {
|
tasks.named('test') {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
maxParallelForks = Math.max(1, Runtime.runtime.availableProcessors().intdiv(2) ?: 1)
|
||||||
systemProperty "updateGolden", System.getProperty("updateGolden")
|
systemProperty "updateGolden", System.getProperty("updateGolden")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<String> resolveFieldConstants(
|
||||||
|
AccessorSummary accessor,
|
||||||
|
CodebaseContext context,
|
||||||
|
ConstructorAnalyzer constructorAnalyzer,
|
||||||
|
CompilationUnit contextCu,
|
||||||
|
Set<String> visited,
|
||||||
|
BiConsumer<org.eclipse.jdt.core.dom.Expression, List<String>> 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<String> 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<org.eclipse.jdt.core.dom.Expression, List<String>> constantExtractor,
|
||||||
|
List<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Map<String, AccessorSummary>> byOwnerAndMethod;
|
||||||
|
private final int typeCount;
|
||||||
|
private final int trivialCount;
|
||||||
|
private final int blockedCount;
|
||||||
|
|
||||||
|
public AccessorIndex(
|
||||||
|
Map<String, Map<String, AccessorSummary>> 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<AccessorSummary> lookup(String ownerFqn, String methodName) {
|
||||||
|
if (ownerFqn == null || methodName == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Map<String, AccessorSummary> methods = byOwnerAndMethod.get(ownerFqn);
|
||||||
|
if (methods == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.ofNullable(methods.get(methodName));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AccessorSummary> accessorsForType(String ownerFqn) {
|
||||||
|
Map<String, AccessorSummary> methods = byOwnerAndMethod.get(ownerFqn);
|
||||||
|
if (methods == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return List.copyOf(methods.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Map<String, AccessorSummary>> asMap() {
|
||||||
|
return byOwnerAndMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int typeCount() {
|
||||||
|
return typeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int trivialCount() {
|
||||||
|
return trivialCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int blockedCount() {
|
||||||
|
return blockedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Map<String, AccessorSummary>> deepCopy(Map<String, Map<String, AccessorSummary>> source) {
|
||||||
|
Map<String, Map<String, AccessorSummary>> copy = new HashMap<>();
|
||||||
|
for (Map.Entry<String, Map<String, AccessorSummary>> entry : source.entrySet()) {
|
||||||
|
copy.put(entry.getKey(), Map.copyOf(entry.getValue()));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, DirectTypeAccessors> 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<String, Map<String, AccessorSummary>> effective = new HashMap<>();
|
||||||
|
int trivial = 0;
|
||||||
|
for (String ownerFqn : direct.keySet()) {
|
||||||
|
Map<String, AccessorSummary> 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<String, DirectTypeAccessors> 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<String, DirectTypeAccessors> 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<String, DirectTypeAccessors> 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<AccessorSummary> summary = detector.detect(ownerFqn, md, fieldLookup);
|
||||||
|
applyClassification(state, methodName, summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyClassification(DirectTypeAccessors state, String methodName, Optional<AccessorSummary> 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<String, AccessorSummary> mergeEffective(
|
||||||
|
String ownerFqn,
|
||||||
|
Map<String, DirectTypeAccessors> direct,
|
||||||
|
Map<String, Map<String, AccessorSummary>> cache,
|
||||||
|
Set<String> 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<String, AccessorSummary> 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<String, AccessorSummary> 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<String, AccessorSummary> trivial = new HashMap<>();
|
||||||
|
private final Set<String> blockedMethods = new HashSet<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> findFieldDeclaringFqn(TypeDeclaration td, String fieldName) {
|
||||||
|
return findFieldDeclaringFqn(td, fieldName, new HashSet<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<String> findFieldDeclaringFqn(TypeDeclaration td, String fieldName, Set<String> 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<String> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AccessorSummary> lookupAccessor(
|
||||||
|
CodebaseContext context,
|
||||||
|
MethodInvocation mi,
|
||||||
|
List<Expression> 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<AccessorSummary> 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<AccessorSummary> declared = lookupOnType(context, declaringClass.getQualifiedName(), methodName);
|
||||||
|
if (declared.isPresent()) {
|
||||||
|
return declared;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Optional<AccessorSummary> lookupAccessor(CodebaseContext context, MethodInvocation mi) {
|
||||||
|
return lookupAccessor(context, mi, List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<AccessorSummary> 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package click.kamil.springstatemachineexporter.analysis.index;
|
||||||
|
|
||||||
|
public enum AccessorKind {
|
||||||
|
GETTER,
|
||||||
|
BOOLEAN_GETTER,
|
||||||
|
RECORD_COMPONENT,
|
||||||
|
SETTER,
|
||||||
|
FLUENT_SETTER
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, Optional<String>> {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AccessorSummary> 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<String> getterField = propertyNameForGetter(methodName, method);
|
||||||
|
if (getterField.isPresent()) {
|
||||||
|
return detectGetter(ownerFqn, method, methodName, getterField.get(), fieldLookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<String> setterField = propertyNameForSetter(methodName);
|
||||||
|
if (setterField.isPresent() && !method.parameters().isEmpty()) {
|
||||||
|
return detectSetter(ownerFqn, method, methodName, setterField.get(), fieldLookup);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<AccessorSummary> 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<AccessorSummary> detectGetter(
|
||||||
|
String ownerFqn,
|
||||||
|
MethodDeclaration method,
|
||||||
|
String methodName,
|
||||||
|
String fieldName,
|
||||||
|
FieldLookup fieldLookup) {
|
||||||
|
if (!isTrivialGetterBody(method.getBody(), fieldName)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Optional<String> 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<AccessorSummary> 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<String> 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<String> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package click.kamil.springstatemachineexporter.analysis.service;
|
package click.kamil.springstatemachineexporter.analysis.service;
|
||||||
|
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
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.CallEdge;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||||
@@ -1364,6 +1365,25 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
|||||||
if (getter == null) {
|
if (getter == null) {
|
||||||
getter = context.findMethodDeclaration(td, getterName, true);
|
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<AccessorSummary> 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<String> indexedConstants = constantExtractor.resolveIndexedGetterFieldConstants(
|
||||||
|
indexedGetter.get(), contextCu, new HashSet<>());
|
||||||
|
if (!indexedConstants.isEmpty()) {
|
||||||
|
return indexedConstants;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (getter != null && getter.getBody() != null) {
|
if (getter != null && getter.getBody() != null) {
|
||||||
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor));
|
fieldValues.putAll(constructorAnalyzer.buildFieldValuesFromCIC(td, cic, context, this::evaluateMethodCallInConstructor));
|
||||||
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
|
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>());
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package click.kamil.springstatemachineexporter.analysis.service;
|
package click.kamil.springstatemachineexporter.analysis.service;
|
||||||
|
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
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 click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.eclipse.jdt.core.dom.*;
|
import org.eclipse.jdt.core.dom.*;
|
||||||
@@ -287,6 +289,16 @@ public class ConstantExtractor {
|
|||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Optional<AccessorSummary> indexedAccessor = context.getAccessorIndex().lookup(className, methodName);
|
||||||
|
if (indexedAccessor.isPresent() && indexedAccessor.get().isGetter()) {
|
||||||
|
List<String> indexedConstants = resolveIndexedGetterFieldConstants(
|
||||||
|
indexedAccessor.get(), contextCu, visited);
|
||||||
|
if (!indexedConstants.isEmpty()) {
|
||||||
|
visited.remove(fqn);
|
||||||
|
return indexedConstants;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<String> constants = new ArrayList<>();
|
List<String> constants = new ArrayList<>();
|
||||||
TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
|
TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
|
||||||
if (tempTd == null) {
|
if (tempTd == null) {
|
||||||
@@ -391,6 +403,19 @@ public class ConstantExtractor {
|
|||||||
return constants;
|
return constants;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<String> resolveIndexedGetterFieldConstants(
|
||||||
|
AccessorSummary accessor,
|
||||||
|
CompilationUnit contextCu,
|
||||||
|
Set<String> visited) {
|
||||||
|
return AccessorFieldResolver.resolveFieldConstants(
|
||||||
|
accessor,
|
||||||
|
context,
|
||||||
|
constructorAnalyzer,
|
||||||
|
contextCu,
|
||||||
|
visited,
|
||||||
|
this::extractConstantsFromExpression);
|
||||||
|
}
|
||||||
|
|
||||||
public String parseEnumSetElement(String eVal) {
|
public String parseEnumSetElement(String eVal) {
|
||||||
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
|
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package click.kamil.springstatemachineexporter.analysis.service;
|
|||||||
|
|
||||||
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
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 click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||||
import org.eclipse.jdt.core.dom.*;
|
import org.eclipse.jdt.core.dom.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -55,11 +57,47 @@ public class VariableTracer {
|
|||||||
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
|
||||||
if (md != null && md.getBody() != null) {
|
if (md != null && md.getBody() != null) {
|
||||||
final Expression[] setterArg = new Expression[1];
|
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<AccessorSummary> 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() {
|
md.getBody().accept(new ASTVisitor() {
|
||||||
@Override
|
@Override
|
||||||
public boolean visit(MethodInvocation node) {
|
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.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) {
|
||||||
if (!node.arguments().isEmpty()) {
|
if (!node.arguments().isEmpty()) {
|
||||||
setterArg[0] = (Expression) node.arguments().get(0);
|
setterArg[0] = (Expression) node.arguments().get(0);
|
||||||
@@ -72,13 +110,13 @@ public class VariableTracer {
|
|||||||
public boolean visit(Assignment node) {
|
public boolean visit(Assignment node) {
|
||||||
if (node.getLeftHandSide() instanceof FieldAccess fa) {
|
if (node.getLeftHandSide() instanceof FieldAccess fa) {
|
||||||
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
|
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();
|
setterArg[0] = node.getRightHandSide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (node.getLeftHandSide() instanceof QualifiedName qqn) {
|
} else if (node.getLeftHandSide() instanceof QualifiedName qqn) {
|
||||||
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
|
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
|
||||||
if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) {
|
if (qqn.getName().getIdentifier().equalsIgnoreCase(resolvedFieldName)) {
|
||||||
setterArg[0] = node.getRightHandSide();
|
setterArg[0] = node.getRightHandSide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,6 +130,32 @@ public class VariableTracer {
|
|||||||
return null;
|
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<String> visited) {
|
private String getFieldType(TypeDeclaration td, String fieldName, CodebaseContext context, java.util.Set<String> visited) {
|
||||||
if (td == null) return null;
|
if (td == null) return null;
|
||||||
String typeFqn = context.getFqn(td);
|
String typeFqn = context.getFqn(td);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package click.kamil.springstatemachineexporter.ast.common;
|
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.model.LibraryHint;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||||
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
|
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
|
||||||
@@ -42,11 +44,16 @@ public class CodebaseContext {
|
|||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private Path projectRoot;
|
private Path projectRoot;
|
||||||
private final Map<String, Object> cache = new HashMap<>();
|
private final Map<String, Object> cache = new HashMap<>();
|
||||||
|
private AccessorIndex accessorIndex = AccessorIndex.empty();
|
||||||
|
|
||||||
public Map<String, Object> getCache() {
|
public Map<String, Object> getCache() {
|
||||||
return cache;
|
return cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AccessorIndex getAccessorIndex() {
|
||||||
|
return accessorIndex;
|
||||||
|
}
|
||||||
|
|
||||||
public void setProjectRoot(Path root) {
|
public void setProjectRoot(Path root) {
|
||||||
this.projectRoot = 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) {
|
private void indexType(TypeDeclaration td, String parentFqn, Path javaFile) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package click.kamil.springstatemachineexporter.ast.common;
|
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 org.eclipse.jdt.core.dom.*;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
@@ -199,6 +201,14 @@ public class JdtDataFlowModel implements DataFlowModel {
|
|||||||
visited.remove(expr);
|
visited.remove(expr);
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Expression> inlined = tryInlineAccessorInvocation(
|
||||||
|
mi, new HashSet<>(visited), paramBindings, instanceFieldBindings, depth);
|
||||||
|
if (!inlined.isEmpty()) {
|
||||||
|
visited.remove(expr);
|
||||||
|
return inlined;
|
||||||
|
}
|
||||||
|
|
||||||
IMethodBinding mb = mi.resolveMethodBinding();
|
IMethodBinding mb = mi.resolveMethodBinding();
|
||||||
if (mb != null) {
|
if (mb != null) {
|
||||||
List<TargetMethod> targets = resolveTargets(mi, mb, visited, paramBindings, instanceFieldBindings, depth);
|
List<TargetMethod> targets = resolveTargets(mi, mb, visited, paramBindings, instanceFieldBindings, depth);
|
||||||
@@ -293,6 +303,433 @@ public class JdtDataFlowModel implements DataFlowModel {
|
|||||||
return List.of(expr);
|
return List.of(expr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<Expression> tryInlineAccessorInvocation(
|
||||||
|
MethodInvocation mi,
|
||||||
|
Set<ASTNode> visited,
|
||||||
|
Map<IVariableBinding, Expression> paramBindings,
|
||||||
|
Map<IVariableBinding, Expression> 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<AccessorSummary> accessor = context.getAccessorIndex().lookup(declaringFqn, methodName);
|
||||||
|
if (accessor.isEmpty() && !looksLikeIndexedAccessorName(methodName)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Expression> 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<Expression> 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<Expression> inlineRecordComponentRead(
|
||||||
|
MethodInvocation mi,
|
||||||
|
AccessorSummary accessor,
|
||||||
|
List<Expression> receiverDefs,
|
||||||
|
Set<ASTNode> visited,
|
||||||
|
Map<IVariableBinding, Expression> paramBindings,
|
||||||
|
Map<IVariableBinding, Expression> instanceFieldBindings,
|
||||||
|
int depth) {
|
||||||
|
Expression receiver = mi.getExpression();
|
||||||
|
List<Expression> candidates = receiverDefs.isEmpty() && receiver != null ? List.of(receiver) : receiverDefs;
|
||||||
|
List<Expression> 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<Expression> 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<Expression> inlineAccessorGetter(
|
||||||
|
MethodInvocation mi,
|
||||||
|
AccessorSummary accessor,
|
||||||
|
List<Expression> receiverDefs,
|
||||||
|
Set<ASTNode> visited,
|
||||||
|
Map<IVariableBinding, Expression> paramBindings,
|
||||||
|
Map<IVariableBinding, Expression> instanceFieldBindings,
|
||||||
|
int depth) {
|
||||||
|
Expression receiver = mi.getExpression();
|
||||||
|
List<Expression> 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<Expression> candidates = receiverDefs.isEmpty() ? List.of(receiver) : receiverDefs;
|
||||||
|
for (Expression candidate : candidates) {
|
||||||
|
if (candidate instanceof ClassInstanceCreation cic) {
|
||||||
|
Map<IVariableBinding, Expression> 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<IVariableBinding, Expression> fieldBindings,
|
||||||
|
Map<IVariableBinding, Expression> paramBindings,
|
||||||
|
Set<ASTNode> visited,
|
||||||
|
int depth) {
|
||||||
|
MethodDeclaration enclosingMethod = findEnclosingMethod(useSite);
|
||||||
|
if (enclosingMethod == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ReachingDefinitions rd = getReachingDefinitionsForMethod(enclosingMethod);
|
||||||
|
if (rd == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<ASTNode> 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<IVariableBinding, Expression> fieldBindings,
|
||||||
|
Map<IVariableBinding, Expression> paramBindings,
|
||||||
|
Set<ASTNode> 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<Expression> receiverDefs = getReachingDefinitions(
|
||||||
|
node.getExpression(), visited, paramBindings, fieldBindings, depth + 1);
|
||||||
|
Optional<AccessorSummary> 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<TargetMethod> 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<IVariableBinding, Expression> 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<String> 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<IVariableBinding, Expression> fieldBindings,
|
||||||
|
Map<IVariableBinding, Expression> paramBindings,
|
||||||
|
Set<ASTNode> visited,
|
||||||
|
int depth) {
|
||||||
|
if (mi.arguments().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Expression argument = (Expression) mi.arguments().get(0);
|
||||||
|
List<Expression> 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) {
|
private IVariableBinding getVariableBinding(Expression expr) {
|
||||||
if (expr instanceof SimpleName sn) {
|
if (expr instanceof SimpleName sn) {
|
||||||
IBinding b = sn.resolveBinding();
|
IBinding b = sn.resolveBinding();
|
||||||
@@ -704,6 +1141,22 @@ public class JdtDataFlowModel implements DataFlowModel {
|
|||||||
Map<IVariableBinding, Expression> fieldBindings) {
|
Map<IVariableBinding, Expression> fieldBindings) {
|
||||||
if (md == null || md.getBody() == null) return;
|
if (md == null || md.getBody() == null) return;
|
||||||
|
|
||||||
|
ASTNode parent = md.getParent();
|
||||||
|
if (parent instanceof AbstractTypeDeclaration typeDeclaration) {
|
||||||
|
String ownerFqn = context.getFqn(typeDeclaration);
|
||||||
|
Optional<AccessorSummary> 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<IVariableBinding, Expression> currentParamValues = new HashMap<>();
|
Map<IVariableBinding, Expression> currentParamValues = new HashMap<>();
|
||||||
List<?> parameters = md.parameters();
|
List<?> parameters = md.parameters();
|
||||||
int count = Math.min(parameters.size(), arguments.size());
|
int count = Math.min(parameters.size(), arguments.size());
|
||||||
@@ -790,6 +1243,14 @@ public class JdtDataFlowModel implements DataFlowModel {
|
|||||||
public boolean visit(MethodInvocation mi) {
|
public boolean visit(MethodInvocation mi) {
|
||||||
Expression receiver = mi.getExpression();
|
Expression receiver = mi.getExpression();
|
||||||
if (receiver != null && isTargetVariable(receiver, targetVar, targetName)) {
|
if (receiver != null && isTargetVariable(receiver, targetVar, targetName)) {
|
||||||
|
List<Expression> receiverDefs = getReachingDefinitions(
|
||||||
|
receiver, visited, paramBindings, fieldBindings, depth + 1);
|
||||||
|
Optional<AccessorSummary> 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();
|
IMethodBinding mb = mi.resolveMethodBinding();
|
||||||
if (mb != null) {
|
if (mb != null) {
|
||||||
List<TargetMethod> targets = resolveTargets(mi, mb, visited, paramBindings, fieldBindings, depth + 1);
|
List<TargetMethod> targets = resolveTargets(mi, mb, visited, paramBindings, fieldBindings, depth + 1);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package click.kamil.springstatemachineexporter;
|
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.analysis.service.EnrichmentService;
|
||||||
import click.kamil.springstatemachineexporter.exporter.Dot;
|
import click.kamil.springstatemachineexporter.exporter.Dot;
|
||||||
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
|
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
|
||||||
@@ -21,8 +23,12 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
public class RegressionTest {
|
public class RegressionTest {
|
||||||
|
|
||||||
private final List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
|
private static final List<StateMachineExporter> EXPORTERS =
|
||||||
private final ExportService exportService = new ExportService(exporters);
|
List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
|
||||||
|
|
||||||
|
private ExportService exportService() {
|
||||||
|
return new ExportService(EXPORTERS);
|
||||||
|
}
|
||||||
|
|
||||||
private static Path findProjectRoot() {
|
private static Path findProjectRoot() {
|
||||||
Path current = Path.of(".").toAbsolutePath();
|
Path current = Path.of(".").toAbsolutePath();
|
||||||
@@ -155,9 +161,10 @@ public class RegressionTest {
|
|||||||
|
|
||||||
@ParameterizedTest(name = "{0}")
|
@ParameterizedTest(name = "{0}")
|
||||||
@MethodSource("provideTestScenarios")
|
@MethodSource("provideTestScenarios")
|
||||||
|
@Execution(ExecutionMode.CONCURRENT)
|
||||||
void testOutputMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception {
|
void testOutputMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception {
|
||||||
System.out.println("Running test for " + scenario.name());
|
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 (scenario.inputPath() == null) System.out.println("inputPath is NULL");
|
||||||
if (tempDir == null) System.out.println("tempDir is NULL");
|
if (tempDir == null) System.out.println("tempDir is NULL");
|
||||||
|
|
||||||
|
|||||||
@@ -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<AccessorSummary> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String> 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<Expression> 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<AccessorSummary> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AccessorSummary> 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<AccessorSummary> 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<AccessorSummary> 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<AccessorSummary> 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<AccessorSummary> 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user