soml-lang 0.0.2 → 0.0.4

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.
@@ -1,18 +1,62 @@
1
1
  /**
2
- Format a document: normalize its layout, and keep everything the author chose about its content.
2
+ Format a document. Returns the document with its layout normalized, ending with one line feed.
3
3
 
4
- The layout follows the spec's formatter: one tab per level, every member and item on its own line, a trailing comma after every member and item inside braces and brackets, and no trailing whitespace or runs of blank lines. Comments, member order, dotted keys, and the spelling of every value stay as they are.
4
+ The layout follows the formatter in the specification: one tab per level, every member and item on its own line with no commas, and no trailing whitespace or runs of blank lines. An object or an array whose brackets are on one line stays on one line, as in `ports: [80, 443]`, with a comma and a space between its members or items. To give a one-line container one member or item per line, put a line break anywhere inside it. Comments, member order, block strings, and the spelling of every value stay as they are, so the value never changes. The one change inside a block comment is the same layout rule: trailing whitespace is removed, and runs of blank lines collapse to one.
5
+
6
+ In detail, as the specification states:
7
+
8
+ - A block comment that a value follows on the same line, as in `1, /* note *\/ 2`, stays in front of that value when each item goes on its own line.
9
+ - One space follows each `:`, as in canonical form.
10
+ - A value on the line after its `key:` moves up to that line, unless a comment comes between them. Then it stays on its own line, one level deeper than the key, and the blank lines between them are removed.
11
+ - A block string begins on the line after its key, and its delimiters and content have the indentation of that line. Its lines are not otherwise changed.
12
+ - There are no blank lines at the start of the file or directly inside brackets.
5
13
 
6
14
  @param text - The document.
7
15
  @returns The formatted document, ending with one line feed.
8
16
  @throws {ParseError} When the document is not valid, the same as `parse()`.
17
+ @throws {TypeError} When `text` is not a string.
9
18
 
10
19
  @example
11
20
  ```
12
21
  import {format} from 'soml-lang';
13
22
 
14
- format('pool: {min: 2, max: 16} # Connections');
15
- //=> 'pool: {\n\tmin: 2,\n\tmax: 16,\n} # Connections\n'
23
+ format('pool: {min: 2, max: 16,} # Connections');
24
+ //=> 'pool: {min: 2, max: 16} # Connections\n'
25
+
26
+ format('pool: {\nmin: 2, max: 16}');
27
+ //=> 'pool: {\n\tmin: 2\n\tmax: 16\n}\n'
16
28
  ```
17
29
  */
18
30
  export declare function format(text: string): string;
31
+ /**
32
+ A change to a document: a range of its text, and the text that replaces it.
33
+ */
34
+ export type FormatEdit = {
35
+ /**
36
+ The start and end of the replaced text, as UTF-16 offsets into the document. They are equal when the edit only inserts.
37
+ */
38
+ readonly range: readonly [start: number, end: number];
39
+ /**
40
+ The text that replaces the range. It is empty when the edit only removes.
41
+ */
42
+ readonly text: string;
43
+ };
44
+ /**
45
+ The changes that `format()` makes to a document, as edits of its text. For an editor or a linter, which shows or applies each change where it is, rather than replacing the whole document.
46
+
47
+ The edits are sorted, do not overlap, and change only spaces, tabs, line feeds, and commas, as `format()` does. Each one is as small as possible, so it leaves out the characters at its ends that stay the same. Applying all of them gives the same text as `format()`, and a document that is already formatted gives no edits.
48
+
49
+ @param text - The document.
50
+ @returns The edits, in the order of their ranges.
51
+ @throws {ParseError} When the document is not valid, the same as `parse()`.
52
+ @throws {TypeError} When `text` is not a string.
53
+
54
+ @example
55
+ ```
56
+ import {formatEdits} from 'soml-lang';
57
+
58
+ formatEdits('a: [1,\n2]\n');
59
+ //=> [{range: [4, 4], text: '\n\t'}, {range: [5, 7], text: '\n\t'}, {range: [8, 8], text: '\n'}]
60
+ ```
61
+ */
62
+ export declare function formatEdits(text: string): FormatEdit[];
@@ -1,43 +1,133 @@
1
1
  import { parseTree, } from "./tree.js";
2
+ import { ASTERISK, isBlankLine, COMMA, HASH, SLASH, findLineEnd, isSpace, skipSpaces, skipSpacesBack, LF, } from "./shared.js";
2
3
  const INDENT = '\t';
3
- const BLANK = /^[\t ]*$/v;
4
4
  /**
5
- Format a document: normalize its layout, and keep everything the author chose about its content.
5
+ Format a document. Returns the document with its layout normalized, ending with one line feed.
6
6
 
7
- The layout follows the spec's formatter: one tab per level, every member and item on its own line, a trailing comma after every member and item inside braces and brackets, and no trailing whitespace or runs of blank lines. Comments, member order, dotted keys, and the spelling of every value stay as they are.
7
+ The layout follows the formatter in the specification: one tab per level, every member and item on its own line with no commas, and no trailing whitespace or runs of blank lines. An object or an array whose brackets are on one line stays on one line, as in `ports: [80, 443]`, with a comma and a space between its members or items. To give a one-line container one member or item per line, put a line break anywhere inside it. Comments, member order, block strings, and the spelling of every value stay as they are, so the value never changes. The one change inside a block comment is the same layout rule: trailing whitespace is removed, and runs of blank lines collapse to one.
8
+
9
+ In detail, as the specification states:
10
+
11
+ - A block comment that a value follows on the same line, as in `1, /* note *\/ 2`, stays in front of that value when each item goes on its own line.
12
+ - One space follows each `:`, as in canonical form.
13
+ - A value on the line after its `key:` moves up to that line, unless a comment comes between them. Then it stays on its own line, one level deeper than the key, and the blank lines between them are removed.
14
+ - A block string begins on the line after its key, and its delimiters and content have the indentation of that line. Its lines are not otherwise changed.
15
+ - There are no blank lines at the start of the file or directly inside brackets.
8
16
 
9
17
  @param text - The document.
10
18
  @returns The formatted document, ending with one line feed.
11
19
  @throws {ParseError} When the document is not valid, the same as `parse()`.
20
+ @throws {TypeError} When `text` is not a string.
12
21
 
13
22
  @example
14
23
  ```
15
24
  import {format} from 'soml-lang';
16
25
 
17
- format('pool: {min: 2, max: 16} # Connections');
18
- //=> 'pool: {\n\tmin: 2,\n\tmax: 16,\n} # Connections\n'
26
+ format('pool: {min: 2, max: 16,} # Connections');
27
+ //=> 'pool: {min: 2, max: 16} # Connections\n'
28
+
29
+ format('pool: {\nmin: 2, max: 16}');
30
+ //=> 'pool: {\n\tmin: 2\n\tmax: 16\n}\n'
19
31
  ```
20
32
  */
21
33
  export function format(text) {
22
34
  return new Printer(text, parseTree(text)).print();
23
35
  }
36
+ /**
37
+ The changes that `format()` makes to a document, as edits of its text. For an editor or a linter, which shows or applies each change where it is, rather than replacing the whole document.
38
+
39
+ The edits are sorted, do not overlap, and change only spaces, tabs, line feeds, and commas, as `format()` does. Each one is as small as possible, so it leaves out the characters at its ends that stay the same. Applying all of them gives the same text as `format()`, and a document that is already formatted gives no edits.
40
+
41
+ @param text - The document.
42
+ @returns The edits, in the order of their ranges.
43
+ @throws {ParseError} When the document is not valid, the same as `parse()`.
44
+ @throws {TypeError} When `text` is not a string.
45
+
46
+ @example
47
+ ```
48
+ import {formatEdits} from 'soml-lang';
49
+
50
+ formatEdits('a: [1,\n2]\n');
51
+ //=> [{range: [4, 4], text: '\n\t'}, {range: [5, 7], text: '\n\t'}, {range: [8, 8], text: '\n'}]
52
+ ```
53
+ */
54
+ export function formatEdits(text) {
55
+ const formatted = format(text);
56
+ const edits = [];
57
+ let textIndex = 0;
58
+ let formattedIndex = 0;
59
+ // Without the characters that the formatter changes, the two texts are the same. So walking both at once, every run of those characters that differs is one edit, which takes linear time, where a general diff takes quadratic time on a document with many changes.
60
+ while (textIndex < text.length || formattedIndex < formatted.length) {
61
+ const textStart = textIndex;
62
+ const formattedStart = formattedIndex;
63
+ const textGapEnd = skipLayout(text, textIndex);
64
+ const formattedGapEnd = skipLayout(formatted, formattedIndex);
65
+ if (text.slice(textIndex, textGapEnd) !== formatted.slice(formattedIndex, formattedGapEnd)) {
66
+ edits.push(createEdit(text, textIndex, textGapEnd, formatted.slice(formattedIndex, formattedGapEnd)));
67
+ }
68
+ textIndex = textGapEnd;
69
+ formattedIndex = formattedGapEnd;
70
+ while (textIndex < text.length && !isLayout(text.charCodeAt(textIndex)) && text.charCodeAt(textIndex) === formatted.charCodeAt(formattedIndex)) {
71
+ textIndex++;
72
+ formattedIndex++;
73
+ }
74
+ // Both stop at a different character, or one at its end, only when the formatter changed something else, and then the walk would never end.
75
+ if (textIndex === textStart && formattedIndex === formattedStart) {
76
+ throw new Error(`The formatter changed more than the layout at offset ${textIndex}. This is a bug in soml-lang.`);
77
+ }
78
+ }
79
+ return edits;
80
+ }
81
+ /*
82
+ The characters that the formatter changes.
83
+ */
84
+ function isLayout(code) {
85
+ return isSpace(code) || code === LF || code === COMMA;
86
+ }
87
+ /*
88
+ The end of the run of layout characters at `index`.
89
+ */
90
+ function skipLayout(text, index) {
91
+ while (index < text.length && isLayout(text.charCodeAt(index))) {
92
+ index++;
93
+ }
94
+ return index;
95
+ }
96
+ /*
97
+ The edit that replaces `text.slice(start, end)` with `replacement`, without the characters at its ends that stay the same, as in `, ` to `\n\t`.
98
+ */
99
+ function createEdit(text, start, end, replacement) {
100
+ let prefixLength = 0;
101
+ while (start + prefixLength < end && prefixLength < replacement.length && text[start + prefixLength] === replacement[prefixLength]) {
102
+ prefixLength++;
103
+ }
104
+ let suffixLength = 0;
105
+ while (end - suffixLength > start + prefixLength && replacement.length - suffixLength > prefixLength && text[end - suffixLength - 1] === replacement[replacement.length - suffixLength - 1]) {
106
+ suffixLength++;
107
+ }
108
+ return {
109
+ range: [start + prefixLength, end - suffixLength],
110
+ text: replacement.slice(prefixLength, replacement.length - suffixLength),
111
+ };
112
+ }
24
113
  class Printer {
25
114
  #text;
26
115
  #tree;
27
- #output = '';
116
+ // Joined once at the end. Appending with `+=` builds a deep rope, and reading it one character at a time, as `formatEdits()` does, can take quadratic time once V8 deoptimizes the reader.
117
+ #output = [];
28
118
  #commentIndex = 0;
29
119
  constructor(text, tree) {
30
120
  this.#text = text;
31
121
  this.#tree = tree;
32
122
  }
33
123
  #write(text) {
34
- this.#output += text;
124
+ this.#output.push(text);
35
125
  }
36
126
  /*
37
127
  Starts a new line at `level`, with one blank line before it when the source had one.
38
128
  */
39
129
  #newLine(level, isBlank) {
40
- if (this.#output === '') {
130
+ if (this.#output.length === 0) {
41
131
  return;
42
132
  }
43
133
  this.#write(`${isBlank ? '\n\n' : '\n'}${INDENT.repeat(level)}`);
@@ -45,8 +135,19 @@ class Printer {
45
135
  #isSameLine(start, end) {
46
136
  return !this.#text.slice(start, end).includes('\n');
47
137
  }
138
+ /*
139
+ Whether a line between `start` and `end` holds only spaces and tabs, if anything.
140
+ */
48
141
  #hasBlankLine(start, end) {
49
- return /\n[\t ]*\n/v.test(this.#text.slice(start, end));
142
+ // A slice, so that the search for a line break stops at `end`.
143
+ const gap = this.#text.slice(start, end);
144
+ for (let index = gap.indexOf('\n'); index !== -1; index = gap.indexOf('\n', index + 1)) {
145
+ index = skipSpaces(gap, index + 1);
146
+ if (gap.charCodeAt(index) === LF) {
147
+ return true;
148
+ }
149
+ }
150
+ return false;
50
151
  }
51
152
  /*
52
153
  The next comment that ends at or before `end`, without consuming it.
@@ -61,37 +162,53 @@ class Printer {
61
162
  this.#write(trimTrailingWhitespace(`#${comment.value}`));
62
163
  return;
63
164
  }
64
- // The lines of a block comment keep their own indentation, apart from trailing whitespace.
65
- this.#write(`/*${comment.value}*/`.split('\n').map(line => trimTrailingWhitespace(line)).join('\n'));
165
+ // The lines of a block comment keep their own indentation. Trailing whitespace is removed, and runs of blank lines collapse to one, as everywhere outside a block string.
166
+ const lines = `/*${comment.value}*/`.split('\n').map(line => trimTrailingWhitespace(line));
167
+ this.#write(lines.filter((line, index) => line !== '' || lines[index - 1] !== '').join('\n'));
66
168
  }
67
169
  /*
68
170
  The offset of the `,` after an item in the source, or `undefined` when there is none.
69
171
  */
70
172
  #findComma(start, end) {
71
- const index = this.#text.slice(start, end).replaceAll(/\/\*.*?\*\/|#[^\n]*/gsv, comment => ' '.repeat(comment.length)).indexOf(',');
72
- return index === -1 ? undefined : start + index;
173
+ const text = this.#text;
174
+ // Between two items there is only whitespace, comments, and at most one comma.
175
+ for (let index = start; index < end; index++) {
176
+ const code = text.charCodeAt(index);
177
+ if (code === COMMA) {
178
+ return index;
179
+ }
180
+ if (code === HASH) {
181
+ index = findLineEnd(text, index);
182
+ }
183
+ else if (code === SLASH && text.charCodeAt(index + 1) === ASTERISK) {
184
+ index = text.indexOf('*/', index + 2) + 1;
185
+ }
186
+ }
187
+ return undefined;
73
188
  }
74
189
  /*
75
190
  Writes `items` one per line at `level`, with the comments between them, from `start` to `end` in the source.
76
191
 
77
- A comment on the line of the item before it, or of the opening bracket, stays at the end of that line. The exception is a block comment after the comma that the next item follows on the same line, as in `[1, /* note *\/ 2]`: it stays in front of that item. Every other comment gets its own line.
192
+ A comment on the line of the item before it, or of the opening bracket, stays at the end of that line. The exception is a block comment after the comma that the next item follows on the same line, as in `[1, /* note *\/ 2]`: it stays in front of that item. Every other comment starts a new line, and a comment or an item that follows a block comment on its line stays on that line.
78
193
  */
79
- #list(items, { start, end, level, hasCommas }) {
194
+ #list(items, { start, end, level, isBracketed }) {
80
195
  let previousEnd = start;
81
196
  let itemEnd;
82
197
  // Whether a comment may stay at the end of the line before it, which needs an item or an opening bracket there.
83
- let canTrail = hasCommas;
198
+ let canTrail = isBracketed;
84
199
  let hasWritten = false;
85
200
  let isOnSameLine = false;
86
201
  for (const item of [...items, undefined]) {
87
202
  const itemStart = item?.range[0] ?? end;
88
- const comma = hasCommas ? this.#findComma(previousEnd, itemStart) : undefined;
203
+ const comma = isBracketed ? this.#findComma(previousEnd, itemStart) : undefined;
204
+ // Found once per item rather than once per comment, which would be quadratic in the number of comments on one line.
205
+ const itemLineStart = item === undefined ? undefined : previousEnd + this.#text.slice(previousEnd, itemStart).lastIndexOf('\n') + 1;
89
206
  for (let comment = this.#peekComment(itemStart); comment !== undefined; comment = this.#peekComment(itemStart)) {
90
207
  const [commentStart, commentEnd] = comment.range;
91
208
  const isTrailing = canTrail && !isOnSameLine && this.#isTrailing(comment, {
92
209
  previousEnd,
93
210
  itemEnd,
94
- itemStart: item?.range[0],
211
+ itemLineStart,
95
212
  comma,
96
213
  });
97
214
  if (isTrailing || isOnSameLine) {
@@ -104,7 +221,7 @@ class Printer {
104
221
  hasWritten ||= !isTrailing;
105
222
  // A block comment that the next comment or item follows on the same line keeps it on that line.
106
223
  const nextStart = this.#peekComment(itemStart)?.range[0] ?? itemStart;
107
- isOnSameLine = comment.type === 'Block' && !isTrailing && (item !== undefined || nextStart < itemStart) && this.#isSameLine(commentEnd, nextStart);
224
+ isOnSameLine = comment.type === 'Block' && !isTrailing && this.#isSameLine(commentEnd, nextStart);
108
225
  previousEnd = commentEnd;
109
226
  }
110
227
  if (item === undefined) {
@@ -116,7 +233,7 @@ class Printer {
116
233
  else {
117
234
  this.#newLine(level, hasWritten && this.#hasBlankLine(previousEnd, itemStart));
118
235
  }
119
- this.#item(item, level, hasCommas);
236
+ this.#item(item, level);
120
237
  previousEnd = item.range[1];
121
238
  itemEnd = previousEnd;
122
239
  canTrail = true;
@@ -127,63 +244,105 @@ class Printer {
127
244
  /*
128
245
  Whether a comment stays at the end of the line before it. That line ends at the item before it, or at the comma after that item.
129
246
  */
130
- #isTrailing(comment, { previousEnd, itemEnd, itemStart, comma }) {
247
+ #isTrailing(comment, { previousEnd, itemEnd, itemLineStart, comma }) {
131
248
  const [commentStart, commentEnd] = comment.range;
132
249
  const isAfterComma = comma !== undefined && commentStart > comma;
133
- // A block comment after the comma that the next item follows on the same line stays in front of that item.
134
- if (itemStart !== undefined && comment.type === 'Block' && (comma === undefined || isAfterComma) && this.#isSameLine(commentEnd, itemStart)) {
250
+ // A block comment after the comma that the next item follows on the same line stays in front of that item. The comment ends on the item's line when it ends after the start of that line.
251
+ if (itemLineStart !== undefined && comment.type === 'Block' && (comma === undefined || isAfterComma) && commentEnd >= itemLineStart) {
135
252
  return false;
136
253
  }
137
- // A comment after the comma ends the comma's line, which may be later than the item's. The comma goes after the item, so that only holds when nothing came between them.
254
+ // A comment after the comma ends the comma's line, which is later than the item's when a block comment that spans lines comes between them. The comma is removed, so the comment moves to the end of the item's line, which only holds when nothing came between them.
138
255
  const lineEnd = isAfterComma && previousEnd === itemEnd ? comma + 1 : previousEnd;
139
256
  return this.#isSameLine(lineEnd, commentStart);
140
257
  }
141
- #item(item, level, hasComma) {
258
+ /*
259
+ Writes a container that is on one line in the source on one line, with a comma and a space between its members or items and no trailing comma. Only a block comment can be inside it, and each one stays where it is among the items and commas.
260
+ */
261
+ #oneLine(items, { start, end, level }, opening, closing) {
262
+ this.#write(opening);
263
+ let previousEnd = start + 1;
264
+ let hasWrittenToken = false;
265
+ for (const item of [...items, undefined]) {
266
+ const itemStart = item?.range[0] ?? (end - 1);
267
+ // The comma after the last item is left out.
268
+ const comma = item === undefined ? undefined : this.#findComma(previousEnd, itemStart);
269
+ let hasWrittenComma = false;
270
+ for (let comment = this.#peekComment(itemStart); comment !== undefined; comment = this.#peekComment(itemStart)) {
271
+ if (!hasWrittenComma && comma !== undefined && comma < comment.range[0]) {
272
+ this.#write(',');
273
+ hasWrittenComma = true;
274
+ }
275
+ this.#write(hasWrittenToken ? ' ' : '');
276
+ this.#writeComment(comment);
277
+ hasWrittenToken = true;
278
+ }
279
+ if (item === undefined) {
280
+ break;
281
+ }
282
+ if (comma !== undefined && !hasWrittenComma) {
283
+ this.#write(', ');
284
+ }
285
+ else if (hasWrittenToken) {
286
+ this.#write(' ');
287
+ }
288
+ this.#item(item, level);
289
+ previousEnd = item.range[1];
290
+ hasWrittenToken = true;
291
+ }
292
+ this.#write(closing);
293
+ }
294
+ #item(item, level) {
142
295
  if (item.type === 'Member') {
143
296
  this.#member(item, level);
144
297
  }
145
298
  else {
146
299
  this.#value(item, level);
147
300
  }
148
- if (hasComma) {
149
- this.#write(',');
150
- }
151
301
  }
152
302
  #member(member, level) {
153
303
  const { key, value } = member;
154
304
  this.#write(`${this.#text.slice(...key.range)}:`);
305
+ const isBlockString = value.type === 'String' && value.block;
155
306
  if (this.#peekComment(value.range[0]) === undefined) {
307
+ // A block string begins on the next line, one level deeper, so its delimiters and content line up.
308
+ if (isBlockString) {
309
+ this.#newLine(level + 1, false);
310
+ this.#value(value, level + 1);
311
+ return;
312
+ }
156
313
  this.#write(' ');
157
314
  this.#value(value, level);
158
315
  return;
159
316
  }
160
- // With comments between the `:` and the value, the line breaks between them are kept, and a value on a new line is one level deeper.
317
+ // With comments between the `:` and the value, the line breaks between them are kept, but not the blank lines, and a value on a new line is one level deeper.
161
318
  let previousEnd = key.range[1] + 1;
162
319
  let valueLevel = level;
163
320
  for (let comment = this.#peekComment(value.range[0]); comment !== undefined; comment = this.#peekComment(value.range[0])) {
164
- if (!this.#isSameLine(previousEnd, comment.range[0])) {
321
+ if (this.#writeSeparator(previousEnd, comment.range[0], level + 1)) {
165
322
  valueLevel = level + 1;
166
323
  }
167
- this.#writeSeparator(previousEnd, comment.range[0], level + 1);
168
324
  this.#writeComment(comment);
169
325
  previousEnd = comment.range[1];
170
326
  }
171
- if (!this.#isSameLine(previousEnd, value.range[0])) {
327
+ if (isBlockString) {
328
+ this.#newLine(level + 1, false);
329
+ valueLevel = level + 1;
330
+ }
331
+ else if (this.#writeSeparator(previousEnd, value.range[0], level + 1)) {
172
332
  valueLevel = level + 1;
173
333
  }
174
- this.#writeSeparator(previousEnd, value.range[0], level + 1);
175
334
  this.#value(value, valueLevel);
176
335
  }
177
336
  /*
178
- A space when `end` is on the line of `start` in the source, and otherwise a new line at `level`.
337
+ A space when `end` is on the line of `start` in the source, and otherwise a new line at `level`. Returns whether it started a new line.
179
338
  */
180
339
  #writeSeparator(start, end, level) {
181
340
  if (this.#isSameLine(start, end)) {
182
341
  this.#write(' ');
342
+ return false;
183
343
  }
184
- else {
185
- this.#newLine(level, false);
186
- }
344
+ this.#newLine(level, false);
345
+ return true;
187
346
  }
188
347
  #value(node, level) {
189
348
  const [start, end] = node.range;
@@ -194,31 +353,36 @@ class Printer {
194
353
  this.#write(opening + closing);
195
354
  return;
196
355
  }
356
+ if (this.#isSameLine(start, end)) {
357
+ this.#oneLine(items, { start, end, level }, opening, closing);
358
+ return;
359
+ }
197
360
  this.#write(opening);
198
361
  this.#list(items, {
199
362
  start: start + 1,
200
363
  end: end - 1,
201
364
  level: level + 1,
202
- hasCommas: true,
365
+ isBracketed: true,
203
366
  });
204
367
  this.#newLine(level, false);
205
368
  this.#write(closing);
206
369
  return;
207
370
  }
208
371
  const text = this.#text.slice(start, end);
209
- this.#write(node.type === 'String' && node.block ? reindentBlockString(text, INDENT.repeat(level + 1)) : text);
372
+ this.#write(node.type === 'String' && node.block ? reindentBlockString(text, INDENT.repeat(level)) : text);
210
373
  }
211
374
  print() {
212
375
  const { body } = this.#tree;
213
376
  const items = body.type === 'Object' && !body.braced ? body.members : [body];
214
- // The document is a list without commas: the members of a brace-less object, or one braced collection.
377
+ // The document is a list without brackets: the members of a brace-less object, or one braced collection.
215
378
  this.#list(items, {
216
379
  start: 0,
217
380
  end: this.#text.length,
218
381
  level: 0,
219
- hasCommas: false,
382
+ isBracketed: false,
220
383
  });
221
- return `${this.#output}\n`;
384
+ this.#output.push('\n');
385
+ return this.#output.join('');
222
386
  }
223
387
  }
224
388
  /*
@@ -226,11 +390,7 @@ Only spaces and tabs are whitespace in SOML. Any other character, such as a no-b
226
390
  */
227
391
  function trimTrailingWhitespace(text) {
228
392
  // A loop rather than a regular expression, which would take quadratic time on a long run of spaces inside a line.
229
- let end = text.length;
230
- while (end > 0 && (text[end - 1] === ' ' || text[end - 1] === '\t')) {
231
- end--;
232
- }
233
- return text.slice(0, end);
393
+ return text.slice(0, skipSpacesBack(text, text.length));
234
394
  }
235
395
  /*
236
396
  Moves a block string to a new indentation. Its content is relative to the closing delimiter's indentation, so replacing that indentation on every line keeps the value. A blank line is left as it is, because it becomes an empty line either way.
@@ -238,6 +398,6 @@ Moves a block string to a new indentation. Its content is relative to the closin
238
398
  function reindentBlockString(text, indentation) {
239
399
  const lines = text.split('\n');
240
400
  const closing = lines.at(-1);
241
- const oldIndentation = closing.slice(0, closing.length - closing.trimStart().length);
242
- return lines.map((line, index) => index === 0 || (index < lines.length - 1 && BLANK.test(line)) ? line : indentation + line.slice(oldIndentation.length)).join('\n');
401
+ const oldIndentation = closing.slice(0, skipSpaces(closing, 0));
402
+ return lines.map((line, index) => index === 0 || isBlankLine(line) ? line : indentation + line.slice(oldIndentation.length)).join('\n');
243
403
  }
@@ -1,5 +1,8 @@
1
+ /// <reference lib="esnext.temporal" preserve="true" />
1
2
  export { parse, type Value, type ObjectValue, type Document, type ParseOptions, } from './parse.ts';
2
- export { parseTree, visitorKeys, type Position, type SourceLocation, type Token, type Comment, type DocumentNode, type ObjectNode, type MemberNode, type ArrayNode, type KeyNode, type KeySegmentNode, type StringNode, type IntegerNode, type FloatNode, type BooleanNode, type NullNode, type InstantNode, type DurationNode, type ValueNode, type Node, } from './tree.ts';
3
- export { format } from './format.ts';
4
- export { stringify, type StringifyOptions } from './stringify.ts';
3
+ export { parseTree, visitorKeys, type Position, type SourceLocation, type Token, type Comment, type DocumentNode, type ObjectNode, type MemberNode, type ArrayNode, type KeyNode, type StringNode, type IntegerNode, type FloatNode, type BooleanNode, type NullNode, type InstantNode, type DurationNode, type DurationPart, type ValueNode, type Node, } from './tree.ts';
4
+ export { format, formatEdits, type FormatEdit } from './format.ts';
5
+ export { edit, type EditOptions, type PathSegment } from './edit.ts';
6
+ export { stringify, stringifyValue, compareKeys, type StringifyOptions, } from './stringify.ts';
7
+ export { isBareKey } from './shared.ts';
5
8
  export { ParseError } from './error.ts';
@@ -1,5 +1,10 @@
1
+ // The types use the global `Temporal` types, which TypeScript has in the `esnext.temporal` lib, so a consumer whose `lib` is older gets them too.
2
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference -- A lib can only be loaded with a directive, and `preserve` keeps it in the emitted types.
3
+ /// <reference lib="esnext.temporal" preserve="true" />
1
4
  export { parse, } from "./parse.js";
2
5
  export { parseTree, visitorKeys, } from "./tree.js";
3
- export { format } from "./format.js";
4
- export { stringify } from "./stringify.js";
6
+ export { format, formatEdits } from "./format.js";
7
+ export { edit } from "./edit.js";
8
+ export { stringify, stringifyValue, compareKeys, } from "./stringify.js";
9
+ export { isBareKey } from "./shared.js";
5
10
  export { ParseError } from "./error.js";
@@ -24,6 +24,9 @@ export type ObjectValue<Integer extends bigint | number = bigint> = {
24
24
  A whole document, which is always an object or an array.
25
25
  */
26
26
  export type Document<Integer extends bigint | number = bigint> = ObjectValue<Integer> | Array<Value<Integer>>;
27
+ /**
28
+ Options for `parse()`.
29
+ */
27
30
  export type ParseOptions = {
28
31
  /**
29
32
  How an int is represented.
@@ -35,13 +38,17 @@ export type ParseOptions = {
35
38
  */
36
39
  readonly integers?: 'bigint' | 'number';
37
40
  };
41
+ export type ParsedValue = Value<bigint | number> | Time | ParsedValue[] | ParsedObject;
42
+ type ParsedObject = {
43
+ [key: string]: ParsedValue;
44
+ };
38
45
  /**
39
- Parse a document.
46
+ Parse a document. Returns an object or an array, because a document is always a collection.
40
47
 
41
- @param text - The document, as a string or as UTF-8 bytes.
48
+ @param text - The document, as a string or as UTF-8 bytes. Invalid UTF-8 is reported with its position.
42
49
  @returns The object or array the document contains.
43
50
  @throws {ParseError} When the document is not valid.
44
- @throws {TypeError} When `text` is not a string or a `Uint8Array`, or `options.integers` is not `'bigint'` or `'number'`.
51
+ @throws {TypeError} When `text` is not a string or a `Uint8Array`, or `options` is not an object or its `integers` is not `'bigint'` or `'number'`.
45
52
 
46
53
  @example
47
54
  ```
@@ -51,18 +58,74 @@ parse(`
51
58
  name: 'api-gateway'
52
59
  replicas: 3
53
60
  timeout: 30.0
54
- postgres.host: 'db.internal'
61
+ postgres: {host: 'db.internal'}
55
62
  `);
56
63
  //=> {name: 'api-gateway', replicas: 3n, timeout: 30, postgres: {host: 'db.internal'}}
57
64
 
58
- parse('replicas: 3', {integers: 'number'});
59
- //=> {replicas: 3}
65
+ parse('port: 8080', {integers: 'number'});
66
+ //=> {port: 8080}
60
67
  ```
61
68
  */
62
69
  export declare function parse(text: string | Uint8Array, options?: ParseOptions & {
63
70
  readonly integers?: 'bigint';
64
71
  }): Document;
72
+ /**
73
+ Parse a document. Returns an object or an array, because a document is always a collection.
74
+
75
+ @param text - The document, as a string or as UTF-8 bytes. Invalid UTF-8 is reported with its position.
76
+ @returns The object or array the document contains.
77
+ @throws {ParseError} When the document is not valid.
78
+ @throws {TypeError} When `text` is not a string or a `Uint8Array`, or `options` is not an object or its `integers` is not `'bigint'` or `'number'`.
79
+
80
+ @example
81
+ ```
82
+ import {parse} from 'soml-lang';
83
+
84
+ parse(`
85
+ name: 'api-gateway'
86
+ replicas: 3
87
+ timeout: 30.0
88
+ postgres: {host: 'db.internal'}
89
+ `);
90
+ //=> {name: 'api-gateway', replicas: 3n, timeout: 30, postgres: {host: 'db.internal'}}
91
+
92
+ parse('port: 8080', {integers: 'number'});
93
+ //=> {port: 8080}
94
+ ```
95
+ */
65
96
  export declare function parse(text: string | Uint8Array, options: ParseOptions & {
66
97
  readonly integers: 'number';
67
98
  }): Document<number>;
99
+ /**
100
+ Parse a document. Returns an object or an array, because a document is always a collection.
101
+
102
+ @param text - The document, as a string or as UTF-8 bytes. Invalid UTF-8 is reported with its position.
103
+ @returns The object or array the document contains.
104
+ @throws {ParseError} When the document is not valid.
105
+ @throws {TypeError} When `text` is not a string or a `Uint8Array`, or `options` is not an object or its `integers` is not `'bigint'` or `'number'`.
106
+
107
+ @example
108
+ ```
109
+ import {parse} from 'soml-lang';
110
+
111
+ parse(`
112
+ name: 'api-gateway'
113
+ replicas: 3
114
+ timeout: 30.0
115
+ postgres: {host: 'db.internal'}
116
+ `);
117
+ //=> {name: 'api-gateway', replicas: 3n, timeout: 30, postgres: {host: 'db.internal'}}
118
+
119
+ parse('port: 8080', {integers: 'number'});
120
+ //=> {port: 8080}
121
+ ```
122
+ */
68
123
  export declare function parse(text: string | Uint8Array, options?: ParseOptions): Document | Document<number>;
124
+ export declare class Time {
125
+ readonly type: 'Instant' | 'Duration';
126
+ readonly nanoseconds: bigint;
127
+ constructor(type: 'Instant' | 'Duration', nanoseconds: bigint);
128
+ toTemporal(): Temporal.Instant | Temporal.Duration;
129
+ }
130
+ export declare function parseWithTimes(text: string): ParsedObject | ParsedValue[];
131
+ export {};