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