@tikoci/centrs 0.1.9-next.22 → 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 +38 -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 +43 -5
- package/commands/explain/README.md +873 -182
- package/commands/explain/examples.md +447 -38
- 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 +18 -9
- package/docs/MATRIX.md +51 -26
- 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 +5 -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 +181 -15
- package/src/explain/blocks.ts +3 -4
- package/src/explain/brace-slots.ts +5 -0
- package/src/explain/catalog.ts +13 -6
- package/src/explain/defects.ts +1 -0
- package/src/explain/group-faults.ts +1060 -0
- package/src/explain/highlight-projection.ts +10 -8
- package/src/explain/operators.ts +22 -20
- package/src/explain/pathresolve.ts +102 -20
- package/src/explain/quoted-string.ts +41 -6
- package/src/explain/run-tokens.ts +223 -0
- package/src/explain/scope-brace.ts +319 -7
- package/src/explain/segment.ts +177 -4
- package/src/explain/spaced-args.ts +116 -0
- package/src/explain/symbols.ts +163 -7
- package/src/explain/transport.ts +13 -0
- package/src/explain/values.ts +55 -4
- package/src/explain/verbsplit.ts +14 -188
- package/src/explain/write.ts +8 -1
- 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 +772 -60
- package/src/index.ts +6 -0
- package/src/offline-gate.ts +30 -9
- package/src/protocols/adapter.ts +88 -10
- package/src/protocols/btest-session.ts +30 -10
- package/src/protocols/mac-telnet-console.ts +16 -0
- 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
package/src/explain/segment.ts
CHANGED
|
@@ -84,13 +84,16 @@ import {
|
|
|
84
84
|
MAX_STRING_FRAME_DEPTH,
|
|
85
85
|
scanQuotedString as scanQuotedStringShared,
|
|
86
86
|
} from "./quoted-string.ts";
|
|
87
|
+
import { runNamesCommand } from "./run-tokens.ts";
|
|
87
88
|
import {
|
|
88
89
|
braceStartsStatements,
|
|
89
90
|
canRetainStatementIndex,
|
|
90
91
|
hashStartsHardError,
|
|
91
92
|
isIndexedStatementStart,
|
|
92
93
|
matchingBraceEnd,
|
|
94
|
+
menuBlockPath,
|
|
93
95
|
scopeNameFromMasked,
|
|
96
|
+
startsArrayCommentLine,
|
|
94
97
|
} from "./scope-brace.ts";
|
|
95
98
|
|
|
96
99
|
/** One top-level statement located in analyzed-byte space. */
|
|
@@ -130,6 +133,15 @@ export interface SegmentResult {
|
|
|
130
133
|
* a throw. Includes the two coordinate-pass classes (`bom`, `non-ascii`).
|
|
131
134
|
*/
|
|
132
135
|
defects: Defect[];
|
|
136
|
+
/**
|
|
137
|
+
* The first `}` or `]` that closes nothing at the top level, where RouterOS
|
|
138
|
+
* stops reading (#504). The device accepts the script and silently discards
|
|
139
|
+
* that byte and everything after it: `:put 1};:put 2` lowers to
|
|
140
|
+
* `(evl /putmessage=1)` and `}⏎:put 1` to `/`, on CHR 7.24.5 and 7.23.8.
|
|
141
|
+
* A `)` there, or any stray close inside an open group, is a rejection
|
|
142
|
+
* instead and is only an `unbalanced-close` defect. Absent when there is none.
|
|
143
|
+
*/
|
|
144
|
+
discardFrom?: number;
|
|
133
145
|
}
|
|
134
146
|
|
|
135
147
|
const isSpace = (c: string): boolean => c === " " || c === "\t" || c === "\r";
|
|
@@ -510,7 +522,12 @@ function maskUncached(
|
|
|
510
522
|
// indented `#` is a comment when the statement is still empty (`do={\` +
|
|
511
523
|
// newline + ` # c`) and content when it is not (`:put a\` + newline +
|
|
512
524
|
// ` # x`, which the device classes `error`) — exactly what `atLead` says.
|
|
513
|
-
if (
|
|
525
|
+
if (
|
|
526
|
+
c === "#" &&
|
|
527
|
+
(atLead ||
|
|
528
|
+
contComment ||
|
|
529
|
+
startsArrayCommentLine(original, i, contexts.at(-1)))
|
|
530
|
+
) {
|
|
514
531
|
const commentStart = i;
|
|
515
532
|
while (i < original.length && original[i] !== "\n") i++;
|
|
516
533
|
if (pieces === undefined) pieces = [];
|
|
@@ -608,6 +625,137 @@ export function segmentScopeBody(
|
|
|
608
625
|
);
|
|
609
626
|
}
|
|
610
627
|
|
|
628
|
+
/** Nesting cap for dispatch-statement counting (#549): the 16× guard (#322). */
|
|
629
|
+
const MAX_DISPATCH_STATEMENT_DEPTH = 16;
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Executable statements in `text`, at any nesting depth (#549).
|
|
633
|
+
*
|
|
634
|
+
* A dispatched script can carry statements inside executed control scopes
|
|
635
|
+
* (`:if … do={…}`, `:for`/`:foreach`/`:while`, `:do`, `:retry`, `:onerror`,
|
|
636
|
+
* `once do={…}`), and those run before a later refusal exactly like
|
|
637
|
+
* top-level ones do — `:if (true) do={ /system/note/set …; /user/add … }`
|
|
638
|
+
* applies the note before the refused add on CHR 7.25. Stored bodies
|
|
639
|
+
* (`source={…}`, `on-event={…}`, any other `name={`) never execute at
|
|
640
|
+
* dispatch, so only `scopeNameFromMasked` scopes recurse — except a `do={…}`
|
|
641
|
+
* under a `:global`/`:local`/`:set` head, which stores a function value.
|
|
642
|
+
* Command substitutions (`[…]`) run at dispatch too — `:put [:put "p5-first";
|
|
643
|
+
* /system/identity/set …]` prints before the refused set on CHR 7.25 — so
|
|
644
|
+
* they recurse unless they sit inside a stored or array `{…}`. An `else={…}`
|
|
645
|
+
* paired with its `do={…}` counts the larger side, not the sum, because the
|
|
646
|
+
* branches exclude each other.
|
|
647
|
+
*
|
|
648
|
+
* A segment with executed blocks or substitutions counts their inner
|
|
649
|
+
* statements, not itself (a control head is not an effect); a `/menu { … }`
|
|
650
|
+
* prefix counts nothing (navigation is not an effect); any other segment
|
|
651
|
+
* counts one. So the commonest write shape, `/… remove [find …]`, stays one
|
|
652
|
+
* statement — at the cost of a lone substitution that itself writes before a
|
|
653
|
+
* refused enclosing statement, which also counts one. Past the depth cap the
|
|
654
|
+
* inner text counts its top-level siblings only. Unbalanced input counts what
|
|
655
|
+
* precedes the break and never throws.
|
|
656
|
+
*/
|
|
657
|
+
export function countExecutableStatements(text: string): number {
|
|
658
|
+
return countExecutableStatementsWithin(text, 0);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function countExecutableStatementsWithin(text: string, depth: number): number {
|
|
662
|
+
const { segments, ascii } = segmentStatements(text);
|
|
663
|
+
if (depth >= MAX_DISPATCH_STATEMENT_DEPTH) return segments.length;
|
|
664
|
+
let count = 0;
|
|
665
|
+
for (const segment of segments) {
|
|
666
|
+
// A `/menu { … }` prefix only navigates; it is not an effect. The
|
|
667
|
+
// `ascii` guard is load-bearing: segment offsets are analyzed-byte
|
|
668
|
+
// offsets, which are string indexes only for pure-ASCII input.
|
|
669
|
+
if (ascii && segment.menuOnly && /^\s*\{/.test(text.slice(segment.end)))
|
|
670
|
+
continue;
|
|
671
|
+
const inner = countExecutedBlockStatements(segment.text, depth);
|
|
672
|
+
count += inner > 0 ? inner : 1;
|
|
673
|
+
}
|
|
674
|
+
return count;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Executable statements in the segment's executed `{…}` scopes and `[…]`. */
|
|
678
|
+
function countExecutedBlockStatements(
|
|
679
|
+
segmentText: string,
|
|
680
|
+
depth: number,
|
|
681
|
+
): number {
|
|
682
|
+
const masked = maskComments(segmentText);
|
|
683
|
+
const storedDoHead = /^:(?:global|local|set)\b/i.test(masked);
|
|
684
|
+
let total = 0;
|
|
685
|
+
let i = 0;
|
|
686
|
+
const stack: string[] = [];
|
|
687
|
+
// The last executed scope closed at this level, for `do`/`else` pairing.
|
|
688
|
+
let lastScope: { name: string; count: number; end: number } | null = null;
|
|
689
|
+
// The outermost open `[` that executes at dispatch, and its stack depth.
|
|
690
|
+
let substitution: { start: number; depth: number } | null = null;
|
|
691
|
+
while (i < masked.length) {
|
|
692
|
+
const c = masked[i];
|
|
693
|
+
if (c === '"') {
|
|
694
|
+
i = scanQuotedString(masked, i).end;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (c === "[" || c === "(") {
|
|
698
|
+
if (c === "[" && !stack.includes("[") && !stack.includes("{")) {
|
|
699
|
+
substitution = { start: i, depth: stack.length };
|
|
700
|
+
}
|
|
701
|
+
stack.push(c);
|
|
702
|
+
i++;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
if (c === "]" || c === ")") {
|
|
706
|
+
const want = c === "]" ? "[" : "(";
|
|
707
|
+
if (stack.at(-1) === want) {
|
|
708
|
+
stack.pop();
|
|
709
|
+
if (c === "]" && stack.length === substitution?.depth) {
|
|
710
|
+
total += countExecutableStatementsWithin(
|
|
711
|
+
segmentText.slice(substitution.start + 1, i),
|
|
712
|
+
depth + 1,
|
|
713
|
+
);
|
|
714
|
+
substitution = null;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
i++;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (c === "{") {
|
|
721
|
+
const scope = stack.length === 0 ? scopeNameFromMasked(masked, i) : null;
|
|
722
|
+
if (
|
|
723
|
+
scope !== null &&
|
|
724
|
+
!(storedDoHead && (scope === "do" || scope === "command"))
|
|
725
|
+
) {
|
|
726
|
+
const end = matchingBraceEnd(masked, i);
|
|
727
|
+
if (end === undefined) return total;
|
|
728
|
+
const count = countExecutableStatementsWithin(
|
|
729
|
+
segmentText.slice(i + 1, end),
|
|
730
|
+
depth + 1,
|
|
731
|
+
);
|
|
732
|
+
if (
|
|
733
|
+
scope === "else" &&
|
|
734
|
+
lastScope?.name === "do" &&
|
|
735
|
+
/^\s*else\s*=?\s*$/.test(masked.slice(lastScope.end + 1, i))
|
|
736
|
+
) {
|
|
737
|
+
total += Math.max(lastScope.count, count) - lastScope.count;
|
|
738
|
+
} else {
|
|
739
|
+
total += count;
|
|
740
|
+
}
|
|
741
|
+
lastScope = { name: scope, count, end };
|
|
742
|
+
i = end + 1;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
stack.push("{");
|
|
746
|
+
i++;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (c === "}") {
|
|
750
|
+
if (stack.at(-1) === "{") stack.pop();
|
|
751
|
+
i++;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
i++;
|
|
755
|
+
}
|
|
756
|
+
return total;
|
|
757
|
+
}
|
|
758
|
+
|
|
611
759
|
function segmentStatementsInternal(
|
|
612
760
|
original: string,
|
|
613
761
|
range: MaskedRange | undefined,
|
|
@@ -650,6 +798,7 @@ function segmentStatementsInternal(
|
|
|
650
798
|
analysis === undefined ? [] : coordinateDefects(analysis),
|
|
651
799
|
raw.defects,
|
|
652
800
|
),
|
|
801
|
+
...(raw.discardFrom === undefined ? {} : { discardFrom: raw.discardFrom }),
|
|
653
802
|
};
|
|
654
803
|
}
|
|
655
804
|
|
|
@@ -699,6 +848,8 @@ interface Frame {
|
|
|
699
848
|
/** Incremental equivalent of trimming and checking the H7 prefix. */
|
|
700
849
|
prefixSawNonSpace: boolean;
|
|
701
850
|
prefixValid: boolean;
|
|
851
|
+
/** The prefix's run has been checked for a command verb (#496). */
|
|
852
|
+
prefixVerbChecked: boolean;
|
|
702
853
|
/** statements collected here; the flattened output for a committed container. */
|
|
703
854
|
buffer: RawSegment[];
|
|
704
855
|
/** true for a container frame; false for the synthetic top level. */
|
|
@@ -726,6 +877,7 @@ function scanAscii(
|
|
|
726
877
|
segments: RawSegment[];
|
|
727
878
|
comments: { start: number; end: number }[];
|
|
728
879
|
defects: Defect[];
|
|
880
|
+
discardFrom?: number;
|
|
729
881
|
} {
|
|
730
882
|
// Local indices remain local in the result; only index-backed questions use
|
|
731
883
|
// document coordinates. The floor prevents an enclosing head from becoming
|
|
@@ -736,6 +888,7 @@ function scanAscii(
|
|
|
736
888
|
const comments: { start: number; end: number }[] = [];
|
|
737
889
|
const defects: Defect[] = [];
|
|
738
890
|
const overDepth: number[] = [];
|
|
891
|
+
let discardFrom: number | undefined;
|
|
739
892
|
let hardHashSeen = false;
|
|
740
893
|
let hardApostropheSeen = false;
|
|
741
894
|
// H2 — every open bracket, for balance. Each frame carries WHERE it opened so
|
|
@@ -752,6 +905,7 @@ function scanAscii(
|
|
|
752
905
|
prefixScanEnd: -1,
|
|
753
906
|
prefixSawNonSpace: false,
|
|
754
907
|
prefixValid: true,
|
|
908
|
+
prefixVerbChecked: false,
|
|
755
909
|
buffer: [],
|
|
756
910
|
container: false,
|
|
757
911
|
openIndex: -1,
|
|
@@ -777,6 +931,7 @@ function scanAscii(
|
|
|
777
931
|
f.prefixScanEnd = -1;
|
|
778
932
|
f.prefixSawNonSpace = false;
|
|
779
933
|
f.prefixValid = true;
|
|
934
|
+
f.prefixVerbChecked = false;
|
|
780
935
|
};
|
|
781
936
|
|
|
782
937
|
const makeSegment = (
|
|
@@ -814,7 +969,8 @@ function scanAscii(
|
|
|
814
969
|
};
|
|
815
970
|
|
|
816
971
|
// A container prefix is empty or a bare `/`-menu path with no `=[($"` — the
|
|
817
|
-
// same test the old `containerOpen` applied to the group's prefix
|
|
972
|
+
// same test the old `containerOpen` applied to the group's prefix — whose
|
|
973
|
+
// run names no command (#496).
|
|
818
974
|
const validPrefix = (f: Frame, start: number, end: number): boolean => {
|
|
819
975
|
if (f.prefixScanStart !== start || f.prefixScanEnd > end) {
|
|
820
976
|
resetPrefixScan(f);
|
|
@@ -841,6 +997,12 @@ function scanAscii(
|
|
|
841
997
|
}
|
|
842
998
|
}
|
|
843
999
|
f.prefixScanEnd = end;
|
|
1000
|
+
// Checked once per statement: a later `{` sits behind this one, which
|
|
1001
|
+
// already ended the run, so the answer cannot change.
|
|
1002
|
+
if (f.prefixValid && !f.prefixVerbChecked) {
|
|
1003
|
+
f.prefixVerbChecked = true;
|
|
1004
|
+
if (runNamesCommand(ascii.slice(start, end))) f.prefixValid = false;
|
|
1005
|
+
}
|
|
844
1006
|
return f.prefixValid;
|
|
845
1007
|
};
|
|
846
1008
|
|
|
@@ -860,6 +1022,7 @@ function scanAscii(
|
|
|
860
1022
|
prefixScanEnd: -1,
|
|
861
1023
|
prefixSawNonSpace: false,
|
|
862
1024
|
prefixValid: true,
|
|
1025
|
+
prefixVerbChecked: false,
|
|
863
1026
|
buffer: [],
|
|
864
1027
|
container: true,
|
|
865
1028
|
openIndex: i,
|
|
@@ -920,7 +1083,12 @@ function scanAscii(
|
|
|
920
1083
|
// mid-continuation stays content.
|
|
921
1084
|
const inCont = f.cont !== "none";
|
|
922
1085
|
const contComment = c === "#" && inCont && f.contLineStart;
|
|
923
|
-
if (
|
|
1086
|
+
if (
|
|
1087
|
+
c === "#" &&
|
|
1088
|
+
(f.atLead ||
|
|
1089
|
+
contComment ||
|
|
1090
|
+
startsArrayCommentLine(ascii, i, delimStack.at(-1)))
|
|
1091
|
+
) {
|
|
924
1092
|
const nl = ascii.indexOf("\n", i);
|
|
925
1093
|
const end = nl === -1 ? ascii.length : nl;
|
|
926
1094
|
comments.push({ start: i, end });
|
|
@@ -1037,7 +1205,10 @@ function scanAscii(
|
|
|
1037
1205
|
if (
|
|
1038
1206
|
c === "{" &&
|
|
1039
1207
|
atContainerLevel() &&
|
|
1040
|
-
validPrefix(f, f.stmtStart >= 0 ? f.stmtStart : i, i)
|
|
1208
|
+
(validPrefix(f, f.stmtStart >= 0 ? f.stmtStart : i, i) ||
|
|
1209
|
+
// A relative submenu inside a container is one too (#351).
|
|
1210
|
+
(frames.length > 1 &&
|
|
1211
|
+
menuBlockPath(indexed, floor + i, floor) !== null))
|
|
1041
1212
|
) {
|
|
1042
1213
|
if (frames.length - 1 < MAX_CONTAINER_DEPTH) {
|
|
1043
1214
|
openContainer(i);
|
|
@@ -1091,6 +1262,7 @@ function scanAscii(
|
|
|
1091
1262
|
f.atLead = false;
|
|
1092
1263
|
}
|
|
1093
1264
|
} else {
|
|
1265
|
+
if (delimStack.length === 0 && c !== ")") discardFrom ??= i;
|
|
1094
1266
|
defects.push(defectAt("unbalanced-close", i, c));
|
|
1095
1267
|
ensureStmt(f, i);
|
|
1096
1268
|
f.atLead = false;
|
|
@@ -1148,6 +1320,7 @@ function scanAscii(
|
|
|
1148
1320
|
// event fail to de-duplicate across analyzers.
|
|
1149
1321
|
...overDepth.map((offset) => defectAt("over-depth", offset)),
|
|
1150
1322
|
],
|
|
1323
|
+
...(discardFrom === undefined ? {} : { discardFrom }),
|
|
1151
1324
|
};
|
|
1152
1325
|
}
|
|
1153
1326
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The space-for-`=` argument spelling (#350).
|
|
3
|
+
*
|
|
4
|
+
* RouterOS lets a space stand in for the `=` of an ordinary command argument:
|
|
5
|
+
* `/ip/firewall/layer7-protocol add comment "something" regexp 1.1` lowers to
|
|
6
|
+
* `(evl /ip/firewall/layer7-protocol/addcomment=something;regexp=1.1)`, byte for
|
|
7
|
+
* byte the IL of the `=` spelling, and the spellings mix freely. That is not a
|
|
8
|
+
* schema-free rule. The device pairs `comment` with `"something"` because it
|
|
9
|
+
* knows `/ip/firewall/layer7-protocol/add` has a `comment` argument:
|
|
10
|
+
* `/system/script add zzznotanarg beep` is `bad parameter zzznotanarg` on
|
|
11
|
+
* 7.24.5 and `expected end of command` on 7.22.1.
|
|
12
|
+
*
|
|
13
|
+
* ## What the device actually does (CHR 7.22.1 and 7.24.5, `:parse` + `highlight`)
|
|
14
|
+
*
|
|
15
|
+
* Identical on both builds:
|
|
16
|
+
*
|
|
17
|
+
* | input | IL | `highlight` on the gap |
|
|
18
|
+
* | ----- | -- | ---------------------- |
|
|
19
|
+
* | `/interface set ether1 comment "x"` | `setcomment=x;numbers=ether1` | `syntax-obsolete` |
|
|
20
|
+
* | `/interface set ether1 name x` | `setname=x;numbers=ether1` | `syntax-obsolete` |
|
|
21
|
+
* | `/interface set ether1 disabled yes` | `setdisabled=true;numbers=ether1` | `syntax-obsolete` |
|
|
22
|
+
* | `/user group set full policy "read,write"` | `setnumbers=full;policy=read,write` | `syntax-obsolete` |
|
|
23
|
+
* | `/interface print detail without-paging` | `printdetail=true;without-paging=true` | none |
|
|
24
|
+
*
|
|
25
|
+
* Two facts come out of that table, and they are why this module has two
|
|
26
|
+
* outputs rather than one.
|
|
27
|
+
*
|
|
28
|
+
* - **Two adjacent bare operands have no schema-free reading.** `name x` is an
|
|
29
|
+
* argument and its value; `detail without-paging` is two value-less flags;
|
|
30
|
+
* `ether1 name` is an unnamed `numbers` operand and then an argument name. The
|
|
31
|
+
* bytes are the same shape in all three. So the strict reading ABSTAINS on any
|
|
32
|
+
* such pair: it never publishes `args: {}` plus a positional list, which
|
|
33
|
+
* claims the list was read and holds no arguments.
|
|
34
|
+
* - **The obsolete marker follows the value, not the name.** The device marks
|
|
35
|
+
* the gap `syntax-obsolete` exactly when it paired the words, and a flag pair
|
|
36
|
+
* gets no marker. Offline can see one half of that: a second operand that is
|
|
37
|
+
* not itself name-shaped (`"x"`, `1.1`, `10s`, `$v`) cannot be a flag, so the
|
|
38
|
+
* pair is very probably the spaced spelling and gets a warning. A bare-word
|
|
39
|
+
* second operand (`name x`, `disabled yes`) is also obsolete on device, but
|
|
40
|
+
* offline cannot tell it from `detail without-paging`, so it abstains with no
|
|
41
|
+
* warning rather than flagging the most common `print` spelling there is. The
|
|
42
|
+
* warning says "possible"; a live probe (#491) is what makes it definite.
|
|
43
|
+
*
|
|
44
|
+
* ## What this deliberately does not decide
|
|
45
|
+
*
|
|
46
|
+
* Root statements are out of scope. A scripting directive's operands are
|
|
47
|
+
* positional by grammar, not spelling: `:pick abc 0 1` lowers to
|
|
48
|
+
* `pickbegin=0;counter=abc;end=1`, and `:local t 200ms` to `name=t;value=200ms`.
|
|
49
|
+
* Pairing those would fabricate argument names the device never uses. `/ping
|
|
50
|
+
* 1.1.1.1 count 1` is spaced (`count` is `syntax-obsolete`) and resolves to the
|
|
51
|
+
* root, so it stays read here; telling a root command's positional grammar from a
|
|
52
|
+
* spaced argument is per-command schema and belongs to the live probe.
|
|
53
|
+
*
|
|
54
|
+
* `where` ends the scan. What follows it is a query expression, and the word
|
|
55
|
+
* before it is never paired with it: `print count-only where …` lowers to
|
|
56
|
+
* `count-only=true;where=…`.
|
|
57
|
+
*
|
|
58
|
+
* Like the #311 separator rule, this reads only what the argument lexer
|
|
59
|
+
* already decided (`lexArgumentTokens`, the skip-tolerant reading), so a
|
|
60
|
+
* `[…]` substitution or a quoted run is one token by construction.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { ARGUMENT_NAME, type Argument } from "./args.ts";
|
|
64
|
+
|
|
65
|
+
/** Two adjacent operands that may be the spaced spelling of `name=value`. */
|
|
66
|
+
export interface SpacedPair {
|
|
67
|
+
/** First byte of the would-be name through the end of the would-be value. */
|
|
68
|
+
span: { start: number; end: number };
|
|
69
|
+
/** The would-be argument name, verbatim. */
|
|
70
|
+
name: string;
|
|
71
|
+
/** The would-be value, verbatim (quotes kept). */
|
|
72
|
+
value: string;
|
|
73
|
+
/**
|
|
74
|
+
* The second operand cannot be a flag, so the device very probably reads the
|
|
75
|
+
* pair as `name=value` and marks the gap `syntax-obsolete`.
|
|
76
|
+
*/
|
|
77
|
+
valueShaped: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An argument-name-shaped bare word. This is the lexer's own grammar, so a word
|
|
82
|
+
* the lexer would read as `name=` in the `=` spelling is a name here too.
|
|
83
|
+
*/
|
|
84
|
+
const NAME_SHAPED = ARGUMENT_NAME;
|
|
85
|
+
|
|
86
|
+
function isWhere(token: Argument): boolean {
|
|
87
|
+
return token.kind === "positional" && token.text.toLowerCase() === "where";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Every adjacent `name operand` pair in one statement's argument tokens.
|
|
92
|
+
*
|
|
93
|
+
* Pairs overlap on purpose. `set ether1 comment "x"` yields `ether1 comment`
|
|
94
|
+
* and `comment "x"`: the first only abstains, the second also warns, and
|
|
95
|
+
* consuming the first would hide the second.
|
|
96
|
+
*
|
|
97
|
+
* Coordinate-space agnostic, like `findMissingSeparators`: spans come back in
|
|
98
|
+
* whatever space `tokens` were lexed in.
|
|
99
|
+
*/
|
|
100
|
+
export function findSpacedPairs(tokens: readonly Argument[]): SpacedPair[] {
|
|
101
|
+
const out: SpacedPair[] = [];
|
|
102
|
+
for (let i = 0; i + 1 < tokens.length; i++) {
|
|
103
|
+
const name = tokens[i] as Argument;
|
|
104
|
+
const value = tokens[i + 1] as Argument;
|
|
105
|
+
if (isWhere(name)) break;
|
|
106
|
+
if (name.kind !== "positional" || value.kind !== "positional") continue;
|
|
107
|
+
if (!NAME_SHAPED.test(name.text) || isWhere(value)) continue;
|
|
108
|
+
out.push({
|
|
109
|
+
span: { start: name.span.start, end: value.span.end },
|
|
110
|
+
name: name.text,
|
|
111
|
+
value: value.text,
|
|
112
|
+
valueShaped: !NAME_SHAPED.test(value.text),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|