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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +38 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +43 -5
  9. package/commands/explain/README.md +873 -182
  10. package/commands/explain/examples.md +447 -38
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +18 -9
  16. package/docs/MATRIX.md +51 -26
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +5 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +181 -15
  35. package/src/explain/blocks.ts +3 -4
  36. package/src/explain/brace-slots.ts +5 -0
  37. package/src/explain/catalog.ts +13 -6
  38. package/src/explain/defects.ts +1 -0
  39. package/src/explain/group-faults.ts +1060 -0
  40. package/src/explain/highlight-projection.ts +10 -8
  41. package/src/explain/operators.ts +22 -20
  42. package/src/explain/pathresolve.ts +102 -20
  43. package/src/explain/quoted-string.ts +41 -6
  44. package/src/explain/run-tokens.ts +223 -0
  45. package/src/explain/scope-brace.ts +319 -7
  46. package/src/explain/segment.ts +177 -4
  47. package/src/explain/spaced-args.ts +116 -0
  48. package/src/explain/symbols.ts +163 -7
  49. package/src/explain/transport.ts +13 -0
  50. package/src/explain/values.ts +55 -4
  51. package/src/explain/verbsplit.ts +14 -188
  52. package/src/explain/write.ts +8 -1
  53. package/src/explain-live-facets.ts +739 -0
  54. package/src/explain-live-types.ts +490 -0
  55. package/src/explain-live.ts +593 -0
  56. package/src/explain.ts +772 -60
  57. package/src/index.ts +6 -0
  58. package/src/offline-gate.ts +30 -9
  59. package/src/protocols/adapter.ts +88 -10
  60. package/src/protocols/btest-session.ts +30 -10
  61. package/src/protocols/mac-telnet-console.ts +16 -0
  62. package/src/protocols/mac-telnet.ts +38 -10
  63. package/src/protocols/native-api.ts +13 -12
  64. package/src/retrieve-follow.ts +2 -1
  65. package/src/retrieve-sample.ts +2 -1
  66. package/src/retrieve-wait.ts +10 -6
  67. package/src/retrieve.ts +77 -27
  68. package/src/transfer.ts +38 -13
@@ -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. */
@@ -415,7 +419,10 @@ interface BracketFrame {
415
419
  pendingSetTarget: boolean;
416
420
  pendingErrVar: boolean;
417
421
  pendingDecl: SymbolClass | null;
422
+ pendingDeclNameValue: boolean;
418
423
  pendingLoopVars: boolean;
424
+ loopCounterOpen: boolean;
425
+ pendingLoopCounterValue: boolean;
419
426
  declaredHere: boolean;
420
427
  headSigil: ":" | "/" | "";
421
428
  cont: Continuation;
@@ -507,6 +514,20 @@ export function resolveSymbols(original: string): SymbolAnalysis {
507
514
  char: "{" | "[" | "(" | '"';
508
515
  at: number;
509
516
  statements: boolean;
517
+ /**
518
+ * #533 reorder — a `{…}` resets the statement it interrupts, but a
519
+ * `:for`/`:foreach` head stays open for `counter=` past it (notably a
520
+ * `:foreach … in={…}` array), so the flag rides the frame and the
521
+ * matching `}` restores it. Only `}` restores: `[` has the
522
+ * `BracketFrame`, quotes and `(` never reset.
523
+ */
524
+ loopCounterOpen: boolean;
525
+ /**
526
+ * #533 reorder — likewise a `:local`/`:global` head stays open for a
527
+ * later `name=` past an array VALUE (`:local value={1;2} name=foo`).
528
+ * Saved only on a `{` that is an argument value; `}` restores it.
529
+ */
530
+ pendingDecl?: SymbolClass | null;
510
531
  }[] = [];
511
532
  /** bracket/brace nesting, strings excluded (the S8 filter region uses it). */
512
533
  let depth = 0;
@@ -521,7 +542,23 @@ export function resolveSymbols(original: string): SymbolAnalysis {
521
542
  /** depth at which a filter word opened its region, or null. */
522
543
  let filterDepth: number | null = null;
523
544
  let pendingDecl: SymbolClass | null = null;
545
+ /**
546
+ * #533 — the pending `:local`/`:global` declaration waits for the VALUE of
547
+ * a `name=` argument (`:local name=foo` declares `foo`, not `name`). Set
548
+ * when a `name=` arg name is read, cleared after its value declares or is
549
+ * skipped. Values of other args (`value=`) never declare.
550
+ */
551
+ let pendingDeclNameValue = false;
524
552
  let pendingLoopVars = false;
553
+ /**
554
+ * #533 reorder — a `:for`/`:foreach` head is still open for a named
555
+ * `counter=`: unlike `pendingLoopVars` (positional vars, ended by
556
+ * `in=`/`from=`/any `arg=`), this survives the whole head — CHR accepts
557
+ * `:for from=1 counter=i to=2` — and only `do` (the body) closes it.
558
+ */
559
+ let loopCounterOpen = false;
560
+ /** The next value word is a `counter=` loop var. */
561
+ let pendingLoopCounterValue = false;
525
562
  /** inside a space-separated menu path run, where bare words are `dir` (S16). */
526
563
  let inMenuPath = false;
527
564
  /**
@@ -589,7 +626,10 @@ export function resolveSymbols(original: string): SymbolAnalysis {
589
626
  pendingSetTarget = false;
590
627
  pendingErrVar = false;
591
628
  pendingDecl = null;
629
+ pendingDeclNameValue = false;
592
630
  pendingLoopVars = false;
631
+ loopCounterOpen = false;
632
+ pendingLoopCounterValue = false;
593
633
  declaredHere = false;
594
634
  headSigil = "";
595
635
  cont = "none";
@@ -662,7 +702,10 @@ export function resolveSymbols(original: string): SymbolAnalysis {
662
702
  pendingSetTarget,
663
703
  pendingErrVar,
664
704
  pendingDecl,
705
+ pendingDeclNameValue,
665
706
  pendingLoopVars,
707
+ loopCounterOpen,
708
+ pendingLoopCounterValue,
666
709
  declaredHere,
667
710
  headSigil,
668
711
  cont,
@@ -715,7 +758,10 @@ export function resolveSymbols(original: string): SymbolAnalysis {
715
758
  pendingSetTarget = frame.pendingSetTarget;
716
759
  pendingErrVar = frame.pendingErrVar;
717
760
  pendingDecl = frame.pendingDecl;
761
+ pendingDeclNameValue = frame.pendingDeclNameValue;
718
762
  pendingLoopVars = frame.pendingLoopVars;
763
+ loopCounterOpen = frame.loopCounterOpen;
764
+ pendingLoopCounterValue = frame.pendingLoopCounterValue;
719
765
  declaredHere = frame.declaredHere;
720
766
  headSigil = frame.headSigil;
721
767
  cont = frame.cont;
@@ -803,7 +849,12 @@ export function resolveSymbols(original: string): SymbolAnalysis {
803
849
  // string. A scan that swallowed this as string bytes would flip
804
850
  // its quote phase on the first nested string and lose every
805
851
  // binding after it.
806
- frames.push({ char: next, at: i + 1, statements: next === "[" });
852
+ frames.push({
853
+ char: next,
854
+ at: i + 1,
855
+ statements: next === "[",
856
+ loopCounterOpen,
857
+ });
807
858
  depth++;
808
859
  if (next === "[") {
809
860
  // F6 — and it is a statement context like any other `[`. A `$[`
@@ -904,6 +955,18 @@ export function resolveSymbols(original: string): SymbolAnalysis {
904
955
  // nested string flipping the quote phase, so every later `#` line read as
905
956
  // string content) is now fixed in the shared scanner too, via the same
906
957
  // frame model (#199); the two must stay in step.
958
+ // #503 — a column-0 `#` inside an array literal is a comment line too, but
959
+ // it ends nothing: the array's statement carries on past it. A continued
960
+ // line's comment keeps the H5 branch below, as in `maskUncached`.
961
+ if (
962
+ c === "#" &&
963
+ !contComment &&
964
+ startsArrayCommentLine(text, i, frames.at(-1))
965
+ ) {
966
+ while (i < text.length && text[i] !== "\n") i++;
967
+ i--; // let the loop advance onto the newline (or end)
968
+ continue;
969
+ }
907
970
  if (c === "#" && (atLead || contComment)) {
908
971
  while (i < text.length && text[i] !== "\n") i++;
909
972
  if (contComment) {
@@ -974,7 +1037,28 @@ export function resolveSymbols(original: string): SymbolAnalysis {
974
1037
  // `set-dns`, and the console carries the class across the quotes, so the
975
1038
  // span includes them. Without this the string is skipped and the next
976
1039
  // bare word (`do`) is declared instead.
977
- if (pendingDecl !== null || pendingLoopVars || pendingErrVar) {
1040
+ if (
1041
+ pendingDecl !== null ||
1042
+ pendingLoopVars ||
1043
+ pendingErrVar ||
1044
+ pendingLoopCounterValue
1045
+ ) {
1046
+ // #533 — a quoted VALUE of a named arg declares only for `name=`.
1047
+ // `:local name="foo"` declares `foo`; `:local value="foo"` declares
1048
+ // nothing and stays a string (so `$` inside still classifies, S9).
1049
+ // Positional quotes (`:local "foo"`) declare.
1050
+ if (pendingDecl !== null) {
1051
+ const prev = prevNonSpace(text, i);
1052
+ if ((prev === "=" || prev === ",") && !pendingDeclNameValue) {
1053
+ frames.push({
1054
+ char: '"',
1055
+ at: i,
1056
+ statements: false,
1057
+ loopCounterOpen,
1058
+ });
1059
+ continue;
1060
+ }
1061
+ }
978
1062
  const close = text.indexOf('"', i + 1);
979
1063
  if (close > i) {
980
1064
  const name = text.slice(i + 1, close);
@@ -998,6 +1082,7 @@ export function resolveSymbols(original: string): SymbolAnalysis {
998
1082
  const binding = bind(name, cls, i + 1);
999
1083
  if (binding !== null) bindingIds.push(binding.id);
1000
1084
  pendingDecl = null;
1085
+ pendingDeclNameValue = false;
1001
1086
  declaredHere = true;
1002
1087
  } else {
1003
1088
  // F7 — statement scope, and for `:onerror` the enclosing claim
@@ -1023,7 +1108,12 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1023
1108
  continue;
1024
1109
  }
1025
1110
  }
1026
- frames.push({ char: '"', at: i, statements: false });
1111
+ frames.push({
1112
+ char: '"',
1113
+ at: i,
1114
+ statements: false,
1115
+ loopCounterOpen,
1116
+ });
1027
1117
  continue;
1028
1118
  }
1029
1119
 
@@ -1036,6 +1126,9 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1036
1126
  (c === "{" &&
1037
1127
  (frames.at(-1)?.statements ?? true) &&
1038
1128
  braceStartsStatements(text, i)),
1129
+ loopCounterOpen,
1130
+ pendingDecl:
1131
+ c === "{" && prevNonSpace(text, i) === "=" ? pendingDecl : null,
1039
1132
  });
1040
1133
  depth++;
1041
1134
  if (c === "[") openBracket(leadBefore, i);
@@ -1071,7 +1164,7 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1071
1164
  defects.push(defectAt("unbalanced-close", i, c));
1072
1165
  continue;
1073
1166
  }
1074
- frames.pop();
1167
+ const popped = frames.pop();
1075
1168
  if (filterDepth !== null && depth <= filterDepth) filterDepth = null;
1076
1169
  // F6 — restore the statement the `[` interrupted, and drop the bracket's
1077
1170
  // scope. Ordered after the `filterDepth` clear on purpose: the saved
@@ -1082,6 +1175,11 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1082
1175
  if (suppressedScopes > 0) suppressedScopes--;
1083
1176
  else if (scopes.length > 1) scopes.pop();
1084
1177
  resetStatement();
1178
+ // #533 reorder — the head outlives its own array braces.
1179
+ if (popped !== undefined) {
1180
+ loopCounterOpen = popped.loopCounterOpen;
1181
+ pendingDecl = popped.pendingDecl ?? null;
1182
+ }
1085
1183
  }
1086
1184
  if (depth > 0) depth--;
1087
1185
  continue;
@@ -1204,8 +1302,10 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1204
1302
  pathOpen = true;
1205
1303
  }
1206
1304
  if (DECL[lower] !== undefined) pendingDecl = DECL[lower] as SymbolClass;
1207
- else if (LOOP_HEADS.has(lower)) pendingLoopVars = true;
1208
- else if (lower === "set") pendingSetTarget = true;
1305
+ else if (LOOP_HEADS.has(lower)) {
1306
+ pendingLoopVars = true;
1307
+ loopCounterOpen = true;
1308
+ } else if (lower === "set") pendingSetTarget = true;
1209
1309
  else if (ERRVAR_HEADS.has(lower)) pendingErrVar = true;
1210
1310
  // S8 — `find`/`where` open a filter region from HEAD position too.
1211
1311
  // `[find comment=$tag]` is the commonest shape of the whole corpus and
@@ -1299,6 +1399,17 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1299
1399
 
1300
1400
  // S1/S2 — this word is the declared name.
1301
1401
  if (pendingDecl !== null) {
1402
+ // #533 — named spelling (`:local name=foo value=1`): an argument
1403
+ // NAME (`word=`) is never the declaration. Only `name=` carries
1404
+ // it; its value declares. Other values (`value=foo`) are skipped,
1405
+ // and the pending declaration survives for a later `name=`
1406
+ // (`value=1 name=foo`). A positional word (`:local foo`, `:local
1407
+ // name`) declares as before.
1408
+ if (nextNonSpace(text, j) === "=") {
1409
+ pendingDeclNameValue = lower === "name";
1410
+ continue;
1411
+ }
1412
+ if (isValue && !pendingDeclNameValue) continue;
1302
1413
  const binding = bind(word, pendingDecl, wordStart);
1303
1414
  record(
1304
1415
  wordStart,
@@ -1311,6 +1422,7 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1311
1422
  binding === null ? [] : [binding.id],
1312
1423
  );
1313
1424
  pendingDecl = null;
1425
+ pendingDeclNameValue = false;
1314
1426
  declaredHere = true;
1315
1427
  continue;
1316
1428
  }
@@ -1318,6 +1430,13 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1318
1430
  // S3 — `:foreach k,v in=…`: every word before `in=`/`from=` is a loop
1319
1431
  // variable.
1320
1432
  if (pendingLoopVars) {
1433
+ // #533 — named spelling (`:for counter=i …`): `counter=` carries
1434
+ // the loop var(s); the word `counter` itself is never one. Any
1435
+ // other `arg=` ends the var list, like `in=`/`from=` already did.
1436
+ if (nextNonSpace(text, j) === "=") {
1437
+ if (lower !== "counter") pendingLoopVars = false;
1438
+ continue;
1439
+ }
1321
1440
  if (lower === "in" || lower === "from") {
1322
1441
  pendingLoopVars = false;
1323
1442
  continue;
@@ -1339,6 +1458,43 @@ export function resolveSymbols(original: string): SymbolAnalysis {
1339
1458
  continue;
1340
1459
  }
1341
1460
 
1461
+ // #533 reorder — a named `counter=` past `in=`/`from=` or other
1462
+ // args still carries the loop var: CHR accepts
1463
+ // `:for from=1 counter=i to=2`, and the value binds exactly like
1464
+ // a positional. Only `do` (the body) closes the head; a value of
1465
+ // another arg, or a further arg name, just ends this `counter=`.
1466
+ if (!isValue && lower === "do") {
1467
+ loopCounterOpen = false;
1468
+ pendingLoopCounterValue = false;
1469
+ }
1470
+ if (
1471
+ loopCounterOpen &&
1472
+ lower === "counter" &&
1473
+ nextNonSpace(text, j) === "="
1474
+ ) {
1475
+ pendingLoopCounterValue = true;
1476
+ continue;
1477
+ }
1478
+ if (pendingLoopCounterValue) {
1479
+ if (nextNonSpace(text, j) === "=" || !isValue) {
1480
+ pendingLoopCounterValue = false;
1481
+ } else {
1482
+ openStatementScope(wordStart);
1483
+ const counterBinding = bind(word, "auto", wordStart);
1484
+ record(
1485
+ wordStart,
1486
+ j,
1487
+ word,
1488
+ "auto",
1489
+ true,
1490
+ undefined,
1491
+ "binding",
1492
+ counterBinding === null ? [] : [counterBinding.id],
1493
+ );
1494
+ continue;
1495
+ }
1496
+ }
1497
+
1342
1498
  if (FILTER_WORDS.has(lower)) {
1343
1499
  filterDepth = depth;
1344
1500
  continue;
@@ -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.
@@ -17,8 +17,9 @@
17
17
  * them; only `:typeof` can. Grounded on CHR 7.21.5, 7.23.3 and 7.24rc4 in
18
18
  * `test/fixtures/explain/operators.json` → `sweep.runtime` rows
19
19
  * `typeof-deferred` / `deferred-array-typeof` and `sweep.opAxis` `defer-*`.
20
- * Offline abstains on every such form; a live `observedType` reports the
21
- * runtime type.
20
+ * Offline abstains on every such form, and so does the live `observedType`
21
+ * (#225): it reads only visible `:parse` conversions and never sends
22
+ * `:typeof`.
22
23
  *
23
24
  * ## The two contexts are different lexicons, not one lexicon plus a fallback
24
25
  *
@@ -51,8 +52,8 @@ import { isIP } from "node:net";
51
52
  * tests spellings, so reordering the members here must not change any result.
52
53
  * `array` is emitted by the structured anchor reader rather than
53
54
  * {@link valueShapeHints}, which receives decoded scalar values only.
54
- * `op` is not a member: a deferred `(> …)` form abstains offline and is
55
- * reported only as a live `observedType` (#288).
55
+ * `op` is not a member: a deferred `(> …)` form abstains offline and live
56
+ * (#288, #225).
56
57
  */
57
58
  export const VALUE_SHAPES = [
58
59
  "num",
@@ -70,6 +71,56 @@ export const VALUE_SHAPES = [
70
71
 
71
72
  export type ValueShape = (typeof VALUE_SHAPES)[number];
72
73
 
74
+ /**
75
+ * The types a visible `:parse` conversion can name (#225 `observedType`).
76
+ *
77
+ * A subset of {@link VALUE_SHAPES}: these are exactly the scalar types whose
78
+ * device rendering differs from some spelling of them (`2.2` → `2.0.0.2`,
79
+ * `0x10` → `16`, `yes` → `true`, `200ms` → `00:00:00.200`, `1:0:0::1` →
80
+ * `1::1`). `str`, `id` and `mac` never convert, so they are never observed.
81
+ */
82
+ export const OBSERVED_VALUE_TYPES = [
83
+ "num",
84
+ "bool",
85
+ "ip",
86
+ "ip-prefix",
87
+ "ip6",
88
+ "ip6-prefix",
89
+ "time",
90
+ ] as const satisfies readonly ValueShape[];
91
+
92
+ export type ObservedValueType = (typeof OBSERVED_VALUE_TYPES)[number];
93
+
94
+ /**
95
+ * The normalized terms a schema declaration maps to (#225 `schemaType`).
96
+ *
97
+ * The scalar names are shared with {@link VALUE_SHAPES} so a schema fact and a
98
+ * shape hint compare directly. The schema-only terms name declarations no
99
+ * literal spelling has: `ip-range` (`A.B.C.D[-A.B.C.D|…] (IP address range)`),
100
+ * `enum` (a listed word set such as `input | forward | output`, never closed:
101
+ * the device also lists current configuration names this way) and `name` (a
102
+ * reference to a configuration object). `bool` and `name` are CLI Reference
103
+ * spellings (`bool`, `iface_enum`) the live projection never emits:
104
+ * `/console/inspect` declares a boolean with a blank row, and prints the
105
+ * current interface names exactly like keywords.
106
+ */
107
+ export const SCHEMA_VALUE_TYPES = [
108
+ "num",
109
+ "bool",
110
+ "ip",
111
+ "ip-prefix",
112
+ "ip-range",
113
+ "ip6",
114
+ "ip6-prefix",
115
+ "time",
116
+ "mac",
117
+ "str",
118
+ "enum",
119
+ "name",
120
+ ] as const;
121
+
122
+ export type SchemaValueType = (typeof SCHEMA_VALUE_TYPES)[number];
123
+
73
124
  /**
74
125
  * Where the value sits, because RouterOS reads the same bytes differently.
75
126
  *
@@ -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)