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