soml-lang 0.0.1 → 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.
@@ -0,0 +1,468 @@
1
+ import { parseWithTimes, } from "./parse.js";
2
+ import { isBareKeyCharacter, isSpace, findNumberEnd, findLineEnd, findBlockStringEnd, LF, DOUBLE_QUOTE, HASH, SINGLE_QUOTE, ASTERISK, COMMA, DOT, SLASH, BACKSLASH, OPEN_BRACKET, CLOSE_BRACKET, OPEN_BRACE, CLOSE_BRACE, } from "./shared.js";
3
+ /**
4
+ 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.
5
+
6
+ @example
7
+ ```
8
+ import {visitorKeys} from 'soml-lang';
9
+
10
+ visitorKeys.Member;
11
+ //=> ['key', 'value']
12
+
13
+ visitorKeys.Integer;
14
+ //=> []
15
+ ```
16
+ */
17
+ /* eslint-disable @typescript-eslint/naming-convention -- The keys are node types. */
18
+ export const visitorKeys = Object.freeze({
19
+ Document: Object.freeze(['body']),
20
+ Object: Object.freeze(['members']),
21
+ Member: Object.freeze(['key', 'value']),
22
+ Array: Object.freeze(['elements']),
23
+ Key: Object.freeze(['segments']),
24
+ KeySegment: Object.freeze([]),
25
+ String: Object.freeze([]),
26
+ Integer: Object.freeze([]),
27
+ Float: Object.freeze([]),
28
+ Boolean: Object.freeze([]),
29
+ Null: Object.freeze([]),
30
+ Instant: Object.freeze([]),
31
+ Duration: Object.freeze([]),
32
+ });
33
+ /* eslint-enable @typescript-eslint/naming-convention */
34
+ /*
35
+ `infinity` and `-infinity` are read as numbers, because they are floats.
36
+ */
37
+ const KEYWORDS = ['true', 'false', 'null'];
38
+ // A map rather than an object, so that a property added to `Object.prototype` is never read as an entry.
39
+ const RADIX = new Map([['x', 16], ['o', 8], ['b', 2]]);
40
+ /**
41
+ 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.
42
+
43
+ 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. A `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.
44
+
45
+ @param text - The document.
46
+ @returns The root node, which also holds every token and comment.
47
+ @throws {ParseError} When the document is not valid, the same as `parse()`.
48
+ @throws {TypeError} When `text` is not a string.
49
+
50
+ @example
51
+ ```
52
+ import {parseTree} from 'soml-lang';
53
+
54
+ const tree = parseTree('port: 8080 # The default');
55
+
56
+ tree.body.members[0].value;
57
+ //=> {type: 'Integer', value: 8080n, radix: 10, range: [6, 10], loc: {…}}
58
+
59
+ tree.comments[0];
60
+ //=> {type: 'Line', value: ' The default', range: [11, 24], loc: {…}}
61
+ ```
62
+ */
63
+ export function parseTree(text) {
64
+ if (typeof text !== 'string') {
65
+ throw new TypeError(`Expected a string, got ${text === null ? 'null' : typeof text}`);
66
+ }
67
+ // Validates the whole document, so the builder below can assume valid input.
68
+ parseWithTimes(text);
69
+ return new TreeBuilder(text).build();
70
+ }
71
+ /*
72
+ The value of one scalar, read by the parser itself so that the tree and `parse()` cannot disagree. A scalar's validity does not depend on its context, so it parses on its own inside an array. An instant or a duration is a `Time`.
73
+ */
74
+ function readScalar(raw) {
75
+ return parseWithTimes(`[${raw}]`)[0];
76
+ }
77
+ /*
78
+ Every node is an object literal with the same property order, `type`, its own fields, `range`, and `loc`, rather than one generic function that spreads the fields, which is several times slower. A node that is one token, such as a scalar or a key segment, shares the token's `range` and `loc`.
79
+ */
80
+ class TreeBuilder {
81
+ #source;
82
+ #index = 0;
83
+ #tokens = [];
84
+ #comments = [];
85
+ #lineStarts = [0];
86
+ // The line of the last offset looked up. Offsets mostly arrive in order, so it is the best first guess for the next one.
87
+ #line = 0;
88
+ constructor(source) {
89
+ this.#source = source;
90
+ for (let index = source.indexOf('\n'); index !== -1; index = source.indexOf('\n', index + 1)) {
91
+ this.#lineStarts.push(index + 1);
92
+ }
93
+ }
94
+ #code(offset = this.#index) {
95
+ return this.#source.charCodeAt(offset);
96
+ }
97
+ /*
98
+ 1-based line, and 0-based column in UTF-16 code units, as in ESTree.
99
+ */
100
+ #position(offset) {
101
+ const lineStarts = this.#lineStarts;
102
+ // The binary search runs only when the offset is not on the line of the last one or the line after it, which makes building the tree about twice as fast.
103
+ if (this.#isOnLine(offset, this.#line + 1)) {
104
+ this.#line++;
105
+ }
106
+ else if (!this.#isOnLine(offset, this.#line)) {
107
+ let low = 0;
108
+ let high = lineStarts.length - 1;
109
+ while (low < high) {
110
+ const middle = Math.ceil((low + high) / 2);
111
+ if (lineStarts[middle] <= offset) {
112
+ low = middle;
113
+ }
114
+ else {
115
+ high = middle - 1;
116
+ }
117
+ }
118
+ this.#line = low;
119
+ }
120
+ return { line: this.#line + 1, column: offset - lineStarts[this.#line] };
121
+ }
122
+ #isOnLine(offset, line) {
123
+ const lineStarts = this.#lineStarts;
124
+ return line < lineStarts.length && lineStarts[line] <= offset && (line + 1 === lineStarts.length || lineStarts[line + 1] > offset);
125
+ }
126
+ #location(start, end) {
127
+ return { start: this.#position(start), end: this.#position(end) };
128
+ }
129
+ #token(type, start, end) {
130
+ const token = {
131
+ type,
132
+ value: this.#source.slice(start, end),
133
+ range: [start, end],
134
+ loc: this.#location(start, end),
135
+ };
136
+ this.#tokens.push(token);
137
+ return token;
138
+ }
139
+ #punctuator() {
140
+ this.#index++;
141
+ return this.#token('Punctuator', this.#index - 1, this.#index);
142
+ }
143
+ #skipTrivia() {
144
+ const source = this.#source;
145
+ for (;;) {
146
+ const code = this.#code();
147
+ const start = this.#index;
148
+ if (code === LF || isSpace(code)) {
149
+ this.#index++;
150
+ }
151
+ else if (code === HASH) {
152
+ this.#index = findLineEnd(source, start);
153
+ this.#comment('Line', start, this.#index, source.slice(start + 1, this.#index));
154
+ }
155
+ else if (code === SLASH && this.#code(start + 1) === ASTERISK) {
156
+ this.#index = source.indexOf('*/', start + 2) + 2;
157
+ this.#comment('Block', start, this.#index, source.slice(start + 2, this.#index - 2));
158
+ }
159
+ else {
160
+ return;
161
+ }
162
+ }
163
+ }
164
+ #comment(type, start, end, value) {
165
+ this.#comments.push({
166
+ type,
167
+ value,
168
+ range: [start, end],
169
+ loc: this.#location(start, end),
170
+ });
171
+ }
172
+ #bareObject() {
173
+ const members = [];
174
+ do {
175
+ members.push(this.#member());
176
+ this.#skipTrivia();
177
+ } while (this.#index < this.#source.length);
178
+ const first = members[0];
179
+ const last = members.at(-1);
180
+ return {
181
+ type: 'Object',
182
+ members,
183
+ braced: false,
184
+ range: [first.range[0], last.range[1]],
185
+ loc: spanLocation(first, last),
186
+ };
187
+ }
188
+ #object() {
189
+ const { items: members, opening, closing } = this.#items(CLOSE_BRACE, () => this.#member());
190
+ return {
191
+ type: 'Object',
192
+ members,
193
+ braced: true,
194
+ range: [opening.range[0], closing.range[1]],
195
+ loc: spanLocation(opening, closing),
196
+ };
197
+ }
198
+ #array() {
199
+ const { items: elements, opening, closing } = this.#items(CLOSE_BRACKET, () => this.#value());
200
+ return {
201
+ type: 'Array',
202
+ elements,
203
+ range: [opening.range[0], closing.range[1]],
204
+ loc: spanLocation(opening, closing),
205
+ };
206
+ }
207
+ /*
208
+ The items between an opening bracket and its `closingCode` bracket, which is consumed too. Items are separated by a comma, a line break, or both, so a comma token may be absent. The parser has already checked the separators, including that a comma is on the line of the item before it.
209
+ */
210
+ #items(closingCode, parseItem) {
211
+ const items = [];
212
+ const opening = this.#punctuator();
213
+ this.#skipTrivia();
214
+ while (this.#code() !== closingCode) {
215
+ items.push(parseItem());
216
+ this.#skipTrivia();
217
+ if (this.#code() !== COMMA) {
218
+ continue;
219
+ }
220
+ this.#punctuator();
221
+ this.#skipTrivia();
222
+ }
223
+ return { items, opening, closing: this.#punctuator() };
224
+ }
225
+ #member() {
226
+ const key = this.#key();
227
+ this.#punctuator(); // `:`
228
+ this.#skipTrivia();
229
+ const value = this.#value();
230
+ return {
231
+ type: 'Member',
232
+ key,
233
+ value,
234
+ range: [key.range[0], value.range[1]],
235
+ loc: spanLocation(key, value),
236
+ };
237
+ }
238
+ #key() {
239
+ const segments = [this.#keySegment()];
240
+ while (this.#code() === DOT) {
241
+ this.#punctuator();
242
+ segments.push(this.#keySegment());
243
+ }
244
+ const first = segments[0];
245
+ const last = segments.at(-1);
246
+ return {
247
+ type: 'Key',
248
+ segments,
249
+ range: [first.range[0], last.range[1]],
250
+ loc: spanLocation(first, last),
251
+ };
252
+ }
253
+ #keySegment() {
254
+ const start = this.#index;
255
+ const code = this.#code();
256
+ if (code === SINGLE_QUOTE || code === DOUBLE_QUOTE) {
257
+ const { value, style, token: { range, loc } } = this.#singleLineString();
258
+ return {
259
+ type: 'KeySegment',
260
+ value,
261
+ style,
262
+ range,
263
+ loc,
264
+ };
265
+ }
266
+ while (isBareKeyCharacter(this.#code())) {
267
+ this.#index++;
268
+ }
269
+ const { value, range, loc } = this.#token('BareKey', start, this.#index);
270
+ return {
271
+ type: 'KeySegment',
272
+ value,
273
+ style: 'bare',
274
+ range,
275
+ loc,
276
+ };
277
+ }
278
+ #value() {
279
+ const code = this.#code();
280
+ if (code === OPEN_BRACE) {
281
+ return this.#object();
282
+ }
283
+ if (code === OPEN_BRACKET) {
284
+ return this.#array();
285
+ }
286
+ const start = this.#index;
287
+ if (code === SINGLE_QUOTE || code === DOUBLE_QUOTE) {
288
+ if (this.#code(start + 1) === code && this.#code(start + 2) === code) {
289
+ return this.#blockString(code);
290
+ }
291
+ const { value, style, token: { range, loc } } = this.#singleLineString();
292
+ return {
293
+ type: 'String',
294
+ value,
295
+ style,
296
+ block: false,
297
+ range,
298
+ loc,
299
+ };
300
+ }
301
+ // The document is valid, so a value that starts with a keyword is that keyword, as a number cannot start with a letter other than the `i` of `infinity`.
302
+ const keyword = KEYWORDS.find(word => this.#source.startsWith(word, start));
303
+ if (keyword !== undefined) {
304
+ this.#index += keyword.length;
305
+ const { range, loc } = this.#token('Keyword', start, this.#index);
306
+ if (keyword === 'null') {
307
+ return { type: 'Null', range, loc };
308
+ }
309
+ return {
310
+ type: 'Boolean',
311
+ value: keyword === 'true',
312
+ range,
313
+ loc,
314
+ };
315
+ }
316
+ return this.#numberOrTime();
317
+ }
318
+ /*
319
+ `'...'` ends at the next `'`. `"..."` ends at the next `"` that is not escaped, and `\` always starts a two-character escape or a `\u{…}`, which holds no quote.
320
+ */
321
+ #singleLineString() {
322
+ const source = this.#source;
323
+ const start = this.#index;
324
+ const quote = this.#code();
325
+ let end = start + 1;
326
+ if (quote === SINGLE_QUOTE) {
327
+ end = source.indexOf('\'', end);
328
+ }
329
+ else {
330
+ while (this.#code(end) !== DOUBLE_QUOTE) {
331
+ end += this.#code(end) === BACKSLASH ? 2 : 1;
332
+ }
333
+ }
334
+ this.#index = end + 1;
335
+ const token = this.#token('String', start, this.#index);
336
+ return quote === SINGLE_QUOTE
337
+ ? { value: source.slice(start + 1, end), style: 'literal', token }
338
+ : { value: readScalar(token.value), style: 'escaped', token };
339
+ }
340
+ /*
341
+ The block ends at the first line whose first non-whitespace content is a run of exactly as many quotes as the opening delimiter.
342
+ */
343
+ #blockString(quote) {
344
+ const source = this.#source;
345
+ const start = this.#index;
346
+ let delimiterLength = 0;
347
+ while (this.#code(start + delimiterLength) === quote) {
348
+ delimiterLength++;
349
+ }
350
+ const closing = findBlockStringEnd(source, source.indexOf('\n', start) + 1, quote, delimiterLength);
351
+ this.#index = closing.delimiterStart + delimiterLength;
352
+ const { value, range, loc } = this.#token('String', start, this.#index);
353
+ return {
354
+ type: 'String',
355
+ value: readScalar(value),
356
+ style: quote === SINGLE_QUOTE ? 'literal' : 'escaped',
357
+ block: true,
358
+ range,
359
+ loc,
360
+ };
361
+ }
362
+ #numberOrTime() {
363
+ const start = this.#index;
364
+ const end = findNumberEnd(this.#source, start);
365
+ this.#index = end;
366
+ const raw = this.#source.slice(start, end);
367
+ const value = readScalar(raw);
368
+ if (typeof value === 'bigint') {
369
+ const { range, loc } = this.#token('Integer', start, end);
370
+ return {
371
+ type: 'Integer',
372
+ value,
373
+ radix: RADIX.get(raw.charAt(1)) ?? 10,
374
+ range,
375
+ loc,
376
+ };
377
+ }
378
+ if (typeof value === 'number') {
379
+ const { range, loc } = this.#token('Float', start, end);
380
+ return {
381
+ type: 'Float',
382
+ value,
383
+ range,
384
+ loc,
385
+ };
386
+ }
387
+ const time = value;
388
+ const { range, loc } = this.#token(time.type, start, end);
389
+ let temporal;
390
+ if (time.type === 'Duration') {
391
+ // A getter, so that the `Temporal` object is made when `value` is first read.
392
+ const node = {
393
+ type: 'Duration',
394
+ get value() {
395
+ temporal ??= time.toTemporal();
396
+ return temporal;
397
+ },
398
+ negative: raw.startsWith('-'),
399
+ parts: readDurationParts(raw),
400
+ range,
401
+ loc,
402
+ };
403
+ return node;
404
+ }
405
+ // A getter, so that the `Temporal` object is made when `value` is first read.
406
+ const node = {
407
+ type: 'Instant',
408
+ get value() {
409
+ temporal ??= time.toTemporal();
410
+ return temporal;
411
+ },
412
+ range,
413
+ loc,
414
+ };
415
+ return node;
416
+ }
417
+ build() {
418
+ this.#skipTrivia();
419
+ const code = this.#code();
420
+ let body;
421
+ if (code === OPEN_BRACE) {
422
+ body = this.#object();
423
+ }
424
+ else if (code === OPEN_BRACKET) {
425
+ body = this.#array();
426
+ }
427
+ else {
428
+ body = this.#bareObject();
429
+ }
430
+ this.#skipTrivia();
431
+ return {
432
+ type: 'Document',
433
+ body,
434
+ tokens: this.#tokens,
435
+ comments: this.#comments,
436
+ range: [0, this.#source.length],
437
+ loc: this.#location(0, this.#source.length),
438
+ };
439
+ }
440
+ }
441
+ /*
442
+ The location from the start of `first` to the end of `last`, which shares their positions rather than looking them up again. A node that ends far from where it starts, such as a large object, would otherwise move the line cache in `#position` back and forth.
443
+ */
444
+ function spanLocation(first, last) {
445
+ return { start: first.loc.start, end: last.loc.end };
446
+ }
447
+ /*
448
+ The parts of a duration that is already valid. A part is a number, which has no letters, and then a unit, which has only lowercase letters.
449
+ */
450
+ function readDurationParts(raw) {
451
+ // From `a` to `z`.
452
+ const isUnitCharacter = (index) => raw.charCodeAt(index) >= 0x61 && raw.charCodeAt(index) <= 0x7A;
453
+ const parts = [];
454
+ let index = raw.startsWith('-') ? 1 : 0;
455
+ while (index < raw.length) {
456
+ let unitStart = index;
457
+ while (!isUnitCharacter(unitStart)) {
458
+ unitStart++;
459
+ }
460
+ let unitEnd = unitStart;
461
+ while (unitEnd < raw.length && isUnitCharacter(unitEnd)) {
462
+ unitEnd++;
463
+ }
464
+ parts.push({ number: raw.slice(index, unitStart), unit: raw.slice(unitStart, unitEnd) });
465
+ index = unitEnd;
466
+ }
467
+ return parts;
468
+ }
package/package.json CHANGED
@@ -1,26 +1,27 @@
1
1
  {
2
2
  "name": "soml-lang",
3
- "version": "0.0.1",
4
- "description": "Reference parser and canonical serializer for SOML, a config format for humans",
3
+ "version": "0.0.3",
4
+ "description": "Reference parser, serializer, and formatter for SOML, a config format for humans",
5
5
  "license": "MIT",
6
+ "repository": "som-lang/soml-javascript",
6
7
  "author": "Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)",
7
8
  "type": "module",
8
9
  "exports": {
9
- "types": "./index.d.ts",
10
- "default": "./index.js"
10
+ "types": "./distribution/index.d.ts",
11
+ "default": "./distribution/index.js"
11
12
  },
12
13
  "sideEffects": false,
13
14
  "engines": {
14
- "node": ">=26"
15
+ "node": ">=22"
15
16
  },
16
17
  "scripts": {
17
- "test": "xo && node --test && tsc",
18
- "bench": "node bench/index.js"
18
+ "build": "tsc --project tsconfig.build.json",
19
+ "prepack": "npm run build",
20
+ "test": "xo && tsc && node --test 'test/*.ts'",
21
+ "bench": "node bench/index.ts"
19
22
  },
20
23
  "files": [
21
- "index.js",
22
- "index.d.ts",
23
- "source"
24
+ "distribution"
24
25
  ],
25
26
  "keywords": [
26
27
  "config",
@@ -35,6 +36,8 @@
35
36
  "yaml"
36
37
  ],
37
38
  "devDependencies": {
39
+ "@sindresorhus/tsconfig": "^8.1.0",
40
+ "@types/node": "^26.6.4",
38
41
  "json5": "^2.2.3",
39
42
  "smol-toml": "^1.9.0",
40
43
  "typescript": "^6.0.3",
@@ -50,19 +53,12 @@
50
53
  {
51
54
  "rules": {
52
55
  "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"
56
+ "@typescript-eslint/switch-exhaustiveness-check": [
57
+ "error",
58
+ {
59
+ "considerDefaultExhaustiveForUnions": true
60
+ }
61
+ ]
66
62
  }
67
63
  },
68
64
  {
@@ -71,20 +67,24 @@
71
67
  ],
72
68
  "rules": {
73
69
  "node-test/no-conditional-assertion": "off",
74
- "node-test/no-conditional-tests": "off",
75
70
  "node-test/no-import-test-files": "off",
76
- "node-test/no-compound-assertion": "off",
77
- "node-test/prefer-assert-throws": "off",
78
71
  "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
72
  "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"
73
+ "@typescript-eslint/no-floating-promises": [
74
+ "error",
75
+ {
76
+ "allowForKnownSafeCalls": [
77
+ {
78
+ "from": "package",
79
+ "package": "node:test",
80
+ "name": [
81
+ "test",
82
+ "suite"
83
+ ]
84
+ }
85
+ ]
86
+ }
87
+ ]
88
88
  }
89
89
  }
90
90
  ]