@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.23
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/GLOSSARY.txt +24 -0
- package/commands/execute/examples.md +19 -0
- package/commands/explain/README.md +428 -128
- package/commands/explain/examples.md +260 -1
- package/docs/CONSTITUTION.md +4 -0
- package/docs/MATRIX.md +23 -11
- package/package.json +4 -1
- package/src/explain/args.ts +69 -8
- 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 +927 -0
- package/src/explain/highlight-projection.ts +10 -8
- package/src/explain/operators.ts +20 -19
- 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 +103 -6
- package/src/explain/segment.ts +41 -3
- package/src/explain/spaced-args.ts +116 -0
- package/src/explain/symbols.ts +17 -1
- package/src/explain/transport.ts +13 -0
- package/src/explain/verbsplit.ts +14 -188
- package/src/explain/write.ts +8 -1
- package/src/explain.ts +378 -18
- package/src/offline-gate.ts +30 -9
- package/src/protocols/btest-session.ts +30 -10
- package/src/protocols/mac-telnet-console.ts +16 -0
|
@@ -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
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { scanQuotedString } from "./quoted-string.ts";
|
|
11
|
+
import { runNamesCommand } from "./run-tokens.ts";
|
|
11
12
|
|
|
12
13
|
/** Error-variable shape for `:onerror V { … }` — a bare/`$`-prefixed name. */
|
|
13
14
|
const ERROR_VAR = /^\$?[A-Za-z][A-Za-z0-9._-]*$/;
|
|
@@ -58,8 +59,53 @@ export const DIRECTIVE_BODY: Record<string, string> = {
|
|
|
58
59
|
onerror: "in",
|
|
59
60
|
};
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Script-valued attribute naming convention, the corpus census's
|
|
64
|
+
* `SCRIPT_BEARING_KEY` shape (`scripts/explain-corpus-census.ts`).
|
|
65
|
+
*
|
|
66
|
+
* A shape, not a list. It decides a brace's ROLE (statement body or value) for
|
|
67
|
+
* every reader of {@link braceStartsStatements}: segmentation, the argument
|
|
68
|
+
* lexer, symbols and path resolution, as well as the `invalid-command-brace`
|
|
69
|
+
* exemption. A name inside the shape is read as a body; a name outside it as a
|
|
70
|
+
* value. The shape errs toward body because the other error is the worse one:
|
|
71
|
+
* reading a real `up-script={ :beep }` as an array hard-failed input the device
|
|
72
|
+
* accepts. The cost is the fail-open direction. An argument the command does
|
|
73
|
+
* not have, `zzz-script={ :beep }` or `on-zzz={ :beep }`, is a device
|
|
74
|
+
* `syntax error` on CHR 7.24.5 that offline does not report, because naming the
|
|
75
|
+
* command's arguments is schema. The `on-…` half of that predates #350.
|
|
76
|
+
* Which slots take a body is per-menu and per-version schema: on CHR 7.24.5
|
|
77
|
+
* `/tool/netwatch add … up-script={ :beep }` and `/ip/dhcp-server set 0
|
|
78
|
+
* lease-script={ :beep }` both parse, while 7.22.1 accepts the first and
|
|
79
|
+
* rejects the second; `comment={ :beep }` and `zzzscript={ :beep }` are
|
|
80
|
+
* `syntax error` on both (#350). The earlier `^(?:source|script|on-.*)$` missed
|
|
81
|
+
* every `…-script` argument and hard-failed the netwatch row the device accepts.
|
|
82
|
+
* The suffix keeps its hyphen, which is how RouterOS spells these names: a bare
|
|
83
|
+
* `…script` would take `:put myScript={#test}`, whose brace CHR 7.23.3 reads as
|
|
84
|
+
* an array (#245).
|
|
85
|
+
*/
|
|
86
|
+
const SCRIPT_BODY_ARG = /^(?:source|script|.+-script|on-.+)$/;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* An abbreviation the {@link SCRIPT_BODY_ARG} shape still pins down (#496).
|
|
90
|
+
* RouterOS takes any unique prefix of an argument name: on CHR 7.24.5 and
|
|
91
|
+
* 7.23.8, `sour { :beep }` and `s={ :beep }` lower to `source=`, `on`/`o=` to
|
|
92
|
+
* `on-event=`, and `down-scr`/`down-s=` to `down-script=`. These are the
|
|
93
|
+
* prefixes of `source` and `script`, of `on-`, and a name ending in `-` plus a
|
|
94
|
+
* prefix of `script`, so each can only abbreviate a script-valued name. Like
|
|
95
|
+
* the shape, it errs toward body: `sc { :beep }` is rejected on device and
|
|
96
|
+
* offline misses it.
|
|
97
|
+
*
|
|
98
|
+
* Abbreviating the FIRST segment (`down={ :beep }` for `down-script`, also
|
|
99
|
+
* accepted on device) stays outside. Every word is a prefix of `<word>-script`,
|
|
100
|
+
* so admitting it would admit `comment={ :beep }`. Telling them apart needs the
|
|
101
|
+
* command's argument list, which is schema.
|
|
102
|
+
*/
|
|
103
|
+
const SCRIPT_BODY_ABBREVIATION =
|
|
104
|
+
/^(?:s(?:o(?:u(?:r(?:c)?)?)?)?|sc(?:r(?:i(?:p)?)?)?|on?|.+-(?:s(?:c(?:r(?:i(?:p)?)?)?)?)?)$/;
|
|
105
|
+
|
|
106
|
+
function isScriptBodyName(name: string): boolean {
|
|
107
|
+
return SCRIPT_BODY_ARG.test(name) || SCRIPT_BODY_ABBREVIATION.test(name);
|
|
108
|
+
}
|
|
63
109
|
|
|
64
110
|
/** Grounded scripting directives whose positional arity makes a later `#` fatal. */
|
|
65
111
|
const DIRECTIVE_POSITIONAL_ARITY: Readonly<Record<string, number>> = {
|
|
@@ -270,6 +316,19 @@ function trailingBareScopeName(
|
|
|
270
316
|
text: string,
|
|
271
317
|
start: number,
|
|
272
318
|
end: number,
|
|
319
|
+
): string | null {
|
|
320
|
+
const name = trailingBareArgumentName(text, start, end);
|
|
321
|
+
return name !== null && SCOPE_ARG_NAMES.has(name) ? name : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The bare word before a `{` when it can be an argument name: not the
|
|
326
|
+
* statement's leading word, and not a `:` directive. Lower-cased.
|
|
327
|
+
*/
|
|
328
|
+
function trailingBareArgumentName(
|
|
329
|
+
text: string,
|
|
330
|
+
start: number,
|
|
331
|
+
end: number,
|
|
273
332
|
): string | null {
|
|
274
333
|
let i = end - 1;
|
|
275
334
|
while (i >= start && isAsciiWhitespace(text[i])) i--;
|
|
@@ -278,7 +337,6 @@ function trailingBareScopeName(
|
|
|
278
337
|
const nameStart = i + 1;
|
|
279
338
|
if (nameStart === nameEnd) return null;
|
|
280
339
|
const name = text.slice(nameStart, nameEnd).toLowerCase();
|
|
281
|
-
if (!SCOPE_ARG_NAMES.has(name)) return null;
|
|
282
340
|
// A COLON makes it a directive, never an argument name, and the device is
|
|
283
341
|
// emphatic: `:if (1=1) :do { :put a }` is `expected end of command` on both
|
|
284
342
|
// 7.21.5 and 7.24.2, while the bare `:if (1=1) do { :put a }` parses. So the
|
|
@@ -415,13 +473,52 @@ export function braceStartsStatements(
|
|
|
415
473
|
const boundary = index.boundaries[open] ?? -1;
|
|
416
474
|
const start = Math.max(boundary + 1, floor);
|
|
417
475
|
const named = trailingName(text, start, open);
|
|
418
|
-
if (named !== null) return
|
|
476
|
+
if (named !== null) return isScriptBodyName(named.toLowerCase());
|
|
477
|
+
// `source { :beep }` — the same body with the `=` left out (#350). Every
|
|
478
|
+
// grounded spelling lowers to the `source=` IL on CHR 7.22.1 and 7.24.5:
|
|
479
|
+
// `/system/script add name=beep source { :beep }`, `/system/scheduler add
|
|
480
|
+
// name=x on-event { :beep }`, `/tool/netwatch add … down-script { :beep }`.
|
|
481
|
+
// The control is `comment { :beep }`, a `syntax error` on both. A
|
|
482
|
+
// declaration's operand is a variable name, not an argument, so
|
|
483
|
+
// `:local myscript {1;2}` keeps its brace an array.
|
|
484
|
+
const bare = trailingBareArgumentName(text, start, open);
|
|
485
|
+
if (bare !== null && isScriptBodyName(bare)) {
|
|
486
|
+
const head = index.firstContent[open] ?? -1;
|
|
487
|
+
const word =
|
|
488
|
+
head < start ? "" : (leadingWords(text, head, open, 1)[0] ?? "");
|
|
489
|
+
if (!/^:|^(?:local|global)$/i.test(word)) return true;
|
|
490
|
+
}
|
|
419
491
|
|
|
420
|
-
// H7's bare `{...}` and `/menu {...}` containers are statement bodies too
|
|
492
|
+
// H7's bare `{...}` and `/menu {...}` containers are statement bodies too,
|
|
493
|
+
// but a run that already names a command makes the brace its operand (#496).
|
|
494
|
+
// `start` follows the last `{`, so each brace reads its own run: linear.
|
|
421
495
|
const first = index.firstContent[open] ?? -1;
|
|
422
496
|
return (
|
|
423
497
|
first < start ||
|
|
424
|
-
(text[first] === "/" &&
|
|
498
|
+
(text[first] === "/" &&
|
|
499
|
+
(index.lastForbidden[open] ?? -1) < start &&
|
|
500
|
+
!runNamesCommand(text.slice(first, open)))
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Whether the `#` at `at` opens a comment line inside an array literal (#503).
|
|
506
|
+
*
|
|
507
|
+
* `frame` is the innermost open delimiter. CHR 7.24.5 and 7.22.1 agree: inside
|
|
508
|
+
* a `{…}` that is not a statement body, a `#` at column 0 comments out the
|
|
509
|
+
* rest of its line, at any nesting depth and in argument values too
|
|
510
|
+
* (`:local z {a=1;⏎# c⏎b=2}` → `value=a=1;b=2`, `:foreach i in={1;⏎# c⏎2}`).
|
|
511
|
+
* An indented `#` (space or tab), a `#` after a value or `;` on the same line,
|
|
512
|
+
* and a `#` directly inside `( … )` are still syntax errors. A comment-only
|
|
513
|
+
* array is rejected, but so is `{}`: that is the empty-array rule, not this one.
|
|
514
|
+
*/
|
|
515
|
+
export function startsArrayCommentLine(
|
|
516
|
+
text: string,
|
|
517
|
+
at: number,
|
|
518
|
+
frame: { char: string; statements: boolean } | undefined,
|
|
519
|
+
): boolean {
|
|
520
|
+
return (
|
|
521
|
+
text[at - 1] === "\n" && frame?.char === "{" && frame.statements === false
|
|
425
522
|
);
|
|
426
523
|
}
|
|
427
524
|
|
package/src/explain/segment.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
MAX_STRING_FRAME_DEPTH,
|
|
85
85
|
scanQuotedString as scanQuotedStringShared,
|
|
86
86
|
} from "./quoted-string.ts";
|
|
87
|
+
import { runNamesCommand } from "./run-tokens.ts";
|
|
87
88
|
import {
|
|
88
89
|
braceStartsStatements,
|
|
89
90
|
canRetainStatementIndex,
|
|
@@ -91,6 +92,7 @@ import {
|
|
|
91
92
|
isIndexedStatementStart,
|
|
92
93
|
matchingBraceEnd,
|
|
93
94
|
scopeNameFromMasked,
|
|
95
|
+
startsArrayCommentLine,
|
|
94
96
|
} from "./scope-brace.ts";
|
|
95
97
|
|
|
96
98
|
/** One top-level statement located in analyzed-byte space. */
|
|
@@ -130,6 +132,15 @@ export interface SegmentResult {
|
|
|
130
132
|
* a throw. Includes the two coordinate-pass classes (`bom`, `non-ascii`).
|
|
131
133
|
*/
|
|
132
134
|
defects: Defect[];
|
|
135
|
+
/**
|
|
136
|
+
* The first `}` or `]` that closes nothing at the top level, where RouterOS
|
|
137
|
+
* stops reading (#504). The device accepts the script and silently discards
|
|
138
|
+
* that byte and everything after it: `:put 1};:put 2` lowers to
|
|
139
|
+
* `(evl /putmessage=1)` and `}⏎:put 1` to `/`, on CHR 7.24.5 and 7.23.8.
|
|
140
|
+
* A `)` there, or any stray close inside an open group, is a rejection
|
|
141
|
+
* instead and is only an `unbalanced-close` defect. Absent when there is none.
|
|
142
|
+
*/
|
|
143
|
+
discardFrom?: number;
|
|
133
144
|
}
|
|
134
145
|
|
|
135
146
|
const isSpace = (c: string): boolean => c === " " || c === "\t" || c === "\r";
|
|
@@ -510,7 +521,12 @@ function maskUncached(
|
|
|
510
521
|
// indented `#` is a comment when the statement is still empty (`do={\` +
|
|
511
522
|
// newline + ` # c`) and content when it is not (`:put a\` + newline +
|
|
512
523
|
// ` # x`, which the device classes `error`) — exactly what `atLead` says.
|
|
513
|
-
if (
|
|
524
|
+
if (
|
|
525
|
+
c === "#" &&
|
|
526
|
+
(atLead ||
|
|
527
|
+
contComment ||
|
|
528
|
+
startsArrayCommentLine(original, i, contexts.at(-1)))
|
|
529
|
+
) {
|
|
514
530
|
const commentStart = i;
|
|
515
531
|
while (i < original.length && original[i] !== "\n") i++;
|
|
516
532
|
if (pieces === undefined) pieces = [];
|
|
@@ -650,6 +666,7 @@ function segmentStatementsInternal(
|
|
|
650
666
|
analysis === undefined ? [] : coordinateDefects(analysis),
|
|
651
667
|
raw.defects,
|
|
652
668
|
),
|
|
669
|
+
...(raw.discardFrom === undefined ? {} : { discardFrom: raw.discardFrom }),
|
|
653
670
|
};
|
|
654
671
|
}
|
|
655
672
|
|
|
@@ -699,6 +716,8 @@ interface Frame {
|
|
|
699
716
|
/** Incremental equivalent of trimming and checking the H7 prefix. */
|
|
700
717
|
prefixSawNonSpace: boolean;
|
|
701
718
|
prefixValid: boolean;
|
|
719
|
+
/** The prefix's run has been checked for a command verb (#496). */
|
|
720
|
+
prefixVerbChecked: boolean;
|
|
702
721
|
/** statements collected here; the flattened output for a committed container. */
|
|
703
722
|
buffer: RawSegment[];
|
|
704
723
|
/** true for a container frame; false for the synthetic top level. */
|
|
@@ -726,6 +745,7 @@ function scanAscii(
|
|
|
726
745
|
segments: RawSegment[];
|
|
727
746
|
comments: { start: number; end: number }[];
|
|
728
747
|
defects: Defect[];
|
|
748
|
+
discardFrom?: number;
|
|
729
749
|
} {
|
|
730
750
|
// Local indices remain local in the result; only index-backed questions use
|
|
731
751
|
// document coordinates. The floor prevents an enclosing head from becoming
|
|
@@ -736,6 +756,7 @@ function scanAscii(
|
|
|
736
756
|
const comments: { start: number; end: number }[] = [];
|
|
737
757
|
const defects: Defect[] = [];
|
|
738
758
|
const overDepth: number[] = [];
|
|
759
|
+
let discardFrom: number | undefined;
|
|
739
760
|
let hardHashSeen = false;
|
|
740
761
|
let hardApostropheSeen = false;
|
|
741
762
|
// H2 — every open bracket, for balance. Each frame carries WHERE it opened so
|
|
@@ -752,6 +773,7 @@ function scanAscii(
|
|
|
752
773
|
prefixScanEnd: -1,
|
|
753
774
|
prefixSawNonSpace: false,
|
|
754
775
|
prefixValid: true,
|
|
776
|
+
prefixVerbChecked: false,
|
|
755
777
|
buffer: [],
|
|
756
778
|
container: false,
|
|
757
779
|
openIndex: -1,
|
|
@@ -777,6 +799,7 @@ function scanAscii(
|
|
|
777
799
|
f.prefixScanEnd = -1;
|
|
778
800
|
f.prefixSawNonSpace = false;
|
|
779
801
|
f.prefixValid = true;
|
|
802
|
+
f.prefixVerbChecked = false;
|
|
780
803
|
};
|
|
781
804
|
|
|
782
805
|
const makeSegment = (
|
|
@@ -814,7 +837,8 @@ function scanAscii(
|
|
|
814
837
|
};
|
|
815
838
|
|
|
816
839
|
// A container prefix is empty or a bare `/`-menu path with no `=[($"` — the
|
|
817
|
-
// same test the old `containerOpen` applied to the group's prefix
|
|
840
|
+
// same test the old `containerOpen` applied to the group's prefix — whose
|
|
841
|
+
// run names no command (#496).
|
|
818
842
|
const validPrefix = (f: Frame, start: number, end: number): boolean => {
|
|
819
843
|
if (f.prefixScanStart !== start || f.prefixScanEnd > end) {
|
|
820
844
|
resetPrefixScan(f);
|
|
@@ -841,6 +865,12 @@ function scanAscii(
|
|
|
841
865
|
}
|
|
842
866
|
}
|
|
843
867
|
f.prefixScanEnd = end;
|
|
868
|
+
// Checked once per statement: a later `{` sits behind this one, which
|
|
869
|
+
// already ended the run, so the answer cannot change.
|
|
870
|
+
if (f.prefixValid && !f.prefixVerbChecked) {
|
|
871
|
+
f.prefixVerbChecked = true;
|
|
872
|
+
if (runNamesCommand(ascii.slice(start, end))) f.prefixValid = false;
|
|
873
|
+
}
|
|
844
874
|
return f.prefixValid;
|
|
845
875
|
};
|
|
846
876
|
|
|
@@ -860,6 +890,7 @@ function scanAscii(
|
|
|
860
890
|
prefixScanEnd: -1,
|
|
861
891
|
prefixSawNonSpace: false,
|
|
862
892
|
prefixValid: true,
|
|
893
|
+
prefixVerbChecked: false,
|
|
863
894
|
buffer: [],
|
|
864
895
|
container: true,
|
|
865
896
|
openIndex: i,
|
|
@@ -920,7 +951,12 @@ function scanAscii(
|
|
|
920
951
|
// mid-continuation stays content.
|
|
921
952
|
const inCont = f.cont !== "none";
|
|
922
953
|
const contComment = c === "#" && inCont && f.contLineStart;
|
|
923
|
-
if (
|
|
954
|
+
if (
|
|
955
|
+
c === "#" &&
|
|
956
|
+
(f.atLead ||
|
|
957
|
+
contComment ||
|
|
958
|
+
startsArrayCommentLine(ascii, i, delimStack.at(-1)))
|
|
959
|
+
) {
|
|
924
960
|
const nl = ascii.indexOf("\n", i);
|
|
925
961
|
const end = nl === -1 ? ascii.length : nl;
|
|
926
962
|
comments.push({ start: i, end });
|
|
@@ -1091,6 +1127,7 @@ function scanAscii(
|
|
|
1091
1127
|
f.atLead = false;
|
|
1092
1128
|
}
|
|
1093
1129
|
} else {
|
|
1130
|
+
if (delimStack.length === 0 && c !== ")") discardFrom ??= i;
|
|
1094
1131
|
defects.push(defectAt("unbalanced-close", i, c));
|
|
1095
1132
|
ensureStmt(f, i);
|
|
1096
1133
|
f.atLead = false;
|
|
@@ -1148,6 +1185,7 @@ function scanAscii(
|
|
|
1148
1185
|
// event fail to de-duplicate across analyzers.
|
|
1149
1186
|
...overDepth.map((offset) => defectAt("over-depth", offset)),
|
|
1150
1187
|
],
|
|
1188
|
+
...(discardFrom === undefined ? {} : { discardFrom }),
|
|
1151
1189
|
};
|
|
1152
1190
|
}
|
|
1153
1191
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The space-for-`=` argument spelling (#350).
|
|
3
|
+
*
|
|
4
|
+
* RouterOS lets a space stand in for the `=` of an ordinary command argument:
|
|
5
|
+
* `/ip/firewall/layer7-protocol add comment "something" regexp 1.1` lowers to
|
|
6
|
+
* `(evl /ip/firewall/layer7-protocol/addcomment=something;regexp=1.1)`, byte for
|
|
7
|
+
* byte the IL of the `=` spelling, and the spellings mix freely. That is not a
|
|
8
|
+
* schema-free rule. The device pairs `comment` with `"something"` because it
|
|
9
|
+
* knows `/ip/firewall/layer7-protocol/add` has a `comment` argument:
|
|
10
|
+
* `/system/script add zzznotanarg beep` is `bad parameter zzznotanarg` on
|
|
11
|
+
* 7.24.5 and `expected end of command` on 7.22.1.
|
|
12
|
+
*
|
|
13
|
+
* ## What the device actually does (CHR 7.22.1 and 7.24.5, `:parse` + `highlight`)
|
|
14
|
+
*
|
|
15
|
+
* Identical on both builds:
|
|
16
|
+
*
|
|
17
|
+
* | input | IL | `highlight` on the gap |
|
|
18
|
+
* | ----- | -- | ---------------------- |
|
|
19
|
+
* | `/interface set ether1 comment "x"` | `setcomment=x;numbers=ether1` | `syntax-obsolete` |
|
|
20
|
+
* | `/interface set ether1 name x` | `setname=x;numbers=ether1` | `syntax-obsolete` |
|
|
21
|
+
* | `/interface set ether1 disabled yes` | `setdisabled=true;numbers=ether1` | `syntax-obsolete` |
|
|
22
|
+
* | `/user group set full policy "read,write"` | `setnumbers=full;policy=read,write` | `syntax-obsolete` |
|
|
23
|
+
* | `/interface print detail without-paging` | `printdetail=true;without-paging=true` | none |
|
|
24
|
+
*
|
|
25
|
+
* Two facts come out of that table, and they are why this module has two
|
|
26
|
+
* outputs rather than one.
|
|
27
|
+
*
|
|
28
|
+
* - **Two adjacent bare operands have no schema-free reading.** `name x` is an
|
|
29
|
+
* argument and its value; `detail without-paging` is two value-less flags;
|
|
30
|
+
* `ether1 name` is an unnamed `numbers` operand and then an argument name. The
|
|
31
|
+
* bytes are the same shape in all three. So the strict reading ABSTAINS on any
|
|
32
|
+
* such pair: it never publishes `args: {}` plus a positional list, which
|
|
33
|
+
* claims the list was read and holds no arguments.
|
|
34
|
+
* - **The obsolete marker follows the value, not the name.** The device marks
|
|
35
|
+
* the gap `syntax-obsolete` exactly when it paired the words, and a flag pair
|
|
36
|
+
* gets no marker. Offline can see one half of that: a second operand that is
|
|
37
|
+
* not itself name-shaped (`"x"`, `1.1`, `10s`, `$v`) cannot be a flag, so the
|
|
38
|
+
* pair is very probably the spaced spelling and gets a warning. A bare-word
|
|
39
|
+
* second operand (`name x`, `disabled yes`) is also obsolete on device, but
|
|
40
|
+
* offline cannot tell it from `detail without-paging`, so it abstains with no
|
|
41
|
+
* warning rather than flagging the most common `print` spelling there is. The
|
|
42
|
+
* warning says "possible"; a live probe (#491) is what makes it definite.
|
|
43
|
+
*
|
|
44
|
+
* ## What this deliberately does not decide
|
|
45
|
+
*
|
|
46
|
+
* Root statements are out of scope. A scripting directive's operands are
|
|
47
|
+
* positional by grammar, not spelling: `:pick abc 0 1` lowers to
|
|
48
|
+
* `pickbegin=0;counter=abc;end=1`, and `:local t 200ms` to `name=t;value=200ms`.
|
|
49
|
+
* Pairing those would fabricate argument names the device never uses. `/ping
|
|
50
|
+
* 1.1.1.1 count 1` is spaced (`count` is `syntax-obsolete`) and resolves to the
|
|
51
|
+
* root, so it stays read here; telling a root command's positional grammar from a
|
|
52
|
+
* spaced argument is per-command schema and belongs to the live probe.
|
|
53
|
+
*
|
|
54
|
+
* `where` ends the scan. What follows it is a query expression, and the word
|
|
55
|
+
* before it is never paired with it: `print count-only where …` lowers to
|
|
56
|
+
* `count-only=true;where=…`.
|
|
57
|
+
*
|
|
58
|
+
* Like the #311 separator rule, this reads only what the argument lexer
|
|
59
|
+
* already decided (`lexArgumentTokens`, the skip-tolerant reading), so a
|
|
60
|
+
* `[…]` substitution or a quoted run is one token by construction.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { ARGUMENT_NAME, type Argument } from "./args.ts";
|
|
64
|
+
|
|
65
|
+
/** Two adjacent operands that may be the spaced spelling of `name=value`. */
|
|
66
|
+
export interface SpacedPair {
|
|
67
|
+
/** First byte of the would-be name through the end of the would-be value. */
|
|
68
|
+
span: { start: number; end: number };
|
|
69
|
+
/** The would-be argument name, verbatim. */
|
|
70
|
+
name: string;
|
|
71
|
+
/** The would-be value, verbatim (quotes kept). */
|
|
72
|
+
value: string;
|
|
73
|
+
/**
|
|
74
|
+
* The second operand cannot be a flag, so the device very probably reads the
|
|
75
|
+
* pair as `name=value` and marks the gap `syntax-obsolete`.
|
|
76
|
+
*/
|
|
77
|
+
valueShaped: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An argument-name-shaped bare word. This is the lexer's own grammar, so a word
|
|
82
|
+
* the lexer would read as `name=` in the `=` spelling is a name here too.
|
|
83
|
+
*/
|
|
84
|
+
const NAME_SHAPED = ARGUMENT_NAME;
|
|
85
|
+
|
|
86
|
+
function isWhere(token: Argument): boolean {
|
|
87
|
+
return token.kind === "positional" && token.text.toLowerCase() === "where";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every adjacent `name operand` pair in one statement's argument tokens.
|
|
92
|
+
*
|
|
93
|
+
* Pairs overlap on purpose. `set ether1 comment "x"` yields `ether1 comment`
|
|
94
|
+
* and `comment "x"`: the first only abstains, the second also warns, and
|
|
95
|
+
* consuming the first would hide the second.
|
|
96
|
+
*
|
|
97
|
+
* Coordinate-space agnostic, like `findMissingSeparators`: spans come back in
|
|
98
|
+
* whatever space `tokens` were lexed in.
|
|
99
|
+
*/
|
|
100
|
+
export function findSpacedPairs(tokens: readonly Argument[]): SpacedPair[] {
|
|
101
|
+
const out: SpacedPair[] = [];
|
|
102
|
+
for (let i = 0; i + 1 < tokens.length; i++) {
|
|
103
|
+
const name = tokens[i] as Argument;
|
|
104
|
+
const value = tokens[i + 1] as Argument;
|
|
105
|
+
if (isWhere(name)) break;
|
|
106
|
+
if (name.kind !== "positional" || value.kind !== "positional") continue;
|
|
107
|
+
if (!NAME_SHAPED.test(name.text) || isWhere(value)) continue;
|
|
108
|
+
out.push({
|
|
109
|
+
span: { start: name.span.start, end: value.span.end },
|
|
110
|
+
name: name.text,
|
|
111
|
+
value: value.text,
|
|
112
|
+
valueShaped: !NAME_SHAPED.test(value.text),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
package/src/explain/symbols.ts
CHANGED
|
@@ -308,7 +308,11 @@
|
|
|
308
308
|
import { analyzeCoordinates } from "./coordinates.ts";
|
|
309
309
|
import { type Defect, defectAt } from "./defects.ts";
|
|
310
310
|
import { isMenuPath } from "./menus.ts";
|
|
311
|
-
import {
|
|
311
|
+
import {
|
|
312
|
+
braceStartsStatements,
|
|
313
|
+
hashStartsHardError,
|
|
314
|
+
startsArrayCommentLine,
|
|
315
|
+
} from "./scope-brace.ts";
|
|
312
316
|
import type { Continuation } from "./segment.ts";
|
|
313
317
|
|
|
314
318
|
/** The five variable classes the console assigns. */
|
|
@@ -904,6 +908,18 @@ export function resolveSymbols(original: string): SymbolAnalysis {
|
|
|
904
908
|
// nested string flipping the quote phase, so every later `#` line read as
|
|
905
909
|
// string content) is now fixed in the shared scanner too, via the same
|
|
906
910
|
// frame model (#199); the two must stay in step.
|
|
911
|
+
// #503 — a column-0 `#` inside an array literal is a comment line too, but
|
|
912
|
+
// it ends nothing: the array's statement carries on past it. A continued
|
|
913
|
+
// line's comment keeps the H5 branch below, as in `maskUncached`.
|
|
914
|
+
if (
|
|
915
|
+
c === "#" &&
|
|
916
|
+
!contComment &&
|
|
917
|
+
startsArrayCommentLine(text, i, frames.at(-1))
|
|
918
|
+
) {
|
|
919
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
920
|
+
i--; // let the loop advance onto the newline (or end)
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
907
923
|
if (c === "#" && (atLead || contComment)) {
|
|
908
924
|
while (i < text.length && text[i] !== "\n") i++;
|
|
909
925
|
if (contComment) {
|