107 Commits

Author SHA1 Message Date
2717a01f13 up 2026-06-28 15:24:44 +02:00
f84043f26e up 2026-06-28 15:23:22 +02:00
4d9fee716b updates 2026-06-28 15:17:16 +02:00
f3d030b19a another test state machine 2026-06-28 11:57:01 +02:00
16dccbf81b another test state machine 2026-06-28 11:42:18 +02:00
20b4ed780b update tranistions 2026-06-28 11:23:24 +02:00
f4188f4384 update tranistions 3 2026-06-28 10:33:08 +02:00
96fc975170 update tranistions 2 2026-06-28 10:13:11 +02:00
8d3fb0d90c update tranistions 2 2026-06-28 09:56:05 +02:00
fc9d6f1f1c update tranistions 2026-06-28 09:45:09 +02:00
858e4524c8 update argument resolution to 2026-06-28 07:05:36 +02:00
93688ef59b update argument resolution to 2026-06-28 07:02:36 +02:00
ff2c8f8cfb update argument resolution to 2026-06-28 03:04:17 +02:00
901b43195a update 2026-06-27 20:25:29 +02:00
ed411f22d8 update 2026-06-27 20:20:49 +02:00
c9d41de13f update 2026-06-27 20:19:48 +02:00
df2a596e26 update 2026-06-27 20:16:20 +02:00
a75b7acfe4 update 2026-06-27 20:12:31 +02:00
74a4e66a33 update 2026-06-27 20:09:14 +02:00
4511e0e02f stage next 2026-06-27 20:07:24 +02:00
a7c3b08164 stage 3 2026-06-27 19:51:26 +02:00
8d66af6d24 stage 2 update loop 2026-06-27 19:33:44 +02:00
642f6f8926 stage 1 update loop 2026-06-27 19:19:59 +02:00
76bb88e065 stage 1 2026-06-27 19:18:32 +02:00
5c7cf3639b more tests fixes 2026-06-27 18:57:30 +02:00
016f0e9b21 more tests 2026-06-27 15:40:22 +02:00
22a0d3f5aa more bugs 2 2026-06-27 15:35:20 +02:00
7008f162b5 more bugs 2026-06-27 15:24:07 +02:00
60c666f7a1 better tests 2026-06-27 15:10:36 +02:00
8a56e92a65 more reorg 2026-06-27 14:50:06 +02:00
e8543ace38 tests reorg 2026-06-27 14:42:33 +02:00
101f2b5dc9 refactor 2026-06-27 14:32:10 +02:00
f525f3d3ce more fixes 2026-06-27 14:17:54 +02:00
5ae233eb75 claude bugs (question mark) 2026-06-27 07:19:18 +02:00
0bda5a0aeb gemini back 4 2026-06-27 05:56:23 +02:00
c35c75efa3 gemini back 3 2026-06-27 05:42:15 +02:00
bd55a66985 gemini back 2 2026-06-27 05:37:10 +02:00
0ac70bd3c6 gemini back 2026-06-27 05:29:12 +02:00
b45513c5fb heuristics updated enum collision 2026-06-25 06:39:31 +02:00
75fd45d983 heuristics updated 2026-06-25 05:45:49 +02:00
b35effd05c heuristics update 2026-06-25 05:45:49 +02:00
f30538e8c9 forward analysis nemotron3 ultra 2026-06-23 22:33:34 +02:00
131ccbeda0 forward analysis 2 2026-06-22 18:51:20 +02:00
14b311be2b forward analysis 2026-06-22 18:38:19 +02:00
1b690582d6 fixes 2 2026-06-22 14:11:35 +02:00
3ca834fa71 fixes 2026-06-22 13:53:27 +02:00
c6b4a4be1e better things extraction of constants 2 2026-06-22 13:12:48 +02:00
24a9be3a4e better things extraction of constants 2026-06-22 07:30:05 +02:00
07d241a060 better things 2026-06-21 22:55:39 +02:00
32de0a51c6 edge case fixes 2026-06-21 18:45:24 +02:00
cbb00e8a0f Fix AST constructor tracking gap for variables and fields 2026-06-21 18:36:37 +02:00
6596303b7d Replace fallback string scraper with precise AST traversal 2026-06-21 18:20:30 +02:00
c903b079d3 refactor 2026-06-21 17:53:26 +02:00
23d79d1930 event maching heuristic update 4 2026-06-21 15:54:36 +02:00
2720296d14 event maching heuristic update 3 2026-06-21 15:24:36 +02:00
b8b180ab3d event maching heuristic update 3 2026-06-21 14:58:28 +02:00
fc267c43c6 event maching heuristic update 2 2026-06-21 14:06:08 +02:00
968601eefc event maching heuristic update 2026-06-21 12:44:00 +02:00
e00f4dca81 beans fix more 2026-06-21 11:36:32 +02:00
bf82cc3562 beans fix 2026-06-21 09:00:36 +02:00
24b67be64b beans 2026-06-21 08:51:42 +02:00
9f651e7dc9 heuristics classpath 2026-06-21 07:51:25 +02:00
e06429cb02 heuristic call graph 2026-06-21 07:33:01 +02:00
9cf2d063b3 matching egnine extraction 2026-06-21 07:20:38 +02:00
073df21395 heauristic bean resolution 2026-06-21 06:55:55 +02:00
f9e6247eb3 fix 6 2026-06-20 13:20:05 +02:00
76ac143370 more fixes 5 2026-06-20 13:11:54 +02:00
9ca8b2fe37 more fixes 4 2026-06-20 12:31:11 +02:00
56cdf96f2d more fixes 3 2026-06-20 12:21:11 +02:00
c37aa92c78 more fixes 2 2026-06-20 07:57:03 +02:00
3ac057618f more fixes 2026-06-20 07:21:19 +02:00
d59f4ac166 fix attempt 1 2026-06-20 07:04:44 +02:00
19547d3c5f better html tooltip styling 2026-06-20 06:16:12 +02:00
ab37eb5d40 call graph fix + tests 2026-06-20 05:51:42 +02:00
bf9208d529 better enricher attempt 2026-06-19 21:21:44 +02:00
7077214c81 better enricher 2026-06-19 20:56:14 +02:00
344e295106 enricher 2026-06-19 19:18:23 +02:00
d6b1571f18 test1 2026-06-19 17:45:57 +02:00
e617fb1754 update 1 test 2026-06-19 17:37:08 +02:00
06dc0ba641 update 1 2026-06-19 17:33:32 +02:00
ba412b905e moar 2026-06-19 17:25:01 +02:00
198c5d830e test 2026-06-19 17:23:27 +02:00
a383de5a5f fix html attempt 2 2026-06-19 17:19:26 +02:00
ef9ebe3512 fix html 2026-06-19 17:11:34 +02:00
b480dc83ef transition enricher rabbit 2026-06-19 09:22:27 +02:00
0a23daae05 transition enricher 2026-06-19 09:06:37 +02:00
e894566112 transition enricher 2026-06-19 07:44:54 +02:00
8d4ba0697e transition enricher 2026-06-18 23:02:39 +02:00
d93d36e8ad html exporter fixed 2026-06-18 22:06:57 +02:00
5894303510 fqn names of events and states 2026-06-18 22:02:42 +02:00
0c9e8de310 inhertiance 2026-06-18 18:00:55 +02:00
566a814671 string resolution 2026-06-17 15:15:20 +02:00
f48cdf080a multi pom 2026-06-17 07:30:08 +02:00
1f4a1667c3 multi 2026-06-17 06:31:11 +02:00
79ddb307c1 fix: resolve properties before direct HTML generation 2026-06-16 19:33:54 +02:00
a9d40aaf8c v2.4 extended analysis render update 2026-06-16 18:32:11 +02:00
ac05513b6c v2.3 extended analysis render update 2026-06-15 22:25:05 +02:00
bd44049e5e v2.2 extended analysis render update 2026-06-15 16:37:19 +02:00
71bcfe65d7 v2.1 extended analysis render update 2026-06-14 22:14:47 +02:00
38d708f85b v2 extended analysis render update 2026-06-14 21:32:13 +02:00
2b6b727de6 v2 extended analysis render 2026-06-14 21:15:04 +02:00
744439c99d v1 extended analysis render 2026-06-14 20:10:55 +02:00
ab474c0766 extended analysis 2026-06-14 16:32:51 +02:00
2251a587d9 analysis update 2026-06-13 09:04:02 +02:00
e2798b26cf tests for golden png 2026-06-13 08:58:54 +02:00
f85a68d9fc enrichment schema 2 2026-06-13 08:45:22 +02:00
8641c7266f enrichment schema 2026-06-12 22:59:02 +02:00
438 changed files with 40259 additions and 3626 deletions

22
InspectGolden.java Normal file
View File

@@ -0,0 +1,22 @@
import java.nio.file.*;
import java.util.*;
import click.kamil.springstatemachineexporter.service.ExportService;
import click.kamil.springstatemachineexporter.exporter.*;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
public class InspectGolden {
public static void main(String[] args) throws Exception {
Path inputDir = Path.of("state_machines/complex_state_machine");
Path tempDir = Files.createTempDirectory("golden-update-");
List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList());
ExportService exportService = new ExportService(exporters, enrichmentService);
exportService.runExporter(inputDir, tempDir, null, true);
try (var stream = Files.list(tempDir)) {
stream.forEach(System.out::println);
}
}
}

View File

@@ -1,34 +1,38 @@
# Spring State Machine Explorer
```shell
brew install graphviz
Static analysis tool to extract and visualize Spring State Machines with deep code traceability.
## Core Modules
- **`state_machine_exporter`**: Java AST analyzer. Scans source code, resolves local Maven/Gradle dependencies, and extracts logic (Lambdas, Actions, Guards) into JSON/PUML.
- **`state_machine_exporter_html`**: Generates an interactive HTML portal with SVG diagrams and rich tooltips.
## Usage
### 1. Extract Metadata (JSON)
```bash
./gradlew :state_machine_exporter:run --args="-i ./my-project -o ./out -f json"
```
```shell
dot -Tpng statemachine.dot -o statemachine.png
### 2. Generate Interactive Portal (HTML)
```bash
./gradlew :state_machine_exporter_html:run --args="-i ./my-project -o ./out_html"
```
*Supports `--profiles prod` to resolve Spring property placeholders.*
## Business Flows
Define sequence of events in `src/main/resources/flows.json`:
```json
[
{
"name": "Order Success",
"description": "Happy path for order placement",
"steps": ["PAY", "CHECK_AVAILABILITY->PENDING", "SHIP"]
}
]
```
```shell
brew install plantuml
```
```shell
plantuml statemachine.puml
plantuml -tsvg statemachine.puml
```
Possible formats
```shell
-teps To generate images using EPS format
-thtml To generate HTML file for class diagram
-tlatex:nopreamble To generate images using LaTeX/Tikz format without preamble
-tlatex To generate images using LaTeX/Tikz format
-tpdf To generate images using PDF format
-tpng To generate images using PNG format (default)
-tscxml To generate SCXML file for state diagram
-tsvg To generate images using SVG format
-ttxt To generate images with ASCII art
-tutxt To generate images with ASCII art using Unicode characters
-tvdx To generate images using VDX format
-txmi To generate XMI file for class diagram
```
plantuml statemachine.scxml
## JSON Structure
- `metadata.entryPoints`: REST, WebFlux, and JMS entry points.
- `metadata.callChains`: Trace from API call to machine trigger (`sendEvent`).
- `transitions.actions[].internalLogic`: Extracted source code/lambdas.
- `metadata.properties`: Dictionary of all detected Spring profiles.

View File

@@ -1,6 +1,5 @@
1.extract from all possible formats (ask chatgpt)
[DONE] json
[TODO] xml, maybe yaml
2. [PLAN] Support external dependencies (JARs/compiled classes):

3
lombok.config Normal file
View File

@@ -0,0 +1,3 @@
lombok.anyConstructor.addConstructorProperties = true
lombok.jacksonized.flagUsage = ALLOW
config.stopBubbling = true

View File

@@ -0,0 +1,26 @@
## Phase 11: Formal Compiler & Static Analysis Alignment (Limitations & Missing Features)
While the custom AST-based `JdtDataFlowModel` supports advanced OOP data flow (like flow-sensitive local mutations, fluent builders, and polymorphic union fallbacks), the following compiler/static analysis behaviors are missing or simplified:
1. **Full Pointer & Alias Analysis**:
- If two variables alias the same object (e.g., `Command cmd1 = cmd2;`), a mutation on `cmd1` (e.g., `cmd1.setEvent("A")`) is not reflected when reading the fields of `cmd2` unless they resolve to the exact same AST node reference. A formal pointer/alias analysis (like Anderson's or Steensgaard's algorithm) would map abstract heap locations rather than AST nodes.
2. **Advanced Loop Fixpoints for Fields**:
- If a loop mutates a field based on its previous values (e.g., `while(cond) { builder.count(builder.getCount() + 1); }`), a simple sequential pass will miss the cyclic dependency. A formal data flow framework uses a mathematical lattice and runs a fixpoint iteration algorithm (using meet/join operators) to compute the stable approximation of values.
3. **Exception Path Analysis (Exception CFG)**:
- Edges for exception flows (`throw` statements, `try-catch-finally` blocks, and implicit unchecked runtime exceptions like `NullPointerException`) are not modeled. Consequently, mutations or return paths within catch/finally blocks may be missed or evaluated out of order.
4. **Collection & Array Tracking**:
- Arrays and Collections (such as `List`, `Map`, `Set`) are treated as opaque objects, and their operations (like `list.add(val)` or `map.put(key, val)`) are evaluated as general method calls. Modifying a collection does not propagate the values to its getter calls (e.g. `list.get(0)`).
5. **Field Shadowing and Inheritance Semantics**:
- If a subclass shadows a superclass field (declaring a field with the same name), our resolver might resolve the wrong field binding. Also, implicit superclass constructors and field initializers from superclass hierarchies are not fully simulated.
6. **Static Initialization Blocks and Static Mutable State**:
- Class loading execution order (i.e. static block initializations `static { ... }`) is not modeled. A mutation on a static field in one method is not propagated to reads in another method.
7. **Type Erasure & Generics Precision**:
- Generics type bounds (e.g., wildcards `? extends T`, type parameter replacements) are not fully tracked, which can result in incorrect polymorphic resolution when method signatures differ slightly from erased bindings.

View File

@@ -0,0 +1,42 @@
# Advanced Context Extraction: Payloads and Interceptors
## 1. Incoming Payload Analysis
To understand *what* data drives the state machine, we need to capture metadata about the incoming parameters at entry points.
### REST Endpoints
For methods annotated with `@PostMapping`, `@PutMapping`, etc.:
1. **Extract Parameters**: Scan method parameters for `@RequestBody`, `@PathVariable`, or `@RequestParam`.
2. **Type Extraction**: Capture the FQN of the parameter type (e.g., `com.example.OrderRequest`).
3. **Data Linkage**: If a field from this payload is used in a `sendEvent(payload.getEvent())` call, mark the transition as "Dynamic based on Payload".
### Message Listeners
For `@RabbitListener` or `@JmsListener`:
1. Identify the parameter receiving the message body.
2. Extract its type and any `@Header` or `@Payload` annotations.
## 2. Interceptor and Filter Analysis
Interceptors often perform cross-cutting logic (authentication, context injection) that affects how state machines are driven.
### Discovery Strategy
1. **Find Interceptors**: Scan for classes implementing `HandlerInterceptor` or `WebRequestInterceptor`.
2. **Registration Mapping**: Look for `WebMvcConfigurer.addInterceptors()` calls to identify which URL patterns each interceptor applies to.
3. **Context Injection**: Analyze if the interceptor modifies the request (e.g., `request.setAttribute("userContext", ...)`) or populates a `ThreadLocal` (like `SecurityContextHolder`).
### Linking to State Machines
If a controller uses a value from a known Interceptor-injected context to decide which event to send or which state machine to restore, we can link the Interceptor's logic to the State Machine flow.
## 3. Implementation in `EnrichmentService`
We will introduce a `ContextEnricher` that:
1. Builds a map of **Active Interceptors** per path.
2. Augments `EntryPoint` metadata with **Payload Type** information.
## 4. Modeling Updates
Update `EntryPoint` and `TriggerPoint` to include `payloadType` and `interceptors`.
```java
public record EntryPoint(
// ... existing fields ...
String payloadType,
List<String> activeInterceptors
) {}
```

View File

@@ -0,0 +1,63 @@
# Decoupled Analysis Architecture: The "Modular Provider" Approach
## Core Philosophy
We decouple the **State Machine Definition** (what the machine IS) from the **Codebase Intelligence** (how the machine IS USED). This allows for swappable analysis tools and prevents the state machine parser from becoming a monolithic "all-knowing" beast.
## 1. The Intelligence Interface (The Bridge)
We introduce an abstraction layer that provides information about the code without tying us to a specific implementation (like JDT).
```java
public interface CodebaseIntelligenceProvider {
// Event Triggers
List<TriggerPoint> findTriggerPoints();
// Entry Points
List<EntryPoint> findEntryPoints();
// Call Graphs
List<CallChain> traceCallChain(MethodReference target);
// Resolution
String resolveValue(ExpressionReference ref);
}
```
## 2. Swappable Implementations
This allows us to offer different "tiers" of analysis or even different tools:
- **JdtProvider (Current)**: High-precision, understands hierarchy, but heavy.
- **RegexProvider**: Lightweight, fast, good for simple projects or quick scans.
- **ExternalLspProvider (Future)**: Could query a running Language Server (like Eclipse JDT.LS) for ultra-accurate cross-project resolution.
## 3. The "State Machine Model" is the Sink
The State Machine Exporter now becomes a "Consumer" of intelligence.
1. **SM Parser**: Extracts the core states and transitions.
2. **Intelligence Service**: Discovers where events are triggered.
3. **Correlation Engine**: Merges the two models based on Event IDs.
## 4. Benefits
- **Testability**: We can mock the `CodebaseIntelligenceProvider` to test state machine rendering without needing a full Java project on disk.
- **Maintainability**: Adding support for a new framework (e.g., Micronaut) only requires updating the `IntelligenceProvider`, not touching the core state machine logic.
- **Performance**: We can run the SM Parser and the Intelligence Scanner in parallel since they are now independent.
## 5. Monorepo and Multi-Module Support
Applications are often split into multiple modules (e.g., `core`, `api`, `workers`).
- **Workspace Scanning**: The analyzer should treat the entire monorepo as a single codebase context.
- **Source Tracking**: Each metadata item (`TriggerPoint`, `EntryPoint`) includes a `sourceModule` identifier to show exactly where it was found.
- **Cross-Module Resolution**: Properties defined in one module's `application.yml` and used in another should be resolved globally.
- **Internal Dependency Following**: If the analyzer finds a call to a method in another module, it should continue the call-chain analysis into that module's source.
## Challenges
- **Multiple State Machines**: How to know which `StateMachine` instance is being used?
- Initial heuristic: If there's only one, assume it's that one.
- If multiple, check generic types `StateMachine<S, E>` or variable names.
- **Indirect Calls**: Method `A` (Controller) calls `B` (Service), and `B` calls `sendEvent`.
- Static analysis might need to follow call graphs.
- Use JDT's cross-reference capabilities if possible, or build a simple call graph.
- **Inheritance**:
- Controllers might extend base classes with common mappings.
- State machine configurations already handle inheritance; Trigger detection should too.
## Next Steps
1. Create a PoC `TriggerDetector` for Spring MVC.
2. Integrate `TriggerAggregator` into the main analysis pipeline.
3. Update the `Exporter` to visualize these links.

View File

@@ -0,0 +1,40 @@
# The Analysis Contract: Decoupling Metadata from Implementation
## 1. Unified Model
Instead of each parser having its own internal model, we move to a "Context-Rich Model".
```java
public record CodebaseMetadata(
List<TriggerPoint> triggers,
List<EntryPoint> entryPoints,
Map<String, String> properties,
CallGraph callGraph
) {}
```
## 2. Intelligence Provider Lifecycle
1. **Initialize**: Provide root paths and configuration.
2. **Scan**: The provider builds its internal index (e.g., JDT AST, Regex indices).
3. **Extract**: The provider returns a `CodebaseMetadata` object.
4. **Dispose**: Cleanup resources.
## 3. Tool-Specific Strengths
### JDT Provider (Advanced)
- **Strengths**: True semantic understanding, inheritance resolution, precise constant lookups.
- **Usage**: Deep analysis where we need to know that `BaseService.notify()` is actually what is being called in `ChildService`.
### Regex / Tree-Sitter Provider (Light)
- **Strengths**: Extremely fast, works even on broken code (doesn't need full classpath), very low memory overhead.
- **Usage**: Large monorepos where full JDT analysis would take minutes.
## 4. Aggregation Logic
The `IntelligenceService` can actually combine results from multiple providers!
- Use JDT for the State Machine Config (where we need high precision).
- Use Regex for a quick scan of 1000+ controllers to find mapping patterns.
## 5. Refactoring Strategy
1. **Extract**: Move current JDT-based transition parsing into a `JdtStateMachineProvider`.
2. **Define**: Create the `CodebaseMetadata` model.
3. **Bridge**: Update the `ExportService` to accept both a `StateMachineModel` and a `CodebaseMetadata` object.
4. **Implement**: Create the `CodebaseIntelligenceProvider` interface and its first implementation (wrapping the existing JDT logic).

View File

@@ -0,0 +1,67 @@
# Low-Impact Abstraction: The Enrichment Pipeline
## 1. Goal
Support enhanced codebase analysis (Endpoints, Listeners, etc.) without a massive refactor of the existing JDT state machine parsers.
## 2. The Core Model: `AnalysisResult`
We wrap the existing output into a container that can hold optional metadata.
```java
public class AnalysisResult {
// Current Core Data
private final String name;
private final List<Transition> transitions;
private final Set<String> startStates;
private final Set<String> endStates;
// Optional Enhanced Data
private CodebaseMetadata metadata = CodebaseMetadata.empty();
public AnalysisResult(String name, List<Transition> transitions, Set<String> startStates, Set<String> endStates) {
this.name = name;
this.transitions = transitions;
this.startStates = startStates;
this.endStates = endStates;
}
public void setMetadata(CodebaseMetadata metadata) {
this.metadata = metadata;
}
}
```
## 3. The "Enricher" Abstraction
Instead of modifying the parser, we "enrich" the result after it's produced.
```java
public interface AnalysisEnricher {
/**
* Examines the core analysis result and the codebase context
* to add extra metadata (triggers, endpoints, etc.).
*/
void enrich(AnalysisResult result, CodebaseContext context);
}
```
## 4. Implementation Tiers
### Tier 1: Current State (No-Op)
The `ExportService` calls `enrich()`, but since no enrichers are registered, it just returns the core data. This is zero-impact refactoring.
### Tier 2: Enhanced Analysis
Once we implement `SpringMvcEnricher` or `RabbitMqEnricher`, we register them. They will:
1. Scan the `CodebaseContext`.
2. Find `TriggerPoints`.
3. Link them to the `transitions` already present in the `AnalysisResult`.
4. Update the `metadata` field.
## 5. Why this is "Good":
- **Non-Breaking**: `StateMachineAggregator` remains untouched.
- **Pluggable**: You can add/remove "Intelligence" without changing the core parser.
- **Future-Proof**: If you switch from JDT to another tool for code analysis, you only change the `Enricher` implementation.
## 6. Refactoring Steps
1. **Create `AnalysisResult`** and **`CodebaseMetadata`** classes.
2. **Update `ExportService.generateOutputs`** to take an `AnalysisResult` object instead of 4 separate parameters.
3. **Introduce `EnrichmentService`** that holds a list of `AnalysisEnricher`s.
4. **Modify `ExportService`** to call `enrichmentService.enrich(result, context)` before passing the result to exporters.

View File

@@ -0,0 +1,92 @@
# Generic and Extensible Endpoint Finder
## The Need for Genericity
Applications use various frameworks and patterns to trigger state machine events. While Spring MVC and Kafka are common, others like Micronuat, Jakarta EE, or custom internal event buses might be used.
## Configuration-Driven Detection
We can allow users to define "Entry Point" patterns via a configuration file (e.g., `analysis-config.json`).
```json
{
"entryPoints": [
{
"name": "SpringMVC",
"classAnnotations": ["RestController", "Controller"],
"methodAnnotations": ["PostMapping", "GetMapping", "PutMapping", "DeleteMapping", "RequestMapping"],
"metadataExtractors": {
"path": ["value", "path"]
}
},
{
"name": "Kafka",
"methodAnnotations": ["KafkaListener"],
"metadataExtractors": {
"topic": ["topics", "topicPartitions"]
}
}
]
}
```
## Abstract EntryPoint Detector
A generic detector that uses the above configuration to identify entry points.
```java
public class ConfigurableTriggerDetector implements TriggerDetector {
private final AnalysisConfig config;
@Override
public List<TriggerPoint> detect(CompilationUnit cu, CodebaseContext context) {
// 1. Find all classes
// 2. If class has any of config.classAnnotations
// 3. Scan methods for any of config.methodAnnotations
// 4. If found, look for sendEvent calls inside the method or its call tree
}
}
```
## Indirect Flow Detection (Call Tree Analysis)
Oftentimes, the `sendEvent` call is not directly in the controller method but in a service called by the controller.
### Simple Call Tree Analysis
1. Find all methods that call `sendEvent(E)`. Let's call them "Direct Triggers".
2. Find all methods that call "Direct Triggers".
3. Recursively build this "Trigger Path".
4. If a "Trigger Path" starts at an "Entry Point" (Controller/Listener), we have a complete flow.
Example:
`OrderController.submitOrder()` -> `OrderService.processOrder()` -> `StateMachine.sendEvent(SUBMIT)`
Analysis:
1. `OrderService.processOrder()` is a Direct Trigger for `SUBMIT`.
2. `OrderController.submitOrder()` calls `OrderService.processOrder()`.
3. `OrderController.submitOrder()` is an Entry Point (annotated with `@PostMapping`).
4. Result: `POST /order` -> `SUBMIT` transition.
## Handling Reactive Chains Statically
In WebFlux/Project Reactor, the logic is decoupled into a pipeline. Static analysis must "connect the dots" across operators.
**Strategy**:
1. When analyzing a method, identify if it returns a reactive type (`Mono`, `Flux`).
2. If so, inspect all lambdas within that method.
3. If a lambda calls `sendEvent`, treat the method as a "Trigger Point".
4. If the method is also an "Entry Point" (via annotations or `RouterFunction`), we have a direct reactive flow.
## Supporting Inheritance in Listeners
Often, base classes or interfaces define generic listeners:
```java
public interface BaseConsumer<T> {
@RabbitListener(queues = "${app.queue}")
void handle(T payload);
}
```
The `CodebaseContext` must resolve the `${app.queue}` if possible (via `application.properties` scanning) or at least note that it's a parameterized listener.
## Advanced Call Graph: The "Data Flow"
For a truly detailed analysis, we should track NOT just method calls, but also how the "Event" object is passed around.
- Is the event hardcoded? `sm.sendEvent(Events.CREATE)`
- Is it derived from a payload field? `sm.sendEvent(payload.getEvent())`
- Is it mapped via a `switch` or `Map`?
**Static Mapping Inference**:
If we see a `switch` statement on a payload field that leads to different `sendEvent` calls, we can report multiple possible flows from the same endpoint.

View File

@@ -0,0 +1,102 @@
# Implementation Details: Trigger Detection with JDT
## 1. Finding `sendEvent` Calls
We can use an `ASTVisitor` to find all `MethodInvocation` nodes.
```java
public class SendEventVisitor extends ASTVisitor {
@Override
public boolean visit(MethodInvocation node) {
if ("sendEvent".equals(node.getName().getIdentifier())) {
// Found a call!
// 1. Extract event (first argument)
// 2. Identify the enclosing method and class
}
return super.visit(node);
}
}
```
### Challenges in Event Extraction
Events can be:
- Enum constants: `Events.SUBMIT`
- Strings: `"SUBMIT"`
- Variables: `sm.sendEvent(eventFromPayload)` (Hard to resolve statically)
We should reuse `CodebaseContext.resolveState` logic (which is basically resolving an expression to a value/fqn).
## 2. Identifying Enclosing Context
Once a `sendEvent` is found, we can traverse up the AST to find the `MethodDeclaration` and `TypeDeclaration`.
```java
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
// parent is now the MethodDeclaration
```
### 2. Extracting Annotations
From `MethodDeclaration`, we can check for mappings:
- `@PostMapping`, `@GetMapping`, etc.
- `@KafkaListener`: Extract `topics`, `groupId`.
- `@RabbitListener`: Extract `queues`, `bindings` (Exchange/RoutingKey).
From `TypeDeclaration`, we can check for:
- `@RestController`, `@Controller`
- `@RequestMapping` at class level (to get base path)
## 3. Specialized WebFlux Analysis
### Annotation-based WebFlux
This is largely identical to Spring MVC. The challenge is if the `sendEvent` is wrapped in a reactive operator.
```java
public Mono<Void> submit(Order order) {
return service.save(order)
.doOnNext(o -> stateMachine.sendEvent(Events.SUBMIT))
.then();
}
```
**Static Strategy**: We need to look inside `LambdaExpression` nodes passed to reactive operators (`doOnNext`, `flatMap`, `map`, `subscribe`). The `ASTVisitor` should traverse into these lambdas.
### Functional WebFlux (RouterFunctions)
Functional endpoints are often defined as Beans returning `RouterFunction`.
```java
@Bean
public RouterFunction<ServerResponse> route(OrderHandler handler) {
return RouterFunctions.route(POST("/orders"), handler::submitOrder);
}
```
**Static Strategy**:
1. Find methods returning `RouterFunction`.
2. Analyze the `MethodInvocation` chain (`route`, `andRoute`, `nest`).
3. Extract the URI pattern and the `HandlerFunction` reference.
4. If the handler is a method reference (`handler::submitOrder`), link it to the corresponding `MethodDeclaration`.
## 4. Specialized RabbitMQ Analysis
`@RabbitListener` can be complex:
```java
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = "orderQueue", durable = "true"),
exchange = @Exchange(value = "orderExchange"),
key = "order.created"
))
public void onOrder(Order order) { ... }
```
**Static Strategy**:
1. Find `@RabbitListener`.
2. If it has `bindings`, drill down into `@QueueBinding`, `@Queue`, `@Exchange` to extract the topology.
3. If it only has `queues`, resolve the queue name (might be a SpEL expression or property placeholder, which we can try to resolve or just keep as-is).
## 5. Indirect Flow Detection (The "Service Link")
If the project has multiple state machines, we need to know which one is being targeted.
Usually, this is done via:
- Autowiring by type: `StateMachine<States, Events> sm;`
- Autowiring by name: `@Qualifier("mySm") StateMachine sm;`
We can look at the fields of the class where `sendEvent` is called.
## 6. Output Enhancement
The `Exporter` should be modified to:
- In DOT: Add nodes for Endpoints/Listeners and link them to the Events/Transitions.
- In SCXML: Add metadata to transitions.

View File

@@ -0,0 +1,73 @@
# State Machine Instance Identification & Persistence Mapping
## 1. The Multi-SM Problem
In large applications, multiple State Machines often coexist. A "Trigger Point" (like a REST Controller) must be linked to the correct State Machine definition.
**Common Identification Patterns**:
- **Unique Types**: `StateMachine<OrderState, OrderEvent>` vs `StateMachine<UserState, UserEvent>`.
- **Bean Qualifiers**: `@Qualifier("orderStateMachine")`.
- **Factory IDs**: `factory.getStateMachine("order-123")`.
- **Persistence Restoration**: `persister.restore(sm, orderId)`.
## 2. Analysis Strategy: "Instance Tracking"
### 1. Definition Discovery (Existing)
Identify all configurations (classes with `@EnableStateMachineFactory` or `@EnableStateMachine`). Store their **Bean Names** and **Generic Types**.
### 2. Dependency Analysis
When a class (e.g., `OrderController`) uses a state machine:
1. **Identify the Field/Parameter**: Look for `StateMachine<S, E>`.
2. **Resolve Generic Types**: Match `S` and `E` against known SM definitions.
3. **Resolve Qualifiers**: Check for `@Qualifier` or variable names that match a SM bean name.
### 3. Loading & Persistence Analysis
A dedicated "Loading Detector" will look for persistence logic.
**Pattern: Persister Restore**
```java
persister.restore(stateMachine, id);
stateMachine.sendEvent(E);
```
**Static Strategy**:
- Find calls to `Persister.restore(sm, ...)` or `PersistStateChangeListener`.
- Link the variable `sm` to the restoration event.
- Mark the `TriggerPoint` as "Restored from Persistence".
**Pattern: Factory Creation**
```java
StateMachine sm = factory.getStateMachine(smId);
```
**Static Strategy**:
- Find `factory.getStateMachine(...)`.
- If the argument is a literal (e.g., `"order"`), map it to the SM definition named "order".
## 3. Implementation: `InstanceIdentifier`
We will introduce an `InstanceIdentifier` that works alongside the `ValueResolver`.
```java
public class InstanceIdentifier {
public StateMachineReference identify(VariableDeclaration var, CodebaseContext context) {
// 1. Check type generics
// 2. Check @Qualifier
// 3. Trace back to factory or persister calls
}
}
```
## 4. Modeling in `AnalysisResult`
The `TriggerPoint` will be enhanced with a `stateMachineId` or `configFqn` field.
```java
public record TriggerPoint(
String className,
String methodName,
String event,
String stateMachineId, // Links back to the specific SM
boolean isRestoredFromPersistence,
Map<String, String> metadata
) {}
```
## 5. Challenges
- **Generic Controllers**: A single base controller that handles multiple SMs via generics. We might need to report "Multiple Potential SMs".
- **Dynamic Factory IDs**: `factory.getStateMachine(payload.getType())`. Hard to resolve statically without data flow analysis.

View File

@@ -0,0 +1,55 @@
# Polyglot Analysis: Supporting Mixed Java and Kotlin
## 1. The Need for Language Abstraction
Professional JVM codebases are increasingly "Mixed Mode" (Java + Kotlin). Tying our analysis logic directly to JDT (Java AST) creates a bottleneck for Kotlin support.
## 2. The "Driver" Architecture
We decouple the **Intelligence Logic** (e.g., "Find all REST controllers") from the **AST Parser** (e.g., "Walk the JDT tree").
### 1. Language Driver Interface
```java
public interface LanguageDriver {
boolean supports(Path filePath);
// Unified Extraction Methods
List<RawClassMetadata> extractClasses(Path filePath);
List<RawMethodMetadata> extractMethods(RawClassMetadata cls);
List<RawInvocationMetadata> findInvocations(RawMethodMetadata method, String targetMethodName);
}
```
### 2. Unified Metadata Model (Intermediate Representation)
We move to a "Generic JVM Model" before performing the final state machine mapping.
```java
public record RawClassMetadata(
String fqn,
String language, // "java", "kotlin"
List<String> annotations,
String superclass,
List<String> interfaces
) {}
public record RawMethodMetadata(
String name,
List<String> annotations,
String returnType,
List<String> parameterTypes
) {}
```
## 3. Polyglot Analysis Flow
1. **File Discovery**: Find all `.java` and `.kt` files.
2. **Driver Assignment**: Delegate each file to the appropriate `LanguageDriver`.
3. **Cross-Language Resolution**: The `CodebaseContext` stores metadata for ALL classes.
- A Java controller calling a Kotlin service is resolved by looking up the Kotlin class's unified metadata.
4. **Enricher Execution**: Enrichers (like `SpringMvcEnricher`) now operate on the **Unified Metadata Model**, making them language-agnostic!
## 4. Why this is "Future Proof":
- **Enrichers are written once**: The logic to find `@PostMapping` only needs to know how to query the `RawClassMetadata` annotations, not how to traverse JDT vs. Kotlin PSI.
- **Easy Expansion**: To support Scala, Groovy, or even a newer Java version, you just add a new `LanguageDriver`.
## 5. Refactoring Plan
1. **Define Intermediate Models**: Create `RawClassMetadata` and `RawMethodMetadata`.
2. **Extract JDT Logic**: Move the current JDT-specific code into `JavaLanguageDriver`.
3. **Kotlin PoC (Phase 12)**: Implement a basic `KotlinLanguageDriver` using Regex or a lightweight parser (like Tree-Sitter) to prove the abstraction works.

View File

@@ -0,0 +1,80 @@
# Spring Property and Value Resolution Strategy
## 1. The Challenge of "Injected" Metadata
In Spring, metadata (like Queue names, Topic names, or even Event names) is often not hardcoded but injected using various mechanisms. To provide an accurate map, the analyzer must resolve these values.
## 2. Supported Injection Patterns
### Field Injection
```java
@Value("${app.order-queue}")
private String orderQueue;
```
**Static Strategy**:
1. Scan all fields for `@Value`.
2. Map `FieldName` -> `Placeholder/Value`.
3. If `${...}` is found, resolve it via the `PropertyResolver`.
### Constructor and Setter Injection
```java
public OrderService(@Value("${app.event.submit}") String submitEvent) {
this.submitEvent = submitEvent;
}
```
**Static Strategy**:
1. Scan constructor/method parameters for `@Value`.
2. Trace the assignment to a class field (`this.submitEvent = ...`).
3. Store the mapping for that field.
### @ConfigurationProperties
```java
@ConfigurationProperties(prefix = "app.messaging")
public class AppProps {
private String queueName;
}
```
**Static Strategy**:
1. Identify `@ConfigurationProperties` classes.
2. Index their fields with the prefix (e.g., `app.messaging.queueName`).
3. When these beans are injected into a service, link the service's usage to the resolved property value.
## 3. Profile-Aware Property Resolver
A component dedicated to building a multi-dimensional map of available properties.
1. **File Scanning**: Parse `application.properties`, `application.yml`, and `bootstrap.yml`.
2. **Profile Identification**:
- From filenames: `application-{profile}.properties`.
- From YAML documents: `spring.config.activate.on-profile` (Spring Boot 2.4+).
3. **The Multi-Profile Map**:
- Instead of one map, we store: `Map<ProfileName, Map<Key, Value>>`.
- The "default" profile is the base.
## 4. Smart Value Propagation
When a trigger uses a property, we want to know it's profile-dependent.
**Example Trace**:
1. `application.yml` -> `app.queue: "orders"`
2. `application-prod.yml` -> `app.queue: "orders-prod"`
3. `TriggerPoint` stores:
- `placeholder: "${app.queue}"`
- `defaultResolvedValue: "orders"`
4. **Rendering Strategy**: The renderer can show "orders" by default, but provide a "Profile Toggle" to switch to "prod" and see the labels update to "orders-prod".
## 5. Metadata Retention
We should never "squash" profiles during analysis. The `CodebaseMetadata` will carry the full profile matrix to the exporter.
## 5. SpEL (Spring Expression Language) Lite
Full SpEL support is hard for static analysis, but we can support "Common Patterns":
- Simple bean property access: `#{myBean.name}`
- System properties: `#{systemProperties['user.dir']}`
- Ternary operators: `${app.enabled ? 'active' : 'inactive'}`
## 6. Constant Resolution
If an annotation uses a reference to a constant:
```java
@RabbitListener(queues = MyConstants.ORDER_QUEUE)
```
**Static Strategy**:
1. Use JDT to resolve the `QualifiedName` (`MyConstants.ORDER_QUEUE`).
2. Fetch the `TypeDeclaration` for `MyConstants`.
3. Find the `VariableDeclarationFragment` for `ORDER_QUEUE` and extract its literal initializer.

View File

@@ -0,0 +1,53 @@
# Advanced String and Variable Resolution Strategy
## 1. The Problem
Annotations often use non-literal values for metadata:
- **Constants**: `@PostMapping(ApiConstants.SUBMIT_PATH)`
- **Concatenation**: `@RequestMapping(BASE + "/orders")`
- **Inherited Variables**: Use of fields defined in base classes.
Simple `StringLiteral` extraction fails in these cases. We need a recursive **Expression Evaluator**.
## 2. Evaluation Logic
### Constant Resolution
If an expression is a `SimpleName` or `QualifiedName`:
1. Use `CodebaseContext.getTypeDeclaration()` to find the class where the variable is defined.
2. Search for the `VariableDeclarationFragment`.
3. If it has an initializer that is a literal or another evaluatable expression, resolve it.
### Concatenation (Infix Expressions)
If an expression is an `InfixExpression` with the `+` operator:
1. Recursively resolve the left operand.
2. Recursively resolve the right operand.
3. Concatenate the results if both are strings.
### Spring Expression Language (SpEL) in Annotations
For values like `@Value("#{systemProperties['path.base'] + '/api'}")`:
1. Identify the SpEL string.
2. Use a "Lite" SpEL parser to extract keys.
3. Match against the `profiles` map in `CodebaseMetadata`.
## 3. Implementation: `ValueResolver`
We will introduce a central `ValueResolver` utility.
```java
public class ValueResolver {
public String resolveString(Expression expr, CodebaseContext context) {
if (expr instanceof StringLiteral sl) return sl.getLiteralValue();
if (expr instanceof InfixExpression ie) return resolveInfix(ie, context);
if (expr instanceof Name name) return resolveVariable(name, context);
// ... fallback to toString() or empty
}
}
```
## 4. Challenges
- **Circular Dependencies**: A + B, where B = A + C. We need a "visited" set to prevent infinite recursion.
- **Runtime-only values**: Some values cannot be resolved statically (e.g., values from a DB). In these cases, we should return the placeholder or a "Runtime Value" marker.
- **Method Calls**: `getPath() + "/orders"`. Resolving method return values is complex; initially, we will only support simple getters returning literals.
## 5. Integration
1. Update `AstUtils` or create `ValueResolver`.
2. Refactor `SpringMvcEnricher`, `RabbitMqEnricher`, and `JmsEnricher` to use the resolver for all path/queue/destination fields.
3. Update `integration_test_state_machine` with complex path examples.

5
run_golden.gradle Normal file
View File

@@ -0,0 +1,5 @@
task runGolden(type: JavaExec) {
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
classpath = sourceSets.test.runtimeClasspath
workingDir = rootProject.projectDir
}

View File

@@ -2,6 +2,7 @@ rootProject.name = 'state machine exporter'
include ':file_combine'
include ':state_machine_exporter'
include ':state_machine_exporter_html'
include ':state_machine_exporter_spring_based'
include ':state_machines:complex_state_machine'
include ':state_machines:dynamic_state_machine'
@@ -12,3 +13,10 @@ include ':state_machines:inheritance_extra_functions_state_machine'
include ':state_machines:inheritance_extra_functions2_state_machine'
include ':state_machines:inheritance_extra_functions3_state_machine'
include ':state_machines:simple_state_machine'
include ':state_machines:extended_analysis_sample'
include ':state_machines:inheritance_sample'
include ':state_machines:ultimate_ecosystem_sm'
include ':state_machines:enterprise_order_system'
include ':state_machines:multi_module_sample:api-module'
include ':state_machines:multi_module_sample:core-module'
include ':state_machines:state_machine_enterprise'

View File

@@ -36,6 +36,7 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1'
compileOnly 'org.projectlombok:lombok:1.18.46'
annotationProcessor 'org.projectlombok:lombok:1.18.46'
@@ -45,8 +46,22 @@ dependencies {
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:6.1.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:6.1.0'
testImplementation 'org.assertj:assertj-core:3.27.7'
implementation 'net.sourceforge.plantuml:plantuml:1.2024.3'
}
tasks.named('test') {
useJUnitPlatform()
systemProperty "updateGolden", System.getProperty("updateGolden")
}
task runGolden(type: JavaExec) {
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
classpath = sourceSets.test.runtimeClasspath
}
task runGoldenFixed(type: JavaExec) {
mainClass = "click.kamil.springstatemachineexporter.GoldenUpdater"
classpath = sourceSets.test.runtimeClasspath
workingDir = rootProject.projectDir
}

View File

@@ -1,101 +0,0 @@
# Interactive HTML Generator: Architecture & Flow Definition Format
Following the success of the interactive SVG PoC, this document outlines the proposed architecture for adding an **HTML Generator** to the State Machine Exporter, allowing users to define and visualize specific business flows dynamically.
## 1. The Flow Definition Format (JSON & JSON Schema)
To make the interactive HTML generator useful and intuitive, users need a way to define their business flows externally. Rather than reinventing the wheel, we looked at industry standards for defining graph paths, workflows, and execution traces:
* **Workflow Definitions:** Tools like *Amazon States Language (ASL)*, *SCXML*, and *XState* are highly structured but define the *entire* machine's logic. They are too heavy for simply defining a "path" through an existing machine.
* **Trace Formats:** *OpenTelemetry (OTel)* uses "Spans" to represent individual steps forming a "Trace". This is closer to what we want (a history of states).
* **Graph Formats:** The *JSON Graph Format (JGF)* standardizes graph nodes and edges representation but is geared towards raw data modeling rather than human-readable business flows.
**Conclusion on Format:** We will use a lightweight custom JSON format inspired by execution traces. It is designed to be highly readable for humans defining business flows, relying simply on ordered arrays of State names.
### JSON Schema for IDE Autocomplete
To make the format truly intuitive and prevent typos, we will publish a **JSON Schema** (`flow-schema.json`).
By referencing this schema, developers writing `flows.json` in IDEs like VS Code or IntelliJ will get **instant autocomplete, hover tooltips, and real-time validation** for fields like `id`, `title`, and `color`.
* *Future Enhancement:* The Java exporter could dynamically generate a project-specific JSON Schema on the fly that hardcodes the allowed `enum` values for the `path` array (e.g., restricted exactly to `"SUBMITTED"`, `"PAID"`, etc.), providing perfect autocomplete for state names!
### Proposed Schema (`flows.json`)
```json
{
"$schema": "./flow-schema.json",
"stateMachine": "click.kamil.examples.statemachine.simple.SimpleEnumStateMachineConfiguration",
"flows": [
{
"id": "flow-happy-path",
"title": "Happy Path (Success)",
"description": "The standard flow for a successful order: Submitted -> Paid -> Fulfilled.",
"color": "#32CD32",
"path": [
".start.",
"SUBMITTED",
"PAID",
"FULFILLED",
".end."
]
},
{
"id": "flow-cancel-post-pay",
"title": "Cancel Post-Payment",
"description": "Order is canceled after payment.",
"color": "#FF6347",
"path": [
".start.",
"SUBMITTED",
"PAID",
"CANCELED",
".end."
]
}
]
}
```
### Format Details:
* **`$schema`**: Points to the JSON schema for IDE tooling.
* **`stateMachine`**: Links these flows to a specific state machine configuration in the codebase.
* **`id`**: A unique identifier for the flow (used for DOM elements).
* **`title` & `description`**: Rendered in the HTML sidebar for the user.
* **`color` (Optional)**: Allows overriding the default highlight color (e.g., green for success, red for errors).
* **`path`**: An ordered array of **State Names** (acting like an OpenTelemetry trace).
* *Note on Edges:* The user only needs to define the *states* they pass through. The generator will automatically calculate the edges (transitions) between these states to highlight the lines. Special tokens like `.start.` and `.end.` map to the initial and terminal pseudo-states.
## 2. Robust Error Handling & Input Validation
Before generating the HTML, the exporter **must validate** that the provided `flows.json` is logically sound against the actual codebase AST. If a user defines a flow that isn't physically possible in the state machine, the tool must fail fast with highly descriptive errors.
### Validation Rules & Error Handling Strategy:
1. **Schema Validation (Syntax):**
* *Check:* Is the JSON structurally correct (required fields present, correct types)?
* *Error:* "Invalid format in flows.json: 'title' is required for flow 'flow-cancel-post-pay'."
2. **State Existence (Dangling Pointers):**
* *Check:* Every state listed in the `path` array must exist in the parsed `StateMachineModel`.
* *Error:* "Validation Error in 'Happy Path': State 'FULLFILLED' does not exist in StateMachine 'SimpleEnum'. Did you mean 'FULFILLED'?" (implementing a Levenshtein distance check for typos would be excellent here).
3. **Transition Validity (Impossible Paths):**
* *Check:* For every adjacent pair in the `path` array (e.g., `["SUBMITTED", "PAID"]`), the validator must check the `StateMachineModel.getTransitions()` to ensure a valid transition exists where `source == "SUBMITTED"` and `target == "PAID"`.
* *Error:* "Validation Error in 'Happy Path': No transition exists from 'PAID' to 'SUBMITTED'. Please check your state machine configuration."
4. **Ambiguous Transitions (Multiple Edges):**
* *Check:* If multiple transitions exist between the same two states (e.g., two different events trigger a move from A -> B).
* *Handling:* By default, highlight *all* edges between A and B to represent the general path.
* *Future Enhancement:* Expand the JSON format to allow specifying the exact `event` name in the path array (e.g., `["SUBMITTED", {"state": "PAID", "event": "PAY_CREDIT_CARD"}]`) to disambiguate.
## 3. HTML Generation Strategy
The HTML Exporter will be a new class implementing the `StateMachineExporter` interface (or a specialized decorator around the `PlantUml` exporter).
### Steps for Generation:
1. **Generate PlantUML SVG:** First, use the existing PlantUML engine to generate the raw SVG string in memory (using the modern CSS `<style>` approach we finalized).
2. **Parse `flows.json`:** Load the user-provided flow definitions.
3. **Validate Flows:** Run the "Flow Validator" against the AST `StateMachineModel`.
4. **Calculate Edges:** For each valid flow, convert the `path` array (States) into an `edges` array (Transitions) so the Javascript knows exactly which SVG arrows to highlight.
5. **Inject Template:** Load a base HTML template (similar to `interactive_poc2.html`).
6. **Data Binding:**
* Inject the raw `<svg>` string directly into the template (avoiding CORS issues).
* Inject the validated flow data into a `<script>` tag as a JSON object, or directly render the sidebar HTML elements with `data-*` attributes.
7. **Output:** Write the final standalone `.html` file to the output directory.
## 4. Advantages of this Architecture
* **Standalone Output:** The resulting HTML file is 100% self-contained. No server, no external CSS/JS, and no CORS issues. It can be directly emailed, attached to Jira tickets, or hosted on GitHub Pages.
* **CI/CD Integration:** Because validation is strictly tied to the AST, if a developer refactors the Java State Machine and breaks a documented "Flow", the CI pipeline will fail, ensuring interactive documentation never goes out of date.

View File

@@ -1,66 +0,0 @@
# Thought Experiment: Interactive SVG Flow Highlighting
## Overview
The goal of this thought experiment is to explore the feasibility and usefulness of animating or interactively highlighting specific "flows" (paths composed of states and transitions) within a generated state machine diagram. For example, hovering over a label like "Successful Payment Flow" would highlight the `SUBMITTED -> PAID -> FULFILLED` path, dimming the rest of the diagram.
## Usefulness
This feature would be **highly useful** and would significantly elevate the value of the State Machine Exporter:
1. **Comprehension of Complex Machines:** In large diagrams (like `ComplexStateMachineConfig`), a static image becomes a "spaghetti" of crossing lines. Interactive highlighting allows developers to visually isolate a single business transaction (e.g., "Cancellation Flow", "Refund Flow") without losing the context of the whole machine.
2. **Documentation & Onboarding:** It turns a static architectural diagram into an interactive documentation tool, making it much easier for new team members to trace how an entity moves through its lifecycle.
3. **Debugging:** By defining flows based on logged events, a developer could "replay" a sequence of events and see exactly what path the state machine took.
## Feasibility
This is **entirely feasible** using the tools currently available in the project.
### The Technical Reality of PlantUML SVGs
When PlantUML generates an SVG (`plantuml -tsvg`), it embeds highly predictable metadata into the DOM structure:
- **States (Nodes):** PlantUML creates `<g>` elements for states. They typically have an `id` matching the state name (e.g., `id="FULFILLED"`) or prefixed with entity (e.g., `id="entity_SUBMITTED"`).
- **Transitions (Links):** PlantUML creates `<g>` elements for arrows with predictable IDs and data attributes. For example: `<g class="link" data-entity-1="SUBMITTED" data-entity-2="PAID" id="link_SUBMITTED_PAID">`.
Because these identifiers match the semantic names of the states in the Java/JSON model, mapping a logical "Flow" to visual SVG elements is straightforward.
## Proposed Technical Approach
To realize this, we would build a lightweight **Interactive HTML Exporter** (or augment the JSON exporter to generate a companion HTML file).
### 1. Data Structure (The "Flow" Definition)
We need a way to define flows. This could be passed via configuration or inferred from the model. A flow is essentially an array of IDs:
```json
{
"name": "Successful Payment",
"elements": ["SUBMITTED", "link_SUBMITTED_PAID", "PAID", "link_PAID_FULFILLED", "FULFILLED"]
}
```
### 2. HTML/JS Wrapper
Instead of just outputting an `.svg` or `.png`, the exporter would output an `.html` file that contains:
1. **The inline SVG code** (generated via PlantUML).
2. **A Sidebar UI** containing a list of defined flows.
3. **Vanilla JavaScript** to handle the interactivity.
### 3. The JavaScript Logic (Proof of Concept)
The JS logic is remarkably simple. It listens for `mouseenter` events on the flow list:
1. **Dim Everything:** Add a class to the root SVG wrapper (e.g., `.flow-active`) that sets `opacity: 0.2` on all `<g>` tags.
2. **Highlight Flow:** Iterate through the `elements` array of the active flow. Find the SVG elements by their `id` (e.g., `document.getElementById('link_SUBMITTED_PAID')`) and add a `.highlighted` class.
3. **CSS Magic:**
```css
.diagram-container.flow-active svg g.highlighted {
opacity: 1 !important;
}
.diagram-container.flow-active svg g.highlighted path {
stroke: red !important;
stroke-width: 3px !important;
}
```
## Limitations & Challenges
While feasible, there are a few edge cases to handle:
1. **Multiple Transitions Between Same States:** If there are two distinct transitions from `A` to `B` (e.g., triggered by different events), PlantUML might generate IDs like `link_A_B` and `link_A_B_1`. The JS logic would need to rely on the event stereotypes we added recently (e.g., `<<e_PAY>>`) to distinguish them. We could parse the SVG text nodes to find the event label, or rely on the custom CSS classes if PlantUML supports passing them through to SVG (currently, PlantUML inlines styles rather than keeping CSS classes for transitions).
2. **Layout Shifts:** We must rely purely on CSS styling (colors, opacities, stroke widths). We cannot animate the *position* or layout of the arrows because PlantUML hardcodes the exact SVG path coordinates.
3. **Standalone vs. Hosted:** Generating a single `.html` file is easy and portable. However, if this is meant to be embedded in a documentation site (like Confluence or Backstage), injecting custom JS/CSS along with raw SVG can sometimes run into sanitization or iframe limitations.
4. **Maintenance:** Any changes to how PlantUML generates its internal SVG IDs in future versions would break the JS highlighting logic.
## Conclusion
The idea is brilliant. It transforms the output from a static image into an interactive, exploratory tool. Since the `StateMachineModel` is already cleanly exported as JSON, a standalone frontend script could easily marry that JSON data with the PlantUML SVG output to create dynamic, highlightable documentation flows without requiring heavy backend changes.

View File

@@ -1,90 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; display: flex; }
#sidebar { width: 300px; padding: 20px; background: #f4f4f4; }
#diagram { flex-grow: 1; padding: 20px; }
.flow-btn { padding: 10px; margin-bottom: 5px; cursor: pointer; background: #ddd; border: 1px solid #ccc; border-radius: 4px;}
.flow-btn:hover { background: #bdf; }
/* Dim everything by default when a flow is active */
.diagram-container.flow-active svg g {
opacity: 0.2;
transition: opacity 0.3s;
}
/* Highlight active elements */
.diagram-container.flow-active svg g.highlighted {
opacity: 1 !important;
}
/* Make highlighted paths thicker/red */
.diagram-container.flow-active svg g.highlighted path,
.diagram-container.flow-active svg g.highlighted polygon {
stroke: red !important;
stroke-width: 3px !important;
}
.diagram-container.flow-active svg g.highlighted rect,
.diagram-container.flow-active svg g.highlighted ellipse {
stroke: red !important;
stroke-width: 3px !important;
}
</style>
</head>
<body>
<div id="sidebar">
<h3>Flows</h3>
<div class="flow-btn" data-flow='["SUBMITTED", "link_SUBMITTED_PAID", "PAID", "link_PAID_FULFILLED", "FULFILLED"]'>Flow: Successful Payment</div>
<div class="flow-btn" data-flow='["SUBMITTED", "link_SUBMITTED_CANCELED", "CANCELED"]'>Flow: Cancel Before Pay</div>
</div>
<div id="diagram" class="diagram-container">
<!-- SVG will be injected here -->
</div>
<script>
// Load SVG
fetch('SimpleEnumStateMachineConfiguration.svg')
.then(r => r.text())
.then(svg => {
document.getElementById('diagram').innerHTML = svg;
setupInteractivity();
});
function setupInteractivity() {
const container = document.getElementById('diagram');
document.querySelectorAll('.flow-btn').forEach(btn => {
btn.addEventListener('mouseenter', (e) => {
container.classList.add('flow-active');
const flowIds = JSON.parse(e.target.getAttribute('data-flow'));
flowIds.forEach(id => {
// PlantUML sometimes prepends entity_
let el = document.getElementById(id) || document.getElementById('entity_' + id);
if(el) {
el.classList.add('highlighted');
} else {
// For links, fallback to data-attributes if id parsing fails
if (id.startsWith('link_')) {
const parts = id.split('_');
let link = document.querySelector(`g[data-entity-1="${parts[1]}"][data-entity-2="${parts[2]}"]`);
if (link) link.classList.add('highlighted');
}
}
});
});
btn.addEventListener('mouseleave', () => {
container.classList.remove('flow-active');
document.querySelectorAll('.highlighted').forEach(el => el.classList.remove('highlighted'));
});
});
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -1,29 +0,0 @@
digraph statemachine {
rankdir=LR;
node [shape=rounded, style=filled, fillcolor=white, fontname="Arial"];
edge [fontname="Arial", fontsize=10];
SUBMITTED_choice [shape=diamond, label="", fillcolor="blue", width=0.2, height=0.2];
SUBMITTED -> SUBMITTED_choice [style=dotted, color="blue"];
PAID_choice [shape=diamond, label="", fillcolor="green", width=0.2, height=0.2];
PAID -> PAID_choice [style=dotted, color="green"];
_start [shape=circle, label="", fillcolor=black, width=0.1];
_start -> SUBMITTED;
CANCELED [fillcolor=lightgray];
FULFILLED [fillcolor=lightgray];
SUBMITTED -> PAID [label="OrderEvents.PAY", style="solid", color="black"];
PAID -> FULFILLED [label="OrderEvents.FULFILL", style="solid", color="black"];
SUBMITTED -> CANCELED [label="OrderEvents.CANCEL [λ]", style="solid", color="black"];
PAID -> CANCELED [label="OrderEvents.CANCEL", style="solid", color="black"];
SUBMITTED -> SUBMITTED [label="OrderEvents.ABCD", style="solid", color="black"];
SUBMITTED_choice -> PAID2 [label="[λ] (order=0)", style="solid", color="blue"];
SUBMITTED_choice -> PAID3 [label="(order=1)", style="solid", color="blue"];
PAID_choice -> PAID1 [label="[λ] (order=0)", style="solid", color="green"];
PAID_choice -> PAID2 [label="[guard1] (order=1)", style="solid", color="green"];
PAID_choice -> HAPPEN [label="[λ] (order=2)", style="solid", color="green"];
PAID_choice -> PAID3 [label="(order=3)", style="solid", color="green"];
PAID2 -> CANCELED [style="solid", color="black"];
PAID3 -> CANCELED [style="solid", color="black"];
PAID1 -> PAID1 [label="OrderEvents.ABCD / λ, action2", style="solid", color="black"];
}

View File

@@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="SUBMITTED">
<state id="PAID1">
<transition target="PAID1" event="OrderEvents.ABCD"/>
</state>
<state id="SUBMITTED">
<transition target="PAID" event="OrderEvents.PAY"/>
<transition target="CANCELED" event="OrderEvents.CANCEL" cond="λ"/>
<transition target="SUBMITTED" event="OrderEvents.ABCD"/>
<transition target="PAID2" cond="λ">
<!-- order=0 -->
</transition>
<transition target="PAID3">
<!-- order=1 -->
</transition>
</state>
<state id="PAID">
<transition target="FULFILLED" event="OrderEvents.FULFILL"/>
<transition target="CANCELED" event="OrderEvents.CANCEL"/>
<transition target="PAID1" cond="λ">
<!-- order=0 -->
</transition>
<transition target="PAID2" cond="guard1">
<!-- order=1 -->
</transition>
<transition target="HAPPEN" cond="λ">
<!-- order=2 -->
</transition>
<transition target="PAID3">
<!-- order=3 -->
</transition>
</state>
<state id="CANCELED">
</state>
<state id="FULFILLED">
</state>
<state id="PAID3">
<transition target="CANCELED"/>
</state>
<state id="HAPPEN">
</state>
<state id="PAID2">
<transition target="CANCELED"/>
</state>
</scxml>

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter;
import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService;
import click.kamil.springstatemachineexporter.exporter.Dot;
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
import click.kamil.springstatemachineexporter.exporter.PlantUml;
@@ -8,10 +9,19 @@ import click.kamil.springstatemachineexporter.command.ExporterCommand;
import click.kamil.springstatemachineexporter.service.ExportService;
import picocli.CommandLine;
import java.util.Collections;
import java.util.List;
public class Main {
public static void main(String[] args) {
// Enable diagnostic mode early before SLF4J initializes
for (String arg : args) {
if ("--debug".equals(arg)) {
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug");
break;
}
}
// Manual DI / Wiring
var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter());
var exportService = new ExportService(exporters);

View File

@@ -0,0 +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, CodebaseIntelligenceProvider intelligence);
}

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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, Map<String, String>> properties = intelligence.resolveProperties();
result.addMetadata(CodebaseMetadata.builder()
.properties(properties)
.build());
}
}

View File

@@ -0,0 +1,167 @@
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.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine;
public class TransitionLinkerEnricher implements AnalysisEnricher {
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
private final EventMatchingEngine matchingEngine = new StrictFqnMatchingEngine();
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
return;
}
List<CallChain> updatedChains = new ArrayList<>();
List<Transition> stateMachineTransitions = result.getTransitions();
for (CallChain chain : result.getMetadata().getCallChains()) {
TriggerPoint tp = chain.getTriggerPoint();
if (tp == null || tp.getEvent() == null) {
updatedChains.add(chain);
continue;
}
String triggerEvent = simplify(tp.getEvent());
String triggerSource = tp.getSourceState() != null ? simplify(tp.getSourceState()) : null;
List<MatchedTransition> matched = new ArrayList<>();
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
// Event matches
for (State smSourceState : t.getSourceStates()) {
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
String smSource = simplify(smSourceRaw);
if (triggerSource == null || triggerSource.equals(smSource)) {
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceRaw)
.targetState(smSourceRaw)
.event(smEventRaw)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt);
}
} else {
for (State smTargetState : t.getTargetStates()) {
String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName();
MatchedTransition mt = MatchedTransition.builder()
.sourceState(smSourceRaw)
.targetState(targetRaw)
.event(smEventRaw)
.build();
if (isRoutedToCorrectMachine(chain, result.getName(), context)
&& isConstraintCompatible(tp.getConstraint(), result.getName())) {
matched.add(mt);
}
}
}
}
}
}
}
if (!matched.isEmpty()) {
CallChain newChain = chain.toBuilder().matchedTransitions(matched).build();
updatedChains.add(newChain);
} else {
updatedChains.add(chain);
}
}
// Update the metadata with the new call chains
CodebaseMetadata updatedMetadata = CodebaseMetadata.builder()
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(updatedChains)
.properties(result.getMetadata().getProperties())
.build();
result.setMetadata(updatedMetadata);
}
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName, context);
}
private boolean isConstraintCompatible(String constraint, String machineName) {
if (constraint == null || machineName == null) return true;
String cleanMachine = machineName.substring(machineName.lastIndexOf('.') + 1).toUpperCase();
String constraintUpper = constraint.toUpperCase();
java.util.List<String> allMachines = java.util.List.of("ORDER", "DOCUMENT", "USER");
for (String m : allMachines) {
if (!m.equals(cleanMachine)) {
if (constraintUpper.contains(m) && !constraintUpper.contains("!" + m) && !constraintUpper.contains("NOT")) {
if (constraintUpper.contains("\"" + m + "\"") || constraintUpper.contains("'" + m + "'") || constraintUpper.contains(m + ".")) {
return false;
}
}
}
}
return true;
}
private String simplify(String name) {
if (name == null) return null;
// Strip common suffixes
String simplified = name.replaceAll("(?i)(.*)(Event|Action|Transition|Command)(s)?$", "$1");
if (simplified.isEmpty()) {
simplified = name;
}
// Simplify full identifiers to just the last part (enum name)
simplified = simplified.replaceAll("^.*\\.([A-Z0-9_]+)$", "$1");
// For remaining full caps with underscores (like EVENT_X), keep as is or try to simplify
return simplified;
}
private boolean isGuardedContains(String smEvent, String triggerEvent) {
// Only fire if it looks like a simple identifier (no spaces, no parens)
// Prevents unresolved AST nodes (e.g. "event.getPayload()") from accidentally matching "Event"
if (smEvent.contains(" ") || smEvent.contains("(") || smEvent.contains(")") ||
triggerEvent.contains(" ") || triggerEvent.contains("(") || triggerEvent.contains(")")) {
return false;
}
String shorter = smEvent.length() < triggerEvent.length() ? smEvent : triggerEvent;
String longer = smEvent.length() >= triggerEvent.length() ? smEvent : triggerEvent;
shorter = shorter.toLowerCase();
longer = longer.toLowerCase();
if (longer.contains(shorter)) {
// Guard: If the matching part is very short (< 4 chars), it must be bounded by word separators
// to avoid matching completely unrelated words (e.g., "PAY" matching "PAYMENT" or "E" matching "UPDATE")
if (shorter.length() < 4) {
return longer.matches(".*(^|_|-)" + shorter + "(_|-|$).*");
}
// For longer strings, a straight contains is usually safe enough
return true;
}
return false;
}
}

View File

@@ -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());
}
}

View File

@@ -0,0 +1,11 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
public interface EventMatchingEngine {
/**
* Determines whether the given trigger point is a match for the state machine event.
*/
boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint);
}

View File

@@ -0,0 +1,147 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
import java.util.List;
public class HeuristicEventMatchingEngine implements EventMatchingEngine {
@Override
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
return false;
}
String rawTriggerEvent = triggerPoint.getEvent();
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String smEvent = simplify(smEventRaw);
boolean isWildcard = isWildcardVariable(rawTriggerEvent);
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
boolean hasPolyMatch = false;
for (String pe : polyEvents) {
if (pe.contains(".") && smEventRaw.contains(".")) {
if (smEventRaw.equals(pe) || smEventRaw.endsWith("." + pe) || pe.endsWith("." + smEventRaw)) {
hasPolyMatch = true;
break;
}
String classPe = pe.substring(0, pe.lastIndexOf('.'));
String classSm = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (classPe.contains(".") && classSm.contains(".")) {
String p1 = classPe.substring(0, classPe.lastIndexOf('.'));
String p2 = classSm.substring(0, classSm.lastIndexOf('.'));
if (!p1.equals(p2) && !p1.startsWith(p2 + ".") && !p2.startsWith(p1 + ".")) {
continue;
}
}
String simpleClassPe = classPe.contains(".") ? classPe.substring(classPe.lastIndexOf('.') + 1) : classPe;
String simpleClassSm = classSm.contains(".") ? classSm.substring(classSm.lastIndexOf('.') + 1) : classSm;
if (!simpleClassPe.equals(simpleClassSm)) {
continue;
}
}
String simplePe = pe;
if (pe.contains(".")) {
simplePe = pe.substring(pe.lastIndexOf('.') + 1);
}
String simplifiedPe = simplify(simplePe);
if (simplePe.equals(smEventRaw) || simplePe.equals(smEvent) ||
simplePe.equalsIgnoreCase(smEvent) || simplifiedPe.equalsIgnoreCase(smEvent) ||
isGuardedContains(smEvent, simplifiedPe)) {
hasPolyMatch = true;
break;
}
}
if (hasPolyMatch) return true;
if (polyEvents.isEmpty() && isWildcard) return true;
if (rawTriggerEvent.contains(".") && smEventRaw.contains(".")) {
if (smEventRaw.equals(rawTriggerEvent) || smEventRaw.endsWith("." + rawTriggerEvent) || rawTriggerEvent.endsWith("." + smEventRaw)) {
return true;
}
String classTrig = rawTriggerEvent.substring(0, rawTriggerEvent.lastIndexOf('.'));
String classSm = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (classTrig.contains(".") && classSm.contains(".")) {
String p1 = classTrig.substring(0, classTrig.lastIndexOf('.'));
String p2 = classSm.substring(0, classSm.lastIndexOf('.'));
if (!p1.equals(p2) && !p1.startsWith(p2 + ".") && !p2.startsWith(p1 + ".")) {
return false;
}
}
String simpleClassTrig = classTrig.contains(".") ? classTrig.substring(classTrig.lastIndexOf('.') + 1) : classTrig;
String simpleClassSm = classSm.contains(".") ? classSm.substring(classSm.lastIndexOf('.') + 1) : classSm;
if (!simpleClassTrig.equals(simpleClassSm)) {
return false;
}
}
String triggerEvent = simplify(rawTriggerEvent);
if (smEvent.equalsIgnoreCase(triggerEvent)) {
return true;
}
return isGuardedContains(smEvent, triggerEvent);
}
private boolean isGuardedContains(String smEvent, String triggerEvent) {
if (smEvent == null || triggerEvent == null) return false;
// Only fire if it looks like a simple identifier (no spaces, no parens)
// Prevents unresolved AST nodes (e.g. "event.getPayload()") from accidentally matching "Event"
if (smEvent.contains(" ") || smEvent.contains("(") || smEvent.contains(")") ||
triggerEvent.contains(" ") || triggerEvent.contains("(") || triggerEvent.contains(")")) {
return false;
}
String smLower = smEvent.toLowerCase();
String triggerLower = triggerEvent.toLowerCase();
if (smLower.equals(triggerLower)) {
return true;
}
// Match if one contains the other as a whole word/segment.
// We quote the patterns to avoid regex syntax injection issues.
String pattern1 = "\\b" + java.util.regex.Pattern.quote(smLower) + "\\b";
String pattern2 = "\\b" + java.util.regex.Pattern.quote(triggerLower) + "\\b";
// Treat underscores and hyphens as word boundary separators
String smNormalized = smLower.replace('_', ' ').replace('-', ' ');
String triggerNormalized = triggerLower.replace('_', ' ').replace('-', ' ');
return smNormalized.matches(".*" + pattern2 + ".*") || triggerNormalized.matches(".*" + pattern1 + ".*");
}
private boolean isWildcardVariable(String eventStr) {
if (eventStr == null) return false;
if (eventStr.equals("event") || eventStr.equals("e") ||
eventStr.equals("msg") || eventStr.equals("message") ||
eventStr.equals("payload")) {
return true;
}
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
return true;
}
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
return true;
}
return false;
}
private String simplify(String name) {
if (name == null) return null;
String simplified = name.replaceAll("(?i)(.*)(Event|Action|Transition|Command)(s)?$", "$1");
if (simplified.isEmpty()) {
simplified = name;
}
simplified = simplified.replaceAll("^.*\\.([A-Z0-9_]+)$", "$1");
return simplified;
}
}

View File

@@ -0,0 +1,82 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
import java.util.List;
public class StrictFqnMatchingEngine implements EventMatchingEngine {
@Override
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
return false;
}
if (triggerPoint.isExternal()) {
return true;
}
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String rawTriggerEvent = triggerPoint.getEvent();
if (rawTriggerEvent != null && rawTriggerEvent.startsWith("<SYMBOLIC: ") && rawTriggerEvent.endsWith(".*>")) {
String targetType = rawTriggerEvent.substring(11, rawTriggerEvent.length() - 3);
if (smEventRaw.startsWith(targetType + ".")) {
return true;
}
if (smEventRaw.contains(".")) {
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
return true;
}
}
return false;
}
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
for (String pe : polyEvents) {
if (pe.equals(smEventRaw)) {
return true;
}
if (pe.startsWith("<SYMBOLIC: ") && pe.endsWith(".*>")) {
String targetType = pe.substring(11, pe.length() - 3);
if (smEventRaw.startsWith(targetType + ".")) {
return true;
}
if (smEventRaw.contains(".")) {
String smType = smEventRaw.substring(0, smEventRaw.lastIndexOf('.'));
if (smType.endsWith("." + targetType) || smType.equals(targetType)) {
return true;
}
}
}
}
if (polyEvents.isEmpty() && isWildcardVariable(rawTriggerEvent)) {
return true;
}
return rawTriggerEvent.equals(smEventRaw);
}
private boolean isWildcardVariable(String eventStr) {
if (eventStr == null) return false;
if (eventStr.equals("event") || eventStr.equals("e") ||
eventStr.equals("msg") || eventStr.equals("message") ||
eventStr.equals("payload")) {
return true;
}
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
return true;
}
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,69 @@
package click.kamil.springstatemachineexporter.analysis.enricher.path;
import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
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.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class ForwardPathEstimationEnricher implements AnalysisEnricher {
private final boolean isEnabled;
private final PathValueEstimator estimator;
public ForwardPathEstimationEnricher() {
this.isEnabled = Boolean.parseBoolean(System.getProperty("analyzer.forward-path-estimation.enabled", "false"));
this.estimator = new SymbolicPathValueEstimator();
}
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (!isEnabled || result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
return;
}
log.info("Running Symbolic Forward Path Analysis for {}", result.getName());
List<CallChain> chains = result.getMetadata().getCallChains();
List<CallChain> updatedChains = chains.stream().map(chain -> {
if (chain.getTriggerPoint() == null || chain.getTriggerPoint().getEvent() == null) return chain;
// If the event looks like a dynamic variable (no dots, not an enum format)
if (!chain.getTriggerPoint().getEvent().contains(".")) {
List<String> estimatedValues = estimator.estimatePossibleValues(chain, context);
if (estimatedValues != null && !estimatedValues.isEmpty()) {
log.info("Symbolic Path Analysis estimated values for {} -> {}", chain.getTriggerPoint().getEvent(), estimatedValues);
List<String> polyEvents = new ArrayList<>();
if (chain.getTriggerPoint().getPolymorphicEvents() != null) {
polyEvents.addAll(chain.getTriggerPoint().getPolymorphicEvents());
}
polyEvents.addAll(estimatedValues);
TriggerPoint newTp = chain.getTriggerPoint().toBuilder()
.polymorphicEvents(polyEvents)
.build();
return chain.toBuilder().triggerPoint(newTp).build();
}
}
return chain;
}).collect(Collectors.toList());
click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata updatedMetadata =
click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(updatedChains)
.properties(result.getMetadata().getProperties())
.build();
result.setMetadata(updatedMetadata);
}
}

View File

@@ -0,0 +1,10 @@
package click.kamil.springstatemachineexporter.analysis.enricher.path;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
public interface PathValueEstimator {
List<String> estimatePossibleValues(CallChain chain, CodebaseContext context);
}

View File

@@ -0,0 +1,130 @@
package click.kamil.springstatemachineexporter.analysis.enricher.path;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class SymbolicPathValueEstimator implements PathValueEstimator {
@Override
public List<String> estimatePossibleValues(CallChain chain, CodebaseContext context) {
if (chain.getMethodChain() == null || chain.getMethodChain().isEmpty()) {
return null;
}
Set<String> collectedConstants = new HashSet<>();
for (String methodFqn : chain.getMethodChain()) {
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot == -1) continue;
String className = methodFqn.substring(0, lastDot);
String methodName = methodFqn.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) continue;
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) continue;
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if ((node.getName().getIdentifier().equals("equals") || node.getName().getIdentifier().equals("equalsIgnoreCase"))
&& node.arguments().size() == 1) {
Expression caller = node.getExpression();
Expression arg = (Expression) node.arguments().get(0);
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String callerVal = resolver.resolve(caller, context);
if (callerVal != null && !callerVal.isEmpty()) {
addValue(collectedConstants, callerVal);
}
String argVal = resolver.resolve(arg, context);
if (argVal != null && !argVal.isEmpty()) {
addValue(collectedConstants, argVal);
}
}
return super.visit(node);
}
@Override
public boolean visit(InfixExpression node) {
if (node.getOperator() == InfixExpression.Operator.EQUALS) {
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String left = resolver.resolve(node.getLeftOperand(), context);
if (left != null && !left.isEmpty()) {
addValue(collectedConstants, left);
}
String right = resolver.resolve(node.getRightOperand(), context);
if (right != null && !right.isEmpty()) {
addValue(collectedConstants, right);
}
}
return super.visit(node);
}
@Override
public boolean visit(SwitchStatement node) {
for (Object stmt : node.statements()) {
if (stmt instanceof SwitchCase sc && !sc.isDefault()) {
for (Object expr : sc.expressions()) {
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String val = resolver.resolve((Expression) expr, context);
if (val != null && !val.isEmpty()) {
addValue(collectedConstants, val);
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SwitchExpression node) {
for (Object stmt : node.statements()) {
if (stmt instanceof SwitchCase sc && !sc.isDefault()) {
for (Object expr : sc.expressions()) {
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String val = resolver.resolve((Expression) expr, context);
if (val != null && !val.isEmpty()) {
addValue(collectedConstants, val);
}
}
}
}
return super.visit(node);
}
});
}
if (collectedConstants.isEmpty()) return null;
return new ArrayList<>(collectedConstants);
}
private void addValue(Set<String> collectedConstants, String val) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!collectedConstants.contains(parsed)) collectedConstants.add(parsed);
}
} else {
collectedConstants.add(val);
}
}
private String parseEnumSetElement(String element) {
if (element.contains(".")) {
return element.substring(element.lastIndexOf('.') + 1);
}
return element;
}
}

View File

@@ -0,0 +1,9 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
public interface BeanResolutionEngine {
boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context);
}

View File

@@ -0,0 +1,379 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.HashSet;
import java.util.Set;
public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
@Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) {
// Precise FQN Type argument match
if (chain.getTriggerPoint() != null && context != null && currentMachineName != null) {
String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn();
String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn();
if (triggerEventFqn != null || triggerStateFqn != null) {
String[] machineTypes = getStateMachineTypeArguments(currentMachineName, context);
String machineStateFqn = machineTypes[0];
String machineEventFqn = machineTypes[1];
if (triggerEventFqn != null && machineEventFqn != null) {
if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) {
return true; // Match!
}
if (isTypeMismatched(triggerEventFqn, machineEventFqn)) {
return false; // Mismatch!
}
}
if (triggerStateFqn != null && machineStateFqn != null) {
if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) {
return true; // Match!
}
if (isTypeMismatched(triggerStateFqn, machineStateFqn)) {
return false; // Mismatch!
}
}
}
}
String targetVar = chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
String simplifiedMachineName = currentMachineName != null ? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase() : "";
if (targetVar != null && !targetVar.isEmpty()) {
targetVar = targetVar.toLowerCase();
if (targetVar.endsWith("statemachine")) {
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
if (!prefix.isEmpty()) {
if (simplifiedMachineName.contains(prefix)) {
return true; // Explicit positive match
} else {
return false; // Explicit negative match
}
}
} else if (simplifiedMachineName.contains(targetVar)) {
return true;
}
}
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
String smPackage = getPackageName(currentMachineName);
String smSimple = getSimpleClassName(currentMachineName);
String smPrefix = getFirstCamelCaseWord(smSimple);
boolean hasPositiveMatch = false;
boolean hasStrongMismatch = false;
if (chain.getTriggerPoint() != null && chain.getTriggerPoint().getClassName() != null) {
String chainClass = chain.getTriggerPoint().getClassName();
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
}
for (String method : chain.getMethodChain()) {
String chainClass = getClassNameOnly(method);
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix, smSimple, chainSimple)) {
hasStrongMismatch = true;
}
}
if (hasPositiveMatch) {
return true;
}
if (hasStrongMismatch) {
return false;
}
}
return true;
}
private boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
if (smPackage.startsWith(chainPackage) || chainPackage.startsWith(smPackage)) {
return true;
}
// Find deepest common package root
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
// If the state machine's prefix is part of the shared common package,
// then the state machine represents the parent domain of the caller.
if (smPrefix != null && matchIdx >= 0) {
String lowerPrefix = smPrefix.toLowerCase();
for (int i = 0; i <= matchIdx; i++) {
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
return true;
}
}
}
return false;
}
private boolean isDomainMismatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix, String smSimple, String chainSimple) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
if (smPrefix == null || chainPrefix == null) return false;
String smLower = smPrefix.toLowerCase();
String chainLower = chainPrefix.toLowerCase();
// Ignore generic/technical class prefixes
if (smLower.equals("state") || smLower.equals("statemachine") || smLower.equals("config") || smLower.equals("configuration") || smLower.equals("adapter") ||
smLower.equals("enterprise") || smLower.equals("app") || smLower.equals("global") || smLower.equals("core") || smLower.equals("project") || smLower.equals("main") || smLower.equals("base") || smLower.equals("common") || smLower.equals("shared") ||
chainLower.equals("state") || chainLower.equals("statemachine") || chainLower.equals("config") || chainLower.equals("configuration") || chainLower.equals("adapter") ||
chainLower.equals("enterprise") || chainLower.equals("app") || chainLower.equals("global") || chainLower.equals("core") || chainLower.equals("project") || chainLower.equals("main") || chainLower.equals("base") || chainLower.equals("common") || chainLower.equals("shared")) {
return false;
}
// If class prefixes diverge, check if they are from different domains
if (!smLower.equals(chainLower)) {
// If one prefix is present as a domain/feature segment in the other package, they are related
if (chainPackage.toLowerCase().contains(smLower) || smPackage.toLowerCase().contains(chainLower)) {
return false;
}
// Otherwise, if they diverge under a shared project package root, it is a mismatch
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
if (matchIdx >= 1) {
// If they diverge under a shared root, check if the divergence is only on generic package layers
boolean onlyGenericDivergence = true;
Set<String> genericLayers = Set.of(
"config", "configuration", "service", "services", "impl", "api", "web",
"controller", "controllers", "messaging", "listener", "listeners",
"repository", "repositories", "db", "model", "models", "domain",
"constants", "utils", "helper", "helpers", "event", "events",
"state", "states", "transition", "transitions"
);
for (int i = matchIdx + 1; i < p1.length; i++) {
if (!genericLayers.contains(p1[i].toLowerCase())) {
onlyGenericDivergence = false;
break;
}
}
if (onlyGenericDivergence) {
for (int i = matchIdx + 1; i < p2.length; i++) {
if (!genericLayers.contains(p2[i].toLowerCase())) {
onlyGenericDivergence = false;
break;
}
}
}
if (!onlyGenericDivergence) {
return true; // Mismatch under a shared root
}
}
}
return false;
}
private Set<String> extractDomainTerms(String pkg, String prefix) {
Set<String> terms = new HashSet<>();
if (pkg != null && !pkg.isEmpty()) {
String[] segments = pkg.split("\\.");
// Take segments from index 2 onwards if length > 2
int start = segments.length > 2 ? 2 : 0;
for (int i = start; i < segments.length; i++) {
terms.add(segments[i].toLowerCase());
}
}
if (prefix != null && !prefix.isEmpty()) {
terms.add(prefix.toLowerCase());
}
return terms;
}
private String getPackageName(String fqn) {
if (fqn == null || !fqn.contains(".")) return "";
return fqn.substring(0, fqn.lastIndexOf('.'));
}
private String getSimpleClassName(String fqn) {
if (fqn == null) return "";
if (!fqn.contains(".")) return fqn;
return fqn.substring(fqn.lastIndexOf('.') + 1);
}
private String getClassNameOnly(String methodFqn) {
if (methodFqn == null) return "";
String clean = methodFqn;
if (clean.contains("(")) {
clean = clean.substring(0, clean.indexOf('('));
}
if (clean.contains(".")) {
clean = clean.substring(0, clean.lastIndexOf('.'));
}
return clean;
}
private String getFirstCamelCaseWord(String simpleName) {
if (simpleName == null || simpleName.isEmpty()) return null;
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
if (words.length > 0) {
return words[0];
}
return null;
}
private String[] getStateMachineTypeArguments(String machineName, CodebaseContext context) {
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration(machineName);
if (td != null) {
org.eclipse.jdt.core.dom.Type superclass = td.getSuperclassType();
Set<String> visited = new HashSet<>();
visited.add(machineName);
return findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
}
return new String[]{null, null};
}
private String[] findTypeArgumentsRecursively(org.eclipse.jdt.core.dom.Type type, CodebaseContext context, org.eclipse.jdt.core.dom.CompilationUnit cu, Set<String> visited) {
if (type == null) return new String[]{null, null};
if (type instanceof org.eclipse.jdt.core.dom.ParameterizedType pt) {
String rawTypeName = pt.getType().toString();
if (rawTypeName.equals("StateMachineConfigurerAdapter") || rawTypeName.equals("EnumStateMachineConfigurerAdapter") ||
rawTypeName.endsWith(".StateMachineConfigurerAdapter") || rawTypeName.endsWith(".EnumStateMachineConfigurerAdapter")) {
java.util.List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
String stateType = resolveTypeToFqn((org.eclipse.jdt.core.dom.Type) typeArgs.get(0), cu, context);
String eventType = resolveTypeToFqn((org.eclipse.jdt.core.dom.Type) typeArgs.get(1), cu, context);
return new String[]{stateType, eventType};
}
}
// Recurse into the raw type declaration's superclass
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(rawTypeName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
org.eclipse.jdt.core.dom.Type superclass = (td instanceof org.eclipse.jdt.core.dom.TypeDeclaration) ? ((org.eclipse.jdt.core.dom.TypeDeclaration) td).getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
if (res[0] != null || res[1] != null) return res;
}
}
} else {
// It's a simple type (e.g. MyBaseConfig)
String simpleName = type.toString();
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
String fqn = context.getFqn(td);
if (visited.add(fqn)) {
org.eclipse.jdt.core.dom.Type superclass = (td instanceof org.eclipse.jdt.core.dom.TypeDeclaration) ? ((org.eclipse.jdt.core.dom.TypeDeclaration) td).getSuperclassType() : null;
String[] res = findTypeArgumentsRecursively(superclass, context, (org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot(), visited);
if (res[0] != null || res[1] != null) return res;
}
}
}
return new String[]{null, null};
}
private String eraseGenerics(String type) {
if (type == null) return null;
int idx = type.indexOf('<');
if (idx != -1) {
return type.substring(0, idx);
}
return type;
}
private String resolveTypeToFqn(org.eclipse.jdt.core.dom.Type type, org.eclipse.jdt.core.dom.CompilationUnit cu, CodebaseContext context) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((org.eclipse.jdt.core.dom.SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((org.eclipse.jdt.core.dom.ParameterizedType) type).getType(), cu, context);
} else {
simpleName = type.toString();
}
if (simpleName.contains("<")) {
simpleName = simpleName.substring(0, simpleName.indexOf('<'));
}
org.eclipse.jdt.core.dom.AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
for (Object impObj : cu.imports()) {
org.eclipse.jdt.core.dom.ImportDeclaration imp = (org.eclipse.jdt.core.dom.ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
org.eclipse.jdt.core.dom.AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) return context.getFqn(localTd);
}
return simpleName;
}
private boolean isTypeMismatched(String type1, String type2) {
if (type1 == null || type2 == null) return false;
type1 = eraseGenerics(type1);
type2 = eraseGenerics(type2);
if (type1.equals("java.lang.String") || type1.equals("String") || type1.equals("java.lang.Object") || type1.equals("Object")) return false;
if (type2.equals("java.lang.String") || type2.equals("String") || type2.equals("java.lang.Object") || type2.equals("Object")) return false;
return !type1.equals(type2);
}
private boolean isSingleStateMachine(CodebaseContext context) {
if (context == null) return false;
int count = 0;
count += context.findEntryPointClasses(java.util.List.of("EnableStateMachineFactory", "EnableStateMachine")).size();
count += context.findBeanMethodsReturning(java.util.Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory")).size();
return count <= 1;
}
}

View File

@@ -0,0 +1,123 @@
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.Setter;
import lombok.extern.jackson.Jacksonized;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Getter
@Setter
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class AnalysisResult {
private String name;
@Builder.Default
private Set<click.kamil.springstatemachineexporter.model.State> states = java.util.Collections.emptySet();
private List<Transition> transitions;
private Set<String> startStates;
private Set<String> endStates;
private boolean renderChoicesAsDiamonds;
@Builder.Default
private List<BusinessFlow> flows = java.util.Collections.emptyList();
@Builder.Default
private CodebaseMetadata metadata = CodebaseMetadata.empty();
public void applyResolution(Map<String, String> properties) {
var resolver = new click.kamil.springstatemachineexporter.analysis.resolver.PropertyResolver();
// 1. Resolve start/end states strings
if (startStates != null) {
this.startStates = startStates.stream()
.map(s -> resolver.resolveValue(s, properties))
.collect(java.util.stream.Collectors.toSet());
}
if (endStates != null) {
this.endStates = endStates.stream()
.map(s -> resolver.resolveValue(s, properties))
.collect(java.util.stream.Collectors.toSet());
}
// 2. Resolve states (State record is immutable, so recreate)
if (states != null) {
this.states = states.stream()
.map(s -> click.kamil.springstatemachineexporter.model.State.of(s.rawName(), resolver.resolveValue(s.fullIdentifier(), properties)))
.collect(java.util.stream.Collectors.toSet());
}
// 3. Resolve transitions
if (transitions != null) {
for (var t : transitions) {
if (t.getEvent() != null) {
t.setEvent(click.kamil.springstatemachineexporter.model.Event.of(t.getEvent().rawName(), resolver.resolveValue(t.getEvent().fullIdentifier(), properties)));
}
// Resolve states within transitions
t.setSourceStates(t.getSourceStates().stream()
.map(s -> click.kamil.springstatemachineexporter.model.State.of(s.rawName(), resolver.resolveValue(s.fullIdentifier(), properties)))
.collect(java.util.stream.Collectors.toList()));
t.setTargetStates(t.getTargetStates().stream()
.map(s -> click.kamil.springstatemachineexporter.model.State.of(s.rawName(), resolver.resolveValue(s.fullIdentifier(), properties)))
.collect(java.util.stream.Collectors.toList()));
}
}
// 4. Resolve metadata (triggers, entry points)
if (metadata != null) {
if (metadata.getTriggers() != null) {
for (var trigger : metadata.getTriggers()) {
if (trigger.getEvent() != null) {
trigger.setEvent(resolver.resolveValue(trigger.getEvent(), properties));
}
}
}
if (metadata.getEntryPoints() != null) {
for (var ep : metadata.getEntryPoints()) {
if (ep.getName() != null) {
ep.setName(resolver.resolveValue(ep.getName(), properties));
}
if (ep.getMetadata() != null) {
Map<String, String> resolvedMeta = new java.util.HashMap<>();
for (var entry : ep.getMetadata().entrySet()) {
resolvedMeta.put(entry.getKey(), resolver.resolveValue(entry.getValue(), properties));
}
ep.setMetadata(resolvedMeta);
}
}
}
}
}
public void addMetadata(CodebaseMetadata newMetadata) {
if (newMetadata == null) return;
List<TriggerPoint> combinedTriggers = new java.util.ArrayList<>(this.metadata.getTriggers());
if (newMetadata.getTriggers() != null) combinedTriggers.addAll(newMetadata.getTriggers());
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, java.util.Map<String, String>> combinedProperties = new java.util.HashMap<>(this.metadata.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();
}
}

View File

@@ -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 BusinessFlow {
private final String name;
private final String description;
private final List<String> steps; // List of Event names in order
}

View File

@@ -0,0 +1,20 @@
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(toBuilder = true)
@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;
private final String contextMachineId;
private final List<MatchedTransition> matchedTransitions;
}

View File

@@ -0,0 +1,25 @@
package click.kamil.springstatemachineexporter.analysis.model;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CallEdge {
private String targetMethod;
private List<String> arguments;
private String receiver;
private String constraint;
public CallEdge(String targetMethod, List<String> arguments) {
this(targetMethod, arguments, null, null);
}
public CallEdge(String targetMethod, List<String> arguments, String receiver) {
this(targetMethod, arguments, receiver, null);
}
}

View File

@@ -0,0 +1,30 @@
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;
import java.util.List;
import java.util.Map;
@Getter
@Builder(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class CodebaseMetadata {
@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, Map<String, String>> properties = Collections.emptyMap();
public static CodebaseMetadata empty() {
return CodebaseMetadata.builder().build();
}
}

View File

@@ -0,0 +1,36 @@
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;
import java.util.Map;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class EntryPoint {
public enum Type { REST, KAFKA, RABBIT, JMS, SQS, SNS, CUSTOM }
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Parameter {
private final String name;
private final String type;
private final List<String> annotations;
}
private final Type type;
private String name; // e.g., "POST /orders" or "Kafka Topic: orders"
private final String className;
private final String methodName;
private final String sourceFile;
private Map<String, String> metadata; // e.g., path, topic, exchange
@Builder.Default
private final List<Parameter> parameters = java.util.Collections.emptyList();
}

View File

@@ -0,0 +1,19 @@
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 (static)
private final Integer eventArgumentIndex; // e.g., 0 to extract from the 0th argument
private final String eventArgumentMethod; // e.g., "getType" to extract from argument method call
}

View File

@@ -0,0 +1,16 @@
package click.kamil.springstatemachineexporter.analysis.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;
@Data
@Builder
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class MatchedTransition {
private final String sourceState;
private final String targetState;
private final String event;
}

View File

@@ -0,0 +1,125 @@
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(toBuilder = true)
@Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true)
public class TriggerPoint {
private String event;
private final String className;
private final String methodName;
private final String sourceFile;
private final String sourceModule;
private final String stateMachineId; // Optional: to link to a specific SM instance
private final String sourceState; // Optional: if we can determine the expected current state
private final int lineNumber;
@com.fasterxml.jackson.annotation.JsonIgnore
private final String stateTypeFqn; // Type of State (e.g. OrderStates FQN)
@com.fasterxml.jackson.annotation.JsonIgnore
private final String eventTypeFqn; // Type of Event (e.g. OrderEvents FQN)
private final java.util.List<String> polymorphicEvents; // NEW: stores concrete events resolved via deep polymorphism
private final boolean external;
private final String constraint;
public TriggerPoint(
String event,
String className,
String methodName,
String sourceFile,
String sourceModule,
String stateMachineId,
String sourceState,
int lineNumber,
String stateTypeFqn,
String eventTypeFqn,
java.util.List<String> polymorphicEvents,
boolean external,
String constraint) {
this.className = className;
this.methodName = methodName;
this.sourceFile = sourceFile;
this.sourceModule = sourceModule;
this.stateMachineId = stateMachineId;
this.sourceState = sourceState;
this.lineNumber = lineNumber;
this.stateTypeFqn = stateTypeFqn;
this.eventTypeFqn = eventTypeFqn;
this.event = qualifyEvent(event, eventTypeFqn);
if (polymorphicEvents != null) {
this.polymorphicEvents = polymorphicEvents.stream()
.map(pe -> qualifyEvent(pe, eventTypeFqn))
.collect(java.util.stream.Collectors.toList());
} else {
this.polymorphicEvents = null;
}
this.external = external;
this.constraint = constraint;
}
private static String qualifyEvent(String e, String eventTypeFqn) {
if (e == null || eventTypeFqn == null || e.isEmpty()) {
return e;
}
if (e.startsWith("<SYMBOLIC: ")) {
return e;
}
if (isWildcardVariable(e)) {
return e;
}
if (Character.isLowerCase(e.charAt(0))) {
return e;
}
if (!isEnumConstantCandidate(e)) {
return e;
}
if (e.contains(".")) {
String qualifier = e.substring(0, e.lastIndexOf('.'));
String lastSegment = e.substring(e.lastIndexOf('.') + 1);
if (qualifier.equals(eventTypeFqn)) {
return e;
}
if (eventTypeFqn.endsWith("." + qualifier) || eventTypeFqn.equals(qualifier)) {
return eventTypeFqn + "." + lastSegment;
}
return e;
}
if (eventTypeFqn.startsWith("java.lang.") || eventTypeFqn.equals("int") || eventTypeFqn.equals("long") || eventTypeFqn.equals("char")) {
return e;
}
return eventTypeFqn + "." + e;
}
private static boolean isEnumConstantCandidate(String eventStr) {
if (eventStr == null) return false;
if (eventStr.startsWith("<SYMBOLIC: ")) {
return false;
}
if (eventStr.contains(" ") || eventStr.contains("(") || eventStr.contains(")") || eventStr.contains("?")) {
return false;
}
return true;
}
private static boolean isWildcardVariable(String eventStr) {
if (eventStr == null) return false;
if (eventStr.equals("event") || eventStr.equals("e") ||
eventStr.equals("msg") || eventStr.equals("message") ||
eventStr.equals("payload")) {
return true;
}
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
return true;
}
if (eventStr.contains(" ? ") && eventStr.contains(" : ")) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,693 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.HashSet;
import java.util.Set;
@Slf4j
public class ConstantResolver {
public String resolve(Expression expr, CodebaseContext context) {
return resolveInternal(expr, context, new HashSet<>());
}
public String evaluateSwitchWithParams(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context) {
return evaluateSwitchExpression(se, paramValues, context, new HashSet<>());
}
public String evaluateSwitchWithParams(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map<String, String> paramValues, CodebaseContext context) {
return evaluateSwitchStatement(ss, paramValues, context, new HashSet<>());
}
private String resolveInternal(Expression expr, CodebaseContext context, Set<String> visited) {
if (expr == null) return null;
// Unwrap common wrappers
if (expr instanceof CastExpression ce) {
return resolveInternal(ce.getExpression(), context, visited);
}
if (expr instanceof ParenthesizedExpression pe) {
return resolveInternal(pe.getExpression(), context, visited);
}
if (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) {
if (!mi.arguments().isEmpty()) {
return resolveInternal((Expression) mi.arguments().get(0), context, visited);
}
}
// 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, visited);
}
if (expr instanceof QualifiedName qn) {
String val = resolveManual(qn, context, visited);
return val != null ? val : qn.toString();
}
if (expr instanceof FieldAccess fa) {
return resolveManual(fa.getName(), context, visited);
}
if (expr instanceof SimpleName sn) {
return resolveManual(sn, context, visited);
}
if (expr instanceof MethodInvocation mi) {
if ((mi.getName().getIdentifier().equals("name") || mi.getName().getIdentifier().equals("toString")) && mi.arguments().isEmpty()) {
String val = resolveInternal(mi.getExpression(), context, visited);
if (val != null) {
// If it was resolved as a fully qualified enum string like "MyEvents.ORDER_PAID", strip it
if (val.contains(".")) {
return val.substring(val.lastIndexOf('.') + 1);
}
return val;
}
// Fallback for unresolved AST nodes
if (mi.getExpression() instanceof QualifiedName qn) {
System.out.println("RETURNING qn name: " + qn.getName().getIdentifier()); return qn.getName().getIdentifier();
} else if (mi.getExpression() instanceof SimpleName sn) {
System.out.println("RETURNING sn name: " + sn.getIdentifier()); return sn.getIdentifier();
}
}
if ("valueOf".equals(mi.getName().getIdentifier())) {
String enumFqn = null;
if (mi.arguments().size() == 2) {
enumFqn = resolveEnumFqn((Expression) mi.arguments().get(0), context);
} else if (mi.arguments().size() == 1 && mi.getExpression() != null) {
enumFqn = resolveEnumFqn(mi.getExpression(), context);
}
if (enumFqn != null) {
return "<SYMBOLIC: " + enumFqn + ".*>";
}
}
IMethodBinding mb = mi.resolveMethodBinding();
if (mb != null) {
ITypeBinding returnType = mb.getReturnType();
if (returnType != null) {
java.util.List<String> values = context.getEnumValues(returnType.getQualifiedName());
if (values != null && !values.isEmpty()) {
return "ENUM_SET:" + String.join(",", values);
}
}
} else {
TypeDeclaration td = null;
if (mi.getExpression() != null) {
ITypeBinding typeBinding = mi.getExpression().resolveTypeBinding();
if (typeBinding != null) {
String typeName = typeBinding.getQualifiedName();
if (typeName != null && !typeName.isEmpty()) {
td = context.getTypeDeclaration(typeName);
}
}
}
if (td == null && mi.getExpression() instanceof SimpleName sn) {
String typeName = resolveLocalType(sn);
if (typeName != null) {
td = context.getTypeDeclaration(typeName);
if (td == null) {
CompilationUnit cu = (CompilationUnit) mi.getRoot();
td = context.getTypeDeclaration(typeName, cu);
}
}
}
if (td == null) {
td = findEnclosingType(mi);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, mi.getName().getIdentifier(), true);
if (md != null) {
String evaluated = evaluateMethodOutput(mi, md, context, visited);
if (evaluated != null) return evaluated;
if (md.getReturnType2() != null) {
java.util.List<String> values = context.getEnumValues(md.getReturnType2().toString());
if (values != null && !values.isEmpty()) {
return "ENUM_SET:" + String.join(",", values);
}
}
}
}
}
}
return null;
}
private String evaluateMethodOutput(MethodInvocation mi, MethodDeclaration md, CodebaseContext context, Set<String> visited) {
if (md.getBody() == null || mi.arguments().size() != md.parameters().size()) return null;
TypeDeclaration td = findEnclosingType(md);
if (td == null) return null;
String methodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
if (!visited.add(methodFqn)) {
if (log.isDebugEnabled()) {
log.debug("Cycle detected in method evaluation for: {} (visited={})", methodFqn, visited);
}
return null; // Cycle detected in method evaluation
}
try {
java.util.Map<String, String> localVars = new java.util.HashMap<>();
java.util.Set<String> declaredLocals = new java.util.HashSet<>();
for (int i = 0; i < mi.arguments().size(); i++) {
Expression arg = (Expression) mi.arguments().get(i);
String val = resolveInternal(arg, context, visited);
if (val != null) {
org.eclipse.jdt.core.dom.SingleVariableDeclaration param =
(org.eclipse.jdt.core.dom.SingleVariableDeclaration) md.parameters().get(i);
String paramName = param.getName().getIdentifier();
localVars.put(paramName, val);
declaredLocals.add(paramName);
}
}
return evaluateBody(md.getBody(), localVars, declaredLocals, context, visited);
} finally {
visited.remove(methodFqn);
}
}
/**
* Evaluates a method body with pre-supplied local values (e.g. field values derived
* from the specific {@code ClassInstanceCreation} that created the receiver object).
* Used by {@link click.kamil.springstatemachineexporter.analysis.service.AbstractCallGraphEngine}
* to resolve transforming getters — methods that map a constructor-injected enum to a
* different state-machine enum via switch expressions.
*/
public String evaluateMethodBodyWithLocals(MethodDeclaration md,
java.util.Map<String, String> prebuiltLocals,
CodebaseContext context,
Set<String> visited) {
if (md.getBody() == null) return null;
TypeDeclaration td = findEnclosingType(md);
if (td == null) return null;
String methodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
if (!visited.add(methodFqn)) {
if (log.isDebugEnabled()) {
log.debug("Cycle detected in method body evaluation for: {} (visited={})", methodFqn, visited);
}
return null; // Cycle detected in method evaluation
}
try {
java.util.Map<String, String> localVars = new java.util.HashMap<>(prebuiltLocals);
java.util.Set<String> declaredLocals = new java.util.HashSet<>(prebuiltLocals.keySet());
return evaluateBody(md.getBody(), localVars, declaredLocals, context, visited);
} finally {
visited.remove(methodFqn);
}
}
/**
* Core body-evaluation logic shared by {@link #evaluateMethodOutput} and
* {@link #evaluateMethodBodyWithLocals}. Visits the block, tracks assignments,
* evaluates switch statements/expressions, and captures the first resolved return value.
*/
private String evaluateBody(org.eclipse.jdt.core.dom.Block body,
java.util.Map<String, String> localVars,
java.util.Set<String> declaredLocals,
CodebaseContext context,
Set<String> visited) {
String[] finalResult = new String[1];
body.accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
String varName = fragment.getName().getIdentifier();
declaredLocals.add(varName);
if (fragment.getInitializer() != null) {
String rhsVal = resolveExpressionWithParams(fragment.getInitializer(), localVars);
if (rhsVal == null) rhsVal = resolveInternal(fragment.getInitializer(), context, visited);
if (rhsVal != null) localVars.put(varName, rhsVal);
}
}
}
return super.visit(vds);
}
@Override
public boolean visit(Assignment assignment) {
Expression lhs = assignment.getLeftHandSide();
String varName = null;
if (lhs instanceof SimpleName sn) {
varName = sn.getIdentifier();
} else if (lhs instanceof FieldAccess fa) {
varName = "this." + fa.getName().getIdentifier();
}
String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars);
if (rhsVal == null) rhsVal = resolveInternal(assignment.getRightHandSide(), context, visited);
if (varName != null && rhsVal != null) {
if (varName.startsWith("this.")) {
localVars.put(varName, rhsVal);
String bareName = varName.substring(5);
if (!declaredLocals.contains(bareName)) localVars.put(bareName, rhsVal);
} else {
localVars.put(varName, rhsVal);
if (!declaredLocals.contains(varName)) localVars.put("this." + varName, rhsVal);
}
}
return super.visit(assignment);
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.SwitchStatement ss) {
if (finalResult[0] == null) {
String result = evaluateSwitchStatement(ss, localVars, context, visited);
if (result != null) finalResult[0] = result;
}
return false; // children handled inside evaluateSwitchStatement
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.ReturnStatement rs) {
if (finalResult[0] == null) {
if (rs.getExpression() instanceof org.eclipse.jdt.core.dom.SwitchExpression se) {
String result = evaluateSwitchExpression(se, localVars, context, visited);
if (result != null) finalResult[0] = result;
} else if (rs.getExpression() != null) {
String result = resolveExpressionWithParams(rs.getExpression(), localVars);
if (result == null) result = resolveInternal(rs.getExpression(), context, visited);
if (result != null) finalResult[0] = result;
}
}
return super.visit(rs);
}
});
return finalResult[0];
}
private String evaluateSwitchStatement(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
String switchVar = resolveExpressionWithParams(ss.getExpression(), paramValues);
if (switchVar == null) return null;
String switchType = null;
if (switchVar.contains(".")) {
switchType = switchVar.substring(0, switchVar.lastIndexOf('.'));
}
boolean matchingCase = false;
for (Object stmtObj : ss.statements()) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) {
matchingCase = false;
if (sc.isDefault()) {
matchingCase = true;
} else {
for (Object exprObj : sc.expressions()) {
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues);
// Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
caseVal = caseSn.getIdentifier();
if (switchType != null) {
caseVal = switchType + "." + caseVal;
}
}
if (caseVal != null) {
if (switchVar.contains(".") && caseVal.contains(".")) {
if (switchVar.equals(caseVal)) {
matchingCase = true;
break;
}
} else if (switchVar.endsWith(caseVal)) {
matchingCase = true;
break;
}
}
}
}
} else if (matchingCase) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
return resolveInternal(rs.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys) {
return resolveInternal(ys.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) {
return resolveInternal(es.getExpression(), context, visited);
}
}
}
return null;
}
private String evaluateSwitchExpression(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
String switchVar = resolveExpressionWithParams(se.getExpression(), paramValues);
if (switchVar == null) return null;
String switchType = null;
if (switchVar.contains(".")) {
switchType = switchVar.substring(0, switchVar.lastIndexOf('.'));
}
boolean matchingCase = false;
for (Object stmtObj : se.statements()) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) {
matchingCase = false;
if (sc.isDefault()) {
matchingCase = true;
} else {
for (Object exprObj : sc.expressions()) {
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues);
// Bare SimpleName case label (e.g. "case PAY"): use identifier as fallback
if (caseVal == null && exprObj instanceof org.eclipse.jdt.core.dom.SimpleName caseSn) {
caseVal = caseSn.getIdentifier();
if (switchType != null) {
caseVal = switchType + "." + caseVal;
}
}
if (caseVal != null) {
if (switchVar.contains(".") && caseVal.contains(".")) {
if (switchVar.equals(caseVal)) {
matchingCase = true;
break;
}
} else if (switchVar.endsWith(caseVal)) {
matchingCase = true;
break;
}
}
}
}
} else if (matchingCase) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) {
return resolveInternal(es.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys) {
return resolveInternal(ys.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
return resolveInternal(rs.getExpression(), context, visited);
}
}
}
return null;
}
private String resolveExpressionWithParams(Expression expr, java.util.Map<String, String> paramValues) {
if (expr instanceof SimpleName sn) {
String name = sn.getIdentifier();
if (paramValues.containsKey(name)) return paramValues.get(name);
return null;
} else if (expr instanceof FieldAccess fa) {
String name = "this." + fa.getName().getIdentifier();
if (paramValues.containsKey(name)) return paramValues.get(name);
return null;
} else if (expr instanceof QualifiedName qn) {
return null;
} else if (expr instanceof org.eclipse.jdt.core.dom.StringLiteral sl) {
return sl.getLiteralValue();
}
return null;
}
private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) {
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
String left = resolveInternal(expr.getLeftOperand(), context, visited);
String right = resolveInternal(expr.getRightOperand(), context, visited);
if (left == null || right == null) return null;
StringBuilder sb = new StringBuilder(left + right);
if (expr.hasExtendedOperands()) {
for (Object operand : expr.extendedOperands()) {
String val = resolveInternal((Expression) operand, context, visited);
if (val == null) return null;
sb.append(val);
}
}
return sb.toString();
}
private String resolveManual(SimpleName sn, CodebaseContext context, Set<String> visited) {
MethodDeclaration enclosingMethod = findEnclosingMethod(sn);
if (enclosingMethod != null) {
for (Object paramObj : enclosingMethod.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(sn.getIdentifier())) {
return null;
}
}
}
if (enclosingMethod.getBody() != null) {
boolean[] isLocal = {false};
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(sn.getIdentifier())) {
isLocal[0] = true;
}
return super.visit(node);
}
});
if (isLocal[0]) {
return null;
}
}
}
TypeDeclaration td = findEnclosingType(sn);
if (td != null) {
String fqn = context.getFqn(td);
String result = resolveFieldInType(td, sn.getIdentifier(), fqn, context, visited);
if (result != null) return result;
}
// Fallback: Static imports
ASTNode root = sn.getRoot();
if (root instanceof CompilationUnit cu) {
TypeDeclaration staticTd = context.resolveStaticImport(sn.getIdentifier(), cu);
if (staticTd != null) {
return resolveFieldInType(staticTd, sn.getIdentifier(), context.getFqn(staticTd), context, visited);
}
}
return null;
}
private String resolveManual(QualifiedName qn, CodebaseContext context, Set<String> visited) {
String qualifier = qn.getQualifier().toString();
String name = qn.getName().getIdentifier();
CompilationUnit contextCu = (CompilationUnit) qn.getRoot();
TypeDeclaration td = context.getTypeDeclaration(qualifier, contextCu);
if (td != null) {
String fqn = context.getFqn(td);
return resolveFieldInType(td, name, fqn, context, visited);
}
return null;
}
private String resolveFieldInType(TypeDeclaration td, String fieldName, String typeFqn, CodebaseContext context, Set<String> visited) {
String fieldId = typeFqn + "#" + fieldName;
if (visited.contains(fieldId)) {
log.warn("Circular reference detected during constant resolution: {}", fieldId);
return null;
}
visited.add(fieldId);
try {
for (FieldDeclaration fd : td.getFields()) {
for (Object fragmentObj : fd.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(fieldName)) {
// Check for @Value annotation first
String valueFromAnnotation = findValueFromAnnotation(fd);
if (valueFromAnnotation != null) return valueFromAnnotation;
Expression initializer = fragment.getInitializer();
if (initializer != null) {
return resolveInternal(initializer, context, visited);
}
}
}
}
}
// Check constructors for assignments
for (MethodDeclaration md : td.getMethods()) {
if (md.isConstructor() && md.getBody() != null) {
for (Object stmtObj : md.getBody().statements()) {
if (stmtObj instanceof ExpressionStatement es) {
if (es.getExpression() instanceof Assignment assignment) {
Expression lhs = assignment.getLeftHandSide();
boolean matches = false;
if (lhs instanceof SimpleName snLhs && snLhs.getIdentifier().equals(fieldName)) {
matches = true;
} else if (lhs instanceof FieldAccess fa && fa.getName().getIdentifier().equals(fieldName) && fa.getExpression() instanceof ThisExpression) {
matches = true;
}
if (matches) {
return resolveInternal(assignment.getRightHandSide(), context, visited);
}
}
}
}
}
}
} finally {
visited.remove(fieldId);
}
return null;
}
private String findValueFromAnnotation(FieldDeclaration fd) {
for (Object mod : fd.modifiers()) {
if (mod instanceof Annotation ann) {
String name = ann.getTypeName().getFullyQualifiedName();
if (name.endsWith("Value")) {
return extractAnnotationValue(ann);
}
}
}
return null;
}
private String extractAnnotationValue(Annotation ann) {
Expression valueExpr = null;
if (ann instanceof SingleMemberAnnotation sma) {
valueExpr = sma.getValue();
} else if (ann instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if ("value".equals(pair.getName().getIdentifier())) {
valueExpr = pair.getValue();
break;
}
}
}
if (valueExpr instanceof StringLiteral sl) {
return sl.getLiteralValue();
}
return valueExpr != null ? valueExpr.toString().replaceAll("^\"|\"$", "") : null;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null) {
if (parent instanceof MethodDeclaration md) {
return md;
}
parent = parent.getParent();
}
return null;
}
private String resolveLocalType(SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(sn);
if (enclosingMethod != null && enclosingMethod.getBody() != null) {
String[] typeName = new String[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
typeName[0] = node.getType().toString();
}
}
return super.visit(node);
}
});
if (typeName[0] != null) return typeName[0];
}
TypeDeclaration enclosingType = findEnclosingType(sn);
if (enclosingType != null) {
for (FieldDeclaration field : enclosingType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return field.getType().toString();
}
}
}
}
return null;
}
private String resolveEnumFqn(Expression expr, CodebaseContext context) {
if (expr == null) return null;
if (expr instanceof TypeLiteral tl) {
ITypeBinding binding = tl.getType().resolveBinding();
if (binding != null) {
return binding.getQualifiedName();
}
String typeName = tl.getType().toString();
if (typeName.endsWith(".class")) {
typeName = typeName.substring(0, typeName.length() - 6);
}
TypeDeclaration td = context.getTypeDeclaration(typeName);
if (td != null) return context.getFqn(td);
return typeName;
}
ITypeBinding binding = expr.resolveTypeBinding();
if (binding != null) {
if (binding.isClass() && "java.lang.Class".equals(binding.getErasure().getQualifiedName())) {
ITypeBinding[] typeArgs = binding.getTypeArguments();
if (typeArgs.length > 0) {
return typeArgs[0].getQualifiedName();
}
}
return binding.getQualifiedName();
}
String exprStr = expr.toString();
if (exprStr.endsWith(".class")) {
exprStr = exprStr.substring(0, exprStr.length() - 6);
}
TypeDeclaration td = context.getTypeDeclaration(exprStr);
if (td != null) return context.getFqn(td);
return exprStr;
}
}

View File

@@ -0,0 +1,22 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
public interface PolymorphicEventResolver {
/**
* Attempts to resolve a dynamically passed event variable into a set of
* concrete polymorphic event names.
*
* @param triggerPoint The original trigger point to resolve.
* @param resolvedValue The current String expression (e.g., "event.getType()" or "event")
* @param path The call path trace leading up to this variable.
* @param context The codebase context for deep type resolution.
* @return The updated TriggerPoint with polymorphic events set, or the original if unable to resolve.
*/
TriggerPoint resolvePolymorphicEvents(TriggerPoint triggerPoint, String resolvedValue, List<String> path, CodebaseContext context);
}

View File

@@ -0,0 +1,136 @@
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.Set;
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;
}
public Map<String, Map<String, String>> resolveAllProperties(Set<Path> rootDirs) {
Map<String, Map<String, String>> profiles = new HashMap<>();
for (Path rootDir : rootDirs) {
// 1. Load default profile
Map<String, String> defaultProps = profiles.computeIfAbsent("default", k -> new HashMap<>());
loadMatching(rootDir, "application.properties", defaultProps);
loadMatching(rootDir, "application.yml", defaultProps);
loadMatching(rootDir, "application.yaml", defaultProps);
// 2. Discover and load all other profiles
try (Stream<Path> paths = Files.walk(rootDir)) {
paths.filter(Files::isRegularFile)
.filter(p -> {
String name = p.getFileName().toString();
return name.startsWith("application-") &&
(name.endsWith(".properties") || name.endsWith(".yml") || name.endsWith(".yaml"));
})
.forEach(p -> {
String name = p.getFileName().toString();
String profile = name.substring("application-".length(), name.lastIndexOf('.'));
Map<String, String> loaded = p.toString().endsWith(".properties") ? loadProperties(p) : loadYaml(p);
profiles.computeIfAbsent(profile, k -> new HashMap<>()).putAll(loaded);
});
} catch (IOException e) {
log.error("Failed to scan for profiles in {}", rootDir, e);
}
}
return profiles;
}
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) {
Map<String, String> props = new HashMap<>();
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(new com.fasterxml.jackson.dataformat.yaml.YAMLFactory());
Map<String, Object> map = mapper.readValue(path.toFile(), new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
flattenYaml("", map, props);
} catch (IOException e) {
log.warn("Failed to load YAML from {}", path);
}
return props;
}
@SuppressWarnings("unchecked")
private void flattenYaml(String prefix, Map<String, Object> map, Map<String, String> result) {
if (map == null) return;
for (Map.Entry<String, Object> entry : map.entrySet()) {
String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey();
Object value = entry.getValue();
if (value instanceof Map) {
flattenYaml(key, (Map<String, Object>) value, result);
} else if (value != null) {
result.put(key, value.toString());
}
}
}
public String resolveValue(String placeholder, Map<String, String> properties) {
return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(placeholder, properties);
}
}

View File

@@ -0,0 +1,164 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Slf4j
public class SiblingDependencyResolver {
private static final Pattern GRADLE_PROJECT_DEP = Pattern.compile("project\\(['\"]:(.*?)['\"]\\)");
private static final Pattern MAVEN_DEPENDENCY = Pattern.compile("<dependency>\\s*<groupId>(.*?)</groupId>\\s*<artifactId>(.*?)</artifactId>\\s*<version>(.*?)</version>", Pattern.DOTALL);
public Set<Path> resolveSiblings(Path projectDir) {
Set<Path> siblings = new HashSet<>();
try {
siblings.addAll(resolveGradleSiblings(projectDir));
siblings.addAll(resolveMavenSiblings(projectDir));
} catch (Exception e) {
log.warn("Error resolving sibling dependencies for {}", projectDir, e);
}
return siblings;
}
private Set<Path> resolveGradleSiblings(Path projectDir) throws IOException {
Set<Path> siblings = new HashSet<>();
Path buildGradle = projectDir.resolve("build.gradle");
if (!Files.exists(buildGradle)) {
buildGradle = projectDir.resolve("build.gradle.kts");
}
if (Files.exists(buildGradle)) {
log.info("Found build.gradle at {}", buildGradle);
String content = Files.readString(buildGradle);
Matcher matcher = GRADLE_PROJECT_DEP.matcher(content);
Set<String> gradlePaths = new HashSet<>();
while (matcher.find()) {
gradlePaths.add(matcher.group(1));
}
log.info("Found project dependencies: {}", gradlePaths);
if (!gradlePaths.isEmpty()) {
Path rootDir = findGradleRoot(projectDir);
log.info("Resolved Gradle root to: {}", rootDir);
if (rootDir != null) {
for (String gradlePath : gradlePaths) {
String relativePath = gradlePath.replace(':', '/');
Path resolvedSibling = rootDir.resolve(relativePath).normalize();
if (Files.exists(resolvedSibling)) {
log.info("Discovered local Gradle sibling dependency: {}", resolvedSibling);
siblings.add(resolvedSibling);
}
}
}
}
}
return siblings;
}
private Path findGradleRoot(Path start) {
Path current = start.toAbsolutePath();
Path lastSettingsDir = null;
while (current != null) {
if (Files.exists(current.resolve("settings.gradle")) || Files.exists(current.resolve("settings.gradle.kts"))) {
lastSettingsDir = current;
}
if (Files.exists(current.resolve(".git"))) {
return lastSettingsDir != null ? lastSettingsDir : current;
}
current = current.getParent();
}
return lastSettingsDir;
}
private Set<Path> resolveMavenSiblings(Path projectDir) throws IOException {
Set<Path> siblings = new HashSet<>();
Path pom = projectDir.resolve("pom.xml");
if (Files.exists(pom)) {
log.info("Found pom.xml at {}", pom);
String content = Files.readString(pom);
Set<String> targetArtifacts = new HashSet<>();
Matcher matcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(content);
// We want artifacts from the <dependencies> section
int depsStart = content.indexOf("<dependencies>");
int depsEnd = content.indexOf("</dependencies>");
if (depsStart != -1 && depsEnd > depsStart) {
String depsContent = content.substring(depsStart, depsEnd);
Matcher depMatcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(depsContent);
while (depMatcher.find()) {
targetArtifacts.add(depMatcher.group(1));
}
}
log.info("Found maven target artifacts: {}", targetArtifacts);
if (!targetArtifacts.isEmpty()) {
Path rootDir = findMavenRoot(projectDir);
log.info("Resolved Maven root to: {}", rootDir);
if (rootDir != null) {
try (Stream<Path> paths = Files.walk(rootDir)) {
List<Path> allPoms = paths.filter(p -> p.getFileName().toString().equals("pom.xml"))
.filter(p -> !p.toString().contains("/target/"))
.collect(Collectors.toList());
log.info("Scanned {} total pom.xml files under {}", allPoms.size(), rootDir);
for (Path p : allPoms) {
try {
String pContent = Files.readString(p);
String artifactId = extractOwnArtifactId(pContent);
log.info("Checking artifactId {} in {}", artifactId, p);
if (artifactId != null && targetArtifacts.contains(artifactId)) {
Path siblingDir = p.getParent();
if (!siblingDir.equals(projectDir)) {
log.info("Discovered local Maven sibling dependency: {}", siblingDir);
siblings.add(siblingDir);
}
}
} catch (IOException e) {
// ignore
}
}
}
}
}
}
return siblings;
}
private String extractOwnArtifactId(String pomContent) {
// Strip <parent> block if it exists
String content = pomContent;
int parentEnd = pomContent.indexOf("</parent>");
if (parentEnd != -1) {
content = pomContent.substring(parentEnd);
}
Matcher matcher = Pattern.compile("<artifactId>(.*?)</artifactId>").matcher(content);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
private Path findMavenRoot(Path start) {
Path current = start.toAbsolutePath();
Path lastPomDir = null;
while (current != null) {
if (Files.exists(current.resolve("pom.xml"))) {
lastPomDir = current;
}
if (Files.exists(current.resolve(".git"))) {
return lastPomDir != null ? lastPomDir : current;
}
current = current.getParent();
}
return lastPomDir;
}
}

View File

@@ -0,0 +1,11 @@
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;
public interface CallGraphEngine {
List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers);
}

View File

@@ -0,0 +1,123 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
@Slf4j
public class CallGraphPathFinder {
private final CodebaseContext context;
public CallGraphPathFinder(CodebaseContext context) {
this.context = context;
}
public List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
if (start.equals(target)) return new ArrayList<>(List.of(start));
if (!visited.add(start)) {
if (log.isDebugEnabled()) {
log.debug("Cycle detected in call graph search: {} is already in visited path {}", start, visited);
}
return null; // Path-scoped cycle detection
}
List<CallEdge> neighbors = graph.get(start);
if (neighbors != null) {
for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod();
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
visited.remove(start);
return new ArrayList<>(List.of(start, target));
}
List<String> path = findPath(neighbor, target, graph, visited);
if (path != null) {
path.add(0, start);
visited.remove(start);
return path;
}
}
}
if (log.isDebugEnabled()) {
log.debug("Path search dead-end at {} when looking for {}", start, target);
}
visited.remove(start);
return null;
}
public String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) {
for (String node : path) {
List<CallEdge> edges = callGraph.get(node);
if (edges != null) {
for (CallEdge edge : edges) {
String target = edge.getTargetMethod();
if (target != null && (target.contains(".restore") || target.contains(".read"))) {
if (edge.getArguments().size() >= 2) {
return edge.getArguments().get(1); // The contextObj / machineId
}
}
}
}
}
return null;
}
public boolean isHeuristicMatch(String neighbor, String target) {
if (neighbor == null || target == null) return false;
if (neighbor.equals(target)) return true;
if (isFullyQualifiedMethod(neighbor) && isFullyQualifiedMethod(target)) {
int lastDotNeighbor = neighbor.lastIndexOf('.');
int lastDotTarget = target.lastIndexOf('.');
if (lastDotNeighbor < 0 || lastDotTarget < 0) return false;
String methodNeighbor = neighbor.substring(lastDotNeighbor + 1);
String methodTarget = target.substring(lastDotTarget + 1);
if (!methodNeighbor.equals(methodTarget)) {
return false;
}
String classNeighbor = neighbor.substring(0, lastDotNeighbor);
String classTarget = target.substring(0, lastDotTarget);
if (classNeighbor.equals(classTarget)) return true;
String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
if (simpleClassNeighbor.equals(simpleClassTarget)) return true;
if (simpleClassNeighbor.contains(simpleClassTarget) || simpleClassTarget.contains(simpleClassNeighbor)) return true;
List<String> impls = context.getImplementations(classTarget);
if (impls != null) {
for (String impl : impls) {
if (impl.equals(classNeighbor) || impl.endsWith("." + classNeighbor)) return true;
}
}
List<String> implsN = context.getImplementations(classNeighbor);
if (implsN != null) {
for (String impl : implsN) {
if (impl.equals(classTarget) || impl.endsWith("." + classTarget)) return true;
}
}
return false;
}
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
return simpleNeighbor.equals(simpleTarget);
}
private boolean isFullyQualifiedMethod(String methodFqn) {
if (methodFqn == null || !methodFqn.contains(".")) return false;
int lastDot = methodFqn.lastIndexOf('.');
String classPart = methodFqn.substring(0, lastDot);
if (classPart.contains(".")) return true;
if (!classPart.isEmpty() && Character.isUpperCase(classPart.charAt(0))) return true;
return false;
}
}

View File

@@ -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 per profile.
*/
Map<String, Map<String, String>> resolveProperties();
}

View File

@@ -0,0 +1,407 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import java.util.function.Function;
@Slf4j
public class ConstantExtractor {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private final Function<MethodInvocation, String> calledMethodResolver;
private VariableTracer variableTracer;
private ConstructorAnalyzer constructorAnalyzer;
public ConstantExtractor(CodebaseContext context, ConstantResolver constantResolver, Function<MethodInvocation, String> calledMethodResolver) {
this.context = context;
this.constantResolver = constantResolver;
this.calledMethodResolver = calledMethodResolver;
}
public void setVariableTracer(VariableTracer variableTracer) {
this.variableTracer = variableTracer;
}
public void setConstructorAnalyzer(ConstructorAnalyzer constructorAnalyzer) {
this.constructorAnalyzer = constructorAnalyzer;
}
public void extractConstantsFromExpression(Expression expr, List<String> constants) {
if (constantResolver != null) {
String resolved = constantResolver.resolve(expr, context);
if (resolved != null) {
constants.add(resolved);
return;
}
}
if (expr instanceof QualifiedName qn) {
constants.add(qn.toString());
} else if (expr instanceof StringLiteral sl) {
constants.add(sl.getLiteralValue());
} else if (expr instanceof ConditionalExpression ce) {
extractConstantsFromExpression(ce.getThenExpression(), constants);
extractConstantsFromExpression(ce.getElseExpression(), constants);
} else if (expr instanceof ParenthesizedExpression pe) {
extractConstantsFromExpression(pe.getExpression(), constants);
} else if (expr instanceof CastExpression ce) {
extractConstantsFromExpression(ce.getExpression(), constants);
} else if (expr instanceof Assignment assignment) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
} else if (expr instanceof SwitchExpression se) {
se.accept(new ASTVisitor() {
@Override
public boolean visit(YieldStatement ys) {
extractConstantsFromExpression(ys.getExpression(), constants);
return super.visit(ys);
}
@Override
public boolean visit(ExpressionStatement es) {
extractConstantsFromExpression(es.getExpression(), constants);
return super.visit(es);
}
@Override
public boolean visit(ReturnStatement rs) {
extractConstantsFromExpression(rs.getExpression(), constants);
return super.visit(rs);
}
});
} else if (expr instanceof ArrayAccess aa) {
extractConstantsFromExpression(aa.getArray(), constants);
} else if (expr instanceof ArrayCreation ac && ac.getInitializer() != null) {
for (Object expObj : ac.getInitializer().expressions()) {
extractConstantsFromArgument((Expression) expObj, constants);
}
} else if (expr instanceof ArrayInitializer ai) {
for (Object expObj : ai.expressions()) {
extractConstantsFromArgument((Expression) expObj, constants);
}
} else if (expr instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
System.out.println("Processing mi: " + mi); if (methodName.equals("get") || methodName.equals("getOrDefault")) {
if (mi.getExpression() != null) {
if (variableTracer != null) {
List<Expression> traced = variableTracer.traceVariableAll(mi.getExpression());
for (Expression t : traced) {
extractConstantsFromExpression(t, constants);
}
} else {
extractConstantsFromExpression(mi.getExpression(), constants);
}
}
return;
}
if (methodName.equals("of") || methodName.equals("ofEntries") || methodName.equals("asList") || methodName.equals("entry") ||
methodName.equals("just") || methodName.equals("withPayload") || methodName.equals("success")) {
for (Object argObj : mi.arguments()) {
extractConstantsFromExpression((Expression) argObj, constants);
}
return;
}
if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event")) && mi.getExpression() instanceof SimpleName sn) {
String varName = sn.getIdentifier();
String propName = methodName.startsWith("get") ? methodName.substring(3) : methodName;
Block block = findEnclosingBlock(mi);
if (block != null) {
for (Object stmtObj : block.statements()) {
if (stmtObj == mi.getParent() || stmtObj == mi) break;
if (stmtObj instanceof ExpressionStatement es) {
if (es.getExpression() instanceof MethodInvocation setterMi) {
if (setterMi.getName().getIdentifier().equalsIgnoreCase("set" + propName) || setterMi.getName().getIdentifier().equalsIgnoreCase(propName)) {
if (setterMi.getExpression() instanceof SimpleName setterSn && setterSn.getIdentifier().equals(varName)) {
if (!setterMi.arguments().isEmpty()) {
extractConstantsFromExpression((Expression) setterMi.arguments().get(0), constants);
return;
}
}
}
} else if (es.getExpression() instanceof Assignment assignment) {
if (assignment.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
return;
}
}
} else if (assignment.getLeftHandSide() instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) {
extractConstantsFromExpression(assignment.getRightHandSide(), constants);
return;
}
}
}
}
}
}
}
}
List<Expression> receivers = new ArrayList<>();
if (mi.getExpression() != null) {
if (mi.getExpression() instanceof ClassInstanceCreation) {
receivers.add(mi.getExpression());
} else if (variableTracer != null) {
receivers.addAll(variableTracer.traceVariableAll(mi.getExpression()));
}
}
for (Expression receiverExpr : receivers) {
if (receiverExpr instanceof ClassInstanceCreation cic) {
if (cic.getAnonymousClassDeclaration() != null) {
for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) {
if (declObj instanceof MethodDeclaration mdecl) {
if (mdecl.getName().getIdentifier().equals(methodName) && mdecl.getBody() != null) {
mdecl.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement rs) {
if (rs.getExpression() != null) {
extractConstantsFromExpression(rs.getExpression(), constants);
}
return super.visit(rs);
}
});
}
}
}
} else if (constructorAnalyzer != null) {
boolean handledByLombok = false;
if (methodName.startsWith("get") && methodName.length() > 3) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
TypeDeclaration typeDecl = context.getTypeDeclaration(typeName);
if (typeDecl != null) {
String fieldName = Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
int fieldIndex = constructorAnalyzer.findConstructorArgumentIndexForLombokField(typeDecl, fieldName);
if (fieldIndex >= 0 && fieldIndex < cic.arguments().size()) {
extractConstantsFromArgument((Expression) cic.arguments().get(fieldIndex), constants);
handledByLombok = true;
}
}
}
if (!handledByLombok && methodName != null && !methodName.isEmpty()) {
List<String> narrowed = constructorAnalyzer.resolveInlineInstantiationGetterResult(
cic, methodName, context, new HashSet<>());
if (narrowed.isEmpty()) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
CompilationUnit contextCu = expr.getRoot() instanceof CompilationUnit ? (CompilationUnit) expr.getRoot() : null;
TypeDeclaration typeDecl = contextCu != null ? context.getTypeDeclaration(typeName, contextCu) : context.getTypeDeclaration(typeName);
if (typeDecl == null) typeDecl = context.getTypeDeclaration(typeName);
if (typeDecl != null) {
narrowed = resolveMethodReturnConstant(context.getFqn(typeDecl), methodName, 0, new HashSet<>(), contextCu);
}
}
if (narrowed != null && !narrowed.isEmpty()) {
constants.addAll(narrowed);
}
}
}
}
}
Expression currentMi = mi;
while (currentMi instanceof MethodInvocation chainMi) {
String chainName = chainMi.getName().getIdentifier();
if (chainName.equals("setHeader") && chainMi.arguments().size() == 2) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(1), constants);
} else if ((chainName.equalsIgnoreCase("event") || chainName.equalsIgnoreCase("type") || chainName.equalsIgnoreCase("eventType")) && chainMi.arguments().size() == 1) {
extractConstantsFromExpression((Expression) chainMi.arguments().get(0), constants);
}
currentMi = chainMi.getExpression();
}
TypeDeclaration td = findEnclosingType(mi);
if (td != null && (mi.getExpression() == null || mi.getExpression() instanceof ThisExpression)) {
List<String> values = resolveMethodReturnConstant(context.getFqn(td), methodName, 0, new HashSet<>(), null);
if (values != null) constants.addAll(values);
}
}
}
public void extractConstantsFromArgument(Expression argObj, List<String> constants) {
if (argObj instanceof ClassInstanceCreation innerCic) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(innerCic.getType());
if ("String".equals(typeName)) {
extractConstantsFromExpression(argObj, constants);
}
} else {
extractConstantsFromExpression(argObj, constants);
}
}
public List<String> resolveClassConstantReturns(String className, CodebaseContext context, CompilationUnit contextCu) {
if (className != null && className.contains(".")) {
String prefix = className.substring(0, className.lastIndexOf('.'));
String suffix = className.substring(className.lastIndexOf('.') + 1);
TypeDeclaration prefixTd = contextCu != null ? context.getTypeDeclaration(prefix, contextCu) : context.getTypeDeclaration(prefix);
if (prefixTd == null) prefixTd = context.getTypeDeclaration(prefix);
boolean isKnownType = prefixTd != null || context.getEnumValues(prefix) != null;
if (isKnownType) {
String simplePrefix = prefix.contains(".") ? prefix.substring(prefix.lastIndexOf('.') + 1) : prefix;
return Collections.singletonList(simplePrefix + "." + suffix);
}
}
TypeDeclaration td = contextCu != null ? context.getTypeDeclaration(className, contextCu) : context.getTypeDeclaration(className);
if (td == null) td = context.getTypeDeclaration(className);
if (td == null) return null;
final List<String> resolvedConstants = new ArrayList<>();
for (MethodDeclaration md : td.getMethods()) {
if (md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
public boolean visit(ReturnStatement node) {
if (variableTracer != null) {
List<Expression> tracedReturns = variableTracer.traceVariableAll(node.getExpression());
for (Expression tracedRet : tracedReturns) {
extractConstantsFromExpression(tracedRet, resolvedConstants);
}
}
return super.visit(node);
}
});
}
if (!resolvedConstants.isEmpty()) {
return resolvedConstants;
}
}
return null;
}
public List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
log.debug("Entering resolveMethodReturnConstant with className={}, methodName={}, depth={}", className, methodName, depth);
if (depth > 20) {
log.debug("Exiting resolveMethodReturnConstant early (depth limit) for {}.{}", className, methodName);
return Collections.emptyList();
}
String fqn = className + "." + methodName;
if (!visited.add(fqn)) {
log.debug("Exiting resolveMethodReturnConstant early (cycle detected) for fqn={}", fqn);
return Collections.emptyList();
}
List<String> constants = new ArrayList<>();
TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
if (tempTd == null) {
tempTd = context.getTypeDeclaration(className);
}
final TypeDeclaration td = tempTd;
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
Expression retExpr = node.getExpression();
if (retExpr != null) {
boolean handled = false;
if (retExpr instanceof MethodInvocation mi && calledMethodResolver != null) {
String called = calledMethodResolver.apply(mi);
if (called != null && called.contains(".")) {
if (visited.contains(called)) {
handled = true;
} else {
String cName = called.substring(0, called.lastIndexOf('.'));
String mName = called.substring(called.lastIndexOf('.') + 1);
TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName);
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
handled = true;
}
}
}
} else if (retExpr instanceof SuperMethodInvocation smi) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
String called = superFqn + "." + smi.getName().getIdentifier();
if (visited.contains(called)) {
handled = true;
} else {
String cName = superFqn;
String mName = smi.getName().getIdentifier();
TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName);
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
handled = true;
}
}
}
}
if (!handled && variableTracer != null && constructorAnalyzer != null) {
List<Expression> tracedRetExprs = variableTracer.traceVariableAll(retExpr);
for (Expression tracedRetExpr : tracedRetExprs) {
extractConstantsFromExpression(tracedRetExpr, constants);
String val = constantResolver.resolve(tracedRetExpr, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!constants.contains(parsed)) constants.add(parsed);
}
} else {
if (!constants.contains(val)) constants.add(val);
}
} else if (tracedRetExpr instanceof SimpleName sn) {
List<String> consts = constructorAnalyzer.traceFieldInConstructors(td, sn.getIdentifier(), context, visited);
if (!consts.isEmpty()) {
constants.addAll(consts);
}
} else if (tracedRetExpr instanceof FieldAccess fa) {
List<String> consts = constructorAnalyzer.traceFieldInConstructors(td, fa.getName().getIdentifier(), context, visited);
if (!consts.isEmpty()) {
constants.addAll(consts);
}
}
}
}
}
return super.visit(node);
}
});
} else {
List<String> impls = context.getImplementations(className);
if (impls != null) {
for (String implName : impls) {
List<String> delegationResult = resolveMethodReturnConstant(implName, methodName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
}
}
}
}
visited.remove(fqn);
log.debug("resolveMethodReturnConstant for class={}, method={} resolved constants: {}", className, methodName, constants);
return constants;
}
public String parseEnumSetElement(String eVal) {
return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal;
}
private Block findEnclosingBlock(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof Block)) {
parent = parent.getParent();
}
return (Block) parent;
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
}

View File

@@ -0,0 +1,808 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class ConstructorAnalyzer {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private VariableTracer variableTracer;
private ConstantExtractor constantExtractor;
@FunctionalInterface
public interface RhsResolver {
String resolve(Expression rhs, Map<String, String> paramValues, TypeDeclaration td);
}
public ConstructorAnalyzer(CodebaseContext context, ConstantResolver constantResolver) {
this.context = context;
this.constantResolver = constantResolver;
}
public void setVariableTracer(VariableTracer variableTracer) {
this.variableTracer = variableTracer;
}
public void setConstantExtractor(ConstantExtractor constantExtractor) {
this.constantExtractor = constantExtractor;
}
public List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) {
if (td == null || fieldName == null) return Collections.emptyList();
String fqn = context.getFqn(td);
if (fqn == null) return Collections.emptyList();
String cycleKey = fqn + "#" + fieldName;
if (!visited.add(cycleKey)) {
return Collections.emptyList();
}
List<String> results = new ArrayList<>();
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName) && frag.getInitializer() != null) {
Expression right = frag.getInitializer();
if (variableTracer != null && constantExtractor != null) {
List<Expression> tracedRights = variableTracer.traceVariableAll(right);
for (Expression tracedRight : tracedRights) {
if (tracedRight instanceof MethodInvocation mi) {
String calledName = mi.getName().getIdentifier();
String targetClass = context.getFqn(td);
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
if (mi.getExpression() == null) {
TypeDeclaration targetTd = context.resolveStaticImport(calledName, cu);
if (targetTd != null) {
targetClass = context.getFqn(targetTd);
}
}
results.addAll(constantExtractor.resolveMethodReturnConstant(targetClass, calledName, 0, visited, cu));
} else {
String val = constantResolver.resolve(tracedRight, context);
if (val != null) results.add(val);
}
}
}
}
}
}
ASTVisitor assignmentVisitor = new ASTVisitor() {
@Override
public boolean visit(Assignment node) {
Expression left = node.getLeftHandSide();
if ((left instanceof SimpleName && ((SimpleName) left).getIdentifier().equals(fieldName)) ||
(left instanceof FieldAccess && ((FieldAccess) left).getName().getIdentifier().equals(fieldName))) {
Expression right = node.getRightHandSide();
if (variableTracer != null && constantExtractor != null) {
List<Expression> tracedRights = variableTracer.traceVariableAll(right);
for (Expression tracedRight : tracedRights) {
if (tracedRight instanceof MethodInvocation mi) {
String calledName = mi.getName().getIdentifier();
String targetClass = context.getFqn(td);
if (mi.getExpression() != null && mi.resolveMethodBinding() != null && mi.resolveMethodBinding().getDeclaringClass() != null) {
targetClass = mi.resolveMethodBinding().getDeclaringClass().getQualifiedName();
} else if (mi.getExpression() instanceof SimpleName) {
String exprName = ((SimpleName) mi.getExpression()).getIdentifier();
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
TypeDeclaration targetTd = context.resolveStaticImport(exprName, cu);
if (targetTd != null) {
targetClass = context.getFqn(targetTd);
}
} else if (mi.getExpression() == null) {
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
TypeDeclaration targetTd = context.resolveStaticImport(calledName, cu);
if (targetTd != null) {
targetClass = context.getFqn(targetTd);
}
}
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
results.addAll(constantExtractor.resolveMethodReturnConstant(targetClass, calledName, 0, visited, cu));
} else {
String val = constantResolver.resolve(tracedRight, context);
if (val != null) results.add(val);
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(ConstructorInvocation node) {
processConstructorInvocationArgs(node.arguments(), findEnclosingType(node), node, results, fieldName, context, visited);
return super.visit(node);
}
@Override
public boolean visit(SuperConstructorInvocation node) {
TypeDeclaration enclosingTd = findEnclosingType(node);
if (enclosingTd != null) {
String superFqn = context.getSuperclassFqn(enclosingTd);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
processConstructorInvocationArgs(node.arguments(), superTd, node, results, fieldName, context, visited);
}
}
return super.visit(node);
}
};
for (MethodDeclaration md : td.getMethods()) {
if (md.isConstructor() && md.getBody() != null) {
md.getBody().accept(assignmentVisitor);
}
}
for (Object bodyDecl : td.bodyDeclarations()) {
if (bodyDecl instanceof Initializer init && init.getBody() != null) {
init.getBody().accept(assignmentVisitor);
}
}
if (results.isEmpty()) {
// Context-Aware Fallback (Up): Check superclass constructors
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null && !superFqn.equals("java.lang.Object")) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
results.addAll(traceFieldInConstructors(superTd, fieldName, context, visited));
}
}
}
if (results.isEmpty()) {
// Context-Aware Fallback (Down): Check subclasses/implementations
List<String> impls = context.getImplementations(fqn);
if (impls != null) {
for (String implFqn : impls) {
TypeDeclaration implTd = context.getTypeDeclaration(implFqn);
if (implTd != null) {
results.addAll(traceFieldInConstructors(implTd, fieldName, context, visited));
}
}
}
}
if (results.isEmpty()) {
// Global Fallback: Search all parsed classes in the workspace
Collection<TypeDeclaration> allTypes = context.getTypeDeclarations();
if (allTypes != null) {
int matchedCount = 0;
for (TypeDeclaration typeDecl : allTypes) {
if (typeDecl == null) continue;
if (context.hasField(typeDecl, fieldName)) {
results.addAll(traceFieldInConstructors(typeDecl, fieldName, context, visited));
matchedCount++;
if (matchedCount >= 10) {
break;
}
}
}
}
}
visited.remove(cycleKey);
return results;
}
public Map<String, String> buildFieldValuesFromCIC(
TypeDeclaration td,
ClassInstanceCreation cic,
CodebaseContext context,
RhsResolver rhsResolver,
Map<String, String> initialLocals) {
String actualTypeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
TypeDeclaration actualTd = context.getTypeDeclaration(actualTypeName);
if (actualTd == null) {
actualTd = td;
}
Map<String, String> fieldValues = new HashMap<>();
Set<String> visitedCtors = new HashSet<>();
buildFieldValuesFromCICRecursive(actualTd, cic, context, fieldValues, visitedCtors, rhsResolver, initialLocals);
return fieldValues;
}
public Map<String, String> buildFieldValuesFromCIC(
TypeDeclaration td,
ClassInstanceCreation cic,
CodebaseContext context,
RhsResolver rhsResolver) {
return buildFieldValuesFromCIC(td, cic, context, rhsResolver, null);
}
public Map<String, String> buildFieldValuesFromCIC(
TypeDeclaration td,
ClassInstanceCreation cic,
CodebaseContext context) {
return buildFieldValuesFromCIC(td, cic, context, null, null);
}
private void buildFieldValuesFromCICRecursive(
TypeDeclaration td,
ClassInstanceCreation cic,
CodebaseContext context,
Map<String, String> fieldValues,
Set<String> visitedCtors,
RhsResolver rhsResolver,
Map<String, String> initialLocals) {
String tdKey = context.getFqn(td);
if (!visitedCtors.add(tdKey)) return;
IMethodBinding resolvedCtor = cic.resolveConstructorBinding();
for (MethodDeclaration ctor : td.getMethods()) {
if (!ctor.isConstructor() || ctor.getBody() == null) continue;
boolean matches = false;
if (resolvedCtor != null && ctor.resolveBinding() != null) {
matches = resolvedCtor.isEqualTo(ctor.resolveBinding()) || resolvedCtor.getKey().equals(ctor.resolveBinding().getKey());
} else {
matches = ctor.parameters().size() == cic.arguments().size();
}
if (!matches) continue;
Map<String, String> paramValues = new HashMap<>();
for (int i = 0; i < ctor.parameters().size(); i++) {
String pName = ((SingleVariableDeclaration) ctor.parameters().get(i)).getName().getIdentifier();
Expression argExpr = (Expression) cic.arguments().get(i);
List<String> consts = new ArrayList<>();
if (constantExtractor != null) {
constantExtractor.extractConstantsFromArgument(argExpr, consts);
}
if (!consts.isEmpty()) {
paramValues.put(pName, consts.get(0));
} else {
String resolved = null;
if (argExpr instanceof SimpleName sn && initialLocals != null && initialLocals.containsKey(sn.getIdentifier())) {
resolved = initialLocals.get(sn.getIdentifier());
}
if (resolved == null) {
resolved = constantResolver.resolve(argExpr, context);
}
if (resolved != null) paramValues.put(pName, resolved);
}
}
if (paramValues.isEmpty()) continue;
ctor.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(Assignment node) {
Expression lhs = node.getLeftHandSide();
Expression rhs = node.getRightHandSide();
String fieldName = null;
if (lhs instanceof FieldAccess fa
&& fa.getExpression() instanceof ThisExpression) {
fieldName = fa.getName().getIdentifier();
} else if (lhs instanceof SimpleName sn
&& !paramValues.containsKey(sn.getIdentifier())) {
fieldName = sn.getIdentifier();
}
if (fieldName != null) {
String paramVal = null;
if (rhs instanceof SimpleName snRhs) {
paramVal = paramValues.get(snRhs.getIdentifier());
} else if (rhsResolver != null) {
paramVal = rhsResolver.resolve(rhs, paramValues, td);
}
if (paramVal != null) {
fieldValues.put(fieldName, paramVal);
fieldValues.put("this." + fieldName, paramVal);
}
}
return super.visit(node);
}
});
ctor.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(SuperConstructorInvocation sci) {
IMethodBinding binding = sci.resolveConstructorBinding();
if (binding != null) {
ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
TypeDeclaration superTd = context.getTypeDeclaration(declaringClass.getErasure().getQualifiedName());
if (superTd != null) {
processSuperConstructorArgs(superTd, binding, sci.arguments(), paramValues, context, fieldValues, visitedCtors, rhsResolver);
return super.visit(sci);
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
processSuperConstructorArgs(superTd, null, sci.arguments(), paramValues, context, fieldValues, visitedCtors, rhsResolver);
}
}
return super.visit(sci);
}
@Override
public boolean visit(ConstructorInvocation ci) {
IMethodBinding binding = ci.resolveConstructorBinding();
processSuperConstructorArgs(td, binding, ci.arguments(), paramValues, context, fieldValues, visitedCtors, rhsResolver);
return super.visit(ci);
}
});
break;
}
}
private void processSuperConstructorArgs(
TypeDeclaration superTd,
IMethodBinding resolvedBinding,
List<?> superArgs,
Map<String, String> subClassParamValues,
CodebaseContext context,
Map<String, String> fieldValues,
Set<String> visitedCtors,
RhsResolver rhsResolver) {
if (superTd == null) return;
for (MethodDeclaration superCtor : superTd.getMethods()) {
if (!superCtor.isConstructor() || superCtor.getBody() == null) continue;
boolean matches = false;
if (resolvedBinding != null && superCtor.resolveBinding() != null) {
matches = resolvedBinding.isEqualTo(superCtor.resolveBinding()) || resolvedBinding.getKey().equals(superCtor.resolveBinding().getKey());
} else {
matches = superCtor.parameters().size() == superArgs.size();
}
if (!matches) continue;
Map<String, String> superParamValues = new HashMap<>();
for (int i = 0; i < superCtor.parameters().size(); i++) {
String pName = ((SingleVariableDeclaration) superCtor.parameters().get(i)).getName().getIdentifier();
Expression argExpr = (Expression) superArgs.get(i);
String resolved = null;
if (argExpr instanceof SimpleName sn) {
resolved = subClassParamValues.get(sn.getIdentifier());
}
if (resolved == null) {
List<String> consts = new ArrayList<>();
if (constantExtractor != null) {
constantExtractor.extractConstantsFromArgument(argExpr, consts);
}
if (!consts.isEmpty()) {
resolved = consts.get(0);
} else {
resolved = constantResolver.resolve(argExpr, context);
}
}
if (resolved != null) {
superParamValues.put(pName, resolved);
}
}
if (superParamValues.isEmpty()) continue;
superCtor.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(Assignment node) {
Expression lhs = node.getLeftHandSide();
Expression rhs = node.getRightHandSide();
String fieldName = null;
if (lhs instanceof FieldAccess fa
&& fa.getExpression() instanceof ThisExpression) {
fieldName = fa.getName().getIdentifier();
} else if (lhs instanceof SimpleName sn
&& !superParamValues.containsKey(sn.getIdentifier())) {
fieldName = sn.getIdentifier();
}
if (fieldName != null) {
String paramVal = null;
if (rhs instanceof SimpleName snRhs) {
paramVal = superParamValues.get(snRhs.getIdentifier());
} else if (rhsResolver != null) {
paramVal = rhsResolver.resolve(rhs, superParamValues, superTd);
}
if (paramVal != null) {
fieldValues.put(fieldName, paramVal);
fieldValues.put("this." + fieldName, paramVal);
}
}
return super.visit(node);
}
});
superCtor.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(SuperConstructorInvocation sci) {
IMethodBinding binding = sci.resolveConstructorBinding();
if (binding != null) {
ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
TypeDeclaration nextSuperTd = context.getTypeDeclaration(declaringClass.getErasure().getQualifiedName());
if (nextSuperTd != null) {
processSuperConstructorArgs(nextSuperTd, binding, sci.arguments(), superParamValues, context, fieldValues, visitedCtors, rhsResolver);
return super.visit(sci);
}
}
}
String superFqn = context.getSuperclassFqn(superTd);
if (superFqn != null) {
TypeDeclaration nextSuperTd = context.getTypeDeclaration(superFqn);
if (nextSuperTd != null) {
processSuperConstructorArgs(nextSuperTd, null, sci.arguments(), superParamValues, context, fieldValues, visitedCtors, rhsResolver);
}
}
return super.visit(sci);
}
@Override
public boolean visit(ConstructorInvocation ci) {
IMethodBinding binding = ci.resolveConstructorBinding();
processSuperConstructorArgs(superTd, binding, ci.arguments(), superParamValues, context, fieldValues, visitedCtors, rhsResolver);
return super.visit(ci);
}
});
break;
}
}
public String evaluateMethodCallInConstructor(
Expression rhs,
Map<String, String> paramValues,
TypeDeclaration td) {
if (!(rhs instanceof MethodInvocation mi)) {
return null;
}
String methodName = mi.getName().getIdentifier();
Expression receiver = mi.getExpression();
if (receiver != null && !(receiver instanceof ThisExpression)) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(td, methodName, true);
if (method == null || method.getBody() == null) {
return null;
}
Map<String, String> localVars = new HashMap<>(paramValues);
return constantResolver.evaluateMethodBodyWithLocals(method, localVars, context, new HashSet<>());
}
public ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context) {
return findReturnedCIC(className, methodName, context, new HashSet<>(), 0);
}
private ClassInstanceCreation findReturnedCIC(
String className, String methodName, CodebaseContext context,
Set<String> visited, int depth) {
if (depth > 50 || !visited.add(className + "#" + methodName)) {
return null;
}
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
ClassInstanceCreation[] result = {null};
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement rs) {
if (rs.getExpression() instanceof ClassInstanceCreation cic) {
result[0] = cic;
}
return false;
}
});
if (result[0] != null) return result[0];
}
}
List<String> impls = context.getImplementations(className);
for (String impl : impls) {
ClassInstanceCreation cic = findReturnedCIC(impl, methodName, context, visited, depth + 1);
if (cic != null) return cic;
}
return null;
}
public List<String> resolveInlineInstantiationGetterResult(
ClassInstanceCreation cic,
String getterName,
CodebaseContext context,
Set<String> visited) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType());
CompilationUnit contextCu = cic.getRoot() instanceof CompilationUnit ? (CompilationUnit) cic.getRoot() : null;
AbstractTypeDeclaration atd = contextCu != null ? context.getAbstractTypeDeclaration(typeName, contextCu) : context.getAbstractTypeDeclaration(typeName);
if (atd == null) atd = context.getAbstractTypeDeclaration(typeName);
if (atd == null) return Collections.emptyList();
if (atd instanceof RecordDeclaration rd) {
int compIdx = -1;
List<?> components = rd.recordComponents();
for (int i = 0; i < components.size(); i++) {
Object compObj = components.get(i);
if (compObj != null) {
try {
java.lang.reflect.Method getNameMethod = compObj.getClass().getMethod("getName");
SimpleName nameObj = (SimpleName) getNameMethod.invoke(compObj);
if (nameObj != null && nameObj.getIdentifier().equals(getterName)) {
compIdx = i;
break;
}
} catch (Exception e) {
// ignore reflection errors
}
}
}
if (compIdx >= 0 && compIdx < cic.arguments().size()) {
Expression argExpr = (Expression) cic.arguments().get(compIdx);
List<String> consts = new ArrayList<>();
if (constantExtractor != null) {
constantExtractor.extractConstantsFromArgument(argExpr, consts);
}
if (!consts.isEmpty()) {
return consts;
} else {
String resolved = constantResolver.resolve(argExpr, context);
if (resolved != null) {
return List.of(resolved);
}
}
}
return Collections.emptyList();
}
if (!(atd instanceof TypeDeclaration td)) return Collections.emptyList();
MethodDeclaration getter = context.findMethodDeclaration(td, getterName, true);
if (getter == null || getter.getBody() == null) return Collections.emptyList();
String getterKey = typeName + "#" + getterName;
if (!visited.add(getterKey)) {
return Collections.emptyList();
}
try {
Map<String, String> fieldValues = buildFieldValuesFromCIC(td, cic, context);
if (fieldValues.isEmpty()) return Collections.emptyList();
String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, visited);
return result != null ? List.of(result) : Collections.emptyList();
} finally {
visited.remove(getterKey);
}
}
public int findConstructorArgumentIndexForLombokField(TypeDeclaration td, String fieldName) {
boolean isAllArgsConstructor = false;
boolean isRequiredArgsConstructor = false;
boolean isData = false;
boolean isValue = false;
for (Object modObj : td.modifiers()) {
if (modObj instanceof MarkerAnnotation ma) {
String name = ma.getTypeName().getFullyQualifiedName();
if (name.equals("AllArgsConstructor") || name.equals("lombok.AllArgsConstructor")) isAllArgsConstructor = true;
if (name.equals("RequiredArgsConstructor") || name.equals("lombok.RequiredArgsConstructor")) isRequiredArgsConstructor = true;
if (name.equals("Data") || name.equals("lombok.Data")) isData = true;
if (name.equals("Value") || name.equals("lombok.Value")) isValue = true;
} else if (modObj instanceof NormalAnnotation na) {
String name = na.getTypeName().getFullyQualifiedName();
if (name.equals("AllArgsConstructor") || name.equals("lombok.AllArgsConstructor")) isAllArgsConstructor = true;
if (name.equals("RequiredArgsConstructor") || name.equals("lombok.RequiredArgsConstructor")) isRequiredArgsConstructor = true;
if (name.equals("Data") || name.equals("lombok.Data")) isData = true;
if (name.equals("Value") || name.equals("lombok.Value")) isValue = true;
}
}
if (!isAllArgsConstructor && !isRequiredArgsConstructor && !isData && !isValue) {
return -1;
}
int index = 0;
for (FieldDeclaration fd : td.getFields()) {
boolean isStatic = false;
boolean isFinal = false;
boolean hasNonNull = false;
for (Object modObj : fd.modifiers()) {
if (modObj instanceof Modifier mod) {
if (mod.isStatic()) isStatic = true;
if (mod.isFinal()) isFinal = true;
} else if (modObj instanceof MarkerAnnotation ma) {
if (ma.getTypeName().getFullyQualifiedName().endsWith("NonNull")) hasNonNull = true;
}
}
if (isStatic) continue;
if (isRequiredArgsConstructor && !isAllArgsConstructor && !isFinal && !hasNonNull) continue;
if (isData && !isAllArgsConstructor && !isFinal && !hasNonNull) continue;
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return index;
}
index++;
}
}
return -1;
}
private void processConstructorInvocationArgs(List<?> arguments, TypeDeclaration targetTd, ASTNode callNode, List<String> results, String fieldName, CodebaseContext context, Set<String> visited) {
if (targetTd == null) return;
MethodDeclaration callerMd = findEnclosingMethod(callNode);
IMethodBinding resolvedBinding = null;
if (callNode instanceof ConstructorInvocation ci) {
resolvedBinding = ci.resolveConstructorBinding();
} else if (callNode instanceof SuperConstructorInvocation sci) {
resolvedBinding = sci.resolveConstructorBinding();
}
for (MethodDeclaration otherMd : targetTd.getMethods()) {
boolean matches = false;
if (resolvedBinding != null && otherMd.resolveBinding() != null) {
matches = resolvedBinding.isEqualTo(otherMd.resolveBinding()) || resolvedBinding.getKey().equals(otherMd.resolveBinding().getKey());
} else {
matches = otherMd.isConstructor() && otherMd.parameters().size() == arguments.size() && otherMd != callerMd;
}
if (matches) {
int targetIdx = findAssignedParameterIndex(otherMd, fieldName, context, new HashSet<>());
if (targetIdx >= 0 && targetIdx < arguments.size()) {
Expression arg = (Expression) arguments.get(targetIdx);
String val = constantResolver.resolve(arg, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
if (constantExtractor != null) {
results.add(constantExtractor.parseEnumSetElement(eVal));
}
}
} else {
results.add(val);
}
}
}
}
}
}
private int findAssignedParameterIndex(MethodDeclaration constructorMd, String fieldName, CodebaseContext context, Set<MethodDeclaration> visited) {
if (constructorMd == null || constructorMd.getBody() == null) return -1;
if (!visited.add(constructorMd)) return -1;
final int[] foundIdx = {-1};
constructorMd.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(Assignment asn) {
Expression left = asn.getLeftHandSide();
if ((left instanceof SimpleName sn && sn.getIdentifier().equals(fieldName)) ||
(left instanceof FieldAccess fa && fa.getName().getIdentifier().equals(fieldName)) ||
(left instanceof SuperFieldAccess sfa && sfa.getName().getIdentifier().equals(fieldName))) {
Expression right = asn.getRightHandSide();
String rightName = extractVariableName(right);
if (rightName != null) {
for (int i = 0; i < constructorMd.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) constructorMd.parameters().get(i);
if (svd.getName().getIdentifier().equals(rightName)) {
foundIdx[0] = i;
}
}
}
}
return super.visit(asn);
}
@Override
public boolean visit(ConstructorInvocation node) {
IMethodBinding resolvedBinding = node.resolveConstructorBinding();
TypeDeclaration enclosingTd = findEnclosingType(node);
if (enclosingTd != null) {
for (MethodDeclaration otherMd : enclosingTd.getMethods()) {
boolean matches = false;
if (resolvedBinding != null && otherMd.resolveBinding() != null) {
matches = resolvedBinding.isEqualTo(otherMd.resolveBinding()) || resolvedBinding.getKey().equals(otherMd.resolveBinding().getKey());
} else {
matches = otherMd.isConstructor() && otherMd != constructorMd && otherMd.parameters().size() == node.arguments().size();
}
if (matches) {
int targetIdx = findAssignedParameterIndex(otherMd, fieldName, context, visited);
if (targetIdx >= 0 && targetIdx < node.arguments().size()) {
Expression arg = (Expression) node.arguments().get(targetIdx);
String argName = extractVariableName(arg);
if (argName != null) {
for (int i = 0; i < constructorMd.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) constructorMd.parameters().get(i);
if (svd.getName().getIdentifier().equals(argName)) {
foundIdx[0] = i;
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperConstructorInvocation node) {
IMethodBinding resolvedBinding = node.resolveConstructorBinding();
TypeDeclaration enclosingTd = findEnclosingType(node);
if (enclosingTd != null) {
String superFqn = context.getSuperclassFqn(enclosingTd);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
for (MethodDeclaration otherMd : superTd.getMethods()) {
boolean matches = false;
if (resolvedBinding != null && otherMd.resolveBinding() != null) {
matches = resolvedBinding.isEqualTo(otherMd.resolveBinding()) || resolvedBinding.getKey().equals(otherMd.resolveBinding().getKey());
} else {
matches = otherMd.isConstructor() && otherMd.parameters().size() == node.arguments().size();
}
if (matches) {
int targetIdx = findAssignedParameterIndex(otherMd, fieldName, context, visited);
if (targetIdx >= 0 && targetIdx < node.arguments().size()) {
Expression arg = (Expression) node.arguments().get(targetIdx);
String argName = extractVariableName(arg);
if (argName != null) {
for (int i = 0; i < constructorMd.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) constructorMd.parameters().get(i);
if (svd.getName().getIdentifier().equals(argName)) {
foundIdx[0] = i;
}
}
}
}
}
}
}
}
}
return super.visit(node);
}
});
return foundIdx[0];
}
private String extractVariableName(Expression expr) {
if (expr instanceof SimpleName sn) return sn.getIdentifier();
if (expr instanceof FieldAccess fa) return fa.getName().getIdentifier();
if (expr instanceof SuperFieldAccess sfa) return sfa.getName().getIdentifier();
if (expr instanceof CastExpression ce) return extractVariableName(ce.getExpression());
if (expr instanceof ParenthesizedExpression pe) return extractVariableName(pe.getExpression());
if (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) {
if (!mi.arguments().isEmpty()) return extractVariableName((Expression)mi.arguments().get(0));
}
return null;
}
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;
}
}

View File

@@ -0,0 +1,162 @@
package click.kamil.springstatemachineexporter.analysis.service;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@Slf4j
public class DynamicClasspathResolver {
public List<String> resolveClasspath(Path projectRoot) {
log.info("Attempting dynamic classpath resolution for project at {}", projectRoot);
if (projectRoot == null || !Files.exists(projectRoot)) {
return Collections.emptyList();
}
if (Files.exists(projectRoot.resolve("pom.xml"))) {
return resolveMaven(projectRoot);
} else if (Files.exists(projectRoot.resolve("build.gradle")) || Files.exists(projectRoot.resolve("build.gradle.kts"))) {
return resolveGradle(projectRoot);
}
log.info("No Maven or Gradle configuration found at {}. Proceeding without dynamic classpath.", projectRoot);
return Collections.emptyList();
}
protected List<String> resolveMaven(Path projectRoot) {
log.info("Maven project detected. Resolving dependencies...");
Path cpFile = null;
try {
cpFile = Files.createTempFile("state_machine_exporter_cp", ".txt");
String mvnCmd = getCommand(projectRoot, "mvnw", "mvn");
if (mvnCmd == null) {
log.warn("Maven executable not found.");
return Collections.emptyList();
}
ProcessBuilder pb = new ProcessBuilder(
mvnCmd,
"-q",
"dependency:build-classpath",
"-DincludeScope=compile",
"-Dmdep.outputFile=" + cpFile.toAbsolutePath().toString()
);
pb.directory(projectRoot.toFile());
runProcess(pb);
if (Files.exists(cpFile)) {
String cpString = Files.readString(cpFile).trim();
if (!cpString.isEmpty()) {
return Arrays.asList(cpString.split(File.pathSeparator));
}
}
} catch (Exception e) {
log.error("Failed to dynamically resolve Maven classpath", e);
} finally {
if (cpFile != null) {
try {
Files.deleteIfExists(cpFile);
} catch (IOException ignored) {}
}
}
return Collections.emptyList();
}
protected List<String> resolveGradle(Path projectRoot) {
log.info("Gradle project detected. Resolving dependencies...");
Path initScript = null;
try {
initScript = Files.createTempFile("state_machine_exporter_init", ".gradle");
String gradleCmd = getCommand(projectRoot, "gradlew", "gradle");
if (gradleCmd == null) {
log.warn("Gradle executable not found.");
return Collections.emptyList();
}
String scriptContent = """
allprojects {
task smePrintClasspath {
doLast {
def cp = []
if (configurations.findByName("compileClasspath")) {
cp = configurations.compileClasspath.files
}
println "SME_CLASSPATH_MARKER:" + cp.join(File.pathSeparator)
}
}
}
""";
Files.writeString(initScript, scriptContent);
ProcessBuilder pb = new ProcessBuilder(
gradleCmd,
"-q",
"-I", initScript.toAbsolutePath().toString(),
"smePrintClasspath"
);
pb.directory(projectRoot.toFile());
String output = runProcessAndGetOutput(pb);
for (String line : output.split("\\r?\\n")) {
if (line.startsWith("SME_CLASSPATH_MARKER:")) {
String cpString = line.substring("SME_CLASSPATH_MARKER:".length()).trim();
if (!cpString.isEmpty()) {
return Arrays.asList(cpString.split(File.pathSeparator));
}
}
}
} catch (Exception e) {
log.error("Failed to dynamically resolve Gradle classpath", e);
} finally {
if (initScript != null) {
try {
Files.deleteIfExists(initScript);
} catch (IOException ignored) {}
}
}
return Collections.emptyList();
}
private String getCommand(Path projectRoot, String wrapper, String systemBinary) {
Path wrapperUnix = projectRoot.resolve(wrapper);
Path wrapperWin = projectRoot.resolve(wrapper + ".bat");
boolean isWin = System.getProperty("os.name").toLowerCase().contains("win");
if (isWin && Files.exists(wrapperWin)) return wrapperWin.toAbsolutePath().toString();
if (!isWin && Files.exists(wrapperUnix)) {
// Need absolute path like ./gradlew
return wrapperUnix.toAbsolutePath().toString();
}
return systemBinary;
}
protected void runProcess(ProcessBuilder pb) throws IOException, InterruptedException {
Process p = pb.start();
p.waitFor();
}
protected String runProcessAndGetOutput(ProcessBuilder pb) throws IOException, InterruptedException {
Process p = pb.start();
StringBuilder out = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
out.append(line).append("\n");
}
}
p.waitFor();
return out.toString();
}
}

View File

@@ -0,0 +1,19 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
import java.util.List;
@RequiredArgsConstructor
public class EnrichmentService {
private final List<AnalysisEnricher> enrichers;
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
for (AnalysisEnricher enricher : enrichers) {
enricher.enrich(result, context, intelligence);
}
}
}

View File

@@ -0,0 +1,749 @@
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 = "unknown";
try {
if (cu.getJavaElement() != null) fileName = cu.getJavaElement().getElementName();
} catch (Exception e) {}
log.debug("Scanning for triggers in {}", fileName);
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
String methodName = node.getName().getIdentifier();
if ("sendEvent".equals(methodName) || "sendEvents".equals(methodName) ||
"sendEventCollect".equals(methodName) || "sendEventMono".equals(methodName)) {
processSendEvent(node, cu, triggers);
} else {
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String refMethod = emr.getName().getIdentifier();
if ("sendEvent".equals(refMethod) || "sendEvents".equals(refMethod) ||
"sendEventCollect".equals(refMethod) || "sendEventMono".equals(refMethod)) {
processSendEventMethodReference(node, emr, cu, triggers);
}
}
}
}
processHints(node, cu, triggers);
return super.visit(node);
}
});
return triggers;
}
private void processSendEventMethodReference(MethodInvocation node, ExpressionMethodReference emr, CompilationUnit cu, List<TriggerPoint> triggers) {
Expression receiver = node.getExpression();
String eventValue = extractEventFromReceiver(receiver);
if (eventValue == null) {
eventValue = receiver != null ? receiver.toString() : null;
}
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node);
if (type == null) return;
String sourceState = extractSourceState(node);
String[] smTypes = resolveStateMachineTypeArgumentsForExpression(emr.getExpression(), node);
boolean external = false;
if (receiver != null) {
AssignmentDag dag = new AssignmentDag(method);
external = dag.isExternal(receiver, new java.util.HashSet<>());
}
if (eventValue != null) {
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
triggers.add(TriggerPoint.builder()
.event(eventValue)
.sourceState(sourceState)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.stateTypeFqn(smTypes[0])
.eventTypeFqn(smTypes[1])
.external(external)
.constraint(constraint)
.build());
}
}
private String extractEventFromReceiver(Expression receiver) {
if (receiver == null) return null;
if (receiver instanceof MethodInvocation mi) {
String mName = mi.getName().getIdentifier();
if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
String resolved = constantResolver.resolve(arg, context);
return resolved != null ? resolved : arg.toString();
}
if (mi.getExpression() != null) {
return extractEventFromReceiver(mi.getExpression());
}
}
if (receiver instanceof SimpleName sn) {
return sn.getIdentifier();
}
return null;
}
private String[] resolveStateMachineTypeArgumentsForExpression(Expression receiver, MethodInvocation node) {
if (receiver == null) return new String[]{null, null};
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
ITypeBinding smBinding = findStateMachineSupertype(binding, new java.util.HashSet<>());
if (smBinding != null) {
ITypeBinding[] typeArgs = smBinding.getTypeArguments();
if (typeArgs.length >= 2) {
return new String[]{typeArgs[0].getQualifiedName(), typeArgs[1].getQualifiedName()};
}
}
}
Type declType = findReceiverTypeManually(receiver, node);
if (declType instanceof ParameterizedType pt) {
List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
CompilationUnit compilationUnit = (CompilationUnit) node.getRoot();
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), compilationUnit);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), compilationUnit);
return new String[]{stateType, eventType};
}
}
return new String[]{null, null};
}
private void processSendEvent(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> triggers) {
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, null);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
log.debug("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;
for (LibraryHint hint : hints) {
if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) {
String eventToUse = hint.getEvent();
if (eventToUse == null && hint.getEventArgumentIndex() != null) {
if (node.arguments().size() > hint.getEventArgumentIndex()) {
Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex());
eventToUse = argExpr.toString();
if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) {
eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()";
}
}
}
List<TriggerPoint> builtTriggers = buildTriggerPoints(node, cu, eventToUse);
if (builtTriggers != null) {
for (TriggerPoint trigger : builtTriggers) {
log.debug("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 List<TriggerPoint> buildTriggerPoints(MethodInvocation node, CompilationUnit cu, String forcedEvent) {
String eventValue = null;
if (forcedEvent != null) {
eventValue = context.resolveString(forcedEvent);
} else {
if (node.arguments().isEmpty()) return Collections.emptyList();
Expression eventExpr = (Expression) node.arguments().get(0);
// Try MessageBuilder extraction first
eventValue = extractEventFromMessageBuilder(eventExpr);
if (eventValue != null) {
eventValue = context.resolveString(eventValue);
} else {
eventValue = context.resolveExpression(eventExpr);
}
}
MethodDeclaration method = findEnclosingMethod(node);
TypeDeclaration type = findEnclosingType(node);
if (type == null) return Collections.emptyList();
String sourceState = extractSourceState(node);
String[] smTypes = resolveStateMachineTypeArguments(node);
boolean external = false;
if (!node.arguments().isEmpty()) {
Expression eventExpr = (Expression) node.arguments().get(0);
AssignmentDag dag = new AssignmentDag(method);
external = dag.isExternal(eventExpr, new java.util.HashSet<>());
}
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
List<TriggerPoint> results = new ArrayList<>();
if (eventValue != null && eventValue.startsWith("ENUM_SET:")) {
String[] parts = eventValue.substring(9).split(",");
for (String part : parts) {
if (!part.trim().isEmpty()) {
results.add(TriggerPoint.builder()
.event(part.trim())
.sourceState(sourceState)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.stateTypeFqn(smTypes[0])
.eventTypeFqn(smTypes[1])
.external(external)
.constraint(constraint)
.build());
}
}
} else {
results.add(TriggerPoint.builder()
.event(eventValue)
.sourceState(sourceState)
.className(context.getFqn(type))
.methodName(method != null ? method.getName().getIdentifier() : "initializer")
.sourceFile(context.getRelativePath(context.getFqn(type)))
.lineNumber(cu.getLineNumber(node.getStartPosition()))
.stateTypeFqn(smTypes[0])
.eventTypeFqn(smTypes[1])
.external(external)
.constraint(constraint)
.build());
}
return results;
}
private String extractSourceState(ASTNode node) {
ASTNode current = node;
while (current != null && !(current instanceof MethodDeclaration)) {
ASTNode parent = current.getParent();
// 1. Direct parent is IfStatement wrapper (e.g., if (state == X) { sm.sendEvent() })
if (parent instanceof IfStatement ifStmt) {
// Only consider if our current node is in the 'then' or 'else' part, not the condition
if (ifStmt.getExpression() != current) {
Expression expr = ifStmt.getExpression();
String state = extractStateFromExpression(expr);
if (state != null) return state;
}
}
// 2. Parent is a Block or SwitchStatement: perform sibling traversal
if (parent instanceof Block block) {
String state = extractStateFromSiblings(current, block.statements());
if (state != null) return state;
} else if (parent instanceof SwitchStatement switchStmt) {
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
}
current = parent;
}
return null;
}
private String extractStateFromSiblings(ASTNode currentNode, List<?> statements) {
int index = statements.indexOf(currentNode);
if (index <= 0) return null; // No previous siblings
// Walk backwards through siblings
for (int i = index - 1; i >= 0; i--) {
Object stmtObj = statements.get(i);
if (stmtObj instanceof SwitchCase switchCase) {
if (!switchCase.expressions().isEmpty()) {
return getSimpleNameString((Expression) switchCase.expressions().get(0));
}
} else if (stmtObj instanceof IfStatement ifStmt) {
// Guard clause detection: check if the 'then' block has a return/throw
if (isGuardClause(ifStmt.getThenStatement())) {
Expression expr = ifStmt.getExpression();
// If it's a guard clause, it usually checks `state != EXPECTED`, so the true state *is* EXPECTED
String state = extractStateFromExpression(expr);
if (state != null) return state;
}
}
}
return null;
}
private boolean isGuardClause(Statement thenStatement) {
if (thenStatement instanceof ReturnStatement || thenStatement instanceof ThrowStatement) {
return true;
}
if (thenStatement instanceof Block block) {
for (Object obj : block.statements()) {
if (obj instanceof ReturnStatement || obj instanceof ThrowStatement) {
return true;
}
}
}
return false;
}
private String extractStateFromExpression(Expression expr) {
if (expr instanceof InfixExpression infix) {
if (infix.getOperator() == InfixExpression.Operator.EQUALS || infix.getOperator() == InfixExpression.Operator.NOT_EQUALS) {
Expression left = infix.getLeftOperand();
Expression right = infix.getRightOperand();
// Ignore null checks entirely
if (left instanceof NullLiteral || right instanceof NullLiteral) {
return null;
}
// Usually one is a method call like getState() or a variable like `state`
// and the other is the constant enum like `OrderState.PENDING` or `"PENDING"`
// If one is a QualifiedName (enum constant) or StringLiteral, it's likely the state
if (left instanceof QualifiedName || left instanceof StringLiteral || left instanceof FieldAccess) {
return getSimpleNameString(left);
}
if (right instanceof QualifiedName || right instanceof StringLiteral || right instanceof FieldAccess) {
return getSimpleNameString(right);
}
// Fallback
return getSimpleNameString(right);
}
} else if (expr instanceof MethodInvocation mi) {
String methodName = mi.getName().getIdentifier();
if (("equals".equals(methodName) || "equalsIgnoreCase".equals(methodName)) && !mi.arguments().isEmpty()) {
Expression receiver = mi.getExpression();
Expression arg = (Expression) mi.arguments().get(0);
// If receiver is null (e.g., implicit this), fall back to arg
if (receiver == null) {
return getSimpleNameString(arg);
}
// Prioritize the one that looks like a constant (QualifiedName or StringLiteral)
if (receiver instanceof QualifiedName || receiver instanceof StringLiteral || receiver instanceof FieldAccess) {
return getSimpleNameString(receiver);
}
if (arg instanceof QualifiedName || arg instanceof StringLiteral || arg instanceof FieldAccess) {
return getSimpleNameString(arg);
}
// Fallback to receiver
return getSimpleNameString(receiver);
}
}
return null;
}
private String getSimpleNameString(Expression expr) {
if (expr instanceof QualifiedName qn) {
return qn.getName().getIdentifier();
} else if (expr instanceof FieldAccess fa) {
return fa.getName().getIdentifier();
} else if (expr instanceof StringLiteral sl) {
return sl.getLiteralValue();
}
return expr.toString();
}
private String extractEventFromMessageBuilder(Expression expr) {
if (expr instanceof CastExpression ce) {
return extractEventFromMessageBuilder(ce.getExpression());
}
if (expr instanceof SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
if (enclosingMethod != null) {
// Find variable declaration
final Expression[] initializer = new Expression[1];
enclosingMethod.accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
});
if (initializer[0] != null) {
return extractEventFromMessageBuilder(initializer[0]); // recursive
}
// If it has NO initializer, it might be a method parameter!
for (Object paramObj : enclosingMethod.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(varName)) {
return varName;
}
}
}
}
}
if (expr instanceof ClassInstanceCreation cic) {
return cic.getType().toString();
}
if (!(expr instanceof MethodInvocation mi)) return null;
// Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build()
MethodInvocation current = mi;
while (current != null) {
String name = current.getName().getIdentifier();
if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) {
Expression payloadExpr = (Expression) current.arguments().get(0);
// If it's Mono.just(msg), where msg is a variable
if ("just".equals(name)) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) return extracted;
}
String resolved = constantResolver.resolve(payloadExpr, context);
if (resolved != null) return resolved;
// If not a constant, it might be a parameter like 'event'
if (payloadExpr instanceof CastExpression ce) {
payloadExpr = ce.getExpression();
}
if (payloadExpr instanceof SimpleName sn) {
String extracted = extractEventFromMessageBuilder(payloadExpr);
if (extracted != null) {
return extracted;
}
return sn.getIdentifier(); // Fall back to returning the parameter name
}
return payloadExpr.toString();
} else if (current.arguments().isEmpty() && current.getExpression() instanceof SimpleName sn) {
// If the event is obtained by calling a method on a provider/supplier parameter,
// return the provider's name so CallGraphBuilder can trace the lambda argument
String traced = extractEventFromMessageBuilder(sn);
return traced != null ? traced : sn.getIdentifier();
}
Expression receiver = current.getExpression();
if (receiver instanceof MethodInvocation nextMi) {
current = nextMi;
} else {
current = null;
}
}
return null;
}
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;
}
private String[] resolveStateMachineTypeArguments(MethodInvocation node) {
Expression receiver = node.getExpression();
if (receiver == null) return new String[]{null, null};
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
ITypeBinding smBinding = findStateMachineSupertype(binding, new java.util.HashSet<>());
if (smBinding != null) {
ITypeBinding[] typeArgs = smBinding.getTypeArguments();
if (typeArgs.length >= 2) {
return new String[]{typeArgs[0].getQualifiedName(), typeArgs[1].getQualifiedName()};
}
}
}
// Fallback: search enclosing class/method for variable declaration
Type declType = findReceiverTypeManually(receiver, node);
if (declType instanceof ParameterizedType pt) {
List<?> typeArgs = pt.typeArguments();
if (typeArgs.size() >= 2) {
CompilationUnit cu = (CompilationUnit) node.getRoot();
String stateType = resolveTypeToFqn((Type) typeArgs.get(0), cu);
String eventType = resolveTypeToFqn((Type) typeArgs.get(1), cu);
return new String[]{stateType, eventType};
}
}
return new String[]{null, null};
}
private ITypeBinding findStateMachineSupertype(ITypeBinding binding, java.util.Set<String> visited) {
ITypeBinding current = binding;
while (current != null) {
String erasureName = current.getErasure().getQualifiedName();
if (erasureName != null && !visited.add(erasureName)) {
return null;
}
if ("org.springframework.statemachine.StateMachine".equals(erasureName)) {
return current;
}
for (ITypeBinding iface : current.getInterfaces()) {
ITypeBinding res = findStateMachineSupertype(iface, visited);
if (res != null) return res;
}
current = current.getSuperclass();
}
return null;
}
private Type findReceiverTypeManually(Expression receiver, MethodInvocation node) {
if (!(receiver instanceof SimpleName sn)) return null;
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(node);
if (enclosingMethod != null) {
// Check params
for (Object paramObj : enclosingMethod.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(varName)) {
return svd.getType();
}
}
}
// Check local variables
if (enclosingMethod.getBody() != null) {
Type[] found = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(varName)) {
found[0] = vds.getType();
}
}
}
return super.visit(vds);
}
});
if (found[0] != null) return found[0];
}
}
// Check fields
TypeDeclaration enclosingType = findEnclosingType(node);
if (enclosingType != null) {
for (FieldDeclaration fd : enclosingType.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(varName)) {
return fd.getType();
}
}
}
}
}
return null;
}
private String resolveTypeToFqn(Type type, CompilationUnit cu) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), cu);
} else {
simpleName = type.toString();
}
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
// Check package name
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
if (!packageName.isEmpty()) {
AbstractTypeDeclaration localTd = context.getAbstractTypeDeclaration(packageName + "." + simpleName);
if (localTd != null) return context.getFqn(localTd);
}
return simpleName;
}
private static class AssignmentDag {
final java.util.Map<String, List<Expression>> assignments = new java.util.HashMap<>();
final java.util.Set<SingleVariableDeclaration> parameters = new java.util.HashSet<>();
AssignmentDag(MethodDeclaration method) {
if (method == null) return;
for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration svd) {
parameters.add(svd);
}
}
if (method.getBody() != null) {
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment fragment) {
String varName = fragment.getName().getIdentifier();
if (fragment.getInitializer() != null) {
assignments.computeIfAbsent(varName, k -> new ArrayList<>()).add(fragment.getInitializer());
}
return super.visit(fragment);
}
@Override
public boolean visit(Assignment assignment) {
if (assignment.getLeftHandSide() instanceof SimpleName sn) {
String varName = sn.getIdentifier();
assignments.computeIfAbsent(varName, k -> new ArrayList<>()).add(assignment.getRightHandSide());
}
return super.visit(assignment);
}
});
}
}
boolean isExternal(Expression expr, java.util.Set<String> visited) {
if (expr == null) return false;
Expression peeled = expr;
while (peeled instanceof ParenthesizedExpression pe) {
peeled = pe.getExpression();
}
while (peeled instanceof CastExpression ce) {
peeled = ce.getExpression();
}
if (peeled instanceof SimpleName sn) {
String varName = sn.getIdentifier();
for (SingleVariableDeclaration svd : parameters) {
if (svd.getName().getIdentifier().equals(varName)) {
return isAnnotatedAsExternal(svd);
}
}
if (assignments.containsKey(varName)) {
if (!visited.add(varName)) {
return false;
}
List<Expression> defs = assignments.get(varName);
for (Expression def : defs) {
if (isExternal(def, visited)) {
return true;
}
}
}
} else if (peeled instanceof MethodInvocation mi) {
if (mi.getExpression() != null && isExternal(mi.getExpression(), visited)) {
return true;
}
for (Object arg : mi.arguments()) {
if (isExternal((Expression) arg, visited)) {
return true;
}
}
}
return false;
}
private boolean isAnnotatedAsExternal(SingleVariableDeclaration svd) {
for (Object modifier : svd.modifiers()) {
if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("PathVariable") || typeName.endsWith("RequestBody") || typeName.endsWith("RequestParam")
|| typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) {
return true;
}
}
}
return false;
}
}
}

View File

@@ -0,0 +1,640 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
@Slf4j
public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
protected String currentMethodFqn;
protected Map<String, List<CallEdge>> graph;
public HeuristicCallGraphEngine(CodebaseContext context) {
super(context);
}
protected Map<String, List<CallEdge>> buildCallGraph() {
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(refMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
String receiver = getReceiverString(node.getExpression());
CallEdge edge = new CallEdge(calledMethod, implicitArgs, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args, receiver);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
String receiver = "super";
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args, receiver);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
}
}
return super.visit(node);
}
});
}
return graph;
}
@Override
protected String postProcessResolvedArgument(org.eclipse.jdt.core.dom.Expression originalExpr, String resolvedValue) {
// Preserve getter calls on 'this' or 'super' for later resolution in trigger parameter tracing
// where we can substitute the actual receiver from the call edge
boolean isThisOrSuperGetter = (originalExpr instanceof org.eclipse.jdt.core.dom.MethodInvocation mi
&& (mi.getExpression() instanceof org.eclipse.jdt.core.dom.ThisExpression || mi.getExpression() instanceof org.eclipse.jdt.core.dom.SuperMethodInvocation)
&& mi.arguments().isEmpty()) || (originalExpr instanceof org.eclipse.jdt.core.dom.SuperMethodInvocation smi && smi.arguments().isEmpty());
if (isThisOrSuperGetter) {
return originalExpr.toString(); // Preserve "this.getTransitionType()" or "super.getEvent()" for later substitution
}
// Narrow resolution for "localVar.getX()" patterns where localVar is initialized
// from a ClassInstanceCreation (directly or via a factory method). This avoids
// the broad ENUM_SET fallback when the getter transforms one enum to another.
if (originalExpr instanceof org.eclipse.jdt.core.dom.MethodInvocation getterMi
&& getterMi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName instanceSn
&& getterMi.arguments().isEmpty()) {
String narrowed = tryNarrowGetterViaLocalVarChain(getterMi, instanceSn);
if (narrowed != null) {
return narrowed;
}
}
return resolvedValue;
}
protected List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) {
String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList();
List<String> allResolved = new ArrayList<>();
allResolved.add(baseCalled);
if (baseCalled.contains(".")) {
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
String definingClass = findDefiningClass(className, methodName);
if (definingClass != null && !definingClass.equals(className)) {
allResolved.set(0, definingClass + "." + methodName);
className = definingClass;
}
List<String> impls = context.getImplementations(className);
for (String impl : impls) {
allResolved.add(impl + "." + methodName);
}
}
return allResolved;
}
private String findDefiningClass(String className, String methodName) {
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) return null;
String resolvedMethodFqn = resolveMethodInTypeHierarchy(td, methodName);
if (resolvedMethodFqn != null) {
return resolvedMethodFqn.substring(0, resolvedMethodFqn.lastIndexOf('.'));
}
return null;
}
protected String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver instanceof MethodInvocation miReceiver) {
String returnType = resolveMethodInvocationReturnType(miReceiver);
if (returnType != null) {
return returnType + "." + methodName;
}
}
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInTypeHierarchy(td, methodName);
}
return null;
}
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
return binding.getQualifiedName() + "." + methodName;
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
}
if (receiver instanceof SimpleName sn) {
String fallbackTypeFqn = resolveReceiverTypeFallback(sn);
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
}
if (receiver instanceof QualifiedName qn) {
String fqn = qn.getFullyQualifiedName();
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td != null) {
return fqn + "." + methodName;
}
return fqn + "." + methodName;
}
return null;
}
protected String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
CompilationUnit cu = receiverNameNode.getRoot() instanceof CompilationUnit ? (CompilationUnit) receiverNameNode.getRoot() : null;
TypeDeclaration staticTd = context.getTypeDeclaration(varName, cu);
if (staticTd != null) {
return context.getFqn(staticTd);
}
TypeDeclaration fallbackStaticTd = context.getTypeDeclaration(varName);
if (fallbackStaticTd != null) {
return context.getFqn(fallbackStaticTd);
}
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class and its superclasses
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
TypeDeclaration currentType = enclosingType;
while (currentType != null) {
for (FieldDeclaration field : currentType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
String superclass = context.getSuperclassFqn(currentType);
currentType = superclass != null ? context.getTypeDeclaration(superclass) : null;
}
return null;
}
protected String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching if CodebaseContext doesn't know it (e.g., external library)
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
/**
* Attempts to narrow the resolution of {@code instanceVar.getterMethod()} when the
* instance variable is initialised from a {@link org.eclipse.jdt.core.dom.ClassInstanceCreation}
* (either directly or via a factory/mapper method).
*
* <p>This handles both:
* <ul>
* <li>Simple pass-through: {@code Wrapper(MyEnum e){this.e=e;} getE(){return e;}}</li>
* <li>Transformation: {@code getSmEvent(){return switch(dtoEnum){case A->SmEvents.X;…};}}</li>
* </ul>
*
* @return the resolved constant string, or {@code null} when narrowing is not possible
*/
private String tryNarrowGetterViaLocalVarChain(
MethodInvocation getterCall, SimpleName instanceSn) {
MethodDeclaration enclosing = findEnclosingMethod(getterCall);
if (enclosing == null || enclosing.getBody() == null) return null;
String varName = instanceSn.getIdentifier();
String getterName = getterCall.getName().getIdentifier();
// Find the declared type and initialiser of the local variable
String[] varTypeName = {null};
Expression[] initExpr = {null};
enclosing.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
varTypeName[0] = vds.getType().toString();
initExpr[0] = frag.getInitializer();
}
}
return super.visit(vds);
}
});
if (varTypeName[0] == null) return null;
// Obtain the ClassInstanceCreation that created the object
org.eclipse.jdt.core.dom.ClassInstanceCreation cic = null;
String receiverType = null;
if (initExpr[0] instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation directCic) {
cic = directCic;
} else if (initExpr[0] instanceof MethodInvocation factoryMi
&& (factoryMi.getExpression() instanceof SimpleName || factoryMi.getExpression() instanceof QualifiedName)) {
// e.g. "mapper.toMsg(dto)" or "com.example.Factory.toMsg()"
if (factoryMi.getExpression() instanceof SimpleName receiverSn) {
receiverType = resolveReceiverTypeFallback(receiverSn);
} else if (factoryMi.getExpression() instanceof QualifiedName qn) {
receiverType = qn.getFullyQualifiedName();
}
if (receiverType != null) {
cic = findReturnedCIC(receiverType, factoryMi.getName().getIdentifier(), context);
}
}
if (cic == null) {
// Support builder pattern: e.g. EventWrapper.builder().event(TransitionEnum.STATE_X).build()
if (initExpr[0] instanceof MethodInvocation initMi) {
String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
Expression currentMi = initMi;
while (currentMi instanceof MethodInvocation chainMi) {
String mName = chainMi.getName().getIdentifier();
if (mName.equalsIgnoreCase("set" + propName) || mName.equalsIgnoreCase(propName)) {
if (!chainMi.arguments().isEmpty()) {
return chainMi.arguments().get(0).toString();
}
}
currentMi = chainMi.getExpression();
}
}
return null;
}
// Resolve the variable's declared type to a TypeDeclaration
CompilationUnit cu = (CompilationUnit) getterCall.getRoot();
TypeDeclaration varTypeTd = context.getTypeDeclaration(varTypeName[0], cu);
if (varTypeTd == null) varTypeTd = context.getTypeDeclaration(varTypeName[0]);
if (varTypeTd == null) return null;
// Evaluate the getter body with field values substituted from the CIC's constructor args
java.util.Map<String, String> initialLocals = new java.util.HashMap<>();
if (initExpr[0] instanceof MethodInvocation factoryMi) {
MethodDeclaration md = context.findMethodDeclaration(context.getTypeDeclaration(receiverType != null ? receiverType : varTypeName[0]), factoryMi.getName().getIdentifier(), true);
if (md != null) {
for (int i = 0; i < md.parameters().size() && i < factoryMi.arguments().size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) md.parameters().get(i);
Expression arg = (Expression) factoryMi.arguments().get(i);
String resolvedVal = constantResolver.resolve(arg, context);
if (resolvedVal != null) {
initialLocals.put(param.getName().getIdentifier(), resolvedVal);
}
}
}
}
java.util.Map<String, String> fieldValues = constructorAnalyzer.buildFieldValuesFromCIC(varTypeTd, cic, context, this::evaluateMethodCallInConstructor, initialLocals);
if (fieldValues.isEmpty()) return null;
// Track setter calls on the variable between its initialization and the getter call
// to capture field updates like e.setType("NEW_VALUE")
trackSetterCallsBetween(enclosing.getBody(), varName, getterCall, fieldValues, context);
MethodDeclaration getter = context.findMethodDeclaration(varTypeTd, getterName, true);
if (getter == null || getter.getBody() == null) return null;
return constantResolver.evaluateMethodBodyWithLocals(
getter, fieldValues, context, new java.util.HashSet<>());
}
/**
* Scans the method body for setter calls or field assignments to the given variable
* that occur between the variable's initialization and the getter call.
* Updates fieldValues with any field values set via setters.
*/
private void trackSetterCallsBetween(org.eclipse.jdt.core.dom.Block methodBody, String varName,
org.eclipse.jdt.core.dom.MethodInvocation getterCall,
java.util.Map<String, String> fieldValues,
CodebaseContext context) {
java.util.List<org.eclipse.jdt.core.dom.ASTNode> statements = new java.util.ArrayList<>();
for (Object stmtObj : methodBody.statements()) {
statements.add((org.eclipse.jdt.core.dom.ASTNode) stmtObj);
}
int varDeclIndex = -1;
int getterCallIndex = -1;
for (int i = 0; i < statements.size(); i++) {
org.eclipse.jdt.core.dom.ASTNode stmt = statements.get(i);
// Find variable declaration
if (varDeclIndex == -1) {
if (stmt instanceof org.eclipse.jdt.core.dom.VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
if (fragObj instanceof org.eclipse.jdt.core.dom.VariableDeclarationFragment frag
&& frag.getName().getIdentifier().equals(varName)) {
varDeclIndex = i;
break;
}
}
}
}
// Find getter call
if (getterCallIndex == -1) {
if (stmt.toString().contains(getterCall.toString())) {
getterCallIndex = i;
break;
}
}
}
if (varDeclIndex >= 0 && getterCallIndex > varDeclIndex) {
// Scan statements between var declaration and getter call
for (int i = varDeclIndex + 1; i < getterCallIndex; i++) {
org.eclipse.jdt.core.dom.ASTNode stmt = statements.get(i);
if (stmt instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) {
org.eclipse.jdt.core.dom.Expression expr = es.getExpression();
// Check for setter calls: var.setField(value)
if (expr instanceof org.eclipse.jdt.core.dom.MethodInvocation mi) {
if (mi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn
&& sn.getIdentifier().equals(varName)) {
String methodName = mi.getName().getIdentifier();
// Check if it's a setter (setXxx or xxx)
if (methodName.startsWith("set") && methodName.length() > 3) {
String fieldName = Character.toLowerCase(methodName.charAt(3))
+ methodName.substring(4);
if (!mi.arguments().isEmpty()) {
org.eclipse.jdt.core.dom.Expression arg = (org.eclipse.jdt.core.dom.Expression) mi.arguments().get(0);
String val = constantResolver.resolve(arg, context);
if (val != null) {
fieldValues.put(fieldName, val);
fieldValues.put("this." + fieldName, val);
}
}
}
}
}
// Check for direct field assignments: var.field = value
else if (expr instanceof org.eclipse.jdt.core.dom.Assignment assignment) {
if (assignment.getLeftHandSide() instanceof org.eclipse.jdt.core.dom.FieldAccess fa) {
if (fa.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn
&& sn.getIdentifier().equals(varName)) {
String fieldName = fa.getName().getIdentifier();
org.eclipse.jdt.core.dom.Expression rhs = assignment.getRightHandSide();
String val = constantResolver.resolve(rhs, context);
if (val != null) {
fieldValues.put(fieldName, val);
fieldValues.put("this." + fieldName, val);
}
}
}
}
}
}
}
}
/**
* Extracts a string representation of the receiver expression from a MethodInvocation.
* Handles ThisExpression, SimpleName, and other expression types.
*/
protected String getReceiverString(Expression receiver) {
if (receiver == null) {
return null;
}
if (receiver instanceof ThisExpression) {
return "this";
}
if (receiver instanceof SuperMethodInvocation) {
return "super";
}
if (receiver instanceof SimpleName sn) {
return sn.getIdentifier();
}
return receiver.toString();
}
/**
* Finds the receiver expression used in the caller method when calling the target method.
* Walks the caller's AST to find a MethodInvocation with the target method name
* and returns the receiver string.
*/
protected String findCallerReceiver(String callerFqn, String targetMethodFqn) {
int lastDot = callerFqn.lastIndexOf('.');
if (lastDot < 0) return null;
String callerClass = callerFqn.substring(0, lastDot);
String callerMethod = callerFqn.substring(lastDot + 1);
int targetLastDot = targetMethodFqn.lastIndexOf('.');
if (targetLastDot < 0) return null;
String targetMethodName = targetMethodFqn.substring(targetLastDot + 1);
TypeDeclaration callerTd = context.getTypeDeclaration(callerClass);
if (callerTd == null) return null;
MethodDeclaration callerMd = context.findMethodDeclaration(callerTd, callerMethod, false);
if (callerMd == null || callerMd.getBody() == null) return null;
final String[] foundReceiver = {null};
callerMd.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (foundReceiver[0] != null) return false;
String called = resolveCalledMethod(node);
if (called != null && (called.equals(targetMethodFqn) || called.endsWith("." + targetMethodName))) {
foundReceiver[0] = getReceiverString(node.getExpression());
return false;
}
return super.visit(node);
}
});
return foundReceiver[0];
}
protected String resolveMethodInvocationReturnType(MethodInvocation mi) {
Expression receiver = mi.getExpression();
String receiverType = null;
if (receiver == null) {
TypeDeclaration td = findEnclosingType(mi);
if (td != null) {
receiverType = context.getFqn(td);
}
} else if (receiver instanceof SimpleName sn) {
receiverType = resolveReceiverTypeFallback(sn);
} else if (receiver instanceof FieldAccess fa) {
receiverType = resolveReceiverTypeFallback(fa.getName());
} else if (receiver instanceof MethodInvocation innerMi) {
receiverType = resolveMethodInvocationReturnType(innerMi);
}
if (receiverType == null) {
ITypeBinding binding = receiver != null ? receiver.resolveTypeBinding() : null;
if (binding != null) {
receiverType = binding.getErasure().getQualifiedName();
}
}
if (receiverType != null) {
if (receiverType.contains("<")) {
receiverType = receiverType.substring(0, receiverType.indexOf('<'));
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(receiverType);
if (td == null && receiverType.contains(".")) {
String simpleClass = receiverType.substring(receiverType.lastIndexOf('.') + 1);
td = context.getTypeDeclaration(simpleClass);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getReturnType2() != null) {
return resolveTypeToFqn(md.getReturnType2(), mi);
}
}
}
return null;
}
}

View File

@@ -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";
}
}

View File

@@ -0,0 +1,412 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
@Slf4j
public class JdtCallGraphEngine extends AbstractCallGraphEngine {
private final InjectionPointAnalyzer injectionAnalyzer;
private String currentMethodFqn;
private Map<String, List<CallEdge>> graph;
public JdtCallGraphEngine(CodebaseContext context, InjectionPointAnalyzer injectionAnalyzer) {
super(context);
this.injectionAnalyzer = injectionAnalyzer;
}
protected Map<String, List<CallEdge>> buildCallGraph() {
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
for (String calledMethod : calledMethods) {
CallEdge edge = new CallEdge(calledMethod, args);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
CallEdge edge = new CallEdge(refMethod, implicitArgs);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
CallEdge edge = new CallEdge(calledMethod, implicitArgs);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
CallEdge implEdge = new CallEdge(impl + "." + emr.getName().getIdentifier(), args);
implEdge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(implEdge);
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node);
CallEdge edge = new CallEdge(calledMethod, args);
edge.setConstraint(constraint);
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(edge);
}
}
}
}
return super.visit(node);
}
});
}
return graph;
}
@Override
protected String resolveArgument(org.eclipse.jdt.core.dom.Expression expr) {
if (expr == null) {
return "null";
}
// Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...))
org.eclipse.jdt.core.dom.Expression unwrappedBuilder = unwrapMessageBuilder(expr);
if (unwrappedBuilder != expr) {
expr = unwrappedBuilder;
}
// Trace variable to resolve local variable references
org.eclipse.jdt.core.dom.Expression tracedExpr = traceVariable(expr);
if (tracedExpr instanceof org.eclipse.jdt.core.dom.QualifiedName
|| tracedExpr instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation
|| tracedExpr instanceof org.eclipse.jdt.core.dom.StringLiteral
|| tracedExpr instanceof org.eclipse.jdt.core.dom.NumberLiteral) {
expr = tracedExpr; // Only accept trace if it resolves to a pure constant/primitive/constructor
}
// Delegate to base class for lambda, method reference, and constructor unwrapping
return super.resolveArgument(expr);
}
private List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) {
String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList();
List<String> allResolved = new ArrayList<>();
allResolved.add(baseCalled);
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;
}
protected String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver instanceof MethodInvocation miReceiver) {
String returnType = resolveMethodInvocationReturnType(miReceiver);
if (returnType != null) {
return returnType + "." + methodName;
}
}
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInType(td, methodName);
}
return null;
}
if (injectionAnalyzer != null) {
IBinding nameBinding = null;
if (receiver instanceof SimpleName sn) {
nameBinding = sn.resolveBinding();
} else if (receiver instanceof FieldAccess fa) {
nameBinding = fa.resolveFieldBinding();
}
if (nameBinding instanceof IVariableBinding varBinding) {
System.out.println("CALLGRAPH RESOLVING BEAN FOR BINDING: " + varBinding.getName() + " calling " + methodName);
String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding);
if (concreteFqn != null) {
System.out.println(" -> RESOLVED CONCRETE FQN: " + concreteFqn);
return concreteFqn + "." + methodName;
} else {
System.out.println(" -> RESOLVE FAILED, RETURNED NULL");
}
} else {
System.out.println("CALLGRAPH NAME BINDING IS NOT VARIABLE: " + nameBinding + " calling " + methodName);
}
}
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
String typeName = null;
try {
if (binding.getErasure() != null) {
typeName = binding.getErasure().getQualifiedName();
if (typeName == null || typeName.isEmpty()) {
typeName = binding.getErasure().getName();
}
}
} catch (Exception e) {
// Ignore JDT internal exceptions
}
if (typeName == null || typeName.isEmpty()) {
typeName = binding.getQualifiedName();
if (typeName == null || typeName.isEmpty()) {
typeName = binding.getName();
}
if (typeName != null && typeName.contains("<")) {
typeName = typeName.replaceAll("<.*>", "");
}
}
if (typeName != null && !typeName.isEmpty()) {
return typeName + "." + methodName;
}
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
}
if (receiver instanceof SimpleName sn) {
String fallbackTypeFqn = resolveReceiverTypeFallback(sn);
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
}
return null;
}
private String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class and its superclasses
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
TypeDeclaration currentType = enclosingType;
while (currentType != null) {
for (FieldDeclaration field : currentType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
String superclass = context.getSuperclassFqn(currentType);
currentType = superclass != null ? context.getTypeDeclaration(superclass) : null;
}
return null;
}
private String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching if CodebaseContext doesn't know it (e.g., external library)
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
private Expression unwrapMessageBuilder(Expression expr) {
if (expr instanceof MethodInvocation mi) {
MethodInvocation current = mi;
while (current != null) {
String name = current.getName().getIdentifier();
if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) {
return (Expression) current.arguments().get(0);
}
Expression receiver = current.getExpression();
if (receiver instanceof MethodInvocation nextMi) {
current = nextMi;
} else {
current = null;
}
}
}
return expr;
}
protected String resolveMethodInvocationReturnType(MethodInvocation mi) {
Expression receiver = mi.getExpression();
String receiverType = null;
if (receiver == null) {
TypeDeclaration td = findEnclosingType(mi);
if (td != null) {
receiverType = context.getFqn(td);
}
} else if (receiver instanceof SimpleName sn) {
receiverType = resolveReceiverTypeFallback(sn);
} else if (receiver instanceof FieldAccess fa) {
receiverType = resolveReceiverTypeFallback(fa.getName());
} else if (receiver instanceof MethodInvocation innerMi) {
receiverType = resolveMethodInvocationReturnType(innerMi);
}
if (receiverType == null) {
ITypeBinding binding = receiver != null ? receiver.resolveTypeBinding() : null;
if (binding != null) {
receiverType = binding.getErasure().getQualifiedName();
}
}
if (receiverType != null) {
if (receiverType.contains("<")) {
receiverType = receiverType.substring(0, receiverType.indexOf('<'));
}
String methodName = mi.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(receiverType);
if (td == null && receiverType.contains(".")) {
String simpleClass = receiverType.substring(receiverType.lastIndexOf('.') + 1);
td = context.getTypeDeclaration(simpleClass);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getReturnType2() != null) {
return resolveTypeToFqn(md.getReturnType2(), mi);
}
}
}
return null;
}
}

View File

@@ -0,0 +1,93 @@
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.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
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.ArrayList;
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 MessagingDetector messagingDetector;
private final CallGraphEngine callGraphEngine;
private final LifecycleDetector lifecycleDetector;
private final InterceptorDetector interceptorDetector;
private final SpringComponentDetector componentDetector;
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.messagingDetector = new MessagingDetector(context);
SpringBeanRegistry registry = new SpringBeanRegistry();
SpringContextScanner scanner = new SpringContextScanner(registry);
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(scanner);
}
SpringDependencyResolver dependencyResolver = new SpringDependencyResolver(registry);
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver);
this.callGraphEngine = new JdtCallGraphEngine(context, injectionAnalyzer);
this.lifecycleDetector = new LifecycleDetector(context);
this.interceptorDetector = new InterceptorDetector(context);
this.componentDetector = new SpringComponentDetector(context);
}
@Override
public List<TriggerPoint> findTriggerPoints() {
List<TriggerPoint> allTriggers = new 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 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(messagingDetector.detect(cu));
allEntryPoints.addAll(interceptorDetector.detect(cu));
allEntryPoints.addAll(componentDetector.detect(cu));
}
log.info("Found {} entry points (including interceptors and listeners) 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 callGraphEngine.findChains(entryPoints, triggers);
}
@Override
public Map<String, Map<String, String>> resolveProperties() {
return context.getProperties();
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,129 @@
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 MessagingDetector {
private final CodebaseContext context;
public List<EntryPoint> detect(CompilationUnit cu) {
List<EntryPoint> entryPoints = new ArrayList<>();
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodDeclaration node) {
processMethod(node, entryPoints);
return super.visit(node);
}
});
return entryPoints;
}
private void processMethod(MethodDeclaration method, List<EntryPoint> entryPoints) {
for (Object modifier : method.modifiers()) {
if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName();
EntryPoint.Type type = null;
String destination = "";
if (typeName.endsWith("JmsListener")) {
type = EntryPoint.Type.JMS;
destination = resolveValue(annotation, "destination");
} else if (typeName.endsWith("RabbitListener")) {
type = EntryPoint.Type.RABBIT;
destination = resolveValue(annotation, "queues");
} else if (typeName.endsWith("SqsListener")) {
type = EntryPoint.Type.SQS;
destination = resolveValue(annotation, "value");
if ("unknown".equals(destination)) {
destination = resolveValue(annotation, "queueNames");
}
} else if (typeName.endsWith("SnsListener")) {
type = EntryPoint.Type.SNS;
destination = resolveValue(annotation, "value");
if ("unknown".equals(destination)) {
destination = resolveValue(annotation, "topicNames");
}
} else if (typeName.endsWith("KafkaListener")) {
type = EntryPoint.Type.KAFKA;
destination = resolveValue(annotation, "topics");
}
if (type != null) {
Map<String, String> metadata = new HashMap<>();
metadata.put("destination", destination);
String protocolName = type.name();
if (typeName.endsWith("SqsListener")) protocolName = "SQS";
if (typeName.endsWith("SnsListener")) protocolName = "SNS";
metadata.put("protocol", protocolName);
entryPoints.add(EntryPoint.builder()
.type(type)
.name(protocolName + ": " + destination)
.className(context.getFqn((TypeDeclaration) method.getParent()))
.methodName(method.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn((TypeDeclaration) method.getParent())))
.metadata(metadata)
.parameters(extractParameters(method))
.build());
}
}
}
}
private String resolveValue(Annotation annotation, String memberName) {
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 (memberName.equals(pair.getName().getIdentifier())) {
value = pair.getValue();
break;
}
}
}
if (value != null) {
if (value instanceof ArrayInitializer ai) {
List<String> values = new ArrayList<>();
for (Object o : ai.expressions()) {
values.add(context.resolveExpression((Expression) o));
}
return String.join(", ", values);
}
return context.resolveExpression(value);
}
return "unknown";
}
private List<EntryPoint.Parameter> extractParameters(MethodDeclaration method) {
List<EntryPoint.Parameter> parameters = new ArrayList<>();
for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration param) {
List<String> annotations = new ArrayList<>();
for (Object mod : param.modifiers()) {
if (mod instanceof Annotation ann) {
annotations.add(ann.getTypeName().getFullyQualifiedName());
}
}
parameters.add(EntryPoint.Parameter.builder()
.name(param.getName().getIdentifier())
.type(param.getType().toString())
.annotations(annotations)
.build());
}
}
return parameters;
}
}

View File

@@ -0,0 +1,117 @@
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.ASTVisitor;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RequiredArgsConstructor
public class SpringComponentDetector {
private final CodebaseContext context;
public List<EntryPoint> detect(CompilationUnit cu) {
List<EntryPoint> entryPoints = new ArrayList<>();
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodDeclaration node) {
if (!(node.getParent() instanceof TypeDeclaration)) {
return super.visit(node);
}
TypeDeclaration parentTd = (TypeDeclaration) node.getParent();
for (Object modifier : node.modifiers()) {
if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("Scheduled")) {
Map<String, String> meta = new HashMap<>();
String cron = extractAnnotationValue(annotation, "cron");
if (!cron.isEmpty())
meta.put("cron", cron);
String fixedRate = extractAnnotationValue(annotation, "fixedRate");
if (!fixedRate.isEmpty())
meta.put("fixedRate", fixedRate);
String fixedDelay = extractAnnotationValue(annotation, "fixedDelay");
if (!fixedDelay.isEmpty())
meta.put("fixedDelay", fixedDelay);
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("@Scheduled: " + node.getName().getIdentifier())
.className(context.getFqn(parentTd))
.methodName(node.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(parentTd)))
.metadata(meta)
.build());
} else if (typeName.endsWith("EventListener")) {
Map<String, String> meta = new HashMap<>();
String classes = extractAnnotationValue(annotation, "classes");
if (!classes.isEmpty())
meta.put("classes", classes);
String condition = extractAnnotationValue(annotation, "condition");
if (!condition.isEmpty())
meta.put("condition", condition);
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("@EventListener: " + node.getName().getIdentifier())
.className(context.getFqn(parentTd))
.methodName(node.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(parentTd)))
.metadata(meta)
.build());
} else if (typeName.endsWith("Around") || typeName.endsWith("Before") || typeName.endsWith("After") || typeName.endsWith("AfterReturning") || typeName.endsWith("AfterThrowing")) {
Map<String, String> meta = new HashMap<>();
String value = extractAnnotationValue(annotation, "value");
if (!value.isEmpty())
meta.put("pointcut", value);
else {
String pointcut = extractAnnotationValue(annotation, "pointcut");
if (!pointcut.isEmpty())
meta.put("pointcut", pointcut);
}
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.CUSTOM)
.name("@" + annotation.getTypeName().getFullyQualifiedName() + ": " + node.getName().getIdentifier())
.className(context.getFqn(parentTd))
.methodName(node.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(parentTd)))
.metadata(meta)
.build());
}
}
}
return super.visit(node);
}
});
return entryPoints;
}
private String extractAnnotationValue(Annotation annotation, String memberName) {
if (annotation instanceof SingleMemberAnnotation sma) {
if ("value".equals(memberName)) {
return sma.getValue().toString();
}
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (pair.getName().getIdentifier().equals(memberName)) {
return pair.getValue().toString();
}
}
}
return "";
}
}

View File

@@ -0,0 +1,256 @@
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);
}
// Support WebFlux RouterFunction beans
processRouterFunctions(node, entryPoints);
return super.visit(node);
}
});
return entryPoints;
}
private void processRouterFunctions(TypeDeclaration td, List<EntryPoint> entryPoints) {
for (MethodDeclaration method : td.getMethods()) {
if (isBeanMethod(method) && method.getReturnType2() != null && method.getReturnType2().toString().contains("RouterFunction")) {
method.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation call) {
String name = call.getName().getIdentifier();
String verb = getHttpVerb(name);
if (verb != null && !call.arguments().isEmpty()) {
Expression pathArg = (Expression) call.arguments().get(0);
String path = context.resolveExpression(pathArg);
entryPoints.add(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name(verb + " " + path)
.className(context.getFqn(td))
.methodName(method.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(td)))
.metadata(Map.of("path", path, "verb", verb, "style", "WebFlux Functional"))
.build());
}
return super.visit(call);
}
});
}
}
}
private boolean isBeanMethod(MethodDeclaration method) {
for (Object mod : method.modifiers()) {
if (mod instanceof Annotation ann && ann.getTypeName().getFullyQualifiedName().endsWith("Bean")) {
return true;
}
}
return false;
}
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") || name.endsWith("Path") || name.endsWith("RequestMapping")) {
return true;
}
}
}
return false;
}
private void processController(TypeDeclaration controller, List<EntryPoint> entryPoints) {
String basePath = getMappingPathHierarchical(controller);
if (basePath.isEmpty()) {
basePath = getJaxRsPath(controller);
}
for (MethodDeclaration method : controller.getMethods()) {
EntryPoint ep = processMethodHierarchical(method, basePath, controller);
if (ep != null) {
entryPoints.add(ep);
}
}
}
private String getMappingPathHierarchical(TypeDeclaration td) {
String path = getMappingPath(td);
if (!path.isEmpty()) return path;
for (Object interfaceType : td.superInterfaceTypes()) {
TypeDeclaration itd = context.getTypeDeclaration(interfaceType.toString());
if (itd != null) {
path = getMappingPath(itd);
if (!path.isEmpty()) return path;
}
}
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) {
EntryPoint ep = processMethod(method, basePath, controller);
if (ep != null) return ep;
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);
if (methodPath.isEmpty()) {
methodPath = getJaxRsPath(method);
}
String fullPath = combinePaths(basePath, methodPath);
Map<String, String> metadata = new HashMap<>();
metadata.put("path", fullPath);
metadata.put("verb", verb);
List<EntryPoint.Parameter> parameters = extractParameters(method);
return EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name(verb + " " + fullPath)
.className(context.getFqn(controller))
.methodName(method.getName().getIdentifier())
.sourceFile(context.getRelativePath(context.getFqn(controller)))
.metadata(metadata)
.parameters(parameters)
.build();
}
}
}
return null;
}
private String getJaxRsPath(BodyDeclaration node) {
for (Object mod : node.modifiers()) {
if (mod instanceof Annotation ann && ann.getTypeName().getFullyQualifiedName().endsWith("Path")) {
return getMappingPath(ann);
}
}
return "";
}
private String getHttpVerb(String annotationName) {
if (annotationName.endsWith("PostMapping") || annotationName.endsWith("POST")) return "POST";
if (annotationName.endsWith("GetMapping") || annotationName.endsWith("GET")) return "GET";
if (annotationName.endsWith("PutMapping") || annotationName.endsWith("PUT")) return "PUT";
if (annotationName.endsWith("DeleteMapping") || annotationName.endsWith("DELETE")) return "DELETE";
if (annotationName.endsWith("PatchMapping") || annotationName.endsWith("PATCH")) 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) {
String name = annotation.getTypeName().getFullyQualifiedName();
if (name.endsWith("RequestMapping") || name.endsWith("Path")) {
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) {
return context.resolveExpression(value);
}
return "";
}
private List<EntryPoint.Parameter> extractParameters(MethodDeclaration method) {
List<EntryPoint.Parameter> parameters = new ArrayList<>();
for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration param) {
List<String> annotations = new ArrayList<>();
for (Object mod : param.modifiers()) {
if (mod instanceof Annotation ann) {
String name = ann.getTypeName().getFullyQualifiedName();
if (name.endsWith("PathParam")) name = "PathVariable";
if (name.endsWith("QueryParam")) name = "RequestParam";
annotations.add(name);
}
}
parameters.add(EntryPoint.Parameter.builder()
.name(param.getName().getIdentifier())
.type(param.getType().toString())
.annotations(annotations)
.build());
}
}
return parameters;
}
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;
}
}

View File

@@ -0,0 +1,133 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class TypeResolver {
private final CodebaseContext context;
public TypeResolver(CodebaseContext context) {
this.context = context;
}
public int getParameterIndex(String methodFqn, String paramName) {
if (methodFqn == null || !methodFqn.contains(".")) return -1;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
for (int i = 0; i < md.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) md.parameters().get(i);
if (svd.getName().getIdentifier().equals(paramName)) {
return i;
}
}
}
}
return -1;
}
public String getParameterType(String methodFqn, int index) {
if (methodFqn == null || !methodFqn.contains(".") || index < 0) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && index < md.parameters().size()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) md.parameters().get(index);
return svd.getType().toString();
}
}
return null;
}
public boolean isTypeCompatible(String actualType, String expectedType) {
if (actualType == null || expectedType == null) return true;
if (expectedType.equals("Object") || expectedType.equals("java.lang.Object")) return true;
if (actualType.equals("Object") || actualType.equals("java.lang.Object")) return true;
if (expectedType.length() == 1 && Character.isUpperCase(expectedType.charAt(0))) return true;
if (actualType.length() == 1 && Character.isUpperCase(actualType.charAt(0))) return true;
String originalExpectedType = expectedType;
if (actualType.contains("<")) {
String rawType = actualType.substring(0, actualType.indexOf('<'));
if (rawType.equals("List") || rawType.equals("Set") || rawType.equals("Collection") || rawType.equals("Iterable") || rawType.endsWith("List") || rawType.endsWith("Set") || rawType.endsWith("Collection")) {
int start = actualType.indexOf('<');
int end = actualType.lastIndexOf('>');
if (start >= 0 && end > start) {
actualType = actualType.substring(start + 1, end);
}
}
}
if (expectedType.contains("<")) {
String rawType = expectedType.substring(0, expectedType.indexOf('<'));
if (rawType.equals("List") || rawType.equals("Set") || rawType.equals("Collection") || rawType.equals("Iterable") || rawType.endsWith("List") || rawType.endsWith("Set") || rawType.endsWith("Collection")) {
int start = expectedType.indexOf('<');
int end = expectedType.lastIndexOf('>');
if (start >= 0 && end > start) {
expectedType = expectedType.substring(start + 1, end);
}
}
}
if (actualType.contains("<")) {
actualType = actualType.substring(0, actualType.indexOf('<'));
}
if (expectedType.contains("<")) {
expectedType = expectedType.substring(0, expectedType.indexOf('<'));
}
if (actualType.equals(expectedType)) return true;
if (actualType.endsWith("." + expectedType) || expectedType.endsWith("." + actualType)) return true;
AbstractTypeDeclaration td = context.getAbstractTypeDeclaration(actualType);
if (td != null) {
if (td instanceof TypeDeclaration typeDecl) {
String superFqn = context.getSuperclassFqn(typeDecl);
while (superFqn != null) {
if (superFqn.contains("<")) {
superFqn = superFqn.substring(0, superFqn.indexOf('<'));
}
if (superFqn.equals(expectedType) || superFqn.endsWith("." + expectedType) || expectedType.endsWith("." + superFqn)) return true;
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
superFqn = superTd != null ? context.getSuperclassFqn(superTd) : null;
}
}
List interfaces = java.util.Collections.emptyList();
if (td instanceof TypeDeclaration typeDecl) {
interfaces = typeDecl.superInterfaceTypes();
} else if (td instanceof EnumDeclaration enumDecl) {
interfaces = enumDecl.superInterfaceTypes();
} else if (td instanceof RecordDeclaration recordDecl) {
interfaces = recordDecl.superInterfaceTypes();
}
for (Object interfaceType : interfaces) {
String itStr = interfaceType.toString();
if (itStr.contains("<")) {
itStr = itStr.substring(0, itStr.indexOf('<'));
}
if (itStr.equals(expectedType) || itStr.endsWith("." + expectedType) || expectedType.endsWith("." + itStr)) return true;
}
}
if (originalExpectedType.contains("<")) {
int start = originalExpectedType.indexOf('<');
int end = originalExpectedType.lastIndexOf('>');
if (start >= 0 && end > start) {
String sub = originalExpectedType.substring(start + 1, end);
for (String part : sub.split(",")) {
String typeArg = part.trim();
if (isTypeCompatible(actualType, typeArg)) {
return true;
}
}
}
}
return false;
}
}

View File

@@ -0,0 +1,651 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class VariableTracer {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private ConstantExtractor constantExtractor;
private final click.kamil.springstatemachineexporter.ast.common.DataFlowModel dataFlowModel;
public VariableTracer(CodebaseContext context, ConstantResolver constantResolver) {
this.context = context;
this.constantResolver = constantResolver;
this.dataFlowModel = new click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel(context);
}
public void setConstantExtractor(ConstantExtractor constantExtractor) {
this.constantExtractor = constantExtractor;
}
public Expression traceVariable(Expression expr) {
List<Expression> all = traceVariableAll(expr);
return all.isEmpty() ? expr : all.get(all.size() - 1);
}
private Expression peelExpression(Expression expr) {
while (expr instanceof ParenthesizedExpression pe) {
expr = pe.getExpression();
}
if (expr instanceof CastExpression ce) {
return peelExpression(ce.getExpression());
}
return expr;
}
public List<Expression> traceVariableAll(Expression expr) {
return dataFlowModel.getReachingDefinitions(expr);
}
public List<Expression> traceVariableAll(Expression expr, Set<String> visitedVariables) {
return dataFlowModel.getReachingDefinitions(expr);
}
public Expression traceLocalSetter(String methodFqn, String varName, String getterName) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
final Expression[] setterArg = new Expression[1];
String propName = getterName.startsWith("get") ? getterName.substring(3) : getterName;
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (node.getName().getIdentifier().equalsIgnoreCase("set" + propName) || node.getName().getIdentifier().equalsIgnoreCase(propName)) {
if (node.getExpression() instanceof SimpleName sn && sn.getIdentifier().equals(varName)) {
if (!node.arguments().isEmpty()) {
setterArg[0] = (Expression) node.arguments().get(0);
}
}
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof FieldAccess fa) {
if (fa.getExpression() instanceof SimpleName faSn && faSn.getIdentifier().equals(varName)) {
if (fa.getName().getIdentifier().equalsIgnoreCase(propName)) {
setterArg[0] = node.getRightHandSide();
}
}
} else if (node.getLeftHandSide() instanceof QualifiedName qqn) {
if (qqn.getQualifier().getFullyQualifiedName().equals(varName)) {
if (qqn.getName().getIdentifier().equalsIgnoreCase(propName)) {
setterArg[0] = node.getRightHandSide();
}
}
}
return super.visit(node);
}
});
return setterArg[0];
}
}
return null;
}
private String getFieldType(TypeDeclaration td, String fieldName, CodebaseContext context, java.util.Set<String> visited) {
if (td == null) return null;
String typeFqn = context.getFqn(td);
if (!visited.add(typeFqn)) return null;
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName)) {
return fd.getType().toString();
}
}
}
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
if (superTd != null) {
return getFieldType(superTd, fieldName, context, visited);
}
}
return null;
}
public String getVariableDeclaredType(String methodFqn, String cleanFieldName) {
if (methodFqn == null) return null;
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot > 0) {
String fqn = methodFqn.substring(0, lastDot);
String methodName = methodFqn.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, false);
if (md != null) {
for (Object pObj : md.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) pObj;
if (svd.getName().getIdentifier().equals(cleanFieldName)) {
return svd.getType().toString();
}
}
final String[] foundType = new String[1];
if (md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(cleanFieldName)) {
foundType[0] = node.getType().toString();
}
}
return super.visit(node);
}
@Override
public boolean visit(LambdaExpression node) {
for (Object paramObj : node.parameters()) {
VariableDeclaration param = (VariableDeclaration) paramObj;
if (param.getName().getIdentifier().equals(cleanFieldName)) {
if (param instanceof SingleVariableDeclaration svd && svd.getType() != null) {
foundType[0] = svd.getType().toString();
return false;
}
ASTNode parent = node.getParent();
if (parent instanceof MethodInvocation mi) {
Expression caller = mi.getExpression();
boolean hasMap = false;
while (caller instanceof MethodInvocation chainMi) {
String mName = chainMi.getName().getIdentifier();
if (mName.equals("map") || mName.equals("flatMap")) {
hasMap = true;
if (chainMi.arguments().size() == 1 && chainMi.arguments().get(0) instanceof LambdaExpression lambda) {
if (lambda.getBody() instanceof MethodInvocation mapMi) {
Expression mapCaller = mapMi.getExpression();
if (mapCaller instanceof SimpleName mapSn) {
String mapCallerType = getVariableDeclaredType(methodFqn, mapSn.getIdentifier());
if (mapCallerType != null) {
TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
TypeDeclaration td = context.getTypeDeclaration(mapCallerType, cu);
if (td != null) {
MethodDeclaration mapMd = context.findMethodDeclaration(td, mapMi.getName().getIdentifier(), true);
if (mapMd != null && mapMd.getReturnType2() != null) {
foundType[0] = mapMd.getReturnType2().toString();
return false;
}
}
}
}
}
}
}
if (mName.equals("stream") || mName.equals("filter") || mName.equals("map") ||
mName.equals("flatMap") || mName.equals("peek") || mName.equals("distinct") ||
mName.equals("sorted") || mName.equals("limit") || mName.equals("skip")) {
caller = chainMi.getExpression();
} else {
break;
}
}
if (!hasMap) {
if (caller instanceof SimpleName callerSn) {
String callerName = callerSn.getIdentifier();
if (!callerName.equals(cleanFieldName)) {
String callerType = getVariableDeclaredType(methodFqn, callerName);
if (callerType != null && callerType.contains("<")) {
int start = callerType.indexOf('<');
int end = callerType.lastIndexOf('>');
if (start >= 0 && end > start) {
foundType[0] = callerType.substring(start + 1, end);
return false;
}
}
}
} else if (caller instanceof FieldAccess fa) {
String callerName = fa.getName().getIdentifier();
if (!callerName.equals(cleanFieldName)) {
String callerType = getVariableDeclaredType(methodFqn, callerName);
if (callerType != null && callerType.contains("<")) {
int start = callerType.indexOf('<');
int end = callerType.lastIndexOf('>');
if (start >= 0 && end > start) {
foundType[0] = callerType.substring(start + 1, end);
return false;
}
}
}
}
}
}
}
}
return super.visit(node);
}
});
}
if (foundType[0] != null) return foundType[0];
}
// Fallback to fields including superclasses
String fieldType = getFieldType(td, cleanFieldName, context, new java.util.HashSet<>());
if (fieldType != null) return fieldType;
}
}
// Final fallback: ask constantExtractor to resolve it via method return constant
if (constantExtractor != null && methodFqn.contains(".")) {
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
CompilationUnit cu = td != null && td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
List<String> values = constantExtractor.resolveMethodReturnConstant(cleanFieldName, methodName, 0, new HashSet<>(), cu);
if (values != null && !values.isEmpty()) {
// If it resolves to some class constant, we could infer type or return a fallback
return null;
}
}
return null;
}
public String traceLocalVariable(String methodFqn, String varName) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
List<Expression> initializers = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializers.add(peelExpression(node.getInitializer()));
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializers.add(peelExpression(node.getRightHandSide()));
}
return super.visit(node);
}
});
if (!initializers.isEmpty()) {
List<String> stringified = new ArrayList<>();
for (Expression expr : initializers) {
Expression traced = traceVariable(expr);
if (traced instanceof MethodInvocation mi) {
Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn);
stringified.add(innerMost.toString());
} else if (traced instanceof ArrayInitializer) {
stringified.add("new Object[]" + traced.toString());
} else {
stringified.add(traced.toString());
}
}
if (stringified.size() == 1) return stringified.get(0);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < stringified.size() - 1; i++) {
sb.append("true ? ").append(stringified.get(i)).append(" : ");
}
sb.append(stringified.get(stringified.size() - 1));
return sb.toString();
}
}
// If local variable not found, check field assignments
String cleanFieldName = varName.startsWith("this.") ? varName.substring(5) : varName;
final Expression[] fieldInitializer = new Expression[1];
ASTVisitor fieldVisitor = new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(cleanFieldName) && node.getInitializer() != null) {
fieldInitializer[0] = node.getInitializer();
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
Expression left = node.getLeftHandSide();
if ((left instanceof SimpleName && ((SimpleName) left).getIdentifier().equals(cleanFieldName)) ||
(left instanceof FieldAccess && ((FieldAccess) left).getName().getIdentifier().equals(cleanFieldName))) {
fieldInitializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
};
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(cleanFieldName) && frag.getInitializer() != null) {
fieldInitializer[0] = frag.getInitializer();
}
}
}
if (fieldInitializer[0] != null) return fieldInitializer[0].toString();
for (MethodDeclaration classMd : td.getMethods()) {
if (classMd.isConstructor() && classMd.getBody() != null) {
classMd.getBody().accept(fieldVisitor);
}
}
if (fieldInitializer[0] != null) return fieldInitializer[0].toString();
for (MethodDeclaration classMd : td.getMethods()) {
if (!classMd.isConstructor() && classMd.getBody() != null) {
classMd.getBody().accept(fieldVisitor);
}
}
if (fieldInitializer[0] != null) return fieldInitializer[0].toString();
}
return null;
}
public String traceLocalVariable(String methodFqn, String varName, Map<String, String> parameterValues) {
String result = traceLocalVariable(methodFqn, varName);
if (result != null && parameterValues != null && !parameterValues.isEmpty()) {
TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.')));
if (td != null) {
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
final ASTNode[] switchNode = new ASTNode[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
Expression init = node.getInitializer();
if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
switchNode[0] = init;
return false;
}
if (init.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = init;
return false;
}
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
Expression rhs = node.getRightHandSide();
if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION) {
switchNode[0] = rhs;
return false;
}
if (rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) {
switchNode[0] = rhs;
return false;
}
}
return super.visit(node);
}
});
if (switchNode[0] != null) {
if (switchNode[0] instanceof SwitchExpression se) {
String evaluated = constantResolver.evaluateSwitchWithParams(se, parameterValues, context);
if (evaluated != null) return evaluated;
} else if (switchNode[0] instanceof SwitchStatement ss) {
String evaluated = constantResolver.evaluateSwitchWithParams(ss, parameterValues, context);
if (evaluated != null) return evaluated;
}
}
}
}
}
return result;
}
public Map<String, String> buildParameterValuesMap(String caller, String target, Map<String, List<CallEdge>> callGraph, List<String> path, int pathIndex) {
Map<String, String> paramValues = new HashMap<>();
List<CallEdge> edges = callGraph.get(caller);
if (edges == null) {
return paramValues;
}
// We also want to support heuristic matches using a helper if isHeuristicMatch is not directly available, but let's check
// We can check if name equals or if it resolves to target
for (CallEdge edge : edges) {
if (edge.getTargetMethod().equals(target) || isHeuristicMatch(edge.getTargetMethod(), target)) {
List<String> args = edge.getArguments();
if (args == null || args.isEmpty()) break;
int lastDot = target.lastIndexOf('.');
if (lastDot < 0) break;
String className = target.substring(0, lastDot);
String methodName = target.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) break;
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null) break;
List<String> paramNames = new ArrayList<>();
for (Object paramObj : md.parameters()) {
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj;
paramNames.add(param.getName().getIdentifier());
}
int minSize = Math.min(paramNames.size(), args.size());
for (int j = 0; j < minSize; j++) {
String argValue = args.get(j);
String resolvedArgValue = resolveArgumentValue(argValue, caller, path, pathIndex, callGraph);
paramValues.put(paramNames.get(j), resolvedArgValue);
}
break;
}
}
return paramValues;
}
public String resolveArgumentValue(String argValue, String callerMethod, List<String> path, int pathIndex, Map<String, List<CallEdge>> callGraph) {
if (argValue == null) return null;
if (argValue.contains(".") || argValue.startsWith("new ") || argValue.matches(".*[0-9].*")) {
return argValue;
}
int callerIndex = path.indexOf(callerMethod);
if (callerIndex <= 0) return argValue;
String prevCaller = path.get(callerIndex - 1);
List<CallEdge> prevEdges = callGraph.get(prevCaller);
if (prevEdges == null) return argValue;
for (CallEdge edge : prevEdges) {
if (edge.getTargetMethod().equals(callerMethod) || isHeuristicMatch(edge.getTargetMethod(), callerMethod)) {
List<String> prevArgs = edge.getArguments();
if (prevArgs == null || prevArgs.isEmpty()) break;
int lastDot = callerMethod.lastIndexOf('.');
if (lastDot < 0) break;
String className = callerMethod.substring(0, lastDot);
String methodName = callerMethod.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) break;
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null) break;
List<String> paramNames = new ArrayList<>();
for (Object paramObj : md.parameters()) {
SingleVariableDeclaration param = (SingleVariableDeclaration) paramObj;
paramNames.add(param.getName().getIdentifier());
}
int paramIdx = paramNames.indexOf(argValue);
if (paramIdx >= 0 && paramIdx < prevArgs.size()) {
String prevArg = prevArgs.get(paramIdx);
return resolveArgumentValue(prevArg, prevCaller, path, pathIndex, callGraph);
}
break;
}
}
return argValue;
}
private boolean isHeuristicMatch(String neighbor, String target) {
if (neighbor == null || target == null) return false;
if (neighbor.equals(target)) return true;
if (neighbor.contains(".") && target.contains(".")) {
String methodNeighbor = neighbor.substring(neighbor.lastIndexOf('.') + 1);
String methodTarget = target.substring(target.lastIndexOf('.') + 1);
if (!methodNeighbor.equals(methodTarget)) return false;
String classNeighbor = neighbor.substring(0, neighbor.lastIndexOf('.'));
String classTarget = target.substring(0, target.lastIndexOf('.'));
if (classNeighbor.equals(classTarget)) return true;
String simpleClassNeighbor = classNeighbor.contains(".") ? classNeighbor.substring(classNeighbor.lastIndexOf('.') + 1) : classNeighbor;
String simpleClassTarget = classTarget.contains(".") ? classTarget.substring(classTarget.lastIndexOf('.') + 1) : classTarget;
if (simpleClassNeighbor.equals(simpleClassTarget)) return true;
List<String> impls = context.getImplementations(classTarget);
if (impls != null && impls.contains(classNeighbor)) return true;
List<String> implsN = context.getImplementations(classNeighbor);
if (implsN != null && implsN.contains(classTarget)) return true;
}
return false;
}
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;
}
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth, String methodFqn) {
if (depth > 5) return mi;
String targetMethodFqn = resolveTargetMethodFqn(mi, methodFqn);
if (targetMethodFqn != null && targetMethodFqn.contains(".")) {
String className = targetMethodFqn.substring(0, targetMethodFqn.lastIndexOf('.'));
String methodName = targetMethodFqn.substring(targetMethodFqn.lastIndexOf('.') + 1);
TypeDeclaration currentTd = methodFqn != null && methodFqn.contains(".") ? context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))) : null;
CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null;
TypeDeclaration td = context.getTypeDeclaration(className, cu);
if (td != null) {
MethodDeclaration localMd = context.findMethodDeclaration(td, methodName, true);
if (localMd != null) {
int paramIdx = getReturnedParameterIndex(localMd);
if (paramIdx >= 0 && paramIdx < mi.arguments().size()) {
Expression arg = peelExpression((Expression) mi.arguments().get(paramIdx));
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
}
}
}
if (mi.getExpression() != null) {
String exprStr = mi.getExpression().toString();
if (!exprStr.equals("Optional") && !exprStr.equals("Stream") && !exprStr.equals("List") && !exprStr.equals("Set") && !exprStr.equals("Arrays") && !exprStr.equals("Objects") && !exprStr.equals("Mono") && !exprStr.equals("Flux")) {
return mi;
}
}
if (!mi.arguments().isEmpty()) {
String mName = mi.getName().getIdentifier();
String lowerName = mName.toLowerCase();
if (lowerName.startsWith("map") || lowerName.startsWith("parse") ||
lowerName.startsWith("convert") || lowerName.startsWith("to") ||
lowerName.startsWith("resolve") || lowerName.startsWith("build") ||
lowerName.startsWith("create") || lowerName.startsWith("from") ||
lowerName.startsWith("transform") || lowerName.startsWith("translate") ||
lowerName.startsWith("derive") || lowerName.startsWith("determine") ||
lowerName.startsWith("calculate") || lowerName.startsWith("decode") ||
lowerName.startsWith("extract") || lowerName.startsWith("get") ||
lowerName.startsWith("find") || lowerName.startsWith("validate")) {
return mi;
}
Expression arg = peelExpression((Expression) mi.arguments().get(0));
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1, methodFqn);
}
return arg;
}
return mi;
}
private String resolveTargetMethodFqn(MethodInvocation mi, String currentMethodFqn) {
if (currentMethodFqn == null || !currentMethodFqn.contains(".")) return null;
String currentClass = currentMethodFqn.substring(0, currentMethodFqn.lastIndexOf('.'));
if (mi.getExpression() == null || mi.getExpression().toString().equals("this")) {
return currentClass + "." + mi.getName().getIdentifier();
}
if (mi.getExpression() instanceof SimpleName sn) {
String declaredType = getVariableDeclaredType(currentMethodFqn, sn.getIdentifier());
if (declaredType != null) {
return declaredType + "." + mi.getName().getIdentifier();
}
} else if (mi.getExpression() instanceof FieldAccess fa) {
String declaredType = getVariableDeclaredType(currentMethodFqn, fa.getName().getIdentifier());
if (declaredType != null) {
return declaredType + "." + mi.getName().getIdentifier();
}
}
return null;
}
private int getReturnedParameterIndex(MethodDeclaration md) {
if (md.getBody() == null) return -1;
List<?> parameters = md.parameters();
if (parameters.isEmpty()) return -1;
final List<String> returnedExprs = new ArrayList<>();
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (node.getExpression() != null) {
returnedExprs.add(node.getExpression().toString());
}
return super.visit(node);
}
});
if (returnedExprs.size() == 1) {
String retStr = returnedExprs.get(0);
for (int i = 0; i < parameters.size(); i++) {
if (parameters.get(i) instanceof SingleVariableDeclaration svd) {
if (svd.getName().getIdentifier().equals(retStr)) {
return i;
}
}
}
}
return -1;
}
}

View File

@@ -0,0 +1,93 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import org.eclipse.jdt.core.dom.IAnnotationBinding;
import org.eclipse.jdt.core.dom.IMemberValuePairBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import java.util.List;
public class InjectionPointAnalyzer {
private final SpringDependencyResolver resolver;
public InjectionPointAnalyzer(SpringDependencyResolver resolver) {
this.resolver = resolver;
}
/**
* Resolves the Spring bean injected into the given variable.
*
* @param variableBinding The binding of the field or parameter being injected.
* @return The FQN of the resolved concrete bean class, or null if unresolved.
*/
public String resolveInjectedBeanFqn(IVariableBinding variableBinding) {
if (variableBinding == null || variableBinding.getType() == null) {
return null;
}
String requiredTypeFqn = variableBinding.getType().getQualifiedName();
String injectionName = variableBinding.getName();
String qualifier = extractQualifier(variableBinding);
List<SpringBean> resolvedBeans = resolver.resolve(requiredTypeFqn, qualifier, injectionName);
if (resolvedBeans != null && resolvedBeans.size() == 1) {
return resolvedBeans.get(0).getTypeFqn();
}
return null;
}
private String extractQualifier(IVariableBinding binding) {
String qualifier = getQualifierValue(binding.getAnnotations());
if (qualifier != null) {
return qualifier;
}
if (binding.isField()) {
org.eclipse.jdt.core.dom.ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
for (org.eclipse.jdt.core.dom.IMethodBinding method : declaringClass.getDeclaredMethods()) {
boolean isAutowiredMethod = false;
for (IAnnotationBinding ann : method.getAnnotations()) {
if (ann.getAnnotationType() != null && "org.springframework.beans.factory.annotation.Autowired".equals(ann.getAnnotationType().getQualifiedName())) {
isAutowiredMethod = true;
break;
}
}
if (method.isConstructor() || isAutowiredMethod) {
for (int i = 0; i < method.getParameterTypes().length; i++) {
org.eclipse.jdt.core.dom.ITypeBinding paramType = method.getParameterTypes()[i];
try {
IAnnotationBinding[] paramAnns = method.getParameterAnnotations(i);
String paramQual = getQualifierValue(paramAnns);
if (paramQual != null && paramType.getErasure().isEqualTo(binding.getType().getErasure())) {
// For setters, verify it roughly matches the field name or just rely on type.
// We will rely on type equality for now as a heuristic.
return paramQual;
}
} catch (Exception e) {
// Ignore
}
}
}
}
}
}
return null;
}
private String getQualifierValue(IAnnotationBinding[] annotations) {
for (IAnnotationBinding ann : annotations) {
if (ann.getAnnotationType() != null && "org.springframework.beans.factory.annotation.Qualifier".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName()) && pair.getValue() instanceof String) {
return (String) pair.getValue();
}
}
}
}
return null;
}
}

View File

@@ -0,0 +1,27 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import java.util.HashSet;
import java.util.Set;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class SpringBean {
private String typeFqn;
@Builder.Default
private Set<String> assignableTypes = new HashSet<>();
@Builder.Default
private Set<String> beanNames = new HashSet<>();
@Builder.Default
private Set<String> qualifiers = new HashSet<>();
private boolean isPrimary;
private Integer order;
private String declaringClassFqn;
private String factoryMethodName;
}

View File

@@ -0,0 +1,17 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class SpringBeanRegistry {
private final List<SpringBean> beans = new ArrayList<>();
public void addBean(SpringBean bean) {
beans.add(bean);
}
public List<SpringBean> getBeans() {
return Collections.unmodifiableList(beans);
}
}

View File

@@ -0,0 +1,235 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import org.eclipse.jdt.core.dom.*;
import java.util.HashSet;
import java.util.Set;
public class SpringContextScanner extends ASTVisitor {
private final SpringBeanRegistry registry;
public SpringContextScanner(SpringBeanRegistry registry) {
this.registry = registry;
}
@Override
public boolean visit(TypeDeclaration node) {
ITypeBinding typeBinding = node.resolveBinding();
if (typeBinding == null) return true;
if (isSpringComponent(typeBinding)) {
SpringBean bean = SpringBean.builder()
.typeFqn(typeBinding.getQualifiedName())
.assignableTypes(getAssignableTypes(typeBinding))
.isPrimary(hasAnnotation(typeBinding, "org.springframework.context.annotation.Primary"))
.qualifiers(extractQualifiers(typeBinding))
.order(extractOrder(typeBinding))
.declaringClassFqn(typeBinding.getQualifiedName())
.build();
// Default bean name is uncapitalized simple name
String defaultName = Character.toLowerCase(node.getName().getIdentifier().charAt(0)) + node.getName().getIdentifier().substring(1);
// Check if @Component or @Service specifies a name (e.g., @Service("myService"))
String explicitName = extractStereotypeValue(typeBinding);
if (explicitName != null && !explicitName.isEmpty()) {
bean.getBeanNames().add(explicitName);
} else {
bean.getBeanNames().add(defaultName);
}
registry.addBean(bean);
}
return true;
}
@Override
public boolean visit(MethodDeclaration node) {
IMethodBinding methodBinding = node.resolveBinding();
if (methodBinding == null) return true;
if (hasAnnotation(methodBinding, "org.springframework.context.annotation.Bean")) {
ITypeBinding returnType = methodBinding.getReturnType();
if (returnType != null) {
Set<String> assignables = getAssignableTypes(returnType);
String[] concreteType = new String[] { returnType.getQualifiedName() };
if (node.getBody() != null) {
node.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement ret) {
if (ret.getExpression() != null) {
ITypeBinding exprType = ret.getExpression().resolveTypeBinding();
if (exprType != null && exprType.isClass()) {
concreteType[0] = exprType.getQualifiedName();
assignables.addAll(getAssignableTypes(exprType));
}
}
return super.visit(ret);
}
});
}
SpringBean bean = SpringBean.builder()
.typeFqn(concreteType[0])
.assignableTypes(assignables)
.isPrimary(hasAnnotation(methodBinding, "org.springframework.context.annotation.Primary"))
.qualifiers(extractQualifiers(methodBinding))
.order(extractOrder(methodBinding))
.declaringClassFqn(methodBinding.getDeclaringClass().getQualifiedName())
.factoryMethodName(node.getName().getIdentifier())
.build();
// Default bean name is method name
String defaultName = node.getName().getIdentifier();
// Check if @Bean specifies a name (e.g., @Bean("myBean"))
String explicitName = extractBeanName(methodBinding);
if (explicitName != null && !explicitName.isEmpty()) {
bean.getBeanNames().add(explicitName);
} else {
bean.getBeanNames().add(defaultName);
}
registry.addBean(bean);
}
}
return true;
}
private boolean isSpringComponent(ITypeBinding binding) {
return hasMetaAnnotation(binding, "org.springframework.stereotype.Component") ||
hasMetaAnnotation(binding, "org.springframework.web.bind.annotation.RestController");
}
private boolean hasMetaAnnotation(ITypeBinding binding, String targetFqn) {
return checkMetaAnnotation(binding.getAnnotations(), targetFqn, new HashSet<>());
}
private boolean checkMetaAnnotation(IAnnotationBinding[] annotations, String targetFqn, Set<String> visited) {
for (IAnnotationBinding ann : annotations) {
ITypeBinding annType = ann.getAnnotationType();
if (annType != null) {
String fqn = annType.getQualifiedName();
if (fqn.equals(targetFqn)) return true;
// Avoid circular meta-annotations (e.g. Documented -> Documented)
if (visited.add(fqn)) {
if (checkMetaAnnotation(annType.getAnnotations(), targetFqn, visited)) {
return true;
}
}
}
}
return false;
}
private boolean hasAnnotation(IBinding binding, String annotationFqn) {
IAnnotationBinding[] annotations = binding.getAnnotations();
for (IAnnotationBinding ann : annotations) {
if (ann.getAnnotationType() != null && annotationFqn.equals(ann.getAnnotationType().getQualifiedName())) {
return true;
}
}
return false;
}
private Set<String> getAssignableTypes(ITypeBinding binding) {
Set<String> types = new HashSet<>();
collectAssignableTypes(binding, types);
return types;
}
private void collectAssignableTypes(ITypeBinding binding, Set<String> types) {
if (binding == null) return;
String qName = binding.getQualifiedName();
if (qName != null && !qName.isEmpty()) {
types.add(qName);
}
ITypeBinding erasure = binding.getErasure();
if (erasure != null) {
String erasureName = erasure.getQualifiedName();
if (erasureName != null && !erasureName.isEmpty()) {
types.add(erasureName);
}
}
collectAssignableTypes(binding.getSuperclass(), types);
if (binding.getInterfaces() != null) {
for (ITypeBinding iface : binding.getInterfaces()) {
collectAssignableTypes(iface, types);
}
}
}
private Set<String> extractQualifiers(IBinding binding) {
Set<String> qualifiers = new HashSet<>();
for (IAnnotationBinding ann : binding.getAnnotations()) {
if (ann.getAnnotationType() != null && "org.springframework.beans.factory.annotation.Qualifier".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName()) && pair.getValue() instanceof String) {
qualifiers.add((String) pair.getValue());
}
}
}
}
return qualifiers;
}
private Integer extractOrder(IBinding binding) {
for (IAnnotationBinding ann : binding.getAnnotations()) {
if (ann.getAnnotationType() != null && "org.springframework.core.annotation.Order".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName())) {
Object val = pair.getValue();
if (val instanceof Integer) {
return (Integer) val;
} else if (val instanceof IVariableBinding) {
Object constantValue = ((IVariableBinding) val).getConstantValue();
if (constantValue instanceof Integer) {
return (Integer) constantValue;
}
}
}
}
return Integer.MAX_VALUE; // Spring's default for @Order()
}
}
return null;
}
private String extractStereotypeValue(ITypeBinding binding) {
for (IAnnotationBinding ann : binding.getAnnotations()) {
// Check if annotation itself is meta-annotated with @Component
if (checkMetaAnnotation(new IAnnotationBinding[]{ann}, "org.springframework.stereotype.Component", new HashSet<>()) ||
"org.springframework.stereotype.Component".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName()) && pair.getValue() instanceof String) {
return (String) pair.getValue();
}
}
}
}
return null;
}
private String extractBeanName(IMethodBinding binding) {
for (IAnnotationBinding ann : binding.getAnnotations()) {
if ("org.springframework.context.annotation.Bean".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if (("value".equals(pair.getName()) || "name".equals(pair.getName())) && pair.getValue() instanceof String) {
return (String) pair.getValue();
} else if (("value".equals(pair.getName()) || "name".equals(pair.getName())) && pair.getValue() instanceof Object[]) {
Object[] vals = (Object[]) pair.getValue();
if (vals.length > 0 && vals[0] instanceof String) {
return (String) vals[0];
}
}
}
}
}
return null;
}
}

View File

@@ -0,0 +1,87 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class SpringDependencyResolver {
private final SpringBeanRegistry registry;
public SpringDependencyResolver(SpringBeanRegistry registry) {
this.registry = registry;
}
/**
* Resolves the exact SpringBean that would be injected for a given injection point.
*
* @param requiredTypeFqn The exact FQN of the type requested at the injection point.
* @param qualifier The value of the @Qualifier annotation on the injection point (if any).
* @param injectionName The name of the field or parameter being injected (used as fallback).
* @return A list of resolved beans. Usually 1. If 0, unresolved. If > 1, ambiguous.
*/
public List<SpringBean> resolve(String requiredTypeFqn, String qualifier, String injectionName) {
if (requiredTypeFqn == null) return new ArrayList<>();
// 1. Filter by Type (Exact FQN or Assignable Type)
System.out.println("RESOLVING " + requiredTypeFqn + " qual=" + qualifier + " injectionName=" + injectionName);
List<SpringBean> candidates = registry.getBeans().stream()
.filter(bean -> requiredTypeFqn.equals(bean.getTypeFqn()) || bean.getAssignableTypes().contains(requiredTypeFqn))
.collect(Collectors.toList());
System.out.println("CANDIDATES AFTER TYPE: " + candidates.stream().map(c -> c.getTypeFqn() + " (primary=" + c.isPrimary() + " names=" + c.getBeanNames() + " qual=" + c.getQualifiers() + ")").collect(Collectors.toList()));
if (candidates.isEmpty() || candidates.size() == 1) {
System.out.println("RETURNING: " + candidates.size());
return candidates;
}
// 2. Filter by Qualifier (if provided)
if (qualifier != null && !qualifier.isEmpty()) {
List<SpringBean> qualifiedCandidates = candidates.stream()
.filter(bean -> bean.getQualifiers().contains(qualifier) || bean.getBeanNames().contains(qualifier))
.collect(Collectors.toList());
if (qualifiedCandidates.size() == 1) {
return qualifiedCandidates;
} else if (!qualifiedCandidates.isEmpty()) {
candidates = qualifiedCandidates;
}
}
// 3. Filter by @Primary
List<SpringBean> primaryCandidates = candidates.stream()
.filter(SpringBean::isPrimary)
.collect(Collectors.toList());
if (primaryCandidates.size() == 1) {
return primaryCandidates;
}
// 4. Fallback to Injection Name (field name or parameter name)
if (injectionName != null && !injectionName.isEmpty()) {
List<SpringBean> namedCandidates = candidates.stream()
.filter(bean -> bean.getBeanNames().contains(injectionName))
.collect(Collectors.toList());
if (namedCandidates.size() == 1) {
return namedCandidates;
}
}
// 5. Fallback to @Order (lowest order wins)
Integer minOrder = candidates.stream()
.map(SpringBean::getOrder)
.filter(o -> o != null)
.min(Integer::compareTo)
.orElse(null);
if (minOrder != null) {
List<SpringBean> orderedCandidates = candidates.stream()
.filter(bean -> bean.getOrder() != null && bean.getOrder().equals(minOrder))
.collect(Collectors.toList());
if (!orderedCandidates.isEmpty()) {
candidates = orderedCandidates;
}
}
// Return whatever candidates remain (could be ambiguous)
System.out.println("RETURNING: " + candidates.size());
return candidates;
}
}

View File

@@ -0,0 +1,72 @@
package click.kamil.springstatemachineexporter.analysis.util;
import lombok.extern.slf4j.Slf4j;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Utility for resolving Spring-style placeholders like ${property.name} or ${property.name:default-value}.
* Supports recursive resolution.
*/
@Slf4j
public class PlaceholderResolver {
private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{([^:}]+)(?::([^}]*))?}");
/**
* Resolves all placeholders in the given text using the provided properties map.
*
* @param text The text containing placeholders.
* @param properties The map of property keys and values.
* @return The resolved string.
*/
public static String resolve(String text, Map<String, String> properties) {
return resolveInternal(text, properties, new HashSet<>());
}
private static String resolveInternal(String text, Map<String, String> properties, Set<String> visited) {
if (text == null || !text.contains("${")) {
return text;
}
StringBuilder result = new StringBuilder();
Matcher matcher = PLACEHOLDER_PATTERN.matcher(text);
int lastMatchEnd = 0;
while (matcher.find()) {
result.append(text, lastMatchEnd, matcher.start());
String key = matcher.group(1);
String defaultValue = matcher.group(2);
if (visited.contains(key)) {
log.warn("Circular placeholder reference detected: {}", key);
result.append(matcher.group(0)); // Keep as is
} else {
String value = properties.get(key);
if (value == null) {
value = defaultValue;
}
if (value != null) {
visited.add(key);
try {
result.append(resolveInternal(value, properties, visited));
} finally {
visited.remove(key);
}
} else {
log.debug("Property not found and no default value for: {}", key);
result.append(matcher.group(0)); // Keep as is if not resolvable
}
}
lastMatchEnd = matcher.end();
}
result.append(text.substring(lastMatchEnd));
return result.toString();
}
}

View File

@@ -1,20 +1,14 @@
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;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.model.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -24,8 +18,11 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
@Slf4j
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)
@@ -173,15 +170,15 @@ public class AstTransitionParser {
Expression secondArg = resolveArg((Expression) args.get(1), argsMap);
if ("last".equals(methodName)) {
// For 'last', the second argument is an action, not a guard
parseAction(secondArg, t);
parseAction(secondArg, t, cu, context);
} else {
// For 'first' and 'then', the second argument is a guard
parseGuard(secondArg, t);
parseGuard(secondArg, t, cu, context);
}
}
if (args.size() > 2) {
Expression thirdArg = resolveArg((Expression) args.get(2), argsMap);
parseAction(thirdArg, t);
parseAction(thirdArg, t, cu, context);
}
t.setOrder(orderCounter++);
transitions.add(t);
@@ -215,32 +212,328 @@ 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(Event.of(resolved.toString(), eventValue));
} else if (resolved instanceof QualifiedName qn) {
String qualifier = qn.getQualifier().toString();
String name = qn.getName().getIdentifier();
TypeDeclaration td = context.getTypeDeclaration(qualifier, cu);
if (td != null) {
t.setEvent(Event.of(resolved.toString(), context.getFqn(td) + "." + name));
} else {
t.setEvent(Event.of(resolved.toString(), qn.getFullyQualifiedName()));
}
} else if (resolved instanceof SimpleName sn) {
String name = sn.getIdentifier();
String full = name;
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + name)) {
full = impName;
break;
}
}
t.setEvent(Event.of(resolved.toString(), full));
} else {
t.setEvent(Event.of(QuotedExpression.of(resolved).toStringWithoutQuotes()));
}
}
case "guard" -> {
case "guard", "guardExpression" -> {
Expression resolved = resolveArg((Expression) args.get(0), argsMap);
parseGuard(resolved, t);
parseGuard(resolved, t, cu, context);
}
case "action" -> {
Expression resolved = resolveArg((Expression) args.get(0), argsMap);
parseAction(resolved, t);
parseAction(resolved, t, cu, context);
}
case "timer", "timerOnce" -> {
Expression resolved = resolveArg((Expression) args.get(0), argsMap);
t.setEvent(Event.of(methodName + "(" + resolved.toString() + ")", methodName + "(" + resolved.toString() + ")"));
}
}
}
return t;
}
private static void parseGuard(Object arg, Transition t) {
private static String extractInternalLogic(Expression expr, CompilationUnit cu, CodebaseContext context) {
if (expr == null) return null;
// 1. Lambda Expressions
if (expr instanceof LambdaExpression le) {
return le.toString();
}
// 2. Anonymous Classes
if (expr instanceof ClassInstanceCreation cic && cic.getAnonymousClassDeclaration() != null) {
return cic.toString();
}
// 3. Method Invocations
if (expr instanceof MethodInvocation mi) {
IMethodBinding binding = mi.resolveMethodBinding();
if (binding != null) {
// Try to resolve deeper logic from the return type (e.g., a Bean returning an Action interface)
ITypeBinding returnType = binding.getReturnType();
if (returnType != null) {
String returnFqn = returnType.getTypeDeclaration().getQualifiedName();
TypeDeclaration returnTd = context.getTypeDeclaration(returnFqn);
if (returnTd != null) {
for (MethodDeclaration rmd : returnTd.getMethods()) {
String name = rmd.getName().getIdentifier();
if (name.equals("execute") || name.equals("evaluate")) {
return rmd.toString();
}
}
}
}
// Try same file
ASTNode declNode = cu.findDeclaringNode(binding.getKey());
if (declNode instanceof MethodDeclaration md) {
if (md.getBody() != null && md.getBody().statements().size() == 1) {
Object stmt = md.getBody().statements().get(0);
if (stmt instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
Expression retExpr = rs.getExpression();
if (isLambdaOrAnonymous(retExpr)) {
return retExpr.toString();
}
}
}
return md.toString();
}
// Try other files via CodebaseContext
ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
String fqn = declaringClass.getTypeDeclaration().getQualifiedName();
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td != null) {
for (MethodDeclaration md : td.getMethods()) {
if (md.getName().getIdentifier().equals(binding.getName()) &&
md.parameters().size() == binding.getParameterTypes().length) {
// Special case: if it's a getter/factory for Action/Guard, look into the return statement
if (md.getBody() != null) {
if (md.getBody().statements().size() == 1) {
Object stmt = md.getBody().statements().get(0);
if (stmt instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
Expression retExpr = rs.getExpression();
if (isLambdaOrAnonymous(retExpr)) {
return retExpr.toString();
}
}
}
}
return md.toString();
}
}
}
}
} else {
// FALLBACK: Manual resolution by name if binding failed
TypeDeclaration currentClass = findEnclosingType(mi);
if (currentClass != null) {
return resolveMethodManually(mi.getName().getIdentifier(), currentClass, context);
}
}
}
// 4. Method References
if (expr instanceof MethodReference mr) {
IMethodBinding binding = mr.resolveMethodBinding();
if (binding != null) {
ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
String fqn = declaringClass.getTypeDeclaration().getQualifiedName();
TypeDeclaration td = context.getTypeDeclaration(fqn);
if (td != null) {
for (MethodDeclaration md : td.getMethods()) {
if (md.getName().getIdentifier().equals(binding.getName()) &&
md.parameters().size() == binding.getParameterTypes().length) {
return md.toString();
}
}
}
}
}
return mr.toString();
}
// 5. Bean Reference (SimpleName, FieldAccess, etc.)
String resolvedBeanBody = resolveBeanClassBody(expr, context);
if (resolvedBeanBody != null) {
return resolvedBeanBody;
}
return null;
}
private static String resolveBeanClassBody(Expression expr, CodebaseContext context) {
String typeFqn = null;
ITypeBinding binding = expr.resolveTypeBinding();
if (binding != null) {
typeFqn = binding.getQualifiedName();
} else if (expr instanceof SimpleName sn) {
// Check enclosing method first for local variables
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
if (enclosingMethod != null) {
final String[] foundType = {null};
final Expression[] foundInitializer = {null};
enclosingMethod.accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragObj : vds.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(sn.getIdentifier())) {
foundType[0] = vds.getType().toString();
foundInitializer[0] = fragment.getInitializer();
}
}
}
return super.visit(vds);
}
});
if (foundInitializer[0] != null) {
ASTNode root = expr.getRoot();
if (root instanceof CompilationUnit cu) {
String initLogic = extractInternalLogic(foundInitializer[0], cu, context);
if (initLogic != null) return initLogic;
}
}
if (foundType[0] != null) {
typeFqn = foundType[0];
}
}
if (typeFqn == null) {
TypeDeclaration enclosingClass = findEnclosingType(expr);
if (enclosingClass != null) {
for (FieldDeclaration fd : enclosingClass.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(sn.getIdentifier())) {
if (fragment.getInitializer() != null) {
ASTNode root = expr.getRoot();
if (root instanceof CompilationUnit cu) {
String initLogic = extractInternalLogic(fragment.getInitializer(), cu, context);
if (initLogic != null) return initLogic;
}
}
typeFqn = fd.getType().toString();
break;
}
}
}
}
}
}
}
if (typeFqn != null) {
if (typeFqn.contains("<")) {
typeFqn = typeFqn.substring(0, typeFqn.indexOf('<'));
}
TypeDeclaration beanTd = context.getTypeDeclaration(typeFqn);
if (beanTd == null) {
ASTNode root = expr.getRoot();
if (root instanceof CompilationUnit cu) {
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
if (!imp.isStatic() && !imp.isOnDemand()) {
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + typeFqn)) {
beanTd = context.getTypeDeclaration(impName);
break;
}
}
}
}
}
if (beanTd != null) {
for (MethodDeclaration md : beanTd.getMethods()) {
String name = md.getName().getIdentifier();
if ("evaluate".equals(name) || "execute".equals(name)) {
return md.toString();
}
}
return beanTd.toString();
}
}
return null;
}
private static MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof MethodDeclaration md) return md;
current = current.getParent();
}
return null;
}
private static String resolveMethodManually(String methodName, TypeDeclaration td, CodebaseContext context) {
// Search in this class
for (MethodDeclaration md : td.getMethods()) {
if (md.getName().getIdentifier().equals(methodName)) {
// Found it. Apply the return-statement-extraction logic here too
if (md.getBody() != null && md.getBody().statements().size() == 1) {
Object stmt = md.getBody().statements().get(0);
if (stmt instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
Expression retExpr = rs.getExpression();
if (isLambdaOrAnonymous(retExpr)) {
return retExpr.toString();
}
}
}
return md.toString();
}
}
// Search in superclass
if (td.getSuperclassType() != null) {
String superName = td.getSuperclassType().toString();
// Try to resolve simple name to FQN if possible, or just search by simple name
TypeDeclaration superTd = context.getTypeDeclaration(superName);
if (superTd != null) {
return resolveMethodManually(methodName, superTd, context);
}
}
return null;
}
private static TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof TypeDeclaration td) return td;
current = current.getParent();
}
return null;
}
private static void parseGuard(Object arg, Transition t, CompilationUnit cu, CodebaseContext context) {
QuotedExpression quotedExpr = QuotedExpression.of(arg);
if (quotedExpr != null) {
t.setGuard(Guard.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression())));
String internalLogic = extractInternalLogic(quotedExpr.getExpression(), cu, context);
int lineNumber = cu.getLineNumber(quotedExpr.getExpression().getStartPosition());
TypeDeclaration td = findEnclosingType(quotedExpr.getExpression());
String fqn = td != null ? context.getFqn(td) : null;
String sourceFile = fqn != null ? context.getRelativePath(fqn) : null;
t.setGuard(Guard.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression()), internalLogic, lineNumber, fqn, sourceFile));
}
}
private static void parseAction(Object arg, Transition t) {
private static void parseAction(Object arg, Transition t, CompilationUnit cu, CodebaseContext context) {
QuotedExpression quotedExpr = QuotedExpression.of(arg);
if (quotedExpr != null) {
t.getActions().add(Action.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression())));
String internalLogic = extractInternalLogic(quotedExpr.getExpression(), cu, context);
int lineNumber = cu.getLineNumber(quotedExpr.getExpression().getStartPosition());
TypeDeclaration td = findEnclosingType(quotedExpr.getExpression());
String fqn = td != null ? context.getFqn(td) : null;
String sourceFile = fqn != null ? context.getRelativePath(fqn) : null;
t.getActions().add(Action.of(quotedExpr.toStringWithoutQuotes(), isLambdaOrAnonymous(quotedExpr.getExpression()), internalLogic, lineNumber, fqn, sourceFile));
}
}

View File

@@ -0,0 +1,620 @@
package click.kamil.springstatemachineexporter.ast.app;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.model.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class StateMachineAdapterParser {
private final CodebaseContext context;
private enum AnalysisGoal {TRANSITIONS, STATES}
public record AdapterStates(Set<String> initialStates, Set<String> endStates) {}
public record AdapterConfig(
List<Transition> transitions,
Set<String> initialStates,
Set<String> endStates
) {}
public StateMachineAdapterParser(CodebaseContext context) {
this.context = context;
}
public AdapterConfig parse(TypeDeclaration startClass) {
List<Transition> transitions = parseTransitions(startClass);
AdapterStates states = parseStates(startClass);
return new AdapterConfig(transitions, states.initialStates(), states.endStates());
}
public List<Transition> parseTransitions(TypeDeclaration startClass) {
List<Transition> allTransitions = new ArrayList<>();
Set<MethodVisit> visitedMethods = new HashSet<>();
aggregateTransitionsRecursive(startClass, startClass, allTransitions, new HashSet<>(), visitedMethods, new HashMap<>());
return allTransitions;
}
public AdapterStates parseStates(TypeDeclaration startClass) {
Set<String> initialStates = new HashSet<>();
Set<String> endStates = new HashSet<>();
Set<MethodVisit> visitedMethods = new HashSet<>();
aggregateStatesRecursive(startClass, startClass, initialStates, endStates, new HashSet<>(), visitedMethods, new HashMap<>());
return new AdapterStates(initialStates, endStates);
}
private void aggregateTransitionsRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, List<Transition> allTransitions, Set<String> visitedClasses, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (currentTd == null)
return;
String fqn = context.getFqn(currentTd);
if (visitedClasses.contains(fqn))
return;
visitedClasses.add(fqn);
for (MethodDeclaration method : currentTd.getMethods()) {
if (isConfigureTransitionsMethod(method)) {
allTransitions.addAll(parseTransitionsWithMethodCalls(method, searchStartClass, visitedMethods, argsMap));
}
}
CompilationUnit cu = (CompilationUnit) currentTd.getRoot();
// Hierarchy - Superclass
Type superclassType = currentTd.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), cu);
if (superclassTd != null) {
aggregateTransitionsRecursive(superclassTd, searchStartClass, allTransitions, visitedClasses, visitedMethods, argsMap);
}
}
// Hierarchy - Interfaces
for (Object interfaceTypeObj : currentTd.superInterfaceTypes()) {
if (interfaceTypeObj instanceof Type interfaceType) {
TypeDeclaration interfaceTd = context.getTypeDeclaration(getSimpleName(interfaceType), cu);
if (interfaceTd != null) {
aggregateTransitionsRecursive(interfaceTd, searchStartClass, allTransitions, visitedClasses, visitedMethods, argsMap);
}
}
}
}
private record MethodVisit(MethodDeclaration method, Map<String, Object> args) {
}
private List<Transition> parseTransitionsWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
MethodVisit visit = new MethodVisit(method, new HashMap<>(argsMap));
if (visitedMethods.contains(visit)) {
return new ArrayList<>();
}
visitedMethods.add(visit);
Block body = method.getBody();
if (body == null) {
return new ArrayList<>();
}
return parseTransitionsInBlock(body, searchStartClass, visitedMethods, argsMap);
}
private List<Transition> parseTransitionsInExpression(Expression expr, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
List<Transition> transitions = new ArrayList<>();
if (expr instanceof MethodInvocation mi) {
if (isTransitionChainEntry(mi)) {
transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi, convertToExpressionMap(argsMap), context));
} else if (isTransitionChainContinuation(mi, argsMap)) {
transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi, convertToExpressionMap(argsMap), true, context));
} else if (isForEach(mi)) {
transitions.addAll(parseForEach(mi, searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
} else {
// Check if it's a call on a lambda parameter or a variable that resolved to a lambda
Expression lambdaReceiver = mi.getExpression();
if (lambdaReceiver instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof LambdaExpression lambda) {
transitions.addAll(parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
}
}
MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass);
if (calledMethod == null) {
// Try to resolve method in another class if it's a static call or on a known type
Expression miReceiver = mi.getExpression();
if (miReceiver instanceof SimpleName sn) {
TypeDeclaration otherTd = context.getTypeDeclaration(sn.getIdentifier(), (CompilationUnit) searchStartClass.getRoot());
if (otherTd != null) {
calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), otherTd);
if (calledMethod != null) {
// For calls to other classes, we switch the "searchStartClass" to that class
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, otherTd, visitedMethods, nextArgsMap));
return transitions; // Handled
}
}
}
}
if (calledMethod != null) {
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, searchStartClass, visitedMethods, nextArgsMap));
}
}
}
return transitions;
}
private boolean isForEach(MethodInvocation mi) {
return mi.getName().getIdentifier().equals("forEach");
}
private List<Transition> parseLambdaInvocation(LambdaExpression lambda, List<?> arguments, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
Map<String, Object> lambdaArgsMap = new HashMap<>(argsMap);
List<?> params = lambda.parameters();
for (int i = 0; i < params.size() && i < arguments.size(); i++) {
Object p = params.get(i);
String paramName = "";
if (p instanceof VariableDeclaration vp)
paramName = vp.getName().getIdentifier();
if (!paramName.isEmpty()) {
lambdaArgsMap.put(paramName, resolveExpression((Expression) arguments.get(i), argsMap));
}
}
if (goal == AnalysisGoal.TRANSITIONS) {
if (lambda.getBody() instanceof Block block)
return parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
return parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap);
} else {
if (lambda.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
parseStatesInExpression(lambdaExpr, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
}
return Collections.emptyList();
}
private List<Transition> parseForEach(MethodInvocation forEachMi, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
List<Transition> transitions = new ArrayList<>();
Expression receiver = forEachMi.getExpression();
List<Expression> elements = unrollCollection(receiver, argsMap);
if (elements.isEmpty() || forEachMi.arguments().isEmpty())
return transitions;
Object lambdaObj = forEachMi.arguments().get(0);
if (lambdaObj instanceof LambdaExpression lambda) {
String paramName = getLambdaParamName(lambda);
for (Expression element : elements) {
Map<String, Object> lambdaArgsMap = new HashMap<>(argsMap);
if (!paramName.isEmpty())
lambdaArgsMap.put(paramName, element);
if (goal == AnalysisGoal.TRANSITIONS) {
if (lambda.getBody() instanceof Block block)
transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap));
else if (lambda.getBody() instanceof Expression lambdaExpr)
transitions.addAll(parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap));
} else {
if (lambda.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
parseStatesInExpression(lambdaExpr, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
}
}
}
return transitions;
}
@SuppressWarnings("unchecked")
private List<Transition> parseEnhancedForLoop(EnhancedForStatement efs, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
List<Transition> transitions = new ArrayList<>();
List<Expression> elements = unrollCollection(efs.getExpression(), argsMap);
String paramName = efs.getParameter().getName().getIdentifier();
for (Expression element : elements) {
Map<String, Object> loopArgsMap = new HashMap<>(argsMap);
loopArgsMap.put(paramName, element);
if (goal == AnalysisGoal.TRANSITIONS) {
if (efs.getBody() instanceof Block block)
transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, loopArgsMap));
else if (efs.getBody() instanceof ExpressionStatement es)
transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, loopArgsMap));
} else {
if (efs.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, loopArgsMap);
else if (efs.getBody() instanceof ExpressionStatement es)
parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, loopArgsMap);
}
}
return transitions;
}
@SuppressWarnings("unchecked")
private List<Expression> unrollCollection(Expression expr, Map<String, Object> argsMap) {
Set<Expression> visited = new HashSet<>();
Expression current = expr;
while (current != null) {
if (!visited.add(current))
break;
if (current instanceof MethodInvocation mi) {
String name = mi.getName().getIdentifier();
Expression receiver = mi.getExpression();
if ((name.equals("of") && receiver != null && (receiver.toString().equals("Stream") || receiver.toString().equals("List")))
|| (name.equals("asList") && receiver != null && receiver.toString().equals("Arrays"))) {
if (mi.arguments().size() == 1) {
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof List list)
return (List<Expression>) list;
}
}
return (List<Expression>) mi.arguments();
}
}
if (current instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof List list)
return (List<Expression>) list;
if (resolved instanceof Expression e) {
current = e;
continue;
}
}
if (current instanceof ArrayInitializer ai)
return (List<Expression>) ai.expressions();
break;
}
return Collections.emptyList();
}
private String getLambdaParamName(LambdaExpression lambda) {
if (lambda.parameters().size() == 1) {
Object p = lambda.parameters().get(0);
if (p instanceof VariableDeclaration vp)
return vp.getName().getIdentifier();
}
return "";
}
private List<Transition> parseTransitionsInBlock(Block block, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
List<Transition> transitions = new ArrayList<>();
for (Object stmt : block.statements()) {
if (stmt instanceof ExpressionStatement es)
transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, argsMap));
else if (stmt instanceof VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
Expression initializer = fragment.getInitializer();
if (initializer != null) {
Object resolved = resolve(initializer, argsMap);
if (resolved != null)
argsMap.put(fragment.getName().getIdentifier(), resolved);
transitions.addAll(parseTransitionsInExpression(initializer, searchStartClass, visitedMethods, argsMap));
}
}
}
} else if (stmt instanceof EnhancedForStatement efs)
transitions.addAll(parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
else if (stmt instanceof TryStatement ts)
transitions.addAll(parseTransitionsInBlock(ts.getBody(), searchStartClass, visitedMethods, argsMap));
}
return transitions;
}
private void parseStatesInBlock(Block block, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
for (Object stmt : block.statements()) {
if (stmt instanceof ExpressionStatement es)
parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, argsMap);
else if (stmt instanceof VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
Expression initializer = fragment.getInitializer();
if (initializer != null) {
Object resolved = resolve(initializer, argsMap);
if (resolved != null)
argsMap.put(fragment.getName().getIdentifier(), resolved);
parseStatesInExpression(initializer, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
}
} else if (stmt instanceof EnhancedForStatement efs)
parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
else if (stmt instanceof TryStatement ts)
parseStatesInBlock(ts.getBody(), searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
private void parseStatesInExpression(Expression expr, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (expr instanceof MethodInvocation mi) {
if (isWithStatesChain(mi, argsMap))
parseStateChain(mi, initialStates, endStates, argsMap);
else if (isForEach(mi))
parseForEach(mi, searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
else {
// Check if it's a call on a lambda parameter or a variable that resolved to a lambda
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof LambdaExpression lambda) {
parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
}
}
MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass);
if (calledMethod != null) {
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
parseStatesWithMethodCalls(calledMethod, searchStartClass, initialStates, endStates, visitedMethods, nextArgsMap);
}
}
}
}
private Map<String, Object> buildArgsMap(MethodDeclaration method, MethodInvocation call, Map<String, Object> currentArgsMap) {
Map<String, Object> map = new HashMap<>(currentArgsMap);
List<?> params = method.parameters();
List<?> args = call.arguments();
for (int i = 0; i < params.size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) params.get(i);
String paramName = param.getName().getIdentifier();
if (param.isVarargs() && i < params.size()) {
List<Expression> varargList = new ArrayList<>();
for (int j = i; j < args.size(); j++) {
Object resolved = resolve((Expression) args.get(j), currentArgsMap);
if (resolved instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Expression e)
varargList.add(e);
}
} else if (resolved instanceof Expression e) {
varargList.add(e);
}
}
map.put(paramName, varargList);
} else if (i < args.size()) {
map.put(paramName, resolve((Expression) args.get(i), currentArgsMap));
}
}
return map;
}
private Object resolve(Expression expr, Map<String, Object> argsMap) {
if (expr instanceof NullLiteral)
return null;
Set<Expression> visited = new HashSet<>();
Object current = expr;
while (current instanceof SimpleName sn) {
if (!visited.add((SimpleName) current))
break;
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved == null || resolved == current)
break;
current = resolved;
}
return current;
}
private Expression resolveExpression(Expression expr, Map<String, Object> argsMap) {
Object resolved = resolve(expr, argsMap);
if (resolved instanceof Expression e)
return e;
return expr;
}
private Map<String, Expression> convertToExpressionMap(Map<String, Object> argsMap) {
Map<String, Expression> map = new HashMap<>();
for (Map.Entry<String, Object> entry : argsMap.entrySet()) {
if (entry.getValue() instanceof Expression e)
map.put(entry.getKey(), e);
}
return map;
}
private boolean isTransitionChainEntry(MethodInvocation mi) {
Expression current = mi;
while (current instanceof MethodInvocation call) {
String name = call.getName().getIdentifier();
if (TransitionType.fromMethodName(name).isPresent())
return true;
current = call.getExpression();
}
return false;
}
private boolean isTransitionChainContinuation(MethodInvocation mi, Map<String, Object> argsMap) {
Expression current = mi;
Set<Expression> visited = new HashSet<>();
while (current instanceof MethodInvocation call) {
if (!visited.add(current))
break;
String name = call.getName().getIdentifier();
if (Set.of("and", "source", "target", "event", "first", "then", "last").contains(name)) {
Expression receiver = call.getExpression();
if (receiver instanceof MethodInvocation next) {
current = next;
continue;
}
Object resolved = resolve(receiver, argsMap);
if (resolved instanceof MethodInvocation next) {
current = next;
continue;
}
return resolved == null; // null means it's a parameter we track
}
if (TransitionType.fromMethodName(name).isPresent())
return true;
break;
}
return false;
}
private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td) {
return findMethodInHierarchy(methodName, td, new HashSet<>());
}
private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td, Set<String> visited) {
if (td == null)
return null;
String fqn = context.getFqn(td);
if (visited.contains(fqn))
return null;
visited.add(fqn);
for (MethodDeclaration method : td.getMethods())
if (method.getName().getIdentifier().equals(methodName))
return method;
Type superclassType = td.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), (CompilationUnit) td.getRoot());
return findMethodInHierarchy(methodName, superclassTd, visited);
}
return null;
}
private boolean isRelevantMethod(MethodDeclaration method) {
return true;
}
private MethodDeclaration findConfigureTransitionsMethod(TypeDeclaration td) {
for (MethodDeclaration method : td.getMethods())
if (isConfigureTransitionsMethod(method))
return method;
return null;
}
private boolean isConfigureTransitionsMethod(MethodDeclaration method) {
if (!method.getName().getIdentifier().equals("configure") || method.parameters().isEmpty())
return false;
return ((SingleVariableDeclaration) method.parameters().get(0)).getType().toString().contains("StateMachineTransitionConfigurer");
}
private void aggregateStatesRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<String> visitedClasses, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (currentTd == null)
return;
String fqn = context.getFqn(currentTd);
if (visitedClasses.contains(fqn))
return;
visitedClasses.add(fqn);
for (MethodDeclaration method : currentTd.getMethods()) {
if (isConfigureStatesMethod(method)) {
parseStatesWithMethodCalls(method, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
CompilationUnit cu = (CompilationUnit) currentTd.getRoot();
// Hierarchy - Superclass
Type superclassType = currentTd.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), cu);
if (superclassTd != null)
aggregateStatesRecursive(superclassTd, searchStartClass, initialStates, endStates, visitedClasses, visitedMethods, argsMap);
}
// Hierarchy - Interfaces
for (Object interfaceTypeObj : currentTd.superInterfaceTypes()) {
if (interfaceTypeObj instanceof Type interfaceType) {
TypeDeclaration interfaceTd = context.getTypeDeclaration(getSimpleName(interfaceType), cu);
if (interfaceTd != null)
aggregateStatesRecursive(interfaceTd, searchStartClass, initialStates, endStates, visitedClasses, visitedMethods, argsMap);
}
}
}
private void parseStatesWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
MethodVisit visit = new MethodVisit(method, new HashMap<>(argsMap));
if (visitedMethods.contains(visit))
return;
visitedMethods.add(visit);
Block body = method.getBody();
if (body == null)
return;
parseStatesInBlock(body, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
private boolean isWithStatesChain(MethodInvocation mi, Map<String, Object> argsMap) {
Expression current = mi;
Set<Expression> visited = new HashSet<>();
while (current instanceof MethodInvocation call) {
if (!visited.add(current))
break;
if (call.getName().getIdentifier().equals("withStates"))
return true;
Expression receiver = call.getExpression();
if (receiver instanceof MethodInvocation next) {
current = next;
continue;
}
Object resolved = resolve(receiver, argsMap);
if (resolved instanceof MethodInvocation next) {
current = next;
continue;
}
break;
}
return false;
}
private void parseStateChain(MethodInvocation mi, Set<String> initialStates, Set<String> endStates, Map<String, Object> argsMap) {
List<MethodInvocation> chain = new ArrayList<>();
Expression current = mi;
while (current instanceof MethodInvocation m) {
chain.addFirst(m);
current = m.getExpression();
}
CompilationUnit cu = (CompilationUnit) mi.getRoot();
for (MethodInvocation call : chain) {
String methodName = call.getName().getIdentifier();
if (call.arguments().isEmpty())
continue;
Expression arg = resolveExpression((Expression) call.arguments().get(0), argsMap);
if ("initial".equals(methodName)) {
if (!isInsideRegionOrFork(call)) {
initialStates.add(context.resolveState(arg, cu).toString());
}
} else if ("end".equals(methodName)) {
endStates.add(context.resolveState(arg, cu).toString());
}
}
}
private boolean isInsideRegionOrFork(MethodInvocation mi) {
Expression expr = mi.getExpression();
while (expr instanceof MethodInvocation parent) {
if (List.of("fork", "region").contains(parent.getName().getIdentifier()))
return true;
expr = parent.getExpression();
}
return false;
}
private MethodDeclaration findConfigureStatesMethod(TypeDeclaration td) {
for (MethodDeclaration method : td.getMethods())
if (isConfigureStatesMethod(method))
return method;
return null;
}
private boolean isConfigureStatesMethod(MethodDeclaration method) {
if (!method.getName().getIdentifier().equals("configure") || method.parameters().isEmpty())
return false;
return ((SingleVariableDeclaration) method.parameters().get(0)).getType().toString().contains("StateMachineStateConfigurer");
}
private String getSimpleName(Type type) {
if (type.isSimpleType())
return ((SimpleType) type).getName().getFullyQualifiedName();
if (type.isParameterizedType())
return getSimpleName(((ParameterizedType) type).getType());
return type.toString();
}
}

View File

@@ -1,632 +1,37 @@
package click.kamil.springstatemachineexporter.ast.app;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.model.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.Getter;
import org.eclipse.jdt.core.dom.ArrayInitializer;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.EnhancedForStatement;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.NullLiteral;
import org.eclipse.jdt.core.dom.ParameterizedType;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.TryStatement;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class StateMachineAggregator {
private final CodebaseContext context;
private enum AnalysisGoal {TRANSITIONS, STATES}
public StateMachineAggregator(CodebaseContext context) {
this.context = context;
}
public List<Transition> aggregateTransitions(TypeDeclaration startClass) {
List<Transition> allTransitions = new ArrayList<>();
Set<MethodVisit> visitedMethods = new HashSet<>();
aggregateTransitionsRecursive(startClass, startClass, allTransitions, new HashSet<>(), visitedMethods, new HashMap<>());
return allTransitions;
}
private void aggregateTransitionsRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, List<Transition> allTransitions, Set<String> visitedClasses, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (currentTd == null)
return;
String fqn = context.getFqn(currentTd);
if (visitedClasses.contains(fqn))
return;
visitedClasses.add(fqn);
for (MethodDeclaration method : currentTd.getMethods()) {
if (isConfigureTransitionsMethod(method)) {
allTransitions.addAll(parseTransitionsWithMethodCalls(method, searchStartClass, visitedMethods, argsMap));
}
}
CompilationUnit cu = (CompilationUnit) currentTd.getRoot();
// Hierarchy - Superclass
Type superclassType = currentTd.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), cu);
if (superclassTd != null) {
aggregateTransitionsRecursive(superclassTd, searchStartClass, allTransitions, visitedClasses, visitedMethods, argsMap);
}
}
// Hierarchy - Interfaces
for (Object interfaceTypeObj : currentTd.superInterfaceTypes()) {
if (interfaceTypeObj instanceof Type interfaceType) {
TypeDeclaration interfaceTd = context.getTypeDeclaration(getSimpleName(interfaceType), cu);
if (interfaceTd != null) {
aggregateTransitionsRecursive(interfaceTd, searchStartClass, allTransitions, visitedClasses, visitedMethods, argsMap);
}
}
}
}
private record MethodVisit(MethodDeclaration method, Map<String, Object> args) {
}
private List<Transition> parseTransitionsWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
MethodVisit visit = new MethodVisit(method, new HashMap<>(argsMap));
if (visitedMethods.contains(visit)) {
return new ArrayList<>();
}
visitedMethods.add(visit);
Block body = method.getBody();
if (body == null) {
return new ArrayList<>();
}
return parseTransitionsInBlock(body, searchStartClass, visitedMethods, argsMap);
}
private List<Transition> parseTransitionsInExpression(Expression expr, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
List<Transition> transitions = new ArrayList<>();
if (expr instanceof MethodInvocation mi) {
if (isTransitionChainEntry(mi)) {
transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi, convertToExpressionMap(argsMap), context));
} else if (isTransitionChainContinuation(mi, argsMap)) {
transitions.addAll(AstTransitionParser.parseTransitionsFromExpression(mi, convertToExpressionMap(argsMap), true, context));
} else if (isForEach(mi)) {
transitions.addAll(parseForEach(mi, searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
} else {
// Check if it's a call on a lambda parameter or a variable that resolved to a lambda
Expression lambdaReceiver = mi.getExpression();
if (lambdaReceiver instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof LambdaExpression lambda) {
transitions.addAll(parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
}
}
MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass);
if (calledMethod == null) {
// Try to resolve method in another class if it's a static call or on a known type
Expression miReceiver = mi.getExpression();
if (miReceiver instanceof SimpleName sn) {
TypeDeclaration otherTd = context.getTypeDeclaration(sn.getIdentifier(), (CompilationUnit) searchStartClass.getRoot());
if (otherTd != null) {
calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), otherTd);
if (calledMethod != null) {
// For calls to other classes, we switch the "searchStartClass" to that class
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, otherTd, visitedMethods, nextArgsMap));
return transitions; // Handled
}
}
}
}
if (calledMethod != null) {
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
transitions.addAll(parseTransitionsWithMethodCalls(calledMethod, searchStartClass, visitedMethods, nextArgsMap));
}
}
}
return transitions;
}
private boolean isForEach(MethodInvocation mi) {
return mi.getName().getIdentifier().equals("forEach");
}
private List<Transition> parseLambdaInvocation(LambdaExpression lambda, List<?> arguments, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
Map<String, Object> lambdaArgsMap = new HashMap<>(argsMap);
List<?> params = lambda.parameters();
for (int i = 0; i < params.size() && i < arguments.size(); i++) {
Object p = params.get(i);
String paramName = "";
if (p instanceof VariableDeclaration vp)
paramName = vp.getName().getIdentifier();
if (!paramName.isEmpty()) {
lambdaArgsMap.put(paramName, resolveExpression((Expression) arguments.get(i), argsMap));
}
}
if (goal == AnalysisGoal.TRANSITIONS) {
if (lambda.getBody() instanceof Block block)
return parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
return parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap);
} else {
if (lambda.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
parseStatesInExpression(lambdaExpr, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
}
return Collections.emptyList();
}
private List<Transition> parseForEach(MethodInvocation forEachMi, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
List<Transition> transitions = new ArrayList<>();
Expression receiver = forEachMi.getExpression();
List<Expression> elements = unrollCollection(receiver, argsMap);
if (elements.isEmpty() || forEachMi.arguments().isEmpty())
return transitions;
Object lambdaObj = forEachMi.arguments().get(0);
if (lambdaObj instanceof LambdaExpression lambda) {
String paramName = getLambdaParamName(lambda);
for (Expression element : elements) {
Map<String, Object> lambdaArgsMap = new HashMap<>(argsMap);
if (!paramName.isEmpty())
lambdaArgsMap.put(paramName, element);
if (goal == AnalysisGoal.TRANSITIONS) {
if (lambda.getBody() instanceof Block block)
transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, lambdaArgsMap));
else if (lambda.getBody() instanceof Expression lambdaExpr)
transitions.addAll(parseTransitionsInExpression(lambdaExpr, searchStartClass, visitedMethods, lambdaArgsMap));
} else {
if (lambda.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
else if (lambda.getBody() instanceof Expression lambdaExpr)
parseStatesInExpression(lambdaExpr, searchStartClass, initialStates, endStates, visitedMethods, lambdaArgsMap);
}
}
}
return transitions;
}
private List<Transition> parseEnhancedForLoop(EnhancedForStatement efs, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap, AnalysisGoal goal, Set<String> initialStates, Set<String> endStates) {
List<Transition> transitions = new ArrayList<>();
List<Expression> elements = unrollCollection(efs.getExpression(), argsMap);
String paramName = efs.getParameter().getName().getIdentifier();
for (Expression element : elements) {
Map<String, Object> loopArgsMap = new HashMap<>(argsMap);
loopArgsMap.put(paramName, element);
if (goal == AnalysisGoal.TRANSITIONS) {
if (efs.getBody() instanceof Block block)
transitions.addAll(parseTransitionsInBlock(block, searchStartClass, visitedMethods, loopArgsMap));
else if (efs.getBody() instanceof ExpressionStatement es)
transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, loopArgsMap));
} else {
if (efs.getBody() instanceof Block block)
parseStatesInBlock(block, searchStartClass, initialStates, endStates, visitedMethods, loopArgsMap);
else if (efs.getBody() instanceof ExpressionStatement es)
parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, loopArgsMap);
}
}
return transitions;
}
private List<Expression> unrollCollection(Expression expr, Map<String, Object> argsMap) {
Set<Expression> visited = new HashSet<>();
Expression current = expr;
while (current != null) {
if (!visited.add(current))
break;
if (current instanceof MethodInvocation mi) {
String name = mi.getName().getIdentifier();
Expression receiver = mi.getExpression();
if ((name.equals("of") && receiver != null && (receiver.toString().equals("Stream") || receiver.toString().equals("List")))
|| (name.equals("asList") && receiver != null && receiver.toString().equals("Arrays"))) {
if (mi.arguments().size() == 1) {
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof List list)
return (List<Expression>) list;
}
}
return (List<Expression>) mi.arguments();
}
}
if (current instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof List list)
return (List<Expression>) list;
if (resolved instanceof Expression e) {
current = e;
continue;
}
}
if (current instanceof ArrayInitializer ai)
return (List<Expression>) ai.expressions();
break;
}
return Collections.emptyList();
}
private String getLambdaParamName(LambdaExpression lambda) {
if (lambda.parameters().size() == 1) {
Object p = lambda.parameters().get(0);
if (p instanceof VariableDeclaration vp)
return vp.getName().getIdentifier();
}
return "";
}
private List<Transition> parseTransitionsInBlock(Block block, TypeDeclaration searchStartClass, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
List<Transition> transitions = new ArrayList<>();
for (Object stmt : block.statements()) {
if (stmt instanceof ExpressionStatement es)
transitions.addAll(parseTransitionsInExpression(es.getExpression(), searchStartClass, visitedMethods, argsMap));
else if (stmt instanceof VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
Expression initializer = fragment.getInitializer();
if (initializer != null) {
Object resolved = resolve(initializer, argsMap);
if (resolved != null)
argsMap.put(fragment.getName().getIdentifier(), resolved);
transitions.addAll(parseTransitionsInExpression(initializer, searchStartClass, visitedMethods, argsMap));
}
}
}
} else if (stmt instanceof EnhancedForStatement efs)
transitions.addAll(parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, AnalysisGoal.TRANSITIONS, null, null));
else if (stmt instanceof TryStatement ts)
transitions.addAll(parseTransitionsInBlock(ts.getBody(), searchStartClass, visitedMethods, argsMap));
}
return transitions;
}
private void parseStatesInBlock(Block block, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
for (Object stmt : block.statements()) {
if (stmt instanceof ExpressionStatement es)
parseStatesInExpression(es.getExpression(), searchStartClass, initialStates, endStates, visitedMethods, argsMap);
else if (stmt instanceof VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
Expression initializer = fragment.getInitializer();
if (initializer != null) {
Object resolved = resolve(initializer, argsMap);
if (resolved != null)
argsMap.put(fragment.getName().getIdentifier(), resolved);
parseStatesInExpression(initializer, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
}
} else if (stmt instanceof EnhancedForStatement efs)
parseEnhancedForLoop(efs, searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
else if (stmt instanceof TryStatement ts)
parseStatesInBlock(ts.getBody(), searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
private void parseStatesInExpression(Expression expr, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (expr instanceof MethodInvocation mi) {
if (isWithStatesChain(mi, argsMap))
parseStateChain(mi, initialStates, endStates, argsMap);
else if (isForEach(mi))
parseForEach(mi, searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
else {
// Check if it's a call on a lambda parameter or a variable that resolved to a lambda
Expression receiver = mi.getExpression();
if (receiver instanceof SimpleName sn) {
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved instanceof LambdaExpression lambda) {
parseLambdaInvocation(lambda, mi.arguments(), searchStartClass, visitedMethods, argsMap, AnalysisGoal.STATES, initialStates, endStates);
}
}
MethodDeclaration calledMethod = findMethodInHierarchy(mi.getName().getIdentifier(), searchStartClass);
if (calledMethod != null) {
Map<String, Object> nextArgsMap = buildArgsMap(calledMethod, mi, argsMap);
parseStatesWithMethodCalls(calledMethod, searchStartClass, initialStates, endStates, visitedMethods, nextArgsMap);
}
}
}
}
private Map<String, Object> buildArgsMap(MethodDeclaration method, MethodInvocation call, Map<String, Object> currentArgsMap) {
Map<String, Object> map = new HashMap<>(currentArgsMap);
List<?> params = method.parameters();
List<?> args = call.arguments();
for (int i = 0; i < params.size(); i++) {
SingleVariableDeclaration param = (SingleVariableDeclaration) params.get(i);
String paramName = param.getName().getIdentifier();
if (param.isVarargs() && i < params.size()) {
List<Expression> varargList = new ArrayList<>();
for (int j = i; j < args.size(); j++) {
Object resolved = resolve((Expression) args.get(j), currentArgsMap);
if (resolved instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Expression e)
varargList.add(e);
}
} else if (resolved instanceof Expression e) {
varargList.add(e);
}
}
map.put(paramName, varargList);
} else if (i < args.size()) {
map.put(paramName, resolve((Expression) args.get(i), currentArgsMap));
}
}
return map;
}
private Object resolve(Expression expr, Map<String, Object> argsMap) {
if (expr instanceof NullLiteral)
return null;
Set<Expression> visited = new HashSet<>();
Object current = expr;
while (current instanceof SimpleName sn) {
if (!visited.add((SimpleName) current))
break;
Object resolved = argsMap.get(sn.getIdentifier());
if (resolved == null || resolved == current)
break;
current = resolved;
}
return current;
}
private Expression resolveExpression(Expression expr, Map<String, Object> argsMap) {
Object resolved = resolve(expr, argsMap);
if (resolved instanceof Expression e)
return e;
return expr;
}
private Map<String, Expression> convertToExpressionMap(Map<String, Object> argsMap) {
Map<String, Expression> map = new HashMap<>();
for (Map.Entry<String, Object> entry : argsMap.entrySet()) {
if (entry.getValue() instanceof Expression e)
map.put(entry.getKey(), e);
}
return map;
}
private boolean isTransitionChainEntry(MethodInvocation mi) {
Expression current = mi;
while (current instanceof MethodInvocation call) {
String name = call.getName().getIdentifier();
if (TransitionType.fromMethodName(name).isPresent())
return true;
current = call.getExpression();
}
return false;
}
private boolean isTransitionChainContinuation(MethodInvocation mi, Map<String, Object> argsMap) {
Expression current = mi;
Set<Expression> visited = new HashSet<>();
while (current instanceof MethodInvocation call) {
if (!visited.add(current))
break;
String name = call.getName().getIdentifier();
if (Set.of("and", "source", "target", "event", "first", "then", "last").contains(name)) {
Expression receiver = call.getExpression();
if (receiver instanceof MethodInvocation next) {
current = next;
continue;
}
Object resolved = resolve(receiver, argsMap);
if (resolved instanceof MethodInvocation next) {
current = next;
continue;
}
return resolved == null; // null means it's a parameter we track
}
if (TransitionType.fromMethodName(name).isPresent())
return true;
break;
}
return false;
}
private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td) {
return findMethodInHierarchy(methodName, td, new HashSet<>());
}
private MethodDeclaration findMethodInHierarchy(String methodName, TypeDeclaration td, Set<String> visited) {
if (td == null)
return null;
String fqn = context.getFqn(td);
if (visited.contains(fqn))
return null;
visited.add(fqn);
for (MethodDeclaration method : td.getMethods())
if (method.getName().getIdentifier().equals(methodName))
return method;
Type superclassType = td.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), (CompilationUnit) td.getRoot());
return findMethodInHierarchy(methodName, superclassTd, visited);
}
return null;
}
private boolean isRelevantMethod(MethodDeclaration method) {
return true;
}
private MethodDeclaration findConfigureTransitionsMethod(TypeDeclaration td) {
for (MethodDeclaration method : td.getMethods())
if (isConfigureTransitionsMethod(method))
return method;
return null;
}
private boolean isConfigureTransitionsMethod(MethodDeclaration method) {
if (!method.getName().getIdentifier().equals("configure") || method.parameters().isEmpty())
return false;
return ((SingleVariableDeclaration) method.parameters().get(0)).getType().toString().contains("StateMachineTransitionConfigurer");
}
private final StateMachineAdapterParser adapterParser;
@Getter
private final Set<String> initialStates = new HashSet<>();
@Getter
private final Set<String> endStates = new HashSet<>();
public StateMachineAggregator(CodebaseContext context) {
this.context = context;
this.adapterParser = new StateMachineAdapterParser(context);
}
public List<Transition> aggregateTransitions(TypeDeclaration startClass) {
return adapterParser.parseTransitions(startClass);
}
public void aggregateStates(TypeDeclaration startClass) {
Set<MethodVisit> visitedMethods = new HashSet<>();
aggregateStatesRecursive(startClass, startClass, initialStates, endStates, new HashSet<>(), visitedMethods, new HashMap<>());
}
private void aggregateStatesRecursive(TypeDeclaration currentTd, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<String> visitedClasses, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
if (currentTd == null)
return;
String fqn = context.getFqn(currentTd);
if (visitedClasses.contains(fqn))
return;
visitedClasses.add(fqn);
for (MethodDeclaration method : currentTd.getMethods()) {
if (isConfigureStatesMethod(method)) {
parseStatesWithMethodCalls(method, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
}
CompilationUnit cu = (CompilationUnit) currentTd.getRoot();
// Hierarchy - Superclass
Type superclassType = currentTd.getSuperclassType();
if (superclassType != null) {
TypeDeclaration superclassTd = context.getTypeDeclaration(getSimpleName(superclassType), cu);
if (superclassTd != null)
aggregateStatesRecursive(superclassTd, searchStartClass, initialStates, endStates, visitedClasses, visitedMethods, argsMap);
}
// Hierarchy - Interfaces
for (Object interfaceTypeObj : currentTd.superInterfaceTypes()) {
if (interfaceTypeObj instanceof Type interfaceType) {
TypeDeclaration interfaceTd = context.getTypeDeclaration(getSimpleName(interfaceType), cu);
if (interfaceTd != null)
aggregateStatesRecursive(interfaceTd, searchStartClass, initialStates, endStates, visitedClasses, visitedMethods, argsMap);
}
}
}
private void parseStatesWithMethodCalls(MethodDeclaration method, TypeDeclaration searchStartClass, Set<String> initialStates, Set<String> endStates, Set<MethodVisit> visitedMethods, Map<String, Object> argsMap) {
MethodVisit visit = new MethodVisit(method, new HashMap<>(argsMap));
if (visitedMethods.contains(visit))
return;
visitedMethods.add(visit);
Block body = method.getBody();
if (body == null)
return;
parseStatesInBlock(body, searchStartClass, initialStates, endStates, visitedMethods, argsMap);
}
private boolean isWithStatesChain(MethodInvocation mi, Map<String, Object> argsMap) {
Expression current = mi;
Set<Expression> visited = new HashSet<>();
while (current instanceof MethodInvocation call) {
if (!visited.add(current))
break;
if (call.getName().getIdentifier().equals("withStates"))
return true;
Expression receiver = call.getExpression();
if (receiver instanceof MethodInvocation next) {
current = next;
continue;
}
Object resolved = resolve(receiver, argsMap);
if (resolved instanceof MethodInvocation next) {
current = next;
continue;
}
break;
}
return false;
}
private void parseStateChain(MethodInvocation mi, Set<String> initialStates, Set<String> endStates, Map<String, Object> argsMap) {
List<MethodInvocation> chain = new ArrayList<>();
Expression current = mi;
while (current instanceof MethodInvocation m) {
chain.addFirst(m);
current = m.getExpression();
}
CompilationUnit cu = (CompilationUnit) mi.getRoot();
for (MethodInvocation call : chain) {
String methodName = call.getName().getIdentifier();
if (call.arguments().isEmpty())
continue;
Expression arg = resolveExpression((Expression) call.arguments().get(0), argsMap);
if ("initial".equals(methodName)) {
if (!isInsideRegionOrFork(call)) {
initialStates.add(context.resolveState(arg, cu).toString());
}
} else if ("end".equals(methodName)) {
endStates.add(context.resolveState(arg, cu).toString());
}
}
}
private boolean isInsideRegionOrFork(MethodInvocation mi) {
Expression expr = mi.getExpression();
while (expr instanceof MethodInvocation parent) {
if (List.of("fork", "region").contains(parent.getName().getIdentifier()))
return true;
expr = parent.getExpression();
}
return false;
}
private MethodDeclaration findConfigureStatesMethod(TypeDeclaration td) {
for (MethodDeclaration method : td.getMethods())
if (isConfigureStatesMethod(method))
return method;
return null;
}
private boolean isConfigureStatesMethod(MethodDeclaration method) {
if (!method.getName().getIdentifier().equals("configure") || method.parameters().isEmpty())
return false;
return ((SingleVariableDeclaration) method.parameters().get(0)).getType().toString().contains("StateMachineStateConfigurer");
}
private String getSimpleName(Type type) {
if (type.isSimpleType())
return ((SimpleType) type).getName().getFullyQualifiedName();
if (type.isParameterizedType())
return getSimpleName(((ParameterizedType) type).getType());
return type.toString();
StateMachineAdapterParser.AdapterStates states = adapterParser.parseStates(startClass);
this.initialStates.clear();
this.initialStates.addAll(states.initialStates());
this.endStates.clear();
this.endStates.addAll(states.endStates());
}
}

View File

@@ -44,6 +44,23 @@ public class TransitionStateUtils {
.collect(Collectors.toSet());
}
public static Set<State> findAllStates(Collection<Transition> transitions, Set<String> initialStates, Set<String> endStates) {
Set<State> allStates = new java.util.HashSet<>();
if (transitions != null) {
transitions.forEach(t -> {
if (t.getSourceStates() != null) allStates.addAll(t.getSourceStates());
if (t.getTargetStates() != null) allStates.addAll(t.getTargetStates());
});
}
if (initialStates != null) {
initialStates.forEach(s -> allStates.add(State.of(s)));
}
if (endStates != null) {
endStates.forEach(s -> allStates.add(State.of(s)));
}
return allStates;
}
private static Stream<String> normalizeStates(Collection<State> states) {
return states.stream()
.map(State::toString)

View File

@@ -36,4 +36,85 @@ public final class AstUtils {
return type.toString(); // fallback
}
}
public static boolean isFunctionalCollectionMethod(org.eclipse.jdt.core.dom.MethodInvocation node) {
if (node.getExpression() == null) {
return false;
}
org.eclipse.jdt.core.dom.ITypeBinding typeBinding = node.getExpression().resolveTypeBinding();
if (typeBinding != null) {
if (isCollectionOrStreamType(typeBinding)) {
return true;
}
return false;
}
String methodName = node.getName().getIdentifier();
if (methodName.equals("forEach") || methodName.equals("map") ||
methodName.equals("filter") || methodName.equals("flatMap") ||
methodName.equals("anyMatch") || methodName.equals("allMatch") ||
methodName.equals("noneMatch") || methodName.equals("reduce") ||
methodName.equals("collect") || methodName.equals("removeIf") ||
methodName.equals("ifPresent") || methodName.equals("ifPresentOrElse") ||
methodName.equals("compute") || methodName.equals("computeIfAbsent") ||
methodName.equals("computeIfPresent") || methodName.equals("replaceAll") ||
methodName.equals("merge") || methodName.equals("peek") ||
methodName.equals("doOnNext") || methodName.equals("doOnSuccess") ||
methodName.equals("doOnError") || methodName.equals("concatMap") ||
methodName.equals("switchMap")) {
return true;
}
return false;
}
private static boolean isCollectionOrStreamType(org.eclipse.jdt.core.dom.ITypeBinding typeBinding) {
if (typeBinding == null) return false;
String fqn = typeBinding.getErasure().getQualifiedName();
if (fqn.startsWith("java.util.stream.") ||
fqn.equals("java.lang.Iterable") ||
fqn.equals("java.util.Collection") ||
fqn.equals("java.util.List") ||
fqn.equals("java.util.Set") ||
fqn.equals("java.util.Map") ||
fqn.equals("java.util.Iterator") ||
fqn.equals("java.util.Optional") ||
fqn.equals("reactor.core.publisher.Flux") ||
fqn.equals("reactor.core.publisher.Mono")) {
return true;
}
for (org.eclipse.jdt.core.dom.ITypeBinding intf : typeBinding.getInterfaces()) {
if (isCollectionOrStreamType(intf)) return true;
}
if (typeBinding.getSuperclass() != null) {
return isCollectionOrStreamType(typeBinding.getSuperclass());
}
return false;
}
public static String findConditionConstraint(org.eclipse.jdt.core.dom.ASTNode node) {
java.util.List<String> conditions = new java.util.ArrayList<>();
org.eclipse.jdt.core.dom.ASTNode current = node;
while (current != null && !(current instanceof org.eclipse.jdt.core.dom.MethodDeclaration)) {
org.eclipse.jdt.core.dom.ASTNode parent = current.getParent();
if (parent instanceof org.eclipse.jdt.core.dom.IfStatement ifStmt) {
if (ifStmt.getExpression() != current) {
org.eclipse.jdt.core.dom.Expression cond = ifStmt.getExpression();
if (current == ifStmt.getElseStatement()) {
conditions.add("!(" + cond.toString() + ")");
} else {
conditions.add(cond.toString());
}
}
}
current = parent;
}
if (conditions.isEmpty()) return null;
java.util.Collections.reverse(conditions);
return String.join(" && ", conditions);
}
}

View File

@@ -0,0 +1,108 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.*;
import java.util.Set;
public final class BindingResolver {
private BindingResolver() {
// Prevent instantiation
}
/**
* Resolves the type binding for a given AST Type node.
*/
public static ITypeBinding resolveType(Type type) {
if (type == null) return null;
return type.resolveBinding();
}
/**
* Resolves the type binding for a given AST Expression node.
*/
public static ITypeBinding resolveType(Expression expression) {
if (expression == null) return null;
return expression.resolveTypeBinding();
}
/**
* Resolves the method binding for a MethodInvocation.
*/
public static IMethodBinding resolveMethod(MethodInvocation invocation) {
if (invocation == null) return null;
return invocation.resolveMethodBinding();
}
/**
* Resolves the constructor binding for a ClassInstanceCreation.
*/
public static IMethodBinding resolveConstructor(ClassInstanceCreation creation) {
if (creation == null) return null;
return creation.resolveConstructorBinding();
}
/**
* Resolves the constructor binding for a ConstructorInvocation (e.g. this(...)).
*/
public static IMethodBinding resolveConstructor(ConstructorInvocation invocation) {
if (invocation == null) return null;
return invocation.resolveConstructorBinding();
}
/**
* Resolves the constructor binding for a SuperConstructorInvocation (e.g. super(...)).
*/
public static IMethodBinding resolveConstructor(SuperConstructorInvocation invocation) {
if (invocation == null) return null;
return invocation.resolveConstructorBinding();
}
/**
* Resolves the type binding of a TypeDeclaration.
*/
public static ITypeBinding resolveType(TypeDeclaration td) {
if (td == null) return null;
return td.resolveBinding();
}
/**
* Gets the fully qualified name of a type binding, safely handling nulls and erasure.
*/
public static String getFullyQualifiedName(ITypeBinding binding) {
if (binding == null) return null;
return binding.getErasure().getQualifiedName();
}
public static boolean isSubtypeOf(ITypeBinding childBinding, String targetFqn) {
return isSubtypeOf(childBinding, targetFqn, new java.util.HashSet<>());
}
private static boolean isSubtypeOf(ITypeBinding childBinding, String targetFqn, Set<String> visited) {
if (childBinding == null || targetFqn == null) return false;
String key = childBinding.getKey();
if (key != null && !visited.add(key)) {
return false;
}
// Direct match
if (targetFqn.equals(childBinding.getErasure().getQualifiedName())) {
return true;
}
// Check interfaces
for (ITypeBinding intf : childBinding.getInterfaces()) {
if (isSubtypeOf(intf, targetFqn, visited)) {
return true;
}
}
// Check superclass
ITypeBinding superclass = childBinding.getSuperclass();
if (superclass != null) {
return isSubtypeOf(superclass, targetFqn, visited);
}
return false;
}
}

View File

@@ -0,0 +1,170 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class CfgBuilder {
public static ControlFlowGraph build(MethodDeclaration method) {
ControlFlowGraph cfg = new ControlFlowGraph(method);
if (method.getBody() == null) {
cfg.getEntryNode().addSuccessor(cfg.getExitNode());
return cfg;
}
// Build CFG recursively
List<ControlFlowGraph.CfgNode> exits = buildFlow(method.getBody(), List.of(cfg.getEntryNode()), cfg);
for (ControlFlowGraph.CfgNode exit : exits) {
exit.addSuccessor(cfg.getExitNode());
}
return cfg;
}
private static List<ControlFlowGraph.CfgNode> buildFlow(
ASTNode node,
List<ControlFlowGraph.CfgNode> sources,
ControlFlowGraph cfg) {
if (node == null || sources.isEmpty()) {
return sources;
}
if (node instanceof Block block) {
List<ControlFlowGraph.CfgNode> currentSources = sources;
for (Object stmtObj : block.statements()) {
currentSources = buildFlow((ASTNode) stmtObj, currentSources, cfg);
}
return currentSources;
}
if (node instanceof IfStatement ifStmt) {
// Condition node
ControlFlowGraph.CfgNode condNode = new ControlFlowGraph.CfgNode(ifStmt.getExpression(), ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(condNode);
for (ControlFlowGraph.CfgNode src : sources) {
src.addSuccessor(condNode);
}
List<ControlFlowGraph.CfgNode> thenExits = buildFlow(ifStmt.getThenStatement(), List.of(condNode), cfg);
List<ControlFlowGraph.CfgNode> elseExits;
if (ifStmt.getElseStatement() != null) {
elseExits = buildFlow(ifStmt.getElseStatement(), List.of(condNode), cfg);
} else {
elseExits = List.of(condNode);
}
List<ControlFlowGraph.CfgNode> combinedExits = new ArrayList<>();
combinedExits.addAll(thenExits);
combinedExits.addAll(elseExits);
return combinedExits;
}
if (node instanceof WhileStatement whileStmt) {
ControlFlowGraph.CfgNode condNode = new ControlFlowGraph.CfgNode(whileStmt.getExpression(), ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(condNode);
for (ControlFlowGraph.CfgNode src : sources) {
src.addSuccessor(condNode);
}
List<ControlFlowGraph.CfgNode> bodyExits = buildFlow(whileStmt.getBody(), List.of(condNode), cfg);
for (ControlFlowGraph.CfgNode exit : bodyExits) {
exit.addSuccessor(condNode); // Loop back
}
return List.of(condNode); // Exit loop when cond is false
}
if (node instanceof DoStatement doStmt) {
ControlFlowGraph.CfgNode condNode = new ControlFlowGraph.CfgNode(doStmt.getExpression(), ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(condNode);
List<ControlFlowGraph.CfgNode> bodySources = new ArrayList<>(sources);
bodySources.add(condNode);
List<ControlFlowGraph.CfgNode> bodyExits = buildFlow(doStmt.getBody(), bodySources, cfg);
for (ControlFlowGraph.CfgNode exit : bodyExits) {
exit.addSuccessor(condNode);
}
return List.of(condNode);
}
if (node instanceof ForStatement forStmt) {
// Initializers
List<ControlFlowGraph.CfgNode> currentSources = sources;
for (Object initObj : forStmt.initializers()) {
ControlFlowGraph.CfgNode initNode = new ControlFlowGraph.CfgNode((ASTNode) initObj, ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(initNode);
for (ControlFlowGraph.CfgNode src : currentSources) {
src.addSuccessor(initNode);
}
currentSources = List.of(initNode);
}
// Condition
ControlFlowGraph.CfgNode condNode;
if (forStmt.getExpression() != null) {
condNode = new ControlFlowGraph.CfgNode(forStmt.getExpression(), ControlFlowGraph.CfgNodeType.STATEMENT);
} else {
condNode = new ControlFlowGraph.CfgNode(forStmt, ControlFlowGraph.CfgNodeType.STATEMENT); // Dummy node
}
cfg.addNode(condNode);
for (ControlFlowGraph.CfgNode src : currentSources) {
src.addSuccessor(condNode);
}
List<ControlFlowGraph.CfgNode> bodyExits = buildFlow(forStmt.getBody(), List.of(condNode), cfg);
// Updaters
List<ControlFlowGraph.CfgNode> updaterSources = bodyExits;
for (Object updateObj : forStmt.updaters()) {
ControlFlowGraph.CfgNode updateNode = new ControlFlowGraph.CfgNode((ASTNode) updateObj, ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(updateNode);
for (ControlFlowGraph.CfgNode src : updaterSources) {
src.addSuccessor(updateNode);
}
updaterSources = List.of(updateNode);
}
for (ControlFlowGraph.CfgNode src : updaterSources) {
src.addSuccessor(condNode); // Loop back
}
return List.of(condNode);
}
if (node instanceof EnhancedForStatement effStmt) {
ControlFlowGraph.CfgNode loopNode = new ControlFlowGraph.CfgNode(effStmt.getParameter(), ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(loopNode);
for (ControlFlowGraph.CfgNode src : sources) {
src.addSuccessor(loopNode);
}
List<ControlFlowGraph.CfgNode> bodyExits = buildFlow(effStmt.getBody(), List.of(loopNode), cfg);
for (ControlFlowGraph.CfgNode exit : bodyExits) {
exit.addSuccessor(loopNode); // Loop back
}
return List.of(loopNode);
}
if (node instanceof ReturnStatement returnStmt) {
ControlFlowGraph.CfgNode retNode = new ControlFlowGraph.CfgNode(returnStmt, ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(retNode);
for (ControlFlowGraph.CfgNode src : sources) {
src.addSuccessor(retNode);
}
// Return flows to exit, not to subsequent sequential nodes
retNode.addSuccessor(cfg.getExitNode());
return Collections.emptyList();
}
// Default: general statement
ControlFlowGraph.CfgNode stmtNode = new ControlFlowGraph.CfgNode(node, ControlFlowGraph.CfgNodeType.STATEMENT);
cfg.addNode(stmtNode);
for (ControlFlowGraph.CfgNode src : sources) {
src.addSuccessor(stmtNode);
}
return List.of(stmtNode);
}
}

View File

@@ -1,107 +1,397 @@
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.analysis.util.PlaceholderResolver;
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.JavaCore;
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;
import java.util.Map;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CodebaseContext {
private final Map<String, CompilationUnit> classes = new HashMap<>();
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 StateResolver stateResolver = new StateResolver(this);
private final PropertyResolver propertyResolver = new PropertyResolver();
public ConstantResolver getConstantResolver() {
return constantResolver;
}
private final List<String> activeProfiles = new ArrayList<>();
private Map<String, Map<String, String>> allProperties = new HashMap<>();
private List<LibraryHint> libraryHints = new ArrayList<>();
private final ObjectMapper objectMapper = new ObjectMapper();
private Path projectRoot;
public void setProjectRoot(Path root) {
this.projectRoot = root;
}
public String getRelativePath(Path fullPath) {
if (projectRoot != null && fullPath.isAbsolute() && fullPath.startsWith(projectRoot)) {
return projectRoot.relativize(fullPath).toString();
}
return fullPath.getFileName().toString();
}
public String getRelativePath(String fqn) {
Path path = classPaths.get(fqn);
return path != null ? getRelativePath(path) : null;
}
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/classes/**", "**/build/libs/**", "**/build/tmp/**", "**/build/reports/**", "**/build/test-results/**", "**/node_modules/**", "**/out/**"));
private String[] classpath = new String[0];
private String[] sourcepath = new String[0];
private boolean resolveBindings = false;
public Map<String, Map<String, String>> getProperties() {
return Collections.unmodifiableMap(allProperties);
}
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.activeProfiles.clear();
if (profiles != null) {
this.activeProfiles.addAll(profiles);
}
this.propertyResolver.setActiveProfiles(profiles);
}
public String resolveString(String input) {
if (input == null) return null;
Map<String, String> merged = new HashMap<>();
// 1. Default properties
Map<String, String> defaultProps = allProperties.get("default");
if (defaultProps != null) {
merged.putAll(defaultProps);
}
// 2. Active profiles (later ones override earlier ones)
for (String profile : activeProfiles) {
Map<String, String> profileProps = allProperties.get(profile);
if (profileProps != null) {
merged.putAll(profileProps);
}
}
return PlaceholderResolver.resolve(input, merged);
}
public String resolveExpression(Expression expr) {
if (expr == null) return null;
String astResolved = constantResolver.resolve(expr, this);
if (astResolved == null) {
astResolved = expr.toString().replaceAll("^\"|\"$", "");
}
return resolveString(astResolved);
}
public TypeDeclaration resolveStaticImport(String memberName, CompilationUnit contextCu) {
if (contextCu == null) return null;
for (Object impObj : contextCu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
if (imp.isStatic()) {
String impName = imp.getName().getFullyQualifiedName();
if (imp.isOnDemand()) {
// Star import: import static com.example.C.*;
TypeDeclaration td = getTypeDeclaration(impName, contextCu);
if (td != null && (hasField(td, memberName) || hasMethod(td, memberName))) {
return td;
}
} else {
// Single import: import static com.example.C.A;
if (impName.endsWith("." + memberName)) {
String typeFqn = impName.substring(0, impName.lastIndexOf('.'));
return getTypeDeclaration(typeFqn, contextCu);
}
}
}
}
return null;
}
public boolean hasMethod(TypeDeclaration td, String name) {
for (MethodDeclaration md : td.getMethods()) {
if (md.getName().getIdentifier().equals(name)) {
return true;
}
}
return false;
}
public boolean hasField(TypeDeclaration td, String name) {
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(name)) {
return true;
}
}
}
}
return false;
}
public void scan(Path rootDir) throws IOException {
List<Path> javaFiles = FileUtils.findFilesWithExtension(Set.of(rootDir), ".java");
scan(Set.of(rootDir), Collections.emptySet());
}
private final List<CompilationUnit> allCus = new ArrayList<>();
private final Map<String, List<String>> interfaceToImpls = new HashMap<>();
private final Map<String, List<String>> enumValues = new HashMap<>();
public void scan(Set<Path> rootDirs, Set<String> customIgnorePatterns) throws IOException {
this.allProperties = propertyResolver.resolveAllProperties(rootDirs);
Set<String> activeIgnore = new HashSet<>(ignorePatterns);
activeIgnore.addAll(customIgnorePatterns);
List<Path> javaFiles = FileUtils.findFilesWithExtension(rootDirs, ".java", activeIgnore);
log.info("CodebaseContext found {} java files across {} root directories", javaFiles.size(), rootDirs.size());
for (Path javaFile : javaFiles) {
String source = Files.readString(javaFile);
CompilationUnit cu = parse(source);
CompilationUnit cu = parse(source, javaFile.toAbsolutePath().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);
} else if (type instanceof EnumDeclaration ed) {
indexEnum(ed, packageName, javaFile);
} else if (type instanceof org.eclipse.jdt.core.dom.RecordDeclaration rd) {
indexRecord(rd, packageName, javaFile);
}
}
}
}
private void indexType(TypeDeclaration td, String parentFqn, Path javaFile) {
String simpleName = td.getName().getIdentifier();
String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + 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
// Track implementations (for both interfaces and superclasses)
if (td.superInterfaceTypes() != null) {
for (Object itfObj : td.superInterfaceTypes()) {
Type itf = (Type) itfObj;
String itfName = extractTypeName(itf);
interfaceToImpls.computeIfAbsent(itfName, k -> new ArrayList<>()).add(fqn);
}
}
Type superclass = td.getSuperclassType();
if (superclass != null) {
String superName = extractTypeName(superclass);
interfaceToImpls.computeIfAbsent(superName, k -> new ArrayList<>()).add(fqn);
}
// Recursively index nested types
for (Object decl : td.bodyDeclarations()) {
if (decl instanceof TypeDeclaration nestedTd) {
indexType(nestedTd, fqn, javaFile);
} else if (decl instanceof EnumDeclaration nestedEd) {
indexEnum(nestedEd, fqn, javaFile);
} else if (decl instanceof org.eclipse.jdt.core.dom.RecordDeclaration nestedRd) {
indexRecord(nestedRd, fqn, javaFile);
}
}
}
private void indexRecord(org.eclipse.jdt.core.dom.RecordDeclaration rd, String parentFqn, Path javaFile) {
String simpleName = rd.getName().getIdentifier();
String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName;
// Treat as a class since it is a type
classes.put(fqn, (CompilationUnit) rd.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);
}
// Recursively index nested types
for (Object decl : rd.bodyDeclarations()) {
if (decl instanceof TypeDeclaration nestedTd) {
indexType(nestedTd, fqn, javaFile);
} else if (decl instanceof EnumDeclaration nestedEd) {
indexEnum(nestedEd, fqn, javaFile);
} else if (decl instanceof org.eclipse.jdt.core.dom.RecordDeclaration nestedRd) {
indexRecord(nestedRd, fqn, javaFile);
}
}
}
public List<String> getImplementations(String interfaceName) {
Set<String> allImpls = new HashSet<>();
collectImplementations(interfaceName, allImpls, new HashSet<>());
System.out.println("GET IMPLEMENTATIONS FOR: " + interfaceName + " -> " + allImpls);
return new ArrayList<>(allImpls);
}
private void collectImplementations(String typeName, Set<String> results, Set<String> visited) {
if (!visited.add(typeName)) return;
// Try direct match
List<String> directImpls = interfaceToImpls.get(typeName);
// Try FQN match if input was simple name
if (directImpls == null) {
String fqn = simpleNameToFqn.get(typeName);
if (fqn != null) {
directImpls = interfaceToImpls.get(fqn);
}
}
// Try simple name match if input was FQN
if (directImpls == null && typeName.contains(".")) {
String simpleName = typeName.substring(typeName.lastIndexOf('.') + 1);
directImpls = interfaceToImpls.get(simpleName);
}
if (directImpls != null) {
for (String impl : directImpls) {
results.add(impl);
// Recursively find implementations of the implementation (e.g., subclasses of an abstract class)
collectImplementations(impl, results, visited);
}
}
}
private void indexEnum(EnumDeclaration ed, String parentFqn, Path javaFile) {
String simpleName = ed.getName().getIdentifier();
String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName;
classes.put(fqn, (CompilationUnit) ed.getRoot());
classPaths.put(fqn, javaFile);
List<String> values = new ArrayList<>();
for (Object o : ed.enumConstants()) {
if (o instanceof EnumConstantDeclaration ecd) {
values.add(fqn + "." + ecd.getName().getIdentifier());
}
}
enumValues.put(fqn, values);
if (!simpleNameToFqn.containsKey(simpleName)) {
simpleNameToFqn.put(simpleName, fqn);
}
// Recursively index nested types inside enum body
for (Object decl : ed.bodyDeclarations()) {
if (decl instanceof TypeDeclaration nestedTd) {
indexType(nestedTd, fqn, javaFile);
} else if (decl instanceof EnumDeclaration nestedEd) {
indexEnum(nestedEd, fqn, javaFile);
} else if (decl instanceof org.eclipse.jdt.core.dom.RecordDeclaration nestedRd) {
indexRecord(nestedRd, fqn, javaFile);
}
}
}
public Map<String, List<String>> getEnumValuesMap() {
return enumValues;
}
public List<String> getEnumValues(String fqnOrSimpleName) {
if (fqnOrSimpleName == null) return null;
String cleanName = fqnOrSimpleName;
if (cleanName.contains("<")) {
cleanName = cleanName.substring(0, cleanName.indexOf('<'));
}
if (cleanName.contains("[")) {
cleanName = cleanName.substring(0, cleanName.indexOf('['));
}
cleanName = cleanName.trim();
List<String> values = enumValues.get(cleanName);
if (values != null) return values;
String fqn = simpleNameToFqn.get(cleanName);
if (fqn != null) return enumValues.get(fqn);
return null;
}
public State resolveState(Expression expr, CompilationUnit cu) {
if (expr == null)
return null;
if (expr instanceof StringLiteral sl)
return State.of(sl.getLiteralValue());
String raw = expr.toString();
if (expr instanceof QualifiedName qn) {
String full = resolveQualifiedName(qn, cu);
return State.of(raw, full);
}
if (expr instanceof SimpleName sn) {
String full = resolveSimpleName(sn, cu);
return State.of(raw, full);
}
return State.of(raw);
return stateResolver.resolveState(expr, cu);
}
private String resolveQualifiedName(QualifiedName qn, CompilationUnit cu) {
String qualifier = qn.getQualifier().toString();
String name = qn.getName().getIdentifier();
TypeDeclaration td = getTypeDeclaration(qualifier, cu);
if (td != null)
return getFqn(td) + "." + name;
return qn.getFullyQualifiedName();
}
private String resolveSimpleName(SimpleName sn, CompilationUnit cu) {
String name = sn.getIdentifier();
if (cu == null)
return name;
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + name))
return impName;
}
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);
parser.setBindingsRecovery(true);
Map<String, String> options = JavaCore.getOptions();
JavaCore.setComplianceOptions(JavaCore.VERSION_21, options);
parser.setCompilerOptions(options);
if (resolveBindings) {
parser.setUnitName(unitName);
parser.setEnvironment(classpath, sourcepath, null, true);
}
return (CompilationUnit) parser.createAST(null);
}
@@ -138,6 +428,14 @@ public class CodebaseContext {
// 2. Check imports in contextCu
if (contextCu != null) {
for (Object typeObj : contextCu.types()) {
if (typeObj instanceof TypeDeclaration tdDecl) {
if (tdDecl.getName().getIdentifier().equals(name)) {
return tdDecl;
}
}
}
for (Object impObj : contextCu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
@@ -173,26 +471,241 @@ public class CodebaseContext {
return getTypeDeclaration(name);
}
public String getFqn(TypeDeclaration td) {
CompilationUnit cu = (CompilationUnit) td.getRoot();
public String getSuperclassFqn(TypeDeclaration td) {
if (td == null) return null;
ITypeBinding binding = td.resolveBinding();
if (binding != null) {
ITypeBinding superBinding = binding.getSuperclass();
if (superBinding != null) {
return superBinding.getErasure().getQualifiedName();
}
}
Type superType = td.getSuperclassType();
if (superType == null) return null;
String superName = extractTypeName(superType);
CompilationUnit cu = (td.getRoot() instanceof CompilationUnit) ? (CompilationUnit) td.getRoot() : null;
TypeDeclaration superTd = getTypeDeclaration(superName, cu);
return superTd != null ? getFqn(superTd) : superName;
}
private String extractTypeName(Type type) {
if (type.isParameterizedType()) {
return extractTypeName(((ParameterizedType) type).getType());
} else if (type.isSimpleType()) {
return ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isQualifiedType()) {
return ((QualifiedType) type).getName().getFullyQualifiedName();
}
return type.toString();
}
public MethodDeclaration findMethodDeclaration(TypeDeclaration td, String methodName, boolean includeSuper) {
if (td == null) return null;
ITypeBinding binding = td.resolveBinding();
if (binding != null && includeSuper) {
IMethodBinding methodBinding = findMethodBinding(binding, methodName);
if (methodBinding != null && methodBinding.getDeclaringClass() != null) {
CompilationUnit declaringCu = classes.get(methodBinding.getDeclaringClass().getErasure().getQualifiedName());
if (declaringCu != null) {
ASTNode declNode = declaringCu.findDeclaringNode(methodBinding.getKey());
if (declNode instanceof MethodDeclaration) {
return (MethodDeclaration) declNode;
}
}
}
}
return findMethodDeclarationLegacy(td, methodName, includeSuper);
}
private IMethodBinding findMethodBinding(ITypeBinding typeBinding, String methodName) {
return findMethodBinding(typeBinding, methodName, new java.util.HashSet<>());
}
private IMethodBinding findMethodBinding(ITypeBinding typeBinding, String methodName, Set<String> visited) {
if (typeBinding == null) return null;
String key = typeBinding.getKey();
if (key != null && !visited.add(key)) {
return null;
}
for (IMethodBinding mb : typeBinding.getDeclaredMethods()) {
if (mb.getName().equals(methodName)) {
return mb;
}
}
ITypeBinding superclass = typeBinding.getSuperclass();
if (superclass != null) {
IMethodBinding mb = findMethodBinding(superclass, methodName, visited);
if (mb != null) return mb;
}
for (ITypeBinding intf : typeBinding.getInterfaces()) {
IMethodBinding mb = findMethodBinding(intf, methodName, visited);
if (mb != null) return mb;
}
return null;
}
private MethodDeclaration findMethodDeclarationLegacy(TypeDeclaration td, String methodName, boolean includeSuper) {
if (td == null) return null;
for (MethodDeclaration method : td.getMethods()) {
if (method.getName().getIdentifier().equals(methodName)) {
return method;
}
}
if (includeSuper) {
String superFqn = getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = getTypeDeclaration(superFqn);
if (superTd != null) {
return findMethodDeclarationLegacy(superTd, methodName, true);
}
}
// Check interfaces
for (Object interfaceTypeObj : td.superInterfaceTypes()) {
Type interfaceType = (Type) interfaceTypeObj;
String interfaceName = extractTypeName(interfaceType);
TypeDeclaration interfaceTd = getTypeDeclaration(interfaceName, (td.getRoot() instanceof CompilationUnit) ? (CompilationUnit) td.getRoot() : null);
if (interfaceTd != null) {
MethodDeclaration found = findMethodDeclarationLegacy(interfaceTd, methodName, true);
if (found != null) return found;
}
}
}
return null;
}
public AbstractTypeDeclaration getAbstractTypeDeclaration(String name) {
// Try exact FQN match first
CompilationUnit cu = classes.get(name);
if (cu != null)
return findAbstractTypeInCu(cu, name);
// If it's a simple name, check if it's ambiguous
if (ambiguousSimpleNames.contains(name)) {
return null;
}
// If not ambiguous, return the one we found during scan
String fqn = simpleNameToFqn.get(name);
if (fqn != null) {
cu = classes.get(fqn);
if (cu != null)
return findAbstractTypeInCu(cu, fqn);
}
return null;
}
public AbstractTypeDeclaration getAbstractTypeDeclaration(String name, CompilationUnit contextCu) {
if (name == null || name.isEmpty())
return null;
// 1. Check if it's already an FQN
if (classes.containsKey(name))
return getAbstractTypeDeclaration(name);
// 2. Check imports in contextCu
if (contextCu != null) {
for (Object impObj : contextCu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (!imp.isStatic() && !imp.isOnDemand()) {
if (impName.endsWith("." + name))
return getAbstractTypeDeclaration(impName);
}
}
// 3. Check same package
String packageName = contextCu.getPackage() != null ? contextCu.getPackage().getName().getFullyQualifiedName() : "";
String localFqn = packageName.isEmpty() ? name : packageName + "." + name;
if (classes.containsKey(localFqn))
return getAbstractTypeDeclaration(localFqn);
// 4. Check on-demand imports (star imports)
for (Object impObj : contextCu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
if (imp.isOnDemand()) {
String starFqn = imp.getName().getFullyQualifiedName() + "." + name;
if (classes.containsKey(starFqn))
return getAbstractTypeDeclaration(starFqn);
}
}
// 5. Fallback to java.lang (common)
String langFqn = "java.lang." + name;
if (classes.containsKey(langFqn))
return getAbstractTypeDeclaration(langFqn);
}
// 6. Last resort: global simple name match
return getAbstractTypeDeclaration(name);
}
private AbstractTypeDeclaration findAbstractTypeInCu(CompilationUnit cu, String fqn) {
for (Object type : cu.types()) {
if (type instanceof AbstractTypeDeclaration atd) {
AbstractTypeDeclaration found = findNestedAbstractType(atd, fqn);
if (found != null) return found;
}
}
return null;
}
private AbstractTypeDeclaration findNestedAbstractType(AbstractTypeDeclaration atd, String targetFqn) {
if (getFqn(atd).equals(targetFqn)) return atd;
if (atd instanceof TypeDeclaration td) {
for (TypeDeclaration nested : td.getTypes()) {
AbstractTypeDeclaration found = findNestedAbstractType(nested, targetFqn);
if (found != null) return found;
}
}
return null;
}
public String getFqn(AbstractTypeDeclaration atd) {
if (atd == null) return null;
StringBuilder sb = new StringBuilder(atd.getName().getIdentifier());
ASTNode current = atd.getParent();
while (current instanceof AbstractTypeDeclaration parent) {
sb.insert(0, parent.getName().getIdentifier() + ".");
current = parent.getParent();
}
CompilationUnit cu = (atd.getRoot() instanceof CompilationUnit) ? (CompilationUnit) atd.getRoot() : null;
if (cu == null) {
return sb.toString();
}
String packageName = cu.getPackage() != null ? cu.getPackage().getName().getFullyQualifiedName() : "";
String simpleName = td.getName().getIdentifier();
return packageName.isEmpty() ? simpleName : packageName + "." + simpleName;
String fqn = sb.toString();
return packageName.isEmpty() ? fqn : packageName + "." + fqn;
}
private TypeDeclaration findTypeInCu(CompilationUnit cu, String fqn) {
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 typeFqn = packageName.isEmpty() ? simpleName : packageName + "." + simpleName;
if (typeFqn.equals(fqn))
return td;
TypeDeclaration found = findNestedType(td, fqn);
if (found != null) return found;
}
}
return null;
}
private TypeDeclaration findNestedType(TypeDeclaration td, String targetFqn) {
if (getFqn(td).equals(targetFqn)) return td;
for (TypeDeclaration nested : td.getTypes()) {
TypeDeclaration found = findNestedType(nested, targetFqn);
if (found != null) return found;
}
return null;
}
public Path getPath(String className) {
return classPaths.get(className);
}
@@ -242,10 +755,19 @@ public class CodebaseContext {
}
public boolean extendsStateMachineConfigurerAdapter(TypeDeclaration td) {
return extendsStateMachineConfigurerAdapter(td, new HashSet<>());
ITypeBinding binding = td.resolveBinding();
if (binding != null) {
boolean isSubtype = BindingResolver.isSubtypeOf(binding, "org.springframework.statemachine.config.builders.StateMachineConfigurer")
|| BindingResolver.isSubtypeOf(binding, "org.springframework.statemachine.config.StateMachineConfigurerAdapter")
|| BindingResolver.isSubtypeOf(binding, "org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter");
if (isSubtype) {
return true;
}
}
return extendsStateMachineConfigurerAdapterLegacy(td, new HashSet<>());
}
private boolean extendsStateMachineConfigurerAdapter(TypeDeclaration td, Set<String> visited) {
private boolean extendsStateMachineConfigurerAdapterLegacy(TypeDeclaration td, Set<String> visited) {
String fqn = getFqn(td);
if (visited.contains(fqn)) {
return false;
@@ -262,7 +784,7 @@ public class CodebaseContext {
if (knownAdapters.contains(superclassName))
return true;
TypeDeclaration superTd = getTypeDeclaration(superclassName, cu);
if (superTd != null && extendsStateMachineConfigurerAdapter(superTd, visited))
if (superTd != null && extendsStateMachineConfigurerAdapterLegacy(superTd, visited))
return true;
}
@@ -273,7 +795,7 @@ public class CodebaseContext {
if (knownAdapters.contains(interfaceName))
return true;
TypeDeclaration interfaceTd = getTypeDeclaration(interfaceName, cu);
if (interfaceTd != null && extendsStateMachineConfigurerAdapter(interfaceTd, visited))
if (interfaceTd != null && extendsStateMachineConfigurerAdapterLegacy(interfaceTd, visited))
return true;
}
}
@@ -321,4 +843,31 @@ public class CodebaseContext {
private String getSimpleName(Type type) {
return AstUtils.extractSimpleTypeName(type);
}
public Collection<CompilationUnit> getCompilationUnits() {
return Collections.unmodifiableCollection(allCus);
}
public Collection<TypeDeclaration> getTypeDeclarations() {
List<TypeDeclaration> types = new ArrayList<>();
for (CompilationUnit cu : allCus) {
for (Object typeObj : cu.types()) {
if (typeObj instanceof TypeDeclaration td) {
types.add(td);
// Handle nested types
types.addAll(getNestedTypes(td));
}
}
}
return types;
}
private List<TypeDeclaration> getNestedTypes(TypeDeclaration td) {
List<TypeDeclaration> nested = new ArrayList<>();
for (TypeDeclaration inner : td.getTypes()) {
nested.add(inner);
nested.addAll(getNestedTypes(inner));
}
return nested;
}
}

View File

@@ -0,0 +1,78 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class ControlFlowGraph {
private final MethodDeclaration method;
private final CfgNode entryNode = new CfgNode(null, CfgNodeType.ENTRY);
private final CfgNode exitNode = new CfgNode(null, CfgNodeType.EXIT);
private final List<CfgNode> nodes = new ArrayList<>();
public enum CfgNodeType {
ENTRY,
EXIT,
STATEMENT
}
public static class CfgNode {
private final ASTNode astNode;
private final CfgNodeType type;
private final Set<CfgNode> predecessors = new HashSet<>();
private final Set<CfgNode> successors = new HashSet<>();
public CfgNode(ASTNode astNode, CfgNodeType type) {
this.astNode = astNode;
this.type = type;
}
public ASTNode getAstNode() {
return astNode;
}
public CfgNodeType getType() {
return type;
}
public Set<CfgNode> getPredecessors() {
return predecessors;
}
public Set<CfgNode> getSuccessors() {
return successors;
}
public void addSuccessor(CfgNode succ) {
if (succ != null) {
this.successors.add(succ);
succ.predecessors.add(this);
}
}
}
public ControlFlowGraph(MethodDeclaration method) {
this.method = method;
this.nodes.add(entryNode);
this.nodes.add(exitNode);
}
public MethodDeclaration getMethod() {
return method;
}
public CfgNode getEntryNode() {
return entryNode;
}
public CfgNode getExitNode() {
return exitNode;
}
public List<CfgNode> getNodes() {
return nodes;
}
public void addNode(CfgNode node) {
this.nodes.add(node);
}
}

View File

@@ -0,0 +1,18 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.Expression;
import java.util.List;
public interface DataFlowModel {
/**
* Resolves all possible Expressions that define the value of the given expression
* at its location in the control flow.
*/
List<Expression> getReachingDefinitions(Expression expr);
/**
* Resolves the single constant/literal value if all definitions converge,
* or if a single value is expected.
*/
String resolveValue(Expression expr, CodebaseContext context);
}

View File

@@ -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,10 @@ public class FileUtils {
})) {
return paths
.filter(p -> Files.isRegularFile(p) && p.toString().endsWith(extension))
.filter(p -> matchers.stream().noneMatch(m -> m.matches(p)))
.map(Path::toAbsolutePath)
.map(Path::normalize)
.distinct()
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,898 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class JdtDataFlowModel implements DataFlowModel {
private final CodebaseContext context;
private final Map<MethodDeclaration, ControlFlowGraph> cfgCache = new HashMap<>();
private final Map<MethodDeclaration, ReachingDefinitions> rdCache = new HashMap<>();
private final Map<ASTNode, Map<IVariableBinding, Expression>> objectFieldBindings = new HashMap<>();
public JdtDataFlowModel(CodebaseContext context) {
this.context = context;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof MethodDeclaration) {
return (MethodDeclaration) current;
}
current = current.getParent();
}
return null;
}
private ReachingDefinitions getReachingDefinitionsForMethod(MethodDeclaration method) {
if (method == null) return null;
return rdCache.computeIfAbsent(method, m -> {
ControlFlowGraph cfg = cfgCache.computeIfAbsent(m, CfgBuilder::build);
return new ReachingDefinitions(cfg);
});
}
@Override
public List<Expression> getReachingDefinitions(Expression expr) {
return getReachingDefinitions(expr, new HashSet<>(), new HashMap<>(), new HashMap<>(), 0);
}
private List<Expression> getReachingDefinitions(
Expression expr,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
if (expr == null || depth > 50 || !visited.add(expr)) {
return List.of();
}
// 1. Handle Ternary / Conditional Expression
if (expr instanceof ConditionalExpression ce) {
String condVal = resolveValue(ce.getExpression(), context);
if ("true".equals(condVal)) {
List<Expression> resolved = getReachingDefinitions(ce.getThenExpression(), visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr);
return resolved;
} else if ("false".equals(condVal)) {
List<Expression> resolved = getReachingDefinitions(ce.getElseExpression(), visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr);
return resolved;
} else {
visited.remove(expr);
return List.of(ce);
}
}
// 2. Handle Field constant / field initializer propagation
IVariableBinding vb = getVariableBinding(expr);
if (vb != null && vb.isField()) {
if (instanceFieldBindings.containsKey(vb)) {
Expression fieldVal = instanceFieldBindings.get(vb);
List<Expression> resolved = getReachingDefinitions(fieldVal, visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr);
return resolved;
}
Expression fieldInit = findFieldInitializer(vb, expr);
if (fieldInit != null) {
List<Expression> resolved = getReachingDefinitions(fieldInit, visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr);
return resolved;
}
}
// 3. Handle Parameter mapping for SimpleName
if (expr instanceof SimpleName sn) {
IBinding b = sn.resolveBinding();
if (b instanceof IVariableBinding paramVb && paramBindings.containsKey(paramVb)) {
Expression argExpr = paramBindings.get(paramVb);
List<Expression> resolved = getReachingDefinitions(argExpr, visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr);
return resolved;
}
// Fallback: Local Reaching Definitions
MethodDeclaration md = findEnclosingMethod(sn);
ReachingDefinitions rd = getReachingDefinitionsForMethod(md);
if (rd != null) {
Set<ASTNode> defs = rd.getReachingDefinitions(sn, sn.getIdentifier());
if (!defs.isEmpty()) {
List<Expression> exprs = new ArrayList<>();
for (ASTNode def : defs) {
Expression defExpr = ReachingDefinitions.getDefinitionExpression(def);
if (defExpr != null) {
exprs.addAll(getReachingDefinitions(defExpr, visited, paramBindings, instanceFieldBindings, depth + 1));
} else {
exprs.add(expr);
}
}
visited.remove(expr);
return exprs;
}
}
}
// Handle ClassInstanceCreation
if (expr instanceof ClassInstanceCreation cic) {
getOrCreateFieldBindings(cic, paramBindings, instanceFieldBindings);
visited.remove(expr);
return List.of(cic);
}
// Handle CastExpression
if (expr instanceof CastExpression ce) {
List<Expression> resolved = getReachingDefinitions(ce.getExpression(), visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr);
return resolved;
}
// Handle ParenthesizedExpression
if (expr instanceof ParenthesizedExpression pe) {
List<Expression> resolved = getReachingDefinitions(pe.getExpression(), visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr);
return resolved;
}
// 4. Handle Method Invocations (Static Factory methods / Transforming Getters)
if (expr instanceof MethodInvocation mi) {
String mName = mi.getName().getIdentifier();
if (("just".equals(mName) || "withPayload".equals(mName) || "success".equals(mName)) && !mi.arguments().isEmpty()) {
List<Expression> resolved = getReachingDefinitions((Expression) mi.arguments().get(0), visited, paramBindings, instanceFieldBindings, depth + 1);
visited.remove(expr);
return resolved;
}
IMethodBinding mb = mi.resolveMethodBinding();
if (mb != null) {
List<TargetMethod> targets = resolveTargets(mi, mb, visited, paramBindings, instanceFieldBindings, depth);
if (!targets.isEmpty()) {
List<Expression> results = new ArrayList<>();
for (TargetMethod target : targets) {
MethodDeclaration md = target.declaration;
if (md != null && md.getBody() != null) {
Map<IVariableBinding, Expression> newParamBindings = new HashMap<>(paramBindings);
List<?> parameters = md.parameters();
List<?> arguments = mi.arguments();
int count = Math.min(parameters.size(), arguments.size());
for (int i = 0; i < count; i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) parameters.get(i);
IVariableBinding paramVb = svd.resolveBinding();
if (paramVb != null) {
newParamBindings.put(paramVb, (Expression) arguments.get(i));
}
}
// Evaluate mutator/setter method body
evaluateMethodMutations(md, mi.arguments(), target.fieldBindings);
List<ReturnStatement> returns = findReturnStatements(md.getBody());
if (!returns.isEmpty()) {
for (ReturnStatement rs : returns) {
if (rs.getExpression() != null) {
Expression retExpr = rs.getExpression();
Expression unwrapped = retExpr;
while (unwrapped instanceof ParenthesizedExpression pe) {
unwrapped = pe.getExpression();
}
while (unwrapped instanceof CastExpression ce) {
unwrapped = ce.getExpression();
}
if (unwrapped instanceof ThisExpression) {
if (mi.getExpression() != null) {
results.addAll(getReachingDefinitions(mi.getExpression(), visited, paramBindings, instanceFieldBindings, depth + 1));
} else {
results.add(unwrapped);
}
} else {
results.addAll(getReachingDefinitions(retExpr, visited, newParamBindings, target.fieldBindings, depth + 1));
}
}
}
}
}
}
if (!results.isEmpty()) {
visited.remove(expr);
return results;
}
}
}
}
// Handle Super Method Invocations
if (expr instanceof SuperMethodInvocation smi) {
IMethodBinding mb = smi.resolveMethodBinding();
if (mb != null) {
MethodDeclaration md = findMethodDeclaration(mb);
if (md != null && md.getBody() != null) {
Map<IVariableBinding, Expression> newParamBindings = new HashMap<>(paramBindings);
List<?> parameters = md.parameters();
List<?> arguments = smi.arguments();
int count = Math.min(parameters.size(), arguments.size());
for (int i = 0; i < count; i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) parameters.get(i);
IVariableBinding paramVb = svd.resolveBinding();
if (paramVb != null) {
newParamBindings.put(paramVb, (Expression) arguments.get(i));
}
}
List<ReturnStatement> returns = findReturnStatements(md.getBody());
if (!returns.isEmpty()) {
List<Expression> results = new ArrayList<>();
for (ReturnStatement rs : returns) {
if (rs.getExpression() != null) {
results.addAll(getReachingDefinitions(rs.getExpression(), visited, newParamBindings, instanceFieldBindings, depth + 1));
}
}
visited.remove(expr);
return results;
}
}
}
}
visited.remove(expr);
return List.of(expr);
}
private IVariableBinding getVariableBinding(Expression expr) {
if (expr instanceof SimpleName sn) {
IBinding b = sn.resolveBinding();
if (b instanceof IVariableBinding varBinding) return varBinding;
} else if (expr instanceof QualifiedName qn) {
IBinding b = qn.resolveBinding();
if (b instanceof IVariableBinding varBinding) return varBinding;
} else if (expr instanceof FieldAccess fa) {
return fa.resolveFieldBinding();
} else if (expr instanceof SuperFieldAccess sfa) {
return sfa.resolveFieldBinding();
}
return null;
}
private Expression findFieldInitializer(IVariableBinding vb, ASTNode contextNode) {
if (vb == null) return null;
ITypeBinding declaringClass = vb.getDeclaringClass();
if (declaringClass == null) return null;
String classFqn = declaringClass.getErasure().getQualifiedName();
if (classFqn == null || classFqn.isEmpty()) return null;
TypeDeclaration td = context.getTypeDeclaration(classFqn);
if (td == null) {
CompilationUnit cu = null;
ASTNode root = contextNode.getRoot();
if (root instanceof CompilationUnit) {
cu = (CompilationUnit) root;
}
td = context.getTypeDeclaration(classFqn, cu);
}
if (td != null) {
// 1. Try field declaration initializer
for (FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
if (fragObj instanceof VariableDeclarationFragment fragment) {
if (fragment.getName().getIdentifier().equals(vb.getName())) {
if (fragment.getInitializer() != null) {
return fragment.getInitializer();
}
}
}
}
}
// 2. Try constructor assignments
for (MethodDeclaration md : td.getMethods()) {
if (md.isConstructor() && md.getBody() != null) {
final Expression[] assignedExpr = new Expression[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(Assignment assignment) {
Expression lhs = assignment.getLeftHandSide();
boolean matches = false;
if (lhs instanceof SimpleName snLhs && snLhs.getIdentifier().equals(vb.getName())) {
IBinding b = snLhs.resolveBinding();
if (b instanceof IVariableBinding resolvedVb && resolvedVb.getKey().equals(vb.getKey())) {
matches = true;
}
} else if (lhs instanceof FieldAccess fa && fa.getName().getIdentifier().equals(vb.getName()) && fa.getExpression() instanceof ThisExpression) {
IBinding b = fa.getName().resolveBinding();
if (b instanceof IVariableBinding resolvedVb && resolvedVb.getKey().equals(vb.getKey())) {
matches = true;
}
}
if (matches) {
assignedExpr[0] = assignment.getRightHandSide();
}
return super.visit(assignment);
}
});
if (assignedExpr[0] != null) {
return assignedExpr[0];
}
}
}
}
return null;
}
private Map<IVariableBinding, Expression> getOrCreateFieldBindings(
ClassInstanceCreation cic,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings) {
Map<IVariableBinding, Expression> bindings = objectFieldBindings.get(cic);
if (bindings == null) {
bindings = buildFieldBindingsFromCic(cic, paramBindings, instanceFieldBindings);
objectFieldBindings.put(cic, bindings);
}
return bindings;
}
private Map<IVariableBinding, Expression> getOrCreateFieldBindings(ClassInstanceCreation cic) {
Map<IVariableBinding, Expression> bindings = objectFieldBindings.get(cic);
if (bindings == null) {
bindings = buildFieldBindingsFromCic(cic);
objectFieldBindings.put(cic, bindings);
}
return bindings;
}
private Map<IVariableBinding, Expression> buildFieldBindingsFromCic(ClassInstanceCreation cic) {
return buildFieldBindingsFromCic(cic, new HashMap<>(), new HashMap<>());
}
private Map<IVariableBinding, Expression> buildFieldBindingsFromCic(
ClassInstanceCreation cic,
Map<IVariableBinding, Expression> callerParamBindings,
Map<IVariableBinding, Expression> callerInstanceFieldBindings) {
Map<IVariableBinding, Expression> fieldBindings = new HashMap<>();
IMethodBinding cb = cic.resolveConstructorBinding();
if (cb == null) return fieldBindings;
List<Expression> resolvedArguments = new ArrayList<>();
for (Object argObj : cic.arguments()) {
Expression arg = (Expression) argObj;
List<Expression> resolved = getReachingDefinitions(arg, new HashSet<>(), callerParamBindings, callerInstanceFieldBindings, 0);
if (!resolved.isEmpty()) {
resolvedArguments.add(resolved.get(0));
} else {
resolvedArguments.add(arg);
}
}
evaluateConstructor(cb, resolvedArguments, new HashMap<>(), fieldBindings, 0);
return fieldBindings;
}
private void evaluateConstructor(
IMethodBinding cb,
List<?> arguments,
Map<IVariableBinding, Expression> callerParamValues,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
if (cb == null || depth > 10) return;
MethodDeclaration cd = findMethodDeclaration(cb);
if (cd == null || cd.getBody() == null) return;
Map<IVariableBinding, Expression> currentParamValues = new HashMap<>();
List<?> parameters = cd.parameters();
int count = Math.min(parameters.size(), arguments.size());
for (int i = 0; i < count; i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) parameters.get(i);
IVariableBinding paramVb = svd.resolveBinding();
if (paramVb != null) {
Expression arg = (Expression) arguments.get(i);
Expression resolvedArg = resolveParamValue(arg, callerParamValues);
currentParamValues.put(paramVb, resolvedArg);
}
}
List<?> statements = cd.getBody().statements();
if (!statements.isEmpty()) {
Object firstStmt = statements.get(0);
if (firstStmt instanceof ConstructorInvocation ci) {
IMethodBinding targetCb = ci.resolveConstructorBinding();
if (targetCb != null) {
evaluateConstructor(targetCb, ci.arguments(), currentParamValues, fieldBindings, depth + 1);
}
} else if (firstStmt instanceof SuperConstructorInvocation sci) {
IMethodBinding targetCb = sci.resolveConstructorBinding();
if (targetCb != null) {
evaluateConstructor(targetCb, sci.arguments(), currentParamValues, fieldBindings, depth + 1);
}
}
}
cd.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(Assignment assignment) {
Expression lhs = assignment.getLeftHandSide();
Expression rhs = assignment.getRightHandSide();
IVariableBinding fieldVb = null;
if (lhs instanceof FieldAccess fa) {
if (fa.getExpression() instanceof ThisExpression) {
IBinding b = fa.getName().resolveBinding();
if (b instanceof IVariableBinding vb && vb.isField()) {
fieldVb = vb;
}
}
} else if (lhs instanceof SimpleName sn) {
IBinding b = sn.resolveBinding();
if (b instanceof IVariableBinding vb && vb.isField()) {
fieldVb = vb;
}
}
if (fieldVb != null) {
Expression resolvedRhs = resolveParamValue(rhs, currentParamValues);
fieldBindings.put(fieldVb, resolvedRhs);
}
return super.visit(assignment);
}
});
}
private Expression resolveParamValue(Expression expr, Map<IVariableBinding, Expression> paramValues) {
if (expr instanceof SimpleName sn) {
IBinding b = sn.resolveBinding();
if (b instanceof IVariableBinding vb && paramValues.containsKey(vb)) {
return paramValues.get(vb);
}
} else if (expr instanceof CastExpression ce) {
Expression resolved = resolveParamValue(ce.getExpression(), paramValues);
if (resolved != ce.getExpression()) {
return resolved;
}
} else if (expr instanceof ParenthesizedExpression pe) {
Expression resolved = resolveParamValue(pe.getExpression(), paramValues);
if (resolved != pe.getExpression()) {
return resolved;
}
}
return expr;
}
private static class TargetMethod {
final MethodDeclaration declaration;
final Map<IVariableBinding, Expression> fieldBindings;
TargetMethod(MethodDeclaration declaration, Map<IVariableBinding, Expression> fieldBindings) {
this.declaration = declaration;
this.fieldBindings = fieldBindings;
}
}
private List<TargetMethod> resolveTargets(
MethodInvocation mi,
IMethodBinding mb,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> instanceFieldBindings,
int depth) {
Expression receiver = mi.getExpression();
boolean isThisReceiver = (receiver == null || receiver instanceof ThisExpression);
List<TargetMethod> targets = new ArrayList<>();
if (receiver != null) {
// Find concrete ClassInstanceCreation definitions in the reaching definitions path of the receiver
List<Expression> receiverDefs = getReachingDefinitions(receiver, visited, paramBindings, instanceFieldBindings, depth + 1);
List<ClassInstanceCreation> concreteCics = new ArrayList<>();
for (Expression rDef : receiverDefs) {
if (rDef instanceof ClassInstanceCreation cic) {
concreteCics.add(cic);
}
}
if (!concreteCics.isEmpty()) {
// Type narrowing is active! Only trace through the concrete instantiated types.
for (ClassInstanceCreation cic : concreteCics) {
ITypeBinding concreteType = null;
if (cic.resolveConstructorBinding() != null) {
concreteType = cic.resolveConstructorBinding().getDeclaringClass();
}
if (concreteType == null) {
concreteType = cic.resolveTypeBinding();
}
if (concreteType != null) {
MethodDeclaration md = findMethodDeclarationInType(concreteType, mb);
if (md != null) {
Map<IVariableBinding, Expression> concreteFieldBindings =
getOrCreateFieldBindings(cic);
// Apply flow-sensitive intermediate local variable mutations
if (receiver instanceof SimpleName sn) {
MethodDeclaration enclosingMethod = findEnclosingMethod(sn);
if (enclosingMethod != null) {
ReachingDefinitions rd = getReachingDefinitionsForMethod(enclosingMethod);
if (rd != null) {
Set<ASTNode> defs = rd.getReachingDefinitions(sn, sn.getIdentifier());
for (ASTNode def : defs) {
Expression defExpr = ReachingDefinitions.getDefinitionExpression(def);
if (defExpr == cic || isExpressionWrapping(defExpr, cic)) {
applyIntermediateMutations(sn, def, mi, enclosingMethod, concreteFieldBindings, paramBindings, visited, depth);
}
}
}
}
}
targets.add(new TargetMethod(md, concreteFieldBindings));
}
}
}
return targets;
}
}
// Fallback: Polymorphic target discovery (including interface implementations)
ITypeBinding declaringClass = mb.getDeclaringClass();
if (declaringClass != null) {
String declaringClassFqn = declaringClass.getErasure().getQualifiedName();
if (declaringClassFqn != null && !declaringClassFqn.isEmpty()) {
// Find all known implementation subclasses in the codebase
List<String> implClassFqns = context.getImplementations(declaringClassFqn);
if (implClassFqns != null && !implClassFqns.isEmpty()) {
for (String implFqn : implClassFqns) {
TypeDeclaration td = context.getTypeDeclaration(implFqn);
if (td != null) {
MethodDeclaration md = findMethodDeclarationInType(td.resolveBinding(), mb);
if (md != null) {
// For polymorphic targets with unknown instance, we don't have concrete field bindings
Map<IVariableBinding, Expression> targetFieldBindings = isThisReceiver ? new HashMap<>(instanceFieldBindings) : new HashMap<>();
targets.add(new TargetMethod(md, targetFieldBindings));
}
}
}
}
}
// If the declaring class is a class (not an interface), or if the declared method itself is concrete/has a body,
// we should also include the declared method itself in the polymorphic targets.
MethodDeclaration declaredMd = findMethodDeclaration(mb);
if (declaredMd != null && declaredMd.getBody() != null) {
boolean alreadyAdded = false;
for (TargetMethod tm : targets) {
if (tm.declaration == declaredMd) {
alreadyAdded = true;
break;
}
}
if (!alreadyAdded) {
Map<IVariableBinding, Expression> targetFieldBindings = isThisReceiver ? new HashMap<>(instanceFieldBindings) : new HashMap<>();
targets.add(new TargetMethod(declaredMd, targetFieldBindings));
}
}
}
// If no polymorphic subclass targets were found, fallback to the declared method itself
if (targets.isEmpty()) {
MethodDeclaration md = findMethodDeclaration(mb);
if (md != null) {
Map<IVariableBinding, Expression> targetFieldBindings = isThisReceiver ? new HashMap<>(instanceFieldBindings) : new HashMap<>();
targets.add(new TargetMethod(md, targetFieldBindings));
}
}
return targets;
}
private boolean isExpressionWrapping(Expression outer, Expression inner) {
Expression current = outer;
while (current != null) {
if (current == inner) return true;
if (current instanceof ParenthesizedExpression pe) {
current = pe.getExpression();
} else if (current instanceof CastExpression ce) {
current = ce.getExpression();
} else {
break;
}
}
return false;
}
private ControlFlowGraph.CfgNode findCfgNode(ControlFlowGraph cfg, ASTNode astNode) {
ASTNode current = astNode;
while (current != null) {
for (ControlFlowGraph.CfgNode node : cfg.getNodes()) {
if (node.getAstNode() == current) {
return node;
}
}
current = current.getParent();
}
return null;
}
private boolean isTargetVariable(Expression expr, IVariableBinding targetVar, String targetName) {
if (expr instanceof SimpleName sn) {
IBinding b = sn.resolveBinding();
if (b instanceof IVariableBinding vb) {
if (targetVar != null && vb.getKey().equals(targetVar.getKey())) {
return true;
}
}
if (targetName != null && sn.getIdentifier().equals(targetName)) {
return true;
}
}
return false;
}
private void evaluateMethodMutations(
MethodDeclaration md,
List<?> arguments,
Map<IVariableBinding, Expression> fieldBindings) {
if (md == null || md.getBody() == null) return;
Map<IVariableBinding, Expression> currentParamValues = new HashMap<>();
List<?> parameters = md.parameters();
int count = Math.min(parameters.size(), arguments.size());
for (int i = 0; i < count; i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) parameters.get(i);
IVariableBinding paramVb = svd.resolveBinding();
if (paramVb != null) {
Expression arg = (Expression) arguments.get(i);
currentParamValues.put(paramVb, arg);
}
}
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(Assignment assignment) {
Expression lhs = assignment.getLeftHandSide();
Expression rhs = assignment.getRightHandSide();
IVariableBinding fieldVb = null;
if (lhs instanceof FieldAccess fa) {
if (fa.getExpression() instanceof ThisExpression) {
IBinding b = fa.getName().resolveBinding();
if (b instanceof IVariableBinding vb && vb.isField()) {
fieldVb = vb;
}
}
} else if (lhs instanceof SimpleName sn) {
IBinding b = sn.resolveBinding();
if (b instanceof IVariableBinding vb && vb.isField()) {
fieldVb = vb;
}
}
if (fieldVb != null) {
Expression resolvedRhs = resolveParamValue(rhs, currentParamValues);
fieldBindings.put(fieldVb, resolvedRhs);
}
return super.visit(assignment);
}
});
}
private void findAndApplyMutations(
ASTNode node,
IVariableBinding targetVar,
String targetName,
Map<IVariableBinding, Expression> fieldBindings,
Map<IVariableBinding, Expression> paramBindings,
Set<ASTNode> visited,
int depth) {
node.accept(new ASTVisitor() {
@Override
public boolean visit(Assignment assignment) {
Expression lhs = assignment.getLeftHandSide();
Expression rhs = assignment.getRightHandSide();
IVariableBinding fieldVb = null;
if (lhs instanceof FieldAccess fa) {
if (isTargetVariable(fa.getExpression(), targetVar, targetName)) {
IBinding b = fa.getName().resolveBinding();
if (b instanceof IVariableBinding vb && vb.isField()) {
fieldVb = vb;
}
}
} else if (lhs instanceof QualifiedName qn) {
if (isTargetVariable(qn.getQualifier(), targetVar, targetName)) {
IBinding b = qn.getName().resolveBinding();
if (b instanceof IVariableBinding vb && vb.isField()) {
fieldVb = vb;
}
}
}
if (fieldVb != null) {
List<Expression> resolvedRhs = getReachingDefinitions(rhs, visited, paramBindings, fieldBindings, depth + 1);
if (!resolvedRhs.isEmpty()) {
fieldBindings.put(fieldVb, resolvedRhs.get(0));
} else {
fieldBindings.put(fieldVb, rhs);
}
}
return super.visit(assignment);
}
@Override
public boolean visit(MethodInvocation mi) {
Expression receiver = mi.getExpression();
if (receiver != null && isTargetVariable(receiver, targetVar, targetName)) {
IMethodBinding mb = mi.resolveMethodBinding();
if (mb != null) {
List<TargetMethod> targets = resolveTargets(mi, mb, visited, paramBindings, fieldBindings, depth + 1);
for (TargetMethod target : targets) {
if (target.declaration != null) {
evaluateMethodMutations(target.declaration, mi.arguments(), fieldBindings);
}
}
}
}
return super.visit(mi);
}
});
}
private void applyIntermediateMutations(
SimpleName receiverSimpleName,
ASTNode defNode,
ASTNode useNode,
MethodDeclaration enclosingMethod,
Map<IVariableBinding, Expression> fieldBindings,
Map<IVariableBinding, Expression> paramBindings,
Set<ASTNode> visited,
int depth) {
IBinding b = receiverSimpleName.resolveBinding();
IVariableBinding targetVar = (b instanceof IVariableBinding vb) ? vb : null;
String targetName = receiverSimpleName.getIdentifier();
ControlFlowGraph cfg = cfgCache.computeIfAbsent(enclosingMethod, CfgBuilder::build);
if (cfg == null) return;
ControlFlowGraph.CfgNode defCfgNode = findCfgNode(cfg, defNode);
ControlFlowGraph.CfgNode useCfgNode = findCfgNode(cfg, useNode);
if (defCfgNode != null && useCfgNode != null) {
Set<ControlFlowGraph.CfgNode> reachFromD = new HashSet<>();
Queue<ControlFlowGraph.CfgNode> queue = new LinkedList<>();
queue.add(defCfgNode);
reachFromD.add(defCfgNode);
while (!queue.isEmpty()) {
ControlFlowGraph.CfgNode curr = queue.poll();
for (ControlFlowGraph.CfgNode succ : curr.getSuccessors()) {
if (reachFromD.add(succ)) {
queue.add(succ);
}
}
}
Set<ControlFlowGraph.CfgNode> reachToU = new HashSet<>();
queue.clear();
queue.add(useCfgNode);
reachToU.add(useCfgNode);
while (!queue.isEmpty()) {
ControlFlowGraph.CfgNode curr = queue.poll();
for (ControlFlowGraph.CfgNode pred : curr.getPredecessors()) {
if (reachToU.add(pred)) {
queue.add(pred);
}
}
}
Set<ControlFlowGraph.CfgNode> pathNodes = new HashSet<>(reachFromD);
pathNodes.retainAll(reachToU);
List<ControlFlowGraph.CfgNode> orderedPathNodes = new ArrayList<>(pathNodes);
List<ControlFlowGraph.CfgNode> allNodes = cfg.getNodes();
orderedPathNodes.sort(Comparator.comparingInt(allNodes::indexOf));
for (ControlFlowGraph.CfgNode node : orderedPathNodes) {
if (node != defCfgNode && node != useCfgNode && node.getAstNode() != null) {
findAndApplyMutations(node.getAstNode(), targetVar, targetName, fieldBindings, paramBindings, visited, depth);
}
}
}
}
private MethodDeclaration findMethodDeclarationInType(ITypeBinding typeBinding, IMethodBinding mb) {
if (typeBinding == null || mb == null) return null;
// 1. Try to find it in the class hierarchy (including typeBinding itself)
ITypeBinding current = typeBinding;
while (current != null) {
MethodDeclaration md = findMethodInTypeDeclarationOnly(current, mb);
if (md != null) return md;
current = current.getSuperclass();
}
// 2. Try to find it in the interface hierarchy of typeBinding
Set<String> visited = new HashSet<>();
Queue<ITypeBinding> queue = new LinkedList<>();
queue.add(typeBinding);
while (!queue.isEmpty()) {
ITypeBinding tb = queue.poll();
if (tb == null) continue;
String key = tb.getKey();
if (key != null && !visited.add(key)) continue;
MethodDeclaration md = findMethodInTypeDeclarationOnly(tb, mb);
if (md != null) return md;
for (ITypeBinding interf : tb.getInterfaces()) {
queue.add(interf);
}
}
return null;
}
private MethodDeclaration findMethodInTypeDeclarationOnly(ITypeBinding typeBinding, IMethodBinding mb) {
String classFqn = typeBinding.getErasure().getQualifiedName();
if (classFqn != null && !classFqn.isEmpty()) {
TypeDeclaration td = context.getTypeDeclaration(classFqn);
if (td != null) {
for (MethodDeclaration md : td.getMethods()) {
IMethodBinding candidateMb = md.resolveBinding();
if (candidateMb != null && candidateMb.getKey().equals(mb.getKey())) {
return md;
}
}
for (MethodDeclaration md : td.getMethods()) {
if (md.getName().getIdentifier().equals(mb.getName()) &&
md.parameters().size() == mb.getParameterTypes().length) {
return md;
}
}
}
}
return null;
}
private MethodDeclaration findMethodDeclaration(IMethodBinding mb) {
if (mb == null) return null;
return findMethodDeclarationInType(mb.getDeclaringClass(), mb);
}
private List<ReturnStatement> findReturnStatements(ASTNode node) {
List<ReturnStatement> returns = new ArrayList<>();
node.accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement rs) {
returns.add(rs);
return super.visit(rs);
}
@Override
public boolean visit(LambdaExpression le) {
return false;
}
@Override
public boolean visit(AnonymousClassDeclaration acd) {
return false;
}
@Override
public boolean visit(TypeDeclarationStatement tds) {
return false;
}
});
return returns;
}
@Override
public String resolveValue(Expression expr, CodebaseContext context) {
if (expr == null) return null;
List<Expression> defs = getReachingDefinitions(expr);
if (defs.isEmpty()) {
return context.resolveExpression(expr);
}
String firstVal = null;
for (Expression def : defs) {
String val = context.resolveExpression(def);
if (val != null) {
if (firstVal == null) {
firstVal = val;
} else if (!firstVal.equals(val)) {
return null;
}
}
}
return firstVal != null ? firstVal : context.resolveExpression(expr);
}
}

View File

@@ -0,0 +1,31 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.Expression;
import click.kamil.springstatemachineexporter.analysis.service.VariableTracer;
import java.util.List;
public class LegacyDataFlowModel implements DataFlowModel {
private final VariableTracer variableTracer;
public LegacyDataFlowModel(VariableTracer variableTracer) {
this.variableTracer = variableTracer;
}
@Override
public List<Expression> getReachingDefinitions(Expression expr) {
if (variableTracer == null || expr == null) {
return expr != null ? List.of(expr) : List.of();
}
return variableTracer.traceVariableAll(expr);
}
@Override
public String resolveValue(Expression expr, CodebaseContext context) {
if (expr == null) return null;
if (variableTracer == null) {
return context.resolveExpression(expr);
}
Expression traced = variableTracer.traceVariable(expr);
return context.resolveExpression(traced);
}
}

View File

@@ -0,0 +1,155 @@
package click.kamil.springstatemachineexporter.ast.common;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
public class ReachingDefinitions {
private final ControlFlowGraph cfg;
private final Map<ControlFlowGraph.CfgNode, Set<ASTNode>> inMap = new HashMap<>();
private final Map<ControlFlowGraph.CfgNode, Set<ASTNode>> outMap = new HashMap<>();
public ReachingDefinitions(ControlFlowGraph cfg) {
this.cfg = cfg;
analyze();
}
private void analyze() {
List<ControlFlowGraph.CfgNode> cfgNodes = cfg.getNodes();
Map<ControlFlowGraph.CfgNode, ASTNode> nodeDefs = new HashMap<>();
Map<String, Set<ASTNode>> varToDefs = new HashMap<>();
for (ControlFlowGraph.CfgNode node : cfgNodes) {
ASTNode ast = node.getAstNode();
if (ast == null) continue;
ASTNode def = findDefinition(ast);
if (def != null) {
nodeDefs.put(node, def);
String varName = getDefinedVariableName(def);
if (varName != null) {
varToDefs.computeIfAbsent(varName, k -> new HashSet<>()).add(def);
}
}
}
for (ControlFlowGraph.CfgNode node : cfgNodes) {
inMap.put(node, new HashSet<>());
outMap.put(node, new HashSet<>());
}
boolean changed = true;
int iterations = 0;
int maxIterations = 5000;
while (changed && iterations < maxIterations) {
iterations++;
changed = false;
for (ControlFlowGraph.CfgNode node : cfgNodes) {
Set<ASTNode> newIn = new HashSet<>();
for (ControlFlowGraph.CfgNode pred : node.getPredecessors()) {
newIn.addAll(outMap.get(pred));
}
inMap.put(node, newIn);
Set<ASTNode> newOut = new HashSet<>(newIn);
ASTNode def = nodeDefs.get(node);
if (def != null) {
String varName = getDefinedVariableName(def);
if (varName != null) {
Set<ASTNode> allDefs = varToDefs.get(varName);
if (allDefs != null) {
newOut.removeAll(allDefs);
}
}
newOut.add(def);
}
Set<ASTNode> oldOut = outMap.put(node, newOut);
if (!newOut.equals(oldOut)) {
changed = true;
}
}
}
if (iterations >= maxIterations) {
// Log warning or print to stderr
System.err.println("Warning: ReachingDefinitions analysis reached max iterations (" + maxIterations + ") and terminated early.");
}
}
public Set<ASTNode> getReachingDefinitions(ASTNode usageNode, String varName) {
ControlFlowGraph.CfgNode cfgNode = findCfgNodeForUsage(usageNode);
if (cfgNode == null) {
return Collections.emptySet();
}
Set<ASTNode> reaching = new HashSet<>();
Set<ASTNode> inDefs = inMap.get(cfgNode);
if (inDefs != null) {
for (ASTNode def : inDefs) {
if (varName.equals(getDefinedVariableName(def))) {
reaching.add(def);
}
}
}
return reaching;
}
private ControlFlowGraph.CfgNode findCfgNodeForUsage(ASTNode usageNode) {
ASTNode current = usageNode;
while (current != null) {
for (ControlFlowGraph.CfgNode node : cfg.getNodes()) {
if (node.getAstNode() == current) {
return node;
}
}
current = current.getParent();
}
return null;
}
private ASTNode findDefinition(ASTNode ast) {
if (ast instanceof VariableDeclarationStatement vds) {
if (!vds.fragments().isEmpty()) {
return (ASTNode) vds.fragments().get(0);
}
}
if (ast instanceof VariableDeclarationFragment) {
return ast;
}
if (ast instanceof SingleVariableDeclaration) {
return ast;
}
if (ast instanceof Assignment) {
return ast;
}
if (ast instanceof ExpressionStatement es) {
return findDefinition(es.getExpression());
}
return null;
}
public static String getDefinedVariableName(ASTNode def) {
if (def instanceof VariableDeclarationFragment frag) {
return frag.getName().getIdentifier();
}
if (def instanceof SingleVariableDeclaration svd) {
return svd.getName().getIdentifier();
}
if (def instanceof Assignment assign) {
if (assign.getLeftHandSide() instanceof SimpleName sn) {
return sn.getIdentifier();
}
}
return null;
}
public static Expression getDefinitionExpression(ASTNode def) {
if (def instanceof VariableDeclarationFragment frag) {
return frag.getInitializer();
}
if (def instanceof Assignment assign) {
return assign.getRightHandSide();
}
return null;
}
}

View File

@@ -0,0 +1,119 @@
package click.kamil.springstatemachineexporter.ast.common;
import click.kamil.springstatemachineexporter.model.State;
import org.eclipse.jdt.core.dom.*;
public class StateResolver {
private final CodebaseContext context;
public StateResolver(CodebaseContext context) {
this.context = context;
}
public State resolveState(Expression expr, CompilationUnit cu) {
if (expr == null)
return null;
// 1. Check for constants
String resolvedValue = context.getConstantResolver().resolve(expr, context);
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());
String raw = expr.toString();
if (expr instanceof QualifiedName qn) {
String full = resolveQualifiedName(qn, cu);
return State.of(raw, full);
}
if (expr instanceof SimpleName sn) {
String full = resolveSimpleName(sn, cu);
return State.of(raw, full);
}
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")) {
return extractAnnotationValue(ann);
}
}
}
}
}
}
}
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();
TypeDeclaration td = context.getTypeDeclaration(qualifier, cu);
if (td != null)
return context.getFqn(td) + "." + name;
return qn.getFullyQualifiedName();
}
private String resolveSimpleName(SimpleName sn, CompilationUnit cu) {
String name = sn.getIdentifier();
if (cu == null)
return name;
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + name))
return impName;
}
return name;
}
}

View File

@@ -45,11 +45,31 @@ public class ExporterCommand implements Callable<Integer> {
@Option(names = {"--no-diamonds"}, description = "Render choice/junction states as regular states instead of diamonds.", negatable = true, defaultValue = "true")
private boolean renderChoicesAsDiamonds;
@Option(names = {"-p", "--profiles"}, description = "Spring profiles to activate for property resolution.", split = ",")
private List<String> activeProfiles;
@Option(names = {"--event"}, description = "Format for events when they are enums: fn (fullName, default), fqn (fully qualified name), sn (short name)", defaultValue = "fn")
private click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat;
@Option(names = {"--state"}, description = "Format for states when they are enums: fn (fullName, default), fqn (fully qualified name), sn (short name)", defaultValue = "fn")
private click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat;
@Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false")
private boolean debug;
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "false")
private boolean resolveClasspath;
@Override
public Integer call() throws Exception {
var out = spec.commandLine().getOut();
var err = spec.commandLine().getErr();
if (debug) {
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug");
out.println(CommandLine.Help.Ansi.AUTO.string("@|bold,cyan Diagnostic mode enabled|@"));
}
if (inputDir == null && jsonFile == null) {
inputDir = Path.of(".");
}
@@ -66,10 +86,11 @@ public class ExporterCommand implements Callable<Integer> {
}
try {
List<String> profiles = activeProfiles != null ? activeProfiles : java.util.Collections.emptyList();
if (jsonFile != null) {
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats);
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat);
} else {
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds);
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, resolveClasspath);
}
out.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,green SUCCESS:|@ All state machines have been exported to " + outputDir.toAbsolutePath()));
return 0;

View File

@@ -18,17 +18,21 @@ public class Dot implements StateMachineExporter {
};
@Override
public String export(String name,
List<Transition> transitions,
Set<String> startStates,
Set<String> endStates,
ExportOptions options) {
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, 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<>();
@@ -37,7 +41,7 @@ public class Dot implements StateMachineExporter {
for (Transition t : transitions) {
if (t.getType() == TransitionType.CHOICE && t.getSourceStates() != null) {
for (State source : t.getSourceStates()) {
String simplified = simplify(source.toString());
String simplified = simplify(options.formatState(source));
statesWithChoice.add(simplified);
if (!choiceColorMap.containsKey(simplified)) {
choiceColorMap.put(simplified, CHOICE_COLORS[colorIndex % CHOICE_COLORS.length]);
@@ -82,11 +86,11 @@ public class Dot implements StateMachineExporter {
}
for (State rawSourceState : t.getSourceStates()) {
String rawSource = rawSourceState.toString();
String rawSource = options.formatState(rawSourceState);
String source = simplify(rawSource);
for (State rawTargetState : targets) {
String rawTarget = rawTargetState.toString();
String rawTarget = options.formatState(rawTargetState);
String target = simplify(rawTarget);
String edgeSource = source;
@@ -96,8 +100,11 @@ public class Dot implements StateMachineExporter {
// Label: event [guard] / actions
StringBuilder label = new StringBuilder();
if (t.getEvent() != null && !t.getEvent().isBlank()) {
label.append(escapeDotString(t.getEvent()));
if (t.getEvent() != null) {
String eventStr = options.formatEvent(t.getEvent());
if (eventStr != null && !eventStr.isBlank()) {
label.append(escapeDotString(eventStr));
}
}
// Guard
@@ -182,6 +189,21 @@ 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();

View File

@@ -0,0 +1,5 @@
package click.kamil.springstatemachineexporter.exporter;
public enum EnumFormat {
fn, fqn, sn
}

View File

@@ -10,4 +10,39 @@ public class ExportOptions {
boolean useLambdaGuards = true;
@Builder.Default
boolean renderChoicesAsDiamonds = true;
@Builder.Default
boolean embedIdentifiers = false;
@Builder.Default
boolean includeMetadataPane = true;
@Builder.Default
EnumFormat eventFormat = EnumFormat.fn;
@Builder.Default
EnumFormat stateFormat = EnumFormat.fn;
public String formatState(click.kamil.springstatemachineexporter.model.State state) {
if (state == null) return null;
return format(state.rawName(), state.fullIdentifier(), stateFormat);
}
public String formatEvent(click.kamil.springstatemachineexporter.model.Event event) {
if (event == null) return null;
return format(event.rawName(), event.fullIdentifier(), eventFormat);
}
private String format(String raw, String fqn, EnumFormat format) {
if (fqn == null) return raw;
switch (format) {
case fqn: return fqn;
case sn: return fqn.substring(fqn.lastIndexOf('.') + 1);
case fn:
int lastDot = fqn.lastIndexOf('.');
if (lastDot > 0) {
int prevDot = fqn.lastIndexOf('.', lastDot - 1);
return prevDot > 0 ? fqn.substring(prevDot + 1) : fqn;
}
return fqn;
default: return raw;
}
}
}

View File

@@ -17,6 +17,23 @@ public class JsonExporter implements StateMachineExporter {
.enable(SerializationFeature.INDENT_OUTPUT);
}
@Override
public String export(click.kamil.springstatemachineexporter.analysis.model.AnalysisResult result, ExportOptions 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
public String export(String name, List<Transition> transitions, Set<String> startStates, Set<String> endStates, ExportOptions options) {
try {

View File

@@ -3,34 +3,20 @@ package click.kamil.springstatemachineexporter.exporter;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import click.kamil.springstatemachineexporter.model.TransitionType;
import click.kamil.springstatemachineexporter.ast.common.StringUtils;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;
public class PlantUml implements StateMachineExporter {
private final List<String> choiceColors = List.of("FF6347", "4682B4", "32CD32", "FFD700", "6A5ACD", "FF69B4");
private final String LAMBDA = "λ";
private String loadBaseStyle() {
try (InputStream is = getClass().getResourceAsStream("/plantuml/default-style.puml")) {
if (is == null) return "";
return new BufferedReader(new InputStreamReader(is))
.lines()
.collect(Collectors.joining("\n"));
} catch (Exception e) {
return "";
}
@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
@@ -42,53 +28,57 @@ public class PlantUml implements StateMachineExporter {
StringBuilder sb = new StringBuilder();
sb.append("@startuml\n");
sb.append("!pragma layout smetana\n");
sb.append("set separator none\n");
sb.append("hide empty description\n");
sb.append("hide stereotype\n");
String style = loadBaseStyle();
// --- Premium Modern Styles ---
sb.append("skinparam state {\n");
sb.append(" BackgroundColor white\n");
sb.append(" BorderColor #94a3b8\n");
sb.append(" BorderThickness 1\n");
sb.append(" FontName Inter\n");
sb.append(" FontSize 9\n"); // Sleek but readable
sb.append(" FontStyle bold\n");
sb.append(" RoundCorner 20\n");
sb.append(" Padding 1\n");
sb.append("}\n");
sb.append("skinparam shadowing false\n");
sb.append("skinparam ArrowFontName JetBrains Mono\n");
sb.append("skinparam ArrowFontSize 8\n");
sb.append("skinparam ArrowColor #cbd5e1\n");
sb.append("skinparam ArrowThickness 1\n");
sb.append("skinparam dpi 110\n");
sb.append("skinparam svgLinkTarget _self\n");
StringBuilder choiceColorStyles = new StringBuilder();
StringBuilder hideChoiceColors = new StringBuilder();
for (int i = 0; i < choiceColors.size(); i++) {
choiceColorStyles.append(" .choice_color_").append(i)
.append(" { LineColor #").append(choiceColors.get(i)).append(" }\n");
hideChoiceColors.append("hide <<choice_color_").append(i).append(">> stereotype\n");
}
style = style.replace("/* CHOICE_COLORS_PLACEHOLDER */", choiceColorStyles.toString());
style = style.replace("/* HIDE_CHOICE_COLORS_PLACEHOLDER */", hideChoiceColors.toString());
sb.append(style).append("\n");
sb.append("\n");
for (String start : startStates) {
sb.append("[*] --> ").append(simplify(start)).append("\n");
}
sb.append("\n");
Set<String> statesWithChoice = new HashSet<>();
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()));
statesWithChoice.add(simplify(options.formatState(source)));
}
}
}
Map<String, String> choiceStateClassMap = new HashMap<>();
int colorIndex = 0;
for (String state : statesWithChoice) {
String className = "choice_color_" + (colorIndex % choiceColors.size());
if (options.isRenderChoicesAsDiamonds()) {
sb.append("state ").append(state).append(" <<choice>>\n");
}
choiceStateClassMap.put(state, className);
colorIndex++;
}
Set<String> junctionStates = transitions.stream()
.filter(t -> t.getType() == TransitionType.JUNCTION)
.flatMap(t -> t.getSourceStates().stream())
.map(s -> simplify(s.toString()))
.collect(Collectors.toSet());
.map(s -> simplify(options.formatState(s)))
.collect(Collectors.toCollection(java.util.LinkedHashSet::new));
for (String junction : junctionStates) {
if (options.isRenderChoicesAsDiamonds()) {
sb.append("state ").append(junction).append(" <<choice>>\n");
@@ -97,7 +87,6 @@ public class PlantUml implements StateMachineExporter {
sb.append("\n");
Set<String> usedEventStereotypes = new HashSet<>();
for (Transition t : transitions) {
if (t.getSourceStates() == null || t.getSourceStates().isEmpty())
continue;
@@ -107,33 +96,44 @@ public class PlantUml implements StateMachineExporter {
List<String> targets;
if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) {
if (!allowNoTarget) {
targets = t.getSourceStates().stream().map(s -> s.toString()).toList();
targets = t.getSourceStates().stream().map(s -> options.formatState(s)).toList();
} else {
continue;
}
} else {
targets = t.getTargetStates().stream().map(s -> s.toString()).toList();
targets = t.getTargetStates().stream().map(s -> options.formatState(s)).toList();
}
for (State rawSourceState : t.getSourceStates()) {
String source = simplify(rawSourceState.toString());
String source = simplify(options.formatState(rawSourceState));
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(">>");
String styleClass = getStyleClass(t, source, Collections.emptyMap());
String color = getLegacyColor(t, source, Collections.emptyMap());
sb.append(source).append(" -[#").append(color).append(",bold]-> ").append(target);
if (t.getEvent() != null && !t.getEvent().isBlank()) {
String eventClass = "e_" + normalize(t.getEvent());
sb.append(" <<").append(eventClass).append(">>");
usedEventStereotypes.add(eventClass);
}
sb.append(" <<").append(styleClass).append(">>");
String label = buildLabel(t, options.isUseLambdaGuards());
if (!label.isEmpty()) {
String label = buildLabel(t, options);
if (options.isEmbedIdentifiers()) {
String eventStr = t.getEvent() != null ? options.formatEvent(t.getEvent()) : null;
String linkId = eventStr != null && !eventStr.isBlank() ? "link_" + normalize(source) + "__" + normalize(eventStr) : "link_anon_" + normalize(source) + "_" + normalize(target);
sb.append(" : [[#").append(linkId).append(" ");
if (!label.isEmpty()) {
String sanitized = label.replace("[", "&#91;")
.replace("]", "&#93;")
.replace("<", "&lt;")
.replace(">", "&gt;");
sb.append(sanitized);
} else {
sb.append(".");
}
sb.append("]]");
} else if (!label.isEmpty()) {
sb.append(" : ").append(label);
}
sb.append("\n");
@@ -141,10 +141,6 @@ public class PlantUml implements StateMachineExporter {
}
}
for (String eventClass : usedEventStereotypes) {
sb.append("hide <<").append(eventClass).append(">> stereotype\n");
}
sb.append("\n");
for (String end : endStates) {
sb.append(simplify(end)).append(" --> [*]\n");
@@ -161,16 +157,9 @@ public class PlantUml implements StateMachineExporter {
private String getLegacyColor(Transition t, String source, Map<String, String> choiceStateClassMap) {
if (t.getType() == null)
return "000000";
return "94a3b8";
return switch (t.getType()) {
case CHOICE -> {
String className = choiceStateClassMap.get(source);
if (className != null && className.startsWith("choice_color_")) {
int index = Integer.parseInt(className.substring("choice_color_".length()));
yield choiceColors.get(index % choiceColors.size());
}
yield "000000";
}
case CHOICE -> "FF6347";
case EXTERNAL -> "1E90FF";
case INTERNAL -> "32CD32";
case LOCAL -> "FFA500";
@@ -184,7 +173,7 @@ public class PlantUml implements StateMachineExporter {
if (t.getType() == null)
return "default";
return switch (t.getType()) {
case CHOICE -> choiceStateClassMap.getOrDefault(source, "choice_type");
case CHOICE -> "choice_type";
case EXTERNAL -> "external";
case INTERNAL -> "internal";
case LOCAL -> "local";
@@ -199,35 +188,33 @@ public class PlantUml implements StateMachineExporter {
return ".puml";
}
private String getGuardText(Transition t, boolean useLambdaGuards) {
if (t.getGuard() == null || StringUtils.isBlank(t.getGuard().expression()))
return "";
if (useLambdaGuards && t.getGuard().isLambda())
return LAMBDA;
return t.getGuard().expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim();
@Override
public String simplify(String state) {
if (state == null) return "";
if (state.contains("\"")) return state;
return state.replaceAll("[^a-zA-Z0-9_.]", "");
}
private String getActionsText(Transition t, boolean useLambdaGuards) {
if (t.getActions() == null || t.getActions().isEmpty())
return "";
return t.getActions().stream()
.map(action -> (useLambdaGuards && action.isLambda()) ? LAMBDA : action.expression())
.collect(Collectors.joining(", "));
}
private String buildLabel(Transition t, boolean useLambdaGuards) {
private String buildLabel(Transition t, ExportOptions options) {
List<String> parts = new ArrayList<>();
if (t.getEvent() != null && !t.getEvent().isBlank())
parts.add(simplify(t.getEvent()));
String guard = getGuardText(t, useLambdaGuards);
if (!guard.isEmpty())
parts.add("[" + guard + "]");
String actions = getActionsText(t, useLambdaGuards);
if (!actions.isEmpty()) {
if (!parts.isEmpty())
parts.add("/ " + actions);
else
parts.add(actions);
if (t.getEvent() != null) {
String eventStr = options.formatEvent(t.getEvent());
if (eventStr != null && !eventStr.isBlank()) {
parts.add(eventStr);
}
}
if (t.getGuard() != null) {
String g = t.getGuard().expression();
if (options.isUseLambdaGuards() && t.getGuard().isLambda()) {
parts.add("[" + LAMBDA + "]");
} else {
parts.add("[" + g.replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim() + "]");
}
}
if (t.getActions() != null && !t.getActions().isEmpty()) {
parts.add("/ " + t.getActions().stream()
.map(a -> (options.isUseLambdaGuards() && a.isLambda()) ? LAMBDA : a.expression().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim())
.collect(Collectors.joining(", ")));
}
Optional.ofNullable(t.getOrder())
.filter(order -> t.getType() == TransitionType.CHOICE || t.getType() == TransitionType.JUNCTION)

View File

@@ -12,6 +12,11 @@ public class Scxml implements StateMachineExporter {
private static final String LAMBDA = "λ";
@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,
@@ -24,10 +29,10 @@ public class Scxml implements StateMachineExporter {
sb.append(startStates.isEmpty() ? "" : simplify(startStates.iterator().next()));
sb.append("\">\n");
Set<String> allStates = new HashSet<>();
Set<String> allStates = new java.util.LinkedHashSet<>();
for (Transition t : transitions) {
t.getSourceStates().forEach(s -> allStates.add(s.toString()));
t.getTargetStates().forEach(s -> allStates.add(s.toString()));
t.getSourceStates().forEach(s -> allStates.add(options.formatState(s)));
t.getTargetStates().forEach(s -> allStates.add(options.formatState(s)));
}
allStates.addAll(startStates);
allStates.addAll(endStates);
@@ -36,7 +41,7 @@ public class Scxml implements StateMachineExporter {
sb.append(" <state id=\"").append(simplify(state)).append("\">\n");
for (Transition t : transitions) {
if (t.getSourceStates() == null || t.getSourceStates().stream().noneMatch(s -> s.toString().equals(state)))
if (t.getSourceStates() == null || t.getSourceStates().stream().noneMatch(s -> options.formatState(s).equals(state)))
continue;
boolean allowNoTarget = t.getType() == TransitionType.JOIN || t.getType() == TransitionType.FORK;
@@ -53,7 +58,7 @@ public class Scxml implements StateMachineExporter {
}
for (State target : targets) {
sb.append(renderTransition(t, target, options.isUseLambdaGuards()));
sb.append(renderTransition(t, target, options));
}
}
sb.append(" </state>\n");
@@ -68,15 +73,18 @@ public class Scxml implements StateMachineExporter {
return ".scxml.xml";
}
private String renderTransition(Transition t, State target, boolean useLambdaGuards) {
private String renderTransition(Transition t, State target, ExportOptions options) {
StringBuilder sb = new StringBuilder();
sb.append(" <transition target=\"").append(simplify(target.toString())).append("\"");
sb.append(" <transition target=\"").append(simplify(options.formatState(target))).append("\"");
if (t.getEvent() != null && !t.getEvent().isBlank()) {
sb.append(" event=\"").append(escapeXml(t.getEvent())).append("\"");
if (t.getEvent() != null) {
String eventStr = options.formatEvent(t.getEvent());
if (eventStr != null && !eventStr.isBlank()) {
sb.append(" event=\"").append(escapeXml(eventStr)).append("\"");
}
}
String guard = getGuardText(t, useLambdaGuards);
String guard = getGuardText(t, options.isUseLambdaGuards());
if (!guard.isBlank()) {
sb.append(" cond=\"").append(escapeXml(guard)).append("\"");
}

View File

@@ -1,11 +1,16 @@
package click.kamil.springstatemachineexporter.exporter;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.model.Transition;
import java.util.List;
import java.util.Set;
public interface StateMachineExporter {
default String export(AnalysisResult result, ExportOptions options) {
return export(result.getName(), result.getTransitions(), result.getStartStates(), result.getEndStates(), options);
}
String export(String name,
List<Transition> transitions,
Set<String> startStates,

Some files were not shown because too many files have changed in this diff Show More