@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.27
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 +163 -0
- package/GLOSSARY.txt +23 -1
- package/README.md +30 -18
- package/commands/api/AGENTS.md +21 -3
- package/commands/api/README.md +18 -7
- package/commands/api/examples.md +1 -1
- package/commands/btest/README.md +9 -0
- package/commands/btest/examples.md +7 -0
- package/commands/devices/README.md +72 -3
- package/commands/devices/examples.md +56 -1
- package/commands/execute/README.md +37 -6
- package/commands/execute/examples.md +24 -5
- package/commands/explain/README.md +675 -64
- package/commands/explain/examples.md +199 -42
- package/commands/retrieve/README.md +35 -3
- package/commands/retrieve/examples.md +114 -1
- package/commands/transfer/README.md +33 -4
- package/commands/transfer/examples.md +46 -0
- package/docs/CLI.md +30 -8
- package/docs/CONSTITUTION.md +14 -9
- package/docs/MATRIX.md +37 -22
- package/docs/errors/cdb/encrypt-failed.md +10 -0
- package/docs/errors/cdb/password-required.md +5 -4
- package/docs/errors/cdb/plaintext-backups.md +11 -0
- package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/input/invalid-routeros-path.md +25 -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 +9 -5
- package/src/api-fanout.ts +2 -0
- package/src/api.ts +46 -39
- package/src/btest.ts +7 -0
- package/src/cli/api.ts +18 -1
- package/src/cli/btest.ts +9 -0
- package/src/cli/devices.ts +76 -8
- package/src/cli/execute.ts +9 -0
- package/src/cli/explain.ts +206 -47
- package/src/cli/retrieve.ts +9 -0
- package/src/cli/terminal.ts +9 -0
- package/src/cli/transfer.ts +10 -1
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +28 -2
- 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/menu-path.ts +89 -0
- package/src/core/routeros-errors.ts +25 -0
- package/src/devices.ts +154 -9
- package/src/execute-cmr.ts +2 -8
- package/src/execute-fanout.ts +2 -0
- package/src/execute.ts +55 -20
- package/src/explain/args.ts +115 -10
- package/src/explain/blocks.ts +2 -2
- package/src/explain/catalog.ts +22 -12
- package/src/explain/group-faults.ts +162 -29
- package/src/explain/operators.ts +2 -1
- package/src/explain/pasted-prompt.ts +198 -0
- package/src/explain/pathresolve.ts +2 -2
- package/src/explain/scope-brace.ts +226 -11
- package/src/explain/segment.ts +138 -3
- package/src/explain/symbols.ts +148 -8
- package/src/explain/values.ts +55 -4
- package/src/explain/verbsplit.ts +2 -2
- package/src/explain/write.ts +2 -2
- 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 +397 -43
- package/src/index.ts +10 -0
- package/src/protocols/adapter.ts +90 -10
- package/src/protocols/btest-session.ts +10 -30
- package/src/protocols/ec-srp5.ts +19 -10
- package/src/protocols/mac-telnet-console.ts +2 -2
- package/src/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- package/src/resolver/cdb.ts +58 -2
- package/src/resolver/index.ts +1 -0
- package/src/resolver/quickchr-provider.ts +5 -0
- package/src/resolver/selection.ts +11 -0
- package/src/retrieve-fanout.ts +2 -0
- 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 +88 -27
- package/src/settings.ts +1 -0
- package/src/terminal.ts +7 -0
- package/src/transfer-fanout.ts +2 -0
- package/src/transfer.ts +117 -27
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { buildTip, type Tip } from "../core/envelope.ts";
|
|
2
|
+
import { type Segment, segmentStatements } from "./segment.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pasted console-prompt repair (#370).
|
|
6
|
+
*
|
|
7
|
+
* Recognizes two grounded prompt shapes at the start of a *statement* (not a
|
|
8
|
+
* raw line, so prompt-looking text inside strings and comments stays legal):
|
|
9
|
+
*
|
|
10
|
+
* - `[user@host] <menu>> <command>` — e.g. `[admin@MikroTik] > /ip/address print`
|
|
11
|
+
* at the root, or `[admin@MikroTik] /ip/address> add …` inside a menu, the
|
|
12
|
+
* shape the manual's Console page shows; a HotLock prompt doubles the `>`
|
|
13
|
+
* - `/menu/path> <command>` — e.g. `/interface/lte> monitor lte1`
|
|
14
|
+
*
|
|
15
|
+
* Segmentation already split the document on real statement boundaries with
|
|
16
|
+
* string/comment awareness, so testing each segment's start is a lexical
|
|
17
|
+
* position, not a line-start regex. A prompt-looking line inside a multiline
|
|
18
|
+
* string is interior to its segment and never at a segment start; comment
|
|
19
|
+
* lines never become segments at all.
|
|
20
|
+
*
|
|
21
|
+
* Severity / envelope behavior (chosen, documented here per #370 scope):
|
|
22
|
+
* the tip is ADVISORY only and never changes `data.verdict`. The bracket
|
|
23
|
+
* shape already fails via `invalid-at-sign` (the `@` is a hard fault); the
|
|
24
|
+
* menu shape stays `warn` (abstention — offline cannot prove the menu
|
|
25
|
+
* exists on any given device). Output banners (`Flags:`, `Columns:`) and
|
|
26
|
+
* bare `>` forms are deliberately NOT detected here; they are separately
|
|
27
|
+
* grounded cases, not automatic extensions.
|
|
28
|
+
*
|
|
29
|
+
* The stripped candidate is offered as repair TEXT only and is never adopted
|
|
30
|
+
* or executed: tips carry no execution semantics. For the menu shape the
|
|
31
|
+
* candidate is the literal text after `>` (it runs in that menu's context
|
|
32
|
+
* on the device, not from the root).
|
|
33
|
+
*
|
|
34
|
+
* The candidate is the prompt-stripped tail of the ORIGINAL input — separators,
|
|
35
|
+
* blocks and newlines preserved verbatim — bounded by the next pasted prompt
|
|
36
|
+
* or the end of input, so each prompt in a transcript gets its own repair.
|
|
37
|
+
* Reconstructing from segment texts would flatten `{ ... }` blocks and drop
|
|
38
|
+
* whatever follows them. The slice needs analyzed-byte offsets as JS indices,
|
|
39
|
+
* which hold only when the segmentation reports `ascii`; otherwise the
|
|
40
|
+
* per-segment candidate stands (narrow, never wrong).
|
|
41
|
+
*
|
|
42
|
+
* No two adjacent quantifiers in BRACKET_PROMPT_RE can match the same bytes
|
|
43
|
+
* (the user part stops at the first `@`; the menu's leading space lives inside
|
|
44
|
+
* its optional group), so a near-miss fails in linear time.
|
|
45
|
+
*/
|
|
46
|
+
const BRACKET_PROMPT_RE =
|
|
47
|
+
/^\[[^[\]\r\n@]*@[^[\]\r\n]*\](?:\s*(\/[A-Za-z0-9_\-/]*))?\s*>>?\s*([\s\S]*)$/;
|
|
48
|
+
const MENU_PROMPT_RE = /^(\/[A-Za-z0-9_\-/]+)\s*>>?\s*([\s\S]*)$/;
|
|
49
|
+
|
|
50
|
+
export interface PastedPromptHit {
|
|
51
|
+
shape: "bracket" | "menu";
|
|
52
|
+
menu?: string;
|
|
53
|
+
candidate: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface StrippedPrompt {
|
|
57
|
+
shape: "bracket" | "menu";
|
|
58
|
+
menu?: string;
|
|
59
|
+
/** Length of the prompt prefix within the segment text. */
|
|
60
|
+
promptEnd: number;
|
|
61
|
+
candidate: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True when re-segmenting `text` reports an unbalanced delimiter. */
|
|
65
|
+
function hasUnbalancedDelimiters(text: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
return segmentStatements(text).defects.some(
|
|
68
|
+
(d) => d.code === "unbalanced-close" || d.code === "unclosed",
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function detectPastedPrompts(input: string): PastedPromptHit[] {
|
|
76
|
+
const hits: PastedPromptHit[] = [];
|
|
77
|
+
let segments: Segment[];
|
|
78
|
+
let ascii: boolean;
|
|
79
|
+
try {
|
|
80
|
+
const result = segmentStatements(input);
|
|
81
|
+
segments = result.segments;
|
|
82
|
+
ascii = result.ascii;
|
|
83
|
+
} catch {
|
|
84
|
+
return hits;
|
|
85
|
+
}
|
|
86
|
+
// Per-segment prompt match. A bare prompt with no command ("prompt-empty")
|
|
87
|
+
// earns no hit but still bounds the previous hit's region.
|
|
88
|
+
const stripped: (StrippedPrompt | "prompt-empty" | null)[] = segments.map(
|
|
89
|
+
(segment) => {
|
|
90
|
+
const text = segment.text;
|
|
91
|
+
if (text.length === 0) return null;
|
|
92
|
+
const bracket = BRACKET_PROMPT_RE.exec(text);
|
|
93
|
+
if (bracket !== null) {
|
|
94
|
+
const menu = bracket[1];
|
|
95
|
+
const group = bracket[2] ?? "";
|
|
96
|
+
return group.trim().length > 0
|
|
97
|
+
? {
|
|
98
|
+
shape: "bracket",
|
|
99
|
+
...(menu && menu !== "/" ? { menu } : {}),
|
|
100
|
+
promptEnd: text.length - group.length,
|
|
101
|
+
candidate: group.trim(),
|
|
102
|
+
}
|
|
103
|
+
: "prompt-empty";
|
|
104
|
+
}
|
|
105
|
+
const menu = MENU_PROMPT_RE.exec(text);
|
|
106
|
+
if (menu !== null) {
|
|
107
|
+
const group = menu[2] ?? "";
|
|
108
|
+
return group.trim().length > 0
|
|
109
|
+
? {
|
|
110
|
+
shape: "menu",
|
|
111
|
+
menu: menu[1],
|
|
112
|
+
promptEnd: text.length - group.length,
|
|
113
|
+
candidate: group.trim(),
|
|
114
|
+
}
|
|
115
|
+
: "prompt-empty";
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
for (let i = 0; i < segments.length; i++) {
|
|
121
|
+
const hit = stripped[i];
|
|
122
|
+
if (hit === null || hit === undefined || hit === "prompt-empty") continue;
|
|
123
|
+
let candidate = hit.candidate;
|
|
124
|
+
if (ascii) {
|
|
125
|
+
const segment = segments[i];
|
|
126
|
+
let end = input.length;
|
|
127
|
+
for (let j = i + 1; j < segments.length; j++) {
|
|
128
|
+
if (stripped[j] !== null && stripped[j] !== undefined) {
|
|
129
|
+
end = segments[j]?.start ?? end;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const sliced = input
|
|
134
|
+
.slice((segment?.start ?? 0) + hit.promptEnd, end)
|
|
135
|
+
.trim();
|
|
136
|
+
// The slice must not cross the prompt's enclosing container: it is
|
|
137
|
+
// kept only when it re-segments balanced, else the narrow
|
|
138
|
+
// per-segment candidate stands (`{ /menu> cmd }` would otherwise
|
|
139
|
+
// offer `cmd }`).
|
|
140
|
+
if (sliced.length > 0 && !hasUnbalancedDelimiters(sliced))
|
|
141
|
+
candidate = sliced;
|
|
142
|
+
}
|
|
143
|
+
hits.push({
|
|
144
|
+
shape: hit.shape,
|
|
145
|
+
...(hit.menu ? { menu: hit.menu } : {}),
|
|
146
|
+
candidate,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return hits;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A markdown code span that survives backticks in `text`: the fence is one
|
|
154
|
+
* longer than the longest backtick run inside, padded when `text` starts or
|
|
155
|
+
* ends with one (CommonMark code spans).
|
|
156
|
+
*/
|
|
157
|
+
function codeSpan(text: string): string {
|
|
158
|
+
const fence = "`".repeat(longestBacktickRun(text) + 1);
|
|
159
|
+
const pad = text.startsWith("`") || text.endsWith("`") ? " " : "";
|
|
160
|
+
return `${fence}${pad}${text}${pad}${fence}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A fenced code block for multi-line text: a code span would render its
|
|
165
|
+
* newlines as spaces, and `:put 1` NEWLINE `:put 2` is not `:put 1 :put 2`.
|
|
166
|
+
*/
|
|
167
|
+
function codeBlock(text: string): string {
|
|
168
|
+
const fence = "`".repeat(Math.max(3, longestBacktickRun(text) + 1));
|
|
169
|
+
return `${fence}\n${text}\n${fence}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function longestBacktickRun(text: string): number {
|
|
173
|
+
let longest = 0;
|
|
174
|
+
for (const run of text.match(/`+/g) ?? [])
|
|
175
|
+
longest = Math.max(longest, run.length);
|
|
176
|
+
return longest;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function pastedPromptTips(input: string): Tip[] {
|
|
180
|
+
return detectPastedPrompts(input).map((hit) => {
|
|
181
|
+
const where = hit.menu ? ` (typed at ${codeSpan(hit.menu)})` : "";
|
|
182
|
+
const fix = /[\r\n]/.test(hit.candidate)
|
|
183
|
+
? `Drop the pasted prompt and try this${where} instead — this input was not executed:\n\n${codeBlock(hit.candidate)}`
|
|
184
|
+
: `Drop the pasted prompt and try ${codeSpan(hit.candidate)}${where} instead — this input was not executed.`;
|
|
185
|
+
if (hit.shape === "menu")
|
|
186
|
+
return buildTip(
|
|
187
|
+
"tip/explain-pasted-prompt",
|
|
188
|
+
`This looks like a pasted ${codeSpan(hit.menu ?? "")} menu prompt, not a command to run from the root.`,
|
|
189
|
+
fix,
|
|
190
|
+
);
|
|
191
|
+
const prompt = hit.menu ? `[user@host] ${hit.menu}>` : "[user@host] >";
|
|
192
|
+
return buildTip(
|
|
193
|
+
"tip/explain-pasted-prompt",
|
|
194
|
+
`This looks like a pasted console transcript (${codeSpan(prompt)}), not a command to run.`,
|
|
195
|
+
fix,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Offline path resolution for `explain` (centrs canonicalizer).
|
|
3
3
|
*
|
|
4
|
-
* Ratified by the phase-0 lab, questions Q3 and Q4 (#185)
|
|
5
|
-
*
|
|
4
|
+
* Ratified by the phase-0 lab, questions Q3 and Q4 (#185); the lab probe
|
|
5
|
+
* it was promoted from is history (see #185), not a runnable method. Two surfaces:
|
|
6
6
|
*
|
|
7
7
|
* resolveStatements (Q4) — the fully-qualified canonical path of every
|
|
8
8
|
* statement in source order, tracking the persistent menu
|
|
@@ -7,8 +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";
|
|
11
|
-
import { runNamesCommand } from "./run-tokens.ts";
|
|
13
|
+
import { runNamesCommand, runTokens } from "./run-tokens.ts";
|
|
14
|
+
import { VERBS } from "./verbs.ts";
|
|
12
15
|
|
|
13
16
|
/** Error-variable shape for `:onerror V { … }` — a bare/`$`-prefixed name. */
|
|
14
17
|
const ERROR_VAR = /^\$?[A-Za-z][A-Za-z0-9._-]*$/;
|
|
@@ -96,17 +99,82 @@ const SCRIPT_BODY_ARG = /^(?:source|script|.+-script|on-.+)$/;
|
|
|
96
99
|
* offline misses it.
|
|
97
100
|
*
|
|
98
101
|
* Abbreviating the FIRST segment (`down={ :beep }` for `down-script`, also
|
|
99
|
-
* accepted on device) stays outside. Every word is a prefix
|
|
100
|
-
* so admitting it would admit `comment={ :beep }
|
|
101
|
-
* command's argument list,
|
|
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`).
|
|
102
109
|
*/
|
|
103
110
|
const SCRIPT_BODY_ABBREVIATION =
|
|
104
111
|
/^(?:s(?:o(?:u(?:r(?:c)?)?)?)?|sc(?:r(?:i(?:p)?)?)?|on?|.+-(?:s(?:c(?:r(?:i(?:p)?)?)?)?)?)$/;
|
|
105
112
|
|
|
106
|
-
|
|
113
|
+
/** Whether `name` is a proven script-body slot (full or pinned abbreviation). */
|
|
114
|
+
export function isScriptBodyName(name: string): boolean {
|
|
107
115
|
return SCRIPT_BODY_ARG.test(name) || SCRIPT_BODY_ABBREVIATION.test(name);
|
|
108
116
|
}
|
|
109
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
|
+
}
|
|
177
|
+
|
|
110
178
|
/** Grounded scripting directives whose positional arity makes a later `#` fatal. */
|
|
111
179
|
const DIRECTIVE_POSITIONAL_ARITY: Readonly<Record<string, number>> = {
|
|
112
180
|
local: 2,
|
|
@@ -121,6 +189,12 @@ interface StatementIndex {
|
|
|
121
189
|
boundaries: Int32Array;
|
|
122
190
|
firstContent: Int32Array;
|
|
123
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>;
|
|
124
198
|
matchingBrace: Map<number, number>;
|
|
125
199
|
}
|
|
126
200
|
|
|
@@ -162,6 +236,7 @@ function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
|
|
|
162
236
|
index.boundaries.byteLength +
|
|
163
237
|
index.firstContent.byteLength +
|
|
164
238
|
index.lastForbidden.byteLength +
|
|
239
|
+
index.nestedSeparators.size * 32 +
|
|
165
240
|
index.matchingBrace.size * 32
|
|
166
241
|
);
|
|
167
242
|
}
|
|
@@ -223,17 +298,34 @@ function statementIndex(text: string): StatementIndex {
|
|
|
223
298
|
firstContent.fill(-1);
|
|
224
299
|
const lastForbidden = new Int32Array(text.length + 1);
|
|
225
300
|
lastForbidden.fill(-1);
|
|
301
|
+
const nestedSeparators = new Map<number, number>();
|
|
226
302
|
const matchingBrace = new Map<number, number>();
|
|
227
303
|
const delimiters: { char: string; at: number }[] = [];
|
|
228
304
|
let boundary = -1;
|
|
229
305
|
let first = -1;
|
|
230
306
|
let forbidden = -1;
|
|
307
|
+
let comment = false;
|
|
231
308
|
let i = 0;
|
|
232
309
|
while (i < text.length) {
|
|
233
310
|
boundaries[i] = boundary;
|
|
234
311
|
firstContent[i] = first;
|
|
235
312
|
lastForbidden[i] = forbidden;
|
|
236
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
|
+
}
|
|
237
329
|
if (c === '"') {
|
|
238
330
|
const end = Math.min(scanQuotedString(text, i).end, text.length);
|
|
239
331
|
boundaries.fill(boundary, i + 1, end + 1);
|
|
@@ -255,6 +347,9 @@ function statementIndex(text: string): StatementIndex {
|
|
|
255
347
|
if (first === -1 && !isAsciiWhitespace(c)) first = i;
|
|
256
348
|
if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
|
|
257
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);
|
|
258
353
|
boundary = i;
|
|
259
354
|
first = -1;
|
|
260
355
|
}
|
|
@@ -267,6 +362,7 @@ function statementIndex(text: string): StatementIndex {
|
|
|
267
362
|
boundaries,
|
|
268
363
|
firstContent,
|
|
269
364
|
lastForbidden,
|
|
365
|
+
nestedSeparators,
|
|
270
366
|
matchingBrace,
|
|
271
367
|
};
|
|
272
368
|
if (cacheStatementIndex(text, index)) adoptBoundaryText(text, index);
|
|
@@ -462,18 +558,41 @@ export function scopeNameFromMasked(
|
|
|
462
558
|
* Whether this brace begins a context in which statement-leading comments exist.
|
|
463
559
|
*
|
|
464
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"`.
|
|
465
568
|
*/
|
|
466
569
|
export function braceStartsStatements(
|
|
467
570
|
text: string,
|
|
468
571
|
open: number,
|
|
469
572
|
floor = 0,
|
|
573
|
+
unprovenName: "body" | "value" = "body",
|
|
470
574
|
): boolean {
|
|
471
575
|
if (scopeNameFromMasked(text, open, floor) !== null) return true;
|
|
472
576
|
const index = statementIndex(text);
|
|
473
577
|
const boundary = index.boundaries[open] ?? -1;
|
|
474
578
|
const start = Math.max(boundary + 1, floor);
|
|
475
579
|
const named = trailingName(text, start, open);
|
|
476
|
-
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
|
+
}
|
|
477
596
|
// `source { :beep }` — the same body with the `=` left out (#350). Every
|
|
478
597
|
// grounded spelling lowers to the `source=` IL on CHR 7.22.1 and 7.24.5:
|
|
479
598
|
// `/system/script add name=beep source { :beep }`, `/system/scheduler add
|
|
@@ -493,12 +612,108 @@ export function braceStartsStatements(
|
|
|
493
612
|
// but a run that already names a command makes the brace its operand (#496).
|
|
494
613
|
// `start` follows the last `{`, so each brace reads its own run: linear.
|
|
495
614
|
const first = index.firstContent[open] ?? -1;
|
|
496
|
-
return
|
|
497
|
-
|
|
498
|
-
(
|
|
615
|
+
if (first < start) return true;
|
|
616
|
+
if (text[first] === "/")
|
|
617
|
+
return (
|
|
499
618
|
(index.lastForbidden[open] ?? -1) < start &&
|
|
500
|
-
!runNamesCommand(text.slice(first, open))
|
|
501
|
-
|
|
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);
|
|
502
717
|
}
|
|
503
718
|
|
|
504
719
|
/**
|