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.
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 with canonical form, 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
@@ -43,20 +46,20 @@ postgres.host: 'db.internal'
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 segment 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
  |---|---|
@@ -149,10 +165,10 @@ Every node, token, and comment has a `range`, which is `[start, end]` as UTF-16
149
165
  | `Float` | `value`, including `Infinity` and `-Infinity` |
150
166
  | `Boolean` | `value` |
151
167
  | `Null` | |
152
- | `Instant` | `value`, a `Temporal.Instant` |
153
- | `Duration` | `value`, a `Temporal.Duration` |
168
+ | `Instant` | `value`, a `Temporal.Instant`, made when it is first read, also by spreading or serializing the node |
169
+ | `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
170
 
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 `*/`.
171
+ 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 segment 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
172
 
157
173
  ### visitorKeys
158
174
 
@@ -172,33 +188,148 @@ visitorKeys.Integer;
172
188
 
173
189
  Format a document. Returns the document with its layout normalized, ending with one line feed.
174
190
 
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.
191
+ 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, dotted keys, 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
192
 
177
193
  Throws the same [`ParseError`](#parseerror) as `parse()`, and a `TypeError` when `text` is not a string.
178
194
 
179
195
  ```js
180
196
  import {format} from 'soml-lang';
181
197
 
182
- format('pool: {min: 2, max: 16} # Connections');
183
- //=> 'pool: {\n\tmin: 2,\n\tmax: 16,\n} # Connections\n'
198
+ format('pool: {min: 2, max: 16,} # Connections');
199
+ //=> 'pool: {min: 2, max: 16} # Connections\n'
200
+
201
+ format('pool: {\nmin: 2, max: 16}');
202
+ //=> 'pool: {\n\tmin: 2\n\tmax: 16\n}\n'
184
203
  ```
185
204
 
186
- Where the specification does not say, the formatter decides:
205
+ In detail, as the specification states:
187
206
 
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.
207
+ - 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.
208
+ - One space follows each `:`, as in canonical form.
209
+ - 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.
210
+ - 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
211
  - There are no blank lines at the start of the file or directly inside brackets.
192
212
 
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.
213
+ ### formatEdits(text)
214
+
215
+ 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.
216
+
217
+ 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.
218
+
219
+ Throws the same errors as `format()`.
220
+
221
+ ```js
222
+ import {formatEdits} from 'soml-lang';
223
+
224
+ formatEdits('a: [1,\n2]\n');
225
+ //=> [{range: [4, 4], text: '\n\t'}, {range: [5, 7], text: '\n\t'}, {range: [8, 8], text: '\n'}]
226
+ ```
227
+
228
+ ### edit(text, path, value, options?)
229
+
230
+ 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.
231
+
232
+ 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.
233
+
234
+ ```js
235
+ import {edit} from 'soml-lang';
236
+
237
+ edit('name: \'api\' # The service\nport: 8080\n', ['port'], 9090n);
238
+ //=> "name: 'api' # The service\nport: 9090\n"
239
+
240
+ edit('postgres.host: \'db\'\n', ['postgres', 'port'], 5432n);
241
+ //=> "postgres.host: 'db'\npostgres.port: 5432\n"
242
+
243
+ edit('a: 1\nb: 2\n', ['a'], undefined);
244
+ //=> 'b: 2\n'
245
+ ```
246
+
247
+ - 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}]`.
248
+ - 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.
249
+ - A new item can be added at the end of an array, with the index that is its length.
250
+ - 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]]`.
251
+ - 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.
252
+ - 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]`.
253
+ - 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 `{}`.
254
+
255
+ 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.
256
+
257
+ The result is parsed before it is returned, so a bug in `edit()` throws an `Error` rather than returning a broken document.
258
+
259
+ 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()`.
260
+
261
+ #### text
262
+
263
+ Type: `string`
264
+
265
+ The document.
266
+
267
+ #### path
268
+
269
+ Type: `Array<string | number>`
270
+
271
+ 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'`.
272
+
273
+ #### value
274
+
275
+ Type: `unknown`
276
+
277
+ The new value, of the types that [`stringify()`](#stringifyvalue-options) accepts, or `undefined` to remove the value.
278
+
279
+ #### options
280
+
281
+ Type: `object`
282
+
283
+ ##### integers
284
+
285
+ Type: `'bigint' | 'number'`\
286
+ Default: `'bigint'`
287
+
288
+ How an int is represented in `value`, as for [`stringify()`](#stringifyvalue-options).
289
+
290
+ ```js
291
+ edit('port: 8080', ['port'], 9090, {integers: 'number'});
292
+ //=> 'port: 9090'
293
+ ```
294
+
295
+ ##### canonical
296
+
297
+ Type: `boolean`\
298
+ Default: `false`
299
+
300
+ Sort the members of every object in `value` by key, as for [`stringify()`](#stringifyvalue-options).
301
+
302
+ ```js
303
+ edit('a: 1\n', ['b'], {y: 1n, x: 2n}, {canonical: true});
304
+ //=> 'a: 1\nb: {\n\tx: 2\n\ty: 1\n}\n'
305
+ ```
194
306
 
195
307
  ### stringify(value, options?)
196
308
 
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.
309
+ Serialize an object or an array to SOML. Nesting is written with braces and tabs, and the output ends with one line feed. Comments, dotted keys, and block strings are never written.
310
+
311
+ 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.
312
+
313
+ 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.
314
+
315
+ 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.
316
+
317
+ 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.
318
+
319
+ 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
320
 
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.
321
+ ```js
322
+ import {stringify} from 'soml-lang';
323
+
324
+ stringify({name: 'api-gateway', replicas: 3n, timeout: 30});
325
+ //=> "name: 'api-gateway'\nreplicas: 3\ntimeout: 30.0\n"
326
+ ```
200
327
 
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.
328
+ #### value
329
+
330
+ Type: `object`
331
+
332
+ A plain object or an array.
202
333
 
203
334
  #### options
204
335
 
@@ -211,7 +342,8 @@ Default: `'bigint'`
211
342
 
212
343
  How an int is represented in `value`.
213
344
 
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.
345
+ - `'bigint'`: A `bigint` is written as an int, and a `number` is always written as a float, so `8080` becomes `8080.0`.
346
+ - `'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
347
 
216
348
  ```js
217
349
  stringify({port: 8080});
@@ -221,9 +353,81 @@ stringify({port: 8080}, {integers: 'number'});
221
353
  //=> 'port: 8080\n'
222
354
  ```
223
355
 
356
+ ##### canonical
357
+
358
+ Type: `boolean`\
359
+ Default: `false`
360
+
361
+ Write exact canonical form, with the members of every object sorted by key, for hashing, signing, or comparing.
362
+
363
+ 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.
364
+
365
+ ```js
366
+ stringify({name: 'api', description: 'The edge service'});
367
+ //=> "name: 'api'\ndescription: 'The edge service'\n"
368
+
369
+ stringify({name: 'api', description: 'The edge service'}, {canonical: true});
370
+ //=> "description: 'The edge service'\nname: 'api'\n"
371
+ ```
372
+
373
+ ### stringifyValue(value, options?)
374
+
375
+ 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.
376
+
377
+ 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.
378
+
379
+ 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`.
380
+
381
+ ```js
382
+ import {stringifyValue} from 'soml-lang';
383
+
384
+ stringifyValue(0.1 + 0.2);
385
+ //=> '0.30000000000000004'
386
+
387
+ stringifyValue(8080n);
388
+ //=> '8080'
389
+
390
+ stringifyValue({b: 1n, a: [true]});
391
+ //=> '{\n\tb: 1\n\ta: [\n\t\ttrue\n\t]\n}'
392
+
393
+ stringifyValue({b: 1n, a: [true]}, {canonical: true});
394
+ //=> '{\n\ta: [\n\t\ttrue\n\t]\n\tb: 1\n}'
395
+ ```
396
+
397
+ ### compareKeys(left, right)
398
+
399
+ 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.
400
+
401
+ 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.
402
+
403
+ Returns a negative number when `left` comes first, a positive number when `right` comes first, and `0` when they are equal.
404
+
405
+ ```js
406
+ import {compareKeys} from 'soml-lang';
407
+
408
+ ['b', 'a', '😀', 'ff'].toSorted(compareKeys);
409
+ //=> ['a', 'b', 'ff', '😀']
410
+ ```
411
+
412
+ ### isBareKey(key)
413
+
414
+ 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.
415
+
416
+ ```js
417
+ import {isBareKey} from 'soml-lang';
418
+
419
+ isBareKey('content-type');
420
+ //=> true
421
+
422
+ isBareKey('a.b');
423
+ //=> false
424
+ ```
425
+
224
426
  ### ParseError
225
427
 
226
- Thrown by `parse()`. Extends `SyntaxError`.
428
+ Thrown when the input is not a valid document, by `parse()`, `parseTree()`, `format()`, and `edit()`. Extends `SyntaxError`.
429
+
430
+ The `message` includes the position and a code frame. Use `reason` for the message alone.
227
431
 
228
432
  ```js
229
433
  import {parse, ParseError} from 'soml-lang';
@@ -235,7 +439,7 @@ try {
235
439
  console.log(error.message);
236
440
  }
237
441
  }
238
- // Unexpected "api-gateway". A string value must be quoted, as in 'api-gateway' at line 1, column 7
442
+ // Unexpected “api-gateway”. A string value must be quoted, as in 'api-gateway' at line 1, column 7
239
443
  //
240
444
  // > 1 | name: api-gateway
241
445
  // | ^
@@ -251,19 +455,19 @@ What is wrong, without the position.
251
455
 
252
456
  Type: `number`
253
457
 
254
- The 1-based line.
458
+ The 1-based line of the error.
255
459
 
256
460
  #### column
257
461
 
258
462
  Type: `number`
259
463
 
260
- The 1-based column, counted in Unicode code points.
464
+ The 1-based column of the error, counted in Unicode code points.
261
465
 
262
466
  #### offset
263
467
 
264
468
  Type: `number`
265
469
 
266
- The 0-based UTF-16 index in the decoded text.
470
+ The 0-based UTF-16 index of the error in the decoded text.
267
471
 
268
472
  #### codeFrame
269
473
 
@@ -273,16 +477,17 @@ Up to three lines ending at the error, with a caret under the position. A long l
273
477
 
274
478
  ## Limits
275
479
 
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.
480
+ - **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, and a dotted key counts the objects it builds, so `a.b.c: 1` has a depth of 3, the same as `a: {b: {c: 1}}`.
277
481
 
278
482
  ## Conformance suite
279
483
 
280
484
  [`test/conformance`](test/conformance) holds the cases as plain files, so that another implementation can run them:
281
485
 
282
- - `valid/**/name.soml` must parse to the value in `name.json`, and serialize to exactly `name.canonical.soml`.
486
+ - `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
487
  - `invalid/**/name.soml` must be rejected.
488
+ - `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
489
 
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.
490
+ 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
491
 
287
492
  ## Benchmark
288
493
 
@@ -290,15 +495,17 @@ The expected values are tagged JSON, as in [toml-test](https://github.com/toml-l
290
495
  npm run bench
291
496
  ```
292
497
 
293
- The same 2.6 MB of config-shaped data on an Apple M-series machine with Node.js 26:
498
+ The same 2.5 MB of config-shaped data on an Apple M-series machine with Node.js 26:
294
499
 
295
500
  | | parse | stringify |
296
501
  |---|---|---|
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 |
502
+ | `JSON` | 600 MB/s | 730 MB/s |
503
+ | this package | 145 MB/s | 120 MB/s |
504
+ | smol-toml | 100 MB/s | 135 MB/s |
505
+ | json5 | 22 MB/s | 85 MB/s |
506
+
507
+ A hand-written style document (comments, dotted keys, 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
508
 
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.
509
+ `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
510
 
304
511
  `JSON` is native code and a much smaller grammar, so it is the ceiling rather than a competitor.