@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,1749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statement-scope ARGUMENT lexing for `explain` (#202c).
|
|
3
|
+
*
|
|
4
|
+
* Phase 0.5 promoted nine analyzers that answer where a statement STARTS, where
|
|
5
|
+
* its menu ends and its verb begins — and stop there. `structure.statements[]`
|
|
6
|
+
* has carried `path` + `verb` and no arguments since #202a, with the gap
|
|
7
|
+
* written down (`commands/explain/README.md` → *What phase 1 offline actually
|
|
8
|
+
* emits*): splitting one statement's arguments needs a lexer for quoted values,
|
|
9
|
+
* `[…]` selectors and `?` queries at statement scope, and it was parked until
|
|
10
|
+
* `--curl` made it load-bearing. This is that lexer.
|
|
11
|
+
*
|
|
12
|
+
* ## Three readings, one walk
|
|
13
|
+
*
|
|
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.
|
|
43
|
+
*
|
|
44
|
+
* ## What it refuses, and why each refusal is a fact rather than a gap
|
|
45
|
+
*
|
|
46
|
+
* - **A value that is not a literal.** `[…]` command substitution, `(…)`
|
|
47
|
+
* expression, `$var`, `{…}` array — the token's VALUE is whatever RouterOS
|
|
48
|
+
* computes at run time, so offline has no value to send. (`canonicalize
|
|
49
|
+
* ExecuteCommand` takes the same line for `[`/`]` and falls back to script
|
|
50
|
+
* mode, `src/execute.ts`.)
|
|
51
|
+
* - **An escape inside a quoted value.** `\n`/`\xHH`/`\"` need a decoder to
|
|
52
|
+
* turn source bytes into the value the device receives, and a decoder that
|
|
53
|
+
* is wrong about one escape corrupts a write. Decoding is grounded work
|
|
54
|
+
* (#225's probe matrix territory), not something to guess at here.
|
|
55
|
+
* - **A name that is not a name.** `{a=1;b=2}` fuses an array literal into
|
|
56
|
+
* what looks like `name=value`, which the phase-0 Q0 catalog measured as the
|
|
57
|
+
* IL oracle's own worst confusion (383 corpus hits). A token whose left side
|
|
58
|
+
* is not a bare RouterOS name is not read as an attribute.
|
|
59
|
+
* - **A `\<newline>` continuation.** The bytes of a continued value are not
|
|
60
|
+
* contiguous, so a `valueSpan` over them would be a lie. `verbsplit.ts`'s
|
|
61
|
+
* word scanner handles continuations for the run END; a VALUE needs its
|
|
62
|
+
* interior, which is exactly what a continuation breaks.
|
|
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
|
+
*
|
|
68
|
+
* ## It emits no defects
|
|
69
|
+
*
|
|
70
|
+
* Every defect class this scan could raise (`unterminated-string`,
|
|
71
|
+
* `bad-escape`, `unclosed`) is already located document-wide by `segment.ts` and
|
|
72
|
+
* `symbols.ts`, whose regions are the ratified ones. Re-raising them here with
|
|
73
|
+
* statement-relative regions would double-report the same byte range under two
|
|
74
|
+
* spans, which `attributeDefects` (`src/explain.ts`) cannot de-duplicate because
|
|
75
|
+
* the regions differ. A defect the caller can see is not this module's to
|
|
76
|
+
* re-announce; the abstention reason names it instead.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
import { isScopeBrace, isScopeBraceIn } from "./blocks.ts";
|
|
80
|
+
import { braceSlotAcceptsBrace, braceSlotTakesArray } from "./brace-slots.ts";
|
|
81
|
+
import { braceStartsStatements } from "./scope-brace.ts";
|
|
82
|
+
import {
|
|
83
|
+
type MaskedRange,
|
|
84
|
+
maskComments,
|
|
85
|
+
maskOf,
|
|
86
|
+
scanQuotedString,
|
|
87
|
+
} from "./segment.ts";
|
|
88
|
+
|
|
89
|
+
/** A RouterOS argument name: bare word, optionally dotted (`.id`, `.proplist`). */
|
|
90
|
+
const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
|
|
91
|
+
|
|
92
|
+
/** What one argument token is. */
|
|
93
|
+
export type ArgumentKind = "attribute" | "positional" | "query";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One argument token, located in the text it was lexed from.
|
|
97
|
+
*
|
|
98
|
+
* The ordered list is the primary shape and the object view is derived from it
|
|
99
|
+
* (the phase-0 normal form: "an ordered token/argument list with spans, never a
|
|
100
|
+
* pre-reduced args object"). Order and multiplicity are observable here and are
|
|
101
|
+
* flattened by {@link ArgumentReading.args}, so a consumer that needs to know a
|
|
102
|
+
* name was given twice still can.
|
|
103
|
+
*/
|
|
104
|
+
export interface Argument {
|
|
105
|
+
kind: ArgumentKind;
|
|
106
|
+
/** The whole token, in the coordinate space of the text passed to the lexer. */
|
|
107
|
+
span: { start: number; end: number };
|
|
108
|
+
/** Attribute name / query word. Absent on a positional. */
|
|
109
|
+
name?: string;
|
|
110
|
+
/**
|
|
111
|
+
* The token's LITERAL value, quotes removed — for an attribute the part after
|
|
112
|
+
* `=`, for a positional the whole token.
|
|
113
|
+
*
|
|
114
|
+
* **Absent means there is no literal value**, whatever the kind: the source
|
|
115
|
+
* spells a substitution, an expression, or an escape this phase does not
|
|
116
|
+
* decode. A consumer rendering a runnable command must read this field, not
|
|
117
|
+
* `text`, and must treat absence as "not renderable" — `text` is the source
|
|
118
|
+
* bytes, which are not the value the device would receive. A query is always
|
|
119
|
+
* absent: `?` words are a grammar of their own and splitting them is not this
|
|
120
|
+
* lexer's claim to make.
|
|
121
|
+
*/
|
|
122
|
+
value?: string;
|
|
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
|
+
*/
|
|
133
|
+
valueSpan?: { start: number; end: number };
|
|
134
|
+
/** The token verbatim, for a positional and for rendering. */
|
|
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;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Internal token classification; quote state is stripped from the REST view. */
|
|
150
|
+
interface ReadArgument extends Argument {
|
|
151
|
+
literalQuoted?: boolean;
|
|
152
|
+
/** A located structured literal that the strict REST view still refuses. */
|
|
153
|
+
sourceShape?: "array";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Every token decided. */
|
|
157
|
+
export interface ArgumentsRead {
|
|
158
|
+
read: true;
|
|
159
|
+
tokens: Argument[];
|
|
160
|
+
/**
|
|
161
|
+
* The derived object view: attribute names to values, LAST occurrence winning
|
|
162
|
+
* — the reading `canonicalizeExecuteCommand` already applies to a repeated
|
|
163
|
+
* name (`src/execute.ts`), so the gate and the analysis cannot disagree about
|
|
164
|
+
* a duplicate. `tokens` is where multiplicity survives.
|
|
165
|
+
*/
|
|
166
|
+
args: Record<string, string>;
|
|
167
|
+
/** `?`-prefixed query words, in order, verbatim. */
|
|
168
|
+
queries: string[];
|
|
169
|
+
/**
|
|
170
|
+
* Bare tokens, in order — an id (`*1`), a `where`, a `find` operand — as
|
|
171
|
+
* SOURCE text. A consumer needing the value reads `tokens` (see
|
|
172
|
+
* {@link Argument.value}); this list exists so the common "is there an id"
|
|
173
|
+
* question needs no walk.
|
|
174
|
+
*/
|
|
175
|
+
positional: string[];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Nothing decided, and the reason. */
|
|
179
|
+
export interface ArgumentsUnread {
|
|
180
|
+
read: false;
|
|
181
|
+
/** Why the lexer refused. Rendered into the transport basis, never dropped. */
|
|
182
|
+
why: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type ArgumentReading = ArgumentsRead | ArgumentsUnread;
|
|
186
|
+
|
|
187
|
+
/** What a located value IS: a command's argument, or one array member. */
|
|
188
|
+
export type ValueAnchorKind = Exclude<ArgumentKind, "query"> | "element";
|
|
189
|
+
|
|
190
|
+
/** One safely located value shape, before any later unreadable token. */
|
|
191
|
+
export interface ValueAnchor {
|
|
192
|
+
kind: ValueAnchorKind;
|
|
193
|
+
/** The whole argument token, or the whole member run including its key. */
|
|
194
|
+
tokenSpan: { start: number; end: number };
|
|
195
|
+
/** Attribute name, or an array member's key; absent on a positional. */
|
|
196
|
+
name?: string;
|
|
197
|
+
/** The literal's source bytes, quotes included. */
|
|
198
|
+
valueSpan: { start: number; end: number };
|
|
199
|
+
/**
|
|
200
|
+
* Decoded scalar value, or exact source spelling when `sourceShape` is set.
|
|
201
|
+
*
|
|
202
|
+
* `str` is the exception in both directions: the leftover of a `{a=}` member
|
|
203
|
+
* is the DECODED name (`{"a b"=}` carries `a b`), because the bytes that
|
|
204
|
+
* spell it are the key's, not a value's.
|
|
205
|
+
*/
|
|
206
|
+
value: string;
|
|
207
|
+
/** True only when one quoted run encloses the whole scalar value. */
|
|
208
|
+
quoted: boolean;
|
|
209
|
+
/**
|
|
210
|
+
* Present only when the SOURCE proves the type, rather than scalar decoding.
|
|
211
|
+
*
|
|
212
|
+
* `array` is proved by the delimiters. The other two are proved by an `=`
|
|
213
|
+
* that does not bind a key (#258): with an empty right side the device drops
|
|
214
|
+
* the sign and keeps the name as a `str`, and with a left side that is not a
|
|
215
|
+
* name the sign COMPARES, which returns `bool` whatever the operands hold.
|
|
216
|
+
* None of the three survives {@link valueShapeHints}, which reads a decoded
|
|
217
|
+
* scalar and cannot see the sign.
|
|
218
|
+
*/
|
|
219
|
+
sourceShape?: "array" | "str" | "bool";
|
|
220
|
+
/**
|
|
221
|
+
* Index, in this same list, of the array literal this member belongs to.
|
|
222
|
+
*
|
|
223
|
+
* An index rather than a nested list because the list stays ONE ordered
|
|
224
|
+
* sequence of located values — the phase-0 normal form — and because a
|
|
225
|
+
* consumer that only wants spans should not have to walk a tree. Members
|
|
226
|
+
* follow their container immediately, so a parent index is always smaller.
|
|
227
|
+
*/
|
|
228
|
+
parent?: number;
|
|
229
|
+
}
|
|
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
|
+
|
|
239
|
+
/** Prefix-safe value anchoring; `complete: false` explains where scanning stopped. */
|
|
240
|
+
export type ValueAnchorReading =
|
|
241
|
+
| { complete: true; anchors: ValueAnchor[] }
|
|
242
|
+
| { complete: false; anchors: ValueAnchor[]; why: string };
|
|
243
|
+
|
|
244
|
+
/** A refusal, carrying the reason a consumer quotes instead of a bare `unknown`. */
|
|
245
|
+
function unread(why: string): ArgumentsUnread {
|
|
246
|
+
return { read: false, why };
|
|
247
|
+
}
|
|
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
|
+
|
|
280
|
+
interface WalkOptions {
|
|
281
|
+
/** Locate `{…}`/`(…)` literals instead of refusing the statement at one. */
|
|
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;
|
|
311
|
+
/**
|
|
312
|
+
* The root scripting directive whose slots decide whether a `{…}` here is an
|
|
313
|
+
* array literal, or undefined when this statement has none.
|
|
314
|
+
*
|
|
315
|
+
* A command argument never takes one. On CHR 7.23.3 `/console/inspect`
|
|
316
|
+
* classes the `{` byte `error` and `:parse` refuses the statement for
|
|
317
|
+
* `/ip/route/add comment={1;2}`, `/ip/dns/set servers={1.1.1.1;8.8.8.8}`
|
|
318
|
+
* (a LIST-typed attribute, which rules out a schema-shaped reading),
|
|
319
|
+
* `/interface/print .proplist={name;comment}`,
|
|
320
|
+
* `/ip/route/print where comment={1;2}`, the relative spelling
|
|
321
|
+
* `ip route add comment={1;2}`, and `:log info message={1;2}`. A `(…)`
|
|
322
|
+
* array is accepted in every one of those positions, so only the brace is
|
|
323
|
+
* gated.
|
|
324
|
+
*
|
|
325
|
+
* Being a directive is necessary but NOT sufficient — `:delay {1;2}` and
|
|
326
|
+
* `:local name={1;2}` are syntax errors too — so which slot it is decides:
|
|
327
|
+
* see {@link braceSlotTakesArray}.
|
|
328
|
+
*/
|
|
329
|
+
directiveVerb?: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** How deep member scanning descends before it stops and keeps only the shape. */
|
|
333
|
+
const MAX_MEMBER_DEPTH = 8;
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The argument name whose value the brace at `at` opens, `null` when the brace
|
|
337
|
+
* IS the token (a positional value), or undefined when it opens neither.
|
|
338
|
+
*/
|
|
339
|
+
function braceValueOwner(
|
|
340
|
+
text: string,
|
|
341
|
+
tokenStart: number,
|
|
342
|
+
at: number,
|
|
343
|
+
): string | null | undefined {
|
|
344
|
+
if (at === tokenStart) return null;
|
|
345
|
+
if (text[at - 1] !== "=") return undefined;
|
|
346
|
+
const name = text.slice(tokenStart, at - 1);
|
|
347
|
+
return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Whether a `{` at `at` may open an array literal in this statement.
|
|
352
|
+
*
|
|
353
|
+
* `positionalIndex` is how many positionals the walk has already read, which is
|
|
354
|
+
* this token's own index if it turns out to be one. Position is load-bearing:
|
|
355
|
+
* `:local {1;2}` puts the literal in the NAME slot and does not parse, while
|
|
356
|
+
* `:local z {1;2}` puts it in the VALUE slot and does.
|
|
357
|
+
*/
|
|
358
|
+
function braceOpensArray(
|
|
359
|
+
text: string,
|
|
360
|
+
tokenStart: number,
|
|
361
|
+
at: number,
|
|
362
|
+
positionalIndex: number,
|
|
363
|
+
options: WalkOptions,
|
|
364
|
+
): boolean {
|
|
365
|
+
const verb = options.directiveVerb;
|
|
366
|
+
if (verb === undefined) return false;
|
|
367
|
+
const owner = braceValueOwner(text, tokenStart, at);
|
|
368
|
+
if (owner === undefined) return false;
|
|
369
|
+
return braceSlotTakesArray(
|
|
370
|
+
verb,
|
|
371
|
+
owner === null ? `#${positionalIndex}` : owner,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The ONE argument token walk, shared by both readings.
|
|
377
|
+
*
|
|
378
|
+
* Yields each decided token in order, or one refusal string and stops. The
|
|
379
|
+
* strict {@link lexArguments} discards everything on that refusal while
|
|
380
|
+
* {@link lexValueAnchors} keeps the prefix — but they must never disagree about
|
|
381
|
+
* where a token starts, ends, or becomes unreadable, so the boundary rules live
|
|
382
|
+
* here once.
|
|
383
|
+
*/
|
|
384
|
+
function* walkArguments(
|
|
385
|
+
text: string,
|
|
386
|
+
from: number,
|
|
387
|
+
options: WalkOptions = {},
|
|
388
|
+
): Generator<ReadArgument | string> {
|
|
389
|
+
const structural = maskOf(options.range) ?? maskComments(text);
|
|
390
|
+
let i = Math.max(0, from);
|
|
391
|
+
// How many positionals have been read, which is the NEXT positional's index.
|
|
392
|
+
let positionals = 0;
|
|
393
|
+
while (i < text.length) {
|
|
394
|
+
// The MASKED view decides whitespace, because `maskComments` blanks a
|
|
395
|
+
// continuation comment that `text` still spells with a `#`. Reading `text`
|
|
396
|
+
// here left the walk and `scanToken` disagreeing about the same byte: the
|
|
397
|
+
// scan broke on the masked space and returned a zero-length token, so
|
|
398
|
+
// `readToken` emitted an empty positional and `i` never advanced — an
|
|
399
|
+
// `explain` that never returns on `list={1;2} \<nl># c<nl> in=foo`. The
|
|
400
|
+
// strict view uses the same mask: a real continuation comment is whitespace,
|
|
401
|
+
// not positional arguments. Found in review of #243/#245.
|
|
402
|
+
const c = structural[i] as string;
|
|
403
|
+
if (c === " " || c === "\t" || c === "\r" || c === "\n") {
|
|
404
|
+
i++;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
// A continuation between tokens is harmless — it is whitespace RouterOS
|
|
408
|
+
// removes — but one INSIDE a token breaks the value's contiguity, which
|
|
409
|
+
// `scanToken` reports.
|
|
410
|
+
const continuation = continuationLength(text, i);
|
|
411
|
+
if (continuation > 0) {
|
|
412
|
+
i += continuation;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const token = scanToken(text, structural, i, positionals, options);
|
|
416
|
+
if (typeof token === "string") {
|
|
417
|
+
yield token;
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
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;
|
|
425
|
+
return;
|
|
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
|
+
}
|
|
448
|
+
if (read.kind === "positional") positionals++;
|
|
449
|
+
yield read;
|
|
450
|
+
i = token.end;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Lex the arguments of ONE statement, starting at `from`.
|
|
456
|
+
*
|
|
457
|
+
* `text` is the statement, `from` is where its leading run ended
|
|
458
|
+
* (`VerbSplitCommandReading.argsAt`). Offsets in the result are relative to
|
|
459
|
+
* `text`, so a caller rebases them by the statement's own span; it never throws.
|
|
460
|
+
*
|
|
461
|
+
* Only ASCII text may be passed. The analyzed surface is ASCII by construction
|
|
462
|
+
* (`coordinates.ts` stands one byte in for every non-ASCII one), and a caller
|
|
463
|
+
* that hands over the ORIGINAL text of a non-ASCII statement would get spans
|
|
464
|
+
* that do not map back — `src/explain.ts` verifies the two agree before calling.
|
|
465
|
+
*/
|
|
466
|
+
export function lexArguments(
|
|
467
|
+
text: string,
|
|
468
|
+
from: number,
|
|
469
|
+
range?: MaskedRange,
|
|
470
|
+
): ArgumentReading {
|
|
471
|
+
const tokens: Argument[] = [];
|
|
472
|
+
for (const step of walkArguments(text, from, rangeOption(range))) {
|
|
473
|
+
if (typeof step === "string") return unread(step);
|
|
474
|
+
const publicToken = { ...step };
|
|
475
|
+
delete publicToken.literalQuoted;
|
|
476
|
+
tokens.push(publicToken);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const args: Record<string, string> = {};
|
|
480
|
+
const queries: string[] = [];
|
|
481
|
+
const positional: string[] = [];
|
|
482
|
+
for (const token of tokens) {
|
|
483
|
+
if (token.kind === "attribute" && token.name !== undefined)
|
|
484
|
+
args[token.name] = token.value ?? "";
|
|
485
|
+
else if (token.kind === "query") queries.push(token.text);
|
|
486
|
+
else if (token.kind === "positional") positional.push(token.text);
|
|
487
|
+
}
|
|
488
|
+
return { read: true, tokens, args, queries, positional };
|
|
489
|
+
}
|
|
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
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Length of the `\<newline>` continuation at `at`, or 0 — the ONE boundary rule
|
|
541
|
+
* this module and `verbsplit.ts`'s word scanner share.
|
|
542
|
+
*
|
|
543
|
+
* A lone `\r` is deliberately NOT a continuation. Accepting one and then
|
|
544
|
+
* skipping three bytes ate the following character, so an argument the source
|
|
545
|
+
* spells `x` + `comment=2` lexed as plain `comment=2` — a token whose reported
|
|
546
|
+
* name is not the one in the source.
|
|
547
|
+
* That is a silent misread rather than a refusal, which is the one failure this
|
|
548
|
+
* module must not have — and the two scanners disagreeing about where a
|
|
549
|
+
* continuation ends is exactly how `argsAt` and the token stream come to
|
|
550
|
+
* describe different text. Found in review of #202c-1; `\<CR>` alone now falls
|
|
551
|
+
* through to the invalid-escape refusal below.
|
|
552
|
+
*/
|
|
553
|
+
function continuationLength(text: string, at: number): 0 | 2 | 3 {
|
|
554
|
+
if (text[at] !== "\\") return 0;
|
|
555
|
+
if (text[at + 1] === "\n") return 2;
|
|
556
|
+
if (text[at + 1] === "\r" && text[at + 2] === "\n") return 3;
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Find where the token starting at `start` ends, or return the reason it is not
|
|
562
|
+
* lexable. A quoted run is skipped by the ONE shared string scanner (#199), so a
|
|
563
|
+
* `"` inside this statement is read exactly as the segmenter read it.
|
|
564
|
+
*/
|
|
565
|
+
function scanToken(
|
|
566
|
+
text: string,
|
|
567
|
+
structural: string,
|
|
568
|
+
start: number,
|
|
569
|
+
positionalIndex: number,
|
|
570
|
+
options: WalkOptions,
|
|
571
|
+
): ScanToken | string {
|
|
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
|
+
}
|
|
591
|
+
while (i < text.length) {
|
|
592
|
+
const c = structural[i] as string;
|
|
593
|
+
if (c === " " || c === "\t" || c === "\r" || c === "\n") break;
|
|
594
|
+
if (c === '"') {
|
|
595
|
+
const scan = scanQuotedString(structural, i);
|
|
596
|
+
if (!scan.closed) return unbounded("unterminated string in an argument");
|
|
597
|
+
i = scan.end;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
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
|
+
}
|
|
620
|
+
const end = delimitedEnd(structural, i);
|
|
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
|
+
}
|
|
627
|
+
i = end;
|
|
628
|
+
const next = nextNonWhitespace(structural, i);
|
|
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++;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (c === "\\") {
|
|
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
|
+
}
|
|
654
|
+
// The #201 rule: in code a backslash is valid only before whitespace.
|
|
655
|
+
// `symbols.ts` already located the defect; refusing is enough here.
|
|
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
|
+
}
|
|
662
|
+
i += 2;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
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");
|
|
669
|
+
i++;
|
|
670
|
+
}
|
|
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;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* RouterOS rejects an unquoted `#` inside array/group expressions (#245) — but
|
|
704
|
+
* only where the expression role actually reaches the hash.
|
|
705
|
+
*
|
|
706
|
+
* A `[…]` substitution nested in an array RE-ENTERS a statement context, and
|
|
707
|
+
* the device keeps the hash there as a value. A flat scan cannot tell the two
|
|
708
|
+
* apart, so carry the same delimiter roles `segment.ts` and `symbols.ts` use.
|
|
709
|
+
* Grounded on CHR 7.23.3 `/console/inspect request=highlight`, class at the `#`:
|
|
710
|
+
*
|
|
711
|
+
* `:local z {[:put #test]}` `none` — bracket restores statements
|
|
712
|
+
* `:local z {1;[:put #test]}` `none`
|
|
713
|
+
* `:local z {[:put {#test}]}` `error` — array again inside the bracket
|
|
714
|
+
* `:local z {[:put (1,#test)]}` `error` — group again inside the bracket
|
|
715
|
+
* `:local z {#test}` `error` — the plain array case
|
|
716
|
+
*
|
|
717
|
+
* Skipping whole `[…]` regions instead would wrongly accept rows 3 and 4.
|
|
718
|
+
*/
|
|
719
|
+
function hasUnquotedHash(
|
|
720
|
+
text: string,
|
|
721
|
+
start: number,
|
|
722
|
+
end: number,
|
|
723
|
+
range?: MaskedRange,
|
|
724
|
+
): boolean {
|
|
725
|
+
// `start` is the `{`/`(` of an array-or-group value: not a statement context.
|
|
726
|
+
const statements: boolean[] = [false];
|
|
727
|
+
for (let i = start + 1; i < end - 1; i++) {
|
|
728
|
+
const c = text[i];
|
|
729
|
+
if (c === '"') {
|
|
730
|
+
i = scanQuotedString(text, i).end - 1;
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
const enclosing = statements[statements.length - 1] === true;
|
|
734
|
+
if (c === "[") statements.push(true);
|
|
735
|
+
else if (c === "{")
|
|
736
|
+
// Only a brace whose enclosing context ALREADY bears statements can be a
|
|
737
|
+
// scope; nested in an array it is another array, so the role is known
|
|
738
|
+
// without the reverse-prefix scan. The short-circuit is what keeps this
|
|
739
|
+
// O(1) per brace on the deep-nesting inputs `explain-write` and Q17 pin.
|
|
740
|
+
statements.push(
|
|
741
|
+
enclosing &&
|
|
742
|
+
braceStartsStatements(
|
|
743
|
+
range?.masked ?? text,
|
|
744
|
+
(range?.start ?? 0) + i,
|
|
745
|
+
range?.start ?? 0,
|
|
746
|
+
),
|
|
747
|
+
);
|
|
748
|
+
else if (c === "(") statements.push(false);
|
|
749
|
+
else if (c === "]" || c === "}" || c === ")") {
|
|
750
|
+
if (statements.length > 1) statements.pop();
|
|
751
|
+
} else if (c === "#" && statements[statements.length - 1] === false)
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function nextNonWhitespace(text: string, from: number): number {
|
|
758
|
+
let i = from;
|
|
759
|
+
while (
|
|
760
|
+
text[i] === " " ||
|
|
761
|
+
text[i] === "\t" ||
|
|
762
|
+
text[i] === "\r" ||
|
|
763
|
+
text[i] === "\n"
|
|
764
|
+
)
|
|
765
|
+
i++;
|
|
766
|
+
return i;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function startsDottedArgument(text: string, at: number): boolean {
|
|
770
|
+
const match = text.slice(at).match(/^(\.[A-Za-z][A-Za-z0-9._-]*)=/);
|
|
771
|
+
return match !== null && ARGUMENT_NAME.test(match[1] as string);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Operators that prove a closed group is only the left side of an expression. */
|
|
775
|
+
function continuesExpression(
|
|
776
|
+
text: string,
|
|
777
|
+
structuredEnd: number,
|
|
778
|
+
at: number,
|
|
779
|
+
): boolean {
|
|
780
|
+
const c = text[at];
|
|
781
|
+
// A space separates RouterOS arguments. Dotted names are legal arguments,
|
|
782
|
+
// so `{1;2} .proplist=.id` is not the `.` operator. Without the separation,
|
|
783
|
+
// `{1;2}.proplist=...` remains one expression token and must be refused.
|
|
784
|
+
if (c === "." && at > structuredEnd && startsDottedArgument(text, at))
|
|
785
|
+
return false;
|
|
786
|
+
if (c !== undefined && ".,+-*/%&|^~<>=!".includes(c)) return true;
|
|
787
|
+
return /^(?:and|or|in)(?=[ \t\r\n(])/.test(text.slice(at).toLowerCase());
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** One balanced `(...)`/`{...}` run, including nested groups and strings. */
|
|
791
|
+
function delimitedEnd(text: string, start: number): number | null {
|
|
792
|
+
const stack: string[] = [];
|
|
793
|
+
for (let i = start; i < text.length; i++) {
|
|
794
|
+
const c = text[i] as string;
|
|
795
|
+
if (c === '"') {
|
|
796
|
+
const scan = scanQuotedString(text, i);
|
|
797
|
+
if (!scan.closed) return null;
|
|
798
|
+
i = scan.end - 1;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (c === "(" || c === "[" || c === "{") stack.push(c);
|
|
802
|
+
else if (c === ")" || c === "]" || c === "}") {
|
|
803
|
+
const want = c === ")" ? "(" : c === "]" ? "[" : "{";
|
|
804
|
+
if (stack.pop() !== want) return null;
|
|
805
|
+
if (stack.length === 0) return i + 1;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Whether an exact structured source run is an array literal, not grouping. */
|
|
812
|
+
function isArraySource(
|
|
813
|
+
text: string,
|
|
814
|
+
structural: string,
|
|
815
|
+
start: number,
|
|
816
|
+
end: number,
|
|
817
|
+
range: MaskedRange | undefined,
|
|
818
|
+
): boolean {
|
|
819
|
+
const open = text[start];
|
|
820
|
+
if (open !== "(" && open !== "{") return false;
|
|
821
|
+
if (delimitedEnd(structural, start) !== end) return false;
|
|
822
|
+
if (open === "{")
|
|
823
|
+
return (
|
|
824
|
+
!scopeBraceAt(text, start, range) &&
|
|
825
|
+
structural.slice(start + 1, end - 1).trim().length > 0
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
let depth = 0;
|
|
829
|
+
let memberStart = start + 1;
|
|
830
|
+
let sawComma = false;
|
|
831
|
+
for (let i = start + 1; i < end - 1; i++) {
|
|
832
|
+
const c = structural[i] as string;
|
|
833
|
+
if (c === '"') {
|
|
834
|
+
i = scanQuotedString(structural, i).end - 1;
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
838
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
839
|
+
else if (c === "," && depth === 0) {
|
|
840
|
+
if (structural.slice(memberStart, i).trim().length === 0) return false;
|
|
841
|
+
sawComma = true;
|
|
842
|
+
memberStart = i + 1;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return sawComma && structural.slice(memberStart, end - 1).trim().length > 0;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function startsStructuredSource(text: string, start: number): boolean {
|
|
849
|
+
return text[start] === "(" || text[start] === "{";
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** Classify one already-bounded token, or say why it cannot be read. */
|
|
853
|
+
function readToken(
|
|
854
|
+
text: string,
|
|
855
|
+
structural: string,
|
|
856
|
+
start: number,
|
|
857
|
+
end: number,
|
|
858
|
+
options: WalkOptions,
|
|
859
|
+
): ReadArgument | string {
|
|
860
|
+
const raw = text.slice(start, end);
|
|
861
|
+
const span = { start, end };
|
|
862
|
+
if (raw.startsWith("?")) {
|
|
863
|
+
const divergent = queryDisagreement(raw);
|
|
864
|
+
return divergent ?? { kind: "query", span, name: raw.slice(1), text: raw };
|
|
865
|
+
}
|
|
866
|
+
if (options.allowArrayValues && startsStructuredSource(text, start)) {
|
|
867
|
+
if (isArraySource(text, structural, start, end, options.range))
|
|
868
|
+
return {
|
|
869
|
+
kind: "positional",
|
|
870
|
+
span,
|
|
871
|
+
valueSpan: span,
|
|
872
|
+
sourceShape: "array",
|
|
873
|
+
text: raw,
|
|
874
|
+
};
|
|
875
|
+
return "a grouped or expression value";
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const eq = unquotedEquals(text, start, end);
|
|
879
|
+
if (eq === null) {
|
|
880
|
+
// A positional: an id, a `where`, a `find` operand, a `:log info` message.
|
|
881
|
+
// It carries a literal `value` under exactly the same rule an attribute
|
|
882
|
+
// does, and NOTHING where that rule refuses — `"…$[:tostr $x]"` is a
|
|
883
|
+
// located token whose value only the device knows. That uniformity is the
|
|
884
|
+
// contract a consumer reads: **`value === undefined` means there is no
|
|
885
|
+
// literal value here**, whatever the token's kind. Refusing the statement
|
|
886
|
+
// instead would be wrong — the token IS decided; only its value is not,
|
|
887
|
+
// and a `print` positional never needed one.
|
|
888
|
+
const positional = literalValue(text, start, end);
|
|
889
|
+
return typeof positional === "string"
|
|
890
|
+
? { kind: "positional", span, text: raw }
|
|
891
|
+
: {
|
|
892
|
+
kind: "positional",
|
|
893
|
+
span,
|
|
894
|
+
value: positional.value,
|
|
895
|
+
valueSpan: span,
|
|
896
|
+
literalQuoted: positional.quoted,
|
|
897
|
+
text: raw,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
const name = text.slice(start, eq);
|
|
901
|
+
if (!ARGUMENT_NAME.test(name))
|
|
902
|
+
return `\`${name}=\` is not a RouterOS argument name`;
|
|
903
|
+
if (options.allowArrayValues && startsStructuredSource(text, eq + 1)) {
|
|
904
|
+
if (isArraySource(text, structural, eq + 1, end, options.range))
|
|
905
|
+
return {
|
|
906
|
+
kind: "attribute",
|
|
907
|
+
span,
|
|
908
|
+
name,
|
|
909
|
+
valueSpan: { start: eq + 1, end },
|
|
910
|
+
sourceShape: "array",
|
|
911
|
+
text: raw,
|
|
912
|
+
};
|
|
913
|
+
return "a grouped or expression value";
|
|
914
|
+
}
|
|
915
|
+
// An empty value is LEGAL (`comment=` clears it), so `literalValue` reports a
|
|
916
|
+
// refusal as a `string` and a decided value — empty included — as an object.
|
|
917
|
+
// Two different types rather than a sentinel string, which `""` would collide
|
|
918
|
+
// with.
|
|
919
|
+
const value = literalValue(text, eq + 1, end);
|
|
920
|
+
if (typeof value === "string") return value;
|
|
921
|
+
return {
|
|
922
|
+
kind: "attribute",
|
|
923
|
+
span,
|
|
924
|
+
name,
|
|
925
|
+
value: value.value,
|
|
926
|
+
valueSpan: { start: eq + 1, end },
|
|
927
|
+
literalQuoted: value.quoted,
|
|
928
|
+
text: raw,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
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
|
+
|
|
977
|
+
/** Offset of the first `=` outside a quoted run, or null. */
|
|
978
|
+
function unquotedEquals(
|
|
979
|
+
text: string,
|
|
980
|
+
start: number,
|
|
981
|
+
end: number,
|
|
982
|
+
): number | null {
|
|
983
|
+
let i = start;
|
|
984
|
+
while (i < end) {
|
|
985
|
+
const c = text[i] as string;
|
|
986
|
+
if (c === '"') {
|
|
987
|
+
i = scanQuotedString(text, i).end;
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
if (c === "=") return i;
|
|
991
|
+
if (c === "\\") {
|
|
992
|
+
i += 2;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
i++;
|
|
996
|
+
}
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* The literal value in `[start,end)`, or the reason there is no literal one.
|
|
1002
|
+
*
|
|
1003
|
+
* Two shapes are literal: a bare run, and a run that is ONE fully-enclosing
|
|
1004
|
+
* quoted string. Anything else — a partly-quoted value (`a"b"`), or a quoted
|
|
1005
|
+
* value carrying an escape — needs a decoder this phase does not have, and a
|
|
1006
|
+
* decoder that is wrong about one escape corrupts a write.
|
|
1007
|
+
*/
|
|
1008
|
+
function literalValue(
|
|
1009
|
+
text: string,
|
|
1010
|
+
start: number,
|
|
1011
|
+
end: number,
|
|
1012
|
+
): { value: string; quoted: boolean } | string {
|
|
1013
|
+
if (start >= end) return { value: "", quoted: false };
|
|
1014
|
+
if ((text[start] as string) === '"') {
|
|
1015
|
+
const scan = scanQuotedString(text, start);
|
|
1016
|
+
if (!scan.closed) return "unterminated string in an argument value";
|
|
1017
|
+
if (scan.end !== end) return "a partly-quoted argument value";
|
|
1018
|
+
const body = text.slice(start + 1, end - 1);
|
|
1019
|
+
if (body.includes("\\")) return "an escape in a quoted argument value";
|
|
1020
|
+
if (body.includes("$")) return "a substitution in a quoted argument value";
|
|
1021
|
+
return { value: body, quoted: true };
|
|
1022
|
+
}
|
|
1023
|
+
const body = text.slice(start, end);
|
|
1024
|
+
if (body.includes('"')) return "a partly-quoted argument value";
|
|
1025
|
+
// An UNQUOTED escape needs decoding just as much as a quoted one:
|
|
1026
|
+
// `comment=a\ b` is the value `a b` on the device, and returning the source
|
|
1027
|
+
// run would send the backslash. `scanToken` accepts `\ `/`\\t` as part of a
|
|
1028
|
+
// token (it is legal RouterOS), so the refusal has to happen here — the token
|
|
1029
|
+
// is decided, its literal value is not. Found in review of #202c-1.
|
|
1030
|
+
if (body.includes("\\")) return "an escape in an argument value";
|
|
1031
|
+
const disagreement = gateDisagreement(body);
|
|
1032
|
+
if (disagreement !== null) return disagreement;
|
|
1033
|
+
return { value: body, quoted: false };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* The same guard for a `?query` word — which is where the first two versions of
|
|
1038
|
+
* it did NOT reach, because the query branch of {@link readToken} returns before
|
|
1039
|
+
* any value is read (found by review of #202c-1, twice: the fix was written at
|
|
1040
|
+
* the value level and one of the three token kinds has no value).
|
|
1041
|
+
*
|
|
1042
|
+
* A query is kept VERBATIM: `ArgumentsRead.queries` is source text, and a query
|
|
1043
|
+
* word's `?` forms (`?>`, `?#|`, existence tests) are a grammar this lexer does
|
|
1044
|
+
* not split. So the check is not about values but about whether the gate's
|
|
1045
|
+
* tokenizer would produce the same STRING — and it is the identity only on a run
|
|
1046
|
+
* with no quote, escape, or `\s`-but-not-ASCII-whitespace byte in it:
|
|
1047
|
+
*
|
|
1048
|
+
* - `'` and `\f`/`\v` — the two disagreements above, unchanged in cause.
|
|
1049
|
+
* - `"` — the gate STRIPS quotes, so `?comment="lan uplink"` is the word
|
|
1050
|
+
* `?comment=lan uplink` there and the source text here.
|
|
1051
|
+
* - `\` — the gate consumes it as an escape, so `?comment=a\ b` is
|
|
1052
|
+
* `?comment=a b` there and `?comment=a\ b` here.
|
|
1053
|
+
*
|
|
1054
|
+
* What survives is exactly the shape on which the gate's tokenizer is a no-op,
|
|
1055
|
+
* which is what makes `arguments.queries` and `canonical.queries` comparable at
|
|
1056
|
+
* all — and that comparison is now an anchored test rather than an assumption.
|
|
1057
|
+
*/
|
|
1058
|
+
function queryDisagreement(raw: string): string | null {
|
|
1059
|
+
const found = gateDisagreement(raw);
|
|
1060
|
+
if (found !== null) return found;
|
|
1061
|
+
if (raw.includes('"'))
|
|
1062
|
+
return "a quoted query word, which centrs's execute gate unquotes and this reading keeps verbatim";
|
|
1063
|
+
if (raw.includes("\\"))
|
|
1064
|
+
return "an escape in a query word, which centrs's execute gate decodes and this reading keeps verbatim";
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Characters an UNQUOTED value may not contain, because centrs's two readers
|
|
1070
|
+
* genuinely disagree about them — so phase 1 publishes neither reading.
|
|
1071
|
+
*
|
|
1072
|
+
* Both cases were found by review of #202c-1, and neither is reachable from the
|
|
1073
|
+
* corpus:
|
|
1074
|
+
*
|
|
1075
|
+
* - **`'`** — RouterOS does not use it as a string delimiter; it is an
|
|
1076
|
+
* ordinary character, which is what this lexer reads. But
|
|
1077
|
+
* `canonicalizeExecuteCommand`'s tokenizer DOES treat it as a quote
|
|
1078
|
+
* (`src/execute.ts`), so `comment='lan uplink'` is `{comment: "lan uplink"}`
|
|
1079
|
+
* to the gate and `{comment: "'lan"}` + a positional here, and
|
|
1080
|
+
* `comment=it's` is `its` there and `it's` here.
|
|
1081
|
+
* - **`\f` / `\v`** — the gate splits tokens on JavaScript `\s`, which
|
|
1082
|
+
* includes them; every `explain` module splits on ASCII whitespace (space,
|
|
1083
|
+
* tab, CR, LF), which does not. So `comment=x\fdisabled=no` is two
|
|
1084
|
+
* arguments to the gate and one value here.
|
|
1085
|
+
*
|
|
1086
|
+
* **Refusing is not a claim that the gate is right.** The device-correct reading
|
|
1087
|
+
* is this module's in both cases, and the gate is locked — widening or
|
|
1088
|
+
* correcting it is a product regression (`docs/CONSTITUTION.md`). What is
|
|
1089
|
+
* refused is putting a SECOND confident value in the same result, which is the
|
|
1090
|
+
* contradiction `command.args` beside `canonical.args` must never contain.
|
|
1091
|
+
*
|
|
1092
|
+
* Two boundaries this deliberately does NOT cross:
|
|
1093
|
+
*
|
|
1094
|
+
* - **Only unquoted values.** Inside a `"…"` run the gate treats all three as
|
|
1095
|
+
* content exactly as this lexer does, so the two agree and refusing there
|
|
1096
|
+
* would cost real readings.
|
|
1097
|
+
* - **Only these two control characters, not the gate's whole `\s` class.**
|
|
1098
|
+
* Adopting `\s` here would put this scanner at odds with `verbsplit.ts` and
|
|
1099
|
+
* `segment.ts` about where a token ends — two scanners disagreeing about a
|
|
1100
|
+
* boundary is the failure the shared `continuationLength` above exists to
|
|
1101
|
+
* prevent. The rest of `\s` is non-ASCII and never reaches this module
|
|
1102
|
+
* as itself: `coordinates.ts` normalization stands a placeholder in, so the
|
|
1103
|
+
* statement fails the addressability check in `src/explain.ts` first
|
|
1104
|
+
* (verified for NBSP, U+2028, U+FEFF and U+3000).
|
|
1105
|
+
*/
|
|
1106
|
+
function gateDisagreement(body: string): string | null {
|
|
1107
|
+
if (body.includes("'"))
|
|
1108
|
+
return "a single quote in an unquoted value, which centrs's execute gate and RouterOS read differently";
|
|
1109
|
+
if (body.includes("\f") || body.includes("\v"))
|
|
1110
|
+
return "a form feed or vertical tab in an unquoted value, which centrs's execute gate treats as a token boundary and RouterOS does not";
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Trim ASCII whitespace off a range, returning the tightened bounds. */
|
|
1115
|
+
function trimRange(
|
|
1116
|
+
text: string,
|
|
1117
|
+
start: number,
|
|
1118
|
+
end: number,
|
|
1119
|
+
): { start: number; end: number } {
|
|
1120
|
+
let from = start;
|
|
1121
|
+
let to = end;
|
|
1122
|
+
while (from < to && " \t\r\n".includes(text[from] as string)) from++;
|
|
1123
|
+
while (to > from && " \t\r\n".includes(text[to - 1] as string)) to--;
|
|
1124
|
+
return { start: from, end: to };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Split `[from,to)` on depth-0 `separator`, or null when the device would call
|
|
1129
|
+
* the literal a syntax error.
|
|
1130
|
+
*
|
|
1131
|
+
* On CHR 7.23.3 an empty member is fatal — `{;}`, `{;1}`, `{1;;2}` and `(1,)`
|
|
1132
|
+
* are all `syntax error` — with exactly one exception: a brace array tolerates
|
|
1133
|
+
* ONE trailing separator (`{1;}` is a one-member array, `{1;2;}` a two-member
|
|
1134
|
+
* one). Returning null rather than "no members" matters, because the enclosing
|
|
1135
|
+
* `array` shape is wrong too when the literal does not parse.
|
|
1136
|
+
*/
|
|
1137
|
+
function splitMembers(
|
|
1138
|
+
structural: string,
|
|
1139
|
+
from: number,
|
|
1140
|
+
to: number,
|
|
1141
|
+
separator: ";" | ",",
|
|
1142
|
+
): { start: number; end: number }[] | null {
|
|
1143
|
+
const runs: { start: number; end: number }[] = [];
|
|
1144
|
+
let memberStart = from;
|
|
1145
|
+
for (const at of depthZeroOffsets(structural, from, to, separator)) {
|
|
1146
|
+
runs.push({ start: memberStart, end: at });
|
|
1147
|
+
memberStart = at + 1;
|
|
1148
|
+
}
|
|
1149
|
+
runs.push({ start: memberStart, end: to });
|
|
1150
|
+
const trimmed = runs.map((run) => trimRange(structural, run.start, run.end));
|
|
1151
|
+
const trailingEmpty =
|
|
1152
|
+
separator === ";" &&
|
|
1153
|
+
trimmed.length > 1 &&
|
|
1154
|
+
(trimmed[trimmed.length - 1] as { start: number; end: number }).start ===
|
|
1155
|
+
(trimmed[trimmed.length - 1] as { start: number; end: number }).end;
|
|
1156
|
+
const members = trailingEmpty ? trimmed.slice(0, -1) : trimmed;
|
|
1157
|
+
if (members.some((run) => run.start === run.end)) return null;
|
|
1158
|
+
return members;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Why a located array literal was withdrawn; each is a device syntax error. */
|
|
1162
|
+
const EMPTY_MEMBER = "an array literal with an empty member";
|
|
1163
|
+
const EMPTY_GROUP = "an array member that is an empty group";
|
|
1164
|
+
const DEPTH_BOUND_REACHED =
|
|
1165
|
+
"an array literal nested deeper than this phase reads";
|
|
1166
|
+
const UNPARSEABLE_MEMBER =
|
|
1167
|
+
"an array member RouterOS rejects at its first character";
|
|
1168
|
+
const HALF_COMPARISON =
|
|
1169
|
+
"an array member whose `=` is missing one of its operands";
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Why a `(…)` member cannot stand where it is, or null when it can.
|
|
1173
|
+
*
|
|
1174
|
+
* A parenthesis inside a literal is either a GROUP — `{(1)}` is the one-member
|
|
1175
|
+
* array `1` — or a comma array, and {@link isArraySource} only recognizes the
|
|
1176
|
+
* second. That left the first sharing the fallback branch with every other
|
|
1177
|
+
* expression, which is an abstention, so a group the device REJECTS kept the
|
|
1178
|
+
* enclosing `array` shape instead of withdrawing it (found in review).
|
|
1179
|
+
*
|
|
1180
|
+
* On CHR 7.23.3 an empty group (`{()}`, `{a=()}`) and an empty comma member
|
|
1181
|
+
* (`{(1,)}`, `{(,)}`, `{1;(2,)}`, `(1,(2,))`) are syntax errors at every
|
|
1182
|
+
* nesting depth, while `{(1)}`, `{a=(1)}` and `{1;(2)}` all parse — so the
|
|
1183
|
+
* check has to name the fault rather than refuse every parenthesis.
|
|
1184
|
+
*
|
|
1185
|
+
* `highlight` is not the oracle here: it accepts `{1;2,}`, `{2,}` and
|
|
1186
|
+
* `{(1,2),}`, all of which `:parse` rejects.
|
|
1187
|
+
*/
|
|
1188
|
+
function parenMemberFault(
|
|
1189
|
+
text: string,
|
|
1190
|
+
structural: string,
|
|
1191
|
+
start: number,
|
|
1192
|
+
end: number,
|
|
1193
|
+
): string | null {
|
|
1194
|
+
if (text[start] !== "(") return null;
|
|
1195
|
+
if (delimitedEnd(structural, start) !== end) return null;
|
|
1196
|
+
if (structural.slice(start + 1, end - 1).trim().length === 0)
|
|
1197
|
+
return EMPTY_GROUP;
|
|
1198
|
+
return splitMembers(structural, start + 1, end - 1, ",") === null
|
|
1199
|
+
? EMPTY_MEMBER
|
|
1200
|
+
: null;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** Characters that cannot OPEN an array member; see {@link pushArrayMembers}. */
|
|
1204
|
+
function leadsInvalidMember(c: string | undefined): boolean {
|
|
1205
|
+
return c === "*" || c === "+";
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* A member key: a bare word or one fully-enclosing quoted run.
|
|
1210
|
+
*
|
|
1211
|
+
* The bare spelling is {@link MEMBER_NAME}, which is NOT the identifier grammar
|
|
1212
|
+
* and was measured rather than assumed — see that constant. Review caught the
|
|
1213
|
+
* first version of this rejecting `.id`, which the fixture itself held as a
|
|
1214
|
+
* valid key.
|
|
1215
|
+
*/
|
|
1216
|
+
function memberKey(
|
|
1217
|
+
text: string,
|
|
1218
|
+
structural: string,
|
|
1219
|
+
start: number,
|
|
1220
|
+
end: number,
|
|
1221
|
+
): string | null {
|
|
1222
|
+
if (start >= end) return null;
|
|
1223
|
+
if (text[start] === '"') {
|
|
1224
|
+
const scan = scanQuotedString(structural, start);
|
|
1225
|
+
if (!scan.closed || scan.end !== end) return null;
|
|
1226
|
+
const body = text.slice(start + 1, end - 1);
|
|
1227
|
+
return body.includes("\\") || body.includes("$") ? null : body;
|
|
1228
|
+
}
|
|
1229
|
+
const raw = text.slice(start, end);
|
|
1230
|
+
return MEMBER_NAME.test(raw) ? raw : null;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* A bare array-member key, as the device spells one on 7.23.3 (#258).
|
|
1235
|
+
*
|
|
1236
|
+
* Swept character by character rather than borrowed from the identifier rules,
|
|
1237
|
+
* because the two disagree in both directions. `.`, `-` and `/` are ordinary
|
|
1238
|
+
* name bytes ANYWHERE, including alone and repeated: `{.id=1}`, `{..id=1}`,
|
|
1239
|
+
* `{.=1}`, `{-=1}`, `{--=1}`, `{/=1}`, `{a/b=1}` and `{-.1=1}` all bind the key
|
|
1240
|
+
* spelled exactly that way. But `_` does NOT: `{_a=1}` and `{a_b=1}` lower to
|
|
1241
|
+
* `(= $_a 1)` and `(= $a_b 1)`, comparisons — even though `$a_b` is a perfectly
|
|
1242
|
+
* good variable. Neither do `: * ~ % > < + ! @ #`.
|
|
1243
|
+
*
|
|
1244
|
+
* The review that caught `.id` is the reason this is a named constant with its
|
|
1245
|
+
* own evidence: a key reader that quietly disagrees with the device about what
|
|
1246
|
+
* a name IS makes every rule built on top of it wrong in the same direction.
|
|
1247
|
+
*/
|
|
1248
|
+
const MEMBER_NAME = /^[A-Za-z0-9./-]+$/;
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Characters the device proved cannot appear in a bare member key (#258).
|
|
1252
|
+
*
|
|
1253
|
+
* Used only to say a left side is POSITIVELY an expression, never to say it is
|
|
1254
|
+
* a name. That asymmetry is the fix for the `.id` defect: an unrecognized
|
|
1255
|
+
* spelling that is merely not in {@link MEMBER_NAME} abstains, while only a
|
|
1256
|
+
* spelling holding one of these bytes is treated as a comparison and can
|
|
1257
|
+
* withdraw its literal. Each byte is a syntax error with an empty right side
|
|
1258
|
+
* (`{_a=}`, `{a:b=}`, `{a*b=}`, `{a~b=}`, `{a>b=}`, `{a%b=}`, `{1+1=}`,
|
|
1259
|
+
* `{a<=}`, `{a b=}`, `{a,b=}`, `{$a=}`, `{(a)=}`, `{[:len 1]=}`, `{{1;2}=}`)
|
|
1260
|
+
* or an operator in the IL of the same member with one.
|
|
1261
|
+
*/
|
|
1262
|
+
const NOT_IN_MEMBER_NAME = /[\s,$([{"'+*<>!~%:_@#=]/;
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* Whether `[start,end)` ends on an operator with no right operand (#258).
|
|
1266
|
+
*
|
|
1267
|
+
* A member and a comparison's left side are both runs the device lexes on
|
|
1268
|
+
* their own, so both carry this fault: `{-}`, `{--}`, `{a-}`, `{$a-}` and
|
|
1269
|
+
* `{$a-;1}` are syntax errors, and so are `{a- =1}` and `{$a-=1}` where the
|
|
1270
|
+
* dangling run is only the left side. The trailing-dot half is narrower and
|
|
1271
|
+
* the asymmetry is the device's: `{$a.}` and `{$a.=1}` are refused while
|
|
1272
|
+
* `{a.}`, `{.}`, `{/}`, `{a/}` and `{a. =1}` all parse as variable references
|
|
1273
|
+
* — a bare name absorbs the dot into itself, a `$` name does not.
|
|
1274
|
+
*
|
|
1275
|
+
* A KEY is not an operand and does not carry it: `{a-=1}` binds the key `a-`
|
|
1276
|
+
* and `{a-=}` is the string `a-`, so this is only asked where the sign failed
|
|
1277
|
+
* to bind.
|
|
1278
|
+
*/
|
|
1279
|
+
function danglesOperator(text: string, start: number, end: number): boolean {
|
|
1280
|
+
if (start >= end) return false;
|
|
1281
|
+
const last = text[end - 1];
|
|
1282
|
+
if (last === "-") return true;
|
|
1283
|
+
return last === "." && text[start] === "$";
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Whether `[start,end)` is ONE complete operand, so a following `=` is the
|
|
1288
|
+
* member's top-level operator (#258).
|
|
1289
|
+
*
|
|
1290
|
+
* This is the whole difference between a member that is provably `bool` and one
|
|
1291
|
+
* that must abstain. `=` returns `bool` for any pair of operands, so `{$a=1}`,
|
|
1292
|
+
* `{(a)=1}`, `{[:timestamp]=1}` and `{{1;2}=1}` are all `bool` — but only
|
|
1293
|
+
* because the `=` is on top. `{a b=1}` lowers to `( $a (= $b 1))`, where the
|
|
1294
|
+
* top operator is the space and the type is its operands', and `{$a=1,2}`
|
|
1295
|
+
* lowers to `(, (= $a 1) 2)`, an `array` whose FIRST ELEMENT is the comparison.
|
|
1296
|
+
* Both were device-typed `bool` under the bindings a probe happened to use, so
|
|
1297
|
+
* the shape of the left side — not the observed type — has to decide.
|
|
1298
|
+
*
|
|
1299
|
+
* A bare name is included because it only reaches here when whitespace kept it
|
|
1300
|
+
* from binding a key (`{a =1}` compares, `{a=1}` does not) — but OPERAND
|
|
1301
|
+
* position is stricter than key position, so it is not simply
|
|
1302
|
+
* {@link memberKey}: `{a-=1}` binds the key `a-`, while `{a- =1}` does not
|
|
1303
|
+
* parse at all. Both classes therefore end on an alphanumeric here.
|
|
1304
|
+
*
|
|
1305
|
+
* The class is narrower than RouterOS's operand grammar on purpose, and the
|
|
1306
|
+
* gap is all abstention: `{a_b=1}`, `{:a=1}`, `{1+1=2}` and `{$a/b=1}` are
|
|
1307
|
+
* `bool` on the device and silent here. Widening it means proving the `=`
|
|
1308
|
+
* stays on top for the new spelling, which is exactly what {@link MEMBER_NAME}
|
|
1309
|
+
* had to be measured for.
|
|
1310
|
+
*/
|
|
1311
|
+
function isSingleOperand(
|
|
1312
|
+
text: string,
|
|
1313
|
+
structural: string,
|
|
1314
|
+
start: number,
|
|
1315
|
+
end: number,
|
|
1316
|
+
): boolean {
|
|
1317
|
+
if (start >= end) return false;
|
|
1318
|
+
const open = text[start];
|
|
1319
|
+
if (open === "(" || open === "[" || open === "{")
|
|
1320
|
+
return delimitedEnd(structural, start) === end;
|
|
1321
|
+
// A `$` name is NOT the member-key name, and review's suggestion to widen it
|
|
1322
|
+
// was the wrong direction: `$:a`, `$_a`, `$/a` and `$.id` are all syntax
|
|
1323
|
+
// errors, so the byte after the `$` must be alphanumeric, and `$a-`/`$a.`
|
|
1324
|
+
// are errors too, so the last byte must be as well. In between, `.` and `-`
|
|
1325
|
+
// join operands (`{$a-b=1}` is `(= (- $a $b) 1)`) and the `=` stays on top,
|
|
1326
|
+
// while `_` and `:` end the name and start a SPACE-concat whose type is its
|
|
1327
|
+
// operands' (`{$a_b=1}` is `( $a (= $_b 1))`).
|
|
1328
|
+
if (open === "$")
|
|
1329
|
+
return /^\$[A-Za-z0-9]+(?:[.-]+[A-Za-z0-9]+)*$/.test(
|
|
1330
|
+
text.slice(start, end),
|
|
1331
|
+
);
|
|
1332
|
+
return (
|
|
1333
|
+
memberKey(text, structural, start, end) !== null &&
|
|
1334
|
+
/[A-Za-z0-9]$/.test(text.slice(start, end))
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Every offset of `needle` at depth 0 in `[start,end)`, strings skipped.
|
|
1340
|
+
*
|
|
1341
|
+
* Member splitting, the key split, and the nested-comma test are the same walk
|
|
1342
|
+
* asked three questions, so they share it: this module's standing failure mode
|
|
1343
|
+
* is two scanners disagreeing about one byte (see {@link continuationLength}).
|
|
1344
|
+
*/
|
|
1345
|
+
function depthZeroOffsets(
|
|
1346
|
+
structural: string,
|
|
1347
|
+
start: number,
|
|
1348
|
+
end: number,
|
|
1349
|
+
needle: string,
|
|
1350
|
+
): number[] {
|
|
1351
|
+
const found: number[] = [];
|
|
1352
|
+
let depth = 0;
|
|
1353
|
+
for (let i = start; i < end; i++) {
|
|
1354
|
+
const c = structural[i] as string;
|
|
1355
|
+
if (c === '"') {
|
|
1356
|
+
i = scanQuotedString(structural, i).end - 1;
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
1360
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
1361
|
+
else if (c === needle && depth === 0) found.push(i);
|
|
1362
|
+
}
|
|
1363
|
+
return found;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Append the members of one array literal, depth first (#225 V1 interior).
|
|
1368
|
+
*
|
|
1369
|
+
* Returns null when every member was read, or the REASON the literal does not
|
|
1370
|
+
* parse on the device — the caller withdraws the enclosing `array` shape and
|
|
1371
|
+
* publishes that reason, so a consumer is never told "empty member" about a
|
|
1372
|
+
* literal whose actual fault was something else (found in review).
|
|
1373
|
+
*
|
|
1374
|
+
* Every rule below is a CHR 7.23.3 reading, taken from each member's own
|
|
1375
|
+
* `:typeof` under `:foreach k,v` and the statement's `:parse` IL:
|
|
1376
|
+
*
|
|
1377
|
+
* - the separator is the DELIMITER's, not a choice: `{1;2}` has two members
|
|
1378
|
+
* while `{1,2}` has ONE (`(, 1 2)`, a nested array), and `(1;2)` is a
|
|
1379
|
+
* syntax error;
|
|
1380
|
+
* - `=` binds a key only in the brace form, and only where a NAME touches the
|
|
1381
|
+
* sign (`{a=1}` -> key `a`, value `num`); anywhere else it COMPARES, so
|
|
1382
|
+
* `{a =1}`, `{$a=1}` and `(a=1,b=2)` are `bool` members and not keys;
|
|
1383
|
+
* - a key whose right side is EMPTY is not an empty-valued key: the device
|
|
1384
|
+
* drops the sign and keeps the name as a positional `str` (`{a=}` -> `a`),
|
|
1385
|
+
* while an empty side anywhere else is a syntax error;
|
|
1386
|
+
* - a member that is itself `{…}`/`(…)`, or a brace member carrying a depth-0
|
|
1387
|
+
* comma, is an array and is descended into;
|
|
1388
|
+
* - everything else is offered to the literal reader, and what it refuses is
|
|
1389
|
+
* a variable reference or an expression on the device — which is exactly
|
|
1390
|
+
* what an abstention should mean.
|
|
1391
|
+
*/
|
|
1392
|
+
function pushArrayMembers(
|
|
1393
|
+
anchors: ValueAnchor[],
|
|
1394
|
+
text: string,
|
|
1395
|
+
structural: string,
|
|
1396
|
+
parent: number,
|
|
1397
|
+
contentStart: number,
|
|
1398
|
+
contentEnd: number,
|
|
1399
|
+
separator: ";" | ",",
|
|
1400
|
+
depth: number,
|
|
1401
|
+
range: MaskedRange | undefined,
|
|
1402
|
+
): string | null {
|
|
1403
|
+
// Past the bound the interior is UNVERIFIED, and an unverified interior can
|
|
1404
|
+
// hold a fault that makes the whole statement a syntax error — `:parse`
|
|
1405
|
+
// rejects a `(1,)` buried nine levels deep exactly as it rejects a shallow
|
|
1406
|
+
// one. Returning null here called those bytes an array anyway, so the bound
|
|
1407
|
+
// withdraws instead. Deepest member in the 948-script corpus is 6, so this
|
|
1408
|
+
// costs nothing that has ever been seen.
|
|
1409
|
+
if (depth >= MAX_MEMBER_DEPTH) return DEPTH_BOUND_REACHED;
|
|
1410
|
+
const members = splitMembers(structural, contentStart, contentEnd, separator);
|
|
1411
|
+
if (members === null) return EMPTY_MEMBER;
|
|
1412
|
+
for (const member of members) {
|
|
1413
|
+
let valueStart = member.start;
|
|
1414
|
+
let valueEnd = member.end;
|
|
1415
|
+
let name: string | undefined;
|
|
1416
|
+
let sourceShape: "str" | "bool" | undefined;
|
|
1417
|
+
let decoded: string | undefined;
|
|
1418
|
+
let quoted = false;
|
|
1419
|
+
// A member may not OPEN with `*` or `+`: `{*1}`, `{ *1 }`, `(*1,2)`,
|
|
1420
|
+
// `{a=*1}` and `{+1}` are all syntax errors on 7.23.3, even though `*1` is
|
|
1421
|
+
// a perfectly good value one position out (`:local x *1` is typed `id`).
|
|
1422
|
+
// Only the first byte is read, because both characters are ordinary
|
|
1423
|
+
// operators between operands — `{2*3}` is `num` 6 and `{1+1}` is 2 — and
|
|
1424
|
+
// unary minus is fine either way (`{-1}` is `num` -1).
|
|
1425
|
+
if (leadsInvalidMember(text[member.start])) return UNPARSEABLE_MEMBER;
|
|
1426
|
+
// `@` is not a member character ANYWHERE, unlike `*`/`+` which are only
|
|
1427
|
+
// fatal at the front: `{a@b}`, `{@a}`, `{a@}`, `{1;a@b}`, `(a@b,2)` and
|
|
1428
|
+
// `{a@b=1}` are all syntax errors, while the same bytes one slot out are
|
|
1429
|
+
// fine (`:local z a@b` parses). Quoted runs keep it — `{"a@b"}` parses —
|
|
1430
|
+
// which is why this is a depth-zero scan and not a substring test.
|
|
1431
|
+
if (depthZeroOffsets(structural, member.start, member.end, "@").length > 0)
|
|
1432
|
+
return UNPARSEABLE_MEMBER;
|
|
1433
|
+
// A `$` takes an alphanumeric or a QUOTE and nothing else: `$.id`, `$_a`,
|
|
1434
|
+
// `$:a`, `$/a`, `$$a`, `$(x)`, `$[:len 1]` and a bare `$` are all syntax
|
|
1435
|
+
// errors, while `$"a b"` is a good quoted variable name — so "alphanumeric"
|
|
1436
|
+
// alone would withdraw a literal the device accepts.
|
|
1437
|
+
for (const at of depthZeroOffsets(
|
|
1438
|
+
structural,
|
|
1439
|
+
member.start,
|
|
1440
|
+
member.end,
|
|
1441
|
+
"$",
|
|
1442
|
+
))
|
|
1443
|
+
if (!/[A-Za-z0-9"]/.test(text[at + 1] ?? "")) return UNPARSEABLE_MEMBER;
|
|
1444
|
+
if (danglesOperator(text, member.start, member.end))
|
|
1445
|
+
return UNPARSEABLE_MEMBER;
|
|
1446
|
+
const eq = depthZeroOffsets(structural, member.start, member.end, "=")[0];
|
|
1447
|
+
if (eq !== undefined) {
|
|
1448
|
+
const left = trimRange(structural, member.start, eq);
|
|
1449
|
+
const right = trimRange(structural, eq + 1, member.end);
|
|
1450
|
+
// A key binds only in the brace form, and only where the NAME TOUCHES
|
|
1451
|
+
// the sign: `{a=1}` is the key `a`, `{a =1}` is the comparison
|
|
1452
|
+
// `(= $a 1)`, and one byte of space is the whole difference (#258).
|
|
1453
|
+
const key =
|
|
1454
|
+
separator === ";" && eq === left.end
|
|
1455
|
+
? memberKey(text, structural, left.start, left.end)
|
|
1456
|
+
: null;
|
|
1457
|
+
// An empty LEFT side is a syntax error in both forms and at any
|
|
1458
|
+
// position: `{=}`, `{=1}`, `{ =1 }`, `(=1,2)`, `(1,=2)`.
|
|
1459
|
+
if (left.start === left.end) return HALF_COMPARISON;
|
|
1460
|
+
if (key === null && right.start === right.end) {
|
|
1461
|
+
// An empty RIGHT side is a syntax error too — but ONLY once the left
|
|
1462
|
+
// side is known not to be a name, because a name means the sign is
|
|
1463
|
+
// dropped instead (`{a=}`). Review found the first version of this
|
|
1464
|
+
// asking `memberKey` that question, which withdrew `{.id=}`: a
|
|
1465
|
+
// literal the device accepts, whose member is the string `.id`. So
|
|
1466
|
+
// the fault now needs POSITIVE evidence of an expression, and a
|
|
1467
|
+
// spelling this reader simply does not recognize abstains.
|
|
1468
|
+
//
|
|
1469
|
+
// The paren form needs no such evidence: `=` never binds a key
|
|
1470
|
+
// there, so an empty right side is always a comparison missing an
|
|
1471
|
+
// operand (`(a=)`, `(a=,2)`).
|
|
1472
|
+
//
|
|
1473
|
+
// The span read here is the RAW one, not the trimmed `left`: the
|
|
1474
|
+
// whitespace between a name and the sign is itself the evidence
|
|
1475
|
+
// (`{a =}` and `{.id =}` are syntax errors while `{a=}` and
|
|
1476
|
+
// `{.id=}` are not), and trimming it away hides exactly that.
|
|
1477
|
+
if (
|
|
1478
|
+
separator === "," ||
|
|
1479
|
+
NOT_IN_MEMBER_NAME.test(text.slice(member.start, eq))
|
|
1480
|
+
)
|
|
1481
|
+
return HALF_COMPARISON;
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
if (key !== null && right.start === right.end) {
|
|
1485
|
+
// `{a=}` is not an empty-valued key: the device DROPS the sign and
|
|
1486
|
+
// keeps the name as a positional `str` — `{1.1=}` is the string
|
|
1487
|
+
// `1.1`, not the address, so no member lexicon runs on it either.
|
|
1488
|
+
sourceShape = "str";
|
|
1489
|
+
decoded = key;
|
|
1490
|
+
quoted = text[left.start] === '"';
|
|
1491
|
+
valueStart = left.start;
|
|
1492
|
+
valueEnd = left.end;
|
|
1493
|
+
} else if (key !== null) {
|
|
1494
|
+
if (leadsInvalidMember(text[right.start])) return UNPARSEABLE_MEMBER;
|
|
1495
|
+
// A value that OPENS with the sign is an `=` with nothing on its
|
|
1496
|
+
// left, which is the same syntax error one level down: `{a==}` is
|
|
1497
|
+
// rejected exactly as `{=1}` is.
|
|
1498
|
+
if (text[right.start] === "=") return HALF_COMPARISON;
|
|
1499
|
+
name = key;
|
|
1500
|
+
valueStart = right.start;
|
|
1501
|
+
} else if (danglesOperator(text, left.start, left.end)) {
|
|
1502
|
+
// The sign's left operand is itself incomplete: `{a- =1}` and
|
|
1503
|
+
// `{$a-=1}` are refused exactly as the bare `{a-}` is.
|
|
1504
|
+
return UNPARSEABLE_MEMBER;
|
|
1505
|
+
} else if (
|
|
1506
|
+
isSingleOperand(text, structural, left.start, left.end) &&
|
|
1507
|
+
depthZeroOffsets(structural, member.start, member.end, ",").length === 0
|
|
1508
|
+
) {
|
|
1509
|
+
// The left operand is a member in its own right, so it carries the
|
|
1510
|
+
// same faults: `{()=1}` is rejected exactly as `{()}` is, and being
|
|
1511
|
+
// on the left of a comparison does not excuse it.
|
|
1512
|
+
const leftFault = parenMemberFault(
|
|
1513
|
+
text,
|
|
1514
|
+
structural,
|
|
1515
|
+
left.start,
|
|
1516
|
+
left.end,
|
|
1517
|
+
);
|
|
1518
|
+
if (leftFault !== null) return leftFault;
|
|
1519
|
+
// The `=` is on top, so the member is `bool` whatever it compares.
|
|
1520
|
+
sourceShape = "bool";
|
|
1521
|
+
} else {
|
|
1522
|
+
// A left side that is not one operand, or a depth-zero comma that
|
|
1523
|
+
// outranks the sign: the device has a member, but its type is its
|
|
1524
|
+
// operands' and offline cannot read those.
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
const anchor: ValueAnchor = {
|
|
1529
|
+
kind: "element",
|
|
1530
|
+
tokenSpan: { start: member.start, end: member.end },
|
|
1531
|
+
...(name === undefined ? {} : { name }),
|
|
1532
|
+
valueSpan: { start: valueStart, end: valueEnd },
|
|
1533
|
+
value: decoded ?? text.slice(valueStart, valueEnd),
|
|
1534
|
+
quoted,
|
|
1535
|
+
parent,
|
|
1536
|
+
};
|
|
1537
|
+
if (sourceShape !== undefined) {
|
|
1538
|
+
anchors.push({ ...anchor, sourceShape });
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
const fault = parenMemberFault(text, structural, valueStart, member.end);
|
|
1542
|
+
if (fault !== null) return fault;
|
|
1543
|
+
if (isArraySource(text, structural, valueStart, member.end, range)) {
|
|
1544
|
+
const at = anchors.length;
|
|
1545
|
+
anchors.push({ ...anchor, sourceShape: "array" });
|
|
1546
|
+
const nested = pushArrayMembers(
|
|
1547
|
+
anchors,
|
|
1548
|
+
text,
|
|
1549
|
+
structural,
|
|
1550
|
+
at,
|
|
1551
|
+
valueStart + 1,
|
|
1552
|
+
member.end - 1,
|
|
1553
|
+
text[valueStart] === "{" ? ";" : ",",
|
|
1554
|
+
depth + 1,
|
|
1555
|
+
range,
|
|
1556
|
+
);
|
|
1557
|
+
if (nested !== null) return nested;
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
if (
|
|
1561
|
+
separator === ";" &&
|
|
1562
|
+
depthZeroOffsets(structural, valueStart, member.end, ",").length > 0
|
|
1563
|
+
) {
|
|
1564
|
+
// `{1;2,3}` is two members, the second a comma array carrying no
|
|
1565
|
+
// delimiters of its own — IL `1;(, 2 3)`. It splits on commas.
|
|
1566
|
+
const at = anchors.length;
|
|
1567
|
+
anchors.push({ ...anchor, sourceShape: "array" });
|
|
1568
|
+
const nested = pushArrayMembers(
|
|
1569
|
+
anchors,
|
|
1570
|
+
text,
|
|
1571
|
+
structural,
|
|
1572
|
+
at,
|
|
1573
|
+
valueStart,
|
|
1574
|
+
member.end,
|
|
1575
|
+
",",
|
|
1576
|
+
depth + 1,
|
|
1577
|
+
range,
|
|
1578
|
+
);
|
|
1579
|
+
if (nested !== null) return nested;
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
const literal = literalValue(text, valueStart, member.end);
|
|
1583
|
+
if (typeof literal === "string") continue;
|
|
1584
|
+
anchors.push({ ...anchor, value: literal.value, quoted: literal.quoted });
|
|
1585
|
+
}
|
|
1586
|
+
return null;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Locate the literal-value prefix of one statement's arguments (#225 V1).
|
|
1591
|
+
*
|
|
1592
|
+
* {@link lexArguments} remains all-or-nothing because its consumer may render a
|
|
1593
|
+
* runnable REST request. Hints are different: an unreadable later expression
|
|
1594
|
+
* must not erase an earlier, independently bounded literal. This scan therefore
|
|
1595
|
+
* reuses the exact same token and literal readers, returns every safe anchor
|
|
1596
|
+
* before the first refusal, and stops there. The one V2 extension is an exact
|
|
1597
|
+
* array literal: braces, or parentheses with a depth-zero comma, are locatable
|
|
1598
|
+
* without evaluating their contents. Other expressions, including `.` concat,
|
|
1599
|
+
* still stop the scan. The strict REST reading remains unchanged.
|
|
1600
|
+
*
|
|
1601
|
+
* A located array is then DESCENDED into, so each member is anchored in its own
|
|
1602
|
+
* right ({@link pushArrayMembers}); a member that does not parse withdraws the
|
|
1603
|
+
* whole literal, since the device rejects the statement in that case.
|
|
1604
|
+
*/
|
|
1605
|
+
export function lexValueAnchors(
|
|
1606
|
+
text: string,
|
|
1607
|
+
from: number,
|
|
1608
|
+
options: { directiveVerb?: string; range?: MaskedRange } = {},
|
|
1609
|
+
): ValueAnchorReading {
|
|
1610
|
+
const anchors: ValueAnchor[] = [];
|
|
1611
|
+
const structural = maskOf(options.range) ?? maskComments(text);
|
|
1612
|
+
for (const read of walkArguments(text, from, {
|
|
1613
|
+
allowArrayValues: true,
|
|
1614
|
+
...rangeOption(options.range),
|
|
1615
|
+
...(options.directiveVerb === undefined
|
|
1616
|
+
? {}
|
|
1617
|
+
: { directiveVerb: options.directiveVerb }),
|
|
1618
|
+
})) {
|
|
1619
|
+
if (typeof read === "string")
|
|
1620
|
+
return { complete: false, anchors, why: read };
|
|
1621
|
+
if (read.kind !== "query" && read.valueSpan !== undefined) {
|
|
1622
|
+
if (read.sourceShape !== undefined) {
|
|
1623
|
+
const at = anchors.length;
|
|
1624
|
+
anchors.push({
|
|
1625
|
+
kind: read.kind,
|
|
1626
|
+
tokenSpan: read.span,
|
|
1627
|
+
...(read.name === undefined ? {} : { name: read.name }),
|
|
1628
|
+
valueSpan: read.valueSpan,
|
|
1629
|
+
value: text.slice(read.valueSpan.start, read.valueSpan.end),
|
|
1630
|
+
sourceShape: read.sourceShape,
|
|
1631
|
+
quoted: false,
|
|
1632
|
+
});
|
|
1633
|
+
const unparsed = pushArrayMembers(
|
|
1634
|
+
anchors,
|
|
1635
|
+
text,
|
|
1636
|
+
structural,
|
|
1637
|
+
at,
|
|
1638
|
+
read.valueSpan.start + 1,
|
|
1639
|
+
read.valueSpan.end - 1,
|
|
1640
|
+
text[read.valueSpan.start] === "{" ? ";" : ",",
|
|
1641
|
+
0,
|
|
1642
|
+
options.range,
|
|
1643
|
+
);
|
|
1644
|
+
if (unparsed !== null) {
|
|
1645
|
+
anchors.length = at;
|
|
1646
|
+
return { complete: false, anchors, why: unparsed };
|
|
1647
|
+
}
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
if (read.value === undefined) continue;
|
|
1651
|
+
anchors.push({
|
|
1652
|
+
kind: read.kind,
|
|
1653
|
+
tokenSpan: read.span,
|
|
1654
|
+
...(read.name === undefined ? {} : { name: read.name }),
|
|
1655
|
+
valueSpan: read.valueSpan,
|
|
1656
|
+
value: read.value,
|
|
1657
|
+
quoted: read.literalQuoted ?? false,
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
return { complete: true, anchors };
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Locate top-level `{...}` values that RouterOS rejects in command arguments.
|
|
1666
|
+
*
|
|
1667
|
+
* Parentheses and substitutions restore expression context, where a brace
|
|
1668
|
+
* literal is legal. Stored-script attributes are also excluded: their brace is
|
|
1669
|
+
* a script body, not an array. The caller applies this only to non-root
|
|
1670
|
+
* commands; root scripting directives use the `(verb, slot)` table instead.
|
|
1671
|
+
*/
|
|
1672
|
+
export function invalidCommandBraceOffsets(
|
|
1673
|
+
text: string,
|
|
1674
|
+
from: number,
|
|
1675
|
+
directiveVerb?: string,
|
|
1676
|
+
range?: MaskedRange,
|
|
1677
|
+
): number[] {
|
|
1678
|
+
const structural = maskOf(range) ?? maskComments(text);
|
|
1679
|
+
const offsets: number[] = [];
|
|
1680
|
+
const stack: string[] = [];
|
|
1681
|
+
let i = Math.max(0, from);
|
|
1682
|
+
let tokenStart: number | null = null;
|
|
1683
|
+
let positionals = 0;
|
|
1684
|
+
while (i < structural.length) {
|
|
1685
|
+
const c = structural[i] as string;
|
|
1686
|
+
if (
|
|
1687
|
+
stack.length === 0 &&
|
|
1688
|
+
(c === " " || c === "\t" || c === "\r" || c === "\n")
|
|
1689
|
+
) {
|
|
1690
|
+
if (tokenStart !== null) {
|
|
1691
|
+
const token = structural.slice(tokenStart, i);
|
|
1692
|
+
if (
|
|
1693
|
+
!token.startsWith("?") &&
|
|
1694
|
+
unquotedEquals(token, 0, token.length) === null
|
|
1695
|
+
)
|
|
1696
|
+
positionals++;
|
|
1697
|
+
tokenStart = null;
|
|
1698
|
+
}
|
|
1699
|
+
i++;
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
if (stack.length === 0 && tokenStart === null) tokenStart = i;
|
|
1703
|
+
if (c === '"') {
|
|
1704
|
+
i = scanQuotedString(structural, i).end;
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
if (c === "[" || c === "(") {
|
|
1708
|
+
stack.push(c);
|
|
1709
|
+
i++;
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
if (c === "]" || c === ")") {
|
|
1713
|
+
const want = c === "]" ? "[" : "(";
|
|
1714
|
+
if (stack.at(-1) === want) stack.pop();
|
|
1715
|
+
i++;
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
if (c !== "{" || stack.length > 0) {
|
|
1719
|
+
i++;
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const end = delimitedEnd(structural, i);
|
|
1724
|
+
const owner =
|
|
1725
|
+
tokenStart === null
|
|
1726
|
+
? undefined
|
|
1727
|
+
: braceValueOwner(structural, tokenStart, i);
|
|
1728
|
+
const slot = owner === null ? `#${positionals}` : owner;
|
|
1729
|
+
if (
|
|
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)) ||
|
|
1738
|
+
(directiveVerb !== undefined &&
|
|
1739
|
+
slot !== undefined &&
|
|
1740
|
+
braceSlotAcceptsBrace(directiveVerb, slot))
|
|
1741
|
+
) {
|
|
1742
|
+
i = end ?? structural.length;
|
|
1743
|
+
continue;
|
|
1744
|
+
}
|
|
1745
|
+
offsets.push(i);
|
|
1746
|
+
i = end ?? i + 1;
|
|
1747
|
+
}
|
|
1748
|
+
return offsets;
|
|
1749
|
+
}
|