links-notation 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -275,7 +275,8 @@ var ELLIPSIS = "...";
275
275
 
276
276
  class ParseError extends Error {
277
277
  constructor(input, error) {
278
- const start = error?.location?.start ?? { offset: 0, line: 1, column: 1 };
278
+ const start = positionAt(input, error?.location?.start?.offset ?? 0);
279
+ const end = positionAt(input, error?.location?.end?.offset ?? start.offset);
279
280
  const lineText = lineAt(input, start.offset);
280
281
  const summary = `line ${start.line}, column ${start.column}: ${error.message}`;
281
282
  const snippet = quote(start.line, lineText, start.column);
@@ -283,7 +284,7 @@ class ParseError extends Error {
283
284
  ${snippet}`);
284
285
  this.name = "ParseError";
285
286
  this.cause = error;
286
- this.location = error.location;
287
+ this.location = { ...error.location, start, end };
287
288
  this.offset = start.offset;
288
289
  this.line = start.line;
289
290
  this.column = start.column;
@@ -294,12 +295,32 @@ ${snippet}`);
294
295
  }
295
296
  function lineAt(input, offset) {
296
297
  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;
298
+ const before = input.slice(0, at);
299
+ const start = Math.max(before.lastIndexOf(`
300
+ `), before.lastIndexOf("\r")) + 1;
301
+ const end = input.slice(start).search(/[\r\n]/);
302
+ return input.slice(start, end === -1 ? input.length : start + end);
303
+ }
304
+ function positionAt(input, offset) {
305
+ const at = Math.max(0, Math.min(offset, input.length));
306
+ let line = 1;
307
+ let column = 1;
308
+ for (let index = 0;index < at; index++) {
309
+ if (input[index] === "\r") {
310
+ line++;
311
+ column = 1;
312
+ if (input[index + 1] === `
313
+ ` && index + 1 < at)
314
+ index++;
315
+ } else if (input[index] === `
316
+ `) {
317
+ line++;
318
+ column = 1;
319
+ } else {
320
+ column++;
321
+ }
322
+ }
323
+ return { offset: at, line, column };
303
324
  }
304
325
  function quote(number, lineText, column) {
305
326
  const [quoted, at] = windowAround(lineText, column);
@@ -400,7 +421,7 @@ function isSubstantiveBody(content) {
400
421
  return false;
401
422
  }
402
423
  }
403
- if (!/\s/.test(character)) {
424
+ if (!/[ \t\n\r]/.test(character)) {
404
425
  hasVisible = true;
405
426
  }
406
427
  }
@@ -2220,7 +2241,7 @@ function peg$parse(input, options) {
2220
2241
  return false;
2221
2242
  }
2222
2243
  }
2223
- if (!/\s/.test(c)) {
2244
+ if (!/[ \t\n\r]/.test(c)) {
2224
2245
  hasVisible = true;
2225
2246
  }
2226
2247
  }
@@ -2334,12 +2355,7 @@ class Parser {
2334
2355
  return;
2335
2356
  if (item.children && item.children.length > 0) {
2336
2357
  if (item.id !== undefined && item.id !== null && (!item.values || item.values.length === 0)) {
2337
- const childValues = item.children.map((child) => {
2338
- if (child.values && child.values.length === 1) {
2339
- return this.transformLink(child.values[0]);
2340
- }
2341
- return this.transformLink(child);
2342
- });
2358
+ const childValues = item.children.map((child) => this.transformIndentedValue(child));
2343
2359
  const linkWithChildren = {
2344
2360
  id: item.id,
2345
2361
  values: childValues
@@ -2371,6 +2387,23 @@ class Parser {
2371
2387
  }
2372
2388
  }
2373
2389
  }
2390
+ transformIndentedValue(item) {
2391
+ const children = item.children || [];
2392
+ if (children.length && item.id != null && !item.values?.length) {
2393
+ return new Link(item.id, children.map((child) => this.transformIndentedValue(child)));
2394
+ }
2395
+ const current = this.transformLink(item);
2396
+ if (children.length) {
2397
+ return new Link(current.id, [
2398
+ ...current.values,
2399
+ ...children.map((child) => this.transformIndentedValue(child))
2400
+ ]);
2401
+ }
2402
+ if (item.id == null && item.nested === undefined && current.values.length === 1) {
2403
+ return current.values[0];
2404
+ }
2405
+ return current;
2406
+ }
2374
2407
  combinePathElements(pathElements, current) {
2375
2408
  if (pathElements.length === 0)
2376
2409
  return current;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
package/src/ParseError.js CHANGED
@@ -19,7 +19,8 @@ export class ParseError extends Error {
19
19
  * @param {Error} error - The error the generated parser threw
20
20
  */
21
21
  constructor(input, error) {
22
- const start = error?.location?.start ?? { offset: 0, line: 1, column: 1 };
22
+ const start = positionAt(input, error?.location?.start?.offset ?? 0);
23
+ const end = positionAt(input, error?.location?.end?.offset ?? start.offset);
23
24
  const lineText = lineAt(input, start.offset);
24
25
  const summary = `line ${start.line}, column ${start.column}: ${error.message}`;
25
26
  const snippet = quote(start.line, lineText, start.column);
@@ -29,7 +30,7 @@ export class ParseError extends Error {
29
30
  /** @type {Error} The error the generated parser threw */
30
31
  this.cause = error;
31
32
  /** @type {{start: {offset: number, line: number, column: number}}} */
32
- this.location = error.location;
33
+ this.location = { ...error.location, start, end };
33
34
  /** @type {number} Offset of the offending position from the start of the document */
34
35
  this.offset = start.offset;
35
36
  /** @type {number} Line the offending position is on, counted from 1 */
@@ -53,10 +54,37 @@ export class ParseError extends Error {
53
54
  */
54
55
  function lineAt(input, offset) {
55
56
  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;
57
+ const before = input.slice(0, at);
58
+ const start =
59
+ Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1;
60
+ const end = input.slice(start).search(/[\r\n]/);
61
+ return input.slice(start, end === -1 ? input.length : start + end);
62
+ }
63
+
64
+ /**
65
+ * Count CR, LF and CRLF as line breaks, as the grammar does. Peggy's error
66
+ * location counts a lone CR as an ordinary character.
67
+ * @param {string} input - The document being parsed
68
+ * @param {number} offset - Offset of the position
69
+ * @returns {{offset: number, line: number, column: number}} The position
70
+ */
71
+ function positionAt(input, offset) {
72
+ const at = Math.max(0, Math.min(offset, input.length));
73
+ let line = 1;
74
+ let column = 1;
75
+ for (let index = 0; index < at; index++) {
76
+ if (input[index] === '\r') {
77
+ line++;
78
+ column = 1;
79
+ if (input[index + 1] === '\n' && index + 1 < at) index++;
80
+ } else if (input[index] === '\n') {
81
+ line++;
82
+ column = 1;
83
+ } else {
84
+ column++;
85
+ }
86
+ }
87
+ return { offset: at, line, column };
60
88
  }
61
89
 
62
90
  /**
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
- // For indented children, extract the actual reference from the child's values
85
- if (child.values && child.values.length === 1) {
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/comments.js CHANGED
@@ -155,7 +155,7 @@ function isSubstantiveBody(content) {
155
155
  return false;
156
156
  }
157
157
  }
158
- if (!/\s/.test(character)) {
158
+ if (!/[ \t\n\r]/.test(character)) {
159
159
  hasVisible = true;
160
160
  }
161
161
  }
package/src/grammar.pegjs CHANGED
@@ -83,7 +83,7 @@
83
83
  return false;
84
84
  }
85
85
  }
86
- if (!/\s/.test(c)) {
86
+ if (!/[ \t\n\r]/.test(c)) {
87
87
  hasVisible = true;
88
88
  }
89
89
  }
@@ -1881,7 +1881,7 @@ function peg$parse(input, options) {
1881
1881
  return false;
1882
1882
  }
1883
1883
  }
1884
- if (!/\s/.test(c)) {
1884
+ if (!/[ \t\n\r]/.test(c)) {
1885
1885
  hasVisible = true;
1886
1886
  }
1887
1887
  }
@@ -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,46 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { Parser } from '../src/Parser.js';
3
+
4
+ const parser = new Parser();
5
+
6
+ function render(node) {
7
+ if (!node.values || node.values.length === 0) {
8
+ return `<${node.id ?? ''}>`;
9
+ }
10
+ const head =
11
+ node.id === null || node.id === undefined ? '' : `<${node.id}>: `;
12
+ return `(${head}${node.values.map(render).join(' ')})`;
13
+ }
14
+
15
+ function parsesAs(source) {
16
+ return parser.parse(source).map(render).join('\n');
17
+ }
18
+
19
+ test('CR is a line break', () => {
20
+ expect(parsesAs('a\rb')).toBe('(<a>)\n(<b>)');
21
+ expect(parsesAs('a\r\nb')).toBe('(<a>)\n(<b>)');
22
+ });
23
+
24
+ test('only grammar whitespace makes an empty document', () => {
25
+ expect(parsesAs(' \t\n\r')).toBe('');
26
+ for (const character of ['\u0085', '\u00a0', '\u2028', '\u3000', '\ufeff']) {
27
+ expect(parsesAs(character)).toBe(`(<${character}>)`);
28
+ }
29
+ });
30
+
31
+ test('Unicode spaces are substantive even quote bodies', () => {
32
+ for (const character of ['\u0085', '\u00a0', '\u2028', '\u3000', '\ufeff']) {
33
+ expect(parsesAs(`''${character}''`)).toBe(`(<${character}>)`);
34
+ }
35
+ });
36
+
37
+ test('trailing indentation is document whitespace', () => {
38
+ expect(parsesAs('a\n ')).toBe('(<a>)');
39
+ expect(parsesAs('a\n \t')).toBe('(<a>)');
40
+ });
41
+
42
+ test('a single reference is a link value at every depth', () => {
43
+ expect(parsesAs('1')).toBe('(<1>)');
44
+ expect(parsesAs('(1)')).toBe('(<1>)');
45
+ expect(parsesAs('((1))')).toBe('((<1>))');
46
+ });
@@ -50,6 +50,14 @@ test('reports the line a late defect is on', () => {
50
50
  expect(error.lineText).toBe('e: f: g');
51
51
  });
52
52
 
53
+ test('lone CR counts as one line break in diagnostics', () => {
54
+ const error = syntaxError('a\rb: c: d');
55
+
56
+ expect(error.line).toBe(2);
57
+ expect(error.column).toBe(5);
58
+ expect(error.lineText).toBe('b: c: d');
59
+ });
60
+
53
61
  test('reports the end of the document when a group is never closed', () => {
54
62
  const error = syntaxError('a (b\n');
55
63