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
+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