@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.24
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 +106 -0
- package/GLOSSARY.txt +38 -0
- package/README.md +2 -2
- package/commands/api/AGENTS.md +18 -1
- package/commands/api/README.md +10 -6
- package/commands/api/examples.md +1 -1
- package/commands/execute/README.md +26 -6
- package/commands/execute/examples.md +43 -5
- package/commands/explain/README.md +873 -182
- package/commands/explain/examples.md +447 -38
- package/commands/retrieve/README.md +13 -2
- package/commands/retrieve/examples.md +30 -1
- package/commands/transfer/README.md +8 -4
- package/docs/CLI.md +19 -5
- package/docs/CONSTITUTION.md +18 -9
- package/docs/MATRIX.md +51 -26
- package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/routeros/permission-denied.md +24 -0
- package/docs/errors/transport/edge-auth-failed.md +22 -0
- package/docs/errors/transport/write-outcome-unknown.md +3 -0
- package/docs/errors/usage/invalid-cursor.md +20 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +5 -2
- package/src/api.ts +34 -39
- package/src/cli/explain.ts +206 -47
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +15 -0
- package/src/core/execute-command.ts +53 -0
- package/src/core/fanout.ts +12 -2
- package/src/core/inspect.ts +4 -5
- package/src/core/routeros-errors.ts +25 -0
- package/src/execute-cmr.ts +2 -8
- package/src/execute.ts +48 -20
- package/src/explain/args.ts +181 -15
- package/src/explain/blocks.ts +3 -4
- package/src/explain/brace-slots.ts +5 -0
- package/src/explain/catalog.ts +13 -6
- package/src/explain/defects.ts +1 -0
- package/src/explain/group-faults.ts +1060 -0
- package/src/explain/highlight-projection.ts +10 -8
- package/src/explain/operators.ts +22 -20
- package/src/explain/pathresolve.ts +102 -20
- package/src/explain/quoted-string.ts +41 -6
- package/src/explain/run-tokens.ts +223 -0
- package/src/explain/scope-brace.ts +319 -7
- package/src/explain/segment.ts +177 -4
- package/src/explain/spaced-args.ts +116 -0
- package/src/explain/symbols.ts +163 -7
- package/src/explain/transport.ts +13 -0
- package/src/explain/values.ts +55 -4
- package/src/explain/verbsplit.ts +14 -188
- package/src/explain/write.ts +8 -1
- package/src/explain-live-facets.ts +739 -0
- package/src/explain-live-types.ts +490 -0
- package/src/explain-live.ts +593 -0
- package/src/explain.ts +772 -60
- package/src/index.ts +6 -0
- package/src/offline-gate.ts +30 -9
- package/src/protocols/adapter.ts +88 -10
- package/src/protocols/btest-session.ts +30 -10
- package/src/protocols/mac-telnet-console.ts +16 -0
- package/src/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- package/src/retrieve-follow.ts +2 -1
- package/src/retrieve-sample.ts +2 -1
- package/src/retrieve-wait.ts +10 -6
- package/src/retrieve.ts +77 -27
- package/src/transfer.ts +38 -13
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The leading path-token run of a statement, for `explain`.
|
|
3
|
+
*
|
|
4
|
+
* A leaf module (it imports only the leaf data tables) so that `segment.ts` and
|
|
5
|
+
* `scope-brace.ts` can ask the question `verbsplit.ts` asks — "does this run
|
|
6
|
+
* already name a command verb?" (#496) — without the import cycle
|
|
7
|
+
* `verbsplit.ts` → `segment.ts` would make.
|
|
8
|
+
* It is the one word scanner both use; a second one written beside it would be
|
|
9
|
+
* free to disagree with the boundary rule. `verbsplit.ts` re-exports the public
|
|
10
|
+
* names, so the Q6 surface is unchanged.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { commandVerbIndex } from "./catalog.ts";
|
|
14
|
+
import type { Span } from "./pathresolve.ts";
|
|
15
|
+
import { VERBS } from "./verbs.ts";
|
|
16
|
+
|
|
17
|
+
const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
|
|
18
|
+
/**
|
|
19
|
+
* A word that ends the leading run: it carries `=` (V2) or opens a group /
|
|
20
|
+
* string / variable (V3). `:` is deliberately NOT here — RouterOS uses it only
|
|
21
|
+
* to start a scripting directive, not as a mid-statement opener, and a
|
|
22
|
+
* `:`-prefixed token is already rejected by `BARE_WORD` below, so it ends the
|
|
23
|
+
* run regardless.
|
|
24
|
+
*/
|
|
25
|
+
const RUN_TERMINATOR = /^[[({"$]/;
|
|
26
|
+
|
|
27
|
+
export function isAsciiWhitespace(char: string | undefined): boolean {
|
|
28
|
+
return char === " " || char === "\t" || char === "\r" || char === "\n";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One whitespace-separated word, and where it sits in the text it came from. */
|
|
32
|
+
export interface Word {
|
|
33
|
+
name: string;
|
|
34
|
+
/** offset of the word's first character, inclusive. */
|
|
35
|
+
start: number;
|
|
36
|
+
/** offset just past its last character, exclusive. */
|
|
37
|
+
end: number;
|
|
38
|
+
/** Source span of each character retained in `name`. */
|
|
39
|
+
chars: Span[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The words of a statement, WITH their offsets (#202c).
|
|
44
|
+
*
|
|
45
|
+
* The one word scanner this module has. It replaced a
|
|
46
|
+
* `replace(/\\\r?\n/g,"").split(whitespace)` pair whose output could not be
|
|
47
|
+
* mapped back to the text it came from — and a second scanner written beside it
|
|
48
|
+
* for the offsets would be free to disagree with the boundary rule, which is the
|
|
49
|
+
* failure #202b hit three times in argument handling.
|
|
50
|
+
*
|
|
51
|
+
* H5 from the Q1 segmenter: a `\<newline>` is removed before RouterOS parses the
|
|
52
|
+
* continued line, so it JOINS the words either side of it rather than separating
|
|
53
|
+
* them (`na\<nl>me` is the single word `name`) and contributes no character to
|
|
54
|
+
* the name. That is why a word's `end - start` is not its `name.length`: the span
|
|
55
|
+
* covers the continuation bytes, the name does not. A caller taking an interior
|
|
56
|
+
* offset from a name is therefore wrong on continued statements, and
|
|
57
|
+
* {@link describeStatement} only ever publishes the run's END.
|
|
58
|
+
*/
|
|
59
|
+
export function* asciiWordSpans(text: string): Generator<Word> {
|
|
60
|
+
// Yielded ONE WORD AT A TIME rather than returned as a list. Both readers
|
|
61
|
+
// stop at the first word the leading run cannot take, and a statement's text
|
|
62
|
+
// carries its whole nested `do={…}` subtree — so building a word (and a span
|
|
63
|
+
// per character of it) for the rest of that subtree was work no caller ever
|
|
64
|
+
// looked at, once per enclosing level (#322). A word is complete when the
|
|
65
|
+
// next one starts, which is why the yield happens on the following boundary
|
|
66
|
+
// rather than at the point the word is opened.
|
|
67
|
+
let current: Word | null = null;
|
|
68
|
+
let i = 0;
|
|
69
|
+
while (i < text.length) {
|
|
70
|
+
const c = text[i] as string;
|
|
71
|
+
if (
|
|
72
|
+
c === "\\" &&
|
|
73
|
+
(text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))
|
|
74
|
+
) {
|
|
75
|
+
// Inside a word this joins; between words it is whitespace that opens
|
|
76
|
+
// none — matching the removal-then-split the rule replaced.
|
|
77
|
+
i += text[i + 1] === "\r" ? 3 : 2;
|
|
78
|
+
if (current !== null) current.end = i;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (isAsciiWhitespace(c)) {
|
|
82
|
+
if (current !== null) yield current;
|
|
83
|
+
current = null;
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (current === null) {
|
|
88
|
+
current = {
|
|
89
|
+
name: c,
|
|
90
|
+
start: i,
|
|
91
|
+
end: i + 1,
|
|
92
|
+
chars: [{ start: i, end: i + 1 }],
|
|
93
|
+
};
|
|
94
|
+
} else {
|
|
95
|
+
current.name += c;
|
|
96
|
+
current.end = i + 1;
|
|
97
|
+
current.chars.push({ start: i, end: i + 1 });
|
|
98
|
+
}
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
if (current !== null) yield current;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Separator that precedes a run token (V1). */
|
|
105
|
+
export type Sep = "start" | "slash" | "space";
|
|
106
|
+
|
|
107
|
+
/** One token of the leading path run, with the punctuation that introduced it. */
|
|
108
|
+
export interface RunToken {
|
|
109
|
+
name: string;
|
|
110
|
+
sep: Sep;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A run token mapped back to the statement bytes that spell it. */
|
|
114
|
+
export interface LocatedRunToken extends RunToken {
|
|
115
|
+
/** Name bytes, split where a `\<newline>` continuation interrupts them. */
|
|
116
|
+
nameSpans: Span[];
|
|
117
|
+
/** One valid `/` immediately before this token, if present. */
|
|
118
|
+
slashBefore?: Span;
|
|
119
|
+
/** One trailing `/` after this token, if present. */
|
|
120
|
+
slashAfter?: Span;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function coalesceChars(chars: readonly Span[]): Span[] {
|
|
124
|
+
const out: Span[] = [];
|
|
125
|
+
for (const char of chars) {
|
|
126
|
+
const last = out.at(-1);
|
|
127
|
+
if (last !== undefined && last.end === char.start) last.end = char.end;
|
|
128
|
+
else out.push({ start: char.start, end: char.end });
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The leading path-token run WITH separators preserved (V1). Same stopping
|
|
135
|
+
* rules as `pathresolve`'s run (V2/V3), so the two stay comparable: a slash-
|
|
136
|
+
* joined word expands to several `slash`-separated tokens; a space-separated
|
|
137
|
+
* word is one `space` token. Stops at the first `=`/group/string/var word, and
|
|
138
|
+
* at any word carrying a non-`BARE_WORD` segment (a variable segment truncates
|
|
139
|
+
* the run before the verb).
|
|
140
|
+
*/
|
|
141
|
+
export function locatedRunTokens(text: string): LocatedRunToken[] {
|
|
142
|
+
const out: LocatedRunToken[] = [];
|
|
143
|
+
for (const word of asciiWordSpans(text)) {
|
|
144
|
+
if (word.name.includes("=") || RUN_TERMINATOR.test(word.name)) break;
|
|
145
|
+
const parts: {
|
|
146
|
+
name: string;
|
|
147
|
+
start: number;
|
|
148
|
+
end: number;
|
|
149
|
+
slashBefore?: Span;
|
|
150
|
+
}[] = [];
|
|
151
|
+
let start = 0;
|
|
152
|
+
for (let i = 0; i <= word.name.length; i++) {
|
|
153
|
+
if (i < word.name.length && word.name[i] !== "/") continue;
|
|
154
|
+
if (start < i) {
|
|
155
|
+
const slashRunStart = start;
|
|
156
|
+
let slashCount = 0;
|
|
157
|
+
while (
|
|
158
|
+
slashRunStart - slashCount - 1 >= 0 &&
|
|
159
|
+
word.name[slashRunStart - slashCount - 1] === "/"
|
|
160
|
+
)
|
|
161
|
+
slashCount++;
|
|
162
|
+
const slashIndex = slashCount === 1 ? slashRunStart - 1 : -1;
|
|
163
|
+
parts.push({
|
|
164
|
+
name: word.name.slice(start, i),
|
|
165
|
+
start,
|
|
166
|
+
end: i,
|
|
167
|
+
...(slashIndex < 0 ? {} : { slashBefore: word.chars[slashIndex] }),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
start = i + 1;
|
|
171
|
+
}
|
|
172
|
+
if (parts.length === 0) continue;
|
|
173
|
+
if (!parts.every((p) => BARE_WORD.test(p.name))) break;
|
|
174
|
+
for (let i = 0; i < parts.length; i++) {
|
|
175
|
+
const part = parts[i] as (typeof parts)[number];
|
|
176
|
+
const leadingSlash = word.name.startsWith("/") && i === 0;
|
|
177
|
+
const trailingSlash =
|
|
178
|
+
i === parts.length - 1 &&
|
|
179
|
+
word.name.endsWith("/") &&
|
|
180
|
+
!word.name.endsWith("//")
|
|
181
|
+
? word.chars.at(-1)
|
|
182
|
+
: undefined;
|
|
183
|
+
out.push({
|
|
184
|
+
name: part.name,
|
|
185
|
+
sep:
|
|
186
|
+
out.length === 0
|
|
187
|
+
? "start"
|
|
188
|
+
: i > 0 || leadingSlash
|
|
189
|
+
? "slash"
|
|
190
|
+
: "space",
|
|
191
|
+
nameSpans: coalesceChars(word.chars.slice(part.start, part.end)),
|
|
192
|
+
...(part.slashBefore === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: { slashBefore: part.slashBefore }),
|
|
195
|
+
...(trailingSlash === undefined ? {} : { slashAfter: trailingSlash }),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function runTokens(text: string): RunToken[] {
|
|
203
|
+
return locatedRunTokens(text).map(({ name, sep }) => ({ name, sep }));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* #496 — the run before a `{` already names a command, so the brace is that
|
|
208
|
+
* command's operand and not a `/menu { … }` container. `/system/script add
|
|
209
|
+
* name beep source { :beep }` is one `add` whose `source` is the body, and
|
|
210
|
+
* `/ip/route/print {1;2}` is a positional brace the device rejects (CHR
|
|
211
|
+
* 7.22.1/7.24.5 `:parse`); neither is a command followed by a menu body.
|
|
212
|
+
* `segment.ts` (H7) and `scope-brace.ts` each spelled the container rule
|
|
213
|
+
* lexically, and both ask here. The two tables are the ones `verbsplit`
|
|
214
|
+
* decides a verb with — the frozen vocabulary and the catalog's command axis —
|
|
215
|
+
* and both are floors: a run naming neither keeps the container reading
|
|
216
|
+
* (`/system script { print }`).
|
|
217
|
+
*/
|
|
218
|
+
export function runNamesCommand(prefix: string): boolean {
|
|
219
|
+
const names = runTokens(prefix).map((token) => token.name);
|
|
220
|
+
return (
|
|
221
|
+
names.some((name) => VERBS.has(name)) || commandVerbIndex(names) !== null
|
|
222
|
+
);
|
|
223
|
+
}
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* gives comment masking, segmentation, and symbol resolution one rule.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { commandVerbIndex } from "./catalog.ts";
|
|
11
|
+
import { isKnownMenuPath } from "./is-known-menu.ts";
|
|
10
12
|
import { scanQuotedString } from "./quoted-string.ts";
|
|
13
|
+
import { runNamesCommand, runTokens } from "./run-tokens.ts";
|
|
14
|
+
import { VERBS } from "./verbs.ts";
|
|
11
15
|
|
|
12
16
|
/** Error-variable shape for `:onerror V { … }` — a bare/`$`-prefixed name. */
|
|
13
17
|
const ERROR_VAR = /^\$?[A-Za-z][A-Za-z0-9._-]*$/;
|
|
@@ -58,8 +62,118 @@ export const DIRECTIVE_BODY: Record<string, string> = {
|
|
|
58
62
|
onerror: "in",
|
|
59
63
|
};
|
|
60
64
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Script-valued attribute naming convention, the corpus census's
|
|
67
|
+
* `SCRIPT_BEARING_KEY` shape (`scripts/explain-corpus-census.ts`).
|
|
68
|
+
*
|
|
69
|
+
* A shape, not a list. It decides a brace's ROLE (statement body or value) for
|
|
70
|
+
* every reader of {@link braceStartsStatements}: segmentation, the argument
|
|
71
|
+
* lexer, symbols and path resolution, as well as the `invalid-command-brace`
|
|
72
|
+
* exemption. A name inside the shape is read as a body; a name outside it as a
|
|
73
|
+
* value. The shape errs toward body because the other error is the worse one:
|
|
74
|
+
* reading a real `up-script={ :beep }` as an array hard-failed input the device
|
|
75
|
+
* accepts. The cost is the fail-open direction. An argument the command does
|
|
76
|
+
* not have, `zzz-script={ :beep }` or `on-zzz={ :beep }`, is a device
|
|
77
|
+
* `syntax error` on CHR 7.24.5 that offline does not report, because naming the
|
|
78
|
+
* command's arguments is schema. The `on-…` half of that predates #350.
|
|
79
|
+
* Which slots take a body is per-menu and per-version schema: on CHR 7.24.5
|
|
80
|
+
* `/tool/netwatch add … up-script={ :beep }` and `/ip/dhcp-server set 0
|
|
81
|
+
* lease-script={ :beep }` both parse, while 7.22.1 accepts the first and
|
|
82
|
+
* rejects the second; `comment={ :beep }` and `zzzscript={ :beep }` are
|
|
83
|
+
* `syntax error` on both (#350). The earlier `^(?:source|script|on-.*)$` missed
|
|
84
|
+
* every `…-script` argument and hard-failed the netwatch row the device accepts.
|
|
85
|
+
* The suffix keeps its hyphen, which is how RouterOS spells these names: a bare
|
|
86
|
+
* `…script` would take `:put myScript={#test}`, whose brace CHR 7.23.3 reads as
|
|
87
|
+
* an array (#245).
|
|
88
|
+
*/
|
|
89
|
+
const SCRIPT_BODY_ARG = /^(?:source|script|.+-script|on-.+)$/;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* An abbreviation the {@link SCRIPT_BODY_ARG} shape still pins down (#496).
|
|
93
|
+
* RouterOS takes any unique prefix of an argument name: on CHR 7.24.5 and
|
|
94
|
+
* 7.23.8, `sour { :beep }` and `s={ :beep }` lower to `source=`, `on`/`o=` to
|
|
95
|
+
* `on-event=`, and `down-scr`/`down-s=` to `down-script=`. These are the
|
|
96
|
+
* prefixes of `source` and `script`, of `on-`, and a name ending in `-` plus a
|
|
97
|
+
* prefix of `script`, so each can only abbreviate a script-valued name. Like
|
|
98
|
+
* the shape, it errs toward body: `sc { :beep }` is rejected on device and
|
|
99
|
+
* offline misses it.
|
|
100
|
+
*
|
|
101
|
+
* Abbreviating the FIRST segment (`down={ :beep }` for `down-script`, also
|
|
102
|
+
* accepted on device) stays outside the PROVEN shape. Every word is a prefix
|
|
103
|
+
* of `<word>-script`, so admitting it here would admit `comment={ :beep }` as
|
|
104
|
+
* a confident body. Telling them apart needs the command's argument list,
|
|
105
|
+
* which is schema — so a `name={` with a script-like body that reaches this
|
|
106
|
+
* far reads as a body for every reader (comments inside are comments) but is
|
|
107
|
+
* warned as a possible abbreviation instead of passing silently (#525,
|
|
108
|
+
* `args.ts` → `invalidCommandBraceOffsets`).
|
|
109
|
+
*/
|
|
110
|
+
const SCRIPT_BODY_ABBREVIATION =
|
|
111
|
+
/^(?:s(?:o(?:u(?:r(?:c)?)?)?)?|sc(?:r(?:i(?:p)?)?)?|on?|.+-(?:s(?:c(?:r(?:i(?:p)?)?)?)?)?)$/;
|
|
112
|
+
|
|
113
|
+
/** Whether `name` is a proven script-body slot (full or pinned abbreviation). */
|
|
114
|
+
export function isScriptBodyName(name: string): boolean {
|
|
115
|
+
return SCRIPT_BODY_ARG.test(name) || SCRIPT_BODY_ABBREVIATION.test(name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isAsciiLetter(char: string | undefined): boolean {
|
|
119
|
+
if (char === undefined) return false;
|
|
120
|
+
return (char >= "A" && char <= "Z") || (char >= "a" && char <= "z");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether the `{` at `open` holds a script-like body (#525). `end` is the
|
|
125
|
+
* exclusive brace end (or the text length when unclosed).
|
|
126
|
+
*
|
|
127
|
+
* A body opens with a directive, an absolute command, or one bare command
|
|
128
|
+
* word: `{ :beep }`, `{ /ip … }`, `{ beep }` and `{ # comment⏎:beep }` are
|
|
129
|
+
* all accepted on CHR 7.25. A bare word followed by a top-level `;` is array
|
|
130
|
+
* members, not commands — `.proplist={name;comment}` is device-rejected —
|
|
131
|
+
* so it keeps the error, as do `{1;2}`, `{"x"}`, `{}` and `$`-led values,
|
|
132
|
+
* which cannot open a command at all. The scan touches each byte between
|
|
133
|
+
* the braces once, skipping strings and `#`-to-end-of-line runs, so it is
|
|
134
|
+
* linear; it works on masked and unmasked text alike (a masked comment is
|
|
135
|
+
* already whitespace).
|
|
136
|
+
*/
|
|
137
|
+
export function braceBodyIsScriptLike(
|
|
138
|
+
text: string,
|
|
139
|
+
open: number,
|
|
140
|
+
end: number,
|
|
141
|
+
): boolean {
|
|
142
|
+
let j = open + 1;
|
|
143
|
+
for (;;) {
|
|
144
|
+
if (j >= end) return false;
|
|
145
|
+
const c = text[j] as string;
|
|
146
|
+
if (c === " " || c === "\t" || c === "\r" || c === "\n") {
|
|
147
|
+
j++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (c === "#") {
|
|
151
|
+
while (j < end && text[j] !== "\n") j++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (c === ":" || c === "/") return true;
|
|
155
|
+
if (!isAsciiLetter(c)) return false;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
let depth = 0;
|
|
159
|
+
while (j < end) {
|
|
160
|
+
const c = text[j] as string;
|
|
161
|
+
if (c === '"') {
|
|
162
|
+
const scan = scanQuotedString(text, j);
|
|
163
|
+
j = Math.min(scan.end, end);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (c === "#") {
|
|
167
|
+
while (j < end && text[j] !== "\n") j++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (c === "{" || c === "[" || c === "(") depth++;
|
|
171
|
+
else if ((c === "}" || c === "]" || c === ")") && depth > 0) depth--;
|
|
172
|
+
else if (c === ";" && depth === 0) return false;
|
|
173
|
+
j++;
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
63
177
|
|
|
64
178
|
/** Grounded scripting directives whose positional arity makes a later `#` fatal. */
|
|
65
179
|
const DIRECTIVE_POSITIONAL_ARITY: Readonly<Record<string, number>> = {
|
|
@@ -75,6 +189,12 @@ interface StatementIndex {
|
|
|
75
189
|
boundaries: Int32Array;
|
|
76
190
|
firstContent: Int32Array;
|
|
77
191
|
lastForbidden: Int32Array;
|
|
192
|
+
/**
|
|
193
|
+
* The innermost open delimiter at each nested `;`/newline boundary. Sparse,
|
|
194
|
+
* because a per-offset array pushed a 540 KiB document past
|
|
195
|
+
* `BOUNDARY_CACHE_BYTE_LIMIT` and made every probe rebuild the index.
|
|
196
|
+
*/
|
|
197
|
+
nestedSeparators: Map<number, number>;
|
|
78
198
|
matchingBrace: Map<number, number>;
|
|
79
199
|
}
|
|
80
200
|
|
|
@@ -116,6 +236,7 @@ function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
|
|
|
116
236
|
index.boundaries.byteLength +
|
|
117
237
|
index.firstContent.byteLength +
|
|
118
238
|
index.lastForbidden.byteLength +
|
|
239
|
+
index.nestedSeparators.size * 32 +
|
|
119
240
|
index.matchingBrace.size * 32
|
|
120
241
|
);
|
|
121
242
|
}
|
|
@@ -177,17 +298,34 @@ function statementIndex(text: string): StatementIndex {
|
|
|
177
298
|
firstContent.fill(-1);
|
|
178
299
|
const lastForbidden = new Int32Array(text.length + 1);
|
|
179
300
|
lastForbidden.fill(-1);
|
|
301
|
+
const nestedSeparators = new Map<number, number>();
|
|
180
302
|
const matchingBrace = new Map<number, number>();
|
|
181
303
|
const delimiters: { char: string; at: number }[] = [];
|
|
182
304
|
let boundary = -1;
|
|
183
305
|
let first = -1;
|
|
184
306
|
let forbidden = -1;
|
|
307
|
+
let comment = false;
|
|
185
308
|
let i = 0;
|
|
186
309
|
while (i < text.length) {
|
|
187
310
|
boundaries[i] = boundary;
|
|
188
311
|
firstContent[i] = first;
|
|
189
312
|
lastForbidden[i] = forbidden;
|
|
190
313
|
const c = text[i] as string;
|
|
314
|
+
// A statement-leading `#` runs to the line end, and a `{`, `}` or `"` in
|
|
315
|
+
// it is not structure: `/ip { # }⏎address { print {1;2} } }` is rejected
|
|
316
|
+
// at the inner `{` on CHR 7.24.5 and 7.23.8, so the `}` must not close
|
|
317
|
+
// `/ip {` (#351). Read raw, the bytes are blank, as `maskComments` makes
|
|
318
|
+
// them; read masked, they already are.
|
|
319
|
+
if (comment && c !== "\n") {
|
|
320
|
+
i++;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
comment = false;
|
|
324
|
+
if (c === "#" && first === -1 && delimiters.at(-1)?.char !== "(") {
|
|
325
|
+
comment = true;
|
|
326
|
+
i++;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
191
329
|
if (c === '"') {
|
|
192
330
|
const end = Math.min(scanQuotedString(text, i).end, text.length);
|
|
193
331
|
boundaries.fill(boundary, i + 1, end + 1);
|
|
@@ -209,6 +347,9 @@ function statementIndex(text: string): StatementIndex {
|
|
|
209
347
|
if (first === -1 && !isAsciiWhitespace(c)) first = i;
|
|
210
348
|
if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
|
|
211
349
|
if (c === "\n" || c === ";" || c === "{" || c === "[") {
|
|
350
|
+
const open = delimiters.at(-1);
|
|
351
|
+
if ((c === "\n" || c === ";") && open !== undefined)
|
|
352
|
+
nestedSeparators.set(i, open.at);
|
|
212
353
|
boundary = i;
|
|
213
354
|
first = -1;
|
|
214
355
|
}
|
|
@@ -221,6 +362,7 @@ function statementIndex(text: string): StatementIndex {
|
|
|
221
362
|
boundaries,
|
|
222
363
|
firstContent,
|
|
223
364
|
lastForbidden,
|
|
365
|
+
nestedSeparators,
|
|
224
366
|
matchingBrace,
|
|
225
367
|
};
|
|
226
368
|
if (cacheStatementIndex(text, index)) adoptBoundaryText(text, index);
|
|
@@ -270,6 +412,19 @@ function trailingBareScopeName(
|
|
|
270
412
|
text: string,
|
|
271
413
|
start: number,
|
|
272
414
|
end: number,
|
|
415
|
+
): string | null {
|
|
416
|
+
const name = trailingBareArgumentName(text, start, end);
|
|
417
|
+
return name !== null && SCOPE_ARG_NAMES.has(name) ? name : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The bare word before a `{` when it can be an argument name: not the
|
|
422
|
+
* statement's leading word, and not a `:` directive. Lower-cased.
|
|
423
|
+
*/
|
|
424
|
+
function trailingBareArgumentName(
|
|
425
|
+
text: string,
|
|
426
|
+
start: number,
|
|
427
|
+
end: number,
|
|
273
428
|
): string | null {
|
|
274
429
|
let i = end - 1;
|
|
275
430
|
while (i >= start && isAsciiWhitespace(text[i])) i--;
|
|
@@ -278,7 +433,6 @@ function trailingBareScopeName(
|
|
|
278
433
|
const nameStart = i + 1;
|
|
279
434
|
if (nameStart === nameEnd) return null;
|
|
280
435
|
const name = text.slice(nameStart, nameEnd).toLowerCase();
|
|
281
|
-
if (!SCOPE_ARG_NAMES.has(name)) return null;
|
|
282
436
|
// A COLON makes it a directive, never an argument name, and the device is
|
|
283
437
|
// emphatic: `:if (1=1) :do { :put a }` is `expected end of command` on both
|
|
284
438
|
// 7.21.5 and 7.24.2, while the bare `:if (1=1) do { :put a }` parses. So the
|
|
@@ -404,24 +558,182 @@ export function scopeNameFromMasked(
|
|
|
404
558
|
* Whether this brace begins a context in which statement-leading comments exist.
|
|
405
559
|
*
|
|
406
560
|
* `floor` bounds the lookback exactly as in {@link scopeNameFromMasked}.
|
|
561
|
+
* `unprovenName` decides the unproven `name={` guess below: `"body"` reads it
|
|
562
|
+
* as a body, `"value"` as a value. Only the script-like disjunct is gated —
|
|
563
|
+
* a proven slot stays a body either way. Readers that are themselves deciding
|
|
564
|
+
* an error AT this brace pass `"value"` where the slot cannot bear a body
|
|
565
|
+
* (`args.ts` for a root directive's slots and a `where` named comparison,
|
|
566
|
+
* `group-faults.ts` for the bracketed form), so the guess does not swallow
|
|
567
|
+
* their error; every other reader keeps `"body"`.
|
|
407
568
|
*/
|
|
408
569
|
export function braceStartsStatements(
|
|
409
570
|
text: string,
|
|
410
571
|
open: number,
|
|
411
572
|
floor = 0,
|
|
573
|
+
unprovenName: "body" | "value" = "body",
|
|
412
574
|
): boolean {
|
|
413
575
|
if (scopeNameFromMasked(text, open, floor) !== null) return true;
|
|
414
576
|
const index = statementIndex(text);
|
|
415
577
|
const boundary = index.boundaries[open] ?? -1;
|
|
416
578
|
const start = Math.max(boundary + 1, floor);
|
|
417
579
|
const named = trailingName(text, start, open);
|
|
418
|
-
if (named !== null)
|
|
580
|
+
if (named !== null) {
|
|
581
|
+
// A `name={` with a script-like body reads as a body for every
|
|
582
|
+
// reader, proven slot or not (#525): `down={ beep }` and
|
|
583
|
+
// `down={ # comment⏎:beep }` are accepted on CHR 7.25, and the `#`
|
|
584
|
+
// is only a comment when the brace bears statements. The warning
|
|
585
|
+
// that marks the unproven slot lives in `args.ts`, which checks
|
|
586
|
+
// ambiguity before this skip.
|
|
587
|
+
if (isScriptBodyName(named.toLowerCase())) return true;
|
|
588
|
+
if (unprovenName === "value") return false;
|
|
589
|
+
const close = matchingBraceEnd(text, open);
|
|
590
|
+
return braceBodyIsScriptLike(
|
|
591
|
+
text,
|
|
592
|
+
open,
|
|
593
|
+
close === undefined ? text.length : close + 1,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
// `source { :beep }` — the same body with the `=` left out (#350). Every
|
|
597
|
+
// grounded spelling lowers to the `source=` IL on CHR 7.22.1 and 7.24.5:
|
|
598
|
+
// `/system/script add name=beep source { :beep }`, `/system/scheduler add
|
|
599
|
+
// name=x on-event { :beep }`, `/tool/netwatch add … down-script { :beep }`.
|
|
600
|
+
// The control is `comment { :beep }`, a `syntax error` on both. A
|
|
601
|
+
// declaration's operand is a variable name, not an argument, so
|
|
602
|
+
// `:local myscript {1;2}` keeps its brace an array.
|
|
603
|
+
const bare = trailingBareArgumentName(text, start, open);
|
|
604
|
+
if (bare !== null && isScriptBodyName(bare)) {
|
|
605
|
+
const head = index.firstContent[open] ?? -1;
|
|
606
|
+
const word =
|
|
607
|
+
head < start ? "" : (leadingWords(text, head, open, 1)[0] ?? "");
|
|
608
|
+
if (!/^:|^(?:local|global)$/i.test(word)) return true;
|
|
609
|
+
}
|
|
419
610
|
|
|
420
|
-
// H7's bare `{...}` and `/menu {...}` containers are statement bodies too
|
|
611
|
+
// H7's bare `{...}` and `/menu {...}` containers are statement bodies too,
|
|
612
|
+
// but a run that already names a command makes the brace its operand (#496).
|
|
613
|
+
// `start` follows the last `{`, so each brace reads its own run: linear.
|
|
421
614
|
const first = index.firstContent[open] ?? -1;
|
|
615
|
+
if (first < start) return true;
|
|
616
|
+
if (text[first] === "/")
|
|
617
|
+
return (
|
|
618
|
+
(index.lastForbidden[open] ?? -1) < start &&
|
|
619
|
+
!runNamesCommand(text.slice(first, open))
|
|
620
|
+
);
|
|
621
|
+
return menuBlockPath(text, open, floor) !== null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Nesting a {@link menuBlockPath} lookup follows before it abstains. */
|
|
625
|
+
const MAX_MENU_BLOCK_DEPTH = 64;
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* A relative run of bare path words, space- or slash-separated. Each word is
|
|
629
|
+
* `pathresolve`'s `BARE_WORD`: the resolver reads no digit-led word as a path,
|
|
630
|
+
* so admitting `6to4 {` here would flatten a block whose navigation it then
|
|
631
|
+
* ignores, resolving the body's `print` against `/interface`.
|
|
632
|
+
*/
|
|
633
|
+
const RELATIVE_MENU_RUN =
|
|
634
|
+
/^[A-Za-z][A-Za-z0-9._-]*(?:[ \t/]+[A-Za-z][A-Za-z0-9._-]*)*$/;
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The menu a `{` opens a statement block over, when the source alone says so
|
|
638
|
+
* (#351). Null means offline cannot tell, not that the brace is a value.
|
|
639
|
+
*
|
|
640
|
+
* On CHR 7.24.5 and 7.23.8 a brace right after a menu word is always a
|
|
641
|
+
* statement block, at any depth and whatever the body holds:
|
|
642
|
+
* `/ip { address { 1;2 } }` is `bad command name 1` at the `1`, and
|
|
643
|
+
* `/ip { address { a;b } }` reads `a` as `add`. A brace after a command word is
|
|
644
|
+
* always an error at the `{` (`/ip { address { print {1;2} } }`). The `/`-led
|
|
645
|
+
* spelling was already read as a block; the RELATIVE one (`address {` inside
|
|
646
|
+
* `/ip { … }`) was read as `/ip address` with a brace operand. That is what
|
|
647
|
+
* hard-failed the outer brace at depth three and hid every fault in the body.
|
|
648
|
+
*
|
|
649
|
+
* A relative run is a block only when the menu it names is known: the
|
|
650
|
+
* enclosing container's path, read the same way outward, plus the run, named by
|
|
651
|
+
* a shipped structure table. A miss abstains, so an unknown word keeps today's
|
|
652
|
+
* reading and the resolver says `unresolved`. The path is lexical: a
|
|
653
|
+
* navigation statement earlier in the same body (`/ip { firewall; address {`)
|
|
654
|
+
* is not followed, and the resolver checks the flattened run against the real
|
|
655
|
+
* context, so that residue can only abstain. Abbreviated menus (`addr {`) are
|
|
656
|
+
* not expanded, as R13 does not expand them.
|
|
657
|
+
*
|
|
658
|
+
* `floor` keeps the ranged-call contract of {@link scopeNameFromMasked}: no
|
|
659
|
+
* enclosing brace before it is consulted, so a range reads exactly as the same
|
|
660
|
+
* text taken alone.
|
|
661
|
+
*/
|
|
662
|
+
export function menuBlockPath(
|
|
663
|
+
text: string,
|
|
664
|
+
open: number,
|
|
665
|
+
floor = 0,
|
|
666
|
+
): readonly string[] | null {
|
|
667
|
+
return blockPathAt(text, statementIndex(text), open, floor, 0);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function blockPathAt(
|
|
671
|
+
text: string,
|
|
672
|
+
index: StatementIndex,
|
|
673
|
+
open: number,
|
|
674
|
+
floor: number,
|
|
675
|
+
depth: number,
|
|
676
|
+
): readonly string[] | null {
|
|
677
|
+
if (depth > MAX_MENU_BLOCK_DEPTH) return null;
|
|
678
|
+
const start = Math.max((index.boundaries[open] ?? -1) + 1, floor);
|
|
679
|
+
const first = index.firstContent[open] ?? -1;
|
|
680
|
+
if (first < start) return enclosingBlockPath(text, index, open, floor, depth);
|
|
681
|
+
if ((index.lastForbidden[open] ?? -1) >= start) return null;
|
|
682
|
+
const run = text.slice(first, open);
|
|
683
|
+
if (text[first] === "/") {
|
|
684
|
+
if (runNamesCommand(run)) return null;
|
|
685
|
+
return runTokens(run).map((token) => token.name);
|
|
686
|
+
}
|
|
687
|
+
const trimmed = run.trim();
|
|
688
|
+
if (!RELATIVE_MENU_RUN.test(trimmed)) return null;
|
|
689
|
+
const names = trimmed.split(/[\s/]+/);
|
|
690
|
+
if (names.some((name) => VERBS.has(name))) return null;
|
|
691
|
+
const parent = enclosingBlockPath(text, index, open, floor, depth);
|
|
692
|
+
if (parent === null) return null;
|
|
693
|
+
const path = [...parent, ...names];
|
|
694
|
+
if (commandVerbIndex(path) !== null || !isKnownMenuPath(path)) return null;
|
|
695
|
+
return path;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The path of the container `{` around the statement that holds `at`, when it
|
|
700
|
+
* is one. The statement starts after a boundary: an opening `{`/`[` is the
|
|
701
|
+
* enclosing delimiter itself, and a separator records it.
|
|
702
|
+
*/
|
|
703
|
+
function enclosingBlockPath(
|
|
704
|
+
text: string,
|
|
705
|
+
index: StatementIndex,
|
|
706
|
+
at: number,
|
|
707
|
+
floor: number,
|
|
708
|
+
depth: number,
|
|
709
|
+
): readonly string[] | null {
|
|
710
|
+
const boundary = index.boundaries[at] ?? -1;
|
|
711
|
+
const outer =
|
|
712
|
+
boundary < 0 || text[boundary] === "{" || text[boundary] === "["
|
|
713
|
+
? boundary
|
|
714
|
+
: (index.nestedSeparators.get(boundary) ?? -1);
|
|
715
|
+
if (outer < floor || text[outer] !== "{") return null;
|
|
716
|
+
return blockPathAt(text, index, outer, floor, depth + 1);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Whether the `#` at `at` opens a comment line inside an array literal (#503).
|
|
721
|
+
*
|
|
722
|
+
* `frame` is the innermost open delimiter. CHR 7.24.5 and 7.22.1 agree: inside
|
|
723
|
+
* a `{…}` that is not a statement body, a `#` at column 0 comments out the
|
|
724
|
+
* rest of its line, at any nesting depth and in argument values too
|
|
725
|
+
* (`:local z {a=1;⏎# c⏎b=2}` → `value=a=1;b=2`, `:foreach i in={1;⏎# c⏎2}`).
|
|
726
|
+
* An indented `#` (space or tab), a `#` after a value or `;` on the same line,
|
|
727
|
+
* and a `#` directly inside `( … )` are still syntax errors. A comment-only
|
|
728
|
+
* array is rejected, but so is `{}`: that is the empty-array rule, not this one.
|
|
729
|
+
*/
|
|
730
|
+
export function startsArrayCommentLine(
|
|
731
|
+
text: string,
|
|
732
|
+
at: number,
|
|
733
|
+
frame: { char: string; statements: boolean } | undefined,
|
|
734
|
+
): boolean {
|
|
422
735
|
return (
|
|
423
|
-
|
|
424
|
-
(text[first] === "/" && (index.lastForbidden[open] ?? -1) < start)
|
|
736
|
+
text[at - 1] === "\n" && frame?.char === "{" && frame.statements === false
|
|
425
737
|
);
|
|
426
738
|
}
|
|
427
739
|
|