flecto 3.0.2 → 4.0.0

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,1057 @@
1
+ import { extname } from 'path';
2
+ import yaml from 'js-yaml';
3
+
4
+ import { isArmoredAgeFile } from './encrypted.js';
5
+ import { isEnvFilename, isIniFilename, stripJsonComments, yamlDocumentKeys } from './parser.js';
6
+
7
+ /**
8
+ * Where a config path lives in the source text (#142).
9
+ *
10
+ * The differ reports paths (`db.pool_size`, `containers["web"].image`), not
11
+ * positions: every parser Flecto uses hands back a plain tree and forgets where
12
+ * each value came from. Diagnostics need a line and a range, and **a diagnostic
13
+ * anchored to the wrong line is worse than none**, so this module is built
14
+ * around one rule: a position is reported only where an independent reading of
15
+ * the text agrees with the tree the differ actually diffed.
16
+ *
17
+ * Each format gets a scanner that records positions — the js-yaml listener for
18
+ * YAML, dotenv's own line pattern, and small hand-written scanners for JSON,
19
+ * INI, and TOML. The scanned structure is then checked against the parsed tree
20
+ * node by node ({@link verify}): a mapping is trusted only when its keys are
21
+ * exactly the parsed object's keys, a sequence only when its length matches.
22
+ * Anything that disagrees — a construct a scanner does not model, a SOPS block
23
+ * the encryption pass rewrote, a merged-in key — stops the lookup there, and the
24
+ * diagnostic anchors to the nearest ancestor that *was* verified, never to a
25
+ * guess.
26
+ *
27
+ * @typedef {{
28
+ * kind: 'mapping' | 'sequence' | 'scalar' | 'opaque',
29
+ * start: number,
30
+ * end: number,
31
+ * entries?: Map<string, PosEntry>,
32
+ * merge?: { start: number, end: number } | null,
33
+ * items?: PosNode[],
34
+ * trusted?: boolean,
35
+ * value?: unknown,
36
+ * }} PosNode
37
+ *
38
+ * @typedef {{ keyStart: number, keyEnd: number, node: PosNode, check?: unknown, unsure?: boolean }} PosEntry
39
+ *
40
+ * @typedef {{ text: string, root: PosNode | null, lineStarts: number[] }} PositionIndex
41
+ *
42
+ * @typedef {'exact' | 'merge' | 'ancestor' | 'file'} Precision
43
+ * `exact`: the path itself. `merge`: a key a YAML merge (`<<`) brought in,
44
+ * anchored at the merge. `ancestor`: the path is not in the text (removed) or
45
+ * not addressable, so the nearest enclosing key that is. `file`: nothing
46
+ * narrower is known.
47
+ *
48
+ * @typedef {{ start: number, end: number, precision: Precision }} Location
49
+ */
50
+
51
+ const MERGE_TAG = 'tag:yaml.org,2002:merge';
52
+
53
+ /**
54
+ * Build the position index for a file's text, verified against the tree the
55
+ * parser produced from that same text.
56
+ * @param {string} filepath decides the format, exactly as the parser does
57
+ * @param {string} text the raw source
58
+ * @param {unknown} parsed what `parseContent(filepath, text)` returned
59
+ * @returns {PositionIndex} `root` is null when the format has no position
60
+ * support or the text could not be scanned; every lookup is then file-level
61
+ */
62
+ export function buildPositionIndex(filepath, text, parsed) {
63
+ const source = String(text);
64
+ let root = null;
65
+ try {
66
+ root = scan(filepath, source);
67
+ } catch {
68
+ root = null;
69
+ }
70
+ if (root) verify(root, parsed);
71
+ return { text: source, root, lineStarts: lineStartsOf(source) };
72
+ }
73
+
74
+ /**
75
+ * @param {string} filepath
76
+ * @param {string} text
77
+ * @returns {PosNode | null}
78
+ */
79
+ function scan(filepath, text) {
80
+ const ext = extname(filepath).toLowerCase();
81
+ if (ext === '.age' || isArmoredAgeFile(text)) return null;
82
+ if (isEnvFilename(filepath) || ext === '.env') return scanDotenv(text);
83
+ if (isIniFilename(filepath)) return scanIni(text);
84
+ if (ext === '.json' || ext === '.jsonc') return scanJson(text);
85
+ if (ext === '.yaml' || ext === '.yml') return scanYaml(text);
86
+ if (ext === '.toml') return scanToml(text);
87
+ return null;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Verification
92
+
93
+ /**
94
+ * @param {unknown} value
95
+ * @returns {value is Record<string, unknown>}
96
+ */
97
+ function isPlainObject(value) {
98
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
99
+ const proto = Object.getPrototypeOf(value);
100
+ return proto === Object.prototype || proto === null;
101
+ }
102
+
103
+ /**
104
+ * Mark each node trusted only where it agrees with the parsed tree, and attach
105
+ * the parsed value it stands for (array identity lookups read it).
106
+ * @param {PosNode} node
107
+ * @param {unknown} value
108
+ */
109
+ function verify(node, value) {
110
+ node.value = value;
111
+ node.trusted = false;
112
+ if (node.kind === 'mapping') {
113
+ if (!isPlainObject(value)) return;
114
+ const entries = /** @type {Map<string, PosEntry>} */ (node.entries);
115
+ for (const key of entries.keys()) {
116
+ if (!Object.hasOwn(value, key)) return;
117
+ }
118
+ // A key the text does not show is only explainable by a merge.
119
+ if (!node.merge && Object.keys(value).some((key) => !entries.has(key))) return;
120
+ node.trusted = true;
121
+ for (const [key, entry] of entries) {
122
+ // A duplicated key resolves by the parser's rule (last wins for dotenv);
123
+ // if the value at the position chosen does not match, that position is
124
+ // not claimed.
125
+ if ('check' in entry && entry.check !== value[key]) entry.unsure = true;
126
+ verify(entry.node, value[key]);
127
+ }
128
+ return;
129
+ }
130
+ if (node.kind === 'sequence') {
131
+ const items = /** @type {PosNode[]} */ (node.items);
132
+ if (!Array.isArray(value) || value.length !== items.length) return;
133
+ node.trusted = true;
134
+ items.forEach((item, index) => verify(item, value[index]));
135
+ return;
136
+ }
137
+ // Nothing below a scalar is addressable; an opaque node is never trusted.
138
+ node.trusted = node.kind === 'scalar';
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Lookup
143
+
144
+ /**
145
+ * Locate a diff path in the text.
146
+ *
147
+ * Paths are the differ's: `a.b`, `list[0]`, `list["web"]` (array identity),
148
+ * `list[*]` (order-insensitive — no single element to point at). A key may
149
+ * itself contain `.` or `[`, so candidates are tried against the raw path and a
150
+ * path that reads two ways resolves to neither.
151
+ * @param {PositionIndex} index
152
+ * @param {string} path
153
+ * @param {{ arrayIdKey?: string | null }} [options] the run's configured
154
+ * identity key, which is the only one the differ would have used
155
+ * @returns {Location}
156
+ */
157
+ export function locatePath(index, path, options = {}) {
158
+ const file = { start: 0, end: 0, precision: /** @type {Precision} */ ('file') };
159
+ if (!index?.root || typeof path !== 'string') return file;
160
+ const outcome = resolve(index.root, path, null, options, index.text);
161
+ if (!outcome.anchor) return file;
162
+ return { ...outcome.anchor, precision: outcome.precision };
163
+ }
164
+
165
+ /**
166
+ * @typedef {{ anchor: { start: number, end: number } | null, precision: Precision, complete: boolean }} Outcome
167
+ */
168
+
169
+ /**
170
+ * @param {PosNode} node
171
+ * @param {string} rest the path still to consume below `node`
172
+ * @param {{ start: number, end: number } | null} anchor where `node` itself is named
173
+ * @param {{ arrayIdKey?: string | null }} options
174
+ * @param {string} text
175
+ * @returns {Outcome}
176
+ */
177
+ function resolve(node, rest, anchor, options, text) {
178
+ const here = { anchor, precision: /** @type {Precision} */ (anchor ? 'ancestor' : 'file'), complete: false };
179
+ if (rest === '') return { anchor, precision: anchor ? 'exact' : 'file', complete: true };
180
+ if (!node.trusted) return here;
181
+
182
+ if (node.kind === 'mapping') {
183
+ /** @type {Outcome[]} */
184
+ const outcomes = [];
185
+ for (const [key, entry] of /** @type {Map<string, PosEntry>} */ (node.entries)) {
186
+ if (entry.unsure || !rest.startsWith(key)) continue;
187
+ const next = rest.slice(key.length);
188
+ if (next !== '' && next[0] !== '.' && next[0] !== '[') continue;
189
+ outcomes.push(resolve(
190
+ entry.node,
191
+ next[0] === '.' ? next.slice(1) : next,
192
+ entryRange(entry, text),
193
+ options,
194
+ text,
195
+ ));
196
+ }
197
+ const complete = outcomes.filter((outcome) => outcome.complete);
198
+ if (complete.length === 1) return complete[0];
199
+ // Two readings of one path: pointing at either would be a guess.
200
+ if (complete.length > 1 || outcomes.length > 1) return here;
201
+ if (outcomes.length === 1) return outcomes[0];
202
+ if (node.merge) return { anchor: node.merge, precision: 'merge', complete: false };
203
+ return here;
204
+ }
205
+
206
+ if (node.kind === 'sequence') {
207
+ const match = /^\[(\d+|"(?:[^"\\]|\\.)*")\]/u.exec(rest);
208
+ if (!match) return here;
209
+ const items = /** @type {PosNode[]} */ (node.items);
210
+ const item = match[1].startsWith('"')
211
+ ? itemByIdentity(items, match[1], options.arrayIdKey)
212
+ : items[Number(match[1])];
213
+ if (!item) return here;
214
+ const next = rest.slice(match[0].length);
215
+ return resolve(item, next[0] === '.' ? next.slice(1) : next, itemRange(item, text), options, text);
216
+ }
217
+
218
+ return here;
219
+ }
220
+
221
+ /**
222
+ * The element an identity segment (`["web"]`) names, the way the differ chose
223
+ * it: the configured key when there is one, else `id`, else `name`. The differ
224
+ * picked a key using both sides of the diff and this sees only one, so an
225
+ * element is returned only when every candidate key agrees on it.
226
+ * @param {PosNode[]} items
227
+ * @param {string} quoted the segment's JSON string literal
228
+ * @param {string | null | undefined} arrayIdKey
229
+ * @returns {PosNode | null}
230
+ */
231
+ function itemByIdentity(items, quoted, arrayIdKey) {
232
+ let target;
233
+ try {
234
+ target = JSON.parse(quoted);
235
+ } catch {
236
+ return null;
237
+ }
238
+ const keys = arrayIdKey ? [String(arrayIdKey)] : ['id', 'name'];
239
+ /** @type {Set<PosNode>} */
240
+ const matches = new Set();
241
+ for (const key of keys) {
242
+ for (const item of items) {
243
+ const value = item.value;
244
+ if (!isPlainObject(value) || !Object.hasOwn(value, key)) continue;
245
+ const id = value[key];
246
+ if (id === null || id === undefined || typeof id === 'object') continue;
247
+ if (String(id) === target) matches.add(item);
248
+ }
249
+ }
250
+ return matches.size === 1 ? [...matches][0] : null;
251
+ }
252
+
253
+ /**
254
+ * The range a diagnostic about an entry underlines: the key, extended over a
255
+ * scalar value on the same line (`pool_size: 20`), but never over a whole
256
+ * nested block.
257
+ * @param {PosEntry} entry
258
+ * @param {string} text
259
+ * @returns {{ start: number, end: number }}
260
+ */
261
+ function entryRange(entry, text) {
262
+ const { node } = entry;
263
+ const sameLine = !/[\r\n]/u.test(text.slice(entry.keyStart, node.end));
264
+ return {
265
+ start: entry.keyStart,
266
+ end: node.kind === 'scalar' && sameLine && node.end > entry.keyEnd ? node.end : entry.keyEnd,
267
+ };
268
+ }
269
+
270
+ /**
271
+ * The range for a sequence element: the whole element when it is a one-line
272
+ * scalar, otherwise its first line.
273
+ * @param {PosNode} item
274
+ * @param {string} text
275
+ * @returns {{ start: number, end: number }}
276
+ */
277
+ function itemRange(item, text) {
278
+ const firstLineEnd = lineEndOf(text, item.start);
279
+ return { start: item.start, end: Math.min(item.end > item.start ? item.end : firstLineEnd, firstLineEnd) };
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Offsets and LSP positions
284
+
285
+ /**
286
+ * @param {string} text
287
+ * @returns {number[]}
288
+ */
289
+ function lineStartsOf(text) {
290
+ const starts = [0];
291
+ for (let i = 0; i < text.length; i++) {
292
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
293
+ }
294
+ return starts;
295
+ }
296
+
297
+ /**
298
+ * @param {string} text
299
+ * @param {number} offset
300
+ * @returns {number} end of the line holding `offset`, before any `\r\n`
301
+ */
302
+ function lineEndOf(text, offset) {
303
+ let end = offset;
304
+ while (end < text.length && text[end] !== '\n' && text[end] !== '\r') end += 1;
305
+ return end;
306
+ }
307
+
308
+ /**
309
+ * An offset as an LSP position. LSP counts characters in UTF-16 code units by
310
+ * default, which is what JavaScript string offsets already are.
311
+ * @param {PositionIndex} index
312
+ * @param {number} offset
313
+ * @returns {{ line: number, character: number }}
314
+ */
315
+ export function offsetToPosition(index, offset) {
316
+ const starts = index.lineStarts;
317
+ let low = 0;
318
+ let high = starts.length - 1;
319
+ while (low < high) {
320
+ const mid = (low + high + 1) >> 1;
321
+ if (starts[mid] <= offset) low = mid;
322
+ else high = mid - 1;
323
+ }
324
+ return { line: low, character: offset - starts[low] };
325
+ }
326
+
327
+ /**
328
+ * @param {PositionIndex} index
329
+ * @param {{ start: number, end: number }} range
330
+ * @returns {{ start: { line: number, character: number }, end: { line: number, character: number } }}
331
+ */
332
+ export function toLspRange(index, range) {
333
+ return { start: offsetToPosition(index, range.start), end: offsetToPosition(index, range.end) };
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Node helpers
338
+
339
+ /**
340
+ * @param {number} start
341
+ * @param {number} end
342
+ * @returns {PosNode}
343
+ */
344
+ function mappingNode(start, end) {
345
+ return { kind: 'mapping', start, end, entries: new Map(), merge: null };
346
+ }
347
+
348
+ /**
349
+ * @param {number} start
350
+ * @param {number} end
351
+ * @returns {PosNode}
352
+ */
353
+ function scalarNode(start, end) {
354
+ return { kind: 'scalar', start, end };
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // YAML: the js-yaml listener
359
+
360
+ /**
361
+ * @typedef {{ open: number, close: number, kind: string | null, tag: string | null, result: unknown, children: RawNode[] }} RawNode
362
+ */
363
+
364
+ /**
365
+ * Rebuild the node tree from js-yaml's open/close events. Every `composeNode`
366
+ * call fires one of each, well nested, with the reader positioned at the node.
367
+ * Keys and values of a mapping are its children in order; items of a sequence
368
+ * likewise. Each pairing is then checked against the value js-yaml itself built
369
+ * ({@link interpretYaml}), so the reconstruction never has to be trusted on the
370
+ * strength of how the loader happens to work.
371
+ * @param {string} text
372
+ * @returns {PosNode | null}
373
+ */
374
+ function scanYaml(text) {
375
+ // js-yaml strips a byte-order mark before reading, so every offset it reports
376
+ // is one short of the text the editor holds.
377
+ const base = text.charCodeAt(0) === 0xFEFF ? 1 : 0;
378
+ const body = base ? text.slice(1) : text;
379
+ /** @type {RawNode[]} */
380
+ const roots = [];
381
+ /** @type {RawNode[]} */
382
+ const stack = [];
383
+ const docs = yaml.loadAll(body, null, {
384
+ listener(type, state) {
385
+ if (type === 'open') {
386
+ /** @type {RawNode} */
387
+ const node = { open: state.position, close: state.position, kind: null, tag: null, result: null, children: [] };
388
+ (stack.length > 0 ? stack[stack.length - 1].children : roots).push(node);
389
+ stack.push(node);
390
+ return;
391
+ }
392
+ const node = stack.pop();
393
+ if (!node) return;
394
+ node.close = state.position;
395
+ node.kind = state.kind;
396
+ node.tag = state.tag;
397
+ node.result = state.result;
398
+ },
399
+ });
400
+ if (!Array.isArray(docs) || roots.length !== docs.length) return null;
401
+
402
+ const present = docs.map((doc, i) => ({ doc, raw: roots[i] })).filter(({ doc }) => doc != null);
403
+ const keys = yamlDocumentKeys(present.map(({ doc }) => doc));
404
+ if (keys === null) return interpretYaml(present[0].raw, body, base);
405
+
406
+ // A multi-document file parses to a synthetic mapping keyed by document
407
+ // identity. The identity is not written anywhere, so each document is named
408
+ // by its first line.
409
+ const root = mappingNode(base, text.length);
410
+ present.forEach(({ raw }, i) => {
411
+ const node = interpretYaml(raw, body, base);
412
+ /** @type {Map<string, PosEntry>} */ (root.entries).set(keys[i], {
413
+ keyStart: node.start,
414
+ keyEnd: lineEndOf(text, node.start),
415
+ node,
416
+ });
417
+ });
418
+ return root;
419
+ }
420
+
421
+ /**
422
+ * A node js-yaml composed and then kept as-is: a block-context scalar is first
423
+ * tried as a mapping key, and on finding no `:` the key's own result becomes the
424
+ * node's. That shows as a node whose only child has the very same result.
425
+ * @param {RawNode} raw
426
+ * @returns {RawNode}
427
+ */
428
+ function unwrap(raw) {
429
+ let node = raw;
430
+ while (
431
+ node.kind !== 'mapping'
432
+ && node.children.length === 1
433
+ && node.children[0].kind === node.kind
434
+ && Object.is(node.children[0].result, node.result)
435
+ ) {
436
+ node = node.children[0];
437
+ }
438
+ return node;
439
+ }
440
+
441
+ /**
442
+ * A key composition that found nothing — how a block mapping discovers it has
443
+ * ended.
444
+ * @param {RawNode} raw
445
+ * @returns {boolean}
446
+ */
447
+ function isEmptyAttempt(raw) {
448
+ return raw.kind === null && (raw.result === null || raw.result === undefined) && raw.open === raw.close;
449
+ }
450
+
451
+ /**
452
+ * @param {RawNode} raw
453
+ * @param {string} body the text js-yaml read
454
+ * @param {number} base offset of `body` within the editor's text
455
+ * @returns {PosNode}
456
+ */
457
+ function interpretYaml(raw, body, base) {
458
+ const node = unwrap(raw);
459
+ if (node.kind === null && (node.result === null || node.result === undefined)) {
460
+ // An empty value has no text of its own. Skipping trivia forward would land
461
+ // on whatever the next line holds, so it stays where it was opened.
462
+ let at = node.open;
463
+ while (at < body.length && (body[at] === ' ' || body[at] === '\t')) at += 1;
464
+ return scalarNode(at + base, at + base);
465
+ }
466
+ const start = skipYamlTrivia(body, node.open);
467
+ const end = Math.max(start, trimEnd(body, node.close));
468
+ const opaque = { kind: /** @type {const} */ ('opaque'), start: start + base, end: end + base };
469
+
470
+ if (node.kind === 'mapping') {
471
+ const children = [...node.children];
472
+ if (children.length % 2 === 1 && isEmptyAttempt(children[children.length - 1])) children.pop();
473
+ if (!isPlainObject(node.result) || children.length % 2 !== 0) return opaque;
474
+ const out = mappingNode(start + base, end + base);
475
+ for (let i = 0; i < children.length; i += 2) {
476
+ const key = unwrap(children[i]);
477
+ const valueRaw = children[i + 1];
478
+ if (key.kind !== 'scalar' || (key.result !== null && typeof key.result === 'object')) return opaque;
479
+ const keyStart = skipYamlTrivia(body, key.open);
480
+ const keyEnd = Math.max(keyStart, trimEnd(body, key.close));
481
+ const child = interpretYaml(valueRaw, body, base);
482
+ if (key.tag === MERGE_TAG) {
483
+ out.merge = { start: keyStart + base, end: Math.max(keyEnd + base, child.end) };
484
+ continue;
485
+ }
486
+ const name = String(key.result);
487
+ // The pairing is only believed if js-yaml put this very value under this
488
+ // very key.
489
+ if (!Object.hasOwn(node.result, name) || !Object.is(node.result[name], valueRaw.result)) return opaque;
490
+ /** @type {Map<string, PosEntry>} */ (out.entries).set(name, {
491
+ keyStart: keyStart + base,
492
+ keyEnd: keyEnd + base,
493
+ node: child,
494
+ });
495
+ }
496
+ return out;
497
+ }
498
+
499
+ if (node.kind === 'sequence') {
500
+ if (!Array.isArray(node.result) || node.result.length !== node.children.length) return opaque;
501
+ /** @type {PosNode[]} */
502
+ const items = [];
503
+ for (let i = 0; i < node.children.length; i++) {
504
+ if (!Object.is(node.result[i], node.children[i].result)) return opaque;
505
+ items.push(interpretYaml(node.children[i], body, base));
506
+ }
507
+ return { kind: 'sequence', start: start + base, end: end + base, items };
508
+ }
509
+
510
+ // Scalars, aliases, and empty values: nothing below them is addressable. An
511
+ // alias's contents live at its anchor, which is a different path.
512
+ return scalarNode(start + base, end + base);
513
+ }
514
+
515
+ /**
516
+ * Skip whitespace and comments forward. js-yaml opens a value node before the
517
+ * separation space in front of it.
518
+ * @param {string} text
519
+ * @param {number} from
520
+ * @returns {number}
521
+ */
522
+ function skipYamlTrivia(text, from) {
523
+ let i = from;
524
+ while (i < text.length) {
525
+ const c = text[i];
526
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\n') {
527
+ i += 1;
528
+ } else if (c === '#' && (i === 0 || /\s/u.test(text[i - 1]))) {
529
+ while (i < text.length && text[i] !== '\n') i += 1;
530
+ } else {
531
+ break;
532
+ }
533
+ }
534
+ return i;
535
+ }
536
+
537
+ /**
538
+ * @param {string} text
539
+ * @param {number} end
540
+ * @returns {number} `end` moved back over trailing whitespace
541
+ */
542
+ function trimEnd(text, end) {
543
+ let i = Math.min(end, text.length);
544
+ while (i > 0 && /\s/u.test(text[i - 1])) i -= 1;
545
+ return i;
546
+ }
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // JSON / JSONC
550
+
551
+ /**
552
+ * Scanned on the comment-blanked text the parser itself reads, which keeps
553
+ * every offset of the original.
554
+ * @param {string} raw
555
+ * @returns {PosNode | null}
556
+ */
557
+ function scanJson(raw) {
558
+ const text = stripJsonComments(raw);
559
+ const state = { text, i: 0 };
560
+ skipJsonSpace(state);
561
+ const root = readJsonValue(state);
562
+ skipJsonSpace(state);
563
+ return state.i === text.length ? root : null;
564
+ }
565
+
566
+ /** @param {{ text: string, i: number }} state */
567
+ function skipJsonSpace(state) {
568
+ while (state.i < state.text.length && /\s/u.test(state.text[state.i])) state.i += 1;
569
+ }
570
+
571
+ /**
572
+ * @param {{ text: string, i: number }} state
573
+ * @returns {number} the offset just past the closing quote
574
+ */
575
+ function readJsonString(state) {
576
+ const { text } = state;
577
+ if (text[state.i] !== '"') throw new Error('expected a string');
578
+ let i = state.i + 1;
579
+ while (i < text.length) {
580
+ if (text[i] === '\\') i += 2;
581
+ else if (text[i] === '"') return (state.i = i + 1);
582
+ else i += 1;
583
+ }
584
+ throw new Error('unterminated string');
585
+ }
586
+
587
+ /**
588
+ * @param {{ text: string, i: number }} state
589
+ * @returns {PosNode}
590
+ */
591
+ function readJsonValue(state) {
592
+ skipJsonSpace(state);
593
+ const { text } = state;
594
+ const start = state.i;
595
+ const c = text[start];
596
+
597
+ if (c === '{') {
598
+ const node = mappingNode(start, start);
599
+ state.i += 1;
600
+ skipJsonSpace(state);
601
+ if (text[state.i] === '}') {
602
+ state.i += 1;
603
+ } else {
604
+ for (;;) {
605
+ skipJsonSpace(state);
606
+ const keyStart = state.i;
607
+ const keyEnd = readJsonString(state);
608
+ const key = JSON.parse(text.slice(keyStart, keyEnd));
609
+ skipJsonSpace(state);
610
+ if (text[state.i] !== ':') throw new Error('expected :');
611
+ state.i += 1;
612
+ const child = readJsonValue(state);
613
+ // JSON.parse keeps the last of duplicated keys; so does a Map.
614
+ /** @type {Map<string, PosEntry>} */ (node.entries).delete(key);
615
+ /** @type {Map<string, PosEntry>} */ (node.entries).set(key, { keyStart, keyEnd, node: child });
616
+ skipJsonSpace(state);
617
+ if (text[state.i] === ',') { state.i += 1; continue; }
618
+ if (text[state.i] === '}') { state.i += 1; break; }
619
+ throw new Error('expected , or }');
620
+ }
621
+ }
622
+ node.end = state.i;
623
+ return node;
624
+ }
625
+
626
+ if (c === '[') {
627
+ /** @type {PosNode[]} */
628
+ const items = [];
629
+ state.i += 1;
630
+ skipJsonSpace(state);
631
+ if (text[state.i] === ']') {
632
+ state.i += 1;
633
+ } else {
634
+ for (;;) {
635
+ items.push(readJsonValue(state));
636
+ skipJsonSpace(state);
637
+ if (text[state.i] === ',') { state.i += 1; continue; }
638
+ if (text[state.i] === ']') { state.i += 1; break; }
639
+ throw new Error('expected , or ]');
640
+ }
641
+ }
642
+ return { kind: 'sequence', start, end: state.i, items };
643
+ }
644
+
645
+ if (c === '"') {
646
+ return scalarNode(start, readJsonString(state));
647
+ }
648
+
649
+ while (state.i < text.length && !/[\s,\]}]/u.test(text[state.i])) state.i += 1;
650
+ if (state.i === start) throw new Error('expected a value');
651
+ return scalarNode(start, state.i);
652
+ }
653
+
654
+ // ---------------------------------------------------------------------------
655
+ // dotenv
656
+
657
+ /**
658
+ * dotenv's own line pattern (dotenv/lib/main.js), with match indices. Reading
659
+ * keys with the parser's exact pattern is what makes the positions agree with
660
+ * it; verification catches a future dotenv that changes it.
661
+ */
662
+ const DOTENV_LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/dgm;
663
+
664
+ /**
665
+ * @param {string} raw
666
+ * @returns {PosNode}
667
+ */
668
+ function scanDotenv(raw) {
669
+ // dotenv normalizes line breaks before matching; map offsets back to the
670
+ // original text, which is what the editor shows.
671
+ const text = raw.replace(/\r\n?/gu, '\n');
672
+ /** @type {number[]} */
673
+ const collapsed = [];
674
+ for (let i = 0, offset = 0; i < raw.length; i++) {
675
+ if (raw[i] === '\r' && raw[i + 1] === '\n') {
676
+ collapsed.push(i - offset);
677
+ offset += 1;
678
+ i += 1;
679
+ }
680
+ }
681
+ const toRaw = (offset) => {
682
+ let low = 0;
683
+ let high = collapsed.length;
684
+ while (low < high) {
685
+ const mid = (low + high) >> 1;
686
+ if (collapsed[mid] < offset) low = mid + 1;
687
+ else high = mid;
688
+ }
689
+ return offset + low;
690
+ };
691
+
692
+ const root = mappingNode(0, raw.length);
693
+ const entries = /** @type {Map<string, PosEntry>} */ (root.entries);
694
+ /** @type {Map<string, number>} */
695
+ const seen = new Map();
696
+ for (const match of text.matchAll(DOTENV_LINE)) {
697
+ const key = match[1];
698
+ const [keyStart, keyEnd] = match.indices[1];
699
+ let [valueStart, valueEnd] = match.indices[2] ?? [keyEnd, keyEnd];
700
+ while (valueStart < valueEnd && /\s/u.test(text[valueStart])) valueStart += 1;
701
+ while (valueEnd > valueStart && /\s/u.test(text[valueEnd - 1])) valueEnd -= 1;
702
+ seen.set(key, (seen.get(key) ?? 0) + 1);
703
+ entries.delete(key);
704
+ entries.set(key, {
705
+ keyStart: toRaw(keyStart),
706
+ keyEnd: toRaw(keyEnd),
707
+ node: scalarNode(toRaw(valueStart), toRaw(valueEnd)),
708
+ check: dotenvValue(match[2]),
709
+ });
710
+ }
711
+ // Only a duplicated key needs its value compared: its position depends on
712
+ // which occurrence the parser kept.
713
+ for (const [key, count] of seen) {
714
+ if (count === 1) delete /** @type {PosEntry} */ (entries.get(key)).check;
715
+ }
716
+ return root;
717
+ }
718
+
719
+ /**
720
+ * dotenv's value handling, so a duplicated key's chosen occurrence can be
721
+ * compared with the value it actually parsed to.
722
+ * @param {string | undefined} raw
723
+ * @returns {string}
724
+ */
725
+ function dotenvValue(raw) {
726
+ let value = (raw || '').trim();
727
+ const quote = value[0];
728
+ value = value.replace(/^(['"`])([\s\S]*)\1$/gmu, '$2');
729
+ if (quote === '"') value = value.replace(/\\n/gu, '\n').replace(/\\r/gu, '\r');
730
+ return value;
731
+ }
732
+
733
+ // ---------------------------------------------------------------------------
734
+ // INI
735
+
736
+ /**
737
+ * Mirrors `parseIni` line for line: a `[section]` groups the keys under it, a
738
+ * repeated section keeps accumulating, the last of a repeated key wins.
739
+ * @param {string} raw
740
+ * @returns {PosNode}
741
+ */
742
+ function scanIni(raw) {
743
+ const root = mappingNode(0, raw.length);
744
+ const rootEntries = /** @type {Map<string, PosEntry>} */ (root.entries);
745
+ let bucket = root;
746
+ const breaks = /\r?\n/gu;
747
+ let lineStart = 0;
748
+ for (;;) {
749
+ const found = breaks.exec(raw);
750
+ const lineEnd = found ? found.index : raw.length;
751
+ const line = raw.slice(lineStart, lineEnd);
752
+ const trimmed = line.trim();
753
+ const trimmedStart = lineStart + (line.length - line.trimStart().length);
754
+
755
+ if (trimmed && !trimmed.startsWith(';') && !trimmed.startsWith('#')) {
756
+ const section = /^\[([^\]]+)\]$/u.exec(trimmed);
757
+ if (section) {
758
+ const name = section[1].trim();
759
+ const nameStart = trimmedStart + 1 + (section[1].length - section[1].trimStart().length);
760
+ const existing = rootEntries.get(name);
761
+ if (existing && existing.node.kind === 'mapping' && existing.node !== root) {
762
+ bucket = existing.node;
763
+ } else {
764
+ bucket = mappingNode(trimmedStart, trimmedStart + trimmed.length);
765
+ rootEntries.set(name, { keyStart: nameStart, keyEnd: nameStart + name.length, node: bucket });
766
+ }
767
+ } else {
768
+ const eq = trimmed.indexOf('=');
769
+ if (eq !== -1) {
770
+ const keyRaw = trimmed.slice(0, eq);
771
+ const key = keyRaw.trim();
772
+ const keyStart = trimmedStart + (keyRaw.length - keyRaw.trimStart().length);
773
+ const valueRaw = trimmed.slice(eq + 1);
774
+ const valueStart = trimmedStart + eq + 1 + (valueRaw.length - valueRaw.trimStart().length);
775
+ const valueEnd = trimmedStart + trimmed.length;
776
+ const entries = /** @type {Map<string, PosEntry>} */ (bucket.entries);
777
+ entries.delete(key);
778
+ entries.set(key, { keyStart, keyEnd: keyStart + key.length, node: scalarNode(valueStart, valueEnd) });
779
+ }
780
+ }
781
+ }
782
+ if (!found) break;
783
+ lineStart = found.index + found[0].length;
784
+ }
785
+ return root;
786
+ }
787
+
788
+ // ---------------------------------------------------------------------------
789
+ // TOML
790
+
791
+ /**
792
+ * A structural scanner for TOML: tables, arrays of tables, dotted and quoted
793
+ * keys, inline tables, arrays, and every string form (so a `#` or `[` inside a
794
+ * string is never read as structure). It does not validate TOML — the real
795
+ * parser already did — and anything it models differently fails verification.
796
+ * @param {string} text
797
+ * @returns {PosNode | null}
798
+ */
799
+ function scanToml(text) {
800
+ const state = { text, i: text.charCodeAt(0) === 0xFEFF ? 1 : 0 };
801
+ const root = mappingNode(0, text.length);
802
+ let table = root;
803
+ for (;;) {
804
+ skipTomlSpace(state, true);
805
+ if (state.i >= text.length) break;
806
+ if (text[state.i] === '[') {
807
+ const headerStart = state.i;
808
+ const isArray = text[state.i + 1] === '[';
809
+ state.i += isArray ? 2 : 1;
810
+ skipTomlSpace(state, false);
811
+ const keys = readTomlKey(state);
812
+ skipTomlSpace(state, false);
813
+ expectToml(state, isArray ? ']]' : ']');
814
+ table = isArray ? openTomlArrayTable(root, keys, headerStart) : openTomlTable(root, keys);
815
+ } else {
816
+ const keys = readTomlKey(state);
817
+ skipTomlSpace(state, false);
818
+ expectToml(state, '=');
819
+ skipTomlSpace(state, false);
820
+ assignToml(table, keys, readTomlValue(state));
821
+ }
822
+ skipTomlSpace(state, false);
823
+ if (state.i < text.length && text[state.i] !== '\n' && text[state.i] !== '\r') {
824
+ throw new Error('expected end of line');
825
+ }
826
+ }
827
+ return root;
828
+ }
829
+
830
+ /**
831
+ * @param {{ text: string, i: number }} state
832
+ * @param {boolean} newlines whether line breaks count as space here
833
+ */
834
+ function skipTomlSpace(state, newlines) {
835
+ const { text } = state;
836
+ while (state.i < text.length) {
837
+ const c = text[state.i];
838
+ if (c === ' ' || c === '\t' || (newlines && (c === '\n' || c === '\r'))) {
839
+ state.i += 1;
840
+ } else if (c === '#') {
841
+ while (state.i < text.length && text[state.i] !== '\n') state.i += 1;
842
+ } else {
843
+ break;
844
+ }
845
+ }
846
+ }
847
+
848
+ /**
849
+ * @param {{ text: string, i: number }} state
850
+ * @param {string} token
851
+ */
852
+ function expectToml(state, token) {
853
+ if (!state.text.startsWith(token, state.i)) throw new Error(`expected ${token}`);
854
+ state.i += token.length;
855
+ }
856
+
857
+ /**
858
+ * A dotted key: bare, "basic", or 'literal' segments.
859
+ * @param {{ text: string, i: number }} state
860
+ * @returns {Array<{ key: string, start: number, end: number }>}
861
+ */
862
+ function readTomlKey(state) {
863
+ const { text } = state;
864
+ const segments = [];
865
+ for (;;) {
866
+ const start = state.i;
867
+ let key;
868
+ if (text[start] === '"') {
869
+ const end = readTomlBasicString(state);
870
+ key = decodeTomlBasic(text.slice(start + 1, end - 1));
871
+ } else if (text[start] === "'") {
872
+ const close = text.indexOf("'", start + 1);
873
+ if (close === -1 || /[\r\n]/u.test(text.slice(start, close))) throw new Error('unterminated key');
874
+ state.i = close + 1;
875
+ key = text.slice(start + 1, close);
876
+ } else {
877
+ while (state.i < text.length && /[A-Za-z0-9_-]/u.test(text[state.i])) state.i += 1;
878
+ if (state.i === start) throw new Error('expected a key');
879
+ key = text.slice(start, state.i);
880
+ }
881
+ segments.push({ key, start, end: state.i });
882
+ skipTomlSpace(state, false);
883
+ if (text[state.i] !== '.') return segments;
884
+ state.i += 1;
885
+ skipTomlSpace(state, false);
886
+ }
887
+ }
888
+
889
+ /**
890
+ * @param {{ text: string, i: number }} state positioned on the opening quote
891
+ * @returns {number} offset just past the closing quote
892
+ */
893
+ function readTomlBasicString(state) {
894
+ const { text } = state;
895
+ let i = state.i + 1;
896
+ while (i < text.length && text[i] !== '\n') {
897
+ if (text[i] === '\\') i += 2;
898
+ else if (text[i] === '"') return (state.i = i + 1);
899
+ else i += 1;
900
+ }
901
+ throw new Error('unterminated string');
902
+ }
903
+
904
+ /**
905
+ * @param {string} inner
906
+ * @returns {string}
907
+ */
908
+ function decodeTomlBasic(inner) {
909
+ const simple = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', e: '\u001B', '"': '"', '\\': '\\' };
910
+ return inner.replace(/\\(?:u([0-9A-Fa-f]{4})|U([0-9A-Fa-f]{8})|x([0-9A-Fa-f]{2})|(.))/gu, (whole, u4, u8, x2, ch) => {
911
+ const hex = u4 ?? u8 ?? x2;
912
+ if (hex) return String.fromCodePoint(Number.parseInt(hex, 16));
913
+ return Object.hasOwn(simple, ch) ? simple[ch] : whole;
914
+ });
915
+ }
916
+
917
+ /**
918
+ * @param {{ text: string, i: number }} state
919
+ * @returns {PosNode}
920
+ */
921
+ function readTomlValue(state) {
922
+ const { text } = state;
923
+ const start = state.i;
924
+
925
+ if (text.startsWith('"""', start) || text.startsWith("'''", start)) {
926
+ const quote = text[start];
927
+ let i = start + 3;
928
+ for (;;) {
929
+ if (i >= text.length) throw new Error('unterminated multi-line string');
930
+ if (quote === '"' && text[i] === '\\') { i += 2; continue; }
931
+ if (text.startsWith(quote.repeat(3), i)) {
932
+ // Up to two quotes may sit against the closing delimiter as content.
933
+ let end = i + 3;
934
+ while (end - i < 5 && text[end] === quote) end += 1;
935
+ state.i = end;
936
+ return scalarNode(start, end);
937
+ }
938
+ i += 1;
939
+ }
940
+ }
941
+ if (text[start] === '"') return scalarNode(start, readTomlBasicString(state));
942
+ if (text[start] === "'") {
943
+ const close = text.indexOf("'", start + 1);
944
+ if (close === -1 || /[\r\n]/u.test(text.slice(start, close))) throw new Error('unterminated string');
945
+ state.i = close + 1;
946
+ return scalarNode(start, state.i);
947
+ }
948
+
949
+ if (text[start] === '[') {
950
+ /** @type {PosNode[]} */
951
+ const items = [];
952
+ state.i += 1;
953
+ for (;;) {
954
+ skipTomlSpace(state, true);
955
+ if (text[state.i] === ']') { state.i += 1; break; }
956
+ items.push(readTomlValue(state));
957
+ skipTomlSpace(state, true);
958
+ if (text[state.i] === ',') { state.i += 1; continue; }
959
+ if (text[state.i] === ']') { state.i += 1; break; }
960
+ throw new Error('expected , or ]');
961
+ }
962
+ return { kind: 'sequence', start, end: state.i, items };
963
+ }
964
+
965
+ if (text[start] === '{') {
966
+ const node = mappingNode(start, start);
967
+ state.i += 1;
968
+ for (;;) {
969
+ skipTomlSpace(state, true);
970
+ if (text[state.i] === '}') { state.i += 1; break; }
971
+ const keys = readTomlKey(state);
972
+ skipTomlSpace(state, false);
973
+ expectToml(state, '=');
974
+ skipTomlSpace(state, false);
975
+ assignToml(node, keys, readTomlValue(state));
976
+ skipTomlSpace(state, true);
977
+ if (text[state.i] === ',') { state.i += 1; continue; }
978
+ if (text[state.i] === '}') { state.i += 1; break; }
979
+ throw new Error('expected , or }');
980
+ }
981
+ node.end = state.i;
982
+ return node;
983
+ }
984
+
985
+ // Numbers, booleans, and dates — a datetime may contain one space.
986
+ while (state.i < text.length && !/[,\]}#\r\n]/u.test(text[state.i])) state.i += 1;
987
+ let end = state.i;
988
+ while (end > start && /[ \t]/u.test(text[end - 1])) end -= 1;
989
+ if (end === start) throw new Error('expected a value');
990
+ state.i = end;
991
+ return scalarNode(start, end);
992
+ }
993
+
994
+ /**
995
+ * The mapping a key segment names under `parent`, created on first mention. A
996
+ * path through an array of tables continues in its latest element, as TOML
997
+ * specifies.
998
+ * @param {PosNode} parent
999
+ * @param {{ key: string, start: number, end: number }} segment
1000
+ * @returns {PosNode}
1001
+ */
1002
+ function tomlChild(parent, segment) {
1003
+ const entries = /** @type {Map<string, PosEntry>} */ (parent.entries);
1004
+ let entry = entries.get(segment.key);
1005
+ if (!entry) {
1006
+ entry = { keyStart: segment.start, keyEnd: segment.end, node: mappingNode(segment.start, segment.end) };
1007
+ entries.set(segment.key, entry);
1008
+ }
1009
+ const node = entry.node.kind === 'sequence'
1010
+ ? /** @type {PosNode[]} */ (entry.node.items)[/** @type {PosNode[]} */ (entry.node.items).length - 1]
1011
+ : entry.node;
1012
+ if (!node || node.kind !== 'mapping') throw new Error('not a table');
1013
+ return node;
1014
+ }
1015
+
1016
+ /**
1017
+ * @param {PosNode} root
1018
+ * @param {Array<{ key: string, start: number, end: number }>} keys
1019
+ * @returns {PosNode}
1020
+ */
1021
+ function openTomlTable(root, keys) {
1022
+ let table = root;
1023
+ for (const segment of keys) table = tomlChild(table, segment);
1024
+ return table;
1025
+ }
1026
+
1027
+ /**
1028
+ * @param {PosNode} root
1029
+ * @param {Array<{ key: string, start: number, end: number }>} keys
1030
+ * @param {number} headerStart
1031
+ * @returns {PosNode} the new element, which is the table keys now go into
1032
+ */
1033
+ function openTomlArrayTable(root, keys, headerStart) {
1034
+ const parent = openTomlTable(root, keys.slice(0, -1));
1035
+ const last = keys[keys.length - 1];
1036
+ const entries = /** @type {Map<string, PosEntry>} */ (parent.entries);
1037
+ let entry = entries.get(last.key);
1038
+ if (!entry) {
1039
+ entry = { keyStart: last.start, keyEnd: last.end, node: { kind: 'sequence', start: headerStart, end: headerStart, items: [] } };
1040
+ entries.set(last.key, entry);
1041
+ }
1042
+ if (entry.node.kind !== 'sequence') throw new Error('not an array of tables');
1043
+ const element = mappingNode(headerStart, headerStart);
1044
+ /** @type {PosNode[]} */ (entry.node.items).push(element);
1045
+ return element;
1046
+ }
1047
+
1048
+ /**
1049
+ * @param {PosNode} table
1050
+ * @param {Array<{ key: string, start: number, end: number }>} keys
1051
+ * @param {PosNode} value
1052
+ */
1053
+ function assignToml(table, keys, value) {
1054
+ const parent = openTomlTable(table, keys.slice(0, -1));
1055
+ const last = keys[keys.length - 1];
1056
+ /** @type {Map<string, PosEntry>} */ (parent.entries).set(last.key, { keyStart: last.start, keyEnd: last.end, node: value });
1057
+ }