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.
package/readme.md CHANGED
@@ -1,16 +1,17 @@
1
1
  # soml
2
2
 
3
- > The reference parser, canonical serializer, and formatter for [SOML](../soml/readme.md), a config format for humans
3
+ > The reference parser, serializer, and formatter for [SOML](https://soml.sh), a config format for humans
4
4
 
5
5
  > [!NOTE]
6
- > The format is a draft. See the [specification](../soml/spec.md).
6
+ > The format is a draft. See the [specification](https://github.com/soml-lang/soml/blob/main/spec.md).
7
7
 
8
8
  - Strict: every rule in the specification is enforced, and every error says what is wrong and where
9
9
  - Exact: an int is a `bigint`, so every 64-bit int survives, and `3` and `3.0` stay different
10
- - Canonical: `stringify()` writes the one canonical form, so equal values give equal bytes
11
- - Safe: no prototype pollution, define semantics for every object member even with a frozen or modified `Object.prototype`, no stack overflow on deep input or on huge tokens, and `parse()` takes linear time on every input shape
12
- - Fast: parses about 1.7 times as fast as smol-toml and 7 times as fast as json5
13
- - Tested by a language-neutral [conformance suite](test/conformance) of almost 900 cases, plus property tests and fuzzing
10
+ - Canonical: `stringify()` keeps the member order of its input for people to read, and with `canonical: true` it writes the one canonical form, so equal values give equal bytes
11
+ - Lossless: `format()` and `edit()` keep comments, member order, and the spelling of every value they do not change
12
+ - Safe: no prototype pollution, every object member is created as an own property even when `Object.prototype` is frozen or changed, no stack overflow on deep input or on huge tokens, and `parse()`, `parseTree()`, `format()`, `edit()`, and `stringify()` take linear time on every input shape
13
+ - Fast: parses about 1.5 times as fast as smol-toml and 7 times as fast as json5
14
+ - Tested by a language-neutral [conformance suite](test/conformance) of more than 900 cases, plus property tests and fuzzing
14
15
 
15
16
  ## Install
16
17
 
@@ -18,13 +19,15 @@
18
19
  npm install soml-lang
19
20
  ```
20
21
 
21
- Requires Node.js 22 or later. Instants and durations use `Temporal`, which Node.js 26 and later have built in. On older versions, everything else works, but an instant, a duration, or a `Date` throws. To use them, load a [`Temporal` polyfill](https://github.com/fullcalendar/temporal-polyfill) before you import this package:
22
+ Requires Node.js 22 or later. Instants and durations use `Temporal`, which Node.js 26 and later have built in. On older versions, everything else works, including `format()`, `parseTree()`, and `edit()` on a document with instants and durations, but `parse()` of an instant or a duration, and reading the `value` of an `Instant` or `Duration` node throw. Spreading or serializing such a node reads its `value` too. To use them, load a [`Temporal` polyfill](https://github.com/fullcalendar/temporal-polyfill) before you import this package:
22
23
 
23
24
  ```js
24
25
  import 'temporal-polyfill/global';
25
26
  import {parse} from 'soml-lang';
26
27
  ```
27
28
 
29
+ The types use the global `Temporal` types, so they need TypeScript 6 or later. They load its `esnext.temporal` lib themselves, so your `lib` setting does not need to include it.
30
+
28
31
  ## Usage
29
32
 
30
33
  ```js
@@ -37,26 +40,26 @@ replicas: 3
37
40
  timeout: 30.0
38
41
  grace: 1m30s
39
42
  deployed-at: 2026-09-19T14:00:00Z
40
- postgres.host: 'db.internal'
43
+ postgres: {host: 'db.internal'}
41
44
  `);
42
45
  //=> {
43
46
  // name: 'api-gateway',
44
47
  // replicas: 3n,
45
48
  // timeout: 30,
46
- // grace: Temporal.Duration <PT1M30S>,
47
- // 'deployed-at': Temporal.Instant <2026-09-19T14:00:00Z>,
49
+ // grace: Temporal.Duration.from('PT1M30S'),
50
+ // 'deployed-at': Temporal.Instant.from('2026-09-19T14:00:00Z'),
48
51
  // postgres: {host: 'db.internal'},
49
52
  // }
50
53
 
51
54
  stringify(config);
52
- //=> `deployed-at: 2026-09-19T14:00:00Z
55
+ //=> `name: 'api-gateway'
56
+ // replicas: 3
57
+ // timeout: 30.0
53
58
  // grace: 1m30s
54
- // name: 'api-gateway'
59
+ // deployed-at: 2026-09-19T14:00:00Z
55
60
  // postgres: {
56
- // host: 'db.internal',
61
+ // host: 'db.internal'
57
62
  // }
58
- // replicas: 3
59
- // timeout: 30.0
60
63
  // `
61
64
  ```
62
65
 
@@ -82,7 +85,7 @@ The specification says that a reader which conflates `3` and `3.0` is not confor
82
85
  With this mapping, two things always hold:
83
86
 
84
87
  - `parse(stringify(value))` deep-equals `value`, for a value made of the types `parse()` returns. (A `Date` comes back as a `Temporal.Instant`, a `Temporal.Duration` as the same length in hours and smaller units, `-0` as `0`, and an object with a null prototype as a plain object.)
85
- - `stringify(parse(text))` is the canonical form of `text`, and `stringify(parse(canonical)) === canonical`.
88
+ - `stringify(parse(text), {canonical: true})` is the canonical form of `text`, and `stringify(parse(canonical), {canonical: true}) === canonical`. Without the option, the output differs from canonical form only in member order: members are in the order of `text`, except that integer-like keys, such as `404`, come first, in numeric order, as JavaScript enumerates them.
86
89
 
87
90
  ## API
88
91
 
@@ -90,7 +93,19 @@ With this mapping, two things always hold:
90
93
 
91
94
  Parse a document. Returns an object or an array, because a document is always a collection.
92
95
 
93
- Throws a [`ParseError`](#parseerror) when `text` is not a valid document, and a `TypeError` when `text` is not a string or a `Uint8Array`, or an option is invalid.
96
+ Throws a [`ParseError`](#parseerror) when `text` is not a valid document, and a `TypeError` when `text` is not a string or a `Uint8Array`, or `options` is not an object or its `integers` is not `'bigint'` or `'number'`.
97
+
98
+ ```js
99
+ import {parse} from 'soml-lang';
100
+
101
+ parse(`
102
+ name: 'api-gateway'
103
+ replicas: 3
104
+ timeout: 30.0
105
+ postgres: {host: 'db.internal'}
106
+ `);
107
+ //=> {name: 'api-gateway', replicas: 3n, timeout: 30, postgres: {host: 'db.internal'}}
108
+ ```
94
109
 
95
110
  #### text
96
111
 
@@ -109,7 +124,8 @@ Default: `'bigint'`
109
124
 
110
125
  How an int is represented.
111
126
 
112
- With `'number'`, an int is a `number`, and an int outside `Number.MIN_SAFE_INTEGER` to `Number.MAX_SAFE_INTEGER` throws a `ParseError` rather than being rounded. `3` and `3.0` both become `3`, so this mode is convenient but not conforming.
127
+ - `'bigint'`: Every int is a `bigint`, and every float is a `number`, so `3` and `3.0` stay different, and every 64-bit int is exact. This is the only conforming mode.
128
+ - `'number'`: Every int is a `number`. An int outside `Number.MIN_SAFE_INTEGER` to `Number.MAX_SAFE_INTEGER` throws a `ParseError` rather than being rounded. `3` and `3.0` both become `3`, so `stringify()` cannot tell them apart afterwards.
113
129
 
114
130
  ```js
115
131
  parse('port: 8080', {integers: 'number'});
@@ -134,7 +150,7 @@ tree.comments[0];
134
150
  //=> {type: 'Line', value: ' The default', range: [11, 24], loc: {…}}
135
151
  ```
136
152
 
137
- Every node, token, and comment has a `range`, which is `[start, end]` as UTF-16 offsets into `text`, and a `loc`, which is `{start: {line, column}, end: {line, column}}`, with a 1-based line and a 0-based column in UTF-16 code units, as in [ESTree](https://github.com/estree/estree). A [`ParseError`](#parseerror) counts its column differently, for people to read, so use its `offset` to find the position in the tree.
153
+ Every node, token, and comment has a `range`, which is `[start, end]` as UTF-16 offsets into `text`, and a `loc`, which is `{start: {line, column}, end: {line, column}}`, with a 1-based line and a 0-based column in UTF-16 code units, as in [ESTree](https://github.com/estree/estree). A [`ParseError`](#parseerror) counts its column differently, for people to read, so use its `offset` to find the position in the tree. A scalar node or a key shares its `range` and `loc` with its token, and other nodes, except `Document`, share the positions in `loc` with their first and last token, so treat them as read-only.
138
154
 
139
155
  | Node | Fields |
140
156
  |---|---|
@@ -142,17 +158,16 @@ Every node, token, and comment has a `range`, which is `[start, end]` as UTF-16
142
158
  | `Object` | `members`; `braced`, which is `false` only for a top-level object without braces |
143
159
  | `Member` | `key`; `value` |
144
160
  | `Array` | `elements` |
145
- | `Key` | `segments`, more than one for a dotted key |
146
- | `KeySegment` | `value`, decoded; `style`: `'bare'`, `'literal'`, or `'escaped'` |
161
+ | `Key` | `value`, decoded; `style`: `'bare'`, `'literal'`, or `'escaped'` |
147
162
  | `String` | `value`, decoded; `style`: `'literal'` or `'escaped'`; `block` |
148
163
  | `Integer` | `value`, a `bigint`; `radix`: `2`, `8`, `10`, or `16` |
149
164
  | `Float` | `value`, including `Infinity` and `-Infinity` |
150
165
  | `Boolean` | `value` |
151
166
  | `Null` | |
152
- | `Instant` | `value`, a `Temporal.Instant` |
153
- | `Duration` | `value`, a `Temporal.Duration` |
167
+ | `Instant` | `value`, a `Temporal.Instant`, made when it is first read, also by spreading or serializing the node |
168
+ | `Duration` | `value`, a `Temporal.Duration`, made when it is first read, also by spreading or serializing the node; `negative`, whether it is written with a `-`; `parts`, each `{number, unit}` as written, so `-1h1.5m` has the parts `{number: '1', unit: 'h'}` and `{number: '1.5', unit: 'm'}` |
154
169
 
155
- A token is `{type, value, range, loc}`, where `type` is `'Punctuator'` (for `{`, `}`, `[`, `]`, `:`, `,`, and `.`), `'BareKey'`, `'String'`, `'Integer'`, `'Float'`, `'Keyword'` (for `true`, `false`, and `null`), `'Instant'`, or `'Duration'`, and `value` is its source text. A comment is `{type, value, range, loc}`, where `type` is `'Line'` or `'Block'`, and `value` is the text without `#`, or without `/*` and `*/`.
170
+ A token is `{type, value, range, loc}`, where `type` is `'Punctuator'` (for `{`, `}`, `[`, `]`, `:`, and `,`), `'BareKey'`, `'String'`, `'Integer'`, `'Float'`, `'Keyword'` (for `true`, `false`, and `null`), `'Instant'`, or `'Duration'`, and `value` is its source text. `infinity` and `-infinity` are `'Float'` tokens, and a quoted key is a `'String'` token. A comment is `{type, value, range, loc}`, where `type` is `'Line'` or `'Block'`, and `value` is the text without `#`, or without `/*` and `*/`.
156
171
 
157
172
  ### visitorKeys
158
173
 
@@ -172,33 +187,148 @@ visitorKeys.Integer;
172
187
 
173
188
  Format a document. Returns the document with its layout normalized, ending with one line feed.
174
189
 
175
- The layout follows the [formatter](../soml/spec.md#formatting) in the specification: one tab per level, every member and item on its own line, a trailing comma after every member and item inside `{}` and `[]`, one space after `:`, and no trailing whitespace or runs of blank lines. Comments, member order, dotted keys, block strings, and the spelling of every value stay as they are, so the value never changes.
190
+ The layout follows the [formatter](https://github.com/soml-lang/soml/blob/main/spec.md#formatting) 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.
176
191
 
177
192
  Throws the same [`ParseError`](#parseerror) as `parse()`, and a `TypeError` when `text` is not a string.
178
193
 
179
194
  ```js
180
195
  import {format} from 'soml-lang';
181
196
 
182
- format('pool: {min: 2, max: 16} # Connections');
183
- //=> 'pool: {\n\tmin: 2,\n\tmax: 16,\n} # Connections\n'
197
+ format('pool: {min: 2, max: 16,} # Connections');
198
+ //=> 'pool: {min: 2, max: 16} # Connections\n'
199
+
200
+ format('pool: {\nmin: 2, max: 16}');
201
+ //=> 'pool: {\n\tmin: 2\n\tmax: 16\n}\n'
184
202
  ```
185
203
 
186
- Where the specification does not say, the formatter decides:
204
+ In detail, as the specification states:
187
205
 
188
- - A comment at the end of a line stays at the end of that line, after the inserted comma. A block comment that a value follows on the same line, as in `[1, /* note */ 2]`, stays in front of that value.
189
- - A value on the line after its `key:` moves up to that line, unless a comment comes between them.
190
- - A block string moves with its closing delimiter, and its lines are not otherwise changed.
206
+ - 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.
207
+ - One space follows each `:`, as in canonical form.
208
+ - 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.
209
+ - 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.
191
210
  - There are no blank lines at the start of the file or directly inside brackets.
192
211
 
193
- In the rare comma-first style, a comment after a comma on its own line moves to its own line when the item before it already ends with a comment.
212
+ ### formatEdits(text)
213
+
214
+ The changes that [`format()`](#formattext) makes to a document, as edits of its text: an array of `{range: [start, end], text}`, with UTF-16 offsets into `text`. For an editor or a linter, which shows or applies each change where it is, rather than replacing the whole document.
215
+
216
+ 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.
217
+
218
+ Throws the same errors as `format()`.
219
+
220
+ ```js
221
+ import {formatEdits} from 'soml-lang';
222
+
223
+ formatEdits('a: [1,\n2]\n');
224
+ //=> [{range: [4, 4], text: '\n\t'}, {range: [5, 7], text: '\n\t'}, {range: [8, 8], text: '\n'}]
225
+ ```
226
+
227
+ ### edit(text, path, value, options?)
228
+
229
+ 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.
230
+
231
+ 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.
232
+
233
+ ```js
234
+ import {edit} from 'soml-lang';
235
+
236
+ edit('name: \'api\' # The service\nport: 8080\n', ['port'], 9090n);
237
+ //=> "name: 'api' # The service\nport: 9090\n"
238
+
239
+ edit('postgres: {host: \'db\'}\n', ['postgres', 'port'], 5432n);
240
+ //=> "postgres: {host: 'db', port: 5432}\n"
241
+
242
+ edit('a: 1\nb: 2\n', ['a'], undefined);
243
+ //=> 'b: 2\n'
244
+ ```
245
+
246
+ - 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}]`.
247
+ - A new member goes after the last member of its object. Missing objects on the way are created.
248
+ - A new item can be added at the end of an array, with the index that is its length.
249
+ - 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]]`.
250
+ - 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.
251
+ - 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]`.
252
+ - Removing the only member of a document without braces leaves `{}`.
253
+
254
+ 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.
255
+
256
+ The result is parsed before it is returned, so a bug in `edit()` throws an `Error` rather than returning a broken document.
257
+
258
+ Throws the same [`ParseError`](#parseerror) as `parse()` when `text` is not a valid document. Throws a `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. Throws a `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. A `value` that cannot be represented or is out of range, and invalid `options`, throw as they do in `stringify()`.
259
+
260
+ #### text
261
+
262
+ Type: `string`
263
+
264
+ The document.
265
+
266
+ #### path
267
+
268
+ Type: `Array<string | number>`
269
+
270
+ The keys and array indexes that lead to the value, such as `['servers', 0, 'port']`.
271
+
272
+ #### value
273
+
274
+ Type: `unknown`
275
+
276
+ The new value, of the types that [`stringify()`](#stringifyvalue-options) accepts, or `undefined` to remove the value.
277
+
278
+ #### options
279
+
280
+ Type: `object`
281
+
282
+ ##### integers
283
+
284
+ Type: `'bigint' | 'number'`\
285
+ Default: `'bigint'`
286
+
287
+ How an int is represented in `value`, as for [`stringify()`](#stringifyvalue-options).
288
+
289
+ ```js
290
+ edit('port: 8080', ['port'], 9090, {integers: 'number'});
291
+ //=> 'port: 9090'
292
+ ```
293
+
294
+ ##### canonical
295
+
296
+ Type: `boolean`\
297
+ Default: `false`
298
+
299
+ Sort the members of every object in `value` by key, as for [`stringify()`](#stringifyvalue-options).
300
+
301
+ ```js
302
+ edit('a: 1\n', ['b'], {y: 1n, x: 2n}, {canonical: true});
303
+ //=> 'a: 1\nb: {\n\tx: 2\n\ty: 1\n}\n'
304
+ ```
194
305
 
195
306
  ### stringify(value, options?)
196
307
 
197
- Serialize an object or an array to canonical form. Members are sorted by key, nesting is written with braces and tabs, and the output ends with one line feed. Comments, dotted keys, and block strings are never written.
308
+ Serialize an object or an array to SOML. Nesting is written with braces and tabs, and the output ends with one line feed. Comments and block strings are never written.
309
+
310
+ Members keep the order of `value`, which reads better in a file for people, and every other rule of canonical form is followed. With the [`canonical: true`](#canonical-1) option, members are sorted by key, and the output is canonical form: two equal values produce the same bytes, so it can be hashed, signed, or compared.
311
+
312
+ JavaScript puts integer-like keys, such as `404` or `10`, before every other key, in numeric order, so their order in `value` cannot be kept.
313
+
314
+ A `bigint` is written as an int and a `number` as a float, so `{port: 8080}` becomes `port: 8080.0`. Use `8080n`, or the `integers: 'number'` option.
315
+
316
+ Besides the types that `parse()` returns, a `Date` is accepted and written as an instant, a `Temporal.Duration` is accepted when it has no years, months, weeks, or days, because those are not a fixed length, and a member whose value is `undefined` is left out, as `JSON.stringify` does.
317
+
318
+ Throws a `TypeError` for a value that cannot be represented: `NaN`, a function, a symbol value, an object that is neither a plain object nor an array (such as a class instance or a `Map`), `undefined` in an array, a circular reference, a non-collection at the top level, an invalid `Date`, a `Temporal.Duration` with years, months, weeks, or days, an object that only claims to be a `Date`, a `Temporal.Instant`, or a `Temporal.Duration`, and a string or key with a lone surrogate or a carriage return. Also when `options` is not an object, its `integers` is not `'bigint'` or `'number'`, or its `canonical` is not a boolean. Throws a `RangeError` for an int outside the 64-bit range, an instant outside the years 0001 to 9999, a duration outside the 64-bit range of nanoseconds, and nesting deeper than 100 levels.
198
319
 
199
- Besides the types above, a `Date` is accepted and written as an instant, a `Temporal.Duration` is accepted when it has no years, months, weeks, or days, because those are not a fixed length, and a member whose value is `undefined` is left out, as `JSON.stringify` does.
320
+ ```js
321
+ import {stringify} from 'soml-lang';
322
+
323
+ stringify({name: 'api-gateway', replicas: 3n, timeout: 30});
324
+ //=> "name: 'api-gateway'\nreplicas: 3\ntimeout: 30.0\n"
325
+ ```
200
326
 
201
- Throws a `TypeError` for a value that cannot be represented: `NaN`, a function, a symbol, a class instance, `undefined` in an array, a circular reference, a non-collection at the top level, an invalid `Date`, a `Temporal.Duration` with years, months, weeks, or days, an object that only claims to be a `Date`, a `Temporal.Instant`, or a `Temporal.Duration`, and a string or key with a lone surrogate or a carriage return. Throws a `RangeError` for an int outside the 64-bit range, an instant outside the years 0001 to 9999, a duration outside the 64-bit range of nanoseconds, and nesting deeper than 100 levels.
327
+ #### value
328
+
329
+ Type: `object`
330
+
331
+ A plain object or an array.
202
332
 
203
333
  #### options
204
334
 
@@ -211,7 +341,8 @@ Default: `'bigint'`
211
341
 
212
342
  How an int is represented in `value`.
213
343
 
214
- With `'number'`, a `number` that is a safe integer is written as an int, and any other `number` as a float. A `bigint` is always written as an int.
344
+ - `'bigint'`: A `bigint` is written as an int, and a `number` is always written as a float, so `8080` becomes `8080.0`.
345
+ - `'number'`: A `number` that is a safe integer is written as an int, and any other `number` as a float. A `bigint` is still written as an int.
215
346
 
216
347
  ```js
217
348
  stringify({port: 8080});
@@ -221,9 +352,81 @@ stringify({port: 8080}, {integers: 'number'});
221
352
  //=> 'port: 8080\n'
222
353
  ```
223
354
 
355
+ ##### canonical
356
+
357
+ Type: `boolean`\
358
+ Default: `false`
359
+
360
+ Write exact canonical form, with the members of every object sorted by key, for hashing, signing, or comparing.
361
+
362
+ By default, members keep the order of `value`, which reads better in a file for people, and every other rule of canonical form is followed. JavaScript puts integer-like keys, such as `404` or `10`, before every other key, in numeric order, so their order in `value` cannot be kept.
363
+
364
+ ```js
365
+ stringify({name: 'api', description: 'The edge service'});
366
+ //=> "name: 'api'\ndescription: 'The edge service'\n"
367
+
368
+ stringify({name: 'api', description: 'The edge service'}, {canonical: true});
369
+ //=> "description: 'The edge service'\nname: 'api'\n"
370
+ ```
371
+
372
+ ### stringifyValue(value, options?)
373
+
374
+ Serialize one value as it is written in a document: a scalar on one line, and an object or an array in braces or brackets over several lines, with each level indented by one more tab. There is no line feed at the end.
375
+
376
+ For showing a value, as in a message, or for writing it into a template. Use [`stringify()`](#stringifyvalue-options) to write a whole document, and [`edit()`](#edittext-path-value-options) to change a value in one.
377
+
378
+ It takes the same values and [options](#options-2) as `stringify()`, and any of them at the top level, so `1.5` gives `'1.5'` and `'it\'s'` gives `"it's"`. Members keep their order, as in `stringify()`, unless the `canonical: true` option sorts them. It throws as `stringify()` does, and a `TypeError` for `undefined`.
379
+
380
+ ```js
381
+ import {stringifyValue} from 'soml-lang';
382
+
383
+ stringifyValue(0.1 + 0.2);
384
+ //=> '0.30000000000000004'
385
+
386
+ stringifyValue(8080n);
387
+ //=> '8080'
388
+
389
+ stringifyValue({b: 1n, a: [true]});
390
+ //=> '{\n\tb: 1\n\ta: [\n\t\ttrue\n\t]\n}'
391
+
392
+ stringifyValue({b: 1n, a: [true]}, {canonical: true});
393
+ //=> '{\n\ta: [\n\t\ttrue\n\t]\n\tb: 1\n}'
394
+ ```
395
+
396
+ ### compareKeys(left, right)
397
+
398
+ Compare two keys in canonical order, which is by their Unicode scalar values, as `stringify()` sorts members with the `canonical: true` option. For a sort, such as in a lint rule that keeps the members of an object in canonical order.
399
+
400
+ The default `Array#sort()` compares UTF-16 code units, which puts U+E000 to U+FFFF after every character above U+FFFF, so it differs from canonical order for keys that hold such characters.
401
+
402
+ Returns a negative number when `left` comes first, a positive number when `right` comes first, and `0` when they are equal.
403
+
404
+ ```js
405
+ import {compareKeys} from 'soml-lang';
406
+
407
+ ['b', 'a', '😀', 'ff'].toSorted(compareKeys);
408
+ //=> ['a', 'b', 'ff', '😀']
409
+ ```
410
+
411
+ ### isBareKey(key)
412
+
413
+ Whether a key can be written without quotes, as a bare key: one or more ASCII letters, digits, `_`, or `-`, in any order, so `404` and `-x` are bare keys too. `stringify()` writes such a key bare and quotes every other key.
414
+
415
+ ```js
416
+ import {isBareKey} from 'soml-lang';
417
+
418
+ isBareKey('content-type');
419
+ //=> true
420
+
421
+ isBareKey('a.b');
422
+ //=> false
423
+ ```
424
+
224
425
  ### ParseError
225
426
 
226
- Thrown by `parse()`. Extends `SyntaxError`.
427
+ Thrown when the input is not a valid document, by `parse()`, `parseTree()`, `format()`, and `edit()`. Extends `SyntaxError`.
428
+
429
+ The `message` includes the position and a code frame. Use `reason` for the message alone.
227
430
 
228
431
  ```js
229
432
  import {parse, ParseError} from 'soml-lang';
@@ -235,7 +438,7 @@ try {
235
438
  console.log(error.message);
236
439
  }
237
440
  }
238
- // Unexpected "api-gateway". A string value must be quoted, as in 'api-gateway' at line 1, column 7
441
+ // Unexpected “api-gateway”. A string value must be quoted, as in 'api-gateway' at line 1, column 7
239
442
  //
240
443
  // > 1 | name: api-gateway
241
444
  // | ^
@@ -251,19 +454,19 @@ What is wrong, without the position.
251
454
 
252
455
  Type: `number`
253
456
 
254
- The 1-based line.
457
+ The 1-based line of the error.
255
458
 
256
459
  #### column
257
460
 
258
461
  Type: `number`
259
462
 
260
- The 1-based column, counted in Unicode code points.
463
+ The 1-based column of the error, counted in Unicode code points.
261
464
 
262
465
  #### offset
263
466
 
264
467
  Type: `number`
265
468
 
266
- The 0-based UTF-16 index in the decoded text.
469
+ The 0-based UTF-16 index of the error in the decoded text.
267
470
 
268
471
  #### codeFrame
269
472
 
@@ -273,16 +476,17 @@ Up to three lines ending at the error, with a caret under the position. A long l
273
476
 
274
477
  ## Limits
275
478
 
276
- - **Nesting is limited to 100 levels**, in `parse()` and in `stringify()`, as the spec requires: every document up to 100 levels is accepted, and every deeper one is rejected. The document's own collection is level 1, and a dotted key counts each segment.
479
+ - **Nesting is limited to 100 levels**, as the spec requires: every document up to 100 levels is accepted, by `parse()`, `parseTree()`, `format()`, and `edit()`, and every deeper one is rejected. `stringify()` refuses a deeper value, and `edit()` refuses a change that would make one. The document's own collection is level 1, so `a: {b: {c: 1}}` has a depth of 3.
277
480
 
278
481
  ## Conformance suite
279
482
 
280
483
  [`test/conformance`](test/conformance) holds the cases as plain files, so that another implementation can run them:
281
484
 
282
- - `valid/**/name.soml` must parse to the value in `name.json`, and serialize to exactly `name.canonical.soml`.
485
+ - `valid/**/name.soml` must parse to the value in `name.json`, and serialize in canonical form, with members sorted by key, to exactly `name.canonical.soml`. An implementation that has a formatter must also format it to exactly `name.formatted.soml`.
283
486
  - `invalid/**/name.soml` must be rejected.
487
+ - `edit/name.json` holds a formatted `document`, a `path`, a tagged `value`, which is left out for a removal, and the `expected` document. An implementation that has an editor must give exactly `expected` when it sets `path` to `value`, or removes it. A case with `error: true` instead of `expected` has a path that does not fit the document, and the change must fail.
284
488
 
285
- The expected values are tagged JSON, as in [toml-test](https://github.com/toml-lang/toml-test): `{"type": "int", "value": "3"}`. The types are `string`, `int`, `float`, `bool`, `null`, `instant`, and `duration`. A float's value is its canonical spelling, including `infinity` and `-infinity`, an instant's is its canonical UTC form, and a duration's is its length in nanoseconds as a decimal int. Case names differ in more than letter case, so the suite checks out on case-insensitive file systems.
489
+ The expected values are tagged JSON, as in [toml-test](https://github.com/toml-lang/toml-test): `{"type": "int", "value": "3"}`. The types are `string`, `int`, `float`, `bool`, `null`, `instant`, and `duration`. A float's value is its canonical spelling, including `infinity` and `-infinity`, an instant's is its canonical UTC form, and a duration's is its length in nanoseconds as a decimal int. Case names differ in more than letter case, and none is a name that Windows reserves, such as `nul`, so the suite checks out on every file system.
286
490
 
287
491
  ## Benchmark
288
492
 
@@ -290,15 +494,17 @@ The expected values are tagged JSON, as in [toml-test](https://github.com/toml-l
290
494
  npm run bench
291
495
  ```
292
496
 
293
- The same 2.6 MB of config-shaped data on an Apple M-series machine with Node.js 26:
497
+ The same 2.5 MB of config-shaped data on an Apple M-series machine with Node.js 26:
294
498
 
295
499
  | | parse | stringify |
296
500
  |---|---|---|
297
- | `JSON` | 570 MB/s | 650 MB/s |
298
- | this package | 155 MB/s | 118 MB/s |
299
- | smol-toml | 91 MB/s | 133 MB/s |
300
- | json5 | 22 MB/s | 80 MB/s |
501
+ | `JSON` | 600 MB/s | 730 MB/s |
502
+ | this package | 145 MB/s | 120 MB/s |
503
+ | smol-toml | 100 MB/s | 135 MB/s |
504
+ | json5 | 22 MB/s | 85 MB/s |
505
+
506
+ A hand-written style document (comments, block strings, hex ints, instants) parses at about 110 MB/s, and minified one-line input at about 120 MB/s. The `stringify` figure is with `canonical: true`, which sorts every object's keys, a step smol-toml does not need. The default keeps member order and skips that step, so it is faster.
301
507
 
302
- A hand-written style document (comments, dotted keys, block strings, hex ints, instants) parses at about 120 MB/s, and so does minified one-line input. `stringify` sorts every object's keys, which smol-toml does not need to do.
508
+ `parseTree()` runs at about 35 MB/s, and `format()` at about 20 to 25 MB/s, also on an already formatted document. Both are slower than `parse()`, because the tree has a node and a location for every token.
303
509
 
304
510
  `JSON` is native code and a much smaller grammar, so it is the ceiling rather than a competitor.