@uniflowed/i18n 0.0.0-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/syntax.js ADDED
@@ -0,0 +1,717 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/i18n/syntax`: MessageFormat 2 source into a tree.
4
+ //
5
+ // A message is a small language, and this is its parser. Nothing here formats
6
+ // anything, knows what a locale is, or has heard of `Intl` — it turns text
7
+ // into a tree and refuses text it cannot turn into one. `format.js` is the
8
+ // half that runs.
9
+ //
10
+ // # Why a hand-written scanner and not a regular expression
11
+ //
12
+ // Three reasons, in the order they became true.
13
+ //
14
+ // MF2 is not a regular language. `{$count :number style=percent}` nests an
15
+ // operand, an annotation and a list of options; a quoted pattern nests a
16
+ // pattern that nests placeholders. A regular expression that appeared to
17
+ // handle it would be handling the examples in the specification and nothing
18
+ // else, and the failures would be silent — a malformed message that parsed
19
+ // into something plausible, formatted, and shipped.
20
+ //
21
+ // A parse error has to say *where*. `unexpected "}" at offset 34` is a
22
+ // message somebody can act on with the source in front of them; `did not
23
+ // match` is not, and a translator handed the second will delete characters
24
+ // until it stops complaining.
25
+ //
26
+ // And `crates/uf_lib/tests/package_surface.rs` reads every shipped module with
27
+ // a scanner that does not model regular-expression literals — it says so, in
28
+ // the comment on `code_only` — so a `/…/` in this file would blank the wrong
29
+ // half of the module and fail a structural test for a reason nobody could see
30
+ // from the error. That is a small reason next to the first two, and it is the
31
+ // one that would have cost an afternoon.
32
+ //
33
+ // # The subset, and where the line is
34
+ //
35
+ // Implemented: simple messages, quoted patterns, the four escapes, variable
36
+ // and literal placeholders, the six functions of the MF2 default registry with
37
+ // their options, `.input` and `.local` declarations, and `.match` with any
38
+ // number of selectors.
39
+ //
40
+ // Refused, each with an error that names itself rather than a generic syntax
41
+ // complaint:
42
+ //
43
+ // - **Markup** — `{#bold}…{/bold}`. `format` returns a string, and markup only
44
+ // means something to a caller that can turn a list of parts into elements.
45
+ // Dropping the tags would silently lose emphasis a translator put in;
46
+ // inlining HTML would put unescaped translator input into a page. Refusing
47
+ // it at the point the catalogue is defined is the only one of the three that
48
+ // cannot go wrong quietly.
49
+ // - **Attributes** — `{$x @unit}`. The specification says attributes do not
50
+ // affect formatting, so accepting and ignoring them would be correct. They
51
+ // are refused anyway, because the only thing they are for is a tool that
52
+ // reads them, uf has no such tool, and a message that carries an attribute
53
+ // uf will never read is a message whose author believes something that is
54
+ // not true.
55
+ // - **Reserved and private-use annotations** — `{$x !foo}`, `{$x ^bar}`. The
56
+ // specification reserves these sigils for later versions and for private
57
+ // agreements. Refusing them is what keeps a message that parses here from
58
+ // meaning something different under a conforming implementation.
59
+ //
60
+ // # What a name is
61
+ //
62
+ // MF2's `name` production draws on a wide slice of Unicode, and this
63
+ // implements a documented approximation rather than the table: ASCII letters,
64
+ // `_`, and any code point at or above U+00A1 may start a name; digits, `-`,
65
+ // `.` and U+00B7 may continue one. The gap is the specification's exclusion of
66
+ // surrogates and a handful of ranges above U+00A1, which this admits.
67
+ //
68
+ // Erring wide is the right direction here. A name uf accepts and the
69
+ // specification does not is a message that works everywhere uf runs and is
70
+ // rejected by a stricter tool — visible the moment anyone tries. A name uf
71
+ // rejected would be a message a translator wrote correctly and uf refused,
72
+ // which looks like a bug in their translation.
73
+
74
+ /** A quoted or unquoted literal: `|two words|`, `42`, `percent`. */
75
+ export type MessageLiteral = { readonly kind: "literal", readonly value: string };
76
+
77
+ /** A reference to an argument or to something `.input`/`.local` declared. */
78
+ export type MessageVariable = { readonly kind: "variable", readonly name: string };
79
+
80
+ /** What a placeholder or an option may be given. */
81
+ export type MessageOperand = MessageLiteral | MessageVariable;
82
+
83
+ /** One `name=value` inside a function annotation. */
84
+ export type MessageOption = {
85
+ readonly name: string,
86
+ readonly value: MessageOperand,
87
+ };
88
+
89
+ /** `:number`, and the options it was given. */
90
+ export type MessageAnnotation = {
91
+ readonly name: string,
92
+ readonly options: $ReadOnlyArray<MessageOption>,
93
+ };
94
+
95
+ /**
96
+ * One `{…}`.
97
+ *
98
+ * `operand` is absent for an annotation-only expression such as
99
+ * `{:datetime}` in a `.local`, and `annotation` is absent for a bare `{$name}`
100
+ * — but never both, which the parser enforces rather than the type, because
101
+ * the type that says so is a union whose two arms are identical everywhere
102
+ * else and would be read at every use.
103
+ */
104
+ export type MessageExpression = {
105
+ readonly kind: "expression",
106
+ readonly operand: MessageOperand | null,
107
+ readonly annotation: MessageAnnotation | null,
108
+ /** Offset in the source, so a formatting error can point at it too. */
109
+ readonly at: number,
110
+ };
111
+
112
+ /** Literal text between placeholders, with escapes already resolved. */
113
+ export type MessageText = { readonly kind: "text", readonly value: string };
114
+
115
+ export type MessagePart = MessageText | MessageExpression;
116
+
117
+ /** A run of text and placeholders: what actually gets formatted. */
118
+ export type MessagePattern = $ReadOnlyArray<MessagePart>;
119
+
120
+ /**
121
+ * `.input {$count :number}` or `.local $n = {$count :number}`.
122
+ *
123
+ * One shape for both, because the difference is only where the value comes
124
+ * from — an `.input` re-annotates an argument under its own name, a `.local`
125
+ * introduces a new one — and every consumer treats them the same way.
126
+ */
127
+ export type MessageDeclaration = {
128
+ readonly kind: "input" | "local",
129
+ readonly name: string,
130
+ readonly expression: MessageExpression,
131
+ };
132
+
133
+ /** One key of one variant: a literal, or `*`. */
134
+ export type MessageVariantKey =
135
+ | { readonly kind: "literal", readonly value: string }
136
+ | { readonly kind: "catch-all" };
137
+
138
+ /** One line of a `.match`: its keys, and what to format if they win. */
139
+ export type MessageVariant = {
140
+ readonly keys: $ReadOnlyArray<MessageVariantKey>,
141
+ readonly pattern: MessagePattern,
142
+ };
143
+
144
+ export type MessageBody =
145
+ | { readonly kind: "pattern", readonly pattern: MessagePattern }
146
+ | {
147
+ readonly kind: "select",
148
+ readonly selectors: $ReadOnlyArray<MessageVariable>,
149
+ readonly variants: $ReadOnlyArray<MessageVariant>,
150
+ };
151
+
152
+ /** A parsed message: its declarations, and the body they feed. */
153
+ export type MessageNode = {
154
+ readonly declarations: $ReadOnlyArray<MessageDeclaration>,
155
+ readonly body: MessageBody,
156
+ };
157
+
158
+ /**
159
+ * A message that is not MF2, or is MF2 uf does not implement.
160
+ *
161
+ * Carries the offset as well as putting it in the text, so a caller that has
162
+ * the source — `catalogue.js` does, and names the key beside it — can point at
163
+ * the character rather than reprinting the sentence.
164
+ */
165
+ export class MessageSyntaxError extends Error {
166
+ offset: number;
167
+ source: string;
168
+
169
+ constructor(message: string, source: string, offset: number) {
170
+ super(`@uniflowed/i18n: ${message} at offset ${String(offset)}`);
171
+ this.name = "MessageSyntaxError";
172
+ this.offset = offset;
173
+ this.source = source;
174
+ }
175
+ }
176
+
177
+ /** MF2's `s`: the five code points that count as whitespace. */
178
+ function isSpace(code: number): boolean {
179
+ return code === 0x20 || code === 0x09 || code === 0x0d || code === 0x0a || code === 0x3000;
180
+ }
181
+
182
+ function isDigit(code: number): boolean {
183
+ return code >= 0x30 && code <= 0x39;
184
+ }
185
+
186
+ /** See the module header on why this is wider than the specification's. */
187
+ function isNameStart(code: number): boolean {
188
+ return (
189
+ (code >= 0x41 && code <= 0x5a) ||
190
+ (code >= 0x61 && code <= 0x7a) ||
191
+ code === 0x5f ||
192
+ code >= 0xa1
193
+ );
194
+ }
195
+
196
+ function isNameChar(code: number): boolean {
197
+ return isNameStart(code) || isDigit(code) || code === 0x2d || code === 0x2e || code === 0xb7;
198
+ }
199
+
200
+ /**
201
+ * A character that may appear unescaped in a literal that has no `|` around
202
+ * it.
203
+ *
204
+ * Wider than `name-char` by `+` alone, which is what lets `1e+6` and `+1` be
205
+ * written as option values and variant keys without quoting. The specification
206
+ * reaches the same place through a separate `number-literal` production; one
207
+ * predicate is the same answer with one thing to read.
208
+ */
209
+ function isUnquotedChar(code: number): boolean {
210
+ return isNameChar(code) || code === 0x2b;
211
+ }
212
+
213
+ /**
214
+ * The cursor.
215
+ *
216
+ * A mutable object rather than an index threaded through twenty functions:
217
+ * every function here advances it and the alternative is returning a position
218
+ * beside every value, which is the same state with a chance to forget to
219
+ * thread it.
220
+ */
221
+ type Cursor = { at: number };
222
+
223
+ function fail(source: string, at: number, message: string): empty {
224
+ throw new MessageSyntaxError(message, source, at);
225
+ }
226
+
227
+ function peek(source: string, cursor: Cursor): number {
228
+ return cursor.at < source.length ? source.charCodeAt(cursor.at) : -1;
229
+ }
230
+
231
+ function skipSpace(source: string, cursor: Cursor): void {
232
+ while (cursor.at < source.length && isSpace(source.charCodeAt(cursor.at))) {
233
+ cursor.at += 1;
234
+ }
235
+ }
236
+
237
+ /** True if `word` is next, and consumes it if so. */
238
+ function eatWord(source: string, cursor: Cursor, word: string): boolean {
239
+ if (source.startsWith(word, cursor.at)) {
240
+ cursor.at += word.length;
241
+ return true;
242
+ }
243
+ return false;
244
+ }
245
+
246
+ function expect(source: string, cursor: Cursor, character: string, what: string): void {
247
+ if (source[cursor.at] !== character) {
248
+ fail(source, cursor.at, `expected ${character} ${what}`);
249
+ }
250
+ cursor.at += 1;
251
+ }
252
+
253
+ function readName(source: string, cursor: Cursor, what: string): string {
254
+ const start = cursor.at;
255
+ if (cursor.at >= source.length || !isNameStart(source.charCodeAt(cursor.at))) {
256
+ fail(source, cursor.at, `expected ${what}`);
257
+ }
258
+ cursor.at += 1;
259
+ while (cursor.at < source.length && isNameChar(source.charCodeAt(cursor.at))) {
260
+ cursor.at += 1;
261
+ }
262
+ return source.slice(start, cursor.at);
263
+ }
264
+
265
+ /**
266
+ * The four escapes MF2 allows, and nothing else.
267
+ *
268
+ * `\n` is deliberately not one of them. A translator who writes `\n` expecting
269
+ * a line break gets a syntax error naming the character, which is a better
270
+ * afternoon than a message that renders a literal backslash-n to a user.
271
+ */
272
+ function readEscape(source: string, cursor: Cursor): string {
273
+ cursor.at += 1;
274
+ const escaped = source[cursor.at];
275
+ if (escaped !== "\\" && escaped !== "{" && escaped !== "}" && escaped !== "|") {
276
+ fail(
277
+ source,
278
+ cursor.at - 1,
279
+ "a backslash may only escape one of \\ { } |, so an ordinary backslash is written \\\\",
280
+ );
281
+ }
282
+ cursor.at += 1;
283
+ return escaped;
284
+ }
285
+
286
+ function readQuotedLiteral(source: string, cursor: Cursor): string {
287
+ const opened = cursor.at;
288
+ cursor.at += 1;
289
+ let value = "";
290
+ while (cursor.at < source.length) {
291
+ const character = source[cursor.at];
292
+ if (character === "|") {
293
+ cursor.at += 1;
294
+ return value;
295
+ }
296
+ if (character === "\\") {
297
+ value += readEscape(source, cursor);
298
+ continue;
299
+ }
300
+ value += character;
301
+ cursor.at += 1;
302
+ }
303
+ return fail(source, opened, "a quoted literal was opened with | and never closed");
304
+ }
305
+
306
+ function readLiteral(source: string, cursor: Cursor, what: string): MessageLiteral {
307
+ if (source[cursor.at] === "|") {
308
+ return { kind: "literal", value: readQuotedLiteral(source, cursor) };
309
+ }
310
+ const start = cursor.at;
311
+ while (cursor.at < source.length && isUnquotedChar(source.charCodeAt(cursor.at))) {
312
+ cursor.at += 1;
313
+ }
314
+ if (cursor.at === start) {
315
+ fail(source, cursor.at, `expected ${what}`);
316
+ }
317
+ return { kind: "literal", value: source.slice(start, cursor.at) };
318
+ }
319
+
320
+ function readVariable(source: string, cursor: Cursor): MessageVariable {
321
+ expect(source, cursor, "$", "to start a variable");
322
+ return { kind: "variable", name: readName(source, cursor, "a variable name after $") };
323
+ }
324
+
325
+ /**
326
+ * The sigils MF2 reserves for later versions and for private agreements.
327
+ *
328
+ * Named here rather than caught by "unexpected character", because the two
329
+ * mean different things to whoever reads the error: an unexpected character is
330
+ * a typo, and one of these is a message written for a different implementation.
331
+ */
332
+ const RESERVED_SIGILS: $ReadOnlyArray<string> = ["!", "%", "*", "+", "<", ">", "?", "~", "^", "&"];
333
+
334
+ function readAnnotation(source: string, cursor: Cursor): MessageAnnotation {
335
+ expect(source, cursor, ":", "to start a function annotation");
336
+ const name = readName(source, cursor, "a function name after :");
337
+ if (source[cursor.at] === ":") {
338
+ fail(
339
+ source,
340
+ cursor.at,
341
+ `a namespaced function (:${name}:…) is outside the subset uf implements`,
342
+ );
343
+ }
344
+ const options: Array<MessageOption> = [];
345
+ while (true) {
346
+ const before = cursor.at;
347
+ skipSpace(source, cursor);
348
+ if (cursor.at === before) break;
349
+ const next = peek(source, cursor);
350
+ if (next < 0 || !isNameStart(next)) {
351
+ // The whitespace belonged to whatever closes the expression.
352
+ cursor.at = before;
353
+ break;
354
+ }
355
+ const optionName = readName(source, cursor, "an option name");
356
+ skipSpace(source, cursor);
357
+ expect(source, cursor, "=", `after the option name ${optionName}`);
358
+ skipSpace(source, cursor);
359
+ const value =
360
+ source[cursor.at] === "$"
361
+ ? readVariable(source, cursor)
362
+ : readLiteral(source, cursor, `a value for the option ${optionName}`);
363
+ options.push({ name: optionName, value });
364
+ }
365
+ return { name, options };
366
+ }
367
+
368
+ function readExpression(source: string, cursor: Cursor): MessageExpression {
369
+ const at = cursor.at;
370
+ expect(source, cursor, "{", "to start a placeholder");
371
+ skipSpace(source, cursor);
372
+
373
+ const opener = source[cursor.at];
374
+ if (opener === "#" || opener === "/") {
375
+ fail(
376
+ source,
377
+ cursor.at,
378
+ "markup ({#tag} … {/tag}) is outside the subset uf implements; format returns a string, " +
379
+ "and a string cannot carry markup without either losing it or inlining it unescaped",
380
+ );
381
+ }
382
+ if (opener != null && RESERVED_SIGILS.includes(opener)) {
383
+ fail(
384
+ source,
385
+ cursor.at,
386
+ `${opener} starts an annotation MessageFormat 2 reserves, so a message using it means ` +
387
+ "something uf cannot know",
388
+ );
389
+ }
390
+
391
+ let operand: MessageOperand | null = null;
392
+ let annotation: MessageAnnotation | null = null;
393
+ if (opener === "$") {
394
+ operand = readVariable(source, cursor);
395
+ } else if (opener !== ":") {
396
+ operand = readLiteral(source, cursor, "a variable, a literal or a :function inside {}");
397
+ }
398
+
399
+ const beforeSpace = cursor.at;
400
+ skipSpace(source, cursor);
401
+ const annotating = source[cursor.at];
402
+ if (annotating === ":") {
403
+ annotation = readAnnotation(source, cursor);
404
+ } else if (annotating != null && RESERVED_SIGILS.includes(annotating)) {
405
+ // The same refusal as at the opener, and it has to be here as well:
406
+ // `{!reserved}` is caught above and `{$x !reserved}` is not, because by
407
+ // then the operand has been read and the sigil is in annotation position.
408
+ // Checking only one of the two spellings would accept half of exactly the
409
+ // messages this refuses.
410
+ fail(
411
+ source,
412
+ cursor.at,
413
+ `${annotating} starts an annotation MessageFormat 2 reserves, so a message using it means ` +
414
+ "something uf cannot know",
415
+ );
416
+ } else if (operand != null) {
417
+ cursor.at = beforeSpace;
418
+ }
419
+
420
+ skipSpace(source, cursor);
421
+ if (source[cursor.at] === "@") {
422
+ fail(
423
+ source,
424
+ cursor.at,
425
+ "an @attribute is outside the subset uf implements; it would not change what this " +
426
+ "message formats to, and nothing in uf reads one",
427
+ );
428
+ }
429
+ expect(source, cursor, "}", "to close the placeholder");
430
+ return { kind: "expression", operand, annotation, at };
431
+ }
432
+
433
+ /**
434
+ * Text and placeholders up to `stop`.
435
+ *
436
+ * `stop` is `"}}"` inside a quoted pattern and the empty string for a simple
437
+ * message, which runs to the end of the source. A bare `}` is an error in
438
+ * both, because MF2 makes it one — and the error names the escape, since the
439
+ * character is common in text somebody is translating.
440
+ */
441
+ function readPattern(source: string, cursor: Cursor, stop: string): MessagePattern {
442
+ const parts: Array<MessagePart> = [];
443
+ let text = "";
444
+
445
+ const flush = () => {
446
+ if (text !== "") {
447
+ parts.push({ kind: "text", value: text });
448
+ text = "";
449
+ }
450
+ };
451
+
452
+ while (cursor.at < source.length) {
453
+ if (stop !== "" && source.startsWith(stop, cursor.at)) break;
454
+ const character = source[cursor.at];
455
+ if (character === "\\") {
456
+ text += readEscape(source, cursor);
457
+ continue;
458
+ }
459
+ if (character === "{") {
460
+ flush();
461
+ parts.push(readExpression(source, cursor));
462
+ continue;
463
+ }
464
+ if (character === "}") {
465
+ fail(source, cursor.at, "an unescaped } in a pattern; write \\} for a literal brace");
466
+ }
467
+ text += character;
468
+ cursor.at += 1;
469
+ }
470
+
471
+ if (stop !== "" && !source.startsWith(stop, cursor.at)) {
472
+ fail(source, cursor.at, `a quoted pattern was opened with {{ and never closed with ${stop}`);
473
+ }
474
+ flush();
475
+ return parts;
476
+ }
477
+
478
+ function readQuotedPattern(source: string, cursor: Cursor): MessagePattern {
479
+ if (!eatWord(source, cursor, "{{")) {
480
+ fail(source, cursor.at, "expected a quoted pattern, which is written {{ like this }}");
481
+ }
482
+ const pattern = readPattern(source, cursor, "}}");
483
+ cursor.at += 2;
484
+ return pattern;
485
+ }
486
+
487
+ function readDeclaration(source: string, cursor: Cursor): MessageDeclaration {
488
+ if (eatWord(source, cursor, ".input")) {
489
+ skipSpace(source, cursor);
490
+ const at = cursor.at;
491
+ const expression = readExpression(source, cursor);
492
+ const operand = expression.operand;
493
+ if (operand == null || operand.kind !== "variable") {
494
+ return fail(source, at, ".input must be given a variable, as in .input {$count :number}");
495
+ }
496
+ return { kind: "input", name: operand.name, expression };
497
+ }
498
+ if (!eatWord(source, cursor, ".local")) {
499
+ return fail(source, cursor.at, "expected .input, .local or .match");
500
+ }
501
+ skipSpace(source, cursor);
502
+ const variable = readVariable(source, cursor);
503
+ skipSpace(source, cursor);
504
+ expect(source, cursor, "=", `after .local $${variable.name}`);
505
+ skipSpace(source, cursor);
506
+ return { kind: "local", name: variable.name, expression: readExpression(source, cursor) };
507
+ }
508
+
509
+ function readVariantKey(source: string, cursor: Cursor): MessageVariantKey {
510
+ if (source[cursor.at] === "*") {
511
+ cursor.at += 1;
512
+ return { kind: "catch-all" };
513
+ }
514
+ return { kind: "literal", value: readLiteral(source, cursor, "a variant key or *").value };
515
+ }
516
+
517
+ function readMatcher(source: string, cursor: Cursor): MessageBody {
518
+ const selectors: Array<MessageVariable> = [];
519
+ while (true) {
520
+ skipSpace(source, cursor);
521
+ if (source[cursor.at] !== "$") break;
522
+ selectors.push(readVariable(source, cursor));
523
+ }
524
+ if (selectors.length === 0) {
525
+ fail(source, cursor.at, ".match needs at least one selector, as in .match $count");
526
+ }
527
+
528
+ const variants: Array<MessageVariant> = [];
529
+ while (true) {
530
+ skipSpace(source, cursor);
531
+ if (cursor.at >= source.length) break;
532
+ const keys: Array<MessageVariantKey> = [];
533
+ while (keys.length < selectors.length) {
534
+ if (keys.length > 0) skipSpace(source, cursor);
535
+ keys.push(readVariantKey(source, cursor));
536
+ }
537
+ skipSpace(source, cursor);
538
+ variants.push({ keys, pattern: readQuotedPattern(source, cursor) });
539
+ }
540
+
541
+ if (variants.length === 0) {
542
+ fail(source, cursor.at, ".match needs at least one variant");
543
+ }
544
+ // Checked here rather than at format time on purpose: a `.match` with no
545
+ // catch-all formats correctly for every value somebody tried and throws on
546
+ // the first one they did not, which is the failure mode a translation layer
547
+ // must not have. MF2 requires it for the same reason.
548
+ const catchAll = variants.some((variant) =>
549
+ variant.keys.every((key) => key.kind === "catch-all"),
550
+ );
551
+ if (!catchAll) {
552
+ fail(
553
+ source,
554
+ cursor.at,
555
+ "a .match needs a variant whose keys are all *, so that every value formats to something",
556
+ );
557
+ }
558
+ return { kind: "select", selectors, variants };
559
+ }
560
+
561
+ /**
562
+ * A duplicate declaration, caught where it is written.
563
+ *
564
+ * MF2 makes this an error, and the reason is worth keeping in view: two
565
+ * `.local $n` lines look like a redefinition and are not — the second cannot
566
+ * see the first, because a declaration's expression is resolved against what
567
+ * was in scope before it. A message with two of them means something nobody
568
+ * intended whichever way it is read.
569
+ */
570
+ function assertDeclarationsAreDistinct(
571
+ source: string,
572
+ declarations: $ReadOnlyArray<MessageDeclaration>,
573
+ ): void {
574
+ const seen: Set<string> = new Set();
575
+ for (const declaration of declarations) {
576
+ if (seen.has(declaration.name)) {
577
+ fail(source, declaration.expression.at, `$${declaration.name} is declared twice`);
578
+ }
579
+ seen.add(declaration.name);
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Parse an MF2 message.
585
+ *
586
+ * Throws [`MessageSyntaxError`] rather than returning a result, and that is a
587
+ * decision rather than an oversight: every caller in this package is
588
+ * `catalogue.js` building a catalogue at start-up, where there is nothing
589
+ * useful to do with a bad message except stop. A `safeParse` twin would exist
590
+ * for a tool that wants to report several at once, and nothing in uf is that
591
+ * tool yet.
592
+ */
593
+ export function parseMessage(source: string): MessageNode {
594
+ const cursor: Cursor = { at: 0 };
595
+
596
+ // MF2 decides simple against complex on the first character alone, which is
597
+ // why a multi-line message has to begin with `.input` or `.match` hard
598
+ // against the backtick. Trimming here would be a kindness that changed what
599
+ // a message means: " .match" is a simple message whose text starts with a
600
+ // space, and uf must not turn it into a matcher nobody wrote.
601
+ if (source[0] !== ".") {
602
+ return {
603
+ declarations: [],
604
+ body: { kind: "pattern", pattern: readPattern(source, cursor, "") },
605
+ };
606
+ }
607
+
608
+ const declarations: Array<MessageDeclaration> = [];
609
+ let body: MessageBody | null = null;
610
+ while (cursor.at < source.length) {
611
+ skipSpace(source, cursor);
612
+ if (eatWord(source, cursor, ".match")) {
613
+ body = readMatcher(source, cursor);
614
+ break;
615
+ }
616
+ if (source[cursor.at] === "{") {
617
+ body = { kind: "pattern", pattern: readQuotedPattern(source, cursor) };
618
+ break;
619
+ }
620
+ declarations.push(readDeclaration(source, cursor));
621
+ }
622
+
623
+ if (body == null) {
624
+ // `return fail(…)` rather than a bare call: `fail` returns `empty`, and
625
+ // returning it is what tells the checker the lines below cannot run with
626
+ // `body` still null. A bare call leaves the narrowing to an inference the
627
+ // checker does not make.
628
+ return fail(
629
+ source,
630
+ cursor.at,
631
+ "a message with declarations needs a body: either a .match or a {{quoted pattern}}",
632
+ );
633
+ }
634
+ skipSpace(source, cursor);
635
+ if (cursor.at < source.length) {
636
+ fail(source, cursor.at, "unexpected text after the end of the message");
637
+ }
638
+ assertDeclarationsAreDistinct(source, declarations);
639
+ return { declarations, body };
640
+ }
641
+
642
+ /** What a message asks of the outside world, read off the tree. */
643
+ export type MessageUsage = {
644
+ /** Every variable it reads and did not declare itself: its parameters. */
645
+ readonly variables: $ReadOnlyArray<string>,
646
+ /** Every `:function` it names, so a caller can refuse ones it cannot run. */
647
+ readonly functions: $ReadOnlyArray<string>,
648
+ /** `["count", "number"]` for each annotation applied directly to a variable. */
649
+ readonly annotated: $ReadOnlyArray<[string, string]>,
650
+ };
651
+
652
+ /**
653
+ * What a message needs, in one walk.
654
+ *
655
+ * This is where the two halves of the promise this package makes are compared.
656
+ * Flow checks the *call* against the declared parameters; `catalogue.js`
657
+ * checks the declared parameters against this, which is the half a type system
658
+ * with no template-literal types cannot reach on its own — a message is a
659
+ * string literal, and the placeholders inside a string literal are not part of
660
+ * its type in any checker.
661
+ *
662
+ * One walk and one return value rather than three functions, because all three
663
+ * facts are wanted at the same moment by the same caller, and a second walk is
664
+ * a second chance for the two to disagree about what a declaration shadows.
665
+ */
666
+ export function messageUsage(node: MessageNode): MessageUsage {
667
+ const variables: Set<string> = new Set();
668
+ const functions: Set<string> = new Set();
669
+ const annotated: Array<[string, string]> = [];
670
+ const declared: Set<string> = new Set();
671
+
672
+ const visitOperand = (operand: MessageOperand | null) => {
673
+ if (operand != null && operand.kind === "variable" && !declared.has(operand.name)) {
674
+ variables.add(operand.name);
675
+ }
676
+ };
677
+ const visitExpression = (expression: MessageExpression) => {
678
+ visitOperand(expression.operand);
679
+ const annotation = expression.annotation;
680
+ if (annotation == null) return;
681
+ functions.add(annotation.name);
682
+ const operand = expression.operand;
683
+ if (operand != null && operand.kind === "variable" && !declared.has(operand.name)) {
684
+ annotated.push([operand.name, annotation.name]);
685
+ }
686
+ for (const option of annotation.options) visitOperand(option.value);
687
+ };
688
+ const visitPattern = (pattern: MessagePattern) => {
689
+ for (const part of pattern) {
690
+ if (part.kind === "expression") visitExpression(part);
691
+ }
692
+ };
693
+
694
+ for (const declaration of node.declarations) {
695
+ // Order matters, and in both directions. A `.local` is resolved against
696
+ // what was in scope before it, so its own expression may read an argument
697
+ // — `.local $n = {$count :number}` has the parameter `count`. An `.input`
698
+ // names the argument it re-annotates, so `.input {$count :number}` also
699
+ // has the parameter `count`, and marking it declared first would hide it.
700
+ visitExpression(declaration.expression);
701
+ declared.add(declaration.name);
702
+ }
703
+
704
+ const body = node.body;
705
+ if (body.kind === "pattern") {
706
+ visitPattern(body.pattern);
707
+ } else {
708
+ for (const selector of body.selectors) visitOperand(selector);
709
+ for (const variant of body.variants) visitPattern(variant.pattern);
710
+ }
711
+
712
+ return {
713
+ variables: Array.from(variables).sort(),
714
+ functions: Array.from(functions).sort(),
715
+ annotated,
716
+ };
717
+ }