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.
@@ -1,7 +1,7 @@
1
- import { parse } from "./parse.js";
2
- import { isBareKeyCharacter, findNumberEnd, findLineEnd, findBlockStringEnd, } from "./shared.js";
1
+ import { parseWithTimes, } from "./parse.js";
2
+ import { isBareKeyCharacter, isSpace, findNumberEnd, findLineEnd, findBlockStringEnd, LF, DOUBLE_QUOTE, HASH, SINGLE_QUOTE, ASTERISK, COMMA, SLASH, BACKSLASH, OPEN_BRACKET, CLOSE_BRACKET, OPEN_BRACE, CLOSE_BRACE, } from "./shared.js";
3
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.
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
5
 
6
6
  @example
7
7
  ```
@@ -20,8 +20,7 @@ export const visitorKeys = Object.freeze({
20
20
  Object: Object.freeze(['members']),
21
21
  Member: Object.freeze(['key', 'value']),
22
22
  Array: Object.freeze(['elements']),
23
- Key: Object.freeze(['segments']),
24
- KeySegment: Object.freeze([]),
23
+ Key: Object.freeze([]),
25
24
  String: Object.freeze([]),
26
25
  Integer: Object.freeze([]),
27
26
  Float: Object.freeze([]),
@@ -31,31 +30,16 @@ export const visitorKeys = Object.freeze({
31
30
  Duration: Object.freeze([]),
32
31
  });
33
32
  /* eslint-enable @typescript-eslint/naming-convention */
34
- const TAB = 0x09;
35
- const LF = 0x0A;
36
- const SPACE = 0x20;
37
- const DOUBLE_QUOTE = 0x22;
38
- const HASH = 0x23;
39
- const SINGLE_QUOTE = 0x27;
40
- const ASTERISK = 0x2A;
41
- const COMMA = 0x2C;
42
- const DOT = 0x2E;
43
- const SLASH = 0x2F;
44
- const BACKSLASH = 0x5C;
45
- const OPEN_BRACKET = 0x5B;
46
- const CLOSE_BRACKET = 0x5D;
47
- const OPEN_BRACE = 0x7B;
48
- const CLOSE_BRACE = 0x7D;
49
- const WHITESPACE = new Set([SPACE, TAB, LF]);
50
33
  /*
51
34
  `infinity` and `-infinity` are read as numbers, because they are floats.
52
35
  */
53
36
  const KEYWORDS = ['true', 'false', 'null'];
54
- const RADIX = { x: 16, o: 8, b: 2 };
37
+ // A map rather than an object, so that a property added to `Object.prototype` is never read as an entry.
38
+ const RADIX = new Map([['x', 16], ['o', 8], ['b', 2]]);
55
39
  /**
56
- Parse a document into a syntax tree, for tools such as linters and formatters.
40
+ 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.
57
41
 
58
- Every node, token, and comment has a `range` and a `loc`.
42
+ 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 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.
59
43
 
60
44
  @param text - The document.
61
45
  @returns The root node, which also holds every token and comment.
@@ -80,21 +64,26 @@ export function parseTree(text) {
80
64
  throw new TypeError(`Expected a string, got ${text === null ? 'null' : typeof text}`);
81
65
  }
82
66
  // Validates the whole document, so the builder below can assume valid input.
83
- parse(text);
67
+ parseWithTimes(text);
84
68
  return new TreeBuilder(text).build();
85
69
  }
86
70
  /*
87
- 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.
71
+ 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`.
88
72
  */
89
73
  function readScalar(raw) {
90
- return parse(`[${raw}]`)[0];
74
+ return parseWithTimes(`[${raw}]`)[0];
91
75
  }
76
+ /*
77
+ 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, shares the token's `range` and `loc`.
78
+ */
92
79
  class TreeBuilder {
93
80
  #source;
94
81
  #index = 0;
95
82
  #tokens = [];
96
83
  #comments = [];
97
84
  #lineStarts = [0];
85
+ // The line of the last offset looked up. Offsets mostly arrive in order, so it is the best first guess for the next one.
86
+ #line = 0;
98
87
  constructor(source) {
99
88
  this.#source = source;
100
89
  for (let index = source.indexOf('\n'); index !== -1; index = source.indexOf('\n', index + 1)) {
@@ -109,29 +98,40 @@ class TreeBuilder {
109
98
  */
110
99
  #position(offset) {
111
100
  const lineStarts = this.#lineStarts;
112
- let low = 0;
113
- let high = lineStarts.length - 1;
114
- while (low < high) {
115
- const middle = Math.ceil((low + high) / 2);
116
- if (lineStarts[middle] <= offset) {
117
- low = middle;
118
- }
119
- else {
120
- high = middle - 1;
101
+ // 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.
102
+ if (this.#isOnLine(offset, this.#line + 1)) {
103
+ this.#line++;
104
+ }
105
+ else if (!this.#isOnLine(offset, this.#line)) {
106
+ let low = 0;
107
+ let high = lineStarts.length - 1;
108
+ while (low < high) {
109
+ const middle = Math.ceil((low + high) / 2);
110
+ if (lineStarts[middle] <= offset) {
111
+ low = middle;
112
+ }
113
+ else {
114
+ high = middle - 1;
115
+ }
121
116
  }
117
+ this.#line = low;
122
118
  }
123
- return { line: low + 1, column: offset - lineStarts[low] };
119
+ return { line: this.#line + 1, column: offset - lineStarts[this.#line] };
124
120
  }
125
- #node(type, start, end, fields) {
126
- return {
121
+ #isOnLine(offset, line) {
122
+ const lineStarts = this.#lineStarts;
123
+ return line < lineStarts.length && lineStarts[line] <= offset && (line + 1 === lineStarts.length || lineStarts[line + 1] > offset);
124
+ }
125
+ #location(start, end) {
126
+ return { start: this.#position(start), end: this.#position(end) };
127
+ }
128
+ #token(type, start, end) {
129
+ const token = {
127
130
  type,
128
- ...fields,
131
+ value: this.#source.slice(start, end),
129
132
  range: [start, end],
130
- loc: { start: this.#position(start), end: this.#position(end) },
133
+ loc: this.#location(start, end),
131
134
  };
132
- }
133
- #token(type, start, end) {
134
- const token = this.#node(type, start, end, { value: this.#source.slice(start, end) });
135
135
  this.#tokens.push(token);
136
136
  return token;
137
137
  }
@@ -144,48 +144,73 @@ class TreeBuilder {
144
144
  for (;;) {
145
145
  const code = this.#code();
146
146
  const start = this.#index;
147
- if (WHITESPACE.has(code)) {
147
+ if (code === LF || isSpace(code)) {
148
148
  this.#index++;
149
149
  }
150
150
  else if (code === HASH) {
151
151
  this.#index = findLineEnd(source, start);
152
- this.#comments.push(this.#node('Line', start, this.#index, { value: source.slice(start + 1, this.#index) }));
152
+ this.#comment('Line', start, this.#index, source.slice(start + 1, this.#index));
153
153
  }
154
154
  else if (code === SLASH && this.#code(start + 1) === ASTERISK) {
155
155
  this.#index = source.indexOf('*/', start + 2) + 2;
156
- this.#comments.push(this.#node('Block', start, this.#index, { value: source.slice(start + 2, this.#index - 2) }));
156
+ this.#comment('Block', start, this.#index, source.slice(start + 2, this.#index - 2));
157
157
  }
158
158
  else {
159
159
  return;
160
160
  }
161
161
  }
162
162
  }
163
+ #comment(type, start, end, value) {
164
+ this.#comments.push({
165
+ type,
166
+ value,
167
+ range: [start, end],
168
+ loc: this.#location(start, end),
169
+ });
170
+ }
163
171
  #bareObject() {
164
172
  const members = [];
165
173
  do {
166
174
  members.push(this.#member());
167
175
  this.#skipTrivia();
168
176
  } while (this.#index < this.#source.length);
169
- return this.#node('Object', members[0].range[0], members.at(-1).range[1], { members, braced: false });
177
+ const first = members[0];
178
+ const last = members.at(-1);
179
+ return {
180
+ type: 'Object',
181
+ members,
182
+ braced: false,
183
+ range: [first.range[0], last.range[1]],
184
+ loc: spanLocation(first, last),
185
+ };
170
186
  }
171
187
  #object() {
172
- const start = this.#index;
173
- const members = this.#items(CLOSE_BRACE, () => this.#member());
174
- return this.#node('Object', start, this.#index, { members, braced: true });
188
+ const { items: members, opening, closing } = this.#items(CLOSE_BRACE, () => this.#member());
189
+ return {
190
+ type: 'Object',
191
+ members,
192
+ braced: true,
193
+ range: [opening.range[0], closing.range[1]],
194
+ loc: spanLocation(opening, closing),
195
+ };
175
196
  }
176
197
  #array() {
177
- const start = this.#index;
178
- const elements = this.#items(CLOSE_BRACKET, () => this.#value());
179
- return this.#node('Array', start, this.#index, { elements });
198
+ const { items: elements, opening, closing } = this.#items(CLOSE_BRACKET, () => this.#value());
199
+ return {
200
+ type: 'Array',
201
+ elements,
202
+ range: [opening.range[0], closing.range[1]],
203
+ loc: spanLocation(opening, closing),
204
+ };
180
205
  }
181
206
  /*
182
- The items between an opening bracket and its `closing` bracket, which is consumed too. The parser has already checked the commas.
207
+ 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.
183
208
  */
184
- #items(closing, parseItem) {
209
+ #items(closingCode, parseItem) {
185
210
  const items = [];
186
- this.#punctuator();
211
+ const opening = this.#punctuator();
187
212
  this.#skipTrivia();
188
- while (this.#code() !== closing) {
213
+ while (this.#code() !== closingCode) {
189
214
  items.push(parseItem());
190
215
  this.#skipTrivia();
191
216
  if (this.#code() !== COMMA) {
@@ -194,36 +219,45 @@ class TreeBuilder {
194
219
  this.#punctuator();
195
220
  this.#skipTrivia();
196
221
  }
197
- this.#punctuator();
198
- return items;
222
+ return { items, opening, closing: this.#punctuator() };
199
223
  }
200
224
  #member() {
201
225
  const key = this.#key();
202
226
  this.#punctuator(); // `:`
203
227
  this.#skipTrivia();
204
228
  const value = this.#value();
205
- return this.#node('Member', key.range[0], value.range[1], { key, value });
229
+ return {
230
+ type: 'Member',
231
+ key,
232
+ value,
233
+ range: [key.range[0], value.range[1]],
234
+ loc: spanLocation(key, value),
235
+ };
206
236
  }
207
237
  #key() {
208
- const segments = [this.#keySegment()];
209
- while (this.#code() === DOT) {
210
- this.#punctuator();
211
- segments.push(this.#keySegment());
212
- }
213
- return this.#node('Key', segments[0].range[0], segments.at(-1).range[1], { segments });
214
- }
215
- #keySegment() {
216
238
  const start = this.#index;
217
239
  const code = this.#code();
218
240
  if (code === SINGLE_QUOTE || code === DOUBLE_QUOTE) {
219
- const { value, style } = this.#singleLineString();
220
- return this.#node('KeySegment', start, this.#index, { value, style });
241
+ const { value, style, token: { range, loc } } = this.#singleLineString();
242
+ return {
243
+ type: 'Key',
244
+ value,
245
+ style,
246
+ range,
247
+ loc,
248
+ };
221
249
  }
222
250
  while (isBareKeyCharacter(this.#code())) {
223
251
  this.#index++;
224
252
  }
225
- const token = this.#token('BareKey', start, this.#index);
226
- return this.#node('KeySegment', start, this.#index, { value: token.value, style: 'bare' });
253
+ const { value, range, loc } = this.#token('BareKey', start, this.#index);
254
+ return {
255
+ type: 'Key',
256
+ value,
257
+ style: 'bare',
258
+ range,
259
+ loc,
260
+ };
227
261
  }
228
262
  #value() {
229
263
  const code = this.#code();
@@ -238,16 +272,30 @@ class TreeBuilder {
238
272
  if (this.#code(start + 1) === code && this.#code(start + 2) === code) {
239
273
  return this.#blockString(code);
240
274
  }
241
- const { value, style } = this.#singleLineString();
242
- return this.#node('String', start, this.#index, { value, style, block: false });
275
+ const { value, style, token: { range, loc } } = this.#singleLineString();
276
+ return {
277
+ type: 'String',
278
+ value,
279
+ style,
280
+ block: false,
281
+ range,
282
+ loc,
283
+ };
243
284
  }
244
- const keyword = KEYWORDS.find(word => this.#source.startsWith(word, start) && !isBareKeyCharacter(this.#code(start + word.length)));
285
+ // 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`.
286
+ const keyword = KEYWORDS.find(word => this.#source.startsWith(word, start));
245
287
  if (keyword !== undefined) {
246
288
  this.#index += keyword.length;
247
- this.#token('Keyword', start, this.#index);
248
- return keyword === 'null'
249
- ? this.#node('Null', start, this.#index, {})
250
- : this.#node('Boolean', start, this.#index, { value: keyword === 'true' });
289
+ const { range, loc } = this.#token('Keyword', start, this.#index);
290
+ if (keyword === 'null') {
291
+ return { type: 'Null', range, loc };
292
+ }
293
+ return {
294
+ type: 'Boolean',
295
+ value: keyword === 'true',
296
+ range,
297
+ loc,
298
+ };
251
299
  }
252
300
  return this.#numberOrTime();
253
301
  }
@@ -270,8 +318,8 @@ class TreeBuilder {
270
318
  this.#index = end + 1;
271
319
  const token = this.#token('String', start, this.#index);
272
320
  return quote === SINGLE_QUOTE
273
- ? { value: source.slice(start + 1, end), style: 'literal' }
274
- : { value: readScalar(token.value), style: 'escaped' };
321
+ ? { value: source.slice(start + 1, end), style: 'literal', token }
322
+ : { value: readScalar(token.value), style: 'escaped', token };
275
323
  }
276
324
  /*
277
325
  The block ends at the first line whose first non-whitespace content is a run of exactly as many quotes as the opening delimiter.
@@ -285,12 +333,15 @@ class TreeBuilder {
285
333
  }
286
334
  const closing = findBlockStringEnd(source, source.indexOf('\n', start) + 1, quote, delimiterLength);
287
335
  this.#index = closing.delimiterStart + delimiterLength;
288
- const token = this.#token('String', start, this.#index);
289
- return this.#node('String', start, this.#index, {
290
- value: readScalar(token.value),
336
+ const { value, range, loc } = this.#token('String', start, this.#index);
337
+ return {
338
+ type: 'String',
339
+ value: readScalar(value),
291
340
  style: quote === SINGLE_QUOTE ? 'literal' : 'escaped',
292
341
  block: true,
293
- });
342
+ range,
343
+ loc,
344
+ };
294
345
  }
295
346
  #numberOrTime() {
296
347
  const start = this.#index;
@@ -299,16 +350,53 @@ class TreeBuilder {
299
350
  const raw = this.#source.slice(start, end);
300
351
  const value = readScalar(raw);
301
352
  if (typeof value === 'bigint') {
302
- this.#token('Integer', start, end);
303
- return this.#node('Integer', start, end, { value, radix: RADIX[raw.charAt(1)] ?? 10 });
353
+ const { range, loc } = this.#token('Integer', start, end);
354
+ return {
355
+ type: 'Integer',
356
+ value,
357
+ radix: RADIX.get(raw.charAt(1)) ?? 10,
358
+ range,
359
+ loc,
360
+ };
304
361
  }
305
362
  if (typeof value === 'number') {
306
- this.#token('Float', start, end);
307
- return this.#node('Float', start, end, { value });
363
+ const { range, loc } = this.#token('Float', start, end);
364
+ return {
365
+ type: 'Float',
366
+ value,
367
+ range,
368
+ loc,
369
+ };
370
+ }
371
+ const time = value;
372
+ const { range, loc } = this.#token(time.type, start, end);
373
+ let temporal;
374
+ if (time.type === 'Duration') {
375
+ // A getter, so that the `Temporal` object is made when `value` is first read.
376
+ const node = {
377
+ type: 'Duration',
378
+ get value() {
379
+ temporal ??= time.toTemporal();
380
+ return temporal;
381
+ },
382
+ negative: raw.startsWith('-'),
383
+ parts: readDurationParts(raw),
384
+ range,
385
+ loc,
386
+ };
387
+ return node;
308
388
  }
309
- const type = value instanceof Temporal.Instant ? 'Instant' : 'Duration';
310
- this.#token(type, start, end);
311
- return this.#node(type, start, end, { value });
389
+ // A getter, so that the `Temporal` object is made when `value` is first read.
390
+ const node = {
391
+ type: 'Instant',
392
+ get value() {
393
+ temporal ??= time.toTemporal();
394
+ return temporal;
395
+ },
396
+ range,
397
+ loc,
398
+ };
399
+ return node;
312
400
  }
313
401
  build() {
314
402
  this.#skipTrivia();
@@ -324,10 +412,41 @@ class TreeBuilder {
324
412
  body = this.#bareObject();
325
413
  }
326
414
  this.#skipTrivia();
327
- return this.#node('Document', 0, this.#source.length, {
415
+ return {
416
+ type: 'Document',
328
417
  body,
329
418
  tokens: this.#tokens,
330
419
  comments: this.#comments,
331
- });
420
+ range: [0, this.#source.length],
421
+ loc: this.#location(0, this.#source.length),
422
+ };
423
+ }
424
+ }
425
+ /*
426
+ 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.
427
+ */
428
+ function spanLocation(first, last) {
429
+ return { start: first.loc.start, end: last.loc.end };
430
+ }
431
+ /*
432
+ 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.
433
+ */
434
+ function readDurationParts(raw) {
435
+ // From `a` to `z`.
436
+ const isUnitCharacter = (index) => raw.charCodeAt(index) >= 0x61 && raw.charCodeAt(index) <= 0x7A;
437
+ const parts = [];
438
+ let index = raw.startsWith('-') ? 1 : 0;
439
+ while (index < raw.length) {
440
+ let unitStart = index;
441
+ while (!isUnitCharacter(unitStart)) {
442
+ unitStart++;
443
+ }
444
+ let unitEnd = unitStart;
445
+ while (unitEnd < raw.length && isUnitCharacter(unitEnd)) {
446
+ unitEnd++;
447
+ }
448
+ parts.push({ number: raw.slice(index, unitStart), unit: raw.slice(unitStart, unitEnd) });
449
+ index = unitEnd;
332
450
  }
451
+ return parts;
333
452
  }
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "soml-lang",
3
- "version": "0.0.2",
4
- "description": "Reference parser, canonical serializer, and formatter for SOML, a config format for humans",
3
+ "version": "0.0.4",
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": {
@@ -16,7 +17,7 @@
16
17
  "scripts": {
17
18
  "build": "tsc --project tsconfig.build.json",
18
19
  "prepack": "npm run build",
19
- "test": "xo && tsc && node --test",
20
+ "test": "xo && tsc && node --test 'test/*.ts'",
20
21
  "bench": "node bench/index.ts"
21
22
  },
22
23
  "files": [