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

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,29 +654,42 @@ 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
- // Chapters must belong to a book - check for "in" keyword
388
- if (!this.match('KEYWORD') || this.peek()?.value !== 'in') {
661
+ // Chapters must belong to a book - check for "in" keyword or nested structure
662
+ let parentName = undefined;
663
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
664
+ // Explicit "in" clause
665
+ this.expect('KEYWORD', 'in');
666
+ const parentToken = this.expect('IDENTIFIER');
667
+ parentName = parentToken.value;
668
+ } else if (!this.match('LBRACE')) {
669
+ // Neither "in" nor nested - error
389
670
  const nextToken = this.peek();
390
671
  const line = nextToken ? nextToken.span.start.line : startToken.span.start.line;
391
672
  throw new Error(
392
673
  `Chapter "${nameToken.value}" must belong to a book. Use "chapter ${nameToken.value} in <BookName> { ... }" at ${this.file}:${line}`,
393
674
  );
394
675
  }
676
+ // If next token is LBRACE, it's nested - parentName stays undefined
395
677
 
396
- this.expect('KEYWORD', 'in');
397
- const parentToken = this.expect('IDENTIFIER');
398
678
  const lbrace = this.expect('LBRACE');
399
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
679
+ this.pushAliasScope();
680
+ const body = this.parseBlockBody(
681
+ ['concept', 'describe', 'title', 'references', 'relationships', 'documentation', 'repository', 'reference'],
682
+ { allowAliases: true },
683
+ );
400
684
  const rbrace = this.expect('RBRACE');
685
+ const aliases = this.popAliasScope();
401
686
 
402
687
  return {
403
688
  kind: 'chapter',
689
+ spelledKind,
404
690
  name: nameToken.value,
405
- parentName: parentToken.value,
691
+ parentName,
692
+ aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
406
693
  body,
407
694
  source: {
408
695
  file: this.file,
@@ -415,8 +702,8 @@ class Parser {
415
702
  /**
416
703
  * @returns {ConceptDecl}
417
704
  */
418
- parseConcept() {
419
- const startToken = this.expect('KEYWORD', 'concept');
705
+ parseConcept(spelledKind = 'concept') {
706
+ const startToken = this.expectKindToken(spelledKind);
420
707
  const nameToken = this.expect('IDENTIFIER');
421
708
 
422
709
  // Optional "in <ParentName>" syntax
@@ -428,13 +715,20 @@ class Parser {
428
715
  }
429
716
 
430
717
  const lbrace = this.expect('LBRACE');
431
- const body = this.parseBlockBody(['describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference']);
718
+ this.pushAliasScope();
719
+ const body = this.parseBlockBody(
720
+ ['describe', 'title', 'references', 'relationships', 'ordering', 'documentation', 'repository', 'reference'],
721
+ { allowAliases: true },
722
+ );
432
723
  const rbrace = this.expect('RBRACE');
724
+ const aliases = this.popAliasScope();
433
725
 
434
726
  return {
435
727
  kind: 'concept',
728
+ spelledKind,
436
729
  name: nameToken.value,
437
730
  parentName,
731
+ aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
438
732
  body,
439
733
  source: {
440
734
  file: this.file,
@@ -447,19 +741,23 @@ class Parser {
447
741
  /**
448
742
  * @returns {RelatesDecl}
449
743
  */
450
- parseRelates() {
451
- const startToken = this.expect('KEYWORD', 'relates');
744
+ parseRelates(spelledKind = 'relates') {
745
+ const startToken = this.expectKindToken(spelledKind);
452
746
  const aToken = this.expect('IDENTIFIER');
453
747
  this.expect('KEYWORD', 'and');
454
748
  const bToken = this.expect('IDENTIFIER');
455
749
  const lbrace = this.expect('LBRACE');
456
- const body = this.parseBlockBody(['describe', 'title', 'from', 'relationships']);
750
+ this.pushAliasScope();
751
+ const body = this.parseBlockBody(['describe', 'title', 'from', 'relationships'], { allowAliases: true });
457
752
  const rbrace = this.expect('RBRACE');
753
+ const aliases = this.popAliasScope();
458
754
 
459
755
  return {
460
756
  kind: 'relates',
757
+ spelledKind,
461
758
  a: aToken.value,
462
759
  b: bToken.value,
760
+ aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
463
761
  body,
464
762
  source: {
465
763
  file: this.file,
@@ -469,6 +767,191 @@ class Parser {
469
767
  };
470
768
  }
471
769
 
770
+ /**
771
+ * Parses a relationship declaration (symmetric or paired)
772
+ * @returns {RelationshipDecl}
773
+ */
774
+ parseRelationship(spelledKind = 'relationship') {
775
+ const startToken = this.expectKindToken(spelledKind);
776
+ const firstIdToken = this.expectIdentifierOrKeyword();
777
+
778
+ // Check if this is paired (has "and" after first identifier)
779
+ if (this.match('KEYWORD') && this.peek()?.value === 'and') {
780
+ // Paired relationship: relationship <leftId> and <rightId> { ... }
781
+ return this.parseRelationshipPaired(startToken, firstIdToken, spelledKind);
782
+ } else {
783
+ // Symmetric relationship: relationship <id> { ... }
784
+ return this.parseRelationshipSymmetric(startToken, firstIdToken, spelledKind);
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Parses a symmetric relationship declaration
790
+ * @param {Token} startToken
791
+ * @param {Token} idToken
792
+ * @param {string} spelledKind
793
+ * @returns {RelationshipDecl}
794
+ */
795
+ parseRelationshipSymmetric(startToken, idToken, spelledKind) {
796
+ const lbrace = this.expect('LBRACE');
797
+ let describe = null;
798
+ let label = null;
799
+
800
+ while (!this.match('RBRACE') && !this.match('EOF')) {
801
+ if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
802
+ const keyword = this.peek()?.value;
803
+ if (keyword === 'describe') {
804
+ if (describe !== null) {
805
+ throw new Error(
806
+ `Multiple describe blocks in relationship "${idToken.value}" at ${this.file}:${startToken.span.start.line}`,
807
+ );
808
+ }
809
+ describe = this.parseDescribe();
810
+ } else if (keyword === 'label') {
811
+ if (label !== null) {
812
+ throw new Error(
813
+ `Multiple label blocks in relationship "${idToken.value}" at ${this.file}:${startToken.span.start.line}`,
814
+ );
815
+ }
816
+ label = this.parseStringBlock('label');
817
+ } else {
818
+ this.advance();
819
+ }
820
+ } else {
821
+ this.advance();
822
+ }
823
+ }
824
+
825
+ const rbrace = this.expect('RBRACE');
826
+
827
+ const relationshipDecl = {
828
+ kind: 'relationship',
829
+ spelledKind,
830
+ type: 'symmetric',
831
+ id: idToken.value,
832
+ describe: describe || undefined,
833
+ label: label || undefined,
834
+ source: {
835
+ file: this.file,
836
+ start: startToken.span.start,
837
+ end: rbrace.span.end,
838
+ },
839
+ };
840
+
841
+ // Declare the relationship in current scope
842
+ this.declareRelationship(relationshipDecl);
843
+
844
+ return relationshipDecl;
845
+ }
846
+
847
+ /**
848
+ * Parses a paired (directional) relationship declaration
849
+ * @param {Token} startToken
850
+ * @param {Token} leftIdToken
851
+ * @param {string} spelledKind
852
+ * @returns {RelationshipDecl}
853
+ */
854
+ parseRelationshipPaired(startToken, leftIdToken, spelledKind) {
855
+ this.expect('KEYWORD', 'and');
856
+ const rightIdToken = this.expectIdentifierOrKeyword();
857
+ const lbrace = this.expect('LBRACE');
858
+
859
+ let pairDescribe = null;
860
+ const fromBlocks = {};
861
+
862
+ while (!this.match('RBRACE') && !this.match('EOF')) {
863
+ if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
864
+ const keyword = this.peek()?.value;
865
+ if (keyword === 'describe') {
866
+ if (pairDescribe !== null) {
867
+ throw new Error(
868
+ `Multiple describe blocks in relationship "${leftIdToken.value} and ${rightIdToken.value}" at ${this.file}:${startToken.span.start.line}`,
869
+ );
870
+ }
871
+ pairDescribe = this.parseDescribe();
872
+ } else if (keyword === 'from') {
873
+ const fromStartToken = this.advance();
874
+ const endpointToken = this.expect('IDENTIFIER');
875
+
876
+ // Validate: from block must reference leftId or rightId
877
+ if (endpointToken.value !== leftIdToken.value && endpointToken.value !== rightIdToken.value) {
878
+ throw new Error(
879
+ `from block references "${endpointToken.value}" which is not declared in relationship "${leftIdToken.value} and ${rightIdToken.value}" at ${this.file}:${endpointToken.span.start.line}`,
880
+ );
881
+ }
882
+
883
+ const fromLbrace = this.expect('LBRACE');
884
+ let fromLabel = null;
885
+ let fromDescribe = null;
886
+
887
+ // Parse from block body (label and describe)
888
+ while (!this.match('RBRACE') && !this.match('EOF')) {
889
+ if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
890
+ const fromKeyword = this.peek()?.value;
891
+ if (fromKeyword === 'label') {
892
+ if (fromLabel !== null) {
893
+ throw new Error(
894
+ `Multiple label blocks in from "${endpointToken.value}" at ${this.file}:${endpointToken.span.start.line}`,
895
+ );
896
+ }
897
+ fromLabel = this.parseStringBlock('label');
898
+ } else if (fromKeyword === 'describe') {
899
+ if (fromDescribe !== null) {
900
+ throw new Error(
901
+ `Multiple describe blocks in from "${endpointToken.value}" at ${this.file}:${endpointToken.span.start.line}`,
902
+ );
903
+ }
904
+ fromDescribe = this.parseDescribe();
905
+ } else {
906
+ this.advance();
907
+ }
908
+ } else {
909
+ this.advance();
910
+ }
911
+ }
912
+
913
+ const fromRbrace = this.expect('RBRACE');
914
+
915
+ const fromSide = {};
916
+ if (fromLabel !== null) {
917
+ fromSide.label = fromLabel;
918
+ }
919
+ if (fromDescribe !== null) {
920
+ fromSide.describe = fromDescribe;
921
+ }
922
+
923
+ fromBlocks[endpointToken.value] = fromSide;
924
+ } else {
925
+ this.advance();
926
+ }
927
+ } else {
928
+ this.advance();
929
+ }
930
+ }
931
+
932
+ const rbrace = this.expect('RBRACE');
933
+
934
+ const relationshipDecl = {
935
+ kind: 'relationship',
936
+ spelledKind,
937
+ type: 'paired',
938
+ leftId: leftIdToken.value,
939
+ rightId: rightIdToken.value,
940
+ describe: pairDescribe || undefined,
941
+ from: Object.keys(fromBlocks).length > 0 ? fromBlocks : undefined,
942
+ source: {
943
+ file: this.file,
944
+ start: startToken.span.start,
945
+ end: rbrace.span.end,
946
+ },
947
+ };
948
+
949
+ // Declare the relationship in current scope (both identifiers)
950
+ this.declareRelationship(relationshipDecl);
951
+
952
+ return relationshipDecl;
953
+ }
954
+
472
955
  /**
473
956
  * @returns {FromBlock}
474
957
  */
@@ -492,11 +975,54 @@ class Parser {
492
975
  }
493
976
 
494
977
  /**
978
+ * Parses a relationships block
979
+ * Supports both legacy syntax (string literals for relates blocks) and new syntax (relationship IDs + targets)
495
980
  * @returns {RelationshipsBlock}
496
981
  */
497
982
  parseRelationships() {
498
983
  const startToken = this.expect('KEYWORD', 'relationships');
499
984
  const lbrace = this.expect('LBRACE');
985
+ const startOffset = lbrace.span.end.offset;
986
+
987
+ // Try to detect which syntax we're using
988
+ // If first token after brace is a STRING, use legacy syntax
989
+ // Otherwise, use new syntax (relationship ID + targets)
990
+
991
+ // Peek ahead to see what comes next
992
+ let peekPos = this.pos;
993
+ let foundString = false;
994
+ let foundIdentifier = false;
995
+
996
+ while (peekPos < this.tokens.length && peekPos < this.pos + 10) {
997
+ const token = this.tokens[peekPos];
998
+ if (token.type === 'RBRACE' || token.type === 'EOF') break;
999
+ if (token.type === 'STRING') {
1000
+ foundString = true;
1001
+ break;
1002
+ }
1003
+ if (token.type === 'IDENTIFIER' || token.type === 'KEYWORD') {
1004
+ foundIdentifier = true;
1005
+ break;
1006
+ }
1007
+ peekPos++;
1008
+ }
1009
+
1010
+ // If we found a string first, use legacy syntax (for relates blocks)
1011
+ if (foundString && !foundIdentifier) {
1012
+ return this.parseRelationshipsLegacy(startToken, lbrace);
1013
+ }
1014
+
1015
+ // Otherwise, use new syntax (relationship ID + targets)
1016
+ return this.parseRelationshipsNew(startToken, lbrace);
1017
+ }
1018
+
1019
+ /**
1020
+ * Parses legacy relationships block (string literals for relates blocks)
1021
+ * @param {Token} startToken
1022
+ * @param {Token} lbrace
1023
+ * @returns {RelationshipsBlock}
1024
+ */
1025
+ parseRelationshipsLegacy(startToken, lbrace) {
500
1026
  const values = [];
501
1027
  let relationshipsSource = {
502
1028
  file: this.file,
@@ -530,6 +1056,89 @@ class Parser {
530
1056
  };
531
1057
  }
532
1058
 
1059
+ /**
1060
+ * Parses new relationships block syntax (relationship ID + target identifiers)
1061
+ * @param {Token} startToken
1062
+ * @param {Token} lbrace
1063
+ * @returns {RelationshipsBlock}
1064
+ */
1065
+ parseRelationshipsNew(startToken, lbrace) {
1066
+ const entries = [];
1067
+ let relationshipsSource = {
1068
+ file: this.file,
1069
+ start: lbrace.span.end,
1070
+ end: lbrace.span.end,
1071
+ };
1072
+
1073
+ while (!this.match('RBRACE') && !this.match('EOF')) {
1074
+ // Parse relationship ID (identifier or keyword)
1075
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
1076
+ const relationshipIdToken = this.advance();
1077
+ const relationshipId = relationshipIdToken.value;
1078
+
1079
+ // Expect opening brace for targets
1080
+ if (!this.match('LBRACE')) {
1081
+ throw new Error(
1082
+ `Expected '{' after relationship identifier "${relationshipId}" at ${this.file}:${relationshipIdToken.span.start.line}`,
1083
+ );
1084
+ }
1085
+ const targetLbrace = this.advance();
1086
+
1087
+ const targets = [];
1088
+
1089
+ // Parse targets until closing brace
1090
+ while (!this.match('RBRACE') && !this.match('EOF')) {
1091
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
1092
+ const targetIdToken = this.advance();
1093
+ const targetId = targetIdToken.value;
1094
+
1095
+ // Check if this target has a metadata block
1096
+ let metadata = null;
1097
+ if (this.match('LBRACE')) {
1098
+ // Parse metadata block (describe)
1099
+ const metadataLbrace = this.advance();
1100
+ const metadataBody = this.parseBlockBody(['describe'], {});
1101
+ const metadataRbrace = this.expect('RBRACE');
1102
+
1103
+ const describeBlock = metadataBody.find((b) => b.kind === 'describe');
1104
+ if (describeBlock) {
1105
+ metadata = describeBlock;
1106
+ }
1107
+ }
1108
+
1109
+ targets.push({
1110
+ id: targetId,
1111
+ metadata: metadata || undefined,
1112
+ });
1113
+ } else {
1114
+ // Skip other tokens
1115
+ this.advance();
1116
+ }
1117
+ }
1118
+
1119
+ const targetRbrace = this.expect('RBRACE');
1120
+ relationshipsSource.end = targetRbrace.span.end;
1121
+
1122
+ entries.push({
1123
+ relationshipId,
1124
+ targets,
1125
+ });
1126
+ } else {
1127
+ // Skip other tokens
1128
+ this.advance();
1129
+ }
1130
+ }
1131
+
1132
+ const rbrace = this.expect('RBRACE');
1133
+ relationshipsSource.end = rbrace.span.start;
1134
+
1135
+ return {
1136
+ kind: 'relationships',
1137
+ entries,
1138
+ source: relationshipsSource,
1139
+ };
1140
+ }
1141
+
533
1142
  /**
534
1143
  * Parses a describe block, consuming raw text until matching closing brace
535
1144
  * Treats braces inside as plain text (doesn't parse nested structures)
@@ -775,8 +1384,8 @@ class Parser {
775
1384
  * Parses a reference declaration: reference <Name> [in <RepoName>] { ... }
776
1385
  * @returns {ReferenceDecl}
777
1386
  */
778
- parseReferenceDecl() {
779
- const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', 'reference') : this.expect('IDENTIFIER', 'reference');
1387
+ parseReferenceDecl(spelledKind = 'reference') {
1388
+ const startToken = this.expectKindToken(spelledKind);
780
1389
  let nameToken = null;
781
1390
  if (this.match('IDENTIFIER')) {
782
1391
  nameToken = this.expect('IDENTIFIER');
@@ -1000,8 +1609,8 @@ class Parser {
1000
1609
  * Parses a repository block: repository <Identifier> { url { ... } ... }
1001
1610
  * @returns {RepositoryDecl}
1002
1611
  */
1003
- parseRepository() {
1004
- const startToken = this.expect('KEYWORD', 'repository');
1612
+ parseRepository(spelledKind = 'repository') {
1613
+ const startToken = this.expectKindToken(spelledKind);
1005
1614
  const nameToken = this.expect('IDENTIFIER');
1006
1615
  let parentName = undefined;
1007
1616