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