diff --git a/README.md b/README.md index a105c62..495c044 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file +plantuml statemachine.scxml diff --git a/state_machine_exporter/build.gradle b/state_machine_exporter/build.gradle index 46ded9c..2b57e3b 100644 --- a/state_machine_exporter/build.gradle +++ b/state_machine_exporter/build.gradle @@ -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' } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java index c627378..17efd24 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java @@ -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 TARGET_ANNOTATIONS = List.of("EnableStateMachineFactory", "EnableStateMachine"); - public static final Set 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 outputs = List.of( - new PlantUml(), - new Dot(), - new Scxml() - ); - - exportAll(context, outputs, outputDir); - } - - public static void exportAll(CodebaseContext context, List outputs, Path outputDir) throws IOException { - Set processedLocations = new HashSet<>(); - - // 1. Find entry point classes (annotated or extending adapter) - List 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 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 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 transitions = aggregator.aggregateTransitions(td); - - aggregator.aggregateStates(td); - Set initialStatesAst = aggregator.getInitialStates(); - Set endStatesAst = aggregator.getEndStates(); - - System.out.println("Start States Ast: " + initialStatesAst); - System.out.println("End States Ast: " + endStatesAst); - - Set startStates = TransitionStateUtils.findStartStates(transitions, initialStatesAst); - Set endStates = TransitionStateUtils.findEndStates(transitions, endStatesAst); - - generateOutputs(outputDir, className, transitions, startStates, endStates, outputs); - } - - static void processBeanMethod(MethodDeclaration m, List 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 transitions = AstTransitionParser.parseTransitions(m, context); - - Set startStates = TransitionStateUtils.findStartStates(transitions); - Set endStates = TransitionStateUtils.findEndStates(transitions); - - generateOutputs(outputDir, uniqueName, transitions, startStates, endStates, outputs); - } - - private static void generateOutputs(Path outputDir, String baseName, List transitions, - Set startStates, Set endStates, - List 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); } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/out/Dot.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/out/Dot.java index dfe0978..1f346ea 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/out/Dot.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/out/Dot.java @@ -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" }; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java new file mode 100644 index 0000000..458892e --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java @@ -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 { + + 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; + } + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java new file mode 100644 index 0000000..b9605a7 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java @@ -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 exporters; + private static final List TARGET_ANNOTATIONS = List.of("EnableStateMachineFactory", "EnableStateMachine"); + public static final Set 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 processedLocations = new HashSet<>(); + + // 1. Find entry point classes (annotated or extending adapter) + List 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 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 transitions = aggregator.aggregateTransitions(td); + + aggregator.aggregateStates(td); + Set initialStatesAst = aggregator.getInitialStates(); + Set endStatesAst = aggregator.getEndStates(); + + log.debug("Start States Ast: {}", initialStatesAst); + log.debug("End States Ast: {}", endStatesAst); + + Set startStates = TransitionStateUtils.findStartStates(transitions, initialStatesAst); + Set 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 transitions = AstTransitionParser.parseTransitions(m, context); + + Set startStates = TransitionStateUtils.findStartStates(transitions); + Set endStates = TransitionStateUtils.findEndStates(transitions); + + generateOutputs(outputDir, uniqueName, transitions, startStates, endStates); + } + + private void generateOutputs(Path outputDir, String baseName, List transitions, + Set startStates, Set 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); + } + } + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java index e8a7653..33370ad 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java @@ -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 exporters = List.of(new PlantUml(), new Dot(), new Scxml()); + private final ExportService exportService = new ExportService(exporters); + private static List 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 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 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();