links-notation 0.11.0 → 0.12.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.
@@ -0,0 +1,16 @@
1
+ import { parse } from '../src/parser-generated.js';
2
+
3
+ const withLeading = ` A: a
4
+ B: b`;
5
+
6
+ console.log('Parsing with leading spaces:');
7
+ console.log(withLeading);
8
+ console.log('---');
9
+
10
+ try {
11
+ const result = parse(withLeading);
12
+ console.log('Result:', JSON.stringify(result, null, 2));
13
+ } catch (e) {
14
+ console.log('Error:', e.message);
15
+ console.log(e);
16
+ }
@@ -0,0 +1,33 @@
1
+ import { Parser } from '../src/Parser.js';
2
+
3
+ const parser = new Parser();
4
+
5
+ // Example with 2 leading spaces
6
+ const withLeading = ` A: a
7
+ B: b`;
8
+
9
+ // Example without leading spaces
10
+ const withoutLeading = `A: a
11
+ B: b`;
12
+
13
+ console.log('=== With Leading Spaces ===');
14
+ try {
15
+ const result = parser.parse(withLeading);
16
+ console.log(`Parsed ${result.length} links:`);
17
+ result.forEach((link, i) => {
18
+ console.log(` Link ${i}: ${link.toString()}`);
19
+ });
20
+ } catch (e) {
21
+ console.log('Error:', e.message);
22
+ }
23
+
24
+ console.log('\n=== Without Leading Spaces ===');
25
+ try {
26
+ const result = parser.parse(withoutLeading);
27
+ console.log(`Parsed ${result.length} links:`);
28
+ result.forEach((link, i) => {
29
+ console.log(` Link ${i}: ${link.toString()}`);
30
+ });
31
+ } catch (e) {
32
+ console.log('Error:', e.message);
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -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,92 @@
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) ?
35
+ return (!this.values || this.values.length === 0) ?
13
36
  '' : this.values.map(v => Link.getValueString(v)).join(' ');
14
37
  }
15
38
 
39
+ /**
40
+ * Simplify the link structure by unwrapping single-value containers
41
+ * @returns {Link} Simplified link
42
+ */
16
43
  simplify() {
17
44
  if (!this.values || this.values.length === 0) {
18
45
  return this;
19
46
  } else if (this.values.length === 1) {
20
47
  return this.values[0];
21
48
  } else {
22
- const newValues = this.values.map(v => v.simplify());
49
+ const newValues = this.values.map(v => {
50
+ // Check if value has simplify method (defensive programming)
51
+ return v && typeof v.simplify === 'function' ? v.simplify() : v;
52
+ });
23
53
  return new Link(this.id, newValues);
24
54
  }
25
55
  }
26
56
 
57
+ /**
58
+ * Combine this link with another link
59
+ * @param {Link} other - The link to combine with
60
+ * @returns {Link} Combined link
61
+ */
27
62
  combine(other) {
28
63
  return new Link(null, [this, other]);
29
64
  }
30
65
 
66
+ /**
67
+ * Get string representation of a value
68
+ * @param {Link} value - The value to stringify
69
+ * @returns {string} String representation
70
+ */
31
71
  static getValueString(value) {
32
- return value.toLinkOrIdString();
72
+ // Defensive check for method existence
73
+ return value && typeof value.toLinkOrIdString === 'function' ? value.toLinkOrIdString() : String(value);
33
74
  }
34
75
 
76
+ /**
77
+ * Escape a reference string by adding quotes if necessary
78
+ * @param {string} reference - The reference to escape
79
+ * @returns {string} Escaped reference
80
+ */
35
81
  static escapeReference(reference) {
36
82
  if (!reference || reference.trim() === '') {
37
83
  return '';
38
84
  }
39
-
40
- const needsSingleQuotes =
85
+
86
+ const hasSingleQuote = reference.includes("'");
87
+ const hasDoubleQuote = reference.includes('"');
88
+
89
+ const needsQuoting =
41
90
  reference.includes(':') ||
42
91
  reference.includes('(') ||
43
92
  reference.includes(')') ||
@@ -45,17 +94,38 @@ export class Link {
45
94
  reference.includes('\t') ||
46
95
  reference.includes('\n') ||
47
96
  reference.includes('\r') ||
48
- reference.includes('"');
49
-
50
- if (needsSingleQuotes) {
97
+ hasDoubleQuote ||
98
+ hasSingleQuote;
99
+
100
+ // Handle edge case: reference contains both single and double quotes
101
+ if (hasSingleQuote && hasDoubleQuote) {
102
+ // Escape single quotes and wrap in single quotes
103
+ return `'${reference.replace(/'/g, "\\'")}'`;
104
+ }
105
+
106
+ // Prefer single quotes if double quotes are present
107
+ if (hasDoubleQuote) {
51
108
  return `'${reference}'`;
52
- } else if (reference.includes("'")) {
109
+ }
110
+
111
+ // Use double quotes if single quotes are present
112
+ if (hasSingleQuote) {
53
113
  return `"${reference}"`;
54
- } else {
55
- return reference;
56
114
  }
115
+
116
+ // Use single quotes for special characters
117
+ if (needsQuoting) {
118
+ return `'${reference}'`;
119
+ }
120
+
121
+ // No quoting needed
122
+ return reference;
57
123
  }
58
124
 
125
+ /**
126
+ * Convert to string using either just ID or full format
127
+ * @returns {string} String representation
128
+ */
59
129
  toLinkOrIdString() {
60
130
  if (!this.values || this.values.length === 0) {
61
131
  return this.id === null ? '' : Link.escapeReference(this.id);
@@ -63,21 +133,53 @@ export class Link {
63
133
  return this.toString();
64
134
  }
65
135
 
136
+ /**
137
+ * Check equality with another Link
138
+ * @param {*} other - Object to compare with
139
+ * @returns {boolean} True if links are equal
140
+ */
66
141
  equals(other) {
67
142
  if (!(other instanceof Link)) return false;
68
143
  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;
144
+
145
+ // Handle null/undefined values arrays
146
+ const thisValues = this.values || [];
147
+ const otherValues = other.values || [];
148
+
149
+ if (thisValues.length !== otherValues.length) return false;
150
+
151
+ for (let i = 0; i < thisValues.length; i++) {
152
+ // Defensive check for equals method
153
+ if (thisValues[i] && typeof thisValues[i].equals === 'function') {
154
+ if (!thisValues[i].equals(otherValues[i])) {
155
+ return false;
156
+ }
157
+ } else {
158
+ // Fallback to reference equality
159
+ if (thisValues[i] !== otherValues[i]) {
160
+ return false;
161
+ }
74
162
  }
75
163
  }
76
164
  return true;
77
165
  }
78
166
 
79
-
167
+
168
+ /**
169
+ * Format the link as a string
170
+ * @param {boolean|FormatOptions} lessParentheses - If true, omit parentheses where safe; or a FormatOptions object
171
+ * @param {boolean} isCompoundValue - If true, this is a value in a compound link
172
+ * @returns {string} Formatted string
173
+ */
80
174
  format(lessParentheses = false, isCompoundValue = false) {
175
+ // Support FormatOptions/FormatConfig as first parameter
176
+ // 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')) {
179
+ return this._formatWithOptions(lessParentheses, isCompoundValue);
180
+ }
181
+
182
+ // Original implementation for backward compatibility
81
183
  // Empty link
82
184
  if (this.id === null && (!this.values || this.values.length === 0)) {
83
185
  return lessParentheses ? '' : '()';
@@ -121,35 +223,195 @@ export class Link {
121
223
  return lessParentheses && !this.needsParentheses(this.id) ? withColon : `(${withColon})`;
122
224
  }
123
225
 
226
+ /**
227
+ * Format a value within this link
228
+ * @param {Link} value - The value to format
229
+ * @returns {string} Formatted value string
230
+ */
124
231
  formatValue(value) {
125
- if (!value.format) {
126
- return Link.escapeReference(value.id || '');
232
+ if (!value || !value.format) {
233
+ return Link.escapeReference((value && value.id) || '');
127
234
  }
128
-
235
+
129
236
  // Check if we're in a compound link that was created from path combinations
130
237
  // This is indicated by having a parent context passed through
131
238
  const isCompoundFromPaths = this._isFromPathCombination === true;
132
-
239
+
133
240
  // For compound links from paths, format values with parentheses
134
241
  if (isCompoundFromPaths) {
135
242
  return value.format(false, true);
136
243
  }
137
-
244
+
138
245
  // Simple link with just an ID - don't wrap in parentheses when used as a value
139
246
  if (!value.values || value.values.length === 0) {
140
247
  return Link.escapeReference(value.id);
141
248
  }
142
-
249
+
143
250
  // Complex value with its own structure - format it normally with parentheses
144
251
  return value.format(false, false);
145
252
  }
146
-
253
+
254
+ /**
255
+ * Check if a string needs to be wrapped in parentheses
256
+ * @param {string} str - The string to check
257
+ * @returns {boolean} True if parentheses are needed
258
+ */
147
259
  needsParentheses(str) {
148
260
  return str && (str.includes(' ') || str.includes(':') || str.includes('(') || str.includes(')'));
149
261
  }
262
+
263
+ /**
264
+ * Format the link using a FormatOptions object
265
+ * @param {FormatOptions} options - FormatOptions object with formatting options
266
+ * @param {boolean} isCompoundValue - If true, this is a value in a compound link
267
+ * @returns {string} Formatted string
268
+ */
269
+ _formatWithOptions(options, isCompoundValue = false) {
270
+ // Empty link
271
+ if (this.id === null && (!this.values || this.values.length === 0)) {
272
+ return options.lessParentheses ? '' : '()';
273
+ }
274
+
275
+ // Link with only ID, no values
276
+ if (!this.values || this.values.length === 0) {
277
+ const escapedId = Link.escapeReference(this.id);
278
+ if (isCompoundValue) {
279
+ return `(${escapedId})`;
280
+ }
281
+ return options.lessParentheses && !this.needsParentheses(this.id) ? escapedId : `(${escapedId})`;
282
+ }
283
+
284
+ // Check if we should use indented format
285
+ let shouldIndent = false;
286
+ if (options.shouldIndentByRefCount(this.values.length)) {
287
+ shouldIndent = true;
288
+ } else {
289
+ // Try inline format first
290
+ const valuesStr = this.values.map(v => this.formatValue(v)).join(' ');
291
+ let testLine;
292
+ if (this.id !== null) {
293
+ const idStr = Link.escapeReference(this.id);
294
+ testLine = options.lessParentheses ? `${idStr}: ${valuesStr}` : `(${idStr}: ${valuesStr})`;
295
+ } else {
296
+ testLine = options.lessParentheses ? valuesStr : `(${valuesStr})`;
297
+ }
298
+
299
+ if (options.shouldIndentByLength(testLine)) {
300
+ shouldIndent = true;
301
+ }
302
+ }
303
+
304
+ // Format with indentation if needed
305
+ if (shouldIndent && options.preferInline === false) {
306
+ return this._formatIndented(options);
307
+ }
308
+
309
+ // Standard inline formatting
310
+ const valuesStr = this.values.map(v => this.formatValue(v)).join(' ');
311
+
312
+ // Link with values only (null id)
313
+ if (this.id === null) {
314
+ if (options.lessParentheses) {
315
+ const allSimple = this.values.every(v => !v.values || v.values.length === 0);
316
+ if (allSimple) {
317
+ return this.values.map(v => Link.escapeReference(v.id)).join(' ');
318
+ }
319
+ return valuesStr;
320
+ }
321
+ return `(${valuesStr})`;
322
+ }
323
+
324
+ // Link with ID and values
325
+ const idStr = Link.escapeReference(this.id);
326
+ const withColon = `${idStr}: ${valuesStr}`;
327
+ return options.lessParentheses && !this.needsParentheses(this.id) ? withColon : `(${withColon})`;
328
+ }
329
+
330
+ /**
331
+ * Format the link with indentation
332
+ * @param {FormatOptions} options - FormatOptions object with formatting options
333
+ * @returns {string} Indented formatted string
334
+ */
335
+ _formatIndented(options) {
336
+ if (this.id === null) {
337
+ // Values only - format each on separate line
338
+ const lines = this.values.map(v => options.indentString + this.formatValue(v));
339
+ return lines.join('\n');
340
+ }
341
+
342
+ // Link with ID - format as id:\n value1\n value2
343
+ const idStr = Link.escapeReference(this.id);
344
+ const lines = [`${idStr}:`];
345
+ for (const v of this.values) {
346
+ lines.push(options.indentString + this.formatValue(v));
347
+ }
348
+ return lines.join('\n');
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Group consecutive links with the same ID
354
+ * @param {Link[]} links - List of links to group
355
+ * @returns {Link[]} New list with consecutive links grouped
356
+ */
357
+ function _groupConsecutiveLinks(links) {
358
+ if (!links || links.length === 0) {
359
+ return links;
360
+ }
361
+
362
+ const grouped = [];
363
+ let i = 0;
364
+
365
+ while (i < links.length) {
366
+ const current = links[i];
367
+
368
+ // Look ahead for consecutive links with same ID
369
+ if (current.id !== null && current.values && current.values.length > 0) {
370
+ // Collect all values with same ID
371
+ const sameIdValues = [...current.values];
372
+ let j = i + 1;
373
+
374
+ while (j < links.length) {
375
+ const nextLink = links[j];
376
+ if (nextLink.id === current.id && nextLink.values && nextLink.values.length > 0) {
377
+ sameIdValues.push(...nextLink.values);
378
+ j++;
379
+ } else {
380
+ break;
381
+ }
382
+ }
383
+
384
+ // If we found consecutive links, create grouped link
385
+ if (j > i + 1) {
386
+ const groupedLink = new Link(current.id, sameIdValues);
387
+ grouped.push(groupedLink);
388
+ i = j;
389
+ continue;
390
+ }
391
+ }
392
+
393
+ grouped.push(current);
394
+ i++;
395
+ }
396
+
397
+ return grouped;
150
398
  }
151
399
 
152
400
  export function formatLinks(links, lessParentheses = false) {
153
401
  if (!links || links.length === 0) return '';
402
+
403
+ // Support FormatOptions/FormatConfig as parameter
404
+ if (lessParentheses && typeof lessParentheses === 'object' &&
405
+ (lessParentheses.constructor.name === 'FormatOptions' || lessParentheses.constructor.name === 'FormatConfig')) {
406
+ const options = lessParentheses;
407
+ // Apply consecutive link grouping if enabled
408
+ let linksToFormat = links;
409
+ if (options.groupConsecutive) {
410
+ linksToFormat = _groupConsecutiveLinks(links);
411
+ }
412
+ return linksToFormat.map(link => link.format(options)).join('\n');
413
+ }
414
+
415
+ // Backward compatibility with boolean parameter
154
416
  return links.map(link => link.format(lessParentheses)).join('\n');
155
417
  }
package/src/Parser.js CHANGED
@@ -2,24 +2,52 @@ import { Link } from './Link.js';
2
2
  import * as parserModule from './parser-generated.js';
3
3
 
4
4
  export class Parser {
5
- constructor() {
5
+ /**
6
+ * Create a new Parser instance
7
+ * @param {Object} options - Parser options
8
+ * @param {number} options.maxInputSize - Maximum input size in bytes (default: 10MB)
9
+ * @param {number} options.maxDepth - Maximum nesting depth (default: 1000)
10
+ */
11
+ constructor(options = {}) {
12
+ this.maxInputSize = options.maxInputSize || 10 * 1024 * 1024; // 10MB default
13
+ this.maxDepth = options.maxDepth || 1000;
6
14
  }
7
15
 
16
+ /**
17
+ * Parse Lino notation text into Link objects
18
+ * @param {string} input - The Lino notation text to parse
19
+ * @returns {Link[]} Array of parsed Link objects
20
+ * @throws {Error} If parsing fails
21
+ */
8
22
  parse(input) {
23
+ // Validate input
24
+ if (typeof input !== 'string') {
25
+ throw new TypeError('Input must be a string');
26
+ }
27
+
28
+ if (input.length > this.maxInputSize) {
29
+ throw new Error(`Input size exceeds maximum allowed size of ${this.maxInputSize} bytes`);
30
+ }
31
+
9
32
  try {
10
33
  const rawResult = parserModule.parse(input);
11
34
  return this.transformResult(rawResult);
12
35
  } catch (error) {
13
- throw new Error(`Parse error: ${error.message}`);
36
+ // Preserve original error information
37
+ const parseError = new Error(`Parse error: ${error.message}`);
38
+ parseError.cause = error;
39
+ parseError.location = error.location;
40
+ throw parseError;
14
41
  }
15
42
  }
16
43
 
17
44
  transformResult(rawResult) {
18
45
  const links = [];
19
46
  const items = Array.isArray(rawResult) ? rawResult : [rawResult];
20
-
47
+
21
48
  for (const item of items) {
22
- if (item) {
49
+ // Use explicit null/undefined check
50
+ if (item !== null && item !== undefined) {
23
51
  this.collectLinks(item, [], links);
24
52
  }
25
53
  }
@@ -27,7 +55,8 @@ export class Parser {
27
55
  }
28
56
 
29
57
  collectLinks(item, parentPath, result) {
30
- if (!item) return;
58
+ // Use explicit null/undefined check
59
+ if (item === null || item === undefined) return;
31
60
 
32
61
  // For items with children (indented structure)
33
62
  if (item.children && item.children.length > 0) {
@@ -105,9 +134,15 @@ export class Parser {
105
134
  }
106
135
 
107
136
 
137
+ /**
138
+ * Transform a parsed item into a Link object
139
+ * @param {*} item - The item to transform
140
+ * @returns {Link|null} The transformed Link or null
141
+ */
108
142
  transformLink(item) {
109
- if (!item) return null;
110
-
143
+ // Use explicit null/undefined check
144
+ if (item === null || item === undefined) return null;
145
+
111
146
  if (item instanceof Link) {
112
147
  return item;
113
148
  }
@@ -124,7 +159,7 @@ export class Parser {
124
159
  link.values = item.values.map(v => this.transformLink(v));
125
160
  return link;
126
161
  }
127
-
162
+
128
163
  // Default case
129
164
  return new Link(item.id || null, []);
130
165
  }