@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/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.expect('KEYWORD', 'universe');
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
- const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository', 'reference', 'references', 'ordering', 'documentation']);
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
- if (keyword === 'anthology' && allowedKeywords.includes('anthology')) {
225
- body.push(this.parseAnthology());
226
- } else if (keyword === 'series' && allowedKeywords.includes('series')) {
227
- body.push(this.parseSeries());
228
- } else if (keyword === 'book' && allowedKeywords.includes('book')) {
229
- body.push(this.parseBook());
230
- } else if (keyword === 'chapter' && allowedKeywords.includes('chapter')) {
231
- body.push(this.parseChapter());
232
- } else if (keyword === 'concept' && allowedKeywords.includes('concept')) {
233
- body.push(this.parseConcept());
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.expect('KEYWORD', 'anthology');
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
- const body = this.parseBlockBody([
288
- 'describe',
289
- 'title',
290
- 'series',
291
- 'book',
292
- 'chapter',
293
- 'concept',
294
- 'relates',
295
- 'references',
296
- 'ordering',
297
- 'documentation',
298
- 'repository',
299
- 'reference',
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.expect('KEYWORD', 'series');
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
- const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference']);
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.expect('KEYWORD', 'book');
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
- const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference']);
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.expect('KEYWORD', 'chapter');
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
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
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.expect('KEYWORD', 'concept');
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
- const body = this.parseBlockBody(['describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference']);
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.expect('KEYWORD', 'relates');
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
- const body = this.parseBlockBody(['describe', 'title', 'from', 'relationships']);
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.match('KEYWORD') ? this.expect('KEYWORD', 'reference') : this.expect('IDENTIFIER', 'reference');
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.expect('KEYWORD', 'repository');
1606
+ parseRepository(spelledKind = 'repository') {
1607
+ const startToken = this.expectKindToken(spelledKind);
1005
1608
  const nameToken = this.expect('IDENTIFIER');
1006
1609
  let parentName = undefined;
1007
1610