@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.
Files changed (32) hide show
  1. package/.rush/temp/chunked-rush-logs/quark-parser.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/quark-parser.build_package-metas.chunks.jsonl +1 -0
  3. package/.rush/temp/operation/apply-exports/all.log +1 -0
  4. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  5. package/.rush/temp/operation/apply-exports/state.json +3 -0
  6. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  7. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  8. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +3 -0
  10. package/config/rig.json +5 -0
  11. package/index.ts +12 -0
  12. package/package.json +39 -0
  13. package/rush-logs/quark-parser.apply-exports.cache.log +1 -0
  14. package/rush-logs/quark-parser.apply-exports.log +1 -0
  15. package/rush-logs/quark-parser.build_package-metas.cache.log +1 -0
  16. package/rush-logs/quark-parser.build_package-metas.log +1 -0
  17. package/src/error.ts +24 -0
  18. package/src/parser.ts +1482 -0
  19. package/src/tables.ts +77 -0
  20. package/src/tokenizer.ts +443 -0
  21. package/src/types.ts +497 -0
  22. package/support/docs/README.md +443 -0
  23. package/support/package-meta.json +33 -0
  24. package/support/tests/grammar-docs.test.ts +109 -0
  25. package/support/tests/parser-at-rules.test.ts +430 -0
  26. package/support/tests/parser-declarations.test.ts +152 -0
  27. package/support/tests/parser-edge-cases.test.ts +296 -0
  28. package/support/tests/parser-expressions.test.ts +413 -0
  29. package/support/tests/parser-real-world.test.ts +429 -0
  30. package/support/tests/parser-selectors.test.ts +169 -0
  31. package/support/tests/tokenizer.test.ts +268 -0
  32. 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
+ }