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

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