links-notation 0.21.0 → 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 +162 -113
- package/package.json +1 -1
- package/src/Parser.js +31 -7
- package/src/StreamParser.js +3 -2
- package/src/comments.js +6 -70
- package/src/grammar.pegjs +8 -73
- package/src/parser-generated.js +7 -73
- package/src/quotes.js +251 -0
- package/tests/IndentedIdNestedValues.test.js +38 -0
- package/tests/IndentedIdSyntax.test.js +1 -1
- package/tests/Issue316QuoteRuns.test.js +163 -0
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -2355,12 +2391,7 @@ class Parser {
|
|
|
2355
2391
|
return;
|
|
2356
2392
|
if (item.children && item.children.length > 0) {
|
|
2357
2393
|
if (item.id !== undefined && item.id !== null && (!item.values || item.values.length === 0)) {
|
|
2358
|
-
const childValues = item.children.map((child) =>
|
|
2359
|
-
if (child.values && child.values.length === 1) {
|
|
2360
|
-
return this.transformLink(child.values[0]);
|
|
2361
|
-
}
|
|
2362
|
-
return this.transformLink(child);
|
|
2363
|
-
});
|
|
2394
|
+
const childValues = item.children.map((child) => this.transformIndentedValue(child));
|
|
2364
2395
|
const linkWithChildren = {
|
|
2365
2396
|
id: item.id,
|
|
2366
2397
|
values: childValues
|
|
@@ -2392,6 +2423,23 @@ class Parser {
|
|
|
2392
2423
|
}
|
|
2393
2424
|
}
|
|
2394
2425
|
}
|
|
2426
|
+
transformIndentedValue(item) {
|
|
2427
|
+
const children = item.children || [];
|
|
2428
|
+
if (children.length && item.id != null && !item.values?.length) {
|
|
2429
|
+
return new Link(item.id, children.map((child) => this.transformIndentedValue(child)));
|
|
2430
|
+
}
|
|
2431
|
+
const current = this.transformLink(item);
|
|
2432
|
+
if (children.length) {
|
|
2433
|
+
return new Link(current.id, [
|
|
2434
|
+
...current.values,
|
|
2435
|
+
...children.map((child) => this.transformIndentedValue(child))
|
|
2436
|
+
]);
|
|
2437
|
+
}
|
|
2438
|
+
if (item.id == null && item.nested === undefined && current.values.length === 1) {
|
|
2439
|
+
return current.values[0];
|
|
2440
|
+
}
|
|
2441
|
+
return current;
|
|
2442
|
+
}
|
|
2395
2443
|
combinePathElements(pathElements, current) {
|
|
2396
2444
|
if (pathElements.length === 0)
|
|
2397
2445
|
return current;
|
|
@@ -2630,12 +2678,13 @@ function structurallyComplete(document, comments) {
|
|
|
2630
2678
|
`, "\r", "(", ":"];
|
|
2631
2679
|
const beforeComment = [" ", "\t", `
|
|
2632
2680
|
`, "\r"];
|
|
2681
|
+
const references = new DelimitedReferences(document);
|
|
2633
2682
|
let depth = 0;
|
|
2634
2683
|
for (let position = 0;position < document.length; position++) {
|
|
2635
2684
|
const character = document[position];
|
|
2636
2685
|
const previous = position === 0 ? null : document[position - 1];
|
|
2637
2686
|
if (quotes.includes(character) && (previous === null || beforeReference.includes(previous))) {
|
|
2638
|
-
const end =
|
|
2687
|
+
const end = references.endAt(position);
|
|
2639
2688
|
if (end === null)
|
|
2640
2689
|
return false;
|
|
2641
2690
|
position = end - 1;
|
package/package.json
CHANGED
package/src/Parser.js
CHANGED
|
@@ -80,13 +80,9 @@ export class Parser {
|
|
|
80
80
|
item.id !== null &&
|
|
81
81
|
(!item.values || item.values.length === 0)
|
|
82
82
|
) {
|
|
83
|
-
const childValues = item.children.map((child) =>
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return this.transformLink(child.values[0]);
|
|
87
|
-
}
|
|
88
|
-
return this.transformLink(child);
|
|
89
|
-
});
|
|
83
|
+
const childValues = item.children.map((child) =>
|
|
84
|
+
this.transformIndentedValue(child)
|
|
85
|
+
);
|
|
90
86
|
const linkWithChildren = {
|
|
91
87
|
id: item.id,
|
|
92
88
|
values: childValues,
|
|
@@ -128,6 +124,34 @@ export class Parser {
|
|
|
128
124
|
}
|
|
129
125
|
}
|
|
130
126
|
|
|
127
|
+
// A line below an indented ID is a value in its own right. Bare single
|
|
128
|
+
// references unwrap, but named links and descendants keep their structure.
|
|
129
|
+
transformIndentedValue(item) {
|
|
130
|
+
const children = item.children || [];
|
|
131
|
+
if (children.length && item.id != null && !item.values?.length) {
|
|
132
|
+
return new Link(
|
|
133
|
+
item.id,
|
|
134
|
+
children.map((child) => this.transformIndentedValue(child))
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const current = this.transformLink(item);
|
|
139
|
+
if (children.length) {
|
|
140
|
+
return new Link(current.id, [
|
|
141
|
+
...current.values,
|
|
142
|
+
...children.map((child) => this.transformIndentedValue(child)),
|
|
143
|
+
]);
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
item.id == null &&
|
|
147
|
+
item.nested === undefined &&
|
|
148
|
+
current.values.length === 1
|
|
149
|
+
) {
|
|
150
|
+
return current.values[0];
|
|
151
|
+
}
|
|
152
|
+
return current;
|
|
153
|
+
}
|
|
154
|
+
|
|
131
155
|
combinePathElements(pathElements, current) {
|
|
132
156
|
if (pathElements.length === 0) return current;
|
|
133
157
|
if (pathElements.length === 1) {
|
package/src/StreamParser.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'events';
|
|
2
2
|
import { Parser } from './Parser.js';
|
|
3
|
-
import {
|
|
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 =
|
|
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 =
|
|
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
|
|
99
|
-
|
|
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
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
package/src/parser-generated.js
CHANGED
|
@@ -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
|
-
//
|
|
1868
|
-
//
|
|
1869
|
-
|
|
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,38 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test';
|
|
2
|
+
import { Parser } from '../src/Parser.js';
|
|
3
|
+
import { formatLinks } from '../src/Link.js';
|
|
4
|
+
|
|
5
|
+
const cases = [
|
|
6
|
+
[
|
|
7
|
+
'outer:\n inner:\n value1\n value2\n value3',
|
|
8
|
+
'(outer: (inner: value1 value2) value3)',
|
|
9
|
+
],
|
|
10
|
+
[
|
|
11
|
+
'statement:\n subject:\n I\n verb:\n love\n object:\n you:\n very\n much',
|
|
12
|
+
'(statement: (subject: I) (verb: love) (object: (you: very much)))',
|
|
13
|
+
],
|
|
14
|
+
[
|
|
15
|
+
'document:\n (metadata: title author date)\n content:\n paragraph1\n (paragraph2: text (with: nested structure))\n paragraph3',
|
|
16
|
+
'(document: (metadata: title author date) (content: paragraph1 (paragraph2: text (with: nested structure)) paragraph3))',
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
'image:\n file: .gitpod.Dockerfile\n context: ./docker-content',
|
|
20
|
+
'(image: (file: .gitpod.Dockerfile) (context: ./docker-content))',
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
'level1:\n level2:\n level3a\n level3b\n level2b',
|
|
24
|
+
'(level1: (level2: level3a level3b) level2b)',
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
'root:\n child1\n child2\n grandchild',
|
|
28
|
+
'(root: child1 (child2 grandchild))',
|
|
29
|
+
],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
test('indented ID retains nested values', () => {
|
|
33
|
+
for (const [source, expected] of cases) {
|
|
34
|
+
const parser = new Parser();
|
|
35
|
+
expect(formatLinks(parser.parse(source))).toBe(expected);
|
|
36
|
+
expect(parser.parse(source)).toEqual(parser.parse(expected));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
@@ -123,7 +123,6 @@ test('Indented ID with deeper nesting', () => {
|
|
|
123
123
|
child2
|
|
124
124
|
grandchild`;
|
|
125
125
|
|
|
126
|
-
// This should work but the grandchild will be processed as a separate nested structure
|
|
127
126
|
const result = parser.parse(input);
|
|
128
127
|
expect(result.length).toBeGreaterThan(0);
|
|
129
128
|
|
|
@@ -131,6 +130,7 @@ test('Indented ID with deeper nesting', () => {
|
|
|
131
130
|
const rootLink = result[0];
|
|
132
131
|
expect(rootLink.id).toBe('root');
|
|
133
132
|
expect(rootLink.values.length).toBe(2);
|
|
133
|
+
expect(formatLinks(result)).toBe('(root: child1 (child2 grandchild))');
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
test('Empty indented ID should work', () => {
|
|
@@ -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
|
+
});
|