statem: add gen_statem! authoring macro (RFC 017 chunk 1)
Declarative macro_rules! that fuses the hand-written statem surface into
one invocation: emits the unified event enum, the machine struct + state
cell, start, the Machine impl (dispatch + stay/transition apply-tail), and
the enter dispatch. User keeps the meaningful types, the per-state
successor enums, and the free handler fns.
Pure sugar: every safety property is a property of the emitted code,
checked by rustc, so a declarative macro carries (almost) the proc-macro
guarantee set:
1. forgotten (state,event) pair -> E0004 (total match, no injected _)
2. conflicting row -> unreachable_patterns (macro self-denies; HARD only
in-crate, suppressed cross-crate by in_external_macro -- documented)
3. orphan handler -> dead_code (handlers are user free fns)
4. out-of-set target -> E0599 (per-state successor enums)
Hygiene: bodies can't see the macro's self/cx, so the caller names them
via `context(data, prev, cx)` (shared call-site hygiene).
No separate transitions{} block: the match IS the table, successor enums
ARE the per-state target sets (fused variant, diverges from RFC
edge-lint).
- examples/statem_macro.rs: Door machine via the macro (parallel to the
hand-written examples/statem_fused.rs; diff the two to see the delta).
- in-crate test exercises a machine + anchors the in-crate #2 guarantee.
This commit is contained in:
@@ -0,0 +1,236 @@
|
|||||||
|
//! SPIKE (throwaway) — RFC 017 "fused" approach.
|
||||||
|
//!
|
||||||
|
//! This is the hand-written *expansion target* of the eventual `statem!` macro,
|
||||||
|
//! written out in full so the primitives can be judged standing on their own.
|
||||||
|
//! A macro would generate exactly this from a `transitions { … }` block; nothing
|
||||||
|
//! here needs the macro to be correct or safe.
|
||||||
|
//!
|
||||||
|
//! The design, as settled over the prior iterations:
|
||||||
|
//!
|
||||||
|
//! * States and events are real enums. An invalid state is unrepresentable;
|
||||||
|
//! there are no bitflags, no `u32` superpositions, no unsafe unions.
|
||||||
|
//!
|
||||||
|
//! * The dispatch `match (state, event)` IS the transition table. It is
|
||||||
|
//! *total* — no catch-all `_` arm — so:
|
||||||
|
//! - a forgotten (state, event) pair is a non-exhaustive `match` (E0004),
|
||||||
|
//! - a duplicated/conflicting row is `unreachable_patterns` (denied below).
|
||||||
|
//!
|
||||||
|
//! * Single-target rows name their target in the table; the handler (if any)
|
||||||
|
//! is side-effect-only. The table owns the target, so it cannot be wrong.
|
||||||
|
//!
|
||||||
|
//! * Branching rows have the handler return a per-row *successor enum*. A
|
||||||
|
//! target outside that enum is E0599; a missing one is E0004. (See
|
||||||
|
//! `UnlockOutcome` and `on_unlock`.)
|
||||||
|
//!
|
||||||
|
//! * Handlers are module-private. The only way to reach one is through the
|
||||||
|
//! actor's message interface via this table, so a handler that is never
|
||||||
|
//! wired in is dead code — and dead_code is denied below, making an orphan
|
||||||
|
//! handler a compile error.
|
||||||
|
//!
|
||||||
|
//! * Drops onto the committed `Machine` / `Resolution` / `Cx` primitives with
|
||||||
|
//! no change to `src/statem.rs`.
|
||||||
|
//!
|
||||||
|
//! Default build is clean and runs. A BREAK-CASE MENU at the bottom documents
|
||||||
|
//! how to make each of the four guarantees fire.
|
||||||
|
//!
|
||||||
|
//! Run: `cargo run --example statem_fused`
|
||||||
|
|
||||||
|
#![deny(dead_code, unreachable_patterns)]
|
||||||
|
|
||||||
|
use smarm::run;
|
||||||
|
use smarm::statem::{spawn, Cx, Machine, Reply, Resolution, StatemRef};
|
||||||
|
|
||||||
|
// === user types ============================================================
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum Door {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
Locked,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Data {
|
||||||
|
enters: u32, // total state entries (incl. initial)
|
||||||
|
pushes: u32, // times a push closed the door
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Cast {
|
||||||
|
Push,
|
||||||
|
Pull,
|
||||||
|
Lock,
|
||||||
|
Unlock(u32), // carries a key
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Call {
|
||||||
|
GetState(Reply<Door>),
|
||||||
|
GetEnters(Reply<u32>),
|
||||||
|
GetPushes(Reply<u32>),
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Ev {
|
||||||
|
Cast(Cast),
|
||||||
|
Call(Call),
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODE: u32 = 1234;
|
||||||
|
|
||||||
|
// === per-row successor enum for the one branching row ======================
|
||||||
|
// `Locked + Unlock` may end in Closed (right key) or Locked (wrong key) — and
|
||||||
|
// nothing else. This type IS that declared set; `on_unlock` cannot name Open.
|
||||||
|
enum UnlockOutcome {
|
||||||
|
Closed,
|
||||||
|
Locked,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UnlockOutcome> for Door {
|
||||||
|
fn from(o: UnlockOutcome) -> Door {
|
||||||
|
match o {
|
||||||
|
UnlockOutcome::Closed => Door::Closed,
|
||||||
|
UnlockOutcome::Locked => Door::Locked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === module-private handlers (side effects / branch choice only) ===========
|
||||||
|
// Reachable solely through the table below. Orphan one and it is dead_code.
|
||||||
|
|
||||||
|
fn on_push(data: &mut Data) {
|
||||||
|
data.pushes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_unlock(key: u32) -> UnlockOutcome {
|
||||||
|
if key == CODE {
|
||||||
|
UnlockOutcome::Closed
|
||||||
|
} else {
|
||||||
|
UnlockOutcome::Locked // wrong key: caller will see this == current -> stay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === the machine ===========================================================
|
||||||
|
|
||||||
|
struct DoorSm {
|
||||||
|
state: Door,
|
||||||
|
data: Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DoorSm {
|
||||||
|
fn start(init: Door) -> StatemRef<DoorSm> {
|
||||||
|
spawn(DoorSm {
|
||||||
|
state: init,
|
||||||
|
data: Data { enters: 0, pushes: 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter(&mut self, _cx: &mut Cx<Ev>) {
|
||||||
|
self.data.enters += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Machine for DoorSm {
|
||||||
|
type Ev = Ev;
|
||||||
|
|
||||||
|
fn on_start(&mut self, cx: &mut Cx<Ev>) {
|
||||||
|
self.enter(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(&mut self, ev: Ev, cx: &mut Cx<Ev>) {
|
||||||
|
let prev = self.state;
|
||||||
|
|
||||||
|
// ---- the transition table -----------------------------------------
|
||||||
|
// This match is total over (Door, Ev). Read it as the declared graph:
|
||||||
|
// each `=> To(x)` is an edge, each `=> Unhandled` an explicit refusal.
|
||||||
|
let res: Resolution<Door> = match (self.state, ev) {
|
||||||
|
// --- Open -------------------------------------------------------
|
||||||
|
(Door::Open, Ev::Cast(Cast::Push)) => {
|
||||||
|
on_push(&mut self.data);
|
||||||
|
Resolution::To(Door::Closed)
|
||||||
|
}
|
||||||
|
(Door::Open, Ev::Cast(Cast::Pull | Cast::Lock | Cast::Unlock(_))) => {
|
||||||
|
Resolution::Unhandled
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Closed -----------------------------------------------------
|
||||||
|
(Door::Closed, Ev::Cast(Cast::Pull)) => Resolution::To(Door::Open),
|
||||||
|
(Door::Closed, Ev::Cast(Cast::Lock)) => Resolution::To(Door::Locked),
|
||||||
|
(Door::Closed, Ev::Cast(Cast::Push | Cast::Unlock(_))) => Resolution::Unhandled,
|
||||||
|
|
||||||
|
// --- Locked (branching row: handler picks within UnlockOutcome) -
|
||||||
|
(Door::Locked, Ev::Cast(Cast::Unlock(key))) => {
|
||||||
|
Resolution::To(on_unlock(key).into())
|
||||||
|
}
|
||||||
|
(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock)) => {
|
||||||
|
Resolution::Unhandled
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- state-independent queries (reply, then stay) ---------------
|
||||||
|
(_, Ev::Call(Call::GetState(r))) => {
|
||||||
|
r.reply(prev);
|
||||||
|
Resolution::To(prev)
|
||||||
|
}
|
||||||
|
(_, Ev::Call(Call::GetEnters(r))) => {
|
||||||
|
r.reply(self.data.enters);
|
||||||
|
Resolution::To(prev)
|
||||||
|
}
|
||||||
|
(_, Ev::Call(Call::GetPushes(r))) => {
|
||||||
|
r.reply(self.data.pushes);
|
||||||
|
Resolution::To(prev)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- apply the resolution -----------------------------------------
|
||||||
|
match res {
|
||||||
|
Resolution::To(s) if s == prev => {} // stay: no enter
|
||||||
|
Resolution::To(s) => {
|
||||||
|
self.state = s; // sole writer of the state cell
|
||||||
|
self.enter(cx);
|
||||||
|
}
|
||||||
|
Resolution::Postpone => unreachable!("postpone lands in chunk 3"),
|
||||||
|
Resolution::Unhandled => cx.on_unhandled(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
run(|| {
|
||||||
|
let door = DoorSm::start(Door::Closed);
|
||||||
|
|
||||||
|
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
|
||||||
|
door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled
|
||||||
|
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay
|
||||||
|
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed
|
||||||
|
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open
|
||||||
|
door.send(Ev::Cast(Cast::Push)).unwrap(); // Open -> Closed (pushes=1)
|
||||||
|
|
||||||
|
let st = door.call(|r| Ev::Call(Call::GetState(r))).unwrap();
|
||||||
|
let enters = door.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||||
|
let pushes = door.call(|r| Ev::Call(Call::GetPushes(r))).unwrap();
|
||||||
|
|
||||||
|
println!("state={st:?} enters={enters} pushes={pushes}");
|
||||||
|
assert_eq!(st, Door::Closed);
|
||||||
|
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
|
||||||
|
assert_eq!(pushes, 1);
|
||||||
|
println!("ok");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// BREAK-CASE MENU — each makes one compile-time guarantee fire.
|
||||||
|
//
|
||||||
|
// 1. ORPHAN HANDLER (dead_code -> error):
|
||||||
|
// add `fn on_slam(_d: &mut Data) {}` and don't reference it.
|
||||||
|
// => error: function `on_slam` is never used
|
||||||
|
//
|
||||||
|
// 2. CONFLICTING ROW (unreachable_patterns -> error):
|
||||||
|
// duplicate an arm, e.g. add a second
|
||||||
|
// `(Door::Open, Ev::Cast(Cast::Push)) => Resolution::Unhandled,`
|
||||||
|
// => error: unreachable pattern
|
||||||
|
//
|
||||||
|
// 3. MISSING PAIR (non-exhaustive match, E0004):
|
||||||
|
// delete the `(Door::Locked, Ev::Cast(Cast::Push | Cast::Pull | Cast::Lock))`
|
||||||
|
// arm.
|
||||||
|
// => error[E0004]: non-exhaustive patterns: ... not covered
|
||||||
|
//
|
||||||
|
// 4. OUT-OF-SET TARGET (E0599):
|
||||||
|
// in `on_unlock`, return `UnlockOutcome::Open`.
|
||||||
|
// => error[E0599]: no variant ... named `Open` found for enum `UnlockOutcome`
|
||||||
|
// ===========================================================================
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//! RFC 017 — the **same** machine as `examples/statem_fused.rs`, written through
|
||||||
|
//! the `gen_statem!` macro. Diff this file against that one to see exactly what
|
||||||
|
//! the macro buys: every `// ===` section there that was boilerplate (the `Ev`
|
||||||
|
//! enum, the `DoorSm` struct, `start`, the whole `Machine` impl, the `enter`
|
||||||
|
//! dispatch, the stay/transition apply-tail) collapses into the invocation
|
||||||
|
//! below. What stays hand-written is what carries meaning: the four types, the
|
||||||
|
//! per-state successor enum, and the handler fns.
|
||||||
|
//!
|
||||||
|
//! The point of the exercise is that the macro is *pure sugar*: the four
|
||||||
|
//! compile-time guarantees the fused spike demonstrates are properties of the
|
||||||
|
//! emitted code, not of the macro, so they survive expansion unchanged. The
|
||||||
|
//! BREAK-CASE MENU at the bottom is the same four cases, re-expressed against
|
||||||
|
//! the macro surface — flip any one on and the compiler fires identically.
|
||||||
|
//!
|
||||||
|
//! Run: `cargo run --example statem_macro`
|
||||||
|
|
||||||
|
#![deny(dead_code)] // guarantee #3 (orphan handlers); the macro denies the
|
||||||
|
// dispatch's own unreachable_patterns internally.
|
||||||
|
|
||||||
|
use smarm::gen_statem;
|
||||||
|
use smarm::run;
|
||||||
|
use smarm::statem::Reply;
|
||||||
|
|
||||||
|
// === user types (identical to statem_fused.rs) =============================
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum Door {
|
||||||
|
Open,
|
||||||
|
Closed,
|
||||||
|
Locked,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Data {
|
||||||
|
enters: u32, // total state entries (incl. initial)
|
||||||
|
pushes: u32, // times a push closed the door
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Cast {
|
||||||
|
Push,
|
||||||
|
Pull,
|
||||||
|
Lock,
|
||||||
|
Unlock(u32), // carries a key
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Call {
|
||||||
|
GetState(Reply<Door>),
|
||||||
|
GetEnters(Reply<u32>),
|
||||||
|
GetPushes(Reply<u32>),
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODE: u32 = 1234;
|
||||||
|
|
||||||
|
// === per-row successor enum for the one branching row ======================
|
||||||
|
// `Locked + Unlock` may end in Closed (right key) or Locked (wrong key) — and
|
||||||
|
// nothing else. This type IS that declared set; `on_unlock` cannot name Open.
|
||||||
|
enum UnlockOutcome {
|
||||||
|
Closed,
|
||||||
|
Locked,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UnlockOutcome> for Door {
|
||||||
|
fn from(o: UnlockOutcome) -> Door {
|
||||||
|
match o {
|
||||||
|
UnlockOutcome::Closed => Door::Closed,
|
||||||
|
UnlockOutcome::Locked => Door::Locked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === module-private handlers (side effects / branch choice only) ===========
|
||||||
|
// Reachable solely through the table below. Orphan one and it is dead_code.
|
||||||
|
|
||||||
|
fn on_push(data: &mut Data) {
|
||||||
|
data.pushes += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_unlock(key: u32) -> UnlockOutcome {
|
||||||
|
if key == CODE {
|
||||||
|
UnlockOutcome::Closed
|
||||||
|
} else {
|
||||||
|
UnlockOutcome::Locked // wrong key: caller will see this == current -> stay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === the machine ===========================================================
|
||||||
|
// Everything below — Ev, DoorSm, start, Machine, enter — is generated.
|
||||||
|
|
||||||
|
gen_statem! {
|
||||||
|
machine: DoorSm { state: Door, data: Data };
|
||||||
|
event: Ev { cast: Cast, call: Call };
|
||||||
|
|
||||||
|
// You name the bindings the bodies use; the macro can't lend you its own
|
||||||
|
// `self`/`cx` across macro hygiene. `data` = &mut Data, `prev` = current
|
||||||
|
// state tag, `cx` = context handle (unused in chunk 1).
|
||||||
|
context(data, prev, cx);
|
||||||
|
|
||||||
|
enter {
|
||||||
|
_ => data.enters += 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
on Door::Open => {
|
||||||
|
cast Cast::Push => { on_push(data); Door::Closed },
|
||||||
|
cast Cast::Pull | Cast::Lock | Cast::Unlock(_) => unhandled,
|
||||||
|
}
|
||||||
|
on Door::Closed => {
|
||||||
|
cast Cast::Pull => Door::Open,
|
||||||
|
cast Cast::Lock => Door::Locked,
|
||||||
|
cast Cast::Push | Cast::Unlock(_) => unhandled,
|
||||||
|
}
|
||||||
|
on Door::Locked => {
|
||||||
|
cast Cast::Unlock(key) => on_unlock(key), // branch -> UnlockOutcome
|
||||||
|
cast Cast::Push | Cast::Pull | Cast::Lock => unhandled,
|
||||||
|
}
|
||||||
|
|
||||||
|
// state-independent queries (reply, then stay via `prev`)
|
||||||
|
on _ => {
|
||||||
|
call Call::GetState(r) => { r.reply(prev); prev },
|
||||||
|
call Call::GetEnters(r) => { r.reply(data.enters); prev },
|
||||||
|
call Call::GetPushes(r) => { r.reply(data.pushes); prev },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
run(|| {
|
||||||
|
let door = DoorSm::start(Door::Closed, Data { enters: 0, pushes: 0 });
|
||||||
|
|
||||||
|
door.send(Ev::Cast(Cast::Lock)).unwrap(); // Closed -> Locked
|
||||||
|
door.send(Ev::Cast(Cast::Push)).unwrap(); // Locked: Push invalid -> Unhandled
|
||||||
|
door.send(Ev::Cast(Cast::Unlock(0))).unwrap(); // Locked: wrong key -> stay
|
||||||
|
door.send(Ev::Cast(Cast::Unlock(CODE))).unwrap(); // Locked -> Closed
|
||||||
|
door.send(Ev::Cast(Cast::Pull)).unwrap(); // Closed -> Open
|
||||||
|
door.send(Ev::Cast(Cast::Push)).unwrap(); // Open -> Closed (pushes=1)
|
||||||
|
|
||||||
|
let st = door.call(|r| Ev::Call(Call::GetState(r))).unwrap();
|
||||||
|
let enters = door.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||||
|
let pushes = door.call(|r| Ev::Call(Call::GetPushes(r))).unwrap();
|
||||||
|
|
||||||
|
println!("state={st:?} enters={enters} pushes={pushes}");
|
||||||
|
assert_eq!(st, Door::Closed);
|
||||||
|
assert_eq!(enters, 5); // Closed(start) + Locked + Closed + Open + Closed
|
||||||
|
assert_eq!(pushes, 1);
|
||||||
|
println!("ok");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// BREAK-CASE MENU — the four guarantees, through the macro. Each fires exactly
|
||||||
|
// as it does in the hand-written statem_fused.rs.
|
||||||
|
//
|
||||||
|
// 1. ORPHAN HANDLER (dead_code -> error):
|
||||||
|
// add `fn on_slam(_d: &mut Data) {}` and don't reference it.
|
||||||
|
// => error: function `on_slam` is never used
|
||||||
|
//
|
||||||
|
// 2. CONFLICTING ROW (unreachable_patterns):
|
||||||
|
// duplicate a row, e.g. add a second
|
||||||
|
// `cast Cast::Push => unhandled,` under `on Door::Open`.
|
||||||
|
// NOTE: this example is a separate crate from `smarm`, so rustc's
|
||||||
|
// in_external_macro rule SILENCES this lint here even though the macro
|
||||||
|
// denies it — the duplicate compiles. The guarantee is real only for
|
||||||
|
// machines defined inside the smarm crate (see the unit test in
|
||||||
|
// src/statem.rs). This is the documented macro_rules! limitation.
|
||||||
|
//
|
||||||
|
// 3. MISSING PAIR (non-exhaustive match, E0004):
|
||||||
|
// delete the `cast Cast::Push | Cast::Pull | Cast::Lock => unhandled,`
|
||||||
|
// row under `on Door::Locked`.
|
||||||
|
// => error[E0004]: non-exhaustive patterns: ... not covered
|
||||||
|
//
|
||||||
|
// 4. OUT-OF-SET TARGET (E0599):
|
||||||
|
// in `on_unlock`, return `UnlockOutcome::Open`.
|
||||||
|
// => error[E0599]: no variant ... named `Open` found for enum `UnlockOutcome`
|
||||||
|
// ===========================================================================
|
||||||
+333
@@ -264,3 +264,336 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, mut machine: M) {
|
|||||||
crate::check!();
|
crate::check!();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// gen_statem! — the authoring macro (RFC 017 §Surface, fused variant)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Assemble a complete [`Machine`] from hand-written types, a glanceable
|
||||||
|
/// transition table, and free handler functions.
|
||||||
|
///
|
||||||
|
/// This is the **fused** authoring surface (see `examples/statem_fused.rs` for
|
||||||
|
/// the same machine written out by hand). You keep ownership of every type that
|
||||||
|
/// carries meaning — the state enum, the data struct, the `cast`/`call` enums,
|
||||||
|
/// and any per-row successor enums — and the macro emits only the mechanical
|
||||||
|
/// scaffolding: the unified event enum, the machine struct and its private
|
||||||
|
/// state cell, `start`, the `Machine` impl, and the `enter` dispatch.
|
||||||
|
///
|
||||||
|
/// The macro is **pure sugar with no semantic analysis of its own** — it never
|
||||||
|
/// inspects your types and never validates the graph. Every safety property is
|
||||||
|
/// a property of the code it emits, enforced by the *compiler* — which is what
|
||||||
|
/// lets a plain `macro_rules!` carry almost the whole guarantee set a proc-macro
|
||||||
|
/// would (the one exception, conflicting rows across a crate boundary, is
|
||||||
|
/// spelled out below).
|
||||||
|
///
|
||||||
|
/// # The four guarantees (and exactly how strong each is)
|
||||||
|
///
|
||||||
|
/// Two of these are hard `rustc` errors that fire unconditionally. The other
|
||||||
|
/// two are *lints*, so they need a deny in force — and lints interact with
|
||||||
|
/// macros, which is called out below. Put `#![deny(dead_code)]` on the crate
|
||||||
|
/// using the macro (the dispatch already denies its own pattern lint).
|
||||||
|
///
|
||||||
|
/// 1. **Forgotten `(state, event)` pair → `E0004` (hard error, always).** The
|
||||||
|
/// dispatch is a *total* `match (state, event)` with **no macro-injected
|
||||||
|
/// catch-all**: a pair you never wrote makes the match non-exhaustive.
|
||||||
|
/// Totality is the price — refuse the events you don't handle with explicit
|
||||||
|
/// `=> unhandled` rows (OR-patterns keep that to one row per state).
|
||||||
|
/// 4. **Out-of-set branch target → `E0599` (hard error, always).** A branching
|
||||||
|
/// row returns a per-state *successor enum* (its variants are that state's
|
||||||
|
/// declared targets); naming a tag outside it is a missing variant.
|
||||||
|
/// 3. **Orphan handler → `dead_code` (lint; needs your `#![deny(dead_code)]`).**
|
||||||
|
/// Handlers are ordinary module-private `fn`s in *your* crate, reachable only
|
||||||
|
/// through the table, so this lint sees them normally.
|
||||||
|
/// 2. **Duplicated / conflicting row → `unreachable_patterns` (lint; with a
|
||||||
|
/// caveat).** Your patterns are emitted verbatim into one match, so a second
|
||||||
|
/// arm for the same pair is unreachable. The macro applies
|
||||||
|
/// `#[deny(unreachable_patterns)]` to the dispatch itself — **but** `rustc`
|
||||||
|
/// silences this lint for code expanded from a macro defined in *another*
|
||||||
|
/// crate (`in_external_macro`). So a conflicting row is a hard error when the
|
||||||
|
/// machine lives in the same crate as this macro, and is **silently dropped
|
||||||
|
/// when you call `gen_statem!` from a downstream crate** — neither a crate
|
||||||
|
/// `#![deny]` nor `#![forbid]` overrides the suppression. This is the one
|
||||||
|
/// guarantee a `macro_rules!` cannot carry across the crate boundary; the
|
||||||
|
/// RFC's proc-macro could (it would stamp the arms with call-site spans).
|
||||||
|
/// Guard against it in review, or with an in-crate test of the machine.
|
||||||
|
///
|
||||||
|
/// There is deliberately **no separate `transitions { … }` adjacency block**:
|
||||||
|
/// in this variant the `match` *is* the table and the successor enums *are* the
|
||||||
|
/// per-state target sets, so a second listing would be redundant and
|
||||||
|
/// un-cross-checkable without a proc-macro. The graph is read off the `on`
|
||||||
|
/// blocks and the successor enums at the top of the file.
|
||||||
|
///
|
||||||
|
/// # Surface
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// // ── your types (the macro never generates or inspects these) ───────────
|
||||||
|
/// #[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
/// enum Switch { Off, On }
|
||||||
|
/// struct Counts { flips: u32, enters: u32 }
|
||||||
|
/// enum SwitchCast { Flip }
|
||||||
|
/// enum SwitchCall { GetCount(Reply<u32>) }
|
||||||
|
///
|
||||||
|
/// gen_statem! {
|
||||||
|
/// machine: SwitchSm { state: Switch, data: Counts };
|
||||||
|
/// event: Ev { cast: SwitchCast, call: SwitchCall };
|
||||||
|
///
|
||||||
|
/// // Name the bindings your handler bodies use. A declarative macro can't
|
||||||
|
/// // hand you its own `self`/`cx` (hygiene), so you choose the identifiers
|
||||||
|
/// // and the macro binds them: `data` = &mut your Data, `prev` = the
|
||||||
|
/// // current state tag, `cx` = the context handle.
|
||||||
|
/// context(data, prev, cx);
|
||||||
|
///
|
||||||
|
/// // `enter` runs on entry to a state; side effects only, returns ().
|
||||||
|
/// // Match the state tag (or `_`); the arm body is statements.
|
||||||
|
/// enter {
|
||||||
|
/// _ => data.enters += 1,
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // The transition table. Group rows by current state with `on <pat>`.
|
||||||
|
/// // A row is: cast|call <event-pattern> [if <guard>] => <tail> ,
|
||||||
|
/// // and the tail is one of:
|
||||||
|
/// // * a state tag `Switch::On` (transition, or "stay"
|
||||||
|
/// // if it equals current)
|
||||||
|
/// // * a block ending in one `{ data.flips += 1; Switch::On }`
|
||||||
|
/// // * a successor-enum value `unlock(key)` (branching row)
|
||||||
|
/// // * the keyword `unhandled` (explicit refusal)
|
||||||
|
/// on Switch::Off => {
|
||||||
|
/// cast SwitchCast::Flip => { data.flips += 1; Switch::On },
|
||||||
|
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev },
|
||||||
|
/// }
|
||||||
|
/// on Switch::On => {
|
||||||
|
/// cast SwitchCast::Flip => Switch::Off,
|
||||||
|
/// call SwitchCall::GetCount(r) => { r.reply(data.flips); prev },
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Writing the rows
|
||||||
|
///
|
||||||
|
/// * **Every row ends in a comma** (block-bodied ones too) and every `on` block
|
||||||
|
/// is `on <state-pat> => { … }`. These two are macro-grammar requirements, not
|
||||||
|
/// style: a declarative matcher can't otherwise tell where a row's tail ends.
|
||||||
|
/// * **Qualify event patterns** (`SwitchCast::Flip`) and **state tags**
|
||||||
|
/// (`Switch::On`). A declarative macro can't prepend the enum name inside an
|
||||||
|
/// opaque pattern fragment, so the `cast`/`call` keyword only selects the
|
||||||
|
/// event wrapper — it does not qualify for you. The keyword also reads as a
|
||||||
|
/// sync/async marker at a glance.
|
||||||
|
/// * **Stay** = return the current tag. The `prev` you named in `context` is
|
||||||
|
/// bound to the pre-handler state for exactly this — handy in any-state
|
||||||
|
/// (`on _`) rows where there is no single literal tag to write.
|
||||||
|
/// * **`data` and `cx`** (the names you chose) are in scope in every body:
|
||||||
|
/// mutate `data`, reply through a `Reply` bound in the pattern, and (chunks
|
||||||
|
/// 2–3) arm timeouts or postpone via `cx`. You never write `self`.
|
||||||
|
/// * **Guards** are plain match guards. A guarded arm does not count toward
|
||||||
|
/// exhaustiveness, so pair it with an unguarded fallback or you'll (correctly)
|
||||||
|
/// trip `E0004`.
|
||||||
|
/// * **Branching rows** return a successor enum that `impl`s `From<_>` for the
|
||||||
|
/// state type; the macro supplies the outer `.into()`.
|
||||||
|
///
|
||||||
|
/// # What it emits
|
||||||
|
///
|
||||||
|
/// `enum $Ev { Cast($Cast), Call($Call) }`, `struct $Sm { state, data }`,
|
||||||
|
/// `$Sm::start(init, data) -> StatemRef<$Sm>`, the `Machine` impl (`on_start`
|
||||||
|
/// running the initial `enter`; `handle` = the dispatch match + the
|
||||||
|
/// stay/transition/unhandled apply-tail, the cell's sole writer), and the
|
||||||
|
/// `enter` dispatch. Chunk 1: real time, no timers, no postpone.
|
||||||
|
///
|
||||||
|
/// # Limitation
|
||||||
|
///
|
||||||
|
/// Diagnostics point at the macro expansion, not the offending source row —
|
||||||
|
/// the cost of staying a `macro_rules!`. The errors are the standard `E0004` /
|
||||||
|
/// `E0599` / lint messages, just sited at the invocation.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! gen_statem {
|
||||||
|
// ===== public entry =====================================================
|
||||||
|
(
|
||||||
|
machine: $sm:ident { state: $State:ty, data: $Data:ty } ;
|
||||||
|
event: $Ev:ident { cast: $Cast:ty, call: $Call:ty } ;
|
||||||
|
context ( $data:ident , $cur:ident , $cx:ident ) ;
|
||||||
|
enter { $( $est:pat => $ebody:expr ),+ $(,)? }
|
||||||
|
$( on $st:pat => { $($rows:tt)* } )+
|
||||||
|
) => {
|
||||||
|
/// Unified inbox payload: the user's `cast`/`call` enums folded together
|
||||||
|
/// (chunks 2–3 add the runtime's internal timeout variants here).
|
||||||
|
enum $Ev {
|
||||||
|
Cast($Cast),
|
||||||
|
Call($Call),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct $sm {
|
||||||
|
state: $State,
|
||||||
|
data: $Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl $sm {
|
||||||
|
fn start(init: $State, data: $Data) -> $crate::statem::StatemRef<$sm> {
|
||||||
|
$crate::statem::spawn($sm { state: init, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
#[deny(unreachable_patterns)]
|
||||||
|
fn enter(&mut self, state: $State, $cx: &mut $crate::statem::Cx<$Ev>) {
|
||||||
|
let $data = &mut self.data;
|
||||||
|
match state {
|
||||||
|
$( $est => { $ebody } ),+
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl $crate::statem::Machine for $sm {
|
||||||
|
type Ev = $Ev;
|
||||||
|
|
||||||
|
fn on_start(&mut self, $cx: &mut $crate::statem::Cx<$Ev>) {
|
||||||
|
let s = self.state;
|
||||||
|
self.enter(s, $cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
#[deny(unreachable_patterns)] // conflicting rows must fail even though
|
||||||
|
// this match is external-macro-expanded
|
||||||
|
fn handle(&mut self, ev: $Ev, $cx: &mut $crate::statem::Cx<$Ev>) {
|
||||||
|
// Caller-named bindings (shared call-site hygiene, so row bodies
|
||||||
|
// can see them): `$cur` = current state tag, `$data` = &mut Data.
|
||||||
|
let $cur = self.state;
|
||||||
|
let $data = &mut self.data;
|
||||||
|
let next: $crate::statem::Resolution<$State> =
|
||||||
|
$crate::gen_statem!(@arms ($Ev) ($cur, ev) [ ]
|
||||||
|
$( on $st => { $($rows)* } )+);
|
||||||
|
match next {
|
||||||
|
$crate::statem::Resolution::To(s) if s == $cur => {}
|
||||||
|
$crate::statem::Resolution::To(s) => {
|
||||||
|
self.state = s; // <- sole writer of the state cell
|
||||||
|
self.enter(s, $cx);
|
||||||
|
}
|
||||||
|
$crate::statem::Resolution::Postpone => {
|
||||||
|
unreachable!("postpone is unreachable until chunk 3")
|
||||||
|
}
|
||||||
|
$crate::statem::Resolution::Unhandled => $cx.on_unhandled(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== @arms: build the dispatch match ==================================
|
||||||
|
// No more on-blocks: emit the (total, catch-all-free) match.
|
||||||
|
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]) => {
|
||||||
|
match ($ss, $se) { $($arms)* }
|
||||||
|
};
|
||||||
|
// Open an on-block: remember its state pat, drain its rows, then continue.
|
||||||
|
(@arms ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ]
|
||||||
|
on $st:pat => { $($rows:tt)* } $($more:tt)*
|
||||||
|
) => {
|
||||||
|
$crate::gen_statem!(@rows ($Ev) ($ss, $se) [ $($arms)* ] ($st)
|
||||||
|
{ $($rows)* } { $($more)* })
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== @rows: drain one on-block's rows, threading the global acc ========
|
||||||
|
// cast, explicit refusal
|
||||||
|
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||||
|
{ cast $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
|
||||||
|
) => {
|
||||||
|
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||||
|
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => $crate::statem::Resolution::Unhandled, ]
|
||||||
|
($st) { $($rows)* } { $($more)* })
|
||||||
|
};
|
||||||
|
// cast, transition / stay / branch
|
||||||
|
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||||
|
{ cast $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
|
||||||
|
) => {
|
||||||
|
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||||
|
[ $($arms)* ($st, $Ev::Cast($ev)) $(if $g)? => $crate::statem::Resolution::To($tail.into()), ]
|
||||||
|
($st) { $($rows)* } { $($more)* })
|
||||||
|
};
|
||||||
|
// call, explicit refusal
|
||||||
|
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||||
|
{ call $ev:pat $(if $g:expr)? => unhandled , $($rows:tt)* } { $($more:tt)* }
|
||||||
|
) => {
|
||||||
|
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||||
|
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::statem::Resolution::Unhandled, ]
|
||||||
|
($st) { $($rows)* } { $($more)* })
|
||||||
|
};
|
||||||
|
// call, transition / stay / branch
|
||||||
|
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||||
|
{ call $ev:pat $(if $g:expr)? => $tail:expr , $($rows:tt)* } { $($more:tt)* }
|
||||||
|
) => {
|
||||||
|
$crate::gen_statem!(@rows ($Ev) ($ss, $se)
|
||||||
|
[ $($arms)* ($st, $Ev::Call($ev)) $(if $g)? => $crate::statem::Resolution::To($tail.into()), ]
|
||||||
|
($st) { $($rows)* } { $($more)* })
|
||||||
|
};
|
||||||
|
// this block is drained: hand the remaining on-blocks back to @arms
|
||||||
|
(@rows ($Ev:ident) ($ss:expr, $se:expr) [ $($arms:tt)* ] ($st:pat)
|
||||||
|
{ } { $($more:tt)* }
|
||||||
|
) => {
|
||||||
|
$crate::gen_statem!(@arms ($Ev) ($ss, $se) [ $($arms)* ] $($more)*)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod gen_statem_tests {
|
||||||
|
use crate::statem::Reply;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
enum Switch {
|
||||||
|
Off,
|
||||||
|
On,
|
||||||
|
}
|
||||||
|
struct Counts {
|
||||||
|
flips: u32,
|
||||||
|
enters: u32,
|
||||||
|
}
|
||||||
|
enum Cast {
|
||||||
|
Flip,
|
||||||
|
Nudge,
|
||||||
|
}
|
||||||
|
enum Call {
|
||||||
|
GetFlips(Reply<u32>),
|
||||||
|
GetEnters(Reply<u32>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_flip(data: &mut Counts) {
|
||||||
|
data.flips += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::gen_statem! {
|
||||||
|
machine: SwitchSm { state: Switch, data: Counts };
|
||||||
|
event: Ev { cast: Cast, call: Call };
|
||||||
|
context(data, prev, cx);
|
||||||
|
|
||||||
|
enter {
|
||||||
|
_ => data.enters += 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
on Switch::Off => {
|
||||||
|
cast Cast::Flip => { on_flip(data); Switch::On },
|
||||||
|
cast Cast::Nudge => unhandled,
|
||||||
|
}
|
||||||
|
on Switch::On => {
|
||||||
|
cast Cast::Flip => { on_flip(data); Switch::Off },
|
||||||
|
cast Cast::Nudge => prev, // explicit stay
|
||||||
|
}
|
||||||
|
on _ => {
|
||||||
|
call Call::GetFlips(r) => { r.reply(data.flips); prev },
|
||||||
|
call Call::GetEnters(r) => { r.reply(data.enters); prev },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE (guarantee #2): because this machine lives in the SAME crate as
|
||||||
|
// `gen_statem!`, adding a duplicate row here — e.g. a second
|
||||||
|
// `cast Cast::Nudge => unhandled,` under `on Switch::Off` — is a hard
|
||||||
|
// `unreachable pattern` error. (From a downstream crate it is silently
|
||||||
|
// suppressed by rustc's in_external_macro rule; see the macro docs.)
|
||||||
|
#[test]
|
||||||
|
fn macro_machine_drives_and_counts() {
|
||||||
|
crate::run(|| {
|
||||||
|
let sm = SwitchSm::start(Switch::Off, Counts { flips: 0, enters: 0 });
|
||||||
|
sm.send(Ev::Cast(Cast::Flip)).unwrap(); // Off -> On (flip=1, enter)
|
||||||
|
sm.send(Ev::Cast(Cast::Nudge)).unwrap(); // On: stay (no enter)
|
||||||
|
sm.send(Ev::Cast(Cast::Flip)).unwrap(); // On -> Off (flip=2, enter)
|
||||||
|
|
||||||
|
let flips = sm.call(|r| Ev::Call(Call::GetFlips(r))).unwrap();
|
||||||
|
let enters = sm.call(|r| Ev::Call(Call::GetEnters(r))).unwrap();
|
||||||
|
assert_eq!(flips, 2);
|
||||||
|
assert_eq!(enters, 3); // Off(start) + On + Off
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user