@sprig-and-prose/sprig-universe 0.3.4 → 0.4.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 +1 -1
- package/src/ast.js +51 -4
- package/src/graph.js +371 -12
- package/src/ir.js +38 -2
- package/src/parser.js +655 -52
- package/src/scanner.js +1 -0
- package/test/aliases.test.js +91 -0
- package/test/fixtures/aliases-basic.prose +7 -0
- package/test/fixtures/aliases-conflict.prose +6 -0
- package/test/fixtures/aliases-no-leak.prose +5 -0
- package/test/fixtures/aliases-shadowing.prose +11 -0
- package/test/fixtures/aliases-single-line.prose +7 -0
- package/test/fixtures/relationship-errors.prose +20 -0
- package/test/fixtures/relationship-paired.prose +28 -0
- package/test/fixtures/relationship-scoped.prose +23 -0
- package/test/fixtures/relationship-symmetric.prose +18 -0
- package/test/fixtures/relationship-usage.prose +31 -0
package/src/parser.js
CHANGED
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
import { mergeSpans } from './util/span.js';
|
|
6
6
|
|
|
7
|
+
const DECLARATION_KINDS = new Set([
|
|
8
|
+
'universe',
|
|
9
|
+
'anthology',
|
|
10
|
+
'series',
|
|
11
|
+
'book',
|
|
12
|
+
'chapter',
|
|
13
|
+
'concept',
|
|
14
|
+
'relates',
|
|
15
|
+
'relationship',
|
|
16
|
+
'repository',
|
|
17
|
+
'reference',
|
|
18
|
+
]);
|
|
19
|
+
|
|
7
20
|
/**
|
|
8
21
|
* @typedef {import('./ast.js').FileAST} FileAST
|
|
9
22
|
* @typedef {import('./ast.js').UniverseDecl} UniverseDecl
|
|
@@ -13,6 +26,7 @@ import { mergeSpans } from './util/span.js';
|
|
|
13
26
|
* @typedef {import('./ast.js').ChapterDecl} ChapterDecl
|
|
14
27
|
* @typedef {import('./ast.js').ConceptDecl} ConceptDecl
|
|
15
28
|
* @typedef {import('./ast.js').RelatesDecl} RelatesDecl
|
|
29
|
+
* @typedef {import('./ast.js').RelationshipDecl} RelationshipDecl
|
|
16
30
|
* @typedef {import('./ast.js').DescribeBlock} DescribeBlock
|
|
17
31
|
* @typedef {import('./ast.js').UnknownBlock} UnknownBlock
|
|
18
32
|
* @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
|
|
@@ -48,6 +62,10 @@ class Parser {
|
|
|
48
62
|
this.file = file;
|
|
49
63
|
this.sourceText = sourceText;
|
|
50
64
|
this.pos = 0;
|
|
65
|
+
this.aliasScopes = [new Map()];
|
|
66
|
+
this.aliasDeclarations = [];
|
|
67
|
+
this.relationshipScopes = [new Map()];
|
|
68
|
+
this.relationshipDeclarations = [];
|
|
51
69
|
}
|
|
52
70
|
|
|
53
71
|
/**
|
|
@@ -100,6 +118,205 @@ class Parser {
|
|
|
100
118
|
return token;
|
|
101
119
|
}
|
|
102
120
|
|
|
121
|
+
/**
|
|
122
|
+
* @param {string} [value]
|
|
123
|
+
* @returns {Token}
|
|
124
|
+
* @throws {Error}
|
|
125
|
+
*/
|
|
126
|
+
expectIdentifierOrKeyword(value) {
|
|
127
|
+
const token = this.advance();
|
|
128
|
+
if (!token || (token.type !== 'KEYWORD' && token.type !== 'IDENTIFIER')) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Expected identifier or keyword, got ${token ? token.type : 'EOF'} at ${this.file}:${token ? token.span.start.line : '?'}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (value !== undefined && token.value !== value) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Expected identifier or keyword "${value}", got "${token.value}" at ${this.file}:${token.span.start.line}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return token;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {string} value
|
|
143
|
+
* @returns {Token}
|
|
144
|
+
* @throws {Error}
|
|
145
|
+
*/
|
|
146
|
+
expectKindToken(value) {
|
|
147
|
+
return this.expectIdentifierOrKeyword(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
pushAliasScope() {
|
|
151
|
+
const parent = this.aliasScopes[this.aliasScopes.length - 1] || new Map();
|
|
152
|
+
this.aliasScopes.push(new Map(parent));
|
|
153
|
+
this.aliasDeclarations.push(new Map());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @returns {Map<string, string>}
|
|
158
|
+
*/
|
|
159
|
+
popAliasScope() {
|
|
160
|
+
this.aliasScopes.pop();
|
|
161
|
+
return this.aliasDeclarations.pop() || new Map();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @returns {Map<string, string>}
|
|
166
|
+
*/
|
|
167
|
+
currentAliasMap() {
|
|
168
|
+
return this.aliasScopes[this.aliasScopes.length - 1] || new Map();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
pushRelationshipScope() {
|
|
172
|
+
const parent = this.relationshipScopes[this.relationshipScopes.length - 1] || new Map();
|
|
173
|
+
this.relationshipScopes.push(new Map(parent));
|
|
174
|
+
this.relationshipDeclarations.push(new Map());
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @returns {Map<string, RelationshipDecl>}
|
|
179
|
+
*/
|
|
180
|
+
popRelationshipScope() {
|
|
181
|
+
this.relationshipScopes.pop();
|
|
182
|
+
return this.relationshipDeclarations.pop() || new Map();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @returns {Map<string, RelationshipDecl>}
|
|
187
|
+
*/
|
|
188
|
+
currentRelationshipMap() {
|
|
189
|
+
return this.relationshipScopes[this.relationshipScopes.length - 1] || new Map();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {RelationshipDecl} relationshipDecl
|
|
194
|
+
*/
|
|
195
|
+
declareRelationship(relationshipDecl) {
|
|
196
|
+
const declaredMap = this.relationshipDeclarations[this.relationshipDeclarations.length - 1];
|
|
197
|
+
if (!declaredMap) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Relationship must be declared inside a container at ${this.file}:${relationshipDecl.source.start.line}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Get identifiers to check for duplicates
|
|
204
|
+
const identifiers = [];
|
|
205
|
+
if (relationshipDecl.type === 'symmetric' && relationshipDecl.id) {
|
|
206
|
+
identifiers.push(relationshipDecl.id);
|
|
207
|
+
} else if (relationshipDecl.type === 'paired') {
|
|
208
|
+
if (relationshipDecl.leftId) identifiers.push(relationshipDecl.leftId);
|
|
209
|
+
if (relationshipDecl.rightId) identifiers.push(relationshipDecl.rightId);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const id of identifiers) {
|
|
213
|
+
if (declaredMap.has(id)) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Duplicate relationship identifier "${id}" in this scope at ${this.file}:${relationshipDecl.source.start.line}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const relationshipMap = this.currentRelationshipMap();
|
|
219
|
+
relationshipMap.set(id, relationshipDecl);
|
|
220
|
+
declaredMap.set(id, relationshipDecl);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {Token} nameToken
|
|
226
|
+
* @param {Token} targetToken
|
|
227
|
+
*/
|
|
228
|
+
declareAlias(nameToken, targetToken) {
|
|
229
|
+
const aliasName = nameToken.value;
|
|
230
|
+
const targetKind = targetToken.value;
|
|
231
|
+
const declaredMap = this.aliasDeclarations[this.aliasDeclarations.length - 1];
|
|
232
|
+
if (!declaredMap) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`Alias "${aliasName}" must be declared inside a container at ${this.file}:${nameToken.span.start.line}`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (DECLARATION_KINDS.has(aliasName)) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Alias "${aliasName}" conflicts with built-in kind "${aliasName}" at ${this.file}:${nameToken.span.start.line}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (!DECLARATION_KINDS.has(targetKind)) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Unknown alias target kind "${targetKind}" at ${this.file}:${targetToken.span.start.line}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (declaredMap.has(aliasName)) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Duplicate alias "${aliasName}" in this scope at ${this.file}:${nameToken.span.start.line}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const aliasMap = this.currentAliasMap();
|
|
253
|
+
aliasMap.set(aliasName, targetKind);
|
|
254
|
+
declaredMap.set(aliasName, targetKind);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
parseAliasStatement() {
|
|
258
|
+
this.expectIdentifierOrKeyword('alias');
|
|
259
|
+
const nameToken = this.expectIdentifierOrKeyword();
|
|
260
|
+
this.expect('LBRACE');
|
|
261
|
+
const targetToken = this.expectIdentifierOrKeyword();
|
|
262
|
+
this.expect('RBRACE');
|
|
263
|
+
this.declareAlias(nameToken, targetToken);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
parseAliasesBlock() {
|
|
267
|
+
this.expectIdentifierOrKeyword('aliases');
|
|
268
|
+
this.expect('LBRACE');
|
|
269
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
270
|
+
if (!this.match('IDENTIFIER') && !this.match('KEYWORD')) {
|
|
271
|
+
const token = this.peek();
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Expected alias name in aliases block at ${this.file}:${token ? token.span.start.line : '?'}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const nameToken = this.expectIdentifierOrKeyword();
|
|
277
|
+
this.expect('LBRACE');
|
|
278
|
+
const targetToken = this.expectIdentifierOrKeyword();
|
|
279
|
+
this.expect('RBRACE');
|
|
280
|
+
this.declareAlias(nameToken, targetToken);
|
|
281
|
+
}
|
|
282
|
+
this.expect('RBRACE');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* @param {string} resolvedKind
|
|
287
|
+
* @param {string} spelledKind
|
|
288
|
+
*/
|
|
289
|
+
parseDeclarationByKind(resolvedKind, spelledKind) {
|
|
290
|
+
if (resolvedKind === 'anthology') {
|
|
291
|
+
return this.parseAnthology(spelledKind);
|
|
292
|
+
}
|
|
293
|
+
if (resolvedKind === 'series') {
|
|
294
|
+
return this.parseSeries(spelledKind);
|
|
295
|
+
}
|
|
296
|
+
if (resolvedKind === 'book') {
|
|
297
|
+
return this.parseBook(spelledKind);
|
|
298
|
+
}
|
|
299
|
+
if (resolvedKind === 'chapter') {
|
|
300
|
+
return this.parseChapter(spelledKind);
|
|
301
|
+
}
|
|
302
|
+
if (resolvedKind === 'concept') {
|
|
303
|
+
return this.parseConcept(spelledKind);
|
|
304
|
+
}
|
|
305
|
+
if (resolvedKind === 'relates') {
|
|
306
|
+
return this.parseRelates(spelledKind);
|
|
307
|
+
}
|
|
308
|
+
if (resolvedKind === 'repository') {
|
|
309
|
+
return this.parseRepository(spelledKind);
|
|
310
|
+
}
|
|
311
|
+
if (resolvedKind === 'reference') {
|
|
312
|
+
return this.parseReferenceDecl(spelledKind);
|
|
313
|
+
}
|
|
314
|
+
if (resolvedKind === 'relationship') {
|
|
315
|
+
return this.parseRelationship(spelledKind);
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
103
320
|
/**
|
|
104
321
|
* @returns {FileAST}
|
|
105
322
|
*/
|
|
@@ -174,16 +391,40 @@ class Parser {
|
|
|
174
391
|
/**
|
|
175
392
|
* @returns {UniverseDecl}
|
|
176
393
|
*/
|
|
177
|
-
parseUniverse() {
|
|
178
|
-
const startToken = this.
|
|
394
|
+
parseUniverse(spelledKind = 'universe') {
|
|
395
|
+
const startToken = this.expectKindToken(spelledKind);
|
|
179
396
|
const nameToken = this.expect('IDENTIFIER');
|
|
180
397
|
const lbrace = this.expect('LBRACE');
|
|
181
|
-
|
|
398
|
+
this.pushAliasScope();
|
|
399
|
+
this.pushRelationshipScope();
|
|
400
|
+
const body = this.parseBlockBody(
|
|
401
|
+
[
|
|
402
|
+
'anthology',
|
|
403
|
+
'series',
|
|
404
|
+
'book',
|
|
405
|
+
'chapter',
|
|
406
|
+
'concept',
|
|
407
|
+
'relates',
|
|
408
|
+
'relationship',
|
|
409
|
+
'describe',
|
|
410
|
+
'title',
|
|
411
|
+
'repository',
|
|
412
|
+
'reference',
|
|
413
|
+
'references',
|
|
414
|
+
'ordering',
|
|
415
|
+
'documentation',
|
|
416
|
+
],
|
|
417
|
+
{ allowAliases: true },
|
|
418
|
+
);
|
|
182
419
|
const rbrace = this.expect('RBRACE');
|
|
420
|
+
const aliases = this.popAliasScope();
|
|
421
|
+
const relationships = this.popRelationshipScope();
|
|
183
422
|
|
|
184
423
|
return {
|
|
185
424
|
kind: 'universe',
|
|
425
|
+
spelledKind,
|
|
186
426
|
name: nameToken.value,
|
|
427
|
+
aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
|
|
187
428
|
body,
|
|
188
429
|
source: {
|
|
189
430
|
file: this.file,
|
|
@@ -195,10 +436,12 @@ class Parser {
|
|
|
195
436
|
|
|
196
437
|
/**
|
|
197
438
|
* @param {string[]} allowedKeywords - Keywords allowed in this body
|
|
439
|
+
* @param {{ allowAliases?: boolean }} [options]
|
|
198
440
|
* @returns {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>}
|
|
199
441
|
*/
|
|
200
|
-
parseBlockBody(allowedKeywords) {
|
|
442
|
+
parseBlockBody(allowedKeywords, options = {}) {
|
|
201
443
|
const body = [];
|
|
444
|
+
const allowAliases = options.allowAliases === true;
|
|
202
445
|
|
|
203
446
|
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
204
447
|
// Check for keywords or identifiers that might start a block
|
|
@@ -206,6 +449,15 @@ class Parser {
|
|
|
206
449
|
const keyword = this.peek()?.value;
|
|
207
450
|
if (!keyword) break;
|
|
208
451
|
|
|
452
|
+
if (allowAliases && keyword === 'alias') {
|
|
453
|
+
this.parseAliasStatement();
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (allowAliases && keyword === 'aliases') {
|
|
457
|
+
this.parseAliasesBlock();
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
|
|
209
461
|
// Check for named document: document <IDENTIFIER> { ... }
|
|
210
462
|
if (keyword === 'document') {
|
|
211
463
|
const nextPos = this.pos + 1;
|
|
@@ -221,19 +473,16 @@ class Parser {
|
|
|
221
473
|
// Fall through to unknown block handling
|
|
222
474
|
}
|
|
223
475
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
} else if (keyword === 'relates' && allowedKeywords.includes('relates')) {
|
|
235
|
-
body.push(this.parseRelates());
|
|
236
|
-
} else if (keyword === 'describe' && allowedKeywords.includes('describe')) {
|
|
476
|
+
const resolvedKind = this.currentAliasMap().get(keyword) || keyword;
|
|
477
|
+
if (allowedKeywords.includes(resolvedKind)) {
|
|
478
|
+
const decl = this.parseDeclarationByKind(resolvedKind, keyword);
|
|
479
|
+
if (decl) {
|
|
480
|
+
body.push(decl);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (keyword === 'describe' && allowedKeywords.includes('describe')) {
|
|
237
486
|
body.push(this.parseDescribe());
|
|
238
487
|
} else if (keyword === 'title' && allowedKeywords.includes('title')) {
|
|
239
488
|
body.push(this.parseTitle());
|
|
@@ -274,8 +523,8 @@ class Parser {
|
|
|
274
523
|
/**
|
|
275
524
|
* @returns {AnthologyDecl}
|
|
276
525
|
*/
|
|
277
|
-
parseAnthology() {
|
|
278
|
-
const startToken = this.
|
|
526
|
+
parseAnthology(spelledKind = 'anthology') {
|
|
527
|
+
const startToken = this.expectKindToken(spelledKind);
|
|
279
528
|
const nameToken = this.expect('IDENTIFIER');
|
|
280
529
|
let parentName = undefined;
|
|
281
530
|
if (this.match('KEYWORD') && this.peek()?.value === 'in') {
|
|
@@ -284,26 +533,37 @@ class Parser {
|
|
|
284
533
|
parentName = parentToken.value;
|
|
285
534
|
}
|
|
286
535
|
const lbrace = this.expect('LBRACE');
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
536
|
+
this.pushAliasScope();
|
|
537
|
+
this.pushRelationshipScope();
|
|
538
|
+
const body = this.parseBlockBody(
|
|
539
|
+
[
|
|
540
|
+
'describe',
|
|
541
|
+
'title',
|
|
542
|
+
'series',
|
|
543
|
+
'book',
|
|
544
|
+
'chapter',
|
|
545
|
+
'concept',
|
|
546
|
+
'relationship',
|
|
547
|
+
'relates',
|
|
548
|
+
'relationship',
|
|
549
|
+
'references',
|
|
550
|
+
'ordering',
|
|
551
|
+
'documentation',
|
|
552
|
+
'repository',
|
|
553
|
+
'reference',
|
|
554
|
+
],
|
|
555
|
+
{ allowAliases: true },
|
|
556
|
+
);
|
|
301
557
|
const rbrace = this.expect('RBRACE');
|
|
558
|
+
const aliases = this.popAliasScope();
|
|
559
|
+
const relationships = this.popRelationshipScope();
|
|
302
560
|
|
|
303
561
|
return {
|
|
304
562
|
kind: 'anthology',
|
|
563
|
+
spelledKind,
|
|
305
564
|
name: nameToken.value,
|
|
306
565
|
parentName,
|
|
566
|
+
aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
|
|
307
567
|
body,
|
|
308
568
|
source: {
|
|
309
569
|
file: this.file,
|
|
@@ -316,8 +576,8 @@ class Parser {
|
|
|
316
576
|
/**
|
|
317
577
|
* @returns {SeriesDecl}
|
|
318
578
|
*/
|
|
319
|
-
parseSeries() {
|
|
320
|
-
const startToken = this.
|
|
579
|
+
parseSeries(spelledKind = 'series') {
|
|
580
|
+
const startToken = this.expectKindToken(spelledKind);
|
|
321
581
|
const nameToken = this.expect('IDENTIFIER');
|
|
322
582
|
|
|
323
583
|
// Optional "in <AnthologyName>" syntax
|
|
@@ -329,13 +589,20 @@ class Parser {
|
|
|
329
589
|
}
|
|
330
590
|
|
|
331
591
|
const lbrace = this.expect('LBRACE');
|
|
332
|
-
|
|
592
|
+
this.pushAliasScope();
|
|
593
|
+
const body = this.parseBlockBody(
|
|
594
|
+
['book', 'chapter', 'concept', 'describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference'],
|
|
595
|
+
{ allowAliases: true },
|
|
596
|
+
);
|
|
333
597
|
const rbrace = this.expect('RBRACE');
|
|
598
|
+
const aliases = this.popAliasScope();
|
|
334
599
|
|
|
335
600
|
return {
|
|
336
601
|
kind: 'series',
|
|
602
|
+
spelledKind,
|
|
337
603
|
name: nameToken.value,
|
|
338
604
|
parentName,
|
|
605
|
+
aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
|
|
339
606
|
body,
|
|
340
607
|
source: {
|
|
341
608
|
file: this.file,
|
|
@@ -348,8 +615,8 @@ class Parser {
|
|
|
348
615
|
/**
|
|
349
616
|
* @returns {BookDecl}
|
|
350
617
|
*/
|
|
351
|
-
parseBook() {
|
|
352
|
-
const startToken = this.
|
|
618
|
+
parseBook(spelledKind = 'book') {
|
|
619
|
+
const startToken = this.expectKindToken(spelledKind);
|
|
353
620
|
const nameToken = this.expect('IDENTIFIER');
|
|
354
621
|
|
|
355
622
|
// Optional "in <ParentName>" syntax
|
|
@@ -361,13 +628,20 @@ class Parser {
|
|
|
361
628
|
}
|
|
362
629
|
|
|
363
630
|
const lbrace = this.expect('LBRACE');
|
|
364
|
-
|
|
631
|
+
this.pushAliasScope();
|
|
632
|
+
const body = this.parseBlockBody(
|
|
633
|
+
['series', 'chapter', 'concept', 'describe', 'title', 'relationships', 'references', 'ordering', 'documentation', 'repository', 'reference'],
|
|
634
|
+
{ allowAliases: true },
|
|
635
|
+
);
|
|
365
636
|
const rbrace = this.expect('RBRACE');
|
|
637
|
+
const aliases = this.popAliasScope();
|
|
366
638
|
|
|
367
639
|
return {
|
|
368
640
|
kind: 'book',
|
|
641
|
+
spelledKind,
|
|
369
642
|
name: nameToken.value,
|
|
370
643
|
parentName,
|
|
644
|
+
aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
|
|
371
645
|
body,
|
|
372
646
|
source: {
|
|
373
647
|
file: this.file,
|
|
@@ -380,8 +654,8 @@ class Parser {
|
|
|
380
654
|
/**
|
|
381
655
|
* @returns {ChapterDecl}
|
|
382
656
|
*/
|
|
383
|
-
parseChapter() {
|
|
384
|
-
const startToken = this.
|
|
657
|
+
parseChapter(spelledKind = 'chapter') {
|
|
658
|
+
const startToken = this.expectKindToken(spelledKind);
|
|
385
659
|
const nameToken = this.expect('IDENTIFIER');
|
|
386
660
|
|
|
387
661
|
// Chapters must belong to a book - check for "in" keyword
|
|
@@ -396,13 +670,20 @@ class Parser {
|
|
|
396
670
|
this.expect('KEYWORD', 'in');
|
|
397
671
|
const parentToken = this.expect('IDENTIFIER');
|
|
398
672
|
const lbrace = this.expect('LBRACE');
|
|
399
|
-
|
|
673
|
+
this.pushAliasScope();
|
|
674
|
+
const body = this.parseBlockBody(
|
|
675
|
+
['concept', 'describe', 'title', 'references', 'relationships', 'documentation', 'repository', 'reference'],
|
|
676
|
+
{ allowAliases: true },
|
|
677
|
+
);
|
|
400
678
|
const rbrace = this.expect('RBRACE');
|
|
679
|
+
const aliases = this.popAliasScope();
|
|
401
680
|
|
|
402
681
|
return {
|
|
403
682
|
kind: 'chapter',
|
|
683
|
+
spelledKind,
|
|
404
684
|
name: nameToken.value,
|
|
405
685
|
parentName: parentToken.value,
|
|
686
|
+
aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
|
|
406
687
|
body,
|
|
407
688
|
source: {
|
|
408
689
|
file: this.file,
|
|
@@ -415,8 +696,8 @@ class Parser {
|
|
|
415
696
|
/**
|
|
416
697
|
* @returns {ConceptDecl}
|
|
417
698
|
*/
|
|
418
|
-
parseConcept() {
|
|
419
|
-
const startToken = this.
|
|
699
|
+
parseConcept(spelledKind = 'concept') {
|
|
700
|
+
const startToken = this.expectKindToken(spelledKind);
|
|
420
701
|
const nameToken = this.expect('IDENTIFIER');
|
|
421
702
|
|
|
422
703
|
// Optional "in <ParentName>" syntax
|
|
@@ -428,13 +709,20 @@ class Parser {
|
|
|
428
709
|
}
|
|
429
710
|
|
|
430
711
|
const lbrace = this.expect('LBRACE');
|
|
431
|
-
|
|
712
|
+
this.pushAliasScope();
|
|
713
|
+
const body = this.parseBlockBody(
|
|
714
|
+
['describe', 'title', 'references', 'relationships', 'ordering', 'documentation', 'repository', 'reference'],
|
|
715
|
+
{ allowAliases: true },
|
|
716
|
+
);
|
|
432
717
|
const rbrace = this.expect('RBRACE');
|
|
718
|
+
const aliases = this.popAliasScope();
|
|
433
719
|
|
|
434
720
|
return {
|
|
435
721
|
kind: 'concept',
|
|
722
|
+
spelledKind,
|
|
436
723
|
name: nameToken.value,
|
|
437
724
|
parentName,
|
|
725
|
+
aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
|
|
438
726
|
body,
|
|
439
727
|
source: {
|
|
440
728
|
file: this.file,
|
|
@@ -447,19 +735,23 @@ class Parser {
|
|
|
447
735
|
/**
|
|
448
736
|
* @returns {RelatesDecl}
|
|
449
737
|
*/
|
|
450
|
-
parseRelates() {
|
|
451
|
-
const startToken = this.
|
|
738
|
+
parseRelates(spelledKind = 'relates') {
|
|
739
|
+
const startToken = this.expectKindToken(spelledKind);
|
|
452
740
|
const aToken = this.expect('IDENTIFIER');
|
|
453
741
|
this.expect('KEYWORD', 'and');
|
|
454
742
|
const bToken = this.expect('IDENTIFIER');
|
|
455
743
|
const lbrace = this.expect('LBRACE');
|
|
456
|
-
|
|
744
|
+
this.pushAliasScope();
|
|
745
|
+
const body = this.parseBlockBody(['describe', 'title', 'from', 'relationships'], { allowAliases: true });
|
|
457
746
|
const rbrace = this.expect('RBRACE');
|
|
747
|
+
const aliases = this.popAliasScope();
|
|
458
748
|
|
|
459
749
|
return {
|
|
460
750
|
kind: 'relates',
|
|
751
|
+
spelledKind,
|
|
461
752
|
a: aToken.value,
|
|
462
753
|
b: bToken.value,
|
|
754
|
+
aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
|
|
463
755
|
body,
|
|
464
756
|
source: {
|
|
465
757
|
file: this.file,
|
|
@@ -469,6 +761,191 @@ class Parser {
|
|
|
469
761
|
};
|
|
470
762
|
}
|
|
471
763
|
|
|
764
|
+
/**
|
|
765
|
+
* Parses a relationship declaration (symmetric or paired)
|
|
766
|
+
* @returns {RelationshipDecl}
|
|
767
|
+
*/
|
|
768
|
+
parseRelationship(spelledKind = 'relationship') {
|
|
769
|
+
const startToken = this.expectKindToken(spelledKind);
|
|
770
|
+
const firstIdToken = this.expectIdentifierOrKeyword();
|
|
771
|
+
|
|
772
|
+
// Check if this is paired (has "and" after first identifier)
|
|
773
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'and') {
|
|
774
|
+
// Paired relationship: relationship <leftId> and <rightId> { ... }
|
|
775
|
+
return this.parseRelationshipPaired(startToken, firstIdToken, spelledKind);
|
|
776
|
+
} else {
|
|
777
|
+
// Symmetric relationship: relationship <id> { ... }
|
|
778
|
+
return this.parseRelationshipSymmetric(startToken, firstIdToken, spelledKind);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Parses a symmetric relationship declaration
|
|
784
|
+
* @param {Token} startToken
|
|
785
|
+
* @param {Token} idToken
|
|
786
|
+
* @param {string} spelledKind
|
|
787
|
+
* @returns {RelationshipDecl}
|
|
788
|
+
*/
|
|
789
|
+
parseRelationshipSymmetric(startToken, idToken, spelledKind) {
|
|
790
|
+
const lbrace = this.expect('LBRACE');
|
|
791
|
+
let describe = null;
|
|
792
|
+
let label = null;
|
|
793
|
+
|
|
794
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
795
|
+
if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
|
|
796
|
+
const keyword = this.peek()?.value;
|
|
797
|
+
if (keyword === 'describe') {
|
|
798
|
+
if (describe !== null) {
|
|
799
|
+
throw new Error(
|
|
800
|
+
`Multiple describe blocks in relationship "${idToken.value}" at ${this.file}:${startToken.span.start.line}`,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
describe = this.parseDescribe();
|
|
804
|
+
} else if (keyword === 'label') {
|
|
805
|
+
if (label !== null) {
|
|
806
|
+
throw new Error(
|
|
807
|
+
`Multiple label blocks in relationship "${idToken.value}" at ${this.file}:${startToken.span.start.line}`,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
label = this.parseStringBlock('label');
|
|
811
|
+
} else {
|
|
812
|
+
this.advance();
|
|
813
|
+
}
|
|
814
|
+
} else {
|
|
815
|
+
this.advance();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const rbrace = this.expect('RBRACE');
|
|
820
|
+
|
|
821
|
+
const relationshipDecl = {
|
|
822
|
+
kind: 'relationship',
|
|
823
|
+
spelledKind,
|
|
824
|
+
type: 'symmetric',
|
|
825
|
+
id: idToken.value,
|
|
826
|
+
describe: describe || undefined,
|
|
827
|
+
label: label || undefined,
|
|
828
|
+
source: {
|
|
829
|
+
file: this.file,
|
|
830
|
+
start: startToken.span.start,
|
|
831
|
+
end: rbrace.span.end,
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
// Declare the relationship in current scope
|
|
836
|
+
this.declareRelationship(relationshipDecl);
|
|
837
|
+
|
|
838
|
+
return relationshipDecl;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Parses a paired (directional) relationship declaration
|
|
843
|
+
* @param {Token} startToken
|
|
844
|
+
* @param {Token} leftIdToken
|
|
845
|
+
* @param {string} spelledKind
|
|
846
|
+
* @returns {RelationshipDecl}
|
|
847
|
+
*/
|
|
848
|
+
parseRelationshipPaired(startToken, leftIdToken, spelledKind) {
|
|
849
|
+
this.expect('KEYWORD', 'and');
|
|
850
|
+
const rightIdToken = this.expectIdentifierOrKeyword();
|
|
851
|
+
const lbrace = this.expect('LBRACE');
|
|
852
|
+
|
|
853
|
+
let pairDescribe = null;
|
|
854
|
+
const fromBlocks = {};
|
|
855
|
+
|
|
856
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
857
|
+
if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
|
|
858
|
+
const keyword = this.peek()?.value;
|
|
859
|
+
if (keyword === 'describe') {
|
|
860
|
+
if (pairDescribe !== null) {
|
|
861
|
+
throw new Error(
|
|
862
|
+
`Multiple describe blocks in relationship "${leftIdToken.value} and ${rightIdToken.value}" at ${this.file}:${startToken.span.start.line}`,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
pairDescribe = this.parseDescribe();
|
|
866
|
+
} else if (keyword === 'from') {
|
|
867
|
+
const fromStartToken = this.advance();
|
|
868
|
+
const endpointToken = this.expect('IDENTIFIER');
|
|
869
|
+
|
|
870
|
+
// Validate: from block must reference leftId or rightId
|
|
871
|
+
if (endpointToken.value !== leftIdToken.value && endpointToken.value !== rightIdToken.value) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`from block references "${endpointToken.value}" which is not declared in relationship "${leftIdToken.value} and ${rightIdToken.value}" at ${this.file}:${endpointToken.span.start.line}`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const fromLbrace = this.expect('LBRACE');
|
|
878
|
+
let fromLabel = null;
|
|
879
|
+
let fromDescribe = null;
|
|
880
|
+
|
|
881
|
+
// Parse from block body (label and describe)
|
|
882
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
883
|
+
if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
|
|
884
|
+
const fromKeyword = this.peek()?.value;
|
|
885
|
+
if (fromKeyword === 'label') {
|
|
886
|
+
if (fromLabel !== null) {
|
|
887
|
+
throw new Error(
|
|
888
|
+
`Multiple label blocks in from "${endpointToken.value}" at ${this.file}:${endpointToken.span.start.line}`,
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
fromLabel = this.parseStringBlock('label');
|
|
892
|
+
} else if (fromKeyword === 'describe') {
|
|
893
|
+
if (fromDescribe !== null) {
|
|
894
|
+
throw new Error(
|
|
895
|
+
`Multiple describe blocks in from "${endpointToken.value}" at ${this.file}:${endpointToken.span.start.line}`,
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
fromDescribe = this.parseDescribe();
|
|
899
|
+
} else {
|
|
900
|
+
this.advance();
|
|
901
|
+
}
|
|
902
|
+
} else {
|
|
903
|
+
this.advance();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const fromRbrace = this.expect('RBRACE');
|
|
908
|
+
|
|
909
|
+
const fromSide = {};
|
|
910
|
+
if (fromLabel !== null) {
|
|
911
|
+
fromSide.label = fromLabel;
|
|
912
|
+
}
|
|
913
|
+
if (fromDescribe !== null) {
|
|
914
|
+
fromSide.describe = fromDescribe;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
fromBlocks[endpointToken.value] = fromSide;
|
|
918
|
+
} else {
|
|
919
|
+
this.advance();
|
|
920
|
+
}
|
|
921
|
+
} else {
|
|
922
|
+
this.advance();
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const rbrace = this.expect('RBRACE');
|
|
927
|
+
|
|
928
|
+
const relationshipDecl = {
|
|
929
|
+
kind: 'relationship',
|
|
930
|
+
spelledKind,
|
|
931
|
+
type: 'paired',
|
|
932
|
+
leftId: leftIdToken.value,
|
|
933
|
+
rightId: rightIdToken.value,
|
|
934
|
+
describe: pairDescribe || undefined,
|
|
935
|
+
from: Object.keys(fromBlocks).length > 0 ? fromBlocks : undefined,
|
|
936
|
+
source: {
|
|
937
|
+
file: this.file,
|
|
938
|
+
start: startToken.span.start,
|
|
939
|
+
end: rbrace.span.end,
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
// Declare the relationship in current scope (both identifiers)
|
|
944
|
+
this.declareRelationship(relationshipDecl);
|
|
945
|
+
|
|
946
|
+
return relationshipDecl;
|
|
947
|
+
}
|
|
948
|
+
|
|
472
949
|
/**
|
|
473
950
|
* @returns {FromBlock}
|
|
474
951
|
*/
|
|
@@ -492,11 +969,54 @@ class Parser {
|
|
|
492
969
|
}
|
|
493
970
|
|
|
494
971
|
/**
|
|
972
|
+
* Parses a relationships block
|
|
973
|
+
* Supports both legacy syntax (string literals for relates blocks) and new syntax (relationship IDs + targets)
|
|
495
974
|
* @returns {RelationshipsBlock}
|
|
496
975
|
*/
|
|
497
976
|
parseRelationships() {
|
|
498
977
|
const startToken = this.expect('KEYWORD', 'relationships');
|
|
499
978
|
const lbrace = this.expect('LBRACE');
|
|
979
|
+
const startOffset = lbrace.span.end.offset;
|
|
980
|
+
|
|
981
|
+
// Try to detect which syntax we're using
|
|
982
|
+
// If first token after brace is a STRING, use legacy syntax
|
|
983
|
+
// Otherwise, use new syntax (relationship ID + targets)
|
|
984
|
+
|
|
985
|
+
// Peek ahead to see what comes next
|
|
986
|
+
let peekPos = this.pos;
|
|
987
|
+
let foundString = false;
|
|
988
|
+
let foundIdentifier = false;
|
|
989
|
+
|
|
990
|
+
while (peekPos < this.tokens.length && peekPos < this.pos + 10) {
|
|
991
|
+
const token = this.tokens[peekPos];
|
|
992
|
+
if (token.type === 'RBRACE' || token.type === 'EOF') break;
|
|
993
|
+
if (token.type === 'STRING') {
|
|
994
|
+
foundString = true;
|
|
995
|
+
break;
|
|
996
|
+
}
|
|
997
|
+
if (token.type === 'IDENTIFIER' || token.type === 'KEYWORD') {
|
|
998
|
+
foundIdentifier = true;
|
|
999
|
+
break;
|
|
1000
|
+
}
|
|
1001
|
+
peekPos++;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// If we found a string first, use legacy syntax (for relates blocks)
|
|
1005
|
+
if (foundString && !foundIdentifier) {
|
|
1006
|
+
return this.parseRelationshipsLegacy(startToken, lbrace);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Otherwise, use new syntax (relationship ID + targets)
|
|
1010
|
+
return this.parseRelationshipsNew(startToken, lbrace);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Parses legacy relationships block (string literals for relates blocks)
|
|
1015
|
+
* @param {Token} startToken
|
|
1016
|
+
* @param {Token} lbrace
|
|
1017
|
+
* @returns {RelationshipsBlock}
|
|
1018
|
+
*/
|
|
1019
|
+
parseRelationshipsLegacy(startToken, lbrace) {
|
|
500
1020
|
const values = [];
|
|
501
1021
|
let relationshipsSource = {
|
|
502
1022
|
file: this.file,
|
|
@@ -530,6 +1050,89 @@ class Parser {
|
|
|
530
1050
|
};
|
|
531
1051
|
}
|
|
532
1052
|
|
|
1053
|
+
/**
|
|
1054
|
+
* Parses new relationships block syntax (relationship ID + target identifiers)
|
|
1055
|
+
* @param {Token} startToken
|
|
1056
|
+
* @param {Token} lbrace
|
|
1057
|
+
* @returns {RelationshipsBlock}
|
|
1058
|
+
*/
|
|
1059
|
+
parseRelationshipsNew(startToken, lbrace) {
|
|
1060
|
+
const entries = [];
|
|
1061
|
+
let relationshipsSource = {
|
|
1062
|
+
file: this.file,
|
|
1063
|
+
start: lbrace.span.end,
|
|
1064
|
+
end: lbrace.span.end,
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
1068
|
+
// Parse relationship ID (identifier or keyword)
|
|
1069
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
1070
|
+
const relationshipIdToken = this.advance();
|
|
1071
|
+
const relationshipId = relationshipIdToken.value;
|
|
1072
|
+
|
|
1073
|
+
// Expect opening brace for targets
|
|
1074
|
+
if (!this.match('LBRACE')) {
|
|
1075
|
+
throw new Error(
|
|
1076
|
+
`Expected '{' after relationship identifier "${relationshipId}" at ${this.file}:${relationshipIdToken.span.start.line}`,
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
const targetLbrace = this.advance();
|
|
1080
|
+
|
|
1081
|
+
const targets = [];
|
|
1082
|
+
|
|
1083
|
+
// Parse targets until closing brace
|
|
1084
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
1085
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
1086
|
+
const targetIdToken = this.advance();
|
|
1087
|
+
const targetId = targetIdToken.value;
|
|
1088
|
+
|
|
1089
|
+
// Check if this target has a metadata block
|
|
1090
|
+
let metadata = null;
|
|
1091
|
+
if (this.match('LBRACE')) {
|
|
1092
|
+
// Parse metadata block (describe)
|
|
1093
|
+
const metadataLbrace = this.advance();
|
|
1094
|
+
const metadataBody = this.parseBlockBody(['describe'], {});
|
|
1095
|
+
const metadataRbrace = this.expect('RBRACE');
|
|
1096
|
+
|
|
1097
|
+
const describeBlock = metadataBody.find((b) => b.kind === 'describe');
|
|
1098
|
+
if (describeBlock) {
|
|
1099
|
+
metadata = describeBlock;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
targets.push({
|
|
1104
|
+
id: targetId,
|
|
1105
|
+
metadata: metadata || undefined,
|
|
1106
|
+
});
|
|
1107
|
+
} else {
|
|
1108
|
+
// Skip other tokens
|
|
1109
|
+
this.advance();
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const targetRbrace = this.expect('RBRACE');
|
|
1114
|
+
relationshipsSource.end = targetRbrace.span.end;
|
|
1115
|
+
|
|
1116
|
+
entries.push({
|
|
1117
|
+
relationshipId,
|
|
1118
|
+
targets,
|
|
1119
|
+
});
|
|
1120
|
+
} else {
|
|
1121
|
+
// Skip other tokens
|
|
1122
|
+
this.advance();
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const rbrace = this.expect('RBRACE');
|
|
1127
|
+
relationshipsSource.end = rbrace.span.start;
|
|
1128
|
+
|
|
1129
|
+
return {
|
|
1130
|
+
kind: 'relationships',
|
|
1131
|
+
entries,
|
|
1132
|
+
source: relationshipsSource,
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
533
1136
|
/**
|
|
534
1137
|
* Parses a describe block, consuming raw text until matching closing brace
|
|
535
1138
|
* Treats braces inside as plain text (doesn't parse nested structures)
|
|
@@ -775,8 +1378,8 @@ class Parser {
|
|
|
775
1378
|
* Parses a reference declaration: reference <Name> [in <RepoName>] { ... }
|
|
776
1379
|
* @returns {ReferenceDecl}
|
|
777
1380
|
*/
|
|
778
|
-
parseReferenceDecl() {
|
|
779
|
-
const startToken = this.
|
|
1381
|
+
parseReferenceDecl(spelledKind = 'reference') {
|
|
1382
|
+
const startToken = this.expectKindToken(spelledKind);
|
|
780
1383
|
let nameToken = null;
|
|
781
1384
|
if (this.match('IDENTIFIER')) {
|
|
782
1385
|
nameToken = this.expect('IDENTIFIER');
|
|
@@ -1000,8 +1603,8 @@ class Parser {
|
|
|
1000
1603
|
* Parses a repository block: repository <Identifier> { url { ... } ... }
|
|
1001
1604
|
* @returns {RepositoryDecl}
|
|
1002
1605
|
*/
|
|
1003
|
-
parseRepository() {
|
|
1004
|
-
const startToken = this.
|
|
1606
|
+
parseRepository(spelledKind = 'repository') {
|
|
1607
|
+
const startToken = this.expectKindToken(spelledKind);
|
|
1005
1608
|
const nameToken = this.expect('IDENTIFIER');
|
|
1006
1609
|
let parentName = undefined;
|
|
1007
1610
|
|