@nette/neon 0.0.0-stage → 1.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,1188 @@
1
+ // The editing operations of the tree. The node classes expose them as methods.
2
+ import { Builder } from "./builder.js";
3
+ import { encode } from "./encoder.js";
4
+ import { NeonError } from "./errors.js";
5
+ import { setItems, setLeading, setSlots, setTrailing } from "./internal.js";
6
+ import { Layout } from "./layout.js";
7
+ import { ArrayItemNode, ArrayNode, BlockArrayNode, DocumentNode, EntityChainNode, EntityNode, InlineArrayNode, itemKeys, LiteralNode, StringNode, ValueNode, } from "./nodes.js";
8
+ import { findNeighbour, Token, Trivia } from "./token.js";
9
+ import { Chain, Entity, entriesOf, literalKey, MaxDepth, NeonDate, valuesEqual } from "./values.js";
10
+ /** What happens to the comments a removed item owns: the one on its line and those directly above it; the comments inside its value go with it. */
11
+ export const CommentPolicy = {
12
+ /** The comments that describe the item go with it. */
13
+ Drop: 'drop',
14
+ /** The comments stay, above whatever follows. */
15
+ MoveToNextToken: 'move',
16
+ };
17
+ // ---- values of items
18
+ /** Writes the value of an item; an equal value is a no-op, a changed one keeps the style where it can. */
19
+ export function setItemValue(item, value) {
20
+ const old = item.value;
21
+ if (value instanceof ValueNode) {
22
+ setItemNode(item, value);
23
+ return;
24
+ }
25
+ else if (old ? sameValue(value, old) : value === null) {
26
+ return;
27
+ }
28
+ else if (value === null && !item.colon && !item.bullet) {
29
+ replaceValue(item, literal('null')); // an inline item without a key cannot lose its value
30
+ }
31
+ else if (value === null) {
32
+ replaceValue(item, null);
33
+ }
34
+ else {
35
+ replaceValue(item, styledScalar(old, value) ?? Builder.value(value, placeOf(item, old, value)));
36
+ }
37
+ guardDashSubblocks(item.parent);
38
+ }
39
+ /** Puts the node into the value slot or empties it, without the comparison and styling of setItemValue(). */
40
+ export function setItemNode(item, node) {
41
+ replaceValue(item, node);
42
+ guardDashSubblocks(item.parent);
43
+ }
44
+ /**
45
+ * A dash right after a key at the same indentation reads as a dash subblock of that key.
46
+ * So a key without a value followed by a dash gets an explicit null, and a dash right after a dash
47
+ * subblock gets its key written out, which ends the subblock.
48
+ */
49
+ function guardDashSubblocks(block) {
50
+ if (!(block instanceof BlockArrayNode)) {
51
+ return;
52
+ }
53
+ guardEnd(block);
54
+ block.items.forEach((item, i) => {
55
+ const next = block.items[i + 1];
56
+ if (!item.key || !next?.bullet) {
57
+ return;
58
+ }
59
+ else if (item.value === null) {
60
+ replaceValue(item, literal('null'));
61
+ }
62
+ else if (item.value instanceof BlockArrayNode && Layout.indentation(item.value.items[0]) === Layout.indentation(item)) {
63
+ giveKey(next, itemKeys(block.items)[i + 1]);
64
+ }
65
+ });
66
+ }
67
+ /** Spaces on the last line of the input would be read as the indentation of a missing value at the end. */
68
+ function guardEnd(at) {
69
+ let root = at;
70
+ while (root.parent) {
71
+ root = root.parent;
72
+ }
73
+ if (!(root instanceof DocumentNode)) {
74
+ return;
75
+ }
76
+ const end = root.endOfFile;
77
+ const last = tokenBefore(end);
78
+ const item = last?.parent;
79
+ if (item instanceof ArrayItemNode && item.value === null && end.leadingTrivia.at(-1)?.kind === 'Whitespace') {
80
+ lead(end, withoutIndentation(end.leadingTrivia));
81
+ }
82
+ }
83
+ /** Where a value of the item is built; a replaced block keeps the indentation it had. */
84
+ function placeOf(item, old, value) {
85
+ const indent = Layout.indentation(item);
86
+ const blockIndent = old instanceof BlockArrayNode ? Layout.indentation(old.items[0]) : null;
87
+ const unit = blockIndent?.startsWith(indent) && blockIndent.length > indent.length
88
+ ? blockIndent.slice(indent.length)
89
+ : Layout.unit(item);
90
+ return { block: item.parent instanceof BlockArrayNode, indent, unit, eol: Layout.lineEnding(item), doubleQuoted: hasFragileString(item, value) };
91
+ }
92
+ /** Puts a node into the value slot, moving the trivia so that the lines stay as they were. */
93
+ function replaceValue(item, node) {
94
+ const old = item.value;
95
+ const head = item.colon ?? item.bullet;
96
+ if (node === old) {
97
+ return;
98
+ }
99
+ else if (node instanceof BlockArrayNode && (item.parent instanceof InlineArrayNode || !head)) {
100
+ throw new NeonError('A block cannot stand in an inline array.');
101
+ }
102
+ else if (node) {
103
+ checkPlaceable(item, node);
104
+ }
105
+ else if (!head) {
106
+ throw new NeonError('The value of an item without a key cannot be empty.');
107
+ }
108
+ if (!head) { // an item of an inline array without a key: the node takes the place of the old one
109
+ takeEdges(node, old);
110
+ item[setSlots]({ value: node });
111
+ return;
112
+ }
113
+ const headEnds = hasLineEnding(head.trailingTrivia);
114
+ const oldOnOwnLine = old !== null && headEnds;
115
+ const finalLineEnding = old ? hasLineEnding(old.getLastToken().trailingTrivia) : headEnds;
116
+ if (node === null) {
117
+ if (!oldOnOwnLine) {
118
+ trail(head, old.getLastToken().trailingTrivia);
119
+ }
120
+ else if (!finalLineEnding) {
121
+ trail(head, withoutLineEnding(head.trailingTrivia));
122
+ }
123
+ item[setSlots]({ value: null });
124
+ const end = tokenAfter(head);
125
+ if (end?.kind === 'EndOfFile') { // spaces on a last line would be read as the indentation of the missing value
126
+ lead(end, withoutIndentation(end.leadingTrivia));
127
+ }
128
+ return;
129
+ }
130
+ const eol = Layout.lineEnding(head);
131
+ if (node instanceof BlockArrayNode) {
132
+ const place = placeOf(item, old, null);
133
+ reindentBlock(node, place.indent + place.unit);
134
+ if (old && !oldOnOwnLine) {
135
+ trail(head, old.getLastToken().trailingTrivia);
136
+ }
137
+ if (!hasLineEnding(head.trailingTrivia)) {
138
+ trail(head, [...withoutTrailingSpace(head.trailingTrivia), new Trivia('LineEnding', eol)]);
139
+ }
140
+ if (old instanceof BlockArrayNode && oldOnOwnLine) { // the lines above the old block stay
141
+ const first = node.getFirstToken();
142
+ lead(first, joinTrivia(withoutIndentation(old.getFirstToken().leadingTrivia), first.leadingTrivia));
143
+ }
144
+ trail(node.getLastToken(), finalLineEnding ? [new Trivia('LineEnding', eol)] : []);
145
+ item[setSlots]({ value: node });
146
+ return;
147
+ }
148
+ if (old && !(old instanceof BlockArrayNode)) { // the token takes the place of the old one, on its line
149
+ takeEdges(node, old);
150
+ }
151
+ else {
152
+ const trailing = old && !oldOnOwnLine // a block opened on the line of a dash ends the line itself
153
+ ? old.getLastToken().trailingTrivia
154
+ : head.trailingTrivia; // the line ending, and a comment, of the head move after the value
155
+ lead(node.getFirstToken(), []);
156
+ trail(node.getLastToken(), finalLineEnding ? trailing : withoutLineEnding(trailing));
157
+ trail(head, [new Trivia('Whitespace', ' ')]);
158
+ }
159
+ item[setSlots]({ value: node });
160
+ }
161
+ /** A node that comes into a slot of the owner must be free, and must not hold the owner. */
162
+ function checkPlaceable(owner, node) {
163
+ checkMovable(owner, node);
164
+ if (node instanceof EntityNode && !node.attributes) {
165
+ throw new TypeError('An entity without parentheses can only be a link of a chain.');
166
+ }
167
+ checkDepth(owner, node);
168
+ }
169
+ function checkMovable(owner, node) {
170
+ if (node.parent) {
171
+ throw new Error('The node already has a parent, detach() or clone() it first.');
172
+ }
173
+ for (let ancestor = owner; ancestor; ancestor = ancestor.parent) {
174
+ if (ancestor === node) {
175
+ throw new Error('A node cannot be put into its own subtree.');
176
+ }
177
+ }
178
+ }
179
+ /** The arrays around the owner and in the node must not nest deeper than the parser reads. */
180
+ function checkDepth(owner, node) {
181
+ let around = 0;
182
+ for (let ancestor = owner; ancestor; ancestor = ancestor.parent) {
183
+ around += ancestor instanceof ArrayNode ? 1 : 0;
184
+ }
185
+ const depth = (n) => (n instanceof ArrayNode ? 1 : 0)
186
+ + Math.max(0, ...n.getChildren().map((child) => child instanceof ArrayItemNode || child instanceof ValueNode ? depth(child) : 0));
187
+ if (node && around + depth(node) > MaxDepth) {
188
+ throw new NeonError('Too deep nesting');
189
+ }
190
+ }
191
+ /** Shifts the indentation of all lines of a block, so that its items stand at the given one. */
192
+ function reindentBlock(block, to) {
193
+ const items = block.items;
194
+ const from = (items[1] ?? items[0]).getIndentation(); // the first item may stand on the line of a dash
195
+ reindent(block, from, to);
196
+ const first = block.getFirstToken();
197
+ lead(first, [...withoutIndentation(first.leadingTrivia), ...indentTrivia(to)]);
198
+ }
199
+ /** The new node takes the leading trivia of the first token and the trailing trivia of the last one. */
200
+ function takeEdges(node, old) {
201
+ lead(node.getFirstToken(), old.getFirstToken().leadingTrivia);
202
+ trail(node.getLastToken(), old.getLastToken().trailingTrivia);
203
+ }
204
+ /** Equality of values between a value of the host and the value of a node; an array compares in the order of the document. */
205
+ function sameValue(value, node) {
206
+ if (!(node instanceof ArrayNode)) {
207
+ return valuesEqual(value, node.toValue());
208
+ }
209
+ const host = entriesOf(value);
210
+ if (!host) {
211
+ return false;
212
+ }
213
+ const keys = itemKeys(node.items);
214
+ const entries = (new Map);
215
+ node.items.forEach((item, i) => entries.set(keys[i], item)); // an overwritten key keeps its first position
216
+ const list = entries.size ? [...entries.keys()].every((key, i) => key === String(i)) : Array.isArray(node.toValue());
217
+ if (host.list !== list || host.entries.length !== entries.size) {
218
+ return false;
219
+ }
220
+ const ordered = [...entries];
221
+ return host.entries.every(([key, v], i) => {
222
+ const item = host.ordered ? (ordered[i][0] === key ? ordered[i][1] : undefined) : entries.get(key);
223
+ return item !== undefined && (item.value ? sameValue(v, item.value) : v === null);
224
+ });
225
+ }
226
+ /** A scalar written in the style of the old one: base of an integer, word of a boolean, quotes of a string. */
227
+ function styledScalar(old, value) {
228
+ if (old instanceof LiteralNode) {
229
+ const text = old.token.text;
230
+ const base = /^0([xob])([0-9a-fA-F]+)$/.exec(text);
231
+ if (base && (typeof value === 'bigint' || (typeof value === 'number' && Number.isSafeInteger(value))) && value >= 0) {
232
+ const radix = { x: 16, o: 8, b: 2 }[base[1]];
233
+ let digits = value.toString(radix).padStart(base[2].length, '0');
234
+ digits = /[A-F]/.test(base[2]) ? digits.toUpperCase() : digits;
235
+ return literal(`0${base[1]}${digits}`);
236
+ }
237
+ else if (typeof value === 'boolean') {
238
+ const word = BooleanWords.find(([yes, no]) => text.toLowerCase() === yes || text.toLowerCase() === no);
239
+ if (word) {
240
+ const res = word[value ? 0 : 1];
241
+ return literal(text === text.toUpperCase() ? res.toUpperCase() : /^[A-Z]/.test(text) ? res[0].toUpperCase() + res.slice(1) : res);
242
+ }
243
+ }
244
+ else if (typeof value === 'string' && /^[^\n]*$/.test(value) && literalIsPlainString(old)) {
245
+ const res = encode(value);
246
+ return res === value ? literal(res) : null;
247
+ }
248
+ }
249
+ else if (old instanceof StringNode && typeof value === 'string') {
250
+ const quote = old.token.text[0];
251
+ const multiline = /^('''|""")\r?\n/.test(old.token.text);
252
+ if (!multiline && !value.includes('\n') && value.isWellFormed()) {
253
+ if (quote === '\'' && !/[\x00-\x08\x0B-\x1F]/.test(value)) { // eslint-disable-line no-control-regex
254
+ return string('\'' + value.replaceAll('\'', '\'\'') + '\'');
255
+ }
256
+ else if (quote === '"') {
257
+ return string(JSON.stringify(value).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029'));
258
+ }
259
+ }
260
+ }
261
+ return null;
262
+ }
263
+ const BooleanWords = [['true', 'false'], ['yes', 'no']];
264
+ function literalIsPlainString(node) {
265
+ return typeof node.toValue() === 'string';
266
+ }
267
+ function literal(text) {
268
+ return new LiteralNode(Token.fromText(text));
269
+ }
270
+ function string(text) {
271
+ return new StringNode(Token.fromText(text));
272
+ }
273
+ // ---- adding
274
+ /** Adds an item to an array, at the end or before the item at the position. */
275
+ export function addItem(array, key, value, position) {
276
+ const items = array.items;
277
+ const at = position ?? items.length;
278
+ if (at < 0 || at > items.length || !Number.isInteger(at)) {
279
+ throw new RangeError(`Position ${at} is out of the array.`);
280
+ }
281
+ else if (key !== null && array.findItem(key)) {
282
+ throw new NeonError(`Duplicated key '${key}'`);
283
+ }
284
+ else if (value instanceof ValueNode) {
285
+ if (value instanceof BlockArrayNode && array instanceof InlineArrayNode) {
286
+ throw new NeonError('A block cannot stand in an inline array.');
287
+ }
288
+ checkPlaceable(array, value);
289
+ }
290
+ const before = keysBefore(array);
291
+ const item = array instanceof BlockArrayNode
292
+ ? addToBlock(array, key, value, at)
293
+ : addToInline(array, key, value, at);
294
+ keepKeys(array, before, key === null);
295
+ guardDashSubblocks(array);
296
+ return item;
297
+ }
298
+ /** The keys of the items before an edit, and whether they made a list. */
299
+ function keysBefore(array) {
300
+ const keys = itemKeys(array.items);
301
+ return {
302
+ keys: new Map(array.items.map((item, i) => [item, keys[i]])),
303
+ list: listLength(array, keys) !== null,
304
+ };
305
+ }
306
+ /**
307
+ * The length of the list the items make in the decoded array, where an overwritten key keeps the place of its first
308
+ * item; null when they make no list.
309
+ */
310
+ function listLength(array, keys) {
311
+ if (!keys.length) {
312
+ return Array.isArray(array.toValue()) ? 0 : null; // `{}` is no list
313
+ }
314
+ const seen = (new Set);
315
+ for (const key of keys) {
316
+ if (!seen.has(key)) {
317
+ if (key !== String(seen.size)) {
318
+ return null;
319
+ }
320
+ seen.add(key);
321
+ }
322
+ }
323
+ return seen.size;
324
+ }
325
+ /**
326
+ * An edit must not change the keys of the other items. In a list, an item added or removed without a
327
+ * key renumbers the following ones, which is what a list means; anywhere else, an item whose implicit
328
+ * key would change gets its old key written out (`{5: a, b}` without `5: a` is `{6: b}`).
329
+ */
330
+ function keepKeys(array, before, keyless) {
331
+ if (before.list && keyless) {
332
+ itemKeys(array.items); // an implicit key out of range fails here
333
+ return;
334
+ }
335
+ for (let changed = true; changed;) {
336
+ changed = false;
337
+ const keys = itemKeys(array.items);
338
+ for (const [i, item] of array.items.entries()) {
339
+ const old = before.keys.get(item);
340
+ if (old !== undefined && !item.key && keys[i] !== old && giveKey(item, old)) {
341
+ changed = true;
342
+ break; // the key changes the implicit keys after it
343
+ }
344
+ }
345
+ }
346
+ }
347
+ /** Writes the key out: `- b` becomes `6: b`, an inline `b` becomes `6: b`. */
348
+ function giveKey(item, key) {
349
+ const [, keyNode, colon] = headTokens(key);
350
+ if (item.bullet) {
351
+ lead(keyNode.getFirstToken(), item.bullet.leadingTrivia);
352
+ if (item.value instanceof BlockArrayNode && !hasLineEnding(item.bullet.trailingTrivia)) {
353
+ // a block opened on the line of the dash moves to the lines below the key
354
+ const first = item.value.items[0];
355
+ lead(first.getFirstToken(), indentTrivia(Layout.indentation(first)));
356
+ trail(colon, [new Trivia('LineEnding', Layout.lineEnding(item))]);
357
+ }
358
+ else {
359
+ trail(colon, item.bullet.trailingTrivia);
360
+ }
361
+ item[setSlots]({ bullet: null, key: keyNode, colon });
362
+ }
363
+ else {
364
+ const first = item.getFirstToken();
365
+ lead(keyNode.getFirstToken(), first.leadingTrivia);
366
+ lead(first, []);
367
+ item[setSlots]({ key: keyNode, colon });
368
+ }
369
+ return true;
370
+ }
371
+ function addToBlock(block, key, value, at) {
372
+ const items = block.items;
373
+ const sibling = items[Math.min(at, items.length - 1)];
374
+ const indent = Layout.indentation(sibling);
375
+ const eol = Layout.lineEnding(sibling);
376
+ if (value instanceof ValueNode) { // the head first, then the node comes in as the value of a placed item
377
+ const item = Builder.blockItem(key, key === null ? 0 : null, { block: true, indent, unit: Layout.unit(block), eol });
378
+ placeInBlock(block, item, at, indent, eol);
379
+ replaceValue(item, value);
380
+ return item;
381
+ }
382
+ const item = Builder.blockItem(key, value, { block: true, indent, unit: Layout.unit(block), eol, doubleQuoted: hasFragileString(block, value) });
383
+ checkDepth(block, item.value);
384
+ placeInBlock(block, item, at, indent, eol);
385
+ return item;
386
+ }
387
+ /** Puts a finished item into a block; its first token holds its comments and its indentation. */
388
+ function placeInBlock(block, item, at, indent, eol) {
389
+ const items = block.items;
390
+ const sibling = items[Math.min(at, items.length - 1)];
391
+ if (at === items.length) {
392
+ const last = sibling.getLastToken();
393
+ if (!hasLineEnding(last.trailingTrivia)) { // a missing final line ending stays missing
394
+ trail(last, [...last.trailingTrivia, new Trivia('LineEnding', eol)]);
395
+ trail(item.getLastToken(), []);
396
+ }
397
+ }
398
+ else if (!startsLine(sibling)) { // the first item of a block opened on the line of a dash
399
+ lead(item.getFirstToken(), []);
400
+ lead(sibling.getFirstToken(), indentTrivia(indent));
401
+ }
402
+ else {
403
+ const { preamble, owned } = splitLeading(sibling.getFirstToken().leadingTrivia);
404
+ lead(item.getFirstToken(), [...preamble, ...item.getFirstToken().leadingTrivia]);
405
+ lead(sibling.getFirstToken(), owned);
406
+ }
407
+ block[setItems]([...items.slice(0, at), item, ...items.slice(at)]);
408
+ }
409
+ /**
410
+ * Inserts an item taken out of a tree, with its comments, reindented to its new place: the common
411
+ * prefix of the indentation of all its lines moves. The lines inside a multiline string are part
412
+ * of its token and its value, so they stay.
413
+ */
414
+ export function insertItem(array, item, at) {
415
+ if (!(item instanceof ArrayItemNode)) {
416
+ throw new TypeError('ArrayItemNode expected.');
417
+ }
418
+ else if (item.parent) {
419
+ throw new Error('The item already has a parent, remove() it first.');
420
+ }
421
+ else if (at < 0 || at > array.items.length || !Number.isInteger(at)) {
422
+ throw new RangeError(`Position ${at} is out of the array.`);
423
+ }
424
+ else if (array instanceof InlineArrayNode && (item.bullet || item.value instanceof BlockArrayNode)) {
425
+ throw new NeonError('A block item cannot stand in an inline array.');
426
+ }
427
+ else if (array instanceof BlockArrayNode && !item.bullet && !item.key) {
428
+ throw new NeonError('An item without a key or a dash cannot stand in a block.');
429
+ }
430
+ checkPlaceable(array, item);
431
+ const key = item.getKeyValue();
432
+ if (key !== null && array.findItem(key)) {
433
+ throw new NeonError(`Duplicated key '${key}'`);
434
+ }
435
+ const comments = array instanceof InlineArrayNode ? edgeComments(item) : [];
436
+ if (comments.length && !isSpread(array)) {
437
+ throw new NeonError('An item with comments cannot stand in an inline array on one line.');
438
+ }
439
+ const before = keysBefore(array);
440
+ if (array instanceof BlockArrayNode) {
441
+ const sibling = array.items[Math.min(at, array.items.length - 1)];
442
+ const indent = Layout.indentation(sibling);
443
+ const eol = Layout.lineEnding(sibling);
444
+ reindent(item, item.getIndentation(), indent);
445
+ if (item.separator) { // a clone of an item of an inline array, with its comma
446
+ const trailing = withoutTrailingSpace(withoutLineEnding(item.separator.trailingTrivia));
447
+ item[setSlots]({ separator: null });
448
+ trail(item.getLastToken(), trailing);
449
+ }
450
+ trail(item.getLastToken(), [...withoutLineEnding(item.getLastToken().trailingTrivia), new Trivia('LineEnding', eol)]);
451
+ placeInBlock(array, item, at, indent, eol);
452
+ }
453
+ else {
454
+ lead(item.getFirstToken(), []);
455
+ trail(item.getLastToken(), []);
456
+ if (item.colon && item.value && hasLineEnding(item.colon.trailingTrivia)) { // in brackets, a line ending after the colon means null
457
+ trail(item.colon, [new Trivia('Whitespace', ' ')]);
458
+ lead(item.value.getFirstToken(), []);
459
+ }
460
+ item[setSlots]({ separator: null });
461
+ addToInline(array, null, item, at);
462
+ if (comments.length) {
463
+ placeCommentsAbove(item, comments);
464
+ }
465
+ }
466
+ keepKeys(array, before, key === null);
467
+ guardDashSubblocks(array);
468
+ }
469
+ /**
470
+ * The comments an item owns at its edges, which an inline array cannot keep in place: the comment lines
471
+ * directly above it, the comments between its key and a value on a later line, and the comment on its line.
472
+ */
473
+ function edgeComments(item) {
474
+ const comments = withoutIndentation(splitLeading(item.getFirstToken().leadingTrivia).owned);
475
+ if (item.colon && item.value && hasLineEnding(item.colon.trailingTrivia)) {
476
+ comments.push(...item.colon.trailingTrivia, ...item.value.getFirstToken().leadingTrivia);
477
+ }
478
+ comments.push(...item.getLastToken().trailingTrivia);
479
+ return comments.filter((t) => t.kind === 'Comment');
480
+ }
481
+ /** Writes the comments as lines of their own above the item, which an inline array spread over lines holds. */
482
+ function placeCommentsAbove(item, comments) {
483
+ const first = item.getFirstToken();
484
+ const eol = Layout.lineEnding(item);
485
+ const before = tokenBefore(first); // the opening bracket, or a comma
486
+ if (!hasLineEnding(before.trailingTrivia)) {
487
+ const indentation = item.parent instanceof InlineArrayNode ? itemIndentation(item.parent) : '';
488
+ trail(before, [...withoutTrailingSpace(before.trailingTrivia), new Trivia('LineEnding', eol)]);
489
+ lead(first, indentTrivia(indentation));
490
+ }
491
+ const { preamble, owned } = splitLeading(first.leadingTrivia);
492
+ const indent = indentationOf(owned);
493
+ const lines = comments.flatMap((comment) => [...indentTrivia(indent), comment, new Trivia('LineEnding', eol)]);
494
+ lead(first, [...preamble, ...withoutIndentation(owned), ...lines, ...indentTrivia(indent)]);
495
+ }
496
+ /** Replaces the indentation prefix of every line of the node, its comment lines included. */
497
+ function reindent(node, from, to) {
498
+ const tokens = node.getTokens();
499
+ tokens.forEach((token, k) => {
500
+ let atLineStart = k === 0 || hasLineEnding(tokens[k - 1].trailingTrivia);
501
+ const trivia = [];
502
+ for (const t of token.leadingTrivia) {
503
+ if (atLineStart && t.kind === 'Whitespace' && t.text.startsWith(from)) {
504
+ trivia.push(...indentTrivia(to + t.text.slice(from.length)));
505
+ }
506
+ else {
507
+ if (atLineStart && from === '' && t.kind !== 'LineEnding' && t.kind !== 'ByteOrderMark') {
508
+ trivia.push(...indentTrivia(to)); // a line without indentation gets the new one
509
+ }
510
+ trivia.push(t);
511
+ }
512
+ atLineStart = t.kind === 'LineEnding';
513
+ }
514
+ if (atLineStart && from === '') { // the line of the token itself
515
+ trivia.push(...indentTrivia(to));
516
+ }
517
+ lead(token, joinAll(trivia));
518
+ });
519
+ }
520
+ function headTokens(key) {
521
+ if (key === null) {
522
+ return [withSpace(Token.fromText('-')), null, null];
523
+ }
524
+ const keyText = Builder.keyText(key);
525
+ return [null, keyText[0] === '\'' || keyText[0] === '"' ? string(keyText) : literal(keyText), withSpace(Token.fromText(':'))];
526
+ }
527
+ function withSpace(token) {
528
+ return trail(token, [new Trivia('Whitespace', ' ')]);
529
+ }
530
+ function addToInline(array, key, value, at) {
531
+ const items = array.items;
532
+ const multiline = isSpread(array);
533
+ const eol = Layout.lineEnding(array);
534
+ const indentation = itemIndentation(array);
535
+ let item;
536
+ if (value instanceof ArrayItemNode) { // a finished item, inserted
537
+ item = value;
538
+ }
539
+ else {
540
+ const node = value instanceof ValueNode ? value : Builder.value(value, { block: false, indent: indentation, unit: Layout.unit(array), eol, doubleQuoted: hasFragileString(array, value) });
541
+ if (value instanceof ValueNode) {
542
+ lead(node.getFirstToken(), []);
543
+ trail(node.getLastToken(), []);
544
+ }
545
+ const [, keyNode, colon] = key === null ? [null, null, null] : headTokens(key);
546
+ checkDepth(array, node);
547
+ item = new ArrayItemNode(null, keyNode, colon, node);
548
+ }
549
+ const space = () => multiline ? [new Trivia('LineEnding', eol)] : [new Trivia('Whitespace', ' ')];
550
+ if (!items.length) {
551
+ // nothing to separate
552
+ }
553
+ else if (at === items.length) {
554
+ const last = items.at(-1);
555
+ if (last.separator) { // a trailing comma: the new item gets one too
556
+ const comma = trail(Token.fromText(','), last.separator.trailingTrivia);
557
+ trail(last.separator, space());
558
+ item[setSlots]({ separator: comma });
559
+ }
560
+ else {
561
+ const lastToken = last.getLastToken();
562
+ const lineEnd = multiline && hasLineEnding(lastToken.trailingTrivia); // else the bracket closes the line of the last item
563
+ trail(item.getLastToken(), lineEnd ? [new Trivia('LineEnding', eol)] : lastToken.trailingTrivia);
564
+ const comma = trail(Token.fromText(','), lineEnd ? lastToken.trailingTrivia : multiline ? [new Trivia('LineEnding', eol)] : [new Trivia('Whitespace', ' ')]);
565
+ trail(lastToken, []);
566
+ last[setSlots]({ separator: comma });
567
+ }
568
+ if (multiline) {
569
+ lead(item.getFirstToken(), indentTrivia(indentation));
570
+ }
571
+ }
572
+ else {
573
+ const next = items[at];
574
+ const { preamble, owned } = splitLeading(next.getFirstToken().leadingTrivia);
575
+ const comments = withoutIndentation(owned);
576
+ const nextIndentation = startsLine(next) ? indentationOf(owned) : indentation;
577
+ lead(item.getFirstToken(), [...preamble, ...indentTrivia(indentationOf(owned))]);
578
+ lead(next.getFirstToken(), multiline ? [...comments, ...indentTrivia(nextIndentation)] : comments);
579
+ item[setSlots]({ separator: trail(Token.fromText(','), space()) });
580
+ }
581
+ array[setItems]([...items.slice(0, at), item, ...items.slice(at)]);
582
+ return item;
583
+ }
584
+ /** The indentation of the first item of an inline array that begins its line, or none. */
585
+ function itemIndentation(array) {
586
+ return array.items.find(startsLine)?.getIndentation() ?? '';
587
+ }
588
+ /** Whether the items of an inline array stand on lines of their own. */
589
+ function isSpread(array) {
590
+ return hasLineEnding(array.openBracket.trailingTrivia)
591
+ || array.items.some((item) => hasLineEnding(item.separator?.trailingTrivia ?? []));
592
+ }
593
+ // ---- removing
594
+ /** Removes the key at the path: the item found, and the items whose value it overwrote. */
595
+ export function removeByPath(document, path, comments, nullable, mergeBlankLines = false) {
596
+ const item = resolvePath(document, path);
597
+ const array = item.parent;
598
+ if (typeof path.at(-1) === 'object') {
599
+ removeItem(item, comments, nullable, mergeBlankLines);
600
+ return;
601
+ }
602
+ const keys = itemKeys(array.items);
603
+ const key = keys[array.items.indexOf(item)];
604
+ for (const victim of array.items.filter((_, i) => keys[i] === key).reverse()) {
605
+ removeItem(victim, comments, nullable, mergeBlankLines);
606
+ }
607
+ }
608
+ /** Removes an item with the comments it owns. */
609
+ export function removeItem(item, comments, nullable, mergeBlankLines = false) {
610
+ const array = item.parent;
611
+ if (!(array instanceof BlockArrayNode || array instanceof InlineArrayNode)) {
612
+ throw new NeonError('The item is not in an array.');
613
+ }
614
+ const items = array.items;
615
+ const index = items.indexOf(item);
616
+ const remaining = items.filter((other) => other !== item);
617
+ const owner = array.parent;
618
+ const leaveNull = nullable ?? (owner instanceof ArrayItemNode || owner instanceof DocumentNode ? owner.nullable : false);
619
+ const before = keysBefore(array);
620
+ if (array instanceof BlockArrayNode && !remaining.length && owner instanceof DocumentNode) {
621
+ emptyDocument(owner, item, comments, leaveNull);
622
+ return;
623
+ }
624
+ else if (array instanceof BlockArrayNode) {
625
+ removeFromBlock(item, comments, mergeBlankLines);
626
+ if (!remaining.length) {
627
+ emptyBlock(array, item, leaveNull);
628
+ return;
629
+ }
630
+ }
631
+ else {
632
+ removeFromInline(array, item, index, comments);
633
+ if (!remaining.length && leaveNull && owner instanceof ArrayItemNode) {
634
+ array[setItems]([]);
635
+ replaceValue(owner, null);
636
+ return;
637
+ }
638
+ else if (!remaining.length && leaveNull && owner instanceof DocumentNode) {
639
+ array[setItems]([]);
640
+ emptyRoot(owner);
641
+ return;
642
+ }
643
+ }
644
+ array[setItems](remaining);
645
+ keepKeys(array, before, true); // an item removed from a list renumbers the rest, whatever its key
646
+ guardDashSubblocks(array);
647
+ }
648
+ function removeFromBlock(item, comments, mergeBlankLines) {
649
+ const { preamble, owned } = splitLeading(item.getFirstToken().leadingTrivia);
650
+ const next = tokenAfter(item.getLastToken());
651
+ const kept = comments === CommentPolicy.MoveToNextToken
652
+ ? [...withoutIndentation(owned), ...commentLine(item, Layout.indentation(item))]
653
+ : [];
654
+ const block = item.parent;
655
+ if (!startsLine(item)) { // the first item of a block opened on the line of a dash
656
+ const second = block.items[1];
657
+ const leading = second?.getFirstToken().leadingTrivia ?? [];
658
+ if (second && (kept.length || leading.some((t) => t.kind === 'Comment'))) { // the dash ends its line, the comments stay below it
659
+ trail(block.parent.bullet, [new Trivia('LineEnding', Layout.lineEnding(item))]);
660
+ lead(second.getFirstToken(), joinTrivia(kept, leading));
661
+ }
662
+ else if (second) { // the next one moves up
663
+ lead(second.getFirstToken(), []);
664
+ }
665
+ }
666
+ else if (next && mergeBlankLines && !kept.length) { // the narrower of the two gaps stays
667
+ const gap = Math.min(blankLines(preamble), blankLines(next.leadingTrivia, true));
668
+ lead(next, joinTrivia(withoutBlankLines(preamble, gap), withoutBlankLines(next.leadingTrivia, 0, true)));
669
+ }
670
+ else if (next) {
671
+ lead(next, joinTrivia([...preamble, ...kept], next.leadingTrivia));
672
+ }
673
+ if (!hasLineEnding(item.getLastToken().trailingTrivia) && next?.kind === 'EndOfFile' && !preamble.length && !kept.length) {
674
+ const before = tokenBefore(item.getFirstToken());
675
+ if (before) {
676
+ trail(before, withoutLineEnding(before.trailingTrivia)); // the file keeps ending without a line ending
677
+ }
678
+ }
679
+ lead(item.getFirstToken(), owned); // the item goes with what it owns, for insert()
680
+ }
681
+ /** The comment on the line of the item as a comment line of its own, for MoveToNextToken. */
682
+ function commentLine(item, indent) {
683
+ for (const token of item.getTokens()) {
684
+ const comment = token.trailingTrivia.find((t) => t.kind === 'Comment');
685
+ if (comment) {
686
+ return [...indentTrivia(indent), comment, new Trivia('LineEnding', Layout.lineEnding(item))];
687
+ }
688
+ else if (hasLineEnding(token.trailingTrivia)) {
689
+ break;
690
+ }
691
+ }
692
+ return [];
693
+ }
694
+ /** The block lost its last item: an empty inline array takes its place, or null. */
695
+ function emptyBlock(block, last, leaveNull) {
696
+ const owner = block.parent;
697
+ const head = (owner.colon ?? owner.bullet);
698
+ const onDashLine = !hasLineEnding(head.trailingTrivia); // then the line ending was the item's
699
+ block[setItems]([]);
700
+ owner[setSlots]({ value: null }); // the tokens of the block are gone, the head keeps its line ending
701
+ if (onDashLine) {
702
+ trail(head, last.getLastToken().trailingTrivia);
703
+ }
704
+ if (!leaveNull) {
705
+ replaceValue(owner, emptyArray(last.key ? '{}' : '[]'));
706
+ }
707
+ guardDashSubblocks(owner.parent);
708
+ }
709
+ /** The document lost its last item: the empty array, or nothing, keeps what stood above the item. */
710
+ function emptyDocument(document, last, comments, leaveNull) {
711
+ const { preamble, owned } = splitLeading(last.getFirstToken().leadingTrivia);
712
+ const kept = [...preamble, ...comments === CommentPolicy.MoveToNextToken ? [...withoutIndentation(owned), ...commentLine(last, '')] : []];
713
+ const trailing = last.getLastToken().trailingTrivia;
714
+ document.value[setItems]([]);
715
+ if (leaveNull) {
716
+ document[setSlots]({ value: null });
717
+ lead(document.endOfFile, joinTrivia(kept, document.endOfFile.leadingTrivia));
718
+ return;
719
+ }
720
+ const node = emptyArray(last.key ? '{}' : '[]');
721
+ lead(node.getFirstToken(), kept);
722
+ trail(node.getLastToken(), withoutComment(trailing)); // the comment on the line of the item went with it, or was kept above
723
+ document[setSlots]({ value: node });
724
+ }
725
+ function emptyArray(brackets) {
726
+ return Builder.parse(brackets);
727
+ }
728
+ /**
729
+ * Removes an item of an inline array. The item goes with what it owns; the lines around it stay lines:
730
+ * an item that started its line takes the line ending before itself along, one that did not leaves
731
+ * the line ending after itself, and what stood above the next item stays there.
732
+ */
733
+ function removeFromInline(array, item, index, comments) {
734
+ const next = array.items[index + 1];
735
+ const prev = array.items[index - 1];
736
+ const first = item.getFirstToken();
737
+ const eol = Layout.lineEnding(item);
738
+ const { preamble, owned } = splitLeading(first.leadingTrivia);
739
+ const ownLine = startsLine(item);
740
+ const endsLine = item.getTokens().some((token) => hasLineEnding(token.trailingTrivia));
741
+ const kept = comments === CommentPolicy.MoveToNextToken ? [...withoutIndentation(owned), ...commentLine(item, '')] : [];
742
+ let before = tokenBefore(first); // the opening bracket, or the comma of the previous item
743
+ let between;
744
+ if (!next && prev?.separator && !item.separator) { // the comma before the last item goes with it
745
+ const comma = prev.separator;
746
+ prev[setSlots]({ separator: null });
747
+ before = prev.getLastToken();
748
+ between = [...before.trailingTrivia, ...ownLine ? comma.trailingTrivia : []];
749
+ }
750
+ else {
751
+ between = ownLine || (next && !endsLine) ? [...before.trailingTrivia] : withoutTrailingSpace(before.trailingTrivia);
752
+ }
753
+ if (!ownLine && endsLine) {
754
+ between = [...withoutTrailingSpace(between), new Trivia('LineEnding', eol)];
755
+ }
756
+ between.push(...preamble, ...kept);
757
+ if (next && ownLine && !endsLine) { // the next item was on the line of the removed one
758
+ between.push(...indentTrivia(indentationOf(owned)));
759
+ }
760
+ const after = next ? next.getFirstToken() : array.closeBracket;
761
+ reattach(before, after, [...between, ...after.leadingTrivia]);
762
+ item[setSlots]({ separator: null }); // the comma separated it in this array
763
+ lead(first, owned); // the item goes with what it owns, for insert()
764
+ }
765
+ /** Splits the trivia between two tokens by the convention: up to the first line ending trailing, the rest leading. */
766
+ function reattach(before, after, trivia) {
767
+ const merged = joinAll(trivia);
768
+ const split = merged.findIndex((t) => t.kind === 'LineEnding') + 1 || merged.length;
769
+ trail(before, merged.slice(0, split));
770
+ lead(after, merged.slice(split));
771
+ }
772
+ // ---- keys
773
+ /** Replaces the key of an item, in the quotes of the old one when it can be written in them; or by a key node. */
774
+ export function setItemKey(item, key) {
775
+ const array = item.parent instanceof ArrayNode ? item.parent : null;
776
+ const old = item.key;
777
+ if (!old) {
778
+ throw new NeonError('The item has no key.');
779
+ }
780
+ else if (typeof key !== 'string') {
781
+ if (!(key instanceof StringNode || key instanceof LiteralNode) || key.token.text.includes('\n')) {
782
+ throw new TypeError('A key is a StringNode or a LiteralNode on one line.');
783
+ }
784
+ checkPlaceable(item, key);
785
+ }
786
+ else if (item.getKeyValue() === key) {
787
+ return;
788
+ }
789
+ const value = typeof key === 'string' ? key : key instanceof StringNode ? key.toValue() : literalKey(key.token.text);
790
+ const found = array?.findItem(value);
791
+ if (found && found !== item) {
792
+ throw new NeonError(`Duplicated key '${value}'`);
793
+ }
794
+ const before = array && keysBefore(array);
795
+ const node = typeof key !== 'string'
796
+ ? key
797
+ : styledScalar(old, key) ?? (() => {
798
+ const text = Builder.keyText(key);
799
+ return text[0] === '\'' || text[0] === '"' ? string(text) : literal(text);
800
+ })();
801
+ takeEdges(node, old);
802
+ item[setSlots]({ key: node });
803
+ if (node instanceof LiteralNode && item.colon?.kind === 'Colon' && item.value && !item.colon.trailingTrivia.length) {
804
+ trail(item.colon, [new Trivia('Whitespace', ' ')]); // `"a":b` stays two tokens, `a:b` would be one literal
805
+ }
806
+ if (array && before) {
807
+ before.keys.set(item, value);
808
+ keepKeys(array, before, false);
809
+ guardDashSubblocks(array);
810
+ }
811
+ }
812
+ // ---- nodes in slots
813
+ /** Puts the node into the slot the old one stands in; the slot decides what that means. */
814
+ export function replaceNode(old, node) {
815
+ const parent = old.parent;
816
+ if (node === old) {
817
+ return;
818
+ }
819
+ else if (!(node instanceof ValueNode)) {
820
+ throw new TypeError('ValueNode expected.');
821
+ }
822
+ else if (parent instanceof ArrayItemNode && parent.value === old) {
823
+ setItemNode(parent, node);
824
+ }
825
+ else if (parent instanceof ArrayItemNode) {
826
+ setItemKey(parent, node);
827
+ }
828
+ else if (parent instanceof DocumentNode) {
829
+ setDocumentNode(parent, node);
830
+ }
831
+ else if (parent instanceof EntityNode && parent.value === old) {
832
+ checkHead(node);
833
+ if (parent.parent instanceof EntityChainNode && parent.parent.entities[0] !== parent && !(node instanceof LiteralNode)) {
834
+ throw new TypeError('A link of a chain after the first has a literal head.');
835
+ }
836
+ checkPlaceable(parent, node);
837
+ takeEdges(node, old);
838
+ parent[setSlots]({ value: node });
839
+ }
840
+ else if (parent instanceof EntityNode) {
841
+ checkAttributes(node);
842
+ checkPlaceable(parent, node);
843
+ takeEdges(node, old);
844
+ parent[setSlots]({ attributes: node });
845
+ }
846
+ else if (parent instanceof EntityChainNode) {
847
+ const entities = parent.entities.map((entity) => entity === old ? node : entity);
848
+ checkLinks(entities);
849
+ if (!node.attributes && old.attributes) {
850
+ throw new TypeError('The link had parentheses, its replacement needs them too.');
851
+ }
852
+ checkMovable(parent, node);
853
+ checkDepth(parent, node);
854
+ takeEdges(node, old);
855
+ parent[setSlots]({ entities });
856
+ }
857
+ else {
858
+ throw new Error('The node is not in a tree.');
859
+ }
860
+ }
861
+ /** The head of an entity is a scalar or an inline array, not the reserved name of a chain. */
862
+ function checkHead(node) {
863
+ checkNode(node, StringNode, LiteralNode, InlineArrayNode);
864
+ if ((node instanceof StringNode ? node.toValue() : node instanceof LiteralNode ? node.token.text : null) === Chain) {
865
+ throw new NeonError(`Entity name '${Chain}' is reserved`);
866
+ }
867
+ }
868
+ function checkAttributes(node) {
869
+ checkNode(node, InlineArrayNode);
870
+ checkToken(node.openBracket, 'OpenParen');
871
+ }
872
+ /** The links after the first have a literal head, and only the last may be without parentheses. */
873
+ function checkLinks(entities) {
874
+ entities.forEach((entity, i) => {
875
+ checkNode(entity, EntityNode);
876
+ if ((i > 0 && !(entity.value instanceof LiteralNode)) || (i < entities.length - 1 && !entity.attributes)) {
877
+ throw new TypeError('A link of a chain after the first is a literal, and only the last one may be without parentheses.');
878
+ }
879
+ });
880
+ }
881
+ function checkToken(token, ...kinds) {
882
+ if (!(token instanceof Token) || !kinds.includes(token.kind)) {
883
+ throw new TypeError(`A token ${kinds.join(' or ')} expected.`);
884
+ }
885
+ }
886
+ function checkNode(node, ...types) {
887
+ if (!types.some((type) => node instanceof type)) {
888
+ throw new TypeError(`${types.map((type) => type.name).join(' or ')} expected.`);
889
+ }
890
+ }
891
+ /** Takes the node out of its slot with its edge trivia; only a value of an item with a head and the root can be empty. */
892
+ export function detachNode(node) {
893
+ const parent = node.parent;
894
+ if (parent instanceof ArrayItemNode && parent.value === node && (parent.colon || parent.bullet)) {
895
+ setItemNode(parent, null);
896
+ }
897
+ else if (parent instanceof DocumentNode) { // the preamble and the byte order mark stay in the document
898
+ const first = node.getFirstToken();
899
+ const { preamble, owned } = splitLeading(first.leadingTrivia);
900
+ lead(first, owned);
901
+ parent[setSlots]({ value: null });
902
+ lead(parent.endOfFile, joinTrivia(preamble, parent.endOfFile.leadingTrivia));
903
+ }
904
+ else if (parent) {
905
+ throw new Error('The slot of the node cannot be empty; use replaceWith().');
906
+ }
907
+ }
908
+ /** Puts the node into the place of the root: a block gets the indentation of the old root block, or none. */
909
+ export function setDocumentNode(document, node) {
910
+ const old = document.value;
911
+ if (node === old) {
912
+ return;
913
+ }
914
+ else if (!node) {
915
+ emptyRoot(document);
916
+ return;
917
+ }
918
+ if (!(node instanceof ValueNode)) {
919
+ throw new TypeError('ValueNode expected.');
920
+ }
921
+ checkPlaceable(document, node);
922
+ const end = document.endOfFile;
923
+ if (node instanceof BlockArrayNode) {
924
+ reindentBlock(node, old instanceof BlockArrayNode ? old.items[0].getIndentation() : '');
925
+ }
926
+ const first = node.getFirstToken();
927
+ const blockLeading = node instanceof BlockArrayNode ? first.leadingTrivia : [];
928
+ if (old) {
929
+ const leading = old.getFirstToken().leadingTrivia;
930
+ lead(first, node instanceof BlockArrayNode ? joinTrivia(withoutIndentation(leading), blockLeading) : leading);
931
+ trail(node.getLastToken(), old.getLastToken().trailingTrivia);
932
+ }
933
+ else { // the first value of an empty document comes after what the document holds
934
+ const leading = withoutIndentation(end.leadingTrivia);
935
+ const eol = Layout.lineEnding(end);
936
+ const lineEnding = leading.at(-1)?.kind === 'Comment' ? [new Trivia('LineEnding', eol)] : [];
937
+ lead(first, joinTrivia([...leading, ...lineEnding], blockLeading));
938
+ trail(node.getLastToken(), hasLineEnding(end.leadingTrivia) ? [new Trivia('LineEnding', eol)] : []);
939
+ lead(end, []);
940
+ }
941
+ document[setSlots]({ value: node });
942
+ }
943
+ /** Empties the document; what stood above the value and the comment on its line stay. */
944
+ function emptyRoot(document) {
945
+ const old = document.value;
946
+ if (!old) {
947
+ return;
948
+ }
949
+ const end = document.endOfFile;
950
+ const trailing = old.getLastToken().trailingTrivia;
951
+ const comment = trailing.filter((t) => t.kind === 'Comment');
952
+ const kept = [
953
+ ...withoutIndentation(old.getFirstToken().leadingTrivia),
954
+ ...comment,
955
+ ...comment.length && (hasLineEnding(trailing) || end.leadingTrivia.length) ? [new Trivia('LineEnding', Layout.lineEnding(old))] : [],
956
+ ];
957
+ document[setSlots]({ value: null });
958
+ lead(end, joinTrivia(kept, end.leadingTrivia));
959
+ }
960
+ // ---- paths
961
+ /** The item at the path, or an error naming the first key that is not there. */
962
+ export function resolvePath(document, path, fullPath = path) {
963
+ let container = document.value;
964
+ let item = null;
965
+ path.forEach((segment) => {
966
+ const array = arrayOf(container);
967
+ item = !array ? null : typeof segment === 'object' ? array.items[segment.item] ?? null : array.findItem(segment);
968
+ if (!item) {
969
+ throw new NeonError(`No item '${segmentText(segment)}' in the path ${pathText(fullPath)}`);
970
+ }
971
+ container = item.value;
972
+ });
973
+ return item;
974
+ }
975
+ /** The array of a container: the array itself, or the attributes of an entity. */
976
+ export function arrayOf(node) {
977
+ if (node instanceof EntityNode) {
978
+ return node.attributes;
979
+ }
980
+ return node instanceof BlockArrayNode || node instanceof InlineArrayNode ? node : null;
981
+ }
982
+ export function setByPath(document, path, value, create) {
983
+ if (!path.length) {
984
+ setDocumentValue(document, value);
985
+ return;
986
+ }
987
+ const last = path.at(-1);
988
+ const owner = path.length > 1 ? resolvePath(document, path.slice(0, -1), path) : null;
989
+ const container = owner ? owner.value : document.value;
990
+ const array = arrayOf(container);
991
+ const existing = array && (typeof last === 'object' ? array.items[last.item] : array.findItem(last));
992
+ if (existing) {
993
+ setItemValue(existing, value);
994
+ return;
995
+ }
996
+ else if (!create || typeof last === 'object') {
997
+ throw new NeonError(`No item '${segmentText(last)}' in the path ${pathText(path)}, and create is false`);
998
+ }
999
+ const key = String(last);
1000
+ if (array) {
1001
+ addItem(array, isImplicitKey(array, key) ? null : key, value);
1002
+ }
1003
+ else if (container === null && owner) {
1004
+ setItemValue(owner, new Map([[key, value]]));
1005
+ }
1006
+ else if (container === null && !owner) {
1007
+ setDocumentValue(document, new Map([[key, value]]));
1008
+ }
1009
+ else {
1010
+ throw new NeonError(`The value at ${pathText(path.slice(0, -1))} is not an array`);
1011
+ }
1012
+ }
1013
+ /** Whether a key is the one the array would assign to an item without a key. */
1014
+ function isImplicitKey(array, key) {
1015
+ const length = listLength(array, itemKeys(array.items));
1016
+ return length !== null && String(length) === key;
1017
+ }
1018
+ function setDocumentValue(document, value) {
1019
+ const old = document.value;
1020
+ if (value instanceof ValueNode) {
1021
+ setDocumentNode(document, value);
1022
+ }
1023
+ else if (old ? sameValue(value, old) : value === null) {
1024
+ // nothing changes
1025
+ }
1026
+ else if (value === null) {
1027
+ emptyRoot(document);
1028
+ }
1029
+ else {
1030
+ const eol = Layout.lineEnding(document.endOfFile);
1031
+ setDocumentNode(document, styledScalar(old, value)
1032
+ ?? Builder.value(value, { block: true, indent: '', unit: Layout.unit(document), eol, doubleQuoted: hasFragileString(document, value) }));
1033
+ }
1034
+ }
1035
+ function segmentText(segment) {
1036
+ return typeof segment === 'object' ? `#${segment.item}` : String(segment);
1037
+ }
1038
+ function pathText(path) {
1039
+ return path.map(segmentText).join('.');
1040
+ }
1041
+ // ---- trivia
1042
+ function lead(token, trivia) {
1043
+ return token[setLeading](keepCarriageReturns(trivia));
1044
+ }
1045
+ function trail(token, trivia) {
1046
+ return token[setTrailing](keepCarriageReturns(trivia));
1047
+ }
1048
+ /** A line feed put after a lone carriage return, which ends a comment or whitespace, is written as CRLF, so the CR stays its text. */
1049
+ function keepCarriageReturns(trivia) {
1050
+ return trivia.some((t, i) => t.text === '\n' && trivia[i - 1]?.text.endsWith('\r'))
1051
+ ? trivia.map((t, i) => t.text === '\n' && trivia[i - 1]?.text.endsWith('\r') ? new Trivia('LineEnding', '\r\n') : t)
1052
+ : trivia;
1053
+ }
1054
+ /**
1055
+ * Splits the leading trivia of the first token of an item into the preamble (lines separated from
1056
+ * the item by a blank line, and the blank lines) and what the item owns: the comment lines directly
1057
+ * above it and its indentation.
1058
+ */
1059
+ function splitLeading(trivia) {
1060
+ const lines = splitLines(trivia);
1061
+ let split = lines.length - 1; // the last line is the indentation
1062
+ while (split > 0 && lines[split - 1].some((t) => t.kind === 'Comment')) {
1063
+ split--;
1064
+ }
1065
+ const preamble = lines.slice(0, split).flat();
1066
+ const owned = lines.slice(split).flat();
1067
+ if (owned[0]?.kind === 'ByteOrderMark') { // the mark belongs to the start of the document
1068
+ preamble.push(owned.shift());
1069
+ }
1070
+ return { preamble, owned };
1071
+ }
1072
+ /** Number of blank lines at the end of the trivia, or at their start. */
1073
+ function blankLines(trivia, atStart = false) {
1074
+ const lines = splitLines(trivia);
1075
+ if (!atStart) {
1076
+ lines.pop(); // the indentation of the line that follows
1077
+ lines.reverse();
1078
+ }
1079
+ let count = 0;
1080
+ while (lines[count]?.every((t) => t.kind !== 'Comment') && lines[count].some((t) => t.kind === 'LineEnding')) {
1081
+ count++;
1082
+ }
1083
+ return count;
1084
+ }
1085
+ /** The trivia with only the given number of blank lines left at the end, or at the start. */
1086
+ function withoutBlankLines(trivia, keep, atStart = false) {
1087
+ const lines = splitLines(trivia);
1088
+ const blank = blankLines(trivia, atStart);
1089
+ if (atStart) {
1090
+ lines.splice(keep, blank - keep);
1091
+ }
1092
+ else {
1093
+ lines.splice(lines.length - 1 - blank + keep, blank - keep);
1094
+ }
1095
+ return lines.flat();
1096
+ }
1097
+ /** Groups trivia into lines, each ending with its line ending; the last group has none. */
1098
+ function splitLines(trivia) {
1099
+ const lines = [[]];
1100
+ for (const t of trivia) {
1101
+ lines.at(-1).push(t);
1102
+ if (t.kind === 'LineEnding') {
1103
+ lines.push([]);
1104
+ }
1105
+ }
1106
+ return lines;
1107
+ }
1108
+ /** The comment lines without the indentation of the item they stood above. */
1109
+ function withoutIndentation(owned) {
1110
+ const last = owned.at(-1);
1111
+ return last?.kind === 'Whitespace' ? owned.slice(0, -1) : [...owned];
1112
+ }
1113
+ function withoutTrailingSpace(trivia) {
1114
+ return trivia.at(-1)?.kind === 'Whitespace' ? trivia.slice(0, -1) : [...trivia];
1115
+ }
1116
+ /** The trivia without a comment and the space before it. */
1117
+ function withoutComment(trivia) {
1118
+ const i = trivia.findIndex((t) => t.kind === 'Comment');
1119
+ const start = trivia[i - 1]?.kind === 'Whitespace' ? i - 1 : i;
1120
+ return i < 0 ? [...trivia] : joinAll([...trivia.slice(0, start), ...trivia.slice(i + 1)]);
1121
+ }
1122
+ function indentationOf(owned) {
1123
+ const last = owned.at(-1);
1124
+ return last?.kind === 'Whitespace' ? last.text : '';
1125
+ }
1126
+ function indentTrivia(indent) {
1127
+ return indent ? [new Trivia('Whitespace', indent)] : [];
1128
+ }
1129
+ /** Concatenates two lists of trivia, merging whitespace that would meet. */
1130
+ function joinTrivia(a, b) {
1131
+ const last = a.at(-1);
1132
+ const first = b[0];
1133
+ if (last?.kind === 'Whitespace' && first?.kind === 'Whitespace') {
1134
+ return [...a.slice(0, -1), ...b];
1135
+ }
1136
+ return [...a, ...b];
1137
+ }
1138
+ function joinAll(trivia) {
1139
+ return trivia.reduce((res, t) => joinTrivia(res, [t]), []);
1140
+ }
1141
+ /**
1142
+ * Whether a multiline string of the value has to use """: the document holds an empty ''' string closed
1143
+ * on the line right after its opening, so a later line starting with ''' would close it instead.
1144
+ */
1145
+ function hasFragileString(at, value) {
1146
+ if (!hasMultilineString(value)) {
1147
+ return false;
1148
+ }
1149
+ let root = at;
1150
+ while (root.parent) {
1151
+ root = root.parent;
1152
+ }
1153
+ return root.getTokens().some((token) => token.kind === 'String' && /^'''\r?\n[\t ]*'''$/.test(token.text));
1154
+ }
1155
+ /** Whether the value holds a string the encoder writes on more lines; a value nested too deep or unknown counts as one. */
1156
+ function hasMultilineString(value, depth = 0) {
1157
+ if (typeof value === 'string') {
1158
+ return value.includes('\n');
1159
+ }
1160
+ else if (typeof value !== 'object' || value === null || value instanceof NeonDate) {
1161
+ return false;
1162
+ }
1163
+ else if (depth > MaxDepth) {
1164
+ return true;
1165
+ }
1166
+ else if (value instanceof Entity) {
1167
+ return hasMultilineString(value.value, depth + 1) || hasMultilineString(value.attributes, depth + 1);
1168
+ }
1169
+ const entries = entriesOf(value);
1170
+ return !entries || entries.entries.some(([, v]) => hasMultilineString(v, depth + 1));
1171
+ }
1172
+ /** Whether the item begins its line; not so the first item of a block opened on the line of a dash. */
1173
+ function startsLine(item) {
1174
+ const before = tokenBefore(item.getFirstToken());
1175
+ return !before || hasLineEnding(before.trailingTrivia);
1176
+ }
1177
+ function hasLineEnding(trivia) {
1178
+ return trivia.some((t) => t.kind === 'LineEnding');
1179
+ }
1180
+ function withoutLineEnding(trivia) {
1181
+ return trivia.filter((t) => t.kind !== 'LineEnding');
1182
+ }
1183
+ function tokenAfter(token) {
1184
+ return findNeighbour(token, false);
1185
+ }
1186
+ function tokenBefore(token) {
1187
+ return findNeighbour(token, true);
1188
+ }