Skip to main content

domtree/
query.rs

1//! A small, hand-rolled CSS-selector engine (behind the `query` feature).
2//!
3//! Supports the subset that covers the vast majority of real queries with zero
4//! dependencies (no `selectors`/`cssparser`):
5//!
6//! - type selectors `div`, the universal `*`
7//! - `#id`, `.class` (compound: `div.a.b#c`)
8//! - attribute presence `[attr]` and equality `[attr=value]` / `[attr="value"]`
9//! - descendant (` `) and child (`>`) combinators
10//! - selector lists separated by `,`
11//!
12//! It does **not** implement pseudo-classes, `~`/`+` combinators, or the more
13//! exotic attribute operators — by design, to stay tiny.
14
15use alloc::string::{String, ToString};
16use alloc::vec::Vec;
17use core::iter::Peekable;
18use core::str::Chars;
19
20use crate::tree::{Dom, NodeRef};
21
22impl Dom {
23    /// Find every element matching a CSS selector, in document order.
24    ///
25    /// ```
26    /// let dom = domtree::parse(
27    ///     "<ul><li class='x'>a</li><li>b</li></ul><ol><li>c</li></ol>",
28    /// );
29    /// // descendant + class
30    /// assert_eq!(dom.select("ul li.x").count(), 1);
31    /// // child combinator
32    /// assert_eq!(dom.select("ol > li").count(), 1);
33    /// // selector list
34    /// assert_eq!(dom.select("ul, ol").count(), 2);
35    /// ```
36    pub fn select<'a>(&'a self, selector: &str) -> impl Iterator<Item = NodeRef<'a>> + 'a {
37        let groups = parse_selector(selector);
38        self.nodes()
39            .filter(move |n| n.is_element() && groups.iter().any(|g| matches_complex(*n, g)))
40    }
41}
42
43#[derive(Clone, Copy)]
44enum Comb {
45    Descendant,
46    Child,
47}
48
49struct Compound {
50    tag: Option<String>,
51    id: Option<String>,
52    classes: Vec<String>,
53    attrs: Vec<AttrPred>,
54}
55
56struct AttrPred {
57    name: String,
58    value: Option<String>,
59}
60
61struct Complex {
62    /// Each step's `Comb` is the combinator to its *left*; `steps[0]`'s is unused.
63    steps: Vec<(Comb, Compound)>,
64}
65
66// --- parsing -------------------------------------------------------------
67
68fn parse_selector(input: &str) -> Vec<Complex> {
69    split_groups(input)
70        .iter()
71        .filter_map(|g| parse_complex(g))
72        .collect()
73}
74
75/// Split a selector list on top-level commas (ignoring commas inside `[...]`).
76fn split_groups(input: &str) -> Vec<String> {
77    let mut groups = Vec::new();
78    let mut cur = String::new();
79    let mut depth = 0i32;
80    for c in input.chars() {
81        match c {
82            '[' => {
83                depth += 1;
84                cur.push(c);
85            }
86            ']' => {
87                depth -= 1;
88                cur.push(c);
89            }
90            ',' if depth <= 0 => {
91                groups.push(cur.clone());
92                cur.clear();
93            }
94            _ => cur.push(c),
95        }
96    }
97    groups.push(cur);
98    groups
99}
100
101fn parse_complex(group: &str) -> Option<Complex> {
102    let mut steps: Vec<(Comb, Compound)> = Vec::new();
103    let mut chars = group.chars().peekable();
104    let mut comb = Comb::Descendant;
105
106    loop {
107        skip_ws(&mut chars);
108        if matches!(chars.peek(), Some('>')) {
109            chars.next();
110            comb = Comb::Child;
111            skip_ws(&mut chars);
112        }
113
114        let mut buf = String::new();
115        let mut in_bracket = false;
116        while let Some(&c) = chars.peek() {
117            if in_bracket {
118                buf.push(c);
119                chars.next();
120                if c == ']' {
121                    in_bracket = false;
122                }
123            } else if c == '[' {
124                in_bracket = true;
125                buf.push(c);
126                chars.next();
127            } else if c.is_whitespace() || c == '>' {
128                break;
129            } else {
130                buf.push(c);
131                chars.next();
132            }
133        }
134
135        if buf.is_empty() {
136            break;
137        }
138        let compound = parse_compound(&buf)?;
139        steps.push((comb, compound));
140        comb = Comb::Descendant;
141        if chars.peek().is_none() {
142            break;
143        }
144    }
145
146    if steps.is_empty() {
147        None
148    } else {
149        Some(Complex { steps })
150    }
151}
152
153fn parse_compound(s: &str) -> Option<Compound> {
154    let mut tag = None;
155    let mut id = None;
156    let mut classes = Vec::new();
157    let mut attrs = Vec::new();
158    let mut chars = s.chars().peekable();
159
160    // Optional leading type selector.
161    match chars.peek() {
162        Some('*') => {
163            chars.next();
164        }
165        Some(&c) if is_ident_start(c) => {
166            let mut t = String::new();
167            while let Some(&c) = chars.peek() {
168                if is_ident_char(c) {
169                    t.push(c.to_ascii_lowercase());
170                    chars.next();
171                } else {
172                    break;
173                }
174            }
175            tag = Some(t);
176        }
177        _ => {}
178    }
179
180    while let Some(&c) = chars.peek() {
181        match c {
182            '#' => {
183                chars.next();
184                id = Some(read_ident(&mut chars));
185            }
186            '.' => {
187                chars.next();
188                classes.push(read_ident(&mut chars));
189            }
190            '[' => {
191                chars.next();
192                attrs.push(read_attr(&mut chars));
193            }
194            _ => {
195                chars.next();
196            }
197        }
198    }
199
200    Some(Compound {
201        tag,
202        id,
203        classes,
204        attrs,
205    })
206}
207
208fn read_ident(chars: &mut Peekable<Chars<'_>>) -> String {
209    let mut s = String::new();
210    while let Some(&c) = chars.peek() {
211        if is_ident_char(c) {
212            s.push(c);
213            chars.next();
214        } else {
215            break;
216        }
217    }
218    s
219}
220
221fn read_attr(chars: &mut Peekable<Chars<'_>>) -> AttrPred {
222    let mut inner = String::new();
223    for c in chars.by_ref() {
224        if c == ']' {
225            break;
226        }
227        inner.push(c);
228    }
229    let inner = inner.trim();
230    match inner.find('=') {
231        Some(eq) => {
232            let name = inner.get(..eq).unwrap_or("").trim().to_ascii_lowercase();
233            let mut val = inner.get(eq + 1..).unwrap_or("").trim();
234            val = strip_quotes(val);
235            AttrPred {
236                name,
237                value: Some(val.to_string()),
238            }
239        }
240        None => AttrPred {
241            name: inner.to_ascii_lowercase(),
242            value: None,
243        },
244    }
245}
246
247fn strip_quotes(v: &str) -> &str {
248    let bytes = v.as_bytes();
249    if bytes.len() >= 2 {
250        let first = bytes.first().copied();
251        let last = bytes.last().copied();
252        if (first == Some(b'"') && last == Some(b'"'))
253            || (first == Some(b'\'') && last == Some(b'\''))
254        {
255            return v.get(1..v.len() - 1).unwrap_or(v);
256        }
257    }
258    v
259}
260
261fn skip_ws(chars: &mut Peekable<Chars<'_>>) {
262    while let Some(&c) = chars.peek() {
263        if c.is_whitespace() {
264            chars.next();
265        } else {
266            break;
267        }
268    }
269}
270
271fn is_ident_start(c: char) -> bool {
272    c.is_ascii_alphabetic() || c == '_' || c == '-'
273}
274
275fn is_ident_char(c: char) -> bool {
276    c.is_ascii_alphanumeric() || c == '_' || c == '-'
277}
278
279// --- matching ------------------------------------------------------------
280
281fn matches_complex(node: NodeRef<'_>, complex: &Complex) -> bool {
282    let n = complex.steps.len();
283    if n == 0 {
284        return false;
285    }
286    matches_from(node, &complex.steps, n - 1)
287}
288
289fn matches_from(node: NodeRef<'_>, steps: &[(Comb, Compound)], i: usize) -> bool {
290    let (comb, compound) = match steps.get(i) {
291        Some(s) => s,
292        None => return false,
293    };
294    if !simple_matches(node, compound) {
295        return false;
296    }
297    if i == 0 {
298        return true;
299    }
300    match comb {
301        Comb::Child => node.parent().is_some_and(|p| matches_from(p, steps, i - 1)),
302        Comb::Descendant => {
303            let mut anc = node.parent();
304            while let Some(a) = anc {
305                if matches_from(a, steps, i - 1) {
306                    return true;
307                }
308                anc = a.parent();
309            }
310            false
311        }
312    }
313}
314
315fn simple_matches(node: NodeRef<'_>, c: &Compound) -> bool {
316    if let Some(tag) = &c.tag {
317        match node.tag_name() {
318            Some(t) if t.eq_ignore_ascii_case(tag) => {}
319            _ => return false,
320        }
321    }
322    if let Some(id) = &c.id {
323        if node.attr("id") != Some(id.as_str()) {
324            return false;
325        }
326    }
327    for class in &c.classes {
328        if !node.has_class(class) {
329            return false;
330        }
331    }
332    for attr in &c.attrs {
333        match node.attr(&attr.name) {
334            None => return false,
335            Some(v) => {
336                if let Some(want) = &attr.value {
337                    if v != want {
338                        return false;
339                    }
340                }
341            }
342        }
343    }
344    true
345}