Skip to main content

oxc_css_parser/parser/
less.rs

1use super::{
2    Parser,
3    state::{LESS_CTX_ALLOW_DIV, LESS_CTX_ALLOW_KEYFRAME_BLOCK, ParserState, QualifiedRuleContext},
4};
5use crate::{
6    Parse,
7    ast::*,
8    config::Syntax,
9    error::{Error, ErrorKind, PResult},
10    pos::Span,
11    tokenizer::{Token, TokenWithSpan},
12    util,
13};
14use std::mem;
15
16const PRECEDENCE_AND: u8 = 2;
17const PRECEDENCE_OR: u8 = 1;
18
19const PRECEDENCE_MULTIPLY: u8 = 2;
20const PRECEDENCE_PLUS: u8 = 1;
21
22impl<'a> Parser<'a> {
23    // A guard condition: <condition> [ [ and | or ] <condition> ]*  (right-assoc)
24    // https://lesscss.org/features/#mixin-guards-feature
25    pub(super) fn parse_less_condition(
26        &mut self,
27        needs_parens: bool,
28    ) -> PResult<LessCondition<'a>> {
29        self.parse_less_condition_recursively(needs_parens, 0)
30    }
31
32    // <condition-atom> = <value> [ <comparison> <value> ]?
33    // <comparison> = '>' | '>=' | '<' | '<=' | '=' | '=<' | '=>'
34    fn parse_less_condition_atom(&mut self) -> PResult<LessCondition<'a>> {
35        let left =
36            self.parse_less_operation(/* allow_mixin_call */ false).map(LessCondition::Value)?;
37
38        let op = match &self.cursor.peek()?.token {
39            Token::GreaterThan(..) => LessBinaryConditionOperator {
40                kind: LessBinaryConditionOperatorKind::GreaterThan,
41                span: self.cursor.bump()?.span,
42            },
43            Token::GreaterThanEqual(..) => LessBinaryConditionOperator {
44                kind: LessBinaryConditionOperatorKind::GreaterThanOrEqual,
45                span: self.cursor.bump()?.span,
46            },
47            Token::LessThan(..) => LessBinaryConditionOperator {
48                kind: LessBinaryConditionOperatorKind::LessThan,
49                span: self.cursor.bump()?.span,
50            },
51            Token::LessThanEqual(..) => LessBinaryConditionOperator {
52                kind: LessBinaryConditionOperatorKind::LessThanOrEqual,
53                span: self.cursor.bump()?.span,
54            },
55            Token::Equal(..) => {
56                let eq_span = self.cursor.bump()?.span;
57                match self.cursor.peek()? {
58                    TokenWithSpan { token: Token::GreaterThan(..), span: gt_span }
59                        if eq_span.end == gt_span.start =>
60                    {
61                        LessBinaryConditionOperator {
62                            kind: LessBinaryConditionOperatorKind::EqualOrGreaterThan,
63                            span: Span { start: eq_span.start, end: self.cursor.bump()?.span.end },
64                        }
65                    }
66                    TokenWithSpan { token: Token::LessThan(..), span: lt_span }
67                        if eq_span.end == lt_span.start =>
68                    {
69                        LessBinaryConditionOperator {
70                            kind: LessBinaryConditionOperatorKind::EqualOrLessThan,
71                            span: Span { start: eq_span.start, end: self.cursor.bump()?.span.end },
72                        }
73                    }
74                    _ => LessBinaryConditionOperator {
75                        kind: LessBinaryConditionOperatorKind::Equal,
76                        span: eq_span,
77                    },
78                }
79            }
80            _ => return Ok(left),
81        };
82
83        let right =
84            self.parse_less_operation(/* allow_mixin_call */ false).map(LessCondition::Value)?;
85
86        let span = Span { start: left.span().start, end: right.span().end };
87        Ok(LessCondition::Binary(LessBinaryCondition {
88            left: self.alloc(left),
89            op,
90            right: self.alloc(right),
91            span,
92        }))
93    }
94
95    /// The parenthesized part of a guard condition, after its `(` was
96    /// consumed: guards are math mode (`when (8+(5-1) < 13)`). Returns the
97    /// condition and the `)`'s end offset.
98    fn parse_less_guard_paren_condition(
99        &mut self,
100        needs_parens: bool,
101    ) -> PResult<(LessCondition<'a>, usize)> {
102        let condition = self
103            .with_state(ParserState {
104                less_ctx: self.state.less_ctx | LESS_CTX_ALLOW_DIV,
105                ..self.state.clone()
106            })
107            .parse_less_condition_inside_parens(needs_parens)?;
108        let (_, Span { end, .. }) = self.cursor.expect_r_paren()?;
109        Ok((condition, end))
110    }
111
112    // A guard operand inside `( … )`: a nested <condition> or a <condition-atom>.
113    fn parse_less_condition_inside_parens(
114        &mut self,
115        needs_parens: bool,
116    ) -> PResult<LessCondition<'a>> {
117        self.try_parse(|parser| {
118            let condition = parser.parse_less_condition(needs_parens);
119            match &condition {
120                Ok(LessCondition::Parenthesized(LessParenthesizedCondition {
121                    condition: inner_condition,
122                    span,
123                })) => match &**inner_condition {
124                    LessCondition::Value(ComponentValue::LessBinaryOperation(..))
125                        if matches!(
126                            parser.cursor.peek()?.token,
127                            Token::GreaterThan(..)
128                                | Token::GreaterThanEqual(..)
129                                | Token::LessThan(..)
130                                | Token::LessThanEqual(..)
131                                | Token::Equal(..)
132                                | Token::Plus(..)
133                                | Token::Minus(..)
134                                | Token::Asterisk(..)
135                                | Token::Solidus(..)
136                        ) =>
137                    {
138                        // special case:
139                        // `when ((8 + 6) > 13)`
140                        // the `(8 + 6)` above is operation, not condition
141                        Err(Error { kind: ErrorKind::TryParseError, span: *span })
142                    }
143                    _ => condition,
144                },
145                _ => condition,
146            }
147        })
148        .or_else(|_| self.parse_less_condition_atom())
149    }
150
151    // Precedence-climbing worker for guards: `or` looser than `and`; a leaf is
152    // ( <condition> ) | not <condition-atom> | <condition-atom>.
153    fn parse_less_condition_recursively(
154        &mut self,
155        needs_parens: bool,
156        precedence: u8,
157    ) -> PResult<LessCondition<'a>> {
158        let mut left = if precedence >= PRECEDENCE_AND {
159            let peek = self.cursor.peek()?;
160            if matches!(peek.token, Token::LParen(..)) {
161                let Span { start, .. } = self.cursor.bump()?.span;
162                let (condition, end) = self.parse_less_guard_paren_condition(needs_parens)?;
163                LessCondition::Parenthesized(LessParenthesizedCondition {
164                    condition: self.alloc(condition),
165                    span: Span { start, end },
166                })
167            } else if peek.is_ident_raw(self.source, "not") {
168                let Span { start, .. } = self.cursor.bump()?.span;
169                let (condition, end) = if self.cursor.eat_l_paren()?.is_some() {
170                    self.parse_less_guard_paren_condition(needs_parens)?
171                } else {
172                    // less.js also accepts a bare operand: `when not @a`
173                    let condition = self.parse_less_condition_atom()?;
174                    let end = condition.span().end;
175                    (condition, end)
176                };
177                LessCondition::Negated(LessNegatedCondition {
178                    condition: self.alloc(condition),
179                    span: Span { start, end },
180                })
181            } else {
182                if needs_parens {
183                    let TokenWithSpan { token, span } = self.cursor.bump()?;
184                    return Err(Error { kind: ErrorKind::Unexpected("(", token.symbol()), span });
185                } else {
186                    self.parse_less_condition_atom()?
187                }
188            }
189        } else {
190            self.parse_less_condition_recursively(needs_parens, precedence + 1)?
191        };
192
193        loop {
194            let peek = self.cursor.peek()?;
195            let op = if peek.is_ident_raw(self.source, "and") && precedence == PRECEDENCE_AND {
196                LessBinaryConditionOperator {
197                    kind: LessBinaryConditionOperatorKind::And,
198                    span: self.cursor.bump()?.span,
199                }
200            } else if peek.is_ident_raw(self.source, "or") && precedence == PRECEDENCE_OR {
201                LessBinaryConditionOperator {
202                    kind: LessBinaryConditionOperatorKind::Or,
203                    span: self.cursor.bump()?.span,
204                }
205            } else {
206                break;
207            };
208
209            // multiple conditions in Less are right-associated
210            let right = self.parse_less_condition_recursively(needs_parens, precedence)?;
211
212            let span = Span { start: left.span().start, end: right.span().end };
213            left = LessCondition::Binary(LessBinaryCondition {
214                left: self.alloc(left),
215                op,
216                right: self.alloc(right),
217                span,
218            });
219        }
220
221        Ok(left)
222    }
223
224    // An identifier interleaving static parts with `@{ <name> }` / `${ <name> }`
225    // interpolation. https://lesscss.org/features/#variables-feature-variable-interpolation
226    pub(super) fn parse_less_interpolated_ident(&mut self) -> PResult<InterpolableIdent<'a>> {
227        debug_assert_eq!(self.syntax, Syntax::Less);
228
229        let (first, Span { start, mut end }) = match self.cursor.peek()? {
230            TokenWithSpan { token: Token::Ident(..), .. } => {
231                let (ident, ident_span) = self.cursor.expect_ident()?;
232                (
233                    LessInterpolatedIdentElement::Static(
234                        self.interpolable_ident_static_part(ident, ident_span),
235                    ),
236                    ident_span,
237                )
238            }
239            TokenWithSpan { token: Token::AtLBraceVar(..), .. } => {
240                let interpolation = self.parse::<LessVariableInterpolation>()?;
241                let span = interpolation.span;
242                (LessInterpolatedIdentElement::Variable(interpolation), span)
243            }
244            TokenWithSpan { token: Token::DollarLBraceVar(..), .. }
245                if matches!(
246                    self.state.qualified_rule_ctx,
247                    Some(QualifiedRuleContext::DeclarationName)
248                ) =>
249            {
250                let interpolation = self.parse::<LessPropertyInterpolation>()?;
251                let span = interpolation.span;
252                (LessInterpolatedIdentElement::Property(interpolation), span)
253            }
254            TokenWithSpan { token, span } => {
255                return Err(Error {
256                    kind: ErrorKind::ExpectOneOf(vec!["<ident>", "@{"], token.symbol()),
257                    span: *span,
258                });
259            }
260        };
261
262        let mut elements = self.parse_less_interpolated_ident_rest(&mut end)?;
263        if elements.is_empty()
264            && let LessInterpolatedIdentElement::Static(ident) = first
265        {
266            return Ok(InterpolableIdent::Literal(Ident {
267                name: ident.value,
268                raw: ident.raw,
269                span: ident.span,
270            }));
271        }
272
273        elements.insert(0, first);
274        Ok(InterpolableIdent::LessInterpolated(LessInterpolatedIdent {
275            elements,
276            span: Span { start, end },
277        }))
278    }
279
280    // The static/`@{…}`/`${…}` element sequence after an interpolated ident's first part.
281    pub(super) fn parse_less_interpolated_ident_rest(
282        &mut self,
283        end: &mut usize,
284    ) -> PResult<oxc_allocator::Vec<'a, LessInterpolatedIdentElement<'a>>> {
285        let mut elements = self.vec();
286        loop {
287            if let Some((token, span)) = self.cursor.tokenizer.scan_ident_template()? {
288                *end = span.end;
289                elements.push(LessInterpolatedIdentElement::Static(
290                    self.interpolable_ident_static_part(token, span),
291                ));
292            } else {
293                match self.cursor.peek()? {
294                    TokenWithSpan { token: Token::AtLBraceVar(..), span: at_lbrace_var_span }
295                        if *end == at_lbrace_var_span.start =>
296                    {
297                        let variable = self.parse::<LessVariableInterpolation>()?;
298                        *end = variable.span.end;
299                        elements.push(LessInterpolatedIdentElement::Variable(variable));
300                    }
301                    TokenWithSpan {
302                        token: Token::DollarLBraceVar(..),
303                        span: dollar_lbrace_var_span,
304                    } if matches!(
305                        self.state.qualified_rule_ctx,
306                        Some(QualifiedRuleContext::DeclarationName)
307                    ) && *end == dollar_lbrace_var_span.start =>
308                    {
309                        let property = self.parse::<LessPropertyInterpolation>()?;
310                        *end = property.span.end;
311                        elements.push(LessInterpolatedIdentElement::Property(property));
312                    }
313                    _ => return Ok(elements),
314                }
315            }
316        }
317    }
318
319    // <mixin-call> [ <lookups> ]?   (a mixin call, optionally with `[...]` lookups)
320    pub(super) fn parse_less_maybe_mixin_call_or_with_lookups(
321        &mut self,
322    ) -> PResult<ComponentValue<'a>> {
323        let mixin_call = self.parse::<LessMixinCall>()?;
324        if matches!(self.cursor.peek()?.token, Token::LBracket(..)) {
325            let lookups = self.parse::<LessLookups>()?;
326            let span = Span { start: mixin_call.span.start, end: lookups.span.end };
327            Ok(ComponentValue::LessNamespaceValue(self.alloc(LessNamespaceValue {
328                callee: LessNamespaceValueCallee::LessMixinCall(mixin_call),
329                lookups,
330                span,
331            })))
332        } else {
333            Ok(ComponentValue::LessMixinCall(self.alloc(mixin_call)))
334        }
335    }
336
337    // <variable> [ '(' ')' | <lookups> ]?   (a variable, a detached-ruleset call,
338    // or a variable with `[...]` map lookups)
339    pub(super) fn parse_less_maybe_variable_or_with_lookups(
340        &mut self,
341    ) -> PResult<ComponentValue<'a>> {
342        let variable = self.parse::<LessVariable>()?;
343        match self.cursor.peek()? {
344            // a detached-ruleset call in value position: `a: @a();`
345            TokenWithSpan { token: Token::LParen(..), span } if variable.span.end == span.start => {
346                self.cursor.bump()?;
347                let (_, Span { end, .. }) = self.cursor.expect_r_paren()?;
348                let span = Span { start: variable.span.start, end };
349                Ok(ComponentValue::LessVariableCall(LessVariableCall { variable, span }))
350            }
351            TokenWithSpan { token: Token::LBracket(..), span }
352                if variable.span.end == span.start =>
353            {
354                let lookups = self.parse::<LessLookups>()?;
355                let span = Span { start: variable.span.start, end: lookups.span.end };
356                Ok(ComponentValue::LessNamespaceValue(self.alloc(LessNamespaceValue {
357                    callee: LessNamespaceValueCallee::LessVariable(variable),
358                    lookups,
359                    span,
360                })))
361            }
362            _ => Ok(ComponentValue::LessVariable(variable)),
363        }
364    }
365
366    // A Less arithmetic operation: <value> [ [ '+' | '-' | '*' | '/' ] <value> ]*
367    // ('*' / '/' bind tighter than '+' / '-'). https://lesscss.org/functions/#math
368    pub(super) fn parse_less_operation(
369        &mut self,
370        allow_mixin_call: bool,
371    ) -> PResult<ComponentValue<'a>> {
372        self.parse_less_operation_recursively(allow_mixin_call, 0)
373    }
374
375    // Precedence-climbing worker for `parse_less_operation`.
376    fn parse_less_operation_recursively(
377        &mut self,
378        allow_mixin_call: bool,
379        precedence: u8,
380    ) -> PResult<ComponentValue<'a>> {
381        let mut left = if precedence >= PRECEDENCE_MULTIPLY {
382            match self.cursor.peek()?.token {
383                Token::LParen(..) => self
384                    .parse_less_parenthesized_operation(allow_mixin_call)
385                    .map(ComponentValue::LessParenthesizedOperation)?,
386                Token::Minus(..) => {
387                    // less.js's keyword regex also matches a bare `-`
388                    // (`a:hover when (2 = true) {5:-}`); only treat it as one
389                    // when a terminator follows immediately
390                    let end = self.cursor.peek()?.span.end;
391                    if matches!(self.source.as_bytes().get(end), None | Some(b';' | b'}')) {
392                        let span = self.cursor.bump()?.span;
393                        ComponentValue::InterpolableIdent(InterpolableIdent::Literal(Ident {
394                            name: "-",
395                            raw: "-",
396                            span,
397                        }))
398                    } else {
399                        self.parse::<LessNegativeValue>().map(ComponentValue::LessNegativeValue)?
400                    }
401                }
402                _ => {
403                    let value = self.parse_component_value_atom()?;
404                    if let ComponentValue::LessMixinCall(mixin_call) = &value
405                        && !allow_mixin_call
406                    {
407                        self.recoverable_errors.push(Error {
408                            kind: ErrorKind::UnexpectedLessMixinCall,
409                            span: mixin_call.span,
410                        });
411                    }
412                    value
413                }
414            }
415        } else {
416            self.parse_less_operation_recursively(allow_mixin_call, precedence + 1)?
417        };
418
419        // delimiter can't be calculated (same guard as the SassScript path):
420        // `func(20px, +20px)` / `func(20px,-20px)` are two args in lessc,
421        // never a `,`-left operation
422        if matches!(left, ComponentValue::Delimiter(..)) {
423            return Ok(left);
424        }
425
426        loop {
427            let op = match self.cursor.peek()? {
428                TokenWithSpan { token: Token::Asterisk(..), .. }
429                    if precedence == PRECEDENCE_MULTIPLY =>
430                {
431                    LessOperationOperator {
432                        kind: LessOperationOperatorKind::Multiply,
433                        span: self.cursor.bump()?.span,
434                    }
435                }
436                TokenWithSpan { token: Token::Solidus(..), .. }
437                    if precedence == PRECEDENCE_MULTIPLY
438                        && (self.state.less_ctx & LESS_CTX_ALLOW_DIV != 0
439                            || can_be_division_operand(&left)) =>
440                {
441                    LessOperationOperator {
442                        kind: LessOperationOperatorKind::Division,
443                        span: self.cursor.bump()?.span,
444                    }
445                }
446                TokenWithSpan { token: Token::Dot(..), .. }
447                    if precedence == PRECEDENCE_MULTIPLY =>
448                {
449                    // `./` is also division
450                    let Span { start, .. } = self.cursor.bump()?.span;
451                    let (_, Span { end, .. }) =
452                        self.cursor.expect_solidus_without_ws_or_comments()?;
453                    LessOperationOperator {
454                        kind: LessOperationOperatorKind::Division,
455                        span: Span { start, end },
456                    }
457                }
458                // A `+`/`-` is a binary operator only when followed by
459                // whitespace. lessc is whitespace-sensitive here: `@a - @b`
460                // is subtraction, but `@a -@b` is two values (`-@b` is a
461                // signed value, not an operator) — see the `margin` shorthand.
462                TokenWithSpan { token: Token::Plus(..), span }
463                    if precedence == PRECEDENCE_PLUS
464                        && (is_followed_by_whitespace(self.source, span.end)
465                            || self.state.less_ctx & LESS_CTX_ALLOW_DIV != 0) =>
466                {
467                    LessOperationOperator {
468                        kind: LessOperationOperatorKind::Plus,
469                        span: self.cursor.bump()?.span,
470                    }
471                }
472                TokenWithSpan { token: Token::Minus(..), span }
473                    if precedence == PRECEDENCE_PLUS
474                        && (is_followed_by_whitespace(self.source, span.end)
475                            || self.state.less_ctx & LESS_CTX_ALLOW_DIV != 0) =>
476                {
477                    LessOperationOperator {
478                        kind: LessOperationOperatorKind::Minus,
479                        span: self.cursor.bump()?.span,
480                    }
481                }
482                // A sign the lexer folded into a number/dimension token is a
483                // binary operator only when glued to the left operand, for `+` and `-` alike:
484                // lessc reads `10px+20px` as `30px` but `10px +20px` as the two-value list `10px 20px`.
485                token @ TokenWithSpan { token: Token::Number(..), span }
486                    if precedence == PRECEDENCE_PLUS
487                        && token
488                            .number_raw(self.source)
489                            .is_some_and(|raw| raw.starts_with('+') || raw.starts_with('-'))
490                        && span.start == left.span().end =>
491                {
492                    let (number, number_span) = self.cursor.expect_number()?;
493                    let op = LessOperationOperator {
494                        kind: if number.raw.starts_with('+') {
495                            LessOperationOperatorKind::Plus
496                        } else {
497                            LessOperationOperatorKind::Minus
498                        },
499                        span: Span { start: number_span.start, end: number_span.start + 1 },
500                    };
501                    let span = Span { start: left.span().start, end: number_span.end };
502                    let right = {
503                        let span = Span { start: number_span.start + 1, end: number_span.end };
504                        let raw = unsafe { number.raw.get_unchecked(1..number.raw.len()) };
505                        raw.parse()
506                            .map_err(|_| Error { kind: ErrorKind::InvalidNumber, span })
507                            .map(|value| ComponentValue::Number(Number { value, raw, span }))?
508                    };
509                    left = ComponentValue::LessBinaryOperation(LessBinaryOperation {
510                        left: self.alloc(left),
511                        op,
512                        right: self.alloc(right),
513                        span,
514                    });
515                    continue;
516                }
517                token @ TokenWithSpan { token: Token::Dimension(..), span }
518                    if precedence == PRECEDENCE_PLUS
519                        && token
520                            .dimension_value_raw(self.source)
521                            .is_some_and(|raw| raw.starts_with('+') || raw.starts_with('-'))
522                        && span.start == left.span().end =>
523                {
524                    let (dimension, dimension_span) = self.cursor.expect_dimension()?;
525                    let op = LessOperationOperator {
526                        kind: if dimension.value.raw.starts_with('+') {
527                            LessOperationOperatorKind::Plus
528                        } else {
529                            LessOperationOperatorKind::Minus
530                        },
531                        span: Span { start: dimension_span.start, end: dimension_span.start + 1 },
532                    };
533                    let mut right = {
534                        self.dimension(
535                            crate::token::Dimension {
536                                value: crate::token::Number {
537                                    raw: unsafe {
538                                        dimension
539                                            .value
540                                            .raw
541                                            .get_unchecked(1..dimension.value.raw.len())
542                                    },
543                                },
544                                unit: dimension.unit,
545                            },
546                            Span { start: dimension_span.start + 1, end: dimension_span.end },
547                        )
548                        .map(ComponentValue::Dimension)?
549                    };
550                    // multiplication binds tighter than the split-off sign:
551                    // `(6px-1px*2)` is `6px - (1px * 2)`
552                    while matches!(
553                        &self.cursor.peek()?.token,
554                        Token::Asterisk(..) | Token::Solidus(..)
555                    ) {
556                        let mul_op = LessOperationOperator {
557                            kind: if matches!(&self.cursor.peek()?.token, Token::Asterisk(..)) {
558                                LessOperationOperatorKind::Multiply
559                            } else {
560                                LessOperationOperatorKind::Division
561                            },
562                            span: self.cursor.bump()?.span,
563                        };
564                        let mul_rhs = self.parse_less_operation_recursively(
565                            allow_mixin_call,
566                            PRECEDENCE_MULTIPLY + 1,
567                        )?;
568                        let span = Span { start: right.span().start, end: mul_rhs.span().end };
569                        right = ComponentValue::LessBinaryOperation(LessBinaryOperation {
570                            left: self.alloc(right),
571                            op: mul_op,
572                            right: self.alloc(mul_rhs),
573                            span,
574                        });
575                    }
576                    let span = Span { start: left.span().start, end: right.span().end };
577                    left = ComponentValue::LessBinaryOperation(LessBinaryOperation {
578                        left: self.alloc(left),
579                        op,
580                        right: self.alloc(right),
581                        span,
582                    });
583                    continue;
584                }
585                _ => break,
586            };
587
588            let right = self.parse_less_operation_recursively(allow_mixin_call, precedence + 1)?;
589            let span = Span { start: left.span().start, end: right.span().end };
590            left = ComponentValue::LessBinaryOperation(LessBinaryOperation {
591                left: self.alloc(left),
592                op,
593                right: self.alloc(right),
594                span,
595            });
596        }
597
598        Ok(left)
599    }
600
601    // ( <operation> )   (parentheses force math mode on their contents)
602    fn parse_less_parenthesized_operation(
603        &mut self,
604        allow_mixin_call: bool,
605    ) -> PResult<LessParenthesizedOperation<'a>> {
606        let (_, Span { start, .. }) = self.cursor.expect_l_paren()?;
607        let operation = self
608            .with_state(ParserState {
609                less_ctx: self.state.less_ctx | LESS_CTX_ALLOW_DIV,
610                ..self.state.clone()
611            })
612            .parse_less_operation(allow_mixin_call)?;
613        let (_, Span { end, .. }) = self.cursor.expect_r_paren()?;
614        Ok(LessParenthesizedOperation {
615            operation: self.alloc(operation),
616            span: Span { start, end },
617        })
618    }
619
620    // A style rule that may carry a guard: <selector-list> [ when <guard> ]? { <block> }
621    // (guards are only allowed on a single-selector rule).
622    pub(super) fn parse_less_qualified_rule(&mut self) -> PResult<Statement<'a>> {
623        debug_assert_eq!(self.syntax, Syntax::Less);
624
625        let selector_list = self
626            .with_state(ParserState {
627                qualified_rule_ctx: Some(QualifiedRuleContext::Selector),
628                ..self.state
629            })
630            .parse::<SelectorList>()?;
631
632        if self.cursor.peek()?.is_ident_raw(self.source, "when") {
633            // less.js: "Guards are only currently allowed on a single
634            // selector." — a guard on a selector list is rejected whether
635            // the comma comes before or after the `when`.
636            if selector_list.selectors.len() > 1 {
637                let span = self.cursor.peek()?.span;
638                return Err(Error { kind: ErrorKind::LessGuardOnMultipleComplexSelectors, span });
639            }
640            let guard = self.parse::<LessConditions>()?;
641            let block = self.parse::<SimpleBlock>()?;
642            let span = Span { start: selector_list.span.start, end: block.span.end };
643            return Ok(Statement::LessConditionalQualifiedRule(LessConditionalQualifiedRule {
644                selector: selector_list,
645                guard,
646                block,
647                span,
648            }));
649        }
650
651        let block = self.parse::<SimpleBlock>()?;
652        let span = Span { start: selector_list.span.start, end: block.span.end };
653        Ok(Statement::QualifiedRule(QualifiedRule { selector: selector_list, block, span }))
654    }
655
656    // A `#…`-led value that is either a <hex-color> or an id-selector <mixin-call>.
657    pub(super) fn parse_maybe_hex_color_or_less_mixin_call(
658        &mut self,
659    ) -> PResult<ComponentValue<'a>> {
660        debug_assert_eq!(self.syntax, Syntax::Less);
661
662        let attempt = self.try_parse(|parser| {
663            let hex_color = parser.parse::<HexColor>()?;
664            match parser.cursor.peek()? {
665                TokenWithSpan { token: Token::LParen(..), span } => {
666                    Err(Error { kind: ErrorKind::TryParseError, span: *span })
667                }
668                TokenWithSpan {
669                    token: Token::LBracket(..) | Token::Dot(..) | Token::Hash(..),
670                    span,
671                } if hex_color.span.end == span.start => {
672                    Err(Error { kind: ErrorKind::TryParseError, span: *span })
673                }
674                _ => Ok(hex_color),
675            }
676        });
677        match attempt {
678            Err(Error { kind: ErrorKind::TryParseError, .. }) => {
679                self.parse_less_maybe_mixin_call_or_with_lookups()
680            }
681            hex_color => hex_color.map(ComponentValue::HexColor),
682        }
683    }
684
685    // A Less list: space- or comma-separated values (comma binds looser than space).
686    pub(super) fn parse_maybe_less_list(
687        &mut self,
688        allow_comma: bool,
689    ) -> PResult<ComponentValue<'a>> {
690        use util::ListSeparatorKind;
691
692        let single_value = if allow_comma {
693            self.parse_maybe_less_list(false)?
694        } else if let Token::Exclamation(..) = self.cursor.peek()?.token {
695            self.parse().map(ComponentValue::ImportantAnnotation)?
696        } else {
697            self.parse_less_operation(/* allow_mixin_call */ true)?
698        };
699
700        let mut elements = self.vec();
701        let mut comma_spans: Option<oxc_allocator::Vec<'a, Span>> = None;
702        let mut separator = ListSeparatorKind::Unknown;
703        let mut end = single_value.span().end;
704        loop {
705            match self.cursor.peek()?.token {
706                Token::LBrace(..)
707                | Token::RBrace(..)
708                | Token::RParen(..)
709                | Token::Semicolon(..)
710                | Token::Colon(..)
711                | Token::DotDotDot(..)
712                | Token::Eof(..) => break,
713                Token::Comma(..) => {
714                    if !allow_comma {
715                        break;
716                    }
717                    if separator == ListSeparatorKind::Space {
718                        break;
719                    } else {
720                        if separator == ListSeparatorKind::Unknown {
721                            separator = ListSeparatorKind::Comma;
722                        }
723                        let TokenWithSpan { span, .. } = self.cursor.bump()?;
724                        end = span.end;
725                        if let Some(spans) = &mut comma_spans {
726                            spans.push(span);
727                        } else {
728                            comma_spans = Some(self.vec1(span));
729                        }
730                    }
731                }
732                Token::Exclamation(..) => {
733                    if let Ok(important_annotation) = self.try_parse(ImportantAnnotation::parse) {
734                        if end < important_annotation.span.start
735                            && separator == ListSeparatorKind::Unknown
736                        {
737                            separator = ListSeparatorKind::Space;
738                        }
739                        end = important_annotation.span.end;
740                        elements.push(ComponentValue::ImportantAnnotation(important_annotation));
741                    } else {
742                        break;
743                    }
744                }
745                _ => {
746                    if separator == ListSeparatorKind::Unknown {
747                        separator = ListSeparatorKind::Space;
748                    }
749                    let item = if separator == ListSeparatorKind::Comma {
750                        self.parse_maybe_less_list(false)?
751                    } else {
752                        self.parse_less_operation(/* allow_mixin_call */ true)?
753                    };
754                    end = item.span().end;
755                    elements.push(item);
756                }
757            }
758        }
759
760        if elements.is_empty() && separator != ListSeparatorKind::Comma {
761            // If there is a trailing comma it can be a list,
762            // though there is only one element.
763            Ok(single_value)
764        } else {
765            debug_assert_ne!(separator, ListSeparatorKind::Unknown);
766
767            let span = Span { start: single_value.span().start, end };
768            elements.insert(0, single_value);
769            Ok(ComponentValue::LessList(LessList { elements, comma_spans, span }))
770        }
771    }
772}
773
774// https://lesscss.org/features/#mixin-guards-feature
775//
776// A guard: when <condition> [ , <condition> ]*
777impl<'a> Parse<'a> for LessConditions<'a> {
778    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
779        let token = input.cursor.bump()?;
780        let when_span = if token.is_ident_raw(input.source, "when") {
781            token.span
782        } else {
783            return Err(Error { kind: ErrorKind::ExpectLessKeyword("when"), span: token.span });
784        };
785
786        let first = input.parse_less_condition(true)?;
787        let mut span = *first.span();
788
789        let mut conditions = input.vec1(first);
790        let mut comma_spans = input.vec();
791        // A comma may also separate the next guarded selector of the rule
792        // (`.a when (..), .b when (..) {`), so only consume it when a
793        // condition actually follows.
794        while matches!(input.cursor.peek()?.token, Token::Comma(..)) {
795            let Ok((comma_span, condition)) = input.try_parse(|p| {
796                let (_, comma_span) = p.cursor.expect_comma()?;
797                let condition = p.parse_less_condition(true)?;
798                Ok((comma_span, condition))
799            }) else {
800                break;
801            };
802            comma_spans.push(comma_span);
803            conditions.push(condition);
804        }
805        debug_assert_eq!(comma_spans.len() + 1, conditions.len());
806
807        if let Some(last) = conditions.last() {
808            span.end = last.span().end;
809        }
810        Ok(LessConditions { conditions, when_span, comma_spans, span })
811    }
812}
813
814// https://lesscss.org/features/#detached-rulesets-feature
815//
816// A detached ruleset (a `{ <block> }` stored in a variable).
817impl<'a> Parse<'a> for LessDetachedRuleset<'a> {
818    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
819        let block = input.parse::<SimpleBlock>()?;
820        let span = block.span;
821        Ok(LessDetachedRuleset { block, span })
822    }
823}
824
825// https://lesscss.org/functions/#string-functions-e
826//
827// An escaped string: '~' <string>   (e.g. `~"raw value"`)
828impl<'a> Parse<'a> for LessEscapedStr<'a> {
829    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
830        let (_, Span { start, .. }) = input.cursor.expect_tilde()?;
831        let str = {
832            let (str, span) = input.cursor.tokenizer.scan_string_only()?;
833            input.str(str, span)
834        };
835        let span = Span { start, end: str.span().end };
836        Ok(LessEscapedStr { str, span })
837    }
838}
839
840// https://lesscss.org/features/#extend-feature
841//
842// One extend target inside `:extend(...)`: <complex-selector> [ all ]?
843impl<'a> Parse<'a> for LessExtend<'a> {
844    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
845        let mut selector = input.parse::<ComplexSelector>()?;
846
847        let span = selector.span;
848        let mut all = None;
849
850        if let [
851            ..,
852            complex_child,
853            ComplexSelectorChild::Combinator(Combinator {
854                kind: CombinatorKind::Descendant, ..
855            }),
856            ComplexSelectorChild::CompoundSelector(CompoundSelector { children, .. }),
857        ] = &selector.children[..]
858            && let [
859                SimpleSelector::Type(TypeSelector::TagName(TagNameSelector {
860                    name:
861                        WqName {
862                            name: InterpolableIdent::Literal(token_all @ Ident { raw: "all", .. }),
863                            prefix: None,
864                            ..
865                        },
866                    ..
867                })),
868            ] = &children[..]
869        {
870            all = Some(Ident { name: token_all.name, raw: token_all.raw, span: token_all.span });
871            selector.span.end = complex_child.span().end;
872            let len = selector.children.len();
873            selector.children.truncate(len - 2);
874        }
875
876        Ok(LessExtend { selector, all, span })
877    }
878}
879
880// <less-extend> [ , <less-extend> ]*   (the contents of `:extend(...)`)
881impl<'a> Parse<'a> for LessExtendList<'a> {
882    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
883        debug_assert_eq!(input.syntax, Syntax::Less);
884
885        let first = input.parse::<LessExtend>()?;
886        let mut span = first.span;
887
888        let mut elements = input.vec1(first);
889        let mut comma_spans = input.vec();
890        while let Some((_, comma_span)) = input.cursor.eat_comma()? {
891            comma_spans.push(comma_span);
892            elements.push(input.parse()?);
893        }
894        debug_assert_eq!(comma_spans.len() + 1, elements.len());
895
896        if let Some(last) = elements.last() {
897            span.end = last.span.end;
898        }
899        Ok(LessExtendList { elements, comma_spans, span })
900    }
901}
902
903// The selector-attached extend form: '&' :extend( <extend-list> )
904impl<'a> Parse<'a> for LessExtendRule<'a> {
905    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
906        let nesting_selector = input.parse::<NestingSelector>()?;
907        if nesting_selector.suffix.is_some() {
908            return Err(Error {
909                kind: ErrorKind::ExpectLessExtendRule,
910                span: nesting_selector.span,
911            });
912        }
913
914        let pseudo_class_selector = input.parse::<PseudoClassSelector>()?;
915        util::assert_no_ws_or_comment(&nesting_selector.span, &pseudo_class_selector.span)?;
916        let span = Span { start: nesting_selector.span.start, end: pseudo_class_selector.span.end };
917
918        let InterpolableIdent::Literal(name_of_extend @ Ident { raw: "extend", .. }) =
919            pseudo_class_selector.name
920        else {
921            return Err(Error { kind: ErrorKind::ExpectLessExtendRule, span });
922        };
923        let Some(PseudoClassSelectorArg {
924            kind: PseudoClassSelectorArgKind::LessExtendList(extend),
925            ..
926        }) = pseudo_class_selector.arg
927        else {
928            return Err(Error { kind: ErrorKind::ExpectLessExtendRule, span });
929        };
930
931        Ok(LessExtendRule { nesting_selector, name_of_extend, extend, span })
932    }
933}
934
935// The Less `%(…)` string-format function name.
936// https://lesscss.org/functions/#string-functions-format
937impl<'a> Parse<'a> for LessFormatFunction {
938    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
939        let (_, span) = input.cursor.expect_percent()?;
940        Ok(LessFormatFunction { span })
941    }
942}
943
944// https://lesscss.org/features/#import-atrules-feature-import-options
945//
946// ( [ less | css | multiple | once | inline | reference | optional ]# )
947impl<'a> Parse<'a> for LessImportOptions<'a> {
948    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
949        let (_, Span { start, .. }) = input.cursor.expect_l_paren()?;
950
951        let mut names = input.vec_with_capacity(1);
952        let mut comma_spans = input.vec();
953        while input.cursor.peek()?.ident_raw(input.source).is_some_and(|raw| {
954            matches!(
955                raw,
956                "less" | "css" | "multiple" | "once" | "inline" | "reference" | "optional"
957            )
958        }) {
959            names.push(input.parse()?);
960            if !matches!(input.cursor.peek()?.token, Token::RParen(..)) {
961                comma_spans.push(input.cursor.expect_comma()?.1);
962            }
963        }
964        debug_assert!(names.len() - comma_spans.len() <= 1);
965
966        let (_, Span { end, .. }) = input.cursor.expect_r_paren()?;
967
968        Ok(LessImportOptions { names, comma_spans, span: Span { start, end } })
969    }
970}
971
972// @import ( <import-options> ) [ <string> | <url> ] <media-query-list>?
973impl<'a> Parse<'a> for LessImportPrelude<'a> {
974    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
975        let options = input.parse::<LessImportOptions>()?;
976        let start = options.span.start;
977
978        let href = match &input.cursor.peek()?.token {
979            Token::Str(..) | Token::StrTemplate(..) => input.parse().map(ImportPreludeHref::Str)?,
980            _ => input.parse().map(ImportPreludeHref::Url)?,
981        };
982        let mut end = href.span().end;
983
984        let media = if matches!(input.cursor.peek()?.token, Token::Semicolon(..)) {
985            None
986        } else {
987            let media = input.parse::<MediaQueryList>()?;
988            end = media.span.end;
989            Some(media)
990        };
991
992        Ok(LessImportPrelude { href, options, media, span: Span { start, end } })
993    }
994}
995
996// A quoted string containing `@{ <name> }` / `${ <name> }` interpolation.
997// https://lesscss.org/features/#variables-feature-variable-interpolation
998impl<'a> Parse<'a> for LessInterpolatedStr<'a> {
999    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1000        let (first, first_span) = input.cursor.expect_str_template()?;
1001        let quote = first.raw.bytes().next().unwrap();
1002        debug_assert!(quote == b'\'' || quote == b'"');
1003        let mut span = first_span;
1004        let mut elements = input.vec1(LessInterpolatedStrElement::Static(
1005            input.interpolable_str_static_part(first, first_span),
1006        ));
1007
1008        let mut is_parsing_static_part = false;
1009        loop {
1010            if is_parsing_static_part {
1011                let (token, str_tpl_span) = input.cursor.tokenizer.scan_string_template(quote)?;
1012                let tail = token.tail;
1013                let end = str_tpl_span.end;
1014                elements.push(LessInterpolatedStrElement::Static(
1015                    input.interpolable_str_static_part(token, str_tpl_span),
1016                ));
1017                if tail {
1018                    span.end = end;
1019                    break;
1020                }
1021            } else {
1022                // '@' or '$' is consumed, so '{' left only
1023                let start = input.cursor.expect_l_brace()?.1.start - 1;
1024                // Less interpolation names may start with a digit (`@{3}`).
1025                let (name, name_span) = input.cursor.expect_ident_without_ws_or_comments(true)?;
1026
1027                let end = input.cursor.expect_r_brace()?.1.end;
1028                elements.push(match input.source.as_bytes().get(start) {
1029                    Some(b'@') => LessInterpolatedStrElement::Variable(LessVariableInterpolation {
1030                        name: input.ident(name, name_span),
1031                        span: Span { start, end },
1032                    }),
1033                    Some(b'$') => LessInterpolatedStrElement::Property(LessPropertyInterpolation {
1034                        name: input.ident(name, name_span),
1035                        span: Span { start, end },
1036                    }),
1037                    _ => unreachable!(),
1038                });
1039            }
1040            is_parsing_static_part = !is_parsing_static_part;
1041        }
1042
1043        Ok(LessInterpolatedStr { elements, span })
1044    }
1045}
1046
1047// Inline JavaScript evaluation: '~'? '`' <code> '`'  (deprecated Less feature)
1048impl<'a> Parse<'a> for LessJavaScriptSnippet<'a> {
1049    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1050        let tilde = input.cursor.eat_tilde()?;
1051        let (token, span) = input.cursor.expect_backtick_code()?;
1052
1053        Ok(LessJavaScriptSnippet {
1054            code: &token.raw[1..token.raw.len() - 1],
1055            raw: token.raw,
1056            escaped: tilde.is_some(),
1057            span: Span {
1058                start: tilde.map(|(_, span)| span.start).unwrap_or(span.start),
1059                end: span.end,
1060            },
1061        })
1062    }
1063}
1064
1065// The Less `~` function-name marker.
1066impl<'a> Parse<'a> for LessListFunction {
1067    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1068        let (_, span) = input.cursor.expect_tilde()?;
1069        Ok(LessListFunction { span })
1070    }
1071}
1072
1073// https://lesscss.org/features/#maps-feature
1074//
1075// A map lookup: '[' <lookup-name>? ']'
1076impl<'a> Parse<'a> for LessLookup<'a> {
1077    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1078        debug_assert_eq!(input.syntax, Syntax::Less);
1079
1080        let (_, Span { start, .. }) = input.cursor.expect_l_bracket()?;
1081        let name = if let Token::RBracket(..) = input.cursor.peek()?.token {
1082            None
1083        } else {
1084            Some(input.parse()?)
1085        };
1086        let (_, Span { end, .. }) = input.cursor.expect_r_bracket()?;
1087        Ok(LessLookup { name, span: Span { start, end } })
1088    }
1089}
1090
1091// <lookup-name> = @<var> | @@<var> | $<prop> | $@<var> | <ident>
1092impl<'a> Parse<'a> for LessLookupName<'a> {
1093    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1094        debug_assert_eq!(input.syntax, Syntax::Less);
1095
1096        let is_dollar = {
1097            let TokenWithSpan { token, span } = input.cursor.peek()?;
1098            matches!(token, Token::Unknown(..))
1099                && input.source.as_bytes().get(span.start) == Some(&b'$')
1100        };
1101        match input.cursor.peek()?.token {
1102            Token::AtKeyword(..) => input.parse().map(LessLookupName::LessVariable),
1103            Token::At(..) => input.parse().map(LessLookupName::LessVariableVariable),
1104            Token::DollarVar(..) => input.parse().map(LessLookupName::LessPropertyVariable),
1105            // `[$@var]` — a property lookup whose name is interpolated from a
1106            // variable; the lone `$` arrives as an <unknown-token>
1107            Token::Unknown(..) if is_dollar => {
1108                let dollar_span = input.cursor.bump()?.span;
1109                let variable = input.parse::<LessVariable>()?;
1110                util::assert_no_ws_or_comment(&dollar_span, &variable.span)?;
1111                let span = Span { start: dollar_span.start, end: variable.span.end };
1112                Ok(LessLookupName::LessPropertyInterpolation(LessPropertyInterpolation {
1113                    name: variable.name,
1114                    span,
1115                }))
1116            }
1117            _ => input.parse().map(LessLookupName::Ident),
1118        }
1119    }
1120}
1121
1122// <lookup>+   (a chain of `[...]` map lookups)
1123impl<'a> Parse<'a> for LessLookups<'a> {
1124    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1125        debug_assert_eq!(input.syntax, Syntax::Less);
1126
1127        let first = input.parse::<LessLookup>()?;
1128        let mut span = first.span;
1129
1130        let mut lookups = input.vec1(first);
1131        while let Token::LBracket(..) = input.cursor.peek()?.token {
1132            lookups.push(input.parse()?);
1133        }
1134
1135        if let Some(last) = lookups.last() {
1136            span.end = last.span.end;
1137        }
1138        Ok(LessLookups { lookups, span })
1139    }
1140}
1141
1142// https://lesscss.org/features/#mixins-feature
1143//
1144// <mixin-callee> [ ( <mixin-args> ) ]? [ !important ]?
1145impl<'a> Parse<'a> for LessMixinCall<'a> {
1146    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1147        debug_assert_eq!(input.syntax, Syntax::Less);
1148
1149        let callee = input.parse::<LessMixinCallee>()?;
1150
1151        let mut end = callee.span.end;
1152        let args = if let Some((_, lparen_span)) = input.cursor.eat_l_paren()? {
1153            let mut semicolon_comes_at = 0;
1154            let mut args = input.vec();
1155            let mut comma_spans = input.vec();
1156            let mut semicolon_spans = input.vec();
1157            loop {
1158                match input.cursor.peek()?.token {
1159                    Token::RParen(..) => {
1160                        let TokenWithSpan { span, .. } = input.cursor.bump()?;
1161                        if semicolon_comes_at > 0 {
1162                            let comma_spans = mem::replace(&mut comma_spans, input.vec());
1163                            wrap_less_mixin_args_into_less_list(
1164                                input.allocator,
1165                                &mut args,
1166                                comma_spans,
1167                                semicolon_comes_at,
1168                            )
1169                            .map_err(|kind| Error {
1170                                kind,
1171                                span: Span {
1172                                    // We've checked `semicolon_comes_at` must be greater than 0,
1173                                    // so `args` won't be empty.
1174                                    start: args.first().unwrap().span().start,
1175                                    end: args.last().unwrap().span().end,
1176                                },
1177                            })?;
1178                        }
1179                        end = span.end;
1180                        break;
1181                    }
1182                    Token::LBrace(..) => args.push(LessMixinArgument::Value(
1183                        ComponentValue::LessDetachedRuleset(input.parse()?),
1184                    )),
1185                    Token::Comma(..) => {
1186                        return Err(Error {
1187                            kind: ErrorKind::ExpectComponentValue,
1188                            span: input.cursor.bump()?.span,
1189                        });
1190                    }
1191                    _ => 'maybe: {
1192                        let value = input.parse_maybe_less_list(/* allow_comma */ false)?;
1193                        let name = {
1194                            match value {
1195                                ComponentValue::LessVariable(variable) => {
1196                                    LessMixinParameterName::Variable(variable)
1197                                }
1198                                ComponentValue::LessPropertyVariable(property) => {
1199                                    LessMixinParameterName::PropertyVariable(property)
1200                                }
1201                                value => {
1202                                    args.push(LessMixinArgument::Value(value));
1203                                    break 'maybe;
1204                                }
1205                            }
1206                        };
1207                        if let Some((_, colon_span)) = input.cursor.eat_colon()? {
1208                            let value = if matches!(input.cursor.peek()?.token, Token::LBrace(..)) {
1209                                input.parse().map(ComponentValue::LessDetachedRuleset)?
1210                            } else {
1211                                input.parse_maybe_less_list(
1212                                    /* allow_comma */ semicolon_comes_at > 0,
1213                                )?
1214                            };
1215                            let span = Span { start: name.span().start, end: value.span().end };
1216                            args.push(LessMixinArgument::Named(LessMixinNamedArgument {
1217                                name,
1218                                colon_span,
1219                                value,
1220                                span,
1221                            }));
1222                        } else if let Some((_, dotdotdot_span)) = input.cursor.eat_dot_dot_dot()? {
1223                            // unlike definitions, call-site spreads may appear
1224                            // anywhere and repeat: `.m(@x..., @a: 0)`,
1225                            // `.aa(@y, @x..., and again, @y...)`
1226                            let span = Span { start: name.span().start, end: dotdotdot_span.end };
1227                            args.push(LessMixinArgument::Variadic(LessMixinVariadicArgument {
1228                                name,
1229                                span,
1230                            }));
1231                        } else {
1232                            args.push(LessMixinArgument::Value(match name {
1233                                LessMixinParameterName::Variable(variable) => {
1234                                    ComponentValue::LessVariable(variable)
1235                                }
1236                                LessMixinParameterName::PropertyVariable(property_variable) => {
1237                                    ComponentValue::LessPropertyVariable(property_variable)
1238                                }
1239                            }));
1240                        }
1241                    }
1242                };
1243
1244                match input.cursor.peek()?.token {
1245                    Token::RParen(..) => {}
1246                    Token::Comma(..) => {
1247                        comma_spans.push(input.cursor.bump()?.span);
1248                    }
1249                    Token::Semicolon(..) => {
1250                        let TokenWithSpan { span, .. } = input.cursor.bump()?;
1251                        let comma_spans = mem::replace(&mut comma_spans, input.vec());
1252                        wrap_less_mixin_args_into_less_list(
1253                            input.allocator,
1254                            &mut args,
1255                            comma_spans,
1256                            semicolon_comes_at,
1257                        )
1258                        .map_err(|kind| Error { kind, span })?;
1259                        semicolon_comes_at = args.len();
1260                        semicolon_spans.push(span);
1261                    }
1262                    _ => {
1263                        let TokenWithSpan { token, span } = input.cursor.bump()?;
1264
1265                        return Err(Error {
1266                            kind: ErrorKind::Unexpected(")", token.symbol()),
1267                            span,
1268                        });
1269                    }
1270                }
1271            }
1272            let is_comma_separated = semicolon_spans.is_empty();
1273            let separator_spans =
1274                if semicolon_spans.is_empty() { comma_spans } else { semicolon_spans };
1275            debug_assert!(args.len() - separator_spans.len() <= 1);
1276            Some(LessMixinArguments {
1277                args,
1278                is_comma_separated,
1279                separator_spans,
1280                span: Span { start: lparen_span.start, end },
1281            })
1282        } else {
1283            None
1284        };
1285
1286        let important = if !matches!(
1287            input.state.qualified_rule_ctx,
1288            Some(QualifiedRuleContext::DeclarationValue)
1289        ) && matches!(input.cursor.peek()?.token, Token::Exclamation(..))
1290        {
1291            input.parse::<ImportantAnnotation>().map(Some)?
1292        } else {
1293            None
1294        };
1295
1296        let span = Span {
1297            start: callee.span.start,
1298            end: important.as_ref().map(|important| important.span.end).unwrap_or(end),
1299        };
1300        Ok(LessMixinCall { callee, args, important, span })
1301    }
1302}
1303
1304impl<'a> Parser<'a> {
1305    // Declared mixin parameters: ( <parameter> [ [ ',' | ';' ] <parameter> ]* )
1306    // <parameter> = <variable> [ ':' <default> ]? | <value> | <variable>... | ...
1307    fn parse_less_mixin_parameters(&mut self) -> PResult<LessMixinParameters<'a>> {
1308        let (_, lparen_span) = self.cursor.expect_l_paren()?;
1309        let rparen_span;
1310        let mut semicolon_comes_at = 0;
1311        let mut params = self.vec();
1312        let mut comma_spans = self.vec();
1313        let mut semicolon_spans = self.vec();
1314        'params: loop {
1315            match self.cursor.peek()?.token {
1316                Token::RParen(..) => {
1317                    rparen_span = self.cursor.bump()?.span;
1318                    break;
1319                }
1320                Token::DotDotDot(..) => {
1321                    let TokenWithSpan { span, .. } = self.cursor.bump()?;
1322                    params.push(LessMixinParameter::Variadic(LessMixinVariadicParameter {
1323                        name: None,
1324                        span,
1325                    }));
1326                    self.cursor.eat_semicolon()?;
1327                    (_, rparen_span) = self.cursor.expect_r_paren()?;
1328                    break;
1329                }
1330                Token::Comma(..) => {
1331                    return Err(Error {
1332                        kind: ErrorKind::ExpectComponentValue,
1333                        span: self.cursor.bump()?.span,
1334                    });
1335                }
1336                _ => 'maybe: {
1337                    let value = self
1338                        .with_state(ParserState {
1339                            less_ctx: self.state.less_ctx | LESS_CTX_ALLOW_DIV,
1340                            ..self.state.clone()
1341                        })
1342                        .parse::<ComponentValue>()?;
1343                    let name = {
1344                        match value {
1345                            ComponentValue::LessVariable(variable) => {
1346                                LessMixinParameterName::Variable(variable)
1347                            }
1348                            ComponentValue::LessPropertyVariable(property) => {
1349                                LessMixinParameterName::PropertyVariable(property)
1350                            }
1351                            value => {
1352                                let span = *value.span();
1353                                params.push(LessMixinParameter::Unnamed(
1354                                    LessMixinUnnamedParameter { value, span },
1355                                ));
1356                                break 'maybe;
1357                            }
1358                        }
1359                    };
1360                    let name_span = name.span();
1361                    if let Some((_, colon_span)) = self.cursor.eat_colon()? {
1362                        let value = if matches!(self.cursor.peek()?.token, Token::LBrace(..)) {
1363                            self.parse().map(ComponentValue::LessDetachedRuleset)?
1364                        } else {
1365                            self.with_state(ParserState {
1366                                less_ctx: self.state.less_ctx | LESS_CTX_ALLOW_DIV,
1367                                ..self.state.clone()
1368                            })
1369                            .parse_maybe_less_list(/* allow_comma */ false)?
1370                        };
1371                        let end = value.span().end;
1372                        let default_value = {
1373                            let span = Span { start: colon_span.start, end };
1374                            LessMixinNamedParameterDefaultValue { colon_span, value, span }
1375                        };
1376                        let span = Span { start: name_span.start, end };
1377                        params.push(LessMixinParameter::Named(LessMixinNamedParameter {
1378                            name,
1379                            value: Some(default_value),
1380                            span,
1381                        }));
1382                    } else if let Some((_, Span { end, .. })) = self.cursor.eat_dot_dot_dot()? {
1383                        let span = Span { start: name_span.start, end };
1384                        params.push(LessMixinParameter::Variadic(LessMixinVariadicParameter {
1385                            name: Some(name),
1386                            span,
1387                        }));
1388                        if let Some((_, semicolon_span)) = self.cursor.eat_semicolon()? {
1389                            semicolon_spans.push(semicolon_span);
1390                        };
1391                        (_, rparen_span) = self.cursor.expect_r_paren()?;
1392                        break 'params;
1393                    } else {
1394                        let span = *name_span;
1395                        params.push(LessMixinParameter::Named(LessMixinNamedParameter {
1396                            name,
1397                            value: None,
1398                            span,
1399                        }));
1400                    }
1401                }
1402            }
1403
1404            match &self.cursor.peek()?.token {
1405                Token::RParen(..) => {
1406                    let span = self.cursor.bump()?.span;
1407                    if semicolon_comes_at > 0 {
1408                        let comma_spans = mem::replace(&mut comma_spans, self.vec());
1409                        wrap_less_mixin_params_into_less_list(
1410                            self.allocator,
1411                            &mut params,
1412                            comma_spans,
1413                            semicolon_comes_at,
1414                        )
1415                        .map_err(|kind| Error {
1416                            kind,
1417                            span: Span {
1418                                // We've checked `semicolon_comes_at` must be greater than 0,
1419                                // so `params` won't be empty.
1420                                start: params.first().unwrap().span().start,
1421                                end: params.last().unwrap().span().end,
1422                            },
1423                        })?;
1424                    }
1425                    rparen_span = span;
1426                    break;
1427                }
1428                Token::Comma(..) => {
1429                    comma_spans.push(self.cursor.bump()?.span);
1430                }
1431                Token::Semicolon(..) => {
1432                    let span = self.cursor.bump()?.span;
1433                    let comma_spans = mem::replace(&mut comma_spans, self.vec());
1434                    wrap_less_mixin_params_into_less_list(
1435                        self.allocator,
1436                        &mut params,
1437                        comma_spans,
1438                        semicolon_comes_at,
1439                    )
1440                    .map_err(|kind| Error { kind, span })?;
1441                    semicolon_comes_at = params.len();
1442                    semicolon_spans.push(span);
1443                }
1444                // less.js also accepts space-separated parameters
1445                // (`.m(@a @b)`); each element becomes its own parameter
1446                _ => {}
1447            }
1448        }
1449        let is_comma_separated = semicolon_spans.is_empty();
1450        let separator_spans =
1451            if semicolon_spans.is_empty() { comma_spans } else { semicolon_spans };
1452        debug_assert!(separator_spans.len() <= params.len());
1453        Ok(LessMixinParameters {
1454            params,
1455            is_comma_separated,
1456            separator_spans,
1457            span: Span { start: lparen_span.start, end: rparen_span.end },
1458        })
1459    }
1460
1461    /// `.(@v; @i) { ... }` / `#(@v, @k, @i) { ... }` — an anonymous mixin,
1462    /// used as a callback argument to `each()` and friends.
1463    pub(super) fn parse_less_anonymous_mixin(&mut self) -> PResult<LessAnonymousMixin<'a>> {
1464        debug_assert_eq!(self.syntax, Syntax::Less);
1465
1466        let TokenWithSpan { token, span: head_span } = self.cursor.bump()?;
1467        if !matches!(token, Token::Dot(..) | Token::NumberSign(..))
1468            || self.cursor.peek()?.span.start != head_span.end
1469        {
1470            return Err(Error { kind: ErrorKind::TryParseError, span: head_span });
1471        }
1472        let params = self.parse_less_mixin_parameters()?;
1473        let block = self.parse::<SimpleBlock>()?;
1474        let span = Span { start: head_span.start, end: block.span.end };
1475        Ok(LessAnonymousMixin { params, block, span })
1476    }
1477}
1478
1479// A (possibly namespaced) mixin path: <mixin-name> [ '>' <mixin-name> ]*
1480impl<'a> Parse<'a> for LessMixinCallee<'a> {
1481    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1482        let first_name = input.parse::<LessMixinName>()?;
1483        let mut span = *first_name.span();
1484
1485        let mut children =
1486            input.vec1(LessMixinCalleeChild { name: first_name, combinator: None, span });
1487        loop {
1488            let combinator = input
1489                .cursor
1490                .eat_greater_than()?
1491                .map(|(_, span)| Combinator { kind: CombinatorKind::Child, span });
1492            let at_name = {
1493                let TokenWithSpan { token, span } = input.cursor.peek()?;
1494                matches!(token, Token::Dot(..) | Token::Hash(..))
1495                    || (matches!(token, Token::Dimension(..))
1496                        && input.source.as_bytes().get(span.start) == Some(&b'.'))
1497            };
1498            if at_name {
1499                let name = input.parse::<LessMixinName>()?;
1500                let name_span = name.span();
1501                let span = Span {
1502                    start: combinator
1503                        .as_ref()
1504                        .map(|combinator| combinator.span.start)
1505                        .unwrap_or(name_span.start),
1506                    end: name_span.end,
1507                };
1508                children.push(LessMixinCalleeChild { name, combinator, span });
1509            } else {
1510                break;
1511            }
1512        }
1513
1514        if let Some(last) = children.last() {
1515            span.end = last.span.end;
1516        }
1517        Ok(LessMixinCallee { children, span })
1518    }
1519}
1520
1521// A mixin definition: <mixin-name> ( <parameters> ) [ when <guard> ]? { <block> }
1522impl<'a> Parse<'a> for LessMixinDefinition<'a> {
1523    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1524        debug_assert_eq!(input.syntax, Syntax::Less);
1525
1526        let name = input.parse::<LessMixinName>()?;
1527
1528        let params = input.parse_less_mixin_parameters()?;
1529
1530        let guard = if input.cursor.peek()?.is_ident_raw(input.source, "when") {
1531            Some(input.parse()?)
1532        } else {
1533            None
1534        };
1535
1536        let block = input
1537            .with_state(ParserState {
1538                less_ctx: input.state.less_ctx | LESS_CTX_ALLOW_KEYFRAME_BLOCK,
1539                ..input.state.clone()
1540            })
1541            .parse::<SimpleBlock>()?;
1542
1543        let span = Span { start: name.span().start, end: block.span.end };
1544
1545        Ok(LessMixinDefinition { name, params, guard, block, span })
1546    }
1547}
1548
1549// <mixin-name> = <class-selector> | <id-selector>   ('.name' or '#name')
1550impl<'a> Parse<'a> for LessMixinName<'a> {
1551    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1552        match input.cursor.bump()? {
1553            // `.3D` — a digit-led name arrives as one <dimension-token>
1554            TokenWithSpan { token: Token::Dimension(..), span }
1555                if input.source.as_bytes().get(span.start) == Some(&b'.') =>
1556            {
1557                let name_span = Span { start: span.start + 1, end: span.end };
1558                let raw = &input.source[name_span.start..name_span.end];
1559                Ok(LessMixinName::ClassSelector(ClassSelector {
1560                    name: InterpolableIdent::Literal(Ident { name: raw, raw, span: name_span }),
1561                    span,
1562                }))
1563            }
1564            TokenWithSpan { token: Token::Dot(..), span: dot_span } => {
1565                let (ident, ident_span) =
1566                    input.cursor.expect_ident_without_ws_or_comments(false)?;
1567                let ident = input.ident(ident, ident_span);
1568                let span = Span { start: dot_span.start, end: ident.span.end };
1569                Ok(LessMixinName::ClassSelector(ClassSelector {
1570                    name: InterpolableIdent::Literal(ident),
1571                    span,
1572                }))
1573            }
1574            token @ TokenWithSpan { token: Token::Hash(..), span } => {
1575                let hash = token.hash(input.source).unwrap();
1576                let raw = hash.raw;
1577                if raw.starts_with(|c: char| c.is_ascii_digit())
1578                    || matches!(raw.as_bytes(), [b'-'] | [b'-', b'0'..=b'9', ..])
1579                {
1580                    input
1581                        .recoverable_errors
1582                        .push(Error { kind: ErrorKind::InvalidIdSelectorName, span });
1583                }
1584                let name =
1585                    if hash.escaped { util::handle_escape_in(raw, input.allocator) } else { raw };
1586                let name_span = Span { start: span.start + 1, end: span.end };
1587                Ok(LessMixinName::IdSelector(IdSelector {
1588                    name: InterpolableIdent::Literal(Ident { name, raw, span: name_span }),
1589                    span,
1590                }))
1591            }
1592            TokenWithSpan { token, span } => Err(Error {
1593                kind: ErrorKind::ExpectOneOf(vec![".", "<hash>"], token.symbol()),
1594                span,
1595            }),
1596        }
1597    }
1598}
1599
1600// <mixin-parameter-name> = @<var> | $<prop>
1601impl<'a> Parse<'a> for LessMixinParameterName<'a> {
1602    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1603        if matches!(input.cursor.peek()?.token, Token::AtKeyword(..)) {
1604            input.parse().map(LessMixinParameterName::Variable)
1605        } else {
1606            input.parse().map(LessMixinParameterName::PropertyVariable)
1607        }
1608    }
1609}
1610
1611// A namespaced value access: <callee> <lookups>   (e.g. `#ns.mixin()[@k]`)
1612impl<'a> Parse<'a> for LessNamespaceValue<'a> {
1613    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1614        let callee = input.parse::<LessNamespaceValueCallee>()?;
1615        let callee_span = callee.span();
1616
1617        let lookups = input.parse::<LessLookups>()?;
1618        util::assert_no_ws_or_comment(callee_span, &lookups.span)?;
1619
1620        let span = Span { start: callee_span.start, end: lookups.span.end };
1621        Ok(LessNamespaceValue { callee, lookups, span })
1622    }
1623}
1624
1625// <namespace-value-callee> = <less-variable> | <mixin-call>
1626impl<'a> Parse<'a> for LessNamespaceValueCallee<'a> {
1627    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1628        if matches!(input.cursor.peek()?.token, Token::AtKeyword(..)) {
1629            input.parse().map(LessNamespaceValueCallee::LessVariable)
1630        } else {
1631            input.parse().map(LessNamespaceValueCallee::LessMixinCall)
1632        }
1633    }
1634}
1635
1636// A negated value: '-' [ <variable> | <property-var> | ( <operation> ) ]
1637impl<'a> Parse<'a> for LessNegativeValue<'a> {
1638    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1639        let (_, minus_span) = input.cursor.expect_minus()?;
1640        let value = match input.cursor.peek()? {
1641            TokenWithSpan {
1642                token: Token::AtKeyword(..) | Token::At(..) | Token::DollarVar(..),
1643                span,
1644            } if minus_span.end == span.start => {
1645                let value = input.parse_component_value_atom()?;
1646                input.alloc(value)
1647            }
1648            TokenWithSpan { token: Token::LParen(..), span } if minus_span.end == span.start => {
1649                let value = ComponentValue::LessParenthesizedOperation(
1650                    input.parse_less_parenthesized_operation(/* allow_mixin_call */ true)?,
1651                );
1652                input.alloc(value)
1653            }
1654            TokenWithSpan { token, span } => {
1655                return Err(Error {
1656                    kind: ErrorKind::ExpectOneOf(vec!["<at-keyword>", "$var", "("], token.symbol()),
1657                    span: *span,
1658                });
1659            }
1660        };
1661
1662        let span = Span { start: minus_span.start, end: value.span().end };
1663        Ok(LessNegativeValue { value, span })
1664    }
1665}
1666
1667// The `%` keyword (e.g. the Less `percentage`/format context).
1668impl<'a> Parse<'a> for LessPercentKeyword {
1669    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1670        let (_, span) = input.cursor.expect_percent()?;
1671        Ok(LessPercentKeyword { span })
1672    }
1673}
1674
1675// https://lesscss.org/features/#plugin-atrules-feature
1676//
1677// @plugin [ ( <args> ) ]? <plugin-path>
1678impl<'a> Parse<'a> for LessPlugin<'a> {
1679    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1680        debug_assert_eq!(input.syntax, Syntax::Less);
1681
1682        let mut start = None;
1683
1684        let args = if let Some((_, span)) = input.cursor.eat_l_paren()? {
1685            start = Some(span.start);
1686            let args = input.parse_tokens_in_parens()?;
1687            input.cursor.expect_r_paren()?;
1688            Some(args)
1689        } else {
1690            None
1691        };
1692
1693        let path = input.parse::<LessPluginPath>()?;
1694        let path_span = path.span();
1695
1696        let span = Span { start: start.unwrap_or(path_span.start), end: path_span.end };
1697        Ok(LessPlugin { path, args, span })
1698    }
1699}
1700
1701// <plugin-path> = <string> | <url>
1702impl<'a> Parse<'a> for LessPluginPath<'a> {
1703    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1704        if let Token::Str(..) = input.cursor.peek()?.token {
1705            input.parse().map(LessPluginPath::Str)
1706        } else {
1707            input.parse().map(LessPluginPath::Url)
1708        }
1709    }
1710}
1711
1712// A property-as-variable interpolation: '${' <ident> '}'
1713impl<'a> Parse<'a> for LessPropertyInterpolation<'a> {
1714    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1715        let (dollar_lbrace_var, span) = input.cursor.expect_dollar_l_brace_var()?;
1716        Ok(LessPropertyInterpolation {
1717            name: input
1718                .ident(dollar_lbrace_var.ident, Span { start: span.start + 2, end: span.end - 1 }),
1719            span,
1720        })
1721    }
1722}
1723
1724// https://lesscss.org/features/#merge-feature
1725//
1726// A property-merge flag after a property name: '+' (comma) | '+_' (space)
1727impl<'a> Parse<'a> for Option<LessPropertyMerge> {
1728    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1729        debug_assert!(matches!(input.syntax, Syntax::Less | Syntax::Css));
1730
1731        match &input.cursor.peek()?.token {
1732            Token::Plus(..) => Ok(Some(LessPropertyMerge {
1733                kind: LessPropertyMergeKind::Comma,
1734                span: input.cursor.bump()?.span,
1735            })),
1736            Token::PlusUnderscore(..) => Ok(Some(LessPropertyMerge {
1737                kind: LessPropertyMergeKind::Space,
1738                span: input.cursor.bump()?.span,
1739            })),
1740            _ => Ok(None),
1741        }
1742    }
1743}
1744
1745// https://lesscss.org/features/#variables-feature-properties-as-variables
1746//
1747// A property accessor: '$' <ident>
1748impl<'a> Parse<'a> for LessPropertyVariable<'a> {
1749    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1750        let (dollar_var, span) = input.cursor.expect_dollar_var()?;
1751        Ok(LessPropertyVariable {
1752            name: input.ident(dollar_var.ident, Span { start: span.start + 1, end: span.end }),
1753            span,
1754        })
1755    }
1756}
1757
1758// https://lesscss.org/features/#variables-feature
1759//
1760// A variable reference: '@' <ident>
1761impl<'a> Parse<'a> for LessVariable<'a> {
1762    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1763        let (at_keyword, span) = input.cursor.expect_at_keyword()?;
1764        Ok(LessVariable {
1765            name: input.ident(at_keyword.ident, Span { start: span.start + 1, end: span.end }),
1766            span,
1767        })
1768    }
1769}
1770
1771// A detached-ruleset call: '@' <ident> '(' ')'
1772impl<'a> Parse<'a> for LessVariableCall<'a> {
1773    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1774        let variable = input.parse::<LessVariable>()?;
1775        input.cursor.expect_l_paren_without_ws_or_comments()?;
1776        let (_, Span { end, .. }) = input.cursor.expect_r_paren()?;
1777
1778        let span = Span { start: variable.span.start, end };
1779        Ok(LessVariableCall { variable, span })
1780    }
1781}
1782
1783// A variable declaration: '@' <ident> ':' <value>
1784impl<'a> Parse<'a> for LessVariableDeclaration<'a> {
1785    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1786        debug_assert_eq!(input.syntax, Syntax::Less);
1787
1788        let name = input.parse::<LessVariable>()?;
1789        let (_, colon_span) = input.cursor.expect_colon()?;
1790        let value = if matches!(input.cursor.peek()?.token, Token::LBrace(..)) {
1791            ComponentValue::LessDetachedRuleset(input.parse()?)
1792        } else {
1793            let typed = input.try_parse(|p| {
1794                let value = p
1795                    .with_state(ParserState {
1796                        less_ctx: p.state.less_ctx | LESS_CTX_ALLOW_DIV,
1797                        ..p.state.clone()
1798                    })
1799                    .parse_maybe_less_list(/* allow_comma */ true)?;
1800                // The declaration must account for everything up to a
1801                // statement boundary — `@page :first {` is an at-rule, not a
1802                // variable named `@page` with the value `first`.
1803                if !matches!(
1804                    &p.cursor.peek()?.token,
1805                    Token::Semicolon(..) | Token::RBrace(..) | Token::Eof(..)
1806                ) {
1807                    let span = p.cursor.peek()?.span;
1808                    return Err(Error { kind: ErrorKind::TryParseError, span });
1809                }
1810                Ok(value)
1811            });
1812            match typed {
1813                Ok(value) => value,
1814                Err(error) => {
1815                    // less.js `permissiveValue`: a variable's value may be any
1816                    // balanced token run (`@this: () => { ... };`), but only
1817                    // when explicitly terminated by `;` — otherwise
1818                    // `@page :first { ... }` would be swallowed too.
1819                    let start = input.cursor.peek()?.span.start;
1820                    let values = input
1821                        .parse_declaration_value_tokens(/* stop_at_top_level_brace */ false)?;
1822                    let end = values.last().map(|v| v.span().end);
1823                    if !matches!(input.cursor.peek()?.token, Token::Semicolon(..)) {
1824                        return Err(error);
1825                    }
1826                    let Some(end) = end else {
1827                        return Err(error);
1828                    };
1829                    ComponentValue::LessList(LessList {
1830                        elements: values,
1831                        comma_spans: None,
1832                        span: Span { start, end },
1833                    })
1834                }
1835            }
1836        };
1837        let span = Span { start: name.span.start, end: value.span().end };
1838        Ok(LessVariableDeclaration { name, colon_span, value, span })
1839    }
1840}
1841
1842// A variable interpolation: '@{' <ident> '}'
1843impl<'a> Parse<'a> for LessVariableInterpolation<'a> {
1844    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1845        let (at_lbrace_var, span) = input.cursor.expect_at_l_brace_var()?;
1846        Ok(LessVariableInterpolation {
1847            name: input
1848                .ident(at_lbrace_var.ident, Span { start: span.start + 2, end: span.end - 1 }),
1849            span,
1850        })
1851    }
1852}
1853
1854// A variable-variable (indirection): '@@' <ident>
1855impl<'a> Parse<'a> for LessVariableVariable<'a> {
1856    fn parse(input: &mut Parser<'a>) -> PResult<Self> {
1857        let (_, at_span) = input.cursor.expect_at()?;
1858        let variable = input.parse::<LessVariable>()?;
1859        util::assert_no_ws_or_comment(&at_span, &variable.span)?;
1860
1861        let span = Span { start: at_span.start, end: variable.span.end };
1862        Ok(LessVariableVariable { variable, span })
1863    }
1864}
1865
1866fn wrap_less_mixin_params_into_less_list<'a>(
1867    allocator: &'a oxc_allocator::Allocator,
1868    params: &mut oxc_allocator::Vec<'a, LessMixinParameter<'a>>,
1869    comma_spans: oxc_allocator::Vec<'a, Span>,
1870    index: usize,
1871) -> Result<(), ErrorKind> {
1872    if let [first, .., last] = &params[index..] {
1873        let span = Span { start: first.span().start, end: last.span().end };
1874        // `@margin: 2, 2, 2, 2; ...` — a named parameter followed by plain
1875        // values is one parameter whose default is the comma list. Two named
1876        // parameters in one comma group stay rejected
1877        // (`.mixin(@a: 5, @b: 6; @c: 7)`), as in less.js.
1878        let named_head = matches!(
1879            &params[index],
1880            LessMixinParameter::Named(LessMixinNamedParameter { value: Some(..), .. })
1881        ) && params.len() - index > 1;
1882        let mut drained = params.drain(index..);
1883        let head = if named_head { drained.next() } else { None };
1884        let mut elements = oxc_allocator::Vec::with_capacity_in(drained.len() + 1, &allocator);
1885        let head = match head {
1886            Some(LessMixinParameter::Named(LessMixinNamedParameter {
1887                name,
1888                value: Some(default),
1889                ..
1890            })) => {
1891                elements.push(default.value);
1892                Some((name, default.colon_span))
1893            }
1894            _ => None,
1895        };
1896        for param in drained {
1897            if let LessMixinParameter::Unnamed(LessMixinUnnamedParameter { value, .. }) = param {
1898                elements.push(value);
1899            } else {
1900                // reject code like this:
1901                // .mixin(@a: 5, @b: 6; @c: 7) {}
1902                // .mixin(@a: 5; @b: 6, @c: 7) {}
1903                return Err(ErrorKind::MixedDelimiterKindInLessMixin);
1904            }
1905        }
1906        debug_assert!(comma_spans.len() < elements.len());
1907        let list_span =
1908            Span { start: elements.first().map_or(span.start, |v| v.span().start), end: span.end };
1909        let list = ComponentValue::LessList(LessList {
1910            elements,
1911            comma_spans: Some(comma_spans),
1912            span: list_span,
1913        });
1914        params.push(match head {
1915            Some((name, colon_span)) => LessMixinParameter::Named(LessMixinNamedParameter {
1916                span: Span { start: name.span().start, end: span.end },
1917                name,
1918                value: Some(LessMixinNamedParameterDefaultValue {
1919                    colon_span,
1920                    value: list,
1921                    span: list_span,
1922                }),
1923            }),
1924            None => LessMixinParameter::Unnamed(LessMixinUnnamedParameter { value: list, span }),
1925        });
1926    }
1927    Ok(())
1928}
1929
1930fn wrap_less_mixin_args_into_less_list<'a>(
1931    allocator: &'a oxc_allocator::Allocator,
1932    args: &mut oxc_allocator::Vec<'a, LessMixinArgument<'a>>,
1933    comma_spans: oxc_allocator::Vec<'a, Span>,
1934    index: usize,
1935) -> Result<(), ErrorKind> {
1936    if let [first, .., last] = &args[index..] {
1937        let span = Span { start: first.span().start, end: last.span().end };
1938        // `@a : d, e; @b : f` — a named argument followed by plain values is
1939        // one named argument whose value is the comma list; its trailing
1940        // values fold into it. Two named arguments in one comma group stay
1941        // rejected (`.mixin(@a: 5, @b: 6; @c: 7)`), as in less.js.
1942        let named_head =
1943            matches!(&args[index], LessMixinArgument::Named(..)) && args.len() - index > 1;
1944        let mut drained = args.drain(index..);
1945        let head = if named_head { drained.next() } else { None };
1946        let mut elements = oxc_allocator::Vec::with_capacity_in(drained.len() + 1, &allocator);
1947        let mut head = match head {
1948            Some(LessMixinArgument::Named(named)) => {
1949                elements.push(named.value);
1950                Some((named.name, named.colon_span))
1951            }
1952            _ => None,
1953        };
1954        for arg in drained {
1955            if let LessMixinArgument::Value(value) = arg {
1956                elements.push(value);
1957            } else {
1958                // reject code like this:
1959                // .mixin(@a: 5, @b: 6; @c: 7) {}
1960                // .mixin(@a: 5; @b: 6, @c: 7) {}
1961                return Err(ErrorKind::MixedDelimiterKindInLessMixin);
1962            }
1963        }
1964        debug_assert!(comma_spans.len() < elements.len());
1965        let list_span =
1966            Span { start: elements.first().map_or(span.start, |v| v.span().start), end: span.end };
1967        let list = ComponentValue::LessList(LessList {
1968            elements,
1969            comma_spans: Some(comma_spans),
1970            span: list_span,
1971        });
1972        args.push(match head.take() {
1973            Some((name, colon_span)) => LessMixinArgument::Named(LessMixinNamedArgument {
1974                span: Span { start: name.span().start, end: span.end },
1975                name,
1976                colon_span,
1977                value: list,
1978            }),
1979            None => LessMixinArgument::Value(list),
1980        });
1981    }
1982    Ok(())
1983}
1984
1985fn can_be_division_operand(left: &ComponentValue) -> bool {
1986    matches!(
1987        left,
1988        ComponentValue::LessVariable(..)
1989            | ComponentValue::LessPropertyVariable(..)
1990            | ComponentValue::LessBinaryOperation(..)
1991            | ComponentValue::LessParenthesizedOperation(..)
1992    )
1993}
1994
1995/// Whether the byte at `pos` in `source` is ASCII whitespace. Used to tell a
1996/// `+`/`-` binary operator (followed by whitespace) from a value sign.
1997fn is_followed_by_whitespace(source: &str, pos: usize) -> bool {
1998    source.as_bytes().get(pos).is_some_and(u8::is_ascii_whitespace)
1999}