initial commit: hsmc v0.2 with during: activities
Hierarchical statechart codegen for Rust with: - during: activities for state-scoped concurrent I/O - field-list borrowing (split-borrow inside the run loop) - embassy-first async with tokio support - STATE_CHART const for tooling/inspection - examples: microwave, microwave_tui, embassy_full, during_radio
This commit is contained in:
56 files changed
+11770
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,529 @@
|
||||
//! Intermediate representation built from the parsed AST.
|
||||
|
||||
use proc_macro2::Span;
|
||||
use syn::{Expr, Ident, Type};
|
||||
|
||||
use crate::parse::{
|
||||
During, EventPayload, HandlerKind, PayloadField, PayloadKind, StateBody, StatechartInput,
|
||||
Trigger,
|
||||
};
|
||||
|
||||
pub struct Ir {
|
||||
pub name: Ident,
|
||||
pub context_ty: Type,
|
||||
pub event_ty: Type,
|
||||
/// True if the user omitted `events:` (timer-only machine). In that case
|
||||
/// `event_ty` points to a synthesized empty enum and no `Sender` is emitted.
|
||||
pub events_omitted: bool,
|
||||
pub terminate_event: Option<Ident>,
|
||||
pub states: Vec<StateIr>,
|
||||
/// Action ident → signature info (trait-method index = position in vec).
|
||||
pub actions: Vec<ActionIr>,
|
||||
/// Event variants referenced anywhere, with the variant kind locked in
|
||||
/// on first use (unit / tuple / struct) for correct pattern matching in
|
||||
/// `__event_to_id`.
|
||||
pub event_variants: Vec<EventVariantIr>,
|
||||
/// Duration triggers referenced anywhere, in first-seen order.
|
||||
/// Each one gets a trigger id used by the timer table.
|
||||
pub duration_triggers: Vec<DurationTrigger>,
|
||||
}
|
||||
|
||||
pub struct ActionIr {
|
||||
pub name: Ident,
|
||||
/// Typed parameters bound from an event payload. Empty for
|
||||
/// entry/exit/timer actions or actions triggered by unit-variant events.
|
||||
pub params: Vec<PayloadField>,
|
||||
/// For param-bearing actions, every variant this action is registered
|
||||
/// with (name + kind). Used by codegen to emit the dispatch match arm.
|
||||
pub bound_variants: Vec<BoundVariant>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BoundVariant {
|
||||
pub name: Ident,
|
||||
pub kind: PayloadKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VariantKind {
|
||||
Unit,
|
||||
Tuple,
|
||||
Struct,
|
||||
}
|
||||
|
||||
pub struct EventVariantIr {
|
||||
pub name: Ident,
|
||||
pub kind: VariantKind,
|
||||
/// Number of fields in the variant (known only when the statechart
|
||||
/// declares a payload binding; `None` if seen only as unit).
|
||||
pub field_count: Option<usize>,
|
||||
}
|
||||
|
||||
pub struct DurationTrigger {
|
||||
pub id: u16,
|
||||
pub key: String,
|
||||
pub expr: Expr,
|
||||
pub repeat: bool,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
pub struct StateIr {
|
||||
pub id: u16,
|
||||
pub name: Ident,
|
||||
pub parent: Option<u16>,
|
||||
pub depth: u8,
|
||||
pub default_child: Option<u16>,
|
||||
pub entries: Vec<u16>, // action ids
|
||||
pub exits: Vec<u16>,
|
||||
pub handlers: Vec<HandlerIr>,
|
||||
pub children: Vec<u16>,
|
||||
/// Duration triggers owned by this state (trigger id).
|
||||
pub owned_timers: Vec<u16>,
|
||||
/// `during:` activities declared on this state, preserved in declaration
|
||||
/// order. The macro emits these as concurrent `&mut`-field borrows in the
|
||||
/// run loop's `select` call for any state path that includes this state.
|
||||
pub durings: Vec<During>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
pub struct HandlerIr {
|
||||
pub trigger: TriggerIr,
|
||||
pub kind: HandlerKindIr,
|
||||
pub decl_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum TriggerIr {
|
||||
/// Event trigger. Payload is `Some` when the statechart declared typed
|
||||
/// bindings at this handler site; the bindings feed the action's typed
|
||||
/// handler signature.
|
||||
Event(u16, Ident, Option<EventPayload>),
|
||||
Duration(u16),
|
||||
}
|
||||
|
||||
pub enum HandlerKindIr {
|
||||
Transition(Ident, Option<u16>),
|
||||
Action(u16, #[allow(dead_code)] Ident),
|
||||
}
|
||||
|
||||
pub fn build_ir(input: StatechartInput) -> syn::Result<Ir> {
|
||||
let name = input.name;
|
||||
let root_body = input.body;
|
||||
|
||||
let context_ty = root_body
|
||||
.context_ty
|
||||
.clone()
|
||||
.ok_or_else(|| syn::Error::new(name.span(), "statechart missing `context:` declaration"))?;
|
||||
let (event_ty, events_omitted) = match root_body.event_ty.clone() {
|
||||
Some(t) => (t, false),
|
||||
None => {
|
||||
let ty: Type = syn::parse_quote! { __NoEvents };
|
||||
(ty, true)
|
||||
}
|
||||
};
|
||||
let terminate_event = root_body.terminate.as_ref().map(|(i, _)| i.clone());
|
||||
|
||||
let mut ir = Ir {
|
||||
name,
|
||||
context_ty,
|
||||
event_ty,
|
||||
events_omitted,
|
||||
terminate_event,
|
||||
states: Vec::new(),
|
||||
actions: Vec::new(),
|
||||
event_variants: Vec::new(),
|
||||
duration_triggers: Vec::new(),
|
||||
};
|
||||
|
||||
// Two passes: collect the set of declared state names first so
|
||||
// `on(...) => Foo;` handlers can classify `Foo` as either a state
|
||||
// (transition) or a handler fn (action) based on the declared set.
|
||||
let mut state_names = std::collections::HashSet::new();
|
||||
collect_state_names(&root_body, &mut state_names);
|
||||
|
||||
let name_span = ir.name.span();
|
||||
let root_name = Ident::new("__Root", name_span);
|
||||
let root_id = alloc_state(&mut ir, root_name, None, 0, root_body.default_span.unwrap_or(name_span));
|
||||
lower_body(&mut ir, root_id, &root_body, true, &state_names)?;
|
||||
|
||||
Ok(ir)
|
||||
}
|
||||
|
||||
fn collect_state_names(
|
||||
body: &crate::parse::StateBody,
|
||||
out: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
for c in &body.children {
|
||||
out.insert(c.name.to_string());
|
||||
collect_state_names(&c.body, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn alloc_state(ir: &mut Ir, name: Ident, parent: Option<u16>, depth: u8, span: Span) -> u16 {
|
||||
let id = ir.states.len() as u16;
|
||||
ir.states.push(StateIr {
|
||||
id,
|
||||
name,
|
||||
parent,
|
||||
depth,
|
||||
default_child: None,
|
||||
entries: Vec::new(),
|
||||
exits: Vec::new(),
|
||||
handlers: Vec::new(),
|
||||
children: Vec::new(),
|
||||
owned_timers: Vec::new(),
|
||||
durings: Vec::new(),
|
||||
span,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
fn intern_action_unit(ir: &mut Ir, i: &Ident) -> syn::Result<u16> {
|
||||
if let Some(p) = ir.actions.iter().position(|x| x.name == *i) {
|
||||
if !ir.actions[p].params.is_empty() {
|
||||
return Err(syn::Error::new(
|
||||
i.span(),
|
||||
format!(
|
||||
"action `{}` was previously declared with typed event-payload \
|
||||
bindings; cannot also be used without bindings",
|
||||
i
|
||||
),
|
||||
));
|
||||
}
|
||||
return Ok(p as u16);
|
||||
}
|
||||
ir.actions.push(ActionIr {
|
||||
name: i.clone(),
|
||||
params: Vec::new(),
|
||||
bound_variants: Vec::new(),
|
||||
});
|
||||
Ok((ir.actions.len() - 1) as u16)
|
||||
}
|
||||
|
||||
fn intern_action_with_params(
|
||||
ir: &mut Ir,
|
||||
i: &Ident,
|
||||
params: &[PayloadField],
|
||||
variant: &Ident,
|
||||
variant_kind: PayloadKind,
|
||||
) -> syn::Result<u16> {
|
||||
if let Some(p) = ir.actions.iter().position(|x| x.name == *i) {
|
||||
if ir.actions[p].params.is_empty() {
|
||||
return Err(syn::Error::new(
|
||||
i.span(),
|
||||
format!(
|
||||
"action `{}` was previously declared without bindings; \
|
||||
cannot also be used with typed event-payload bindings",
|
||||
i
|
||||
),
|
||||
));
|
||||
}
|
||||
if !params_match(&ir.actions[p].params, params) {
|
||||
return Err(syn::Error::new(
|
||||
i.span(),
|
||||
format!(
|
||||
"action `{}` used with a different payload binding shape \
|
||||
than its first declaration",
|
||||
i
|
||||
),
|
||||
));
|
||||
}
|
||||
// Add this variant to bound_variants if not already present.
|
||||
let already = ir.actions[p]
|
||||
.bound_variants
|
||||
.iter()
|
||||
.any(|bv| bv.name == *variant);
|
||||
if !already {
|
||||
ir.actions[p].bound_variants.push(BoundVariant {
|
||||
name: variant.clone(),
|
||||
kind: variant_kind,
|
||||
});
|
||||
}
|
||||
return Ok(p as u16);
|
||||
}
|
||||
ir.actions.push(ActionIr {
|
||||
name: i.clone(),
|
||||
params: params.to_vec(),
|
||||
bound_variants: vec![BoundVariant {
|
||||
name: variant.clone(),
|
||||
kind: variant_kind,
|
||||
}],
|
||||
});
|
||||
Ok((ir.actions.len() - 1) as u16)
|
||||
}
|
||||
|
||||
fn params_match(a: &[PayloadField], b: &[PayloadField]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b.iter()).all(|(x, y)| {
|
||||
x.name == y.name && {
|
||||
let xs = {
|
||||
let t = &x.ty;
|
||||
quote::quote! { #t }.to_string()
|
||||
};
|
||||
let ys = {
|
||||
let t = &y.ty;
|
||||
quote::quote! { #t }.to_string()
|
||||
};
|
||||
xs == ys
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn intern_event(
|
||||
ir: &mut Ir,
|
||||
i: &Ident,
|
||||
payload: Option<&EventPayload>,
|
||||
) -> syn::Result<u16> {
|
||||
let kind = match payload {
|
||||
None => VariantKind::Unit,
|
||||
Some(p) => match p.kind {
|
||||
PayloadKind::Tuple => VariantKind::Tuple,
|
||||
PayloadKind::Struct => VariantKind::Struct,
|
||||
},
|
||||
};
|
||||
let field_count = payload.map(|p| p.fields.len());
|
||||
|
||||
if let Some(p) = ir.event_variants.iter().position(|x| x.name == *i) {
|
||||
let existing = &mut ir.event_variants[p];
|
||||
match (existing.kind, kind) {
|
||||
(VariantKind::Unit, VariantKind::Unit) => {}
|
||||
// First saw it as Unit, now a typed binding appears — upgrade
|
||||
// the variant's kind.
|
||||
(VariantKind::Unit, k) => {
|
||||
existing.kind = k;
|
||||
existing.field_count = field_count;
|
||||
}
|
||||
// Already knew it was tuple/struct, and this use is also typed.
|
||||
// Shape must match.
|
||||
(a, b) if a == b => {
|
||||
if let (Some(x), Some(y)) = (existing.field_count, field_count) {
|
||||
if x != y {
|
||||
return Err(syn::Error::new(
|
||||
i.span(),
|
||||
format!(
|
||||
"event variant `{}` is used with inconsistent payload field counts ({} vs {})",
|
||||
i, x, y
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Already knew it was tuple/struct, this use is un-bound. Fine:
|
||||
// the user is matching the variant without extracting payload
|
||||
// (e.g. a transition target). The recorded kind controls the
|
||||
// match pattern in `__event_to_id`; leaving it as-is still
|
||||
// matches this use.
|
||||
(_, VariantKind::Unit) => {}
|
||||
// Conflict: tuple vs struct binding shape.
|
||||
(a, b) => {
|
||||
return Err(syn::Error::new(
|
||||
i.span(),
|
||||
format!(
|
||||
"event variant `{}` is used with inconsistent kinds ({} vs {})",
|
||||
i,
|
||||
variant_kind_name(a),
|
||||
variant_kind_name(b),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(p as u16);
|
||||
}
|
||||
|
||||
ir.event_variants.push(EventVariantIr {
|
||||
name: i.clone(),
|
||||
kind,
|
||||
field_count,
|
||||
});
|
||||
Ok((ir.event_variants.len() - 1) as u16)
|
||||
}
|
||||
|
||||
fn variant_kind_name(k: VariantKind) -> &'static str {
|
||||
match k {
|
||||
VariantKind::Unit => "unit",
|
||||
VariantKind::Tuple => "tuple",
|
||||
VariantKind::Struct => "struct",
|
||||
}
|
||||
}
|
||||
|
||||
fn intern_duration(
|
||||
ir: &mut Ir,
|
||||
expr: &Expr,
|
||||
key: &str,
|
||||
repeat: bool,
|
||||
span: Span,
|
||||
) -> u16 {
|
||||
let full_key = format!("{}:{}", key, repeat);
|
||||
if let Some(dt) = ir.duration_triggers.iter().find(|d| d.key == full_key) {
|
||||
return dt.id;
|
||||
}
|
||||
let id = ir.duration_triggers.len() as u16;
|
||||
ir.duration_triggers.push(DurationTrigger {
|
||||
id,
|
||||
key: full_key,
|
||||
expr: expr.clone(),
|
||||
repeat,
|
||||
span,
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
fn lower_body(
|
||||
ir: &mut Ir,
|
||||
state_id: u16,
|
||||
body: &StateBody,
|
||||
_is_root: bool,
|
||||
state_names: &std::collections::HashSet<String>,
|
||||
) -> syn::Result<()> {
|
||||
let mut entries: Vec<u16> = Vec::new();
|
||||
for i in &body.entries {
|
||||
entries.push(intern_action_unit(ir, i)?);
|
||||
}
|
||||
let mut exits: Vec<u16> = Vec::new();
|
||||
for i in &body.exits {
|
||||
exits.push(intern_action_unit(ir, i)?);
|
||||
}
|
||||
ir.states[state_id as usize].entries = entries;
|
||||
ir.states[state_id as usize].exits = exits;
|
||||
|
||||
let mut new_handlers = Vec::new();
|
||||
for h in &body.handlers {
|
||||
let trig = match &h.trigger {
|
||||
Trigger::Event { variant, payload } => {
|
||||
let id = intern_event(ir, variant, payload.as_ref())?;
|
||||
TriggerIr::Event(id, variant.clone(), payload.clone())
|
||||
}
|
||||
Trigger::Duration { expr, key, span, repeat } => {
|
||||
let id = intern_duration(ir, expr, key, *repeat, *span);
|
||||
let owned = &mut ir.states[state_id as usize].owned_timers;
|
||||
if !owned.contains(&id) {
|
||||
owned.push(id);
|
||||
}
|
||||
TriggerIr::Duration(id)
|
||||
}
|
||||
};
|
||||
let kind = match &h.kind {
|
||||
HandlerKind::Target(target) => classify_target(ir, target, &trig, state_names)?,
|
||||
};
|
||||
new_handlers.push(HandlerIr {
|
||||
trigger: trig,
|
||||
kind,
|
||||
decl_index: h.decl_index,
|
||||
});
|
||||
}
|
||||
ir.states[state_id as usize].handlers = new_handlers;
|
||||
|
||||
// `during:` declarations are stored verbatim for codegen. Field-to-struct
|
||||
// validation happens in codegen (we don't have access to the context
|
||||
// struct's fields here) via the natural borrow-checker error on the
|
||||
// generated call site — validate.rs additionally catches the obvious
|
||||
// cases earlier.
|
||||
ir.states[state_id as usize].durings = body.durings.clone();
|
||||
|
||||
let parent_depth = ir.states[state_id as usize].depth;
|
||||
let mut child_ids = Vec::new();
|
||||
for c in &body.children {
|
||||
let cid = alloc_state(ir, c.name.clone(), Some(state_id), parent_depth + 1, c.span);
|
||||
child_ids.push(cid);
|
||||
lower_body(ir, cid, &c.body, false, state_names)?;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decide whether `on(trigger) => target;` is a transition (target is a
|
||||
/// declared state) or an action (target is a handler fn). Also enforce the
|
||||
/// rules that only apply to one form or the other.
|
||||
fn classify_target(
|
||||
ir: &mut Ir,
|
||||
target: &Ident,
|
||||
trig: &TriggerIr,
|
||||
state_names: &std::collections::HashSet<String>,
|
||||
) -> syn::Result<HandlerKindIr> {
|
||||
let tname = target.to_string();
|
||||
if state_names.contains(&tname) {
|
||||
// Transition. A repeating timer transitioning is nonsensical (the
|
||||
// state exits on first fire and cancels the timer). Payload
|
||||
// bindings can't feed a target state — they go to action args.
|
||||
if let TriggerIr::Duration(tid) = trig {
|
||||
if ir.duration_triggers[*tid as usize].repeat {
|
||||
return Err(syn::Error::new(
|
||||
target.span(),
|
||||
"`every` timers are only valid on action handlers; \
|
||||
transitioning to a state cancels the timer on first fire",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let TriggerIr::Event(_, variant, Some(_)) = trig {
|
||||
return Err(syn::Error::new(
|
||||
variant.span(),
|
||||
"event payload bindings are only valid when dispatching to an \
|
||||
action handler; a transition target takes no arguments",
|
||||
));
|
||||
}
|
||||
// State-id resolution happens later in `resolve_transitions`.
|
||||
Ok(HandlerKindIr::Transition(target.clone(), None))
|
||||
} else {
|
||||
// Action handler.
|
||||
let aid = match trig {
|
||||
TriggerIr::Event(_, variant, Some(payload)) => intern_action_with_params(
|
||||
ir,
|
||||
target,
|
||||
&payload.fields,
|
||||
variant,
|
||||
payload.kind,
|
||||
)?,
|
||||
_ => intern_action_unit(ir, target)?,
|
||||
};
|
||||
Ok(HandlerKindIr::Action(aid, target.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve transition targets from state idents to state ids after the tree is built.
|
||||
pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> {
|
||||
let mut name_map = std::collections::HashMap::new();
|
||||
for s in &ir.states {
|
||||
if s.name != "__Root" {
|
||||
if name_map.insert(s.name.to_string(), s.id).is_some() {}
|
||||
}
|
||||
}
|
||||
|
||||
let n = ir.states.len();
|
||||
for i in 0..n {
|
||||
let len = ir.states[i].handlers.len();
|
||||
for h in 0..len {
|
||||
if let HandlerKindIr::Transition(target, slot) = &ir.states[i].handlers[h].kind {
|
||||
if slot.is_some() {
|
||||
continue;
|
||||
}
|
||||
let tname = target.to_string();
|
||||
let resolved = name_map.get(&tname).copied();
|
||||
let target_clone = target.clone();
|
||||
match resolved {
|
||||
Some(id) => {
|
||||
ir.states[i].handlers[h].kind =
|
||||
HandlerKindIr::Transition(target_clone, Some(id));
|
||||
}
|
||||
None => {
|
||||
return Err(syn::Error::new(
|
||||
target.span(),
|
||||
format!("unknown state `{}` in transition", target),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Proc-macro backing for the `hsmc` crate. The sole public item is the
|
||||
//! [`statechart!`] macro which generates a state machine from a declarative
|
||||
//! description.
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
|
||||
mod parse;
|
||||
mod ir;
|
||||
mod validate;
|
||||
mod codegen;
|
||||
|
||||
/// See the `hsmc` crate for documentation.
|
||||
#[proc_macro]
|
||||
pub fn statechart(input: TokenStream) -> TokenStream {
|
||||
let input = proc_macro2::TokenStream::from(input);
|
||||
match statechart_impl(input) {
|
||||
Ok(ts) => ts.into(),
|
||||
Err(e) => e.to_compile_error().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn statechart_impl(input: proc_macro2::TokenStream) -> syn::Result<proc_macro2::TokenStream> {
|
||||
let parsed = syn::parse2::<parse::StatechartInput>(input)?;
|
||||
validate::validate_parse_tree(&parsed)?;
|
||||
let mut ir = ir::build_ir(parsed)?;
|
||||
ir::resolve_transitions(&mut ir)?;
|
||||
validate::validate(&ir)?;
|
||||
Ok(codegen::generate(&ir))
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Parsing for the `statechart!` grammar.
|
||||
|
||||
use proc_macro2::Span;
|
||||
use syn::parse::{Parse, ParseStream};
|
||||
use syn::{braced, parenthesized, token, Expr, Ident, Token, Type};
|
||||
|
||||
pub struct StatechartInput {
|
||||
pub name: Ident,
|
||||
pub body: StateBody,
|
||||
}
|
||||
|
||||
pub struct StateBody {
|
||||
/// Only present at root.
|
||||
pub context_ty: Option<Type>,
|
||||
pub event_ty: Option<Type>,
|
||||
pub terminate: Option<(Ident, Span)>,
|
||||
pub default_child: Option<(Ident, Span)>,
|
||||
pub default_span: Option<Span>, // to detect duplicates
|
||||
pub default_count: usize,
|
||||
pub terminate_count: usize,
|
||||
pub entries: Vec<Ident>,
|
||||
pub exits: Vec<Ident>,
|
||||
pub handlers: Vec<Handler>,
|
||||
pub children: Vec<StateDecl>,
|
||||
/// `during: fn_name(field_a, field_b);` declarations on this state. Each
|
||||
/// `During` names a free async function and the root-context fields it
|
||||
/// needs `&mut` access to. Field non-overlap is enforced by Rust's native
|
||||
/// split borrow on the generated `select` call site; the macro emits a
|
||||
/// clearer error when it can detect overlap at expansion time.
|
||||
pub durings: Vec<During>,
|
||||
}
|
||||
|
||||
impl StateBody {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
context_ty: None,
|
||||
event_ty: None,
|
||||
terminate: None,
|
||||
default_child: None,
|
||||
default_span: None,
|
||||
default_count: 0,
|
||||
terminate_count: 0,
|
||||
entries: Vec::new(),
|
||||
exits: Vec::new(),
|
||||
handlers: Vec::new(),
|
||||
children: Vec::new(),
|
||||
durings: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `during: <fn_ident> ( <field_ident>, ... ) ;` — an async activity scoped to
|
||||
/// a state. Runs concurrently with other durings on the active state path,
|
||||
/// the external event channel, and the next timer deadline. Dropped whenever
|
||||
/// any other branch of the run-loop's `select` wins or the state transitions.
|
||||
#[derive(Clone)]
|
||||
pub struct During {
|
||||
/// Name of the free async function the macro will call.
|
||||
pub fn_name: Ident,
|
||||
/// Fields of the root context struct the function borrows `&mut`.
|
||||
/// An empty list means the function takes no arguments.
|
||||
pub fields: Vec<Ident>,
|
||||
/// Span of the `during:` keyword for diagnostic messages. Currently
|
||||
/// unused by codegen (per-field spans come from the ident list) but
|
||||
/// kept for future clearer diagnostics.
|
||||
#[allow(dead_code)]
|
||||
pub kw_span: Span,
|
||||
}
|
||||
|
||||
pub struct StateDecl {
|
||||
pub name: Ident,
|
||||
pub body: StateBody,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Handler {
|
||||
pub trigger: Trigger,
|
||||
pub kind: HandlerKind,
|
||||
pub decl_index: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum HandlerKind {
|
||||
/// Unresolved handler target: could be the name of a declared state
|
||||
/// (→ transition) or the name of an action handler function (→ internal
|
||||
/// transition). Classified during IR build once all state names are
|
||||
/// known.
|
||||
Target(Ident),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Trigger {
|
||||
Event {
|
||||
variant: Ident,
|
||||
payload: Option<EventPayload>,
|
||||
},
|
||||
/// An arbitrary expression evaluating to `core::time::Duration`, together
|
||||
/// with a human-readable key for dedup/diagnostics. `repeat` means the
|
||||
/// timer re-arms after firing; only valid on action handlers.
|
||||
Duration {
|
||||
expr: Expr,
|
||||
key: String,
|
||||
span: Span,
|
||||
repeat: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Typed payload bindings attached to an event-variant trigger.
|
||||
/// e.g. `action(PacketRx(rssi: i16, snr: i16)) => h;` parses to
|
||||
/// `EventPayload { kind: Tuple, fields: [(rssi, i16), (snr, i16)] }`.
|
||||
#[derive(Clone)]
|
||||
pub struct EventPayload {
|
||||
pub kind: PayloadKind,
|
||||
pub fields: Vec<PayloadField>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PayloadKind {
|
||||
Tuple,
|
||||
Struct,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PayloadField {
|
||||
pub name: Ident,
|
||||
pub ty: Type,
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
#[allow(dead_code)]
|
||||
pub fn key(&self) -> String {
|
||||
match self {
|
||||
Trigger::Event { variant, .. } => format!("event:{}", variant),
|
||||
Trigger::Duration { key, repeat, .. } => format!("dur:{}:{}", key, repeat),
|
||||
}
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn span(&self) -> Span {
|
||||
match self {
|
||||
Trigger::Event { variant, .. } => variant.span(),
|
||||
Trigger::Duration { span, .. } => *span,
|
||||
}
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
pub fn display(&self) -> String {
|
||||
match self {
|
||||
Trigger::Event { variant, .. } => variant.to_string(),
|
||||
Trigger::Duration { key, .. } => key.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Parse for StatechartInput {
|
||||
fn parse(input: ParseStream) -> syn::Result<Self> {
|
||||
let name: Ident = input.parse()?;
|
||||
let content;
|
||||
braced!(content in input);
|
||||
let body = parse_body(&content, true)?;
|
||||
Ok(StatechartInput { name, body })
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_body(input: ParseStream, is_root: bool) -> syn::Result<StateBody> {
|
||||
let mut body = StateBody::new();
|
||||
let mut decl_index: usize = 0;
|
||||
while !input.is_empty() {
|
||||
let lookahead = input.lookahead1();
|
||||
if lookahead.peek(Ident) {
|
||||
let ident: Ident = input.fork().parse()?;
|
||||
let name = ident.to_string();
|
||||
match name.as_str() {
|
||||
"context" => {
|
||||
let _: Ident = input.parse()?;
|
||||
input.parse::<Token![:]>()?;
|
||||
let ty: Type = input.parse()?;
|
||||
input.parse::<Token![;]>()?;
|
||||
if !is_root {
|
||||
return Err(syn::Error::new(ident.span(), "`context:` is only valid at root"));
|
||||
}
|
||||
body.context_ty = Some(ty);
|
||||
}
|
||||
"events" => {
|
||||
let _: Ident = input.parse()?;
|
||||
input.parse::<Token![:]>()?;
|
||||
let ty: Type = input.parse()?;
|
||||
input.parse::<Token![;]>()?;
|
||||
if !is_root {
|
||||
return Err(syn::Error::new(ident.span(), "`events:` is only valid at root"));
|
||||
}
|
||||
body.event_ty = Some(ty);
|
||||
}
|
||||
"entry" => {
|
||||
let _: Ident = input.parse()?;
|
||||
input.parse::<Token![:]>()?;
|
||||
let list = parse_ident_list(input)?;
|
||||
input.parse::<Token![;]>()?;
|
||||
body.entries.extend(list);
|
||||
}
|
||||
"exit" => {
|
||||
let _: Ident = input.parse()?;
|
||||
input.parse::<Token![:]>()?;
|
||||
let list = parse_ident_list(input)?;
|
||||
input.parse::<Token![;]>()?;
|
||||
body.exits.extend(list);
|
||||
}
|
||||
"default" => {
|
||||
let kw: Ident = input.parse()?;
|
||||
let inner;
|
||||
parenthesized!(inner in input);
|
||||
let child: Ident = inner.parse()?;
|
||||
input.parse::<Token![;]>()?;
|
||||
body.default_count += 1;
|
||||
if body.default_child.is_none() {
|
||||
body.default_child = Some((child, kw.span()));
|
||||
body.default_span = Some(kw.span());
|
||||
}
|
||||
}
|
||||
"terminate" => {
|
||||
let kw: Ident = input.parse()?;
|
||||
let inner;
|
||||
parenthesized!(inner in input);
|
||||
let ev: Ident = inner.parse()?;
|
||||
input.parse::<Token![;]>()?;
|
||||
body.terminate_count += 1;
|
||||
if body.terminate.is_none() {
|
||||
body.terminate = Some((ev, kw.span()));
|
||||
}
|
||||
}
|
||||
"on" => {
|
||||
// Unified handler declaration. The RHS may be a single
|
||||
// target or a comma-separated list — every entry in the
|
||||
// list fires atomically in declaration order. Targets
|
||||
// disambiguate at IR-build time: ident names a declared
|
||||
// state → transition; otherwise → handler fn.
|
||||
let _: Ident = input.parse()?;
|
||||
let inner;
|
||||
parenthesized!(inner in input);
|
||||
let trigger = parse_trigger(&inner)?;
|
||||
input.parse::<Token![=>]>()?;
|
||||
let targets = parse_ident_list(input)?;
|
||||
input.parse::<Token![;]>()?;
|
||||
for target in targets {
|
||||
body.handlers.push(Handler {
|
||||
trigger: trigger.clone(),
|
||||
kind: HandlerKind::Target(target),
|
||||
decl_index,
|
||||
});
|
||||
decl_index += 1;
|
||||
}
|
||||
}
|
||||
"during" => {
|
||||
// `during: fn_name ( field_a, field_b, ... ) ;` or
|
||||
// `during: fn_name ;` (zero-arg). Fields name struct
|
||||
// members of the root context; the macro emits
|
||||
// `fn_name(&mut ctx.field_a, &mut ctx.field_b)` at the
|
||||
// call site and Rust's split borrow verifies no overlap.
|
||||
let kw: Ident = input.parse()?;
|
||||
input.parse::<Token![:]>()?;
|
||||
let fn_name: Ident = input.parse()?;
|
||||
let fields = if input.peek(token::Paren) {
|
||||
let inner;
|
||||
parenthesized!(inner in input);
|
||||
let mut fields = Vec::new();
|
||||
while !inner.is_empty() {
|
||||
fields.push(inner.parse::<Ident>()?);
|
||||
if inner.peek(Token![,]) {
|
||||
inner.parse::<Token![,]>()?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !inner.is_empty() {
|
||||
return Err(inner.error("unexpected tokens after field list"));
|
||||
}
|
||||
fields
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
input.parse::<Token![;]>()?;
|
||||
body.durings.push(During {
|
||||
fn_name,
|
||||
fields,
|
||||
kw_span: kw.span(),
|
||||
});
|
||||
}
|
||||
"state" => {
|
||||
let _: Ident = input.parse()?;
|
||||
let sname: Ident = input.parse()?;
|
||||
let content;
|
||||
braced!(content in input);
|
||||
let cbody = parse_body(&content, false)?;
|
||||
body.children.push(StateDecl { name: sname.clone(), body: cbody, span: sname.span() });
|
||||
}
|
||||
other => {
|
||||
return Err(syn::Error::new(ident.span(), format!("unknown item `{}` in statechart body", other)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(lookahead.error());
|
||||
}
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn parse_ident_list(input: ParseStream) -> syn::Result<Vec<Ident>> {
|
||||
let mut v = Vec::new();
|
||||
v.push(input.parse()?);
|
||||
while input.peek(Token![,]) {
|
||||
input.parse::<Token![,]>()?;
|
||||
v.push(input.parse()?);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn parse_trigger(input: ParseStream) -> syn::Result<Trigger> {
|
||||
// Duration triggers must be prefixed with an explicit mode keyword:
|
||||
// `every <dur>` — repeating timer
|
||||
// `after <dur>` — one-shot timer
|
||||
//
|
||||
// Everything else is an event trigger:
|
||||
// `Variant` — unit
|
||||
// `Variant { a: T, b: U }` — struct-style payload binding
|
||||
// `Variant(a: T, b: U)` — tuple-style payload binding
|
||||
if input.peek(Ident) {
|
||||
let fork = input.fork();
|
||||
if let Ok(id) = fork.parse::<Ident>() {
|
||||
if id == "every" {
|
||||
let _: Ident = input.parse()?;
|
||||
return parse_duration_rest(input, true);
|
||||
}
|
||||
if id == "after" {
|
||||
let _: Ident = input.parse()?;
|
||||
return parse_duration_rest(input, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !input.peek(Ident) || input.peek2(Token![::]) {
|
||||
return Err(input.error(
|
||||
"expected an event variant (`Name`, `Name(..)`, `Name { .. }`) or a \
|
||||
duration trigger prefixed with `after` or `every`",
|
||||
));
|
||||
}
|
||||
|
||||
let variant: Ident = input.parse()?;
|
||||
|
||||
if input.is_empty() {
|
||||
return Ok(Trigger::Event {
|
||||
variant,
|
||||
payload: None,
|
||||
});
|
||||
}
|
||||
|
||||
if input.peek(token::Brace) {
|
||||
let inner;
|
||||
braced!(inner in input);
|
||||
let fields = parse_typed_fields(&inner)?;
|
||||
return Ok(Trigger::Event {
|
||||
variant,
|
||||
payload: Some(EventPayload {
|
||||
kind: PayloadKind::Struct,
|
||||
fields,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if input.peek(token::Paren) {
|
||||
let inner;
|
||||
parenthesized!(inner in input);
|
||||
let fields = parse_typed_fields(&inner)?;
|
||||
return Ok(Trigger::Event {
|
||||
variant,
|
||||
payload: Some(EventPayload {
|
||||
kind: PayloadKind::Tuple,
|
||||
fields,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Err(input.error(
|
||||
"unexpected token after event variant name; \
|
||||
expected end of trigger, `(..)`, or `{ .. }`",
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_typed_fields(input: ParseStream) -> syn::Result<Vec<PayloadField>> {
|
||||
let mut fields = Vec::new();
|
||||
while !input.is_empty() {
|
||||
let name: Ident = input.parse()?;
|
||||
input.parse::<Token![:]>()?;
|
||||
let ty: Type = input.parse()?;
|
||||
fields.push(PayloadField { name, ty });
|
||||
if input.peek(Token![,]) {
|
||||
input.parse::<Token![,]>()?;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !input.is_empty() {
|
||||
return Err(input.error("unexpected tokens after typed payload bindings"));
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
fn parse_duration_rest(input: ParseStream, repeat: bool) -> syn::Result<Trigger> {
|
||||
let expr: Expr = input.parse()?;
|
||||
let span = syn::spanned::Spanned::span(&expr);
|
||||
// Bare numeric literals are rejected: readers shouldn't have to remember
|
||||
// what unit a lone integer or float means. Always require an explicit
|
||||
// `core::time::Duration` expression (or the re-exported `hsmc::Duration`).
|
||||
if let Expr::Lit(lit) = &expr {
|
||||
match &lit.lit {
|
||||
syn::Lit::Int(_) | syn::Lit::Float(_) => {
|
||||
return Err(syn::Error::new(
|
||||
span,
|
||||
"bare numeric literals are not accepted as durations; \
|
||||
use a typed expression like `Duration::from_secs(5)` or \
|
||||
`Duration::from_millis(250)`",
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let key = expr_to_string(&expr);
|
||||
Ok(Trigger::Duration { expr, key, span, repeat })
|
||||
}
|
||||
|
||||
fn expr_to_string(e: &Expr) -> String {
|
||||
// Token-stream-based canonical form. Good enough for dedup keying.
|
||||
let ts = quote::quote! { #e };
|
||||
ts.to_string().split_whitespace().collect()
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! 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<()> {
|
||||
// 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 name_map: HashMap<String, u16> = ir
|
||||
.states
|
||||
.iter()
|
||||
.filter(|s| s.name != "__Root")
|
||||
.map(|s| (s.name.to_string(), s.id))
|
||||
.collect();
|
||||
|
||||
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
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
));
|
||||
}
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
/// 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).
|
||||
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"));
|
||||
}
|
||||
}
|
||||
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
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
for c in &body.children {
|
||||
let cname = c.name.to_string();
|
||||
validate_body(&c.body, false, &cname)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user