diff --git a/src/main/java/click/kamil/springstatemachineexporter/ast/out/Dot.java b/src/main/java/click/kamil/springstatemachineexporter/ast/out/Dot.java index ee32c12..128d92b 100644 --- a/src/main/java/click/kamil/springstatemachineexporter/ast/out/Dot.java +++ b/src/main/java/click/kamil/springstatemachineexporter/ast/out/Dot.java @@ -10,18 +10,17 @@ public class Dot implements StateMachineExporter { @Override public String export(List transitions, - Set startStates, - Set endStates, - boolean useLambdaGuards) { + Set startStates, + Set endStates, + boolean useLambdaGuards) { + StringBuilder sb = new StringBuilder(); sb.append("digraph stateMachine {\n"); sb.append(" rankdir=LR;\n"); sb.append(" node [shape=circle, style=filled, fillcolor=lightgray];\n\n"); - // 1. Fake start node sb.append(" start [shape=point, label=\"\"];\n"); - // 2. Find states with choice transitions Set statesWithChoice = new HashSet<>(); for (Transition t : transitions) { if ("withChoice".equals(t.getType())) { @@ -31,18 +30,20 @@ public class Dot implements StateMachineExporter { } } - // 3. Declare normal states and choice pseudostates Set allStates = new HashSet<>(); for (Transition t : transitions) { if (t.getSourceStates() != null) { - for (String s : t.getSourceStates()) allStates.add(simplify(s)); + for (String s : t.getSourceStates()) { + allStates.add(simplify(s)); + } } if (t.getTargetStates() != null) { - for (String tgt : t.getTargetStates()) allStates.add(simplify(tgt)); + for (String tgt : t.getTargetStates()) { + allStates.add(simplify(tgt)); + } } } - // Print all normal states (except choice pseudostates for now) for (String state : allStates) { if (!statesWithChoice.contains(state + "_choice")) { String shape = endStates.contains(state) ? "doublecircle" : "circle"; @@ -50,46 +51,40 @@ public class Dot implements StateMachineExporter { .append(" [shape=").append(shape).append(", fillcolor=lightgray];\n"); } } - sb.append("\n"); - // Declare choice pseudostates as diamonds for (String state : statesWithChoice) { String choiceNode = state + "_choice"; sb.append(" ").append(choiceNode) .append(" [shape=diamond, label=\"\", fillcolor=lightyellow];\n"); } + sb.append("\n"); - // 4. Connect start node(s) to start states for (String start : startStates) { sb.append(" start -> ").append(simplify(start)).append(";\n"); } + sb.append("\n"); - // 5. Connect normal states to their choice pseudostates for (String state : statesWithChoice) { String choiceNode = state + "_choice"; sb.append(" ").append(state).append(" -> ").append(choiceNode).append(";\n"); } + sb.append("\n"); - // 6. Transitions: reroute withChoice transitions via choice pseudostate for (Transition t : transitions) { if (t.getSourceStates() == null || t.getSourceStates().isEmpty()) continue; boolean allowNoTarget = "withJoin".equals(t.getType()) || "withFork".equals(t.getType()); + List targets = t.getTargetStates(); - List targets; - if (t.getTargetStates() == null || t.getTargetStates().isEmpty()) { + if (targets == null || targets.isEmpty()) { if (!allowNoTarget) { - // self-loop: targets = sources - targets = t.getSourceStates(); + targets = t.getSourceStates(); // self-loop } else { - // withJoin/withFork with no targets: skip continue; } - } else { - targets = t.getTargetStates(); } for (String rawSource : t.getSourceStates()) { @@ -103,54 +98,97 @@ public class Dot implements StateMachineExporter { edgeSource = source + "_choice"; } - // Prepare guard text + // Guard String guardText = ""; if (t.getGuard() != null && !t.getGuard().isBlank()) { if (useLambdaGuards && t.isLambdaGuard()) { - guardText = "lambda"; + guardText = "λ"; } else { - guardText = t.getGuard().replaceAll("[\\n\\r]+", " ").replaceAll("\\s{2,}", " ").trim(); + guardText = escapeDotString( + t.getGuard().replaceAll("[\\n\\r]+", " ") + .replaceAll("\\s{2,}", " ") + .trim() + ); } } - // Prepare actions text + // Actions String actionsText = ""; if (t.getActions() != null && !t.getActions().isEmpty()) { StringBuilder actionBuilder = new StringBuilder(); for (int i = 0; i < t.getActions().size(); i++) { if (i > 0) actionBuilder.append(", "); String action = t.getActions().get(i); - boolean isLambda = t.getIsLambdaActions().get(i); + boolean isLambda = i < t.getIsLambdaActions().size() && t.getIsLambdaActions().get(i); if (useLambdaGuards && isLambda) { - actionBuilder.append("lambda"); + actionBuilder.append("λ"); } else { - actionBuilder.append(action); + actionBuilder.append(escapeDotString(action)); } } actionsText = actionBuilder.toString(); } - // Build label with event, guard, and actions + // Event and label StringBuilder label = new StringBuilder(); if (t.getEvent() != null && !t.getEvent().isBlank()) { - label.append(simplify(t.getEvent())); + label.append(escapeDotString(simplify(t.getEvent()))); } + if (!guardText.isBlank()) { if (!label.isEmpty()) label.append(" "); label.append("[").append(guardText).append("]"); } + if (!actionsText.isBlank()) { if (!label.isEmpty()) label.append(" / "); label.append(actionsText); } - // Add order info for choice/junction transitions if (("withChoice".equals(t.getType()) || "withJunction".equals(t.getType())) && t.getOrder() != null) { if (!label.isEmpty()) label.append(" "); label.append("(order=").append(t.getOrder()).append(")"); } - String edgeLabel = !label.isEmpty() ? " [label=\"" + label.toString() + "\"]" : ""; + // Build edge attributes + StringBuilder edgeAttributes = new StringBuilder(); + if (!label.isEmpty()) { + appendEdgeAttr(edgeAttributes, "label", label.toString()); + } + + switch (t.getType()) { + case "withInternal": + appendEdgeAttr(edgeAttributes, "style", "dashed"); + appendEdgeAttr(edgeAttributes, "color", "gray"); + break; + case "withLocal": + appendEdgeAttr(edgeAttributes, "style", "dotted"); + appendEdgeAttr(edgeAttributes, "color", "darkgray"); + break; + case "withChoice": + appendEdgeAttr(edgeAttributes, "style", "solid"); + appendEdgeAttr(edgeAttributes, "color", "blue"); + break; + case "withJunction": + appendEdgeAttr(edgeAttributes, "style", "dotted"); + appendEdgeAttr(edgeAttributes, "color", "purple"); + break; + case "withFork": + appendEdgeAttr(edgeAttributes, "style", "bold"); + appendEdgeAttr(edgeAttributes, "color", "green"); + break; + case "withJoin": + appendEdgeAttr(edgeAttributes, "style", "bold"); + appendEdgeAttr(edgeAttributes, "color", "darkorange"); + break; + case "withExternal": + default: + appendEdgeAttr(edgeAttributes, "style", "solid"); + appendEdgeAttr(edgeAttributes, "color", "black"); + break; + } + + String edgeLabel = !edgeAttributes.isEmpty() ? " [" + edgeAttributes + "]" : ""; sb.append(" ").append(edgeSource).append(" -> ").append(target).append(edgeLabel).append(";\n"); } @@ -165,4 +203,15 @@ public class Dot implements StateMachineExporter { public String getFileExtension() { return ".dot"; } + + private String escapeDotString(String input) { + if (input == null) return ""; + return input.replace("\\", "\\\\") + .replace("\"", "\\\""); + } + + private void appendEdgeAttr(StringBuilder sb, String key, String value) { + if (!sb.isEmpty()) sb.append(", "); + sb.append(key).append("=\"").append(value).append("\""); + } }