Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 35 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<br>[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.
Expand Down
9 changes: 8 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<br>[disconnect was not intentional]
// s1 --> s0 : reconnect
// g0 --> s2 : kick
// s2 --> [*]
}
4 changes: 2 additions & 2 deletions fsm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading