flecto 3.1.0 → 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.
- package/CHANGELOG.md +190 -0
- package/README.md +8 -0
- package/drift.js +226 -0
- package/index.js +527 -31
- package/package.json +10 -7
- package/src/alerter.js +272 -24
- package/src/config.js +208 -2
- package/src/drift-sources.js +444 -0
- package/src/explain.js +706 -0
- package/src/lsp-analysis.js +397 -0
- package/src/lsp-worker.js +13 -0
- package/src/lsp.js +407 -0
- package/src/mcp.js +487 -0
- package/src/parser.js +24 -14
- package/src/policy.js +71 -47
- package/src/positions.js +1057 -0
- package/src/pr-comment.js +33 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +24 -0
- package/src/snapshot-store.js +6 -2
package/src/positions.js
ADDED
|
@@ -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
|
+
}
|