@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
@@ -0,0 +1,413 @@
1
+ import { parseExpression, QuarkParseError, parse } from "../../index";
2
+ import {
3
+ describe,
4
+ expect,
5
+ it,
6
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
7
+
8
+ const expr = (src: string): any => parseExpression(src);
9
+
10
+ describe("expressions", () => {
11
+ describe("literals", () => {
12
+ it("parses numbers with units", () => {
13
+ expect(expr("13")).toMatchObject({ type: "number", value: 13, unit: null });
14
+ expect(expr("32.125px")).toMatchObject({ value: 32.125, unit: "px" });
15
+ expect(expr("50%")).toMatchObject({ value: 50, unit: "%" });
16
+ expect(expr("-27.835")).toMatchObject({ value: -27.835 });
17
+ });
18
+
19
+ it("parses strings, booleans, null, colors, identifiers", () => {
20
+ expect(expr('"hello"')).toMatchObject({ type: "string", value: "hello" });
21
+ expect(expr("true")).toMatchObject({ type: "boolean", value: true });
22
+ expect(expr("false")).toMatchObject({ type: "boolean", value: false });
23
+ expect(expr("null")).toMatchObject({ type: "null" });
24
+ expect(expr("#0af")).toMatchObject({ type: "color", value: "#0af" });
25
+ expect(expr("solid")).toMatchObject({ type: "identifier", name: "solid" });
26
+ });
27
+
28
+ it("parses string interpolation", () => {
29
+ const s = expr('"ID is #{$todo.id}!"');
30
+ expect(s.value).toBeNull();
31
+ expect(s.parts[0]).toBe("ID is ");
32
+ expect(s.parts[1].type).toBe("interpolation");
33
+ expect(s.parts[1].expression).toMatchObject({
34
+ type: "member",
35
+ property: "id",
36
+ });
37
+ expect(s.parts[2]).toBe("!");
38
+ });
39
+
40
+ it("parses unquoted and quoted urls", () => {
41
+ expect(expr("url(/img/kroger.png)")).toMatchObject({
42
+ type: "url",
43
+ parts: ["/img/kroger.png"],
44
+ });
45
+ const quoted = expr('url("a.png")');
46
+ expect(quoted.type).toBe("function");
47
+ expect(quoted.callee.name).toBe("url");
48
+ });
49
+ });
50
+
51
+ describe("accessors (Quark deviations)", () => {
52
+ it("parses dot accessor chains", () => {
53
+ const e = expr("item.coordinate.lng");
54
+ expect(e).toMatchObject({
55
+ type: "member",
56
+ property: "lng",
57
+ object: {
58
+ type: "member",
59
+ property: "coordinate",
60
+ object: { type: "identifier", name: "item" },
61
+ },
62
+ });
63
+ });
64
+
65
+ it("parses dot access on variables and calls", () => {
66
+ expect(expr("$packageMeta.elementApis")).toMatchObject({
67
+ type: "member",
68
+ property: "elementApis",
69
+ object: { type: "variable", name: "packageMeta" },
70
+ });
71
+ expect(expr("prop(\"provision\").body")).toMatchObject({
72
+ type: "member",
73
+ property: "body",
74
+ object: { type: "function" },
75
+ });
76
+ });
77
+
78
+ it("parses namespaced variables (math.$pi)", () => {
79
+ expect(expr("math.$pi")).toMatchObject({
80
+ type: "member",
81
+ property: "pi",
82
+ variable: true,
83
+ });
84
+ });
85
+
86
+ it("parses bracket accessors", () => {
87
+ expect(expr('$myObj["myField"]')).toMatchObject({
88
+ type: "index",
89
+ object: { type: "variable", name: "myObj" },
90
+ index: { type: "string", value: "myField" },
91
+ });
92
+ expect(expr("$selectedTags[$tagIndex]").index).toMatchObject({
93
+ type: "variable",
94
+ name: "tagIndex",
95
+ });
96
+ expect(expr("prop(\"provision\").body.message[0]")).toMatchObject({
97
+ type: "index",
98
+ index: { type: "number", value: 0 },
99
+ });
100
+ });
101
+
102
+ it("parses method calls on members", () => {
103
+ const e = expr("item.status.toLowerCase()");
104
+ expect(e.type).toBe("function");
105
+ expect(e.callee).toMatchObject({
106
+ type: "member",
107
+ property: "toLowerCase",
108
+ });
109
+ expect(e.args).toEqual([]);
110
+ });
111
+
112
+ it("distinguishes index access from bracket lists by adjacency", () => {
113
+ expect(expr("$a[0]").type).toBe("index");
114
+ const list = expr("$a [0]");
115
+ expect(list.type).toBe("list");
116
+ expect(list.items[1]).toMatchObject({ type: "list", brackets: true });
117
+ });
118
+ });
119
+
120
+ describe("functions", () => {
121
+ it("parses calls with mixed arguments", () => {
122
+ const e = expr('iterate($items, ":scope > template", "id")');
123
+ expect(e.callee.name).toBe("iterate");
124
+ expect(e.args).toHaveLength(3);
125
+ expect(e.args[0].value).toMatchObject({ type: "variable", name: "items" });
126
+ expect(e.args[1].value.value).toBe(":scope > template");
127
+ });
128
+
129
+ it("parses & as an argument (fn(&))", () => {
130
+ expect(expr("fn(&)").args[0].value.type).toBe("parent_reference");
131
+ });
132
+
133
+ it("parses nested calls", () => {
134
+ const e = expr("reverse(prop(\"provision\"))");
135
+ expect(e.args[0].value.type).toBe("function");
136
+ });
137
+
138
+ it("parses named arguments and spreads", () => {
139
+ const named = expr("corner($radius: 3px, $style: solid)");
140
+ expect(named.args[0]).toMatchObject({ name: "radius", spread: false });
141
+ expect(named.args[0].value).toMatchObject({ unit: "px" });
142
+ expect(expr("apply($args...)").args[0].spread).toBe(true);
143
+ });
144
+
145
+ it("parses namespaced calls (math.div)", () => {
146
+ const e = expr("math.div($a, $b)");
147
+ expect(e.callee).toMatchObject({ type: "member", property: "div" });
148
+ });
149
+ });
150
+
151
+ describe("operators", () => {
152
+ it("applies arithmetic precedence", () => {
153
+ const e = expr("1 + 2 * 3");
154
+ expect(e).toMatchObject({
155
+ type: "binary",
156
+ operator: "+",
157
+ right: { type: "binary", operator: "*" },
158
+ });
159
+ });
160
+
161
+ it("parses string concatenation chains left-associatively", () => {
162
+ const e = expr('"Index: " + index + ". ID: " + item.id');
163
+ expect(e.operator).toBe("+");
164
+ expect(e.right).toMatchObject({ type: "member", property: "id" });
165
+ expect(e.left.operator).toBe("+");
166
+ });
167
+
168
+ it("parses comparisons and equality", () => {
169
+ expect(expr("$maxUsers == 1").operator).toBe("==");
170
+ expect(expr("$a != $b").operator).toBe("!=");
171
+ expect(expr("$i < 5").operator).toBe("<");
172
+ expect(expr("$i >= 5").operator).toBe(">=");
173
+ });
174
+
175
+ it("parses and/or/not with SCSS precedence", () => {
176
+ const e = expr("$a == 1 or $b == 2 and not $c");
177
+ expect(e.operator).toBe("or");
178
+ expect(e.right.operator).toBe("and");
179
+ expect(e.right.right).toMatchObject({ type: "unary", operator: "not" });
180
+ });
181
+
182
+ it("binds not looser than comparison", () => {
183
+ const e = expr("not $a == $b");
184
+ expect(e).toMatchObject({
185
+ type: "unary",
186
+ operator: "not",
187
+ argument: { type: "binary", operator: "==" },
188
+ });
189
+ });
190
+
191
+ it("parses unary minus on non-literals", () => {
192
+ expect(expr("-$offset")).toMatchObject({
193
+ type: "unary",
194
+ operator: "-",
195
+ argument: { type: "variable", name: "offset" },
196
+ });
197
+ });
198
+
199
+ it("parses division and modulo", () => {
200
+ expect(expr("(10 / 2)").operator).toBe("/");
201
+ expect(expr("$i % 3").operator).toBe("%");
202
+ });
203
+
204
+ it("respects parentheses for grouping", () => {
205
+ const e = expr("(1 + 2) * 3");
206
+ expect(e.operator).toBe("*");
207
+ expect(e.left.operator).toBe("+");
208
+ });
209
+ });
210
+
211
+ describe("lists and maps", () => {
212
+ it("parses space-separated lists", () => {
213
+ const e = expr("10px 20px 30px");
214
+ expect(e).toMatchObject({ type: "list", separator: " " });
215
+ expect(e.items).toHaveLength(3);
216
+ });
217
+
218
+ it("parses space lists with signed numbers", () => {
219
+ const e = expr("10px -5px");
220
+ expect(e.items.map((i: any) => i.value)).toEqual([10, -5]);
221
+ });
222
+
223
+ it("sign spacing decides list vs addition ($x +1 is a list, $x + 1 adds)", () => {
224
+ /* Whitespace before the sign but not after = a signed list item
225
+ * (CSS `margin: 10px -5px`). Easy to trip over with `$sig.value + 1`. */
226
+ expect(expr("$x +1")).toMatchObject({ type: "list", separator: " " });
227
+ expect(expr("$x + 1")).toMatchObject({ type: "binary", operator: "+" });
228
+ expect(expr("$x+1")).toMatchObject({ type: "binary", operator: "+" });
229
+ });
230
+
231
+ it("parses comma-separated lists", () => {
232
+ const e = expr("first, second");
233
+ expect(e).toMatchObject({ type: "list", separator: "," });
234
+ expect(e.items).toHaveLength(2);
235
+ });
236
+
237
+ it("widens grouping spans to include the parens", () => {
238
+ /* Consumers slice source by spans; `(1 + 2) * 3` must not become
239
+ * `1 + 2) * 3`. */
240
+ const src = "(1 + 2) * 3";
241
+ const e = expr(src);
242
+ expect(e).toMatchObject({ type: "binary", operator: "*" });
243
+ expect(src.slice(e.left.start, e.left.end)).toBe("(1 + 2)");
244
+ });
245
+
246
+ it("parses parenthesized and bracketed lists", () => {
247
+ expect(expr("(1, 2, 3)")).toMatchObject({
248
+ type: "list",
249
+ separator: ",",
250
+ parens: true,
251
+ });
252
+ expect(expr("[1, 2, 3]")).toMatchObject({
253
+ type: "list",
254
+ brackets: true,
255
+ });
256
+ expect(expr("()")).toMatchObject({ type: "list", items: [] });
257
+ });
258
+
259
+ it("parses maps", () => {
260
+ const e = expr('(key1: "value1", key2: 10px 20px)');
261
+ expect(e.type).toBe("map");
262
+ expect(e.entries).toHaveLength(2);
263
+ expect(e.entries[0].key).toMatchObject({ name: "key1" });
264
+ expect(e.entries[1].value.type).toBe("list");
265
+ });
266
+
267
+ it("parses nested maps", () => {
268
+ const e = expr("(a: (b: 1))");
269
+ expect(e.entries[0].value.type).toBe("map");
270
+ });
271
+ });
272
+
273
+ describe("interpolation", () => {
274
+ it("parses standalone interpolation", () => {
275
+ expect(expr("#{$x + 1}")).toMatchObject({
276
+ type: "interpolation",
277
+ expression: { type: "binary", operator: "+" },
278
+ });
279
+ });
280
+ });
281
+
282
+ describe("CSS-style if()", () => {
283
+ it("parses a condition arm and an else arm", () => {
284
+ const e = expr('if($isOpen: "open"; else: "closed")');
285
+ expect(e.type).toBe("if");
286
+ expect(e.arms).toHaveLength(2);
287
+ expect(e.arms[0].condition).toMatchObject({
288
+ type: "variable",
289
+ name: "isOpen",
290
+ });
291
+ expect(e.arms[0].value).toMatchObject({ type: "string", value: "open" });
292
+ expect(e.arms[1].condition).toBeNull();
293
+ expect(e.arms[1].value).toMatchObject({
294
+ type: "string",
295
+ value: "closed",
296
+ });
297
+ });
298
+
299
+ it("parses multiple arms with complex conditions", () => {
300
+ const e = expr(
301
+ 'if($count == 0: "none"; $count > 3 and $isActive: "many"; else: "few")',
302
+ );
303
+ expect(e.arms).toHaveLength(3);
304
+ expect(e.arms[0].condition).toMatchObject({
305
+ type: "binary",
306
+ operator: "==",
307
+ });
308
+ expect(e.arms[1].condition).toMatchObject({
309
+ type: "binary",
310
+ operator: "and",
311
+ });
312
+ expect(e.arms[2].condition).toBeNull();
313
+ });
314
+
315
+ it("parses without an else arm and with a trailing semicolon", () => {
316
+ const e = expr('if($x: 1;)');
317
+ expect(e.arms).toHaveLength(1);
318
+ expect(e.arms[0].condition).toMatchObject({ type: "variable" });
319
+ });
320
+
321
+ it("parses list values and nested if() in arms", () => {
322
+ const list = expr("if($x: 1px solid, 2px dashed; else: none)");
323
+ expect(list.arms[0].value).toMatchObject({
324
+ type: "list",
325
+ separator: ",",
326
+ });
327
+ const nested = expr('if($a: if($b: 1; else: 2); else: 3)');
328
+ expect(nested.arms[0].value.type).toBe("if");
329
+ });
330
+
331
+ it("allows dot access on the if() result", () => {
332
+ const e = expr("if($a: $x; else: $y).name");
333
+ expect(e).toMatchObject({
334
+ type: "member",
335
+ property: "name",
336
+ object: { type: "if" },
337
+ });
338
+ });
339
+
340
+ it("parses colon-less if(...) as a regular function call", () => {
341
+ const e = expr('if($cond, "yes", "no")');
342
+ expect(e.type).toBe("function");
343
+ expect(e.callee).toMatchObject({ type: "identifier", name: "if" });
344
+ expect(e.args).toHaveLength(3);
345
+ });
346
+
347
+ it("ignores colons nested inside maps when detecting arms", () => {
348
+ const e = expr("if((a: 1), 2, 3)");
349
+ expect(e.type).toBe("function");
350
+ });
351
+
352
+ it("rejects arms after else", () => {
353
+ expect(() => expr('if(else: 1; $x: 2)')).toThrow(QuarkParseError);
354
+ expect(() => expr('if(else: 1; $x: 2)')).toThrow(/last arm/);
355
+ });
356
+ });
357
+
358
+ describe("rejected JS-style syntax", () => {
359
+ const bad = (src: string) =>
360
+ expect(() => parse(src)).toThrow(QuarkParseError);
361
+
362
+ it("rejects ternaries", () => {
363
+ bad('a { b: $x ? "yes" : "no"; }');
364
+ });
365
+
366
+ it("rejects optional chaining", () => {
367
+ bad("a { b: $x?.y; }");
368
+ });
369
+
370
+ it("rejects nullish coalescing", () => {
371
+ bad("a { b: $x ?? 1; }");
372
+ });
373
+
374
+ it("rejects strict equality", () => {
375
+ bad("a { b: $x === 1; }");
376
+ });
377
+
378
+ it("rejects logical || and &&", () => {
379
+ bad('a { b: $x || "fallback"; }');
380
+ bad("a { b: $x && $y; }");
381
+ });
382
+
383
+ it("rejects arrow functions", () => {
384
+ bad("a { b: $list.filter(s => s.active); }");
385
+ });
386
+
387
+ it("mentions the unsupported syntax in the ternary error", () => {
388
+ try {
389
+ parse("a { b: $x ? 1 : 2; }");
390
+ throw new Error("should have thrown");
391
+ } catch (e: any) {
392
+ expect(e.message).toMatch(/not supported/);
393
+ }
394
+ });
395
+ });
396
+
397
+ describe("errors", () => {
398
+ it("reports line and column", () => {
399
+ try {
400
+ parse("a {\n b: ?;\n}");
401
+ throw new Error("should have thrown");
402
+ } catch (e: any) {
403
+ expect(e).toBeInstanceOf(QuarkParseError);
404
+ expect(e.line).toBe(2);
405
+ expect(e.column).toBe(6);
406
+ }
407
+ });
408
+
409
+ it("throws on unclosed blocks", () => {
410
+ expect(() => parse("a { b: c;")).toThrow(/Unclosed block/);
411
+ });
412
+ });
413
+ });