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}