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/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
+ });