From d3434cbcb731170b6ac13aa964ccce54073ca0d3 Mon Sep 17 00:00:00 2001 From: Vsevolod Strukchinsky Date: Sun, 4 Oct 2026 00:03:29 +0500 Subject: [PATCH] Draw the machine as a Mermaid state diagram Machine.Mermaid renders what DOT renders, for a mermaid block in Markdown that GitHub draws in place: groups are composite states, nested and left out exactly as DOT's clusters, with inherited transitions drawn once from the composite under the same conditions; an internal transition is a line inside its state; [*] marks the start and follows a terminal. The two renderers share one layout, so they cannot disagree about which states a group holds or which arrows leave its boundary. Names are only labels, behind positional ids, and every character Mermaid reads as syntax or markup is written as an entity code, in the comment for an undrawn group too, since Mermaid applies a %%{ directive even there. Every shape the tests pin was rendered with mermaid-cli. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 45 ++++++-- README.md | 34 +++++- docs/DESIGN.md | 9 +- example_test.go | 26 +++++ fsm.go | 4 +- fsm_test.go | 154 +++++++++++++++++++++++++++- introspect.go | 267 +++++++++++++++++++++++++++++++++++------------- rules.go | 15 +-- 8 files changed, 460 insertions(+), 94 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02323a4..2b0c4dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,8 @@ chain through `On`/`To` with its `Guard`/`Action` methods, plus `Rules`, `Group`, `OnEnter`, `OnExit`, `OnEnterVia`, `OnExitVia`, `OnEnterWith`, `OnExitWith`, `OnTransition`, `Initial`, `Stay`, `OnEnterGroup`/`OnExitGroup` and their With variants, and `buildPlans`), `introspect.go` (`States`, `Events`, `Edges`, -`Groups`, `Initial`, `Terminals`, `Unreachable`, `DOT`). Tests are +`Groups`, `Initial`, `Terminals`, `Unreachable`, `DOT`, `Mermaid`, and the +`layout` both diagrams draw groups from). Tests are `fsm_test.go` and the worked machines in `example_test.go`. ## Working rules @@ -143,7 +144,7 @@ without measuring. **`Initial` is configuration, not state.** It records where a fresh instance starts so that `New` can reject an unreachable state or a dead start, and so -`DOT` can draw the `__start` point. It must not grow into a `Machine.State()`; +`DOT` can draw the `__start` point and `Mermaid` its `[*]`. It must not grow into a `Machine.State()`; see the first paragraph. The check runs in `b.deferred`, after group expansion, so inherited edges count as reachability, and is skipped when the definition already has errors, since a mistyped group member is a state that @@ -233,6 +234,27 @@ mirroring `Transition.Event()`/`Transition.trigger`/`Transition.Is`. `compound=true` is only emitted when the machine has groups, which keeps the existing `ExampleMachine_DOT` output byte-identical. +**`Mermaid` and `DOT` share one `layout`.** `layout` holds which groups are +boxed (`drawableGroups`) and which are not (`unboxed`), how boxes nest +(`parent`), the box holding each state (`owner`), and per edge whether it +leaves a box's boundary (`tail`) or is covered by an arrow already drawn from +it (`skip`); `nest` walks the boxes for either renderer, and `Edge.label` +writes both renderers' edge labels. A rule about what may be drawn goes +there, once, so the two diagrams cannot drift apart. Mermaid ids are +positional — `sN` by `States()` order, `gN` by `Groups()` order — and names +are only ever labels, written by `mermaidEscape` with entity codes (`#58;`) +for every character Mermaid reads as syntax or markup, so no name can break +the syntax: `&` because labels are HTML, `%` because Mermaid applies a +`%%{…}%%` directive even inside a `%%` comment, which is why the comment for +an unboxed group is escaped the same way. An empty name is a zero-width +space, since Mermaid fills an empty label with the id. The title is +`strconv.Quote`d, whose escapes YAML's double-quoted strings read. An +internal transition is a description line in its state (`sN : ev [guard]`), +the first line being the name, and a terminal leads to `[*]`. Every shape the +tests pin was rendered with `@mermaid-js/mermaid-cli` (`mmdc`), edges between +states in different composite states included, which Mermaid's docs call +unsupported but it draws; re-check with it before changing the syntax. + **A broken group reports one error, not a cascade.** `declareGroup` returns a bool and `expandGroup` bails on false, so an unnamed group, an empty one, or one listing a member twice does not also get reported as "every member @@ -296,9 +318,9 @@ but it is not a way out (`Terminals` skips it) and it enters and leaves nothing (`decl.internal` keeps it out of every Via and With hook). **Declaration order is the output order.** `Machine.states` and `Machine.edges` -are slices kept alongside the maps purely so `States`, `Edges`, `Terminals` and -`DOT` never iterate a map. `S` is only `comparable`, not ordered, so there is -nothing to sort by; first-seen order is the stable answer. `DOT` output is +are slices kept alongside the maps purely so `States`, `Edges`, `Terminals`, +`DOT` and `Mermaid` never iterate a map. `S` is only `comparable`, not ordered, so there is +nothing to sort by; first-seen order is the stable answer. The diagrams are committed-and-diffable only as long as that holds. ## Tests @@ -331,8 +353,9 @@ thing there is to test against, and that is the right target anyway. machine from fuzzed `(source, event, target)` rows — sources are the four states plus two groups overlapping on `running`, targets the four states or `Stay`, and two of the three events share the name `a` — then asserts what - must hold for any definition `New` accepted: `DOT` is deterministic, every - edge endpoint is in `States()`, an internal edge loops on its source, an + must hold for any definition `New` accepted: `DOT` and `Mermaid` are + deterministic, every edge endpoint is in `States()`, an internal edge loops + on its source, an inherited edge names a group that `Has` its source, `Events()` repeats no name, `Terminals()` is exactly the states with no outgoing external edge, `Unreachable` is closed under the edge relation, a declared `Initial` leaves @@ -366,13 +389,14 @@ thing there is to test against, and that is the right target anyway. concrete-func assertion in `Fire` panics. - **`GuardError` unwraps to the guard's error**, so a guard can reject with a sentinel the caller matches with `errors.Is`. `Guard`'s `desc` is the - *static* condition — it labels the edge in `DOT` and names the guard in the + *static* condition — it labels the edge in the diagrams and names the guard in the message — and the returned error is the *dynamic* reason it did not hold this time. Keep both; they are not redundant. - **Examples are `Example` functions with verified `Output`.** `example_test.go` carries the two machines the design was drawn from, a recording lifecycle and a participant connection. They are tests, not prose: changing the API means - changing them, and `ExampleMachine_DOT` pins the exact rendering. + changing them, and `ExampleMachine_DOT` and `ExampleMachine_Mermaid` pin the + exact renderings. - **Events are prefixed `ev`; states take the plain domain prefix.** The rule exists because the natural names collide by tense — `recStop` the event beside `recStopped` the state, `pcpReconnect` beside `pcpReconnecting`. A @@ -418,7 +442,8 @@ thing there is to test against, and that is the right target anyway. `looplab.FSM` is one object per entity, which is a different trade, not a slower implementation of the same one. - **Every Go block in `README.md` compiles.** The quick start is a whole - `main.go`, and its printed output, the `Edges` listing and the `DOT` block + `main.go`, and its printed output, the `Edges` listing, the `DOT` block and + the `mermaid` block (`ExampleMachine_Mermaid`'s output) are copied from a real run of that exact machine, which is the same machine `example_test.go` pins. Changing one means re-running all of them — extract the blocks, `go vet`, run, paste back. diff --git a/README.md b/README.md index 79cb3a0..0387878 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ fsm.From(recStopped).On(evRecFinish).To(recFinished). Action(sealManifest) ``` -**Guards** run in registration order and the first rejection wins. The description is the static condition — it names the guard in the error and labels the edge in [`DOT`](#dot); the returned error is why it failed *this time*, and `GuardError` unwraps to it, so `errors.Is(err, errUploadPending)` works. +**Guards** run in registration order and the first rejection wins. The description is the static condition — it names the guard in the error and labels the edge in [`DOT`](#dot) and [`Mermaid`](#mermaid); the returned error is why it failed *this time*, and `GuardError` unwraps to it, so `errors.Is(err, errUploadPending)` works. An **action** runs after every guard passed and before the state moves; if it fails, nothing is assigned and no hook runs. @@ -179,7 +179,7 @@ fsm.MustNew("recording", // panic: fsm recording: states [finished uploaded] cannot be reached from initial state active ``` -It is also what [`DOT`](#dot) draws the start marker from. +It is also what [`DOT`](#dot) and [`Mermaid`](#mermaid) draw the start marker from. ## Firing @@ -252,7 +252,7 @@ An event a state handles without leaving it — a heartbeat that refreshes a dea fsm.From(follower).On(evHeartbeat).Stay().Action(recordLeader) ``` -Guards and actions run as on any transition, and `Fire` returns a transition from the state to itself; the state is not left, so no hook runs, `OnTransition` included. Keeping such events in the table is what lets it say what each state accepts: a `NoTransitionError` still means the state refuses the event, `Check` and `Can` answer for it, and `DOT` draws it as a dashed loop. It is not a way out, so a state with only internal transitions is still in `Terminals()`. +Guards and actions run as on any transition, and `Fire` returns a transition from the state to itself; the state is not left, so no hook runs, `OnTransition` included. Keeping such events in the table is what lets it say what each state accepts: a `NoTransitionError` still means the state refuses the event, `Check` and `Can` answer for it, and `DOT` draws it as a dashed loop, `Mermaid` as a line inside the state. It is not a way out, so a state with only internal transitions is still in `Terminals()`. ### Typed hooks @@ -410,6 +410,8 @@ func TestRecordingShape(t *testing.T) { An unintended terminal is somewhere a value gets stuck; an unreachable state usually means a missing transition. Declaring `Initial` turns the second check into a build error. +`DOT` and `Mermaid` draw the machine. + `Edges` returns the table, guard descriptions included: ```go @@ -452,6 +454,32 @@ digraph "recording" { A group inside another is drawn inside its cluster. A group that partly overlaps an earlier one cannot be drawn as a cluster without boxing the wrong states, so it gets none, and a comment in the output says so. A boundary arrow is drawn only when it would be accurate — per-member arrows are used when a member overrides the event, when the group has no cluster, or when the target is itself a member. Output follows declaration order, so it can be committed next to the code and diffed. +### Mermaid + +`Mermaid` renders the same diagram as a Mermaid state diagram, for a `mermaid` block in Markdown, which GitHub draws in place. Groups are composite states, nested, left out and drawn from as `DOT` draws its clusters; an internal transition is a line inside its state, below the name; `[*]` points at the initial state, and a terminal points at `[*]`. This is `participantFSM` from [Groups](#groups): + +```mermaid +--- +title: "participant" +--- +stateDiagram-v2 + direction LR + state "live" as g0 { + s0 + s1 + } + s0 : connected + s1 : reconnecting + s2 : deleted + [*] --> s0 + s0 --> s1 : disconnect
[disconnect was not intentional] + s1 --> s0 : reconnect + g0 --> s2 : kick + s2 --> [*] +``` + +A block in a README goes stale as the machine changes; a test that compares it with `Mermaid()` keeps it honest. States and groups are numbered by declaration order (`s0`, `g0`) and labelled with their names, so any name is safe: Mermaid's syntax characters are written as entity codes. + ## Performance Guards and actions are combined at construction, where the payload type is still known, and stored as a concrete `func(context.Context, A) error`. `Fire` asserts back to that type and passes the payload directly, so nothing is boxed. Hooks are worked out at construction too: every row gets a plan, the hooks it runs before the assignment and after it, so a machine with hooks does one lookup per fire, and one without skips even that on a flag. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0465245..ec12af3 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -185,6 +185,13 @@ than it has — and drawing it anyway, as this package once did, put a box around the wrong states. It gets no cluster, and a `//` comment in the output says why. +`Mermaid` draws from the same layout: a group is a composite state, nested, +left out and drawn from exactly where `DOT` has a cluster, so the two cannot +disagree about which states a group holds. Its own choices follow UML's +notation: an internal transition is a line in the state's box rather than a +loop, since a loop reads as leaving and coming back, and a terminal leads to +`[*]`, Mermaid's final state, rather than wearing a style a theme can lose. + The count behind that decision is keyed on the trigger's identity, not its name. Keying on the name merges two same-named events into one group transition, and the arrow it collapses to covers neither of them. `Edge` keeps @@ -374,7 +381,7 @@ saying what the state accepts. A Raft elector shows how much that is: granting a vote, counting one, a follower's heartbeat and a leader's acks are the events it sees most, and none of them moves it. With `Stay` they are rows again: `NoTransitionError` means the state refuses the event rather than -"handled elsewhere", `Check` and `Can` answer for them, and `DOT` draws them. +"handled elsewhere", `Check` and `Can` answer for them, and the diagrams draw them. An internal transition enters and leaves nothing, so it is not a way out (`Terminals` keeps a state that has only these), and it triggers no Via or diff --git a/example_test.go b/example_test.go index eabf169..66f567f 100644 --- a/example_test.go +++ b/example_test.go @@ -263,3 +263,29 @@ func ExampleMachine_DOT() { // "finished" -> "uploaded" [label="uploaded"]; // } } + +// Mermaid draws the same diagram for a mermaid block in Markdown, which +// GitHub renders: the group is a composite state, the kick leaves it once, +// and [*] marks where the machine starts and where it can end. +func ExampleMachine_Mermaid() { + fmt.Print(participantFSM.Mermaid()) + + // Output: + // --- + // title: "participant" + // --- + // stateDiagram-v2 + // direction LR + // state "live" as g0 { + // s0 + // s1 + // } + // s0 : connected + // s1 : reconnecting + // s2 : deleted + // [*] --> s0 + // s0 --> s1 : disconnect
[disconnect was not intentional] + // s1 --> s0 : reconnect + // g0 --> s2 : kick + // s2 --> [*] +} diff --git a/fsm.go b/fsm.go index defeb71..396fe21 100644 --- a/fsm.go +++ b/fsm.go @@ -164,14 +164,14 @@ type Machine[S comparable] struct { initial S hasInitial bool - // Declaration order, kept so that introspection and DOT output are + // Declaration order, kept so that introspection and the diagrams are // deterministic rather than map-iteration order. states []S edges []Edge[S] groups []Group[S] } -// Name returns the machine's name, used in error messages and DOT output. +// Name returns the machine's name, used in error messages and diagrams. func (m *Machine[S]) Name() string { return m.name } // Fire applies ev to the state pointed to by st and returns the transition diff --git a/fsm_test.go b/fsm_test.go index 23936e5..225dfc3 100644 --- a/fsm_test.go +++ b/fsm_test.go @@ -3281,6 +3281,155 @@ func TestGroupRejectsARepeatedMember(t *testing.T) { } } +// --- Mermaid --------------------------------------------------------------- + +// Mermaid draws the groups DOT draws, as composite states nested the same +// way, leaves out the one DOT leaves out with a comment, and draws a +// boundary arrow from the inner group as DOT does. +func TestMermaidDrawsGroupsAsDOTDoes(t *testing.T) { + t.Parallel() + + outer := fsm.NewGroup("outer", idle, running, done) + inner := fsm.NewGroup("inner", running, done) + cross := fsm.NewGroup("cross", done, cancelled) + m, err := fsm.New("nested", + fsm.Initial(idle), + fsm.From(idle).On(evStart).To(running), + fsm.From(running).On(evFinish).To(done), + fsm.FromGroup(inner).On(evCancel).To(idle), + fsm.FromGroup(outer).On(evStart).To(cancelled), + cross, + ) + if err != nil { + t.Fatalf("build: %v", err) + } + want := `--- +title: "nested" +--- +stateDiagram-v2 + direction LR + state "outer" as g1 { + state "inner" as g0 { + s1 + s2 + } + s0 + } + %% group cross overlaps another without containing it, so it is not drawn as a composite state + s0 : idle + s1 : running + s2 : done + s3 : cancelled + [*] --> s0 + s0 --> s1 : start + s1 --> s2 : finish + g0 --> s0 : cancel + s1 --> s3 : start + s2 --> s3 : start + s3 --> [*] +` + if got := m.Mermaid(); got != want { + t.Errorf("Mermaid\n%s\nwant\n%s", got, want) + } +} + +// An internal transition is a line inside its state, below the name, rather +// than an arrow, and its guard follows the event on that line. +func TestMermaidWritesInternalTransitionsInsideTheState(t *testing.T) { + t.Parallel() + + m, err := fsm.New("job", + fsm.From(idle).On(evStart).To(running), + fsm.From(running).On(evFinish).Stay().Guard("positive", func(context.Context, int) error { return nil }), + fsm.From(running).On(evCancel).Stay(), + ) + if err != nil { + t.Fatalf("build: %v", err) + } + want := `--- +title: "job" +--- +stateDiagram-v2 + direction LR + s0 : idle + s1 : running + s1 : finish [positive] + s1 : cancel + s0 --> s1 : start + s1 --> [*] +` + if got := m.Mermaid(); got != want { + t.Errorf("Mermaid\n%s\nwant\n%s", got, want) + } +} + +// On an arrow a guard goes below its event, as in DOT. +func TestMermaidPutsAGuardBelowItsEvent(t *testing.T) { + t.Parallel() + + m, err := fsm.New("job", + fsm.From(idle).On(evStart).To(running).Guard("ready", func(context.Context, fsm.Unit) error { return nil }), + ) + if err != nil { + t.Fatalf("build: %v", err) + } + if got, want := m.Mermaid(), " s0 --> s1 : start
[ready]\n"; !strings.Contains(got, want) { + t.Errorf("Mermaid lacks %q:\n%s", want, got) + } +} + +// A machine with no Initial has no start; [*] is only ever where it ends. +func TestMermaidDrawsNoStartWithoutInitial(t *testing.T) { + t.Parallel() + + if got := linear(t).Mermaid(); strings.Contains(got, "[*] -->") { + t.Errorf("a machine without Initial is entered from [*]:\n%s", got) + } +} + +// Names are written as Mermaid entity codes wherever Mermaid would read +// them as syntax or markup, the comment for an undrawn group included, since +// Mermaid applies a %%{ directive even there; they are kept on their line, +// an empty one is a zero-width space rather than nothing, which Mermaid would +// fill with the id, and the title is a quoted YAML string. +func TestMermaidEscapesNames(t *testing.T) { + t.Parallel() + + odd := fsm.Signal(`a --> b; c: 50% "q" #1 &`) + m, err := fsm.New("we\"ird: \\ name\x1b", + fsm.From("a:b").On(odd).To("c\nd").Guard("x; y", func(context.Context, fsm.Unit) error { return nil }), + fsm.From("c\nd").On(odd).To(""), + fsm.NewGroup("g%%\n", "a:b"), + fsm.NewGroup("h\n", "a:b", "c\nd"), + fsm.NewGroup("x%%{init: {}}%%", "c\nd", ""), + ) + if err != nil { + t.Fatalf("build: %v", err) + } + want := `--- +title: "we\"ird: \\ name\x1b" +--- +stateDiagram-v2 + direction LR + state "h " as g1 { + state "g#37;#37; " as g0 { + s0 + } + s1 + } + %% group x#37;#37;{init#58; {}}#37;#37; overlaps another without containing it, so it is not drawn as a composite state + s0 : a#58;b + s1 : c d + s2 : #8203; + s0 --> s1 : a --#62; b#59; c#58; 50#37; #60;i#62;#34;q#34;#60;/i#62; #35;1 #38;amp#59;
[x#59; y] + s1 --> s2 : a --#62; b#59; c#58; 50#37; #60;i#62;#34;q#34;#60;/i#62; #35;1 #38;amp#59; + s2 --> [*] +` + if got := m.Mermaid(); got != want { + t.Errorf("Mermaid\n%s\nwant\n%s", got, want) + } +} + // --- Fuzz ------------------------------------------------------------------ // Whatever table it is handed, a machine New accepted must keep the contract @@ -3368,6 +3517,9 @@ func FuzzMachineInvariants(f *testing.F) { if dot := m.DOT(); dot != m.DOT() { t.Fatalf("DOT output varies between calls:\n%s", dot) } + if mmd := m.Mermaid(); mmd != m.Mermaid() { + t.Fatalf("Mermaid output varies between calls:\n%s", mmd) + } known, edges := m.States(), m.Edges() outgoing := map[state]bool{} @@ -3490,7 +3642,7 @@ func TestAMachineIsSafeToShareAcrossGoroutines(t *testing.T) { // gauge nets to zero only if every leg ran. _, start := m.Send(ctx, &st, evStart) // Introspection reads the same tables Fire does. - _, _, _ = m.DOT(), m.Terminals(), m.Edges() + _, _, _, _ = m.DOT(), m.Mermaid(), m.Terminals(), m.Edges() _, finish := m.Fire(ctx, &st, evFinish, 0) _, cancel := m.Send(ctx, &st, evCancel) if err := errors.Join(start, finish, cancel); err != nil { diff --git a/introspect.go b/introspect.go index 94dcdce..9ee9648 100644 --- a/introspect.go +++ b/introspect.go @@ -1,8 +1,10 @@ package fsm import ( + "cmp" "fmt" "slices" + "strconv" "strings" ) @@ -106,65 +108,178 @@ func (m *Machine[S]) DOT() string { fmt.Fprintf(&b, "%s\"%s\" [shape=%s];\n", indent, dotEscape(fmt.Sprint(s)), shape) } - // Graphviz puts a node in one cluster, and nests clusters, so groups - // that are disjoint or contain one another are drawn as they are, a - // group inside the smallest that contains it and a state inside the - // smallest group that holds it. A group that partly overlaps an earlier - // drawn one cannot be: it gets no cluster, and a comment says so. - drawn := m.drawableGroups() - parent := make(map[string]string, len(drawn)) - owner := make(map[S]string, len(m.states)) - for i, g := range drawn { - if h, ok := innermost(drawn, g.members, i); ok { - parent[g.name] = h + l := m.layout() + l.nest(func(g Group[S], depth int) { + indent := strings.Repeat("\t", depth) + fmt.Fprintf(&b, "%ssubgraph \"cluster_%s\" {\n", indent, dotEscape(g.name)) + fmt.Fprintf(&b, "%s\tlabel=\"%s\";\n", indent, dotEscape(g.name)) + fmt.Fprintf(&b, "%s\tstyle=rounded;\n", indent) + }, func(s S, depth int) { + node(strings.Repeat("\t", depth), s) + }, func(depth int) { + fmt.Fprintf(&b, "%s}\n", strings.Repeat("\t", depth)) + }) + for _, g := range l.unboxed { + fmt.Fprintf(&b, "\t// group %s overlaps another without containing it, so it is not drawn as a cluster\n", + commentReplacer.Replace(g.name)) + } + for _, s := range m.states { + if _, grouped := l.owner[s]; !grouped { + node("\t", s) + } + } + + if m.hasInitial { + fmt.Fprintf(&b, "\t\"%s\" -> \"%s\";\n", start, dotEscape(fmt.Sprint(m.initial))) + } + for i, e := range m.edges { + if l.skip[i] { + continue + } + // The separator is added after escaping so that the \n stays a + // Graphviz line break rather than becoming a literal backslash-n. + label := e.label(dotEscape, `\n`) + + var attrs string + if e.Internal { + attrs = ", style=dashed" // handled in the state, which it does not leave + } + if l.tail[i] != "" { + attrs = fmt.Sprintf(`, ltail="cluster_%s"`, dotEscape(l.tail[i])) + } + fmt.Fprintf(&b, "\t\"%s\" -> \"%s\" [label=\"%s\"%s];\n", + dotEscape(fmt.Sprint(e.From)), dotEscape(fmt.Sprint(e.To)), label, attrs) + } + + b.WriteString("}\n") + return b.String() +} + +// Mermaid renders the machine as a Mermaid state diagram, for a mermaid +// code block in Markdown, which GitHub draws. It draws what [Machine.DOT] +// draws, in Mermaid's terms: a [Group] is a composite state, nested and +// left out exactly as DOT's clusters are, with a transition every member +// inherited drawn once from it under the same conditions; an internal +// transition is a line inside its state rather than a loop; the [Initial] +// state is entered from [*], and a terminal state leads to [*]. +// +// Output is deterministic, as DOT's is, so it can be committed and diffed. +func (m *Machine[S]) Mermaid() string { + ids := make(map[S]string, len(m.states)) + for i, s := range m.states { + ids[s] = "s" + strconv.Itoa(i) + } + gids := make(map[string]string, len(m.groups)) + for i, g := range m.groups { + gids[g.name] = "g" + strconv.Itoa(i) + } + indent := func(depth int) string { return strings.Repeat(" ", depth) } + + var b strings.Builder + fmt.Fprintf(&b, "---\ntitle: %s\n---\n", strconv.Quote(m.name)) // a YAML double-quoted string + b.WriteString("stateDiagram-v2\n direction LR\n") + l := m.layout() + l.nest(func(g Group[S], depth int) { + fmt.Fprintf(&b, "%sstate \"%s\" as %s {\n", indent(depth), mermaidEscape(g.name), gids[g.name]) + }, func(s S, depth int) { + fmt.Fprintf(&b, "%s%s\n", indent(depth), ids[s]) + }, func(depth int) { + fmt.Fprintf(&b, "%s}\n", indent(depth)) + }) + // Mermaid reads a %%{ directive even inside a comment, so the name is + // escaped here too. + for _, g := range l.unboxed { + fmt.Fprintf(&b, " %%%% group %s overlaps another without containing it, so it is not drawn as a composite state\n", + mermaidEscape(g.name)) + } + + // A state's first description line is its name, and each internal + // transition adds one below it. + inside := make(map[S][]Edge[S]) + for _, e := range m.edges { + if e.Internal { + inside[e.From] = append(inside[e.From], e) } } for _, s := range m.states { - if h, ok := innermost(drawn, []S{s}, -1); ok { - owner[s] = h + fmt.Fprintf(&b, " %s : %s\n", ids[s], mermaidEscape(fmt.Sprint(s))) + for _, e := range inside[s] { + fmt.Fprintf(&b, " %s : %s\n", ids[s], e.label(mermaidEscape, " ")) } } - var cluster func(g Group[S], indent string) - cluster = func(g Group[S], indent string) { - fmt.Fprintf(&b, "%ssubgraph \"cluster_%s\" {\n", indent, dotEscape(g.name)) - fmt.Fprintf(&b, "%s\tlabel=\"%s\";\n", indent, dotEscape(g.name)) - fmt.Fprintf(&b, "%s\tstyle=rounded;\n", indent) - for _, h := range drawn { - if parent[h.name] == g.name { - cluster(h, indent+"\t") - } + + if m.hasInitial { + fmt.Fprintf(&b, " [*] --> %s\n", ids[m.initial]) + } + for i, e := range m.edges { + if e.Internal || l.skip[i] { + continue } - for _, s := range g.members { - if owner[s] == g.name { - node(indent+"\t", s) - } + from := ids[e.From] + if l.tail[i] != "" { + from = gids[l.tail[i]] } - fmt.Fprintf(&b, "%s}\n", indent) + fmt.Fprintf(&b, " %s --> %s : %s\n", from, ids[e.To], e.label(mermaidEscape, "
")) + } + for _, s := range m.Terminals() { + fmt.Fprintf(&b, " %s --> [*]\n", ids[s]) } - for _, g := range drawn { - if _, inner := parent[g.name]; !inner { - cluster(g, "\t") + return b.String() +} + +// label is e's event and its guard in brackets, each escaped, joined by +// sep, which is not. +func (e Edge[S]) label(escape func(string) string, sep string) string { + label := escape(e.Event()) + if e.Guard != "" { + label += sep + "[" + escape(e.Guard) + "]" + } + return label +} + +// layout is how a diagram draws the groups: which it boxes, how the boxes +// nest and which holds each state, and which edges it draws once from a +// box's boundary instead of once per member. +type layout[S comparable] struct { + drawn []Group[S] + unboxed []Group[S] // the groups left out of drawn, in declaration order + parent map[string]string // a drawn group's innermost enclosing one + owner map[S]string // a state's innermost drawn group + tail []string // per edge: the group it is drawn from, or "" + skip []bool // per edge: drawn already, from its group +} + +// layout works out how m's groups are drawn. A box holds a state, or +// another box, only once, and boxes nest, so groups that are disjoint or +// contain one another are drawn as they are, a group inside the smallest +// that contains it and a state inside the smallest group that holds it. A +// group that partly overlaps an earlier drawn one cannot be: it gets no box. +func (m *Machine[S]) layout() layout[S] { + l := layout[S]{ + drawn: m.drawableGroups(), + parent: make(map[string]string), + owner: make(map[S]string, len(m.states)), + tail: make([]string, len(m.edges)), + skip: make([]bool, len(m.edges)), + } + boxed := make(map[string]bool, len(l.drawn)) + for i, g := range l.drawn { + boxed[g.name] = true + if h, ok := innermost(l.drawn, g.members, i); ok { + l.parent[g.name] = h } } for _, g := range m.groups { - if !slices.ContainsFunc(drawn, func(h Group[S]) bool { return h.name == g.name }) { - fmt.Fprintf(&b, "\t// group %s overlaps another without containing it, so it is not drawn as a cluster\n", - commentReplacer.Replace(g.name)) + if !boxed[g.name] { + l.unboxed = append(l.unboxed, g) } } for _, s := range m.states { - if _, grouped := owner[s]; !grouped { - node("\t", s) + if h, ok := innermost(l.drawn, []S{s}, -1); ok { + l.owner[s] = h } } - // A cluster can only be the tail of an edge if it really holds every one - // of its members, which a drawn group does, nested clusters included. - whole := make(map[string]bool, len(drawn)) - for _, g := range drawn { - whole[g.name] = true - } - // Rows are counted per group transition, identified by the group and the // trigger itself — two events can share a name, so counting by name would // merge two transitions and collapse an arrow that covers neither. @@ -172,49 +287,52 @@ func (m *Machine[S]) DOT() string { group string trigger *eventDef } - of := func(e Edge[S]) boundary { return boundary{group: e.Group, trigger: e.trigger} } rows := make(map[boundary]int) for _, e := range m.edges { if e.Group != "" { - rows[of(e)]++ + rows[boundary{e.Group, e.trigger}]++ } } - byName := make(map[string]Group[S], len(m.groups)) for _, g := range m.groups { byName[g.name] = g } - - if m.hasInitial { - fmt.Fprintf(&b, "\t\"%s\" -> \"%s\";\n", start, dotEscape(fmt.Sprint(m.initial))) - } - collapsed := make(map[boundary]bool) - for _, e := range m.edges { - // The guard is appended after escaping so that the \n stays a - // Graphviz line break rather than becoming a literal backslash-n. - label := dotEscape(e.Event()) - if e.Guard != "" { - label += `\n[` + dotEscape(e.Guard) + `]` + drawnFrom := make(map[boundary]bool) + for i, e := range m.edges { + k := boundary{e.Group, e.trigger} + if e.Group == "" || !collapsible(e, byName[e.Group], rows[k], boxed[e.Group]) { + continue } + l.tail[i], l.skip[i] = e.Group, drawnFrom[k] + drawnFrom[k] = true + } + return l +} - var attrs string - if e.Internal { - attrs = ", style=dashed" // handled in the state, which it does not leave +// nest walks the boxes depth first, in declaration order: open and close +// bracket each, and state is called for each state a box holds directly, +// after the boxes inside it. An outermost box is at depth 1. +func (l *layout[S]) nest(open func(g Group[S], depth int), state func(s S, depth int), closed func(depth int)) { + var walk func(g Group[S], depth int) + walk = func(g Group[S], depth int) { + open(g, depth) + for _, h := range l.drawn { + if l.parent[h.name] == g.name { + walk(h, depth+1) + } } - if e.Group != "" && collapsible(e, byName[e.Group], rows[of(e)], whole[e.Group]) { - k := of(e) - if collapsed[k] { - continue + for _, s := range g.members { + if l.owner[s] == g.name { + state(s, depth+1) } - collapsed[k] = true - attrs = fmt.Sprintf(`, ltail="cluster_%s"`, dotEscape(e.Group)) } - fmt.Fprintf(&b, "\t\"%s\" -> \"%s\" [label=\"%s\"%s];\n", - dotEscape(fmt.Sprint(e.From)), dotEscape(fmt.Sprint(e.To)), label, attrs) + closed(depth) + } + for _, g := range l.drawn { + if _, inner := l.parent[g.name]; !inner { + walk(g, 1) + } } - - b.WriteString("}\n") - return b.String() } // drawableGroups returns the groups that can be drawn as clusters, in @@ -280,6 +398,15 @@ func collapsible[S comparable](e Edge[S], g Group[S], rows int, whole bool) bool // commentReplacer keeps a name on its line in a // comment. var commentReplacer = strings.NewReplacer("\n", " ", "\r", " ") +// mermaidReplacer keeps a string on its line, and writes as entity codes +// the characters Mermaid would read as syntax or markup. +var mermaidReplacer = strings.NewReplacer("#", "#35;", `"`, "#34;", ":", "#58;", ";", "#59;", + "%", "#37;", "&", "#38;", "<", "#60;", ">", "#62;", "\n", " ", "\r", " ") + +// mermaidEscape makes s safe as a Mermaid label. An empty one is a +// zero-width space, since Mermaid shows an id in place of an empty label. +func mermaidEscape(s string) string { return cmp.Or(mermaidReplacer.Replace(s), "#8203;") } + // dotReplacer makes a string safe inside double quotes in Graphviz. var dotReplacer = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`) diff --git a/rules.go b/rules.go index f7e9559..8ccd18c 100644 --- a/rules.go +++ b/rules.go @@ -114,7 +114,7 @@ type decl[S comparable] struct { // pointers compare equal exactly when their types match. func payloadToken[A any]() any { return (*A)(nil) } -// New builds a machine from its rules. name appears in errors and DOT output. +// New builds a machine from its rules. name appears in errors and diagrams. // // S is inferred from the rules, so it rarely has to be written out. A machine // with no rules cannot infer it, and is an error in any case. @@ -297,7 +297,7 @@ type ToStep[S comparable, A any] struct { // wrapped in a [GuardError], which unwraps to it, so a guard can reject with // a sentinel the caller matches using errors.Is. // -// desc is the guard's static description: it labels the edge in DOT output +// desc is the guard's static description: it labels the edge in diagrams // and appears in the error, so it should read as the condition being // enforced — "all chunks and tracks uploaded". The returned error carries the // dynamic reason the condition did not hold this time. @@ -580,7 +580,8 @@ func OnTransition[S comparable](h Hook[S]) Rule[S] { // Initial declares the state a fresh instance starts in. The machine still // holds no state, so nothing changes at fire time; it lets [New] reject a // machine in which some state cannot be reached from the start, or whose -// start has no way out, and lets [Machine.DOT] mark where the machine begins. +// start has no way out, and lets [Machine.DOT] and [Machine.Mermaid] mark +// where the machine begins. // // Entering the initial state is not a transition, so no hook runs for it and // the first increment of a counter kept by entry hooks is the caller's. @@ -972,15 +973,15 @@ func join[S comparable](lists ...[]call[S]) []call[S] { // pays for it beyond the hooks declared on it ([OnEnterGroup], [OnExitGroup]), // which run only on the transitions that cross its boundary. // -// A Group is itself a [Rule], so a group used only for [Group.Has] or for DOT -// output can be passed to [New] on its own. +// A Group is itself a [Rule], so a group used only for [Group.Has] or for a +// diagram can be passed to [New] on its own. type Group[S comparable] struct { name string members []S } -// NewGroup declares a group of states. name labels it in errors and in DOT -// output, and must be unique within a machine. +// NewGroup declares a group of states. name labels it in errors and in +// diagrams, and must be unique within a machine. func NewGroup[S comparable](name string, members ...S) Group[S] { return Group[S]{name: name, members: slices.Clone(members)} }