soml-lang 0.0.1
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/index.d.ts +151 -0
- package/index.js +3 -0
- package/license +9 -0
- package/package.json +91 -0
- package/readme.md +224 -0
- package/source/error.js +130 -0
- package/source/parse.js +1574 -0
- package/source/shared.js +166 -0
- package/source/stringify.js +439 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
A value in a document.
|
|
3
|
+
|
|
4
|
+
| Format | JavaScript |
|
|
5
|
+
|---|---|
|
|
6
|
+
| string | `string` |
|
|
7
|
+
| int | `bigint`, or `number` with `integers: 'number'` |
|
|
8
|
+
| float | `number` |
|
|
9
|
+
| bool | `boolean` |
|
|
10
|
+
| null | `null` |
|
|
11
|
+
| instant | `Temporal.Instant` |
|
|
12
|
+
| duration | `Temporal.Duration`, in hours and smaller units |
|
|
13
|
+
| array | `Array` |
|
|
14
|
+
| object | plain `Object` |
|
|
15
|
+
*/
|
|
16
|
+
export type Value<Integer extends bigint | number = bigint> =
|
|
17
|
+
| string
|
|
18
|
+
| Integer
|
|
19
|
+
| number
|
|
20
|
+
| boolean
|
|
21
|
+
| null // eslint-disable-line @typescript-eslint/no-restricted-types -- The format has a null value, and `parse()` returns `null` for it.
|
|
22
|
+
| Temporal.Instant
|
|
23
|
+
| Temporal.Duration
|
|
24
|
+
| Value<Integer>[] // eslint-disable-line @typescript-eslint/array-type
|
|
25
|
+
| ObjectValue<Integer>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
An object in a document.
|
|
29
|
+
*/
|
|
30
|
+
export type ObjectValue<Integer extends bigint | number = bigint> = {[key: string]: Value<Integer>};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
A whole document, which is always an object or an array.
|
|
34
|
+
*/
|
|
35
|
+
export type Document<Integer extends bigint | number = bigint> = ObjectValue<Integer> | Array<Value<Integer>>;
|
|
36
|
+
|
|
37
|
+
export type ParseOptions = {
|
|
38
|
+
/**
|
|
39
|
+
How an int is represented.
|
|
40
|
+
|
|
41
|
+
- `'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.
|
|
42
|
+
- `'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.
|
|
43
|
+
|
|
44
|
+
@default 'bigint'
|
|
45
|
+
*/
|
|
46
|
+
readonly integers?: 'bigint' | 'number';
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type StringifyOptions = {
|
|
50
|
+
/**
|
|
51
|
+
How an int is represented in the value.
|
|
52
|
+
|
|
53
|
+
- `'bigint'`: A `bigint` is written as an int, and a `number` is always written as a float, so `8080` becomes `8080.0`.
|
|
54
|
+
- `'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.
|
|
55
|
+
|
|
56
|
+
@default 'bigint'
|
|
57
|
+
*/
|
|
58
|
+
readonly integers?: 'bigint' | 'number';
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
Parse a document.
|
|
63
|
+
|
|
64
|
+
@param text - The document, as a string or as UTF-8 bytes.
|
|
65
|
+
@returns The object or array the document contains.
|
|
66
|
+
@throws {ParseError} When the document is not valid.
|
|
67
|
+
@throws {TypeError} When `text` is not a string or a `Uint8Array`, or `options.integers` is not `'bigint'` or `'number'`.
|
|
68
|
+
|
|
69
|
+
@example
|
|
70
|
+
```
|
|
71
|
+
import {parse} from 'soml';
|
|
72
|
+
|
|
73
|
+
parse(`
|
|
74
|
+
name: 'api-gateway'
|
|
75
|
+
replicas: 3
|
|
76
|
+
timeout: 30.0
|
|
77
|
+
postgres.host: 'db.internal'
|
|
78
|
+
`);
|
|
79
|
+
//=> {name: 'api-gateway', replicas: 3n, timeout: 30, postgres: {host: 'db.internal'}}
|
|
80
|
+
|
|
81
|
+
parse('replicas: 3', {integers: 'number'});
|
|
82
|
+
//=> {replicas: 3}
|
|
83
|
+
```
|
|
84
|
+
*/
|
|
85
|
+
export function parse(text: string | Uint8Array, options?: ParseOptions & {readonly integers?: 'bigint'}): Document;
|
|
86
|
+
export function parse(text: string | Uint8Array, options: ParseOptions & {readonly integers: 'number'}): Document<number>;
|
|
87
|
+
export function parse(text: string | Uint8Array, options?: ParseOptions): Document | Document<number>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
Serialize an object or an array to canonical form.
|
|
91
|
+
|
|
92
|
+
Canonical form is unique for a value: two equal values produce the same bytes, so the output can be hashed, signed, or compared. Members are sorted by key, and comments, dotted keys, and block strings are never written.
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
A `Date` is accepted and written as an instant. A member whose value is `undefined` is left out.
|
|
97
|
+
|
|
98
|
+
@param value - A plain object or an array.
|
|
99
|
+
@returns The canonical form, ending with one line feed.
|
|
100
|
+
@throws {TypeError} When the value contains something that cannot be represented, such as `NaN`, a function, a class instance, an invalid `Date`, a `Temporal.Duration` with years, months, weeks, or days, a circular reference, or a string or key with a lone surrogate or a carriage return. Also when `options.integers` is not `'bigint'` or `'number'`.
|
|
101
|
+
@throws {RangeError} When an int is outside the 64-bit range, an instant is outside the years 0001 to 9999, a duration is outside the 64-bit range of nanoseconds, or the value is nested more than 1000 levels deep.
|
|
102
|
+
|
|
103
|
+
@example
|
|
104
|
+
```
|
|
105
|
+
import {stringify} from 'soml';
|
|
106
|
+
|
|
107
|
+
stringify({name: 'api-gateway', replicas: 3n, timeout: 30});
|
|
108
|
+
//=> "name: 'api-gateway'\nreplicas: 3\ntimeout: 30.0\n"
|
|
109
|
+
```
|
|
110
|
+
*/
|
|
111
|
+
// `object` rather than `Record<string, unknown>`, so that a value typed with an interface is accepted too. The value is checked at runtime.
|
|
112
|
+
export function stringify(value: object, options?: StringifyOptions): string; // eslint-disable-line @typescript-eslint/no-restricted-types
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
Thrown by `parse()` when the input is not a valid document.
|
|
116
|
+
|
|
117
|
+
The `message` includes the position and a code frame. Use `reason` for the message alone.
|
|
118
|
+
*/
|
|
119
|
+
export class ParseError extends SyntaxError {
|
|
120
|
+
/**
|
|
121
|
+
Only `parse()` creates a `ParseError`.
|
|
122
|
+
*/
|
|
123
|
+
private constructor();
|
|
124
|
+
|
|
125
|
+
readonly name: 'ParseError';
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
What is wrong, without the position.
|
|
129
|
+
*/
|
|
130
|
+
readonly reason: string;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
The 1-based line of the error.
|
|
134
|
+
*/
|
|
135
|
+
readonly line: number;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
The 1-based column of the error, counted in Unicode code points.
|
|
139
|
+
*/
|
|
140
|
+
readonly column: number;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
The 0-based UTF-16 index of the error in the decoded text.
|
|
144
|
+
*/
|
|
145
|
+
readonly offset: number;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
Up to three lines ending at the error, with a caret under the position. A long line is clipped around the position.
|
|
149
|
+
*/
|
|
150
|
+
readonly codeFrame: string;
|
|
151
|
+
}
|
package/index.js
ADDED
package/license
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "soml-lang",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Reference parser and canonical serializer for SOML, a config format for humans",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
"types": "./index.d.ts",
|
|
10
|
+
"default": "./index.js"
|
|
11
|
+
},
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=26"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "xo && node --test && tsc",
|
|
18
|
+
"bench": "node bench/index.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"index.js",
|
|
22
|
+
"index.d.ts",
|
|
23
|
+
"source"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"config",
|
|
27
|
+
"configuration",
|
|
28
|
+
"parse",
|
|
29
|
+
"parser",
|
|
30
|
+
"stringify",
|
|
31
|
+
"serialize",
|
|
32
|
+
"canonical",
|
|
33
|
+
"json",
|
|
34
|
+
"toml",
|
|
35
|
+
"yaml"
|
|
36
|
+
],
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"json5": "^2.2.3",
|
|
39
|
+
"smol-toml": "^1.9.0",
|
|
40
|
+
"typescript": "^6.0.3",
|
|
41
|
+
"xo": "^5.0.1"
|
|
42
|
+
},
|
|
43
|
+
"xo": [
|
|
44
|
+
{
|
|
45
|
+
"ignores": [
|
|
46
|
+
"test/conformance/**",
|
|
47
|
+
"test/fixtures/**"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"rules": {
|
|
52
|
+
"unicorn/prefer-code-point": "off",
|
|
53
|
+
"unicorn/consistent-class-member-order": "off",
|
|
54
|
+
"unicorn/consistent-boolean-name": "off",
|
|
55
|
+
"unicorn/prefer-simple-condition-first": "off",
|
|
56
|
+
"unicorn/prefer-ternary": "off",
|
|
57
|
+
"unicorn/prefer-continue": "off",
|
|
58
|
+
"unicorn/prefer-early-return": "off",
|
|
59
|
+
"unicorn/no-declarations-before-early-exit": "off",
|
|
60
|
+
"unicorn/prefer-global-number-constants": "off",
|
|
61
|
+
"unicorn/no-break-in-nested-loop": "off",
|
|
62
|
+
"unicorn/prefer-else-if": "off",
|
|
63
|
+
"jsdoc/require-param-description": "off",
|
|
64
|
+
"jsdoc/require-param-type": "off",
|
|
65
|
+
"jsdoc/require-param": "off"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"files": [
|
|
70
|
+
"test/**"
|
|
71
|
+
],
|
|
72
|
+
"rules": {
|
|
73
|
+
"node-test/no-conditional-assertion": "off",
|
|
74
|
+
"node-test/no-conditional-tests": "off",
|
|
75
|
+
"node-test/no-import-test-files": "off",
|
|
76
|
+
"node-test/no-compound-assertion": "off",
|
|
77
|
+
"node-test/prefer-assert-throws": "off",
|
|
78
|
+
"node-test/require-assertion": "off",
|
|
79
|
+
"no-bitwise": "off",
|
|
80
|
+
"unicorn/prefer-unicode-code-point-escapes": "off",
|
|
81
|
+
"@stylistic/object-curly-newline": "off",
|
|
82
|
+
"regexp/no-control-character": "off",
|
|
83
|
+
"no-control-regex": "off",
|
|
84
|
+
"unicorn/max-nested-calls": "off",
|
|
85
|
+
"unicorn/prefer-number-is-safe-integer": "off",
|
|
86
|
+
"unicorn/require-array-sort-compare": "off",
|
|
87
|
+
"unicorn/no-duplicate-loops": "off"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# soml
|
|
2
|
+
|
|
3
|
+
> The reference parser and canonical serializer for [SOML](../readme.md), a config format for humans
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> The format is a draft. See the [specification](../spec.md).
|
|
7
|
+
|
|
8
|
+
- Strict: every rule in the specification is enforced, and every error says what is wrong and where
|
|
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 more than 800 cases, plus property tests and fuzzing
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install soml
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Node.js 26 or later, for `Temporal`.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import {parse, stringify} from 'soml';
|
|
27
|
+
|
|
28
|
+
const config = parse(`
|
|
29
|
+
# The edge service.
|
|
30
|
+
name: 'api-gateway'
|
|
31
|
+
replicas: 3
|
|
32
|
+
timeout: 30.0
|
|
33
|
+
grace: 1m30s
|
|
34
|
+
deployed-at: 2026-09-19T14:00:00Z
|
|
35
|
+
postgres.host: 'db.internal'
|
|
36
|
+
`);
|
|
37
|
+
//=> {
|
|
38
|
+
// name: 'api-gateway',
|
|
39
|
+
// replicas: 3n,
|
|
40
|
+
// timeout: 30,
|
|
41
|
+
// grace: Temporal.Duration <PT1M30S>,
|
|
42
|
+
// 'deployed-at': Temporal.Instant <2026-09-19T14:00:00Z>,
|
|
43
|
+
// postgres: {host: 'db.internal'},
|
|
44
|
+
// }
|
|
45
|
+
|
|
46
|
+
stringify(config);
|
|
47
|
+
//=> `deployed-at: 2026-09-19T14:00:00Z
|
|
48
|
+
// grace: 1m30s
|
|
49
|
+
// name: 'api-gateway'
|
|
50
|
+
// postgres: {
|
|
51
|
+
// host: 'db.internal',
|
|
52
|
+
// }
|
|
53
|
+
// replicas: 3
|
|
54
|
+
// timeout: 30.0
|
|
55
|
+
// `
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
> [!IMPORTANT]
|
|
59
|
+
> An int is a `bigint` and a float is a `number`, in both directions. So `stringify({port: 8080})` writes `port: 8080.0`, a float. Write `8080n`, or pass `{integers: 'number'}`.
|
|
60
|
+
|
|
61
|
+
## Types
|
|
62
|
+
|
|
63
|
+
| SOML | JavaScript | `integers: 'number'` |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| string | `string` | |
|
|
66
|
+
| int | `bigint` | `number` |
|
|
67
|
+
| float | `number` | |
|
|
68
|
+
| bool | `boolean` | |
|
|
69
|
+
| null | `null` | |
|
|
70
|
+
| instant | [`Temporal.Instant`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Instant) | |
|
|
71
|
+
| duration | [`Temporal.Duration`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration), in hours and smaller units | |
|
|
72
|
+
| array | `Array` | |
|
|
73
|
+
| object | plain `Object` | |
|
|
74
|
+
|
|
75
|
+
The specification says that a reader which conflates `3` and `3.0` is not conforming, and that every 64-bit int must be exact. `bigint` for an int and `number` for a float is the one mapping onto JavaScript primitives that does both, so it is the default.
|
|
76
|
+
|
|
77
|
+
With this mapping, two things always hold:
|
|
78
|
+
|
|
79
|
+
- `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.)
|
|
80
|
+
- `stringify(parse(text))` is the canonical form of `text`, and `stringify(parse(canonical)) === canonical`.
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
|
|
84
|
+
### parse(text, options?)
|
|
85
|
+
|
|
86
|
+
Parse a document. Returns an object or an array, because a document is always a collection.
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
#### text
|
|
91
|
+
|
|
92
|
+
Type: `string | Uint8Array`
|
|
93
|
+
|
|
94
|
+
The document. A `Uint8Array` is decoded as UTF-8, and invalid UTF-8 is reported with its position.
|
|
95
|
+
|
|
96
|
+
#### options
|
|
97
|
+
|
|
98
|
+
Type: `object`
|
|
99
|
+
|
|
100
|
+
##### integers
|
|
101
|
+
|
|
102
|
+
Type: `'bigint' | 'number'`\
|
|
103
|
+
Default: `'bigint'`
|
|
104
|
+
|
|
105
|
+
How an int is represented.
|
|
106
|
+
|
|
107
|
+
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.
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
parse('port: 8080', {integers: 'number'});
|
|
111
|
+
//=> {port: 8080}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### stringify(value, options?)
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
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 1000 levels.
|
|
121
|
+
|
|
122
|
+
#### options
|
|
123
|
+
|
|
124
|
+
Type: `object`
|
|
125
|
+
|
|
126
|
+
##### integers
|
|
127
|
+
|
|
128
|
+
Type: `'bigint' | 'number'`\
|
|
129
|
+
Default: `'bigint'`
|
|
130
|
+
|
|
131
|
+
How an int is represented in `value`.
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
stringify({port: 8080});
|
|
137
|
+
//=> 'port: 8080.0\n'
|
|
138
|
+
|
|
139
|
+
stringify({port: 8080}, {integers: 'number'});
|
|
140
|
+
//=> 'port: 8080\n'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### ParseError
|
|
144
|
+
|
|
145
|
+
Thrown by `parse()`. Extends `SyntaxError`.
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
import {parse, ParseError} from 'soml';
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
parse('name: api-gateway');
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof ParseError) {
|
|
154
|
+
console.log(error.message);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Unexpected "api-gateway". A string value must be quoted, as in 'api-gateway' at line 1, column 7
|
|
158
|
+
//
|
|
159
|
+
// > 1 | name: api-gateway
|
|
160
|
+
// | ^
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
#### reason
|
|
164
|
+
|
|
165
|
+
Type: `string`
|
|
166
|
+
|
|
167
|
+
What is wrong, without the position.
|
|
168
|
+
|
|
169
|
+
#### line
|
|
170
|
+
|
|
171
|
+
Type: `number`
|
|
172
|
+
|
|
173
|
+
The 1-based line.
|
|
174
|
+
|
|
175
|
+
#### column
|
|
176
|
+
|
|
177
|
+
Type: `number`
|
|
178
|
+
|
|
179
|
+
The 1-based column, counted in Unicode code points.
|
|
180
|
+
|
|
181
|
+
#### offset
|
|
182
|
+
|
|
183
|
+
Type: `number`
|
|
184
|
+
|
|
185
|
+
The 0-based UTF-16 index in the decoded text.
|
|
186
|
+
|
|
187
|
+
#### codeFrame
|
|
188
|
+
|
|
189
|
+
Type: `string`
|
|
190
|
+
|
|
191
|
+
Up to three lines ending at the error, with a caret under the position. A long line is clipped around the position.
|
|
192
|
+
|
|
193
|
+
## Limits
|
|
194
|
+
|
|
195
|
+
- **Nesting is limited to 1000 levels**, in `parse()` and in `stringify()`. A recursive parser would otherwise end with an engine stack overflow. A dotted key counts each segment.
|
|
196
|
+
- **No formatter yet.** The specification defines a formatter that keeps comments, member order, and the author's spelling. This package only writes canonical form.
|
|
197
|
+
|
|
198
|
+
## Conformance suite
|
|
199
|
+
|
|
200
|
+
[`test/conformance`](test/conformance) holds the cases as plain files, so that another implementation can run them:
|
|
201
|
+
|
|
202
|
+
- `valid/**/name.soml` must parse to the value in `name.json`, and serialize to exactly `name.canonical.soml`.
|
|
203
|
+
- `invalid/**/name.soml` must be rejected.
|
|
204
|
+
|
|
205
|
+
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.
|
|
206
|
+
|
|
207
|
+
## Benchmark
|
|
208
|
+
|
|
209
|
+
```sh
|
|
210
|
+
npm run bench
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The same 2.6 MB of config-shaped data on an Apple M-series machine with Node.js 26:
|
|
214
|
+
|
|
215
|
+
| | parse | stringify |
|
|
216
|
+
|---|---|---|
|
|
217
|
+
| `JSON` | 570 MB/s | 650 MB/s |
|
|
218
|
+
| this package | 155 MB/s | 118 MB/s |
|
|
219
|
+
| smol-toml | 91 MB/s | 133 MB/s |
|
|
220
|
+
| json5 | 22 MB/s | 80 MB/s |
|
|
221
|
+
|
|
222
|
+
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.
|
|
223
|
+
|
|
224
|
+
`JSON` is native code and a much smaller grammar, so it is the ceiling rather than a competitor.
|
package/source/error.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const CONTEXT_LINES = 2;
|
|
2
|
+
const MAX_LINE_WIDTH = 100;
|
|
3
|
+
|
|
4
|
+
// With the `v` flag, a surrogate in the class matches only when it is unpaired.
|
|
5
|
+
const UNPRINTABLE = /[\p{Bidi_Control}\u{D800}-\u{DFFF}[\p{Control}--\t]]/gv;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
Thrown by `parse()` when the input is not a valid document.
|
|
9
|
+
*/
|
|
10
|
+
export class ParseError extends SyntaxError {
|
|
11
|
+
name = 'ParseError';
|
|
12
|
+
|
|
13
|
+
constructor(reason, source, offset) {
|
|
14
|
+
const sanitizedReason = sanitize(reason);
|
|
15
|
+
const {line, column} = locate(source, offset);
|
|
16
|
+
const codeFrame = createCodeFrame(source, offset, line);
|
|
17
|
+
super(`${sanitizedReason} at line ${line}, column ${column}\n\n${codeFrame}`);
|
|
18
|
+
this.reason = sanitizedReason;
|
|
19
|
+
this.line = line;
|
|
20
|
+
this.column = column;
|
|
21
|
+
this.offset = offset;
|
|
22
|
+
this.codeFrame = codeFrame;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
Line and column are 1-based, and the column counts code points, so an emoji is one column.
|
|
28
|
+
*/
|
|
29
|
+
function locate(source, offset) {
|
|
30
|
+
let line = 1;
|
|
31
|
+
let lineStart = 0;
|
|
32
|
+
|
|
33
|
+
for (let index = source.indexOf('\n'); index !== -1 && index < offset; index = source.indexOf('\n', index + 1)) {
|
|
34
|
+
line++;
|
|
35
|
+
lineStart = index + 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {line, column: countCodePoints(source.slice(lineStart, offset)) + 1};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function countCodePoints(string) {
|
|
42
|
+
let count = 0;
|
|
43
|
+
|
|
44
|
+
for (let index = 0; index < string.length; index++) {
|
|
45
|
+
const code = string.charCodeAt(index);
|
|
46
|
+
|
|
47
|
+
// A high surrogate followed by a low one is one code point.
|
|
48
|
+
if (code >= 0xD8_00 && code <= 0xDB_FF) {
|
|
49
|
+
const next = string.charCodeAt(index + 1);
|
|
50
|
+
|
|
51
|
+
if (next >= 0xDC_00 && next <= 0xDF_FF) {
|
|
52
|
+
index++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
count++;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return count;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createCodeFrame(source, offset, line) {
|
|
63
|
+
// The error line and up to two lines before it, found by scanning back from the error, so that an error late in a long document does not split the whole document.
|
|
64
|
+
const lineStarts = [offset === 0 ? 0 : source.lastIndexOf('\n', offset - 1) + 1];
|
|
65
|
+
|
|
66
|
+
while (lineStarts.length <= CONTEXT_LINES && lineStarts[0] > 0) {
|
|
67
|
+
const searchFrom = lineStarts[0] - 2;
|
|
68
|
+
lineStarts.unshift(searchFrom < 0 ? 0 : source.lastIndexOf('\n', searchFrom) + 1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const firstLine = line - lineStarts.length + 1;
|
|
72
|
+
const gutterWidth = String(line).length;
|
|
73
|
+
const output = [];
|
|
74
|
+
|
|
75
|
+
for (const [index, lineStart] of lineStarts.entries()) {
|
|
76
|
+
const number = firstLine + index;
|
|
77
|
+
const isErrorLine = number === line;
|
|
78
|
+
const lineEnd = source.indexOf('\n', lineStart);
|
|
79
|
+
const {text, pointerOffset} = clip(sanitize(source.slice(lineStart, lineEnd === -1 ? undefined : lineEnd)), isErrorLine ? offset - lineStart : 0);
|
|
80
|
+
const gutter = `${isErrorLine ? '>' : ' '} ${String(number).padStart(gutterWidth)} |`;
|
|
81
|
+
output.push(text === '' ? gutter : `${gutter} ${text}`);
|
|
82
|
+
|
|
83
|
+
if (isErrorLine) {
|
|
84
|
+
// Keep the tabs from the line, so the caret lines up whatever the tab width is.
|
|
85
|
+
const padding = text.slice(0, pointerOffset).replaceAll(/[^\t]/gv, ' ');
|
|
86
|
+
output.push(` ${' '.repeat(gutterWidth)} | ${padding}^`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return output.join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
The rejected text can hold characters that a terminal acts on rather than shows: control characters such as an escape or a carriage return, bidirectional controls, and lone surrogates. Each becomes U+FFFD, which is also one code unit, so the caret still lines up. Tabs are kept.
|
|
95
|
+
*/
|
|
96
|
+
function sanitize(text) {
|
|
97
|
+
return text.replaceAll(UNPRINTABLE, '\u{FFFD}');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
A long line, such as a minified document, is cut to a window around the pointer. The cut never splits a surrogate pair.
|
|
102
|
+
*/
|
|
103
|
+
function clip(text, pointerOffset) {
|
|
104
|
+
if (text.length <= MAX_LINE_WIDTH) {
|
|
105
|
+
return {text, pointerOffset};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let start = Math.max(0, Math.min(pointerOffset - (MAX_LINE_WIDTH / 2), text.length - MAX_LINE_WIDTH));
|
|
109
|
+
let end = start + MAX_LINE_WIDTH;
|
|
110
|
+
|
|
111
|
+
if (isLowSurrogate(text.charCodeAt(start))) {
|
|
112
|
+
start++;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (isLowSurrogate(text.charCodeAt(end))) {
|
|
116
|
+
end--;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const prefix = start > 0 ? '…' : '';
|
|
120
|
+
const suffix = end < text.length ? '…' : '';
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
text: `${prefix}${text.slice(start, end)}${suffix}`,
|
|
124
|
+
pointerOffset: pointerOffset - start + prefix.length,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isLowSurrogate(code) {
|
|
129
|
+
return code >= 0xDC_00 && code <= 0xDF_FF;
|
|
130
|
+
}
|