@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/catalogue.js +501 -0
- package/format.js +824 -0
- package/index.js +271 -0
- package/negotiate.js +162 -0
- package/package.json +28 -0
- package/syntax.js +717 -0
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
|
+
}
|