desmost 0.7.3 → 0.8.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 (131) hide show
  1. package/dist/compiler/compile.d.ts.map +1 -1
  2. package/dist/compiler/compile.js.map +1 -1
  3. package/dist/compiler/evaluate.d.ts.map +1 -1
  4. package/dist/compiler/evaluate.js +1 -11
  5. package/dist/compiler/evaluate.js.map +1 -1
  6. package/dist/compiler/format.d.ts +2 -0
  7. package/dist/compiler/format.d.ts.map +1 -1
  8. package/dist/compiler/format.js +11 -0
  9. package/dist/compiler/format.js.map +1 -1
  10. package/dist/compiler/options.d.ts.map +1 -1
  11. package/dist/compiler/options.js.map +1 -1
  12. package/dist/decompiler/decompile.d.ts +9 -0
  13. package/dist/decompiler/decompile.d.ts.map +1 -0
  14. package/dist/decompiler/decompile.js +21 -0
  15. package/dist/decompiler/decompile.js.map +1 -0
  16. package/dist/decompiler/emit.d.ts +4 -0
  17. package/dist/decompiler/emit.d.ts.map +1 -0
  18. package/dist/decompiler/emit.js +28 -0
  19. package/dist/decompiler/emit.js.map +1 -0
  20. package/dist/decompiler/extract.d.ts +5 -0
  21. package/dist/decompiler/extract.d.ts.map +1 -0
  22. package/dist/decompiler/extract.js +53 -0
  23. package/dist/decompiler/extract.js.map +1 -0
  24. package/dist/decompiler/format.d.ts +3 -0
  25. package/dist/decompiler/format.d.ts.map +1 -0
  26. package/dist/decompiler/format.js +11 -0
  27. package/dist/decompiler/format.js.map +1 -0
  28. package/dist/decompiler/index.d.ts +2 -0
  29. package/dist/decompiler/index.d.ts.map +1 -0
  30. package/dist/decompiler/index.js +2 -0
  31. package/dist/decompiler/index.js.map +1 -0
  32. package/dist/desmos.d.ts.map +1 -1
  33. package/dist/desmos.js.map +1 -1
  34. package/dist/errors.d.ts.map +1 -1
  35. package/dist/errors.js.map +1 -1
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.internal.d.ts +1 -0
  39. package/dist/index.internal.d.ts.map +1 -1
  40. package/dist/index.internal.js +1 -0
  41. package/dist/index.internal.js.map +1 -1
  42. package/dist/index.js +1 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/magic/expr/index.js.map +1 -1
  45. package/dist/magic/expr/latex.d.ts.map +1 -1
  46. package/dist/magic/expr/latex.js.map +1 -1
  47. package/dist/magic/expr/text.d.ts.map +1 -1
  48. package/dist/magic/expr/text.js.map +1 -1
  49. package/dist/magic/global/dark-mode.d.ts.map +1 -1
  50. package/dist/magic/global/dark-mode.js.map +1 -1
  51. package/dist/magic/global/desmos.d.ts.map +1 -1
  52. package/dist/magic/global/desmos.js.map +1 -1
  53. package/dist/magic/global/index.d.ts +3 -0
  54. package/dist/magic/global/index.d.ts.map +1 -1
  55. package/dist/magic/global/index.js +3 -0
  56. package/dist/magic/global/index.js.map +1 -1
  57. package/dist/magic/global/viewport.d.ts.map +1 -1
  58. package/dist/magic/global/viewport.js +0 -1
  59. package/dist/magic/global/viewport.js.map +1 -1
  60. package/dist/magic/incantation.d.ts.map +1 -1
  61. package/dist/magic/incantation.js.map +1 -1
  62. package/dist/magic/local/anim.d.ts.map +1 -1
  63. package/dist/magic/local/anim.js.map +1 -1
  64. package/dist/magic/local/colour.d.ts.map +1 -1
  65. package/dist/magic/local/colour.js.map +1 -1
  66. package/dist/magic/local/dashed.d.ts.map +1 -1
  67. package/dist/magic/local/dashed.js.map +1 -1
  68. package/dist/magic/local/fill.d.ts.map +1 -1
  69. package/dist/magic/local/fill.js.map +1 -1
  70. package/dist/magic/local/hide.d.ts.map +1 -1
  71. package/dist/magic/local/hide.js.map +1 -1
  72. package/dist/magic/local/index.js.map +1 -1
  73. package/dist/magic/local/label.d.ts.map +1 -1
  74. package/dist/magic/local/label.js.map +1 -1
  75. package/dist/magic/local/line.d.ts.map +1 -1
  76. package/dist/magic/local/line.js.map +1 -1
  77. package/dist/magic/local/no-line.d.ts.map +1 -1
  78. package/dist/magic/local/no-line.js.map +1 -1
  79. package/dist/magic/local/point.d.ts.map +1 -1
  80. package/dist/magic/local/point.js.map +1 -1
  81. package/dist/magic/local/secret.d.ts.map +1 -1
  82. package/dist/magic/local/secret.js.map +1 -1
  83. package/dist/magic/local/slider.d.ts.map +1 -1
  84. package/dist/magic/local/slider.js.map +1 -1
  85. package/dist/parser/ast.d.ts.map +1 -1
  86. package/dist/parser/ast.js.map +1 -1
  87. package/dist/parser/desmost-parser.d.ts.map +1 -1
  88. package/dist/parser/desmost-parser.js +1 -1
  89. package/dist/parser/desmost-parser.js.map +1 -1
  90. package/dist/parser/generic-parser.d.ts.map +1 -1
  91. package/dist/parser/generic-parser.js.map +1 -1
  92. package/dist/utils.d.ts.map +1 -1
  93. package/dist/utils.js.map +1 -1
  94. package/package.json +1 -1
  95. package/src/compiler/compile.ts +124 -124
  96. package/src/compiler/evaluate.ts +86 -118
  97. package/src/compiler/format.ts +39 -6
  98. package/src/compiler/options.ts +78 -75
  99. package/src/decompiler/decompile.ts +48 -0
  100. package/src/decompiler/emit.ts +47 -0
  101. package/src/decompiler/extract.ts +100 -0
  102. package/src/decompiler/format.ts +20 -0
  103. package/src/decompiler/index.ts +1 -0
  104. package/src/desmos.ts +6 -6
  105. package/src/errors.ts +12 -12
  106. package/src/index.internal.ts +2 -0
  107. package/src/index.ts +1 -0
  108. package/src/magic/expr/index.ts +2 -2
  109. package/src/magic/expr/latex.ts +16 -16
  110. package/src/magic/expr/text.ts +13 -13
  111. package/src/magic/global/dark-mode.ts +10 -10
  112. package/src/magic/global/desmos.ts +15 -15
  113. package/src/magic/global/index.ts +8 -3
  114. package/src/magic/global/viewport.ts +19 -20
  115. package/src/magic/incantation.ts +94 -94
  116. package/src/magic/local/anim.ts +10 -10
  117. package/src/magic/local/colour.ts +26 -26
  118. package/src/magic/local/dashed.ts +9 -9
  119. package/src/magic/local/fill.ts +12 -12
  120. package/src/magic/local/hide.ts +9 -9
  121. package/src/magic/local/index.ts +11 -11
  122. package/src/magic/local/label.ts +51 -51
  123. package/src/magic/local/line.ts +16 -16
  124. package/src/magic/local/no-line.ts +9 -9
  125. package/src/magic/local/point.ts +16 -16
  126. package/src/magic/local/secret.ts +9 -9
  127. package/src/magic/local/slider.ts +15 -15
  128. package/src/parser/ast.ts +45 -45
  129. package/src/parser/desmost-parser.ts +550 -550
  130. package/src/parser/generic-parser.ts +176 -176
  131. package/src/utils.ts +33 -33
@@ -7,8 +7,8 @@ import type { NoMatch, Unrecoverable } from "../errors";
7
7
  import * as utils from "../utils";
8
8
 
9
9
  import {
10
- Incantation, ArgIncantation,
11
- GLOBAL_INCANTATIONS, LOCAL_INCANTATIONS, EXPR_INCANTATIONS
10
+ Incantation, ArgIncantation,
11
+ GLOBAL_INCANTATIONS, LOCAL_INCANTATIONS, EXPR_INCANTATIONS
12
12
  } from "../magic";
13
13
  import type { GLOBAL, LOCAL, EXPR } from "../magic";
14
14
 
@@ -18,569 +18,569 @@ import type { GLOBAL, LOCAL, EXPR } from "../magic";
18
18
  */
19
19
  export class DesmostParser extends GenericParser
20
20
  {
21
- constructor(source: string, protected options?: DesmostOptions)
22
- {
23
- super(source + "\n");
24
- }
25
-
26
-
27
- // == TOP-LEVEL == //
28
-
29
- /**
30
- * Parse the next semantic block of source code, producing an `Ast` object, or `null` if the parser has successfully reached the end of the source code.
31
- */
32
- public parse_next(): Ast | null
33
- {
34
- this.consume_spaces();
35
-
36
- if (this.out_of_bounds()) {
37
- return null;
38
- }
39
-
40
- if (this.current === "%") {
41
- if (this.options?.ignore_comments) {
42
- // ignore comment
43
- this.parse_line();
44
- this.consume_end_of_block();
45
- return this.parse_next();
46
- }
47
- else {
48
- // comment
49
- var expr = this.parse_comment();
50
- }
51
- }
52
- else if (this.current === "/") {
53
- let r = this.parse_pre_sep();
54
-
55
- // 1 global
56
- if ("global" in r) {
57
- return r.global;
58
- }
59
-
60
- // 1+ locals + 1 expr
61
- let incantations = r.local;
62
-
63
- if (incantations.length > 0) {
64
- this.parse_sep();
65
- }
66
-
67
- var expr = this.parse_post_sep();
68
-
69
- for (let invocation of incantations) {
70
- expr.incantations.push(invocation);
71
- }
72
- }
73
- else {
74
- // 1 expr
75
- var expr = this.parse_post_sep();
76
- }
77
-
78
- this.consume_end_of_block();
79
- return expr;
80
- }
81
-
82
- /**
83
- * Parse Desmost syntax before the `::` separator, which may be:
84
- *
85
- * - 1 global incantation
86
- * - 1+ local incantations
87
- */
88
- parse_pre_sep():
89
- Unrecoverable<
90
- | { global: Ast.IncantationInvocation<GLOBAL> | Ast.InvalidInvocation }
91
- | { local: Array<Ast.IncantationInvocation<LOCAL> | Ast.InvalidInvocation> }
92
- >
93
- {
94
- // 1 global incantation
95
- let r = this.try_parse_global_incantation();
96
-
97
- if (r !== NO_MATCH) {
98
- this.consume_end_of_block(
99
- `Received excess input after global incantation /${r.incantation.identifier}`
100
- );
101
-
102
- return { global: r };
103
- }
104
-
105
- // 1+ local incantations
106
- let incantations = [];
107
-
108
- while (this.current === "/") {
109
- let invocation = this.try_parse_local_incantation();
110
- if (invocation === NO_MATCH) break;
111
- incantations.push(invocation);
112
- }
113
-
114
- return { local: incantations };
115
- }
116
-
117
- /**
118
- * Parse the Desmost `::` separator.
119
- */
120
- parse_sep(): Unrecoverable<void>
121
- {
122
- this.consume_whitespace();
123
- this.consume("::",
124
- `Expected \`::\` separator between local incantations and expression, but found: \`${this.preview()}\``
125
- );
126
- this.consume_whitespace();
127
- }
128
-
129
- /**
130
- * Parse Desmost syntax after the `::` separator, which may be:
131
- *
132
- * - 1 line of LaTeX
133
- * - 1 expression incantation
134
- */
135
- parse_post_sep(): Unrecoverable<Ast.Expression>
136
- {
137
- this.consume_spaces();
138
-
139
- switch (this.current) {
140
- // empty block
141
- case "\n":
142
- return {
143
- kind: Ast.Kind.EXPRESSION,
144
- data: { latex: ` ` },
145
- incantations: [],
146
- };
147
-
148
- // expr incantation
149
- case "/":
150
- let incantation = this.try_parse_expr_incantation();
151
- if (incantation !== NO_MATCH) return incantation;
152
- // FALLTHROUGH: to fallback on plain LaTeX
153
-
154
- // plain LaTeX
155
- default:
156
- return {
157
- kind: Ast.Kind.EXPRESSION,
158
- data: { latex: this.parse_line() },
159
- incantations: [],
160
- };
161
- }
162
- }
163
-
164
- parse_comment(): Unrecoverable<Ast.Expression>
165
- {
166
- this.advance();
167
- let text = this.parse_line();
168
-
169
- return {
170
- kind: Ast.Kind.EXPRESSION,
171
- data: { type: "text", text },
172
- incantations: [],
173
- };
174
- }
175
-
176
-
177
- // == LOW-LEVEL == //
178
-
179
- /**
180
- * Parse a single line of arbitrary text.
181
- */
182
- parse_line(): Unrecoverable<string>
183
- {
184
- let init = this.i;
185
-
186
- while (!this.out_of_bounds() && this.current !== "\n") {
187
- this.advance();
188
- }
189
-
190
- return this.source.slice(init, this.i).trim();
191
- }
192
-
193
- /**
194
- * Attempt to parse a global incantation invocation.
195
- */
196
- try_parse_global_incantation():
197
- | Ast.IncantationInvocation<GLOBAL>
198
- | Ast.InvalidInvocation
199
- | NoMatch
200
- {
201
- let init = this.i;
202
-
203
- if (this.try_consume("/") === NO_MATCH) return NO_MATCH;
204
-
205
- let incantation = this.try_parse_identifier(GLOBAL_INCANTATIONS);
206
- if (incantation === NO_MATCH) {
207
- this.i = init;
208
- return NO_MATCH;
209
- }
210
-
211
- let data = undefined;
212
-
213
- if (incantation instanceof ArgIncantation) {
214
- if (this.current === "{") {
215
- data = this.parse_incantation_arg(incantation.arg_type);
216
- }
217
- else if (incantation.requires_arg) {
218
- return {
219
- kind: Ast.Kind.INVALID_INCANTATION,
220
- incantation,
221
- error: new UnrecoverableError.MissingInput(
222
- `No argument provided for /${incantation.identifier}, which requires an argument of type: \`${incantation.arg_type}\``
223
- )
224
- };
225
- }
226
- }
227
-
228
- this.consume_spaces();
229
-
230
- // @ts-expect-error: FIXME
231
- return {
232
- kind: Ast.Kind.INCANTATION_INVOCATION,
233
- incantation,
234
- arg_raw: data,
235
- };
236
- }
237
-
238
- /**
239
- * Attempt to parse a local incantation invocation.
240
- */
241
- try_parse_local_incantation():
242
- | Ast.IncantationInvocation<LOCAL>
243
- | Ast.InvalidInvocation
244
- | NoMatch
245
- {
246
- let init = this.i;
247
-
248
- if (this.try_consume("/") === NO_MATCH) return NO_MATCH;
249
-
250
- let incantation = this.try_parse_identifier(LOCAL_INCANTATIONS);
251
- if (incantation === NO_MATCH) {
252
- this.i = init;
253
- return NO_MATCH;
254
- }
255
-
256
- let arg_raw = undefined;
257
-
258
- if (incantation instanceof ArgIncantation) {
259
- if (this.current === "{") {
260
- arg_raw = this.parse_incantation_arg(incantation.arg_type);
261
- }
262
- else if (incantation.requires_arg) {
263
- return {
264
- kind: Ast.Kind.INVALID_INCANTATION,
265
- incantation,
266
- error: new UnrecoverableError.MissingInput(
267
- `No argument provided for /${incantation.identifier}, which requires an argument of type: \`${incantation.arg_type}\``
268
- )
269
- };
270
- }
271
- }
272
-
273
- this.consume_whitespace();
274
-
275
- // @ts-expect-error: FIXME
276
- return {
277
- kind: Ast.Kind.INCANTATION_INVOCATION,
278
- incantation,
279
- arg_raw,
280
- };
281
- }
282
-
283
- /**
284
- * Attempt to parse an expression incantation invocation.
285
- */
286
- try_parse_expr_incantation(): Unrecoverable<Ast.Expression | NoMatch>
287
- {
288
- let init = this.i;
289
-
290
- if (this.try_consume("/") === NO_MATCH) return NO_MATCH;
291
-
292
- let incantation = this.try_parse_identifier(EXPR_INCANTATIONS);
293
- if (incantation === NO_MATCH) {
294
- // TODO maybe flag to user
295
- this.i = init;
296
- return NO_MATCH;
297
- }
298
-
299
- let arg_raw = this.parse_incantation_arg((incantation as ArgIncantation<EXPR>).arg_type);
300
-
301
- let data = {};
302
- incantation.apply(data, arg_raw);
303
-
304
- return {
305
- kind: Ast.Kind.EXPRESSION,
306
- data,
307
- incantations: [],
308
- };
309
- }
310
-
311
- /**
312
- * Attempt to parse an incantation identifier.
313
- */
314
- try_parse_identifier<Effect extends Incantation.Effect>(
315
- incantations: Incantation<Effect>[]
316
- ): Incantation<Effect> | NoMatch
317
- {
318
- for (let incantation of incantations) {
319
- // yeah the duplication here is a little meh, unfortunately needing `return` means we can't extract it into a helper
320
- let r = this.try_consume(incantation.identifier);
321
- if (r !== NO_MATCH) return incantation;
322
-
323
- if (incantation.alias != undefined) {
324
- let r = this.try_consume(incantation.alias);
325
- if (r !== NO_MATCH) return incantation;
326
- }
327
- }
328
-
329
- return NO_MATCH;
330
- }
331
-
332
- /**
333
- * Parse an argument to an incantation, enclosed in `{}`.
334
- *
335
- * ```ts
336
- * /incantation{ arg }
337
- * ^^^^^^^
338
- * ```
339
- *
340
- * Returns the raw text without `{}`.
341
- *
342
- * ```ts
343
- * /incantation{ arg }
344
- * ^^^
345
- * ```
346
- *
347
- * ## Notes
348
- *
349
- * We don't actually care for what type the argument is since we won't be evaluating it, we just want to find the closing `}`.
350
- *
351
- * However, the argument itself might contain one or many `}`! So we need a way to accurately identify the actual closing brace:
352
- *
353
- * ```ts
354
- * /incantation{ field: { value: 1 } }
355
- * ^ ^
356
- * ```
357
- *
358
- * In our context, we have the guarantee that any well-formed argument always has matching pairs of `{}`. So we'll keep track of a context stack that starts with the opening `{`, and when the stack is depleted, we must've reached the end of the argument.
359
- *
360
- * However, there's still more edge cases:
361
- *
362
- * ```ts
363
- * /label{ text: "This }s weird" }
364
- * ^
365
- * ```
366
- *
367
- * `{` and `}` can appear in strings, and they won't necessarily be matched, so we'll ignore them by also tracking string contexts.
368
- *
369
- * ```ts
370
- * /latex{ \{ x, y ) }
371
- * ^
372
- * ```
373
- *
374
- * `{` and `}` in LaTeX can be escaped with `\{` or `\}`, and these won't necessarily be matched, so we'll ignore them by also tracking escapes.
375
- *
376
- * If the user truly mismatches `{}`, then, well ...parsing will fail catastrophically!
377
- *
378
- * This method also handles parsing raw enum literals by converting them to strings. So this:
379
- *
380
- * ```ts
381
- * /line{ style: DOTTED }
382
- * ^^^^^^
383
- * ```
384
- *
385
- * Produces `style: "DOTTED"` (instead of `style: DOTTED`, which would fail to later parse as JSON).
386
- */
387
- parse_incantation_arg(
388
- /**
389
- * The type of argument to parse.
390
- *
391
- * Parsing strategy varies for different types:
392
- *
393
- * - String / LaTeX: Track only balanced `{}` and escaped `\{\}`.
394
- * - JavaScript object: The above, plus balanced string quotes.
395
- */
396
- arg_type: Incantation.ArgType,
397
- ): Unrecoverable<string>
398
- {
399
- let init = this.i;
400
-
401
- this.consume("{",
402
- `Expected \`{\` to start incantation argument, but found: \`${this.preview()}\``
403
- );
404
-
405
- /** The context stack to keep track of when the closing `}` is encountered. */
406
- let stack = new ContextStack();
407
-
408
- /** Indices at which to later insert `"` quotes. */
409
- let indices_to_insert_quotes: number[] = [];
410
-
411
- while (stack.length > 0) {
412
- // If the last character was an escape, we don't care at all what the next character is!
413
- // This handles \\ double escape perfectly fine, since the first negates the second
414
- if (stack.try_pop(Ctx.ESCAPE)) {
415
- this.advance(
416
- `Unexpected end of input while parsing incantation argument, stack: ${stack.debug()}`
417
- );
418
- }
419
-
420
- let top = stack.top!;
421
-
422
- switch (this.current)
423
- {
424
- case Char.BACKSLASH: stack.push(Ctx.ESCAPE); break;
425
-
426
- case "{": stack.push( Ctx.BLOCK, { unless: [Ctx.STR_1, Ctx.STR_2, Ctx.STR_F] }); break;
427
- case "}": stack.force_pop(Ctx.BLOCK, { unless: [Ctx.STR_1, Ctx.STR_2, Ctx.STR_F] }); break;
428
- }
429
-
430
- if (arg_type === Incantation.ArgType.OBJECT)
431
- {
432
- if (top === Ctx.VALUE && this.current?.match(/[a-zA-Z]/)) {
433
- stack.push(Ctx.ENUM);
434
- indices_to_insert_quotes.push(this.i);
435
- }
436
- else if (top === Ctx.ENUM && this.current?.match(/[^a-zA-Z]/)) {
437
- stack.pop();
438
- indices_to_insert_quotes.push(this.i);
439
-
440
- if (indices_to_insert_quotes.length % 2 != 0) {
441
- console.error(`Desmost [INTERNAL]: Logic error in \`parse_incantation_arg()\`: Mismatched inserted quotes`);
442
- }
443
- }
444
-
445
- switch (this.current)
446
- {
447
- case ":": stack.push(Ctx.VALUE, { when: [Ctx.BLOCK] }); break;
448
- case ",": stack.try_pop(Ctx.VALUE); break;
449
-
450
- case Char.QUOTE_1:
451
- stack.pop_or_push(Ctx.STR_1, { unless: [Ctx.STR_2, Ctx.STR_F] });
452
- stack.try_pop(Ctx.VALUE);
453
- break;
454
- case Char.QUOTE_2:
455
- stack.pop_or_push(Ctx.STR_2, { unless: [Ctx.STR_1, Ctx.STR_F] });
456
- stack.try_pop(Ctx.VALUE);
457
- break;
458
- case Char.BACKTICK:
459
- stack.pop_or_push(Ctx.STR_F, { unless: [Ctx.STR_1, Ctx.STR_2] });
460
- stack.try_pop(Ctx.VALUE);
461
- break;
462
- }
463
- }
464
-
465
- this.advance(
466
- `Unexpected end of input while parsing incantation argument, stack: ${JSON.stringify(stack)}`
467
- );
468
- }
469
-
470
- /* NOTE: Cut in by 1 on both sides to exclude {} braces */
471
- let out = utils.chars(this.source, init + 1, this.i - 1);
472
-
473
- for (let [i, j] of utils.reversing(utils.paired(indices_to_insert_quotes))) {
474
- i -= init + 1;
475
- j -= init + 1;
476
-
477
- let value = out.slice(i, j).join("");
478
- if (["true", "false", "null", "undefined"].includes(value)) continue;
479
-
480
- out.splice(j, 0, `"`);
481
- out.splice(i, 0, `"`);
482
- }
483
-
484
- return out.join("").trim();
485
- }
21
+ constructor(source: string, protected options?: DesmostOptions)
22
+ {
23
+ super(source + "\n");
24
+ }
25
+
26
+
27
+ // == TOP-LEVEL == //
28
+
29
+ /**
30
+ * Parse the next semantic block of source code, producing an `Ast` object, or `null` if the parser has successfully reached the end of the source code.
31
+ */
32
+ public parse_next(): Ast | null
33
+ {
34
+ this.consume_spaces();
35
+
36
+ if (this.out_of_bounds()) {
37
+ return null;
38
+ }
39
+
40
+ if (this.current === "%") {
41
+ if (this.options?.ignore_comments) {
42
+ // ignore comment
43
+ this.parse_line();
44
+ this.consume_end_of_block();
45
+ return this.parse_next();
46
+ }
47
+ else {
48
+ // comment
49
+ var expr = this.parse_comment();
50
+ }
51
+ }
52
+ else if (this.current === "/") {
53
+ let r = this.parse_pre_sep();
54
+
55
+ // 1 global
56
+ if ("global" in r) {
57
+ return r.global;
58
+ }
59
+
60
+ // 1+ locals + 1 expr
61
+ let incantations = r.local;
62
+
63
+ if (incantations.length > 0) {
64
+ this.parse_sep();
65
+ }
66
+
67
+ var expr = this.parse_post_sep();
68
+
69
+ for (let invocation of incantations) {
70
+ expr.incantations.push(invocation);
71
+ }
72
+ }
73
+ else {
74
+ // 1 expr
75
+ var expr = this.parse_post_sep();
76
+ }
77
+
78
+ this.consume_end_of_block();
79
+ return expr;
80
+ }
81
+
82
+ /**
83
+ * Parse Desmost syntax before the `::` separator, which may be:
84
+ *
85
+ * - 1 global incantation
86
+ * - 1+ local incantations
87
+ */
88
+ parse_pre_sep():
89
+ Unrecoverable<
90
+ | { global: Ast.IncantationInvocation<GLOBAL> | Ast.InvalidInvocation }
91
+ | { local: Array<Ast.IncantationInvocation<LOCAL> | Ast.InvalidInvocation> }
92
+ >
93
+ {
94
+ // 1 global incantation
95
+ let r = this.try_parse_global_incantation();
96
+
97
+ if (r !== NO_MATCH) {
98
+ this.consume_end_of_block(
99
+ `Received excess input after global incantation /${r.incantation.identifier}`
100
+ );
101
+
102
+ return { global: r };
103
+ }
104
+
105
+ // 1+ local incantations
106
+ let incantations = [];
107
+
108
+ while (this.current === "/") {
109
+ let invocation = this.try_parse_local_incantation();
110
+ if (invocation === NO_MATCH) break;
111
+ incantations.push(invocation);
112
+ }
113
+
114
+ return { local: incantations };
115
+ }
116
+
117
+ /**
118
+ * Parse the Desmost `::` separator.
119
+ */
120
+ parse_sep(): Unrecoverable<void>
121
+ {
122
+ this.consume_whitespace();
123
+ this.consume("::",
124
+ `Expected \`::\` separator between local incantations and expression, but found: \`${this.preview()}\``
125
+ );
126
+ this.consume_whitespace();
127
+ }
128
+
129
+ /**
130
+ * Parse Desmost syntax after the `::` separator, which may be:
131
+ *
132
+ * - 1 line of LaTeX
133
+ * - 1 expression incantation
134
+ */
135
+ parse_post_sep(): Unrecoverable<Ast.Expression>
136
+ {
137
+ this.consume_spaces();
138
+
139
+ switch (this.current) {
140
+ // empty block
141
+ case "\n":
142
+ return {
143
+ kind: Ast.Kind.EXPRESSION,
144
+ data: { latex: ` ` },
145
+ incantations: [],
146
+ };
147
+
148
+ // expr incantation
149
+ case "/":
150
+ let incantation = this.try_parse_expr_incantation();
151
+ if (incantation !== NO_MATCH) return incantation;
152
+ // FALLTHROUGH: to fallback on plain LaTeX
153
+
154
+ // plain LaTeX
155
+ default:
156
+ return {
157
+ kind: Ast.Kind.EXPRESSION,
158
+ data: { latex: this.parse_line() },
159
+ incantations: [],
160
+ };
161
+ }
162
+ }
163
+
164
+ parse_comment(): Unrecoverable<Ast.Expression>
165
+ {
166
+ this.advance();
167
+ let text = this.parse_line();
168
+
169
+ return {
170
+ kind: Ast.Kind.EXPRESSION,
171
+ data: { type: "text", text },
172
+ incantations: [],
173
+ };
174
+ }
175
+
176
+
177
+ // == LOW-LEVEL == //
178
+
179
+ /**
180
+ * Parse a single line of arbitrary text.
181
+ */
182
+ parse_line(): Unrecoverable<string>
183
+ {
184
+ let init = this.i;
185
+
186
+ while (!this.out_of_bounds() && this.current !== "\n") {
187
+ this.advance();
188
+ }
189
+
190
+ return this.source.slice(init, this.i).trim();
191
+ }
192
+
193
+ /**
194
+ * Attempt to parse a global incantation invocation.
195
+ */
196
+ try_parse_global_incantation():
197
+ | Ast.IncantationInvocation<GLOBAL>
198
+ | Ast.InvalidInvocation
199
+ | NoMatch
200
+ {
201
+ let init = this.i;
202
+
203
+ if (this.try_consume("/") === NO_MATCH) return NO_MATCH;
204
+
205
+ let incantation = this.try_parse_identifier(GLOBAL_INCANTATIONS);
206
+ if (incantation === NO_MATCH) {
207
+ this.i = init;
208
+ return NO_MATCH;
209
+ }
210
+
211
+ let data = undefined;
212
+
213
+ if (incantation instanceof ArgIncantation) {
214
+ if (this.current === "{") {
215
+ data = this.parse_incantation_arg(incantation.arg_type);
216
+ }
217
+ else if (incantation.requires_arg) {
218
+ return {
219
+ kind: Ast.Kind.INVALID_INCANTATION,
220
+ incantation,
221
+ error: new UnrecoverableError.MissingInput(
222
+ `No argument provided for /${incantation.identifier}, which requires an argument of type: \`${incantation.arg_type}\``
223
+ )
224
+ };
225
+ }
226
+ }
227
+
228
+ this.consume_spaces();
229
+
230
+ // @ts-expect-error: FIXME
231
+ return {
232
+ kind: Ast.Kind.INCANTATION_INVOCATION,
233
+ incantation,
234
+ arg_raw: data,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Attempt to parse a local incantation invocation.
240
+ */
241
+ try_parse_local_incantation():
242
+ | Ast.IncantationInvocation<LOCAL>
243
+ | Ast.InvalidInvocation
244
+ | NoMatch
245
+ {
246
+ let init = this.i;
247
+
248
+ if (this.try_consume("/") === NO_MATCH) return NO_MATCH;
249
+
250
+ let incantation = this.try_parse_identifier(LOCAL_INCANTATIONS);
251
+ if (incantation === NO_MATCH) {
252
+ this.i = init;
253
+ return NO_MATCH;
254
+ }
255
+
256
+ let arg_raw = undefined;
257
+
258
+ if (incantation instanceof ArgIncantation) {
259
+ if (this.current === "{") {
260
+ arg_raw = this.parse_incantation_arg(incantation.arg_type);
261
+ }
262
+ else if (incantation.requires_arg) {
263
+ return {
264
+ kind: Ast.Kind.INVALID_INCANTATION,
265
+ incantation,
266
+ error: new UnrecoverableError.MissingInput(
267
+ `No argument provided for /${incantation.identifier}, which requires an argument of type: \`${incantation.arg_type}\``
268
+ )
269
+ };
270
+ }
271
+ }
272
+
273
+ this.consume_whitespace();
274
+
275
+ // @ts-expect-error: FIXME
276
+ return {
277
+ kind: Ast.Kind.INCANTATION_INVOCATION,
278
+ incantation,
279
+ arg_raw,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Attempt to parse an expression incantation invocation.
285
+ */
286
+ try_parse_expr_incantation(): Unrecoverable<Ast.Expression | NoMatch>
287
+ {
288
+ let init = this.i;
289
+
290
+ if (this.try_consume("/") === NO_MATCH) return NO_MATCH;
291
+
292
+ let incantation = this.try_parse_identifier(EXPR_INCANTATIONS);
293
+ if (incantation === NO_MATCH) {
294
+ // TODO maybe flag to user
295
+ this.i = init;
296
+ return NO_MATCH;
297
+ }
298
+
299
+ let arg_raw = this.parse_incantation_arg((incantation as ArgIncantation<EXPR>).arg_type);
300
+
301
+ let data = {};
302
+ incantation.apply(data, arg_raw);
303
+
304
+ return {
305
+ kind: Ast.Kind.EXPRESSION,
306
+ data,
307
+ incantations: [],
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Attempt to parse an incantation identifier.
313
+ */
314
+ try_parse_identifier<Effect extends Incantation.Effect>(
315
+ incantations: Incantation<Effect>[]
316
+ ): Incantation<Effect> | NoMatch
317
+ {
318
+ for (let incantation of incantations) {
319
+ // yeah the duplication here is a little meh, unfortunately needing `return` means we can't extract it into a helper
320
+ let r = this.try_consume(incantation.identifier);
321
+ if (r !== NO_MATCH) return incantation;
322
+
323
+ if (incantation.alias != undefined) {
324
+ let r = this.try_consume(incantation.alias);
325
+ if (r !== NO_MATCH) return incantation;
326
+ }
327
+ }
328
+
329
+ return NO_MATCH;
330
+ }
331
+
332
+ /**
333
+ * Parse an argument to an incantation, enclosed in `{}`.
334
+ *
335
+ * ```ts
336
+ * /incantation{ arg }
337
+ * ^^^^^^^
338
+ * ```
339
+ *
340
+ * Returns the raw text without `{}`.
341
+ *
342
+ * ```ts
343
+ * /incantation{ arg }
344
+ * ^^^
345
+ * ```
346
+ *
347
+ * ## Notes
348
+ *
349
+ * We don't actually care for what type the argument is since we won't be evaluating it, we just want to find the closing `}`.
350
+ *
351
+ * However, the argument itself might contain one or many `}`! So we need a way to accurately identify the actual closing brace:
352
+ *
353
+ * ```ts
354
+ * /incantation{ field: { value: 1 } }
355
+ * ^ ^
356
+ * ```
357
+ *
358
+ * In our context, we have the guarantee that any well-formed argument always has matching pairs of `{}`. So we'll keep track of a context stack that starts with the opening `{`, and when the stack is depleted, we must've reached the end of the argument.
359
+ *
360
+ * However, there's still more edge cases:
361
+ *
362
+ * ```ts
363
+ * /label{ text: "This }s weird" }
364
+ * ^
365
+ * ```
366
+ *
367
+ * `{` and `}` can appear in strings, and they won't necessarily be matched, so we'll ignore them by also tracking string contexts.
368
+ *
369
+ * ```ts
370
+ * /latex{ \{ x, y ) }
371
+ * ^
372
+ * ```
373
+ *
374
+ * `{` and `}` in LaTeX can be escaped with `\{` or `\}`, and these won't necessarily be matched, so we'll ignore them by also tracking escapes.
375
+ *
376
+ * If the user truly mismatches `{}`, then, well ...parsing will fail catastrophically!
377
+ *
378
+ * This method also handles parsing raw enum literals by converting them to strings. So this:
379
+ *
380
+ * ```ts
381
+ * /line{ style: DOTTED }
382
+ * ^^^^^^
383
+ * ```
384
+ *
385
+ * Produces `style: "DOTTED"` (instead of `style: DOTTED`, which would fail to later parse as JSON).
386
+ */
387
+ parse_incantation_arg(
388
+ /**
389
+ * The type of argument to parse.
390
+ *
391
+ * Parsing strategy varies for different types:
392
+ *
393
+ * - String / LaTeX: Track only balanced `{}` and escaped `\{\}`.
394
+ * - JavaScript object: The above, plus balanced string quotes.
395
+ */
396
+ arg_type: Incantation.ArgType,
397
+ ): Unrecoverable<string>
398
+ {
399
+ let init = this.i;
400
+
401
+ this.consume("{",
402
+ `Expected \`{\` to start incantation argument, but found: \`${this.preview()}\``
403
+ );
404
+
405
+ /** The context stack to keep track of when the closing `}` is encountered. */
406
+ let stack = new ContextStack();
407
+
408
+ /** Indices at which to later insert `"` quotes. */
409
+ let indices_to_insert_quotes: number[] = [];
410
+
411
+ while (stack.length > 0) {
412
+ // If the last character was an escape, we don't care at all what the next character is!
413
+ // This handles \\ double escape perfectly fine, since the first negates the second
414
+ if (stack.try_pop(Ctx.ESCAPE)) {
415
+ this.advance(
416
+ `Unexpected end of input while parsing incantation argument, stack: ${stack.debug()}`
417
+ );
418
+ }
419
+
420
+ let top = stack.top;
421
+
422
+ switch (this.current)
423
+ {
424
+ case Char.BACKSLASH: stack.push(Ctx.ESCAPE); break;
425
+
426
+ case "{": stack.push( Ctx.BLOCK, { unless: [Ctx.STR_1, Ctx.STR_2, Ctx.STR_F] }); break;
427
+ case "}": stack.force_pop(Ctx.BLOCK, { unless: [Ctx.STR_1, Ctx.STR_2, Ctx.STR_F] }); break;
428
+ }
429
+
430
+ if (arg_type === Incantation.ArgType.OBJECT)
431
+ {
432
+ if (top === Ctx.VALUE && this.current?.match(/[a-zA-Z]/)) {
433
+ stack.push(Ctx.ENUM);
434
+ indices_to_insert_quotes.push(this.i);
435
+ }
436
+ else if (top === Ctx.ENUM && this.current?.match(/[^a-zA-Z]/)) {
437
+ stack.pop();
438
+ indices_to_insert_quotes.push(this.i);
439
+
440
+ if (indices_to_insert_quotes.length % 2 != 0) {
441
+ console.error(`Desmost [INTERNAL]: Logic error in \`parse_incantation_arg()\`: Mismatched inserted quotes`);
442
+ }
443
+ }
444
+
445
+ switch (this.current)
446
+ {
447
+ case ":": stack.push(Ctx.VALUE, { when: [Ctx.BLOCK] }); break;
448
+ case ",": stack.try_pop(Ctx.VALUE); break;
449
+
450
+ case Char.QUOTE_1:
451
+ stack.pop_or_push(Ctx.STR_1, { unless: [Ctx.STR_2, Ctx.STR_F] });
452
+ stack.try_pop(Ctx.VALUE);
453
+ break;
454
+ case Char.QUOTE_2:
455
+ stack.pop_or_push(Ctx.STR_2, { unless: [Ctx.STR_1, Ctx.STR_F] });
456
+ stack.try_pop(Ctx.VALUE);
457
+ break;
458
+ case Char.BACKTICK:
459
+ stack.pop_or_push(Ctx.STR_F, { unless: [Ctx.STR_1, Ctx.STR_2] });
460
+ stack.try_pop(Ctx.VALUE);
461
+ break;
462
+ }
463
+ }
464
+
465
+ this.advance(
466
+ `Unexpected end of input while parsing incantation argument, stack: ${JSON.stringify(stack)}`
467
+ );
468
+ }
469
+
470
+ /* NOTE: Cut in by 1 on both sides to exclude {} braces */
471
+ let out = utils.chars(this.source, init + 1, this.i - 1);
472
+
473
+ for (let [i, j] of utils.reversing(utils.paired(indices_to_insert_quotes))) {
474
+ i -= init + 1;
475
+ j -= init + 1;
476
+
477
+ let value = out.slice(i, j).join("");
478
+ if (["true", "false", "null", "undefined"].includes(value)) continue;
479
+
480
+ out.splice(j, 0, `"`);
481
+ out.splice(i, 0, `"`);
482
+ }
483
+
484
+ return out.join("").trim();
485
+ }
486
486
  }
487
487
 
488
488
 
489
489
  class ContextStack
490
490
  {
491
- #data: Ctx[] = [Ctx.BLOCK]
492
-
493
- get length(): number {
494
- return this.#data.length;
495
- }
496
-
497
- /** The currently active context. */
498
- get top(): Ctx {
499
- return this.#data.at(-1)!;
500
- }
501
-
502
- push(ctx: Ctx, options?: StackOperationOptions): boolean
503
- {
504
- if (this.#should_skip(options)) return false;
505
-
506
- this.#data.push(ctx);
507
- return true;
508
- }
509
-
510
- pop(): void
511
- {
512
- this.#data.pop();
513
- }
514
-
515
- /** Pop `ctx` if it is the currently active context, returning `true` if so. */
516
- try_pop(ctx: Ctx): boolean
517
- {
518
- if (this.top === ctx) {
519
- this.#data.pop();
520
- return true;
521
- } else {
522
- return false;
523
- }
524
- }
525
-
526
- /** Backtrack until `ctx` is popped. */
527
- force_pop(ctx: Ctx, options?: StackOperationOptions): boolean
528
- {
529
- if (this.#should_skip(options)) return false;
530
-
531
- let idx = this.#data.lastIndexOf(ctx);
532
- if (idx === -1) return false;
533
-
534
- /* NOTE: We could report errors for unterminated contexts, but we'll leave that for the actual evaluation - in case we get something wrong ;) */
535
- this.#data.splice(idx);
536
- return true;
537
- }
538
-
539
- /** Pop `ctx` if it is the current context, else push it onto the stack. */
540
- pop_or_push(ctx: Ctx, options?: StackOperationOptions): boolean
541
- {
542
- if (this.#should_skip(options)) return false;
543
-
544
- this.try_pop(ctx) || this.push(ctx);
545
- return true;
546
- }
547
-
548
- debug(): string {
549
- return JSON.stringify(this.#data);
550
- }
551
-
552
- #should_skip(options: StackOperationOptions | undefined): boolean
553
- {
554
- return Boolean(
555
- options?.when != undefined && !options?.when?.includes(this.top)
556
- || options?.unless?.includes(this.top)
557
- );
558
- }
491
+ #data: Ctx[] = [Ctx.BLOCK]
492
+
493
+ get length(): number {
494
+ return this.#data.length;
495
+ }
496
+
497
+ /** The currently active context. */
498
+ get top(): Ctx {
499
+ return this.#data.at(-1)!;
500
+ }
501
+
502
+ push(ctx: Ctx, options?: StackOperationOptions): boolean
503
+ {
504
+ if (this.#should_skip(options)) return false;
505
+
506
+ this.#data.push(ctx);
507
+ return true;
508
+ }
509
+
510
+ pop(): void
511
+ {
512
+ this.#data.pop();
513
+ }
514
+
515
+ /** Pop `ctx` if it is the currently active context, returning `true` if so. */
516
+ try_pop(ctx: Ctx): boolean
517
+ {
518
+ if (this.top === ctx) {
519
+ this.#data.pop();
520
+ return true;
521
+ } else {
522
+ return false;
523
+ }
524
+ }
525
+
526
+ /** Backtrack until `ctx` is popped. */
527
+ force_pop(ctx: Ctx, options?: StackOperationOptions): boolean
528
+ {
529
+ if (this.#should_skip(options)) return false;
530
+
531
+ let idx = this.#data.lastIndexOf(ctx);
532
+ if (idx === -1) return false;
533
+
534
+ /* NOTE: We could report errors for unterminated contexts, but we'll leave that for the actual evaluation - in case we get something wrong ;) */
535
+ this.#data.splice(idx);
536
+ return true;
537
+ }
538
+
539
+ /** Pop `ctx` if it is the current context, else push it onto the stack. */
540
+ pop_or_push(ctx: Ctx, options?: StackOperationOptions): boolean
541
+ {
542
+ if (this.#should_skip(options)) return false;
543
+
544
+ this.try_pop(ctx) || this.push(ctx);
545
+ return true;
546
+ }
547
+
548
+ debug(): string {
549
+ return JSON.stringify(this.#data);
550
+ }
551
+
552
+ #should_skip(options: StackOperationOptions | undefined): boolean
553
+ {
554
+ return Boolean(
555
+ options?.when != undefined && !options.when.includes(this.top)
556
+ || options?.unless?.includes(this.top)
557
+ );
558
+ }
559
559
  }
560
560
 
561
561
  interface StackOperationOptions
562
562
  {
563
- /** Only perform this operation if the current context is any of these. */
564
- when?: Ctx[];
563
+ /** Only perform this operation if the current context is any of these. */
564
+ when?: Ctx[];
565
565
 
566
- /** Don't perform this operation if the current context is any of these. */
567
- unless?: Ctx[];
566
+ /** Don't perform this operation if the current context is any of these. */
567
+ unless?: Ctx[];
568
568
  }
569
569
 
570
570
  enum Char
571
571
  {
572
- BACKSLASH = "\\",
573
- QUOTE_1 = `'`,
574
- QUOTE_2 = `"`,
575
- BACKTICK = "`",
572
+ BACKSLASH = "\\",
573
+ QUOTE_1 = `'`,
574
+ QUOTE_2 = `"`,
575
+ BACKTICK = "`",
576
576
  }
577
577
 
578
578
  enum Ctx {
579
- BLOCK = "{",
580
- VALUE = ":",
581
- ENUM = "<enum literal>",
582
- STR_1 = Char.QUOTE_1,
583
- STR_2 = Char.QUOTE_2,
584
- STR_F = Char.BACKTICK,
585
- ESCAPE = Char.BACKSLASH,
579
+ BLOCK = "{",
580
+ VALUE = ":",
581
+ ENUM = "<enum literal>",
582
+ STR_1 = Char.QUOTE_1,
583
+ STR_2 = Char.QUOTE_2,
584
+ STR_F = Char.BACKTICK,
585
+ ESCAPE = Char.BACKSLASH,
586
586
  }