Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a31eb88
docs: add design for Mermaid diagram export + initial-state arm (B4 +…
MarcelRoozekrans May 23, 2026
84fcb12
docs: add implementation plan for Mermaid export + initial-arm (B4 + …
MarcelRoozekrans May 23, 2026
a61ab32
feat: add Diagram property to [StateMachine] and [StateMachineGroup] …
MarcelRoozekrans May 23, 2026
e075f03
Add ZSM0020 (EmptyDiagramRequest) and ZSM0021 (MissingHookConstructor…
MarcelRoozekrans May 23, 2026
fc55470
feat(generator): extend models with Diagram flag
MarcelRoozekrans May 23, 2026
a322f92
feat(generator): parse Diagram named arg on [StateMachine] + [StateMa…
MarcelRoozekrans May 23, 2026
8d5d76a
feat(generator): detect ZSM0020 (empty Diagram request)
MarcelRoozekrans May 23, 2026
5a5750a
feat(generator): add MermaidDiagramWriter for flat-machine rendering
MarcelRoozekrans May 23, 2026
5ac0b30
feat(generator): render composite states as nested state blocks in Me…
MarcelRoozekrans May 23, 2026
1ee34ac
feat(generator): render shallow history pseudo-state inside composite…
MarcelRoozekrans May 23, 2026
6d8b023
feat(generator): render [StateMachineGroup] as top-level state blocks…
MarcelRoozekrans May 23, 2026
4df3df6
feat(generator): emit MermaidDiagram const from StateMachineWriter wh…
MarcelRoozekrans May 23, 2026
50268aa
feat(generator): emit MermaidDiagram const from StateMachineGroupWrit…
MarcelRoozekrans May 23, 2026
ada0ef1
feat(generator): emit ArmInitialStateTimers helper in StateMachineWriter
MarcelRoozekrans May 23, 2026
1c750f7
feat(generator): emit HookConstructor + default ctor in StateMachineW…
MarcelRoozekrans May 23, 2026
c14e451
feat(generator): Reset() and ResetTo(state) now arm initial-state timers
MarcelRoozekrans May 23, 2026
e41443d
feat(generator): emit per-part ArmInitialStateTimers + group ctor in …
MarcelRoozekrans May 23, 2026
7ff1043
feat(generator): detect ZSM0021 (user-declared ctor must call HookCon…
MarcelRoozekrans May 23, 2026
375f485
test(generator): snapshot tests for Mermaid diagram emit (B4)
MarcelRoozekrans May 23, 2026
d6256ae
test(generator): diagnostic tests for ZSM0020 + ZSM0021
MarcelRoozekrans May 23, 2026
be84eb4
test: runtime tests for initial-state arm (closes v1.4 caveat)
MarcelRoozekrans May 23, 2026
674aa4f
docs: diagram export (B4) + initial-arm closes v1.4 caveat
MarcelRoozekrans May 23, 2026
acc3420
fix(generator): ZSM0021 now accepts this.HookConstructor() / base.Hoo…
MarcelRoozekrans May 23, 2026
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
5 changes: 4 additions & 1 deletion docs/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Marks a `partial` class or struct as a source-generated state machine. The gener
|----------|------|----------|---------|-------------|
| `InitialState` | `string` | yes | — | The name of the initial state. **Always use `nameof(...)`** to keep it refactor-safe. |
| `Concurrent` | `bool` | no | `false` | When `true`, state is stored as `volatile long` and transitions use `Interlocked.CompareExchange`. Safe for concurrent callers. Guards are not generated in this mode. |
| `Diagram` | `bool` | no | `false` | When `true`, the generator emits a `public const string MermaidDiagram` on the partial containing a Mermaid `stateDiagram-v2` rendering of the machine. See [Diagram Export](core-concepts/diagram-export.md). Emits **ZSM0020** on a class with no transitions. |

### Examples

Expand Down Expand Up @@ -224,7 +225,9 @@ Marks a `partial` class as a group of independent concurrent state machines. Eac

### Properties

None.
| Property | Type | Required | Default | Description |
|----------|------|----------|---------|-------------|
| `Diagram` | `bool` | no | `false` | When `true`, the generator emits a `public const string MermaidDiagram` on the group partial. Each `[StateMachinePart]` renders as a top-level `state Name { ... }` block. See [Diagram Export](core-concepts/diagram-export.md). Emits **ZSM0020** on a group whose parts declare no transitions. |

### Example

Expand Down
126 changes: 126 additions & 0 deletions docs/core-concepts/diagram-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
id: diagram-export
title: Diagram Export
sidebar_position: 7
---

# Diagram Export

`[StateMachine(Diagram = true)]` (and `[StateMachineGroup(Diagram = true)]`)
asks the generator to emit a **Mermaid `stateDiagram-v2`** rendering of the
machine's transitions next to the dispatcher. The diagram lands on the
partial as a `public const string MermaidDiagram` — paste it into any
Mermaid-aware renderer (GitHub, Markdown previewers, docs sites) to view it.

---

## Why

The transition graph is already fully declared in `[Transition]` attributes;
keeping a hand-drawn diagram in sync with the attributes is busywork that
rots. `Diagram = true` lets the source of truth — the attributes — drive a
human-readable picture at zero runtime cost (it's a compile-time string
literal).

---

## How

Add `Diagram = true` to the machine attribute:

```csharp
using ZeroAlloc.StateMachine;

public enum OrderState { Idle, Submitted, Shipped }
public enum OrderTrigger { Submit, Ship }

[StateMachine(InitialState = nameof(OrderState.Idle), Diagram = true)]
[Transition<OrderState, OrderTrigger>(From = OrderState.Idle, On = OrderTrigger.Submit, To = OrderState.Submitted)]
[Transition<OrderState, OrderTrigger>(From = OrderState.Submitted, On = OrderTrigger.Ship, To = OrderState.Shipped)]
[Terminal<OrderState>(State = OrderState.Shipped)]
public partial class Order { }
```

The generated partial gains a constant you can read at runtime:

```csharp
Console.WriteLine(Order.MermaidDiagram);
```

The emitted string is:

```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Submitted: Submit
Submitted --> Shipped: Ship
Shipped --> [*]
```

---

## What gets rendered

| Feature | Rendering |
|---|---|
| **Initial state** | `[*] --> InitialState` |
| **Flat transitions** | `From --> To: Trigger` |
| **Guards** (`When = true`) | `From --> To: Trigger [guard]` |
| **Timed edges** (`AfterMs = N`) | `From --> To: Trigger (after Nms)` |
| **Terminal states** (`[Terminal]`) | `State --> [*]` |
| **Composite states** | `state Parent { ... }` nested block, with the sub-FSM's own diagram inside |
| **Shallow history** (`[HistoryState]`) | `state H as History` marker inside the composite block |
| **Concurrent parts** (`[StateMachineGroup]`) | one top-level `state PartName { ... }` block per `[StateMachinePart]` |

The output is a string literal — no reflection, no runtime cost, AOT-safe.

If `Diagram = true` is set on a class with zero transitions, the generator
emits [ZSM0020](../diagnostics/ZSM0020.md) — the resulting `MermaidDiagram`
would be empty.

---

## Example: composite with history

```csharp
[StateMachine(InitialState = nameof(LoadStep.Fetching))]
[Transition<LoadStep, AppTrigger>(From = LoadStep.Fetching, On = AppTrigger.Tick, To = LoadStep.Parsing)]
[Transition<LoadStep, AppTrigger>(From = LoadStep.Parsing, On = AppTrigger.Complete, To = LoadStep.Done)]
[Terminal<LoadStep>(State = LoadStep.Done)]
public partial class LoadingFsm { }

[StateMachine(InitialState = nameof(AppState.Idle), Diagram = true)]
[Transition<AppState, AppTrigger>(From = AppState.Idle, On = AppTrigger.Begin, To = AppState.Loading)]
[Transition<AppState, AppTrigger>(From = AppState.Loading, On = AppTrigger.Complete, To = AppState.Ready)]
[Terminal<AppState>(State = AppState.Ready)]
[CompositeState<AppState>(State = AppState.Loading, SubMachine = typeof(LoadingFsm))]
[HistoryState<AppState>(State = AppState.Loading)]
public partial class App { }
```

`App.MermaidDiagram` renders as:

```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Loading: Begin
Loading --> Ready: Complete
Ready --> [*]
state Loading {
state H as History
[*] --> Fetching
Fetching --> Parsing: Tick
Parsing --> Done: Complete
Done --> [*]
}
```

---

## Related

- [States and Triggers](states-and-triggers.md) — the enum surface that names diagram nodes and edges
- [Composite States](composite-states.md) — nested rendering and `[HistoryState]`
- [Concurrent Parts](concurrent-parts.md) — group rendering as side-by-side blocks
- [Attribute Reference — `Diagram`](../attributes.md)
- [ZSM0020](../diagnostics/ZSM0020.md) — `Diagram = true` with no transitions
31 changes: 8 additions & 23 deletions docs/core-concepts/timeout-transitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ w.TryFire(WatchTrigger.Arm); // Idle → Armed; timer armed for 5000ms
concurrent). Otherwise the generator emits [ZSM0012](../diagnostics/ZSM0012.md).
- `AfterMs` must be strictly positive. `AfterMs = 0` or negative emits
[ZSM0013](../diagnostics/ZSM0013.md).
- Timers are **one-shot per arm**. Each entry into the source state arms
the timer; each exit disarms it. There is no interval / repeat mode.
- Timers are **one-shot per arm**. They are armed on construction (if the
initial state owns a timed edge), on every entry into the source state,
and on `Reset()` / `ResetTo(state)`. Each exit disarms them. There is no
interval / repeat mode.

---

Expand All @@ -78,7 +80,9 @@ For every transition with `AfterMs > 0`, the generator emits:
tuple. Allocated on first arm, reused via `Timer.Change(...)` thereafter.
2. **Arm-on-enter** — when the dispatcher writes the source state, it either
constructs the timer (first time) or re-arms the existing one to
`AfterMs` milliseconds.
`AfterMs` milliseconds. Initial-state timers are armed by the generated
constructor (via a `private void HookConstructor()` hook) and re-armed by
`Reset()` / `ResetTo(state)`.
3. **Disarm-on-exit** — when a transition leaves the source state, it calls
`Timer.Change(Timeout.Infinite, Timeout.Infinite)` on the corresponding
field. The timer object is reused, not disposed.
Expand Down Expand Up @@ -120,29 +124,10 @@ disposable.

---

## Caveats

**The initial state is not auto-armed.** Timers are armed only when the
generated `TryFire` advances the state machine *into* the source state. If
your `InitialState` is the source of a timed transition — e.g.
`InitialState = nameof(WatchState.Armed)` paired with
`[Transition(From = Armed, On = Timeout, To = Tripped, AfterMs = 5000)]` —
the timer does **not** arm at construction. Two workarounds:

1. Model the machine so timed states are only ever entered via an explicit
transition (the recommended shape — `Idle → Armed` in the example above).
2. Fire any trigger that lands back on the same state (a self-transition)
immediately after construction to arm the timer on demand.

A future release may add constructor-time arming for initial-state timers;
until then, prefer option 1.

---

## Related

- [Transitions](transitions.md) — the underlying `TryFire` switch model
- [Concurrent Mode](concurrent-mode.md) — required by `AfterMs`
- [Concurrent Parts](concurrent-parts.md) — multi-machine classes with their own timed edges
- [Attribute Reference — `AfterMs`](../attributes.md#transition-afterms)
- [ZSM0012](../diagnostics/ZSM0012.md), [ZSM0013](../diagnostics/ZSM0013.md), [ZSM0019](../diagnostics/ZSM0019.md)
- [ZSM0012](../diagnostics/ZSM0012.md), [ZSM0013](../diagnostics/ZSM0013.md), [ZSM0019](../diagnostics/ZSM0019.md), [ZSM0021](../diagnostics/ZSM0021.md)
59 changes: 59 additions & 0 deletions docs/diagnostics/ZSM0020.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
id: ZSM0020
title: ZSM0020 — Diagram = true on a Class with No Transitions
sidebar_position: 20
---

# ZSM0020 — Diagram = true on a Class with No Transitions

**Severity:** Warning

The class sets `Diagram = true` on `[StateMachine]` (or
`[StateMachineGroup]`) but declares no `[Transition]` attributes. The
generator would emit a `public const string MermaidDiagram` containing only
the `stateDiagram-v2` header and the `[*] --> InitialState` arrow — there's
nothing else to render. Either delete the `Diagram = true` opt-in or add the
transitions you meant to declare.

## Example

```csharp
// ZSM0020: Diagram = true with no [Transition] declarations
[StateMachine(InitialState = nameof(S.Idle), Diagram = true)]
public partial class StubMachine { }

public enum S { Idle, Done }
```

For a `[StateMachineGroup]` the same diagnostic fires when no
`[StateMachinePart]` contributes any transitions:

```csharp
// ZSM0020: group has no transitions across any part
[StateMachineGroup(Diagram = true)]
[StateMachinePart<OpState, OpTrigger>(Name = "Op", InitialState = OpState.Idle)]
public partial class Device { }
```

## How to fix

Either drop the `Diagram = true` opt-in until you actually have edges to
render:

```csharp
[StateMachine(InitialState = nameof(S.Idle))]
public partial class StubMachine { }
```

… or add the transitions that justify the diagram:

```csharp
[StateMachine(InitialState = nameof(S.Idle), Diagram = true)]
[Transition<S, T>(From = S.Idle, On = T.Go, To = S.Done)]
[Terminal<S>(State = S.Done)]
public partial class StubMachine { }
```

See [Diagram Export](../core-concepts/diagram-export.md) for what the
generator renders, and the [Attribute Reference](../attributes.md) for the
full `Diagram` property surface.
72 changes: 72 additions & 0 deletions docs/diagnostics/ZSM0021.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
id: ZSM0021
title: ZSM0021 — User Constructor Must Call HookConstructor()
sidebar_position: 21
---

# ZSM0021 — User Constructor Must Call HookConstructor()

**Severity:** Error

The class has at least one timed transition (`[Transition(... AfterMs > 0)]`)
**and** declares its own constructor, but that constructor does not call
the generator-emitted `partial void HookConstructor()`. Without that call,
initial-state timers never arm: any timed edge whose `From` is the
`InitialState` would simply sit idle until the machine transitioned in by
some other route.

When the user owns the constructor, the generator can't inject the arming
call for them — `HookConstructor()` is the explicit handshake that says
"now arm my initial-state timers."

## Example

```csharp
// ZSM0021: timed edge from InitialState, user ctor doesn't call HookConstructor()
[StateMachine(InitialState = nameof(WatchState.Armed), Concurrent = true)]
[Transition<WatchState, WatchTrigger>(From = WatchState.Armed, On = WatchTrigger.Timeout, To = WatchState.Tripped, AfterMs = 5000)]
public partial class Watchdog
{
private readonly string _name;

public Watchdog(string name)
{
_name = name;
// missing: HookConstructor();
}
}
```

## How to fix

Add a `HookConstructor()` call inside the user-declared constructor. The
generator emits it as a `private void HookConstructor()` on the partial; the
body arms every initial-state timer.

```csharp
[StateMachine(InitialState = nameof(WatchState.Armed), Concurrent = true)]
[Transition<WatchState, WatchTrigger>(From = WatchState.Armed, On = WatchTrigger.Timeout, To = WatchState.Tripped, AfterMs = 5000)]
public partial class Watchdog
{
private readonly string _name;

public Watchdog(string name)
{
_name = name;
HookConstructor(); // arms initial-state timers
}
}
```

If you don't need a custom constructor, simply delete it — the generator
emits a default constructor that already calls `HookConstructor()` for you:

```csharp
[StateMachine(InitialState = nameof(WatchState.Armed), Concurrent = true)]
[Transition<WatchState, WatchTrigger>(From = WatchState.Armed, On = WatchTrigger.Timeout, To = WatchState.Tripped, AfterMs = 5000)]
public partial class Watchdog { } // generator emits the ctor + HookConstructor() call
```

See [Timeout Transitions](../core-concepts/timeout-transitions.md) for the
full timer lifecycle and [ZSM0019](ZSM0019.md) for the related
`Dispose` contract on timed classes.
3 changes: 3 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ machine.Current; // Pending
| [Composite States](core-concepts/composite-states.md) | Hierarchical sub-FSMs, dispatch order, shallow history |
| [Timeout Transitions](core-concepts/timeout-transitions.md) | `AfterMs` edges, lazy timers, race-safe CAS, `IDisposable` |
| [Concurrent Parts](core-concepts/concurrent-parts.md) | Multiple independent FSMs in one class via `[StateMachineGroup]` |
| [Diagram Export](core-concepts/diagram-export.md) | `Diagram = true` emits a Mermaid `stateDiagram-v2` `const string` |

### Guides

Expand Down Expand Up @@ -84,3 +85,5 @@ machine.Current; // Pending
| [ZSM0017](diagnostics/ZSM0017.md) | Error | `[StateMachineGroup]` declared with no parts |
| [ZSM0018](diagnostics/ZSM0018.md) | Error | Composite state inside a group |
| [ZSM0019](diagnostics/ZSM0019.md) | Error | Incompatible user-supplied `Dispose` |
| [ZSM0020](diagnostics/ZSM0020.md) | Warning | `Diagram = true` on a class with no transitions |
| [ZSM0021](diagnostics/ZSM0021.md) | Error | User constructor must call `HookConstructor()` |
Loading
Loading