Skip to main content

media_query_parse/
parser.rs

1//! Recursive-descent parser for the Media Queries Level 4 grammar,
2//! built on top of the [`crate::tokenizer::Tokenizer`] from phase 02.
3//!
4//! One function per grammar production (see the doc comment on each
5//! function below for the production it implements), mirroring the
6//! structure of the grammar in `plan/03-grammar.md` §"Grammatik-
7//! Produktionen".
8//!
9//! **Parsing approach — no tokenizer backtracking.** `<media-in-parens>`
10//! has three alternatives behind the same opening `(` (or function
11//! token). Instead of backtracking the main token stream, the content
12//! of a parenthesized block is first consumed as one atomic token
13//! block (analogous to "Consume a simple block", [CSS Syntax Level
14//! 3][syntax-3] §5.4.8: all tokens up to the mirror closing token,
15//! respecting nested brackets), then `<media-condition>` and
16//! `<media-feature>` are tried in turn against that isolated token
17//! slice. If both fail, the block is kept verbatim as
18//! `<general-enclosed>` (see `ast::GeneralEnclosed`) — lookahead stays
19//! confined to the bracket content instead of needing true backtracking
20//! on the main stream.
21//!
22//! **Error handling.** Two distinct failure kinds exist per spec §3.2:
23//! a syntax error a parser can't even parse as a block (e.g. unbalanced
24//! parentheses) is a real error, represented by [`ParseError`] /
25//! `Result`. A single `<media-query>` list entry that is syntactically
26//! well-formed as a block but doesn't match the grammar (e.g. an
27//! unknown media feature) is, per spec, a browser-matching concern (UAs
28//! replace it with `not all`) — this crate has no "matches" concept
29//! (see `CLAUDE.md`), so [`parse_media_query_list`] surfaces the actual
30//! per-entry `Result` instead of silently rewriting it.
31//!
32//! [syntax-3]: https://www.w3.org/TR/css-syntax-3/
33
34use crate::ast::{
35    GeneralEnclosed, MediaCondition, MediaConditionWithoutOr, MediaFeature, MediaInParens,
36    MediaModifier, MediaQuery, MediaType, MfComparison, MfName, MfRange, MfRangeDirection, MfValue,
37};
38use crate::tokenizer::{NumericType, Token, tokenize};
39
40/// A real syntax error, distinct from a `<media-query>` that is
41/// syntactically well-formed but doesn't match the grammar (see the
42/// module doc comment). One variant per failure cause.
43#[derive(Debug, Clone, PartialEq)]
44#[non_exhaustive]
45pub enum ParseError {
46    /// A `(` (or a function's implicit `(`) was never closed before
47    /// the input ended.
48    UnbalancedParens,
49    /// A `<media-query-list>` entry (or a `<media-in-parens>` block)
50    /// had no tokens to parse.
51    EmptyMediaQuery,
52    /// Extra tokens remained after a production was fully parsed.
53    TrailingTokens(Token),
54    /// Expected a `<media-type>` (a plain `<ident>`).
55    ExpectedMediaType(Option<Token>),
56    /// `<media-type>` matched one of the reserved keywords
57    /// `not`/`and`/`or`/`only`/`layer`, which the grammar excludes.
58    ReservedMediaType(String),
59    /// Expected `<media-in-parens>` (`(` or a function token).
60    ExpectedMediaInParens(Option<Token>),
61    /// Expected an `<ident>` for `<mf-name>`.
62    ExpectedMfName(Option<Token>),
63    /// Expected `:` between `<mf-name>` and `<mf-value>`.
64    ExpectedColon(Option<Token>),
65    /// Expected an `<mf-value>` (`<number>`/`<dimension>`/`<ident>`/`<ratio>`).
66    ExpectedMfValue(Option<Token>),
67    /// A `<ratio>` (`<number> / <number>`) was malformed.
68    InvalidRatio,
69    /// Expected an `<mf-comparison>` (`<`, `<=`, `>`, `>=`, or `=`).
70    ExpectedMfComparison(Option<Token>),
71    /// The second comparison operator of a two-sided `<mf-range>` was
72    /// missing, from the wrong family (mixing `<mf-lt>`/`<mf-gt>`), or
73    /// the first operator was `<mf-eq>` (`<mf-eq>` never appears in the
74    /// two-sided form) — see the grammar note on [`crate::ast::MfRange`].
75    InvalidMfRangeInterval,
76    /// Expected a specific keyword (`not`/`and`/`or`).
77    ExpectedKeyword {
78        /// The keyword that was expected.
79        keyword: &'static str,
80        /// The token found instead, or `None` at end of input.
81        found: Option<Token>,
82    },
83}
84
85/// Maps an opening token to its mirror closing token, per [CSS Syntax
86/// Level 3][spec] §5.4.8 ("Consume a simple block"). `None` if `token`
87/// is not an opener.
88///
89/// [spec]: https://www.w3.org/TR/css-syntax-3/
90fn matching_close(token: &Token) -> Option<Token> {
91    match token {
92        Token::OpenParen | Token::Function(_) => Some(Token::CloseParen),
93        Token::OpenSquare => Some(Token::CloseSquare),
94        Token::OpenCurly => Some(Token::CloseCurly),
95        _ => None,
96    }
97}
98
99fn is_ident_ci(token: Option<&Token>, keyword: &str) -> bool {
100    matches!(token, Some(Token::Ident(s)) if s.eq_ignore_ascii_case(keyword))
101}
102
103/// A token paired with whether a [`Token::Whitespace`] token
104/// immediately preceded it in the raw tokenizer output, before
105/// whitespace tokens are stripped by [`prepare_tokens`]. Needed to
106/// recognize `<mf-lt>`/`<mf-gt>` (`'<' '='?` / `'>' '='?`) as two
107/// *directly adjacent* `Delim` tokens with no space between — after
108/// whitespace tokens are stripped, `<=` and `< =` would otherwise both
109/// tokenize to the same `Delim('<'), Delim('=')` pair and become
110/// indistinguishable. See [`mf_comparison`] and `plan/DECISIONS.md`.
111type PositionedToken = (Token, bool);
112
113/// Cursor over a token slice for the recursive-descent parser. Plays
114/// the role of the `Peekable<Tokenizer>` wrapper from `plan/03-
115/// grammar.md`, specialized to a slice so that `<media-in-parens>`
116/// content (already isolated via [`Parser::consume_block`]) can be
117/// parsed the same way as a top-level `<media-query>`.
118struct Parser<'a> {
119    tokens: &'a [PositionedToken],
120    pos: usize,
121}
122
123impl<'a> Parser<'a> {
124    fn new(tokens: &'a [PositionedToken]) -> Self {
125        Self { tokens, pos: 0 }
126    }
127
128    fn peek(&self) -> Option<&Token> {
129        self.tokens.get(self.pos).map(|(token, _)| token)
130    }
131
132    fn peek_at(&self, offset: usize) -> Option<&Token> {
133        self.tokens.get(self.pos + offset).map(|(token, _)| token)
134    }
135
136    fn advance(&mut self) -> Option<Token> {
137        let token = self.peek().cloned();
138        if token.is_some() {
139            self.pos += 1;
140        }
141        token
142    }
143
144    fn is_at_end(&self) -> bool {
145        self.pos == self.tokens.len()
146    }
147
148    /// Whether the token at the current position was immediately
149    /// preceded by whitespace in the original, unfiltered token
150    /// stream. Used by [`mf_comparison`] to tell `<=`/`>=` (no
151    /// whitespace between `<`/`>` and `=`) apart from `< =`/`> =` (an
152    /// `<mf-lt>`/`<mf-gt>` operator immediately followed by an
153    /// unrelated `<mf-eq>`).
154    fn current_preceded_by_whitespace(&self) -> bool {
155        self.tokens
156            .get(self.pos)
157            .map(|(_, preceded_by_ws)| *preceded_by_ws)
158            .unwrap_or(false)
159    }
160
161    /// Consumes a bracketed block starting at the current position
162    /// (which must be an opener: `(`, `[`, `{`, or a function token),
163    /// returning the raw tokens strictly between the matching open/
164    /// close (nested blocks of any kind are included whole). This is
165    /// the "consume content as an atomic block" step described in the
166    /// module doc comment — it never backtracks, it only fails if no
167    /// matching closer is found before the input ends.
168    fn consume_block(&mut self) -> Result<Vec<PositionedToken>, ParseError> {
169        let opener = self.tokens[self.pos].0.clone();
170        let mut closers = vec![matching_close(&opener).expect("caller checked an opener")];
171        self.pos += 1;
172        let mut inner = Vec::new();
173        loop {
174            match self.tokens.get(self.pos).cloned() {
175                None => return Err(ParseError::UnbalancedParens),
176                Some((token, preceded_by_ws)) => {
177                    let closed_outermost = track_bracket_depth(&token, &mut closers);
178                    self.pos += 1;
179                    if closed_outermost {
180                        return Ok(inner);
181                    }
182                    inner.push((token, preceded_by_ws));
183                }
184            }
185        }
186    }
187}
188
189/// Updates `closers` (a stack of pending closing tokens, innermost
190/// last) for `token`, per [CSS Syntax Level 3][spec] §5.4.8's bracket-
191/// nesting rule: opening tokens push their mirror closer, and a token
192/// matching the innermost pending closer pops it. Returns `true` if
193/// `token` closed the outermost bracket, i.e. `closers` just became
194/// empty. Shared by [`Parser::consume_block`] (atomic `<media-in-
195/// parens>` block consumption) and [`split_top_level_commas`]
196/// (`<media-query-list>` splitting), which both need this same
197/// nesting-depth tracking for otherwise unrelated purposes.
198///
199/// [spec]: https://www.w3.org/TR/css-syntax-3/
200fn track_bracket_depth(token: &Token, closers: &mut Vec<Token>) -> bool {
201    if Some(token) == closers.last() {
202        closers.pop();
203        closers.is_empty()
204    } else {
205        if let Some(closer) = matching_close(token) {
206            closers.push(closer);
207        }
208        false
209    }
210}
211
212/// Parses `tokens` fully with `production`, requiring every token to
213/// be consumed — used to try `<media-condition>` and `<media-feature>`
214/// in turn against an already-isolated `<media-in-parens>` block.
215fn parse_fully<T>(
216    tokens: &[PositionedToken],
217    production: impl Fn(&mut Parser) -> Result<T, ParseError>,
218) -> Result<T, ParseError> {
219    let mut parser = Parser::new(tokens);
220    let result = production(&mut parser)?;
221    if parser.is_at_end() {
222        Ok(result)
223    } else {
224        Err(ParseError::TrailingTokens(
225            parser.peek().expect("not at end").clone(),
226        ))
227    }
228}
229
230fn expect_end(parser: &Parser) -> Result<(), ParseError> {
231    match parser.peek() {
232        None => Ok(()),
233        Some(token) => Err(ParseError::TrailingTokens(token.clone())),
234    }
235}
236
237/// Consumes an `<ident>` matching `keyword` case-insensitively, or
238/// fails. Shared by `media_not`/`media_and`/`media_or` below, which
239/// otherwise differ only in which keyword introduces their
240/// `<media-in-parens>` operand.
241fn consume_keyword(parser: &mut Parser, keyword: &'static str) -> Result<(), ParseError> {
242    match parser.advance() {
243        Some(Token::Ident(s)) if s.eq_ignore_ascii_case(keyword) => Ok(()),
244        found => Err(ParseError::ExpectedKeyword { keyword, found }),
245    }
246}
247
248/// `<media-not> = not <media-in-parens>`. Returns the operand, since
249/// the `not` keyword itself carries no data.
250fn media_not(parser: &mut Parser) -> Result<MediaInParens, ParseError> {
251    consume_keyword(parser, "not")?;
252    media_in_parens(parser)
253}
254
255/// `<media-and> = and <media-in-parens>`.
256fn media_and(parser: &mut Parser) -> Result<MediaInParens, ParseError> {
257    consume_keyword(parser, "and")?;
258    media_in_parens(parser)
259}
260
261/// `<media-or> = or <media-in-parens>`.
262fn media_or(parser: &mut Parser) -> Result<MediaInParens, ParseError> {
263    consume_keyword(parser, "or")?;
264    media_in_parens(parser)
265}
266
267/// Parses `<media-in-parens> [ keyword <media-in-parens> ]*`, given the
268/// already-parsed leading `<media-in-parens>` as `first`, `keyword` as
269/// the connective (`"and"`/`"or"`), and `next` as the per-iteration
270/// production (`media_and`/`media_or`). Shared by `<media-condition>`'s
271/// `and`- and `or`-branches and by `<media-condition-without-or>`,
272/// which all reduce to exactly this "one operand, then zero or more
273/// `keyword`-joined operands" shape.
274fn in_parens_chain(
275    parser: &mut Parser,
276    first: MediaInParens,
277    keyword: &'static str,
278    next: impl Fn(&mut Parser) -> Result<MediaInParens, ParseError>,
279) -> Result<Vec<MediaInParens>, ParseError> {
280    let mut items = vec![first];
281    while is_ident_ci(parser.peek(), keyword) {
282        items.push(next(parser)?);
283    }
284    Ok(items)
285}
286
287/// Shared prefix of `<media-condition>` and `<media-condition-without-or>`:
288/// both productions are `<media-not> | <media-in-parens> ...`. Returns
289/// `(true, operand)` for the `<media-not>` branch (`operand` being its
290/// `<media-in-parens>` operand), or `(false, operand)` for the plain
291/// leading `<media-in-parens>`, for the caller to continue parsing.
292fn media_not_or_first_in_parens(parser: &mut Parser) -> Result<(bool, MediaInParens), ParseError> {
293    if is_ident_ci(parser.peek(), "not") {
294        return Ok((true, media_not(parser)?));
295    }
296    Ok((false, media_in_parens(parser)?))
297}
298
299/// `<media-condition> = <media-not> | <media-in-parens> [ <media-and>* | <media-or>* ]`
300fn media_condition(parser: &mut Parser) -> Result<MediaCondition, ParseError> {
301    let (is_not, first) = media_not_or_first_in_parens(parser)?;
302    if is_not {
303        return Ok(MediaCondition::Not(first));
304    }
305    if is_ident_ci(parser.peek(), "or") {
306        return Ok(MediaCondition::Or(in_parens_chain(
307            parser, first, "or", media_or,
308        )?));
309    }
310    Ok(MediaCondition::And(in_parens_chain(
311        parser, first, "and", media_and,
312    )?))
313}
314
315/// `<media-condition-without-or> = <media-not> | <media-in-parens> <media-and>*`
316fn media_condition_without_or(parser: &mut Parser) -> Result<MediaConditionWithoutOr, ParseError> {
317    let (is_not, first) = media_not_or_first_in_parens(parser)?;
318    if is_not {
319        return Ok(MediaConditionWithoutOr::Not(first));
320    }
321    Ok(MediaConditionWithoutOr::And(in_parens_chain(
322        parser, first, "and", media_and,
323    )?))
324}
325
326/// `<media-in-parens> = ( <media-condition> ) | ( <media-feature> ) | <general-enclosed>`
327fn media_in_parens(parser: &mut Parser) -> Result<MediaInParens, ParseError> {
328    match parser.peek() {
329        Some(Token::OpenParen) => {
330            let inner = parser.consume_block()?;
331            if let Ok(condition) = parse_fully(&inner, media_condition) {
332                return Ok(MediaInParens::Condition(Box::new(condition)));
333            }
334            if let Ok(feature) = parse_fully(&inner, media_feature) {
335                return Ok(MediaInParens::Feature(feature));
336            }
337            Ok(MediaInParens::GeneralEnclosed(GeneralEnclosed {
338                tokens: inner.into_iter().map(|(token, _)| token).collect(),
339            }))
340        }
341        Some(Token::Function(_)) => {
342            // <general-enclosed> alternative: [ <function-token> <any-value>? ) ]
343            let function_token = parser.tokens[parser.pos].0.clone();
344            let inner = parser.consume_block()?;
345            let mut tokens = vec![function_token];
346            tokens.extend(inner.into_iter().map(|(token, _)| token));
347            Ok(MediaInParens::GeneralEnclosed(GeneralEnclosed { tokens }))
348        }
349        other => Err(ParseError::ExpectedMediaInParens(other.cloned())),
350    }
351}
352
353/// `<mf-name> = <ident>`
354fn mf_name(parser: &mut Parser) -> Result<MfName, ParseError> {
355    match parser.advance() {
356        Some(Token::Ident(name)) => Ok(MfName(name)),
357        other => Err(ParseError::ExpectedMfName(other)),
358    }
359}
360
361/// `<mf-value> = <number> | <dimension> | <ident> | <ratio>`
362fn mf_value(parser: &mut Parser) -> Result<MfValue, ParseError> {
363    match parser.advance() {
364        Some(Token::Number { value, type_flag }) => {
365            if matches!(parser.peek(), Some(Token::Delim('/'))) {
366                parser.advance();
367                match parser.advance() {
368                    Some(Token::Number {
369                        value: denominator,
370                        type_flag: NumericType::Integer,
371                    }) if type_flag == NumericType::Integer
372                        && value >= 0.0
373                        && denominator >= 0.0
374                        && value <= u32::MAX as f64
375                        && denominator <= u32::MAX as f64 =>
376                    {
377                        Ok(MfValue::Ratio {
378                            numerator: value as u32,
379                            denominator: denominator as u32,
380                        })
381                    }
382                    _ => Err(ParseError::InvalidRatio),
383                }
384            } else {
385                Ok(MfValue::Number(value))
386            }
387        }
388        Some(Token::Dimension { value, unit, .. }) => Ok(MfValue::Dimension { value, unit }),
389        Some(Token::Ident(name)) => Ok(MfValue::Ident(name)),
390        other => Err(ParseError::ExpectedMfValue(other)),
391    }
392}
393
394/// Whether `token` can start an `<mf-comparison>` (`<`, `<=`, `>`,
395/// `>=`, or `=`) — i.e. is a `Delim` of `<`, `>`, or `=`.
396fn starts_mf_comparison(token: Option<&Token>) -> bool {
397    matches!(token, Some(Token::Delim('<' | '>' | '=')))
398}
399
400/// `<mf-comparison> = <mf-lt> | <mf-gt> | <mf-eq>`
401/// `<mf-lt> = '<' '='?`, `<mf-gt> = '>' '='?`, `<mf-eq> = '='`.
402///
403/// The optional `=` in `<mf-lt>`/`<mf-gt>` must be *directly adjacent*
404/// to the `<`/`>` — the grammar's `'<' '='?` denotes two adjacent
405/// characters, not two independent component values separated by
406/// whitespace — hence the [`Parser::current_preceded_by_whitespace`]
407/// check before folding the `=` into `Le`/`Ge`. Without it, `<=` and
408/// `< =` would be indistinguishable once whitespace tokens are
409/// stripped (see [`PositionedToken`]).
410fn mf_comparison(parser: &mut Parser) -> Result<MfComparison, ParseError> {
411    match parser.advance() {
412        Some(Token::Delim('<')) => {
413            if parser.peek() == Some(&Token::Delim('=')) && !parser.current_preceded_by_whitespace()
414            {
415                parser.advance();
416                Ok(MfComparison::Le)
417            } else {
418                Ok(MfComparison::Lt)
419            }
420        }
421        Some(Token::Delim('>')) => {
422            if parser.peek() == Some(&Token::Delim('=')) && !parser.current_preceded_by_whitespace()
423            {
424                parser.advance();
425                Ok(MfComparison::Ge)
426            } else {
427                Ok(MfComparison::Gt)
428            }
429        }
430        Some(Token::Delim('=')) => Ok(MfComparison::Eq),
431        other => Err(ParseError::ExpectedMfComparison(other)),
432    }
433}
434
435/// `<media-feature> = [ <mf-plain> | <mf-boolean> | <mf-range> ]`
436///
437/// Lookahead (see `plan/04-range-syntax.md` §"Parser-Änderungen"):
438/// starting with `<mf-name>` (an `<ident>`) is ambiguous with starting
439/// with `<mf-value>` when the value is itself an `<ident>`, so the
440/// *following* token decides which grammar alternative applies:
441///
442/// 1. `<ident> :` → `<mf-plain>`.
443/// 2. `<ident>` alone (end of block) → `<mf-boolean>`.
444/// 3. `<ident>` followed by an `<mf-comparison>` start → `<mf-range>`,
445///    `NameFirst` (`<mf-name> <mf-comparison> <mf-value>`).
446/// 4. Anything else (a `<number>`/`<dimension>`, or an `<ident>` that
447///    didn't match 1–3) → try `<mf-value> <mf-comparison> <mf-name>`
448///    (`ValueFirst`), optionally continued by a second same-family
449///    `<mf-lt>`/`<mf-gt>` and another `<mf-value>` (`Interval`).
450fn media_feature(parser: &mut Parser) -> Result<MediaFeature, ParseError> {
451    if matches!(parser.peek(), Some(Token::Ident(_))) {
452        if matches!(parser.peek_at(1), Some(Token::Colon)) {
453            let name = mf_name(parser)?;
454            parser.advance();
455            return Ok(MediaFeature::Plain {
456                name,
457                value: mf_value(parser)?,
458            });
459        }
460        if parser.peek_at(1).is_none() {
461            return Ok(MediaFeature::Boolean(mf_name(parser)?));
462        }
463        if starts_mf_comparison(parser.peek_at(1)) {
464            let name = mf_name(parser)?;
465            let operator = mf_comparison(parser)?;
466            let value = mf_value(parser)?;
467            return Ok(MediaFeature::Range(MfRange::NameFirst {
468                name,
469                operator,
470                value,
471            }));
472        }
473    }
474
475    let value = mf_value(parser)?;
476    let operator = mf_comparison(parser)?;
477    let name = mf_name(parser)?;
478    if parser.is_at_end() {
479        return Ok(MediaFeature::Range(MfRange::ValueFirst {
480            value,
481            operator,
482            name,
483        }));
484    }
485
486    let (direction, lower_inclusive) = match operator {
487        MfComparison::Lt => (MfRangeDirection::Ascending, false),
488        MfComparison::Le => (MfRangeDirection::Ascending, true),
489        MfComparison::Gt => (MfRangeDirection::Descending, false),
490        MfComparison::Ge => (MfRangeDirection::Descending, true),
491        MfComparison::Eq => return Err(ParseError::InvalidMfRangeInterval),
492    };
493    let upper_inclusive = match (direction, mf_comparison(parser)?) {
494        (MfRangeDirection::Ascending, MfComparison::Lt) => false,
495        (MfRangeDirection::Ascending, MfComparison::Le) => true,
496        (MfRangeDirection::Descending, MfComparison::Gt) => false,
497        (MfRangeDirection::Descending, MfComparison::Ge) => true,
498        _ => return Err(ParseError::InvalidMfRangeInterval),
499    };
500    let upper = mf_value(parser)?;
501    Ok(MediaFeature::Range(MfRange::Interval {
502        lower: value,
503        lower_inclusive,
504        name,
505        upper_inclusive,
506        upper,
507        direction,
508    }))
509}
510
511/// `<media-type> = <ident>`, rejecting the keywords the grammar
512/// excludes at this position (spec §3): `not`/`and`/`or`/`only`/`layer`.
513fn media_type(parser: &mut Parser) -> Result<MediaType, ParseError> {
514    match parser.advance() {
515        Some(Token::Ident(name)) => {
516            let lower = name.to_ascii_lowercase();
517            if matches!(lower.as_str(), "not" | "and" | "or" | "only" | "layer") {
518                Err(ParseError::ReservedMediaType(name))
519            } else {
520                Ok(MediaType(name))
521            }
522        }
523        other => Err(ParseError::ExpectedMediaType(other)),
524    }
525}
526
527/// The `[ not | only ]? <media-type> [ and <media-condition-without-or> ]?`
528/// branch of `<media-query>`.
529fn media_query_type_branch(parser: &mut Parser) -> Result<MediaQuery, ParseError> {
530    let modifier = match parser.peek() {
531        Some(Token::Ident(s)) if s.eq_ignore_ascii_case("not") => {
532            parser.advance();
533            Some(MediaModifier::Not)
534        }
535        Some(Token::Ident(s)) if s.eq_ignore_ascii_case("only") => {
536            parser.advance();
537            Some(MediaModifier::Only)
538        }
539        _ => None,
540    };
541    let media_type = media_type(parser)?;
542    let condition = if is_ident_ci(parser.peek(), "and") {
543        parser.advance();
544        Some(media_condition_without_or(parser)?)
545    } else {
546        None
547    };
548    Ok(MediaQuery::TypeQuery {
549        modifier,
550        media_type,
551        condition,
552    })
553}
554
555/// `<media-query> = <media-condition>
556///                | [ not | only ]? <media-type> [ and <media-condition-without-or> ]?`
557///
558/// The two branches are disambiguated by lookahead: a bare `(` or
559/// function token starts `<media-condition>` directly; `not` followed
560/// by `(`/a function token is the `<media-not>` branch of
561/// `<media-condition>`, while `not` followed by anything else (an
562/// `<ident>`) is the `not <media-type>` branch. `only` and a plain
563/// `<ident>` always start the `<media-type>` branch.
564fn media_query(parser: &mut Parser) -> Result<MediaQuery, ParseError> {
565    let starts_condition = matches!(
566        parser.peek(),
567        Some(Token::OpenParen) | Some(Token::Function(_))
568    ) || (is_ident_ci(parser.peek(), "not")
569        && matches!(
570            parser.peek_at(1),
571            Some(Token::OpenParen) | Some(Token::Function(_))
572        ));
573    if starts_condition {
574        Ok(MediaQuery::Condition(media_condition(parser)?))
575    } else {
576        media_query_type_branch(parser)
577    }
578}
579
580/// Tokenizes `input`, strips whitespace/EOF tokens (which carry no
581/// grammatical meaning above the tokenizer, see the module doc comment
582/// on `Parser`), and pairs each remaining token with whether a
583/// whitespace token immediately preceded it — see [`PositionedToken`].
584fn prepare_tokens(input: &str) -> Vec<PositionedToken> {
585    let mut result = Vec::new();
586    let mut preceded_by_whitespace = false;
587    for token in tokenize(input) {
588        match token {
589            Token::Whitespace => preceded_by_whitespace = true,
590            Token::Eof => {}
591            other => {
592                result.push((other, preceded_by_whitespace));
593                preceded_by_whitespace = false;
594            }
595        }
596    }
597    result
598}
599
600/// Splits `tokens` on top-level commas (per `<media-query-list> =
601/// <media-query>#`), respecting bracket nesting so that a comma inside
602/// a `<media-in-parens>` block doesn't split the list.
603fn split_top_level_commas(tokens: &[PositionedToken]) -> Vec<Vec<PositionedToken>> {
604    let mut segments = Vec::new();
605    let mut current = Vec::new();
606    let mut closers: Vec<Token> = Vec::new();
607    for (token, preceded_by_whitespace) in tokens {
608        if closers.is_empty() && *token == Token::Comma {
609            segments.push(std::mem::take(&mut current));
610            continue;
611        }
612        track_bracket_depth(token, &mut closers);
613        current.push((token.clone(), *preceded_by_whitespace));
614    }
615    segments.push(current);
616    segments
617}
618
619fn parse_media_query_tokens(tokens: &[PositionedToken]) -> Result<MediaQuery, ParseError> {
620    if tokens.is_empty() {
621        return Err(ParseError::EmptyMediaQuery);
622    }
623    let mut parser = Parser::new(tokens);
624    let query = media_query(&mut parser)?;
625    expect_end(&parser)?;
626    Ok(query)
627}
628
629/// Parses `input` as a single `<media-query>`.
630pub fn parse_media_query(input: &str) -> Result<MediaQuery, ParseError> {
631    parse_media_query_tokens(&prepare_tokens(input))
632}
633
634/// Parses `input` as a `<media-query-list>`: a comma-separated list of
635/// component values (spec §3), with each entry parsed independently as
636/// a `<media-query>`. Returns one `Result` per entry rather than a
637/// single `MediaQueryList`/error, since a single invalid entry must not
638/// invalidate the rest of the list (see the module doc comment).
639pub fn parse_media_query_list(input: &str) -> Vec<Result<MediaQuery, ParseError>> {
640    let tokens = prepare_tokens(input);
641    split_top_level_commas(&tokens)
642        .into_iter()
643        .map(|segment| parse_media_query_tokens(&segment))
644        .collect()
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::ast::MediaCondition::*;
651    use crate::ast::MediaInParens as InParens;
652
653    fn feature_boolean(name: &str) -> InParens {
654        InParens::Feature(MediaFeature::Boolean(MfName(name.into())))
655    }
656
657    fn feature_plain(name: &str, value: MfValue) -> InParens {
658        InParens::Feature(MediaFeature::Plain {
659            name: MfName(name.into()),
660            value,
661        })
662    }
663
664    fn feature_range(range: MfRange) -> InParens {
665        InParens::Feature(MediaFeature::Range(range))
666    }
667
668    fn dim(value: f64, unit: &str) -> MfValue {
669        MfValue::Dimension {
670            value,
671            unit: unit.into(),
672        }
673    }
674
675    #[test]
676    fn media_query_list_multiple_entries() {
677        let results = parse_media_query_list("screen, print");
678        assert_eq!(results.len(), 2);
679        assert_eq!(
680            results[0],
681            Ok(MediaQuery::TypeQuery {
682                modifier: None,
683                media_type: MediaType("screen".into()),
684                condition: None,
685            })
686        );
687        assert_eq!(
688            results[1],
689            Ok(MediaQuery::TypeQuery {
690                modifier: None,
691                media_type: MediaType("print".into()),
692                condition: None,
693            })
694        );
695    }
696
697    #[test]
698    fn media_query_type_bare() {
699        assert_eq!(
700            parse_media_query("screen"),
701            Ok(MediaQuery::TypeQuery {
702                modifier: None,
703                media_type: MediaType("screen".into()),
704                condition: None,
705            })
706        );
707    }
708
709    #[test]
710    fn media_query_type_with_not_modifier() {
711        assert_eq!(
712            parse_media_query("not screen"),
713            Ok(MediaQuery::TypeQuery {
714                modifier: Some(MediaModifier::Not),
715                media_type: MediaType("screen".into()),
716                condition: None,
717            })
718        );
719    }
720
721    #[test]
722    fn media_query_type_with_only_modifier() {
723        assert_eq!(
724            parse_media_query("only screen"),
725            Ok(MediaQuery::TypeQuery {
726                modifier: Some(MediaModifier::Only),
727                media_type: MediaType("screen".into()),
728                condition: None,
729            })
730        );
731    }
732
733    #[test]
734    fn media_query_type_with_and_condition() {
735        assert_eq!(
736            parse_media_query("screen and (color)"),
737            Ok(MediaQuery::TypeQuery {
738                modifier: None,
739                media_type: MediaType("screen".into()),
740                condition: Some(MediaConditionWithoutOr::And(vec![feature_boolean("color")])),
741            })
742        );
743    }
744
745    #[test]
746    fn media_query_type_with_and_chain_condition() {
747        assert_eq!(
748            parse_media_query("screen and (color) and (monochrome)"),
749            Ok(MediaQuery::TypeQuery {
750                modifier: None,
751                media_type: MediaType("screen".into()),
752                condition: Some(MediaConditionWithoutOr::And(vec![
753                    feature_boolean("color"),
754                    feature_boolean("monochrome"),
755                ])),
756            })
757        );
758    }
759
760    #[test]
761    fn media_condition_without_or_rejects_or() {
762        // "or" is not allowed in <media-condition-without-or>; it's
763        // left over as a trailing token and must be a parse error.
764        assert_eq!(
765            parse_media_query("screen and (color) or (monochrome)"),
766            Err(ParseError::TrailingTokens(Token::Ident("or".into())))
767        );
768    }
769
770    #[test]
771    fn media_query_condition_shorthand() {
772        assert_eq!(
773            parse_media_query("(color)"),
774            Ok(MediaQuery::Condition(And(vec![feature_boolean("color")])))
775        );
776    }
777
778    #[test]
779    fn media_condition_not() {
780        assert_eq!(
781            parse_media_query("not (color)"),
782            Ok(MediaQuery::Condition(Not(feature_boolean("color"))))
783        );
784    }
785
786    #[test]
787    fn media_condition_and_chain() {
788        assert_eq!(
789            parse_media_query("(color) and (monochrome)"),
790            Ok(MediaQuery::Condition(And(vec![
791                feature_boolean("color"),
792                feature_boolean("monochrome"),
793            ])))
794        );
795    }
796
797    #[test]
798    fn media_condition_or_chain() {
799        assert_eq!(
800            parse_media_query("(color) or (monochrome)"),
801            Ok(MediaQuery::Condition(Or(vec![
802                feature_boolean("color"),
803                feature_boolean("monochrome"),
804            ])))
805        );
806    }
807
808    #[test]
809    fn media_in_parens_condition() {
810        assert_eq!(
811            parse_media_query("((color) and (monochrome))"),
812            Ok(MediaQuery::Condition(And(vec![InParens::Condition(
813                Box::new(And(vec![
814                    feature_boolean("color"),
815                    feature_boolean("monochrome"),
816                ]))
817            )])))
818        );
819    }
820
821    #[test]
822    fn media_in_parens_feature() {
823        assert_eq!(
824            parse_media_query("(width: 400px)"),
825            Ok(MediaQuery::Condition(And(vec![feature_plain(
826                "width",
827                MfValue::Dimension {
828                    value: 400.0,
829                    unit: "px".into()
830                }
831            )])))
832        );
833    }
834
835    #[test]
836    fn media_in_parens_general_enclosed() {
837        let Ok(MediaQuery::Condition(And(items))) = parse_media_query("(3 + 5)") else {
838            panic!("expected a bare condition with one <media-in-parens>");
839        };
840        assert_eq!(items.len(), 1);
841        assert!(matches!(items[0], InParens::GeneralEnclosed(_)));
842    }
843
844    #[test]
845    fn general_enclosed_function_token() {
846        let Ok(MediaQuery::Condition(And(items))) = parse_media_query("foo(bar)") else {
847            panic!("expected a bare condition with one <media-in-parens>");
848        };
849        assert_eq!(
850            items,
851            vec![InParens::GeneralEnclosed(GeneralEnclosed {
852                tokens: vec![Token::Function("foo".into()), Token::Ident("bar".into())],
853            })]
854        );
855    }
856
857    #[test]
858    fn mf_boolean() {
859        assert_eq!(
860            parse_media_query("(color)"),
861            Ok(MediaQuery::Condition(And(vec![feature_boolean("color")])))
862        );
863    }
864
865    #[test]
866    fn mf_plain_number() {
867        assert_eq!(
868            parse_media_query("(color-index: 2)"),
869            Ok(MediaQuery::Condition(And(vec![feature_plain(
870                "color-index",
871                MfValue::Number(2.0)
872            )])))
873        );
874    }
875
876    #[test]
877    fn mf_plain_dimension() {
878        assert_eq!(
879            parse_media_query("(width: 400px)"),
880            Ok(MediaQuery::Condition(And(vec![feature_plain(
881                "width",
882                MfValue::Dimension {
883                    value: 400.0,
884                    unit: "px".into()
885                }
886            )])))
887        );
888    }
889
890    #[test]
891    fn mf_plain_ident() {
892        assert_eq!(
893            parse_media_query("(orientation: landscape)"),
894            Ok(MediaQuery::Condition(And(vec![feature_plain(
895                "orientation",
896                MfValue::Ident("landscape".into())
897            )])))
898        );
899    }
900
901    #[test]
902    fn mf_plain_ratio() {
903        assert_eq!(
904            parse_media_query("(aspect-ratio: 16/9)"),
905            Ok(MediaQuery::Condition(And(vec![feature_plain(
906                "aspect-ratio",
907                MfValue::Ratio {
908                    numerator: 16,
909                    denominator: 9
910                }
911            )])))
912        );
913    }
914
915    #[test]
916    fn mf_plain_ratio_out_of_u32_range_is_invalid() {
917        let tokens = prepare_tokens("aspect-ratio: 99999999999999/1");
918        assert_eq!(
919            parse_fully(&tokens, media_feature),
920            Err(ParseError::InvalidRatio)
921        );
922    }
923
924    #[test]
925    fn media_type_collision_rule() {
926        assert_eq!(
927            parse_media_query("layer"),
928            Err(ParseError::ReservedMediaType("layer".into()))
929        );
930        assert_eq!(
931            parse_media_query("and"),
932            Err(ParseError::ReservedMediaType("and".into()))
933        );
934    }
935
936    #[test]
937    fn parse_error_on_unbalanced_parens() {
938        assert_eq!(
939            parse_media_query("(color"),
940            Err(ParseError::UnbalancedParens)
941        );
942    }
943
944    #[test]
945    fn parse_media_query_list_surfaces_per_entry_errors() {
946        let results = parse_media_query_list("screen, layer, (color");
947        assert_eq!(results.len(), 3);
948        assert!(results[0].is_ok());
949        assert_eq!(
950            results[1],
951            Err(ParseError::ReservedMediaType("layer".into()))
952        );
953        assert_eq!(results[2], Err(ParseError::UnbalancedParens));
954    }
955
956    // --- `<mf-comparison>` (phase 04) ---
957
958    #[test]
959    fn mf_comparison_lt() {
960        assert_eq!(
961            parse_fully(&prepare_tokens("<"), mf_comparison),
962            Ok(MfComparison::Lt)
963        );
964    }
965
966    #[test]
967    fn mf_comparison_gt() {
968        assert_eq!(
969            parse_fully(&prepare_tokens(">"), mf_comparison),
970            Ok(MfComparison::Gt)
971        );
972    }
973
974    #[test]
975    fn mf_comparison_eq() {
976        assert_eq!(
977            parse_fully(&prepare_tokens("="), mf_comparison),
978            Ok(MfComparison::Eq)
979        );
980    }
981
982    #[test]
983    fn mf_comparison_le_no_whitespace() {
984        assert_eq!(
985            parse_fully(&prepare_tokens("<="), mf_comparison),
986            Ok(MfComparison::Le)
987        );
988    }
989
990    #[test]
991    fn mf_comparison_ge_no_whitespace() {
992        assert_eq!(
993            parse_fully(&prepare_tokens(">="), mf_comparison),
994            Ok(MfComparison::Ge)
995        );
996    }
997
998    #[test]
999    fn mf_comparison_lt_then_eq_with_whitespace_is_not_le() {
1000        // Verifies the tokenizer/parser boundary from `plan/04-range-
1001        // syntax.md`: `<` and `=` tokenize as two independent `Delim`
1002        // tokens with no combined `<=` token (CSS Syntax Level 3 has
1003        // none). `mf_comparison` must only fold them into `Le` when
1004        // they're directly adjacent — with whitespace between, `<`
1005        // alone is a complete `<mf-lt>`, leaving the `=` as an
1006        // unconsumed trailing token.
1007        assert_eq!(
1008            parse_fully(&prepare_tokens("< ="), mf_comparison),
1009            Err(ParseError::TrailingTokens(Token::Delim('=')))
1010        );
1011    }
1012
1013    #[test]
1014    fn mf_comparison_gt_then_eq_with_whitespace_is_not_ge() {
1015        assert_eq!(
1016            parse_fully(&prepare_tokens("> ="), mf_comparison),
1017            Err(ParseError::TrailingTokens(Token::Delim('=')))
1018        );
1019    }
1020
1021    // --- `<mf-range>` (phase 04) ---
1022
1023    #[test]
1024    fn mf_range_name_first_lt() {
1025        assert_eq!(
1026            parse_media_query("(width < 400px)"),
1027            Ok(MediaQuery::Condition(And(vec![feature_range(
1028                MfRange::NameFirst {
1029                    name: MfName("width".into()),
1030                    operator: MfComparison::Lt,
1031                    value: dim(400.0, "px"),
1032                }
1033            )])))
1034        );
1035    }
1036
1037    #[test]
1038    fn mf_range_name_first_le() {
1039        assert_eq!(
1040            parse_media_query("(width <= 400px)"),
1041            Ok(MediaQuery::Condition(And(vec![feature_range(
1042                MfRange::NameFirst {
1043                    name: MfName("width".into()),
1044                    operator: MfComparison::Le,
1045                    value: dim(400.0, "px"),
1046                }
1047            )])))
1048        );
1049    }
1050
1051    #[test]
1052    fn mf_range_name_first_gt() {
1053        assert_eq!(
1054            parse_media_query("(width > 400px)"),
1055            Ok(MediaQuery::Condition(And(vec![feature_range(
1056                MfRange::NameFirst {
1057                    name: MfName("width".into()),
1058                    operator: MfComparison::Gt,
1059                    value: dim(400.0, "px"),
1060                }
1061            )])))
1062        );
1063    }
1064
1065    #[test]
1066    fn mf_range_name_first_ge() {
1067        assert_eq!(
1068            parse_media_query("(width >= 400px)"),
1069            Ok(MediaQuery::Condition(And(vec![feature_range(
1070                MfRange::NameFirst {
1071                    name: MfName("width".into()),
1072                    operator: MfComparison::Ge,
1073                    value: dim(400.0, "px"),
1074                }
1075            )])))
1076        );
1077    }
1078
1079    #[test]
1080    fn mf_range_name_first_eq_with_ident_value() {
1081        assert_eq!(
1082            parse_media_query("(orientation = landscape)"),
1083            Ok(MediaQuery::Condition(And(vec![feature_range(
1084                MfRange::NameFirst {
1085                    name: MfName("orientation".into()),
1086                    operator: MfComparison::Eq,
1087                    value: MfValue::Ident("landscape".into()),
1088                }
1089            )])))
1090        );
1091    }
1092
1093    #[test]
1094    fn mf_range_name_first_number_value() {
1095        assert_eq!(
1096            parse_media_query("(color-index >= 2)"),
1097            Ok(MediaQuery::Condition(And(vec![feature_range(
1098                MfRange::NameFirst {
1099                    name: MfName("color-index".into()),
1100                    operator: MfComparison::Ge,
1101                    value: MfValue::Number(2.0),
1102                }
1103            )])))
1104        );
1105    }
1106
1107    #[test]
1108    fn mf_range_name_first_ratio_value() {
1109        assert_eq!(
1110            parse_media_query("(aspect-ratio >= 16/9)"),
1111            Ok(MediaQuery::Condition(And(vec![feature_range(
1112                MfRange::NameFirst {
1113                    name: MfName("aspect-ratio".into()),
1114                    operator: MfComparison::Ge,
1115                    value: MfValue::Ratio {
1116                        numerator: 16,
1117                        denominator: 9,
1118                    },
1119                }
1120            )])))
1121        );
1122    }
1123
1124    #[test]
1125    fn mf_range_value_first_dimension() {
1126        assert_eq!(
1127            parse_media_query("(400px <= width)"),
1128            Ok(MediaQuery::Condition(And(vec![feature_range(
1129                MfRange::ValueFirst {
1130                    value: dim(400.0, "px"),
1131                    operator: MfComparison::Le,
1132                    name: MfName("width".into()),
1133                }
1134            )])))
1135        );
1136    }
1137
1138    #[test]
1139    fn mf_range_value_first_number() {
1140        assert_eq!(
1141            parse_media_query("(2 < color-index)"),
1142            Ok(MediaQuery::Condition(And(vec![feature_range(
1143                MfRange::ValueFirst {
1144                    value: MfValue::Number(2.0),
1145                    operator: MfComparison::Lt,
1146                    name: MfName("color-index".into()),
1147                }
1148            )])))
1149        );
1150    }
1151
1152    #[test]
1153    fn mf_range_value_first_ratio() {
1154        assert_eq!(
1155            parse_media_query("(16/9 <= aspect-ratio)"),
1156            Ok(MediaQuery::Condition(And(vec![feature_range(
1157                MfRange::ValueFirst {
1158                    value: MfValue::Ratio {
1159                        numerator: 16,
1160                        denominator: 9,
1161                    },
1162                    operator: MfComparison::Le,
1163                    name: MfName("aspect-ratio".into()),
1164                }
1165            )])))
1166        );
1167    }
1168
1169    #[test]
1170    fn mf_range_interval_ascending_inclusive() {
1171        assert_eq!(
1172            parse_media_query("(400px <= width <= 700px)"),
1173            Ok(MediaQuery::Condition(And(vec![feature_range(
1174                MfRange::Interval {
1175                    lower: dim(400.0, "px"),
1176                    lower_inclusive: true,
1177                    name: MfName("width".into()),
1178                    upper_inclusive: true,
1179                    upper: dim(700.0, "px"),
1180                    direction: MfRangeDirection::Ascending,
1181                }
1182            )])))
1183        );
1184    }
1185
1186    #[test]
1187    fn mf_range_interval_ascending_exclusive() {
1188        assert_eq!(
1189            parse_media_query("(400px < width < 700px)"),
1190            Ok(MediaQuery::Condition(And(vec![feature_range(
1191                MfRange::Interval {
1192                    lower: dim(400.0, "px"),
1193                    lower_inclusive: false,
1194                    name: MfName("width".into()),
1195                    upper_inclusive: false,
1196                    upper: dim(700.0, "px"),
1197                    direction: MfRangeDirection::Ascending,
1198                }
1199            )])))
1200        );
1201    }
1202
1203    #[test]
1204    fn mf_range_interval_descending_inclusive() {
1205        assert_eq!(
1206            parse_media_query("(700px >= width >= 400px)"),
1207            Ok(MediaQuery::Condition(And(vec![feature_range(
1208                MfRange::Interval {
1209                    lower: dim(700.0, "px"),
1210                    lower_inclusive: true,
1211                    name: MfName("width".into()),
1212                    upper_inclusive: true,
1213                    upper: dim(400.0, "px"),
1214                    direction: MfRangeDirection::Descending,
1215                }
1216            )])))
1217        );
1218    }
1219
1220    #[test]
1221    fn mf_range_interval_descending_exclusive() {
1222        assert_eq!(
1223            parse_media_query("(700px > width > 400px)"),
1224            Ok(MediaQuery::Condition(And(vec![feature_range(
1225                MfRange::Interval {
1226                    lower: dim(700.0, "px"),
1227                    lower_inclusive: false,
1228                    name: MfName("width".into()),
1229                    upper_inclusive: false,
1230                    upper: dim(400.0, "px"),
1231                    direction: MfRangeDirection::Descending,
1232                }
1233            )])))
1234        );
1235    }
1236
1237    #[test]
1238    fn mf_range_interval_rejects_mixed_family() {
1239        let tokens = prepare_tokens("400px <= width > 700px");
1240        assert_eq!(
1241            parse_fully(&tokens, media_feature),
1242            Err(ParseError::InvalidMfRangeInterval)
1243        );
1244    }
1245
1246    #[test]
1247    fn mf_range_interval_rejects_eq_as_first_operator() {
1248        let tokens = prepare_tokens("400px = width < 700px");
1249        assert_eq!(
1250            parse_fully(&tokens, media_feature),
1251            Err(ParseError::InvalidMfRangeInterval)
1252        );
1253    }
1254
1255    #[test]
1256    fn mf_range_incomplete_name_first_is_invalid() {
1257        let tokens = prepare_tokens("width <");
1258        assert_eq!(
1259            parse_fully(&tokens, media_feature),
1260            Err(ParseError::ExpectedMfValue(None))
1261        );
1262    }
1263
1264    #[test]
1265    fn mf_plain_and_boolean_regression_after_range_support() {
1266        // `<mf-plain>`/`<mf-boolean>` from phase 03 must keep working
1267        // unchanged now that `media_feature`'s lookahead also considers
1268        // `<mf-range>` — in particular an `<ident>` value (not a range
1269        // comparison) must still parse as `<mf-plain>`.
1270        assert_eq!(
1271            parse_media_query("(orientation: landscape)"),
1272            Ok(MediaQuery::Condition(And(vec![feature_plain(
1273                "orientation",
1274                MfValue::Ident("landscape".into())
1275            )])))
1276        );
1277        assert_eq!(
1278            parse_media_query("(color)"),
1279            Ok(MediaQuery::Condition(And(vec![feature_boolean("color")])))
1280        );
1281    }
1282}