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)} }