Skip to main content

sup_xml_core/xpath/
pattern.rs

1//! A small subset of XPath 1.0 sized for fast per-node matching.
2//!
3//! This is the engine behind libxml2's `xmlPattern` family — a stripped
4//! XPath dialect that the SAX/pull-parser side of libxml2 uses for
5//! questions like "does this node match `//book[@id]`?" during a
6//! traversal.  It's much faster than firing the full XPath engine
7//! per node, and the grammar covers most real-world streaming
8//! selectors.
9//!
10//! # Grammar
11//!
12//! ```text
13//! Pattern    := Branch ('|' Branch)*
14//! Branch     := '/'?  Step ('/' Step | '//' Step)*
15//! Step       := AxisStep Predicate*
16//! AxisStep   := '@' NCName
17//!             | '@' '*'
18//!             | NCName
19//!             | '*'
20//! Predicate  := '[' PredExpr ']'
21//! PredExpr   := Integer                     -- positional, 1-based
22//!             | 'last' '(' ')'              -- last position
23//!             | '@' NCName                  -- attribute exists
24//!             | '@' NCName '=' Literal      -- attribute equals
25//! Literal    := '"' …no " … '"' | "'" …no ' … "'"
26//! ```
27//!
28//! Not supported (intentional — fall outside libxml2's pattern subset):
29//! XPath function calls in predicates beyond `last()`, other axes
30//! (`parent::`, `ancestor::`, …), numeric expressions, variable
31//! references.  Patterns that use them won't compile.
32//!
33//! # Two evaluation modes
34//!
35//! - **Backward walk**: [`Pattern::matches`] — given a node with parent
36//!   pointers, walk upward through ancestors checking each step.
37//!   Random access; works on any DOM tree.
38//! - **Forward streaming**: [`Pattern::streaming`] — TODO.  An NFA-shaped
39//!   state machine that tracks pattern match progress as SAX-style
40//!   events flow past, without needing parent pointers.
41
42use sup_xml_tree::dom::{Node, NodeKind};
43
44/// A compiled libxml2-flavour pattern.  Match nodes via
45/// [`Self::matches`].
46#[derive(Debug, Clone)]
47pub struct Pattern {
48    /// One or more `|`-separated branches.  A node matches the pattern
49    /// iff it matches at least one branch.
50    branches: Vec<Branch>,
51}
52
53#[derive(Debug, Clone)]
54struct Branch {
55    /// Steps in right-to-left order: `steps[0]` matches the candidate
56    /// node; `steps[i]` (for `i > 0`) matches the ancestor reached
57    /// from `steps[i-1]` via `links[i-1]`.
58    steps: Vec<Step>,
59    /// Length is `steps.len() - 1`.  `links[i]` says how step `i`
60    /// connects to step `i+1` (one parent up, or any ancestor up).
61    links: Vec<Link>,
62    /// True iff the branch begins with `/`.  Anchored to the document
63    /// root — after consuming every step, the cursor must be the
64    /// document node.
65    absolute: bool,
66}
67
68#[derive(Debug, Clone)]
69struct Step {
70    test:       Test,
71    predicates: Vec<Predicate>,
72}
73
74#[derive(Debug, Clone)]
75enum Test {
76    /// `foo` — element name match.
77    Element(String),
78    /// `*` — any element.
79    AnyElement,
80    /// `@foo` — attribute name match.  Step targets an attribute node.
81    Attribute(String),
82    /// `@*` — any attribute.
83    AnyAttribute,
84}
85
86#[derive(Debug, Clone)]
87enum Predicate {
88    /// `[N]` — 1-based positional, evaluated against same-name siblings.
89    Position(usize),
90    /// `[last()]` — last among same-name siblings.
91    Last,
92    /// `[@foo]` — attribute exists.
93    AttrExists(String),
94    /// `[@foo='x']` or `[@foo="x"]` — attribute equals literal.
95    AttrEquals(String, String),
96}
97
98#[derive(Debug, Clone, Copy)]
99enum Link {
100    /// `/` — the next step must match the immediate parent.
101    Parent,
102    /// `//` — the next step must match some ancestor (any distance).
103    Ancestor,
104}
105
106impl Pattern {
107    /// Compile a pattern source string into a matcher.
108    ///
109    /// Returns `Err` on syntax errors or grammar features outside the
110    /// libxml2 pattern subset.
111    pub fn compile(src: &str) -> Result<Self, String> {
112        let mut p = Parser::new(src);
113        let pat = p.parse_pattern()?;
114        p.skip_ws();
115        if !p.at_eof() {
116            return Err(format!("unexpected trailing input near {:?}", &p.src[p.pos..]));
117        }
118        Ok(pat)
119    }
120
121    /// Does `node` match this pattern?  Walks upward via parent
122    /// pointers, so the node must be attached to a tree.
123    pub fn matches(&self, node: &Node<'_>) -> bool {
124        self.branches.iter().any(|b| b.matches(node))
125    }
126}
127
128// ── matching ────────────────────────────────────────────────────────────
129
130impl Branch {
131    fn matches(&self, node: &Node<'_>) -> bool {
132        // `cursor` starts on the candidate node.  After matching step 0
133        // we use links[0] to move up to the node matching step 1, etc.
134        let Some(mut cursor) = Some(node) else { return false; };
135
136        for (i, step) in self.steps.iter().enumerate() {
137            // Step's test applies to whatever the cursor currently points at.
138            if !step.test.matches(cursor) {
139                return false;
140            }
141            if !predicates_hold(&step.predicates, cursor) {
142                return false;
143            }
144
145            // After the rightmost step there's no further link.
146            let Some(link) = self.links.get(i) else { break; };
147
148            cursor = match link {
149                Link::Parent => match cursor.parent.get() {
150                    Some(p) => p,
151                    None    => return false,
152                },
153                Link::Ancestor => {
154                    // We need an ancestor that satisfies the NEXT
155                    // step's test+predicates.  Walk upward until we
156                    // find one (or run out of ancestors).
157                    let Some(next_step) = self.steps.get(i + 1) else {
158                        return false;
159                    };
160                    let mut found = None;
161                    let mut up = cursor.parent.get();
162                    while let Some(anc) = up {
163                        if next_step.test.matches(anc) && predicates_hold(&next_step.predicates, anc) {
164                            found = Some(anc);
165                            break;
166                        }
167                        up = anc.parent.get();
168                    }
169                    match found {
170                        Some(a) => {
171                            // We already matched the test+predicates;
172                            // skip the test step in the next iteration.
173                            // Easiest way: short-circuit by overwriting
174                            // cursor and continuing.
175                            //
176                            // But the outer for loop will re-test in
177                            // the next iteration, which is correct AND
178                            // already true.  No duplicate work hazard.
179                            a
180                        }
181                        None => return false,
182                    }
183                }
184            };
185        }
186
187        if self.absolute {
188            // After matching the leftmost step, the cursor's parent
189            // must be the document root — i.e. the leftmost step is at
190            // depth 1.  Equivalently: cursor has no element parent.
191            match cursor.parent.get() {
192                None    => true,
193                Some(p) => matches!(p.kind, NodeKind::Document),
194            }
195        } else {
196            true
197        }
198    }
199}
200
201impl Test {
202    fn matches(&self, node: &Node<'_>) -> bool {
203        match self {
204            Test::Element(name) => {
205                matches!(node.kind, NodeKind::Element) && node.name() == name.as_str()
206            }
207            Test::AnyElement => matches!(node.kind, NodeKind::Element),
208            // Under the `c-abi` layout an `xmlAttr*` is passed as an
209            // `xmlNode*` carrying `NodeKind::Attribute` (offset-8 `type`),
210            // and `parent` points at the owning element — so an attribute
211            // step matches it directly and the parent-walk continues
212            // upward.  In the pure-Rust DOM attributes are never nodes, so
213            // these arms are simply never satisfied there.
214            Test::Attribute(name) => {
215                matches!(node.kind, NodeKind::Attribute) && node.name() == name.as_str()
216            }
217            Test::AnyAttribute => matches!(node.kind, NodeKind::Attribute),
218        }
219    }
220}
221
222fn predicates_hold(preds: &[Predicate], node: &Node<'_>) -> bool {
223    preds.iter().all(|p| predicate_holds(p, node))
224}
225
226fn predicate_holds(pred: &Predicate, node: &Node<'_>) -> bool {
227    match pred {
228        Predicate::Position(want) => sibling_position(node) == Some(*want),
229        Predicate::Last => {
230            let pos = sibling_position(node);
231            let cnt = sibling_count(node);
232            matches!((pos, cnt), (Some(p), Some(c)) if p == c)
233        }
234        Predicate::AttrExists(name) => node.attributes().any(|a| a.name() == name.as_str()),
235        Predicate::AttrEquals(name, val) => node
236            .attributes()
237            .any(|a| a.name() == name.as_str() && a.value() == val.as_str()),
238    }
239}
240
241/// 1-based position of `node` among siblings of the same kind+name.
242fn sibling_position(node: &Node<'_>) -> Option<usize> {
243    let parent = node.parent.get()?;
244    let target_name = node.name();
245    let mut idx = 0usize;
246    for sib in parent.children() {
247        if !matches!(sib.kind, NodeKind::Element) { continue; }
248        if sib.name() != target_name { continue; }
249        idx += 1;
250        if std::ptr::eq(sib as *const _, node as *const _) {
251            return Some(idx);
252        }
253    }
254    None
255}
256
257/// Count of same-name element siblings (inclusive of `node`).
258fn sibling_count(node: &Node<'_>) -> Option<usize> {
259    let parent = node.parent.get()?;
260    let target_name = node.name();
261    let mut n = 0usize;
262    for sib in parent.children() {
263        if !matches!(sib.kind, NodeKind::Element) { continue; }
264        if sib.name() != target_name { continue; }
265        n += 1;
266    }
267    Some(n)
268}
269
270// ── parsing ─────────────────────────────────────────────────────────────
271
272struct Parser<'a> {
273    src: &'a str,
274    pos: usize,
275}
276
277impl<'a> Parser<'a> {
278    fn new(src: &'a str) -> Self { Self { src, pos: 0 } }
279
280    fn at_eof(&self) -> bool { self.pos >= self.src.len() }
281
282    fn peek(&self) -> Option<char> { self.src[self.pos..].chars().next() }
283
284    fn bump(&mut self) -> Option<char> {
285        let c = self.peek()?;
286        self.pos += c.len_utf8();
287        Some(c)
288    }
289
290    fn skip_ws(&mut self) {
291        while let Some(c) = self.peek() {
292            if c.is_ascii_whitespace() { self.bump(); } else { break; }
293        }
294    }
295
296    fn eat(&mut self, lit: &str) -> bool {
297        if self.src[self.pos..].starts_with(lit) {
298            self.pos += lit.len();
299            true
300        } else { false }
301    }
302
303    fn parse_pattern(&mut self) -> Result<Pattern, String> {
304        let mut branches = vec![self.parse_branch()?];
305        loop {
306            self.skip_ws();
307            if !self.eat("|") { break; }
308            branches.push(self.parse_branch()?);
309        }
310        Ok(Pattern { branches })
311    }
312
313    fn parse_branch(&mut self) -> Result<Branch, String> {
314        self.skip_ws();
315        let absolute_or_descendant_root = self.peek() == Some('/');
316        let mut leading_descendant = false;
317        if absolute_or_descendant_root {
318            self.bump();
319            if self.peek() == Some('/') {
320                self.bump();
321                leading_descendant = true;
322            }
323        }
324        // Source-order steps (leftmost first).
325        let mut steps_lr: Vec<Step> = vec![self.parse_step()?];
326        // Inter-step links, source-order.  links_lr[i] connects steps_lr[i] to steps_lr[i+1].
327        let mut links_lr: Vec<Link> = Vec::new();
328        loop {
329            self.skip_ws();
330            if !self.eat("/") {
331                break;
332            }
333            let link = if self.peek() == Some('/') { self.bump(); Link::Ancestor } else { Link::Parent };
334            links_lr.push(link);
335            steps_lr.push(self.parse_step()?);
336        }
337
338        // Reverse so steps[0] is the rightmost (matches candidate).
339        steps_lr.reverse();
340        let mut links: Vec<Link> = links_lr.into_iter().rev().collect();
341
342        // A leading `//` means the leftmost step is reached from the
343        // doc root via Ancestor, but since we're walking backward this
344        // is equivalent to "no constraint on what's above the leftmost
345        // step."  Drop the absolute flag in that case.
346        let absolute = if leading_descendant {
347            false
348        } else {
349            absolute_or_descendant_root
350        };
351
352        // Validate: any attribute step must be the rightmost (libxml2
353        // pattern grammar restriction — attributes can't have child
354        // steps below them).
355        if steps_lr.len() > 1 {
356            for s in &steps_lr[1..] {
357                if matches!(s.test, Test::Attribute(_) | Test::AnyAttribute) {
358                    return Err("attribute step must be the rightmost step".into());
359                }
360            }
361        }
362
363        // Belt-and-braces: trim trailing Link that has no destination
364        // (shouldn't happen if parser is correct).
365        links.truncate(steps_lr.len().saturating_sub(1));
366
367        Ok(Branch { steps: steps_lr, links, absolute })
368    }
369
370    fn parse_step(&mut self) -> Result<Step, String> {
371        self.skip_ws();
372        let test = if self.peek() == Some('@') {
373            self.bump();
374            if self.eat("*") { Test::AnyAttribute }
375            else {
376                let n = self.parse_ncname()?;
377                Test::Attribute(n)
378            }
379        } else if self.eat("*") {
380            Test::AnyElement
381        } else {
382            let n = self.parse_ncname()?;
383            Test::Element(n)
384        };
385        let mut predicates = Vec::new();
386        loop {
387            self.skip_ws();
388            if self.peek() != Some('[') { break; }
389            predicates.push(self.parse_predicate()?);
390        }
391        Ok(Step { test, predicates })
392    }
393
394    fn parse_ncname(&mut self) -> Result<String, String> {
395        // Accepts NCName (ASCII-friendly form for the libxml2 pattern
396        // subset).  Permissive: letter | '_' to start, then word chars
397        // plus '-'.  Also accept `prefix:local` (treated as a single
398        // name for matching).
399        let start = self.pos;
400        let first = match self.peek() {
401            Some(c) if c.is_ascii_alphabetic() || c == '_' => c,
402            _ => return Err(format!("expected NCName at offset {}", self.pos)),
403        };
404        self.bump();
405        let _ = first;
406        while let Some(c) = self.peek() {
407            if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' || c == ':' {
408                self.bump();
409            } else { break; }
410        }
411        Ok(self.src[start..self.pos].to_string())
412    }
413
414    fn parse_predicate(&mut self) -> Result<Predicate, String> {
415        if !self.eat("[") { return Err("expected '['".into()); }
416        self.skip_ws();
417        let pred = if self.peek().is_some_and(|c| c.is_ascii_digit()) {
418            let start = self.pos;
419            while let Some(c) = self.peek() {
420                if c.is_ascii_digit() { self.bump(); } else { break; }
421            }
422            let n: usize = self.src[start..self.pos].parse().map_err(|e| format!("bad position: {e}"))?;
423            Predicate::Position(n)
424        } else if self.eat("last") {
425            self.skip_ws();
426            if !self.eat("(") || { self.skip_ws(); !self.eat(")") } {
427                return Err("expected 'last()'".into());
428            }
429            Predicate::Last
430        } else if self.peek() == Some('@') {
431            self.bump();
432            let name = self.parse_ncname()?;
433            self.skip_ws();
434            if self.eat("=") {
435                self.skip_ws();
436                let val = self.parse_string_literal()?;
437                Predicate::AttrEquals(name, val)
438            } else {
439                Predicate::AttrExists(name)
440            }
441        } else {
442            return Err(format!("unsupported predicate at offset {}", self.pos));
443        };
444        self.skip_ws();
445        if !self.eat("]") { return Err("expected ']'".into()); }
446        Ok(pred)
447    }
448
449    fn parse_string_literal(&mut self) -> Result<String, String> {
450        let q = self.bump().ok_or("expected quoted string")?;
451        if q != '"' && q != '\'' {
452            return Err(format!("expected quote, got {q:?}"));
453        }
454        let start = self.pos;
455        while let Some(c) = self.peek() {
456            if c == q { break; }
457            self.bump();
458        }
459        let s = self.src[start..self.pos].to_string();
460        if !self.eat(&q.to_string()) {
461            return Err("unterminated string literal".into());
462        }
463        Ok(s)
464    }
465}
466
467// ── tests ───────────────────────────────────────────────────────────────
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::{parse_str, ParseOptions};
473
474    fn parse(s: &str) -> sup_xml_tree::dom::Document {
475        parse_str(s, &ParseOptions::default()).unwrap()
476    }
477
478    fn root<'a>(d: &'a sup_xml_tree::dom::Document) -> &'a Node<'a> {
479        d.root()
480    }
481
482    fn nth_child<'a>(parent: &'a Node<'a>, i: usize) -> &'a Node<'a> {
483        parent.children().nth(i).unwrap()
484    }
485
486    #[test]
487    fn compile_basic_shapes() {
488        for src in [
489            "foo",
490            "*",
491            "@id",
492            "@*",
493            "foo/bar",
494            "foo//bar",
495            "//book",
496            "/catalog/book",
497            "foo[1]",
498            "foo[@id]",
499            "foo[@id='x']",
500            "foo[last()]",
501            "a | b | c",
502        ] {
503            Pattern::compile(src).unwrap_or_else(|e| panic!("failed on {src:?}: {e}"));
504        }
505    }
506
507    #[test]
508    fn rejects_unsupported() {
509        for src in ["foo[contains(@id,'x')]", "parent::*", "..", "foo[bar=1]"] {
510            assert!(Pattern::compile(src).is_err(), "should reject {src:?}");
511        }
512    }
513
514    #[test]
515    fn match_simple_element() {
516        let d = parse("<catalog><book/></catalog>");
517        let book = nth_child(root(&d), 0);
518        assert!(Pattern::compile("book").unwrap().matches(book));
519        assert!(!Pattern::compile("catalog").unwrap().matches(book));
520    }
521
522    #[test]
523    fn match_child_chain() {
524        let d = parse("<catalog><book/></catalog>");
525        let book = nth_child(root(&d), 0);
526        assert!(Pattern::compile("catalog/book").unwrap().matches(book));
527        assert!(!Pattern::compile("library/book").unwrap().matches(book));
528    }
529
530    #[test]
531    fn match_descendant() {
532        let d = parse("<r><a><b><c/></b></a></r>");
533        let c = nth_child(nth_child(nth_child(root(&d), 0), 0), 0);
534        assert!(Pattern::compile("//c").unwrap().matches(c));
535        assert!(Pattern::compile("r//c").unwrap().matches(c));
536        assert!(Pattern::compile("a//c").unwrap().matches(c));
537        assert!(!Pattern::compile("a/c").unwrap().matches(c));
538    }
539
540    #[test]
541    fn match_wildcard() {
542        let d = parse("<r><a/></r>");
543        let a = nth_child(root(&d), 0);
544        assert!(Pattern::compile("*").unwrap().matches(a));
545        assert!(Pattern::compile("r/*").unwrap().matches(a));
546    }
547
548    #[test]
549    fn match_absolute() {
550        let d = parse("<r><a/></r>");
551        let r = root(&d);
552        let a = nth_child(r, 0);
553        assert!(Pattern::compile("/r").unwrap().matches(r));
554        assert!(Pattern::compile("/r/a").unwrap().matches(a));
555        // Absolute path: candidate's ancestry must reach the doc root with
556        // no extra ancestors above the leftmost step.
557        assert!(!Pattern::compile("/a").unwrap().matches(a));
558    }
559
560    #[test]
561    fn match_position_predicate() {
562        let d = parse("<catalog><book/><book/><book/></catalog>");
563        let books: Vec<_> = root(&d).children().collect();
564        assert!(Pattern::compile("book[1]").unwrap().matches(books[0]));
565        assert!(!Pattern::compile("book[1]").unwrap().matches(books[1]));
566        assert!(Pattern::compile("book[2]").unwrap().matches(books[1]));
567        assert!(Pattern::compile("book[last()]").unwrap().matches(books[2]));
568        assert!(!Pattern::compile("book[last()]").unwrap().matches(books[0]));
569    }
570
571    #[test]
572    fn match_attr_predicate() {
573        let d = parse(r#"<catalog><book id="b1"/><book/></catalog>"#);
574        let books: Vec<_> = root(&d).children().collect();
575        assert!(Pattern::compile("book[@id]").unwrap().matches(books[0]));
576        assert!(!Pattern::compile("book[@id]").unwrap().matches(books[1]));
577        assert!(Pattern::compile(r#"book[@id="b1"]"#).unwrap().matches(books[0]));
578        assert!(!Pattern::compile(r#"book[@id="b2"]"#).unwrap().matches(books[0]));
579    }
580
581    #[test]
582    fn match_union() {
583        let d = parse("<r><a/><b/></r>");
584        let a = nth_child(root(&d), 0);
585        let b = nth_child(root(&d), 1);
586        let p = Pattern::compile("a | b").unwrap();
587        assert!(p.matches(a));
588        assert!(p.matches(b));
589        let p2 = Pattern::compile("a | c").unwrap();
590        assert!(p2.matches(a));
591        assert!(!p2.matches(b));
592    }
593}