Skip to content

Interpolation @{var} in an at-rule prelude fails to parse (@media @{mq-xs}) #180

Description

@dshovchko
  • Node Version: 24.19.0
  • NPM Version: 11.17.0
  • postcss Version: 8.5.26
  • postcss-less Version: 6.0.0

Variable interpolation in an at-rule prelude - e.g. @media @{mq-xs} - throws CssSyntaxError: Unknown word. The same interpolation works fine in a selector position (@{mq-xs} { ... }), so the gap looks specific to at-rules.

LESS

@media @{mq-xs} { .a { color: red } }
@media @{mq-xs} and (min-width: 5px) { .a { color: red } }

/* these two parse fine today */
@media @mq-xs { .a { color: red } }
@{mq-xs} { .a { color: red } }

JavaScript

const postcssLess = require('postcss-less');

const samples = [
  '@media @{mq-xs} { .a { color: red } }',
  '@media @{mq-xs} and (min-width: 5px) { .a { color: red } }',
  '@media @mq-xs { .a { color: red } }',
  '@{mq-xs} { .a { color: red } }'
];

for (const css of samples) {
  try {
    const root = postcssLess.parse(css, { from: 'test.less' });
    console.log('OK    ', css, '->', JSON.stringify(root.first.params ?? root.first.selector));
  } catch (error) {
    console.log('THROWS', css, '->', error.message.split('\n')[0]);
  }
}

Errors

THROWS @media @{mq-xs} { .a { color: red } }
        -> test.less:1:10: Unknown word mq-xs
THROWS @media @{mq-xs} and (min-width: 5px) { .a { color: red } }
        -> test.less:1:10: Unknown word mq-xs
OK     @media @mq-xs { .a { color: red } } -> "@mq-xs"
OK     @{mq-xs} { .a { color: red } } -> "@{mq-xs}"

Expected Behavior

@media @{mq-xs} { ... } parses into an AtRule with name === 'media' and params === '@{mq-xs}', mirroring how the already-supported selector case @{mq-xs} { ... } is handled.

Actual Behavior

A CssSyntaxError is thrown.

How can we reproduce the behavior?

mkdir repro && cd repro
npm init -y && npm install postcss-less@6.0.0
# save the JavaScript snippet above as repro.js
node repro.js

Analysis

The tokenizer emits the interpolation as three separate tokens:

at-word "@media" | space | at-word "@" | { | word "mq-xs" | } | space | {

PostCSS's Parser#atrule only tracks ( and [ in its brackets stack, so on the interpolation's { it takes brackets.length === 0open = true; break. The prelude is cut short and mq-xs ends up parsed as an unknown word inside the block.

LessParser#atrule calls interpolation() only on the at-rule's first token, which returns false for @media (length > 1), so interpolations in the prelude are never merged. LessParser#rule already works around the same problem for selectors - this is the missing at-rule counterpart.

A fix that merges @{...} token sequences in the prelude before delegating to super.atrule() resolves all cases above with no regressions on my side (@import, @plugin, variable declarations, mixin calls, detached rulesets, @supports, @media with variables inside features, and @@var indirection all still round-trip). Happy to open a PR with tests if that's welcome.

Why this matters now

Less >=4.7.x deprecates the bare form and points users at exactly the syntax that cannot be parsed:

DEPRECATED WARNING: A bare @variable in an at-rule prelude is deprecated.
Use @{variable} interpolation instead.

So any codebase migrating off the deprecation becomes unparseable by postcss-less, and therefore un-lintable by stylelint. In our case that's ~1800 occurrences across ~900 files.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions