Skip to main content

xsd_schema/validation/
identity_parser.rs

1//! Hand-written recursive-descent parser for identity-constraint XPath.
2//!
3//! Parses the restricted XPath subset used in XSD `<selector>` and `<field>` expressions
4//! over pre-lexed tokens from [`IdXPathLexer`].
5//!
6//! Grammar:
7//! ```text
8//! Selector ::= Path ( '|' Path )*
9//! Field    ::= Path ( '|' Path )*
10//! Path     ::= ('.' '//')? Step ( '/' Step )*
11//! Step     ::= '.' | '@' NameTest | AxisName '::' NameTest | NameTest
12//! NameTest ::= '*' | NCName ':' '*' | NCName ':' NCName | NCName
13//! ```
14
15#![allow(dead_code)]
16
17use crate::ids::NameId;
18use crate::namespace::context::NamespaceContextSnapshot;
19use crate::namespace::table::NameTable;
20
21use super::asttree::{AstPath, AstStep, Asttree, IdentityXPathError, NameTest, NamespaceMatch};
22use super::identity_lexer::{IdXPathLexer, IdXPathSpanned, IdXPathToken};
23
24/// Recursive-descent parser for identity-constraint XPath expressions.
25pub(crate) struct IdXPathParser<'a> {
26    /// Pre-lexed token buffer.
27    tokens: Vec<IdXPathSpanned<'a>>,
28    /// Current position in the token buffer.
29    pos: usize,
30    /// Namespace context snapshot (for prefix resolution).
31    ns_snapshot: &'a NamespaceContextSnapshot,
32    /// Name table (for string interning).
33    name_table: &'a NameTable,
34    /// Resolved default namespace for unprefixed element names.
35    unprefixed_ns: NamespaceMatch,
36}
37
38impl<'a> IdXPathParser<'a> {
39    /// Create a new parser by lexing the input string.
40    pub fn new(
41        input: &'a str,
42        ns_snapshot: &'a NamespaceContextSnapshot,
43        name_table: &'a NameTable,
44        unprefixed_ns: NamespaceMatch,
45    ) -> Result<Self, IdentityXPathError> {
46        let tokens: Vec<_> = IdXPathLexer::new(input).collect::<Result<Vec<_>, _>>()?;
47        Ok(Self {
48            tokens,
49            pos: 0,
50            ns_snapshot,
51            name_table,
52            unprefixed_ns,
53        })
54    }
55
56    // --- Helpers ---
57
58    /// Peek at the current token.
59    fn peek(&self) -> Option<&IdXPathToken<'a>> {
60        self.tokens.get(self.pos).map(|(_, tok, _)| tok)
61    }
62
63    /// Peek at the token `n` positions ahead of current.
64    fn peek_at(&self, n: usize) -> Option<&IdXPathToken<'a>> {
65        self.tokens.get(self.pos + n).map(|(_, tok, _)| tok)
66    }
67
68    /// Get the byte position of the current token (or end of input).
69    fn current_position(&self) -> usize {
70        self.tokens
71            .get(self.pos)
72            .map(|(start, _, _)| *start)
73            .unwrap_or_else(|| self.tokens.last().map(|(_, _, end)| *end).unwrap_or(0))
74    }
75
76    /// Advance past the current token, returning it.
77    fn advance(&mut self) -> Option<IdXPathSpanned<'a>> {
78        if self.pos < self.tokens.len() {
79            let tok = self.tokens[self.pos];
80            self.pos += 1;
81            Some(tok)
82        } else {
83            None
84        }
85    }
86
87    /// Consume the current token if it matches `expected`, or return an error.
88    fn eat(
89        &mut self,
90        expected: IdXPathToken<'_>,
91    ) -> Result<IdXPathSpanned<'a>, IdentityXPathError> {
92        if self.peek() == Some(&expected) {
93            Ok(self.advance().unwrap())
94        } else {
95            Err(IdentityXPathError::Parse {
96                message: format!("expected `{expected:?}`, found {:?}", self.peek()),
97                position: self.current_position(),
98            })
99        }
100    }
101
102    /// Check if we've consumed all tokens.
103    fn at_end(&self) -> bool {
104        self.pos >= self.tokens.len()
105    }
106
107    /// Resolve a prefix string to a namespace NameId.
108    fn resolve_prefix(&self, prefix: &str, pos: usize) -> Result<NameId, IdentityXPathError> {
109        let prefix_id = self.name_table.add(prefix);
110        self.ns_snapshot.resolve_prefix(prefix_id).ok_or_else(|| {
111            IdentityXPathError::UnboundPrefix {
112                prefix: prefix.to_string(),
113                position: pos,
114            }
115        })
116    }
117
118    // --- Grammar productions ---
119
120    /// Parse a selector expression: `Path ( '|' Path )*`.
121    ///
122    /// Rejects any attribute steps.
123    pub fn parse_selector(&mut self) -> Result<Asttree, IdentityXPathError> {
124        if self.at_end() {
125            return Err(IdentityXPathError::Parse {
126                message: "empty selector expression".into(),
127                position: 0,
128            });
129        }
130
131        let mut paths = vec![self.parse_path(false)?];
132
133        while self.peek() == Some(&IdXPathToken::Pipe) {
134            self.advance(); // consume '|'
135            paths.push(self.parse_path(false)?);
136        }
137
138        if !self.at_end() {
139            return Err(IdentityXPathError::Parse {
140                message: format!("unexpected token {:?} after expression", self.peek()),
141                position: self.current_position(),
142            });
143        }
144
145        Ok(Asttree { paths })
146    }
147
148    /// Parse a field expression: `Path ( '|' Path )*`.
149    ///
150    /// Allows an optional final attribute step in each path.
151    pub fn parse_field(&mut self) -> Result<Asttree, IdentityXPathError> {
152        if self.at_end() {
153            return Err(IdentityXPathError::Parse {
154                message: "empty field expression".into(),
155                position: 0,
156            });
157        }
158
159        let mut paths = vec![self.parse_path(true)?];
160
161        while self.peek() == Some(&IdXPathToken::Pipe) {
162            self.advance(); // consume '|'
163            paths.push(self.parse_path(true)?);
164        }
165
166        if !self.at_end() {
167            return Err(IdentityXPathError::Parse {
168                message: format!("unexpected token {:?} after expression", self.peek()),
169                position: self.current_position(),
170            });
171        }
172
173        Ok(Asttree { paths })
174    }
175
176    /// Parse a single path: `('.' '//')? Step ( '/' Step )*`.
177    fn parse_path(&mut self, allow_attr: bool) -> Result<AstPath, IdentityXPathError> {
178        let mut descendant = false;
179        let mut steps = Vec::new();
180
181        // Check for leading '.' followed by '//' or '/'
182        if self.peek() == Some(&IdXPathToken::Dot) {
183            if self.peek_at(1) == Some(&IdXPathToken::DoubleSlash) {
184                // .// prefix
185                self.advance(); // consume '.'
186                self.advance(); // consume '//'
187                descendant = true;
188            } else if self.peek_at(1) == Some(&IdXPathToken::Slash) {
189                // ./step — '.' is a self-node step, then '/' + more steps
190                let pos = self.current_position();
191                self.advance(); // consume '.'
192                steps.push(AstStep::SelfNode);
193                // The '/' will be consumed in the loop below
194                // But first check this isn't a trailing slash
195                if self.peek() == Some(&IdXPathToken::Slash) && self.peek_at(1).is_none() {
196                    return Err(IdentityXPathError::Parse {
197                        message: "trailing `/` in path".into(),
198                        position: self.current_position(),
199                    });
200                }
201                _ = pos;
202            } else if self.peek_at(1).is_none() || self.peek_at(1) == Some(&IdXPathToken::Pipe) {
203                // Bare '.' — self node
204                self.advance(); // consume '.'
205                steps.push(AstStep::SelfNode);
206                return Ok(AstPath { descendant, steps });
207            } else {
208                return Err(IdentityXPathError::Parse {
209                    message: format!("unexpected token {:?} after `.`", self.peek_at(1)),
210                    position: self.current_position(),
211                });
212            }
213        }
214
215        // Check for absolute path (leading '/')
216        if steps.is_empty()
217            && (self.peek() == Some(&IdXPathToken::Slash)
218                || self.peek() == Some(&IdXPathToken::DoubleSlash))
219        {
220            return Err(IdentityXPathError::Parse {
221                message: "absolute paths are not allowed in identity-constraint XPath".into(),
222                position: self.current_position(),
223            });
224        }
225
226        // Parse first step (if not already consumed as SelfNode)
227        if steps.is_empty() {
228            steps.push(self.parse_step(allow_attr)?);
229        }
230
231        // Parse remaining steps separated by '/'
232        while self.peek() == Some(&IdXPathToken::Slash) {
233            // Check for trailing slash
234            if self.peek_at(1).is_none() {
235                return Err(IdentityXPathError::Parse {
236                    message: "trailing `/` in path".into(),
237                    position: self.current_position(),
238                });
239            }
240            self.advance(); // consume '/'
241            steps.push(self.parse_step(allow_attr)?);
242        }
243
244        // Validate attribute placement: attribute step must be last
245        for (i, step) in steps.iter().enumerate() {
246            if matches!(step, AstStep::Attribute(_)) && i < steps.len() - 1 {
247                return Err(IdentityXPathError::Restriction {
248                    message: "attribute step must be the last step in a path".into(),
249                    position: self.current_position(),
250                });
251            }
252        }
253
254        Ok(AstPath { descendant, steps })
255    }
256
257    /// Parse a single step: `.` | `@NameTest` | axis `::` NameTest | NameTest.
258    fn parse_step(&mut self, allow_attr: bool) -> Result<AstStep, IdentityXPathError> {
259        let pos = self.current_position();
260
261        match self.peek() {
262            Some(IdXPathToken::Dot) => {
263                self.advance();
264                Ok(AstStep::SelfNode)
265            }
266            Some(IdXPathToken::At) => {
267                if !allow_attr {
268                    return Err(IdentityXPathError::Restriction {
269                        message: "attribute axis is not allowed in selector expressions".into(),
270                        position: pos,
271                    });
272                }
273                self.advance(); // consume '@'
274                let name_test = self.parse_name_test(true)?;
275                Ok(AstStep::Attribute(name_test))
276            }
277            Some(IdXPathToken::NCName(_)) => {
278                // Check for explicit axis: NCName '::'
279                if self.peek_at(1) == Some(&IdXPathToken::DoubleColon) {
280                    self.parse_explicit_axis(allow_attr)
281                } else {
282                    let name_test = self.parse_name_test(false)?;
283                    Ok(AstStep::Child(name_test))
284                }
285            }
286            Some(IdXPathToken::Star) => {
287                let name_test = self.parse_name_test(false)?;
288                Ok(AstStep::Child(name_test))
289            }
290            Some(tok) => Err(IdentityXPathError::Parse {
291                message: format!("unexpected token `{tok:?}` at start of step"),
292                position: pos,
293            }),
294            None => Err(IdentityXPathError::Parse {
295                message: "unexpected end of expression".into(),
296                position: pos,
297            }),
298        }
299    }
300
301    /// Parse an explicit axis step: `child::NameTest` or `attribute::NameTest`.
302    fn parse_explicit_axis(&mut self, allow_attr: bool) -> Result<AstStep, IdentityXPathError> {
303        let (pos, tok, _) = self.advance().unwrap(); // consume axis name NCName
304        let axis_name = match tok {
305            IdXPathToken::NCName(name) => name,
306            _ => unreachable!(),
307        };
308        self.advance(); // consume '::'
309
310        match axis_name {
311            "child" => {
312                let name_test = self.parse_name_test(false)?;
313                Ok(AstStep::Child(name_test))
314            }
315            "attribute" => {
316                if !allow_attr {
317                    return Err(IdentityXPathError::Restriction {
318                        message: "attribute axis is not allowed in selector expressions".into(),
319                        position: pos,
320                    });
321                }
322                let name_test = self.parse_name_test(true)?;
323                Ok(AstStep::Attribute(name_test))
324            }
325            other => Err(IdentityXPathError::Parse {
326                message: format!(
327                    "unsupported axis `{other}` in identity-constraint XPath \
328                     (only `child` and `attribute` are allowed)"
329                ),
330                position: pos,
331            }),
332        }
333    }
334
335    /// Parse a name test: `*` | `NCName:*` | `NCName:NCName` | `NCName`.
336    ///
337    /// When `is_attribute` is true, unprefixed names are always in no namespace
338    /// (per XPath: `xpathDefaultNamespace` only affects element names, not attributes).
339    fn parse_name_test(&mut self, is_attribute: bool) -> Result<NameTest, IdentityXPathError> {
340        let pos = self.current_position();
341
342        match self.peek() {
343            Some(IdXPathToken::Star) => {
344                self.advance();
345                Ok(NameTest::Wildcard)
346            }
347            Some(IdXPathToken::NCName(_)) => {
348                let (start, tok, ncname_end) = self.advance().unwrap();
349                let first_name = match tok {
350                    IdXPathToken::NCName(n) => n,
351                    _ => unreachable!(),
352                };
353
354                // Check for ':' (namespace separator)
355                // Reject whitespace between NCName and ':' (e.g., "ns :*" is invalid)
356                if self.peek() == Some(&IdXPathToken::Colon) {
357                    let (colon_start, _, colon_end) = self.tokens[self.pos];
358                    if colon_start != ncname_end {
359                        return Err(IdentityXPathError::Parse {
360                            message: "whitespace is not allowed between namespace prefix and `:`"
361                                .into(),
362                            position: ncname_end,
363                        });
364                    }
365                    self.advance(); // consume ':'
366
367                    match self.peek() {
368                        Some(IdXPathToken::Star) => {
369                            // ns:*
370                            let (star_start, _, _) = self.tokens[self.pos];
371                            if star_start != colon_end {
372                                return Err(IdentityXPathError::Parse {
373                                    message: "whitespace is not allowed between `:` and `*`".into(),
374                                    position: colon_end,
375                                });
376                            }
377                            self.advance();
378                            let ns_id = self.resolve_prefix(first_name, start)?;
379                            Ok(NameTest::NamespaceWildcard(ns_id))
380                        }
381                        Some(IdXPathToken::NCName(_)) => {
382                            // ns:local
383                            let (local_start, _, _) = self.tokens[self.pos];
384                            if local_start != colon_end {
385                                return Err(IdentityXPathError::Parse {
386                                    message: "whitespace is not allowed between `:` and local name"
387                                        .into(),
388                                    position: colon_end,
389                                });
390                            }
391                            let (_, tok2, _) = self.advance().unwrap();
392                            let local = match tok2 {
393                                IdXPathToken::NCName(n) => n,
394                                _ => unreachable!(),
395                            };
396                            let ns_id = self.resolve_prefix(first_name, start)?;
397                            let local_id = self.name_table.add(local);
398                            Ok(NameTest::QName {
399                                namespace: NamespaceMatch::Exact(ns_id),
400                                local_name: local_id,
401                            })
402                        }
403                        _ => Err(IdentityXPathError::Parse {
404                            message: "expected name or `*` after `:`".into(),
405                            position: self.current_position(),
406                        }),
407                    }
408                } else {
409                    // Unprefixed name: attributes are always in no namespace,
410                    // elements use the xpathDefaultNamespace cascade.
411                    let local_id = self.name_table.add(first_name);
412                    let ns = if is_attribute {
413                        NamespaceMatch::NoNamespace
414                    } else {
415                        self.unprefixed_ns
416                    };
417                    Ok(NameTest::QName {
418                        namespace: ns,
419                        local_name: local_id,
420                    })
421                }
422            }
423            _ => Err(IdentityXPathError::Parse {
424                message: format!("expected name or `*`, found {:?}", self.peek()),
425                position: pos,
426            }),
427        }
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use crate::namespace::context::NamespaceContextSnapshot;
435    use crate::namespace::table::NameTable;
436
437    /// Helper: create a snapshot with a single prefix binding.
438    fn snapshot_with_prefix(
439        table: &NameTable,
440        prefix: &str,
441        uri: &str,
442    ) -> NamespaceContextSnapshot {
443        let prefix_id = table.add(prefix);
444        let uri_id = table.add(uri);
445        NamespaceContextSnapshot {
446            default_ns: None,
447            bindings: vec![(prefix_id, uri_id)],
448        }
449    }
450
451    /// Helper: compile a selector with no namespace context.
452    fn compile_selector(input: &str) -> Result<Asttree, IdentityXPathError> {
453        use crate::schema::model::XsdVersion;
454        let table = NameTable::new();
455        let snapshot = NamespaceContextSnapshot::default();
456        Asttree::compile_selector(input, &snapshot, &table, None, None, None, XsdVersion::V1_1)
457    }
458
459    /// Helper: compile a field with no namespace context.
460    fn compile_field(input: &str) -> Result<Asttree, IdentityXPathError> {
461        use crate::schema::model::XsdVersion;
462        let table = NameTable::new();
463        let snapshot = NamespaceContextSnapshot::default();
464        Asttree::compile_field(input, &snapshot, &table, None, None, None, XsdVersion::V1_1)
465    }
466
467    // --- Parser tests ---
468
469    #[test]
470    fn simple_child() {
471        let tree = compile_selector("foo").unwrap();
472        assert_eq!(tree.paths.len(), 1);
473        let path = &tree.paths[0];
474        assert!(!path.descendant);
475        assert_eq!(path.steps.len(), 1);
476        match &path.steps[0] {
477            AstStep::Child(NameTest::QName { namespace, .. }) => {
478                assert_eq!(*namespace, NamespaceMatch::NoNamespace);
479            }
480            other => panic!("expected Child(QName), got {other:?}"),
481        }
482    }
483
484    #[test]
485    fn multi_step() {
486        let tree = compile_selector("foo/bar").unwrap();
487        assert_eq!(tree.paths.len(), 1);
488        assert_eq!(tree.paths[0].steps.len(), 2);
489        assert!(matches!(&tree.paths[0].steps[0], AstStep::Child(_)));
490        assert!(matches!(&tree.paths[0].steps[1], AstStep::Child(_)));
491    }
492
493    #[test]
494    fn descendant_prefix() {
495        let tree = compile_selector(".//foo").unwrap();
496        assert_eq!(tree.paths.len(), 1);
497        assert!(tree.paths[0].descendant);
498        assert_eq!(tree.paths[0].steps.len(), 1);
499    }
500
501    #[test]
502    fn self_then_child() {
503        let tree = compile_selector("./foo").unwrap();
504        assert_eq!(tree.paths.len(), 1);
505        let path = &tree.paths[0];
506        assert!(!path.descendant);
507        assert_eq!(path.steps.len(), 2);
508        assert_eq!(path.steps[0], AstStep::SelfNode);
509        assert!(matches!(&path.steps[1], AstStep::Child(_)));
510    }
511
512    #[test]
513    fn union() {
514        let tree = compile_selector("a|b|c").unwrap();
515        assert_eq!(tree.paths.len(), 3);
516    }
517
518    #[test]
519    fn wildcard() {
520        let tree = compile_selector("*").unwrap();
521        assert_eq!(tree.paths[0].steps.len(), 1);
522        assert!(matches!(
523            &tree.paths[0].steps[0],
524            AstStep::Child(NameTest::Wildcard)
525        ));
526    }
527
528    #[test]
529    fn ns_wildcard() {
530        use crate::schema::model::XsdVersion;
531        let table = NameTable::new();
532        let snapshot = snapshot_with_prefix(&table, "ns", "http://example.com");
533        let tree = Asttree::compile_selector(
534            "ns:*",
535            &snapshot,
536            &table,
537            None,
538            None,
539            None,
540            XsdVersion::V1_1,
541        )
542        .unwrap();
543        assert!(matches!(
544            &tree.paths[0].steps[0],
545            AstStep::Child(NameTest::NamespaceWildcard(_))
546        ));
547    }
548
549    #[test]
550    fn prefixed_qname() {
551        use crate::schema::model::XsdVersion;
552        let table = NameTable::new();
553        let snapshot = snapshot_with_prefix(&table, "ns", "http://example.com");
554        let ns_id = table.add("http://example.com");
555        let tree = Asttree::compile_selector(
556            "ns:foo",
557            &snapshot,
558            &table,
559            None,
560            None,
561            None,
562            XsdVersion::V1_1,
563        )
564        .unwrap();
565        match &tree.paths[0].steps[0] {
566            AstStep::Child(NameTest::QName {
567                namespace: NamespaceMatch::Exact(ns),
568                local_name,
569            }) => {
570                assert_eq!(*ns, ns_id);
571                assert_eq!(table.resolve(*local_name), "foo");
572            }
573            other => panic!("expected Child(QName{{Exact, foo}}), got {other:?}"),
574        }
575    }
576
577    #[test]
578    fn explicit_child_axis() {
579        let tree = compile_selector("child::foo").unwrap();
580        assert_eq!(tree.paths[0].steps.len(), 1);
581        assert!(matches!(&tree.paths[0].steps[0], AstStep::Child(_)));
582    }
583
584    #[test]
585    fn explicit_attr_field() {
586        let tree = compile_field("attribute::bar").unwrap();
587        assert_eq!(tree.paths[0].steps.len(), 1);
588        assert!(matches!(&tree.paths[0].steps[0], AstStep::Attribute(_)));
589    }
590
591    #[test]
592    fn attr_shorthand() {
593        let tree = compile_field("@bar").unwrap();
594        assert_eq!(tree.paths[0].steps.len(), 1);
595        assert!(matches!(&tree.paths[0].steps[0], AstStep::Attribute(_)));
596    }
597
598    #[test]
599    fn field_path_with_attr() {
600        let tree = compile_field("foo/@bar").unwrap();
601        assert_eq!(tree.paths[0].steps.len(), 2);
602        assert!(matches!(&tree.paths[0].steps[0], AstStep::Child(_)));
603        assert!(matches!(&tree.paths[0].steps[1], AstStep::Attribute(_)));
604    }
605
606    #[test]
607    fn complex_field() {
608        let tree = compile_field(".//a/b/@c").unwrap();
609        let path = &tree.paths[0];
610        assert!(path.descendant);
611        assert_eq!(path.steps.len(), 3);
612        assert!(matches!(&path.steps[0], AstStep::Child(_)));
613        assert!(matches!(&path.steps[1], AstStep::Child(_)));
614        assert!(matches!(&path.steps[2], AstStep::Attribute(_)));
615    }
616
617    // --- Rejection tests ---
618
619    #[test]
620    fn reject_attr_in_selector() {
621        let err = compile_selector("@foo").unwrap_err();
622        assert!(matches!(err, IdentityXPathError::Restriction { .. }));
623    }
624
625    #[test]
626    fn reject_attr_axis_in_selector() {
627        let err = compile_selector("attribute::foo").unwrap_err();
628        assert!(matches!(err, IdentityXPathError::Restriction { .. }));
629    }
630
631    #[test]
632    fn reject_attr_not_last() {
633        let err = compile_field("@foo/bar").unwrap_err();
634        assert!(matches!(err, IdentityXPathError::Restriction { .. }));
635    }
636
637    #[test]
638    fn reject_unsupported_axis() {
639        let err = compile_selector("parent::foo").unwrap_err();
640        assert!(matches!(err, IdentityXPathError::Parse { .. }));
641    }
642
643    #[test]
644    fn reject_absolute_path() {
645        let err = compile_selector("/foo").unwrap_err();
646        assert!(matches!(err, IdentityXPathError::Parse { .. }));
647    }
648
649    #[test]
650    fn reject_empty() {
651        let err = compile_selector("").unwrap_err();
652        assert!(matches!(err, IdentityXPathError::Parse { .. }));
653    }
654
655    #[test]
656    fn reject_trailing_slash() {
657        let err = compile_selector("foo/").unwrap_err();
658        assert!(matches!(err, IdentityXPathError::Parse { .. }));
659    }
660
661    #[test]
662    fn reject_unbound_prefix() {
663        let err = compile_selector("unknown:foo").unwrap_err();
664        assert!(matches!(err, IdentityXPathError::UnboundPrefix { .. }));
665    }
666
667    #[test]
668    fn reject_predicate() {
669        let err = compile_selector("foo[1]").unwrap_err();
670        assert!(matches!(err, IdentityXPathError::Lex(_)));
671    }
672
673    #[test]
674    fn reject_parent_axis() {
675        let err = compile_selector("foo/..").unwrap_err();
676        assert!(matches!(err, IdentityXPathError::Lex(_)));
677    }
678
679    // --- Attribute namespace tests ---
680
681    #[test]
682    fn unprefixed_attr_ignores_xpath_default_ns() {
683        // Even with xpathDefaultNamespace set, unprefixed attribute names
684        // must resolve to no-namespace (XPath static context rule).
685        use crate::schema::model::XsdVersion;
686        let table = NameTable::new();
687        let snapshot = NamespaceContextSnapshot::default();
688        let tree = Asttree::compile_field(
689            "@id",
690            &snapshot,
691            &table,
692            Some("http://example.com/default"),
693            None,
694            None,
695            XsdVersion::V1_1,
696        )
697        .unwrap();
698        match &tree.paths[0].steps[0] {
699            AstStep::Attribute(NameTest::QName { namespace, .. }) => {
700                assert_eq!(*namespace, NamespaceMatch::NoNamespace);
701            }
702            other => panic!("expected Attribute(QName{{NoNamespace, ..}}), got {other:?}"),
703        }
704    }
705
706    #[test]
707    fn unprefixed_attr_via_explicit_axis_ignores_xpath_default_ns() {
708        use crate::schema::model::XsdVersion;
709        let table = NameTable::new();
710        let snapshot = NamespaceContextSnapshot::default();
711        let tree = Asttree::compile_field(
712            "attribute::id",
713            &snapshot,
714            &table,
715            Some("http://example.com/default"),
716            None,
717            None,
718            XsdVersion::V1_1,
719        )
720        .unwrap();
721        match &tree.paths[0].steps[0] {
722            AstStep::Attribute(NameTest::QName { namespace, .. }) => {
723                assert_eq!(*namespace, NamespaceMatch::NoNamespace);
724            }
725            other => panic!("expected Attribute(QName{{NoNamespace, ..}}), got {other:?}"),
726        }
727    }
728
729    #[test]
730    fn unprefixed_child_uses_xpath_default_ns() {
731        // Child axis should still use xpathDefaultNamespace.
732        use crate::schema::model::XsdVersion;
733        let table = NameTable::new();
734        let snapshot = NamespaceContextSnapshot::default();
735        let ns_id = table.add("http://example.com/default");
736        let tree = Asttree::compile_selector(
737            "foo",
738            &snapshot,
739            &table,
740            Some("http://example.com/default"),
741            None,
742            None,
743            XsdVersion::V1_1,
744        )
745        .unwrap();
746        match &tree.paths[0].steps[0] {
747            AstStep::Child(NameTest::QName { namespace, .. }) => {
748                assert_eq!(*namespace, NamespaceMatch::Exact(ns_id));
749            }
750            other => panic!("expected Child(QName{{Exact, ..}}), got {other:?}"),
751        }
752    }
753}