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