@tikoci/centrs 0.1.9-next.23 → 0.1.9-next.24
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 +106 -0
- package/GLOSSARY.txt +14 -0
- package/README.md +2 -2
- package/commands/api/AGENTS.md +18 -1
- package/commands/api/README.md +10 -6
- package/commands/api/examples.md +1 -1
- package/commands/execute/README.md +26 -6
- package/commands/execute/examples.md +24 -5
- package/commands/explain/README.md +452 -61
- package/commands/explain/examples.md +191 -41
- package/commands/retrieve/README.md +13 -2
- package/commands/retrieve/examples.md +30 -1
- package/commands/transfer/README.md +8 -4
- package/docs/CLI.md +19 -5
- package/docs/CONSTITUTION.md +14 -9
- package/docs/MATRIX.md +34 -21
- package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/routeros/permission-denied.md +24 -0
- package/docs/errors/transport/edge-auth-failed.md +22 -0
- package/docs/errors/transport/write-outcome-unknown.md +3 -0
- package/docs/errors/usage/invalid-cursor.md +20 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +2 -2
- package/src/api.ts +34 -39
- package/src/cli/explain.ts +206 -47
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +15 -0
- package/src/core/execute-command.ts +53 -0
- package/src/core/fanout.ts +12 -2
- package/src/core/inspect.ts +4 -5
- package/src/core/routeros-errors.ts +25 -0
- package/src/execute-cmr.ts +2 -8
- package/src/execute.ts +48 -20
- package/src/explain/args.ts +115 -10
- package/src/explain/group-faults.ts +162 -29
- package/src/explain/operators.ts +2 -1
- package/src/explain/scope-brace.ts +226 -11
- package/src/explain/segment.ts +136 -1
- package/src/explain/symbols.ts +146 -6
- package/src/explain/values.ts +55 -4
- package/src/explain-live-facets.ts +739 -0
- package/src/explain-live-types.ts +490 -0
- package/src/explain-live.ts +593 -0
- package/src/explain.ts +395 -43
- package/src/index.ts +6 -0
- package/src/protocols/adapter.ts +88 -10
- package/src/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- package/src/retrieve-follow.ts +2 -1
- package/src/retrieve-sample.ts +2 -1
- package/src/retrieve-wait.ts +10 -6
- package/src/retrieve.ts +77 -27
- package/src/transfer.ts +38 -13
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
* after `<name>=` with the property's own parser: `where (comment=+)`,
|
|
30
30
|
* `(comment=a-)`, `(comment=@)` and `(.id=*1)` parse, though each is an
|
|
31
31
|
* expression error elsewhere. So a group in a query gets only the structural
|
|
32
|
-
* checks (empty, `;`, newline)
|
|
33
|
-
* `@` rules.
|
|
32
|
+
* checks (empty, `;`, newline), #509's brace rule and the unquoted-byte
|
|
33
|
+
* rule below, never the operand or `@` rules.
|
|
34
34
|
* - **Arrays (#511).** An array that is the whole value of a statement or an
|
|
35
35
|
* argument must have an element: `:local z {}`, `:put {}`, `:return {}`,
|
|
36
36
|
* `[:len {}]` and `in={}` are errors, while `{1;{}}`, `{a={}}`, `({})`,
|
|
@@ -53,6 +53,21 @@
|
|
|
53
53
|
* declared name that is not letters and digits. `gluedAt`, `wordFaults` and
|
|
54
54
|
* `nameFault` carry the device rules. One token fault is reported per
|
|
55
55
|
* statement, and the head word is never read.
|
|
56
|
+
* - **Unquoted bytes (#358, #371).** A `?`, a backtick and a non-ASCII byte
|
|
57
|
+
* are never part of a bare word: the device rejects each at its byte in a
|
|
58
|
+
* head word, a name, a `$name`, a value, a query, an array and an
|
|
59
|
+
* expression (`/systemé`, `print?`, `$café`, `url=http://x/a?b`,
|
|
60
|
+
* `where comment=café`, `{a`b}`, `(café)`), but a menu command's argument
|
|
61
|
+
* name at the name's first byte (`addré=1`, `a?b=1`). A second `=` in a
|
|
62
|
+
* menu command's argument is rejected too (`name=a=b`, a bare base64 key
|
|
63
|
+
* ending in `=`). In a query and in an array it is an operator and a key
|
|
64
|
+
* (`where comment=a=b`, `{a=b}` parse), and a query group takes the byte
|
|
65
|
+
* rule but no other (`where (comment=a?b)` and `in {a?b}` are rejected at
|
|
66
|
+
* the `?`, `where (comment=a=b)` parses); a directive's `a=b` is a named
|
|
67
|
+
* argument on 7.24.5 and a syntax error on 7.23.8, so it is left alone. No
|
|
68
|
+
* property parser was found that takes these bytes (`regexp=a?b`,
|
|
69
|
+
* `public-key=…=`, `secret=…==`, `on-event=a=b` are all rejected). Strings
|
|
70
|
+
* and comments carry any byte (`"café"`, `$"café"`, `:local "café"`).
|
|
56
71
|
*
|
|
57
72
|
* Deliberately not here: a `$( … )` group (`:put $({})` is rejected and is
|
|
58
73
|
* read as `({})`), the `@` in a later head word (`/ip address@ print`), and
|
|
@@ -72,7 +87,9 @@ export type GroupFaultCode =
|
|
|
72
87
|
| "named-value-brace"
|
|
73
88
|
| "glued-token"
|
|
74
89
|
| "invalid-variable"
|
|
75
|
-
| "invalid-name"
|
|
90
|
+
| "invalid-name"
|
|
91
|
+
| "unquoted-non-ascii"
|
|
92
|
+
| "unquoted-reserved-byte";
|
|
76
93
|
|
|
77
94
|
export interface GroupFault {
|
|
78
95
|
code: GroupFaultCode;
|
|
@@ -84,6 +101,12 @@ export interface GroupFault {
|
|
|
84
101
|
detail?: string;
|
|
85
102
|
/** The group the fault sits in, for a message that must say which. */
|
|
86
103
|
group?: "(" | "{";
|
|
104
|
+
/**
|
|
105
|
+
* Where an unquoted-byte fault sits when it is not in a value: a menu
|
|
106
|
+
* command's argument name, or a statement's head word (path, command,
|
|
107
|
+
* `$name`). The remedy differs, since neither has a value to quote.
|
|
108
|
+
*/
|
|
109
|
+
site?: "argument-name" | "head-word";
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
interface StatementFrame {
|
|
@@ -95,6 +118,8 @@ interface StatementFrame {
|
|
|
95
118
|
whereEnd: number | null;
|
|
96
119
|
/** The head word without its colon, the key into `brace-slots.ts`. */
|
|
97
120
|
verb: string | null;
|
|
121
|
+
/** The head word is a `$` call, whose arguments no menu reads (`$f a=1`). */
|
|
122
|
+
call: boolean;
|
|
98
123
|
/** Positional operands read after the head. */
|
|
99
124
|
positionals: number;
|
|
100
125
|
/** No token of the current statement read yet. */
|
|
@@ -116,6 +141,8 @@ interface StatementToken {
|
|
|
116
141
|
closedAt: number;
|
|
117
142
|
/** Just past the token's last bare-word run, or -1. */
|
|
118
143
|
wordEnd: number;
|
|
144
|
+
/** The token's first bare `=`, or -1. */
|
|
145
|
+
eqAt: number;
|
|
119
146
|
}
|
|
120
147
|
|
|
121
148
|
interface ArrayFrame {
|
|
@@ -201,6 +228,31 @@ const STRICT_NAME_DIRECTIVES = new Set(["local", "global", "foreach"]);
|
|
|
201
228
|
const ALNUM = /[A-Za-z0-9]/;
|
|
202
229
|
const SUB = "\x1a";
|
|
203
230
|
|
|
231
|
+
/** A byte no bare word holds: `?`, a backtick, a non-ASCII stand-in (#358, #371). */
|
|
232
|
+
function isUnquotedFaultByte(c: string | undefined): boolean {
|
|
233
|
+
return c === "?" || c === "`" || c === SUB;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The first such byte in `[from, end)`, or -1. */
|
|
237
|
+
function unquotedFaultByteAt(masked: string, from: number, end: number) {
|
|
238
|
+
for (let j = from; j < end; j++) if (isUnquotedFaultByte(masked[j])) return j;
|
|
239
|
+
return -1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The fault for the byte at `at`; a non-ASCII run is reported whole. */
|
|
243
|
+
function unquotedByteFault(masked: string, at: number): GroupFault {
|
|
244
|
+
if (masked[at] !== SUB)
|
|
245
|
+
return {
|
|
246
|
+
code: "unquoted-reserved-byte",
|
|
247
|
+
start: at,
|
|
248
|
+
end: at + 1,
|
|
249
|
+
detail: masked[at],
|
|
250
|
+
};
|
|
251
|
+
let end = at;
|
|
252
|
+
while (masked[end] === SUB) end++;
|
|
253
|
+
return { code: "unquoted-non-ascii", start: at, end };
|
|
254
|
+
}
|
|
255
|
+
|
|
204
256
|
function statementFrame(char: StatementFrame["char"]): StatementFrame {
|
|
205
257
|
return {
|
|
206
258
|
kind: "statement",
|
|
@@ -208,6 +260,7 @@ function statementFrame(char: StatementFrame["char"]): StatementFrame {
|
|
|
208
260
|
query: false,
|
|
209
261
|
whereEnd: null,
|
|
210
262
|
verb: null,
|
|
263
|
+
call: false,
|
|
211
264
|
positionals: 0,
|
|
212
265
|
lead: true,
|
|
213
266
|
token: null,
|
|
@@ -245,6 +298,7 @@ function openToken(frame: StatementFrame, at: number) {
|
|
|
245
298
|
equals: false,
|
|
246
299
|
closedAt: -1,
|
|
247
300
|
wordEnd: -1,
|
|
301
|
+
eqAt: -1,
|
|
248
302
|
};
|
|
249
303
|
frame.lead = false;
|
|
250
304
|
return frame.token;
|
|
@@ -287,12 +341,10 @@ function gluedAt(
|
|
|
287
341
|
(frame.verb !== null || ALNUM.test(before ?? "")));
|
|
288
342
|
if (!glued) return null;
|
|
289
343
|
const c = masked[at];
|
|
290
|
-
// A stray `\` is `symbols.ts`'s, and a non-ASCII stand-in is #371's.
|
|
291
|
-
if (c === "\\" || c === SUB) {
|
|
292
|
-
frame.settled = true;
|
|
293
|
-
return null;
|
|
294
|
-
}
|
|
295
344
|
frame.settled = true;
|
|
345
|
+
// A stray `\` is `symbols.ts`'s; a non-ASCII byte is its own fault (`"x"é`).
|
|
346
|
+
if (c === "\\") return null;
|
|
347
|
+
if (c === SUB) return unquotedByteFault(masked, at);
|
|
296
348
|
return { code: "glued-token", start: at, end: at + 1 };
|
|
297
349
|
}
|
|
298
350
|
|
|
@@ -343,7 +395,21 @@ function wordFaults(
|
|
|
343
395
|
if (named !== undefined) return named;
|
|
344
396
|
}
|
|
345
397
|
while (j < end) {
|
|
346
|
-
|
|
398
|
+
const c = masked[j];
|
|
399
|
+
if (isUnquotedFaultByte(c)) {
|
|
400
|
+
frame.settled = true;
|
|
401
|
+
return (
|
|
402
|
+
argumentNameFault(masked, frame, j, end) ?? unquotedByteFault(masked, j)
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (c === "=") {
|
|
406
|
+
if (token.eqAt < 0) token.eqAt = j;
|
|
407
|
+
else if (frame.verb === null && !frame.call) {
|
|
408
|
+
frame.settled = true;
|
|
409
|
+
return unquotedByteFault(masked, j);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (c !== "$") {
|
|
347
413
|
j++;
|
|
348
414
|
continue;
|
|
349
415
|
}
|
|
@@ -367,14 +433,12 @@ function wordFaults(
|
|
|
367
433
|
token.wordEnd = -1;
|
|
368
434
|
return null;
|
|
369
435
|
}
|
|
436
|
+
if (next === SUB) {
|
|
437
|
+
frame.settled = true;
|
|
438
|
+
return unquotedByteFault(masked, j + 1);
|
|
439
|
+
}
|
|
370
440
|
// `$$v` is rejected after the name, not at the second `$`.
|
|
371
|
-
if (
|
|
372
|
-
next === "(" ||
|
|
373
|
-
next === "[" ||
|
|
374
|
-
next === "{" ||
|
|
375
|
-
next === "$" ||
|
|
376
|
-
next === SUB
|
|
377
|
-
) {
|
|
441
|
+
if (next === "(" || next === "[" || next === "{" || next === "$") {
|
|
378
442
|
frame.settled = true;
|
|
379
443
|
return null;
|
|
380
444
|
}
|
|
@@ -390,6 +454,26 @@ function wordFaults(
|
|
|
390
454
|
return null;
|
|
391
455
|
}
|
|
392
456
|
|
|
457
|
+
/**
|
|
458
|
+
* A bad byte at `at` inside a menu command's argument name: the device
|
|
459
|
+
* reports it at the name's first byte (`/ip/address/add addré=1` and
|
|
460
|
+
* `a?b=1` at the `a`), and the span is the name. A directive (`:put café=1`)
|
|
461
|
+
* and a `$` call (`$f café=1`) report at the byte, so this is `null` there.
|
|
462
|
+
*/
|
|
463
|
+
function argumentNameFault(
|
|
464
|
+
masked: string,
|
|
465
|
+
frame: StatementFrame & { token: StatementToken },
|
|
466
|
+
at: number,
|
|
467
|
+
end: number,
|
|
468
|
+
): GroupFault | null {
|
|
469
|
+
const { token } = frame;
|
|
470
|
+
if (frame.verb !== null || frame.call || token.eqAt >= 0) return null;
|
|
471
|
+
const eq = masked.indexOf("=", at);
|
|
472
|
+
if (eq < 0 || eq >= end) return null;
|
|
473
|
+
const fault = unquotedByteFault(masked, at);
|
|
474
|
+
return { ...fault, start: token.start, end: eq, site: "argument-name" };
|
|
475
|
+
}
|
|
476
|
+
|
|
393
477
|
/**
|
|
394
478
|
* The declared-name fault in the word at `at`, `null` when the name is
|
|
395
479
|
* well formed, or `undefined` when the word is not a bare name at all (a
|
|
@@ -473,9 +557,15 @@ const CLOSER = { "(": ")", "[": "]", "{": "}" } as const;
|
|
|
473
557
|
|
|
474
558
|
/**
|
|
475
559
|
* Find every group fault in an analyzed document. `masked` is its comment
|
|
476
|
-
* mask; `text` is read only to tell a masked comment line from spaces.
|
|
560
|
+
* mask; `text` is read only to tell a masked comment line from spaces. The
|
|
561
|
+
* walk starts at `from`, past a leading BOM, which is a positional fact and
|
|
562
|
+
* not a byte of the first word.
|
|
477
563
|
*/
|
|
478
|
-
export function findGroupFaults(
|
|
564
|
+
export function findGroupFaults(
|
|
565
|
+
text: string,
|
|
566
|
+
masked: string,
|
|
567
|
+
from = 0,
|
|
568
|
+
): GroupFault[] {
|
|
479
569
|
const faults: GroupFault[] = [];
|
|
480
570
|
const frames: Frame[] = [statementFrame("")];
|
|
481
571
|
const top = (): Frame => frames[frames.length - 1] as Frame;
|
|
@@ -502,7 +592,7 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
|
|
|
502
592
|
// `(1 +\⏎⏎2)`, and reject `(1 +\⏎# c⏎⏎2)` at the blank line.
|
|
503
593
|
let cont: "none" | "escape" | "comment" = "none";
|
|
504
594
|
let contLineStart = false;
|
|
505
|
-
let i =
|
|
595
|
+
let i = from;
|
|
506
596
|
while (i < masked.length) {
|
|
507
597
|
const c = masked[i] as string;
|
|
508
598
|
const frame = top();
|
|
@@ -597,20 +687,27 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
|
|
|
597
687
|
(frame.kind === "statement" && frame.char === "[")
|
|
598
688
|
? whereEnd
|
|
599
689
|
: null;
|
|
690
|
+
// A `where` named comparison never takes a script body through an
|
|
691
|
+
// unproven name — `[find where comment={ :beep }]` is `expected
|
|
692
|
+
// comment value` on CHR 7.25 — so the brace reads as a VALUE for
|
|
693
|
+
// the role below and the fault stands (#554). Proven slots still
|
|
694
|
+
// read as bodies; the gate is only the unproven disjunct.
|
|
695
|
+
const inWhereComparison =
|
|
696
|
+
c === "{" && floor !== null && comparesNamedValue(masked, i, floor);
|
|
600
697
|
const role =
|
|
601
698
|
c === "{" && frame.kind === "statement"
|
|
602
|
-
? braceStartsStatements(
|
|
699
|
+
? braceStartsStatements(
|
|
700
|
+
masked,
|
|
701
|
+
i,
|
|
702
|
+
0,
|
|
703
|
+
inWhereComparison ? "value" : "body",
|
|
704
|
+
)
|
|
603
705
|
? "program"
|
|
604
706
|
: directiveSlotRole(masked, i, frame)
|
|
605
707
|
: null;
|
|
606
708
|
// A body (`do={`) is never a comparison, though `where` is still open:
|
|
607
709
|
// corpus `eworm/hotspot-to-wpa.template.rsc` interleaves two `:if`s.
|
|
608
|
-
if (
|
|
609
|
-
c === "{" &&
|
|
610
|
-
role !== "program" &&
|
|
611
|
-
floor !== null &&
|
|
612
|
-
comparesNamedValue(masked, i, floor)
|
|
613
|
-
) {
|
|
710
|
+
if (c === "{" && role !== "program" && inWhereComparison) {
|
|
614
711
|
// The device stops at this brace, so neither its contents nor the
|
|
615
712
|
// rest of the enclosing group are read.
|
|
616
713
|
faults.push({ code: "named-value-brace", start: i, end: i + 1 });
|
|
@@ -738,10 +835,26 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
|
|
|
738
835
|
: BARE_DECLARATIONS.has(word)
|
|
739
836
|
? word
|
|
740
837
|
: null;
|
|
838
|
+
frame.call = word.startsWith("$");
|
|
839
|
+
// `[/ip/address/find comment=a=b]` is a query, as `[find …]` is.
|
|
840
|
+
if (frame.verb === null && /(^|\/)find$/i.test(word))
|
|
841
|
+
frame.query = true;
|
|
842
|
+
const bad = unquotedFaultByteAt(masked, i, end);
|
|
843
|
+
if (bad >= 0 && (at < 0 || i + at > bad)) {
|
|
844
|
+
frame.settled = true;
|
|
845
|
+
faults.push({ ...unquotedByteFault(masked, bad), site: "head-word" });
|
|
846
|
+
}
|
|
741
847
|
}
|
|
742
848
|
if (readsTokens(frame)) {
|
|
743
849
|
const fault = wordFaults(masked, frame, i, end);
|
|
744
850
|
if (fault !== null) faults.push(fault);
|
|
851
|
+
} else if (frame.query && !frame.settled && !token.head) {
|
|
852
|
+
// A query keeps `=` as an operator, but not these bytes.
|
|
853
|
+
const bad = unquotedFaultByteAt(masked, i, end);
|
|
854
|
+
if (bad >= 0) {
|
|
855
|
+
frame.settled = true;
|
|
856
|
+
faults.push(unquotedByteFault(masked, bad));
|
|
857
|
+
}
|
|
745
858
|
}
|
|
746
859
|
if (word.includes("=")) token.equals = true;
|
|
747
860
|
const lower = word.toLowerCase();
|
|
@@ -760,6 +873,12 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
|
|
|
760
873
|
}
|
|
761
874
|
|
|
762
875
|
if (frame.kind === "opaque") {
|
|
876
|
+
// A query's `in {…}` is read as opaque, so `where … in {}` keeps
|
|
877
|
+
// parsing; its bytes are still rejected (`in {a?b}` at the `?`).
|
|
878
|
+
if (frame.query && !frame.muted && isUnquotedFaultByte(c)) {
|
|
879
|
+
faults.push(unquotedByteFault(masked, i));
|
|
880
|
+
frame.muted = true;
|
|
881
|
+
}
|
|
763
882
|
i++;
|
|
764
883
|
continue;
|
|
765
884
|
}
|
|
@@ -824,6 +943,12 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
|
|
|
824
943
|
i++;
|
|
825
944
|
continue;
|
|
826
945
|
}
|
|
946
|
+
// Unlike `@`, these bytes are rejected in a query too (`in {a?b}`).
|
|
947
|
+
if (isUnquotedFaultByte(c)) {
|
|
948
|
+
report(unquotedByteFault(masked, i), frame);
|
|
949
|
+
i++;
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
827
952
|
frame.element = true;
|
|
828
953
|
frame.last = "element";
|
|
829
954
|
i++;
|
|
@@ -849,8 +974,16 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
|
|
|
849
974
|
i++;
|
|
850
975
|
continue;
|
|
851
976
|
}
|
|
977
|
+
// A query group keeps its operators, but not these bytes:
|
|
978
|
+
// `where (comment=a?b)` is a `syntax error` at the `?`.
|
|
979
|
+
if (isUnquotedFaultByte(c)) {
|
|
980
|
+
report(unquotedByteFault(masked, i), frame);
|
|
981
|
+
i++;
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
852
984
|
if (frame.query) {
|
|
853
|
-
// Structural checks only (module header): any other byte is
|
|
985
|
+
// Structural and byte checks only (module header): any other byte is
|
|
986
|
+
// content.
|
|
854
987
|
frame.empty = false;
|
|
855
988
|
frame.wantOperand = false;
|
|
856
989
|
i++;
|
|
@@ -918,8 +1051,8 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
|
|
|
918
1051
|
i += spelling.length;
|
|
919
1052
|
continue;
|
|
920
1053
|
}
|
|
921
|
-
// `#`,
|
|
922
|
-
//
|
|
1054
|
+
// `#`, `'`, a stray `\`: another rule's byte, and nothing here can say
|
|
1055
|
+
// what the group means after it.
|
|
923
1056
|
frame.done = true;
|
|
924
1057
|
i++;
|
|
925
1058
|
}
|
package/src/explain/operators.ts
CHANGED
|
@@ -143,7 +143,8 @@ const OPERATORS: readonly RouterosOperator[] = [
|
|
|
143
143
|
// different operator, and arity is the only thing that separates them.
|
|
144
144
|
// Offline value-shape hints abstain on every such form: identical
|
|
145
145
|
// `(> …)` syntax can be `op` or `array` at runtime and `:parse` IL cannot
|
|
146
|
-
// distinguish them
|
|
146
|
+
// distinguish them; only `:typeof` can, and live explain never sends it,
|
|
147
|
+
// so `op` has no producer at all (#288, #225).
|
|
147
148
|
op(">", [1, 2], 5, "left", "relational", ">"),
|
|
148
149
|
op("=", [2], 5, "left", "relational", "="),
|
|
149
150
|
op("<=", [2], 5, "left", "relational", "<="),
|
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* gives comment masking, segmentation, and symbol resolution one rule.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { commandVerbIndex } from "./catalog.ts";
|
|
11
|
+
import { isKnownMenuPath } from "./is-known-menu.ts";
|
|
10
12
|
import { scanQuotedString } from "./quoted-string.ts";
|
|
11
|
-
import { runNamesCommand } from "./run-tokens.ts";
|
|
13
|
+
import { runNamesCommand, runTokens } from "./run-tokens.ts";
|
|
14
|
+
import { VERBS } from "./verbs.ts";
|
|
12
15
|
|
|
13
16
|
/** Error-variable shape for `:onerror V { … }` — a bare/`$`-prefixed name. */
|
|
14
17
|
const ERROR_VAR = /^\$?[A-Za-z][A-Za-z0-9._-]*$/;
|
|
@@ -96,17 +99,82 @@ const SCRIPT_BODY_ARG = /^(?:source|script|.+-script|on-.+)$/;
|
|
|
96
99
|
* offline misses it.
|
|
97
100
|
*
|
|
98
101
|
* Abbreviating the FIRST segment (`down={ :beep }` for `down-script`, also
|
|
99
|
-
* accepted on device) stays outside. Every word is a prefix
|
|
100
|
-
* so admitting it would admit `comment={ :beep }
|
|
101
|
-
* command's argument list,
|
|
102
|
+
* accepted on device) stays outside the PROVEN shape. Every word is a prefix
|
|
103
|
+
* of `<word>-script`, so admitting it here would admit `comment={ :beep }` as
|
|
104
|
+
* a confident body. Telling them apart needs the command's argument list,
|
|
105
|
+
* which is schema — so a `name={` with a script-like body that reaches this
|
|
106
|
+
* far reads as a body for every reader (comments inside are comments) but is
|
|
107
|
+
* warned as a possible abbreviation instead of passing silently (#525,
|
|
108
|
+
* `args.ts` → `invalidCommandBraceOffsets`).
|
|
102
109
|
*/
|
|
103
110
|
const SCRIPT_BODY_ABBREVIATION =
|
|
104
111
|
/^(?:s(?:o(?:u(?:r(?:c)?)?)?)?|sc(?:r(?:i(?:p)?)?)?|on?|.+-(?:s(?:c(?:r(?:i(?:p)?)?)?)?)?)$/;
|
|
105
112
|
|
|
106
|
-
|
|
113
|
+
/** Whether `name` is a proven script-body slot (full or pinned abbreviation). */
|
|
114
|
+
export function isScriptBodyName(name: string): boolean {
|
|
107
115
|
return SCRIPT_BODY_ARG.test(name) || SCRIPT_BODY_ABBREVIATION.test(name);
|
|
108
116
|
}
|
|
109
117
|
|
|
118
|
+
function isAsciiLetter(char: string | undefined): boolean {
|
|
119
|
+
if (char === undefined) return false;
|
|
120
|
+
return (char >= "A" && char <= "Z") || (char >= "a" && char <= "z");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether the `{` at `open` holds a script-like body (#525). `end` is the
|
|
125
|
+
* exclusive brace end (or the text length when unclosed).
|
|
126
|
+
*
|
|
127
|
+
* A body opens with a directive, an absolute command, or one bare command
|
|
128
|
+
* word: `{ :beep }`, `{ /ip … }`, `{ beep }` and `{ # comment⏎:beep }` are
|
|
129
|
+
* all accepted on CHR 7.25. A bare word followed by a top-level `;` is array
|
|
130
|
+
* members, not commands — `.proplist={name;comment}` is device-rejected —
|
|
131
|
+
* so it keeps the error, as do `{1;2}`, `{"x"}`, `{}` and `$`-led values,
|
|
132
|
+
* which cannot open a command at all. The scan touches each byte between
|
|
133
|
+
* the braces once, skipping strings and `#`-to-end-of-line runs, so it is
|
|
134
|
+
* linear; it works on masked and unmasked text alike (a masked comment is
|
|
135
|
+
* already whitespace).
|
|
136
|
+
*/
|
|
137
|
+
export function braceBodyIsScriptLike(
|
|
138
|
+
text: string,
|
|
139
|
+
open: number,
|
|
140
|
+
end: number,
|
|
141
|
+
): boolean {
|
|
142
|
+
let j = open + 1;
|
|
143
|
+
for (;;) {
|
|
144
|
+
if (j >= end) return false;
|
|
145
|
+
const c = text[j] as string;
|
|
146
|
+
if (c === " " || c === "\t" || c === "\r" || c === "\n") {
|
|
147
|
+
j++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (c === "#") {
|
|
151
|
+
while (j < end && text[j] !== "\n") j++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (c === ":" || c === "/") return true;
|
|
155
|
+
if (!isAsciiLetter(c)) return false;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
let depth = 0;
|
|
159
|
+
while (j < end) {
|
|
160
|
+
const c = text[j] as string;
|
|
161
|
+
if (c === '"') {
|
|
162
|
+
const scan = scanQuotedString(text, j);
|
|
163
|
+
j = Math.min(scan.end, end);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (c === "#") {
|
|
167
|
+
while (j < end && text[j] !== "\n") j++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (c === "{" || c === "[" || c === "(") depth++;
|
|
171
|
+
else if ((c === "}" || c === "]" || c === ")") && depth > 0) depth--;
|
|
172
|
+
else if (c === ";" && depth === 0) return false;
|
|
173
|
+
j++;
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
110
178
|
/** Grounded scripting directives whose positional arity makes a later `#` fatal. */
|
|
111
179
|
const DIRECTIVE_POSITIONAL_ARITY: Readonly<Record<string, number>> = {
|
|
112
180
|
local: 2,
|
|
@@ -121,6 +189,12 @@ interface StatementIndex {
|
|
|
121
189
|
boundaries: Int32Array;
|
|
122
190
|
firstContent: Int32Array;
|
|
123
191
|
lastForbidden: Int32Array;
|
|
192
|
+
/**
|
|
193
|
+
* The innermost open delimiter at each nested `;`/newline boundary. Sparse,
|
|
194
|
+
* because a per-offset array pushed a 540 KiB document past
|
|
195
|
+
* `BOUNDARY_CACHE_BYTE_LIMIT` and made every probe rebuild the index.
|
|
196
|
+
*/
|
|
197
|
+
nestedSeparators: Map<number, number>;
|
|
124
198
|
matchingBrace: Map<number, number>;
|
|
125
199
|
}
|
|
126
200
|
|
|
@@ -162,6 +236,7 @@ function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
|
|
|
162
236
|
index.boundaries.byteLength +
|
|
163
237
|
index.firstContent.byteLength +
|
|
164
238
|
index.lastForbidden.byteLength +
|
|
239
|
+
index.nestedSeparators.size * 32 +
|
|
165
240
|
index.matchingBrace.size * 32
|
|
166
241
|
);
|
|
167
242
|
}
|
|
@@ -223,17 +298,34 @@ function statementIndex(text: string): StatementIndex {
|
|
|
223
298
|
firstContent.fill(-1);
|
|
224
299
|
const lastForbidden = new Int32Array(text.length + 1);
|
|
225
300
|
lastForbidden.fill(-1);
|
|
301
|
+
const nestedSeparators = new Map<number, number>();
|
|
226
302
|
const matchingBrace = new Map<number, number>();
|
|
227
303
|
const delimiters: { char: string; at: number }[] = [];
|
|
228
304
|
let boundary = -1;
|
|
229
305
|
let first = -1;
|
|
230
306
|
let forbidden = -1;
|
|
307
|
+
let comment = false;
|
|
231
308
|
let i = 0;
|
|
232
309
|
while (i < text.length) {
|
|
233
310
|
boundaries[i] = boundary;
|
|
234
311
|
firstContent[i] = first;
|
|
235
312
|
lastForbidden[i] = forbidden;
|
|
236
313
|
const c = text[i] as string;
|
|
314
|
+
// A statement-leading `#` runs to the line end, and a `{`, `}` or `"` in
|
|
315
|
+
// it is not structure: `/ip { # }⏎address { print {1;2} } }` is rejected
|
|
316
|
+
// at the inner `{` on CHR 7.24.5 and 7.23.8, so the `}` must not close
|
|
317
|
+
// `/ip {` (#351). Read raw, the bytes are blank, as `maskComments` makes
|
|
318
|
+
// them; read masked, they already are.
|
|
319
|
+
if (comment && c !== "\n") {
|
|
320
|
+
i++;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
comment = false;
|
|
324
|
+
if (c === "#" && first === -1 && delimiters.at(-1)?.char !== "(") {
|
|
325
|
+
comment = true;
|
|
326
|
+
i++;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
237
329
|
if (c === '"') {
|
|
238
330
|
const end = Math.min(scanQuotedString(text, i).end, text.length);
|
|
239
331
|
boundaries.fill(boundary, i + 1, end + 1);
|
|
@@ -255,6 +347,9 @@ function statementIndex(text: string): StatementIndex {
|
|
|
255
347
|
if (first === -1 && !isAsciiWhitespace(c)) first = i;
|
|
256
348
|
if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
|
|
257
349
|
if (c === "\n" || c === ";" || c === "{" || c === "[") {
|
|
350
|
+
const open = delimiters.at(-1);
|
|
351
|
+
if ((c === "\n" || c === ";") && open !== undefined)
|
|
352
|
+
nestedSeparators.set(i, open.at);
|
|
258
353
|
boundary = i;
|
|
259
354
|
first = -1;
|
|
260
355
|
}
|
|
@@ -267,6 +362,7 @@ function statementIndex(text: string): StatementIndex {
|
|
|
267
362
|
boundaries,
|
|
268
363
|
firstContent,
|
|
269
364
|
lastForbidden,
|
|
365
|
+
nestedSeparators,
|
|
270
366
|
matchingBrace,
|
|
271
367
|
};
|
|
272
368
|
if (cacheStatementIndex(text, index)) adoptBoundaryText(text, index);
|
|
@@ -462,18 +558,41 @@ export function scopeNameFromMasked(
|
|
|
462
558
|
* Whether this brace begins a context in which statement-leading comments exist.
|
|
463
559
|
*
|
|
464
560
|
* `floor` bounds the lookback exactly as in {@link scopeNameFromMasked}.
|
|
561
|
+
* `unprovenName` decides the unproven `name={` guess below: `"body"` reads it
|
|
562
|
+
* as a body, `"value"` as a value. Only the script-like disjunct is gated —
|
|
563
|
+
* a proven slot stays a body either way. Readers that are themselves deciding
|
|
564
|
+
* an error AT this brace pass `"value"` where the slot cannot bear a body
|
|
565
|
+
* (`args.ts` for a root directive's slots and a `where` named comparison,
|
|
566
|
+
* `group-faults.ts` for the bracketed form), so the guess does not swallow
|
|
567
|
+
* their error; every other reader keeps `"body"`.
|
|
465
568
|
*/
|
|
466
569
|
export function braceStartsStatements(
|
|
467
570
|
text: string,
|
|
468
571
|
open: number,
|
|
469
572
|
floor = 0,
|
|
573
|
+
unprovenName: "body" | "value" = "body",
|
|
470
574
|
): boolean {
|
|
471
575
|
if (scopeNameFromMasked(text, open, floor) !== null) return true;
|
|
472
576
|
const index = statementIndex(text);
|
|
473
577
|
const boundary = index.boundaries[open] ?? -1;
|
|
474
578
|
const start = Math.max(boundary + 1, floor);
|
|
475
579
|
const named = trailingName(text, start, open);
|
|
476
|
-
if (named !== null)
|
|
580
|
+
if (named !== null) {
|
|
581
|
+
// A `name={` with a script-like body reads as a body for every
|
|
582
|
+
// reader, proven slot or not (#525): `down={ beep }` and
|
|
583
|
+
// `down={ # comment⏎:beep }` are accepted on CHR 7.25, and the `#`
|
|
584
|
+
// is only a comment when the brace bears statements. The warning
|
|
585
|
+
// that marks the unproven slot lives in `args.ts`, which checks
|
|
586
|
+
// ambiguity before this skip.
|
|
587
|
+
if (isScriptBodyName(named.toLowerCase())) return true;
|
|
588
|
+
if (unprovenName === "value") return false;
|
|
589
|
+
const close = matchingBraceEnd(text, open);
|
|
590
|
+
return braceBodyIsScriptLike(
|
|
591
|
+
text,
|
|
592
|
+
open,
|
|
593
|
+
close === undefined ? text.length : close + 1,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
477
596
|
// `source { :beep }` — the same body with the `=` left out (#350). Every
|
|
478
597
|
// grounded spelling lowers to the `source=` IL on CHR 7.22.1 and 7.24.5:
|
|
479
598
|
// `/system/script add name=beep source { :beep }`, `/system/scheduler add
|
|
@@ -493,12 +612,108 @@ export function braceStartsStatements(
|
|
|
493
612
|
// but a run that already names a command makes the brace its operand (#496).
|
|
494
613
|
// `start` follows the last `{`, so each brace reads its own run: linear.
|
|
495
614
|
const first = index.firstContent[open] ?? -1;
|
|
496
|
-
return
|
|
497
|
-
|
|
498
|
-
(
|
|
615
|
+
if (first < start) return true;
|
|
616
|
+
if (text[first] === "/")
|
|
617
|
+
return (
|
|
499
618
|
(index.lastForbidden[open] ?? -1) < start &&
|
|
500
|
-
!runNamesCommand(text.slice(first, open))
|
|
501
|
-
|
|
619
|
+
!runNamesCommand(text.slice(first, open))
|
|
620
|
+
);
|
|
621
|
+
return menuBlockPath(text, open, floor) !== null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Nesting a {@link menuBlockPath} lookup follows before it abstains. */
|
|
625
|
+
const MAX_MENU_BLOCK_DEPTH = 64;
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* A relative run of bare path words, space- or slash-separated. Each word is
|
|
629
|
+
* `pathresolve`'s `BARE_WORD`: the resolver reads no digit-led word as a path,
|
|
630
|
+
* so admitting `6to4 {` here would flatten a block whose navigation it then
|
|
631
|
+
* ignores, resolving the body's `print` against `/interface`.
|
|
632
|
+
*/
|
|
633
|
+
const RELATIVE_MENU_RUN =
|
|
634
|
+
/^[A-Za-z][A-Za-z0-9._-]*(?:[ \t/]+[A-Za-z][A-Za-z0-9._-]*)*$/;
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The menu a `{` opens a statement block over, when the source alone says so
|
|
638
|
+
* (#351). Null means offline cannot tell, not that the brace is a value.
|
|
639
|
+
*
|
|
640
|
+
* On CHR 7.24.5 and 7.23.8 a brace right after a menu word is always a
|
|
641
|
+
* statement block, at any depth and whatever the body holds:
|
|
642
|
+
* `/ip { address { 1;2 } }` is `bad command name 1` at the `1`, and
|
|
643
|
+
* `/ip { address { a;b } }` reads `a` as `add`. A brace after a command word is
|
|
644
|
+
* always an error at the `{` (`/ip { address { print {1;2} } }`). The `/`-led
|
|
645
|
+
* spelling was already read as a block; the RELATIVE one (`address {` inside
|
|
646
|
+
* `/ip { … }`) was read as `/ip address` with a brace operand. That is what
|
|
647
|
+
* hard-failed the outer brace at depth three and hid every fault in the body.
|
|
648
|
+
*
|
|
649
|
+
* A relative run is a block only when the menu it names is known: the
|
|
650
|
+
* enclosing container's path, read the same way outward, plus the run, named by
|
|
651
|
+
* a shipped structure table. A miss abstains, so an unknown word keeps today's
|
|
652
|
+
* reading and the resolver says `unresolved`. The path is lexical: a
|
|
653
|
+
* navigation statement earlier in the same body (`/ip { firewall; address {`)
|
|
654
|
+
* is not followed, and the resolver checks the flattened run against the real
|
|
655
|
+
* context, so that residue can only abstain. Abbreviated menus (`addr {`) are
|
|
656
|
+
* not expanded, as R13 does not expand them.
|
|
657
|
+
*
|
|
658
|
+
* `floor` keeps the ranged-call contract of {@link scopeNameFromMasked}: no
|
|
659
|
+
* enclosing brace before it is consulted, so a range reads exactly as the same
|
|
660
|
+
* text taken alone.
|
|
661
|
+
*/
|
|
662
|
+
export function menuBlockPath(
|
|
663
|
+
text: string,
|
|
664
|
+
open: number,
|
|
665
|
+
floor = 0,
|
|
666
|
+
): readonly string[] | null {
|
|
667
|
+
return blockPathAt(text, statementIndex(text), open, floor, 0);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function blockPathAt(
|
|
671
|
+
text: string,
|
|
672
|
+
index: StatementIndex,
|
|
673
|
+
open: number,
|
|
674
|
+
floor: number,
|
|
675
|
+
depth: number,
|
|
676
|
+
): readonly string[] | null {
|
|
677
|
+
if (depth > MAX_MENU_BLOCK_DEPTH) return null;
|
|
678
|
+
const start = Math.max((index.boundaries[open] ?? -1) + 1, floor);
|
|
679
|
+
const first = index.firstContent[open] ?? -1;
|
|
680
|
+
if (first < start) return enclosingBlockPath(text, index, open, floor, depth);
|
|
681
|
+
if ((index.lastForbidden[open] ?? -1) >= start) return null;
|
|
682
|
+
const run = text.slice(first, open);
|
|
683
|
+
if (text[first] === "/") {
|
|
684
|
+
if (runNamesCommand(run)) return null;
|
|
685
|
+
return runTokens(run).map((token) => token.name);
|
|
686
|
+
}
|
|
687
|
+
const trimmed = run.trim();
|
|
688
|
+
if (!RELATIVE_MENU_RUN.test(trimmed)) return null;
|
|
689
|
+
const names = trimmed.split(/[\s/]+/);
|
|
690
|
+
if (names.some((name) => VERBS.has(name))) return null;
|
|
691
|
+
const parent = enclosingBlockPath(text, index, open, floor, depth);
|
|
692
|
+
if (parent === null) return null;
|
|
693
|
+
const path = [...parent, ...names];
|
|
694
|
+
if (commandVerbIndex(path) !== null || !isKnownMenuPath(path)) return null;
|
|
695
|
+
return path;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The path of the container `{` around the statement that holds `at`, when it
|
|
700
|
+
* is one. The statement starts after a boundary: an opening `{`/`[` is the
|
|
701
|
+
* enclosing delimiter itself, and a separator records it.
|
|
702
|
+
*/
|
|
703
|
+
function enclosingBlockPath(
|
|
704
|
+
text: string,
|
|
705
|
+
index: StatementIndex,
|
|
706
|
+
at: number,
|
|
707
|
+
floor: number,
|
|
708
|
+
depth: number,
|
|
709
|
+
): readonly string[] | null {
|
|
710
|
+
const boundary = index.boundaries[at] ?? -1;
|
|
711
|
+
const outer =
|
|
712
|
+
boundary < 0 || text[boundary] === "{" || text[boundary] === "["
|
|
713
|
+
? boundary
|
|
714
|
+
: (index.nestedSeparators.get(boundary) ?? -1);
|
|
715
|
+
if (outer < floor || text[outer] !== "{") return null;
|
|
716
|
+
return blockPathAt(text, index, outer, floor, depth + 1);
|
|
502
717
|
}
|
|
503
718
|
|
|
504
719
|
/**
|