links-notation 0.21.2 → 0.22.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 +19 -3
- package/README.ru.md +17 -0
- package/dist/index.js +383 -135
- package/index.d.ts +15 -1
- package/package.json +1 -1
- package/src/ParseError.js +8 -1
- package/src/Parser.js +12 -3
- package/src/grammar.pegjs +85 -5
- package/src/index.js +1 -1
- package/src/parser-generated.js +382 -127
- package/tests/NestingDepth.test.js +124 -0
- package/tests/NestingLimit.test.js +132 -0
- package/tests/support/nestingDepthWorker.js +17 -0
|
@@ -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,132 @@
|
|
|
1
|
+
// Links nested too deeply are refused with an error rather than recursed into
|
|
2
|
+
// until the stack overflows
|
|
3
|
+
// (https://github.com/link-foundation/links-notation/issues/315).
|
|
4
|
+
//
|
|
5
|
+
// Every parenthesized group and every indentation level is one level, and the
|
|
6
|
+
// lines of a document start at level 0. The positions asserted here are the
|
|
7
|
+
// ones the Rust port reports for the same input.
|
|
8
|
+
|
|
9
|
+
import { test, expect } from 'bun:test';
|
|
10
|
+
import {
|
|
11
|
+
Parser,
|
|
12
|
+
ParseError,
|
|
13
|
+
StreamParser,
|
|
14
|
+
StreamParseError,
|
|
15
|
+
DEFAULT_MAX_DEPTH,
|
|
16
|
+
} from '../src/index.js';
|
|
17
|
+
|
|
18
|
+
const parens = (depth) => '('.repeat(depth) + 'a' + ')'.repeat(depth);
|
|
19
|
+
|
|
20
|
+
const values = (depth) => '(a '.repeat(depth) + 'b' + ')'.repeat(depth);
|
|
21
|
+
|
|
22
|
+
const indentation = (depth) =>
|
|
23
|
+
Array.from(
|
|
24
|
+
{ length: depth + 1 },
|
|
25
|
+
(_, level) => ' '.repeat(level) + 'a\n'
|
|
26
|
+
).join('');
|
|
27
|
+
|
|
28
|
+
function tooDeep(document, maxDepth) {
|
|
29
|
+
try {
|
|
30
|
+
const links = new Parser({ maxDepth }).parse(document);
|
|
31
|
+
throw new Error(
|
|
32
|
+
`expected ${JSON.stringify(document)} to be too deep, got ${links.length} links`
|
|
33
|
+
);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
expect(error).toBeInstanceOf(ParseError);
|
|
36
|
+
expect(error.maxDepth).toBe(maxDepth);
|
|
37
|
+
return error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const accepted = (document, maxDepth) =>
|
|
42
|
+
new Parser({ maxDepth }).parse(document).length > 0;
|
|
43
|
+
|
|
44
|
+
test('the default limit is shared by every implementation', () => {
|
|
45
|
+
expect(DEFAULT_MAX_DEPTH).toBe(64);
|
|
46
|
+
expect(new Parser().maxDepth).toBe(DEFAULT_MAX_DEPTH);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('parentheses up to the limit are accepted', () => {
|
|
50
|
+
expect(accepted(parens(3), 3)).toBe(true);
|
|
51
|
+
expect(accepted(values(3), 3)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('parentheses past the limit are refused at the group that is too deep', () => {
|
|
55
|
+
const error = tooDeep(parens(4), 3);
|
|
56
|
+
|
|
57
|
+
expect([error.line, error.column, error.offset]).toEqual([1, 4, 3]);
|
|
58
|
+
expect(error.message).toBe(
|
|
59
|
+
'Nesting too deep at line 1, column 4: nesting depth exceeds the maximum of 3\n' +
|
|
60
|
+
'1 | ((((a))))\n' +
|
|
61
|
+
' | ^'
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('groups in value position count like any other group', () => {
|
|
66
|
+
const error = tooDeep(values(4), 3);
|
|
67
|
+
|
|
68
|
+
expect([error.line, error.column]).toEqual([1, 10]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('indentation up to the limit is accepted', () => {
|
|
72
|
+
expect(accepted(indentation(3), 3)).toBe(true);
|
|
73
|
+
expect(accepted(indentation(DEFAULT_MAX_DEPTH), undefined)).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('indentation past the limit is refused at the line that is too deep', () => {
|
|
77
|
+
const error = tooDeep(indentation(4), 3);
|
|
78
|
+
|
|
79
|
+
expect([error.line, error.column]).toEqual([5, 5]);
|
|
80
|
+
expect(error.lineText).toBe(' a');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('groups and indentation add up', () => {
|
|
84
|
+
// `(b)` on the line indented once is at level 2.
|
|
85
|
+
expect(accepted('a\n (b)\n', 2)).toBe(true);
|
|
86
|
+
const error = tooDeep('a\n (b)\n', 1);
|
|
87
|
+
|
|
88
|
+
expect([error.line, error.column]).toEqual([2, 3]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('trailing spaces on a deep line are not a deeper line', () => {
|
|
92
|
+
expect(accepted('a\n b\n c \n', 2)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a syntax error is not mistaken for nesting that is too deep', () => {
|
|
96
|
+
try {
|
|
97
|
+
new Parser().parse('a: b: c');
|
|
98
|
+
throw new Error('expected a syntax error');
|
|
99
|
+
} catch (error) {
|
|
100
|
+
expect(error).toBeInstanceOf(ParseError);
|
|
101
|
+
expect(error.maxDepth).toBeNull();
|
|
102
|
+
expect(error.message).toStartWith('Syntax error at ');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('refuses a document far past the limit instead of overflowing the stack', () => {
|
|
107
|
+
// Before the limit existed each of these ran out of stack.
|
|
108
|
+
for (const document of [
|
|
109
|
+
parens(100_000),
|
|
110
|
+
values(100_000),
|
|
111
|
+
indentation(2_000),
|
|
112
|
+
]) {
|
|
113
|
+
const error = tooDeep(document, DEFAULT_MAX_DEPTH);
|
|
114
|
+
expect(error.message).toStartWith('Nesting too deep at ');
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('the stream parser reports where the nesting is too deep', () => {
|
|
119
|
+
const stream = new StreamParser({ maxDepth: 1 });
|
|
120
|
+
stream.write('a\nb ((c))\n');
|
|
121
|
+
|
|
122
|
+
let error;
|
|
123
|
+
try {
|
|
124
|
+
stream.end();
|
|
125
|
+
} catch (thrown) {
|
|
126
|
+
error = thrown;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
expect(error).toBeInstanceOf(StreamParseError);
|
|
130
|
+
expect(error.cause.maxDepth).toBe(1);
|
|
131
|
+
expect([error.line, error.column, error.offset]).toEqual([2, 4, 5]);
|
|
132
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
// The documents nest deeper than the default limit on purpose; only the time
|
|
8
|
+
// they take is measured here.
|
|
9
|
+
const parser = new Parser({ maxDepth: Infinity });
|
|
10
|
+
|
|
11
|
+
parentPort.on('message', (source) => {
|
|
12
|
+
try {
|
|
13
|
+
parentPort.postMessage({ formatted: formatLinks(parser.parse(source)) });
|
|
14
|
+
} catch (error) {
|
|
15
|
+
parentPort.postMessage({ error: error.name });
|
|
16
|
+
}
|
|
17
|
+
});
|