links-notation 0.17.0 → 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
@@ -279,11 +279,44 @@ Container for grouping related links.
279
279
  - `constructor(links)` - Create a new group
280
280
  - `format()` - Format the group as a string
281
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
+
282
314
  ## Project Structure
283
315
 
284
316
  - `src/grammar.pegjs` - Peggy.js grammar definition
285
317
  - `src/Link.js` - Link data structure
286
318
  - `src/LinksGroup.js` - Links group container
319
+ - `src/ParseError.js` - Parse error with the position of the defect
287
320
  - `src/Parser.js` - Parser wrapper
288
321
  - `src/index.js` - Main entry point
289
322
  - `tests/` - Test files
package/README.ru.md CHANGED
@@ -198,11 +198,44 @@ console.log(formatLinks(links)); // (value ((id 1) (label one)))
198
198
  - `constructor(links)` - Создание новой группы
199
199
  - `format()` - Форматирование группы в строку
200
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
+
201
233
  ## Структура проекта
202
234
 
203
235
  - `src/grammar.pegjs` - Определение грамматики Peggy.js
204
236
  - `src/Link.js` - Структура данных связи
205
237
  - `src/LinksGroup.js` - Контейнер групп связей
238
+ - `src/ParseError.js` - Ошибка разбора с позицией дефекта
206
239
  - `src/Parser.js` - Обертка парсера
207
240
  - `src/index.js` - Главная точка входа
208
241
  - `tests/` - Файлы тестов
package/dist/index.js CHANGED
@@ -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) {
@@ -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
  }
@@ -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.17.0",
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
+ });