Skip to main content

vertep_core/
validate.rs

1//! Well-formedness validation and initial-state resolution.
2//!
3//! [`validate`] checks every rule and returns all violations together, each naming the offending
4//! node; a chart that passes can be compiled with [`crate::compile::CompiledChart::new`] and run.
5
6use alloc::collections::BTreeMap;
7use alloc::format;
8use alloc::string::{String, ToString};
9use alloc::vec::Vec;
10
11use crate::ir::{Block, Chart, Exec, ExecKind, Param, Span, State, StateElement, StateKind};
12
13/// Where a resolved default entry came from.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum InitialSource {
16    /// The `initial` attribute.
17    Attribute,
18    /// The transition of the `<initial>` element.
19    Element,
20    /// The first child state in document order.
21    FirstChild,
22    /// The state has no default entry (it is not compound, or has no child states).
23    None,
24}
25
26/// The resolved default entry of a compound state or of the root.
27#[derive(Clone, Debug, PartialEq)]
28pub struct ResolvedInitial<'a> {
29    /// The identifiers of the default entry targets, in document order of the source.
30    pub targets: Vec<&'a str>,
31    /// Where the targets came from.
32    pub source: InitialSource,
33    /// The executable content of the `<initial>` transition, if it came from an element.
34    pub body: Option<&'a Block>,
35}
36
37/// Resolve the default entry of a state: the `initial` attribute, else the `<initial>` element's
38/// transition, else the first child state (not a history pseudo-state) in document order.
39// @tg: implements:: [[openspec:chart-model#Initial State Resolution]]
40pub fn resolve_initial(state: &State) -> ResolvedInitial<'_> {
41    if state.kind() != StateKind::Compound {
42        return ResolvedInitial {
43            targets: Vec::new(),
44            source: InitialSource::None,
45            body: None,
46        };
47    }
48    if !state.initial.is_empty() {
49        return ResolvedInitial {
50            targets: state.initial.iter().map(String::as_str).collect(),
51            source: InitialSource::Attribute,
52            body: None,
53        };
54    }
55    if let Some(init) = &state.initial_element {
56        return ResolvedInitial {
57            targets: init.transition.targets.iter().map(String::as_str).collect(),
58            source: InitialSource::Element,
59            body: Some(&init.transition.body),
60        };
61    }
62    first_child(&state.children)
63}
64
65/// Resolve the default entry of the root: its `initial` attribute, else its first child state.
66pub fn resolve_root_initial(chart: &Chart) -> ResolvedInitial<'_> {
67    if !chart.initial.is_empty() {
68        return ResolvedInitial {
69            targets: chart.initial.iter().map(String::as_str).collect(),
70            source: InitialSource::Attribute,
71            body: None,
72        };
73    }
74    first_child(&chart.states)
75}
76
77fn first_child(children: &[State]) -> ResolvedInitial<'_> {
78    match children
79        .iter()
80        .find(|c| !matches!(c.element, StateElement::History(_)))
81    {
82        Some(c) => ResolvedInitial {
83            targets: alloc::vec![c.id.as_str()],
84            source: InitialSource::FirstChild,
85            body: None,
86        },
87        None => ResolvedInitial {
88            targets: Vec::new(),
89            source: InitialSource::None,
90            body: None,
91        },
92    }
93}
94
95/// The node a violation is about.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub enum NodeRef {
98    /// The `<scxml>` root.
99    Root,
100    /// A state, by identifier (possibly empty when the identifier is missing).
101    State {
102        /// The state identifier.
103        id: String,
104    },
105    /// A transition, by source state and index in its transition list.
106    Transition {
107        /// The source state identifier.
108        source: String,
109        /// Index in `State::transitions`.
110        index: usize,
111    },
112    /// The `initial` attribute or `<initial>` element of a state.
113    Initial {
114        /// The owning state identifier.
115        state: String,
116    },
117    /// A data item, by identifier.
118    Data {
119        /// The data identifier.
120        id: String,
121    },
122    /// An invoke, by owning state and index.
123    Invoke {
124        /// The owning state identifier.
125        state: String,
126        /// Index in `State::invokes`.
127        index: usize,
128    },
129    /// An executable content element inside a state (its handlers, transitions or invokes).
130    Exec {
131        /// The owning state identifier.
132        state: String,
133    },
134}
135
136/// What is wrong.
137#[derive(Clone, Debug, PartialEq, Eq)]
138pub enum ViolationKind {
139    /// The chart has no child states.
140    EmptyChart,
141    /// A state has no identifier (run [`crate::identity::assign_generated_ids`] first).
142    MissingId,
143    /// Two states share an identifier.
144    DuplicateStateId {
145        /// The repeated identifier.
146        id: String,
147    },
148    /// A target names no state.
149    UnknownTarget {
150        /// The unknown identifier.
151        target: String,
152    },
153    /// An `initial` attribute or `<initial>` transition targets a state that is not a proper
154    /// descendant of the owning state.
155    IllegalInitialTarget {
156        /// The offending target.
157        target: String,
158    },
159    /// A state has both an `initial` attribute and an `<initial>` element.
160    InitialAttributeAndElement,
161    /// An `initial` attribute or `<initial>` element on a state that is not compound.
162    InitialOnNonCompound,
163    /// An `<initial>` transition with an event or a condition, or without a target.
164    IllegalInitialTransition,
165    /// A history state with more than one transition.
166    HistoryTransitionCount,
167    /// A history default transition with an event or a condition, or without a target.
168    IllegalHistoryTransition,
169    /// A history default transition target outside the history's parent.
170    IllegalHistoryTarget {
171        /// The offending target.
172        target: String,
173    },
174    /// A history state that is a child of the root.
175    HistoryAtRoot,
176    /// A history state with content other than its default transition.
177    HistoryContent,
178    /// A final state with content other than `onentry`, `onexit` and `donedata`.
179    FinalContent {
180        /// What it contains.
181        what: String,
182    },
183    /// `donedata` on a state that is not final.
184    DoneDataOutsideFinal,
185    /// A parallel state whose children are all history states.
186    ParallelHistoryOnly,
187    /// A `<state>` whose children are all history states.
188    StateHistoryOnly,
189    /// The targets of one transition cannot be active together.
190    IllegalTargetSet {
191        /// The first target of the conflicting pair.
192        first: String,
193        /// The second target of the conflicting pair.
194        second: String,
195    },
196    /// Default history entry never reaches a real state: following the default transitions of
197    /// history states (a history state without one defaults to its parent's `initial`) leads
198    /// back to this history state, for example `<state initial="h">` whose history child `h`
199    /// has no default transition.
200    HistoryDefaultCycle {
201        /// The history state on the cycle.
202        history: String,
203    },
204    /// Two data items share an identifier.
205    DuplicateDataId {
206        /// The repeated identifier.
207        id: String,
208    },
209    /// An element has attributes or children that exclude each other, or lacks a required one.
210    ConflictingAttributes {
211        /// The element name.
212        element: String,
213        /// The attributes involved.
214        attributes: String,
215    },
216}
217
218/// One well-formedness violation.
219#[derive(Clone, Debug, PartialEq, Eq)]
220pub struct Violation {
221    /// What is wrong.
222    pub kind: ViolationKind,
223    /// The offending node.
224    pub node: NodeRef,
225    /// Source location of the offending node, if known.
226    pub span: Span,
227}
228
229impl core::fmt::Display for Violation {
230    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
231        write!(f, "{:?} at {:?}", self.kind, self.node)
232    }
233}
234
235/// Flat pre-order view of the state tree used by validation and compilation.
236pub(crate) struct Flat<'a> {
237    /// States in document order.
238    pub states: Vec<&'a State>,
239    /// Parent index, `None` for children of the root.
240    pub parent: Vec<Option<usize>>,
241    /// Index of the last descendant of each state (itself when it has none).
242    pub last: Vec<usize>,
243    /// Identifier to first index.
244    pub by_id: BTreeMap<&'a str, usize>,
245}
246
247impl<'a> Flat<'a> {
248    pub(crate) fn new(chart: &'a Chart) -> Self {
249        let mut f = Flat {
250            states: Vec::new(),
251            parent: Vec::new(),
252            last: Vec::new(),
253            by_id: BTreeMap::new(),
254        };
255        for s in &chart.states {
256            f.push(s, None);
257        }
258        f
259    }
260
261    fn push(&mut self, s: &'a State, parent: Option<usize>) -> usize {
262        let i = self.states.len();
263        self.states.push(s);
264        self.parent.push(parent);
265        self.last.push(i);
266        self.by_id.entry(s.id.as_str()).or_insert(i);
267        for c in &s.children {
268            let l = self.push(c, Some(i));
269            self.last[i] = l;
270        }
271        self.last[i]
272    }
273
274    /// Whether `d` is a proper descendant of `a`.
275    pub(crate) fn is_descendant(&self, d: usize, a: usize) -> bool {
276        a < d && d <= self.last[a]
277    }
278}
279
280/// Validate a chart, returning every violation found.
281// @tg: implements:: [[openspec:chart-model#Well-Formedness Validation]]
282pub fn validate(chart: &Chart) -> Result<(), Vec<Violation>> {
283    let flat = Flat::new(chart);
284    let mut v = Vec::new();
285    let mut push = |kind: ViolationKind, node: NodeRef, span: Span| {
286        v.push(Violation { kind, node, span });
287    };
288
289    if chart.states.is_empty() {
290        push(ViolationKind::EmptyChart, NodeRef::Root, chart.span);
291    }
292
293    // Identifiers.
294    let mut seen: BTreeMap<&str, usize> = BTreeMap::new();
295    for s in &flat.states {
296        if s.id.is_empty() {
297            push(
298                ViolationKind::MissingId,
299                NodeRef::State { id: String::new() },
300                s.span,
301            );
302            continue;
303        }
304        let n = seen.entry(s.id.as_str()).or_insert(0);
305        *n += 1;
306        if *n == 2 {
307            push(
308                ViolationKind::DuplicateStateId { id: s.id.clone() },
309                NodeRef::State { id: s.id.clone() },
310                s.span,
311            );
312        }
313    }
314
315    // Root initial.
316    for t in &chart.initial {
317        if !flat.by_id.contains_key(t.as_str()) {
318            push(
319                ViolationKind::UnknownTarget { target: t.clone() },
320                NodeRef::Root,
321                chart.span,
322            );
323        }
324    }
325    check_target_set(&flat, &chart.initial, NodeRef::Root, chart.span, &mut push);
326    for s in &chart.states {
327        if matches!(s.element, StateElement::History(_)) {
328            push(
329                ViolationKind::HistoryAtRoot,
330                NodeRef::State { id: s.id.clone() },
331                s.span,
332            );
333        }
334    }
335
336    check_history_defaults(&flat, &mut push);
337
338    // Data identifiers.
339    let mut data_seen: BTreeMap<&str, usize> = BTreeMap::new();
340    let all_data = chart
341        .data
342        .iter()
343        .chain(flat.states.iter().flat_map(|s| s.data.iter()));
344    for d in all_data {
345        let n = data_seen.entry(d.id.as_str()).or_insert(0);
346        *n += 1;
347        if *n == 2 {
348            push(
349                ViolationKind::DuplicateDataId { id: d.id.clone() },
350                NodeRef::Data { id: d.id.clone() },
351                d.span,
352            );
353        }
354        // A resolved `src` keeps both the reference and the content it resolved to (the
355        // reader's convention), so `src` with `content` is one source, not two.
356        let given = [d.src.is_some() || d.content.is_some(), d.expr.is_some()]
357            .iter()
358            .filter(|b| **b)
359            .count();
360        if given > 1 {
361            push(
362                conflict("data", "src, expr, content"),
363                NodeRef::Data { id: d.id.clone() },
364                d.span,
365            );
366        }
367    }
368
369    for (i, s) in flat.states.iter().enumerate() {
370        let sid = || NodeRef::State { id: s.id.clone() };
371        let kind = s.kind();
372
373        // Initial attribute and element.
374        let has_initial = !s.initial.is_empty() || s.initial_element.is_some();
375        if has_initial && kind != StateKind::Compound {
376            push(ViolationKind::InitialOnNonCompound, sid(), s.span);
377        }
378        if !s.initial.is_empty() && s.initial_element.is_some() {
379            push(
380                ViolationKind::InitialAttributeAndElement,
381                NodeRef::Initial {
382                    state: s.id.clone(),
383                },
384                s.span,
385            );
386        }
387        let init_node = || NodeRef::Initial {
388            state: s.id.clone(),
389        };
390        for t in &s.initial {
391            match flat.by_id.get(t.as_str()) {
392                None => push(
393                    ViolationKind::UnknownTarget { target: t.clone() },
394                    init_node(),
395                    s.span,
396                ),
397                Some(&ti) if !flat.is_descendant(ti, i) => push(
398                    ViolationKind::IllegalInitialTarget { target: t.clone() },
399                    init_node(),
400                    s.span,
401                ),
402                _ => {}
403            }
404        }
405        check_target_set(&flat, &s.initial, init_node(), s.span, &mut push);
406        if let Some(init) = &s.initial_element {
407            let tr = &init.transition;
408            if !tr.events.is_empty() || tr.cond.is_some() || tr.targets.is_empty() {
409                push(
410                    ViolationKind::IllegalInitialTransition,
411                    init_node(),
412                    init.span,
413                );
414            }
415            for t in &tr.targets {
416                match flat.by_id.get(t.as_str()) {
417                    None => push(
418                        ViolationKind::UnknownTarget { target: t.clone() },
419                        init_node(),
420                        init.span,
421                    ),
422                    Some(&ti) if !flat.is_descendant(ti, i) => push(
423                        ViolationKind::IllegalInitialTarget { target: t.clone() },
424                        init_node(),
425                        init.span,
426                    ),
427                    _ => {}
428                }
429            }
430            check_target_set(&flat, &tr.targets, init_node(), init.span, &mut push);
431            check_block(&tr.body, &s.id, &mut push);
432        }
433
434        // Transitions.
435        for (ti, t) in s.transitions.iter().enumerate() {
436            let tnode = || NodeRef::Transition {
437                source: s.id.clone(),
438                index: ti,
439            };
440            for target in &t.targets {
441                if !flat.by_id.contains_key(target.as_str()) {
442                    push(
443                        ViolationKind::UnknownTarget {
444                            target: target.clone(),
445                        },
446                        tnode(),
447                        t.span,
448                    );
449                }
450            }
451            check_target_set(&flat, &t.targets, tnode(), t.span, &mut push);
452            check_block(&t.body, &s.id, &mut push);
453        }
454
455        // Kind-specific rules.
456        match kind {
457            StateKind::History(_) => {
458                if s.transitions.len() > 1 {
459                    push(ViolationKind::HistoryTransitionCount, sid(), s.span);
460                }
461                if let Some(t) = s.transitions.first() {
462                    if !t.events.is_empty() || t.cond.is_some() || t.targets.is_empty() {
463                        push(
464                            ViolationKind::IllegalHistoryTransition,
465                            NodeRef::Transition {
466                                source: s.id.clone(),
467                                index: 0,
468                            },
469                            t.span,
470                        );
471                    }
472                    if let Some(p) = flat.parent[i] {
473                        for target in &t.targets {
474                            if let Some(&ti) = flat.by_id.get(target.as_str())
475                                && !flat.is_descendant(ti, p)
476                            {
477                                push(
478                                    ViolationKind::IllegalHistoryTarget {
479                                        target: target.clone(),
480                                    },
481                                    NodeRef::Transition {
482                                        source: s.id.clone(),
483                                        index: 0,
484                                    },
485                                    t.span,
486                                );
487                            }
488                        }
489                    }
490                }
491                if !s.children.is_empty()
492                    || !s.onentry.is_empty()
493                    || !s.onexit.is_empty()
494                    || !s.invokes.is_empty()
495                    || !s.data.is_empty()
496                {
497                    push(ViolationKind::HistoryContent, sid(), s.span);
498                }
499            }
500            StateKind::Final => {
501                let mut what = Vec::new();
502                if !s.children.is_empty() {
503                    what.push("child states");
504                }
505                if !s.transitions.is_empty() {
506                    what.push("transitions");
507                }
508                if !s.invokes.is_empty() {
509                    what.push("invokes");
510                }
511                if !s.data.is_empty() {
512                    what.push("data");
513                }
514                if !what.is_empty() {
515                    push(
516                        ViolationKind::FinalContent {
517                            what: what.join(", "),
518                        },
519                        sid(),
520                        s.span,
521                    );
522                }
523            }
524            StateKind::Parallel | StateKind::Compound
525                if !s.children.is_empty()
526                    && s.children
527                        .iter()
528                        .all(|c| matches!(c.element, StateElement::History(_))) =>
529            {
530                // A `<state>` whose only children are history states is atomic in W3C SCXML
531                // but compound by `State::kind`; such charts are rejected rather than run
532                // under either reading.
533                let k = if kind == StateKind::Parallel {
534                    ViolationKind::ParallelHistoryOnly
535                } else {
536                    ViolationKind::StateHistoryOnly
537                };
538                push(k, sid(), s.span);
539            }
540            _ => {}
541        }
542        if s.donedata.is_some() && kind != StateKind::Final {
543            push(ViolationKind::DoneDataOutsideFinal, sid(), s.span);
544        }
545        if let Some(dd) = &s.donedata {
546            if dd.content.is_some() && !dd.params.is_empty() {
547                push(conflict("donedata", "content, param"), sid(), dd.span);
548            }
549            check_params(&dd.params, &s.id, &mut push);
550        }
551
552        // Executable content and invokes.
553        for b in s.onentry.iter().chain(s.onexit.iter()) {
554            check_block(b, &s.id, &mut push);
555        }
556        for (ii, inv) in s.invokes.iter().enumerate() {
557            let inode = || NodeRef::Invoke {
558                state: s.id.clone(),
559                index: ii,
560            };
561            let pairs = [
562                (
563                    inv.type_.is_some() && inv.typeexpr.is_some(),
564                    "type, typeexpr",
565                ),
566                (inv.src.is_some() && inv.srcexpr.is_some(), "src, srcexpr"),
567                (
568                    inv.id.is_some() && inv.idlocation.is_some(),
569                    "id, idlocation",
570                ),
571                // A resolved `src` keeps the reference and the document it resolved to as an
572                // inline `<content>` body, so only `srcexpr`, or `src` with a `<content expr>`,
573                // conflicts with content.
574                (
575                    inv.content.as_ref().is_some_and(|c| {
576                        inv.srcexpr.is_some() || (inv.src.is_some() && c.expr.is_some())
577                    }),
578                    "content, src",
579                ),
580                (
581                    !inv.params.is_empty() && !inv.namelist.is_empty(),
582                    "param, namelist",
583                ),
584            ];
585            for (bad, attrs) in pairs {
586                if bad {
587                    push(conflict("invoke", attrs), inode(), inv.span);
588                }
589            }
590            check_params(&inv.params, &s.id, &mut push);
591            check_block(&inv.finalize, &s.id, &mut push);
592        }
593    }
594
595    if v.is_empty() { Ok(()) } else { Err(v) }
596}
597
598fn conflict(element: &str, attributes: &str) -> ViolationKind {
599    ViolationKind::ConflictingAttributes {
600        element: element.to_string(),
601        attributes: attributes.to_string(),
602    }
603}
604
605/// The history states the default entry of history state `h` targets: its default transition's
606/// history targets, or, without one, the history targets of its parent's `initial` attribute or
607/// `<initial>` transition (which is what compilation synthesizes as the default).
608fn history_default_edges(flat: &Flat<'_>, h: usize) -> Vec<usize> {
609    let st = flat.states[h];
610    let targets: Vec<&str> = match (st.transitions.first(), flat.parent[h]) {
611        (Some(t), _) => t.targets.iter().map(String::as_str).collect(),
612        (None, Some(p)) => resolve_initial(flat.states[p]).targets,
613        (None, None) => Vec::new(),
614    };
615    targets
616        .iter()
617        .filter_map(|t| flat.by_id.get(t).copied())
618        .filter(|&i| matches!(flat.states[i].element, StateElement::History(_)))
619        .collect()
620}
621
622/// Default history entry must terminate: no history state may reach itself through
623/// [`history_default_edges`]. Without this rule entering such a history state would recurse
624/// forever.
625fn check_history_defaults(flat: &Flat<'_>, push: &mut impl FnMut(ViolationKind, NodeRef, Span)) {
626    let histories: Vec<usize> = (0..flat.states.len())
627        .filter(|&i| matches!(flat.states[i].element, StateElement::History(_)))
628        .collect();
629    for &h in &histories {
630        let mut seen = alloc::vec![false; flat.states.len()];
631        let mut stack = history_default_edges(flat, h);
632        let mut cyclic = false;
633        while let Some(x) = stack.pop() {
634            if x == h {
635                cyclic = true;
636                break;
637            }
638            if !core::mem::replace(&mut seen[x], true) {
639                stack.extend(history_default_edges(flat, x));
640            }
641        }
642        if cyclic {
643            let id = flat.states[h].id.clone();
644            push(
645                ViolationKind::HistoryDefaultCycle {
646                    history: id.clone(),
647                },
648                NodeRef::State { id },
649                flat.states[h].span,
650            );
651        }
652    }
653}
654
655/// Targets of one transition must be able to be active together: no target may be an ancestor of
656/// another, and every pair must meet below a parallel state. A history target stands for the
657/// whole content of its parent, so it takes its parent's position: it excludes its siblings,
658/// their descendants and other history states of the same parent.
659fn check_target_set(
660    flat: &Flat<'_>,
661    targets: &[String],
662    node: NodeRef,
663    span: Span,
664    push: &mut impl FnMut(ViolationKind, NodeRef, Span),
665) {
666    let idx: Vec<(usize, &String)> = targets
667        .iter()
668        .filter_map(|t| flat.by_id.get(t.as_str()).map(|i| (*i, t)))
669        .filter_map(|(i, t)| {
670            if matches!(flat.states[i].element, StateElement::History(_)) {
671                // A history state at the root is reported on its own.
672                flat.parent[i].map(|p| (p, t))
673            } else {
674                Some((i, t))
675            }
676        })
677        .collect();
678    for a in 0..idx.len() {
679        for b in (a + 1)..idx.len() {
680            let (x, xs) = idx[a];
681            let (y, ys) = idx[b];
682            let ok = if x == y || flat.is_descendant(x, y) || flat.is_descendant(y, x) {
683                false
684            } else {
685                // Lowest common proper ancestor must be a parallel state.
686                let mut anc = flat.parent[x];
687                loop {
688                    match anc {
689                        Some(p) if flat.is_descendant(y, p) => {
690                            break flat.states[p].kind() == StateKind::Parallel;
691                        }
692                        Some(p) => anc = flat.parent[p],
693                        None => break false,
694                    }
695                }
696            };
697            if !ok {
698                push(
699                    ViolationKind::IllegalTargetSet {
700                        first: xs.clone(),
701                        second: ys.clone(),
702                    },
703                    node.clone(),
704                    span,
705                );
706            }
707        }
708    }
709}
710
711fn check_params(
712    params: &[Param],
713    state: &str,
714    push: &mut impl FnMut(ViolationKind, NodeRef, Span),
715) {
716    for p in params {
717        if p.expr.is_some() == p.location.is_some() {
718            push(
719                conflict("param", "expr, location"),
720                NodeRef::Exec {
721                    state: state.to_string(),
722                },
723                p.span,
724            );
725        }
726    }
727}
728
729fn check_block(block: &Block, state: &str, push: &mut impl FnMut(ViolationKind, NodeRef, Span)) {
730    for e in block {
731        check_exec(e, state, push);
732    }
733}
734
735fn check_exec(e: &Exec, state: &str, push: &mut impl FnMut(ViolationKind, NodeRef, Span)) {
736    let node = || NodeRef::Exec {
737        state: state.to_string(),
738    };
739    match &e.kind {
740        ExecKind::Send(s) => {
741            let pairs = [
742                (
743                    s.event.is_some() && s.eventexpr.is_some(),
744                    "event, eventexpr",
745                ),
746                (
747                    s.target.is_some() && s.targetexpr.is_some(),
748                    "target, targetexpr",
749                ),
750                (s.type_.is_some() && s.typeexpr.is_some(), "type, typeexpr"),
751                (s.id.is_some() && s.idlocation.is_some(), "id, idlocation"),
752                (
753                    s.delay.is_some() && s.delayexpr.is_some(),
754                    "delay, delayexpr",
755                ),
756                (
757                    s.content.is_some() && (!s.params.is_empty() || !s.namelist.is_empty()),
758                    "content, param, namelist",
759                ),
760            ];
761            for (bad, attrs) in pairs {
762                if bad {
763                    push(conflict("send", attrs), node(), e.span);
764                }
765            }
766            check_params(&s.params, state, push);
767        }
768        ExecKind::Cancel { sendid, sendidexpr } if sendid.is_some() == sendidexpr.is_some() => {
769            push(conflict("cancel", "sendid, sendidexpr"), node(), e.span);
770        }
771        ExecKind::Assign { expr, content, .. } if expr.is_some() && content.is_some() => {
772            push(conflict("assign", "expr, content"), node(), e.span);
773        }
774        ExecKind::If { branches } => {
775            for (bi, br) in branches.iter().enumerate() {
776                let is_else = br.cond.is_none();
777                if is_else && bi + 1 != branches.len() {
778                    push(
779                        conflict("if", &format!("else before branch {}", bi + 1)),
780                        node(),
781                        br.span,
782                    );
783                }
784                if bi == 0 && is_else {
785                    push(conflict("if", "cond"), node(), br.span);
786                }
787                check_block(&br.body, state, push);
788            }
789        }
790        ExecKind::Foreach { body, item, .. } => {
791            if item.is_empty() {
792                push(conflict("foreach", "item"), node(), e.span);
793            }
794            check_block(body, state, push);
795        }
796        _ => {}
797    }
798}