Skip to main content

oxc_css_parser/parser/
stmt.rs

1use super::{
2    Parser,
3    state::{ParserState, QualifiedRuleContext},
4};
5use crate::{
6    Parse, Syntax,
7    ast::*,
8    error::{Error, ErrorKind, PResult},
9    pos::Span,
10    tokenizer::{Token, TokenWithSpan},
11};
12
13// https://drafts.csswg.org/css-syntax-3/#consume-declaration
14//
15// <declaration> = <ident-token> : <declaration-value>? [ '!' important ]?
16impl<'a> Parse<'a> for Declaration<'a> {
17    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
18        // Legacy IE hacks glued to the property name: `*color: red` targets
19        // IE<=7; dart-sass's plain-CSS parser additionally accepts `:x`, `.x`
20        // and `#x` prefixes. Keep them as a property-name prefix — but only
21        // when glued: `* color` (whitespace or a comment after the sigil) is
22        // not the hack, so leave the token for the normal (failing) parse.
23        let mut name_prefix = if input.state.allow_ie_star_hack
24            && let TokenWithSpan { token, span } = input.cursor.peek()?
25            && let Some(prefix) = match token {
26                Token::Asterisk(..) => Some('*'),
27                Token::Dot(..) if input.syntax == Syntax::Css => Some('.'),
28                Token::Colon(..) if input.syntax == Syntax::Css => Some(':'),
29                _ => None,
30            }
31            && input
32                .source
33                .as_bytes()
34                .get(span.end)
35                .is_some_and(|b| !b.is_ascii_whitespace() && *b != b'/')
36        {
37            let start = span.start;
38            input.cursor.bump()?;
39            Some((start, prefix))
40        } else {
41            None
42        };
43        // A css-in-js `${}` placeholder may stand in for the property name
44        // (`${foo}: ${bar}`); it is not a real ident, so accept it directly.
45        let name = if let Token::Placeholder(..) = input.cursor.peek()?.token {
46            let (placeholder, span) = input.cursor.expect_placeholder()?;
47            InterpolableIdent::Placeholder((placeholder, span).into())
48        } else if input.state.allow_ie_star_hack
49            && input.syntax == Syntax::Less
50            && let token = input.cursor.peek()?
51            && let Some(raw) = token.number_raw(input.source)
52            && raw.bytes().all(|b| b.is_ascii_digit())
53        {
54            let span = token.span;
55            input.cursor.bump()?;
56            InterpolableIdent::Literal(Ident { name: raw, raw, span })
57        } else if name_prefix.is_none()
58            && input.state.allow_ie_star_hack
59            && input.syntax == Syntax::Css
60            && matches!(input.cursor.peek()?.token, Token::Hash(..))
61        {
62            // `#x: y` — the `#` and the name arrive as one <hash-token>.
63            let token @ TokenWithSpan { token: Token::Hash(..), span } = input.cursor.bump()?
64            else {
65                unreachable!()
66            };
67            let hash = token.hash(input.source).unwrap();
68            name_prefix = Some((span.start, '#'));
69            let name = if hash.escaped {
70                crate::util::handle_escape_in(hash.raw, input.allocator)
71            } else {
72                hash.raw
73            };
74            InterpolableIdent::Literal(Ident {
75                name,
76                raw: hash.raw,
77                span: Span { start: span.start + 1, end: span.end },
78            })
79        } else {
80            input
81                .with_state(ParserState {
82                    qualified_rule_ctx: Some(QualifiedRuleContext::DeclarationName),
83                    ..input.state
84                })
85                .parse::<InterpolableIdent>()?
86        };
87
88        // https://tailwindcss.com/docs/theme#overriding-the-default-theme
89        let name_suffix = if let TokenWithSpan { token: Token::Asterisk(..), span } =
90            input.cursor.peek()?
91            && name.span().end == span.start
92        {
93            input.cursor.bump()?;
94            Some('*')
95        } else {
96            None
97        };
98
99        // Less property merge (`prop+: v`, `prop+_: v`) — also accepted for
100        // plain CSS since Less serializes the flag into its output.
101        let less_property_merge =
102            if matches!(input.syntax, Syntax::Less | Syntax::Css) { input.parse()? } else { None };
103
104        let (_, colon_span) = input.cursor.expect_colon()?;
105        let (mut value, mut important) = {
106            let mut parser = input.with_state(ParserState {
107                qualified_rule_ctx: Some(QualifiedRuleContext::DeclarationValue),
108                ..input.state
109            });
110            // For IE-compatibility, regardless of the property name (`filter`,
111            // `-ms-filter`, vendor variants...): `filter: progid:...`.
112            let source = parser.source;
113            let starts_with_progid =
114                parser.cursor.peek()?.is_ident_name_eq_ignore_ascii_case(source, "progid");
115            match &name {
116                InterpolableIdent::Literal(ident)
117                    if ident.name.starts_with("--") || starts_with_progid =>
118                'value: {
119                    if parser.options.try_parsing_value_in_custom_property
120                        && let Ok(values) = parser.try_parse(Parser::parse_declaration_value)
121                    {
122                        break 'value (values, None);
123                    }
124                    (parser.parse_declaration_value_tokens(false)?, None)
125                }
126                // In CSS, a declaration value is any sequence of component
127                // values (CSS Syntax §5): serialized selectors (`b: .c > d`),
128                // map-like blocks (`b: (3: 4)`), or stray delimiters are all
129                // valid preserved tokens even though the typed grammar has no
130                // node for them. Try the typed grammar first; if it fails, or
131                // succeeds without accounting for everything up to the
132                // declaration terminator, re-parse the whole value as raw
133                // tokens. Scss/Sass/Less keep the strict grammar: their
134                // dialects assign meaning to these tokens and are expected to
135                // reject exactly what their reference compilers reject.
136                _ if parser.syntax == Syntax::Css
137                    || (parser.state.in_css_function_body
138                        && matches!(&name, InterpolableIdent::Literal(..))) =>
139                {
140                    let typed = parser.try_parse(|p| {
141                        let values = p.parse_declaration_value()?;
142                        let important = match &p.cursor.peek()?.token {
143                            Token::Exclamation(..) => Some(p.parse::<ImportantAnnotation>()?),
144                            _ => None,
145                        };
146                        let next = p.cursor.peek()?;
147                        if at_declaration_value_end(&next.token) {
148                            Ok((values, important))
149                        } else {
150                            Err(Error { kind: ErrorKind::ExpectComponentValue, span: next.span })
151                        }
152                    });
153                    match typed {
154                        Ok(value_and_important) => value_and_important,
155                        Err(error) => {
156                            // A CSS custom function body holds declarations
157                            // only, so a top-level `{}` there is part of the
158                            // value; elsewhere it means this construct is
159                            // really a qualified rule (CSS Nesting
160                            // disambiguation) and the declaration is rejected.
161                            let in_fn_body = parser.state.in_css_function_body;
162                            let values = parser.parse_declaration_value_tokens(!in_fn_body)?;
163                            if !in_fn_body && let Token::LBrace(..) = &parser.cursor.peek()?.token {
164                                return Err(error);
165                            }
166                            (values, None)
167                        }
168                    }
169                }
170                _ => (parser.parse_declaration_value()?, None),
171            }
172        };
173
174        if important.is_none()
175            && let Token::Exclamation(..) = &input.cursor.peek()?.token
176        {
177            important = Some(input.parse::<ImportantAnnotation>()?);
178        }
179        // dart-sass allows `!important` mid-value (`fludge: foo bar
180        // !important hux;`): when more value follows, the annotation is just
181        // another component, and only a trailing one is structural.
182        while matches!(input.syntax, Syntax::Scss | Syntax::Sass)
183            && important.is_some()
184            && !at_declaration_value_end(&input.cursor.peek()?.token)
185        {
186            if let Some(annotation) = important.take() {
187                value.push(ComponentValue::ImportantAnnotation(annotation));
188            }
189            let more = input
190                .with_state(ParserState {
191                    qualified_rule_ctx: Some(QualifiedRuleContext::DeclarationValue),
192                    ..input.state
193                })
194                .parse_declaration_value()?;
195            for component in more {
196                value.push(component);
197            }
198            if let Token::Exclamation(..) = &input.cursor.peek()?.token {
199                important = Some(input.parse::<ImportantAnnotation>()?);
200            }
201        }
202
203        let span = Span {
204            start: name_prefix.map_or(name.span().start, |(start, _)| start),
205            end: if let Some(important) = &important {
206                important.span.end
207            } else if let Some(last) = value.last() {
208                last.span().end
209            } else {
210                colon_span.end
211            },
212        };
213        Ok(Declaration {
214            name,
215            name_prefix: name_prefix.map(|(_, prefix)| prefix),
216            name_suffix,
217            colon_span,
218            value,
219            important,
220            less_property_merge,
221            span,
222        })
223    }
224}
225
226/// End of a declaration's value: the declaration terminator tokens.
227fn at_declaration_value_end(token: &Token) -> bool {
228    matches!(
229        token,
230        Token::Semicolon(..)
231            | Token::RBrace(..)
232            | Token::RParen(..)
233            | Token::Dedent(..)
234            | Token::Linebreak(..)
235            | Token::Eof(..)
236    )
237}
238
239// <important> = '!' important
240impl<'a> Parse<'a> for ImportantAnnotation<'a> {
241    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
242        let (_, span) = input.cursor.expect_exclamation()?;
243        input.eat_sass_line_continuation()?;
244        let ident: Ident = input.parse::<Ident>()?;
245        let span = Span { start: span.start, end: ident.span.end };
246        if ident.name.eq_ignore_ascii_case("important") {
247            Ok(ImportantAnnotation { ident, span })
248        } else {
249            Err(Error { kind: ErrorKind::ExpectImportantAnnotation, span })
250        }
251    }
252}
253
254// https://drafts.csswg.org/css-syntax-3/#consume-qualified-rule
255//
256// <qualified-rule> = <prelude> <{}-block>
257// In a style context the prelude is a selector list:
258//   <style-rule> = <selector-list> { <style-block> }
259impl<'a> Parse<'a> for QualifiedRule<'a> {
260    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
261        let selector_list = input
262            .with_state(ParserState {
263                qualified_rule_ctx: Some(QualifiedRuleContext::Selector),
264                ..input.state
265            })
266            .parse::<SelectorList>()?;
267        let block = input.parse::<SimpleBlock>()?;
268        let span = Span { start: selector_list.span.start, end: block.span.end };
269        Ok(QualifiedRule { selector: selector_list, block, span })
270    }
271}
272
273// https://drafts.csswg.org/css-syntax-3/#consume-simple-block
274//
275// <simple-block> = '{' <block-contents> '}'
276// (Sass indented syntax substitutes Indent/Dedent for the braces.)
277impl<'a> Parse<'a> for SimpleBlock<'a> {
278    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
279        let is_sass = input.syntax == Syntax::Sass;
280        let start = if is_sass {
281            // A continuation line deeper than this block's own level leaves a
282            // pending indent whose `Dedent` arrives before the block opens
283            // (`a,\n    b\n  c: d`); cancel those out first.
284            let drained = input.drain_sass_pending_dedents()?;
285            if let Some((_, span)) = input.cursor.eat_indent()? {
286                span.end
287            } else if drained
288                && input.sass_pending_indents == 0
289                && input.cursor.tokenizer.reopen_indent_level()
290            {
291                // The block's level sat between two known indents, so its
292                // `Indent` was never emitted; re-open it directly.
293                input.cursor.peek()?.span.start
294            } else if input.sass_pending_indents > 0 {
295                // The statement's clause consumed this block's `Indent` as a
296                // line continuation (`@each $a in\n  b, c\n  .x\n    ...`);
297                // enter the block "virtually" at that depth.
298                input.sass_pending_indents -= 1;
299                input.cursor.peek()?.span.start
300            } else {
301                let offset = input.cursor.peek()?.span.start;
302                return Ok(SimpleBlock {
303                    statements: input.vec(),
304                    span: Span { start: offset, end: offset },
305                });
306            }
307        } else {
308            input.cursor.expect_l_brace()?.1.start
309        };
310
311        let statements = input.parse_statements(/* is_top_level */ false)?;
312
313        // CSS Syntax: EOF closes all open constructs (a parse error, but the
314        // tree is valid — browsers accept unclosed blocks at EOF). The
315        // dialects' reference compilers reject them. Recovery is unchanged; the
316        // parse error is surfaced via `recoverable_errors` so downstream
317        // consumers can tell it apart from a properly closed block.
318        if input.syntax == Syntax::Css && matches!(input.cursor.peek()?.token, Token::Eof(..)) {
319            let end = input.cursor.peek()?.span.start;
320            input
321                .recoverable_errors
322                .push(Error { kind: ErrorKind::EofInBlock, span: Span { start, end: start + 1 } });
323            return Ok(SimpleBlock { statements, span: Span { start, end } });
324        }
325
326        if is_sass {
327            match input.cursor.bump()? {
328                TokenWithSpan { token: Token::Dedent(..) | Token::Eof(..), span } => {
329                    let end = statements.last().map_or(span.start, |last| last.span().end);
330                    Ok(SimpleBlock { statements, span: Span { start, end } })
331                }
332                TokenWithSpan { span, .. } => {
333                    Err(Error { kind: ErrorKind::ExpectDedentOrEof, span })
334                }
335            }
336        } else {
337            let end = input.cursor.expect_r_brace()?.1.end;
338            Ok(SimpleBlock { statements, span: Span { start, end } })
339        }
340    }
341}
342
343// https://drafts.csswg.org/css-syntax-3/#parse-a-stylesheet
344//
345// <stylesheet> = <rule-list>
346impl<'a> Parse<'a> for Stylesheet<'a> {
347    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
348        let statements = input.parse_statements(/* is_top_level */ true)?;
349        input.cursor.expect_eof()?;
350        Ok(Stylesheet { statements, span: Span { start: 0, end: input.source.len() } })
351    }
352}
353
354impl<'a> Parser<'a> {
355    /// `<declaration-value>` consumed as raw tokens (CSS Syntax "preserved
356    /// tokens"), balancing `()`/`[]`/`{}` pairs, until a top-level `;`, an
357    /// unbalanced closer, or a statement boundary. Used for custom-property
358    /// values and as the fallback for CSS values the typed grammar rejects.
359    ///
360    /// <https://drafts.csswg.org/css-syntax-3/#typedef-declaration-value>
361    ///
362    /// `stop_at_top_level_brace` implements the CSS Nesting disambiguation: a
363    /// `{` at the top level of a normal declaration's value means the whole
364    /// construct is really a qualified rule, so the value must end there.
365    /// Custom properties are exempt (`--foo: {a:b}` is a valid value).
366    pub(super) fn parse_declaration_value_tokens(
367        &mut self,
368        stop_at_top_level_brace: bool,
369    ) -> PResult<oxc_allocator::Vec<'a, ComponentValue<'a>>> {
370        let mut values = self.vec_with_capacity(3);
371        let mut pairs = Vec::with_capacity(1);
372        // Span of the outermost currently-open pair (kept in sync with `pairs`
373        // going empty↔non-empty), so the EOF parse error can point at the
374        // opener like `SimpleBlock` does, not at the end of file.
375        let mut outermost_pair_span: Option<Span> = None;
376        loop {
377            match &self.cursor.peek()?.token {
378                Token::Dedent(..) | Token::Linebreak(..) => break,
379                // CSS Syntax: EOF closes any still-open `(`/`[`/`{` group; recovery
380                // is unchanged, but record the parse error so downstream consumers
381                // can tell it apart from a balanced value. Report the outermost
382                // unclosed opener; a raw-value `{` (e.g. `--x: {` — legal in custom
383                // properties) is a block, not a paren.
384                Token::Eof(..) => {
385                    if let (Some(pair), Some(span)) = (pairs.first(), outermost_pair_span) {
386                        let kind = match pair {
387                            crate::util::PairedToken::Brace => ErrorKind::EofInBlock,
388                            _ => ErrorKind::UnclosedParen,
389                        };
390                        self.recoverable_errors.push(Error { kind, span });
391                    }
392                    break;
393                }
394                Token::Semicolon(..) if pairs.is_empty() => {
395                    break;
396                }
397                Token::LBrace(..) if stop_at_top_level_brace && pairs.is_empty() => {
398                    break;
399                }
400                // An unterminated string survives as a preserved token (a parse
401                // error kept verbatim; CSS Syntax §4.3.5). The tokenizer emits a
402                // `BadStr` for both recoverable forms; recover the spec's split
403                // from where the string stopped — at EOF it is a `<string-token>`
404                // (canonically closable by appending the quote), at a newline a
405                // `<bad-string-token>`.
406                Token::BadStr(..) => {
407                    let span = self.cursor.peek()?.span.clone();
408                    let kind = if span.end == self.source.len() {
409                        ErrorKind::UnterminatedString
410                    } else {
411                        ErrorKind::BadString
412                    };
413                    self.recoverable_errors.push(Error { kind, span });
414                }
415                // An interpolated string (e.g. `'#{$expr}'` inside
416                // `filter: progid:...`) must be parsed structurally:
417                // the tokenizer needs `scan_string_template` to resume
418                // the string after each `#{...}`, so consuming its
419                // tokens as a plain stream would mis-lex the rest.
420                Token::StrTemplate(..) => {
421                    values.push(ComponentValue::InterpolableStr(self.parse()?));
422                    continue;
423                }
424                token => {
425                    let was_empty = pairs.is_empty();
426                    if !crate::util::track_paired_token(token, &mut pairs) {
427                        break;
428                    }
429                    if was_empty && !pairs.is_empty() {
430                        outermost_pair_span = Some(self.cursor.peek()?.span.clone());
431                    }
432                }
433            }
434            values.push(ComponentValue::TokenWithSpan(self.cursor.bump()?));
435        }
436        Ok(values)
437    }
438
439    // The typed form of `<declaration-value>`: a list of `<component-value>` up to
440    // the declaration terminator (`;`, `!`, `}`, or a statement boundary).
441    pub(super) fn parse_declaration_value(
442        &mut self,
443    ) -> PResult<oxc_allocator::Vec<'a, ComponentValue<'a>>> {
444        let mut values = self.vec_with_capacity(3);
445        loop {
446            match &self.cursor.peek()?.token {
447                Token::RBrace(..)
448                | Token::RParen(..)
449                | Token::Semicolon(..)
450                | Token::Dedent(..)
451                | Token::Linebreak(..)
452                | Token::Exclamation(..)
453                | Token::Eof(..) => break,
454                _ => {
455                    let value = self.parse::<ComponentValue>()?;
456                    match &value {
457                        ComponentValue::SassNestingDeclaration(..)
458                            if matches!(self.syntax, Syntax::Scss | Syntax::Sass) =>
459                        {
460                            values.push(value);
461                            break;
462                        }
463                        _ => values.push(value),
464                    }
465                }
466            }
467        }
468        Ok(values)
469    }
470
471    /// In a `@keyframes` body, an ident may start a keyframe block (`from {`)
472    /// or — in real-world code — a plain declaration (`blah: blee;`); dart-sass
473    /// accepts both. Returns the statement and whether it opened a block.
474    fn parse_keyframe_block_or_declaration(&mut self) -> PResult<(Statement<'a>, bool)> {
475        if let Ok(block) = self.try_parse(KeyframeBlock::parse) {
476            Ok((Statement::KeyframeBlock(block), true))
477        } else {
478            let decl = self.parse_style_rule_declaration()?;
479            Ok((Statement::Declaration(decl), false))
480        }
481    }
482
483    /// The CSS Nesting `<style-block>` ambiguity: parse a qualified rule, falling
484    /// back to a declaration when the `foo: bar` vs `foo { }` prelude is
485    /// ambiguous. Returns the statement and whether it opened a block (for the
486    /// caller's `is_block_element`).
487    ///
488    /// <https://drafts.csswg.org/css-nesting-1/#syntax>
489    fn parse_rule_or_declaration(&mut self, is_top_level: bool) -> PResult<(Statement<'a>, bool)> {
490        match self.try_parse(QualifiedRule::parse) {
491            Ok(rule) => Ok((Statement::QualifiedRule(rule), true)),
492            Err(error_rule) => match self.parse_style_rule_declaration() {
493                Ok(decl) => {
494                    // Only Scss/Sass produce `SassNestingDeclaration`; in CSS this is
495                    // always `false`, matching the previous per-syntax behavior.
496                    let is_block_element = matches!(
497                        decl.value.last(),
498                        Some(ComponentValue::SassNestingDeclaration(..))
499                    );
500                    if is_top_level {
501                        self.recoverable_errors
502                            .push(Error { kind: ErrorKind::TopLevelDeclaration, span: decl.span });
503                    }
504                    Ok((Statement::Declaration(decl), is_block_element))
505                }
506                Err(error_decl) => Err(if is_top_level { error_rule } else { error_decl }),
507            },
508        }
509    }
510
511    /// Parse a declaration that is a statement in a style-rule block, enabling the
512    /// IE `*color` hack (see `ParserState::allow_ie_star_hack`). Feature-query
513    /// declarations (`@supports`, `@container style()`, `@import supports()`) call
514    /// `Declaration::parse` directly and so never enable it.
515    fn parse_style_rule_declaration(&mut self) -> PResult<Declaration<'a>> {
516        self.with_state(ParserState { allow_ie_star_hack: true, ..self.state.clone() }).parse()
517    }
518
519    // Block contents: a mix of declarations, nested style rules and at-rules
520    // (CSS Syntax `<block-contents>`; `is_top_level` selects the `<stylesheet>`
521    // rule-list, which has no declarations).
522    // https://drafts.csswg.org/css-syntax-3/#consume-block-contents
523    fn parse_statements(
524        &mut self,
525        is_top_level: bool,
526    ) -> PResult<oxc_allocator::Vec<'a, Statement<'a>>> {
527        let mut statements = self.vec_with_capacity(1);
528        loop {
529            // Set true for braced blocks AND `${}` placeholder statements: both
530            // make the trailing terminator optional. A placeholder substitutes a
531            // whole statement/declaration and, like postcss, needs no `;`, so the
532            // next statement may follow directly (`${mixin}\n@media {...}`,
533            // `${a} ${b}`, `${foo}: ${bar}`).
534            let mut is_block_element = false;
535            let TokenWithSpan { token, span } = self.cursor.peek()?;
536            match token {
537                Token::Ident(..) | Token::HashLBrace(..) | Token::AtLBraceVar(..) => {
538                    match self.syntax {
539                        Syntax::Css => {
540                            if self.state.in_keyframes_at_rule {
541                                let (stmt, is_block) =
542                                    self.parse_keyframe_block_or_declaration()?;
543                                is_block_element = is_block;
544                                statements.push(stmt);
545                            } else {
546                                let (stmt, is_block) =
547                                    self.parse_rule_or_declaration(is_top_level)?;
548                                is_block_element = is_block;
549                                statements.push(stmt);
550                            }
551                        }
552                        Syntax::Scss | Syntax::Sass => {
553                            if let Ok(sass_var_decl) =
554                                self.try_parse(SassVariableDeclaration::parse)
555                            {
556                                statements.push(Statement::SassVariableDeclaration(
557                                    self.alloc(sass_var_decl),
558                                ));
559                            } else if self.state.in_keyframes_at_rule {
560                                let (stmt, is_block) =
561                                    self.parse_keyframe_block_or_declaration()?;
562                                is_block_element = is_block;
563                                statements.push(stmt);
564                            } else {
565                                let (stmt, is_block) =
566                                    self.parse_rule_or_declaration(is_top_level)?;
567                                is_block_element = is_block;
568                                statements.push(stmt);
569                            }
570                        }
571                        Syntax::Less => {
572                            if let Ok(stmt) = self.try_parse(Parser::parse_less_qualified_rule) {
573                                statements.push(stmt);
574                                is_block_element = true;
575                            } else if let Ok(decl) =
576                                // less.js parses root-level declarations and
577                                // only rejects them at eval time.
578                                self.try_parse(Declaration::parse)
579                            {
580                                statements.push(Statement::Declaration(decl));
581                            } else if self.state.in_keyframes_at_rule {
582                                statements.push(Statement::KeyframeBlock(self.parse()?));
583                                is_block_element = true;
584                            } else {
585                                let fn_call = self.parse::<Function>()?;
586                                is_block_element = matches!(
587                                    fn_call.args.last(),
588                                    Some(ComponentValue::LessDetachedRuleset(..))
589                                );
590                                statements.push(Statement::LessFunctionCall(fn_call));
591                            }
592                        }
593                    }
594                }
595                // `5:-` — less.js's ruleProperty regex (`[_a-zA-Z0-9-]+`)
596                // allows digit-only declaration names
597                Token::Number(..)
598                    if self.syntax == Syntax::Less
599                        && !is_top_level
600                        && self.source.as_bytes().get(span.end) == Some(&b':') =>
601                {
602                    let decl = self.parse_style_rule_declaration()?;
603                    statements.push(Statement::Declaration(decl));
604                }
605                // `.3D(...)` — less.js allows digit-led mixin names, which
606                // arrive as one <dimension-token>; they behave exactly like
607                // `.foo` (`.3D ()`, `.3D;`), so only the leading `.` matters
608                Token::Dot(..) | Token::Hash(..) | Token::Dimension(..)
609                    if self.syntax == Syntax::Less
610                        && (!matches!(token, Token::Dimension(..))
611                            || self.source.as_bytes().get(span.start) == Some(&b'.')) =>
612                {
613                    let stmt = if let Ok(stmt) = self.try_parse(Parser::parse_less_qualified_rule) {
614                        is_block_element = true;
615                        stmt
616                    } else if let Ok(mixin_def) = self.try_parse(LessMixinDefinition::parse) {
617                        is_block_element = true;
618                        Statement::LessMixinDefinition(self.alloc(mixin_def))
619                    } else {
620                        self.parse().map(Statement::LessMixinCall)?
621                    };
622                    statements.push(stmt);
623                }
624                Token::Dot(..) | Token::Hash(..) if !self.state.in_keyframes_at_rule => {
625                    if self.syntax == Syntax::Css {
626                        let (stmt, is_block) = self.parse_rule_or_declaration(is_top_level)?;
627                        is_block_element = is_block;
628                        statements.push(stmt);
629                    } else {
630                        statements.push(Statement::QualifiedRule(self.parse()?));
631                        is_block_element = true;
632                    }
633                }
634                Token::Ampersand(..)
635                | Token::LBracket(..)
636                | Token::Colon(..)
637                | Token::ColonColon(..)
638                | Token::Asterisk(..)
639                | Token::Bar(..)
640                | Token::NumberSign(..)
641                    if !self.state.in_keyframes_at_rule =>
642                {
643                    if matches!(self.cursor.peek()?.token, Token::Asterisk(..)) {
644                        // `*color: red` / `*zoom: 1` (an IE<=7 hack) looks like a `*`
645                        // universal selector but is a declaration; try the rule, then
646                        // fall back to a declaration. (A `*` never starts a
647                        // `LessExtendRule`, so this can precede the Less split.)
648                        if self.syntax == Syntax::Less {
649                            match self.try_parse(Parser::parse_less_qualified_rule) {
650                                Ok(stmt) => {
651                                    statements.push(stmt);
652                                    is_block_element = true;
653                                }
654                                // Less refuses declarations at the top level, like the
655                                // ident-led path; keep root-level `*zoom: 1` an error.
656                                Err(rule_err) if is_top_level => return Err(rule_err),
657                                Err(_) => {
658                                    let decl = self.parse_style_rule_declaration()?;
659                                    statements.push(Statement::Declaration(decl));
660                                }
661                            }
662                        } else {
663                            let (stmt, is_block) = self.parse_rule_or_declaration(is_top_level)?;
664                            is_block_element = is_block;
665                            statements.push(stmt);
666                        }
667                    } else if self.syntax == Syntax::Less {
668                        if let Ok(extend_rule) = self.try_parse(LessExtendRule::parse) {
669                            statements.push(Statement::LessExtendRule(extend_rule));
670                        } else {
671                            statements.push(self.parse_less_qualified_rule()?);
672                            is_block_element = true;
673                        }
674                    } else if self.syntax == Syntax::Css
675                        && matches!(self.cursor.peek()?.token, Token::Colon(..))
676                    {
677                        let (stmt, is_block) = self.parse_rule_or_declaration(is_top_level)?;
678                        is_block_element = is_block;
679                        statements.push(stmt);
680                    } else {
681                        statements.push(Statement::QualifiedRule(self.parse()?));
682                        is_block_element = true;
683                    }
684                }
685                Token::AtKeyword(..) => match self.syntax {
686                    Syntax::Css => {
687                        let at_rule = self.parse::<AtRule>()?;
688                        is_block_element = at_rule.block.is_some();
689                        statements.push(Statement::AtRule(at_rule));
690                    }
691                    Syntax::Scss | Syntax::Sass => {
692                        let at_keyword_name =
693                            self.cursor.peek()?.at_keyword(self.source).unwrap().ident.name();
694                        match &*at_keyword_name {
695                            "if" => {
696                                let sass_if_at_rule = self.parse()?;
697                                statements
698                                    .push(Statement::SassIfAtRule(self.alloc(sass_if_at_rule)));
699                                is_block_element = true;
700                            }
701                            "else" => {
702                                return Err(Error {
703                                    kind: ErrorKind::UnexpectedSassElseAtRule,
704                                    span: self.cursor.bump()?.span,
705                                });
706                            }
707                            _ => {
708                                let at_rule = self.parse::<AtRule>()?;
709                                is_block_element = at_rule.block.is_some();
710                                statements.push(Statement::AtRule(at_rule));
711                            }
712                        }
713                    }
714                    Syntax::Less => {
715                        if let Ok(less_variable_declaration) =
716                            self.try_parse(LessVariableDeclaration::parse)
717                        {
718                            is_block_element = matches!(
719                                less_variable_declaration.value,
720                                ComponentValue::LessDetachedRuleset(..)
721                            );
722                            statements.push(Statement::LessVariableDeclaration(
723                                self.alloc(less_variable_declaration),
724                            ));
725                        } else if let Ok(variable_call) = self.try_parse(LessVariableCall::parse) {
726                            statements.push(Statement::LessVariableCall(variable_call));
727                        } else {
728                            let at_rule = self.parse::<AtRule>()?;
729                            is_block_element = at_rule.block.is_some();
730                            statements.push(Statement::AtRule(at_rule));
731                        }
732                    }
733                },
734                Token::Placeholder(..) => {
735                    // A placeholder may start a qualified rule (a substituted
736                    // selector, e.g. CSS-in-JS `${Component} { ... }`) or stand
737                    // alone as a statement (e.g. `` `PLACEHOLDER-0`; ``).
738                    //
739                    // A placeholder-led selector must not absorb across a newline:
740                    // prettier keeps `${mixin}` on its own line and the following
741                    // selector as a separate rule (`${mixin}\n& > .x {}` is two
742                    // statements, not one). So only attempt the rule when the block
743                    // `{` is reachable without an intervening newline-then-selector.
744                    //
745                    // A placeholder may also be a declaration property name
746                    // (`${foo}: ${bar}`), so try a declaration before falling back
747                    // to a bare placeholder statement.
748                    let ph_end = self.cursor.peek()?.span.end;
749                    if self.placeholder_starts_qualified_rule(ph_end)
750                        && let Ok(rule) = self.try_parse(QualifiedRule::parse)
751                    {
752                        statements.push(Statement::QualifiedRule(rule));
753                        is_block_element = true;
754                    } else if let Ok(declaration) = self.try_parse(Declaration::parse) {
755                        // Reached only via the placeholder token above, so this
756                        // is the `${foo}: ${bar}` form (placeholder property name).
757                        statements.push(Statement::Declaration(declaration));
758                        is_block_element = true;
759                    } else {
760                        let (placeholder, span) = self.cursor.expect_placeholder()?;
761                        statements.push(Statement::Placeholder((placeholder, span).into()));
762                        is_block_element = true;
763                    }
764                }
765                // Css too: postcss-extend-rule's `%thick-border {}`
766                // (see the placeholder arm in `SimpleSelector`'s parser).
767                Token::Percent(..)
768                    if matches!(self.syntax, Syntax::Scss | Syntax::Sass | Syntax::Css) =>
769                {
770                    statements.push(Statement::QualifiedRule(self.parse()?));
771                    is_block_element = true;
772                }
773                Token::DollarVar(..) if matches!(self.syntax, Syntax::Scss | Syntax::Sass) => {
774                    let declaration = self.parse()?;
775                    statements.push(Statement::SassVariableDeclaration(self.alloc(declaration)));
776                }
777                Token::DollarVar(..)
778                    if self.syntax == Syntax::Css && self.options.allow_postcss_simple_vars =>
779                {
780                    let declaration = self.parse()?;
781                    statements
782                        .push(Statement::PostcssSimpleVarDeclaration(self.alloc(declaration)));
783                }
784                // Indented-syntax shorthands: `=name` defines a mixin
785                // (`@mixin name`) and `+name` includes one (`@include name`).
786                // A spaced `+ b` stays a sibling-combinator selector: `+` is
787                // an include only when glued to an identifier.
788                Token::Equal(..) if self.syntax == Syntax::Sass => {
789                    let eq_span = self.cursor.bump()?.span;
790                    self.eat_sass_line_continuation()?;
791                    let prelude = self.parse::<SassMixin>()?;
792                    let block = self
793                        .with_state(ParserState {
794                            sass_ctx: self.state.sass_ctx
795                                | super::state::SASS_CTX_ALLOW_KEYFRAME_BLOCK,
796                            ..self.state.clone()
797                        })
798                        .parse::<SimpleBlock>()?;
799                    let span = Span { start: eq_span.start, end: block.span.end };
800                    statements.push(Statement::AtRule(AtRule {
801                        name: Ident { name: "mixin", raw: "=", span: eq_span },
802                        prelude: Some(AtRulePrelude::SassMixin(self.alloc(prelude))),
803                        block: Some(block),
804                        span,
805                    }));
806                    is_block_element = true;
807                }
808                Token::Plus(..)
809                    if self.syntax == Syntax::Sass
810                        && crate::tokenizer::ident_starts_at(self.source, span.end) =>
811                {
812                    let plus_span = self.cursor.bump()?.span;
813                    let prelude = self.parse::<SassInclude>()?;
814                    let block = if matches!(
815                        self.cursor.peek()?.token,
816                        Token::LBrace(..) | Token::Indent(..)
817                    ) {
818                        Some(
819                            self.with_state(ParserState {
820                                sass_ctx: self.state.sass_ctx
821                                    | super::state::SASS_CTX_ALLOW_KEYFRAME_BLOCK,
822                                ..self.state.clone()
823                            })
824                            .parse::<SimpleBlock>()?,
825                        )
826                    } else {
827                        None
828                    };
829                    let end = block.as_ref().map_or(prelude.span.end, |block| block.span.end);
830                    let span = Span { start: plus_span.start, end };
831                    is_block_element = block.is_some();
832                    statements.push(Statement::AtRule(AtRule {
833                        name: Ident { name: "include", raw: "+", span: plus_span },
834                        prelude: Some(AtRulePrelude::SassInclude(self.alloc(prelude))),
835                        block,
836                        span,
837                    }));
838                }
839                Token::GreaterThan(..) | Token::Plus(..) | Token::Tilde(..) | Token::BarBar(..) => {
840                    if self.syntax == Syntax::Less {
841                        statements.push(self.parse_less_qualified_rule()?);
842                    } else {
843                        statements.push(Statement::QualifiedRule(self.parse()?));
844                    }
845                    is_block_element = true;
846                }
847                Token::DollarLBraceVar(..) if self.syntax == Syntax::Less => {
848                    statements.push(self.parse().map(Statement::Declaration)?);
849                }
850                Token::Cdo(..) | Token::Cdc(..) => {
851                    self.cursor.bump()?;
852                    continue;
853                }
854                Token::At(..) if matches!(self.syntax, Syntax::Scss | Syntax::Sass) => {
855                    let unknown_sass_at_rule = self.parse::<UnknownSassAtRule>()?;
856                    is_block_element = unknown_sass_at_rule.block.is_some();
857                    statements.push(Statement::UnknownSassAtRule(self.alloc(unknown_sass_at_rule)));
858                }
859                Token::Percentage(..)
860                    if self.state.in_keyframes_at_rule
861                        || self.state.sass_ctx & super::state::SASS_CTX_ALLOW_KEYFRAME_BLOCK
862                            != 0
863                        || self.state.less_ctx & super::state::LESS_CTX_ALLOW_KEYFRAME_BLOCK
864                            != 0 =>
865                {
866                    statements.push(Statement::KeyframeBlock(self.parse()?));
867                    is_block_element = true;
868                }
869                Token::RBrace(..) | Token::Eof(..) | Token::Dedent(..) => break,
870                Token::Semicolon(..) | Token::Linebreak(..) => {
871                    self.cursor.bump()?;
872                    continue;
873                }
874                Token::LBrace(..) if self.syntax == Syntax::Css => {
875                    // An empty selector (`{}`): postcss parses it as a qualified rule
876                    // with no selector, so build one with an empty selector list.
877                    let start = span.start;
878                    let block = self.parse::<SimpleBlock>()?;
879                    let selector = SelectorList {
880                        selectors: self.vec(),
881                        comma_spans: self.vec(),
882                        span: Span { start, end: start },
883                    };
884                    let span = Span { start, end: block.span.end };
885                    statements.push(Statement::QualifiedRule(QualifiedRule {
886                        selector,
887                        block,
888                        span,
889                    }));
890                    is_block_element = true;
891                }
892                _ => {
893                    return Err(Error {
894                        kind: if self.state.in_keyframes_at_rule {
895                            ErrorKind::ExpectKeyframeBlock
896                        } else {
897                            ErrorKind::ExpectRule
898                        },
899                        span: *span,
900                    });
901                }
902            };
903            // Drain continuation indents that never became a block (e.g.
904            // `$a\n  : b` — the deeper line belonged to the statement's own
905            // clause, so its matching `Dedent` has no block to close). A
906            // drained `Dedent` is itself a line boundary, so the statement
907            // separator is already satisfied.
908            if self.drain_sass_pending_dedents()? {
909                continue;
910            }
911            match &self.cursor.peek()?.token {
912                Token::RBrace(..) | Token::Eof(..) | Token::Dedent(..) => break,
913                _ => {
914                    if self.syntax == Syntax::Sass {
915                        // The indented syntax also accepts `;` as a statement
916                        // terminator/separator (`a; b`), like a newline.
917                        if is_block_element {
918                            if self.cursor.eat_semicolon()?.is_none() {
919                                self.cursor.eat_linebreak()?;
920                            }
921                        } else if self.cursor.eat_semicolon()?.is_none() {
922                            self.cursor.expect_linebreak()?;
923                        }
924                    } else if is_block_element {
925                        self.cursor.eat_semicolon()?;
926                    } else {
927                        self.cursor.expect_semicolon()?;
928                    }
929                }
930            }
931        }
932        Ok(statements)
933    }
934
935    /// Whether a statement-position `${}` placeholder (ending at byte `from`)
936    /// should be offered to `QualifiedRule::parse`. The css-in-js rule the parser
937    /// can't see on its own, matching prettier:
938    /// - a bare `{` after the placeholder IS absorbed — the placeholder is the
939    ///   selector for that block (`${mixin}\n{ color: red }` is one rule; a bare
940    ///   `{...}` is meaningless without a selector, so this is the only valid read)
941    /// - a placeholder separated by whitespace from what follows, then a newline,
942    ///   then selector content = a separate rule (`${mixin}\n& > .x {}` and
943    ///   `${a} ${b}\nhtml {}` are two statements, not one — spaced placeholders
944    ///   are typically mixin invocations, not selector pieces)
945    /// - but a placeholder IMMEDIATELY glued to non-whitespace (e.g. `${p}:hover`
946    ///   or `${p},`) is a compound-selector piece, so a multi-line selector list
947    ///   (`${p}:hover &,\n${q}:focus &, { ... }`) is one rule — keep scanning for `{` across newlines.
948    ///
949    /// The real grammar (strings, comments, `#{...}` interpolations, validity) is
950    /// left to `QualifiedRule::parse`, which runs next and rolls back if this guess was wrong.
951    /// Deliberately NOT a tokenizer: it never early-exits on `;`/`}`
952    /// (those may sit inside an attribute string or comment),
953    /// so it can't misclassify a same-line selector containing them.
954    fn placeholder_starts_qualified_rule(&self, from: usize) -> bool {
955        let bytes = &self.source.as_bytes()[from..];
956        // Immediately-adjacent non-whitespace (`${p}:hover`, `${p},`) means the
957        // placeholder is a compound-selector piece: only `{` matters from here, regardless of newlines.
958        if bytes.first().is_some_and(|b| !b.is_ascii_whitespace()) {
959            return bytes.contains(&b'{');
960        }
961        // Otherwise the placeholder is separated by whitespace from what follows.
962        // A `{` on the same line (whitespace-only prefix) still makes the
963        // placeholder its selector; any non-whitespace after a newline starts a separate rule.
964        let mut newline_seen = false;
965        for &b in bytes {
966            match b {
967                b'{' => return true,
968                // `\r`, `\r\n`, and `\n` all count as a newline (the tokenizer
969                // treats a bare `\r` as a line break too).
970                b'\n' | b'\r' => newline_seen = true,
971                _ if b.is_ascii_whitespace() => {}
972                _ if newline_seen => return false,
973                _ => {}
974            }
975        }
976        // No block at all -> a declaration or a bare placeholder, not a rule.
977        false
978    }
979}