cli
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user