tabular-center

tabular-center Home · Rust · Kotlin · Swift · Migrating · Compatibility · Cells · Diagnostics · Matrix files · Source

Rust

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"

The matrix

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

The code you write

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.

When a cell is missing

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.

Composition

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.

Composing steps

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.

Colors

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.

Introspection

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.

Examples

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