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/.eslintrc.json +28 -0
- package/.prettierignore +4 -0
- package/.prettierrc +4 -0
- package/README.md +107 -7
- package/README.ru.md +3 -6
- package/bun.lock +189 -10
- package/dist/index.js +628 -263
- package/eslint.config.js +34 -0
- package/examples/empty_reference.js +43 -0
- package/examples/typescript-example.ts +95 -0
- package/index.d.ts +312 -0
- package/package.json +22 -7
- package/src/FormatOptions.js +1 -1
- package/src/Link.js +78 -34
- package/src/LinksGroup.js +2 -2
- package/src/Parser.js +51 -18
- package/src/grammar.pegjs +190 -15
- package/src/index.js +1 -1
- package/src/parser-generated.js +618 -231
- package/tests/ApiTests.test.js +2 -2
- package/tests/EdgeCaseParser.test.js +3 -3
- package/tests/EmptyReference.test.js +91 -0
- package/tests/FormatConfig.test.js +32 -10
- package/tests/IndentedIdSyntax.test.js +39 -39
- package/tests/Link.test.js +4 -4
- package/tests/LinksGroup.test.js +8 -5
- package/tests/MixedIndentationModes.test.js +35 -35
- package/tests/MultiQuoteParser.test.js +463 -0
- package/tests/MultilineParser.test.js +12 -12
- package/tests/MultilineQuotedString.test.js +13 -11
- package/tests/NestedIndentation.test.js +114 -0
- package/tests/NestedParser.test.js +5 -3
- package/tests/NestedSelfReference.test.js +4 -2
- package/tests/SingleLineParser.test.js +9 -9
- package/tsconfig.json +15 -0
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
|
|
36
|
-
|
|
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'
|
|
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 (
|
|
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 (
|
|
178
|
-
|
|
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)
|
|
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(
|
|
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
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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(
|
|
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)
|
|
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(
|
|
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 (
|
|
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 (
|
|
405
|
-
|
|
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
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(
|
|
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 (
|
|
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
|
|
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
|
|
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 = &{
|
|
40
|
-
/ &{
|
|
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 {
|
|
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 =
|
|
177
|
+
multiLineAnyLink = nestedGroup
|
|
60
178
|
|
|
61
179
|
singleLineAnyLink = fl:singleLineLink eol { return fl; }
|
|
62
180
|
/ vl:singleLineValueLink eol { return vl; }
|
|
63
181
|
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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';
|