@tikoci/centrs 0.1.4 → 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 +309 -0
- package/GLOSSARY.txt +40 -1
- package/README.md +8 -4
- package/commands/AGENTS.md +7 -4
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +37 -8
- package/commands/api/examples.md +31 -1
- package/commands/execute/README.md +39 -10
- package/commands/execute/examples.md +79 -7
- package/commands/explain/README.md +1195 -155
- package/commands/explain/examples.md +99 -3
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/terminal/examples.md +7 -1
- package/docs/CLI.md +2 -2
- package/docs/CONSTITUTION.md +72 -15
- package/docs/MATRIX.md +89 -52
- package/docs/errors/validation/syntax.md +29 -4
- package/package.json +13 -2
- package/src/api-fanout.ts +17 -4
- package/src/api.ts +254 -67
- package/src/cli/api.ts +1 -1
- package/src/cli/btest.ts +1 -1
- package/src/cli/explain.ts +1 -1
- package/src/cli/terminal.ts +1 -1
- package/src/core/envelope.ts +53 -0
- package/src/core/error-catalog.ts +2 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/execute-fanout.ts +18 -4
- package/src/execute.ts +225 -244
- package/src/explain/arg-tokens.ts +62 -25
- package/src/explain/args.ts +398 -52
- package/src/explain/blocks.ts +54 -11
- package/src/explain/brace-tokens.ts +4 -3
- package/src/explain/catalog.ts +42 -44
- package/src/explain/coordinates.ts +159 -0
- package/src/explain/defects.ts +2 -1
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/menus.ts +9 -5
- package/src/explain/operator-tokens.ts +13 -7
- package/src/explain/operators.ts +27 -20
- package/src/explain/path-tokens.ts +1 -1
- package/src/explain/pathresolve.ts +358 -113
- package/src/explain/quoted-string.ts +110 -20
- package/src/explain/scope-brace.ts +175 -22
- package/src/explain/segment.ts +429 -21
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +14 -4
- package/src/explain/symbol-values.ts +209 -173
- package/src/explain/symbols.ts +21 -1
- package/src/explain/transport.ts +17 -0
- package/src/explain/value-tokens.ts +15 -12
- package/src/explain/verbsplit.ts +165 -23
- package/src/explain/write.ts +47 -6
- package/src/explain.ts +522 -72
- package/src/index.ts +19 -2
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/retrieve-fanout.ts +1 -1
- package/src/retrieve.ts +1 -59
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +1 -1
- package/src/transfer.ts +1 -1
package/src/explain/segment.ts
CHANGED
|
@@ -80,8 +80,18 @@ import {
|
|
|
80
80
|
runAtByte,
|
|
81
81
|
} from "./coordinates.ts";
|
|
82
82
|
import { type Defect, defectAt, mergeDefects } from "./defects.ts";
|
|
83
|
-
import {
|
|
84
|
-
|
|
83
|
+
import {
|
|
84
|
+
MAX_STRING_FRAME_DEPTH,
|
|
85
|
+
scanQuotedString as scanQuotedStringShared,
|
|
86
|
+
} from "./quoted-string.ts";
|
|
87
|
+
import {
|
|
88
|
+
braceStartsStatements,
|
|
89
|
+
canRetainStatementIndex,
|
|
90
|
+
hashStartsHardError,
|
|
91
|
+
isIndexedStatementStart,
|
|
92
|
+
matchingBraceEnd,
|
|
93
|
+
scopeNameFromMasked,
|
|
94
|
+
} from "./scope-brace.ts";
|
|
85
95
|
|
|
86
96
|
/** One top-level statement located in analyzed-byte space. */
|
|
87
97
|
export interface Segment {
|
|
@@ -108,6 +118,13 @@ export interface SegmentResult {
|
|
|
108
118
|
segments: Segment[];
|
|
109
119
|
/** comment spans (analyzed-byte offsets), dropped from the statement stream. */
|
|
110
120
|
comments: { start: number; end: number }[];
|
|
121
|
+
/**
|
|
122
|
+
* The input was pure ASCII, so an analyzed-byte offset into it is also a JS
|
|
123
|
+
* string index — the test a caller needs before it may treat the spans above
|
|
124
|
+
* as offsets into `original` itself. Published from the coordinate pass this
|
|
125
|
+
* result already ran rather than re-derived, so the two cannot disagree.
|
|
126
|
+
*/
|
|
127
|
+
ascii: boolean;
|
|
111
128
|
/**
|
|
112
129
|
* Unbalanced delimiters and other structural surprises, each located; never
|
|
113
130
|
* a throw. Includes the two coordinate-pass classes (`bom`, `non-ascii`).
|
|
@@ -154,8 +171,33 @@ export interface QuotedStringScan {
|
|
|
154
171
|
* can reach it, which is malformed by construction; sequential substitutions
|
|
155
172
|
* (`"$[a]$[b]…"`) pop and never accumulate.
|
|
156
173
|
*/
|
|
157
|
-
export function scanQuotedString(
|
|
158
|
-
|
|
174
|
+
export function scanQuotedString(
|
|
175
|
+
text: string,
|
|
176
|
+
open: number,
|
|
177
|
+
limit: number = text.length,
|
|
178
|
+
): QuotedStringScan {
|
|
179
|
+
return scanQuotedStringWithin(text, open, limit, MAX_STRING_FRAME_DEPTH);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scanQuotedStringWithin(
|
|
183
|
+
text: string,
|
|
184
|
+
open: number,
|
|
185
|
+
limit: number,
|
|
186
|
+
frameBudget: number,
|
|
187
|
+
): QuotedStringScan {
|
|
188
|
+
return scanQuotedStringShared(
|
|
189
|
+
text,
|
|
190
|
+
open,
|
|
191
|
+
limit,
|
|
192
|
+
(source, bracket, end, frameDepth) =>
|
|
193
|
+
scanStringInterpolationWithin(
|
|
194
|
+
source,
|
|
195
|
+
bracket,
|
|
196
|
+
end,
|
|
197
|
+
frameBudget - frameDepth,
|
|
198
|
+
).end,
|
|
199
|
+
frameBudget,
|
|
200
|
+
);
|
|
159
201
|
}
|
|
160
202
|
|
|
161
203
|
/**
|
|
@@ -188,7 +230,250 @@ export type Continuation = "none" | "escape" | "comment";
|
|
|
188
230
|
* can slice the original for content. Idempotent.
|
|
189
231
|
*/
|
|
190
232
|
export function maskComments(original: string): string {
|
|
191
|
-
|
|
233
|
+
if (recentMaskInput === original && recentMaskOutput !== undefined)
|
|
234
|
+
return recentMaskOutput;
|
|
235
|
+
const cached = maskCache.get(original);
|
|
236
|
+
if (cached !== undefined) {
|
|
237
|
+
// Refresh recency — see `scope-brace.ts` → `statementIndex` for why an
|
|
238
|
+
// insertion-ordered eviction loses exactly the entry worth keeping.
|
|
239
|
+
maskCache.delete(original);
|
|
240
|
+
maskCache.set(original, cached);
|
|
241
|
+
adoptMask(original, cached);
|
|
242
|
+
return cached;
|
|
243
|
+
}
|
|
244
|
+
const masked = maskUncached(original);
|
|
245
|
+
if (cacheMask(original, masked)) adoptMask(original, masked);
|
|
246
|
+
return masked;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const MASK_CACHE_LIMIT = 4;
|
|
250
|
+
const MASK_CACHE_BYTE_LIMIT = 8 * 1024 * 1024;
|
|
251
|
+
const maskCache = new Map<string, string>();
|
|
252
|
+
let maskCacheBytes = 0;
|
|
253
|
+
// Same shape, and the same reason, as `scope-brace.ts`'s boundary cache: one
|
|
254
|
+
// slot for the hot object, a small bounded map behind it, and adoption on a hit
|
|
255
|
+
// so a content-equal twin is compared once rather than on every call.
|
|
256
|
+
let recentMaskInput: string | undefined;
|
|
257
|
+
let recentMaskOutput: string | undefined;
|
|
258
|
+
|
|
259
|
+
/** Adopt `original` as the single-slot key (see `scope-brace.ts` → `adoptBoundaryText`). */
|
|
260
|
+
function adoptMask(original: string, masked: string): void {
|
|
261
|
+
recentMaskInput = original;
|
|
262
|
+
recentMaskOutput = masked;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function maskCacheEntryBytes(original: string, masked: string): number {
|
|
266
|
+
// Comment-free input is its own mask, so that entry holds one string.
|
|
267
|
+
return (original.length + (masked === original ? 0 : masked.length)) * 2;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function cacheMask(original: string, masked: string): boolean {
|
|
271
|
+
const entryBytes = maskCacheEntryBytes(original, masked);
|
|
272
|
+
if (entryBytes > MASK_CACHE_BYTE_LIMIT) return false;
|
|
273
|
+
while (
|
|
274
|
+
maskCache.size >= MASK_CACHE_LIMIT ||
|
|
275
|
+
maskCacheBytes + entryBytes > MASK_CACHE_BYTE_LIMIT
|
|
276
|
+
) {
|
|
277
|
+
const oldest = maskCache.entries().next().value;
|
|
278
|
+
if (oldest === undefined) break;
|
|
279
|
+
maskCache.delete(oldest[0]);
|
|
280
|
+
maskCacheBytes -= maskCacheEntryBytes(oldest[0], oldest[1]);
|
|
281
|
+
if (recentMaskInput === oldest[0]) {
|
|
282
|
+
recentMaskInput = undefined;
|
|
283
|
+
recentMaskOutput = undefined;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
maskCache.set(original, masked);
|
|
287
|
+
maskCacheBytes += entryBytes;
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A region of one document, carried with that document's comment mask.
|
|
293
|
+
*
|
|
294
|
+
* Every reader below the statement walk asks its questions of a statement's
|
|
295
|
+
* `text` or of a `do={…}` body — and a statement's text CONTAINS its whole
|
|
296
|
+
* nested subtree, so each enclosing level re-derived the mask, and the
|
|
297
|
+
* statement index behind it, over bytes an inner level had already read. That
|
|
298
|
+
* is the O(bytes x depth) term: a 60 KiB document nested 64 deep masked 27.7 MB
|
|
299
|
+
* (#322).
|
|
300
|
+
*
|
|
301
|
+
* A range replaces the substring. `text` and `masked` are always the WHOLE
|
|
302
|
+
* document; `start` and `end` say which part of it this reader may look at. The
|
|
303
|
+
* mask is then derived once per document and every nested reading is an offset
|
|
304
|
+
* into it.
|
|
305
|
+
*
|
|
306
|
+
* Soundness is a property of where the ranges come from, not of this type: a
|
|
307
|
+
* comment mask is decided by the statement context a byte sits in, and a
|
|
308
|
+
* statement boundary and a scope body both begin one — statement-leading, no
|
|
309
|
+
* continuation pending, an enclosing context that admits statements. So the
|
|
310
|
+
* mask of such a region is the document's mask restricted to it. Readers still
|
|
311
|
+
* have to be told the floor (see `scope-brace.ts` → `scopeNameFromMasked`) so
|
|
312
|
+
* that lookback stops where a fresh call on the region alone would stop.
|
|
313
|
+
*/
|
|
314
|
+
export interface MaskedRange {
|
|
315
|
+
/** The whole document. Offsets below index into this, never into a slice. */
|
|
316
|
+
readonly text: string;
|
|
317
|
+
/** `maskComments(text)`. */
|
|
318
|
+
readonly masked: string;
|
|
319
|
+
readonly start: number;
|
|
320
|
+
readonly end: number;
|
|
321
|
+
/** This range's string indices are analyzed-byte offsets. */
|
|
322
|
+
readonly coordinateIdentity?: true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The range covering all of `text`, masking it once. */
|
|
326
|
+
export function documentRange(
|
|
327
|
+
text: string,
|
|
328
|
+
coordinateIdentity = false,
|
|
329
|
+
): MaskedRange {
|
|
330
|
+
return {
|
|
331
|
+
text,
|
|
332
|
+
masked: maskComments(text),
|
|
333
|
+
start: 0,
|
|
334
|
+
end: text.length,
|
|
335
|
+
...(coordinateIdentity ? { coordinateIdentity: true as const } : {}),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Locate one `$[…]` body's closing bracket using its own comment context.
|
|
341
|
+
*
|
|
342
|
+
* The document mask deliberately leaves an enclosing string opaque. Once `$[`
|
|
343
|
+
* enters code, statement-leading comments are real again, so brackets inside
|
|
344
|
+
* them cannot close or extend the substitution. The returned range starts at
|
|
345
|
+
* `open` and retains that local mask for the readers of the body.
|
|
346
|
+
*/
|
|
347
|
+
export function scanStringInterpolation(
|
|
348
|
+
text: string,
|
|
349
|
+
open: number,
|
|
350
|
+
limit: number = text.length,
|
|
351
|
+
): { end: number; range: MaskedRange } {
|
|
352
|
+
return scanStringInterpolationWithin(
|
|
353
|
+
text,
|
|
354
|
+
open,
|
|
355
|
+
limit,
|
|
356
|
+
MAX_STRING_FRAME_DEPTH - 1,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function scanStringInterpolationWithin(
|
|
361
|
+
text: string,
|
|
362
|
+
open: number,
|
|
363
|
+
limit: number,
|
|
364
|
+
frameBudget: number,
|
|
365
|
+
): { end: number; range: MaskedRange } {
|
|
366
|
+
const local = text.slice(open, limit);
|
|
367
|
+
// Masking must find each code-level string boundary comment-aware. Reuse
|
|
368
|
+
// those answers below instead of recursively scanning the same strings again.
|
|
369
|
+
const quotedEnds = new Map<number, number>();
|
|
370
|
+
const range: MaskedRange = {
|
|
371
|
+
text: local,
|
|
372
|
+
masked: maskUncached(local, frameBudget, quotedEnds),
|
|
373
|
+
start: 0,
|
|
374
|
+
end: local.length,
|
|
375
|
+
};
|
|
376
|
+
if (frameBudget < 1) return { end: limit, range };
|
|
377
|
+
const frames: string[] = ["["];
|
|
378
|
+
for (let i = 1; i < range.end; i++) {
|
|
379
|
+
const c = range.masked[i];
|
|
380
|
+
if (c === '"') {
|
|
381
|
+
const quotedEnd = quotedEnds.get(i);
|
|
382
|
+
if (quotedEnd === undefined) return { end: limit, range };
|
|
383
|
+
i = quotedEnd - 1;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (c === "[" || c === "(" || c === "{") {
|
|
387
|
+
if (frames.length >= frameBudget) return { end: limit, range };
|
|
388
|
+
frames.push(c);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (c !== "]" && c !== ")" && c !== "}") continue;
|
|
392
|
+
const want = c === "]" ? "[" : c === ")" ? "(" : "{";
|
|
393
|
+
if (frames.at(-1) !== want) continue;
|
|
394
|
+
frames.pop();
|
|
395
|
+
if (frames.length === 0) return { end: open + i, range };
|
|
396
|
+
}
|
|
397
|
+
return { end: limit, range };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The sub-range `[start, end)` of `parent`, in `parent`'s own coordinates.
|
|
402
|
+
*
|
|
403
|
+
* Offsets must be JS string indices — for a statement span that means the
|
|
404
|
+
* document was ASCII ({@link SegmentResult.ascii}), which is the only case in
|
|
405
|
+
* which an analyzed-byte offset is one.
|
|
406
|
+
*/
|
|
407
|
+
export function nestedRange(
|
|
408
|
+
parent: MaskedRange,
|
|
409
|
+
start: number,
|
|
410
|
+
end: number,
|
|
411
|
+
): MaskedRange {
|
|
412
|
+
return {
|
|
413
|
+
text: parent.text,
|
|
414
|
+
masked: parent.masked,
|
|
415
|
+
start: parent.start + start,
|
|
416
|
+
end: parent.start + end,
|
|
417
|
+
...(parent.coordinateIdentity ? { coordinateIdentity: true as const } : {}),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* The range `[start, end)` of the same document, in ABSOLUTE offsets.
|
|
423
|
+
*
|
|
424
|
+
* The counterpart to {@link nestedRange}, for a reader that is already working
|
|
425
|
+
* in the document's own offsets rather than in a parent's.
|
|
426
|
+
*/
|
|
427
|
+
export function rangeAt(
|
|
428
|
+
parent: MaskedRange,
|
|
429
|
+
start: number,
|
|
430
|
+
end: number,
|
|
431
|
+
): MaskedRange {
|
|
432
|
+
return {
|
|
433
|
+
text: parent.text,
|
|
434
|
+
masked: parent.masked,
|
|
435
|
+
start,
|
|
436
|
+
end,
|
|
437
|
+
...(parent.coordinateIdentity ? { coordinateIdentity: true as const } : {}),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The comment mask of what this range covers. */
|
|
442
|
+
export function rangeMask(range: MaskedRange): string {
|
|
443
|
+
return range.masked.slice(range.start, range.end);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* {@link rangeMask}, tolerating no range — for readers that take one OPTIONALLY
|
|
448
|
+
* and fall back to masking their own text: `maskOf(range) ?? maskComments(text)`.
|
|
449
|
+
*/
|
|
450
|
+
export function maskOf(range: MaskedRange | undefined): string | undefined {
|
|
451
|
+
return range === undefined ? undefined : rangeMask(range);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The mask itself. Pure, so the memo above cannot change an answer — only how
|
|
456
|
+
* often it is computed.
|
|
457
|
+
*
|
|
458
|
+
* The walk asks for the mask of the SAME statement several times over: the
|
|
459
|
+
* structural-defect scan, the scope-block scan and the bracket scan each take
|
|
460
|
+
* one, and the bare-directive test takes another over the trimmed spelling.
|
|
461
|
+
* Each of those re-ran this scan AND, through `braceStartsStatements`, the
|
|
462
|
+
* statement index behind it — and a statement's text carries its whole nested
|
|
463
|
+
* subtree, so the repetition was paid once per enclosing level (#322).
|
|
464
|
+
*/
|
|
465
|
+
function maskUncached(
|
|
466
|
+
original: string,
|
|
467
|
+
frameBudget: number = MAX_STRING_FRAME_DEPTH,
|
|
468
|
+
quotedEnds?: Map<number, number>,
|
|
469
|
+
): string {
|
|
470
|
+
// Masked output is built from whole slices, and only once a comment is
|
|
471
|
+
// actually found: comment-free input is returned as the SAME string object.
|
|
472
|
+
// `split("")` allocated one string per character of every call — 16 walks
|
|
473
|
+
// over a 64 KiB document made a million of them — where a document's comment
|
|
474
|
+
// runs are a handful of slices (#313).
|
|
475
|
+
let pieces: string[] | undefined;
|
|
476
|
+
let copied = 0;
|
|
192
477
|
const contexts: { char: "{" | "[" | "("; statements: boolean }[] = [];
|
|
193
478
|
let atLead = true;
|
|
194
479
|
let cont: Continuation = "none";
|
|
@@ -211,7 +496,14 @@ export function maskComments(original: string): string {
|
|
|
211
496
|
}
|
|
212
497
|
if (c === '"') {
|
|
213
498
|
atLead = false;
|
|
214
|
-
|
|
499
|
+
const end = scanQuotedStringWithin(
|
|
500
|
+
original,
|
|
501
|
+
i,
|
|
502
|
+
original.length,
|
|
503
|
+
frameBudget - contexts.length,
|
|
504
|
+
).end;
|
|
505
|
+
quotedEnds?.set(i, end);
|
|
506
|
+
i = end - 1;
|
|
215
507
|
continue; // i rests on the closing quote (or past end)
|
|
216
508
|
}
|
|
217
509
|
// H4 is unchanged and H5 only ADDS the immediate-line-start case: an
|
|
@@ -219,10 +511,14 @@ export function maskComments(original: string): string {
|
|
|
219
511
|
// newline + ` # c`) and content when it is not (`:put a\` + newline +
|
|
220
512
|
// ` # x`, which the device classes `error`) — exactly what `atLead` says.
|
|
221
513
|
if (c === "#" && (atLead || contComment)) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
514
|
+
const commentStart = i;
|
|
515
|
+
while (i < original.length && original[i] !== "\n") i++;
|
|
516
|
+
if (pieces === undefined) pieces = [];
|
|
517
|
+
pieces.push(
|
|
518
|
+
original.slice(copied, commentStart),
|
|
519
|
+
" ".repeat(i - commentStart),
|
|
520
|
+
);
|
|
521
|
+
copied = i;
|
|
226
522
|
if (contComment) {
|
|
227
523
|
// The comment line does not end the statement: step over its newline
|
|
228
524
|
// (the loop's i++) with the continuation still pending.
|
|
@@ -263,7 +559,9 @@ export function maskComments(original: string): string {
|
|
|
263
559
|
atLead = contexts.at(-1)?.statements ?? true;
|
|
264
560
|
} else if (c !== " " && c !== "\t" && c !== "\r") atLead = false;
|
|
265
561
|
}
|
|
266
|
-
|
|
562
|
+
if (pieces === undefined) return original;
|
|
563
|
+
pieces.push(original.slice(copied));
|
|
564
|
+
return pieces.join("");
|
|
267
565
|
}
|
|
268
566
|
|
|
269
567
|
/**
|
|
@@ -282,23 +580,76 @@ const MAX_CONTAINER_DEPTH = 256;
|
|
|
282
580
|
/**
|
|
283
581
|
* Segment `original` into top-level statements. Spans are analyzed-byte offsets
|
|
284
582
|
* (see module header); `text` is the original substring for each span.
|
|
583
|
+
*
|
|
584
|
+
* A statement-bearing `range` may anchor the same `original` text in its
|
|
585
|
+
* enclosing document. On ASCII input, structural lookback can reuse that
|
|
586
|
+
* document's statement index instead of indexing every nested body (#322).
|
|
587
|
+
* The scan still reads the original bytes, including comments: replacing them
|
|
588
|
+
* with the mask would lose comment spans and H5 continuation state. Non-ASCII
|
|
589
|
+
* input, or a body opener skipped by the raw document index (a quote inside an
|
|
590
|
+
* earlier comment), keeps the standalone analyzed-byte scan.
|
|
285
591
|
*/
|
|
286
|
-
export function segmentStatements(
|
|
287
|
-
|
|
592
|
+
export function segmentStatements(
|
|
593
|
+
original: string,
|
|
594
|
+
range?: MaskedRange,
|
|
595
|
+
): SegmentResult {
|
|
596
|
+
return segmentStatementsInternal(original, range, false);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Segment one known scope body while treating its child scopes as opaque. */
|
|
600
|
+
export function segmentScopeBody(
|
|
601
|
+
original: string,
|
|
602
|
+
range: MaskedRange,
|
|
603
|
+
): SegmentResult {
|
|
604
|
+
return segmentStatementsInternal(
|
|
605
|
+
original,
|
|
606
|
+
range,
|
|
607
|
+
range.coordinateIdentity === true,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function segmentStatementsInternal(
|
|
612
|
+
original: string,
|
|
613
|
+
range: MaskedRange | undefined,
|
|
614
|
+
opaqueChildScopes: boolean,
|
|
615
|
+
): SegmentResult {
|
|
616
|
+
const identity =
|
|
617
|
+
range?.coordinateIdentity === true &&
|
|
618
|
+
range.end - range.start === original.length;
|
|
619
|
+
const analysis = identity ? undefined : analyzeCoordinates(original);
|
|
288
620
|
// `analyzed` is pure ASCII, so a string built from it has index === byte.
|
|
289
|
-
|
|
290
|
-
|
|
621
|
+
// When the INPUT was already ASCII the two are the same sequence, so the
|
|
622
|
+
// original is that string and the decode is skipped (#322).
|
|
623
|
+
const ascii =
|
|
624
|
+
analysis === undefined || analysis.ascii
|
|
625
|
+
? original
|
|
626
|
+
: new TextDecoder().decode(analysis.analyzed);
|
|
627
|
+
const anchored =
|
|
628
|
+
(analysis === undefined || analysis.ascii) &&
|
|
629
|
+
range !== undefined &&
|
|
630
|
+
canRetainStatementIndex(range.text) &&
|
|
631
|
+
isIndexedStatementStart(range.text, range.start)
|
|
632
|
+
? range
|
|
633
|
+
: undefined;
|
|
634
|
+
const raw = scanAscii(ascii, anchored, opaqueChildScopes);
|
|
291
635
|
// Recover the human-readable `text` for each segment from the original.
|
|
292
636
|
const segments = raw.segments.map((s) => ({
|
|
293
637
|
...s,
|
|
294
|
-
text:
|
|
638
|
+
text:
|
|
639
|
+
analysis === undefined
|
|
640
|
+
? original.slice(s.start, s.end)
|
|
641
|
+
: originalSlice(analysis, s.start, s.end),
|
|
295
642
|
}));
|
|
296
643
|
return {
|
|
297
644
|
segments,
|
|
298
645
|
comments: raw.comments,
|
|
646
|
+
ascii: analysis?.ascii ?? true,
|
|
299
647
|
// Coordinate-pass regions first: they are facts about the input as
|
|
300
648
|
// received, and they exist even when the scan finds nothing structural.
|
|
301
|
-
defects: mergeDefects(
|
|
649
|
+
defects: mergeDefects(
|
|
650
|
+
analysis === undefined ? [] : coordinateDefects(analysis),
|
|
651
|
+
raw.defects,
|
|
652
|
+
),
|
|
302
653
|
};
|
|
303
654
|
}
|
|
304
655
|
|
|
@@ -314,7 +665,8 @@ function originalSlice(
|
|
|
314
665
|
/** UTF-16 offset in the original for an analyzed-byte boundary. */
|
|
315
666
|
function utf16At(a: CoordinateAnalysis, byte: number): number {
|
|
316
667
|
if (byte >= a.analyzed.length) return a.original.length;
|
|
317
|
-
|
|
668
|
+
// Identity on an all-ASCII input: one byte per code unit in both spaces.
|
|
669
|
+
return a.ascii ? byte : runAtByte(a, byte).utf16Start;
|
|
318
670
|
}
|
|
319
671
|
|
|
320
672
|
/** A located statement before its `text` is recovered from the original. */
|
|
@@ -366,15 +718,26 @@ interface Frame {
|
|
|
366
718
|
* flattening H7 containers inline. Returns spans only; `segmentStatements`
|
|
367
719
|
* recovers each `text` from the original afterward.
|
|
368
720
|
*/
|
|
369
|
-
function scanAscii(
|
|
721
|
+
function scanAscii(
|
|
722
|
+
ascii: string,
|
|
723
|
+
range?: MaskedRange,
|
|
724
|
+
opaqueChildScopes = false,
|
|
725
|
+
): {
|
|
370
726
|
segments: RawSegment[];
|
|
371
727
|
comments: { start: number; end: number }[];
|
|
372
728
|
defects: Defect[];
|
|
373
729
|
} {
|
|
730
|
+
// Local indices remain local in the result; only index-backed questions use
|
|
731
|
+
// document coordinates. The floor prevents an enclosing head from becoming
|
|
732
|
+
// the head of the body being segmented.
|
|
733
|
+
const indexed = range?.text ?? ascii;
|
|
734
|
+
const structural = range?.masked ?? ascii;
|
|
735
|
+
const floor = range?.start ?? 0;
|
|
374
736
|
const comments: { start: number; end: number }[] = [];
|
|
375
737
|
const defects: Defect[] = [];
|
|
376
738
|
const overDepth: number[] = [];
|
|
377
739
|
let hardHashSeen = false;
|
|
740
|
+
let hardApostropheSeen = false;
|
|
378
741
|
// H2 — every open bracket, for balance. Each frame carries WHERE it opened so
|
|
379
742
|
// an `unclosed` defect can point at the opener rather than at the end of
|
|
380
743
|
// input.
|
|
@@ -596,7 +959,12 @@ function scanAscii(ascii: string): {
|
|
|
596
959
|
if (
|
|
597
960
|
c === "#" &&
|
|
598
961
|
!hardHashSeen &&
|
|
599
|
-
hashStartsHardError(
|
|
962
|
+
hashStartsHardError(
|
|
963
|
+
indexed,
|
|
964
|
+
floor + i,
|
|
965
|
+
delimStack.at(-1)?.statements === false,
|
|
966
|
+
floor,
|
|
967
|
+
)
|
|
600
968
|
) {
|
|
601
969
|
defects.push(defectAt("invalid-hash", i, "#"));
|
|
602
970
|
hardHashSeen = true;
|
|
@@ -607,6 +975,31 @@ function scanAscii(ascii: string): {
|
|
|
607
975
|
continue;
|
|
608
976
|
}
|
|
609
977
|
|
|
978
|
+
// #355 — `'` is NOT a RouterOS token: not a string delimiter, and not a
|
|
979
|
+
// legal bare byte. `:put [:parse]` rejects `name=don't`, `name=don't'`,
|
|
980
|
+
// `name='abc'` and `:put 'abc'` byte-identically on 7.21.5 / 7.23.5 /
|
|
981
|
+
// 7.24.2 / 7.25beta3, so this is a lexical rule and not a quote-balance
|
|
982
|
+
// one — the BALANCED forms are rejected too, and only `"` opens a string.
|
|
983
|
+
//
|
|
984
|
+
// Only the FIRST apostrophe is recorded, for the same reason as the hard
|
|
985
|
+
// hash above and grounded the same way: the device reports one column and
|
|
986
|
+
// it is always the first apostrophe (`name=don't'` reports column 30, the
|
|
987
|
+
// one at `don|'t`, not the trailing one), because classification stops
|
|
988
|
+
// there. A second occurrence is not an independent finding.
|
|
989
|
+
//
|
|
990
|
+
// Apostrophes inside a `"`-string or a comment never reach this branch:
|
|
991
|
+
// both runs are consumed whole above (H3 and H4/H5), which is why
|
|
992
|
+
// `comment="it's here"` stays legal with no special case here.
|
|
993
|
+
if (c === "'" && !hardApostropheSeen) {
|
|
994
|
+
defects.push(defectAt("invalid-apostrophe", i, "'"));
|
|
995
|
+
hardApostropheSeen = true;
|
|
996
|
+
f.structurallyInvalid = true;
|
|
997
|
+
ensureStmt(f, i);
|
|
998
|
+
f.atLead = false;
|
|
999
|
+
i++;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
610
1003
|
// H3 — string.
|
|
611
1004
|
if (c === '"') {
|
|
612
1005
|
ensureStmt(f, i);
|
|
@@ -657,9 +1050,24 @@ function scanAscii(ascii: string): {
|
|
|
657
1050
|
} else {
|
|
658
1051
|
ensureStmt(f, i);
|
|
659
1052
|
const enclosing = delimStack.at(-1)?.statements ?? true;
|
|
1053
|
+
if (
|
|
1054
|
+
opaqueChildScopes &&
|
|
1055
|
+
c === "{" &&
|
|
1056
|
+
atContainerLevel() &&
|
|
1057
|
+
scopeNameFromMasked(structural, floor + i, floor) !== null
|
|
1058
|
+
) {
|
|
1059
|
+
const end = matchingBraceEnd(structural, floor + i);
|
|
1060
|
+
if (end !== undefined && end < floor + ascii.length) {
|
|
1061
|
+
f.atLead = false;
|
|
1062
|
+
i = end - floor + 1;
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
660
1066
|
const statements =
|
|
661
1067
|
c === "[" ||
|
|
662
|
-
(c === "{" &&
|
|
1068
|
+
(c === "{" &&
|
|
1069
|
+
enclosing &&
|
|
1070
|
+
braceStartsStatements(indexed, floor + i, floor));
|
|
663
1071
|
delimStack.push({ char: c, at: i, statements });
|
|
664
1072
|
f.atLead = statements;
|
|
665
1073
|
}
|