@jesscss/less-parser 2.0.0-alpha.6 → 2.0.0-alpha.8
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.
- package/README.md +149 -36
- package/lib/builders.d.ts +381 -0
- package/lib/builders.d.ts.map +1 -0
- package/lib/cst.cjs +14 -0
- package/lib/cst.d.ts +6 -0
- package/lib/cst.d.ts.map +1 -0
- package/lib/cst.js +12 -0
- package/lib/functional-parser.cjs +2654 -0
- package/lib/functional-parser.d.ts +18 -0
- package/lib/functional-parser.d.ts.map +1 -0
- package/lib/functional-parser.js +2589 -0
- package/lib/grammar.cjs +35057 -0
- package/lib/grammar.d.ts +2 -0
- package/lib/grammar.d.ts.map +1 -0
- package/lib/grammar.js +35056 -0
- package/lib/index.cjs +17 -4096
- package/lib/index.d.ts +9 -149
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -4091
- package/lib/jess.cjs +3968 -0
- package/lib/jess.d.ts +7 -0
- package/lib/jess.d.ts.map +1 -0
- package/lib/jess.js +3962 -0
- package/lib/lessParser.d.ts +38 -0
- package/lib/lessParser.d.ts.map +1 -0
- package/lib/lessRecursiveParser.d.ts +99 -0
- package/lib/lessRecursiveParser.d.ts.map +1 -0
- package/lib/lessTokens.d.ts +23 -0
- package/lib/lessTokens.d.ts.map +1 -0
- package/lib/productions/guards.d.ts +113 -0
- package/lib/productions/guards.d.ts.map +1 -0
- package/lib/productions/index.d.ts +5 -0
- package/lib/productions/index.d.ts.map +1 -0
- package/lib/productions/root.d.ts +38 -0
- package/lib/productions/root.d.ts.map +1 -0
- package/lib/productions/selectors.d.ts +41 -0
- package/lib/productions/selectors.d.ts.map +1 -0
- package/lib/productions/values.d.ts +35 -0
- package/lib/productions/values.d.ts.map +1 -0
- package/lib/utils.d.ts +9 -0
- package/lib/utils.d.ts.map +1 -0
- package/package.json +40 -9
- package/src/__tests__/debug-log.ts +35 -0
- package/src/__tests__/wall5-parse.test.ts +67 -0
- package/src/builders.ts +3190 -0
- package/src/cst.ts +25 -0
- package/src/functional-parser.ts +162 -0
- package/src/grammar.ts +870 -0
- package/src/index.ts +19 -0
- package/src/jess.ts +6 -0
- package/src/lessParser.ts +120 -0
- package/src/lessRecursiveParser.ts +279 -0
- package/src/lessTokens.ts +350 -0
- package/src/productions/guards.ts +1066 -0
- package/src/productions/index.ts +29 -0
- package/src/productions/root.ts +1613 -0
- package/src/productions/selectors.ts +1309 -0
- package/src/productions/values.ts +1449 -0
- package/src/utils.ts +178 -0
- package/lib/index.d.cts +0 -150
- package/lib/index.d.cts.map +0 -1
- package/lib/index.js.map +0 -1
package/src/builders.ts
ADDED
|
@@ -0,0 +1,3190 @@
|
|
|
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
|
+
}
|