@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/tables.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Grammar tables, the data-driven parts of the Quark grammar, kept in one
3
+ * dependency-free module so the parser, the formatter, tooling, and the
4
+ * language reference (`support/docs/README.md`, locked by tests) read the
5
+ * same values.
6
+ */
7
+
8
+ /**
9
+ * Binary operator binding powers (Pratt parser). Higher binds tighter; all
10
+ * operators are left-associative. `not` is unary and sits at `NOT_BP`.
11
+ */
12
+ export const BINARY_BP: Readonly<Record<string, number>> = {
13
+ or: 1,
14
+ and: 2,
15
+ "==": 4,
16
+ "!=": 4,
17
+ "<": 5,
18
+ ">": 5,
19
+ "<=": 5,
20
+ ">=": 5,
21
+ "+": 6,
22
+ "-": 6,
23
+ "*": 7,
24
+ "/": 7,
25
+ "%": 7,
26
+ };
27
+
28
+ /** Binding power of the unary `not` operator (between `and` and `==`). */
29
+ export const NOT_BP = 3;
30
+
31
+ /** Attribute selector operators: `[name<op>value]`. */
32
+ export const ATTR_OPERATORS: ReadonlySet<string> = new Set([
33
+ "=",
34
+ "*=",
35
+ "^=",
36
+ "$=",
37
+ "|=",
38
+ "~=",
39
+ ]);
40
+
41
+ /**
42
+ * Pseudo-classes whose argument parses as a selector list; every other
43
+ * pseudo-class argument is kept as raw text (`:nth-child(2n+1)`).
44
+ */
45
+ export const SELECTOR_PSEUDOS: ReadonlySet<string> = new Set([
46
+ "not",
47
+ "is",
48
+ "where",
49
+ "has",
50
+ "matches",
51
+ "any",
52
+ "-webkit-any",
53
+ "-moz-any",
54
+ "host",
55
+ "host-context",
56
+ "current",
57
+ ]);
58
+
59
+ /**
60
+ * Quark's at-rules — the whole set. Each has a dedicated AST node and a
61
+ * parse method; any other name (`@media`, `@if`, `@keyframes`, …) is a
62
+ * parse error, since Quark is a derivative of CSS, not a superset.
63
+ */
64
+ export const QUARK_AT_RULES = [
65
+ "use",
66
+ "scope",
67
+ "on",
68
+ "dispatch",
69
+ "command",
70
+ "view-transition",
71
+ "delay",
72
+ "warn",
73
+ "debug",
74
+ "error",
75
+ ] as const;
76
+
77
+ export type QuarkAtRuleName = (typeof QUARK_AT_RULES)[number];
@@ -0,0 +1,443 @@
1
+ import { QuarkParseError } from "./error";
2
+ import type { Token, TokenizeResult } from "./types";
3
+
4
+ // Character codes used by the scanner.
5
+ const TAB = 9;
6
+ const LF = 10;
7
+ const FF = 12;
8
+ const CR = 13;
9
+ const SPACE = 32;
10
+ const BANG = 33; // !
11
+ const DQUOTE = 34; // "
12
+ const HASH = 35; // #
13
+ const DOLLAR = 36; // $
14
+ const PERCENT = 37; // %
15
+ const SQUOTE = 39; // '
16
+ const LPAREN = 40; // (
17
+ const RPAREN = 41; // )
18
+ const STAR = 42; // *
19
+ const PLUS = 43; // +
20
+ const MINUS = 45; // -
21
+ const DOT = 46; // .
22
+ const SLASH = 47; // /
23
+ const COLON = 58; // :
24
+ const LT = 60; // <
25
+ const EQ = 61; // =
26
+ const GT = 62; // >
27
+ const AT = 64; // @
28
+ const BACKSLASH = 92; // \
29
+ const CARET = 94; // ^
30
+ const UNDERSCORE = 95; // _
31
+ const LBRACE = 123; // {
32
+ const PIPE = 124; // |
33
+ const RBRACE = 125; // }
34
+ const TILDE = 126; // ~
35
+
36
+ const isWs = (c: number): boolean =>
37
+ c === SPACE || c === TAB || c === LF || c === CR || c === FF;
38
+ const isDigit = (c: number): boolean => c >= 48 && c <= 57;
39
+ const isIdentStart = (c: number): boolean =>
40
+ (c >= 97 && c <= 122) ||
41
+ (c >= 65 && c <= 90) ||
42
+ c === UNDERSCORE ||
43
+ c === BACKSLASH ||
44
+ c >= 0x80;
45
+ const isIdentChar = (c: number): boolean =>
46
+ isIdentStart(c) || isDigit(c) || c === MINUS;
47
+
48
+ /**
49
+ * Single-pass tokenizer. Whitespace is not tokenized; each token carries a
50
+ * `ws` flag indicating whether whitespace/comments preceded it. Comments are
51
+ * returned separately so expression parsing never has to skip them.
52
+ */
53
+ export function tokenize(source: string): TokenizeResult {
54
+ const tokens: Token[] = [];
55
+ const comments: Token[] = [];
56
+ const len = source.length;
57
+ let i = 0;
58
+ let ws = false;
59
+
60
+ const err = (message: string, at: number): never => {
61
+ throw new QuarkParseError(message, source, at);
62
+ };
63
+
64
+ const push = (
65
+ type: Token["type"],
66
+ value: string,
67
+ start: number,
68
+ end: number,
69
+ unit?: string,
70
+ quote?: '"' | "'"
71
+ ): void => {
72
+ const token: Token = { type, value, start, end, ws };
73
+ if (unit !== undefined) token.unit = unit;
74
+ if (quote !== undefined) token.quote = quote;
75
+ tokens.push(token);
76
+ ws = false;
77
+ };
78
+
79
+ /** True when the previous token can end a value (for `-`/`+` handling). */
80
+ const prevValueLike = (): boolean => {
81
+ const t = tokens[tokens.length - 1];
82
+ if (!t) return false;
83
+ if (t.type === "punct") return t.value === ")" || t.value === "]";
84
+ return t.type !== "at";
85
+ };
86
+
87
+ /** Skips a balanced `#{...}`; `idx` points just after the `#{`. */
88
+ const skipInterpolation = (idx: number): number => {
89
+ let depth = 1;
90
+ while (idx < len && depth > 0) {
91
+ const c = source.charCodeAt(idx);
92
+ if (c === LBRACE) depth++;
93
+ else if (c === RBRACE) depth--;
94
+ else if (c === DQUOTE || c === SQUOTE) {
95
+ idx++;
96
+ while (idx < len && source.charCodeAt(idx) !== c) {
97
+ if (source.charCodeAt(idx) === BACKSLASH) idx++;
98
+ idx++;
99
+ }
100
+ }
101
+ idx++;
102
+ }
103
+ return idx;
104
+ };
105
+
106
+ const scanIdent = (start: number): string => {
107
+ let j = start;
108
+ while (j < len) {
109
+ const c = source.charCodeAt(j);
110
+ if (c === BACKSLASH) {
111
+ j += 2;
112
+ continue;
113
+ }
114
+ if (!isIdentChar(c)) break;
115
+ j++;
116
+ }
117
+ i = j;
118
+ return source.slice(start, j);
119
+ };
120
+
121
+ const scanString = (quote: number): void => {
122
+ const start = i;
123
+ i++;
124
+ while (i < len) {
125
+ const c = source.charCodeAt(i);
126
+ if (c === quote) break;
127
+ if (c === BACKSLASH) {
128
+ i += 2;
129
+ continue;
130
+ }
131
+ if (c === HASH && source.charCodeAt(i + 1) === LBRACE) {
132
+ i = skipInterpolation(i + 2);
133
+ continue;
134
+ }
135
+ i++;
136
+ }
137
+ if (i >= len) err("Unterminated string", start);
138
+ push(
139
+ "string",
140
+ source.slice(start + 1, i),
141
+ start,
142
+ i + 1,
143
+ undefined,
144
+ quote === DQUOTE ? '"' : "'"
145
+ );
146
+ i++;
147
+ };
148
+
149
+ /** `start` may point at a sign, a leading dot, or a digit. */
150
+ const scanNumber = (start: number): void => {
151
+ let j = start;
152
+ const first = source.charCodeAt(j);
153
+ if (first === MINUS || first === PLUS) j++;
154
+ while (isDigit(source.charCodeAt(j))) j++;
155
+ if (source.charCodeAt(j) === DOT && isDigit(source.charCodeAt(j + 1))) {
156
+ j++;
157
+ while (isDigit(source.charCodeAt(j))) j++;
158
+ }
159
+ // Exponent (`2e3`, `2e-3`), but not units that start with `e` (`2em`).
160
+ const e = source.charCodeAt(j);
161
+ if (e === 101 || e === 69) {
162
+ let k = j + 1;
163
+ const s = source.charCodeAt(k);
164
+ if (s === MINUS || s === PLUS) k++;
165
+ if (isDigit(source.charCodeAt(k))) {
166
+ k++;
167
+ while (isDigit(source.charCodeAt(k))) k++;
168
+ j = k;
169
+ }
170
+ }
171
+ const numEnd = j;
172
+ let unit: string | undefined;
173
+ if (source.charCodeAt(j) === PERCENT) {
174
+ unit = "%";
175
+ j++;
176
+ } else if (isIdentStart(source.charCodeAt(j))) {
177
+ const u = j;
178
+ while (j < len && isIdentChar(source.charCodeAt(j))) j++;
179
+ unit = source.slice(u, j);
180
+ }
181
+ push("number", source.slice(start, numEnd), start, j, unit);
182
+ i = j;
183
+ };
184
+
185
+ /**
186
+ * Attempts to scan the contents of an unquoted `url(...)`. Returns `null`
187
+ * (without emitting) when the contents look like a normal expression
188
+ * (quoted string, variable, ...), in which case regular tokenization
189
+ * continues from the `(`.
190
+ */
191
+ const tryScanRawUrl = (): {
192
+ rawStart: number;
193
+ rawEnd: number;
194
+ endAfterParen: number;
195
+ } | null => {
196
+ let j = i + 1; // after "("
197
+ while (j < len && isWs(source.charCodeAt(j))) j++;
198
+ const q = source.charCodeAt(j);
199
+ if (q === DQUOTE || q === SQUOTE || q === DOLLAR || q === RPAREN) {
200
+ return null;
201
+ }
202
+ const rawStart = j;
203
+ let lastNonWs = j - 1;
204
+ let sawWs = false;
205
+ while (j < len) {
206
+ const c = source.charCodeAt(j);
207
+ if (c === RPAREN) break;
208
+ if (c === BACKSLASH) {
209
+ j += 2;
210
+ lastNonWs = j - 1;
211
+ continue;
212
+ }
213
+ if (c === HASH && source.charCodeAt(j + 1) === LBRACE) {
214
+ j = skipInterpolation(j + 2);
215
+ lastNonWs = j - 1;
216
+ continue;
217
+ }
218
+ if (c === DQUOTE || c === SQUOTE || c === LPAREN || c === DOLLAR) {
219
+ return null;
220
+ }
221
+ if (isWs(c)) {
222
+ sawWs = true;
223
+ } else {
224
+ if (sawWs) return null; // internal whitespace: not a raw url
225
+ lastNonWs = j;
226
+ }
227
+ j++;
228
+ }
229
+ if (j >= len || lastNonWs < rawStart) return null;
230
+ return { rawStart, rawEnd: lastNonWs + 1, endAfterParen: j + 1 };
231
+ };
232
+
233
+ while (i < len) {
234
+ const c = source.charCodeAt(i);
235
+
236
+ if (isWs(c)) {
237
+ i++;
238
+ ws = true;
239
+ continue;
240
+ }
241
+
242
+ // `/* */` comments and the `/` operator.
243
+ if (c === SLASH) {
244
+ const n = source.charCodeAt(i + 1);
245
+ if (n === SLASH) err("Line comments are not supported, use /* */", i);
246
+ if (n === STAR) {
247
+ const start = i;
248
+ i += 2;
249
+ while (
250
+ i < len &&
251
+ !(source.charCodeAt(i) === STAR && source.charCodeAt(i + 1) === SLASH)
252
+ ) {
253
+ i++;
254
+ }
255
+ if (i >= len) err("Unterminated comment", start);
256
+ comments.push({
257
+ type: "comment",
258
+ value: source.slice(start + 2, i),
259
+ start,
260
+ end: i + 2,
261
+ ws,
262
+ });
263
+ i += 2;
264
+ ws = true;
265
+ continue;
266
+ }
267
+ push("punct", "/", i, i + 1);
268
+ i++;
269
+ continue;
270
+ }
271
+
272
+ if (c === DQUOTE || c === SQUOTE) {
273
+ scanString(c);
274
+ continue;
275
+ }
276
+
277
+ if (isDigit(c)) {
278
+ scanNumber(i);
279
+ continue;
280
+ }
281
+
282
+ if (c === DOT) {
283
+ if (isDigit(source.charCodeAt(i + 1))) {
284
+ scanNumber(i);
285
+ continue;
286
+ }
287
+ if (
288
+ source.charCodeAt(i + 1) === DOT &&
289
+ source.charCodeAt(i + 2) === DOT
290
+ ) {
291
+ push("punct", "...", i, i + 3);
292
+ i += 3;
293
+ continue;
294
+ }
295
+ push("punct", ".", i, i + 1);
296
+ i++;
297
+ continue;
298
+ }
299
+
300
+ if (c === MINUS || c === PLUS) {
301
+ const n = source.charCodeAt(i + 1);
302
+ const startsNumber =
303
+ isDigit(n) || (n === DOT && isDigit(source.charCodeAt(i + 2)));
304
+ /*
305
+ * A sign when at an expression start, or when in the `10px -5px`
306
+ * shape (whitespace before, none after). `10-5` and `10 - 5` stay
307
+ * subtraction.
308
+ */
309
+ const isSign = !prevValueLike() || (ws && !isWs(n));
310
+ if (startsNumber && isSign) {
311
+ scanNumber(i);
312
+ continue;
313
+ }
314
+ if (c === MINUS && (isIdentStart(n) || n === MINUS) && isSign) {
315
+ const start = i;
316
+ i++; // include the '-' via slice below
317
+ scanIdent(i);
318
+ push("ident", source.slice(start, i), start, i);
319
+ continue;
320
+ }
321
+ push("punct", c === MINUS ? "-" : "+", i, i + 1);
322
+ i++;
323
+ continue;
324
+ }
325
+
326
+ if (c === DOLLAR) {
327
+ const n = source.charCodeAt(i + 1);
328
+ if (isIdentStart(n) || isDigit(n)) {
329
+ const start = i;
330
+ const value = scanIdent(i + 1);
331
+ push("variable", value, start, i);
332
+ continue;
333
+ }
334
+ if (n === EQ) {
335
+ push("punct", "$=", i, i + 2);
336
+ i += 2;
337
+ continue;
338
+ }
339
+ push("punct", "$", i, i + 1);
340
+ i++;
341
+ continue;
342
+ }
343
+
344
+ if (c === AT) {
345
+ const n = source.charCodeAt(i + 1);
346
+ if (isIdentStart(n) || n === MINUS) {
347
+ const start = i;
348
+ const value = scanIdent(i + 1);
349
+ push("at", value, start, i);
350
+ continue;
351
+ }
352
+ push("punct", "@", i, i + 1);
353
+ i++;
354
+ continue;
355
+ }
356
+
357
+ if (c === HASH) {
358
+ const n = source.charCodeAt(i + 1);
359
+ if (n === LBRACE) {
360
+ push("punct", "#{", i, i + 2);
361
+ i += 2;
362
+ continue;
363
+ }
364
+ if (isIdentChar(n)) {
365
+ const start = i;
366
+ let j = i + 1;
367
+ while (j < len && isIdentChar(source.charCodeAt(j))) j++;
368
+ push("hash", source.slice(start + 1, j), start, j);
369
+ i = j;
370
+ continue;
371
+ }
372
+ push("punct", "#", i, i + 1);
373
+ i++;
374
+ continue;
375
+ }
376
+
377
+ if (isIdentStart(c)) {
378
+ const start = i;
379
+ const value = scanIdent(start);
380
+ if (
381
+ source.charCodeAt(i) === LPAREN &&
382
+ (value === "url" || value === "URL" || value === "Url")
383
+ ) {
384
+ const parenAt = i;
385
+ const url = tryScanRawUrl();
386
+ if (url) {
387
+ push("ident", value, start, parenAt);
388
+ push("punct", "(", parenAt, parenAt + 1);
389
+ push(
390
+ "url",
391
+ source.slice(url.rawStart, url.rawEnd),
392
+ url.rawStart,
393
+ url.rawEnd
394
+ );
395
+ push("punct", ")", url.endAfterParen - 1, url.endAfterParen);
396
+ i = url.endAfterParen;
397
+ continue;
398
+ }
399
+ }
400
+ push("ident", value, start, i);
401
+ continue;
402
+ }
403
+
404
+ // Multi-character and single-character punctuation.
405
+ const n = source.charCodeAt(i + 1);
406
+ let punct: string | null = null;
407
+ switch (c) {
408
+ case EQ:
409
+ punct = n === EQ ? "==" : "=";
410
+ break;
411
+ case BANG:
412
+ punct = n === EQ ? "!=" : "!";
413
+ break;
414
+ case LT:
415
+ punct = n === EQ ? "<=" : "<";
416
+ break;
417
+ case GT:
418
+ punct = n === EQ ? ">=" : ">";
419
+ break;
420
+ case COLON:
421
+ punct = n === COLON ? "::" : ":";
422
+ break;
423
+ case STAR:
424
+ punct = n === EQ ? "*=" : "*";
425
+ break;
426
+ case TILDE:
427
+ punct = n === EQ ? "~=" : "~";
428
+ break;
429
+ case CARET:
430
+ punct = n === EQ ? "^=" : "^";
431
+ break;
432
+ case PIPE:
433
+ punct = n === EQ ? "|=" : "|";
434
+ break;
435
+ default:
436
+ punct = source[i];
437
+ }
438
+ push("punct", punct, i, i + punct.length);
439
+ i += punct.length;
440
+ }
441
+
442
+ return { tokens, comments };
443
+ }