@tikoci/centrs 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/GLOSSARY.txt +213 -0
- package/LICENSE +21 -0
- package/README.md +36 -8
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +19 -18
- package/commands/api/examples.md +15 -0
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +41 -29
- package/commands/execute/examples.md +41 -3
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +66 -15
- package/docs/MATRIX.md +38 -1
- package/docs/errors/README.md +3 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +45 -10
- package/src/api-fanout.ts +29 -14
- package/src/api.ts +97 -54
- package/src/cli/api.ts +25 -6
- package/src/cli/btest.ts +18 -2
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +40 -0
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +29 -14
- package/src/execute.ts +114 -63
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +206 -4
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +26 -12
- package/src/retrieve.ts +81 -18
- package/src/transfer-fanout.ts +42 -25
- package/src/transfer.ts +87 -44
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared RouterOS double-quoted string boundary scanner.
|
|
3
|
+
*
|
|
4
|
+
* Kept below both `segment.ts` and `scope-brace.ts` so statement-boundary
|
|
5
|
+
* lookback can ignore separators inside strings without creating an import
|
|
6
|
+
* cycle or growing a second, drifting quote scanner (#199/#246 review).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type Defect, defectAt } from "./defects.ts";
|
|
10
|
+
|
|
11
|
+
/** Analyzer resource bound, not a RouterOS grammar limit. */
|
|
12
|
+
const MAX_STRING_FRAME_DEPTH = 256;
|
|
13
|
+
|
|
14
|
+
/** Where a double-quoted string ends, and whether it was closed at all. */
|
|
15
|
+
export interface QuotedStringScan {
|
|
16
|
+
/** index just past the closing `"`, or `text.length` when unterminated. */
|
|
17
|
+
end: number;
|
|
18
|
+
closed: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Valid single-char escapes after `\` inside a RouterOS string.
|
|
23
|
+
*
|
|
24
|
+
* This is the DEVICE's set, not the manual's table. The manual's
|
|
25
|
+
* "Constant Escape Sequences" list is a lower bound: it omits `\?`, which
|
|
26
|
+
* RouterOS classes `escaped` and evaluates to `?`. Swept byte by byte on CHR
|
|
27
|
+
* 7.23.3 — `:put "\<c>"` for every 0x20–0x7E plus TAB/LF/CRLF, scored on both
|
|
28
|
+
* the `/console/inspect request=highlight` class and the runtime result — and
|
|
29
|
+
* the accepted set is exactly these plus whitespace continuations plus
|
|
30
|
+
* two-digit uppercase hex (#252). Re-derive with
|
|
31
|
+
* `bun run explain:probe:escapes` (#186).
|
|
32
|
+
*/
|
|
33
|
+
const VALID_SINGLE = new Set([
|
|
34
|
+
'"',
|
|
35
|
+
"\\",
|
|
36
|
+
"n",
|
|
37
|
+
"r",
|
|
38
|
+
"t",
|
|
39
|
+
"$",
|
|
40
|
+
"_",
|
|
41
|
+
"a",
|
|
42
|
+
"b",
|
|
43
|
+
"f",
|
|
44
|
+
"v",
|
|
45
|
+
"?",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
function isHexUpper(c: string): boolean {
|
|
49
|
+
return (c >= "0" && c <= "9") || (c >= "A" && c <= "F");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `\` before whitespace is a line continuation INSIDE a string too, not only in
|
|
54
|
+
* code: the device swallows the pair and emits nothing (`:put "a\ b"` -> `ab`,
|
|
55
|
+
* and the same for TAB/CR/LF/CRLF). The manual states it under "Line joining" —
|
|
56
|
+
* "A backslash does not continue a token except for string". Every multi-line
|
|
57
|
+
* `source=`/`on-event=` script in the wild relies on it.
|
|
58
|
+
*/
|
|
59
|
+
function isContinuationWhitespace(c: string): boolean {
|
|
60
|
+
return c === " " || c === "\t" || c === "\r" || c === "\n";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Is the escape at `at` (where text[at] === `\`) inside a string valid?
|
|
65
|
+
* Returns true for valid, false for invalid (unknown, lowercase hex, truncated).
|
|
66
|
+
*/
|
|
67
|
+
function isValidStringEscape(text: string, at: number): boolean {
|
|
68
|
+
const next1 = text[at + 1];
|
|
69
|
+
if (next1 === undefined) return false;
|
|
70
|
+
if (VALID_SINGLE.has(next1)) return true;
|
|
71
|
+
if (isContinuationWhitespace(next1)) return true;
|
|
72
|
+
if (isHexUpper(next1)) {
|
|
73
|
+
const next2 = text[at + 2];
|
|
74
|
+
if (next2 !== undefined && isHexUpper(next2)) return true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Bytes to skip past the escape at `at`. Always 2 or 3 so recovery advances
|
|
82
|
+
* even when the escape is malformed (#199). CRLF is ONE continuation, so it is
|
|
83
|
+
* the only non-hex three-byte escape.
|
|
84
|
+
*/
|
|
85
|
+
function escapeLength(text: string, at: number): 2 | 3 {
|
|
86
|
+
const next1 = text[at + 1];
|
|
87
|
+
if (next1 === "\r" && text[at + 2] === "\n") return 3;
|
|
88
|
+
if (
|
|
89
|
+
next1 !== undefined &&
|
|
90
|
+
isHexUpper(next1) &&
|
|
91
|
+
isHexUpper(text[at + 2] ?? "")
|
|
92
|
+
)
|
|
93
|
+
return 3;
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Single source of truth for the frame grammar (#253).
|
|
99
|
+
//
|
|
100
|
+
// Both `scanQuotedString` and `collectStringEscapeDefects` walk the same
|
|
101
|
+
// nesting: a `"` frame is string phase, anything else is code phase where
|
|
102
|
+
// `$[`/`$(` inside a string push a bracket frame and brackets nest until
|
|
103
|
+
// matched. The helpers below own the transitions, the depth guard, and the
|
|
104
|
+
// phase dispatch once — callers differ only in what they do with an escape
|
|
105
|
+
// (skip vs validate) and whether they skip comment spans.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One step of the shared frame machine. Owns the depth guard, the `top` read,
|
|
110
|
+
* and the phase dispatch (`"` vs code) so callers cannot drift on them.
|
|
111
|
+
* When inside a string at `\`, delegates to `onStringEscape` so callers can
|
|
112
|
+
* validate or just skip. Returns bytes to advance, or a Defect, or 0 when the
|
|
113
|
+
* shared depth cap is hit (caller must break).
|
|
114
|
+
*/
|
|
115
|
+
function stepFrame(
|
|
116
|
+
text: string,
|
|
117
|
+
i: number,
|
|
118
|
+
frames: string[],
|
|
119
|
+
onStringEscape: (at: number) => number | Defect,
|
|
120
|
+
): number | Defect {
|
|
121
|
+
if (frames.length > MAX_STRING_FRAME_DEPTH) return 0;
|
|
122
|
+
const top = frames[frames.length - 1] as string | undefined;
|
|
123
|
+
const c = text[i] as string;
|
|
124
|
+
if (top === '"') {
|
|
125
|
+
if (c === "\\") return onStringEscape(i);
|
|
126
|
+
if (c === '"') {
|
|
127
|
+
frames.pop();
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
if (c === "$" && (text[i + 1] === "[" || text[i + 1] === "(")) {
|
|
131
|
+
frames.push(text[i + 1] as string);
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
if (c === '"') {
|
|
137
|
+
frames.push('"');
|
|
138
|
+
return 1;
|
|
139
|
+
}
|
|
140
|
+
if (c === "[" || c === "(" || c === "{") {
|
|
141
|
+
frames.push(c);
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
if (c === "]" || c === ")" || c === "}") {
|
|
145
|
+
const want = c === "]" ? "[" : c === ")" ? "(" : "{";
|
|
146
|
+
if (top === want) frames.pop();
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Boundary recovery must skip the escaped byte even when the escape itself
|
|
154
|
+
* is malformed, otherwise the closing quote is lost and every later comment
|
|
155
|
+
* is swallowed (#199). Validation is separate; here we just advance.
|
|
156
|
+
*/
|
|
157
|
+
function stringEscapeSkip(text: string, at: number): number {
|
|
158
|
+
return escapeLength(text, at);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Locate defect at the escaped byte the device marks `error`.
|
|
163
|
+
* For `\q` it's `q`; for `\0a` it's `a`.
|
|
164
|
+
* For truncated `\0` before `"` the device marks the closing quote;
|
|
165
|
+
* centrs reports at the backslash instead, because that byte is stable
|
|
166
|
+
* when the string is unterminated (#252).
|
|
167
|
+
*/
|
|
168
|
+
function stringEscapeValidated(text: string, at: number): number | Defect {
|
|
169
|
+
if (!isValidStringEscape(text, at)) {
|
|
170
|
+
const next1 = text[at + 1];
|
|
171
|
+
let defectAtOffset: number;
|
|
172
|
+
if (next1 === undefined) defectAtOffset = at;
|
|
173
|
+
else if (isHexUpper(next1)) {
|
|
174
|
+
const next2 = text[at + 2];
|
|
175
|
+
if (next2 === undefined) defectAtOffset = at;
|
|
176
|
+
else if (!isHexUpper(next2)) defectAtOffset = at + 2;
|
|
177
|
+
else defectAtOffset = at;
|
|
178
|
+
} else defectAtOffset = at + 1;
|
|
179
|
+
return defectAt("bad-string-escape", defectAtOffset);
|
|
180
|
+
}
|
|
181
|
+
return escapeLength(text, at);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Find the end of the double-quoted string that opens at `open`.
|
|
186
|
+
*
|
|
187
|
+
* A `$[…]` or `$(…)` substitution inside a string is code and may contain
|
|
188
|
+
* nested strings, so the next quote is not necessarily the outer close.
|
|
189
|
+
*/
|
|
190
|
+
export function scanQuotedString(text: string, open: number): QuotedStringScan {
|
|
191
|
+
const frames: string[] = ['"'];
|
|
192
|
+
let i = open + 1;
|
|
193
|
+
while (i < text.length) {
|
|
194
|
+
const res = stepFrame(text, i, frames, (at) => stringEscapeSkip(text, at));
|
|
195
|
+
if (typeof res !== "number") break;
|
|
196
|
+
if (res === 0) break;
|
|
197
|
+
i += res;
|
|
198
|
+
if (frames.length === 0) return { end: i, closed: true };
|
|
199
|
+
}
|
|
200
|
+
return { end: text.length, closed: false };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Collect the first invalid string-internal escape in `text`, if any.
|
|
205
|
+
*
|
|
206
|
+
* Walks the entire document with the same frame model as `scanQuotedString`,
|
|
207
|
+
* but validates each `\` inside a string frame against the documented escape
|
|
208
|
+
* table (capital hex, truncated, unknown). Returns an array with at most one
|
|
209
|
+
* defect at the first invalid escape. Comment spans are skipped: a `"` inside
|
|
210
|
+
* a comment is not a string.
|
|
211
|
+
*/
|
|
212
|
+
export function collectStringEscapeDefects(
|
|
213
|
+
text: string,
|
|
214
|
+
comments: readonly { start: number; end: number }[] = [],
|
|
215
|
+
): Defect[] {
|
|
216
|
+
const frames: string[] = [];
|
|
217
|
+
let i = 0;
|
|
218
|
+
let ci = 0;
|
|
219
|
+
while (i < text.length) {
|
|
220
|
+
// Skip comment spans (analyzed-byte offsets, same space as `text`).
|
|
221
|
+
while (
|
|
222
|
+
ci < comments.length &&
|
|
223
|
+
i >= (comments[ci] as { start: number; end: number }).end
|
|
224
|
+
)
|
|
225
|
+
ci++;
|
|
226
|
+
if (ci < comments.length) {
|
|
227
|
+
const c = comments[ci] as { start: number; end: number };
|
|
228
|
+
if (i >= c.start && i < c.end) {
|
|
229
|
+
i = c.end;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const res = stepFrame(text, i, frames, (at) =>
|
|
234
|
+
stringEscapeValidated(text, at),
|
|
235
|
+
);
|
|
236
|
+
if (typeof res !== "number") return [res];
|
|
237
|
+
if (res === 0) break;
|
|
238
|
+
i += res;
|
|
239
|
+
}
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-visible brace roles shared by the explain walkers.
|
|
3
|
+
*
|
|
4
|
+
* RouterOS does not make every `{` a statement body. `do={...}` and stored
|
|
5
|
+
* script attributes do, while `name={...}` is ordinarily an array literal.
|
|
6
|
+
* Keeping this decision below `segment.ts` avoids a cycle with `blocks.ts` and
|
|
7
|
+
* gives comment masking, segmentation, and symbol resolution one rule.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { scanQuotedString } from "./quoted-string.ts";
|
|
11
|
+
|
|
12
|
+
/** Error-variable shape for `:onerror V { … }` — a bare/`$`-prefixed name. */
|
|
13
|
+
const ERROR_VAR = /^\$?[A-Za-z][A-Za-z0-9._-]*$/;
|
|
14
|
+
|
|
15
|
+
function isAsciiWhitespace(char: string | undefined): boolean {
|
|
16
|
+
return char === " " || char === "\t" || char === "\r" || char === "\n";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Source-visible argument names whose `{…}` is a scope. Closed set, harvested
|
|
21
|
+
* from every projected corpus capture (`do` 5623, `else` 720, `command` 177,
|
|
22
|
+
* `on-error` 132).
|
|
23
|
+
*
|
|
24
|
+
* `command` is here because users DO write it explicitly:
|
|
25
|
+
* `:retry delay=1s max=3 on-error={…} command={…}`
|
|
26
|
+
* (topic-169237/post-0021-snippet-01 @ 7.22.1).
|
|
27
|
+
*
|
|
28
|
+
* `in` is deliberately NOT here even though IL lowers `:onerror V { … }` under
|
|
29
|
+
* that name — in SOURCE, `in={…}` is always an array literal
|
|
30
|
+
* (`:foreach Type in={ "p12"; "pem" }` → `in=p12;pem`,
|
|
31
|
+
* eworm/check-certificates.rsc @ 7.22.1). The IL name and the source name
|
|
32
|
+
* collide; only the source spelling governs offline.
|
|
33
|
+
*/
|
|
34
|
+
export const SCOPE_ARG_NAMES: ReadonlySet<string> = new Set([
|
|
35
|
+
"do",
|
|
36
|
+
"else",
|
|
37
|
+
"on-error",
|
|
38
|
+
"command",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Argument names that are scopes only under a specific head directive. */
|
|
42
|
+
export const HEAD_SCOPED_ARG_NAMES: Record<string, ReadonlySet<string>> = {
|
|
43
|
+
in: new Set([":onerror", "onerror"]),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Directives whose brace body attaches to the directive itself, with the IL
|
|
48
|
+
* name the body is lowered under. The colon is optional in practice: `do {`
|
|
49
|
+
* opens a body the same way `:do {` does (topic-142687/post-0010-snippet-01 @
|
|
50
|
+
* 7.22.1).
|
|
51
|
+
*/
|
|
52
|
+
export const DIRECTIVE_BODY: Record<string, string> = {
|
|
53
|
+
":do": "command",
|
|
54
|
+
":retry": "command",
|
|
55
|
+
":onerror": "in",
|
|
56
|
+
do: "command",
|
|
57
|
+
retry: "command",
|
|
58
|
+
onerror: "in",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Script-valued attribute naming convention already used by the corpus census. */
|
|
62
|
+
const SCRIPT_BODY_ARG = /^(?:source|script|on-.*)$/;
|
|
63
|
+
|
|
64
|
+
/** Grounded scripting directives whose positional arity makes a later `#` fatal. */
|
|
65
|
+
const DIRECTIVE_POSITIONAL_ARITY: Readonly<Record<string, number>> = {
|
|
66
|
+
local: 2,
|
|
67
|
+
global: 2,
|
|
68
|
+
set: 2,
|
|
69
|
+
put: 1,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const BOUNDARY_CACHE_LIMIT = 4;
|
|
73
|
+
const BOUNDARY_CACHE_BYTE_LIMIT = 8 * 1024 * 1024;
|
|
74
|
+
interface StatementIndex {
|
|
75
|
+
boundaries: Int32Array;
|
|
76
|
+
firstContent: Int32Array;
|
|
77
|
+
lastForbidden: Int32Array;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const boundaryCache = new Map<string, StatementIndex>();
|
|
81
|
+
let boundaryCacheBytes = 0;
|
|
82
|
+
// Most probes in one walk ask about the same document. Bypass Map's string-key
|
|
83
|
+
// lookup for that hot path without retaining anything outside the bounded cache.
|
|
84
|
+
let recentBoundaryText: string | undefined;
|
|
85
|
+
let recentBoundaryIndex: StatementIndex | undefined;
|
|
86
|
+
|
|
87
|
+
function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
|
|
88
|
+
// JavaScript strings use at most two bytes per UTF-16 code unit. Include the
|
|
89
|
+
// key as well as the typed array so unusually large sources cannot be retained.
|
|
90
|
+
return (
|
|
91
|
+
text.length * 2 +
|
|
92
|
+
index.boundaries.byteLength +
|
|
93
|
+
index.firstContent.byteLength +
|
|
94
|
+
index.lastForbidden.byteLength
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cacheStatementIndex(text: string, index: StatementIndex): boolean {
|
|
99
|
+
const entryBytes = boundaryCacheEntryBytes(text, index);
|
|
100
|
+
if (entryBytes > BOUNDARY_CACHE_BYTE_LIMIT) return false;
|
|
101
|
+
|
|
102
|
+
while (
|
|
103
|
+
boundaryCache.size >= BOUNDARY_CACHE_LIMIT ||
|
|
104
|
+
boundaryCacheBytes + entryBytes > BOUNDARY_CACHE_BYTE_LIMIT
|
|
105
|
+
) {
|
|
106
|
+
const oldest = boundaryCache.entries().next().value;
|
|
107
|
+
if (oldest === undefined) break;
|
|
108
|
+
boundaryCache.delete(oldest[0]);
|
|
109
|
+
boundaryCacheBytes -= boundaryCacheEntryBytes(oldest[0], oldest[1]);
|
|
110
|
+
if (recentBoundaryText === oldest[0]) {
|
|
111
|
+
recentBoundaryText = undefined;
|
|
112
|
+
recentBoundaryIndex = undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
boundaryCache.set(text, index);
|
|
116
|
+
boundaryCacheBytes += entryBytes;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Statement-boundary and prefix facts for every source offset, in one pass. */
|
|
121
|
+
function statementIndex(text: string): StatementIndex {
|
|
122
|
+
if (recentBoundaryText === text && recentBoundaryIndex !== undefined)
|
|
123
|
+
return recentBoundaryIndex;
|
|
124
|
+
const cached = boundaryCache.get(text);
|
|
125
|
+
if (cached !== undefined) {
|
|
126
|
+
recentBoundaryText = text;
|
|
127
|
+
recentBoundaryIndex = cached;
|
|
128
|
+
return cached;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const boundaries = new Int32Array(text.length + 1);
|
|
132
|
+
boundaries.fill(-1);
|
|
133
|
+
const firstContent = new Int32Array(text.length + 1);
|
|
134
|
+
firstContent.fill(-1);
|
|
135
|
+
const lastForbidden = new Int32Array(text.length + 1);
|
|
136
|
+
lastForbidden.fill(-1);
|
|
137
|
+
let boundary = -1;
|
|
138
|
+
let first = -1;
|
|
139
|
+
let forbidden = -1;
|
|
140
|
+
let i = 0;
|
|
141
|
+
while (i < text.length) {
|
|
142
|
+
boundaries[i] = boundary;
|
|
143
|
+
firstContent[i] = first;
|
|
144
|
+
lastForbidden[i] = forbidden;
|
|
145
|
+
const c = text[i] as string;
|
|
146
|
+
if (c === '"') {
|
|
147
|
+
const end = Math.min(scanQuotedString(text, i).end, text.length);
|
|
148
|
+
boundaries.fill(boundary, i + 1, end + 1);
|
|
149
|
+
firstContent.fill(first === -1 ? i : first, i + 1, end + 1);
|
|
150
|
+
lastForbidden.fill(i, i + 1, end + 1);
|
|
151
|
+
if (first === -1) first = i;
|
|
152
|
+
forbidden = i;
|
|
153
|
+
i = end;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (first === -1 && !isAsciiWhitespace(c)) first = i;
|
|
157
|
+
if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
|
|
158
|
+
if (c === "\n" || c === ";" || c === "{" || c === "[") {
|
|
159
|
+
boundary = i;
|
|
160
|
+
first = -1;
|
|
161
|
+
}
|
|
162
|
+
i++;
|
|
163
|
+
}
|
|
164
|
+
boundaries[text.length] = boundary;
|
|
165
|
+
firstContent[text.length] = first;
|
|
166
|
+
lastForbidden[text.length] = forbidden;
|
|
167
|
+
|
|
168
|
+
const index = { boundaries, firstContent, lastForbidden };
|
|
169
|
+
if (cacheStatementIndex(text, index)) {
|
|
170
|
+
recentBoundaryText = text;
|
|
171
|
+
recentBoundaryIndex = index;
|
|
172
|
+
}
|
|
173
|
+
return index;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function trailingName(text: string, start: number, end: number): string | null {
|
|
177
|
+
let i = end - 1;
|
|
178
|
+
while (i >= start && isAsciiWhitespace(text[i])) i--;
|
|
179
|
+
if (text[i] !== "=") return null;
|
|
180
|
+
i--;
|
|
181
|
+
const nameEnd = i + 1;
|
|
182
|
+
while (i >= start && /[A-Za-z0-9.-]/.test(text[i] as string)) i--;
|
|
183
|
+
let nameStart = i + 1;
|
|
184
|
+
while (nameStart < nameEnd && !/[A-Za-z]/.test(text[nameStart] as string))
|
|
185
|
+
nameStart++;
|
|
186
|
+
if (nameStart === nameEnd || !/[A-Za-z]/.test(text[nameStart] as string))
|
|
187
|
+
return null;
|
|
188
|
+
return text.slice(nameStart, nameEnd);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function leadingWords(
|
|
192
|
+
text: string,
|
|
193
|
+
start: number,
|
|
194
|
+
end: number,
|
|
195
|
+
limit: number,
|
|
196
|
+
removeContinuations = false,
|
|
197
|
+
): string[] {
|
|
198
|
+
const words: string[] = [];
|
|
199
|
+
let word = "";
|
|
200
|
+
for (let i = start; i < end && words.length < limit; i++) {
|
|
201
|
+
if (
|
|
202
|
+
removeContinuations &&
|
|
203
|
+
text[i] === "\\" &&
|
|
204
|
+
(text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))
|
|
205
|
+
) {
|
|
206
|
+
i += text[i + 1] === "\r" ? 2 : 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const c = text[i] as string;
|
|
210
|
+
if (isAsciiWhitespace(c)) {
|
|
211
|
+
if (word.length > 0) {
|
|
212
|
+
words.push(word);
|
|
213
|
+
word = "";
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
word += c;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (word.length > 0 && words.length < limit) words.push(word);
|
|
220
|
+
return words;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Scope name for an already comment-masked source view. */
|
|
224
|
+
export function scopeNameFromMasked(
|
|
225
|
+
masked: string,
|
|
226
|
+
open: number,
|
|
227
|
+
): string | null {
|
|
228
|
+
const index = statementIndex(masked);
|
|
229
|
+
const start = (index.boundaries[open] ?? -1) + 1;
|
|
230
|
+
const named = trailingName(masked, start, open);
|
|
231
|
+
if (named !== null) {
|
|
232
|
+
const name = named.toLowerCase();
|
|
233
|
+
if (SCOPE_ARG_NAMES.has(name)) return name;
|
|
234
|
+
const heads = HEAD_SCOPED_ARG_NAMES[name];
|
|
235
|
+
if (heads !== undefined) {
|
|
236
|
+
const first = index.firstContent[open] ?? -1;
|
|
237
|
+
const head =
|
|
238
|
+
first < start
|
|
239
|
+
? ""
|
|
240
|
+
: (leadingWords(masked, first, open, 1)[0] ?? "").toLowerCase();
|
|
241
|
+
return heads.has(head) ? name : null;
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
// `:do {`, `:retry {`, `:onerror Err {` — the directive may carry ONE bare
|
|
246
|
+
// error-variable word before the brace. A second token that is not an
|
|
247
|
+
// identifier (`:onerror [find] {`) is not this form, so the brace is a value.
|
|
248
|
+
const firstContentOffset = index.firstContent[open] ?? -1;
|
|
249
|
+
const words =
|
|
250
|
+
firstContentOffset < start
|
|
251
|
+
? []
|
|
252
|
+
: leadingWords(masked, firstContentOffset, open, 3);
|
|
253
|
+
const firstWord = (words[0] ?? "").toLowerCase();
|
|
254
|
+
const body = DIRECTIVE_BODY[firstWord];
|
|
255
|
+
if (body === undefined) return null;
|
|
256
|
+
if (words.length === 1) return body;
|
|
257
|
+
if (words.length === 2 && ERROR_VAR.test(words[1] as string)) return body;
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Whether this brace begins a context in which statement-leading comments exist. */
|
|
262
|
+
export function braceStartsStatements(text: string, open: number): boolean {
|
|
263
|
+
if (scopeNameFromMasked(text, open) !== null) return true;
|
|
264
|
+
const index = statementIndex(text);
|
|
265
|
+
const boundary = index.boundaries[open] ?? -1;
|
|
266
|
+
const start = boundary + 1;
|
|
267
|
+
const named = trailingName(text, start, open);
|
|
268
|
+
if (named !== null) return SCRIPT_BODY_ARG.test(named.toLowerCase());
|
|
269
|
+
|
|
270
|
+
// H7's bare `{...}` and `/menu {...}` containers are statement bodies too.
|
|
271
|
+
const first = index.firstContent[open] ?? -1;
|
|
272
|
+
return (
|
|
273
|
+
first < start ||
|
|
274
|
+
(text[first] === "/" && (index.lastForbidden[open] ?? -1) < start)
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Whether an unquoted, non-comment `#` is a device hard error (#245).
|
|
280
|
+
*
|
|
281
|
+
* `inExpression` is the caller's already-known delimiter role: arrays and
|
|
282
|
+
* parenthesized expressions reject every unquoted hash. Outside them, a hash
|
|
283
|
+
* joined to a value (`comment=a#b`) stays content. A whitespace-led hash is
|
|
284
|
+
* rejected only where CHR proves the statement has already consumed the fixed
|
|
285
|
+
* positional operands of a scripting directive, or after a closing scope.
|
|
286
|
+
*/
|
|
287
|
+
export function hashStartsHardError(
|
|
288
|
+
text: string,
|
|
289
|
+
at: number,
|
|
290
|
+
inExpression: boolean,
|
|
291
|
+
): boolean {
|
|
292
|
+
if (inExpression) return true;
|
|
293
|
+
if (at <= 0 || !isAsciiWhitespace(text[at - 1])) return false;
|
|
294
|
+
|
|
295
|
+
let previous = at - 1;
|
|
296
|
+
while (previous >= 0 && isAsciiWhitespace(text[previous])) previous--;
|
|
297
|
+
if (text[previous] === "}") return true;
|
|
298
|
+
|
|
299
|
+
const index = statementIndex(text);
|
|
300
|
+
const start = (index.boundaries[at] ?? -1) + 1;
|
|
301
|
+
const first = index.firstContent[at] ?? -1;
|
|
302
|
+
const words = first < start ? [] : leadingWords(text, first, at, 3, true);
|
|
303
|
+
const rawHead = (words[0] ?? "").toLowerCase();
|
|
304
|
+
if (!rawHead.startsWith(":") && !rawHead.startsWith("/")) return false;
|
|
305
|
+
const head = rawHead.slice(1);
|
|
306
|
+
const arity = DIRECTIVE_POSITIONAL_ARITY[head];
|
|
307
|
+
return arity !== undefined && words.length - 1 >= arity;
|
|
308
|
+
}
|