@sprig-and-prose/sprig-universe 0.1.1 → 0.3.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/package.json +1 -1
- package/src/ast.js +30 -32
- package/src/cli.js +1 -210
- package/src/graph.js +888 -235
- package/src/ir.js +21 -6
- package/src/parser.js +251 -174
- 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/ir.js
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
* @property {NodeId} [container] - Container node ID (for book/chapter "in" relationship; always set for book/chapter nodes when resolved)
|
|
64
64
|
* @property {TextBlock} [describe] - Describe block if present
|
|
65
65
|
* @property {UnknownBlock[]} [unknownBlocks] - Unknown blocks if any
|
|
66
|
-
* @property {
|
|
66
|
+
* @property {string[]} [references] - Reference IDs attached to this node
|
|
67
67
|
* @property {Array<{title?: string, kind: string, path: string, describe?: TextBlock, source: SourceSpan}>} [documentation] - Documentation if any
|
|
68
68
|
* @property {SourceSpan} source - Source span
|
|
69
69
|
* @property {NodeId[]} [endpoints] - Endpoint node IDs (for relates nodes)
|
|
@@ -88,12 +88,27 @@
|
|
|
88
88
|
*/
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
|
-
* @typedef {Object}
|
|
91
|
+
* @typedef {Object} RepositoryModel
|
|
92
|
+
* @property {string} id - Repository ID (qualified)
|
|
93
|
+
* @property {string} name - Repository name
|
|
94
|
+
* @property {string} url - Base URL
|
|
95
|
+
* @property {TextBlock} [describe] - Optional describe block
|
|
96
|
+
* @property {TextBlock} [note] - Optional note block
|
|
97
|
+
* @property {string} [title] - Optional title
|
|
98
|
+
* @property {SourceSpan} source - Source span
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @typedef {Object} ReferenceModel
|
|
103
|
+
* @property {string} id - Reference ID (qualified)
|
|
92
104
|
* @property {string} name - Reference name
|
|
93
|
-
* @property {string} repository - Repository name
|
|
94
|
-
* @property {string[]} paths - Array of path strings
|
|
95
105
|
* @property {string} [kind] - Optional reference kind
|
|
106
|
+
* @property {string} [title] - Optional title
|
|
96
107
|
* @property {TextBlock} [describe] - Optional describe block
|
|
108
|
+
* @property {TextBlock} [note] - Optional note block
|
|
109
|
+
* @property {string[]} urls - Computed URLs
|
|
110
|
+
* @property {string} [repositoryRef] - Repository ID if reference uses a repository
|
|
111
|
+
* @property {string[]} [paths] - Raw paths (if provided)
|
|
97
112
|
* @property {SourceSpan} source - Source span
|
|
98
113
|
*/
|
|
99
114
|
|
|
@@ -113,9 +128,9 @@
|
|
|
113
128
|
* @property {Record<NodeId, NodeModel>} nodes - Nodes keyed by ID
|
|
114
129
|
* @property {Record<EdgeId, EdgeModel>} edges - Edges keyed by ID
|
|
115
130
|
* @property {Diagnostic[]} diagnostics - Diagnostics
|
|
116
|
-
* @property {Record<string,
|
|
131
|
+
* @property {Record<string, RepositoryModel>} [repositories] - Repositories by ID
|
|
132
|
+
* @property {Record<string, ReferenceModel>} [references] - References by ID
|
|
117
133
|
* @property {string} [generatedAt] - ISO timestamp when manifest was generated
|
|
118
|
-
* @property {Record<string, Record<string, NamedReferenceModel>>} [referencesByName] - Named references by universe name, then by reference name
|
|
119
134
|
* @property {Record<string, Record<string, NamedDocumentModel>>} [documentsByName] - Named documents by universe name, then by document name
|
|
120
135
|
*/
|
|
121
136
|
|
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,6 +105,7 @@ class Parser {
|
|
|
106
105
|
parseFile() {
|
|
107
106
|
const universes = [];
|
|
108
107
|
const scenes = [];
|
|
108
|
+
const topLevelDeclsUnscoped = [];
|
|
109
109
|
const startToken = this.peek();
|
|
110
110
|
|
|
111
111
|
while (!this.match('EOF')) {
|
|
@@ -113,6 +113,34 @@ class Parser {
|
|
|
113
113
|
universes.push(this.parseUniverse());
|
|
114
114
|
} else if (this.match('KEYWORD') && this.peek()?.value === 'scene') {
|
|
115
115
|
scenes.push(this.parseScene());
|
|
116
|
+
} else if (this.match('KEYWORD')) {
|
|
117
|
+
const keyword = this.peek()?.value;
|
|
118
|
+
let decl = null;
|
|
119
|
+
if (keyword === 'series') {
|
|
120
|
+
decl = this.parseSeries();
|
|
121
|
+
} else if (keyword === 'book') {
|
|
122
|
+
decl = this.parseBook();
|
|
123
|
+
} else if (keyword === 'chapter') {
|
|
124
|
+
decl = this.parseChapter();
|
|
125
|
+
} else if (keyword === 'concept') {
|
|
126
|
+
decl = this.parseConcept();
|
|
127
|
+
} else if (keyword === 'anthology') {
|
|
128
|
+
decl = this.parseAnthology();
|
|
129
|
+
} else if (keyword === 'repository') {
|
|
130
|
+
decl = this.parseRepository();
|
|
131
|
+
} else if (keyword === 'reference') {
|
|
132
|
+
decl = this.parseReferenceDecl();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (decl) {
|
|
136
|
+
topLevelDeclsUnscoped.push(decl);
|
|
137
|
+
} else {
|
|
138
|
+
// Skip unknown top-level content (tolerant parsing)
|
|
139
|
+
const token = this.advance();
|
|
140
|
+
if (token && token.type !== 'EOF') {
|
|
141
|
+
// Could emit warning here, but for now just skip
|
|
142
|
+
}
|
|
143
|
+
}
|
|
116
144
|
} else {
|
|
117
145
|
// Skip unknown top-level content (tolerant parsing)
|
|
118
146
|
const token = this.advance();
|
|
@@ -122,10 +150,16 @@ class Parser {
|
|
|
122
150
|
}
|
|
123
151
|
}
|
|
124
152
|
|
|
153
|
+
// Merge top-level declarations into their target universe
|
|
154
|
+
if (topLevelDeclsUnscoped.length > 0 && universes.length === 1) {
|
|
155
|
+
universes[0].body.push(...topLevelDeclsUnscoped);
|
|
156
|
+
}
|
|
157
|
+
|
|
125
158
|
return {
|
|
126
159
|
file: this.file,
|
|
127
160
|
universes,
|
|
128
161
|
scenes,
|
|
162
|
+
topLevelDecls: topLevelDeclsUnscoped.length > 0 ? topLevelDeclsUnscoped : undefined,
|
|
129
163
|
source: startToken
|
|
130
164
|
? {
|
|
131
165
|
file: this.file,
|
|
@@ -143,7 +177,7 @@ class Parser {
|
|
|
143
177
|
const startToken = this.expect('KEYWORD', 'universe');
|
|
144
178
|
const nameToken = this.expect('IDENTIFIER');
|
|
145
179
|
const lbrace = this.expect('LBRACE');
|
|
146
|
-
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']);
|
|
147
181
|
const rbrace = this.expect('RBRACE');
|
|
148
182
|
|
|
149
183
|
return {
|
|
@@ -160,7 +194,7 @@ class Parser {
|
|
|
160
194
|
|
|
161
195
|
/**
|
|
162
196
|
* @param {string[]} allowedKeywords - Keywords allowed in this body
|
|
163
|
-
* @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>}
|
|
164
198
|
*/
|
|
165
199
|
parseBlockBody(allowedKeywords) {
|
|
166
200
|
const body = [];
|
|
@@ -171,21 +205,6 @@ class Parser {
|
|
|
171
205
|
const keyword = this.peek()?.value;
|
|
172
206
|
if (!keyword) break;
|
|
173
207
|
|
|
174
|
-
// Check for named reference: reference <IDENTIFIER> { ... }
|
|
175
|
-
if (keyword === 'reference') {
|
|
176
|
-
const nextPos = this.pos + 1;
|
|
177
|
-
if (nextPos < this.tokens.length &&
|
|
178
|
-
this.tokens[nextPos].type === 'IDENTIFIER' &&
|
|
179
|
-
nextPos + 1 < this.tokens.length &&
|
|
180
|
-
this.tokens[nextPos + 1].type === 'LBRACE') {
|
|
181
|
-
// This is a named reference block
|
|
182
|
-
body.push(this.parseNamedReference());
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
// Otherwise, it's an inline reference (only valid inside references block)
|
|
186
|
-
// Fall through to unknown block handling
|
|
187
|
-
}
|
|
188
|
-
|
|
189
208
|
// Check for named document: document <IDENTIFIER> { ... }
|
|
190
209
|
if (keyword === 'document') {
|
|
191
210
|
const nextPos = this.pos + 1;
|
|
@@ -219,13 +238,15 @@ class Parser {
|
|
|
219
238
|
body.push(this.parseTitle());
|
|
220
239
|
} else if (keyword === 'repository' && allowedKeywords.includes('repository')) {
|
|
221
240
|
body.push(this.parseRepository());
|
|
241
|
+
} else if (keyword === 'reference' && allowedKeywords.includes('reference')) {
|
|
242
|
+
body.push(this.parseReferenceDecl());
|
|
222
243
|
} else if (keyword === 'from' && allowedKeywords.includes('from')) {
|
|
223
244
|
body.push(this.parseFrom());
|
|
224
245
|
} else if (keyword === 'relationships' && allowedKeywords.includes('relationships')) {
|
|
225
246
|
body.push(this.parseRelationships());
|
|
226
|
-
} else if (keyword === 'references') {
|
|
247
|
+
} else if (keyword === 'references' && allowedKeywords.includes('references')) {
|
|
227
248
|
body.push(this.parseReferences());
|
|
228
|
-
} else if (keyword === 'documentation') {
|
|
249
|
+
} else if (keyword === 'documentation' && allowedKeywords.includes('documentation')) {
|
|
229
250
|
body.push(this.parseDocumentation());
|
|
230
251
|
} else {
|
|
231
252
|
// Unknown keyword/identifier followed by brace - parse as UnknownBlock
|
|
@@ -253,13 +274,32 @@ class Parser {
|
|
|
253
274
|
parseAnthology() {
|
|
254
275
|
const startToken = this.expect('KEYWORD', 'anthology');
|
|
255
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
|
+
}
|
|
256
283
|
const lbrace = this.expect('LBRACE');
|
|
257
|
-
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
|
+
]);
|
|
258
297
|
const rbrace = this.expect('RBRACE');
|
|
259
298
|
|
|
260
299
|
return {
|
|
261
300
|
kind: 'anthology',
|
|
262
301
|
name: nameToken.value,
|
|
302
|
+
parentName,
|
|
263
303
|
body,
|
|
264
304
|
source: {
|
|
265
305
|
file: this.file,
|
|
@@ -285,7 +325,7 @@ class Parser {
|
|
|
285
325
|
}
|
|
286
326
|
|
|
287
327
|
const lbrace = this.expect('LBRACE');
|
|
288
|
-
const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation']);
|
|
328
|
+
const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
289
329
|
const rbrace = this.expect('RBRACE');
|
|
290
330
|
|
|
291
331
|
return {
|
|
@@ -310,7 +350,7 @@ class Parser {
|
|
|
310
350
|
this.expect('KEYWORD', 'in');
|
|
311
351
|
const parentToken = this.expect('IDENTIFIER');
|
|
312
352
|
const lbrace = this.expect('LBRACE');
|
|
313
|
-
const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation']);
|
|
353
|
+
const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
314
354
|
const rbrace = this.expect('RBRACE');
|
|
315
355
|
|
|
316
356
|
return {
|
|
@@ -335,7 +375,7 @@ class Parser {
|
|
|
335
375
|
this.expect('KEYWORD', 'in');
|
|
336
376
|
const parentToken = this.expect('IDENTIFIER');
|
|
337
377
|
const lbrace = this.expect('LBRACE');
|
|
338
|
-
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
|
|
378
|
+
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
339
379
|
const rbrace = this.expect('RBRACE');
|
|
340
380
|
|
|
341
381
|
return {
|
|
@@ -367,7 +407,7 @@ class Parser {
|
|
|
367
407
|
}
|
|
368
408
|
|
|
369
409
|
const lbrace = this.expect('LBRACE');
|
|
370
|
-
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
|
|
410
|
+
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
|
|
371
411
|
const rbrace = this.expect('RBRACE');
|
|
372
412
|
|
|
373
413
|
return {
|
|
@@ -585,193 +625,141 @@ class Parser {
|
|
|
585
625
|
}
|
|
586
626
|
|
|
587
627
|
/**
|
|
588
|
-
* Parses a references block containing
|
|
628
|
+
* Parses a references block containing a list of identifier paths
|
|
589
629
|
* @returns {ReferencesBlock}
|
|
590
630
|
*/
|
|
591
631
|
parseReferences() {
|
|
592
632
|
const startToken = this.expect('IDENTIFIER', 'references');
|
|
593
633
|
const lbrace = this.expect('LBRACE');
|
|
594
|
-
const
|
|
634
|
+
const items = [];
|
|
635
|
+
let depth = 1;
|
|
595
636
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
// Look for 'reference' identifier
|
|
603
|
-
references.push(this.parseReference());
|
|
604
|
-
} else {
|
|
605
|
-
// Skip unexpected tokens (tolerant parsing)
|
|
637
|
+
while (this.pos < this.tokens.length && !this.match('EOF')) {
|
|
638
|
+
if (this.match('LBRACE')) {
|
|
639
|
+
throw new Error(`Unexpected '{' in references block at ${this.file}:${this.peek()?.span.start.line}. Use identifiers only.`);
|
|
640
|
+
}
|
|
641
|
+
if (this.match('LBRACE')) {
|
|
642
|
+
depth += 1;
|
|
606
643
|
this.advance();
|
|
644
|
+
continue;
|
|
607
645
|
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const rbrace = this.expect('RBRACE');
|
|
611
|
-
|
|
612
|
-
return {
|
|
613
|
-
kind: 'references',
|
|
614
|
-
references,
|
|
615
|
-
source: {
|
|
616
|
-
file: this.file,
|
|
617
|
-
start: startToken.span.start,
|
|
618
|
-
end: rbrace.span.end,
|
|
619
|
-
},
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
646
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
* @returns {UsingInReferencesBlock}
|
|
626
|
-
*/
|
|
627
|
-
parseUsingInReferences() {
|
|
628
|
-
const startToken = this.expect('KEYWORD', 'using');
|
|
629
|
-
const lbrace = this.expect('LBRACE');
|
|
630
|
-
const names = [];
|
|
631
|
-
|
|
632
|
-
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
633
|
-
if (this.match('IDENTIFIER')) {
|
|
634
|
-
names.push(this.expect('IDENTIFIER').value);
|
|
635
|
-
// Skip optional comma
|
|
636
|
-
if (this.match('COMMA')) {
|
|
637
|
-
this.expect('COMMA');
|
|
638
|
-
}
|
|
639
|
-
} else if (this.match('COMMA')) {
|
|
640
|
-
// Skip stray commas
|
|
641
|
-
this.expect('COMMA');
|
|
642
|
-
} else {
|
|
643
|
-
// Skip non-identifiers (tolerant parsing)
|
|
647
|
+
if (this.match('RBRACE')) {
|
|
648
|
+
depth -= 1;
|
|
644
649
|
this.advance();
|
|
650
|
+
if (depth === 0) {
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
continue;
|
|
645
654
|
}
|
|
646
|
-
}
|
|
647
655
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
names,
|
|
653
|
-
source: {
|
|
654
|
-
file: this.file,
|
|
655
|
-
start: startToken.span.start,
|
|
656
|
-
end: rbrace.span.end,
|
|
657
|
-
},
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
/**
|
|
662
|
-
* Parses a single reference block
|
|
663
|
-
* @returns {ReferenceBlock}
|
|
664
|
-
*/
|
|
665
|
-
parseReference() {
|
|
666
|
-
const startToken = this.expect('IDENTIFIER', 'reference');
|
|
667
|
-
const lbrace = this.expect('LBRACE');
|
|
668
|
-
let repository = null;
|
|
669
|
-
let paths = [];
|
|
670
|
-
let describe = null;
|
|
671
|
-
let kind = null;
|
|
656
|
+
if (depth !== 1) {
|
|
657
|
+
this.advance();
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
672
660
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
661
|
+
if (this.match('COMMA')) {
|
|
662
|
+
this.advance();
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
676
665
|
|
|
677
|
-
// Check for identifier or keyword (describe is a keyword)
|
|
678
666
|
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
667
|
+
const startToken = this.peek();
|
|
668
|
+
const name = this.parseIdentifierPath();
|
|
669
|
+
if (name && startToken) {
|
|
670
|
+
const lastToken = this.tokens[this.pos - 1] || startToken;
|
|
671
|
+
items.push({
|
|
672
|
+
name,
|
|
673
|
+
source: {
|
|
674
|
+
file: this.file,
|
|
675
|
+
start: startToken.span.start,
|
|
676
|
+
end: lastToken.span.end,
|
|
677
|
+
},
|
|
678
|
+
});
|
|
691
679
|
}
|
|
692
|
-
|
|
693
|
-
// Skip unexpected tokens
|
|
694
|
-
this.advance();
|
|
680
|
+
continue;
|
|
695
681
|
}
|
|
696
|
-
}
|
|
697
682
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (!repository) {
|
|
701
|
-
throw new Error(`Missing 'repository' field in reference block at ${this.file}:${startToken.span.start.line}`);
|
|
702
|
-
}
|
|
703
|
-
if (paths.length === 0) {
|
|
704
|
-
throw new Error(`Missing or empty 'paths' field in reference block at ${this.file}:${startToken.span.start.line}`);
|
|
683
|
+
this.advance();
|
|
705
684
|
}
|
|
706
685
|
|
|
707
686
|
return {
|
|
708
|
-
kind: '
|
|
709
|
-
|
|
710
|
-
paths,
|
|
711
|
-
referenceKind: kind,
|
|
712
|
-
describe,
|
|
687
|
+
kind: 'references',
|
|
688
|
+
items,
|
|
713
689
|
source: {
|
|
714
690
|
file: this.file,
|
|
715
691
|
start: startToken.span.start,
|
|
716
|
-
end:
|
|
692
|
+
end: this.tokens[this.pos - 1]?.span.end || lbrace.span.end,
|
|
717
693
|
},
|
|
718
694
|
};
|
|
719
695
|
}
|
|
720
696
|
|
|
721
697
|
/**
|
|
722
|
-
* Parses a
|
|
723
|
-
* @returns {
|
|
698
|
+
* Parses a reference declaration: reference <Name> [in <RepoName>] { ... }
|
|
699
|
+
* @returns {ReferenceDecl}
|
|
724
700
|
*/
|
|
725
|
-
|
|
726
|
-
const startToken = this.expect('IDENTIFIER', 'reference');
|
|
727
|
-
|
|
701
|
+
parseReferenceDecl() {
|
|
702
|
+
const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', 'reference') : this.expect('IDENTIFIER', 'reference');
|
|
703
|
+
let nameToken = null;
|
|
704
|
+
if (this.match('IDENTIFIER')) {
|
|
705
|
+
nameToken = this.expect('IDENTIFIER');
|
|
706
|
+
}
|
|
707
|
+
let repositoryName = null;
|
|
708
|
+
|
|
709
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'in') {
|
|
710
|
+
this.expect('KEYWORD', 'in');
|
|
711
|
+
const repoName = this.parseIdentifierPath();
|
|
712
|
+
if (!repoName) {
|
|
713
|
+
throw new Error(`Expected repository name after "in" at ${this.file}:${this.peek()?.span.start.line || '?'}`);
|
|
714
|
+
}
|
|
715
|
+
repositoryName = repoName;
|
|
716
|
+
}
|
|
717
|
+
|
|
728
718
|
const lbrace = this.expect('LBRACE');
|
|
729
|
-
let
|
|
730
|
-
let paths =
|
|
731
|
-
let describe = null;
|
|
719
|
+
let url = null;
|
|
720
|
+
let paths = null;
|
|
732
721
|
let kind = null;
|
|
722
|
+
let describe = null;
|
|
723
|
+
let note = null;
|
|
724
|
+
let title = null;
|
|
733
725
|
|
|
734
|
-
// Parse repository, paths, optional kind, and optional describe (same as parseReference)
|
|
735
726
|
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
736
727
|
if (this.match('RBRACE')) break;
|
|
737
728
|
|
|
738
|
-
// Check for identifier or keyword (describe is a keyword)
|
|
739
729
|
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
740
730
|
const identifier = this.peek()?.value;
|
|
741
|
-
if (identifier === '
|
|
742
|
-
|
|
731
|
+
if (identifier === 'url') {
|
|
732
|
+
url = this.parseStringBlock('url');
|
|
743
733
|
} else if (identifier === 'paths') {
|
|
744
734
|
paths = this.parsePathsBlock();
|
|
745
735
|
} else if (identifier === 'kind') {
|
|
746
736
|
kind = this.parseStringBlock('kind');
|
|
747
737
|
} else if (identifier === 'describe') {
|
|
748
738
|
describe = this.parseDescribe();
|
|
739
|
+
} else if (identifier === 'note') {
|
|
740
|
+
note = this.parseNote();
|
|
741
|
+
} else if (identifier === 'title') {
|
|
742
|
+
title = this.parseTitle();
|
|
749
743
|
} else {
|
|
750
|
-
// Skip unknown identifier/keyword
|
|
751
744
|
this.advance();
|
|
752
745
|
}
|
|
753
746
|
} else {
|
|
754
|
-
// Skip unexpected tokens
|
|
755
747
|
this.advance();
|
|
756
748
|
}
|
|
757
749
|
}
|
|
758
750
|
|
|
759
751
|
const rbrace = this.expect('RBRACE');
|
|
760
752
|
|
|
761
|
-
if (!repository) {
|
|
762
|
-
throw new Error(`Missing 'repository' field in named reference block at ${this.file}:${startToken.span.start.line}`);
|
|
763
|
-
}
|
|
764
|
-
if (paths.length === 0) {
|
|
765
|
-
throw new Error(`Missing or empty 'paths' field in named reference block at ${this.file}:${startToken.span.start.line}`);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
753
|
return {
|
|
769
|
-
kind: '
|
|
770
|
-
name: nameToken.value,
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
754
|
+
kind: 'reference',
|
|
755
|
+
name: nameToken ? nameToken.value : undefined,
|
|
756
|
+
repositoryName: repositoryName || undefined,
|
|
757
|
+
url: url || undefined,
|
|
758
|
+
paths: paths || undefined,
|
|
759
|
+
referenceKind: kind || undefined,
|
|
760
|
+
describe: describe || undefined,
|
|
761
|
+
note: note || undefined,
|
|
762
|
+
title: title || undefined,
|
|
775
763
|
source: {
|
|
776
764
|
file: this.file,
|
|
777
765
|
start: startToken.span.start,
|
|
@@ -932,45 +920,58 @@ class Parser {
|
|
|
932
920
|
}
|
|
933
921
|
|
|
934
922
|
/**
|
|
935
|
-
* Parses a repository block: repository <Identifier> {
|
|
923
|
+
* Parses a repository block: repository <Identifier> { url { ... } ... }
|
|
936
924
|
* @returns {RepositoryDecl}
|
|
937
925
|
*/
|
|
938
926
|
parseRepository() {
|
|
939
927
|
const startToken = this.expect('KEYWORD', 'repository');
|
|
940
928
|
const nameToken = this.expect('IDENTIFIER');
|
|
929
|
+
let parentName = undefined;
|
|
930
|
+
|
|
931
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'in') {
|
|
932
|
+
this.expect('KEYWORD', 'in');
|
|
933
|
+
const containerName = this.parseIdentifierPath();
|
|
934
|
+
if (!containerName) {
|
|
935
|
+
throw new Error(`Expected container name after "in" at ${this.file}:${this.peek()?.span.start.line || '?'}`);
|
|
936
|
+
}
|
|
937
|
+
parentName = containerName;
|
|
938
|
+
}
|
|
941
939
|
const lbrace = this.expect('LBRACE');
|
|
942
940
|
|
|
943
|
-
let
|
|
944
|
-
let
|
|
941
|
+
let url = null;
|
|
942
|
+
let describe = null;
|
|
943
|
+
let note = null;
|
|
944
|
+
let title = null;
|
|
945
945
|
|
|
946
946
|
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
947
947
|
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
948
948
|
const identifier = this.peek()?.value;
|
|
949
|
-
if (identifier === '
|
|
950
|
-
|
|
951
|
-
} else if (identifier === '
|
|
952
|
-
|
|
949
|
+
if (identifier === 'url') {
|
|
950
|
+
url = this.parseStringBlock('url');
|
|
951
|
+
} else if (identifier === 'describe') {
|
|
952
|
+
describe = this.parseDescribe();
|
|
953
|
+
} else if (identifier === 'note') {
|
|
954
|
+
note = this.parseNote();
|
|
955
|
+
} else if (identifier === 'title') {
|
|
956
|
+
title = this.parseTitle();
|
|
953
957
|
} else {
|
|
954
|
-
// Skip unknown identifier/keyword
|
|
955
958
|
this.advance();
|
|
956
959
|
}
|
|
957
960
|
} else {
|
|
958
|
-
// Skip unexpected tokens
|
|
959
961
|
this.advance();
|
|
960
962
|
}
|
|
961
963
|
}
|
|
962
964
|
|
|
963
965
|
const rbrace = this.expect('RBRACE');
|
|
964
966
|
|
|
965
|
-
if (!kind) {
|
|
966
|
-
throw new Error(`Missing 'kind' field in repository block at ${this.file}:${startToken.span.start.line}`);
|
|
967
|
-
}
|
|
968
|
-
|
|
969
967
|
return {
|
|
970
968
|
kind: 'repository',
|
|
971
969
|
name: nameToken.value,
|
|
972
|
-
|
|
973
|
-
|
|
970
|
+
parentName,
|
|
971
|
+
url: url || undefined,
|
|
972
|
+
describe: describe || undefined,
|
|
973
|
+
note: note || undefined,
|
|
974
|
+
title: title || undefined,
|
|
974
975
|
source: {
|
|
975
976
|
file: this.file,
|
|
976
977
|
start: startToken.span.start,
|
|
@@ -979,6 +980,82 @@ class Parser {
|
|
|
979
980
|
};
|
|
980
981
|
}
|
|
981
982
|
|
|
983
|
+
/**
|
|
984
|
+
* Parses an identifier path (IDENTIFIER (DOT IDENTIFIER)*)
|
|
985
|
+
* @returns {string | null}
|
|
986
|
+
*/
|
|
987
|
+
parseIdentifierPath() {
|
|
988
|
+
if (!this.match('IDENTIFIER') && !this.match('KEYWORD')) {
|
|
989
|
+
return null;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const parts = [];
|
|
993
|
+
parts.push(this.advance().value);
|
|
994
|
+
|
|
995
|
+
while (this.match('DOT')) {
|
|
996
|
+
this.expect('DOT');
|
|
997
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
998
|
+
parts.push(this.advance().value);
|
|
999
|
+
} else {
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return parts.join('.');
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Parses a note block, same format as describe
|
|
1009
|
+
* @returns {DescribeBlock}
|
|
1010
|
+
*/
|
|
1011
|
+
parseNote() {
|
|
1012
|
+
const startToken = this.expect('IDENTIFIER', 'note');
|
|
1013
|
+
const lbrace = this.expect('LBRACE');
|
|
1014
|
+
|
|
1015
|
+
let depth = 1;
|
|
1016
|
+
const startOffset = lbrace.span.end.offset;
|
|
1017
|
+
let endOffset = startOffset;
|
|
1018
|
+
let endToken = null;
|
|
1019
|
+
|
|
1020
|
+
while (depth > 0 && this.pos < this.tokens.length) {
|
|
1021
|
+
const token = this.tokens[this.pos];
|
|
1022
|
+
if (token.type === 'EOF') break;
|
|
1023
|
+
|
|
1024
|
+
if (token.type === 'LBRACE') {
|
|
1025
|
+
depth++;
|
|
1026
|
+
this.pos++;
|
|
1027
|
+
} else if (token.type === 'RBRACE') {
|
|
1028
|
+
depth--;
|
|
1029
|
+
if (depth === 0) {
|
|
1030
|
+
endToken = token;
|
|
1031
|
+
endOffset = token.span.start.offset;
|
|
1032
|
+
this.pos++;
|
|
1033
|
+
break;
|
|
1034
|
+
} else {
|
|
1035
|
+
this.pos++;
|
|
1036
|
+
}
|
|
1037
|
+
} else {
|
|
1038
|
+
this.pos++;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (depth > 0) {
|
|
1043
|
+
throw new Error(`Unclosed note block at ${this.file}:${startToken.span.start.line}`);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
|
|
1047
|
+
|
|
1048
|
+
return {
|
|
1049
|
+
kind: 'note',
|
|
1050
|
+
raw: rawContent,
|
|
1051
|
+
source: {
|
|
1052
|
+
file: this.file,
|
|
1053
|
+
start: lbrace.span.end,
|
|
1054
|
+
end: endToken ? endToken.span.start : lbrace.span.end,
|
|
1055
|
+
},
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
982
1059
|
/**
|
|
983
1060
|
* Parses a paths block (e.g., paths { 'path1', 'path2' })
|
|
984
1061
|
* @returns {string[]}
|