links-notation 0.27.1 → 0.30.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/src/quotes.js CHANGED
@@ -43,8 +43,10 @@ export class DelimitedReferences {
43
43
  /**
44
44
  * The reference opened at `start`.
45
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
46
+ * @returns {{value: string, length: number, closed: boolean}|null} What the
47
+ * reference holds, how many characters it takes and whether a run closes
48
+ * it (an even run nothing closes is the empty reference, until a closing
49
+ * run is written after it), or null when nothing opens there
48
50
  */
49
51
  readAt(start) {
50
52
  if (!this.readings.has(start)) {
@@ -103,8 +105,9 @@ export class DelimitedReferences {
103
105
  * reference that is as quick, since it is read once.
104
106
  * @param {string} document - The document being read
105
107
  * @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
+ * @returns {{value: string, length: number, closed: boolean}|null} What the
109
+ * reference holds, how many characters it takes and whether a run closes it,
110
+ * or null when nothing opens there
108
111
  */
109
112
  export function readReference(document, start) {
110
113
  const quote = document[start];
@@ -131,10 +134,13 @@ export function readReference(document, start) {
131
134
  * by the run that ends at `end` when there is one.
132
135
  */
133
136
  function reading(document, start, quote, count, end) {
134
- const emptyReference = count % 2 === 0 ? { value: '', length: count } : null;
135
137
  if (end === null) {
136
- return emptyReference;
138
+ // Nothing closes the run: an even one is the empty reference, which a
139
+ // later closing run would still turn into one holding what follows.
140
+ return count % 2 === 0 ? { value: '', length: count, closed: false } : null;
137
141
  }
142
+ const emptyReference =
143
+ count % 2 === 0 ? { value: '', length: count, closed: true } : null;
138
144
 
139
145
  const parts = [];
140
146
  let position = start + count;
@@ -154,7 +160,7 @@ function reading(document, start, quote, count, end) {
154
160
  if (emptyReference !== null && !isSubstantiveBody(value)) {
155
161
  return emptyReference;
156
162
  }
157
- return { value, length: end - start };
163
+ return { value, length: end - start, closed: true };
158
164
  }
159
165
 
160
166
  /** The number of `quote` delimiters in a row from `start` of `text`. */
package/src/references.js CHANGED
@@ -61,3 +61,21 @@ export function unescapeReference(reference) {
61
61
  }
62
62
  return reading.value;
63
63
  }
64
+
65
+ /**
66
+ * The delimiter a reference was written between, read from the reference as it
67
+ * was written: `'`, `"` or `` ` `` for a quoted reference (however many of them
68
+ * open it), and null for a bare reference, a versioned literal, or no text.
69
+ * @param {string|null|undefined} raw - One reference exactly as written
70
+ * @returns {string|null} The delimiter character, or null
71
+ * @example
72
+ * referenceQuote("'1'"); // "'"
73
+ * referenceQuote('""a b""'); // '"'
74
+ * referenceQuote('1'); // null
75
+ * referenceQuote("'unclosed"); // null: a bare reference that starts with '
76
+ */
77
+ export function referenceQuote(raw) {
78
+ if (typeof raw !== 'string' || !QUOTES.includes(raw[0])) return null;
79
+ const reading = readReference(raw, 0);
80
+ return reading !== null && reading.length === raw.length ? raw[0] : null;
81
+ }
@@ -158,3 +158,34 @@ test('a hash that cannot open a comment is left unquoted', () => {
158
158
  expect(Link.escapeReference('#')).toBe("'#'");
159
159
  expect(Link.escapeReference('#ff0000')).toBe("'#ff0000'");
160
160
  });
161
+
162
+ // Issue #377: the comment reader and the grammar must agree on where every
163
+ // delimited reference starts and how far it reaches, or a comment is blanked
164
+ // inside what the grammar then reads as a reference.
165
+
166
+ test('a hash inside a reference that follows a group is content', () => {
167
+ expect(parsesAs('(a)" #b"\n')).toBe('((<a>) < #b>)');
168
+ });
169
+
170
+ test('a hash inside a reference that follows another reference is content', () => {
171
+ expect(parsesAs('"x"\' #y\'\n')).toBe('(<x> < #y>)');
172
+ expect(parsesAs('```q```"" #c\n"""\n')).toBe('(<q> < #c\n">)');
173
+ });
174
+
175
+ test('a hash inside a reference that follows a literal is content', () => {
176
+ expect(parsesAs('~1{62}" #b"\n')).toBe('(<b> < #b>)');
177
+ });
178
+
179
+ test('a reference is read in the document as written, comments included', () => {
180
+ // The body of the first `""` straddles a parenthesis, so it is the empty
181
+ // reference and the `#` after it opens a comment. Blanking that comment
182
+ // must not turn the `""` into a reference that holds the blanked text.
183
+ expect(parsesAs('"" a #)b\n""\n')).toBe('(<> <a>)\n(<>)');
184
+ });
185
+
186
+ test('the comment reader skips the references the grammar reads', () => {
187
+ expect(stripComments('(a)" #b"\n')).toBe('(a)" #b"\n');
188
+ expect(stripComments('"x"\' #y\'\n')).toBe('"x"\' #y\'\n');
189
+ expect(stripComments('~1{62}" #b"\n')).toBe('~1{62}" #b"\n');
190
+ expect(stripComments('a"b #c"\n')).toBe('a"b \n');
191
+ });
@@ -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
+ });
@@ -162,4 +162,17 @@ describe('StreamParser', () => {
162
162
  buffered: 2,
163
163
  });
164
164
  });
165
+
166
+ test('waits for the line that closes an even run of quotes (issue #377)', () => {
167
+ // `""` is the empty reference only when nothing closes it, and here the
168
+ // next line does, so the first line cannot be emitted on its own.
169
+ const input = 'a "" b\n"" c\n';
170
+ const stream = new StreamParser();
171
+ for (const character of input) {
172
+ expect(stream.write(character)).toEqual([]);
173
+ }
174
+ expect(formatLinks(stream.end())).toBe(
175
+ formatLinks(new Parser().parse(input))
176
+ );
177
+ });
165
178
  });
@@ -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
+ });