links-notation 0.27.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/README.ru.md +38 -0
- package/dist/index.js +303 -95
- package/index.d.ts +84 -0
- package/package.json +1 -1
- package/src/Cst.js +263 -0
- package/src/Link.js +41 -1
- package/src/Parser.js +49 -10
- package/src/grammar.pegjs +15 -4
- package/src/index.js +6 -1
- package/src/parser-generated.js +73 -53
- package/src/references.js +18 -0
- package/tests/Cst.test.js +109 -0
- package/tests/SharedSyntaxParity.test.js +47 -0
- package/tests/SyntaxMetadata.test.js +102 -0
package/index.d.ts
CHANGED
|
@@ -24,6 +24,22 @@ export class Link {
|
|
|
24
24
|
*/
|
|
25
25
|
values: Link[];
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The ID exactly as the parsed document wrote it, quotes, escapes and
|
|
29
|
+
* literal syntax included: `'1'` for a reference written `'1'`, `1` for one
|
|
30
|
+
* written `1`. Null for a link without an ID, a link built in code, a link
|
|
31
|
+
* made by indentation path combination, or a parser in AST mode
|
|
32
|
+
* (`syntax: false`). Equality, formatting and serialization ignore it.
|
|
33
|
+
* @throws {TypeError} When set to anything but a string or null
|
|
34
|
+
*/
|
|
35
|
+
rawId: string | null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The delimiter the ID was written between (`'`, `"` or `` ` ``), read from
|
|
39
|
+
* `rawId`; null for a bare or literal reference, or no `rawId`.
|
|
40
|
+
*/
|
|
41
|
+
readonly quote: "'" | '"' | '`' | null;
|
|
42
|
+
|
|
27
43
|
/**
|
|
28
44
|
* Create a new Link
|
|
29
45
|
* @param id - Optional identifier for the link
|
|
@@ -123,6 +139,56 @@ export function escapeReference(
|
|
|
123
139
|
/** Decode a complete escaped reference, including n-quotes and versioned literals. */
|
|
124
140
|
export function unescapeReference(reference: string): string;
|
|
125
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The delimiter one reference was written between, read from the reference as
|
|
144
|
+
* written: `'`, `"` or `` ` `` for a complete quoted reference, otherwise null.
|
|
145
|
+
*/
|
|
146
|
+
export function referenceQuote(
|
|
147
|
+
raw: string | null | undefined
|
|
148
|
+
): "'" | '"' | '`' | null;
|
|
149
|
+
|
|
150
|
+
/** The kinds of nodes and tokens of a concrete syntax tree. */
|
|
151
|
+
export const CstKind: Readonly<{
|
|
152
|
+
DOCUMENT: 'document';
|
|
153
|
+
LINE: 'line';
|
|
154
|
+
GROUP: 'group';
|
|
155
|
+
WHITESPACE: 'whitespace';
|
|
156
|
+
NEWLINE: 'newline';
|
|
157
|
+
COMMENT: 'comment';
|
|
158
|
+
OPEN_PAREN: 'open_paren';
|
|
159
|
+
CLOSE_PAREN: 'close_paren';
|
|
160
|
+
COLON: 'colon';
|
|
161
|
+
REFERENCE: 'reference';
|
|
162
|
+
}>;
|
|
163
|
+
|
|
164
|
+
export type CstKindName = (typeof CstKind)[keyof typeof CstKind];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One node or token of a lossless concrete syntax tree: every character of
|
|
168
|
+
* the document belongs to exactly one token, so the document is the text of
|
|
169
|
+
* the tree.
|
|
170
|
+
*/
|
|
171
|
+
export class CstNode {
|
|
172
|
+
/** The kind of this node or token. */
|
|
173
|
+
kind: CstKindName;
|
|
174
|
+
/** The children of a `document`, `line` or `group` node; empty for a token. */
|
|
175
|
+
children: CstNode[];
|
|
176
|
+
/** What a reference token means; null for every other kind. */
|
|
177
|
+
value: string | null;
|
|
178
|
+
/** The delimiter of a quoted reference token; null otherwise. */
|
|
179
|
+
quote: "'" | '"' | '`' | null;
|
|
180
|
+
/** Whether this is a token rather than a node. */
|
|
181
|
+
readonly isToken: boolean;
|
|
182
|
+
/** The exact text this node or token was read from. */
|
|
183
|
+
readonly text: string;
|
|
184
|
+
/** The exact text this node or token was read from. */
|
|
185
|
+
toString(): string;
|
|
186
|
+
/** Every token of this subtree, in document order. */
|
|
187
|
+
tokens(): CstNode[];
|
|
188
|
+
/** The tree as an S-expression: `(kind child ...)` and `kind:"text"`. */
|
|
189
|
+
toSExpression(): string;
|
|
190
|
+
}
|
|
191
|
+
|
|
126
192
|
/**
|
|
127
193
|
* Parser options for configuring the parser behavior
|
|
128
194
|
*/
|
|
@@ -150,6 +216,12 @@ export interface ParserOptions {
|
|
|
150
216
|
* comment (default: true)
|
|
151
217
|
*/
|
|
152
218
|
comments?: boolean;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* If false (AST mode), parsed links do not keep how their IDs were written:
|
|
222
|
+
* `rawId` and `quote` stay null (default: true)
|
|
223
|
+
*/
|
|
224
|
+
syntax?: boolean;
|
|
153
225
|
}
|
|
154
226
|
|
|
155
227
|
/**
|
|
@@ -173,6 +245,11 @@ export class Parser {
|
|
|
173
245
|
*/
|
|
174
246
|
comments: boolean;
|
|
175
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Whether parsed links keep how their IDs were written (`rawId`)
|
|
250
|
+
*/
|
|
251
|
+
syntax: boolean;
|
|
252
|
+
|
|
176
253
|
/**
|
|
177
254
|
* Create a new Parser instance
|
|
178
255
|
* @param options - Parser options
|
|
@@ -189,6 +266,13 @@ export class Parser {
|
|
|
189
266
|
|
|
190
267
|
/** Read the indentation tree without expanding it into path combinations. */
|
|
191
268
|
parseGroups(input: string): IndentedGroup[];
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Read the document as a lossless concrete syntax tree, so that
|
|
272
|
+
* `parseCst(input).toString() === input`.
|
|
273
|
+
* @throws {ParseError} When `parse(input)` would throw
|
|
274
|
+
*/
|
|
275
|
+
parseCst(input: string): CstNode;
|
|
192
276
|
}
|
|
193
277
|
|
|
194
278
|
/** A parsed line and the immediately indented lines below it. */
|
package/package.json
CHANGED
package/src/Cst.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The concrete syntax tree of a Links Notation document.
|
|
3
|
+
*
|
|
4
|
+
* Every character of a document belongs to exactly one token, so the text of
|
|
5
|
+
* the tree is the document itself, comments, blank lines, indentation, quotes
|
|
6
|
+
* and line endings included. A document is a list of `line` nodes; a line holds
|
|
7
|
+
* the tokens up to and including its line ending, and a parenthesized `group`
|
|
8
|
+
* node holds its own parentheses and the lines written between them. A group is
|
|
9
|
+
* part of the line it opens on, however many lines it spans.
|
|
10
|
+
*
|
|
11
|
+
* The tree records how a document was written; `Parser.parse()` reads what it
|
|
12
|
+
* means. They agree because a document is parsed before its tree is built, and
|
|
13
|
+
* the tokens are read with the same rules the grammar uses: comments are found
|
|
14
|
+
* by {@link stripComments}, delimited references by {@link DelimitedReferences}
|
|
15
|
+
* and versioned literals by the same pattern.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { stripComments } from './comments.js';
|
|
19
|
+
import { DelimitedReferences, QUOTES } from './quotes.js';
|
|
20
|
+
import { decodeReferenceLiteral } from './ReferenceLiteral.js';
|
|
21
|
+
|
|
22
|
+
/** The kinds of nodes and tokens of a concrete syntax tree. */
|
|
23
|
+
export const CstKind = Object.freeze({
|
|
24
|
+
/** The whole document: its lines. */
|
|
25
|
+
DOCUMENT: 'document',
|
|
26
|
+
/** One line: its tokens and groups, up to and including its line ending. */
|
|
27
|
+
LINE: 'line',
|
|
28
|
+
/** A parenthesized group: `(`, the lines inside it, and `)`. */
|
|
29
|
+
GROUP: 'group',
|
|
30
|
+
/** A run of spaces and tabs. */
|
|
31
|
+
WHITESPACE: 'whitespace',
|
|
32
|
+
/** One line ending: `\n`, `\r\n` or `\r`. */
|
|
33
|
+
NEWLINE: 'newline',
|
|
34
|
+
/** A comment, from `#` up to the line ending. */
|
|
35
|
+
COMMENT: 'comment',
|
|
36
|
+
/** `(` */
|
|
37
|
+
OPEN_PAREN: 'open_paren',
|
|
38
|
+
/** `)` */
|
|
39
|
+
CLOSE_PAREN: 'close_paren',
|
|
40
|
+
/** `:` */
|
|
41
|
+
COLON: 'colon',
|
|
42
|
+
/** A bare, quoted or versioned literal reference. */
|
|
43
|
+
REFERENCE: 'reference',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const NODE_KINDS = new Set([CstKind.DOCUMENT, CstKind.LINE, CstKind.GROUP]);
|
|
47
|
+
|
|
48
|
+
/** One node or token of a concrete syntax tree. */
|
|
49
|
+
export class CstNode {
|
|
50
|
+
#text;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} kind - One of {@link CstKind}
|
|
54
|
+
* @param {Object} parts - The token's text, or the node's children
|
|
55
|
+
* @param {string} [parts.text] - A token's text exactly as written
|
|
56
|
+
* @param {CstNode[]} [parts.children] - A node's children
|
|
57
|
+
* @param {string|null} [parts.value] - What a reference token means
|
|
58
|
+
* @param {string|null} [parts.quote] - The delimiter of a quoted reference
|
|
59
|
+
*/
|
|
60
|
+
constructor(
|
|
61
|
+
kind,
|
|
62
|
+
{ text = '', children = [], value = null, quote = null } = {}
|
|
63
|
+
) {
|
|
64
|
+
/** One of {@link CstKind}. */
|
|
65
|
+
this.kind = kind;
|
|
66
|
+
/** The children of a node, in document order; empty for a token. */
|
|
67
|
+
this.children = children;
|
|
68
|
+
/**
|
|
69
|
+
* What a reference token means, its delimiters and escapes resolved (the
|
|
70
|
+
* ID a parsed link has); null for every other node and token.
|
|
71
|
+
*/
|
|
72
|
+
this.value = value;
|
|
73
|
+
/**
|
|
74
|
+
* The delimiter a quoted reference token was written between (`'`, `"` or
|
|
75
|
+
* `` ` ``); null for a bare or literal reference and every other kind.
|
|
76
|
+
*/
|
|
77
|
+
this.quote = quote;
|
|
78
|
+
this.#text = text;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether this is a token rather than a node with children. */
|
|
82
|
+
get isToken() {
|
|
83
|
+
return !NODE_KINDS.has(this.kind);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The exact text this node or token was read from. */
|
|
87
|
+
get text() {
|
|
88
|
+
if (this.isToken) return this.#text;
|
|
89
|
+
return this.children.map((child) => child.text).join('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The exact text this node or token was read from. */
|
|
93
|
+
toString() {
|
|
94
|
+
return this.text;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every token of this subtree, in document order.
|
|
99
|
+
* @returns {CstNode[]}
|
|
100
|
+
*/
|
|
101
|
+
tokens() {
|
|
102
|
+
if (this.isToken) return [this];
|
|
103
|
+
return this.children.flatMap((child) => child.tokens());
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The tree as an S-expression: `(kind child ...)` for a node and
|
|
108
|
+
* `kind:"text"` for a token, the text escaped as a JSON string would escape
|
|
109
|
+
* `\`, `"`, line feeds, carriage returns and tabs. Two trees are the same
|
|
110
|
+
* exactly when their S-expressions are.
|
|
111
|
+
* @returns {string}
|
|
112
|
+
* @example
|
|
113
|
+
* new Parser().parseCst("a '1'").toSExpression();
|
|
114
|
+
* // (document (line reference:"a" whitespace:" " reference:"'1'"))
|
|
115
|
+
*/
|
|
116
|
+
toSExpression() {
|
|
117
|
+
if (this.isToken) return `${this.kind}:${quoteText(this.#text)}`;
|
|
118
|
+
return `(${[this.kind, ...this.children.map((child) => child.toSExpression())].join(' ')})`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ESCAPES = {
|
|
123
|
+
'\\': '\\\\',
|
|
124
|
+
'"': '\\"',
|
|
125
|
+
'\n': '\\n',
|
|
126
|
+
'\r': '\\r',
|
|
127
|
+
'\t': '\\t',
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function quoteText(text) {
|
|
131
|
+
return `"${text.replace(/[\\"\n\r\t]/g, (character) => ESCAPES[character])}"`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What a versioned literal's payload cannot contain. */
|
|
135
|
+
const LITERAL_PAYLOAD_END = new Set(['}', ' ', '\t', '\r', '\n', '(', ')']);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Where the versioned literal `~<digits>{<payload>}` that starts at `start`
|
|
139
|
+
* ends, or -1 if none starts there. A single forward scan, so it stays linear
|
|
140
|
+
* on any input (a regular expression here was flagged as a ReDoS risk).
|
|
141
|
+
*/
|
|
142
|
+
function literalEnd(text, start) {
|
|
143
|
+
if (text[start] !== '~') return -1;
|
|
144
|
+
let index = start + 1;
|
|
145
|
+
while (index < text.length && text[index] >= '0' && text[index] <= '9') {
|
|
146
|
+
index++;
|
|
147
|
+
}
|
|
148
|
+
if (index === start + 1 || text[index] !== '{') return -1;
|
|
149
|
+
index++;
|
|
150
|
+
while (index < text.length && !LITERAL_PAYLOAD_END.has(text[index])) {
|
|
151
|
+
index++;
|
|
152
|
+
}
|
|
153
|
+
return text[index] === '}' ? index + 1 : -1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** What ends a bare reference. */
|
|
157
|
+
const BARE_REFERENCE_END = new Set([' ', '\t', '\r', '\n', '(', ':', ')']);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The concrete syntax tree of a document that has already been parsed.
|
|
161
|
+
* @param {string} input - The document as written
|
|
162
|
+
* @param {Object} options
|
|
163
|
+
* @param {boolean} [options.comments=true] - Whether `#` starts a comment
|
|
164
|
+
* @returns {CstNode} The `document` node
|
|
165
|
+
*/
|
|
166
|
+
export function buildCst(input, { comments = true } = {}) {
|
|
167
|
+
// References are read in the document the grammar reads: the one with its
|
|
168
|
+
// comments blanked, which keeps every position of the one written.
|
|
169
|
+
const prepared = comments ? stripComments(input) : input;
|
|
170
|
+
const references = new DelimitedReferences(prepared);
|
|
171
|
+
const document = new CstNode(CstKind.DOCUMENT);
|
|
172
|
+
// The open containers, innermost last, each with the line being filled.
|
|
173
|
+
const containers = [{ node: document, line: null }];
|
|
174
|
+
|
|
175
|
+
const add = (token) => {
|
|
176
|
+
const container = containers[containers.length - 1];
|
|
177
|
+
if (container.line === null) {
|
|
178
|
+
container.line = new CstNode(CstKind.LINE);
|
|
179
|
+
container.node.children.push(container.line);
|
|
180
|
+
}
|
|
181
|
+
container.line.children.push(token);
|
|
182
|
+
};
|
|
183
|
+
const token = (kind, start, end, parts = {}) =>
|
|
184
|
+
new CstNode(kind, { text: input.slice(start, end), ...parts });
|
|
185
|
+
|
|
186
|
+
let position = 0;
|
|
187
|
+
while (position < input.length) {
|
|
188
|
+
const character = input[position];
|
|
189
|
+
let end = position + 1;
|
|
190
|
+
|
|
191
|
+
if (comments && character === '#' && prepared[position] !== '#') {
|
|
192
|
+
// A blanked `#` opens a comment, which runs to the line ending.
|
|
193
|
+
while (end < input.length && input[end] !== '\n' && input[end] !== '\r') {
|
|
194
|
+
end++;
|
|
195
|
+
}
|
|
196
|
+
add(token(CstKind.COMMENT, position, end));
|
|
197
|
+
} else if (character === '\r' || character === '\n') {
|
|
198
|
+
if (character === '\r' && input[end] === '\n') end++;
|
|
199
|
+
add(token(CstKind.NEWLINE, position, end));
|
|
200
|
+
containers[containers.length - 1].line = null;
|
|
201
|
+
} else if (character === ' ' || character === '\t') {
|
|
202
|
+
while (
|
|
203
|
+
end < input.length &&
|
|
204
|
+
(input[end] === ' ' || input[end] === '\t')
|
|
205
|
+
) {
|
|
206
|
+
end++;
|
|
207
|
+
}
|
|
208
|
+
add(token(CstKind.WHITESPACE, position, end));
|
|
209
|
+
} else if (character === '(') {
|
|
210
|
+
const group = new CstNode(CstKind.GROUP, {
|
|
211
|
+
children: [token(CstKind.OPEN_PAREN, position, end)],
|
|
212
|
+
});
|
|
213
|
+
add(group);
|
|
214
|
+
containers.push({ node: group, line: null });
|
|
215
|
+
} else if (character === ')') {
|
|
216
|
+
if (containers.length === 1) {
|
|
217
|
+
throw new SyntaxError(`Unbalanced ")" at offset ${position}`);
|
|
218
|
+
}
|
|
219
|
+
containers
|
|
220
|
+
.pop()
|
|
221
|
+
.node.children.push(token(CstKind.CLOSE_PAREN, position, end));
|
|
222
|
+
} else if (character === ':') {
|
|
223
|
+
add(token(CstKind.COLON, position, end));
|
|
224
|
+
} else {
|
|
225
|
+
end = referenceEnd(prepared, position, references);
|
|
226
|
+
const text = input.slice(position, end);
|
|
227
|
+
const quoted = QUOTES.includes(character) && references.readAt(position);
|
|
228
|
+
add(
|
|
229
|
+
token(CstKind.REFERENCE, position, end, {
|
|
230
|
+
value: quoted
|
|
231
|
+
? quoted.value
|
|
232
|
+
: character === '~' && isLiteral(prepared, position, end)
|
|
233
|
+
? decodeReferenceLiteral(text)
|
|
234
|
+
: text,
|
|
235
|
+
quote: quoted ? character : null,
|
|
236
|
+
})
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
position = end;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (containers.length !== 1) {
|
|
243
|
+
throw new SyntaxError('Unbalanced "(" at the end of the document');
|
|
244
|
+
}
|
|
245
|
+
return document;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Where the reference that starts at `start` ends, read as the grammar reads it. */
|
|
249
|
+
function referenceEnd(prepared, start, references) {
|
|
250
|
+
const literal = literalEnd(prepared, start);
|
|
251
|
+
if (literal !== -1) return literal;
|
|
252
|
+
if (QUOTES.includes(prepared[start])) {
|
|
253
|
+
const end = references.endAt(start);
|
|
254
|
+
if (end !== null) return end;
|
|
255
|
+
}
|
|
256
|
+
let end = start + 1;
|
|
257
|
+
while (end < prepared.length && !BARE_REFERENCE_END.has(prepared[end])) end++;
|
|
258
|
+
return end;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function isLiteral(prepared, start, end) {
|
|
262
|
+
return literalEnd(prepared, start) === end;
|
|
263
|
+
}
|
package/src/Link.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
escapeReference,
|
|
3
|
+
referenceQuote,
|
|
4
|
+
unescapeReference,
|
|
5
|
+
} from './references.js';
|
|
2
6
|
|
|
3
7
|
export class Link {
|
|
8
|
+
/**
|
|
9
|
+
* The ID exactly as the parsed document wrote it, kept out of the link's own
|
|
10
|
+
* enumerable properties so that comparing or serializing links is unchanged.
|
|
11
|
+
*/
|
|
12
|
+
#rawId = null;
|
|
13
|
+
|
|
4
14
|
/**
|
|
5
15
|
* Create a new Link
|
|
6
16
|
* @param {string|null} id - Optional identifier for the link
|
|
@@ -21,6 +31,36 @@ export class Link {
|
|
|
21
31
|
}
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The ID exactly as the parsed document wrote it: `'1'` for a quoted `1`,
|
|
36
|
+
* `1` for a bare one, `~1{31}` for a versioned literal. Null for a link
|
|
37
|
+
* without an ID, a link built in code, a link made by combining the lines of
|
|
38
|
+
* an indented document, and every link read by a parser created with
|
|
39
|
+
* `syntax: false`. It takes no part in `equals()` or `format()`.
|
|
40
|
+
* @type {string|null}
|
|
41
|
+
*/
|
|
42
|
+
get rawId() {
|
|
43
|
+
return this.#rawId;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
set rawId(raw) {
|
|
47
|
+
if (raw !== null && raw !== undefined && typeof raw !== 'string') {
|
|
48
|
+
throw new TypeError('rawId must be a string or null');
|
|
49
|
+
}
|
|
50
|
+
this.#rawId = raw ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The delimiter the ID was written between: `'`, `"` or `` ` `` when it was
|
|
55
|
+
* quoted, and null when it was bare, a versioned literal, or not read from a
|
|
56
|
+
* document (see {@link Link#rawId}). `1` and `'1'` stay equal links; this
|
|
57
|
+
* only says how each was written.
|
|
58
|
+
* @type {string|null}
|
|
59
|
+
*/
|
|
60
|
+
get quote() {
|
|
61
|
+
return referenceQuote(this.#rawId);
|
|
62
|
+
}
|
|
63
|
+
|
|
24
64
|
/**
|
|
25
65
|
* Convert link to string representation
|
|
26
66
|
* @returns {string} String representation of the link
|
package/src/Parser.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Link } from './Link.js';
|
|
|
2
2
|
import { LinksGroup } from './LinksGroup.js';
|
|
3
3
|
import { ParseError } from './ParseError.js';
|
|
4
4
|
import { stripComments } from './comments.js';
|
|
5
|
+
import { buildCst } from './Cst.js';
|
|
5
6
|
import * as parserModule from './parser-generated.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -18,11 +19,13 @@ export class Parser {
|
|
|
18
19
|
* @param {number} options.maxInputSize - Maximum input size in bytes (default: 10MB)
|
|
19
20
|
* @param {number} options.maxDepth - How deep links may nest: every parenthesized group and every indentation level is one level (default: 64)
|
|
20
21
|
* @param {boolean} options.comments - If false, read `#` as an ordinary character instead of the start of a comment (default: true)
|
|
22
|
+
* @param {boolean} options.syntax - If false, read links as a bare abstract syntax tree: they do not record how their IDs were written, so `rawId` and `quote` are null (default: true)
|
|
21
23
|
*/
|
|
22
24
|
constructor(options = {}) {
|
|
23
25
|
this.maxInputSize = options.maxInputSize || 10 * 1024 * 1024; // 10MB default
|
|
24
26
|
this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
25
27
|
this.comments = options.comments ?? true;
|
|
28
|
+
this.syntax = options.syntax ?? true;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -48,6 +51,20 @@ export class Parser {
|
|
|
48
51
|
);
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Read the document as a lossless concrete syntax tree: every character of
|
|
56
|
+
* the input, comments and whitespace included, belongs to exactly one token,
|
|
57
|
+
* so `parseCst(input).toString() === input`. The document is first parsed
|
|
58
|
+
* with this parser's options, so it is refused exactly when `parse()` would
|
|
59
|
+
* refuse it.
|
|
60
|
+
* @param {string} input - The Lino document
|
|
61
|
+
* @returns {CstNode} The `document` node
|
|
62
|
+
*/
|
|
63
|
+
parseCst(input) {
|
|
64
|
+
this.parse(input);
|
|
65
|
+
return buildCst(input, { comments: this.comments });
|
|
66
|
+
}
|
|
67
|
+
|
|
51
68
|
_transformGroup(item) {
|
|
52
69
|
return new LinksGroup(
|
|
53
70
|
this.transformLink(item),
|
|
@@ -74,6 +91,7 @@ export class Parser {
|
|
|
74
91
|
try {
|
|
75
92
|
const rawResult = parserModule.parse(prepared, {
|
|
76
93
|
maxDepth: this.maxDepth,
|
|
94
|
+
source: input,
|
|
77
95
|
});
|
|
78
96
|
return transform(rawResult);
|
|
79
97
|
} catch (error) {
|
|
@@ -119,6 +137,7 @@ export class Parser {
|
|
|
119
137
|
);
|
|
120
138
|
const linkWithChildren = {
|
|
121
139
|
id: item.id,
|
|
140
|
+
rawId: item.rawId,
|
|
122
141
|
values: childValues,
|
|
123
142
|
};
|
|
124
143
|
const currentLink = this.transformLink(linkWithChildren);
|
|
@@ -163,18 +182,24 @@ export class Parser {
|
|
|
163
182
|
transformIndentedValue(item) {
|
|
164
183
|
const children = item.children || [];
|
|
165
184
|
if (children.length && item.id != null && !item.values?.length) {
|
|
166
|
-
return
|
|
167
|
-
|
|
168
|
-
|
|
185
|
+
return this._withRawId(
|
|
186
|
+
new Link(
|
|
187
|
+
item.id,
|
|
188
|
+
children.map((child) => this.transformIndentedValue(child))
|
|
189
|
+
),
|
|
190
|
+
item
|
|
169
191
|
);
|
|
170
192
|
}
|
|
171
193
|
|
|
172
194
|
const current = this.transformLink(item);
|
|
173
195
|
if (children.length) {
|
|
174
|
-
return
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
196
|
+
return this._withRawId(
|
|
197
|
+
new Link(current.id, [
|
|
198
|
+
...current.values,
|
|
199
|
+
...children.map((child) => this.transformIndentedValue(child)),
|
|
200
|
+
]),
|
|
201
|
+
item
|
|
202
|
+
);
|
|
178
203
|
}
|
|
179
204
|
if (
|
|
180
205
|
item.id == null &&
|
|
@@ -252,18 +277,32 @@ export class Parser {
|
|
|
252
277
|
|
|
253
278
|
// Handle simple reference objects like {id: 'a'}
|
|
254
279
|
if (item.id !== undefined && !item.values && !item.children) {
|
|
255
|
-
return new Link(item.id);
|
|
280
|
+
return this._withRawId(new Link(item.id), item);
|
|
256
281
|
}
|
|
257
282
|
|
|
258
283
|
// For items with values, create a link with those values
|
|
259
284
|
if (item.values && Array.isArray(item.values)) {
|
|
260
285
|
// Create a link with id (if present) and transformed values
|
|
261
|
-
const link = new Link(item.id ?? null, []);
|
|
286
|
+
const link = this._withRawId(new Link(item.id ?? null, []), item);
|
|
262
287
|
link.values = item.values.map((v) => this.transformLink(v));
|
|
263
288
|
return link;
|
|
264
289
|
}
|
|
265
290
|
|
|
266
291
|
// Default case
|
|
267
|
-
return new Link(item.id ?? null, []);
|
|
292
|
+
return this._withRawId(new Link(item.id ?? null, []), item);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Record how the parsed item wrote its ID, unless this parser reads a bare
|
|
297
|
+
* abstract syntax tree.
|
|
298
|
+
* @param {Link} link - The link made from the item
|
|
299
|
+
* @param {Object} item - The parsed item, with the `rawId` the grammar read
|
|
300
|
+
* @returns {Link} The same link
|
|
301
|
+
*/
|
|
302
|
+
_withRawId(link, item) {
|
|
303
|
+
if (this.syntax && link.id !== null && typeof item.rawId === 'string') {
|
|
304
|
+
link.rawId = item.rawId;
|
|
305
|
+
}
|
|
306
|
+
return link;
|
|
268
307
|
}
|
|
269
308
|
}
|
package/src/grammar.pegjs
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
// until the stack overflows.
|
|
17
17
|
let contextDepth = 0;
|
|
18
18
|
const maxDepth = options.maxDepth ?? Infinity;
|
|
19
|
+
// The document as the caller wrote it, comments included.
|
|
20
|
+
const source = options.source ?? input;
|
|
19
21
|
// Lines already found unreadable, keyed by where they start and whether they
|
|
20
22
|
// are inside a parenthesised group. Reading a line depends on nothing else: a
|
|
21
23
|
// group starts a fresh indentation context, and indentation only decides
|
|
@@ -164,7 +166,7 @@ element = &{ return !unreadableLines.has(lineKey(offset())); }
|
|
|
164
166
|
// succeed without consuming input.
|
|
165
167
|
/ &{ unreadableLines.add(lineKey(offset())); return false; } .
|
|
166
168
|
|
|
167
|
-
referenceOrLink = l:multiLineAnyLink { return l; } /
|
|
169
|
+
referenceOrLink = l:multiLineAnyLink { return l; } / r:reference { return { id: r.value, rawId: r.raw }; }
|
|
168
170
|
|
|
169
171
|
// A line that starts with a parenthesised group reads that group once and then
|
|
170
172
|
// branches on what follows it: the end of the line makes the group the whole
|
|
@@ -210,15 +212,24 @@ singleLineValueAndWhitespace = __ value:referenceOrLink { return value; }
|
|
|
210
212
|
|
|
211
213
|
singleLineValues = list:singleLineValueAndWhitespace+ { return list; }
|
|
212
214
|
|
|
213
|
-
singleLineLink = __ id:reference __ ":" v:singleLineValues { return { id: id, values: v }; }
|
|
215
|
+
singleLineLink = __ id:reference __ ":" v:singleLineValues { return { id: id.value, rawId: id.raw, values: v }; }
|
|
214
216
|
|
|
215
217
|
singleLineValueLink = v:singleLineValues { return { values: v }; }
|
|
216
218
|
|
|
217
|
-
indentedIdLink = id:reference __ ":" eol { return { id: id, values: [] }; }
|
|
219
|
+
indentedIdLink = id:reference __ ":" eol { return { id: id.value, rawId: id.raw, values: [] }; }
|
|
220
|
+
|
|
221
|
+
// A reference is read as its value together with the text it was written as,
|
|
222
|
+
// so a parsed link can say whether its ID was bare, quoted or a literal. The
|
|
223
|
+
// text is taken from the document as the caller wrote it, which keeps the same
|
|
224
|
+
// offsets as the document with its comments blanked out.
|
|
225
|
+
reference = value:referenceValue {
|
|
226
|
+
const { start, end } = range();
|
|
227
|
+
return { value: value, raw: source.slice(start, end) };
|
|
228
|
+
}
|
|
218
229
|
|
|
219
230
|
// Reference can be quoted (with any number of quotes N >= 1) or simple unquoted
|
|
220
231
|
// Universal approach: use procedural parsing for all quote types and counts
|
|
221
|
-
|
|
232
|
+
referenceValue = referenceLiteral / quotedReference / simpleReference
|
|
222
233
|
|
|
223
234
|
// Versioned literals reserve ~<version>{<UTF-8 hex>} at a reference boundary.
|
|
224
235
|
// A malformed reserved token must fail rather than fall back to a bare name.
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export { Link, formatLinks } from './Link.js';
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
escapeReference,
|
|
4
|
+
unescapeReference,
|
|
5
|
+
referenceQuote,
|
|
6
|
+
} from './references.js';
|
|
7
|
+
export { CstKind, CstNode } from './Cst.js';
|
|
3
8
|
export {
|
|
4
9
|
parseIndentedDocument,
|
|
5
10
|
formatIndentedDocument,
|