@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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * The leading path-token run of a statement, for `explain`.
3
+ *
4
+ * A leaf module (it imports only the leaf data tables) so that `segment.ts` and
5
+ * `scope-brace.ts` can ask the question `verbsplit.ts` asks — "does this run
6
+ * already name a command verb?" (#496) — without the import cycle
7
+ * `verbsplit.ts` → `segment.ts` would make.
8
+ * It is the one word scanner both use; a second one written beside it would be
9
+ * free to disagree with the boundary rule. `verbsplit.ts` re-exports the public
10
+ * names, so the Q6 surface is unchanged.
11
+ */
12
+
13
+ import { commandVerbIndex } from "./catalog.ts";
14
+ import type { Span } from "./pathresolve.ts";
15
+ import { VERBS } from "./verbs.ts";
16
+
17
+ const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
18
+ /**
19
+ * A word that ends the leading run: it carries `=` (V2) or opens a group /
20
+ * string / variable (V3). `:` is deliberately NOT here — RouterOS uses it only
21
+ * to start a scripting directive, not as a mid-statement opener, and a
22
+ * `:`-prefixed token is already rejected by `BARE_WORD` below, so it ends the
23
+ * run regardless.
24
+ */
25
+ const RUN_TERMINATOR = /^[[({"$]/;
26
+
27
+ export function isAsciiWhitespace(char: string | undefined): boolean {
28
+ return char === " " || char === "\t" || char === "\r" || char === "\n";
29
+ }
30
+
31
+ /** One whitespace-separated word, and where it sits in the text it came from. */
32
+ export interface Word {
33
+ name: string;
34
+ /** offset of the word's first character, inclusive. */
35
+ start: number;
36
+ /** offset just past its last character, exclusive. */
37
+ end: number;
38
+ /** Source span of each character retained in `name`. */
39
+ chars: Span[];
40
+ }
41
+
42
+ /**
43
+ * The words of a statement, WITH their offsets (#202c).
44
+ *
45
+ * The one word scanner this module has. It replaced a
46
+ * `replace(/\\\r?\n/g,"").split(whitespace)` pair whose output could not be
47
+ * mapped back to the text it came from — and a second scanner written beside it
48
+ * for the offsets would be free to disagree with the boundary rule, which is the
49
+ * failure #202b hit three times in argument handling.
50
+ *
51
+ * H5 from the Q1 segmenter: a `\<newline>` is removed before RouterOS parses the
52
+ * continued line, so it JOINS the words either side of it rather than separating
53
+ * them (`na\<nl>me` is the single word `name`) and contributes no character to
54
+ * the name. That is why a word's `end - start` is not its `name.length`: the span
55
+ * covers the continuation bytes, the name does not. A caller taking an interior
56
+ * offset from a name is therefore wrong on continued statements, and
57
+ * {@link describeStatement} only ever publishes the run's END.
58
+ */
59
+ export function* asciiWordSpans(text: string): Generator<Word> {
60
+ // Yielded ONE WORD AT A TIME rather than returned as a list. Both readers
61
+ // stop at the first word the leading run cannot take, and a statement's text
62
+ // carries its whole nested `do={…}` subtree — so building a word (and a span
63
+ // per character of it) for the rest of that subtree was work no caller ever
64
+ // looked at, once per enclosing level (#322). A word is complete when the
65
+ // next one starts, which is why the yield happens on the following boundary
66
+ // rather than at the point the word is opened.
67
+ let current: Word | null = null;
68
+ let i = 0;
69
+ while (i < text.length) {
70
+ const c = text[i] as string;
71
+ if (
72
+ c === "\\" &&
73
+ (text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))
74
+ ) {
75
+ // Inside a word this joins; between words it is whitespace that opens
76
+ // none — matching the removal-then-split the rule replaced.
77
+ i += text[i + 1] === "\r" ? 3 : 2;
78
+ if (current !== null) current.end = i;
79
+ continue;
80
+ }
81
+ if (isAsciiWhitespace(c)) {
82
+ if (current !== null) yield current;
83
+ current = null;
84
+ i++;
85
+ continue;
86
+ }
87
+ if (current === null) {
88
+ current = {
89
+ name: c,
90
+ start: i,
91
+ end: i + 1,
92
+ chars: [{ start: i, end: i + 1 }],
93
+ };
94
+ } else {
95
+ current.name += c;
96
+ current.end = i + 1;
97
+ current.chars.push({ start: i, end: i + 1 });
98
+ }
99
+ i++;
100
+ }
101
+ if (current !== null) yield current;
102
+ }
103
+
104
+ /** Separator that precedes a run token (V1). */
105
+ export type Sep = "start" | "slash" | "space";
106
+
107
+ /** One token of the leading path run, with the punctuation that introduced it. */
108
+ export interface RunToken {
109
+ name: string;
110
+ sep: Sep;
111
+ }
112
+
113
+ /** A run token mapped back to the statement bytes that spell it. */
114
+ export interface LocatedRunToken extends RunToken {
115
+ /** Name bytes, split where a `\<newline>` continuation interrupts them. */
116
+ nameSpans: Span[];
117
+ /** One valid `/` immediately before this token, if present. */
118
+ slashBefore?: Span;
119
+ /** One trailing `/` after this token, if present. */
120
+ slashAfter?: Span;
121
+ }
122
+
123
+ function coalesceChars(chars: readonly Span[]): Span[] {
124
+ const out: Span[] = [];
125
+ for (const char of chars) {
126
+ const last = out.at(-1);
127
+ if (last !== undefined && last.end === char.start) last.end = char.end;
128
+ else out.push({ start: char.start, end: char.end });
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * The leading path-token run WITH separators preserved (V1). Same stopping
135
+ * rules as `pathresolve`'s run (V2/V3), so the two stay comparable: a slash-
136
+ * joined word expands to several `slash`-separated tokens; a space-separated
137
+ * word is one `space` token. Stops at the first `=`/group/string/var word, and
138
+ * at any word carrying a non-`BARE_WORD` segment (a variable segment truncates
139
+ * the run before the verb).
140
+ */
141
+ export function locatedRunTokens(text: string): LocatedRunToken[] {
142
+ const out: LocatedRunToken[] = [];
143
+ for (const word of asciiWordSpans(text)) {
144
+ if (word.name.includes("=") || RUN_TERMINATOR.test(word.name)) break;
145
+ const parts: {
146
+ name: string;
147
+ start: number;
148
+ end: number;
149
+ slashBefore?: Span;
150
+ }[] = [];
151
+ let start = 0;
152
+ for (let i = 0; i <= word.name.length; i++) {
153
+ if (i < word.name.length && word.name[i] !== "/") continue;
154
+ if (start < i) {
155
+ const slashRunStart = start;
156
+ let slashCount = 0;
157
+ while (
158
+ slashRunStart - slashCount - 1 >= 0 &&
159
+ word.name[slashRunStart - slashCount - 1] === "/"
160
+ )
161
+ slashCount++;
162
+ const slashIndex = slashCount === 1 ? slashRunStart - 1 : -1;
163
+ parts.push({
164
+ name: word.name.slice(start, i),
165
+ start,
166
+ end: i,
167
+ ...(slashIndex < 0 ? {} : { slashBefore: word.chars[slashIndex] }),
168
+ });
169
+ }
170
+ start = i + 1;
171
+ }
172
+ if (parts.length === 0) continue;
173
+ if (!parts.every((p) => BARE_WORD.test(p.name))) break;
174
+ for (let i = 0; i < parts.length; i++) {
175
+ const part = parts[i] as (typeof parts)[number];
176
+ const leadingSlash = word.name.startsWith("/") && i === 0;
177
+ const trailingSlash =
178
+ i === parts.length - 1 &&
179
+ word.name.endsWith("/") &&
180
+ !word.name.endsWith("//")
181
+ ? word.chars.at(-1)
182
+ : undefined;
183
+ out.push({
184
+ name: part.name,
185
+ sep:
186
+ out.length === 0
187
+ ? "start"
188
+ : i > 0 || leadingSlash
189
+ ? "slash"
190
+ : "space",
191
+ nameSpans: coalesceChars(word.chars.slice(part.start, part.end)),
192
+ ...(part.slashBefore === undefined
193
+ ? {}
194
+ : { slashBefore: part.slashBefore }),
195
+ ...(trailingSlash === undefined ? {} : { slashAfter: trailingSlash }),
196
+ });
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ export function runTokens(text: string): RunToken[] {
203
+ return locatedRunTokens(text).map(({ name, sep }) => ({ name, sep }));
204
+ }
205
+
206
+ /**
207
+ * #496 — the run before a `{` already names a command, so the brace is that
208
+ * command's operand and not a `/menu { … }` container. `/system/script add
209
+ * name beep source { :beep }` is one `add` whose `source` is the body, and
210
+ * `/ip/route/print {1;2}` is a positional brace the device rejects (CHR
211
+ * 7.22.1/7.24.5 `:parse`); neither is a command followed by a menu body.
212
+ * `segment.ts` (H7) and `scope-brace.ts` each spelled the container rule
213
+ * lexically, and both ask here. The two tables are the ones `verbsplit`
214
+ * decides a verb with — the frozen vocabulary and the catalog's command axis —
215
+ * and both are floors: a run naming neither keeps the container reading
216
+ * (`/system script { print }`).
217
+ */
218
+ export function runNamesCommand(prefix: string): boolean {
219
+ const names = runTokens(prefix).map((token) => token.name);
220
+ return (
221
+ names.some((name) => VERBS.has(name)) || commandVerbIndex(names) !== null
222
+ );
223
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { scanQuotedString } from "./quoted-string.ts";
11
+ import { runNamesCommand } from "./run-tokens.ts";
11
12
 
12
13
  /** Error-variable shape for `:onerror V { … }` — a bare/`$`-prefixed name. */
13
14
  const ERROR_VAR = /^\$?[A-Za-z][A-Za-z0-9._-]*$/;
@@ -58,8 +59,53 @@ export const DIRECTIVE_BODY: Record<string, string> = {
58
59
  onerror: "in",
59
60
  };
60
61
 
61
- /** Script-valued attribute naming convention already used by the corpus census. */
62
- const SCRIPT_BODY_ARG = /^(?:source|script|on-.*)$/;
62
+ /**
63
+ * Script-valued attribute naming convention, the corpus census's
64
+ * `SCRIPT_BEARING_KEY` shape (`scripts/explain-corpus-census.ts`).
65
+ *
66
+ * A shape, not a list. It decides a brace's ROLE (statement body or value) for
67
+ * every reader of {@link braceStartsStatements}: segmentation, the argument
68
+ * lexer, symbols and path resolution, as well as the `invalid-command-brace`
69
+ * exemption. A name inside the shape is read as a body; a name outside it as a
70
+ * value. The shape errs toward body because the other error is the worse one:
71
+ * reading a real `up-script={ :beep }` as an array hard-failed input the device
72
+ * accepts. The cost is the fail-open direction. An argument the command does
73
+ * not have, `zzz-script={ :beep }` or `on-zzz={ :beep }`, is a device
74
+ * `syntax error` on CHR 7.24.5 that offline does not report, because naming the
75
+ * command's arguments is schema. The `on-…` half of that predates #350.
76
+ * Which slots take a body is per-menu and per-version schema: on CHR 7.24.5
77
+ * `/tool/netwatch add … up-script={ :beep }` and `/ip/dhcp-server set 0
78
+ * lease-script={ :beep }` both parse, while 7.22.1 accepts the first and
79
+ * rejects the second; `comment={ :beep }` and `zzzscript={ :beep }` are
80
+ * `syntax error` on both (#350). The earlier `^(?:source|script|on-.*)$` missed
81
+ * every `…-script` argument and hard-failed the netwatch row the device accepts.
82
+ * The suffix keeps its hyphen, which is how RouterOS spells these names: a bare
83
+ * `…script` would take `:put myScript={#test}`, whose brace CHR 7.23.3 reads as
84
+ * an array (#245).
85
+ */
86
+ const SCRIPT_BODY_ARG = /^(?:source|script|.+-script|on-.+)$/;
87
+
88
+ /**
89
+ * An abbreviation the {@link SCRIPT_BODY_ARG} shape still pins down (#496).
90
+ * RouterOS takes any unique prefix of an argument name: on CHR 7.24.5 and
91
+ * 7.23.8, `sour { :beep }` and `s={ :beep }` lower to `source=`, `on`/`o=` to
92
+ * `on-event=`, and `down-scr`/`down-s=` to `down-script=`. These are the
93
+ * prefixes of `source` and `script`, of `on-`, and a name ending in `-` plus a
94
+ * prefix of `script`, so each can only abbreviate a script-valued name. Like
95
+ * the shape, it errs toward body: `sc { :beep }` is rejected on device and
96
+ * offline misses it.
97
+ *
98
+ * Abbreviating the FIRST segment (`down={ :beep }` for `down-script`, also
99
+ * accepted on device) stays outside. Every word is a prefix of `<word>-script`,
100
+ * so admitting it would admit `comment={ :beep }`. Telling them apart needs the
101
+ * command's argument list, which is schema.
102
+ */
103
+ const SCRIPT_BODY_ABBREVIATION =
104
+ /^(?:s(?:o(?:u(?:r(?:c)?)?)?)?|sc(?:r(?:i(?:p)?)?)?|on?|.+-(?:s(?:c(?:r(?:i(?:p)?)?)?)?)?)$/;
105
+
106
+ function isScriptBodyName(name: string): boolean {
107
+ return SCRIPT_BODY_ARG.test(name) || SCRIPT_BODY_ABBREVIATION.test(name);
108
+ }
63
109
 
64
110
  /** Grounded scripting directives whose positional arity makes a later `#` fatal. */
65
111
  const DIRECTIVE_POSITIONAL_ARITY: Readonly<Record<string, number>> = {
@@ -270,6 +316,19 @@ function trailingBareScopeName(
270
316
  text: string,
271
317
  start: number,
272
318
  end: number,
319
+ ): string | null {
320
+ const name = trailingBareArgumentName(text, start, end);
321
+ return name !== null && SCOPE_ARG_NAMES.has(name) ? name : null;
322
+ }
323
+
324
+ /**
325
+ * The bare word before a `{` when it can be an argument name: not the
326
+ * statement's leading word, and not a `:` directive. Lower-cased.
327
+ */
328
+ function trailingBareArgumentName(
329
+ text: string,
330
+ start: number,
331
+ end: number,
273
332
  ): string | null {
274
333
  let i = end - 1;
275
334
  while (i >= start && isAsciiWhitespace(text[i])) i--;
@@ -278,7 +337,6 @@ function trailingBareScopeName(
278
337
  const nameStart = i + 1;
279
338
  if (nameStart === nameEnd) return null;
280
339
  const name = text.slice(nameStart, nameEnd).toLowerCase();
281
- if (!SCOPE_ARG_NAMES.has(name)) return null;
282
340
  // A COLON makes it a directive, never an argument name, and the device is
283
341
  // emphatic: `:if (1=1) :do { :put a }` is `expected end of command` on both
284
342
  // 7.21.5 and 7.24.2, while the bare `:if (1=1) do { :put a }` parses. So the
@@ -415,13 +473,52 @@ export function braceStartsStatements(
415
473
  const boundary = index.boundaries[open] ?? -1;
416
474
  const start = Math.max(boundary + 1, floor);
417
475
  const named = trailingName(text, start, open);
418
- if (named !== null) return SCRIPT_BODY_ARG.test(named.toLowerCase());
476
+ if (named !== null) return isScriptBodyName(named.toLowerCase());
477
+ // `source { :beep }` — the same body with the `=` left out (#350). Every
478
+ // grounded spelling lowers to the `source=` IL on CHR 7.22.1 and 7.24.5:
479
+ // `/system/script add name=beep source { :beep }`, `/system/scheduler add
480
+ // name=x on-event { :beep }`, `/tool/netwatch add … down-script { :beep }`.
481
+ // The control is `comment { :beep }`, a `syntax error` on both. A
482
+ // declaration's operand is a variable name, not an argument, so
483
+ // `:local myscript {1;2}` keeps its brace an array.
484
+ const bare = trailingBareArgumentName(text, start, open);
485
+ if (bare !== null && isScriptBodyName(bare)) {
486
+ const head = index.firstContent[open] ?? -1;
487
+ const word =
488
+ head < start ? "" : (leadingWords(text, head, open, 1)[0] ?? "");
489
+ if (!/^:|^(?:local|global)$/i.test(word)) return true;
490
+ }
419
491
 
420
- // H7's bare `{...}` and `/menu {...}` containers are statement bodies too.
492
+ // H7's bare `{...}` and `/menu {...}` containers are statement bodies too,
493
+ // but a run that already names a command makes the brace its operand (#496).
494
+ // `start` follows the last `{`, so each brace reads its own run: linear.
421
495
  const first = index.firstContent[open] ?? -1;
422
496
  return (
423
497
  first < start ||
424
- (text[first] === "/" && (index.lastForbidden[open] ?? -1) < start)
498
+ (text[first] === "/" &&
499
+ (index.lastForbidden[open] ?? -1) < start &&
500
+ !runNamesCommand(text.slice(first, open)))
501
+ );
502
+ }
503
+
504
+ /**
505
+ * Whether the `#` at `at` opens a comment line inside an array literal (#503).
506
+ *
507
+ * `frame` is the innermost open delimiter. CHR 7.24.5 and 7.22.1 agree: inside
508
+ * a `{…}` that is not a statement body, a `#` at column 0 comments out the
509
+ * rest of its line, at any nesting depth and in argument values too
510
+ * (`:local z {a=1;⏎# c⏎b=2}` → `value=a=1;b=2`, `:foreach i in={1;⏎# c⏎2}`).
511
+ * An indented `#` (space or tab), a `#` after a value or `;` on the same line,
512
+ * and a `#` directly inside `( … )` are still syntax errors. A comment-only
513
+ * array is rejected, but so is `{}`: that is the empty-array rule, not this one.
514
+ */
515
+ export function startsArrayCommentLine(
516
+ text: string,
517
+ at: number,
518
+ frame: { char: string; statements: boolean } | undefined,
519
+ ): boolean {
520
+ return (
521
+ text[at - 1] === "\n" && frame?.char === "{" && frame.statements === false
425
522
  );
426
523
  }
427
524
 
@@ -84,6 +84,7 @@ import {
84
84
  MAX_STRING_FRAME_DEPTH,
85
85
  scanQuotedString as scanQuotedStringShared,
86
86
  } from "./quoted-string.ts";
87
+ import { runNamesCommand } from "./run-tokens.ts";
87
88
  import {
88
89
  braceStartsStatements,
89
90
  canRetainStatementIndex,
@@ -91,6 +92,7 @@ import {
91
92
  isIndexedStatementStart,
92
93
  matchingBraceEnd,
93
94
  scopeNameFromMasked,
95
+ startsArrayCommentLine,
94
96
  } from "./scope-brace.ts";
95
97
 
96
98
  /** One top-level statement located in analyzed-byte space. */
@@ -130,6 +132,15 @@ export interface SegmentResult {
130
132
  * a throw. Includes the two coordinate-pass classes (`bom`, `non-ascii`).
131
133
  */
132
134
  defects: Defect[];
135
+ /**
136
+ * The first `}` or `]` that closes nothing at the top level, where RouterOS
137
+ * stops reading (#504). The device accepts the script and silently discards
138
+ * that byte and everything after it: `:put 1};:put 2` lowers to
139
+ * `(evl /putmessage=1)` and `}⏎:put 1` to `/`, on CHR 7.24.5 and 7.23.8.
140
+ * A `)` there, or any stray close inside an open group, is a rejection
141
+ * instead and is only an `unbalanced-close` defect. Absent when there is none.
142
+ */
143
+ discardFrom?: number;
133
144
  }
134
145
 
135
146
  const isSpace = (c: string): boolean => c === " " || c === "\t" || c === "\r";
@@ -510,7 +521,12 @@ function maskUncached(
510
521
  // indented `#` is a comment when the statement is still empty (`do={\` +
511
522
  // newline + ` # c`) and content when it is not (`:put a\` + newline +
512
523
  // ` # x`, which the device classes `error`) — exactly what `atLead` says.
513
- if (c === "#" && (atLead || contComment)) {
524
+ if (
525
+ c === "#" &&
526
+ (atLead ||
527
+ contComment ||
528
+ startsArrayCommentLine(original, i, contexts.at(-1)))
529
+ ) {
514
530
  const commentStart = i;
515
531
  while (i < original.length && original[i] !== "\n") i++;
516
532
  if (pieces === undefined) pieces = [];
@@ -650,6 +666,7 @@ function segmentStatementsInternal(
650
666
  analysis === undefined ? [] : coordinateDefects(analysis),
651
667
  raw.defects,
652
668
  ),
669
+ ...(raw.discardFrom === undefined ? {} : { discardFrom: raw.discardFrom }),
653
670
  };
654
671
  }
655
672
 
@@ -699,6 +716,8 @@ interface Frame {
699
716
  /** Incremental equivalent of trimming and checking the H7 prefix. */
700
717
  prefixSawNonSpace: boolean;
701
718
  prefixValid: boolean;
719
+ /** The prefix's run has been checked for a command verb (#496). */
720
+ prefixVerbChecked: boolean;
702
721
  /** statements collected here; the flattened output for a committed container. */
703
722
  buffer: RawSegment[];
704
723
  /** true for a container frame; false for the synthetic top level. */
@@ -726,6 +745,7 @@ function scanAscii(
726
745
  segments: RawSegment[];
727
746
  comments: { start: number; end: number }[];
728
747
  defects: Defect[];
748
+ discardFrom?: number;
729
749
  } {
730
750
  // Local indices remain local in the result; only index-backed questions use
731
751
  // document coordinates. The floor prevents an enclosing head from becoming
@@ -736,6 +756,7 @@ function scanAscii(
736
756
  const comments: { start: number; end: number }[] = [];
737
757
  const defects: Defect[] = [];
738
758
  const overDepth: number[] = [];
759
+ let discardFrom: number | undefined;
739
760
  let hardHashSeen = false;
740
761
  let hardApostropheSeen = false;
741
762
  // H2 — every open bracket, for balance. Each frame carries WHERE it opened so
@@ -752,6 +773,7 @@ function scanAscii(
752
773
  prefixScanEnd: -1,
753
774
  prefixSawNonSpace: false,
754
775
  prefixValid: true,
776
+ prefixVerbChecked: false,
755
777
  buffer: [],
756
778
  container: false,
757
779
  openIndex: -1,
@@ -777,6 +799,7 @@ function scanAscii(
777
799
  f.prefixScanEnd = -1;
778
800
  f.prefixSawNonSpace = false;
779
801
  f.prefixValid = true;
802
+ f.prefixVerbChecked = false;
780
803
  };
781
804
 
782
805
  const makeSegment = (
@@ -814,7 +837,8 @@ function scanAscii(
814
837
  };
815
838
 
816
839
  // A container prefix is empty or a bare `/`-menu path with no `=[($"` — the
817
- // same test the old `containerOpen` applied to the group's prefix.
840
+ // same test the old `containerOpen` applied to the group's prefix — whose
841
+ // run names no command (#496).
818
842
  const validPrefix = (f: Frame, start: number, end: number): boolean => {
819
843
  if (f.prefixScanStart !== start || f.prefixScanEnd > end) {
820
844
  resetPrefixScan(f);
@@ -841,6 +865,12 @@ function scanAscii(
841
865
  }
842
866
  }
843
867
  f.prefixScanEnd = end;
868
+ // Checked once per statement: a later `{` sits behind this one, which
869
+ // already ended the run, so the answer cannot change.
870
+ if (f.prefixValid && !f.prefixVerbChecked) {
871
+ f.prefixVerbChecked = true;
872
+ if (runNamesCommand(ascii.slice(start, end))) f.prefixValid = false;
873
+ }
844
874
  return f.prefixValid;
845
875
  };
846
876
 
@@ -860,6 +890,7 @@ function scanAscii(
860
890
  prefixScanEnd: -1,
861
891
  prefixSawNonSpace: false,
862
892
  prefixValid: true,
893
+ prefixVerbChecked: false,
863
894
  buffer: [],
864
895
  container: true,
865
896
  openIndex: i,
@@ -920,7 +951,12 @@ function scanAscii(
920
951
  // mid-continuation stays content.
921
952
  const inCont = f.cont !== "none";
922
953
  const contComment = c === "#" && inCont && f.contLineStart;
923
- if (c === "#" && (f.atLead || contComment)) {
954
+ if (
955
+ c === "#" &&
956
+ (f.atLead ||
957
+ contComment ||
958
+ startsArrayCommentLine(ascii, i, delimStack.at(-1)))
959
+ ) {
924
960
  const nl = ascii.indexOf("\n", i);
925
961
  const end = nl === -1 ? ascii.length : nl;
926
962
  comments.push({ start: i, end });
@@ -1091,6 +1127,7 @@ function scanAscii(
1091
1127
  f.atLead = false;
1092
1128
  }
1093
1129
  } else {
1130
+ if (delimStack.length === 0 && c !== ")") discardFrom ??= i;
1094
1131
  defects.push(defectAt("unbalanced-close", i, c));
1095
1132
  ensureStmt(f, i);
1096
1133
  f.atLead = false;
@@ -1148,6 +1185,7 @@ function scanAscii(
1148
1185
  // event fail to de-duplicate across analyzers.
1149
1186
  ...overDepth.map((offset) => defectAt("over-depth", offset)),
1150
1187
  ],
1188
+ ...(discardFrom === undefined ? {} : { discardFrom }),
1151
1189
  };
1152
1190
  }
1153
1191
 
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The space-for-`=` argument spelling (#350).
3
+ *
4
+ * RouterOS lets a space stand in for the `=` of an ordinary command argument:
5
+ * `/ip/firewall/layer7-protocol add comment "something" regexp 1.1` lowers to
6
+ * `(evl /ip/firewall/layer7-protocol/addcomment=something;regexp=1.1)`, byte for
7
+ * byte the IL of the `=` spelling, and the spellings mix freely. That is not a
8
+ * schema-free rule. The device pairs `comment` with `"something"` because it
9
+ * knows `/ip/firewall/layer7-protocol/add` has a `comment` argument:
10
+ * `/system/script add zzznotanarg beep` is `bad parameter zzznotanarg` on
11
+ * 7.24.5 and `expected end of command` on 7.22.1.
12
+ *
13
+ * ## What the device actually does (CHR 7.22.1 and 7.24.5, `:parse` + `highlight`)
14
+ *
15
+ * Identical on both builds:
16
+ *
17
+ * | input | IL | `highlight` on the gap |
18
+ * | ----- | -- | ---------------------- |
19
+ * | `/interface set ether1 comment "x"` | `setcomment=x;numbers=ether1` | `syntax-obsolete` |
20
+ * | `/interface set ether1 name x` | `setname=x;numbers=ether1` | `syntax-obsolete` |
21
+ * | `/interface set ether1 disabled yes` | `setdisabled=true;numbers=ether1` | `syntax-obsolete` |
22
+ * | `/user group set full policy "read,write"` | `setnumbers=full;policy=read,write` | `syntax-obsolete` |
23
+ * | `/interface print detail without-paging` | `printdetail=true;without-paging=true` | none |
24
+ *
25
+ * Two facts come out of that table, and they are why this module has two
26
+ * outputs rather than one.
27
+ *
28
+ * - **Two adjacent bare operands have no schema-free reading.** `name x` is an
29
+ * argument and its value; `detail without-paging` is two value-less flags;
30
+ * `ether1 name` is an unnamed `numbers` operand and then an argument name. The
31
+ * bytes are the same shape in all three. So the strict reading ABSTAINS on any
32
+ * such pair: it never publishes `args: {}` plus a positional list, which
33
+ * claims the list was read and holds no arguments.
34
+ * - **The obsolete marker follows the value, not the name.** The device marks
35
+ * the gap `syntax-obsolete` exactly when it paired the words, and a flag pair
36
+ * gets no marker. Offline can see one half of that: a second operand that is
37
+ * not itself name-shaped (`"x"`, `1.1`, `10s`, `$v`) cannot be a flag, so the
38
+ * pair is very probably the spaced spelling and gets a warning. A bare-word
39
+ * second operand (`name x`, `disabled yes`) is also obsolete on device, but
40
+ * offline cannot tell it from `detail without-paging`, so it abstains with no
41
+ * warning rather than flagging the most common `print` spelling there is. The
42
+ * warning says "possible"; a live probe (#491) is what makes it definite.
43
+ *
44
+ * ## What this deliberately does not decide
45
+ *
46
+ * Root statements are out of scope. A scripting directive's operands are
47
+ * positional by grammar, not spelling: `:pick abc 0 1` lowers to
48
+ * `pickbegin=0;counter=abc;end=1`, and `:local t 200ms` to `name=t;value=200ms`.
49
+ * Pairing those would fabricate argument names the device never uses. `/ping
50
+ * 1.1.1.1 count 1` is spaced (`count` is `syntax-obsolete`) and resolves to the
51
+ * root, so it stays read here; telling a root command's positional grammar from a
52
+ * spaced argument is per-command schema and belongs to the live probe.
53
+ *
54
+ * `where` ends the scan. What follows it is a query expression, and the word
55
+ * before it is never paired with it: `print count-only where …` lowers to
56
+ * `count-only=true;where=…`.
57
+ *
58
+ * Like the #311 separator rule, this reads only what the argument lexer
59
+ * already decided (`lexArgumentTokens`, the skip-tolerant reading), so a
60
+ * `[…]` substitution or a quoted run is one token by construction.
61
+ */
62
+
63
+ import { ARGUMENT_NAME, type Argument } from "./args.ts";
64
+
65
+ /** Two adjacent operands that may be the spaced spelling of `name=value`. */
66
+ export interface SpacedPair {
67
+ /** First byte of the would-be name through the end of the would-be value. */
68
+ span: { start: number; end: number };
69
+ /** The would-be argument name, verbatim. */
70
+ name: string;
71
+ /** The would-be value, verbatim (quotes kept). */
72
+ value: string;
73
+ /**
74
+ * The second operand cannot be a flag, so the device very probably reads the
75
+ * pair as `name=value` and marks the gap `syntax-obsolete`.
76
+ */
77
+ valueShaped: boolean;
78
+ }
79
+
80
+ /**
81
+ * An argument-name-shaped bare word. This is the lexer's own grammar, so a word
82
+ * the lexer would read as `name=` in the `=` spelling is a name here too.
83
+ */
84
+ const NAME_SHAPED = ARGUMENT_NAME;
85
+
86
+ function isWhere(token: Argument): boolean {
87
+ return token.kind === "positional" && token.text.toLowerCase() === "where";
88
+ }
89
+
90
+ /**
91
+ * Every adjacent `name operand` pair in one statement's argument tokens.
92
+ *
93
+ * Pairs overlap on purpose. `set ether1 comment "x"` yields `ether1 comment`
94
+ * and `comment "x"`: the first only abstains, the second also warns, and
95
+ * consuming the first would hide the second.
96
+ *
97
+ * Coordinate-space agnostic, like `findMissingSeparators`: spans come back in
98
+ * whatever space `tokens` were lexed in.
99
+ */
100
+ export function findSpacedPairs(tokens: readonly Argument[]): SpacedPair[] {
101
+ const out: SpacedPair[] = [];
102
+ for (let i = 0; i + 1 < tokens.length; i++) {
103
+ const name = tokens[i] as Argument;
104
+ const value = tokens[i + 1] as Argument;
105
+ if (isWhere(name)) break;
106
+ if (name.kind !== "positional" || value.kind !== "positional") continue;
107
+ if (!NAME_SHAPED.test(name.text) || isWhere(value)) continue;
108
+ out.push({
109
+ span: { start: name.span.start, end: value.span.end },
110
+ name: name.text,
111
+ value: value.text,
112
+ valueShaped: !NAME_SHAPED.test(value.text),
113
+ });
114
+ }
115
+ return out;
116
+ }
@@ -308,7 +308,11 @@
308
308
  import { analyzeCoordinates } from "./coordinates.ts";
309
309
  import { type Defect, defectAt } from "./defects.ts";
310
310
  import { isMenuPath } from "./menus.ts";
311
- import { braceStartsStatements, hashStartsHardError } from "./scope-brace.ts";
311
+ import {
312
+ braceStartsStatements,
313
+ hashStartsHardError,
314
+ startsArrayCommentLine,
315
+ } from "./scope-brace.ts";
312
316
  import type { Continuation } from "./segment.ts";
313
317
 
314
318
  /** The five variable classes the console assigns. */
@@ -904,6 +908,18 @@ export function resolveSymbols(original: string): SymbolAnalysis {
904
908
  // nested string flipping the quote phase, so every later `#` line read as
905
909
  // string content) is now fixed in the shared scanner too, via the same
906
910
  // frame model (#199); the two must stay in step.
911
+ // #503 — a column-0 `#` inside an array literal is a comment line too, but
912
+ // it ends nothing: the array's statement carries on past it. A continued
913
+ // line's comment keeps the H5 branch below, as in `maskUncached`.
914
+ if (
915
+ c === "#" &&
916
+ !contComment &&
917
+ startsArrayCommentLine(text, i, frames.at(-1))
918
+ ) {
919
+ while (i < text.length && text[i] !== "\n") i++;
920
+ i--; // let the loop advance onto the newline (or end)
921
+ continue;
922
+ }
907
923
  if (c === "#" && (atLead || contComment)) {
908
924
  while (i < text.length && text[i] !== "\n") i++;
909
925
  if (contComment) {