links-notation 0.18.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.
package/README.md CHANGED
@@ -252,6 +252,39 @@ const links = new Parser().parse(`value (
252
252
  console.log(formatLinks(links)); // (value ((id 1) (label one)))
253
253
  ```
254
254
 
255
+ ### Comments
256
+
257
+ A `#` hides the rest of the line it stands on, so a document can carry prose
258
+ about itself:
259
+
260
+ ```lino
261
+ # the machines this deploys to
262
+ deploy: staging # only staging, for now
263
+ ```
264
+
265
+ Both comments are gone by the time the document is read, leaving the single
266
+ link `(deploy: staging)`. A `#` only opens a comment where a reference could
267
+ begin, so a `#` inside a token (`issue#1047`) and a `#` inside a delimited
268
+ reference (`"#"`) stay ordinary characters.
269
+
270
+ A formatter keeps the same rule from the other side: a reference that begins
271
+ with a `#` is written quoted (`'#tag'`), so a document it writes reads back as
272
+ itself.
273
+
274
+ Comments are on by default, and a parser can be told to read `#` as an ordinary
275
+ character again, for documents written before comments existed:
276
+
277
+ ```javascript
278
+ import { Parser, formatLinks } from 'links-notation';
279
+
280
+ const document =
281
+ '# the machines this deploys to\ndeploy: staging # only staging, for now\n';
282
+ console.log(formatLinks(new Parser().parse(document))); // (deploy: staging)
283
+
284
+ const plain = new Parser({ comments: false });
285
+ console.log(formatLinks(plain.parse('# a b\n'))); // (# a b)
286
+ ```
287
+
255
288
  ## API Reference
256
289
 
257
290
  ### Classes
@@ -260,6 +293,8 @@ console.log(formatLinks(links)); // (value ((id 1) (label one)))
260
293
 
261
294
  Main parser class for converting strings to links.
262
295
 
296
+ - `constructor(options)` - Create a parser; `options.comments` set to `false`
297
+ reads `#` as an ordinary character instead of the start of a comment
263
298
  - `initialize()` - Initialize the parser (async)
264
299
  - `parse(input)` - Parse a Lino string and return links
265
300
 
@@ -288,7 +323,7 @@ where the document stopped making sense and quotes the offending line:
288
323
  import { Parser, ParseError } from 'links-notation';
289
324
 
290
325
  try {
291
- new Parser().parse('# ok line\n# break: two\nci_gate x\n');
326
+ new Parser().parse('ci_gate x\nstage: rust: nextest\n');
292
327
  } catch (error) {
293
328
  console.error(error.message);
294
329
  if (error instanceof ParseError) {
@@ -298,9 +333,9 @@ try {
298
333
  ```
299
334
 
300
335
  ```text
301
- Syntax error at line 2, column 8: Expected "(", [ \t], [\r\n], or [^ \t\n\r(:)] but ":" found.
302
- 2 | # break: two
303
- | ^
336
+ Syntax error at line 2, column 12: Expected "(", [ \t], [\r\n], or [^ \t\n\r(:)] but ":" found.
337
+ 2 | stage: rust: nextest
338
+ | ^
304
339
  ```
305
340
 
306
341
  - `offset` - Offset of the offending position from the start of the document
package/README.ru.md CHANGED
@@ -171,6 +171,39 @@ const links = new Parser().parse(`value (
171
171
  console.log(formatLinks(links)); // (value ((id 1) (label one)))
172
172
  ```
173
173
 
174
+ ### Комментарии
175
+
176
+ `#` скрывает остаток строки, на которой стоит, поэтому документ может нести
177
+ пояснения о самом себе:
178
+
179
+ ```lino
180
+ # машины, на которые идёт выкладка
181
+ deploy: staging # пока только staging
182
+ ```
183
+
184
+ К моменту чтения документа обоих комментариев уже нет, остаётся одна связь
185
+ `(deploy: staging)`. `#` открывает комментарий только там, где могла бы
186
+ начаться ссылка, поэтому `#` внутри токена (`issue#1047`) и `#` внутри ссылки
187
+ в кавычках (`"#"`) остаются обычными символами.
188
+
189
+ Форматтер соблюдает то же правило с другой стороны: ссылка, начинающаяся с `#`,
190
+ записывается в кавычках (`'#tag'`), поэтому написанный им документ читается
191
+ обратно как он сам.
192
+
193
+ Комментарии включены по умолчанию, а парсеру можно велеть снова читать `#` как
194
+ обычный символ - для документов, написанных до появления комментариев:
195
+
196
+ ```javascript
197
+ import { Parser, formatLinks } from 'links-notation';
198
+
199
+ const document =
200
+ '# машины, на которые идёт выкладка\ndeploy: staging # пока только staging\n';
201
+ console.log(formatLinks(new Parser().parse(document))); // (deploy: staging)
202
+
203
+ const plain = new Parser({ comments: false });
204
+ console.log(formatLinks(plain.parse('# a b\n'))); // (# a b)
205
+ ```
206
+
174
207
  ## Справочник API
175
208
 
176
209
  ### Классы
@@ -179,6 +212,8 @@ console.log(formatLinks(links)); // (value ((id 1) (label one)))
179
212
 
180
213
  Основной класс парсера для преобразования строк в связи.
181
214
 
215
+ - `constructor(options)` - Создать парсер; `options.comments` со значением
216
+ `false` читает `#` как обычный символ, а не как начало комментария
182
217
  - `initialize()` - Инициализация парсера (асинхронно)
183
218
  - `parse(input)` - Парсинг строки Lino и возвращение связей
184
219
 
@@ -207,7 +242,7 @@ console.log(formatLinks(links)); // (value ((id 1) (label one)))
207
242
  import { Parser, ParseError } from 'links-notation';
208
243
 
209
244
  try {
210
- new Parser().parse('# ok line\n# break: two\nci_gate x\n');
245
+ new Parser().parse('ci_gate x\nstage: rust: nextest\n');
211
246
  } catch (error) {
212
247
  console.error(error.message);
213
248
  if (error instanceof ParseError) {
@@ -217,9 +252,9 @@ try {
217
252
  ```
218
253
 
219
254
  ```text
220
- Syntax error at line 2, column 8: Expected "(", [ \t], [\r\n], or [^ \t\n\r(:)] but ":" found.
221
- 2 | # break: two
222
- | ^
255
+ Syntax error at line 2, column 12: Expected "(", [ \t], [\r\n], or [^ \t\n\r(:)] but ":" found.
256
+ 2 | stage: rust: nextest
257
+ | ^
223
258
  ```
224
259
 
225
260
  - `offset` - Смещение сломанной позиции от начала документа
package/dist/index.js CHANGED
@@ -44,7 +44,7 @@ class Link {
44
44
  }
45
45
  const hasSingleQuote = reference.includes("'");
46
46
  const hasDoubleQuote = reference.includes('"');
47
- const needsQuoting = reference.includes(":") || reference.includes("(") || reference.includes(")") || reference.includes(" ") || reference.includes("\t") || reference.includes(`
47
+ const needsQuoting = reference.startsWith("#") || reference.includes(":") || reference.includes("(") || reference.includes(")") || reference.includes(" ") || reference.includes("\t") || reference.includes(`
48
48
  `) || reference.includes("\r") || hasDoubleQuote || hasSingleQuote;
49
49
  if (hasSingleQuote && hasDoubleQuote) {
50
50
  return `'${reference.replace(/'/g, "\\'")}'`;
@@ -320,6 +320,93 @@ function windowAround(lineText, column) {
320
320
  return [quoted, target - start + shift + 1];
321
321
  }
322
322
 
323
+ // src/comments.js
324
+ var COMMENT = "#";
325
+ var QUOTES = ['"', "'", "`"];
326
+ var BEFORE_REFERENCE = [" ", "\t", `
327
+ `, "\r", "(", ":"];
328
+ var BEFORE_COMMENT = [" ", "\t", `
329
+ `, "\r"];
330
+ function stripComments(document) {
331
+ let blanked = null;
332
+ let position = 0;
333
+ while (position < document.length) {
334
+ const character = document[position];
335
+ if (QUOTES.includes(character) && follows(document, position, BEFORE_REFERENCE)) {
336
+ const end = quotedReferenceEnd(document, position);
337
+ position = end === null ? position + 1 : end;
338
+ continue;
339
+ }
340
+ if (character === COMMENT && follows(document, position, BEFORE_COMMENT)) {
341
+ blanked = blanked ?? document.split("");
342
+ while (position < document.length && document[position] !== `
343
+ ` && document[position] !== "\r") {
344
+ blanked[position] = " ";
345
+ position++;
346
+ }
347
+ continue;
348
+ }
349
+ position++;
350
+ }
351
+ return blanked === null ? document : blanked.join("");
352
+ }
353
+ function follows(document, position, allowed) {
354
+ return position === 0 || allowed.includes(document[position - 1]);
355
+ }
356
+ function quotedReferenceEnd(document, start) {
357
+ const quote = document[start];
358
+ if (!QUOTES.includes(quote)) {
359
+ return null;
360
+ }
361
+ let position = start;
362
+ while (position < document.length && document[position] === quote) {
363
+ position++;
364
+ }
365
+ const count = position - start;
366
+ const isEvenRun = count % 2 === 0;
367
+ const emptyReference = isEvenRun ? start + count : null;
368
+ const closing = quote.repeat(count);
369
+ const escape = quote.repeat(count * 2);
370
+ let content = "";
371
+ while (position < document.length) {
372
+ if (document.startsWith(escape, position)) {
373
+ content += closing;
374
+ position += escape.length;
375
+ continue;
376
+ }
377
+ if (document.startsWith(closing, position)) {
378
+ const afterClosing = position + count;
379
+ if (afterClosing >= document.length || document[afterClosing] !== quote) {
380
+ if (isEvenRun && !isSubstantiveBody(content)) {
381
+ return emptyReference;
382
+ }
383
+ return afterClosing;
384
+ }
385
+ }
386
+ content += document[position];
387
+ position++;
388
+ }
389
+ return emptyReference;
390
+ }
391
+ function isSubstantiveBody(content) {
392
+ let depth = 0;
393
+ let hasVisible = false;
394
+ for (const character of content) {
395
+ if (character === "(") {
396
+ depth++;
397
+ } else if (character === ")") {
398
+ depth--;
399
+ if (depth < 0) {
400
+ return false;
401
+ }
402
+ }
403
+ if (!/\s/.test(character)) {
404
+ hasVisible = true;
405
+ }
406
+ }
407
+ return hasVisible && depth === 0;
408
+ }
409
+
323
410
  // src/parser-generated.js
324
411
  class peg$SyntaxError extends SyntaxError {
325
412
  constructor(message, expected, found, location) {
@@ -1751,42 +1838,189 @@ function peg$parse(input, options) {
1751
1838
  return s0;
1752
1839
  }
1753
1840
  function peg$parseeol() {
1754
- let s0, s1, s2, s3;
1841
+ let s0, s1, s2;
1755
1842
  s0 = peg$currPos;
1756
1843
  s1 = peg$parse__();
1757
- s2 = [];
1758
- s3 = input.charAt(peg$currPos);
1759
- if (peg$r1.test(s3)) {
1844
+ s2 = peg$parselineBreaks();
1845
+ if (s2 === peg$FAILED) {
1846
+ s2 = peg$parseeof();
1847
+ if (s2 === peg$FAILED) {
1848
+ s2 = peg$parsenestedGroupEnd();
1849
+ }
1850
+ }
1851
+ if (s2 !== peg$FAILED) {
1852
+ s1 = [s1, s2];
1853
+ s0 = s1;
1854
+ } else {
1855
+ peg$currPos = s0;
1856
+ s0 = peg$FAILED;
1857
+ }
1858
+ return s0;
1859
+ }
1860
+ function peg$parselineBreaks() {
1861
+ let s0, s1, s2, s3, s4, s5, s6;
1862
+ s0 = peg$currPos;
1863
+ s1 = [];
1864
+ s2 = input.charAt(peg$currPos);
1865
+ if (peg$r1.test(s2)) {
1760
1866
  peg$currPos++;
1761
1867
  } else {
1762
- s3 = peg$FAILED;
1868
+ s2 = peg$FAILED;
1763
1869
  if (peg$silentFails === 0) {
1764
1870
  peg$fail(peg$e1);
1765
1871
  }
1766
1872
  }
1767
- if (s3 !== peg$FAILED) {
1768
- while (s3 !== peg$FAILED) {
1769
- s2.push(s3);
1770
- s3 = input.charAt(peg$currPos);
1771
- if (peg$r1.test(s3)) {
1873
+ if (s2 !== peg$FAILED) {
1874
+ while (s2 !== peg$FAILED) {
1875
+ s1.push(s2);
1876
+ s2 = input.charAt(peg$currPos);
1877
+ if (peg$r1.test(s2)) {
1772
1878
  peg$currPos++;
1773
1879
  } else {
1774
- s3 = peg$FAILED;
1880
+ s2 = peg$FAILED;
1775
1881
  if (peg$silentFails === 0) {
1776
1882
  peg$fail(peg$e1);
1777
1883
  }
1778
1884
  }
1779
1885
  }
1780
1886
  } else {
1781
- s2 = peg$FAILED;
1887
+ s1 = peg$FAILED;
1782
1888
  }
1783
- if (s2 === peg$FAILED) {
1784
- s2 = peg$parseeof();
1785
- if (s2 === peg$FAILED) {
1786
- s2 = peg$parsenestedGroupEnd();
1889
+ if (s1 !== peg$FAILED) {
1890
+ s2 = [];
1891
+ s3 = peg$currPos;
1892
+ s4 = [];
1893
+ s5 = input.charAt(peg$currPos);
1894
+ if (peg$r0.test(s5)) {
1895
+ peg$currPos++;
1896
+ } else {
1897
+ s5 = peg$FAILED;
1898
+ if (peg$silentFails === 0) {
1899
+ peg$fail(peg$e0);
1900
+ }
1901
+ }
1902
+ if (s5 !== peg$FAILED) {
1903
+ while (s5 !== peg$FAILED) {
1904
+ s4.push(s5);
1905
+ s5 = input.charAt(peg$currPos);
1906
+ if (peg$r0.test(s5)) {
1907
+ peg$currPos++;
1908
+ } else {
1909
+ s5 = peg$FAILED;
1910
+ if (peg$silentFails === 0) {
1911
+ peg$fail(peg$e0);
1912
+ }
1913
+ }
1914
+ }
1915
+ } else {
1916
+ s4 = peg$FAILED;
1917
+ }
1918
+ if (s4 !== peg$FAILED) {
1919
+ s5 = [];
1920
+ s6 = input.charAt(peg$currPos);
1921
+ if (peg$r1.test(s6)) {
1922
+ peg$currPos++;
1923
+ } else {
1924
+ s6 = peg$FAILED;
1925
+ if (peg$silentFails === 0) {
1926
+ peg$fail(peg$e1);
1927
+ }
1928
+ }
1929
+ if (s6 !== peg$FAILED) {
1930
+ while (s6 !== peg$FAILED) {
1931
+ s5.push(s6);
1932
+ s6 = input.charAt(peg$currPos);
1933
+ if (peg$r1.test(s6)) {
1934
+ peg$currPos++;
1935
+ } else {
1936
+ s6 = peg$FAILED;
1937
+ if (peg$silentFails === 0) {
1938
+ peg$fail(peg$e1);
1939
+ }
1940
+ }
1941
+ }
1942
+ } else {
1943
+ s5 = peg$FAILED;
1944
+ }
1945
+ if (s5 !== peg$FAILED) {
1946
+ s4 = [s4, s5];
1947
+ s3 = s4;
1948
+ } else {
1949
+ peg$currPos = s3;
1950
+ s3 = peg$FAILED;
1951
+ }
1952
+ } else {
1953
+ peg$currPos = s3;
1954
+ s3 = peg$FAILED;
1955
+ }
1956
+ while (s3 !== peg$FAILED) {
1957
+ s2.push(s3);
1958
+ s3 = peg$currPos;
1959
+ s4 = [];
1960
+ s5 = input.charAt(peg$currPos);
1961
+ if (peg$r0.test(s5)) {
1962
+ peg$currPos++;
1963
+ } else {
1964
+ s5 = peg$FAILED;
1965
+ if (peg$silentFails === 0) {
1966
+ peg$fail(peg$e0);
1967
+ }
1968
+ }
1969
+ if (s5 !== peg$FAILED) {
1970
+ while (s5 !== peg$FAILED) {
1971
+ s4.push(s5);
1972
+ s5 = input.charAt(peg$currPos);
1973
+ if (peg$r0.test(s5)) {
1974
+ peg$currPos++;
1975
+ } else {
1976
+ s5 = peg$FAILED;
1977
+ if (peg$silentFails === 0) {
1978
+ peg$fail(peg$e0);
1979
+ }
1980
+ }
1981
+ }
1982
+ } else {
1983
+ s4 = peg$FAILED;
1984
+ }
1985
+ if (s4 !== peg$FAILED) {
1986
+ s5 = [];
1987
+ s6 = input.charAt(peg$currPos);
1988
+ if (peg$r1.test(s6)) {
1989
+ peg$currPos++;
1990
+ } else {
1991
+ s6 = peg$FAILED;
1992
+ if (peg$silentFails === 0) {
1993
+ peg$fail(peg$e1);
1994
+ }
1995
+ }
1996
+ if (s6 !== peg$FAILED) {
1997
+ while (s6 !== peg$FAILED) {
1998
+ s5.push(s6);
1999
+ s6 = input.charAt(peg$currPos);
2000
+ if (peg$r1.test(s6)) {
2001
+ peg$currPos++;
2002
+ } else {
2003
+ s6 = peg$FAILED;
2004
+ if (peg$silentFails === 0) {
2005
+ peg$fail(peg$e1);
2006
+ }
2007
+ }
2008
+ }
2009
+ } else {
2010
+ s5 = peg$FAILED;
2011
+ }
2012
+ if (s5 !== peg$FAILED) {
2013
+ s4 = [s4, s5];
2014
+ s3 = s4;
2015
+ } else {
2016
+ peg$currPos = s3;
2017
+ s3 = peg$FAILED;
2018
+ }
2019
+ } else {
2020
+ peg$currPos = s3;
2021
+ s3 = peg$FAILED;
2022
+ }
1787
2023
  }
1788
- }
1789
- if (s2 !== peg$FAILED) {
1790
2024
  s1 = [s1, s2];
1791
2025
  s0 = s1;
1792
2026
  } else {
@@ -2063,6 +2297,7 @@ class Parser {
2063
2297
  constructor(options = {}) {
2064
2298
  this.maxInputSize = options.maxInputSize || 10 * 1024 * 1024;
2065
2299
  this.maxDepth = options.maxDepth || 1000;
2300
+ this.comments = options.comments ?? true;
2066
2301
  }
2067
2302
  parse(input) {
2068
2303
  if (typeof input !== "string") {
@@ -2071,8 +2306,9 @@ class Parser {
2071
2306
  if (input.length > this.maxInputSize) {
2072
2307
  throw new Error(`Input size exceeds maximum allowed size of ${this.maxInputSize} bytes`);
2073
2308
  }
2309
+ const prepared = this.comments ? stripComments(input) : input;
2074
2310
  try {
2075
- const rawResult = peg$parse(input);
2311
+ const rawResult = peg$parse(prepared);
2076
2312
  return this.transformResult(rawResult);
2077
2313
  } catch (error) {
2078
2314
  if (error && error.location) {
@@ -2218,5 +2454,6 @@ export {
2218
2454
  LinksGroup,
2219
2455
  ParseError,
2220
2456
  Parser,
2221
- formatLinks
2457
+ formatLinks,
2458
+ stripComments
2222
2459
  };
package/index.d.ts CHANGED
@@ -116,6 +116,12 @@ export interface ParserOptions {
116
116
  * Maximum nesting depth (default: 1000)
117
117
  */
118
118
  maxDepth?: number;
119
+
120
+ /**
121
+ * If false, read `#` as an ordinary character instead of the start of a
122
+ * comment (default: true)
123
+ */
124
+ comments?: boolean;
119
125
  }
120
126
 
121
127
  /**
@@ -134,6 +140,11 @@ export class Parser {
134
140
  */
135
141
  maxDepth: number;
136
142
 
143
+ /**
144
+ * Whether `#` starts a comment that runs to the end of its line
145
+ */
146
+ comments: boolean;
147
+
137
148
  /**
138
149
  * Create a new Parser instance
139
150
  * @param options - Parser options
@@ -149,6 +160,14 @@ export class Parser {
149
160
  parse(input: string): Link[];
150
161
  }
151
162
 
163
+ /**
164
+ * Blank out every comment in a document, keeping its length so that every
165
+ * character stays at the offset it was written at.
166
+ * @param document - The document to read
167
+ * @returns The document, of the same length, without its comments
168
+ */
169
+ export function stripComments(document: string): string;
170
+
152
171
  /**
153
172
  * The position a parse stopped at, as the generated parser reports it
154
173
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
package/src/Link.js CHANGED
@@ -95,7 +95,11 @@ export class Link {
95
95
  const hasSingleQuote = reference.includes("'");
96
96
  const hasDoubleQuote = reference.includes('"');
97
97
 
98
+ // A reference that begins with a `#` has to be quoted, or it would read
99
+ // back as a comment. A `#` anywhere else in a reference is content
100
+ // (`issue#1047`), so only the first character matters.
98
101
  const needsQuoting =
102
+ reference.startsWith('#') ||
99
103
  reference.includes(':') ||
100
104
  reference.includes('(') ||
101
105
  reference.includes(')') ||
package/src/Parser.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Link } from './Link.js';
2
2
  import { ParseError } from './ParseError.js';
3
+ import { stripComments } from './comments.js';
3
4
  import * as parserModule from './parser-generated.js';
4
5
 
5
6
  export class Parser {
@@ -8,10 +9,12 @@ export class Parser {
8
9
  * @param {Object} options - Parser options
9
10
  * @param {number} options.maxInputSize - Maximum input size in bytes (default: 10MB)
10
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)
11
13
  */
12
14
  constructor(options = {}) {
13
15
  this.maxInputSize = options.maxInputSize || 10 * 1024 * 1024; // 10MB default
14
16
  this.maxDepth = options.maxDepth || 1000;
17
+ this.comments = options.comments ?? true;
15
18
  }
16
19
 
17
20
  /**
@@ -32,8 +35,12 @@ export class Parser {
32
35
  );
33
36
  }
34
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
+
35
42
  try {
36
- const rawResult = parserModule.parse(input);
43
+ const rawResult = parserModule.parse(prepared);
37
44
  return this.transformResult(rawResult);
38
45
  } catch (error) {
39
46
  // A syntax error knows where it stopped; anything else is passed on with
@@ -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
@@ -4,3 +4,4 @@ export { Parser } from './Parser.js';
4
4
  export { ParseError } from './ParseError.js';
5
5
  export { FormatConfig } from './FormatConfig.js';
6
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
+ });
@@ -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
  });