Skip to main content

oxc_css_parser/parser/at_rule/
mod.rs

1use super::{Parser, state::ParserState};
2use crate::{
3    Parse, Syntax,
4    ast::*,
5    error::{Error, ErrorKind, PResult},
6    pos::Span,
7    tokenizer::Token,
8};
9
10mod color_profile;
11mod container;
12mod counter_style;
13mod custom_media;
14mod custom_selector;
15mod document;
16mod font_feature_values;
17mod import;
18mod keyframes;
19mod layer;
20mod media;
21mod namespace;
22mod page;
23mod scope;
24mod supports;
25
26// https://drafts.csswg.org/css-syntax-3/#consume-at-rule
27//
28// <at-rule> = <at-keyword-token> <component-value>* [ <{}-block> | ; ]
29//
30// This dispatches on the at-keyword name to a typed prelude/block grammar; each
31// rule's grammar is documented in its own module (see the `mod` list above).
32// Unrecognized names fall back to raw component values (`parse_unknown_at_rule`).
33impl<'a> Parse<'a> for AtRule<'a> {
34    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
35        let (at_keyword, at_keyword_span) = input.cursor.expect_at_keyword()?;
36
37        let at_rule_name = at_keyword.ident.name();
38        let (prelude, block, end) = if at_rule_name.eq_ignore_ascii_case("media") {
39            // The typed grammar must account for the whole prelude; queries it
40            // can't express (`@media all #{$m}`) are kept as raw tokens.
41            let prelude = match input
42                .try_parse_full_prelude(|p| MediaQueryList::parse(p).map(AtRulePrelude::Media))
43            {
44                Ok(prelude) => Some(prelude),
45                Err(_)
46                    if matches!(
47                        input.cursor.peek()?.token,
48                        Token::LBrace(..) | Token::Indent(..)
49                    ) =>
50                {
51                    None
52                }
53                // Only interpolation justifies the raw form — dart-sass
54                // reparses such queries after resolving `#{...}`; plain
55                // malformed logic (`@media a and b or c`) must keep erroring.
56                Err(_) => {
57                    // Only substitution constructs justify the raw form —
58                    // `#{...}` in Sass (dart-sass reparses after resolving) and
59                    // variables in Less (`@media @all and @tv {`); plain
60                    // malformed logic must keep erroring.
61                    let raw = if input.syntax != Syntax::Css {
62                        input.try_parse(|p| {
63                            let raw = p.parse_raw_at_rule_prelude()?;
64                            let has_substitution = matches!(
65                                &raw,
66                                UnknownAtRulePrelude::TokenSeq(seq)
67                                    if seq.tokens.iter().any(|t| match &t.token {
68                                        Token::HashLBrace(..) | Token::StrTemplate(..) => {
69                                            matches!(p.syntax, Syntax::Scss | Syntax::Sass)
70                                        }
71                                        Token::AtKeyword(..) | Token::AtLBraceVar(..) => {
72                                            p.syntax == Syntax::Less
73                                        }
74                                        _ => false,
75                                    })
76                            );
77                            if has_substitution {
78                                Ok(raw)
79                            } else {
80                                let span = p.cursor.peek()?.span.clone();
81                                Err(Error { kind: ErrorKind::TryParseError, span })
82                            }
83                        })
84                    } else {
85                        Err(Error {
86                            kind: ErrorKind::TryParseError,
87                            span: input.cursor.peek()?.span.clone(),
88                        })
89                    };
90                    match raw {
91                        Ok(raw) => Some(AtRulePrelude::Unknown(input.alloc(raw))),
92                        Err(_) => Some(AtRulePrelude::Media(input.parse()?)),
93                    }
94                }
95            };
96            let block = input.parse::<SimpleBlock>()?;
97            let end = block.span.end;
98            (prelude, Some(block), end)
99        } else if at_rule_name.eq_ignore_ascii_case("keyframes")
100            || at_rule_name.eq_ignore_ascii_case("-webkit-keyframes")
101            || at_rule_name.eq_ignore_ascii_case("-moz-keyframes")
102            || at_rule_name.eq_ignore_ascii_case("-ms-keyframes")
103            || at_rule_name.eq_ignore_ascii_case("-o-keyframes")
104        {
105            // A nameless `@keyframes {}` is invalid CSS, but Sass parses it
106            // (the name may be produced elsewhere, e.g. a keyframes mixin
107            // emitting vendor-prefixed blocks around `@content`).
108            let prelude = match &input.cursor.peek()?.token {
109                Token::LBrace(..) => None,
110                _ => {
111                    // A typed name normally ends the prelude; real-world code
112                    // also carries loose preludes (`@keyframes \$a`,
113                    // `@-moz-keyframes name /* c */ line 429`) — keep those as
114                    // raw tokens like an unknown at-rule's prelude.
115                    let typed = input.try_parse_full_prelude(KeyframesName::parse);
116                    match typed {
117                        Ok(name) => Some(AtRulePrelude::Keyframes(name)),
118                        Err(_) => input
119                            .parse_unknown_at_rule_prelude()?
120                            .map(|prelude| AtRulePrelude::Unknown(input.alloc(prelude))),
121                    }
122                }
123            };
124            let block = input
125                .with_state(ParserState { in_keyframes_at_rule: true, ..input.state.clone() })
126                .parse::<SimpleBlock>()?;
127            let end = block.span.end;
128            (prelude, Some(block), end)
129        } else if at_rule_name.eq_ignore_ascii_case("import") {
130            let (end, prelude) = match input.syntax {
131                Syntax::Css => {
132                    let prelude = input.parse::<ImportPrelude>()?;
133                    (prelude.span.end, AtRulePrelude::Import(input.alloc(prelude)))
134                }
135                Syntax::Scss | Syntax::Sass => {
136                    // A multi-path import (`@import 'a', 'b';`) is
137                    // Sass-specific and must win over `ImportPrelude`'s
138                    // lenient raw-modifiers tail so each path stays typed.
139                    // Claim it only on a full-prelude match with more than
140                    // one path: a single path keeps the standard
141                    // `ImportPrelude` shape, and any unparsed tail
142                    // (`@import 'a', 'b' screen;`) falls through to
143                    // `ImportPrelude`'s leniency.
144                    let multi_path = input.try_parse(|parser| {
145                        let prelude = parser.try_parse_full_prelude(SassImportPrelude::parse)?;
146                        if prelude.paths.len() > 1 {
147                            Ok(prelude)
148                        } else {
149                            Err(Error {
150                                kind: ErrorKind::TryParseError,
151                                span: prelude.span.clone(),
152                            })
153                        }
154                    });
155                    if let Ok(prelude) = multi_path {
156                        (prelude.span.end, AtRulePrelude::SassImport(prelude))
157                    } else if let Ok(prelude) = input.try_parse(ImportPrelude::parse) {
158                        (prelude.span.end, AtRulePrelude::Import(input.alloc(prelude)))
159                    } else {
160                        let prelude = input.parse::<SassImportPrelude>()?;
161                        (prelude.span.end, AtRulePrelude::SassImport(prelude))
162                    }
163                }
164                Syntax::Less => {
165                    if let Ok(prelude) = input.try_parse(ImportPrelude::parse) {
166                        (prelude.span.end, AtRulePrelude::Import(input.alloc(prelude)))
167                    } else {
168                        let prelude = input.parse::<LessImportPrelude>()?;
169                        (prelude.span.end, AtRulePrelude::LessImport(input.alloc(prelude)))
170                    }
171                }
172            };
173            (Some(prelude), None, end)
174        } else if at_rule_name.eq_ignore_ascii_case("charset") {
175            // https://drafts.csswg.org/css2/#charset%E2%91%A0
176            // Less may interpolate into it: `@charset "UTF-@{Eight}";`
177            if input.syntax == Syntax::Less
178                && matches!(input.cursor.peek()?.token, Token::StrTemplate(..))
179            {
180                let prelude = input.parse::<InterpolableStr>()?;
181                let end = prelude.span().end;
182                (
183                    Some(AtRulePrelude::Unknown(input.alloc(
184                        UnknownAtRulePrelude::ComponentValue(ComponentValue::InterpolableStr(
185                            prelude,
186                        )),
187                    ))),
188                    None,
189                    end,
190                )
191            } else {
192                let prelude = input.parse::<Str>()?;
193                let end = prelude.span.end;
194                (Some(AtRulePrelude::Charset(prelude)), None, end)
195            }
196        } else if at_rule_name.eq_ignore_ascii_case("font-face") {
197            let block = input.parse::<SimpleBlock>()?;
198            let end = block.span.end;
199            (None, Some(block), end)
200        } else if at_rule_name.eq_ignore_ascii_case("supports") {
201            let prelude = Some(AtRulePrelude::Supports(input.parse()?));
202            let block = input.parse::<SimpleBlock>()?;
203            let end = block.span.end;
204            (prelude, Some(block), end)
205        } else if at_rule_name.eq_ignore_ascii_case("layer") {
206            let prelude = match input.try_parse(LayerNames::parse) {
207                Ok(names) => Some(AtRulePrelude::Layer(names)),
208                // a Less variable may stand for the name: `@layer @layer-name {`
209                Err(_)
210                    if input.syntax == Syntax::Less
211                        && matches!(input.cursor.peek()?.token, Token::AtKeyword(..)) =>
212                {
213                    let raw = input.parse_raw_at_rule_prelude()?;
214                    Some(AtRulePrelude::Unknown(input.alloc(raw)))
215                }
216                Err(_) => None,
217            };
218            let block =
219                if matches!(input.cursor.peek()?.token, Token::LBrace(..) | Token::Indent(..)) {
220                    Some(input.parse::<SimpleBlock>()?)
221                } else {
222                    None
223                };
224            if let Some(block) = &block
225                && matches!(&prelude, Some(AtRulePrelude::Layer(names)) if names.names.len() > 1)
226            {
227                input.recoverable_errors.push(Error {
228                    kind: ErrorKind::UnexpectedSimpleBlock,
229                    span: block.span.clone(),
230                });
231            }
232            let end = block
233                .as_ref()
234                .map(|block| block.span.end)
235                .or_else(|| prelude.as_ref().map(|prelude| prelude.span().end))
236                .unwrap_or(at_keyword_span.end);
237            (prelude, block, end)
238        } else if at_rule_name.eq_ignore_ascii_case("container") {
239            // less.js emits comma-separated query lists
240            // (`@container card (w > 400px), style(--x: y) {`); those — and
241            // only those — are kept as raw tokens. A malformed single query
242            // still errors.
243            let prelude =
244                match input.try_parse_full_prelude(|p| p.parse().map(AtRulePrelude::Container)) {
245                    Ok(prelude) => prelude,
246                    Err(error) => {
247                        let is_query_list = input
248                            .try_parse(|p| {
249                                p.parse::<ContainerPrelude>()?;
250                                if matches!(p.cursor.peek()?.token, Token::Comma(..)) {
251                                    Ok(())
252                                } else {
253                                    let span = p.cursor.peek()?.span.clone();
254                                    Err(Error { kind: ErrorKind::TryParseError, span })
255                                }
256                            })
257                            .is_ok();
258                        // a Less variable may stand for the container name:
259                        // `@container @varfoo (min-width: @threshold) {`
260                        let less_variable_name = input.syntax == Syntax::Less
261                            && matches!(input.cursor.peek()?.token, Token::AtKeyword(..));
262                        if !is_query_list && !less_variable_name {
263                            return Err(error);
264                        }
265                        let prelude = input.parse_raw_at_rule_prelude()?;
266                        AtRulePrelude::Unknown(input.alloc(prelude))
267                    }
268                };
269            let block = input.parse::<SimpleBlock>()?;
270            let end = block.span.end;
271            (Some(prelude), Some(block), end)
272        } else if at_rule_name.eq_ignore_ascii_case("page") {
273            let prelude = input.try_parse(PageSelectorList::parse).map(AtRulePrelude::Page).ok();
274            let block = input.try_parse(SimpleBlock::parse).ok();
275            let end = block
276                .as_ref()
277                .map(|block| block.span.end)
278                .or_else(|| prelude.as_ref().map(|prelude| prelude.span().end))
279                .unwrap_or(at_keyword_span.end);
280            (prelude, block, end)
281        } else if at_rule_name.eq_ignore_ascii_case("namespace")
282            && input.syntax == Syntax::Less
283            && matches!(input.cursor.peek()?.token, Token::AtKeyword(..))
284        {
285            // `@namespace @ns "http://...";` — a Less variable prefix
286            let raw = input.parse_raw_at_rule_prelude()?;
287            let end = raw.span().end;
288            (Some(AtRulePrelude::Unknown(input.alloc(raw))), None, end)
289        } else if at_rule_name.eq_ignore_ascii_case("namespace") {
290            let namespace = input.parse::<NamespacePrelude>()?;
291            let end = namespace.span.end;
292            (Some(AtRulePrelude::Namespace(input.alloc(namespace))), None, end)
293        } else if at_rule_name.eq_ignore_ascii_case("color-profile") {
294            let prelude = Some(AtRulePrelude::ColorProfile(input.parse()?));
295            let block = input.parse::<SimpleBlock>()?;
296            let end = block.span.end;
297            (prelude, Some(block), end)
298        } else if at_rule_name.eq_ignore_ascii_case("font-feature-values") {
299            let prelude = Some(AtRulePrelude::FontFeatureValues(input.parse()?));
300            let block = input.parse::<SimpleBlock>()?;
301            let end = block.span.end;
302            (prelude, Some(block), end)
303        } else if at_rule_name.eq_ignore_ascii_case("font-palette-values") {
304            // https://drafts.csswg.org/css-fonts/Overview.bs
305            let prelude = Some(AtRulePrelude::FontPaletteValues(input.parse_dashed_ident()?));
306            let block = input.parse::<SimpleBlock>()?;
307            let end = block.span.end;
308            (prelude, Some(block), end)
309        } else if at_rule_name.eq_ignore_ascii_case("counter-style") {
310            let prelude = Some(AtRulePrelude::CounterStyle(input.parse_counter_style_prelude()?));
311            let block = input.parse::<SimpleBlock>()?;
312            let end = block.span.end;
313            (prelude, Some(block), end)
314        } else if at_rule_name.eq_ignore_ascii_case("custom-media") {
315            let custom_media = input.parse::<CustomMedia>()?;
316            let end = custom_media.span.end;
317            (Some(AtRulePrelude::CustomMedia(input.alloc(custom_media))), None, end)
318        } else if at_rule_name.eq_ignore_ascii_case("custom-selector") {
319            let custom_selector_prelude = input.parse::<CustomSelectorPrelude>()?;
320            let end = custom_selector_prelude.span.end;
321            (Some(AtRulePrelude::CustomSelector(input.alloc(custom_selector_prelude))), None, end)
322        } else if at_rule_name.eq_ignore_ascii_case("position-try") {
323            // https://drafts.csswg.org/css-anchor-position-1/#fallback-rule
324            let prelude = Some(AtRulePrelude::PositionTry(input.parse_dashed_ident()?));
325            let block = input.parse::<SimpleBlock>()?;
326            let end = block.span.end;
327            (prelude, Some(block), end)
328        } else if at_rule_name.eq_ignore_ascii_case("nest") {
329            // https://www.w3.org/TR/css-nesting-1/#at-nest
330            let prelude = Some(AtRulePrelude::Nest(input.parse()?));
331            let block = input.parse::<SimpleBlock>()?;
332            let end = block.span.end;
333            (prelude, Some(block), end)
334        } else if at_rule_name.eq_ignore_ascii_case("property") {
335            // https://drafts.css-houdini.org/css-properties-values-api/#at-property-rule
336            let prelude = Some(AtRulePrelude::Property(input.parse_dashed_ident()?));
337            let block = input.parse::<SimpleBlock>()?;
338            let end = block.span.end;
339            (prelude, Some(block), end)
340        } else if at_rule_name.eq_ignore_ascii_case("scope") {
341            let prelude = if let Token::LParen(..) | Token::Ident(..) = input.cursor.peek()?.token {
342                let scope = input.parse()?;
343                Some(AtRulePrelude::Scope(input.alloc(scope)))
344            } else {
345                None
346            };
347            let block = input.parse::<SimpleBlock>()?;
348            let end = block.span.end;
349            (prelude, Some(block), end)
350        } else if at_rule_name.eq_ignore_ascii_case("document")
351            || at_rule_name.eq_ignore_ascii_case("-moz-document")
352        {
353            let prelude = match input.try_parse(|p| p.parse().map(AtRulePrelude::Document)) {
354                Ok(prelude) => prelude,
355                // e.g. less.js's permissive `@-moz-document @fn("(x)")` — but
356                // an empty prelude (`@document {}`) stays an error.
357                Err(error) => {
358                    if matches!(
359                        input.cursor.peek()?.token,
360                        Token::LBrace(..) | Token::Indent(..) | Token::Semicolon(..)
361                    ) {
362                        return Err(error);
363                    }
364                    let prelude = input.parse_raw_at_rule_prelude()?;
365                    AtRulePrelude::Unknown(input.alloc(prelude))
366                }
367            };
368            // real-world code also writes a block-less `@document ...;`
369            let block =
370                if matches!(input.cursor.peek()?.token, Token::LBrace(..) | Token::Indent(..)) {
371                    Some(input.parse::<SimpleBlock>()?)
372                } else {
373                    None
374                };
375            let end = block.as_ref().map_or(prelude.span().end, |block| block.span.end);
376            (Some(prelude), block, end)
377        } else if at_rule_name.eq_ignore_ascii_case("stylistic")
378            || at_rule_name.eq_ignore_ascii_case("historical-forms")
379            || at_rule_name.eq_ignore_ascii_case("styleset")
380            || at_rule_name.eq_ignore_ascii_case("character-variant")
381            || at_rule_name.eq_ignore_ascii_case("swash")
382            || at_rule_name.eq_ignore_ascii_case("ornaments")
383            || at_rule_name.eq_ignore_ascii_case("annotation")
384            || at_rule_name.eq_ignore_ascii_case("top-left-corner")
385            || at_rule_name.eq_ignore_ascii_case("top-left")
386            || at_rule_name.eq_ignore_ascii_case("top-center")
387            || at_rule_name.eq_ignore_ascii_case("top-right")
388            || at_rule_name.eq_ignore_ascii_case("top-right-corner")
389            || at_rule_name.eq_ignore_ascii_case("bottom-left-corner")
390            || at_rule_name.eq_ignore_ascii_case("bottom-left")
391            || at_rule_name.eq_ignore_ascii_case("bottom-center")
392            || at_rule_name.eq_ignore_ascii_case("bottom-right")
393            || at_rule_name.eq_ignore_ascii_case("bottom-right-corner")
394            || at_rule_name.eq_ignore_ascii_case("left-top")
395            || at_rule_name.eq_ignore_ascii_case("left-middle")
396            || at_rule_name.eq_ignore_ascii_case("left-bottom")
397            || at_rule_name.eq_ignore_ascii_case("right-top")
398            || at_rule_name.eq_ignore_ascii_case("right-middle")
399            || at_rule_name.eq_ignore_ascii_case("right-bottom")
400            || at_rule_name.eq_ignore_ascii_case("viewport")
401            || at_rule_name.eq_ignore_ascii_case("try")
402            || at_rule_name.eq_ignore_ascii_case("starting-style")
403        {
404            let block = input.parse::<SimpleBlock>()?;
405            let end = block.span.end;
406            (None, Some(block), end)
407        } else if at_rule_name == "plugin" && input.syntax == Syntax::Less {
408            let prelude = input.parse::<LessPlugin>()?;
409            let end = prelude.span.end;
410            (Some(AtRulePrelude::LessPlugin(input.alloc(prelude))), None, end)
411        } else if at_rule_name.eq_ignore_ascii_case("function")
412            && matches!(&input.cursor.peek()?.token, Token::Ident(ident) if ident.raw.starts_with("--"))
413        {
414            // A CSS custom function (css-mixins spec): `@function --name(params)
415            // returns <type> { declarations }`. dart-sass parses this as plain
416            // CSS in every syntax (the `--` name marks it), so the prelude is
417            // kept as raw tokens and the body takes declarations with raw
418            // values.
419            let prelude = input.parse_raw_at_rule_prelude()?;
420            let block = input
421                .with_state(ParserState { in_css_function_body: true, ..input.state.clone() })
422                .parse::<SimpleBlock>()?;
423            let end = block.span.end;
424            (Some(AtRulePrelude::Unknown(input.alloc(prelude))), Some(block), end)
425        } else if matches!(input.syntax, Syntax::Scss | Syntax::Sass) {
426            use super::state::{
427                SASS_CTX_ALLOW_DIV, SASS_CTX_ALLOW_KEYFRAME_BLOCK, SASS_CTX_IN_FUNCTION,
428            };
429            match &*at_rule_name {
430                "each" => {
431                    let prelude = input.parse()?;
432                    let block = input.parse::<SimpleBlock>()?;
433                    let end = block.span.end;
434                    (Some(AtRulePrelude::SassEach(input.alloc(prelude))), Some(block), end)
435                }
436                "while" => {
437                    input.eat_sass_line_continuation()?;
438                    let prelude = input.parse()?;
439                    let block = input.parse::<SimpleBlock>()?;
440                    let end = block.span.end;
441                    (Some(AtRulePrelude::SassExpr(input.alloc(prelude))), Some(block), end)
442                }
443                "for" => {
444                    let prelude = input.parse()?;
445                    let block = input.parse::<SimpleBlock>()?;
446                    let end = block.span.end;
447                    (Some(AtRulePrelude::SassFor(input.alloc(prelude))), Some(block), end)
448                }
449                "mixin" => {
450                    let prelude = input.parse()?;
451                    let block = input
452                        .with_state(ParserState {
453                            sass_ctx: input.state.sass_ctx | SASS_CTX_ALLOW_KEYFRAME_BLOCK,
454                            ..input.state.clone()
455                        })
456                        .parse::<SimpleBlock>()?;
457                    let end = block.span.end;
458                    (Some(AtRulePrelude::SassMixin(input.alloc(prelude))), Some(block), end)
459                }
460                "include" => {
461                    let prelude = input.parse::<SassInclude>()?;
462                    let block = if matches!(
463                        input.cursor.peek()?.token,
464                        Token::LBrace(..) | Token::Indent(..)
465                    ) {
466                        Some(
467                            input
468                                .with_state(ParserState {
469                                    sass_ctx: input.state.sass_ctx | SASS_CTX_ALLOW_KEYFRAME_BLOCK,
470                                    ..input.state.clone()
471                                })
472                                .parse::<SimpleBlock>()?,
473                        )
474                    } else {
475                        None
476                    };
477                    let end =
478                        block.as_ref().map(|block| block.span.end).unwrap_or(prelude.span.end);
479                    (Some(AtRulePrelude::SassInclude(input.alloc(prelude))), block, end)
480                }
481                "content" => {
482                    if matches!(input.cursor.peek()?.token, Token::LParen(..)) {
483                        let prelude = input.parse::<SassContent>()?;
484                        let end = prelude.span.end;
485                        (Some(AtRulePrelude::SassContent(prelude)), None, end)
486                    } else {
487                        (None, None, input.cursor.tokenizer.current_offset())
488                    }
489                }
490                "use" => {
491                    let prelude = input.parse::<SassUse>()?;
492                    let end = prelude.span.end;
493                    (Some(AtRulePrelude::SassUse(input.alloc(prelude))), None, end)
494                }
495                "function" => {
496                    let prelude = input.parse::<SassFunction>()?;
497                    let block = input
498                        .with_state(ParserState {
499                            sass_ctx: input.state.sass_ctx | SASS_CTX_IN_FUNCTION,
500                            ..input.state.clone()
501                        })
502                        .parse::<SimpleBlock>()?;
503                    let end = block.span.end;
504                    (Some(AtRulePrelude::SassFunction(input.alloc(prelude))), Some(block), end)
505                }
506                "return" => {
507                    input.eat_sass_line_continuation()?;
508                    let expr = input
509                        .with_state(ParserState {
510                            sass_ctx: input.state.sass_ctx | SASS_CTX_ALLOW_DIV,
511                            ..input.state.clone()
512                        })
513                        .parse_maybe_sass_list(/* allow_comma */ true)?;
514                    let end = expr.span().end;
515                    if input.state.sass_ctx & SASS_CTX_IN_FUNCTION == 0 {
516                        input.recoverable_errors.push(Error {
517                            kind: ErrorKind::ReturnOutsideFunction,
518                            span: Span { start: at_keyword_span.start, end },
519                        });
520                    }
521                    (Some(AtRulePrelude::SassExpr(input.alloc(expr))), None, end)
522                }
523                "extend" => {
524                    let prelude = input.parse::<SassExtend>()?;
525                    let end = prelude.span.end;
526                    (Some(AtRulePrelude::SassExtend(input.alloc(prelude))), None, end)
527                }
528                "warn" | "error" | "debug" => {
529                    input.eat_sass_line_continuation()?;
530                    let expr = input.parse_maybe_sass_list(/* allow_comma */ true)?;
531                    let end = expr.span().end;
532                    (Some(AtRulePrelude::SassExpr(input.alloc(expr))), None, end)
533                }
534                "forward" => {
535                    let prelude = input.parse::<SassForward>()?;
536                    let end = prelude.span.end;
537                    (Some(AtRulePrelude::SassForward(input.alloc(prelude))), None, end)
538                }
539                "at-root" => {
540                    let prelude = if !matches!(
541                        input.cursor.peek()?.token,
542                        Token::LBrace(..)
543                            | Token::Indent(..)
544                            | Token::Linebreak(..)
545                            | Token::Dedent(..)
546                            | Token::Eof(..)
547                    ) {
548                        Some(AtRulePrelude::SassAtRoot(input.parse()?))
549                    } else {
550                        None
551                    };
552                    // `@at-root` escapes surrounding contexts, including a
553                    // `@keyframes` body — its block holds normal rules.
554                    let block = input
555                        .with_state(ParserState {
556                            in_keyframes_at_rule: false,
557                            ..input.state.clone()
558                        })
559                        .parse::<SimpleBlock>()?;
560                    let end = block.span.end;
561                    (prelude, Some(block), end)
562                }
563                _ => {
564                    let (prelude, block, end) = input.parse_unknown_at_rule()?;
565                    (
566                        prelude.map(|prelude| AtRulePrelude::Unknown(input.alloc(prelude))),
567                        block,
568                        end.unwrap_or(at_keyword_span.end),
569                    )
570                }
571            }
572        } else {
573            let (prelude, block, end) = input.parse_unknown_at_rule()?;
574            (
575                prelude.map(|prelude| AtRulePrelude::Unknown(input.alloc(prelude))),
576                block,
577                end.unwrap_or(at_keyword_span.end),
578            )
579        };
580
581        let span = Span { start: at_keyword_span.start, end };
582        Ok(AtRule {
583            name: input.ident(
584                at_keyword.ident,
585                Span { start: at_keyword_span.start + 1, end: at_keyword_span.end },
586            ),
587            prelude,
588            block,
589            span,
590        })
591    }
592}
593
594impl<'a> Parser<'a> {
595    /// `try_parse` a typed at-rule prelude that must account for everything
596    /// up to the end of the prelude (the block's opener or the statement
597    /// boundary) — otherwise the parse is rolled back so the caller can fall
598    /// back to a raw form.
599    fn try_parse_full_prelude<T>(&mut self, f: impl FnOnce(&mut Self) -> PResult<T>) -> PResult<T> {
600        self.try_parse(|p| {
601            let value = f(p)?;
602            match &p.cursor.peek()?.token {
603                Token::LBrace(..)
604                | Token::Indent(..)
605                | Token::Semicolon(..)
606                | Token::Dedent(..)
607                | Token::Linebreak(..)
608                | Token::Eof(..) => Ok(value),
609                _ => {
610                    let span = p.cursor.peek()?.span.clone();
611                    Err(Error { kind: ErrorKind::TryParseError, span })
612                }
613            }
614        })
615    }
616
617    /// A raw at-rule prelude: everything up to the body's `{` (or the end of
618    /// the statement), balancing pairs so interpolations and parens pass
619    /// through — CSS custom function preludes (`--name(--arg) returns <type>`)
620    /// and media queries the typed grammar can't express.
621    fn parse_raw_at_rule_prelude(&mut self) -> PResult<UnknownAtRulePrelude<'a>> {
622        let start = self.cursor.tokenizer.current_offset();
623        let mut tokens = self.vec();
624        let mut pairs: Vec<crate::util::PairedToken> = Vec::new();
625        loop {
626            match &self.cursor.peek()?.token {
627                Token::Semicolon(..)
628                | Token::Dedent(..)
629                | Token::Linebreak(..)
630                | Token::Indent(..)
631                | Token::Eof(..) => break,
632                Token::LBrace(..) if pairs.is_empty() => break,
633                // Interpolated strings must be consumed structurally — the
634                // tokenizer resumes the string after each `#{...}` — but
635                // their pieces are still plain tokens.
636                Token::StrTemplate(..) => {
637                    self.consume_str_template_tokens_into(&mut tokens)?;
638                    continue;
639                }
640                token => {
641                    if !crate::util::track_paired_token(token, &mut pairs) {
642                        break;
643                    }
644                }
645            }
646            tokens.push(self.cursor.bump()?);
647        }
648        let span = Span {
649            start: tokens.first().map_or(start, |token| token.span.start),
650            end: tokens.last().map_or(start, |token| token.span.end),
651        };
652        Ok(UnknownAtRulePrelude::TokenSeq(TokenSeq { tokens, span }))
653    }
654
655    // The generic `<at-rule>` for a name with no typed grammar: an arbitrary
656    // prelude of component values, then an optional `{}`-block or a `;`.
657    // https://drafts.csswg.org/css-syntax-3/#consume-at-rule
658    pub(super) fn parse_unknown_at_rule(
659        &mut self,
660    ) -> PResult<(Option<UnknownAtRulePrelude<'a>>, Option<SimpleBlock<'a>>, Option<usize>)> {
661        let prelude = self.parse_unknown_at_rule_prelude()?;
662        let block = match &self.cursor.peek()?.token {
663            // An unknown at-rule's children parse generically; in Sass that
664            // includes keyframe-style `10% { ... }` blocks
665            // (`@keyfr#{"ames"} a {...}`). less.js keeps them an error.
666            Token::LBrace(..) | Token::Indent(..) => {
667                let sass_ctx = if matches!(self.syntax, Syntax::Scss | Syntax::Sass) {
668                    self.state.sass_ctx | super::state::SASS_CTX_ALLOW_KEYFRAME_BLOCK
669                } else {
670                    self.state.sass_ctx
671                };
672                Some(
673                    self.with_state(ParserState { sass_ctx, ..self.state.clone() })
674                        .parse::<SimpleBlock>()?,
675                )
676            }
677            _ => None,
678        };
679        let end = block
680            .as_ref()
681            .map(|block| block.span.end)
682            .or_else(|| prelude.as_ref().map(|prelude| prelude.span().end));
683        Ok((prelude, block, end))
684    }
685
686    // The prelude of an unknown at-rule: raw `<component-value>*` up to the body
687    // `{` or the statement boundary.
688    fn parse_unknown_at_rule_prelude(&mut self) -> PResult<Option<UnknownAtRulePrelude<'a>>> {
689        if let Ok(prelude) = self.try_parse(|parser| {
690            let mut tokens = parser.vec();
691            loop {
692                match &parser.cursor.peek()?.token {
693                    Token::LBrace(..)
694                    | Token::RBrace(..)
695                    | Token::Semicolon(..)
696                    | Token::Indent(..)
697                    | Token::Dedent(..)
698                    | Token::Linebreak(..)
699                    | Token::Eof(..) => break,
700                    Token::StrTemplate(..) | Token::HashLBrace(..) => {
701                        return Err(Error {
702                            kind: ErrorKind::TryParseError,
703                            span: parser.cursor.bump()?.span,
704                        });
705                    }
706                    _ => tokens.push(parser.cursor.bump()?),
707                }
708            }
709            if let Some((first, last)) = tokens.first().zip(tokens.last()) {
710                let span = Span { start: first.span.start, end: last.span.end };
711                Ok(Some(UnknownAtRulePrelude::TokenSeq(TokenSeq { tokens, span })))
712            } else {
713                Ok(None)
714            }
715        }) {
716            return Ok(prelude);
717        }
718
719        Ok(Some(UnknownAtRulePrelude::ComponentValue(match self.syntax {
720            Syntax::Css => self.parse()?,
721            Syntax::Scss | Syntax::Sass => {
722                self.parse_maybe_sass_list(/* allow_comma */ true)?
723            }
724            Syntax::Less => self.parse_maybe_less_list(/* allow_comma */ true)?,
725        })))
726    }
727}