@tikoci/centrs 0.1.4 → 0.1.7
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 +417 -1
- package/GLOSSARY.txt +40 -1
- package/README.md +16 -8
- package/commands/AGENTS.md +7 -4
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +50 -10
- package/commands/api/examples.md +100 -2
- package/commands/devices/README.md +10 -1
- package/commands/devices/examples.md +33 -2
- package/commands/execute/README.md +68 -25
- package/commands/execute/examples.md +168 -7
- package/commands/explain/README.md +1206 -155
- package/commands/explain/examples.md +99 -3
- package/commands/mcp/README.md +9 -5
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/examples.md +20 -0
- package/commands/terminal/examples.md +7 -1
- package/docs/CLI.md +3 -3
- package/docs/CONSTITUTION.md +83 -21
- package/docs/MATRIX.md +89 -52
- package/docs/errors/cdb/create-failed.md +20 -0
- package/docs/errors/cdb/file-exists.md +23 -0
- package/docs/errors/input/invalid-target.md +25 -3
- package/docs/errors/routeros/invalid-value.md +3 -2
- package/docs/errors/transport/cancel-unacknowledged.md +24 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/package.json +13 -2
- package/src/api-fanout.ts +17 -4
- package/src/api.ts +346 -75
- package/src/cli/api.ts +1 -1
- package/src/cli/btest.ts +1 -1
- package/src/cli/devices.ts +16 -3
- package/src/cli/explain.ts +1 -1
- package/src/cli/terminal.ts +1 -1
- package/src/core/envelope.ts +53 -0
- package/src/core/error-catalog.ts +16 -1
- package/src/core/execute-command.ts +455 -0
- package/src/core/inspect.ts +58 -26
- package/src/core/routeros-errors.ts +66 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/data/winbox-cdb-write.ts +22 -8
- package/src/devices.ts +142 -28
- package/src/execute-fanout.ts +19 -5
- package/src/execute.ts +247 -295
- package/src/explain/arg-tokens.ts +62 -25
- package/src/explain/args.ts +398 -52
- package/src/explain/blocks.ts +54 -11
- package/src/explain/brace-tokens.ts +4 -3
- package/src/explain/catalog.ts +42 -44
- package/src/explain/coordinates.ts +159 -0
- package/src/explain/defects.ts +2 -1
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/menus.ts +9 -5
- package/src/explain/operator-tokens.ts +13 -7
- package/src/explain/operators.ts +27 -20
- package/src/explain/path-tokens.ts +1 -1
- package/src/explain/pathresolve.ts +358 -113
- package/src/explain/quoted-string.ts +110 -20
- package/src/explain/scope-brace.ts +175 -22
- package/src/explain/segment.ts +429 -21
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +14 -4
- package/src/explain/symbol-values.ts +209 -173
- package/src/explain/symbols.ts +21 -1
- package/src/explain/transport.ts +17 -0
- package/src/explain/value-tokens.ts +15 -12
- package/src/explain/verbsplit.ts +165 -23
- package/src/explain/write.ts +47 -6
- package/src/explain.ts +522 -72
- package/src/index.ts +21 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/adapter.ts +12 -1
- package/src/protocols/mac-telnet-console.ts +6 -4
- package/src/protocols/native-api.ts +29 -3
- package/src/resolver/target.ts +32 -12
- package/src/retrieve-fanout.ts +1 -1
- package/src/retrieve.ts +18 -63
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +1 -1
- package/src/transfer.ts +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { type Defect, defectAt } from "./defects.ts";
|
|
10
10
|
|
|
11
11
|
/** Analyzer resource bound, not a RouterOS grammar limit. */
|
|
12
|
-
const MAX_STRING_FRAME_DEPTH = 256;
|
|
12
|
+
export const MAX_STRING_FRAME_DEPTH = 256;
|
|
13
13
|
|
|
14
14
|
/** Where a double-quoted string ends, and whether it was closed at all. */
|
|
15
15
|
export interface QuotedStringScan {
|
|
@@ -18,6 +18,13 @@ export interface QuotedStringScan {
|
|
|
18
18
|
closed: boolean;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
type InterpolationEnd = (
|
|
22
|
+
text: string,
|
|
23
|
+
open: number,
|
|
24
|
+
limit: number,
|
|
25
|
+
frameDepth: number,
|
|
26
|
+
) => number;
|
|
27
|
+
|
|
21
28
|
/**
|
|
22
29
|
* Valid single-char escapes after `\` inside a RouterOS string.
|
|
23
30
|
*
|
|
@@ -97,7 +104,7 @@ function escapeLength(text: string, at: number): 2 | 3 {
|
|
|
97
104
|
// ---------------------------------------------------------------------------
|
|
98
105
|
// Single source of truth for the frame grammar (#253).
|
|
99
106
|
//
|
|
100
|
-
// Both `scanQuotedString` and `
|
|
107
|
+
// Both `scanQuotedString` and `walkStringEscapes` walk the same
|
|
101
108
|
// nesting: a `"` frame is string phase, anything else is code phase where
|
|
102
109
|
// `$[`/`$(` inside a string push a bracket frame and brackets nest until
|
|
103
110
|
// matched. The helpers below own the transitions, the depth guard, and the
|
|
@@ -117,8 +124,9 @@ function stepFrame(
|
|
|
117
124
|
i: number,
|
|
118
125
|
frames: string[],
|
|
119
126
|
onStringEscape: (at: number) => number | Defect,
|
|
127
|
+
maxFrameDepth: number = MAX_STRING_FRAME_DEPTH,
|
|
120
128
|
): number | Defect {
|
|
121
|
-
if (frames.length >
|
|
129
|
+
if (frames.length > maxFrameDepth) return 0;
|
|
122
130
|
const top = frames[frames.length - 1] as string | undefined;
|
|
123
131
|
const c = text[i] as string;
|
|
124
132
|
if (top === '"') {
|
|
@@ -186,33 +194,98 @@ function stringEscapeValidated(text: string, at: number): number | Defect {
|
|
|
186
194
|
*
|
|
187
195
|
* A `$[…]` or `$(…)` substitution inside a string is code and may contain
|
|
188
196
|
* nested strings, so the next quote is not necessarily the outer close.
|
|
197
|
+
*
|
|
198
|
+
* `limit` is the end of the region the caller is reading, defaulting to the
|
|
199
|
+
* whole text. A reader working on a RANGE of a larger document must pass it:
|
|
200
|
+
* a string left unterminated inside the range would otherwise be closed by a
|
|
201
|
+
* quote belonging to later text, which is not the answer the same reader gets
|
|
202
|
+
* from the range taken as a string of its own (#322). Lookahead past `limit`
|
|
203
|
+
* cannot change the result — every advance that reads ahead is at least one
|
|
204
|
+
* byte, so it can only overshoot an end that is reported clamped either way.
|
|
189
205
|
*/
|
|
190
|
-
export function scanQuotedString(
|
|
206
|
+
export function scanQuotedString(
|
|
207
|
+
text: string,
|
|
208
|
+
open: number,
|
|
209
|
+
limit: number = text.length,
|
|
210
|
+
interpolationEnd?: InterpolationEnd,
|
|
211
|
+
maxFrameDepth: number = MAX_STRING_FRAME_DEPTH,
|
|
212
|
+
): QuotedStringScan {
|
|
213
|
+
if (maxFrameDepth < 1) return { end: limit, closed: false };
|
|
191
214
|
const frames: string[] = ['"'];
|
|
192
215
|
let i = open + 1;
|
|
193
|
-
while (i <
|
|
194
|
-
|
|
216
|
+
while (i < limit) {
|
|
217
|
+
if (
|
|
218
|
+
frames.at(-1) === '"' &&
|
|
219
|
+
text[i] === "$" &&
|
|
220
|
+
text[i + 1] === "[" &&
|
|
221
|
+
interpolationEnd !== undefined
|
|
222
|
+
) {
|
|
223
|
+
const end = interpolationEnd(text, i + 1, limit, frames.length);
|
|
224
|
+
if (end >= limit) return { end: limit, closed: false };
|
|
225
|
+
i = end + 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const res = stepFrame(
|
|
229
|
+
text,
|
|
230
|
+
i,
|
|
231
|
+
frames,
|
|
232
|
+
(at) => stringEscapeSkip(text, at),
|
|
233
|
+
maxFrameDepth,
|
|
234
|
+
);
|
|
195
235
|
if (typeof res !== "number") break;
|
|
196
236
|
if (res === 0) break;
|
|
197
237
|
i += res;
|
|
198
238
|
if (frames.length === 0) return { end: i, closed: true };
|
|
199
239
|
}
|
|
200
|
-
return { end:
|
|
240
|
+
return { end: limit, closed: false };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** One string-internal escape: `[start, end)` over the `\` and what it escapes. */
|
|
244
|
+
export interface StringEscapeSpan {
|
|
245
|
+
start: number;
|
|
246
|
+
end: number;
|
|
201
247
|
}
|
|
202
248
|
|
|
203
249
|
/**
|
|
204
|
-
*
|
|
250
|
+
* What one document-wide escape walk found: the valid escapes, and the first
|
|
251
|
+
* invalid one if the walk hit one.
|
|
252
|
+
*/
|
|
253
|
+
export interface StringEscapeWalk {
|
|
254
|
+
/** Valid escapes in document order, up to (not including) the first invalid one. */
|
|
255
|
+
escapes: StringEscapeSpan[];
|
|
256
|
+
/** At most one defect, at the first invalid escape. */
|
|
257
|
+
defects: Defect[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Walk every string-internal escape in `text`, once.
|
|
205
262
|
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
263
|
+
* Same frame model as `scanQuotedString`, validating each `\` inside a string
|
|
264
|
+
* frame against the documented escape table (capital hex, truncated, unknown).
|
|
265
|
+
* Comment spans are skipped: a `"` inside a comment is not a string.
|
|
266
|
+
*
|
|
267
|
+
* The walk yields BOTH answers because they are the same reading (#264): a
|
|
268
|
+
* valid escape becomes an `escaped` token and an invalid one becomes a
|
|
269
|
+
* `bad-string-escape` diagnostic, and deriving them from two walks would let
|
|
270
|
+
* the token stream and the diagnostics disagree about which bytes are an
|
|
271
|
+
* escape. It stops at the first invalid escape, so the escapes list is the
|
|
272
|
+
* prefix the validator actually vouched for — past a malformed escape the
|
|
273
|
+
* string boundaries themselves are in doubt, and painting `escaped` there
|
|
274
|
+
* would be a claim this walk cannot support.
|
|
275
|
+
*
|
|
276
|
+
* `collectEscapes: false` validates without retaining the valid spans. The
|
|
277
|
+
* default `explainCommand` path consumes only `defects`, and materializing a
|
|
278
|
+
* span per escape for a caller that never asked for `data.tokens` is the kind
|
|
279
|
+
* of cost #313/#317/#320 were about — work nobody requested. Validation is identical
|
|
280
|
+
* either way — only the retention differs, so the two callers cannot diverge
|
|
281
|
+
* on what counts as an escape.
|
|
211
282
|
*/
|
|
212
|
-
export function
|
|
283
|
+
export function walkStringEscapes(
|
|
213
284
|
text: string,
|
|
214
285
|
comments: readonly { start: number; end: number }[] = [],
|
|
215
|
-
|
|
286
|
+
collectEscapes = true,
|
|
287
|
+
): StringEscapeWalk {
|
|
288
|
+
const escapes: StringEscapeSpan[] = [];
|
|
216
289
|
const frames: string[] = [];
|
|
217
290
|
let i = 0;
|
|
218
291
|
let ci = 0;
|
|
@@ -230,12 +303,29 @@ export function collectStringEscapeDefects(
|
|
|
230
303
|
continue;
|
|
231
304
|
}
|
|
232
305
|
}
|
|
233
|
-
const res = stepFrame(text, i, frames, (at) =>
|
|
234
|
-
stringEscapeValidated(text, at)
|
|
235
|
-
|
|
236
|
-
|
|
306
|
+
const res = stepFrame(text, i, frames, (at) => {
|
|
307
|
+
const step = stringEscapeValidated(text, at);
|
|
308
|
+
if (collectEscapes && typeof step === "number")
|
|
309
|
+
escapes.push({ start: at, end: at + step });
|
|
310
|
+
return step;
|
|
311
|
+
});
|
|
312
|
+
if (typeof res !== "number") return { escapes, defects: [res] };
|
|
237
313
|
if (res === 0) break;
|
|
238
314
|
i += res;
|
|
239
315
|
}
|
|
240
|
-
return [];
|
|
316
|
+
return { escapes, defects: [] };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Collect the first invalid string-internal escape in `text`, if any.
|
|
321
|
+
*
|
|
322
|
+
* The defect half of {@link walkStringEscapes}, retaining no escape spans.
|
|
323
|
+
* Callers that also need the valid escapes should call the walk directly
|
|
324
|
+
* rather than walking twice.
|
|
325
|
+
*/
|
|
326
|
+
export function collectStringEscapeDefects(
|
|
327
|
+
text: string,
|
|
328
|
+
comments: readonly { start: number; end: number }[] = [],
|
|
329
|
+
): Defect[] {
|
|
330
|
+
return walkStringEscapes(text, comments, false).defects;
|
|
241
331
|
}
|
|
@@ -75,15 +75,39 @@ interface StatementIndex {
|
|
|
75
75
|
boundaries: Int32Array;
|
|
76
76
|
firstContent: Int32Array;
|
|
77
77
|
lastForbidden: Int32Array;
|
|
78
|
+
matchingBrace: Map<number, number>;
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
const boundaryCache = new Map<string, StatementIndex>();
|
|
81
82
|
let boundaryCacheBytes = 0;
|
|
82
83
|
// Most probes in one walk ask about the same document. Bypass Map's string-key
|
|
83
|
-
// lookup for that hot path
|
|
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.
|
|
84
87
|
let recentBoundaryText: string | undefined;
|
|
85
88
|
let recentBoundaryIndex: StatementIndex | undefined;
|
|
86
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
|
+
|
|
87
111
|
function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
|
|
88
112
|
// JavaScript strings use at most two bytes per UTF-16 code unit. Include the
|
|
89
113
|
// key as well as the typed array so unusually large sources cannot be retained.
|
|
@@ -91,11 +115,21 @@ function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
|
|
|
91
115
|
text.length * 2 +
|
|
92
116
|
index.boundaries.byteLength +
|
|
93
117
|
index.firstContent.byteLength +
|
|
94
|
-
index.lastForbidden.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
|
|
95
128
|
);
|
|
96
129
|
}
|
|
97
130
|
|
|
98
131
|
function cacheStatementIndex(text: string, index: StatementIndex): boolean {
|
|
132
|
+
if (!canRetainStatementIndex(text)) return false;
|
|
99
133
|
const entryBytes = boundaryCacheEntryBytes(text, index);
|
|
100
134
|
if (entryBytes > BOUNDARY_CACHE_BYTE_LIMIT) return false;
|
|
101
135
|
|
|
@@ -119,12 +153,21 @@ function cacheStatementIndex(text: string, index: StatementIndex): boolean {
|
|
|
119
153
|
|
|
120
154
|
/** Statement-boundary and prefix facts for every source offset, in one pass. */
|
|
121
155
|
function statementIndex(text: string): StatementIndex {
|
|
122
|
-
if (recentBoundaryText === text && recentBoundaryIndex !== undefined)
|
|
123
|
-
|
|
156
|
+
if (recentBoundaryText === text && recentBoundaryIndex !== undefined) {
|
|
157
|
+
const hit = recentBoundaryIndex;
|
|
158
|
+
adoptBoundaryText(text, hit);
|
|
159
|
+
return hit;
|
|
160
|
+
}
|
|
124
161
|
const cached = boundaryCache.get(text);
|
|
125
162
|
if (cached !== undefined) {
|
|
126
|
-
|
|
127
|
-
|
|
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);
|
|
128
171
|
return cached;
|
|
129
172
|
}
|
|
130
173
|
|
|
@@ -134,6 +177,8 @@ function statementIndex(text: string): StatementIndex {
|
|
|
134
177
|
firstContent.fill(-1);
|
|
135
178
|
const lastForbidden = new Int32Array(text.length + 1);
|
|
136
179
|
lastForbidden.fill(-1);
|
|
180
|
+
const matchingBrace = new Map<number, number>();
|
|
181
|
+
const delimiters: { char: string; at: number }[] = [];
|
|
137
182
|
let boundary = -1;
|
|
138
183
|
let first = -1;
|
|
139
184
|
let forbidden = -1;
|
|
@@ -153,6 +198,14 @@ function statementIndex(text: string): StatementIndex {
|
|
|
153
198
|
i = end;
|
|
154
199
|
continue;
|
|
155
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
|
+
}
|
|
156
209
|
if (first === -1 && !isAsciiWhitespace(c)) first = i;
|
|
157
210
|
if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
|
|
158
211
|
if (c === "\n" || c === ";" || c === "{" || c === "[") {
|
|
@@ -164,15 +217,78 @@ function statementIndex(text: string): StatementIndex {
|
|
|
164
217
|
boundaries[text.length] = boundary;
|
|
165
218
|
firstContent[text.length] = first;
|
|
166
219
|
lastForbidden[text.length] = forbidden;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
220
|
+
const index = {
|
|
221
|
+
boundaries,
|
|
222
|
+
firstContent,
|
|
223
|
+
lastForbidden,
|
|
224
|
+
matchingBrace,
|
|
225
|
+
};
|
|
226
|
+
if (cacheStatementIndex(text, index)) adoptBoundaryText(text, index);
|
|
173
227
|
return index;
|
|
174
228
|
}
|
|
175
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
|
+
|
|
176
292
|
function trailingName(text: string, start: number, end: number): string | null {
|
|
177
293
|
let i = end - 1;
|
|
178
294
|
while (i >= start && isAsciiWhitespace(text[i])) i--;
|
|
@@ -220,13 +336,34 @@ function leadingWords(
|
|
|
220
336
|
return words;
|
|
221
337
|
}
|
|
222
338
|
|
|
223
|
-
/**
|
|
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
|
+
*/
|
|
224
360
|
export function scopeNameFromMasked(
|
|
225
361
|
masked: string,
|
|
226
362
|
open: number,
|
|
363
|
+
floor = 0,
|
|
227
364
|
): string | null {
|
|
228
365
|
const index = statementIndex(masked);
|
|
229
|
-
const start = (index.boundaries[open] ?? -1) + 1;
|
|
366
|
+
const start = Math.max((index.boundaries[open] ?? -1) + 1, floor);
|
|
230
367
|
const named = trailingName(masked, start, open);
|
|
231
368
|
if (named !== null) {
|
|
232
369
|
const name = named.toLowerCase();
|
|
@@ -242,6 +379,11 @@ export function scopeNameFromMasked(
|
|
|
242
379
|
}
|
|
243
380
|
return null;
|
|
244
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;
|
|
245
387
|
// `:do {`, `:retry {`, `:onerror Err {` — the directive may carry ONE bare
|
|
246
388
|
// error-variable word before the brace. A second token that is not an
|
|
247
389
|
// identifier (`:onerror [find] {`) is not this form, so the brace is a value.
|
|
@@ -258,12 +400,20 @@ export function scopeNameFromMasked(
|
|
|
258
400
|
return null;
|
|
259
401
|
}
|
|
260
402
|
|
|
261
|
-
/**
|
|
262
|
-
|
|
263
|
-
|
|
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;
|
|
264
414
|
const index = statementIndex(text);
|
|
265
415
|
const boundary = index.boundaries[open] ?? -1;
|
|
266
|
-
const start = boundary + 1;
|
|
416
|
+
const start = Math.max(boundary + 1, floor);
|
|
267
417
|
const named = trailingName(text, start, open);
|
|
268
418
|
if (named !== null) return SCRIPT_BODY_ARG.test(named.toLowerCase());
|
|
269
419
|
|
|
@@ -288,16 +438,19 @@ export function hashStartsHardError(
|
|
|
288
438
|
text: string,
|
|
289
439
|
at: number,
|
|
290
440
|
inExpression: boolean,
|
|
441
|
+
floor = 0,
|
|
291
442
|
): boolean {
|
|
292
443
|
if (inExpression) return true;
|
|
293
|
-
if (at <=
|
|
444
|
+
if (at <= floor || !isAsciiWhitespace(text[at - 1])) return false;
|
|
294
445
|
|
|
295
446
|
let previous = at - 1;
|
|
296
|
-
while (previous >=
|
|
297
|
-
|
|
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;
|
|
298
451
|
|
|
299
452
|
const index = statementIndex(text);
|
|
300
|
-
const start = (index.boundaries[at] ?? -1) + 1;
|
|
453
|
+
const start = Math.max((index.boundaries[at] ?? -1) + 1, floor);
|
|
301
454
|
const first = index.firstContent[at] ?? -1;
|
|
302
455
|
const words = first < start ? [] : leadingWords(text, first, at, 3, true);
|
|
303
456
|
const rawHead = (words[0] ?? "").toLowerCase();
|