links-notation 0.27.0 → 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.
@@ -0,0 +1,187 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { on, once } from 'node:events';
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath, URL } from 'node:url';
5
+ import { Emitter } from '../src/Emitter.js';
6
+ import { Parser, StreamParser } from '../src/index.js';
7
+
8
+ const source = fileURLToPath(new URL('../src/index.js', import.meta.url));
9
+ const published = fileURLToPath(new URL('../dist/index.js', import.meta.url));
10
+
11
+ // Bundlers such as esbuild (`--platform=browser`) cannot resolve Node built-ins
12
+ // like `events`, so any bare import in the package graph breaks browser builds
13
+ // unless consumers add a polyfill (issue #351). Bun's own browser target would
14
+ // silently polyfill them, so this plugin fails on every bare import instead.
15
+ const rejectBareImports = (imports) => ({
16
+ name: 'reject-bare-imports',
17
+ setup(build) {
18
+ build.onResolve({ filter: /^[^./]/ }, (args) => {
19
+ imports.push(`${args.path} (from ${args.importer})`);
20
+ return { path: args.path, external: true };
21
+ });
22
+ },
23
+ });
24
+
25
+ async function bundleForBrowser(entrypoint) {
26
+ const imports = [];
27
+ const result = await globalThis.Bun.build({
28
+ entrypoints: [entrypoint],
29
+ target: 'browser',
30
+ plugins: [rejectBareImports(imports)],
31
+ });
32
+ return { result, imports };
33
+ }
34
+
35
+ describe('browser bundling (issue #351)', () => {
36
+ test('the source entry point imports no Node built-ins or packages', async () => {
37
+ const { result, imports } = await bundleForBrowser(source);
38
+ expect(result.success).toBe(true);
39
+ expect(imports).toEqual([]);
40
+ });
41
+
42
+ test('the published dist entry point imports no Node built-ins or packages', async () => {
43
+ const { result, imports } = await bundleForBrowser(published);
44
+ expect(result.success).toBe(true);
45
+ expect(imports).toEqual([]);
46
+
47
+ const text = readFileSync(published, 'utf8');
48
+ expect(text).not.toMatch(/^\s*import\s[^;]*from\s*["'][^./]/m);
49
+ expect(text).not.toMatch(/\brequire\(\s*["']/);
50
+ });
51
+
52
+ test('the dist bundle parses and streams like the source', async () => {
53
+ const dist = await import(published);
54
+ const input = '(law: text)\nnext value';
55
+ expect(dist.formatLinks(new dist.Parser().parse(input))).toBe(
56
+ dist.formatLinks(new Parser().parse(input))
57
+ );
58
+
59
+ const stream = new dist.StreamParser();
60
+ const seen = [];
61
+ stream.on('link', (link) => seen.push(link.toString()));
62
+ stream.end(input);
63
+ expect(seen).toEqual(['(law: text)', '(next value)']);
64
+ });
65
+ });
66
+
67
+ describe('Emitter', () => {
68
+ test('calls listeners in order with the emitter as this', () => {
69
+ const emitter = new Emitter();
70
+ const calls = [];
71
+ emitter.on('event', function (value) {
72
+ calls.push(['first', value, this === emitter]);
73
+ });
74
+ emitter.addListener('event', (value) => calls.push(['second', value]));
75
+ emitter.prependListener('event', (value) => calls.push(['zeroth', value]));
76
+
77
+ expect(emitter.emit('event', 1)).toBe(true);
78
+ expect(calls).toEqual([
79
+ ['zeroth', 1],
80
+ ['first', 1, true],
81
+ ['second', 1],
82
+ ]);
83
+ expect(emitter.emit('missing')).toBe(false);
84
+ });
85
+
86
+ test('once listeners run a single time', () => {
87
+ const emitter = new Emitter();
88
+ const calls = [];
89
+ emitter.once('event', (value) => calls.push(`once ${value}`));
90
+ emitter.prependOnceListener('event', (value) => calls.push(`pre ${value}`));
91
+ emitter.emit('event', 1);
92
+ emitter.emit('event', 2);
93
+ expect(calls).toEqual(['pre 1', 'once 1']);
94
+ expect(emitter.listenerCount('event')).toBe(0);
95
+ });
96
+
97
+ test('off removes the latest registration, including once listeners', () => {
98
+ const emitter = new Emitter();
99
+ const calls = [];
100
+ const listener = (value) => calls.push(value);
101
+ emitter.on('event', listener);
102
+ emitter.on('event', listener);
103
+ emitter.off('event', listener);
104
+ emitter.emit('event', 'a');
105
+ emitter.removeListener('event', listener);
106
+ emitter.emit('event', 'b');
107
+ emitter.once('event', listener);
108
+ emitter.off('event', listener);
109
+ emitter.emit('event', 'c');
110
+ expect(calls).toEqual(['a']);
111
+ expect(emitter.eventNames()).toEqual([]);
112
+ });
113
+
114
+ test('listener changes during emit take effect on the next emit', () => {
115
+ const emitter = new Emitter();
116
+ const calls = [];
117
+ const second = () => calls.push('second');
118
+ emitter.on('event', () => {
119
+ calls.push('first');
120
+ emitter.off('event', second);
121
+ emitter.on('event', () => calls.push('added'));
122
+ });
123
+ emitter.on('event', second);
124
+ emitter.emit('event');
125
+ expect(calls).toEqual(['first', 'second']);
126
+ });
127
+
128
+ test('inspects and removes listeners', () => {
129
+ const emitter = new Emitter();
130
+ const listener = () => {};
131
+ emitter.on('a', listener).on('b', listener).on('b', listener);
132
+ expect(emitter.listeners('b')).toEqual([listener, listener]);
133
+ expect(emitter.listenerCount('b')).toBe(2);
134
+ expect(emitter.eventNames()).toEqual(['a', 'b']);
135
+ emitter.removeAllListeners('b');
136
+ expect(emitter.eventNames()).toEqual(['a']);
137
+ emitter.removeAllListeners();
138
+ expect(emitter.listenerCount('a')).toBe(0);
139
+ });
140
+
141
+ test('an unhandled error event throws like Node.js', () => {
142
+ const emitter = new Emitter();
143
+ const error = new Error('boom');
144
+ expect(() => emitter.emit('error', error)).toThrow(error);
145
+ expect(() => emitter.emit('error', 'text')).toThrow(
146
+ 'Unhandled error. (text)'
147
+ );
148
+ emitter.on('error', () => {});
149
+ expect(emitter.emit('error', error)).toBe(true);
150
+ });
151
+
152
+ test('rejects listeners that are not functions', () => {
153
+ expect(() => new Emitter().on('event', 'nope')).toThrow(TypeError);
154
+ });
155
+
156
+ test('works with the Node.js events.once helper', async () => {
157
+ const stream = new StreamParser();
158
+ const ended = once(stream, 'end');
159
+ stream.end('a b');
160
+ const [links] = await ended;
161
+ expect(links.map(String)).toEqual(['(a b)']);
162
+ });
163
+
164
+ test('works with the Node.js events.on async iterator', async () => {
165
+ const stream = new StreamParser();
166
+ const controller = new globalThis.AbortController();
167
+ const links = on(stream, 'link', { signal: controller.signal });
168
+ globalThis.setTimeout(() => stream.end('a b\nc d'), 0);
169
+ const seen = [];
170
+ try {
171
+ for await (const [link] of links) {
172
+ seen.push(String(link));
173
+ if (seen.length === 2) controller.abort();
174
+ }
175
+ } catch (error) {
176
+ if (error.name !== 'AbortError') throw error;
177
+ }
178
+ expect(seen).toEqual(['(a b)', '(c d)']);
179
+ expect(stream.listenerCount('link')).toBe(0);
180
+ expect(stream.listenerCount('error')).toBe(0);
181
+ });
182
+
183
+ test('StreamParser still throws the stream error without a listener', () => {
184
+ const stream = new StreamParser();
185
+ expect(() => stream.write(1)).toThrow(TypeError);
186
+ });
187
+ });
@@ -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
+ });
@@ -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
+ });