@tikoci/centrs 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +348 -0
- package/GLOSSARY.txt +252 -0
- package/LICENSE +21 -0
- package/README.md +44 -12
- package/commands/AGENTS.md +14 -5
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +56 -26
- package/commands/api/examples.md +46 -1
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +77 -36
- package/commands/execute/examples.md +120 -10
- package/commands/explain/README.md +2756 -0
- package/commands/explain/examples.md +702 -0
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/terminal/examples.md +7 -1
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +138 -30
- package/docs/MATRIX.md +92 -18
- package/docs/errors/README.md +3 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +56 -10
- package/src/api-fanout.ts +46 -18
- package/src/api.ts +351 -121
- package/src/cli/api.ts +26 -7
- package/src/cli/btest.ts +19 -3
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/terminal.ts +1 -1
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +58 -2
- package/src/core/error-catalog.ts +42 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +47 -18
- package/src/execute.ts +339 -307
- package/src/explain/arg-tokens.ts +151 -0
- package/src/explain/args.ts +1749 -0
- package/src/explain/blocks.ts +175 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +39 -0
- package/src/explain/catalog.ts +1343 -0
- package/src/explain/coordinates.ts +488 -0
- package/src/explain/defects.ts +218 -0
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +668 -0
- package/src/explain/operator-tokens.ts +316 -0
- package/src/explain/operators.ts +465 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1569 -0
- package/src/explain/quoted-string.ts +331 -0
- package/src/explain/scope-brace.ts +461 -0
- package/src/explain/segment.ts +1165 -0
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +77 -0
- package/src/explain/symbol-values.ts +556 -0
- package/src/explain/symbols.ts +1617 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +514 -0
- package/src/explain/value-tokens.ts +83 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +955 -0
- package/src/explain/write.ts +856 -0
- package/src/explain.ts +2602 -0
- package/src/index.ts +225 -6
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +27 -13
- package/src/retrieve.ts +82 -77
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +43 -26
- package/src/transfer.ts +88 -45
|
@@ -0,0 +1,461 @@
|
|
|
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
|
+
matchingBrace: Map<number, number>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const boundaryCache = new Map<string, StatementIndex>();
|
|
82
|
+
let boundaryCacheBytes = 0;
|
|
83
|
+
// Most probes in one walk ask about the same document. Bypass Map's string-key
|
|
84
|
+
// lookup for that hot path. The slot holds ONE string reference, which may be a
|
|
85
|
+
// content-equal twin of a cache key rather than the key itself (see
|
|
86
|
+
// `adoptBoundaryText`), so retention is the bounded cache plus that one string.
|
|
87
|
+
let recentBoundaryText: string | undefined;
|
|
88
|
+
let recentBoundaryIndex: StatementIndex | undefined;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Adopt `text` as the single-slot key.
|
|
92
|
+
*
|
|
93
|
+
* A walk asks about several string objects with IDENTICAL content — the
|
|
94
|
+
* `analyzed` surface, the raw input, and a comment mask with nothing to mask
|
|
95
|
+
* are equal strings built by different steps. JavaScript cannot compare string
|
|
96
|
+
* identity, so `recentBoundaryText === text` across two such objects is a full
|
|
97
|
+
* content comparison: measured at 10 us per hit on a 64 KiB document versus
|
|
98
|
+
* 0.01 us when the slot already holds that object. Leaving the slot pointing at
|
|
99
|
+
* the first object made every later probe pay that comparison — a per-call cost
|
|
100
|
+
* that grows with the document, which is the shape #313 saw as superlinear
|
|
101
|
+
* growth through the public entry. Adopting the object on a hit pays it once.
|
|
102
|
+
*
|
|
103
|
+
* Sound because the index is a pure function of content: an index built for one
|
|
104
|
+
* string describes every string equal to it.
|
|
105
|
+
*/
|
|
106
|
+
function adoptBoundaryText(text: string, index: StatementIndex): void {
|
|
107
|
+
recentBoundaryText = text;
|
|
108
|
+
recentBoundaryIndex = index;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
|
|
112
|
+
// JavaScript strings use at most two bytes per UTF-16 code unit. Include the
|
|
113
|
+
// key as well as the typed array so unusually large sources cannot be retained.
|
|
114
|
+
return (
|
|
115
|
+
text.length * 2 +
|
|
116
|
+
index.boundaries.byteLength +
|
|
117
|
+
index.firstContent.byteLength +
|
|
118
|
+
index.lastForbidden.byteLength +
|
|
119
|
+
index.matchingBrace.size * 32
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether one statement index fits the bounded cache's per-entry ceiling. */
|
|
124
|
+
export function canRetainStatementIndex(text: string): boolean {
|
|
125
|
+
return (
|
|
126
|
+
text.length * 2 + (text.length + 1) * Int32Array.BYTES_PER_ELEMENT * 3 <=
|
|
127
|
+
BOUNDARY_CACHE_BYTE_LIMIT
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function cacheStatementIndex(text: string, index: StatementIndex): boolean {
|
|
132
|
+
if (!canRetainStatementIndex(text)) return false;
|
|
133
|
+
const entryBytes = boundaryCacheEntryBytes(text, index);
|
|
134
|
+
if (entryBytes > BOUNDARY_CACHE_BYTE_LIMIT) return false;
|
|
135
|
+
|
|
136
|
+
while (
|
|
137
|
+
boundaryCache.size >= BOUNDARY_CACHE_LIMIT ||
|
|
138
|
+
boundaryCacheBytes + entryBytes > BOUNDARY_CACHE_BYTE_LIMIT
|
|
139
|
+
) {
|
|
140
|
+
const oldest = boundaryCache.entries().next().value;
|
|
141
|
+
if (oldest === undefined) break;
|
|
142
|
+
boundaryCache.delete(oldest[0]);
|
|
143
|
+
boundaryCacheBytes -= boundaryCacheEntryBytes(oldest[0], oldest[1]);
|
|
144
|
+
if (recentBoundaryText === oldest[0]) {
|
|
145
|
+
recentBoundaryText = undefined;
|
|
146
|
+
recentBoundaryIndex = undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
boundaryCache.set(text, index);
|
|
150
|
+
boundaryCacheBytes += entryBytes;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Statement-boundary and prefix facts for every source offset, in one pass. */
|
|
155
|
+
function statementIndex(text: string): StatementIndex {
|
|
156
|
+
if (recentBoundaryText === text && recentBoundaryIndex !== undefined) {
|
|
157
|
+
const hit = recentBoundaryIndex;
|
|
158
|
+
adoptBoundaryText(text, hit);
|
|
159
|
+
return hit;
|
|
160
|
+
}
|
|
161
|
+
const cached = boundaryCache.get(text);
|
|
162
|
+
if (cached !== undefined) {
|
|
163
|
+
// Move to the most-recent end. Eviction walks the Map in INSERTION order,
|
|
164
|
+
// so without this the cache is FIFO: the document-wide index every ranged
|
|
165
|
+
// reader shares is re-read constantly but never re-inserted, and a stream
|
|
166
|
+
// of one-off statement indices ages it out and makes the next reader
|
|
167
|
+
// rebuild it over the whole document (#322).
|
|
168
|
+
boundaryCache.delete(text);
|
|
169
|
+
boundaryCache.set(text, cached);
|
|
170
|
+
adoptBoundaryText(text, cached);
|
|
171
|
+
return cached;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const boundaries = new Int32Array(text.length + 1);
|
|
175
|
+
boundaries.fill(-1);
|
|
176
|
+
const firstContent = new Int32Array(text.length + 1);
|
|
177
|
+
firstContent.fill(-1);
|
|
178
|
+
const lastForbidden = new Int32Array(text.length + 1);
|
|
179
|
+
lastForbidden.fill(-1);
|
|
180
|
+
const matchingBrace = new Map<number, number>();
|
|
181
|
+
const delimiters: { char: string; at: number }[] = [];
|
|
182
|
+
let boundary = -1;
|
|
183
|
+
let first = -1;
|
|
184
|
+
let forbidden = -1;
|
|
185
|
+
let i = 0;
|
|
186
|
+
while (i < text.length) {
|
|
187
|
+
boundaries[i] = boundary;
|
|
188
|
+
firstContent[i] = first;
|
|
189
|
+
lastForbidden[i] = forbidden;
|
|
190
|
+
const c = text[i] as string;
|
|
191
|
+
if (c === '"') {
|
|
192
|
+
const end = Math.min(scanQuotedString(text, i).end, text.length);
|
|
193
|
+
boundaries.fill(boundary, i + 1, end + 1);
|
|
194
|
+
firstContent.fill(first === -1 ? i : first, i + 1, end + 1);
|
|
195
|
+
lastForbidden.fill(i, i + 1, end + 1);
|
|
196
|
+
if (first === -1) first = i;
|
|
197
|
+
forbidden = i;
|
|
198
|
+
i = end;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (c === "{" || c === "[" || c === "(") {
|
|
202
|
+
delimiters.push({ char: c, at: i });
|
|
203
|
+
} else if (c === "}" || c === "]" || c === ")") {
|
|
204
|
+
const want = c === "}" ? "{" : c === "]" ? "[" : "(";
|
|
205
|
+
const delimiter = delimiters.pop();
|
|
206
|
+
if (delimiter?.char === want && c === "}")
|
|
207
|
+
matchingBrace.set(delimiter.at, i);
|
|
208
|
+
}
|
|
209
|
+
if (first === -1 && !isAsciiWhitespace(c)) first = i;
|
|
210
|
+
if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
|
|
211
|
+
if (c === "\n" || c === ";" || c === "{" || c === "[") {
|
|
212
|
+
boundary = i;
|
|
213
|
+
first = -1;
|
|
214
|
+
}
|
|
215
|
+
i++;
|
|
216
|
+
}
|
|
217
|
+
boundaries[text.length] = boundary;
|
|
218
|
+
firstContent[text.length] = first;
|
|
219
|
+
lastForbidden[text.length] = forbidden;
|
|
220
|
+
const index = {
|
|
221
|
+
boundaries,
|
|
222
|
+
firstContent,
|
|
223
|
+
lastForbidden,
|
|
224
|
+
matchingBrace,
|
|
225
|
+
};
|
|
226
|
+
if (cacheStatementIndex(text, index)) adoptBoundaryText(text, index);
|
|
227
|
+
return index;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Closing `}` paired with `open`, outside strings/comments, when one exists. */
|
|
231
|
+
export function matchingBraceEnd(
|
|
232
|
+
text: string,
|
|
233
|
+
open: number,
|
|
234
|
+
): number | undefined {
|
|
235
|
+
return statementIndex(text).matchingBrace.get(open);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Whether the index itself starts a fresh statement at `start`.
|
|
240
|
+
*
|
|
241
|
+
* Most readers index a comment mask, but segmentation must retain the raw
|
|
242
|
+
* comments. A quote in an earlier comment can make that raw index skip a real
|
|
243
|
+
* body opener as string content. Such a body must build its own local index:
|
|
244
|
+
* clamping lookback alone cannot restore boundaries the outer index skipped.
|
|
245
|
+
*/
|
|
246
|
+
export function isIndexedStatementStart(text: string, start: number): boolean {
|
|
247
|
+
return start === 0 || statementIndex(text).boundaries[start] === start - 1;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The bare scope-slot word before a `{`, when the `=` is simply omitted (#347).
|
|
252
|
+
*
|
|
253
|
+
* The device settles this: `:if (1=1) do={…} else={…}`,
|
|
254
|
+
* `:if (1=1) do={…} else {…}` and `:if (1=1) do {…} else {…}` all lower to
|
|
255
|
+
* BYTE-IDENTICAL IL (`…;do=;(evl …);else=;(evl …)`) on 7.21.5, 7.23.5, 7.24.2
|
|
256
|
+
* and 7.25beta3, with `:if (1=1) do={…} elsy {…}` rejected on all four as the
|
|
257
|
+
* control. The `=` is optional punctuation, not part of the slot.
|
|
258
|
+
*
|
|
259
|
+
* Deliberately narrower than {@link trailingName} in two ways, because a bare
|
|
260
|
+
* word is far weaker evidence than `name=`:
|
|
261
|
+
*
|
|
262
|
+
* - the word must already be a {@link SCOPE_ARG_NAMES} member, so a positional
|
|
263
|
+
* never becomes a slot — `:local z {1;2}` keeps `z` a positional and its
|
|
264
|
+
* brace an array;
|
|
265
|
+
* - it may not be the statement's own leading directive. `:do {` reads its
|
|
266
|
+
* body through {@link DIRECTIVE_BODY} (as `command`, the IL name), and
|
|
267
|
+
* letting the head word match here would rename that slot to `do`.
|
|
268
|
+
*/
|
|
269
|
+
function trailingBareScopeName(
|
|
270
|
+
text: string,
|
|
271
|
+
start: number,
|
|
272
|
+
end: number,
|
|
273
|
+
): string | null {
|
|
274
|
+
let i = end - 1;
|
|
275
|
+
while (i >= start && isAsciiWhitespace(text[i])) i--;
|
|
276
|
+
const nameEnd = i + 1;
|
|
277
|
+
while (i >= start && /[A-Za-z0-9.-]/.test(text[i] as string)) i--;
|
|
278
|
+
const nameStart = i + 1;
|
|
279
|
+
if (nameStart === nameEnd) return null;
|
|
280
|
+
const name = text.slice(nameStart, nameEnd).toLowerCase();
|
|
281
|
+
if (!SCOPE_ARG_NAMES.has(name)) return null;
|
|
282
|
+
// A COLON makes it a directive, never an argument name, and the device is
|
|
283
|
+
// emphatic: `:if (1=1) :do { :put a }` is `expected end of command` on both
|
|
284
|
+
// 7.21.5 and 7.24.2, while the bare `:if (1=1) do { :put a }` parses. So the
|
|
285
|
+
// colon form is refused here and left to the `DIRECTIVE_BODY` branch below,
|
|
286
|
+
// which is what legitimately reads a statement-head `:do { … }`.
|
|
287
|
+
if (text[nameStart - 1] === ":") return null;
|
|
288
|
+
if (leadingWords(text, start, nameStart, 1).length === 0) return null;
|
|
289
|
+
return name;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function trailingName(text: string, start: number, end: number): string | null {
|
|
293
|
+
let i = end - 1;
|
|
294
|
+
while (i >= start && isAsciiWhitespace(text[i])) i--;
|
|
295
|
+
if (text[i] !== "=") return null;
|
|
296
|
+
i--;
|
|
297
|
+
const nameEnd = i + 1;
|
|
298
|
+
while (i >= start && /[A-Za-z0-9.-]/.test(text[i] as string)) i--;
|
|
299
|
+
let nameStart = i + 1;
|
|
300
|
+
while (nameStart < nameEnd && !/[A-Za-z]/.test(text[nameStart] as string))
|
|
301
|
+
nameStart++;
|
|
302
|
+
if (nameStart === nameEnd || !/[A-Za-z]/.test(text[nameStart] as string))
|
|
303
|
+
return null;
|
|
304
|
+
return text.slice(nameStart, nameEnd);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function leadingWords(
|
|
308
|
+
text: string,
|
|
309
|
+
start: number,
|
|
310
|
+
end: number,
|
|
311
|
+
limit: number,
|
|
312
|
+
removeContinuations = false,
|
|
313
|
+
): string[] {
|
|
314
|
+
const words: string[] = [];
|
|
315
|
+
let word = "";
|
|
316
|
+
for (let i = start; i < end && words.length < limit; i++) {
|
|
317
|
+
if (
|
|
318
|
+
removeContinuations &&
|
|
319
|
+
text[i] === "\\" &&
|
|
320
|
+
(text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))
|
|
321
|
+
) {
|
|
322
|
+
i += text[i + 1] === "\r" ? 2 : 1;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const c = text[i] as string;
|
|
326
|
+
if (isAsciiWhitespace(c)) {
|
|
327
|
+
if (word.length > 0) {
|
|
328
|
+
words.push(word);
|
|
329
|
+
word = "";
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
word += c;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (word.length > 0 && words.length < limit) words.push(word);
|
|
336
|
+
return words;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Scope name for an already comment-masked source view.
|
|
341
|
+
*
|
|
342
|
+
* `floor` is the start of the region the caller is reading, defaulting to the
|
|
343
|
+
* whole view. Clamping the statement start to it makes an answer read off a
|
|
344
|
+
* RANGE of a document identical to the same answer read off that range taken as
|
|
345
|
+
* a string of its own: a fresh call cannot see before index 0, so a ranged call
|
|
346
|
+
* must not see before `floor` (#322).
|
|
347
|
+
*
|
|
348
|
+
* It is a guard, not a correction. The clamp does fire on ordinary input —
|
|
349
|
+
* 7,448 times over the 948-script corpus, where a region begins somewhere the
|
|
350
|
+
* preceding boundary character does not — but removing it leaves every one of
|
|
351
|
+
* those 948 results byte-identical, because the extra bytes a wider lookback
|
|
352
|
+
* sees do not spell a scope name. What it buys is that the equality holds by
|
|
353
|
+
* CONSTRUCTION rather than by a property of where today's ranges happen to
|
|
354
|
+
* start, which is what the next reader to take a range will rely on.
|
|
355
|
+
*
|
|
356
|
+
* Nothing else needs clamping — `firstContent` and `lastForbidden` are only
|
|
357
|
+
* ever compared against `start`, so a value from before the floor is already
|
|
358
|
+
* excluded by being less than it.
|
|
359
|
+
*/
|
|
360
|
+
export function scopeNameFromMasked(
|
|
361
|
+
masked: string,
|
|
362
|
+
open: number,
|
|
363
|
+
floor = 0,
|
|
364
|
+
): string | null {
|
|
365
|
+
const index = statementIndex(masked);
|
|
366
|
+
const start = Math.max((index.boundaries[open] ?? -1) + 1, floor);
|
|
367
|
+
const named = trailingName(masked, start, open);
|
|
368
|
+
if (named !== null) {
|
|
369
|
+
const name = named.toLowerCase();
|
|
370
|
+
if (SCOPE_ARG_NAMES.has(name)) return name;
|
|
371
|
+
const heads = HEAD_SCOPED_ARG_NAMES[name];
|
|
372
|
+
if (heads !== undefined) {
|
|
373
|
+
const first = index.firstContent[open] ?? -1;
|
|
374
|
+
const head =
|
|
375
|
+
first < start
|
|
376
|
+
? ""
|
|
377
|
+
: (leadingWords(masked, first, open, 1)[0] ?? "").toLowerCase();
|
|
378
|
+
return heads.has(head) ? name : null;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
// `else {` / `do {` / `on-error {` — the same slot with the `=` left off.
|
|
383
|
+
// Checked before the directive-body branch so the ARGUMENT reading wins over
|
|
384
|
+
// the head reading for a word that can be either.
|
|
385
|
+
const bare = trailingBareScopeName(masked, start, open);
|
|
386
|
+
if (bare !== null) return bare;
|
|
387
|
+
// `:do {`, `:retry {`, `:onerror Err {` — the directive may carry ONE bare
|
|
388
|
+
// error-variable word before the brace. A second token that is not an
|
|
389
|
+
// identifier (`:onerror [find] {`) is not this form, so the brace is a value.
|
|
390
|
+
const firstContentOffset = index.firstContent[open] ?? -1;
|
|
391
|
+
const words =
|
|
392
|
+
firstContentOffset < start
|
|
393
|
+
? []
|
|
394
|
+
: leadingWords(masked, firstContentOffset, open, 3);
|
|
395
|
+
const firstWord = (words[0] ?? "").toLowerCase();
|
|
396
|
+
const body = DIRECTIVE_BODY[firstWord];
|
|
397
|
+
if (body === undefined) return null;
|
|
398
|
+
if (words.length === 1) return body;
|
|
399
|
+
if (words.length === 2 && ERROR_VAR.test(words[1] as string)) return body;
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Whether this brace begins a context in which statement-leading comments exist.
|
|
405
|
+
*
|
|
406
|
+
* `floor` bounds the lookback exactly as in {@link scopeNameFromMasked}.
|
|
407
|
+
*/
|
|
408
|
+
export function braceStartsStatements(
|
|
409
|
+
text: string,
|
|
410
|
+
open: number,
|
|
411
|
+
floor = 0,
|
|
412
|
+
): boolean {
|
|
413
|
+
if (scopeNameFromMasked(text, open, floor) !== null) return true;
|
|
414
|
+
const index = statementIndex(text);
|
|
415
|
+
const boundary = index.boundaries[open] ?? -1;
|
|
416
|
+
const start = Math.max(boundary + 1, floor);
|
|
417
|
+
const named = trailingName(text, start, open);
|
|
418
|
+
if (named !== null) return SCRIPT_BODY_ARG.test(named.toLowerCase());
|
|
419
|
+
|
|
420
|
+
// H7's bare `{...}` and `/menu {...}` containers are statement bodies too.
|
|
421
|
+
const first = index.firstContent[open] ?? -1;
|
|
422
|
+
return (
|
|
423
|
+
first < start ||
|
|
424
|
+
(text[first] === "/" && (index.lastForbidden[open] ?? -1) < start)
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Whether an unquoted, non-comment `#` is a device hard error (#245).
|
|
430
|
+
*
|
|
431
|
+
* `inExpression` is the caller's already-known delimiter role: arrays and
|
|
432
|
+
* parenthesized expressions reject every unquoted hash. Outside them, a hash
|
|
433
|
+
* joined to a value (`comment=a#b`) stays content. A whitespace-led hash is
|
|
434
|
+
* rejected only where CHR proves the statement has already consumed the fixed
|
|
435
|
+
* positional operands of a scripting directive, or after a closing scope.
|
|
436
|
+
*/
|
|
437
|
+
export function hashStartsHardError(
|
|
438
|
+
text: string,
|
|
439
|
+
at: number,
|
|
440
|
+
inExpression: boolean,
|
|
441
|
+
floor = 0,
|
|
442
|
+
): boolean {
|
|
443
|
+
if (inExpression) return true;
|
|
444
|
+
if (at <= floor || !isAsciiWhitespace(text[at - 1])) return false;
|
|
445
|
+
|
|
446
|
+
let previous = at - 1;
|
|
447
|
+
while (previous >= floor && isAsciiWhitespace(text[previous])) previous--;
|
|
448
|
+
// The `}` must be inside the region: a fresh call on the region alone runs
|
|
449
|
+
// off index 0 and finds nothing, so a ranged call must stop at `floor`.
|
|
450
|
+
if (previous >= floor && text[previous] === "}") return true;
|
|
451
|
+
|
|
452
|
+
const index = statementIndex(text);
|
|
453
|
+
const start = Math.max((index.boundaries[at] ?? -1) + 1, floor);
|
|
454
|
+
const first = index.firstContent[at] ?? -1;
|
|
455
|
+
const words = first < start ? [] : leadingWords(text, first, at, 3, true);
|
|
456
|
+
const rawHead = (words[0] ?? "").toLowerCase();
|
|
457
|
+
if (!rawHead.startsWith(":") && !rawHead.startsWith("/")) return false;
|
|
458
|
+
const head = rawHead.slice(1);
|
|
459
|
+
const arity = DIRECTIVE_POSITIONAL_ARITY[head];
|
|
460
|
+
return arity !== undefined && words.length - 1 >= arity;
|
|
461
|
+
}
|