links-notation 0.21.1 → 0.21.3
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/dist/index.js +439 -225
- package/package.json +1 -1
- package/src/StreamParser.js +3 -2
- package/src/comments.js +6 -70
- package/src/grammar.pegjs +54 -76
- package/src/parser-generated.js +304 -186
- package/src/quotes.js +251 -0
- package/tests/Issue316QuoteRuns.test.js +163 -0
- package/tests/NestingDepth.test.js +124 -0
- package/tests/support/nestingDepthWorker.js +15 -0
package/src/quotes.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delimited references, and how far each one reaches.
|
|
3
|
+
*
|
|
4
|
+
* A reference opened by a run of N delimiters (`"`, `'` or `` ` ``) closes at
|
|
5
|
+
* the next run of exactly N, and a run of 2N inside it is that delimiter
|
|
6
|
+
* escaped. An even run that encloses nothing substantive is the empty
|
|
7
|
+
* reference: the shortest reading, a bare delimiter pair enclosing nothing,
|
|
8
|
+
* wins over a longer n-quote delimiter.
|
|
9
|
+
*
|
|
10
|
+
* A run of R delimiters in a body is read as a whole: each 2N of it in turn are
|
|
11
|
+
* an escaped N, and the R % 2N left over close the reference when there are at
|
|
12
|
+
* least N of them - the last N close it, the ones before are content - and are
|
|
13
|
+
* content otherwise. So a run closes the reference exactly when
|
|
14
|
+
* `Math.floor(R / N)` is odd, and a run shorter than N never does.
|
|
15
|
+
*
|
|
16
|
+
* {@link DelimitedReferences} lists the runs of a delimiter once per document
|
|
17
|
+
* and links each run to the next longer one, so finding where a reference
|
|
18
|
+
* closes skips every run too short to close it. Reading a reference takes time
|
|
19
|
+
* linear in its body however wide its opening run is, and finding that a
|
|
20
|
+
* reference never closes does not take a pass over the rest of the document
|
|
21
|
+
* each time it is asked.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The characters a reference can be written between. */
|
|
25
|
+
export const QUOTES = ['"', "'", '`'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every delimited reference of one document, read on demand.
|
|
29
|
+
*
|
|
30
|
+
* The grammar and the comment reader each keep one per document they read, so
|
|
31
|
+
* a reference asked for twice is read once.
|
|
32
|
+
*/
|
|
33
|
+
export class DelimitedReferences {
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} document - The document the references are read from
|
|
36
|
+
*/
|
|
37
|
+
constructor(document) {
|
|
38
|
+
this.document = document;
|
|
39
|
+
this.runsByQuote = new Map();
|
|
40
|
+
this.readings = new Map();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The reference opened at `start`.
|
|
45
|
+
* @param {number} start - Position of the opening delimiter
|
|
46
|
+
* @returns {{value: string, length: number}|null} What the reference holds
|
|
47
|
+
* and how many characters it takes, or null when nothing opens there
|
|
48
|
+
*/
|
|
49
|
+
readAt(start) {
|
|
50
|
+
if (!this.readings.has(start)) {
|
|
51
|
+
this.readings.set(start, this.#read(start));
|
|
52
|
+
}
|
|
53
|
+
return this.readings.get(start);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The position just past the reference opened at `start`.
|
|
58
|
+
* @param {number} start - Position of the opening delimiter
|
|
59
|
+
* @returns {number|null} The position, or null when nothing opens there
|
|
60
|
+
*/
|
|
61
|
+
endAt(start) {
|
|
62
|
+
const reading = this.readAt(start);
|
|
63
|
+
return reading === null ? null : start + reading.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#read(start) {
|
|
67
|
+
const document = this.document;
|
|
68
|
+
const quote = document[start];
|
|
69
|
+
if (!QUOTES.includes(quote)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const runs = this.#runsOf(quote);
|
|
74
|
+
const opening = runs.indexOf(start);
|
|
75
|
+
const count = runs.end(opening) - start;
|
|
76
|
+
|
|
77
|
+
// The first run after the opening one that closes the reference.
|
|
78
|
+
let closing = opening + 1;
|
|
79
|
+
while (closing < runs.count) {
|
|
80
|
+
const length = runs.lengths[closing];
|
|
81
|
+
if (length < count) {
|
|
82
|
+
closing = runs.nextLonger[closing];
|
|
83
|
+
} else if (Math.floor(length / count) % 2 === 1) {
|
|
84
|
+
break;
|
|
85
|
+
} else {
|
|
86
|
+
closing++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const end = closing < runs.count ? runs.end(closing) : null;
|
|
90
|
+
return reading(document, start, quote, count, end);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#runsOf(quote) {
|
|
94
|
+
if (!this.runsByQuote.has(quote)) {
|
|
95
|
+
this.runsByQuote.set(quote, new DelimiterRuns(this.document, quote));
|
|
96
|
+
}
|
|
97
|
+
return this.runsByQuote.get(quote);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The reference opened at `start`, read without a list of runs: for a single
|
|
103
|
+
* reference that is as quick, since it is read once.
|
|
104
|
+
* @param {string} document - The document being read
|
|
105
|
+
* @param {number} start - Position of the opening delimiter
|
|
106
|
+
* @returns {{value: string, length: number}|null} What the reference holds
|
|
107
|
+
* and how many characters it takes, or null when nothing opens there
|
|
108
|
+
*/
|
|
109
|
+
export function readReference(document, start) {
|
|
110
|
+
const quote = document[start];
|
|
111
|
+
if (!QUOTES.includes(quote)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const count = runLength(document, start, quote);
|
|
115
|
+
|
|
116
|
+
let end = null;
|
|
117
|
+
let position = document.indexOf(quote, start + count);
|
|
118
|
+
while (position !== -1) {
|
|
119
|
+
const length = runLength(document, position, quote);
|
|
120
|
+
if (length >= count && Math.floor(length / count) % 2 === 1) {
|
|
121
|
+
end = position + length;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
position = document.indexOf(quote, position + length);
|
|
125
|
+
}
|
|
126
|
+
return reading(document, start, quote, count, end);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The reading of a reference opened by `count` delimiters at `start`, closed
|
|
131
|
+
* by the run that ends at `end` when there is one.
|
|
132
|
+
*/
|
|
133
|
+
function reading(document, start, quote, count, end) {
|
|
134
|
+
const emptyReference = count % 2 === 0 ? { value: '', length: count } : null;
|
|
135
|
+
if (end === null) {
|
|
136
|
+
return emptyReference;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const parts = [];
|
|
140
|
+
let position = start + count;
|
|
141
|
+
let run = document.indexOf(quote, position);
|
|
142
|
+
while (run !== -1 && run < end) {
|
|
143
|
+
const length = runLength(document, run, quote);
|
|
144
|
+
const escaped = Math.floor(length / (2 * count)) * count;
|
|
145
|
+
// The closing run is the last one of the body.
|
|
146
|
+
const closes = run + length === end ? count : 0;
|
|
147
|
+
parts.push(document.slice(position, run));
|
|
148
|
+
parts.push(quote.repeat(length - escaped - closes));
|
|
149
|
+
position = run + length;
|
|
150
|
+
run = document.indexOf(quote, position);
|
|
151
|
+
}
|
|
152
|
+
const value = parts.join('');
|
|
153
|
+
|
|
154
|
+
if (emptyReference !== null && !isSubstantiveBody(value)) {
|
|
155
|
+
return emptyReference;
|
|
156
|
+
}
|
|
157
|
+
return { value, length: end - start };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The number of `quote` delimiters in a row from `start` of `text`. */
|
|
161
|
+
function runLength(text, start, quote) {
|
|
162
|
+
let end = start;
|
|
163
|
+
while (end < text.length && text[end] === quote) {
|
|
164
|
+
end++;
|
|
165
|
+
}
|
|
166
|
+
return end - start;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The maximal runs of one delimiter in a document, in order, each linked to the
|
|
171
|
+
* next run that is longer than it.
|
|
172
|
+
*/
|
|
173
|
+
class DelimiterRuns {
|
|
174
|
+
constructor(document, quote) {
|
|
175
|
+
this.starts = [];
|
|
176
|
+
this.lengths = [];
|
|
177
|
+
|
|
178
|
+
let position = document.indexOf(quote);
|
|
179
|
+
while (position !== -1) {
|
|
180
|
+
const length = runLength(document, position, quote);
|
|
181
|
+
this.starts.push(position);
|
|
182
|
+
this.lengths.push(length);
|
|
183
|
+
position = document.indexOf(quote, position + length);
|
|
184
|
+
}
|
|
185
|
+
this.count = this.starts.length;
|
|
186
|
+
|
|
187
|
+
// Every run between a run and the next longer one is at most as long as
|
|
188
|
+
// it, which is what lets a search for a run of some length jump over them.
|
|
189
|
+
this.nextLonger = new Array(this.count);
|
|
190
|
+
const longer = [];
|
|
191
|
+
for (let run = this.count - 1; run >= 0; run--) {
|
|
192
|
+
while (
|
|
193
|
+
longer.length > 0 &&
|
|
194
|
+
this.lengths[longer[longer.length - 1]] <= this.lengths[run]
|
|
195
|
+
) {
|
|
196
|
+
longer.pop();
|
|
197
|
+
}
|
|
198
|
+
this.nextLonger[run] =
|
|
199
|
+
longer.length > 0 ? longer[longer.length - 1] : this.count;
|
|
200
|
+
longer.push(run);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Position just past the given run. */
|
|
205
|
+
end(run) {
|
|
206
|
+
return this.starts[run] + this.lengths[run];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The run that holds the given position of a delimiter. */
|
|
210
|
+
indexOf(position) {
|
|
211
|
+
let low = 0;
|
|
212
|
+
let high = this.count - 1;
|
|
213
|
+
while (low < high) {
|
|
214
|
+
const middle = (low + high + 1) >> 1;
|
|
215
|
+
if (this.starts[middle] <= position) {
|
|
216
|
+
low = middle;
|
|
217
|
+
} else {
|
|
218
|
+
high = middle - 1;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return low;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Whether a body written between an even run of delimiters carries something a
|
|
227
|
+
* pair of delimiters enclosing nothing cannot: at least one visible character,
|
|
228
|
+
* and no parenthesis it straddles.
|
|
229
|
+
* @param {string} content - The body between the delimiters
|
|
230
|
+
* @returns {boolean} True when the n-quote reading is the one to take
|
|
231
|
+
*/
|
|
232
|
+
export function isSubstantiveBody(content) {
|
|
233
|
+
let depth = 0;
|
|
234
|
+
let hasVisible = false;
|
|
235
|
+
|
|
236
|
+
for (const character of content) {
|
|
237
|
+
if (character === '(') {
|
|
238
|
+
depth++;
|
|
239
|
+
} else if (character === ')') {
|
|
240
|
+
depth--;
|
|
241
|
+
if (depth < 0) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!/[ \t\n\r]/.test(character)) {
|
|
246
|
+
hasVisible = true;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return hasVisible && depth === 0;
|
|
251
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { test, expect, setDefaultTimeout } from 'bun:test';
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { Parser } from '../src/Parser.js';
|
|
4
|
+
import { quotedReferenceEnd, stripComments } from '../src/comments.js';
|
|
5
|
+
import { DelimitedReferences } from '../src/quotes.js';
|
|
6
|
+
import { StreamParser } from '../src/StreamParser.js';
|
|
7
|
+
|
|
8
|
+
// Issue #316: a reference that opens with a wide run of delimiters used to be
|
|
9
|
+
// read one character at a time, comparing the whole width at every position,
|
|
10
|
+
// and every run that never closed was read to the end of the document again.
|
|
11
|
+
// These documents took from seconds to tens of seconds; read a run at a time
|
|
12
|
+
// they take milliseconds, so the bound below only fails on the old behaviour.
|
|
13
|
+
|
|
14
|
+
const parser = new Parser();
|
|
15
|
+
const BOUND_MS = 2000;
|
|
16
|
+
const SIZE = 400000;
|
|
17
|
+
// A regression fails on the bound; the timeout stops it from hanging the run.
|
|
18
|
+
setDefaultTimeout(20000);
|
|
19
|
+
|
|
20
|
+
function render(node) {
|
|
21
|
+
if (!node.values || node.values.length === 0) {
|
|
22
|
+
return `<${node.id ?? ''}>`;
|
|
23
|
+
}
|
|
24
|
+
const head =
|
|
25
|
+
node.id === null || node.id === undefined ? '' : `<${node.id}>: `;
|
|
26
|
+
return `(${head}${node.values.map(render).join(' ')})`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parsesAs(source) {
|
|
30
|
+
return parser.parse(source).map(render).join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const q = (count) => "'".repeat(count);
|
|
34
|
+
|
|
35
|
+
function timed(read) {
|
|
36
|
+
const started = performance.now();
|
|
37
|
+
const result = read();
|
|
38
|
+
return { result, elapsed: performance.now() - started };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The shapes from the issue, and two that defeat shortcuts a fix could take.
|
|
42
|
+
|
|
43
|
+
function wideQuoteOverALongRun(size) {
|
|
44
|
+
const width = Math.floor(size / 4);
|
|
45
|
+
return `${q(width + 1)} a ${q(width - 4)} ${'x'.repeat(2 * width)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function narrowingUnclosedQuotes(size) {
|
|
49
|
+
const widths = [];
|
|
50
|
+
for (
|
|
51
|
+
let width = 2 * Math.floor(Math.sqrt(size / 2) / 2) + 1;
|
|
52
|
+
width >= 1;
|
|
53
|
+
width -= 2
|
|
54
|
+
) {
|
|
55
|
+
widths.push(width);
|
|
56
|
+
}
|
|
57
|
+
const head = widths.map((width) => `${q(width)}x`).join(' ');
|
|
58
|
+
return `${head} ${"a' ".repeat(Math.floor((size - head.length) / 3))}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function closedWideQuoteOverQuotes(size) {
|
|
62
|
+
const width = Math.floor(size / 8);
|
|
63
|
+
return `${q(width)}${`${q(width - 1)}x`.repeat(6)}${q(width)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function unclosedWidthsOverAnEvenRun(size) {
|
|
67
|
+
const run = Math.floor(size / 4);
|
|
68
|
+
const widths = [];
|
|
69
|
+
for (let width = Math.floor(Math.sqrt(size)); width >= 2; width--) {
|
|
70
|
+
if (Math.floor(run / width) % 2 === 0) widths.push(width);
|
|
71
|
+
}
|
|
72
|
+
const head = widths.map((width) => `${q(width)}x`).join(' ');
|
|
73
|
+
const tail = "a' ".repeat(Math.floor((size - head.length - run) / 3));
|
|
74
|
+
return `${head} ${tail}${q(run)} b`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function expectParsedWithinBound(source) {
|
|
78
|
+
expect(timed(() => parser.parse(source)).elapsed).toBeLessThan(BOUND_MS);
|
|
79
|
+
expect(timed(() => stripComments(source)).elapsed).toBeLessThan(BOUND_MS);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test('a run inside a body is read as escapes then a closing run', () => {
|
|
83
|
+
expect(parsesAs("'''a'''")).toBe('(<a>)');
|
|
84
|
+
expect(parsesAs("'''a''''''b'''")).toBe("(<a'''b>)");
|
|
85
|
+
expect(parsesAs("''a'''")).toBe("(<a'>)");
|
|
86
|
+
expect(parsesAs("'a'''")).toBe("(<a'>)");
|
|
87
|
+
expect(parsesAs(`${q(3)}a${q(9)} b`)).toBe("(<a'''> <b>)");
|
|
88
|
+
expect(parsesAs(`${q(50)}a${q(100)}b${q(50)}`)).toBe(`(<a${q(50)}b>)`);
|
|
89
|
+
expect(parsesAs('"""a""b"""')).toBe('(<a""b>)');
|
|
90
|
+
expect(parsesAs('``a````b``')).toBe('(<a``b>)');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a run that never closes the reference leaves it unclosed', () => {
|
|
94
|
+
// Math.floor(7 / 3) is even: two escaped runs of three and one delimiter.
|
|
95
|
+
expect(parsesAs(`${q(3)}a${q(7)}`)).toBe(`(<${q(3)}a${q(7)}>)`);
|
|
96
|
+
expect(parsesAs(`${q(5)}a${q(4)}`)).toBe(`(<${q(5)}a${q(4)}>)`);
|
|
97
|
+
expect(parsesAs("''a'''''")).toBe("(<> <a'''''>)");
|
|
98
|
+
expect(parsesAs("''''x a")).toBe('(<> <x> <a>)');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a reference can open inside a run', () => {
|
|
102
|
+
// Comment stripping and the stream parser ask where a reference opened at
|
|
103
|
+
// any delimiter ends, including one in the middle of a run.
|
|
104
|
+
const cases = [
|
|
105
|
+
["x'''a'''", 1, 8],
|
|
106
|
+
["x'''a''", 2, 7],
|
|
107
|
+
["x'''a'", 3, 6],
|
|
108
|
+
["'''a", 0, null],
|
|
109
|
+
["''''a", 0, 4],
|
|
110
|
+
['a', 0, null],
|
|
111
|
+
["'a'", 3, null],
|
|
112
|
+
];
|
|
113
|
+
for (const [document, start, end] of cases) {
|
|
114
|
+
expect(quotedReferenceEnd(document, start)).toBe(end);
|
|
115
|
+
expect(new DelimitedReferences(document).endAt(start)).toBe(end);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('a reference that opens with a wide run is read in linear time', () => {
|
|
120
|
+
const width = Math.floor(SIZE / 4);
|
|
121
|
+
const source = wideQuoteOverALongRun(SIZE);
|
|
122
|
+
const { result, elapsed } = timed(() => parser.parse(source));
|
|
123
|
+
// The odd opening run never closes, so it is a plain reference; the even
|
|
124
|
+
// run after `a` encloses nothing and is the empty reference.
|
|
125
|
+
expect(result.length).toBe(1);
|
|
126
|
+
expect(result[0].values.map((value) => value.id)).toEqual([
|
|
127
|
+
q(width + 1),
|
|
128
|
+
'a',
|
|
129
|
+
'',
|
|
130
|
+
'x'.repeat(2 * width),
|
|
131
|
+
]);
|
|
132
|
+
expect(elapsed).toBeLessThan(BOUND_MS);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('wide quote over a long run is parsed within the time bound', () => {
|
|
136
|
+
expectParsedWithinBound(wideQuoteOverALongRun(SIZE));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('narrowing unclosed quotes is parsed within the time bound', () => {
|
|
140
|
+
expectParsedWithinBound(narrowingUnclosedQuotes(SIZE));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('closed wide quote over quotes is parsed within the time bound', () => {
|
|
144
|
+
expectParsedWithinBound(closedWideQuoteOverQuotes(SIZE));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('unclosed widths over an even run is parsed within the time bound', () => {
|
|
148
|
+
expectParsedWithinBound(unclosedWidthsOverAnEvenRun(SIZE));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('a stream of wide quotes is read within the time bound', () => {
|
|
152
|
+
// The second line is what makes the stream ask whether the first is complete.
|
|
153
|
+
const source = `${closedWideQuoteOverQuotes(SIZE)}\nb`;
|
|
154
|
+
const links = [];
|
|
155
|
+
const { elapsed } = timed(() => {
|
|
156
|
+
const stream = new StreamParser();
|
|
157
|
+
stream.on('link', (link) => links.push(link));
|
|
158
|
+
stream.write(source);
|
|
159
|
+
stream.end();
|
|
160
|
+
});
|
|
161
|
+
expect(links.length).toBe(2);
|
|
162
|
+
expect(elapsed).toBeLessThan(BOUND_MS);
|
|
163
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Reading nested groups must take time that grows with the size of the
|
|
2
|
+
// document, not with two to the power of its nesting depth
|
|
3
|
+
// (https://github.com/link-foundation/links-notation/issues/314).
|
|
4
|
+
//
|
|
5
|
+
// Before the fix, every level of nesting doubled the work: a nine-byte document
|
|
6
|
+
// took seconds. Each case is parsed on its own thread and fails when it has not
|
|
7
|
+
// finished within the budget, so a regression fails instead of hanging.
|
|
8
|
+
|
|
9
|
+
import { test, expect } from 'bun:test';
|
|
10
|
+
import { clearTimeout, setTimeout } from 'node:timers';
|
|
11
|
+
import { URL } from 'node:url';
|
|
12
|
+
import { Worker } from 'node:worker_threads';
|
|
13
|
+
import { Parser } from '../src/Parser.js';
|
|
14
|
+
import { formatLinks } from '../src/Link.js';
|
|
15
|
+
|
|
16
|
+
// Generous enough for a slow machine, and far below what the exponential
|
|
17
|
+
// reading took at the depths used here.
|
|
18
|
+
const BUDGET_MS = 5000;
|
|
19
|
+
|
|
20
|
+
// The depth used for the shapes that used to take exponential time.
|
|
21
|
+
const DEEP = 32;
|
|
22
|
+
|
|
23
|
+
// A depth that shows the time grows with the size of the document.
|
|
24
|
+
const VERY_DEEP = 256;
|
|
25
|
+
|
|
26
|
+
function withinBudget(what, source) {
|
|
27
|
+
const worker = new Worker(
|
|
28
|
+
new URL('./support/nestingDepthWorker.js', import.meta.url)
|
|
29
|
+
);
|
|
30
|
+
let timer;
|
|
31
|
+
const timeout = new Promise((_, reject) => {
|
|
32
|
+
timer = setTimeout(
|
|
33
|
+
() => reject(new Error(`${what} did not finish within ${BUDGET_MS} ms`)),
|
|
34
|
+
BUDGET_MS
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
const result = new Promise((resolve, reject) => {
|
|
38
|
+
worker.once('message', resolve);
|
|
39
|
+
worker.once('error', reject);
|
|
40
|
+
});
|
|
41
|
+
worker.postMessage(source);
|
|
42
|
+
return Promise.race([result, timeout]).finally(() => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
worker.terminate();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const closed = (depth) => `${'('.repeat(depth)}a${')'.repeat(depth)}`;
|
|
49
|
+
|
|
50
|
+
const valueAfter = (depth) => `${'('.repeat(depth)}a${') b'.repeat(depth)}`;
|
|
51
|
+
|
|
52
|
+
const unclosed = (depth) => `${'('.repeat(depth)}a`;
|
|
53
|
+
|
|
54
|
+
// Unclosed groups on lines that are each indented one space deeper.
|
|
55
|
+
const indentedUnclosed = (depth) =>
|
|
56
|
+
Array.from({ length: depth }, (_, level) => `${' '.repeat(level)}(a`).join(
|
|
57
|
+
'\n'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
test(
|
|
61
|
+
'closed groups read in linear time',
|
|
62
|
+
async () => {
|
|
63
|
+
const source = closed(DEEP);
|
|
64
|
+
const result = await withinBudget('closed groups', source);
|
|
65
|
+
expect(result).toEqual({ formatted: source });
|
|
66
|
+
},
|
|
67
|
+
BUDGET_MS * 2
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
test(
|
|
71
|
+
'values after groups read in linear time',
|
|
72
|
+
async () => {
|
|
73
|
+
const source = valueAfter(DEEP);
|
|
74
|
+
const result = await withinBudget('values after groups', source);
|
|
75
|
+
expect(result).toEqual({ formatted: `(${source})` });
|
|
76
|
+
},
|
|
77
|
+
BUDGET_MS * 2
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
test(
|
|
81
|
+
'unclosed groups fail in linear time',
|
|
82
|
+
async () => {
|
|
83
|
+
const result = await withinBudget('unclosed groups', unclosed(DEEP));
|
|
84
|
+
expect(result).toEqual({ error: 'ParseError' });
|
|
85
|
+
},
|
|
86
|
+
BUDGET_MS * 2
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
test(
|
|
90
|
+
'unclosed groups on indented lines fail in linear time',
|
|
91
|
+
async () => {
|
|
92
|
+
const result = await withinBudget(
|
|
93
|
+
'unclosed indented groups',
|
|
94
|
+
indentedUnclosed(DEEP)
|
|
95
|
+
);
|
|
96
|
+
expect(result).toEqual({ error: 'ParseError' });
|
|
97
|
+
},
|
|
98
|
+
BUDGET_MS * 2
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
test(
|
|
102
|
+
'values after very deep groups read in linear time',
|
|
103
|
+
async () => {
|
|
104
|
+
const source = valueAfter(VERY_DEEP);
|
|
105
|
+
const result = await withinBudget('very deep values after groups', source);
|
|
106
|
+
expect(result).toEqual({ formatted: `(${source})` });
|
|
107
|
+
},
|
|
108
|
+
BUDGET_MS * 2
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
test('group followed by values keeps its structure', () => {
|
|
112
|
+
const cases = [
|
|
113
|
+
['(a) b', '((a) b)'],
|
|
114
|
+
['(a) (b) c', '((a) (b) c)'],
|
|
115
|
+
['((a) b) c', '(((a) b) c)'],
|
|
116
|
+
['(a: b) c', '((a: b) c)'],
|
|
117
|
+
['(a)\n(b) c', '(a)\n((b) c)'],
|
|
118
|
+
['x\n (a) b\n (c)', '(x)\n((x) ((a) b))\n((x) (c))'],
|
|
119
|
+
];
|
|
120
|
+
const parser = new Parser();
|
|
121
|
+
for (const [source, expected] of cases) {
|
|
122
|
+
expect(formatLinks(parser.parse(source))).toBe(expected);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Parses the documents it is sent on its own thread, so a test can stop a
|
|
2
|
+
// parse that takes too long instead of hanging.
|
|
3
|
+
import { parentPort } from 'node:worker_threads';
|
|
4
|
+
import { Parser } from '../../src/Parser.js';
|
|
5
|
+
import { formatLinks } from '../../src/Link.js';
|
|
6
|
+
|
|
7
|
+
const parser = new Parser();
|
|
8
|
+
|
|
9
|
+
parentPort.on('message', (source) => {
|
|
10
|
+
try {
|
|
11
|
+
parentPort.postMessage({ formatted: formatLinks(parser.parse(source)) });
|
|
12
|
+
} catch (error) {
|
|
13
|
+
parentPort.postMessage({ error: error.name });
|
|
14
|
+
}
|
|
15
|
+
});
|