@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,1617 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symbol scope resolution for `explain` (centrs canonicalizer).
|
|
3
|
+
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q13 (#185) and promoted from the
|
|
5
|
+
* throwaway probe `.scratch/explain-lab-symbols.ts`. Q13 asks whether an
|
|
6
|
+
* offline, schema-free reader can reproduce the class the console assigns to
|
|
7
|
+
* every variable occurrence — `variable-local` / `-global` / `-auto` /
|
|
8
|
+
* `-parameter` / `-undefined` in `/console/inspect request=highlight`.
|
|
9
|
+
*
|
|
10
|
+
* **The ratified answer: yes for the lexical classes, and `undefined` must
|
|
11
|
+
* ABSTAIN.** The naive rule from the device's own documentation ("a bare
|
|
12
|
+
* identifier in expression position that resolves to nothing is undefined")
|
|
13
|
+
* scored 2.5% precision in the lab — 39 false positives against 1 true
|
|
14
|
+
* positive — because a bare word in expression position is far more often a
|
|
15
|
+
* menu field the console binds as a local, a bare query flag, or an operator
|
|
16
|
+
* keyword. The device emits `variable-undefined` in 9 of 580 clean corpus
|
|
17
|
+
* files. So this module says what a name is bound TO and never says that a
|
|
18
|
+
* bare word is unbound: `cls: null` is a first-class outcome, the same
|
|
19
|
+
* abstain-never-assert conclusion Q3 (absolute inner paths), Q6 (`ambiguous`)
|
|
20
|
+
* and Q16 (`unknown`) reached independently.
|
|
21
|
+
*
|
|
22
|
+
* Two console conflations are MATCHED rather than beaten:
|
|
23
|
+
* - an undeclared `$typo` is `parameter`, not an error — the console cannot
|
|
24
|
+
* tell "undeclared" from "supplied at call time", so S5 is the ANSWER, not
|
|
25
|
+
* a fallback. Flagging it would be wrong.
|
|
26
|
+
* - a bare identifier in `where`/filter position is a menu field bound as a
|
|
27
|
+
* local, and telling a real field from a typo needs the menu schema, so
|
|
28
|
+
* S8 abstains.
|
|
29
|
+
*
|
|
30
|
+
* **The three causes are not one gap, and this abstention is not permanent.**
|
|
31
|
+
* An operator keyword is purely lexical — and device-grounded, since `not` and
|
|
32
|
+
* `..` class `variable-undefined` while a doc-derived operator list would call
|
|
33
|
+
* them operators (#255) — so closing that cause needs no schema. A menu field
|
|
34
|
+
* and a bare query flag both need a per-path argument list, which `catalog.ts`
|
|
35
|
+
* deliberately is not ("never what a command accepts"); those are
|
|
36
|
+
* schema-shaped holes, closable by an enriched caller without a router. What
|
|
37
|
+
* genuinely requires a live device is narrower than the abstention: a
|
|
38
|
+
* `:global` persisted from a previous console session. Do not read `cls: null`
|
|
39
|
+
* here as "undefined is unreachable offline" (#263).
|
|
40
|
+
*
|
|
41
|
+
* Spans are **analyzed-byte offsets** (half-open), like `segment.ts` and the
|
|
42
|
+
* coordinate contract (#188): the ASCII normalization is byte-count-preserving,
|
|
43
|
+
* so an occurrence offset is also its offset in the device's highlight byte
|
|
44
|
+
* stream — which is what made scoring against that stream possible at all.
|
|
45
|
+
* An occurrence's `name` is the key it binds as, which is not always its span's
|
|
46
|
+
* text (S11 quotes); slice the span for display text.
|
|
47
|
+
*
|
|
48
|
+
* The rules, as ratified (S-numbers are the lab's):
|
|
49
|
+
* S1 `:local NAME` declares NAME `local` from the declaration to the end of
|
|
50
|
+
* the enclosing brace scope; an inner `:local` shadows an outer one. (The
|
|
51
|
+
* `:` is optional on every head below — see F3.)
|
|
52
|
+
* S2 `:global NAME` declares NAME `global`. (As ratified this said "visible
|
|
53
|
+
* for the remainder of the document regardless of brace nesting"; the
|
|
54
|
+
* device disagrees — see F7, which supersedes the extent half of it.)
|
|
55
|
+
* S3 `:foreach A[,B] in=… do={…}` / `:for I from=… do={…}` declare their
|
|
56
|
+
* loop variables `auto`. (Their EXTENT is F7's statement scope.)
|
|
57
|
+
* S4 A reference resolves to the nearest enclosing declaration and takes
|
|
58
|
+
* its class. (Within one scope, "nearest" is F7's claim, not the nearest
|
|
59
|
+
* preceding declaration.)
|
|
60
|
+
* S5 `$NAME` with no visible declaration is `parameter` (see above).
|
|
61
|
+
* S6 `$0`, `$1`, … are `parameter`.
|
|
62
|
+
* S7 A bare identifier in expression position is NOT asserted `undefined`;
|
|
63
|
+
* a resolvable one takes its binding's class, an unresolvable one
|
|
64
|
+
* abstains (the 2.5%-precision finding above).
|
|
65
|
+
* S8 Every bare identifier in a `where`/`find` filter region is a menu
|
|
66
|
+
* field — not only the `field=value` form — so it abstains.
|
|
67
|
+
* S9 `$NAME` inside a double-quoted string interpolates and is classified;
|
|
68
|
+
* inside a comment it is not.
|
|
69
|
+
* S10 `:set NAME` is a REFERENCE, never a declaration; on an undeclared name
|
|
70
|
+
* the device raises a hard error, which is not a class — so it abstains.
|
|
71
|
+
* S11 A quoted declaration name (`:global "set-dns"`) carries its class
|
|
72
|
+
* across the quotes, and the span includes them.
|
|
73
|
+
* S12 A word carrying a `:` or `/` sigil is a directive or menu-path
|
|
74
|
+
* segment, never a symbol.
|
|
75
|
+
* S13 Bare literals (`true`/`false`/`yes`/`no`/`nothing`, anything starting
|
|
76
|
+
* with a digit) are values.
|
|
77
|
+
* S14 The reference-name boundary is not the argument-name charset: `-` and
|
|
78
|
+
* `.` terminate a bare name.
|
|
79
|
+
* S15 A bare word right after `=` or `,` is a VALUE.
|
|
80
|
+
* S16 A bare word continuing a space-separated menu path is a path segment.
|
|
81
|
+
* S17 `or` / `and` / `not` / `in` are operators, not symbols.
|
|
82
|
+
* S18 `:onerror NAME` binds NAME `local` (in TWO places — see F7).
|
|
83
|
+
* S19 `-` always terminates a bare `$name`; hyphenated names are reachable
|
|
84
|
+
* only through the quoted `$"set-dns"` spelling. `${...}` is not a
|
|
85
|
+
* RouterOS reference form and is skipped rather than resolved. In
|
|
86
|
+
* expression position the remaining bytes are scanned independently, so
|
|
87
|
+
* `($a-b)` resolves both operands while `"$set-dns"` leaves `-dns` as
|
|
88
|
+
* string content.
|
|
89
|
+
*
|
|
90
|
+
* EIGHT behaviors go beyond the lab SUT. None is invented: the probe declared the
|
|
91
|
+
* first two and left them unmodeled (it was throwaway and depth-scoped), F2 is
|
|
92
|
+
* required verbatim by the ratified spec — `commands/explain/README.md`, "Symbol
|
|
93
|
+
* scopes follow RouterOS scope identity, not brace depth alone" — and F3/F4 are
|
|
94
|
+
* lexical facts the probe simply got wrong. Each was priced against the frozen
|
|
95
|
+
* corpus streams before being shipped, then device-verified per occurrence on
|
|
96
|
+
* CHR 7.23.2 (`bun run explain:probe:symbol-arms-score` +
|
|
97
|
+
* `bun run explain:probe:symbol-anchors`, the Q16 re-score method):
|
|
98
|
+
*
|
|
99
|
+
* F1 A `$[…]` / `$(…)` substitution inside a double-quoted string is CODE
|
|
100
|
+
* (Q3 grounded the same fact in `:parse` IL). A scan that treats it as
|
|
101
|
+
* string bytes flips its quote phase on the first nested string, which
|
|
102
|
+
* silently destroys every later binding. Modeling it moved holdout
|
|
103
|
+
* precision 96.75% → 99.85% and dev 93.06% → 98.50%.
|
|
104
|
+
* F3 The SIGIL on a scripting head is optional in both directions, but the
|
|
105
|
+
* head must be at STATEMENT START and carry at most ONE sigil character. Q2
|
|
106
|
+
* grounded the colon-less form for body-taking directives (`do {` ≡
|
|
107
|
+
* `:do {`); CHR 7.23.2 extends it to every head this module reads —
|
|
108
|
+
* `local foo 1`, `global foo 1`, `foreach i in={1} …`, `for i from=1 …`
|
|
109
|
+
* and `set foo 2` bind exactly like their `:` twins, and so does the `/`
|
|
110
|
+
* spelling, because these directives are also ROOT commands (`/global g 1`
|
|
111
|
+
* → `g` = `variable-global`, the same root vocabulary `write.ts` froze as
|
|
112
|
+
* `ROOT_CMDS`). A deeper path is untouched: in `/ip/local foo 1` the head
|
|
113
|
+
* word is `ip` and `local` is an `obj-inactive` menu item. Requiring the
|
|
114
|
+
* colon dropped those declarations and then reported every later use as a
|
|
115
|
+
* confident `parameter` — a false class on valid input, which is exactly
|
|
116
|
+
* what Q13's posture forbids. The two guards matter as much as the rule:
|
|
117
|
+
* `$fn local foo 1` makes `local` an ordinary word (device: `none`), and
|
|
118
|
+
* `//local foo 1` is a device error, so neither may declare.
|
|
119
|
+
* F5 A sigil RUN at statement start is a hard error — `//local`, `::put` and
|
|
120
|
+
* `:/local` all error at the second character and class every later byte
|
|
121
|
+
* `none` — so it fails closed with `bad-sigil:<offset>` like a malformed
|
|
122
|
+
* escape. Inside a VALUE a doubled slash is ordinary text (`url=http://…`
|
|
123
|
+
* and `comment=a//b` are clean). F8 extends the same stop past the
|
|
124
|
+
* statement lead.
|
|
125
|
+
* F4 A backslash in CODE is valid only before whitespace; before a newline it
|
|
126
|
+
* is the H5 CONTINUATION, so the statement head, the pending declaration
|
|
127
|
+
* and `declaredHere` all survive it. Treating it as a boundary lost
|
|
128
|
+
* `:local \<nl>foo 1` outright and reset the closure flag in
|
|
129
|
+
* `:local fn do=\<nl>{ … }`, leaking the outer scope into a body the device
|
|
130
|
+
* treats as a closure. Every other spelling (`\a`, `\$v`, `\\ `, `\{`) is a
|
|
131
|
+
* hard device error that classes the whole remainder `none`, so the walker
|
|
132
|
+
* records `bad-escape:<offset>` and stops there instead of reading
|
|
133
|
+
* `do=\{` as a closure. `escapeKind` is shared with the `do=` lookback on
|
|
134
|
+
* purpose: `do=\ {`, `do=\<tab>{` and `do=\<nl>{` are all `do={` on the
|
|
135
|
+
* device, and a scan that accepted an escape the lookback did not would
|
|
136
|
+
* silently stop treating the body as a closure. The continuation reaches
|
|
137
|
+
* past its own line — blank lines and an IMMEDIATE `#` comment line keep
|
|
138
|
+
* the statement pending, and statement-leading position survives both — so
|
|
139
|
+
* the walker carries the `Continuation` state `segment.ts` defines, in the
|
|
140
|
+
* same shape as the two scanners there (#215).
|
|
141
|
+
* F2 A NAMED-FUNCTION body (`:local`/`:global NAME do={…}`) is a CLOSURE:
|
|
142
|
+
* outer names read `parameter` inside it, and a global needs an in-body
|
|
143
|
+
* `:global NAME` re-import to read `global`. A control-flow `do={…}`
|
|
144
|
+
* (`:if`/`:foreach`/`:while`/`:do`) SHARES the enclosing scope; both
|
|
145
|
+
* spell `do={`, and the statement head is the distinguisher. The lab
|
|
146
|
+
* could not express this because a depth counter has no scope IDENTITY;
|
|
147
|
+
* this walker carries a scope stack, so it does. Modeling it moved
|
|
148
|
+
* holdout 99.85% → 99.97% and dev 98.50% → 99.73% at zero abstention
|
|
149
|
+
* cost. The ratified alternative — abstaining inside function bodies
|
|
150
|
+
* instead — measured 100.00% / 99.76% but at +1.7pp / +2.9pp abstention,
|
|
151
|
+
* so modeling won on the trade.
|
|
152
|
+
*
|
|
153
|
+
* F6 A `[…]` substitution is a nested STATEMENT context, and the two
|
|
154
|
+
* positions it can appear in differ in SCOPE EXTENT (#201, was K1). The
|
|
155
|
+
* claim K1 carried was measured from a mid-statement bracket and
|
|
156
|
+
* generalized; CHR 7.23.2 splits them:
|
|
157
|
+
*
|
|
158
|
+
* - a statement-LEADING `[` lets what it declares ESCAPE. `[:local b 1]`
|
|
159
|
+
* + `:put $b` reads `local`, and so does a `:global`. "Leading"
|
|
160
|
+
* survives indentation, a preceding `;`, and the lead of a `do={`
|
|
161
|
+
* body, and it composes: a lead bracket nested in a mid-statement one
|
|
162
|
+
* escapes INTO that bracket, not past it. (F6 called this TRANSPARENT.
|
|
163
|
+
* F7 corrects the mechanism: the bracket has its own scope and
|
|
164
|
+
* PROMOTES it outwards at the `]`. The two are indistinguishable until
|
|
165
|
+
* the enclosing scope already claims the name.)
|
|
166
|
+
* - a MID-statement `[` CONFINES what it declares — `:put [:local b 1]`
|
|
167
|
+
* + `:put $b` reads `parameter`. Globals are confined too, which is
|
|
168
|
+
* where K1 was most wrong (`:put [:global g 1]` does NOT escape; only
|
|
169
|
+
* the statement-leading spelling does).
|
|
170
|
+
*
|
|
171
|
+
* Either way the bracket SHARES the enclosing bindings — it is not a
|
|
172
|
+
* closure — and the `]` RESUMES the interrupted statement instead of
|
|
173
|
+
* starting one (`:put [:put 1] :local v 1` declares nothing), which is why
|
|
174
|
+
* the statement state is saved and restored rather than reset. A `$[…]` in
|
|
175
|
+
* a string is the same context and can never be leading. `(` is NOT this:
|
|
176
|
+
* the device reads a directive inside parens as an ordinary expression
|
|
177
|
+
* term (`(:local v 1)` classes the `:local` text itself
|
|
178
|
+
* `variable-undefined`), so no statement context opens there.
|
|
179
|
+
*
|
|
180
|
+
* Modeling it moved holdout abstention 4.53% → 4.25% and dev 6.27% →
|
|
181
|
+
* 5.40%, at no precision cost. Two rules had to move to the HEAD branch to
|
|
182
|
+
* hold that: a `/`-sigilled head opens a menu path (S16) and a
|
|
183
|
+
* `find`/`where` head opens a filter region (S8). Both were previously
|
|
184
|
+
* written only on the general word path, and were unreachable from a head
|
|
185
|
+
* only because a bracket used to inherit its head from the enclosing
|
|
186
|
+
* statement; without them `[find comment=$tag]`, the corpus's commonest
|
|
187
|
+
* bracket, stopped reporting its field. That is #201's "one rule, one
|
|
188
|
+
* implementation" exactly.
|
|
189
|
+
*
|
|
190
|
+
* F6 also composes with H4: the first position inside a `[` is
|
|
191
|
+
* statement-leading, so a `#` there opens a COMMENT — which then swallows
|
|
192
|
+
* the `]` and runs to the newline. CHR 7.23.2 agrees (`[# c<nl>…` classes
|
|
193
|
+
* `# c<nl>` `comment` in both spellings, while a mid-statement `#` is a
|
|
194
|
+
* hard `error`), so the declarations that follow such a `]` are real. The
|
|
195
|
+
* head-position `find` is device-grounded the same way
|
|
196
|
+
* (`/ip route<nl>find comment=x` reads `comment` `variable-local`).
|
|
197
|
+
*
|
|
198
|
+
* F7 The FIRST declaration written directly in a scope CLAIMS the name for
|
|
199
|
+
* that scope (#201, was the K3 known limit). A later redeclaration in the
|
|
200
|
+
* same scope binds nothing — its own span still reads its own head's
|
|
201
|
+
* class, but every reference keeps the first one's:
|
|
202
|
+
*
|
|
203
|
+
* :global v 1 / :local v 2 / :put $v -> the use is GLOBAL
|
|
204
|
+
* :local v 1 / :global v 2 / :put $v -> the use is LOCAL
|
|
205
|
+
*
|
|
206
|
+
* K3 read the first row as "an earlier `:global` outranks a later
|
|
207
|
+
* `:local`". The second row shows it is not a precedence between the
|
|
208
|
+
* classes at all. Probing the extent of the real rule overturned three
|
|
209
|
+
* more things, all measured on CHR 7.23.2 AND 7.24rc2, which agreed on
|
|
210
|
+
* every row of the deciding round:
|
|
211
|
+
*
|
|
212
|
+
* - A `:global` does NOT escape the `{…}` it was written in. S2 as
|
|
213
|
+
* ratified says it is visible for the remainder of the document;
|
|
214
|
+
* `:if (1=1) do={:global v 1}` + `:put $v` reads PARAMETER. `:global`
|
|
215
|
+
* and `:local` have IDENTICAL lexical visibility and differ only in
|
|
216
|
+
* the class they emit, so `bind` writes to the current scope and
|
|
217
|
+
* nowhere else. This is the only rule here the CORPUS reaches: it
|
|
218
|
+
* moved 4 dev occurrences, all onto the device's reading (a `:global`
|
|
219
|
+
* re-import inside a named-function body that used to leak out of it).
|
|
220
|
+
* - A statement-LEADING `[` PROMOTES rather than being transparent (see
|
|
221
|
+
* F6). Promotion is subject to the claim rule, which is the only thing
|
|
222
|
+
* that distinguishes the two: with `:global x 1` in force,
|
|
223
|
+
* `[:local x 2; :put $x]` reads the in-bracket use LOCAL and the use
|
|
224
|
+
* after the `]` GLOBAL.
|
|
225
|
+
* - Loop variables (S3) and the `:onerror` error variable (S18) bind in
|
|
226
|
+
* a STATEMENT scope that wraps the rest of the statement, body
|
|
227
|
+
* included. Neither the enclosing scope nor the body scope can hold
|
|
228
|
+
* them: an enclosing claim would swallow the binding (`:global i 1` +
|
|
229
|
+
* a loop over `i` still reads `auto` inside the body), and the body's
|
|
230
|
+
* own `:local i` has to be able to shadow it. `:onerror` ALSO claims
|
|
231
|
+
* the enclosing scope, which a loop variable does not — the device
|
|
232
|
+
* reads `$e` after `:onerror e …` as `local` but `$i` after
|
|
233
|
+
* `:foreach i …` as `parameter`.
|
|
234
|
+
*
|
|
235
|
+
* The control that made all of this measurable: `request=highlight` is
|
|
236
|
+
* purely LEXICAL. Inspecting `:global zzz 1` does not change how a later
|
|
237
|
+
* inspect reads `$zzz`, and `/system/script/environment` stays empty
|
|
238
|
+
* throughout, so none of these readings are probe-ordering artifacts.
|
|
239
|
+
*
|
|
240
|
+
* Modeling it moved dev precision 99.75% → 99.79% at unchanged abstention
|
|
241
|
+
* and unchanged `missed`, with holdout unchanged; a per-offset corpus diff
|
|
242
|
+
* against the pre-change module shows 4 changed occurrences on dev, all 4
|
|
243
|
+
* toward the device, none away, none dropped.
|
|
244
|
+
*
|
|
245
|
+
* F8 A sigil is a DEFECT in menu-PATH position, not only at the statement
|
|
246
|
+
* lead (#201, was the K2 known limit). `/ip//address print` is a hard
|
|
247
|
+
* device error exactly like `//local foo 1`, and the walker now stops on
|
|
248
|
+
* both. Three device readings fix the shape:
|
|
249
|
+
*
|
|
250
|
+
* - The region ENDS AT THE COMMAND, and the device does not end it at a
|
|
251
|
+
* verb. `/ip address zzzz //foo` is as clean as
|
|
252
|
+
* `/ip address print //foo`, while `/ip address //foo` — nothing
|
|
253
|
+
* having ended the path — errors. So the question is "is this word
|
|
254
|
+
* still a submenu?", which `menus.ts` (#207) answers; K2 read it as
|
|
255
|
+
* needing parser context that did not exist, when what it needed was
|
|
256
|
+
* the baked table.
|
|
257
|
+
* - WHICH BYTE errors depends on the sigil. A `/` is a legal separator
|
|
258
|
+
* when it is ADJACENT to the segment before it, so a doubled one errors
|
|
259
|
+
* on the second (`/ip//address` → offset 4); after a space it is
|
|
260
|
+
* already wrong, so `/ip /address` errors on the first (offset 4 too,
|
|
261
|
+
* for a different reason). A `:` is never legal in a path and errors on
|
|
262
|
+
* itself, doubled or not (`/ip:address` → offset 3).
|
|
263
|
+
* - A `:`-spelled head is a scripting directive and opens no path at all
|
|
264
|
+
* (`:put //foo`, `:put ::foo`, `:put :/foo` are clean), and so is a
|
|
265
|
+
* colon-less head the table does not know (`put //foo`).
|
|
266
|
+
*
|
|
267
|
+
* - A token that cannot be a path segment ENDS the region. A digit-led
|
|
268
|
+
* object name is the one that matters: `/ip 1 //ip/address` is CLEAN
|
|
269
|
+
* on the device (`1` is `obj-inactive`, every later byte `none`) and
|
|
270
|
+
* only the RUNTIME rejects it — "bad command name 1 (line 1 column
|
|
271
|
+
* 5)", at the `1`, never at the `//`. `$`, `"`, `(` and `[` there are
|
|
272
|
+
* hard errors on their own first byte, which this walker does not
|
|
273
|
+
* model, so it closes the region and stays silent rather than
|
|
274
|
+
* reporting a defect at the wrong offset.
|
|
275
|
+
*
|
|
276
|
+
* `menus.ts` being a FLOOR is the safe direction: an unlisted menu closes
|
|
277
|
+
* the region and the defect goes unreported, rather than a valid script
|
|
278
|
+
* being truncated. Measured that way — 91/97 device rows, **0 false
|
|
279
|
+
* positives and 0 wrong offsets** — and over the whole 913-script frozen
|
|
280
|
+
* corpus **no file where this stops and the device is clean**. The 6
|
|
281
|
+
* remaining rows are device errors this walker does not model at all (the
|
|
282
|
+
* "argument before a command" error, which lands on the `=`, and the four
|
|
283
|
+
* non-path tokens above), every one of them the silent direction. The
|
|
284
|
+
* corpus moves no occurrence: the 14 files it newly stops in are
|
|
285
|
+
* non-RouterOS snippets (YAML, log text) that the device already rejects
|
|
286
|
+
* earlier, so they were past a cliff already.
|
|
287
|
+
*
|
|
288
|
+
* The path-region guard came out of review. Without it the walker scored 7
|
|
289
|
+
* FALSE POSITIVES and 4 wrong offsets on those same rows — a defect stop on
|
|
290
|
+
* input the device's highlight reads clean, which is the one failure mode
|
|
291
|
+
* this rule may not have.
|
|
292
|
+
*
|
|
293
|
+
* Measured on the frozen split (`test/fixtures/explain/corpus-partition.json`,
|
|
294
|
+
* promoted in #186 — it is the leakage guard, and only scores taken on the
|
|
295
|
+
* SAME split are comparable to the ones below) against
|
|
296
|
+
* the per-occurrence highlight streams for 7.23.2 AND 7.24rc2. On 7.23.2:
|
|
297
|
+
* **holdout 99.89% precision on decided (6144/6151), 3.60% abstention, 19
|
|
298
|
+
* missed; dev 99.79% (8733/8751), 4.20%, 32 missed.** On 7.24rc2 the decided
|
|
299
|
+
* precision is unchanged (holdout 6144/6151; dev 8747/8765), with 3.63%/4.22%
|
|
300
|
+
* abstention and 19/30 missed.
|
|
301
|
+
*
|
|
302
|
+
* The scan is a single left-to-right pass with an explicit delimiter stack (no
|
|
303
|
+
* recursion, Q17 posture) and never throws; structural surprises land in
|
|
304
|
+
* `defects` with the same vocabulary the segmenter uses. Scope creation is capped
|
|
305
|
+
* at `MAX_SCOPE_DEPTH` so pathological nesting cannot make lookup unbounded.
|
|
306
|
+
*/
|
|
307
|
+
|
|
308
|
+
import { analyzeCoordinates } from "./coordinates.ts";
|
|
309
|
+
import { type Defect, defectAt } from "./defects.ts";
|
|
310
|
+
import { isMenuPath } from "./menus.ts";
|
|
311
|
+
import { braceStartsStatements, hashStartsHardError } from "./scope-brace.ts";
|
|
312
|
+
import type { Continuation } from "./segment.ts";
|
|
313
|
+
|
|
314
|
+
/** The five variable classes the console assigns. */
|
|
315
|
+
export type SymbolClass =
|
|
316
|
+
| "local"
|
|
317
|
+
| "global"
|
|
318
|
+
| "auto"
|
|
319
|
+
| "parameter"
|
|
320
|
+
| "undefined";
|
|
321
|
+
|
|
322
|
+
/** The source-level job an occurrence performs. */
|
|
323
|
+
export type SymbolRole =
|
|
324
|
+
| "declaration"
|
|
325
|
+
| "binding"
|
|
326
|
+
| "assignment"
|
|
327
|
+
| "reference"
|
|
328
|
+
| "field";
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Device highlight token-class name for each class, for callers comparing
|
|
332
|
+
* against `/console/inspect request=highlight` output.
|
|
333
|
+
*
|
|
334
|
+
* `undefined` is present for completeness of the device vocabulary: this
|
|
335
|
+
* resolver never emits it offline (the ratified Q13 answer), it abstains
|
|
336
|
+
* instead.
|
|
337
|
+
*/
|
|
338
|
+
export const HIGHLIGHT_CLASS: Record<SymbolClass, string> = {
|
|
339
|
+
local: "variable-local",
|
|
340
|
+
global: "variable-global",
|
|
341
|
+
auto: "variable-auto",
|
|
342
|
+
parameter: "variable-parameter",
|
|
343
|
+
undefined: "variable-undefined",
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** One located semantic symbol occurrence. */
|
|
347
|
+
export interface SymbolOccurrence {
|
|
348
|
+
/** analyzed-byte offset, inclusive. */
|
|
349
|
+
start: number;
|
|
350
|
+
/** analyzed-byte offset, exclusive. */
|
|
351
|
+
end: number;
|
|
352
|
+
/**
|
|
353
|
+
* the symbol's name — the key it binds/resolves as, which is NOT always the
|
|
354
|
+
* span's text: a quoted declaration's span includes its quotes (S11). Slice
|
|
355
|
+
* `start`/`end` out of the original for display text. Names are read off the
|
|
356
|
+
* `analyzed` surface, so a non-ASCII byte in a name reads as the `SUB`
|
|
357
|
+
* placeholder.
|
|
358
|
+
*/
|
|
359
|
+
name: string;
|
|
360
|
+
/** true when written with the `$` sigil. */
|
|
361
|
+
sigil: boolean;
|
|
362
|
+
/** true at a declaration/binding site, false otherwise. */
|
|
363
|
+
declaration: boolean;
|
|
364
|
+
/** Declaration/binding, assignment target, reference, or filter-field site. */
|
|
365
|
+
role: SymbolRole;
|
|
366
|
+
/**
|
|
367
|
+
* Result-local identities of the bindings this occurrence establishes or
|
|
368
|
+
* resolves to. Usually zero or one; `:onerror` establishes both its statement
|
|
369
|
+
* binding and (when unclaimed) an enclosing binding, so declarations may name
|
|
370
|
+
* two. Statement-leading bracket promotion preserves the original identity.
|
|
371
|
+
*/
|
|
372
|
+
bindingIds: string[];
|
|
373
|
+
/** resolved class, or null where offline must abstain (S7/S8/S10). */
|
|
374
|
+
cls: SymbolClass | null;
|
|
375
|
+
/** why the resolver abstained, or how the reference was read. */
|
|
376
|
+
note?: string;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export interface SymbolAnalysis {
|
|
380
|
+
occurrences: SymbolOccurrence[];
|
|
381
|
+
/** Located structural surprises; never a throw. See `defects.ts`. */
|
|
382
|
+
defects: Defect[];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
interface Binding {
|
|
386
|
+
/** Stable only within one `resolveSymbols` result. */
|
|
387
|
+
id: string;
|
|
388
|
+
cls: SymbolClass;
|
|
389
|
+
/** analyzed-byte offset from which the binding is visible. */
|
|
390
|
+
from: number;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
interface Scope {
|
|
394
|
+
/** true for a named-function body: a closure boundary (F2). */
|
|
395
|
+
closure: boolean;
|
|
396
|
+
/**
|
|
397
|
+
* At most ONE binding per name: the FIRST declaration written directly in
|
|
398
|
+
* this scope CLAIMS the name and no later one in the same scope rebinds it
|
|
399
|
+
* (F7).
|
|
400
|
+
*/
|
|
401
|
+
bindings: Map<string, Binding>;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Statement state saved at a `[`, restored at its `]` (F6).
|
|
406
|
+
*
|
|
407
|
+
* A bracket is a nested STATEMENT context, so the walk resets the statement
|
|
408
|
+
* bookkeeping on the way in; the enclosing statement has to survive that, since
|
|
409
|
+
* a `]` resumes it rather than starting a new one.
|
|
410
|
+
*/
|
|
411
|
+
interface BracketFrame {
|
|
412
|
+
head: string | null;
|
|
413
|
+
filterDepth: number | null;
|
|
414
|
+
inMenuPath: boolean;
|
|
415
|
+
pendingSetTarget: boolean;
|
|
416
|
+
pendingErrVar: boolean;
|
|
417
|
+
pendingDecl: SymbolClass | null;
|
|
418
|
+
pendingLoopVars: boolean;
|
|
419
|
+
declaredHere: boolean;
|
|
420
|
+
headSigil: ":" | "/" | "";
|
|
421
|
+
cont: Continuation;
|
|
422
|
+
contLineStart: boolean;
|
|
423
|
+
/** the scope this bracket pushed, or null when the depth cap refused one. */
|
|
424
|
+
scope: Scope | null;
|
|
425
|
+
/** statement-LEADING: its claims are PROMOTED to the enclosing scope (F7). */
|
|
426
|
+
lead: boolean;
|
|
427
|
+
/** F8 — the enclosing statement's menu-path state. */
|
|
428
|
+
pathSegments: string[];
|
|
429
|
+
pathOpen: boolean;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* F7 — `:foreach`/`:for` loop variables and the `:onerror` error variable bind
|
|
434
|
+
* in a scope of their own that wraps the rest of the STATEMENT, body included,
|
|
435
|
+
* and pops with it.
|
|
436
|
+
*
|
|
437
|
+
* Neither the enclosing scope nor the body scope can hold them: an enclosing
|
|
438
|
+
* claim would swallow the binding (`:global i 1` + a loop over `i` still reads
|
|
439
|
+
* `auto` inside the body), and the body's own `:local i` has to be able to
|
|
440
|
+
* shadow it (`:foreach i … do={:local i 9; :put $i}` reads `local`).
|
|
441
|
+
*/
|
|
442
|
+
interface StatementScope {
|
|
443
|
+
scope: Scope;
|
|
444
|
+
/** delimiter depth of the binding head; the scope pops at a reset back here. */
|
|
445
|
+
depth: number;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Heads that declare a name in their next word. */
|
|
449
|
+
const DECL: Record<string, SymbolClass> = { local: "local", global: "global" };
|
|
450
|
+
/** Heads whose leading words are loop variables (S3). */
|
|
451
|
+
const LOOP_HEADS: ReadonlySet<string> = new Set(["foreach", "for"]);
|
|
452
|
+
/** Heads that bind an error variable in their next word (S18). */
|
|
453
|
+
const ERRVAR_HEADS: ReadonlySet<string> = new Set(["onerror"]);
|
|
454
|
+
/**
|
|
455
|
+
* Words that open a filter/query region where bare identifiers are menu fields
|
|
456
|
+
* (S8). `where` is the documented one; `find` takes the same shorthand.
|
|
457
|
+
*/
|
|
458
|
+
const FILTER_WORDS: ReadonlySet<string> = new Set(["where", "find"]);
|
|
459
|
+
/** Operator keywords the console tokenizes as `syntax-meta`, not symbols (S17). */
|
|
460
|
+
const OPERATOR_WORDS: ReadonlySet<string> = new Set(["or", "and", "not", "in"]);
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Scope-stack cap.
|
|
464
|
+
*
|
|
465
|
+
* Not a RouterOS limit — the same resource guard `segment.ts` applies to
|
|
466
|
+
* container frames. Beyond this depth a `{` still tracks as a delimiter but
|
|
467
|
+
* opens no new scope (inner declarations join the innermost one) and an
|
|
468
|
+
* `over-depth` defect is raised at that offset, so lookup stays bounded on
|
|
469
|
+
* adversarial input.
|
|
470
|
+
*/
|
|
471
|
+
const MAX_SCOPE_DEPTH = 256;
|
|
472
|
+
|
|
473
|
+
const isIdent = (c: string): boolean => /[A-Za-z0-9_.-]/.test(c);
|
|
474
|
+
const isIdentStart = (c: string): boolean => /[A-Za-z_]/.test(c);
|
|
475
|
+
|
|
476
|
+
/** Resolve every variable occurrence in `original`. Never throws. */
|
|
477
|
+
export function resolveSymbols(original: string): SymbolAnalysis {
|
|
478
|
+
const analysis = analyzeCoordinates(original);
|
|
479
|
+
// `analyzed` is pure ASCII, so a string built from it has index === byte.
|
|
480
|
+
// An already-ASCII input is that string, so the decode is skipped (#322).
|
|
481
|
+
const text = analysis.ascii
|
|
482
|
+
? original
|
|
483
|
+
: new TextDecoder().decode(analysis.analyzed);
|
|
484
|
+
|
|
485
|
+
// S7's "inside a `(…)` expression" test, precomputed in one forward pass.
|
|
486
|
+
// Scanning backward per bare word was O(n²) on a long single-line script: an
|
|
487
|
+
// unmatched `)` kept the scan from stopping at the newline, so it could run
|
|
488
|
+
// back to offset 0 for every word. This table is line-scoped, which is what
|
|
489
|
+
// the rule says and what the backward scan did on balanced input.
|
|
490
|
+
const openParen = parenMap(text);
|
|
491
|
+
|
|
492
|
+
const occurrences: SymbolOccurrence[] = [];
|
|
493
|
+
const defects: Defect[] = [];
|
|
494
|
+
let nextBindingId = 0;
|
|
495
|
+
const root: Scope = { closure: false, bindings: new Map() };
|
|
496
|
+
const scopes: Scope[] = [root];
|
|
497
|
+
/** F6 — saved statement state per open `[`, innermost last. */
|
|
498
|
+
const brackets: BracketFrame[] = [];
|
|
499
|
+
/** F7 — open statement scopes (loop / error variables), innermost last. */
|
|
500
|
+
const statementScopes: StatementScope[] = [];
|
|
501
|
+
/**
|
|
502
|
+
* open delimiters, innermost last; `"` is a string frame. Each carries WHERE
|
|
503
|
+
* it opened so an `unclosed`/`unterminated-string` defect can point at the
|
|
504
|
+
* opener instead of at end-of-input (#192).
|
|
505
|
+
*/
|
|
506
|
+
const frames: {
|
|
507
|
+
char: "{" | "[" | "(" | '"';
|
|
508
|
+
at: number;
|
|
509
|
+
statements: boolean;
|
|
510
|
+
}[] = [];
|
|
511
|
+
/** bracket/brace nesting, strings excluded (the S8 filter region uses it). */
|
|
512
|
+
let depth = 0;
|
|
513
|
+
/** `{` opens past the cap that pushed no scope; their `}` must not pop one. */
|
|
514
|
+
let suppressedScopes = 0;
|
|
515
|
+
let overDepth = false;
|
|
516
|
+
/** a malformed escape ended the analysis (the device errors out there). */
|
|
517
|
+
let defect = false;
|
|
518
|
+
|
|
519
|
+
/** lowercased leading word of the current statement. */
|
|
520
|
+
let head: string | null = null;
|
|
521
|
+
/** depth at which a filter word opened its region, or null. */
|
|
522
|
+
let filterDepth: number | null = null;
|
|
523
|
+
let pendingDecl: SymbolClass | null = null;
|
|
524
|
+
let pendingLoopVars = false;
|
|
525
|
+
/** inside a space-separated menu path run, where bare words are `dir` (S16). */
|
|
526
|
+
let inMenuPath = false;
|
|
527
|
+
/**
|
|
528
|
+
* F8 — the menu-path segments read so far in this statement, and whether the
|
|
529
|
+
* console would still be reading the PATH at this point.
|
|
530
|
+
*
|
|
531
|
+
* Deliberately NOT `inMenuPath`, which answers a different question (is this
|
|
532
|
+
* bare word a symbol?) and stays true past the verb to the end of the
|
|
533
|
+
* statement. The defect rule needs the narrower region that ENDS at the
|
|
534
|
+
* command, so it accumulates segments and asks `menus.ts`.
|
|
535
|
+
*/
|
|
536
|
+
let pathSegments: string[] = [];
|
|
537
|
+
let pathOpen = false;
|
|
538
|
+
let pendingSetTarget = false;
|
|
539
|
+
let pendingErrVar = false;
|
|
540
|
+
/** this statement declared a name, so a `do={` it opens is a closure (F2). */
|
|
541
|
+
let declaredHere = false;
|
|
542
|
+
/** the sigil the statement head was written with: `:`, `/`, or none. */
|
|
543
|
+
let headSigil: ":" | "/" | "" = "";
|
|
544
|
+
/** H4 — still before the first real token of the current statement? */
|
|
545
|
+
let atLead = true;
|
|
546
|
+
/** H5 — how far the pending `\<newline>` continuation still reaches (#215). */
|
|
547
|
+
let cont: Continuation = "none";
|
|
548
|
+
/** H5 — at the immediate start of a line the continuation carried into? */
|
|
549
|
+
let contLineStart = false;
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* F7 — drop every statement scope whose statement cannot still be running at
|
|
553
|
+
* the current depth. A reset INSIDE the body (its newlines, its `{`) must not
|
|
554
|
+
* take one down, which is why the depth of the binding head is what decides.
|
|
555
|
+
*/
|
|
556
|
+
const unwindStatementScopes = (): void => {
|
|
557
|
+
while (statementScopes.length > 0) {
|
|
558
|
+
const top = statementScopes[statementScopes.length - 1] as StatementScope;
|
|
559
|
+
if (top.depth < depth) return;
|
|
560
|
+
statementScopes.pop();
|
|
561
|
+
if (scopes[scopes.length - 1] === top.scope) scopes.pop();
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* F7 — open the scope a statement binds its own variables in, once per
|
|
567
|
+
* statement (`:foreach k,v in=…` shares one).
|
|
568
|
+
*/
|
|
569
|
+
const openStatementScope = (at: number): void => {
|
|
570
|
+
const top = statementScopes[statementScopes.length - 1];
|
|
571
|
+
if (top !== undefined && top.depth === depth) return;
|
|
572
|
+
if (scopes.length >= MAX_SCOPE_DEPTH) {
|
|
573
|
+
if (!overDepth) {
|
|
574
|
+
overDepth = true;
|
|
575
|
+
defects.push(defectAt("over-depth", at));
|
|
576
|
+
}
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const scope: Scope = { closure: false, bindings: new Map() };
|
|
580
|
+
scopes.push(scope);
|
|
581
|
+
statementScopes.push({ scope, depth });
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const resetStatement = (): void => {
|
|
585
|
+
unwindStatementScopes();
|
|
586
|
+
head = null;
|
|
587
|
+
filterDepth = null;
|
|
588
|
+
inMenuPath = false;
|
|
589
|
+
pendingSetTarget = false;
|
|
590
|
+
pendingErrVar = false;
|
|
591
|
+
pendingDecl = null;
|
|
592
|
+
pendingLoopVars = false;
|
|
593
|
+
declaredHere = false;
|
|
594
|
+
headSigil = "";
|
|
595
|
+
cont = "none";
|
|
596
|
+
contLineStart = false;
|
|
597
|
+
pathSegments = [];
|
|
598
|
+
pathOpen = false;
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Nearest visible binding for `name` at `at`, innermost scope first. The
|
|
603
|
+
* search STOPS at a closure boundary (F2): a named-function body cannot see
|
|
604
|
+
* the names around it.
|
|
605
|
+
*/
|
|
606
|
+
const lookup = (name: string, at: number): Binding | null => {
|
|
607
|
+
for (let s = scopes.length - 1; s >= 0; s--) {
|
|
608
|
+
const scope = scopes[s] as Scope;
|
|
609
|
+
const b = scope.bindings.get(name);
|
|
610
|
+
// A claim that has not been reached yet does not hide the enclosing
|
|
611
|
+
// binding: in `:global v 1` + `{:put $v; :local v 2; :put $v}` the device
|
|
612
|
+
// reads the first in-body use `global` and the second `local`.
|
|
613
|
+
if (b !== undefined && b.from <= at) return b;
|
|
614
|
+
if (scope.closure) return null;
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* F7 — the FIRST declaration written directly in a scope claims the name.
|
|
621
|
+
* Returns null when the scope already had it, which is not an error: the
|
|
622
|
+
* declaration's own span still records its own head's class, it just binds
|
|
623
|
+
* nothing.
|
|
624
|
+
*/
|
|
625
|
+
const claim = (
|
|
626
|
+
scope: Scope,
|
|
627
|
+
name: string,
|
|
628
|
+
cls: SymbolClass,
|
|
629
|
+
from: number,
|
|
630
|
+
promotedId?: string,
|
|
631
|
+
): Binding | null => {
|
|
632
|
+
if (scope.bindings.has(name)) return null;
|
|
633
|
+
const binding = {
|
|
634
|
+
id: promotedId ?? `b${nextBindingId++}`,
|
|
635
|
+
cls,
|
|
636
|
+
from,
|
|
637
|
+
};
|
|
638
|
+
scope.bindings.set(name, binding);
|
|
639
|
+
return binding;
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
// Case matters: RouterOS variable resolution is CASE-SENSITIVE (`:local
|
|
643
|
+
// UserName` then `$username` reads `parameter` on the device, not local), so
|
|
644
|
+
// binding keys are never folded.
|
|
645
|
+
//
|
|
646
|
+
// F7 — `:global` and `:local` bind IDENTICALLY: current scope, from here on,
|
|
647
|
+
// not escaping the enclosing `{…}`. Only the class they emit differs.
|
|
648
|
+
const bind = (name: string, cls: SymbolClass, from: number): Binding | null =>
|
|
649
|
+
claim(scopes[scopes.length - 1] as Scope, name, cls, from);
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* F6/F7 — enter a `[`. Saves the enclosing statement, opens a nested
|
|
653
|
+
* statement context, and pushes a scope. EVERY bracket gets one: a
|
|
654
|
+
* statement-LEADING bracket differs only in what happens at the `]`, where
|
|
655
|
+
* its claims are promoted outwards.
|
|
656
|
+
*/
|
|
657
|
+
const openBracket = (lead: boolean, at: number): void => {
|
|
658
|
+
const frame: BracketFrame = {
|
|
659
|
+
head,
|
|
660
|
+
filterDepth,
|
|
661
|
+
inMenuPath,
|
|
662
|
+
pendingSetTarget,
|
|
663
|
+
pendingErrVar,
|
|
664
|
+
pendingDecl,
|
|
665
|
+
pendingLoopVars,
|
|
666
|
+
declaredHere,
|
|
667
|
+
headSigil,
|
|
668
|
+
cont,
|
|
669
|
+
contLineStart,
|
|
670
|
+
scope: null,
|
|
671
|
+
lead,
|
|
672
|
+
pathSegments,
|
|
673
|
+
pathOpen,
|
|
674
|
+
};
|
|
675
|
+
if (scopes.length >= MAX_SCOPE_DEPTH) {
|
|
676
|
+
if (!overDepth) {
|
|
677
|
+
overDepth = true;
|
|
678
|
+
defects.push(defectAt("over-depth", at));
|
|
679
|
+
}
|
|
680
|
+
} else {
|
|
681
|
+
const scope: Scope = { closure: false, bindings: new Map() };
|
|
682
|
+
scopes.push(scope);
|
|
683
|
+
frame.scope = scope;
|
|
684
|
+
}
|
|
685
|
+
brackets.push(frame);
|
|
686
|
+
resetStatement();
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* F6/F7 — leave a `]`. `atLead` is deliberately NOT restored: the H4
|
|
691
|
+
* bookkeeping already cleared it for this character, and the device agrees
|
|
692
|
+
* that a `]` resumes the enclosing statement rather than starting one
|
|
693
|
+
* (`:put [:put 1] :local v 1` declares nothing).
|
|
694
|
+
*/
|
|
695
|
+
const closeBracket = (): void => {
|
|
696
|
+
const frame = brackets.pop();
|
|
697
|
+
if (frame === undefined) return;
|
|
698
|
+
if (frame.scope !== null && scopes[scopes.length - 1] === frame.scope) {
|
|
699
|
+
scopes.pop();
|
|
700
|
+
// F7 — a statement-LEADING bracket runs at the enclosing level, so what
|
|
701
|
+
// it declared escapes: each claim is re-offered outwards, and the claim
|
|
702
|
+
// rule decides. A name the enclosing scope already holds keeps its
|
|
703
|
+
// class, which is the whole lead-vs-mid difference once a conflict
|
|
704
|
+
// exists (`:global x 1` + `[:local x 2; :put $x]` reads the in-bracket
|
|
705
|
+
// use `local` and the one after the `]` `global`).
|
|
706
|
+
if (frame.lead) {
|
|
707
|
+
const outer = scopes[scopes.length - 1] as Scope;
|
|
708
|
+
for (const [name, b] of frame.scope.bindings)
|
|
709
|
+
claim(outer, name, b.cls, b.from, b.id);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
head = frame.head;
|
|
713
|
+
filterDepth = frame.filterDepth;
|
|
714
|
+
inMenuPath = frame.inMenuPath;
|
|
715
|
+
pendingSetTarget = frame.pendingSetTarget;
|
|
716
|
+
pendingErrVar = frame.pendingErrVar;
|
|
717
|
+
pendingDecl = frame.pendingDecl;
|
|
718
|
+
pendingLoopVars = frame.pendingLoopVars;
|
|
719
|
+
declaredHere = frame.declaredHere;
|
|
720
|
+
headSigil = frame.headSigil;
|
|
721
|
+
cont = frame.cont;
|
|
722
|
+
contLineStart = frame.contLineStart;
|
|
723
|
+
pathSegments = frame.pathSegments;
|
|
724
|
+
pathOpen = frame.pathOpen;
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
/** Record a `$`-sigilled reference, applying S5/S6. */
|
|
728
|
+
const pushRef = (
|
|
729
|
+
r: { start: number; end: number; name: string },
|
|
730
|
+
note?: string,
|
|
731
|
+
): void => {
|
|
732
|
+
if (/^\d+$/.test(r.name)) {
|
|
733
|
+
// S6 — `$0`, `$1`, … are positional parameters.
|
|
734
|
+
occurrences.push({
|
|
735
|
+
start: r.start,
|
|
736
|
+
end: r.end,
|
|
737
|
+
name: r.name,
|
|
738
|
+
sigil: true,
|
|
739
|
+
declaration: false,
|
|
740
|
+
role: "reference",
|
|
741
|
+
bindingIds: [],
|
|
742
|
+
cls: "parameter",
|
|
743
|
+
note,
|
|
744
|
+
});
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
const binding = lookup(r.name, r.start);
|
|
748
|
+
occurrences.push({
|
|
749
|
+
start: r.start,
|
|
750
|
+
end: r.end,
|
|
751
|
+
name: r.name,
|
|
752
|
+
sigil: true,
|
|
753
|
+
declaration: false,
|
|
754
|
+
role: "reference",
|
|
755
|
+
bindingIds: binding === null ? [] : [binding.id],
|
|
756
|
+
// S5 — no visible declaration is `parameter`, matching the console.
|
|
757
|
+
cls: binding === null ? "parameter" : binding.cls,
|
|
758
|
+
note,
|
|
759
|
+
});
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const record = (
|
|
763
|
+
start: number,
|
|
764
|
+
end: number,
|
|
765
|
+
name: string,
|
|
766
|
+
cls: SymbolClass | null,
|
|
767
|
+
declaration: boolean,
|
|
768
|
+
note?: string,
|
|
769
|
+
role: SymbolRole = declaration ? "declaration" : "reference",
|
|
770
|
+
bindingIds: string[] = [],
|
|
771
|
+
): void => {
|
|
772
|
+
occurrences.push({
|
|
773
|
+
start,
|
|
774
|
+
end,
|
|
775
|
+
name,
|
|
776
|
+
sigil: false,
|
|
777
|
+
declaration,
|
|
778
|
+
role,
|
|
779
|
+
bindingIds,
|
|
780
|
+
cls,
|
|
781
|
+
note,
|
|
782
|
+
});
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
for (let i = 0; i < text.length; i++) {
|
|
786
|
+
const c = text[i] as string;
|
|
787
|
+
|
|
788
|
+
// --- inside a double-quoted string --------------------------------------
|
|
789
|
+
if (frames[frames.length - 1]?.char === '"') {
|
|
790
|
+
if (c === "\\") {
|
|
791
|
+
i++;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (c === '"') {
|
|
795
|
+
frames.pop();
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (c === "$") {
|
|
799
|
+
const next = text[i + 1];
|
|
800
|
+
if (next === "[" || next === "(") {
|
|
801
|
+
// F1 — a substitution inside a string is CODE. Push its frame and
|
|
802
|
+
// keep scanning normally; the matching close pops back into the
|
|
803
|
+
// string. A scan that swallowed this as string bytes would flip
|
|
804
|
+
// its quote phase on the first nested string and lose every
|
|
805
|
+
// binding after it.
|
|
806
|
+
frames.push({ char: next, at: i + 1, statements: next === "[" });
|
|
807
|
+
depth++;
|
|
808
|
+
if (next === "[") {
|
|
809
|
+
// F6 — and it is a statement context like any other `[`. A `$[`
|
|
810
|
+
// can never be statement-LEADING (a string opened it), so it is
|
|
811
|
+
// always the confining, mid-statement form.
|
|
812
|
+
openBracket(false, i + 1);
|
|
813
|
+
atLead = true;
|
|
814
|
+
}
|
|
815
|
+
i++;
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const r = readRef(text, i, true);
|
|
819
|
+
if (r !== null) {
|
|
820
|
+
// S9 — interpolated references classify like any other.
|
|
821
|
+
if (r.name !== null) pushRef({ ...r, name: r.name }, "in-string");
|
|
822
|
+
i = r.next - 1;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// H5 continuation reach (#215) — decided BEFORE the bookkeeping below, so an
|
|
829
|
+
// indented `#` mid-continuation stays content. See `Continuation`.
|
|
830
|
+
const inCont = cont !== "none";
|
|
831
|
+
const contComment = c === "#" && inCont && contLineStart;
|
|
832
|
+
if (inCont && !contComment) {
|
|
833
|
+
if (c === " " || c === "\t" || c === "\r") {
|
|
834
|
+
contLineStart = false;
|
|
835
|
+
} else if (c === "\n" && cont === "escape") {
|
|
836
|
+
// A blank line inside the `\` run is still the run: the statement, its
|
|
837
|
+
// head and its pending declaration all survive it.
|
|
838
|
+
contLineStart = true;
|
|
839
|
+
continue;
|
|
840
|
+
} else {
|
|
841
|
+
// Real content, or the blank line that spends a comment run's reach —
|
|
842
|
+
// which then ends the statement in the newline branch below.
|
|
843
|
+
cont = "none";
|
|
844
|
+
contLineStart = false;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// --- H5 line continuation and escape validity ---------------------------
|
|
849
|
+
//
|
|
850
|
+
// In CODE a backslash is valid ONLY before whitespace (CHR 7.23.2:
|
|
851
|
+
// `:put \\ $v` and `:put \\<tab>$v` are clean, `:put \\a`, `:put \\$v` and
|
|
852
|
+
// `:put \\\\ $v` all raise a hard `error`). Before a newline it is the H5
|
|
853
|
+
// CONTINUATION, so the statement head, the pending declaration and
|
|
854
|
+
// `declaredHere` — which decides whether the next `do={` opens a closure —
|
|
855
|
+
// must all survive it.
|
|
856
|
+
//
|
|
857
|
+
// Anything else is a malformed escape. The device dies there and classes
|
|
858
|
+
// every later byte `none`, so this resolver stops too: it records
|
|
859
|
+
// `bad-escape:<offset>` and reports nothing further, rather than carrying
|
|
860
|
+
// confident classes across a boundary the console never crossed. Occurrences
|
|
861
|
+
// BEFORE the defect stand (the lab's X1 rule).
|
|
862
|
+
if (c === "\\") {
|
|
863
|
+
const kind = escapeKind(text, i);
|
|
864
|
+
if (kind === "continuation") {
|
|
865
|
+
// `atLead` survives: on CHR 7.23.3 `do={\` + newline + `:local x 1`
|
|
866
|
+
// still reads `:local` as the head and declares `x` (#215).
|
|
867
|
+
cont = "escape";
|
|
868
|
+
contLineStart = true;
|
|
869
|
+
i += text[i + 1] === "\r" ? 2 : 1;
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
if (kind === "whitespace") {
|
|
873
|
+
i += 1;
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
defects.push(defectAt("bad-escape", i));
|
|
877
|
+
defect = true;
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// --- `'` is not a RouterOS token (#355) ---------------------------------
|
|
882
|
+
//
|
|
883
|
+
// Same fail-closed stop as the malformed escape above, for the same reason:
|
|
884
|
+
// the device rejects at this byte and classes nothing after it, so carrying
|
|
885
|
+
// confident symbol classes across it would publish facts about bytes the
|
|
886
|
+
// console never reached. `:put 'abc'; :put $after` must not name `$after`.
|
|
887
|
+
//
|
|
888
|
+
// This walk reaches here only in CODE — a `"` string is consumed by the
|
|
889
|
+
// quote phase above and a comment by the `#` branch below — so an
|
|
890
|
+
// apostrophe inside either never trips it, matching `segment.ts`.
|
|
891
|
+
// Occurrences BEFORE the defect stand (the lab's X1 rule).
|
|
892
|
+
if (c === "'") {
|
|
893
|
+
defects.push(defectAt("invalid-apostrophe", i, "'"));
|
|
894
|
+
defect = true;
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// --- comments: `#` in statement-leading position (Q1 rule H4) ------------
|
|
899
|
+
//
|
|
900
|
+
// Done inline rather than through `segment.ts`'s shared `maskComments`:
|
|
901
|
+
// this walk needs the substitution's code CLASSIFIED, not skipped, and it
|
|
902
|
+
// already carries the frame stack for that. Pre-masking measured 98.50% dev
|
|
903
|
+
// where this reads 99.59% — the F1 defect behind that gap (a `$[…]` with a
|
|
904
|
+
// nested string flipping the quote phase, so every later `#` line read as
|
|
905
|
+
// string content) is now fixed in the shared scanner too, via the same
|
|
906
|
+
// frame model (#199); the two must stay in step.
|
|
907
|
+
if (c === "#" && (atLead || contComment)) {
|
|
908
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
909
|
+
if (contComment) {
|
|
910
|
+
// H5 — the comment line does not end the statement: the head, the
|
|
911
|
+
// pending declaration and `declaredHere` all survive it. The loop's
|
|
912
|
+
// i++ steps over the newline so it cannot reset anything either.
|
|
913
|
+
cont = "comment";
|
|
914
|
+
contLineStart = true;
|
|
915
|
+
} else {
|
|
916
|
+
atLead = true;
|
|
917
|
+
resetStatement();
|
|
918
|
+
}
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
// #245, CHR 7.23.3/7.24rc3: an unquoted `#` inside an array or
|
|
922
|
+
// parenthesized expression is a hard error, not a value or comment. The
|
|
923
|
+
// device stops highlighting there, so semantic resolution must stop too.
|
|
924
|
+
if (
|
|
925
|
+
c === "#" &&
|
|
926
|
+
hashStartsHardError(text, i, frames.at(-1)?.statements === false)
|
|
927
|
+
) {
|
|
928
|
+
defects.push(defectAt("invalid-hash", i, "#"));
|
|
929
|
+
defect = true;
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
// H4 bookkeeping. `leadBefore` is the value for the character ABOUT to be
|
|
933
|
+
// read, which is what decides whether a word is the statement HEAD.
|
|
934
|
+
const leadBefore = atLead;
|
|
935
|
+
// F6 — a `[` opens a nested statement context, so the word after it is a
|
|
936
|
+
// HEAD. `(` does not: the device reads a directive there as an ordinary
|
|
937
|
+
// expression term (`(:local v 1)` classes `:local` itself
|
|
938
|
+
// `variable-undefined`), which is why only the bracket is listed here.
|
|
939
|
+
if (c === ";" || c === "\n") atLead = frames.at(-1)?.statements ?? true;
|
|
940
|
+
else if (c === "{") {
|
|
941
|
+
const enclosing = frames.at(-1)?.statements ?? true;
|
|
942
|
+
atLead = enclosing && braceStartsStatements(text, i);
|
|
943
|
+
} else if (c === "[") atLead = true;
|
|
944
|
+
else if (c !== " " && c !== "\t" && c !== "\r") atLead = false;
|
|
945
|
+
|
|
946
|
+
// F8 — anything that cannot be a path token ENDS the path region, so no
|
|
947
|
+
// defect may be reported past it. A digit-led object name is the case that
|
|
948
|
+
// matters: `/ip 1 //ip/address` is CLEAN on CHR 7.23.2 (`1` is
|
|
949
|
+
// `obj-inactive`, every later byte `none`) and only the RUNTIME rejects it,
|
|
950
|
+
// at the `1` — "bad command name 1 (line 1 column 5)" — never at the `//`.
|
|
951
|
+
// Reporting the `//` there was a false positive against the very oracle
|
|
952
|
+
// this module is scored on. The same holds for `1.1.1.1`, `-1` and a `:`
|
|
953
|
+
// following any of them.
|
|
954
|
+
//
|
|
955
|
+
// `$`, `"`, `(` and `[` in path position are hard device ERRORS on their
|
|
956
|
+
// own first byte, which this walker does not model; closing the region
|
|
957
|
+
// there keeps it silent rather than reporting a defect at the wrong offset.
|
|
958
|
+
// Word characters never reach here — the word branch consumes them — and
|
|
959
|
+
// `\` is the H5 continuation, which a path survives.
|
|
960
|
+
if (
|
|
961
|
+
pathOpen &&
|
|
962
|
+
!isIdentStart(c) &&
|
|
963
|
+
c !== ":" &&
|
|
964
|
+
c !== "/" &&
|
|
965
|
+
c !== " " &&
|
|
966
|
+
c !== "\t" &&
|
|
967
|
+
c !== "\r" &&
|
|
968
|
+
c !== "\\"
|
|
969
|
+
)
|
|
970
|
+
pathOpen = false;
|
|
971
|
+
|
|
972
|
+
if (c === '"') {
|
|
973
|
+
// S11 — a QUOTED declaration name. `:global "set-dns" do={…}` declares
|
|
974
|
+
// `set-dns`, and the console carries the class across the quotes, so the
|
|
975
|
+
// span includes them. Without this the string is skipped and the next
|
|
976
|
+
// bare word (`do`) is declared instead.
|
|
977
|
+
if (pendingDecl !== null || pendingLoopVars || pendingErrVar) {
|
|
978
|
+
const close = text.indexOf('"', i + 1);
|
|
979
|
+
if (close > i) {
|
|
980
|
+
const name = text.slice(i + 1, close);
|
|
981
|
+
// F7 — a quoted name binds exactly like a bare one, and the three
|
|
982
|
+
// binding heads differ. Routing them all through `bind` in the
|
|
983
|
+
// enclosing scope (what this branch used to do) broke every F7 rule
|
|
984
|
+
// for the quoted spelling, device-verified on CHR 7.23.2:
|
|
985
|
+
// `:foreach "i" …` + `$"i"` reads `parameter` after the statement,
|
|
986
|
+
// an enclosing `:global i` does not swallow the in-body `auto`, and
|
|
987
|
+
// `:for "i" … do={$"i"}` reads `auto` — so `declaredHere`, which
|
|
988
|
+
// makes the following `do={` an F2 closure, belongs to a
|
|
989
|
+
// `:local`/`:global` declaration ONLY. `:onerror` was not accepted
|
|
990
|
+
// here at all, so its `pendingErrVar` survived to the next bare
|
|
991
|
+
// word and declared `in`.
|
|
992
|
+
const cls: SymbolClass =
|
|
993
|
+
pendingDecl ?? (pendingErrVar ? "local" : "auto");
|
|
994
|
+
const role: SymbolRole =
|
|
995
|
+
pendingDecl !== null ? "declaration" : "binding";
|
|
996
|
+
const bindingIds: string[] = [];
|
|
997
|
+
if (pendingDecl !== null) {
|
|
998
|
+
const binding = bind(name, cls, i + 1);
|
|
999
|
+
if (binding !== null) bindingIds.push(binding.id);
|
|
1000
|
+
pendingDecl = null;
|
|
1001
|
+
declaredHere = true;
|
|
1002
|
+
} else {
|
|
1003
|
+
// F7 — statement scope, and for `:onerror` the enclosing claim
|
|
1004
|
+
// too. Same order as the bare paths: claim before the statement
|
|
1005
|
+
// scope exists.
|
|
1006
|
+
if (pendingErrVar) {
|
|
1007
|
+
const outer = claim(
|
|
1008
|
+
scopes[scopes.length - 1] as Scope,
|
|
1009
|
+
name,
|
|
1010
|
+
"local",
|
|
1011
|
+
i + 1,
|
|
1012
|
+
);
|
|
1013
|
+
if (outer !== null) bindingIds.push(outer.id);
|
|
1014
|
+
}
|
|
1015
|
+
openStatementScope(i + 1);
|
|
1016
|
+
const statement = bind(name, cls, i + 1);
|
|
1017
|
+
if (statement !== null) bindingIds.push(statement.id);
|
|
1018
|
+
pendingErrVar = false;
|
|
1019
|
+
}
|
|
1020
|
+
// The span includes the quotes; the NAME does not.
|
|
1021
|
+
record(i, close + 1, name, cls, true, undefined, role, bindingIds);
|
|
1022
|
+
i = close;
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
frames.push({ char: '"', at: i, statements: false });
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
if (c === "{" || c === "[" || c === "(") {
|
|
1031
|
+
frames.push({
|
|
1032
|
+
char: c,
|
|
1033
|
+
at: i,
|
|
1034
|
+
statements:
|
|
1035
|
+
c === "[" ||
|
|
1036
|
+
(c === "{" &&
|
|
1037
|
+
(frames.at(-1)?.statements ?? true) &&
|
|
1038
|
+
braceStartsStatements(text, i)),
|
|
1039
|
+
});
|
|
1040
|
+
depth++;
|
|
1041
|
+
if (c === "[") openBracket(leadBefore, i);
|
|
1042
|
+
if (c === "{") {
|
|
1043
|
+
if (scopes.length >= MAX_SCOPE_DEPTH) {
|
|
1044
|
+
suppressedScopes++;
|
|
1045
|
+
if (!overDepth) {
|
|
1046
|
+
overDepth = true;
|
|
1047
|
+
defects.push(defectAt("over-depth", i));
|
|
1048
|
+
}
|
|
1049
|
+
} else {
|
|
1050
|
+
// F2 — `:local F do={…}` opens a closure; `:if … do={…}` does not.
|
|
1051
|
+
// Both spell `do={`; the declaring statement head decides.
|
|
1052
|
+
scopes.push({
|
|
1053
|
+
closure: declaredHere && argNameBefore(text, i) === "do",
|
|
1054
|
+
bindings: new Map(),
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
resetStatement();
|
|
1058
|
+
}
|
|
1059
|
+
continue;
|
|
1060
|
+
}
|
|
1061
|
+
if (c === "}" || c === "]" || c === ")") {
|
|
1062
|
+
const want = c === "}" ? "{" : c === "]" ? "[" : "(";
|
|
1063
|
+
// F7 — a statement scope opened INSIDE this delimiter ends with it, and
|
|
1064
|
+
// it sits above the scope this close is about to pop, so it comes down
|
|
1065
|
+
// first. `depth` is still the inner value here (it decrements below).
|
|
1066
|
+
if (frames[frames.length - 1]?.char === want) unwindStatementScopes();
|
|
1067
|
+
if (frames[frames.length - 1]?.char !== want) {
|
|
1068
|
+
// A mismatched close is not a close: popping it would unwind a real
|
|
1069
|
+
// enclosing scope and drop its bindings early. `segment.ts` takes the
|
|
1070
|
+
// same line — report it and treat the character as content.
|
|
1071
|
+
defects.push(defectAt("unbalanced-close", i, c));
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
frames.pop();
|
|
1075
|
+
if (filterDepth !== null && depth <= filterDepth) filterDepth = null;
|
|
1076
|
+
// F6 — restore the statement the `[` interrupted, and drop the bracket's
|
|
1077
|
+
// scope. Ordered after the `filterDepth` clear on purpose: the saved
|
|
1078
|
+
// value is the enclosing region, which outlives this bracket.
|
|
1079
|
+
if (c === "]") closeBracket();
|
|
1080
|
+
if (c === "}") {
|
|
1081
|
+
// S1 — locals declared inside this brace scope go out of view.
|
|
1082
|
+
if (suppressedScopes > 0) suppressedScopes--;
|
|
1083
|
+
else if (scopes.length > 1) scopes.pop();
|
|
1084
|
+
resetStatement();
|
|
1085
|
+
}
|
|
1086
|
+
if (depth > 0) depth--;
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (c === ";" || c === "\n") {
|
|
1091
|
+
resetStatement();
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// --- `$` reference --------------------------------------------------------
|
|
1096
|
+
if (c === "$") {
|
|
1097
|
+
const r = readRef(text, i, false);
|
|
1098
|
+
if (r !== null) {
|
|
1099
|
+
if (r.name !== null) pushRef({ ...r, name: r.name });
|
|
1100
|
+
i = r.next - 1;
|
|
1101
|
+
}
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// S13 — consume the whole identifier-shaped tail of a digit-led literal.
|
|
1106
|
+
// Without this, `1d` skipped the `1` and revisited `d` as a bare symbol;
|
|
1107
|
+
// the same leak affected duration forms such as `1w7h2s`. Identifier-shaped
|
|
1108
|
+
// tails, including `.` and `-`, are consumed here; separators such as `:` and
|
|
1109
|
+
// `/` stop the run and return to the ordinary byte walk.
|
|
1110
|
+
if (/[0-9]/.test(c)) {
|
|
1111
|
+
let j = i + 1;
|
|
1112
|
+
while (j < text.length && isIdent(text[j] as string)) j++;
|
|
1113
|
+
i = j - 1;
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// --- bare word ------------------------------------------------------------
|
|
1118
|
+
if (isIdentStart(c) || c === ":" || c === "/") {
|
|
1119
|
+
const start = i;
|
|
1120
|
+
// `:` and `/` are sigils/path characters, not part of the word.
|
|
1121
|
+
let j = i;
|
|
1122
|
+
while (j < text.length && (text[j] === ":" || text[j] === "/")) j++;
|
|
1123
|
+
const wordStart = j;
|
|
1124
|
+
while (j < text.length && isIdent(text[j] as string)) j++;
|
|
1125
|
+
const word = text.slice(wordStart, j);
|
|
1126
|
+
const lower = word.toLowerCase();
|
|
1127
|
+
const sigilRun = wordStart - start;
|
|
1128
|
+
i = j - 1;
|
|
1129
|
+
// A sigil RUN at statement start is a hard device error — `//local`,
|
|
1130
|
+
// `::put`, `:/local` and a bare `//` all error at the second character
|
|
1131
|
+
// and class every later byte `none`. Checked BEFORE the empty-word exit,
|
|
1132
|
+
// because `//` on its own carries no word and would otherwise slip past.
|
|
1133
|
+
// Inside a value a doubled slash is ordinary text (`url=http://example.com`,
|
|
1134
|
+
// `comment=a//b`, `:put //foo`), so this gate is statement-leading only.
|
|
1135
|
+
// The MID-statement form (`/ip//address`) is a defect too, but only in
|
|
1136
|
+
// menu-path position — F8 below decides that, and the value forms above
|
|
1137
|
+
// stay clean because the path region has already closed by then.
|
|
1138
|
+
if (leadBefore && sigilRun > 1) {
|
|
1139
|
+
defects.push(defectAt("bad-sigil", start + 1));
|
|
1140
|
+
defect = true;
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
// F8 — the same failure MID-statement, now that the walker can tell
|
|
1144
|
+
// path position from the rest. Only inside the path region (see
|
|
1145
|
+
// `pathOpen`), and the offending byte differs by which sigil it is:
|
|
1146
|
+
// a `/` is a legal SEPARATOR when it is adjacent to the segment before
|
|
1147
|
+
// it, so the error lands on the second one; after a space it is already
|
|
1148
|
+
// wrong, so it lands on the first. A `:` is never legal in a path.
|
|
1149
|
+
if (pathOpen && sigilRun > 0) {
|
|
1150
|
+
const adjacent = start > 0 && !/\s/.test(text[start - 1] as string);
|
|
1151
|
+
const at =
|
|
1152
|
+
text[start] === ":" || !adjacent
|
|
1153
|
+
? start
|
|
1154
|
+
: sigilRun > 1
|
|
1155
|
+
? start + 1
|
|
1156
|
+
: -1;
|
|
1157
|
+
if (at >= 0) {
|
|
1158
|
+
defects.push(defectAt("bad-sigil", at));
|
|
1159
|
+
defect = true;
|
|
1160
|
+
break;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
if (word === "") continue;
|
|
1164
|
+
|
|
1165
|
+
// A directive head must be at STATEMENT START. `head === null` alone is
|
|
1166
|
+
// not that test: nothing before it sets `head`, so after a dynamic
|
|
1167
|
+
// command (`$fn local foo 1`) or a bracket the next word looked like a
|
|
1168
|
+
// head and fabricated a declaration. CHR 7.23.2 classes `local`/`foo`
|
|
1169
|
+
// there as `none` and the later `$foo` as `variable-parameter`.
|
|
1170
|
+
//
|
|
1171
|
+
// The sigil run must also be zero or one character: `//local foo 1` and
|
|
1172
|
+
// `/:local foo 1` are device errors (every byte `none`), not
|
|
1173
|
+
// declarations.
|
|
1174
|
+
if (head === null && leadBefore && sigilRun <= 1) {
|
|
1175
|
+
head = lower;
|
|
1176
|
+
// The sigil on a scripting head is OPTIONAL — in BOTH directions.
|
|
1177
|
+
// Q2 grounded the colon-less form for body-taking directives
|
|
1178
|
+
// (`do {` ≡ `:do {`), and CHR 7.23.2 classes `local foo 1`,
|
|
1179
|
+
// `global foo 1`, `foreach i in={1} do={…}` and `for i from=1 …`
|
|
1180
|
+
// exactly like their `:`-spelled twins. The `/` spelling binds too,
|
|
1181
|
+
// because these directives ARE root commands (`/global g 1` →
|
|
1182
|
+
// `g` = `variable-global`) — the same root vocabulary `write.ts`
|
|
1183
|
+
// froze as ROOT_CMDS. A DEEPER path is unaffected: `/ip/local foo 1`
|
|
1184
|
+
// and `/interface local foo 1` make `local` an `obj-inactive` menu
|
|
1185
|
+
// item, and there the head word is `ip`/`interface`, not `local`.
|
|
1186
|
+
headSigil = text[start] === ":" ? ":" : text[start] === "/" ? "/" : "";
|
|
1187
|
+
// S16 — a `/`-sigilled HEAD opens a menu path just as a `/`-sigilled
|
|
1188
|
+
// later word does, so the space-separated segments after it are `dir`,
|
|
1189
|
+
// not symbols. The rule lived only on the non-head path below, which
|
|
1190
|
+
// was invisible while a bracket inherited its head from the enclosing
|
|
1191
|
+
// statement; once `[` opens its own statement context (F6) the first
|
|
1192
|
+
// word inside IS the head, and `[/interface list find …]` started
|
|
1193
|
+
// abstaining on `list`.
|
|
1194
|
+
if (headSigil === "/") inMenuPath = true;
|
|
1195
|
+
// F8 — the DEFECT region opens only on a head the baked menu table
|
|
1196
|
+
// recognizes, whatever its sigil. A `:`-spelled head is a scripting
|
|
1197
|
+
// directive, never a path (`:put //foo` and `:put ::foo` are clean on
|
|
1198
|
+
// the device), and a `/`-spelled head that is not a known menu — the
|
|
1199
|
+
// `/global g 1` form F3 grounds — is a command, not navigation. The
|
|
1200
|
+
// table being a FLOOR is the safe direction here: an unlisted menu
|
|
1201
|
+
// closes the region and the defect simply goes unreported.
|
|
1202
|
+
if (headSigil !== ":" && isMenuPath([lower])) {
|
|
1203
|
+
pathSegments = [lower];
|
|
1204
|
+
pathOpen = true;
|
|
1205
|
+
}
|
|
1206
|
+
if (DECL[lower] !== undefined) pendingDecl = DECL[lower] as SymbolClass;
|
|
1207
|
+
else if (LOOP_HEADS.has(lower)) pendingLoopVars = true;
|
|
1208
|
+
else if (lower === "set") pendingSetTarget = true;
|
|
1209
|
+
else if (ERRVAR_HEADS.has(lower)) pendingErrVar = true;
|
|
1210
|
+
// S8 — `find`/`where` open a filter region from HEAD position too.
|
|
1211
|
+
// `[find comment=$tag]` is the commonest shape of the whole corpus and
|
|
1212
|
+
// its `find` only became a head once `[` started a statement context
|
|
1213
|
+
// (F6); before that the bracket inherited the enclosing head, so this
|
|
1214
|
+
// branch never saw a filter word and the region was opened below.
|
|
1215
|
+
else if (FILTER_WORDS.has(lower)) filterDepth = depth;
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const prevChar = prevNonSpace(text, start);
|
|
1220
|
+
const isValue = prevChar === "=" || prevChar === ",";
|
|
1221
|
+
const sigilled = wordStart > start;
|
|
1222
|
+
|
|
1223
|
+
// F8 — extend or CLOSE the path region. The device ends it at the first
|
|
1224
|
+
// word that is not a known submenu of the path so far, whether or not
|
|
1225
|
+
// that word is a verb: `/ip address zzzz //foo` is clean exactly like
|
|
1226
|
+
// `/ip address print //foo`, while `/ip address //foo` — nothing having
|
|
1227
|
+
// ended the path — is a hard error. An `arg=value` also ends it (the
|
|
1228
|
+
// device rejects an argument before a command outright), which keeps
|
|
1229
|
+
// the `://` inside a `url=` value out of path position no matter how the
|
|
1230
|
+
// menu table reads the words before it.
|
|
1231
|
+
if (pathOpen) {
|
|
1232
|
+
if (isValue || nextNonSpace(text, j) === "=") pathOpen = false;
|
|
1233
|
+
else if (isMenuPath([...pathSegments, lower])) pathSegments.push(lower);
|
|
1234
|
+
else pathOpen = false;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// S10 — `:set NAME` is a REFERENCE carrying the class NAME was declared
|
|
1238
|
+
// as. On an undeclared name the device raises a hard error, which is not
|
|
1239
|
+
// a class at all, so abstain rather than invent one.
|
|
1240
|
+
if (head === "set" && pendingSetTarget) {
|
|
1241
|
+
pendingSetTarget = false;
|
|
1242
|
+
const binding = lookup(word, start);
|
|
1243
|
+
// Spelling decides what an unresolved target means on the device:
|
|
1244
|
+
// `set foo 2` with no `foo` in scope is the MENU verb `set` and the
|
|
1245
|
+
// device reports no variable at all, while `:set`/`/set` are the
|
|
1246
|
+
// scripting command and report a hard `error` — not a class either,
|
|
1247
|
+
// so those abstain (S10) instead of inventing one.
|
|
1248
|
+
if (binding === null && headSigil === "") continue;
|
|
1249
|
+
record(
|
|
1250
|
+
wordStart,
|
|
1251
|
+
j,
|
|
1252
|
+
word,
|
|
1253
|
+
binding === null ? null : binding.cls,
|
|
1254
|
+
false,
|
|
1255
|
+
binding === null
|
|
1256
|
+
? "S10 :set on an undeclared name — the device raises a hard error here"
|
|
1257
|
+
: undefined,
|
|
1258
|
+
"assignment",
|
|
1259
|
+
binding === null ? [] : [binding.id],
|
|
1260
|
+
);
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// S18 — the error variable of `:onerror NAME {…}`.
|
|
1265
|
+
if (pendingErrVar) {
|
|
1266
|
+
pendingErrVar = false;
|
|
1267
|
+
// F7 — it binds TWICE, and the two are separable on the device: a
|
|
1268
|
+
// statement scope makes it `local` inside the body even when the
|
|
1269
|
+
// enclosing scope already claims the name (`:global e 1` + `:onerror
|
|
1270
|
+
// e … do={:put $e}` reads the use `local`), while a claim on the
|
|
1271
|
+
// enclosing scope is what keeps it visible AFTER the statement
|
|
1272
|
+
// (`:onerror e …` + `:put $e` reads `local`, where the same shape
|
|
1273
|
+
// with a loop variable reads `parameter`). Order matters: the claim
|
|
1274
|
+
// is offered to the ENCLOSING scope, before the statement scope
|
|
1275
|
+
// exists.
|
|
1276
|
+
const bindingIds: string[] = [];
|
|
1277
|
+
const outer = claim(
|
|
1278
|
+
scopes[scopes.length - 1] as Scope,
|
|
1279
|
+
word,
|
|
1280
|
+
"local",
|
|
1281
|
+
wordStart,
|
|
1282
|
+
);
|
|
1283
|
+
if (outer !== null) bindingIds.push(outer.id);
|
|
1284
|
+
openStatementScope(wordStart);
|
|
1285
|
+
const statement = bind(word, "local", wordStart);
|
|
1286
|
+
if (statement !== null) bindingIds.push(statement.id);
|
|
1287
|
+
record(
|
|
1288
|
+
wordStart,
|
|
1289
|
+
j,
|
|
1290
|
+
word,
|
|
1291
|
+
"local",
|
|
1292
|
+
true,
|
|
1293
|
+
undefined,
|
|
1294
|
+
"binding",
|
|
1295
|
+
bindingIds,
|
|
1296
|
+
);
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// S1/S2 — this word is the declared name.
|
|
1301
|
+
if (pendingDecl !== null) {
|
|
1302
|
+
const binding = bind(word, pendingDecl, wordStart);
|
|
1303
|
+
record(
|
|
1304
|
+
wordStart,
|
|
1305
|
+
j,
|
|
1306
|
+
word,
|
|
1307
|
+
pendingDecl,
|
|
1308
|
+
true,
|
|
1309
|
+
undefined,
|
|
1310
|
+
"declaration",
|
|
1311
|
+
binding === null ? [] : [binding.id],
|
|
1312
|
+
);
|
|
1313
|
+
pendingDecl = null;
|
|
1314
|
+
declaredHere = true;
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// S3 — `:foreach k,v in=…`: every word before `in=`/`from=` is a loop
|
|
1319
|
+
// variable.
|
|
1320
|
+
if (pendingLoopVars) {
|
|
1321
|
+
if (lower === "in" || lower === "from") {
|
|
1322
|
+
pendingLoopVars = false;
|
|
1323
|
+
continue;
|
|
1324
|
+
}
|
|
1325
|
+
// F7 — loop variables get a scope of their own, wrapping the rest of
|
|
1326
|
+
// the statement including its body.
|
|
1327
|
+
openStatementScope(wordStart);
|
|
1328
|
+
const binding = bind(word, "auto", wordStart);
|
|
1329
|
+
record(
|
|
1330
|
+
wordStart,
|
|
1331
|
+
j,
|
|
1332
|
+
word,
|
|
1333
|
+
"auto",
|
|
1334
|
+
true,
|
|
1335
|
+
undefined,
|
|
1336
|
+
"binding",
|
|
1337
|
+
binding === null ? [] : [binding.id],
|
|
1338
|
+
);
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
if (FILTER_WORDS.has(lower)) {
|
|
1343
|
+
filterDepth = depth;
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// S8 — inside a filter/query region EVERY bare identifier is a menu
|
|
1348
|
+
// field, not only the `field=value` form: `[find … !ca (common-name or
|
|
1349
|
+
// subject-alt-name)]` and the bare flag in `[find running]` are all
|
|
1350
|
+
// `variable-local` on the device. Schema-dependent, so abstain.
|
|
1351
|
+
if (
|
|
1352
|
+
filterDepth !== null &&
|
|
1353
|
+
!isValue &&
|
|
1354
|
+
!sigilled &&
|
|
1355
|
+
!isLiteralWord(lower) &&
|
|
1356
|
+
!OPERATOR_WORDS.has(lower)
|
|
1357
|
+
) {
|
|
1358
|
+
record(wordStart, j, word, null, false, FILTER_NOTE, "field");
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// A bare word followed by `=` is an argument NAME, not a symbol — except
|
|
1363
|
+
// inside a `(…)` expression, where `word = value` is a COMPARISON and the
|
|
1364
|
+
// left side is a symbol position (the device's own `variable-undefined`
|
|
1365
|
+
// example is `(lppp=0)` in an `:if` condition). Abstain there rather than
|
|
1366
|
+
// stay silent, so a caller still learns a symbol is present.
|
|
1367
|
+
if (nextNonSpace(text, j) === "=") {
|
|
1368
|
+
if (
|
|
1369
|
+
filterDepth !== null ||
|
|
1370
|
+
(!sigilled && !isLiteralWord(lower) && openParen[start] === 1)
|
|
1371
|
+
)
|
|
1372
|
+
record(
|
|
1373
|
+
wordStart,
|
|
1374
|
+
j,
|
|
1375
|
+
word,
|
|
1376
|
+
null,
|
|
1377
|
+
false,
|
|
1378
|
+
filterDepth !== null ? FILTER_NOTE : BARE_WORD_NOTE,
|
|
1379
|
+
filterDepth !== null ? "field" : "reference",
|
|
1380
|
+
);
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// S16 — a bare word CONTINUING a space-separated menu path is a path
|
|
1385
|
+
// segment (`/interface list find`), which the S12 sigil rule alone
|
|
1386
|
+
// cannot see because only the first segment carries the `/`.
|
|
1387
|
+
if (sigilled && text[start] === "/") inMenuPath = true;
|
|
1388
|
+
else if (
|
|
1389
|
+
isValue ||
|
|
1390
|
+
prevChar === "[" ||
|
|
1391
|
+
prevChar === "(" ||
|
|
1392
|
+
prevChar === null
|
|
1393
|
+
)
|
|
1394
|
+
inMenuPath = false;
|
|
1395
|
+
if (inMenuPath) continue;
|
|
1396
|
+
// S17 — operator keywords are `syntax-meta`, never symbols. Schema-free
|
|
1397
|
+
// and knowable, so they are excluded outright rather than abstained.
|
|
1398
|
+
if (OPERATOR_WORDS.has(lower)) continue;
|
|
1399
|
+
// S7/S12/S13/S15 — only inside an expression or a filter region does the
|
|
1400
|
+
// console treat a bare word as a symbol at all; a sigilled word is a
|
|
1401
|
+
// directive or path segment, a literal is a value, and a word right
|
|
1402
|
+
// after `=`/`,` is a value.
|
|
1403
|
+
if (
|
|
1404
|
+
!sigilled &&
|
|
1405
|
+
!isValue &&
|
|
1406
|
+
!isLiteralWord(lower) &&
|
|
1407
|
+
depth > 0 &&
|
|
1408
|
+
(filterDepth !== null || openParen[start] === 1)
|
|
1409
|
+
) {
|
|
1410
|
+
const binding = lookup(word, start);
|
|
1411
|
+
record(
|
|
1412
|
+
wordStart,
|
|
1413
|
+
j,
|
|
1414
|
+
word,
|
|
1415
|
+
binding === null ? null : binding.cls,
|
|
1416
|
+
false,
|
|
1417
|
+
// S7 — a resolved binding is asserted (that part is lexical and
|
|
1418
|
+
// scored 100% precision); an unresolved bare word is NOT called
|
|
1419
|
+
// `undefined`, because menu field vs unbound name needs a schema.
|
|
1420
|
+
binding === null ? BARE_WORD_NOTE : undefined,
|
|
1421
|
+
"reference",
|
|
1422
|
+
binding === null ? [] : [binding.id],
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (!defect && frames.length > 0) {
|
|
1429
|
+
// One defect per open frame, pointing at its OPENER. An unterminated string
|
|
1430
|
+
// spans from its quote to end of input — that whole run is what the scan
|
|
1431
|
+
// swallowed — while a bracket marks just its opener, since its intended
|
|
1432
|
+
// extent is exactly what is unknown.
|
|
1433
|
+
for (const f of frames)
|
|
1434
|
+
defects.push(
|
|
1435
|
+
f.char === '"'
|
|
1436
|
+
? { code: "unterminated-string", start: f.at, end: text.length }
|
|
1437
|
+
: defectAt("unclosed", f.at, f.char),
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
return { occurrences, defects };
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const FILTER_NOTE =
|
|
1445
|
+
"S8 filter-field: schema-dependent (local if a real menu field, undefined if not)";
|
|
1446
|
+
const BARE_WORD_NOTE =
|
|
1447
|
+
"S7 bare word in expression position: menu field vs undefined name needs a schema";
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* Read a `$name` or `$"quoted name"` reference at `at`.
|
|
1451
|
+
*
|
|
1452
|
+
* `next` is where the scan RESUMES, which is not always `end`: `$"…"` consumes
|
|
1453
|
+
* its closing delimiter, while a bare `$name` stops ON its terminator, which
|
|
1454
|
+
* the caller still has to see. `${…}` is rejected by RouterOS at `{`; consume
|
|
1455
|
+
* the invalid form without producing an occurrence so its contents cannot be
|
|
1456
|
+
* mistaken for symbols or scope. Conflating `next` and `end` made every `$var]`
|
|
1457
|
+
* swallow its bracket in the lab's first run, drifting the nesting depth upward
|
|
1458
|
+
* across a whole script.
|
|
1459
|
+
*/
|
|
1460
|
+
function readRef(
|
|
1461
|
+
text: string,
|
|
1462
|
+
at: number,
|
|
1463
|
+
inString: boolean,
|
|
1464
|
+
): { start: number; end: number; name: string | null; next: number } | null {
|
|
1465
|
+
let i = at + 1;
|
|
1466
|
+
if (text[i] === "{") {
|
|
1467
|
+
const close = text.indexOf("}", i);
|
|
1468
|
+
if (close < 0) return null;
|
|
1469
|
+
return {
|
|
1470
|
+
start: i + 1,
|
|
1471
|
+
end: close,
|
|
1472
|
+
name: null,
|
|
1473
|
+
next: close + 1,
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
if (text[i] === '"' && !inString) {
|
|
1477
|
+
const close = text.indexOf('"', i + 1);
|
|
1478
|
+
if (close < 0) return null;
|
|
1479
|
+
// The span includes the quotes — the class runs across them (S11).
|
|
1480
|
+
return {
|
|
1481
|
+
start: i,
|
|
1482
|
+
end: close + 1,
|
|
1483
|
+
name: text.slice(i + 1, close),
|
|
1484
|
+
next: close + 1,
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
if (text[i] === "[" || text[i] === "(") return null; // substitution, not a name
|
|
1488
|
+
const start = i;
|
|
1489
|
+
// S14 — the reference-name boundary is NOT the argument-name charset:
|
|
1490
|
+
// `-` always terminates a bare reference. `$set-dns` is `$set` followed
|
|
1491
|
+
// by subtraction/string content; only `$"set-dns"` names a
|
|
1492
|
+
// hyphenated variable. This also covers `->` indexing.
|
|
1493
|
+
// `.` property access. `$ipprefix.0` is `ipprefix` then `.0`.
|
|
1494
|
+
while (i < text.length) {
|
|
1495
|
+
const ch = text[i] as string;
|
|
1496
|
+
if (ch === "." || ch === "-") break;
|
|
1497
|
+
if (!isIdent(ch)) break;
|
|
1498
|
+
i++;
|
|
1499
|
+
}
|
|
1500
|
+
if (i === start) return null;
|
|
1501
|
+
return { start, end: i, name: text.slice(start, i), next: i };
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/** The `name=` immediately before the `{` at `open`, lowercased, or null. */
|
|
1505
|
+
function argNameBefore(text: string, open: number): string | null {
|
|
1506
|
+
let i = open - 1;
|
|
1507
|
+
// Every escape the WALKER accepts has to be transparent here too, or the two
|
|
1508
|
+
// disagree about where `do=` ends: `do=\ {`, `do=\<tab>{` and `do=\<nl>{` are
|
|
1509
|
+
// all `do={` on the device (the body is a closure), while `do=\{` is a hard
|
|
1510
|
+
// error the walker already stopped on. Sharing `escapeKind` is what keeps the
|
|
1511
|
+
// closure test and the scan from drifting apart.
|
|
1512
|
+
while (i >= 0) {
|
|
1513
|
+
const c = text[i] as string;
|
|
1514
|
+
if (isSpace(c)) {
|
|
1515
|
+
i--;
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
if (c === "\\" && escapeKind(text, i) !== "invalid") {
|
|
1519
|
+
i--;
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
break;
|
|
1523
|
+
}
|
|
1524
|
+
if (text[i] !== "=") return null;
|
|
1525
|
+
i--;
|
|
1526
|
+
const end = i + 1;
|
|
1527
|
+
while (i >= 0 && isIdent(text[i] as string)) i--;
|
|
1528
|
+
const name = text.slice(i + 1, end);
|
|
1529
|
+
return name === "" ? null : name.toLowerCase();
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* What the backslash at `at` means in CODE.
|
|
1534
|
+
*
|
|
1535
|
+
* Grounded on CHR 7.23.2: a backslash is valid ONLY before whitespace. Before a
|
|
1536
|
+
* newline it is the H5 line continuation (`\<nl>` and `\<cr><nl>`); before a
|
|
1537
|
+
* space, a tab or a lone carriage return it escapes that character and the
|
|
1538
|
+
* statement carries on; before anything else (`\a`, `\$v`, `\\`, `\{`) the
|
|
1539
|
+
* console raises a hard error and classes the whole remainder `none`.
|
|
1540
|
+
*/
|
|
1541
|
+
function escapeKind(
|
|
1542
|
+
text: string,
|
|
1543
|
+
at: number,
|
|
1544
|
+
): "continuation" | "whitespace" | "invalid" {
|
|
1545
|
+
const next = text[at + 1];
|
|
1546
|
+
if (next === "\n") return "continuation";
|
|
1547
|
+
if (next === "\r")
|
|
1548
|
+
return text[at + 2] === "\n" ? "continuation" : "whitespace";
|
|
1549
|
+
if (next === " " || next === "\t") return "whitespace";
|
|
1550
|
+
return "invalid";
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const isSpace = (c: string): boolean =>
|
|
1554
|
+
c === " " || c === "\t" || c === "\r" || c === "\n";
|
|
1555
|
+
|
|
1556
|
+
/** Previous non-blank character on this line, or null at line start. */
|
|
1557
|
+
function prevNonSpace(text: string, from: number): string | null {
|
|
1558
|
+
for (let i = from - 1; i >= 0; i--) {
|
|
1559
|
+
const c = text[i] as string;
|
|
1560
|
+
if (c === " " || c === "\t") continue;
|
|
1561
|
+
if (c === "\n") return null;
|
|
1562
|
+
return c;
|
|
1563
|
+
}
|
|
1564
|
+
return null;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/** Next non-blank character at or after `from`, or null at end of input. */
|
|
1568
|
+
function nextNonSpace(text: string, from: number): string | null {
|
|
1569
|
+
for (let i = from; i < text.length; i++) {
|
|
1570
|
+
const c = text[i] as string;
|
|
1571
|
+
if (c !== " " && c !== "\t") return c;
|
|
1572
|
+
}
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* Per-byte flag: is this offset inside an unclosed `(` on its own line?
|
|
1578
|
+
*
|
|
1579
|
+
* Strings are deliberately not special-cased — the rule this feeds (S7) was
|
|
1580
|
+
* scored with a paren test that counted every `(`, and this table reproduces it
|
|
1581
|
+
* on any line whose parentheses balance.
|
|
1582
|
+
*/
|
|
1583
|
+
function parenMap(text: string): Uint8Array {
|
|
1584
|
+
const open = new Uint8Array(text.length);
|
|
1585
|
+
let depth = 0;
|
|
1586
|
+
for (let i = 0; i < text.length; i++) {
|
|
1587
|
+
const c = text[i];
|
|
1588
|
+
if (c === "\n") {
|
|
1589
|
+
depth = 0;
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
if (c === "(") {
|
|
1593
|
+
open[i] = depth > 0 ? 1 : 0;
|
|
1594
|
+
depth++;
|
|
1595
|
+
continue;
|
|
1596
|
+
}
|
|
1597
|
+
if (c === ")" && depth > 0) depth--;
|
|
1598
|
+
open[i] = depth > 0 ? 1 : 0;
|
|
1599
|
+
}
|
|
1600
|
+
return open;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* S13 — bare words the console classifies as `none` (literal values), not as
|
|
1605
|
+
* symbols. Booleans/nothing are keywords; anything starting with a digit is a
|
|
1606
|
+
* number, time, IP, or version literal.
|
|
1607
|
+
*/
|
|
1608
|
+
function isLiteralWord(lower: string): boolean {
|
|
1609
|
+
if (/^[0-9]/.test(lower)) return true;
|
|
1610
|
+
return (
|
|
1611
|
+
lower === "true" ||
|
|
1612
|
+
lower === "false" ||
|
|
1613
|
+
lower === "yes" ||
|
|
1614
|
+
lower === "no" ||
|
|
1615
|
+
lower === "nothing"
|
|
1616
|
+
);
|
|
1617
|
+
}
|