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/distribution/format.d.ts
CHANGED
|
@@ -1,18 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
Format a document
|
|
2
|
+
Format a document. Returns the document with its layout normalized, ending with one line feed.
|
|
3
3
|
|
|
4
|
-
The layout follows the
|
|
4
|
+
The layout follows the formatter 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.
|
|
5
|
+
|
|
6
|
+
In detail, as the specification states:
|
|
7
|
+
|
|
8
|
+
- 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.
|
|
9
|
+
- One space follows each `:`, as in canonical form.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- There are no blank lines at the start of the file or directly inside brackets.
|
|
5
13
|
|
|
6
14
|
@param text - The document.
|
|
7
15
|
@returns The formatted document, ending with one line feed.
|
|
8
16
|
@throws {ParseError} When the document is not valid, the same as `parse()`.
|
|
17
|
+
@throws {TypeError} When `text` is not a string.
|
|
9
18
|
|
|
10
19
|
@example
|
|
11
20
|
```
|
|
12
21
|
import {format} from 'soml-lang';
|
|
13
22
|
|
|
14
|
-
format('pool: {min: 2,
|
|
15
|
-
//=> 'pool: {
|
|
23
|
+
format('pool: {min: 2, max: 16,} # Connections');
|
|
24
|
+
//=> 'pool: {min: 2, max: 16} # Connections\n'
|
|
25
|
+
|
|
26
|
+
format('pool: {\nmin: 2, max: 16}');
|
|
27
|
+
//=> 'pool: {\n\tmin: 2\n\tmax: 16\n}\n'
|
|
16
28
|
```
|
|
17
29
|
*/
|
|
18
30
|
export declare function format(text: string): string;
|
|
31
|
+
/**
|
|
32
|
+
A change to a document: a range of its text, and the text that replaces it.
|
|
33
|
+
*/
|
|
34
|
+
export type FormatEdit = {
|
|
35
|
+
/**
|
|
36
|
+
The start and end of the replaced text, as UTF-16 offsets into the document. They are equal when the edit only inserts.
|
|
37
|
+
*/
|
|
38
|
+
readonly range: readonly [start: number, end: number];
|
|
39
|
+
/**
|
|
40
|
+
The text that replaces the range. It is empty when the edit only removes.
|
|
41
|
+
*/
|
|
42
|
+
readonly text: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
The changes that `format()` makes to a document, as edits of its text. For an editor or a linter, which shows or applies each change where it is, rather than replacing the whole document.
|
|
46
|
+
|
|
47
|
+
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.
|
|
48
|
+
|
|
49
|
+
@param text - The document.
|
|
50
|
+
@returns The edits, in the order of their ranges.
|
|
51
|
+
@throws {ParseError} When the document is not valid, the same as `parse()`.
|
|
52
|
+
@throws {TypeError} When `text` is not a string.
|
|
53
|
+
|
|
54
|
+
@example
|
|
55
|
+
```
|
|
56
|
+
import {formatEdits} from 'soml-lang';
|
|
57
|
+
|
|
58
|
+
formatEdits('a: [1,\n2]\n');
|
|
59
|
+
//=> [{range: [4, 4], text: '\n\t'}, {range: [5, 7], text: '\n\t'}, {range: [8, 8], text: '\n'}]
|
|
60
|
+
```
|
|
61
|
+
*/
|
|
62
|
+
export declare function formatEdits(text: string): FormatEdit[];
|
package/distribution/format.js
CHANGED
|
@@ -1,43 +1,133 @@
|
|
|
1
1
|
import { parseTree, } from "./tree.js";
|
|
2
|
+
import { ASTERISK, isBlankLine, COMMA, HASH, SLASH, findLineEnd, isSpace, skipSpaces, skipSpacesBack, LF, } from "./shared.js";
|
|
2
3
|
const INDENT = '\t';
|
|
3
|
-
const BLANK = /^[\t ]*$/v;
|
|
4
4
|
/**
|
|
5
|
-
Format a document
|
|
5
|
+
Format a document. Returns the document with its layout normalized, ending with one line feed.
|
|
6
6
|
|
|
7
|
-
The layout follows the
|
|
7
|
+
The layout follows the formatter 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.
|
|
8
|
+
|
|
9
|
+
In detail, as the specification states:
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
- One space follows each `:`, as in canonical form.
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- There are no blank lines at the start of the file or directly inside brackets.
|
|
8
16
|
|
|
9
17
|
@param text - The document.
|
|
10
18
|
@returns The formatted document, ending with one line feed.
|
|
11
19
|
@throws {ParseError} When the document is not valid, the same as `parse()`.
|
|
20
|
+
@throws {TypeError} When `text` is not a string.
|
|
12
21
|
|
|
13
22
|
@example
|
|
14
23
|
```
|
|
15
24
|
import {format} from 'soml-lang';
|
|
16
25
|
|
|
17
|
-
format('pool: {min: 2,
|
|
18
|
-
//=> 'pool: {
|
|
26
|
+
format('pool: {min: 2, max: 16,} # Connections');
|
|
27
|
+
//=> 'pool: {min: 2, max: 16} # Connections\n'
|
|
28
|
+
|
|
29
|
+
format('pool: {\nmin: 2, max: 16}');
|
|
30
|
+
//=> 'pool: {\n\tmin: 2\n\tmax: 16\n}\n'
|
|
19
31
|
```
|
|
20
32
|
*/
|
|
21
33
|
export function format(text) {
|
|
22
34
|
return new Printer(text, parseTree(text)).print();
|
|
23
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
The changes that `format()` makes to a document, as edits of its text. For an editor or a linter, which shows or applies each change where it is, rather than replacing the whole document.
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
@param text - The document.
|
|
42
|
+
@returns The edits, in the order of their ranges.
|
|
43
|
+
@throws {ParseError} When the document is not valid, the same as `parse()`.
|
|
44
|
+
@throws {TypeError} When `text` is not a string.
|
|
45
|
+
|
|
46
|
+
@example
|
|
47
|
+
```
|
|
48
|
+
import {formatEdits} from 'soml-lang';
|
|
49
|
+
|
|
50
|
+
formatEdits('a: [1,\n2]\n');
|
|
51
|
+
//=> [{range: [4, 4], text: '\n\t'}, {range: [5, 7], text: '\n\t'}, {range: [8, 8], text: '\n'}]
|
|
52
|
+
```
|
|
53
|
+
*/
|
|
54
|
+
export function formatEdits(text) {
|
|
55
|
+
const formatted = format(text);
|
|
56
|
+
const edits = [];
|
|
57
|
+
let textIndex = 0;
|
|
58
|
+
let formattedIndex = 0;
|
|
59
|
+
// Without the characters that the formatter changes, the two texts are the same. So walking both at once, every run of those characters that differs is one edit, which takes linear time, where a general diff takes quadratic time on a document with many changes.
|
|
60
|
+
while (textIndex < text.length || formattedIndex < formatted.length) {
|
|
61
|
+
const textStart = textIndex;
|
|
62
|
+
const formattedStart = formattedIndex;
|
|
63
|
+
const textGapEnd = skipLayout(text, textIndex);
|
|
64
|
+
const formattedGapEnd = skipLayout(formatted, formattedIndex);
|
|
65
|
+
if (text.slice(textIndex, textGapEnd) !== formatted.slice(formattedIndex, formattedGapEnd)) {
|
|
66
|
+
edits.push(createEdit(text, textIndex, textGapEnd, formatted.slice(formattedIndex, formattedGapEnd)));
|
|
67
|
+
}
|
|
68
|
+
textIndex = textGapEnd;
|
|
69
|
+
formattedIndex = formattedGapEnd;
|
|
70
|
+
while (textIndex < text.length && !isLayout(text.charCodeAt(textIndex)) && text.charCodeAt(textIndex) === formatted.charCodeAt(formattedIndex)) {
|
|
71
|
+
textIndex++;
|
|
72
|
+
formattedIndex++;
|
|
73
|
+
}
|
|
74
|
+
// Both stop at a different character, or one at its end, only when the formatter changed something else, and then the walk would never end.
|
|
75
|
+
if (textIndex === textStart && formattedIndex === formattedStart) {
|
|
76
|
+
throw new Error(`The formatter changed more than the layout at offset ${textIndex}. This is a bug in soml-lang.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return edits;
|
|
80
|
+
}
|
|
81
|
+
/*
|
|
82
|
+
The characters that the formatter changes.
|
|
83
|
+
*/
|
|
84
|
+
function isLayout(code) {
|
|
85
|
+
return isSpace(code) || code === LF || code === COMMA;
|
|
86
|
+
}
|
|
87
|
+
/*
|
|
88
|
+
The end of the run of layout characters at `index`.
|
|
89
|
+
*/
|
|
90
|
+
function skipLayout(text, index) {
|
|
91
|
+
while (index < text.length && isLayout(text.charCodeAt(index))) {
|
|
92
|
+
index++;
|
|
93
|
+
}
|
|
94
|
+
return index;
|
|
95
|
+
}
|
|
96
|
+
/*
|
|
97
|
+
The edit that replaces `text.slice(start, end)` with `replacement`, without the characters at its ends that stay the same, as in `, ` to `\n\t`.
|
|
98
|
+
*/
|
|
99
|
+
function createEdit(text, start, end, replacement) {
|
|
100
|
+
let prefixLength = 0;
|
|
101
|
+
while (start + prefixLength < end && prefixLength < replacement.length && text[start + prefixLength] === replacement[prefixLength]) {
|
|
102
|
+
prefixLength++;
|
|
103
|
+
}
|
|
104
|
+
let suffixLength = 0;
|
|
105
|
+
while (end - suffixLength > start + prefixLength && replacement.length - suffixLength > prefixLength && text[end - suffixLength - 1] === replacement[replacement.length - suffixLength - 1]) {
|
|
106
|
+
suffixLength++;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
range: [start + prefixLength, end - suffixLength],
|
|
110
|
+
text: replacement.slice(prefixLength, replacement.length - suffixLength),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
24
113
|
class Printer {
|
|
25
114
|
#text;
|
|
26
115
|
#tree;
|
|
27
|
-
|
|
116
|
+
// Joined once at the end. Appending with `+=` builds a deep rope, and reading it one character at a time, as `formatEdits()` does, can take quadratic time once V8 deoptimizes the reader.
|
|
117
|
+
#output = [];
|
|
28
118
|
#commentIndex = 0;
|
|
29
119
|
constructor(text, tree) {
|
|
30
120
|
this.#text = text;
|
|
31
121
|
this.#tree = tree;
|
|
32
122
|
}
|
|
33
123
|
#write(text) {
|
|
34
|
-
this.#output
|
|
124
|
+
this.#output.push(text);
|
|
35
125
|
}
|
|
36
126
|
/*
|
|
37
127
|
Starts a new line at `level`, with one blank line before it when the source had one.
|
|
38
128
|
*/
|
|
39
129
|
#newLine(level, isBlank) {
|
|
40
|
-
if (this.#output ===
|
|
130
|
+
if (this.#output.length === 0) {
|
|
41
131
|
return;
|
|
42
132
|
}
|
|
43
133
|
this.#write(`${isBlank ? '\n\n' : '\n'}${INDENT.repeat(level)}`);
|
|
@@ -45,8 +135,19 @@ class Printer {
|
|
|
45
135
|
#isSameLine(start, end) {
|
|
46
136
|
return !this.#text.slice(start, end).includes('\n');
|
|
47
137
|
}
|
|
138
|
+
/*
|
|
139
|
+
Whether a line between `start` and `end` holds only spaces and tabs, if anything.
|
|
140
|
+
*/
|
|
48
141
|
#hasBlankLine(start, end) {
|
|
49
|
-
|
|
142
|
+
// A slice, so that the search for a line break stops at `end`.
|
|
143
|
+
const gap = this.#text.slice(start, end);
|
|
144
|
+
for (let index = gap.indexOf('\n'); index !== -1; index = gap.indexOf('\n', index + 1)) {
|
|
145
|
+
index = skipSpaces(gap, index + 1);
|
|
146
|
+
if (gap.charCodeAt(index) === LF) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
50
151
|
}
|
|
51
152
|
/*
|
|
52
153
|
The next comment that ends at or before `end`, without consuming it.
|
|
@@ -61,37 +162,53 @@ class Printer {
|
|
|
61
162
|
this.#write(trimTrailingWhitespace(`#${comment.value}`));
|
|
62
163
|
return;
|
|
63
164
|
}
|
|
64
|
-
// The lines of a block comment keep their own indentation,
|
|
65
|
-
|
|
165
|
+
// The lines of a block comment keep their own indentation. Trailing whitespace is removed, and runs of blank lines collapse to one, as everywhere outside a block string.
|
|
166
|
+
const lines = `/*${comment.value}*/`.split('\n').map(line => trimTrailingWhitespace(line));
|
|
167
|
+
this.#write(lines.filter((line, index) => line !== '' || lines[index - 1] !== '').join('\n'));
|
|
66
168
|
}
|
|
67
169
|
/*
|
|
68
170
|
The offset of the `,` after an item in the source, or `undefined` when there is none.
|
|
69
171
|
*/
|
|
70
172
|
#findComma(start, end) {
|
|
71
|
-
const
|
|
72
|
-
|
|
173
|
+
const text = this.#text;
|
|
174
|
+
// Between two items there is only whitespace, comments, and at most one comma.
|
|
175
|
+
for (let index = start; index < end; index++) {
|
|
176
|
+
const code = text.charCodeAt(index);
|
|
177
|
+
if (code === COMMA) {
|
|
178
|
+
return index;
|
|
179
|
+
}
|
|
180
|
+
if (code === HASH) {
|
|
181
|
+
index = findLineEnd(text, index);
|
|
182
|
+
}
|
|
183
|
+
else if (code === SLASH && text.charCodeAt(index + 1) === ASTERISK) {
|
|
184
|
+
index = text.indexOf('*/', index + 2) + 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return undefined;
|
|
73
188
|
}
|
|
74
189
|
/*
|
|
75
190
|
Writes `items` one per line at `level`, with the comments between them, from `start` to `end` in the source.
|
|
76
191
|
|
|
77
|
-
A comment on the line of the item before it, or of the opening bracket, stays at the end of that line. The exception is a block comment after the comma that the next item follows on the same line, as in `[1, /* note *\/ 2]`: it stays in front of that item. Every other comment
|
|
192
|
+
A comment on the line of the item before it, or of the opening bracket, stays at the end of that line. The exception is a block comment after the comma that the next item follows on the same line, as in `[1, /* note *\/ 2]`: it stays in front of that item. Every other comment starts a new line, and a comment or an item that follows a block comment on its line stays on that line.
|
|
78
193
|
*/
|
|
79
|
-
#list(items, { start, end, level,
|
|
194
|
+
#list(items, { start, end, level, isBracketed }) {
|
|
80
195
|
let previousEnd = start;
|
|
81
196
|
let itemEnd;
|
|
82
197
|
// Whether a comment may stay at the end of the line before it, which needs an item or an opening bracket there.
|
|
83
|
-
let canTrail =
|
|
198
|
+
let canTrail = isBracketed;
|
|
84
199
|
let hasWritten = false;
|
|
85
200
|
let isOnSameLine = false;
|
|
86
201
|
for (const item of [...items, undefined]) {
|
|
87
202
|
const itemStart = item?.range[0] ?? end;
|
|
88
|
-
const comma =
|
|
203
|
+
const comma = isBracketed ? this.#findComma(previousEnd, itemStart) : undefined;
|
|
204
|
+
// Found once per item rather than once per comment, which would be quadratic in the number of comments on one line.
|
|
205
|
+
const itemLineStart = item === undefined ? undefined : previousEnd + this.#text.slice(previousEnd, itemStart).lastIndexOf('\n') + 1;
|
|
89
206
|
for (let comment = this.#peekComment(itemStart); comment !== undefined; comment = this.#peekComment(itemStart)) {
|
|
90
207
|
const [commentStart, commentEnd] = comment.range;
|
|
91
208
|
const isTrailing = canTrail && !isOnSameLine && this.#isTrailing(comment, {
|
|
92
209
|
previousEnd,
|
|
93
210
|
itemEnd,
|
|
94
|
-
|
|
211
|
+
itemLineStart,
|
|
95
212
|
comma,
|
|
96
213
|
});
|
|
97
214
|
if (isTrailing || isOnSameLine) {
|
|
@@ -104,7 +221,7 @@ class Printer {
|
|
|
104
221
|
hasWritten ||= !isTrailing;
|
|
105
222
|
// A block comment that the next comment or item follows on the same line keeps it on that line.
|
|
106
223
|
const nextStart = this.#peekComment(itemStart)?.range[0] ?? itemStart;
|
|
107
|
-
isOnSameLine = comment.type === 'Block' && !isTrailing &&
|
|
224
|
+
isOnSameLine = comment.type === 'Block' && !isTrailing && this.#isSameLine(commentEnd, nextStart);
|
|
108
225
|
previousEnd = commentEnd;
|
|
109
226
|
}
|
|
110
227
|
if (item === undefined) {
|
|
@@ -116,7 +233,7 @@ class Printer {
|
|
|
116
233
|
else {
|
|
117
234
|
this.#newLine(level, hasWritten && this.#hasBlankLine(previousEnd, itemStart));
|
|
118
235
|
}
|
|
119
|
-
this.#item(item, level
|
|
236
|
+
this.#item(item, level);
|
|
120
237
|
previousEnd = item.range[1];
|
|
121
238
|
itemEnd = previousEnd;
|
|
122
239
|
canTrail = true;
|
|
@@ -127,63 +244,105 @@ class Printer {
|
|
|
127
244
|
/*
|
|
128
245
|
Whether a comment stays at the end of the line before it. That line ends at the item before it, or at the comma after that item.
|
|
129
246
|
*/
|
|
130
|
-
#isTrailing(comment, { previousEnd, itemEnd,
|
|
247
|
+
#isTrailing(comment, { previousEnd, itemEnd, itemLineStart, comma }) {
|
|
131
248
|
const [commentStart, commentEnd] = comment.range;
|
|
132
249
|
const isAfterComma = comma !== undefined && commentStart > comma;
|
|
133
|
-
// A block comment after the comma that the next item follows on the same line stays in front of that item.
|
|
134
|
-
if (
|
|
250
|
+
// A block comment after the comma that the next item follows on the same line stays in front of that item. The comment ends on the item's line when it ends after the start of that line.
|
|
251
|
+
if (itemLineStart !== undefined && comment.type === 'Block' && (comma === undefined || isAfterComma) && commentEnd >= itemLineStart) {
|
|
135
252
|
return false;
|
|
136
253
|
}
|
|
137
|
-
// A comment after the comma ends the comma's line, which
|
|
254
|
+
// A comment after the comma ends the comma's line, which is later than the item's when a block comment that spans lines comes between them. The comma is removed, so the comment moves to the end of the item's line, which only holds when nothing came between them.
|
|
138
255
|
const lineEnd = isAfterComma && previousEnd === itemEnd ? comma + 1 : previousEnd;
|
|
139
256
|
return this.#isSameLine(lineEnd, commentStart);
|
|
140
257
|
}
|
|
141
|
-
|
|
258
|
+
/*
|
|
259
|
+
Writes a container that is on one line in the source on one line, with a comma and a space between its members or items and no trailing comma. Only a block comment can be inside it, and each one stays where it is among the items and commas.
|
|
260
|
+
*/
|
|
261
|
+
#oneLine(items, { start, end, level }, opening, closing) {
|
|
262
|
+
this.#write(opening);
|
|
263
|
+
let previousEnd = start + 1;
|
|
264
|
+
let hasWrittenToken = false;
|
|
265
|
+
for (const item of [...items, undefined]) {
|
|
266
|
+
const itemStart = item?.range[0] ?? (end - 1);
|
|
267
|
+
// The comma after the last item is left out.
|
|
268
|
+
const comma = item === undefined ? undefined : this.#findComma(previousEnd, itemStart);
|
|
269
|
+
let hasWrittenComma = false;
|
|
270
|
+
for (let comment = this.#peekComment(itemStart); comment !== undefined; comment = this.#peekComment(itemStart)) {
|
|
271
|
+
if (!hasWrittenComma && comma !== undefined && comma < comment.range[0]) {
|
|
272
|
+
this.#write(',');
|
|
273
|
+
hasWrittenComma = true;
|
|
274
|
+
}
|
|
275
|
+
this.#write(hasWrittenToken ? ' ' : '');
|
|
276
|
+
this.#writeComment(comment);
|
|
277
|
+
hasWrittenToken = true;
|
|
278
|
+
}
|
|
279
|
+
if (item === undefined) {
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
if (comma !== undefined && !hasWrittenComma) {
|
|
283
|
+
this.#write(', ');
|
|
284
|
+
}
|
|
285
|
+
else if (hasWrittenToken) {
|
|
286
|
+
this.#write(' ');
|
|
287
|
+
}
|
|
288
|
+
this.#item(item, level);
|
|
289
|
+
previousEnd = item.range[1];
|
|
290
|
+
hasWrittenToken = true;
|
|
291
|
+
}
|
|
292
|
+
this.#write(closing);
|
|
293
|
+
}
|
|
294
|
+
#item(item, level) {
|
|
142
295
|
if (item.type === 'Member') {
|
|
143
296
|
this.#member(item, level);
|
|
144
297
|
}
|
|
145
298
|
else {
|
|
146
299
|
this.#value(item, level);
|
|
147
300
|
}
|
|
148
|
-
if (hasComma) {
|
|
149
|
-
this.#write(',');
|
|
150
|
-
}
|
|
151
301
|
}
|
|
152
302
|
#member(member, level) {
|
|
153
303
|
const { key, value } = member;
|
|
154
304
|
this.#write(`${this.#text.slice(...key.range)}:`);
|
|
305
|
+
const isBlockString = value.type === 'String' && value.block;
|
|
155
306
|
if (this.#peekComment(value.range[0]) === undefined) {
|
|
307
|
+
// A block string begins on the next line, one level deeper, so its delimiters and content line up.
|
|
308
|
+
if (isBlockString) {
|
|
309
|
+
this.#newLine(level + 1, false);
|
|
310
|
+
this.#value(value, level + 1);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
156
313
|
this.#write(' ');
|
|
157
314
|
this.#value(value, level);
|
|
158
315
|
return;
|
|
159
316
|
}
|
|
160
|
-
// With comments between the `:` and the value, the line breaks between them are kept, and a value on a new line is one level deeper.
|
|
317
|
+
// With comments between the `:` and the value, the line breaks between them are kept, but not the blank lines, and a value on a new line is one level deeper.
|
|
161
318
|
let previousEnd = key.range[1] + 1;
|
|
162
319
|
let valueLevel = level;
|
|
163
320
|
for (let comment = this.#peekComment(value.range[0]); comment !== undefined; comment = this.#peekComment(value.range[0])) {
|
|
164
|
-
if (
|
|
321
|
+
if (this.#writeSeparator(previousEnd, comment.range[0], level + 1)) {
|
|
165
322
|
valueLevel = level + 1;
|
|
166
323
|
}
|
|
167
|
-
this.#writeSeparator(previousEnd, comment.range[0], level + 1);
|
|
168
324
|
this.#writeComment(comment);
|
|
169
325
|
previousEnd = comment.range[1];
|
|
170
326
|
}
|
|
171
|
-
if (
|
|
327
|
+
if (isBlockString) {
|
|
328
|
+
this.#newLine(level + 1, false);
|
|
329
|
+
valueLevel = level + 1;
|
|
330
|
+
}
|
|
331
|
+
else if (this.#writeSeparator(previousEnd, value.range[0], level + 1)) {
|
|
172
332
|
valueLevel = level + 1;
|
|
173
333
|
}
|
|
174
|
-
this.#writeSeparator(previousEnd, value.range[0], level + 1);
|
|
175
334
|
this.#value(value, valueLevel);
|
|
176
335
|
}
|
|
177
336
|
/*
|
|
178
|
-
A space when `end` is on the line of `start` in the source, and otherwise a new line at `level`.
|
|
337
|
+
A space when `end` is on the line of `start` in the source, and otherwise a new line at `level`. Returns whether it started a new line.
|
|
179
338
|
*/
|
|
180
339
|
#writeSeparator(start, end, level) {
|
|
181
340
|
if (this.#isSameLine(start, end)) {
|
|
182
341
|
this.#write(' ');
|
|
342
|
+
return false;
|
|
183
343
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
344
|
+
this.#newLine(level, false);
|
|
345
|
+
return true;
|
|
187
346
|
}
|
|
188
347
|
#value(node, level) {
|
|
189
348
|
const [start, end] = node.range;
|
|
@@ -194,31 +353,36 @@ class Printer {
|
|
|
194
353
|
this.#write(opening + closing);
|
|
195
354
|
return;
|
|
196
355
|
}
|
|
356
|
+
if (this.#isSameLine(start, end)) {
|
|
357
|
+
this.#oneLine(items, { start, end, level }, opening, closing);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
197
360
|
this.#write(opening);
|
|
198
361
|
this.#list(items, {
|
|
199
362
|
start: start + 1,
|
|
200
363
|
end: end - 1,
|
|
201
364
|
level: level + 1,
|
|
202
|
-
|
|
365
|
+
isBracketed: true,
|
|
203
366
|
});
|
|
204
367
|
this.#newLine(level, false);
|
|
205
368
|
this.#write(closing);
|
|
206
369
|
return;
|
|
207
370
|
}
|
|
208
371
|
const text = this.#text.slice(start, end);
|
|
209
|
-
this.#write(node.type === 'String' && node.block ? reindentBlockString(text, INDENT.repeat(level
|
|
372
|
+
this.#write(node.type === 'String' && node.block ? reindentBlockString(text, INDENT.repeat(level)) : text);
|
|
210
373
|
}
|
|
211
374
|
print() {
|
|
212
375
|
const { body } = this.#tree;
|
|
213
376
|
const items = body.type === 'Object' && !body.braced ? body.members : [body];
|
|
214
|
-
// The document is a list without
|
|
377
|
+
// The document is a list without brackets: the members of a brace-less object, or one braced collection.
|
|
215
378
|
this.#list(items, {
|
|
216
379
|
start: 0,
|
|
217
380
|
end: this.#text.length,
|
|
218
381
|
level: 0,
|
|
219
|
-
|
|
382
|
+
isBracketed: false,
|
|
220
383
|
});
|
|
221
|
-
|
|
384
|
+
this.#output.push('\n');
|
|
385
|
+
return this.#output.join('');
|
|
222
386
|
}
|
|
223
387
|
}
|
|
224
388
|
/*
|
|
@@ -226,11 +390,7 @@ Only spaces and tabs are whitespace in SOML. Any other character, such as a no-b
|
|
|
226
390
|
*/
|
|
227
391
|
function trimTrailingWhitespace(text) {
|
|
228
392
|
// A loop rather than a regular expression, which would take quadratic time on a long run of spaces inside a line.
|
|
229
|
-
|
|
230
|
-
while (end > 0 && (text[end - 1] === ' ' || text[end - 1] === '\t')) {
|
|
231
|
-
end--;
|
|
232
|
-
}
|
|
233
|
-
return text.slice(0, end);
|
|
393
|
+
return text.slice(0, skipSpacesBack(text, text.length));
|
|
234
394
|
}
|
|
235
395
|
/*
|
|
236
396
|
Moves a block string to a new indentation. Its content is relative to the closing delimiter's indentation, so replacing that indentation on every line keeps the value. A blank line is left as it is, because it becomes an empty line either way.
|
|
@@ -238,6 +398,6 @@ Moves a block string to a new indentation. Its content is relative to the closin
|
|
|
238
398
|
function reindentBlockString(text, indentation) {
|
|
239
399
|
const lines = text.split('\n');
|
|
240
400
|
const closing = lines.at(-1);
|
|
241
|
-
const oldIndentation = closing.slice(0, closing
|
|
242
|
-
return lines.map((line, index) => index === 0 || (
|
|
401
|
+
const oldIndentation = closing.slice(0, skipSpaces(closing, 0));
|
|
402
|
+
return lines.map((line, index) => index === 0 || isBlankLine(line) ? line : indentation + line.slice(oldIndentation.length)).join('\n');
|
|
243
403
|
}
|
package/distribution/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
/// <reference lib="esnext.temporal" preserve="true" />
|
|
1
2
|
export { parse, type Value, type ObjectValue, type Document, type ParseOptions, } from './parse.ts';
|
|
2
|
-
export { parseTree, visitorKeys, type Position, type SourceLocation, type Token, type Comment, type DocumentNode, type ObjectNode, type MemberNode, type ArrayNode, type KeyNode, type
|
|
3
|
-
export { format } from './format.ts';
|
|
4
|
-
export {
|
|
3
|
+
export { parseTree, visitorKeys, type Position, type SourceLocation, type Token, type Comment, type DocumentNode, type ObjectNode, type MemberNode, type ArrayNode, type KeyNode, type StringNode, type IntegerNode, type FloatNode, type BooleanNode, type NullNode, type InstantNode, type DurationNode, type DurationPart, type ValueNode, type Node, } from './tree.ts';
|
|
4
|
+
export { format, formatEdits, type FormatEdit } from './format.ts';
|
|
5
|
+
export { edit, type EditOptions, type PathSegment } from './edit.ts';
|
|
6
|
+
export { stringify, stringifyValue, compareKeys, type StringifyOptions, } from './stringify.ts';
|
|
7
|
+
export { isBareKey } from './shared.ts';
|
|
5
8
|
export { ParseError } from './error.ts';
|
package/distribution/index.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
// The types use the global `Temporal` types, which TypeScript has in the `esnext.temporal` lib, so a consumer whose `lib` is older gets them too.
|
|
2
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference -- A lib can only be loaded with a directive, and `preserve` keeps it in the emitted types.
|
|
3
|
+
/// <reference lib="esnext.temporal" preserve="true" />
|
|
1
4
|
export { parse, } from "./parse.js";
|
|
2
5
|
export { parseTree, visitorKeys, } from "./tree.js";
|
|
3
|
-
export { format } from "./format.js";
|
|
4
|
-
export {
|
|
6
|
+
export { format, formatEdits } from "./format.js";
|
|
7
|
+
export { edit } from "./edit.js";
|
|
8
|
+
export { stringify, stringifyValue, compareKeys, } from "./stringify.js";
|
|
9
|
+
export { isBareKey } from "./shared.js";
|
|
5
10
|
export { ParseError } from "./error.js";
|
package/distribution/parse.d.ts
CHANGED
|
@@ -24,6 +24,9 @@ export type ObjectValue<Integer extends bigint | number = bigint> = {
|
|
|
24
24
|
A whole document, which is always an object or an array.
|
|
25
25
|
*/
|
|
26
26
|
export type Document<Integer extends bigint | number = bigint> = ObjectValue<Integer> | Array<Value<Integer>>;
|
|
27
|
+
/**
|
|
28
|
+
Options for `parse()`.
|
|
29
|
+
*/
|
|
27
30
|
export type ParseOptions = {
|
|
28
31
|
/**
|
|
29
32
|
How an int is represented.
|
|
@@ -35,13 +38,17 @@ export type ParseOptions = {
|
|
|
35
38
|
*/
|
|
36
39
|
readonly integers?: 'bigint' | 'number';
|
|
37
40
|
};
|
|
41
|
+
export type ParsedValue = Value<bigint | number> | Time | ParsedValue[] | ParsedObject;
|
|
42
|
+
type ParsedObject = {
|
|
43
|
+
[key: string]: ParsedValue;
|
|
44
|
+
};
|
|
38
45
|
/**
|
|
39
|
-
Parse a document.
|
|
46
|
+
Parse a document. Returns an object or an array, because a document is always a collection.
|
|
40
47
|
|
|
41
|
-
@param text - The document, as a string or as UTF-8 bytes.
|
|
48
|
+
@param text - The document, as a string or as UTF-8 bytes. Invalid UTF-8 is reported with its position.
|
|
42
49
|
@returns The object or array the document contains.
|
|
43
50
|
@throws {ParseError} When the document is not valid.
|
|
44
|
-
@throws {TypeError} When `text` is not a string or a `Uint8Array`, or `options
|
|
51
|
+
@throws {TypeError} When `text` is not a string or a `Uint8Array`, or `options` is not an object or its `integers` is not `'bigint'` or `'number'`.
|
|
45
52
|
|
|
46
53
|
@example
|
|
47
54
|
```
|
|
@@ -51,18 +58,74 @@ parse(`
|
|
|
51
58
|
name: 'api-gateway'
|
|
52
59
|
replicas: 3
|
|
53
60
|
timeout: 30.0
|
|
54
|
-
postgres
|
|
61
|
+
postgres: {host: 'db.internal'}
|
|
55
62
|
`);
|
|
56
63
|
//=> {name: 'api-gateway', replicas: 3n, timeout: 30, postgres: {host: 'db.internal'}}
|
|
57
64
|
|
|
58
|
-
parse('
|
|
59
|
-
//=> {
|
|
65
|
+
parse('port: 8080', {integers: 'number'});
|
|
66
|
+
//=> {port: 8080}
|
|
60
67
|
```
|
|
61
68
|
*/
|
|
62
69
|
export declare function parse(text: string | Uint8Array, options?: ParseOptions & {
|
|
63
70
|
readonly integers?: 'bigint';
|
|
64
71
|
}): Document;
|
|
72
|
+
/**
|
|
73
|
+
Parse a document. Returns an object or an array, because a document is always a collection.
|
|
74
|
+
|
|
75
|
+
@param text - The document, as a string or as UTF-8 bytes. Invalid UTF-8 is reported with its position.
|
|
76
|
+
@returns The object or array the document contains.
|
|
77
|
+
@throws {ParseError} When the document is not valid.
|
|
78
|
+
@throws {TypeError} When `text` is not a string or a `Uint8Array`, or `options` is not an object or its `integers` is not `'bigint'` or `'number'`.
|
|
79
|
+
|
|
80
|
+
@example
|
|
81
|
+
```
|
|
82
|
+
import {parse} from 'soml-lang';
|
|
83
|
+
|
|
84
|
+
parse(`
|
|
85
|
+
name: 'api-gateway'
|
|
86
|
+
replicas: 3
|
|
87
|
+
timeout: 30.0
|
|
88
|
+
postgres: {host: 'db.internal'}
|
|
89
|
+
`);
|
|
90
|
+
//=> {name: 'api-gateway', replicas: 3n, timeout: 30, postgres: {host: 'db.internal'}}
|
|
91
|
+
|
|
92
|
+
parse('port: 8080', {integers: 'number'});
|
|
93
|
+
//=> {port: 8080}
|
|
94
|
+
```
|
|
95
|
+
*/
|
|
65
96
|
export declare function parse(text: string | Uint8Array, options: ParseOptions & {
|
|
66
97
|
readonly integers: 'number';
|
|
67
98
|
}): Document<number>;
|
|
99
|
+
/**
|
|
100
|
+
Parse a document. Returns an object or an array, because a document is always a collection.
|
|
101
|
+
|
|
102
|
+
@param text - The document, as a string or as UTF-8 bytes. Invalid UTF-8 is reported with its position.
|
|
103
|
+
@returns The object or array the document contains.
|
|
104
|
+
@throws {ParseError} When the document is not valid.
|
|
105
|
+
@throws {TypeError} When `text` is not a string or a `Uint8Array`, or `options` is not an object or its `integers` is not `'bigint'` or `'number'`.
|
|
106
|
+
|
|
107
|
+
@example
|
|
108
|
+
```
|
|
109
|
+
import {parse} from 'soml-lang';
|
|
110
|
+
|
|
111
|
+
parse(`
|
|
112
|
+
name: 'api-gateway'
|
|
113
|
+
replicas: 3
|
|
114
|
+
timeout: 30.0
|
|
115
|
+
postgres: {host: 'db.internal'}
|
|
116
|
+
`);
|
|
117
|
+
//=> {name: 'api-gateway', replicas: 3n, timeout: 30, postgres: {host: 'db.internal'}}
|
|
118
|
+
|
|
119
|
+
parse('port: 8080', {integers: 'number'});
|
|
120
|
+
//=> {port: 8080}
|
|
121
|
+
```
|
|
122
|
+
*/
|
|
68
123
|
export declare function parse(text: string | Uint8Array, options?: ParseOptions): Document | Document<number>;
|
|
124
|
+
export declare class Time {
|
|
125
|
+
readonly type: 'Instant' | 'Duration';
|
|
126
|
+
readonly nanoseconds: bigint;
|
|
127
|
+
constructor(type: 'Instant' | 'Duration', nanoseconds: bigint);
|
|
128
|
+
toTemporal(): Temporal.Instant | Temporal.Duration;
|
|
129
|
+
}
|
|
130
|
+
export declare function parseWithTimes(text: string): ParsedObject | ParsedValue[];
|
|
131
|
+
export {};
|