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.
- package/bun.lock +1 -0
- package/dist/index.js +484 -143
- package/examples/debug_indentation.js +16 -0
- package/examples/test_leading_spaces.js +33 -0
- package/package.json +1 -1
- package/src/FormatConfig.js +19 -0
- package/src/FormatOptions.js +52 -0
- package/src/Link.js +287 -25
- package/src/Parser.js +43 -8
- package/src/grammar.pegjs +30 -10
- package/src/index.js +3 -1
- package/src/parser-generated.js +276 -123
- package/tests/ApiTests.test.js +104 -7
- package/tests/EdgeCaseParser.test.js +9 -9
- package/tests/FormatConfig.test.js +126 -0
- package/tests/IndentationConsistency.test.js +128 -0
- package/tests/IndentedIdSyntax.test.js +2 -2
- package/tests/Link.test.js +1 -1
- package/tests/LinksGroup.test.js +28 -1
- package/tests/MixedIndentationModes.test.js +5 -5
- package/tests/MultilineParser.test.js +5 -5
- package/tests/NestedParser.test.js +31 -4
- package/tests/NestedSelfReference.test.js +102 -0
- package/tests/SingleLineParser.test.js +86 -6
|
@@ -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
|
@@ -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
|
-
|
|
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 =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|