extended analysis
This commit is contained in:
@@ -16,8 +16,7 @@ public class Main {
|
||||
public static void main(String[] args) {
|
||||
// Manual DI / Wiring
|
||||
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
|
||||
var enrichmentService = new EnrichmentService(Collections.emptyList());
|
||||
var exportService = new ExportService(exporters, enrichmentService);
|
||||
var exportService = new ExportService(exporters);
|
||||
var command = new ExporterCommand(exportService);
|
||||
|
||||
int exitCode = new CommandLine(command).execute(args);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
|
||||
public interface AnalysisEnricher {
|
||||
void enrich(AnalysisResult result, CodebaseContext context);
|
||||
void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class CallChainEnricher implements AnalysisEnricher {
|
||||
|
||||
@Override
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
log.info("Enriching {} with call chains", result.getName());
|
||||
|
||||
List<CallChain> chains = intelligence.findCallChains(
|
||||
result.getMetadata().getEntryPoints(),
|
||||
result.getMetadata().getTriggers()
|
||||
);
|
||||
|
||||
result.addMetadata(CodebaseMetadata.builder()
|
||||
.callChains(chains)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class EntryPointEnricher implements AnalysisEnricher {
|
||||
|
||||
@Override
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
log.info("Enriching {} with entry points", result.getName());
|
||||
|
||||
List<EntryPoint> entryPoints = intelligence.findEntryPoints();
|
||||
|
||||
result.addMetadata(CodebaseMetadata.builder()
|
||||
.entryPoints(entryPoints)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
public class PropertyEnricher implements AnalysisEnricher {
|
||||
|
||||
@Override
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
log.info("Enriching {} with properties", result.getName());
|
||||
|
||||
Map<String, String> properties = intelligence.resolveProperties();
|
||||
|
||||
result.addMetadata(CodebaseMetadata.builder()
|
||||
.properties(properties)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.enricher;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class TriggerEnricher implements AnalysisEnricher {
|
||||
|
||||
@Override
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
log.info("Enriching {} with triggers", result.getName());
|
||||
|
||||
List<TriggerPoint> triggers = intelligence.findTriggerPoints();
|
||||
|
||||
// Initially, we just add all triggers found in the codebase to the metadata.
|
||||
// In later steps of Phase 2, we will filter them by State Machine ID.
|
||||
result.addMetadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
|
||||
.triggers(triggers)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -10,6 +14,8 @@ import java.util.Set;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class AnalysisResult {
|
||||
private final String name;
|
||||
private final List<Transition> transitions;
|
||||
@@ -23,18 +29,22 @@ public class AnalysisResult {
|
||||
public void addMetadata(CodebaseMetadata newMetadata) {
|
||||
if (newMetadata == null) return;
|
||||
|
||||
List<CodebaseMetadata.TriggerPoint> combinedTriggers = new java.util.ArrayList<>(this.metadata.getTriggers());
|
||||
combinedTriggers.addAll(newMetadata.getTriggers());
|
||||
List<TriggerPoint> combinedTriggers = new java.util.ArrayList<>(this.metadata.getTriggers());
|
||||
if (newMetadata.getTriggers() != null) combinedTriggers.addAll(newMetadata.getTriggers());
|
||||
|
||||
List<CodebaseMetadata.EntryPoint> combinedEntryPoints = new java.util.ArrayList<>(this.metadata.getEntryPoints());
|
||||
combinedEntryPoints.addAll(newMetadata.getEntryPoints());
|
||||
List<EntryPoint> combinedEntryPoints = new java.util.ArrayList<>(this.metadata.getEntryPoints());
|
||||
if (newMetadata.getEntryPoints() != null) combinedEntryPoints.addAll(newMetadata.getEntryPoints());
|
||||
|
||||
List<CallChain> combinedCallChains = new java.util.ArrayList<>(this.metadata.getCallChains());
|
||||
if (newMetadata.getCallChains() != null) combinedCallChains.addAll(newMetadata.getCallChains());
|
||||
|
||||
java.util.Map<String, String> combinedProperties = new java.util.HashMap<>(this.metadata.getProperties());
|
||||
combinedProperties.putAll(newMetadata.getProperties());
|
||||
if (newMetadata.getProperties() != null) combinedProperties.putAll(newMetadata.getProperties());
|
||||
|
||||
this.metadata = CodebaseMetadata.builder()
|
||||
.triggers(Collections.unmodifiableList(combinedTriggers))
|
||||
.entryPoints(Collections.unmodifiableList(combinedEntryPoints))
|
||||
.callChains(Collections.unmodifiableList(combinedCallChains))
|
||||
.properties(Collections.unmodifiableMap(combinedProperties))
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class CallChain {
|
||||
private final EntryPoint entryPoint;
|
||||
private final List<String> methodChain; // e.g., ["Controller.submit", "Service.process", "Service.send"]
|
||||
private final TriggerPoint triggerPoint;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -10,19 +12,19 @@ import java.util.Map;
|
||||
|
||||
@Getter
|
||||
@Builder(toBuilder = true)
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class CodebaseMetadata {
|
||||
private final List<TriggerPoint> triggers;
|
||||
private final List<EntryPoint> entryPoints;
|
||||
private final Map<String, String> properties;
|
||||
@Builder.Default
|
||||
private final List<TriggerPoint> triggers = Collections.emptyList();
|
||||
@Builder.Default
|
||||
private final List<EntryPoint> entryPoints = Collections.emptyList();
|
||||
@Builder.Default
|
||||
private final List<CallChain> callChains = Collections.emptyList();
|
||||
@Builder.Default
|
||||
private final Map<String, String> properties = Collections.emptyMap();
|
||||
|
||||
public static CodebaseMetadata empty() {
|
||||
return CodebaseMetadata.builder()
|
||||
.triggers(Collections.emptyList())
|
||||
.entryPoints(Collections.emptyList())
|
||||
.properties(Collections.emptyMap())
|
||||
.build();
|
||||
return CodebaseMetadata.builder().build();
|
||||
}
|
||||
|
||||
public record TriggerPoint(String className, String methodName, String event, Map<String, String> metadata) {}
|
||||
public record EntryPoint(String className, String methodName, String type, Map<String, String> metadata) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class EntryPoint {
|
||||
public enum Type { REST, KAFKA, RABBIT, JMS, CUSTOM }
|
||||
|
||||
private final Type type;
|
||||
private final String name; // e.g., "POST /orders" or "Kafka Topic: orders"
|
||||
private final String className;
|
||||
private final String methodName;
|
||||
private final Map<String, String> metadata; // e.g., path, topic, exchange
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class LibraryHint {
|
||||
private final String methodFqn; // e.g., "com.thirdparty.Workflow.send"
|
||||
private final String event; // The event it triggers
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.extern.jackson.Jacksonized;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@Jacksonized
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class TriggerPoint {
|
||||
private final String event;
|
||||
private final String className;
|
||||
private final String methodName;
|
||||
private final String sourceModule;
|
||||
private final String stateMachineId; // Optional: to link to a specific SM instance
|
||||
private final int lineNumber;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
@Slf4j
|
||||
public class ConstantResolver {
|
||||
|
||||
public String resolve(Expression expr, CodebaseContext context) {
|
||||
if (expr == null) return null;
|
||||
|
||||
// 1. Literal?
|
||||
if (expr instanceof StringLiteral sl) {
|
||||
return sl.getLiteralValue();
|
||||
}
|
||||
if (expr instanceof NumberLiteral nl) {
|
||||
return nl.getToken();
|
||||
}
|
||||
if (expr instanceof BooleanLiteral bl) {
|
||||
return String.valueOf(bl.booleanValue());
|
||||
}
|
||||
|
||||
// 2. JDT Bindings? (Most robust)
|
||||
IVariableBinding binding = null;
|
||||
if (expr instanceof SimpleName sn) {
|
||||
IBinding b = sn.resolveBinding();
|
||||
if (b instanceof IVariableBinding vb) binding = vb;
|
||||
} else if (expr instanceof QualifiedName qn) {
|
||||
IBinding b = qn.resolveBinding();
|
||||
if (b instanceof IVariableBinding vb) binding = vb;
|
||||
}
|
||||
|
||||
if (binding != null && binding.isField()) {
|
||||
Object value = binding.getConstantValue();
|
||||
if (value != null) return value.toString();
|
||||
}
|
||||
|
||||
// 3. Fallback: Manual AST Traversal (if bindings not available or resolved)
|
||||
if (expr instanceof InfixExpression infix) {
|
||||
return resolveInfix(infix, context);
|
||||
}
|
||||
if (expr instanceof QualifiedName qn) {
|
||||
return resolveManual(qn, context);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveInfix(InfixExpression expr, CodebaseContext context) {
|
||||
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
|
||||
|
||||
String left = resolve(expr.getLeftOperand(), context);
|
||||
String right = resolve(expr.getRightOperand(), context);
|
||||
|
||||
if (left == null || right == null) return null;
|
||||
|
||||
StringBuilder sb = new StringBuilder(left + right);
|
||||
if (expr.hasExtendedOperands()) {
|
||||
for (Object operand : expr.extendedOperands()) {
|
||||
String val = resolve((Expression) operand, context);
|
||||
if (val == null) return null;
|
||||
sb.append(val);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String resolveManual(QualifiedName qn, CodebaseContext context) {
|
||||
String qualifier = qn.getQualifier().toString();
|
||||
String name = qn.getName().getIdentifier();
|
||||
|
||||
CompilationUnit contextCu = (CompilationUnit) qn.getRoot();
|
||||
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
|
||||
|
||||
if (td != null) {
|
||||
for (FieldDeclaration fd : td.getFields()) {
|
||||
for (Object fragmentObj : fd.fragments()) {
|
||||
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
|
||||
if (fragment.getName().getIdentifier().equals(name)) {
|
||||
Expression initializer = fragment.getInitializer();
|
||||
// Simple recursive resolve (be careful of cycles)
|
||||
if (initializer instanceof StringLiteral sl) return sl.getLiteralValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Slf4j
|
||||
public class PropertyResolver {
|
||||
|
||||
private final List<String> activeProfiles = new ArrayList<>();
|
||||
|
||||
public void setActiveProfiles(List<String> profiles) {
|
||||
this.activeProfiles.clear();
|
||||
if (profiles != null) {
|
||||
this.activeProfiles.addAll(profiles);
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, String> resolveProperties(Path rootDir) {
|
||||
Map<String, String> allProperties = new HashMap<>();
|
||||
|
||||
// 1. Load base properties
|
||||
loadMatching(rootDir, "application.properties", allProperties);
|
||||
loadMatching(rootDir, "application.yml", allProperties);
|
||||
loadMatching(rootDir, "application.yaml", allProperties);
|
||||
|
||||
// 2. Load profile-specific properties (overriding base)
|
||||
for (String profile : activeProfiles) {
|
||||
loadMatching(rootDir, "application-" + profile + ".properties", allProperties);
|
||||
loadMatching(rootDir, "application-" + profile + ".yml", allProperties);
|
||||
loadMatching(rootDir, "application-" + profile + ".yaml", allProperties);
|
||||
}
|
||||
|
||||
return allProperties;
|
||||
}
|
||||
|
||||
private void loadMatching(Path rootDir, String fileName, Map<String, String> target) {
|
||||
try (Stream<Path> paths = Files.walk(rootDir)) {
|
||||
paths.filter(p -> Files.isRegularFile(p))
|
||||
.filter(p -> p.getFileName().toString().equals(fileName))
|
||||
.forEach(p -> {
|
||||
if (p.toString().endsWith(".properties")) {
|
||||
target.putAll(loadProperties(p));
|
||||
} else {
|
||||
target.putAll(loadYaml(p));
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to scan for {} in {}", fileName, rootDir, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> loadProperties(Path path) {
|
||||
Map<String, String> props = new HashMap<>();
|
||||
Properties p = new Properties();
|
||||
try (InputStream is = Files.newInputStream(path)) {
|
||||
p.load(is);
|
||||
p.forEach((k, v) -> props.put(k.toString(), v.toString()));
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to load properties from {}", path);
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
private Map<String, String> loadYaml(Path path) {
|
||||
// Placeholder for future YAML support
|
||||
log.warn("YAML parsing not fully implemented yet for {}", path);
|
||||
return new HashMap<>();
|
||||
}
|
||||
|
||||
public String resolveValue(String placeholder, Map<String, String> properties) {
|
||||
if (placeholder == null || !placeholder.contains("${")) return placeholder;
|
||||
|
||||
// Very basic placeholder extraction: ${key} or ${key:default}
|
||||
String content = placeholder.substring(placeholder.indexOf("${") + 2, placeholder.lastIndexOf("}"));
|
||||
String key = content;
|
||||
String defaultValue = null;
|
||||
|
||||
if (content.contains(":")) {
|
||||
int colonIndex = content.indexOf(":");
|
||||
key = content.substring(0, colonIndex);
|
||||
defaultValue = content.substring(colonIndex + 1);
|
||||
}
|
||||
|
||||
return properties.getOrDefault(key, defaultValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class CallGraphBuilder {
|
||||
private final CodebaseContext context;
|
||||
|
||||
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||
Map<String, List<String>> callGraph = buildCallGraph();
|
||||
List<CallChain> chains = new ArrayList<>();
|
||||
|
||||
for (EntryPoint ep : entryPoints) {
|
||||
String startMethod = ep.getClassName() + "." + ep.getMethodName();
|
||||
for (TriggerPoint tp : triggers) {
|
||||
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
|
||||
List<String> path = findPath(startMethod, targetMethod, callGraph, new HashSet<>());
|
||||
if (path != null) {
|
||||
chains.add(CallChain.builder()
|
||||
.entryPoint(ep)
|
||||
.triggerPoint(tp)
|
||||
.methodChain(path)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
}
|
||||
return chains;
|
||||
}
|
||||
|
||||
private Map<String, List<String>> buildCallGraph() {
|
||||
Map<String, List<String>> graph = new HashMap<>();
|
||||
for (CompilationUnit cu : context.getCompilationUnits()) {
|
||||
cu.accept(new ASTVisitor() {
|
||||
private String currentMethodFqn;
|
||||
|
||||
@Override
|
||||
public boolean visit(MethodDeclaration node) {
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
currentMethodFqn = context.getFqn(td) + "." + node.getName().getIdentifier();
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
if (currentMethodFqn != null) {
|
||||
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
|
||||
for (String calledMethod : calledMethods) {
|
||||
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(calledMethod);
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
private List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) {
|
||||
String baseCalled = resolveCalledMethod(node);
|
||||
if (baseCalled == null) return Collections.emptyList();
|
||||
|
||||
List<String> allResolved = new ArrayList<>();
|
||||
allResolved.add(baseCalled);
|
||||
|
||||
// Polymorphic fan-out
|
||||
if (baseCalled.contains(".")) {
|
||||
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
|
||||
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
|
||||
|
||||
List<String> impls = context.getImplementations(className);
|
||||
for (String impl : impls) {
|
||||
allResolved.add(impl + "." + methodName);
|
||||
}
|
||||
}
|
||||
|
||||
return allResolved;
|
||||
}
|
||||
|
||||
private String resolveCalledMethod(MethodInvocation node) {
|
||||
Expression receiver = node.getExpression();
|
||||
String methodName = node.getName().getIdentifier();
|
||||
|
||||
if (receiver == null) {
|
||||
// Local call or static import (simplified)
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
return td != null ? context.getFqn(td) + "." + methodName : null;
|
||||
}
|
||||
|
||||
// Try to resolve receiver type
|
||||
// This is a simplified resolution without full JDT bindings.
|
||||
// It works for local variables and fields within the same project.
|
||||
ITypeBinding binding = receiver.resolveTypeBinding();
|
||||
if (binding != null) {
|
||||
return binding.getQualifiedName() + "." + methodName;
|
||||
}
|
||||
|
||||
// Fallback: heuristic resolution for simple cases like 'orderService.process()'
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
String receiverName = sn.getIdentifier();
|
||||
// Look for fields or variables (simplified)
|
||||
// For now, we'll return a "best guess" if we can't resolve it perfectly
|
||||
// In a real implementation, we'd use JDT bindings more heavily.
|
||||
return receiverName + "." + methodName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> findPath(String start, String target, Map<String, List<String>> graph, Set<String> visited) {
|
||||
if (start.equals(target)) return new ArrayList<>(List.of(start));
|
||||
if (!visited.add(start)) return null;
|
||||
|
||||
List<String> neighbors = graph.get(start);
|
||||
if (neighbors != null) {
|
||||
for (String neighbor : neighbors) {
|
||||
// Heuristic: neighbor might be 'orderService.process' while target is 'click.kamil...OrderService.process'
|
||||
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
|
||||
return new ArrayList<>(List.of(start, target));
|
||||
}
|
||||
List<String> path = findPath(neighbor, target, graph, visited);
|
||||
if (path != null) {
|
||||
path.add(0, start);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isHeuristicMatch(String neighbor, String target) {
|
||||
// Match 'orderService.process' with 'click.kamil.OrderService.process'
|
||||
if (target.endsWith("." + neighbor)) return true;
|
||||
// Match simple names if we don't have FQN
|
||||
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
|
||||
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
|
||||
|
||||
// Very basic heuristic for now
|
||||
return neighbor.toLowerCase().contains(simpleTarget.toLowerCase()) || target.toLowerCase().contains(simpleNeighbor.toLowerCase());
|
||||
}
|
||||
|
||||
private TypeDeclaration findEnclosingType(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof TypeDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return (TypeDeclaration) parent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public interface CodebaseIntelligenceProvider {
|
||||
/**
|
||||
* Discovers all locations where events are triggered.
|
||||
*/
|
||||
List<TriggerPoint> findTriggerPoints();
|
||||
|
||||
/**
|
||||
* Discovers all entry points (REST, Kafka, etc.).
|
||||
*/
|
||||
List<EntryPoint> findEntryPoints();
|
||||
|
||||
/**
|
||||
* Links Entry Points to Trigger Points.
|
||||
*/
|
||||
List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers);
|
||||
|
||||
/**
|
||||
* Resolves configuration properties.
|
||||
*/
|
||||
Map<String, String> resolveProperties();
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import java.util.List;
|
||||
public class EnrichmentService {
|
||||
private final List<AnalysisEnricher> enrichers;
|
||||
|
||||
public void enrich(AnalysisResult result, CodebaseContext context) {
|
||||
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
|
||||
for (AnalysisEnricher enricher : enrichers) {
|
||||
enricher.enrich(result, context);
|
||||
enricher.enrich(result, context, intelligence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class GenericEventDetector {
|
||||
private final CodebaseContext context;
|
||||
private final ConstantResolver constantResolver;
|
||||
private final List<LibraryHint> hints;
|
||||
|
||||
public List<TriggerPoint> detect(CompilationUnit cu) {
|
||||
List<TriggerPoint> triggers = new ArrayList<>();
|
||||
String fileName = cu.getJavaElement() != null ? cu.getJavaElement().getElementName() : "unknown";
|
||||
log.debug("Scanning for triggers in {}", fileName);
|
||||
|
||||
cu.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
String methodName = node.getName().getIdentifier();
|
||||
|
||||
// 1. Core sendEvent
|
||||
if ("sendEvent".equals(methodName)) {
|
||||
processSendEvent(node, cu, triggers);
|
||||
}
|
||||
|
||||
// 2. Library Hints
|
||||
processHints(node, cu, triggers);
|
||||
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return triggers;
|
||||
}
|
||||
|
||||
private void processSendEvent(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
|
||||
log.info("Found potential trigger: {}", node);
|
||||
TriggerPoint trigger = buildTriggerPoint(node, cu, null);
|
||||
if (trigger != null) {
|
||||
log.info("Successfully built trigger point: {}", trigger.getEvent());
|
||||
triggers.add(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
private void processHints(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
|
||||
if (hints == null || hints.isEmpty()) return;
|
||||
|
||||
String calledMethod = resolveCalledMethodName(node);
|
||||
if (calledMethod == null) return;
|
||||
|
||||
log.info("Checking method call '{}' against {} hints", calledMethod, hints.size());
|
||||
|
||||
for (LibraryHint hint : hints) {
|
||||
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) {
|
||||
log.info("MATCH! Found hint for '{}'", calledMethod);
|
||||
TriggerPoint trigger = buildTriggerPoint(node, cu, hint.getEvent());
|
||||
if (trigger != null) {
|
||||
log.info("Successfully built synthetic trigger point from hint: {}", trigger.getEvent());
|
||||
triggers.add(trigger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isHintMatch(String called, String hintFqn) {
|
||||
return hintFqn.endsWith("." + called);
|
||||
}
|
||||
|
||||
private String resolveCalledMethodName(MethodInvocation node) {
|
||||
Expression receiver = node.getExpression();
|
||||
String methodName = node.getName().getIdentifier();
|
||||
if (receiver == null) return methodName;
|
||||
|
||||
if (receiver instanceof SimpleName sn) {
|
||||
String name = sn.getIdentifier();
|
||||
// Check if it's a field in the enclosing class
|
||||
TypeDeclaration td = findEnclosingType(node);
|
||||
if (td != null) {
|
||||
for (FieldDeclaration fd : td.getFields()) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
if (fragObj instanceof VariableDeclarationFragment fragment) {
|
||||
if (fragment.getName().getIdentifier().equals(name)) {
|
||||
String typeName = fd.getType().toString();
|
||||
return typeName + "." + methodName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return name + "." + methodName;
|
||||
}
|
||||
|
||||
return receiver.toString() + "." + methodName;
|
||||
}
|
||||
|
||||
private TriggerPoint buildTriggerPoint(MethodInvocation node, CompilationUnit cu, String forcedEvent) {
|
||||
String eventValue;
|
||||
if (forcedEvent != null) {
|
||||
eventValue = forcedEvent;
|
||||
} else {
|
||||
if (node.arguments().isEmpty()) return null;
|
||||
Expression eventExpr = (Expression) node.arguments().get(0);
|
||||
eventValue = constantResolver.resolve(eventExpr, context);
|
||||
if (eventValue == null) {
|
||||
eventValue = eventExpr.toString();
|
||||
}
|
||||
}
|
||||
|
||||
MethodDeclaration method = findEnclosingMethod(node);
|
||||
TypeDeclaration type = findEnclosingType(node);
|
||||
|
||||
if (type == null) return null;
|
||||
|
||||
return TriggerPoint.builder()
|
||||
.event(eventValue)
|
||||
.className(context.getFqn(type))
|
||||
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
|
||||
.lineNumber(cu.getLineNumber(node.getStartPosition()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private MethodDeclaration findEnclosingMethod(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof MethodDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return (MethodDeclaration) parent;
|
||||
}
|
||||
|
||||
private TypeDeclaration findEnclosingType(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof TypeDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return (TypeDeclaration) parent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class InterceptorDetector {
|
||||
private final CodebaseContext context;
|
||||
|
||||
public List<EntryPoint> detect(CompilationUnit cu) {
|
||||
List<EntryPoint> entryPoints = new ArrayList<>();
|
||||
cu.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(TypeDeclaration node) {
|
||||
if (isInterceptor(node)) {
|
||||
processInterceptor(node, entryPoints);
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return entryPoints;
|
||||
}
|
||||
|
||||
private boolean isInterceptor(TypeDeclaration node) {
|
||||
if (node.getSuperclassType() != null) {
|
||||
// Check for common adapter/base classes if needed
|
||||
}
|
||||
for (Object interfaceType : node.superInterfaceTypes()) {
|
||||
String name = interfaceType.toString();
|
||||
if (name.contains("HandlerInterceptor") || name.contains("Filter") || name.contains("WebRequestInterceptor")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void processInterceptor(TypeDeclaration node, List<EntryPoint> entryPoints) {
|
||||
String fqn = context.getFqn(node);
|
||||
for (MethodDeclaration method : node.getMethods()) {
|
||||
String methodName = method.getName().getIdentifier();
|
||||
if (isInterceptorMethod(methodName)) {
|
||||
Map<String, String> metadata = new HashMap<>();
|
||||
metadata.put("interceptorType", getInterceptorType(node));
|
||||
|
||||
entryPoints.add(EntryPoint.builder()
|
||||
.type(EntryPoint.Type.CUSTOM)
|
||||
.name("INTERCEPTOR: " + node.getName().getIdentifier() + "." + methodName)
|
||||
.className(fqn)
|
||||
.methodName(methodName)
|
||||
.metadata(metadata)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isInterceptorMethod(String name) {
|
||||
return name.equals("preHandle") || name.equals("postHandle") || name.equals("afterCompletion") || name.equals("doFilter");
|
||||
}
|
||||
|
||||
private String getInterceptorType(TypeDeclaration node) {
|
||||
for (Object interfaceType : node.superInterfaceTypes()) {
|
||||
String name = interfaceType.toString();
|
||||
if (name.contains("HandlerInterceptor")) return "Spring MVC Interceptor";
|
||||
if (name.contains("Filter")) return "Servlet Filter";
|
||||
}
|
||||
return "Generic Interceptor";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
|
||||
private final CodebaseContext context;
|
||||
private final Path rootDir;
|
||||
private final ConstantResolver constantResolver = new ConstantResolver();
|
||||
private final GenericEventDetector eventDetector;
|
||||
private final SpringMvcDetector mvcDetector;
|
||||
private final CallGraphBuilder callGraphBuilder;
|
||||
private final LifecycleDetector lifecycleDetector;
|
||||
private final InterceptorDetector interceptorDetector;
|
||||
|
||||
public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) {
|
||||
this.context = context;
|
||||
this.rootDir = rootDir;
|
||||
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
|
||||
this.mvcDetector = new SpringMvcDetector(context, constantResolver);
|
||||
this.callGraphBuilder = new CallGraphBuilder(context);
|
||||
this.lifecycleDetector = new LifecycleDetector(context);
|
||||
this.interceptorDetector = new InterceptorDetector(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TriggerPoint> findTriggerPoints() {
|
||||
List<TriggerPoint> allTriggers = new java.util.ArrayList<>();
|
||||
var cus = context.getCompilationUnits();
|
||||
log.info("JdtIntelligenceProvider scanning {} compilation units for triggers", cus.size());
|
||||
for (CompilationUnit cu : cus) {
|
||||
allTriggers.addAll(eventDetector.detect(cu));
|
||||
allTriggers.addAll(lifecycleDetector.detect(cu));
|
||||
}
|
||||
log.info("Found {} triggers (including lifecycle) in total", allTriggers.size());
|
||||
return allTriggers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<EntryPoint> findEntryPoints() {
|
||||
List<EntryPoint> allEntryPoints = new java.util.ArrayList<>();
|
||||
var cus = context.getCompilationUnits();
|
||||
log.info("JdtIntelligenceProvider scanning {} compilation units for entry points", cus.size());
|
||||
for (CompilationUnit cu : cus) {
|
||||
allEntryPoints.addAll(mvcDetector.detect(cu));
|
||||
allEntryPoints.addAll(interceptorDetector.detect(cu));
|
||||
}
|
||||
log.info("Found {} entry points (including interceptors) in total", allEntryPoints.size());
|
||||
return allEntryPoints;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
|
||||
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers", entryPoints.size(), triggers.size());
|
||||
return callGraphBuilder.findChains(entryPoints, triggers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> resolveProperties() {
|
||||
return context.getProperties();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class LifecycleDetector {
|
||||
private final CodebaseContext context;
|
||||
|
||||
public List<TriggerPoint> detect(CompilationUnit cu) {
|
||||
List<TriggerPoint> lifecyclePoints = new ArrayList<>();
|
||||
cu.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(MethodInvocation node) {
|
||||
String methodName = node.getName().getIdentifier();
|
||||
if ("restore".equals(methodName) || "reset".equals(methodName)) {
|
||||
TriggerPoint point = buildLifecyclePoint(node, cu, methodName);
|
||||
if (point != null) {
|
||||
lifecyclePoints.add(point);
|
||||
}
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return lifecyclePoints;
|
||||
}
|
||||
|
||||
private TriggerPoint buildLifecyclePoint(MethodInvocation node, CompilationUnit cu, String type) {
|
||||
MethodDeclaration method = findEnclosingMethod(node);
|
||||
TypeDeclaration typeDecl = findEnclosingType(node);
|
||||
|
||||
if (typeDecl == null) return null;
|
||||
|
||||
return TriggerPoint.builder()
|
||||
.event("[LIFECYCLE:" + type.toUpperCase() + "]")
|
||||
.className(context.getFqn(typeDecl))
|
||||
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
|
||||
.lineNumber(cu.getLineNumber(node.getStartPosition()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private MethodDeclaration findEnclosingMethod(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof MethodDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return (MethodDeclaration) parent;
|
||||
}
|
||||
|
||||
private TypeDeclaration findEnclosingType(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof TypeDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return (TypeDeclaration) parent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class SpringMvcDetector {
|
||||
private final CodebaseContext context;
|
||||
private final ConstantResolver constantResolver;
|
||||
|
||||
public List<EntryPoint> detect(CompilationUnit cu) {
|
||||
List<EntryPoint> entryPoints = new ArrayList<>();
|
||||
cu.accept(new ASTVisitor() {
|
||||
@Override
|
||||
public boolean visit(TypeDeclaration node) {
|
||||
if (isRestController(node)) {
|
||||
processController(node, entryPoints);
|
||||
}
|
||||
return super.visit(node);
|
||||
}
|
||||
});
|
||||
return entryPoints;
|
||||
}
|
||||
|
||||
private boolean isRestController(TypeDeclaration node) {
|
||||
for (Object modifier : node.modifiers()) {
|
||||
if (modifier instanceof Annotation annotation) {
|
||||
String name = annotation.getTypeName().getFullyQualifiedName();
|
||||
if (name.endsWith("RestController") || name.endsWith("Controller")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void processController(TypeDeclaration controller, List<EntryPoint> entryPoints) {
|
||||
String basePath = getMappingPathHierarchical(controller);
|
||||
for (MethodDeclaration method : controller.getMethods()) {
|
||||
EntryPoint ep = processMethodHierarchical(method, basePath, controller);
|
||||
if (ep != null) {
|
||||
entryPoints.add(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String getMappingPathHierarchical(TypeDeclaration td) {
|
||||
// 1. Check class itself
|
||||
String path = getMappingPath(td);
|
||||
if (!path.isEmpty()) return path;
|
||||
|
||||
// 2. Check interfaces
|
||||
for (Object interfaceType : td.superInterfaceTypes()) {
|
||||
TypeDeclaration itd = context.getTypeDeclaration(interfaceType.toString());
|
||||
if (itd != null) {
|
||||
path = getMappingPath(itd);
|
||||
if (!path.isEmpty()) return path;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check superclass
|
||||
if (td.getSuperclassType() != null) {
|
||||
TypeDeclaration std = context.getTypeDeclaration(td.getSuperclassType().toString());
|
||||
if (std != null) {
|
||||
return getMappingPathHierarchical(std);
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private EntryPoint processMethodHierarchical(MethodDeclaration method, String basePath, TypeDeclaration controller) {
|
||||
// 1. Check the method itself
|
||||
EntryPoint ep = processMethod(method, basePath, controller);
|
||||
if (ep != null) return ep;
|
||||
|
||||
// 2. Check interface methods
|
||||
String methodName = method.getName().getIdentifier();
|
||||
for (Object interfaceType : controller.superInterfaceTypes()) {
|
||||
TypeDeclaration itd = context.getTypeDeclaration(interfaceType.toString());
|
||||
if (itd != null) {
|
||||
for (MethodDeclaration im : itd.getMethods()) {
|
||||
if (im.getName().getIdentifier().equals(methodName)) {
|
||||
ep = processMethod(im, basePath, controller);
|
||||
if (ep != null) return ep;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private EntryPoint processMethod(MethodDeclaration method, String basePath, TypeDeclaration controller) {
|
||||
for (Object modifier : method.modifiers()) {
|
||||
if (modifier instanceof Annotation annotation) {
|
||||
String name = annotation.getTypeName().getFullyQualifiedName();
|
||||
String verb = getHttpVerb(name);
|
||||
if (verb != null) {
|
||||
String methodPath = getMappingPath(annotation);
|
||||
String fullPath = combinePaths(basePath, methodPath);
|
||||
|
||||
Map<String, String> metadata = new HashMap<>();
|
||||
metadata.put("path", fullPath);
|
||||
metadata.put("verb", verb);
|
||||
|
||||
return EntryPoint.builder()
|
||||
.type(EntryPoint.Type.REST)
|
||||
.name(verb + " " + fullPath)
|
||||
.className(context.getFqn(controller))
|
||||
.methodName(method.getName().getIdentifier())
|
||||
.metadata(metadata)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getHttpVerb(String annotationName) {
|
||||
if (annotationName.endsWith("PostMapping")) return "POST";
|
||||
if (annotationName.endsWith("GetMapping")) return "GET";
|
||||
if (annotationName.endsWith("PutMapping")) return "PUT";
|
||||
if (annotationName.endsWith("DeleteMapping")) return "DELETE";
|
||||
if (annotationName.endsWith("PatchMapping")) return "PATCH";
|
||||
if (annotationName.endsWith("RequestMapping")) return "REQUEST";
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getMappingPath(TypeDeclaration td) {
|
||||
for (Object modifier : td.modifiers()) {
|
||||
if (modifier instanceof Annotation annotation) {
|
||||
if (annotation.getTypeName().getFullyQualifiedName().endsWith("RequestMapping")) {
|
||||
return getMappingPath(annotation);
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String getMappingPath(Annotation annotation) {
|
||||
Expression value = null;
|
||||
if (annotation instanceof SingleMemberAnnotation sma) {
|
||||
value = sma.getValue();
|
||||
} else if (annotation instanceof NormalAnnotation na) {
|
||||
for (Object pairObj : na.values()) {
|
||||
MemberValuePair pair = (MemberValuePair) pairObj;
|
||||
if ("value".equals(pair.getName().getIdentifier()) || "path".equals(pair.getName().getIdentifier())) {
|
||||
value = pair.getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (value != null) {
|
||||
String resolved = constantResolver.resolve(value, context);
|
||||
return resolved != null ? resolved : value.toString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String combinePaths(String base, String method) {
|
||||
String b = base.startsWith("/") ? base : "/" + base;
|
||||
if (b.endsWith("/")) b = b.substring(0, b.length() - 1);
|
||||
String m = method.startsWith("/") ? method : "/" + method;
|
||||
if ("/".equals(m)) m = "";
|
||||
return b + m;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package click.kamil.springstatemachineexporter.ast.app;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.model.Action;
|
||||
import click.kamil.springstatemachineexporter.model.Guard;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
@@ -26,6 +27,7 @@ import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class AstTransitionParser {
|
||||
private static final ConstantResolver constantResolver = new ConstantResolver();
|
||||
|
||||
private static final Set<String> SUPPORTED_TRANSITION_METHOD_NAMES = Arrays.stream(TransitionType.values())
|
||||
.map(TransitionType::getMethodName)
|
||||
@@ -215,7 +217,12 @@ public class AstTransitionParser {
|
||||
});
|
||||
case "event" -> {
|
||||
Expression resolved = resolveArg((Expression) args.get(0), argsMap);
|
||||
t.setEvent(QuotedExpression.of(resolved).toStringWithoutQuotes());
|
||||
String eventValue = constantResolver.resolve(resolved, context);
|
||||
if (eventValue != null) {
|
||||
t.setEvent(eventValue);
|
||||
} else {
|
||||
t.setEvent(QuotedExpression.of(resolved).toStringWithoutQuotes());
|
||||
}
|
||||
}
|
||||
case "guard" -> {
|
||||
Expression resolved = resolveArg((Expression) args.get(0), argsMap);
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
package click.kamil.springstatemachineexporter.ast.common;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.LibraryHint;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import org.eclipse.jdt.core.dom.AST;
|
||||
import org.eclipse.jdt.core.dom.ASTParser;
|
||||
import org.eclipse.jdt.core.dom.Annotation;
|
||||
import org.eclipse.jdt.core.dom.CompilationUnit;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.ImportDeclaration;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.Modifier;
|
||||
import org.eclipse.jdt.core.dom.QualifiedName;
|
||||
import org.eclipse.jdt.core.dom.SimpleName;
|
||||
import org.eclipse.jdt.core.dom.StringLiteral;
|
||||
import org.eclipse.jdt.core.dom.Type;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -30,36 +25,135 @@ public class CodebaseContext {
|
||||
private final Map<String, Path> classPaths = new HashMap<>();
|
||||
private final Map<String, String> simpleNameToFqn = new HashMap<>();
|
||||
private final Set<String> ambiguousSimpleNames = new HashSet<>();
|
||||
private final ConstantResolver constantResolver = new ConstantResolver();
|
||||
private final PropertyResolver propertyResolver = new PropertyResolver();
|
||||
private Map<String, String> properties = new HashMap<>();
|
||||
private List<LibraryHint> libraryHints = new ArrayList<>();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
public void loadLibraryHints(Path hintsFile) throws IOException {
|
||||
if (Files.exists(hintsFile)) {
|
||||
System.out.println("Loading hints from " + hintsFile.toAbsolutePath());
|
||||
this.libraryHints = objectMapper.readValue(hintsFile.toFile(), new TypeReference<List<LibraryHint>>() {});
|
||||
System.out.println("Loaded " + libraryHints.size() + " library hints");
|
||||
}
|
||||
}
|
||||
|
||||
public List<LibraryHint> getLibraryHints() {
|
||||
return Collections.unmodifiableList(libraryHints);
|
||||
}
|
||||
|
||||
private final Set<String> ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/**", "**/node_modules/**", "**/out/**"));
|
||||
private String[] classpath = new String[0];
|
||||
private String[] sourcepath = new String[0];
|
||||
private boolean resolveBindings = false;
|
||||
|
||||
public Map<String, String> getProperties() {
|
||||
return Collections.unmodifiableMap(properties);
|
||||
}
|
||||
|
||||
public void setClasspath(List<String> classpath) {
|
||||
this.classpath = classpath.toArray(new String[0]);
|
||||
}
|
||||
|
||||
public void setSourcepath(List<String> sourcepath) {
|
||||
this.sourcepath = sourcepath.toArray(new String[0]);
|
||||
}
|
||||
|
||||
public void setResolveBindings(boolean resolveBindings) {
|
||||
this.resolveBindings = resolveBindings;
|
||||
}
|
||||
|
||||
public void setActiveProfiles(List<String> profiles) {
|
||||
this.propertyResolver.setActiveProfiles(profiles);
|
||||
}
|
||||
|
||||
public void scan(Path rootDir) throws IOException {
|
||||
List<Path> javaFiles = FileUtils.findFilesWithExtension(Set.of(rootDir), ".java");
|
||||
scan(rootDir, Collections.emptySet());
|
||||
}
|
||||
|
||||
private final List<CompilationUnit> allCus = new ArrayList<>();
|
||||
|
||||
private final Map<String, List<String>> interfaceToImpls = new HashMap<>();
|
||||
|
||||
public void scan(Path rootDir, Set<String> customIgnorePatterns) throws IOException {
|
||||
this.properties = propertyResolver.resolveProperties(rootDir);
|
||||
|
||||
Set<String> activeIgnore = new HashSet<>(ignorePatterns);
|
||||
activeIgnore.addAll(customIgnorePatterns);
|
||||
|
||||
List<Path> javaFiles = FileUtils.findFilesWithExtension(Set.of(rootDir), ".java", activeIgnore);
|
||||
for (Path javaFile : javaFiles) {
|
||||
String source = Files.readString(javaFile);
|
||||
CompilationUnit cu = parse(source);
|
||||
CompilationUnit cu = parse(source, javaFile.getFileName().toString());
|
||||
allCus.add(cu);
|
||||
|
||||
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
|
||||
for (Object type : cu.types()) {
|
||||
if (type instanceof TypeDeclaration td) {
|
||||
String simpleName = td.getName().getIdentifier();
|
||||
String fqn = packageName.isEmpty() ? simpleName : packageName + "." + simpleName;
|
||||
classes.put(fqn, cu);
|
||||
classPaths.put(fqn, javaFile);
|
||||
|
||||
if (simpleNameToFqn.containsKey(simpleName)) {
|
||||
String existingFqn = simpleNameToFqn.get(simpleName);
|
||||
if (!existingFqn.equals(fqn)) {
|
||||
ambiguousSimpleNames.add(simpleName);
|
||||
}
|
||||
} else {
|
||||
simpleNameToFqn.put(simpleName, fqn);
|
||||
}
|
||||
indexType(td, packageName, javaFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void indexType(TypeDeclaration td, String packageName, Path javaFile) {
|
||||
String simpleName = td.getName().getIdentifier();
|
||||
String fqn = packageName.isEmpty() ? simpleName : packageName + "." + simpleName;
|
||||
|
||||
classes.put(fqn, (CompilationUnit) td.getRoot());
|
||||
classPaths.put(fqn, javaFile);
|
||||
|
||||
if (simpleNameToFqn.containsKey(simpleName)) {
|
||||
String existingFqn = simpleNameToFqn.get(simpleName);
|
||||
if (!existingFqn.equals(fqn)) {
|
||||
ambiguousSimpleNames.add(simpleName);
|
||||
}
|
||||
} else {
|
||||
simpleNameToFqn.put(simpleName, fqn);
|
||||
}
|
||||
|
||||
// Inheritance Mapping
|
||||
for (Object itf : td.superInterfaceTypes()) {
|
||||
String itfName = itf.toString();
|
||||
interfaceToImpls.computeIfAbsent(itfName, k -> new ArrayList<>()).add(fqn);
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> getImplementations(String interfaceName) {
|
||||
// Try direct match
|
||||
List<String> impls = interfaceToImpls.get(interfaceName);
|
||||
if (impls != null) return impls;
|
||||
|
||||
// Try FQN match if input was simple name
|
||||
String fqn = simpleNameToFqn.get(interfaceName);
|
||||
if (fqn != null) return interfaceToImpls.getOrDefault(fqn, Collections.emptyList());
|
||||
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
public State resolveState(Expression expr, CompilationUnit cu) {
|
||||
if (expr == null)
|
||||
return null;
|
||||
|
||||
// 1. Check for constants
|
||||
String resolvedValue = constantResolver.resolve(expr, this);
|
||||
if (resolvedValue != null) {
|
||||
return State.of(expr.toString(), resolvedValue);
|
||||
}
|
||||
|
||||
// 2. Check for @Value fields (SimpleName)
|
||||
if (expr instanceof SimpleName sn) {
|
||||
String fieldName = sn.getIdentifier();
|
||||
TypeDeclaration td = findEnclosingType(sn);
|
||||
if (td != null) {
|
||||
String value = findValueFromField(td, fieldName);
|
||||
if (value != null) {
|
||||
return State.of(fieldName, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expr instanceof StringLiteral sl)
|
||||
return State.of(sl.getLiteralValue());
|
||||
|
||||
@@ -75,6 +169,55 @@ public class CodebaseContext {
|
||||
return State.of(raw);
|
||||
}
|
||||
|
||||
private String findValueFromField(TypeDeclaration td, String fieldName) {
|
||||
for (FieldDeclaration fd : td.getFields()) {
|
||||
for (Object fragObj : fd.fragments()) {
|
||||
if (fragObj instanceof VariableDeclarationFragment fragment) {
|
||||
if (fragment.getName().getIdentifier().equals(fieldName)) {
|
||||
// Check for @Value annotation
|
||||
for (Object mod : fd.modifiers()) {
|
||||
if (mod instanceof Annotation ann) {
|
||||
String name = ann.getTypeName().getFullyQualifiedName();
|
||||
if (name.endsWith("Value")) {
|
||||
String placeholder = extractAnnotationValue(ann);
|
||||
return propertyResolver.resolveValue(placeholder, properties);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String extractAnnotationValue(Annotation ann) {
|
||||
if (ann instanceof SingleMemberAnnotation sma) {
|
||||
return stripQuotes(sma.getValue().toString());
|
||||
} else if (ann instanceof NormalAnnotation na) {
|
||||
for (Object pairObj : na.values()) {
|
||||
MemberValuePair pair = (MemberValuePair) pairObj;
|
||||
if ("value".equals(pair.getName().getIdentifier())) {
|
||||
return stripQuotes(pair.getValue().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String stripQuotes(String s) {
|
||||
if (s == null) return null;
|
||||
return s.replaceAll("^\"|\"$", "");
|
||||
}
|
||||
|
||||
private TypeDeclaration findEnclosingType(ASTNode node) {
|
||||
ASTNode parent = node.getParent();
|
||||
while (parent != null && !(parent instanceof TypeDeclaration)) {
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return (TypeDeclaration) parent;
|
||||
}
|
||||
|
||||
private String resolveQualifiedName(QualifiedName qn, CompilationUnit cu) {
|
||||
String qualifier = qn.getQualifier().toString();
|
||||
String name = qn.getName().getIdentifier();
|
||||
@@ -97,11 +240,15 @@ public class CodebaseContext {
|
||||
return name;
|
||||
}
|
||||
|
||||
private CompilationUnit parse(String source) {
|
||||
private CompilationUnit parse(String source, String unitName) {
|
||||
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
|
||||
parser.setSource(source.toCharArray());
|
||||
parser.setKind(ASTParser.K_COMPILATION_UNIT);
|
||||
parser.setResolveBindings(false);
|
||||
parser.setResolveBindings(resolveBindings);
|
||||
if (resolveBindings) {
|
||||
parser.setUnitName(unitName);
|
||||
parser.setEnvironment(classpath, sourcepath, null, true);
|
||||
}
|
||||
return (CompilationUnit) parser.createAST(null);
|
||||
}
|
||||
|
||||
@@ -321,4 +468,8 @@ public class CodebaseContext {
|
||||
private String getSimpleName(Type type) {
|
||||
return AstUtils.extractSimpleTypeName(type);
|
||||
}
|
||||
|
||||
public Collection<CompilationUnit> getCompilationUnits() {
|
||||
return Collections.unmodifiableCollection(allCus);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package click.kamil.springstatemachineexporter.ast.common;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.PathMatcher;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -10,6 +13,14 @@ import java.util.stream.Stream;
|
||||
|
||||
public class FileUtils {
|
||||
public static List<Path> findFilesWithExtension(Set<Path> startDirs, String extension) throws RuntimeException {
|
||||
return findFilesWithExtension(startDirs, extension, Collections.emptySet());
|
||||
}
|
||||
|
||||
public static List<Path> findFilesWithExtension(Set<Path> startDirs, String extension, Set<String> ignorePatterns) throws RuntimeException {
|
||||
List<PathMatcher> matchers = ignorePatterns.stream()
|
||||
.map(p -> FileSystems.getDefault().getPathMatcher("glob:" + p))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
try (Stream<Path> paths = startDirs.stream()
|
||||
.flatMap(startDir -> {
|
||||
try {
|
||||
@@ -20,6 +31,7 @@ public class FileUtils {
|
||||
})) {
|
||||
return paths
|
||||
.filter(p -> Files.isRegularFile(p) && p.toString().endsWith(extension))
|
||||
.filter(p -> matchers.stream().noneMatch(m -> m.matches(p)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,21 +19,20 @@ public class Dot implements StateMachineExporter {
|
||||
|
||||
@Override
|
||||
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, ExportOptions options) {
|
||||
return export(result.getName(), result.getTransitions(), result.getStartStates(), result.getEndStates(), options);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String export(String name,
|
||||
List<Transition> transitions,
|
||||
Set<String> startStates,
|
||||
Set<String> endStates,
|
||||
ExportOptions options) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("digraph statemachine {\n");
|
||||
sb.append(" rankdir=LR;\n");
|
||||
sb.append(" node [shape=rounded, style=filled, fillcolor=white, fontname=\"Arial\"];\n");
|
||||
sb.append(" edge [fontname=\"Arial\", fontsize=10];\n\n");
|
||||
|
||||
// --- Core State Machine Rendering ---
|
||||
renderCore(result.getTransitions(), result.getStartStates(), result.getEndStates(), options, sb);
|
||||
|
||||
sb.append("}\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void renderCore(List<Transition> transitions, Set<String> startStates, Set<String> endStates, ExportOptions options, StringBuilder sb) {
|
||||
// Choices
|
||||
Set<String> statesWithChoice = new LinkedHashSet<>();
|
||||
Map<String, String> choiceColorMap = new HashMap<>();
|
||||
@@ -187,7 +186,22 @@ public class Dot implements StateMachineExporter {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String export(String name,
|
||||
List<Transition> transitions,
|
||||
Set<String> startStates,
|
||||
Set<String> endStates,
|
||||
ExportOptions options) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("digraph statemachine {\n");
|
||||
sb.append(" rankdir=LR;\n");
|
||||
sb.append(" node [shape=rounded, style=filled, fillcolor=white, fontname=\"Arial\"];\n");
|
||||
sb.append(" edge [fontname=\"Arial\", fontsize=10];\n\n");
|
||||
|
||||
renderCore(transitions, startStates, endStates, options, sb);
|
||||
|
||||
sb.append("}\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,19 @@ public class JsonExporter implements StateMachineExporter {
|
||||
|
||||
@Override
|
||||
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, ExportOptions options) {
|
||||
return export(result.getName(), result.getTransitions(), result.getStartStates(), result.getEndStates(), options);
|
||||
try {
|
||||
java.util.Map<String, Object> output = new java.util.HashMap<>();
|
||||
output.put("name", result.getName());
|
||||
output.put("transitions", result.getTransitions());
|
||||
output.put("startStates", result.getStartStates());
|
||||
output.put("endStates", result.getEndStates());
|
||||
output.put("renderChoicesAsDiamonds", options.isRenderChoicesAsDiamonds());
|
||||
output.put("metadata", result.getMetadata());
|
||||
|
||||
return objectMapper.writeValueAsString(output);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to export to JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -11,6 +11,7 @@ import java.io.InputStreamReader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -35,19 +36,7 @@ public class PlantUml implements StateMachineExporter {
|
||||
|
||||
@Override
|
||||
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, ExportOptions options) {
|
||||
String baseContent = export(result.getName(), result.getTransitions(), result.getStartStates(), result.getEndStates(), options);
|
||||
|
||||
// Allow custom styling from metadata
|
||||
String customStyle = result.getMetadata().getProperties().get("plantuml.style");
|
||||
if (customStyle != null && !customStyle.isBlank()) {
|
||||
// Insert custom style before @enduml
|
||||
int endumlIndex = baseContent.lastIndexOf("@enduml");
|
||||
if (endumlIndex >= 0) {
|
||||
return baseContent.substring(0, endumlIndex) + "\n" + customStyle + "\n" + baseContent.substring(endumlIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return baseContent;
|
||||
return export(result.getName(), result.getTransitions(), result.getStartStates(), result.getEndStates(), options);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -59,6 +48,18 @@ public class PlantUml implements StateMachineExporter {
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("@startuml\n");
|
||||
sb.append("!pragma layout smetana\n");
|
||||
sb.append("hide empty description\n");
|
||||
sb.append("hide stereotype\n");
|
||||
|
||||
// Pre-declare all unique states for layout stability
|
||||
Set<String> allUniqueStates = new LinkedHashSet<>();
|
||||
transitions.forEach(t -> {
|
||||
t.getSourceStates().forEach(s -> allUniqueStates.add(simplify(s.toString())));
|
||||
t.getTargetStates().forEach(s -> allUniqueStates.add(simplify(s.toString())));
|
||||
});
|
||||
allUniqueStates.forEach(s -> sb.append("state ").append(s).append("\n"));
|
||||
sb.append("\n");
|
||||
|
||||
String style = loadBaseStyle();
|
||||
|
||||
@@ -82,7 +83,7 @@ public class PlantUml implements StateMachineExporter {
|
||||
|
||||
Set<String> statesWithChoice = new java.util.LinkedHashSet<>();
|
||||
for (Transition t : transitions) {
|
||||
if (t.getType() == TransitionType.CHOICE) {
|
||||
if (t.getType() == TransitionType.CHOICE && t.getSourceStates() != null) {
|
||||
for (State source : t.getSourceStates()) {
|
||||
statesWithChoice.add(simplify(source.toString()));
|
||||
}
|
||||
@@ -137,11 +138,10 @@ public class PlantUml implements StateMachineExporter {
|
||||
|
||||
for (String rawTarget : targets) {
|
||||
String target = simplify(rawTarget);
|
||||
String transitionSource = source;
|
||||
|
||||
String styleClass = getStyleClass(t, source, choiceStateClassMap);
|
||||
String color = getLegacyColor(t, source, choiceStateClassMap);
|
||||
sb.append(transitionSource).append(" -[#").append(color).append(",bold]-> ").append(target).append(" <<").append(styleClass).append(">>");
|
||||
sb.append(source).append(" -[#").append(color).append(",bold]-> ").append(target).append(" <<").append(styleClass).append(">>");
|
||||
|
||||
if (t.getEvent() != null && !t.getEvent().isBlank()) {
|
||||
String eventClass = "e_" + normalize(t.getEvent());
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package click.kamil.springstatemachineexporter.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.PropertyEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
|
||||
import click.kamil.springstatemachineexporter.analysis.service.JdtIntelligenceProvider;
|
||||
import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser;
|
||||
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
|
||||
import click.kamil.springstatemachineexporter.ast.app.TransitionStateUtils;
|
||||
@@ -10,7 +16,6 @@ import click.kamil.springstatemachineexporter.model.Transition;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.exporter.ExportOptions;
|
||||
import click.kamil.springstatemachineexporter.exporter.StateMachineExporter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
@@ -25,36 +30,62 @@ import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ExportService {
|
||||
|
||||
private final List<StateMachineExporter> exporters;
|
||||
private final EnrichmentService enrichmentService;
|
||||
|
||||
public ExportService(List<StateMachineExporter> exporters, EnrichmentService enrichmentService) {
|
||||
this.exporters = exporters;
|
||||
this.enrichmentService = enrichmentService;
|
||||
}
|
||||
|
||||
public ExportService(List<StateMachineExporter> exporters) {
|
||||
this(exporters, new EnrichmentService(List.of(
|
||||
new TriggerEnricher(),
|
||||
new EntryPointEnricher(),
|
||||
new PropertyEnricher(),
|
||||
new CallChainEnricher()
|
||||
)));
|
||||
}
|
||||
|
||||
private static final List<String> TARGET_ANNOTATIONS = List.of("EnableStateMachineFactory", "EnableStateMachine");
|
||||
public static final Set<String> STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory");
|
||||
|
||||
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
|
||||
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList());
|
||||
}
|
||||
|
||||
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles) throws IOException {
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setActiveProfiles(activeProfiles);
|
||||
context.setSourcepath(List.of(inputDir.toString()));
|
||||
context.setResolveBindings(true);
|
||||
|
||||
Path hintsFile = inputDir.resolve("hints.json");
|
||||
if (!Files.exists(hintsFile)) {
|
||||
hintsFile = inputDir.resolve("src/main/resources/hints.json");
|
||||
}
|
||||
|
||||
if (Files.exists(hintsFile)) {
|
||||
log.info("Loading hints from {}", hintsFile.toAbsolutePath());
|
||||
context.loadLibraryHints(hintsFile);
|
||||
}
|
||||
|
||||
context.scan(inputDir);
|
||||
exportAll(context, outputDir, selectedFormats, renderChoicesAsDiamonds);
|
||||
|
||||
CodebaseIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, inputDir);
|
||||
exportAll(context, intelligence, outputDir, selectedFormats, renderChoicesAsDiamonds);
|
||||
}
|
||||
|
||||
public void runJsonExporter(Path jsonFile, Path outputDir, List<String> selectedFormats) throws IOException {
|
||||
JsonImportService jsonImportService = new JsonImportService();
|
||||
StateMachineModel model = jsonImportService.importJson(jsonFile);
|
||||
AnalysisResult result = jsonImportService.importAnalysisResult(jsonFile);
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name(model.getName())
|
||||
.transitions(model.getTransitions())
|
||||
.startStates(model.getStartStates())
|
||||
.endStates(model.getEndStates())
|
||||
.renderChoicesAsDiamonds(model.isRenderChoicesAsDiamonds())
|
||||
.build();
|
||||
|
||||
generateOutputs(outputDir, result, selectedFormats);
|
||||
}
|
||||
|
||||
private void exportAll(CodebaseContext context, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
|
||||
private void exportAll(CodebaseContext context, CodebaseIntelligenceProvider intelligence, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
|
||||
Set<String> processedLocations = new HashSet<>();
|
||||
|
||||
// 1. Find entry point classes (annotated or extending adapter)
|
||||
@@ -62,7 +93,7 @@ public class ExportService {
|
||||
for (TypeDeclaration td : entryPoints) {
|
||||
String fqn = context.getFqn(td);
|
||||
if (processedLocations.add(fqn)) {
|
||||
processEntryPoint(td, outputDir, context, selectedFormats, renderChoicesAsDiamonds);
|
||||
processEntryPoint(td, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,12 +106,12 @@ public class ExportService {
|
||||
String uniqueId = parentFqn + "#" + methodName;
|
||||
|
||||
if (processedLocations.add(uniqueId)) {
|
||||
processBeanMethod(m, outputDir, context, selectedFormats, renderChoicesAsDiamonds);
|
||||
processBeanMethod(m, outputDir, context, intelligence, selectedFormats, renderChoicesAsDiamonds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
|
||||
private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
|
||||
String className = context.getFqn(td);
|
||||
log.info("Processing state machine config: {}", className);
|
||||
|
||||
@@ -105,12 +136,12 @@ public class ExportService {
|
||||
.renderChoicesAsDiamonds(renderChoicesAsDiamonds)
|
||||
.build();
|
||||
|
||||
enrichmentService.enrich(result, context);
|
||||
enrichmentService.enrich(result, context, intelligence);
|
||||
|
||||
generateOutputs(outputDir, result, selectedFormats);
|
||||
}
|
||||
|
||||
private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
|
||||
private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context, CodebaseIntelligenceProvider intelligence, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
|
||||
TypeDeclaration parentClass = (TypeDeclaration) m.getParent();
|
||||
String parentFqn = context.getFqn(parentClass);
|
||||
String methodName = m.getName().getIdentifier();
|
||||
@@ -131,29 +162,32 @@ public class ExportService {
|
||||
.renderChoicesAsDiamonds(renderChoicesAsDiamonds)
|
||||
.build();
|
||||
|
||||
enrichmentService.enrich(result, context);
|
||||
enrichmentService.enrich(result, context, intelligence);
|
||||
|
||||
generateOutputs(outputDir, result, selectedFormats);
|
||||
}
|
||||
|
||||
private void generateOutputs(Path outputDir, AnalysisResult result, List<String> selectedFormats) throws IOException {
|
||||
if (selectedFormats == null || selectedFormats.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path targetDir = outputDir.resolve(result.getName());
|
||||
String actualName = result.getName();
|
||||
Path targetDir = outputDir.resolve(actualName);
|
||||
Files.createDirectories(targetDir);
|
||||
|
||||
ExportOptions options = ExportOptions.builder()
|
||||
.useLambdaGuards(true)
|
||||
.renderChoicesAsDiamonds(result.isRenderChoicesAsDiamonds())
|
||||
.build();
|
||||
|
||||
for (StateMachineExporter output : exporters) {
|
||||
if (selectedFormats != null && !selectedFormats.isEmpty()) {
|
||||
boolean match = selectedFormats.stream().anyMatch(f -> output.getFileExtension().toLowerCase().contains(f.toLowerCase()));
|
||||
if (!match) continue;
|
||||
for (StateMachineExporter exporter : exporters) {
|
||||
String ext = exporter.getFileExtension().toLowerCase();
|
||||
if (!selectedFormats.stream().anyMatch(fmt -> ext.contains(fmt.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String content = output.export(result, options);
|
||||
String fileName = result.getName() + output.getFileExtension();
|
||||
String content = exporter.export(result, options);
|
||||
String fileName = result.getName() + exporter.getFileExtension();
|
||||
|
||||
try (PrintWriter out = new PrintWriter(targetDir.resolve(fileName).toFile())) {
|
||||
out.println(content);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package click.kamil.springstatemachineexporter.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.model.StateMachineModel;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
@@ -23,4 +24,8 @@ public class JsonImportService {
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
public AnalysisResult importAnalysisResult(Path jsonFile) throws IOException {
|
||||
return objectMapper.readValue(jsonFile.toFile(), AnalysisResult.class);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user