diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/MatchedTransition.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/MatchedTransition.java index b9fbef2..20754ae 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/MatchedTransition.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/MatchedTransition.java @@ -1,16 +1,20 @@ package click.kamil.springstatemachineexporter.analysis.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Builder; import lombok.Data; import lombok.extern.jackson.Jacksonized; @Data -@Builder +@Builder(toBuilder = true) @Jacksonized @JsonIgnoreProperties(ignoreUnknown = true) public class MatchedTransition { private final String sourceState; private final String targetState; private final String event; + /** Precomputed {@code #link_*} suffix for HTML/SVG highlight; optional in JSON exports. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String linkKey; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/exporter/TransitionLinkKey.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/exporter/TransitionLinkKey.java new file mode 100644 index 0000000..e648b45 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/exporter/TransitionLinkKey.java @@ -0,0 +1,60 @@ +package click.kamil.springstatemachineexporter.exporter; + +/** + * Builds SVG/HTML transition link keys ({@code Source__Event}) using the same formatting rules as + * {@link PlantUml} embedded identifiers and the HTML explorer {@code buildLinkKey} helper. + */ +public final class TransitionLinkKey { + + private TransitionLinkKey() { + } + + public static String build(String sourceStateIdentifier, String eventIdentifier) { + return build(sourceStateIdentifier, eventIdentifier, EnumFormat.fn, EnumFormat.fn); + } + + public static String build( + String sourceStateIdentifier, + String eventIdentifier, + EnumFormat stateFormat, + EnumFormat eventFormat) { + if (eventIdentifier == null || eventIdentifier.isBlank()) { + return ""; + } + String formattedEvent = formatIdentifier(eventIdentifier, eventFormat); + if (sourceStateIdentifier == null || sourceStateIdentifier.isBlank()) { + return "__" + normalize(formattedEvent); + } + return normalize(formatIdentifier(sourceStateIdentifier, stateFormat)) + + "__" + + normalize(formattedEvent); + } + + static String formatIdentifier(String identifier, EnumFormat format) { + if (identifier == null || identifier.isBlank()) { + return ""; + } + return switch (format) { + case fqn -> identifier; + case sn -> { + int lastDot = identifier.lastIndexOf('.'); + yield lastDot >= 0 ? identifier.substring(lastDot + 1) : identifier; + } + case fn -> { + int lastDot = identifier.lastIndexOf('.'); + if (lastDot <= 0) { + yield identifier; + } + int prevDot = identifier.lastIndexOf('.', lastDot - 1); + yield prevDot > 0 ? identifier.substring(prevDot + 1) : identifier; + } + }; + } + + static String normalize(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value.replaceAll("[^a-zA-Z0-9]", "_"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/TransitionLinkKeyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/TransitionLinkKeyTest.java new file mode 100644 index 0000000..2dc4ccc --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/TransitionLinkKeyTest.java @@ -0,0 +1,32 @@ +package click.kamil.springstatemachineexporter.exporter; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TransitionLinkKeyTest { + + @Test + void shouldBuildFnFormLinkKeyFromPackageCanonicalIdentifiers() { + assertThat(TransitionLinkKey.build( + "com.example.order.OrderState.NEW", + "com.example.order.OrderEvent.PAY")) + .isEqualTo("OrderState_NEW__OrderEvent_PAY"); + } + + @Test + void shouldBuildFqnFormLinkKeyWhenRequested() { + assertThat(TransitionLinkKey.build( + "com.example.order.OrderState.NEW", + "com.example.order.OrderEvent.PAY", + EnumFormat.fqn, + EnumFormat.fqn)) + .isEqualTo("com_example_order_OrderState_NEW__com_example_order_OrderEvent_PAY"); + } + + @Test + void shouldBuildEventOnlyLinkKeyWhenSourceMissing() { + assertThat(TransitionLinkKey.build(null, "com.example.order.OrderEvent.PAY")) + .isEqualTo("__OrderEvent_PAY"); + } +} diff --git a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java index 7528055..49ba604 100644 --- a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java +++ b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/exporter/HtmlExporter.java @@ -2,11 +2,14 @@ package click.kamil.springstatemachineexporter.html.exporter; 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.LinkResolution; +import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.exporter.ExportOptions; import click.kamil.springstatemachineexporter.exporter.PlantUml; import click.kamil.springstatemachineexporter.exporter.StateMachineExporter; +import click.kamil.springstatemachineexporter.exporter.TransitionLinkKey; import click.kamil.springstatemachineexporter.model.Transition; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -58,8 +61,9 @@ public class HtmlExporter implements StateMachineExporter { // 4. Load Template String template = loadTemplate(); - // 5. Prepare Metadata JSON - String metadataJson = objectMapper.writeValueAsString(result); + // 5. Prepare Metadata JSON (precompute link keys so HTML JS matches SVG identifiers) + AnalysisResult htmlMetadata = enrichLinkKeys(result, options); + String metadataJson = objectMapper.writeValueAsString(htmlMetadata); // Check if we should render metadata pane boolean hideMetadataProp = Boolean.getBoolean("html.hideMetadataPane"); @@ -147,6 +151,41 @@ public class HtmlExporter implements StateMachineExporter { return false; } + private static AnalysisResult enrichLinkKeys(AnalysisResult result, ExportOptions options) { + CodebaseMetadata metadata = result.getMetadata(); + if (metadata == null || metadata.getCallChains() == null || metadata.getCallChains().isEmpty()) { + return result; + } + List enrichedChains = metadata.getCallChains().stream() + .map(chain -> enrichChainLinkKeys(chain, options)) + .toList(); + return result.toBuilder() + .metadata(metadata.toBuilder().callChains(enrichedChains).build()) + .build(); + } + + private static CallChain enrichChainLinkKeys(CallChain chain, ExportOptions options) { + if (chain.getMatchedTransitions() == null || chain.getMatchedTransitions().isEmpty()) { + return chain; + } + List enriched = chain.getMatchedTransitions().stream() + .map(mt -> enrichMatchedTransitionLinkKey(mt, options)) + .toList(); + return chain.toBuilder().matchedTransitions(enriched).build(); + } + + private static MatchedTransition enrichMatchedTransitionLinkKey(MatchedTransition mt, ExportOptions options) { + if (mt.getLinkKey() != null && !mt.getLinkKey().isBlank()) { + return mt; + } + String linkKey = TransitionLinkKey.build( + mt.getSourceState(), + mt.getEvent(), + options.getStateFormat(), + options.getEventFormat()); + return mt.toBuilder().linkKey(linkKey).build(); + } + private String loadTemplate() { try (InputStream is = getClass().getResourceAsStream("/html/template.html")) { if (is == null) throw new RuntimeException("HTML template not found"); diff --git a/state_machine_exporter_html/src/main/resources/html/template.html b/state_machine_exporter_html/src/main/resources/html/template.html index 71cc5db..158df4f 100644 --- a/state_machine_exporter_html/src/main/resources/html/template.html +++ b/state_machine_exporter_html/src/main/resources/html/template.html @@ -492,12 +492,13 @@ let steps = []; chains.forEach(c => { c.matchedTransitions.forEach(t => { - const key = buildLinkKey(t.sourceState, t.event); + const key = t.linkKey || buildLinkKey(t.sourceState, t.event); if (!key || seen.has(key)) return; seen.add(key); steps.push({ event: t.event, source: t.sourceState, + linkKey: t.linkKey, ambiguous: c.linkResolution === 'AMBIGUOUS_WIDEN' || (c.triggerPoint && c.triggerPoint.ambiguous) }); @@ -528,7 +529,7 @@ let isAmbiguous = false; if (typeof step === 'object') { - targetLinkKey = buildLinkKey(step.source, step.event); + targetLinkKey = step.linkKey || buildLinkKey(step.source, step.event); isAmbiguous = step.ambiguous; } else if (step.includes("->")) { const parts = step.split("->"); diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java index dfd0831..b3c30ad 100644 --- a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java @@ -412,6 +412,7 @@ class HtmlTransitionLinkConsistencyTest { assertThat(html).contains("Ambiguous link"); assertThat(html).contains("#link_OrderState_NEW__OrderEvent_PAY"); + assertThat(html).contains("\"linkKey\" : \"OrderState_NEW__OrderEvent_PAY\""); assertThat(html).contains("com.example.web.AmbiguousController"); }