Skip to content

Commit c58808f

Browse files
authored
feat: deprecate bare @variable in non-value at-rule positions (#4462)
* feat: deprecate bare @variable in non-value at-rule positions Bare @var in at-rule preludes, names, and identifiers is deprecated in favour of @{foo} interpolation; the bare form still resolves, so this is a warning only (id: variable-in-at-rule-prelude, respects --quiet-deprecations and the repetition cap). Covered positions: - @media / @container feature preludes - @supports / @document / unknown & custom at-rule preludes - @Keyframes / @counter-style / @charset identifiers - @layer names and lists - @namespace prefix @{foo} interpolation is now accepted in these positions as the migration target (previously it errored in most of them). A bare @var in a nested declaration value -- e.g. @supports (display: @v) or @media (min-width: @v) -- is NOT deprecated: it is a declaration value and stays valid, detected via paren-depth awareness so parsing and output are unchanged. Value-position parsing is otherwise untouched: @var works, @{var} is not newly accepted in top-level declaration values. Migrates existing fixtures to @{var} and adds a dedicated fixture locking in backward-compatible resolution of the bare form. * fix: also deprecate @@variable-variable prefix in @namespace The @namespace prefix lookahead used `@[\w-]`, which misses an indirect `@@ref` (variable-variable) reference — entities.variable() accepts `@@name`, so `@namespace @@ref "..."` fell through to expression() and resolved without the deprecation warning. Widen the lookahead to `@@?[\w-]` so @@-prefixes hit the same warning path. Adds fixture coverage.
1 parent 8ae2cc3 commit c58808f

9 files changed

Lines changed: 267 additions & 25 deletions

File tree

packages/less/lib/less/deprecation.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ const deprecations = {
1717
description: 'The ./ operator is deprecated.'
1818
},
1919
'variable-in-unknown-value': {
20-
description: '@[ident] in custom property values is treated as literal text.'
20+
description: '@variable in custom property values is treated as literal text.'
21+
},
22+
'variable-in-at-rule-prelude': {
23+
description: 'A bare @variable in an at-rule prelude (e.g. @media @foo) is deprecated. Use @{variable} interpolation instead.'
2124
},
2225
'property-in-unknown-value': {
23-
description: '$[ident] in custom property values is treated as literal text.'
26+
description: '$property in custom property values is treated as literal text.'
2427
},
2528
'js-eval': {
2629
description: 'Inline JavaScript evaluation (backtick expressions) is deprecated and will be removed in Less 5.x.'

packages/less/lib/less/parser/parser.js

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
6262

6363
const deprecationHandler = new DeprecationHandler();
6464

65+
// Tracks `${deprecationId}@${index}` pairs already warned about, so a source
66+
// position that gets re-parsed via parser backtracking only warns once.
67+
const warnedDeprecations = new Set();
68+
6569
/**
6670
* @param {string} msg
6771
* @param {number} index
@@ -71,6 +75,11 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
7175
function warn(msg, index, type, deprecationId) {
7276
if (context.quiet) { return; }
7377
if (deprecationId && context.quietDeprecations) { return; }
78+
if (deprecationId) {
79+
const key = `${deprecationId}@${index ?? parserInput.i}`;
80+
if (warnedDeprecations.has(key)) { return; }
81+
warnedDeprecations.add(key);
82+
}
7483
if (deprecationId && !deprecationHandler.shouldWarn(deprecationId)) { return; }
7584

7685
logger.warn(
@@ -86,6 +95,42 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
8695
);
8796
}
8897

98+
/**
99+
* Warn that a bare `@variable` reference is being used in a non-value
100+
* position (an at-rule prelude, name, or identifier), where it still
101+
* resolves today but is deprecated in favour of `@{variable}` interpolation.
102+
*
103+
* @param {number} index - source position of the bare reference
104+
*/
105+
function warnBareAtRuleVariable(index) {
106+
warn('A bare @variable in an at-rule prelude is deprecated. Use @{variable} interpolation instead.', index, 'DEPRECATED', 'variable-in-at-rule-prelude');
107+
}
108+
109+
/**
110+
* Whether `text` contains a bare `@variable` at the top level — i.e. outside
111+
* any `(...)` group. A `@variable` inside parentheses is a declaration value
112+
* (e.g. the `@v` in `@supports (display: @v)`) and remains valid; only a bare
113+
* `@variable` in a structural position is deprecated.
114+
*
115+
* @param {string} text
116+
* @returns {boolean}
117+
*/
118+
function hasTopLevelBareVariable(text) {
119+
let depth = 0;
120+
for (let j = 0; j < text.length; j++) {
121+
const c = text.charAt(j);
122+
if (c === '(') {
123+
depth++;
124+
} else if (c === ')') {
125+
if (depth > 0) { depth--; }
126+
} else if (c === '@' && depth === 0) {
127+
// a bare `@ident`, not `@{ident}` interpolation
128+
if (/[\w-]/.test(text.charAt(j + 1))) { return true; }
129+
}
130+
}
131+
return false;
132+
}
133+
89134
function expect(arg, msg) {
90135
// some older browsers return typeof 'function' for RegExp
91136
const result = (arg instanceof Function) ? arg.call(parsers) : parserInput.$re(arg);
@@ -1636,7 +1681,7 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
16361681
if (parserInput.$char(';')) {
16371682
value = new Anonymous('');
16381683
} else {
1639-
value = this.permissiveValue(/[;}]/, true);
1684+
value = this.permissiveValue(/[;}]/);
16401685
}
16411686
}
16421687
// Try to store values as anonymous
@@ -1695,8 +1740,12 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
16951740
* math is allowed.
16961741
*
16971742
* @param {RexExp} untilTokens - Characters to stop parsing at
1743+
* @param {boolean} [deprecateVariables] - when set, this is an at-rule
1744+
* prelude (non-value position); accept `@{var}` interpolation and warn
1745+
* on a bare `@var` reference (which resolves today but is deprecated).
16981746
*/
1699-
permissiveValue: function (untilTokens) {
1747+
permissiveValue: function (untilTokens, deprecateVariables) {
1748+
const entities = this.entities;
17001749
let i;
17011750
let e;
17021751
let done;
@@ -1723,7 +1772,20 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
17231772
value.push(e);
17241773
continue;
17251774
}
1726-
e = this.entity();
1775+
if (deprecateVariables) {
1776+
// In an at-rule prelude, `@{var}` interpolation is the supported
1777+
// form; consume it here so its `{` is not mistaken for a block.
1778+
e = entities.variableCurly();
1779+
if (!e) {
1780+
const varIndex = parserInput.i;
1781+
e = this.entity();
1782+
if (e && e.type === 'Variable') {
1783+
warnBareAtRuleVariable(varIndex);
1784+
}
1785+
}
1786+
} else {
1787+
e = this.entity();
1788+
}
17271789
if (e) {
17281790
value.push(e);
17291791
}
@@ -1776,11 +1838,18 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
17761838
const quote = new tree.Quoted('\'', item, true, index, fileInfo);
17771839
const variableRegex = /@([\w-]+)/g;
17781840
const propRegex = /\$([\w-]+)/g;
1779-
if (variableRegex.test(item)) {
1780-
warn('@[ident] in unknown values will not be evaluated as variables in the future. Use @{[ident]}', index, 'DEPRECATED', 'variable-in-unknown-value');
1841+
if (deprecateVariables) {
1842+
// At-rule prelude: only a bare @var in a structural
1843+
// (top-level) position is deprecated; @vars inside
1844+
// `(...)` are declaration values and stay valid.
1845+
if (hasTopLevelBareVariable(item)) {
1846+
warnBareAtRuleVariable(index);
1847+
}
1848+
} else if (variableRegex.test(item)) {
1849+
warn('@variable in unknown values will not be evaluated as variables in the future. Use @{variable}', index, 'DEPRECATED', 'variable-in-unknown-value');
17811850
}
17821851
if (propRegex.test(item)) {
1783-
warn('$[ident] in unknown values will not be evaluated as property references in the future. Use ${[ident]}', index, 'DEPRECATED', 'property-in-unknown-value');
1852+
warn('$property in unknown values will not be evaluated as property references in the future. Use ${property}', index, 'DEPRECATED', 'property-in-unknown-value');
17841853
}
17851854
quote.variableRegex = /@([\w-]+)|@{([\w-]+)}/g;
17861855
quote.propRegex = /\$([\w-]+)|\${([\w-]+)}/g;
@@ -1889,7 +1958,17 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
18891958
}
18901959
parserInput.restore();
18911960

1892-
e = entities.declarationCall.bind(this)() || cssKeyword() || entities.keyword() || entities.variable() || entities.mixinLookup()
1961+
e = entities.declarationCall.bind(this)() || cssKeyword() || entities.keyword() || entities.variableCurly();
1962+
if (!e) {
1963+
const varIndex = parserInput.i;
1964+
const bareVariable = entities.variable();
1965+
if (bareVariable) {
1966+
warnBareAtRuleVariable(varIndex);
1967+
e = bareVariable;
1968+
} else {
1969+
e = entities.mixinLookup();
1970+
}
1971+
}
18931972
if (e) {
18941973
nodes.push(e);
18951974
if (e.type === 'Variable' ||
@@ -1980,7 +2059,17 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
19802059
features[features.length - 1].noSpacing = false;
19812060
}
19822061
} else {
1983-
e = entities.variable() || entities.mixinLookup();
2062+
e = entities.variableCurly();
2063+
if (!e) {
2064+
const varIndex = parserInput.i;
2065+
const bareVariable = entities.variable();
2066+
if (bareVariable) {
2067+
warnBareAtRuleVariable(varIndex);
2068+
e = bareVariable;
2069+
} else {
2070+
e = entities.mixinLookup();
2071+
}
2072+
}
19842073
if (e) {
19852074
features.push(e);
19862075
if (!parserInput.$char(',')) { break; }
@@ -2094,8 +2183,24 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
20942183
return null;
20952184
}
20962185
},
2186+
/**
2187+
* An entity in a non-value at-rule position (an at-rule identifier,
2188+
* name, or keyword-list item — e.g. the name in `@keyframes @foo`).
2189+
* `@{foo}` interpolation is the supported form; a bare `@foo` still
2190+
* resolves but is deprecated.
2191+
*/
2192+
atRuleEntity: function () {
2193+
const curly = this.entities.variableCurly();
2194+
if (curly) { return curly; }
2195+
const index = parserInput.i;
2196+
const e = this.entity();
2197+
if (e && e.type === 'Variable') {
2198+
warnBareAtRuleVariable(index);
2199+
}
2200+
return e;
2201+
},
20972202
atruleUnknown: function (value, name, hasBlock) {
2098-
value = this.permissiveValue(/^[{;]/);
2203+
value = this.permissiveValue(/^[{;]/, true);
20992204
hasBlock = (parserInput.currentChar() === '{');
21002205
if (!value) {
21012206
if (!hasBlock && parserInput.currentChar() !== ';') {
@@ -2111,16 +2216,16 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
21112216
rules = this.blockRuleset();
21122217
parserInput.save();
21132218
if (!rules && !isRooted) {
2114-
value = this.entity();
2219+
value = this.atRuleEntity();
21152220
rules = this.blockRuleset();
21162221
}
21172222
if (!rules && !isRooted) {
21182223
parserInput.restore();
21192224
var e = [];
2120-
value = this.entity();
2225+
value = this.atRuleEntity();
21212226
while (parserInput.$char(',')) {
21222227
e.push(value);
2123-
value = this.entity();
2228+
value = this.atRuleEntity();
21242229
}
21252230
if (value && e.length > 0) {
21262231
e.push(value);
@@ -2205,12 +2310,25 @@ const Parser = function Parser(context, imports, fileInfo, currentIndex) {
22052310
parserInput.commentStore.length = 0;
22062311

22072312
if (hasIdentifier) {
2208-
value = this.entity();
2313+
value = this.atRuleEntity();
22092314
if (!value) {
22102315
error(`expected ${name} identifier`);
22112316
}
22122317
} else if (hasExpression) {
2318+
// `@namespace` may carry an interpolated `@{ns}` prefix (or a
2319+
// deprecated bare `@ns`). Parse that prefix directly so `@{ns}`
2320+
// is accepted here without treating value positions as
2321+
// interpolation contexts, then read the namespace URL.
2322+
let prefix = this.entities.variableCurly();
2323+
if (!prefix && parserInput.peek(/^@@?[\w-]/)) {
2324+
const prefixIndex = parserInput.i;
2325+
prefix = this.entities.variable();
2326+
if (prefix) { warnBareAtRuleVariable(prefixIndex); }
2327+
}
22132328
value = this.expression();
2329+
if (prefix) {
2330+
value = value ? new(tree.Expression)([prefix, ...value.value]) : prefix;
2331+
}
22142332
if (!value) {
22152333
error(`expected ${name} expression`);
22162334
}

packages/test-data/tests-config/at-rules-compressed-evaluation/at-rules-compressed-evaluation.less

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
// Test eval with value evaluation and keywordList conversion
2727
@breakpoint: screen;
28-
@media @breakpoint, print {
28+
@media @{breakpoint}, print {
2929
body {
3030
margin: 0;
3131
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
@media only screen and (max-width: 200px) {
2+
.body {
3+
width: 480px;
4+
}
5+
}
6+
@media all and (tv) {
7+
.all-and-tv {
8+
var: yes;
9+
}
10+
}
11+
@media screen, print {
12+
.list {
13+
margin: 0;
14+
}
15+
}
16+
@container foo (min-width: 400px) {
17+
.sticky-child {
18+
font-size: 75%;
19+
}
20+
}
21+
@supports (display: flex) {
22+
.flex {
23+
display: flex;
24+
}
25+
}
26+
@supports (display: grid) and (gap: 1rem) {
27+
.grid {
28+
display: grid;
29+
}
30+
}
31+
@keyframes enlarger {
32+
from {
33+
font-size: 12px;
34+
}
35+
to {
36+
font-size: 15px;
37+
}
38+
}
39+
@namespace less "http://lesscss.org";
40+
@namespace svgns "http://www.w3.org/2000/svg";
41+
@layer base {
42+
.layered {
43+
color: red;
44+
}
45+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Backward-compatibility coverage for the deprecated bare `@variable`
2+
// reference in non-value positions (at-rule preludes, names, and identifiers).
3+
// These still resolve, but emit a `variable-in-at-rule-prelude` deprecation
4+
// warning at parse time. New code should use `@{variable}` interpolation
5+
// instead (see media.less / variables-in-at-rules.less).
6+
7+
// --- nestable at-rule preludes ---
8+
@smartphone: ~"only screen and (max-width: 200px)";
9+
@media @smartphone {
10+
.body {
11+
width: 480px;
12+
}
13+
}
14+
15+
@all: ~"all";
16+
@tv: ~"(tv)";
17+
@media @all and @tv {
18+
.all-and-tv {
19+
var: yes;
20+
}
21+
}
22+
23+
@breakpoint: screen;
24+
@media @breakpoint, print {
25+
.list {
26+
margin: 0;
27+
}
28+
}
29+
30+
@varfoo: foo;
31+
@threshold: 400px;
32+
@container @varfoo (min-width: @threshold) {
33+
.sticky-child {
34+
font-size: 75%;
35+
}
36+
}
37+
38+
// --- unknown at-rule prelude (structural bare variable — deprecated) ---
39+
@supported: ~"(display: flex)";
40+
@supports @supported {
41+
.flex {
42+
display: flex;
43+
}
44+
}
45+
46+
// A bare @variable in a *nested declaration value* (inside `(...)`) is NOT a
47+
// structural position — it is a declaration value and remains valid without a
48+
// deprecation warning, mirroring `@media (min-width: @var)`.
49+
@disp: grid;
50+
@supports (display: @disp) and (gap: 1rem) {
51+
.grid {
52+
display: grid;
53+
}
54+
}
55+
56+
// --- at-rule identifiers / names ---
57+
@anim: enlarger;
58+
@keyframes @anim {
59+
from { font-size: 12px; }
60+
to { font-size: 15px; }
61+
}
62+
63+
@ns: less;
64+
@namespace @ns "http://lesscss.org";
65+
66+
// indirect (variable-variable) prefix — also a deprecated bare reference
67+
@ns-ref: svg;
68+
@svg: svgns;
69+
@namespace @@ns-ref "http://www.w3.org/2000/svg";
70+
71+
@layer-name: base;
72+
@layer @layer-name {
73+
.layered {
74+
color: red;
75+
}
76+
}

0 commit comments

Comments
 (0)