feat(default)!: model default(...) as a real LCA-aware transition
`default(...)` is now a transition with the same semantics as any
event-driven transition. It fires immediately after the declaring
state's entries finish and its durings start, applying the standard
exit-to-LCA / enter-to-target algorithm. Three behavioral changes
follow from this unification:
* Optional. A state with children is no longer required to declare a
`default(...)`. When omitted, the state itself is the resting
innermost active state; substates are reachable only via explicit
transitions. This is the "microwave Standby" pattern — Standby
has substates like LowPower that you enter only on demand, and
`=> Standby;` lands at Standby itself with no descent.
* Targets anywhere. The default target may be any state in the chart
— a child (the classic descent case), a sibling, an ancestor,
anywhere. `default(<sibling-or-ancestor>)` exits the just-entered
state as part of the default-fire transition; the same rule fires
whether you wrote it for descent, redirection, or up-bounce.
* Cycle-checked at compile time. The default-edge graph (out-degree
≤ 1 per node) is rejected if it contains any cycle, including
`default(self)`. `state A { default(A); }` and `A → B → A` chains
fail to compile with a pinpoint error message naming the cycle.
This guarantees default chains terminate at runtime.
Implementation:
* `validate.rs`: drop the "must be direct child" and "must have
children" restrictions; add `validate_default_graph` (iterative
three-color cycle detection via chain walk).
* `ir.rs`: keep the unresolved default ident through to
`resolve_transitions` so non-child targets resolve against the
global state-name map (the same map transition targets use).
* `codegen.rs`: precomputed `(from, to)` paths no longer fold default
descent into the enter slice — each path is LCA-relative
`(exit, enter)` ending exactly at the explicit target.
`descend_defaults` (a parent-pointer walk) is replaced by
`follow_defaults`, which fires real transitions through
`apply_path` and emits `TransitionFired{Internal}` /
`TransitionComplete` brackets per link. Up-transitions don't
trigger default-following (nothing was freshly entered, so no
state's default fires).
* `active_durings_per_innermost_state`: predicate widened to include
composites without a default (they can now be the innermost active
state, so any durings they declare must be raced when current).
BREAKING CHANGES:
* Journal format. Default-fires now emit `TransitionFired` (with
`reason: TransitionReason::Internal`) and `TransitionComplete`
brackets around their entry sequence. `Internal` was previously
reserved and never emitted; it is now active. Tests that
hand-build expected journal sequences must include these brackets
around every default-fire.
* Validation. `default(<non-child>)` and `default` on a childless
state previously raised compile-time errors. Both are now valid.
Code relying on these as guard rails must adapt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
30 files changed
+1356
-229
No files matched your search
+124
-68
@@ -100,21 +100,27 @@ type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)
|
||||
/// in `transition()` with two slice lookups and two straight slice iterations.
|
||||
///
|
||||
/// The (from, to) lookup matches the runtime LCA call: `from` is the active
|
||||
/// leaf (`self.current`), `to` is the transition target. The same three
|
||||
/// transition shapes are encoded uniformly by what the precomputed paths
|
||||
/// contain:
|
||||
/// innermost state (`self.current`), `to` is the transition target. The
|
||||
/// same three transition shapes are encoded uniformly by what the
|
||||
/// precomputed paths contain:
|
||||
///
|
||||
/// - **Up-transition** (`to` is a strict ancestor of `from`): exit_path is
|
||||
/// `from..to` (target excluded; `to` is already on the active path and
|
||||
/// must not be re-entered). enter_path is empty — `transition()` sets
|
||||
/// `self.current = Some(target)` after the loops.
|
||||
/// - **Self-transition** (`from == to`): exit_path is `[to]`. enter_path
|
||||
/// is `[to, default_child(to), default_child(default_child(to)), ...]`
|
||||
/// — re-enter target plus descend defaults.
|
||||
/// is `[to]` — re-enter the target.
|
||||
/// - **Lateral** (LCA is a proper ancestor of both): exit_path walks
|
||||
/// `from` up to but not including LCA. enter_path walks
|
||||
/// LCA-side child of `to` down to `to`, then continues descending
|
||||
/// `__DEFAULT_CHILD` from `to`.
|
||||
/// LCA-side child of `to` down to `to`.
|
||||
///
|
||||
/// Default-descent is **not** folded into these paths. After `transition()`
|
||||
/// applies the precomputed exit/enter, it follows the default chain by
|
||||
/// firing further transitions in a loop (`follow_defaults`). This is
|
||||
/// because `default(...)` may target any state in the chart — sibling,
|
||||
/// ancestor, anywhere — so each step of the chain is itself a full
|
||||
/// LCA-aware transition, not a simple child descent. The compile-time
|
||||
/// default-graph cycle check guarantees the loop terminates.
|
||||
///
|
||||
/// Storage: `__EXIT_DATA` and `__ENTER_DATA` are flat `[u16]` blobs;
|
||||
/// `__EXIT_RANGE[i*N + j]` / `__ENTER_RANGE[i*N + j]` give `(start, end)`
|
||||
@@ -126,7 +132,6 @@ type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)
|
||||
fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
let n = states.len();
|
||||
let parent = |s: u16| -> Option<u16> { states[s as usize].parent };
|
||||
let default_child = |s: u16| -> Option<u16> { states[s as usize].default_child };
|
||||
let depth = |s: u16| -> i32 { states[s as usize].depth as i32 };
|
||||
|
||||
let lca = |mut a: u16, mut b: u16| -> Option<u16> {
|
||||
@@ -171,21 +176,15 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
return (exit_path, Vec::new());
|
||||
}
|
||||
|
||||
// Self-transition: external semantics — exit and re-enter target,
|
||||
// then descend defaults.
|
||||
// Self-transition: external semantics — exit and re-enter target.
|
||||
// Default-following (if any) happens in the runtime chain loop.
|
||||
if from == to {
|
||||
let exit_path = vec![to];
|
||||
let mut enter_path = vec![to];
|
||||
let mut cur = default_child(to);
|
||||
while let Some(c) = cur {
|
||||
enter_path.push(c);
|
||||
cur = default_child(c);
|
||||
}
|
||||
return (exit_path, enter_path);
|
||||
return (vec![to], vec![to]);
|
||||
}
|
||||
|
||||
// Lateral: exit `from` up to (not including) LCA; enter from
|
||||
// LCA-child on `to`'s side down to `to`, then descend defaults.
|
||||
// LCA-child on `to`'s side down to `to`. Default-following
|
||||
// happens in the runtime chain loop.
|
||||
let lca_id = lca(from, to);
|
||||
|
||||
let mut exit_path = Vec::new();
|
||||
@@ -213,13 +212,7 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let mut enter_path: Vec<u16> = rev.into_iter().rev().collect();
|
||||
|
||||
let mut cur = default_child(to);
|
||||
while let Some(c) = cur {
|
||||
enter_path.push(c);
|
||||
cur = default_child(c);
|
||||
}
|
||||
let enter_path: Vec<u16> = rev.into_iter().rev().collect();
|
||||
|
||||
(exit_path, enter_path)
|
||||
};
|
||||
@@ -260,8 +253,8 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
(exit_data, enter_data, exit_range, enter_range)
|
||||
}
|
||||
|
||||
/// Static precompute of `do_terminate()`'s exit walk: for each leaf state,
|
||||
/// the chain `[current, parent(current), ..., __Root]` (root inclusive,
|
||||
/// Static precompute of `do_terminate()`'s exit walk: for each state, the
|
||||
/// chain `[current, parent(current), ..., __Root]` (root inclusive,
|
||||
/// since `__Root` may carry root-level `entry:`/`exit:` actions declared
|
||||
/// at the chart top level — see the `det_initial` test for that pattern).
|
||||
///
|
||||
@@ -376,17 +369,21 @@ fn compute_event_dispatch(ir: &Ir) -> Vec<(u16, Vec<u16>, Option<u16>)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// For each user-declared leaf state that has one or more `during:` activities
|
||||
/// active (either declared on it, on any ancestor, or on root), return its
|
||||
/// state id and the flattened list of durings in leaf-first order. Durings
|
||||
/// declared on the same state follow their declaration order.
|
||||
/// For each state that can be the innermost active state, return its state id
|
||||
/// and the flattened list of `during:` activities active there (declared on it
|
||||
/// or on any ancestor) in leaf-first order. Durings declared on the same state
|
||||
/// follow their declaration order.
|
||||
///
|
||||
/// Leaf states with no active durings are omitted — the run loop uses its
|
||||
/// default (channel+timer) path for those.
|
||||
fn active_durings_per_leaf(ir: &Ir) -> Vec<(u16, Vec<During>)> {
|
||||
/// A state can be innermost iff it has no children, OR it has children but no
|
||||
/// `default` child (so default-descent stops at it). Composites with a default
|
||||
/// child are always descended past at runtime, so they're skipped here.
|
||||
///
|
||||
/// Innermost-able states with no active durings are omitted — the run loop
|
||||
/// uses its default (channel+timer) path for those.
|
||||
fn active_durings_per_innermost_state(ir: &Ir) -> Vec<(u16, Vec<During>)> {
|
||||
let mut out = Vec::new();
|
||||
for s in &ir.states {
|
||||
if !s.children.is_empty() {
|
||||
if !s.children.is_empty() && s.default_child.is_some() {
|
||||
continue;
|
||||
}
|
||||
if s.parent.is_none() {
|
||||
@@ -425,14 +422,14 @@ fn emit_during_bindings(chain: &[During]) -> (Vec<Ident>, TokenStream) {
|
||||
(idents, quote! { #(#stmts)* })
|
||||
}
|
||||
|
||||
/// Emit the per-leaf match arms for the tokio run loop. Each arm constructs
|
||||
/// the active during: futures (split-borrowing context fields), then races
|
||||
/// them against the event channel and the next-timer sleep via
|
||||
/// `tokio::select!`. The default arm — used for leaves with no durings — is
|
||||
/// emitted by the caller.
|
||||
/// Emit the per-state match arms for the tokio run loop, keyed on the
|
||||
/// innermost active state id. Each arm constructs the active during: futures
|
||||
/// (split-borrowing context fields), then races them against the event channel
|
||||
/// and the next-timer sleep via `tokio::select!`. The default arm — used for
|
||||
/// states with no active durings — is emitted by the caller.
|
||||
fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let (binds, stmts) = emit_during_bindings(&chain);
|
||||
let select_arms = binds.iter().map(|b| {
|
||||
quote! { ev = #b => __HsmcRace::Event(ev), }
|
||||
@@ -462,7 +459,7 @@ fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
/// (no `events:` declared, no channel). The default arm races only the timer.
|
||||
fn emit_tokio_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let (binds, stmts) = emit_during_bindings(&chain);
|
||||
let select_arms = binds.iter().map(|b| {
|
||||
quote! { ev = #b => __HsmcRace::Event(ev), }
|
||||
@@ -639,7 +636,7 @@ fn embassy_select_for_arity(arity: usize) -> (Ident, Vec<Ident>) {
|
||||
/// compiles unchanged.
|
||||
fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let n = chain.len();
|
||||
if n + 2 > 6 {
|
||||
let msg = format!(
|
||||
@@ -703,7 +700,7 @@ fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
/// durings on a timer-only path.
|
||||
fn emit_embassy_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let n = chain.len();
|
||||
if n == 0 {
|
||||
continue; // falls through to default (just timer)
|
||||
@@ -1104,8 +1101,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
__timer_reason_str(__t_state, __t_trigger)
|
||||
);
|
||||
};
|
||||
#[allow(dead_code)]
|
||||
let _observe_transition_fired_internal = quote! {
|
||||
let observe_transition_fired_internal = quote! {
|
||||
::hsmc::__chart_observe!(
|
||||
TransitionFired,
|
||||
&mut self.__journal,
|
||||
@@ -1890,7 +1886,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
// run() bodies to decide whether to exit when no timers are pending: if
|
||||
// the current state has an active during, the run loop should keep
|
||||
// awaiting rather than break (the during might yet produce an event).
|
||||
let states_with_active_durings: Vec<u16> = active_durings_per_leaf(ir)
|
||||
let states_with_active_durings: Vec<u16> = active_durings_per_innermost_state(ir)
|
||||
.into_iter()
|
||||
.map(|(sid, _)| sid)
|
||||
.collect();
|
||||
@@ -2595,7 +2591,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
/// Returns `true` once the machine has processed its `terminate` event (§4.4).
|
||||
pub fn is_terminated(&self) -> bool { self.terminated }
|
||||
|
||||
/// Return the current leaf state. Panics if called before the
|
||||
/// Return the innermost active state. Panics if called before the
|
||||
/// first `step()` (§4.4).
|
||||
pub fn current_state(&self) -> #state_enum_name {
|
||||
__state_id_to_public(self.current.expect("machine not started; call step() first"))
|
||||
@@ -2950,12 +2946,18 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
// An empty enter slice (`m == e`) means up-transition: target
|
||||
// is already on the active path, must not be re-entered, and
|
||||
// `self.current` is set to `target` after the exit loop. A
|
||||
// non-empty enter slice ends at the deepest default-descended
|
||||
// leaf; `enter_state_no_descent` sets `self.current` as it
|
||||
// goes, so no fixup is needed on that path.
|
||||
// non-empty enter slice ends at the target itself —
|
||||
// `enter_state_no_descent` sets `self.current` as it goes.
|
||||
// Default-following runs after the path apply via
|
||||
// `follow_defaults`, which fires further transitions for any
|
||||
// chained `default(...)` declarations on the just-entered
|
||||
// state. The compile-time default-graph cycle check
|
||||
// guarantees the chain terminates.
|
||||
// Returns true if any state was newly entered (i.e., the
|
||||
// enter_path was non-empty). False on an up-transition, where
|
||||
// the target was already on the active path.
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
fn apply_path(&mut self, from: u16, target: u16) -> bool {
|
||||
let idx = (from as usize) * #n_states + (target as usize);
|
||||
let (s, m, e) = __PATH_RANGE[idx];
|
||||
let exit_path = &__PATH_DATA[s as usize..m as usize];
|
||||
@@ -2965,15 +2967,18 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
}
|
||||
for &sid in enter_path {
|
||||
self.enter_state_no_descent(sid);
|
||||
if self.terminated { return !enter_path.is_empty(); }
|
||||
}
|
||||
if enter_path.is_empty() {
|
||||
self.current = Some(target);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
async fn apply_path(&mut self, from: u16, target: u16) -> bool {
|
||||
let idx = (from as usize) * #n_states + (target as usize);
|
||||
let (s, m, e) = __PATH_RANGE[idx];
|
||||
let exit_path = &__PATH_DATA[s as usize..m as usize];
|
||||
@@ -2983,22 +2988,48 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
}
|
||||
for &sid in enter_path {
|
||||
self.enter_state_no_descent(sid).await;
|
||||
if self.terminated { return !enter_path.is_empty(); }
|
||||
}
|
||||
if enter_path.is_empty() {
|
||||
self.current = Some(target);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
let entered = self.apply_path(from, target);
|
||||
if self.terminated { return; }
|
||||
if entered {
|
||||
self.follow_defaults();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
let entered = self.apply_path(from, target).await;
|
||||
if self.terminated { return; }
|
||||
if entered {
|
||||
self.follow_defaults().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn enter_path(&mut self, root: u16) {
|
||||
self.enter_state_no_descent(root);
|
||||
self.descend_defaults(root);
|
||||
if self.terminated { return; }
|
||||
self.follow_defaults();
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn enter_path(&mut self, root: u16) {
|
||||
self.enter_state_no_descent(root).await;
|
||||
self.descend_defaults(root).await;
|
||||
if self.terminated { return; }
|
||||
self.follow_defaults().await;
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
@@ -3041,21 +3072,46 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
#observe_entered
|
||||
}
|
||||
|
||||
// After a state's entries finish (initial entry or any
|
||||
// transition), follow its `default(...)` chain by firing
|
||||
// further transitions. Each step is a full LCA-aware
|
||||
// transition because the default target may be a sibling,
|
||||
// ancestor, or anywhere else — not just a direct child. The
|
||||
// compile-time default-graph cycle check guarantees the loop
|
||||
// terminates.
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn descend_defaults(&mut self, sid: u16) {
|
||||
let mut cur = sid;
|
||||
while let Some(child) = __DEFAULT_CHILD[cur as usize] {
|
||||
self.enter_state_no_descent(child);
|
||||
cur = child;
|
||||
fn follow_defaults(&mut self) {
|
||||
while let Some(cur) = self.current {
|
||||
let target = match __DEFAULT_CHILD[cur as usize] {
|
||||
Some(t) => t,
|
||||
None => return,
|
||||
};
|
||||
#observe_transition_fired_internal
|
||||
let __t_complete_from = self.current;
|
||||
let entered = self.apply_path(cur, target);
|
||||
#observe_transition_complete
|
||||
if self.terminated { return; }
|
||||
// If the default fired an up-transition (no new state
|
||||
// entered), don't keep chaining — the ancestor we
|
||||
// landed on was already on the active path; its
|
||||
// default was not "freshly entered" and must not fire.
|
||||
if !entered { return; }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn descend_defaults(&mut self, sid: u16) {
|
||||
let mut cur = sid;
|
||||
while let Some(child) = __DEFAULT_CHILD[cur as usize] {
|
||||
self.enter_state_no_descent(child).await;
|
||||
cur = child;
|
||||
async fn follow_defaults(&mut self) {
|
||||
while let Some(cur) = self.current {
|
||||
let target = match __DEFAULT_CHILD[cur as usize] {
|
||||
Some(t) => t,
|
||||
None => return,
|
||||
};
|
||||
#observe_transition_fired_internal
|
||||
let __t_complete_from = self.current;
|
||||
let entered = self.apply_path(cur, target).await;
|
||||
#observe_transition_complete
|
||||
if self.terminated { return; }
|
||||
if !entered { return; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-6
@@ -79,7 +79,17 @@ pub struct StateIr {
|
||||
pub name: Ident,
|
||||
pub parent: Option<u16>,
|
||||
pub depth: u8,
|
||||
/// Target of this state's `default(...)` declaration, if any. Resolved
|
||||
/// in `resolve_transitions`. The target may be any state in the chart
|
||||
/// (not just a direct child) — `default(...)` is a transition that
|
||||
/// fires immediately after the declaring state's entries, using the
|
||||
/// same LCA-aware algorithm as event-driven transitions. The
|
||||
/// default-graph cycle check in `validate.rs` ensures the chain
|
||||
/// terminates.
|
||||
pub default_child: Option<u16>,
|
||||
/// Original ident from the parse tree, kept until resolution so we can
|
||||
/// emit good error spans on unknown targets / cycles.
|
||||
pub default_target_ident: Option<(Ident, Span)>,
|
||||
pub entries: Vec<u16>, // action ids
|
||||
pub exits: Vec<u16>,
|
||||
pub handlers: Vec<HandlerIr>,
|
||||
@@ -192,6 +202,7 @@ fn alloc_state(ir: &mut Ir, name: Ident, parent: Option<u16>, depth: u8, span: S
|
||||
parent,
|
||||
depth,
|
||||
default_child: None,
|
||||
default_target_ident: None,
|
||||
entries: Vec::new(),
|
||||
exits: Vec::new(),
|
||||
handlers: Vec::new(),
|
||||
@@ -451,12 +462,11 @@ fn lower_body(
|
||||
}
|
||||
ir.states[state_id as usize].children = child_ids.clone();
|
||||
|
||||
if let Some((dc_ident, _)) = &body.default_child {
|
||||
let resolved = child_ids
|
||||
.iter()
|
||||
.find(|&&cid| ir.states[cid as usize].name == *dc_ident)
|
||||
.copied();
|
||||
ir.states[state_id as usize].default_child = resolved;
|
||||
// Stash the unresolved default target. The actual id is filled in by
|
||||
// `resolve_transitions`, which has the global state-name → id map and
|
||||
// can resolve targets that aren't direct children.
|
||||
if let Some((dc_ident, dc_span)) = &body.default_child {
|
||||
ir.states[state_id as usize].default_target_ident = Some((dc_ident.clone(), *dc_span));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -547,5 +557,24 @@ pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve default-transition targets against the same global map.
|
||||
for i in 0..n {
|
||||
if let Some((ident, _span)) = ir.states[i].default_target_ident.clone() {
|
||||
let tname = ident.to_string();
|
||||
match name_map.get(&tname).copied() {
|
||||
Some(id) => {
|
||||
ir.states[i].default_child = Some(id);
|
||||
}
|
||||
None => {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
format!("unknown state `{}` in `default(...)`", ident),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+76
-34
@@ -8,6 +8,7 @@ use crate::ir::{HandlerKindIr, Ir, TriggerIr};
|
||||
use crate::parse::{StateBody, StatechartInput};
|
||||
|
||||
pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
validate_default_graph(ir)?;
|
||||
// Duplicate state names
|
||||
let mut seen = HashMap::new();
|
||||
for s in &ir.states {
|
||||
@@ -94,16 +95,11 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Children require default; default must be a direct child; no default allowed if no children.
|
||||
if !s.children.is_empty() && s.default_child.is_none() {
|
||||
return Err(syn::Error::new(
|
||||
s.span,
|
||||
format!(
|
||||
"state `{}` has children but no `default` declaration",
|
||||
s.name
|
||||
),
|
||||
));
|
||||
}
|
||||
// `default` is optional: a composite without a `default(...)` is a
|
||||
// valid resting state — transitions targeting it land on the composite
|
||||
// itself, with no descent. Substates of such a composite are reachable
|
||||
// only via explicit transitions. (`default` must still be a direct
|
||||
// child when declared; that's checked in `validate_parse_tree`.)
|
||||
|
||||
// duplicate transitions per trigger in this state, AND transition
|
||||
// target (if present) must be declared after every action handler
|
||||
@@ -184,9 +180,70 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk the default-edge graph and reject cycles. Each state has at most one
|
||||
/// outgoing default edge, so the graph has out-degree ≤ 1 and any cycle is a
|
||||
/// simple chain that loops. Detection: 3-color DFS via an iterative chain walk.
|
||||
///
|
||||
/// Cycles are rejected at compile time because at runtime, a default chain
|
||||
/// fires as a sequence of immediate transitions on entry — a cycle would loop
|
||||
/// forever, never returning control to the dispatcher.
|
||||
fn validate_default_graph(ir: &Ir) -> syn::Result<()> {
|
||||
let n = ir.states.len();
|
||||
// 0 = white (unvisited), 1 = gray (on current chain), 2 = black (done).
|
||||
let mut color = vec![0u8; n];
|
||||
for start in 0..n {
|
||||
if color[start] != 0 {
|
||||
continue;
|
||||
}
|
||||
let mut chain: Vec<usize> = Vec::new();
|
||||
let mut cur = start;
|
||||
loop {
|
||||
if color[cur] == 2 {
|
||||
// Joined a previously-cleared subgraph. Chain so far is fine.
|
||||
break;
|
||||
}
|
||||
if color[cur] == 1 {
|
||||
// Back-edge into the current chain — cycle.
|
||||
let cycle_start = chain
|
||||
.iter()
|
||||
.position(|&x| x == cur)
|
||||
.expect("gray node must be on current chain");
|
||||
let cycle: Vec<String> = chain[cycle_start..]
|
||||
.iter()
|
||||
.map(|&id| ir.states[id].name.to_string())
|
||||
.collect();
|
||||
let pretty = format!("{} -> {}", cycle.join(" -> "), cycle[0]);
|
||||
let err_span = ir.states[chain[cycle_start]]
|
||||
.default_target_ident
|
||||
.as_ref()
|
||||
.map(|(_, s)| *s)
|
||||
.unwrap_or(ir.states[chain[cycle_start]].span);
|
||||
return Err(syn::Error::new(
|
||||
err_span,
|
||||
format!(
|
||||
"default-transition cycle detected: {} \
|
||||
(entering any state in this cycle would loop forever \
|
||||
before reaching user code)",
|
||||
pretty
|
||||
),
|
||||
));
|
||||
}
|
||||
color[cur] = 1;
|
||||
chain.push(cur);
|
||||
match ir.states[cur].default_child {
|
||||
Some(next) => cur = next as usize,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
for &id in &chain {
|
||||
color[id] = 2;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Additional checks that require the original parse tree (for counting duplicate
|
||||
/// `default`/`terminate` with their spans, and the "default in childless state" /
|
||||
/// "default not a direct child" / "terminate in non-root" errors).
|
||||
/// `default`/`terminate` with their spans, and the "terminate in non-root" error).
|
||||
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
|
||||
let root_name = input.name.to_string();
|
||||
validate_body(&input.body, true, &root_name)
|
||||
@@ -217,28 +274,13 @@ fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Resu
|
||||
));
|
||||
}
|
||||
}
|
||||
if body.default_child.is_some() && body.children.is_empty() {
|
||||
return Err(syn::Error::new(
|
||||
body.default_span.unwrap(),
|
||||
format!(
|
||||
"`default` in state `{}` which has no child states",
|
||||
state_name
|
||||
),
|
||||
));
|
||||
}
|
||||
if let (Some((dc, dspan)), false) = (&body.default_child, body.children.is_empty()) {
|
||||
// default must be a direct child
|
||||
let is_child = body.children.iter().any(|c| c.name == *dc);
|
||||
if !is_child {
|
||||
return Err(syn::Error::new(
|
||||
*dspan,
|
||||
format!(
|
||||
"`default({})` in state `{}`: `{}` is not a direct child of `{}`",
|
||||
dc, state_name, dc, state_name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
// default(...) is a transition that fires immediately after the
|
||||
// declaring state's entries finish. It may be declared on any state
|
||||
// (composite or leaf) and may target any state in the chart. A leaf
|
||||
// with `default(T)` just transitions to T on every entry. The
|
||||
// default-graph cycle check in `validate_default_graph` (run from the
|
||||
// top-level `validate_parse_tree` over the lowered IR) ensures we
|
||||
// cannot construct an infinite chain at startup.
|
||||
for c in &body.children {
|
||||
let cname = c.name.to_string();
|
||||
validate_body(&c.body, false, &cname)?;
|
||||
|
||||
Reference in new issue
Block a user