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 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
- - Version: 0.1.0
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
- - Версия: 0.1.0
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 valuesStr2 = this.values.map((v) => this.formatValue(v)).join(" ");
152
+ const valuesStr = this.values.map((v) => this.formatValue(v)).join(" ");
153
153
  let testLine;
154
154
  if (this.id !== null) {
155
- const idStr2 = Link.escapeReference(this.id);
156
- testLine = options.lessParentheses ? `${idStr2}: ${valuesStr2}` : `(${idStr2}: ${valuesStr2})`;
155
+ const idStr = Link.escapeReference(this.id);
156
+ testLine = options.lessParentheses ? `${idStr}: ${valuesStr}` : `(${idStr}: ${valuesStr})`;
157
157
  } else {
158
- testLine = options.lessParentheses ? valuesStr2 : `(${valuesStr2})`;
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 lines2 = this.values.map((v) => options.indentString + this.formatValue(v));
185
- return lines2.join(`
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((s2) => s2.source === this.location.source);
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(expected2) {
349
- const descriptions = expected2.map(describeExpectation);
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(found2) {
371
- return found2 ? '"' + literalEscape(found2) + '"' : "end of input";
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, location2) {
601
- location2 = location2 !== undefined ? location2 : peg$computeLocation(peg$savedPos, peg$currPos);
602
- throw peg$buildStructuredError([peg$otherExpectation(description)], input.substring(peg$savedPos, peg$currPos), location2);
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, location2) {
605
- location2 = location2 !== undefined ? location2 : peg$computeLocation(peg$savedPos, peg$currPos);
606
- throw peg$buildSimpleError(message, location2);
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(text2, ignoreCase) {
616
- return { type: "literal", text: text2, ignoreCase };
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, offset2) {
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 (offset2 && peg$source && typeof peg$source.offset === "function") {
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(expected2) {
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(expected2);
741
+ peg$maxFailExpected.push(expected);
691
742
  }
692
- function peg$buildSimpleError(message, location2) {
693
- return new peg$SyntaxError(message, null, null, location2);
743
+ function peg$buildSimpleError(message, location) {
744
+ return new peg$SyntaxError(message, null, null, location);
694
745
  }
695
- function peg$buildStructuredError(expected2, found, location2) {
696
- return new peg$SyntaxError(peg$SyntaxError.buildMessage(expected2, found), expected2, found, location2);
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 combined2 = new Link(null, [pathElements[0], current]);
2090
- combined2._isFromPathCombination = true;
2091
- return combined2;
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 {Error} If parsing fails
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.16.1",
3
+ "version": "0.18.0",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
@@ -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
- // Preserve original error information
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
+ });