@tikoci/centrs 0.1.3 → 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 +348 -0
- package/GLOSSARY.txt +252 -0
- package/LICENSE +21 -0
- package/README.md +44 -12
- package/commands/AGENTS.md +14 -5
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +56 -26
- package/commands/api/examples.md +46 -1
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +77 -36
- package/commands/execute/examples.md +120 -10
- package/commands/explain/README.md +2756 -0
- package/commands/explain/examples.md +702 -0
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/terminal/examples.md +7 -1
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +138 -30
- package/docs/MATRIX.md +92 -18
- package/docs/errors/README.md +3 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +56 -10
- package/src/api-fanout.ts +46 -18
- package/src/api.ts +351 -121
- package/src/cli/api.ts +26 -7
- package/src/cli/btest.ts +19 -3
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/terminal.ts +1 -1
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +58 -2
- package/src/core/error-catalog.ts +42 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +47 -18
- package/src/execute.ts +339 -307
- package/src/explain/arg-tokens.ts +151 -0
- package/src/explain/args.ts +1749 -0
- package/src/explain/blocks.ts +175 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +39 -0
- package/src/explain/catalog.ts +1343 -0
- package/src/explain/coordinates.ts +488 -0
- package/src/explain/defects.ts +218 -0
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +668 -0
- package/src/explain/operator-tokens.ts +316 -0
- package/src/explain/operators.ts +465 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1569 -0
- package/src/explain/quoted-string.ts +331 -0
- package/src/explain/scope-brace.ts +461 -0
- package/src/explain/segment.ts +1165 -0
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +77 -0
- package/src/explain/symbol-values.ts +556 -0
- package/src/explain/symbols.ts +1617 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +514 -0
- package/src/explain/value-tokens.ts +83 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +955 -0
- package/src/explain/write.ts +856 -0
- package/src/explain.ts +2602 -0
- package/src/index.ts +225 -6
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +27 -13
- package/src/retrieve.ts +82 -77
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +43 -26
- package/src/transfer.ts +88 -45
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The missing statement separator between two command-shaped runs (#311).
|
|
3
|
+
*
|
|
4
|
+
* RouterOS accepts slash-shaped operands after a verb — `/file remove
|
|
5
|
+
* /flash/skins/foo.html` lowers cleanly to `numbers=/flash/skins/foo.html` — so
|
|
6
|
+
* "a `/` after the verb" is NOT the signal, and a rule built on it would reject
|
|
7
|
+
* valid input. What the device rejects is narrower: a known **menu** path in
|
|
8
|
+
* operand position, followed by a console **verb**.
|
|
9
|
+
*
|
|
10
|
+
* ## What the device actually does (CHR 7.24.2, `:parse` + `highlight`)
|
|
11
|
+
*
|
|
12
|
+
* #311 reported the error byte as the `=` of the trailing attribute
|
|
13
|
+
* (`expected end of command (line 1 column 55)` for `/ip/address add
|
|
14
|
+
* interface=ether1 /ip/route add gateway=192.168.88.1`). Probing the shape
|
|
15
|
+
* around that row shows the `=` is an ARTEFACT of that particular head:
|
|
16
|
+
* `add` abbreviates `/ip/address/add`'s own `address=` argument, so the console
|
|
17
|
+
* reads `add gateway` as `address=gateway` and only complains at the `=` after
|
|
18
|
+
* it. Vary the head so the second verb abbreviates nothing and the byte moves —
|
|
19
|
+
* `/system/note set note=hello /ip/route add gateway=1.2.3.4` is not a hard
|
|
20
|
+
* reject at all; `:parse` returns IL carrying `bad parameter /ip/route
|
|
21
|
+
* (line 1 column 38)`, naming the second PATH. The same input also proves the
|
|
22
|
+
* head's own argument list is not safe to keep: `… /ip/route add placeholder` lowers
|
|
23
|
+
* to `address=placeholder;;interface=ether1`, a phantom attribute the source never
|
|
24
|
+
* wrote, and whether that happens depends on per-menu argument names — schema
|
|
25
|
+
* knowledge offline does not ship.
|
|
26
|
+
*
|
|
27
|
+
* So the grounded invariant is: **RouterOS never accepts a known menu path
|
|
28
|
+
* followed by a verb in operand position.** Across every probed row it answers
|
|
29
|
+
* either `bad parameter <that path>` or `expected end of command`; none is
|
|
30
|
+
* clean. It is also the shape with an obvious fix, which is what makes it worth
|
|
31
|
+
* a diagnostic rather than an abstention.
|
|
32
|
+
*
|
|
33
|
+
* ## What this deliberately does not decide
|
|
34
|
+
*
|
|
35
|
+
* A bare operand the head does not accept (`… placeholder`, `/file remove a b`) also
|
|
36
|
+
* draws `bad parameter`, and so does a menu path with no verb after it
|
|
37
|
+
* (`… /ip/route`). Those are the generic "this menu takes no such positional"
|
|
38
|
+
* complaint — deciding them needs the per-menu positional list, which
|
|
39
|
+
* `catalog.ts` deliberately is not, and a separator is not necessarily the fix.
|
|
40
|
+
* They abstain.
|
|
41
|
+
*
|
|
42
|
+
* The rule reads only what the argument lexer already DECIDED. It never
|
|
43
|
+
* re-scans bytes, so a `[…]` substitution (a located token the lexer declined
|
|
44
|
+
* to decode, hence no `value` and no operand), a `{…}` body (a separate
|
|
45
|
+
* flattened statement), a quoted operand and a `/` inside a VALUE are all
|
|
46
|
+
* outside its input by construction rather than by a guard it could get wrong.
|
|
47
|
+
* `bareOperand` is that construction: `value` absent is the lexer saying it
|
|
48
|
+
* decoded nothing, whatever the token's bytes spell.
|
|
49
|
+
*
|
|
50
|
+
* ## The reach it has, stated
|
|
51
|
+
*
|
|
52
|
+
* The input is `lexArgumentTokens`, the skip-tolerant reading (#316), never the
|
|
53
|
+
* strict `lexArguments`. The strict lexer discards every token it decoded as
|
|
54
|
+
* soon as it declines one, so a statement holding a variable — `… gateway=$g` —
|
|
55
|
+
* used to carry no token list for this rule at all and the run in it was missed
|
|
56
|
+
* while its literal twin was found. The tolerant reading locates the
|
|
57
|
+
* undecodable token and keeps walking, so the run is found on either side of
|
|
58
|
+
* it; that the strict reading still refuses the statement is correct and
|
|
59
|
+
* separate, since nothing here makes `$g` renderable.
|
|
60
|
+
*
|
|
61
|
+
* What remains out of reach is an unterminated string and an unbalanced
|
|
62
|
+
* delimiter, where no walk can find a boundary to resume from. That is still a
|
|
63
|
+
* reach limit rather than a judgement: nothing here decides those shapes are
|
|
64
|
+
* acceptable.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import type { Argument } from "./args.ts";
|
|
68
|
+
import { isKnownMenuPath } from "./is-known-menu.ts";
|
|
69
|
+
import { VERBS } from "./verbs.ts";
|
|
70
|
+
|
|
71
|
+
/** A second command-shaped run where a `;` or newline should have been. */
|
|
72
|
+
export interface MissingSeparator {
|
|
73
|
+
/** The whole second run — first byte of its path through the end of its verb. */
|
|
74
|
+
span: { start: number; end: number };
|
|
75
|
+
/** Where a `;` or newline belongs: the first byte of the second path. */
|
|
76
|
+
insertAt: number;
|
|
77
|
+
/** The second run's menu path, slash-spelled and leading-slashed. */
|
|
78
|
+
path: string;
|
|
79
|
+
/** The verb token that closed the second run. */
|
|
80
|
+
verb: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Both patterns below admit a segment that STARTS WITH A DIGIT. `/interface/6to4`
|
|
84
|
+
// is the shipped tables' one such name — a menu in both `MENU_PATHS` and
|
|
85
|
+
// `PATH_CATALOG` — and requiring a leading letter made every spelling of it miss
|
|
86
|
+
// the rule. These are only a cheap shape filter; presence in the structure table
|
|
87
|
+
// is the actual gate, so a wider first character cannot admit a path the table
|
|
88
|
+
// does not already list.
|
|
89
|
+
|
|
90
|
+
/** A bare path segment run, `route` or `firewall/filter`, with no leading slash. */
|
|
91
|
+
const RELATIVE_SEGMENTS =
|
|
92
|
+
/^[A-Za-z0-9][A-Za-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9-]*)*$/;
|
|
93
|
+
|
|
94
|
+
/** An absolute menu path spelled with slashes, `/ip` or `/ip/firewall/filter`. */
|
|
95
|
+
const ABSOLUTE_SEGMENTS =
|
|
96
|
+
/^\/[A-Za-z0-9][A-Za-z0-9-]*(?:\/[A-Za-z0-9][A-Za-z0-9-]*)*$/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A positional whose source text IS its value — so it carries no quotes, no
|
|
100
|
+
* substitution and no escape. `Argument.value` is already quote-stripped, which
|
|
101
|
+
* makes the comparison the whole quoting check; `value` absent means the lexer
|
|
102
|
+
* declined to decode the token and there is nothing to match against.
|
|
103
|
+
*/
|
|
104
|
+
function bareOperand(token: Argument | undefined): string | undefined {
|
|
105
|
+
if (token === undefined || token.kind !== "positional") return undefined;
|
|
106
|
+
if (token.value === undefined || token.value !== token.text) return undefined;
|
|
107
|
+
return token.value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every complete second command-shaped run in one statement's argument tokens.
|
|
112
|
+
*
|
|
113
|
+
* Coordinate-space agnostic: the spans come back in whatever space `tokens`
|
|
114
|
+
* were lexed in, so the caller passes document-rebased tokens and gets document
|
|
115
|
+
* offsets.
|
|
116
|
+
*/
|
|
117
|
+
export function findMissingSeparators(
|
|
118
|
+
tokens: readonly Argument[],
|
|
119
|
+
): MissingSeparator[] {
|
|
120
|
+
const out: MissingSeparator[] = [];
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < tokens.length) {
|
|
123
|
+
const run = separatorAt(tokens, i);
|
|
124
|
+
if (run === null) {
|
|
125
|
+
i++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
out.push(run.separator);
|
|
129
|
+
i = run.next;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** One run starting at `i`, or `null` when the tokens there are not that shape. */
|
|
135
|
+
function separatorAt(
|
|
136
|
+
tokens: readonly Argument[],
|
|
137
|
+
i: number,
|
|
138
|
+
): { separator: MissingSeparator; next: number } | null {
|
|
139
|
+
const head = bareOperand(tokens[i]);
|
|
140
|
+
if (head === undefined || !ABSOLUTE_SEGMENTS.test(head)) return null;
|
|
141
|
+
const run = longestKnownMenuRun(tokens, i, head);
|
|
142
|
+
if (run === null) return null;
|
|
143
|
+
const verb = bareOperand(tokens[run.last + 1]);
|
|
144
|
+
if (verb === undefined || !VERBS.has(verb)) return null;
|
|
145
|
+
const pathStart = tokens[i]?.span.start;
|
|
146
|
+
const verbEnd = tokens[run.last + 1]?.span.end;
|
|
147
|
+
if (pathStart === undefined || verbEnd === undefined) return null;
|
|
148
|
+
return {
|
|
149
|
+
separator: {
|
|
150
|
+
span: { start: pathStart, end: verbEnd },
|
|
151
|
+
insertAt: pathStart,
|
|
152
|
+
path: `/${run.segments.join("/")}`,
|
|
153
|
+
verb,
|
|
154
|
+
},
|
|
155
|
+
next: run.last + 2,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The longest run of operands from `i` that spells a known menu path.
|
|
161
|
+
*
|
|
162
|
+
* A menu path may be written with slashes, spaces, or a mix (Q3 rule R4), so
|
|
163
|
+
* `/ip/route`, `/ip route` and `/ip route` split across operands are the same
|
|
164
|
+
* path and all of them have to be reachable — the device rejects the spaced
|
|
165
|
+
* spelling identically. Longest wins: `/ip route` is a menu and so is `/ip`, and
|
|
166
|
+
* only the longer one leaves `add` in verb position. `null` when no prefix is a
|
|
167
|
+
* known menu — absence in the structure table abstains and never asserts a
|
|
168
|
+
* command (`is-known-menu.ts`).
|
|
169
|
+
*/
|
|
170
|
+
function longestKnownMenuRun(
|
|
171
|
+
tokens: readonly Argument[],
|
|
172
|
+
i: number,
|
|
173
|
+
head: string,
|
|
174
|
+
): { last: number; segments: string[] } | null {
|
|
175
|
+
const segments = head.slice(1).split("/");
|
|
176
|
+
const runs: { last: number; segments: string[] }[] = [];
|
|
177
|
+
if (isKnownMenuPath(segments))
|
|
178
|
+
runs.push({ last: i, segments: [...segments] });
|
|
179
|
+
for (let j = i + 1; j < tokens.length; j++) {
|
|
180
|
+
const next = bareOperand(tokens[j]);
|
|
181
|
+
if (next === undefined || !RELATIVE_SEGMENTS.test(next)) break;
|
|
182
|
+
segments.push(...next.split("/"));
|
|
183
|
+
if (isKnownMenuPath(segments))
|
|
184
|
+
runs.push({ last: j, segments: [...segments] });
|
|
185
|
+
}
|
|
186
|
+
return runs.at(-1) ?? null;
|
|
187
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Which statement owns this span?" as an index rather than a scan (#317).
|
|
3
|
+
*
|
|
4
|
+
* `symbol-values.ts` asks this question once per symbol, once per value and
|
|
5
|
+
* once per scope brace. Answering it by scanning every statement split makes
|
|
6
|
+
* the product quadratic: on 62 KiB of `:local v<i> <i>; :put $v<i>;` the scan
|
|
7
|
+
* ran 6,144 queries over 4,096 splits — 25.2 M visits, 42% of total analysis
|
|
8
|
+
* time — and the visit count quadrupled for every doubling of the input.
|
|
9
|
+
*
|
|
10
|
+
* ## The answer that has to be preserved, exactly
|
|
11
|
+
*
|
|
12
|
+
* The **shortest ADDRESSABLE split span containing `[start, end)`**, and on a
|
|
13
|
+
* length tie the **earliest split index**. Addressable means the span slices
|
|
14
|
+
* back to the split's own text: `pathresolve.ts` can hand back a *widened
|
|
15
|
+
* fallback* span, and a span that is not this statement's own bytes cannot
|
|
16
|
+
* prove statement ownership (the same gate `valuesOf`/`argumentsOf` apply to
|
|
17
|
+
* their own reads).
|
|
18
|
+
* Shortest — not first, not outermost — is what keeps `:local f do={:put 1}`
|
|
19
|
+
* from claiming the body's `1` for `f`.
|
|
20
|
+
*
|
|
21
|
+
* ## Why a parent chain is enough
|
|
22
|
+
*
|
|
23
|
+
* Statement spans NEST: a parent statement's span contains its scope bodies'
|
|
24
|
+
* statements. If the addressable spans are a laminar family — every pair
|
|
25
|
+
* either nested or disjoint, never crossing — then the spans containing a
|
|
26
|
+
* given point form a chain, so the answer is reachable by walking up from the
|
|
27
|
+
* deepest span that starts at or before `start`.
|
|
28
|
+
*
|
|
29
|
+
* Laminarity is **measured, not assumed**. Over the pinned 948-script corpus
|
|
30
|
+
* (`corpus-pin.json`), all 18,487 addressable spans of 18,648 splits are
|
|
31
|
+
* laminar: zero crossings, zero duplicate `(start, end)` pairs, maximum
|
|
32
|
+
* nesting depth 11. That is one genre of input (see #203 on the corpus bias),
|
|
33
|
+
* so it is a measurement, not a proof — {@link buildSplitOwnerIndex} therefore
|
|
34
|
+
* DETECTS a crossing while it builds and falls back to the exhaustive scan for
|
|
35
|
+
* that document. The fallback is the old code, so a crossing costs speed and
|
|
36
|
+
* never correctness, and `test/unit/explain-split-owner.test.ts` drives both
|
|
37
|
+
* paths against a brute-force oracle on randomly generated span families.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { DocumentVerbSplit } from "./verbsplit.ts";
|
|
41
|
+
|
|
42
|
+
/** A prepared owner lookup over one document's splits. */
|
|
43
|
+
export interface SplitOwnerIndex {
|
|
44
|
+
/**
|
|
45
|
+
* The owning split index for `[start, end)`, or `undefined` when no
|
|
46
|
+
* addressable span contains it.
|
|
47
|
+
*/
|
|
48
|
+
ownerOf(start: number, end: number): number | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* Were the addressable spans laminar? `false` means this index answers by
|
|
51
|
+
* exhaustive scan. Exposed so the unit test can prove it drove both paths,
|
|
52
|
+
* not for callers to branch on.
|
|
53
|
+
*/
|
|
54
|
+
readonly laminar: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Entry {
|
|
58
|
+
start: number;
|
|
59
|
+
end: number;
|
|
60
|
+
/** Index into the original `splits` array. */
|
|
61
|
+
split: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The addressable spans, in split order. */
|
|
65
|
+
function addressableEntries(
|
|
66
|
+
analyzed: string,
|
|
67
|
+
splits: readonly DocumentVerbSplit[],
|
|
68
|
+
): Entry[] {
|
|
69
|
+
const entries: Entry[] = [];
|
|
70
|
+
for (let i = 0; i < splits.length; i++) {
|
|
71
|
+
const split = splits[i] as DocumentVerbSplit;
|
|
72
|
+
const { start, end } = split.span;
|
|
73
|
+
// A widened fallback span cannot prove statement ownership: it is not
|
|
74
|
+
// this statement's own bytes.
|
|
75
|
+
if (analyzed.slice(start, end) !== split.text) continue;
|
|
76
|
+
entries.push({ start, end, split: i });
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The exhaustive scan, over the addressable entries rather than over `splits`.
|
|
83
|
+
* Same answer as the pre-#317 loop: the filter it applied per candidate is the
|
|
84
|
+
* one `addressableEntries` applied once, and `length < ownerLength` is strict,
|
|
85
|
+
* so ascending split order still resolves a length tie to the earliest index.
|
|
86
|
+
*/
|
|
87
|
+
function scanOwner(
|
|
88
|
+
entries: readonly Entry[],
|
|
89
|
+
start: number,
|
|
90
|
+
end: number,
|
|
91
|
+
): number | undefined {
|
|
92
|
+
let owner: number | undefined;
|
|
93
|
+
let ownerLength = Number.POSITIVE_INFINITY;
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (entry.start > start || end > entry.end) continue;
|
|
96
|
+
const length = entry.end - entry.start;
|
|
97
|
+
if (length < ownerLength) {
|
|
98
|
+
owner = entry.split;
|
|
99
|
+
ownerLength = length;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return owner;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Prepare the owner lookup for one document. Build once per analysis and reuse
|
|
107
|
+
* it for every query: building it per query would be the scan it replaces.
|
|
108
|
+
*/
|
|
109
|
+
export function buildSplitOwnerIndex(
|
|
110
|
+
analyzed: string,
|
|
111
|
+
splits: readonly DocumentVerbSplit[],
|
|
112
|
+
): SplitOwnerIndex {
|
|
113
|
+
const entries = addressableEntries(analyzed, splits);
|
|
114
|
+
// Parents first: ascending start, and on equal starts the longer span
|
|
115
|
+
// (the container) before the shorter one. The last entry with
|
|
116
|
+
// `start <= query.start` is then the deepest candidate, which is what the
|
|
117
|
+
// binary search below relies on.
|
|
118
|
+
const ordered = [...entries].sort(
|
|
119
|
+
(a, b) => a.start - b.start || b.end - a.end || a.split - b.split,
|
|
120
|
+
);
|
|
121
|
+
// Two splits can claim exactly the same span; the sort has made them
|
|
122
|
+
// adjacent, and dropping all but the first keeps the earliest index. It
|
|
123
|
+
// also leaves the nesting STRICT, which is what lets the walk stop at the
|
|
124
|
+
// first long-enough span: after this, two distinct entries of equal length
|
|
125
|
+
// cannot both contain one query, because in a laminar family nested plus
|
|
126
|
+
// equal length means identical.
|
|
127
|
+
const sorted = ordered.filter((entry, i) => {
|
|
128
|
+
const previous = ordered[i - 1];
|
|
129
|
+
return (
|
|
130
|
+
previous === undefined ||
|
|
131
|
+
previous.start !== entry.start ||
|
|
132
|
+
previous.end !== entry.end
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
const parent = new Int32Array(sorted.length).fill(-1);
|
|
136
|
+
const stack: number[] = [];
|
|
137
|
+
let laminar = true;
|
|
138
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
139
|
+
const cur = sorted[i] as Entry;
|
|
140
|
+
while (stack.length > 0) {
|
|
141
|
+
const top = sorted[stack[stack.length - 1] as number] as Entry;
|
|
142
|
+
if (top.end <= cur.start) stack.pop();
|
|
143
|
+
else break;
|
|
144
|
+
}
|
|
145
|
+
const topIndex = stack[stack.length - 1];
|
|
146
|
+
if (topIndex !== undefined) {
|
|
147
|
+
const top = sorted[topIndex] as Entry;
|
|
148
|
+
// Overlapping but not containing: the family is not laminar, so the
|
|
149
|
+
// containing spans of a point are not a chain and the walk below
|
|
150
|
+
// could miss the real owner.
|
|
151
|
+
if (top.end < cur.end) laminar = false;
|
|
152
|
+
parent[i] = topIndex;
|
|
153
|
+
}
|
|
154
|
+
stack.push(i);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!laminar)
|
|
158
|
+
return {
|
|
159
|
+
laminar,
|
|
160
|
+
ownerOf: (start, end) => scanOwner(entries, start, end),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
laminar,
|
|
165
|
+
ownerOf(start, end) {
|
|
166
|
+
// A ZERO-WIDTH query is outside the nesting model and takes the scan.
|
|
167
|
+
// Containment of `[p, p)` is satisfied by a span that merely TOUCHES
|
|
168
|
+
// `p`, so `[a, p)` and `[p, b)` both "contain" it while being disjoint
|
|
169
|
+
// — the containing spans stop being a chain and the walk below can
|
|
170
|
+
// reach only one of them. No real query is zero-width (a symbol, a
|
|
171
|
+
// value anchor and a scope brace all span at least one byte; measured
|
|
172
|
+
// 0 of 19,292 symbol occurrences over the pinned corpus), so this
|
|
173
|
+
// costs nothing in practice and keeps the answer exact if that ever
|
|
174
|
+
// stops being true.
|
|
175
|
+
if (start >= end) return scanOwner(entries, start, end);
|
|
176
|
+
// Last entry whose span starts at or before `start`.
|
|
177
|
+
let lo = 0;
|
|
178
|
+
let hi = sorted.length;
|
|
179
|
+
while (lo < hi) {
|
|
180
|
+
const mid = (lo + hi) >> 1;
|
|
181
|
+
if ((sorted[mid] as Entry).start <= start) lo = mid + 1;
|
|
182
|
+
else hi = mid;
|
|
183
|
+
}
|
|
184
|
+
// Ends grow as the walk rises, so the first span long enough to
|
|
185
|
+
// cover `end` is the shortest one that does. Entries that stop
|
|
186
|
+
// before `start` are simply skipped by the same test.
|
|
187
|
+
for (let i = lo - 1; i >= 0; i = parent[i] as number) {
|
|
188
|
+
const entry = sorted[i] as Entry;
|
|
189
|
+
if (entry.end >= end) return entry.split;
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B3 string fill — claims quoted-string spans on the residual.
|
|
3
|
+
*
|
|
4
|
+
* Every RouterOS double-quoted string is a lexical unit the device highlights
|
|
5
|
+
* (even when its value is not a literal, the delimiters are). `data.values`
|
|
6
|
+
* already claims argument value strings (quotes included), so this fill runs
|
|
7
|
+
* **after** `value` and sees only the residual: quoted declaration names
|
|
8
|
+
* (`:global "set-dns"`), quoted variable references (`$"set-dns"`), and
|
|
9
|
+
* string content inside substitutions that value anchoring did not locate.
|
|
10
|
+
*
|
|
11
|
+
* All quoted runs are produced by the single shared scanner
|
|
12
|
+
* `src/explain/quoted-string.ts` (`scanQuotedString`), the same primitive the
|
|
13
|
+
* segmenter and symbol resolver use, so boundaries cannot drift.
|
|
14
|
+
*
|
|
15
|
+
* One `string` class per quoted run, delimiters included, regardless of whether
|
|
16
|
+
* the string is a literal value, a name, or code. The delimiters are part of
|
|
17
|
+
* the run because they are its own first and last byte, so nothing is gained by
|
|
18
|
+
* naming them separately (#264 B5).
|
|
19
|
+
*
|
|
20
|
+
* A run is **not** necessarily one token, and escapes are why. The `escaped`
|
|
21
|
+
* fill (`src/explain/escape-tokens.ts`) claims valid escapes two stages earlier,
|
|
22
|
+
* so what reaches here is the run minus its escapes and this fill emits a
|
|
23
|
+
* fragment either side of each one — `"a\nb"` is `string("a) escaped(\n)
|
|
24
|
+
* string(b")`. Splitting the run that way is not new machinery: `clipToResidual`
|
|
25
|
+
* already fragmented these runs around `variable-*` spans. #264 made that split
|
|
26
|
+
* because a valid escape is recoverable from no other published surface, where a
|
|
27
|
+
* delimiter is recoverable from the run's own first and last byte — which is why
|
|
28
|
+
* the escapes are named and the delimiters are not.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { ExplainToken } from "../explain.ts";
|
|
32
|
+
import { scanQuotedString } from "./quoted-string.ts";
|
|
33
|
+
import { clipToResidual } from "./token-ranges.ts";
|
|
34
|
+
|
|
35
|
+
/** Quoted-string spans on the residual. */
|
|
36
|
+
export function stringSpans(
|
|
37
|
+
analyzed: string,
|
|
38
|
+
residual: readonly { start: number; end: number }[],
|
|
39
|
+
): ExplainToken[] {
|
|
40
|
+
const len = analyzed.length;
|
|
41
|
+
if (len === 0 || residual.length === 0) return [];
|
|
42
|
+
const raw: { start: number; end: number }[] = [];
|
|
43
|
+
for (let i = 0; i < analyzed.length; i++) {
|
|
44
|
+
if (analyzed[i] !== '"') continue;
|
|
45
|
+
// Earlier fills own semantic regions. In particular, a quote inside a
|
|
46
|
+
// comment must not open a string that paints later statements when that
|
|
47
|
+
// comment has no matching quote. A real residual string always has its
|
|
48
|
+
// opening delimiter on the residual; already-owned strings need no fill.
|
|
49
|
+
if (clipToResidual(i, i + 1, residual).length === 0) continue;
|
|
50
|
+
const scan = scanQuotedString(analyzed, i);
|
|
51
|
+
if (!scan.closed) {
|
|
52
|
+
// Unterminated strings are already a defect; claim from opener to
|
|
53
|
+
// end so the residual does not stay gap-filled as unclassified
|
|
54
|
+
// while the diagnostic says the same bytes are defective.
|
|
55
|
+
raw.push({ start: i, end: analyzed.length });
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
raw.push({ start: i, end: scan.end });
|
|
59
|
+
i = scan.end - 1;
|
|
60
|
+
}
|
|
61
|
+
if (raw.length === 0) return [];
|
|
62
|
+
const out: ExplainToken[] = [];
|
|
63
|
+
for (const { start, end } of raw) {
|
|
64
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) continue;
|
|
65
|
+
if (start < 0 || end <= start || end > len) continue;
|
|
66
|
+
for (const r of clipToResidual(start, end, residual)) {
|
|
67
|
+
out.push({
|
|
68
|
+
start: r.start,
|
|
69
|
+
end: r.end,
|
|
70
|
+
class: "string" as const,
|
|
71
|
+
ev: "e13",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
out.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
76
|
+
return out;
|
|
77
|
+
}
|