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/distribution/error.d.ts +32 -0
- package/distribution/error.js +139 -0
- package/distribution/format.d.ts +18 -0
- package/distribution/format.js +243 -0
- package/distribution/index.d.ts +5 -0
- package/distribution/index.js +5 -0
- package/distribution/parse.d.ts +68 -0
- package/distribution/parse.js +1171 -0
- package/distribution/shared.d.ts +48 -0
- package/distribution/shared.js +203 -0
- package/distribution/stringify.d.ts +35 -0
- package/distribution/stringify.js +359 -0
- package/distribution/tree.d.ts +153 -0
- package/distribution/tree.js +333 -0
- package/package.json +33 -34
- package/readme.md +90 -10
- package/index.d.ts +0 -151
- package/index.js +0 -3
- package/source/error.js +0 -130
- package/source/parse.js +0 -1574
- package/source/shared.js +0 -166
- package/source/stringify.js +0 -439
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Thrown by `parse()` when the input is not a valid document.
|
|
3
|
+
|
|
4
|
+
The `message` includes the position and a code frame. Use `reason` for the message alone.
|
|
5
|
+
*/
|
|
6
|
+
export declare class ParseError extends SyntaxError {
|
|
7
|
+
readonly name = "ParseError";
|
|
8
|
+
/**
|
|
9
|
+
What is wrong, without the position.
|
|
10
|
+
*/
|
|
11
|
+
readonly reason: string;
|
|
12
|
+
/**
|
|
13
|
+
The 1-based line of the error.
|
|
14
|
+
*/
|
|
15
|
+
readonly line: number;
|
|
16
|
+
/**
|
|
17
|
+
The 1-based column of the error, counted in Unicode code points.
|
|
18
|
+
*/
|
|
19
|
+
readonly column: number;
|
|
20
|
+
/**
|
|
21
|
+
The 0-based UTF-16 index of the error in the decoded text.
|
|
22
|
+
*/
|
|
23
|
+
readonly offset: number;
|
|
24
|
+
/**
|
|
25
|
+
Up to three lines ending at the error, with a caret under the position. A long line is clipped around the position.
|
|
26
|
+
*/
|
|
27
|
+
readonly codeFrame: string;
|
|
28
|
+
/**
|
|
29
|
+
Only `parse()` creates a `ParseError`.
|
|
30
|
+
*/
|
|
31
|
+
private constructor();
|
|
32
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { findLineEnd } from "./shared.js";
|
|
2
|
+
const CONTEXT_LINES = 2;
|
|
3
|
+
const MAX_LINE_WIDTH = 100;
|
|
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
|
+
Thrown by `parse()` when the input is not a valid document.
|
|
8
|
+
|
|
9
|
+
The `message` includes the position and a code frame. Use `reason` for the message alone.
|
|
10
|
+
*/
|
|
11
|
+
export class ParseError extends SyntaxError {
|
|
12
|
+
/**
|
|
13
|
+
For `parse()`, which cannot call the private constructor.
|
|
14
|
+
|
|
15
|
+
@internal
|
|
16
|
+
*/
|
|
17
|
+
static create(reason, source, offset) {
|
|
18
|
+
return new ParseError(reason, source, offset);
|
|
19
|
+
}
|
|
20
|
+
name = 'ParseError';
|
|
21
|
+
/**
|
|
22
|
+
What is wrong, without the position.
|
|
23
|
+
*/
|
|
24
|
+
reason;
|
|
25
|
+
/**
|
|
26
|
+
The 1-based line of the error.
|
|
27
|
+
*/
|
|
28
|
+
line;
|
|
29
|
+
/**
|
|
30
|
+
The 1-based column of the error, counted in Unicode code points.
|
|
31
|
+
*/
|
|
32
|
+
column;
|
|
33
|
+
/**
|
|
34
|
+
The 0-based UTF-16 index of the error in the decoded text.
|
|
35
|
+
*/
|
|
36
|
+
offset;
|
|
37
|
+
/**
|
|
38
|
+
Up to three lines ending at the error, with a caret under the position. A long line is clipped around the position.
|
|
39
|
+
*/
|
|
40
|
+
codeFrame;
|
|
41
|
+
/**
|
|
42
|
+
Only `parse()` creates a `ParseError`.
|
|
43
|
+
*/
|
|
44
|
+
constructor(reason, source, offset) {
|
|
45
|
+
const sanitizedReason = sanitize(reason);
|
|
46
|
+
const { line, column } = locate(source, offset);
|
|
47
|
+
const codeFrame = createCodeFrame(source, offset, line);
|
|
48
|
+
super(`${sanitizedReason} at line ${line}, column ${column}\n\n${codeFrame}`);
|
|
49
|
+
this.reason = sanitizedReason;
|
|
50
|
+
this.line = line;
|
|
51
|
+
this.column = column;
|
|
52
|
+
this.offset = offset;
|
|
53
|
+
this.codeFrame = codeFrame;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/*
|
|
57
|
+
Line and column are 1-based, and the column counts code points, so an emoji is one column.
|
|
58
|
+
*/
|
|
59
|
+
function locate(source, offset) {
|
|
60
|
+
let line = 1;
|
|
61
|
+
let lineStart = 0;
|
|
62
|
+
for (let index = source.indexOf('\n'); index !== -1 && index < offset; index = source.indexOf('\n', index + 1)) {
|
|
63
|
+
line++;
|
|
64
|
+
lineStart = index + 1;
|
|
65
|
+
}
|
|
66
|
+
return { line, column: countCodePoints(source.slice(lineStart, offset)) + 1 };
|
|
67
|
+
}
|
|
68
|
+
function countCodePoints(string) {
|
|
69
|
+
let count = 0;
|
|
70
|
+
for (let index = 0; index < string.length; index++) {
|
|
71
|
+
const code = string.charCodeAt(index);
|
|
72
|
+
// A high surrogate followed by a low one is one code point.
|
|
73
|
+
if (code >= 0xD8_00 && code <= 0xDB_FF) {
|
|
74
|
+
const next = string.charCodeAt(index + 1);
|
|
75
|
+
if (next >= 0xDC_00 && next <= 0xDF_FF) {
|
|
76
|
+
index++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
count++;
|
|
80
|
+
}
|
|
81
|
+
return count;
|
|
82
|
+
}
|
|
83
|
+
function createCodeFrame(source, offset, line) {
|
|
84
|
+
// 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.
|
|
85
|
+
const lineStarts = [offset === 0 ? 0 : source.lastIndexOf('\n', offset - 1) + 1];
|
|
86
|
+
while (lineStarts.length <= CONTEXT_LINES && lineStarts[0] > 0) {
|
|
87
|
+
const searchFrom = lineStarts[0] - 2;
|
|
88
|
+
lineStarts.unshift(searchFrom < 0 ? 0 : source.lastIndexOf('\n', searchFrom) + 1);
|
|
89
|
+
}
|
|
90
|
+
const firstLine = line - lineStarts.length + 1;
|
|
91
|
+
const gutterWidth = String(line).length;
|
|
92
|
+
const output = [];
|
|
93
|
+
for (const [index, lineStart] of lineStarts.entries()) {
|
|
94
|
+
const number = firstLine + index;
|
|
95
|
+
const isErrorLine = number === line;
|
|
96
|
+
const lineText = sanitize(source.slice(lineStart, findLineEnd(source, lineStart)));
|
|
97
|
+
const { text, pointerOffset } = clip(lineText, isErrorLine ? offset - lineStart : 0);
|
|
98
|
+
const gutter = `${isErrorLine ? '>' : ' '} ${String(number).padStart(gutterWidth)} |`;
|
|
99
|
+
output.push(text === '' ? gutter : `${gutter} ${text}`);
|
|
100
|
+
if (!isErrorLine) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Keep the tabs from the line, so the caret lines up whatever the tab width is.
|
|
104
|
+
const padding = text.slice(0, pointerOffset).replaceAll(/[^\t]/gv, ' ');
|
|
105
|
+
output.push(` ${' '.repeat(gutterWidth)} | ${padding}^`);
|
|
106
|
+
}
|
|
107
|
+
return output.join('\n');
|
|
108
|
+
}
|
|
109
|
+
/*
|
|
110
|
+
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.
|
|
111
|
+
*/
|
|
112
|
+
function sanitize(text) {
|
|
113
|
+
return text.replaceAll(UNPRINTABLE, '\u{FFFD}');
|
|
114
|
+
}
|
|
115
|
+
/*
|
|
116
|
+
A long line, such as a minified document, is cut to a window around the pointer. The cut never splits a surrogate pair.
|
|
117
|
+
*/
|
|
118
|
+
function clip(text, pointerOffset) {
|
|
119
|
+
if (text.length <= MAX_LINE_WIDTH) {
|
|
120
|
+
return { text, pointerOffset };
|
|
121
|
+
}
|
|
122
|
+
let start = Math.max(0, Math.min(pointerOffset - (MAX_LINE_WIDTH / 2), text.length - MAX_LINE_WIDTH));
|
|
123
|
+
let end = start + MAX_LINE_WIDTH;
|
|
124
|
+
if (isLowSurrogate(text.charCodeAt(start))) {
|
|
125
|
+
start++;
|
|
126
|
+
}
|
|
127
|
+
if (isLowSurrogate(text.charCodeAt(end))) {
|
|
128
|
+
end--;
|
|
129
|
+
}
|
|
130
|
+
const prefix = start > 0 ? '…' : '';
|
|
131
|
+
const suffix = end < text.length ? '…' : '';
|
|
132
|
+
return {
|
|
133
|
+
text: `${prefix}${text.slice(start, end)}${suffix}`,
|
|
134
|
+
pointerOffset: pointerOffset - start + prefix.length,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function isLowSurrogate(code) {
|
|
138
|
+
return code >= 0xDC_00 && code <= 0xDF_FF;
|
|
139
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
Format a document: normalize its layout, and keep everything the author chose about its content.
|
|
3
|
+
|
|
4
|
+
The layout follows the spec's formatter: one tab per level, every member and item on its own line, a trailing comma after every member and item inside braces and brackets, and no trailing whitespace or runs of blank lines. Comments, member order, dotted keys, and the spelling of every value stay as they are.
|
|
5
|
+
|
|
6
|
+
@param text - The document.
|
|
7
|
+
@returns The formatted document, ending with one line feed.
|
|
8
|
+
@throws {ParseError} When the document is not valid, the same as `parse()`.
|
|
9
|
+
|
|
10
|
+
@example
|
|
11
|
+
```
|
|
12
|
+
import {format} from 'soml-lang';
|
|
13
|
+
|
|
14
|
+
format('pool: {min: 2, max: 16} # Connections');
|
|
15
|
+
//=> 'pool: {\n\tmin: 2,\n\tmax: 16,\n} # Connections\n'
|
|
16
|
+
```
|
|
17
|
+
*/
|
|
18
|
+
export declare function format(text: string): string;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { parseTree, } from "./tree.js";
|
|
2
|
+
const INDENT = '\t';
|
|
3
|
+
const BLANK = /^[\t ]*$/v;
|
|
4
|
+
/**
|
|
5
|
+
Format a document: normalize its layout, and keep everything the author chose about its content.
|
|
6
|
+
|
|
7
|
+
The layout follows the spec's formatter: one tab per level, every member and item on its own line, a trailing comma after every member and item inside braces and brackets, and no trailing whitespace or runs of blank lines. Comments, member order, dotted keys, and the spelling of every value stay as they are.
|
|
8
|
+
|
|
9
|
+
@param text - The document.
|
|
10
|
+
@returns The formatted document, ending with one line feed.
|
|
11
|
+
@throws {ParseError} When the document is not valid, the same as `parse()`.
|
|
12
|
+
|
|
13
|
+
@example
|
|
14
|
+
```
|
|
15
|
+
import {format} from 'soml-lang';
|
|
16
|
+
|
|
17
|
+
format('pool: {min: 2, max: 16} # Connections');
|
|
18
|
+
//=> 'pool: {\n\tmin: 2,\n\tmax: 16,\n} # Connections\n'
|
|
19
|
+
```
|
|
20
|
+
*/
|
|
21
|
+
export function format(text) {
|
|
22
|
+
return new Printer(text, parseTree(text)).print();
|
|
23
|
+
}
|
|
24
|
+
class Printer {
|
|
25
|
+
#text;
|
|
26
|
+
#tree;
|
|
27
|
+
#output = '';
|
|
28
|
+
#commentIndex = 0;
|
|
29
|
+
constructor(text, tree) {
|
|
30
|
+
this.#text = text;
|
|
31
|
+
this.#tree = tree;
|
|
32
|
+
}
|
|
33
|
+
#write(text) {
|
|
34
|
+
this.#output += text;
|
|
35
|
+
}
|
|
36
|
+
/*
|
|
37
|
+
Starts a new line at `level`, with one blank line before it when the source had one.
|
|
38
|
+
*/
|
|
39
|
+
#newLine(level, isBlank) {
|
|
40
|
+
if (this.#output === '') {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.#write(`${isBlank ? '\n\n' : '\n'}${INDENT.repeat(level)}`);
|
|
44
|
+
}
|
|
45
|
+
#isSameLine(start, end) {
|
|
46
|
+
return !this.#text.slice(start, end).includes('\n');
|
|
47
|
+
}
|
|
48
|
+
#hasBlankLine(start, end) {
|
|
49
|
+
return /\n[\t ]*\n/v.test(this.#text.slice(start, end));
|
|
50
|
+
}
|
|
51
|
+
/*
|
|
52
|
+
The next comment that ends at or before `end`, without consuming it.
|
|
53
|
+
*/
|
|
54
|
+
#peekComment(end) {
|
|
55
|
+
const comment = this.#tree.comments[this.#commentIndex];
|
|
56
|
+
return comment !== undefined && comment.range[1] <= end ? comment : undefined;
|
|
57
|
+
}
|
|
58
|
+
#writeComment(comment) {
|
|
59
|
+
this.#commentIndex++;
|
|
60
|
+
if (comment.type === 'Line') {
|
|
61
|
+
this.#write(trimTrailingWhitespace(`#${comment.value}`));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// The lines of a block comment keep their own indentation, apart from trailing whitespace.
|
|
65
|
+
this.#write(`/*${comment.value}*/`.split('\n').map(line => trimTrailingWhitespace(line)).join('\n'));
|
|
66
|
+
}
|
|
67
|
+
/*
|
|
68
|
+
The offset of the `,` after an item in the source, or `undefined` when there is none.
|
|
69
|
+
*/
|
|
70
|
+
#findComma(start, end) {
|
|
71
|
+
const index = this.#text.slice(start, end).replaceAll(/\/\*.*?\*\/|#[^\n]*/gsv, comment => ' '.repeat(comment.length)).indexOf(',');
|
|
72
|
+
return index === -1 ? undefined : start + index;
|
|
73
|
+
}
|
|
74
|
+
/*
|
|
75
|
+
Writes `items` one per line at `level`, with the comments between them, from `start` to `end` in the source.
|
|
76
|
+
|
|
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 gets its own line.
|
|
78
|
+
*/
|
|
79
|
+
#list(items, { start, end, level, hasCommas }) {
|
|
80
|
+
let previousEnd = start;
|
|
81
|
+
let itemEnd;
|
|
82
|
+
// 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 = hasCommas;
|
|
84
|
+
let hasWritten = false;
|
|
85
|
+
let isOnSameLine = false;
|
|
86
|
+
for (const item of [...items, undefined]) {
|
|
87
|
+
const itemStart = item?.range[0] ?? end;
|
|
88
|
+
const comma = hasCommas ? this.#findComma(previousEnd, itemStart) : undefined;
|
|
89
|
+
for (let comment = this.#peekComment(itemStart); comment !== undefined; comment = this.#peekComment(itemStart)) {
|
|
90
|
+
const [commentStart, commentEnd] = comment.range;
|
|
91
|
+
const isTrailing = canTrail && !isOnSameLine && this.#isTrailing(comment, {
|
|
92
|
+
previousEnd,
|
|
93
|
+
itemEnd,
|
|
94
|
+
itemStart: item?.range[0],
|
|
95
|
+
comma,
|
|
96
|
+
});
|
|
97
|
+
if (isTrailing || isOnSameLine) {
|
|
98
|
+
this.#write(' ');
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
this.#newLine(level, hasWritten && this.#hasBlankLine(previousEnd, commentStart));
|
|
102
|
+
}
|
|
103
|
+
this.#writeComment(comment);
|
|
104
|
+
hasWritten ||= !isTrailing;
|
|
105
|
+
// A block comment that the next comment or item follows on the same line keeps it on that line.
|
|
106
|
+
const nextStart = this.#peekComment(itemStart)?.range[0] ?? itemStart;
|
|
107
|
+
isOnSameLine = comment.type === 'Block' && !isTrailing && (item !== undefined || nextStart < itemStart) && this.#isSameLine(commentEnd, nextStart);
|
|
108
|
+
previousEnd = commentEnd;
|
|
109
|
+
}
|
|
110
|
+
if (item === undefined) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (isOnSameLine) {
|
|
114
|
+
this.#write(' ');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
this.#newLine(level, hasWritten && this.#hasBlankLine(previousEnd, itemStart));
|
|
118
|
+
}
|
|
119
|
+
this.#item(item, level, hasCommas);
|
|
120
|
+
previousEnd = item.range[1];
|
|
121
|
+
itemEnd = previousEnd;
|
|
122
|
+
canTrail = true;
|
|
123
|
+
hasWritten = true;
|
|
124
|
+
isOnSameLine = false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/*
|
|
128
|
+
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
|
+
*/
|
|
130
|
+
#isTrailing(comment, { previousEnd, itemEnd, itemStart, comma }) {
|
|
131
|
+
const [commentStart, commentEnd] = comment.range;
|
|
132
|
+
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 (itemStart !== undefined && comment.type === 'Block' && (comma === undefined || isAfterComma) && this.#isSameLine(commentEnd, itemStart)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
// A comment after the comma ends the comma's line, which may be later than the item's. The comma goes after the item, so that only holds when nothing came between them.
|
|
138
|
+
const lineEnd = isAfterComma && previousEnd === itemEnd ? comma + 1 : previousEnd;
|
|
139
|
+
return this.#isSameLine(lineEnd, commentStart);
|
|
140
|
+
}
|
|
141
|
+
#item(item, level, hasComma) {
|
|
142
|
+
if (item.type === 'Member') {
|
|
143
|
+
this.#member(item, level);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
this.#value(item, level);
|
|
147
|
+
}
|
|
148
|
+
if (hasComma) {
|
|
149
|
+
this.#write(',');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
#member(member, level) {
|
|
153
|
+
const { key, value } = member;
|
|
154
|
+
this.#write(`${this.#text.slice(...key.range)}:`);
|
|
155
|
+
if (this.#peekComment(value.range[0]) === undefined) {
|
|
156
|
+
this.#write(' ');
|
|
157
|
+
this.#value(value, level);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
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.
|
|
161
|
+
let previousEnd = key.range[1] + 1;
|
|
162
|
+
let valueLevel = level;
|
|
163
|
+
for (let comment = this.#peekComment(value.range[0]); comment !== undefined; comment = this.#peekComment(value.range[0])) {
|
|
164
|
+
if (!this.#isSameLine(previousEnd, comment.range[0])) {
|
|
165
|
+
valueLevel = level + 1;
|
|
166
|
+
}
|
|
167
|
+
this.#writeSeparator(previousEnd, comment.range[0], level + 1);
|
|
168
|
+
this.#writeComment(comment);
|
|
169
|
+
previousEnd = comment.range[1];
|
|
170
|
+
}
|
|
171
|
+
if (!this.#isSameLine(previousEnd, value.range[0])) {
|
|
172
|
+
valueLevel = level + 1;
|
|
173
|
+
}
|
|
174
|
+
this.#writeSeparator(previousEnd, value.range[0], level + 1);
|
|
175
|
+
this.#value(value, valueLevel);
|
|
176
|
+
}
|
|
177
|
+
/*
|
|
178
|
+
A space when `end` is on the line of `start` in the source, and otherwise a new line at `level`.
|
|
179
|
+
*/
|
|
180
|
+
#writeSeparator(start, end, level) {
|
|
181
|
+
if (this.#isSameLine(start, end)) {
|
|
182
|
+
this.#write(' ');
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
this.#newLine(level, false);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
#value(node, level) {
|
|
189
|
+
const [start, end] = node.range;
|
|
190
|
+
if (node.type === 'Object' || node.type === 'Array') {
|
|
191
|
+
const [opening, closing] = node.type === 'Object' ? ['{', '}'] : ['[', ']'];
|
|
192
|
+
const items = node.type === 'Object' ? node.members : node.elements;
|
|
193
|
+
if (items.length === 0 && this.#peekComment(end) === undefined) {
|
|
194
|
+
this.#write(opening + closing);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.#write(opening);
|
|
198
|
+
this.#list(items, {
|
|
199
|
+
start: start + 1,
|
|
200
|
+
end: end - 1,
|
|
201
|
+
level: level + 1,
|
|
202
|
+
hasCommas: true,
|
|
203
|
+
});
|
|
204
|
+
this.#newLine(level, false);
|
|
205
|
+
this.#write(closing);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const text = this.#text.slice(start, end);
|
|
209
|
+
this.#write(node.type === 'String' && node.block ? reindentBlockString(text, INDENT.repeat(level + 1)) : text);
|
|
210
|
+
}
|
|
211
|
+
print() {
|
|
212
|
+
const { body } = this.#tree;
|
|
213
|
+
const items = body.type === 'Object' && !body.braced ? body.members : [body];
|
|
214
|
+
// The document is a list without commas: the members of a brace-less object, or one braced collection.
|
|
215
|
+
this.#list(items, {
|
|
216
|
+
start: 0,
|
|
217
|
+
end: this.#text.length,
|
|
218
|
+
level: 0,
|
|
219
|
+
hasCommas: false,
|
|
220
|
+
});
|
|
221
|
+
return `${this.#output}\n`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/*
|
|
225
|
+
Only spaces and tabs are whitespace in SOML. Any other character, such as a no-break space, is content.
|
|
226
|
+
*/
|
|
227
|
+
function trimTrailingWhitespace(text) {
|
|
228
|
+
// A loop rather than a regular expression, which would take quadratic time on a long run of spaces inside a line.
|
|
229
|
+
let end = text.length;
|
|
230
|
+
while (end > 0 && (text[end - 1] === ' ' || text[end - 1] === '\t')) {
|
|
231
|
+
end--;
|
|
232
|
+
}
|
|
233
|
+
return text.slice(0, end);
|
|
234
|
+
}
|
|
235
|
+
/*
|
|
236
|
+
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.
|
|
237
|
+
*/
|
|
238
|
+
function reindentBlockString(text, indentation) {
|
|
239
|
+
const lines = text.split('\n');
|
|
240
|
+
const closing = lines.at(-1);
|
|
241
|
+
const oldIndentation = closing.slice(0, closing.length - closing.trimStart().length);
|
|
242
|
+
return lines.map((line, index) => index === 0 || (index < lines.length - 1 && BLANK.test(line)) ? line : indentation + line.slice(oldIndentation.length)).join('\n');
|
|
243
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
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 KeySegmentNode, type StringNode, type IntegerNode, type FloatNode, type BooleanNode, type NullNode, type InstantNode, type DurationNode, type ValueNode, type Node, } from './tree.ts';
|
|
3
|
+
export { format } from './format.ts';
|
|
4
|
+
export { stringify, type StringifyOptions } from './stringify.ts';
|
|
5
|
+
export { ParseError } from './error.ts';
|
|
@@ -0,0 +1,68 @@
|
|
|
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> = string | Integer | number | boolean | null | Temporal.Instant | Temporal.Duration | Value<Integer>[] | ObjectValue<Integer>;
|
|
17
|
+
/**
|
|
18
|
+
An object in a document.
|
|
19
|
+
*/
|
|
20
|
+
export type ObjectValue<Integer extends bigint | number = bigint> = {
|
|
21
|
+
[key: string]: Value<Integer>;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
A whole document, which is always an object or an array.
|
|
25
|
+
*/
|
|
26
|
+
export type Document<Integer extends bigint | number = bigint> = ObjectValue<Integer> | Array<Value<Integer>>;
|
|
27
|
+
export type ParseOptions = {
|
|
28
|
+
/**
|
|
29
|
+
How an int is represented.
|
|
30
|
+
|
|
31
|
+
- `'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.
|
|
32
|
+
- `'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.
|
|
33
|
+
|
|
34
|
+
@default 'bigint'
|
|
35
|
+
*/
|
|
36
|
+
readonly integers?: 'bigint' | 'number';
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
Parse a document.
|
|
40
|
+
|
|
41
|
+
@param text - The document, as a string or as UTF-8 bytes.
|
|
42
|
+
@returns The object or array the document contains.
|
|
43
|
+
@throws {ParseError} When the document is not valid.
|
|
44
|
+
@throws {TypeError} When `text` is not a string or a `Uint8Array`, or `options.integers` is not `'bigint'` or `'number'`.
|
|
45
|
+
|
|
46
|
+
@example
|
|
47
|
+
```
|
|
48
|
+
import {parse} from 'soml-lang';
|
|
49
|
+
|
|
50
|
+
parse(`
|
|
51
|
+
name: 'api-gateway'
|
|
52
|
+
replicas: 3
|
|
53
|
+
timeout: 30.0
|
|
54
|
+
postgres.host: 'db.internal'
|
|
55
|
+
`);
|
|
56
|
+
//=> {name: 'api-gateway', replicas: 3n, timeout: 30, postgres: {host: 'db.internal'}}
|
|
57
|
+
|
|
58
|
+
parse('replicas: 3', {integers: 'number'});
|
|
59
|
+
//=> {replicas: 3}
|
|
60
|
+
```
|
|
61
|
+
*/
|
|
62
|
+
export declare function parse(text: string | Uint8Array, options?: ParseOptions & {
|
|
63
|
+
readonly integers?: 'bigint';
|
|
64
|
+
}): Document;
|
|
65
|
+
export declare function parse(text: string | Uint8Array, options: ParseOptions & {
|
|
66
|
+
readonly integers: 'number';
|
|
67
|
+
}): Document<number>;
|
|
68
|
+
export declare function parse(text: string | Uint8Array, options?: ParseOptions): Document | Document<number>;
|