`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>
290 lines
12 KiB
Rust
290 lines
12 KiB
Rust
//! Compile-time validation (§6.2 / §9.9).
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use syn::Ident;
|
|
|
|
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 {
|
|
if s.name == "__Root" {
|
|
continue;
|
|
}
|
|
if let Some(_prev) = seen.insert(s.name.to_string(), s.id) {
|
|
return Err(syn::Error::new(
|
|
s.span,
|
|
format!("duplicate state name `{}`", s.name),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Resolve transitions: done already? We need post-build resolution.
|
|
// We'll do it here because we need IR mutable; but we're given &Ir.
|
|
// Instead perform target lookup inline.
|
|
let mut name_map: HashMap<String, u16> = ir
|
|
.states
|
|
.iter()
|
|
.filter(|s| s.name != "__Root")
|
|
.map(|s| (s.name.to_string(), s.id))
|
|
.collect();
|
|
// Per spec: the root state is targetable as a transition destination
|
|
// by the chart's own name.
|
|
if let Some(root) = ir.states.iter().find(|s| s.parent.is_none()) {
|
|
name_map.insert(ir.name.to_string(), root.id);
|
|
}
|
|
|
|
for s in &ir.states {
|
|
// Empty state (not root). A state with a `during:` is not empty —
|
|
// the during is a real behavior scoped to the state's lifetime.
|
|
if s.parent.is_some()
|
|
&& s.entries.is_empty()
|
|
&& s.exits.is_empty()
|
|
&& s.handlers.is_empty()
|
|
&& s.children.is_empty()
|
|
&& s.durings.is_empty()
|
|
{
|
|
return Err(syn::Error::new(s.span, format!("empty state `{}`", s.name)));
|
|
}
|
|
|
|
// During-level checks: each `during:` declares a list of root-context
|
|
// fields it borrows `&mut`. We catch the obvious overlap cases at
|
|
// macro expansion time so the user gets a clear message rather than
|
|
// a raw rustc borrow-checker error on the generated `select` call.
|
|
//
|
|
// 1. Within a single `during:`, field names must be unique.
|
|
// 2. Across durings *on the same state*, field names must be disjoint.
|
|
//
|
|
// Cross-hierarchy overlaps (parent vs. descendant) are left to rustc
|
|
// — the borrow checker reports them accurately on the `&mut ctx.field`
|
|
// call site in codegen.
|
|
for d in &s.durings {
|
|
let mut seen_in_one = std::collections::HashSet::new();
|
|
for f in &d.fields {
|
|
if !seen_in_one.insert(f.to_string()) {
|
|
return Err(syn::Error::new(
|
|
f.span(),
|
|
format!(
|
|
"field `{}` listed twice in `during: {}(...)` — each \
|
|
field can only be borrowed once per during",
|
|
f, d.fn_name
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
let mut field_owner: std::collections::HashMap<String, Ident> =
|
|
std::collections::HashMap::new();
|
|
for d in &s.durings {
|
|
for f in &d.fields {
|
|
if let Some(prev) = field_owner.insert(f.to_string(), d.fn_name.clone()) {
|
|
return Err(syn::Error::new(
|
|
f.span(),
|
|
format!(
|
|
"field `{}` is borrowed by two concurrent durings on state `{}` \
|
|
(`{}` and `{}`) — move one of them to a disjoint field or \
|
|
combine the activities into a single during that uses `select`",
|
|
f, s.name, prev, d.fn_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
|
|
// on the same trigger — so the grammar's left-to-right reading
|
|
// matches execution order (actions first, then transition).
|
|
let mut trig_txn_seen: HashMap<String, usize> = HashMap::new();
|
|
let mut trig_last_action_idx: HashMap<String, usize> = HashMap::new();
|
|
for h in &s.handlers {
|
|
let key = match &h.trigger {
|
|
TriggerIr::Event(_, i, _) => format!("ev:{}", i),
|
|
TriggerIr::Duration(id) => format!("dur:{}", id),
|
|
};
|
|
let trig_display = match &h.trigger {
|
|
TriggerIr::Event(_, i, _) => i.to_string(),
|
|
TriggerIr::Duration(id) => ir.duration_triggers[*id as usize].key.clone(),
|
|
};
|
|
let trig_span = match &h.trigger {
|
|
TriggerIr::Event(_, i, _) => i.span(),
|
|
TriggerIr::Duration(id) => ir.duration_triggers[*id as usize].span,
|
|
};
|
|
match &h.kind {
|
|
HandlerKindIr::Transition(target, _) => {
|
|
if let Some(_prev) = trig_txn_seen.insert(key.clone(), h.decl_index) {
|
|
return Err(syn::Error::new(
|
|
trig_span,
|
|
format!(
|
|
"duplicate transition on trigger `{}` in state `{}`",
|
|
trig_display, s.name
|
|
),
|
|
));
|
|
}
|
|
if !name_map.contains_key(&target.to_string()) {
|
|
return Err(syn::Error::new(
|
|
target.span(),
|
|
format!("unknown state `{}` in transition", target),
|
|
));
|
|
}
|
|
// Any action handler on the same trigger must have
|
|
// already been seen (smaller decl_index).
|
|
if let Some(&last_action_idx) = trig_last_action_idx.get(&key) {
|
|
if last_action_idx > h.decl_index {
|
|
return Err(syn::Error::new(
|
|
target.span(),
|
|
format!(
|
|
"transition to state `{}` on trigger `{}` must come after \
|
|
every action handler on the same trigger; hsmc runs \
|
|
all actions before the transition, so declaring the state \
|
|
target earlier misleads the reader",
|
|
target, trig_display
|
|
),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
HandlerKindIr::Action(_, _) => {
|
|
// If a transition on this trigger was already declared,
|
|
// this action would silently run *before* it at runtime
|
|
// — same ordering lie. Reject.
|
|
if let Some(&txn_idx) = trig_txn_seen.get(&key) {
|
|
if txn_idx < h.decl_index {
|
|
return Err(syn::Error::new(
|
|
trig_span,
|
|
format!(
|
|
"action handler on trigger `{}` must be declared before the \
|
|
state transition target; hsmc runs all actions before the \
|
|
transition, so placing actions after the target misleads \
|
|
the reader",
|
|
trig_display
|
|
),
|
|
));
|
|
}
|
|
}
|
|
trig_last_action_idx.insert(key, h.decl_index);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 "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)
|
|
}
|
|
|
|
fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Result<()> {
|
|
if body.default_count > 1 {
|
|
return Err(syn::Error::new(
|
|
body.default_span
|
|
.unwrap_or_else(proc_macro2::Span::call_site),
|
|
format!("multiple `default` declarations in state `{}`", state_name),
|
|
));
|
|
}
|
|
if body.terminate_count > 1 {
|
|
return Err(syn::Error::new(
|
|
body.terminate
|
|
.as_ref()
|
|
.map(|(_, s)| *s)
|
|
.unwrap_or_else(proc_macro2::Span::call_site),
|
|
"multiple `terminate` declarations",
|
|
));
|
|
}
|
|
if !is_root {
|
|
if let Some((_, span)) = &body.terminate {
|
|
return Err(syn::Error::new(
|
|
*span,
|
|
"`terminate` is only valid at the root level",
|
|
));
|
|
}
|
|
}
|
|
// 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)?;
|
|
}
|
|
Ok(())
|
|
}
|