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/distribution/edit.d.ts +56 -0
- package/distribution/edit.js +635 -0
- package/distribution/error.d.ts +19 -2
- package/distribution/error.js +20 -3
- package/distribution/format.d.ts +48 -4
- package/distribution/format.js +209 -49
- package/distribution/index.d.ts +6 -3
- package/distribution/index.js +7 -2
- package/distribution/parse.d.ts +68 -5
- package/distribution/parse.js +593 -177
- package/distribution/shared.d.ts +58 -4
- package/distribution/shared.js +93 -11
- package/distribution/stringify.d.ts +88 -8
- package/distribution/stringify.js +173 -59
- package/distribution/tree.d.ts +115 -4
- package/distribution/tree.js +221 -86
- package/package.json +4 -3
- package/readme.md +254 -47
package/readme.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# soml
|
|
2
2
|
|
|
3
|
-
> The reference parser, canonical
|
|
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](
|
|
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
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
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
|
|
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
|
|
47
|
-
// 'deployed-at': Temporal.Instant
|
|
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
|
-
//=> `
|
|
55
|
+
//=> `name: 'api-gateway'
|
|
56
|
+
// replicas: 3
|
|
57
|
+
// timeout: 30.0
|
|
53
58
|
// grace: 1m30s
|
|
54
|
-
//
|
|
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
|
|
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
|
-
|
|
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](
|
|
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,
|
|
183
|
-
//=> 'pool: {
|
|
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
|
-
|
|
205
|
+
In detail, as the specification states:
|
|
187
206
|
|
|
188
|
-
- A
|
|
189
|
-
-
|
|
190
|
-
- A
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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**,
|
|
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
|
|
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.
|
|
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` |
|
|
298
|
-
| this package |
|
|
299
|
-
| smol-toml |
|
|
300
|
-
| json5 | 22 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
|
-
|
|
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.
|