Skip to main content

sup_xml_core/xsd/
dfa.rs

1//! Deterministic finite automaton for content-model matching.
2//!
3//! Built once at schema-compile time from a [`ContentModel`].  At
4//! validation time the validator just walks the DFA: O(1) state lookup
5//! per child element instead of tree-walking the particle list.
6//!
7//! ## Coverage
8//!
9//! Sequence, choice, single elements, nested groups (flattened in), and
10//! wildcards all compile to DFA transitions.  `xs:all` does **not** —
11//! its semantics (each particle in any order) require either subset
12//! construction (worst-case 2ⁿ states) or runtime bitset tracking.  We
13//! keep the existing particle-walk matcher for all-groups; the DFA path
14//! is opt-in per ComplexType via [`ContentMatcher`].
15//!
16//! ## Determinism
17//!
18//! XSD §3.8.6 requires content models to satisfy *Unique Particle
19//! Attribution*: each child must match at most one particle without
20//! lookahead.  This means the resulting state machine is naturally
21//! deterministic.  The compiler enforces UPA — any duplicate
22//! transition out of the same state is a compile error.
23//!
24//! ## Substitution groups
25//!
26//! When a transition matches an element with substitutes, the
27//! substitutes are pre-expanded into separate transitions.  Lookup at
28//! validation time stays a linear scan (small N typical), no
29//! per-validate substitution-group lookup needed.
30
31use std::collections::HashMap;
32use std::sync::Arc;
33
34use super::error::SchemaCompileError;
35use super::schema::{
36    ContentModel, GroupKind, MaxOccurs, NamespaceConstraint, Particle, ProcessContents,
37    QName, Term, Wildcard,
38};
39
40// ── public DFA types ─────────────────────────────────────────────────────────
41
42pub type StateId = u32;
43
44#[derive(Debug)]
45pub struct Dfa {
46    pub states:  Vec<DfaState>,
47    pub initial: StateId,
48    /// Names of every element declaration that appears statically in
49    /// the source content model.  Consulted by `notQName="##definedSibling"`
50    /// wildcards (XSD 1.1 §3.10.4) to exclude names declared as
51    /// siblings in the enclosing complex type.  Sorted by `(namespace,
52    /// local)` so membership checks can binary-search if the list
53    /// grows; typical schemas keep this short.
54    pub defined_siblings: Arc<[QName]>,
55}
56
57#[derive(Debug, Default)]
58pub struct DfaState {
59    /// True iff the parent's content is allowed to end at this state
60    /// (all required particles satisfied so far).
61    pub accept: bool,
62    /// Element-name transitions.  Each carries the [`ElementDecl`]
63    /// the matched element resolves to — stored inline so the
64    /// validator gets both the next state AND the decl in one lookup.
65    /// Linear scan — for typical N≤8 it beats a HashMap on cache use.
66    pub on_element: Vec<ElementTransition>,
67    /// Wildcard transitions — consulted *only* after element-name
68    /// transitions don't match (specific declarations take priority
69    /// over wildcards per spec).
70    pub on_wildcard: Vec<WildcardTransition>,
71}
72
73#[derive(Debug, Clone)]
74pub struct WildcardTransition {
75    pub wc:   Wildcard,
76    pub next: StateId,
77    /// Synthetic transitions stand in for a `Term::GroupRef` cycle
78    /// whose referenced group can't be expanded inline.  They admit
79    /// `##any` so the validator over-accepts the cycle's content;
80    /// UPA cannot meaningfully clash against them.
81    pub synthetic: bool,
82}
83
84#[derive(Debug, Clone)]
85pub struct ElementTransition {
86    pub name: QName,
87    pub next: StateId,
88    /// Resolved declaration for the matched element — its own decl
89    /// for direct matches, or the substitute's decl when the
90    /// transition was generated for a substitution-group member.
91    pub decl: Arc<super::schema::ElementDecl>,
92}
93
94impl Dfa {
95    /// Look up the next state for an incoming element with name `qn`.
96    /// Element-name transitions return both the next state and the
97    /// matched [`ElementDecl`]; wildcard transitions return the next
98    /// state and the wildcard's `processContents`.
99    ///
100    /// `wildcard_admits` decides whether a candidate wildcard accepts
101    /// `qn` — the caller supplies the wildcard-semantics policy
102    /// (positive namespace constraint plus the XSD 1.1 `notQName` /
103    /// `notNamespace` exclusions, including `##defined` and
104    /// `##definedSibling` lookups).  See [`wildcard_admits`] for the
105    /// canonical implementation.
106    pub fn step(
107        &self,
108        state: StateId,
109        qn: &QName,
110        mut wildcard_admits: impl FnMut(&Wildcard, &QName) -> bool,
111    ) -> Option<DfaTransition> {
112        let s = &self.states[state as usize];
113        for t in &s.on_element {
114            if &t.name == qn {
115                return Some(DfaTransition::Element {
116                    next: t.next,
117                    decl: t.decl.clone(),
118                });
119            }
120        }
121        for t in &s.on_wildcard {
122            if wildcard_admits(&t.wc, qn) {
123                return Some(DfaTransition::Wildcard {
124                    next: t.next,
125                    process_contents: t.wc.process_contents,
126                });
127            }
128        }
129        None
130    }
131
132    pub fn is_accept(&self, state: StateId) -> bool {
133        self.states[state as usize].accept
134    }
135}
136
137#[derive(Debug)]
138pub enum DfaTransition {
139    Element { next: StateId, decl: Arc<super::schema::ElementDecl> },
140    Wildcard { next: StateId, process_contents: ProcessContents },
141}
142
143/// What a [`ComplexType`](super::types::ComplexType) uses to validate
144/// its content at runtime.  The Builder picks one based on the
145/// content-model shape.
146#[derive(Debug)]
147pub enum ContentMatcher {
148    /// DFA-driven matching for sequence/choice/element/wildcard models.
149    Dfa(Arc<Dfa>),
150    /// Particle-walk matching for `xs:all` (the runtime tracks a
151    /// bitset of consumed particles).
152    All,
153    /// Empty or simple-content type — no matcher needed.
154    None,
155}
156
157/// Build a [`ContentMatcher`] for a [`ContentModel`].  Substitution
158/// groups need to be available so transitions can pre-expand to all
159/// substitutes.
160/// Set of derivation methods used to derive `child` from `ancestor`
161/// via `types`. Used at DFA-compile time to filter substitution-group
162/// members whose type derivation is blocked by their head's `block`.
163pub(super) fn derivation_methods_between(
164    child:    &super::schema::TypeRef,
165    ancestor: &super::schema::TypeRef,
166    types:    &HashMap<QName, super::schema::TypeRef>,
167) -> Option<super::schema::BlockSet> {
168    use super::schema::{BlockSet, TypeRef};
169    use super::types::DerivationMethod;
170    fn resolve(tr: &TypeRef, types: &HashMap<QName, TypeRef>) -> TypeRef {
171        if let TypeRef::Simple(st) = tr {
172            if let Some(name) = &st.name {
173                if let Some(rest) = name.strip_prefix("UNRESOLVED:") {
174                    // Same encoding as parser::resolve_typeref_to_qname.
175                    let qn = if let Some(rest) = rest.strip_prefix('{') {
176                        if let Some(end) = rest.find('}') {
177                            let ns    = &rest[..end];
178                            let local = &rest[end + 1..];
179                            QName::new(if ns.is_empty() { None } else { Some(ns) }, local)
180                        } else { QName::new(None, rest) }
181                    } else { QName::new(None, rest) };
182                    if let Some(real) = types.get(&qn) { return real.clone(); }
183                }
184            }
185        }
186        tr.clone()
187    }
188    fn eq(a: &TypeRef, b: &TypeRef) -> bool {
189        match (a, b) {
190            (TypeRef::Simple(x), TypeRef::Simple(y))   => Arc::ptr_eq(x, y) || x.name == y.name,
191            (TypeRef::Complex(x), TypeRef::Complex(y)) => Arc::ptr_eq(x, y) || x.name == y.name,
192            _ => false,
193        }
194    }
195    fn is_any_type(tr: &TypeRef) -> bool {
196        match tr {
197            TypeRef::Complex(c) => c.name.as_ref().map(|n|
198                n.namespace.as_deref() == Some(QName::XSD_NS) && &*n.local == "anyType"
199            ).unwrap_or(false),
200            _ => false,
201        }
202    }
203    let child = resolve(child, types);
204    let ancestor = resolve(ancestor, types);
205    if eq(&child, &ancestor) { return Some(BlockSet::empty()); }
206    // XSD §3.4.7 — every type, including every simple type, transitively
207    // derives from xs:anyType (the ur-type root).  Substitution-group
208    // typing checks routinely use anyType as the head's type to mean
209    // "any element body is acceptable as a substitute," so this short
210    // cut keeps that working without walking a simple-type chain that
211    // doesn't carry an explicit pointer up through anySimpleType.
212    if is_any_type(&ancestor) {
213        return Some(BlockSet::RESTRICTION | BlockSet::EXTENSION);
214    }
215    if let TypeRef::Complex(ct) = &child {
216        let mut methods = BlockSet::empty();
217        let mut cur: Arc<super::types::ComplexType> = ct.clone();
218        for _ in 0..64 {
219            let d = match cur.derivation.as_ref() {
220                Some(d) => d,
221                // XSD §3.4.7 — a complex type without an explicit
222                // derivation is implicitly an extension of xs:anyType.
223                // When the ancestor we're matching is anyType itself,
224                // accumulate that final extension step and succeed.
225                None => return if is_any_type(&ancestor) {
226                    Some(methods | BlockSet::EXTENSION)
227                } else { None },
228            };
229            methods |= match d.method {
230                DerivationMethod::Restriction => BlockSet::RESTRICTION,
231                DerivationMethod::Extension   => BlockSet::EXTENSION,
232            };
233            let base = resolve(&d.base, types);
234            if eq(&base, &ancestor) { return Some(methods); }
235            match base {
236                TypeRef::Complex(next) => { cur = next; }
237                TypeRef::Simple(_)     => return None,
238            }
239        }
240        None
241    } else if let (TypeRef::Simple(c_st), TypeRef::Simple(a_st)) = (&child, &ancestor) {
242        // XSD §3.16.6 / cos-st-derived-ok — a simple type derives from
243        // another only along a restriction chain or up to xs:anySimpleType.
244        // List and union types derive from xs:anySimpleType only; they do
245        // NOT derive from their item / member types.
246        use super::types::Variety;
247        fn is_any_simple_type(st: &super::types::SimpleType) -> bool {
248            // Two forms reach this branch: a real "xs:anySimpleType"
249            // simple-type carrier (name = "anySimpleType"), or the
250            // type_ref_for placeholder for it (the parser doesn't have
251            // a BuiltinType variant for anySimpleType so the placeholder
252            // path is taken).
253            match st.name.as_deref() {
254                Some("anySimpleType") => true,
255                Some(s) => s.starts_with("UNRESOLVED:") && s.ends_with("anySimpleType"),
256                None => false,
257            }
258        }
259        if is_any_simple_type(a_st) {
260            return Some(super::schema::BlockSet::RESTRICTION);
261        }
262        // For atomic-atomic, walk the built-in lineage.  This works for
263        // the common case where the schema author derives a custom
264        // simple type with the same `builtin` field as one of its
265        // ancestors in the XSD type hierarchy.
266        match (&c_st.variety, &a_st.variety) {
267            (Variety::Atomic, Variety::Atomic) => {
268                if c_st.builtin.derives_from(a_st.builtin) {
269                    Some(super::schema::BlockSet::RESTRICTION)
270                } else {
271                    None
272                }
273            }
274            // List/union widen the value space relative to their items
275            // or members, so they aren't restrictions of any non-
276            // anySimpleType ancestor.
277            (Variety::List { .. }, _) | (Variety::Union { .. }, _) => None,
278            _ => None,
279        }
280    } else {
281        None
282    }
283}
284
285fn resolve_typeref_via_types(
286    tr: &super::schema::TypeRef,
287    types: &HashMap<QName, super::schema::TypeRef>,
288) -> super::schema::TypeRef {
289    use super::schema::TypeRef;
290    if let TypeRef::Simple(st) = tr {
291        if let Some(name) = &st.name {
292            if let Some(rest) = name.strip_prefix("UNRESOLVED:") {
293                let qn = if let Some(rest) = rest.strip_prefix('{') {
294                    if let Some(end) = rest.find('}') {
295                        let ns    = &rest[..end];
296                        let local = &rest[end + 1..];
297                        QName::new(if ns.is_empty() { None } else { Some(ns) }, local)
298                    } else { QName::new(None, rest) }
299                } else { QName::new(None, rest) };
300                if let Some(real) = types.get(&qn) { return real.clone(); }
301            }
302        }
303    }
304    tr.clone()
305}
306
307pub fn build_matcher(
308    cm: &ContentModel,
309    substitutions: &HashMap<QName, Vec<Arc<super::schema::ElementDecl>>>,
310    types: &HashMap<QName, super::schema::TypeRef>,
311) -> Result<ContentMatcher, SchemaCompileError> {
312    build_matcher_with_target_ns(cm, substitutions, types, None)
313}
314
315pub fn build_matcher_with_target_ns(
316    cm: &ContentModel,
317    substitutions: &HashMap<QName, Vec<Arc<super::schema::ElementDecl>>>,
318    types: &HashMap<QName, super::schema::TypeRef>,
319    target_ns: Option<&str>,
320) -> Result<ContentMatcher, SchemaCompileError> {
321    match cm {
322        ContentModel::Empty | ContentModel::Simple(_) => Ok(ContentMatcher::None),
323        ContentModel::Complex { root, .. } => {
324            // Top-level all-group → keep on the existing matcher.
325            if let Term::Group { kind: GroupKind::All, .. } = &root.term {
326                return Ok(ContentMatcher::All);
327            }
328            // Collect sibling-element names up front so the UPA
329            // checks against `notQName="##definedSibling"` wildcards
330            // see the full sibling set when each transition is added,
331            // not only the prefix walked so far.
332            let defined_siblings = collect_defined_siblings(root);
333            let mut b = DfaBuilder::new(defined_siblings.clone(),
334                                        target_ns.map(|s| Arc::<str>::from(s)));
335            let frag = b.compile_particle(root, substitutions, types)?;
336            // `frag.entries` are the start states; collapse to one
337            // explicit initial state with all entries' outgoing
338            // transitions copied in (UPA keeps this clean).
339            let initial = b.merge_into_initial(&frag)?;
340            // Every exit state is an accept state.
341            for &x in &frag.exits {
342                b.states[x as usize].accept = true;
343            }
344            // The fragment is "skippable" — accepts zero input — iff
345            // at least one entry is also one of its exits (covers
346            // choice fragments where some branch is skippable while
347            // others aren't).  In that case the initial state is
348            // itself an accept state.
349            if frag.entries.iter().any(|e| frag.exits.contains(e)) {
350                b.states[initial as usize].accept = true;
351            }
352            Ok(ContentMatcher::Dfa(Arc::new(Dfa {
353                states: b.states,
354                initial,
355                defined_siblings,
356            })))
357        }
358    }
359}
360
361// ── DFA construction ─────────────────────────────────────────────────────────
362
363/// One sub-DFA fragment during compilation.  Consists of the entry
364/// state(s) — where matching starts when the surrounding context hands
365/// control to this fragment — and the exit state(s) — where matching
366/// returns to the surrounding context.  Entries and exits are kept as
367/// state-id sets because particles can be skipped (`minOccurs=0`),
368/// which means the entries also serve as exits for the empty path.
369struct Fragment {
370    entries: Vec<StateId>,
371    exits:   Vec<StateId>,
372}
373
374struct DfaBuilder {
375    states: Vec<DfaState>,
376    /// All element names declared as siblings in the source content
377    /// model.  Consulted by `wildcard_might_admit` when a wildcard
378    /// carries `notQName="##definedSibling"` (XSD 1.1 §3.10.4).
379    defined_siblings: Arc<[QName]>,
380    /// The schema's targetNamespace, used to give the `##other` /
381    /// `##targetNamespace` UPA overlap checks the precise answer
382    /// they need.  `None` when the schema has no targetNamespace
383    /// (i.e., declarations live in the absent namespace).
384    target_ns:        Option<Arc<str>>,
385}
386
387impl DfaBuilder {
388    fn new(defined_siblings: Arc<[QName]>, target_ns: Option<Arc<str>>) -> Self {
389        // State 0 is reserved as the global initial; we'll fill it in
390        // at the top.
391        Self { states: vec![DfaState::default()], defined_siblings, target_ns }
392    }
393
394    fn new_state(&mut self) -> StateId {
395        let id = self.states.len() as StateId;
396        self.states.push(DfaState::default());
397        id
398    }
399
400    /// Add a transition `from --(name)--> to` carrying the matched
401    /// element's declaration.  Errors on UPA violation (two transitions
402    /// from the same state on the same element name).
403    fn add_element(
404        &mut self,
405        from: StateId,
406        name: QName,
407        to: StateId,
408        decl: Arc<super::schema::ElementDecl>,
409    ) -> Result<(), SchemaCompileError> {
410        let s = &mut self.states[from as usize];
411        if let Some(existing) = s.on_element.iter().find(|t| t.name == name) {
412            // Identical particle attribution is fine — same element
413            // decl seen twice from the same state happens when a
414            // bounded repeat is concatenated and the next iteration's
415            // first element overlaps with the previous iteration's
416            // last.  We keep the existing transition; the resulting
417            // DFA can under-count iterations across the boundary but
418            // still attributes every accepted element to a single
419            // particle (which is what UPA actually requires).
420            if Arc::ptr_eq(&existing.decl, &decl) {
421                return Ok(());
422            }
423            return Err(SchemaCompileError::msg(format!(
424                "Unique Particle Attribution violation: element <{name}> reachable two ways from the same content-model position"
425            )));
426        }
427        // XSD §3.8.6 UPA — an incoming element-name transition can't
428        // share its source state with a wildcard that also admits the
429        // same name, unless both attribute to the same next state.
430        // Synthetic wildcards (cycle stand-ins) are excluded.
431        let target_ns = self.target_ns.as_deref();
432        for t in &s.on_wildcard {
433            if t.synthetic { continue; }
434            if t.next == to { continue; }
435            if wildcard_might_admit(&t.wc, &name, &self.defined_siblings, target_ns) {
436                return Err(SchemaCompileError::msg(format!(
437                    "Unique Particle Attribution violation: element <{name}> \
438                     is also admitted by a wildcard reachable from the same \
439                     content-model position"
440                )));
441            }
442        }
443        s.on_element.push(ElementTransition { name, next: to, decl });
444        Ok(())
445    }
446
447    /// `synthetic` means the wildcard wasn't authored by the user —
448    /// the builder fabricates one when a `Term::GroupRef` participates
449    /// in a cycle (the inner group's shape isn't known here, so an
450    /// `##any` wildcard stands in).  UPA cannot meaningfully clash
451    /// against a synthetic wildcard since the real shape lives in
452    /// the referenced group; skip UPA for those.
453    fn add_wildcard_inner(&mut self, from: StateId, wc: Wildcard, to: StateId, synthetic: bool)
454        -> Result<(), SchemaCompileError>
455    {
456        if synthetic {
457            self.states[from as usize].on_wildcard.push(WildcardTransition {
458                wc, next: to, synthetic,
459            });
460            return Ok(());
461        }
462        self.add_wildcard_checked(from, wc, to)
463    }
464
465    fn add_wildcard(&mut self, from: StateId, wc: Wildcard, to: StateId)
466        -> Result<(), SchemaCompileError>
467    {
468        self.add_wildcard_inner(from, wc, to, false)
469    }
470
471    fn add_wildcard_checked(&mut self, from: StateId, wc: Wildcard, to: StateId)
472        -> Result<(), SchemaCompileError>
473    {
474        // XSD §3.8.6 UPA — two distinct wildcards reachable from the
475        // same state with overlapping namespace sets and different
476        // next states cannot be attributed unambiguously.  Wildcards
477        // sharing a `to` (which happens when concatenation glues the
478        // same particle into adjacent positions) are NOT a clash —
479        // both attributions resolve to the same particle.  Synthetic
480        // cycle stand-ins are excluded.
481        let target_ns = self.target_ns.as_deref();
482        for t in &self.states[from as usize].on_wildcard {
483            if t.synthetic { continue; }
484            if t.next == to { continue; }
485            if wildcards_overlap(&t.wc, &wc, target_ns) {
486                return Err(SchemaCompileError::msg(
487                    "Unique Particle Attribution violation: two wildcards reachable \
488                     from the same content-model position have overlapping namespace \
489                     constraints and attribute to different particles"
490                ));
491            }
492        }
493        // …and the same rule applies symmetrically against any
494        // already-recorded element-name transitions whose name the
495        // new wildcard would admit.
496        for t in &self.states[from as usize].on_element {
497            if t.next == to { continue; }
498            if wildcard_might_admit(&wc, &t.name, &self.defined_siblings, target_ns) {
499                return Err(SchemaCompileError::msg(format!(
500                    "Unique Particle Attribution violation: wildcard admits \
501                     element <{}> which is also reachable as a named transition \
502                     from the same content-model position",
503                    t.name,
504                )));
505            }
506        }
507        self.states[from as usize].on_wildcard.push(WildcardTransition {
508            wc, next: to, synthetic: false,
509        });
510        Ok(())
511    }
512
513    /// Copy every outgoing transition of `src` into `dst`.  Used when
514    /// gluing sub-DFAs together — the prior fragment's exit takes over
515    /// the next fragment's entry transitions.
516    fn copy_outgoing(&mut self, src: StateId, dst: StateId)
517        -> Result<(), SchemaCompileError>
518    {
519        // Borrow checker: clone source's transition list before mutating dst.
520        let src_state = &self.states[src as usize];
521        let elems    = src_state.on_element.clone();
522        let wilds    = src_state.on_wildcard.clone();
523        let src_accept = src_state.accept;
524        for t in elems {
525            self.add_element(dst, t.name, t.next, t.decl)?;
526        }
527        for t in wilds {
528            self.add_wildcard_inner(dst, t.wc, t.next, t.synthetic)?;
529        }
530        if src_accept {
531            self.states[dst as usize].accept = true;
532        }
533        Ok(())
534    }
535
536    /// Build a single state collecting all entry transitions of a
537    /// fragment.  Used to give the top-level Dfa one canonical
538    /// `initial`.  Returns a UPA error if two entries share an
539    /// outgoing transition key.
540    fn merge_into_initial(&mut self, frag: &Fragment)
541        -> Result<StateId, SchemaCompileError>
542    {
543        let initial = 0; // reserved at construction time
544        for &e in &frag.entries {
545            self.copy_outgoing(e, initial)?;
546        }
547        Ok(initial)
548    }
549
550    // ── particle compilation ───────────────────────────────────────────
551
552    fn compile_particle(
553        &mut self,
554        p: &Particle,
555        subs: &HashMap<QName, Vec<Arc<super::schema::ElementDecl>>>,
556        types: &HashMap<QName, super::schema::TypeRef>,
557    ) -> Result<Fragment, SchemaCompileError> {
558        // XSD 1.0 §3.9.6: a particle with `maxOccurs="0"` contributes
559        // no transitions to the content model — the schema author has
560        // declared it absent.  Short-circuit to an empty fragment
561        // (entry doubles as exit, no edges added) so the inner shape
562        // never reaches the state graph; otherwise the loopback paths
563        // in `repeat_fragment` keep the transitions live and we admit
564        // children the schema forbids.
565        if matches!(p.max_occurs, MaxOccurs::Bounded(0)) {
566            let s = self.new_state();
567            return Ok(Fragment { entries: vec![s], exits: vec![s] });
568        }
569        match &p.term {
570            Term::Element(decl) => {
571                // Build (name, decl) pairs for the element itself plus
572                // every member of its substitution group.  Each
573                // substitute resolves to its own decl, not the head's,
574                // so the validator validates against the correct type.
575                //
576                // XSD 1.0 §3.3.4 / cvc-elt-2.2: an anchor with
577                // `block="substitution"` (or `#all`) admits no
578                // substitution-group members at all in instance
579                // documents — only the anchor's own name.  Skip
580                // injecting substitutes in that case.  Anchors that
581                // are themselves abstract pass through the validator's
582                // separate abstract-rejection path.
583                //
584                // §3.3.6 (cvc-elt-substitution) also says: an anchor
585                // with block="restriction" / "extension" forbids
586                // substitutes whose type derives from the anchor's
587                // type via that method. Filter those out here.
588                let mut targets: Vec<(QName, Arc<super::schema::ElementDecl>)> = Vec::new();
589                targets.push((decl.name.clone(), decl.clone()));
590                let blocks_substitution = decl.block
591                    .contains(super::schema::BlockSet::SUBSTITUTION);
592                if !blocks_substitution
593                    && let Some(subs_list) = subs.get(&decl.name)
594                {
595                    // XSD §3.3.6 (cvc-elt-substitution): both the
596                    // head element's `block` and the head element's
597                    // *type*'s `block` contribute. The element's
598                    // block applies methods listed on `<xs:element>`;
599                    // the type's block applies methods listed on
600                    // the head's `<xs:complexType>`.
601                    let head_type_block: super::schema::BlockSet =
602                        match resolve_typeref_via_types(&decl.type_def, types) {
603                            super::schema::TypeRef::Complex(ct) => ct.block,
604                            _ => super::schema::BlockSet::empty(),
605                        };
606                    let blocked_methods = (decl.block | head_type_block) & (
607                        super::schema::BlockSet::RESTRICTION
608                        | super::schema::BlockSet::EXTENSION
609                    );
610                    for sub in subs_list {
611                        if !blocked_methods.is_empty() {
612                            if let Some(used) = derivation_methods_between(
613                                &sub.type_def, &decl.type_def, types,
614                            ) {
615                                if !(used & blocked_methods).is_empty() {
616                                    continue;
617                                }
618                            }
619                        }
620                        targets.push((sub.name.clone(), sub.clone()));
621                    }
622                }
623                self.compile_repeated(p.min_occurs, p.max_occurs,
624                    |b, from, to| {
625                        for (n, d) in &targets {
626                            b.add_element(from, n.clone(), to, d.clone())?;
627                        }
628                        Ok(())
629                    })
630            }
631            Term::Wildcard(wc) => {
632                let wc = wc.clone();
633                self.compile_repeated(p.min_occurs, p.max_occurs,
634                    |b, from, to| b.add_wildcard(from, wc.clone(), to))
635            }
636            Term::Group { kind, particles } => {
637                // XSD §3.8.6 — for a sequence/choice with `min > 1`,
638                // `max = unbounded` wrapping a SINGLE inner particle
639                // whose own `max = unbounded`, the effective bound is
640                // `inner_min * outer_min .. unbounded` on a chain of
641                // copies of the inner particle.  The general
642                // [`repeat_group`] concat-then-loopback path drops
643                // the concat's "advance" transition when the inner
644                // already has a self-loop on the same element name
645                // (the UPA-conflict-tolerant `add_element` keeps the
646                // existing self-loop instead) — so a min=2 outer
647                // wrapping a max=unbounded inner accepts only the
648                // self-loop and never reaches the second-copy exit.
649                // Collapse the bounds and compile the inner directly
650                // when it's a single particle, sidestepping the
651                // concat boundary entirely.
652                if matches!(p.max_occurs, MaxOccurs::Unbounded)
653                    && p.min_occurs > 1
654                    && particles.len() == 1
655                    && matches!(kind, GroupKind::Sequence | GroupKind::Choice)
656                {
657                    let inner = &particles[0];
658                    let inner_min = inner.min_occurs.saturating_mul(p.min_occurs);
659                    let collapsed = Particle {
660                        term:       inner.term.clone(),
661                        min_occurs: inner_min,
662                        max_occurs: MaxOccurs::Unbounded,
663                    };
664                    return self.compile_particle(&collapsed, subs, types);
665                }
666                let build_inner = |this: &mut Self| -> Result<Fragment, SchemaCompileError> {
667                    match kind {
668                        GroupKind::Sequence => this.compile_sequence(particles, subs, types),
669                        GroupKind::Choice   => this.compile_choice(particles, subs, types),
670                        GroupKind::All      => Err(SchemaCompileError::msg(
671                            "nested xs:all is not supported in v1 (use sequence/choice)"
672                        )),
673                    }
674                };
675                self.repeat_group(p.min_occurs, p.max_occurs, build_inner)
676            }
677            Term::GroupRef(_name) => {
678                // An unresolved GroupRef at DFA build time means the
679                // ref participates in a cycle that crosses an
680                // element boundary (the `resolve_group_refs` pass
681                // leaves these intact rather than recursing forever).
682                //
683                // Model the cycle position as a wildcard.  The
684                // particle's own `min_occurs` / `max_occurs` apply
685                // to the WHOLE referenced group; the effective range
686                // contributed by this position is
687                // `(p.min * inner_min, p.max * inner_max)`.  Since
688                // the inner is unknown — it could collapse to zero
689                // elements at runtime or expand arbitrarily — we
690                // use `(0, ∞)` regardless of `p`'s outer bounds.
691                // Over-accept rather than mis-reject.
692                let _ = p.min_occurs;
693                let _ = p.max_occurs;
694                let wc = Wildcard {
695                    namespaces:                    NamespaceConstraint::Any,
696                    process_contents:              ProcessContents::Lax,
697                    not_qnames:                    Vec::new(),
698                    not_namespaces:                Vec::new(),
699                    not_qname_defined:             false,
700                    not_qname_defined_sibling:     false,
701                };
702                self.compile_repeated(0, MaxOccurs::Unbounded,
703                    |b, from, to| b.add_wildcard_inner(from, wc.clone(), to, true))
704            }
705        }
706    }
707
708    /// Build a fragment for a single transition repeated `min..max`
709    /// times.  Used by both element and wildcard particles.  The
710    /// `add` closure plugs the transition definition into a state pair.
711    fn compile_repeated<F>(
712        &mut self,
713        min: u32,
714        max: MaxOccurs,
715        mut add: F,
716    ) -> Result<Fragment, SchemaCompileError>
717    where
718        F: FnMut(&mut Self, StateId, StateId) -> Result<(), SchemaCompileError>,
719    {
720        // Build a chain of states: s0 -> s1 -> ... -> s_n on the
721        // element name.  States from index `min` onward are exits.
722        // For unbounded: s0 -> s1 -> s1 (self-loop after min).
723        //
724        // Bounded maxOccurs larger than `LARGE_BOUND` is treated as
725        // unbounded: a 9_999_999-state chain blows up DFA size and
726        // memory for negligible practical benefit (the loopback
727        // accepts everything the chain would up to that point and
728        // more — over-accepting beyond max instead of mis-rejecting
729        // before max).  Real schemas almost never use precise upper
730        // bounds higher than a few dozen.
731        const LARGE_BOUND: u32 = 128;
732        let treat_as_unbounded = matches!(max, MaxOccurs::Unbounded)
733            || matches!(max, MaxOccurs::Bounded(n) if n > LARGE_BOUND);
734        let max_n: u32 = match max {
735            MaxOccurs::Unbounded   => u32::MAX, // sentinel
736            MaxOccurs::Bounded(n)  => n,
737        };
738        let chain_len = if treat_as_unbounded {
739            min.max(1)
740        } else {
741            max_n
742        };
743        let chain_len = chain_len.min(LARGE_BOUND);
744
745        let mut state_ids = Vec::with_capacity(chain_len as usize + 1);
746        for _ in 0..=chain_len {
747            state_ids.push(self.new_state());
748        }
749        for i in 0..chain_len as usize {
750            add(self, state_ids[i], state_ids[i + 1])?;
751        }
752        if treat_as_unbounded {
753            // Self-loop on the last state.
754            let last = *state_ids.last().unwrap();
755            add(self, last, last)?;
756        }
757
758        let entries = vec![state_ids[0]];
759        let exits = if min == 0 {
760            // Skippable: entry itself is also an exit.
761            let mut e = vec![state_ids[0]];
762            e.extend_from_slice(&state_ids[1..]);
763            e
764        } else {
765            state_ids[(min as usize).min(state_ids.len() - 1)..].to_vec()
766        };
767        Ok(Fragment { entries, exits })
768    }
769
770    fn compile_sequence(
771        &mut self,
772        particles: &[Particle],
773        subs: &HashMap<QName, Vec<Arc<super::schema::ElementDecl>>>,
774        types: &HashMap<QName, super::schema::TypeRef>,
775    ) -> Result<Fragment, SchemaCompileError> {
776        if particles.is_empty() {
777            // Empty sequence → fragment that accepts immediately.
778            let s = self.new_state();
779            return Ok(Fragment { entries: vec![s], exits: vec![s] });
780        }
781
782        let first = self.compile_particle(&particles[0], subs, types)?;
783        if particles.len() == 1 {
784            return Ok(first);
785        }
786
787        let mut current = first;
788        for p in &particles[1..] {
789            let next = self.compile_particle(p, subs, types)?;
790            current = self.concat(current, next)?;
791        }
792        Ok(current)
793    }
794
795    /// Concatenate two fragments — every exit of the first picks up
796    /// the entries' transitions of the second.
797    fn concat(&mut self, a: Fragment, b: Fragment)
798        -> Result<Fragment, SchemaCompileError>
799    {
800        // For each exit of a, copy each entry of b's outgoing transitions in.
801        for &exit in &a.exits {
802            for &entry in &b.entries {
803                self.copy_outgoing(entry, exit)?;
804            }
805        }
806        // The combined fragment's entries are a's entries.  If a was
807        // skippable (entry ∈ exits), the combined fragment can start
808        // by using b's entries — handled implicitly by the
809        // `merge_into_initial` step.
810        let entries = a.entries.clone();
811        // Combined exits: b's exits, plus a's exits if b is skippable.
812        let mut exits = b.exits.clone();
813        if b.entries.iter().any(|e| b.exits.contains(e)) {
814            for e in &a.exits {
815                if !exits.contains(e) { exits.push(*e); }
816            }
817        }
818        Ok(Fragment { entries, exits })
819    }
820
821    fn compile_choice(
822        &mut self,
823        particles: &[Particle],
824        subs: &HashMap<QName, Vec<Arc<super::schema::ElementDecl>>>,
825        types: &HashMap<QName, super::schema::TypeRef>,
826    ) -> Result<Fragment, SchemaCompileError> {
827        if particles.is_empty() {
828            let s = self.new_state();
829            return Ok(Fragment { entries: vec![s], exits: vec![s] });
830        }
831        let mut entries = Vec::new();
832        let mut exits   = Vec::new();
833        for p in particles {
834            let frag = self.compile_particle(p, subs, types)?;
835            entries.extend(frag.entries);
836            exits.extend(frag.exits);
837        }
838        Ok(Fragment { entries, exits })
839    }
840
841    /// Wrap a fragment in min/max occurrence handling — used when an
842    /// `xs:sequence` or `xs:choice` itself has `minOccurs`/`maxOccurs`
843    /// other than 1.  For v1 we support the common cases (max=1 + min
844    /// ≥ 0; arbitrary min with unbounded max via a self-concat).  Other
845    /// shapes return the unwrapped fragment with a documented note.
846    /// Apply `minOccurs` / `maxOccurs` to a model-group particle.
847    ///
848    /// Strategy: when `minOccurs > 1`, we *concatenate* `min` fresh
849    /// copies of the inner fragment so the validator can't shortcut
850    /// through fewer iterations.  For everything after the minimum we
851    /// fall back to the cheap loopback (treating the tail as
852    /// unbounded) — this can over-accept when `maxOccurs` is bounded,
853    /// but never crashes with state-explosion and avoids the spurious
854    /// UPA conflicts that a full unroll triggers when an inner
855    /// element name overlaps between consecutive iterations.
856    fn repeat_group<F>(
857        &mut self,
858        min: u32,
859        max: MaxOccurs,
860        mut build_inner: F,
861    ) -> Result<Fragment, SchemaCompileError>
862    where
863        F: FnMut(&mut Self) -> Result<Fragment, SchemaCompileError>,
864    {
865        if min <= 1 {
866            let inner = build_inner(self)?;
867            return self.repeat_fragment(inner, min, max);
868        }
869        // Cap the mandatory copy count.  XSD permits arbitrary
870        // values, but anything beyond a handful is either pathological
871        // or wraps a large content model — in both cases we'd rather
872        // accept a few extra (over-permissive) instances than blow up
873        // DFA state count.
874        let required = (min as usize).min(8);
875
876        let mut current = build_inner(self)?;
877        let mut last_entries = current.entries.clone();
878        for _ in 1..required {
879            let next = build_inner(self)?;
880            last_entries = next.entries.clone();
881            current = self.concat(current, next)?;
882        }
883        // After the mandatory copies, loop the final iteration back
884        // to itself so the validator can take any number of
885        // additional rounds — bounded `max` becomes over-permissive
886        // (we accept a few extra iterations the spec forbids) but
887        // we never under-accept.  The loopback uses the *last*
888        // copy's entries (which match the current exits' position
889        // in iteration space) so the targets remain accepting.
890        let _ = max;
891        let pseudo = Fragment { entries: last_entries, exits: current.exits.clone() };
892        self.add_loopback_transitions(&pseudo)?;
893        Ok(current)
894    }
895
896    fn repeat_fragment(
897        &mut self,
898        frag: Fragment,
899        min: u32,
900        max: MaxOccurs,
901    ) -> Result<Fragment, SchemaCompileError> {
902        // Single occurrence — most common case.
903        if min == 1 && matches!(max, MaxOccurs::Bounded(1)) {
904            return Ok(frag);
905        }
906        // Optional (0..=1) — entry doubles as exit.
907        if min == 0 && matches!(max, MaxOccurs::Bounded(1)) {
908            let mut frag = frag;
909            for &e in &frag.entries.clone() {
910                if !frag.exits.contains(&e) {
911                    frag.exits.push(e);
912                }
913            }
914            return Ok(frag);
915        }
916        // Unbounded — splice each entry's outgoing transitions onto
917        // every exit so the fragment can be re-entered.  For min=0
918        // entries are also exits (zero iterations allowed).
919        if matches!(max, MaxOccurs::Unbounded) {
920            let mut frag = frag;
921            self.add_loopback_transitions(&frag)?;
922            if min == 0 {
923                for &e in &frag.entries.clone() {
924                    if !frag.exits.contains(&e) {
925                        frag.exits.push(e);
926                    }
927                }
928            }
929            return Ok(frag);
930        }
931        // Bounded re-iteration (max ≥ 2): exact bound enforcement
932        // would require either an NFA → DFA pass or a state-per-
933        // iteration unroll, both of which interact badly with our
934        // single-transition-per-(state, name) UPA check.  The
935        // pragmatic tradeoff: treat the upper bound as unbounded
936        // (a few extra iterations may slip through) but enforce
937        // the lower bound exactly via the caller's mandatory-copy
938        // concat.  For `min == 0 || min == 1` reps the optional
939        // first iteration also needs entries to be exits.
940        let mut frag = frag;
941        self.add_loopback_transitions(&frag)?;
942        if min == 0 {
943            for &e in &frag.entries.clone() {
944                if !frag.exits.contains(&e) {
945                    frag.exits.push(e);
946                }
947            }
948        }
949        Ok(frag)
950    }
951
952    /// For every (exit, entry) pair in the fragment, copy the entry's
953    /// outgoing element + wildcard transitions onto the exit.  This
954    /// turns the fragment into a Kleene-plus loop.  Skipped when
955    /// entry == exit (the loop is already implicit on that state).
956    fn add_loopback_transitions(&mut self, frag: &Fragment) -> Result<(), SchemaCompileError> {
957        for &exit in &frag.exits {
958            for &entry in &frag.entries {
959                if exit == entry { continue; }
960                let entry_elems  = self.states[entry as usize].on_element.clone();
961                let entry_wilds  = self.states[entry as usize].on_wildcard.clone();
962                let exit_state   = &mut self.states[exit as usize];
963                for t in entry_elems {
964                    if !exit_state.on_element.iter().any(|e| e.name == t.name) {
965                        exit_state.on_element.push(t);
966                    }
967                }
968                for t in entry_wilds {
969                    let already_present = exit_state.on_wildcard.iter()
970                        .any(|other| std::ptr::eq(&other.wc as *const _, &t.wc as *const _));
971                    if !already_present {
972                        exit_state.on_wildcard.push(t);
973                    }
974                }
975            }
976        }
977        Ok(())
978    }
979}
980
981/// Conservative test: does the wildcard's positive namespace
982/// constraint admit `qn`?  An exact `notQName="…"` exclusion is
983/// honoured; `notNamespace` and the `##defined` / `##definedSibling`
984/// tokens require schema context the DFA builder doesn't have, so
985/// we ignore them here — the conservative answer (treat as admitted)
986/// is what makes the UPA check report the clash rather than miss it.
987fn wildcard_might_admit(
988    wc: &Wildcard,
989    qn: &QName,
990    defined_siblings: &[QName],
991    target_ns: Option<&str>,
992) -> bool {
993    use NamespaceConstraint::*;
994    // notQName= literal QNames is the one exclusion we can apply
995    // unambiguously at compile time.
996    if !qn.local.is_empty() {
997        for forbidden in &wc.not_qnames {
998            if forbidden.namespace == qn.namespace && forbidden.local == qn.local {
999                return false;
1000            }
1001        }
1002    }
1003    // `##definedSibling` excludes the names that the source content
1004    // model declares as siblings of this wildcard — the same set
1005    // the validator consults at instance time.
1006    if wc.not_qname_defined_sibling && defined_siblings.iter().any(|s| s == qn) {
1007        return false;
1008    }
1009    let ns = qn.namespace.as_deref();
1010    match &wc.namespaces {
1011        Any => true,
1012        Other => ns.is_some() && ns != target_ns,
1013        List(allowed) => allowed.iter().any(|item| match (item.as_deref(), ns) {
1014            (None,    None)    => true,
1015            (Some(a), Some(b)) => a == b,
1016            _ => false,
1017        }),
1018    }
1019}
1020
1021/// Conservative overlap predicate between two wildcards' namespace
1022/// constraints: returns true when there exists some namespace that
1023/// both constraints admit.  Used to detect UPA violations between
1024/// distinct wildcards at the same DFA state.  Notes:
1025///
1026/// * `notNamespace` / `notQName` exclusions are not subtracted here —
1027///   excluding from one side without excluding from the other can
1028///   still leave overlap, so the conservative answer is "overlap".
1029fn wildcards_overlap(a: &Wildcard, b: &Wildcard, target_ns: Option<&str>) -> bool {
1030    use NamespaceConstraint::*;
1031    // `##other` admits a list entry iff that entry is some non-target
1032    // URI.  An entry equal to the targetNamespace or `None`
1033    // (no-namespace) is excluded from `##other`.
1034    fn list_admits_other(list: &[Option<Arc<str>>], target_ns: Option<&str>) -> bool {
1035        list.iter().any(|entry| match entry.as_deref() {
1036            None      => false,
1037            Some(ns)  => target_ns != Some(ns),
1038        })
1039    }
1040    match (&a.namespaces, &b.namespaces) {
1041        (Any, _) | (_, Any) => true,
1042        (Other, Other)      => true,
1043        (Other, List(l)) | (List(l), Other) => list_admits_other(l, target_ns),
1044        (List(la), List(lb)) => la.iter().any(|x| lb.iter().any(|y| x == y)),
1045    }
1046}
1047
1048// ── helpers shared with the validator ────────────────────────────────────────
1049
1050/// Full XSD 1.1 §3.10.4 wildcard-match decision: does the wildcard
1051/// admit `qn` once positive namespace and all `notNamespace`,
1052/// `notQName`, `##defined`, and `##definedSibling` exclusions are
1053/// applied?
1054///
1055/// `is_defined` is consulted only when the wildcard's `notQName`
1056/// carries `##defined` (XSD 1.1: "any element/attribute with a
1057/// top-level declaration of this kind in the schema").  Element
1058/// wildcards pass `|q| schema.element(q).is_some()`; attribute
1059/// wildcards pass `|q| schema.attribute(q).is_some()`.
1060///
1061/// `is_sibling` is consulted only when `notQName` carries
1062/// `##definedSibling` — the set of element/attribute names declared
1063/// as siblings in the enclosing complex type.  For element wildcards
1064/// the DFA caches this on [`Dfa::defined_siblings`]; for attribute
1065/// wildcards the caller walks `ComplexType::attributes`.
1066pub(super) fn wildcard_admits(
1067    wc:         &Wildcard,
1068    qn:         &QName,
1069    target_ns:  Option<&str>,
1070    is_defined: impl FnOnce(&QName) -> bool,
1071    is_sibling: impl FnOnce(&QName) -> bool,
1072) -> bool {
1073    let ns = qn.namespace.as_deref();
1074    let ns_ok = match &wc.namespaces {
1075        NamespaceConstraint::Any   => true,
1076        NamespaceConstraint::Other => ns != target_ns && ns.is_some(),
1077        NamespaceConstraint::List(allowed) => allowed.iter().any(|item| {
1078            match (item.as_deref(), ns) {
1079                (None,    None)    => true,
1080                (Some(a), Some(b)) => a == b,
1081                _ => false,
1082            }
1083        }),
1084    };
1085    if !ns_ok { return false; }
1086    for item in &wc.not_namespaces {
1087        match (item.as_deref(), ns) {
1088            (None,    None)             => return false,
1089            (Some(a), Some(b)) if a == b => return false,
1090            _ => {}
1091        }
1092    }
1093    if !qn.local.is_empty() {
1094        for forbidden in &wc.not_qnames {
1095            if forbidden.namespace == qn.namespace && forbidden.local == qn.local {
1096                return false;
1097            }
1098        }
1099    }
1100    if wc.not_qname_defined && is_defined(qn) {
1101        return false;
1102    }
1103    if wc.not_qname_defined_sibling && is_sibling(qn) {
1104        return false;
1105    }
1106    true
1107}
1108
1109/// Collect the names of every `Term::Element` particle reachable from
1110/// `root` — the static `##definedSibling` set for the enclosing
1111/// complex type's content model.  Walks through nested groups; does
1112/// not expand substitution-group members (those are independent
1113/// top-level declarations, not sibling declarations of this type).
1114fn collect_defined_siblings(root: &Particle) -> Arc<[QName]> {
1115    let mut out: Vec<QName> = Vec::new();
1116    fn walk(p: &Particle, out: &mut Vec<QName>) {
1117        match &p.term {
1118            Term::Element(decl) => {
1119                if !out.iter().any(|n| n == &decl.name) {
1120                    out.push(decl.name.clone());
1121                }
1122            }
1123            Term::Group { particles, .. } => {
1124                for c in particles.iter() { walk(c, out); }
1125            }
1126            Term::Wildcard(_) | Term::GroupRef(_) => {}
1127        }
1128    }
1129    walk(root, &mut out);
1130    out.sort_by(|a, b| (a.namespace.as_deref(), a.local.as_ref())
1131        .cmp(&(b.namespace.as_deref(), b.local.as_ref())));
1132    out.into()
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use super::*;
1138    use crate::xsd::schema::{ElementDecl, BlockSet};
1139    use crate::xsd::types::SimpleType;
1140    use crate::xsd::BuiltinType;
1141
1142    fn elem_decl(name: &str) -> Arc<ElementDecl> {
1143        Arc::new(ElementDecl {
1144            name: QName::new(None, name),
1145            type_def: super::super::TypeRef::Simple(Arc::new(SimpleType::of_builtin(BuiltinType::String))),
1146            nillable: false,
1147            default: None, fixed: None,
1148            abstract_: false, substitution_group: None,
1149            block: BlockSet::default(), final_: BlockSet::default(),
1150            identity: Vec::new(),
1151        })
1152    }
1153
1154    fn one_of(name: &str) -> Particle {
1155        Particle {
1156            min_occurs: 1, max_occurs: MaxOccurs::Bounded(1),
1157            term: Term::Element(elem_decl(name)),
1158        }
1159    }
1160
1161    fn unbounded(name: &str) -> Particle {
1162        Particle {
1163            min_occurs: 1, max_occurs: MaxOccurs::Unbounded,
1164            term: Term::Element(elem_decl(name)),
1165        }
1166    }
1167
1168    fn optional(name: &str) -> Particle {
1169        Particle {
1170            min_occurs: 0, max_occurs: MaxOccurs::Bounded(1),
1171            term: Term::Element(elem_decl(name)),
1172        }
1173    }
1174
1175    fn sequence(particles: Vec<Particle>) -> ContentModel {
1176        ContentModel::Complex {
1177            root: Particle {
1178                min_occurs: 1, max_occurs: MaxOccurs::Bounded(1),
1179                term: Term::Group { kind: GroupKind::Sequence, particles: particles.into() },
1180            },
1181            mixed: false,
1182        }
1183    }
1184
1185    fn choice(particles: Vec<Particle>) -> ContentModel {
1186        ContentModel::Complex {
1187            root: Particle {
1188                min_occurs: 1, max_occurs: MaxOccurs::Bounded(1),
1189                term: Term::Group { kind: GroupKind::Choice, particles: particles.into() },
1190            },
1191            mixed: false,
1192        }
1193    }
1194
1195    fn matcher(cm: &ContentModel) -> Arc<Dfa> {
1196        match build_matcher(cm, &HashMap::new(), &HashMap::new()).unwrap() {
1197            ContentMatcher::Dfa(d) => d,
1198            _ => panic!("expected DFA"),
1199        }
1200    }
1201
1202    fn run<'a>(dfa: &Dfa, names: impl IntoIterator<Item = &'a str>) -> Option<bool> {
1203        let mut s = dfa.initial;
1204        for n in names {
1205            let qn = QName::new(None, n);
1206            let step = dfa.step(s, &qn, |wc, qn| {
1207                wildcard_admits(wc, qn, None, |_| false, |q| {
1208                    dfa.defined_siblings.iter().any(|n| n == q)
1209                })
1210            });
1211            match step {
1212                Some(DfaTransition::Element { next, .. })  => s = next,
1213                Some(DfaTransition::Wildcard { next, .. }) => s = next,
1214                None => return None,
1215            }
1216        }
1217        Some(dfa.is_accept(s))
1218    }
1219
1220    #[test]
1221    fn sequence_single_element() {
1222        let cm = sequence(vec![one_of("a")]);
1223        let dfa = matcher(&cm);
1224        assert_eq!(run(&dfa, ["a"]), Some(true));
1225        assert_eq!(run(&dfa, []), Some(false));        // missing
1226        assert_eq!(run(&dfa, ["a", "a"]), None);       // too many
1227        assert_eq!(run(&dfa, ["b"]), None);            // wrong name
1228    }
1229
1230    #[test]
1231    fn sequence_multiple_elements() {
1232        let cm = sequence(vec![one_of("a"), one_of("b"), one_of("c")]);
1233        let dfa = matcher(&cm);
1234        assert_eq!(run(&dfa, ["a", "b", "c"]), Some(true));
1235        assert_eq!(run(&dfa, ["a", "b"]), Some(false)); // c missing
1236        assert_eq!(run(&dfa, ["a", "c"]), None);        // wrong order
1237        assert_eq!(run(&dfa, ["b", "a", "c"]), None);   // wrong order
1238    }
1239
1240    #[test]
1241    fn sequence_with_unbounded() {
1242        let cm = sequence(vec![unbounded("item")]);
1243        let dfa = matcher(&cm);
1244        assert_eq!(run(&dfa, ["item"]), Some(true));
1245        assert_eq!(run(&dfa, ["item", "item", "item"]), Some(true));
1246        assert_eq!(run(&dfa, []), Some(false));         // min 1 unmet
1247    }
1248
1249    #[test]
1250    fn sequence_with_optional_tail() {
1251        let cm = sequence(vec![one_of("a"), optional("b")]);
1252        let dfa = matcher(&cm);
1253        assert_eq!(run(&dfa, ["a"]), Some(true));
1254        assert_eq!(run(&dfa, ["a", "b"]), Some(true));
1255        assert_eq!(run(&dfa, []), Some(false));
1256    }
1257
1258    #[test]
1259    fn choice_first_branch() {
1260        let cm = choice(vec![one_of("a"), one_of("b")]);
1261        let dfa = matcher(&cm);
1262        assert_eq!(run(&dfa, ["a"]), Some(true));
1263        assert_eq!(run(&dfa, ["b"]), Some(true));
1264        assert_eq!(run(&dfa, ["c"]), None);
1265        assert_eq!(run(&dfa, ["a", "b"]), None); // can't take both branches
1266    }
1267
1268    #[test]
1269    fn upa_violation_is_compile_error() {
1270        // Two particles in a choice with the same name → ambiguous.
1271        let cm = choice(vec![one_of("a"), one_of("a")]);
1272        let r = build_matcher(&cm, &HashMap::new(), &HashMap::new());
1273        assert!(r.is_err(), "expected UPA error");
1274    }
1275}