Home · Rust · Kotlin · Swift · Migrating · Compatibility · Cells · Diagnostics · Matrix files · Source
transition_matrix! is a macro_rules! macro. No proc-macro crate, no build
script, no dependencies at all – the library’s [dependencies] table is
empty, and it builds #![no_std] with default-features = false.
MSRV 1.94. On crates.io:
[dependencies]
tabular-center = "0.3"
Examples keep the matrix in a file of its own, named *.tb.rs
(why):
transition_matrix! {
machine Timer;
context Ctx;
state State;
action Action;
effects Effect { StartClock, StopClock { reason: Reason } }
initial Idle;
states { Idle, Running { since: u64 }, Done }
actions { Start, Tick { now: u64 }, Cancel }
// Start Tick Cancel
Idle => [ HANDLE, IGNORE, IGNORE ];
Running => [ IGNORE, HANDLE, GO!(Idle, StopClock { reason: Reason::Cancelled }) ];
Done => [ GO!(Running { since: 0 }, StartClock), IGNORE, IGNORE ];
}
From tabular-center-rust/examples/02-timer/src/machine.tb.rs.
The header names the generated types: State and Action are enums of the
listed variants, and every variant is also a struct of its own – Idle,
Running { since }, Tick { now } – which is what lets a cell receive
exactly its state and action. effects declares the effect enum the same way. Each row is a state, each column an action, and each cell one of
six kinds (Cells):
HANDLE |
you implement it |
IGNORE |
the action means nothing in this state |
GO!(Target, Effect...) |
a static transition, emitting effects |
EMIT(Effect...) |
stay, emitting effects |
DELEGATE!(child) |
hand the cell to a child machine |
UNREACHABLE |
a trap, counted by Table::coverage |
One Handle impl per HANDLE cell and one Perform impl per effect. Each is
narrowed: the dispatcher has already matched, so s.since and a.now are
plain fields.
impl Handle<Timer, Idle, Start> for Impl {
fn handle(&mut self, _c: &mut Ctx, _s: Idle, _a: Start) -> Step<State, Effect> {
Step::go(State::Running(Running { since: 0 })).emit(StartClock.into())
}
}
impl Handle<Timer, Running, Tick> for Impl {
fn handle(&mut self, c: &mut Ctx, s: Running, a: Tick) -> Step<State, Effect> {
if a.now.saturating_sub(s.since) >= c.limit {
Step::go(State::Done(Done)).emit(
StopClock {
reason: Reason::Elapsed,
}
.into(),
)
} else {
Step::stay()
}
}
}
impl Perform<Timer, StartClock> for Impl {
fn perform(&mut self, c: &mut Ctx, _e: StartClock) -> Option<Action> {
c.log.push("start".into());
None
}
}
impl Perform<Timer, StopClock> for Impl {
fn perform(&mut self, c: &mut Ctx, e: StopClock) -> Option<Action> {
c.log.push(format!("stop:{:?}", e.reason));
None
}
}
From tabular-center-rust/examples/02-timer/src/lib.rs.
That is the entire implementation. step(&mut Impl, &mut ctx, state, action)
dispatches; Driver runs a mailbox over it, so an effect handler that returns
a follow-up action queues it instead of re-entering step.
Delete the Handle<Timer, Running, Tick> impl and the build stops:
error: the trait bound `T: tabular_center::Handle<Timer, Running, Tick>` is not satisfied
That is rustc’s own message – the step it generated carries one trait bound
per HANDLE cell – so it names the exact hole and survives even if the
macro is bypassed. It is a fixture: compile-fail requires it on every run,
along with one fixture per diagnostic the macro itself emits
(Diagnostics), such as a row with too few cells.
DELEGATE!(auth) hands cells to a child machine. The parent’s state carries
the child’s, and one type implements both:
transition_matrix! {
machine Session;
context Ctx;
state State;
action Action;
effects Effect { Audit, Warn, Redirect }
initial LoggedOut;
states { LoggedOut { auth: super::auth::State }, Active, Banned }
actions { Credentials { ok: u32 }, StartOver, Logout }
// Credentials StartOver Logout
LoggedOut => [ DELEGATE!(auth), DELEGATE!(auth), IGNORE ];
Active => [ IGNORE, IGNORE, GO!(Banned, Audit) ];
Banned => [ IGNORE, IGNORE, IGNORE ];
}
From tabular-center-rust/examples/04-login/src/session.tb.rs.
A Step is a value, so a HANDLE cell can be written from small decisions
rather than assembled in one expression: map relabels a target, and_then
chains a decision onto a transition (its effects follow the first step’s), and
zip combines two.
fn enter(door: Door) -> Step<Door, Signal> {
match door {
Door::Open => Step::go(Door::Open).emit(Signal::Chime),
other => Step::go(other),
}
}
From tabular-center-rust/tabular-center/tests/step_algebra.rs.
fn unlock(code_ok: bool) -> Step<Door, Signal> {
let decided = if code_ok {
Step::go(Door::Open)
} else {
Step::stay().emit(Signal::Buzz)
};
decided.and_then(enter)
}
From tabular-center-rust/tabular-center/tests/step_algebra.rs.
fn open_both(left_ok: bool, right_ok: bool) -> Step<(Door, Door), Signal> {
unlock(left_ok).zip(unlock(right_ok))
}
From tabular-center-rust/tabular-center/tests/step_algebra.rs.
Stay and Ignored short-circuit: unlock(false) stays and buzzes without
calling enter, and zip drops the right side’s effects when the left does
not move. An Ignored anywhere absorbs, and the whole step emits nothing.
Effects concatenate into the step’s capacity K; try_and_then returns the
overflow instead of panicking. The rules and the laws they obey are in
Cells, section 6, and all three implementations replay the same
cases.
prototype async fn handle; after context makes the machine async: step
becomes an async fn, and the cells implement AsyncHandle / AsyncPerform.
A plain child composes into an async parent; an async child under a plain
parent is refused by rustc.
Every machine has a TABLE const describing its matrix, from which the
library renders a diffable grid, Mermaid and DOT diagrams, a coverage report,
and lints – a column nothing handles, a field repeated across every state that
belongs in the context, a machine that is mostly IGNORE (the order lifecycle
on the front page trips that one, fairly). table-diff prints the grid:
nix run .#table-diff -- timer.
In tabular-center-rust/examples, each its own crate:
| adds | ||
|---|---|---|
| 1 | traffic-light |
the minimum; #![no_std], default-features = false |
| 2 | timer |
payloads, effects, narrowed handlers – the machine above |
| 3 | retry |
a binary: effects feeding actions back through the driver |
| 4 | login |
composition, parent and child |
| 5 | iced |
a GUI: the same machine driving iced, which knows nothing about it |