This commit is contained in:
2026-06-08 19:19:08 +02:00
parent ebb9fb5425
commit 7643c2fc07
7 changed files with 199 additions and 131 deletions

View File

@@ -12,8 +12,8 @@ dot -Tpng statemachine.dot -o statemachine.png
brew install plantuml
```
```shell
plantuml statemachine.uml.dot
plantuml -tsvg statemachine.uml.dot
plantuml statemachine.puml
plantuml -tsvg statemachine.puml
```
Possible formats
```shell
@@ -31,4 +31,4 @@ Possible formats
-txmi To generate XMI file for class diagram
```
plantuml statemachine.scxml
plantuml statemachine.scxml

View File

@@ -1,10 +1,11 @@
plugins {
id 'java'
id 'application'
id 'com.gradleup.shadow' version '8.3.5'
}
group = 'click.kamil.springstatemachineexporter'
version = '0.0.1-SNAPSHOT'
version = '1.0.0'
application {
mainClass = 'click.kamil.springstatemachineexporter.Main'
@@ -16,17 +17,18 @@ java {
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
// Picocli (standard version)
implementation 'info.picocli:picocli:4.7.6'
// Logging
implementation 'org.slf4j:slf4j-api:2.0.12'
implementation 'org.slf4j:slf4j-simple:2.0.12'
// deps for parsing java AST
implementation 'org.eclipse.jdt:org.eclipse.jdt.core:3.36.0'
@@ -35,7 +37,7 @@ dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:6.1.0'
testImplementation 'org.junit.jupiter:junit-jupiter-params:6.1.0'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:6.1.0'
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'
}

View File

@@ -1,124 +1,22 @@
package click.kamil.springstatemachineexporter;
import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.app.TransitionStateUtils;
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.out.Dot;
import click.kamil.springstatemachineexporter.ast.out.PlantUml;
import click.kamil.springstatemachineexporter.ast.out.Scxml;
import click.kamil.springstatemachineexporter.ast.out.StateMachineExporter;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import click.kamil.springstatemachineexporter.command.ExporterCommand;
import click.kamil.springstatemachineexporter.service.ExportService;
import picocli.CommandLine;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class Main {
private static final String START_DIR = ".";
private static final String OUTPUT_DIR = "./out";
private static final List<String> TARGET_ANNOTATIONS = List.of("EnableStateMachineFactory", "EnableStateMachine");
public static final Set<String> STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory");
public static void main(String[] args) {
// Manual DI / Wiring
var exporters = List.of(new PlantUml(), new Dot(), new Scxml());
var exportService = new ExportService(exporters);
var command = new ExporterCommand(exportService);
public static void main(String[] args) throws IOException {
runExporter(Path.of(START_DIR), Path.of(OUTPUT_DIR));
}
public static void runExporter(Path inputDir, Path outputDir) throws IOException {
CodebaseContext context = new CodebaseContext();
context.scan(inputDir);
List<StateMachineExporter> outputs = List.of(
new PlantUml(),
new Dot(),
new Scxml()
);
exportAll(context, outputs, outputDir);
}
public static void exportAll(CodebaseContext context, List<StateMachineExporter> outputs, Path outputDir) throws IOException {
Set<String> processedLocations = new HashSet<>();
// 1. Find entry point classes (annotated or extending adapter)
List<TypeDeclaration> entryPoints = context.findEntryPointClasses(TARGET_ANNOTATIONS);
for (TypeDeclaration td : entryPoints) {
String fqn = context.getFqn(td);
if (processedLocations.add(fqn)) {
processEntryPoint(td, outputs, outputDir, context);
}
}
// 2. Find methods returning StateMachine beans
List<MethodDeclaration> beanMethods = context.findBeanMethodsReturning(STATE_MACHINE_RETURN_TYPES);
for (MethodDeclaration m : beanMethods) {
TypeDeclaration parentClass = (TypeDeclaration) m.getParent();
String parentFqn = context.getFqn(parentClass);
String methodName = m.getName().getIdentifier();
String uniqueId = parentFqn + "#" + methodName;
if (processedLocations.add(uniqueId)) {
processBeanMethod(m, outputs, outputDir, context);
}
}
}
static void processEntryPoint(TypeDeclaration td, List<StateMachineExporter> outputs, Path outputDir, CodebaseContext context) throws IOException {
String className = context.getFqn(td);
System.out.println("Processing state machine config: " + className);
StateMachineAggregator aggregator = new StateMachineAggregator(context);
List<Transition> transitions = aggregator.aggregateTransitions(td);
aggregator.aggregateStates(td);
Set<String> initialStatesAst = aggregator.getInitialStates();
Set<String> endStatesAst = aggregator.getEndStates();
System.out.println("Start States Ast: " + initialStatesAst);
System.out.println("End States Ast: " + endStatesAst);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, initialStatesAst);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst);
generateOutputs(outputDir, className, transitions, startStates, endStates, outputs);
}
static void processBeanMethod(MethodDeclaration m, List<StateMachineExporter> outputs, Path outputDir, CodebaseContext context) throws IOException {
TypeDeclaration parentClass = (TypeDeclaration) m.getParent();
String parentFqn = context.getFqn(parentClass);
String methodName = m.getName().getIdentifier();
String uniqueName = parentFqn + "#" + methodName;
System.out.println("Processing state machine bean: " + uniqueName);
List<Transition> transitions = AstTransitionParser.parseTransitions(m, context);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
generateOutputs(outputDir, uniqueName, transitions, startStates, endStates, outputs);
}
private static void generateOutputs(Path outputDir, String baseName, List<Transition> transitions,
Set<String> startStates, Set<String> endStates,
List<StateMachineExporter> outputs) throws IOException {
Path targetDir = outputDir.resolve(baseName);
Files.createDirectories(targetDir);
for (StateMachineExporter output : outputs) {
String content = output.export(transitions, startStates, endStates, true);
String fileName = baseName + output.getFileExtension();
try (PrintWriter out = new PrintWriter(targetDir.resolve(fileName).toFile())) {
out.println(content);
}
}
int exitCode = new CommandLine(command).execute(args);
System.exit(exitCode);
}
}

View File

@@ -12,6 +12,7 @@ import java.util.Set;
public class Dot implements StateMachineExporter {
private static final String[] CHOICE_COLORS = {
"blue", "green", "red", "purple", "orange", "brown", "darkgreen", "darkblue"
};

View File

@@ -0,0 +1,56 @@
package click.kamil.springstatemachineexporter.command;
import click.kamil.springstatemachineexporter.service.ExportService;
import lombok.RequiredArgsConstructor;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import java.nio.file.Path;
import java.util.concurrent.Callable;
@Command(
name = "spring-sm-exporter",
mixinStandardHelpOptions = true,
version = "1.0.0",
description = "Exports Spring State Machine configurations to UML, DOT, and SCXML using AST analysis.",
headerHeading = "@|bold,underline Usage:|@%n%n",
synopsisHeading = "%n",
descriptionHeading = "%n@|bold,underline Description:|@%n%n",
parameterListHeading = "%n@|bold,underline Parameters:|@%n",
optionListHeading = "%n@|bold,underline Options:|@%n",
header = "Welcome to the @|bold,blue Spring State Machine Renderer|@!%n"
)
@RequiredArgsConstructor
public class ExporterCommand implements Callable<Integer> {
private final ExportService exportService;
@CommandLine.Spec
CommandLine.Model.CommandSpec spec;
@Option(names = {"-i", "--input"}, description = "Input directory containing the Spring Boot project source code.", defaultValue = ".")
private Path inputDir;
@Option(names = {"-o", "--output"}, description = "Output directory for the generated diagrams.", defaultValue = "./out")
private Path outputDir;
@Override
public Integer call() throws Exception {
var out = spec.commandLine().getOut();
var err = spec.commandLine().getErr();
out.println(CommandLine.Help.Ansi.AUTO.string("@|bold,green Starting export process...|@"));
out.println(CommandLine.Help.Ansi.AUTO.string("@|yellow Input Directory:|@ " + inputDir.toAbsolutePath()));
out.println(CommandLine.Help.Ansi.AUTO.string("@|yellow Output Directory:|@ " + outputDir.toAbsolutePath()));
try {
exportService.runExporter(inputDir, outputDir);
out.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,green SUCCESS:|@ All state machines have been exported to " + outputDir.toAbsolutePath()));
return 0;
} catch (Exception e) {
err.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,red ERROR:|@ An error occurred during the export process: " + e.getMessage()));
return 1;
}
}
}

View File

@@ -0,0 +1,113 @@
package click.kamil.springstatemachineexporter.service;
import click.kamil.springstatemachineexporter.ast.app.AstTransitionParser;
import click.kamil.springstatemachineexporter.ast.app.StateMachineAggregator;
import click.kamil.springstatemachineexporter.ast.app.TransitionStateUtils;
import click.kamil.springstatemachineexporter.ast.app.domain.Transition;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.out.StateMachineExporter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Slf4j
@RequiredArgsConstructor
public class ExportService {
private final List<StateMachineExporter> exporters;
private static final List<String> TARGET_ANNOTATIONS = List.of("EnableStateMachineFactory", "EnableStateMachine");
public static final Set<String> STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory");
public void runExporter(Path inputDir, Path outputDir) throws IOException {
CodebaseContext context = new CodebaseContext();
context.scan(inputDir);
exportAll(context, outputDir);
}
private void exportAll(CodebaseContext context, Path outputDir) throws IOException {
Set<String> processedLocations = new HashSet<>();
// 1. Find entry point classes (annotated or extending adapter)
List<TypeDeclaration> entryPoints = context.findEntryPointClasses(TARGET_ANNOTATIONS);
for (TypeDeclaration td : entryPoints) {
String fqn = context.getFqn(td);
if (processedLocations.add(fqn)) {
processEntryPoint(td, outputDir, context);
}
}
// 2. Find methods returning StateMachine beans
List<MethodDeclaration> beanMethods = context.findBeanMethodsReturning(STATE_MACHINE_RETURN_TYPES);
for (MethodDeclaration m : beanMethods) {
TypeDeclaration parentClass = (TypeDeclaration) m.getParent();
String parentFqn = context.getFqn(parentClass);
String methodName = m.getName().getIdentifier();
String uniqueId = parentFqn + "#" + methodName;
if (processedLocations.add(uniqueId)) {
processBeanMethod(m, outputDir, context);
}
}
}
private void processEntryPoint(TypeDeclaration td, Path outputDir, CodebaseContext context) throws IOException {
String className = context.getFqn(td);
log.info("Processing state machine config: {}", className);
StateMachineAggregator aggregator = new StateMachineAggregator(context);
List<Transition> transitions = aggregator.aggregateTransitions(td);
aggregator.aggregateStates(td);
Set<String> initialStatesAst = aggregator.getInitialStates();
Set<String> endStatesAst = aggregator.getEndStates();
log.debug("Start States Ast: {}", initialStatesAst);
log.debug("End States Ast: {}", endStatesAst);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions, initialStatesAst);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst);
generateOutputs(outputDir, className, transitions, startStates, endStates);
}
private void processBeanMethod(MethodDeclaration m, Path outputDir, CodebaseContext context) throws IOException {
TypeDeclaration parentClass = (TypeDeclaration) m.getParent();
String parentFqn = context.getFqn(parentClass);
String methodName = m.getName().getIdentifier();
String uniqueName = parentFqn + "#" + methodName;
log.info("Processing state machine bean: {}", uniqueName);
List<Transition> transitions = AstTransitionParser.parseTransitions(m, context);
Set<String> startStates = TransitionStateUtils.findStartStates(transitions);
Set<String> endStates = TransitionStateUtils.findEndStates(transitions);
generateOutputs(outputDir, uniqueName, transitions, startStates, endStates);
}
private void generateOutputs(Path outputDir, String baseName, List<Transition> transitions,
Set<String> startStates, Set<String> endStates) throws IOException {
Path targetDir = outputDir.resolve(baseName);
Files.createDirectories(targetDir);
for (StateMachineExporter output : exporters) {
String content = output.export(transitions, startStates, endStates, true);
String fileName = baseName + output.getFileExtension();
try (PrintWriter out = new PrintWriter(targetDir.resolve(fileName).toFile())) {
out.println(content);
}
}
}
}

View File

@@ -1,10 +1,10 @@
package click.kamil.springstatemachineexporter;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.out.Dot;
import click.kamil.springstatemachineexporter.ast.out.PlantUml;
import click.kamil.springstatemachineexporter.ast.out.Scxml;
import click.kamil.springstatemachineexporter.ast.out.StateMachineExporter;
import click.kamil.springstatemachineexporter.service.ExportService;
import click.kamil.springstatemachineexporter.utils.TestScenario;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
@@ -18,6 +18,9 @@ import static org.assertj.core.api.Assertions.assertThat;
public class RegressionTest {
private final List<StateMachineExporter> exporters = List.of(new PlantUml(), new Dot(), new Scxml());
private final ExportService exportService = new ExportService(exporters);
private static List<TestScenario> provideTestScenarios() {
return List.of(
new TestScenario(
@@ -92,12 +95,7 @@ public class RegressionTest {
@ParameterizedTest(name = "{0}")
@MethodSource("provideTestScenarios")
void testOutputMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception {
CodebaseContext context = new CodebaseContext();
context.scan(scenario.inputPath());
List<StateMachineExporter> outputs = List.of(new PlantUml(), new Dot(), new Scxml());
Main.exportAll(context, outputs, tempDir);
exportService.runExporter(scenario.inputPath(), tempDir);
// Find the generated directory (it might be named with FQN)
List<Path> generatedDirs;
@@ -113,7 +111,7 @@ public class RegressionTest {
String actualBaseName = actualDir.getFileName().toString();
for (StateMachineExporter exporter : outputs) {
for (StateMachineExporter exporter : exporters) {
String fileName = actualBaseName + exporter.getFileExtension();
String goldenFileName = targetBaseName + exporter.getFileExtension();