@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,1165 @@
1
+ /**
2
+ * Statement segmenter for `explain` (centrs canonicalizer).
3
+ *
4
+ * Ratified by the phase-0 lab, question Q1 (#185) and promoted from the
5
+ * throwaway probe `.scratch/explain-lab-segmenter.ts`. This is the first walker
6
+ * stage: a schema-free, device-free splitter of multi-statement RouterOS input
7
+ * into top-level statements, matching `(evl …)` sibling adjacency in `:parse`
8
+ * IL. It never throws; structural surprises are reported as located
9
+ * {@link Defect}s.
10
+ *
11
+ * Segmentation runs on the byte-count-preserving `analyzed` surface from
12
+ * `./coordinates.ts`, so a segment's `start`/`end` are **analyzed-byte offsets**
13
+ * (half-open, aligned with the coordinate contract and device highlight spans),
14
+ * while `text` is the **original** human-readable statement, recovered through
15
+ * the coordinate mapping. Because non-ASCII bytes are `SUB` in `analyzed` and
16
+ * `SUB` is none of the RouterOS delimiters, prose or names cannot corrupt the
17
+ * delimiter stack.
18
+ *
19
+ * The boundary rules (grounded from the documented scripting semantics before
20
+ * any scoring, so the disagreement bins meant something):
21
+ * H1 Statements separate on `;` and newline, at nesting depth 0 only.
22
+ * H2 Depth counts `{}`, `[]`, `()` uniformly; a newline inside any of them
23
+ * continues the statement.
24
+ * H3 `"` opens a string; `\` escapes the next char inside it; a string
25
+ * suppresses every separator and depth character. RouterOS has no
26
+ * single-quote string form, so `'` is an ordinary character. A string is
27
+ * not opaque to the NEXT quote, though: a `$[…]`/`$(…)` substitution
28
+ * inside it is code that may carry strings of its own, so where a string
29
+ * ends is decided by the one shared `scanQuotedString` (#199).
30
+ * H4 `#` starts a comment in statement-leading position (start of input, or
31
+ * the first non-space after a separator in a statement-bearing context)
32
+ * — and, per H5 below, at the immediate start of a line that a
33
+ * continuation carried into. A scope/stored-script brace starts such a
34
+ * context; an array brace or parenthesized expression does not, and an
35
+ * unquoted `#` there is a hard device error (#245). A real comment runs to
36
+ * end of line and produces no statement, so its apostrophes and braces
37
+ * cannot leak into the delimiter stack.
38
+ * H5 A backslash at end of line is a continuation: it does not separate. Its
39
+ * reach is wider than one line, and was measured on CHR 7.23.3 (#215):
40
+ * - blank and whitespace-only lines right after the `\` are part of the
41
+ * continuation (the device classes the whole run `escaped`);
42
+ * - a `#` at the IMMEDIATE start of such a line is a comment, and the
43
+ * comment line does not end the pending statement — `:local \` +
44
+ * newline + `# c` + newline + `foo 1` still declares `foo`;
45
+ * - after a continuation COMMENT line the reach is spent: the next
46
+ * blank line ENDS the statement (where directly after the `\` it
47
+ * would not), while ordinary content simply continues it;
48
+ * - a continuation never moves statement-leading position. `do={\` +
49
+ * newline + `:local x 1` still reads `:local` as the head, and an
50
+ * indented `#` is a comment there (lead) but content mid-statement.
51
+ * H6 Empty statements (`;;`, blank lines) produce nothing.
52
+ * H7 A statement that is only a `{…}` group (optionally behind a bare menu
53
+ * path) is a CONTAINER: IL flattens its children into siblings, so its
54
+ * children are segmented in its place.
55
+ *
56
+ * The scan is a single left-to-right pass (Q17, #190): one index-based walker
57
+ * with an explicit `frames` stack does H7 flattening inline — no per-container
58
+ * recursion and no re-scan of container bodies. Each open container is a frame
59
+ * whose inner statements accumulate in a buffer; on its closing `}` the frame
60
+ * either COMMITS (its buffered statements, behind an optional menu-prefix
61
+ * segment, replace the container) or is DISCARDED (an empty body, trailing
62
+ * content after the `}`, or an unclosed brace — the parent statement absorbs the
63
+ * whole `{…}` verbatim). This reproduces the recursive `containerOpen` +
64
+ * body re-scan contract the earlier version pinned, at O(n) time / O(depth)
65
+ * space with no stack-overflow surface.
66
+ *
67
+ * H7 stays depth-bounded even so. `explain` accepts untrusted editor/MCP input,
68
+ * so `MAX_CONTAINER_DEPTH` container frames is a hard cap: a `{` that would open
69
+ * a deeper container is treated as an opaque group instead, the innermost
70
+ * remainder stays one segment, and an `over-depth` defect is raised at that
71
+ * offset. With recursion gone the cap is now a resource guard (bounding the
72
+ * `frames` array and abstention granularity), not the stack-safety boundary it
73
+ * once was.
74
+ */
75
+
76
+ import {
77
+ analyzeCoordinates,
78
+ type CoordinateAnalysis,
79
+ coordinateDefects,
80
+ runAtByte,
81
+ } from "./coordinates.ts";
82
+ import { type Defect, defectAt, mergeDefects } from "./defects.ts";
83
+ import {
84
+ MAX_STRING_FRAME_DEPTH,
85
+ scanQuotedString as scanQuotedStringShared,
86
+ } from "./quoted-string.ts";
87
+ import {
88
+ braceStartsStatements,
89
+ canRetainStatementIndex,
90
+ hashStartsHardError,
91
+ isIndexedStatementStart,
92
+ matchingBraceEnd,
93
+ scopeNameFromMasked,
94
+ } from "./scope-brace.ts";
95
+
96
+ /** One top-level statement located in analyzed-byte space. */
97
+ export interface Segment {
98
+ /** analyzed-byte offset, inclusive. */
99
+ start: number;
100
+ /** analyzed-byte offset, exclusive. */
101
+ end: number;
102
+ /** the original (human-readable) statement text, trimmed. */
103
+ text: string;
104
+ /** the separator that ENDED this statement, for the boundary taxonomy. */
105
+ terminator: ";" | "newline" | "eof";
106
+ /**
107
+ * true when the statement *looks* like pure menu navigation: a `/`-rooted
108
+ * path of path-shaped tokens with no `=`, quotes, brackets, or `$`. This is
109
+ * a SHAPE hint, not a verb claim — offline (schema-free) cannot tell a
110
+ * trailing verb (`/ip address print`) from a deeper menu (`/ip address`), so
111
+ * both are `menuOnly: true`. Q4/Q6 decide what it means. See `isMenuOnly`.
112
+ */
113
+ menuOnly: boolean;
114
+ }
115
+
116
+ /** The full segmentation of one input string. */
117
+ export interface SegmentResult {
118
+ segments: Segment[];
119
+ /** comment spans (analyzed-byte offsets), dropped from the statement stream. */
120
+ comments: { start: number; end: number }[];
121
+ /**
122
+ * The input was pure ASCII, so an analyzed-byte offset into it is also a JS
123
+ * string index — the test a caller needs before it may treat the spans above
124
+ * as offsets into `original` itself. Published from the coordinate pass this
125
+ * result already ran rather than re-derived, so the two cannot disagree.
126
+ */
127
+ ascii: boolean;
128
+ /**
129
+ * Unbalanced delimiters and other structural surprises, each located; never
130
+ * a throw. Includes the two coordinate-pass classes (`bom`, `non-ascii`).
131
+ */
132
+ defects: Defect[];
133
+ }
134
+
135
+ const isSpace = (c: string): boolean => c === " " || c === "\t" || c === "\r";
136
+
137
+ /** Where a double-quoted string ends, and whether it was closed at all. */
138
+ export interface QuotedStringScan {
139
+ /** index just past the closing `"`, or `text.length` when unterminated. */
140
+ end: number;
141
+ closed: boolean;
142
+ }
143
+
144
+ /**
145
+ * Find the end of the double-quoted string that opens at `open` (H3).
146
+ *
147
+ * The one shared string skip for every structural scan in `explain`. A string is
148
+ * NOT opaque up to the next `"`: a `$[…]` or `$(…)` substitution inside it is
149
+ * CODE, and that code may open strings of its own —
150
+ * `:local a "$[[:parse "(\"x\")"]]"` is ONE string on the device (CHR 7.23.2
151
+ * `/console/inspect request=highlight` classes the following `#` line `comment`
152
+ * and both `$a` occurrences `variable-local`). A scanner that stops at the first
153
+ * nested `"` flips its quote phase and reads every later comment as string
154
+ * content; on the frozen 913-script corpus that hid 2,184 device-`comment` bytes
155
+ * across 7 files, and cost `symbols.ts` 3.1 points of precision before it grew
156
+ * the frame model this function now shares (#198/#199).
157
+ *
158
+ * Frames mirror `symbols.ts`: a `"` frame is string phase (`\` escapes the next
159
+ * character, `$[`/`$(` push a code frame), anything else is code phase (`"`
160
+ * opens a nested string, brackets nest, a mismatched close is content rather
161
+ * than a close — the same line `segment.ts` and `symbols.ts` take elsewhere).
162
+ * Iterative, so deeply nested input cannot overflow the stack. Escapes and
163
+ * comments inside the substitution are NOT interpreted here: the whole string,
164
+ * substitutions included, stays opaque to the caller — only its END moves.
165
+ *
166
+ * Frame depth is capped for the same reason `MAX_CONTAINER_DEPTH` is: `explain`
167
+ * accepts untrusted editor/MCP input. Past `MAX_STRING_FRAME_DEPTH` the scan
168
+ * stops and reports the string UNCLOSED — fail-closed, the direction every
169
+ * caller already handles (`unterminated-string` → `structuralDefect` →
170
+ * `unresolved`). Only an input that opens that many frames without closing them
171
+ * can reach it, which is malformed by construction; sequential substitutions
172
+ * (`"$[a]$[b]…"`) pop and never accumulate.
173
+ */
174
+ export function scanQuotedString(
175
+ text: string,
176
+ open: number,
177
+ limit: number = text.length,
178
+ ): QuotedStringScan {
179
+ return scanQuotedStringWithin(text, open, limit, MAX_STRING_FRAME_DEPTH);
180
+ }
181
+
182
+ function scanQuotedStringWithin(
183
+ text: string,
184
+ open: number,
185
+ limit: number,
186
+ frameBudget: number,
187
+ ): QuotedStringScan {
188
+ return scanQuotedStringShared(
189
+ text,
190
+ open,
191
+ limit,
192
+ (source, bracket, end, frameDepth) =>
193
+ scanStringInterpolationWithin(
194
+ source,
195
+ bracket,
196
+ end,
197
+ frameBudget - frameDepth,
198
+ ).end,
199
+ frameBudget,
200
+ );
201
+ }
202
+
203
+ /**
204
+ * How far a H5 `\<newline>` continuation still reaches (#215, CHR 7.23.3).
205
+ *
206
+ * "none" — no continuation pending.
207
+ * "escape" — inside the `\<newline>` run itself. Blank and whitespace-only
208
+ * lines stay inside it (the device classes them `escaped`).
209
+ * "comment" — the run was ended by a continuation COMMENT line, which does
210
+ * not end the pending statement but does spend the run's reach:
211
+ * a following blank line now terminates.
212
+ *
213
+ * `Continuation` and the `lineStart` flag beside it are the vocabulary the
214
+ * three parallel walkers (`maskComments`, `scanAscii`, `symbols.ts`) share, so
215
+ * a change to the rule can be made in the same shape in each. #199 was two of
216
+ * them drifting apart; this state is where the third one can.
217
+ */
218
+ export type Continuation = "none" | "escape" | "comment";
219
+
220
+ /**
221
+ * Blank out RouterOS comments so a later delimiter re-scan cannot be fooled by
222
+ * `#` text (a `}` or `[` inside a comment is not a real delimiter). A `#` starts
223
+ * a comment in statement-leading position (H4) or at the immediate start of a
224
+ * line a continuation carried into (H5). Statement-leading is contextual:
225
+ * scope/stored-script braces admit it, array/group expressions do not. Any
226
+ * intervening space or tab makes a continuation-line `#` content unless the
227
+ * surrounding statement context is still empty. Comments are opaque inside
228
+ * strings. Comment characters become spaces; length and every non-comment
229
+ * offset are preserved, so indices stay valid against the original and callers
230
+ * can slice the original for content. Idempotent.
231
+ */
232
+ export function maskComments(original: string): string {
233
+ if (recentMaskInput === original && recentMaskOutput !== undefined)
234
+ return recentMaskOutput;
235
+ const cached = maskCache.get(original);
236
+ if (cached !== undefined) {
237
+ // Refresh recency — see `scope-brace.ts` → `statementIndex` for why an
238
+ // insertion-ordered eviction loses exactly the entry worth keeping.
239
+ maskCache.delete(original);
240
+ maskCache.set(original, cached);
241
+ adoptMask(original, cached);
242
+ return cached;
243
+ }
244
+ const masked = maskUncached(original);
245
+ if (cacheMask(original, masked)) adoptMask(original, masked);
246
+ return masked;
247
+ }
248
+
249
+ const MASK_CACHE_LIMIT = 4;
250
+ const MASK_CACHE_BYTE_LIMIT = 8 * 1024 * 1024;
251
+ const maskCache = new Map<string, string>();
252
+ let maskCacheBytes = 0;
253
+ // Same shape, and the same reason, as `scope-brace.ts`'s boundary cache: one
254
+ // slot for the hot object, a small bounded map behind it, and adoption on a hit
255
+ // so a content-equal twin is compared once rather than on every call.
256
+ let recentMaskInput: string | undefined;
257
+ let recentMaskOutput: string | undefined;
258
+
259
+ /** Adopt `original` as the single-slot key (see `scope-brace.ts` → `adoptBoundaryText`). */
260
+ function adoptMask(original: string, masked: string): void {
261
+ recentMaskInput = original;
262
+ recentMaskOutput = masked;
263
+ }
264
+
265
+ function maskCacheEntryBytes(original: string, masked: string): number {
266
+ // Comment-free input is its own mask, so that entry holds one string.
267
+ return (original.length + (masked === original ? 0 : masked.length)) * 2;
268
+ }
269
+
270
+ function cacheMask(original: string, masked: string): boolean {
271
+ const entryBytes = maskCacheEntryBytes(original, masked);
272
+ if (entryBytes > MASK_CACHE_BYTE_LIMIT) return false;
273
+ while (
274
+ maskCache.size >= MASK_CACHE_LIMIT ||
275
+ maskCacheBytes + entryBytes > MASK_CACHE_BYTE_LIMIT
276
+ ) {
277
+ const oldest = maskCache.entries().next().value;
278
+ if (oldest === undefined) break;
279
+ maskCache.delete(oldest[0]);
280
+ maskCacheBytes -= maskCacheEntryBytes(oldest[0], oldest[1]);
281
+ if (recentMaskInput === oldest[0]) {
282
+ recentMaskInput = undefined;
283
+ recentMaskOutput = undefined;
284
+ }
285
+ }
286
+ maskCache.set(original, masked);
287
+ maskCacheBytes += entryBytes;
288
+ return true;
289
+ }
290
+
291
+ /**
292
+ * A region of one document, carried with that document's comment mask.
293
+ *
294
+ * Every reader below the statement walk asks its questions of a statement's
295
+ * `text` or of a `do={…}` body — and a statement's text CONTAINS its whole
296
+ * nested subtree, so each enclosing level re-derived the mask, and the
297
+ * statement index behind it, over bytes an inner level had already read. That
298
+ * is the O(bytes x depth) term: a 60 KiB document nested 64 deep masked 27.7 MB
299
+ * (#322).
300
+ *
301
+ * A range replaces the substring. `text` and `masked` are always the WHOLE
302
+ * document; `start` and `end` say which part of it this reader may look at. The
303
+ * mask is then derived once per document and every nested reading is an offset
304
+ * into it.
305
+ *
306
+ * Soundness is a property of where the ranges come from, not of this type: a
307
+ * comment mask is decided by the statement context a byte sits in, and a
308
+ * statement boundary and a scope body both begin one — statement-leading, no
309
+ * continuation pending, an enclosing context that admits statements. So the
310
+ * mask of such a region is the document's mask restricted to it. Readers still
311
+ * have to be told the floor (see `scope-brace.ts` → `scopeNameFromMasked`) so
312
+ * that lookback stops where a fresh call on the region alone would stop.
313
+ */
314
+ export interface MaskedRange {
315
+ /** The whole document. Offsets below index into this, never into a slice. */
316
+ readonly text: string;
317
+ /** `maskComments(text)`. */
318
+ readonly masked: string;
319
+ readonly start: number;
320
+ readonly end: number;
321
+ /** This range's string indices are analyzed-byte offsets. */
322
+ readonly coordinateIdentity?: true;
323
+ }
324
+
325
+ /** The range covering all of `text`, masking it once. */
326
+ export function documentRange(
327
+ text: string,
328
+ coordinateIdentity = false,
329
+ ): MaskedRange {
330
+ return {
331
+ text,
332
+ masked: maskComments(text),
333
+ start: 0,
334
+ end: text.length,
335
+ ...(coordinateIdentity ? { coordinateIdentity: true as const } : {}),
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Locate one `$[…]` body's closing bracket using its own comment context.
341
+ *
342
+ * The document mask deliberately leaves an enclosing string opaque. Once `$[`
343
+ * enters code, statement-leading comments are real again, so brackets inside
344
+ * them cannot close or extend the substitution. The returned range starts at
345
+ * `open` and retains that local mask for the readers of the body.
346
+ */
347
+ export function scanStringInterpolation(
348
+ text: string,
349
+ open: number,
350
+ limit: number = text.length,
351
+ ): { end: number; range: MaskedRange } {
352
+ return scanStringInterpolationWithin(
353
+ text,
354
+ open,
355
+ limit,
356
+ MAX_STRING_FRAME_DEPTH - 1,
357
+ );
358
+ }
359
+
360
+ function scanStringInterpolationWithin(
361
+ text: string,
362
+ open: number,
363
+ limit: number,
364
+ frameBudget: number,
365
+ ): { end: number; range: MaskedRange } {
366
+ const local = text.slice(open, limit);
367
+ // Masking must find each code-level string boundary comment-aware. Reuse
368
+ // those answers below instead of recursively scanning the same strings again.
369
+ const quotedEnds = new Map<number, number>();
370
+ const range: MaskedRange = {
371
+ text: local,
372
+ masked: maskUncached(local, frameBudget, quotedEnds),
373
+ start: 0,
374
+ end: local.length,
375
+ };
376
+ if (frameBudget < 1) return { end: limit, range };
377
+ const frames: string[] = ["["];
378
+ for (let i = 1; i < range.end; i++) {
379
+ const c = range.masked[i];
380
+ if (c === '"') {
381
+ const quotedEnd = quotedEnds.get(i);
382
+ if (quotedEnd === undefined) return { end: limit, range };
383
+ i = quotedEnd - 1;
384
+ continue;
385
+ }
386
+ if (c === "[" || c === "(" || c === "{") {
387
+ if (frames.length >= frameBudget) return { end: limit, range };
388
+ frames.push(c);
389
+ continue;
390
+ }
391
+ if (c !== "]" && c !== ")" && c !== "}") continue;
392
+ const want = c === "]" ? "[" : c === ")" ? "(" : "{";
393
+ if (frames.at(-1) !== want) continue;
394
+ frames.pop();
395
+ if (frames.length === 0) return { end: open + i, range };
396
+ }
397
+ return { end: limit, range };
398
+ }
399
+
400
+ /**
401
+ * The sub-range `[start, end)` of `parent`, in `parent`'s own coordinates.
402
+ *
403
+ * Offsets must be JS string indices — for a statement span that means the
404
+ * document was ASCII ({@link SegmentResult.ascii}), which is the only case in
405
+ * which an analyzed-byte offset is one.
406
+ */
407
+ export function nestedRange(
408
+ parent: MaskedRange,
409
+ start: number,
410
+ end: number,
411
+ ): MaskedRange {
412
+ return {
413
+ text: parent.text,
414
+ masked: parent.masked,
415
+ start: parent.start + start,
416
+ end: parent.start + end,
417
+ ...(parent.coordinateIdentity ? { coordinateIdentity: true as const } : {}),
418
+ };
419
+ }
420
+
421
+ /**
422
+ * The range `[start, end)` of the same document, in ABSOLUTE offsets.
423
+ *
424
+ * The counterpart to {@link nestedRange}, for a reader that is already working
425
+ * in the document's own offsets rather than in a parent's.
426
+ */
427
+ export function rangeAt(
428
+ parent: MaskedRange,
429
+ start: number,
430
+ end: number,
431
+ ): MaskedRange {
432
+ return {
433
+ text: parent.text,
434
+ masked: parent.masked,
435
+ start,
436
+ end,
437
+ ...(parent.coordinateIdentity ? { coordinateIdentity: true as const } : {}),
438
+ };
439
+ }
440
+
441
+ /** The comment mask of what this range covers. */
442
+ export function rangeMask(range: MaskedRange): string {
443
+ return range.masked.slice(range.start, range.end);
444
+ }
445
+
446
+ /**
447
+ * {@link rangeMask}, tolerating no range — for readers that take one OPTIONALLY
448
+ * and fall back to masking their own text: `maskOf(range) ?? maskComments(text)`.
449
+ */
450
+ export function maskOf(range: MaskedRange | undefined): string | undefined {
451
+ return range === undefined ? undefined : rangeMask(range);
452
+ }
453
+
454
+ /**
455
+ * The mask itself. Pure, so the memo above cannot change an answer — only how
456
+ * often it is computed.
457
+ *
458
+ * The walk asks for the mask of the SAME statement several times over: the
459
+ * structural-defect scan, the scope-block scan and the bracket scan each take
460
+ * one, and the bare-directive test takes another over the trimmed spelling.
461
+ * Each of those re-ran this scan AND, through `braceStartsStatements`, the
462
+ * statement index behind it — and a statement's text carries its whole nested
463
+ * subtree, so the repetition was paid once per enclosing level (#322).
464
+ */
465
+ function maskUncached(
466
+ original: string,
467
+ frameBudget: number = MAX_STRING_FRAME_DEPTH,
468
+ quotedEnds?: Map<number, number>,
469
+ ): string {
470
+ // Masked output is built from whole slices, and only once a comment is
471
+ // actually found: comment-free input is returned as the SAME string object.
472
+ // `split("")` allocated one string per character of every call — 16 walks
473
+ // over a 64 KiB document made a million of them — where a document's comment
474
+ // runs are a handful of slices (#313).
475
+ let pieces: string[] | undefined;
476
+ let copied = 0;
477
+ const contexts: { char: "{" | "[" | "("; statements: boolean }[] = [];
478
+ let atLead = true;
479
+ let cont: Continuation = "none";
480
+ let contLineStart = false;
481
+ for (let i = 0; i < original.length; i++) {
482
+ const c = original[i];
483
+ const inCont = cont !== "none";
484
+ const contComment = c === "#" && inCont && contLineStart;
485
+ if (inCont && !contComment) {
486
+ if (c === " " || c === "\t" || c === "\r") {
487
+ contLineStart = false;
488
+ } else if (c === "\n" && cont === "escape") {
489
+ contLineStart = true;
490
+ continue; // a blank line inside the `\` run is still the run
491
+ } else {
492
+ // Real content, or the blank line that spends a comment run's reach.
493
+ cont = "none";
494
+ contLineStart = false;
495
+ }
496
+ }
497
+ if (c === '"') {
498
+ atLead = false;
499
+ const end = scanQuotedStringWithin(
500
+ original,
501
+ i,
502
+ original.length,
503
+ frameBudget - contexts.length,
504
+ ).end;
505
+ quotedEnds?.set(i, end);
506
+ i = end - 1;
507
+ continue; // i rests on the closing quote (or past end)
508
+ }
509
+ // H4 is unchanged and H5 only ADDS the immediate-line-start case: an
510
+ // indented `#` is a comment when the statement is still empty (`do={\` +
511
+ // newline + ` # c`) and content when it is not (`:put a\` + newline +
512
+ // ` # x`, which the device classes `error`) — exactly what `atLead` says.
513
+ if (c === "#" && (atLead || contComment)) {
514
+ const commentStart = i;
515
+ while (i < original.length && original[i] !== "\n") i++;
516
+ if (pieces === undefined) pieces = [];
517
+ pieces.push(
518
+ original.slice(copied, commentStart),
519
+ " ".repeat(i - commentStart),
520
+ );
521
+ copied = i;
522
+ if (contComment) {
523
+ // The comment line does not end the statement: step over its newline
524
+ // (the loop's i++) with the continuation still pending.
525
+ cont = "comment";
526
+ contLineStart = true;
527
+ continue;
528
+ }
529
+ i--; // let the loop advance onto the newline (or end)
530
+ continue;
531
+ }
532
+ if (
533
+ c === "\\" &&
534
+ (original[i + 1] === "\n" ||
535
+ (original[i + 1] === "\r" && original[i + 2] === "\n"))
536
+ ) {
537
+ // H5 leaves `atLead` alone: `do={\` + newline + `:local x 1` still reads
538
+ // `:local` as the statement head on the device.
539
+ cont = "escape";
540
+ contLineStart = true;
541
+ i += original[i + 1] === "\r" ? 2 : 1;
542
+ continue;
543
+ }
544
+ if (c === "{" || c === "[" || c === "(") {
545
+ const enclosing = contexts.at(-1)?.statements ?? true;
546
+ const statements =
547
+ c === "[" ||
548
+ (c === "{" && enclosing && braceStartsStatements(original, i));
549
+ contexts.push({ char: c, statements });
550
+ // A bracket is a nested statement context too: `[# c\n:put 1]` starts
551
+ // with a real comment, while `[:put #value]` consumes the lead on `:put`
552
+ // and keeps the hash as that command's value.
553
+ atLead = statements;
554
+ } else if (c === "}" || c === "]" || c === ")") {
555
+ const want = c === "}" ? "{" : c === "]" ? "[" : "(";
556
+ if (contexts[contexts.length - 1]?.char === want) contexts.pop();
557
+ atLead = false;
558
+ } else if (c === ";" || c === "\n") {
559
+ atLead = contexts.at(-1)?.statements ?? true;
560
+ } else if (c !== " " && c !== "\t" && c !== "\r") atLead = false;
561
+ }
562
+ if (pieces === undefined) return original;
563
+ pieces.push(original.slice(copied));
564
+ return pieces.join("");
565
+ }
566
+
567
+ /**
568
+ * Container-frame depth cap.
569
+ *
570
+ * Not a RouterOS grammar limit — an implementation guard derived from the Q17
571
+ * stress finding (the lab walker overflowed the JS stack around 32k nested
572
+ * containers). The single-pass scanner no longer recurses, so this is now a
573
+ * resource guard rather than a stack-safety boundary: 256 preserves far more
574
+ * nesting than normal scripts use while bounding the `frames` array. A `{` that
575
+ * would open frame 257 is kept as an opaque group and the analysis abstains with
576
+ * an `over-depth:<analyzed-byte-offset>` note.
577
+ */
578
+ const MAX_CONTAINER_DEPTH = 256;
579
+
580
+ /**
581
+ * Segment `original` into top-level statements. Spans are analyzed-byte offsets
582
+ * (see module header); `text` is the original substring for each span.
583
+ *
584
+ * A statement-bearing `range` may anchor the same `original` text in its
585
+ * enclosing document. On ASCII input, structural lookback can reuse that
586
+ * document's statement index instead of indexing every nested body (#322).
587
+ * The scan still reads the original bytes, including comments: replacing them
588
+ * with the mask would lose comment spans and H5 continuation state. Non-ASCII
589
+ * input, or a body opener skipped by the raw document index (a quote inside an
590
+ * earlier comment), keeps the standalone analyzed-byte scan.
591
+ */
592
+ export function segmentStatements(
593
+ original: string,
594
+ range?: MaskedRange,
595
+ ): SegmentResult {
596
+ return segmentStatementsInternal(original, range, false);
597
+ }
598
+
599
+ /** Segment one known scope body while treating its child scopes as opaque. */
600
+ export function segmentScopeBody(
601
+ original: string,
602
+ range: MaskedRange,
603
+ ): SegmentResult {
604
+ return segmentStatementsInternal(
605
+ original,
606
+ range,
607
+ range.coordinateIdentity === true,
608
+ );
609
+ }
610
+
611
+ function segmentStatementsInternal(
612
+ original: string,
613
+ range: MaskedRange | undefined,
614
+ opaqueChildScopes: boolean,
615
+ ): SegmentResult {
616
+ const identity =
617
+ range?.coordinateIdentity === true &&
618
+ range.end - range.start === original.length;
619
+ const analysis = identity ? undefined : analyzeCoordinates(original);
620
+ // `analyzed` is pure ASCII, so a string built from it has index === byte.
621
+ // When the INPUT was already ASCII the two are the same sequence, so the
622
+ // original is that string and the decode is skipped (#322).
623
+ const ascii =
624
+ analysis === undefined || analysis.ascii
625
+ ? original
626
+ : new TextDecoder().decode(analysis.analyzed);
627
+ const anchored =
628
+ (analysis === undefined || analysis.ascii) &&
629
+ range !== undefined &&
630
+ canRetainStatementIndex(range.text) &&
631
+ isIndexedStatementStart(range.text, range.start)
632
+ ? range
633
+ : undefined;
634
+ const raw = scanAscii(ascii, anchored, opaqueChildScopes);
635
+ // Recover the human-readable `text` for each segment from the original.
636
+ const segments = raw.segments.map((s) => ({
637
+ ...s,
638
+ text:
639
+ analysis === undefined
640
+ ? original.slice(s.start, s.end)
641
+ : originalSlice(analysis, s.start, s.end),
642
+ }));
643
+ return {
644
+ segments,
645
+ comments: raw.comments,
646
+ ascii: analysis?.ascii ?? true,
647
+ // Coordinate-pass regions first: they are facts about the input as
648
+ // received, and they exist even when the scan finds nothing structural.
649
+ defects: mergeDefects(
650
+ analysis === undefined ? [] : coordinateDefects(analysis),
651
+ raw.defects,
652
+ ),
653
+ };
654
+ }
655
+
656
+ /** Original substring for an analyzed-byte span (boundaries are char-aligned). */
657
+ function originalSlice(
658
+ a: CoordinateAnalysis,
659
+ start: number,
660
+ end: number,
661
+ ): string {
662
+ return a.original.slice(utf16At(a, start), utf16At(a, end));
663
+ }
664
+
665
+ /** UTF-16 offset in the original for an analyzed-byte boundary. */
666
+ function utf16At(a: CoordinateAnalysis, byte: number): number {
667
+ if (byte >= a.analyzed.length) return a.original.length;
668
+ // Identity on an all-ASCII input: one byte per code unit in both spaces.
669
+ return a.ascii ? byte : runAtByte(a, byte).utf16Start;
670
+ }
671
+
672
+ /** A located statement before its `text` is recovered from the original. */
673
+ type RawSegment = Omit<Segment, "text">;
674
+
675
+ /**
676
+ * One nesting level of the single-pass walker. The top level is `frames[0]`
677
+ * (`container: false`); each open CONTAINER `{…}` pushes a frame. Statements
678
+ * accumulate in the active (innermost) frame's `buffer`; a container's buffer is
679
+ * spliced into its parent on COMMIT and dropped on DISCARD.
680
+ */
681
+ interface Frame {
682
+ /** in-progress statement start in analyzed-byte space, or -1 (H6). */
683
+ stmtStart: number;
684
+ /** H4 — still before the first real token of the current statement? */
685
+ atLead: boolean;
686
+ /** H5 — how far the pending `\<newline>` continuation still reaches. */
687
+ cont: Continuation;
688
+ /** H5 — at the immediate start of a line the continuation carried into? */
689
+ contLineStart: boolean;
690
+ /**
691
+ * A mismatched close occurred in this statement/container. A container with
692
+ * a known structural defect must DISCARD rather than promote its buffered
693
+ * children as confident sibling statements.
694
+ */
695
+ structurallyInvalid: boolean;
696
+ /** Start/end of the prefix range already classified for H7. */
697
+ prefixScanStart: number;
698
+ prefixScanEnd: number;
699
+ /** Incremental equivalent of trimming and checking the H7 prefix. */
700
+ prefixSawNonSpace: boolean;
701
+ prefixValid: boolean;
702
+ /** statements collected here; the flattened output for a committed container. */
703
+ buffer: RawSegment[];
704
+ /** true for a container frame; false for the synthetic top level. */
705
+ container: boolean;
706
+ /** analyzed-byte index of the `{` that opened this container. */
707
+ openIndex: number;
708
+ /**
709
+ * Start of the statement this container belongs to — the menu prefix before
710
+ * the `{`. On DISCARD the parent statement resumes from here, so the whole
711
+ * `{…}` is absorbed verbatim, matching the old `containerOpen` = -1 path.
712
+ */
713
+ prefixStart: number;
714
+ }
715
+
716
+ /**
717
+ * Single left-to-right walk of a pure-ASCII string (byte === string index),
718
+ * flattening H7 containers inline. Returns spans only; `segmentStatements`
719
+ * recovers each `text` from the original afterward.
720
+ */
721
+ function scanAscii(
722
+ ascii: string,
723
+ range?: MaskedRange,
724
+ opaqueChildScopes = false,
725
+ ): {
726
+ segments: RawSegment[];
727
+ comments: { start: number; end: number }[];
728
+ defects: Defect[];
729
+ } {
730
+ // Local indices remain local in the result; only index-backed questions use
731
+ // document coordinates. The floor prevents an enclosing head from becoming
732
+ // the head of the body being segmented.
733
+ const indexed = range?.text ?? ascii;
734
+ const structural = range?.masked ?? ascii;
735
+ const floor = range?.start ?? 0;
736
+ const comments: { start: number; end: number }[] = [];
737
+ const defects: Defect[] = [];
738
+ const overDepth: number[] = [];
739
+ let hardHashSeen = false;
740
+ let hardApostropheSeen = false;
741
+ // H2 — every open bracket, for balance. Each frame carries WHERE it opened so
742
+ // an `unclosed` defect can point at the opener rather than at the end of
743
+ // input.
744
+ const delimStack: { char: string; at: number; statements: boolean }[] = [];
745
+ const top: Frame = {
746
+ stmtStart: -1,
747
+ atLead: true,
748
+ cont: "none",
749
+ contLineStart: false,
750
+ structurallyInvalid: false,
751
+ prefixScanStart: -1,
752
+ prefixScanEnd: -1,
753
+ prefixSawNonSpace: false,
754
+ prefixValid: true,
755
+ buffer: [],
756
+ container: false,
757
+ openIndex: -1,
758
+ prefixStart: -1,
759
+ };
760
+ const frames: Frame[] = [top];
761
+ const cur = (): Frame => frames[frames.length - 1] as Frame;
762
+
763
+ // At container level when every open bracket is a container `{` — i.e. no
764
+ // value bracket (`[ ( ` or a non-container `{`) is open. Only then do
765
+ // separators split and only then can a `{` open a new container. (Container
766
+ // frames only open at this level, so they always sit at the bottom of
767
+ // `delimStack`; plain brackets sit above.)
768
+ const atContainerLevel = (): boolean =>
769
+ delimStack.length === frames.length - 1;
770
+
771
+ const ensureStmt = (f: Frame, i: number): void => {
772
+ if (f.stmtStart < 0) f.stmtStart = i;
773
+ };
774
+
775
+ const resetPrefixScan = (f: Frame): void => {
776
+ f.prefixScanStart = -1;
777
+ f.prefixScanEnd = -1;
778
+ f.prefixSawNonSpace = false;
779
+ f.prefixValid = true;
780
+ };
781
+
782
+ const makeSegment = (
783
+ start: number,
784
+ end: number,
785
+ terminator: Segment["terminator"],
786
+ ): RawSegment | null => {
787
+ const raw = ascii.slice(start, end);
788
+ const trimmed = raw.trim();
789
+ if (trimmed.length === 0) return null; // H6
790
+ const lead = raw.length - raw.trimStart().length;
791
+ const s = start + lead;
792
+ return {
793
+ start: s,
794
+ end: s + trimmed.length,
795
+ terminator,
796
+ menuOnly: isMenuOnly(trimmed),
797
+ };
798
+ };
799
+
800
+ const flush = (
801
+ f: Frame,
802
+ end: number,
803
+ terminator: Segment["terminator"],
804
+ ): void => {
805
+ if (f.stmtStart < 0) return;
806
+ const s = makeSegment(f.stmtStart, end, terminator);
807
+ f.stmtStart = -1;
808
+ resetPrefixScan(f);
809
+ if (s) f.buffer.push(s);
810
+ // The synthetic top frame can recover at a real statement boundary. A
811
+ // container stays poisoned until it closes so a defect in one buffered
812
+ // child cannot make later children look independently trustworthy.
813
+ if (!f.container) f.structurallyInvalid = false;
814
+ };
815
+
816
+ // A container prefix is empty or a bare `/`-menu path with no `=[($"` — the
817
+ // same test the old `containerOpen` applied to the group's prefix.
818
+ const validPrefix = (f: Frame, start: number, end: number): boolean => {
819
+ if (f.prefixScanStart !== start || f.prefixScanEnd > end) {
820
+ resetPrefixScan(f);
821
+ f.prefixScanStart = start;
822
+ f.prefixScanEnd = start;
823
+ }
824
+ if (!f.prefixValid) {
825
+ f.prefixScanEnd = end;
826
+ return false;
827
+ }
828
+ for (let i = f.prefixScanEnd; i < end; i++) {
829
+ const c = ascii[i] as string;
830
+ if (!f.prefixSawNonSpace) {
831
+ if (c.trim().length === 0) continue;
832
+ f.prefixSawNonSpace = true;
833
+ if (c !== "/") {
834
+ f.prefixValid = false;
835
+ break;
836
+ }
837
+ }
838
+ if (/[=[($"]/.test(c)) {
839
+ f.prefixValid = false;
840
+ break;
841
+ }
842
+ }
843
+ f.prefixScanEnd = end;
844
+ return f.prefixValid;
845
+ };
846
+
847
+ const openContainer = (i: number): void => {
848
+ const parent = cur();
849
+ // prefixStart = the parent statement's start, or the `{` itself when the
850
+ // container has no prefix (so a DISCARD absorbs from the `{`).
851
+ const prefixStart = parent.stmtStart >= 0 ? parent.stmtStart : i;
852
+ delimStack.push({ char: "{", at: i, statements: true });
853
+ frames.push({
854
+ stmtStart: -1,
855
+ atLead: true,
856
+ cont: "none",
857
+ contLineStart: false,
858
+ structurallyInvalid: parent.structurallyInvalid,
859
+ prefixScanStart: -1,
860
+ prefixScanEnd: -1,
861
+ prefixSawNonSpace: false,
862
+ prefixValid: true,
863
+ buffer: [],
864
+ container: true,
865
+ openIndex: i,
866
+ prefixStart,
867
+ });
868
+ };
869
+
870
+ // A container's `}` ends the parent statement (so the group is a real trailing
871
+ // container) when the next non-blank char is a separator, end of input, or the
872
+ // `}` that closes an enclosing container. Any other trailing content means the
873
+ // `}` was mid-statement — the old `containerOpen` = -1 case.
874
+ const trailingEndsStatement = (closeIndex: number): boolean => {
875
+ let j = closeIndex + 1;
876
+ while (j < ascii.length && isSpace(ascii[j] as string)) j++;
877
+ if (j >= ascii.length) return true;
878
+ const n = ascii[j];
879
+ if (n === ";" || n === "\n") return true;
880
+ if (n === "}") return cur().container; // closes the parent container
881
+ return false;
882
+ };
883
+
884
+ const closeContainer = (i: number): void => {
885
+ const frame = frames.pop() as Frame;
886
+ // Flush the body's last statement. Its terminator is "eof" — the end of
887
+ // the container body, matching the old per-body scan; the `}` itself is
888
+ // not a statement terminator token (only `;`/newline/actual EOF are).
889
+ flush(frame, i, "eof");
890
+ const parent = cur();
891
+ if (
892
+ !frame.structurallyInvalid &&
893
+ frame.buffer.length > 0 &&
894
+ trailingEndsStatement(i)
895
+ ) {
896
+ // COMMIT: menu-prefix segment (if any), then the buffered body, replace
897
+ // the container in the parent stream; the parent statement is consumed.
898
+ const prefix = makeSegment(frame.prefixStart, frame.openIndex, "newline");
899
+ if (prefix) parent.buffer.push(prefix);
900
+ for (const s of frame.buffer) parent.buffer.push(s);
901
+ parent.stmtStart = -1;
902
+ resetPrefixScan(parent);
903
+ } else {
904
+ // DISCARD: empty body or trailing content — the parent statement absorbs
905
+ // the whole `{…}` verbatim by resuming from the container's prefix.
906
+ parent.stmtStart = frame.prefixStart;
907
+ parent.structurallyInvalid ||= frame.structurallyInvalid;
908
+ }
909
+ parent.atLead = false;
910
+ };
911
+
912
+ let i = 0;
913
+ while (i < ascii.length) {
914
+ const c = ascii[i] as string;
915
+ const f = cur();
916
+
917
+ // H4/H5 — a comment is statement-leading, or at the immediate start of a
918
+ // line a continuation carried into. Both are decided from state as it
919
+ // stands BEFORE the continuation bookkeeping below, so an indented `#`
920
+ // mid-continuation stays content.
921
+ const inCont = f.cont !== "none";
922
+ const contComment = c === "#" && inCont && f.contLineStart;
923
+ if (c === "#" && (f.atLead || contComment)) {
924
+ const nl = ascii.indexOf("\n", i);
925
+ const end = nl === -1 ? ascii.length : nl;
926
+ comments.push({ start: i, end });
927
+ if (contComment) {
928
+ // The comment line does not end the statement — step over its newline
929
+ // rather than letting the separator branch flush there.
930
+ f.cont = "comment";
931
+ f.contLineStart = true;
932
+ i = nl === -1 ? end : end + 1;
933
+ } else {
934
+ i = end;
935
+ }
936
+ continue;
937
+ }
938
+
939
+ if (inCont) {
940
+ if (c === " " || c === "\t" || c === "\r") {
941
+ f.contLineStart = false;
942
+ } else if (c === "\n" && f.cont === "escape") {
943
+ // A blank line inside the `\` run is still the run: no flush.
944
+ f.contLineStart = true;
945
+ i++;
946
+ continue;
947
+ } else {
948
+ // Real content, or the blank line that spends a comment run's reach
949
+ // and so falls through to the separator branch below.
950
+ f.cont = "none";
951
+ f.contLineStart = false;
952
+ }
953
+ }
954
+
955
+ // #245 — once the device reaches an unquoted hash that cannot be a
956
+ // statement-leading comment or value, highlight marks that byte `error` and
957
+ // stops. Record only that first hard hash, then keep scanning solely to
958
+ // recover balanced spans without pretending later hashes were classified.
959
+ if (
960
+ c === "#" &&
961
+ !hardHashSeen &&
962
+ hashStartsHardError(
963
+ indexed,
964
+ floor + i,
965
+ delimStack.at(-1)?.statements === false,
966
+ floor,
967
+ )
968
+ ) {
969
+ defects.push(defectAt("invalid-hash", i, "#"));
970
+ hardHashSeen = true;
971
+ f.structurallyInvalid = true;
972
+ ensureStmt(f, i);
973
+ f.atLead = false;
974
+ i++;
975
+ continue;
976
+ }
977
+
978
+ // #355 — `'` is NOT a RouterOS token: not a string delimiter, and not a
979
+ // legal bare byte. `:put [:parse]` rejects `name=don't`, `name=don't'`,
980
+ // `name='abc'` and `:put 'abc'` byte-identically on 7.21.5 / 7.23.5 /
981
+ // 7.24.2 / 7.25beta3, so this is a lexical rule and not a quote-balance
982
+ // one — the BALANCED forms are rejected too, and only `"` opens a string.
983
+ //
984
+ // Only the FIRST apostrophe is recorded, for the same reason as the hard
985
+ // hash above and grounded the same way: the device reports one column and
986
+ // it is always the first apostrophe (`name=don't'` reports column 30, the
987
+ // one at `don|'t`, not the trailing one), because classification stops
988
+ // there. A second occurrence is not an independent finding.
989
+ //
990
+ // Apostrophes inside a `"`-string or a comment never reach this branch:
991
+ // both runs are consumed whole above (H3 and H4/H5), which is why
992
+ // `comment="it's here"` stays legal with no special case here.
993
+ if (c === "'" && !hardApostropheSeen) {
994
+ defects.push(defectAt("invalid-apostrophe", i, "'"));
995
+ hardApostropheSeen = true;
996
+ f.structurallyInvalid = true;
997
+ ensureStmt(f, i);
998
+ f.atLead = false;
999
+ i++;
1000
+ continue;
1001
+ }
1002
+
1003
+ // H3 — string.
1004
+ if (c === '"') {
1005
+ ensureStmt(f, i);
1006
+ f.atLead = false;
1007
+ const str = scanQuotedString(ascii, i);
1008
+ if (!str.closed) {
1009
+ // The region is the whole unterminated run — from the opening quote
1010
+ // to where the scan gave up — not just the quote. A consumer
1011
+ // highlighting the defect wants the text that is swallowed by it.
1012
+ defects.push({ code: "unterminated-string", start: i, end: str.end });
1013
+ }
1014
+ i = str.end;
1015
+ continue;
1016
+ }
1017
+
1018
+ // H5 — line continuation.
1019
+ if (
1020
+ c === "\\" &&
1021
+ (ascii[i + 1] === "\n" ||
1022
+ (ascii[i + 1] === "\r" && ascii[i + 2] === "\n"))
1023
+ ) {
1024
+ // `atLead` deliberately survives: `do={\` + newline + `:local x 1` still
1025
+ // reads `:local` as the statement head on the device (#215).
1026
+ ensureStmt(f, i);
1027
+ f.cont = "escape";
1028
+ f.contLineStart = true;
1029
+ i += ascii[i + 1] === "\r" ? 3 : 2;
1030
+ continue;
1031
+ }
1032
+
1033
+ // H2/H7 — opens. A `{` at container level with a valid prefix opens a
1034
+ // container frame (or, past the cap, abstains); everything else is a plain
1035
+ // group that only tracks balance and suppresses separators.
1036
+ if (c === "{" || c === "[" || c === "(") {
1037
+ if (
1038
+ c === "{" &&
1039
+ atContainerLevel() &&
1040
+ validPrefix(f, f.stmtStart >= 0 ? f.stmtStart : i, i)
1041
+ ) {
1042
+ if (frames.length - 1 < MAX_CONTAINER_DEPTH) {
1043
+ openContainer(i);
1044
+ } else {
1045
+ overDepth.push(i);
1046
+ ensureStmt(f, i);
1047
+ delimStack.push({ char: "{", at: i, statements: true });
1048
+ f.atLead = true;
1049
+ }
1050
+ } else {
1051
+ ensureStmt(f, i);
1052
+ const enclosing = delimStack.at(-1)?.statements ?? true;
1053
+ if (
1054
+ opaqueChildScopes &&
1055
+ c === "{" &&
1056
+ atContainerLevel() &&
1057
+ scopeNameFromMasked(structural, floor + i, floor) !== null
1058
+ ) {
1059
+ const end = matchingBraceEnd(structural, floor + i);
1060
+ if (end !== undefined && end < floor + ascii.length) {
1061
+ f.atLead = false;
1062
+ i = end - floor + 1;
1063
+ continue;
1064
+ }
1065
+ }
1066
+ const statements =
1067
+ c === "[" ||
1068
+ (c === "{" &&
1069
+ enclosing &&
1070
+ braceStartsStatements(indexed, floor + i, floor));
1071
+ delimStack.push({ char: c, at: i, statements });
1072
+ f.atLead = statements;
1073
+ }
1074
+ i++;
1075
+ continue;
1076
+ }
1077
+
1078
+ // H2 — closes.
1079
+ if (c === "}" || c === "]" || c === ")") {
1080
+ const want = c === "}" ? "{" : c === "]" ? "[" : "(";
1081
+ if (delimStack[delimStack.length - 1]?.char === want) {
1082
+ const isContainerClose =
1083
+ c === "}" &&
1084
+ frames.length > 1 &&
1085
+ delimStack.length === frames.length - 1;
1086
+ delimStack.pop();
1087
+ if (isContainerClose) {
1088
+ closeContainer(i);
1089
+ } else {
1090
+ ensureStmt(f, i);
1091
+ f.atLead = false;
1092
+ }
1093
+ } else {
1094
+ defects.push(defectAt("unbalanced-close", i, c));
1095
+ ensureStmt(f, i);
1096
+ f.atLead = false;
1097
+ f.structurallyInvalid = true;
1098
+ }
1099
+ i++;
1100
+ continue;
1101
+ }
1102
+
1103
+ // H1 — separators. They END a statement only at container level, but
1104
+ // restore statement-leading position only in a statement-bearing context.
1105
+ // Array and parenthesized expression members do not admit bare `#` comments.
1106
+ if (c === ";" || c === "\n") {
1107
+ if (atContainerLevel()) flush(f, i, c === ";" ? ";" : "newline");
1108
+ f.atLead = delimStack.at(-1)?.statements ?? true;
1109
+ i++;
1110
+ continue;
1111
+ }
1112
+
1113
+ if (isSpace(c)) {
1114
+ i++;
1115
+ continue;
1116
+ }
1117
+
1118
+ ensureStmt(f, i);
1119
+ f.atLead = false;
1120
+ i++;
1121
+ }
1122
+
1123
+ // End of input: any still-open container never closed, so it never flattens —
1124
+ // unwind each as a DISCARD (outermost open container's prefix wins), then flush
1125
+ // the surviving top-level statement.
1126
+ while (frames.length > 1) {
1127
+ const frame = frames.pop() as Frame;
1128
+ const parent = cur();
1129
+ parent.stmtStart = frame.prefixStart;
1130
+ parent.atLead = false;
1131
+ }
1132
+ flush(top, ascii.length, "eof");
1133
+ if (delimStack.length > 0) {
1134
+ // One defect per still-open delimiter, each pointing at its OPENER —
1135
+ // "something is unclosed" is not actionable, "the `{` at byte 41 is
1136
+ // unclosed" is.
1137
+ for (const d of delimStack)
1138
+ defects.push(defectAt("unclosed", d.at, d.char));
1139
+ }
1140
+
1141
+ return {
1142
+ segments: top.buffer,
1143
+ comments,
1144
+ defects: [
1145
+ ...defects,
1146
+ // No `detail`: `symbols.ts` and `pathresolve.ts` emit this class without
1147
+ // one, and `mergeDefects` keys on it, so a `"{"` here would make the same
1148
+ // event fail to de-duplicate across analyzers.
1149
+ ...overDepth.map((offset) => defectAt("over-depth", offset)),
1150
+ ],
1151
+ };
1152
+ }
1153
+
1154
+ /**
1155
+ * A menu-navigation SHAPE: a `/`-rooted path of path-shaped tokens (letters,
1156
+ * digits, space, and `/_.-`) with no `=`, quotes, brackets, or `$` — e.g.
1157
+ * `/ip address`, `/interface`, `/`. It is deliberately shape-only: offline
1158
+ * cannot tell a trailing verb (`/ip address print`) from a deeper menu without
1159
+ * a schema, so `print` still reads as menu-shaped here. A HINT for Q4/Q6, not a
1160
+ * claim that no verb is present.
1161
+ */
1162
+ function isMenuOnly(trimmed: string): boolean {
1163
+ if (!trimmed.startsWith("/")) return false;
1164
+ return /^\/[A-Za-z0-9 /_.-]*$/.test(trimmed);
1165
+ }