links-notation 0.11.2 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -8,7 +8,9 @@
8
8
  "build": "bun run build:grammar && bun build src/index.js --outdir=dist --target=node",
9
9
  "build:grammar": "peggy --format es -o src/parser-generated.js src/grammar.pegjs",
10
10
  "test": "bun test",
11
- "test:watch": "bun test --watch"
11
+ "test:watch": "bun test --watch",
12
+ "lint": "eslint src/ tests/",
13
+ "lint:fix": "eslint src/ tests/ --fix"
12
14
  },
13
15
  "keywords": [
14
16
  "lino",
@@ -20,7 +22,8 @@
20
22
  "license": "Unlicense",
21
23
  "devDependencies": {
22
24
  "bun-types": "^1.2.19",
25
+ "eslint": "^8.56.0",
23
26
  "peggy": "^5.0.6"
24
27
  },
25
28
  "dependencies": {}
26
- }
29
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * FormatConfig is an alias for FormatOptions to maintain API consistency
3
+ * with the Python implementation.
4
+ *
5
+ * JavaScript uses 'FormatOptions' internally, but exports 'FormatConfig'
6
+ * as the primary name to match Python's naming convention.
7
+ */
8
+ import { FormatOptions } from './FormatOptions.js';
9
+
10
+ /**
11
+ * FormatConfig for Lino notation formatting.
12
+ *
13
+ * This is an alias for FormatOptions to maintain API consistency across languages.
14
+ * Python uses 'FormatConfig', so this export provides the same name for JavaScript.
15
+ */
16
+ export class FormatConfig extends FormatOptions {
17
+ // FormatConfig inherits all functionality from FormatOptions
18
+ // No additional implementation needed - this is purely for naming consistency
19
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * FormatOptions for Lino notation formatting.
3
+ *
4
+ * Provides configuration options for controlling how Link objects are formatted.
5
+ */
6
+ export class FormatOptions {
7
+ /**
8
+ * Create a new FormatOptions instance
9
+ * @param {Object} options - Configuration options
10
+ * @param {boolean} [options.lessParentheses=false] - If true, omit parentheses where safe
11
+ * @param {number} [options.maxLineLength=80] - Maximum line length before auto-indenting
12
+ * @param {boolean} [options.indentLongLines=false] - If true, indent lines exceeding maxLineLength
13
+ * @param {number|null} [options.maxInlineRefs=null] - Maximum number of references before auto-indenting
14
+ * @param {boolean} [options.groupConsecutive=false] - If true, group consecutive links with same ID
15
+ * @param {string} [options.indentString=" "] - String to use for indentation
16
+ * @param {boolean} [options.preferInline=true] - If true, prefer inline format when under thresholds
17
+ */
18
+ constructor(options = {}) {
19
+ this.lessParentheses = options.lessParentheses ?? false;
20
+ this.maxLineLength = options.maxLineLength ?? 80;
21
+ this.indentLongLines = options.indentLongLines ?? false;
22
+ this.maxInlineRefs = options.maxInlineRefs ?? null;
23
+ this.groupConsecutive = options.groupConsecutive ?? false;
24
+ this.indentString = options.indentString ?? ' ';
25
+ this.preferInline = options.preferInline ?? true;
26
+ }
27
+
28
+ /**
29
+ * Check if line should be indented based on length
30
+ * @param {string} line - The line to check
31
+ * @returns {boolean} True if line should be indented based on length threshold
32
+ */
33
+ shouldIndentByLength(line) {
34
+ if (!this.indentLongLines) {
35
+ return false;
36
+ }
37
+ // Count printable unicode characters
38
+ return line.length > this.maxLineLength;
39
+ }
40
+
41
+ /**
42
+ * Check if link should be indented based on reference count
43
+ * @param {number} refCount - Number of references in the link
44
+ * @returns {boolean} True if link should be indented based on reference count threshold
45
+ */
46
+ shouldIndentByRefCount(refCount) {
47
+ if (this.maxInlineRefs === null) {
48
+ return false;
49
+ }
50
+ return refCount > this.maxInlineRefs;
51
+ }
52
+ }
package/src/Link.js CHANGED
@@ -1,43 +1,95 @@
1
1
  export class Link {
2
+ /**
3
+ * Create a new Link
4
+ * @param {string|null} id - Optional identifier for the link
5
+ * @param {Link[]|null} values - Optional array of nested links
6
+ * @throws {TypeError} If values is not an array or null
7
+ */
2
8
  constructor(id = null, values = null) {
3
9
  this.id = id;
4
- this.values = values || [];
10
+
11
+ // Validate that values is an array if provided
12
+ if (values !== null && values !== undefined) {
13
+ if (!Array.isArray(values)) {
14
+ throw new TypeError('values must be an array or null');
15
+ }
16
+ this.values = values;
17
+ } else {
18
+ this.values = [];
19
+ }
5
20
  }
6
21
 
22
+ /**
23
+ * Convert link to string representation
24
+ * @returns {string} String representation of the link
25
+ */
7
26
  toString() {
8
27
  return this.format(false);
9
28
  }
10
29
 
30
+ /**
31
+ * Get formatted string of all values
32
+ * @returns {string} Space-separated string of values
33
+ */
11
34
  getValuesString() {
12
- return (!this.values || this.values.length === 0) ?
13
- '' : 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(' ');
14
38
  }
15
39
 
40
+ /**
41
+ * Simplify the link structure by unwrapping single-value containers
42
+ * @returns {Link} Simplified link
43
+ */
16
44
  simplify() {
17
45
  if (!this.values || this.values.length === 0) {
18
46
  return this;
19
47
  } else if (this.values.length === 1) {
20
48
  return this.values[0];
21
49
  } else {
22
- const newValues = this.values.map(v => v.simplify());
50
+ const newValues = this.values.map((v) => {
51
+ // Check if value has simplify method (defensive programming)
52
+ return v && typeof v.simplify === 'function' ? v.simplify() : v;
53
+ });
23
54
  return new Link(this.id, newValues);
24
55
  }
25
56
  }
26
57
 
58
+ /**
59
+ * Combine this link with another link
60
+ * @param {Link} other - The link to combine with
61
+ * @returns {Link} Combined link
62
+ */
27
63
  combine(other) {
28
64
  return new Link(null, [this, other]);
29
65
  }
30
66
 
67
+ /**
68
+ * Get string representation of a value
69
+ * @param {Link} value - The value to stringify
70
+ * @returns {string} String representation
71
+ */
31
72
  static getValueString(value) {
32
- return value.toLinkOrIdString();
73
+ // Defensive check for method existence
74
+ return value && typeof value.toLinkOrIdString === 'function'
75
+ ? value.toLinkOrIdString()
76
+ : String(value);
33
77
  }
34
78
 
79
+ /**
80
+ * Escape a reference string by adding quotes if necessary
81
+ * @param {string} reference - The reference to escape
82
+ * @returns {string} Escaped reference
83
+ */
35
84
  static escapeReference(reference) {
36
85
  if (!reference || reference.trim() === '') {
37
86
  return '';
38
87
  }
39
-
40
- const needsSingleQuotes =
88
+
89
+ const hasSingleQuote = reference.includes("'");
90
+ const hasDoubleQuote = reference.includes('"');
91
+
92
+ const needsQuoting =
41
93
  reference.includes(':') ||
42
94
  reference.includes('(') ||
43
95
  reference.includes(')') ||
@@ -45,17 +97,38 @@ export class Link {
45
97
  reference.includes('\t') ||
46
98
  reference.includes('\n') ||
47
99
  reference.includes('\r') ||
48
- reference.includes('"');
49
-
50
- if (needsSingleQuotes) {
100
+ hasDoubleQuote ||
101
+ hasSingleQuote;
102
+
103
+ // Handle edge case: reference contains both single and double quotes
104
+ if (hasSingleQuote && hasDoubleQuote) {
105
+ // Escape single quotes and wrap in single quotes
106
+ return `'${reference.replace(/'/g, "\\'")}'`;
107
+ }
108
+
109
+ // Prefer single quotes if double quotes are present
110
+ if (hasDoubleQuote) {
51
111
  return `'${reference}'`;
52
- } else if (reference.includes("'")) {
112
+ }
113
+
114
+ // Use double quotes if single quotes are present
115
+ if (hasSingleQuote) {
53
116
  return `"${reference}"`;
54
- } else {
55
- return reference;
56
117
  }
118
+
119
+ // Use single quotes for special characters
120
+ if (needsQuoting) {
121
+ return `'${reference}'`;
122
+ }
123
+
124
+ // No quoting needed
125
+ return reference;
57
126
  }
58
127
 
128
+ /**
129
+ * Convert to string using either just ID or full format
130
+ * @returns {string} String representation
131
+ */
59
132
  toLinkOrIdString() {
60
133
  if (!this.values || this.values.length === 0) {
61
134
  return this.id === null ? '' : Link.escapeReference(this.id);
@@ -63,26 +136,61 @@ export class Link {
63
136
  return this.toString();
64
137
  }
65
138
 
139
+ /**
140
+ * Check equality with another Link
141
+ * @param {*} other - Object to compare with
142
+ * @returns {boolean} True if links are equal
143
+ */
66
144
  equals(other) {
67
145
  if (!(other instanceof Link)) return false;
68
146
  if (this.id !== other.id) return false;
69
- if (this.values.length !== other.values.length) return false;
70
-
71
- for (let i = 0; i < this.values.length; i++) {
72
- if (!this.values[i].equals(other.values[i])) {
73
- return false;
147
+
148
+ // Handle null/undefined values arrays
149
+ const thisValues = this.values || [];
150
+ const otherValues = other.values || [];
151
+
152
+ if (thisValues.length !== otherValues.length) return false;
153
+
154
+ for (let i = 0; i < thisValues.length; i++) {
155
+ // Defensive check for equals method
156
+ if (thisValues[i] && typeof thisValues[i].equals === 'function') {
157
+ if (!thisValues[i].equals(otherValues[i])) {
158
+ return false;
159
+ }
160
+ } else {
161
+ // Fallback to reference equality
162
+ if (thisValues[i] !== otherValues[i]) {
163
+ return false;
164
+ }
74
165
  }
75
166
  }
76
167
  return true;
77
168
  }
78
169
 
79
-
170
+ /**
171
+ * Format the link as a string
172
+ * @param {boolean|FormatOptions} lessParentheses - If true, omit parentheses where safe; or a FormatOptions object
173
+ * @param {boolean} isCompoundValue - If true, this is a value in a compound link
174
+ * @returns {string} Formatted string
175
+ */
80
176
  format(lessParentheses = false, isCompoundValue = false) {
177
+ // Support FormatOptions/FormatConfig as first parameter
178
+ // Check if it's an object with formatting properties (FormatOptions or FormatConfig)
179
+ if (
180
+ lessParentheses &&
181
+ typeof lessParentheses === 'object' &&
182
+ (lessParentheses.constructor.name === 'FormatOptions' ||
183
+ lessParentheses.constructor.name === 'FormatConfig')
184
+ ) {
185
+ return this._formatWithOptions(lessParentheses, isCompoundValue);
186
+ }
187
+
188
+ // Original implementation for backward compatibility
81
189
  // Empty link
82
190
  if (this.id === null && (!this.values || this.values.length === 0)) {
83
191
  return lessParentheses ? '' : '()';
84
192
  }
85
-
193
+
86
194
  // Link with only ID, no values
87
195
  if (!this.values || this.values.length === 0) {
88
196
  const escapedId = Link.escapeReference(this.id);
@@ -90,21 +198,27 @@ export class Link {
90
198
  if (isCompoundValue) {
91
199
  return `(${escapedId})`;
92
200
  }
93
- return lessParentheses && !this.needsParentheses(this.id) ? escapedId : `(${escapedId})`;
201
+ return lessParentheses && !this.needsParentheses(this.id)
202
+ ? escapedId
203
+ : `(${escapedId})`;
94
204
  }
95
-
96
- // Format values recursively
97
- const valuesStr = this.values.map(v => this.formatValue(v)).join(' ');
98
-
205
+
206
+ // Format values recursively
207
+ const valuesStr = this.values.map((v) => this.formatValue(v)).join(' ');
208
+
99
209
  // Link with values only (null id)
100
210
  if (this.id === null) {
101
211
  // For lessParentheses mode with simple values, don't wrap the whole thing
102
212
  if (lessParentheses) {
103
213
  // Check if all values are simple (no nested values)
104
- const allSimple = this.values.every(v => !v.values || v.values.length === 0);
214
+ const allSimple = this.values.every(
215
+ (v) => !v.values || v.values.length === 0
216
+ );
105
217
  if (allSimple) {
106
218
  // Format each value without extra wrapping
107
- const simpleValuesStr = this.values.map(v => Link.escapeReference(v.id)).join(' ');
219
+ const simpleValuesStr = this.values
220
+ .map((v) => Link.escapeReference(v.id))
221
+ .join(' ');
108
222
  return simpleValuesStr;
109
223
  }
110
224
  // For mixed or complex values in lessParentheses mode, still avoid outer wrapper
@@ -114,42 +228,228 @@ export class Link {
114
228
  // For normal mode, wrap in parentheses
115
229
  return `(${valuesStr})`;
116
230
  }
117
-
231
+
118
232
  // Link with ID and values
119
233
  const idStr = Link.escapeReference(this.id);
120
234
  const withColon = `${idStr}: ${valuesStr}`;
121
- return lessParentheses && !this.needsParentheses(this.id) ? withColon : `(${withColon})`;
235
+ return lessParentheses && !this.needsParentheses(this.id)
236
+ ? withColon
237
+ : `(${withColon})`;
122
238
  }
123
-
239
+
240
+ /**
241
+ * Format a value within this link
242
+ * @param {Link} value - The value to format
243
+ * @returns {string} Formatted value string
244
+ */
124
245
  formatValue(value) {
125
- if (!value.format) {
126
- return Link.escapeReference(value.id || '');
246
+ if (!value || !value.format) {
247
+ return Link.escapeReference((value && value.id) || '');
127
248
  }
128
-
249
+
129
250
  // Check if we're in a compound link that was created from path combinations
130
251
  // This is indicated by having a parent context passed through
131
252
  const isCompoundFromPaths = this._isFromPathCombination === true;
132
-
253
+
133
254
  // For compound links from paths, format values with parentheses
134
255
  if (isCompoundFromPaths) {
135
256
  return value.format(false, true);
136
257
  }
137
-
258
+
138
259
  // Simple link with just an ID - don't wrap in parentheses when used as a value
139
260
  if (!value.values || value.values.length === 0) {
140
261
  return Link.escapeReference(value.id);
141
262
  }
142
-
263
+
143
264
  // Complex value with its own structure - format it normally with parentheses
144
265
  return value.format(false, false);
145
266
  }
146
-
267
+
268
+ /**
269
+ * Check if a string needs to be wrapped in parentheses
270
+ * @param {string} str - The string to check
271
+ * @returns {boolean} True if parentheses are needed
272
+ */
147
273
  needsParentheses(str) {
148
- return str && (str.includes(' ') || str.includes(':') || str.includes('(') || str.includes(')'));
274
+ return (
275
+ str &&
276
+ (str.includes(' ') ||
277
+ str.includes(':') ||
278
+ str.includes('(') ||
279
+ str.includes(')'))
280
+ );
281
+ }
282
+
283
+ /**
284
+ * Format the link using a FormatOptions object
285
+ * @param {FormatOptions} options - FormatOptions object with formatting options
286
+ * @param {boolean} isCompoundValue - If true, this is a value in a compound link
287
+ * @returns {string} Formatted string
288
+ */
289
+ _formatWithOptions(options, isCompoundValue = false) {
290
+ // Empty link
291
+ if (this.id === null && (!this.values || this.values.length === 0)) {
292
+ return options.lessParentheses ? '' : '()';
293
+ }
294
+
295
+ // Link with only ID, no values
296
+ if (!this.values || this.values.length === 0) {
297
+ const escapedId = Link.escapeReference(this.id);
298
+ if (isCompoundValue) {
299
+ return `(${escapedId})`;
300
+ }
301
+ return options.lessParentheses && !this.needsParentheses(this.id)
302
+ ? escapedId
303
+ : `(${escapedId})`;
304
+ }
305
+
306
+ // Check if we should use indented format
307
+ let shouldIndent = false;
308
+ if (options.shouldIndentByRefCount(this.values.length)) {
309
+ shouldIndent = true;
310
+ } else {
311
+ // Try inline format first
312
+ const valuesStr = this.values.map((v) => this.formatValue(v)).join(' ');
313
+ let testLine;
314
+ if (this.id !== null) {
315
+ const idStr = Link.escapeReference(this.id);
316
+ testLine = options.lessParentheses
317
+ ? `${idStr}: ${valuesStr}`
318
+ : `(${idStr}: ${valuesStr})`;
319
+ } else {
320
+ testLine = options.lessParentheses ? valuesStr : `(${valuesStr})`;
321
+ }
322
+
323
+ if (options.shouldIndentByLength(testLine)) {
324
+ shouldIndent = true;
325
+ }
326
+ }
327
+
328
+ // Format with indentation if needed
329
+ if (shouldIndent && options.preferInline === false) {
330
+ return this._formatIndented(options);
331
+ }
332
+
333
+ // Standard inline formatting
334
+ const valuesStr = this.values.map((v) => this.formatValue(v)).join(' ');
335
+
336
+ // Link with values only (null id)
337
+ if (this.id === null) {
338
+ if (options.lessParentheses) {
339
+ const allSimple = this.values.every(
340
+ (v) => !v.values || v.values.length === 0
341
+ );
342
+ if (allSimple) {
343
+ return this.values.map((v) => Link.escapeReference(v.id)).join(' ');
344
+ }
345
+ return valuesStr;
346
+ }
347
+ return `(${valuesStr})`;
348
+ }
349
+
350
+ // Link with ID and values
351
+ const idStr = Link.escapeReference(this.id);
352
+ const withColon = `${idStr}: ${valuesStr}`;
353
+ return options.lessParentheses && !this.needsParentheses(this.id)
354
+ ? withColon
355
+ : `(${withColon})`;
356
+ }
357
+
358
+ /**
359
+ * Format the link with indentation
360
+ * @param {FormatOptions} options - FormatOptions object with formatting options
361
+ * @returns {string} Indented formatted string
362
+ */
363
+ _formatIndented(options) {
364
+ if (this.id === null) {
365
+ // Values only - format each on separate line
366
+ const lines = this.values.map(
367
+ (v) => options.indentString + this.formatValue(v)
368
+ );
369
+ return lines.join('\n');
370
+ }
371
+
372
+ // Link with ID - format as id:\n value1\n value2
373
+ const idStr = Link.escapeReference(this.id);
374
+ const lines = [`${idStr}:`];
375
+ for (const v of this.values) {
376
+ lines.push(options.indentString + this.formatValue(v));
377
+ }
378
+ return lines.join('\n');
149
379
  }
150
380
  }
151
381
 
382
+ /**
383
+ * Group consecutive links with the same ID
384
+ * @param {Link[]} links - List of links to group
385
+ * @returns {Link[]} New list with consecutive links grouped
386
+ */
387
+ function _groupConsecutiveLinks(links) {
388
+ if (!links || links.length === 0) {
389
+ return links;
390
+ }
391
+
392
+ const grouped = [];
393
+ let i = 0;
394
+
395
+ while (i < links.length) {
396
+ const current = links[i];
397
+
398
+ // Look ahead for consecutive links with same ID
399
+ if (current.id !== null && current.values && current.values.length > 0) {
400
+ // Collect all values with same ID
401
+ const sameIdValues = [...current.values];
402
+ let j = i + 1;
403
+
404
+ while (j < links.length) {
405
+ const nextLink = links[j];
406
+ if (
407
+ nextLink.id === current.id &&
408
+ nextLink.values &&
409
+ nextLink.values.length > 0
410
+ ) {
411
+ sameIdValues.push(...nextLink.values);
412
+ j++;
413
+ } else {
414
+ break;
415
+ }
416
+ }
417
+
418
+ // If we found consecutive links, create grouped link
419
+ if (j > i + 1) {
420
+ const groupedLink = new Link(current.id, sameIdValues);
421
+ grouped.push(groupedLink);
422
+ i = j;
423
+ continue;
424
+ }
425
+ }
426
+
427
+ grouped.push(current);
428
+ i++;
429
+ }
430
+
431
+ return grouped;
432
+ }
433
+
152
434
  export function formatLinks(links, lessParentheses = false) {
153
435
  if (!links || links.length === 0) return '';
154
- return links.map(link => link.format(lessParentheses)).join('\n');
155
- }
436
+
437
+ // Support FormatOptions/FormatConfig as parameter
438
+ if (
439
+ lessParentheses &&
440
+ typeof lessParentheses === 'object' &&
441
+ (lessParentheses.constructor.name === 'FormatOptions' ||
442
+ lessParentheses.constructor.name === 'FormatConfig')
443
+ ) {
444
+ const options = lessParentheses;
445
+ // Apply consecutive link grouping if enabled
446
+ let linksToFormat = links;
447
+ if (options.groupConsecutive) {
448
+ linksToFormat = _groupConsecutiveLinks(links);
449
+ }
450
+ return linksToFormat.map((link) => link.format(options)).join('\n');
451
+ }
452
+
453
+ // Backward compatibility with boolean parameter
454
+ return links.map((link) => link.format(lessParentheses)).join('\n');
455
+ }
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
+ }