@tikoci/centrs 0.1.4 → 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 +309 -0
- package/GLOSSARY.txt +40 -1
- package/README.md +8 -4
- package/commands/AGENTS.md +7 -4
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +37 -8
- package/commands/api/examples.md +31 -1
- package/commands/execute/README.md +39 -10
- package/commands/execute/examples.md +79 -7
- package/commands/explain/README.md +1195 -155
- package/commands/explain/examples.md +99 -3
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/terminal/examples.md +7 -1
- package/docs/CLI.md +2 -2
- package/docs/CONSTITUTION.md +72 -15
- package/docs/MATRIX.md +89 -52
- package/docs/errors/validation/syntax.md +29 -4
- package/package.json +13 -2
- package/src/api-fanout.ts +17 -4
- package/src/api.ts +254 -67
- package/src/cli/api.ts +1 -1
- package/src/cli/btest.ts +1 -1
- 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 +2 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/execute-fanout.ts +18 -4
- package/src/execute.ts +225 -244
- 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 +19 -2
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/retrieve-fanout.ts +1 -1
- package/src/retrieve.ts +1 -59
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +1 -1
- package/src/transfer.ts +1 -1
package/src/explain/args.ts
CHANGED
|
@@ -9,14 +9,37 @@
|
|
|
9
9
|
* `[…]` selectors and `?` queries at statement scope, and it was parked until
|
|
10
10
|
* `--curl` made it load-bearing. This is that lexer.
|
|
11
11
|
*
|
|
12
|
-
* ##
|
|
12
|
+
* ## Three readings, one walk
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* `walkArguments` owns the boundary rules — where a token starts, where it ends,
|
|
15
|
+
* and where it stops being decodable — and three exported readings differ only
|
|
16
|
+
* in what they do at a refusal. They can never disagree about a boundary,
|
|
17
|
+
* because there is only one of them.
|
|
18
|
+
*
|
|
19
|
+
* - {@link lexArguments} — **strict, all-or-nothing.** The consumer is a REST
|
|
20
|
+
* mapping that renders a runnable `curl` body. A partially-read argument
|
|
21
|
+
* list is worse there than no list at all: dropping one token silently
|
|
22
|
+
* changes what the rendered command DOES, which is the fabrication the
|
|
23
|
+
* fail-closed floor exists to prevent. So every token must be decided, or
|
|
24
|
+
* this decides nothing and says why. The price is measured rather than
|
|
25
|
+
* assumed — see the abstention rates in the #202c PR body.
|
|
26
|
+
* - {@link lexValueAnchors} — **prefix-safe.** An unreadable later expression
|
|
27
|
+
* must not erase an earlier, independently bounded literal, so it keeps
|
|
28
|
+
* every anchor found before the first refusal and stops there (#225 V1).
|
|
29
|
+
* - {@link lexArgumentTokens} — **skip-tolerant.** The reading a token-level
|
|
30
|
+
* RULE wants (#316). A refusal whose token boundary is still knowable is
|
|
31
|
+
* published as a located token with `undecided` set and no `value`, and the
|
|
32
|
+
* walk carries on. It reaches 100% of the corpus's argument-bearing
|
|
33
|
+
* statements against the strict reading's 51.8%; the skip-vs-prefix fork is
|
|
34
|
+
* priced by `bun run explain:arg-reach` and decided in
|
|
35
|
+
* `commands/explain/README.md` → *The token stream a rule reads*.
|
|
36
|
+
*
|
|
37
|
+
* A refusal reason is a fact about the input, not a reading-specific message:
|
|
38
|
+
* all three name the same one for the same byte. The one place they differ is
|
|
39
|
+
* where the tolerant walk needs something the strict one never asked for — an
|
|
40
|
+
* unclosed `[…]` is `a substitution or expression value` to a reading that
|
|
41
|
+
* refuses it on sight, and `an unclosed structured argument value` to the one
|
|
42
|
+
* that went looking for the boundary it would have resumed from.
|
|
20
43
|
*
|
|
21
44
|
* ## What it refuses, and why each refusal is a fact rather than a gap
|
|
22
45
|
*
|
|
@@ -38,6 +61,10 @@
|
|
|
38
61
|
* word scanner handles continuations for the run END; a VALUE needs its
|
|
39
62
|
* interior, which is exactly what a continuation breaks.
|
|
40
63
|
*
|
|
64
|
+
* Each of those is a refusal of the VALUE, and the tolerant reading publishes
|
|
65
|
+
* the token anyway with the same reason attached — which is sound precisely
|
|
66
|
+
* because `Argument.value` stays absent. None of them becomes renderable.
|
|
67
|
+
*
|
|
41
68
|
* ## It emits no defects
|
|
42
69
|
*
|
|
43
70
|
* Every defect class this scan could raise (`unterminated-string`,
|
|
@@ -49,10 +76,15 @@
|
|
|
49
76
|
* re-announce; the abstention reason names it instead.
|
|
50
77
|
*/
|
|
51
78
|
|
|
52
|
-
import { isScopeBrace } from "./blocks.ts";
|
|
79
|
+
import { isScopeBrace, isScopeBraceIn } from "./blocks.ts";
|
|
53
80
|
import { braceSlotAcceptsBrace, braceSlotTakesArray } from "./brace-slots.ts";
|
|
54
81
|
import { braceStartsStatements } from "./scope-brace.ts";
|
|
55
|
-
import {
|
|
82
|
+
import {
|
|
83
|
+
type MaskedRange,
|
|
84
|
+
maskComments,
|
|
85
|
+
maskOf,
|
|
86
|
+
scanQuotedString,
|
|
87
|
+
} from "./segment.ts";
|
|
56
88
|
|
|
57
89
|
/** A RouterOS argument name: bare word, optionally dotted (`.id`, `.proplist`). */
|
|
58
90
|
const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
|
|
@@ -88,10 +120,30 @@ export interface Argument {
|
|
|
88
120
|
* lexer's claim to make.
|
|
89
121
|
*/
|
|
90
122
|
value?: string;
|
|
91
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* Where the value sits, quotes INCLUDED.
|
|
125
|
+
*
|
|
126
|
+
* In the strict {@link lexArguments} reading this is absent whenever `value`
|
|
127
|
+
* is. The tolerant {@link lexArgumentTokens} reading also publishes it for a
|
|
128
|
+
* value whose BYTES are located but whose literal is unknown
|
|
129
|
+
* (`interface=$x`), which is what lets a positional fill claim the `name=`
|
|
130
|
+
* run of a token no consumer may render. `undecided` is the field that says
|
|
131
|
+
* which of the two this is.
|
|
132
|
+
*/
|
|
92
133
|
valueSpan?: { start: number; end: number };
|
|
93
134
|
/** The token verbatim, for a positional and for rendering. */
|
|
94
135
|
text: string;
|
|
136
|
+
/**
|
|
137
|
+
* Why this token's value or shape was not decoded — tolerant reading only.
|
|
138
|
+
*
|
|
139
|
+
* Never set by {@link lexArguments}: the strict reading publishes no token it
|
|
140
|
+
* could not decide, so a consumer holding an {@link ArgumentsRead} never sees
|
|
141
|
+
* this field. {@link lexArgumentTokens} sets it on exactly the tokens the
|
|
142
|
+
* strict walk would have aborted at, carrying the same reason string the
|
|
143
|
+
* strict refusal would have carried. A token with `undecided` set has no
|
|
144
|
+
* `value` and must never be rendered into a runnable command.
|
|
145
|
+
*/
|
|
146
|
+
undecided?: string;
|
|
95
147
|
}
|
|
96
148
|
|
|
97
149
|
/** Internal token classification; quote state is stripped from the REST view. */
|
|
@@ -176,6 +228,14 @@ export interface ValueAnchor {
|
|
|
176
228
|
parent?: number;
|
|
177
229
|
}
|
|
178
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Skip-tolerant argument tokens; `complete: false` explains where the walk
|
|
233
|
+
* stopped, which is a rarer event than in the strict reading.
|
|
234
|
+
*/
|
|
235
|
+
export type ArgumentTokenReading =
|
|
236
|
+
| { complete: true; tokens: Argument[] }
|
|
237
|
+
| { complete: false; tokens: Argument[]; why: string };
|
|
238
|
+
|
|
179
239
|
/** Prefix-safe value anchoring; `complete: false` explains where scanning stopped. */
|
|
180
240
|
export type ValueAnchorReading =
|
|
181
241
|
| { complete: true; anchors: ValueAnchor[] }
|
|
@@ -186,9 +246,68 @@ function unread(why: string): ArgumentsUnread {
|
|
|
186
246
|
return { read: false, why };
|
|
187
247
|
}
|
|
188
248
|
|
|
249
|
+
/** Where a token ends, and — tolerant walk only — why it was not decoded. */
|
|
250
|
+
interface ScanToken {
|
|
251
|
+
end: number;
|
|
252
|
+
why?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Whether the `{` at `at` opens a scope, reading the document's mask when the
|
|
257
|
+
* caller anchored this statement and masking `text` itself otherwise.
|
|
258
|
+
*
|
|
259
|
+
* `at` is relative to `text` either way, so callers need no offset arithmetic.
|
|
260
|
+
*/
|
|
261
|
+
function scopeBraceAt(
|
|
262
|
+
text: string,
|
|
263
|
+
at: number,
|
|
264
|
+
range: MaskedRange | undefined,
|
|
265
|
+
): boolean {
|
|
266
|
+
return range === undefined
|
|
267
|
+
? isScopeBrace(text, at)
|
|
268
|
+
: isScopeBraceIn(range, at);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* `{ range }` when the caller supplied one, otherwise nothing — so an omitted
|
|
273
|
+
* range stays absent rather than becoming an explicit `undefined` under
|
|
274
|
+
* `exactOptionalPropertyTypes`.
|
|
275
|
+
*/
|
|
276
|
+
function rangeOption(range: MaskedRange | undefined): { range?: MaskedRange } {
|
|
277
|
+
return range === undefined ? {} : { range };
|
|
278
|
+
}
|
|
279
|
+
|
|
189
280
|
interface WalkOptions {
|
|
190
281
|
/** Locate `{…}`/`(…)` literals instead of refusing the statement at one. */
|
|
191
282
|
allowArrayValues?: boolean;
|
|
283
|
+
/**
|
|
284
|
+
* Where `text` sits in its document, with that document's comment mask.
|
|
285
|
+
*
|
|
286
|
+
* A statement's text carries its whole nested `do={…}` subtree, and every
|
|
287
|
+
* pass over the document asks a different one of these readers for the same
|
|
288
|
+
* statement — so each re-derived a mask over bytes the last one had already
|
|
289
|
+
* read, once per pass and once per enclosing level (#322). A caller holding
|
|
290
|
+
* the document's mask passes the statement's range of it instead; the mask of
|
|
291
|
+
* a statement IS the document's mask restricted to that statement, because a
|
|
292
|
+
* statement boundary begins a comment context (see `segment.ts` →
|
|
293
|
+
* `MaskedRange`). Omitted, this reader masks `text` itself as before.
|
|
294
|
+
*
|
|
295
|
+
* Offsets in and out of these readers stay relative to `text`. The range is
|
|
296
|
+
* read for the mask, and for the questions whose answer depends on the
|
|
297
|
+
* DOCUMENT's statement index rather than this statement's.
|
|
298
|
+
*/
|
|
299
|
+
range?: MaskedRange;
|
|
300
|
+
/**
|
|
301
|
+
* Publish a token the walk cannot DECODE instead of aborting at it (#316).
|
|
302
|
+
*
|
|
303
|
+
* Off, every refusal stops the walk — the reading the REST/`curl` consumer
|
|
304
|
+
* needs. On, a refusal whose token boundary is still knowable becomes an
|
|
305
|
+
* {@link Argument} with `undecided` set and no `value`, and the walk carries
|
|
306
|
+
* on to the next token. The boundary rules do not move: both walks agree
|
|
307
|
+
* about where every token starts and ends, and the tolerant one only differs
|
|
308
|
+
* in whether it returns there.
|
|
309
|
+
*/
|
|
310
|
+
tolerant?: boolean;
|
|
192
311
|
/**
|
|
193
312
|
* The root scripting directive whose slots decide whether a `{…}` here is an
|
|
194
313
|
* array literal, or undefined when this statement has none.
|
|
@@ -267,7 +386,7 @@ function* walkArguments(
|
|
|
267
386
|
from: number,
|
|
268
387
|
options: WalkOptions = {},
|
|
269
388
|
): Generator<ReadArgument | string> {
|
|
270
|
-
const structural = maskComments(text);
|
|
389
|
+
const structural = maskOf(options.range) ?? maskComments(text);
|
|
271
390
|
let i = Math.max(0, from);
|
|
272
391
|
// How many positionals have been read, which is the NEXT positional's index.
|
|
273
392
|
let positionals = 0;
|
|
@@ -298,11 +417,34 @@ function* walkArguments(
|
|
|
298
417
|
yield token;
|
|
299
418
|
return;
|
|
300
419
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
420
|
+
// A refusal whose extent collapsed to nothing leaves no boundary to resume
|
|
421
|
+
// from — resuming at `i` would scan the same byte forever. The tolerant
|
|
422
|
+
// walk stops there exactly as the strict one does.
|
|
423
|
+
if (token.why !== undefined && token.end <= i) {
|
|
424
|
+
yield token.why;
|
|
304
425
|
return;
|
|
305
426
|
}
|
|
427
|
+
// `readToken` is skipped entirely for an undecodable token rather than
|
|
428
|
+
// having its result stripped: `literalValue` would read `$x` as the bare
|
|
429
|
+
// run `$x` and publish it as a literal, which is precisely the fabrication
|
|
430
|
+
// the refusal exists to prevent. Shape only, never a value.
|
|
431
|
+
const read =
|
|
432
|
+
token.why === undefined
|
|
433
|
+
? readToken(text, structural, i, token.end, options)
|
|
434
|
+
: undecidedToken(text, i, token.end, token.why);
|
|
435
|
+
if (typeof read === "string") {
|
|
436
|
+
if (options.tolerant !== true) {
|
|
437
|
+
yield read;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
// A `readToken` refusal already knows the token's extent — `scanToken`
|
|
441
|
+
// found it — so the tolerant walk publishes the shape and moves on.
|
|
442
|
+
const located = undecidedToken(text, i, token.end, read);
|
|
443
|
+
if (located.kind === "positional") positionals++;
|
|
444
|
+
yield located;
|
|
445
|
+
i = token.end;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
306
448
|
if (read.kind === "positional") positionals++;
|
|
307
449
|
yield read;
|
|
308
450
|
i = token.end;
|
|
@@ -321,9 +463,13 @@ function* walkArguments(
|
|
|
321
463
|
* that hands over the ORIGINAL text of a non-ASCII statement would get spans
|
|
322
464
|
* that do not map back — `src/explain.ts` verifies the two agree before calling.
|
|
323
465
|
*/
|
|
324
|
-
export function lexArguments(
|
|
466
|
+
export function lexArguments(
|
|
467
|
+
text: string,
|
|
468
|
+
from: number,
|
|
469
|
+
range?: MaskedRange,
|
|
470
|
+
): ArgumentReading {
|
|
325
471
|
const tokens: Argument[] = [];
|
|
326
|
-
for (const step of walkArguments(text, from)) {
|
|
472
|
+
for (const step of walkArguments(text, from, rangeOption(range))) {
|
|
327
473
|
if (typeof step === "string") return unread(step);
|
|
328
474
|
const publicToken = { ...step };
|
|
329
475
|
delete publicToken.literalQuoted;
|
|
@@ -342,6 +488,54 @@ export function lexArguments(text: string, from: number): ArgumentReading {
|
|
|
342
488
|
return { read: true, tokens, args, queries, positional };
|
|
343
489
|
}
|
|
344
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Lex the arguments of ONE statement as a TOKEN STREAM, skipping what it cannot
|
|
493
|
+
* decode (#316).
|
|
494
|
+
*
|
|
495
|
+
* Same text, same `from`, same boundary rules and the same reason strings as
|
|
496
|
+
* {@link lexArguments} — the difference is only what a refusal does. Where the
|
|
497
|
+
* strict reading discards the whole list, this one publishes the token it could
|
|
498
|
+
* not decode with `undecided` set, no `value`, and its bytes located, then
|
|
499
|
+
* carries on to the next token. It is the reading every TOKEN-LEVEL rule wants
|
|
500
|
+
* (`findMissingSeparators`, the `arg` fill), and the one no RENDERING consumer
|
|
501
|
+
* may have: a token with `undecided` set has no value to send.
|
|
502
|
+
*
|
|
503
|
+
* ## Skip-and-continue, not stop-at-refusal
|
|
504
|
+
*
|
|
505
|
+
* The cheaper design — publish the prefix decoded before the first refusal,
|
|
506
|
+
* which is what {@link lexValueAnchors} does for values — was measured and
|
|
507
|
+
* rejected. On the 948-script pinned corpus the prefix reading recovers 3,841
|
|
508
|
+
* argument tokens and finds **zero** additional missing-separator runs, because
|
|
509
|
+
* a refusal is usually the FIRST interesting token in the statement
|
|
510
|
+
* (`… interface=$x /ip/route add …` has an empty prefix). Skipping past it is
|
|
511
|
+
* what reaches the rest of the operand stream, and the corpus numbers for both
|
|
512
|
+
* designs are in `commands/explain/README.md` → *The token stream a rule reads*.
|
|
513
|
+
*
|
|
514
|
+
* ## Where it still stops
|
|
515
|
+
*
|
|
516
|
+
* Two constructs have no knowable end, so neither walk can resume past them: an
|
|
517
|
+
* unterminated string and an unbalanced `(`/`[`/`{`. A `;` is a third, and
|
|
518
|
+
* deliberately so — it is a statement boundary the segmenter owns, and resuming
|
|
519
|
+
* after it would read the NEXT statement's bytes as this one's arguments.
|
|
520
|
+
*/
|
|
521
|
+
export function lexArgumentTokens(
|
|
522
|
+
text: string,
|
|
523
|
+
from: number,
|
|
524
|
+
range?: MaskedRange,
|
|
525
|
+
): ArgumentTokenReading {
|
|
526
|
+
const tokens: Argument[] = [];
|
|
527
|
+
for (const step of walkArguments(text, from, {
|
|
528
|
+
tolerant: true,
|
|
529
|
+
...rangeOption(range),
|
|
530
|
+
})) {
|
|
531
|
+
if (typeof step === "string") return { complete: false, tokens, why: step };
|
|
532
|
+
const publicToken = { ...step };
|
|
533
|
+
delete publicToken.literalQuoted;
|
|
534
|
+
tokens.push(publicToken);
|
|
535
|
+
}
|
|
536
|
+
return { complete: true, tokens };
|
|
537
|
+
}
|
|
538
|
+
|
|
345
539
|
/**
|
|
346
540
|
* Length of the `\<newline>` continuation at `at`, or 0 — the ONE boundary rule
|
|
347
541
|
* this module and `verbsplit.ts`'s word scanner share.
|
|
@@ -374,54 +568,135 @@ function scanToken(
|
|
|
374
568
|
start: number,
|
|
375
569
|
positionalIndex: number,
|
|
376
570
|
options: WalkOptions,
|
|
377
|
-
):
|
|
571
|
+
): ScanToken | string {
|
|
378
572
|
let i = start;
|
|
573
|
+
// The first refusal seen while still able to find the token's END. Only the
|
|
574
|
+
// tolerant walk ever sets it; the strict walk returns on the first refusal,
|
|
575
|
+
// so `refuse` hands the reason straight back to be returned (#316).
|
|
576
|
+
let why: string | undefined;
|
|
577
|
+
function refuse(reason: string): string | undefined {
|
|
578
|
+
if (options.tolerant !== true) return reason;
|
|
579
|
+
if (why === undefined) why = reason;
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
// A construct whose END is not knowable. The token stops in FRONT of it,
|
|
583
|
+
// keeping the reason its own bytes earned, and the walk resumes at the
|
|
584
|
+
// construct — where this runs again with nothing scanned (`i === start`) and
|
|
585
|
+
// hands back the unbounded reason, which is why the walk stops. Reporting the
|
|
586
|
+
// recorded reason there instead would say the walk stopped at a value it
|
|
587
|
+
// normally skips, when it stopped because there was no boundary to skip to.
|
|
588
|
+
function unbounded(reason: string): ScanToken | string {
|
|
589
|
+
return why === undefined || i <= start ? reason : { end: i, why };
|
|
590
|
+
}
|
|
379
591
|
while (i < text.length) {
|
|
380
592
|
const c = structural[i] as string;
|
|
381
593
|
if (c === " " || c === "\t" || c === "\r" || c === "\n") break;
|
|
382
594
|
if (c === '"') {
|
|
383
595
|
const scan = scanQuotedString(structural, i);
|
|
384
|
-
if (!scan.closed) return "unterminated string in an argument";
|
|
596
|
+
if (!scan.closed) return unbounded("unterminated string in an argument");
|
|
385
597
|
i = scan.end;
|
|
386
598
|
continue;
|
|
387
599
|
}
|
|
388
|
-
if (c === "["
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
600
|
+
if (c === "[" || c === "(" || c === "{") {
|
|
601
|
+
const declined = structuredRefusal(
|
|
602
|
+
text,
|
|
603
|
+
c,
|
|
604
|
+
start,
|
|
605
|
+
i,
|
|
606
|
+
positionalIndex,
|
|
607
|
+
options,
|
|
608
|
+
);
|
|
609
|
+
if (declined !== undefined) {
|
|
610
|
+
const abort = refuse(declined);
|
|
611
|
+
if (abort !== undefined) return abort;
|
|
612
|
+
// The reason is recorded; the extent is still the balanced run, which
|
|
613
|
+
// is what lets the walk carry on past a value it cannot decode.
|
|
614
|
+
const skip = delimitedEnd(structural, i);
|
|
615
|
+
if (skip === null)
|
|
616
|
+
return unbounded("an unclosed structured argument value");
|
|
617
|
+
i = skip;
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
400
620
|
const end = delimitedEnd(structural, i);
|
|
401
|
-
if (end === null)
|
|
402
|
-
|
|
403
|
-
|
|
621
|
+
if (end === null)
|
|
622
|
+
return unbounded("an unclosed structured argument value");
|
|
623
|
+
if (hasUnquotedHash(structural, i, end, options.range)) {
|
|
624
|
+
const abort = refuse("an invalid hash in a structured argument value");
|
|
625
|
+
if (abort !== undefined) return abort;
|
|
626
|
+
}
|
|
404
627
|
i = end;
|
|
405
628
|
const next = nextNonWhitespace(structural, i);
|
|
406
|
-
if (continuesExpression(structural, i, next))
|
|
407
|
-
|
|
629
|
+
if (continuesExpression(structural, i, next)) {
|
|
630
|
+
const abort = refuse(
|
|
631
|
+
"an expression continuing after a structured value",
|
|
632
|
+
);
|
|
633
|
+
if (abort !== undefined) return abort;
|
|
634
|
+
}
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
if (c === "$") {
|
|
638
|
+
const abort = refuse("a variable value");
|
|
639
|
+
if (abort !== undefined) return abort;
|
|
640
|
+
i++;
|
|
408
641
|
continue;
|
|
409
642
|
}
|
|
410
|
-
if (c === "$") return "a variable value";
|
|
411
643
|
if (c === "\\") {
|
|
412
|
-
if (continuationLength(text, i) > 0)
|
|
413
|
-
|
|
644
|
+
if (continuationLength(text, i) > 0) {
|
|
645
|
+
const abort = refuse("a line continuation inside an argument");
|
|
646
|
+
if (abort !== undefined) return abort;
|
|
647
|
+
// The token's BYTES stay contiguous across the continuation even
|
|
648
|
+
// though its value does not, and `verbsplit.ts`'s word scanner already
|
|
649
|
+
// reads them as one word for the run end. So the span is honest while
|
|
650
|
+
// the value stays absent — which is the whole tolerant contract.
|
|
651
|
+
i += continuationLength(text, i);
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
414
654
|
// The #201 rule: in code a backslash is valid only before whitespace.
|
|
415
655
|
// `symbols.ts` already located the defect; refusing is enough here.
|
|
416
|
-
if (text[i + 1] !== " " && text[i + 1] !== "\t")
|
|
417
|
-
|
|
656
|
+
if (text[i + 1] !== " " && text[i + 1] !== "\t") {
|
|
657
|
+
const abort = refuse("an invalid escape in an argument");
|
|
658
|
+
if (abort !== undefined) return abort;
|
|
659
|
+
i++;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
418
662
|
i += 2;
|
|
419
663
|
continue;
|
|
420
664
|
}
|
|
421
|
-
|
|
665
|
+
// A `;` is a BOUNDARY the segmenter should already have split on, not a
|
|
666
|
+
// value this walk may skip: resuming after it would read the next
|
|
667
|
+
// statement's bytes as this one's arguments. Both walks stop.
|
|
668
|
+
if (c === ";") return unbounded("a statement separator inside an argument");
|
|
422
669
|
i++;
|
|
423
670
|
}
|
|
424
|
-
return { end: i };
|
|
671
|
+
return why === undefined ? { end: i } : { end: i, why };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Why a `[`/`(`/`{` at `at` is not a value this lexer reads, or `undefined`
|
|
676
|
+
* when {@link WalkOptions.allowArrayValues} accepts it as an array literal.
|
|
677
|
+
*
|
|
678
|
+
* Split out of {@link scanToken} so the strict and tolerant walks choose the
|
|
679
|
+
* same reason for the same byte — the tolerant walk then skips the balanced run
|
|
680
|
+
* instead of returning, which is the only difference between them.
|
|
681
|
+
*/
|
|
682
|
+
function structuredRefusal(
|
|
683
|
+
text: string,
|
|
684
|
+
c: string,
|
|
685
|
+
start: number,
|
|
686
|
+
at: number,
|
|
687
|
+
positionalIndex: number,
|
|
688
|
+
options: WalkOptions,
|
|
689
|
+
): string | undefined {
|
|
690
|
+
if (c === "[") return "a substitution or expression value";
|
|
691
|
+
if (!options.allowArrayValues)
|
|
692
|
+
return c === "("
|
|
693
|
+
? "a substitution or expression value"
|
|
694
|
+
: "an array or block value";
|
|
695
|
+
if (c === "{" && scopeBraceAt(text, at, options.range))
|
|
696
|
+
return "a scope block value";
|
|
697
|
+
if (c === "{" && !braceOpensArray(text, start, at, positionalIndex, options))
|
|
698
|
+
return "a brace value RouterOS does not read as an array here";
|
|
699
|
+
return undefined;
|
|
425
700
|
}
|
|
426
701
|
|
|
427
702
|
/**
|
|
@@ -441,7 +716,12 @@ function scanToken(
|
|
|
441
716
|
*
|
|
442
717
|
* Skipping whole `[…]` regions instead would wrongly accept rows 3 and 4.
|
|
443
718
|
*/
|
|
444
|
-
function hasUnquotedHash(
|
|
719
|
+
function hasUnquotedHash(
|
|
720
|
+
text: string,
|
|
721
|
+
start: number,
|
|
722
|
+
end: number,
|
|
723
|
+
range?: MaskedRange,
|
|
724
|
+
): boolean {
|
|
445
725
|
// `start` is the `{`/`(` of an array-or-group value: not a statement context.
|
|
446
726
|
const statements: boolean[] = [false];
|
|
447
727
|
for (let i = start + 1; i < end - 1; i++) {
|
|
@@ -457,7 +737,14 @@ function hasUnquotedHash(text: string, start: number, end: number): boolean {
|
|
|
457
737
|
// scope; nested in an array it is another array, so the role is known
|
|
458
738
|
// without the reverse-prefix scan. The short-circuit is what keeps this
|
|
459
739
|
// O(1) per brace on the deep-nesting inputs `explain-write` and Q17 pin.
|
|
460
|
-
statements.push(
|
|
740
|
+
statements.push(
|
|
741
|
+
enclosing &&
|
|
742
|
+
braceStartsStatements(
|
|
743
|
+
range?.masked ?? text,
|
|
744
|
+
(range?.start ?? 0) + i,
|
|
745
|
+
range?.start ?? 0,
|
|
746
|
+
),
|
|
747
|
+
);
|
|
461
748
|
else if (c === "(") statements.push(false);
|
|
462
749
|
else if (c === "]" || c === "}" || c === ")") {
|
|
463
750
|
if (statements.length > 1) statements.pop();
|
|
@@ -527,13 +814,14 @@ function isArraySource(
|
|
|
527
814
|
structural: string,
|
|
528
815
|
start: number,
|
|
529
816
|
end: number,
|
|
817
|
+
range: MaskedRange | undefined,
|
|
530
818
|
): boolean {
|
|
531
819
|
const open = text[start];
|
|
532
820
|
if (open !== "(" && open !== "{") return false;
|
|
533
821
|
if (delimitedEnd(structural, start) !== end) return false;
|
|
534
822
|
if (open === "{")
|
|
535
823
|
return (
|
|
536
|
-
!
|
|
824
|
+
!scopeBraceAt(text, start, range) &&
|
|
537
825
|
structural.slice(start + 1, end - 1).trim().length > 0
|
|
538
826
|
);
|
|
539
827
|
|
|
@@ -576,7 +864,7 @@ function readToken(
|
|
|
576
864
|
return divergent ?? { kind: "query", span, name: raw.slice(1), text: raw };
|
|
577
865
|
}
|
|
578
866
|
if (options.allowArrayValues && startsStructuredSource(text, start)) {
|
|
579
|
-
if (isArraySource(text, structural, start, end))
|
|
867
|
+
if (isArraySource(text, structural, start, end, options.range))
|
|
580
868
|
return {
|
|
581
869
|
kind: "positional",
|
|
582
870
|
span,
|
|
@@ -613,7 +901,7 @@ function readToken(
|
|
|
613
901
|
if (!ARGUMENT_NAME.test(name))
|
|
614
902
|
return `\`${name}=\` is not a RouterOS argument name`;
|
|
615
903
|
if (options.allowArrayValues && startsStructuredSource(text, eq + 1)) {
|
|
616
|
-
if (isArraySource(text, structural, eq + 1, end))
|
|
904
|
+
if (isArraySource(text, structural, eq + 1, end, options.range))
|
|
617
905
|
return {
|
|
618
906
|
kind: "attribute",
|
|
619
907
|
span,
|
|
@@ -641,6 +929,51 @@ function readToken(
|
|
|
641
929
|
};
|
|
642
930
|
}
|
|
643
931
|
|
|
932
|
+
/**
|
|
933
|
+
* One located token whose value this phase does not decode (#316).
|
|
934
|
+
*
|
|
935
|
+
* Shape only: the `?`/`name=`/bare split comes from bytes the scanner already
|
|
936
|
+
* bounded, and NOTHING here reads a value — `undecided` is set and `value` stays
|
|
937
|
+
* absent, whatever the source spells. A left side that is not a RouterOS name is
|
|
938
|
+
* not promoted to an attribute (`{a=1;b=2}` stays one bare token), which is the
|
|
939
|
+
* same call {@link readToken} makes when it refuses that shape outright.
|
|
940
|
+
*/
|
|
941
|
+
function undecidedToken(
|
|
942
|
+
text: string,
|
|
943
|
+
start: number,
|
|
944
|
+
end: number,
|
|
945
|
+
why: string,
|
|
946
|
+
): ReadArgument {
|
|
947
|
+
const raw = text.slice(start, end);
|
|
948
|
+
const span = { start, end };
|
|
949
|
+
if (raw.startsWith("?"))
|
|
950
|
+
return {
|
|
951
|
+
kind: "query",
|
|
952
|
+
span,
|
|
953
|
+
name: raw.slice(1),
|
|
954
|
+
text: raw,
|
|
955
|
+
undecided: why,
|
|
956
|
+
};
|
|
957
|
+
const eq = unquotedEquals(text, start, end);
|
|
958
|
+
const name = eq === null ? null : text.slice(start, eq);
|
|
959
|
+
if (eq !== null && name !== null && ARGUMENT_NAME.test(name))
|
|
960
|
+
return {
|
|
961
|
+
kind: "attribute",
|
|
962
|
+
span,
|
|
963
|
+
name,
|
|
964
|
+
valueSpan: { start: eq + 1, end },
|
|
965
|
+
text: raw,
|
|
966
|
+
undecided: why,
|
|
967
|
+
};
|
|
968
|
+
return {
|
|
969
|
+
kind: "positional",
|
|
970
|
+
span,
|
|
971
|
+
valueSpan: span,
|
|
972
|
+
text: raw,
|
|
973
|
+
undecided: why,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
644
977
|
/** Offset of the first `=` outside a quoted run, or null. */
|
|
645
978
|
function unquotedEquals(
|
|
646
979
|
text: string,
|
|
@@ -1065,6 +1398,7 @@ function pushArrayMembers(
|
|
|
1065
1398
|
contentEnd: number,
|
|
1066
1399
|
separator: ";" | ",",
|
|
1067
1400
|
depth: number,
|
|
1401
|
+
range: MaskedRange | undefined,
|
|
1068
1402
|
): string | null {
|
|
1069
1403
|
// Past the bound the interior is UNVERIFIED, and an unverified interior can
|
|
1070
1404
|
// hold a fault that makes the whole statement a syntax error — `:parse`
|
|
@@ -1206,7 +1540,7 @@ function pushArrayMembers(
|
|
|
1206
1540
|
}
|
|
1207
1541
|
const fault = parenMemberFault(text, structural, valueStart, member.end);
|
|
1208
1542
|
if (fault !== null) return fault;
|
|
1209
|
-
if (isArraySource(text, structural, valueStart, member.end)) {
|
|
1543
|
+
if (isArraySource(text, structural, valueStart, member.end, range)) {
|
|
1210
1544
|
const at = anchors.length;
|
|
1211
1545
|
anchors.push({ ...anchor, sourceShape: "array" });
|
|
1212
1546
|
const nested = pushArrayMembers(
|
|
@@ -1218,6 +1552,7 @@ function pushArrayMembers(
|
|
|
1218
1552
|
member.end - 1,
|
|
1219
1553
|
text[valueStart] === "{" ? ";" : ",",
|
|
1220
1554
|
depth + 1,
|
|
1555
|
+
range,
|
|
1221
1556
|
);
|
|
1222
1557
|
if (nested !== null) return nested;
|
|
1223
1558
|
continue;
|
|
@@ -1239,6 +1574,7 @@ function pushArrayMembers(
|
|
|
1239
1574
|
member.end,
|
|
1240
1575
|
",",
|
|
1241
1576
|
depth + 1,
|
|
1577
|
+
range,
|
|
1242
1578
|
);
|
|
1243
1579
|
if (nested !== null) return nested;
|
|
1244
1580
|
continue;
|
|
@@ -1269,12 +1605,13 @@ function pushArrayMembers(
|
|
|
1269
1605
|
export function lexValueAnchors(
|
|
1270
1606
|
text: string,
|
|
1271
1607
|
from: number,
|
|
1272
|
-
options: { directiveVerb?: string } = {},
|
|
1608
|
+
options: { directiveVerb?: string; range?: MaskedRange } = {},
|
|
1273
1609
|
): ValueAnchorReading {
|
|
1274
1610
|
const anchors: ValueAnchor[] = [];
|
|
1275
|
-
const structural = maskComments(text);
|
|
1611
|
+
const structural = maskOf(options.range) ?? maskComments(text);
|
|
1276
1612
|
for (const read of walkArguments(text, from, {
|
|
1277
1613
|
allowArrayValues: true,
|
|
1614
|
+
...rangeOption(options.range),
|
|
1278
1615
|
...(options.directiveVerb === undefined
|
|
1279
1616
|
? {}
|
|
1280
1617
|
: { directiveVerb: options.directiveVerb }),
|
|
@@ -1302,6 +1639,7 @@ export function lexValueAnchors(
|
|
|
1302
1639
|
read.valueSpan.end - 1,
|
|
1303
1640
|
text[read.valueSpan.start] === "{" ? ";" : ",",
|
|
1304
1641
|
0,
|
|
1642
|
+
options.range,
|
|
1305
1643
|
);
|
|
1306
1644
|
if (unparsed !== null) {
|
|
1307
1645
|
anchors.length = at;
|
|
@@ -1335,8 +1673,9 @@ export function invalidCommandBraceOffsets(
|
|
|
1335
1673
|
text: string,
|
|
1336
1674
|
from: number,
|
|
1337
1675
|
directiveVerb?: string,
|
|
1676
|
+
range?: MaskedRange,
|
|
1338
1677
|
): number[] {
|
|
1339
|
-
const structural = maskComments(text);
|
|
1678
|
+
const structural = maskOf(range) ?? maskComments(text);
|
|
1340
1679
|
const offsets: number[] = [];
|
|
1341
1680
|
const stack: string[] = [];
|
|
1342
1681
|
let i = Math.max(0, from);
|
|
@@ -1388,7 +1727,14 @@ export function invalidCommandBraceOffsets(
|
|
|
1388
1727
|
: braceValueOwner(structural, tokenStart, i);
|
|
1389
1728
|
const slot = owner === null ? `#${positionals}` : owner;
|
|
1390
1729
|
if (
|
|
1391
|
-
|
|
1730
|
+
// Asked of the DOCUMENT when the caller anchored this statement, so the
|
|
1731
|
+
// statement index behind it is the document's one index rather than a
|
|
1732
|
+
// fresh one over this statement's text — which carries its whole nested
|
|
1733
|
+
// subtree, and so was rebuilt once per enclosing level (#322). The floor
|
|
1734
|
+
// makes the two readings the same answer (`scope-brace.ts`).
|
|
1735
|
+
(range === undefined
|
|
1736
|
+
? braceStartsStatements(structural, i)
|
|
1737
|
+
: braceStartsStatements(range.masked, range.start + i, range.start)) ||
|
|
1392
1738
|
(directiveVerb !== undefined &&
|
|
1393
1739
|
slot !== undefined &&
|
|
1394
1740
|
braceSlotAcceptsBrace(directiveVerb, slot))
|