Skip to main content

rama_http/protocols/html/selector/
parser.rs

1//! Hand-rolled recursive-descent parser for the supported CSS selector
2//! subset (see the [module docs](super) for the grammar).
3//!
4//! The parser operates over `&str` with a byte cursor that always rests on
5//! a UTF-8 boundary (it only advances by whole `char`s). It returns a
6//! typed [`SelectorError`] for any malformed or unsupported input and
7//! never panics.
8
9use std::str::FromStr;
10
11use super::SelectorError;
12use super::ast::{
13    AttributeOperator, AttributeSelector, CaseSensitivity, Combinator, ComplexSelector, Compound,
14    LocalName, Nth, NthType, Selector, SelectorPart,
15};
16
17impl FromStr for Selector {
18    type Err = SelectorError;
19
20    fn from_str(s: &str) -> Result<Self, Self::Err> {
21        Parser::new(s).parse_selector()
22    }
23}
24
25struct Parser<'i> {
26    input: &'i str,
27    pos: usize,
28}
29
30impl<'i> Parser<'i> {
31    fn new(input: &'i str) -> Self {
32        Self { input, pos: 0 }
33    }
34
35    fn rest(&self) -> &'i str {
36        // `pos` is always a char boundary, so this slice never panics.
37        self.input.get(self.pos..).unwrap_or("")
38    }
39
40    fn peek(&self) -> Option<char> {
41        self.rest().chars().next().map(preprocess)
42    }
43
44    fn peek_nth(&self, n: usize) -> Option<char> {
45        self.rest().chars().nth(n).map(preprocess)
46    }
47
48    fn bump(&mut self) -> Option<char> {
49        let raw = self.rest().chars().next()?;
50        self.pos += raw.len_utf8();
51        Some(preprocess(raw))
52    }
53
54    fn eat(&mut self, expected: char) -> bool {
55        if self.peek() == Some(expected) {
56            self.pos += expected.len_utf8();
57            true
58        } else {
59            false
60        }
61    }
62
63    fn eof(&self) -> bool {
64        self.pos >= self.input.len()
65    }
66
67    /// Skips CSS whitespace, returning whether any was consumed.
68    fn skip_ws(&mut self) -> bool {
69        let mut any = false;
70        while self.peek().is_some_and(is_css_whitespace) {
71            self.pos += 1;
72            any = true;
73        }
74        any
75    }
76
77    fn parse_selector(&mut self) -> Result<Selector, SelectorError> {
78        self.skip_ws();
79        if self.eof() {
80            return Err(SelectorError::EmptySelector);
81        }
82
83        let mut selectors = Vec::new();
84        loop {
85            selectors.push(self.parse_complex()?);
86            self.skip_ws();
87            match self.peek() {
88                None => break,
89                Some(',') => {
90                    self.bump();
91                    self.skip_ws();
92                    if self.peek().is_none() {
93                        return Err(SelectorError::UnexpectedEnd);
94                    }
95                }
96                Some('+') => return Err(SelectorError::UnsupportedCombinator('+')),
97                Some('~') => return Err(SelectorError::UnsupportedCombinator('~')),
98                Some(_) => return Err(SelectorError::UnexpectedToken),
99            }
100        }
101        Ok(Selector { selectors })
102    }
103
104    fn parse_complex(&mut self) -> Result<ComplexSelector, SelectorError> {
105        let mut parts = vec![SelectorPart {
106            combinator: None,
107            compound: self.parse_compound()?,
108        }];
109
110        loop {
111            let saw_ws = self.skip_ws();
112            match self.peek() {
113                None | Some(',') => break,
114                Some('>') => {
115                    self.bump();
116                    self.skip_ws();
117                    if matches!(self.peek(), None | Some(',')) {
118                        return Err(SelectorError::DanglingCombinator);
119                    }
120                    parts.push(SelectorPart {
121                        combinator: Some(Combinator::Child),
122                        compound: self.parse_compound()?,
123                    });
124                }
125                Some('+') => return Err(SelectorError::UnsupportedCombinator('+')),
126                Some('~') => return Err(SelectorError::UnsupportedCombinator('~')),
127                Some(_) => {
128                    if !saw_ws {
129                        return Err(SelectorError::UnexpectedToken);
130                    }
131                    parts.push(SelectorPart {
132                        combinator: Some(Combinator::Descendant),
133                        compound: self.parse_compound()?,
134                    });
135                }
136            }
137        }
138
139        Ok(ComplexSelector { parts })
140    }
141
142    fn parse_compound(&mut self) -> Result<Compound, SelectorError> {
143        let mut compound = Compound::default();
144        let mut has_any = false;
145
146        // Optional type / universal selector.
147        match self.peek() {
148            Some('*') => {
149                self.bump();
150                compound.explicit_universal = true;
151                has_any = true;
152                if self.peek() == Some('|') {
153                    return Err(SelectorError::NamespacedSelector);
154                }
155            }
156            Some('|') => return Err(SelectorError::NamespacedSelector),
157            Some(c) if is_ident_start(c) || c == '\\' || c == '-' => {
158                let name = self.parse_ident()?;
159                if self.peek() == Some('|') {
160                    return Err(SelectorError::NamespacedSelector);
161                }
162                compound.name = Some(LocalName::new(&name));
163                has_any = true;
164            }
165            _ => {}
166        }
167
168        // Subclass selectors.
169        loop {
170            match self.peek() {
171                Some('#') => {
172                    self.bump();
173                    compound.id = Some(self.parse_ident()?.into());
174                }
175                Some('.') => {
176                    self.bump();
177                    compound.classes.push(self.parse_ident()?.into());
178                }
179                Some('[') => compound.attributes.push(self.parse_attribute()?),
180                Some(':') => self.parse_pseudo(&mut compound)?,
181                _ => break,
182            }
183            has_any = true;
184        }
185
186        if !has_any {
187            return Err(if self.eof() {
188                SelectorError::UnexpectedEnd
189            } else {
190                SelectorError::UnexpectedToken
191            });
192        }
193        Ok(compound)
194    }
195
196    fn parse_attribute(&mut self) -> Result<AttributeSelector, SelectorError> {
197        self.bump(); // consume '['
198        self.skip_ws();
199
200        let name = match self.peek() {
201            Some('=' | ']') => return Err(SelectorError::MissingAttributeName),
202            Some('*' | '|') => return Err(SelectorError::NamespacedSelector),
203            None => return Err(SelectorError::UnexpectedEnd),
204            _ => self
205                .parse_ident()
206                .map_err(|_err| SelectorError::UnexpectedTokenInAttribute)?,
207        };
208        // A `|` immediately after the name is a namespace separator
209        // (`ns|attr`) — unless it is the start of the `|=` operator.
210        if self.peek() == Some('|') && self.peek_nth(1) != Some('=') {
211            return Err(SelectorError::NamespacedSelector);
212        }
213        self.skip_ws();
214
215        let operator = match self.peek() {
216            Some(']') => None,
217            Some('=') => {
218                self.bump();
219                Some(AttributeOperator::Equals)
220            }
221            Some('~') => Some(self.parse_attr_operator(AttributeOperator::Includes)?),
222            Some('|') => Some(self.parse_attr_operator(AttributeOperator::DashMatch)?),
223            Some('^') => Some(self.parse_attr_operator(AttributeOperator::Prefix)?),
224            Some('$') => Some(self.parse_attr_operator(AttributeOperator::Suffix)?),
225            Some('*') => Some(self.parse_attr_operator(AttributeOperator::Substring)?),
226            None => return Err(SelectorError::UnexpectedEnd),
227            Some(_) => return Err(SelectorError::UnexpectedTokenInAttribute),
228        };
229
230        let (value, case) = if operator.is_some() {
231            self.skip_ws();
232            let value = match self.peek() {
233                Some('"' | '\'') => self.parse_string()?,
234                Some(']') | None => return Err(SelectorError::UnexpectedTokenInAttribute),
235                _ => self
236                    .parse_ident()
237                    .map_err(|_err| SelectorError::UnexpectedTokenInAttribute)?,
238            };
239            self.skip_ws();
240            let case = match self.peek() {
241                Some('i' | 'I') => {
242                    self.bump();
243                    self.skip_ws();
244                    CaseSensitivity::AsciiCaseInsensitive
245                }
246                Some('s' | 'S') => {
247                    self.bump();
248                    self.skip_ws();
249                    CaseSensitivity::CaseSensitive
250                }
251                _ => CaseSensitivity::CaseSensitive,
252            };
253            (value, case)
254        } else {
255            (String::new(), CaseSensitivity::CaseSensitive)
256        };
257
258        if !self.eat(']') {
259            return Err(if self.eof() {
260                SelectorError::UnexpectedEnd
261            } else {
262                SelectorError::UnexpectedTokenInAttribute
263            });
264        }
265
266        Ok(AttributeSelector {
267            name: name.to_ascii_lowercase().into(),
268            operator,
269            value: value.into(),
270            case,
271        })
272    }
273
274    /// Consumes the trailing `=` of a two-character attribute operator
275    /// (e.g. the `=` in `~=`), returning the given operator.
276    fn parse_attr_operator(
277        &mut self,
278        operator: AttributeOperator,
279    ) -> Result<AttributeOperator, SelectorError> {
280        self.bump(); // the operator's first char (`~`, `|`, `^`, `$`, `*`)
281        if self.eat('=') {
282            Ok(operator)
283        } else {
284            Err(SelectorError::UnexpectedTokenInAttribute)
285        }
286    }
287
288    fn parse_pseudo(&mut self, compound: &mut Compound) -> Result<(), SelectorError> {
289        self.bump(); // consume ':'
290        if self.eat(':') {
291            // Pseudo-element — unsupported. The cursor position no longer
292            // matters once we return an error.
293            return Err(SelectorError::UnsupportedPseudoClass);
294        }
295
296        let name = self.parse_ident()?.to_ascii_lowercase();
297
298        if self.eat('(') {
299            match name.as_str() {
300                "nth-child" => {
301                    let (a, b) = self.parse_nth_args()?;
302                    compound.nth.push(Nth {
303                        ty: NthType::Child,
304                        a,
305                        b,
306                    });
307                }
308                "nth-of-type" => {
309                    let (a, b) = self.parse_nth_args()?;
310                    compound.nth.push(Nth {
311                        ty: NthType::OfType,
312                        a,
313                        b,
314                    });
315                }
316                "not" => self.parse_negation(compound)?,
317                _ => {
318                    self.skip_to_close_paren();
319                    return Err(SelectorError::UnsupportedPseudoClass);
320                }
321            }
322            Ok(())
323        } else {
324            match name.as_str() {
325                "first-child" => {
326                    compound.nth.push(Nth {
327                        ty: NthType::Child,
328                        a: 0,
329                        b: 1,
330                    });
331                    Ok(())
332                }
333                "first-of-type" => {
334                    compound.nth.push(Nth {
335                        ty: NthType::OfType,
336                        a: 0,
337                        b: 1,
338                    });
339                    Ok(())
340                }
341                _ => Err(SelectorError::UnsupportedPseudoClass),
342            }
343        }
344    }
345
346    fn parse_negation(&mut self, compound: &mut Compound) -> Result<(), SelectorError> {
347        // The opening '(' has already been consumed.
348        self.skip_ws();
349        if self.peek() == Some(')') {
350            self.bump();
351            return Err(SelectorError::EmptyNegation);
352        }
353        loop {
354            compound.negations.push(self.parse_compound()?);
355            self.skip_ws();
356            match self.peek() {
357                Some(')') => {
358                    self.bump();
359                    return Ok(());
360                }
361                Some(',') => {
362                    self.bump();
363                    self.skip_ws();
364                    if self.peek() == Some(')') {
365                        return Err(SelectorError::EmptyNegation);
366                    }
367                }
368                None => return Err(SelectorError::UnexpectedEnd),
369                // A combinator (or any other content) inside `:not()` means
370                // the argument is not a bare compound — unsupported.
371                Some(_) => return Err(SelectorError::UnsupportedPseudoClass),
372            }
373        }
374    }
375
376    /// Parses the `An+B` argument of an `:nth-*()` and the closing `)`.
377    fn parse_nth_args(&mut self) -> Result<(i32, i32), SelectorError> {
378        self.skip_ws();
379        let anb = self.parse_anb()?;
380        self.skip_ws();
381        if !self.eat(')') {
382            return Err(SelectorError::UnexpectedToken);
383        }
384        Ok(anb)
385    }
386
387    /// Parses the `An+B` micro-syntax (CSS Syntax §"The An+B microsyntax").
388    fn parse_anb(&mut self) -> Result<(i32, i32), SelectorError> {
389        if self.match_keyword_ci("even") {
390            return Ok((2, 0));
391        }
392        if self.match_keyword_ci("odd") {
393            return Ok((2, 1));
394        }
395
396        // Optional leading sign. No whitespace may follow it.
397        let mut sign = 1i64;
398        let has_sign = match self.peek() {
399            Some('+') => {
400                self.bump();
401                true
402            }
403            Some('-') => {
404                self.bump();
405                sign = -1;
406                true
407            }
408            _ => false,
409        };
410        if has_sign && self.peek().is_some_and(is_css_whitespace) {
411            return Err(SelectorError::InvalidNth);
412        }
413
414        let digits = self.read_digits();
415
416        if matches!(self.peek(), Some('n' | 'N')) {
417            self.bump();
418            let a = sign * digits.unwrap_or(1);
419            let b = self.parse_anb_b()?;
420            Ok((clamp_i32(a)?, clamp_i32(b)?))
421        } else {
422            let value = digits.ok_or(SelectorError::InvalidNth)?;
423            Ok((0, clamp_i32(sign * value)?))
424        }
425    }
426
427    /// Parses the optional `± B` tail of an `An+B` value (after `n`).
428    fn parse_anb_b(&mut self) -> Result<i64, SelectorError> {
429        let save = self.pos;
430        self.skip_ws();
431        let sign = match self.peek() {
432            Some('+') => {
433                self.bump();
434                1
435            }
436            Some('-') => {
437                self.bump();
438                -1
439            }
440            _ => {
441                self.pos = save;
442                return Ok(0);
443            }
444        };
445        self.skip_ws();
446        if matches!(self.peek(), Some('+' | '-')) {
447            return Err(SelectorError::InvalidNth);
448        }
449        let digits = self.read_digits().ok_or(SelectorError::InvalidNth)?;
450        Ok(sign * digits)
451    }
452
453    fn read_digits(&mut self) -> Option<i64> {
454        let mut value: i64 = 0;
455        let mut any = false;
456        while let Some(c) = self.peek() {
457            let Some(d) = c.to_digit(10) else { break };
458            value = value.saturating_mul(10).saturating_add(i64::from(d));
459            any = true;
460            self.bump();
461        }
462        any.then_some(value)
463    }
464
465    /// Consumes `keyword` (ASCII case-insensitive) if it is next and is
466    /// not immediately followed by an identifier character.
467    fn match_keyword_ci(&mut self, keyword: &str) -> bool {
468        let rest = self.rest();
469        if rest.len() < keyword.len() {
470            return false;
471        }
472        let Some(head) = rest.get(..keyword.len()) else {
473            return false;
474        };
475        if !head.eq_ignore_ascii_case(keyword) {
476            return false;
477        }
478        if let Some(next) = rest[keyword.len()..].chars().next()
479            && is_ident_char(next)
480        {
481            return false;
482        }
483        self.pos += keyword.len();
484        true
485    }
486
487    fn parse_ident(&mut self) -> Result<String, SelectorError> {
488        let mut out = String::new();
489        match self.peek() {
490            Some('\\') => out.push(self.parse_escape()?),
491            Some('-') => {
492                self.bump();
493                out.push('-');
494            }
495            Some(c) if is_ident_start(c) => {
496                self.bump();
497                out.push(c);
498            }
499            _ => {
500                return Err(if self.eof() {
501                    SelectorError::UnexpectedEnd
502                } else {
503                    SelectorError::UnexpectedToken
504                });
505            }
506        }
507
508        loop {
509            match self.peek() {
510                Some('\\') => out.push(self.parse_escape()?),
511                Some(c) if is_ident_char(c) => {
512                    self.bump();
513                    out.push(c);
514                }
515                _ => break,
516            }
517        }
518
519        if out == "-" {
520            return Err(SelectorError::UnexpectedToken);
521        }
522        Ok(out)
523    }
524
525    fn parse_string(&mut self) -> Result<String, SelectorError> {
526        let quote = self.bump().unwrap_or('"');
527        let mut out = String::new();
528        loop {
529            match self.peek() {
530                None => return Err(SelectorError::UnexpectedEnd),
531                Some(c) if c == quote => {
532                    self.bump();
533                    break;
534                }
535                Some('\n') => return Err(SelectorError::UnexpectedToken),
536                Some('\\') => {
537                    self.bump();
538                    match self.peek() {
539                        None => return Err(SelectorError::UnexpectedEnd),
540                        // Escaped newline is a line continuation: emit nothing.
541                        Some('\n') => {
542                            self.bump();
543                        }
544                        Some(c) if c.is_ascii_hexdigit() => out.push(self.parse_hex_escape()),
545                        Some(c) => {
546                            self.bump();
547                            out.push(c);
548                        }
549                    }
550                }
551                Some(c) => {
552                    self.bump();
553                    out.push(c);
554                }
555            }
556        }
557        Ok(out)
558    }
559
560    /// Parses a CSS escape sequence; the leading `\` is the current char.
561    fn parse_escape(&mut self) -> Result<char, SelectorError> {
562        self.bump(); // consume '\'
563        match self.peek() {
564            None | Some('\n') => Err(SelectorError::UnexpectedToken),
565            Some(c) if c.is_ascii_hexdigit() => Ok(self.parse_hex_escape()),
566            Some(c) => {
567                self.bump();
568                Ok(c)
569            }
570        }
571    }
572
573    /// Parses 1-6 hex digits (the current char is the first one) plus an
574    /// optional single trailing whitespace, returning the code point.
575    fn parse_hex_escape(&mut self) -> char {
576        let mut value: u32 = 0;
577        let mut count = 0;
578        while count < 6 {
579            match self.peek().and_then(|c| c.to_digit(16)) {
580                Some(d) => {
581                    value = value * 16 + d;
582                    self.bump();
583                    count += 1;
584                }
585                None => break,
586            }
587        }
588        if self.peek().is_some_and(is_css_whitespace) {
589            self.bump();
590        }
591        if value == 0 || (0xD800..=0xDFFF).contains(&value) || value > 0x0010_FFFF {
592            '\u{FFFD}'
593        } else {
594            char::from_u32(value).unwrap_or('\u{FFFD}')
595        }
596    }
597
598    /// Skips input up to and including the matching `)` (the opening `(`
599    /// having been consumed). Used to recover after an unsupported
600    /// functional pseudo-class.
601    fn skip_to_close_paren(&mut self) {
602        let mut depth = 1usize;
603        while let Some(c) = self.bump() {
604            match c {
605                '(' => depth += 1,
606                ')' => {
607                    depth -= 1;
608                    if depth == 0 {
609                        break;
610                    }
611                }
612                _ => {}
613            }
614        }
615    }
616}
617
618fn clamp_i32(value: i64) -> Result<i32, SelectorError> {
619    i32::try_from(value).map_err(|_err| SelectorError::InvalidNth)
620}
621
622/// CSS input preprocessing (CSS Syntax §3.3): a U+0000 NULL input code
623/// point is treated as U+FFFD REPLACEMENT CHARACTER. Applying this at the
624/// cursor keeps the parser and the CSSOM serializer in agreement, so
625/// parsing always round-trips.
626fn preprocess(c: char) -> char {
627    if c == '\0' { '\u{FFFD}' } else { c }
628}
629
630/// CSS whitespace (CSS Syntax §"Whitespace"): space, tab, LF, FF, CR.
631fn is_css_whitespace(c: char) -> bool {
632    matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{0c}')
633}
634
635fn is_ident_start(c: char) -> bool {
636    c.is_ascii_alphabetic() || c == '_' || !c.is_ascii()
637}
638
639fn is_ident_char(c: char) -> bool {
640    is_ident_start(c) || c.is_ascii_digit() || c == '-'
641}