@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.23

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.
@@ -0,0 +1,927 @@
1
+ /**
2
+ * Device syntax errors inside groups: `( … )` expressions, `{ … }` arrays,
3
+ * the head word of a statement, and the tokens of one (#505, #506, #509, #511,
4
+ * #512, #518).
5
+ *
6
+ * One walk over the comment-masked document, with the same frame roles as the
7
+ * other walkers (`segment.ts` → `maskUncached`): `[` opens a statement
8
+ * context, `(` an expression, and `{` a statement body when its enclosing
9
+ * frame bears statements and either `braceStartsStatements` says so or the
10
+ * statement's directive takes a program in that slot (`:execute {…}`,
11
+ * `:time {…}`, `brace-slots.ts`). Every other `{` is an array. Each group
12
+ * reports at most one fault, because the device stops at its first error and a
13
+ * second report from the same group would describe input it never read.
14
+ *
15
+ * What each rule says was grounded with `:parse` on a stored `/system script`
16
+ * source, on CHR 7.24.5 and 7.23.8, identical on both:
17
+ *
18
+ * - **Expressions (#505).** `()`, `(1 +)`, `(+ 1)`, `(1 + + 2)`, `(!)`,
19
+ * `(1,)` and `(1 any)` are `syntax error`. `(1 2)` (juxtaposition),
20
+ * `(- 1)`, `(1 + -2)`, `(-> 1)` and `(any any 1)` parse: `-`, `!`, `>`, `~`
21
+ * and `any` are prefix operators, and the rest need a left operand. A `;`
22
+ * or a newline directly inside the group is an error too (`(1; 2)`,
23
+ * `(1⏎+ 2)`); a `\`-continued line is not a newline. At operand position
24
+ * `and`, `or`, `in`, `.` and `/` are variable names (`(and 1)` is
25
+ * `( $and 1)`, `(. 1)` is `( $. 1)`), and `1.`, `1/`, `a_b`, `1:2` and
26
+ * `a/b` are single words. `-` never joins a word: `(a-b)` is `(- $a $b)`
27
+ * and `(a-)` an error. A `$name` stops at `.`, `-` and `/`.
28
+ * - **Queries.** Inside `where …` and `find …` the device parses the value
29
+ * after `<name>=` with the property's own parser: `where (comment=+)`,
30
+ * `(comment=a-)`, `(comment=@)` and `(.id=*1)` parse, though each is an
31
+ * expression error elsewhere. So a group in a query gets only the structural
32
+ * checks (empty, `;`, newline) and #509's brace rule, never the operand or
33
+ * `@` rules.
34
+ * - **Arrays (#511).** An array that is the whole value of a statement or an
35
+ * argument must have an element: `:local z {}`, `:put {}`, `:return {}`,
36
+ * `[:len {}]` and `in={}` are errors, while `{1;{}}`, `{a={}}`, `({})`,
37
+ * `where … in {}` and a body (`do={}`, `source={}`, `:execute {}`) parse. A
38
+ * `;` with no element before it on its line is an error (`{;1}`, `{1;;2}`,
39
+ * `{1⏎;2}`), and a trailing one is not (`{1;}`, `{1;2;⏎}`). A newline
40
+ * separates elements (`{1⏎2}` is `1;2`). `,` is the list operator, so
41
+ * `{1,}`, `{,1}` and `{1,⏎2}` are errors.
42
+ * - **`@` (#512).** A `@` is an error in the head word of a statement at any
43
+ * level (`admin@x`, `[admin@MikroTik]`, `:put@x`, `/ip@x`), anywhere in an
44
+ * array (`{1;a@b}`, `{a=c@d}`) and in an expression (`(a@b)`). It is a value
45
+ * byte in an argument (`comment=a@b`, `:put a@b`, `[:len a@b]`).
46
+ * - **Named comparisons (#509).** A brace after `<property>=` in a query that
47
+ * `args.ts` cannot see, inside `( … )` or a `[ … ]` command, uses the
48
+ * property's parser and is rejected, as at the top level (#502).
49
+ *
50
+ * - **Tokens (#506, #518).** The same walk reads each statement-level token
51
+ * part by part (a bare word, a string, a `$name`, a group) outside a query:
52
+ * a closed part glued to the next byte, a `$` with no name after it, and a
53
+ * declared name that is not letters and digits. `gluedAt`, `wordFaults` and
54
+ * `nameFault` carry the device rules. One token fault is reported per
55
+ * statement, and the head word is never read.
56
+ *
57
+ * Deliberately not here: a `$( … )` group (`:put $({})` is rejected and is
58
+ * read as `({})`), the `@` in a later head word (`/ip address@ print`), and
59
+ * operand rules for array elements other than `,`.
60
+ */
61
+
62
+ import { comparesNamedValue } from "./args.ts";
63
+ import { braceSlotTakesArray, braceSlotTakesProgram } from "./brace-slots.ts";
64
+ import { scanQuotedString } from "./quoted-string.ts";
65
+ import { braceStartsStatements } from "./scope-brace.ts";
66
+
67
+ export type GroupFaultCode =
68
+ | "empty-group"
69
+ | "misplaced-separator"
70
+ | "missing-operand"
71
+ | "invalid-at-sign"
72
+ | "named-value-brace"
73
+ | "glued-token"
74
+ | "invalid-variable"
75
+ | "invalid-name";
76
+
77
+ export interface GroupFault {
78
+ code: GroupFaultCode;
79
+ /** Analyzed-byte offset, inclusive. */
80
+ start: number;
81
+ /** Analyzed-byte offset, exclusive. */
82
+ end: number;
83
+ /** The operator, separator or opener the fault is about. */
84
+ detail?: string;
85
+ /** The group the fault sits in, for a message that must say which. */
86
+ group?: "(" | "{";
87
+ }
88
+
89
+ interface StatementFrame {
90
+ kind: "statement";
91
+ char: "" | "[" | "{";
92
+ /** The statement has said `where` or `find`, so its groups are queries. */
93
+ query: boolean;
94
+ /** The end of the statement's `where`, the floor for #509. */
95
+ whereEnd: number | null;
96
+ /** The head word without its colon, the key into `brace-slots.ts`. */
97
+ verb: string | null;
98
+ /** Positional operands read after the head. */
99
+ positionals: number;
100
+ /** No token of the current statement read yet. */
101
+ lead: boolean;
102
+ /** The whitespace-delimited token being read, if any. */
103
+ token: StatementToken | null;
104
+ /**
105
+ * The token rules stop for the rest of the statement: one already
106
+ * reported, or a boundary they cannot trust (a string ending in `$"`).
107
+ */
108
+ settled: boolean;
109
+ }
110
+
111
+ interface StatementToken {
112
+ start: number;
113
+ head: boolean;
114
+ equals: boolean;
115
+ /** Just past the token's last closed part (`"…"`, `$name`, a group), or -1. */
116
+ closedAt: number;
117
+ /** Just past the token's last bare-word run, or -1. */
118
+ wordEnd: number;
119
+ }
120
+
121
+ interface ArrayFrame {
122
+ kind: "array";
123
+ open: number;
124
+ /** What the last significant byte was; `key` is a named element's `=`. */
125
+ last: "open" | "separator" | "comma" | "element" | "key";
126
+ commaAt: number;
127
+ element: boolean;
128
+ /** The current element is `name=value`, which takes a trailing `,`. */
129
+ named: boolean;
130
+ query: boolean;
131
+ done: boolean;
132
+ }
133
+
134
+ interface ExpressionFrame {
135
+ kind: "expression";
136
+ open: number;
137
+ /** Whether the next token must be an operand. */
138
+ wantOperand: boolean;
139
+ empty: boolean;
140
+ /** The operator still waiting for its operand. */
141
+ pending: { start: number; spelling: string } | null;
142
+ query: boolean;
143
+ whereEnd: number | null;
144
+ done: boolean;
145
+ }
146
+
147
+ /**
148
+ * A `{` whose role is not grounded: a brace after a relative menu word
149
+ * (`/ip { route { } }`, a menu body the device accepts empty), or one in a
150
+ * command argument, which `args.ts` already judges. Also every group inside
151
+ * one that has already faulted, since the device never reads past the fault.
152
+ * Only nesting is tracked.
153
+ */
154
+ interface OpaqueFrame {
155
+ kind: "opaque";
156
+ close: ")" | "]" | "}";
157
+ query: boolean;
158
+ /** Inside a faulted group: every descendant is opaque too. */
159
+ muted: boolean;
160
+ }
161
+
162
+ type Frame = StatementFrame | ArrayFrame | ExpressionFrame | OpaqueFrame;
163
+
164
+ /** Prefix operators: the device accepts each with no left operand. */
165
+ const PREFIX = new Set(["-", "!", ">", "~"]);
166
+
167
+ /** Every operator spelling after an operand, longest first. */
168
+ const INFIX_SPELLINGS = [
169
+ "<%%",
170
+ "<<",
171
+ ">>",
172
+ "<=",
173
+ ">=",
174
+ "!=",
175
+ "&&",
176
+ "||",
177
+ "->",
178
+ "+",
179
+ "-",
180
+ "*",
181
+ "/",
182
+ "%",
183
+ "<",
184
+ ">",
185
+ "=",
186
+ "!",
187
+ "~",
188
+ "|",
189
+ "^",
190
+ "&",
191
+ ".",
192
+ ",",
193
+ ];
194
+ const OPERATOR_BYTES = "+-*/%<>=!~|^&.,";
195
+ const WORD_OPERATORS = new Set(["and", "or", "in"]);
196
+ const BARE_DECLARATIONS = new Set(["local", "global"]);
197
+ /** Directives whose first operand is a variable name (`:local a_b` is rejected). */
198
+ const NAME_DIRECTIVES = new Set(["local", "global", "set", "for", "foreach"]);
199
+ /** The ones whose name must also START alphanumeric; `:set (a) 1` parses. */
200
+ const STRICT_NAME_DIRECTIVES = new Set(["local", "global", "foreach"]);
201
+ const ALNUM = /[A-Za-z0-9]/;
202
+ const SUB = "\x1a";
203
+
204
+ function statementFrame(char: StatementFrame["char"]): StatementFrame {
205
+ return {
206
+ kind: "statement",
207
+ char,
208
+ query: false,
209
+ whereEnd: null,
210
+ verb: null,
211
+ positionals: 0,
212
+ lead: true,
213
+ token: null,
214
+ settled: false,
215
+ };
216
+ }
217
+
218
+ function isSpace(c: string | undefined): boolean {
219
+ return c === " " || c === "\t" || c === "\r";
220
+ }
221
+
222
+ function isWordByte(c: string | undefined): boolean {
223
+ return c !== undefined && /[A-Za-z0-9_.:/]/.test(c);
224
+ }
225
+
226
+ function continuationLength(text: string, at: number): number {
227
+ if (text[at] !== "\\") return 0;
228
+ if (text[at + 1] === "\n") return 2;
229
+ return text[at + 1] === "\r" && text[at + 2] === "\n" ? 3 : 0;
230
+ }
231
+
232
+ /** A statement-frame word ends at whitespace, a separator or a delimiter. */
233
+ function statementWordEnd(text: string, from: number): number {
234
+ let j = from;
235
+ while (j < text.length && !/[\s;"()[\]{}\\]/.test(text[j] as string)) j++;
236
+ return Math.max(j, from + 1);
237
+ }
238
+
239
+ /** The token being read in `frame`, opened at `at` if there is none. */
240
+ function openToken(frame: StatementFrame, at: number) {
241
+ if (frame.token === null)
242
+ frame.token = {
243
+ start: at,
244
+ head: frame.lead,
245
+ equals: false,
246
+ closedAt: -1,
247
+ wordEnd: -1,
248
+ };
249
+ frame.lead = false;
250
+ return frame.token;
251
+ }
252
+
253
+ /** Whether the token rules read this byte of `frame`'s current token. */
254
+ function readsTokens(frame: StatementFrame): frame is StatementFrame & {
255
+ token: StatementToken;
256
+ } {
257
+ return (
258
+ !frame.settled && !frame.query && frame.token !== null && !frame.token.head
259
+ );
260
+ }
261
+
262
+ /**
263
+ * The glued-token fault at `at`, the first byte of a token part (a word, a
264
+ * string, a `$`, a group), or `null`.
265
+ *
266
+ * After a closed part only a separator may follow: `"a"b`, `$v.b`, `(1)"a"`,
267
+ * `[:len a]b`, `{1}b` and `do={…}else={…}` are `expected end of command` at
268
+ * this byte, in a directive and in a command argument alike
269
+ * (`src-address=$v/24`, `dst-port="80",443`). After a bare word the device
270
+ * rejects a `"`, `$`, `(`, `[` or `{` (`a"b"`, `a$v`, `a(1)`), but in a
271
+ * command argument the property's own parser can take the glued part when the
272
+ * word ends in a prefix or a separator (`dst-address-list=!"my list"`,
273
+ * `dst-port=80,"443"` parse on CHR 7.24.5 and 7.23.8). So there the rule
274
+ * fires only after an alphanumeric byte, and never after the `=`.
275
+ */
276
+ function gluedAt(
277
+ masked: string,
278
+ frame: StatementFrame & { token: StatementToken },
279
+ at: number,
280
+ ): GroupFault | null {
281
+ const { token } = frame;
282
+ const before = masked[at - 1];
283
+ const glued =
284
+ token.closedAt === at ||
285
+ (token.wordEnd === at &&
286
+ before !== "=" &&
287
+ (frame.verb !== null || ALNUM.test(before ?? "")));
288
+ if (!glued) return null;
289
+ const c = masked[at];
290
+ // A stray `\` is `symbols.ts`'s, and a non-ASCII stand-in is #371's.
291
+ if (c === "\\" || c === SUB) {
292
+ frame.settled = true;
293
+ return null;
294
+ }
295
+ frame.settled = true;
296
+ return { code: "glued-token", start: at, end: at + 1 };
297
+ }
298
+
299
+ /** Is the `$` at `at` escaped by an odd run of `\` before it? */
300
+ function escapedAt(text: string, at: number): boolean {
301
+ let n = 0;
302
+ for (let j = at - 1; j >= 0 && text[j] === "\\"; j--) n++;
303
+ return n % 2 === 1;
304
+ }
305
+
306
+ /**
307
+ * The faults in one statement-frame word `[at, end)` (a run with no
308
+ * whitespace, separator, quote, bracket or `\` in it), and the token state it
309
+ * leaves for the next part.
310
+ *
311
+ * - **`$` (#506).** Outside a string a `$` takes a name (`[A-Za-z0-9]+`) or
312
+ * `$"…"`; `$_`, `$-`, `$.`, `$` before a space and a bare trailing `$` are
313
+ * `syntax error` at the byte after it. The name is a closed part, so
314
+ * `$a_b`, `$x$x` and `$a->1` are glued tokens. `$(…)`, `$[…]` and `${…}`
315
+ * are rejected too, at the end of the group, and `$$v` after the name;
316
+ * both are left unreported.
317
+ * - **Declared names (#506).** The first operand of `:local`, `:global`,
318
+ * `:set`, `:for` and `:foreach` is a name: `[A-Za-z0-9]+` or a string. Any
319
+ * other byte after the name is `expected end of command` there (`a_b`,
320
+ * `a-b`, `a.b`, `a~b`, `a$v`), and for `:local`, `:global` and `:foreach`
321
+ * so is any first byte that is not alphanumeric (`:local $a`, `:local -a`).
322
+ * `:foreach` takes a comma list (`a,b`, `a,"b"`), and an empty slot in it
323
+ * is rejected (`a,`, `,a`, `a,,b`). A `name=` operand is a named argument
324
+ * and is not read here.
325
+ */
326
+ function wordFaults(
327
+ masked: string,
328
+ frame: StatementFrame & { token: StatementToken },
329
+ at: number,
330
+ end: number,
331
+ ): GroupFault | null {
332
+ const { token } = frame;
333
+ const glued = gluedAt(masked, frame, at);
334
+ if (glued !== null || frame.settled) return glued;
335
+ let j = at;
336
+ if (
337
+ token.start === at &&
338
+ frame.positionals === 0 &&
339
+ frame.verb !== null &&
340
+ NAME_DIRECTIVES.has(frame.verb)
341
+ ) {
342
+ const named = nameFault(masked, frame, at, end);
343
+ if (named !== undefined) return named;
344
+ }
345
+ while (j < end) {
346
+ if (masked[j] !== "$") {
347
+ j++;
348
+ continue;
349
+ }
350
+ if (j > at) {
351
+ token.wordEnd = j;
352
+ const fault = gluedAt(masked, frame, j);
353
+ if (fault !== null || frame.settled) return fault;
354
+ }
355
+ const next = masked[j + 1];
356
+ if (next !== undefined && ALNUM.test(next)) {
357
+ let k = j + 1;
358
+ while (k < end && ALNUM.test(masked[k] as string)) k++;
359
+ token.closedAt = k;
360
+ token.wordEnd = -1;
361
+ if (k < end) return gluedAt(masked, frame, k);
362
+ return null;
363
+ }
364
+ if (next === '"') {
365
+ // `$"name"`: the string branch reads the name, not glued to the `$`.
366
+ token.closedAt = -1;
367
+ token.wordEnd = -1;
368
+ return null;
369
+ }
370
+ // `$$v` is rejected after the name, not at the second `$`.
371
+ if (
372
+ next === "(" ||
373
+ next === "[" ||
374
+ next === "{" ||
375
+ next === "$" ||
376
+ next === SUB
377
+ ) {
378
+ frame.settled = true;
379
+ return null;
380
+ }
381
+ frame.settled = true;
382
+ return {
383
+ code: "invalid-variable",
384
+ start: j,
385
+ end: Math.min(j + 2, masked.length),
386
+ };
387
+ }
388
+ token.wordEnd = end;
389
+ token.closedAt = -1;
390
+ return null;
391
+ }
392
+
393
+ /**
394
+ * The declared-name fault in the word at `at`, `null` when the name is
395
+ * well formed, or `undefined` when the word is not a bare name at all (a
396
+ * `name=` argument, or a `:set`/`:for` operand that is not one).
397
+ */
398
+ function nameFault(
399
+ masked: string,
400
+ frame: StatementFrame & { token: StatementToken },
401
+ at: number,
402
+ end: number,
403
+ ): GroupFault | null | undefined {
404
+ const verb = frame.verb as string;
405
+ const fault = (start: number): GroupFault => {
406
+ frame.settled = true;
407
+ return {
408
+ code: "invalid-name",
409
+ start: Math.min(start, masked.length - 1),
410
+ end: Math.min(start, masked.length - 1) + 1,
411
+ };
412
+ };
413
+ if (!ALNUM.test(masked[at] as string))
414
+ return STRICT_NAME_DIRECTIVES.has(verb) ? fault(at) : undefined;
415
+ let k = at;
416
+ for (;;) {
417
+ while (k < end && ALNUM.test(masked[k] as string)) k++;
418
+ const next = masked[k];
419
+ if (k === end) {
420
+ frame.token.wordEnd = end;
421
+ frame.token.closedAt = -1;
422
+ // `a,"b"`: a string may fill a slot, and the rules after it are the
423
+ // list's, not the glued-token rule's.
424
+ if (verb === "foreach" && masked[k - 1] === ",") frame.settled = true;
425
+ return null;
426
+ }
427
+ if (next === "=") return undefined;
428
+ if (next === "," && verb === "foreach") {
429
+ const after = masked[k + 1];
430
+ if (after !== undefined && ALNUM.test(after)) {
431
+ k++;
432
+ continue;
433
+ }
434
+ if (after === '"' && k + 1 === end) {
435
+ k++;
436
+ continue;
437
+ }
438
+ return fault(k + 1);
439
+ }
440
+ return fault(k);
441
+ }
442
+ }
443
+
444
+ function closeToken(frame: StatementFrame) {
445
+ if (frame.token !== null && !frame.token.head && !frame.token.equals)
446
+ frame.positionals++;
447
+ frame.token = null;
448
+ }
449
+
450
+ /**
451
+ * What the `{` at `at` is in a root directive's slot, by the device-swept
452
+ * `(verb, slot)` table: a program (`:execute {…}`, `:time {…}`, which
453
+ * `braceStartsStatements` does not know), an array (`:local z {…}`,
454
+ * `in={…}`), or unknown.
455
+ */
456
+ function directiveSlotRole(
457
+ masked: string,
458
+ at: number,
459
+ frame: StatementFrame,
460
+ ): "program" | "array" | null {
461
+ if (frame.verb === null) return null;
462
+ const token = frame.token;
463
+ let slot: string;
464
+ if (token === null) slot = `#${frame.positionals}`;
465
+ else if (!token.head && masked[at - 1] === "=")
466
+ slot = masked.slice(token.start, at - 1).toLowerCase();
467
+ else return null;
468
+ if (braceSlotTakesProgram(frame.verb, slot)) return "program";
469
+ return braceSlotTakesArray(frame.verb, slot) ? "array" : null;
470
+ }
471
+
472
+ const CLOSER = { "(": ")", "[": "]", "{": "}" } as const;
473
+
474
+ /**
475
+ * Find every group fault in an analyzed document. `masked` is its comment
476
+ * mask; `text` is read only to tell a masked comment line from spaces.
477
+ */
478
+ export function findGroupFaults(text: string, masked: string): GroupFault[] {
479
+ const faults: GroupFault[] = [];
480
+ const frames: Frame[] = [statementFrame("")];
481
+ const top = (): Frame => frames[frames.length - 1] as Frame;
482
+ const report = (fault: GroupFault, frame: ArrayFrame | ExpressionFrame) => {
483
+ faults.push(fault);
484
+ frame.done = true;
485
+ };
486
+ // A completed operand (a word, string, `$name` or closed group).
487
+ const operand = () => {
488
+ const frame = top();
489
+ if (frame.kind === "array") {
490
+ frame.element = true;
491
+ frame.last = "element";
492
+ } else if (frame.kind === "expression") {
493
+ frame.empty = false;
494
+ frame.wantOperand = false;
495
+ frame.pending = null;
496
+ }
497
+ };
498
+
499
+ // H5, as `segment.ts` → `maskUncached` reads it: a `\` continuation runs on
500
+ // across blank lines and line-leading comment lines, and a blank line after
501
+ // a comment line ends it. CHR 7.24.5 and 7.23.8 join `(1 +\⏎# c⏎2)` and
502
+ // `(1 +\⏎⏎2)`, and reject `(1 +\⏎# c⏎⏎2)` at the blank line.
503
+ let cont: "none" | "escape" | "comment" = "none";
504
+ let contLineStart = false;
505
+ let i = 0;
506
+ while (i < masked.length) {
507
+ const c = masked[i] as string;
508
+ const frame = top();
509
+ if (cont !== "none") {
510
+ if (c === "\n" && cont === "escape") {
511
+ contLineStart = true;
512
+ i++;
513
+ continue;
514
+ }
515
+ if (text[i] === "#" && contLineStart) {
516
+ const newline = masked.indexOf("\n", i);
517
+ cont = "comment";
518
+ contLineStart = true;
519
+ i = newline < 0 ? masked.length : newline + 1;
520
+ continue;
521
+ }
522
+ if (isSpace(c)) contLineStart = false;
523
+ else cont = "none";
524
+ }
525
+ const continued = continuationLength(masked, i);
526
+ if (continued > 0 || isSpace(c)) {
527
+ if (continued > 0) {
528
+ cont = "escape";
529
+ contLineStart = true;
530
+ }
531
+ if (frame.kind === "statement") closeToken(frame);
532
+ i += Math.max(continued, 1);
533
+ continue;
534
+ }
535
+ if (c === '"') {
536
+ const end = scanQuotedString(masked, i).end;
537
+ if (frame.kind === "statement") {
538
+ openToken(frame, i);
539
+ if (readsTokens(frame)) {
540
+ const glued = gluedAt(masked, frame, i);
541
+ if (glued !== null) faults.push(glued);
542
+ frame.token.closedAt = end;
543
+ frame.token.wordEnd = -1;
544
+ // `:foreach "a",b in=…` parses: a string fills a counter slot.
545
+ if (
546
+ frame.verb === "foreach" &&
547
+ frame.positionals === 0 &&
548
+ frame.token.start === i
549
+ )
550
+ frame.settled = true;
551
+ // `"a$"v""` is ONE string on the device, which reads `$"` as a
552
+ // quoted name; every scanner here ends it at the second quote.
553
+ if (masked[end - 2] === "$" && !escapedAt(masked, end - 2))
554
+ frame.settled = true;
555
+ }
556
+ } else operand();
557
+ i = end;
558
+ continue;
559
+ }
560
+ if (c === "(" || c === "[" || c === "{") {
561
+ if (frame.kind === "statement") {
562
+ if (readsTokens(frame)) {
563
+ const glued = gluedAt(masked, frame, i);
564
+ if (glued !== null) faults.push(glued);
565
+ } else if (
566
+ frame.token === null &&
567
+ !frame.lead &&
568
+ !frame.settled &&
569
+ frame.positionals === 0 &&
570
+ frame.verb !== null &&
571
+ STRICT_NAME_DIRECTIVES.has(frame.verb)
572
+ ) {
573
+ // `:local (a)`, `:local [x]`, `:local {a}`: a name never starts
574
+ // with a group (`syntax error` at it).
575
+ frame.settled = true;
576
+ faults.push({ code: "invalid-name", start: i, end: i + 1 });
577
+ }
578
+ }
579
+ const query = frame.query;
580
+ const whereEnd =
581
+ frame.kind === "statement" || frame.kind === "expression"
582
+ ? frame.whereEnd
583
+ : null;
584
+ if (
585
+ ((frame.kind === "array" || frame.kind === "expression") &&
586
+ frame.done) ||
587
+ (frame.kind === "opaque" && frame.muted)
588
+ ) {
589
+ frames.push({ kind: "opaque", close: CLOSER[c], query, muted: true });
590
+ i++;
591
+ continue;
592
+ }
593
+ // #509: the top level and scope bodies are `args.ts`'s; a bracket
594
+ // command and a group are only visible here.
595
+ const floor =
596
+ frame.kind === "expression" ||
597
+ (frame.kind === "statement" && frame.char === "[")
598
+ ? whereEnd
599
+ : null;
600
+ const role =
601
+ c === "{" && frame.kind === "statement"
602
+ ? braceStartsStatements(masked, i)
603
+ ? "program"
604
+ : directiveSlotRole(masked, i, frame)
605
+ : null;
606
+ // A body (`do={`) is never a comparison, though `where` is still open:
607
+ // corpus `eworm/hotspot-to-wpa.template.rsc` interleaves two `:if`s.
608
+ if (
609
+ c === "{" &&
610
+ role !== "program" &&
611
+ floor !== null &&
612
+ comparesNamedValue(masked, i, floor)
613
+ ) {
614
+ // The device stops at this brace, so neither its contents nor the
615
+ // rest of the enclosing group are read.
616
+ faults.push({ code: "named-value-brace", start: i, end: i + 1 });
617
+ if (frame.kind === "expression") frame.done = true;
618
+ frames.push({ kind: "opaque", close: "}", query, muted: true });
619
+ if (frame.kind === "statement") openToken(frame, i);
620
+ i++;
621
+ continue;
622
+ }
623
+ if (c === "[") frames.push(statementFrame("["));
624
+ else if (c === "(")
625
+ frames.push({
626
+ kind: "expression",
627
+ open: i,
628
+ wantOperand: true,
629
+ empty: true,
630
+ pending: null,
631
+ query,
632
+ whereEnd,
633
+ done: false,
634
+ });
635
+ else if (role === "program") frames.push(statementFrame("{"));
636
+ else if (
637
+ frame.kind === "opaque" ||
638
+ (frame.kind === "statement" && role !== "array")
639
+ )
640
+ frames.push({ kind: "opaque", close: "}", query, muted: false });
641
+ else
642
+ frames.push({
643
+ kind: "array",
644
+ open: i,
645
+ last: "open",
646
+ commaAt: -1,
647
+ element: false,
648
+ named: false,
649
+ query,
650
+ done: false,
651
+ });
652
+ // After the slot was read: the brace starts or continues a token.
653
+ if (frame.kind === "statement") openToken(frame, i);
654
+ i++;
655
+ continue;
656
+ }
657
+ if (c === ")" || c === "]" || c === "}") {
658
+ const matches =
659
+ frame.kind === "expression"
660
+ ? c === ")"
661
+ : frame.kind === "array"
662
+ ? c === "}"
663
+ : frame.kind === "opaque"
664
+ ? c === frame.close
665
+ : frame.char === (c === "]" ? "[" : c === "}" ? "{" : "");
666
+ if (!matches || frames.length === 1) {
667
+ i++;
668
+ continue;
669
+ }
670
+ frames.pop();
671
+ const parent = top();
672
+ if (parent.kind === "statement" && parent.token !== null) {
673
+ parent.token.closedAt = i + 1;
674
+ parent.token.wordEnd = -1;
675
+ }
676
+ if (frame.kind === "expression" && !frame.done && frame.wantOperand) {
677
+ if (frame.empty)
678
+ faults.push({
679
+ code: "empty-group",
680
+ start: frame.open,
681
+ end: i + 1,
682
+ detail: "(",
683
+ });
684
+ else if (frame.pending !== null)
685
+ faults.push({
686
+ code: "missing-operand",
687
+ start: frame.pending.start,
688
+ end: frame.pending.start + frame.pending.spelling.length,
689
+ detail: frame.pending.spelling,
690
+ });
691
+ }
692
+ if (frame.kind === "array" && !frame.done) {
693
+ if (frame.last === "comma" && !frame.named)
694
+ faults.push({
695
+ code: "missing-operand",
696
+ start: frame.commaAt,
697
+ end: frame.commaAt + 1,
698
+ detail: ",",
699
+ });
700
+ else if (!frame.element && parent.kind === "statement")
701
+ faults.push({
702
+ code: "empty-group",
703
+ start: frame.open,
704
+ end: i + 1,
705
+ detail: "{",
706
+ });
707
+ }
708
+ operand();
709
+ i++;
710
+ continue;
711
+ }
712
+
713
+ if (frame.kind === "statement") {
714
+ if (c === ";" || c === "\n") {
715
+ frames[frames.length - 1] = statementFrame(frame.char);
716
+ i++;
717
+ continue;
718
+ }
719
+ const end = statementWordEnd(masked, i);
720
+ const word = masked.slice(i, end);
721
+ const token = openToken(frame, i);
722
+ if (token.head && token.start === i) {
723
+ // The head word runs to its first `=` too: `a=b@c` is no head.
724
+ const cut = word.indexOf("=");
725
+ const at = (cut < 0 ? word : word.slice(0, cut)).indexOf("@");
726
+ if (at >= 0)
727
+ faults.push({
728
+ code: "invalid-at-sign",
729
+ start: i + at,
730
+ end: i + at + 1,
731
+ });
732
+ // A `:` directive, or a bare declaration: `local where (comment={})`
733
+ // is `/localname=$where` at the root on CHR 7.24.5 and 7.23.8 (in a
734
+ // menu it is `bad command name local`). A bare `set` or `find` may be
735
+ // a menu command, so it stays unread.
736
+ frame.verb = word.startsWith(":")
737
+ ? word.slice(1)
738
+ : BARE_DECLARATIONS.has(word)
739
+ ? word
740
+ : null;
741
+ }
742
+ if (readsTokens(frame)) {
743
+ const fault = wordFaults(masked, frame, i, end);
744
+ if (fault !== null) faults.push(fault);
745
+ }
746
+ if (word.includes("=")) token.equals = true;
747
+ const lower = word.toLowerCase();
748
+ // A directive's operand is a name, never a query: `:local where
749
+ // (comment={})` parses, and `:local where (1 +)` is a syntax error.
750
+ if (
751
+ frame.verb === null &&
752
+ token.start === i &&
753
+ (lower === "where" || lower === "find")
754
+ ) {
755
+ frame.query = true;
756
+ if (lower === "where") frame.whereEnd = end;
757
+ }
758
+ i = end;
759
+ continue;
760
+ }
761
+
762
+ if (frame.kind === "opaque") {
763
+ i++;
764
+ continue;
765
+ }
766
+
767
+ if (frame.kind === "array") {
768
+ if (frame.done) {
769
+ i++;
770
+ continue;
771
+ }
772
+ if (c === ";" || c === "\n") {
773
+ // A named element takes a trailing `,`: `{a=1,}` is `value=a` and
774
+ // `{a=1,;b=2}` is `value=a;b=2` on CHR 7.24.5 and 7.23.8, while
775
+ // `{1,}`, `{a=,1}` and `{a=1,,2}` are syntax errors.
776
+ if (frame.last === "comma" && !frame.named)
777
+ report(
778
+ {
779
+ code: "missing-operand",
780
+ start: frame.commaAt,
781
+ end: frame.commaAt + 1,
782
+ detail: ",",
783
+ },
784
+ frame,
785
+ );
786
+ else if (
787
+ c === ";" &&
788
+ (frame.last === "open" || frame.last === "separator")
789
+ )
790
+ report(
791
+ {
792
+ code: "misplaced-separator",
793
+ start: i,
794
+ end: i + 1,
795
+ detail: ";",
796
+ group: "{",
797
+ },
798
+ frame,
799
+ );
800
+ frame.last = "separator";
801
+ frame.named = false;
802
+ i++;
803
+ continue;
804
+ }
805
+ if (c === "=" && frame.last === "element" && !frame.named) {
806
+ frame.named = true;
807
+ frame.last = "key";
808
+ i++;
809
+ continue;
810
+ }
811
+ if (c === ",") {
812
+ if (frame.last !== "element")
813
+ report(
814
+ { code: "missing-operand", start: i, end: i + 1, detail: "," },
815
+ frame,
816
+ );
817
+ frame.last = "comma";
818
+ frame.commaAt = i;
819
+ i++;
820
+ continue;
821
+ }
822
+ if (c === "@" && !frame.query) {
823
+ report({ code: "invalid-at-sign", start: i, end: i + 1 }, frame);
824
+ i++;
825
+ continue;
826
+ }
827
+ frame.element = true;
828
+ frame.last = "element";
829
+ i++;
830
+ continue;
831
+ }
832
+
833
+ // An expression group.
834
+ if (frame.done) {
835
+ i++;
836
+ continue;
837
+ }
838
+ if (c === ";" || c === "\n") {
839
+ report(
840
+ {
841
+ code: "misplaced-separator",
842
+ start: i,
843
+ end: i + 1,
844
+ detail: c,
845
+ group: "(",
846
+ },
847
+ frame,
848
+ );
849
+ i++;
850
+ continue;
851
+ }
852
+ if (frame.query) {
853
+ // Structural checks only (module header): any other byte is content.
854
+ frame.empty = false;
855
+ frame.wantOperand = false;
856
+ i++;
857
+ continue;
858
+ }
859
+ if (c === "@") {
860
+ report({ code: "invalid-at-sign", start: i, end: i + 1 }, frame);
861
+ i++;
862
+ continue;
863
+ }
864
+ if (c === "$") {
865
+ const next = masked[i + 1];
866
+ if (next !== undefined && /[A-Za-z0-9_]/.test(next)) {
867
+ let j = i + 1;
868
+ while (j < masked.length && /[A-Za-z0-9_]/.test(masked[j] as string))
869
+ j++;
870
+ operand();
871
+ i = j;
872
+ continue;
873
+ }
874
+ // `$"name"` and `$(…)`/`$[…]`/`${…}`: the operand is what follows.
875
+ if (next === '"' || next === "(" || next === "[" || next === "{") {
876
+ i++;
877
+ continue;
878
+ }
879
+ frame.done = true;
880
+ i++;
881
+ continue;
882
+ }
883
+ // `.`, `/` and `:` start a word only where an operand is due: `(. 1)` is
884
+ // `( $. 1)` and `(::1)` an address, while `(1 .2)` is `(. 1 2)`.
885
+ if (isWordByte(c) && (/[A-Za-z0-9_]/.test(c) || frame.wantOperand)) {
886
+ let j = i;
887
+ while (j < masked.length && isWordByte(masked[j])) j++;
888
+ const word = masked.slice(i, j);
889
+ if (word === "any" || (!frame.wantOperand && WORD_OPERATORS.has(word))) {
890
+ frame.empty = false;
891
+ frame.wantOperand = true;
892
+ frame.pending = { start: i, spelling: word };
893
+ } else operand();
894
+ i = j;
895
+ continue;
896
+ }
897
+ if (OPERATOR_BYTES.includes(c)) {
898
+ const spelling =
899
+ frame.wantOperand && PREFIX.has(c)
900
+ ? c
901
+ : (INFIX_SPELLINGS.find((s) => masked.startsWith(s, i)) ?? c);
902
+ if (frame.wantOperand && !PREFIX.has(c)) {
903
+ report(
904
+ {
905
+ code: "missing-operand",
906
+ start: i,
907
+ end: i + spelling.length,
908
+ detail: spelling,
909
+ },
910
+ frame,
911
+ );
912
+ i += spelling.length;
913
+ continue;
914
+ }
915
+ frame.empty = false;
916
+ frame.wantOperand = true;
917
+ frame.pending = { start: i, spelling };
918
+ i += spelling.length;
919
+ continue;
920
+ }
921
+ // `#`, `?`, a backtick, `'`, a stray `\`, a non-ASCII placeholder: another
922
+ // rule's byte, and nothing here can say what the group means after it.
923
+ frame.done = true;
924
+ i++;
925
+ }
926
+ return faults;
927
+ }