links-notation 0.18.0 → 0.20.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,160 @@
1
+ // Conformance tests for line comments (issue #301).
2
+ //
3
+ // `#` starts a comment that runs to the end of the line, unless it sits inside
4
+ // a token or inside a delimited reference. Parsers accept comments by default
5
+ // and can be told to treat `#` as an ordinary character again. The table below
6
+ // is shared with the Rust, Python, C#, Go, Java and PHP suites, so a document
7
+ // written by one implementation reads the same in all of them.
8
+
9
+ import { test, expect } from 'bun:test';
10
+ import {
11
+ Link,
12
+ Parser,
13
+ ParseError,
14
+ formatLinks,
15
+ stripComments,
16
+ } from '../src/index.js';
17
+
18
+ const parser = new Parser();
19
+
20
+ // Render a parsed node unambiguously: every reference is wrapped in angle
21
+ // brackets, so a `#` that survived as content is visible.
22
+ function render(node) {
23
+ if (!node.values || node.values.length === 0) {
24
+ return `<${node.id ?? ''}>`;
25
+ }
26
+ const head =
27
+ node.id === null || node.id === undefined ? '' : `<${node.id}>: `;
28
+ return `(${head}${node.values.map(render).join(' ')})`;
29
+ }
30
+
31
+ function parsesAs(input) {
32
+ return parser.parse(input).map(render).join('\n');
33
+ }
34
+
35
+ test('a line that starts with a hash is a comment', () => {
36
+ expect(parsesAs('# a b\n')).toBe('');
37
+ });
38
+
39
+ test('a comment may hold a colon', () => {
40
+ // The document from #301: prose with a colon used to be a parse error.
41
+ expect(parsesAs('# a: b\n')).toBe('');
42
+ });
43
+
44
+ test('a comment may hold anything at all', () => {
45
+ expect(parsesAs('# ) : ( " \' ` #\n')).toBe('');
46
+ });
47
+
48
+ test('a comment ends at the end of its line', () => {
49
+ expect(parsesAs('# note\na: b\n')).toBe('(<a>: <b>)');
50
+ });
51
+
52
+ test('a comment may follow a link', () => {
53
+ expect(parsesAs('a: b # why\n')).toBe('(<a>: <b>)');
54
+ });
55
+
56
+ test('a comment may follow a group', () => {
57
+ expect(parsesAs('(a b) # why\n')).toBe('(<a> <b>)');
58
+ });
59
+
60
+ test('a comment needs no closing newline', () => {
61
+ expect(parsesAs('a: b # why')).toBe('(<a>: <b>)');
62
+ });
63
+
64
+ test('a comment line inside an indented block is skipped', () => {
65
+ expect(parsesAs('parent\n # what the child is for\n child\n')).toBe(
66
+ parsesAs('parent\n child\n')
67
+ );
68
+ });
69
+
70
+ test('a comment line inside a group is skipped', () => {
71
+ expect(parsesAs('(\n a\n # why\n b\n)\n')).toBe(
72
+ parsesAs('(\n a\n b\n)\n')
73
+ );
74
+ });
75
+
76
+ test('a line of spaces separates links the way an empty line does', () => {
77
+ // Blanking a comment leaves a line of spaces behind, so such a line has to
78
+ // read as a blank line.
79
+ expect(parsesAs('a\n \nb\n')).toBe(parsesAs('a\n\nb\n'));
80
+ });
81
+
82
+ test('a document of comments alone holds no links', () => {
83
+ expect(parsesAs('# one\n# two\n')).toBe('');
84
+ });
85
+
86
+ test('a hash inside a token is an ordinary character', () => {
87
+ expect(parsesAs('issue#1047\n')).toBe('(<issue#1047>)');
88
+ });
89
+
90
+ test('a hash that opens a token is an ordinary character', () => {
91
+ expect(parsesAs('a: b#c\n')).toBe('(<a>: <b#c>)');
92
+ });
93
+
94
+ test('a hash inside a delimited reference is content', () => {
95
+ expect(parsesAs('"# not a comment" a\n')).toBe('(<# not a comment> <a>)');
96
+ });
97
+
98
+ test('a comment may follow a delimited reference', () => {
99
+ expect(parsesAs('"a" # why\n')).toBe('(<a>)');
100
+ });
101
+
102
+ test('a hash inside a multiline delimited reference is content', () => {
103
+ expect(parsesAs('"a # b\nc" d\n')).toBe('(<a # b\nc> <d>)');
104
+ });
105
+
106
+ test('a parser without comments keeps the hash', () => {
107
+ const plain = new Parser({ comments: false });
108
+
109
+ expect(plain.parse('# a b\n').map(render).join('\n')).toBe('(<#> <a> <b>)');
110
+ });
111
+
112
+ test('a parser without comments still rejects the document from the issue', () => {
113
+ const plain = new Parser({ comments: false });
114
+
115
+ expect(() => plain.parse('# a: b\n')).toThrow(ParseError);
116
+ });
117
+
118
+ test('comments are on by default', () => {
119
+ expect(new Parser().comments).toBe(true);
120
+ expect(new Parser({}).comments).toBe(true);
121
+ });
122
+
123
+ test('a comment does not move the position a later error is reported at', () => {
124
+ // Blanking a comment keeps every later character where it was, so the
125
+ // position reported for a defect is the position in the original document.
126
+ let error = null;
127
+ try {
128
+ parser.parse('# a comment\nstage: rust: nextest\n');
129
+ } catch (thrown) {
130
+ error = thrown;
131
+ }
132
+
133
+ expect(error).toBeInstanceOf(ParseError);
134
+ expect(error.line).toBe(2);
135
+ expect(error.column).toBe(12);
136
+ expect(error.lineText).toBe('stage: rust: nextest');
137
+ });
138
+
139
+ test('blanking a comment keeps the length of the document', () => {
140
+ expect(stripComments('a: b # why\n')).toBe('a: b \n');
141
+ expect(stripComments('"# kept"\n')).toBe('"# kept"\n');
142
+ expect(stripComments('issue#1047\n')).toBe('issue#1047\n');
143
+ expect(stripComments('a: b\n').length).toBe(5);
144
+ });
145
+
146
+ test('a reference that begins with a hash is written quoted', () => {
147
+ // Without the quotes the document would read as `a` followed by a comment.
148
+ const document = formatLinks([
149
+ new Link(null, [new Link('a'), new Link('#tag')]),
150
+ ]);
151
+
152
+ expect(document).toBe("(a '#tag')");
153
+ expect(parser.parse(document).map(render).join('\n')).toBe('(<a> <#tag>)');
154
+ });
155
+
156
+ test('a hash that cannot open a comment is left unquoted', () => {
157
+ expect(Link.escapeReference('issue#1047')).toBe('issue#1047');
158
+ expect(Link.escapeReference('#')).toBe("'#'");
159
+ expect(Link.escapeReference('#ff0000')).toBe("'#ff0000'");
160
+ });
@@ -22,22 +22,24 @@ function syntaxError(document) {
22
22
  }
23
23
 
24
24
  test('reports the line and column of the defect', () => {
25
- // The example from the issue: the defect is the colon on line 2, and the
26
- // two lines after it are fine.
27
- const error = syntaxError('# ok line\n# break: two\nci_gate x\n stage rust');
25
+ // The defect is the second colon on line 2, and the two lines after it are
26
+ // fine.
27
+ const error = syntaxError(
28
+ 'ci_gate x\nstage: rust: nextest\nnext stage\n clippy'
29
+ );
28
30
 
29
31
  expect(error.line).toBe(2);
30
- expect(error.column).toBe(8);
32
+ expect(error.column).toBe(12);
31
33
  expect(error.found).toBe(':');
32
34
  });
33
35
 
34
36
  test('offset agrees with the other implementations', () => {
35
- // Rust and C# report offset 17, line 2, column 8 for this document.
36
- const error = syntaxError('# ok line\n# break: two\n');
37
+ // Rust and C# report offset 21, line 2, column 12 for this document.
38
+ const error = syntaxError('ci_gate x\nstage: rust: nextest\n');
37
39
 
38
- expect(error.offset).toBe(17);
40
+ expect(error.offset).toBe(21);
39
41
  expect(error.line).toBe(2);
40
- expect(error.column).toBe(8);
42
+ expect(error.column).toBe(12);
41
43
  });
42
44
 
43
45
  test('reports the line a late defect is on', () => {
@@ -68,20 +70,20 @@ test('reports an unmatched closing parenthesis', () => {
68
70
  });
69
71
 
70
72
  test('message says where the document broke', () => {
71
- const error = syntaxError('# ok line\n# break: two\n');
73
+ const error = syntaxError('ci_gate x\nstage: rust: nextest\n');
72
74
 
73
- expect(error.message.startsWith('Syntax error at line 2, column 8:')).toBe(
75
+ expect(error.message.startsWith('Syntax error at line 2, column 12:')).toBe(
74
76
  true
75
77
  );
76
- expect(error.snippet).toBe('2 | # break: two\n | ^');
78
+ expect(error.snippet).toBe('2 | stage: rust: nextest\n | ^');
77
79
  });
78
80
 
79
81
  test('message quotes one line rather than the rest of the document', () => {
80
- const document = `# ok line\n# break: two\n${'trailing line\n'.repeat(500)}`;
82
+ const document = `ci_gate x\nstage: rust: nextest\n${'trailing line\n'.repeat(500)}`;
81
83
 
82
84
  const error = syntaxError(document);
83
85
 
84
- expect(error.message).toContain('line 2, column 8');
86
+ expect(error.message).toContain('line 2, column 12');
85
87
  expect(error.message).not.toContain('trailing line');
86
88
  expect(error.message.length).toBeLessThan(200);
87
89
  });
@@ -0,0 +1,165 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ formatLinks,
4
+ Parser,
5
+ StreamParseError,
6
+ StreamParser,
7
+ } from '../src/index.js';
8
+
9
+ const document = `first loves data
10
+ # streamed comment
11
+ profile:
12
+ name Ada
13
+ note "line one
14
+ line two"
15
+ (nested:
16
+ child value)
17
+ last sees first`;
18
+
19
+ const formatted = (links) => formatLinks(links);
20
+
21
+ describe('StreamParser', () => {
22
+ test('matches canonical parser one symbol at a time', () => {
23
+ const stream = new StreamParser();
24
+ for (const character of document) stream.write(character);
25
+
26
+ expect(formatted(stream.end())).toBe(
27
+ formatted(new Parser().parse(document))
28
+ );
29
+ });
30
+
31
+ test('supports line chunks final record and position', () => {
32
+ const stream = new StreamParser();
33
+ for (const line of document.match(/.*(?:\n|$)/g).filter(Boolean)) {
34
+ stream.write(line);
35
+ }
36
+
37
+ expect(formatted(stream.end())).toBe(
38
+ formatted(new Parser().parse(document))
39
+ );
40
+ expect(stream.position().offset).toBe(document.length);
41
+ expect(stream.position().buffered).toBe(0);
42
+ });
43
+
44
+ test('emits a record as soon as the next top-level record starts', () => {
45
+ const stream = new StreamParser();
46
+ const seen = [];
47
+ stream.on('link', (link) => seen.push(link.toString()));
48
+
49
+ expect(stream.write('first loves data\n')).toEqual([]);
50
+ expect(stream.write('s').map(String)).toEqual(['(first loves data)']);
51
+ expect(seen).toEqual(['(first loves data)']);
52
+ });
53
+
54
+ test('emits only complete records', () => {
55
+ const stream = new StreamParser();
56
+ const seen = [];
57
+ stream.on('link', (link) => seen.push(link));
58
+
59
+ for (const character of 'profile:\n name Ada\n role engineer\n') {
60
+ stream.write(character);
61
+ }
62
+ expect(seen).toEqual([]);
63
+
64
+ stream.write('n');
65
+ expect(formatted(seen)).toBe('(profile: (name Ada) (role engineer))');
66
+ });
67
+
68
+ test('does not split multiline quotes or parenthesized contexts', () => {
69
+ const stream = new StreamParser();
70
+ const seen = [];
71
+ stream.on('link', (link) => seen.push(link));
72
+
73
+ stream.write('"first\nsecond"\n(nested:\n value one)\n');
74
+ expect(seen).toHaveLength(1);
75
+ stream.write('n');
76
+ expect(seen).toHaveLength(2);
77
+ });
78
+
79
+ test('accepts a final record without a newline', () => {
80
+ const stream = new StreamParser();
81
+ stream.write('only one record');
82
+ expect(formatted(stream.end())).toBe('(only one record)');
83
+ });
84
+
85
+ test('reports positions relative to the whole stream', () => {
86
+ const stream = new StreamParser();
87
+ stream.on('error', () => {});
88
+ stream.write('good link\nnext link\nb');
89
+
90
+ let caught;
91
+ try {
92
+ stream.end('ad: value: nope');
93
+ } catch (error) {
94
+ caught = error;
95
+ }
96
+ expect(caught).toBeInstanceOf(StreamParseError);
97
+ expect(caught.line).toBe(3);
98
+ expect(caught.offset).toBeGreaterThan('good link\nnext link\n'.length);
99
+ });
100
+
101
+ test('can avoid retaining output for bounded-memory event processing', () => {
102
+ const stream = new StreamParser({ collect: false });
103
+ const seen = [];
104
+ stream.on('link', (link) => seen.push(link.toString()));
105
+ stream.write('one link\nt');
106
+ expect(stream.drain()).toEqual([]);
107
+ expect(stream.end('wo link').map(String)).toEqual(['(two link)']);
108
+ expect(seen).toEqual(['(one link)', '(two link)']);
109
+ });
110
+
111
+ test('supports drain reset and bounded memory', () => {
112
+ const stream = new StreamParser();
113
+ stream.write('one link\nt');
114
+ expect(stream.drain().map(String)).toEqual(['(one link)']);
115
+ expect(stream.end('wo link').map(String)).toEqual(['(two link)']);
116
+
117
+ stream.reset().write('fresh record');
118
+ expect(stream.end().map(String)).toEqual(['(fresh record)']);
119
+
120
+ const bounded = new StreamParser({ maxBufferSize: 4 });
121
+ bounded.on('error', () => {});
122
+ expect(() => bounded.write('12345')).toThrow(RangeError);
123
+ });
124
+
125
+ test('provides lazy adapters', async () => {
126
+ expect(
127
+ [...StreamParser.parse(['one link\n', 'two link'])].map(String)
128
+ ).toEqual(['(one link)', '(two link)']);
129
+
130
+ async function* chunks() {
131
+ yield 'one link\n';
132
+ yield 'two link';
133
+ }
134
+ const links = [];
135
+ for await (const link of StreamParser.parseAsync(chunks())) {
136
+ links.push(link.toString());
137
+ }
138
+ expect(links).toEqual(['(one link)', '(two link)']);
139
+ });
140
+
141
+ test('limits only the unresolved record, not total stream size', () => {
142
+ const stream = new StreamParser({ maxBufferSize: 12, collect: false });
143
+ stream.on('error', () => {});
144
+ for (let index = 0; index < 100; index++) stream.write(`a${index}\n`);
145
+ expect(stream.end().map(String)).toEqual(['(a99)']);
146
+ });
147
+
148
+ test('rejects writes after finish', () => {
149
+ const stream = new StreamParser();
150
+ stream.end('one');
151
+ stream.on('error', () => {});
152
+ expect(() => stream.write('two')).toThrow('after end');
153
+ });
154
+
155
+ test('tracks the absolute stream position', () => {
156
+ const stream = new StreamParser();
157
+ stream.write('one\ntw');
158
+ expect(stream.position()).toEqual({
159
+ offset: 6,
160
+ line: 2,
161
+ column: 3,
162
+ buffered: 2,
163
+ });
164
+ });
165
+ });