@sprig-and-prose/sprig-universe 0.2.0 → 0.3.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/package.json +1 -1
- package/src/ast.js +31 -33
- package/src/cli.js +1 -210
- package/src/graph.js +682 -157
- package/src/ir.js +21 -6
- package/src/parser.js +245 -218
- package/test/fixtures/amaranthine-mini.prose +14 -8
- package/test/fixtures/multi-file-universe-a.prose +9 -4
- package/test/fixtures/multi-file-universe-b.prose +5 -4
- package/test/fixtures/named-duplicate.prose +6 -4
- package/test/fixtures/reference-attachments.prose +19 -0
- package/test/fixtures/reference-commas.prose +15 -0
- package/test/fixtures/reference-inline.prose +14 -0
- package/test/fixtures/reference-raw-url.prose +9 -0
- package/test/fixtures/reference-repo-paths.prose +11 -0
- package/test/fixtures/reference-unknown.prose +7 -0
- package/test/references.test.js +105 -0
- package/test/universe-basic.test.js +21 -166
- package/repositories/sprig-repository-github/index.js +0 -29
package/src/parser.js
CHANGED
|
@@ -16,9 +16,8 @@ import { mergeSpans } from './util/span.js';
|
|
|
16
16
|
* @typedef {import('./ast.js').DescribeBlock} DescribeBlock
|
|
17
17
|
* @typedef {import('./ast.js').UnknownBlock} UnknownBlock
|
|
18
18
|
* @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
|
|
19
|
-
* @typedef {import('./ast.js').
|
|
20
|
-
* @typedef {import('./ast.js').
|
|
21
|
-
* @typedef {import('./ast.js').UsingInReferencesBlock} UsingInReferencesBlock
|
|
19
|
+
* @typedef {import('./ast.js').RepositoryDecl} RepositoryDecl
|
|
20
|
+
* @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
|
|
22
21
|
* @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
|
|
23
22
|
* @typedef {import('./ast.js').DocumentBlock} DocumentBlock
|
|
24
23
|
* @typedef {import('./ast.js').NamedDocumentBlock} NamedDocumentBlock
|
|
@@ -106,7 +105,6 @@ class Parser {
|
|
|
106
105
|
parseFile() {
|
|
107
106
|
const universes = [];
|
|
108
107
|
const scenes = [];
|
|
109
|
-
const topLevelDeclsByUniverse = new Map();
|
|
110
108
|
const topLevelDeclsUnscoped = [];
|
|
111
109
|
const startToken = this.peek();
|
|
112
110
|
|
|
@@ -128,18 +126,14 @@ class Parser {
|
|
|
128
126
|
decl = this.parseConcept();
|
|
129
127
|
} else if (keyword === 'anthology') {
|
|
130
128
|
decl = this.parseAnthology();
|
|
129
|
+
} else if (keyword === 'repository') {
|
|
130
|
+
decl = this.parseRepository();
|
|
131
|
+
} else if (keyword === 'reference') {
|
|
132
|
+
decl = this.parseReferenceDecl();
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
if (decl) {
|
|
134
|
-
|
|
135
|
-
const universeName = decl.parentName;
|
|
136
|
-
if (!topLevelDeclsByUniverse.has(universeName)) {
|
|
137
|
-
topLevelDeclsByUniverse.set(universeName, []);
|
|
138
|
-
}
|
|
139
|
-
topLevelDeclsByUniverse.get(universeName).push(decl);
|
|
140
|
-
} else {
|
|
141
|
-
topLevelDeclsUnscoped.push(decl);
|
|
142
|
-
}
|
|
136
|
+
topLevelDeclsUnscoped.push(decl);
|
|
143
137
|
} else {
|
|
144
138
|
// Skip unknown top-level content (tolerant parsing)
|
|
145
139
|
const token = this.advance();
|
|
@@ -156,43 +150,16 @@ class Parser {
|
|
|
156
150
|
}
|
|
157
151
|
}
|
|
158
152
|
|
|
159
|
-
// Merge top-level declarations into their target
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
for (const universe of universes) {
|
|
163
|
-
universesByName.set(universe.name, universe);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
for (const [universeName, decls] of topLevelDeclsByUniverse.entries()) {
|
|
167
|
-
const universe = universesByName.get(universeName);
|
|
168
|
-
if (universe) {
|
|
169
|
-
universe.body.push(...decls);
|
|
170
|
-
} else {
|
|
171
|
-
const firstDecl = decls[0];
|
|
172
|
-
const lastDecl = decls[decls.length - 1];
|
|
173
|
-
universes.push({
|
|
174
|
-
kind: 'universe',
|
|
175
|
-
name: universeName,
|
|
176
|
-
body: decls,
|
|
177
|
-
source: {
|
|
178
|
-
file: this.file,
|
|
179
|
-
start: firstDecl.source.start,
|
|
180
|
-
end: lastDecl.source.end,
|
|
181
|
-
},
|
|
182
|
-
});
|
|
183
|
-
universesByName.set(universeName, universes[universes.length - 1]);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (topLevelDeclsUnscoped.length > 0 && universes.length === 1) {
|
|
188
|
-
universes[0].body.push(...topLevelDeclsUnscoped);
|
|
189
|
-
}
|
|
153
|
+
// Merge top-level declarations into their target universe
|
|
154
|
+
if (topLevelDeclsUnscoped.length > 0 && universes.length === 1) {
|
|
155
|
+
universes[0].body.push(...topLevelDeclsUnscoped);
|
|
190
156
|
}
|
|
191
157
|
|
|
192
158
|
return {
|
|
193
159
|
file: this.file,
|
|
194
160
|
universes,
|
|
195
161
|
scenes,
|
|
162
|
+
topLevelDecls: topLevelDeclsUnscoped.length > 0 ? topLevelDeclsUnscoped : undefined,
|
|
196
163
|
source: startToken
|
|
197
164
|
? {
|
|
198
165
|
file: this.file,
|
|
@@ -210,7 +177,7 @@ class Parser {
|
|
|
210
177
|
const startToken = this.expect('KEYWORD', 'universe');
|
|
211
178
|
const nameToken = this.expect('IDENTIFIER');
|
|
212
179
|
const lbrace = this.expect('LBRACE');
|
|
213
|
-
const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository']);
|
|
180
|
+
const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository', 'reference', 'references', 'documentation']);
|
|
214
181
|
const rbrace = this.expect('RBRACE');
|
|
215
182
|
|
|
216
183
|
return {
|
|
@@ -227,7 +194,7 @@ class Parser {
|
|
|
227
194
|
|
|
228
195
|
/**
|
|
229
196
|
* @param {string[]} allowedKeywords - Keywords allowed in this body
|
|
230
|
-
* @returns {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | DocumentationBlock |
|
|
197
|
+
* @returns {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>}
|
|
231
198
|
*/
|
|
232
199
|
parseBlockBody(allowedKeywords) {
|
|
233
200
|
const body = [];
|
|
@@ -238,21 +205,6 @@ class Parser {
|
|
|
238
205
|
const keyword = this.peek()?.value;
|
|
239
206
|
if (!keyword) break;
|
|
240
207
|
|
|
241
|
-
// Check for named reference: reference <IDENTIFIER> { ... }
|
|
242
|
-
if (keyword === 'reference') {
|
|
243
|
-
const nextPos = this.pos + 1;
|
|
244
|
-
if (nextPos < this.tokens.length &&
|
|
245
|
-
this.tokens[nextPos].type === 'IDENTIFIER' &&
|
|
246
|
-
nextPos + 1 < this.tokens.length &&
|
|
247
|
-
this.tokens[nextPos + 1].type === 'LBRACE') {
|
|
248
|
-
// This is a named reference block
|
|
249
|
-
body.push(this.parseNamedReference());
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
// Otherwise, it's an inline reference (only valid inside references block)
|
|
253
|
-
// Fall through to unknown block handling
|
|
254
|
-
}
|
|
255
|
-
|
|
256
208
|
// Check for named document: document <IDENTIFIER> { ... }
|
|
257
209
|
if (keyword === 'document') {
|
|
258
210
|
const nextPos = this.pos + 1;
|
|
@@ -286,13 +238,15 @@ class Parser {
|
|
|
286
238
|
body.push(this.parseTitle());
|
|
287
239
|
} else if (keyword === 'repository' && allowedKeywords.includes('repository')) {
|
|
288
240
|
body.push(this.parseRepository());
|
|
241
|
+
} else if (keyword === 'reference' && allowedKeywords.includes('reference')) {
|
|
242
|
+
body.push(this.parseReferenceDecl());
|
|
289
243
|
} else if (keyword === 'from' && allowedKeywords.includes('from')) {
|
|
290
244
|
body.push(this.parseFrom());
|
|
291
245
|
} else if (keyword === 'relationships' && allowedKeywords.includes('relationships')) {
|
|
292
246
|
body.push(this.parseRelationships());
|
|
293
|
-
} else if (keyword === 'references') {
|
|
247
|
+
} else if (keyword === 'references' && allowedKeywords.includes('references')) {
|
|
294
248
|
body.push(this.parseReferences());
|
|
295
|
-
} else if (keyword === 'documentation') {
|
|
249
|
+
} else if (keyword === 'documentation' && allowedKeywords.includes('documentation')) {
|
|
296
250
|
body.push(this.parseDocumentation());
|
|
297
251
|
} else {
|
|
298
252
|
// Unknown keyword/identifier followed by brace - parse as UnknownBlock
|
|
@@ -320,13 +274,32 @@ class Parser {
|
|
|
320
274
|
parseAnthology() {
|
|
321
275
|
const startToken = this.expect('KEYWORD', 'anthology');
|
|
322
276
|
const nameToken = this.expect('IDENTIFIER');
|
|
277
|
+
let parentName = undefined;
|
|
278
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'in') {
|
|
279
|
+
this.expect('KEYWORD', 'in');
|
|
280
|
+
const parentToken = this.expect('IDENTIFIER');
|
|
281
|
+
parentName = parentToken.value;
|
|
282
|
+
}
|
|
323
283
|
const lbrace = this.expect('LBRACE');
|
|
324
|
-
const body = this.parseBlockBody([
|
|
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
|
+
]);
|
|
325
297
|
const rbrace = this.expect('RBRACE');
|
|
326
298
|
|
|
327
299
|
return {
|
|
328
300
|
kind: 'anthology',
|
|
329
301
|
name: nameToken.value,
|
|
302
|
+
parentName,
|
|
330
303
|
body,
|
|
331
304
|
source: {
|
|
332
305
|
file: this.file,
|
|
@@ -352,7 +325,7 @@ class Parser {
|
|
|
352
325
|
}
|
|
353
326
|
|
|
354
327
|
const lbrace = this.expect('LBRACE');
|
|
355
|
-
const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation']);
|
|
328
|
+
const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
356
329
|
const rbrace = this.expect('RBRACE');
|
|
357
330
|
|
|
358
331
|
return {
|
|
@@ -374,16 +347,23 @@ class Parser {
|
|
|
374
347
|
parseBook() {
|
|
375
348
|
const startToken = this.expect('KEYWORD', 'book');
|
|
376
349
|
const nameToken = this.expect('IDENTIFIER');
|
|
377
|
-
|
|
378
|
-
|
|
350
|
+
|
|
351
|
+
// Optional "in <ParentName>" syntax
|
|
352
|
+
let parentName = undefined;
|
|
353
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'in') {
|
|
354
|
+
this.expect('KEYWORD', 'in');
|
|
355
|
+
const parentToken = this.expect('IDENTIFIER');
|
|
356
|
+
parentName = parentToken.value;
|
|
357
|
+
}
|
|
358
|
+
|
|
379
359
|
const lbrace = this.expect('LBRACE');
|
|
380
|
-
const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation']);
|
|
360
|
+
const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
381
361
|
const rbrace = this.expect('RBRACE');
|
|
382
362
|
|
|
383
363
|
return {
|
|
384
364
|
kind: 'book',
|
|
385
365
|
name: nameToken.value,
|
|
386
|
-
parentName
|
|
366
|
+
parentName,
|
|
387
367
|
body,
|
|
388
368
|
source: {
|
|
389
369
|
file: this.file,
|
|
@@ -399,10 +379,20 @@ class Parser {
|
|
|
399
379
|
parseChapter() {
|
|
400
380
|
const startToken = this.expect('KEYWORD', 'chapter');
|
|
401
381
|
const nameToken = this.expect('IDENTIFIER');
|
|
382
|
+
|
|
383
|
+
// Chapters must belong to a book - check for "in" keyword
|
|
384
|
+
if (!this.match('KEYWORD') || this.peek()?.value !== 'in') {
|
|
385
|
+
const nextToken = this.peek();
|
|
386
|
+
const line = nextToken ? nextToken.span.start.line : startToken.span.start.line;
|
|
387
|
+
throw new Error(
|
|
388
|
+
`Chapter "${nameToken.value}" must belong to a book. Use "chapter ${nameToken.value} in <BookName> { ... }" at ${this.file}:${line}`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
402
392
|
this.expect('KEYWORD', 'in');
|
|
403
393
|
const parentToken = this.expect('IDENTIFIER');
|
|
404
394
|
const lbrace = this.expect('LBRACE');
|
|
405
|
-
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
|
|
395
|
+
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
406
396
|
const rbrace = this.expect('RBRACE');
|
|
407
397
|
|
|
408
398
|
return {
|
|
@@ -434,7 +424,7 @@ class Parser {
|
|
|
434
424
|
}
|
|
435
425
|
|
|
436
426
|
const lbrace = this.expect('LBRACE');
|
|
437
|
-
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
|
|
427
|
+
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
438
428
|
const rbrace = this.expect('RBRACE');
|
|
439
429
|
|
|
440
430
|
return {
|
|
@@ -652,193 +642,141 @@ class Parser {
|
|
|
652
642
|
}
|
|
653
643
|
|
|
654
644
|
/**
|
|
655
|
-
* Parses a references block containing
|
|
645
|
+
* Parses a references block containing a list of identifier paths
|
|
656
646
|
* @returns {ReferencesBlock}
|
|
657
647
|
*/
|
|
658
648
|
parseReferences() {
|
|
659
649
|
const startToken = this.expect('IDENTIFIER', 'references');
|
|
660
650
|
const lbrace = this.expect('LBRACE');
|
|
661
|
-
const
|
|
651
|
+
const items = [];
|
|
652
|
+
let depth = 1;
|
|
662
653
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
// Look for 'reference' identifier
|
|
670
|
-
references.push(this.parseReference());
|
|
671
|
-
} else {
|
|
672
|
-
// Skip unexpected tokens (tolerant parsing)
|
|
654
|
+
while (this.pos < this.tokens.length && !this.match('EOF')) {
|
|
655
|
+
if (this.match('LBRACE')) {
|
|
656
|
+
throw new Error(`Unexpected '{' in references block at ${this.file}:${this.peek()?.span.start.line}. Use identifiers only.`);
|
|
657
|
+
}
|
|
658
|
+
if (this.match('LBRACE')) {
|
|
659
|
+
depth += 1;
|
|
673
660
|
this.advance();
|
|
661
|
+
continue;
|
|
674
662
|
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const rbrace = this.expect('RBRACE');
|
|
678
|
-
|
|
679
|
-
return {
|
|
680
|
-
kind: 'references',
|
|
681
|
-
references,
|
|
682
|
-
source: {
|
|
683
|
-
file: this.file,
|
|
684
|
-
start: startToken.span.start,
|
|
685
|
-
end: rbrace.span.end,
|
|
686
|
-
},
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
663
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
* @returns {UsingInReferencesBlock}
|
|
693
|
-
*/
|
|
694
|
-
parseUsingInReferences() {
|
|
695
|
-
const startToken = this.expect('KEYWORD', 'using');
|
|
696
|
-
const lbrace = this.expect('LBRACE');
|
|
697
|
-
const names = [];
|
|
698
|
-
|
|
699
|
-
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
700
|
-
if (this.match('IDENTIFIER')) {
|
|
701
|
-
names.push(this.expect('IDENTIFIER').value);
|
|
702
|
-
// Skip optional comma
|
|
703
|
-
if (this.match('COMMA')) {
|
|
704
|
-
this.expect('COMMA');
|
|
705
|
-
}
|
|
706
|
-
} else if (this.match('COMMA')) {
|
|
707
|
-
// Skip stray commas
|
|
708
|
-
this.expect('COMMA');
|
|
709
|
-
} else {
|
|
710
|
-
// Skip non-identifiers (tolerant parsing)
|
|
664
|
+
if (this.match('RBRACE')) {
|
|
665
|
+
depth -= 1;
|
|
711
666
|
this.advance();
|
|
667
|
+
if (depth === 0) {
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
continue;
|
|
712
671
|
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const rbrace = this.expect('RBRACE');
|
|
716
|
-
|
|
717
|
-
return {
|
|
718
|
-
kind: 'using-in-references',
|
|
719
|
-
names,
|
|
720
|
-
source: {
|
|
721
|
-
file: this.file,
|
|
722
|
-
start: startToken.span.start,
|
|
723
|
-
end: rbrace.span.end,
|
|
724
|
-
},
|
|
725
|
-
};
|
|
726
|
-
}
|
|
727
672
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
parseReference() {
|
|
733
|
-
const startToken = this.expect('IDENTIFIER', 'reference');
|
|
734
|
-
const lbrace = this.expect('LBRACE');
|
|
735
|
-
let repository = null;
|
|
736
|
-
let paths = [];
|
|
737
|
-
let describe = null;
|
|
738
|
-
let kind = null;
|
|
673
|
+
if (depth !== 1) {
|
|
674
|
+
this.advance();
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
739
677
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
678
|
+
if (this.match('COMMA')) {
|
|
679
|
+
this.advance();
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
743
682
|
|
|
744
|
-
// Check for identifier or keyword (describe is a keyword)
|
|
745
683
|
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
684
|
+
const startToken = this.peek();
|
|
685
|
+
const name = this.parseIdentifierPath();
|
|
686
|
+
if (name && startToken) {
|
|
687
|
+
const lastToken = this.tokens[this.pos - 1] || startToken;
|
|
688
|
+
items.push({
|
|
689
|
+
name,
|
|
690
|
+
source: {
|
|
691
|
+
file: this.file,
|
|
692
|
+
start: startToken.span.start,
|
|
693
|
+
end: lastToken.span.end,
|
|
694
|
+
},
|
|
695
|
+
});
|
|
758
696
|
}
|
|
759
|
-
|
|
760
|
-
// Skip unexpected tokens
|
|
761
|
-
this.advance();
|
|
697
|
+
continue;
|
|
762
698
|
}
|
|
763
|
-
}
|
|
764
699
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
if (!repository) {
|
|
768
|
-
throw new Error(`Missing 'repository' field in reference block at ${this.file}:${startToken.span.start.line}`);
|
|
769
|
-
}
|
|
770
|
-
if (paths.length === 0) {
|
|
771
|
-
throw new Error(`Missing or empty 'paths' field in reference block at ${this.file}:${startToken.span.start.line}`);
|
|
700
|
+
this.advance();
|
|
772
701
|
}
|
|
773
702
|
|
|
774
703
|
return {
|
|
775
|
-
kind: '
|
|
776
|
-
|
|
777
|
-
paths,
|
|
778
|
-
referenceKind: kind,
|
|
779
|
-
describe,
|
|
704
|
+
kind: 'references',
|
|
705
|
+
items,
|
|
780
706
|
source: {
|
|
781
707
|
file: this.file,
|
|
782
708
|
start: startToken.span.start,
|
|
783
|
-
end:
|
|
709
|
+
end: this.tokens[this.pos - 1]?.span.end || lbrace.span.end,
|
|
784
710
|
},
|
|
785
711
|
};
|
|
786
712
|
}
|
|
787
713
|
|
|
788
714
|
/**
|
|
789
|
-
* Parses a
|
|
790
|
-
* @returns {
|
|
715
|
+
* Parses a reference declaration: reference <Name> [in <RepoName>] { ... }
|
|
716
|
+
* @returns {ReferenceDecl}
|
|
791
717
|
*/
|
|
792
|
-
|
|
793
|
-
const startToken = this.expect('IDENTIFIER', 'reference');
|
|
794
|
-
|
|
718
|
+
parseReferenceDecl() {
|
|
719
|
+
const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', 'reference') : this.expect('IDENTIFIER', 'reference');
|
|
720
|
+
let nameToken = null;
|
|
721
|
+
if (this.match('IDENTIFIER')) {
|
|
722
|
+
nameToken = this.expect('IDENTIFIER');
|
|
723
|
+
}
|
|
724
|
+
let repositoryName = null;
|
|
725
|
+
|
|
726
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'in') {
|
|
727
|
+
this.expect('KEYWORD', 'in');
|
|
728
|
+
const repoName = this.parseIdentifierPath();
|
|
729
|
+
if (!repoName) {
|
|
730
|
+
throw new Error(`Expected repository name after "in" at ${this.file}:${this.peek()?.span.start.line || '?'}`);
|
|
731
|
+
}
|
|
732
|
+
repositoryName = repoName;
|
|
733
|
+
}
|
|
734
|
+
|
|
795
735
|
const lbrace = this.expect('LBRACE');
|
|
796
|
-
let
|
|
797
|
-
let paths =
|
|
798
|
-
let describe = null;
|
|
736
|
+
let url = null;
|
|
737
|
+
let paths = null;
|
|
799
738
|
let kind = null;
|
|
739
|
+
let describe = null;
|
|
740
|
+
let note = null;
|
|
741
|
+
let title = null;
|
|
800
742
|
|
|
801
|
-
// Parse repository, paths, optional kind, and optional describe (same as parseReference)
|
|
802
743
|
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
803
744
|
if (this.match('RBRACE')) break;
|
|
804
745
|
|
|
805
|
-
// Check for identifier or keyword (describe is a keyword)
|
|
806
746
|
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
807
747
|
const identifier = this.peek()?.value;
|
|
808
|
-
if (identifier === '
|
|
809
|
-
|
|
748
|
+
if (identifier === 'url') {
|
|
749
|
+
url = this.parseStringBlock('url');
|
|
810
750
|
} else if (identifier === 'paths') {
|
|
811
751
|
paths = this.parsePathsBlock();
|
|
812
752
|
} else if (identifier === 'kind') {
|
|
813
753
|
kind = this.parseStringBlock('kind');
|
|
814
754
|
} else if (identifier === 'describe') {
|
|
815
755
|
describe = this.parseDescribe();
|
|
756
|
+
} else if (identifier === 'note') {
|
|
757
|
+
note = this.parseNote();
|
|
758
|
+
} else if (identifier === 'title') {
|
|
759
|
+
title = this.parseTitle();
|
|
816
760
|
} else {
|
|
817
|
-
// Skip unknown identifier/keyword
|
|
818
761
|
this.advance();
|
|
819
762
|
}
|
|
820
763
|
} else {
|
|
821
|
-
// Skip unexpected tokens
|
|
822
764
|
this.advance();
|
|
823
765
|
}
|
|
824
766
|
}
|
|
825
767
|
|
|
826
768
|
const rbrace = this.expect('RBRACE');
|
|
827
769
|
|
|
828
|
-
if (!repository) {
|
|
829
|
-
throw new Error(`Missing 'repository' field in named reference block at ${this.file}:${startToken.span.start.line}`);
|
|
830
|
-
}
|
|
831
|
-
if (paths.length === 0) {
|
|
832
|
-
throw new Error(`Missing or empty 'paths' field in named reference block at ${this.file}:${startToken.span.start.line}`);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
770
|
return {
|
|
836
|
-
kind: '
|
|
837
|
-
name: nameToken.value,
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
771
|
+
kind: 'reference',
|
|
772
|
+
name: nameToken ? nameToken.value : undefined,
|
|
773
|
+
repositoryName: repositoryName || undefined,
|
|
774
|
+
url: url || undefined,
|
|
775
|
+
paths: paths || undefined,
|
|
776
|
+
referenceKind: kind || undefined,
|
|
777
|
+
describe: describe || undefined,
|
|
778
|
+
note: note || undefined,
|
|
779
|
+
title: title || undefined,
|
|
842
780
|
source: {
|
|
843
781
|
file: this.file,
|
|
844
782
|
start: startToken.span.start,
|
|
@@ -999,45 +937,58 @@ class Parser {
|
|
|
999
937
|
}
|
|
1000
938
|
|
|
1001
939
|
/**
|
|
1002
|
-
* Parses a repository block: repository <Identifier> {
|
|
940
|
+
* Parses a repository block: repository <Identifier> { url { ... } ... }
|
|
1003
941
|
* @returns {RepositoryDecl}
|
|
1004
942
|
*/
|
|
1005
943
|
parseRepository() {
|
|
1006
944
|
const startToken = this.expect('KEYWORD', 'repository');
|
|
1007
945
|
const nameToken = this.expect('IDENTIFIER');
|
|
946
|
+
let parentName = undefined;
|
|
947
|
+
|
|
948
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'in') {
|
|
949
|
+
this.expect('KEYWORD', 'in');
|
|
950
|
+
const containerName = this.parseIdentifierPath();
|
|
951
|
+
if (!containerName) {
|
|
952
|
+
throw new Error(`Expected container name after "in" at ${this.file}:${this.peek()?.span.start.line || '?'}`);
|
|
953
|
+
}
|
|
954
|
+
parentName = containerName;
|
|
955
|
+
}
|
|
1008
956
|
const lbrace = this.expect('LBRACE');
|
|
1009
957
|
|
|
1010
|
-
let
|
|
1011
|
-
let
|
|
958
|
+
let url = null;
|
|
959
|
+
let describe = null;
|
|
960
|
+
let note = null;
|
|
961
|
+
let title = null;
|
|
1012
962
|
|
|
1013
963
|
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
1014
964
|
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
1015
965
|
const identifier = this.peek()?.value;
|
|
1016
|
-
if (identifier === '
|
|
1017
|
-
|
|
1018
|
-
} else if (identifier === '
|
|
1019
|
-
|
|
966
|
+
if (identifier === 'url') {
|
|
967
|
+
url = this.parseStringBlock('url');
|
|
968
|
+
} else if (identifier === 'describe') {
|
|
969
|
+
describe = this.parseDescribe();
|
|
970
|
+
} else if (identifier === 'note') {
|
|
971
|
+
note = this.parseNote();
|
|
972
|
+
} else if (identifier === 'title') {
|
|
973
|
+
title = this.parseTitle();
|
|
1020
974
|
} else {
|
|
1021
|
-
// Skip unknown identifier/keyword
|
|
1022
975
|
this.advance();
|
|
1023
976
|
}
|
|
1024
977
|
} else {
|
|
1025
|
-
// Skip unexpected tokens
|
|
1026
978
|
this.advance();
|
|
1027
979
|
}
|
|
1028
980
|
}
|
|
1029
981
|
|
|
1030
982
|
const rbrace = this.expect('RBRACE');
|
|
1031
983
|
|
|
1032
|
-
if (!kind) {
|
|
1033
|
-
throw new Error(`Missing 'kind' field in repository block at ${this.file}:${startToken.span.start.line}`);
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
984
|
return {
|
|
1037
985
|
kind: 'repository',
|
|
1038
986
|
name: nameToken.value,
|
|
1039
|
-
|
|
1040
|
-
|
|
987
|
+
parentName,
|
|
988
|
+
url: url || undefined,
|
|
989
|
+
describe: describe || undefined,
|
|
990
|
+
note: note || undefined,
|
|
991
|
+
title: title || undefined,
|
|
1041
992
|
source: {
|
|
1042
993
|
file: this.file,
|
|
1043
994
|
start: startToken.span.start,
|
|
@@ -1046,6 +997,82 @@ class Parser {
|
|
|
1046
997
|
};
|
|
1047
998
|
}
|
|
1048
999
|
|
|
1000
|
+
/**
|
|
1001
|
+
* Parses an identifier path (IDENTIFIER (DOT IDENTIFIER)*)
|
|
1002
|
+
* @returns {string | null}
|
|
1003
|
+
*/
|
|
1004
|
+
parseIdentifierPath() {
|
|
1005
|
+
if (!this.match('IDENTIFIER') && !this.match('KEYWORD')) {
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const parts = [];
|
|
1010
|
+
parts.push(this.advance().value);
|
|
1011
|
+
|
|
1012
|
+
while (this.match('DOT')) {
|
|
1013
|
+
this.expect('DOT');
|
|
1014
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
1015
|
+
parts.push(this.advance().value);
|
|
1016
|
+
} else {
|
|
1017
|
+
break;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return parts.join('.');
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Parses a note block, same format as describe
|
|
1026
|
+
* @returns {DescribeBlock}
|
|
1027
|
+
*/
|
|
1028
|
+
parseNote() {
|
|
1029
|
+
const startToken = this.expect('IDENTIFIER', 'note');
|
|
1030
|
+
const lbrace = this.expect('LBRACE');
|
|
1031
|
+
|
|
1032
|
+
let depth = 1;
|
|
1033
|
+
const startOffset = lbrace.span.end.offset;
|
|
1034
|
+
let endOffset = startOffset;
|
|
1035
|
+
let endToken = null;
|
|
1036
|
+
|
|
1037
|
+
while (depth > 0 && this.pos < this.tokens.length) {
|
|
1038
|
+
const token = this.tokens[this.pos];
|
|
1039
|
+
if (token.type === 'EOF') break;
|
|
1040
|
+
|
|
1041
|
+
if (token.type === 'LBRACE') {
|
|
1042
|
+
depth++;
|
|
1043
|
+
this.pos++;
|
|
1044
|
+
} else if (token.type === 'RBRACE') {
|
|
1045
|
+
depth--;
|
|
1046
|
+
if (depth === 0) {
|
|
1047
|
+
endToken = token;
|
|
1048
|
+
endOffset = token.span.start.offset;
|
|
1049
|
+
this.pos++;
|
|
1050
|
+
break;
|
|
1051
|
+
} else {
|
|
1052
|
+
this.pos++;
|
|
1053
|
+
}
|
|
1054
|
+
} else {
|
|
1055
|
+
this.pos++;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (depth > 0) {
|
|
1060
|
+
throw new Error(`Unclosed note block at ${this.file}:${startToken.span.start.line}`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
|
|
1064
|
+
|
|
1065
|
+
return {
|
|
1066
|
+
kind: 'note',
|
|
1067
|
+
raw: rawContent,
|
|
1068
|
+
source: {
|
|
1069
|
+
file: this.file,
|
|
1070
|
+
start: lbrace.span.end,
|
|
1071
|
+
end: endToken ? endToken.span.start : lbrace.span.end,
|
|
1072
|
+
},
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1049
1076
|
/**
|
|
1050
1077
|
* Parses a paths block (e.g., paths { 'path1', 'path2' })
|
|
1051
1078
|
* @returns {string[]}
|