Expand description
§sml.rs
A no_std state-machine library whose primary sml! procedural macro
mirrors the sml.cpp transition-table DSL.
§sml! transition-table DSL
The primary macro deliberately mirrors an sml.cpp transition table:
use sml::sml;
pub struct E1;
pub struct E2 { pub value: u32 }
sml! {
Example {
*"idle"_s + event<E1> / start = "running"_s,
"done"_s <= "running"_s + event<E2> [valid] / (capture, audit),
"done"_s / finish = X,
}
}The machine name replaces the C++ transition-table type. It prefixes generated
items: ExampleStateMachine, ExampleStateMachineContext, ExampleStates,
ExampleEvents, and ExampleError.
§Mechanical C++ to Rust mapping
sml.cpp table expression | sml.rs macro expression |
|---|---|
*"idle"_s + event<E> = "run"_s | identical |
"run"_s <= *"idle"_s + event<E> | identical |
"idle"_s + event<E> [guard] / action = X | identical |
/ (first, second) | identical for named Rust callbacks |
"idle"_s + event<E> / action | identical internal transition |
"idle"_s + event<E> = "idle"_s | identical external self-transition |
*"initial"_s / action = "ready"_s | identical anonymous transition |
"name"_e | identical named-event trigger |
state + on_entry<_> / action | identical |
state + on_exit<_> / action | identical |
state + sml::on_entry<_> / action | accepted qualified spelling |
... = sml::X | accepted qualified terminal spelling |
state + unexpected_event<E> / action | identical |
state + unexpected_event<_> / action | identical wildcard form |
| origin-aware completion | state + completion<E> / action = target |
/ defer | identical bounded deferred-event action |
/ process(E {}) | identical processed-event action |
Rust callback bodies are implemented on the generated context trait rather
than written as C++ lambdas inside the table. External event<E> values are
ordinary Rust types and can be passed directly to process_event.
§Grammar
sml! {
MachineName [<event generic parameters>] [where bounds] {
transition (, transition)* [,]
}
}
transition := [*] state + trigger [guard] [/ action] [= state]
| state <= [*] state + trigger [guard] [/ action]
| [*] state [/ action] = state
state := "state name"_s | RustIdentifier | X
trigger := event<RustType>
| "named event"_e
| on_entry<_> | on_exit<_>
| unexpected_event<RustType> | unexpected_event<_>
| completion<RustType> | completion<_>
| exception<RustError> | exception<_>
guard := [name] | [!name] | [(a && !b) || c]
action := name | (first, second, ...)
| (..., eval [guard] / action, ...)A leading * selects the initial state. Omitting the target makes a true
internal transition: no exit or entry callbacks run. An explicit target equal
to the source is an external self-transition, so exit and entry callbacks do
run. X is the terminal state.
§Generic event types
Flat tables declare Rust lifetime, type, and const parameters immediately after
the machine name. Bounds may be inline or in an ordinary where clause:
use core::fmt::Debug;
use sml::sml;
pub struct Owned<T, const N: usize>(pub T, pub [u8; N]);
pub struct Operation<T, const N: usize> {
pub input: [T; N],
pub result: Option<[T; N]>,
}
sml! {
Typed<'operation, T, const N: usize>
where
T: Clone + Debug + 'operation,
[T; N]: AsRef<[T]>,
{
*"idle"_s + event<Owned<T, N>> / consume,
"idle"_s + event<&'operation mut Operation<T, N>> / complete,
}
}The trigger accepts a named type path (event<Message<T>>) or a shared or
mutable reference to one (event<&'a Message<T>> and
event<&'a mut Operation<T, N>>). Qualified paths, concrete lifetimes such as
'static, and const arguments are
accepted. unexpected_event<...> uses the same typed form. Tuple, slice,
array, pointer, function, trait-object, and other anonymous trigger types are
rejected; put such data in a named event type.
Generated APIs preserve static dispatch:
TypedEvents<'operation, T, N>stores the concrete event or reference.- Each affected context callback is a generic trait method with the declared
bounds and
whereclause. - The inherent
process_eventmethod and synchronousMachine<Events<...>>implementation are monomorphized for each concrete argument set. - Event-only lifetimes remain on the dispatch call. They are not added to
TypedStateMachine, cannot be stored in machine state, and mutable borrows must end before dispatch returns. - A mutable borrowed event cannot be named by
completion<Event>because completion processing would need to retain the origin; the macro emits a targeted diagnostic for this combination. - The generated machine uses no type erasure,
Any, trait object, allocation, downcast, unsafe code, or dispatch-local storage.
Every directly dispatched external event must mention every declared type and
const parameter. A typed internal event that uses any declared generic must do
the same. The generated API owns one Events<...> family, so an event that
omitted one of those arguments would leave dispatch or its generic callback
unable to infer the family. The macro rejects that declaration directly instead
of adding hidden routing. Lifetimes may remain event-specific: they are inferred
at the call and cannot escape dispatch. A single machine value may process, for
example, both Owned<u32, 4> and Owned<String, 8> when its generic callback
implementation satisfies the declared bounds. Parameter defaults are rejected
because Rust does not permit defaults on the generated generic callback and
dispatch methods. A where clause therefore also requires at least one
declared parameter after the machine name.
Type and const parameters are dispatch-scoped: they may appear in typed event
payloads and callbacks, but not in stored state payloads or typed exception
payloads. Stored state would otherwise need to choose one monomorphization for
the lifetime of the machine, while typed exception recovery is generated
outside the individual dispatch method. The macro rejects both forms with a
targeted diagnostic. A declared lifetime may also appear in state data; in that
case the lifetime belongs to the machine and is reused, rather than redeclared,
by process_event.
Generic event declarations currently apply to flat tables. Orthogonal and
composite generators have fixed event enums and reject them with a targeted
diagnostic. Generic events also reject / defer and / process(...): those
operations store events in the machine’s fixed queue, which cannot hold a
dispatch-scoped family of monomorphizations. Concrete event types such as
event<Message<u32>> remain available in every machine shape and may use the
normal queue features.
Every declared event parameter must occur in at least one event payload. Each external event must carry every declared type and const parameter; lifetimes may remain event-specific and may occur inside nested type arguments. A temporary context may use event parameters when the event payloads define the family. It must carry every declared type and const parameter so those parameters can be propagated to callbacks for otherwise non-generic triggers.
Higher-ranked lifetimes remain local to their binder, so a payload such as
event<Message<for<'borrow> fn(&'borrow T), T>> does not add 'borrow to the
generated event family. Callback and completion APIs retain only the
event-specific lifetimes, bounds, and where predicates required by their own
payloads. Rust’s implicit late binding in a bare function payload such as
fn(&T) is supported as well.
Multiple leading * states define orthogonal regions exactly as in sml.cpp:
use sml::sml;
pub struct E1;
pub struct E2;
pub struct E3;
sml! {
Regions {
*"idle"_s + event<E1> = "s1"_s,
"s1"_s + event<E2> = X,
*"idle2"_s + event<E2> = "s2"_s,
"s2"_s + event<E3> = X,
}
}Each event is borrowed and broadcast to every active region, so E2 can move
both regions during one call. Orthogonal machines expose states(),
state(region), is(&[...]), and is_region(region, &state), and are
terminated only when every region is X. Specific and wildcard unexpected
handlers are resolved independently per region. Anonymous rows stabilize every
region during initialize() and again after each handled broadcast. Prefixing
any orthogonal guard or action with async generates an async broadcast and
stabilization path and awaits callbacks in every region.
State strings are converted to PascalCase generated variants; for example,
"fin wait 1"_s becomes States::FinWait1. Named events are converted the
same way. Generated event enum variants remain useful for named events, while
typed event<E> transitions generate From<E> and support direct dispatch.
In a flat table, state<T> creates a States::T(T) payload variant. Initial
typed states and actionless typed targets use T::default(). When a transition
action constructs the target, its final action returns T; this is the
ownership-safe Rust counterpart to sml.cpp injecting a mutable destination
state object. Use new_with_state_data(context, value) to override an inferred
initial value.
§Context callbacks
Implement the generated MachineStateMachineContext trait. Guards borrow the
event and return Result<bool, Error>; actions borrow external events and
return Result<(), Error>. If a transition constructs a data-bearing output
state, the final action returns that state’s data. Earlier actions in a
sequence return ().
impl ExampleStateMachineContext for Context {
fn valid(&self, event: &E2) -> Result<bool, ()> {
Ok(event.value != 0)
}
fn capture(&mut self, event: &E2) -> Result<(), ()> {
self.value = event.value;
Ok(())
}
fn audit(&mut self, _event: &E2) -> Result<(), ()> {
Ok(())
}
}Call initialize() once after construction to run initial entry behavior and
anonymous-transition stabilization. Normal event processing automatically
stabilizes subsequent anonymous and completion transitions.
state() borrows the generated state enum, while is(&States::Idle) performs
the payload-insensitive state identity check corresponding to sm.is("idle"_s)
in C++.
Prefix a callback with async in a guard or action position to generate an
async machine, for example [async ready] / async send. Rust futures provide
the coroutine behavior directly.
Generated synchronous flat machines without a temporary call-scoped context
implement Machine<E>. Generic callers can use process_event directly or
await process_event_async for the same run-to-completion operation. Both
report event acceptance as bool, matching the sml.cpp co_sm contract; the
generated machine’s inherent process_event retains the detailed Rust
Result. The async trait entry point is an allocation-free future using the
uncontended inline RTC fast path. This is independent of async guards and
actions. Generic callers pass the generated event enum; inherent methods also
accept external event types that convert into that enum. Orthogonal, composite,
async-callback, and temporary-context machines retain their shape-specific
inherent processing APIs. A scheduler-backed co_sm adapter for pending RTC
completion remains separate work.
Action sequences accept eval [guard] / action in any position. The nested
action runs only when its guard expression passes, while surrounding actions
retain their original order. Both the eval guard and action may be async.
For flat machines, state + exception<_> / recover = target handles a guard
or action that returned Err. A typed exception<MyError> additionally
infers MyError as the machine callback-error type and injects &MyError into
the handler action. The original GuardFailed/ActionFailed is intercepted
and the exception transition becomes the event result. Sync and async
callbacks are supported. A machine currently uses one concrete Rust error
type; use an enum when several error variants need typed routing.
The reserved / defer action stores the current event in a generated,
allocation-free queue and retries it after the next state change. The reserved
/ process(Event {}) action dispatches the supplied event after the current
transition has installed its target state. Queue actions work in sync/async
flat, orthogonal, and composite tables through allocation-free iterative
dispatch. Deferring an owned structural payload requires that payload to
implement Clone.
§Composite machines
Place the child and parent tables adjacently in one sml! invocation and use
the same state<Sub> spelling as C++:
use sml::sml;
pub struct Enter;
pub struct ChildEvent;
pub struct Leave;
sml! {
Sub {
*"idle"_s + event<ChildEvent> = X,
}
Parent {
*"idle"_s + event<Enter> = state<Sub>,
state<Sub> + event<Leave> = X,
}
}The generated parent owns both state values and one unified context. While
state<Sub> is active, events route to the child first and bubble to the
parent only when the child has no matching transition. Leaving and re-entering
the child resets it to its initial state by default. Mark the child’s initial
state as *"idle"_s(H) to retain shallow history across re-entry, exactly as
in sml.cpp. child_state(), is_child(...), and
child_is_active() expose typed composite queries. Parent and child lifecycle
rows and unexpected handlers participate in the same child-first ordering.
Sync or async guards/actions are supported. A parent anonymous transition from
state<Sub> becomes eligible only after the child reaches X, matching
composite completion semantics.
The public macro surface is sml!.
Modules§
- utility
- Allocation-free utilities for runtime dispatch and groups of state machines.
Macros§
- sml
- Defines a state machine using sml.cpp-shaped transition-table syntax.
Traits§
- Machine
- Common synchronous interface implemented by generated state machines that do not require a temporary context.
- Terminated
- Reports whether a state machine has reached its terminal state.