links-notation 0.21.1 → 0.21.2

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/dist/index.js CHANGED
@@ -341,9 +341,146 @@ function windowAround(lineText, column) {
341
341
  return [quoted, target - start + shift + 1];
342
342
  }
343
343
 
344
+ // src/quotes.js
345
+ var QUOTES = ['"', "'", "`"];
346
+
347
+ class DelimitedReferences {
348
+ constructor(document) {
349
+ this.document = document;
350
+ this.runsByQuote = new Map;
351
+ this.readings = new Map;
352
+ }
353
+ readAt(start) {
354
+ if (!this.readings.has(start)) {
355
+ this.readings.set(start, this.#read(start));
356
+ }
357
+ return this.readings.get(start);
358
+ }
359
+ endAt(start) {
360
+ const reading = this.readAt(start);
361
+ return reading === null ? null : start + reading.length;
362
+ }
363
+ #read(start) {
364
+ const document = this.document;
365
+ const quote = document[start];
366
+ if (!QUOTES.includes(quote)) {
367
+ return null;
368
+ }
369
+ const runs = this.#runsOf(quote);
370
+ const opening = runs.indexOf(start);
371
+ const count = runs.end(opening) - start;
372
+ let closing = opening + 1;
373
+ while (closing < runs.count) {
374
+ const length = runs.lengths[closing];
375
+ if (length < count) {
376
+ closing = runs.nextLonger[closing];
377
+ } else if (Math.floor(length / count) % 2 === 1) {
378
+ break;
379
+ } else {
380
+ closing++;
381
+ }
382
+ }
383
+ const end = closing < runs.count ? runs.end(closing) : null;
384
+ return reading(document, start, quote, count, end);
385
+ }
386
+ #runsOf(quote) {
387
+ if (!this.runsByQuote.has(quote)) {
388
+ this.runsByQuote.set(quote, new DelimiterRuns(this.document, quote));
389
+ }
390
+ return this.runsByQuote.get(quote);
391
+ }
392
+ }
393
+ function reading(document, start, quote, count, end) {
394
+ const emptyReference = count % 2 === 0 ? { value: "", length: count } : null;
395
+ if (end === null) {
396
+ return emptyReference;
397
+ }
398
+ const parts = [];
399
+ let position = start + count;
400
+ let run = document.indexOf(quote, position);
401
+ while (run !== -1 && run < end) {
402
+ const length = runLength(document, run, quote);
403
+ const escaped = Math.floor(length / (2 * count)) * count;
404
+ const closes = run + length === end ? count : 0;
405
+ parts.push(document.slice(position, run));
406
+ parts.push(quote.repeat(length - escaped - closes));
407
+ position = run + length;
408
+ run = document.indexOf(quote, position);
409
+ }
410
+ const value = parts.join("");
411
+ if (emptyReference !== null && !isSubstantiveBody(value)) {
412
+ return emptyReference;
413
+ }
414
+ return { value, length: end - start };
415
+ }
416
+ function runLength(text, start, quote) {
417
+ let end = start;
418
+ while (end < text.length && text[end] === quote) {
419
+ end++;
420
+ }
421
+ return end - start;
422
+ }
423
+
424
+ class DelimiterRuns {
425
+ constructor(document, quote) {
426
+ this.starts = [];
427
+ this.lengths = [];
428
+ let position = document.indexOf(quote);
429
+ while (position !== -1) {
430
+ const length = runLength(document, position, quote);
431
+ this.starts.push(position);
432
+ this.lengths.push(length);
433
+ position = document.indexOf(quote, position + length);
434
+ }
435
+ this.count = this.starts.length;
436
+ this.nextLonger = new Array(this.count);
437
+ const longer = [];
438
+ for (let run = this.count - 1;run >= 0; run--) {
439
+ while (longer.length > 0 && this.lengths[longer[longer.length - 1]] <= this.lengths[run]) {
440
+ longer.pop();
441
+ }
442
+ this.nextLonger[run] = longer.length > 0 ? longer[longer.length - 1] : this.count;
443
+ longer.push(run);
444
+ }
445
+ }
446
+ end(run) {
447
+ return this.starts[run] + this.lengths[run];
448
+ }
449
+ indexOf(position) {
450
+ let low = 0;
451
+ let high = this.count - 1;
452
+ while (low < high) {
453
+ const middle = low + high + 1 >> 1;
454
+ if (this.starts[middle] <= position) {
455
+ low = middle;
456
+ } else {
457
+ high = middle - 1;
458
+ }
459
+ }
460
+ return low;
461
+ }
462
+ }
463
+ function isSubstantiveBody(content) {
464
+ let depth = 0;
465
+ let hasVisible = false;
466
+ for (const character of content) {
467
+ if (character === "(") {
468
+ depth++;
469
+ } else if (character === ")") {
470
+ depth--;
471
+ if (depth < 0) {
472
+ return false;
473
+ }
474
+ }
475
+ if (!/[ \t\n\r]/.test(character)) {
476
+ hasVisible = true;
477
+ }
478
+ }
479
+ return hasVisible && depth === 0;
480
+ }
481
+
344
482
  // src/comments.js
345
483
  var COMMENT = "#";
346
- var QUOTES = ['"', "'", "`"];
347
484
  var BEFORE_REFERENCE = [" ", "\t", `
348
485
  `, "\r", "(", ":"];
349
486
  var BEFORE_COMMENT = [" ", "\t", `
@@ -351,10 +488,11 @@ var BEFORE_COMMENT = [" ", "\t", `
351
488
  function stripComments(document) {
352
489
  let blanked = null;
353
490
  let position = 0;
491
+ const references = new DelimitedReferences(document);
354
492
  while (position < document.length) {
355
493
  const character = document[position];
356
494
  if (QUOTES.includes(character) && follows(document, position, BEFORE_REFERENCE)) {
357
- const end = quotedReferenceEnd(document, position);
495
+ const end = references.endAt(position);
358
496
  position = end === null ? position + 1 : end;
359
497
  continue;
360
498
  }
@@ -374,59 +512,6 @@ function stripComments(document) {
374
512
  function follows(document, position, allowed) {
375
513
  return position === 0 || allowed.includes(document[position - 1]);
376
514
  }
377
- function quotedReferenceEnd(document, start) {
378
- const quote = document[start];
379
- if (!QUOTES.includes(quote)) {
380
- return null;
381
- }
382
- let position = start;
383
- while (position < document.length && document[position] === quote) {
384
- position++;
385
- }
386
- const count = position - start;
387
- const isEvenRun = count % 2 === 0;
388
- const emptyReference = isEvenRun ? start + count : null;
389
- const closing = quote.repeat(count);
390
- const escape = quote.repeat(count * 2);
391
- let content = "";
392
- while (position < document.length) {
393
- if (document.startsWith(escape, position)) {
394
- content += closing;
395
- position += escape.length;
396
- continue;
397
- }
398
- if (document.startsWith(closing, position)) {
399
- const afterClosing = position + count;
400
- if (afterClosing >= document.length || document[afterClosing] !== quote) {
401
- if (isEvenRun && !isSubstantiveBody(content)) {
402
- return emptyReference;
403
- }
404
- return afterClosing;
405
- }
406
- }
407
- content += document[position];
408
- position++;
409
- }
410
- return emptyReference;
411
- }
412
- function isSubstantiveBody(content) {
413
- let depth = 0;
414
- let hasVisible = false;
415
- for (const character of content) {
416
- if (character === "(") {
417
- depth++;
418
- } else if (character === ")") {
419
- depth--;
420
- if (depth < 0) {
421
- return false;
422
- }
423
- }
424
- if (!/[ \t\n\r]/.test(character)) {
425
- hasVisible = true;
426
- }
427
- }
428
- return hasVisible && depth === 0;
429
- }
430
515
 
431
516
  // src/parser-generated.js
432
517
  class peg$SyntaxError extends SyntaxError {
@@ -2229,61 +2314,12 @@ function peg$parse(input, options) {
2229
2314
  function getCurrentIndentation() {
2230
2315
  return indentationStack[indentationStack.length - 1];
2231
2316
  }
2232
- function isSubstantiveBody(content) {
2233
- let depth = 0;
2234
- let hasVisible = false;
2235
- for (const c of content) {
2236
- if (c === "(") {
2237
- depth++;
2238
- } else if (c === ")") {
2239
- depth--;
2240
- if (depth < 0) {
2241
- return false;
2242
- }
2243
- }
2244
- if (!/[ \t\n\r]/.test(c)) {
2245
- hasVisible = true;
2246
- }
2247
- }
2248
- return hasVisible && depth === 0;
2249
- }
2317
+ const delimitedReferences = new DelimitedReferences(input);
2250
2318
  function parseQuotedStringAt(inputStr, startPos, quoteChar) {
2251
2319
  if (startPos >= inputStr.length || inputStr[startPos] !== quoteChar) {
2252
2320
  return null;
2253
2321
  }
2254
- let quoteCount = 0;
2255
- let pos = startPos;
2256
- while (pos < inputStr.length && inputStr[pos] === quoteChar) {
2257
- quoteCount++;
2258
- pos++;
2259
- }
2260
- const isEvenRun = quoteCount % 2 === 0;
2261
- const emptyReference = isEvenRun ? { value: "", length: quoteCount } : null;
2262
- const closeSeq = quoteChar.repeat(quoteCount);
2263
- const escapeSeq = quoteChar.repeat(quoteCount * 2);
2264
- let content = "";
2265
- while (pos < inputStr.length) {
2266
- if (inputStr.substr(pos, escapeSeq.length) === escapeSeq) {
2267
- content += closeSeq;
2268
- pos += escapeSeq.length;
2269
- continue;
2270
- }
2271
- if (inputStr.substr(pos, quoteCount) === closeSeq) {
2272
- const afterClose = pos + quoteCount;
2273
- if (afterClose >= inputStr.length || inputStr[afterClose] !== quoteChar) {
2274
- if (isEvenRun && !isSubstantiveBody(content)) {
2275
- return emptyReference;
2276
- }
2277
- return {
2278
- value: content,
2279
- length: afterClose - startPos
2280
- };
2281
- }
2282
- }
2283
- content += inputStr[pos];
2284
- pos++;
2285
- }
2286
- return emptyReference;
2322
+ return delimitedReferences.readAt(startPos);
2287
2323
  }
2288
2324
  let parsedValue = null;
2289
2325
  let parsedLength = 0;
@@ -2642,12 +2678,13 @@ function structurallyComplete(document, comments) {
2642
2678
  `, "\r", "(", ":"];
2643
2679
  const beforeComment = [" ", "\t", `
2644
2680
  `, "\r"];
2681
+ const references = new DelimitedReferences(document);
2645
2682
  let depth = 0;
2646
2683
  for (let position = 0;position < document.length; position++) {
2647
2684
  const character = document[position];
2648
2685
  const previous = position === 0 ? null : document[position - 1];
2649
2686
  if (quotes.includes(character) && (previous === null || beforeReference.includes(previous))) {
2650
- const end = quotedReferenceEnd(document, position);
2687
+ const end = references.endAt(position);
2651
2688
  if (end === null)
2652
2689
  return false;
2653
2690
  position = end - 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.21.1",
3
+ "version": "0.21.2",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import { Parser } from './Parser.js';
3
- import { quotedReferenceEnd } from './comments.js';
3
+ import { DelimitedReferences } from './quotes.js';
4
4
 
5
5
  const DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
6
6
 
@@ -249,6 +249,7 @@ function structurallyComplete(document, comments) {
249
249
  const quotes = ['"', "'", '`'];
250
250
  const beforeReference = [' ', '\t', '\n', '\r', '(', ':'];
251
251
  const beforeComment = [' ', '\t', '\n', '\r'];
252
+ const references = new DelimitedReferences(document);
252
253
  let depth = 0;
253
254
 
254
255
  for (let position = 0; position < document.length; position++) {
@@ -259,7 +260,7 @@ function structurallyComplete(document, comments) {
259
260
  quotes.includes(character) &&
260
261
  (previous === null || beforeReference.includes(previous))
261
262
  ) {
262
- const end = quotedReferenceEnd(document, position);
263
+ const end = references.endAt(position);
263
264
  if (end === null) return false;
264
265
  position = end - 1;
265
266
  continue;
package/src/comments.js CHANGED
@@ -12,12 +12,11 @@
12
12
  * the reader can see.
13
13
  */
14
14
 
15
+ import { DelimitedReferences, QUOTES, readReference } from './quotes.js';
16
+
15
17
  /** The character that starts a comment. */
16
18
  export const COMMENT = '#';
17
19
 
18
- /** The delimiters a reference can be written between. */
19
- const QUOTES = ['"', "'", '`'];
20
-
21
20
  /** What a delimited reference may start after. */
22
21
  const BEFORE_REFERENCE = [' ', '\t', '\n', '\r', '(', ':'];
23
22
 
@@ -38,6 +37,7 @@ export function stripComments(document) {
38
37
  // parse error reports are both counted in.
39
38
  let blanked = null;
40
39
  let position = 0;
40
+ const references = new DelimitedReferences(document);
41
41
 
42
42
  while (position < document.length) {
43
43
  const character = document[position];
@@ -46,7 +46,7 @@ export function stripComments(document) {
46
46
  QUOTES.includes(character) &&
47
47
  follows(document, position, BEFORE_REFERENCE)
48
48
  ) {
49
- const end = quotedReferenceEnd(document, position);
49
+ const end = references.endAt(position);
50
50
  position = end === null ? position + 1 : end;
51
51
  continue;
52
52
  }
@@ -95,70 +95,6 @@ function follows(document, position, allowed) {
95
95
  * @returns {number|null} The position just past the reference
96
96
  */
97
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 (!/[ \t\n\r]/.test(character)) {
159
- hasVisible = true;
160
- }
161
- }
162
-
163
- return hasVisible && depth === 0;
98
+ const reading = readReference(document, start);
99
+ return reading === null ? null : start + reading.length;
164
100
  }
package/src/grammar.pegjs CHANGED
@@ -1,3 +1,7 @@
1
+ {{
2
+ import { DelimitedReferences } from './quotes.js';
3
+ }}
4
+
1
5
  {
2
6
  let indentationStack = [0];
3
7
  let baseIndentation = null;
@@ -66,87 +70,18 @@
66
70
  return indentationStack[indentationStack.length - 1];
67
71
  }
68
72
 
69
- // A body written between an even run of delimiters is substantive when it
70
- // holds at least one visible character and does not straddle a parenthesis.
71
- // An even run can always be read as delimiter pairs enclosing nothing, so the
72
- // n-quote reading is only taken when it carries something the pairs cannot.
73
- function isSubstantiveBody(content) {
74
- let depth = 0;
75
- let hasVisible = false;
76
-
77
- for (const c of content) {
78
- if (c === '(') {
79
- depth++;
80
- } else if (c === ')') {
81
- depth--;
82
- if (depth < 0) {
83
- return false;
84
- }
85
- }
86
- if (!/[ \t\n\r]/.test(c)) {
87
- hasVisible = true;
88
- }
89
- }
90
-
91
- return hasVisible && depth === 0;
92
- }
73
+ // Delimited references of this document, each read once however many
74
+ // alternatives ask for it (see quotes.js for how a reference is read)
75
+ const delimitedReferences = new DelimitedReferences(input);
93
76
 
94
77
  // Universal procedural parser for N-quote strings (any N >= 1)
95
78
  // Parses from the given position in the input string
96
- // A run of an even number of delimiters that does not open a reference with a
97
- // substantive body is the empty reference: the shortest reading, a bare
98
- // delimiter pair enclosing nothing, wins over a longer n-quote delimiter.
99
79
  // Returns { value, length } or null
100
80
  function parseQuotedStringAt(inputStr, startPos, quoteChar) {
101
81
  if (startPos >= inputStr.length || inputStr[startPos] !== quoteChar) {
102
82
  return null;
103
83
  }
104
-
105
- // Count opening quotes
106
- let quoteCount = 0;
107
- let pos = startPos;
108
- while (pos < inputStr.length && inputStr[pos] === quoteChar) {
109
- quoteCount++;
110
- pos++;
111
- }
112
-
113
- const isEvenRun = quoteCount % 2 === 0;
114
- const emptyReference = isEvenRun ? { value: '', length: quoteCount } : null;
115
-
116
- const closeSeq = quoteChar.repeat(quoteCount);
117
- const escapeSeq = quoteChar.repeat(quoteCount * 2);
118
-
119
- let content = '';
120
- while (pos < inputStr.length) {
121
- // Check for escape sequence (2*N quotes)
122
- if (inputStr.substr(pos, escapeSeq.length) === escapeSeq) {
123
- content += closeSeq; // 2*N quotes become N quotes
124
- pos += escapeSeq.length;
125
- continue;
126
- }
127
-
128
- // Check for closing sequence (exactly N quotes)
129
- if (inputStr.substr(pos, quoteCount) === closeSeq) {
130
- // Verify it's exactly N quotes (not followed by more of same char)
131
- const afterClose = pos + quoteCount;
132
- if (afterClose >= inputStr.length || inputStr[afterClose] !== quoteChar) {
133
- // Found valid closing
134
- if (isEvenRun && !isSubstantiveBody(content)) {
135
- return emptyReference;
136
- }
137
- return {
138
- value: content,
139
- length: afterClose - startPos
140
- };
141
- }
142
- }
143
-
144
- // Add character to content
145
- content += inputStr[pos];
146
- pos++;
147
- }
148
-
149
- return emptyReference; // No valid closing found
84
+ return delimitedReferences.readAt(startPos);
150
85
  }
151
86
 
152
87
  // Global state for passing parsed values between predicate and action
@@ -3,6 +3,9 @@
3
3
  // https://peggyjs.org/
4
4
 
5
5
 
6
+
7
+ import { DelimitedReferences } from './quotes.js';
8
+
6
9
  class peg$SyntaxError extends SyntaxError {
7
10
  constructor(message, expected, found, location) {
8
11
  super(message);
@@ -1864,87 +1867,18 @@ function peg$parse(input, options) {
1864
1867
  return indentationStack[indentationStack.length - 1];
1865
1868
  }
1866
1869
 
1867
- // A body written between an even run of delimiters is substantive when it
1868
- // holds at least one visible character and does not straddle a parenthesis.
1869
- // An even run can always be read as delimiter pairs enclosing nothing, so the
1870
- // n-quote reading is only taken when it carries something the pairs cannot.
1871
- function isSubstantiveBody(content) {
1872
- let depth = 0;
1873
- let hasVisible = false;
1874
-
1875
- for (const c of content) {
1876
- if (c === '(') {
1877
- depth++;
1878
- } else if (c === ')') {
1879
- depth--;
1880
- if (depth < 0) {
1881
- return false;
1882
- }
1883
- }
1884
- if (!/[ \t\n\r]/.test(c)) {
1885
- hasVisible = true;
1886
- }
1887
- }
1888
-
1889
- return hasVisible && depth === 0;
1890
- }
1870
+ // Delimited references of this document, each read once however many
1871
+ // alternatives ask for it (see quotes.js for how a reference is read)
1872
+ const delimitedReferences = new DelimitedReferences(input);
1891
1873
 
1892
1874
  // Universal procedural parser for N-quote strings (any N >= 1)
1893
1875
  // Parses from the given position in the input string
1894
- // A run of an even number of delimiters that does not open a reference with a
1895
- // substantive body is the empty reference: the shortest reading, a bare
1896
- // delimiter pair enclosing nothing, wins over a longer n-quote delimiter.
1897
1876
  // Returns { value, length } or null
1898
1877
  function parseQuotedStringAt(inputStr, startPos, quoteChar) {
1899
1878
  if (startPos >= inputStr.length || inputStr[startPos] !== quoteChar) {
1900
1879
  return null;
1901
1880
  }
1902
-
1903
- // Count opening quotes
1904
- let quoteCount = 0;
1905
- let pos = startPos;
1906
- while (pos < inputStr.length && inputStr[pos] === quoteChar) {
1907
- quoteCount++;
1908
- pos++;
1909
- }
1910
-
1911
- const isEvenRun = quoteCount % 2 === 0;
1912
- const emptyReference = isEvenRun ? { value: '', length: quoteCount } : null;
1913
-
1914
- const closeSeq = quoteChar.repeat(quoteCount);
1915
- const escapeSeq = quoteChar.repeat(quoteCount * 2);
1916
-
1917
- let content = '';
1918
- while (pos < inputStr.length) {
1919
- // Check for escape sequence (2*N quotes)
1920
- if (inputStr.substr(pos, escapeSeq.length) === escapeSeq) {
1921
- content += closeSeq; // 2*N quotes become N quotes
1922
- pos += escapeSeq.length;
1923
- continue;
1924
- }
1925
-
1926
- // Check for closing sequence (exactly N quotes)
1927
- if (inputStr.substr(pos, quoteCount) === closeSeq) {
1928
- // Verify it's exactly N quotes (not followed by more of same char)
1929
- const afterClose = pos + quoteCount;
1930
- if (afterClose >= inputStr.length || inputStr[afterClose] !== quoteChar) {
1931
- // Found valid closing
1932
- if (isEvenRun && !isSubstantiveBody(content)) {
1933
- return emptyReference;
1934
- }
1935
- return {
1936
- value: content,
1937
- length: afterClose - startPos
1938
- };
1939
- }
1940
- }
1941
-
1942
- // Add character to content
1943
- content += inputStr[pos];
1944
- pos++;
1945
- }
1946
-
1947
- return emptyReference; // No valid closing found
1881
+ return delimitedReferences.readAt(startPos);
1948
1882
  }
1949
1883
 
1950
1884
  // Global state for passing parsed values between predicate and action
package/src/quotes.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Delimited references, and how far each one reaches.
3
+ *
4
+ * A reference opened by a run of N delimiters (`"`, `'` or `` ` ``) closes at
5
+ * the next run of exactly N, and a run of 2N inside it is that delimiter
6
+ * escaped. An even run that encloses nothing substantive is the empty
7
+ * reference: the shortest reading, a bare delimiter pair enclosing nothing,
8
+ * wins over a longer n-quote delimiter.
9
+ *
10
+ * A run of R delimiters in a body is read as a whole: each 2N of it in turn are
11
+ * an escaped N, and the R % 2N left over close the reference when there are at
12
+ * least N of them - the last N close it, the ones before are content - and are
13
+ * content otherwise. So a run closes the reference exactly when
14
+ * `Math.floor(R / N)` is odd, and a run shorter than N never does.
15
+ *
16
+ * {@link DelimitedReferences} lists the runs of a delimiter once per document
17
+ * and links each run to the next longer one, so finding where a reference
18
+ * closes skips every run too short to close it. Reading a reference takes time
19
+ * linear in its body however wide its opening run is, and finding that a
20
+ * reference never closes does not take a pass over the rest of the document
21
+ * each time it is asked.
22
+ */
23
+
24
+ /** The characters a reference can be written between. */
25
+ export const QUOTES = ['"', "'", '`'];
26
+
27
+ /**
28
+ * Every delimited reference of one document, read on demand.
29
+ *
30
+ * The grammar and the comment reader each keep one per document they read, so
31
+ * a reference asked for twice is read once.
32
+ */
33
+ export class DelimitedReferences {
34
+ /**
35
+ * @param {string} document - The document the references are read from
36
+ */
37
+ constructor(document) {
38
+ this.document = document;
39
+ this.runsByQuote = new Map();
40
+ this.readings = new Map();
41
+ }
42
+
43
+ /**
44
+ * The reference opened at `start`.
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
48
+ */
49
+ readAt(start) {
50
+ if (!this.readings.has(start)) {
51
+ this.readings.set(start, this.#read(start));
52
+ }
53
+ return this.readings.get(start);
54
+ }
55
+
56
+ /**
57
+ * The position just past the reference opened at `start`.
58
+ * @param {number} start - Position of the opening delimiter
59
+ * @returns {number|null} The position, or null when nothing opens there
60
+ */
61
+ endAt(start) {
62
+ const reading = this.readAt(start);
63
+ return reading === null ? null : start + reading.length;
64
+ }
65
+
66
+ #read(start) {
67
+ const document = this.document;
68
+ const quote = document[start];
69
+ if (!QUOTES.includes(quote)) {
70
+ return null;
71
+ }
72
+
73
+ const runs = this.#runsOf(quote);
74
+ const opening = runs.indexOf(start);
75
+ const count = runs.end(opening) - start;
76
+
77
+ // The first run after the opening one that closes the reference.
78
+ let closing = opening + 1;
79
+ while (closing < runs.count) {
80
+ const length = runs.lengths[closing];
81
+ if (length < count) {
82
+ closing = runs.nextLonger[closing];
83
+ } else if (Math.floor(length / count) % 2 === 1) {
84
+ break;
85
+ } else {
86
+ closing++;
87
+ }
88
+ }
89
+ const end = closing < runs.count ? runs.end(closing) : null;
90
+ return reading(document, start, quote, count, end);
91
+ }
92
+
93
+ #runsOf(quote) {
94
+ if (!this.runsByQuote.has(quote)) {
95
+ this.runsByQuote.set(quote, new DelimiterRuns(this.document, quote));
96
+ }
97
+ return this.runsByQuote.get(quote);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The reference opened at `start`, read without a list of runs: for a single
103
+ * reference that is as quick, since it is read once.
104
+ * @param {string} document - The document being read
105
+ * @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
+ */
109
+ export function readReference(document, start) {
110
+ const quote = document[start];
111
+ if (!QUOTES.includes(quote)) {
112
+ return null;
113
+ }
114
+ const count = runLength(document, start, quote);
115
+
116
+ let end = null;
117
+ let position = document.indexOf(quote, start + count);
118
+ while (position !== -1) {
119
+ const length = runLength(document, position, quote);
120
+ if (length >= count && Math.floor(length / count) % 2 === 1) {
121
+ end = position + length;
122
+ break;
123
+ }
124
+ position = document.indexOf(quote, position + length);
125
+ }
126
+ return reading(document, start, quote, count, end);
127
+ }
128
+
129
+ /**
130
+ * The reading of a reference opened by `count` delimiters at `start`, closed
131
+ * by the run that ends at `end` when there is one.
132
+ */
133
+ function reading(document, start, quote, count, end) {
134
+ const emptyReference = count % 2 === 0 ? { value: '', length: count } : null;
135
+ if (end === null) {
136
+ return emptyReference;
137
+ }
138
+
139
+ const parts = [];
140
+ let position = start + count;
141
+ let run = document.indexOf(quote, position);
142
+ while (run !== -1 && run < end) {
143
+ const length = runLength(document, run, quote);
144
+ const escaped = Math.floor(length / (2 * count)) * count;
145
+ // The closing run is the last one of the body.
146
+ const closes = run + length === end ? count : 0;
147
+ parts.push(document.slice(position, run));
148
+ parts.push(quote.repeat(length - escaped - closes));
149
+ position = run + length;
150
+ run = document.indexOf(quote, position);
151
+ }
152
+ const value = parts.join('');
153
+
154
+ if (emptyReference !== null && !isSubstantiveBody(value)) {
155
+ return emptyReference;
156
+ }
157
+ return { value, length: end - start };
158
+ }
159
+
160
+ /** The number of `quote` delimiters in a row from `start` of `text`. */
161
+ function runLength(text, start, quote) {
162
+ let end = start;
163
+ while (end < text.length && text[end] === quote) {
164
+ end++;
165
+ }
166
+ return end - start;
167
+ }
168
+
169
+ /**
170
+ * The maximal runs of one delimiter in a document, in order, each linked to the
171
+ * next run that is longer than it.
172
+ */
173
+ class DelimiterRuns {
174
+ constructor(document, quote) {
175
+ this.starts = [];
176
+ this.lengths = [];
177
+
178
+ let position = document.indexOf(quote);
179
+ while (position !== -1) {
180
+ const length = runLength(document, position, quote);
181
+ this.starts.push(position);
182
+ this.lengths.push(length);
183
+ position = document.indexOf(quote, position + length);
184
+ }
185
+ this.count = this.starts.length;
186
+
187
+ // Every run between a run and the next longer one is at most as long as
188
+ // it, which is what lets a search for a run of some length jump over them.
189
+ this.nextLonger = new Array(this.count);
190
+ const longer = [];
191
+ for (let run = this.count - 1; run >= 0; run--) {
192
+ while (
193
+ longer.length > 0 &&
194
+ this.lengths[longer[longer.length - 1]] <= this.lengths[run]
195
+ ) {
196
+ longer.pop();
197
+ }
198
+ this.nextLonger[run] =
199
+ longer.length > 0 ? longer[longer.length - 1] : this.count;
200
+ longer.push(run);
201
+ }
202
+ }
203
+
204
+ /** Position just past the given run. */
205
+ end(run) {
206
+ return this.starts[run] + this.lengths[run];
207
+ }
208
+
209
+ /** The run that holds the given position of a delimiter. */
210
+ indexOf(position) {
211
+ let low = 0;
212
+ let high = this.count - 1;
213
+ while (low < high) {
214
+ const middle = (low + high + 1) >> 1;
215
+ if (this.starts[middle] <= position) {
216
+ low = middle;
217
+ } else {
218
+ high = middle - 1;
219
+ }
220
+ }
221
+ return low;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Whether a body written between an even run of delimiters carries something a
227
+ * pair of delimiters enclosing nothing cannot: at least one visible character,
228
+ * and no parenthesis it straddles.
229
+ * @param {string} content - The body between the delimiters
230
+ * @returns {boolean} True when the n-quote reading is the one to take
231
+ */
232
+ export function isSubstantiveBody(content) {
233
+ let depth = 0;
234
+ let hasVisible = false;
235
+
236
+ for (const character of content) {
237
+ if (character === '(') {
238
+ depth++;
239
+ } else if (character === ')') {
240
+ depth--;
241
+ if (depth < 0) {
242
+ return false;
243
+ }
244
+ }
245
+ if (!/[ \t\n\r]/.test(character)) {
246
+ hasVisible = true;
247
+ }
248
+ }
249
+
250
+ return hasVisible && depth === 0;
251
+ }
@@ -0,0 +1,163 @@
1
+ import { test, expect, setDefaultTimeout } from 'bun:test';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { Parser } from '../src/Parser.js';
4
+ import { quotedReferenceEnd, stripComments } from '../src/comments.js';
5
+ import { DelimitedReferences } from '../src/quotes.js';
6
+ import { StreamParser } from '../src/StreamParser.js';
7
+
8
+ // Issue #316: a reference that opens with a wide run of delimiters used to be
9
+ // read one character at a time, comparing the whole width at every position,
10
+ // and every run that never closed was read to the end of the document again.
11
+ // These documents took from seconds to tens of seconds; read a run at a time
12
+ // they take milliseconds, so the bound below only fails on the old behaviour.
13
+
14
+ const parser = new Parser();
15
+ const BOUND_MS = 2000;
16
+ const SIZE = 400000;
17
+ // A regression fails on the bound; the timeout stops it from hanging the run.
18
+ setDefaultTimeout(20000);
19
+
20
+ function render(node) {
21
+ if (!node.values || node.values.length === 0) {
22
+ return `<${node.id ?? ''}>`;
23
+ }
24
+ const head =
25
+ node.id === null || node.id === undefined ? '' : `<${node.id}>: `;
26
+ return `(${head}${node.values.map(render).join(' ')})`;
27
+ }
28
+
29
+ function parsesAs(source) {
30
+ return parser.parse(source).map(render).join('\n');
31
+ }
32
+
33
+ const q = (count) => "'".repeat(count);
34
+
35
+ function timed(read) {
36
+ const started = performance.now();
37
+ const result = read();
38
+ return { result, elapsed: performance.now() - started };
39
+ }
40
+
41
+ // The shapes from the issue, and two that defeat shortcuts a fix could take.
42
+
43
+ function wideQuoteOverALongRun(size) {
44
+ const width = Math.floor(size / 4);
45
+ return `${q(width + 1)} a ${q(width - 4)} ${'x'.repeat(2 * width)}`;
46
+ }
47
+
48
+ function narrowingUnclosedQuotes(size) {
49
+ const widths = [];
50
+ for (
51
+ let width = 2 * Math.floor(Math.sqrt(size / 2) / 2) + 1;
52
+ width >= 1;
53
+ width -= 2
54
+ ) {
55
+ widths.push(width);
56
+ }
57
+ const head = widths.map((width) => `${q(width)}x`).join(' ');
58
+ return `${head} ${"a' ".repeat(Math.floor((size - head.length) / 3))}`;
59
+ }
60
+
61
+ function closedWideQuoteOverQuotes(size) {
62
+ const width = Math.floor(size / 8);
63
+ return `${q(width)}${`${q(width - 1)}x`.repeat(6)}${q(width)}`;
64
+ }
65
+
66
+ function unclosedWidthsOverAnEvenRun(size) {
67
+ const run = Math.floor(size / 4);
68
+ const widths = [];
69
+ for (let width = Math.floor(Math.sqrt(size)); width >= 2; width--) {
70
+ if (Math.floor(run / width) % 2 === 0) widths.push(width);
71
+ }
72
+ const head = widths.map((width) => `${q(width)}x`).join(' ');
73
+ const tail = "a' ".repeat(Math.floor((size - head.length - run) / 3));
74
+ return `${head} ${tail}${q(run)} b`;
75
+ }
76
+
77
+ function expectParsedWithinBound(source) {
78
+ expect(timed(() => parser.parse(source)).elapsed).toBeLessThan(BOUND_MS);
79
+ expect(timed(() => stripComments(source)).elapsed).toBeLessThan(BOUND_MS);
80
+ }
81
+
82
+ test('a run inside a body is read as escapes then a closing run', () => {
83
+ expect(parsesAs("'''a'''")).toBe('(<a>)');
84
+ expect(parsesAs("'''a''''''b'''")).toBe("(<a'''b>)");
85
+ expect(parsesAs("''a'''")).toBe("(<a'>)");
86
+ expect(parsesAs("'a'''")).toBe("(<a'>)");
87
+ expect(parsesAs(`${q(3)}a${q(9)} b`)).toBe("(<a'''> <b>)");
88
+ expect(parsesAs(`${q(50)}a${q(100)}b${q(50)}`)).toBe(`(<a${q(50)}b>)`);
89
+ expect(parsesAs('"""a""b"""')).toBe('(<a""b>)');
90
+ expect(parsesAs('``a````b``')).toBe('(<a``b>)');
91
+ });
92
+
93
+ test('a run that never closes the reference leaves it unclosed', () => {
94
+ // Math.floor(7 / 3) is even: two escaped runs of three and one delimiter.
95
+ expect(parsesAs(`${q(3)}a${q(7)}`)).toBe(`(<${q(3)}a${q(7)}>)`);
96
+ expect(parsesAs(`${q(5)}a${q(4)}`)).toBe(`(<${q(5)}a${q(4)}>)`);
97
+ expect(parsesAs("''a'''''")).toBe("(<> <a'''''>)");
98
+ expect(parsesAs("''''x a")).toBe('(<> <x> <a>)');
99
+ });
100
+
101
+ test('a reference can open inside a run', () => {
102
+ // Comment stripping and the stream parser ask where a reference opened at
103
+ // any delimiter ends, including one in the middle of a run.
104
+ const cases = [
105
+ ["x'''a'''", 1, 8],
106
+ ["x'''a''", 2, 7],
107
+ ["x'''a'", 3, 6],
108
+ ["'''a", 0, null],
109
+ ["''''a", 0, 4],
110
+ ['a', 0, null],
111
+ ["'a'", 3, null],
112
+ ];
113
+ for (const [document, start, end] of cases) {
114
+ expect(quotedReferenceEnd(document, start)).toBe(end);
115
+ expect(new DelimitedReferences(document).endAt(start)).toBe(end);
116
+ }
117
+ });
118
+
119
+ test('a reference that opens with a wide run is read in linear time', () => {
120
+ const width = Math.floor(SIZE / 4);
121
+ const source = wideQuoteOverALongRun(SIZE);
122
+ const { result, elapsed } = timed(() => parser.parse(source));
123
+ // The odd opening run never closes, so it is a plain reference; the even
124
+ // run after `a` encloses nothing and is the empty reference.
125
+ expect(result.length).toBe(1);
126
+ expect(result[0].values.map((value) => value.id)).toEqual([
127
+ q(width + 1),
128
+ 'a',
129
+ '',
130
+ 'x'.repeat(2 * width),
131
+ ]);
132
+ expect(elapsed).toBeLessThan(BOUND_MS);
133
+ });
134
+
135
+ test('wide quote over a long run is parsed within the time bound', () => {
136
+ expectParsedWithinBound(wideQuoteOverALongRun(SIZE));
137
+ });
138
+
139
+ test('narrowing unclosed quotes is parsed within the time bound', () => {
140
+ expectParsedWithinBound(narrowingUnclosedQuotes(SIZE));
141
+ });
142
+
143
+ test('closed wide quote over quotes is parsed within the time bound', () => {
144
+ expectParsedWithinBound(closedWideQuoteOverQuotes(SIZE));
145
+ });
146
+
147
+ test('unclosed widths over an even run is parsed within the time bound', () => {
148
+ expectParsedWithinBound(unclosedWidthsOverAnEvenRun(SIZE));
149
+ });
150
+
151
+ test('a stream of wide quotes is read within the time bound', () => {
152
+ // The second line is what makes the stream ask whether the first is complete.
153
+ const source = `${closedWideQuoteOverQuotes(SIZE)}\nb`;
154
+ const links = [];
155
+ const { elapsed } = timed(() => {
156
+ const stream = new StreamParser();
157
+ stream.on('link', (link) => links.push(link));
158
+ stream.write(source);
159
+ stream.end();
160
+ });
161
+ expect(links.length).toBe(2);
162
+ expect(elapsed).toBeLessThan(BOUND_MS);
163
+ });