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/distribution/edit.d.ts +56 -0
- package/distribution/edit.js +536 -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 +69 -6
- package/distribution/parse.js +603 -227
- 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 +111 -9
- package/distribution/tree.js +214 -95
- package/package.json +4 -3
- package/readme.md +256 -50
package/readme.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# soml
|
|
2
2
|
|
|
3
|
-
> The reference parser,
|
|
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](
|
|
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
|
|
@@ -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
|
|
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
|
|
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 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` | `
|
|
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 `{`, `}`, `[`, `]`, `:`,
|
|
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](
|
|
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,
|
|
183
|
-
//=> 'pool: {
|
|
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
|
-
|
|
204
|
+
In detail, as the specification states:
|
|
187
205
|
|
|
188
|
-
- A
|
|
189
|
-
-
|
|
190
|
-
- A
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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**,
|
|
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
|
|
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.
|
|
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` |
|
|
298
|
-
| this package |
|
|
299
|
-
| smol-toml |
|
|
300
|
-
| json5 | 22 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
|
-
|
|
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.
|