links-notation 0.12.0 → 0.15.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/src/Link.js CHANGED
@@ -32,8 +32,9 @@ export class Link {
32
32
  * @returns {string} Space-separated string of values
33
33
  */
34
34
  getValuesString() {
35
- return (!this.values || this.values.length === 0) ?
36
- '' : this.values.map(v => Link.getValueString(v)).join(' ');
35
+ return !this.values || this.values.length === 0
36
+ ? ''
37
+ : this.values.map((v) => Link.getValueString(v)).join(' ');
37
38
  }
38
39
 
39
40
  /**
@@ -46,7 +47,7 @@ export class Link {
46
47
  } else if (this.values.length === 1) {
47
48
  return this.values[0];
48
49
  } else {
49
- const newValues = this.values.map(v => {
50
+ const newValues = this.values.map((v) => {
50
51
  // Check if value has simplify method (defensive programming)
51
52
  return v && typeof v.simplify === 'function' ? v.simplify() : v;
52
53
  });
@@ -70,7 +71,9 @@ export class Link {
70
71
  */
71
72
  static getValueString(value) {
72
73
  // Defensive check for method existence
73
- return value && typeof value.toLinkOrIdString === 'function' ? value.toLinkOrIdString() : String(value);
74
+ return value && typeof value.toLinkOrIdString === 'function'
75
+ ? value.toLinkOrIdString()
76
+ : String(value);
74
77
  }
75
78
 
76
79
  /**
@@ -79,10 +82,16 @@ export class Link {
79
82
  * @returns {string} Escaped reference
80
83
  */
81
84
  static escapeReference(reference) {
82
- if (!reference || reference.trim() === '') {
85
+ if (reference === null || reference === undefined) {
83
86
  return '';
84
87
  }
85
88
 
89
+ // The empty reference is written as a bare delimiter pair, so that it reads
90
+ // back as itself instead of disappearing from the document.
91
+ if (reference === '') {
92
+ return '""';
93
+ }
94
+
86
95
  const hasSingleQuote = reference.includes("'");
87
96
  const hasDoubleQuote = reference.includes('"');
88
97
 
@@ -164,7 +173,6 @@ export class Link {
164
173
  return true;
165
174
  }
166
175
 
167
-
168
176
  /**
169
177
  * Format the link as a string
170
178
  * @param {boolean|FormatOptions} lessParentheses - If true, omit parentheses where safe; or a FormatOptions object
@@ -174,8 +182,12 @@ export class Link {
174
182
  format(lessParentheses = false, isCompoundValue = false) {
175
183
  // Support FormatOptions/FormatConfig as first parameter
176
184
  // Check if it's an object with formatting properties (FormatOptions or FormatConfig)
177
- if (lessParentheses && typeof lessParentheses === 'object' &&
178
- (lessParentheses.constructor.name === 'FormatOptions' || lessParentheses.constructor.name === 'FormatConfig')) {
185
+ if (
186
+ lessParentheses &&
187
+ typeof lessParentheses === 'object' &&
188
+ (lessParentheses.constructor.name === 'FormatOptions' ||
189
+ lessParentheses.constructor.name === 'FormatConfig')
190
+ ) {
179
191
  return this._formatWithOptions(lessParentheses, isCompoundValue);
180
192
  }
181
193
 
@@ -184,7 +196,7 @@ export class Link {
184
196
  if (this.id === null && (!this.values || this.values.length === 0)) {
185
197
  return lessParentheses ? '' : '()';
186
198
  }
187
-
199
+
188
200
  // Link with only ID, no values
189
201
  if (!this.values || this.values.length === 0) {
190
202
  const escapedId = Link.escapeReference(this.id);
@@ -192,21 +204,27 @@ export class Link {
192
204
  if (isCompoundValue) {
193
205
  return `(${escapedId})`;
194
206
  }
195
- return lessParentheses && !this.needsParentheses(this.id) ? escapedId : `(${escapedId})`;
207
+ return lessParentheses && !this.needsParentheses(this.id)
208
+ ? escapedId
209
+ : `(${escapedId})`;
196
210
  }
197
-
198
- // Format values recursively
199
- const valuesStr = this.values.map(v => this.formatValue(v)).join(' ');
200
-
211
+
212
+ // Format values recursively
213
+ const valuesStr = this.values.map((v) => this.formatValue(v)).join(' ');
214
+
201
215
  // Link with values only (null id)
202
216
  if (this.id === null) {
203
217
  // For lessParentheses mode with simple values, don't wrap the whole thing
204
218
  if (lessParentheses) {
205
219
  // Check if all values are simple (no nested values)
206
- const allSimple = this.values.every(v => !v.values || v.values.length === 0);
220
+ const allSimple = this.values.every(
221
+ (v) => !v.values || v.values.length === 0
222
+ );
207
223
  if (allSimple) {
208
224
  // Format each value without extra wrapping
209
- const simpleValuesStr = this.values.map(v => Link.escapeReference(v.id)).join(' ');
225
+ const simpleValuesStr = this.values
226
+ .map((v) => Link.escapeReference(v.id))
227
+ .join(' ');
210
228
  return simpleValuesStr;
211
229
  }
212
230
  // For mixed or complex values in lessParentheses mode, still avoid outer wrapper
@@ -216,13 +234,15 @@ export class Link {
216
234
  // For normal mode, wrap in parentheses
217
235
  return `(${valuesStr})`;
218
236
  }
219
-
237
+
220
238
  // Link with ID and values
221
239
  const idStr = Link.escapeReference(this.id);
222
240
  const withColon = `${idStr}: ${valuesStr}`;
223
- return lessParentheses && !this.needsParentheses(this.id) ? withColon : `(${withColon})`;
241
+ return lessParentheses && !this.needsParentheses(this.id)
242
+ ? withColon
243
+ : `(${withColon})`;
224
244
  }
225
-
245
+
226
246
  /**
227
247
  * Format a value within this link
228
248
  * @param {Link} value - The value to format
@@ -257,7 +277,13 @@ export class Link {
257
277
  * @returns {boolean} True if parentheses are needed
258
278
  */
259
279
  needsParentheses(str) {
260
- return str && (str.includes(' ') || str.includes(':') || str.includes('(') || str.includes(')'));
280
+ return (
281
+ str &&
282
+ (str.includes(' ') ||
283
+ str.includes(':') ||
284
+ str.includes('(') ||
285
+ str.includes(')'))
286
+ );
261
287
  }
262
288
 
263
289
  /**
@@ -278,7 +304,9 @@ export class Link {
278
304
  if (isCompoundValue) {
279
305
  return `(${escapedId})`;
280
306
  }
281
- return options.lessParentheses && !this.needsParentheses(this.id) ? escapedId : `(${escapedId})`;
307
+ return options.lessParentheses && !this.needsParentheses(this.id)
308
+ ? escapedId
309
+ : `(${escapedId})`;
282
310
  }
283
311
 
284
312
  // Check if we should use indented format
@@ -287,11 +315,13 @@ export class Link {
287
315
  shouldIndent = true;
288
316
  } else {
289
317
  // Try inline format first
290
- const valuesStr = this.values.map(v => this.formatValue(v)).join(' ');
318
+ const valuesStr = this.values.map((v) => this.formatValue(v)).join(' ');
291
319
  let testLine;
292
320
  if (this.id !== null) {
293
321
  const idStr = Link.escapeReference(this.id);
294
- testLine = options.lessParentheses ? `${idStr}: ${valuesStr}` : `(${idStr}: ${valuesStr})`;
322
+ testLine = options.lessParentheses
323
+ ? `${idStr}: ${valuesStr}`
324
+ : `(${idStr}: ${valuesStr})`;
295
325
  } else {
296
326
  testLine = options.lessParentheses ? valuesStr : `(${valuesStr})`;
297
327
  }
@@ -307,14 +337,16 @@ export class Link {
307
337
  }
308
338
 
309
339
  // Standard inline formatting
310
- const valuesStr = this.values.map(v => this.formatValue(v)).join(' ');
340
+ const valuesStr = this.values.map((v) => this.formatValue(v)).join(' ');
311
341
 
312
342
  // Link with values only (null id)
313
343
  if (this.id === null) {
314
344
  if (options.lessParentheses) {
315
- const allSimple = this.values.every(v => !v.values || v.values.length === 0);
345
+ const allSimple = this.values.every(
346
+ (v) => !v.values || v.values.length === 0
347
+ );
316
348
  if (allSimple) {
317
- return this.values.map(v => Link.escapeReference(v.id)).join(' ');
349
+ return this.values.map((v) => Link.escapeReference(v.id)).join(' ');
318
350
  }
319
351
  return valuesStr;
320
352
  }
@@ -324,7 +356,9 @@ export class Link {
324
356
  // Link with ID and values
325
357
  const idStr = Link.escapeReference(this.id);
326
358
  const withColon = `${idStr}: ${valuesStr}`;
327
- return options.lessParentheses && !this.needsParentheses(this.id) ? withColon : `(${withColon})`;
359
+ return options.lessParentheses && !this.needsParentheses(this.id)
360
+ ? withColon
361
+ : `(${withColon})`;
328
362
  }
329
363
 
330
364
  /**
@@ -335,7 +369,9 @@ export class Link {
335
369
  _formatIndented(options) {
336
370
  if (this.id === null) {
337
371
  // Values only - format each on separate line
338
- const lines = this.values.map(v => options.indentString + this.formatValue(v));
372
+ const lines = this.values.map(
373
+ (v) => options.indentString + this.formatValue(v)
374
+ );
339
375
  return lines.join('\n');
340
376
  }
341
377
 
@@ -373,7 +409,11 @@ function _groupConsecutiveLinks(links) {
373
409
 
374
410
  while (j < links.length) {
375
411
  const nextLink = links[j];
376
- if (nextLink.id === current.id && nextLink.values && nextLink.values.length > 0) {
412
+ if (
413
+ nextLink.id === current.id &&
414
+ nextLink.values &&
415
+ nextLink.values.length > 0
416
+ ) {
377
417
  sameIdValues.push(...nextLink.values);
378
418
  j++;
379
419
  } else {
@@ -401,17 +441,21 @@ export function formatLinks(links, lessParentheses = false) {
401
441
  if (!links || links.length === 0) return '';
402
442
 
403
443
  // Support FormatOptions/FormatConfig as parameter
404
- if (lessParentheses && typeof lessParentheses === 'object' &&
405
- (lessParentheses.constructor.name === 'FormatOptions' || lessParentheses.constructor.name === 'FormatConfig')) {
444
+ if (
445
+ lessParentheses &&
446
+ typeof lessParentheses === 'object' &&
447
+ (lessParentheses.constructor.name === 'FormatOptions' ||
448
+ lessParentheses.constructor.name === 'FormatConfig')
449
+ ) {
406
450
  const options = lessParentheses;
407
451
  // Apply consecutive link grouping if enabled
408
452
  let linksToFormat = links;
409
453
  if (options.groupConsecutive) {
410
454
  linksToFormat = _groupConsecutiveLinks(links);
411
455
  }
412
- return linksToFormat.map(link => link.format(options)).join('\n');
456
+ return linksToFormat.map((link) => link.format(options)).join('\n');
413
457
  }
414
458
 
415
459
  // Backward compatibility with boolean parameter
416
- return links.map(link => link.format(lessParentheses)).join('\n');
417
- }
460
+ return links.map((link) => link.format(lessParentheses)).join('\n');
461
+ }
package/src/LinksGroup.js CHANGED
@@ -25,6 +25,6 @@ export class LinksGroup {
25
25
 
26
26
  toString() {
27
27
  const list = this.toList();
28
- return list.map(item => `(${item.id || item})`).join(' ');
28
+ return list.map((item) => `(${item.id || item})`).join(' ');
29
29
  }
30
- }
30
+ }
package/src/Parser.js CHANGED
@@ -26,7 +26,9 @@ export class Parser {
26
26
  }
27
27
 
28
28
  if (input.length > this.maxInputSize) {
29
- throw new Error(`Input size exceeds maximum allowed size of ${this.maxInputSize} bytes`);
29
+ throw new Error(
30
+ `Input size exceeds maximum allowed size of ${this.maxInputSize} bytes`
31
+ );
30
32
  }
31
33
 
32
34
  try {
@@ -62,7 +64,11 @@ export class Parser {
62
64
  if (item.children && item.children.length > 0) {
63
65
  // Special case: If this is an ID with empty values but has children,
64
66
  // the children should become the values of the link (indented ID syntax)
65
- if (item.id && (!item.values || item.values.length === 0)) {
67
+ if (
68
+ item.id !== undefined &&
69
+ item.id !== null &&
70
+ (!item.values || item.values.length === 0)
71
+ ) {
66
72
  const childValues = item.children.map((child) => {
67
73
  // For indented children, extract the actual reference from the child's values
68
74
  if (child.values && child.values.length === 1) {
@@ -72,10 +78,10 @@ export class Parser {
72
78
  });
73
79
  const linkWithChildren = {
74
80
  id: item.id,
75
- values: childValues
81
+ values: childValues,
76
82
  };
77
83
  const currentLink = this.transformLink(linkWithChildren);
78
-
84
+
79
85
  if (parentPath.length === 0) {
80
86
  result.push(currentLink);
81
87
  } else {
@@ -84,17 +90,17 @@ export class Parser {
84
90
  } else {
85
91
  // Regular indented structure - process as before
86
92
  const currentLink = this.transformLink(item);
87
-
93
+
88
94
  // Add the link combined with parent path
89
95
  if (parentPath.length === 0) {
90
96
  result.push(currentLink);
91
97
  } else {
92
98
  result.push(this.combinePathElements(parentPath, currentLink));
93
99
  }
94
-
100
+
95
101
  // Process each child with this item in the path
96
102
  const newPath = [...parentPath, currentLink];
97
-
103
+
98
104
  for (const child of item.children) {
99
105
  this.collectLinks(child, newPath, result);
100
106
  }
@@ -102,7 +108,7 @@ export class Parser {
102
108
  } else {
103
109
  // Leaf item or item with inline values
104
110
  const currentLink = this.transformLink(item);
105
-
111
+
106
112
  if (parentPath.length === 0) {
107
113
  result.push(currentLink);
108
114
  } else {
@@ -110,7 +116,7 @@ export class Parser {
110
116
  }
111
117
  }
112
118
  }
113
-
119
+
114
120
  combinePathElements(pathElements, current) {
115
121
  if (pathElements.length === 0) return current;
116
122
  if (pathElements.length === 1) {
@@ -118,21 +124,44 @@ export class Parser {
118
124
  combined._isFromPathCombination = true;
119
125
  return combined;
120
126
  }
121
-
127
+
122
128
  // For multiple path elements, we need to build proper nesting
123
129
  // The last element in the path should be combined with its parent
124
130
  const parentPath = pathElements.slice(0, -1);
125
131
  const lastElement = pathElements[pathElements.length - 1];
126
-
132
+
127
133
  // Build the parent structure
128
134
  let parent = this.combinePathElements(parentPath, lastElement);
129
-
135
+
130
136
  // Add current element to the built structure
131
137
  const combined = new Link(null, [parent, current]);
132
138
  combined._isFromPathCombination = true;
133
139
  return combined;
134
140
  }
135
-
141
+
142
+ /**
143
+ * Transform the links of a nested (parenthesised) context into a single Link.
144
+ * The nested context is parsed with the same rules as the root, so it yields
145
+ * a list of links; a single link is used as is, several links become the
146
+ * values of one anonymous link. An already parenthesised single link keeps
147
+ * its own group so that `((a b))` stays distinct from `(a b)`.
148
+ * @param {Array} nested - Raw items parsed inside the parentheses
149
+ * @returns {Link} The link representing the parenthesised group
150
+ */
151
+ transformNested(nested) {
152
+ const nestedLinks = [];
153
+ for (const item of nested) {
154
+ if (item !== null && item !== undefined) {
155
+ this.collectLinks(item, [], nestedLinks);
156
+ }
157
+ }
158
+ const wrapsSingleGroup =
159
+ nested.length === 1 && nested[0] && nested[0].nested !== undefined;
160
+ if (nestedLinks.length === 1 && !wrapsSingleGroup) {
161
+ return nestedLinks[0];
162
+ }
163
+ return new Link(null, nestedLinks);
164
+ }
136
165
 
137
166
  /**
138
167
  * Transform a parsed item into a Link object
@@ -147,6 +176,11 @@ export class Parser {
147
176
  return item;
148
177
  }
149
178
 
179
+ // Parenthesised group parsed as a nested context
180
+ if (item.nested !== undefined) {
181
+ return this.transformNested(item.nested);
182
+ }
183
+
150
184
  // Handle simple reference objects like {id: 'a'}
151
185
  if (item.id !== undefined && !item.values && !item.children) {
152
186
  return new Link(item.id);
@@ -155,13 +189,12 @@ export class Parser {
155
189
  // For items with values, create a link with those values
156
190
  if (item.values && Array.isArray(item.values)) {
157
191
  // Create a link with id (if present) and transformed values
158
- const link = new Link(item.id || null, []);
159
- link.values = item.values.map(v => this.transformLink(v));
192
+ const link = new Link(item.id ?? null, []);
193
+ link.values = item.values.map((v) => this.transformLink(v));
160
194
  return link;
161
195
  }
162
196
 
163
197
  // Default case
164
- return new Link(item.id || null, []);
198
+ return new Link(item.id ?? null, []);
165
199
  }
166
-
167
- }
200
+ }
package/src/grammar.pegjs CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  let indentationStack = [0];
3
3
  let baseIndentation = null;
4
+ // Saved indentation contexts of the enclosing scopes. Every parenthesised
5
+ // group opens a nested context that starts fresh at indentation level zero,
6
+ // so line breaks and indentation mean the same thing at every depth.
7
+ let contextStack = [];
8
+
9
+ function resetState() {
10
+ indentationStack = [0];
11
+ baseIndentation = null;
12
+ contextStack = [];
13
+ return true;
14
+ }
15
+
16
+ function enterNestedContext() {
17
+ contextStack.push({ indentationStack, baseIndentation });
18
+ indentationStack = [0];
19
+ baseIndentation = null;
20
+ return true;
21
+ }
22
+
23
+ function exitNestedContext() {
24
+ const saved = contextStack.pop();
25
+ if (saved) {
26
+ indentationStack = saved.indentationStack;
27
+ baseIndentation = saved.baseIndentation;
28
+ }
29
+ return true;
30
+ }
31
+
32
+ function isInsideNestedContext() {
33
+ return contextStack.length > 0;
34
+ }
4
35
 
5
36
  function setBaseIndentation(spaces) {
6
37
  if (baseIndentation === null) {
@@ -34,10 +65,97 @@
34
65
  function getCurrentIndentation() {
35
66
  return indentationStack[indentationStack.length - 1];
36
67
  }
68
+
69
+ // A body written between an even run of delimiters is substantive when it
70
+ // holds at least one visible character and does not straddle a parenthesis.
71
+ // An even run can always be read as delimiter pairs enclosing nothing, so the
72
+ // n-quote reading is only taken when it carries something the pairs cannot.
73
+ function isSubstantiveBody(content) {
74
+ let depth = 0;
75
+ let hasVisible = false;
76
+
77
+ for (const c of content) {
78
+ if (c === '(') {
79
+ depth++;
80
+ } else if (c === ')') {
81
+ depth--;
82
+ if (depth < 0) {
83
+ return false;
84
+ }
85
+ }
86
+ if (!/\s/.test(c)) {
87
+ hasVisible = true;
88
+ }
89
+ }
90
+
91
+ return hasVisible && depth === 0;
92
+ }
93
+
94
+ // Universal procedural parser for N-quote strings (any N >= 1)
95
+ // Parses from the given position in the input string
96
+ // A run of an even number of delimiters that does not open a reference with a
97
+ // substantive body is the empty reference: the shortest reading, a bare
98
+ // delimiter pair enclosing nothing, wins over a longer n-quote delimiter.
99
+ // Returns { value, length } or null
100
+ function parseQuotedStringAt(inputStr, startPos, quoteChar) {
101
+ if (startPos >= inputStr.length || inputStr[startPos] !== quoteChar) {
102
+ return null;
103
+ }
104
+
105
+ // Count opening quotes
106
+ let quoteCount = 0;
107
+ let pos = startPos;
108
+ while (pos < inputStr.length && inputStr[pos] === quoteChar) {
109
+ quoteCount++;
110
+ pos++;
111
+ }
112
+
113
+ const isEvenRun = quoteCount % 2 === 0;
114
+ const emptyReference = isEvenRun ? { value: '', length: quoteCount } : null;
115
+
116
+ const closeSeq = quoteChar.repeat(quoteCount);
117
+ const escapeSeq = quoteChar.repeat(quoteCount * 2);
118
+
119
+ let content = '';
120
+ while (pos < inputStr.length) {
121
+ // Check for escape sequence (2*N quotes)
122
+ if (inputStr.substr(pos, escapeSeq.length) === escapeSeq) {
123
+ content += closeSeq; // 2*N quotes become N quotes
124
+ pos += escapeSeq.length;
125
+ continue;
126
+ }
127
+
128
+ // Check for closing sequence (exactly N quotes)
129
+ if (inputStr.substr(pos, quoteCount) === closeSeq) {
130
+ // Verify it's exactly N quotes (not followed by more of same char)
131
+ const afterClose = pos + quoteCount;
132
+ if (afterClose >= inputStr.length || inputStr[afterClose] !== quoteChar) {
133
+ // Found valid closing
134
+ if (isEvenRun && !isSubstantiveBody(content)) {
135
+ return emptyReference;
136
+ }
137
+ return {
138
+ value: content,
139
+ length: afterClose - startPos
140
+ };
141
+ }
142
+ }
143
+
144
+ // Add character to content
145
+ content += inputStr[pos];
146
+ pos++;
147
+ }
148
+
149
+ return emptyReference; // No valid closing found
150
+ }
151
+
152
+ // Global state for passing parsed values between predicate and action
153
+ let parsedValue = null;
154
+ let parsedLength = 0;
37
155
  }
38
156
 
39
- document = &{ indentationStack = [0]; baseIndentation = null; return true; } skipEmptyLines links:links _ eof { return links; }
40
- / &{ indentationStack = [0]; baseIndentation = null; return true; } _ eof { return []; }
157
+ document = &{ return resetState(); } skipEmptyLines links:links _ eof { return links; }
158
+ / &{ return resetState(); } _ eof { return []; }
41
159
 
42
160
  skipEmptyLines = ([ \t]* [\r\n])*
43
161
 
@@ -48,7 +166,7 @@ firstLine = SET_BASE_INDENTATION l:element { return l; }
48
166
  line = CHECK_INDENTATION l:element { return l; }
49
167
 
50
168
  element = e:anyLink PUSH_INDENTATION l:links {
51
- return { id: e.id, values: e.values, children: l };
169
+ return Object.assign({}, e, { children: l });
52
170
  }
53
171
  / e:anyLink { return e; }
54
172
 
@@ -56,14 +174,23 @@ referenceOrLink = l:multiLineAnyLink { return l; } / i:reference { return { id:
56
174
 
57
175
  anyLink = ml:multiLineAnyLink eol { return ml; } / il:indentedIdLink { return il; } / sl:singleLineAnyLink { return sl; }
58
176
 
59
- multiLineAnyLink = multiLineValueLink / multiLineLink
177
+ multiLineAnyLink = nestedGroup
60
178
 
61
179
  singleLineAnyLink = fl:singleLineLink eol { return fl; }
62
180
  / vl:singleLineValueLink eol { return vl; }
63
181
 
64
- multiLineValueAndWhitespace = value:referenceOrLink _ { return value; }
182
+ // A parenthesised group opens a nested context that follows exactly the same
183
+ // rules as the root of the document: line breaks separate links and
184
+ // indentation nests them, starting fresh at indentation level zero.
185
+ nestedGroup = "(" ENTER_NESTED_CONTEXT body:nestedGroupBody {
186
+ exitNestedContext();
187
+ return body;
188
+ }
189
+
190
+ nestedGroupBody = skipEmptyLines l:links _ ")" { return { nested: l }; }
191
+ / _ ")" { return { nested: [] }; }
65
192
 
66
- multiLineValues = _ list:multiLineValueAndWhitespace* { return list; }
193
+ ENTER_NESTED_CONTEXT = &{ return enterNestedContext(); }
67
194
 
68
195
  singleLineValueAndWhitespace = __ value:referenceOrLink { return value; }
69
196
 
@@ -71,21 +198,65 @@ singleLineValues = list:singleLineValueAndWhitespace+ { return list; }
71
198
 
72
199
  singleLineLink = __ id:reference __ ":" v:singleLineValues { return { id: id, values: v }; }
73
200
 
74
- multiLineLink = "(" _ id:reference _ ":" v:multiLineValues _ ")" { return { id: id, values: v }; }
75
-
76
201
  singleLineValueLink = v:singleLineValues { return { values: v }; }
77
202
 
78
- multiLineValueLink = "(" v:multiLineValues _ ")" { return { values: v }; }
79
-
80
203
  indentedIdLink = id:reference __ ":" eol { return { id: id, values: [] }; }
81
204
 
82
- reference = doubleQuotedReference / singleQuotedReference / simpleReference
205
+ // Reference can be quoted (with any number of quotes N >= 1) or simple unquoted
206
+ // Universal approach: use procedural parsing for all quote types and counts
207
+ reference = quotedReference / simpleReference
83
208
 
84
209
  simpleReference = chars:referenceSymbol+ { return chars.join(''); }
85
210
 
86
- doubleQuotedReference = '"' r:[^"]+ '"' { return r.join(''); }
211
+ // Universal quoted reference - handles any N quotes for all quote types
212
+ // Uses procedural parsing with input/offset() for clean, simple logic
213
+ quotedReference = doubleQuotedUniversal / singleQuotedUniversal / backtickQuotedUniversal
214
+
215
+ // Double quotes: peek at input, parse procedurally, consume exact chars
216
+ doubleQuotedUniversal = &'"' &{
217
+ const pos = offset();
218
+ const result = parseQuotedStringAt(input, pos, '"');
219
+ if (result) {
220
+ parsedValue = result.value;
221
+ parsedLength = result.length;
222
+ return true;
223
+ }
224
+ return false;
225
+ } chars:consumeDouble { return parsedValue; }
226
+
227
+ // Consume exactly parsedLength characters for double quotes
228
+ consumeDouble = c:. cs:consumeDoubleMore* { return [c].concat(cs).join(''); }
229
+ consumeDoubleMore = &{ return parsedLength > 1 && (parsedLength--, true); } c:. { return c; }
230
+
231
+ // Single quotes
232
+ singleQuotedUniversal = &"'" &{
233
+ const pos = offset();
234
+ const result = parseQuotedStringAt(input, pos, "'");
235
+ if (result) {
236
+ parsedValue = result.value;
237
+ parsedLength = result.length;
238
+ return true;
239
+ }
240
+ return false;
241
+ } chars:consumeSingle { return parsedValue; }
242
+
243
+ consumeSingle = c:. cs:consumeSingleMore* { return [c].concat(cs).join(''); }
244
+ consumeSingleMore = &{ return parsedLength > 1 && (parsedLength--, true); } c:. { return c; }
245
+
246
+ // Backticks
247
+ backtickQuotedUniversal = &'`' &{
248
+ const pos = offset();
249
+ const result = parseQuotedStringAt(input, pos, '`');
250
+ if (result) {
251
+ parsedValue = result.value;
252
+ parsedLength = result.length;
253
+ return true;
254
+ }
255
+ return false;
256
+ } chars:consumeBacktick { return parsedValue; }
87
257
 
88
- singleQuotedReference = "'" r:[^']+ "'" { return r.join(''); }
258
+ consumeBacktick = c:. cs:consumeBacktickMore* { return [c].concat(cs).join(''); }
259
+ consumeBacktickMore = &{ return parsedLength > 1 && (parsedLength--, true); } c:. { return c; }
89
260
 
90
261
  SET_BASE_INDENTATION = spaces:" "* { setBaseIndentation(spaces); }
91
262
 
@@ -93,7 +264,11 @@ PUSH_INDENTATION = spaces:" "* &{ return normalizeIndentation(spaces) > getCurre
93
264
 
94
265
  CHECK_INDENTATION = spaces:" "* &{ return checkIndentation(spaces); }
95
266
 
96
- eol = __ ([\r\n]+ / eof)
267
+ eol = __ ([\r\n]+ / eof / nestedGroupEnd)
268
+
269
+ // Inside a parenthesised group the closing parenthesis terminates a line the
270
+ // same way the end of the input does at the root.
271
+ nestedGroupEnd = &{ return isInsideNestedContext(); } &")"
97
272
 
98
273
  eof = !.
99
274
 
@@ -103,4 +278,4 @@ _ = whiteSpaceSymbol*
103
278
 
104
279
  whiteSpaceSymbol = [ \t\n\r]
105
280
 
106
- referenceSymbol = [^ \t\n\r(:)]
281
+ referenceSymbol = [^ \t\n\r(:)]
package/src/index.js CHANGED
@@ -2,4 +2,4 @@ export { Link, formatLinks } from './Link.js';
2
2
  export { LinksGroup } from './LinksGroup.js';
3
3
  export { Parser } from './Parser.js';
4
4
  export { FormatConfig } from './FormatConfig.js';
5
- export { FormatOptions } from './FormatOptions.js';
5
+ export { FormatOptions } from './FormatOptions.js';