links-notation 0.27.1 → 0.30.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 +492 -404
- package/index.d.ts +84 -0
- package/package.json +7 -4
- package/src/Cst.js +246 -0
- package/src/Link.js +41 -1
- package/src/Parser.js +49 -10
- package/src/ReferenceLiteral.js +22 -0
- package/src/StreamParser.js +33 -13
- package/src/comments.js +50 -11
- package/src/grammar.pegjs +19 -6
- package/src/index.js +6 -1
- package/src/parser-generated.js +183 -327
- package/src/quotes.js +13 -7
- package/src/references.js +18 -0
- package/tests/Comments.test.js +31 -0
- package/tests/Cst.test.js +109 -0
- package/tests/SharedSyntaxParity.test.js +47 -0
- package/tests/StreamParser.test.js +13 -0
- package/tests/SyntaxMetadata.test.js +102 -0
package/src/quotes.js
CHANGED
|
@@ -43,8 +43,10 @@ export class DelimitedReferences {
|
|
|
43
43
|
/**
|
|
44
44
|
* The reference opened at `start`.
|
|
45
45
|
* @param {number} start - Position of the opening delimiter
|
|
46
|
-
* @returns {{value: string, length: number}|null} What the
|
|
47
|
-
*
|
|
46
|
+
* @returns {{value: string, length: number, closed: boolean}|null} What the
|
|
47
|
+
* reference holds, how many characters it takes and whether a run closes
|
|
48
|
+
* it (an even run nothing closes is the empty reference, until a closing
|
|
49
|
+
* run is written after it), or null when nothing opens there
|
|
48
50
|
*/
|
|
49
51
|
readAt(start) {
|
|
50
52
|
if (!this.readings.has(start)) {
|
|
@@ -103,8 +105,9 @@ export class DelimitedReferences {
|
|
|
103
105
|
* reference that is as quick, since it is read once.
|
|
104
106
|
* @param {string} document - The document being read
|
|
105
107
|
* @param {number} start - Position of the opening delimiter
|
|
106
|
-
* @returns {{value: string, length: number}|null} What the
|
|
107
|
-
*
|
|
108
|
+
* @returns {{value: string, length: number, closed: boolean}|null} What the
|
|
109
|
+
* reference holds, how many characters it takes and whether a run closes it,
|
|
110
|
+
* or null when nothing opens there
|
|
108
111
|
*/
|
|
109
112
|
export function readReference(document, start) {
|
|
110
113
|
const quote = document[start];
|
|
@@ -131,10 +134,13 @@ export function readReference(document, start) {
|
|
|
131
134
|
* by the run that ends at `end` when there is one.
|
|
132
135
|
*/
|
|
133
136
|
function reading(document, start, quote, count, end) {
|
|
134
|
-
const emptyReference = count % 2 === 0 ? { value: '', length: count } : null;
|
|
135
137
|
if (end === null) {
|
|
136
|
-
|
|
138
|
+
// Nothing closes the run: an even one is the empty reference, which a
|
|
139
|
+
// later closing run would still turn into one holding what follows.
|
|
140
|
+
return count % 2 === 0 ? { value: '', length: count, closed: false } : null;
|
|
137
141
|
}
|
|
142
|
+
const emptyReference =
|
|
143
|
+
count % 2 === 0 ? { value: '', length: count, closed: true } : null;
|
|
138
144
|
|
|
139
145
|
const parts = [];
|
|
140
146
|
let position = start + count;
|
|
@@ -154,7 +160,7 @@ function reading(document, start, quote, count, end) {
|
|
|
154
160
|
if (emptyReference !== null && !isSubstantiveBody(value)) {
|
|
155
161
|
return emptyReference;
|
|
156
162
|
}
|
|
157
|
-
return { value, length: end - start };
|
|
163
|
+
return { value, length: end - start, closed: true };
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
/** The number of `quote` delimiters in a row from `start` of `text`. */
|
package/src/references.js
CHANGED
|
@@ -61,3 +61,21 @@ export function unescapeReference(reference) {
|
|
|
61
61
|
}
|
|
62
62
|
return reading.value;
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The delimiter a reference was written between, read from the reference as it
|
|
67
|
+
* was written: `'`, `"` or `` ` `` for a quoted reference (however many of them
|
|
68
|
+
* open it), and null for a bare reference, a versioned literal, or no text.
|
|
69
|
+
* @param {string|null|undefined} raw - One reference exactly as written
|
|
70
|
+
* @returns {string|null} The delimiter character, or null
|
|
71
|
+
* @example
|
|
72
|
+
* referenceQuote("'1'"); // "'"
|
|
73
|
+
* referenceQuote('""a b""'); // '"'
|
|
74
|
+
* referenceQuote('1'); // null
|
|
75
|
+
* referenceQuote("'unclosed"); // null: a bare reference that starts with '
|
|
76
|
+
*/
|
|
77
|
+
export function referenceQuote(raw) {
|
|
78
|
+
if (typeof raw !== 'string' || !QUOTES.includes(raw[0])) return null;
|
|
79
|
+
const reading = readReference(raw, 0);
|
|
80
|
+
return reading !== null && reading.length === raw.length ? raw[0] : null;
|
|
81
|
+
}
|
package/tests/Comments.test.js
CHANGED
|
@@ -158,3 +158,34 @@ test('a hash that cannot open a comment is left unquoted', () => {
|
|
|
158
158
|
expect(Link.escapeReference('#')).toBe("'#'");
|
|
159
159
|
expect(Link.escapeReference('#ff0000')).toBe("'#ff0000'");
|
|
160
160
|
});
|
|
161
|
+
|
|
162
|
+
// Issue #377: the comment reader and the grammar must agree on where every
|
|
163
|
+
// delimited reference starts and how far it reaches, or a comment is blanked
|
|
164
|
+
// inside what the grammar then reads as a reference.
|
|
165
|
+
|
|
166
|
+
test('a hash inside a reference that follows a group is content', () => {
|
|
167
|
+
expect(parsesAs('(a)" #b"\n')).toBe('((<a>) < #b>)');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a hash inside a reference that follows another reference is content', () => {
|
|
171
|
+
expect(parsesAs('"x"\' #y\'\n')).toBe('(<x> < #y>)');
|
|
172
|
+
expect(parsesAs('```q```"" #c\n"""\n')).toBe('(<q> < #c\n">)');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('a hash inside a reference that follows a literal is content', () => {
|
|
176
|
+
expect(parsesAs('~1{62}" #b"\n')).toBe('(<b> < #b>)');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('a reference is read in the document as written, comments included', () => {
|
|
180
|
+
// The body of the first `""` straddles a parenthesis, so it is the empty
|
|
181
|
+
// reference and the `#` after it opens a comment. Blanking that comment
|
|
182
|
+
// must not turn the `""` into a reference that holds the blanked text.
|
|
183
|
+
expect(parsesAs('"" a #)b\n""\n')).toBe('(<> <a>)\n(<>)');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('the comment reader skips the references the grammar reads', () => {
|
|
187
|
+
expect(stripComments('(a)" #b"\n')).toBe('(a)" #b"\n');
|
|
188
|
+
expect(stripComments('"x"\' #y\'\n')).toBe('"x"\' #y\'\n');
|
|
189
|
+
expect(stripComments('~1{62}" #b"\n')).toBe('~1{62}" #b"\n');
|
|
190
|
+
expect(stripComments('a"b #c"\n')).toBe('a"b \n');
|
|
191
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { CstKind, CstNode, Parser } from '../src/index.js';
|
|
3
|
+
|
|
4
|
+
const roundTrip = (input, options) =>
|
|
5
|
+
new Parser(options).parseCst(input).toString();
|
|
6
|
+
|
|
7
|
+
describe('concrete syntax tree (issue #364)', () => {
|
|
8
|
+
test('is the default lossless view of a document', () => {
|
|
9
|
+
const input = "(t 1 '1')";
|
|
10
|
+
const cst = new Parser().parseCst(input);
|
|
11
|
+
expect(cst).toBeInstanceOf(CstNode);
|
|
12
|
+
expect(cst.kind).toBe(CstKind.DOCUMENT);
|
|
13
|
+
expect(cst.toString()).toBe(input);
|
|
14
|
+
expect(cst.toSExpression()).toBe(
|
|
15
|
+
'(document (line (group open_paren:"(" (line reference:"t" whitespace:" " reference:"1" whitespace:" " reference:"\'1\'") close_paren:")")))'
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('reference tokens carry their value and delimiter', () => {
|
|
20
|
+
const references = new Parser()
|
|
21
|
+
.parseCst("(a \"b c\" ~1{64} ''e''''f'')")
|
|
22
|
+
.tokens()
|
|
23
|
+
.filter((token) => token.kind === CstKind.REFERENCE);
|
|
24
|
+
expect(
|
|
25
|
+
references.map((token) => [token.text, token.value, token.quote])
|
|
26
|
+
).toEqual([
|
|
27
|
+
['a', 'a', null],
|
|
28
|
+
['"b c"', 'b c', '"'],
|
|
29
|
+
['~1{64}', 'd', null],
|
|
30
|
+
["''e''''f''", "e''f", "'"],
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test.each([
|
|
35
|
+
['empty', ''],
|
|
36
|
+
['only whitespace', ' \t '],
|
|
37
|
+
['blank lines', '\n\n'],
|
|
38
|
+
['CRLF and CR', 'a\r\nb\rc\r\n'],
|
|
39
|
+
['tabs and trailing spaces', 'a\t b \n'],
|
|
40
|
+
['comments everywhere', '# head\na: b # tail\n # inner\n c\n# end'],
|
|
41
|
+
['multiline quotes', '(note: "one\n two\r\nthree")'],
|
|
42
|
+
['nested groups over lines', '(a (b\n (c d)\n) e)\n'],
|
|
43
|
+
['indentation', 'root:\n child:\n leaf\n tail'],
|
|
44
|
+
['literals', '(~1{} ~1{e6a0b9})'],
|
|
45
|
+
['comment marks inside quotes', '(\'#a\' "b # c" `#`)'],
|
|
46
|
+
['unicode', '(根: café 🙂)\n'],
|
|
47
|
+
])('recovers %s exactly', (_, input) => {
|
|
48
|
+
expect(roundTrip(input)).toBe(input);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('without comment support, # is part of references', () => {
|
|
52
|
+
const input = 'a #b';
|
|
53
|
+
const cst = new Parser({ comments: false }).parseCst(input);
|
|
54
|
+
expect(cst.toString()).toBe(input);
|
|
55
|
+
expect(cst.tokens().map((token) => token.kind)).toEqual([
|
|
56
|
+
'reference',
|
|
57
|
+
'whitespace',
|
|
58
|
+
'reference',
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a group belongs to the line it opens on', () => {
|
|
63
|
+
const cst = new Parser().parseCst('a (b\nc)\nd');
|
|
64
|
+
expect(cst.children.map((line) => line.toString())).toEqual([
|
|
65
|
+
'a (b\nc)\n',
|
|
66
|
+
'd',
|
|
67
|
+
]);
|
|
68
|
+
const group = cst.children[0].children[2];
|
|
69
|
+
expect(group.kind).toBe(CstKind.GROUP);
|
|
70
|
+
expect(group.children.map((child) => child.kind)).toEqual([
|
|
71
|
+
'open_paren',
|
|
72
|
+
'line',
|
|
73
|
+
'line',
|
|
74
|
+
'close_paren',
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('reads versioned literals in linear time', () => {
|
|
79
|
+
// CodeQL js/polynomial-redos flagged the regular expression that used to
|
|
80
|
+
// find where a literal ends; the scanner that replaced it is one pass.
|
|
81
|
+
const input = [
|
|
82
|
+
`~1{${'61'.repeat(50000)}}`,
|
|
83
|
+
`~${'1'.repeat(50000)}x`,
|
|
84
|
+
'~1{61} '.repeat(20000),
|
|
85
|
+
].join('\n');
|
|
86
|
+
const started = Date.now();
|
|
87
|
+
const cst = new Parser().parseCst(input);
|
|
88
|
+
expect(Date.now() - started).toBeLessThan(5000);
|
|
89
|
+
expect(cst.toString()).toBe(input);
|
|
90
|
+
expect(
|
|
91
|
+
new Parser()
|
|
92
|
+
.parseCst('(~1{64} ~{} ~a{b} ~1x)')
|
|
93
|
+
.tokens()
|
|
94
|
+
.filter((token) => token.kind === CstKind.REFERENCE)
|
|
95
|
+
.map((token) => [token.text, token.value])
|
|
96
|
+
).toEqual([
|
|
97
|
+
['~1{64}', 'd'],
|
|
98
|
+
['~{}', '~{}'],
|
|
99
|
+
['~a{b}', '~a{b}'],
|
|
100
|
+
['~1x', '~1x'],
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('rejects what parse() rejects', () => {
|
|
105
|
+
expect(() => new Parser().parseCst('(a')).toThrow();
|
|
106
|
+
expect(() => new Parser().parseCst('a)')).toThrow();
|
|
107
|
+
expect(() => new Parser({ maxDepth: 1 }).parseCst('((a))')).toThrow();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { URL } from 'node:url';
|
|
3
|
+
import { expect, test } from 'bun:test';
|
|
4
|
+
import { Parser, StreamParser } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
const read = (name) =>
|
|
7
|
+
JSON.parse(
|
|
8
|
+
readFileSync(new URL(`../../parity/${name}`, import.meta.url), 'utf8')
|
|
9
|
+
);
|
|
10
|
+
const syntaxFixtures = read('syntax-fixtures.json');
|
|
11
|
+
const textFixtures = read('text-fixtures.json');
|
|
12
|
+
const tree = (links) =>
|
|
13
|
+
links.map((link) => ({
|
|
14
|
+
id: link.id,
|
|
15
|
+
raw: link.rawId,
|
|
16
|
+
quote: link.quote,
|
|
17
|
+
values: tree(link.values),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
test('shared syntax parity: raw references and concrete syntax trees', () => {
|
|
21
|
+
for (const fixture of syntaxFixtures) {
|
|
22
|
+
const parser = new Parser();
|
|
23
|
+
expect(tree(parser.parse(fixture.input)), fixture.id).toEqual(
|
|
24
|
+
fixture.links
|
|
25
|
+
);
|
|
26
|
+
const cst = parser.parseCst(fixture.input);
|
|
27
|
+
expect(cst.toSExpression(), fixture.id).toBe(fixture.cst);
|
|
28
|
+
expect(cst.toString(), fixture.id).toBe(fixture.input);
|
|
29
|
+
const stream = new StreamParser();
|
|
30
|
+
for (const character of fixture.input) stream.write(character);
|
|
31
|
+
expect(tree(stream.end()), fixture.id).toEqual(fixture.links);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('every shared input is recovered exactly from its concrete syntax tree', () => {
|
|
36
|
+
for (const { id, input } of [...textFixtures, ...syntaxFixtures]) {
|
|
37
|
+
const cst = new Parser().parseCst(input);
|
|
38
|
+
expect(cst.toString(), id).toBe(input);
|
|
39
|
+
expect(
|
|
40
|
+
cst
|
|
41
|
+
.tokens()
|
|
42
|
+
.map((token) => token.text)
|
|
43
|
+
.join(''),
|
|
44
|
+
id
|
|
45
|
+
).toBe(input);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
@@ -162,4 +162,17 @@ describe('StreamParser', () => {
|
|
|
162
162
|
buffered: 2,
|
|
163
163
|
});
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
test('waits for the line that closes an even run of quotes (issue #377)', () => {
|
|
167
|
+
// `""` is the empty reference only when nothing closes it, and here the
|
|
168
|
+
// next line does, so the first line cannot be emitted on its own.
|
|
169
|
+
const input = 'a "" b\n"" c\n';
|
|
170
|
+
const stream = new StreamParser();
|
|
171
|
+
for (const character of input) {
|
|
172
|
+
expect(stream.write(character)).toEqual([]);
|
|
173
|
+
}
|
|
174
|
+
expect(formatLinks(stream.end())).toBe(
|
|
175
|
+
formatLinks(new Parser().parse(input))
|
|
176
|
+
);
|
|
177
|
+
});
|
|
165
178
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
Link,
|
|
4
|
+
Parser,
|
|
5
|
+
StreamParser,
|
|
6
|
+
formatLinks,
|
|
7
|
+
referenceQuote,
|
|
8
|
+
} from '../src/index.js';
|
|
9
|
+
|
|
10
|
+
describe('syntax metadata on parsed links (issue #364)', () => {
|
|
11
|
+
test("1 and '1' are the same reference but keep how they were written", () => {
|
|
12
|
+
const [link] = new Parser().parse("(t 1 '1')");
|
|
13
|
+
const [, bare, quoted] = link.values;
|
|
14
|
+
expect(bare.id).toBe('1');
|
|
15
|
+
expect(quoted.id).toBe('1');
|
|
16
|
+
expect(bare.rawId).toBe('1');
|
|
17
|
+
expect(quoted.rawId).toBe("'1'");
|
|
18
|
+
expect(bare.quote).toBeNull();
|
|
19
|
+
expect(quoted.quote).toBe("'");
|
|
20
|
+
// Equality and formatting ignore the metadata.
|
|
21
|
+
expect(bare.equals(quoted)).toBe(true);
|
|
22
|
+
expect(link.format()).toBe('(t 1 1)');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('records every quote kind and run length', () => {
|
|
26
|
+
const [link] = new Parser().parse('("a": \'b\' `c` ""d"" ~1{65} f)');
|
|
27
|
+
expect(link.rawId).toBe('"a"');
|
|
28
|
+
expect(link.quote).toBe('"');
|
|
29
|
+
expect(link.values.map((value) => [value.rawId, value.quote])).toEqual([
|
|
30
|
+
["'b'", "'"],
|
|
31
|
+
['`c`', '`'],
|
|
32
|
+
['""d""', '"'],
|
|
33
|
+
['~1{65}', null],
|
|
34
|
+
['f', null],
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('keeps raw text from the source, not from comment stripping', () => {
|
|
39
|
+
const [link] = new Parser().parse("(a 'b # c') # d");
|
|
40
|
+
expect(link.values[1].rawId).toBe("'b # c'");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('leaves links without written IDs without raw text', () => {
|
|
44
|
+
const [link] = new Parser().parse('(a b)');
|
|
45
|
+
expect(link.rawId).toBeNull();
|
|
46
|
+
expect(link.quote).toBeNull();
|
|
47
|
+
expect(new Link('x').rawId).toBeNull();
|
|
48
|
+
expect(new Link('x').quote).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('does not change equality, formatting or serialization', () => {
|
|
52
|
+
const quoted = new Parser().parse("('a': 'b')");
|
|
53
|
+
const bare = new Parser().parse('(a: b)');
|
|
54
|
+
expect(quoted[0].equals(bare[0])).toBe(true);
|
|
55
|
+
expect(formatLinks(quoted)).toBe(formatLinks(bare));
|
|
56
|
+
expect(quoted[0].toString()).toBe(bare[0].toString());
|
|
57
|
+
expect(JSON.stringify(quoted)).toBe(JSON.stringify(bare));
|
|
58
|
+
expect(quoted).toEqual(bare);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('AST mode (syntax: false) drops the metadata', () => {
|
|
62
|
+
const [link] = new Parser({ syntax: false }).parse("('a': 'b')");
|
|
63
|
+
expect(link.rawId).toBeNull();
|
|
64
|
+
expect(link.values[0].rawId).toBeNull();
|
|
65
|
+
expect(link.values[0].quote).toBeNull();
|
|
66
|
+
expect(link.equals(new Parser().parse("('a': 'b')")[0])).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('parseGroups and StreamParser keep the metadata', () => {
|
|
70
|
+
const groups = new Parser().parseGroups('\'root\':\n "child"');
|
|
71
|
+
expect(groups[0].element.rawId).toBe("'root'");
|
|
72
|
+
expect(groups[0].children[0].element.values[0].rawId).toBe('"child"');
|
|
73
|
+
const stream = new StreamParser();
|
|
74
|
+
stream.write("(a '");
|
|
75
|
+
stream.write("b')");
|
|
76
|
+
const [link] = stream.end();
|
|
77
|
+
expect(link.values[1].rawId).toBe("'b'");
|
|
78
|
+
expect(link.values[1].quote).toBe("'");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('rawId can be set on links built in code and must be text or null', () => {
|
|
82
|
+
const link = new Link('a');
|
|
83
|
+
link.rawId = '"a"';
|
|
84
|
+
expect(link.quote).toBe('"');
|
|
85
|
+
link.rawId = null;
|
|
86
|
+
expect(link.quote).toBeNull();
|
|
87
|
+
expect(() => {
|
|
88
|
+
link.rawId = 1;
|
|
89
|
+
}).toThrow(TypeError);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('referenceQuote reads the delimiter of one complete reference', () => {
|
|
93
|
+
expect(referenceQuote("'1'")).toBe("'");
|
|
94
|
+
expect(referenceQuote('""a b""')).toBe('"');
|
|
95
|
+
expect(referenceQuote('`x`')).toBe('`');
|
|
96
|
+
expect(referenceQuote('1')).toBeNull();
|
|
97
|
+
expect(referenceQuote('~1{31}')).toBeNull();
|
|
98
|
+
expect(referenceQuote("'unclosed")).toBeNull();
|
|
99
|
+
expect(referenceQuote("'a' b")).toBeNull();
|
|
100
|
+
expect(referenceQuote(null)).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
});
|