@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.
- package/CHANGELOG.md +348 -0
- package/GLOSSARY.txt +252 -0
- package/LICENSE +21 -0
- package/README.md +44 -12
- package/commands/AGENTS.md +14 -5
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +56 -26
- package/commands/api/examples.md +46 -1
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +77 -36
- package/commands/execute/examples.md +120 -10
- package/commands/explain/README.md +2756 -0
- package/commands/explain/examples.md +702 -0
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/terminal/examples.md +7 -1
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +138 -30
- package/docs/MATRIX.md +92 -18
- package/docs/errors/README.md +3 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +56 -10
- package/src/api-fanout.ts +46 -18
- package/src/api.ts +351 -121
- package/src/cli/api.ts +26 -7
- package/src/cli/btest.ts +19 -3
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/terminal.ts +1 -1
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +58 -2
- package/src/core/error-catalog.ts +42 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +47 -18
- package/src/execute.ts +339 -307
- package/src/explain/arg-tokens.ts +151 -0
- package/src/explain/args.ts +1749 -0
- package/src/explain/blocks.ts +175 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +39 -0
- package/src/explain/catalog.ts +1343 -0
- package/src/explain/coordinates.ts +488 -0
- package/src/explain/defects.ts +218 -0
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +668 -0
- package/src/explain/operator-tokens.ts +316 -0
- package/src/explain/operators.ts +465 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1569 -0
- package/src/explain/quoted-string.ts +331 -0
- package/src/explain/scope-brace.ts +461 -0
- package/src/explain/segment.ts +1165 -0
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +77 -0
- package/src/explain/symbol-values.ts +556 -0
- package/src/explain/symbols.ts +1617 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +514 -0
- package/src/explain/value-tokens.ts +83 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +955 -0
- package/src/explain/write.ts +856 -0
- package/src/explain.ts +2602 -0
- package/src/index.ts +225 -6
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +27 -13
- package/src/retrieve.ts +82 -77
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +43 -26
- 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
|
+
}
|