//! Structural state-machine rules. Expressions describe intent; they are not executed. use crate::{ validation::{Issue, ancestor_of_kind, error, warning}, *, }; use std::collections::BTreeSet; fn ancestors(p: &Project, id: Id) -> Vec { let mut result = Vec::new(); let mut current = p.elements.get(&id).and_then(|e| e.parent); let mut seen = BTreeSet::from([id]); while let Some(id) = current { if !seen.insert(id) { break; } result.push(id); current = p.elements.get(&id).and_then(|e| e.parent); } result } fn within(p: &Project, id: Id, owner: Id) -> bool { ancestors(p, id).contains(&owner) } fn boundary(p: &Project, vertex: &Element) -> Option { vertex .parent .and_then(|region| p.elements.get(®ion)) .and_then(|region| region.parent) } fn first_region(p: &Project, vertex: Id, owner: Id) -> Option { ancestors(p, vertex).into_iter().find(|id| { p.elements .get(id) .is_some_and(|e| e.kind() == Kind::Region && e.parent == Some(owner)) }) } fn edges<'a>( p: &'a Project, candidate: Option<&'a Relation>, ) -> impl Iterator { p.relations .values() .filter(move |r| candidate.is_none_or(|c| c.id != r.id)) .chain(candidate) .filter(|r| r.kind() == RelationKind::StateTransition) } fn guard(r: &Relation) -> &str { match &r.body { RelationBody::StateTransition { guard, .. } => guard.trim(), _ => "", } } fn orthogonal(p: &Project, arms: &[Id]) -> bool { let Some(first) = arms.first() else { return true; }; if arms.len() < 2 { return true; } let Some(owner) = ancestors(p, *first).into_iter().find(|id| { p.elements .get(id) .is_some_and(|e| e.kind().is_stable_state() || e.kind() == Kind::StateMachine) && arms.iter().all(|arm| within(p, *arm, *id)) }) else { return false; }; let regions: Option> = arms .iter() .map(|arm| first_region(p, *arm, owner)) .collect(); regions.is_some_and(|regions| regions.len() == arms.len()) } pub(crate) fn validate_transition( p: &Project, r: &Relation, a: &Element, b: &Element, out: &mut Vec, ) { let RelationBody::StateTransition { transition_kind, trigger, triggers, guard, .. } = &r.body else { return; }; let machine = ancestor_of_kind(p, a.id, Kind::StateMachine); if machine.is_none() || machine != ancestor_of_kind(p, b.id, Kind::StateMachine) { error( out, r.id, "transition_machine", "endpoints", "State transition endpoints belong to one state machine.", ); } if !a.kind().is_state() || !b.kind().is_state() { return; } if a.id == b.id && !a.kind().is_stable_state() { error( out, r.id, "pseudostate_self", "endpoints", "Only a State or Mode can have a self-transition.", ); } let valid_kind = match transition_kind { TransitionKind::Internal => a.kind().is_stable_state() && a.id == b.id, TransitionKind::Local if a.kind() == Kind::EntryPoint => { boundary(p, a).is_some_and(|owner| within(p, b.id, owner)) } TransitionKind::Local => { a.kind().is_stable_state() && p.children(a.id).any(|e| e.kind() == Kind::Region) && within(p, b.id, a.id) } TransitionKind::External => a.kind() != Kind::EntryPoint, }; if !valid_kind { error( out, r.id, "transition_kind", "transition_kind", "Internal requires one stable state; Local goes from a composite or EntryPoint into its boundary; EntryPoint routing must be Local.", ); } if matches!(a.kind(), Kind::Final | Kind::Terminate) || b.kind() == Kind::Initial { error( out, r.id, "pseudostate_direction", "endpoints", "Final/Terminate have no outgoing transitions; Initial has no incoming transition.", ); } let triggered = !trigger.trim().is_empty() || !triggers.is_empty(); if !a.kind().is_stable_state() && triggered { error( out, r.id, if a.kind() == Kind::Initial { "initial_transition" } else { "pseudostate_trigger" }, "triggers", "Pseudostate routing cannot wait for an event/work trigger; put the trigger on the incoming stable-state transition.", ); } if (matches!( a.kind(), Kind::Initial | Kind::Fork | Kind::ShallowHistory | Kind::DeepHistory ) || b.kind() == Kind::Join) && (!guard.trim().is_empty() || (b.kind() == Kind::Join && triggered)) { error( out, r.id, if a.kind() == Kind::Initial { "initial_transition" } else { "pseudostate_segment" }, "guard", "Initial/history fallback and Fork output/Join input segments have no guard or trigger.", ); } if a.kind() == Kind::Fork && !b.kind().is_stable_state() || b.kind() == Kind::Join && !a.kind().is_stable_state() { error( out, r.id, "orthogonal_endpoint", "endpoints", "Fork outputs and Join inputs connect stable States/Modes in distinct orthogonal regions.", ); } if matches!(a.kind(), Kind::ShallowHistory | Kind::DeepHistory) { let scope = a.parent.is_some_and(|region| { if a.kind() == Kind::ShallowHistory { b.parent == Some(region) } else { within(p, b.id, region) } }); if !b.kind().is_stable_state() || !scope { error( out, r.id, "history_scope", "target", "Shallow history falls back to a direct stable child of its region; deep history to a stable descendant within that region.", ); } } for (point, other, outgoing) in [(a, b, true), (b, a, false)] { if !matches!(point.kind(), Kind::EntryPoint | Kind::ExitPoint) { continue; } let Some(owner) = boundary(p, point) else { continue; }; let needs_inside = (point.kind() == Kind::EntryPoint) == outgoing; if within(p, other.id, owner) != needs_inside || other.id == owner { error( out, r.id, "boundary_transition", "endpoints", "EntryPoint routes from outside to inside its owner boundary; ExitPoint routes from inside to outside it.", ); } } } pub(crate) fn validate_vertex( p: &Project, e: &Element, candidate: Option<&Relation>, out: &mut Vec, ) { if !e.kind().is_state() { return; } let incoming: Vec<_> = edges(p, candidate).filter(|r| r.target() == e.id).collect(); let outgoing: Vec<_> = edges(p, candidate).filter(|r| r.source() == e.id).collect(); let (ins, outs) = (incoming.len(), outgoing.len()); let max_in = matches!(e.kind(), Kind::Choice | Kind::Fork).then_some(1); let max_out = matches!( e.kind(), Kind::Initial | Kind::Join | Kind::ShallowHistory | Kind::DeepHistory | Kind::ExitPoint ) .then_some(1); if max_in.is_some_and(|max| ins > max) || max_out.is_some_and(|max| outs > max) { error( out, e.id, if e.kind() == Kind::Initial { "initial_transition_count" } else { "pseudostate_cardinality" }, "body", format!( "{} has too many {} transitions.", e.kind(), if max_in.is_some_and(|max| ins > max) { "incoming" } else { "outgoing" } ), ); } let incomplete = match e.kind() { Kind::Initial | Kind::ShallowHistory | Kind::DeepHistory => outs == 0, Kind::Final | Kind::Terminate => ins == 0, Kind::Choice | Kind::Fork => ins == 0 || outs < 2, Kind::Join => ins < 2 || outs == 0, Kind::Junction => ins == 0 || outs == 0, Kind::EntryPoint => outs == 0, Kind::ExitPoint => { ins == 0 || (outs == 0 && boundary(p, e) .and_then(|id| p.elements.get(&id)) .is_some_and(|e| e.kind() != Kind::StateMachine)) } _ => false, }; if incomplete { warning( out, e.id, "incomplete_state_vertex", "body", format!( "Finish the required {} incoming/outgoing routing; incomplete vertices can be saved while wiring.", e.kind() ), ); } if e.kind() == Kind::Choice { let defaults = outgoing.iter().filter(|r| guard(r) == "else").count(); if defaults > 1 { error( out, e.id, "choice_default", "guard", "Choice has at most one literal else branch.", ); } if defaults == 0 { warning( out, e.id, "missing_choice_default", "guard", "Add an else branch, or review and document why no alternative may apply.", ); } if outgoing.iter().any(|r| guard(r).is_empty()) { warning( out, e.id, "missing_choice_guard", "guard", "Each Choice alternative needs an explicit guard or literal else.", ); } } if e.kind() == Kind::Junction && outs > 1 && outgoing.iter().any(|r| guard(r).is_empty()) { warning( out, e.id, "junction_guard", "guard", "Review the unguarded Junction alternatives; guard expressions are not interpreted for disjointness.", ); } if matches!(e.kind(), Kind::Fork | Kind::Join) { let arms: Vec<_> = if e.kind() == Kind::Fork { outgoing.iter().map(|r| r.target()).collect() } else { incoming.iter().map(|r| r.source()).collect() }; if !orthogonal(p, &arms) { error( out, e.id, "orthogonal_regions", "endpoints", "Each Fork/Join arm must occupy a distinct first region below their nearest common composite or machine; deeper nesting does not create concurrency.", ); } } if e.kind() == Kind::EntryPoint && let Some(owner) = boundary(p, e) { let regions: Vec<_> = outgoing .iter() .filter_map(|r| first_region(p, r.target(), owner)) .collect(); let distinct: BTreeSet<_> = regions.iter().copied().collect(); if distinct.len() != regions.len() { error( out, e.id, "entry_point_cardinality", "target", "EntryPoint has at most one outgoing transition per destination region.", ); } if p.children(owner) .filter(|r| r.kind() == Kind::Region) .any(|r| { !distinct.contains(&r.id) && !p.children(r.id).any(|v| v.kind() == Kind::Initial) }) { warning( out, e.id, "entry_region_default", "target", "Provide an EntryPoint route or Initial default entry for every concurrent region in this boundary.", ); } } } pub(crate) fn validate_graph(p: &Project, out: &mut Vec) { for e in p.elements.values() { validate_vertex(p, e, None, out); if e.kind() == Kind::Region { for (kind, code) in [ (Kind::Initial, "multiple_initial"), (Kind::ShallowHistory, "multiple_shallow_history"), (Kind::DeepHistory, "multiple_deep_history"), ] { if p.children(e.id).filter(|c| c.kind() == kind).count() > 1 { error( out, e.id, code, "parent", format!("A region has at most one {kind}."), ); } } let explicit_entry = edges(p, None).any(|r| within(p, r.target(), e.id) && !within(p, r.source(), e.id)); if !p .children(e.id) .any(|v| matches!(v.kind(), Kind::Initial | Kind::EntryPoint)) && !explicit_entry { warning( out, e.id, "missing_initial", "parent", "Add an Initial default entry or an explicit incoming entry path for this region.", ); } } if e.kind() == Kind::StateMachine { validate_reachability(p, e.id, out); } } } fn validate_reachability(p: &Project, machine: Id, out: &mut Vec) { let mut pending: Vec<_> = p .children(machine) .filter(|e| e.kind() == Kind::Region) .flat_map(|region| p.children(region.id)) .filter(|e| matches!(e.kind(), Kind::Initial | Kind::EntryPoint)) .map(|e| e.id) .collect(); let mut reached = BTreeSet::new(); while let Some(id) = pending.pop() { if !reached.insert(id) { continue; } pending.extend( edges(p, None) .filter(|r| r.source() == id) .map(Relation::target), ); // Entering a nested vertex activates its composite ancestors. Reaching a // composite also permits each region's default entry; this deliberately // over-approximates guards/history instead of pretending to execute them. pending.extend(ancestors(p, id).into_iter().filter(|id| { p.elements .get(id) .is_some_and(|e| e.kind().is_stable_state()) })); pending.extend( p.children(id) .filter(|e| e.kind() == Kind::Region) .flat_map(|region| p.children(region.id)) .filter(|e| e.kind() == Kind::Initial) .map(|e| e.id), ); } for e in p.elements.values().filter(|e| { e.kind().is_state() && ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine) }) { if !reached.contains(&e.id) { warning( out, e.id, "unreachable_state", "body", "No structural path from a machine entry reaches this vertex; review entry, nesting and transitions (guards are not executed).", ); } } }