Skip to main content

sup_xml_core/
selector.rs

1//! Minimal CSS-subset selector for streaming-parser emit modes.
2//!
3//! # Supported syntax
4//!
5//! | Syntax              | Meaning                                                |
6//! |---------------------|--------------------------------------------------------|
7//! | `tag`               | element name matches `tag`                             |
8//! | `*`                 | any element                                            |
9//! | `.class`            | `class` attribute contains the token (HTML semantics)  |
10//! | `#id`               | `id` attribute equals                                  |
11//! | `[attr]`            | element has the attribute                              |
12//! | `[attr=value]`      | attribute equals (unquoted, `"…"`, or `'…'`)           |
13//! | `A B`               | `B` is a descendant of `A`                             |
14//! | `A > B`             | `B` is a direct child of `A`                           |
15//!
16//! Simple selectors combine: `tag.class.other#id[attr][a=b]`.  Combinators
17//! chain: `feed > entry[type="article"]`.
18//!
19//! # Intentionally unsupported
20//!
21//! * `:pseudo-classes` like `:nth-child` — most need lookahead the streaming
22//!   parser cannot provide.
23//! * `~` and `+` sibling combinators — same reason.
24//! * Attribute operators other than `=`: no `~=`, `|=`, `^=`, `$=`, `*=`.
25//! * Comma-separated multi-selectors (`a, b`).
26//!
27//! # Anchoring
28//!
29//! Like real CSS, selectors are **not anchored to the document root**.  `item`
30//! matches an `<item>` element anywhere, including nested ones.  Use a
31//! path-based emit mode on the streaming parser when you need exact
32//! root-anchored matching with guaranteed memory bounding.
33
34use sup_xml_tree::dom::Node as ArenaNode;
35
36/// A parsed selector.  Construct with [`Selector::parse`].
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct Selector {
39    parts: Vec<Part>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43struct Part {
44    sel: SimpleSelector,
45    /// How to relate this part to the next-leftward part.  `Start` on `parts[0]`.
46    combinator_to_left: Combinator,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum Combinator {
51    Start,
52    Descendant,
53    Child,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57struct SimpleSelector {
58    /// `None` for `*` or when the simple selector starts with `.`/`#`/`[`.
59    tag:     Option<String>,
60    id:      Option<String>,
61    classes: Vec<String>,
62    attrs:   Vec<AttrFilter>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66enum AttrFilter {
67    Has(String),
68    Equals(String, String),
69}
70
71/// Error parsing a selector string.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ParseSelectorError {
74    message: String,
75}
76
77impl std::fmt::Display for ParseSelectorError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        write!(f, "invalid selector: {}", self.message)
80    }
81}
82
83impl std::error::Error for ParseSelectorError {}
84
85impl Selector {
86    /// Parse a selector string.  See the module docs for the supported subset.
87    pub fn parse(input: &str) -> Result<Self, ParseSelectorError> {
88        let mut p = Parser { input, pos: 0 };
89        p.skip_ws();
90        let parts = p.parse_chain()?;
91        if parts.is_empty() {
92            return Err(p.err("empty selector"));
93        }
94        Ok(Selector { parts })
95    }
96
97    /// Match this selector against the just-closed arena node `popped`,
98    /// given its ancestor chain `stack` (root at index 0).
99    ///
100    /// `stack` is the ancestor chain by reference: `stack[0]` is the root,
101    /// `stack[stack.len() - 1]` is the immediate parent of `popped`.  All
102    /// references typically come from one [`sup_xml_tree::dom::Document`]
103    /// but the matcher doesn't enforce this — it only reads `name` and
104    /// `attributes()`.
105    pub fn matches(&self, popped: &ArenaNode<'_>, stack: &[&ArenaNode<'_>]) -> bool {
106        let n = self.parts.len();
107        if n == 0 || !self.parts[n - 1].sel.matches(popped) {
108            return false;
109        }
110        if n == 1 {
111            return true;
112        }
113        self.match_left(n - 1, stack.len(), stack)
114    }
115
116    fn match_left(&self, right_idx: usize, cursor: usize, stack: &[&ArenaNode<'_>]) -> bool {
117        let comb     = self.parts[right_idx].combinator_to_left;
118        let left_idx = right_idx - 1;
119        let left_sel = &self.parts[left_idx].sel;
120        match comb {
121            Combinator::Child => {
122                if cursor == 0 { return false; }
123                let pos = cursor - 1;
124                if !left_sel.matches(stack[pos]) { return false; }
125                if left_idx == 0 { return true; }
126                self.match_left(left_idx, pos, stack)
127            }
128            Combinator::Descendant => {
129                for pos in (0..cursor).rev() {
130                    if !left_sel.matches(stack[pos]) { continue; }
131                    if left_idx == 0 { return true; }
132                    if self.match_left(left_idx, pos, stack) { return true; }
133                }
134                false
135            }
136            Combinator::Start => unreachable!("Start only at parts[0]"),
137        }
138    }
139}
140
141impl SimpleSelector {
142    /// Same rules as CSS — tag-name equality, id/class/attr filters — but
143    /// iterates the linked-list attribute list.  Non-elements never match.
144    fn matches(&self, el: &ArenaNode<'_>) -> bool {
145        if !el.is_element() { return false; }
146        if let Some(t) = &self.tag {
147            if el.name() != t.as_str() { return false; }
148        }
149        if let Some(id) = &self.id {
150            let has = el.attributes().any(|a| a.name() == "id" && a.value() == *id);
151            if !has { return false; }
152        }
153        for class in &self.classes {
154            let has = el.attributes().any(|a| {
155                a.name() == "class"
156                    && a.value().split_ascii_whitespace().any(|tok| tok == class)
157            });
158            if !has { return false; }
159        }
160        for filter in &self.attrs {
161            let ok = match filter {
162                AttrFilter::Has(n)       => el.attributes().any(|a| a.name() == n.as_str()),
163                AttrFilter::Equals(n, v) => el.attributes().any(|a| a.name() == n.as_str() && a.value() == *v),
164            };
165            if !ok { return false; }
166        }
167        true
168    }
169}
170
171// ── parser ───────────────────────────────────────────────────────────────────
172
173struct Parser<'a> {
174    input: &'a str,
175    pos:   usize,
176}
177
178impl<'a> Parser<'a> {
179    fn err(&self, msg: impl Into<String>) -> ParseSelectorError {
180        ParseSelectorError { message: format!("{} (at offset {})", msg.into(), self.pos) }
181    }
182
183    fn peek(&self) -> Option<char> { self.input[self.pos..].chars().next() }
184
185    fn bump(&mut self) -> Option<char> {
186        let c = self.peek()?;
187        self.pos += c.len_utf8();
188        Some(c)
189    }
190
191    /// Skip whitespace; return whether any was skipped (relevant for the
192    /// descendant combinator, which IS whitespace).
193    fn skip_ws(&mut self) -> bool {
194        let start = self.pos;
195        while let Some(c) = self.peek() {
196            if c.is_whitespace() { self.bump(); } else { break; }
197        }
198        self.pos != start
199    }
200
201    fn parse_chain(&mut self) -> Result<Vec<Part>, ParseSelectorError> {
202        let mut parts = Vec::new();
203        if self.peek().is_none() {
204            return Ok(parts);
205        }
206        let sel = self.parse_simple()?;
207        parts.push(Part { sel, combinator_to_left: Combinator::Start });
208        loop {
209            let had_ws = self.skip_ws();
210            match self.peek() {
211                None => break,
212                Some('>') => {
213                    self.bump();
214                    self.skip_ws();
215                    let sel = self.parse_simple()?;
216                    parts.push(Part { sel, combinator_to_left: Combinator::Child });
217                }
218                Some(_) => {
219                    if !had_ws {
220                        return Err(self.err("expected combinator (` ` or `>`) or end of selector"));
221                    }
222                    let sel = self.parse_simple()?;
223                    parts.push(Part { sel, combinator_to_left: Combinator::Descendant });
224                }
225            }
226        }
227        Ok(parts)
228    }
229
230    fn parse_simple(&mut self) -> Result<SimpleSelector, ParseSelectorError> {
231        let mut sel        = SimpleSelector::default();
232        let mut had_prefix = false;  // saw `*` or a tag name
233        match self.peek() {
234            Some('*') => { self.bump(); had_prefix = true; }
235            Some(c) if is_ident_start(c) => {
236                sel.tag = Some(self.parse_ident()?);
237                had_prefix = true;
238            }
239            _ => {}
240        }
241        loop {
242            match self.peek() {
243                Some('.') => {
244                    self.bump();
245                    sel.classes.push(self.parse_ident()?);
246                }
247                Some('#') => {
248                    self.bump();
249                    if sel.id.is_some() {
250                        return Err(self.err("multiple #id selectors"));
251                    }
252                    sel.id = Some(self.parse_ident()?);
253                }
254                Some('[') => {
255                    self.bump();
256                    sel.attrs.push(self.parse_attr_filter()?);
257                }
258                _ => break,
259            }
260        }
261        if !had_prefix && sel.id.is_none() && sel.classes.is_empty() && sel.attrs.is_empty() {
262            return Err(self.err("expected element name, `*`, `.class`, `#id`, or `[attr]`"));
263        }
264        Ok(sel)
265    }
266
267    fn parse_ident(&mut self) -> Result<String, ParseSelectorError> {
268        let start = self.pos;
269        while let Some(c) = self.peek() {
270            if is_ident_continue(c) { self.bump(); } else { break; }
271        }
272        if start == self.pos {
273            return Err(self.err("expected identifier"));
274        }
275        Ok(self.input[start..self.pos].to_owned())
276    }
277
278    fn parse_attr_filter(&mut self) -> Result<AttrFilter, ParseSelectorError> {
279        self.skip_ws();
280        let name = self.parse_ident()?;
281        self.skip_ws();
282        match self.peek() {
283            Some(']') => { self.bump(); Ok(AttrFilter::Has(name)) }
284            Some('=') => {
285                self.bump();
286                self.skip_ws();
287                let value = self.parse_attr_value()?;
288                self.skip_ws();
289                if self.peek() != Some(']') {
290                    return Err(self.err("expected `]`"));
291                }
292                self.bump();
293                Ok(AttrFilter::Equals(name, value))
294            }
295            _ => Err(self.err("expected `=` or `]` after attribute name")),
296        }
297    }
298
299    fn parse_attr_value(&mut self) -> Result<String, ParseSelectorError> {
300        let quote = match self.peek() {
301            Some('"') | Some('\'') => self.bump().unwrap(),
302            _ => return self.parse_ident(),
303        };
304        let start = self.pos;
305        while let Some(c) = self.peek() {
306            if c == quote { break; }
307            self.bump();
308        }
309        if self.peek() != Some(quote) {
310            return Err(self.err("unterminated attribute value"));
311        }
312        let value = self.input[start..self.pos].to_owned();
313        self.bump();
314        Ok(value)
315    }
316}
317
318fn is_ident_start(c: char) -> bool {
319    c == '_' || c.is_alphabetic()
320}
321
322fn is_ident_continue(c: char) -> bool {
323    // XML names can contain `:` (namespace prefix), `-`, digits.  `.` is the
324    // class-selector delimiter so we exclude it; tags with `.` in the name are
325    // not addressable via this subset (use `[name=…]` if you need that).
326    c == '_' || c == '-' || c == ':' || c.is_alphanumeric()
327}
328
329// ── tests ────────────────────────────────────────────────────────────────────
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use sup_xml_tree::dom::{DocumentBuilder, Node};
335
336    /// Build a standalone arena element + return the builder so refs stay alive.
337    fn make_el<'a>(b: &'a DocumentBuilder, name: &str, attrs: &[(&str, &str)]) -> &'a Node<'a> {
338        let n  = b.alloc_str(name);
339        let el = b.new_element(n);
340        for (an, av) in attrs {
341            let attr = b.new_attribute(b.alloc_str(an), b.alloc_str(av));
342            b.append_attribute(el, attr);
343        }
344        el
345    }
346
347    // ── parser ─────────────────────────────────────────────────────────────
348
349    #[test]
350    fn parse_simple_tag() {
351        let s = Selector::parse("foo").unwrap();
352        assert_eq!(s.parts.len(), 1);
353        assert_eq!(s.parts[0].sel.tag.as_deref(), Some("foo"));
354    }
355
356    #[test]
357    fn parse_wildcard() {
358        let s = Selector::parse("*").unwrap();
359        assert_eq!(s.parts[0].sel.tag, None);
360    }
361
362    #[test]
363    fn parse_class_id_attrs() {
364        let s = Selector::parse(r#"div.foo.bar#main[hidden][type="x"]"#).unwrap();
365        let p = &s.parts[0].sel;
366        assert_eq!(p.tag.as_deref(), Some("div"));
367        assert_eq!(p.id.as_deref(), Some("main"));
368        assert_eq!(p.classes, vec!["foo".to_string(), "bar".into()]);
369        assert_eq!(p.attrs, vec![
370            AttrFilter::Has("hidden".into()),
371            AttrFilter::Equals("type".into(), "x".into()),
372        ]);
373    }
374
375    #[test]
376    fn parse_child_combinator() {
377        let s = Selector::parse("a > b > c").unwrap();
378        assert_eq!(s.parts.len(), 3);
379        assert_eq!(s.parts[0].combinator_to_left, Combinator::Start);
380        assert_eq!(s.parts[1].combinator_to_left, Combinator::Child);
381        assert_eq!(s.parts[2].combinator_to_left, Combinator::Child);
382    }
383
384    #[test]
385    fn parse_descendant_combinator() {
386        let s = Selector::parse("a b").unwrap();
387        assert_eq!(s.parts.len(), 2);
388        assert_eq!(s.parts[1].combinator_to_left, Combinator::Descendant);
389    }
390
391    #[test]
392    fn parse_mixed_combinators() {
393        let s = Selector::parse("a b > c").unwrap();
394        assert_eq!(s.parts[1].combinator_to_left, Combinator::Descendant);
395        assert_eq!(s.parts[2].combinator_to_left, Combinator::Child);
396    }
397
398    #[test]
399    fn parse_no_space_around_child_combinator() {
400        let s = Selector::parse("a>b").unwrap();
401        assert_eq!(s.parts.len(), 2);
402        assert_eq!(s.parts[1].combinator_to_left, Combinator::Child);
403    }
404
405    #[test]
406    fn parse_namespaced_name_with_colon() {
407        let s = Selector::parse("atom:entry").unwrap();
408        assert_eq!(s.parts[0].sel.tag.as_deref(), Some("atom:entry"));
409    }
410
411    #[test]
412    fn parse_unquoted_attr_value() {
413        let s = Selector::parse("[rel=next]").unwrap();
414        assert_eq!(s.parts[0].sel.attrs[0], AttrFilter::Equals("rel".into(), "next".into()));
415    }
416
417    #[test]
418    fn parse_single_quoted_attr_value() {
419        let s = Selector::parse("[a='hello world']").unwrap();
420        assert_eq!(s.parts[0].sel.attrs[0], AttrFilter::Equals("a".into(), "hello world".into()));
421    }
422
423    #[test]
424    fn parse_errors() {
425        assert!(Selector::parse("").is_err());
426        assert!(Selector::parse("   ").is_err());
427        assert!(Selector::parse("a,b").is_err(), "comma not supported");
428        // Note: `:` is treated as a namespace separator (e.g. `atom:entry`),
429        // so `a:hover` parses as the tag name `a:hover`.  Pseudo-classes are
430        // unsupported in the sense of "no semantic, just a tag-name char."
431        assert!(Selector::parse("[a").is_err(), "unterminated attr");
432        assert!(Selector::parse("[a=").is_err(), "missing value");
433        assert!(Selector::parse("[a='unterminated").is_err());
434        assert!(Selector::parse("#one#two").is_err(), "multiple ids");
435        assert!(Selector::parse("> foo").is_err(), "leading combinator");
436    }
437
438    // ── simple match ───────────────────────────────────────────────────────
439
440    #[test]
441    fn match_tag() {
442        let s = Selector::parse("item").unwrap();
443        let b = DocumentBuilder::new();
444        assert!( s.matches(make_el(&b, "item",  &[]), &[]));
445        assert!(!s.matches(make_el(&b, "entry", &[]), &[]));
446    }
447
448    #[test]
449    fn match_wildcard_any() {
450        let s = Selector::parse("*").unwrap();
451        let b = DocumentBuilder::new();
452        assert!(s.matches(make_el(&b, "anything", &[]), &[]));
453    }
454
455    #[test]
456    fn match_class_token() {
457        let s = Selector::parse(".bar").unwrap();
458        let b = DocumentBuilder::new();
459        assert!( s.matches(make_el(&b, "div", &[("class", "foo bar baz")]), &[]));
460        assert!(!s.matches(make_el(&b, "div", &[("class", "foo baz")]),     &[]));
461    }
462
463    #[test]
464    fn match_id_equals() {
465        let s = Selector::parse("#main").unwrap();
466        let b = DocumentBuilder::new();
467        assert!( s.matches(make_el(&b, "div", &[("id", "main")]),  &[]));
468        assert!(!s.matches(make_el(&b, "div", &[("id", "main2")]), &[]));
469    }
470
471    #[test]
472    fn match_attr_has_and_equals() {
473        let has = Selector::parse("[hidden]").unwrap();
474        let eq  = Selector::parse("[rel=next]").unwrap();
475        let b = DocumentBuilder::new();
476        assert!( has.matches(make_el(&b, "a", &[("hidden", "")]),  &[]));
477        assert!(!has.matches(make_el(&b, "a", &[("rel", "next")]), &[]));
478        assert!( eq.matches (make_el(&b, "a", &[("rel", "next")]), &[]));
479        assert!(!eq.matches (make_el(&b, "a", &[("rel", "prev")]), &[]));
480    }
481
482    // ── combinator match ───────────────────────────────────────────────────
483
484    #[test]
485    fn match_child_combinator() {
486        let s = Selector::parse("channel > item").unwrap();
487        let b = DocumentBuilder::new();
488        let rss     = make_el(&b, "rss",     &[]);
489        let channel = make_el(&b, "channel", &[]);
490        let item    = make_el(&b, "item",    &[]);
491        assert!( s.matches(item, &[rss, channel]));
492        // wrong parent
493        assert!(!s.matches(item, &[rss]));
494    }
495
496    #[test]
497    fn match_descendant_combinator() {
498        let s = Selector::parse("rss item").unwrap();
499        let b = DocumentBuilder::new();
500        let rss     = make_el(&b, "rss",     &[]);
501        let channel = make_el(&b, "channel", &[]);
502        let item    = make_el(&b, "item",    &[]);
503        assert!(s.matches(item, &[rss, channel]));
504
505        let feed     = make_el(&b, "feed",    &[]);
506        let channel2 = make_el(&b, "channel", &[]);
507        assert!(!s.matches(item, &[feed, channel2]));
508    }
509
510    #[test]
511    fn match_descendant_backtracks() {
512        // `div p` against <div><div><p/></div></div> — outer div, inner div, p.
513        let s = Selector::parse("div p").unwrap();
514        let b = DocumentBuilder::new();
515        let outer_div = make_el(&b, "div", &[]);
516        let inner_div = make_el(&b, "div", &[]);
517        let p         = make_el(&b, "p",   &[]);
518        assert!(s.matches(p, &[outer_div, inner_div]));
519    }
520
521    #[test]
522    fn match_mixed_combinators() {
523        let s = Selector::parse("feed > entry > title").unwrap();
524        let b = DocumentBuilder::new();
525        let feed  = make_el(&b, "feed",  &[]);
526        let entry = make_el(&b, "entry", &[]);
527        let title = make_el(&b, "title", &[]);
528        assert!( s.matches(title, &[feed, entry]));
529
530        let section = make_el(&b, "section", &[]);
531        assert!(!s.matches(title, &[feed, section]));
532    }
533
534    #[test]
535    fn match_complex_simple_selector_compound() {
536        let s = Selector::parse(r#"entry[type="article"].published"#).unwrap();
537        let b = DocumentBuilder::new();
538        assert!( s.matches(
539            make_el(&b, "entry", &[("type", "article"), ("class", "published")]), &[]));
540        assert!(!s.matches(
541            make_el(&b, "entry", &[("type", "draft"),   ("class", "published")]), &[]));
542        assert!(!s.matches(
543            make_el(&b, "entry", &[("type", "article"), ("class", "unpublished")]), &[]));
544    }
545
546    #[test]
547    fn non_element_never_matches() {
548        // `*` shouldn't match a text node.
549        let s = Selector::parse("*").unwrap();
550        let b = DocumentBuilder::new();
551        let text = b.new_text(b.alloc_str("hello"));
552        assert!(!s.matches(text, &[]));
553    }
554}