470 lines
15 KiB
Rust
470 lines
15 KiB
Rust
//! 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<Id> {
|
|
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<Id> {
|
|
vertex
|
|
.parent
|
|
.and_then(|region| p.elements.get(®ion))
|
|
.and_then(|region| region.parent)
|
|
}
|
|
fn first_region(p: &Project, vertex: Id, owner: Id) -> Option<Id> {
|
|
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<Item = &'a Relation> {
|
|
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<BTreeSet<_>> = 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<Issue>,
|
|
) {
|
|
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<Issue>,
|
|
) {
|
|
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<Issue>) {
|
|
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<Issue>) {
|
|
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).",
|
|
);
|
|
}
|
|
}
|
|
}
|