@jesscss/less-parser 2.0.0-alpha.8 → 2.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +27 -10
  2. package/lib/ast/grammar.d.ts +2 -0
  3. package/lib/ast/grammar.d.ts.map +1 -0
  4. package/lib/grammar.cjs +39855 -31054
  5. package/lib/grammar.d.ts.map +1 -1
  6. package/lib/grammar.js +39855 -31054
  7. package/lib/index.cjs +49609 -15
  8. package/lib/index.d.ts +4 -9
  9. package/lib/index.d.ts.map +1 -1
  10. package/lib/index.js +49607 -5
  11. package/lib/parse-error.d.ts +15 -0
  12. package/lib/parse-error.d.ts.map +1 -0
  13. package/package.json +9 -24
  14. package/lib/builders.d.ts +0 -381
  15. package/lib/builders.d.ts.map +0 -1
  16. package/lib/functional-parser.cjs +0 -2654
  17. package/lib/functional-parser.d.ts +0 -18
  18. package/lib/functional-parser.d.ts.map +0 -1
  19. package/lib/functional-parser.js +0 -2589
  20. package/lib/jess.cjs +0 -3968
  21. package/lib/jess.d.ts +0 -7
  22. package/lib/jess.d.ts.map +0 -1
  23. package/lib/jess.js +0 -3962
  24. package/lib/lessParser.d.ts +0 -38
  25. package/lib/lessParser.d.ts.map +0 -1
  26. package/lib/lessRecursiveParser.d.ts +0 -99
  27. package/lib/lessRecursiveParser.d.ts.map +0 -1
  28. package/lib/lessTokens.d.ts +0 -23
  29. package/lib/lessTokens.d.ts.map +0 -1
  30. package/lib/productions/guards.d.ts +0 -113
  31. package/lib/productions/guards.d.ts.map +0 -1
  32. package/lib/productions/index.d.ts +0 -5
  33. package/lib/productions/index.d.ts.map +0 -1
  34. package/lib/productions/root.d.ts +0 -38
  35. package/lib/productions/root.d.ts.map +0 -1
  36. package/lib/productions/selectors.d.ts +0 -41
  37. package/lib/productions/selectors.d.ts.map +0 -1
  38. package/lib/productions/values.d.ts +0 -35
  39. package/lib/productions/values.d.ts.map +0 -1
  40. package/lib/utils.d.ts +0 -9
  41. package/lib/utils.d.ts.map +0 -1
  42. package/src/__tests__/debug-log.ts +0 -35
  43. package/src/__tests__/wall5-parse.test.ts +0 -67
  44. package/src/builders.ts +0 -3190
  45. package/src/cst.ts +0 -25
  46. package/src/functional-parser.ts +0 -162
  47. package/src/grammar.ts +0 -870
  48. package/src/index.ts +0 -19
  49. package/src/jess.ts +0 -6
  50. package/src/lessParser.ts +0 -120
  51. package/src/lessRecursiveParser.ts +0 -279
  52. package/src/lessTokens.ts +0 -350
  53. package/src/productions/guards.ts +0 -1066
  54. package/src/productions/index.ts +0 -29
  55. package/src/productions/root.ts +0 -1613
  56. package/src/productions/selectors.ts +0 -1309
  57. package/src/productions/values.ts +0 -1449
  58. package/src/utils.ts +0 -178
package/src/builders.ts DELETED
@@ -1,3190 +0,0 @@
1
- /**
2
- * LessGrammar — builder methods for the Less grammar.
3
- *
4
- * Builder-only class: no grammar rules, no Parséman Parser base.
5
- * Grammar rules live in grammar-fn.ts (macro-compiled functional grammar),
6
- * which uses LessGrammar via a thin BuilderHost subclass.
7
- * Extends CssParser to inherit the shared CSS builder methods.
8
- */
9
-
10
- import type { FieldMap, Span } from 'parseman';
11
- import type { CSTLeaf, CSTError } from 'parseman';
12
- import {
13
- CssParser,
14
- spannedComponents, type Spanned, type Component
15
- } from '@jesscss/css-parser/jess';
16
- import { getInterpolatedOrString, getInterpolatedNode, createInterpolatedReference } from './utils.js';
17
-
18
- import {
19
- type Node,
20
- type LocationInfo,
21
- Any, Keyword, Rules, Ruleset,
22
- type Selector,
23
- ComplexSelector, type ComplexSelectorValue,
24
- CompoundSelector,
25
- isSelectorListLike, selectorListItems,
26
- Declaration,
27
- VarDeclaration, type VarDeclarationOptions,
28
- NESTABLE_AT_RULES,
29
- Reference, type ReferenceValue,
30
- Ampersand, List, DefaultGuard, Extend, ExtendFlag, Call,
31
- For, type ForPattern,
32
- Interpolated, InterpolatedSelector, Sequence, CustomDeclaration,
33
- Color, Paren, Condition, type ConditionOperator,
34
- Num, Dimension,
35
- Mixin, Expression, Operation, Negative,
36
- shouldOperateWithMathFrames, type MathMode,
37
- StyleImport,
38
- JsImport,
39
- Nil,
40
- Rest,
41
- Quoted,
42
- Url,
43
- AtRuleStatement,
44
- AtRule,
45
- QueryCondition,
46
- INTERPOLATION_PLACEHOLDER,
47
- Block
48
- } from '@jesscss/core';
49
-
50
- // ---------------------------------------------------------------------------
51
- // Types
52
- // ---------------------------------------------------------------------------
53
-
54
- type JessNode = Node<any, any>;
55
- type Child = JessNode | CSTLeaf | CSTError;
56
-
57
- // ---------------------------------------------------------------------------
58
- // Helpers
59
- // ---------------------------------------------------------------------------
60
-
61
- // Mirrors grammar.ts's `knownAtVar` regex (isVariableLike in the reference): a
62
- // known at-rule name (incl. vendor-prefixed document/keyframes/viewport) used
63
- // as a variable call (`@media()`) is only legal with empty parens, and is
64
- // itself a deprecated form.
65
- const KNOWN_AT_RULE_VAR_NAME_RE = /^(?:(?:-moz-)?document|(?:-[a-z]+-)?keyframes|(?:-ms-)?viewport|import|media|supports|layer|container|scope|page|font-face|starting-style|property|counter-style|color-profile|font-palette-values|namespace)$/i;
66
-
67
- function spanToLocation(span: Span): LocationInfo {
68
- return { start: span.start, end: span.end };
69
- }
70
-
71
- function nodeChildren(children: ReadonlyArray<Child>): JessNode[] {
72
- return children.filter((c): c is JessNode => c._tag === 'node') as JessNode[];
73
- }
74
-
75
- // ---------------------------------------------------------------------------
76
- // LessGrammar
77
- // ---------------------------------------------------------------------------
78
-
79
- export class LessGrammar extends CssParser {
80
- /** Math mode governing when arithmetic operates / when `/` divides. Less default. */
81
- mathMode: MathMode = 'parens-division';
82
-
83
- /** Bare ident/keyword token in value or guard position. */
84
- private _lessKeyword(text: string, loc: LocationInfo): Keyword {
85
- return this._valueKeyword(text, loc);
86
- }
87
-
88
- private _isKeywordLike(node: unknown): node is Keyword | Any {
89
- return !!node && typeof node === 'object'
90
- && ((node as { type?: string }).type === 'Keyword' || (node as { type?: string }).type === 'Any');
91
- }
92
-
93
- private _isEmptyKeywordLike(node: unknown): boolean {
94
- return !node
95
- || (this._isKeywordLike(node) && !String((node as { value?: string }).value ?? '').trim());
96
- }
97
-
98
- // -- buildNode -------------------------------------------------------------
99
- /* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/naming-convention */
100
-
101
- protected override buildNode(
102
- type: string,
103
- span: Span,
104
- children: ReadonlyArray<JessNode | CSTLeaf | CSTError>,
105
- _state: unknown,
106
- _rawChildren: ReadonlyArray<{ _tag: string }>,
107
- fields?: FieldMap,
108
- triviaLog: readonly number[] = []
109
- ): JessNode {
110
- const loc = spanToLocation(span);
111
- const raw = _rawChildren;
112
- switch (type) {
113
- case 'VarDeclaration': return this._buildVarDeclaration(children, raw, loc);
114
- case 'Reference': return this._buildReference(children, loc);
115
- case 'LessAmpersand': return this._buildAmpersand(children, loc);
116
- case 'ComplexSelector': return this._buildComplexSelector(raw, loc);
117
- case 'SelectorList': return this._buildSelectorList(raw, loc);
118
- case 'Ruleset': return this._buildRuleset(children, raw, loc) as unknown as JessNode;
119
- case 'Declaration':
120
- this._warnDeprecatedValue(span);
121
- return this._buildLessDeclaration(raw, loc);
122
- case 'CustomDeclaration':
123
- this._warnCustomPropVars(span);
124
- return this._buildLessCustomDecl(children, loc);
125
- case 'Block': return this._buildLessCustomBlock(children, loc);
126
- case 'AtRuleBlock':
127
- this._warnAtRulePreludeVars(span);
128
- return this._buildAtRuleBlock(children, loc) as unknown as JessNode;
129
- case 'QueryAtRuleBlock':
130
- this._warnAtRulePreludeVars(span);
131
- return this._buildLessQueryAtRuleBlock(children, raw, loc);
132
- case 'NamedColor': return this._buildNamedColor(children, loc);
133
- case 'Comparison': return this._buildComparison(raw, loc);
134
- case 'GuardDefault': return new DefaultGuard('default()', {}, loc) as unknown as JessNode;
135
- case 'GuardInParens': return this._buildGuardInParens(children, loc);
136
- case 'GuardTerm': return this._buildGuardTerm(raw, loc);
137
- case 'GuardAnd': return this._buildGuardJoin(children, loc, 'and');
138
- case 'GuardOr': return this._buildGuardJoin(children, loc, 'or');
139
- case 'Guard': return this._buildGuard(children, loc);
140
- case 'CondArgTerm': return this._buildCondArgTerm(raw, loc);
141
- case 'CondArgAnd': return this._buildCondArgJoin(children, loc, 'and');
142
- case 'CondArgOr': return this._buildCondArgJoin(children, loc, 'or');
143
- case 'UnicodeRange': return this._lessKeyword(this._source.slice(span.start, span.end), loc) as unknown as JessNode;
144
- case 'PseudoSelector': return this._buildLessPseudo(type, span, children, _state, raw, fields, triviaLog, loc);
145
- case 'InterpolatedSelector': return this._buildInterpolatedSelector(children, loc);
146
- case 'VarCall': return this._buildVarCall(children, raw, loc);
147
- case 'MixinCall': return this._buildMixinCall(children, raw, loc);
148
- case 'Rest': return this._buildRest(children, loc);
149
- case 'NamedArg': return this._buildNamedArg(raw, loc);
150
- case 'MixinArgs': return this._buildMixinArgs(raw, loc);
151
- case 'AnonymousMixinDefinition': return this._buildAnonMixin(children, loc) as unknown as JessNode;
152
- case 'DetachedRuleset': return this._buildDetachedRuleset(children, loc) as unknown as JessNode;
153
- case 'For': return this._buildEachFor(children, loc) as unknown as JessNode;
154
- case 'FormatCall': return this._buildFormatCall(raw, loc);
155
- case 'MixinOrQualifiedRule': return this._buildMixinOrQualified(children, loc);
156
- case 'Negative': return new Negative(this._negativeOperand(children), undefined, loc) as unknown as JessNode;
157
- case 'OperationTop': return this._buildOperation(children, loc, this.mathMode === 'always') as unknown as JessNode;
158
- case 'EscapedValue': return this._buildEscapedValue(children, loc);
159
- case 'InterpValue': return this._buildInterpValue(raw, loc);
160
- case 'NsAccessor': return this._buildNsAccessor(children, loc);
161
- case 'AtRuleStatement': return this._buildAtRuleStatement(children, loc);
162
- case 'ExtendTarget': return this._buildExtendTarget(children, raw, loc);
163
- case 'ExtendPseudo': return this._buildExtendPseudo(children, loc);
164
- case 'ExtendStatement': return this._buildExtendStatement(children, raw, loc);
165
- default: return super.buildNode(type, span, children, _state, raw, fields, triviaLog) as unknown as JessNode;
166
- }
167
- }
168
-
169
- // -- Private Less AST builders ---------------------------------------------
170
-
171
- /**
172
- * The operand of a `Negative` (`-value`). The grammar emits the leading `-`
173
- * as a leaf followed by the operand, which may itself be a node or a bare
174
- * string terminal (e.g. `-@color` → `var(--color)`'s inner `-color-accent`).
175
- * Prefer a node child; otherwise take the operand leaf's text so `Negative`
176
- * coerces it to the canonical node form rather than receiving `undefined`.
177
- */
178
- private _negativeOperand(children: ReadonlyArray<Child>): JessNode | string {
179
- const node = nodeChildren(children)[0];
180
- if (node) {
181
- return node;
182
- }
183
- const operand = children.find((c): c is CSTLeaf => c._tag === 'leaf' && c.value !== '-');
184
- return operand?.value ?? '';
185
- }
186
-
187
- private _buildVarDeclaration(children: ReadonlyArray<JessNode | CSTLeaf | CSTError>, rawChildren: ReadonlyArray<{ _tag: string }>, loc: LocationInfo) {
188
- const items = spannedComponents(rawChildren);
189
- const rawName = typeof items[0]?.comp === 'string' ? items[0]!.comp : '';
190
- const name = rawName.startsWith('@') ? rawName.slice(1) : rawName;
191
- // Less.js still accepts a digit-leading variable name (`@3`) — its name regex
192
- // is `[\w-]+` — but it's a footgun (collides with numeric tokens), so flag it.
193
- if (/^-?\d/.test(name)) {
194
- this._warn(
195
- `Variable name "@${name}" starts with a digit; digit-leading variable names are deprecated.`,
196
- 'digit-leading-variable'
197
- );
198
- }
199
- const colonIdx = items.findIndex(i => i.comp === ':');
200
- const afterColon = items[colonIdx + 1];
201
- if (afterColon?.comp === '{') {
202
- // Detached ruleset: @var: { ... }
203
- const ruleNodes = nodeChildren(children);
204
- const openBrace = items[colonIdx + 1]!;
205
- const closeBrace = items[items.length - 1]!.comp === '}'
206
- ? items[items.length - 1]!
207
- : undefined;
208
- // Raw-string detached ruleset (grammar's rawDetachedBlock fallback): the body
209
- // had no structurable declarations but is non-empty (special-char keys like
210
- // bootstrap's `@escaped-characters: { <: %3c; … }`). Historical Less keeps such
211
- // a block as a raw `Quoted` string (braces included); @plugin functions such as
212
- // escape-svg read it via `.value`.
213
- if (ruleNodes.length === 0 && closeBrace) {
214
- const bodyText = this._source.slice(openBrace.span.end, closeBrace.span.start);
215
- if (bodyText.trim() !== '') {
216
- const rawBlock = this._source.slice(openBrace.span.start, closeBrace.span.end);
217
- const nameNode = name || undefined;
218
- return new VarDeclaration(
219
- { name: (nameNode ?? name) as any, value: new Quoted(rawBlock, {}, loc) as any } as any,
220
- {} as VarDeclarationOptions,
221
- loc
222
- );
223
- }
224
- }
225
- const mixin = new Mixin({ rules: ruleNodes }, {}, loc);
226
- const nameNode = name || undefined;
227
- return new VarDeclaration(
228
- { name: (nameNode ?? name) as any, value: mixin as any } as any,
229
- {} as VarDeclarationOptions,
230
- loc
231
- );
232
- }
233
- let end = items.length;
234
- let bangIdx = -1;
235
- for (let i = colonIdx + 1; i < items.length; i++) {
236
- const c = items[i]!.comp;
237
- if (c === '!') {
238
- end = i;
239
- bangIdx = i;
240
- break;
241
- }
242
- if (c === 'important' || c === ';') {
243
- end = i;
244
- break;
245
- }
246
- }
247
- const valItems = items.slice(colonIdx + 1, end);
248
- // A tolerated trailing comma (`@x: a, b, c,;`) is not a value item — Less 4.x
249
- // drops it, so a comma-list value keeps N items, not N + 1 empty. (Plain
250
- // declarations go through the CSS builder, which already filters empty
251
- // segments; the Less var path assembles valItems directly, so strip it here.)
252
- if (valItems.length > 0 && valItems[valItems.length - 1]!.comp === ',') {
253
- valItems.pop();
254
- }
255
- if (valItems.length) {
256
- const vText = this._source.slice(valItems[0]!.span.start, valItems[valItems.length - 1]!.span.end);
257
- if (/(?:^|[\s,])\.-?[_a-zA-Z]/.test(vText)) {
258
- this._warn(
259
- `Unquoted selector capture in variable "@${name}" is deprecated; wrap the value in quotes or ~"...".`,
260
- 'unquoted-selector-capture'
261
- );
262
- }
263
- }
264
- const nsRef = this._tryParseNamespaceRef(valItems, loc);
265
- let { value: rawValue } = nsRef ? { value: nsRef } : this._assembleLessValue(valItems, loc);
266
- // Check for trailing [accessor] and/or () in source that the grammar couldn't consume.
267
- // (nsRef already handles this via internal lookahead; only do it when nsRef is null)
268
- if (!nsRef && valItems.length > 0) {
269
- const lastSpan = valItems[valItems.length - 1]!.span;
270
- const afterVal = this._source.slice(lastSpan.end);
271
- // The grammar may partially consume '[' into the Reference CST due to parseman
272
- // not rolling back CST leaves on optional-sequence failure. Detect this case:
273
- // rawValue is Reference with target set but key='' (empty from grammar bug).
274
- const rv = rawValue as any;
275
- // Grammar bug: parseman leaks '[' into CST but accessor fails → Reference(target, key=''|Quoted(''))
276
- const rvKey = rv?.key;
277
- const isEmptyKey = rvKey === '' || rvKey === undefined
278
- || (rvKey && typeof rvKey === 'object' && rvKey.type === 'Quoted' && (rvKey.value === '' || rvKey.valueOf?.() === ''));
279
- const grammarPartialAccessor =
280
- rv && rv.type === 'Reference' && rv.target !== undefined && isEmptyKey;
281
- const accMatch = /^\s*\[([^\]]+)\]/.exec(afterVal);
282
- if (accMatch) {
283
- const accText = accMatch[1]!.trim();
284
- const accessorKey = this._decodeAccessorKey(accText, loc);
285
- // A numeric accessor key (`foo[2]` / `foo[]` → last, key -1) is an INDEX
286
- // lookup; a variable dispatch would fail with `'-1' is not defined`.
287
- const accessorRefOptions = typeof accessorKey === 'number'
288
- ? { type: 'index' as const }
289
- : {};
290
- if (grammarPartialAccessor) {
291
- // Fix in-place: replace the wrong key on the existing Reference wrapper
292
- rawValue = new Reference(
293
- { target: rv.target as any, key: accessorKey as any } as unknown as ReferenceValue,
294
- accessorRefOptions,
295
- loc
296
- ) as unknown as JessNode;
297
- } else {
298
- // No partial grammar accessor: wrap with new Reference
299
- rawValue = new Reference(
300
- { target: rawValue as any, key: accessorKey as any } as unknown as ReferenceValue,
301
- accessorRefOptions,
302
- loc
303
- ) as unknown as JessNode;
304
- }
305
- const afterAcc = afterVal.slice(accMatch[0].length);
306
- if (/^\s*\(\s*\)/.test(afterAcc)) {
307
- rawValue = new Call({ name: rawValue as any } as any, {}, loc) as unknown as JessNode;
308
- }
309
- }
310
- }
311
- const value = typeof rawValue === 'string' && rawValue
312
- ? this._lessKeyword(rawValue, loc)
313
- : rawValue;
314
- // `important` is the verbatim source text (`!important`, `! important`, …),
315
- // not a boolean — the declaration stores the string it will re-emit.
316
- let important: string | undefined;
317
- if (bangIdx >= 0) {
318
- const bang = items[bangIdx]!;
319
- const kw = items[bangIdx + 1];
320
- const impEnd = kw && typeof kw.comp === 'string' && kw.comp.toLowerCase() === 'important'
321
- ? kw.span.end
322
- : bang.span.end;
323
- important = this._source.slice(bang.span.start, impEnd);
324
- }
325
- const nameNode = name || undefined;
326
- return new VarDeclaration(
327
- { name: (nameNode ?? name) as any, value, important } as any,
328
- {} as VarDeclarationOptions,
329
- loc
330
- );
331
- }
332
-
333
- /**
334
- * Build a Less variable reference and its accessor/call chain. Faithful 1:1
335
- * port of the Chevrotain `varReference` + `lookupOrCall` productions
336
- * (productions/values.ts, productions/guards.ts): a `@var` base glued to a
337
- * left-folded chain of `[index]` accessors and `(call)`s. The grammar's
338
- * noTrivia() guarantees the chain is adjacent (no whitespace between segments).
339
- */
340
- private _buildReference(children: ReadonlyArray<Child>, loc: LocationInfo) {
341
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
342
- const varName = ls[0]?.value ?? '';
343
- // Head sigil types the base reference. `@a` → variable. `$color` → a bare
344
- // property accessor: an `index` reference with a Quoted key and no target,
345
- // resolved against the current scope (port of varReference's PropertyReference
346
- // branch). Anything else → bare index base.
347
- const isVar = varName.startsWith('@');
348
- const isProp = varName.startsWith('$');
349
- let base: JessNode = isProp
350
- ? new Reference(
351
- { key: new Quoted(varName.slice(1), { quote: '\'' }, loc) as any } as unknown as ReferenceValue,
352
- { type: 'index' },
353
- loc
354
- ) as unknown as JessNode
355
- : new Reference(
356
- isVar ? varName.slice(1) : varName,
357
- isVar ? { type: 'variable' as const } : {},
358
- loc
359
- ) as unknown as JessNode;
360
- let i = 1;
361
- while (i < ls.length) {
362
- const tok = ls[i]!.value;
363
- if (tok === '[') {
364
- if (ls[i + 1]?.value === ']') {
365
- // Empty `[]` → key = -1, type index (lookupOrCall else-branch).
366
- base = new Reference(
367
- { target: base as any, key: -1 } as unknown as ReferenceValue,
368
- { type: 'index' }, loc
369
- ) as unknown as JessNode;
370
- i += 2;
371
- } else {
372
- base = this._applyReferenceAccessor(base, ls[i + 1]!.value, loc);
373
- i += 3; // '[', key, ']'
374
- }
375
- } else if (tok === '(') {
376
- const payload: Record<string, unknown> = { name: base };
377
- if (ls[i + 1]?.value === ')') {
378
- i += 2;
379
- } else {
380
- const args = this._buildRefCallArgs(ls[i + 1]!.value, loc);
381
- if (args) {
382
- payload.args = args;
383
- }
384
- i += 3; // '(', content, ')'
385
- }
386
- base = new Call(payload as any, {}, loc) as unknown as JessNode;
387
- } else {
388
- break;
389
- }
390
- }
391
- return base;
392
- }
393
-
394
- /**
395
- * Build a namespace INDEXED-accessor value: a `.`/`#` selector-path head glued to
396
- * a `[accessor]` (and any further `[accessor]`/`(call)` chain), e.g.
397
- * `#ns.options[val1]`. The grammar (NsAccessor) captures this as ONE value operand
398
- * so it survives arithmetic folding; here we reassemble it into the mixin-ruleset
399
- * name Reference + accessor chain — the SAME shape the declaration-value
400
- * _assembleSegment path produces for a lone `#ns.options[val1]`. Call-headed forms
401
- * (`.mixin()`, `.mixin()[k]`) do NOT reach here (they keep the GluedParen path).
402
- * Leaves: [ headText, '[', key?, ']', '(', content?, ')' … ].
403
- */
404
- private _buildNsAccessor(children: ReadonlyArray<Child>, loc: LocationInfo): JessNode {
405
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
406
- const headText = (ls[0]?.value ?? '').trim();
407
- // Split the selector path into segments: `#ns.options` → ['#ns', '.options'];
408
- // combinators (`#ns > .a`) are dropped, each `.`/`#` name is one segment.
409
- const pathSegs = headText.match(/[#.][^#.>+~\s]*/g) ?? [headText];
410
- const nameKey: string | string[] = pathSegs.length === 1 ? pathSegs[0]! : pathSegs;
411
- const rawKey = pathSegs.length > 1 ? pathSegs.join('') : undefined;
412
- let base: JessNode = new Reference(
413
- { key: nameKey, ...(rawKey ? { rawKey } : {}) } as unknown as ReferenceValue,
414
- { type: 'mixin-ruleset', role: 'name' } as any, loc
415
- ) as unknown as JessNode;
416
- let i = 1;
417
- while (i < ls.length) {
418
- const tok = ls[i]!.value;
419
- if (tok === '[') {
420
- if (ls[i + 1]?.value === ']') {
421
- base = new Reference(
422
- { target: base as any, key: -1 } as unknown as ReferenceValue,
423
- { type: 'index' }, loc
424
- ) as unknown as JessNode;
425
- i += 2;
426
- } else {
427
- base = this._applyReferenceAccessor(base, ls[i + 1]!.value, loc);
428
- i += 3;
429
- }
430
- } else if (tok === '(') {
431
- const payload: Record<string, unknown> = { name: base };
432
- if (ls[i + 1]?.value === ')') {
433
- i += 2;
434
- } else {
435
- const args = this._buildRefCallArgs(ls[i + 1]!.value, loc);
436
- if (args) {
437
- payload.args = args;
438
- }
439
- i += 3;
440
- }
441
- base = new Call(payload as any, {}, loc) as unknown as JessNode;
442
- } else {
443
- break;
444
- }
445
- }
446
- return base;
447
- }
448
-
449
- /**
450
- * Apply one `[key]` accessor to `base`, reproducing lookupOrCall's key logic:
451
- * type is `variable` when the key token starts with `@`, else `index`; the key
452
- * text runs through getInterpolatedOrString (handling `$@x`/`@{x}` interpolation),
453
- * and index keys are wrapped in a Quoted.
454
- */
455
- private _applyReferenceAccessor(base: JessNode, keyStr: string, loc: LocationInfo): JessNode {
456
- const type: 'variable' | 'index' = keyStr.startsWith('@') ? 'variable' : 'index';
457
- let result: string | JessNode = getInterpolatedOrString(keyStr, loc) as string | JessNode;
458
- if (type === 'index') {
459
- result = new Quoted(result as any, { quote: '\'' }, loc) as unknown as JessNode;
460
- }
461
- return new Reference(
462
- { target: base as any, key: result as any } as unknown as ReferenceValue,
463
- { type }, loc
464
- ) as unknown as JessNode;
465
- }
466
-
467
- /**
468
- * Build mixin-call args for a `(…)` segment in a reference chain from the raw
469
- * captured content. Comma-separated values become a List; an empty segment
470
- * yields null (no args). Sub-structure here is intentionally shallow — the
471
- * accessor-chain call form is rare and no consumer inspects nested arg shape.
472
- */
473
- private _buildRefCallArgs(content: string, loc: LocationInfo): JessNode | null {
474
- const trimmed = content.trim();
475
- if (!trimmed) {
476
- return null;
477
- }
478
- const parts = trimmed.split(',').map(p => p.trim()).filter(Boolean);
479
- const items = parts.map(p => p.startsWith('@')
480
- ? new Reference(p.slice(1), { type: 'variable' as const }, loc) as unknown as Node
481
- : this._lessKeyword(p, loc) as unknown as Node);
482
- return new List(items as any, undefined, loc) as unknown as JessNode;
483
- }
484
-
485
- private _buildNamedColor(children: ReadonlyArray<Child>, loc: LocationInfo) {
486
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
487
- const name = ls[0]?.value ?? '';
488
- return new Color({ node: name }, {}, loc) as unknown as JessNode;
489
- }
490
-
491
- private _normalizeCompareOp(op: string): ConditionOperator {
492
- switch (op) {
493
- case '=>':
494
- case '>=':
495
- return '>=';
496
- case '=<':
497
- case '<=':
498
- return '<=';
499
- case '>':
500
- return '>';
501
- case '<':
502
- return '<';
503
- default:
504
- return '=';
505
- }
506
- }
507
-
508
- /** A guard comparison operator leaf (`=`, `<`, `>=`, `=<`, `=~`, …). */
509
- private _isCompareOpLeaf(text: string): boolean {
510
- return />=|<=|=>|=<|=~|[<>=]/.test(text);
511
- }
512
-
513
- /**
514
- * Split a guard term's ordered components into `left [op right]`. A bare-keyword
515
- * operand (`foo`, `true`) is a leaf string — not a node — so `nodeChildren`
516
- * alone drops it; walk the ordered stream so string operands become real
517
- * keyword nodes (their guard truthiness is decided at eval by `Condition`).
518
- */
519
- private _guardComparison(raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo):
520
- { left: Node; op?: ConditionOperator; right?: Node } {
521
- const items = spannedComponents(raw);
522
- let op: ConditionOperator | undefined;
523
- const operands: Node[] = [];
524
- for (const it of items) {
525
- if (typeof it.comp === 'string') {
526
- if (this._isCompareOpLeaf(it.comp)) {
527
- op = this._normalizeCompareOp(it.comp);
528
- continue;
529
- }
530
- operands.push(this._lessKeyword(it.comp, loc) as unknown as Node);
531
- } else {
532
- operands.push(it.comp as unknown as Node);
533
- }
534
- }
535
- const left = this._maybeDefaultGuard(operands[0] ?? this._lessKeyword('', loc), loc);
536
- const right = operands[1] !== undefined ? this._maybeDefaultGuard(operands[1], loc) : undefined;
537
- return { left, op, right };
538
- }
539
-
540
- private _buildComparison(raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo) {
541
- const { left, op, right } = this._guardComparison(raw, loc);
542
- if (op && right) {
543
- return new Condition([left, op, right], {}, loc) as unknown as JessNode;
544
- }
545
- return new Condition([left], {}, loc) as unknown as JessNode;
546
- }
547
-
548
- /**
549
- * Coerce a `_assembleValue` result — a single Component or a raw space-group
550
- * array — into ONE Node, so it can be a Condition operand. A single string
551
- * becomes a keyword; a space-group array becomes a `Sequence` (the same coercion
552
- * the List serializer applies to a raw group).
553
- */
554
- private _condOperandNode(comps: Spanned[], loc: LocationInfo): Node {
555
- const { value } = this._assembleValue(comps, loc);
556
- if (Array.isArray(value)) {
557
- const nodes = (value as Component[]).map(c =>
558
- typeof c === 'string' ? this._lessKeyword(c, loc) as unknown as Node : c as unknown as Node);
559
- return new Sequence(nodes as any, undefined, loc) as unknown as Node;
560
- }
561
- if (typeof value === 'string') {
562
- return this._lessKeyword(value, loc) as unknown as Node;
563
- }
564
- return value as unknown as Node;
565
- }
566
-
567
- /**
568
- * `CondArgTerm` — a name-independent condition-argument term: optional leading
569
- * `not`, a bounded value operand, and an optional `<op> right` comparison. Builds
570
- * a `Condition` (comparison → `[left, op, right]`; bare `not` → `{negate:true}`)
571
- * or, when neither a `not` nor a `compareOp` is present, the plain operand value
572
- * (byte-identical to an ordinary value arg). Multi-token operands (`1px solid`)
573
- * survive as a `Sequence`, unlike the single-operand guard path.
574
- */
575
- private _buildCondArgTerm(raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo): JessNode {
576
- const items = spannedComponents(raw);
577
- const hasNot = items.length > 0 && items[0]!.comp === 'not';
578
- const rest = hasNot ? items.slice(1) : items;
579
- const opIdx = rest.findIndex(it => typeof it.comp === 'string' && this._isCompareOpLeaf(it.comp));
580
- let term: Node;
581
- if (opIdx >= 0) {
582
- const left = this._condOperandNode(rest.slice(0, opIdx), loc);
583
- const op = this._normalizeCompareOp(rest[opIdx]!.comp as string);
584
- const right = this._condOperandNode(rest.slice(opIdx + 1), loc);
585
- term = new Condition([left, op, right], {}, loc) as unknown as Node;
586
- } else {
587
- term = this._condOperandNode(rest, loc);
588
- }
589
- if (hasNot) {
590
- return new Condition([term as any], { negate: true }, loc) as unknown as JessNode;
591
- }
592
- return term as unknown as JessNode;
593
- }
594
-
595
- /**
596
- * Fold a left-associative `and`/`or` chain of condition-arg terms into Conditions.
597
- *
598
- * Less accepts a bare `and`/`or` join in value-position condition args (`if(@a > 5
599
- * and @b < 2, …)`) — verified against less@4.6.7 (`if`/`boolean` route their arg
600
- * through `condition()` with no `needsParens`, so bare comparisons split on `and`/
601
- * `or`). We keep accepting the bare form for Less parity, but NORMALIZE the AST so
602
- * each join operand is `Paren`-wrapped: `@a > 5 and @b < 2` builds the SAME tree as
603
- * the explicitly-parenthesised `(@a > 5) and (@b < 2)`. This is a structural
604
- * normalisation only — `Paren(Condition)` evaluates to the same boolean as the bare
605
- * `Condition`, so rendered CSS is byte-identical. A single unjoined operand (one
606
- * node) is untouched (no synthetic Paren).
607
- */
608
- private _buildCondArgJoin(children: ReadonlyArray<Child>, loc: LocationInfo, op: ConditionOperator): JessNode {
609
- const nodes = nodeChildren(children);
610
- if (nodes.length === 0) {
611
- return this._lessKeyword('', loc) as unknown as JessNode;
612
- }
613
- if (nodes.length === 1) {
614
- return nodes[0]! as unknown as JessNode;
615
- }
616
- const wrap = (n: Node): Node =>
617
- (n as { type?: string }).type === 'Paren'
618
- ? n
619
- : (new Paren(n as any, {}, loc) as unknown as Node);
620
- let left = wrap(nodes[0]!);
621
- for (let i = 1; i < nodes.length; i++) {
622
- left = new Condition([left, op, wrap(nodes[i]!)], {}, loc) as unknown as Node;
623
- }
624
- return left as unknown as JessNode;
625
- }
626
-
627
- /** Coerce a default() call/reference into a DefaultGuard, mirroring isDefaultGuardCall. */
628
- private _maybeDefaultGuard(node: Node, loc: LocationInfo): Node {
629
- const n = node as any;
630
- if (n?.type === 'Call') {
631
- const name = n.name;
632
- const nameStr = String(
633
- typeof name === 'object' && name !== null && 'valueOf' in name ? name.valueOf() : name ?? ''
634
- );
635
- if (nameStr === 'default' || nameStr === '??') {
636
- return new DefaultGuard('default()', {}, loc) as unknown as Node;
637
- }
638
- }
639
- return node;
640
- }
641
-
642
- /** guardInParens: `(` guardOr `)` → Paren, or a bare default() → Paren(DefaultGuard). */
643
- private _buildGuardInParens(children: ReadonlyArray<Child>, loc: LocationInfo) {
644
- let inner = nodeChildren(children)[0] ?? this._lessKeyword('', loc);
645
- inner = this._maybeDefaultGuard(inner, loc) as Node;
646
- // `(default())` nests guardInParens(GuardDefault) inside another guardInParens;
647
- // collapse the redundant Paren-around-Paren(DefaultGuard) to a single Paren.
648
- const innerAny = inner as any;
649
- if (innerAny?.type === 'Paren' && (innerAny.value as any)?.type === 'DefaultGuard') {
650
- return inner as unknown as JessNode;
651
- }
652
- return new Paren(inner as any, {}, loc) as unknown as JessNode;
653
- }
654
-
655
- /** A single guard term: optional `not`, then a paren-guard or a comparison/value. */
656
- private _buildGuardTerm(raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo) {
657
- const items = spannedComponents(raw);
658
- const hasNot = items.some(i => typeof i.comp === 'string' && i.comp === 'not');
659
- // Everything after an optional leading `not` is the operand run.
660
- const rest = raw.filter(rc => !(rc._tag === 'leaf' && (rc as { value?: string }).value === 'not'));
661
- const nodes = nodeChildren(rest as ReadonlyArray<Child>);
662
- let term: Node;
663
- if (nodes.length >= 1 && (nodes[0] as any).type === 'Paren') {
664
- // guardInParens branch (already a Paren node)
665
- term = nodes[0]!;
666
- } else {
667
- const { left, op, right } = this._guardComparison(rest, loc);
668
- term = op && right
669
- ? new Condition([left, op, right], {}, loc) as unknown as Node
670
- : left;
671
- }
672
- if (hasNot) {
673
- return new Condition([term as any], { negate: true }, loc) as unknown as JessNode;
674
- }
675
- return term as unknown as JessNode;
676
- }
677
-
678
- /** Fold a left-associative chain of terms joined by `and` / `or`. */
679
- private _buildGuardJoin(children: ReadonlyArray<Child>, loc: LocationInfo, op: ConditionOperator) {
680
- const nodes = nodeChildren(children);
681
- if (nodes.length === 0) {
682
- return this._lessKeyword('', loc) as unknown as JessNode;
683
- }
684
- let left = nodes[0]!;
685
- for (let i = 1; i < nodes.length; i++) {
686
- left = new Condition([left, op, nodes[i]!], {}, loc) as unknown as Node;
687
- }
688
- return left as unknown as JessNode;
689
- }
690
-
691
- /** guard: `when` guardOr — returns the single guardOr child. */
692
- private _buildGuard(children: ReadonlyArray<Child>, loc: LocationInfo) {
693
- return (nodeChildren(children)[0] ?? this._lessKeyword('', loc)) as unknown as JessNode;
694
- }
695
-
696
- /**
697
- * Deprecated Less `%(format, args…)` string formatting, LOWERED at build time.
698
- *
699
- * Jess already has full string interpolation, so `%()` is redundant Less-4 legacy —
700
- * we emit a `percent-format` deprecation warning and, when the format is a string
701
- * literal, splice its `%[sda]` directives into the canonical `Interpolated` node
702
- * (the same one `@{var}` string interpolation builds), wrapped in a `Quoted` that
703
- * preserves the literal's quote char / escaped flag:
704
- * - `%s` → bare interpolation slot (a Quoted arg inserts with its quotes stripped);
705
- * - `%d`/`%a` → identical bare slot (d/a are the same in Less);
706
- * - `%S`/`%D`/`%A` → the arg WRAPPED in `escape(…)` (URL-encode);
707
- * - `%%` → a literal `%`.
708
- * A dynamic (non-literal) format (`%(hello)`, `%(e("…"), …)`) can't be lowered at
709
- * parse time, so it falls back to a best-effort runtime `%` Call with the same warning.
710
- */
711
- private _buildFormatCall(raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo): JessNode {
712
- this._warn('%() string formatting is deprecated — use string interpolation', 'percent-format');
713
-
714
- // Assemble the args exactly like a normal Call so bare keywords (`%(hello)`) are
715
- // keyword-ified and comma runs fold uniformly.
716
- const argList = this._assembleArgs(this._betweenParens(spannedComponents(raw)), loc) as unknown as List<Node>;
717
- const args = (argList.value ?? []) as Node[];
718
- const format = args[0];
719
- const rest = args.slice(1);
720
-
721
- // The format must be a Quoted literal (plain `"…"`, `'…'`, or escaped `~"…"`)
722
- // wrapping a bare string to lower; anything else stays a runtime call.
723
- if (format instanceof Quoted && typeof format.value === 'string') {
724
- const { source, replacements } = this._lowerFormatString(format.value, rest, loc);
725
- const interp = new Interpolated({ source, replacements: replacements as any }, { role: 'ident' }, loc);
726
- return new Quoted(interp as any, { quote: format.quote, escaped: format.escaped }, loc) as unknown as JessNode;
727
- }
728
-
729
- // Dynamic / non-literal format (`%(hello)`, `%(e("…"), …)`): best-effort runtime `%` Call.
730
- const nameRef = new Reference('%', { type: 'function', fallbackValue: true } as any, loc);
731
- return new Call({ name: nameRef as any, args: argList as any }, { silentFail: true } as any, loc) as unknown as JessNode;
732
- }
733
-
734
- /**
735
- * Turn a printf-style format string into an `Interpolated` source + replacements.
736
- * `%[sda]` → one `INTERPOLATION_PLACEHOLDER` slot consuming the next positional arg
737
- * (uppercase → wrapped in `escape(…)` to URL-encode); `%%` → a literal `%`.
738
- */
739
- private _lowerFormatString(
740
- formatText: string, restArgs: ReadonlyArray<Node>, loc: LocationInfo
741
- ): { source: string; replacements: Node[] } {
742
- let source = '';
743
- const replacements: Node[] = [];
744
- let argIndex = 0;
745
- for (let i = 0; i < formatText.length; i++) {
746
- const ch = formatText[i]!;
747
- if (ch !== '%') {
748
- source += ch;
749
- continue;
750
- }
751
- const next = formatText[i + 1];
752
- if (next === '%') {
753
- source += '%';
754
- i++;
755
- continue;
756
- }
757
- if (next && /[sda]/i.test(next)) {
758
- const arg = restArgs[argIndex++];
759
- if (arg) {
760
- source += INTERPOLATION_PLACEHOLDER;
761
- // Uppercase directive (`%S`/`%D`/`%A`) → URL-encode the inserted value.
762
- if (/[A-Z]/.test(next)) {
763
- const escapeRef = new Reference('escape', { type: 'function', fallbackValue: true } as any, loc);
764
- const escapeArgs = new List([arg] as any, {}, loc);
765
- replacements.push(new Call({ name: escapeRef as any, args: escapeArgs as any }, { silentFail: true } as any, loc) as unknown as Node);
766
- } else {
767
- replacements.push(arg);
768
- }
769
- } else {
770
- // No matching arg — Less leaves the directive in place as literal text.
771
- source += ch + next;
772
- }
773
- i++;
774
- continue;
775
- }
776
- source += ch;
777
- }
778
- return { source, replacements };
779
- }
780
-
781
- private _buildInterpolatedSelector(children: ReadonlyArray<Child>, loc: LocationInfo) {
782
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
783
- const replacements: Node[] = [];
784
- let source = '';
785
- for (const l of ls) {
786
- if (l.value.startsWith('@{')) {
787
- const varName = l.value.slice(2, -1);
788
- replacements.push(new Reference(varName, { role: 'ident' }, loc) as unknown as Node);
789
- source += INTERPOLATION_PLACEHOLDER;
790
- } else {
791
- source += l.value;
792
- }
793
- }
794
- const interp = new Interpolated({ source, replacements }, { role: 'ident' }, loc);
795
- return new InterpolatedSelector(interp as any, {}, loc) as unknown as JessNode;
796
- }
797
-
798
- private _buildLessPseudo(
799
- type: string, span: Span,
800
- children: ReadonlyArray<JessNode | CSTLeaf | CSTError>,
801
- state: unknown, raw: ReadonlyArray<{ _tag: string }>, fields: FieldMap | undefined, triviaLog: readonly number[], loc: LocationInfo
802
- ): JessNode {
803
- // `:extend(...)` is parsed by the dedicated ExtendPseudo grammar rule, never
804
- // here — generic PseudoSelector is guarded against it (extendAhead). So this
805
- // builder only ever sees real CSS pseudo-classes/elements.
806
- const pseudo = super.buildNode(type, span, children, state, raw, fields, triviaLog) as JessNode;
807
- // `readPseudoArg` (css builder) only recognizes node/array args. Under the Less
808
- // grammar an `nth` arg (`4n+1`) arrives as a leaf string and a single-member
809
- // selector list collapses to a bare string — both of which it skips, leaving
810
- // `arg` undefined (`:not(.one)` → `:not`). Recover the arg as the child that
811
- // sits between the `(` and `)` leaves.
812
- let pseudoArg = (pseudo as unknown as { arg?: unknown }).arg;
813
- if (pseudoArg === undefined) {
814
- const open = children.findIndex(c => (c as CSTLeaf)._tag === 'leaf' && (c as CSTLeaf).value === '(');
815
- if (open >= 0) {
816
- const inner = children[open + 1];
817
- const isClose = (inner as CSTLeaf)?._tag === 'leaf' && (inner as CSTLeaf).value === ')';
818
- if (inner !== undefined && !isClose) {
819
- // A collapsed single-member selector list arrives as a bare string, an
820
- // `nth` value as a leaf token; both wrap to a Keyword so the pseudo arg
821
- // is an eval-able Node. A real node (multi-member list, etc.) passes through.
822
- const recovered = typeof inner === 'string'
823
- ? this._lessKeyword(inner, loc)
824
- : (inner as CSTLeaf)._tag === 'leaf'
825
- ? this._lessKeyword((inner as CSTLeaf).value, loc)
826
- : (inner as unknown as JessNode);
827
- (pseudo as unknown as { arg: unknown }).arg = recovered;
828
- pseudoArg = recovered;
829
- }
830
- }
831
- }
832
- if (Array.isArray(pseudoArg)) {
833
- // Unknown-pseudo: raw string array → Keyword[] for structured serialization.
834
- const keywordNodes = (pseudoArg as unknown[]).map(item =>
835
- typeof item === 'string' && item !== ' '
836
- ? this._lessKeyword(item, loc)
837
- : item as JessNode
838
- );
839
- (pseudo as unknown as { arg: unknown }).arg = keywordNodes;
840
- }
841
- return pseudo;
842
- }
843
-
844
- private _buildLessDeclaration(raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo) {
845
- const items = spannedComponents(raw);
846
- const decl = this._buildDeclaration(raw, loc);
847
- const colonIdx = items.findIndex(i => i.comp === ':');
848
- const merge = colonIdx > 0 ? items[colonIdx - 1]?.comp : undefined;
849
- const assign = merge === '+_' ? '+_:' : merge === '+' ? '+,:' : ':';
850
- const d = decl as unknown as { _options?: Record<string, unknown>; options?: Record<string, unknown>; name?: unknown };
851
- d._options = { ...(d._options ?? {}), assign };
852
- // Wrap the string name. An interpolated property name (`@{prop}`, `pre-@{x}`)
853
- // becomes an Interpolated (port of `declaration`'s getInterpolatedNode branch);
854
- // a plain name becomes a bare string (or Interpolated when templated).
855
- if (typeof d.name === 'string' && d.name) {
856
- const nameStr = d.name;
857
- (decl as unknown as { name: unknown }).name =
858
- (nameStr.includes('@{') || nameStr.includes('${'))
859
- ? getInterpolatedNode(nameStr, loc)
860
- : nameStr;
861
- }
862
- // Arithmetic precedence is now folded in the grammar (topSum → Operation node),
863
- // so a top-level `10px + 5px` arrives as a single Operation. Wrap it in an
864
- // explicit parenthesized Expression (the Jess `$( … )` form) when math mode would
865
- // actually perform the operation. Port of wrapOuterExpressionIfNeeded.
866
- const dvRaw = (decl as unknown as { value?: unknown }).value;
867
- if (dvRaw && typeof dvRaw === 'object' && (dvRaw as { type?: string }).type === 'Operation') {
868
- const f = dvRaw as unknown as { operator?: any; left?: any; right?: any };
869
- if (shouldOperateWithMathFrames({ mathMode: this.mathMode, parenFrames: [], calcFrames: 0 }, f.operator, f.left, f.right)) {
870
- (decl as unknown as { value: unknown }).value = new Expression(dvRaw as any, { parens: true } as any, loc);
871
- }
872
- return decl;
873
- }
874
- // A top-level `/`-list that math mode WOULD divide (e.g. `math:always`) promotes to
875
- // a division Operation. Default `parens-division` keeps a top-level slash a list.
876
- const dvList = (decl as unknown as { value?: unknown }).value;
877
- if (dvList && (dvList as any).type === 'List' && (dvList as any).options?.sep === '/' && this.mathMode === 'always') {
878
- const items = (dvList as any).value as JessNode[];
879
- if (items.length >= 2 && items.every(it => this._isDivisionLike(it))) {
880
- let op: JessNode = items[0]!;
881
- for (let i = 1; i < items.length; i++) {
882
- op = new Operation([op, '/', items[i]] as any, undefined, loc) as unknown as JessNode;
883
- }
884
- const f = op as unknown as { operator: any; left: any; right: any };
885
- (decl as unknown as { value: unknown }).value =
886
- shouldOperateWithMathFrames({ mathMode: this.mathMode, parenFrames: [], calcFrames: 0 }, f.operator, f.left, f.right)
887
- ? new Expression(op as any, { parens: true } as any, loc)
888
- : op;
889
- return decl;
890
- }
891
- }
892
- // Legacy IE `filter: progid:…(…)` values. Chevrotain lexes the whole run as one
893
- // `LegacyMSFilter` token and `processLegacyMSFilterToken` collapses it to a single
894
- // Interpolated (role=any): the raw source with every `@var` replaced by a
895
- // placeholder (colorstr assignments keep the surrounding quotes) and the `@var`
896
- // references as replacements. We have no such token — the value parsed into a
897
- // Sequence/List/Paren tree — but for a `progid:` filter run we reconstruct the
898
- // identical node from the raw value source.
899
- const dv = (decl as unknown as { value?: unknown }).value;
900
- const dvIsArray = Array.isArray(dv);
901
- const dvHasNode = dvIsArray && (dv as unknown[]).some(p => !!p && typeof p === 'object' && 'type' in (p as object));
902
- if (dvIsArray && dvHasNode) {
903
- const src = this._source.slice(loc.start, loc.end);
904
- const colonPos = src.indexOf(':');
905
- const rawVal = colonPos >= 0 ? src.slice(colonPos + 1).trim().replace(/;\s*$/, '').trim() : src;
906
- if (/^progid:/i.test(rawVal)) {
907
- const legacy = this._buildLegacyMSFilter(rawVal, loc);
908
- (decl as unknown as { value: unknown }).value = legacy;
909
- }
910
- }
911
- return decl;
912
- }
913
-
914
- /**
915
- * Port of `processLegacyMSFilterToken` (lessRecursiveParser.ts): a `progid:…`
916
- * filter value string → Interpolated(role=any) with `@var` runs templated out,
917
- * or a plain Keyword when the run has no variables.
918
- */
919
- private _buildLegacyMSFilter(source: string, loc: LocationInfo): JessNode {
920
- source = source.replace(/\s*=\s*/g, '=');
921
- const varRe = /@([_a-zA-Z\xA0-￿][-_a-zA-Z0-9\xA0-￿]*)/g;
922
- const matches = [...source.matchAll(varRe)];
923
- if (matches.length === 0) {
924
- return this._lessKeyword(source, loc) as unknown as JessNode;
925
- }
926
- const templatedSource = source.replace(
927
- varRe,
928
- (_full, _name, offset: number, fullSource: string) => {
929
- const prefix = fullSource.slice(0, offset);
930
- const key = prefix.match(/([A-Za-z]+)=$/)?.[1];
931
- if (key && /colorstr$/i.test(key)) {
932
- return `"${INTERPOLATION_PLACEHOLDER}"`;
933
- }
934
- return INTERPOLATION_PLACEHOLDER;
935
- }
936
- );
937
- const replacements = matches.map(match =>
938
- createInterpolatedReference('@', match[1]!, loc) as unknown as JessNode);
939
- return new Interpolated(
940
- { source: templatedSource, replacements: replacements as any },
941
- { role: 'any' },
942
- loc
943
- ) as unknown as JessNode;
944
- }
945
-
946
- // Precedence is folded in the grammar (mathSum/topSum → Operation); the base
947
- // _buildOperation / _isDivisionLike (inherited from CssParser) handle the slash-
948
- // vs-list decision. `OperationTop` dispatches here with slashEnabled = math:always.
949
-
950
- private _buildAmpersand(children: ReadonlyArray<Child>, loc: LocationInfo): JessNode {
951
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
952
- const hasParen = ls.some(l => l.value === '(');
953
- if (!hasParen) {
954
- // The ampersand token is always `&`-led (`&`, `&-bar`, `&1`) — a `.`/`#` prefix
955
- // like `.foo-&` parses as a separate BasicSelector + a bare `&`, not one token.
956
- // The suffix after `&` is the append value; a bare `&` has none.
957
- const image = ls[0]?.value ?? '&';
958
- const appendValue = this._ampersandTemplateValue(image);
959
- return new Ampersand(appendValue, {}, loc) as unknown as JessNode;
960
- }
961
- const content = ls.find(l => l.value !== '&' && l.value !== '(' && l.value !== ')')?.value ?? '';
962
- const trimmed = content.trim();
963
- const appendValue = trimmed === 'nil'
964
- ? ''
965
- : trimmed.replace(/^(['"])([\s\S]*)\1$/, '$2');
966
- return new Ampersand(appendValue, {}, loc) as unknown as JessNode;
967
- }
968
-
969
- /** The append value of a `&`-led ampersand token: the suffix after `&` (`&-bar` →
970
- * `-bar`, `&1` → `1`), or undefined for a bare `&`. */
971
- private _ampersandTemplateValue(image: string): string | undefined {
972
- return image === '&' ? undefined : image.slice(1) || undefined;
973
- }
974
-
975
- /**
976
- * A single extend target inside `extend( … )`: a complex selector plus its
977
- * optional `all` / `!all` flag (selectors.ts `complexSelector`'s OPTION2 flag).
978
- * Produced as an `Extend` carrier the surrounding pseudo/statement groups.
979
- */
980
- private _buildExtendTarget(
981
- _children: ReadonlyArray<Child>, raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo
982
- ): JessNode {
983
- // Components: the target selector (string or selector node) + an optional
984
- // trailing `all` / `!all` flag leaf. The complexSelector builder collapses a
985
- // lone `.x` to a bare string, so accept either form here.
986
- const comps = spannedComponents(raw);
987
- const isFlag = (c: unknown): c is string => typeof c === 'string' && /^!?all$/.test(c);
988
- const hasFlag = comps.some(c => isFlag(c.comp));
989
- const flag = hasFlag ? ExtendFlag.All : ExtendFlag.Exact;
990
- const targetComp = comps.find(c => !isFlag(c.comp))?.comp;
991
- const target = (typeof targetComp === 'string'
992
- ? targetComp
993
- : (targetComp ?? '&')) as Selector;
994
- return new Extend({ target, flag }, {}, loc) as unknown as JessNode;
995
- }
996
-
997
- /**
998
- * `:extend( … )` pseudo form (selectors.ts `extend`): groups its ExtendTarget
999
- * children. Targets sharing one flag collapse to a single Extend whose target is
1000
- * a SelectorList (or the lone selector); mixed flags stay as one Extend each,
1001
- * returned in a List. Mirrors mergeExtends' target-and-flag grouping.
1002
- */
1003
- private _buildExtendPseudo(children: ReadonlyArray<Child>, loc: LocationInfo): JessNode {
1004
- // Grammar guarantees ≥1 target: `extendBody = sepBy(ExtendTarget, ',')`.
1005
- const targets = nodeChildren(children).filter(n => n.type === 'Extend') as unknown as Array<{
1006
- target: Selector; flag: number;
1007
- }>;
1008
- const firstFlag = targets[0]!.flag;
1009
- const allSameFlag = targets.every(t => t.flag === firstFlag);
1010
- if (allSameFlag) {
1011
- const target = targets.length === 1
1012
- ? targets[0]!.target
1013
- : this._makeSelectorList(targets.map(t => t.target) as any, loc) as unknown as Selector;
1014
- return new Extend({ target, flag: firstFlag }, {}, loc) as unknown as JessNode;
1015
- }
1016
- const extendNodes: JessNode[] = targets.map(t =>
1017
- new Extend({ target: t.target, flag: t.flag }, {}, loc) as unknown as JessNode
1018
- );
1019
- return new List(extendNodes as any, {}, loc) as unknown as JessNode;
1020
- }
1021
-
1022
- /**
1023
- * `&:extend( … );` (or bare `:extend( … );`) statement form (selectors.ts
1024
- * `ampersandExtend`). The ExtendPseudo child already carries the grouped
1025
- * Extend(s); the leading `&` is just the statement marker.
1026
- */
1027
- private _buildExtendStatement(
1028
- children: ReadonlyArray<Child>, _raw: ReadonlyArray<{ _tag: string }>, _loc: LocationInfo
1029
- ): JessNode {
1030
- // ExtendPseudo always yields the grouped Extend (or List of Extends).
1031
- const built = nodeChildren(children).find(n => n.type === 'Extend' || n.type === 'List')!;
1032
- return built as unknown as JessNode;
1033
- }
1034
-
1035
- /**
1036
- * Decode a single `[key]` accessor into its Less lookup-key AST — the ONE shared
1037
- * decoder for every accessor-chain builder path (var-decl value, declaration value,
1038
- * at-rule prelude, namespace ref). Accepts either a raw authored key STRING (the
1039
- * text inside the brackets, e.g. `@@foo`, `$@x`, `bar`, ``), or a SquareParen `Paren`
1040
- * node whose inner content the grammar already parsed (a raw string or a `@var`
1041
- * Reference). Returns the key exactly as the reference lookupOrCall production would:
1042
- *
1043
- * `[]` → -1 (index, empty)
1044
- * `[@@name]` → Reference{type:variable,key:name} (dynamic variable lookup)
1045
- * `[$@name]` / `[@$name]` → Quoted(Interpolated(@name)) (dynamic property lookup)
1046
- * `[@name]` → 'name' (static variable lookup; bare string key)
1047
- * `[$name]` → Quoted('name') (property lookup; `$` marker dropped)
1048
- * `[name]` → Quoted('name') (index)
1049
- *
1050
- * Exactly one `@`/`$` marker is the lookup marker and is never kept.
1051
- */
1052
- private _decodeAccessorKey(
1053
- rawTextOrNode: JessNode | string,
1054
- loc: LocationInfo
1055
- ): JessNode | string | number {
1056
- // Recover the authored key text: a bare string, a SquareParen node's inner
1057
- // content (string or parsed `@foo` Reference), or an already-extracted string.
1058
- let rawText: string | undefined;
1059
- let innerVal: unknown;
1060
- if (typeof rawTextOrNode === 'string') {
1061
- rawText = rawTextOrNode.trim();
1062
- } else {
1063
- innerVal = (rawTextOrNode as any).node ?? (rawTextOrNode as any).value;
1064
- // Empty `[]` — no inner content, or an empty Keyword placeholder → index key -1.
1065
- if (!innerVal
1066
- || this._isEmptyKeywordLike(innerVal)) {
1067
- return -1;
1068
- }
1069
- if (typeof innerVal === 'string') {
1070
- rawText = innerVal.trim();
1071
- } else if (typeof innerVal === 'object'
1072
- && (innerVal as any).type === 'Reference' && typeof (innerVal as any).key === 'string') {
1073
- rawText = '@' + (innerVal as any).key;
1074
- } else if (this._isKeywordLike(innerVal)
1075
- && typeof (innerVal as any).value === 'string') {
1076
- // A bare/ident/`$prop` accessor key parsed as a Keyword leaf (e.g.
1077
- // `#ns[foo]`, `#ns.vars[$sub]`) — recover its text so the `$`/`@`/bare
1078
- // key logic below applies uniformly with the string path.
1079
- rawText = (innerVal as any).value.trim();
1080
- }
1081
- }
1082
- if (rawText === undefined || rawText === '') {
1083
- // Empty `[]` (bare string) → index key -1. A non-string/non-@var node (e.g. an
1084
- // interpolated key) falls back to its `.key` or the node itself.
1085
- if (rawText === '') {
1086
- return -1;
1087
- }
1088
- return (innerVal as any)?.key ?? (innerVal as JessNode);
1089
- }
1090
- // `@@name` → dynamic variable lookup; key is a variable Reference.
1091
- if (rawText.startsWith('@@')) {
1092
- return new Reference(rawText.slice(2), { type: 'variable' as const }, loc) as unknown as JessNode;
1093
- }
1094
- // `$@name` / `@$name` → property lookup with a dynamic (variable) name:
1095
- // Quoted(Interpolated(@name)). The `$`/`@` markers are never kept.
1096
- if (rawText.startsWith('$@') || rawText.startsWith('@$')) {
1097
- const varRef = new Reference(rawText.slice(2), { role: 'ident' as const }, loc) as unknown as Node;
1098
- const interp = new Interpolated(
1099
- { source: INTERPOLATION_PLACEHOLDER, replacements: [varRef] as any },
1100
- { role: 'ident' as const }, loc
1101
- ) as unknown as string;
1102
- return new Quoted(interp, {}, loc) as unknown as JessNode;
1103
- }
1104
- // `@name` → variable lookup; key is the bare name (a string).
1105
- if (rawText.startsWith('@')) {
1106
- return rawText.slice(1);
1107
- }
1108
- // `$name` (property reference) or bare `name` (index) → Quoted(name); the `$`
1109
- // property marker is dropped.
1110
- return new Quoted(rawText.replace(/^\$/, ''), {}, loc) as unknown as JessNode;
1111
- }
1112
-
1113
- protected override _assembleSegment(seg: Spanned[], loc: LocationInfo): Component {
1114
- const result = super._assembleSegment(seg, loc);
1115
- const isNsNameEarly = (c: unknown): c is string =>
1116
- typeof c === 'string' && /^[#.]-?[_a-zA-Z]/.test(c.trim());
1117
- if (!Array.isArray(result)) {
1118
- // A lone `#ns.mixin` / `.mixin` string in declaration-value position is a
1119
- // mixin-ruleset name Reference — not a raw string. Faithful to the reference
1120
- // `mixinReference`→`mixinName` (asReference:true). (The var-decl namespace path
1121
- // does this too via _tryParseNamespaceRef.)
1122
- if (isNsNameEarly(result)) {
1123
- const segs = (result as string).trim().match(/[#.][^#.]*/g) ?? [(result as string).trim()];
1124
- const nameKey: string | string[] = segs.length === 1 ? segs[0]! : segs;
1125
- const rawKey = segs.length > 1 ? segs.join('') : undefined;
1126
- return new Reference(
1127
- { key: nameKey, ...(rawKey ? { rawKey } : {}) } as unknown as ReferenceValue,
1128
- { type: 'mixin-ruleset', role: 'name' } as any, loc
1129
- ) as unknown as Component;
1130
- }
1131
- return result as Component;
1132
- }
1133
- if (result.length < 2) {
1134
- return result as Component;
1135
- }
1136
- const comps = result as Component[];
1137
- const isNsName = (c: unknown): c is string =>
1138
- typeof c === 'string' && /^[#.]-?[_a-zA-Z€-￿]/.test(c.trim());
1139
- const isSquareParen = (c: unknown): c is JessNode =>
1140
- !!c && typeof c === 'object' && (c as any).type === 'Paren'
1141
- && (c as any)._options?.delimiter === 'square';
1142
- const isRoundParen = (c: unknown): c is JessNode =>
1143
- !!c && typeof c === 'object' && (c as any).type === 'Paren'
1144
- && (c as any)._options?.delimiter !== 'square';
1145
- if (!isNsName(comps[0])) {
1146
- return comps as unknown as Component;
1147
- }
1148
- // Consume all leading #/.-prefixed strings as namespace path segments.
1149
- // A single token like '#ns.breakpoint' also gets split into sub-segments.
1150
- const splitNsToken = (s: string): string[] => s.match(/[#.][^#.]*/g) ?? [s];
1151
- let i = 0;
1152
- const pathSegs: string[] = [];
1153
- while (i < comps.length && isNsName(comps[i])) {
1154
- pathSegs.push(...splitNsToken((comps[i] as string).trim()));
1155
- i++;
1156
- }
1157
- const rawPathText = pathSegs.join('');
1158
- const nameKey: string | string[] = pathSegs.length === 1 ? pathSegs[0]! : pathSegs;
1159
- const rawKey = pathSegs.length > 1 ? rawPathText : undefined;
1160
- let base: JessNode = new Reference(
1161
- { key: nameKey, ...(rawKey ? { rawKey } : {}) } as unknown as ReferenceValue,
1162
- { type: 'mixin-ruleset', role: 'name' } as any, loc
1163
- ) as unknown as JessNode;
1164
- // A bare `#ns.mixin` / `.mixin` / `#ns > .a` run in declaration-value position,
1165
- // with NO following `[`/`(`, is still a mixin-ruleset name Reference — not a raw
1166
- // string/array. Faithful to the reference `mixinReference`→`mixinName`
1167
- // (asReference:true) `flushPendingAsRef` shape. (The var-decl path does this too.)
1168
- if (i >= comps.length || (!isSquareParen(comps[i]) && !isRoundParen(comps[i]))) {
1169
- // Only rewrite when the namespace run is the WHOLE value; a trailing non-paren
1170
- // component means this wasn't a lone namespace target (leave it as-is).
1171
- return (i === comps.length ? base : comps as unknown) as Component;
1172
- }
1173
- while (i < comps.length) {
1174
- const item = comps[i];
1175
- if (isSquareParen(item)) {
1176
- const innerKey = this._decodeAccessorKey(item as JessNode, loc);
1177
- // A bare-string key (`@var`) or an `@@name` indirection Reference is a
1178
- // variable lookup; a Quoted/number key is a property (`index`) lookup —
1179
- // mirror _applyReferenceAccessor's key→type logic (the var-decl accessor
1180
- // path does the same).
1181
- const keyIsVar = typeof innerKey === 'string'
1182
- || (innerKey != null && typeof innerKey === 'object'
1183
- && (innerKey as any).type === 'Reference');
1184
- const accType: 'variable' | 'index' = keyIsVar ? 'variable' : 'index';
1185
- base = new Reference(
1186
- { target: base as any, key: innerKey as any } as unknown as ReferenceValue,
1187
- { type: accType }, loc
1188
- ) as unknown as JessNode;
1189
- i++;
1190
- } else if (isRoundParen(item)) {
1191
- const innerContent = (item as any).value ?? (item as any).node;
1192
- const isEmpty = this._isEmptyKeywordLike(innerContent);
1193
- const argsNode = isEmpty ? null : this._parenToArgs(item, loc);
1194
- const callPayload: Record<string, unknown> = { name: base };
1195
- if (argsNode) {
1196
- callPayload.args = argsNode;
1197
- }
1198
- base = new Call(callPayload as any, {}, loc) as unknown as JessNode;
1199
- i++;
1200
- } else {
1201
- break;
1202
- }
1203
- }
1204
- return (i === comps.length ? base : comps as unknown) as Component;
1205
- }
1206
-
1207
- private _buildEscapedValue(children: ReadonlyArray<Child>, loc: LocationInfo): JessNode {
1208
- const inner = nodeChildren(children)[0];
1209
- if (!inner) {
1210
- return this._lessKeyword('', loc) as unknown as JessNode;
1211
- }
1212
- // Quoted keeps `escaped` as its own readonly instance field (render reads the
1213
- // field, not `_options`), so mutating `_options` alone would leave the field
1214
- // `false` and the string would print quoted. Rebuild the Quoted through its
1215
- // constructor so both the field and `_options` carry `escaped: true`. Paren
1216
- // (`~(…)`) reads `_options.escaped` directly, so the option merge suffices.
1217
- if (inner instanceof Quoted) {
1218
- return new Quoted(
1219
- inner.value,
1220
- { quote: inner.quote, escaped: true },
1221
- loc
1222
- ) as unknown as JessNode;
1223
- }
1224
- const n = inner as unknown as { _options?: Record<string, unknown> };
1225
- n._options = { ...(n._options ?? {}), escaped: true };
1226
- return inner;
1227
- }
1228
-
1229
- // `@{colorVar}` / `pre-@{x}` in value position. Port of `processValueToken`'s
1230
- // InterpolatedIdent branch: getInterpolatedOrString → Interpolated (role=ident),
1231
- // or a plain Keyword when the run resolves to a bare string.
1232
- private _buildInterpValue(raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo): JessNode {
1233
- const items = spannedComponents(raw);
1234
- const image = items.map(i => (typeof i.comp === 'string' ? i.comp : '')).join('');
1235
- const result = getInterpolatedOrString(image, loc);
1236
- if (typeof result === 'string') {
1237
- return this._lessKeyword(result, loc) as unknown as JessNode;
1238
- }
1239
- return result as unknown as JessNode;
1240
- }
1241
-
1242
- /**
1243
- * A quoted string value. Unlike plain CSS, Less interpolates `@{var}` / `${prop}`
1244
- * inside quoted (and escaped `~"…"`) strings and inside `@import` paths. When the
1245
- * raw content holds an interpolation, split it into an `Interpolated` value the same
1246
- * way the reference parser's `processStringInterpolation` does (source with
1247
- * INTERPOLATION_PLACEHOLDER, `@var`/`$prop` references in `replacements`); otherwise
1248
- * fall through to the plain CSS builder (bare-string value).
1249
- */
1250
- /**
1251
- * The Less `Url` grammar tokenizes the inner string as bare leaves (no child
1252
- * node), so the css base builder wraps a quoted url body in a raw-string
1253
- * `Quoted` — which never interpolates `@{var}`/`${prop}`. Less 4.x DOES resolve
1254
- * interpolation inside a QUOTED url body (`url("@{base}/@{i}.svg")`), the same as
1255
- * any other quoted string, so route the quoted inner through the same
1256
- * interpolation-aware construction `_buildQuoted` uses. Unquoted url bodies stay
1257
- * verbatim (Less 4.x leaves `url(@{x})` literal), as does a quoted body with no
1258
- * interpolation.
1259
- */
1260
- protected override _buildUrl(children: ReadonlyArray<Child>, loc: LocationInfo) {
1261
- const innerNode = nodeChildren(children)[0];
1262
- if (innerNode) {
1263
- return super._buildUrl(children, loc);
1264
- }
1265
- const inner = children
1266
- .filter((c): c is CSTLeaf => c._tag === 'leaf')
1267
- .filter(l => !/^url\($/i.test(l.value) && l.value !== ')')
1268
- .map(l => l.value).join('').trim();
1269
- const quote = inner[0];
1270
- if ((quote === '"' || quote === '\'') && inner.at(-1) === quote) {
1271
- const body = inner.slice(1, -1);
1272
- if (body.includes('@{') || body.includes('${')) {
1273
- const value = this._buildStringInterpolation(body, loc);
1274
- return new Url(
1275
- new Quoted(value as any, { quote }, loc) as any,
1276
- undefined, loc
1277
- ) as unknown as JessNode;
1278
- }
1279
- }
1280
- return super._buildUrl(children, loc);
1281
- }
1282
-
1283
- protected override _buildQuoted(children: ReadonlyArray<Child>, loc: LocationInfo) {
1284
- const text = children
1285
- .filter((c): c is CSTLeaf => c._tag === 'leaf')
1286
- .map(l => l.value)
1287
- .join('');
1288
- const inner = text.slice(1, -1);
1289
- if (inner.includes('@{') || inner.includes('${')) {
1290
- const value = this._buildStringInterpolation(inner, loc);
1291
- return new Quoted(value as any, { quote: text[0] as '"' | '\'' }, loc) as unknown as JessNode;
1292
- }
1293
- return super._buildQuoted(children, loc);
1294
- }
1295
-
1296
- /**
1297
- * Build an escaped `~'…'` Quoted (at-rule prelude position), interpolating any
1298
- * `@{var}` / `${prop}` in its body the same way `_buildQuoted` does — so
1299
- * `~'@{a} / @{b}'` renders its substituted values instead of literal text.
1300
- */
1301
- private _buildEscapedQuoted(inner: string, quote: '"' | '\'', loc: LocationInfo): JessNode {
1302
- const value = (inner.includes('@{') || inner.includes('${'))
1303
- ? this._buildStringInterpolation(inner, loc)
1304
- : inner;
1305
- return new Quoted(value as any, { quote, escaped: true }, loc) as unknown as JessNode;
1306
- }
1307
-
1308
- /**
1309
- * Split a quoted-string body on `@{…}` / `${…}` interpolations into an
1310
- * `Interpolated` (source + reference replacements). Port of the reference parser's
1311
- * `processStringInterpolation`/`findInterpolations` (productions/values.ts): brace
1312
- * matching is nesting-aware, and a nested-interpolated name resolves through a
1313
- * variable Reference wrapped in an Expression.
1314
- */
1315
- private _buildStringInterpolation(value: string, loc: LocationInfo): Interpolated {
1316
- const matches = this._findInterpolations(value);
1317
- const replacements: Node[] = [];
1318
- let source = value;
1319
- let offset = 0;
1320
- for (const match of matches) {
1321
- const adjustedStart = match.start - offset;
1322
- const adjustedEnd = match.end - offset;
1323
- source = source.slice(0, adjustedStart) + INTERPOLATION_PLACEHOLDER + source.slice(adjustedEnd);
1324
- offset += (match.end - match.start) - INTERPOLATION_PLACEHOLDER.length;
1325
- if (match.content.includes('@{') || match.content.includes('${')) {
1326
- // Nested interpolation resolves through a variable Reference, kept
1327
- // expression-wrapped so it re-renders as a single interpolated slot.
1328
- const nestedRef = new Reference(
1329
- { key: this._buildStringInterpolation(match.content, loc) as any } as unknown as ReferenceValue,
1330
- { type: 'variable', role: 'ident' } as any, loc
1331
- );
1332
- replacements.push(new Expression(nestedRef as any, undefined, loc) as unknown as Node);
1333
- } else {
1334
- replacements.push(createInterpolatedReference(match.prefix, match.content, loc) as unknown as Node);
1335
- }
1336
- }
1337
- return new Interpolated({ source, replacements: replacements as any }, { role: 'ident' }, loc);
1338
- }
1339
-
1340
- /**
1341
- * Locate `@{…}` / `${…}` interpolation runs in a string, counting nested braces so
1342
- * `@{@{x}}` and `@{fn(a, b)}` are matched whole. Returns start/end/prefix/content.
1343
- */
1344
- private _findInterpolations(value: string): Array<{ start: number; end: number; prefix: string; content: string }> {
1345
- const matches: Array<{ start: number; end: number; prefix: string; content: string }> = [];
1346
- let i = 0;
1347
- while (i < value.length) {
1348
- if ((value[i] === '@' || value[i] === '$') && value[i + 1] === '{') {
1349
- const prefix = value[i]!;
1350
- const start = i;
1351
- i += 2;
1352
- let braceCount = 1;
1353
- const contentStart = i;
1354
- while (i < value.length && braceCount > 0) {
1355
- if (value[i] === '{') {
1356
- braceCount++;
1357
- } else if (value[i] === '}') {
1358
- braceCount--;
1359
- }
1360
- i++;
1361
- }
1362
- if (braceCount === 0) {
1363
- matches.push({ start, end: i, prefix, content: value.slice(contentStart, i - 1) });
1364
- }
1365
- } else {
1366
- i++;
1367
- }
1368
- }
1369
- return matches;
1370
- }
1371
-
1372
- protected override _buildCall(rawChildren: ReadonlyArray<{ _tag: string }>, loc: LocationInfo) {
1373
- const call = super._buildCall(rawChildren, loc) as unknown as {
1374
- name: unknown; args: unknown; _options?: Record<string, unknown>;
1375
- };
1376
- const key = typeof call.name === 'string' ? call.name : '';
1377
- // Function calls share the mixin args grammar, so they get the same `,`/`;`
1378
- // mix rejection (e.g. `foo(@a: 1; @b: 2, @c: 3)`).
1379
- this._checkMixedArgDelimiters(call.args as unknown as JessNode, 'function', loc);
1380
- // Lower `;`-args to comma + `~(…)` (after the mixed-delimiter check), matching
1381
- // the mixin path so function calls converge on the same unified AST.
1382
- const loweredArgs = this._lowerSemiArgs(call.args as unknown as JessNode, loc);
1383
- const nameRef = new Reference(key, { type: 'function', fallbackValue: true } as any, loc);
1384
- const next = new Call({ name: nameRef as any, args: loweredArgs as any }, { silentFail: true } as any, loc);
1385
- return next as unknown as JessNode;
1386
- }
1387
-
1388
- private _buildLessCustomDecl(children: ReadonlyArray<Child>, loc: LocationInfo) {
1389
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
1390
- const propNameText = ls[0]?.value ?? '';
1391
- // `--@{key}: …` (port of the reference's InterpolatedCustomProperty branch):
1392
- // an interpolated name becomes an Interpolated node, same as a regular
1393
- // declaration's `getInterpolatedNode` branch.
1394
- const name = (propNameText.includes('@') || propNameText.includes('$'))
1395
- ? getInterpolatedNode(propNameText, loc)
1396
- : propNameText;
1397
- const valueNodes = nodeChildren(children);
1398
- if (valueNodes.length > 0) {
1399
- const value = valueNodes.length === 1 ? valueNodes[0]! : valueNodes;
1400
- return new CustomDeclaration({ name: name as any, value: value as any }, undefined, loc);
1401
- }
1402
- const valueText = ls.slice(2).filter(l => l.value !== ';').map(l => l.value).join('').trim();
1403
- return new CustomDeclaration({ name: name as any, value: this._lessKeyword(valueText, loc) as any }, undefined, loc);
1404
- }
1405
-
1406
- /**
1407
- * `--foo: { color: @a; }` — a curly-brace custom-property value whose body
1408
- * opportunistically structured as a declaration list (customCurlyBlock in the
1409
- * grammar), so nested `@var`/calls evaluate normally instead of staying opaque
1410
- * text. Wrapped in a Block(type: 'curly') so `{`/`}` re-render around it.
1411
- */
1412
- private _buildLessCustomBlock(children: ReadonlyArray<Child>, loc: LocationInfo): JessNode {
1413
- const bodyNodes = nodeChildren(children);
1414
- // Block.value must remain a single Node; Sequence is the interim container until
1415
- // Block can hold a bare declaration array.
1416
- const seq = new Sequence(bodyNodes as any, undefined, loc);
1417
- return new Block(seq as any, { type: 'curly' }, loc) as unknown as JessNode;
1418
- }
1419
-
1420
- private _warnDeprecatedValue(span: Span) {
1421
- const text = this._source.slice(span.start, span.end);
1422
- if (/\d\s*\.\//.test(text)) {
1423
- this._warn('The ./ operator is deprecated and will be removed.', 'dot-slash-operator');
1424
- }
1425
- }
1426
-
1427
- private _warnCustomPropVars(span: Span) {
1428
- const text = this._source.slice(span.start, span.end);
1429
- const colon = text.indexOf(':');
1430
- const value = colon >= 0 ? text.slice(colon + 1) : text;
1431
- const at = value.match(/@[a-zA-Z][\w-]*/);
1432
- if (at && !value.includes('@{')) {
1433
- this._warn(
1434
- `"${at[0]}" in custom property values is treated as literal text. Use @{${at[0].slice(1)}} for interpolation.`,
1435
- 'variable-in-unknown-value'
1436
- );
1437
- }
1438
- const dollar = value.match(/\$[a-zA-Z][\w-]*/);
1439
- if (dollar && !value.includes('${')) {
1440
- this._warn(
1441
- `"${dollar[0]}" in custom property values is treated as literal text. Use \${${dollar[0].slice(1)}} for interpolation.`,
1442
- 'property-in-unknown-value'
1443
- );
1444
- }
1445
- }
1446
-
1447
- private _warnAtRulePreludeVars(span: Span) {
1448
- const text = this._source.slice(span.start, span.end);
1449
- const varName = this._firstTopLevelBareAtVar(text);
1450
- if (varName !== null) {
1451
- this._warn(
1452
- `A bare "@${varName}" in an at-rule prelude is deprecated. Use @{${varName}} interpolation instead.`,
1453
- 'variable-in-at-rule-prelude'
1454
- );
1455
- }
1456
- }
1457
-
1458
- /**
1459
- * The first bare `@ident` reference in an at-rule prelude that is deprecated
1460
- * under Less 4.x PR #4462 (`variable-in-at-rule-prelude`), or null when there
1461
- * is none. A bare `@var` in a *structural* (top-level) prelude position still
1462
- * resolves but is deprecated in favour of `@{var}` interpolation; the scan
1463
- * therefore ignores, mirroring `hasTopLevelBareVariable` / `warnBareAtRuleVariable`:
1464
- * - the leading at-rule name itself (`@media`, `@-moz-document`, …);
1465
- * - `@{ident}` interpolation — the supported migration target;
1466
- * - a `@var` inside `(...)` — a declaration/feature value (e.g. the `@size`
1467
- * in `@media (min-width: @size)`), which stays valid;
1468
- * - `@`/`(` characters inside string literals, which are not structural.
1469
- */
1470
- private _firstTopLevelBareAtVar(text: string): string | null {
1471
- let depth = 0;
1472
- // Skip the leading at-rule name (`@media`, `@-moz-document`, …).
1473
- let i = /^\s*@-?[\w-]+/.exec(text)?.[0].length ?? 0;
1474
- for (; i < text.length; i++) {
1475
- const c = text[i]!;
1476
- if (c === '"' || c === '\'') {
1477
- // A string literal: skip its contents so inner `(`/`@` are not counted.
1478
- i++;
1479
- while (i < text.length && text[i] !== c) {
1480
- i++;
1481
- }
1482
- continue;
1483
- }
1484
- if (c === '@') {
1485
- if (text[i + 1] === '{') {
1486
- // `@{ident}` interpolation — skip the whole group (its `}` is not the
1487
- // block opener, and a later bare `@var` must still be reported).
1488
- i += 2;
1489
- while (i < text.length && text[i] !== '}') {
1490
- i++;
1491
- }
1492
- continue;
1493
- }
1494
- if (depth === 0) {
1495
- const m = /^@(-?[a-zA-Z\x80-￿][\w-]*)/.exec(text.slice(i));
1496
- if (m) {
1497
- return m[1]!;
1498
- }
1499
- }
1500
- continue;
1501
- }
1502
- if (c === '{') {
1503
- // The block's opening brace ends the prelude.
1504
- break;
1505
- }
1506
- if (c === '(') {
1507
- depth++;
1508
- } else if (c === ')' && depth > 0) {
1509
- depth--;
1510
- }
1511
- }
1512
- return null;
1513
- }
1514
-
1515
- private _buildMixinCall(
1516
- children: ReadonlyArray<Child>,
1517
- raw: ReadonlyArray<{ _tag: string }>,
1518
- loc: LocationInfo
1519
- ): JessNode {
1520
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
1521
- // `!important` (the `!`/`important` leaves) must end the name path and set
1522
- // markImportant — never leak into the Reference key.
1523
- const markImportant = ls.some(l => l.value === '!');
1524
- const nameParts: string[] = [];
1525
- for (const l of ls) {
1526
- if (l.value === '(' || l.value === ';' || l.value === '!') {
1527
- break;
1528
- }
1529
- nameParts.push(l.value);
1530
- }
1531
- const name = nameParts.join('');
1532
- const nodes = nodeChildren(children);
1533
- const argsList = nodes.find(n => n.type === 'List');
1534
- const hasArgs = argsList && (argsList as unknown as { value?: unknown[] }).value?.length;
1535
- if (argsList === undefined) {
1536
- this._warn('Calling a mixin without parentheses is deprecated', 'mixin-call-no-parens');
1537
- } else {
1538
- const src = this._source.slice(loc.start, loc.end);
1539
- if (/^\S+\s+\(/.test(src)) {
1540
- this._warn('Whitespace between a mixin name and parentheses is deprecated', 'mixin-call-whitespace');
1541
- }
1542
- }
1543
- const ref = new Reference(
1544
- { key: name } as unknown as ReferenceValue,
1545
- { type: 'mixin-ruleset', role: 'name' } as any,
1546
- loc
1547
- );
1548
- const callArgs = hasArgs ? this._convertArgsForCall(argsList as unknown as JessNode, loc) : undefined;
1549
- return new Call(
1550
- { name: ref as any, args: callArgs as any },
1551
- { markImportant } as any, loc
1552
- ) as unknown as JessNode;
1553
- }
1554
-
1555
- /**
1556
- * `@name(...)` (no `:`) → a detached-ruleset variable CALL. Faithful port of
1557
- * `varDeclarationOrCall`'s LParen branch (selectors.ts): build a `Reference`
1558
- * over the var name (`type: 'variable', role: 'name'`), wrap in a `Call` with
1559
- * the (optional) args, and wrap THAT in an `Expression` (a top-level variable
1560
- * call is an expression, not a parenthesized one). `!important` sets
1561
- * `markImportant` on the Call, mirroring the production.
1562
- */
1563
- private _buildVarCall(
1564
- children: ReadonlyArray<Child>,
1565
- raw: ReadonlyArray<{ _tag: string }>,
1566
- loc: LocationInfo
1567
- ): JessNode {
1568
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
1569
- const markImportant = ls.some(l => l.value === '!');
1570
- // First leaf is the `@name` token (the MixinArgs parens live in the sub-node).
1571
- const nameLeaf = ls.find(l => l.value.startsWith('@'));
1572
- const rawName = nameLeaf?.value ?? '';
1573
- const name = rawName.startsWith('@') ? rawName.slice(1) : rawName;
1574
- const nameNode = this._lessKeyword(name, loc);
1575
- const nameRef = new Reference(
1576
- { key: nameNode } as unknown as ReferenceValue,
1577
- { type: 'variable', role: 'name' } as any,
1578
- loc
1579
- );
1580
- const nodes = nodeChildren(children);
1581
- const argsList = nodes.find(n => n.type === 'List');
1582
- const hasArgs = argsList && (argsList as unknown as { value?: unknown[] }).value?.length;
1583
- // `@media()` etc — a known at-rule name used as a variable call, allowed only
1584
- // with empty parens (port of isVariableLike's 'at-rule-variable' warning).
1585
- if (!hasArgs && KNOWN_AT_RULE_VAR_NAME_RE.test(name)) {
1586
- this._warn('Using known at-rule names as variables is deprecated', 'at-rule-variable');
1587
- }
1588
- const callArgs = hasArgs ? this._convertArgsForCall(argsList as unknown as JessNode, loc) : undefined;
1589
- const call = new Call(
1590
- { name: nameRef as any, args: callArgs as any },
1591
- (markImportant ? { markImportant: true } : undefined) as any,
1592
- loc
1593
- );
1594
- return new Expression(call as unknown as Node, undefined, loc) as unknown as JessNode;
1595
- }
1596
-
1597
- /** `...` or `@name...` variadic arg → `Rest`. Definition-shape (string name); a
1598
- * CALL turns it into `Rest(Reference)` via `_convertArgsForCall`. */
1599
- private _buildRest(raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo): JessNode {
1600
- const items = spannedComponents(raw);
1601
- const nameItem = items.find(i => typeof i.comp === 'string' && i.comp.startsWith('@'));
1602
- const name = nameItem ? String(nameItem.comp).slice(1) : '';
1603
- return new Rest(name, {}, loc) as unknown as JessNode;
1604
- }
1605
-
1606
- /** `@name: value` named arg/param → `VarDeclaration`. The value is assembled by the
1607
- * shared value builder (`_assembleValue`) — the same machinery as a declaration
1608
- * value, so trivia and Keyword-ification are handled and no manual trimming is
1609
- * needed. Named args flow through function calls too; the runtime decides whether
1610
- * the target accepts them. */
1611
- private _buildNamedArg(raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo): JessNode {
1612
- const items = spannedComponents(raw);
1613
- const colonIdx = items.findIndex(i => i.comp === ':');
1614
- const nameItem = items.find(i => typeof i.comp === 'string' && i.comp.startsWith('@'));
1615
- const name = String(nameItem?.comp ?? '').slice(1);
1616
- const valueItems = colonIdx >= 0 ? items.slice(colonIdx + 1) : [];
1617
- const { value } = this._assembleValue(valueItems, loc);
1618
- // A param/arg VarDeclaration value is always a single Node in the callable-
1619
- // binding path (it calls `value.hasFlag(...)`). `_assembleValue` leaves a lone
1620
- // bare keyword (`@a: inherit`) as a raw string, and a space-separated segment
1621
- // (`@padding: 40px 10px`) as a bare Component array — wrap each into a Node.
1622
- let paramValue: Component;
1623
- if (typeof value === 'string') {
1624
- paramValue = this._valueKeyword(value, loc) as unknown as Component;
1625
- } else if (Array.isArray(value)) {
1626
- const seq = value.map(c => this._argComponent(c, loc));
1627
- paramValue = new Sequence(seq as unknown as Node[], undefined, loc) as unknown as Component;
1628
- } else {
1629
- paramValue = value;
1630
- }
1631
- return new VarDeclaration(
1632
- { name: name as any, value: paramValue as any } as any,
1633
- {} as VarDeclarationOptions,
1634
- loc
1635
- ) as unknown as JessNode;
1636
- }
1637
-
1638
- /** Mixin-call args are assembled by the SAME builder as function-call args
1639
- * (`_assembleArgs` via `_betweenParens`) — identical comma/semicolon and value
1640
- * handling. Named args are `VarDeclaration`s and variadic args `Rest`, which pass
1641
- * through as single components. A bare `@name` is a `Reference` (the call shape);
1642
- * the mixin-DEFINITION builder reinterprets a lone `@name` as a param. */
1643
- private _buildMixinArgs(raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo) {
1644
- const inner = this._betweenParens(spannedComponents(raw));
1645
- const args = this._assembleArgs(inner, loc);
1646
- this._checkMixedArgDelimiters(args as unknown as JessNode, 'mixin', loc);
1647
- // Lower `;`-args to comma + `~(…)` (after the mixed-delimiter check, which
1648
- // needs the `;`-List) so Less `;` and Jess `~(…)` produce the same AST.
1649
- return this._lowerSemiArgs(args as unknown as JessNode, loc) as unknown as typeof args;
1650
- }
1651
-
1652
- /** Less forbids mixing the COMMA and SEMICOLON argument separators: once a
1653
- * semicolon separates args, a comma is a value-list separator, so a semicolon-group
1654
- * may not hold 2+ named params (`@a: 1, @b: 2`). `_assembleArgs` renders such a group
1655
- * as a List of ≥2 VarDeclarations. (This is purely about the `,` vs `;` argument
1656
- * separators — a `/` inside a value is unrelated and never checked.) Applies to BOTH
1657
- * mixin and function calls (args are unified). */
1658
- private _checkMixedArgDelimiters(args: JessNode | undefined, kind: 'mixin' | 'function', loc: LocationInfo): void {
1659
- const list = args as unknown as { type?: string; options?: { sep?: string }; value?: JessNode[] };
1660
- if (list?.type !== 'List' || list.options?.sep !== ';' || !Array.isArray(list.value)) {
1661
- return;
1662
- }
1663
- for (const el of list.value) {
1664
- const group = el as unknown as { type?: string; value?: JessNode[] };
1665
- if (group?.type === 'List' && Array.isArray(group.value)
1666
- && group.value.filter(n => (n as { type?: string })?.type === 'VarDeclaration').length >= 2) {
1667
- this._error(`Cannot mix ; and , as delimiter types in ${kind} arguments`, loc.start);
1668
- break;
1669
- }
1670
- }
1671
- }
1672
-
1673
- /**
1674
- * Lower Less `;`-separated call args to the unified Jess representation: the outer
1675
- * args `List{ sep: ';' }` becomes comma-separated, and each element that is itself
1676
- * a comma-`List` (a `;`-group that held a comma-list) is wrapped in an escaped
1677
- * `Paren` — the same shape Jess authors write as `~(1, 2)`. So
1678
- * `.mixin(1, 2; 3, 4)` and Jess `mixin(~(1, 2), ~(3, 4))` converge on one AST.
1679
- *
1680
- * The escaped `Paren` evaluates to its inner value STRIPPED (paren.ts §escaped),
1681
- * so `~(1, 2)` binds/renders identically to the bare list `1, 2` — representation
1682
- * only, semantics unchanged. Scalar (non-List) elements pass through untouched.
1683
- *
1684
- * MUST run AFTER `_checkMixedArgDelimiters` (which inspects the `;`-List).
1685
- */
1686
- private _lowerSemiArgs(args: JessNode | undefined, loc: LocationInfo): JessNode | undefined {
1687
- const list = args as unknown as { type?: string; options?: { sep?: string }; value?: JessNode[] } | undefined;
1688
- if (!list || list.type !== 'List' || list.options?.sep !== ';' || !Array.isArray(list.value)) {
1689
- return args;
1690
- }
1691
- const lowered = list.value.map((el) => {
1692
- // A comma-list arg (an inner `List`) becomes `~(…)`; scalars stay as-is.
1693
- if ((el as { type?: string })?.type === 'List') {
1694
- return new Paren(el as unknown as Node, { escaped: true }, loc) as unknown as JessNode;
1695
- }
1696
- return el;
1697
- });
1698
- return new List(lowered as unknown as Node[], undefined, loc) as unknown as JessNode;
1699
- }
1700
-
1701
- /**
1702
- * Mixin-DEFINITION param conversion. With combinator-composed args a bare `@name`
1703
- * value parses as a `Reference{variable}` (the CALL shape); in a DEFINITION it is a
1704
- * param, so convert it to `VarDeclaration(name, Nil)`. Named params (`@a: 1`),
1705
- * variadic (`Rest`) and pattern-match values stay as-is. Returns a NEW List (the
1706
- * def/call split must not mutate a shared node).
1707
- */
1708
- private _convertArgsForDefinition(argsList: JessNode | undefined, loc: LocationInfo): JessNode | undefined {
1709
- if (!argsList || argsList.type !== 'List') {
1710
- return argsList;
1711
- }
1712
- const list = argsList as unknown as List<Node>;
1713
- const value = (list as unknown as { value?: JessNode[] }).value;
1714
- if (!value || value.length === 0) {
1715
- return argsList;
1716
- }
1717
- let changed = false;
1718
- const converted = value.map((node): JessNode => {
1719
- if (node.type === 'Reference'
1720
- && (node as unknown as { options?: { type?: string } }).options?.type === 'variable') {
1721
- const key = (node as unknown as { key?: unknown }).key;
1722
- const name = typeof key === 'string'
1723
- ? key
1724
- : String((key as { valueOf?(): unknown } | undefined)?.valueOf?.() ?? '');
1725
- changed = true;
1726
- return new VarDeclaration(
1727
- { name: name as any, value: new Nil('', {}, loc) as unknown as JessNode as any } as any,
1728
- {} as VarDeclarationOptions,
1729
- loc
1730
- ) as unknown as JessNode;
1731
- }
1732
- return node;
1733
- });
1734
- if (!changed) {
1735
- return argsList;
1736
- }
1737
- return new List(converted as any, list.options as any, loc) as unknown as JessNode;
1738
- }
1739
-
1740
- /**
1741
- * Mixin-CALL argument conversion (reference `convertArgsForCall`, root.ts).
1742
- * `_buildMixinArgs` builds bare `@name` args as definition-style VarDeclarations
1743
- * (Nil value) — correct for a DEFINITION param, but in a CALL a bare `@name` is a
1744
- * variable being PASSED, i.e. a `Reference{type:variable}`. Named args (`@a: 1`)
1745
- * and value args stay as-is; a `Rest('name')` becomes `Rest(Reference{variable})`.
1746
- * Returns a NEW List (the def/call split must not mutate a shared node).
1747
- */
1748
- private _convertArgsForCall(argsList: JessNode | undefined, loc: LocationInfo): JessNode | undefined {
1749
- if (!argsList || argsList.type !== 'List') {
1750
- return argsList;
1751
- }
1752
- const list = argsList as unknown as List<Node>;
1753
- const value = (list as unknown as { value?: JessNode[] }).value;
1754
- if (!value || value.length === 0) {
1755
- return argsList;
1756
- }
1757
- let changed = false;
1758
- const converted = value.map((node): JessNode => {
1759
- if (node.type === 'VarDeclaration') {
1760
- const decl = node as unknown as { name: JessNode; value?: JessNode };
1761
- const val = decl.value;
1762
- if (!val || val.type === 'Nil') {
1763
- // Bare `@name` → a variable reference being passed to the call.
1764
- const key = (decl.name as unknown as { valueOf(): string }).valueOf();
1765
- changed = true;
1766
- return new Reference(
1767
- { key } as unknown as ReferenceValue,
1768
- { type: 'variable' } as any,
1769
- loc
1770
- ) as unknown as JessNode;
1771
- }
1772
- return node;
1773
- }
1774
- if (node.type === 'Rest') {
1775
- const restVal = (node as unknown as { value: unknown }).value;
1776
- if (typeof restVal === 'string') {
1777
- changed = true;
1778
- return new Rest(
1779
- new Reference({ key: restVal } as unknown as ReferenceValue, { type: 'variable' } as any, loc) as any,
1780
- {} as any,
1781
- loc
1782
- ) as unknown as JessNode;
1783
- }
1784
- return node;
1785
- }
1786
- return node;
1787
- });
1788
- if (!changed) {
1789
- return argsList;
1790
- }
1791
- return new List(
1792
- converted as any,
1793
- list.options as any,
1794
- loc
1795
- ) as unknown as JessNode;
1796
- }
1797
-
1798
- private _buildAnonMixin(children: ReadonlyArray<Child>, loc: LocationInfo) {
1799
- // `.(@p) { … }` → a nameless Mixin (reference `anonymousMixinDefinition`,
1800
- // selectors.ts: `new Mixin({ params, rules })`), NOT a `.`-selector Ruleset.
1801
- // The MixinArgs sub-node is the param List; everything else is the body.
1802
- const nodes = nodeChildren(children);
1803
- const argsList = nodes.find(n => n.type === 'List');
1804
- const rules = nodes.filter(n => n !== argsList);
1805
- // Definition params: a bare `@name` value is a param, so reinterpret it.
1806
- const defParams = this._convertArgsForDefinition(argsList as unknown as JessNode, loc);
1807
- const params = (defParams as unknown as { value?: unknown[] })?.value?.length
1808
- ? defParams as unknown as List<Node>
1809
- : undefined;
1810
- return new Mixin(
1811
- { params, rules } as any,
1812
- undefined,
1813
- loc
1814
- ) as unknown as JessNode;
1815
- }
1816
-
1817
- /**
1818
- * `each(<iterable>, { … })` → a `For` control node (the $for shape), not a Call.
1819
- * The value(s) before the comma are the iterable; the callback block's body becomes
1820
- * the loop rules. A literal block callback carries no captured params here, so the
1821
- * pattern defaults to the Less `[value, key, index]` triple.
1822
- */
1823
- /** A bare detached ruleset `{ … }` in value / argument position → a Mixin holding
1824
- * its rules (same shape `@var: { … }` produces in `_buildVarDeclaration`). */
1825
- private _buildDetachedRuleset(children: ReadonlyArray<Child>, loc: LocationInfo) {
1826
- const ruleNodes = nodeChildren(children);
1827
- return new Mixin({ rules: ruleNodes } as any, {}, loc);
1828
- }
1829
-
1830
- private _buildEachFor(children: ReadonlyArray<Child>, loc: LocationInfo) {
1831
- // Args come from the shared functionCallArgs: the callback (detached ruleset /
1832
- // `.(…){…}`) is a Mixin sub-node; everything else is the iterable.
1833
- const nodes = nodeChildren(children);
1834
- const callback = nodes.find(n => n.type === 'Mixin') as unknown as { rules?: JessNode[]; params?: JessNode } | undefined;
1835
- const iterableNodes = nodes.filter(n => (n as unknown) !== (callback as unknown));
1836
- const paramsList = ((callback?.params as unknown as { type?: string } | undefined)?.type === 'List')
1837
- ? callback!.params as JessNode
1838
- : undefined;
1839
- const ruleNodes = callback?.rules ?? [];
1840
- const iterable: JessNode = iterableNodes.length === 1
1841
- ? iterableNodes[0]!
1842
- : (new List(iterableNodes as any, undefined, loc) as unknown as JessNode);
1843
- return new For(
1844
- { pattern: this._eachPattern(paramsList, loc), iterable: { kind: 'node', value: iterable as unknown as Node }, rules: ruleNodes as unknown as Node[] },
1845
- undefined,
1846
- loc
1847
- );
1848
- }
1849
-
1850
- private _eachPattern(paramsList: JessNode | undefined, loc: LocationInfo): ForPattern {
1851
- // Explicit `.(@v; @i)` callback params parse straight into VarDeclaration nodes
1852
- // (name 'v'/'i'); reuse them as the loop's binding pattern.
1853
- const params = ((paramsList as unknown as { value?: JessNode[] } | undefined)?.value ?? [])
1854
- .filter((p): p is JessNode => p?.type === 'VarDeclaration');
1855
- if (params.length === 1) {
1856
- return { kind: 'single', value: params[0] as any };
1857
- }
1858
- if (params.length >= 2) {
1859
- return { kind: 'tuple', values: [params[0], ...params.slice(1)] as any };
1860
- }
1861
- // A param-less block callback iterates with the Less default triple.
1862
- const paramVar = (name: string) => new VarDeclaration(
1863
- { name, value: this._lessKeyword('', loc) } as any,
1864
- { paramVar: true } as any,
1865
- loc
1866
- );
1867
- return { kind: 'tuple', values: [paramVar('value'), paramVar('key'), paramVar('index')] };
1868
- }
1869
-
1870
- private _buildMixinOrQualified(children: ReadonlyArray<Child>, loc: LocationInfo) {
1871
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
1872
- const nodes = nodeChildren(children);
1873
- const hasBlock = ls.some(l => l.value === '{');
1874
- // `!important` on a mixin call: the `!`/`important` leaves are direct children
1875
- // (MixinArgs's own parens live in the sub-node, not in `ls`). The `!` ends the
1876
- // name path and flips markImportant — it must NOT leak into the Reference key.
1877
- const markImportant = ls.some(l => l.value === '!');
1878
- const nameParts: string[] = [];
1879
- for (const l of ls) {
1880
- if (l.value === '(' || l.value === '{' || l.value === '}' || l.value === ';' || l.value === ')' || l.value === '!') {
1881
- break;
1882
- }
1883
- nameParts.push(l.value);
1884
- }
1885
- const name = nameParts.join('');
1886
- const argsList = nodes.find(n => n.type === 'List');
1887
- const guard = nodes.find(n => n.type === 'Paren' || n.type === 'Condition' || n.type === 'DefaultGuard');
1888
- // argsList presence (from MixinArgs sub-node) signals explicit parens
1889
- const hasExplicitParens = argsList !== undefined;
1890
- if (hasBlock) {
1891
- const rawRuleNodes = nodes.filter(n => n !== argsList && n !== guard);
1892
- // Lift standalone comments in the body (the Mixin/qualified-rule body is built
1893
- // inline here, bypassing _buildRuleset's own comment lift).
1894
- const braceIdx = this._source.indexOf('{', loc.start);
1895
- const bodyStart = braceIdx >= 0 ? braceIdx + 1 : loc.start;
1896
- const closeIdx = this._source.lastIndexOf('}', loc.end - 1);
1897
- const bodyEnd = closeIdx >= bodyStart ? closeIdx : loc.end;
1898
- const ruleNodes = this._liftStandaloneComments(rawRuleNodes as any, bodyStart, bodyEnd, loc);
1899
- if (hasExplicitParens) {
1900
- // Has explicit parens -- it's a Mixin definition
1901
- const guardText = guard !== undefined ? (guard as any).toTrimmedString?.() ?? '' : '';
1902
- const hasDefault = guardText.includes('default');
1903
- // Definition params: reinterpret a bare `@name` value as a param.
1904
- const defParams = this._convertArgsForDefinition(argsList as unknown as JessNode, loc);
1905
- const nonEmptyParams = (defParams as unknown as { value?: unknown[] })?.value?.length
1906
- ? defParams as unknown as List<Node>
1907
- : undefined;
1908
- return new Mixin(
1909
- { name, params: nonEmptyParams, rules: ruleNodes, guard: guard as any },
1910
- { hasDefault: !!hasDefault },
1911
- loc
1912
- ) as unknown as JessNode;
1913
- }
1914
- // No parens -- qualified rule (Ruleset)
1915
- // Extract any Extend nodes from the selector and prepend them to rules
1916
- const { cleanedSelector, extractedExtends } = this._extractExtendsFromSelectorText(name || '&', loc);
1917
- const finalRules = extractedExtends.length > 0
1918
- ? [...extractedExtends, ...ruleNodes]
1919
- : ruleNodes;
1920
- return new Ruleset(
1921
- { selector: cleanedSelector || '&', rules: finalRules, guard: guard as any },
1922
- undefined, loc
1923
- ) as unknown as JessNode;
1924
- }
1925
- // Build the rawKey ComplexSelector from the name parts (for complex paths)
1926
- const combinatorValues = new Set(['>', '+', '~']);
1927
- const selectorTokens = nameParts.filter(p => !combinatorValues.has(p.trim()) && p.trim() !== '');
1928
- const hasComplexPath = selectorTokens.length > 1;
1929
- const refKey: string | string[] = hasComplexPath ? selectorTokens : name;
1930
- const rawKey = hasComplexPath ? new ComplexSelector(nameParts as unknown as ComplexSelectorValue, undefined, loc) : undefined;
1931
- const isMixinName = name.startsWith('.') || name.startsWith('#');
1932
- const ref = isMixinName
1933
- ? new Reference(
1934
- { key: refKey, ...(rawKey ? { rawKey } : {}) } as unknown as ReferenceValue,
1935
- { type: 'mixin-ruleset', role: 'name' } as any,
1936
- loc
1937
- )
1938
- : new Reference(
1939
- { key: refKey } as unknown as ReferenceValue,
1940
- { type: 'function', silentFail: true, fallbackValue: true } as any,
1941
- loc
1942
- );
1943
- const hasArgs2 = argsList && (argsList as unknown as { value?: unknown[] }).value?.length;
1944
- const hasSemi = ls.some(l => l.value === ';');
1945
- if (hasSemi && !hasExplicitParens) {
1946
- this._warn('Calling a mixin without parentheses is deprecated', 'mixin-call-no-parens');
1947
- } else if (hasSemi && hasExplicitParens) {
1948
- const src = this._source.slice(loc.start, loc.end);
1949
- if (/^\S+\s+\(/.test(src)) {
1950
- this._warn('Whitespace between a mixin name and parentheses is deprecated', 'mixin-call-whitespace');
1951
- }
1952
- }
1953
- const callArgs = hasArgs2 ? this._convertArgsForCall(argsList, loc) : undefined;
1954
- return new Call(
1955
- { name: ref as any, args: callArgs as any },
1956
- { markImportant } as any, loc
1957
- ) as unknown as JessNode;
1958
- }
1959
-
1960
- private _extractExtendsFromSelectorText(selectorText: string, _loc: LocationInfo) {
1961
- // Simple passthrough - extend extraction from selector text is handled elsewhere
1962
- return { cleanedSelector: selectorText, extractedExtends: [] as JessNode[] };
1963
- }
1964
-
1965
- private _selectorHasNestedExtend(sel: JessNode | string | undefined): boolean {
1966
- if (!sel || typeof sel === 'string') {
1967
- return false;
1968
- }
1969
- if (sel.type === 'PseudoSelector') {
1970
- const arg = (sel as unknown as { arg?: JessNode }).arg;
1971
- return arg ? this._treeHasExtend(arg) : false;
1972
- }
1973
- if (sel instanceof CompoundSelector || sel instanceof ComplexSelector) {
1974
- return sel.value.some(p => this._selectorHasNestedExtend(p as JessNode));
1975
- }
1976
- if (isSelectorListLike(sel)) {
1977
- return selectorListItems(sel).some(p => this._selectorHasNestedExtend(p as JessNode));
1978
- }
1979
- return false;
1980
- }
1981
-
1982
- private _treeHasExtend(node: JessNode): boolean {
1983
- if (node instanceof Extend) {
1984
- return true;
1985
- }
1986
- if (node instanceof CompoundSelector || node instanceof ComplexSelector) {
1987
- return node.value.some(p => this._treeHasExtend(p as JessNode));
1988
- }
1989
- if (isSelectorListLike(node)) {
1990
- return selectorListItems(node).some(p => this._treeHasExtend(p as JessNode));
1991
- }
1992
- return false;
1993
- }
1994
-
1995
- /**
1996
- * A guarded ruleset (`sel when …`) is parsed by the shared CSS builder, which
1997
- * has no `when` concept, so the Guard CST child folds into the body as the
1998
- * first rule — always a Paren/Condition/DefaultGuard. Lift it into the
1999
- * ruleset's `guard` field (rebuilt through the canonical Ruleset ctor so the
2000
- * guard is adopted) so it gates output instead of rendering as a `{ true }`
2001
- * body. Non-guarded rulesets never begin their body with one of these node
2002
- * types, so the leading-node check is unambiguous.
2003
- */
2004
- private _liftRulesetGuard(base: Ruleset, loc: LocationInfo): Ruleset {
2005
- const rules = (base as unknown as { rules?: unknown }).rules;
2006
- if (!Array.isArray(rules) || rules.length === 0) {
2007
- return base;
2008
- }
2009
- const first = rules[0] as { type?: string } | undefined;
2010
- if (first?.type !== 'Paren' && first?.type !== 'Condition' && first?.type !== 'DefaultGuard') {
2011
- return base;
2012
- }
2013
- return new Ruleset(
2014
- {
2015
- selector: (base as unknown as { selector: any }).selector,
2016
- rules: rules.slice(1) as any,
2017
- guard: first as any
2018
- },
2019
- undefined, loc
2020
- ) as unknown as Ruleset;
2021
- }
2022
-
2023
- protected override _buildRuleset(
2024
- children: ReadonlyArray<Child>,
2025
- rawChildren: ReadonlyArray<{ _tag: string }>,
2026
- loc: LocationInfo
2027
- ) {
2028
- let base = super._buildRuleset(children, rawChildren, loc);
2029
- const selector = base.selector;
2030
- if (!selector) {
2031
- return base;
2032
- }
2033
- // The shared CSS builder has no notion of `when` guards, so the Guard CST
2034
- // child lands as the first body rule. A guarded ruleset (`sel when …`)
2035
- // always emits its guard as a leading Paren/Condition/DefaultGuard; lift it
2036
- // into the ruleset's `guard` field so it gates output instead of rendering.
2037
- base = this._liftRulesetGuard(base, loc);
2038
- if (typeof selector === 'string') {
2039
- return base;
2040
- }
2041
- if (this._selectorHasNestedExtend(selector as unknown as JessNode)) {
2042
- this._error(':extend() is not allowed inside a pseudo-class selector', loc.start);
2043
- }
2044
- const baseRules = Array.isArray(base.rules) ? base.rules as JessNode[] : [];
2045
- const baseGuard = (base as unknown as { guard?: unknown }).guard;
2046
- const withGuard = (rs: Ruleset): Ruleset => {
2047
- if (baseGuard !== undefined) {
2048
- (rs as unknown as { guard?: unknown }).guard = baseGuard;
2049
- }
2050
- return rs;
2051
- };
2052
-
2053
- const extendKey = (e: JessNode): string => {
2054
- const ext = e as unknown as { target?: { valueOf?(): unknown }; flag?: unknown };
2055
- return `${String(ext.target?.valueOf?.() ?? ext.target)}:${ext.flag}`;
2056
- };
2057
-
2058
- // Non-list: simple single-selector extraction.
2059
- if (!isSelectorListLike(selector)) {
2060
- const { cleanedSelector, extractedExtends } = this._extractExtendsFromSelector(
2061
- selector as unknown as JessNode, loc
2062
- );
2063
- if (extractedExtends.length === 0) {
2064
- return base;
2065
- }
2066
- return withGuard(new Ruleset(
2067
- { selector: cleanedSelector as any, rules: [...extractedExtends, ...baseRules] },
2068
- undefined, loc
2069
- )) as unknown as Ruleset;
2070
- }
2071
-
2072
- // Selector list: extract extends per selector, then decide structure.
2073
- const perSelector: Array<{ clean: JessNode | string | undefined; extends: JessNode[] }> = [];
2074
- let anyExtends = false;
2075
- for (const item of selectorListItems(selector)) {
2076
- const { cleanedSelector: cs, extractedExtends: ee } = this._extractExtendsFromSelector(
2077
- item as unknown as JessNode, loc
2078
- );
2079
- perSelector.push({ clean: cs, extends: ee });
2080
- if (ee.length > 0) {
2081
- anyExtends = true;
2082
- }
2083
- }
2084
-
2085
- if (!anyExtends) {
2086
- return base;
2087
- }
2088
-
2089
- // If all selectors share identical extend sets → flat Ruleset, deduplicated extends.
2090
- const allExtendKeys = perSelector.map(s => s.extends.map(extendKey).sort().join('|'));
2091
- const allSame = allExtendKeys.every(k => k === allExtendKeys[0]!);
2092
-
2093
- if (allSame) {
2094
- const uniqueExtends = perSelector[0]!.extends;
2095
- const cleanedItems = perSelector.map(s => s.clean).filter((c): c is JessNode | string => c !== undefined);
2096
- const combinedSel = cleanedItems.length === 1
2097
- ? cleanedItems[0]!
2098
- : this._makeSelectorList(cleanedItems as any, loc);
2099
- return withGuard(new Ruleset(
2100
- { selector: combinedSel as any, rules: [...uniqueExtends, ...baseRules] },
2101
- undefined, loc
2102
- )) as unknown as Ruleset;
2103
- }
2104
-
2105
- // Different extends per selector → Rules wrapper with per-selector Extend nodes.
2106
- const wrapperRules: JessNode[] = [];
2107
- const cleanedItems: (JessNode | string)[] = [];
2108
- for (const { clean, extends: exts } of perSelector) {
2109
- for (const ext of exts) {
2110
- const extNode = ext as unknown as { target?: unknown; flag?: unknown };
2111
- wrapperRules.push(new Extend(
2112
- {
2113
- target: extNode.target as any,
2114
- flag: extNode.flag as any,
2115
- selector: clean as unknown as Selector
2116
- },
2117
- {}, loc
2118
- ) as unknown as JessNode);
2119
- }
2120
- if (clean !== undefined) {
2121
- cleanedItems.push(clean);
2122
- }
2123
- }
2124
-
2125
- const combinedSel = cleanedItems.length === 1
2126
- ? cleanedItems[0]!
2127
- : this._makeSelectorList(cleanedItems as any, loc);
2128
-
2129
- wrapperRules.push(withGuard(new Ruleset(
2130
- { selector: combinedSel as any, rules: baseRules },
2131
- undefined, loc
2132
- )) as unknown as JessNode);
2133
-
2134
- return new Rules(wrapperRules as any, undefined, loc) as unknown as Ruleset;
2135
- }
2136
-
2137
- private _extractExtendsFromSelector(
2138
- selector: JessNode | string | undefined,
2139
- loc: LocationInfo
2140
- ): { cleanedSelector: JessNode | string | undefined; extractedExtends: JessNode[] } {
2141
- if (!selector || typeof selector === 'string') {
2142
- return { cleanedSelector: selector, extractedExtends: [] };
2143
- }
2144
-
2145
- // CompoundSelector: extract Extend nodes from .value[]
2146
- if (selector instanceof CompoundSelector) {
2147
- const extractedExtends: JessNode[] = [];
2148
- const newParts: any[] = [];
2149
- for (const part of selector.value) {
2150
- if (part instanceof Extend) {
2151
- extractedExtends.push(part as unknown as JessNode);
2152
- } else if (part instanceof List) {
2153
- // List of Extend nodes from multi-target :extend()
2154
- for (const item of (part as any).value ?? []) {
2155
- if (item instanceof Extend) {
2156
- extractedExtends.push(item as unknown as JessNode);
2157
- } else {
2158
- newParts.push(item);
2159
- }
2160
- }
2161
- } else {
2162
- newParts.push(part);
2163
- }
2164
- }
2165
- if (extractedExtends.length === 0) {
2166
- return { cleanedSelector: selector, extractedExtends: [] };
2167
- }
2168
- const cleanedSelector = newParts.length === 0
2169
- ? '&'
2170
- : newParts.length === 1
2171
- ? newParts[0] as JessNode | string
2172
- : new CompoundSelector(newParts, undefined, loc) as unknown as JessNode;
2173
- return { cleanedSelector, extractedExtends };
2174
- }
2175
-
2176
- // ComplexSelector: recurse into its CompoundSelector components and pull out
2177
- // any trailing Extend / List<Extend> (the `:extend(...)` pseudo lives at the
2178
- // end of the complex selector — see grammar's ComplexSelector).
2179
- if (selector instanceof ComplexSelector) {
2180
- const allExtends: JessNode[] = [];
2181
- const newParts: any[] = [];
2182
- for (const part of selector.value) {
2183
- if (part instanceof Extend) {
2184
- allExtends.push(part as unknown as JessNode);
2185
- } else if (part instanceof List) {
2186
- for (const item of (part as any).value ?? []) {
2187
- if (item instanceof Extend) {
2188
- allExtends.push(item as unknown as JessNode);
2189
- } else {
2190
- newParts.push(item);
2191
- }
2192
- }
2193
- } else if (part instanceof CompoundSelector) {
2194
- const { cleanedSelector: cs, extractedExtends: ee } = this._extractExtendsFromSelector(part as unknown as JessNode, loc);
2195
- allExtends.push(...ee);
2196
- if (cs !== undefined) {
2197
- newParts.push(cs);
2198
- }
2199
- } else {
2200
- newParts.push(part);
2201
- }
2202
- }
2203
- if (allExtends.length === 0) {
2204
- return { cleanedSelector: selector, extractedExtends: [] };
2205
- }
2206
- const newComplex = newParts.length === 1
2207
- ? newParts[0] as JessNode | string
2208
- : new ComplexSelector(newParts as any, undefined, loc) as unknown as JessNode;
2209
- return { cleanedSelector: newComplex, extractedExtends: allExtends };
2210
- }
2211
-
2212
- // Selector list node or parser-delivered array.
2213
- if (isSelectorListLike(selector)) {
2214
- const allExtends: JessNode[] = [];
2215
- const cleanedItems: (JessNode | string)[] = [];
2216
- let changed = false;
2217
- for (const item of selectorListItems(selector)) {
2218
- const { cleanedSelector: cs, extractedExtends: ee } = this._extractExtendsFromSelector(
2219
- item as unknown as JessNode, loc
2220
- );
2221
- allExtends.push(...ee);
2222
- if (ee.length > 0) {
2223
- changed = true;
2224
- }
2225
- if (cs !== undefined) {
2226
- cleanedItems.push(cs);
2227
- }
2228
- }
2229
- if (!changed) {
2230
- return { cleanedSelector: selector, extractedExtends: [] };
2231
- }
2232
- const newSel = cleanedItems.length === 1
2233
- ? cleanedItems[0]!
2234
- : this._makeSelectorList(cleanedItems as any, loc);
2235
- return { cleanedSelector: newSel as JessNode, extractedExtends: allExtends };
2236
- }
2237
-
2238
- return { cleanedSelector: selector, extractedExtends: [] };
2239
- }
2240
-
2241
- // -- Import helpers --------------------------------------------------------
2242
-
2243
- private static _isCssUrl(url: string, opts: string[]): boolean {
2244
- if (opts.includes('inline') || opts.includes('less')) {
2245
- return false;
2246
- }
2247
- return url.endsWith('.css') || url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//');
2248
- }
2249
-
2250
- private _buildImportAtRuleFromPrelude(
2251
- children: ReadonlyArray<Child>,
2252
- raw: ReadonlyArray<{ _tag: string }>,
2253
- loc: LocationInfo,
2254
- name: string
2255
- ): JessNode {
2256
- const preludeText = this._source.slice(loc.start, loc.end);
2257
- const optMatch = /^\s*\(([^)]+)\)/.exec(preludeText.replace(/^@import\s*/, ''));
2258
- const opts: string[] = optMatch ? optMatch[1]!.split(',').map(s => s.trim()) : [];
2259
- const builtNodes = nodeChildren(children);
2260
- // `@import url("x.css")` parses the path as a Url node, `@import "x.css"` as a
2261
- // Quoted. The url() wrapper is part of the serialized path — keep it as the
2262
- // prelude for CSS imports (and strip the whole `url(...)`, not just its inner
2263
- // quotes, when extracting a trailing media query).
2264
- const urlNode = builtNodes.find(n => n.type === 'Url') as unknown as Url | undefined;
2265
- const quotedNode = builtNodes.find(n => n.type === 'Quoted') as unknown as Quoted | undefined;
2266
- let pathNode: Quoted | undefined;
2267
- if (quotedNode) {
2268
- // Reuse the built Quoted so an interpolated path (`@import "@{theme}.less"`)
2269
- // keeps its Interpolated value and resolves before import resolution — flattening
2270
- // it to `.valueOf()` would strip the `@{…}` references.
2271
- pathNode = new Quoted(quotedNode.value, { quote: quotedNode.quote ?? '"' }, loc);
2272
- } else {
2273
- // Fallback: extract path from preludeText (AtRuleStatement uses scanTo leaves)
2274
- const _qm = preludeText.match(/(['"])([^'"]+)\1/);
2275
- if (_qm) {
2276
- const quote: '"' | '\'' = _qm[1] === '\'' ? '\'' : '"';
2277
- const inner = _qm[2]!;
2278
- const innerNode = inner;
2279
- pathNode = new Quoted(innerNode, { quote }, loc);
2280
- }
2281
- }
2282
- let mediaNode: Node | undefined;
2283
- {
2284
- // Remove @name, (options), the path (url(...) or quoted), and 'as namespace'
2285
- // to find a trailing media query.
2286
- let rest = preludeText.replace(/^@-?[_a-zA-Z][-_a-zA-Z0-9]*\s*/, '');
2287
- rest = rest.replace(/^\([^)]*\)\s*/, '');
2288
- rest = urlNode
2289
- ? rest.replace(/url\(\s*(['"])[^'"]*\1\s*\)\s*/i, '')
2290
- : rest.replace(/(['"])[^'"]*\1\s*/, '');
2291
- rest = rest.replace(/\bas\s+[^\s;(]+\s*/g, '');
2292
- rest = rest.replace(/;\s*$/, '').trim();
2293
- if (rest) {
2294
- // Parse the trailing media query as a real media prelude (feature
2295
- // conditions become Paren(Declaration) etc.) so it re-serializes with
2296
- // normalized spacing (`(min-width:600px)` → `(min-width: 600px)`),
2297
- // matching Less. A bare keyword tail (`screen`) round-trips unchanged.
2298
- mediaNode = this._buildAtRulePrelude(rest, loc) as unknown as Node;
2299
- }
2300
- }
2301
- const pathMatch2 = /['"]([^'"]+)['"]/.exec(preludeText);
2302
- const pathStr = pathMatch2 ? pathMatch2[1] : '';
2303
- const isCssImport = pathStr ? LessGrammar._isCssUrl(pathStr, opts) : false;
2304
- // `(inline)` wins over `(css)`: even `@import (inline, css) "x"` must inject
2305
- // the target's raw text verbatim (StyleImport inline path), never emit a
2306
- // passthrough CSS `@import`.
2307
- if (!opts.includes('inline') && (isCssImport || opts.includes('css'))) {
2308
- const preludeItems: JessNode[] = [];
2309
- const pathPrelude = (urlNode ?? pathNode) as unknown as JessNode | undefined;
2310
- if (pathPrelude) {
2311
- preludeItems.push(pathPrelude);
2312
- }
2313
- // A plain (non-Less) import can carry a trailing media-query tail, same as
2314
- // the StyleImport `postlude` option below — don't drop it here.
2315
- if (mediaNode) {
2316
- preludeItems.push(mediaNode as unknown as JessNode);
2317
- }
2318
- let prelude: JessNode | string;
2319
- if (preludeItems.length === 1) {
2320
- prelude = preludeItems[0]!;
2321
- } else {
2322
- const joined = preludeItems.map(item => item.toTrimmedString()).join(' ');
2323
- // A multi-item prelude (path + trailing media/supports/layer tail) whose
2324
- // parts are ALL static (no `@{…}`/`$…` interpolation) is itself a static
2325
- // token: wrap it in an `Any` so it carries `F_STATIC` and the spine can
2326
- // fold the bodyless CSS `@import` statement inline (byte-identical — `Any`
2327
- // re-serializes its value, its `evalNode` is a no-op). A non-static part
2328
- // (interpolated path/media) keeps the raw string, deferring to eval where
2329
- // the interpolation resolves.
2330
- const allStatic = preludeItems.every(item => item.structuralStaticFlag());
2331
- prelude = allStatic ? (new Any(joined, undefined, loc) as unknown as JessNode) : joined;
2332
- }
2333
- return new AtRuleStatement({ name, prelude }, undefined, loc) as unknown as JessNode;
2334
- }
2335
- const isForward = name === '@-export';
2336
- const importType: 'import' | 'compose' = isForward ? 'compose' : 'import';
2337
- const importOpts: Record<string, unknown> = {
2338
- once: !opts.includes('multiple')
2339
- };
2340
- if (opts.includes('reference')) {
2341
- importOpts.reference = true;
2342
- }
2343
- if (opts.includes('multiple')) {
2344
- importOpts.multiple = true;
2345
- }
2346
- if (opts.includes('optional')) {
2347
- importOpts.optional = true;
2348
- }
2349
- if (opts.includes('inline')) {
2350
- importOpts.inline = true;
2351
- }
2352
- if (opts.includes('less')) {
2353
- importOpts.type = 'less';
2354
- }
2355
- if (mediaNode) {
2356
- importOpts.postlude = mediaNode;
2357
- }
2358
- if (isForward) {
2359
- importOpts.forward = true;
2360
- }
2361
- const nsMatch2 = /\bas\s+([^\s;(]+)/.exec(preludeText);
2362
- const namespace = nsMatch2?.[1];
2363
- const styleImportOptions: Record<string, unknown> = { type: importType, importOptions: importOpts };
2364
- if (namespace) {
2365
- styleImportOptions.namespace = namespace;
2366
- }
2367
- // A Less `@import (reference) url(https://…)` with an UNQUOTED url() has no
2368
- // Quoted path; StyleImport accepts a Url path directly, so fall back to the
2369
- // parsed Url node rather than leaving `path` undefined (it later derefs
2370
- // `this.path.eval`).
2371
- const path = pathNode ?? (urlNode as unknown as JessNode | undefined);
2372
- return new StyleImport(
2373
- { path: path as any },
2374
- styleImportOptions as any,
2375
- loc
2376
- ) as unknown as JessNode;
2377
- }
2378
-
2379
- protected override _buildAtRuleBlock(children: ReadonlyArray<Child>, loc: LocationInfo) {
2380
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
2381
- const nameLf = ls[0];
2382
- const name = nameLf?.value ?? '';
2383
- const IMPORT_NAMES = ['@import', '@-import', '@-export'];
2384
- if (IMPORT_NAMES.includes(name)) {
2385
- return this._buildImportAtRuleFromPrelude(children, ls as any, loc, name);
2386
- }
2387
- const USE_NAMES = ['@use', '@-use'];
2388
- if (USE_NAMES.includes(name)) {
2389
- return this._buildUseAtRuleFromPrelude(children, loc, name);
2390
- }
2391
- // Reconstruct the prelude from source between the name keyword and the `{`,
2392
- // so a comment authored right after the name (`@keyframes /* c */ hover`) is
2393
- // kept — the prelude leaf itself starts at the first non-trivia token, past
2394
- // that comment. A trailing comment already sits inside the leaf. Falls back
2395
- // to the leaf value when spans are unavailable.
2396
- const nameSpan = (nameLf as unknown as { span?: Span })?.span;
2397
- const braceLf = ls.find(l => l.value === '{');
2398
- const braceStart = (braceLf as unknown as { span?: Span })?.span?.start;
2399
- let rawPreludeText: string | undefined;
2400
- if (nameSpan && typeof braceStart === 'number') {
2401
- const sliced = this._source.slice(nameSpan.end, braceStart).trim();
2402
- rawPreludeText = sliced.length > 0 ? sliced : undefined;
2403
- } else {
2404
- rawPreludeText = ls.slice(1).find(l => l.value !== '{' && l.value !== '}')?.value.trim();
2405
- }
2406
- return this._buildAtRuleFromParts(name, rawPreludeText, nodeChildren(children), loc);
2407
- }
2408
-
2409
- /**
2410
- * Shared AtRule assembly used by both the flat `AtRuleBlock` builder and the
2411
- * structured, committed `QueryAtRuleBlock` builder. `preludeText` is the raw
2412
- * prelude source (already `{`/`}` stripped); routing it through
2413
- * `_buildAtRulePrelude` keeps the emitted AST identical regardless of which
2414
- * grammar rule matched.
2415
- */
2416
- private _buildAtRuleFromParts(
2417
- name: string,
2418
- preludeText: string | undefined,
2419
- ruleNodes: JessNode[],
2420
- loc: LocationInfo
2421
- ): JessNode {
2422
- const isNestable = (NESTABLE_AT_RULES as readonly string[]).includes(name);
2423
- const nestableOpts = isNestable ? { nestable: true } : undefined;
2424
- const nameNode = name;
2425
- const prelude = preludeText ? this._buildAtRulePrelude(preludeText, loc) : undefined;
2426
- return new AtRule(
2427
- { name: nameNode as any, prelude: prelude as any, rules: ruleNodes },
2428
- nestableOpts, loc
2429
- ) as unknown as JessNode;
2430
- }
2431
-
2432
- /**
2433
- * Builder for the structured, committed `@media`/`@container`/`@supports`
2434
- * query block. The grammar rule parses the prelude with real query structure
2435
- * (so a stray/unbalanced bracket is rejected instead of swallowed) and commits
2436
- * on `expect('{')`, but the AST is reconstructed from the prelude source text
2437
- * via the shared `_buildAtRuleFromParts` path — so well-formed queries emit the
2438
- * exact same AtRule the flat `AtRuleBlock` builder would.
2439
- */
2440
- private _buildLessQueryAtRuleBlock(children: ReadonlyArray<Child>, raw: ReadonlyArray<{ _tag: string }>, loc: LocationInfo): JessNode {
2441
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
2442
- const name = ls[0]?.value ?? '';
2443
- const comps = spannedComponents(raw);
2444
- const keywordEnd = comps[0]?.span.end ?? loc.start;
2445
- const braceComp = comps.find(c => c.comp === '{');
2446
- const braceStart = braceComp?.span.start ?? loc.end;
2447
- const preludeText = this._source.slice(keywordEnd, braceStart).trim();
2448
- // `g.queryPrelude` parses the prelude into real node children (e.g. the
2449
- // `(max-width: 600px)` Paren), so `children` holds BOTH prelude nodes and
2450
- // body nodes. The prelude is reconstructed from source text above; the body
2451
- // is only the nodes that begin after the opening brace.
2452
- const bodyNodes = comps
2453
- .filter((c): c is Spanned & { comp: JessNode } => typeof c.comp !== 'string' && c.span.start >= braceStart)
2454
- .map(c => c.comp);
2455
- return this._buildAtRuleFromParts(name, preludeText || undefined, bodyNodes, loc);
2456
- }
2457
-
2458
- private _buildAtRulePrelude(text: string, loc: LocationInfo): JessNode {
2459
- const singleVarRe = /^@(-?[_a-zA-Z\x80-￿][-_a-zA-Z0-9\x80-￿]*)$/;
2460
- const MEDIA_KEYWORDS = new Set(['and', 'or', 'not', 'only', 'all', 'print', 'screen', 'speech']);
2461
- const COMPARISON_OPS = new Set(['>', '<', '>=', '<=', '=', '!=']);
2462
-
2463
- // `~"screen"` / `~'screen'` — an escaped string standing in for the whole
2464
- // query (lessMediaQueryFromString in the reference). Mirrors
2465
- // `_buildEscapedValue`: a Quoted with `escaped: true` so eval unwraps it to
2466
- // the literal content instead of quoted CSS.
2467
- const escapedStrRe = /^~(['"])([\s\S]*)\1$/;
2468
- const buildWord = (w: string): JessNode => {
2469
- const es = escapedStrRe.exec(w);
2470
- if (es) {
2471
- return this._buildEscapedQuoted(es[2]!, es[1] as '\'' | '"', loc);
2472
- }
2473
- const mv = singleVarRe.exec(w);
2474
- if (mv) {
2475
- return new Reference(mv[1]!, { type: 'index' as const, role: 'ident' as const }, loc) as unknown as JessNode;
2476
- }
2477
- if (MEDIA_KEYWORDS.has(w.toLowerCase())) {
2478
- return this._lessKeyword(w, loc) as unknown as JessNode;
2479
- }
2480
- if (COMPARISON_OPS.has(w)) {
2481
- return w as unknown as JessNode;
2482
- }
2483
- return this._lessKeyword(w, loc) as unknown as JessNode;
2484
- };
2485
-
2486
- // `@var[key]` accessor in value position → Reference(target=Reference(var), key).
2487
- // (Authoritative accessor shape: lookupOrCall in productions/guards.ts; mirrors
2488
- // the top-level `varAccRe` branch in buildItem below.)
2489
- const varAccRe = /^@(-?[_a-zA-Z\x80-￿][-_a-zA-Z0-9\x80-￿]*)\[([^\]]*)\]$/;
2490
- const buildAccessor = (varName: string, accInner: string): JessNode => {
2491
- const varBase = new Reference(
2492
- { key: varName } as unknown as ReferenceValue, {}, loc
2493
- ) as unknown as JessNode;
2494
- const inner = accInner.trim();
2495
- let accKey: JessNode | string | number;
2496
- let accType: 'variable' | 'index';
2497
- if (inner === '') {
2498
- accKey = -1;
2499
- accType = 'index';
2500
- } else if (inner.startsWith('@')) {
2501
- accKey = inner.slice(1);
2502
- accType = 'variable';
2503
- } else {
2504
- accKey = new Quoted(inner, {}, loc) as unknown as JessNode;
2505
- accType = 'index';
2506
- }
2507
- return new Reference(
2508
- { target: varBase as any, key: accKey as any } as unknown as ReferenceValue,
2509
- { type: accType }, loc
2510
- ) as unknown as JessNode;
2511
- };
2512
-
2513
- // Operator token in a prelude math expression (`(@some-var + 1)`). Kept simple:
2514
- // a `+ - * /` surrounded by whitespace (bare `-`/`+` glued to a following number
2515
- // is a signed operand, not a binary op — matches the value grammar's sumOp gate).
2516
- const prodOps = new Set(['*', '/']);
2517
- const buildFeatureValue = (raw: string): JessNode => {
2518
- const propVal = raw.trim();
2519
- // `~"…"` escaped string, bare `@var`, or `@var[key]` accessor.
2520
- if (escapedStrRe.test(propVal) || singleVarRe.test(propVal)) {
2521
- return buildWord(propVal);
2522
- }
2523
- const vam = varAccRe.exec(propVal);
2524
- if (vam) {
2525
- return buildAccessor(vam[1]!, vam[2] ?? '');
2526
- }
2527
- // A parenthesized math expression `(<expr>)` — fold `left op right …` into a
2528
- // left-associative Operation over References/Dimensions/Nums so eval computes
2529
- // it (`(@some-var + 1)` → `61px`). `* /` bind tighter than `+ -`.
2530
- const paren = /^\(([\s\S]*)\)$/.exec(propVal);
2531
- if (paren) {
2532
- const op = buildMathExpr(paren[1]!.trim());
2533
- if (op) {
2534
- return new Expression(op as unknown as Node, { parens: true } as any, loc) as unknown as JessNode;
2535
- }
2536
- }
2537
- // A `<ratio>` feature value (`aspect-ratio: 3/2`) serializes with spaces
2538
- // around the slash (`3 / 2`) — the slash is a ratio separator, not a
2539
- // division to evaluate. @see https://drafts.csswg.org/css-values-4/#ratios
2540
- const ratio = /^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/.exec(propVal);
2541
- if (ratio) {
2542
- return this._lessKeyword(`${ratio[1]} / ${ratio[2]}`, loc) as unknown as JessNode;
2543
- }
2544
- return this._lessKeyword(propVal, loc) as unknown as JessNode;
2545
- };
2546
-
2547
- // Build a left-associative Operation tree from a flat `operand op operand …`
2548
- // math run, honoring `* /` over `+ -` precedence. Returns undefined if the run
2549
- // isn't a recognizable binary expression (caller falls back to a keyword).
2550
- const buildMathExpr = (expr: string): JessNode | undefined => {
2551
- // Split on whitespace; operators must be space-separated (`@some-var + 1`).
2552
- const parts = expr.split(/\s+/).filter(Boolean);
2553
- if (parts.length < 3 || parts.length % 2 === 0) {
2554
- return undefined;
2555
- }
2556
- const buildOperand = (t: string): JessNode | undefined => {
2557
- const es = escapedStrRe.exec(t);
2558
- if (es) {
2559
- return this._buildEscapedQuoted(es[2]!, es[1] as '\'' | '"', loc);
2560
- }
2561
- const mv = singleVarRe.exec(t);
2562
- if (mv) {
2563
- return new Reference(mv[1]!, { type: 'index' as const, role: 'ident' as const }, loc) as unknown as JessNode;
2564
- }
2565
- const va = varAccRe.exec(t);
2566
- if (va) {
2567
- return buildAccessor(va[1]!, va[2] ?? '');
2568
- }
2569
- const dim = /^([+-]?(?:\d*\.\d+|\d+))([_a-zA-Z%][-_a-zA-Z0-9%]*)?$/.exec(t);
2570
- if (dim) {
2571
- return dim[2]
2572
- ? new Dimension({ number: parseFloat(dim[1]!), unit: dim[2]! }, undefined, loc) as unknown as JessNode
2573
- : new Num(parseFloat(dim[1]!), undefined, loc) as unknown as JessNode;
2574
- }
2575
- return undefined;
2576
- };
2577
- // First fold `* /`, then `+ -`, over a flat operand/operator list.
2578
- const nodes: Array<JessNode | string | undefined> = parts.map((p, i) =>
2579
- i % 2 === 0 ? buildOperand(p) : (/^[-+*/]$/.test(p) ? p : undefined));
2580
- if (nodes.some(n => n === undefined)) {
2581
- return undefined;
2582
- }
2583
- const foldPass = (matchOp: (op: string) => boolean): boolean => {
2584
- for (let i = 1; i < nodes.length - 1; i += 2) {
2585
- const op = nodes[i] as string;
2586
- if (matchOp(op)) {
2587
- const left = nodes[i - 1] as JessNode;
2588
- const right = nodes[i + 1] as JessNode;
2589
- const combined = new Operation([left, op, right] as any, undefined, loc) as unknown as JessNode;
2590
- nodes.splice(i - 1, 3, combined);
2591
- return true;
2592
- }
2593
- }
2594
- return false;
2595
- };
2596
- while (foldPass(op => prodOps.has(op))) { /* fold products */ }
2597
- while (foldPass(op => op === '+' || op === '-')) { /* fold sums */ }
2598
- return nodes.length === 1 ? (nodes[0] as JessNode) : undefined;
2599
- };
2600
-
2601
- const buildParen = (inner: string): JessNode => {
2602
- const trimmed = inner.trim();
2603
- const colonIdx = trimmed.indexOf(':');
2604
- if (colonIdx > 0 && !/[><=!]/.test(trimmed.slice(0, colonIdx))) {
2605
- const propName = trimmed.slice(0, colonIdx).trim();
2606
- const propVal = trimmed.slice(colonIdx + 1).trim();
2607
- // The value may be a bare `@var` (→ indexed Reference, matching
2608
- // atRulePreludeBareVariableAs:'index'), a `@var[key]` accessor, a `~"…"`
2609
- // escaped string, or a parenthesized math expression — all evaluated so
2610
- // the prelude renders computed values (Less 4.x parity).
2611
- const valueNode = buildFeatureValue(propVal);
2612
- // A custom-property style query — `@container style(--responsive: true)`.
2613
- // Less normalizes the feature to `name: value` (single space after the
2614
- // colon), NOT the verbatim custom-property spacing a `Declaration` would
2615
- // preserve, so model it as a query condition (`--name:` keyword + value).
2616
- // @see https://drafts.csswg.org/css-conditional-5/#style-container
2617
- if (propName.startsWith('--')) {
2618
- const qc = new QueryCondition(
2619
- [this._lessKeyword(`${propName}:`, loc), valueNode] as any, undefined, loc);
2620
- return new Paren(qc as any, undefined, loc) as unknown as JessNode;
2621
- }
2622
- const decl = new Declaration({ name: propName as any, value: valueNode as any }, undefined, loc);
2623
- return new Paren(decl as any, undefined, loc) as unknown as JessNode;
2624
- }
2625
- // Normalize interior whitespace of a range/comparison query so it renders
2626
- // canonically regardless of author spacing: no space after `(` or before
2627
- // `)`, and a single space around a comparison operator even when it is
2628
- // glued to an operand (`( width< 500px)` → `(width < 500px)`).
2629
- // @see https://drafts.csswg.org/mediaqueries-5/#mq-range-context
2630
- const normalized = trimmed
2631
- .replace(/\(\s+/g, '(')
2632
- .replace(/\s+\)/g, ')')
2633
- .replace(/\s*(<=|>=|!=|[<>=])\s*/g, ' $1 ')
2634
- .trim();
2635
- const words = normalized.split(/\s+/).filter(Boolean).map(w => buildWord(w));
2636
- const qc = new QueryCondition(words as any, undefined, loc);
2637
- return new Paren(qc as any, undefined, loc) as unknown as JessNode;
2638
- };
2639
-
2640
- const tokenize = (t: string): JessNode[] => {
2641
- const tokens: JessNode[] = [];
2642
- let i = 0;
2643
- while (i < t.length) {
2644
- if (t[i] === '(') {
2645
- let depth = 1;
2646
- let j = i + 1;
2647
- while (j < t.length && depth > 0) {
2648
- if (t[j] === '(') {
2649
- depth++;
2650
- } else if (t[j] === ')') {
2651
- depth--;
2652
- }
2653
- j++;
2654
- }
2655
- tokens.push(buildParen(t.slice(i + 1, j - 1)));
2656
- i = j;
2657
- } else if (t[i] === '"' || t[i] === '\'' || (t[i] === '~' && (t[i + 1] === '"' || t[i + 1] === '\''))) {
2658
- // A quoted (optionally `~`-escaped) run is one token, even with spaces
2659
- // inside — matches the outer atPrelude scan, which already treats
2660
- // strings as atomic.
2661
- const start = i;
2662
- if (t[i] === '~') {
2663
- i++;
2664
- }
2665
- const quote = t[i]!;
2666
- i++;
2667
- while (i < t.length && t[i] !== quote) {
2668
- i++;
2669
- }
2670
- i = Math.min(i + 1, t.length);
2671
- tokens.push(buildWord(t.slice(start, i)));
2672
- } else if (/\s/.test(t[i]!)) {
2673
- i++;
2674
- } else {
2675
- let j = i;
2676
- while (j < t.length && !/\s/.test(t[j]!) && t[j] !== '(') {
2677
- j++;
2678
- }
2679
- tokens.push(buildWord(t.slice(i, j)));
2680
- i = j;
2681
- }
2682
- }
2683
- return tokens;
2684
- };
2685
-
2686
- const splitCommas = (t: string): string[] => {
2687
- const parts: string[] = [];
2688
- let depth = 0;
2689
- let start = 0;
2690
- for (let i = 0; i < t.length; i++) {
2691
- if (t[i] === '(') {
2692
- depth++;
2693
- } else if (t[i] === ')') {
2694
- depth--;
2695
- } else if (t[i] === ',' && depth === 0) {
2696
- parts.push(t.slice(start, i).trim());
2697
- start = i + 1;
2698
- }
2699
- }
2700
- parts.push(t.slice(start).trim());
2701
- return parts.filter(Boolean);
2702
- };
2703
-
2704
- // Regex: namespace path (#ns.sub or #ns > .sub), optional (args), optional [accessor]
2705
- const nsMediaRe = /^([#.][^(\[,\s]*)(\([^)]*\))?(\[[^\]]*\])?$/;
2706
- const buildItem = (t: string): JessNode => {
2707
- const trimmed = t.trim();
2708
- const mv = singleVarRe.exec(trimmed);
2709
- if (mv) {
2710
- const ref = new Reference(mv[1]!, { type: 'index' as const, role: 'ident' as const }, loc) as unknown as JessNode;
2711
- return new QueryCondition([ref] as any, undefined, loc) as unknown as JessNode;
2712
- }
2713
- // `@var[accessor]` prelude → Expression(Reference(target=Reference(var), key))
2714
- // (Authoritative accessor shape: lookupOrCall in productions/guards.ts.)
2715
- const varAccRe = /^@(-?[_a-zA-Z\x80-￿][-_a-zA-Z0-9\x80-￿]*)(\[([^\]]*)\])$/;
2716
- const vam = varAccRe.exec(trimmed);
2717
- if (vam) {
2718
- const varBase = new Reference(
2719
- { key: vam[1]! } as unknown as ReferenceValue, {}, loc
2720
- ) as unknown as JessNode;
2721
- const accInner = (vam[3] ?? '').trim();
2722
- let accKey: JessNode | string | number;
2723
- let accType: 'variable' | 'index';
2724
- if (accInner === '') {
2725
- accKey = -1;
2726
- accType = 'index';
2727
- } else if (accInner.startsWith('@')) {
2728
- accKey = accInner.slice(1);
2729
- accType = 'variable';
2730
- } else {
2731
- accKey = new Quoted(accInner, {}, loc) as unknown as JessNode;
2732
- accType = 'index';
2733
- }
2734
- const acc = new Reference(
2735
- { target: varBase as any, key: accKey as any } as unknown as ReferenceValue,
2736
- { type: accType }, loc
2737
- ) as unknown as JessNode;
2738
- return new Expression(acc as unknown as Node, undefined, loc) as unknown as JessNode;
2739
- }
2740
- const nsm = nsMediaRe.exec(trimmed);
2741
- if (nsm) {
2742
- const nsPath = nsm[1]!;
2743
- const argsText = nsm[2];
2744
- const accText = nsm[3];
2745
- // Build namespace reference: split compound selector path into segments
2746
- const segments = nsPath.match(/[#.][^#.]*/g) ?? [nsPath];
2747
- const nameKey: string | string[] = segments.length === 1 ? segments[0]! : segments;
2748
- const rawKey = segments.length > 1 ? nsPath : undefined;
2749
- let base: JessNode = new Reference(
2750
- { key: nameKey, ...(rawKey ? { rawKey } : {}) } as unknown as ReferenceValue,
2751
- { type: 'mixin-ruleset', role: 'name' } as any, loc
2752
- ) as unknown as JessNode;
2753
- if (argsText) {
2754
- const argsInner = argsText.slice(1, -1).trim();
2755
- let argsNode: JessNode | null = null;
2756
- if (argsInner) {
2757
- // Build accessor-style arg ref if it looks like .sel[]
2758
- const argRefMatch = /^([.#][^\[\]()\s]+)(\[([^\]]*)\])?$/.exec(argsInner);
2759
- if (argRefMatch) {
2760
- let argBase: JessNode = new Reference(
2761
- { key: argRefMatch[1]! } as unknown as ReferenceValue,
2762
- { role: 'name' } as any, loc
2763
- ) as unknown as JessNode;
2764
- if (argRefMatch[2] !== undefined) {
2765
- const argAcc = argRefMatch[3] ?? '';
2766
- const argKey: string | number = argAcc === '' ? -1 : argAcc;
2767
- argBase = new Reference(
2768
- { target: argBase as any, key: argKey as any } as unknown as ReferenceValue,
2769
- {}, loc
2770
- ) as unknown as JessNode;
2771
- }
2772
- argsNode = new List([argBase as unknown as Node] as any, undefined, loc) as unknown as JessNode;
2773
- }
2774
- }
2775
- const callPayload: Record<string, unknown> = { name: base };
2776
- if (argsNode) {
2777
- callPayload.args = argsNode;
2778
- }
2779
- base = new Call(callPayload as any, {}, loc) as unknown as JessNode;
2780
- }
2781
- if (accText) {
2782
- const inner = accText.slice(1, -1).trim();
2783
- const key: string | number = inner.startsWith('@') ? inner.slice(1) : (inner === '' ? -1 : inner);
2784
- base = new Reference(
2785
- { target: base as any, key: key as any } as unknown as ReferenceValue,
2786
- { type: 'variable' as const }, loc
2787
- ) as unknown as JessNode;
2788
- }
2789
- return new Expression(base as unknown as Node, undefined, loc) as unknown as JessNode;
2790
- }
2791
- const tokens = tokenize(t);
2792
- // A leading `<container-name>` — an identifier (or `@var`) separated from
2793
- // the following `<container-query>` by whitespace (`@container sidebar
2794
- // (min-width: 700px)`) — is NOT a query function token (`size(…)`), so the
2795
- // serializer keeps a space before the query group instead of gluing it.
2796
- // @see https://drafts.csswg.org/css-conditional-5/#container-condition
2797
- const nameMatch = /^(@?-?[_a-zA-Z\x80-\uffff][-_a-zA-Z0-9\x80-\uffff]*)\s+(?:\(|not(?![-\w]))/i.exec(trimmed);
2798
- const leadingContainerName = !!nameMatch
2799
- && tokens.length > 1
2800
- && !['not', 'and', 'or', 'only'].includes(nameMatch[1]!.replace(/^@/, '').toLowerCase());
2801
- return new QueryCondition(
2802
- tokens as any,
2803
- leadingContainerName ? { leadingContainerName: true } : undefined,
2804
- loc
2805
- ) as unknown as JessNode;
2806
- };
2807
-
2808
- const commaItems = splitCommas(text);
2809
- if (commaItems.length === 1) {
2810
- return buildItem(commaItems[0]!);
2811
- }
2812
- return new List(commaItems.map(buildItem) as any, undefined, loc) as unknown as JessNode;
2813
- }
2814
-
2815
- protected _buildAtRuleStatement(children: ReadonlyArray<Child>, loc: LocationInfo): JessNode {
2816
- const ls = children.filter((c): c is CSTLeaf => c._tag === 'leaf');
2817
- const name = ls[0]?.value ?? '';
2818
- const IMPORT_NAMES = ['@import', '@-import', '@-export'];
2819
- if (IMPORT_NAMES.includes(name)) {
2820
- return this._buildImportAtRuleFromPrelude(children, ls as any, loc, name);
2821
- }
2822
- const USE_NAMES = ['@use', '@-use'];
2823
- if (USE_NAMES.includes(name)) {
2824
- return this._buildUseAtRuleFromPrelude(children, loc, name);
2825
- }
2826
- return super._buildAtRuleStatement(children, loc) as unknown as JessNode;
2827
- }
2828
-
2829
- private _buildUseAtRuleFromPrelude(
2830
- children: ReadonlyArray<Child>,
2831
- loc: LocationInfo,
2832
- name: string
2833
- ): JessNode {
2834
- const preludeText = this._source.slice(loc.start, loc.end);
2835
- const builtNodes = nodeChildren(children);
2836
- const quotedNode = builtNodes.find(n => n.type === 'Quoted') as unknown as { quote?: '"' | '\''; value?: unknown } | undefined;
2837
- let rawPath = '';
2838
- let pathNode: Quoted | undefined;
2839
- if (quotedNode) {
2840
- const quote = quotedNode.quote ?? '"';
2841
- const innerVal = quotedNode.value;
2842
- const inner = typeof innerVal === 'string'
2843
- ? innerVal
2844
- : (innerVal as any)?.value ?? String((innerVal as any)?.valueOf?.() ?? '');
2845
- rawPath = inner;
2846
- const innerNode = inner;
2847
- pathNode = new Quoted(innerNode, { quote }, loc);
2848
- } else {
2849
- // Fallback: extract from preludeText (AtRuleStatement uses scanTo, not Quoted node)
2850
- const qm = /(['"])((?:[^'"\\]|\\.)*)\1/.exec(preludeText);
2851
- if (qm) {
2852
- const quote: '"' | '\'' = qm[1] === '\'' ? '\'' : '"';
2853
- const inner = qm[2]!;
2854
- rawPath = inner;
2855
- const innerNode = inner;
2856
- pathNode = new Quoted(innerNode, { quote }, loc);
2857
- }
2858
- }
2859
- const nsMatch = /\bas\s+([^\s;]+)/.exec(preludeText);
2860
- const explicitNs = nsMatch?.[1];
2861
- const isJsFile = /\.[cm]?[jt]sx?$/.test(rawPath) || rawPath.startsWith('#');
2862
- if (isJsFile) {
2863
- let ns = explicitNs;
2864
- if (!ns) {
2865
- const base = rawPath.split('/').pop() ?? '';
2866
- ns = base.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_$]/g, '_');
2867
- }
2868
- return new JsImport(
2869
- { path: pathNode as any },
2870
- { namespace: ns },
2871
- loc
2872
- ) as unknown as JessNode;
2873
- }
2874
- // Not a JS import - build plain AtRule/AtRuleStatement
2875
- const nameAny = name;
2876
- const preludeNode: JessNode | undefined = pathNode as unknown as JessNode | undefined;
2877
- return new AtRule(
2878
- { name: nameAny as any, prelude: preludeNode as any, rules: [] },
2879
- undefined, loc
2880
- ) as unknown as JessNode;
2881
- }
2882
-
2883
- private _parenToArgs(paren: JessNode, loc: LocationInfo): JessNode | null {
2884
- // Convert Paren content to List of mixin args.
2885
- // Handles patterns like @foo: bar (keyword args) and bare values.
2886
- const inner = (paren as any).value ?? (paren as any).node;
2887
- if (!inner) {
2888
- return null;
2889
- }
2890
- const items: JessNode[] = [];
2891
- // Inner may be a component array (e.g. [Reference, ':', 'bar']) or legacy Sequence node.
2892
- const isSeq = inner && typeof inner === 'object' && inner.type === 'Sequence';
2893
- const isRawArray = Array.isArray(inner);
2894
- if (isSeq || isRawArray) {
2895
- const rawSeq: unknown[] = isSeq ? ((inner as any).value ?? []) : (inner as unknown[]);
2896
- // Normalize bare strings to Keyword nodes so both the ':' / @var
2897
- // detection and the value-node construction below operate on real nodes.
2898
- const seqItems: JessNode[] = rawSeq.map(it =>
2899
- typeof it === 'string'
2900
- ? (this._lessKeyword(it.trim(), loc) as unknown as JessNode)
2901
- : it as JessNode);
2902
- // Look for @var : value patterns
2903
- let j = 0;
2904
- while (j < seqItems.length) {
2905
- const item = seqItems[j]!;
2906
- // Check for Reference (potential @foo) followed by ':' and value
2907
- if (
2908
- item.type === 'Reference'
2909
- && j + 2 < seqItems.length
2910
- && (seqItems[j + 1] as any)?.value === ':'
2911
- ) {
2912
- const varName = (item as any).key ?? '';
2913
- const nameAny = varName;
2914
- const valNode = seqItems[j + 2]!;
2915
- const valueNode = this._isKeywordLike(valNode)
2916
- ? this._lessKeyword(String((valNode as any).value ?? '').trim(), loc) as unknown as JessNode
2917
- : this._lessKeyword(String((valNode as any).value ?? ''), loc) as unknown as JessNode;
2918
- const vd = new VarDeclaration(
2919
- { name: nameAny as any, value: valueNode } as any,
2920
- {} as VarDeclarationOptions,
2921
- loc
2922
- );
2923
- items.push(vd as unknown as JessNode);
2924
- j += 3;
2925
- } else {
2926
- items.push(item);
2927
- j++;
2928
- }
2929
- }
2930
- } else if (inner && (inner as any).type === 'List') {
2931
- // A comma/semicolon List as the direct paren content is the ARG SEPARATOR,
2932
- // not a single list-valued arg: `.mixin(10px, 10px)` is two args, so spread
2933
- // the list's items. (The `@var(...)` path splits on comma in
2934
- // `_buildRefCallArgs`; this mirrors it for the namespace/mixin call path
2935
- // where the grammar hands us a real List node.) Space-separated content
2936
- // arrives as a Sequence/single node and stays one arg.
2937
- for (const it of ((inner as any).value ?? []) as JessNode[]) {
2938
- items.push(it);
2939
- }
2940
- } else if (inner) {
2941
- const isEmptyInner = this._isEmptyKeywordLike(inner);
2942
- if (!isEmptyInner) {
2943
- items.push(inner as JessNode);
2944
- }
2945
- }
2946
- if (items.length === 0) {
2947
- return null;
2948
- }
2949
- return new List(items as any, undefined, loc) as unknown as JessNode;
2950
- }
2951
-
2952
- private _tryParseNamespaceRef(
2953
- valItems: Spanned[],
2954
- loc: LocationInfo
2955
- ): JessNode | null {
2956
- // Check if this looks like a namespace selector reference/call
2957
- // Pattern: #id or .class, optionally followed by > .mixin, [accessor], ()
2958
- const isSel = (s: unknown): s is string =>
2959
- typeof s === 'string' && /^[#.]-?[_a-zA-Z\u0080-\uffff]/.test(s.trim());
2960
- const isCombinator = (s: unknown): s is string =>
2961
- typeof s === 'string' && /^[>+~|]$|^\|\|$/.test(String(s).trim());
2962
- const isJessNodeVal = (x: unknown): x is JessNode =>
2963
- !!x && typeof x === 'object' && 'type' in x;
2964
-
2965
- // Quick check: does first item look like a selector segment?
2966
- const first = valItems[0]?.comp;
2967
- const isVarRef = (x: unknown): x is JessNode =>
2968
- !!x && typeof x === 'object' && (x as any).type === 'Reference';
2969
- if (!isSel(first) && !isVarRef(first)) {
2970
- return null;
2971
- }
2972
- // Must have at least one SquareParen accessor when starting with a variable ref
2973
- if (isVarRef(first) && valItems.length < 2) {
2974
- return null;
2975
- }
2976
- const hasSquareAfterVar = isVarRef(first) && valItems.slice(1).some(vi =>
2977
- isJessNodeVal(vi.comp) && (vi.comp as any).type === 'Paren' && (vi.comp as any)._options?.delimiter === 'square'
2978
- );
2979
- if (isVarRef(first) && !hasSquareAfterVar) {
2980
- return null;
2981
- }
2982
-
2983
- // Parse selector chain with interleaved calls.
2984
- // Each "segment ()" pair becomes a Call; subsequent "> segment" chains further.
2985
- // e.g.: .a() > .b() => Call(name=Ref(target=Call(name=Ref(key=.a)) key=.b))
2986
- // e.g.: #ns > .mixin => Ref[role=name](key=['#ns', '.mixin'])
2987
- let i = 0;
2988
- let base: JessNode | null = null;
2989
- // Pending segments since the last call (or since start)
2990
- let pendingSegments: string[] = [];
2991
- let hasMidCall = false; // any () seen mid-chain
2992
-
2993
- const flushPendingAsRef = (): JessNode => {
2994
- const k: string | string[] = pendingSegments.length === 1 ? pendingSegments[0]! : pendingSegments;
2995
- pendingSegments = [];
2996
- if (base === null) {
2997
- return new Reference(
2998
- { key: k } as unknown as ReferenceValue,
2999
- { type: 'mixin-ruleset', role: 'name' } as any, loc
3000
- ) as unknown as JessNode;
3001
- }
3002
- return new Reference(
3003
- { target: base as any, key: k } as unknown as ReferenceValue,
3004
- { type: 'mixin-ruleset', role: 'name' } as any, loc
3005
- ) as unknown as JessNode;
3006
- };
3007
-
3008
- while (i < valItems.length) {
3009
- const c = valItems[i]!.comp;
3010
- if (isVarRef(c) && base === null && pendingSegments.length === 0) {
3011
- // Variable reference node (e.g. Reference('config')) as the base.
3012
- // Observed: for `@config[$@prop]` the grammar emits TWO items —
3013
- // 1. Reference(target=Reference('config'), key=Quoted('')) — a leaked
3014
- // empty-accessor wrapper, and
3015
- // 2. a separate `[$@prop]` SquareParen that carries the real key.
3016
- // Strip the empty-key wrapper so the trailing SquareParen accessor applies
3017
- // to Reference('config'). (Authoritative shape: lookupOrCall in productions/guards.ts.)
3018
- const rv = c as any;
3019
- const isLeakedEmptyKey = rv.target !== undefined
3020
- && (rv.key === '' || rv.key === undefined
3021
- || (rv.key && typeof rv.key === 'object'
3022
- && rv.key.type === 'Quoted' && !String(rv.key.value ?? '').trim()));
3023
- base = isLeakedEmptyKey ? rv.target as JessNode : c as JessNode;
3024
- i++;
3025
- } else if (isSel(c)) {
3026
- // Split compound selectors like '#ns.breakpoint' → ['#ns', '.breakpoint']
3027
- const seg = (c as string).trim();
3028
- const splitSeg = seg.match(/[#.][^#.]*/g) ?? [seg];
3029
- for (const s of splitSeg) {
3030
- pendingSegments.push(s);
3031
- }
3032
- i++;
3033
- } else if (isCombinator(c) && i + 1 < valItems.length && isSel(valItems[i + 1]?.comp)) {
3034
- // Combinator between segments — skip
3035
- i++;
3036
- } else if (isJessNodeVal(c) && (c as any).type === 'Paren' && (c as any)._options?.delimiter === 'square') {
3037
- // Square paren: accessor on current base
3038
- if (pendingSegments.length > 0) {
3039
- base = flushPendingAsRef();
3040
- }
3041
- if (base === null) {
3042
- break;
3043
- }
3044
- const innerKey = this._decodeAccessorKey(c as JessNode, loc);
3045
- // A numeric accessor key (`foo[2]`, or `foo[]` → last, key -1) is an
3046
- // INDEX lookup, not a variable lookup. Dispatching it as `variable`
3047
- // sends `-1` through the variable resolver and fails with `'-1' is not
3048
- // defined`; `index` resolves it via `rules.at(-1)` (the last value).
3049
- base = new Reference(
3050
- { target: base as any, key: innerKey as any } as unknown as ReferenceValue,
3051
- { type: typeof innerKey === 'number' ? 'index' as const : 'variable' as const }, loc
3052
- ) as unknown as JessNode;
3053
- i++;
3054
- } else if (isJessNodeVal(c) && (c as any).type === 'Paren') {
3055
- // Round paren: flush pending segments as Reference (if any), then wrap in Call
3056
- if (pendingSegments.length === 0 && base === null) {
3057
- break;
3058
- } // unexpected ()
3059
- if (pendingSegments.length > 0) {
3060
- base = flushPendingAsRef();
3061
- }
3062
- // Extract args from the Paren's inner node (may be null for empty parens)
3063
- const argsNode = this._parenToArgs(c as JessNode, loc);
3064
- const callPayload: Record<string, unknown> = { name: base as any };
3065
- if (argsNode) {
3066
- callPayload.args = argsNode;
3067
- }
3068
- base = new Call(callPayload as any, {}, loc) as unknown as JessNode;
3069
- hasMidCall = true;
3070
- i++;
3071
- } else {
3072
- break;
3073
- }
3074
- }
3075
-
3076
- // Must have consumed all valItems
3077
- if (i !== valItems.length) {
3078
- return null;
3079
- }
3080
- if (pendingSegments.length === 0 && base === null) {
3081
- return null;
3082
- }
3083
-
3084
- // If we still have pending segments (no trailing call), flush them
3085
- if (pendingSegments.length > 0) {
3086
- base = flushPendingAsRef();
3087
- }
3088
-
3089
- // Check source text for (args)[accessor] AFTER the last parsed item
3090
- // Process in order: (args) call → [accessor] → trailing ()
3091
- if (valItems.length > 0) {
3092
- const lastSpan = valItems[valItems.length - 1]!.span;
3093
- let afterVal = this._source.slice(lastSpan.end).trimStart();
3094
-
3095
- // Step 1: Check for a trailing call (...) when grammar didn't catch it
3096
- if (!hasMidCall && afterVal.startsWith('(')) {
3097
- const closeIdx = afterVal.indexOf(')', 1);
3098
- if (closeIdx > 0) {
3099
- const callInner = afterVal.slice(1, closeIdx).trim();
3100
- afterVal = afterVal.slice(closeIdx + 1).trimStart();
3101
- // Build args from call content if non-empty
3102
- let argsNode: JessNode | null = null;
3103
- if (callInner) {
3104
- // Check for .selector[accessor] pattern in call args
3105
- const argRefMatch = /^([.#][^\[\]()\s]+)(\[([^\]]*)\])?$/.exec(callInner);
3106
- if (argRefMatch) {
3107
- const argSel = argRefMatch[1]!;
3108
- const argAccContent = argRefMatch[3]; // may be undefined or empty string
3109
- let argBase: JessNode = new Reference(
3110
- { key: argSel } as unknown as ReferenceValue,
3111
- { role: 'name' } as any, loc
3112
- ) as unknown as JessNode;
3113
- if (argRefMatch[2] !== undefined) {
3114
- const argAccKey: string | number = argAccContent === undefined || argAccContent === ''
3115
- ? -1
3116
- : (argAccContent.startsWith('@') ? argAccContent.slice(1) : argAccContent);
3117
- argBase = new Reference(
3118
- { target: argBase as any, key: argAccKey as any } as unknown as ReferenceValue,
3119
- {}, loc
3120
- ) as unknown as JessNode;
3121
- }
3122
- argsNode = new List([argBase as unknown as Node] as any, undefined, loc) as unknown as JessNode;
3123
- }
3124
- }
3125
- const callPayload: Record<string, unknown> = { name: base as any };
3126
- if (argsNode) {
3127
- callPayload.args = argsNode;
3128
- }
3129
- base = new Call(callPayload as any, {}, loc) as unknown as JessNode;
3130
- }
3131
- }
3132
-
3133
- // Step 2: Check for [accessor] suffix
3134
- const accMatch = /^\[([^\]]*)\]/.exec(afterVal);
3135
- if (accMatch) {
3136
- const accText = accMatch[1]!.trim();
3137
- let accessorKey: JessNode | string | number;
3138
- if (accText === '') {
3139
- accessorKey = -1;
3140
- } else if (accText.startsWith('@')) {
3141
- accessorKey = accText.slice(1);
3142
- } else {
3143
- accessorKey = new Quoted(accText, {}, loc) as unknown as JessNode;
3144
- }
3145
- base = new Reference(
3146
- { target: base as any, key: accessorKey as any } as unknown as ReferenceValue,
3147
- {}, loc
3148
- ) as unknown as JessNode;
3149
- const afterAcc = afterVal.slice(accMatch[0].length).trimStart();
3150
- if (/^\(\s*\)/.test(afterAcc)) {
3151
- base = new Call({ name: base as any } as any, {}, loc) as unknown as JessNode;
3152
- }
3153
- } else if (!hasMidCall) {
3154
- // Step 3: Check for trailing () (simple empty call)
3155
- const callMatch = /^\(\s*\)/.exec(afterVal);
3156
- if (callMatch) {
3157
- base = new Call({ name: base as any } as any, {}, loc) as unknown as JessNode;
3158
- }
3159
- }
3160
- }
3161
-
3162
- return base;
3163
- }
3164
-
3165
- private _assembleLessValue(
3166
- valItems: Spanned[],
3167
- loc: LocationInfo
3168
- ): { value: JessNode | string } {
3169
- const parts: JessNode[] = [];
3170
- for (const item of valItems) {
3171
- const c = item.comp;
3172
- if (typeof c === 'string') {
3173
- if (c.trim()) {
3174
- parts.push(this._lessKeyword(c.trim(), loc) as unknown as JessNode);
3175
- }
3176
- } else {
3177
- parts.push(c as JessNode);
3178
- }
3179
- }
3180
- if (parts.length === 0) {
3181
- return { value: '' };
3182
- }
3183
- if (parts.length === 1) {
3184
- return { value: parts[0]! };
3185
- }
3186
- return { value: parts as unknown as JessNode };
3187
- }
3188
-
3189
- /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
3190
- }