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`
|
||||
// ===========================================================================
|
||||
Reference in New Issue
Block a user