@sprig-and-prose/sprig-universe 0.3.3 → 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,9 +26,11 @@ 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
33
+ * @typedef {import('./ast.js').OrderingBlock} OrderingBlock
19
34
  * @typedef {import('./ast.js').RepositoryDecl} RepositoryDecl
20
35
  * @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
21
36
  * @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
@@ -47,6 +62,10 @@ class Parser {
47
62
  this.file = file;
48
63
  this.sourceText = sourceText;
49
64
  this.pos = 0;
65
+ this.aliasScopes = [new Map()];
66
+ this.aliasDeclarations = [];
67
+ this.relationshipScopes = [new Map()];
68
+ this.relationshipDeclarations = [];
50
69
  }
51
70
 
52
71
  /**
@@ -99,6 +118,205 @@ class Parser {
99
118
  return token;
100
119
  }
101
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
+
102
320
  /**
103
321
  * @returns {FileAST}
104
322
  */
@@ -173,16 +391,40 @@ class Parser {
173
391
  /**
174
392
  * @returns {UniverseDecl}
175
393
  */
176
- parseUniverse() {
177
- const startToken = this.expect('KEYWORD', 'universe');
394
+ parseUniverse(spelledKind = 'universe') {
395
+ const startToken = this.expectKindToken(spelledKind);
178
396
  const nameToken = this.expect('IDENTIFIER');
179
397
  const lbrace = this.expect('LBRACE');
180
- const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository', 'reference', 'references', '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
+ );
181
419
  const rbrace = this.expect('RBRACE');
420
+ const aliases = this.popAliasScope();
421
+ const relationships = this.popRelationshipScope();
182
422
 
183
423
  return {
184
424
  kind: 'universe',
425
+ spelledKind,
185
426
  name: nameToken.value,
427
+ aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
186
428
  body,
187
429
  source: {
188
430
  file: this.file,
@@ -194,10 +436,12 @@ class Parser {
194
436
 
195
437
  /**
196
438
  * @param {string[]} allowedKeywords - Keywords allowed in this body
439
+ * @param {{ allowAliases?: boolean }} [options]
197
440
  * @returns {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>}
198
441
  */
199
- parseBlockBody(allowedKeywords) {
442
+ parseBlockBody(allowedKeywords, options = {}) {
200
443
  const body = [];
444
+ const allowAliases = options.allowAliases === true;
201
445
 
202
446
  while (!this.match('RBRACE') && !this.match('EOF')) {
203
447
  // Check for keywords or identifiers that might start a block
@@ -205,6 +449,15 @@ class Parser {
205
449
  const keyword = this.peek()?.value;
206
450
  if (!keyword) break;
207
451
 
452
+ if (allowAliases && keyword === 'alias') {
453
+ this.parseAliasStatement();
454
+ continue;
455
+ }
456
+ if (allowAliases && keyword === 'aliases') {
457
+ this.parseAliasesBlock();
458
+ continue;
459
+ }
460
+
208
461
  // Check for named document: document <IDENTIFIER> { ... }
209
462
  if (keyword === 'document') {
210
463
  const nextPos = this.pos + 1;
@@ -220,19 +473,16 @@ class Parser {
220
473
  // Fall through to unknown block handling
221
474
  }
222
475
 
223
- if (keyword === 'anthology' && allowedKeywords.includes('anthology')) {
224
- body.push(this.parseAnthology());
225
- } else if (keyword === 'series' && allowedKeywords.includes('series')) {
226
- body.push(this.parseSeries());
227
- } else if (keyword === 'book' && allowedKeywords.includes('book')) {
228
- body.push(this.parseBook());
229
- } else if (keyword === 'chapter' && allowedKeywords.includes('chapter')) {
230
- body.push(this.parseChapter());
231
- } else if (keyword === 'concept' && allowedKeywords.includes('concept')) {
232
- body.push(this.parseConcept());
233
- } else if (keyword === 'relates' && allowedKeywords.includes('relates')) {
234
- body.push(this.parseRelates());
235
- } 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')) {
236
486
  body.push(this.parseDescribe());
237
487
  } else if (keyword === 'title' && allowedKeywords.includes('title')) {
238
488
  body.push(this.parseTitle());
@@ -246,6 +496,8 @@ class Parser {
246
496
  body.push(this.parseRelationships());
247
497
  } else if (keyword === 'references' && allowedKeywords.includes('references')) {
248
498
  body.push(this.parseReferences());
499
+ } else if (keyword === 'ordering' && allowedKeywords.includes('ordering')) {
500
+ body.push(this.parseOrdering());
249
501
  } else if (keyword === 'documentation' && allowedKeywords.includes('documentation')) {
250
502
  body.push(this.parseDocumentation());
251
503
  } else {
@@ -271,8 +523,8 @@ class Parser {
271
523
  /**
272
524
  * @returns {AnthologyDecl}
273
525
  */
274
- parseAnthology() {
275
- const startToken = this.expect('KEYWORD', 'anthology');
526
+ parseAnthology(spelledKind = 'anthology') {
527
+ const startToken = this.expectKindToken(spelledKind);
276
528
  const nameToken = this.expect('IDENTIFIER');
277
529
  let parentName = undefined;
278
530
  if (this.match('KEYWORD') && this.peek()?.value === 'in') {
@@ -281,25 +533,37 @@ class Parser {
281
533
  parentName = parentToken.value;
282
534
  }
283
535
  const lbrace = this.expect('LBRACE');
284
- const body = this.parseBlockBody([
285
- 'describe',
286
- 'title',
287
- 'series',
288
- 'book',
289
- 'chapter',
290
- 'concept',
291
- 'relates',
292
- 'references',
293
- 'documentation',
294
- 'repository',
295
- 'reference',
296
- ]);
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
+ );
297
557
  const rbrace = this.expect('RBRACE');
558
+ const aliases = this.popAliasScope();
559
+ const relationships = this.popRelationshipScope();
298
560
 
299
561
  return {
300
562
  kind: 'anthology',
563
+ spelledKind,
301
564
  name: nameToken.value,
302
565
  parentName,
566
+ aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
303
567
  body,
304
568
  source: {
305
569
  file: this.file,
@@ -312,8 +576,8 @@ class Parser {
312
576
  /**
313
577
  * @returns {SeriesDecl}
314
578
  */
315
- parseSeries() {
316
- const startToken = this.expect('KEYWORD', 'series');
579
+ parseSeries(spelledKind = 'series') {
580
+ const startToken = this.expectKindToken(spelledKind);
317
581
  const nameToken = this.expect('IDENTIFIER');
318
582
 
319
583
  // Optional "in <AnthologyName>" syntax
@@ -325,13 +589,20 @@ class Parser {
325
589
  }
326
590
 
327
591
  const lbrace = this.expect('LBRACE');
328
- const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', '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
+ );
329
597
  const rbrace = this.expect('RBRACE');
598
+ const aliases = this.popAliasScope();
330
599
 
331
600
  return {
332
601
  kind: 'series',
602
+ spelledKind,
333
603
  name: nameToken.value,
334
604
  parentName,
605
+ aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
335
606
  body,
336
607
  source: {
337
608
  file: this.file,
@@ -344,8 +615,8 @@ class Parser {
344
615
  /**
345
616
  * @returns {BookDecl}
346
617
  */
347
- parseBook() {
348
- const startToken = this.expect('KEYWORD', 'book');
618
+ parseBook(spelledKind = 'book') {
619
+ const startToken = this.expectKindToken(spelledKind);
349
620
  const nameToken = this.expect('IDENTIFIER');
350
621
 
351
622
  // Optional "in <ParentName>" syntax
@@ -357,13 +628,20 @@ class Parser {
357
628
  }
358
629
 
359
630
  const lbrace = this.expect('LBRACE');
360
- const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', '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
+ );
361
636
  const rbrace = this.expect('RBRACE');
637
+ const aliases = this.popAliasScope();
362
638
 
363
639
  return {
364
640
  kind: 'book',
641
+ spelledKind,
365
642
  name: nameToken.value,
366
643
  parentName,
644
+ aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
367
645
  body,
368
646
  source: {
369
647
  file: this.file,
@@ -376,8 +654,8 @@ class Parser {
376
654
  /**
377
655
  * @returns {ChapterDecl}
378
656
  */
379
- parseChapter() {
380
- const startToken = this.expect('KEYWORD', 'chapter');
657
+ parseChapter(spelledKind = 'chapter') {
658
+ const startToken = this.expectKindToken(spelledKind);
381
659
  const nameToken = this.expect('IDENTIFIER');
382
660
 
383
661
  // Chapters must belong to a book - check for "in" keyword
@@ -392,13 +670,20 @@ class Parser {
392
670
  this.expect('KEYWORD', 'in');
393
671
  const parentToken = this.expect('IDENTIFIER');
394
672
  const lbrace = this.expect('LBRACE');
395
- 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
+ );
396
678
  const rbrace = this.expect('RBRACE');
679
+ const aliases = this.popAliasScope();
397
680
 
398
681
  return {
399
682
  kind: 'chapter',
683
+ spelledKind,
400
684
  name: nameToken.value,
401
685
  parentName: parentToken.value,
686
+ aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
402
687
  body,
403
688
  source: {
404
689
  file: this.file,
@@ -411,8 +696,8 @@ class Parser {
411
696
  /**
412
697
  * @returns {ConceptDecl}
413
698
  */
414
- parseConcept() {
415
- const startToken = this.expect('KEYWORD', 'concept');
699
+ parseConcept(spelledKind = 'concept') {
700
+ const startToken = this.expectKindToken(spelledKind);
416
701
  const nameToken = this.expect('IDENTIFIER');
417
702
 
418
703
  // Optional "in <ParentName>" syntax
@@ -424,13 +709,20 @@ class Parser {
424
709
  }
425
710
 
426
711
  const lbrace = this.expect('LBRACE');
427
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
712
+ this.pushAliasScope();
713
+ const body = this.parseBlockBody(
714
+ ['describe', 'title', 'references', 'relationships', 'ordering', 'documentation', 'repository', 'reference'],
715
+ { allowAliases: true },
716
+ );
428
717
  const rbrace = this.expect('RBRACE');
718
+ const aliases = this.popAliasScope();
429
719
 
430
720
  return {
431
721
  kind: 'concept',
722
+ spelledKind,
432
723
  name: nameToken.value,
433
724
  parentName,
725
+ aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
434
726
  body,
435
727
  source: {
436
728
  file: this.file,
@@ -443,19 +735,23 @@ class Parser {
443
735
  /**
444
736
  * @returns {RelatesDecl}
445
737
  */
446
- parseRelates() {
447
- const startToken = this.expect('KEYWORD', 'relates');
738
+ parseRelates(spelledKind = 'relates') {
739
+ const startToken = this.expectKindToken(spelledKind);
448
740
  const aToken = this.expect('IDENTIFIER');
449
741
  this.expect('KEYWORD', 'and');
450
742
  const bToken = this.expect('IDENTIFIER');
451
743
  const lbrace = this.expect('LBRACE');
452
- const body = this.parseBlockBody(['describe', 'title', 'from', 'relationships']);
744
+ this.pushAliasScope();
745
+ const body = this.parseBlockBody(['describe', 'title', 'from', 'relationships'], { allowAliases: true });
453
746
  const rbrace = this.expect('RBRACE');
747
+ const aliases = this.popAliasScope();
454
748
 
455
749
  return {
456
750
  kind: 'relates',
751
+ spelledKind,
457
752
  a: aToken.value,
458
753
  b: bToken.value,
754
+ aliases: aliases.size > 0 ? Object.fromEntries(aliases) : undefined,
459
755
  body,
460
756
  source: {
461
757
  file: this.file,
@@ -465,6 +761,191 @@ class Parser {
465
761
  };
466
762
  }
467
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
+
468
949
  /**
469
950
  * @returns {FromBlock}
470
951
  */
@@ -488,11 +969,54 @@ class Parser {
488
969
  }
489
970
 
490
971
  /**
972
+ * Parses a relationships block
973
+ * Supports both legacy syntax (string literals for relates blocks) and new syntax (relationship IDs + targets)
491
974
  * @returns {RelationshipsBlock}
492
975
  */
493
976
  parseRelationships() {
494
977
  const startToken = this.expect('KEYWORD', 'relationships');
495
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) {
496
1020
  const values = [];
497
1021
  let relationshipsSource = {
498
1022
  file: this.file,
@@ -526,6 +1050,89 @@ class Parser {
526
1050
  };
527
1051
  }
528
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
+
529
1136
  /**
530
1137
  * Parses a describe block, consuming raw text until matching closing brace
531
1138
  * Treats braces inside as plain text (doesn't parse nested structures)
@@ -641,6 +1248,62 @@ class Parser {
641
1248
  };
642
1249
  }
643
1250
 
1251
+ /**
1252
+ * Parses an ordering block containing a list of identifiers
1253
+ * @returns {OrderingBlock}
1254
+ */
1255
+ parseOrdering() {
1256
+ const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', 'ordering') : this.expect('IDENTIFIER', 'ordering');
1257
+ const lbrace = this.expect('LBRACE');
1258
+ const identifiers = [];
1259
+ let depth = 1;
1260
+
1261
+ while (this.pos < this.tokens.length && !this.match('EOF')) {
1262
+ if (this.match('LBRACE')) {
1263
+ throw new Error(`Unexpected '{' in ordering block at ${this.file}:${this.peek()?.span.start.line}. Use identifiers only.`);
1264
+ }
1265
+
1266
+ if (this.match('RBRACE')) {
1267
+ depth -= 1;
1268
+ this.advance();
1269
+ if (depth === 0) {
1270
+ break;
1271
+ }
1272
+ continue;
1273
+ }
1274
+
1275
+ if (depth !== 1) {
1276
+ this.advance();
1277
+ continue;
1278
+ }
1279
+
1280
+ if (this.match('COMMA')) {
1281
+ this.advance();
1282
+ continue;
1283
+ }
1284
+
1285
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
1286
+ const identifierToken = this.advance();
1287
+ if (identifierToken) {
1288
+ identifiers.push(identifierToken.value);
1289
+ }
1290
+ continue;
1291
+ }
1292
+
1293
+ this.advance();
1294
+ }
1295
+
1296
+ return {
1297
+ kind: 'ordering',
1298
+ identifiers,
1299
+ source: {
1300
+ file: this.file,
1301
+ start: startToken.span.start,
1302
+ end: this.tokens[this.pos - 1]?.span.end || lbrace.span.end,
1303
+ },
1304
+ };
1305
+ }
1306
+
644
1307
  /**
645
1308
  * Parses a references block containing a list of identifier paths
646
1309
  * @returns {ReferencesBlock}
@@ -715,8 +1378,8 @@ class Parser {
715
1378
  * Parses a reference declaration: reference <Name> [in <RepoName>] { ... }
716
1379
  * @returns {ReferenceDecl}
717
1380
  */
718
- parseReferenceDecl() {
719
- const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', 'reference') : this.expect('IDENTIFIER', 'reference');
1381
+ parseReferenceDecl(spelledKind = 'reference') {
1382
+ const startToken = this.expectKindToken(spelledKind);
720
1383
  let nameToken = null;
721
1384
  if (this.match('IDENTIFIER')) {
722
1385
  nameToken = this.expect('IDENTIFIER');
@@ -940,8 +1603,8 @@ class Parser {
940
1603
  * Parses a repository block: repository <Identifier> { url { ... } ... }
941
1604
  * @returns {RepositoryDecl}
942
1605
  */
943
- parseRepository() {
944
- const startToken = this.expect('KEYWORD', 'repository');
1606
+ parseRepository(spelledKind = 'repository') {
1607
+ const startToken = this.expectKindToken(spelledKind);
945
1608
  const nameToken = this.expect('IDENTIFIER');
946
1609
  let parentName = undefined;
947
1610