links-notation 0.28.0 → 0.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.28.0",
3
+ "version": "0.30.1",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
@@ -32,10 +32,13 @@
32
32
  },
33
33
  "homepage": "https://github.com/link-foundation/links-notation#readme",
34
34
  "devDependencies": {
35
- "bun-types": "^1.4.3",
36
- "eslint": "^10.13.0",
37
- "peggy": "^5.1.0",
35
+ "bun-types": "^1.4.4",
36
+ "eslint": "^10.14.0",
37
+ "peggy": "^5.1.1",
38
38
  "prettier": "^3.9.10",
39
39
  "typescript": "^7.0.2"
40
+ },
41
+ "overrides": {
42
+ "minimatch": "^11.0.4"
40
43
  }
41
44
  }
package/src/Cst.js CHANGED
@@ -17,7 +17,10 @@
17
17
 
18
18
  import { stripComments } from './comments.js';
19
19
  import { DelimitedReferences, QUOTES } from './quotes.js';
20
- import { decodeReferenceLiteral } from './ReferenceLiteral.js';
20
+ import {
21
+ decodeReferenceLiteral,
22
+ referenceLiteralEnd as literalEnd,
23
+ } from './ReferenceLiteral.js';
21
24
 
22
25
  /** The kinds of nodes and tokens of a concrete syntax tree. */
23
26
  export const CstKind = Object.freeze({
@@ -131,28 +134,6 @@ function quoteText(text) {
131
134
  return `"${text.replace(/[\\"\n\r\t]/g, (character) => ESCAPES[character])}"`;
132
135
  }
133
136
 
134
- /** What a versioned literal's payload cannot contain. */
135
- const LITERAL_PAYLOAD_END = new Set(['}', ' ', '\t', '\r', '\n', '(', ')']);
136
-
137
- /**
138
- * Where the versioned literal `~<digits>{<payload>}` that starts at `start`
139
- * ends, or -1 if none starts there. A single forward scan, so it stays linear
140
- * on any input (a regular expression here was flagged as a ReDoS risk).
141
- */
142
- function literalEnd(text, start) {
143
- if (text[start] !== '~') return -1;
144
- let index = start + 1;
145
- while (index < text.length && text[index] >= '0' && text[index] <= '9') {
146
- index++;
147
- }
148
- if (index === start + 1 || text[index] !== '{') return -1;
149
- index++;
150
- while (index < text.length && !LITERAL_PAYLOAD_END.has(text[index])) {
151
- index++;
152
- }
153
- return text[index] === '}' ? index + 1 : -1;
154
- }
155
-
156
137
  /** What ends a bare reference. */
157
138
  const BARE_REFERENCE_END = new Set([' ', '\t', '\r', '\n', '(', ':', ')']);
158
139
 
@@ -164,10 +145,12 @@ const BARE_REFERENCE_END = new Set([' ', '\t', '\r', '\n', '(', ':', ')']);
164
145
  * @returns {CstNode} The `document` node
165
146
  */
166
147
  export function buildCst(input, { comments = true } = {}) {
167
- // References are read in the document the grammar reads: the one with its
168
- // comments blanked, which keeps every position of the one written.
148
+ // Tokens end where they end in the document the grammar reads: the one with
149
+ // its comments blanked, which keeps every position of the one written.
150
+ // Delimited references are read, as the grammar reads them, in the document
151
+ // as written, so a comment never changes what a reference holds.
169
152
  const prepared = comments ? stripComments(input) : input;
170
- const references = new DelimitedReferences(prepared);
153
+ const references = new DelimitedReferences(input);
171
154
  const document = new CstNode(CstKind.DOCUMENT);
172
155
  // The open containers, innermost last, each with the line being filled.
173
156
  const containers = [{ node: document, line: null }];
package/src/Link.js CHANGED
@@ -196,7 +196,7 @@ export class Link {
196
196
  // Original implementation for backward compatibility
197
197
  // Empty link
198
198
  if (this.id === null && (!this.values || this.values.length === 0)) {
199
- return lessParentheses ? '' : '()';
199
+ return '()';
200
200
  }
201
201
 
202
202
  // Link with only ID, no values
@@ -217,7 +217,7 @@ export class Link {
217
217
  // Link with values only (null id)
218
218
  if (this.id === null) {
219
219
  // For lessParentheses mode with simple values, don't wrap the whole thing
220
- if (lessParentheses) {
220
+ if (lessParentheses && !this._hasLoneNestedValue()) {
221
221
  // Check if all values are simple (no nested values)
222
222
  const allSimple = this.values.every(
223
223
  (v) => !v.values || v.values.length === 0
@@ -288,6 +288,16 @@ export class Link {
288
288
  );
289
289
  }
290
290
 
291
+ /**
292
+ * Whether this link holds exactly one link that has values. The parentheses
293
+ * of an anonymous link are what nest such a value: `((b c))` without them is
294
+ * `(b c)`, a different link, so lessParentheses has to keep them.
295
+ * @returns {boolean} True if the only value has values of its own
296
+ */
297
+ _hasLoneNestedValue() {
298
+ return this.values.length === 1 && this.values[0].values?.length > 0;
299
+ }
300
+
291
301
  /**
292
302
  * Format the link using a FormatOptions object
293
303
  * @param {FormatOptions} options - FormatOptions object with formatting options
@@ -297,7 +307,7 @@ export class Link {
297
307
  _formatWithOptions(options, isCompoundValue = false) {
298
308
  // Empty link
299
309
  if (this.id === null && (!this.values || this.values.length === 0)) {
300
- return options.lessParentheses ? '' : '()';
310
+ return '()';
301
311
  }
302
312
 
303
313
  // Link with only ID, no values
@@ -325,7 +335,10 @@ export class Link {
325
335
  ? `${idStr}: ${valuesStr}`
326
336
  : `(${idStr}: ${valuesStr})`;
327
337
  } else {
328
- testLine = options.lessParentheses ? valuesStr : `(${valuesStr})`;
338
+ testLine =
339
+ options.lessParentheses && !this._hasLoneNestedValue()
340
+ ? valuesStr
341
+ : `(${valuesStr})`;
329
342
  }
330
343
 
331
344
  if (options.shouldIndentByLength(testLine)) {
@@ -343,7 +356,7 @@ export class Link {
343
356
 
344
357
  // Link with values only (null id)
345
358
  if (this.id === null) {
346
- if (options.lessParentheses) {
359
+ if (options.lessParentheses && !this._hasLoneNestedValue()) {
347
360
  const allSimple = this.values.every(
348
361
  (v) => !v.values || v.values.length === 0
349
362
  );
@@ -5,6 +5,28 @@ export function hasReferenceLiteralPrefix(text, start = 0) {
5
5
  return /^~[0-9]+\{/.test(text.slice(start));
6
6
  }
7
7
 
8
+ /** What a versioned literal's payload cannot contain. */
9
+ const LITERAL_PAYLOAD_END = new Set(['}', ' ', '\t', '\r', '\n', '(', ')']);
10
+
11
+ /**
12
+ * Where the versioned literal `~<digits>{<payload>}` that starts at `start`
13
+ * ends, or -1 if none starts there. A single forward scan, so it stays linear
14
+ * on any input (a regular expression here was flagged as a ReDoS risk).
15
+ */
16
+ export function referenceLiteralEnd(text, start) {
17
+ if (text[start] !== '~') return -1;
18
+ let index = start + 1;
19
+ while (index < text.length && text[index] >= '0' && text[index] <= '9') {
20
+ index++;
21
+ }
22
+ if (index === start + 1 || text[index] !== '{') return -1;
23
+ index++;
24
+ while (index < text.length && !LITERAL_PAYLOAD_END.has(text[index])) {
25
+ index++;
26
+ }
27
+ return text[index] === '}' ? index + 1 : -1;
28
+ }
29
+
8
30
  /** Encode exact Unicode text as a version 1 UTF-8 hexadecimal reference. */
9
31
  export function encodeReferenceLiteral(text) {
10
32
  if (typeof text !== 'string' || !text.isWellFormed()) {
@@ -1,6 +1,7 @@
1
1
  import { Emitter } from './Emitter.js';
2
2
  import { Parser } from './Parser.js';
3
3
  import { DelimitedReferences } from './quotes.js';
4
+ import { referenceLiteralEnd } from './ReferenceLiteral.js';
4
5
 
5
6
  const DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
6
7
 
@@ -246,26 +247,20 @@ function countNewlines(value) {
246
247
  }
247
248
 
248
249
  function structurallyComplete(document, comments) {
250
+ // Tokens are read as the comment reader and the grammar read them: a
251
+ // reference may start after a separator, and right after a delimited
252
+ // reference or a literal (issue #377).
249
253
  const quotes = ['"', "'", '`'];
250
- const beforeReference = [' ', '\t', '\n', '\r', '(', ':'];
254
+ const separators = [' ', '\t', '\n', '\r', '(', ':', ')'];
251
255
  const beforeComment = [' ', '\t', '\n', '\r'];
252
256
  const references = new DelimitedReferences(document);
253
257
  let depth = 0;
258
+ let tokenStart = true;
254
259
 
255
260
  for (let position = 0; position < document.length; position++) {
256
261
  const character = document[position];
257
262
  const previous = position === 0 ? null : document[position - 1];
258
263
 
259
- if (
260
- quotes.includes(character) &&
261
- (previous === null || beforeReference.includes(previous))
262
- ) {
263
- const end = references.endAt(position);
264
- if (end === null) return false;
265
- position = end - 1;
266
- continue;
267
- }
268
-
269
264
  if (
270
265
  comments &&
271
266
  character === '#' &&
@@ -274,11 +269,36 @@ function structurallyComplete(document, comments) {
274
269
  const newline = document.indexOf('\n', position);
275
270
  if (newline === -1) return true;
276
271
  position = newline;
272
+ tokenStart = true;
273
+ continue;
274
+ }
275
+
276
+ if (separators.includes(character)) {
277
+ if (character === '(') depth += 1;
278
+ if (character === ')') depth -= 1;
279
+ tokenStart = true;
280
+ continue;
281
+ }
282
+
283
+ if (tokenStart && quotes.includes(character)) {
284
+ // A reference nothing closes yet may be closed by a later line, which
285
+ // changes what it holds: an even run is the empty reference only when
286
+ // no run in the whole document closes it.
287
+ const reading = references.readAt(position);
288
+ if (reading === null || !reading.closed) return false;
289
+ position += reading.length - 1;
277
290
  continue;
278
291
  }
279
292
 
280
- if (character === '(') depth += 1;
281
- if (character === ')') depth -= 1;
293
+ if (tokenStart) {
294
+ const end = referenceLiteralEnd(document, position);
295
+ if (end !== -1) {
296
+ position = end - 1;
297
+ continue;
298
+ }
299
+ }
300
+
301
+ tokenStart = false;
282
302
  }
283
303
 
284
304
  return depth === 0;
package/src/comments.js CHANGED
@@ -13,23 +13,35 @@
13
13
  */
14
14
 
15
15
  import { DelimitedReferences, QUOTES, readReference } from './quotes.js';
16
+ import { referenceLiteralEnd } from './ReferenceLiteral.js';
16
17
 
17
18
  /** The character that starts a comment. */
18
19
  export const COMMENT = '#';
19
20
 
20
- /** What a delimited reference may start after. */
21
- const BEFORE_REFERENCE = [' ', '\t', '\n', '\r', '(', ':'];
21
+ /**
22
+ * What ends a bare reference. A token starts after one of these, at the start
23
+ * of the document, and right after a delimited reference or a versioned
24
+ * literal: the places the grammar reads a reference from.
25
+ */
26
+ const SEPARATORS = [' ', '\t', '\n', '\r', '(', ':', ')'];
22
27
 
23
28
  /** What a comment may start after. */
24
29
  const BEFORE_COMMENT = [' ', '\t', '\n', '\r'];
25
30
 
26
31
  /**
27
32
  * The document with every comment blanked out.
33
+ *
34
+ * The document is read token by token, the way the grammar reads it, so a `#`
35
+ * inside a delimited reference that starts right after a group, another
36
+ * reference or a literal is content, as it is in one that starts after a
37
+ * space. References are read in the document as written, as the grammar
38
+ * reads them.
28
39
  * @param {string} document - The document to read
29
40
  * @returns {string} The document, of the same length, without its comments
30
41
  * @example
31
42
  * stripComments('a: b # why\n'); // 'a: b \n'
32
43
  * stripComments('"# kept"\n'); // '"# kept"\n'
44
+ * stripComments('(a)" #kept"\n'); // '(a)" #kept"\n'
33
45
  * stripComments('issue#1047\n'); // 'issue#1047\n'
34
46
  */
35
47
  export function stripComments(document) {
@@ -37,20 +49,13 @@ export function stripComments(document) {
37
49
  // parse error reports are both counted in.
38
50
  let blanked = null;
39
51
  let position = 0;
52
+ // Whether a token may start at `position`.
53
+ let tokenStart = true;
40
54
  const references = new DelimitedReferences(document);
41
55
 
42
56
  while (position < document.length) {
43
57
  const character = document[position];
44
58
 
45
- if (
46
- QUOTES.includes(character) &&
47
- follows(document, position, BEFORE_REFERENCE)
48
- ) {
49
- const end = references.endAt(position);
50
- position = end === null ? position + 1 : end;
51
- continue;
52
- }
53
-
54
59
  if (character === COMMENT && follows(document, position, BEFORE_COMMENT)) {
55
60
  blanked = blanked ?? document.split('');
56
61
  while (
@@ -64,12 +69,46 @@ export function stripComments(document) {
64
69
  continue;
65
70
  }
66
71
 
72
+ if (SEPARATORS.includes(character)) {
73
+ tokenStart = true;
74
+ position++;
75
+ continue;
76
+ }
77
+
78
+ if (tokenStart) {
79
+ const end = delimitedTokenEnd(document, position, references);
80
+ if (end !== null) {
81
+ position = end;
82
+ continue;
83
+ }
84
+ }
85
+
86
+ // Anything else is part of a bare reference, which runs to a separator.
87
+ tokenStart = false;
67
88
  position++;
68
89
  }
69
90
 
70
91
  return blanked === null ? document : blanked.join('');
71
92
  }
72
93
 
94
+ /**
95
+ * The position just past the delimited reference or versioned literal that
96
+ * starts the token at `start`, or `null` when the token there is a bare
97
+ * reference: a delimiter run that never closes is read as one, as the grammar
98
+ * reads it.
99
+ * @param {string} document - The document being read
100
+ * @param {number} start - Position where a token starts
101
+ * @param {DelimitedReferences} references - The references of the document
102
+ * @returns {number|null} The position just past the token
103
+ */
104
+ export function delimitedTokenEnd(document, start, references) {
105
+ const literal = referenceLiteralEnd(document, start);
106
+ if (literal !== -1) {
107
+ return literal;
108
+ }
109
+ return QUOTES.includes(document[start]) ? references.endAt(start) : null;
110
+ }
111
+
73
112
  /**
74
113
  * Whether the character before the given position is one the caller allows,
75
114
  * the start of the document counting as allowed.
package/src/grammar.pegjs CHANGED
@@ -117,8 +117,10 @@
117
117
  }
118
118
 
119
119
  // Delimited references of this document, each read once however many
120
- // alternatives ask for it (see quotes.js for how a reference is read)
121
- const delimitedReferences = new DelimitedReferences(input);
120
+ // alternatives ask for it (see quotes.js for how a reference is read). They
121
+ // are read in the document as written, as the comment reader reads them, so
122
+ // blanking a comment never changes how far a reference reaches (issue #377).
123
+ const delimitedReferences = new DelimitedReferences(source);
122
124
 
123
125
  // Universal procedural parser for N-quote strings (any N >= 1)
124
126
  // Parses from the given position in the input string