soml-lang 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/readme.md CHANGED
@@ -1,29 +1,34 @@
1
1
  # soml
2
2
 
3
- > The reference parser and canonical serializer for [SOML](../readme.md), a config format for humans
3
+ > The reference parser, canonical serializer, and formatter for [SOML](../soml/readme.md), a config format for humans
4
4
 
5
5
  > [!NOTE]
6
- > The format is a draft. See the [specification](../spec.md).
6
+ > The format is a draft. See the [specification](../soml/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
10
  - Canonical: `stringify()` writes the one canonical form, so equal values give equal bytes
11
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
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
13
+ - Tested by a language-neutral [conformance suite](test/conformance) of almost 900 cases, plus property tests and fuzzing
14
14
 
15
15
  ## Install
16
16
 
17
17
  ```sh
18
- npm install soml
18
+ npm install soml-lang
19
19
  ```
20
20
 
21
- Requires Node.js 26 or later, for `Temporal`.
21
+ Requires Node.js 22 or later. Instants and durations use `Temporal`, which Node.js 26 and later have built in. On older versions, everything else works, but an instant, a duration, or a `Date` throws. To use them, load a [`Temporal` polyfill](https://github.com/fullcalendar/temporal-polyfill) before you import this package:
22
+
23
+ ```js
24
+ import 'temporal-polyfill/global';
25
+ import {parse} from 'soml-lang';
26
+ ```
22
27
 
23
28
  ## Usage
24
29
 
25
30
  ```js
26
- import {parse, stringify} from 'soml';
31
+ import {parse, stringify} from 'soml-lang';
27
32
 
28
33
  const config = parse(`
29
34
  # The edge service.
@@ -111,13 +116,89 @@ parse('port: 8080', {integers: 'number'});
111
116
  //=> {port: 8080}
112
117
  ```
113
118
 
119
+ ### parseTree(text)
120
+
121
+ Parse a document into a syntax tree, for tools such as linters and formatters. Returns a `Document` node, which also holds every token and comment.
122
+
123
+ Throws the same [`ParseError`](#parseerror) as `parse()`, and a `TypeError` when `text` is not a string.
124
+
125
+ ```js
126
+ import {parseTree} from 'soml-lang';
127
+
128
+ const tree = parseTree('port: 8080 # The default');
129
+
130
+ tree.body.members[0].value;
131
+ //=> {type: 'Integer', value: 8080n, radix: 10, range: [6, 10], loc: {…}}
132
+
133
+ tree.comments[0];
134
+ //=> {type: 'Line', value: ' The default', range: [11, 24], loc: {…}}
135
+ ```
136
+
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.
138
+
139
+ | Node | Fields |
140
+ |---|---|
141
+ | `Document` | `body`: an `Object` or an `Array`; `tokens`; `comments` |
142
+ | `Object` | `members`; `braced`, which is `false` only for a top-level object without braces |
143
+ | `Member` | `key`; `value` |
144
+ | `Array` | `elements` |
145
+ | `Key` | `segments`, more than one for a dotted key |
146
+ | `KeySegment` | `value`, decoded; `style`: `'bare'`, `'literal'`, or `'escaped'` |
147
+ | `String` | `value`, decoded; `style`: `'literal'` or `'escaped'`; `block` |
148
+ | `Integer` | `value`, a `bigint`; `radix`: `2`, `8`, `10`, or `16` |
149
+ | `Float` | `value`, including `Infinity` and `-Infinity` |
150
+ | `Boolean` | `value` |
151
+ | `Null` | |
152
+ | `Instant` | `value`, a `Temporal.Instant` |
153
+ | `Duration` | `value`, a `Temporal.Duration` |
154
+
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 `*/`.
156
+
157
+ ### visitorKeys
158
+
159
+ The properties of each node type that hold its child nodes, in source order, for tools that walk the tree, such as an ESLint language plugin.
160
+
161
+ ```js
162
+ import {visitorKeys} from 'soml-lang';
163
+
164
+ visitorKeys.Member;
165
+ //=> ['key', 'value']
166
+
167
+ visitorKeys.Integer;
168
+ //=> []
169
+ ```
170
+
171
+ ### format(text)
172
+
173
+ Format a document. Returns the document with its layout normalized, ending with one line feed.
174
+
175
+ The layout follows the [formatter](../soml/spec.md#formatting) in the specification: one tab per level, every member and item on its own line, a trailing comma after every member and item inside `{}` and `[]`, one space after `:`, and no trailing whitespace or runs of blank lines. Comments, member order, dotted keys, block strings, and the spelling of every value stay as they are, so the value never changes.
176
+
177
+ Throws the same [`ParseError`](#parseerror) as `parse()`, and a `TypeError` when `text` is not a string.
178
+
179
+ ```js
180
+ import {format} from 'soml-lang';
181
+
182
+ format('pool: {min: 2, max: 16} # Connections');
183
+ //=> 'pool: {\n\tmin: 2,\n\tmax: 16,\n} # Connections\n'
184
+ ```
185
+
186
+ Where the specification does not say, the formatter decides:
187
+
188
+ - A comment at the end of a line stays at the end of that line, after the inserted comma. A block comment that a value follows on the same line, as in `[1, /* note */ 2]`, stays in front of that value.
189
+ - A value on the line after its `key:` moves up to that line, unless a comment comes between them.
190
+ - A block string moves with its closing delimiter, and its lines are not otherwise changed.
191
+ - There are no blank lines at the start of the file or directly inside brackets.
192
+
193
+ In the rare comma-first style, a comment after a comma on its own line moves to its own line when the item before it already ends with a comment.
194
+
114
195
  ### stringify(value, options?)
115
196
 
116
197
  Serialize an object or an array to canonical form. Members are sorted by key, nesting is written with braces and tabs, and the output ends with one line feed. Comments, dotted keys, and block strings are never written.
117
198
 
118
199
  Besides the types above, a `Date` is accepted and written as an instant, a `Temporal.Duration` is accepted when it has no years, months, weeks, or days, because those are not a fixed length, and a member whose value is `undefined` is left out, as `JSON.stringify` does.
119
200
 
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.
201
+ Throws a `TypeError` for a value that cannot be represented: `NaN`, a function, a symbol, a class instance, `undefined` in an array, a circular reference, a non-collection at the top level, an invalid `Date`, a `Temporal.Duration` with years, months, weeks, or days, an object that only claims to be a `Date`, a `Temporal.Instant`, or a `Temporal.Duration`, and a string or key with a lone surrogate or a carriage return. Throws a `RangeError` for an int outside the 64-bit range, an instant outside the years 0001 to 9999, a duration outside the 64-bit range of nanoseconds, and nesting deeper than 100 levels.
121
202
 
122
203
  #### options
123
204
 
@@ -145,7 +226,7 @@ stringify({port: 8080}, {integers: 'number'});
145
226
  Thrown by `parse()`. Extends `SyntaxError`.
146
227
 
147
228
  ```js
148
- import {parse, ParseError} from 'soml';
229
+ import {parse, ParseError} from 'soml-lang';
149
230
 
150
231
  try {
151
232
  parse('name: api-gateway');
@@ -192,8 +273,7 @@ Up to three lines ending at the error, with a caret under the position. A long l
192
273
 
193
274
  ## Limits
194
275
 
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.
276
+ - **Nesting is limited to 100 levels**, in `parse()` and in `stringify()`, as the spec requires: every document up to 100 levels is accepted, and every deeper one is rejected. The document's own collection is level 1, and a dotted key counts each segment.
197
277
 
198
278
  ## Conformance suite
199
279
 
package/index.d.ts DELETED
@@ -1,151 +0,0 @@
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 DELETED
@@ -1,3 +0,0 @@
1
- export {parse} from './source/parse.js';
2
- export {stringify} from './source/stringify.js';
3
- export {ParseError} from './source/error.js';
package/source/error.js DELETED
@@ -1,130 +0,0 @@
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
- }