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 // Css: a postcss property name (README "Acceptance").
19 // Statement position only: a feature query keeps the `<ident-token>`
20 // grammar, and one the typed grammar rejects (`@supports (*zoom: 1)`)
21 // is kept anyway by the `<general-enclosed>` raw fallback.
22 let postcss_name = if input.syntax == Syntax::Css
23 && input.state.in_statement
24 && !input.at_plain_ident_property_name()?
25 {
26 input.try_parse(Parser::parse_postcss_property_name).ok()
27 } else {
28 None
29 };
30 // Scss / Less: the IE `*color` hack (dart-sass and less.js accept it),
31 // kept as a property-name prefix, but only when glued: `* color`
32 // (whitespace or a comment after the sigil) is not the hack, so leave
33 // the token for the normal (failing) parse.
34 let name_prefix_start = if input.syntax != Syntax::Css
35 && input.state.in_statement
36 && let TokenWithSpan { token: Token::Asterisk(..), span } = input.cursor.peek()?
37 && input
38 .source
39 .as_bytes()
40 .get(span.end)
41 .is_some_and(|b| !b.is_ascii_whitespace() && *b != b'/')
42 {
43 let start = span.start;
44 input.cursor.bump()?;
45 Some(start)
46 } else {
47 None
48 };
49 // A css-in-js `${}` placeholder may stand in for the property name
50 // (`${foo}: ${bar}`); it is not a real ident, so accept it directly.
51 let name = if let Some(name) = postcss_name {
52 name
53 } else if let Token::Placeholder(..) = input.cursor.peek()?.token {
54 let (placeholder, span) = input.cursor.expect_placeholder()?;
55 InterpolableIdent::Placeholder((placeholder, span).into())
56 } else if input.state.in_statement
57 && input.syntax == Syntax::Less
58 && let token = input.cursor.peek()?
59 && let Some(raw) = token.number_raw(input.source)
60 && raw.bytes().all(|b| b.is_ascii_digit())
61 {
62 let span = token.span;
63 input.cursor.bump()?;
64 InterpolableIdent::Literal(Ident { name: raw, raw, span })
65 } else {
66 input
67 .with_state(ParserState {
68 qualified_rule_ctx: Some(QualifiedRuleContext::DeclarationName),
69 ..input.state
70 })
71 .parse::<InterpolableIdent>()?
72 };
73
74 // https://tailwindcss.com/docs/theme#overriding-the-default-theme
75 let name_suffix = if let TokenWithSpan { token: Token::Asterisk(..), span } =
76 input.cursor.peek()?
77 && name.span().end == span.start
78 {
79 input.cursor.bump()?;
80 Some('*')
81 } else {
82 None
83 };
84
85 // Less property merge (`prop+: v`, `prop+_: v`). In Css the `+` is
86 // part of the postcss property name above.
87 let less_property_merge = if input.syntax == Syntax::Less { input.parse()? } else { None };
88
89 let (_, colon_span) = input.cursor.expect_colon()?;
90 let is_custom_property = name.is_custom_property();
91 let (mut value, mut important, value_is_raw) = {
92 let mut parser = input.with_state(ParserState {
93 qualified_rule_ctx: Some(QualifiedRuleContext::DeclarationValue),
94 ..input.state
95 });
96 // For IE-compatibility, regardless of the property name (`filter`,
97 // `-ms-filter`, vendor variants...): `filter: progid:...`.
98 // Not peeked for a custom property: its text rescan below must
99 // start from the colon with nothing cached.
100 let source = parser.source;
101 let starts_with_progid = !is_custom_property
102 && matches!(&name, InterpolableIdent::Literal(..))
103 && parser.cursor.peek()?.is_ident_name_eq_ignore_ascii_case(source, "progid");
104 if is_custom_property && matches!(parser.syntax, Syntax::Scss | Syntax::Sass) {
105 // dart-sass reads a custom property as text,
106 // so `//` outside `#{...}` is part of the value
107 // even when the typed grammar can otherwise reach the terminator.
108 parser.parse_sass_custom_property_value()?
109 } else if is_custom_property || starts_with_progid {
110 // The value is everything up to the top-level `;` (§5.5.6).
111 // The typed parse may stop earlier (Scss ends a value at a nesting block),
112 // which would turn `--x: { a: b } --y: c;` into two declarations.
113 // So take the typed value only when it reaches the terminator.
114 if let Ok((values, important)) =
115 parser.try_parse(Parser::parse_whole_declaration_value)
116 {
117 (values, important, false)
118 } else {
119 (parser.parse_declaration_value_tokens(false)?, None, true)
120 }
121 } else if parser.syntax == Syntax::Css
122 || (parser.state.in_css_function_body
123 && matches!(&name, InterpolableIdent::Literal(..)))
124 {
125 // Scss/Sass/Less keep the strict grammar:
126 // their dialects assign meaning to these tokens
127 // and are expected to reject exactly what their reference compilers reject.
128 parser.parse_css_any_value()?
129 } else {
130 (parser.parse_declaration_value()?, None, false)
131 }
132 };
133
134 // CSS Syntax removes a trailing top-level `!important` from every declaration value,
135 // including one preserved as raw tokens because the typed grammar could not read it.
136 // Do this before looking at the cursor:
137 // the raw scan has already advanced to the declaration terminator.
138 if important.is_none() && value_is_raw {
139 important = take_raw_important(input, &mut value);
140 }
141 if important.is_none()
142 && let Token::Exclamation(..) = &input.cursor.peek()?.token
143 {
144 important = Some(input.parse::<ImportantAnnotation>()?);
145 }
146 // dart-sass allows `!important` mid-value (`fludge: foo bar
147 // !important hux;`): when more value follows, the annotation is just
148 // another component, and only a trailing one is structural.
149 while matches!(input.syntax, Syntax::Scss | Syntax::Sass)
150 && important.is_some()
151 && !at_declaration_value_end(&input.cursor.peek()?.token)
152 {
153 if let Some(annotation) = important.take() {
154 value.push(ComponentValue::ImportantAnnotation(annotation));
155 }
156 let more = input
157 .with_state(ParserState {
158 qualified_rule_ctx: Some(QualifiedRuleContext::DeclarationValue),
159 ..input.state
160 })
161 .parse_declaration_value()?;
162 for component in more {
163 value.push(component);
164 }
165 if let Token::Exclamation(..) = &input.cursor.peek()?.token {
166 important = Some(input.parse::<ImportantAnnotation>()?);
167 }
168 }
169
170 let span = Span {
171 start: name_prefix_start.unwrap_or(name.span().start),
172 end: if let Some(important) = &important {
173 important.span.end
174 } else if let Some(last) = value.last() {
175 last.span().end
176 } else {
177 colon_span.end
178 },
179 };
180 Ok(Declaration {
181 name,
182 name_prefix: name_prefix_start.map(|_| '*'),
183 name_suffix,
184 colon_span,
185 value,
186 value_is_raw,
187 important,
188 less_property_merge,
189 span,
190 })
191 }
192}
193
194/// Remove a trailing top-level `!important` from a preserved raw value.
195///
196/// Raw values flatten paired blocks into delimiter tokens, so first replay the
197/// pair stack up to the `!`: at EOF an unclosed `(... !important` must keep the
198/// annotation inside the value rather than promote it to declaration priority.
199fn take_raw_important<'a>(
200 input: &Parser<'a>,
201 value: &mut oxc_allocator::Vec<'a, ComponentValue<'a>>,
202) -> Option<ImportantAnnotation<'a>> {
203 let len = value.len();
204 if len < 2 {
205 return None;
206 }
207 let (bang_start, important) = match (&value[len - 2], &value[len - 1]) {
208 (
209 ComponentValue::TokenWithSpan(TokenWithSpan { token: Token::Exclamation(..), span }),
210 ComponentValue::TokenWithSpan(important),
211 ) if important.is_ident_name_eq_ignore_ascii_case(input.source, "important") => {
212 (span.start, *important)
213 }
214 _ => return None,
215 };
216
217 let mut pairs = Vec::with_capacity(1);
218 for component in &value[..len - 2] {
219 if let ComponentValue::TokenWithSpan(token) = component
220 && !crate::util::track_paired_token(&token.token, &mut pairs)
221 {
222 return None;
223 }
224 }
225 if !pairs.is_empty() {
226 return None;
227 }
228
229 let ident = input.ident(important.ident(input.source)?, important.span);
230 value.truncate(len - 2);
231 Some(ImportantAnnotation { span: Span { start: bang_start, end: important.span.end }, ident })
232}
233
234impl<'a> Parser<'a> {
235 /// `)` ends a feature-query declaration (`@supports (a: b)`), never a
236 /// statement one: there it is a stray closer the caller must deal with.
237 fn at_statement_value_end(in_statement: bool, token: &Token) -> bool {
238 at_declaration_value_end(token) && !(in_statement && matches!(token, Token::RParen(..)))
239 }
240
241 /// A Scss / Sass custom property value as dart-sass reads it: text where
242 /// `//` starts no comment outside `#{...}` (whose contents are SassScript).
243 /// The typed grammar wins for formatter layout only when it reaches the
244 /// terminator without meeting a line comment; otherwise the value is the
245 /// raw text run.
246 fn parse_sass_custom_property_value(
247 &mut self,
248 ) -> PResult<(oxc_allocator::Vec<'a, ComponentValue<'a>>, Option<ImportantAnnotation<'a>>, bool)>
249 {
250 debug_assert!(self.cursor.cached_token.is_none());
251 let line_comments_seen = self.cursor.tokenizer.state.line_comments_seen;
252 let typed = self.try_parse(|parser| {
253 let value = parser.parse_whole_declaration_value()?;
254 // A `//` the typed grammar read as a comment is value text (or, inside
255 // `#{...}`, a comment the typed stream cannot place): rescan as text.
256 if parser.cursor.tokenizer.state.line_comments_seen != line_comments_seen {
257 let span = parser.cursor.peek()?.span;
258 return Err(Error { kind: ErrorKind::TryParseError, span });
259 }
260 Ok(value)
261 });
262 if let Ok((values, important)) = typed {
263 return Ok((values, important, false));
264 }
265
266 // The failed `try_parse` restored the cursor to the colon; rescan the
267 // text with line comments disabled outside interpolation.
268 let line_comments = self.cursor.tokenizer.state.line_comments;
269 self.cursor.tokenizer.state.line_comments = false;
270 let values = self.parse_declaration_value_tokens(false);
271 self.cursor.tokenizer.state.line_comments = line_comments;
272 Ok((values?, None, true))
273 }
274
275 /// The Css `<any-value>` declaration value (CSS Syntax §5): serialized
276 /// selectors (`b: .c > d`), map-like blocks (`b: (3: 4)`) or stray delimiters
277 /// are all valid preserved tokens even though the typed grammar has no node
278 /// for them. The typed grammar wins when it accounts for everything up to
279 /// the terminator; otherwise the whole value is the raw token run.
280 pub(super) fn parse_css_any_value(
281 &mut self,
282 ) -> PResult<(oxc_allocator::Vec<'a, ComponentValue<'a>>, Option<ImportantAnnotation<'a>>, bool)>
283 {
284 if let Ok((values, important)) = self.try_parse(Parser::parse_whole_declaration_value) {
285 return Ok((values, important, false));
286 }
287 // A CSS custom function body holds declarations only, so a top-level
288 // `{}` there is part of the value; elsewhere it means this construct is
289 // really a qualified rule (CSS Nesting disambiguation) and the
290 // declaration is rejected.
291 let in_fn_body = self.state.in_css_function_body;
292 let values = self.parse_declaration_value_tokens(!in_fn_body)?;
293 let next = self.cursor.peek()?;
294 if !in_fn_body && let Token::LBrace(..) = next.token {
295 return Err(Error { kind: ErrorKind::BlockInDeclarationValue, span: next.span });
296 }
297 Ok((values, None, true))
298 }
299
300 /// The common `color: red`: an `<ident-token>` followed by whitespace or a
301 /// terminator can only be the single-ident run `parse_postcss_property_name`
302 /// rejects, so skip its snapshot and rescan.
303 fn at_plain_ident_property_name(&mut self) -> PResult<bool> {
304 let TokenWithSpan { token, span } = self.cursor.peek()?;
305 Ok(matches!(token, Token::Ident(..))
306 && self
307 .source
308 .as_bytes()
309 .get(span.end)
310 .is_none_or(|b| b.is_ascii_whitespace() || matches!(b, b':' | b';' | b'{' | b'}')))
311 }
312
313 /// postcss's property name (Css only): the glued token run up to the first
314 /// top-level `:`, whitespace or comment. A leading `:` is part of the run
315 /// (`:x: y`, an IE hack). Errors when the run is a single `<ident-token>`
316 /// (the typed grammar owns it), empty, or unbalanced, so `try_parse`
317 /// restores the cursor.
318 fn parse_postcss_property_name(&mut self) -> PResult<InterpolableIdent<'a>> {
319 let TokenWithSpan { token, span } = self.cursor.peek()?;
320 let start = span.start;
321 let ident_only_end = matches!(token, Token::Ident(..)).then_some(span.end);
322 let mut end = start;
323 let mut pairs: Vec<crate::util::PairedToken> = Vec::new();
324 loop {
325 let TokenWithSpan { token, span } = self.cursor.peek()?;
326 if end != start && span.start != end {
327 break;
328 }
329 match token {
330 Token::Colon(..) if pairs.is_empty() && end != start => break,
331 Token::Semicolon(..) | Token::LBrace(..) | Token::RBrace(..)
332 if pairs.is_empty() =>
333 {
334 break;
335 }
336 // Never name bytes; `#{}` pieces never appear in a Css property name.
337 Token::Eof(..)
338 | Token::Dedent(..)
339 | Token::Linebreak(..)
340 | Token::StrTemplate(..)
341 | Token::Placeholder(..) => break,
342 token => {
343 if !crate::util::track_paired_token(token, &mut pairs) {
344 break;
345 }
346 }
347 }
348 end = span.end;
349 self.cursor.bump()?;
350 }
351 if end == start || Some(end) == ident_only_end || !pairs.is_empty() {
352 return Err(Error { kind: ErrorKind::ExpectRule, span: Span { start, end } });
353 }
354 let raw = &self.source[start..end];
355 Ok(InterpolableIdent::Literal(Ident { name: raw, raw, span: Span { start, end } }))
356 }
357
358 /// The typed `<declaration-value>` and its `!important`.
359 /// Fails unless they reach the declaration terminator,
360 /// so the caller can fall back to raw tokens for whatever the typed grammar missed.
361 pub(super) fn parse_whole_declaration_value(
362 &mut self,
363 ) -> PResult<(oxc_allocator::Vec<'a, ComponentValue<'a>>, Option<ImportantAnnotation<'a>>)>
364 {
365 let values = self.parse_declaration_value()?;
366 let important = match &self.cursor.peek()?.token {
367 Token::Exclamation(..) => Some(self.parse::<ImportantAnnotation>()?),
368 _ => None,
369 };
370 let in_statement = self.state.in_statement;
371 let next = self.cursor.peek()?;
372 if Self::at_statement_value_end(in_statement, &next.token) {
373 Ok((values, important))
374 } else {
375 Err(Error { kind: ErrorKind::ExpectComponentValue, span: next.span })
376 }
377 }
378}
379
380/// End of a declaration's value: the declaration terminator tokens.
381fn at_declaration_value_end(token: &Token) -> bool {
382 matches!(
383 token,
384 Token::Semicolon(..)
385 | Token::RBrace(..)
386 | Token::RParen(..)
387 | Token::Dedent(..)
388 | Token::Linebreak(..)
389 | Token::Eof(..)
390 )
391}
392
393// <important> = '!' important
394impl<'a> Parse<'a> for ImportantAnnotation<'a> {
395 fn parse(input: &mut Parser<'a>) -> PResult<Self> {
396 let (_, span) = input.cursor.expect_exclamation()?;
397 input.eat_sass_line_continuation()?;
398 let ident: Ident = input.parse::<Ident>()?;
399 let span = Span { start: span.start, end: ident.span.end };
400 if ident.name.eq_ignore_ascii_case("important") {
401 Ok(ImportantAnnotation { ident, span })
402 } else {
403 Err(Error { kind: ErrorKind::ExpectImportantAnnotation, span })
404 }
405 }
406}
407
408// https://drafts.csswg.org/css-syntax-3/#consume-qualified-rule
409//
410// <qualified-rule> = <prelude> <{}-block>
411// In a style context the prelude is a selector list:
412// <style-rule> = <selector-list> { <style-block> }
413impl<'a> Parse<'a> for QualifiedRule<'a> {
414 fn parse(input: &mut Parser<'a>) -> PResult<Self> {
415 let selector_list = input
416 .with_state(ParserState {
417 qualified_rule_ctx: Some(QualifiedRuleContext::Selector),
418 ..input.state
419 })
420 .parse::<SelectorList>()?;
421 let block = input.parse::<SimpleBlock>()?;
422 let span = Span { start: selector_list.span.start, end: block.span.end };
423 Ok(QualifiedRule { selector: selector_list, block, span })
424 }
425}
426
427// https://drafts.csswg.org/css-syntax-3/#consume-block-contents
428//
429// <unknown-qualified-rule> = <ident-token> ':' <any-value> <{}-block>
430//
431// Two shapes end up here in CSS (section numbers: 2026-07 ED):
432// - a declaration §5.5.6 rejects because a `{}` block is mixed with other values
433// (`BlockInDeclarationValue`); §5.5.5 then re-consumes it as a qualified rule
434// - a statement led by a token that can start neither a declaration nor an at-rule
435// (`50% { }`); §5.5.5 consumes it as a qualified rule directly
436// Either way the prelude is no selector, so it is kept as raw tokens.
437// postcss keeps such rules too (postcss-nested-style dialects use the shape for nested config blocks),
438// and Prettier prints the prelude verbatim.
439impl<'a> Parse<'a> for UnknownQualifiedRule<'a> {
440 fn parse(input: &mut Parser<'a>) -> PResult<Self> {
441 debug_assert!(input.syntax == Syntax::Css);
442 let prelude = input.parse_raw_prelude_tokens()?;
443 // The scan also stops at `;` / statement boundaries;
444 // `SimpleBlock`'s `expect_l_brace` rejects those, so only a block opener makes this shape.
445 let block = input.parse::<SimpleBlock>()?;
446 let span = Span { start: prelude.span.start, end: block.span.end };
447 Ok(UnknownQualifiedRule { prelude, block, span })
448 }
449}
450
451// https://drafts.csswg.org/css-syntax-3/#consume-simple-block
452//
453// <simple-block> = '{' <block-contents> '}'
454// (Sass indented syntax substitutes Indent/Dedent for the braces.)
455impl<'a> Parse<'a> for SimpleBlock<'a> {
456 fn parse(input: &mut Parser<'a>) -> PResult<Self> {
457 let is_sass = input.syntax == Syntax::Sass;
458 let start = if is_sass {
459 // A continuation line deeper than this block's own level leaves a
460 // pending indent whose `Dedent` arrives before the block opens
461 // (`a,\n b\n c: d`); cancel those out first.
462 let drained = input.drain_sass_pending_dedents()?;
463 if let Some((_, span)) = input.cursor.eat_indent()? {
464 span.end
465 } else if drained
466 && input.sass_pending_indents == 0
467 && input.cursor.tokenizer.reopen_indent_level()
468 {
469 // The block's level sat between two known indents, so its
470 // `Indent` was never emitted; re-open it directly.
471 input.cursor.peek()?.span.start
472 } else if input.sass_pending_indents > 0 {
473 // The statement's clause consumed this block's `Indent` as a
474 // line continuation (`@each $a in\n b, c\n .x\n ...`);
475 // enter the block "virtually" at that depth.
476 input.sass_pending_indents -= 1;
477 input.cursor.peek()?.span.start
478 } else {
479 let offset = input.cursor.peek()?.span.start;
480 return Ok(SimpleBlock {
481 statements: input.vec(),
482 span: Span { start: offset, end: offset },
483 });
484 }
485 } else {
486 input.cursor.expect_l_brace()?.1.start
487 };
488
489 let statements = input.parse_statements(/* is_top_level */ false)?;
490
491 // CSS Syntax: EOF closes all open constructs (a parse error, but the
492 // tree is valid — browsers accept unclosed blocks at EOF). The
493 // dialects' reference compilers reject them. Recovery is unchanged; the
494 // parse error is surfaced via `recoverable_errors` so downstream
495 // consumers can tell it apart from a properly closed block.
496 if input.syntax == Syntax::Css && matches!(input.cursor.peek()?.token, Token::Eof(..)) {
497 let end = input.cursor.peek()?.span.start;
498 input
499 .recoverable_errors
500 .push(Error { kind: ErrorKind::EofInBlock, span: Span { start, end: start + 1 } });
501 return Ok(SimpleBlock { statements, span: Span { start, end } });
502 }
503
504 if is_sass {
505 match input.cursor.bump()? {
506 TokenWithSpan { token: Token::Dedent(..) | Token::Eof(..), span } => {
507 let end = statements.last().map_or(span.start, |last| last.span().end);
508 Ok(SimpleBlock { statements, span: Span { start, end } })
509 }
510 TokenWithSpan { span, .. } => {
511 Err(Error { kind: ErrorKind::ExpectDedentOrEof, span })
512 }
513 }
514 } else {
515 let end = input.cursor.expect_r_brace()?.1.end;
516 Ok(SimpleBlock { statements, span: Span { start, end } })
517 }
518 }
519}
520
521// https://drafts.csswg.org/css-syntax-3/#parse-a-stylesheet
522//
523// <stylesheet> = <rule-list>
524impl<'a> Parse<'a> for Stylesheet<'a> {
525 fn parse(input: &mut Parser<'a>) -> PResult<Self> {
526 let statements = input.parse_statements(/* is_top_level */ true)?;
527 input.cursor.expect_eof()?;
528 Ok(Stylesheet { statements, span: Span { start: 0, end: input.source.len() } })
529 }
530}
531
532impl<'a> Parser<'a> {
533 /// `<declaration-value>` consumed as raw tokens (CSS Syntax "preserved
534 /// tokens"), balancing `()`/`[]`/`{}` pairs, until a top-level `;`, an
535 /// unbalanced closer, or a statement boundary. Used for custom-property
536 /// values and as the fallback for CSS values the typed grammar rejects.
537 ///
538 /// <https://drafts.csswg.org/css-syntax-3/#typedef-declaration-value>
539 ///
540 /// `stop_at_top_level_brace` implements the CSS Nesting disambiguation: a
541 /// `{` at the top level of a normal declaration's value means the whole
542 /// construct is really a qualified rule, so the value must end there.
543 /// Custom properties are exempt (`--foo: {a:b}` is a valid value).
544 pub(super) fn parse_declaration_value_tokens(
545 &mut self,
546 stop_at_top_level_brace: bool,
547 ) -> PResult<oxc_allocator::Vec<'a, ComponentValue<'a>>> {
548 let mut values = self.vec_with_capacity(3);
549 let mut pairs = Vec::with_capacity(1);
550 // Span of the outermost currently-open pair (kept in sync with `pairs`
551 // going empty↔non-empty), so the EOF parse error can point at the
552 // opener like `SimpleBlock` does, not at the end of file.
553 let mut outermost_pair_span: Option<Span> = None;
554 loop {
555 match &self.cursor.peek()?.token {
556 Token::Dedent(..) | Token::Linebreak(..) => break,
557 // CSS Syntax: EOF closes any still-open `(`/`[`/`{` group; recovery
558 // is unchanged, but record the parse error so downstream consumers
559 // can tell it apart from a balanced value. Report the outermost
560 // unclosed opener; a raw-value `{` (e.g. `--x: {` — legal in custom
561 // properties) is a block, not a paren.
562 Token::Eof(..) => {
563 if let (Some(pair), Some(span)) = (pairs.first(), outermost_pair_span) {
564 let kind = match pair {
565 crate::util::PairedToken::Brace => ErrorKind::EofInBlock,
566 _ => ErrorKind::UnclosedParen,
567 };
568 self.recoverable_errors.push(Error { kind, span });
569 }
570 break;
571 }
572 Token::Semicolon(..) if pairs.is_empty() => {
573 break;
574 }
575 Token::LBrace(..) if stop_at_top_level_brace && pairs.is_empty() => {
576 break;
577 }
578 // An unterminated string survives as a preserved token (a parse
579 // error kept verbatim; CSS Syntax §4.3.5). The tokenizer emits a
580 // `BadStr` for both recoverable forms; recover the spec's split
581 // from where the string stopped — at EOF it is a `<string-token>`
582 // (canonically closable by appending the quote), at a newline a
583 // `<bad-string-token>`.
584 Token::BadStr(..) => {
585 let span = self.cursor.peek()?.span;
586 let kind = if span.end == self.source.len() {
587 ErrorKind::UnterminatedString
588 } else {
589 ErrorKind::BadString
590 };
591 self.recoverable_errors.push(Error { kind, span });
592 }
593 // An interpolated string (e.g. `'#{$expr}'` inside
594 // `filter: progid:...`) must be parsed structurally:
595 // the tokenizer needs `scan_string_template` to resume
596 // the string after each `#{...}`, so consuming its
597 // tokens as a plain stream would mis-lex the rest.
598 Token::StrTemplate(..) => {
599 values.push(ComponentValue::InterpolableStr(self.parse()?));
600 continue;
601 }
602 token => {
603 let was_empty = pairs.is_empty();
604 if !crate::util::track_paired_token(token, &mut pairs) {
605 break;
606 }
607 if was_empty && !pairs.is_empty() {
608 outermost_pair_span = Some(self.cursor.peek()?.span);
609 }
610 }
611 }
612 values.push(ComponentValue::TokenWithSpan(self.cursor.bump()?));
613 }
614 Ok(values)
615 }
616
617 // The typed form of `<declaration-value>`: a list of `<component-value>` up to
618 // the declaration terminator (`;`, `!`, `}`, or a statement boundary).
619 pub(super) fn parse_declaration_value(
620 &mut self,
621 ) -> PResult<oxc_allocator::Vec<'a, ComponentValue<'a>>> {
622 let mut values = self.vec_with_capacity(3);
623 loop {
624 match &self.cursor.peek()?.token {
625 Token::RBrace(..)
626 | Token::RParen(..)
627 | Token::Semicolon(..)
628 | Token::Dedent(..)
629 | Token::Linebreak(..)
630 | Token::Exclamation(..)
631 | Token::Eof(..) => break,
632 _ => {
633 let value = self.parse::<ComponentValue>()?;
634 match &value {
635 ComponentValue::SassNestingDeclaration(..)
636 if matches!(self.syntax, Syntax::Scss | Syntax::Sass) =>
637 {
638 values.push(value);
639 break;
640 }
641 _ => values.push(value),
642 }
643 }
644 }
645 }
646 Ok(values)
647 }
648
649 /// In a `@keyframes` body, an ident may start a keyframe block (`from {`)
650 /// or — in real-world code — a plain declaration (`blah: blee;`); dart-sass
651 /// accepts both. Returns the statement and whether it opened a block.
652 fn parse_keyframe_block_or_declaration(&mut self) -> PResult<(Statement<'a>, bool)> {
653 if let Ok(block) = self.try_parse(KeyframeBlock::parse) {
654 Ok((Statement::KeyframeBlock(block), true))
655 } else {
656 match self.parse_statement_declaration() {
657 Ok(decl) => Ok((Statement::Declaration(decl), false)),
658 Err(error_decl) => {
659 // postcss accepts the declaration-shaped rule inside `@keyframes` too
660 if let Some(rule) = self.try_declaration_shaped_rule(&error_decl) {
661 return Ok((Statement::UnknownQualifiedRule(rule), true));
662 }
663 Err(error_decl)
664 }
665 }
666 }
667 }
668
669 /// A declaration in statement position.
670 /// CSS snapshots the statement start so `try_declaration_shaped_rule` can re-consume it;
671 /// dialects have no fallback, and an unrecovered Err aborts the parse, so no snapshot.
672 fn parse_statement_declaration(&mut self) -> PResult<Declaration<'a>> {
673 if self.syntax == Syntax::Css {
674 self.try_parse(Parser::parse_style_rule_declaration)
675 } else {
676 self.parse_style_rule_declaration()
677 }
678 }
679
680 /// The §5.5.5 re-consume (see `UnknownQualifiedRule`), CSS only: dialects
681 /// keep their reference compilers' strictness (Scss types the shape as
682 /// nested properties; less.js rejects it).
683 fn try_declaration_shaped_rule(
684 &mut self,
685 error_decl: &Error,
686 ) -> Option<UnknownQualifiedRule<'a>> {
687 if !matches!(error_decl.kind, ErrorKind::BlockInDeclarationValue)
688 || self.syntax != Syntax::Css
689 {
690 return None;
691 }
692 self.try_parse(UnknownQualifiedRule::parse).ok()
693 }
694
695 /// The CSS Nesting `<style-block>` ambiguity: parse a qualified rule, falling
696 /// back to a declaration when the `foo: bar` vs `foo { }` prelude is
697 /// ambiguous. Returns the statement and whether it opened a block (for the
698 /// caller's `is_block_element`).
699 /// `prefer_rule_error` picks which attempt's error surfaces when both fail
700 /// (inside a block the declaration's: `color red;` reports the missing `:`).
701 ///
702 /// <https://drafts.csswg.org/css-nesting-1/#syntax>
703 fn parse_rule_or_declaration(
704 &mut self,
705 is_top_level: bool,
706 prefer_rule_error: bool,
707 ) -> PResult<(Statement<'a>, bool)> {
708 match self.try_parse(QualifiedRule::parse) {
709 Ok(rule) => Ok((Statement::QualifiedRule(rule), true)),
710 Err(error_rule) => match self.parse_statement_declaration() {
711 Ok(decl) => {
712 // Only Scss/Sass produce `SassNestingDeclaration`; in CSS this is
713 // always `false`, matching the previous per-syntax behavior.
714 let is_block_element = matches!(
715 decl.value.last(),
716 Some(ComponentValue::SassNestingDeclaration(..))
717 );
718 // A root declaration is a statement only in the css-in-js
719 // parse mode (README "Acceptance").
720 if is_top_level && self.options.template_placeholder.is_none() {
721 self.recoverable_errors
722 .push(Error { kind: ErrorKind::TopLevelDeclaration, span: decl.span });
723 }
724 Ok((Statement::Declaration(decl), is_block_element))
725 }
726 Err(error_decl) => {
727 if let Some(rule) = self.try_declaration_shaped_rule(&error_decl) {
728 return Ok((Statement::UnknownQualifiedRule(rule), true));
729 }
730 Err(if prefer_rule_error { error_rule } else { error_decl })
731 }
732 },
733 }
734 }
735
736 /// A Css statement led by anything but an ident or an at-keyword:
737 /// a qualified rule, a declaration with a postcss property name (`+color: red`),
738 /// or, numeric-led, a §5.5.5 qualified rule with no selector prelude
739 /// (`50% { }` inside a raw-prelude rule, oxc-project/oxc#26291). `"foo" {}` stays rejected.
740 fn parse_css_statement(&mut self, is_top_level: bool) -> PResult<(Statement<'a>, bool)> {
741 let TokenWithSpan { token, span } = self.cursor.peek()?;
742 let span = *span;
743 let numeric =
744 matches!(token, Token::Percentage(..) | Token::Number(..) | Token::Dimension(..));
745 // A non-ident lead is first of all a selector, so its rule error is
746 // the useful one (`[attr {` reports the attribute matcher, not a colon).
747 match self.parse_rule_or_declaration(is_top_level, true) {
748 Err(_) if numeric => {
749 let rule = self
750 .parse::<UnknownQualifiedRule>()
751 .map_err(|_| Error { kind: ErrorKind::ExpectRule, span })?;
752 Ok((Statement::UnknownQualifiedRule(rule), true))
753 }
754 result => result,
755 }
756 }
757
758 /// Parse a declaration in statement position (`ParserState::in_statement`);
759 /// feature-query declarations call `Declaration::parse` directly.
760 fn parse_style_rule_declaration(&mut self) -> PResult<Declaration<'a>> {
761 self.with_state(ParserState { in_statement: true, ..self.state.clone() }).parse()
762 }
763
764 // Block contents: a mix of declarations, nested style rules and at-rules
765 // (CSS Syntax `<block-contents>`; `is_top_level` selects the `<stylesheet>`
766 // rule-list, where a declaration is a `TopLevelDeclaration` error except
767 // in the css-in-js parse mode and in Less).
768 // https://drafts.csswg.org/css-syntax-3/#consume-block-contents
769 fn parse_statements(
770 &mut self,
771 is_top_level: bool,
772 ) -> PResult<oxc_allocator::Vec<'a, Statement<'a>>> {
773 let mut statements = self.vec_with_capacity(1);
774 loop {
775 // Set true for braced blocks AND `${}` placeholder statements: both
776 // make the trailing terminator optional. A placeholder substitutes a
777 // whole statement/declaration and, like postcss, needs no `;`, so the
778 // next statement may follow directly (`${mixin}\n@media {...}`,
779 // `${a} ${b}`, `${foo}: ${bar}`).
780 let mut is_block_element = false;
781 let TokenWithSpan { token, span } = self.cursor.peek()?;
782 match token {
783 Token::Ident(..) | Token::HashLBrace(..) | Token::AtLBraceVar(..) => {
784 match self.syntax {
785 Syntax::Css => {
786 if self.state.in_keyframes_at_rule {
787 let (stmt, is_block) =
788 self.parse_keyframe_block_or_declaration()?;
789 is_block_element = is_block;
790 statements.push(stmt);
791 } else {
792 let (stmt, is_block) =
793 self.parse_rule_or_declaration(is_top_level, is_top_level)?;
794 is_block_element = is_block;
795 statements.push(stmt);
796 }
797 }
798 Syntax::Scss | Syntax::Sass => {
799 if let Ok(sass_var_decl) =
800 self.try_parse(SassVariableDeclaration::parse)
801 {
802 statements.push(Statement::SassVariableDeclaration(
803 self.alloc(sass_var_decl),
804 ));
805 } else if self.state.in_keyframes_at_rule {
806 let (stmt, is_block) =
807 self.parse_keyframe_block_or_declaration()?;
808 is_block_element = is_block;
809 statements.push(stmt);
810 } else {
811 let (stmt, is_block) =
812 self.parse_rule_or_declaration(is_top_level, is_top_level)?;
813 is_block_element = is_block;
814 statements.push(stmt);
815 }
816 }
817 Syntax::Less => {
818 if let Ok(stmt) = self.try_parse(Parser::parse_less_qualified_rule) {
819 statements.push(stmt);
820 is_block_element = true;
821 } else if let Ok(decl) =
822 // less.js parses root-level declarations and
823 // only rejects them at eval time.
824 self.try_parse(Declaration::parse)
825 {
826 statements.push(Statement::Declaration(decl));
827 } else if self.state.in_keyframes_at_rule {
828 statements.push(Statement::KeyframeBlock(self.parse()?));
829 is_block_element = true;
830 } else {
831 let fn_call = self.parse::<Function>()?;
832 is_block_element = matches!(
833 fn_call.args.last(),
834 Some(ComponentValue::LessDetachedRuleset(..))
835 );
836 statements.push(Statement::LessFunctionCall(fn_call));
837 }
838 }
839 }
840 }
841 // `5:-` — less.js's ruleProperty regex (`[_a-zA-Z0-9-]+`)
842 // allows digit-only declaration names
843 Token::Number(..)
844 if self.syntax == Syntax::Less
845 && !is_top_level
846 && self.source.as_bytes().get(span.end) == Some(&b':') =>
847 {
848 let decl = self.parse_style_rule_declaration()?;
849 statements.push(Statement::Declaration(decl));
850 }
851 // `.3D(...)` — less.js allows digit-led mixin names, which
852 // arrive as one <dimension-token>; they behave exactly like
853 // `.foo` (`.3D ()`, `.3D;`), so only the leading `.` matters
854 Token::Dot(..) | Token::Hash(..) | Token::Dimension(..)
855 if self.syntax == Syntax::Less
856 && (!matches!(token, Token::Dimension(..))
857 || self.source.as_bytes().get(span.start) == Some(&b'.')) =>
858 {
859 let stmt = if let Ok(stmt) = self.try_parse(Parser::parse_less_qualified_rule) {
860 is_block_element = true;
861 stmt
862 } else if let Ok(mixin_def) = self.try_parse(LessMixinDefinition::parse) {
863 is_block_element = true;
864 Statement::LessMixinDefinition(self.alloc(mixin_def))
865 } else {
866 self.parse().map(Statement::LessMixinCall)?
867 };
868 statements.push(stmt);
869 }
870 // Css takes every remaining lead token through `parse_css_statement` below.
871 Token::Dot(..) | Token::Hash(..)
872 if self.syntax != Syntax::Css && !self.state.in_keyframes_at_rule =>
873 {
874 statements.push(Statement::QualifiedRule(self.parse()?));
875 is_block_element = true;
876 }
877 Token::Ampersand(..)
878 | Token::LBracket(..)
879 | Token::Colon(..)
880 | Token::ColonColon(..)
881 | Token::Asterisk(..)
882 | Token::Bar(..)
883 | Token::NumberSign(..)
884 if self.syntax != Syntax::Css && !self.state.in_keyframes_at_rule =>
885 {
886 if matches!(self.cursor.peek()?.token, Token::Asterisk(..)) {
887 // `*color: red` / `*zoom: 1` (an IE<=7 hack) looks like a `*`
888 // universal selector but is a declaration; try the rule, then
889 // fall back to a declaration. (A `*` never starts a
890 // `LessExtendRule`, so this can precede the Less split.)
891 if self.syntax == Syntax::Less {
892 match self.try_parse(Parser::parse_less_qualified_rule) {
893 Ok(stmt) => {
894 statements.push(stmt);
895 is_block_element = true;
896 }
897 // less.js parses a root declaration (ident-led path above)
898 // but not a root `*` hack; keep root-level `*zoom: 1` an error.
899 Err(rule_err) if is_top_level => return Err(rule_err),
900 Err(_) => {
901 let decl = self.parse_style_rule_declaration()?;
902 statements.push(Statement::Declaration(decl));
903 }
904 }
905 } else {
906 let (stmt, is_block) =
907 self.parse_rule_or_declaration(is_top_level, is_top_level)?;
908 is_block_element = is_block;
909 statements.push(stmt);
910 }
911 } else if self.syntax == Syntax::Less {
912 if let Ok(extend_rule) = self.try_parse(LessExtendRule::parse) {
913 statements.push(Statement::LessExtendRule(extend_rule));
914 } else {
915 statements.push(self.parse_less_qualified_rule()?);
916 is_block_element = true;
917 }
918 } else {
919 statements.push(Statement::QualifiedRule(self.parse()?));
920 is_block_element = true;
921 }
922 }
923 Token::AtKeyword(..) => match self.syntax {
924 Syntax::Css => {
925 let at_rule = self.parse::<AtRule>()?;
926 is_block_element = at_rule.block.is_some();
927 statements.push(Statement::AtRule(at_rule));
928 }
929 Syntax::Scss | Syntax::Sass => {
930 let at_keyword_name =
931 self.cursor.peek()?.at_keyword(self.source).unwrap().ident.name();
932 match &*at_keyword_name {
933 "if" => {
934 let sass_if_at_rule = self.parse()?;
935 statements
936 .push(Statement::SassIfAtRule(self.alloc(sass_if_at_rule)));
937 is_block_element = true;
938 }
939 "else" => {
940 return Err(Error {
941 kind: ErrorKind::UnexpectedSassElseAtRule,
942 span: self.cursor.bump()?.span,
943 });
944 }
945 _ => {
946 let at_rule = self.parse::<AtRule>()?;
947 is_block_element = at_rule.block.is_some();
948 statements.push(Statement::AtRule(at_rule));
949 }
950 }
951 }
952 Syntax::Less => {
953 if let Ok(less_variable_declaration) =
954 self.try_parse(LessVariableDeclaration::parse)
955 {
956 is_block_element = matches!(
957 less_variable_declaration.value,
958 ComponentValue::LessDetachedRuleset(..)
959 );
960 statements.push(Statement::LessVariableDeclaration(
961 self.alloc(less_variable_declaration),
962 ));
963 } else if let Ok(variable_call) = self.try_parse(LessVariableCall::parse) {
964 statements.push(Statement::LessVariableCall(variable_call));
965 } else {
966 let at_rule = self.parse::<AtRule>()?;
967 is_block_element = at_rule.block.is_some();
968 statements.push(Statement::AtRule(at_rule));
969 }
970 }
971 },
972 Token::Placeholder(..) => {
973 // A placeholder may start a qualified rule (a substituted
974 // selector, e.g. CSS-in-JS `${Component} { ... }`) or stand
975 // alone as a statement (e.g. `` `PLACEHOLDER-0`; ``).
976 //
977 // A placeholder-led selector must not absorb across a newline:
978 // prettier keeps `${mixin}` on its own line and the following
979 // selector as a separate rule (`${mixin}\n& > .x {}` is two
980 // statements, not one). So only attempt the rule when the block
981 // `{` is reachable without an intervening newline-then-selector.
982 //
983 // A placeholder may also be a declaration property name
984 // (`${foo}: ${bar}`), so try a declaration before falling back
985 // to a bare placeholder statement. Same-line only: the `:` of
986 // a rule on the next line (`${mixin}\n:hover { ... }`) must
987 // not be absorbed as a declaration colon — like the qualified
988 // rule check above, a newline ends what the placeholder can own.
989 let ph_end = self.cursor.peek()?.span.end;
990 if self.placeholder_starts_qualified_rule(ph_end)
991 && let Ok(rule) = self.try_parse(QualifiedRule::parse)
992 {
993 statements.push(Statement::QualifiedRule(rule));
994 is_block_element = true;
995 } else if self.placeholder_starts_declaration(ph_end)
996 && let Ok(declaration) = self.try_parse(Declaration::parse)
997 {
998 // Reached only via the placeholder token above, so this
999 // is the `${foo}: ${bar}` form (placeholder property name).
1000 statements.push(Statement::Declaration(declaration));
1001 is_block_element = true;
1002 } else {
1003 let (placeholder, span) = self.cursor.expect_placeholder()?;
1004 statements.push(Statement::Placeholder((placeholder, span).into()));
1005 is_block_element = true;
1006 }
1007 }
1008 // Css too: postcss-extend-rule's `%thick-border {}`
1009 // (see the placeholder arm in `SimpleSelector`'s parser).
1010 Token::Percent(..)
1011 if matches!(self.syntax, Syntax::Scss | Syntax::Sass | Syntax::Css) =>
1012 {
1013 statements.push(Statement::QualifiedRule(self.parse()?));
1014 is_block_element = true;
1015 }
1016 Token::DollarVar(..) if matches!(self.syntax, Syntax::Scss | Syntax::Sass) => {
1017 let declaration = self.parse()?;
1018 statements.push(Statement::SassVariableDeclaration(self.alloc(declaration)));
1019 }
1020 Token::DollarVar(..) if self.syntax == Syntax::Css => {
1021 // Prefer the typed postcss-simple-vars node for an exact
1022 // `$name: value` declaration. If the name continues
1023 // (`$name+`, `$name.foo`) or a top-level block makes the
1024 // statement a raw-prelude rule, use the general Css
1025 // rule/declaration disambiguation instead.
1026 if let Ok(declaration) = self.try_parse(PostcssSimpleVarDeclaration::parse) {
1027 statements
1028 .push(Statement::PostcssSimpleVarDeclaration(self.alloc(declaration)));
1029 } else {
1030 let (stmt, is_block) = self.parse_css_statement(is_top_level)?;
1031 is_block_element = is_block;
1032 statements.push(stmt);
1033 }
1034 }
1035 // Indented-syntax shorthands: `=name` defines a mixin
1036 // (`@mixin name`) and `+name` includes one (`@include name`).
1037 // A spaced `+ b` stays a sibling-combinator selector: `+` is
1038 // an include only when glued to an identifier.
1039 Token::Equal(..) if self.syntax == Syntax::Sass => {
1040 let eq_span = self.cursor.bump()?.span;
1041 self.eat_sass_line_continuation()?;
1042 let prelude = self.parse::<SassMixin>()?;
1043 let block = self
1044 .with_state(ParserState {
1045 sass_ctx: self.state.sass_ctx
1046 | super::state::SASS_CTX_ALLOW_KEYFRAME_BLOCK,
1047 ..self.state.clone()
1048 })
1049 .parse::<SimpleBlock>()?;
1050 let span = Span { start: eq_span.start, end: block.span.end };
1051 statements.push(Statement::AtRule(AtRule {
1052 name: Ident { name: "mixin", raw: "=", span: eq_span },
1053 prelude: Some(AtRulePrelude::SassMixin(self.alloc(prelude))),
1054 block: Some(block),
1055 span,
1056 }));
1057 is_block_element = true;
1058 }
1059 Token::Plus(..)
1060 if self.syntax == Syntax::Sass
1061 && crate::tokenizer::ident_starts_at(self.source, span.end) =>
1062 {
1063 let plus_span = self.cursor.bump()?.span;
1064 let prelude = self.parse::<SassInclude>()?;
1065 let block = if matches!(
1066 self.cursor.peek()?.token,
1067 Token::LBrace(..) | Token::Indent(..)
1068 ) {
1069 Some(
1070 self.with_state(ParserState {
1071 sass_ctx: self.state.sass_ctx
1072 | super::state::SASS_CTX_ALLOW_KEYFRAME_BLOCK,
1073 ..self.state.clone()
1074 })
1075 .parse::<SimpleBlock>()?,
1076 )
1077 } else {
1078 None
1079 };
1080 let end = block.as_ref().map_or(prelude.span.end, |block| block.span.end);
1081 let span = Span { start: plus_span.start, end };
1082 is_block_element = block.is_some();
1083 statements.push(Statement::AtRule(AtRule {
1084 name: Ident { name: "include", raw: "+", span: plus_span },
1085 prelude: Some(AtRulePrelude::SassInclude(self.alloc(prelude))),
1086 block,
1087 span,
1088 }));
1089 }
1090 Token::GreaterThan(..) | Token::Plus(..) | Token::Tilde(..) | Token::BarBar(..)
1091 if self.syntax != Syntax::Css =>
1092 {
1093 if self.syntax == Syntax::Less {
1094 statements.push(self.parse_less_qualified_rule()?);
1095 } else {
1096 statements.push(Statement::QualifiedRule(self.parse()?));
1097 }
1098 is_block_element = true;
1099 }
1100 Token::DollarLBraceVar(..) if self.syntax == Syntax::Less => {
1101 statements.push(self.parse().map(Statement::Declaration)?);
1102 }
1103 Token::Cdo(..) | Token::Cdc(..) => {
1104 self.cursor.bump()?;
1105 continue;
1106 }
1107 Token::At(..) if matches!(self.syntax, Syntax::Scss | Syntax::Sass) => {
1108 let unknown_sass_at_rule = self.parse::<UnknownSassAtRule>()?;
1109 is_block_element = unknown_sass_at_rule.block.is_some();
1110 statements.push(Statement::UnknownSassAtRule(self.alloc(unknown_sass_at_rule)));
1111 }
1112 Token::Percentage(..)
1113 if self.state.in_keyframes_at_rule
1114 || self.state.sass_ctx & super::state::SASS_CTX_ALLOW_KEYFRAME_BLOCK
1115 != 0
1116 || self.state.less_ctx & super::state::LESS_CTX_ALLOW_KEYFRAME_BLOCK
1117 != 0 =>
1118 {
1119 statements.push(Statement::KeyframeBlock(self.parse()?));
1120 is_block_element = true;
1121 }
1122 Token::RBrace(..) | Token::Eof(..) | Token::Dedent(..) => break,
1123 Token::Semicolon(..) | Token::Linebreak(..) => {
1124 self.cursor.bump()?;
1125 continue;
1126 }
1127 Token::LBrace(..) if self.syntax == Syntax::Css => {
1128 // An empty selector (`{}`): postcss parses it as a qualified rule
1129 // with no selector, so build one with an empty selector list.
1130 let start = span.start;
1131 let block = self.parse::<SimpleBlock>()?;
1132 let selector = SelectorList {
1133 selectors: self.vec(),
1134 comma_spans: self.vec(),
1135 span: Span { start, end: start },
1136 };
1137 let span = Span { start, end: block.span.end };
1138 statements.push(Statement::QualifiedRule(QualifiedRule {
1139 selector,
1140 block,
1141 span,
1142 }));
1143 is_block_element = true;
1144 }
1145 // `@3: red` is an at-word to postcss, not a property name.
1146 Token::At(..) if self.syntax == Syntax::Css => {
1147 return Err(Error { kind: ErrorKind::ExpectRule, span: *span });
1148 }
1149 _ if self.syntax == Syntax::Css && !self.state.in_keyframes_at_rule => {
1150 let (stmt, is_block) = self.parse_css_statement(is_top_level)?;
1151 is_block_element = is_block;
1152 statements.push(stmt);
1153 }
1154 _ => {
1155 return Err(Error {
1156 kind: if self.state.in_keyframes_at_rule {
1157 ErrorKind::ExpectKeyframeBlock
1158 } else {
1159 ErrorKind::ExpectRule
1160 },
1161 span: *span,
1162 });
1163 }
1164 };
1165 // Drain continuation indents that never became a block (e.g.
1166 // `$a\n : b` — the deeper line belonged to the statement's own
1167 // clause, so its matching `Dedent` has no block to close). A
1168 // drained `Dedent` is itself a line boundary, so the statement
1169 // separator is already satisfied.
1170 if self.drain_sass_pending_dedents()? {
1171 continue;
1172 }
1173 match &self.cursor.peek()?.token {
1174 Token::RBrace(..) | Token::Eof(..) | Token::Dedent(..) => break,
1175 _ => {
1176 if self.syntax == Syntax::Sass {
1177 // The indented syntax also accepts `;` as a statement
1178 // terminator/separator (`a; b`), like a newline.
1179 if is_block_element {
1180 if self.cursor.eat_semicolon()?.is_none() {
1181 self.cursor.eat_linebreak()?;
1182 }
1183 } else if self.cursor.eat_semicolon()?.is_none() {
1184 self.cursor.expect_linebreak()?;
1185 }
1186 } else if is_block_element {
1187 self.cursor.eat_semicolon()?;
1188 } else {
1189 self.cursor.expect_semicolon()?;
1190 }
1191 }
1192 }
1193 }
1194 Ok(statements)
1195 }
1196
1197 /// Whether a statement-position `${}` placeholder (ending at byte `from`)
1198 /// should be offered to `QualifiedRule::parse`. The css-in-js rule the parser
1199 /// can't see on its own, matching prettier:
1200 /// - a bare `{` after the placeholder IS absorbed — the placeholder is the
1201 /// selector for that block (`${mixin}\n{ color: red }` is one rule; a bare
1202 /// `{...}` is meaningless without a selector, so this is the only valid read)
1203 /// - a placeholder separated by whitespace from what follows, then a newline,
1204 /// then selector content = a separate rule (`${mixin}\n& > .x {}` and
1205 /// `${a} ${b}\nhtml {}` are two statements, not one — spaced placeholders
1206 /// are typically mixin invocations, not selector pieces)
1207 /// - but a placeholder IMMEDIATELY glued to non-whitespace (e.g. `${p}:hover`
1208 /// or `${p},`) is a compound-selector piece, so a multi-line selector list
1209 /// (`${p}:hover &,\n${q}:focus &, { ... }`) is one rule — keep scanning for `{` across newlines.
1210 ///
1211 /// The real grammar (strings, comments, `#{...}` interpolations, validity) is
1212 /// left to `QualifiedRule::parse`, which runs next and rolls back if this guess was wrong.
1213 /// Deliberately NOT a tokenizer: it never early-exits on `;`/`}`
1214 /// (those may sit inside an attribute string or comment),
1215 /// so it can't misclassify a same-line selector containing them.
1216 fn placeholder_starts_qualified_rule(&self, from: usize) -> bool {
1217 let bytes = &self.source.as_bytes()[from..];
1218 // Immediately-adjacent non-whitespace (`${p}:hover`, `${p},`) means the
1219 // placeholder is a compound-selector piece: only `{` matters from here, regardless of newlines.
1220 if bytes.first().is_some_and(|b| !b.is_ascii_whitespace()) {
1221 return bytes.contains(&b'{');
1222 }
1223 // Otherwise the placeholder is separated by whitespace from what follows.
1224 // A `{` on the same line (whitespace-only prefix) still makes the
1225 // placeholder its selector; any non-whitespace after a newline starts a separate rule.
1226 let mut newline_seen = false;
1227 for &b in bytes {
1228 match b {
1229 b'{' => return true,
1230 // `\r`, `\r\n`, and `\n` all count as a newline (the tokenizer
1231 // treats a bare `\r` as a line break too).
1232 b'\n' | b'\r' => newline_seen = true,
1233 _ if b.is_ascii_whitespace() => {}
1234 _ if newline_seen => return false,
1235 _ => {}
1236 }
1237 }
1238 // No block at all -> a declaration or a bare placeholder, not a rule.
1239 false
1240 }
1241
1242 /// Whether a statement-position `${}` placeholder (ending at byte `from`)
1243 /// should be offered to `Declaration::parse` as a property name
1244 /// (`${foo}: ${bar}`). Same-line only: a newline before the next
1245 /// non-whitespace means the placeholder stands alone and what follows is a
1246 /// separate statement (`${mixin}\n\n:disabled { ... }` must not become a
1247 /// declaration `${mixin}: disabled { ... }`). Whether a same-line follower
1248 /// actually forms a declaration is left to `Declaration::parse`, which
1249 /// rolls back if this guess was wrong.
1250 fn placeholder_starts_declaration(&self, from: usize) -> bool {
1251 for &b in &self.source.as_bytes()[from..] {
1252 match b {
1253 // A bare `\r` counts as a newline too, same as
1254 // `placeholder_starts_qualified_rule` above.
1255 b'\n' | b'\r' => return false,
1256 _ if b.is_ascii_whitespace() => {}
1257 _ => return true,
1258 }
1259 }
1260 false
1261 }
1262}