@tikoci/centrs 0.1.3 → 0.1.6

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 (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
@@ -0,0 +1,1749 @@
1
+ /**
2
+ * Statement-scope ARGUMENT lexing for `explain` (#202c).
3
+ *
4
+ * Phase 0.5 promoted nine analyzers that answer where a statement STARTS, where
5
+ * its menu ends and its verb begins — and stop there. `structure.statements[]`
6
+ * has carried `path` + `verb` and no arguments since #202a, with the gap
7
+ * written down (`commands/explain/README.md` → *What phase 1 offline actually
8
+ * emits*): splitting one statement's arguments needs a lexer for quoted values,
9
+ * `[…]` selectors and `?` queries at statement scope, and it was parked until
10
+ * `--curl` made it load-bearing. This is that lexer.
11
+ *
12
+ * ## Three readings, one walk
13
+ *
14
+ * `walkArguments` owns the boundary rules — where a token starts, where it ends,
15
+ * and where it stops being decodable — and three exported readings differ only
16
+ * in what they do at a refusal. They can never disagree about a boundary,
17
+ * because there is only one of them.
18
+ *
19
+ * - {@link lexArguments} — **strict, all-or-nothing.** The consumer is a REST
20
+ * mapping that renders a runnable `curl` body. A partially-read argument
21
+ * list is worse there than no list at all: dropping one token silently
22
+ * changes what the rendered command DOES, which is the fabrication the
23
+ * fail-closed floor exists to prevent. So every token must be decided, or
24
+ * this decides nothing and says why. The price is measured rather than
25
+ * assumed — see the abstention rates in the #202c PR body.
26
+ * - {@link lexValueAnchors} — **prefix-safe.** An unreadable later expression
27
+ * must not erase an earlier, independently bounded literal, so it keeps
28
+ * every anchor found before the first refusal and stops there (#225 V1).
29
+ * - {@link lexArgumentTokens} — **skip-tolerant.** The reading a token-level
30
+ * RULE wants (#316). A refusal whose token boundary is still knowable is
31
+ * published as a located token with `undecided` set and no `value`, and the
32
+ * walk carries on. It reaches 100% of the corpus's argument-bearing
33
+ * statements against the strict reading's 51.8%; the skip-vs-prefix fork is
34
+ * priced by `bun run explain:arg-reach` and decided in
35
+ * `commands/explain/README.md` → *The token stream a rule reads*.
36
+ *
37
+ * A refusal reason is a fact about the input, not a reading-specific message:
38
+ * all three name the same one for the same byte. The one place they differ is
39
+ * where the tolerant walk needs something the strict one never asked for — an
40
+ * unclosed `[…]` is `a substitution or expression value` to a reading that
41
+ * refuses it on sight, and `an unclosed structured argument value` to the one
42
+ * that went looking for the boundary it would have resumed from.
43
+ *
44
+ * ## What it refuses, and why each refusal is a fact rather than a gap
45
+ *
46
+ * - **A value that is not a literal.** `[…]` command substitution, `(…)`
47
+ * expression, `$var`, `{…}` array — the token's VALUE is whatever RouterOS
48
+ * computes at run time, so offline has no value to send. (`canonicalize
49
+ * ExecuteCommand` takes the same line for `[`/`]` and falls back to script
50
+ * mode, `src/execute.ts`.)
51
+ * - **An escape inside a quoted value.** `\n`/`\xHH`/`\"` need a decoder to
52
+ * turn source bytes into the value the device receives, and a decoder that
53
+ * is wrong about one escape corrupts a write. Decoding is grounded work
54
+ * (#225's probe matrix territory), not something to guess at here.
55
+ * - **A name that is not a name.** `{a=1;b=2}` fuses an array literal into
56
+ * what looks like `name=value`, which the phase-0 Q0 catalog measured as the
57
+ * IL oracle's own worst confusion (383 corpus hits). A token whose left side
58
+ * is not a bare RouterOS name is not read as an attribute.
59
+ * - **A `\<newline>` continuation.** The bytes of a continued value are not
60
+ * contiguous, so a `valueSpan` over them would be a lie. `verbsplit.ts`'s
61
+ * word scanner handles continuations for the run END; a VALUE needs its
62
+ * interior, which is exactly what a continuation breaks.
63
+ *
64
+ * Each of those is a refusal of the VALUE, and the tolerant reading publishes
65
+ * the token anyway with the same reason attached — which is sound precisely
66
+ * because `Argument.value` stays absent. None of them becomes renderable.
67
+ *
68
+ * ## It emits no defects
69
+ *
70
+ * Every defect class this scan could raise (`unterminated-string`,
71
+ * `bad-escape`, `unclosed`) is already located document-wide by `segment.ts` and
72
+ * `symbols.ts`, whose regions are the ratified ones. Re-raising them here with
73
+ * statement-relative regions would double-report the same byte range under two
74
+ * spans, which `attributeDefects` (`src/explain.ts`) cannot de-duplicate because
75
+ * the regions differ. A defect the caller can see is not this module's to
76
+ * re-announce; the abstention reason names it instead.
77
+ */
78
+
79
+ import { isScopeBrace, isScopeBraceIn } from "./blocks.ts";
80
+ import { braceSlotAcceptsBrace, braceSlotTakesArray } from "./brace-slots.ts";
81
+ import { braceStartsStatements } from "./scope-brace.ts";
82
+ import {
83
+ type MaskedRange,
84
+ maskComments,
85
+ maskOf,
86
+ scanQuotedString,
87
+ } from "./segment.ts";
88
+
89
+ /** A RouterOS argument name: bare word, optionally dotted (`.id`, `.proplist`). */
90
+ const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
91
+
92
+ /** What one argument token is. */
93
+ export type ArgumentKind = "attribute" | "positional" | "query";
94
+
95
+ /**
96
+ * One argument token, located in the text it was lexed from.
97
+ *
98
+ * The ordered list is the primary shape and the object view is derived from it
99
+ * (the phase-0 normal form: "an ordered token/argument list with spans, never a
100
+ * pre-reduced args object"). Order and multiplicity are observable here and are
101
+ * flattened by {@link ArgumentReading.args}, so a consumer that needs to know a
102
+ * name was given twice still can.
103
+ */
104
+ export interface Argument {
105
+ kind: ArgumentKind;
106
+ /** The whole token, in the coordinate space of the text passed to the lexer. */
107
+ span: { start: number; end: number };
108
+ /** Attribute name / query word. Absent on a positional. */
109
+ name?: string;
110
+ /**
111
+ * The token's LITERAL value, quotes removed — for an attribute the part after
112
+ * `=`, for a positional the whole token.
113
+ *
114
+ * **Absent means there is no literal value**, whatever the kind: the source
115
+ * spells a substitution, an expression, or an escape this phase does not
116
+ * decode. A consumer rendering a runnable command must read this field, not
117
+ * `text`, and must treat absence as "not renderable" — `text` is the source
118
+ * bytes, which are not the value the device would receive. A query is always
119
+ * absent: `?` words are a grammar of their own and splitting them is not this
120
+ * lexer's claim to make.
121
+ */
122
+ value?: string;
123
+ /**
124
+ * Where the value sits, quotes INCLUDED.
125
+ *
126
+ * In the strict {@link lexArguments} reading this is absent whenever `value`
127
+ * is. The tolerant {@link lexArgumentTokens} reading also publishes it for a
128
+ * value whose BYTES are located but whose literal is unknown
129
+ * (`interface=$x`), which is what lets a positional fill claim the `name=`
130
+ * run of a token no consumer may render. `undecided` is the field that says
131
+ * which of the two this is.
132
+ */
133
+ valueSpan?: { start: number; end: number };
134
+ /** The token verbatim, for a positional and for rendering. */
135
+ text: string;
136
+ /**
137
+ * Why this token's value or shape was not decoded — tolerant reading only.
138
+ *
139
+ * Never set by {@link lexArguments}: the strict reading publishes no token it
140
+ * could not decide, so a consumer holding an {@link ArgumentsRead} never sees
141
+ * this field. {@link lexArgumentTokens} sets it on exactly the tokens the
142
+ * strict walk would have aborted at, carrying the same reason string the
143
+ * strict refusal would have carried. A token with `undecided` set has no
144
+ * `value` and must never be rendered into a runnable command.
145
+ */
146
+ undecided?: string;
147
+ }
148
+
149
+ /** Internal token classification; quote state is stripped from the REST view. */
150
+ interface ReadArgument extends Argument {
151
+ literalQuoted?: boolean;
152
+ /** A located structured literal that the strict REST view still refuses. */
153
+ sourceShape?: "array";
154
+ }
155
+
156
+ /** Every token decided. */
157
+ export interface ArgumentsRead {
158
+ read: true;
159
+ tokens: Argument[];
160
+ /**
161
+ * The derived object view: attribute names to values, LAST occurrence winning
162
+ * — the reading `canonicalizeExecuteCommand` already applies to a repeated
163
+ * name (`src/execute.ts`), so the gate and the analysis cannot disagree about
164
+ * a duplicate. `tokens` is where multiplicity survives.
165
+ */
166
+ args: Record<string, string>;
167
+ /** `?`-prefixed query words, in order, verbatim. */
168
+ queries: string[];
169
+ /**
170
+ * Bare tokens, in order — an id (`*1`), a `where`, a `find` operand — as
171
+ * SOURCE text. A consumer needing the value reads `tokens` (see
172
+ * {@link Argument.value}); this list exists so the common "is there an id"
173
+ * question needs no walk.
174
+ */
175
+ positional: string[];
176
+ }
177
+
178
+ /** Nothing decided, and the reason. */
179
+ export interface ArgumentsUnread {
180
+ read: false;
181
+ /** Why the lexer refused. Rendered into the transport basis, never dropped. */
182
+ why: string;
183
+ }
184
+
185
+ export type ArgumentReading = ArgumentsRead | ArgumentsUnread;
186
+
187
+ /** What a located value IS: a command's argument, or one array member. */
188
+ export type ValueAnchorKind = Exclude<ArgumentKind, "query"> | "element";
189
+
190
+ /** One safely located value shape, before any later unreadable token. */
191
+ export interface ValueAnchor {
192
+ kind: ValueAnchorKind;
193
+ /** The whole argument token, or the whole member run including its key. */
194
+ tokenSpan: { start: number; end: number };
195
+ /** Attribute name, or an array member's key; absent on a positional. */
196
+ name?: string;
197
+ /** The literal's source bytes, quotes included. */
198
+ valueSpan: { start: number; end: number };
199
+ /**
200
+ * Decoded scalar value, or exact source spelling when `sourceShape` is set.
201
+ *
202
+ * `str` is the exception in both directions: the leftover of a `{a=}` member
203
+ * is the DECODED name (`{"a b"=}` carries `a b`), because the bytes that
204
+ * spell it are the key's, not a value's.
205
+ */
206
+ value: string;
207
+ /** True only when one quoted run encloses the whole scalar value. */
208
+ quoted: boolean;
209
+ /**
210
+ * Present only when the SOURCE proves the type, rather than scalar decoding.
211
+ *
212
+ * `array` is proved by the delimiters. The other two are proved by an `=`
213
+ * that does not bind a key (#258): with an empty right side the device drops
214
+ * the sign and keeps the name as a `str`, and with a left side that is not a
215
+ * name the sign COMPARES, which returns `bool` whatever the operands hold.
216
+ * None of the three survives {@link valueShapeHints}, which reads a decoded
217
+ * scalar and cannot see the sign.
218
+ */
219
+ sourceShape?: "array" | "str" | "bool";
220
+ /**
221
+ * Index, in this same list, of the array literal this member belongs to.
222
+ *
223
+ * An index rather than a nested list because the list stays ONE ordered
224
+ * sequence of located values — the phase-0 normal form — and because a
225
+ * consumer that only wants spans should not have to walk a tree. Members
226
+ * follow their container immediately, so a parent index is always smaller.
227
+ */
228
+ parent?: number;
229
+ }
230
+
231
+ /**
232
+ * Skip-tolerant argument tokens; `complete: false` explains where the walk
233
+ * stopped, which is a rarer event than in the strict reading.
234
+ */
235
+ export type ArgumentTokenReading =
236
+ | { complete: true; tokens: Argument[] }
237
+ | { complete: false; tokens: Argument[]; why: string };
238
+
239
+ /** Prefix-safe value anchoring; `complete: false` explains where scanning stopped. */
240
+ export type ValueAnchorReading =
241
+ | { complete: true; anchors: ValueAnchor[] }
242
+ | { complete: false; anchors: ValueAnchor[]; why: string };
243
+
244
+ /** A refusal, carrying the reason a consumer quotes instead of a bare `unknown`. */
245
+ function unread(why: string): ArgumentsUnread {
246
+ return { read: false, why };
247
+ }
248
+
249
+ /** Where a token ends, and — tolerant walk only — why it was not decoded. */
250
+ interface ScanToken {
251
+ end: number;
252
+ why?: string;
253
+ }
254
+
255
+ /**
256
+ * Whether the `{` at `at` opens a scope, reading the document's mask when the
257
+ * caller anchored this statement and masking `text` itself otherwise.
258
+ *
259
+ * `at` is relative to `text` either way, so callers need no offset arithmetic.
260
+ */
261
+ function scopeBraceAt(
262
+ text: string,
263
+ at: number,
264
+ range: MaskedRange | undefined,
265
+ ): boolean {
266
+ return range === undefined
267
+ ? isScopeBrace(text, at)
268
+ : isScopeBraceIn(range, at);
269
+ }
270
+
271
+ /**
272
+ * `{ range }` when the caller supplied one, otherwise nothing — so an omitted
273
+ * range stays absent rather than becoming an explicit `undefined` under
274
+ * `exactOptionalPropertyTypes`.
275
+ */
276
+ function rangeOption(range: MaskedRange | undefined): { range?: MaskedRange } {
277
+ return range === undefined ? {} : { range };
278
+ }
279
+
280
+ interface WalkOptions {
281
+ /** Locate `{…}`/`(…)` literals instead of refusing the statement at one. */
282
+ allowArrayValues?: boolean;
283
+ /**
284
+ * Where `text` sits in its document, with that document's comment mask.
285
+ *
286
+ * A statement's text carries its whole nested `do={…}` subtree, and every
287
+ * pass over the document asks a different one of these readers for the same
288
+ * statement — so each re-derived a mask over bytes the last one had already
289
+ * read, once per pass and once per enclosing level (#322). A caller holding
290
+ * the document's mask passes the statement's range of it instead; the mask of
291
+ * a statement IS the document's mask restricted to that statement, because a
292
+ * statement boundary begins a comment context (see `segment.ts` →
293
+ * `MaskedRange`). Omitted, this reader masks `text` itself as before.
294
+ *
295
+ * Offsets in and out of these readers stay relative to `text`. The range is
296
+ * read for the mask, and for the questions whose answer depends on the
297
+ * DOCUMENT's statement index rather than this statement's.
298
+ */
299
+ range?: MaskedRange;
300
+ /**
301
+ * Publish a token the walk cannot DECODE instead of aborting at it (#316).
302
+ *
303
+ * Off, every refusal stops the walk — the reading the REST/`curl` consumer
304
+ * needs. On, a refusal whose token boundary is still knowable becomes an
305
+ * {@link Argument} with `undecided` set and no `value`, and the walk carries
306
+ * on to the next token. The boundary rules do not move: both walks agree
307
+ * about where every token starts and ends, and the tolerant one only differs
308
+ * in whether it returns there.
309
+ */
310
+ tolerant?: boolean;
311
+ /**
312
+ * The root scripting directive whose slots decide whether a `{…}` here is an
313
+ * array literal, or undefined when this statement has none.
314
+ *
315
+ * A command argument never takes one. On CHR 7.23.3 `/console/inspect`
316
+ * classes the `{` byte `error` and `:parse` refuses the statement for
317
+ * `/ip/route/add comment={1;2}`, `/ip/dns/set servers={1.1.1.1;8.8.8.8}`
318
+ * (a LIST-typed attribute, which rules out a schema-shaped reading),
319
+ * `/interface/print .proplist={name;comment}`,
320
+ * `/ip/route/print where comment={1;2}`, the relative spelling
321
+ * `ip route add comment={1;2}`, and `:log info message={1;2}`. A `(…)`
322
+ * array is accepted in every one of those positions, so only the brace is
323
+ * gated.
324
+ *
325
+ * Being a directive is necessary but NOT sufficient — `:delay {1;2}` and
326
+ * `:local name={1;2}` are syntax errors too — so which slot it is decides:
327
+ * see {@link braceSlotTakesArray}.
328
+ */
329
+ directiveVerb?: string;
330
+ }
331
+
332
+ /** How deep member scanning descends before it stops and keeps only the shape. */
333
+ const MAX_MEMBER_DEPTH = 8;
334
+
335
+ /**
336
+ * The argument name whose value the brace at `at` opens, `null` when the brace
337
+ * IS the token (a positional value), or undefined when it opens neither.
338
+ */
339
+ function braceValueOwner(
340
+ text: string,
341
+ tokenStart: number,
342
+ at: number,
343
+ ): string | null | undefined {
344
+ if (at === tokenStart) return null;
345
+ if (text[at - 1] !== "=") return undefined;
346
+ const name = text.slice(tokenStart, at - 1);
347
+ return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
348
+ }
349
+
350
+ /**
351
+ * Whether a `{` at `at` may open an array literal in this statement.
352
+ *
353
+ * `positionalIndex` is how many positionals the walk has already read, which is
354
+ * this token's own index if it turns out to be one. Position is load-bearing:
355
+ * `:local {1;2}` puts the literal in the NAME slot and does not parse, while
356
+ * `:local z {1;2}` puts it in the VALUE slot and does.
357
+ */
358
+ function braceOpensArray(
359
+ text: string,
360
+ tokenStart: number,
361
+ at: number,
362
+ positionalIndex: number,
363
+ options: WalkOptions,
364
+ ): boolean {
365
+ const verb = options.directiveVerb;
366
+ if (verb === undefined) return false;
367
+ const owner = braceValueOwner(text, tokenStart, at);
368
+ if (owner === undefined) return false;
369
+ return braceSlotTakesArray(
370
+ verb,
371
+ owner === null ? `#${positionalIndex}` : owner,
372
+ );
373
+ }
374
+
375
+ /**
376
+ * The ONE argument token walk, shared by both readings.
377
+ *
378
+ * Yields each decided token in order, or one refusal string and stops. The
379
+ * strict {@link lexArguments} discards everything on that refusal while
380
+ * {@link lexValueAnchors} keeps the prefix — but they must never disagree about
381
+ * where a token starts, ends, or becomes unreadable, so the boundary rules live
382
+ * here once.
383
+ */
384
+ function* walkArguments(
385
+ text: string,
386
+ from: number,
387
+ options: WalkOptions = {},
388
+ ): Generator<ReadArgument | string> {
389
+ const structural = maskOf(options.range) ?? maskComments(text);
390
+ let i = Math.max(0, from);
391
+ // How many positionals have been read, which is the NEXT positional's index.
392
+ let positionals = 0;
393
+ while (i < text.length) {
394
+ // The MASKED view decides whitespace, because `maskComments` blanks a
395
+ // continuation comment that `text` still spells with a `#`. Reading `text`
396
+ // here left the walk and `scanToken` disagreeing about the same byte: the
397
+ // scan broke on the masked space and returned a zero-length token, so
398
+ // `readToken` emitted an empty positional and `i` never advanced — an
399
+ // `explain` that never returns on `list={1;2} \<nl># c<nl> in=foo`. The
400
+ // strict view uses the same mask: a real continuation comment is whitespace,
401
+ // not positional arguments. Found in review of #243/#245.
402
+ const c = structural[i] as string;
403
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") {
404
+ i++;
405
+ continue;
406
+ }
407
+ // A continuation between tokens is harmless — it is whitespace RouterOS
408
+ // removes — but one INSIDE a token breaks the value's contiguity, which
409
+ // `scanToken` reports.
410
+ const continuation = continuationLength(text, i);
411
+ if (continuation > 0) {
412
+ i += continuation;
413
+ continue;
414
+ }
415
+ const token = scanToken(text, structural, i, positionals, options);
416
+ if (typeof token === "string") {
417
+ yield token;
418
+ return;
419
+ }
420
+ // A refusal whose extent collapsed to nothing leaves no boundary to resume
421
+ // from — resuming at `i` would scan the same byte forever. The tolerant
422
+ // walk stops there exactly as the strict one does.
423
+ if (token.why !== undefined && token.end <= i) {
424
+ yield token.why;
425
+ return;
426
+ }
427
+ // `readToken` is skipped entirely for an undecodable token rather than
428
+ // having its result stripped: `literalValue` would read `$x` as the bare
429
+ // run `$x` and publish it as a literal, which is precisely the fabrication
430
+ // the refusal exists to prevent. Shape only, never a value.
431
+ const read =
432
+ token.why === undefined
433
+ ? readToken(text, structural, i, token.end, options)
434
+ : undecidedToken(text, i, token.end, token.why);
435
+ if (typeof read === "string") {
436
+ if (options.tolerant !== true) {
437
+ yield read;
438
+ return;
439
+ }
440
+ // A `readToken` refusal already knows the token's extent — `scanToken`
441
+ // found it — so the tolerant walk publishes the shape and moves on.
442
+ const located = undecidedToken(text, i, token.end, read);
443
+ if (located.kind === "positional") positionals++;
444
+ yield located;
445
+ i = token.end;
446
+ continue;
447
+ }
448
+ if (read.kind === "positional") positionals++;
449
+ yield read;
450
+ i = token.end;
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Lex the arguments of ONE statement, starting at `from`.
456
+ *
457
+ * `text` is the statement, `from` is where its leading run ended
458
+ * (`VerbSplitCommandReading.argsAt`). Offsets in the result are relative to
459
+ * `text`, so a caller rebases them by the statement's own span; it never throws.
460
+ *
461
+ * Only ASCII text may be passed. The analyzed surface is ASCII by construction
462
+ * (`coordinates.ts` stands one byte in for every non-ASCII one), and a caller
463
+ * that hands over the ORIGINAL text of a non-ASCII statement would get spans
464
+ * that do not map back — `src/explain.ts` verifies the two agree before calling.
465
+ */
466
+ export function lexArguments(
467
+ text: string,
468
+ from: number,
469
+ range?: MaskedRange,
470
+ ): ArgumentReading {
471
+ const tokens: Argument[] = [];
472
+ for (const step of walkArguments(text, from, rangeOption(range))) {
473
+ if (typeof step === "string") return unread(step);
474
+ const publicToken = { ...step };
475
+ delete publicToken.literalQuoted;
476
+ tokens.push(publicToken);
477
+ }
478
+
479
+ const args: Record<string, string> = {};
480
+ const queries: string[] = [];
481
+ const positional: string[] = [];
482
+ for (const token of tokens) {
483
+ if (token.kind === "attribute" && token.name !== undefined)
484
+ args[token.name] = token.value ?? "";
485
+ else if (token.kind === "query") queries.push(token.text);
486
+ else if (token.kind === "positional") positional.push(token.text);
487
+ }
488
+ return { read: true, tokens, args, queries, positional };
489
+ }
490
+
491
+ /**
492
+ * Lex the arguments of ONE statement as a TOKEN STREAM, skipping what it cannot
493
+ * decode (#316).
494
+ *
495
+ * Same text, same `from`, same boundary rules and the same reason strings as
496
+ * {@link lexArguments} — the difference is only what a refusal does. Where the
497
+ * strict reading discards the whole list, this one publishes the token it could
498
+ * not decode with `undecided` set, no `value`, and its bytes located, then
499
+ * carries on to the next token. It is the reading every TOKEN-LEVEL rule wants
500
+ * (`findMissingSeparators`, the `arg` fill), and the one no RENDERING consumer
501
+ * may have: a token with `undecided` set has no value to send.
502
+ *
503
+ * ## Skip-and-continue, not stop-at-refusal
504
+ *
505
+ * The cheaper design — publish the prefix decoded before the first refusal,
506
+ * which is what {@link lexValueAnchors} does for values — was measured and
507
+ * rejected. On the 948-script pinned corpus the prefix reading recovers 3,841
508
+ * argument tokens and finds **zero** additional missing-separator runs, because
509
+ * a refusal is usually the FIRST interesting token in the statement
510
+ * (`… interface=$x /ip/route add …` has an empty prefix). Skipping past it is
511
+ * what reaches the rest of the operand stream, and the corpus numbers for both
512
+ * designs are in `commands/explain/README.md` → *The token stream a rule reads*.
513
+ *
514
+ * ## Where it still stops
515
+ *
516
+ * Two constructs have no knowable end, so neither walk can resume past them: an
517
+ * unterminated string and an unbalanced `(`/`[`/`{`. A `;` is a third, and
518
+ * deliberately so — it is a statement boundary the segmenter owns, and resuming
519
+ * after it would read the NEXT statement's bytes as this one's arguments.
520
+ */
521
+ export function lexArgumentTokens(
522
+ text: string,
523
+ from: number,
524
+ range?: MaskedRange,
525
+ ): ArgumentTokenReading {
526
+ const tokens: Argument[] = [];
527
+ for (const step of walkArguments(text, from, {
528
+ tolerant: true,
529
+ ...rangeOption(range),
530
+ })) {
531
+ if (typeof step === "string") return { complete: false, tokens, why: step };
532
+ const publicToken = { ...step };
533
+ delete publicToken.literalQuoted;
534
+ tokens.push(publicToken);
535
+ }
536
+ return { complete: true, tokens };
537
+ }
538
+
539
+ /**
540
+ * Length of the `\<newline>` continuation at `at`, or 0 — the ONE boundary rule
541
+ * this module and `verbsplit.ts`'s word scanner share.
542
+ *
543
+ * A lone `\r` is deliberately NOT a continuation. Accepting one and then
544
+ * skipping three bytes ate the following character, so an argument the source
545
+ * spells `x` + `comment=2` lexed as plain `comment=2` — a token whose reported
546
+ * name is not the one in the source.
547
+ * That is a silent misread rather than a refusal, which is the one failure this
548
+ * module must not have — and the two scanners disagreeing about where a
549
+ * continuation ends is exactly how `argsAt` and the token stream come to
550
+ * describe different text. Found in review of #202c-1; `\<CR>` alone now falls
551
+ * through to the invalid-escape refusal below.
552
+ */
553
+ function continuationLength(text: string, at: number): 0 | 2 | 3 {
554
+ if (text[at] !== "\\") return 0;
555
+ if (text[at + 1] === "\n") return 2;
556
+ if (text[at + 1] === "\r" && text[at + 2] === "\n") return 3;
557
+ return 0;
558
+ }
559
+
560
+ /**
561
+ * Find where the token starting at `start` ends, or return the reason it is not
562
+ * lexable. A quoted run is skipped by the ONE shared string scanner (#199), so a
563
+ * `"` inside this statement is read exactly as the segmenter read it.
564
+ */
565
+ function scanToken(
566
+ text: string,
567
+ structural: string,
568
+ start: number,
569
+ positionalIndex: number,
570
+ options: WalkOptions,
571
+ ): ScanToken | string {
572
+ let i = start;
573
+ // The first refusal seen while still able to find the token's END. Only the
574
+ // tolerant walk ever sets it; the strict walk returns on the first refusal,
575
+ // so `refuse` hands the reason straight back to be returned (#316).
576
+ let why: string | undefined;
577
+ function refuse(reason: string): string | undefined {
578
+ if (options.tolerant !== true) return reason;
579
+ if (why === undefined) why = reason;
580
+ return undefined;
581
+ }
582
+ // A construct whose END is not knowable. The token stops in FRONT of it,
583
+ // keeping the reason its own bytes earned, and the walk resumes at the
584
+ // construct — where this runs again with nothing scanned (`i === start`) and
585
+ // hands back the unbounded reason, which is why the walk stops. Reporting the
586
+ // recorded reason there instead would say the walk stopped at a value it
587
+ // normally skips, when it stopped because there was no boundary to skip to.
588
+ function unbounded(reason: string): ScanToken | string {
589
+ return why === undefined || i <= start ? reason : { end: i, why };
590
+ }
591
+ while (i < text.length) {
592
+ const c = structural[i] as string;
593
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") break;
594
+ if (c === '"') {
595
+ const scan = scanQuotedString(structural, i);
596
+ if (!scan.closed) return unbounded("unterminated string in an argument");
597
+ i = scan.end;
598
+ continue;
599
+ }
600
+ if (c === "[" || c === "(" || c === "{") {
601
+ const declined = structuredRefusal(
602
+ text,
603
+ c,
604
+ start,
605
+ i,
606
+ positionalIndex,
607
+ options,
608
+ );
609
+ if (declined !== undefined) {
610
+ const abort = refuse(declined);
611
+ if (abort !== undefined) return abort;
612
+ // The reason is recorded; the extent is still the balanced run, which
613
+ // is what lets the walk carry on past a value it cannot decode.
614
+ const skip = delimitedEnd(structural, i);
615
+ if (skip === null)
616
+ return unbounded("an unclosed structured argument value");
617
+ i = skip;
618
+ continue;
619
+ }
620
+ const end = delimitedEnd(structural, i);
621
+ if (end === null)
622
+ return unbounded("an unclosed structured argument value");
623
+ if (hasUnquotedHash(structural, i, end, options.range)) {
624
+ const abort = refuse("an invalid hash in a structured argument value");
625
+ if (abort !== undefined) return abort;
626
+ }
627
+ i = end;
628
+ const next = nextNonWhitespace(structural, i);
629
+ if (continuesExpression(structural, i, next)) {
630
+ const abort = refuse(
631
+ "an expression continuing after a structured value",
632
+ );
633
+ if (abort !== undefined) return abort;
634
+ }
635
+ continue;
636
+ }
637
+ if (c === "$") {
638
+ const abort = refuse("a variable value");
639
+ if (abort !== undefined) return abort;
640
+ i++;
641
+ continue;
642
+ }
643
+ if (c === "\\") {
644
+ if (continuationLength(text, i) > 0) {
645
+ const abort = refuse("a line continuation inside an argument");
646
+ if (abort !== undefined) return abort;
647
+ // The token's BYTES stay contiguous across the continuation even
648
+ // though its value does not, and `verbsplit.ts`'s word scanner already
649
+ // reads them as one word for the run end. So the span is honest while
650
+ // the value stays absent — which is the whole tolerant contract.
651
+ i += continuationLength(text, i);
652
+ continue;
653
+ }
654
+ // The #201 rule: in code a backslash is valid only before whitespace.
655
+ // `symbols.ts` already located the defect; refusing is enough here.
656
+ if (text[i + 1] !== " " && text[i + 1] !== "\t") {
657
+ const abort = refuse("an invalid escape in an argument");
658
+ if (abort !== undefined) return abort;
659
+ i++;
660
+ continue;
661
+ }
662
+ i += 2;
663
+ continue;
664
+ }
665
+ // A `;` is a BOUNDARY the segmenter should already have split on, not a
666
+ // value this walk may skip: resuming after it would read the next
667
+ // statement's bytes as this one's arguments. Both walks stop.
668
+ if (c === ";") return unbounded("a statement separator inside an argument");
669
+ i++;
670
+ }
671
+ return why === undefined ? { end: i } : { end: i, why };
672
+ }
673
+
674
+ /**
675
+ * Why a `[`/`(`/`{` at `at` is not a value this lexer reads, or `undefined`
676
+ * when {@link WalkOptions.allowArrayValues} accepts it as an array literal.
677
+ *
678
+ * Split out of {@link scanToken} so the strict and tolerant walks choose the
679
+ * same reason for the same byte — the tolerant walk then skips the balanced run
680
+ * instead of returning, which is the only difference between them.
681
+ */
682
+ function structuredRefusal(
683
+ text: string,
684
+ c: string,
685
+ start: number,
686
+ at: number,
687
+ positionalIndex: number,
688
+ options: WalkOptions,
689
+ ): string | undefined {
690
+ if (c === "[") return "a substitution or expression value";
691
+ if (!options.allowArrayValues)
692
+ return c === "("
693
+ ? "a substitution or expression value"
694
+ : "an array or block value";
695
+ if (c === "{" && scopeBraceAt(text, at, options.range))
696
+ return "a scope block value";
697
+ if (c === "{" && !braceOpensArray(text, start, at, positionalIndex, options))
698
+ return "a brace value RouterOS does not read as an array here";
699
+ return undefined;
700
+ }
701
+
702
+ /**
703
+ * RouterOS rejects an unquoted `#` inside array/group expressions (#245) — but
704
+ * only where the expression role actually reaches the hash.
705
+ *
706
+ * A `[…]` substitution nested in an array RE-ENTERS a statement context, and
707
+ * the device keeps the hash there as a value. A flat scan cannot tell the two
708
+ * apart, so carry the same delimiter roles `segment.ts` and `symbols.ts` use.
709
+ * Grounded on CHR 7.23.3 `/console/inspect request=highlight`, class at the `#`:
710
+ *
711
+ * `:local z {[:put #test]}` `none` — bracket restores statements
712
+ * `:local z {1;[:put #test]}` `none`
713
+ * `:local z {[:put {#test}]}` `error` — array again inside the bracket
714
+ * `:local z {[:put (1,#test)]}` `error` — group again inside the bracket
715
+ * `:local z {#test}` `error` — the plain array case
716
+ *
717
+ * Skipping whole `[…]` regions instead would wrongly accept rows 3 and 4.
718
+ */
719
+ function hasUnquotedHash(
720
+ text: string,
721
+ start: number,
722
+ end: number,
723
+ range?: MaskedRange,
724
+ ): boolean {
725
+ // `start` is the `{`/`(` of an array-or-group value: not a statement context.
726
+ const statements: boolean[] = [false];
727
+ for (let i = start + 1; i < end - 1; i++) {
728
+ const c = text[i];
729
+ if (c === '"') {
730
+ i = scanQuotedString(text, i).end - 1;
731
+ continue;
732
+ }
733
+ const enclosing = statements[statements.length - 1] === true;
734
+ if (c === "[") statements.push(true);
735
+ else if (c === "{")
736
+ // Only a brace whose enclosing context ALREADY bears statements can be a
737
+ // scope; nested in an array it is another array, so the role is known
738
+ // without the reverse-prefix scan. The short-circuit is what keeps this
739
+ // O(1) per brace on the deep-nesting inputs `explain-write` and Q17 pin.
740
+ statements.push(
741
+ enclosing &&
742
+ braceStartsStatements(
743
+ range?.masked ?? text,
744
+ (range?.start ?? 0) + i,
745
+ range?.start ?? 0,
746
+ ),
747
+ );
748
+ else if (c === "(") statements.push(false);
749
+ else if (c === "]" || c === "}" || c === ")") {
750
+ if (statements.length > 1) statements.pop();
751
+ } else if (c === "#" && statements[statements.length - 1] === false)
752
+ return true;
753
+ }
754
+ return false;
755
+ }
756
+
757
+ function nextNonWhitespace(text: string, from: number): number {
758
+ let i = from;
759
+ while (
760
+ text[i] === " " ||
761
+ text[i] === "\t" ||
762
+ text[i] === "\r" ||
763
+ text[i] === "\n"
764
+ )
765
+ i++;
766
+ return i;
767
+ }
768
+
769
+ function startsDottedArgument(text: string, at: number): boolean {
770
+ const match = text.slice(at).match(/^(\.[A-Za-z][A-Za-z0-9._-]*)=/);
771
+ return match !== null && ARGUMENT_NAME.test(match[1] as string);
772
+ }
773
+
774
+ /** Operators that prove a closed group is only the left side of an expression. */
775
+ function continuesExpression(
776
+ text: string,
777
+ structuredEnd: number,
778
+ at: number,
779
+ ): boolean {
780
+ const c = text[at];
781
+ // A space separates RouterOS arguments. Dotted names are legal arguments,
782
+ // so `{1;2} .proplist=.id` is not the `.` operator. Without the separation,
783
+ // `{1;2}.proplist=...` remains one expression token and must be refused.
784
+ if (c === "." && at > structuredEnd && startsDottedArgument(text, at))
785
+ return false;
786
+ if (c !== undefined && ".,+-*/%&|^~<>=!".includes(c)) return true;
787
+ return /^(?:and|or|in)(?=[ \t\r\n(])/.test(text.slice(at).toLowerCase());
788
+ }
789
+
790
+ /** One balanced `(...)`/`{...}` run, including nested groups and strings. */
791
+ function delimitedEnd(text: string, start: number): number | null {
792
+ const stack: string[] = [];
793
+ for (let i = start; i < text.length; i++) {
794
+ const c = text[i] as string;
795
+ if (c === '"') {
796
+ const scan = scanQuotedString(text, i);
797
+ if (!scan.closed) return null;
798
+ i = scan.end - 1;
799
+ continue;
800
+ }
801
+ if (c === "(" || c === "[" || c === "{") stack.push(c);
802
+ else if (c === ")" || c === "]" || c === "}") {
803
+ const want = c === ")" ? "(" : c === "]" ? "[" : "{";
804
+ if (stack.pop() !== want) return null;
805
+ if (stack.length === 0) return i + 1;
806
+ }
807
+ }
808
+ return null;
809
+ }
810
+
811
+ /** Whether an exact structured source run is an array literal, not grouping. */
812
+ function isArraySource(
813
+ text: string,
814
+ structural: string,
815
+ start: number,
816
+ end: number,
817
+ range: MaskedRange | undefined,
818
+ ): boolean {
819
+ const open = text[start];
820
+ if (open !== "(" && open !== "{") return false;
821
+ if (delimitedEnd(structural, start) !== end) return false;
822
+ if (open === "{")
823
+ return (
824
+ !scopeBraceAt(text, start, range) &&
825
+ structural.slice(start + 1, end - 1).trim().length > 0
826
+ );
827
+
828
+ let depth = 0;
829
+ let memberStart = start + 1;
830
+ let sawComma = false;
831
+ for (let i = start + 1; i < end - 1; i++) {
832
+ const c = structural[i] as string;
833
+ if (c === '"') {
834
+ i = scanQuotedString(structural, i).end - 1;
835
+ continue;
836
+ }
837
+ if (c === "(" || c === "[" || c === "{") depth++;
838
+ else if (c === ")" || c === "]" || c === "}") depth--;
839
+ else if (c === "," && depth === 0) {
840
+ if (structural.slice(memberStart, i).trim().length === 0) return false;
841
+ sawComma = true;
842
+ memberStart = i + 1;
843
+ }
844
+ }
845
+ return sawComma && structural.slice(memberStart, end - 1).trim().length > 0;
846
+ }
847
+
848
+ function startsStructuredSource(text: string, start: number): boolean {
849
+ return text[start] === "(" || text[start] === "{";
850
+ }
851
+
852
+ /** Classify one already-bounded token, or say why it cannot be read. */
853
+ function readToken(
854
+ text: string,
855
+ structural: string,
856
+ start: number,
857
+ end: number,
858
+ options: WalkOptions,
859
+ ): ReadArgument | string {
860
+ const raw = text.slice(start, end);
861
+ const span = { start, end };
862
+ if (raw.startsWith("?")) {
863
+ const divergent = queryDisagreement(raw);
864
+ return divergent ?? { kind: "query", span, name: raw.slice(1), text: raw };
865
+ }
866
+ if (options.allowArrayValues && startsStructuredSource(text, start)) {
867
+ if (isArraySource(text, structural, start, end, options.range))
868
+ return {
869
+ kind: "positional",
870
+ span,
871
+ valueSpan: span,
872
+ sourceShape: "array",
873
+ text: raw,
874
+ };
875
+ return "a grouped or expression value";
876
+ }
877
+
878
+ const eq = unquotedEquals(text, start, end);
879
+ if (eq === null) {
880
+ // A positional: an id, a `where`, a `find` operand, a `:log info` message.
881
+ // It carries a literal `value` under exactly the same rule an attribute
882
+ // does, and NOTHING where that rule refuses — `"…$[:tostr $x]"` is a
883
+ // located token whose value only the device knows. That uniformity is the
884
+ // contract a consumer reads: **`value === undefined` means there is no
885
+ // literal value here**, whatever the token's kind. Refusing the statement
886
+ // instead would be wrong — the token IS decided; only its value is not,
887
+ // and a `print` positional never needed one.
888
+ const positional = literalValue(text, start, end);
889
+ return typeof positional === "string"
890
+ ? { kind: "positional", span, text: raw }
891
+ : {
892
+ kind: "positional",
893
+ span,
894
+ value: positional.value,
895
+ valueSpan: span,
896
+ literalQuoted: positional.quoted,
897
+ text: raw,
898
+ };
899
+ }
900
+ const name = text.slice(start, eq);
901
+ if (!ARGUMENT_NAME.test(name))
902
+ return `\`${name}=\` is not a RouterOS argument name`;
903
+ if (options.allowArrayValues && startsStructuredSource(text, eq + 1)) {
904
+ if (isArraySource(text, structural, eq + 1, end, options.range))
905
+ return {
906
+ kind: "attribute",
907
+ span,
908
+ name,
909
+ valueSpan: { start: eq + 1, end },
910
+ sourceShape: "array",
911
+ text: raw,
912
+ };
913
+ return "a grouped or expression value";
914
+ }
915
+ // An empty value is LEGAL (`comment=` clears it), so `literalValue` reports a
916
+ // refusal as a `string` and a decided value — empty included — as an object.
917
+ // Two different types rather than a sentinel string, which `""` would collide
918
+ // with.
919
+ const value = literalValue(text, eq + 1, end);
920
+ if (typeof value === "string") return value;
921
+ return {
922
+ kind: "attribute",
923
+ span,
924
+ name,
925
+ value: value.value,
926
+ valueSpan: { start: eq + 1, end },
927
+ literalQuoted: value.quoted,
928
+ text: raw,
929
+ };
930
+ }
931
+
932
+ /**
933
+ * One located token whose value this phase does not decode (#316).
934
+ *
935
+ * Shape only: the `?`/`name=`/bare split comes from bytes the scanner already
936
+ * bounded, and NOTHING here reads a value — `undecided` is set and `value` stays
937
+ * absent, whatever the source spells. A left side that is not a RouterOS name is
938
+ * not promoted to an attribute (`{a=1;b=2}` stays one bare token), which is the
939
+ * same call {@link readToken} makes when it refuses that shape outright.
940
+ */
941
+ function undecidedToken(
942
+ text: string,
943
+ start: number,
944
+ end: number,
945
+ why: string,
946
+ ): ReadArgument {
947
+ const raw = text.slice(start, end);
948
+ const span = { start, end };
949
+ if (raw.startsWith("?"))
950
+ return {
951
+ kind: "query",
952
+ span,
953
+ name: raw.slice(1),
954
+ text: raw,
955
+ undecided: why,
956
+ };
957
+ const eq = unquotedEquals(text, start, end);
958
+ const name = eq === null ? null : text.slice(start, eq);
959
+ if (eq !== null && name !== null && ARGUMENT_NAME.test(name))
960
+ return {
961
+ kind: "attribute",
962
+ span,
963
+ name,
964
+ valueSpan: { start: eq + 1, end },
965
+ text: raw,
966
+ undecided: why,
967
+ };
968
+ return {
969
+ kind: "positional",
970
+ span,
971
+ valueSpan: span,
972
+ text: raw,
973
+ undecided: why,
974
+ };
975
+ }
976
+
977
+ /** Offset of the first `=` outside a quoted run, or null. */
978
+ function unquotedEquals(
979
+ text: string,
980
+ start: number,
981
+ end: number,
982
+ ): number | null {
983
+ let i = start;
984
+ while (i < end) {
985
+ const c = text[i] as string;
986
+ if (c === '"') {
987
+ i = scanQuotedString(text, i).end;
988
+ continue;
989
+ }
990
+ if (c === "=") return i;
991
+ if (c === "\\") {
992
+ i += 2;
993
+ continue;
994
+ }
995
+ i++;
996
+ }
997
+ return null;
998
+ }
999
+
1000
+ /**
1001
+ * The literal value in `[start,end)`, or the reason there is no literal one.
1002
+ *
1003
+ * Two shapes are literal: a bare run, and a run that is ONE fully-enclosing
1004
+ * quoted string. Anything else — a partly-quoted value (`a"b"`), or a quoted
1005
+ * value carrying an escape — needs a decoder this phase does not have, and a
1006
+ * decoder that is wrong about one escape corrupts a write.
1007
+ */
1008
+ function literalValue(
1009
+ text: string,
1010
+ start: number,
1011
+ end: number,
1012
+ ): { value: string; quoted: boolean } | string {
1013
+ if (start >= end) return { value: "", quoted: false };
1014
+ if ((text[start] as string) === '"') {
1015
+ const scan = scanQuotedString(text, start);
1016
+ if (!scan.closed) return "unterminated string in an argument value";
1017
+ if (scan.end !== end) return "a partly-quoted argument value";
1018
+ const body = text.slice(start + 1, end - 1);
1019
+ if (body.includes("\\")) return "an escape in a quoted argument value";
1020
+ if (body.includes("$")) return "a substitution in a quoted argument value";
1021
+ return { value: body, quoted: true };
1022
+ }
1023
+ const body = text.slice(start, end);
1024
+ if (body.includes('"')) return "a partly-quoted argument value";
1025
+ // An UNQUOTED escape needs decoding just as much as a quoted one:
1026
+ // `comment=a\ b` is the value `a b` on the device, and returning the source
1027
+ // run would send the backslash. `scanToken` accepts `\ `/`\\t` as part of a
1028
+ // token (it is legal RouterOS), so the refusal has to happen here — the token
1029
+ // is decided, its literal value is not. Found in review of #202c-1.
1030
+ if (body.includes("\\")) return "an escape in an argument value";
1031
+ const disagreement = gateDisagreement(body);
1032
+ if (disagreement !== null) return disagreement;
1033
+ return { value: body, quoted: false };
1034
+ }
1035
+
1036
+ /**
1037
+ * The same guard for a `?query` word — which is where the first two versions of
1038
+ * it did NOT reach, because the query branch of {@link readToken} returns before
1039
+ * any value is read (found by review of #202c-1, twice: the fix was written at
1040
+ * the value level and one of the three token kinds has no value).
1041
+ *
1042
+ * A query is kept VERBATIM: `ArgumentsRead.queries` is source text, and a query
1043
+ * word's `?` forms (`?>`, `?#|`, existence tests) are a grammar this lexer does
1044
+ * not split. So the check is not about values but about whether the gate's
1045
+ * tokenizer would produce the same STRING — and it is the identity only on a run
1046
+ * with no quote, escape, or `\s`-but-not-ASCII-whitespace byte in it:
1047
+ *
1048
+ * - `'` and `\f`/`\v` — the two disagreements above, unchanged in cause.
1049
+ * - `"` — the gate STRIPS quotes, so `?comment="lan uplink"` is the word
1050
+ * `?comment=lan uplink` there and the source text here.
1051
+ * - `\` — the gate consumes it as an escape, so `?comment=a\ b` is
1052
+ * `?comment=a b` there and `?comment=a\ b` here.
1053
+ *
1054
+ * What survives is exactly the shape on which the gate's tokenizer is a no-op,
1055
+ * which is what makes `arguments.queries` and `canonical.queries` comparable at
1056
+ * all — and that comparison is now an anchored test rather than an assumption.
1057
+ */
1058
+ function queryDisagreement(raw: string): string | null {
1059
+ const found = gateDisagreement(raw);
1060
+ if (found !== null) return found;
1061
+ if (raw.includes('"'))
1062
+ return "a quoted query word, which centrs's execute gate unquotes and this reading keeps verbatim";
1063
+ if (raw.includes("\\"))
1064
+ return "an escape in a query word, which centrs's execute gate decodes and this reading keeps verbatim";
1065
+ return null;
1066
+ }
1067
+
1068
+ /**
1069
+ * Characters an UNQUOTED value may not contain, because centrs's two readers
1070
+ * genuinely disagree about them — so phase 1 publishes neither reading.
1071
+ *
1072
+ * Both cases were found by review of #202c-1, and neither is reachable from the
1073
+ * corpus:
1074
+ *
1075
+ * - **`'`** — RouterOS does not use it as a string delimiter; it is an
1076
+ * ordinary character, which is what this lexer reads. But
1077
+ * `canonicalizeExecuteCommand`'s tokenizer DOES treat it as a quote
1078
+ * (`src/execute.ts`), so `comment='lan uplink'` is `{comment: "lan uplink"}`
1079
+ * to the gate and `{comment: "'lan"}` + a positional here, and
1080
+ * `comment=it's` is `its` there and `it's` here.
1081
+ * - **`\f` / `\v`** — the gate splits tokens on JavaScript `\s`, which
1082
+ * includes them; every `explain` module splits on ASCII whitespace (space,
1083
+ * tab, CR, LF), which does not. So `comment=x\fdisabled=no` is two
1084
+ * arguments to the gate and one value here.
1085
+ *
1086
+ * **Refusing is not a claim that the gate is right.** The device-correct reading
1087
+ * is this module's in both cases, and the gate is locked — widening or
1088
+ * correcting it is a product regression (`docs/CONSTITUTION.md`). What is
1089
+ * refused is putting a SECOND confident value in the same result, which is the
1090
+ * contradiction `command.args` beside `canonical.args` must never contain.
1091
+ *
1092
+ * Two boundaries this deliberately does NOT cross:
1093
+ *
1094
+ * - **Only unquoted values.** Inside a `"…"` run the gate treats all three as
1095
+ * content exactly as this lexer does, so the two agree and refusing there
1096
+ * would cost real readings.
1097
+ * - **Only these two control characters, not the gate's whole `\s` class.**
1098
+ * Adopting `\s` here would put this scanner at odds with `verbsplit.ts` and
1099
+ * `segment.ts` about where a token ends — two scanners disagreeing about a
1100
+ * boundary is the failure the shared `continuationLength` above exists to
1101
+ * prevent. The rest of `\s` is non-ASCII and never reaches this module
1102
+ * as itself: `coordinates.ts` normalization stands a placeholder in, so the
1103
+ * statement fails the addressability check in `src/explain.ts` first
1104
+ * (verified for NBSP, U+2028, U+FEFF and U+3000).
1105
+ */
1106
+ function gateDisagreement(body: string): string | null {
1107
+ if (body.includes("'"))
1108
+ return "a single quote in an unquoted value, which centrs's execute gate and RouterOS read differently";
1109
+ if (body.includes("\f") || body.includes("\v"))
1110
+ return "a form feed or vertical tab in an unquoted value, which centrs's execute gate treats as a token boundary and RouterOS does not";
1111
+ return null;
1112
+ }
1113
+
1114
+ /** Trim ASCII whitespace off a range, returning the tightened bounds. */
1115
+ function trimRange(
1116
+ text: string,
1117
+ start: number,
1118
+ end: number,
1119
+ ): { start: number; end: number } {
1120
+ let from = start;
1121
+ let to = end;
1122
+ while (from < to && " \t\r\n".includes(text[from] as string)) from++;
1123
+ while (to > from && " \t\r\n".includes(text[to - 1] as string)) to--;
1124
+ return { start: from, end: to };
1125
+ }
1126
+
1127
+ /**
1128
+ * Split `[from,to)` on depth-0 `separator`, or null when the device would call
1129
+ * the literal a syntax error.
1130
+ *
1131
+ * On CHR 7.23.3 an empty member is fatal — `{;}`, `{;1}`, `{1;;2}` and `(1,)`
1132
+ * are all `syntax error` — with exactly one exception: a brace array tolerates
1133
+ * ONE trailing separator (`{1;}` is a one-member array, `{1;2;}` a two-member
1134
+ * one). Returning null rather than "no members" matters, because the enclosing
1135
+ * `array` shape is wrong too when the literal does not parse.
1136
+ */
1137
+ function splitMembers(
1138
+ structural: string,
1139
+ from: number,
1140
+ to: number,
1141
+ separator: ";" | ",",
1142
+ ): { start: number; end: number }[] | null {
1143
+ const runs: { start: number; end: number }[] = [];
1144
+ let memberStart = from;
1145
+ for (const at of depthZeroOffsets(structural, from, to, separator)) {
1146
+ runs.push({ start: memberStart, end: at });
1147
+ memberStart = at + 1;
1148
+ }
1149
+ runs.push({ start: memberStart, end: to });
1150
+ const trimmed = runs.map((run) => trimRange(structural, run.start, run.end));
1151
+ const trailingEmpty =
1152
+ separator === ";" &&
1153
+ trimmed.length > 1 &&
1154
+ (trimmed[trimmed.length - 1] as { start: number; end: number }).start ===
1155
+ (trimmed[trimmed.length - 1] as { start: number; end: number }).end;
1156
+ const members = trailingEmpty ? trimmed.slice(0, -1) : trimmed;
1157
+ if (members.some((run) => run.start === run.end)) return null;
1158
+ return members;
1159
+ }
1160
+
1161
+ /** Why a located array literal was withdrawn; each is a device syntax error. */
1162
+ const EMPTY_MEMBER = "an array literal with an empty member";
1163
+ const EMPTY_GROUP = "an array member that is an empty group";
1164
+ const DEPTH_BOUND_REACHED =
1165
+ "an array literal nested deeper than this phase reads";
1166
+ const UNPARSEABLE_MEMBER =
1167
+ "an array member RouterOS rejects at its first character";
1168
+ const HALF_COMPARISON =
1169
+ "an array member whose `=` is missing one of its operands";
1170
+
1171
+ /**
1172
+ * Why a `(…)` member cannot stand where it is, or null when it can.
1173
+ *
1174
+ * A parenthesis inside a literal is either a GROUP — `{(1)}` is the one-member
1175
+ * array `1` — or a comma array, and {@link isArraySource} only recognizes the
1176
+ * second. That left the first sharing the fallback branch with every other
1177
+ * expression, which is an abstention, so a group the device REJECTS kept the
1178
+ * enclosing `array` shape instead of withdrawing it (found in review).
1179
+ *
1180
+ * On CHR 7.23.3 an empty group (`{()}`, `{a=()}`) and an empty comma member
1181
+ * (`{(1,)}`, `{(,)}`, `{1;(2,)}`, `(1,(2,))`) are syntax errors at every
1182
+ * nesting depth, while `{(1)}`, `{a=(1)}` and `{1;(2)}` all parse — so the
1183
+ * check has to name the fault rather than refuse every parenthesis.
1184
+ *
1185
+ * `highlight` is not the oracle here: it accepts `{1;2,}`, `{2,}` and
1186
+ * `{(1,2),}`, all of which `:parse` rejects.
1187
+ */
1188
+ function parenMemberFault(
1189
+ text: string,
1190
+ structural: string,
1191
+ start: number,
1192
+ end: number,
1193
+ ): string | null {
1194
+ if (text[start] !== "(") return null;
1195
+ if (delimitedEnd(structural, start) !== end) return null;
1196
+ if (structural.slice(start + 1, end - 1).trim().length === 0)
1197
+ return EMPTY_GROUP;
1198
+ return splitMembers(structural, start + 1, end - 1, ",") === null
1199
+ ? EMPTY_MEMBER
1200
+ : null;
1201
+ }
1202
+
1203
+ /** Characters that cannot OPEN an array member; see {@link pushArrayMembers}. */
1204
+ function leadsInvalidMember(c: string | undefined): boolean {
1205
+ return c === "*" || c === "+";
1206
+ }
1207
+
1208
+ /**
1209
+ * A member key: a bare word or one fully-enclosing quoted run.
1210
+ *
1211
+ * The bare spelling is {@link MEMBER_NAME}, which is NOT the identifier grammar
1212
+ * and was measured rather than assumed — see that constant. Review caught the
1213
+ * first version of this rejecting `.id`, which the fixture itself held as a
1214
+ * valid key.
1215
+ */
1216
+ function memberKey(
1217
+ text: string,
1218
+ structural: string,
1219
+ start: number,
1220
+ end: number,
1221
+ ): string | null {
1222
+ if (start >= end) return null;
1223
+ if (text[start] === '"') {
1224
+ const scan = scanQuotedString(structural, start);
1225
+ if (!scan.closed || scan.end !== end) return null;
1226
+ const body = text.slice(start + 1, end - 1);
1227
+ return body.includes("\\") || body.includes("$") ? null : body;
1228
+ }
1229
+ const raw = text.slice(start, end);
1230
+ return MEMBER_NAME.test(raw) ? raw : null;
1231
+ }
1232
+
1233
+ /**
1234
+ * A bare array-member key, as the device spells one on 7.23.3 (#258).
1235
+ *
1236
+ * Swept character by character rather than borrowed from the identifier rules,
1237
+ * because the two disagree in both directions. `.`, `-` and `/` are ordinary
1238
+ * name bytes ANYWHERE, including alone and repeated: `{.id=1}`, `{..id=1}`,
1239
+ * `{.=1}`, `{-=1}`, `{--=1}`, `{/=1}`, `{a/b=1}` and `{-.1=1}` all bind the key
1240
+ * spelled exactly that way. But `_` does NOT: `{_a=1}` and `{a_b=1}` lower to
1241
+ * `(= $_a 1)` and `(= $a_b 1)`, comparisons — even though `$a_b` is a perfectly
1242
+ * good variable. Neither do `: * ~ % > < + ! @ #`.
1243
+ *
1244
+ * The review that caught `.id` is the reason this is a named constant with its
1245
+ * own evidence: a key reader that quietly disagrees with the device about what
1246
+ * a name IS makes every rule built on top of it wrong in the same direction.
1247
+ */
1248
+ const MEMBER_NAME = /^[A-Za-z0-9./-]+$/;
1249
+
1250
+ /**
1251
+ * Characters the device proved cannot appear in a bare member key (#258).
1252
+ *
1253
+ * Used only to say a left side is POSITIVELY an expression, never to say it is
1254
+ * a name. That asymmetry is the fix for the `.id` defect: an unrecognized
1255
+ * spelling that is merely not in {@link MEMBER_NAME} abstains, while only a
1256
+ * spelling holding one of these bytes is treated as a comparison and can
1257
+ * withdraw its literal. Each byte is a syntax error with an empty right side
1258
+ * (`{_a=}`, `{a:b=}`, `{a*b=}`, `{a~b=}`, `{a>b=}`, `{a%b=}`, `{1+1=}`,
1259
+ * `{a<=}`, `{a b=}`, `{a,b=}`, `{$a=}`, `{(a)=}`, `{[:len 1]=}`, `{{1;2}=}`)
1260
+ * or an operator in the IL of the same member with one.
1261
+ */
1262
+ const NOT_IN_MEMBER_NAME = /[\s,$([{"'+*<>!~%:_@#=]/;
1263
+
1264
+ /**
1265
+ * Whether `[start,end)` ends on an operator with no right operand (#258).
1266
+ *
1267
+ * A member and a comparison's left side are both runs the device lexes on
1268
+ * their own, so both carry this fault: `{-}`, `{--}`, `{a-}`, `{$a-}` and
1269
+ * `{$a-;1}` are syntax errors, and so are `{a- =1}` and `{$a-=1}` where the
1270
+ * dangling run is only the left side. The trailing-dot half is narrower and
1271
+ * the asymmetry is the device's: `{$a.}` and `{$a.=1}` are refused while
1272
+ * `{a.}`, `{.}`, `{/}`, `{a/}` and `{a. =1}` all parse as variable references
1273
+ * — a bare name absorbs the dot into itself, a `$` name does not.
1274
+ *
1275
+ * A KEY is not an operand and does not carry it: `{a-=1}` binds the key `a-`
1276
+ * and `{a-=}` is the string `a-`, so this is only asked where the sign failed
1277
+ * to bind.
1278
+ */
1279
+ function danglesOperator(text: string, start: number, end: number): boolean {
1280
+ if (start >= end) return false;
1281
+ const last = text[end - 1];
1282
+ if (last === "-") return true;
1283
+ return last === "." && text[start] === "$";
1284
+ }
1285
+
1286
+ /**
1287
+ * Whether `[start,end)` is ONE complete operand, so a following `=` is the
1288
+ * member's top-level operator (#258).
1289
+ *
1290
+ * This is the whole difference between a member that is provably `bool` and one
1291
+ * that must abstain. `=` returns `bool` for any pair of operands, so `{$a=1}`,
1292
+ * `{(a)=1}`, `{[:timestamp]=1}` and `{{1;2}=1}` are all `bool` — but only
1293
+ * because the `=` is on top. `{a b=1}` lowers to `( $a (= $b 1))`, where the
1294
+ * top operator is the space and the type is its operands', and `{$a=1,2}`
1295
+ * lowers to `(, (= $a 1) 2)`, an `array` whose FIRST ELEMENT is the comparison.
1296
+ * Both were device-typed `bool` under the bindings a probe happened to use, so
1297
+ * the shape of the left side — not the observed type — has to decide.
1298
+ *
1299
+ * A bare name is included because it only reaches here when whitespace kept it
1300
+ * from binding a key (`{a =1}` compares, `{a=1}` does not) — but OPERAND
1301
+ * position is stricter than key position, so it is not simply
1302
+ * {@link memberKey}: `{a-=1}` binds the key `a-`, while `{a- =1}` does not
1303
+ * parse at all. Both classes therefore end on an alphanumeric here.
1304
+ *
1305
+ * The class is narrower than RouterOS's operand grammar on purpose, and the
1306
+ * gap is all abstention: `{a_b=1}`, `{:a=1}`, `{1+1=2}` and `{$a/b=1}` are
1307
+ * `bool` on the device and silent here. Widening it means proving the `=`
1308
+ * stays on top for the new spelling, which is exactly what {@link MEMBER_NAME}
1309
+ * had to be measured for.
1310
+ */
1311
+ function isSingleOperand(
1312
+ text: string,
1313
+ structural: string,
1314
+ start: number,
1315
+ end: number,
1316
+ ): boolean {
1317
+ if (start >= end) return false;
1318
+ const open = text[start];
1319
+ if (open === "(" || open === "[" || open === "{")
1320
+ return delimitedEnd(structural, start) === end;
1321
+ // A `$` name is NOT the member-key name, and review's suggestion to widen it
1322
+ // was the wrong direction: `$:a`, `$_a`, `$/a` and `$.id` are all syntax
1323
+ // errors, so the byte after the `$` must be alphanumeric, and `$a-`/`$a.`
1324
+ // are errors too, so the last byte must be as well. In between, `.` and `-`
1325
+ // join operands (`{$a-b=1}` is `(= (- $a $b) 1)`) and the `=` stays on top,
1326
+ // while `_` and `:` end the name and start a SPACE-concat whose type is its
1327
+ // operands' (`{$a_b=1}` is `( $a (= $_b 1))`).
1328
+ if (open === "$")
1329
+ return /^\$[A-Za-z0-9]+(?:[.-]+[A-Za-z0-9]+)*$/.test(
1330
+ text.slice(start, end),
1331
+ );
1332
+ return (
1333
+ memberKey(text, structural, start, end) !== null &&
1334
+ /[A-Za-z0-9]$/.test(text.slice(start, end))
1335
+ );
1336
+ }
1337
+
1338
+ /**
1339
+ * Every offset of `needle` at depth 0 in `[start,end)`, strings skipped.
1340
+ *
1341
+ * Member splitting, the key split, and the nested-comma test are the same walk
1342
+ * asked three questions, so they share it: this module's standing failure mode
1343
+ * is two scanners disagreeing about one byte (see {@link continuationLength}).
1344
+ */
1345
+ function depthZeroOffsets(
1346
+ structural: string,
1347
+ start: number,
1348
+ end: number,
1349
+ needle: string,
1350
+ ): number[] {
1351
+ const found: number[] = [];
1352
+ let depth = 0;
1353
+ for (let i = start; i < end; i++) {
1354
+ const c = structural[i] as string;
1355
+ if (c === '"') {
1356
+ i = scanQuotedString(structural, i).end - 1;
1357
+ continue;
1358
+ }
1359
+ if (c === "(" || c === "[" || c === "{") depth++;
1360
+ else if (c === ")" || c === "]" || c === "}") depth--;
1361
+ else if (c === needle && depth === 0) found.push(i);
1362
+ }
1363
+ return found;
1364
+ }
1365
+
1366
+ /**
1367
+ * Append the members of one array literal, depth first (#225 V1 interior).
1368
+ *
1369
+ * Returns null when every member was read, or the REASON the literal does not
1370
+ * parse on the device — the caller withdraws the enclosing `array` shape and
1371
+ * publishes that reason, so a consumer is never told "empty member" about a
1372
+ * literal whose actual fault was something else (found in review).
1373
+ *
1374
+ * Every rule below is a CHR 7.23.3 reading, taken from each member's own
1375
+ * `:typeof` under `:foreach k,v` and the statement's `:parse` IL:
1376
+ *
1377
+ * - the separator is the DELIMITER's, not a choice: `{1;2}` has two members
1378
+ * while `{1,2}` has ONE (`(, 1 2)`, a nested array), and `(1;2)` is a
1379
+ * syntax error;
1380
+ * - `=` binds a key only in the brace form, and only where a NAME touches the
1381
+ * sign (`{a=1}` -> key `a`, value `num`); anywhere else it COMPARES, so
1382
+ * `{a =1}`, `{$a=1}` and `(a=1,b=2)` are `bool` members and not keys;
1383
+ * - a key whose right side is EMPTY is not an empty-valued key: the device
1384
+ * drops the sign and keeps the name as a positional `str` (`{a=}` -> `a`),
1385
+ * while an empty side anywhere else is a syntax error;
1386
+ * - a member that is itself `{…}`/`(…)`, or a brace member carrying a depth-0
1387
+ * comma, is an array and is descended into;
1388
+ * - everything else is offered to the literal reader, and what it refuses is
1389
+ * a variable reference or an expression on the device — which is exactly
1390
+ * what an abstention should mean.
1391
+ */
1392
+ function pushArrayMembers(
1393
+ anchors: ValueAnchor[],
1394
+ text: string,
1395
+ structural: string,
1396
+ parent: number,
1397
+ contentStart: number,
1398
+ contentEnd: number,
1399
+ separator: ";" | ",",
1400
+ depth: number,
1401
+ range: MaskedRange | undefined,
1402
+ ): string | null {
1403
+ // Past the bound the interior is UNVERIFIED, and an unverified interior can
1404
+ // hold a fault that makes the whole statement a syntax error — `:parse`
1405
+ // rejects a `(1,)` buried nine levels deep exactly as it rejects a shallow
1406
+ // one. Returning null here called those bytes an array anyway, so the bound
1407
+ // withdraws instead. Deepest member in the 948-script corpus is 6, so this
1408
+ // costs nothing that has ever been seen.
1409
+ if (depth >= MAX_MEMBER_DEPTH) return DEPTH_BOUND_REACHED;
1410
+ const members = splitMembers(structural, contentStart, contentEnd, separator);
1411
+ if (members === null) return EMPTY_MEMBER;
1412
+ for (const member of members) {
1413
+ let valueStart = member.start;
1414
+ let valueEnd = member.end;
1415
+ let name: string | undefined;
1416
+ let sourceShape: "str" | "bool" | undefined;
1417
+ let decoded: string | undefined;
1418
+ let quoted = false;
1419
+ // A member may not OPEN with `*` or `+`: `{*1}`, `{ *1 }`, `(*1,2)`,
1420
+ // `{a=*1}` and `{+1}` are all syntax errors on 7.23.3, even though `*1` is
1421
+ // a perfectly good value one position out (`:local x *1` is typed `id`).
1422
+ // Only the first byte is read, because both characters are ordinary
1423
+ // operators between operands — `{2*3}` is `num` 6 and `{1+1}` is 2 — and
1424
+ // unary minus is fine either way (`{-1}` is `num` -1).
1425
+ if (leadsInvalidMember(text[member.start])) return UNPARSEABLE_MEMBER;
1426
+ // `@` is not a member character ANYWHERE, unlike `*`/`+` which are only
1427
+ // fatal at the front: `{a@b}`, `{@a}`, `{a@}`, `{1;a@b}`, `(a@b,2)` and
1428
+ // `{a@b=1}` are all syntax errors, while the same bytes one slot out are
1429
+ // fine (`:local z a@b` parses). Quoted runs keep it — `{"a@b"}` parses —
1430
+ // which is why this is a depth-zero scan and not a substring test.
1431
+ if (depthZeroOffsets(structural, member.start, member.end, "@").length > 0)
1432
+ return UNPARSEABLE_MEMBER;
1433
+ // A `$` takes an alphanumeric or a QUOTE and nothing else: `$.id`, `$_a`,
1434
+ // `$:a`, `$/a`, `$$a`, `$(x)`, `$[:len 1]` and a bare `$` are all syntax
1435
+ // errors, while `$"a b"` is a good quoted variable name — so "alphanumeric"
1436
+ // alone would withdraw a literal the device accepts.
1437
+ for (const at of depthZeroOffsets(
1438
+ structural,
1439
+ member.start,
1440
+ member.end,
1441
+ "$",
1442
+ ))
1443
+ if (!/[A-Za-z0-9"]/.test(text[at + 1] ?? "")) return UNPARSEABLE_MEMBER;
1444
+ if (danglesOperator(text, member.start, member.end))
1445
+ return UNPARSEABLE_MEMBER;
1446
+ const eq = depthZeroOffsets(structural, member.start, member.end, "=")[0];
1447
+ if (eq !== undefined) {
1448
+ const left = trimRange(structural, member.start, eq);
1449
+ const right = trimRange(structural, eq + 1, member.end);
1450
+ // A key binds only in the brace form, and only where the NAME TOUCHES
1451
+ // the sign: `{a=1}` is the key `a`, `{a =1}` is the comparison
1452
+ // `(= $a 1)`, and one byte of space is the whole difference (#258).
1453
+ const key =
1454
+ separator === ";" && eq === left.end
1455
+ ? memberKey(text, structural, left.start, left.end)
1456
+ : null;
1457
+ // An empty LEFT side is a syntax error in both forms and at any
1458
+ // position: `{=}`, `{=1}`, `{ =1 }`, `(=1,2)`, `(1,=2)`.
1459
+ if (left.start === left.end) return HALF_COMPARISON;
1460
+ if (key === null && right.start === right.end) {
1461
+ // An empty RIGHT side is a syntax error too — but ONLY once the left
1462
+ // side is known not to be a name, because a name means the sign is
1463
+ // dropped instead (`{a=}`). Review found the first version of this
1464
+ // asking `memberKey` that question, which withdrew `{.id=}`: a
1465
+ // literal the device accepts, whose member is the string `.id`. So
1466
+ // the fault now needs POSITIVE evidence of an expression, and a
1467
+ // spelling this reader simply does not recognize abstains.
1468
+ //
1469
+ // The paren form needs no such evidence: `=` never binds a key
1470
+ // there, so an empty right side is always a comparison missing an
1471
+ // operand (`(a=)`, `(a=,2)`).
1472
+ //
1473
+ // The span read here is the RAW one, not the trimmed `left`: the
1474
+ // whitespace between a name and the sign is itself the evidence
1475
+ // (`{a =}` and `{.id =}` are syntax errors while `{a=}` and
1476
+ // `{.id=}` are not), and trimming it away hides exactly that.
1477
+ if (
1478
+ separator === "," ||
1479
+ NOT_IN_MEMBER_NAME.test(text.slice(member.start, eq))
1480
+ )
1481
+ return HALF_COMPARISON;
1482
+ continue;
1483
+ }
1484
+ if (key !== null && right.start === right.end) {
1485
+ // `{a=}` is not an empty-valued key: the device DROPS the sign and
1486
+ // keeps the name as a positional `str` — `{1.1=}` is the string
1487
+ // `1.1`, not the address, so no member lexicon runs on it either.
1488
+ sourceShape = "str";
1489
+ decoded = key;
1490
+ quoted = text[left.start] === '"';
1491
+ valueStart = left.start;
1492
+ valueEnd = left.end;
1493
+ } else if (key !== null) {
1494
+ if (leadsInvalidMember(text[right.start])) return UNPARSEABLE_MEMBER;
1495
+ // A value that OPENS with the sign is an `=` with nothing on its
1496
+ // left, which is the same syntax error one level down: `{a==}` is
1497
+ // rejected exactly as `{=1}` is.
1498
+ if (text[right.start] === "=") return HALF_COMPARISON;
1499
+ name = key;
1500
+ valueStart = right.start;
1501
+ } else if (danglesOperator(text, left.start, left.end)) {
1502
+ // The sign's left operand is itself incomplete: `{a- =1}` and
1503
+ // `{$a-=1}` are refused exactly as the bare `{a-}` is.
1504
+ return UNPARSEABLE_MEMBER;
1505
+ } else if (
1506
+ isSingleOperand(text, structural, left.start, left.end) &&
1507
+ depthZeroOffsets(structural, member.start, member.end, ",").length === 0
1508
+ ) {
1509
+ // The left operand is a member in its own right, so it carries the
1510
+ // same faults: `{()=1}` is rejected exactly as `{()}` is, and being
1511
+ // on the left of a comparison does not excuse it.
1512
+ const leftFault = parenMemberFault(
1513
+ text,
1514
+ structural,
1515
+ left.start,
1516
+ left.end,
1517
+ );
1518
+ if (leftFault !== null) return leftFault;
1519
+ // The `=` is on top, so the member is `bool` whatever it compares.
1520
+ sourceShape = "bool";
1521
+ } else {
1522
+ // A left side that is not one operand, or a depth-zero comma that
1523
+ // outranks the sign: the device has a member, but its type is its
1524
+ // operands' and offline cannot read those.
1525
+ continue;
1526
+ }
1527
+ }
1528
+ const anchor: ValueAnchor = {
1529
+ kind: "element",
1530
+ tokenSpan: { start: member.start, end: member.end },
1531
+ ...(name === undefined ? {} : { name }),
1532
+ valueSpan: { start: valueStart, end: valueEnd },
1533
+ value: decoded ?? text.slice(valueStart, valueEnd),
1534
+ quoted,
1535
+ parent,
1536
+ };
1537
+ if (sourceShape !== undefined) {
1538
+ anchors.push({ ...anchor, sourceShape });
1539
+ continue;
1540
+ }
1541
+ const fault = parenMemberFault(text, structural, valueStart, member.end);
1542
+ if (fault !== null) return fault;
1543
+ if (isArraySource(text, structural, valueStart, member.end, range)) {
1544
+ const at = anchors.length;
1545
+ anchors.push({ ...anchor, sourceShape: "array" });
1546
+ const nested = pushArrayMembers(
1547
+ anchors,
1548
+ text,
1549
+ structural,
1550
+ at,
1551
+ valueStart + 1,
1552
+ member.end - 1,
1553
+ text[valueStart] === "{" ? ";" : ",",
1554
+ depth + 1,
1555
+ range,
1556
+ );
1557
+ if (nested !== null) return nested;
1558
+ continue;
1559
+ }
1560
+ if (
1561
+ separator === ";" &&
1562
+ depthZeroOffsets(structural, valueStart, member.end, ",").length > 0
1563
+ ) {
1564
+ // `{1;2,3}` is two members, the second a comma array carrying no
1565
+ // delimiters of its own — IL `1;(, 2 3)`. It splits on commas.
1566
+ const at = anchors.length;
1567
+ anchors.push({ ...anchor, sourceShape: "array" });
1568
+ const nested = pushArrayMembers(
1569
+ anchors,
1570
+ text,
1571
+ structural,
1572
+ at,
1573
+ valueStart,
1574
+ member.end,
1575
+ ",",
1576
+ depth + 1,
1577
+ range,
1578
+ );
1579
+ if (nested !== null) return nested;
1580
+ continue;
1581
+ }
1582
+ const literal = literalValue(text, valueStart, member.end);
1583
+ if (typeof literal === "string") continue;
1584
+ anchors.push({ ...anchor, value: literal.value, quoted: literal.quoted });
1585
+ }
1586
+ return null;
1587
+ }
1588
+
1589
+ /**
1590
+ * Locate the literal-value prefix of one statement's arguments (#225 V1).
1591
+ *
1592
+ * {@link lexArguments} remains all-or-nothing because its consumer may render a
1593
+ * runnable REST request. Hints are different: an unreadable later expression
1594
+ * must not erase an earlier, independently bounded literal. This scan therefore
1595
+ * reuses the exact same token and literal readers, returns every safe anchor
1596
+ * before the first refusal, and stops there. The one V2 extension is an exact
1597
+ * array literal: braces, or parentheses with a depth-zero comma, are locatable
1598
+ * without evaluating their contents. Other expressions, including `.` concat,
1599
+ * still stop the scan. The strict REST reading remains unchanged.
1600
+ *
1601
+ * A located array is then DESCENDED into, so each member is anchored in its own
1602
+ * right ({@link pushArrayMembers}); a member that does not parse withdraws the
1603
+ * whole literal, since the device rejects the statement in that case.
1604
+ */
1605
+ export function lexValueAnchors(
1606
+ text: string,
1607
+ from: number,
1608
+ options: { directiveVerb?: string; range?: MaskedRange } = {},
1609
+ ): ValueAnchorReading {
1610
+ const anchors: ValueAnchor[] = [];
1611
+ const structural = maskOf(options.range) ?? maskComments(text);
1612
+ for (const read of walkArguments(text, from, {
1613
+ allowArrayValues: true,
1614
+ ...rangeOption(options.range),
1615
+ ...(options.directiveVerb === undefined
1616
+ ? {}
1617
+ : { directiveVerb: options.directiveVerb }),
1618
+ })) {
1619
+ if (typeof read === "string")
1620
+ return { complete: false, anchors, why: read };
1621
+ if (read.kind !== "query" && read.valueSpan !== undefined) {
1622
+ if (read.sourceShape !== undefined) {
1623
+ const at = anchors.length;
1624
+ anchors.push({
1625
+ kind: read.kind,
1626
+ tokenSpan: read.span,
1627
+ ...(read.name === undefined ? {} : { name: read.name }),
1628
+ valueSpan: read.valueSpan,
1629
+ value: text.slice(read.valueSpan.start, read.valueSpan.end),
1630
+ sourceShape: read.sourceShape,
1631
+ quoted: false,
1632
+ });
1633
+ const unparsed = pushArrayMembers(
1634
+ anchors,
1635
+ text,
1636
+ structural,
1637
+ at,
1638
+ read.valueSpan.start + 1,
1639
+ read.valueSpan.end - 1,
1640
+ text[read.valueSpan.start] === "{" ? ";" : ",",
1641
+ 0,
1642
+ options.range,
1643
+ );
1644
+ if (unparsed !== null) {
1645
+ anchors.length = at;
1646
+ return { complete: false, anchors, why: unparsed };
1647
+ }
1648
+ continue;
1649
+ }
1650
+ if (read.value === undefined) continue;
1651
+ anchors.push({
1652
+ kind: read.kind,
1653
+ tokenSpan: read.span,
1654
+ ...(read.name === undefined ? {} : { name: read.name }),
1655
+ valueSpan: read.valueSpan,
1656
+ value: read.value,
1657
+ quoted: read.literalQuoted ?? false,
1658
+ });
1659
+ }
1660
+ }
1661
+ return { complete: true, anchors };
1662
+ }
1663
+
1664
+ /**
1665
+ * Locate top-level `{...}` values that RouterOS rejects in command arguments.
1666
+ *
1667
+ * Parentheses and substitutions restore expression context, where a brace
1668
+ * literal is legal. Stored-script attributes are also excluded: their brace is
1669
+ * a script body, not an array. The caller applies this only to non-root
1670
+ * commands; root scripting directives use the `(verb, slot)` table instead.
1671
+ */
1672
+ export function invalidCommandBraceOffsets(
1673
+ text: string,
1674
+ from: number,
1675
+ directiveVerb?: string,
1676
+ range?: MaskedRange,
1677
+ ): number[] {
1678
+ const structural = maskOf(range) ?? maskComments(text);
1679
+ const offsets: number[] = [];
1680
+ const stack: string[] = [];
1681
+ let i = Math.max(0, from);
1682
+ let tokenStart: number | null = null;
1683
+ let positionals = 0;
1684
+ while (i < structural.length) {
1685
+ const c = structural[i] as string;
1686
+ if (
1687
+ stack.length === 0 &&
1688
+ (c === " " || c === "\t" || c === "\r" || c === "\n")
1689
+ ) {
1690
+ if (tokenStart !== null) {
1691
+ const token = structural.slice(tokenStart, i);
1692
+ if (
1693
+ !token.startsWith("?") &&
1694
+ unquotedEquals(token, 0, token.length) === null
1695
+ )
1696
+ positionals++;
1697
+ tokenStart = null;
1698
+ }
1699
+ i++;
1700
+ continue;
1701
+ }
1702
+ if (stack.length === 0 && tokenStart === null) tokenStart = i;
1703
+ if (c === '"') {
1704
+ i = scanQuotedString(structural, i).end;
1705
+ continue;
1706
+ }
1707
+ if (c === "[" || c === "(") {
1708
+ stack.push(c);
1709
+ i++;
1710
+ continue;
1711
+ }
1712
+ if (c === "]" || c === ")") {
1713
+ const want = c === "]" ? "[" : "(";
1714
+ if (stack.at(-1) === want) stack.pop();
1715
+ i++;
1716
+ continue;
1717
+ }
1718
+ if (c !== "{" || stack.length > 0) {
1719
+ i++;
1720
+ continue;
1721
+ }
1722
+
1723
+ const end = delimitedEnd(structural, i);
1724
+ const owner =
1725
+ tokenStart === null
1726
+ ? undefined
1727
+ : braceValueOwner(structural, tokenStart, i);
1728
+ const slot = owner === null ? `#${positionals}` : owner;
1729
+ if (
1730
+ // Asked of the DOCUMENT when the caller anchored this statement, so the
1731
+ // statement index behind it is the document's one index rather than a
1732
+ // fresh one over this statement's text — which carries its whole nested
1733
+ // subtree, and so was rebuilt once per enclosing level (#322). The floor
1734
+ // makes the two readings the same answer (`scope-brace.ts`).
1735
+ (range === undefined
1736
+ ? braceStartsStatements(structural, i)
1737
+ : braceStartsStatements(range.masked, range.start + i, range.start)) ||
1738
+ (directiveVerb !== undefined &&
1739
+ slot !== undefined &&
1740
+ braceSlotAcceptsBrace(directiveVerb, slot))
1741
+ ) {
1742
+ i = end ?? structural.length;
1743
+ continue;
1744
+ }
1745
+ offsets.push(i);
1746
+ i = end ?? i + 1;
1747
+ }
1748
+ return offsets;
1749
+ }