@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
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
* certainty, because R4 has it REPLACE the context rather than extend it.
|
|
91
91
|
*/
|
|
92
92
|
|
|
93
|
-
import {
|
|
93
|
+
import { type ScopeBlock, scopeBlocksIn } from "./blocks.ts";
|
|
94
94
|
import { commandVerbIndex } from "./catalog.ts";
|
|
95
95
|
import {
|
|
96
96
|
type Defect,
|
|
@@ -101,13 +101,19 @@ import {
|
|
|
101
101
|
import { isKnownMenuPath } from "./is-known-menu.ts";
|
|
102
102
|
import {
|
|
103
103
|
braceStartsStatements,
|
|
104
|
+
canRetainStatementIndex,
|
|
104
105
|
hashStartsHardError,
|
|
105
106
|
scopeNameFromMasked,
|
|
106
107
|
} from "./scope-brace.ts";
|
|
107
108
|
import {
|
|
108
|
-
|
|
109
|
+
documentRange,
|
|
110
|
+
type MaskedRange,
|
|
111
|
+
nestedRange,
|
|
112
|
+
rangeAt,
|
|
109
113
|
type SegmentResult,
|
|
110
114
|
scanQuotedString,
|
|
115
|
+
scanStringInterpolation,
|
|
116
|
+
segmentScopeBody,
|
|
111
117
|
segmentStatements,
|
|
112
118
|
} from "./segment.ts";
|
|
113
119
|
import { VERBS } from "./verbs.ts";
|
|
@@ -159,6 +165,29 @@ function asciiWords(text: string): string[] {
|
|
|
159
165
|
return trimmed.length === 0 ? [] : trimmed.split(ASCII_WHITESPACE);
|
|
160
166
|
}
|
|
161
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The same words, yielded one at a time.
|
|
170
|
+
*
|
|
171
|
+
* For the readers that STOP at the first word which is not a path segment — the
|
|
172
|
+
* leading run, and the unreadable-menu test. A statement's text carries its
|
|
173
|
+
* whole nested `do={…}` subtree, so splitting all of it to look at the first
|
|
174
|
+
* two words was work repeated once per enclosing level (#322). Equivalent to
|
|
175
|
+
* {@link asciiWords} by construction: a trim followed by a split on runs of
|
|
176
|
+
* ASCII whitespace is exactly the maximal non-whitespace runs.
|
|
177
|
+
*/
|
|
178
|
+
function* asciiWordsIter(text: string): Generator<string> {
|
|
179
|
+
let i = 0;
|
|
180
|
+
while (i < text.length) {
|
|
181
|
+
if (isAsciiWhitespace(text[i])) {
|
|
182
|
+
i++;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const start = i;
|
|
186
|
+
while (i < text.length && !isAsciiWhitespace(text[i])) i++;
|
|
187
|
+
yield text.slice(start, i);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
162
191
|
function isAbsolutePathToken(token: string): boolean {
|
|
163
192
|
if (!token.startsWith("/")) return false;
|
|
164
193
|
const parts = token.split("/").filter(Boolean);
|
|
@@ -182,16 +211,87 @@ const MAX_DEPTH = 256;
|
|
|
182
211
|
* statement must never yield a confident command, so the resolver degrades it
|
|
183
212
|
* to `unresolved` and does not descend into it. Statement-local; the
|
|
184
213
|
* segmenter's document-level defects are surfaced separately on the envelope.
|
|
214
|
+
*
|
|
215
|
+
* Reads a RANGE of the document's own comment mask rather than masking the
|
|
216
|
+
* statement's text, which carries its whole nested subtree (#322).
|
|
185
217
|
*/
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
218
|
+
interface StructuralIndex {
|
|
219
|
+
wellNested: boolean;
|
|
220
|
+
hardHashes: number[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let recentStructuralText: string | undefined;
|
|
224
|
+
let recentStructuralIndex: StructuralIndex | undefined;
|
|
225
|
+
|
|
226
|
+
function structuralIndex(masked: string): StructuralIndex {
|
|
227
|
+
if (recentStructuralText === masked && recentStructuralIndex !== undefined)
|
|
228
|
+
return recentStructuralIndex;
|
|
229
|
+
|
|
230
|
+
let wellNested = true;
|
|
231
|
+
const hardHashes: number[] = [];
|
|
189
232
|
const openOf: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
|
|
190
233
|
const stack: { char: string; statements: boolean }[] = [];
|
|
191
234
|
for (let i = 0; i < masked.length; i++) {
|
|
192
235
|
const c = masked[i];
|
|
193
236
|
if (c === '"') {
|
|
194
237
|
const str = scanQuotedString(masked, i);
|
|
238
|
+
if (!str.closed) wellNested = false;
|
|
239
|
+
i = str.end - 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (
|
|
243
|
+
c === "#" &&
|
|
244
|
+
hashStartsHardError(masked, i, stack.at(-1)?.statements === false, 0)
|
|
245
|
+
)
|
|
246
|
+
hardHashes.push(i);
|
|
247
|
+
if (c === "(" || c === "[" || c === "{")
|
|
248
|
+
stack.push({
|
|
249
|
+
char: c,
|
|
250
|
+
statements:
|
|
251
|
+
c === "[" ||
|
|
252
|
+
(c === "{" &&
|
|
253
|
+
(stack.at(-1)?.statements ?? true) &&
|
|
254
|
+
braceStartsStatements(masked, i, 0)),
|
|
255
|
+
});
|
|
256
|
+
else if (c === ")" || c === "]" || c === "}") {
|
|
257
|
+
if (stack.pop()?.char !== openOf[c]) wellNested = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (stack.length > 0) wellNested = false;
|
|
261
|
+
const index = { wellNested, hardHashes };
|
|
262
|
+
if (canRetainStatementIndex(masked)) {
|
|
263
|
+
recentStructuralText = masked;
|
|
264
|
+
recentStructuralIndex = index;
|
|
265
|
+
}
|
|
266
|
+
return index;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function structuralDefectIn(range: MaskedRange): string | null {
|
|
270
|
+
// The document mask makes comments opaque before this shared topology pass.
|
|
271
|
+
const index = structuralIndex(range.masked);
|
|
272
|
+
if (!index.wellNested) return structuralDefectByScan(range);
|
|
273
|
+
const defects = index.hardHashes;
|
|
274
|
+
let lo = 0;
|
|
275
|
+
let hi = defects.length;
|
|
276
|
+
while (lo < hi) {
|
|
277
|
+
const mid = (lo + hi) >>> 1;
|
|
278
|
+
if ((defects[mid] as number) < range.start) lo = mid + 1;
|
|
279
|
+
else hi = mid;
|
|
280
|
+
}
|
|
281
|
+
const defect = defects[lo];
|
|
282
|
+
return defect !== undefined && defect < range.end
|
|
283
|
+
? "structural defect: invalid unquoted hash"
|
|
284
|
+
: null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function structuralDefectByScan(range: MaskedRange): string | null {
|
|
288
|
+
const { masked, start: lo, end: hi } = range;
|
|
289
|
+
const openOf: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
|
|
290
|
+
const stack: { char: string; statements: boolean }[] = [];
|
|
291
|
+
for (let i = lo; i < hi; i++) {
|
|
292
|
+
const c = masked[i];
|
|
293
|
+
if (c === '"') {
|
|
294
|
+
const str = scanQuotedString(masked, i, hi);
|
|
195
295
|
if (!str.closed)
|
|
196
296
|
return "structural defect: unbalanced delimiter or string";
|
|
197
297
|
i = str.end - 1;
|
|
@@ -199,7 +299,7 @@ function structuralDefect(text: string): string | null {
|
|
|
199
299
|
}
|
|
200
300
|
if (
|
|
201
301
|
c === "#" &&
|
|
202
|
-
hashStartsHardError(masked, i, stack.at(-1)?.statements === false)
|
|
302
|
+
hashStartsHardError(masked, i, stack.at(-1)?.statements === false, lo)
|
|
203
303
|
)
|
|
204
304
|
return "structural defect: invalid unquoted hash";
|
|
205
305
|
if (c === "(" || c === "[" || c === "{")
|
|
@@ -209,7 +309,7 @@ function structuralDefect(text: string): string | null {
|
|
|
209
309
|
c === "[" ||
|
|
210
310
|
(c === "{" &&
|
|
211
311
|
(stack.at(-1)?.statements ?? true) &&
|
|
212
|
-
braceStartsStatements(masked, i)),
|
|
312
|
+
braceStartsStatements(masked, i, lo)),
|
|
213
313
|
});
|
|
214
314
|
else if (c === ")" || c === "]" || c === "}") {
|
|
215
315
|
if (stack.pop()?.char !== openOf[c])
|
|
@@ -229,11 +329,14 @@ function structuralDefect(text: string): string | null {
|
|
|
229
329
|
const CONTEXT_LOST = "context lost to an earlier unreadable statement";
|
|
230
330
|
|
|
231
331
|
/** R11 — a directive written without its colon, told by its scope block. */
|
|
232
|
-
function isBareDirective(
|
|
332
|
+
function isBareDirective(text: string, range: MaskedRange): boolean {
|
|
333
|
+
const start = trimmedStart(text, 0, text.length);
|
|
334
|
+
const end = trimmedEnd(text, start, text.length);
|
|
335
|
+
const trimmed = text.slice(start, end);
|
|
233
336
|
return (
|
|
234
337
|
!trimmed.startsWith(":") &&
|
|
235
338
|
!trimmed.startsWith("/") &&
|
|
236
|
-
|
|
339
|
+
scopeBlocksIn(nestedRange(range, start, end)).length > 0
|
|
237
340
|
);
|
|
238
341
|
}
|
|
239
342
|
|
|
@@ -242,9 +345,9 @@ function isBareDirective(trimmed: string): boolean {
|
|
|
242
345
|
* context? Mirrors `canonicalPath`'s `base` exactly — absolute, `:` directive,
|
|
243
346
|
* or bare directive (R11) all resolve at the root.
|
|
244
347
|
*/
|
|
245
|
-
function isContextIndependent(text: string): boolean {
|
|
348
|
+
function isContextIndependent(text: string, range: MaskedRange): boolean {
|
|
246
349
|
const t = trimAscii(text);
|
|
247
|
-
return t.startsWith(":") || t.startsWith("/") || isBareDirective(
|
|
350
|
+
return t.startsWith(":") || t.startsWith("/") || isBareDirective(text, range);
|
|
248
351
|
}
|
|
249
352
|
|
|
250
353
|
/**
|
|
@@ -254,9 +357,9 @@ function isContextIndependent(text: string): boolean {
|
|
|
254
357
|
* at the root but hands its body the context in force (R5), so `:foreach … do={
|
|
255
358
|
* add … }` still depends on an upstream context its own path does not.
|
|
256
359
|
*/
|
|
257
|
-
function isBodyContextIndependent(text: string): boolean {
|
|
360
|
+
function isBodyContextIndependent(text: string, range: MaskedRange): boolean {
|
|
258
361
|
const t = trimAscii(text);
|
|
259
|
-
return t.startsWith("/") || isBareDirective(
|
|
362
|
+
return t.startsWith("/") || isBareDirective(text, range);
|
|
260
363
|
}
|
|
261
364
|
|
|
262
365
|
/**
|
|
@@ -287,7 +390,7 @@ function isUnreadablePath(text: string): boolean {
|
|
|
287
390
|
if (t.length === 0) return false;
|
|
288
391
|
// A head that is not path-shaped names no menu.
|
|
289
392
|
if (/^[$[("':]/.test(t)) return false;
|
|
290
|
-
for (const word of
|
|
393
|
+
for (const word of asciiWordsIter(t)) {
|
|
291
394
|
// Arguments begin: everything before this was readable. A `$` word
|
|
292
395
|
// STANDING ALONE is a positional operand (`get $item time`), not a path
|
|
293
396
|
// segment — only a `$` slash-joined inside a word is a menu segment.
|
|
@@ -340,12 +443,16 @@ function isUnreadableAbsolute(text: string): boolean {
|
|
|
340
443
|
* anchors nothing, in EITHER spelling: `statementPath` derives a base from a
|
|
341
444
|
* relative one too (`route/$verb remove [find]` yields `<ctx>/route/$verb`).
|
|
342
445
|
*/
|
|
343
|
-
function handsKnownContext(
|
|
446
|
+
function handsKnownContext(
|
|
447
|
+
text: string,
|
|
448
|
+
contextCertain: boolean,
|
|
449
|
+
range: MaskedRange,
|
|
450
|
+
): boolean {
|
|
344
451
|
// Either spelling of an unreadable menu anchors nothing. `statementPath`
|
|
345
452
|
// derives a base from a RELATIVE one too (`route/$verb remove [find]` yields
|
|
346
453
|
// `<ctx>/route/$verb`), so this cannot test only the absolute form.
|
|
347
454
|
if (isUnreadableAbsolute(text) || isUnreadablePath(text)) return false;
|
|
348
|
-
return contextCertain || isBodyContextIndependent(text);
|
|
455
|
+
return contextCertain || isBodyContextIndependent(text, range);
|
|
349
456
|
}
|
|
350
457
|
|
|
351
458
|
/** A re-constituted `[…]` command substitution (Q3). */
|
|
@@ -381,6 +488,30 @@ export interface Resolution {
|
|
|
381
488
|
* {@link Loc}.
|
|
382
489
|
*/
|
|
383
490
|
span: Span;
|
|
491
|
+
/**
|
|
492
|
+
* Where {@link inner} itself sits, in the same document byte space.
|
|
493
|
+
*
|
|
494
|
+
* Not derivable from `span`: the two bracket spellings do not agree on what
|
|
495
|
+
* `span` covers. `collectBrackets` spans `[…]`, so `inner` starts one byte
|
|
496
|
+
* in; `scanInterpolations` spans `$[…]` **from the sigil** (that `$` is part
|
|
497
|
+
* of the substitution), so it starts two. `inner` is also trimmed, so any
|
|
498
|
+
* leading whitespace shifts it further right again. A consumer that guessed
|
|
499
|
+
* `span.start + 1` therefore read one byte early on every interpolated
|
|
500
|
+
* substitution and, finding the text did not match, silently dropped the
|
|
501
|
+
* resolution — which is how a `$[…]` inside a string lost its path tokens to
|
|
502
|
+
* the string fill (#325). Widens with `span` when interior offsets cannot be
|
|
503
|
+
* mapped, so a consumer must still check the slice against `inner`.
|
|
504
|
+
*/
|
|
505
|
+
innerSpan: Span;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const resolutionRanges = new WeakMap<Resolution, MaskedRange>();
|
|
509
|
+
|
|
510
|
+
/** The exact masked view used to read one resolution's inner text. */
|
|
511
|
+
export function rangeForResolution(
|
|
512
|
+
resolution: Resolution,
|
|
513
|
+
): MaskedRange | undefined {
|
|
514
|
+
return resolutionRanges.get(resolution);
|
|
384
515
|
}
|
|
385
516
|
|
|
386
517
|
/** The canonical path of one statement in source order (Q4). */
|
|
@@ -505,6 +636,12 @@ interface Located {
|
|
|
505
636
|
text: string;
|
|
506
637
|
span: Span;
|
|
507
638
|
loc: Loc;
|
|
639
|
+
/**
|
|
640
|
+
* This statement as a range of the document, with the document's comment
|
|
641
|
+
* mask. Every reader that would otherwise re-mask `text` — which carries the
|
|
642
|
+
* whole nested subtree — takes this instead (#322).
|
|
643
|
+
*/
|
|
644
|
+
range: MaskedRange;
|
|
508
645
|
}
|
|
509
646
|
|
|
510
647
|
/**
|
|
@@ -517,6 +654,7 @@ interface Located {
|
|
|
517
654
|
function locate(
|
|
518
655
|
segmented: SegmentResult,
|
|
519
656
|
loc: Loc,
|
|
657
|
+
parent: MaskedRange | null,
|
|
520
658
|
): { units: Located[]; defects: Defect[] } {
|
|
521
659
|
const units = segmented.segments.map((s) => {
|
|
522
660
|
const span = spanIn(loc, s.start, s.end);
|
|
@@ -531,6 +669,15 @@ function locate(
|
|
|
531
669
|
loc.base < 0 || !asciiOnly
|
|
532
670
|
? { base: -1, fallback: span }
|
|
533
671
|
: { base: loc.base + s.start, fallback: span },
|
|
672
|
+
// A statement is a range of its parent whenever the parent offered one
|
|
673
|
+
// AND the text just segmented was pure ASCII — the only case in which
|
|
674
|
+
// `s.start`/`s.end`, which are analyzed BYTES, are also JS string
|
|
675
|
+
// indices into it. Decided per level, so one non-ASCII body drops just
|
|
676
|
+
// its own subtree back to a mask of its own, exactly as before (#322).
|
|
677
|
+
range:
|
|
678
|
+
parent !== null && segmented.ascii
|
|
679
|
+
? nestedRange(parent, s.start, s.end)
|
|
680
|
+
: documentRange(s.text),
|
|
534
681
|
};
|
|
535
682
|
});
|
|
536
683
|
// STRUCTURAL defects only. `segmentStatements` merges `coordinateDefects`
|
|
@@ -553,6 +700,30 @@ function locate(
|
|
|
553
700
|
*/
|
|
554
701
|
const DOCUMENT_LOC: Loc = { base: 0, fallback: { start: 0, end: 0 } };
|
|
555
702
|
|
|
703
|
+
/**
|
|
704
|
+
* The document-wide masked range a walk descends through, or null.
|
|
705
|
+
*
|
|
706
|
+
* Null for a non-ASCII document: the walk's offsets are analyzed BYTES, and
|
|
707
|
+
* only in an all-ASCII document is such an offset also a JS string index. There
|
|
708
|
+
* every level re-derives its own mask, which is what every level did before
|
|
709
|
+
* (#322) — the anchored reading is a fast path, never a second answer.
|
|
710
|
+
*/
|
|
711
|
+
function rootRange(text: string, segmented: SegmentResult): MaskedRange | null {
|
|
712
|
+
return segmented.ascii ? documentRange(text, true) : null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* A scope body as a range of the statement that carries it.
|
|
717
|
+
*
|
|
718
|
+
* `ScopeBlock.start` is a JS index into the statement's text by construction —
|
|
719
|
+
* `scopeBlocksIn` walks the mask, which is code-unit-for-code-unit with it — so
|
|
720
|
+
* this needs no ASCII test of its own. Whether the body's own STATEMENTS may
|
|
721
|
+
* then be nested is decided by `locate`, against that body's segmentation.
|
|
722
|
+
*/
|
|
723
|
+
function bodyRange(range: MaskedRange, block: ScopeBlock): MaskedRange {
|
|
724
|
+
return nestedRange(range, block.start, block.start + block.body.length);
|
|
725
|
+
}
|
|
726
|
+
|
|
556
727
|
/** Q3 — resolve every `[…]` command substitution in `text`. */
|
|
557
728
|
export function resolveDocument(text: string): DocumentAnalysis {
|
|
558
729
|
// Segment ONCE and hand the result to `locate` — `containsWrite` calls both
|
|
@@ -564,7 +735,7 @@ export function resolveDocument(text: string): DocumentAnalysis {
|
|
|
564
735
|
// substring's coordinate space.
|
|
565
736
|
const defects: Defect[] = [...segmented.defects];
|
|
566
737
|
const resolutions: Resolution[] = [];
|
|
567
|
-
const top = locate(segmented, DOCUMENT_LOC);
|
|
738
|
+
const top = locate(segmented, DOCUMENT_LOC, rootRange(text, segmented));
|
|
568
739
|
defects.push(...top.defects);
|
|
569
740
|
walk(top.units, "/", resolutions, 0, defects, true);
|
|
570
741
|
return { resolutions, defects: mergeDefects(defects) };
|
|
@@ -581,9 +752,9 @@ function walk(
|
|
|
581
752
|
let ctx = context;
|
|
582
753
|
let certain = contextCertain;
|
|
583
754
|
for (const unit of units) {
|
|
584
|
-
const { text, loc } = unit;
|
|
755
|
+
const { text, loc, range } = unit;
|
|
585
756
|
// R4 — a menu-navigation statement moves the document context.
|
|
586
|
-
const nav = menuNavPath(text, ctx);
|
|
757
|
+
const nav = menuNavPath(text, ctx, range);
|
|
587
758
|
if (nav !== null) {
|
|
588
759
|
const relative = !trimAscii(text).startsWith("/");
|
|
589
760
|
// A `..` read against an unknown context stays unknown; an absolute
|
|
@@ -597,26 +768,26 @@ function walk(
|
|
|
597
768
|
// bracket path and is not descended. It may have been a navigation, so the
|
|
598
769
|
// context after it is unknown (Q14 C3b, the same contract the statement
|
|
599
770
|
// walk applies — these two walks must move in lockstep).
|
|
600
|
-
if (
|
|
771
|
+
if (structuralDefectIn(range) !== null) {
|
|
601
772
|
certain = false;
|
|
602
773
|
continue;
|
|
603
774
|
}
|
|
604
775
|
// #235 — a relative bare-word that abstained (neither table) poisons
|
|
605
776
|
// downstream context, so the next relative statement cannot fabricate.
|
|
606
|
-
const poisonAbstention = isAbstainedRelativeNav(text, ctx);
|
|
777
|
+
const poisonAbstention = isAbstainedRelativeNav(text, ctx, range);
|
|
607
778
|
// R3 — the statement's own path is the context its brackets see.
|
|
608
|
-
const stmtCtx = statementPath(text, ctx);
|
|
779
|
+
const stmtCtx = statementPath(text, ctx, range);
|
|
609
780
|
// Derived BEFORE the poison, from the certainty in force at this statement:
|
|
610
781
|
// `handsKnownContext` answers about the context this statement HANDS DOWN,
|
|
611
782
|
// which its own unreadability already governs. An abstained nav poisons
|
|
612
783
|
// what follows, so its own body must already be uncertain.
|
|
613
784
|
const stmtCertain = poisonAbstention
|
|
614
785
|
? false
|
|
615
|
-
: handsKnownContext(text, certain);
|
|
786
|
+
: handsKnownContext(text, certain, range);
|
|
616
787
|
if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
|
|
617
|
-
collectBrackets(
|
|
788
|
+
collectBrackets(range, stmtCtx, 0, out, defects, stmtCertain, loc);
|
|
618
789
|
// R5 — block bodies inherit the context in force here.
|
|
619
|
-
for (const block of
|
|
790
|
+
for (const block of scopeBlocksIn(range)) {
|
|
620
791
|
if (blockDepth >= MAX_DEPTH) {
|
|
621
792
|
// The BLOCK that exceeded the limit, not the whole statement —
|
|
622
793
|
// narrowing regions is what this channel is for.
|
|
@@ -627,7 +798,11 @@ function walk(
|
|
|
627
798
|
continue;
|
|
628
799
|
}
|
|
629
800
|
const bodyLoc = nest(loc, block.start, unit.span);
|
|
630
|
-
const body = locate(
|
|
801
|
+
const body = locate(
|
|
802
|
+
segmentScopeBody(block.body, bodyRange(range, block)),
|
|
803
|
+
bodyLoc,
|
|
804
|
+
bodyRange(range, block),
|
|
805
|
+
);
|
|
631
806
|
defects.push(...body.defects);
|
|
632
807
|
walk(body.units, stmtCtx, out, blockDepth + 1, defects, stmtCertain);
|
|
633
808
|
}
|
|
@@ -640,7 +815,7 @@ export function resolveStatements(text: string): StatementAnalysis {
|
|
|
640
815
|
// As `resolveDocument`: coordinate facts come from the root only.
|
|
641
816
|
const defects: Defect[] = [...segmented.defects];
|
|
642
817
|
const statements: StatementResolution[] = [];
|
|
643
|
-
const top = locate(segmented, DOCUMENT_LOC);
|
|
818
|
+
const top = locate(segmented, DOCUMENT_LOC, rootRange(text, segmented));
|
|
644
819
|
defects.push(...top.defects);
|
|
645
820
|
walkStatements(top.units, "/", statements, 0, defects, true);
|
|
646
821
|
return { statements, defects: mergeDefects(defects) };
|
|
@@ -657,8 +832,8 @@ function walkStatements(
|
|
|
657
832
|
let ctx = context;
|
|
658
833
|
let certain = contextCertain;
|
|
659
834
|
for (const unit of units) {
|
|
660
|
-
const { text, span } = unit;
|
|
661
|
-
const nav = menuNavPath(text, ctx);
|
|
835
|
+
const { text, span, range } = unit;
|
|
836
|
+
const nav = menuNavPath(text, ctx, range);
|
|
662
837
|
if (nav !== null) {
|
|
663
838
|
// `..` ascends FROM the context, so it cannot be read while the context
|
|
664
839
|
// is unknown. A `/`-led navigation (and a bare `/`) REPLACES it (R4) and
|
|
@@ -691,7 +866,7 @@ function walkStatements(
|
|
|
691
866
|
// Q14 fail-closed — a malformed statement degrades to unresolved and is
|
|
692
867
|
// not descended; it does not move the context either. It may however have
|
|
693
868
|
// BEEN a navigation, so what the context now is becomes unknown.
|
|
694
|
-
const defectReason =
|
|
869
|
+
const defectReason = structuralDefectIn(range);
|
|
695
870
|
if (defectReason !== null) {
|
|
696
871
|
out.push({
|
|
697
872
|
text,
|
|
@@ -708,17 +883,19 @@ function walkStatements(
|
|
|
708
883
|
// #235 — a relative bare-word that abstained (neither table) poisons
|
|
709
884
|
// downstream context. Capture before the push so the current statement's
|
|
710
885
|
// own `contextCertain` stays true while the following statements lose it.
|
|
711
|
-
const poisonAbstention = isAbstainedRelativeNav(text, ctx);
|
|
886
|
+
const poisonAbstention = isAbstainedRelativeNav(text, ctx, range);
|
|
712
887
|
// Q14 C3b — the cascade. A statement that CONSUMES the context cannot be
|
|
713
888
|
// resolved against a value the resolver has already admitted it lost.
|
|
714
889
|
// Applied to the RESOLUTION, not ahead of it, so a statement that would
|
|
715
890
|
// have refused on its own terms keeps its own — more specific — reason.
|
|
716
|
-
const resolved = canonicalPath(text, ctx);
|
|
891
|
+
const resolved = canonicalPath(text, ctx, range);
|
|
717
892
|
out.push({
|
|
718
893
|
text,
|
|
719
894
|
isNav: false,
|
|
720
895
|
context: ctx,
|
|
721
|
-
...(resolved.path !== null &&
|
|
896
|
+
...(resolved.path !== null &&
|
|
897
|
+
!certain &&
|
|
898
|
+
!isContextIndependent(text, range)
|
|
722
899
|
? { path: null, unresolved: CONTEXT_LOST }
|
|
723
900
|
: resolved),
|
|
724
901
|
contextCertain: certain,
|
|
@@ -729,12 +906,12 @@ function walkStatements(
|
|
|
729
906
|
// knowable, when the statement spells its path out or is a bare directive,
|
|
730
907
|
// and unknowable when the statement's own path could not be read. Same
|
|
731
908
|
// predicate and same order as the bracket walk, per the lockstep rule.
|
|
732
|
-
const stmtCtx = statementPath(text, ctx);
|
|
909
|
+
const stmtCtx = statementPath(text, ctx, range);
|
|
733
910
|
const bodyCertain = poisonAbstention
|
|
734
911
|
? false
|
|
735
|
-
: handsKnownContext(text, certain);
|
|
912
|
+
: handsKnownContext(text, certain, range);
|
|
736
913
|
if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
|
|
737
|
-
for (const block of
|
|
914
|
+
for (const block of scopeBlocksIn(range)) {
|
|
738
915
|
if (blockDepth >= MAX_DEPTH) {
|
|
739
916
|
defects.push({
|
|
740
917
|
code: "over-depth",
|
|
@@ -743,7 +920,11 @@ function walkStatements(
|
|
|
743
920
|
continue;
|
|
744
921
|
}
|
|
745
922
|
const bodyLoc = nest(unit.loc, block.start, span);
|
|
746
|
-
const body = locate(
|
|
923
|
+
const body = locate(
|
|
924
|
+
segmentScopeBody(block.body, bodyRange(range, block)),
|
|
925
|
+
bodyLoc,
|
|
926
|
+
bodyRange(range, block),
|
|
927
|
+
);
|
|
747
928
|
defects.push(...body.defects);
|
|
748
929
|
walkStatements(
|
|
749
930
|
body.units,
|
|
@@ -801,7 +982,11 @@ function walkStatements(
|
|
|
801
982
|
* now disagree CONFIDENTLY rather than one of them refusing. The same test pins
|
|
802
983
|
* the invariant for both rules.
|
|
803
984
|
*/
|
|
804
|
-
function menuNavPath(
|
|
985
|
+
function menuNavPath(
|
|
986
|
+
text: string,
|
|
987
|
+
ctx: string,
|
|
988
|
+
range: MaskedRange,
|
|
989
|
+
): string | null {
|
|
805
990
|
const trimmed = trimAscii(text);
|
|
806
991
|
if (trimmed === "/") return "/";
|
|
807
992
|
if (/^\.\.(?:[ \t\r\n]|$)/.test(trimmed)) {
|
|
@@ -811,7 +996,7 @@ function menuNavPath(text: string, ctx: string): string | null {
|
|
|
811
996
|
}
|
|
812
997
|
if (!trimmed.startsWith("/")) {
|
|
813
998
|
// #235 R13 — relative bare-word menu navigation.
|
|
814
|
-
const reading = readRelativeBare(text, ctx);
|
|
999
|
+
const reading = readRelativeBare(text, ctx, range);
|
|
815
1000
|
return reading?.kind === "nav" ? reading.path : null;
|
|
816
1001
|
}
|
|
817
1002
|
if (/[=[({"$]/.test(trimmed)) return null;
|
|
@@ -856,6 +1041,7 @@ type RelativeBareReading = { kind: "nav"; path: string } | { kind: "abstain" };
|
|
|
856
1041
|
function readRelativeBare(
|
|
857
1042
|
text: string,
|
|
858
1043
|
ctx: string,
|
|
1044
|
+
range: MaskedRange,
|
|
859
1045
|
): RelativeBareReading | null {
|
|
860
1046
|
const trimmed = trimAscii(text);
|
|
861
1047
|
if (
|
|
@@ -865,7 +1051,7 @@ function readRelativeBare(
|
|
|
865
1051
|
trimmed.startsWith("$") ||
|
|
866
1052
|
trimmed.startsWith("[") ||
|
|
867
1053
|
trimmed.startsWith("(") ||
|
|
868
|
-
isBareDirective(
|
|
1054
|
+
isBareDirective(text, range)
|
|
869
1055
|
)
|
|
870
1056
|
return null;
|
|
871
1057
|
// Whitespace and `/` are interchangeable on the device, so the run is read
|
|
@@ -893,13 +1079,18 @@ function readRelativeBare(
|
|
|
893
1079
|
* Presence in a table → nav; absence → abstain → poison downstream, so the
|
|
894
1080
|
* next relative statement cannot resolve against a context that may have moved.
|
|
895
1081
|
*/
|
|
896
|
-
function isAbstainedRelativeNav(
|
|
897
|
-
|
|
1082
|
+
function isAbstainedRelativeNav(
|
|
1083
|
+
text: string,
|
|
1084
|
+
ctx: string,
|
|
1085
|
+
range: MaskedRange,
|
|
1086
|
+
): boolean {
|
|
1087
|
+
return readRelativeBare(text, ctx, range)?.kind === "abstain";
|
|
898
1088
|
}
|
|
899
1089
|
|
|
900
1090
|
function canonicalPath(
|
|
901
1091
|
text: string,
|
|
902
1092
|
ctx: string,
|
|
1093
|
+
range: MaskedRange,
|
|
903
1094
|
): { path: string | null; candidates?: string[]; unresolved?: string } {
|
|
904
1095
|
const t = trimAscii(text);
|
|
905
1096
|
if (t.startsWith("$") || t.startsWith("[") || t.startsWith("("))
|
|
@@ -913,10 +1104,11 @@ function canonicalPath(
|
|
|
913
1104
|
// R11 — a scripting directive written WITHOUT its colon is still at the root.
|
|
914
1105
|
// A statement that carries a SCOPE-valued block is a directive, because no
|
|
915
1106
|
// menu command takes one — a schema-free tell.
|
|
916
|
-
const bareDirective = isBareDirective(
|
|
1107
|
+
const bareDirective = isBareDirective(text, range);
|
|
917
1108
|
// The cascade contract reads this same split to decide which statements may
|
|
918
1109
|
// keep resolving once the context is lost, so both must come from one place.
|
|
919
|
-
const base =
|
|
1110
|
+
const base =
|
|
1111
|
+
t.startsWith(":") || t.startsWith("/") || bareDirective ? "/" : ctx;
|
|
920
1112
|
// R7 — a variable segment anywhere in the leading run leaves the path
|
|
921
1113
|
// unresolved, INCLUDING after a static word. `statementRun` drops the whole
|
|
922
1114
|
// word carrying the variable, so `/ip route/$menu/remove` would otherwise
|
|
@@ -950,7 +1142,7 @@ function canonicalPath(
|
|
|
950
1142
|
*/
|
|
951
1143
|
function statementRun(text: string): string[] {
|
|
952
1144
|
const out: string[] = [];
|
|
953
|
-
for (const t of
|
|
1145
|
+
for (const t of asciiWordsIter(text)) {
|
|
954
1146
|
if (t.includes("=") || /^[[({"$:]/.test(t)) break;
|
|
955
1147
|
const parts = t.split("/").filter((p) => p.length > 0);
|
|
956
1148
|
if (parts.length === 0) continue;
|
|
@@ -965,22 +1157,22 @@ function statementRun(text: string): string[] {
|
|
|
965
1157
|
* [find …]` that is `/ip/route`: the leading path run minus its last token,
|
|
966
1158
|
* which is the verb (Q6's reading, right ~93.7% of the time).
|
|
967
1159
|
*/
|
|
968
|
-
function statementPath(text: string, ctx: string): string {
|
|
1160
|
+
function statementPath(text: string, ctx: string, range: MaskedRange): string {
|
|
969
1161
|
const t = trimAsciiStart(text);
|
|
970
1162
|
// R11 — a bare directive is at the root, so the context it hands its body is
|
|
971
1163
|
// the root too, not `<ctx>/while`.
|
|
972
|
-
if (isBareDirective(
|
|
1164
|
+
if (isBareDirective(text, range)) return "/";
|
|
973
1165
|
const lead = leadingRun(text);
|
|
974
1166
|
if (lead.length === 0) return ctx;
|
|
975
1167
|
// Same split `isBodyContextIndependent` reports to the cascade contract.
|
|
976
|
-
const base =
|
|
1168
|
+
const base = t.startsWith("/") ? "/" : ctx;
|
|
977
1169
|
return joinPath(base, lead.slice(0, -1).join("/"));
|
|
978
1170
|
}
|
|
979
1171
|
|
|
980
1172
|
/** Leading bare/path tokens, stopping at the first argument or group. */
|
|
981
1173
|
function leadingRun(text: string): string[] {
|
|
982
1174
|
const out: string[] = [];
|
|
983
|
-
for (const t of
|
|
1175
|
+
for (const t of asciiWordsIter(text)) {
|
|
984
1176
|
if (t.includes("=") || /^[[({"$:]/.test(t)) break;
|
|
985
1177
|
for (const part of t.split("/")) if (part.length > 0) out.push(part);
|
|
986
1178
|
if (out.length === 0 && t === "/") out.push("");
|
|
@@ -1001,8 +1193,21 @@ function joinPath(base: string, rest: string): string {
|
|
|
1001
1193
|
return `/${stack.join("/")}`;
|
|
1002
1194
|
}
|
|
1003
1195
|
|
|
1196
|
+
/**
|
|
1197
|
+
* Q3's bracket scan over one range of a document.
|
|
1198
|
+
*
|
|
1199
|
+
* Reads `range.masked` and never `range.text`: comments hold no valid inner
|
|
1200
|
+
* command (`#` mid-`[…]` is not statement-leading), so an inner command is the
|
|
1201
|
+
* same text in either view, and the recursion already descended into masked
|
|
1202
|
+
* slices before this took a range. What changed is who masks — the caller hands
|
|
1203
|
+
* down the document's one mask instead of each level re-deriving its own over a
|
|
1204
|
+
* subtree an inner level has already read (#322).
|
|
1205
|
+
*
|
|
1206
|
+
* Offsets are absolute, while `loc` stays anchored at the range start, so every
|
|
1207
|
+
* `nest`/`spanIn` here passes a range-relative index.
|
|
1208
|
+
*/
|
|
1004
1209
|
function collectBrackets(
|
|
1005
|
-
|
|
1210
|
+
range: MaskedRange,
|
|
1006
1211
|
ctx: string,
|
|
1007
1212
|
depth: number,
|
|
1008
1213
|
out: Resolution[],
|
|
@@ -1011,33 +1216,30 @@ function collectBrackets(
|
|
|
1011
1216
|
loc: Loc,
|
|
1012
1217
|
inLiteral = false,
|
|
1013
1218
|
): void {
|
|
1219
|
+
const { masked, start: lo, end: hi } = range;
|
|
1014
1220
|
// Bound the recursion (bracket nesting AND literal-brace descent) so
|
|
1015
1221
|
// untrusted deeply nested input abstains instead of overflowing the stack.
|
|
1016
1222
|
if (depth >= MAX_DEPTH) {
|
|
1017
|
-
//
|
|
1018
|
-
// which `regionIn` widens rather than emitting a zero-width
|
|
1019
|
-
defects.push({ code: "over-depth", ...regionIn(loc, 0,
|
|
1223
|
+
// The region is empty when the nesting bottoms out on an empty body
|
|
1224
|
+
// (`[]`), which `regionIn` widens rather than emitting a zero-width one.
|
|
1225
|
+
defects.push({ code: "over-depth", ...regionIn(loc, 0, hi - lo) });
|
|
1020
1226
|
return;
|
|
1021
1227
|
}
|
|
1022
|
-
|
|
1023
|
-
// substitution or scope. Comments never hold valid inner commands (`#` mid
|
|
1024
|
-
// `[…]` is not statement-leading), so masked slices equal the originals.
|
|
1025
|
-
const masked = maskComments(text);
|
|
1026
|
-
for (let i = 0; i < masked.length; i++) {
|
|
1228
|
+
for (let i = lo; i < hi; i++) {
|
|
1027
1229
|
const c = masked[i];
|
|
1028
1230
|
if (c === '"') {
|
|
1029
1231
|
// A string is opaque EXCEPT for `$[ … ]` interpolation, a real command
|
|
1030
1232
|
// substitution: `:put "$[:pick $ip 0 $n]"` lowers with `/pick` inside
|
|
1031
1233
|
// (topic-…/post-0003-snippet-01 @ 7.22.1).
|
|
1032
|
-
const strEnd = stringEnd(masked, i);
|
|
1234
|
+
const strEnd = stringEnd(masked, i, hi);
|
|
1033
1235
|
scanInterpolations(
|
|
1034
|
-
|
|
1236
|
+
rangeAt(range, i + 1, strEnd),
|
|
1035
1237
|
ctx,
|
|
1036
1238
|
depth,
|
|
1037
1239
|
out,
|
|
1038
1240
|
defects,
|
|
1039
1241
|
contextCertain,
|
|
1040
|
-
nest(loc, i + 1, loc.fallback),
|
|
1242
|
+
nest(loc, i + 1 - lo, loc.fallback),
|
|
1041
1243
|
);
|
|
1042
1244
|
i = strEnd;
|
|
1043
1245
|
continue;
|
|
@@ -1046,69 +1248,98 @@ function collectBrackets(
|
|
|
1046
1248
|
// A literal body is a value (Q2), but can still contain command
|
|
1047
1249
|
// substitutions: `{[/terminal/inkey]}` lowers the bracket. Descend for
|
|
1048
1250
|
// brackets only; scope bodies are walked by walk().
|
|
1049
|
-
const end = matchDelim(masked, i, "{", "}");
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
if (!isScope && (body.includes("[") || body.includes("{")))
|
|
1251
|
+
const end = matchDelim(masked, i, "{", "}", hi);
|
|
1252
|
+
const isScope = !inLiteral && scopeNameFromMasked(masked, i, lo) !== null;
|
|
1253
|
+
if (!isScope && hasBracketOrBrace(masked, i + 1, end))
|
|
1053
1254
|
collectBrackets(
|
|
1054
|
-
|
|
1255
|
+
rangeAt(range, i + 1, end),
|
|
1055
1256
|
ctx,
|
|
1056
1257
|
depth + 1,
|
|
1057
1258
|
out,
|
|
1058
1259
|
defects,
|
|
1059
1260
|
contextCertain,
|
|
1060
|
-
nest(loc, i + 1, loc.fallback),
|
|
1261
|
+
nest(loc, i + 1 - lo, loc.fallback),
|
|
1061
1262
|
true,
|
|
1062
1263
|
);
|
|
1063
1264
|
i = end;
|
|
1064
1265
|
continue;
|
|
1065
1266
|
}
|
|
1066
1267
|
if (c !== "[") continue;
|
|
1067
|
-
const end = matchDelim(masked, i, "[", "]");
|
|
1068
|
-
|
|
1268
|
+
const end = matchDelim(masked, i, "[", "]", hi);
|
|
1269
|
+
// `inner` is the trimmed region of `masked` starting at i+1; the leading
|
|
1270
|
+
// whitespace trim shifts it further right.
|
|
1271
|
+
const innerAt = trimmedStart(masked, i + 1, end);
|
|
1272
|
+
const innerEnd = trimmedEnd(masked, innerAt, end);
|
|
1273
|
+
const inner = masked.slice(innerAt, innerEnd);
|
|
1069
1274
|
// The span covers the whole `[…]`, closing bracket included.
|
|
1070
|
-
|
|
1275
|
+
const resolution: Resolution = {
|
|
1071
1276
|
...resolveInner(inner, ctx, depth, contextCertain),
|
|
1072
|
-
span: spanIn(loc, i, Math.min(end + 1,
|
|
1073
|
-
|
|
1277
|
+
span: spanIn(loc, i - lo, Math.min(end + 1, hi) - lo),
|
|
1278
|
+
innerSpan: spanIn(loc, innerAt - lo, innerEnd - lo),
|
|
1279
|
+
};
|
|
1280
|
+
out.push(resolution);
|
|
1281
|
+
resolutionRanges.set(resolution, rangeAt(range, innerAt, innerEnd));
|
|
1074
1282
|
// R6 — nested brackets inherit from this one's resolution.
|
|
1075
|
-
const nestedCtx =
|
|
1283
|
+
const nestedCtx = resolution.path ?? ctx;
|
|
1076
1284
|
collectBrackets(
|
|
1077
|
-
|
|
1285
|
+
rangeAt(range, innerAt, innerEnd),
|
|
1078
1286
|
nestedCtx,
|
|
1079
1287
|
depth + 1,
|
|
1080
1288
|
out,
|
|
1081
1289
|
defects,
|
|
1082
|
-
nestedCertainty(
|
|
1083
|
-
|
|
1084
|
-
// whitespace trim shifts it further right.
|
|
1085
|
-
nest(
|
|
1086
|
-
loc,
|
|
1087
|
-
i +
|
|
1088
|
-
1 +
|
|
1089
|
-
(masked.slice(i + 1, end).length -
|
|
1090
|
-
trimAsciiStart(masked.slice(i + 1, end)).length),
|
|
1091
|
-
loc.fallback,
|
|
1092
|
-
),
|
|
1290
|
+
nestedCertainty(resolution, contextCertain),
|
|
1291
|
+
nest(loc, innerAt - lo, loc.fallback),
|
|
1093
1292
|
);
|
|
1094
1293
|
i = end;
|
|
1095
1294
|
}
|
|
1096
1295
|
}
|
|
1097
1296
|
|
|
1297
|
+
/** First non-ASCII-whitespace index in `[start, end)`, or `end`. */
|
|
1298
|
+
function trimmedStart(text: string, start: number, end: number): number {
|
|
1299
|
+
let i = start;
|
|
1300
|
+
while (i < end && isAsciiWhitespace(text[i])) i++;
|
|
1301
|
+
return i;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** One past the last non-ASCII-whitespace index in `[start, end)`. */
|
|
1305
|
+
function trimmedEnd(text: string, start: number, end: number): number {
|
|
1306
|
+
let i = end;
|
|
1307
|
+
while (i > start && isAsciiWhitespace(text[i - 1])) i--;
|
|
1308
|
+
return i;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/** Whether `[start, end)` holds a `[` or a `{` — the literal-descent test. */
|
|
1312
|
+
function hasBracketOrBrace(text: string, start: number, end: number): boolean {
|
|
1313
|
+
for (let i = start; i < end; i++) {
|
|
1314
|
+
const c = text[i];
|
|
1315
|
+
if (c === "[" || c === "{") return true;
|
|
1316
|
+
}
|
|
1317
|
+
return false;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1098
1320
|
/**
|
|
1099
1321
|
* Index of the closing quote of the string starting at `open`. Delegates to the
|
|
1100
1322
|
* shared `scanQuotedString`, so a `$[…]` substitution carrying its own string
|
|
1101
1323
|
* does not end the outer one early and cut `scanInterpolations`' body short
|
|
1102
1324
|
* (#199).
|
|
1103
1325
|
*/
|
|
1104
|
-
function stringEnd(
|
|
1105
|
-
|
|
1106
|
-
|
|
1326
|
+
function stringEnd(
|
|
1327
|
+
text: string,
|
|
1328
|
+
open: number,
|
|
1329
|
+
limit: number = text.length,
|
|
1330
|
+
): number {
|
|
1331
|
+
const str = scanQuotedString(text, open, limit);
|
|
1332
|
+
return str.closed ? str.end - 1 : limit;
|
|
1107
1333
|
}
|
|
1108
1334
|
|
|
1109
|
-
/**
|
|
1335
|
+
/**
|
|
1336
|
+
* `$[ … ]` command substitutions inside the string body `[lo, hi)`.
|
|
1337
|
+
*
|
|
1338
|
+
* As {@link collectBrackets}: offsets are absolute into the document mask and
|
|
1339
|
+
* `loc` is anchored at `lo`.
|
|
1340
|
+
*/
|
|
1110
1341
|
function scanInterpolations(
|
|
1111
|
-
|
|
1342
|
+
range: MaskedRange,
|
|
1112
1343
|
ctx: string,
|
|
1113
1344
|
depth: number,
|
|
1114
1345
|
out: Resolution[],
|
|
@@ -1116,28 +1347,41 @@ function scanInterpolations(
|
|
|
1116
1347
|
contextCertain: boolean,
|
|
1117
1348
|
loc: Loc,
|
|
1118
1349
|
): void {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
const
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1350
|
+
const { text, start: lo, end: hi } = range;
|
|
1351
|
+
for (let i = lo; i < hi - 1; i++) {
|
|
1352
|
+
if (text[i] !== "$" || text[i + 1] !== "[") continue;
|
|
1353
|
+
const scanned = scanStringInterpolation(text, i + 1, hi);
|
|
1354
|
+
const end = scanned.end;
|
|
1355
|
+
const innerAt = trimmedStart(text, i + 2, end);
|
|
1356
|
+
const innerEnd = trimmedEnd(text, innerAt, end);
|
|
1357
|
+
const inner = text.slice(innerAt, innerEnd);
|
|
1358
|
+
const innerRange = rangeAt(
|
|
1359
|
+
scanned.range,
|
|
1360
|
+
innerAt - (i + 1),
|
|
1361
|
+
innerEnd - (i + 1),
|
|
1362
|
+
);
|
|
1363
|
+
const maskedInner = innerRange.masked.slice(
|
|
1364
|
+
innerRange.start,
|
|
1365
|
+
innerRange.end,
|
|
1366
|
+
);
|
|
1367
|
+
// Span the `$[…]` from its sigil through the closing bracket; `innerSpan`
|
|
1368
|
+
// is the two-byte-in start that difference makes non-derivable.
|
|
1369
|
+
const resolution: Resolution = {
|
|
1370
|
+
...resolveInner(maskedInner, ctx, depth, contextCertain),
|
|
1130
1371
|
inner,
|
|
1131
|
-
|
|
1372
|
+
span: spanIn(loc, i - lo, Math.min(end + 1, hi) - lo),
|
|
1373
|
+
innerSpan: spanIn(loc, innerAt - lo, innerEnd - lo),
|
|
1374
|
+
};
|
|
1375
|
+
out.push(resolution);
|
|
1376
|
+
resolutionRanges.set(resolution, innerRange);
|
|
1377
|
+
collectBrackets(
|
|
1378
|
+
innerRange,
|
|
1379
|
+
resolution.path ?? ctx,
|
|
1132
1380
|
depth + 1,
|
|
1133
1381
|
out,
|
|
1134
1382
|
defects,
|
|
1135
|
-
nestedCertainty(
|
|
1136
|
-
nest(
|
|
1137
|
-
loc,
|
|
1138
|
-
i + 2 + (raw.length - trimAsciiStart(raw).length),
|
|
1139
|
-
loc.fallback,
|
|
1140
|
-
),
|
|
1383
|
+
nestedCertainty(resolution, contextCertain),
|
|
1384
|
+
nest(loc, innerAt - lo, loc.fallback),
|
|
1141
1385
|
);
|
|
1142
1386
|
i = end;
|
|
1143
1387
|
}
|
|
@@ -1191,7 +1435,7 @@ function resolveInner(
|
|
|
1191
1435
|
ctx: string,
|
|
1192
1436
|
depth: number,
|
|
1193
1437
|
contextCertain: boolean,
|
|
1194
|
-
): Omit<Resolution, "span"> {
|
|
1438
|
+
): Omit<Resolution, "span" | "innerSpan"> {
|
|
1195
1439
|
const absolute = inner.startsWith("/");
|
|
1196
1440
|
const klass = classify(inner, absolute, depth);
|
|
1197
1441
|
if (klass === "cli-prompt-artifact")
|
|
@@ -1294,7 +1538,7 @@ function classify(inner: string, absolute: boolean, depth: number): string {
|
|
|
1294
1538
|
if (CLI_PROMPT_RE.test(trimAscii(inner))) return "cli-prompt-artifact";
|
|
1295
1539
|
if (depth > 0) return "nested-bracket";
|
|
1296
1540
|
if (absolute) return "absolute-inner-path";
|
|
1297
|
-
const head = (
|
|
1541
|
+
const head = (asciiWordsIter(inner).next().value ?? "").toLowerCase();
|
|
1298
1542
|
if (head.startsWith("$")) return "dynamic-invocation";
|
|
1299
1543
|
if (head.startsWith(":")) return "scripting-directive";
|
|
1300
1544
|
if (head === "find") return "bare-find";
|
|
@@ -1306,12 +1550,13 @@ function matchDelim(
|
|
|
1306
1550
|
start: number,
|
|
1307
1551
|
open: string,
|
|
1308
1552
|
close: string,
|
|
1553
|
+
limit: number = text.length,
|
|
1309
1554
|
): number {
|
|
1310
1555
|
let depth = 0;
|
|
1311
|
-
for (let i = start; i <
|
|
1556
|
+
for (let i = start; i < limit; i++) {
|
|
1312
1557
|
const c = text[i];
|
|
1313
1558
|
if (c === '"') {
|
|
1314
|
-
i = scanQuotedString(text, i).end - 1;
|
|
1559
|
+
i = scanQuotedString(text, i, limit).end - 1;
|
|
1315
1560
|
continue;
|
|
1316
1561
|
}
|
|
1317
1562
|
if (c === open) depth++;
|
|
@@ -1320,5 +1565,5 @@ function matchDelim(
|
|
|
1320
1565
|
if (depth === 0) return i;
|
|
1321
1566
|
}
|
|
1322
1567
|
}
|
|
1323
|
-
return
|
|
1568
|
+
return limit;
|
|
1324
1569
|
}
|