@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.23

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.
@@ -111,6 +111,17 @@ export interface ExplainTransportInput {
111
111
  * centrs surface should be offered for them and none is rendered.
112
112
  */
113
113
  rejected?: string;
114
+ /**
115
+ * Why offline cannot pair the operands into a request, when two adjacent
116
+ * words may be an argument and its value with the `=` left out (#350).
117
+ *
118
+ * Also distinct from an unread argument list: the bytes are no evaluation,
119
+ * and nothing is wrong with them, but which word is an argument name is
120
+ * per-command schema. The reading before #350 classified these `unknown`
121
+ * through its positionals, and this keeps that fail-closed answer rather
122
+ * than letting the withdrawal turn it into an `execute` line.
123
+ */
124
+ unpaired?: string;
114
125
  }
115
126
 
116
127
  export interface ExplainTransportOptions {
@@ -440,6 +451,8 @@ export function classifyExplainTransport(
440
451
  // syntax error, which is the false confident reading in a second place.
441
452
  if (input.rejected !== undefined)
442
453
  return unknown(input.rejected, options.evidenceId);
454
+ if (input.unpaired !== undefined)
455
+ return unknown(input.unpaired, options.evidenceId);
443
456
 
444
457
  // Root scripting directives have no RouterOS REST path. They always ride the
445
458
  // raw execute surface, regardless of whether their operands are literal.
@@ -88,6 +88,12 @@ import {
88
88
  type Span,
89
89
  type StatementAnalysis,
90
90
  } from "./pathresolve.ts";
91
+ import {
92
+ asciiWordSpans,
93
+ isAsciiWhitespace,
94
+ type RunToken,
95
+ runTokens,
96
+ } from "./run-tokens.ts";
91
97
  import {
92
98
  documentRange,
93
99
  type MaskedRange,
@@ -96,20 +102,6 @@ import {
96
102
  } from "./segment.ts";
97
103
  import { SUBMENU_DIRECTIVES, VERBS } from "./verbs.ts";
98
104
 
99
- const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
100
- /**
101
- * A word that ends the leading run: it carries `=` (V2) or opens a group /
102
- * string / variable (V3). `:` is deliberately NOT here — RouterOS uses it only
103
- * to start a scripting directive, not as a mid-statement opener, and a
104
- * `:`-prefixed token is already rejected by `BARE_WORD` below, so it ends the
105
- * run regardless.
106
- */
107
- const RUN_TERMINATOR = /^[[({"$]/;
108
-
109
- function isAsciiWhitespace(char: string | undefined): boolean {
110
- return char === " " || char === "\t" || char === "\r" || char === "\n";
111
- }
112
-
113
105
  function trimAscii(text: string): string {
114
106
  let start = 0;
115
107
  let end = text.length;
@@ -125,78 +117,14 @@ function trimAsciiStart(text: string): string {
125
117
  return text.slice(start);
126
118
  }
127
119
 
128
- /** One whitespace-separated word, and where it sits in the text it came from. */
129
- interface Word {
130
- name: string;
131
- /** offset of the word's first character, inclusive. */
132
- start: number;
133
- /** offset just past its last character, exclusive. */
134
- end: number;
135
- /** Source span of each character retained in `name`. */
136
- chars: Span[];
137
- }
138
-
139
- /**
140
- * The words of a statement, WITH their offsets (#202c).
141
- *
142
- * The one word scanner this module has. It replaced a
143
- * `replace(/\\\r?\n/g,"").split(whitespace)` pair whose output could not be
144
- * mapped back to the text it came from — and a second scanner written beside it
145
- * for the offsets would be free to disagree with the boundary rule, which is the
146
- * failure #202b hit three times in argument handling.
147
- *
148
- * H5 from the Q1 segmenter: a `\<newline>` is removed before RouterOS parses the
149
- * continued line, so it JOINS the words either side of it rather than separating
150
- * them (`na\<nl>me` is the single word `name`) and contributes no character to
151
- * the name. That is why a word's `end - start` is not its `name.length`: the span
152
- * covers the continuation bytes, the name does not. A caller taking an interior
153
- * offset from a name is therefore wrong on continued statements, and
154
- * {@link describeStatement} only ever publishes the run's END.
155
- */
156
- function* asciiWordSpans(text: string): Generator<Word> {
157
- // Yielded ONE WORD AT A TIME rather than returned as a list. Both readers
158
- // stop at the first word the leading run cannot take, and a statement's text
159
- // carries its whole nested `do={…}` subtree — so building a word (and a span
160
- // per character of it) for the rest of that subtree was work no caller ever
161
- // looked at, once per enclosing level (#322). A word is complete when the
162
- // next one starts, which is why the yield happens on the following boundary
163
- // rather than at the point the word is opened.
164
- let current: Word | null = null;
165
- let i = 0;
166
- while (i < text.length) {
167
- const c = text[i] as string;
168
- if (
169
- c === "\\" &&
170
- (text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))
171
- ) {
172
- // Inside a word this joins; between words it is whitespace that opens
173
- // none — matching the removal-then-split the rule replaced.
174
- i += text[i + 1] === "\r" ? 3 : 2;
175
- if (current !== null) current.end = i;
176
- continue;
177
- }
178
- if (isAsciiWhitespace(c)) {
179
- if (current !== null) yield current;
180
- current = null;
181
- i++;
182
- continue;
183
- }
184
- if (current === null) {
185
- current = {
186
- name: c,
187
- start: i,
188
- end: i + 1,
189
- chars: [{ start: i, end: i + 1 }],
190
- };
191
- } else {
192
- current.name += c;
193
- current.end = i + 1;
194
- current.chars.push({ start: i, end: i + 1 });
195
- }
196
- i++;
197
- }
198
- if (current !== null) yield current;
199
- }
120
+ /** The run scanner lives in the leaf `run-tokens.ts` so `segment.ts` can share it (#496). */
121
+ export {
122
+ type LocatedRunToken,
123
+ locatedRunTokens,
124
+ type RunToken,
125
+ runTokens,
126
+ type Sep,
127
+ } from "./run-tokens.ts";
200
128
 
201
129
  /**
202
130
  * The frozen vocabulary lives in the leaf `verbs.ts` because `pathresolve.ts`
@@ -205,108 +133,6 @@ function* asciiWordSpans(text: string): Generator<Word> {
205
133
  */
206
134
  export { SUBMENU_DIRECTIVES, VERBS } from "./verbs.ts";
207
135
 
208
- /** Separator that precedes a run token (V1). */
209
- export type Sep = "start" | "slash" | "space";
210
-
211
- /** One token of the leading path run, with the punctuation that introduced it. */
212
- export interface RunToken {
213
- name: string;
214
- sep: Sep;
215
- }
216
-
217
- /** A run token mapped back to the statement bytes that spell it. */
218
- export interface LocatedRunToken extends RunToken {
219
- /** Name bytes, split where a `\<newline>` continuation interrupts them. */
220
- nameSpans: Span[];
221
- /** One valid `/` immediately before this token, if present. */
222
- slashBefore?: Span;
223
- /** One trailing `/` after this token, if present. */
224
- slashAfter?: Span;
225
- }
226
-
227
- function coalesceChars(chars: readonly Span[]): Span[] {
228
- const out: Span[] = [];
229
- for (const char of chars) {
230
- const last = out.at(-1);
231
- if (last !== undefined && last.end === char.start) last.end = char.end;
232
- else out.push({ start: char.start, end: char.end });
233
- }
234
- return out;
235
- }
236
-
237
- /**
238
- * The leading path-token run WITH separators preserved (V1). Same stopping
239
- * rules as `pathresolve`'s run (V2/V3), so the two stay comparable: a slash-
240
- * joined word expands to several `slash`-separated tokens; a space-separated
241
- * word is one `space` token. Stops at the first `=`/group/string/var word, and
242
- * at any word carrying a non-`BARE_WORD` segment (a variable segment truncates
243
- * the run before the verb).
244
- */
245
- export function locatedRunTokens(text: string): LocatedRunToken[] {
246
- const out: LocatedRunToken[] = [];
247
- for (const word of asciiWordSpans(text)) {
248
- if (word.name.includes("=") || RUN_TERMINATOR.test(word.name)) break;
249
- const parts: {
250
- name: string;
251
- start: number;
252
- end: number;
253
- slashBefore?: Span;
254
- }[] = [];
255
- let start = 0;
256
- for (let i = 0; i <= word.name.length; i++) {
257
- if (i < word.name.length && word.name[i] !== "/") continue;
258
- if (start < i) {
259
- const slashRunStart = start;
260
- let slashCount = 0;
261
- while (
262
- slashRunStart - slashCount - 1 >= 0 &&
263
- word.name[slashRunStart - slashCount - 1] === "/"
264
- )
265
- slashCount++;
266
- const slashIndex = slashCount === 1 ? slashRunStart - 1 : -1;
267
- parts.push({
268
- name: word.name.slice(start, i),
269
- start,
270
- end: i,
271
- ...(slashIndex < 0 ? {} : { slashBefore: word.chars[slashIndex] }),
272
- });
273
- }
274
- start = i + 1;
275
- }
276
- if (parts.length === 0) continue;
277
- if (!parts.every((p) => BARE_WORD.test(p.name))) break;
278
- for (let i = 0; i < parts.length; i++) {
279
- const part = parts[i] as (typeof parts)[number];
280
- const leadingSlash = word.name.startsWith("/") && i === 0;
281
- const trailingSlash =
282
- i === parts.length - 1 &&
283
- word.name.endsWith("/") &&
284
- !word.name.endsWith("//")
285
- ? word.chars.at(-1)
286
- : undefined;
287
- out.push({
288
- name: part.name,
289
- sep:
290
- out.length === 0
291
- ? "start"
292
- : i > 0 || leadingSlash
293
- ? "slash"
294
- : "space",
295
- nameSpans: coalesceChars(word.chars.slice(part.start, part.end)),
296
- ...(part.slashBefore === undefined
297
- ? {}
298
- : { slashBefore: part.slashBefore }),
299
- ...(trailingSlash === undefined ? {} : { slashAfter: trailingSlash }),
300
- });
301
- }
302
- }
303
- return out;
304
- }
305
-
306
- export function runTokens(text: string): RunToken[] {
307
- return locatedRunTokens(text).map(({ name, sep }) => ({ name, sep }));
308
- }
309
-
310
136
  /** True when the statement is a scripting directive (`:x`, or a bare `x do={…}`). */
311
137
  export function isDirective(text: string, range?: MaskedRange): boolean {
312
138
  const t = trimAscii(text);
@@ -694,7 +694,14 @@ function collect(
694
694
  });
695
695
  continue;
696
696
  }
697
- if (statement.isNav && !isCommandShaped(described, catalogAt)) {
697
+ // An ABSOLUTE `maybeNav` statement (#211) was `isNav` before R14, so it
698
+ // keeps this branch. `isConfirmedNav` cannot drop it, because its path is
699
+ // in neither table. A relative one never took this branch (#235), and
700
+ // still does not.
701
+ if (
702
+ (statement.isNav || (statement.maybeNav && t.startsWith("/"))) &&
703
+ !isCommandShaped(described, catalogAt)
704
+ ) {
698
705
  if (
699
706
  isConfirmedNav(described) &&
700
707
  !isDanglingBarePath(described, lastNonEmpty, index)