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