@excom/quark-parser 0.1.0
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/.rush/temp/chunked-rush-logs/quark-parser.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/quark-parser.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +5 -0
- package/index.ts +12 -0
- package/package.json +39 -0
- package/rush-logs/quark-parser.apply-exports.cache.log +1 -0
- package/rush-logs/quark-parser.apply-exports.log +1 -0
- package/rush-logs/quark-parser.build_package-metas.cache.log +1 -0
- package/rush-logs/quark-parser.build_package-metas.log +1 -0
- package/src/error.ts +24 -0
- package/src/parser.ts +1482 -0
- package/src/tables.ts +77 -0
- package/src/tokenizer.ts +443 -0
- package/src/types.ts +497 -0
- package/support/docs/README.md +443 -0
- package/support/package-meta.json +33 -0
- package/support/tests/grammar-docs.test.ts +109 -0
- package/support/tests/parser-at-rules.test.ts +430 -0
- package/support/tests/parser-declarations.test.ts +152 -0
- package/support/tests/parser-edge-cases.test.ts +296 -0
- package/support/tests/parser-expressions.test.ts +413 -0
- package/support/tests/parser-real-world.test.ts +429 -0
- package/support/tests/parser-selectors.test.ts +169 -0
- package/support/tests/tokenizer.test.ts +268 -0
- package/tsconfig.json +5 -0
package/src/parser.ts
ADDED
|
@@ -0,0 +1,1482 @@
|
|
|
1
|
+
import { QuarkParseError } from "./error";
|
|
2
|
+
import type { QuarkAtRuleName } from "./tables";
|
|
3
|
+
import { ATTR_OPERATORS, BINARY_BP, NOT_BP, SELECTOR_PSEUDOS } from "./tables";
|
|
4
|
+
import { tokenize } from "./tokenizer";
|
|
5
|
+
import type {
|
|
6
|
+
ActionRule,
|
|
7
|
+
Argument,
|
|
8
|
+
AtRule,
|
|
9
|
+
AttributeSelector,
|
|
10
|
+
Block,
|
|
11
|
+
CommentNode,
|
|
12
|
+
Declaration,
|
|
13
|
+
DelayRule,
|
|
14
|
+
EventName,
|
|
15
|
+
Expression,
|
|
16
|
+
FunctionCall,
|
|
17
|
+
IfArm,
|
|
18
|
+
IfFunction,
|
|
19
|
+
Interpolation,
|
|
20
|
+
ListenerOption,
|
|
21
|
+
ListenerRule,
|
|
22
|
+
ListLiteral,
|
|
23
|
+
MapEntry,
|
|
24
|
+
Member,
|
|
25
|
+
Property,
|
|
26
|
+
RawArgument,
|
|
27
|
+
Rule,
|
|
28
|
+
ScopeRule,
|
|
29
|
+
Selector,
|
|
30
|
+
SelectorList,
|
|
31
|
+
SelectorPart,
|
|
32
|
+
Statement,
|
|
33
|
+
Stylesheet,
|
|
34
|
+
Token,
|
|
35
|
+
TransitionRule,
|
|
36
|
+
UseRule,
|
|
37
|
+
ValueAtRule,
|
|
38
|
+
Variable,
|
|
39
|
+
} from "./types";
|
|
40
|
+
|
|
41
|
+
/** Parse a full Quark stylesheet into an AST. */
|
|
42
|
+
export function parse(source: string): Stylesheet {
|
|
43
|
+
return new Parser(source).parseStylesheet();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse a standalone expression (e.g. a declaration value on its own). */
|
|
47
|
+
export function parseExpression(source: string): Expression {
|
|
48
|
+
return new Parser(source).parseStandaloneExpression();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Parse a standalone selector list (e.g. a rule selector on its own). */
|
|
52
|
+
export function parseSelectorList(source: string): SelectorList {
|
|
53
|
+
return new Parser(source).parseStandaloneSelectorList();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const HEX_COLOR = /^[0-9a-fA-F]+$/;
|
|
57
|
+
/**
|
|
58
|
+
* Quark's at-rules and the method that parses each. The table is the whole
|
|
59
|
+
* set: an at-rule missing from it is a parse error.
|
|
60
|
+
*/
|
|
61
|
+
const AT_RULE_PARSERS: Readonly<
|
|
62
|
+
Record<QuarkAtRuleName, (parser: Parser, at: Token) => AtRule>
|
|
63
|
+
> = {
|
|
64
|
+
use: (p, at) => p.parseUseRule(at),
|
|
65
|
+
scope: (p, at) => p.parseScopeRule(at),
|
|
66
|
+
on: (p, at) => p.parseListenerRule(at),
|
|
67
|
+
dispatch: (p, at) => p.parseActionRule(at),
|
|
68
|
+
command: (p, at) => p.parseActionRule(at),
|
|
69
|
+
"view-transition": (p, at) => p.parseTransitionRule(at),
|
|
70
|
+
delay: (p, at) => p.parseDelayRule(at),
|
|
71
|
+
warn: (p, at) => p.parseValueAtRule(at),
|
|
72
|
+
debug: (p, at) => p.parseValueAtRule(at),
|
|
73
|
+
error: (p, at) => p.parseValueAtRule(at),
|
|
74
|
+
};
|
|
75
|
+
/** Tokens that terminate a value/space-list in any context. */
|
|
76
|
+
const HARD_STOPS = new Set([",", ";", ")", "]", "}", "{", ":", "!"]);
|
|
77
|
+
|
|
78
|
+
class Parser {
|
|
79
|
+
private source: string;
|
|
80
|
+
private tokens: Token[];
|
|
81
|
+
private comments: Token[];
|
|
82
|
+
private pos = 0;
|
|
83
|
+
private commentIdx = 0;
|
|
84
|
+
private lastEnd = 0;
|
|
85
|
+
|
|
86
|
+
constructor(source: string, tokens?: Token[], comments?: Token[]) {
|
|
87
|
+
this.source = source;
|
|
88
|
+
if (tokens) {
|
|
89
|
+
this.tokens = tokens;
|
|
90
|
+
this.comments = comments ?? [];
|
|
91
|
+
} else {
|
|
92
|
+
const result = tokenize(source);
|
|
93
|
+
this.tokens = result.tokens;
|
|
94
|
+
this.comments = result.comments;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/*
|
|
99
|
+
* -------------------------------------------------------------------------
|
|
100
|
+
* Cursor helpers
|
|
101
|
+
* -------------------------------------------------------------------------
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
private peek(offset = 0): Token | undefined {
|
|
105
|
+
return this.tokens[this.pos + offset];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private next(): Token {
|
|
109
|
+
const t = this.tokens[this.pos];
|
|
110
|
+
if (!t) this.fail("Unexpected end of input");
|
|
111
|
+
this.pos++;
|
|
112
|
+
this.lastEnd = t.end;
|
|
113
|
+
return t;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private atEnd(): boolean {
|
|
117
|
+
return this.pos >= this.tokens.length;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private isPunct(value: string, offset = 0): boolean {
|
|
121
|
+
const t = this.tokens[this.pos + offset];
|
|
122
|
+
return t !== undefined && t.type === "punct" && t.value === value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private isIdent(value: string, offset = 0): boolean {
|
|
126
|
+
const t = this.tokens[this.pos + offset];
|
|
127
|
+
return t !== undefined && t.type === "ident" && t.value === value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private expectPunct(value: string): Token {
|
|
131
|
+
const t = this.peek();
|
|
132
|
+
if (!t || t.type !== "punct" || t.value !== value) {
|
|
133
|
+
this.fail(`Expected "${value}"${t ? ` but found "${t.value}"` : ""}`);
|
|
134
|
+
}
|
|
135
|
+
return this.next();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private expectIdent(): Token {
|
|
139
|
+
const t = this.peek();
|
|
140
|
+
if (!t || t.type !== "ident") {
|
|
141
|
+
this.fail(`Expected identifier${t ? ` but found "${t.value}"` : ""}`);
|
|
142
|
+
}
|
|
143
|
+
return this.next();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private expectString(): Token {
|
|
147
|
+
const t = this.peek();
|
|
148
|
+
if (!t || t.type !== "string") {
|
|
149
|
+
this.fail(`Expected string${t ? ` but found "${t.value}"` : ""}`);
|
|
150
|
+
}
|
|
151
|
+
return this.next();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private fail(message: string, at?: number): never {
|
|
155
|
+
const position = at ?? this.peek()?.start ?? this.source.length;
|
|
156
|
+
throw new QuarkParseError(message, this.source, position);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/*
|
|
160
|
+
* -------------------------------------------------------------------------
|
|
161
|
+
* Statements
|
|
162
|
+
* -------------------------------------------------------------------------
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
parseStylesheet(): Stylesheet {
|
|
166
|
+
const body: Statement[] = [];
|
|
167
|
+
for (;;) {
|
|
168
|
+
this.flushComments(body, this.peek()?.start ?? Infinity);
|
|
169
|
+
if (this.atEnd()) break;
|
|
170
|
+
const stmt = this.parseStatement();
|
|
171
|
+
if (stmt) body.push(stmt);
|
|
172
|
+
}
|
|
173
|
+
return { type: "stylesheet", body, start: 0, end: this.source.length };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
parseStandaloneExpression(): Expression {
|
|
177
|
+
const expr = this.parseValue();
|
|
178
|
+
if (!this.atEnd()) this.fail("Unexpected trailing input");
|
|
179
|
+
return expr;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
parseStandaloneSelectorList(): SelectorList {
|
|
183
|
+
const list = this.parseSelectorList(["{"]);
|
|
184
|
+
if (!this.atEnd()) this.fail("Unexpected trailing input");
|
|
185
|
+
return list;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private flushComments(body: Statement[], before: number): void {
|
|
189
|
+
while (
|
|
190
|
+
this.commentIdx < this.comments.length &&
|
|
191
|
+
this.comments[this.commentIdx].start < before
|
|
192
|
+
) {
|
|
193
|
+
const c = this.comments[this.commentIdx++];
|
|
194
|
+
const node: CommentNode = {
|
|
195
|
+
type: "comment",
|
|
196
|
+
text: c.value,
|
|
197
|
+
start: c.start,
|
|
198
|
+
end: c.end,
|
|
199
|
+
};
|
|
200
|
+
body.push(node);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private parseStatement(): Statement | null {
|
|
205
|
+
if (this.isPunct(";")) {
|
|
206
|
+
this.next();
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
const t = this.peek()!;
|
|
210
|
+
if (t.type === "at") return this.parseAtRule();
|
|
211
|
+
|
|
212
|
+
const la = this.lookahead();
|
|
213
|
+
if (la.term === "{") {
|
|
214
|
+
/* A key then a block (`font: { … }`, `font: bold { … }`) is a nested
|
|
215
|
+
* property block; `a:hover { … }`, with no space, is a rule. */
|
|
216
|
+
const after = this.peek(2);
|
|
217
|
+
if (
|
|
218
|
+
t.type === "ident" &&
|
|
219
|
+
this.isPunct(":", 1) &&
|
|
220
|
+
la.colon === this.pos + 1 &&
|
|
221
|
+
after !== undefined &&
|
|
222
|
+
(after.ws || (after.type === "punct" && after.value === "{"))
|
|
223
|
+
) {
|
|
224
|
+
this.fail("Nested property blocks are not supported", t.start);
|
|
225
|
+
}
|
|
226
|
+
return this.parseRule();
|
|
227
|
+
}
|
|
228
|
+
if (la.colon >= 0) return this.parseDeclaration();
|
|
229
|
+
this.fail("Expected declaration or rule");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Scans forward (without consuming) to the token that terminates the
|
|
234
|
+
* current statement: `{`, `;`, `}`, or end of input, tracking nesting so
|
|
235
|
+
* parens, brackets, and interpolations are skipped. Also records the first
|
|
236
|
+
* top-level `:`.
|
|
237
|
+
*/
|
|
238
|
+
private lookahead(): { term: string; colon: number } {
|
|
239
|
+
const toks = this.tokens;
|
|
240
|
+
let depth = 0;
|
|
241
|
+
let colon = -1;
|
|
242
|
+
for (let j = this.pos; j < toks.length; j++) {
|
|
243
|
+
const t = toks[j];
|
|
244
|
+
if (t.type !== "punct") continue;
|
|
245
|
+
const v = t.value;
|
|
246
|
+
if (v === "(" || v === "[" || v === "#{") depth++;
|
|
247
|
+
else if (v === ")" || v === "]") depth--;
|
|
248
|
+
else if (v === "{") {
|
|
249
|
+
if (depth > 0) depth++;
|
|
250
|
+
else return { term: "{", colon };
|
|
251
|
+
} else if (v === "}") {
|
|
252
|
+
if (depth > 0) depth--;
|
|
253
|
+
else return { term: "}", colon };
|
|
254
|
+
} else if (v === ";" && depth === 0) {
|
|
255
|
+
return { term: ";", colon };
|
|
256
|
+
} else if ((v === ":" || v === "::") && depth === 0 && colon < 0) {
|
|
257
|
+
colon = j;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { term: "eof", colon };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private parseRule(): Rule {
|
|
264
|
+
const start = this.peek()!.start;
|
|
265
|
+
const selector = this.parseSelectorList(["{"]);
|
|
266
|
+
const block = this.parseBlock();
|
|
267
|
+
return { type: "rule", selector, block, start, end: block.end };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private parseBlock(): Block {
|
|
271
|
+
const open = this.expectPunct("{");
|
|
272
|
+
const body: Statement[] = [];
|
|
273
|
+
for (;;) {
|
|
274
|
+
this.flushComments(body, this.peek()?.start ?? Infinity);
|
|
275
|
+
if (this.atEnd()) this.fail("Unclosed block", open.start);
|
|
276
|
+
if (this.isPunct("}")) break;
|
|
277
|
+
const stmt = this.parseStatement();
|
|
278
|
+
if (stmt) body.push(stmt);
|
|
279
|
+
}
|
|
280
|
+
const close = this.next();
|
|
281
|
+
return { type: "block", body, start: open.start, end: close.end };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/*
|
|
285
|
+
* -------------------------------------------------------------------------
|
|
286
|
+
* Declarations
|
|
287
|
+
* -------------------------------------------------------------------------
|
|
288
|
+
*/
|
|
289
|
+
|
|
290
|
+
private parseDeclaration(): Declaration {
|
|
291
|
+
const startTok = this.peek()!;
|
|
292
|
+
let property: Property | Variable;
|
|
293
|
+
if (startTok.type === "variable") {
|
|
294
|
+
this.next();
|
|
295
|
+
/* A member key (`$sig.value:`) was the signal write form, removed
|
|
296
|
+
* 2026-09-13: bindings are written on the owner (an `@on` block
|
|
297
|
+
* there, or `element.quark.setProperty()` from JS). */
|
|
298
|
+
if (this.isPunct(".") && !this.peek()!.ws) {
|
|
299
|
+
this.fail(
|
|
300
|
+
`Member keys ($${startTok.value}.…:) are not supported: declare $${startTok.value} on the owner rule, write it from an @on block on the owner, or from JS via element.quark.setProperty()`,
|
|
301
|
+
this.peek()!.start
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
property = {
|
|
305
|
+
type: "variable",
|
|
306
|
+
name: startTok.value,
|
|
307
|
+
start: startTok.start,
|
|
308
|
+
end: startTok.end,
|
|
309
|
+
};
|
|
310
|
+
} else {
|
|
311
|
+
property = this.parsePropertyName();
|
|
312
|
+
}
|
|
313
|
+
this.expectPunct(":");
|
|
314
|
+
const value = this.parseValue();
|
|
315
|
+
if (this.isPunct("!")) {
|
|
316
|
+
const bang = this.next();
|
|
317
|
+
this.fail(`!${this.expectIdent().value} is not supported`, bang.start);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (this.isPunct(";")) this.next();
|
|
321
|
+
else if (!this.atEnd() && !this.isPunct("}")) {
|
|
322
|
+
this.fail(
|
|
323
|
+
`Expected ";" but found "${this.peek()!.value}"` +
|
|
324
|
+
(this.isPunct("?")
|
|
325
|
+
? " (JS-style ternary/optional-chaining syntax is not supported in Quark)"
|
|
326
|
+
: "")
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
type: "declaration",
|
|
332
|
+
property,
|
|
333
|
+
value,
|
|
334
|
+
start: startTok.start,
|
|
335
|
+
end: this.lastEnd,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private parsePropertyName(): Property {
|
|
340
|
+
const startTok = this.peek()!;
|
|
341
|
+
let name = "";
|
|
342
|
+
let end = startTok.start;
|
|
343
|
+
let first = true;
|
|
344
|
+
for (;;) {
|
|
345
|
+
const t = this.peek();
|
|
346
|
+
if (!t || (!first && t.ws)) break;
|
|
347
|
+
if (t.type === "punct" && t.value === "#{") {
|
|
348
|
+
this.fail("Interpolation is only supported inside strings", t.start);
|
|
349
|
+
}
|
|
350
|
+
if (t.type !== "ident" && !(t.type === "punct" && t.value === "*")) {
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
this.next();
|
|
354
|
+
name += t.value;
|
|
355
|
+
end = t.end;
|
|
356
|
+
first = false;
|
|
357
|
+
}
|
|
358
|
+
if (!name) this.fail("Expected property name");
|
|
359
|
+
return { type: "property", name, start: startTok.start, end };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/*
|
|
363
|
+
* -------------------------------------------------------------------------
|
|
364
|
+
* Selectors
|
|
365
|
+
* -------------------------------------------------------------------------
|
|
366
|
+
*/
|
|
367
|
+
|
|
368
|
+
private parseSelectorList(stops: string[]): SelectorList {
|
|
369
|
+
const start = this.peek()?.start ?? this.lastEnd;
|
|
370
|
+
const selectors: Selector[] = [];
|
|
371
|
+
for (;;) {
|
|
372
|
+
selectors.push(this.parseSelector(stops));
|
|
373
|
+
if (this.isPunct(",")) {
|
|
374
|
+
this.next();
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
type: "selector_list",
|
|
381
|
+
selectors,
|
|
382
|
+
start,
|
|
383
|
+
end: this.lastEnd,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private parseSelector(stops: string[]): Selector {
|
|
388
|
+
const parts: SelectorPart[] = [];
|
|
389
|
+
const start = this.peek()?.start ?? this.lastEnd;
|
|
390
|
+
for (;;) {
|
|
391
|
+
const t = this.peek();
|
|
392
|
+
if (!t) break;
|
|
393
|
+
if (t.type === "punct" && (stops.includes(t.value) || t.value === ",")) {
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
if (
|
|
397
|
+
t.type === "punct" &&
|
|
398
|
+
(t.value === ">" || t.value === "+" || t.value === "~")
|
|
399
|
+
) {
|
|
400
|
+
this.next();
|
|
401
|
+
parts.push({
|
|
402
|
+
type: "combinator",
|
|
403
|
+
value: t.value as ">" | "+" | "~",
|
|
404
|
+
start: t.start,
|
|
405
|
+
end: t.end,
|
|
406
|
+
});
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (
|
|
410
|
+
parts.length &&
|
|
411
|
+
t.ws &&
|
|
412
|
+
parts[parts.length - 1].type !== "combinator"
|
|
413
|
+
) {
|
|
414
|
+
parts.push({
|
|
415
|
+
type: "combinator",
|
|
416
|
+
value: " ",
|
|
417
|
+
start: this.lastEnd,
|
|
418
|
+
end: t.start,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
parts.push(this.parseCompoundPart());
|
|
422
|
+
}
|
|
423
|
+
if (!parts.length) this.fail("Expected selector");
|
|
424
|
+
return { type: "selector", parts, start, end: this.lastEnd };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private parseCompoundPart(): SelectorPart {
|
|
428
|
+
const t = this.peek()!;
|
|
429
|
+
switch (t.type) {
|
|
430
|
+
case "ident": {
|
|
431
|
+
this.next();
|
|
432
|
+
return {
|
|
433
|
+
type: "type_selector",
|
|
434
|
+
name: t.value,
|
|
435
|
+
start: t.start,
|
|
436
|
+
end: t.end,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
case "hash": {
|
|
440
|
+
this.next();
|
|
441
|
+
return {
|
|
442
|
+
type: "id_selector",
|
|
443
|
+
name: t.value,
|
|
444
|
+
start: t.start,
|
|
445
|
+
end: t.end,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
case "punct":
|
|
449
|
+
switch (t.value) {
|
|
450
|
+
case "*": {
|
|
451
|
+
this.next();
|
|
452
|
+
return {
|
|
453
|
+
type: "type_selector",
|
|
454
|
+
name: "*",
|
|
455
|
+
start: t.start,
|
|
456
|
+
end: t.end,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
case ".": {
|
|
460
|
+
this.next();
|
|
461
|
+
return {
|
|
462
|
+
type: "class_selector",
|
|
463
|
+
name: this.expectIdent().value,
|
|
464
|
+
start: t.start,
|
|
465
|
+
end: this.lastEnd,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
case "%":
|
|
469
|
+
this.fail("Placeholder selectors are not supported", t.start);
|
|
470
|
+
break;
|
|
471
|
+
case "&": {
|
|
472
|
+
this.next();
|
|
473
|
+
let suffix: string | null = null;
|
|
474
|
+
const nextTok = this.peek();
|
|
475
|
+
if (nextTok && !nextTok.ws && nextTok.type === "ident") {
|
|
476
|
+
this.next();
|
|
477
|
+
suffix = nextTok.value;
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
type: "parent_selector",
|
|
481
|
+
suffix,
|
|
482
|
+
start: t.start,
|
|
483
|
+
end: this.lastEnd,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
case "#{":
|
|
487
|
+
this.fail(
|
|
488
|
+
"Interpolation is only supported inside strings",
|
|
489
|
+
t.start
|
|
490
|
+
);
|
|
491
|
+
break;
|
|
492
|
+
case "[":
|
|
493
|
+
return this.parseAttributeSelector();
|
|
494
|
+
case ":": {
|
|
495
|
+
this.next();
|
|
496
|
+
const nameTok = this.expectIdent();
|
|
497
|
+
let argument: SelectorList | RawArgument | null = null;
|
|
498
|
+
if (this.isPunct("(") && !this.peek()!.ws) {
|
|
499
|
+
if (SELECTOR_PSEUDOS.has(nameTok.value)) {
|
|
500
|
+
this.expectPunct("(");
|
|
501
|
+
argument = this.parseSelectorList([")"]);
|
|
502
|
+
this.expectPunct(")");
|
|
503
|
+
} else {
|
|
504
|
+
argument = this.parseRawParens();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
type: "pseudo_class_selector",
|
|
509
|
+
name: nameTok.value,
|
|
510
|
+
argument,
|
|
511
|
+
start: t.start,
|
|
512
|
+
end: this.lastEnd,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
case "::": {
|
|
516
|
+
this.next();
|
|
517
|
+
const nameTok = this.expectIdent();
|
|
518
|
+
let argument: RawArgument | null = null;
|
|
519
|
+
if (this.isPunct("(") && !this.peek()!.ws) {
|
|
520
|
+
argument = this.parseRawParens();
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
type: "pseudo_element_selector",
|
|
524
|
+
name: nameTok.value,
|
|
525
|
+
argument,
|
|
526
|
+
start: t.start,
|
|
527
|
+
end: this.lastEnd,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
this.fail(`Unexpected token "${t.value}" in selector`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private parseAttributeSelector(): AttributeSelector {
|
|
537
|
+
const open = this.expectPunct("[");
|
|
538
|
+
const name = this.expectIdent().value;
|
|
539
|
+
let operator: string | null = null;
|
|
540
|
+
let value: Expression | null = null;
|
|
541
|
+
let modifier: string | null = null;
|
|
542
|
+
const opTok = this.peek();
|
|
543
|
+
if (opTok && opTok.type === "punct" && ATTR_OPERATORS.has(opTok.value)) {
|
|
544
|
+
this.next();
|
|
545
|
+
operator = opTok.value;
|
|
546
|
+
const v = this.peek();
|
|
547
|
+
if (!v) this.fail("Expected attribute value");
|
|
548
|
+
if (v.type === "string") {
|
|
549
|
+
this.next();
|
|
550
|
+
value = this.makeString(v);
|
|
551
|
+
} else if (v.type === "ident") {
|
|
552
|
+
this.next();
|
|
553
|
+
value = {
|
|
554
|
+
type: "identifier",
|
|
555
|
+
name: v.value,
|
|
556
|
+
start: v.start,
|
|
557
|
+
end: v.end,
|
|
558
|
+
};
|
|
559
|
+
} else if (v.type === "number") {
|
|
560
|
+
this.next();
|
|
561
|
+
value = {
|
|
562
|
+
type: "number",
|
|
563
|
+
value: parseFloat(v.value),
|
|
564
|
+
unit: v.unit ?? null,
|
|
565
|
+
start: v.start,
|
|
566
|
+
end: v.end,
|
|
567
|
+
};
|
|
568
|
+
} else if (v.type === "punct" && v.value === "#{") {
|
|
569
|
+
this.fail("Interpolation is only supported inside strings", v.start);
|
|
570
|
+
} else {
|
|
571
|
+
this.fail(`Unexpected attribute value "${v.value}"`);
|
|
572
|
+
}
|
|
573
|
+
const mod = this.peek();
|
|
574
|
+
if (
|
|
575
|
+
mod &&
|
|
576
|
+
mod.type === "ident" &&
|
|
577
|
+
(mod.value === "i" || mod.value === "s")
|
|
578
|
+
) {
|
|
579
|
+
this.next();
|
|
580
|
+
modifier = mod.value;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const close = this.expectPunct("]");
|
|
584
|
+
return {
|
|
585
|
+
type: "attribute_selector",
|
|
586
|
+
name,
|
|
587
|
+
operator,
|
|
588
|
+
value,
|
|
589
|
+
modifier,
|
|
590
|
+
start: open.start,
|
|
591
|
+
end: close.end,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private parseRawParens(): RawArgument {
|
|
596
|
+
const open = this.expectPunct("(");
|
|
597
|
+
let depth = 1;
|
|
598
|
+
let end = open.end;
|
|
599
|
+
while (depth > 0) {
|
|
600
|
+
const t = this.next();
|
|
601
|
+
if (t.type === "punct") {
|
|
602
|
+
if (t.value === "(" || t.value === "#{") depth++;
|
|
603
|
+
else if (t.value === ")") depth--;
|
|
604
|
+
else if (t.value === "}") depth--;
|
|
605
|
+
}
|
|
606
|
+
if (depth > 0) end = t.end;
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
type: "raw",
|
|
610
|
+
value: this.source.slice(open.end, end).trim(),
|
|
611
|
+
start: open.start,
|
|
612
|
+
end: this.lastEnd,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/*
|
|
617
|
+
* -------------------------------------------------------------------------
|
|
618
|
+
* Expressions
|
|
619
|
+
* -------------------------------------------------------------------------
|
|
620
|
+
*/
|
|
621
|
+
|
|
622
|
+
/** Full declaration-value grammar: comma lists of space lists. */
|
|
623
|
+
private parseValue(): Expression {
|
|
624
|
+
const start = this.peek()?.start ?? this.lastEnd;
|
|
625
|
+
const first = this.parseSpaceList();
|
|
626
|
+
if (!this.isPunct(",")) return first;
|
|
627
|
+
const items = [first];
|
|
628
|
+
while (this.isPunct(",")) {
|
|
629
|
+
this.next();
|
|
630
|
+
if (!this.canStartExpression()) break; // tolerate trailing comma
|
|
631
|
+
items.push(this.parseSpaceList());
|
|
632
|
+
}
|
|
633
|
+
return {
|
|
634
|
+
type: "list",
|
|
635
|
+
separator: ",",
|
|
636
|
+
items,
|
|
637
|
+
brackets: false,
|
|
638
|
+
parens: false,
|
|
639
|
+
start,
|
|
640
|
+
end: this.lastEnd,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private parseSpaceList(): Expression {
|
|
645
|
+
const start = this.peek()?.start ?? this.lastEnd;
|
|
646
|
+
const first = this.parseExpr(0);
|
|
647
|
+
if (!this.canStartExpression()) return first;
|
|
648
|
+
const items = [first];
|
|
649
|
+
while (this.canStartExpression()) items.push(this.parseExpr(0));
|
|
650
|
+
return {
|
|
651
|
+
type: "list",
|
|
652
|
+
separator: " ",
|
|
653
|
+
items,
|
|
654
|
+
brackets: false,
|
|
655
|
+
parens: false,
|
|
656
|
+
start,
|
|
657
|
+
end: this.lastEnd,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private canStartExpression(): boolean {
|
|
662
|
+
const t = this.peek();
|
|
663
|
+
if (!t) return false;
|
|
664
|
+
switch (t.type) {
|
|
665
|
+
case "ident":
|
|
666
|
+
case "variable":
|
|
667
|
+
case "string":
|
|
668
|
+
case "number":
|
|
669
|
+
case "hash":
|
|
670
|
+
return true;
|
|
671
|
+
case "punct":
|
|
672
|
+
if (HARD_STOPS.has(t.value)) return false;
|
|
673
|
+
return (
|
|
674
|
+
t.value === "(" ||
|
|
675
|
+
t.value === "[" ||
|
|
676
|
+
t.value === "#{" ||
|
|
677
|
+
t.value === "&"
|
|
678
|
+
);
|
|
679
|
+
default:
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private parseExpr(minBp: number): Expression {
|
|
685
|
+
let left = this.parseUnary();
|
|
686
|
+
for (;;) {
|
|
687
|
+
const t = this.peek();
|
|
688
|
+
if (!t) break;
|
|
689
|
+
let op: string | null = null;
|
|
690
|
+
if (t.type === "punct" && BINARY_BP[t.value] !== undefined) op = t.value;
|
|
691
|
+
else if (t.type === "ident" && (t.value === "and" || t.value === "or")) {
|
|
692
|
+
op = t.value;
|
|
693
|
+
}
|
|
694
|
+
if (!op) break;
|
|
695
|
+
const bp = BINARY_BP[op];
|
|
696
|
+
if (bp <= minBp) break;
|
|
697
|
+
this.next();
|
|
698
|
+
const right = this.parseExpr(bp);
|
|
699
|
+
left = {
|
|
700
|
+
type: "binary",
|
|
701
|
+
operator: op as never,
|
|
702
|
+
left,
|
|
703
|
+
right,
|
|
704
|
+
start: left.start,
|
|
705
|
+
end: right.end,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
return left;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private parseUnary(): Expression {
|
|
712
|
+
const t = this.peek();
|
|
713
|
+
if (!t) this.fail("Unexpected end of input");
|
|
714
|
+
if (t.type === "punct" && (t.value === "-" || t.value === "+")) {
|
|
715
|
+
this.next();
|
|
716
|
+
const argument = this.parseUnary();
|
|
717
|
+
return {
|
|
718
|
+
type: "unary",
|
|
719
|
+
operator: t.value as "-" | "+",
|
|
720
|
+
argument,
|
|
721
|
+
start: t.start,
|
|
722
|
+
end: argument.end,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
if (t.type === "ident" && t.value === "not") {
|
|
726
|
+
this.next();
|
|
727
|
+
const argument = this.parseExpr(NOT_BP);
|
|
728
|
+
return {
|
|
729
|
+
type: "unary",
|
|
730
|
+
operator: "not",
|
|
731
|
+
argument,
|
|
732
|
+
start: t.start,
|
|
733
|
+
end: argument.end,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
return this.parsePostfix();
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
private parsePostfix(): Expression {
|
|
740
|
+
let node = this.parsePrimary();
|
|
741
|
+
for (;;) {
|
|
742
|
+
const t = this.peek();
|
|
743
|
+
if (!t || t.type !== "punct") break;
|
|
744
|
+
if (t.value === ".") {
|
|
745
|
+
const prop = this.peek(1);
|
|
746
|
+
if (!prop || (prop.type !== "ident" && prop.type !== "variable")) {
|
|
747
|
+
this.fail('Expected property name after "."', t.start);
|
|
748
|
+
}
|
|
749
|
+
this.next();
|
|
750
|
+
this.next();
|
|
751
|
+
const member: Member = {
|
|
752
|
+
type: "member",
|
|
753
|
+
object: node,
|
|
754
|
+
property: prop.value,
|
|
755
|
+
variable: prop.type === "variable",
|
|
756
|
+
start: node.start,
|
|
757
|
+
end: prop.end,
|
|
758
|
+
};
|
|
759
|
+
node = member;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (t.value === "[" && !t.ws) {
|
|
763
|
+
this.next();
|
|
764
|
+
const index = this.parseExpr(0);
|
|
765
|
+
const close = this.expectPunct("]");
|
|
766
|
+
node = {
|
|
767
|
+
type: "index",
|
|
768
|
+
object: node,
|
|
769
|
+
index,
|
|
770
|
+
start: node.start,
|
|
771
|
+
end: close.end,
|
|
772
|
+
};
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (
|
|
776
|
+
t.value === "(" &&
|
|
777
|
+
!t.ws &&
|
|
778
|
+
(node.type === "identifier" ||
|
|
779
|
+
node.type === "member" ||
|
|
780
|
+
node.type === "interpolation")
|
|
781
|
+
) {
|
|
782
|
+
const args = this.parseArguments();
|
|
783
|
+
const call: FunctionCall = {
|
|
784
|
+
type: "function",
|
|
785
|
+
callee: node,
|
|
786
|
+
args,
|
|
787
|
+
start: node.start,
|
|
788
|
+
end: this.lastEnd,
|
|
789
|
+
};
|
|
790
|
+
node = call;
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
break;
|
|
794
|
+
}
|
|
795
|
+
return node;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private parsePrimary(): Expression {
|
|
799
|
+
const t = this.peek();
|
|
800
|
+
if (!t) this.fail("Unexpected end of input");
|
|
801
|
+
switch (t.type) {
|
|
802
|
+
case "number":
|
|
803
|
+
this.next();
|
|
804
|
+
return {
|
|
805
|
+
type: "number",
|
|
806
|
+
value: parseFloat(t.value),
|
|
807
|
+
unit: t.unit ?? null,
|
|
808
|
+
start: t.start,
|
|
809
|
+
end: t.end,
|
|
810
|
+
};
|
|
811
|
+
case "string":
|
|
812
|
+
this.next();
|
|
813
|
+
return this.makeString(t);
|
|
814
|
+
case "variable":
|
|
815
|
+
this.next();
|
|
816
|
+
return { type: "variable", name: t.value, start: t.start, end: t.end };
|
|
817
|
+
case "hash": {
|
|
818
|
+
this.next();
|
|
819
|
+
if (HEX_COLOR.test(t.value)) {
|
|
820
|
+
return {
|
|
821
|
+
type: "color",
|
|
822
|
+
value: "#" + t.value,
|
|
823
|
+
start: t.start,
|
|
824
|
+
end: t.end,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
return {
|
|
828
|
+
type: "identifier",
|
|
829
|
+
name: "#" + t.value,
|
|
830
|
+
start: t.start,
|
|
831
|
+
end: t.end,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
case "ident": {
|
|
835
|
+
if (t.value === "true" || t.value === "false") {
|
|
836
|
+
this.next();
|
|
837
|
+
return {
|
|
838
|
+
type: "boolean",
|
|
839
|
+
value: t.value === "true",
|
|
840
|
+
start: t.start,
|
|
841
|
+
end: t.end,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
if (t.value === "null") {
|
|
845
|
+
this.next();
|
|
846
|
+
return { type: "null", start: t.start, end: t.end };
|
|
847
|
+
}
|
|
848
|
+
/*
|
|
849
|
+
* CSS-style conditional: `if($cond: a; else: b)`. Only when the
|
|
850
|
+
* parens contain top-level `condition: value` arms; a colon-less
|
|
851
|
+
* `if(...)` falls through to a regular function call.
|
|
852
|
+
*/
|
|
853
|
+
if (
|
|
854
|
+
t.value === "if" &&
|
|
855
|
+
this.isPunct("(", 1) &&
|
|
856
|
+
!this.peek(1)!.ws &&
|
|
857
|
+
this.ifParensHaveArms()
|
|
858
|
+
) {
|
|
859
|
+
return this.parseIfFunction(t);
|
|
860
|
+
}
|
|
861
|
+
// Unquoted url(...): tokenizer emitted [ident url, "(", url, ")"].
|
|
862
|
+
if (this.peek(1)?.type === "punct" && this.isPunct("(", 1)) {
|
|
863
|
+
const rawTok = this.peek(2);
|
|
864
|
+
if (rawTok?.type === "url") {
|
|
865
|
+
this.next(); // url ident
|
|
866
|
+
this.next(); // (
|
|
867
|
+
this.next(); // raw
|
|
868
|
+
const close = this.expectPunct(")");
|
|
869
|
+
return {
|
|
870
|
+
type: "url",
|
|
871
|
+
parts: this.splitInterpolatable(rawTok.value, rawTok.start),
|
|
872
|
+
start: t.start,
|
|
873
|
+
end: close.end,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
this.next();
|
|
878
|
+
return {
|
|
879
|
+
type: "identifier",
|
|
880
|
+
name: t.value,
|
|
881
|
+
start: t.start,
|
|
882
|
+
end: t.end,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
case "punct":
|
|
886
|
+
switch (t.value) {
|
|
887
|
+
case "(":
|
|
888
|
+
return this.parseParens();
|
|
889
|
+
case "[":
|
|
890
|
+
return this.parseBracketList();
|
|
891
|
+
case "#{":
|
|
892
|
+
return this.parseInterpolation();
|
|
893
|
+
case "&": {
|
|
894
|
+
this.next();
|
|
895
|
+
if (this.isPunct("&") && !this.peek()!.ws) {
|
|
896
|
+
this.fail(
|
|
897
|
+
'"&&" is not supported in Quark (JS-style logical operators are not part of the language)',
|
|
898
|
+
t.start
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
return { type: "parent_reference", start: t.start, end: t.end };
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
this.fail(
|
|
907
|
+
`Unexpected token "${t.value}"` +
|
|
908
|
+
(t.value === "?"
|
|
909
|
+
? " (JS-style ternary/optional-chaining syntax is not supported in Quark)"
|
|
910
|
+
: "")
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** `(...)`: grouping, comma list, or map. */
|
|
915
|
+
private parseParens(): Expression {
|
|
916
|
+
const open = this.expectPunct("(");
|
|
917
|
+
if (this.isPunct(")")) {
|
|
918
|
+
const close = this.next();
|
|
919
|
+
return {
|
|
920
|
+
type: "list",
|
|
921
|
+
separator: ",",
|
|
922
|
+
items: [],
|
|
923
|
+
brackets: false,
|
|
924
|
+
parens: true,
|
|
925
|
+
start: open.start,
|
|
926
|
+
end: close.end,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
const first = this.parseSpaceList();
|
|
930
|
+
if (this.isPunct(":")) {
|
|
931
|
+
// Map literal.
|
|
932
|
+
const entries: MapEntry[] = [];
|
|
933
|
+
this.next();
|
|
934
|
+
entries.push({ key: first, value: this.parseSpaceList() });
|
|
935
|
+
while (this.isPunct(",")) {
|
|
936
|
+
this.next();
|
|
937
|
+
if (this.isPunct(")")) break;
|
|
938
|
+
const key = this.parseSpaceList();
|
|
939
|
+
this.expectPunct(":");
|
|
940
|
+
entries.push({ key, value: this.parseSpaceList() });
|
|
941
|
+
}
|
|
942
|
+
const close = this.expectPunct(")");
|
|
943
|
+
return { type: "map", entries, start: open.start, end: close.end };
|
|
944
|
+
}
|
|
945
|
+
if (this.isPunct(",")) {
|
|
946
|
+
const items = [first];
|
|
947
|
+
while (this.isPunct(",")) {
|
|
948
|
+
this.next();
|
|
949
|
+
if (this.isPunct(")")) break;
|
|
950
|
+
items.push(this.parseSpaceList());
|
|
951
|
+
}
|
|
952
|
+
const close = this.expectPunct(")");
|
|
953
|
+
return {
|
|
954
|
+
type: "list",
|
|
955
|
+
separator: ",",
|
|
956
|
+
items,
|
|
957
|
+
brackets: false,
|
|
958
|
+
parens: true,
|
|
959
|
+
start: open.start,
|
|
960
|
+
end: close.end,
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
const close = this.expectPunct(")");
|
|
964
|
+
if (first.type === "list") (first as ListLiteral).parens = true;
|
|
965
|
+
/* Widen the span to the parens so source slicers (quark declaration
|
|
966
|
+
* values) reparse cleanly: `(1 + 2) * 3` must not become `1 + 2) * 3`. */
|
|
967
|
+
first.start = open.start;
|
|
968
|
+
first.end = close.end;
|
|
969
|
+
return first;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
private parseBracketList(): ListLiteral {
|
|
973
|
+
const open = this.expectPunct("[");
|
|
974
|
+
const items: Expression[] = [];
|
|
975
|
+
let separator: "," | " " = " ";
|
|
976
|
+
while (!this.isPunct("]")) {
|
|
977
|
+
items.push(this.parseSpaceList());
|
|
978
|
+
if (this.isPunct(",")) {
|
|
979
|
+
separator = ",";
|
|
980
|
+
this.next();
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
const close = this.expectPunct("]");
|
|
984
|
+
return {
|
|
985
|
+
type: "list",
|
|
986
|
+
separator,
|
|
987
|
+
items,
|
|
988
|
+
brackets: true,
|
|
989
|
+
parens: false,
|
|
990
|
+
start: open.start,
|
|
991
|
+
end: close.end,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
private parseInterpolation(): Interpolation {
|
|
996
|
+
const open = this.expectPunct("#{");
|
|
997
|
+
const expression = this.parseValue();
|
|
998
|
+
const close = this.expectPunct("}");
|
|
999
|
+
return {
|
|
1000
|
+
type: "interpolation",
|
|
1001
|
+
expression,
|
|
1002
|
+
start: open.start,
|
|
1003
|
+
end: close.end,
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Lookahead from the `(` after `if`: does it contain a `:` at paren depth
|
|
1009
|
+
* one? Colons nested deeper (maps, nested calls) don't count, and `:`
|
|
1010
|
+
* inside strings is part of the string token.
|
|
1011
|
+
*/
|
|
1012
|
+
private ifParensHaveArms(): boolean {
|
|
1013
|
+
let depth = 0;
|
|
1014
|
+
for (let i = this.pos + 1; i < this.tokens.length; i++) {
|
|
1015
|
+
const t = this.tokens[i];
|
|
1016
|
+
if (t.type !== "punct") continue;
|
|
1017
|
+
if (t.value === "(" || t.value === "[" || t.value === "#{") {
|
|
1018
|
+
depth++;
|
|
1019
|
+
} else if (t.value === ")" || t.value === "]" || t.value === "}") {
|
|
1020
|
+
depth--;
|
|
1021
|
+
if (depth === 0) return false;
|
|
1022
|
+
} else if (t.value === ":" && depth === 1) {
|
|
1023
|
+
return true;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** `if(condition: value; condition: value; else: value)` */
|
|
1030
|
+
private parseIfFunction(ifTok: Token): IfFunction {
|
|
1031
|
+
this.next(); // `if`
|
|
1032
|
+
this.expectPunct("(");
|
|
1033
|
+
const arms: IfArm[] = [];
|
|
1034
|
+
let sawElse = false;
|
|
1035
|
+
while (!this.isPunct(")")) {
|
|
1036
|
+
const first = this.peek();
|
|
1037
|
+
if (!first) this.fail("Unclosed if()");
|
|
1038
|
+
if (sawElse) {
|
|
1039
|
+
this.fail('"else" must be the last arm in if()', first.start);
|
|
1040
|
+
}
|
|
1041
|
+
let condition: Expression | null = null;
|
|
1042
|
+
if (
|
|
1043
|
+
first.type === "ident" &&
|
|
1044
|
+
first.value === "else" &&
|
|
1045
|
+
this.isPunct(":", 1)
|
|
1046
|
+
) {
|
|
1047
|
+
this.next();
|
|
1048
|
+
sawElse = true;
|
|
1049
|
+
} else {
|
|
1050
|
+
condition = this.parseExpr(0);
|
|
1051
|
+
}
|
|
1052
|
+
this.expectPunct(":");
|
|
1053
|
+
arms.push({ condition, value: this.parseValue() });
|
|
1054
|
+
if (this.isPunct(";")) this.next();
|
|
1055
|
+
else break;
|
|
1056
|
+
}
|
|
1057
|
+
const close = this.expectPunct(")");
|
|
1058
|
+
return { type: "if", arms, start: ifTok.start, end: close.end };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
private parseArguments(): Argument[] {
|
|
1062
|
+
this.expectPunct("(");
|
|
1063
|
+
const args: Argument[] = [];
|
|
1064
|
+
while (!this.isPunct(")")) {
|
|
1065
|
+
const startTok = this.peek();
|
|
1066
|
+
if (!startTok) this.fail("Unclosed arguments");
|
|
1067
|
+
let name: string | null = null;
|
|
1068
|
+
if (startTok.type === "variable" && this.isPunct(":", 1)) {
|
|
1069
|
+
this.next();
|
|
1070
|
+
this.next();
|
|
1071
|
+
name = startTok.value;
|
|
1072
|
+
}
|
|
1073
|
+
const value = this.parseSpaceList();
|
|
1074
|
+
let spread = false;
|
|
1075
|
+
if (this.isPunct("...")) {
|
|
1076
|
+
this.next();
|
|
1077
|
+
spread = true;
|
|
1078
|
+
}
|
|
1079
|
+
args.push({
|
|
1080
|
+
type: "argument",
|
|
1081
|
+
name,
|
|
1082
|
+
value,
|
|
1083
|
+
spread,
|
|
1084
|
+
start: startTok.start,
|
|
1085
|
+
end: this.lastEnd,
|
|
1086
|
+
});
|
|
1087
|
+
if (this.isPunct(",")) this.next();
|
|
1088
|
+
else break;
|
|
1089
|
+
}
|
|
1090
|
+
this.expectPunct(")");
|
|
1091
|
+
return args;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
private makeString(t: Token): Expression {
|
|
1095
|
+
const raw = t.value;
|
|
1096
|
+
const quote = t.quote ?? '"';
|
|
1097
|
+
if (!raw.includes("#{")) {
|
|
1098
|
+
return {
|
|
1099
|
+
type: "string",
|
|
1100
|
+
quote,
|
|
1101
|
+
parts: raw.length ? [raw] : [],
|
|
1102
|
+
value: raw,
|
|
1103
|
+
start: t.start,
|
|
1104
|
+
end: t.end,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
// Contents start one char after the opening quote.
|
|
1108
|
+
const parts = this.splitInterpolatable(raw, t.start + 1);
|
|
1109
|
+
return {
|
|
1110
|
+
type: "string",
|
|
1111
|
+
quote,
|
|
1112
|
+
parts,
|
|
1113
|
+
value: null,
|
|
1114
|
+
start: t.start,
|
|
1115
|
+
end: t.end,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Splits raw text containing `#{...}` into literal parts and parsed
|
|
1121
|
+
* interpolation expressions. `baseOffset` is the absolute source offset of
|
|
1122
|
+
* `raw[0]` so spans stay correct.
|
|
1123
|
+
*/
|
|
1124
|
+
private splitInterpolatable(
|
|
1125
|
+
raw: string,
|
|
1126
|
+
baseOffset: number
|
|
1127
|
+
): Array<string | Interpolation> {
|
|
1128
|
+
if (!raw.includes("#{")) return raw.length ? [raw] : [];
|
|
1129
|
+
const parts: Array<string | Interpolation> = [];
|
|
1130
|
+
let lit = 0;
|
|
1131
|
+
for (let k = 0; k < raw.length; k++) {
|
|
1132
|
+
const c = raw.charCodeAt(k);
|
|
1133
|
+
if (c === 92 /* \ */) {
|
|
1134
|
+
k++;
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
if (c === 35 /* # */ && raw.charCodeAt(k + 1) === 123 /* { */) {
|
|
1138
|
+
if (k > lit) parts.push(raw.slice(lit, k));
|
|
1139
|
+
// Find the balanced close brace, skipping nested strings.
|
|
1140
|
+
let depth = 1;
|
|
1141
|
+
let j = k + 2;
|
|
1142
|
+
while (j < raw.length && depth > 0) {
|
|
1143
|
+
const cc = raw.charCodeAt(j);
|
|
1144
|
+
if (cc === 123) depth++;
|
|
1145
|
+
else if (cc === 125) depth--;
|
|
1146
|
+
else if (cc === 34 || cc === 39) {
|
|
1147
|
+
j++;
|
|
1148
|
+
while (j < raw.length && raw.charCodeAt(j) !== cc) {
|
|
1149
|
+
if (raw.charCodeAt(j) === 92) j++;
|
|
1150
|
+
j++;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
j++;
|
|
1154
|
+
}
|
|
1155
|
+
if (depth > 0) {
|
|
1156
|
+
this.fail("Unterminated interpolation", baseOffset + k);
|
|
1157
|
+
}
|
|
1158
|
+
const inner = raw.slice(k + 2, j - 1);
|
|
1159
|
+
const innerOffset = baseOffset + k + 2;
|
|
1160
|
+
const sub = tokenize(inner);
|
|
1161
|
+
for (const tk of sub.tokens) {
|
|
1162
|
+
tk.start += innerOffset;
|
|
1163
|
+
tk.end += innerOffset;
|
|
1164
|
+
}
|
|
1165
|
+
const subParser = new Parser(this.source, sub.tokens, []);
|
|
1166
|
+
const expression = subParser.parseValue();
|
|
1167
|
+
if (!subParser.atEnd()) {
|
|
1168
|
+
subParser.fail("Unexpected trailing input in interpolation");
|
|
1169
|
+
}
|
|
1170
|
+
parts.push({
|
|
1171
|
+
type: "interpolation",
|
|
1172
|
+
expression,
|
|
1173
|
+
start: baseOffset + k,
|
|
1174
|
+
end: baseOffset + j,
|
|
1175
|
+
});
|
|
1176
|
+
k = j - 1;
|
|
1177
|
+
lit = j;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (lit < raw.length) parts.push(raw.slice(lit));
|
|
1181
|
+
return parts;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/*
|
|
1185
|
+
* -------------------------------------------------------------------------
|
|
1186
|
+
* At-rules
|
|
1187
|
+
* -------------------------------------------------------------------------
|
|
1188
|
+
*/
|
|
1189
|
+
|
|
1190
|
+
private parseAtRule(): AtRule {
|
|
1191
|
+
const at = this.next(); // "at" token
|
|
1192
|
+
if (at.value === "off") {
|
|
1193
|
+
this.fail(
|
|
1194
|
+
'@off is not supported: gate the @on listener with options such as (target: "…") / (key: "…"), or with event data inside its block',
|
|
1195
|
+
at.start
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
if (!(at.value in AT_RULE_PARSERS)) {
|
|
1199
|
+
this.fail(`@${at.value} is not a Quark at-rule`, at.start);
|
|
1200
|
+
}
|
|
1201
|
+
return AT_RULE_PARSERS[at.value as QuarkAtRuleName](this, at);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/** `@warn` / `@debug` / `@error`: one value, then `;`. */
|
|
1205
|
+
parseValueAtRule(at: Token): ValueAtRule {
|
|
1206
|
+
const value = this.parseValue();
|
|
1207
|
+
if (this.isPunct(";")) this.next();
|
|
1208
|
+
return {
|
|
1209
|
+
type: "atrule",
|
|
1210
|
+
name: at.value as ValueAtRule["name"],
|
|
1211
|
+
value,
|
|
1212
|
+
start: at.start,
|
|
1213
|
+
end: this.lastEnd,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** `@use "url" [as name | as *];`: JS modules only, never configured. */
|
|
1218
|
+
parseUseRule(at: Token): UseRule {
|
|
1219
|
+
const url = this.expectString().value;
|
|
1220
|
+
let namespace: string | null = null;
|
|
1221
|
+
if (this.isIdent("as")) {
|
|
1222
|
+
this.next();
|
|
1223
|
+
if (this.isPunct("*")) {
|
|
1224
|
+
this.next();
|
|
1225
|
+
namespace = "*";
|
|
1226
|
+
} else {
|
|
1227
|
+
namespace = this.expectIdent().value;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (this.isIdent("with")) {
|
|
1231
|
+
this.fail("@use does not take a with clause", this.peek()!.start);
|
|
1232
|
+
}
|
|
1233
|
+
if (this.isPunct(";")) this.next();
|
|
1234
|
+
return {
|
|
1235
|
+
type: "atrule",
|
|
1236
|
+
name: "use",
|
|
1237
|
+
url,
|
|
1238
|
+
namespace,
|
|
1239
|
+
start: at.start,
|
|
1240
|
+
end: this.lastEnd,
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** `@scope { … }`: no prelude — CSS's `(from) to (limit)` is not Quark. */
|
|
1245
|
+
parseScopeRule(at: Token): ScopeRule {
|
|
1246
|
+
if (!this.isPunct("{")) {
|
|
1247
|
+
this.fail(
|
|
1248
|
+
"@scope does not take a prelude: @scope { … }",
|
|
1249
|
+
this.peek()?.start ?? at.start
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
const block = this.parseBlock();
|
|
1253
|
+
return {
|
|
1254
|
+
type: "atrule",
|
|
1255
|
+
name: "scope",
|
|
1256
|
+
block,
|
|
1257
|
+
start: at.start,
|
|
1258
|
+
end: block.end,
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* `<name> { "," <name> }` after `@on` / `@dispatch` / `@command`: each
|
|
1264
|
+
* name is a bare identifier (`click`, `--refresh`) or a string. At
|
|
1265
|
+
* least one.
|
|
1266
|
+
*/
|
|
1267
|
+
private parseNameList(atRule: string): EventName[] {
|
|
1268
|
+
const names: EventName[] = [];
|
|
1269
|
+
for (;;) {
|
|
1270
|
+
const token = this.peek();
|
|
1271
|
+
if (!token || (token.type !== "ident" && token.type !== "string")) {
|
|
1272
|
+
this.fail(
|
|
1273
|
+
names.length
|
|
1274
|
+
? `Expected a name after "," in @${atRule}`
|
|
1275
|
+
: `Expected an event name after @${atRule}`,
|
|
1276
|
+
token?.start
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
this.next();
|
|
1280
|
+
names.push({
|
|
1281
|
+
type: "event_name",
|
|
1282
|
+
name: token.value,
|
|
1283
|
+
quoted: token.type === "string",
|
|
1284
|
+
start: token.start,
|
|
1285
|
+
end: token.end,
|
|
1286
|
+
});
|
|
1287
|
+
if (!this.isPunct(",")) break;
|
|
1288
|
+
this.next();
|
|
1289
|
+
}
|
|
1290
|
+
return names;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* `@on click, submit (debounce: 300, handle: save) { … }`: a comma list
|
|
1295
|
+
* of event names (idents or strings), an optional options group, then a
|
|
1296
|
+
* block parsed as a plain rule body or `;`. Handlers live in the
|
|
1297
|
+
* options group (`handle:`); a bare expression after the events is the
|
|
1298
|
+
* removed handler-list form and fails with guidance.
|
|
1299
|
+
*/
|
|
1300
|
+
parseListenerRule(at: Token): ListenerRule {
|
|
1301
|
+
const name = "on";
|
|
1302
|
+
const events = this.parseNameList(name);
|
|
1303
|
+
const options = this.isPunct("(") ? this.parseListenerOptions(name) : [];
|
|
1304
|
+
let block: Block | null = null;
|
|
1305
|
+
if (this.isPunct("{")) {
|
|
1306
|
+
block = this.parseBlock();
|
|
1307
|
+
} else if (this.isPunct(";")) {
|
|
1308
|
+
this.next();
|
|
1309
|
+
} else if (!this.atEnd() && !this.isPunct("}")) {
|
|
1310
|
+
this.fail(
|
|
1311
|
+
`Unexpected token after @${name} ${events.map((e) => e.name).join(", ")}: handlers go in the options group — @${name} ${events[0].name} (handle: myFn); or @${name} ${events[0].name} (handle: (a, b));`,
|
|
1312
|
+
this.peek()?.start
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
if (!block && !options.length) {
|
|
1316
|
+
this.fail(
|
|
1317
|
+
`@${name} ${events.map((e) => e.name).join(", ")} has nothing to do: add an options group such as (handle: myFn) or (prevent-default), or a block`,
|
|
1318
|
+
at.start
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
return {
|
|
1322
|
+
type: "atrule",
|
|
1323
|
+
name,
|
|
1324
|
+
events,
|
|
1325
|
+
options,
|
|
1326
|
+
block,
|
|
1327
|
+
start: at.start,
|
|
1328
|
+
end: block ? block.end : this.lastEnd,
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* `@dispatch cart-add (detail: (sku: $sku), target: "cart-view");` /
|
|
1334
|
+
* `@command --refresh (target: "#feed");`: a comma list of names, an
|
|
1335
|
+
* optional options group, then `;`. A block is a parse error.
|
|
1336
|
+
*/
|
|
1337
|
+
parseActionRule(at: Token): ActionRule {
|
|
1338
|
+
const name = at.value as ActionRule["name"];
|
|
1339
|
+
const names = this.parseNameList(name);
|
|
1340
|
+
const options = this.isPunct("(") ? this.parseListenerOptions(name) : [];
|
|
1341
|
+
if (this.isPunct("{")) {
|
|
1342
|
+
this.fail(
|
|
1343
|
+
`@${name} is a statement: @${name} ${names[0].name} (options);`,
|
|
1344
|
+
this.peek()?.start
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
if (this.isPunct(";")) {
|
|
1348
|
+
this.next();
|
|
1349
|
+
} else if (!this.atEnd() && !this.isPunct("}")) {
|
|
1350
|
+
this.fail(
|
|
1351
|
+
`Expected "(" or ";" after @${name} ${names.map((e) => e.name).join(", ")}`,
|
|
1352
|
+
this.peek()?.start
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
return {
|
|
1356
|
+
type: "atrule",
|
|
1357
|
+
name,
|
|
1358
|
+
names,
|
|
1359
|
+
options,
|
|
1360
|
+
start: at.start,
|
|
1361
|
+
end: this.lastEnd,
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* `@view-transition (types: "todo", timeout: 500) { … }`: an optional
|
|
1367
|
+
* options group (same grammar as `@on`'s), then a required block parsed
|
|
1368
|
+
* as a plain rule body.
|
|
1369
|
+
*/
|
|
1370
|
+
parseTransitionRule(at: Token): TransitionRule {
|
|
1371
|
+
const name = "view-transition";
|
|
1372
|
+
const options = this.isPunct("(") ? this.parseListenerOptions(name) : [];
|
|
1373
|
+
if (!this.isPunct("{")) {
|
|
1374
|
+
this.fail(
|
|
1375
|
+
`@${name} needs a block: @${name} (options) { … }`,
|
|
1376
|
+
this.peek()?.start ?? at.start
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
const block = this.parseBlock();
|
|
1380
|
+
return {
|
|
1381
|
+
type: "atrule",
|
|
1382
|
+
name,
|
|
1383
|
+
options,
|
|
1384
|
+
block,
|
|
1385
|
+
start: at.start,
|
|
1386
|
+
end: block.end,
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* `@delay 2000 { … }` / `@delay $ms * 2 { … }`: one duration expression
|
|
1392
|
+
* (a value, so a comma list parses too — the runtime rejects it), then a
|
|
1393
|
+
* required block parsed as an ordinary rule body.
|
|
1394
|
+
*/
|
|
1395
|
+
parseDelayRule(at: Token): DelayRule {
|
|
1396
|
+
const name = "delay";
|
|
1397
|
+
// a duration may open with a unary sign (`+attr("data-ms") or 1500`)
|
|
1398
|
+
if (
|
|
1399
|
+
!this.canStartExpression() &&
|
|
1400
|
+
!this.isPunct("+") &&
|
|
1401
|
+
!this.isPunct("-")
|
|
1402
|
+
) {
|
|
1403
|
+
this.fail(
|
|
1404
|
+
`Expected a duration after @${name}`,
|
|
1405
|
+
this.peek()?.start ?? at.start
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
const duration = this.parseValue();
|
|
1409
|
+
if (!this.isPunct("{")) {
|
|
1410
|
+
this.fail(
|
|
1411
|
+
`@${name} needs a block: @${name} <ms> { … }`,
|
|
1412
|
+
this.peek()?.start ?? at.start
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
const block = this.parseBlock();
|
|
1416
|
+
return {
|
|
1417
|
+
type: "atrule",
|
|
1418
|
+
name,
|
|
1419
|
+
duration,
|
|
1420
|
+
block,
|
|
1421
|
+
start: at.start,
|
|
1422
|
+
end: block.end,
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* `( option { "," option } )` after `@on` / `@dispatch` / `@command`
|
|
1428
|
+
* names or `@view-transition`:
|
|
1429
|
+
* each option is an ident, optionally followed by `:` and one space-list
|
|
1430
|
+
* value (commas separate options, so a value never spans a comma).
|
|
1431
|
+
* Duplicate names are a parse error; unknown names are the runtime's
|
|
1432
|
+
* business.
|
|
1433
|
+
*/
|
|
1434
|
+
private parseListenerOptions(atRule = "on"): ListenerOption[] {
|
|
1435
|
+
const open = this.expectPunct("(");
|
|
1436
|
+
const options: ListenerOption[] = [];
|
|
1437
|
+
const seen = new Set<string>();
|
|
1438
|
+
while (!this.isPunct(")")) {
|
|
1439
|
+
const nameToken = this.peek();
|
|
1440
|
+
if (!nameToken || nameToken.type !== "ident") {
|
|
1441
|
+
this.fail(
|
|
1442
|
+
`Expected an option name inside @${atRule} ( … )`,
|
|
1443
|
+
nameToken?.start ?? open.start
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
this.next();
|
|
1447
|
+
if (seen.has(nameToken.value)) {
|
|
1448
|
+
this.fail(
|
|
1449
|
+
`Duplicate @${atRule} option "${nameToken.value}"`,
|
|
1450
|
+
nameToken.start
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
seen.add(nameToken.value);
|
|
1454
|
+
let value: Expression | null = null;
|
|
1455
|
+
if (this.isPunct(":")) {
|
|
1456
|
+
this.next();
|
|
1457
|
+
if (!this.canStartExpression()) {
|
|
1458
|
+
this.fail(
|
|
1459
|
+
`Expected a value after @${atRule} option "${nameToken.value}:"`
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
value = this.parseSpaceList();
|
|
1463
|
+
}
|
|
1464
|
+
options.push({
|
|
1465
|
+
type: "listener_option",
|
|
1466
|
+
name: nameToken.value,
|
|
1467
|
+
value,
|
|
1468
|
+
start: nameToken.start,
|
|
1469
|
+
end: this.lastEnd,
|
|
1470
|
+
});
|
|
1471
|
+
if (this.isPunct(",")) {
|
|
1472
|
+
this.next();
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
if (!this.isPunct(")")) {
|
|
1476
|
+
this.fail(`Expected "," or ")" in @${atRule} ( … )`);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
this.expectPunct(")");
|
|
1480
|
+
return options;
|
|
1481
|
+
}
|
|
1482
|
+
}
|