soml-lang 0.0.2 → 0.0.3

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,635 @@
1
+ import { parseWithTimes } from "./parse.js";
2
+ import { parseTree, } from "./tree.js";
3
+ import { formatKey, stringifyValueAt, getStringifyOptions, } from "./stringify.js";
4
+ import { abbreviate, describeKey, CLOSE_BRACE, CLOSE_BRACKET, COMMA, LF, isSpace, skipSpaces, skipSpacesBack, } from "./shared.js";
5
+ /**
6
+ Change one value in a document, and keep everything else as it is written: comments, member order, layout, and the spelling of every other value. For tools that update a config file, such as a dependency bumper or a `set` command.
7
+
8
+ The value at `path` is replaced, or added when it does not exist yet, and an `undefined` value removes it. Only the changed part of the text is rewritten, and an edit to a formatted document leaves it formatted.
9
+
10
+ - A new value is written as `stringify()` writes it, at the indentation of its line. So `0xFF` that is replaced by `255n` becomes `255`, and `8080` becomes `8080.0` unless you pass `8080n` or the `integers: 'number'` option. The members of a new object keep their order, unless you pass the `canonical: true` option. In a container that is on one line, it is written on one line too, so `[1, 2]` with a new item `{a: 3n}` becomes `[1, 2, {a: 3}]`.
11
+ - A new member goes after the last member of its object, or after the last member that shares its dotted prefix, as `postgres.port` after `postgres.host`. Missing objects on the way are created.
12
+ - A new item can be added at the end of an array, with the index that is its length.
13
+ - A new member or item goes on its own line without a comma, after the comments that the one before it owns (see below), and the commas of the other members and items stay as they are. When something follows the member or item before it on the same line, such as the closing bracket of the one-line container `{a: 1}`, it goes on that line after a comma, as in `{a: 1, b: 2}`. In an empty `[]` or `{}`, it goes on a line of its own, unless that container is inside a container on one line, so `a: [1, []]` becomes `a: [1, [2]]`.
14
+ - A removed member or item takes the comments it owns, which are the ones that `format()` keeps with it: the comments after it on its line, also after its comma when nothing else follows there, and the block comments before it on its line, after the comma or bracket before it. So removing `2` from `[1, /* note *\/ 2]` gives `[1]`. A comment on a line of its own belongs to no member or item, so it stays. A removed member or item also removes its lines when nothing else is on them. A line that a block comment after it continues onto counts as one of its lines. Removing every item of a container closes it up to `[]` or `{}`, unless a comment is left inside.
15
+ - A removed member or item takes its comma with it. When the removed items are the last ones and have no comma after them, they take the comma directly before them instead, when only spaces, tabs, and the comments they own are between, so `[1, 2]` becomes `[1]`.
16
+ - Removing the last member under a dotted prefix, such as `a.b` when it is the only member that starts with `a.`, leaves `a: {}`, because the object `a` still exists. Removing the only member of a document without braces leaves `{}`.
17
+
18
+ Comments inside a value that is replaced or removed are removed with it. In a layout that `format()` never writes, an edit can leave odd spacing, such as a new item after a closing block string delimiter on its line that is indented differently from its neighbors. The document is always valid and has the right value. Removing a value that does not exist changes nothing and is not an error: a missing member, a missing object or array on the way, or an index at or past the end of its array. So removing the same path twice is safe, and `edit(text, path, undefined) === text` tells whether something was removed.
19
+
20
+ The result is parsed before it is returned, so a bug in `edit()` throws an `Error` rather than returning a broken document.
21
+
22
+ @param text - The document.
23
+ @param path - The keys and array indexes that lead to the value, such as `['servers', 0, 'port']`. A dotted key is followed like the objects it builds, so `['postgres', 'host']` finds `postgres.host: 'db'`.
24
+ @param value - The new value, of the types that `stringify()` accepts, or `undefined` to remove the value.
25
+ @param options - How an int is represented in `value`, and whether to sort its members, as for `stringify()`.
26
+ @returns The changed document.
27
+ @throws {ParseError} When `text` is not a valid document.
28
+ @throws {TypeError} When `text` is not a string, when `path` is not a non-empty array of keys and array indexes, when it leads through a value that is not an object or an array, or when it has a key where an array is or an index where an object is. Also when `value` cannot be represented, or `options` is invalid, as for `stringify()`.
29
+ @throws {RangeError} When a value is set at an index past the end of its array, or at an index under a value that does not exist, or when the change would nest the document more than 100 levels deep. Also when `value` is out of range, as for `stringify()`.
30
+
31
+ @example
32
+ ```
33
+ import {edit} from 'soml-lang';
34
+
35
+ edit('name: \'api\' # The service\nport: 8080\n', ['port'], 9090n);
36
+ //=> "name: 'api' # The service\nport: 9090\n"
37
+
38
+ edit('postgres.host: \'db\'\n', ['postgres', 'port'], 5432n);
39
+ //=> "postgres.host: 'db'\npostgres.port: 5432\n"
40
+
41
+ edit('a: 1\nb: 2\n', ['a'], undefined);
42
+ //=> 'b: 2\n'
43
+
44
+ edit('port: 8080', ['port'], 9090, {integers: 'number'});
45
+ //=> 'port: 9090'
46
+
47
+ edit('a: 1\n', ['b'], {y: 1n, x: 2n}, {canonical: true});
48
+ //=> 'a: 1\nb: {\n\tx: 2\n\ty: 1\n}\n'
49
+ ```
50
+ */
51
+ export function edit(text, path, value, options) {
52
+ // `Array#some()` skips a hole, so `Array#includes()` finds it.
53
+ if (!Array.isArray(path) || path.length === 0 || path.includes(undefined) || path.some(segment => !isPathSegment(segment))) {
54
+ throw new TypeError('The path must be a non-empty array of keys and array indexes');
55
+ }
56
+ const stringifyOptions = getStringifyOptions(options);
57
+ const tree = parseTree(text);
58
+ const replacements = new Editor({
59
+ text,
60
+ comments: tree.comments,
61
+ stringifyOptions,
62
+ path,
63
+ value,
64
+ }).replacements(tree.body);
65
+ // Replacements never overlap. A stable sort keeps the creation order of two insertions at the same offset.
66
+ let result = '';
67
+ let position = 0;
68
+ for (const replacement of replacements.toSorted((first, second) => first.start - second.start)) {
69
+ result += text.slice(position, replacement.start) + replacement.text;
70
+ position = replacement.end;
71
+ }
72
+ result += text.slice(position);
73
+ // The result is checked, so that a mistake here throws rather than writing a broken document.
74
+ try {
75
+ parseWithTimes(result);
76
+ }
77
+ catch (error) {
78
+ throw new Error(`edit() made an invalid document for the path ${describePath(path)}. This is a bug in soml-lang`, { cause: error });
79
+ }
80
+ return result;
81
+ }
82
+ class Editor {
83
+ #text;
84
+ #stringifyOptions;
85
+ #path;
86
+ /*
87
+ The new value, or `undefined` to remove it.
88
+ */
89
+ #value;
90
+ /*
91
+ The end of each comment, by its start, so that the trivia between tokens can be skipped.
92
+ */
93
+ #commentEnds = new Map();
94
+ /*
95
+ The start of each block comment, by its end, so that the comments before a member or an item can be found.
96
+ */
97
+ #blockCommentStarts = new Map();
98
+ #comments;
99
+ #replacements = [];
100
+ /*
101
+ Whether the container being edited is inside one that stays on one line, so that it stays on one line too. An edit follows one path, so one flag is enough.
102
+ */
103
+ #isInsideOneLine = false;
104
+ constructor({ text, comments, stringifyOptions, path, value }) {
105
+ this.#text = text;
106
+ this.#stringifyOptions = stringifyOptions;
107
+ this.#path = path;
108
+ this.#value = value;
109
+ this.#comments = comments;
110
+ for (const comment of comments) {
111
+ const [start, end] = comment.range;
112
+ this.#commentEnds.set(start, end);
113
+ if (comment.type === 'Block') {
114
+ this.#blockCommentStarts.set(end, start);
115
+ }
116
+ }
117
+ }
118
+ #editInArray(array, index, depth) {
119
+ const path = this.#path;
120
+ const value = this.#value;
121
+ const position = path[index];
122
+ if (typeof position !== 'number') {
123
+ throw new TypeError(`Cannot edit ${describePath(path)}, because ${describeParent(path, index)} is an array, so it needs an index, not a key`);
124
+ }
125
+ const element = array.elements[position];
126
+ if (element === undefined) {
127
+ // Removing an item that is not there changes nothing, at the end of the array or past it, and so does removing something below it.
128
+ if (value === undefined) {
129
+ return;
130
+ }
131
+ const count = array.elements.length;
132
+ if (position !== count) {
133
+ const items = count === 1 ? '1 item' : `${count} items`;
134
+ const owner = index === 0 ? 'the document' : `the array at ${describeParent(path, index)}`;
135
+ throw new RangeError(`Cannot edit ${describePath(path)}, because ${owner} has ${items}. Add an item at index ${count}`);
136
+ }
137
+ const newValue = nest(path, index + 1, value);
138
+ this.#insert(array, array.elements.at(-1), indentation => this.#valueText(newValue, depth + 1, indentation, array));
139
+ return;
140
+ }
141
+ if (index < path.length - 1) {
142
+ this.#isInsideOneLine = this.#isOneLine(array);
143
+ this.#descend(element, index + 1, depth + 1);
144
+ }
145
+ else if (value === undefined) {
146
+ this.#removeNodes(array, [element]);
147
+ }
148
+ else {
149
+ this.#replace(element, depth + 1, array);
150
+ }
151
+ }
152
+ #editInObject(object, index, depth) {
153
+ const path = this.#path;
154
+ if (typeof path[index] !== 'string') {
155
+ throw new TypeError(`Cannot edit ${describePath(path)}, because ${describeParent(path, index)} is an object, so it needs a key, not an index`);
156
+ }
157
+ const value = this.#value;
158
+ const rest = path.slice(index);
159
+ // The members inside the object that `rest` names, when dotted keys built it, as `a.b` and `a.c` build `a`.
160
+ const dottedMembers = [];
161
+ // The last member whose key shares the most leading segments with `rest`, which is where a new member goes.
162
+ let sibling;
163
+ let siblingLength = 0;
164
+ for (const member of object.members) {
165
+ const { segments } = member.key;
166
+ let length = 0;
167
+ while (length < segments.length && length < rest.length && rest[length] === segments[length].value) {
168
+ length++;
169
+ }
170
+ if (length === segments.length) {
171
+ if (length < rest.length) {
172
+ this.#isInsideOneLine = this.#isOneLine(object);
173
+ this.#descend(member.value, index + length, depth + length);
174
+ }
175
+ else if (value === undefined) {
176
+ this.#removeMembers(object, [member], length);
177
+ }
178
+ else {
179
+ this.#replaceMemberValue(member, depth + length, object);
180
+ }
181
+ return;
182
+ }
183
+ if (length === rest.length) {
184
+ dottedMembers.push(member);
185
+ }
186
+ else if (length > 0 && length >= siblingLength) {
187
+ sibling = member;
188
+ siblingLength = length;
189
+ }
190
+ }
191
+ // An index where dotted keys built an object, as `['a', 0]` for `a.b: 1`, matches no key segment.
192
+ if (sibling !== undefined && typeof rest[siblingLength] !== 'string') {
193
+ throw new TypeError(`Cannot edit ${describePath(path)}, because ${describeParent(path, index + siblingLength)} is an object, so it needs a key, not an index`);
194
+ }
195
+ if (dottedMembers.length > 0) {
196
+ if (value === undefined) {
197
+ this.#removeMembers(object, dottedMembers, rest.length);
198
+ }
199
+ else {
200
+ // The object that the dotted keys build becomes one member, where the first of them was.
201
+ const [first, ...others] = dottedMembers;
202
+ this.#replaceMembers(object, first, others, `${this.#keyPrefix(first, rest.length)}: ${this.#valueText(value, depth + rest.length, this.#indentation(first.range[0]), object)}`);
203
+ }
204
+ return;
205
+ }
206
+ if (value === undefined) {
207
+ return;
208
+ }
209
+ // A new member continues the dotted prefix it shares with a sibling, and the rest of the path becomes nested objects.
210
+ const keyLength = siblingLength + 1;
211
+ const key = `${sibling === undefined ? '' : `${this.#keyPrefix(sibling, siblingLength)}.`}${formatKey(rest[siblingLength])}`;
212
+ const newValue = nest(path, index + keyLength, value);
213
+ this.#insert(object, sibling ?? object.members.at(-1), indentation => `${key}: ${this.#valueText(newValue, depth + keyLength, indentation, object)}`);
214
+ }
215
+ /*
216
+ Edits the path from `index` on, inside `node`, whose own depth is `depth`.
217
+ */
218
+ #descend(node, index, depth) {
219
+ if (node.type === 'Array') {
220
+ this.#editInArray(node, index, depth);
221
+ }
222
+ else if (node.type === 'Object') {
223
+ this.#editInObject(node, index, depth);
224
+ }
225
+ else {
226
+ throw new TypeError(`Cannot edit ${describePath(this.#path)}, because ${describeParent(this.#path, index)} is not an object or an array`);
227
+ }
228
+ }
229
+ #replace(node, depth, container) {
230
+ this.#replacements.push({ start: node.range[0], end: node.range[1], text: this.#valueText(this.#value, depth, this.#indentation(node.range[0]), container) });
231
+ }
232
+ /*
233
+ Replaces the value of `member`. A block string begins on the line after its key, and the new value is never a block string, so when only whitespace comes between the `:` and a block string, the new value goes on the key's line, as the formatter writes it.
234
+ */
235
+ #replaceMemberValue(member, depth, object) {
236
+ const { key, value } = member;
237
+ const colonEnd = key.range[1] + 1;
238
+ if (value.type !== 'String' || !value.block || this.#skipWhitespace(colonEnd) !== value.range[0]) {
239
+ this.#replace(value, depth, object);
240
+ return;
241
+ }
242
+ this.#replacements.push({ start: colonEnd, end: value.range[1], text: ` ${this.#valueText(this.#value, depth, this.#indentation(member.range[0]), object)}` });
243
+ }
244
+ /*
245
+ Skips spaces, tabs, and line feeds, but not comments.
246
+ */
247
+ #skipWhitespace(offset) {
248
+ const text = this.#text;
249
+ while (isSpace(text.charCodeAt(offset)) || text.charCodeAt(offset) === LF) {
250
+ offset++;
251
+ }
252
+ return offset;
253
+ }
254
+ /*
255
+ Removes `members`, which share the first `length` segments of their keys. When no other member shares the segments before the last one, the object they are in would disappear with them, so it is written as `{}` instead: `a: {}` for an object that dotted keys built, and `{}` for a document without braces. A braced object keeps its braces anyway.
256
+ */
257
+ #removeMembers(object, members, length) {
258
+ const [first, ...others] = members;
259
+ const parentLength = length - 1;
260
+ const removed = new Set(members);
261
+ const isParentEmptied = object.members.every(member => removed.has(member) || !this.#sharesPrefix(member, first, parentLength));
262
+ if (!isParentEmptied || (parentLength === 0 && object.braced)) {
263
+ this.#removeNodes(object, members);
264
+ return;
265
+ }
266
+ this.#replaceMembers(object, first, others, parentLength > 0 ? `${this.#keyPrefix(first, parentLength)}: {}` : '{}');
267
+ }
268
+ /*
269
+ Writes `text` in place of `first`, and removes `others`.
270
+ */
271
+ #replaceMembers(object, first, others, text) {
272
+ this.#replacements.push({ start: first.range[0], end: first.range[1], text });
273
+ this.#removeNodes(object, others);
274
+ }
275
+ /*
276
+ Removes `nodes` from `container`.
277
+
278
+ Removed lines that only blank lines separate are one removal, so that the blank lines between them go too, and each removal also takes a blank line that would be left next to another one or directly inside a bracket. When every item of a braced container goes and no comment is left inside, its brackets close up, as `[]`.
279
+ */
280
+ #removeNodes(container, nodes) {
281
+ const items = container.type === 'Array' ? container.elements : container.members;
282
+ const removed = new Set(nodes);
283
+ let trailingStart = items.length;
284
+ while (trailingStart > 0 && removed.has(items[trailingStart - 1])) {
285
+ trailingStart--;
286
+ }
287
+ // When the removed items reach the end and the last one has no comma after it, the first of them takes the comma before it, so that no trailing comma is left, as `[1, 2]` becomes `[1]`.
288
+ const lastItem = items.at(-1);
289
+ const firstToTakeCommaBefore = lastItem !== undefined && this.#text.charCodeAt(this.#skipTrivia(lastItem.range[1])) !== COMMA ? items[trailingStart] : undefined;
290
+ const removals = [];
291
+ for (const removal of nodes.flatMap(node => this.#removals(node, node === firstToTakeCommaBefore))) {
292
+ const previous = removals.at(-1);
293
+ if (previous !== undefined && removal.start < previous.end) {
294
+ // A removal that took the comma before it overlaps the one before.
295
+ previous.end = Math.max(previous.end, removal.end);
296
+ previous.isWholeLines = false;
297
+ }
298
+ else if (removal.isWholeLines && previous?.isWholeLines && this.#skipBlankLines(previous.end) === removal.start) {
299
+ previous.end = removal.end;
300
+ }
301
+ else {
302
+ removals.push(removal);
303
+ }
304
+ }
305
+ // Each one takes at most the blank line next to it, so two never take the same one: only blank lines would be between them, and then they are one removal already.
306
+ const contentStart = this.#contentStart(container);
307
+ for (const removal of removals) {
308
+ if (removal.isWholeLines) {
309
+ this.#takeBlankLine(removal, container, contentStart);
310
+ }
311
+ }
312
+ if (nodes.length === items.length && isBraced(container) && !this.#isCommentLeft(container, removals)) {
313
+ const [start, end] = container.range;
314
+ this.#replacements.push({ start: start + 1, end: end - 1, text: '' });
315
+ return;
316
+ }
317
+ for (const { start, end } of removals) {
318
+ this.#replacements.push({ start, end, text: '' });
319
+ }
320
+ }
321
+ /*
322
+ Whether a comment inside `container` is outside every one of `removals`. The removals and the comments are both in source order, so one pass finds it.
323
+ */
324
+ #isCommentLeft(container, removals) {
325
+ const [start, end] = container.range;
326
+ let removalIndex = 0;
327
+ return this.#comments.some(comment => {
328
+ const [commentStart] = comment.range;
329
+ if (commentStart <= start || commentStart >= end) {
330
+ return false;
331
+ }
332
+ while (removalIndex < removals.length && removals[removalIndex].end <= commentStart) {
333
+ removalIndex++;
334
+ }
335
+ return removalIndex === removals.length || commentStart < removals[removalIndex].start;
336
+ });
337
+ }
338
+ /*
339
+ What removing a member or an item, with the comments it owns and its comma, takes out of the text. With `shouldTakeCommaBefore`, a comma directly before it goes too.
340
+ */
341
+ #removals(node, shouldTakeCommaBefore) {
342
+ const text = this.#text;
343
+ const start = this.#ownedStart(node.range[0]);
344
+ const end = this.#ownedEnd(node.range[1]);
345
+ const next = skipSpaces(text, end);
346
+ // Only a braced container can have commas.
347
+ const hasComma = text.charCodeAt(next) === COMMA;
348
+ // The comma before it is only taken when spaces alone are between them and the comments it owns. The scan back only crosses spaces, so it stays short on a long line.
349
+ const before = skipSpacesBack(text, start);
350
+ const removalStart = shouldTakeCommaBefore && text.charCodeAt(before - 1) === COMMA ? before - 1 : start;
351
+ // The comments after its comma go with it when they end its line, as the formatter keeps them after it.
352
+ const removalEnd = hasComma ? this.#findLineEndAfterTrivia(next + 1) ?? (next + 1) : end;
353
+ const removal = this.#removal(removalStart, removalEnd);
354
+ // When it takes the comma before it, the spaces before a comment after it stay, so that the comment does not join the item before the comma.
355
+ if (removalStart !== start && !removal.isWholeLines && this.#commentEnds.has(removal.end)) {
356
+ removal.end = skipSpacesBack(text, removal.end);
357
+ }
358
+ return [removal];
359
+ }
360
+ /*
361
+ The start of the block comments that a member or an item at `offset` owns before it: those on its line after the comma or the opening bracket before it, as `/* note *\/` in `[1, /* note *\/ 2]`. The formatter keeps them in front of it. It is `offset` when there are none.
362
+ */
363
+ #ownedStart(offset) {
364
+ for (;;) {
365
+ const commentStart = this.#blockCommentStarts.get(skipSpacesBack(this.#text, offset));
366
+ if (commentStart === undefined) {
367
+ return offset;
368
+ }
369
+ offset = commentStart;
370
+ }
371
+ }
372
+ /*
373
+ The end of the comments that a member or an item ending at `offset` owns after it: those on its line before its comma or the closing bracket, as `/* note *\/` in `[1 /* note *\/, 2]` and `[1, 2 /* note *\/]`, and a line comment at the end of its line. The formatter keeps them after it. It is `offset` when there are none.
374
+ */
375
+ #ownedEnd(offset) {
376
+ for (;;) {
377
+ const commentEnd = this.#commentEnds.get(skipSpaces(this.#text, offset));
378
+ if (commentEnd === undefined) {
379
+ return offset;
380
+ }
381
+ offset = commentEnd;
382
+ }
383
+ }
384
+ /*
385
+ The removal of the text from `start` to `end`. When nothing else is on its lines, it takes the lines, including a comment at the end of the last one.
386
+ */
387
+ #removal(start, end) {
388
+ const text = this.#text;
389
+ const lineEnd = this.#findLineEndAfterTrivia(end);
390
+ // The line start is only looked for when nothing follows on the line, because the scan back to it is long for an item in the middle of a long line, and doing it for every item would be quadratic.
391
+ if (lineEnd !== undefined) {
392
+ const lineStart = this.#lineStart(start);
393
+ if (skipSpaces(this.#text, lineStart) === start) {
394
+ return { start: lineStart, end: Math.min(lineEnd + 1, text.length), isWholeLines: true };
395
+ }
396
+ }
397
+ // The spaces after the text go too, and the spaces before it when nothing follows it on the line or in its container.
398
+ const after = skipSpaces(this.#text, end);
399
+ const code = text.charCodeAt(after);
400
+ const isLast = after === text.length || code === LF || code === CLOSE_BRACE || code === CLOSE_BRACKET;
401
+ return { start: isLast ? skipSpacesBack(this.#text, start) : start, end: after, isWholeLines: false };
402
+ }
403
+ /*
404
+ Widens a removal of whole lines by a blank line that would otherwise be left next to another blank line, or directly inside a bracket. `contentStart` is the container's, from `#contentStart()`.
405
+ */
406
+ #takeBlankLine(removal, container, contentStart) {
407
+ const text = this.#text;
408
+ const isBlankBefore = removal.start > 0 && this.#isBlankLine(this.#lineStart(removal.start - 1));
409
+ const isBlankAfter = removal.end < text.length && this.#isBlankLine(removal.end);
410
+ if (isBlankBefore && (isBlankAfter || this.#isAtClosing(removal.end, container))) {
411
+ removal.start = this.#lineStart(removal.start - 1);
412
+ }
413
+ else if (isBlankAfter && removal.start === contentStart) {
414
+ removal.end = text.indexOf('\n', removal.end) + 1;
415
+ }
416
+ }
417
+ /*
418
+ The offset after the blank lines that start at `offset`.
419
+ */
420
+ #skipBlankLines(offset) {
421
+ while (offset < this.#text.length && this.#isBlankLine(offset)) {
422
+ offset = this.#text.indexOf('\n', offset) + 1;
423
+ }
424
+ return offset;
425
+ }
426
+ /*
427
+ Whether only blank lines come between the line at `offset` and the line that closes `container`, or the end of the text.
428
+ */
429
+ #isAtClosing(offset, container) {
430
+ offset = this.#skipBlankLines(offset);
431
+ return offset === this.#text.length || (isBraced(container) && skipSpaces(this.#text, offset) === container.range[1] - 1);
432
+ }
433
+ /*
434
+ The start of the first line that is not blank after the line that opens `container`, or after the start of the text. The line that opens a container ends after the comments that follow its bracket, and when an item follows on it, this is `undefined`. Found once per removal of nodes rather than once per removed line, which would be quadratic in the number of blank lines or comments there.
435
+ */
436
+ #contentStart(container) {
437
+ if (!isBraced(container)) {
438
+ return this.#skipBlankLines(0);
439
+ }
440
+ const lineEnd = this.#findLineEndAfterTrivia(container.range[0] + 1);
441
+ return lineEnd === undefined ? undefined : this.#skipBlankLines(lineEnd + 1);
442
+ }
443
+ #isBlankLine(lineStart) {
444
+ return this.#text.charCodeAt(skipSpaces(this.#text, lineStart)) === LF;
445
+ }
446
+ #lineStart(offset) {
447
+ // `lastIndexOf` treats a negative start as 0, so it would find a line break at offset 0 that is not before `offset`.
448
+ return offset === 0 ? 0 : this.#text.lastIndexOf('\n', offset - 1) + 1;
449
+ }
450
+ /*
451
+ Adds an item after `previous`, or into the empty `container` when there is none. `makeItem` gets the indentation of the item's line.
452
+
453
+ An item on its own line needs no comma, so only an item added on the line of the item before it gets one, as in a one-line container.
454
+ */
455
+ #insert(container, previous, makeItem) {
456
+ if (previous === undefined) {
457
+ const closing = container.range[1] - 1;
458
+ // A container that stays on one line keeps the item on that line, so `[/* note */]` becomes `[/* note */ 1]`, and `[1, []]` becomes `[1, [2]]`.
459
+ if (this.#isOneLine(container)) {
460
+ const start = skipSpacesBack(this.#text, closing);
461
+ this.#replacements.push({ start, end: closing, text: `${start > container.range[0] + 1 ? ' ' : ''}${makeItem('')}` });
462
+ return;
463
+ }
464
+ const outer = this.#indentation(container.range[0]);
465
+ const inner = `${outer}\t`;
466
+ const lineStart = this.#lineStart(closing);
467
+ // A closing bracket on its own line keeps that line, and the item goes on a line before it.
468
+ if (lineStart > container.range[0] && skipSpaces(this.#text, lineStart) === closing) {
469
+ this.#replacements.push({ start: lineStart, end: lineStart, text: `${inner}${makeItem(inner)}\n` });
470
+ }
471
+ else {
472
+ this.#replacements.push({ start: skipSpacesBack(this.#text, closing), end: closing, text: `\n${inner}${makeItem(inner)}\n${outer}` });
473
+ }
474
+ return;
475
+ }
476
+ const text = this.#text;
477
+ // The new item goes after the comments that the one before it owns, so that they stay with that one.
478
+ const previousEnd = this.#ownedEnd(previous.range[1]);
479
+ const next = this.#skipTrivia(previousEnd);
480
+ const hasComma = text.charCodeAt(next) === COMMA;
481
+ const afterComma = hasComma ? next + 1 : previousEnd;
482
+ const lineEnd = this.#findLineEndAfterTrivia(afterComma);
483
+ // Something follows on the same line, such as the closing bracket of a one-line object, so the item goes on that line too, and takes its indentation.
484
+ if (lineEnd === undefined) {
485
+ const item = makeItem(this.#indentation(afterComma));
486
+ this.#replacements.push(hasComma ? { start: afterComma, end: afterComma, text: ` ${item},` } : { start: previousEnd, end: previousEnd, text: `, ${item}` });
487
+ return;
488
+ }
489
+ const indentation = this.#indentation(previous.range[0]);
490
+ this.#replacements.push({ start: lineEnd, end: lineEnd, text: `\n${indentation}${makeItem(indentation)}` });
491
+ }
492
+ /*
493
+ Whether `container` stays on one line: its brackets are on one line and something is between them, which `format()` keeps, or it is inside a container that stays on one line. An empty `[]` or `{}` on its own gets its new item on a line of its own.
494
+ */
495
+ #isOneLine(container) {
496
+ if (this.#isInsideOneLine) {
497
+ return true;
498
+ }
499
+ const [start, end] = container.range;
500
+ return isBraced(container) && skipSpaces(this.#text, start + 1) < end - 1 && !this.#text.slice(start, end).includes('\n');
501
+ }
502
+ /*
503
+ A new value inside `container`. In a container on one line, the value is on one line too.
504
+ */
505
+ #valueText(value, depth, indentation, container) {
506
+ return stringifyValueAt(value, {
507
+ ...this.#stringifyOptions,
508
+ depth,
509
+ indent: indentation,
510
+ isOneLine: this.#isOneLine(container),
511
+ });
512
+ }
513
+ /*
514
+ The first `length` segments of a member's key, as they are written.
515
+ */
516
+ #keyPrefix(member, length) {
517
+ const { segments } = member.key;
518
+ return this.#text.slice(segments[0].range[0], segments[length - 1].range[1]);
519
+ }
520
+ /*
521
+ Whether the first `length` segments of the two keys are the same. A key with fewer segments always differs in one of its own, because a key that is a prefix of another one collides with it.
522
+ */
523
+ #sharesPrefix(member, other, length) {
524
+ const { segments } = member.key;
525
+ for (let index = 0; index < length; index++) {
526
+ if (segments[index].value !== other.key.segments[index].value) {
527
+ return false;
528
+ }
529
+ }
530
+ return true;
531
+ }
532
+ /*
533
+ The spaces and tabs at the start of the line that holds `offset`.
534
+ */
535
+ #indentation(offset) {
536
+ let lineStart = this.#lineStart(offset);
537
+ // A line that a block comment from an earlier line runs into, as in `/* a\n b */ x: 1`, is indented as the line where that comment starts.
538
+ for (let comment = this.#commentAcross(lineStart); comment !== undefined; comment = this.#commentAcross(lineStart)) {
539
+ lineStart = this.#lineStart(comment.range[0]);
540
+ }
541
+ return this.#text.slice(lineStart, skipSpaces(this.#text, lineStart));
542
+ }
543
+ /*
544
+ The comment that starts before `offset` and ends after it, if any. The comments are in source order, so a binary search finds the last one that starts before `offset`.
545
+ */
546
+ #commentAcross(offset) {
547
+ let low = 0;
548
+ let high = this.#comments.length;
549
+ while (low < high) {
550
+ const middle = Math.floor((low + high) / 2);
551
+ if (this.#comments[middle].range[0] < offset) {
552
+ low = middle + 1;
553
+ }
554
+ else {
555
+ high = middle;
556
+ }
557
+ }
558
+ const comment = this.#comments[low - 1];
559
+ return comment !== undefined && comment.range[1] > offset ? comment : undefined;
560
+ }
561
+ /*
562
+ Skips whitespace, line breaks, and comments.
563
+ */
564
+ #skipTrivia(offset) {
565
+ offset = this.#skipLineTrivia(offset);
566
+ while (this.#text.charCodeAt(offset) === LF) {
567
+ offset = this.#skipLineTrivia(offset + 1);
568
+ }
569
+ return offset;
570
+ }
571
+ /*
572
+ Skips spaces, tabs, and comments, but not a line break outside a comment.
573
+ */
574
+ #skipLineTrivia(offset) {
575
+ for (;;) {
576
+ offset = skipSpaces(this.#text, offset);
577
+ const commentEnd = this.#commentEnds.get(offset);
578
+ if (commentEnd === undefined) {
579
+ return offset;
580
+ }
581
+ offset = commentEnd;
582
+ }
583
+ }
584
+ /*
585
+ The offset of the line break that ends the line at `offset`, or the end of the text, when only whitespace and comments come before it. Otherwise `undefined`.
586
+ */
587
+ #findLineEndAfterTrivia(offset) {
588
+ offset = this.#skipLineTrivia(offset);
589
+ return offset === this.#text.length || this.#text.charCodeAt(offset) === LF ? offset : undefined;
590
+ }
591
+ /*
592
+ The replacements that make the edit, from the document's collection, which is level 1.
593
+ */
594
+ replacements(body) {
595
+ this.#descend(body, 0, 1);
596
+ return this.#replacements;
597
+ }
598
+ }
599
+ function isPathSegment(segment) {
600
+ return typeof segment === 'string' || (Number.isSafeInteger(segment) && segment >= 0);
601
+ }
602
+ function isBraced(container) {
603
+ return container.type === 'Array' || container.braced;
604
+ }
605
+ /*
606
+ `value` inside new objects for the keys of `path` from `index` on. An index there names an item of an array that does not exist yet, so it throws a `RangeError`.
607
+ */
608
+ function nest(path, index, value) {
609
+ let nested = value;
610
+ for (let position = path.length - 1; position >= index; position--) {
611
+ const segment = path[position];
612
+ if (typeof segment === 'number') {
613
+ throw new RangeError(`Cannot edit ${describePath(path)}, because ${describeParent(path, position)} does not exist, so it has no index ${segment}`);
614
+ }
615
+ // `Object.fromEntries` defines the member, so a key named `__proto__` is an ordinary member.
616
+ nested = Object.fromEntries([[segment, nested]]);
617
+ }
618
+ return nested;
619
+ }
620
+ function describePath(path) {
621
+ const description = path.map((segment, index) => {
622
+ if (typeof segment === 'number') {
623
+ return `[${segment}]`;
624
+ }
625
+ return index === 0 ? describeKey(segment) : `.${describeKey(segment)}`;
626
+ }).join('');
627
+ // The path comes from the caller, perhaps from user input, so it is cut short, and invisible characters and those that a terminal acts on, which `JSON.stringify()` leaves as they are, become escapes.
628
+ return abbreviate(description, 200).replaceAll(/[\p{Control}\p{Format}\p{Line_Separator}\p{Paragraph_Separator}]/gv, character => String.raw `\u{${character.codePointAt(0).toString(16)}}`);
629
+ }
630
+ /*
631
+ The value that holds `path[index]`.
632
+ */
633
+ function describeParent(path, index) {
634
+ return index === 0 ? 'the document' : describePath(path.slice(0, index));
635
+ }
@@ -1,7 +1,24 @@
1
1
  /**
2
- Thrown by `parse()` when the input is not a valid document.
2
+ Thrown when the input is not a valid document, by `parse()`, `parseTree()`, `format()`, and `edit()`. Extends `SyntaxError`.
3
3
 
4
4
  The `message` includes the position and a code frame. Use `reason` for the message alone.
5
+
6
+ @example
7
+ ```
8
+ import {parse, ParseError} from 'soml-lang';
9
+
10
+ try {
11
+ parse('name: api-gateway');
12
+ } catch (error) {
13
+ if (error instanceof ParseError) {
14
+ console.log(error.message);
15
+ }
16
+ }
17
+ // Unexpected "api-gateway". A string value must be quoted, as in 'api-gateway' at line 1, column 7
18
+ //
19
+ // > 1 | name: api-gateway
20
+ // | ^
21
+ ```
5
22
  */
6
23
  export declare class ParseError extends SyntaxError {
7
24
  readonly name = "ParseError";
@@ -26,7 +43,7 @@ export declare class ParseError extends SyntaxError {
26
43
  */
27
44
  readonly codeFrame: string;
28
45
  /**
29
- Only `parse()` creates a `ParseError`.
46
+ Only the parser creates a `ParseError`.
30
47
  */
31
48
  private constructor();
32
49
  }