From 582f793b48c186be2ec60daf8299dce668d5a652 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sat, 22 Aug 2026 06:27:52 -0600 Subject: [PATCH] Adds the Mermaid configuration renderer StatifierUI.Diagram.render/2 is a pure function from a compiled machine and an active configuration to Mermaid stateDiagram-v2 source for Kino.Mermaid: composite blocks for compound states, "--" region dividers for parallel states, [*] markers for resolved initial states, and an active highlight class over the full configuration. Mermaid cannot draw a transition between internal states of two different composite states, so those edges are lifted to the composite siblings under the least common ancestor and marked [lifted: source -> target]; deep initial targets get the same treatment. The compromise is documented in the moduledoc, and the render/2 interface stays stable for the later elkjs swap. Refs: sui-t36.4 --- changelog.d/sui-t36.4.md | 7 + lib/statifier_ui/diagram.ex | 290 +++++++++++++++++++++++++ test/statifier_ui/diagram_test.exs | 325 +++++++++++++++++++++++++++++ 3 files changed, 622 insertions(+) create mode 100644 changelog.d/sui-t36.4.md create mode 100644 lib/statifier_ui/diagram.ex create mode 100644 test/statifier_ui/diagram_test.exs diff --git a/changelog.d/sui-t36.4.md b/changelog.d/sui-t36.4.md new file mode 100644 index 0000000..afbca02 --- /dev/null +++ b/changelog.d/sui-t36.4.md @@ -0,0 +1,7 @@ +### Added + +- `StatifierUI.Diagram.render/2` renders a compiled machine and an active + configuration as Mermaid `stateDiagram-v2` source for `Kino.Mermaid`, with + composite nesting, parallel regions, active-state highlighting, and + cross-hierarchy transitions lifted to the composite level with a + `[lifted: ...]` marker. diff --git a/lib/statifier_ui/diagram.ex b/lib/statifier_ui/diagram.ex new file mode 100644 index 0000000..abf17ca --- /dev/null +++ b/lib/statifier_ui/diagram.ex @@ -0,0 +1,290 @@ +defmodule StatifierUI.Diagram do + @moduledoc """ + Renders a compiled `Statifier.Machine` and an active configuration as + Mermaid `stateDiagram-v2` source, for display via `Kino.Mermaid` (or any + other Mermaid consumer - this module is pure and depends on neither Kino + nor LiveView). + + `render/2` is a pure function: machine and configuration in, diagram + source out. That interface is the stable part. The Mermaid backend is the + Livebook inspector's first rendering (statifier-ui ADR-0008 fixes the + destination stack as client-side elkjs producing SVG, and explicitly + leaves this first cut to the inspector epic); when the elkjs renderer + arrives it replaces the body of this module, not its callers. + + ## The accepted Mermaid compromise + + Mermaid cannot draw a transition between internal states of two + *different* composite states - the cross-hierarchy edges SCXML's LCCA + semantics make routine, and the limitation that disqualified Mermaid as + the destination renderer (ADR-0008). Rather than drop those transitions, + `render/2` lifts each one to the pair of composite siblings under the + endpoints' least common ancestor and draws the edge there, with a + `[lifted: -> ]` marker appended to the label naming the + real endpoints. The same lifting applies to a composite's `[*]` initial + marker when its resolved initial state is a deep descendant rather than a + direct child - the marker there is `[deep: ]`. + + ## What the source contains + + - Every state except the synthesized `:scxml` root is declared with a + stable `s` alias (the engine's document-order state index, the + identity vocabulary of statifier ADR-0012), so tests and callers can + address nodes without depending on how labels are escaped. + - Compound states are composite blocks; parallel states are composite + blocks whose children are separated by Mermaid's `--` region divider. + - `` states carry a `(final)` label suffix; history states carry + `(H)` (shallow) or `(H*)` (deep) - Mermaid has no native pseudo-state + notation for either. + - Transitions are labeled with their event descriptors; a guarded + transition carries a `[cond]` marker (the Machine retains the compiled + expression, not its source text). + - Active states - every index in the configuration, ancestors included, + per the full-configuration convention of statifier-ui ADR-0005 - are + assigned the `active` Mermaid class. Out-of-range indexes and the root + are ignored, so a stale or empty configuration degrades to an + unhighlighted chart rather than an error. + """ + + alias Statifier.Machine + alias Statifier.Machine.State + alias Statifier.Machine.Transition + + @indent " " + + @active_class "classDef active fill:#e0f2fe,stroke:#0284c7," <> + "stroke-width:2px,color:#0c4a6e" + + @doc """ + Renders `machine` with `configuration` highlighted, as Mermaid + `stateDiagram-v2` source. + + `configuration` is any enumerable of state indexes - typically the full + active configuration (`MapSet.t(non_neg_integer())`) a trace message or + `Statifier.MachineState` carries. Pass `[]` (or an empty set) for a chart + that is not running. + + ## Examples + + iex> {:ok, machine} = + ...> Statifier.compile(\"\"\" + ...> + ...> + ...> + ...> + ...> + ...> + ...> \"\"\") + iex> source = StatifierUI.Diagram.render(machine, [1]) + iex> String.starts_with?(source, "stateDiagram-v2") + true + iex> source =~ "class s1 active" + true + """ + @spec render(Machine.t(), Enumerable.t()) :: String.t() + def render(%Machine{} = machine, configuration) do + root = Machine.at(machine, 0) + + lines = + ["stateDiagram-v2"] ++ + Enum.flat_map(root.children, &declare(machine, &1, 1)) ++ + initial_lines(machine, root, 1) ++ + transition_lines(machine) ++ + highlight_lines(machine, configuration) + + Enum.join(lines, "\n") <> "\n" + end + + # ── Declarations ────────────────────────────────────────────────── + + @spec declare(Machine.t(), non_neg_integer(), pos_integer()) :: [String.t()] + defp declare(machine, index, depth) do + state = Machine.at(machine, index) + pad = String.duplicate(@indent, depth) + + case state do + %State{children: []} -> + [~s(#{pad}state "#{label(state)}" as s#{index})] + + %State{kind: :parallel} = parallel -> + regions = + parallel.children + |> Enum.map(&declare(machine, &1, depth + 1)) + |> Enum.intersperse([pad <> @indent <> "--"]) + |> List.flatten() + + [~s(#{pad}state "#{label(parallel)}" as s#{index} {)] ++ + regions ++ ["#{pad}}"] + + %State{} = compound -> + [~s(#{pad}state "#{label(compound)}" as s#{index} {)] ++ + Enum.flat_map(compound.children, &declare(machine, &1, depth + 1)) ++ + initial_lines(machine, compound, depth + 1) ++ + ["#{pad}}"] + end + end + + # A `[*] --> sN` marker per resolved initial state. A parallel state + # enters every region, so it gets no markers. An initial state that is + # not a direct child is lifted to the direct child on its path, with a + # `[deep: ...]` marker naming the real target (see the moduledoc). + @spec initial_lines(Machine.t(), State.t(), pos_integer()) :: [String.t()] + defp initial_lines(_machine, %State{kind: :parallel}, _depth), do: [] + + defp initial_lines(machine, %State{index: index, initial: initial}, depth) do + pad = String.duplicate(@indent, depth) + + Enum.map(initial, fn target -> + case child_toward(machine, index, target) do + ^target -> "#{pad}[*] --> s#{target}" + lifted -> "#{pad}[*] --> s#{lifted} : [deep: #{name(machine, target)}]" + end + end) + end + + # ── Transitions ─────────────────────────────────────────────────── + + @spec transition_lines(Machine.t()) :: [String.t()] + defp transition_lines(machine) do + skip = initial_transition_indexes(machine) + + for index <- 1..(tuple_size(machine.states) - 1)//1, + t_index <- Machine.at(machine, index).transitions, + not MapSet.member?(skip, t_index), + transition = Machine.transition(machine, t_index), + target <- transition.targets do + edge_line(machine, transition, target) + end + end + + @spec initial_transition_indexes(Machine.t()) :: MapSet.t(non_neg_integer()) + defp initial_transition_indexes(machine) do + for index <- 0..(tuple_size(machine.states) - 1)//1, + t_index = Machine.at(machine, index).initial_transition, + into: MapSet.new() do + t_index + end + end + + @spec edge_line(Machine.t(), Transition.t(), non_neg_integer()) :: String.t() + defp edge_line(machine, transition, target) do + {source, drawn_target, lifted?} = lift(machine, transition.source, target) + + marker = + if lifted? do + "[lifted: #{name(machine, transition.source)} -> #{name(machine, target)}]" + end + + edge_label = + [events_label(transition), cond_label(transition), marker] + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + + line = "#{@indent}s#{source} --> s#{drawn_target}" + if edge_label == "", do: line, else: "#{line} : #{edge_label}" + end + + @spec events_label(Transition.t()) :: String.t() | nil + defp events_label(%Transition{events: []}), do: nil + + defp events_label(%Transition{events: events}) do + Enum.map_join(events, " ", &Enum.join(&1, ".")) + end + + @spec cond_label(Transition.t()) :: String.t() | nil + defp cond_label(%Transition{cond: nil}), do: nil + defp cond_label(%Transition{}), do: "[cond]" + + # Mermaid draws an edge unless *both* endpoints sit strictly inside + # different composite children of their least common ancestor. In that + # one case, lift each endpoint to its composite child of the LCA. + @spec lift(Machine.t(), non_neg_integer(), non_neg_integer()) :: + {non_neg_integer(), non_neg_integer(), boolean()} + defp lift(machine, source, target) do + lca = last_common(ancestry(machine, source), ancestry(machine, target)) + source_child = child_toward(machine, lca, source) + target_child = child_toward(machine, lca, target) + + if source_child not in [nil, source] and target_child not in [nil, target] do + {source_child, target_child, true} + else + {source, target, false} + end + end + + # ── Highlighting ────────────────────────────────────────────────── + + @spec highlight_lines(Machine.t(), Enumerable.t()) :: [String.t()] + defp highlight_lines(machine, configuration) do + count = tuple_size(machine.states) + + drawn = + configuration + |> MapSet.new() + |> Enum.filter(&(is_integer(&1) and &1 > 0 and &1 < count)) + |> Enum.sort() + + case drawn do + [] -> + [] + + indexes -> + [ + @indent <> @active_class, + @indent <> "class " <> Enum.map_join(indexes, ",", &"s#{&1}") <> " active" + ] + end + end + + # ── Naming ──────────────────────────────────────────────────────── + + @spec label(State.t()) :: String.t() + defp label(%State{kind: :final} = state), do: "#{base_name(state)} (final)" + + defp label(%State{kind: :history, history_type: :deep} = state) do + "#{base_name(state)} (H*)" + end + + defp label(%State{kind: :history} = state), do: "#{base_name(state)} (H)" + defp label(%State{} = state), do: base_name(state) + + @spec base_name(State.t()) :: String.t() + defp base_name(%State{id: nil, index: index}), do: "(state #{index})" + defp base_name(%State{id: id}), do: String.replace(id, ~s("), "'") + + @spec name(Machine.t(), non_neg_integer()) :: String.t() + defp name(machine, index), do: base_name(Machine.at(machine, index)) + + # ── Hierarchy walks ─────────────────────────────────────────────── + + # Root-first ancestor chain, `index` included. + @spec ancestry(Machine.t(), non_neg_integer()) :: [non_neg_integer()] + defp ancestry(machine, index), do: ancestry(machine, index, []) + + defp ancestry(_machine, nil, acc), do: acc + + defp ancestry(machine, index, acc) do + ancestry(machine, Machine.at(machine, index).parent, [index | acc]) + end + + @spec last_common([non_neg_integer()], [non_neg_integer()]) :: non_neg_integer() + defp last_common([x, a | as], [x, b | bs]) when a == b do + last_common([a | as], [b | bs]) + end + + defp last_common([x | _], [x | _]), do: x + + # The direct child of `ancestor` on the path down to `descendant`; + # `descendant` itself when it is that child, `nil` when `descendant` + # is `ancestor`. + @spec child_toward(Machine.t(), non_neg_integer(), non_neg_integer()) :: + non_neg_integer() | nil + defp child_toward(_machine, ancestor, ancestor), do: nil + + defp child_toward(machine, ancestor, descendant) do + case Machine.at(machine, descendant).parent do + ^ancestor -> descendant + parent -> child_toward(machine, ancestor, parent) + end + end +end diff --git a/test/statifier_ui/diagram_test.exs b/test/statifier_ui/diagram_test.exs new file mode 100644 index 0000000..b10dbf3 --- /dev/null +++ b/test/statifier_ui/diagram_test.exs @@ -0,0 +1,325 @@ +defmodule StatifierUI.DiagramTest do + use ExUnit.Case, async: true + + alias StatifierUI.Diagram + + doctest StatifierUI.Diagram + + defp compile!(xml) do + {:ok, machine} = Statifier.compile(xml) + machine + end + + defp index!(machine, id) do + {:ok, index} = Statifier.Machine.index(machine, id) + index + end + + @flat """ + + + + + + + """ + + @nested """ + + + + + + + + + + """ + + @cross """ + + + + + + + + + + + """ + + @parallel """ + + + + + + + + + + + """ + + describe "render/2 - flat charts" do + test "produces stateDiagram-v2 source with aliased states and transitions" do + machine = compile!(@flat) + a = index!(machine, "a") + b = index!(machine, "b") + + source = Diagram.render(machine, []) + lines = String.split(source, "\n") + + assert hd(lines) == "stateDiagram-v2" + assert ~s(state "a" as s#{a}) in Enum.map(lines, &String.trim/1) + assert ~s(state "b" as s#{b}) in Enum.map(lines, &String.trim/1) + assert "s#{a} --> s#{b} : go" in Enum.map(lines, &String.trim/1) + end + + test "marks the chart's initial state from the root" do + machine = compile!(@flat) + a = index!(machine, "a") + + source = Diagram.render(machine, []) + + assert "[*] --> s#{a}" in trimmed_lines(source) + end + + test "renders eventless guarded transitions with a cond marker" do + machine = + compile!(""" + + + + + + + """) + + a = index!(machine, "a") + b = index!(machine, "b") + + assert "s#{a} --> s#{b} : [cond]" in trimmed_lines(Diagram.render(machine, [])) + end + + test "renders one edge per target of a multi-target transition" do + machine = + compile!(""" + + + + + + + + + + """) + + a = index!(machine, "a") + one = index!(machine, "one") + two = index!(machine, "two") + lines = trimmed_lines(Diagram.render(machine, [])) + + assert "s#{a} --> s#{one} : split" in lines + assert "s#{a} --> s#{two} : split" in lines + end + end + + describe "render/2 - active configuration highlighting" do + test "declares the active class and assigns it to every active state" do + machine = compile!(@nested) + outer = index!(machine, "outer") + inner_a = index!(machine, "inner_a") + + source = Diagram.render(machine, MapSet.new([0, outer, inner_a])) + lines = trimmed_lines(source) + + assert Enum.any?(lines, &String.starts_with?(&1, "classDef active ")) + assert "class s#{outer},s#{inner_a} active" in lines + end + + test "omits highlighting entirely for an empty configuration" do + machine = compile!(@flat) + source = Diagram.render(machine, MapSet.new()) + + refute source =~ "classDef" + refute source =~ "\nclass " + end + + test "never assigns the synthesized root a class" do + machine = compile!(@flat) + source = Diagram.render(machine, MapSet.new([0])) + + refute source =~ ~r/^\s*class /m + end + + test "accepts a plain list as the configuration" do + machine = compile!(@flat) + a = index!(machine, "a") + + assert "class s#{a} active" in trimmed_lines(Diagram.render(machine, [a])) + end + end + + describe "render/2 - compound nesting" do + test "renders a compound state as a composite block with its own initial" do + machine = compile!(@nested) + outer = index!(machine, "outer") + inner_a = index!(machine, "inner_a") + inner_b = index!(machine, "inner_b") + + source = Diagram.render(machine, []) + lines = trimmed_lines(source) + + assert ~s(state "outer" as s#{outer} {) in lines + assert ~s(state "inner_a" as s#{inner_a}) in lines + assert "[*] --> s#{inner_a}" in lines + assert "s#{inner_a} --> s#{inner_b} : step" in lines + + # The composite block closes. + assert "}" in lines + end + + test "nested declarations sit inside their parent's block" do + machine = compile!(@nested) + outer = index!(machine, "outer") + inner_a = index!(machine, "inner_a") + + source = Diagram.render(machine, []) + + opens = :binary.match(source, ~s(state "outer" as s#{outer} {)) |> elem(0) + inner = :binary.match(source, ~s(state "inner_a" as s#{inner_a})) |> elem(0) + closes = source |> String.reverse() |> :binary.match("}") |> elem(0) + closes = String.length(source) - closes + + assert opens < inner + assert inner < closes + end + end + + describe "render/2 - parallel states" do + test "separates parallel regions with the -- divider" do + machine = compile!(@parallel) + p = index!(machine, "p") + r1 = index!(machine, "region_one") + r2 = index!(machine, "region_two") + + source = Diagram.render(machine, []) + lines = trimmed_lines(source) + + assert ~s(state "p" as s#{p} {) in lines + assert "--" in lines + + # Both regions render inside; the divider sits between them. + r1_at = Enum.find_index(lines, &(&1 == ~s(state "region_one" as s#{r1} {))) + div_at = Enum.find_index(lines, &(&1 == "--")) + r2_at = Enum.find_index(lines, &(&1 == ~s(state "region_two" as s#{r2} {))) + + assert r1_at < div_at + assert div_at < r2_at + end + end + + describe "render/2 - cross-hierarchy transitions (the documented Mermaid limit)" do + test "lifts an edge between substates of different composites to the composites" do + machine = compile!(@cross) + left = index!(machine, "left") + right = index!(machine, "right") + l1 = index!(machine, "l1") + r1 = index!(machine, "r1") + + source = Diagram.render(machine, []) + lines = trimmed_lines(source) + + assert "s#{left} --> s#{right} : jump [lifted: l1 -> r1]" in lines + refute "s#{l1} --> s#{r1} : jump" in lines + end + + test "an edge from a substate to a top-level sibling is drawn directly" do + machine = + compile!(""" + + + + + + + + + """) + + inner = index!(machine, "inner") + other = index!(machine, "other") + + assert "s#{inner} --> s#{other} : out" in trimmed_lines(Diagram.render(machine, [])) + end + end + + describe "render/2 - final and history states" do + test "labels a final state" do + machine = + compile!(""" + + + + + + + """) + + done = index!(machine, "done_state") + + assert "state \"done_state (final)\" as s#{done}" in trimmed_lines( + Diagram.render(machine, []) + ) + end + + test "labels shallow and deep history states" do + machine = + compile!(""" + + + + + + + + + + + + """) + + shallow = index!(machine, "h_shallow") + deep = index!(machine, "h_deep") + lines = trimmed_lines(Diagram.render(machine, [])) + + assert "state \"h_shallow (H)\" as s#{shallow}" in lines + assert "state \"h_deep (H*)\" as s#{deep}" in lines + end + end + + describe "render/2 - anonymous states" do + test "falls back to an index-derived label for a state without an id" do + machine = + compile!(""" + + + + + + + """) + + lines = trimmed_lines(Diagram.render(machine, [])) + + assert "state \"(state 1)\" as s1" in lines + end + end + + defp trimmed_lines(source) do + source |> String.split("\n") |> Enum.map(&String.trim/1) + end +end