@sprig-and-prose/sprig-universe 0.4.1 → 0.4.2

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,1751 @@
1
+ /**
2
+ * @fileoverview Declarative parselet-based parser for Sprig universe syntax
3
+ * Syntax-only parsing with no semantic validation
4
+ */
5
+
6
+ /**
7
+ * @typedef {import('../ast.js').UniverseDecl} UniverseDecl
8
+ * @typedef {import('../ast.js').AnthologyDecl} AnthologyDecl
9
+ * @typedef {import('../ast.js').SeriesDecl} SeriesDecl
10
+ * @typedef {import('../ast.js').BookDecl} BookDecl
11
+ * @typedef {import('../ast.js').ChapterDecl} ChapterDecl
12
+ * @typedef {import('../ast.js').ConceptDecl} ConceptDecl
13
+ * @typedef {import('../ast.js').RelatesDecl} RelatesDecl
14
+ * @typedef {import('./scanner.js').Token} Token
15
+ * @typedef {import('../ir.js').Diagnostic} Diagnostic
16
+ * @typedef {import('../ast.js').SourceSpan} SourceSpan
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} FileAST
21
+ * @property {string} kind - Always 'file'
22
+ * @property {any[]} decls - Top-level declarations
23
+ * @property {SourceSpan} [source] - File-level source span
24
+ */
25
+
26
+ /**
27
+ * @typedef {Object} DescribeBlock
28
+ * @property {string} kind - Always 'describe'
29
+ * @property {{ startOffset: number, endOffset: number }} contentSpan - Content span (inside braces)
30
+ * @property {SourceSpan} span - Full span including braces
31
+ */
32
+
33
+ /**
34
+ * @typedef {Object} TitleBlock
35
+ * @property {string} kind - Always 'title'
36
+ * @property {{ startOffset: number, endOffset: number }} contentSpan - Content span (inside braces)
37
+ * @property {SourceSpan} span - Full span including braces
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} NoteBlock
42
+ * @property {string} kind - Always 'note'
43
+ * @property {{ startOffset: number, endOffset: number }} contentSpan - Content span (inside braces)
44
+ * @property {SourceSpan} span - Full span including braces
45
+ */
46
+
47
+ /**
48
+ * @typedef {Object} RelUseBlock
49
+ * @property {string} kind - Always 'relUse'
50
+ * @property {string} name - Relationship identifier
51
+ * @property {RelItem[]} items - List of relationship items
52
+ * @property {SourceSpan} span - Source span
53
+ */
54
+
55
+ /**
56
+ * @typedef {Object} RelItem
57
+ * @property {string} kind - 'ref' or 'string'
58
+ * @property {string} [ref] - Reference path (dot-separated identifiers) for 'ref' items
59
+ * @property {string} [value] - String value for 'string' items
60
+ * @property {any[]} [body] - Optional inline body (for 'ref' items)
61
+ * @property {SourceSpan} span - Source span
62
+ */
63
+
64
+ /**
65
+ * @typedef {Object} RelationshipDecl
66
+ * @property {string} kind - Always 'relationshipDecl'
67
+ * @property {string[]} ids - Relationship identifier(s) [id1, id2?]
68
+ * @property {any[]} body - Body declarations
69
+ * @property {SourceSpan} span - Source span
70
+ */
71
+
72
+ /**
73
+ * Core parser class with low-level token navigation utilities
74
+ */
75
+ class ParserCore {
76
+ /**
77
+ * @param {Token[]} tokens
78
+ * @param {string} filePath
79
+ * @param {string} sourceText
80
+ */
81
+ constructor(tokens, filePath, sourceText) {
82
+ this.tokens = tokens;
83
+ this.filePath = filePath;
84
+ this.sourceText = sourceText;
85
+ this.index = 0;
86
+ this.diagnostics = [];
87
+ }
88
+
89
+ /**
90
+ * @returns {Token | null}
91
+ */
92
+ peek() {
93
+ if (this.index >= this.tokens.length) {
94
+ return null;
95
+ }
96
+ return this.tokens[this.index];
97
+ }
98
+
99
+ /**
100
+ * @returns {Token | null}
101
+ */
102
+ previous() {
103
+ if (this.index === 0) {
104
+ return null;
105
+ }
106
+ return this.tokens[this.index - 1];
107
+ }
108
+
109
+ /**
110
+ * @returns {Token | null}
111
+ */
112
+ advance() {
113
+ if (this.index >= this.tokens.length) {
114
+ return null;
115
+ }
116
+ return this.tokens[this.index++];
117
+ }
118
+
119
+ /**
120
+ * @returns {boolean}
121
+ */
122
+ isAtEnd() {
123
+ const token = this.peek();
124
+ return token === null || token.type === 'EOF';
125
+ }
126
+
127
+ /**
128
+ * @param {string} type
129
+ * @param {string} [value]
130
+ * @returns {boolean}
131
+ */
132
+ match(type, value) {
133
+ const token = this.peek();
134
+ if (!token || token.type !== type) {
135
+ return false;
136
+ }
137
+ if (value !== undefined && token.value !== value) {
138
+ return false;
139
+ }
140
+ return true;
141
+ }
142
+
143
+ /**
144
+ * @param {string} type
145
+ * @param {string} [value]
146
+ * @returns {{ token: Token | null, diagnostic: Diagnostic | null }}
147
+ */
148
+ expect(type, value) {
149
+ const token = this.peek();
150
+ if (!token || token.type !== type) {
151
+ const diagnostic = {
152
+ severity: 'error',
153
+ message: `Expected ${type}${value !== undefined ? ` with value "${value}"` : ''}, got ${token ? token.type : 'EOF'}`,
154
+ source: token ? token.span : undefined,
155
+ };
156
+ this.diagnostics.push(diagnostic);
157
+ return { token: null, diagnostic };
158
+ }
159
+ if (value !== undefined && token.value !== value) {
160
+ const diagnostic = {
161
+ severity: 'error',
162
+ message: `Expected ${type} with value "${value}", got "${token.value}"`,
163
+ source: token.span,
164
+ };
165
+ this.diagnostics.push(diagnostic);
166
+ return { token: null, diagnostic };
167
+ }
168
+ // Success: advance and return token
169
+ this.advance();
170
+ return { token, diagnostic: null };
171
+ }
172
+
173
+ /**
174
+ * @param {string} [value]
175
+ * @returns {{ token: Token | null, diagnostic: Diagnostic | null }}
176
+ */
177
+ expectIdentifierOrKeyword(value) {
178
+ const token = this.peek();
179
+ if (!token || (token.type !== 'KEYWORD' && token.type !== 'IDENTIFIER')) {
180
+ const diagnostic = {
181
+ severity: 'error',
182
+ message: `Expected identifier or keyword${value !== undefined ? ` "${value}"` : ''}, got ${token ? token.type : 'EOF'}`,
183
+ source: token ? token.span : undefined,
184
+ };
185
+ this.diagnostics.push(diagnostic);
186
+ return { token: null, diagnostic };
187
+ }
188
+ if (value !== undefined && token.value !== value) {
189
+ const diagnostic = {
190
+ severity: 'error',
191
+ message: `Expected identifier or keyword "${value}", got "${token.value}"`,
192
+ source: token.span,
193
+ };
194
+ this.diagnostics.push(diagnostic);
195
+ return { token: null, diagnostic };
196
+ }
197
+ // Success: advance and return token
198
+ this.advance();
199
+ return { token, diagnostic: null };
200
+ }
201
+
202
+ /**
203
+ * @param {string} value
204
+ * @returns {{ token: Token | null, diagnostic: Diagnostic | null }}
205
+ */
206
+ expectKindToken(value) {
207
+ return this.expectIdentifierOrKeyword(value);
208
+ }
209
+
210
+ /**
211
+ * Reads an identifier name (IDENTIFIER or KEYWORD)
212
+ * @returns {string | null}
213
+ */
214
+ readIdent() {
215
+ const token = this.peek();
216
+ if (!token || (token.type !== 'IDENTIFIER' && token.type !== 'KEYWORD')) {
217
+ return null;
218
+ }
219
+ this.advance();
220
+ return token.value;
221
+ }
222
+
223
+ /**
224
+ * Consumes an identifier, reporting diagnostic if missing
225
+ * @returns {string | null}
226
+ */
227
+ consumeIdentifier() {
228
+ const { token, diagnostic } = this.expectIdentifierOrKeyword();
229
+ return token ? token.value : null;
230
+ }
231
+
232
+ /**
233
+ * Parses an identifier path (IDENTIFIER (DOT IDENTIFIER)*)
234
+ * @returns {string | null}
235
+ */
236
+ parseIdentifierPath() {
237
+ if (!this.match('IDENTIFIER') && !this.match('KEYWORD')) {
238
+ return null;
239
+ }
240
+
241
+ const parts = [];
242
+ const firstToken = this.advance();
243
+ if (firstToken) {
244
+ parts.push(firstToken.value);
245
+ }
246
+
247
+ while (this.match('DOT')) {
248
+ this.advance(); // consume DOT
249
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
250
+ const partToken = this.advance();
251
+ if (partToken) {
252
+ parts.push(partToken.value);
253
+ }
254
+ } else {
255
+ break;
256
+ }
257
+ }
258
+
259
+ return parts.join('.');
260
+ }
261
+
262
+ /**
263
+ * Reports a diagnostic
264
+ * @param {'error' | 'warning'} severity
265
+ * @param {string} message
266
+ * @param {SourceSpan} [span]
267
+ */
268
+ reportDiagnostic(severity, message, span) {
269
+ this.diagnostics.push({
270
+ severity,
271
+ message,
272
+ source: span,
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Creates a span from start and end tokens
278
+ * @param {Token} startToken
279
+ * @param {Token} endToken
280
+ * @returns {SourceSpan}
281
+ */
282
+ createSpan(startToken, endToken) {
283
+ return {
284
+ file: this.filePath,
285
+ start: startToken.span.start,
286
+ end: endToken.span.end,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Creates a span from a single token
292
+ * @param {Token} token
293
+ * @returns {SourceSpan}
294
+ */
295
+ spanFromToken(token) {
296
+ return token.span;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Parses tokens into an AST with diagnostics
302
+ * @param {{ tokens: Token[], sourceText: string, filePath: string }} options
303
+ * @returns {{ ast: FileAST, diags: Diagnostic[] }}
304
+ */
305
+ export function parseUniverse({ tokens, sourceText, filePath }) {
306
+ const parser = new ParserCore(tokens, filePath, sourceText);
307
+ const ast = parser.parseFile();
308
+ return {
309
+ ast,
310
+ diags: parser.diagnostics,
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Declaration parselets: keyword → parse function
316
+ * @type {Map<string, (p: ParserCore) => any>}
317
+ */
318
+ const declParselets = new Map([
319
+ ['universe', (p) => p.parseUniverseDecl()],
320
+ ['anthology', (p) => p.parseContainerDecl('anthology')],
321
+ ['series', (p) => p.parseContainerDecl('series')],
322
+ ['book', (p) => p.parseContainerDecl('book')],
323
+ ['chapter', (p) => p.parseContainerDecl('chapter')],
324
+ ['concept', (p) => p.parseContainerDecl('concept')],
325
+ ['relates', (p) => p.parseRelatesDecl()],
326
+ ['relationship', (p) => p.parseRelationshipDecl()],
327
+ ['alias', (p) => p.parseAliasDecl()],
328
+ ['aliases', (p) => p.parseAliasesBlock()],
329
+ ['repository', (p) => p.parseRepositoryDecl()],
330
+ ['reference', (p) => p.parseNamedReferenceDecl()],
331
+ ]);
332
+
333
+ /**
334
+ * Block parselets: keyword → parse function
335
+ * @type {Map<string, (p: ParserCore) => any>}
336
+ */
337
+ const blockParselets = new Map([
338
+ ['describe', (p) => p.parseDescribeBlock()],
339
+ ['title', (p) => p.parseTitleBlock()],
340
+ ['note', (p) => p.parseNoteBlock()],
341
+ ['from', (p) => p.parseFromBlock()],
342
+ ['label', (p) => p.parseLabelBlock()],
343
+ ['reference', (p) => p.parseReferenceBlock()],
344
+ ['references', (p) => p.parseReferencesBlock()],
345
+ ['relationships', (p) => p.parseRelationshipsBlock()],
346
+ ]);
347
+
348
+ /**
349
+ * Parses a file (top-level declarations)
350
+ * @returns {FileAST}
351
+ */
352
+ ParserCore.prototype.parseFile = function () {
353
+ const decls = [];
354
+ const startToken = this.peek();
355
+
356
+ while (!this.isAtEnd()) {
357
+ const token = this.peek();
358
+ if (!token) break;
359
+
360
+ if (token.type === 'KEYWORD') {
361
+ const keyword = token.value;
362
+ const parselet = declParselets.get(keyword);
363
+ if (parselet) {
364
+ const decl = parselet(this);
365
+ if (decl) {
366
+ decls.push(decl);
367
+ }
368
+ } else {
369
+ // Try aliased container declaration before reporting error
370
+ const aliasedDecl = this.parseAliasedContainerDeclIfPresent();
371
+ if (aliasedDecl) {
372
+ decls.push(aliasedDecl);
373
+ } else {
374
+ // Unknown keyword - report diagnostic and skip
375
+ this.reportDiagnostic('error', `Unexpected keyword "${keyword}" at top level`, token.span);
376
+ this.advance();
377
+ }
378
+ }
379
+ } else if (token.type === 'IDENTIFIER') {
380
+ // Handle 'repository' and 'reference' even if they're IDENTIFIER tokens (not keywords)
381
+ const identifierValue = token.value;
382
+ if (identifierValue === 'repository') {
383
+ const parselet = declParselets.get('repository');
384
+ if (parselet) {
385
+ const decl = parselet(this);
386
+ if (decl) {
387
+ decls.push(decl);
388
+ }
389
+ }
390
+ continue;
391
+ }
392
+ if (identifierValue === 'reference') {
393
+ const parselet = declParselets.get('reference');
394
+ if (parselet) {
395
+ const decl = parselet(this);
396
+ if (decl) {
397
+ decls.push(decl);
398
+ }
399
+ }
400
+ continue;
401
+ }
402
+ // Try aliased container declaration before reporting error
403
+ const aliasedDecl = this.parseAliasedContainerDeclIfPresent();
404
+ if (aliasedDecl) {
405
+ decls.push(aliasedDecl);
406
+ } else {
407
+ // IDENTIFIER at top level - report and skip
408
+ this.reportDiagnostic('error', `Unexpected identifier "${token.value}" at top level`, token.span);
409
+ this.advance();
410
+ }
411
+ } else if (token.type === 'EOF') {
412
+ break;
413
+ } else {
414
+ // Unexpected token - skip (ensure we advance to avoid infinite loops)
415
+ this.advance();
416
+ }
417
+ }
418
+
419
+ const endToken = this.previous() || startToken;
420
+ return {
421
+ kind: 'file',
422
+ decls,
423
+ source: startToken && endToken ? this.createSpan(startToken, endToken) : undefined,
424
+ };
425
+ };
426
+
427
+ /**
428
+ * Parses a body until closing brace
429
+ * @returns {any[]}
430
+ */
431
+ ParserCore.prototype.parseBodyUntilRBrace = function () {
432
+ const body = [];
433
+
434
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
435
+ const token = this.peek();
436
+ if (!token) break;
437
+
438
+ if (token.type === 'KEYWORD') {
439
+ const keyword = token.value;
440
+ // Special handling for 'reference' in body context
441
+ // Check if it's a named reference decl (reference <Name> {) or a block (reference [in X] {)
442
+ if (keyword === 'reference') {
443
+ // Peek ahead to see if next token is a named decl, including optional "in <Repo>"
444
+ const nextToken = this.tokens[this.index + 1];
445
+ const tokenAfterNext = this.tokens[this.index + 2];
446
+ const tokenAfterIn = this.tokens[this.index + 3];
447
+ const tokenAfterInLbrace = this.tokens[this.index + 4];
448
+ const hasNameToken = nextToken && (nextToken.type === 'IDENTIFIER' || nextToken.type === 'KEYWORD');
449
+ const isNamedDeclDirect = hasNameToken && tokenAfterNext && tokenAfterNext.type === 'LBRACE';
450
+ const isNamedDeclWithRepo = hasNameToken &&
451
+ tokenAfterNext && tokenAfterNext.type === 'KEYWORD' && tokenAfterNext.value === 'in' &&
452
+ tokenAfterIn && (tokenAfterIn.type === 'IDENTIFIER' || tokenAfterIn.type === 'KEYWORD') &&
453
+ tokenAfterInLbrace && tokenAfterInLbrace.type === 'LBRACE';
454
+ // If next is IDENTIFIER/KEYWORD and then LBRACE (or "in" + Repo + LBRACE), it's a named decl
455
+ if (isNamedDeclDirect || isNamedDeclWithRepo) {
456
+ // Parse as named reference declaration
457
+ const declParselet = declParselets.get('reference');
458
+ if (declParselet) {
459
+ const decl = declParselet(this);
460
+ if (decl) {
461
+ body.push(decl);
462
+ }
463
+ continue;
464
+ }
465
+ }
466
+ // Otherwise parse as reference block
467
+ const blockParselet = blockParselets.get('reference');
468
+ if (blockParselet) {
469
+ const block = blockParselet(this);
470
+ if (block) {
471
+ body.push(block);
472
+ }
473
+ continue;
474
+ }
475
+ // If both failed, report error and skip
476
+ this.reportDiagnostic('error', `Unexpected reference keyword in body`, token.span);
477
+ this.advance();
478
+ continue;
479
+ }
480
+ // Check declaration parselets first (for nested decls)
481
+ const declParselet = declParselets.get(keyword);
482
+ if (declParselet) {
483
+ const decl = declParselet(this);
484
+ if (decl) {
485
+ body.push(decl);
486
+ }
487
+ continue;
488
+ }
489
+ // Check block parselets
490
+ const blockParselet = blockParselets.get(keyword);
491
+ if (blockParselet) {
492
+ const block = blockParselet(this);
493
+ if (block) {
494
+ body.push(block);
495
+ }
496
+ continue;
497
+ }
498
+ // Try aliased container declaration
499
+ const aliasedDecl = this.parseAliasedContainerDeclIfPresent();
500
+ if (aliasedDecl) {
501
+ body.push(aliasedDecl);
502
+ continue;
503
+ }
504
+ // No implicit relationship usage blocks; require explicit relationships { ... }
505
+ // Unknown keyword - report and skip
506
+ this.reportDiagnostic('error', `Unexpected keyword "${keyword}" in body`, token.span);
507
+ this.advance();
508
+ } else if (token.type === 'IDENTIFIER') {
509
+ // Handle 'repository' and 'reference' even if they're IDENTIFIER tokens
510
+ const identifierValue = token.value;
511
+ if (identifierValue === 'repository') {
512
+ const parselet = declParselets.get('repository');
513
+ if (parselet) {
514
+ const decl = parselet(this);
515
+ if (decl) {
516
+ body.push(decl);
517
+ }
518
+ }
519
+ continue;
520
+ }
521
+ if (identifierValue === 'reference') {
522
+ // In body context, 'reference' should be a block, not a named decl
523
+ const blockParselet = blockParselets.get('reference');
524
+ if (blockParselet) {
525
+ const block = blockParselet(this);
526
+ if (block) {
527
+ body.push(block);
528
+ }
529
+ }
530
+ continue;
531
+ }
532
+ // Try aliased container declaration first
533
+ const aliasedDecl = this.parseAliasedContainerDeclIfPresent();
534
+ if (aliasedDecl) {
535
+ body.push(aliasedDecl);
536
+ continue;
537
+ }
538
+ // No implicit relationship usage blocks; require explicit relationships { ... }
539
+ // Avoid stalling on bare identifiers.
540
+ this.reportDiagnostic('error', `Unexpected identifier "${identifierValue}" in body`, token.span);
541
+ this.advance();
542
+ } else if (token.type === 'RBRACE') {
543
+ break;
544
+ } else {
545
+ // Unexpected token - report and skip (ensure we advance to avoid infinite loops)
546
+ this.reportDiagnostic('error', `Unexpected token ${token.type} in body`, token.span);
547
+ this.advance();
548
+ }
549
+ }
550
+
551
+ return body;
552
+ };
553
+
554
+ /**
555
+ * Parses raw content block by brace matching (returns spans only, no raw string)
556
+ * @param {string} kind - Block kind ('describe', 'title', 'note')
557
+ * @param {Token} keywordToken - The keyword token
558
+ * @returns {{ kind: string, contentSpan: { startOffset: number, endOffset: number }, span: SourceSpan } | null}
559
+ */
560
+ ParserCore.prototype.parseRawContentBlock = function (kind, keywordToken) {
561
+ const { token: lbrace, diagnostic } = this.expect('LBRACE');
562
+ if (!lbrace) {
563
+ return null;
564
+ }
565
+
566
+ // Find matching closing brace by tracking depth
567
+ let depth = 1;
568
+ const startOffset = lbrace.span.end.offset;
569
+ let endOffset = startOffset;
570
+ let endToken = null;
571
+
572
+ while (depth > 0 && this.index < this.tokens.length) {
573
+ const token = this.tokens[this.index];
574
+ if (token.type === 'EOF') break;
575
+
576
+ if (token.type === 'LBRACE') {
577
+ depth++;
578
+ this.index++;
579
+ } else if (token.type === 'RBRACE') {
580
+ depth--;
581
+ if (depth === 0) {
582
+ endToken = token;
583
+ endOffset = token.span.start.offset;
584
+ this.index++;
585
+ break;
586
+ } else {
587
+ this.index++;
588
+ }
589
+ } else {
590
+ this.index++;
591
+ }
592
+ }
593
+
594
+ if (depth > 0) {
595
+ this.reportDiagnostic('error', `Unclosed ${kind} block`, keywordToken.span);
596
+ return null;
597
+ }
598
+
599
+ return {
600
+ kind,
601
+ contentSpan: {
602
+ startOffset,
603
+ endOffset,
604
+ },
605
+ span: this.createSpan(keywordToken, endToken || lbrace),
606
+ };
607
+ };
608
+
609
+ // ============================================================================
610
+ // Parselet Implementations
611
+ // ============================================================================
612
+
613
+ /**
614
+ * Parses a universe declaration
615
+ * @returns {UniverseDecl | null}
616
+ */
617
+ ParserCore.prototype.parseUniverseDecl = function () {
618
+ const startToken = this.peek();
619
+ if (!startToken) return null;
620
+
621
+ const { token: kindToken } = this.expectKindToken('universe');
622
+ if (!kindToken) return null;
623
+
624
+ const name = this.consumeIdentifier();
625
+ if (!name) return null;
626
+
627
+ const { token: lbrace } = this.expect('LBRACE');
628
+ if (!lbrace) return null;
629
+
630
+ const body = this.parseBodyUntilRBrace();
631
+
632
+ const { token: rbrace } = this.expect('RBRACE');
633
+ if (!rbrace) return null;
634
+
635
+ return {
636
+ kind: 'universe',
637
+ spelledKind: kindToken.value,
638
+ name,
639
+ body,
640
+ source: this.createSpan(kindToken, rbrace),
641
+ };
642
+ };
643
+
644
+ /**
645
+ * Parses container declarations (anthology, series, book, chapter, concept)
646
+ * @param {string} kind
647
+ * @returns {any | null}
648
+ */
649
+ ParserCore.prototype.parseContainerDecl = function (kind) {
650
+ const startToken = this.peek();
651
+ if (!startToken) return null;
652
+
653
+ const { token: kindToken } = this.expectKindToken(kind);
654
+ if (!kindToken) return null;
655
+
656
+ const name = this.consumeIdentifier();
657
+ if (!name) return null;
658
+
659
+ // Parse optional "in ParentName" clause
660
+ let parentName = undefined;
661
+ if (this.match('KEYWORD', 'in')) {
662
+ this.advance(); // consume 'in'
663
+ const parentToken = this.peek();
664
+ if (parentToken && (parentToken.type === 'IDENTIFIER' || parentToken.type === 'KEYWORD')) {
665
+ parentName = this.parseIdentifierPath();
666
+ }
667
+ }
668
+
669
+ const { token: lbrace } = this.expect('LBRACE');
670
+ if (!lbrace) return null;
671
+
672
+ const body = this.parseBodyUntilRBrace();
673
+
674
+ const { token: rbrace } = this.expect('RBRACE');
675
+ if (!rbrace) return null;
676
+
677
+ return {
678
+ kind,
679
+ spelledKind: kindToken.value,
680
+ name,
681
+ parentName,
682
+ body,
683
+ source: this.createSpan(kindToken, rbrace),
684
+ };
685
+ };
686
+
687
+ /**
688
+ * Parses relates declarations: relates A and B { ... }
689
+ * @returns {RelatesDecl | null}
690
+ */
691
+ ParserCore.prototype.parseRelatesDecl = function () {
692
+ const startToken = this.peek();
693
+ if (!startToken) return null;
694
+
695
+ const { token: kindToken } = this.expectKindToken('relates');
696
+ if (!kindToken) return null;
697
+
698
+ const a = this.consumeIdentifier();
699
+ if (!a) return null;
700
+
701
+ const { token: andToken } = this.expect('KEYWORD', 'and');
702
+ if (!andToken) return null;
703
+
704
+ const b = this.consumeIdentifier();
705
+ if (!b) return null;
706
+
707
+ const { token: lbrace } = this.expect('LBRACE');
708
+ if (!lbrace) return null;
709
+
710
+ const body = this.parseBodyUntilRBrace();
711
+
712
+ const { token: rbrace } = this.expect('RBRACE');
713
+ if (!rbrace) return null;
714
+
715
+ return {
716
+ kind: 'relates',
717
+ spelledKind: kindToken.value,
718
+ a,
719
+ b,
720
+ body,
721
+ source: this.createSpan(kindToken, rbrace),
722
+ };
723
+ };
724
+
725
+ /**
726
+ * Parses relationship declarations: relationship <id> [and <id2>] { ... }
727
+ * @returns {RelationshipDecl | null}
728
+ */
729
+ ParserCore.prototype.parseRelationshipDecl = function () {
730
+ const startToken = this.peek();
731
+ if (!startToken) return null;
732
+
733
+ const { token: kindToken } = this.expectKindToken('relationship');
734
+ if (!kindToken) return null;
735
+
736
+ const firstIdToken = this.peek();
737
+ if (!firstIdToken || (firstIdToken.type !== 'IDENTIFIER' && firstIdToken.type !== 'KEYWORD')) {
738
+ this.reportDiagnostic('error', 'Expected identifier for relationship', startToken.span);
739
+ return null;
740
+ }
741
+ this.advance();
742
+ const ids = [firstIdToken.value];
743
+
744
+ // Optional "and <id2>" for paired relationships
745
+ if (this.match('KEYWORD', 'and')) {
746
+ this.advance(); // consume 'and'
747
+ const secondIdToken = this.peek();
748
+ if (secondIdToken && (secondIdToken.type === 'IDENTIFIER' || secondIdToken.type === 'KEYWORD')) {
749
+ this.advance();
750
+ ids.push(secondIdToken.value);
751
+ } else {
752
+ this.reportDiagnostic('error', 'Expected identifier after "and"', secondIdToken?.span || startToken.span);
753
+ }
754
+ }
755
+
756
+ const { token: lbrace } = this.expect('LBRACE');
757
+ if (!lbrace) return null;
758
+
759
+ const body = this.parseBodyUntilRBrace();
760
+
761
+ const { token: rbrace } = this.expect('RBRACE');
762
+ if (!rbrace) return null;
763
+
764
+ return {
765
+ kind: 'relationshipDecl',
766
+ ids,
767
+ body,
768
+ span: this.createSpan(kindToken, rbrace),
769
+ };
770
+ };
771
+
772
+ /**
773
+ * Parses alias declaration: alias Name { TargetKind }
774
+ * @returns {any | null}
775
+ */
776
+ ParserCore.prototype.parseAliasDecl = function () {
777
+ const startToken = this.peek();
778
+ if (!startToken) return null;
779
+
780
+ const { token: aliasToken } = this.expectIdentifierOrKeyword('alias');
781
+ if (!aliasToken) return null;
782
+
783
+ const name = this.consumeIdentifier();
784
+ if (!name) return null;
785
+
786
+ const { token: lbrace } = this.expect('LBRACE');
787
+ if (!lbrace) return null;
788
+
789
+ const targetKind = this.consumeIdentifier();
790
+ if (!targetKind) return null;
791
+
792
+ const { token: rbrace } = this.expect('RBRACE');
793
+ if (!rbrace) return null;
794
+
795
+ return {
796
+ kind: 'alias',
797
+ name,
798
+ targetKind,
799
+ source: this.createSpan(aliasToken, rbrace),
800
+ };
801
+ };
802
+
803
+ /**
804
+ * Parses aliases block: aliases { Name { TargetKind } ... }
805
+ * @returns {any | null}
806
+ */
807
+ ParserCore.prototype.parseAliasesBlock = function () {
808
+ const startToken = this.peek();
809
+ if (!startToken) return null;
810
+
811
+ const { token: aliasesToken } = this.expectIdentifierOrKeyword('aliases');
812
+ if (!aliasesToken) return null;
813
+
814
+ const { token: lbrace } = this.expect('LBRACE');
815
+ if (!lbrace) return null;
816
+
817
+ const aliases = [];
818
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
819
+ const name = this.consumeIdentifier();
820
+ if (!name) break;
821
+
822
+ const { token: innerLbrace } = this.expect('LBRACE');
823
+ if (!innerLbrace) break;
824
+
825
+ const targetKind = this.consumeIdentifier();
826
+ if (!targetKind) break;
827
+
828
+ const { token: innerRbrace } = this.expect('RBRACE');
829
+ if (!innerRbrace) break;
830
+
831
+ aliases.push({ name, targetKind });
832
+ }
833
+
834
+ const { token: rbrace } = this.expect('RBRACE');
835
+ if (!rbrace) return null;
836
+
837
+ return {
838
+ kind: 'aliases',
839
+ aliases,
840
+ source: this.createSpan(aliasesToken, rbrace),
841
+ };
842
+ };
843
+
844
+ /**
845
+ * Parses a describe block (spans only, no raw string)
846
+ * @returns {DescribeBlock | null}
847
+ */
848
+ ParserCore.prototype.parseDescribeBlock = function () {
849
+ const startToken = this.peek();
850
+ if (!startToken) return null;
851
+
852
+ const { token: keywordToken } = this.expect('KEYWORD', 'describe');
853
+ if (!keywordToken) return null;
854
+
855
+ const block = this.parseRawContentBlock('describe', keywordToken);
856
+ if (!block) return null;
857
+
858
+ return {
859
+ kind: 'describe',
860
+ contentSpan: block.contentSpan,
861
+ span: block.span,
862
+ };
863
+ };
864
+
865
+ /**
866
+ * Parses a title block (spans only, no raw string)
867
+ * @returns {TitleBlock | null}
868
+ */
869
+ ParserCore.prototype.parseTitleBlock = function () {
870
+ const startToken = this.peek();
871
+ if (!startToken) return null;
872
+
873
+ const { token: keywordToken } = this.expect('KEYWORD', 'title');
874
+ if (!keywordToken) return null;
875
+
876
+ const block = this.parseRawContentBlock('title', keywordToken);
877
+ if (!block) return null;
878
+
879
+ return {
880
+ kind: 'title',
881
+ contentSpan: block.contentSpan,
882
+ span: block.span,
883
+ };
884
+ };
885
+
886
+ /**
887
+ * Parses a note block (spans only, no raw string)
888
+ * @returns {NoteBlock | null}
889
+ */
890
+ ParserCore.prototype.parseNoteBlock = function () {
891
+ const startToken = this.peek();
892
+ if (!startToken) return null;
893
+
894
+ const { token: keywordToken } = this.expect('KEYWORD', 'note');
895
+ if (!keywordToken) return null;
896
+
897
+ const block = this.parseRawContentBlock('note', keywordToken);
898
+ if (!block) return null;
899
+
900
+ return {
901
+ kind: 'note',
902
+ contentSpan: block.contentSpan,
903
+ span: block.span,
904
+ };
905
+ };
906
+
907
+ /**
908
+ * Parses from block: from Endpoint { ... }
909
+ * @returns {any | null}
910
+ */
911
+ ParserCore.prototype.parseFromBlock = function () {
912
+ const startToken = this.peek();
913
+ if (!startToken) return null;
914
+
915
+ const { token: fromToken } = this.expect('KEYWORD', 'from');
916
+ if (!fromToken) return null;
917
+
918
+ const endpoint = this.consumeIdentifier();
919
+ if (!endpoint) return null;
920
+
921
+ const { token: lbrace } = this.expect('LBRACE');
922
+ if (!lbrace) return null;
923
+
924
+ const body = this.parseBodyUntilRBrace();
925
+
926
+ const { token: rbrace } = this.expect('RBRACE');
927
+ if (!rbrace) return null;
928
+
929
+ return {
930
+ kind: 'from',
931
+ endpoint,
932
+ body,
933
+ source: this.createSpan(fromToken, rbrace),
934
+ };
935
+ };
936
+
937
+ /**
938
+ * Parses a label block (placeholder - similar to raw content blocks)
939
+ * @returns {any | null}
940
+ */
941
+ ParserCore.prototype.parseLabelBlock = function () {
942
+ const startToken = this.peek();
943
+ if (!startToken) return null;
944
+
945
+ const { token: labelToken } = this.expectIdentifierOrKeyword('label');
946
+ if (!labelToken) return null;
947
+
948
+ // Parse as raw content block (it will handle LBRACE)
949
+ const block = this.parseRawContentBlock('label', labelToken);
950
+ if (!block) return null;
951
+
952
+ return {
953
+ kind: 'label',
954
+ contentSpan: block.contentSpan,
955
+ span: block.span,
956
+ };
957
+ };
958
+
959
+ /**
960
+ * Parses a repository declaration: repository <Name> [in <Parent>] { ... }
961
+ * @returns {any | null}
962
+ */
963
+ ParserCore.prototype.parseRepositoryDecl = function () {
964
+ const startToken = this.peek();
965
+ if (!startToken) return null;
966
+
967
+ const { token: kindToken } = this.expectKindToken('repository');
968
+ if (!kindToken) return null;
969
+
970
+ const name = this.consumeIdentifier();
971
+ if (!name) return null;
972
+
973
+ // Parse optional "in ParentName" clause
974
+ let parentName = undefined;
975
+ if (this.match('KEYWORD', 'in')) {
976
+ this.advance(); // consume 'in'
977
+ const parentToken = this.peek();
978
+ if (parentToken && (parentToken.type === 'IDENTIFIER' || parentToken.type === 'KEYWORD')) {
979
+ parentName = this.parseIdentifierPath();
980
+ }
981
+ }
982
+
983
+ const { token: lbrace } = this.expect('LBRACE');
984
+ if (!lbrace) return null;
985
+
986
+ const body = this.parseRepositoryBody();
987
+
988
+ const { token: rbrace } = this.expect('RBRACE');
989
+ if (!rbrace) return null;
990
+
991
+ return {
992
+ kind: 'repository',
993
+ spelledKind: kindToken.value,
994
+ name,
995
+ parentName,
996
+ children: body,
997
+ source: this.createSpan(kindToken, rbrace),
998
+ };
999
+ };
1000
+
1001
+ /**
1002
+ * Parses a named reference declaration: reference <Name> { ... }
1003
+ * @returns {any | null}
1004
+ */
1005
+ ParserCore.prototype.parseNamedReferenceDecl = function () {
1006
+ const startToken = this.peek();
1007
+ if (!startToken) return null;
1008
+
1009
+ const { token: kindToken } = this.expectKindToken('reference');
1010
+ if (!kindToken) return null;
1011
+
1012
+ const name = this.consumeIdentifier();
1013
+ if (!name) return null;
1014
+
1015
+ // Optional "in <RepositoryName>" clause
1016
+ let repositoryName = undefined;
1017
+ if (this.match('KEYWORD', 'in')) {
1018
+ this.advance();
1019
+ repositoryName = this.parseIdentifierPath();
1020
+ }
1021
+
1022
+ const { token: lbrace } = this.expect('LBRACE');
1023
+ if (!lbrace) return null;
1024
+
1025
+ const body = this.parseReferenceBody();
1026
+
1027
+ const { token: rbrace } = this.expect('RBRACE');
1028
+ if (!rbrace) return null;
1029
+
1030
+ return {
1031
+ kind: 'referenceDecl',
1032
+ name,
1033
+ repositoryName,
1034
+ children: body,
1035
+ source: this.createSpan(kindToken, rbrace),
1036
+ };
1037
+ };
1038
+
1039
+ /**
1040
+ * Parses a reference block: reference [in <RepositoryName>] { ... }
1041
+ * Used inside container bodies (unnamed reference)
1042
+ * @returns {any | null}
1043
+ */
1044
+ ParserCore.prototype.parseReferenceBlock = function () {
1045
+ const startToken = this.peek();
1046
+ if (!startToken) return null;
1047
+
1048
+ const { token: refToken } = this.expect('KEYWORD', 'reference');
1049
+ if (!refToken) return null;
1050
+
1051
+ // Parse optional "in RepositoryName" clause
1052
+ let repositoryName = undefined;
1053
+ if (this.match('KEYWORD', 'in')) {
1054
+ this.advance(); // consume 'in'
1055
+ const repoToken = this.peek();
1056
+ if (repoToken && (repoToken.type === 'IDENTIFIER' || repoToken.type === 'KEYWORD')) {
1057
+ repositoryName = this.parseIdentifierPath();
1058
+ }
1059
+ }
1060
+
1061
+ const { token: lbrace } = this.expect('LBRACE');
1062
+ if (!lbrace) return null;
1063
+
1064
+ const body = this.parseReferenceBody();
1065
+
1066
+ const { token: rbrace } = this.expect('RBRACE');
1067
+ if (!rbrace) return null;
1068
+
1069
+ return {
1070
+ kind: 'reference',
1071
+ repositoryName,
1072
+ children: body,
1073
+ source: this.createSpan(refToken, rbrace),
1074
+ };
1075
+ };
1076
+
1077
+ /**
1078
+ * Parses a references block: references { <items> }
1079
+ * Items are identifier paths (reference names)
1080
+ * @returns {any | null}
1081
+ */
1082
+ ParserCore.prototype.parseReferencesBlock = function () {
1083
+ const startToken = this.peek();
1084
+ if (!startToken) return null;
1085
+
1086
+ const { token: refsToken } = this.expect('KEYWORD', 'references');
1087
+ if (!refsToken) return null;
1088
+
1089
+ const { token: lbrace } = this.expect('LBRACE');
1090
+ if (!lbrace) return null;
1091
+
1092
+ const items = [];
1093
+
1094
+ // Parse items until closing brace
1095
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1096
+ // Skip optional commas
1097
+ if (this.match('COMMA')) {
1098
+ this.advance();
1099
+ continue;
1100
+ }
1101
+
1102
+ const itemStartToken = this.peek();
1103
+ if (!itemStartToken) break;
1104
+
1105
+ // Parse reference name as identifier path
1106
+ const refPath = this.parseIdentifierPath();
1107
+ if (!refPath) {
1108
+ // Not a valid item - report and try to recover
1109
+ const token = this.peek();
1110
+ if (token) {
1111
+ this.reportDiagnostic('error', `Expected identifier path in references block`, token.span);
1112
+ this.advance(); // Advance to avoid infinite loop
1113
+ } else {
1114
+ break;
1115
+ }
1116
+ continue;
1117
+ }
1118
+
1119
+ // After parseIdentifierPath, previous() gives us the last token of the path
1120
+ let refEndToken = this.previous();
1121
+ if (!refEndToken) refEndToken = itemStartToken;
1122
+
1123
+ // Create item span
1124
+ const itemSpan = itemStartToken && refEndToken
1125
+ ? this.createSpan(itemStartToken, refEndToken)
1126
+ : (itemStartToken ? this.spanFromToken(itemStartToken) : undefined);
1127
+
1128
+ items.push({
1129
+ kind: 'ref',
1130
+ ref: refPath,
1131
+ span: itemSpan,
1132
+ });
1133
+ }
1134
+
1135
+ const { token: rbrace } = this.expect('RBRACE');
1136
+ if (!rbrace) return null;
1137
+
1138
+ return {
1139
+ kind: 'references',
1140
+ items,
1141
+ source: this.createSpan(refsToken, rbrace),
1142
+ };
1143
+ };
1144
+
1145
+ /**
1146
+ * Parses repository declaration body.
1147
+ * @returns {any[]}
1148
+ */
1149
+ ParserCore.prototype.parseRepositoryBody = function () {
1150
+ const body = [];
1151
+
1152
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1153
+ const token = this.peek();
1154
+ if (!token) break;
1155
+
1156
+ if (token.type === 'KEYWORD' || token.type === 'IDENTIFIER') {
1157
+ const keyword = token.value;
1158
+ if (keyword === 'url') {
1159
+ const block = this.parseStringValueBlock('url');
1160
+ if (block) body.push(block);
1161
+ continue;
1162
+ }
1163
+ if (keyword === 'title') {
1164
+ const block = this.parseTitleBlock();
1165
+ if (block) body.push(block);
1166
+ continue;
1167
+ }
1168
+ if (keyword === 'describe') {
1169
+ const block = this.parseDescribeBlock();
1170
+ if (block) body.push(block);
1171
+ continue;
1172
+ }
1173
+ if (keyword === 'note') {
1174
+ const block = this.parseNoteBlock();
1175
+ if (block) body.push(block);
1176
+ continue;
1177
+ }
1178
+ }
1179
+
1180
+ this.reportDiagnostic('error', `Unexpected token ${token.type} in repository body`, token.span);
1181
+ this.advance();
1182
+ }
1183
+
1184
+ return body;
1185
+ };
1186
+
1187
+ /**
1188
+ * Parses reference declaration body.
1189
+ * @returns {any[]}
1190
+ */
1191
+ ParserCore.prototype.parseReferenceBody = function () {
1192
+ const body = [];
1193
+
1194
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1195
+ const token = this.peek();
1196
+ if (!token) break;
1197
+
1198
+ if (token.type === 'KEYWORD' || token.type === 'IDENTIFIER') {
1199
+ const keyword = token.value;
1200
+ if (keyword === 'url') {
1201
+ const block = this.parseStringValueBlock('url');
1202
+ if (block) body.push(block);
1203
+ continue;
1204
+ }
1205
+ if (keyword === 'paths') {
1206
+ const block = this.parsePathsBlock();
1207
+ if (block) body.push(block);
1208
+ continue;
1209
+ }
1210
+ if (keyword === 'kind') {
1211
+ const block = this.parseStringValueBlock('kind');
1212
+ if (block) body.push(block);
1213
+ continue;
1214
+ }
1215
+ if (keyword === 'title') {
1216
+ const block = this.parseTitleBlock();
1217
+ if (block) body.push(block);
1218
+ continue;
1219
+ }
1220
+ if (keyword === 'describe') {
1221
+ const block = this.parseDescribeBlock();
1222
+ if (block) body.push(block);
1223
+ continue;
1224
+ }
1225
+ if (keyword === 'note') {
1226
+ const block = this.parseNoteBlock();
1227
+ if (block) body.push(block);
1228
+ continue;
1229
+ }
1230
+ }
1231
+
1232
+ this.reportDiagnostic('error', `Unexpected token ${token.type} in reference body`, token.span);
1233
+ this.advance();
1234
+ }
1235
+
1236
+ return body;
1237
+ };
1238
+
1239
+ /**
1240
+ * Parses a string value block like url { '...' } or kind { '...' }.
1241
+ * @param {string} kind
1242
+ * @returns {{ kind: string, value: string, span: SourceSpan } | null}
1243
+ */
1244
+ ParserCore.prototype.parseStringValueBlock = function (kind) {
1245
+ const startToken = this.peek();
1246
+ if (!startToken) return null;
1247
+ const { token: kindToken } = this.expectIdentifierOrKeyword(kind);
1248
+ if (!kindToken) return null;
1249
+
1250
+ const { token: lbrace } = this.expect('LBRACE');
1251
+ if (!lbrace) return null;
1252
+
1253
+ let valueToken = null;
1254
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1255
+ if (this.match('STRING') || this.match('IDENTIFIER') || this.match('KEYWORD')) {
1256
+ valueToken = this.advance();
1257
+ break;
1258
+ }
1259
+ this.advance();
1260
+ }
1261
+
1262
+ const { token: rbrace } = this.expect('RBRACE');
1263
+ if (!rbrace) return null;
1264
+ if (!valueToken) {
1265
+ this.reportDiagnostic('error', `Expected value in ${kind} block`, kindToken.span);
1266
+ return null;
1267
+ }
1268
+
1269
+ return {
1270
+ kind,
1271
+ value: valueToken.value,
1272
+ span: this.createSpan(kindToken, rbrace),
1273
+ };
1274
+ };
1275
+
1276
+ /**
1277
+ * Parses a paths block: paths { 'a' 'b' }
1278
+ * @returns {{ kind: string, paths: string[], span: SourceSpan } | null}
1279
+ */
1280
+ ParserCore.prototype.parsePathsBlock = function () {
1281
+ const startToken = this.peek();
1282
+ if (!startToken) return null;
1283
+ const { token: kindToken } = this.expectIdentifierOrKeyword('paths');
1284
+ if (!kindToken) return null;
1285
+
1286
+ const { token: lbrace } = this.expect('LBRACE');
1287
+ if (!lbrace) return null;
1288
+
1289
+ const paths = [];
1290
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1291
+ if (this.match('COMMA')) {
1292
+ this.advance();
1293
+ continue;
1294
+ }
1295
+ if (this.match('STRING')) {
1296
+ const valueToken = this.advance();
1297
+ if (valueToken) {
1298
+ paths.push(valueToken.value);
1299
+ }
1300
+ continue;
1301
+ }
1302
+ this.advance();
1303
+ }
1304
+
1305
+ const { token: rbrace } = this.expect('RBRACE');
1306
+ if (!rbrace) return null;
1307
+
1308
+ return {
1309
+ kind: 'paths',
1310
+ paths,
1311
+ span: this.createSpan(kindToken, rbrace),
1312
+ };
1313
+ };
1314
+
1315
+ /**
1316
+ * Parses a relationships block
1317
+ * Supports legacy string syntax and new relationship/targets syntax
1318
+ * @returns {any | null}
1319
+ */
1320
+ ParserCore.prototype.parseRelationshipsBlock = function () {
1321
+ const startToken = this.peek();
1322
+ if (!startToken) return null;
1323
+
1324
+ const { token: relationshipsToken } = this.expect('KEYWORD', 'relationships');
1325
+ if (!relationshipsToken) return null;
1326
+
1327
+ const { token: lbrace } = this.expect('LBRACE');
1328
+ if (!lbrace) return null;
1329
+
1330
+ const source = {
1331
+ file: this.filePath,
1332
+ start: lbrace.span.end,
1333
+ end: lbrace.span.end,
1334
+ };
1335
+
1336
+ // Probe for legacy syntax (string literals only)
1337
+ let foundString = false;
1338
+ let foundIdentifier = false;
1339
+ let probeIndex = this.index;
1340
+ while (probeIndex < this.tokens.length) {
1341
+ const token = this.tokens[probeIndex];
1342
+ if (!token || token.type === 'EOF' || token.type === 'RBRACE') break;
1343
+ if (token.type === 'STRING') foundString = true;
1344
+ if (token.type === 'IDENTIFIER' || token.type === 'KEYWORD') foundIdentifier = true;
1345
+ if (foundString && foundIdentifier) break;
1346
+ probeIndex++;
1347
+ }
1348
+
1349
+ if (foundString && !foundIdentifier) {
1350
+ const values = [];
1351
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1352
+ if (this.match('COMMA')) {
1353
+ this.advance();
1354
+ continue;
1355
+ }
1356
+ if (this.match('STRING')) {
1357
+ const stringToken = this.advance();
1358
+ if (stringToken) {
1359
+ values.push(stringToken.value);
1360
+ source.end = stringToken.span.end;
1361
+ }
1362
+ } else {
1363
+ this.advance();
1364
+ }
1365
+ }
1366
+
1367
+ const { token: rbrace } = this.expect('RBRACE');
1368
+ if (!rbrace) return null;
1369
+ source.end = rbrace.span.start;
1370
+
1371
+ return {
1372
+ kind: 'relationships',
1373
+ values,
1374
+ source,
1375
+ };
1376
+ }
1377
+
1378
+ const entries = [];
1379
+
1380
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1381
+ if (this.match('COMMA')) {
1382
+ this.advance();
1383
+ continue;
1384
+ }
1385
+
1386
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
1387
+ const relationshipToken = this.advance();
1388
+ if (!relationshipToken) {
1389
+ break;
1390
+ }
1391
+ const relationshipId = relationshipToken.value;
1392
+
1393
+ if (!this.match('LBRACE')) {
1394
+ this.reportDiagnostic(
1395
+ 'error',
1396
+ `Expected '{' after relationship identifier "${relationshipId}"`,
1397
+ relationshipToken.span,
1398
+ );
1399
+ continue;
1400
+ }
1401
+
1402
+ const { token: targetLbrace } = this.expect('LBRACE');
1403
+ if (!targetLbrace) return null;
1404
+
1405
+ const targets = [];
1406
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1407
+ if (this.match('COMMA')) {
1408
+ this.advance();
1409
+ continue;
1410
+ }
1411
+
1412
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
1413
+ const targetToken = this.advance();
1414
+ if (!targetToken) {
1415
+ break;
1416
+ }
1417
+ const targetId = targetToken.value;
1418
+ let metadata = undefined;
1419
+
1420
+ if (this.match('LBRACE')) {
1421
+ const { token: metadataLbrace } = this.expect('LBRACE');
1422
+ if (!metadataLbrace) return null;
1423
+ const metadataBody = this.parseBodyUntilRBrace();
1424
+ const { token: metadataRbrace } = this.expect('RBRACE');
1425
+ if (!metadataRbrace) return null;
1426
+
1427
+ const describeBlock = metadataBody.find((b) => b.kind === 'describe');
1428
+ if (describeBlock) {
1429
+ metadata = describeBlock;
1430
+ }
1431
+ }
1432
+
1433
+ targets.push({
1434
+ id: targetId,
1435
+ metadata,
1436
+ });
1437
+ } else {
1438
+ this.advance();
1439
+ }
1440
+ }
1441
+
1442
+ const { token: targetRbrace } = this.expect('RBRACE');
1443
+ if (!targetRbrace) return null;
1444
+ source.end = targetRbrace.span.end;
1445
+
1446
+ entries.push({
1447
+ relationshipId,
1448
+ targets,
1449
+ });
1450
+ continue;
1451
+ }
1452
+
1453
+ this.advance();
1454
+ }
1455
+
1456
+ const { token: rbrace } = this.expect('RBRACE');
1457
+ if (!rbrace) return null;
1458
+ source.end = rbrace.span.start;
1459
+
1460
+ return {
1461
+ kind: 'relationships',
1462
+ entries,
1463
+ source,
1464
+ };
1465
+ };
1466
+
1467
+ /**
1468
+ * Parses aliased container kind declaration if present
1469
+ * Pattern: (IDENTIFIER|KEYWORD) (IDENTIFIER|KEYWORD) [KEYWORD 'in' + identifierPath] LBRACE
1470
+ * Only triggers when first token is NOT a known decl keyword parselet
1471
+ * @returns {any | null}
1472
+ */
1473
+ ParserCore.prototype.parseAliasedContainerDeclIfPresent = function () {
1474
+ const startToken = this.peek();
1475
+ if (!startToken) return null;
1476
+
1477
+ // Must be IDENTIFIER or KEYWORD
1478
+ if (startToken.type !== 'IDENTIFIER' && startToken.type !== 'KEYWORD') {
1479
+ return null;
1480
+ }
1481
+
1482
+ const kindName = startToken.value;
1483
+
1484
+ // Don't steal known declaration keywords
1485
+ if (declParselets.has(kindName)) {
1486
+ return null;
1487
+ }
1488
+
1489
+ // Check if pattern matches: kindName name [in parent] {
1490
+ // Need to peek ahead to see if we have: kindName name [in ...] LBRACE
1491
+ const savedIndex = this.index;
1492
+
1493
+ // Consume kindName
1494
+ this.advance();
1495
+
1496
+ // Next must be IDENTIFIER or KEYWORD (the name)
1497
+ const nameToken = this.peek();
1498
+ if (!nameToken || (nameToken.type !== 'IDENTIFIER' && nameToken.type !== 'KEYWORD')) {
1499
+ // Restore position
1500
+ this.index = savedIndex;
1501
+ return null;
1502
+ }
1503
+
1504
+ // Consume name
1505
+ const name = nameToken.value;
1506
+ this.advance();
1507
+
1508
+ // Check for optional "in ParentName"
1509
+ let parentName = undefined;
1510
+ if (this.match('KEYWORD', 'in')) {
1511
+ this.advance(); // consume 'in'
1512
+ const parentToken = this.peek();
1513
+ if (parentToken && (parentToken.type === 'IDENTIFIER' || parentToken.type === 'KEYWORD')) {
1514
+ parentName = this.parseIdentifierPath();
1515
+ }
1516
+ }
1517
+
1518
+ // Must be followed by LBRACE
1519
+ if (!this.match('LBRACE')) {
1520
+ // Restore position
1521
+ this.index = savedIndex;
1522
+ return null;
1523
+ }
1524
+
1525
+ // Pattern matches - parse the declaration
1526
+ const kindToken = startToken;
1527
+ const { token: lbrace } = this.expect('LBRACE');
1528
+ if (!lbrace) {
1529
+ this.index = savedIndex;
1530
+ return null;
1531
+ }
1532
+
1533
+ const body = this.parseBodyUntilRBrace();
1534
+
1535
+ const { token: rbrace } = this.expect('RBRACE');
1536
+ if (!rbrace) {
1537
+ this.index = savedIndex;
1538
+ return null;
1539
+ }
1540
+
1541
+ return {
1542
+ kind: 'container',
1543
+ spelledKind: kindName,
1544
+ name,
1545
+ parentName,
1546
+ body,
1547
+ source: this.createSpan(kindToken, rbrace),
1548
+ };
1549
+ };
1550
+
1551
+ /**
1552
+ * Parses relationship usage block: <identifier|keyword> { <items> }
1553
+ * Grammar: RefPath = (IDENTIFIER|KEYWORD) (DOT (IDENTIFIER|KEYWORD))*
1554
+ * RelItem = STRING | (RefPath [LBRACE body RBRACE])
1555
+ * Items repeat until closing RBRACE, commas optional
1556
+ * @returns {RelUseBlock | null}
1557
+ */
1558
+ ParserCore.prototype.parseRelUseBlock = function () {
1559
+ const startToken = this.peek();
1560
+ if (!startToken) return null;
1561
+
1562
+ // Name can be IDENTIFIER or KEYWORD
1563
+ const nameToken = this.peek();
1564
+ if (!nameToken || (nameToken.type !== 'IDENTIFIER' && nameToken.type !== 'KEYWORD')) {
1565
+ return null;
1566
+ }
1567
+ const name = nameToken.value;
1568
+ this.advance();
1569
+
1570
+ // Check if followed by brace
1571
+ if (!this.match('LBRACE')) {
1572
+ // Not a block, just an identifier/keyword - report and return null
1573
+ this.reportDiagnostic('error', `Unexpected ${nameToken.type.toLowerCase()} "${name}"`, startToken.span);
1574
+ return null;
1575
+ }
1576
+
1577
+ const { token: lbrace } = this.expect('LBRACE');
1578
+ if (!lbrace) return null;
1579
+
1580
+ const items = [];
1581
+
1582
+ // Parse items until closing brace
1583
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1584
+ // Skip optional commas
1585
+ if (this.match('COMMA')) {
1586
+ this.advance();
1587
+ continue;
1588
+ }
1589
+
1590
+ const itemStartToken = this.peek();
1591
+ if (!itemStartToken) break;
1592
+
1593
+ // Check if it's a STRING item
1594
+ if (itemStartToken.type === 'STRING') {
1595
+ const stringToken = this.advance();
1596
+ if (stringToken) {
1597
+ items.push({
1598
+ kind: 'string',
1599
+ value: stringToken.value,
1600
+ span: this.spanFromToken(stringToken),
1601
+ });
1602
+ }
1603
+ continue;
1604
+ }
1605
+
1606
+ // Otherwise parse as reference path
1607
+ const refPath = this.parseIdentifierPath();
1608
+ if (!refPath) {
1609
+ // Not a valid item - report and try to recover
1610
+ const token = this.peek();
1611
+ if (token) {
1612
+ this.reportDiagnostic('error', `Expected string or identifier path in relationship usage block`, token.span);
1613
+ this.advance(); // Advance to avoid infinite loop
1614
+ } else {
1615
+ break;
1616
+ }
1617
+ continue;
1618
+ }
1619
+
1620
+ // After parseIdentifierPath, previous() gives us the last token of the path
1621
+ let refEndToken = this.previous();
1622
+ if (!refEndToken) refEndToken = itemStartToken;
1623
+
1624
+ // Check if followed by inline body
1625
+ let body = undefined;
1626
+ if (this.match('LBRACE')) {
1627
+ const bodyLbrace = this.advance();
1628
+ if (bodyLbrace) {
1629
+ // Parse body using existing parseBodyUntilRBrace
1630
+ body = this.parseBodyUntilRBrace();
1631
+ const { token: bodyRbrace } = this.expect('RBRACE');
1632
+ if (bodyRbrace) {
1633
+ refEndToken = bodyRbrace;
1634
+ }
1635
+ }
1636
+ }
1637
+
1638
+ // Create item span from ref path start to end (or body end if present)
1639
+ const itemSpan = itemStartToken && refEndToken
1640
+ ? this.createSpan(itemStartToken, refEndToken)
1641
+ : (itemStartToken ? this.spanFromToken(itemStartToken) : undefined);
1642
+
1643
+ items.push({
1644
+ kind: 'ref',
1645
+ ref: refPath,
1646
+ body,
1647
+ span: itemSpan,
1648
+ });
1649
+ }
1650
+
1651
+ const { token: rbrace } = this.expect('RBRACE');
1652
+ if (!rbrace) return null;
1653
+
1654
+ return {
1655
+ kind: 'relUse',
1656
+ name,
1657
+ items,
1658
+ span: this.createSpan(startToken, rbrace),
1659
+ };
1660
+ };
1661
+
1662
+ /**
1663
+ * Debug helper: prints AST in stable tree format
1664
+ * @param {any} ast - AST node or FileAST
1665
+ */
1666
+ export function debugAst(ast) {
1667
+ /**
1668
+ * @param {any} node
1669
+ * @param {number} indent
1670
+ */
1671
+ function printNode(node, indent = 0) {
1672
+ const prefix = ' '.repeat(indent);
1673
+ if (!node || typeof node !== 'object') {
1674
+ console.log(`${prefix}${node}`);
1675
+ return;
1676
+ }
1677
+
1678
+ if (node.kind) {
1679
+ // AST node
1680
+ const kind = node.kind;
1681
+ const fields = [];
1682
+ if (node.name !== undefined) fields.push(`name=${node.name}`);
1683
+ if (node.spelledKind !== undefined) fields.push(`spelledKind=${node.spelledKind}`);
1684
+ if (node.a !== undefined) fields.push(`a=${node.a}`);
1685
+ if (node.b !== undefined) fields.push(`b=${node.b}`);
1686
+ if (node.endpoint !== undefined) fields.push(`endpoint=${node.endpoint}`);
1687
+ if (node.ids !== undefined) fields.push(`ids=[${node.ids.join(',')}]`);
1688
+ if (node.ref !== undefined) fields.push(`ref=${node.ref}`);
1689
+ if (node.keyword !== undefined) fields.push(`keyword=${node.keyword}`);
1690
+ if (node.parentName !== undefined) fields.push(`parentName=${node.parentName}`);
1691
+ if (node.repositoryName !== undefined) fields.push(`repositoryName=${node.repositoryName}`);
1692
+ if (node.contentSpan !== undefined) fields.push(`contentSpan=[${node.contentSpan.startOffset}..${node.contentSpan.endOffset}]`);
1693
+
1694
+ const childCount = Array.isArray(node.body) ? node.body.length
1695
+ : Array.isArray(node.children) ? node.children.length
1696
+ : Array.isArray(node.items) ? node.items.length
1697
+ : Array.isArray(node.decls) ? node.decls.length
1698
+ : 0;
1699
+ if (childCount > 0) fields.push(`children=${childCount}`);
1700
+
1701
+ const fieldStr = fields.length > 0 ? ` (${fields.join(', ')})` : '';
1702
+ console.log(`${prefix}${kind}${fieldStr}`);
1703
+
1704
+ if (Array.isArray(node.body)) {
1705
+ node.body.forEach(/** @param {any} child */ (child) => printNode(child, indent + 1));
1706
+ } else if (Array.isArray(node.children)) {
1707
+ node.children.forEach(/** @param {any} child */ (child) => printNode(child, indent + 1));
1708
+ } else if (Array.isArray(node.items)) {
1709
+ // Special handling for references block vs relUse block
1710
+ if (node.kind === 'references') {
1711
+ node.items.forEach(/** @param {any} item */ (item) => {
1712
+ if (item && typeof item === 'object' && item.kind === 'ref') {
1713
+ console.log(`${prefix} item ref=${item.ref}`);
1714
+ } else {
1715
+ printNode(item, indent + 1);
1716
+ }
1717
+ });
1718
+ } else {
1719
+ // relUse block items
1720
+ node.items.forEach(/** @param {any} item */ (item) => {
1721
+ if (item && typeof item === 'object' && item.kind) {
1722
+ if (item.kind === 'string') {
1723
+ console.log(`${prefix} item string='${item.value}'`);
1724
+ } else if (item.kind === 'ref') {
1725
+ const refFields = [`ref=${item.ref}`];
1726
+ if (item.body && Array.isArray(item.body) && item.body.length > 0) {
1727
+ refFields.push(`body=${item.body.length}`);
1728
+ }
1729
+ console.log(`${prefix} item ref (${refFields.join(', ')})`);
1730
+ if (item.body && Array.isArray(item.body)) {
1731
+ item.body.forEach(/** @param {any} child */ (child) => printNode(child, indent + 2));
1732
+ }
1733
+ } else {
1734
+ printNode(item, indent + 1);
1735
+ }
1736
+ } else {
1737
+ printNode(item, indent + 1);
1738
+ }
1739
+ });
1740
+ }
1741
+ } else if (Array.isArray(node.decls)) {
1742
+ node.decls.forEach(/** @param {any} child */ (child) => printNode(child, indent + 1));
1743
+ }
1744
+ } else {
1745
+ // Unknown object
1746
+ console.log(`${prefix}[object]`);
1747
+ }
1748
+ }
1749
+
1750
+ printNode(ast);
1751
+ }