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

tabular-center: a state machine declared as a transition matrix. One cell is missing, and the compiler refuses it with tabular-center::row-arity.

tabular-center

State machines whose declaration is the transition matrix – for Rust, Kotlin and Swift, held to one behaviour by a shared conformance suite.

You write the matrix. Every cell is one word, or a call to code you write. The compiler refuses a machine with a hole in it.

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.

That is the whole behaviour of a three-state timer. Five of its nine cells are decided right there – IGNORE, or GO! to a state with an effect – and cost nothing more. Two are HANDLE: those, and only those, become methods you implement, each receiving its state and action already narrowed to concrete types. Forget one and the build fails naming it.

The one guarantee

Every cell of the state × action matrix is a required member. An incomplete machine does not compile.

Not pattern-match exhaustiveness, which else, default: and _ defeat. Not purity, which Kotlin and Swift cannot enforce. The oldest mechanism in all three languages: you declared a required member and did not implement it. States, actions and effects are generated sum types, so adding an effect breaks every handler’s build the same way adding a state breaks every matrix.

Concise at the size that matters

The Timer is small on purpose. Here is a real order lifecycle, 8 states by 12 actions:

transition_matrix! {
    machine Order;
    context Ctx;
    state   State;
    action  Action;
    effects Effect {
        RequestPayment, ReleaseHold, NotifyExpired, ChargeCard,
        NotifyFailure, SchedulePickup, NotifyShipped, IssueRefund, NotifyDelivered
    }
    initial Draft;

    states {
        Draft, AwaitingPayment, PaymentFailed { code: u32 }, Paid,
        Preparing, Shipped { tracking: u64 }, Delivered, Cancelled
    }
    actions {
        AddItem, RemoveItem, Submit, PaidOk, PaidFail { code: u32 }, Retry,
        Cancel, Prepare, Ship { tracking: u64 }, Deliver, Refund, Expire
    }

    //                    AddItem  RemoveItem  Submit                                  PaidOk                     PaidFail  Retry   Cancel                          Prepare                       Ship                                   Deliver                                    Refund  Expire
    Draft           => [  HANDLE,  HANDLE,     GO!(AwaitingPayment, RequestPayment),   IGNORE,                    IGNORE,   IGNORE, GO!(Cancelled),                 IGNORE,                       IGNORE,                                IGNORE,                                    IGNORE, GO!(Cancelled, NotifyExpired) ];
    AwaitingPayment => [  IGNORE,  IGNORE,     IGNORE,                                 GO!(Paid, ChargeCard),     HANDLE,   IGNORE, GO!(Cancelled, ReleaseHold),    IGNORE,                       IGNORE,                                IGNORE,                                    IGNORE, GO!(Cancelled, ReleaseHold)   ];
    PaymentFailed   => [  IGNORE,  IGNORE,     IGNORE,                                 IGNORE,                    IGNORE,   HANDLE, GO!(Cancelled, ReleaseHold),    IGNORE,                       IGNORE,                                IGNORE,                                    IGNORE, GO!(Cancelled, ReleaseHold)   ];
    Paid            => [  IGNORE,  IGNORE,     IGNORE,                                 IGNORE,                    IGNORE,   IGNORE, GO!(Cancelled, IssueRefund),    GO!(Preparing, SchedulePickup), IGNORE,                               IGNORE,                                    GO!(Cancelled, IssueRefund), IGNORE ];
    Preparing       => [  IGNORE,  IGNORE,     IGNORE,                                 IGNORE,                    IGNORE,   IGNORE, HANDLE,                         IGNORE,                       HANDLE,                                IGNORE,                                    HANDLE, IGNORE ];
    Shipped         => [  IGNORE,  IGNORE,     IGNORE,                                 IGNORE,                    IGNORE,   IGNORE, IGNORE,                         IGNORE,                       IGNORE,                                GO!(Delivered, NotifyDelivered),           HANDLE, IGNORE ];
    Delivered       => [  IGNORE,  IGNORE,     IGNORE,                                 IGNORE,                    IGNORE,   IGNORE, IGNORE,                         IGNORE,                       IGNORE,                                IGNORE,                                    HANDLE, IGNORE ];
    Cancelled       => [  IGNORE,  IGNORE,     IGNORE,                                 IGNORE,                    IGNORE,   IGNORE, IGNORE,                         IGNORE,                       IGNORE,                                IGNORE,                                    IGNORE, IGNORE ];
}

From tabular-center-rust/tabular-center/tests/scale.rs.

   
cells 96
IGNORE 75 (78%)
GO! 12
methods you write 9

Measured, not estimated: ninety_six_cells_cost_nine_implementations in that file asserts the 9. Eight lines declare 96 transitions, the grid stays reviewable as a grid, and the 75 cells that do nothing cost one word each – which is the only reason a machine this size is pleasant to write at all.

Composition, checked by the same mechanism

A parent hands cells to a child written knowing nothing about it:

    //                 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.

The parent’s cell surface requires the child’s, so a hole in the child is a build error in the parent – the same error as a hole in the parent itself. No registration, no runtime check. It holds in all three languages, each with a compile-fail fixture proving it, and through colors: a plain child composes into an async or suspend parent; the reverse does not compile.

Three languages, one behaviour

  Rust Kotlin Swift
Generator macro_rules! – no build step, no dependencies KSP, from annotations TabularCenterCodegen today; the @Machine macro is designed and pending
Runtime dependencies none, no_std capable none none
Colors async suspend, receivers, any modifier async, throws, attributes
Driver blocking, non-reentrant mailbox blocking and suspend sync and async, ObservableStore
Introspection TABLE, grid, Mermaid, DOT, lints, coverage the same the same

The same fixtures in spec/conformance/ are replayed by all three, and each renders every fixture’s grid, diagram, lints and coverage; a check diffs the three and fails on a single differing byte. A semantic difference between the languages is a red build, not a bug report.

Read on

Every sample on these pages is included from the repository when the site is built, so each one is code that CI compiles and runs.