Skip to content

A StateMachine derive

Level: 301 · deep dive

One line: The chapter in one macro: #[derive(StateMachine)] reads #[state(initial, to(…))] from each variant with darling, refuses a transition to a state that does not exist, or a machine without exactly one initial state, with the error on the offending token, and writes a trait impl that adds no name of its own to the user's code.

demo/door/src/lib.rs
use state_machine::StateMachine;

/// A door that can be locked only while closed.
#[derive(StateMachine)]
pub enum Door {
    #[state(initial, to(Closed))]
    Open,
    #[state(to(Open, Locked))]
    Closed,
    #[state(to(Closed))]
    Locked,
}
demo/door/src/main.rs
use door::Door;
use state_machine::StateMachine;

fn main() {
    for state in [Door::Open, Door::Closed, Door::Locked] {
        let next: Vec<&str> = state.next_states().iter().map(|next| next.name()).collect();
        println!("{:<6} -> {}", state.name(), next.join(", "));
    }

    let mut door = Door::INITIAL;
    println!("\nstart: {}", door.name());
    // Locked -> Open is the one move below that no transition declares.
    for next in [Door::Closed, Door::Locked, Door::Open, Door::Closed, Door::Open] {
        door = match door.transition_to(next) {
            Ok(state) => {
                println!("  now: {}", state.name());
                state
            }
            Err(refused) => {
                println!("  refused: {refused}");
                refused.from
            }
        };
    }
}

Verified output of cargo run -q -p door — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

Open   -> Closed
Closed -> Open, Locked
Locked -> Closed

start: Open
  now: Closed
  now: Locked
  refused: no transition from Locked to Open
  now: Closed
  now: Open

The table at the top comes from code the derive wrote; Door itself declares nothing but its variants and their attributes. Two kinds of check are at work, and they happen at different times:

  • The declaration is checked while compiling. to(Lockd), or a second initial, stops the build, with the error under the word that is wrong (Refusals, below).
  • A move is checked while running. Locked -> Open comes back as Err, because which state a Door is in is a value, and the macro ran before any value existed. Making a wrong move a compile error takes a type per state instead of a variant per state: the typestate pattern in Phantom types.

The pieces

Piece of the chapter Where it is in this derive Taught in
A proc-macro crate, and a facade crate that re-exports it state_machine_derive, re-exported by state_machine A proc-macro crate, The re-export pattern
Tokens parsed into a syntax tree parse_macro_input!(input as DeriveInput) Parsing with syn
A helper attribute the derive declares #[proc_macro_derive(StateMachine, attributes(state))] Helper attributes by hand
The attribute read into a struct FromDeriveInput, FromVariant, Flag Helper attributes with darling
One arm per enum variant the two match self blocks Every struct and enum shape
Errors on the right token, all at once Error::accumulator, with_span, write_errors Errors: from panic! to syn::Error
Repetition, nested #(Self::#from => &[#(Self::#targets),*],)* Generating with quote
Paths that start with :: ::state_machine::StateMachine, ::core::primitive::str Absolute paths and hygiene
The errors kept under test tests/ui.rs Testing with trybuild

The workspace has five packages: state_machine (the facade users depend on), state_machine_derive (the macro), door (above), no_clash (below), and refusals, which builds the three failing examples as ordinary binaries.

The facade: what the derive has to write

demo/state_machine/src/lib.rs
//! The crate users depend on. It holds the trait the derive implements, the
//! error type, and the derive itself, re-exported under the trait's name.

use std::{fmt, mem};

pub use state_machine_derive::StateMachine;

/// An enum whose variants are states. The derive writes the first three items;
/// the last two are written once, here, for every machine.
pub trait StateMachine: Sized + 'static {
    /// The state a new machine starts in.
    const INITIAL: Self;

    /// The variant's name, for messages.
    fn name(&self) -> &'static str;

    /// The states a declared transition leads to from this one.
    fn next_states(&self) -> &'static [Self];

    /// Whether a declared transition leads from this state to `next`.
    fn can_transition_to(&self, next: &Self) -> bool {
        let next = mem::discriminant(next);
        self.next_states().iter().any(|state| mem::discriminant(state) == next)
    }

    /// Moves to `next`, or hands both states back if no transition allows it.
    fn transition_to(self, next: Self) -> Result<Self, InvalidTransition<Self>> {
        if self.can_transition_to(&next) {
            Ok(next)
        } else {
            Err(InvalidTransition { from: self, to: next })
        }
    }
}

/// One error type for every machine, generic over the machine, so the derive
/// never has to invent a type name in the user's module.
pub struct InvalidTransition<S> {
    pub from: S,
    pub to: S,
}

impl<S: StateMachine> fmt::Display for InvalidTransition<S> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "no transition from {} to {}", self.from.name(), self.to.name())
    }
}

impl<S: StateMachine> fmt::Debug for InvalidTransition<S> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("InvalidTransition")
            .field("from", &self.from.name())
            .field("to", &self.to.name())
            .finish()
    }
}

impl<S: StateMachine> std::error::Error for InvalidTransition<S> {}

The trait decides the division of labour. The derive writes the part that depends on the variants, INITIAL, name and next_states, which is data. The behaviour, can_transition_to and transition_to, is ordinary generic Rust, written once in the facade. mem::discriminant ↗ compares two variants without asking the user to derive PartialEq.

Reading and checking the attributes

demo/state_machine_derive/src/machine.rs
//! What the attributes declare, read by darling and then checked.

use darling::ast::Data;
use darling::util::Flag;
use darling::{Error, FromDeriveInput, FromVariant};
use syn::{DeriveInput, Ident};

/// The enum as darling reads it. `supports(enum_unit)` accepts only an enum
/// whose variants have no fields, so `take_enum` below cannot fail.
#[derive(FromDeriveInput)]
#[darling(supports(enum_unit))]
struct Input {
    ident: Ident,
    data: Data<State, ()>,
}

/// One variant and its `#[state(initial, to(A, B))]`. Both parts are optional:
/// a `Flag` is absent unless written, and `to` defaults to no transitions.
#[derive(FromVariant)]
#[darling(attributes(state))]
pub struct State {
    pub ident: Ident,
    initial: Flag,
    #[darling(default)]
    pub to: Vec<Ident>,
}

/// A machine that passed every check, so generating code from it cannot fail.
pub struct Machine {
    pub name: Ident,
    pub initial: Ident,
    pub states: Vec<State>,
}

impl Machine {
    pub fn from_input(input: &DeriveInput) -> darling::Result<Machine> {
        let Input { ident: name, data } = Input::from_derive_input(input)?;
        let states = data.take_enum().expect("supports(enum_unit) lets only enums through");
        // Collect every problem, so the user fixes them in one pass.
        let mut errors = Error::accumulator();

        // A transition to a state that does not exist: the error goes on its name.
        for target in states.iter().flat_map(|state| &state.to) {
            if !states.iter().any(|state| state.ident == *target) {
                let message = format!("`{name}` has no state named `{target}`");
                errors.push(Error::custom(message).with_span(target));
            }
        }

        // Exactly one initial state. A second `initial` is underlined itself.
        let initials: Vec<&State> = states.iter().filter(|state| state.initial.is_present()).collect();
        for extra in initials.iter().skip(1) {
            let message = format!("`{}` is already the initial state", initials[0].ident);
            errors.push(Error::custom(message).with_span(&extra.initial.span()));
        }
        let Some(initial) = initials.first().map(|state| state.ident.clone()) else {
            let message = "no initial state: mark one variant `#[state(initial)]`";
            errors.push(Error::custom(message).with_span(&name));
            return Err(Error::multiple(errors.into_inner()));
        };

        errors.finish_with(Machine { name, initial, states })
    }
}

Two passes, with different jobs:

  • darling checks each attribute's shape. Input and State are declarations of what #[state(…)] may contain; darling writes the parser. Flag ↗ records whether initial was written and keeps the span of the word, which is what lets the second initial be underlined below. to is optional because of #[darling(default)]: Order::Shipped in the no-clash program has no attribute at all.
  • from_input checks the machine. Whether Lockd is a state is not a question about one attribute: it needs every variant at once, so it is the macro's own check. Each problem goes into the Accumulator ↗ rather than returning at the first, and each carries the span of the tokens it is about.

Machine is only built once every check has passed, so the generator below takes a &Machine and returns plain tokens, with no error path of its own.

Nothing this derive calls in syn changed between syn 2 and syn 3: parse_macro_input!, DeriveInput and Variant are defined identically in syn 2.0.119 and 3.0.6. darling did have to move. Its 0.24.0 release ↗ is the first built on syn 3, and 0.20 to 0.23 are built on syn 2.

Writing the impl

demo/state_machine_derive/src/lib.rs
//! `#[derive(StateMachine)]`: read each variant's `#[state(…)]`, check the
//! machine it describes, and implement `state_machine::StateMachine` for it.

mod machine;

use proc_macro::TokenStream;
use quote::quote;
use syn::{DeriveInput, parse_macro_input};

use machine::Machine;

#[proc_macro_derive(StateMachine, attributes(state))]
pub fn derive_state_machine(input: TokenStream) -> TokenStream {
    let input = parse_macro_input!(input as DeriveInput);
    match Machine::from_input(&input) {
        Ok(machine) => generate(&machine).into(),
        // One `compile_error!` per problem, each carrying its own span.
        Err(errors) => errors.write_errors().into(),
    }
}

/// The trait impl. It reaches the trait and `str` by absolute paths, adds
/// nothing but the trait's own items, and binds no variables: every other name
/// in it is the user's enum, its variants, or a keyword.
fn generate(machine: &Machine) -> proc_macro2::TokenStream {
    let name = &machine.name;
    let initial = &machine.initial;
    let states = machine.states.iter().map(|state| &state.ident);
    let labels = machine.states.iter().map(|state| state.ident.to_string());
    let from = machine.states.iter().map(|state| &state.ident);
    let targets = machine.states.iter().map(|state| &state.to);

    quote! {
        impl ::state_machine::StateMachine for #name {
            const INITIAL: Self = Self::#initial;

            fn name(&self) -> &'static ::core::primitive::str {
                match self {
                    #(Self::#states => #labels,)*
                }
            }

            fn next_states(&self) -> &'static [Self] {
                match self {
                    #(Self::#from => &[#(Self::#targets),*],)*
                }
            }
        }
    }
}

cargo expand is what you would run to see what this writes for Door. The key below was recorded with the command it runs underneath, with RUSTC_BOOTSTRAP=1 so that the stable toolchain accepts -Zunpretty=expanded:

Verified output of cargo rustc -q -p door --lib --profile=check -- -Zunpretty=expanded — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

#![feature(prelude_import)]
extern crate std;
#[prelude_import]
use std::prelude::rust_2024::*;
use state_machine::StateMachine;

/// A door that can be locked only while closed.
pub enum Door {

    #[state(initial, to(Closed))]
    Open,

    #[state(to(Open, Locked))]
    Closed,

    #[state(to(Closed))]
    Locked,
}
impl ::state_machine::StateMachine for Door {
    const INITIAL: Self = Self::Open;
    fn name(&self) -> &'static ::core::primitive::str {
        match self {
            Self::Open => "Open",
            Self::Closed => "Closed",
            Self::Locked => "Locked",
        }
    }
    fn next_states(&self) -> &'static [Self] {
        match self {
            Self::Open => &[Self::Closed],
            Self::Closed => &[Self::Open, Self::Locked],
            Self::Locked => &[Self::Closed],
        }
    }
}
  • The first four lines are the compiler's own prelude import, not the macro's.
  • #[derive(StateMachine)] is gone and #[state(…)] stays. The helper attributes are inert once the derive has read them.
  • #(Self::#states => #labels,)* made one arm per variant. labels holds Strings, and quote writes a String as a string literal: "Open".
  • The nested repetition walks the variants on the outside and each variant's to list on the inside, producing &[Self::Open, Self::Locked] for Closed.

Refusals

Each case below is a file under state_machine/tests/ui/, and refusals/Cargo.toml declares each one as a binary, so a plain cargo build shows what a user of the derive would see. That is why the paths in these errors pass through refusals/...

A misspelled target:

demo/state_machine/tests/ui/unknown_state.rs
use state_machine::StateMachine;

#[derive(StateMachine)]
enum Door {
    #[state(initial, to(Clsoed))]
    Open,
    #[state(to(Open, Lockd))]
    Closed,
    #[state(to(Closed))]
    Locked,
}

fn main() {}

Verified output of cargo build -q -p refusals --bin unknown_state, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error: `Door` has no state named `Clsoed`
 --> refusals/../state_machine/tests/ui/unknown_state.rs:5:25
  |
5 |     #[state(initial, to(Clsoed))]
  |                         ^^^^^^

error: `Door` has no state named `Lockd`
 --> refusals/../state_machine/tests/ui/unknown_state.rs:7:22
  |
7 |     #[state(to(Open, Lockd))]
  |                      ^^^^^

error: could not compile `refusals` (bin "unknown_state") due to 2 previous errors

Both misspellings come out of one build, each underlined where it was written, because the loop pushed to the accumulator instead of returning.

No initial state:

demo/state_machine/tests/ui/missing_initial.rs
use state_machine::StateMachine;

#[derive(StateMachine)]
enum Door {
    #[state(to(Closed))]
    Open,
    #[state(to(Open))]
    Closed,
}

fn main() {}

Verified output of cargo build -q -p refusals --bin missing_initial, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error: no initial state: mark one variant `#[state(initial)]`
 --> refusals/../state_machine/tests/ui/missing_initial.rs:4:6
  |
4 | enum Door {
  |      ^^^^

error: could not compile `refusals` (bin "missing_initial") due to 1 previous error

There is no token for something that is missing, so the error goes on the enum's name, the closest place a reader would start looking.

Two initial states:

demo/state_machine/tests/ui/two_initial_states.rs
use state_machine::StateMachine;

#[derive(StateMachine)]
enum Door {
    #[state(initial, to(Closed))]
    Open,
    #[state(initial, to(Open))]
    Closed,
}

fn main() {}

Verified output of cargo build -q -p refusals --bin two_initial_states, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

error: `Open` is already the initial state
 --> refusals/../state_machine/tests/ui/two_initial_states.rs:7:13
  |
7 |     #[state(initial, to(Open))]
  |             ^^^^^^^

error: could not compile `refusals` (bin "two_initial_states") due to 1 previous error

The first initial is not wrong on its own, so only the second is underlined.

The same three under trybuild

demo/state_machine/tests/ui.rs
//! Every refusal the derive makes, compiled and compared with the recorded
//! message beside it: `tests/ui/<name>.stderr`.

#[test]
fn refusals() {
    trybuild::TestCases::new().compile_fail("tests/ui/*.rs");
}

Beside each case sits the message it must produce. This one was written by running the test once with TRYBUILD=overwrite, and differs from the cargo build error above only in the path and the missing summary line:

demo/state_machine/tests/ui/unknown_state.stderr
error: `Door` has no state named `Clsoed`
 --> tests/ui/unknown_state.rs:5:25
  |
5 |     #[state(initial, to(Clsoed))]
  |                         ^^^^^^

error: `Door` has no state named `Lockd`
 --> tests/ui/unknown_state.rs:7:22
  |
7 |     #[state(to(Open, Lockd))]
  |                      ^^^^^

Verified output of cargo test -q -p state_machine --test ui — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

test tests/ui/missing_initial.rs ... ok
test tests/ui/two_initial_states.rs ... ok
test tests/ui/unknown_state.rs ... ok

If a change to machine.rs moved an underline by one column or reworded a message, this test would fail, and show the new message beside the recorded one.

Names that cannot clash

The expansion above adds no name of its own. Every name in it is the user's (Door and its variants), a keyword (self, Self), an item of the trait (INITIAL, name, next_states), or reached by a path that starts with :: (::state_machine::StateMachine, ::core::primitive::str). This program already uses those names for something else:

demo/no_clash/src/main.rs
//! A program that already uses the names the derive's output relies on.

use ::state_machine::StateMachine;

/// The user's own module, named like the facade crate. In this file
/// `state_machine::` means this module, and `::state_machine::` the crate.
mod state_machine {
    pub fn whose() -> &'static str {
        "the user's module"
    }
}

/// A type named like a primitive. From here on, `str` in this module means
/// this struct (not inside `mod state_machine`, which is a module of its own).
#[allow(dead_code, non_camel_case_types)]
struct str;

#[derive(StateMachine)]
enum Order {
    #[state(initial, to(Paid))]
    Placed,
    #[state(to(Shipped))]
    Paid,
    Shipped,
}

/// Inherent items with the trait's names.
impl Order {
    const INITIAL: Order = Order::Paid;

    fn name(&self) -> String {
        format!("order ({})", StateMachine::name(self))
    }
}

fn main() {
    let order = <Order as StateMachine>::INITIAL;
    println!("state_machine::whose()            {}", state_machine::whose());
    println!("Order::INITIAL                    {}", StateMachine::name(&Order::INITIAL));
    println!("<Order as StateMachine>::INITIAL  {}", StateMachine::name(&order));
    println!("order.name()                      {}", order.name());

    let shipped = order.transition_to(Order::Paid).and_then(|paid| paid.transition_to(Order::Shipped));
    if let Err(refused) = shipped.unwrap().transition_to(Order::Placed) {
        println!("refused                           {refused}");
    }
}

Verified output of cargo run -q -p no_clash — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.

state_machine::whose()            the user's module
Order::INITIAL                    Paid
<Order as StateMachine>::INITIAL  Placed
order.name()                      order (Placed)
refused                           no transition from Shipped to Placed

It builds without a warning, and the output shows each decision in the generated code holding:

  1. Paths that start with ::. In this file state_machine:: means the user's module and str means the user's struct. A leading :: names an external crate, so the generated ::state_machine::StateMachine and ::core::primitive::str reached neither. core::primitive ↗ exists for this: its module docs show the failure it prevents, with a struct named bool.
  2. Trait items, not inherent ones. The user's Order::INITIAL is Paid, the trait's is Placed, and both exist. That is also the trap, and it is silent: Order::INITIAL and order.name() reach the user's items, because the type's own impl is searched before its traits. The trait's versions take <Order as StateMachine>::INITIAL and StateMachine::name(&order).
  3. Generic code sees only the trait. The facade's Display for InvalidTransition calls name() on a type parameter, so the refusal says Shipped, not order (Shipped).
  4. No invented names. There is no format_ident! in lib.rs, no local variable in the expansion, and no companion type: the error type is generic and lives in the facade. Absolute paths and hygiene covers what it takes when generated code does need a variable of its own.

If you are coming from another language

  • C and C++. An X-macro, a list such as #define DOOR_STATES X(Open) X(Closed) X(Locked) expanded several times, generates the enum and its name table from one source, which is the name() half of this derive. The transition half is where the preprocessor stops: it cannot compare one list against another and emit a message of its own, so a misspelled state surfaces, if at all, as the compiler's own undeclared-identifier error wherever the expansion used it. The derive runs arbitrary Rust over the tokens and chooses both the message and the token it points at.
  • Python. The nearest equivalent is a class decorator or __init_subclass__ on an Enum that reads a transition table and raises if a target is not a member. The check itself transfers directly. What changes is when it runs: Python raises when the module is imported, so a misspelling in a module no test imports ships. The derive's check runs when the crate compiles, and the failing build is the report.
  • Java. An annotation processor on an enum is the same contract as a derive: it reads the declaration and may generate new classes, but not edit the enum. It also has spans: Messager.printMessage ↗ takes an Element, an AnnotationMirror and an AnnotationValue, so an error can point at the one annotation value that is wrong, as with_span(target) does. Libraries such as Spring Statemachine instead build the machine at run time, from builder calls.
  • ABAP. (Not machine-checked — CI cannot run ABAP.) SAP's general status management keeps a status profile in customizing (transaction BS02): an initial-status flag, and status numbers that restrict which status may follow. The allowed moves are data in tables, and they are checked when a document changes status, at run time. The derive puts the table in the source, next to the enum, and checks the table itself before the program exists.

See also

Po polsku

Maszyna stanów (state machine) jako makro derive: #[derive(StateMachine)] na enumie, którego warianty są stanami, a atrybut pomocniczy #[state(initial, to(Closed))] mówi, który stan jest początkowy i dokąd wolno przejść. Makro składa w całość cały rozdział: osobny crate proc-macro z fasadą, która go reeksportuje; darling do czytania atrybutów (FromDeriveInput, FromVariant, Flag); własną walidację, która zbiera wszystkie błędy naraz i każdy przypina do właściwego tokenu — literówka Lockd jest podkreślona dokładnie tam, gdzie ją napisano, a brak stanu początkowego wskazuje nazwę enuma; quote z zagnieżdżonym powtórzeniem; oraz trybuild, który pilnuje, żeby te komunikaty się nie zmieniły. Rozróżnienie, o którym łatwo zapomnieć: błędna deklaracja przejścia to błąd kompilacji, ale błędny ruch (Locked -> Open) wychodzi dopiero w czasie działania programu jako Err, bo makro nie widzi wartości, tylko tokeny.

Wygenerowany kod nie wprowadza żadnej własnej nazwy: ścieżki ::state_machine::StateMachine i ::core::primitive::str zaczynają się od ::, więc nie przesłoni ich ani moduł użytkownika o nazwie state_machine, ani jego własna struktura o nazwie str, a INITIAL i name są elementami traitu, nie elementami własnego impl typu. Pułapka: jeśli użytkownik ma własne fn name, to order.name() wywoła jego metodę — do wersji z traitu trzeba napisać StateMachine::name(&order).

Szukaj po polsku: maszyna stanów w Ruscie · makro derive dla enuma · darling FromVariant · trybuild compile_fail · rust derive state machine