@tikoci/centrs 0.1.2 → 0.1.4
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 +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -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/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- package/src/transfer.ts +133 -40
|
@@ -0,0 +1,757 @@
|
|
|
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 { scanQuotedString as scanQuotedStringShared } from "./quoted-string.ts";
|
|
84
|
+
import { braceStartsStatements, hashStartsHardError } from "./scope-brace.ts";
|
|
85
|
+
|
|
86
|
+
/** One top-level statement located in analyzed-byte space. */
|
|
87
|
+
export interface Segment {
|
|
88
|
+
/** analyzed-byte offset, inclusive. */
|
|
89
|
+
start: number;
|
|
90
|
+
/** analyzed-byte offset, exclusive. */
|
|
91
|
+
end: number;
|
|
92
|
+
/** the original (human-readable) statement text, trimmed. */
|
|
93
|
+
text: string;
|
|
94
|
+
/** the separator that ENDED this statement, for the boundary taxonomy. */
|
|
95
|
+
terminator: ";" | "newline" | "eof";
|
|
96
|
+
/**
|
|
97
|
+
* true when the statement *looks* like pure menu navigation: a `/`-rooted
|
|
98
|
+
* path of path-shaped tokens with no `=`, quotes, brackets, or `$`. This is
|
|
99
|
+
* a SHAPE hint, not a verb claim — offline (schema-free) cannot tell a
|
|
100
|
+
* trailing verb (`/ip address print`) from a deeper menu (`/ip address`), so
|
|
101
|
+
* both are `menuOnly: true`. Q4/Q6 decide what it means. See `isMenuOnly`.
|
|
102
|
+
*/
|
|
103
|
+
menuOnly: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The full segmentation of one input string. */
|
|
107
|
+
export interface SegmentResult {
|
|
108
|
+
segments: Segment[];
|
|
109
|
+
/** comment spans (analyzed-byte offsets), dropped from the statement stream. */
|
|
110
|
+
comments: { start: number; end: number }[];
|
|
111
|
+
/**
|
|
112
|
+
* Unbalanced delimiters and other structural surprises, each located; never
|
|
113
|
+
* a throw. Includes the two coordinate-pass classes (`bom`, `non-ascii`).
|
|
114
|
+
*/
|
|
115
|
+
defects: Defect[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const isSpace = (c: string): boolean => c === " " || c === "\t" || c === "\r";
|
|
119
|
+
|
|
120
|
+
/** Where a double-quoted string ends, and whether it was closed at all. */
|
|
121
|
+
export interface QuotedStringScan {
|
|
122
|
+
/** index just past the closing `"`, or `text.length` when unterminated. */
|
|
123
|
+
end: number;
|
|
124
|
+
closed: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Find the end of the double-quoted string that opens at `open` (H3).
|
|
129
|
+
*
|
|
130
|
+
* The one shared string skip for every structural scan in `explain`. A string is
|
|
131
|
+
* NOT opaque up to the next `"`: a `$[…]` or `$(…)` substitution inside it is
|
|
132
|
+
* CODE, and that code may open strings of its own —
|
|
133
|
+
* `:local a "$[[:parse "(\"x\")"]]"` is ONE string on the device (CHR 7.23.2
|
|
134
|
+
* `/console/inspect request=highlight` classes the following `#` line `comment`
|
|
135
|
+
* and both `$a` occurrences `variable-local`). A scanner that stops at the first
|
|
136
|
+
* nested `"` flips its quote phase and reads every later comment as string
|
|
137
|
+
* content; on the frozen 913-script corpus that hid 2,184 device-`comment` bytes
|
|
138
|
+
* across 7 files, and cost `symbols.ts` 3.1 points of precision before it grew
|
|
139
|
+
* the frame model this function now shares (#198/#199).
|
|
140
|
+
*
|
|
141
|
+
* Frames mirror `symbols.ts`: a `"` frame is string phase (`\` escapes the next
|
|
142
|
+
* character, `$[`/`$(` push a code frame), anything else is code phase (`"`
|
|
143
|
+
* opens a nested string, brackets nest, a mismatched close is content rather
|
|
144
|
+
* than a close — the same line `segment.ts` and `symbols.ts` take elsewhere).
|
|
145
|
+
* Iterative, so deeply nested input cannot overflow the stack. Escapes and
|
|
146
|
+
* comments inside the substitution are NOT interpreted here: the whole string,
|
|
147
|
+
* substitutions included, stays opaque to the caller — only its END moves.
|
|
148
|
+
*
|
|
149
|
+
* Frame depth is capped for the same reason `MAX_CONTAINER_DEPTH` is: `explain`
|
|
150
|
+
* accepts untrusted editor/MCP input. Past `MAX_STRING_FRAME_DEPTH` the scan
|
|
151
|
+
* stops and reports the string UNCLOSED — fail-closed, the direction every
|
|
152
|
+
* caller already handles (`unterminated-string` → `structuralDefect` →
|
|
153
|
+
* `unresolved`). Only an input that opens that many frames without closing them
|
|
154
|
+
* can reach it, which is malformed by construction; sequential substitutions
|
|
155
|
+
* (`"$[a]$[b]…"`) pop and never accumulate.
|
|
156
|
+
*/
|
|
157
|
+
export function scanQuotedString(text: string, open: number): QuotedStringScan {
|
|
158
|
+
return scanQuotedStringShared(text, open);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* How far a H5 `\<newline>` continuation still reaches (#215, CHR 7.23.3).
|
|
163
|
+
*
|
|
164
|
+
* "none" — no continuation pending.
|
|
165
|
+
* "escape" — inside the `\<newline>` run itself. Blank and whitespace-only
|
|
166
|
+
* lines stay inside it (the device classes them `escaped`).
|
|
167
|
+
* "comment" — the run was ended by a continuation COMMENT line, which does
|
|
168
|
+
* not end the pending statement but does spend the run's reach:
|
|
169
|
+
* a following blank line now terminates.
|
|
170
|
+
*
|
|
171
|
+
* `Continuation` and the `lineStart` flag beside it are the vocabulary the
|
|
172
|
+
* three parallel walkers (`maskComments`, `scanAscii`, `symbols.ts`) share, so
|
|
173
|
+
* a change to the rule can be made in the same shape in each. #199 was two of
|
|
174
|
+
* them drifting apart; this state is where the third one can.
|
|
175
|
+
*/
|
|
176
|
+
export type Continuation = "none" | "escape" | "comment";
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Blank out RouterOS comments so a later delimiter re-scan cannot be fooled by
|
|
180
|
+
* `#` text (a `}` or `[` inside a comment is not a real delimiter). A `#` starts
|
|
181
|
+
* a comment in statement-leading position (H4) or at the immediate start of a
|
|
182
|
+
* line a continuation carried into (H5). Statement-leading is contextual:
|
|
183
|
+
* scope/stored-script braces admit it, array/group expressions do not. Any
|
|
184
|
+
* intervening space or tab makes a continuation-line `#` content unless the
|
|
185
|
+
* surrounding statement context is still empty. Comments are opaque inside
|
|
186
|
+
* strings. Comment characters become spaces; length and every non-comment
|
|
187
|
+
* offset are preserved, so indices stay valid against the original and callers
|
|
188
|
+
* can slice the original for content. Idempotent.
|
|
189
|
+
*/
|
|
190
|
+
export function maskComments(original: string): string {
|
|
191
|
+
const out = original.split("");
|
|
192
|
+
const contexts: { char: "{" | "[" | "("; statements: boolean }[] = [];
|
|
193
|
+
let atLead = true;
|
|
194
|
+
let cont: Continuation = "none";
|
|
195
|
+
let contLineStart = false;
|
|
196
|
+
for (let i = 0; i < original.length; i++) {
|
|
197
|
+
const c = original[i];
|
|
198
|
+
const inCont = cont !== "none";
|
|
199
|
+
const contComment = c === "#" && inCont && contLineStart;
|
|
200
|
+
if (inCont && !contComment) {
|
|
201
|
+
if (c === " " || c === "\t" || c === "\r") {
|
|
202
|
+
contLineStart = false;
|
|
203
|
+
} else if (c === "\n" && cont === "escape") {
|
|
204
|
+
contLineStart = true;
|
|
205
|
+
continue; // a blank line inside the `\` run is still the run
|
|
206
|
+
} else {
|
|
207
|
+
// Real content, or the blank line that spends a comment run's reach.
|
|
208
|
+
cont = "none";
|
|
209
|
+
contLineStart = false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (c === '"') {
|
|
213
|
+
atLead = false;
|
|
214
|
+
i = scanQuotedString(original, i).end - 1;
|
|
215
|
+
continue; // i rests on the closing quote (or past end)
|
|
216
|
+
}
|
|
217
|
+
// H4 is unchanged and H5 only ADDS the immediate-line-start case: an
|
|
218
|
+
// indented `#` is a comment when the statement is still empty (`do={\` +
|
|
219
|
+
// newline + ` # c`) and content when it is not (`:put a\` + newline +
|
|
220
|
+
// ` # x`, which the device classes `error`) — exactly what `atLead` says.
|
|
221
|
+
if (c === "#" && (atLead || contComment)) {
|
|
222
|
+
while (i < original.length && original[i] !== "\n") {
|
|
223
|
+
out[i] = " ";
|
|
224
|
+
i++;
|
|
225
|
+
}
|
|
226
|
+
if (contComment) {
|
|
227
|
+
// The comment line does not end the statement: step over its newline
|
|
228
|
+
// (the loop's i++) with the continuation still pending.
|
|
229
|
+
cont = "comment";
|
|
230
|
+
contLineStart = true;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
i--; // let the loop advance onto the newline (or end)
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (
|
|
237
|
+
c === "\\" &&
|
|
238
|
+
(original[i + 1] === "\n" ||
|
|
239
|
+
(original[i + 1] === "\r" && original[i + 2] === "\n"))
|
|
240
|
+
) {
|
|
241
|
+
// H5 leaves `atLead` alone: `do={\` + newline + `:local x 1` still reads
|
|
242
|
+
// `:local` as the statement head on the device.
|
|
243
|
+
cont = "escape";
|
|
244
|
+
contLineStart = true;
|
|
245
|
+
i += original[i + 1] === "\r" ? 2 : 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (c === "{" || c === "[" || c === "(") {
|
|
249
|
+
const enclosing = contexts.at(-1)?.statements ?? true;
|
|
250
|
+
const statements =
|
|
251
|
+
c === "[" ||
|
|
252
|
+
(c === "{" && enclosing && braceStartsStatements(original, i));
|
|
253
|
+
contexts.push({ char: c, statements });
|
|
254
|
+
// A bracket is a nested statement context too: `[# c\n:put 1]` starts
|
|
255
|
+
// with a real comment, while `[:put #value]` consumes the lead on `:put`
|
|
256
|
+
// and keeps the hash as that command's value.
|
|
257
|
+
atLead = statements;
|
|
258
|
+
} else if (c === "}" || c === "]" || c === ")") {
|
|
259
|
+
const want = c === "}" ? "{" : c === "]" ? "[" : "(";
|
|
260
|
+
if (contexts[contexts.length - 1]?.char === want) contexts.pop();
|
|
261
|
+
atLead = false;
|
|
262
|
+
} else if (c === ";" || c === "\n") {
|
|
263
|
+
atLead = contexts.at(-1)?.statements ?? true;
|
|
264
|
+
} else if (c !== " " && c !== "\t" && c !== "\r") atLead = false;
|
|
265
|
+
}
|
|
266
|
+
return out.join("");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Container-frame depth cap.
|
|
271
|
+
*
|
|
272
|
+
* Not a RouterOS grammar limit — an implementation guard derived from the Q17
|
|
273
|
+
* stress finding (the lab walker overflowed the JS stack around 32k nested
|
|
274
|
+
* containers). The single-pass scanner no longer recurses, so this is now a
|
|
275
|
+
* resource guard rather than a stack-safety boundary: 256 preserves far more
|
|
276
|
+
* nesting than normal scripts use while bounding the `frames` array. A `{` that
|
|
277
|
+
* would open frame 257 is kept as an opaque group and the analysis abstains with
|
|
278
|
+
* an `over-depth:<analyzed-byte-offset>` note.
|
|
279
|
+
*/
|
|
280
|
+
const MAX_CONTAINER_DEPTH = 256;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Segment `original` into top-level statements. Spans are analyzed-byte offsets
|
|
284
|
+
* (see module header); `text` is the original substring for each span.
|
|
285
|
+
*/
|
|
286
|
+
export function segmentStatements(original: string): SegmentResult {
|
|
287
|
+
const analysis = analyzeCoordinates(original);
|
|
288
|
+
// `analyzed` is pure ASCII, so a string built from it has index === byte.
|
|
289
|
+
const ascii = new TextDecoder().decode(analysis.analyzed);
|
|
290
|
+
const raw = scanAscii(ascii);
|
|
291
|
+
// Recover the human-readable `text` for each segment from the original.
|
|
292
|
+
const segments = raw.segments.map((s) => ({
|
|
293
|
+
...s,
|
|
294
|
+
text: originalSlice(analysis, s.start, s.end),
|
|
295
|
+
}));
|
|
296
|
+
return {
|
|
297
|
+
segments,
|
|
298
|
+
comments: raw.comments,
|
|
299
|
+
// Coordinate-pass regions first: they are facts about the input as
|
|
300
|
+
// received, and they exist even when the scan finds nothing structural.
|
|
301
|
+
defects: mergeDefects(coordinateDefects(analysis), raw.defects),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Original substring for an analyzed-byte span (boundaries are char-aligned). */
|
|
306
|
+
function originalSlice(
|
|
307
|
+
a: CoordinateAnalysis,
|
|
308
|
+
start: number,
|
|
309
|
+
end: number,
|
|
310
|
+
): string {
|
|
311
|
+
return a.original.slice(utf16At(a, start), utf16At(a, end));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** UTF-16 offset in the original for an analyzed-byte boundary. */
|
|
315
|
+
function utf16At(a: CoordinateAnalysis, byte: number): number {
|
|
316
|
+
if (byte >= a.analyzed.length) return a.original.length;
|
|
317
|
+
return runAtByte(a, byte).utf16Start;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** A located statement before its `text` is recovered from the original. */
|
|
321
|
+
type RawSegment = Omit<Segment, "text">;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* One nesting level of the single-pass walker. The top level is `frames[0]`
|
|
325
|
+
* (`container: false`); each open CONTAINER `{…}` pushes a frame. Statements
|
|
326
|
+
* accumulate in the active (innermost) frame's `buffer`; a container's buffer is
|
|
327
|
+
* spliced into its parent on COMMIT and dropped on DISCARD.
|
|
328
|
+
*/
|
|
329
|
+
interface Frame {
|
|
330
|
+
/** in-progress statement start in analyzed-byte space, or -1 (H6). */
|
|
331
|
+
stmtStart: number;
|
|
332
|
+
/** H4 — still before the first real token of the current statement? */
|
|
333
|
+
atLead: boolean;
|
|
334
|
+
/** H5 — how far the pending `\<newline>` continuation still reaches. */
|
|
335
|
+
cont: Continuation;
|
|
336
|
+
/** H5 — at the immediate start of a line the continuation carried into? */
|
|
337
|
+
contLineStart: boolean;
|
|
338
|
+
/**
|
|
339
|
+
* A mismatched close occurred in this statement/container. A container with
|
|
340
|
+
* a known structural defect must DISCARD rather than promote its buffered
|
|
341
|
+
* children as confident sibling statements.
|
|
342
|
+
*/
|
|
343
|
+
structurallyInvalid: boolean;
|
|
344
|
+
/** Start/end of the prefix range already classified for H7. */
|
|
345
|
+
prefixScanStart: number;
|
|
346
|
+
prefixScanEnd: number;
|
|
347
|
+
/** Incremental equivalent of trimming and checking the H7 prefix. */
|
|
348
|
+
prefixSawNonSpace: boolean;
|
|
349
|
+
prefixValid: boolean;
|
|
350
|
+
/** statements collected here; the flattened output for a committed container. */
|
|
351
|
+
buffer: RawSegment[];
|
|
352
|
+
/** true for a container frame; false for the synthetic top level. */
|
|
353
|
+
container: boolean;
|
|
354
|
+
/** analyzed-byte index of the `{` that opened this container. */
|
|
355
|
+
openIndex: number;
|
|
356
|
+
/**
|
|
357
|
+
* Start of the statement this container belongs to — the menu prefix before
|
|
358
|
+
* the `{`. On DISCARD the parent statement resumes from here, so the whole
|
|
359
|
+
* `{…}` is absorbed verbatim, matching the old `containerOpen` = -1 path.
|
|
360
|
+
*/
|
|
361
|
+
prefixStart: number;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Single left-to-right walk of a pure-ASCII string (byte === string index),
|
|
366
|
+
* flattening H7 containers inline. Returns spans only; `segmentStatements`
|
|
367
|
+
* recovers each `text` from the original afterward.
|
|
368
|
+
*/
|
|
369
|
+
function scanAscii(ascii: string): {
|
|
370
|
+
segments: RawSegment[];
|
|
371
|
+
comments: { start: number; end: number }[];
|
|
372
|
+
defects: Defect[];
|
|
373
|
+
} {
|
|
374
|
+
const comments: { start: number; end: number }[] = [];
|
|
375
|
+
const defects: Defect[] = [];
|
|
376
|
+
const overDepth: number[] = [];
|
|
377
|
+
let hardHashSeen = false;
|
|
378
|
+
// H2 — every open bracket, for balance. Each frame carries WHERE it opened so
|
|
379
|
+
// an `unclosed` defect can point at the opener rather than at the end of
|
|
380
|
+
// input.
|
|
381
|
+
const delimStack: { char: string; at: number; statements: boolean }[] = [];
|
|
382
|
+
const top: Frame = {
|
|
383
|
+
stmtStart: -1,
|
|
384
|
+
atLead: true,
|
|
385
|
+
cont: "none",
|
|
386
|
+
contLineStart: false,
|
|
387
|
+
structurallyInvalid: false,
|
|
388
|
+
prefixScanStart: -1,
|
|
389
|
+
prefixScanEnd: -1,
|
|
390
|
+
prefixSawNonSpace: false,
|
|
391
|
+
prefixValid: true,
|
|
392
|
+
buffer: [],
|
|
393
|
+
container: false,
|
|
394
|
+
openIndex: -1,
|
|
395
|
+
prefixStart: -1,
|
|
396
|
+
};
|
|
397
|
+
const frames: Frame[] = [top];
|
|
398
|
+
const cur = (): Frame => frames[frames.length - 1] as Frame;
|
|
399
|
+
|
|
400
|
+
// At container level when every open bracket is a container `{` — i.e. no
|
|
401
|
+
// value bracket (`[ ( ` or a non-container `{`) is open. Only then do
|
|
402
|
+
// separators split and only then can a `{` open a new container. (Container
|
|
403
|
+
// frames only open at this level, so they always sit at the bottom of
|
|
404
|
+
// `delimStack`; plain brackets sit above.)
|
|
405
|
+
const atContainerLevel = (): boolean =>
|
|
406
|
+
delimStack.length === frames.length - 1;
|
|
407
|
+
|
|
408
|
+
const ensureStmt = (f: Frame, i: number): void => {
|
|
409
|
+
if (f.stmtStart < 0) f.stmtStart = i;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const resetPrefixScan = (f: Frame): void => {
|
|
413
|
+
f.prefixScanStart = -1;
|
|
414
|
+
f.prefixScanEnd = -1;
|
|
415
|
+
f.prefixSawNonSpace = false;
|
|
416
|
+
f.prefixValid = true;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const makeSegment = (
|
|
420
|
+
start: number,
|
|
421
|
+
end: number,
|
|
422
|
+
terminator: Segment["terminator"],
|
|
423
|
+
): RawSegment | null => {
|
|
424
|
+
const raw = ascii.slice(start, end);
|
|
425
|
+
const trimmed = raw.trim();
|
|
426
|
+
if (trimmed.length === 0) return null; // H6
|
|
427
|
+
const lead = raw.length - raw.trimStart().length;
|
|
428
|
+
const s = start + lead;
|
|
429
|
+
return {
|
|
430
|
+
start: s,
|
|
431
|
+
end: s + trimmed.length,
|
|
432
|
+
terminator,
|
|
433
|
+
menuOnly: isMenuOnly(trimmed),
|
|
434
|
+
};
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const flush = (
|
|
438
|
+
f: Frame,
|
|
439
|
+
end: number,
|
|
440
|
+
terminator: Segment["terminator"],
|
|
441
|
+
): void => {
|
|
442
|
+
if (f.stmtStart < 0) return;
|
|
443
|
+
const s = makeSegment(f.stmtStart, end, terminator);
|
|
444
|
+
f.stmtStart = -1;
|
|
445
|
+
resetPrefixScan(f);
|
|
446
|
+
if (s) f.buffer.push(s);
|
|
447
|
+
// The synthetic top frame can recover at a real statement boundary. A
|
|
448
|
+
// container stays poisoned until it closes so a defect in one buffered
|
|
449
|
+
// child cannot make later children look independently trustworthy.
|
|
450
|
+
if (!f.container) f.structurallyInvalid = false;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// A container prefix is empty or a bare `/`-menu path with no `=[($"` — the
|
|
454
|
+
// same test the old `containerOpen` applied to the group's prefix.
|
|
455
|
+
const validPrefix = (f: Frame, start: number, end: number): boolean => {
|
|
456
|
+
if (f.prefixScanStart !== start || f.prefixScanEnd > end) {
|
|
457
|
+
resetPrefixScan(f);
|
|
458
|
+
f.prefixScanStart = start;
|
|
459
|
+
f.prefixScanEnd = start;
|
|
460
|
+
}
|
|
461
|
+
if (!f.prefixValid) {
|
|
462
|
+
f.prefixScanEnd = end;
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
for (let i = f.prefixScanEnd; i < end; i++) {
|
|
466
|
+
const c = ascii[i] as string;
|
|
467
|
+
if (!f.prefixSawNonSpace) {
|
|
468
|
+
if (c.trim().length === 0) continue;
|
|
469
|
+
f.prefixSawNonSpace = true;
|
|
470
|
+
if (c !== "/") {
|
|
471
|
+
f.prefixValid = false;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (/[=[($"]/.test(c)) {
|
|
476
|
+
f.prefixValid = false;
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
f.prefixScanEnd = end;
|
|
481
|
+
return f.prefixValid;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const openContainer = (i: number): void => {
|
|
485
|
+
const parent = cur();
|
|
486
|
+
// prefixStart = the parent statement's start, or the `{` itself when the
|
|
487
|
+
// container has no prefix (so a DISCARD absorbs from the `{`).
|
|
488
|
+
const prefixStart = parent.stmtStart >= 0 ? parent.stmtStart : i;
|
|
489
|
+
delimStack.push({ char: "{", at: i, statements: true });
|
|
490
|
+
frames.push({
|
|
491
|
+
stmtStart: -1,
|
|
492
|
+
atLead: true,
|
|
493
|
+
cont: "none",
|
|
494
|
+
contLineStart: false,
|
|
495
|
+
structurallyInvalid: parent.structurallyInvalid,
|
|
496
|
+
prefixScanStart: -1,
|
|
497
|
+
prefixScanEnd: -1,
|
|
498
|
+
prefixSawNonSpace: false,
|
|
499
|
+
prefixValid: true,
|
|
500
|
+
buffer: [],
|
|
501
|
+
container: true,
|
|
502
|
+
openIndex: i,
|
|
503
|
+
prefixStart,
|
|
504
|
+
});
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// A container's `}` ends the parent statement (so the group is a real trailing
|
|
508
|
+
// container) when the next non-blank char is a separator, end of input, or the
|
|
509
|
+
// `}` that closes an enclosing container. Any other trailing content means the
|
|
510
|
+
// `}` was mid-statement — the old `containerOpen` = -1 case.
|
|
511
|
+
const trailingEndsStatement = (closeIndex: number): boolean => {
|
|
512
|
+
let j = closeIndex + 1;
|
|
513
|
+
while (j < ascii.length && isSpace(ascii[j] as string)) j++;
|
|
514
|
+
if (j >= ascii.length) return true;
|
|
515
|
+
const n = ascii[j];
|
|
516
|
+
if (n === ";" || n === "\n") return true;
|
|
517
|
+
if (n === "}") return cur().container; // closes the parent container
|
|
518
|
+
return false;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const closeContainer = (i: number): void => {
|
|
522
|
+
const frame = frames.pop() as Frame;
|
|
523
|
+
// Flush the body's last statement. Its terminator is "eof" — the end of
|
|
524
|
+
// the container body, matching the old per-body scan; the `}` itself is
|
|
525
|
+
// not a statement terminator token (only `;`/newline/actual EOF are).
|
|
526
|
+
flush(frame, i, "eof");
|
|
527
|
+
const parent = cur();
|
|
528
|
+
if (
|
|
529
|
+
!frame.structurallyInvalid &&
|
|
530
|
+
frame.buffer.length > 0 &&
|
|
531
|
+
trailingEndsStatement(i)
|
|
532
|
+
) {
|
|
533
|
+
// COMMIT: menu-prefix segment (if any), then the buffered body, replace
|
|
534
|
+
// the container in the parent stream; the parent statement is consumed.
|
|
535
|
+
const prefix = makeSegment(frame.prefixStart, frame.openIndex, "newline");
|
|
536
|
+
if (prefix) parent.buffer.push(prefix);
|
|
537
|
+
for (const s of frame.buffer) parent.buffer.push(s);
|
|
538
|
+
parent.stmtStart = -1;
|
|
539
|
+
resetPrefixScan(parent);
|
|
540
|
+
} else {
|
|
541
|
+
// DISCARD: empty body or trailing content — the parent statement absorbs
|
|
542
|
+
// the whole `{…}` verbatim by resuming from the container's prefix.
|
|
543
|
+
parent.stmtStart = frame.prefixStart;
|
|
544
|
+
parent.structurallyInvalid ||= frame.structurallyInvalid;
|
|
545
|
+
}
|
|
546
|
+
parent.atLead = false;
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
let i = 0;
|
|
550
|
+
while (i < ascii.length) {
|
|
551
|
+
const c = ascii[i] as string;
|
|
552
|
+
const f = cur();
|
|
553
|
+
|
|
554
|
+
// H4/H5 — a comment is statement-leading, or at the immediate start of a
|
|
555
|
+
// line a continuation carried into. Both are decided from state as it
|
|
556
|
+
// stands BEFORE the continuation bookkeeping below, so an indented `#`
|
|
557
|
+
// mid-continuation stays content.
|
|
558
|
+
const inCont = f.cont !== "none";
|
|
559
|
+
const contComment = c === "#" && inCont && f.contLineStart;
|
|
560
|
+
if (c === "#" && (f.atLead || contComment)) {
|
|
561
|
+
const nl = ascii.indexOf("\n", i);
|
|
562
|
+
const end = nl === -1 ? ascii.length : nl;
|
|
563
|
+
comments.push({ start: i, end });
|
|
564
|
+
if (contComment) {
|
|
565
|
+
// The comment line does not end the statement — step over its newline
|
|
566
|
+
// rather than letting the separator branch flush there.
|
|
567
|
+
f.cont = "comment";
|
|
568
|
+
f.contLineStart = true;
|
|
569
|
+
i = nl === -1 ? end : end + 1;
|
|
570
|
+
} else {
|
|
571
|
+
i = end;
|
|
572
|
+
}
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (inCont) {
|
|
577
|
+
if (c === " " || c === "\t" || c === "\r") {
|
|
578
|
+
f.contLineStart = false;
|
|
579
|
+
} else if (c === "\n" && f.cont === "escape") {
|
|
580
|
+
// A blank line inside the `\` run is still the run: no flush.
|
|
581
|
+
f.contLineStart = true;
|
|
582
|
+
i++;
|
|
583
|
+
continue;
|
|
584
|
+
} else {
|
|
585
|
+
// Real content, or the blank line that spends a comment run's reach
|
|
586
|
+
// and so falls through to the separator branch below.
|
|
587
|
+
f.cont = "none";
|
|
588
|
+
f.contLineStart = false;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// #245 — once the device reaches an unquoted hash that cannot be a
|
|
593
|
+
// statement-leading comment or value, highlight marks that byte `error` and
|
|
594
|
+
// stops. Record only that first hard hash, then keep scanning solely to
|
|
595
|
+
// recover balanced spans without pretending later hashes were classified.
|
|
596
|
+
if (
|
|
597
|
+
c === "#" &&
|
|
598
|
+
!hardHashSeen &&
|
|
599
|
+
hashStartsHardError(ascii, i, delimStack.at(-1)?.statements === false)
|
|
600
|
+
) {
|
|
601
|
+
defects.push(defectAt("invalid-hash", i, "#"));
|
|
602
|
+
hardHashSeen = true;
|
|
603
|
+
f.structurallyInvalid = true;
|
|
604
|
+
ensureStmt(f, i);
|
|
605
|
+
f.atLead = false;
|
|
606
|
+
i++;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// H3 — string.
|
|
611
|
+
if (c === '"') {
|
|
612
|
+
ensureStmt(f, i);
|
|
613
|
+
f.atLead = false;
|
|
614
|
+
const str = scanQuotedString(ascii, i);
|
|
615
|
+
if (!str.closed) {
|
|
616
|
+
// The region is the whole unterminated run — from the opening quote
|
|
617
|
+
// to where the scan gave up — not just the quote. A consumer
|
|
618
|
+
// highlighting the defect wants the text that is swallowed by it.
|
|
619
|
+
defects.push({ code: "unterminated-string", start: i, end: str.end });
|
|
620
|
+
}
|
|
621
|
+
i = str.end;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// H5 — line continuation.
|
|
626
|
+
if (
|
|
627
|
+
c === "\\" &&
|
|
628
|
+
(ascii[i + 1] === "\n" ||
|
|
629
|
+
(ascii[i + 1] === "\r" && ascii[i + 2] === "\n"))
|
|
630
|
+
) {
|
|
631
|
+
// `atLead` deliberately survives: `do={\` + newline + `:local x 1` still
|
|
632
|
+
// reads `:local` as the statement head on the device (#215).
|
|
633
|
+
ensureStmt(f, i);
|
|
634
|
+
f.cont = "escape";
|
|
635
|
+
f.contLineStart = true;
|
|
636
|
+
i += ascii[i + 1] === "\r" ? 3 : 2;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// H2/H7 — opens. A `{` at container level with a valid prefix opens a
|
|
641
|
+
// container frame (or, past the cap, abstains); everything else is a plain
|
|
642
|
+
// group that only tracks balance and suppresses separators.
|
|
643
|
+
if (c === "{" || c === "[" || c === "(") {
|
|
644
|
+
if (
|
|
645
|
+
c === "{" &&
|
|
646
|
+
atContainerLevel() &&
|
|
647
|
+
validPrefix(f, f.stmtStart >= 0 ? f.stmtStart : i, i)
|
|
648
|
+
) {
|
|
649
|
+
if (frames.length - 1 < MAX_CONTAINER_DEPTH) {
|
|
650
|
+
openContainer(i);
|
|
651
|
+
} else {
|
|
652
|
+
overDepth.push(i);
|
|
653
|
+
ensureStmt(f, i);
|
|
654
|
+
delimStack.push({ char: "{", at: i, statements: true });
|
|
655
|
+
f.atLead = true;
|
|
656
|
+
}
|
|
657
|
+
} else {
|
|
658
|
+
ensureStmt(f, i);
|
|
659
|
+
const enclosing = delimStack.at(-1)?.statements ?? true;
|
|
660
|
+
const statements =
|
|
661
|
+
c === "[" ||
|
|
662
|
+
(c === "{" && enclosing && braceStartsStatements(ascii, i));
|
|
663
|
+
delimStack.push({ char: c, at: i, statements });
|
|
664
|
+
f.atLead = statements;
|
|
665
|
+
}
|
|
666
|
+
i++;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// H2 — closes.
|
|
671
|
+
if (c === "}" || c === "]" || c === ")") {
|
|
672
|
+
const want = c === "}" ? "{" : c === "]" ? "[" : "(";
|
|
673
|
+
if (delimStack[delimStack.length - 1]?.char === want) {
|
|
674
|
+
const isContainerClose =
|
|
675
|
+
c === "}" &&
|
|
676
|
+
frames.length > 1 &&
|
|
677
|
+
delimStack.length === frames.length - 1;
|
|
678
|
+
delimStack.pop();
|
|
679
|
+
if (isContainerClose) {
|
|
680
|
+
closeContainer(i);
|
|
681
|
+
} else {
|
|
682
|
+
ensureStmt(f, i);
|
|
683
|
+
f.atLead = false;
|
|
684
|
+
}
|
|
685
|
+
} else {
|
|
686
|
+
defects.push(defectAt("unbalanced-close", i, c));
|
|
687
|
+
ensureStmt(f, i);
|
|
688
|
+
f.atLead = false;
|
|
689
|
+
f.structurallyInvalid = true;
|
|
690
|
+
}
|
|
691
|
+
i++;
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// H1 — separators. They END a statement only at container level, but
|
|
696
|
+
// restore statement-leading position only in a statement-bearing context.
|
|
697
|
+
// Array and parenthesized expression members do not admit bare `#` comments.
|
|
698
|
+
if (c === ";" || c === "\n") {
|
|
699
|
+
if (atContainerLevel()) flush(f, i, c === ";" ? ";" : "newline");
|
|
700
|
+
f.atLead = delimStack.at(-1)?.statements ?? true;
|
|
701
|
+
i++;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (isSpace(c)) {
|
|
706
|
+
i++;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
ensureStmt(f, i);
|
|
711
|
+
f.atLead = false;
|
|
712
|
+
i++;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// End of input: any still-open container never closed, so it never flattens —
|
|
716
|
+
// unwind each as a DISCARD (outermost open container's prefix wins), then flush
|
|
717
|
+
// the surviving top-level statement.
|
|
718
|
+
while (frames.length > 1) {
|
|
719
|
+
const frame = frames.pop() as Frame;
|
|
720
|
+
const parent = cur();
|
|
721
|
+
parent.stmtStart = frame.prefixStart;
|
|
722
|
+
parent.atLead = false;
|
|
723
|
+
}
|
|
724
|
+
flush(top, ascii.length, "eof");
|
|
725
|
+
if (delimStack.length > 0) {
|
|
726
|
+
// One defect per still-open delimiter, each pointing at its OPENER —
|
|
727
|
+
// "something is unclosed" is not actionable, "the `{` at byte 41 is
|
|
728
|
+
// unclosed" is.
|
|
729
|
+
for (const d of delimStack)
|
|
730
|
+
defects.push(defectAt("unclosed", d.at, d.char));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return {
|
|
734
|
+
segments: top.buffer,
|
|
735
|
+
comments,
|
|
736
|
+
defects: [
|
|
737
|
+
...defects,
|
|
738
|
+
// No `detail`: `symbols.ts` and `pathresolve.ts` emit this class without
|
|
739
|
+
// one, and `mergeDefects` keys on it, so a `"{"` here would make the same
|
|
740
|
+
// event fail to de-duplicate across analyzers.
|
|
741
|
+
...overDepth.map((offset) => defectAt("over-depth", offset)),
|
|
742
|
+
],
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* A menu-navigation SHAPE: a `/`-rooted path of path-shaped tokens (letters,
|
|
748
|
+
* digits, space, and `/_.-`) with no `=`, quotes, brackets, or `$` — e.g.
|
|
749
|
+
* `/ip address`, `/interface`, `/`. It is deliberately shape-only: offline
|
|
750
|
+
* cannot tell a trailing verb (`/ip address print`) from a deeper menu without
|
|
751
|
+
* a schema, so `print` still reads as menu-shaped here. A HINT for Q4/Q6, not a
|
|
752
|
+
* claim that no verb is present.
|
|
753
|
+
*/
|
|
754
|
+
function isMenuOnly(trimmed: string): boolean {
|
|
755
|
+
if (!trimmed.startsWith("/")) return false;
|
|
756
|
+
return /^\/[A-Za-z0-9 /_.-]*$/.test(trimmed);
|
|
757
|
+
}
|