links-notation 0.17.0 → 0.19.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,104 @@
1
+ /** The number of characters a quoted line is cut down to. */
2
+ const QUOTED_LINE_WIDTH = 80;
3
+
4
+ /** What a message writes in place of the part of a long line it left out. */
5
+ const ELLIPSIS = '...';
6
+
7
+ /**
8
+ * The error thrown when a document does not parse, carrying the position the
9
+ * parser stopped at.
10
+ *
11
+ * The generated parser reports that position, but only on the error object; the
12
+ * message it writes says what it expected without saying where. This error puts
13
+ * the line and the column in the message as well, and quotes the offending line
14
+ * with a caret under it, the way the Rust and C# ports do.
15
+ */
16
+ export class ParseError extends Error {
17
+ /**
18
+ * @param {string} input - The document that failed to parse
19
+ * @param {Error} error - The error the generated parser threw
20
+ */
21
+ constructor(input, error) {
22
+ const start = error?.location?.start ?? { offset: 0, line: 1, column: 1 };
23
+ const lineText = lineAt(input, start.offset);
24
+ const summary = `line ${start.line}, column ${start.column}: ${error.message}`;
25
+ const snippet = quote(start.line, lineText, start.column);
26
+ super(`Syntax error at ${summary}\n${snippet}`);
27
+
28
+ this.name = 'ParseError';
29
+ /** @type {Error} The error the generated parser threw */
30
+ this.cause = error;
31
+ /** @type {{start: {offset: number, line: number, column: number}}} */
32
+ this.location = error.location;
33
+ /** @type {number} Offset of the offending position from the start of the document */
34
+ this.offset = start.offset;
35
+ /** @type {number} Line the offending position is on, counted from 1 */
36
+ this.line = start.line;
37
+ /** @type {number} Column the offending position is at, counted from 1 */
38
+ this.column = start.column;
39
+ /** @type {string|null} The character found instead, or null at the end of the document */
40
+ this.found = error.found ?? null;
41
+ /** @type {string} The offending line, as written, without its line ending */
42
+ this.lineText = lineText;
43
+ /** @type {string} The offending line with a caret under the offending column */
44
+ this.snippet = snippet;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The line the given offset falls on, without its line ending.
50
+ * @param {string} input - The document being parsed
51
+ * @param {number} offset - Offset of the offending position
52
+ * @returns {string} The offending line
53
+ */
54
+ function lineAt(input, offset) {
55
+ const at = Math.max(0, Math.min(offset, input.length));
56
+ const start = input.lastIndexOf('\n', at - 1) + 1;
57
+ const end = input.indexOf('\n', start);
58
+ const line = input.slice(start, end === -1 ? input.length : end);
59
+ return line.endsWith('\r') ? line.slice(0, -1) : line;
60
+ }
61
+
62
+ /**
63
+ * The offending line with a caret under the offending column, quoted the way a
64
+ * compiler quotes source. A long line is shown as a window around the caret, so
65
+ * the message stays the same size whether the document has ten lines or fifteen
66
+ * hundred.
67
+ * @param {number} number - The line number, counted from 1
68
+ * @param {string} lineText - The offending line
69
+ * @param {number} column - The offending column, counted from 1
70
+ * @returns {string} The quoted line and the caret under it
71
+ */
72
+ function quote(number, lineText, column) {
73
+ const [quoted, at] = windowAround(lineText, column);
74
+ const gutter = ' '.repeat(String(number).length);
75
+ return `${number} | ${quoted}\n${gutter} | ${' '.repeat(at - 1)}^`;
76
+ }
77
+
78
+ /**
79
+ * Cuts a line down to a window around the given column, and says which column
80
+ * the offending character sits at in that window. Both columns count from 1.
81
+ * @param {string} lineText - The offending line
82
+ * @param {number} column - The offending column, counted from 1
83
+ * @returns {[string, number]} The window and the column within it
84
+ */
85
+ function windowAround(lineText, column) {
86
+ if (lineText.length <= QUOTED_LINE_WIDTH) {
87
+ return [lineText, column];
88
+ }
89
+
90
+ const target = column - 1;
91
+ const lastStart = lineText.length - QUOTED_LINE_WIDTH;
92
+ const start = Math.min(
93
+ Math.max(target - Math.floor(QUOTED_LINE_WIDTH / 2), 0),
94
+ lastStart
95
+ );
96
+ const end = start + QUOTED_LINE_WIDTH;
97
+
98
+ const quoted =
99
+ (start > 0 ? ELLIPSIS : '') +
100
+ lineText.slice(start, end) +
101
+ (end < lineText.length ? ELLIPSIS : '');
102
+ const shift = start > 0 ? ELLIPSIS.length : 0;
103
+ return [quoted, target - start + shift + 1];
104
+ }
package/src/Parser.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { Link } from './Link.js';
2
+ import { ParseError } from './ParseError.js';
3
+ import { stripComments } from './comments.js';
2
4
  import * as parserModule from './parser-generated.js';
3
5
 
4
6
  export class Parser {
@@ -7,10 +9,12 @@ export class Parser {
7
9
  * @param {Object} options - Parser options
8
10
  * @param {number} options.maxInputSize - Maximum input size in bytes (default: 10MB)
9
11
  * @param {number} options.maxDepth - Maximum nesting depth (default: 1000)
12
+ * @param {boolean} options.comments - If false, read `#` as an ordinary character instead of the start of a comment (default: true)
10
13
  */
11
14
  constructor(options = {}) {
12
15
  this.maxInputSize = options.maxInputSize || 10 * 1024 * 1024; // 10MB default
13
16
  this.maxDepth = options.maxDepth || 1000;
17
+ this.comments = options.comments ?? true;
14
18
  }
15
19
 
16
20
  /**
@@ -31,14 +35,21 @@ export class Parser {
31
35
  );
32
36
  }
33
37
 
38
+ // Comments are blanked rather than removed, so a position reported for the
39
+ // prepared document is the same position in the document the caller wrote.
40
+ const prepared = this.comments ? stripComments(input) : input;
41
+
34
42
  try {
35
- const rawResult = parserModule.parse(input);
43
+ const rawResult = parserModule.parse(prepared);
36
44
  return this.transformResult(rawResult);
37
45
  } catch (error) {
38
- // Preserve original error information
46
+ // A syntax error knows where it stopped; anything else is passed on with
47
+ // the original error kept as the cause.
48
+ if (error && error.location) {
49
+ throw new ParseError(input, error);
50
+ }
39
51
  const parseError = new Error(`Parse error: ${error.message}`);
40
52
  parseError.cause = error;
41
- parseError.location = error.location;
42
53
  throw parseError;
43
54
  }
44
55
  }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Comments, and the rule that decides where one starts.
3
+ *
4
+ * `#` starts a comment when it opens a token: at the start of the document or
5
+ * after a space, a tab or a line break. A `#` written inside a token
6
+ * (`issue#1047`) or inside a delimited reference (`"# not a comment"`) is an
7
+ * ordinary character.
8
+ *
9
+ * {@link stripComments} replaces every character of every comment with a space
10
+ * rather than removing it, so every later character keeps the offset it had in
11
+ * the document the caller wrote, and a parse error still points at the position
12
+ * the reader can see.
13
+ */
14
+
15
+ /** The character that starts a comment. */
16
+ export const COMMENT = '#';
17
+
18
+ /** The delimiters a reference can be written between. */
19
+ const QUOTES = ['"', "'", '`'];
20
+
21
+ /** What a delimited reference may start after. */
22
+ const BEFORE_REFERENCE = [' ', '\t', '\n', '\r', '(', ':'];
23
+
24
+ /** What a comment may start after. */
25
+ const BEFORE_COMMENT = [' ', '\t', '\n', '\r'];
26
+
27
+ /**
28
+ * The document with every comment blanked out.
29
+ * @param {string} document - The document to read
30
+ * @returns {string} The document, of the same length, without its comments
31
+ * @example
32
+ * stripComments('a: b # why\n'); // 'a: b \n'
33
+ * stripComments('"# kept"\n'); // '"# kept"\n'
34
+ * stripComments('issue#1047\n'); // 'issue#1047\n'
35
+ */
36
+ export function stripComments(document) {
37
+ // Split into UTF-16 units, the units the positions here and the positions a
38
+ // parse error reports are both counted in.
39
+ let blanked = null;
40
+ let position = 0;
41
+
42
+ while (position < document.length) {
43
+ const character = document[position];
44
+
45
+ if (
46
+ QUOTES.includes(character) &&
47
+ follows(document, position, BEFORE_REFERENCE)
48
+ ) {
49
+ const end = quotedReferenceEnd(document, position);
50
+ position = end === null ? position + 1 : end;
51
+ continue;
52
+ }
53
+
54
+ if (character === COMMENT && follows(document, position, BEFORE_COMMENT)) {
55
+ blanked = blanked ?? document.split('');
56
+ while (
57
+ position < document.length &&
58
+ document[position] !== '\n' &&
59
+ document[position] !== '\r'
60
+ ) {
61
+ blanked[position] = ' ';
62
+ position++;
63
+ }
64
+ continue;
65
+ }
66
+
67
+ position++;
68
+ }
69
+
70
+ return blanked === null ? document : blanked.join('');
71
+ }
72
+
73
+ /**
74
+ * Whether the character before the given position is one the caller allows,
75
+ * the start of the document counting as allowed.
76
+ * @param {string} document - The document being read
77
+ * @param {number} position - Position of the character being judged
78
+ * @param {string[]} allowed - The characters that may stand before it
79
+ * @returns {boolean} True when the position opens a token
80
+ */
81
+ function follows(document, position, allowed) {
82
+ return position === 0 || allowed.includes(document[position - 1]);
83
+ }
84
+
85
+ /**
86
+ * The position just past the delimited reference that starts at `start`, or
87
+ * `null` when nothing there opens one.
88
+ *
89
+ * This follows the same rules as the `parseQuotedStringAt` of the grammar: a
90
+ * run of N delimiters closes at the next run of exactly N, a run of 2N is that
91
+ * delimiter escaped, and an even run that encloses nothing substantive is the
92
+ * empty reference.
93
+ * @param {string} document - The document being read
94
+ * @param {number} start - Position of the opening delimiter
95
+ * @returns {number|null} The position just past the reference
96
+ */
97
+ export function quotedReferenceEnd(document, start) {
98
+ const quote = document[start];
99
+ if (!QUOTES.includes(quote)) {
100
+ return null;
101
+ }
102
+
103
+ let position = start;
104
+ while (position < document.length && document[position] === quote) {
105
+ position++;
106
+ }
107
+ const count = position - start;
108
+ const isEvenRun = count % 2 === 0;
109
+ const emptyReference = isEvenRun ? start + count : null;
110
+
111
+ const closing = quote.repeat(count);
112
+ const escape = quote.repeat(count * 2);
113
+ let content = '';
114
+
115
+ while (position < document.length) {
116
+ if (document.startsWith(escape, position)) {
117
+ content += closing;
118
+ position += escape.length;
119
+ continue;
120
+ }
121
+
122
+ if (document.startsWith(closing, position)) {
123
+ const afterClosing = position + count;
124
+ if (afterClosing >= document.length || document[afterClosing] !== quote) {
125
+ if (isEvenRun && !isSubstantiveBody(content)) {
126
+ return emptyReference;
127
+ }
128
+ return afterClosing;
129
+ }
130
+ }
131
+
132
+ content += document[position];
133
+ position++;
134
+ }
135
+
136
+ return emptyReference;
137
+ }
138
+
139
+ /**
140
+ * Whether a body written between an even run of delimiters carries something a
141
+ * pair of delimiters enclosing nothing cannot.
142
+ * @param {string} content - The body between the delimiters
143
+ * @returns {boolean} True when the n-quote reading is the one to take
144
+ */
145
+ function isSubstantiveBody(content) {
146
+ let depth = 0;
147
+ let hasVisible = false;
148
+
149
+ for (const character of content) {
150
+ if (character === '(') {
151
+ depth++;
152
+ } else if (character === ')') {
153
+ depth--;
154
+ if (depth < 0) {
155
+ return false;
156
+ }
157
+ }
158
+ if (!/\s/.test(character)) {
159
+ hasVisible = true;
160
+ }
161
+ }
162
+
163
+ return hasVisible && depth === 0;
164
+ }
package/src/grammar.pegjs CHANGED
@@ -264,7 +264,13 @@ PUSH_INDENTATION = spaces:" "* &{ return normalizeIndentation(spaces) > getCurre
264
264
 
265
265
  CHECK_INDENTATION = spaces:" "* &{ return checkIndentation(spaces); }
266
266
 
267
- eol = __ ([\r\n]+ / eof / nestedGroupEnd)
267
+ eol = __ (lineBreaks / eof / nestedGroupEnd)
268
+
269
+ // A line ends at the first line break, and the blank lines that follow it
270
+ // belong to that ending: a line holding nothing but spaces or tabs separates
271
+ // links exactly the way an empty line does. Blanking a comment leaves such a
272
+ // line behind, so this is also what lets a comment stand on a line of its own.
273
+ lineBreaks = [\r\n]+ ([ \t]+ [\r\n]+)*
268
274
 
269
275
  // Inside a parenthesised group the closing parenthesis terminates a line the
270
276
  // same way the end of the input does at the root.
package/src/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { Link, formatLinks } from './Link.js';
2
2
  export { LinksGroup } from './LinksGroup.js';
3
3
  export { Parser } from './Parser.js';
4
+ export { ParseError } from './ParseError.js';
4
5
  export { FormatConfig } from './FormatConfig.js';
5
6
  export { FormatOptions } from './FormatOptions.js';
7
+ export { stripComments } from './comments.js';
@@ -1487,39 +1487,173 @@ function peg$parse(input, options) {
1487
1487
  }
1488
1488
 
1489
1489
  function peg$parseeol() {
1490
- let s0, s1, s2, s3;
1490
+ let s0, s1, s2;
1491
1491
 
1492
1492
  s0 = peg$currPos;
1493
1493
  s1 = peg$parse__();
1494
- s2 = [];
1495
- s3 = input.charAt(peg$currPos);
1496
- if (peg$r1.test(s3)) {
1494
+ s2 = peg$parselineBreaks();
1495
+ if (s2 === peg$FAILED) {
1496
+ s2 = peg$parseeof();
1497
+ if (s2 === peg$FAILED) {
1498
+ s2 = peg$parsenestedGroupEnd();
1499
+ }
1500
+ }
1501
+ if (s2 !== peg$FAILED) {
1502
+ s1 = [s1, s2];
1503
+ s0 = s1;
1504
+ } else {
1505
+ peg$currPos = s0;
1506
+ s0 = peg$FAILED;
1507
+ }
1508
+
1509
+ return s0;
1510
+ }
1511
+
1512
+ function peg$parselineBreaks() {
1513
+ let s0, s1, s2, s3, s4, s5, s6;
1514
+
1515
+ s0 = peg$currPos;
1516
+ s1 = [];
1517
+ s2 = input.charAt(peg$currPos);
1518
+ if (peg$r1.test(s2)) {
1497
1519
  peg$currPos++;
1498
1520
  } else {
1499
- s3 = peg$FAILED;
1521
+ s2 = peg$FAILED;
1500
1522
  if (peg$silentFails === 0) { peg$fail(peg$e1); }
1501
1523
  }
1502
- if (s3 !== peg$FAILED) {
1503
- while (s3 !== peg$FAILED) {
1504
- s2.push(s3);
1505
- s3 = input.charAt(peg$currPos);
1506
- if (peg$r1.test(s3)) {
1524
+ if (s2 !== peg$FAILED) {
1525
+ while (s2 !== peg$FAILED) {
1526
+ s1.push(s2);
1527
+ s2 = input.charAt(peg$currPos);
1528
+ if (peg$r1.test(s2)) {
1507
1529
  peg$currPos++;
1508
1530
  } else {
1509
- s3 = peg$FAILED;
1531
+ s2 = peg$FAILED;
1510
1532
  if (peg$silentFails === 0) { peg$fail(peg$e1); }
1511
1533
  }
1512
1534
  }
1513
1535
  } else {
1514
- s2 = peg$FAILED;
1536
+ s1 = peg$FAILED;
1515
1537
  }
1516
- if (s2 === peg$FAILED) {
1517
- s2 = peg$parseeof();
1518
- if (s2 === peg$FAILED) {
1519
- s2 = peg$parsenestedGroupEnd();
1538
+ if (s1 !== peg$FAILED) {
1539
+ s2 = [];
1540
+ s3 = peg$currPos;
1541
+ s4 = [];
1542
+ s5 = input.charAt(peg$currPos);
1543
+ if (peg$r0.test(s5)) {
1544
+ peg$currPos++;
1545
+ } else {
1546
+ s5 = peg$FAILED;
1547
+ if (peg$silentFails === 0) { peg$fail(peg$e0); }
1548
+ }
1549
+ if (s5 !== peg$FAILED) {
1550
+ while (s5 !== peg$FAILED) {
1551
+ s4.push(s5);
1552
+ s5 = input.charAt(peg$currPos);
1553
+ if (peg$r0.test(s5)) {
1554
+ peg$currPos++;
1555
+ } else {
1556
+ s5 = peg$FAILED;
1557
+ if (peg$silentFails === 0) { peg$fail(peg$e0); }
1558
+ }
1559
+ }
1560
+ } else {
1561
+ s4 = peg$FAILED;
1562
+ }
1563
+ if (s4 !== peg$FAILED) {
1564
+ s5 = [];
1565
+ s6 = input.charAt(peg$currPos);
1566
+ if (peg$r1.test(s6)) {
1567
+ peg$currPos++;
1568
+ } else {
1569
+ s6 = peg$FAILED;
1570
+ if (peg$silentFails === 0) { peg$fail(peg$e1); }
1571
+ }
1572
+ if (s6 !== peg$FAILED) {
1573
+ while (s6 !== peg$FAILED) {
1574
+ s5.push(s6);
1575
+ s6 = input.charAt(peg$currPos);
1576
+ if (peg$r1.test(s6)) {
1577
+ peg$currPos++;
1578
+ } else {
1579
+ s6 = peg$FAILED;
1580
+ if (peg$silentFails === 0) { peg$fail(peg$e1); }
1581
+ }
1582
+ }
1583
+ } else {
1584
+ s5 = peg$FAILED;
1585
+ }
1586
+ if (s5 !== peg$FAILED) {
1587
+ s4 = [s4, s5];
1588
+ s3 = s4;
1589
+ } else {
1590
+ peg$currPos = s3;
1591
+ s3 = peg$FAILED;
1592
+ }
1593
+ } else {
1594
+ peg$currPos = s3;
1595
+ s3 = peg$FAILED;
1596
+ }
1597
+ while (s3 !== peg$FAILED) {
1598
+ s2.push(s3);
1599
+ s3 = peg$currPos;
1600
+ s4 = [];
1601
+ s5 = input.charAt(peg$currPos);
1602
+ if (peg$r0.test(s5)) {
1603
+ peg$currPos++;
1604
+ } else {
1605
+ s5 = peg$FAILED;
1606
+ if (peg$silentFails === 0) { peg$fail(peg$e0); }
1607
+ }
1608
+ if (s5 !== peg$FAILED) {
1609
+ while (s5 !== peg$FAILED) {
1610
+ s4.push(s5);
1611
+ s5 = input.charAt(peg$currPos);
1612
+ if (peg$r0.test(s5)) {
1613
+ peg$currPos++;
1614
+ } else {
1615
+ s5 = peg$FAILED;
1616
+ if (peg$silentFails === 0) { peg$fail(peg$e0); }
1617
+ }
1618
+ }
1619
+ } else {
1620
+ s4 = peg$FAILED;
1621
+ }
1622
+ if (s4 !== peg$FAILED) {
1623
+ s5 = [];
1624
+ s6 = input.charAt(peg$currPos);
1625
+ if (peg$r1.test(s6)) {
1626
+ peg$currPos++;
1627
+ } else {
1628
+ s6 = peg$FAILED;
1629
+ if (peg$silentFails === 0) { peg$fail(peg$e1); }
1630
+ }
1631
+ if (s6 !== peg$FAILED) {
1632
+ while (s6 !== peg$FAILED) {
1633
+ s5.push(s6);
1634
+ s6 = input.charAt(peg$currPos);
1635
+ if (peg$r1.test(s6)) {
1636
+ peg$currPos++;
1637
+ } else {
1638
+ s6 = peg$FAILED;
1639
+ if (peg$silentFails === 0) { peg$fail(peg$e1); }
1640
+ }
1641
+ }
1642
+ } else {
1643
+ s5 = peg$FAILED;
1644
+ }
1645
+ if (s5 !== peg$FAILED) {
1646
+ s4 = [s4, s5];
1647
+ s3 = s4;
1648
+ } else {
1649
+ peg$currPos = s3;
1650
+ s3 = peg$FAILED;
1651
+ }
1652
+ } else {
1653
+ peg$currPos = s3;
1654
+ s3 = peg$FAILED;
1655
+ }
1520
1656
  }
1521
- }
1522
- if (s2 !== peg$FAILED) {
1523
1657
  s1 = [s1, s2];
1524
1658
  s0 = s1;
1525
1659
  } else {
@@ -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
+ });