links-notation 0.16.1 → 0.18.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 +63 -2
- package/README.ru.md +63 -2
- package/dist/index.js +85 -31
- package/index.d.ts +52 -1
- package/package.json +1 -1
- package/src/ParseError.js +104 -0
- package/src/Parser.js +6 -2
- package/src/index.js +1 -0
- package/tests/ParseErrorPosition.test.js +109 -0
package/README.md
CHANGED
|
@@ -223,6 +223,35 @@ parent
|
|
|
223
223
|
grandchild2
|
|
224
224
|
```
|
|
225
225
|
|
|
226
|
+
### Multi-line Groups
|
|
227
|
+
|
|
228
|
+
A parenthesized group opens a _nested context_: its body starts fresh at
|
|
229
|
+
indentation level zero and follows the same rules as the root document, so a
|
|
230
|
+
line break inside parentheses is structure rather than decoration.
|
|
231
|
+
|
|
232
|
+
```lino
|
|
233
|
+
value (
|
|
234
|
+
id "1"
|
|
235
|
+
label "one"
|
|
236
|
+
)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The document above parses to `(value ((id 1) (label one)))` - two children, each
|
|
240
|
+
a link of its own - rather than to one flat list in which the boundary between
|
|
241
|
+
`id` and `label` would be lost. A body that stays on a single line still
|
|
242
|
+
collapses to a single link, so `(a b c)` is unchanged.
|
|
243
|
+
|
|
244
|
+
```javascript
|
|
245
|
+
import { Parser, formatLinks } from 'links-notation';
|
|
246
|
+
|
|
247
|
+
const links = new Parser().parse(`value (
|
|
248
|
+
id "1"
|
|
249
|
+
label "one"
|
|
250
|
+
)`);
|
|
251
|
+
|
|
252
|
+
console.log(formatLinks(links)); // (value ((id 1) (label one)))
|
|
253
|
+
```
|
|
254
|
+
|
|
226
255
|
## API Reference
|
|
227
256
|
|
|
228
257
|
### Classes
|
|
@@ -250,11 +279,44 @@ Container for grouping related links.
|
|
|
250
279
|
- `constructor(links)` - Create a new group
|
|
251
280
|
- `format()` - Format the group as a string
|
|
252
281
|
|
|
282
|
+
#### `ParseError`
|
|
283
|
+
|
|
284
|
+
Thrown by `parse(input)` when the document does not parse. The message says
|
|
285
|
+
where the document stopped making sense and quotes the offending line:
|
|
286
|
+
|
|
287
|
+
```js
|
|
288
|
+
import { Parser, ParseError } from 'links-notation';
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
new Parser().parse('# ok line\n# break: two\nci_gate x\n');
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error(error.message);
|
|
294
|
+
if (error instanceof ParseError) {
|
|
295
|
+
console.error(`${error.line}:${error.column} (offset ${error.offset})`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
```text
|
|
301
|
+
Syntax error at line 2, column 8: Expected "(", [ \t], [\r\n], or [^ \t\n\r(:)] but ":" found.
|
|
302
|
+
2 | # break: two
|
|
303
|
+
| ^
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
- `offset` - Offset of the offending position from the start of the document
|
|
307
|
+
- `line`, `column` - Where the document stopped parsing, counted from 1
|
|
308
|
+
- `found` - The character found instead, or `null` at the end of the document
|
|
309
|
+
- `lineText` - The offending line, as written
|
|
310
|
+
- `snippet` - The offending line with a caret under the offending column
|
|
311
|
+
- `location` - The position as the generated parser reports it
|
|
312
|
+
- `cause` - The error the generated parser threw
|
|
313
|
+
|
|
253
314
|
## Project Structure
|
|
254
315
|
|
|
255
316
|
- `src/grammar.pegjs` - Peggy.js grammar definition
|
|
256
317
|
- `src/Link.js` - Link data structure
|
|
257
318
|
- `src/LinksGroup.js` - Links group container
|
|
319
|
+
- `src/ParseError.js` - Parse error with the position of the defect
|
|
258
320
|
- `src/Parser.js` - Parser wrapper
|
|
259
321
|
- `src/index.js` - Main entry point
|
|
260
322
|
- `tests/` - Test files
|
|
@@ -315,5 +377,4 @@ fail the format check.
|
|
|
315
377
|
## Package Information
|
|
316
378
|
|
|
317
379
|
- Package: `links-notation`
|
|
318
|
-
-
|
|
319
|
-
- License: MIT
|
|
380
|
+
- License: Unlicense (see [LICENSE](../LICENSE))
|
package/README.ru.md
CHANGED
|
@@ -142,6 +142,35 @@ console.log(group.format());
|
|
|
142
142
|
внук2
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
+
### Многострочные группы
|
|
146
|
+
|
|
147
|
+
Скобочная группа открывает _вложенный контекст_: её тело начинается заново с
|
|
148
|
+
нулевого уровня отступа и подчиняется тем же правилам, что и корень документа,
|
|
149
|
+
поэтому перенос строки внутри скобок — это структура, а не оформление.
|
|
150
|
+
|
|
151
|
+
```lino
|
|
152
|
+
value (
|
|
153
|
+
id "1"
|
|
154
|
+
label "one"
|
|
155
|
+
)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Документ выше разбирается в `(value ((id 1) (label one)))` — два потомка,
|
|
159
|
+
каждый из которых сам является связью, — а не в один плоский список, в котором
|
|
160
|
+
граница между `id` и `label` была бы потеряна. Тело, умещающееся в одну строку,
|
|
161
|
+
по-прежнему сворачивается в одну связь, так что `(a b c)` не меняется.
|
|
162
|
+
|
|
163
|
+
```javascript
|
|
164
|
+
import { Parser, formatLinks } from 'links-notation';
|
|
165
|
+
|
|
166
|
+
const links = new Parser().parse(`value (
|
|
167
|
+
id "1"
|
|
168
|
+
label "one"
|
|
169
|
+
)`);
|
|
170
|
+
|
|
171
|
+
console.log(formatLinks(links)); // (value ((id 1) (label one)))
|
|
172
|
+
```
|
|
173
|
+
|
|
145
174
|
## Справочник API
|
|
146
175
|
|
|
147
176
|
### Классы
|
|
@@ -169,11 +198,44 @@ console.log(group.format());
|
|
|
169
198
|
- `constructor(links)` - Создание новой группы
|
|
170
199
|
- `format()` - Форматирование группы в строку
|
|
171
200
|
|
|
201
|
+
#### `ParseError`
|
|
202
|
+
|
|
203
|
+
Выбрасывается методом `parse(input)`, когда документ не разбирается. Сообщение
|
|
204
|
+
говорит, где документ перестал быть понятным, и цитирует сломанную строку:
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
import { Parser, ParseError } from 'links-notation';
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
new Parser().parse('# ok line\n# break: two\nci_gate x\n');
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.error(error.message);
|
|
213
|
+
if (error instanceof ParseError) {
|
|
214
|
+
console.error(`${error.line}:${error.column} (смещение ${error.offset})`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
```text
|
|
220
|
+
Syntax error at line 2, column 8: Expected "(", [ \t], [\r\n], or [^ \t\n\r(:)] but ":" found.
|
|
221
|
+
2 | # break: two
|
|
222
|
+
| ^
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- `offset` - Смещение сломанной позиции от начала документа
|
|
226
|
+
- `line`, `column` - Где документ перестал разбираться, счет с 1
|
|
227
|
+
- `found` - Найденный символ или `null` в конце документа
|
|
228
|
+
- `lineText` - Сломанная строка, как она написана
|
|
229
|
+
- `snippet` - Сломанная строка с указателем под нужным столбцом
|
|
230
|
+
- `location` - Позиция в том виде, в каком ее сообщает сгенерированный парсер
|
|
231
|
+
- `cause` - Ошибка, которую выбросил сгенерированный парсер
|
|
232
|
+
|
|
172
233
|
## Структура проекта
|
|
173
234
|
|
|
174
235
|
- `src/grammar.pegjs` - Определение грамматики Peggy.js
|
|
175
236
|
- `src/Link.js` - Структура данных связи
|
|
176
237
|
- `src/LinksGroup.js` - Контейнер групп связей
|
|
238
|
+
- `src/ParseError.js` - Ошибка разбора с позицией дефекта
|
|
177
239
|
- `src/Parser.js` - Обертка парсера
|
|
178
240
|
- `src/index.js` - Главная точка входа
|
|
179
241
|
- `tests/` - Файлы тестов
|
|
@@ -186,5 +248,4 @@ console.log(group.format());
|
|
|
186
248
|
## Информация о пакете
|
|
187
249
|
|
|
188
250
|
- Пакет: `links-notation`
|
|
189
|
-
-
|
|
190
|
-
- Лицензия: MIT
|
|
251
|
+
- Лицензия: Unlicense (см. [LICENSE](../LICENSE))
|
package/dist/index.js
CHANGED
|
@@ -149,13 +149,13 @@ class Link {
|
|
|
149
149
|
if (options.shouldIndentByRefCount(this.values.length)) {
|
|
150
150
|
shouldIndent = true;
|
|
151
151
|
} else {
|
|
152
|
-
const
|
|
152
|
+
const valuesStr = this.values.map((v) => this.formatValue(v)).join(" ");
|
|
153
153
|
let testLine;
|
|
154
154
|
if (this.id !== null) {
|
|
155
|
-
const
|
|
156
|
-
testLine = options.lessParentheses ? `${
|
|
155
|
+
const idStr = Link.escapeReference(this.id);
|
|
156
|
+
testLine = options.lessParentheses ? `${idStr}: ${valuesStr}` : `(${idStr}: ${valuesStr})`;
|
|
157
157
|
} else {
|
|
158
|
-
testLine = options.lessParentheses ?
|
|
158
|
+
testLine = options.lessParentheses ? valuesStr : `(${valuesStr})`;
|
|
159
159
|
}
|
|
160
160
|
if (options.shouldIndentByLength(testLine)) {
|
|
161
161
|
shouldIndent = true;
|
|
@@ -181,8 +181,8 @@ class Link {
|
|
|
181
181
|
}
|
|
182
182
|
_formatIndented(options) {
|
|
183
183
|
if (this.id === null) {
|
|
184
|
-
const
|
|
185
|
-
return
|
|
184
|
+
const lines = this.values.map((v) => options.indentString + this.formatValue(v));
|
|
185
|
+
return lines.join(`
|
|
186
186
|
`);
|
|
187
187
|
}
|
|
188
188
|
const idStr = Link.escapeReference(this.id);
|
|
@@ -269,6 +269,57 @@ class LinksGroup {
|
|
|
269
269
|
return list.map((item) => `(${item.id || item})`).join(" ");
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
|
+
// src/ParseError.js
|
|
273
|
+
var QUOTED_LINE_WIDTH = 80;
|
|
274
|
+
var ELLIPSIS = "...";
|
|
275
|
+
|
|
276
|
+
class ParseError extends Error {
|
|
277
|
+
constructor(input, error) {
|
|
278
|
+
const start = error?.location?.start ?? { offset: 0, line: 1, column: 1 };
|
|
279
|
+
const lineText = lineAt(input, start.offset);
|
|
280
|
+
const summary = `line ${start.line}, column ${start.column}: ${error.message}`;
|
|
281
|
+
const snippet = quote(start.line, lineText, start.column);
|
|
282
|
+
super(`Syntax error at ${summary}
|
|
283
|
+
${snippet}`);
|
|
284
|
+
this.name = "ParseError";
|
|
285
|
+
this.cause = error;
|
|
286
|
+
this.location = error.location;
|
|
287
|
+
this.offset = start.offset;
|
|
288
|
+
this.line = start.line;
|
|
289
|
+
this.column = start.column;
|
|
290
|
+
this.found = error.found ?? null;
|
|
291
|
+
this.lineText = lineText;
|
|
292
|
+
this.snippet = snippet;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function lineAt(input, offset) {
|
|
296
|
+
const at = Math.max(0, Math.min(offset, input.length));
|
|
297
|
+
const start = input.lastIndexOf(`
|
|
298
|
+
`, at - 1) + 1;
|
|
299
|
+
const end = input.indexOf(`
|
|
300
|
+
`, start);
|
|
301
|
+
const line = input.slice(start, end === -1 ? input.length : end);
|
|
302
|
+
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
303
|
+
}
|
|
304
|
+
function quote(number, lineText, column) {
|
|
305
|
+
const [quoted, at] = windowAround(lineText, column);
|
|
306
|
+
const gutter = " ".repeat(String(number).length);
|
|
307
|
+
return `${number} | ${quoted}
|
|
308
|
+
${gutter} | ${" ".repeat(at - 1)}^`;
|
|
309
|
+
}
|
|
310
|
+
function windowAround(lineText, column) {
|
|
311
|
+
if (lineText.length <= QUOTED_LINE_WIDTH) {
|
|
312
|
+
return [lineText, column];
|
|
313
|
+
}
|
|
314
|
+
const target = column - 1;
|
|
315
|
+
const lastStart = lineText.length - QUOTED_LINE_WIDTH;
|
|
316
|
+
const start = Math.min(Math.max(target - Math.floor(QUOTED_LINE_WIDTH / 2), 0), lastStart);
|
|
317
|
+
const end = start + QUOTED_LINE_WIDTH;
|
|
318
|
+
const quoted = (start > 0 ? ELLIPSIS : "") + lineText.slice(start, end) + (end < lineText.length ? ELLIPSIS : "");
|
|
319
|
+
const shift = start > 0 ? ELLIPSIS.length : 0;
|
|
320
|
+
return [quoted, target - start + shift + 1];
|
|
321
|
+
}
|
|
322
|
+
|
|
272
323
|
// src/parser-generated.js
|
|
273
324
|
class peg$SyntaxError extends SyntaxError {
|
|
274
325
|
constructor(message, expected, found, location) {
|
|
@@ -282,7 +333,7 @@ class peg$SyntaxError extends SyntaxError {
|
|
|
282
333
|
let str = "Error: " + this.message;
|
|
283
334
|
if (this.location) {
|
|
284
335
|
let src = null;
|
|
285
|
-
const st = sources.find((
|
|
336
|
+
const st = sources.find((s) => s.source === this.location.source);
|
|
286
337
|
if (st) {
|
|
287
338
|
src = st.text.split(/\r\n|\n|\r/g);
|
|
288
339
|
}
|
|
@@ -345,8 +396,8 @@ class peg$SyntaxError extends SyntaxError {
|
|
|
345
396
|
function describeExpectation(expectation) {
|
|
346
397
|
return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation);
|
|
347
398
|
}
|
|
348
|
-
function describeExpected(
|
|
349
|
-
const descriptions =
|
|
399
|
+
function describeExpected(expected) {
|
|
400
|
+
const descriptions = expected.map(describeExpectation);
|
|
350
401
|
descriptions.sort();
|
|
351
402
|
if (descriptions.length > 0) {
|
|
352
403
|
let j = 1;
|
|
@@ -367,8 +418,8 @@ class peg$SyntaxError extends SyntaxError {
|
|
|
367
418
|
return descriptions.slice(0, -1).join(", ") + ", or " + descriptions[descriptions.length - 1];
|
|
368
419
|
}
|
|
369
420
|
}
|
|
370
|
-
function describeFound(
|
|
371
|
-
return
|
|
421
|
+
function describeFound(found) {
|
|
422
|
+
return found ? '"' + literalEscape(found) + '"' : "end of input";
|
|
372
423
|
}
|
|
373
424
|
return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found.";
|
|
374
425
|
}
|
|
@@ -597,13 +648,13 @@ function peg$parse(input, options) {
|
|
|
597
648
|
function location() {
|
|
598
649
|
return peg$computeLocation(peg$savedPos, peg$currPos);
|
|
599
650
|
}
|
|
600
|
-
function expected(description,
|
|
601
|
-
|
|
602
|
-
throw peg$buildStructuredError([peg$otherExpectation(description)], input.substring(peg$savedPos, peg$currPos),
|
|
651
|
+
function expected(description, location) {
|
|
652
|
+
location = location !== undefined ? location : peg$computeLocation(peg$savedPos, peg$currPos);
|
|
653
|
+
throw peg$buildStructuredError([peg$otherExpectation(description)], input.substring(peg$savedPos, peg$currPos), location);
|
|
603
654
|
}
|
|
604
|
-
function error(message,
|
|
605
|
-
|
|
606
|
-
throw peg$buildSimpleError(message,
|
|
655
|
+
function error(message, location) {
|
|
656
|
+
location = location !== undefined ? location : peg$computeLocation(peg$savedPos, peg$currPos);
|
|
657
|
+
throw peg$buildSimpleError(message, location);
|
|
607
658
|
}
|
|
608
659
|
function peg$getUnicode(pos = peg$currPos) {
|
|
609
660
|
const cp = input.codePointAt(pos);
|
|
@@ -612,8 +663,8 @@ function peg$parse(input, options) {
|
|
|
612
663
|
}
|
|
613
664
|
return String.fromCodePoint(cp);
|
|
614
665
|
}
|
|
615
|
-
function peg$literalExpectation(
|
|
616
|
-
return { type: "literal", text
|
|
666
|
+
function peg$literalExpectation(text, ignoreCase) {
|
|
667
|
+
return { type: "literal", text, ignoreCase };
|
|
617
668
|
}
|
|
618
669
|
function peg$classExpectation(parts, inverted, ignoreCase, unicode) {
|
|
619
670
|
return { type: "class", parts, inverted, ignoreCase, unicode };
|
|
@@ -657,7 +708,7 @@ function peg$parse(input, options) {
|
|
|
657
708
|
return details;
|
|
658
709
|
}
|
|
659
710
|
}
|
|
660
|
-
function peg$computeLocation(startPos, endPos,
|
|
711
|
+
function peg$computeLocation(startPos, endPos, offset) {
|
|
661
712
|
const startPosDetails = peg$computePosDetails(startPos);
|
|
662
713
|
const endPosDetails = peg$computePosDetails(endPos);
|
|
663
714
|
const res = {
|
|
@@ -673,13 +724,13 @@ function peg$parse(input, options) {
|
|
|
673
724
|
column: endPosDetails.column
|
|
674
725
|
}
|
|
675
726
|
};
|
|
676
|
-
if (
|
|
727
|
+
if (offset && peg$source && typeof peg$source.offset === "function") {
|
|
677
728
|
res.start = peg$source.offset(res.start);
|
|
678
729
|
res.end = peg$source.offset(res.end);
|
|
679
730
|
}
|
|
680
731
|
return res;
|
|
681
732
|
}
|
|
682
|
-
function peg$fail(
|
|
733
|
+
function peg$fail(expected) {
|
|
683
734
|
if (peg$currPos < peg$maxFailPos) {
|
|
684
735
|
return;
|
|
685
736
|
}
|
|
@@ -687,13 +738,13 @@ function peg$parse(input, options) {
|
|
|
687
738
|
peg$maxFailPos = peg$currPos;
|
|
688
739
|
peg$maxFailExpected = [];
|
|
689
740
|
}
|
|
690
|
-
peg$maxFailExpected.push(
|
|
741
|
+
peg$maxFailExpected.push(expected);
|
|
691
742
|
}
|
|
692
|
-
function peg$buildSimpleError(message,
|
|
693
|
-
return new peg$SyntaxError(message, null, null,
|
|
743
|
+
function peg$buildSimpleError(message, location) {
|
|
744
|
+
return new peg$SyntaxError(message, null, null, location);
|
|
694
745
|
}
|
|
695
|
-
function peg$buildStructuredError(
|
|
696
|
-
return new peg$SyntaxError(peg$SyntaxError.buildMessage(
|
|
746
|
+
function peg$buildStructuredError(expected, found, location) {
|
|
747
|
+
return new peg$SyntaxError(peg$SyntaxError.buildMessage(expected, found), expected, found, location);
|
|
697
748
|
}
|
|
698
749
|
function peg$parsedocument() {
|
|
699
750
|
let s0, s1, s2, s3, s4, s5;
|
|
@@ -2024,9 +2075,11 @@ class Parser {
|
|
|
2024
2075
|
const rawResult = peg$parse(input);
|
|
2025
2076
|
return this.transformResult(rawResult);
|
|
2026
2077
|
} catch (error) {
|
|
2078
|
+
if (error && error.location) {
|
|
2079
|
+
throw new ParseError(input, error);
|
|
2080
|
+
}
|
|
2027
2081
|
const parseError = new Error(`Parse error: ${error.message}`);
|
|
2028
2082
|
parseError.cause = error;
|
|
2029
|
-
parseError.location = error.location;
|
|
2030
2083
|
throw parseError;
|
|
2031
2084
|
}
|
|
2032
2085
|
}
|
|
@@ -2086,9 +2139,9 @@ class Parser {
|
|
|
2086
2139
|
if (pathElements.length === 0)
|
|
2087
2140
|
return current;
|
|
2088
2141
|
if (pathElements.length === 1) {
|
|
2089
|
-
const
|
|
2090
|
-
|
|
2091
|
-
return
|
|
2142
|
+
const combined = new Link(null, [pathElements[0], current]);
|
|
2143
|
+
combined._isFromPathCombination = true;
|
|
2144
|
+
return combined;
|
|
2092
2145
|
}
|
|
2093
2146
|
const parentPath = pathElements.slice(0, -1);
|
|
2094
2147
|
const lastElement = pathElements[pathElements.length - 1];
|
|
@@ -2163,6 +2216,7 @@ export {
|
|
|
2163
2216
|
FormatOptions,
|
|
2164
2217
|
Link,
|
|
2165
2218
|
LinksGroup,
|
|
2219
|
+
ParseError,
|
|
2166
2220
|
Parser,
|
|
2167
2221
|
formatLinks
|
|
2168
2222
|
};
|
package/index.d.ts
CHANGED
|
@@ -144,11 +144,62 @@ export class Parser {
|
|
|
144
144
|
* Parse Lino notation text into Link objects
|
|
145
145
|
* @param input - The Lino notation text to parse
|
|
146
146
|
* @returns Array of parsed Link objects
|
|
147
|
-
* @throws {
|
|
147
|
+
* @throws {ParseError} If the text does not parse
|
|
148
148
|
*/
|
|
149
149
|
parse(input: string): Link[];
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The position a parse stopped at, as the generated parser reports it
|
|
154
|
+
*/
|
|
155
|
+
export interface ParseErrorLocation {
|
|
156
|
+
start: { offset: number; line: number; column: number };
|
|
157
|
+
end: { offset: number; line: number; column: number };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The error thrown when a document does not parse
|
|
162
|
+
*
|
|
163
|
+
* The message says where the document stopped making sense and quotes the
|
|
164
|
+
* offending line with a caret under it.
|
|
165
|
+
*/
|
|
166
|
+
export class ParseError extends Error {
|
|
167
|
+
/**
|
|
168
|
+
* Offset of the offending position from the start of the document
|
|
169
|
+
*/
|
|
170
|
+
offset: number;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Line the offending position is on, counted from 1
|
|
174
|
+
*/
|
|
175
|
+
line: number;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Column the offending position is at, counted from 1
|
|
179
|
+
*/
|
|
180
|
+
column: number;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The character found instead, or null at the end of the document
|
|
184
|
+
*/
|
|
185
|
+
found: string | null;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The offending line, as written, without its line ending
|
|
189
|
+
*/
|
|
190
|
+
lineText: string;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The offending line with a caret under the offending column
|
|
194
|
+
*/
|
|
195
|
+
snippet: string;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The position the generated parser reported
|
|
199
|
+
*/
|
|
200
|
+
location: ParseErrorLocation;
|
|
201
|
+
}
|
|
202
|
+
|
|
152
203
|
/**
|
|
153
204
|
* Options for formatting links
|
|
154
205
|
*/
|
package/package.json
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** The number of characters a quoted line is cut down to. */
|
|
2
|
+
const QUOTED_LINE_WIDTH = 80;
|
|
3
|
+
|
|
4
|
+
/** What a message writes in place of the part of a long line it left out. */
|
|
5
|
+
const ELLIPSIS = '...';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The error thrown when a document does not parse, carrying the position the
|
|
9
|
+
* parser stopped at.
|
|
10
|
+
*
|
|
11
|
+
* The generated parser reports that position, but only on the error object; the
|
|
12
|
+
* message it writes says what it expected without saying where. This error puts
|
|
13
|
+
* the line and the column in the message as well, and quotes the offending line
|
|
14
|
+
* with a caret under it, the way the Rust and C# ports do.
|
|
15
|
+
*/
|
|
16
|
+
export class ParseError extends Error {
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} input - The document that failed to parse
|
|
19
|
+
* @param {Error} error - The error the generated parser threw
|
|
20
|
+
*/
|
|
21
|
+
constructor(input, error) {
|
|
22
|
+
const start = error?.location?.start ?? { offset: 0, line: 1, column: 1 };
|
|
23
|
+
const lineText = lineAt(input, start.offset);
|
|
24
|
+
const summary = `line ${start.line}, column ${start.column}: ${error.message}`;
|
|
25
|
+
const snippet = quote(start.line, lineText, start.column);
|
|
26
|
+
super(`Syntax error at ${summary}\n${snippet}`);
|
|
27
|
+
|
|
28
|
+
this.name = 'ParseError';
|
|
29
|
+
/** @type {Error} The error the generated parser threw */
|
|
30
|
+
this.cause = error;
|
|
31
|
+
/** @type {{start: {offset: number, line: number, column: number}}} */
|
|
32
|
+
this.location = error.location;
|
|
33
|
+
/** @type {number} Offset of the offending position from the start of the document */
|
|
34
|
+
this.offset = start.offset;
|
|
35
|
+
/** @type {number} Line the offending position is on, counted from 1 */
|
|
36
|
+
this.line = start.line;
|
|
37
|
+
/** @type {number} Column the offending position is at, counted from 1 */
|
|
38
|
+
this.column = start.column;
|
|
39
|
+
/** @type {string|null} The character found instead, or null at the end of the document */
|
|
40
|
+
this.found = error.found ?? null;
|
|
41
|
+
/** @type {string} The offending line, as written, without its line ending */
|
|
42
|
+
this.lineText = lineText;
|
|
43
|
+
/** @type {string} The offending line with a caret under the offending column */
|
|
44
|
+
this.snippet = snippet;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The line the given offset falls on, without its line ending.
|
|
50
|
+
* @param {string} input - The document being parsed
|
|
51
|
+
* @param {number} offset - Offset of the offending position
|
|
52
|
+
* @returns {string} The offending line
|
|
53
|
+
*/
|
|
54
|
+
function lineAt(input, offset) {
|
|
55
|
+
const at = Math.max(0, Math.min(offset, input.length));
|
|
56
|
+
const start = input.lastIndexOf('\n', at - 1) + 1;
|
|
57
|
+
const end = input.indexOf('\n', start);
|
|
58
|
+
const line = input.slice(start, end === -1 ? input.length : end);
|
|
59
|
+
return line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The offending line with a caret under the offending column, quoted the way a
|
|
64
|
+
* compiler quotes source. A long line is shown as a window around the caret, so
|
|
65
|
+
* the message stays the same size whether the document has ten lines or fifteen
|
|
66
|
+
* hundred.
|
|
67
|
+
* @param {number} number - The line number, counted from 1
|
|
68
|
+
* @param {string} lineText - The offending line
|
|
69
|
+
* @param {number} column - The offending column, counted from 1
|
|
70
|
+
* @returns {string} The quoted line and the caret under it
|
|
71
|
+
*/
|
|
72
|
+
function quote(number, lineText, column) {
|
|
73
|
+
const [quoted, at] = windowAround(lineText, column);
|
|
74
|
+
const gutter = ' '.repeat(String(number).length);
|
|
75
|
+
return `${number} | ${quoted}\n${gutter} | ${' '.repeat(at - 1)}^`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Cuts a line down to a window around the given column, and says which column
|
|
80
|
+
* the offending character sits at in that window. Both columns count from 1.
|
|
81
|
+
* @param {string} lineText - The offending line
|
|
82
|
+
* @param {number} column - The offending column, counted from 1
|
|
83
|
+
* @returns {[string, number]} The window and the column within it
|
|
84
|
+
*/
|
|
85
|
+
function windowAround(lineText, column) {
|
|
86
|
+
if (lineText.length <= QUOTED_LINE_WIDTH) {
|
|
87
|
+
return [lineText, column];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const target = column - 1;
|
|
91
|
+
const lastStart = lineText.length - QUOTED_LINE_WIDTH;
|
|
92
|
+
const start = Math.min(
|
|
93
|
+
Math.max(target - Math.floor(QUOTED_LINE_WIDTH / 2), 0),
|
|
94
|
+
lastStart
|
|
95
|
+
);
|
|
96
|
+
const end = start + QUOTED_LINE_WIDTH;
|
|
97
|
+
|
|
98
|
+
const quoted =
|
|
99
|
+
(start > 0 ? ELLIPSIS : '') +
|
|
100
|
+
lineText.slice(start, end) +
|
|
101
|
+
(end < lineText.length ? ELLIPSIS : '');
|
|
102
|
+
const shift = start > 0 ? ELLIPSIS.length : 0;
|
|
103
|
+
return [quoted, target - start + shift + 1];
|
|
104
|
+
}
|
package/src/Parser.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Link } from './Link.js';
|
|
2
|
+
import { ParseError } from './ParseError.js';
|
|
2
3
|
import * as parserModule from './parser-generated.js';
|
|
3
4
|
|
|
4
5
|
export class Parser {
|
|
@@ -35,10 +36,13 @@ export class Parser {
|
|
|
35
36
|
const rawResult = parserModule.parse(input);
|
|
36
37
|
return this.transformResult(rawResult);
|
|
37
38
|
} catch (error) {
|
|
38
|
-
//
|
|
39
|
+
// A syntax error knows where it stopped; anything else is passed on with
|
|
40
|
+
// the original error kept as the cause.
|
|
41
|
+
if (error && error.location) {
|
|
42
|
+
throw new ParseError(input, error);
|
|
43
|
+
}
|
|
39
44
|
const parseError = new Error(`Parse error: ${error.message}`);
|
|
40
45
|
parseError.cause = error;
|
|
41
|
-
parseError.location = error.location;
|
|
42
46
|
throw parseError;
|
|
43
47
|
}
|
|
44
48
|
}
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { Link, formatLinks } from './Link.js';
|
|
2
2
|
export { LinksGroup } from './LinksGroup.js';
|
|
3
3
|
export { Parser } from './Parser.js';
|
|
4
|
+
export { ParseError } from './ParseError.js';
|
|
4
5
|
export { FormatConfig } from './FormatConfig.js';
|
|
5
6
|
export { FormatOptions } from './FormatOptions.js';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// A parse error has to say where the document stopped making sense.
|
|
2
|
+
//
|
|
3
|
+
// The positions asserted here are the ones the Rust and C# ports report for the
|
|
4
|
+
// same input, so the implementations can be held to the same contract
|
|
5
|
+
// (https://github.com/link-foundation/links-notation/issues/302).
|
|
6
|
+
|
|
7
|
+
import { test, expect } from 'bun:test';
|
|
8
|
+
import { Parser, ParseError } from '../src/index.js';
|
|
9
|
+
|
|
10
|
+
const parser = new Parser();
|
|
11
|
+
|
|
12
|
+
function syntaxError(document) {
|
|
13
|
+
try {
|
|
14
|
+
const links = parser.parse(document);
|
|
15
|
+
throw new Error(
|
|
16
|
+
`expected ${JSON.stringify(document)} not to parse, got ${links.length} links`
|
|
17
|
+
);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
expect(error).toBeInstanceOf(ParseError);
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
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');
|
|
28
|
+
|
|
29
|
+
expect(error.line).toBe(2);
|
|
30
|
+
expect(error.column).toBe(8);
|
|
31
|
+
expect(error.found).toBe(':');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
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
|
+
|
|
38
|
+
expect(error.offset).toBe(17);
|
|
39
|
+
expect(error.line).toBe(2);
|
|
40
|
+
expect(error.column).toBe(8);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('reports the line a late defect is on', () => {
|
|
44
|
+
const error = syntaxError('a\nb\nc\nd\ne: f: g\nh\n');
|
|
45
|
+
|
|
46
|
+
expect(error.line).toBe(5);
|
|
47
|
+
expect(error.column).toBe(5);
|
|
48
|
+
expect(error.lineText).toBe('e: f: g');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('reports the end of the document when a group is never closed', () => {
|
|
52
|
+
const error = syntaxError('a (b\n');
|
|
53
|
+
|
|
54
|
+
expect(error.offset).toBe(5);
|
|
55
|
+
expect(error.line).toBe(2);
|
|
56
|
+
expect(error.column).toBe(1);
|
|
57
|
+
expect(error.found).toBeNull();
|
|
58
|
+
expect(error.message).toContain('end of input');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('reports an unmatched closing parenthesis', () => {
|
|
62
|
+
const error = syntaxError('a b)\n');
|
|
63
|
+
|
|
64
|
+
expect(error.offset).toBe(3);
|
|
65
|
+
expect(error.line).toBe(1);
|
|
66
|
+
expect(error.column).toBe(4);
|
|
67
|
+
expect(error.found).toBe(')');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('message says where the document broke', () => {
|
|
71
|
+
const error = syntaxError('# ok line\n# break: two\n');
|
|
72
|
+
|
|
73
|
+
expect(error.message.startsWith('Syntax error at line 2, column 8:')).toBe(
|
|
74
|
+
true
|
|
75
|
+
);
|
|
76
|
+
expect(error.snippet).toBe('2 | # break: two\n | ^');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
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)}`;
|
|
81
|
+
|
|
82
|
+
const error = syntaxError(document);
|
|
83
|
+
|
|
84
|
+
expect(error.message).toContain('line 2, column 8');
|
|
85
|
+
expect(error.message).not.toContain('trailing line');
|
|
86
|
+
expect(error.message.length).toBeLessThan(200);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('message of a long line stays a message', () => {
|
|
90
|
+
const document = `${'a'.repeat(400)}: ${'b'.repeat(400)}: c`;
|
|
91
|
+
|
|
92
|
+
const error = syntaxError(document);
|
|
93
|
+
|
|
94
|
+
expect(error.line).toBe(1);
|
|
95
|
+
expect(error.column).toBe(803);
|
|
96
|
+
expect(error.message).toContain('...');
|
|
97
|
+
expect(error.message.length).toBeLessThan(300);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the location the parser used to report is still there', () => {
|
|
101
|
+
const error = syntaxError('a: b: c');
|
|
102
|
+
|
|
103
|
+
expect(error.location.start).toEqual({ offset: 4, line: 1, column: 5 });
|
|
104
|
+
expect(error.cause).toBeDefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a document that parses reports nothing', () => {
|
|
108
|
+
expect(parser.parse('a: b\n c: d\n').length).toBe(2);
|
|
109
|
+
});
|