@sprig-and-prose/sprig-universe 0.4.1 → 0.4.2
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/index.js +30 -0
- package/src/universe/graph.js +1619 -0
- package/src/universe/parser.js +1751 -0
- package/src/universe/scanner.js +240 -0
- package/src/universe/scene-manifest.js +856 -0
- package/src/universe/test-graph.js +157 -0
- package/src/universe/test-parser.js +61 -0
- package/src/universe/test-scanner.js +37 -0
- package/src/universe/universe.prose +169 -0
- package/src/universe/validator.js +862 -0
|
@@ -0,0 +1,1751 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Declarative parselet-based parser for Sprig universe syntax
|
|
3
|
+
* Syntax-only parsing with no semantic validation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {import('../ast.js').UniverseDecl} UniverseDecl
|
|
8
|
+
* @typedef {import('../ast.js').AnthologyDecl} AnthologyDecl
|
|
9
|
+
* @typedef {import('../ast.js').SeriesDecl} SeriesDecl
|
|
10
|
+
* @typedef {import('../ast.js').BookDecl} BookDecl
|
|
11
|
+
* @typedef {import('../ast.js').ChapterDecl} ChapterDecl
|
|
12
|
+
* @typedef {import('../ast.js').ConceptDecl} ConceptDecl
|
|
13
|
+
* @typedef {import('../ast.js').RelatesDecl} RelatesDecl
|
|
14
|
+
* @typedef {import('./scanner.js').Token} Token
|
|
15
|
+
* @typedef {import('../ir.js').Diagnostic} Diagnostic
|
|
16
|
+
* @typedef {import('../ast.js').SourceSpan} SourceSpan
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} FileAST
|
|
21
|
+
* @property {string} kind - Always 'file'
|
|
22
|
+
* @property {any[]} decls - Top-level declarations
|
|
23
|
+
* @property {SourceSpan} [source] - File-level source span
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} DescribeBlock
|
|
28
|
+
* @property {string} kind - Always 'describe'
|
|
29
|
+
* @property {{ startOffset: number, endOffset: number }} contentSpan - Content span (inside braces)
|
|
30
|
+
* @property {SourceSpan} span - Full span including braces
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {Object} TitleBlock
|
|
35
|
+
* @property {string} kind - Always 'title'
|
|
36
|
+
* @property {{ startOffset: number, endOffset: number }} contentSpan - Content span (inside braces)
|
|
37
|
+
* @property {SourceSpan} span - Full span including braces
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {Object} NoteBlock
|
|
42
|
+
* @property {string} kind - Always 'note'
|
|
43
|
+
* @property {{ startOffset: number, endOffset: number }} contentSpan - Content span (inside braces)
|
|
44
|
+
* @property {SourceSpan} span - Full span including braces
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {Object} RelUseBlock
|
|
49
|
+
* @property {string} kind - Always 'relUse'
|
|
50
|
+
* @property {string} name - Relationship identifier
|
|
51
|
+
* @property {RelItem[]} items - List of relationship items
|
|
52
|
+
* @property {SourceSpan} span - Source span
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {Object} RelItem
|
|
57
|
+
* @property {string} kind - 'ref' or 'string'
|
|
58
|
+
* @property {string} [ref] - Reference path (dot-separated identifiers) for 'ref' items
|
|
59
|
+
* @property {string} [value] - String value for 'string' items
|
|
60
|
+
* @property {any[]} [body] - Optional inline body (for 'ref' items)
|
|
61
|
+
* @property {SourceSpan} span - Source span
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {Object} RelationshipDecl
|
|
66
|
+
* @property {string} kind - Always 'relationshipDecl'
|
|
67
|
+
* @property {string[]} ids - Relationship identifier(s) [id1, id2?]
|
|
68
|
+
* @property {any[]} body - Body declarations
|
|
69
|
+
* @property {SourceSpan} span - Source span
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Core parser class with low-level token navigation utilities
|
|
74
|
+
*/
|
|
75
|
+
class ParserCore {
|
|
76
|
+
/**
|
|
77
|
+
* @param {Token[]} tokens
|
|
78
|
+
* @param {string} filePath
|
|
79
|
+
* @param {string} sourceText
|
|
80
|
+
*/
|
|
81
|
+
constructor(tokens, filePath, sourceText) {
|
|
82
|
+
this.tokens = tokens;
|
|
83
|
+
this.filePath = filePath;
|
|
84
|
+
this.sourceText = sourceText;
|
|
85
|
+
this.index = 0;
|
|
86
|
+
this.diagnostics = [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @returns {Token | null}
|
|
91
|
+
*/
|
|
92
|
+
peek() {
|
|
93
|
+
if (this.index >= this.tokens.length) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return this.tokens[this.index];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @returns {Token | null}
|
|
101
|
+
*/
|
|
102
|
+
previous() {
|
|
103
|
+
if (this.index === 0) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return this.tokens[this.index - 1];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @returns {Token | null}
|
|
111
|
+
*/
|
|
112
|
+
advance() {
|
|
113
|
+
if (this.index >= this.tokens.length) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return this.tokens[this.index++];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
isAtEnd() {
|
|
123
|
+
const token = this.peek();
|
|
124
|
+
return token === null || token.type === 'EOF';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {string} type
|
|
129
|
+
* @param {string} [value]
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
match(type, value) {
|
|
133
|
+
const token = this.peek();
|
|
134
|
+
if (!token || token.type !== type) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
if (value !== undefined && token.value !== value) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {string} type
|
|
145
|
+
* @param {string} [value]
|
|
146
|
+
* @returns {{ token: Token | null, diagnostic: Diagnostic | null }}
|
|
147
|
+
*/
|
|
148
|
+
expect(type, value) {
|
|
149
|
+
const token = this.peek();
|
|
150
|
+
if (!token || token.type !== type) {
|
|
151
|
+
const diagnostic = {
|
|
152
|
+
severity: 'error',
|
|
153
|
+
message: `Expected ${type}${value !== undefined ? ` with value "${value}"` : ''}, got ${token ? token.type : 'EOF'}`,
|
|
154
|
+
source: token ? token.span : undefined,
|
|
155
|
+
};
|
|
156
|
+
this.diagnostics.push(diagnostic);
|
|
157
|
+
return { token: null, diagnostic };
|
|
158
|
+
}
|
|
159
|
+
if (value !== undefined && token.value !== value) {
|
|
160
|
+
const diagnostic = {
|
|
161
|
+
severity: 'error',
|
|
162
|
+
message: `Expected ${type} with value "${value}", got "${token.value}"`,
|
|
163
|
+
source: token.span,
|
|
164
|
+
};
|
|
165
|
+
this.diagnostics.push(diagnostic);
|
|
166
|
+
return { token: null, diagnostic };
|
|
167
|
+
}
|
|
168
|
+
// Success: advance and return token
|
|
169
|
+
this.advance();
|
|
170
|
+
return { token, diagnostic: null };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} [value]
|
|
175
|
+
* @returns {{ token: Token | null, diagnostic: Diagnostic | null }}
|
|
176
|
+
*/
|
|
177
|
+
expectIdentifierOrKeyword(value) {
|
|
178
|
+
const token = this.peek();
|
|
179
|
+
if (!token || (token.type !== 'KEYWORD' && token.type !== 'IDENTIFIER')) {
|
|
180
|
+
const diagnostic = {
|
|
181
|
+
severity: 'error',
|
|
182
|
+
message: `Expected identifier or keyword${value !== undefined ? ` "${value}"` : ''}, got ${token ? token.type : 'EOF'}`,
|
|
183
|
+
source: token ? token.span : undefined,
|
|
184
|
+
};
|
|
185
|
+
this.diagnostics.push(diagnostic);
|
|
186
|
+
return { token: null, diagnostic };
|
|
187
|
+
}
|
|
188
|
+
if (value !== undefined && token.value !== value) {
|
|
189
|
+
const diagnostic = {
|
|
190
|
+
severity: 'error',
|
|
191
|
+
message: `Expected identifier or keyword "${value}", got "${token.value}"`,
|
|
192
|
+
source: token.span,
|
|
193
|
+
};
|
|
194
|
+
this.diagnostics.push(diagnostic);
|
|
195
|
+
return { token: null, diagnostic };
|
|
196
|
+
}
|
|
197
|
+
// Success: advance and return token
|
|
198
|
+
this.advance();
|
|
199
|
+
return { token, diagnostic: null };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {string} value
|
|
204
|
+
* @returns {{ token: Token | null, diagnostic: Diagnostic | null }}
|
|
205
|
+
*/
|
|
206
|
+
expectKindToken(value) {
|
|
207
|
+
return this.expectIdentifierOrKeyword(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Reads an identifier name (IDENTIFIER or KEYWORD)
|
|
212
|
+
* @returns {string | null}
|
|
213
|
+
*/
|
|
214
|
+
readIdent() {
|
|
215
|
+
const token = this.peek();
|
|
216
|
+
if (!token || (token.type !== 'IDENTIFIER' && token.type !== 'KEYWORD')) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
this.advance();
|
|
220
|
+
return token.value;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Consumes an identifier, reporting diagnostic if missing
|
|
225
|
+
* @returns {string | null}
|
|
226
|
+
*/
|
|
227
|
+
consumeIdentifier() {
|
|
228
|
+
const { token, diagnostic } = this.expectIdentifierOrKeyword();
|
|
229
|
+
return token ? token.value : null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Parses an identifier path (IDENTIFIER (DOT IDENTIFIER)*)
|
|
234
|
+
* @returns {string | null}
|
|
235
|
+
*/
|
|
236
|
+
parseIdentifierPath() {
|
|
237
|
+
if (!this.match('IDENTIFIER') && !this.match('KEYWORD')) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const parts = [];
|
|
242
|
+
const firstToken = this.advance();
|
|
243
|
+
if (firstToken) {
|
|
244
|
+
parts.push(firstToken.value);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
while (this.match('DOT')) {
|
|
248
|
+
this.advance(); // consume DOT
|
|
249
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
250
|
+
const partToken = this.advance();
|
|
251
|
+
if (partToken) {
|
|
252
|
+
parts.push(partToken.value);
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return parts.join('.');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Reports a diagnostic
|
|
264
|
+
* @param {'error' | 'warning'} severity
|
|
265
|
+
* @param {string} message
|
|
266
|
+
* @param {SourceSpan} [span]
|
|
267
|
+
*/
|
|
268
|
+
reportDiagnostic(severity, message, span) {
|
|
269
|
+
this.diagnostics.push({
|
|
270
|
+
severity,
|
|
271
|
+
message,
|
|
272
|
+
source: span,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Creates a span from start and end tokens
|
|
278
|
+
* @param {Token} startToken
|
|
279
|
+
* @param {Token} endToken
|
|
280
|
+
* @returns {SourceSpan}
|
|
281
|
+
*/
|
|
282
|
+
createSpan(startToken, endToken) {
|
|
283
|
+
return {
|
|
284
|
+
file: this.filePath,
|
|
285
|
+
start: startToken.span.start,
|
|
286
|
+
end: endToken.span.end,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Creates a span from a single token
|
|
292
|
+
* @param {Token} token
|
|
293
|
+
* @returns {SourceSpan}
|
|
294
|
+
*/
|
|
295
|
+
spanFromToken(token) {
|
|
296
|
+
return token.span;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Parses tokens into an AST with diagnostics
|
|
302
|
+
* @param {{ tokens: Token[], sourceText: string, filePath: string }} options
|
|
303
|
+
* @returns {{ ast: FileAST, diags: Diagnostic[] }}
|
|
304
|
+
*/
|
|
305
|
+
export function parseUniverse({ tokens, sourceText, filePath }) {
|
|
306
|
+
const parser = new ParserCore(tokens, filePath, sourceText);
|
|
307
|
+
const ast = parser.parseFile();
|
|
308
|
+
return {
|
|
309
|
+
ast,
|
|
310
|
+
diags: parser.diagnostics,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Declaration parselets: keyword → parse function
|
|
316
|
+
* @type {Map<string, (p: ParserCore) => any>}
|
|
317
|
+
*/
|
|
318
|
+
const declParselets = new Map([
|
|
319
|
+
['universe', (p) => p.parseUniverseDecl()],
|
|
320
|
+
['anthology', (p) => p.parseContainerDecl('anthology')],
|
|
321
|
+
['series', (p) => p.parseContainerDecl('series')],
|
|
322
|
+
['book', (p) => p.parseContainerDecl('book')],
|
|
323
|
+
['chapter', (p) => p.parseContainerDecl('chapter')],
|
|
324
|
+
['concept', (p) => p.parseContainerDecl('concept')],
|
|
325
|
+
['relates', (p) => p.parseRelatesDecl()],
|
|
326
|
+
['relationship', (p) => p.parseRelationshipDecl()],
|
|
327
|
+
['alias', (p) => p.parseAliasDecl()],
|
|
328
|
+
['aliases', (p) => p.parseAliasesBlock()],
|
|
329
|
+
['repository', (p) => p.parseRepositoryDecl()],
|
|
330
|
+
['reference', (p) => p.parseNamedReferenceDecl()],
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Block parselets: keyword → parse function
|
|
335
|
+
* @type {Map<string, (p: ParserCore) => any>}
|
|
336
|
+
*/
|
|
337
|
+
const blockParselets = new Map([
|
|
338
|
+
['describe', (p) => p.parseDescribeBlock()],
|
|
339
|
+
['title', (p) => p.parseTitleBlock()],
|
|
340
|
+
['note', (p) => p.parseNoteBlock()],
|
|
341
|
+
['from', (p) => p.parseFromBlock()],
|
|
342
|
+
['label', (p) => p.parseLabelBlock()],
|
|
343
|
+
['reference', (p) => p.parseReferenceBlock()],
|
|
344
|
+
['references', (p) => p.parseReferencesBlock()],
|
|
345
|
+
['relationships', (p) => p.parseRelationshipsBlock()],
|
|
346
|
+
]);
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Parses a file (top-level declarations)
|
|
350
|
+
* @returns {FileAST}
|
|
351
|
+
*/
|
|
352
|
+
ParserCore.prototype.parseFile = function () {
|
|
353
|
+
const decls = [];
|
|
354
|
+
const startToken = this.peek();
|
|
355
|
+
|
|
356
|
+
while (!this.isAtEnd()) {
|
|
357
|
+
const token = this.peek();
|
|
358
|
+
if (!token) break;
|
|
359
|
+
|
|
360
|
+
if (token.type === 'KEYWORD') {
|
|
361
|
+
const keyword = token.value;
|
|
362
|
+
const parselet = declParselets.get(keyword);
|
|
363
|
+
if (parselet) {
|
|
364
|
+
const decl = parselet(this);
|
|
365
|
+
if (decl) {
|
|
366
|
+
decls.push(decl);
|
|
367
|
+
}
|
|
368
|
+
} else {
|
|
369
|
+
// Try aliased container declaration before reporting error
|
|
370
|
+
const aliasedDecl = this.parseAliasedContainerDeclIfPresent();
|
|
371
|
+
if (aliasedDecl) {
|
|
372
|
+
decls.push(aliasedDecl);
|
|
373
|
+
} else {
|
|
374
|
+
// Unknown keyword - report diagnostic and skip
|
|
375
|
+
this.reportDiagnostic('error', `Unexpected keyword "${keyword}" at top level`, token.span);
|
|
376
|
+
this.advance();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} else if (token.type === 'IDENTIFIER') {
|
|
380
|
+
// Handle 'repository' and 'reference' even if they're IDENTIFIER tokens (not keywords)
|
|
381
|
+
const identifierValue = token.value;
|
|
382
|
+
if (identifierValue === 'repository') {
|
|
383
|
+
const parselet = declParselets.get('repository');
|
|
384
|
+
if (parselet) {
|
|
385
|
+
const decl = parselet(this);
|
|
386
|
+
if (decl) {
|
|
387
|
+
decls.push(decl);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (identifierValue === 'reference') {
|
|
393
|
+
const parselet = declParselets.get('reference');
|
|
394
|
+
if (parselet) {
|
|
395
|
+
const decl = parselet(this);
|
|
396
|
+
if (decl) {
|
|
397
|
+
decls.push(decl);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
// Try aliased container declaration before reporting error
|
|
403
|
+
const aliasedDecl = this.parseAliasedContainerDeclIfPresent();
|
|
404
|
+
if (aliasedDecl) {
|
|
405
|
+
decls.push(aliasedDecl);
|
|
406
|
+
} else {
|
|
407
|
+
// IDENTIFIER at top level - report and skip
|
|
408
|
+
this.reportDiagnostic('error', `Unexpected identifier "${token.value}" at top level`, token.span);
|
|
409
|
+
this.advance();
|
|
410
|
+
}
|
|
411
|
+
} else if (token.type === 'EOF') {
|
|
412
|
+
break;
|
|
413
|
+
} else {
|
|
414
|
+
// Unexpected token - skip (ensure we advance to avoid infinite loops)
|
|
415
|
+
this.advance();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const endToken = this.previous() || startToken;
|
|
420
|
+
return {
|
|
421
|
+
kind: 'file',
|
|
422
|
+
decls,
|
|
423
|
+
source: startToken && endToken ? this.createSpan(startToken, endToken) : undefined,
|
|
424
|
+
};
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Parses a body until closing brace
|
|
429
|
+
* @returns {any[]}
|
|
430
|
+
*/
|
|
431
|
+
ParserCore.prototype.parseBodyUntilRBrace = function () {
|
|
432
|
+
const body = [];
|
|
433
|
+
|
|
434
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
435
|
+
const token = this.peek();
|
|
436
|
+
if (!token) break;
|
|
437
|
+
|
|
438
|
+
if (token.type === 'KEYWORD') {
|
|
439
|
+
const keyword = token.value;
|
|
440
|
+
// Special handling for 'reference' in body context
|
|
441
|
+
// Check if it's a named reference decl (reference <Name> {) or a block (reference [in X] {)
|
|
442
|
+
if (keyword === 'reference') {
|
|
443
|
+
// Peek ahead to see if next token is a named decl, including optional "in <Repo>"
|
|
444
|
+
const nextToken = this.tokens[this.index + 1];
|
|
445
|
+
const tokenAfterNext = this.tokens[this.index + 2];
|
|
446
|
+
const tokenAfterIn = this.tokens[this.index + 3];
|
|
447
|
+
const tokenAfterInLbrace = this.tokens[this.index + 4];
|
|
448
|
+
const hasNameToken = nextToken && (nextToken.type === 'IDENTIFIER' || nextToken.type === 'KEYWORD');
|
|
449
|
+
const isNamedDeclDirect = hasNameToken && tokenAfterNext && tokenAfterNext.type === 'LBRACE';
|
|
450
|
+
const isNamedDeclWithRepo = hasNameToken &&
|
|
451
|
+
tokenAfterNext && tokenAfterNext.type === 'KEYWORD' && tokenAfterNext.value === 'in' &&
|
|
452
|
+
tokenAfterIn && (tokenAfterIn.type === 'IDENTIFIER' || tokenAfterIn.type === 'KEYWORD') &&
|
|
453
|
+
tokenAfterInLbrace && tokenAfterInLbrace.type === 'LBRACE';
|
|
454
|
+
// If next is IDENTIFIER/KEYWORD and then LBRACE (or "in" + Repo + LBRACE), it's a named decl
|
|
455
|
+
if (isNamedDeclDirect || isNamedDeclWithRepo) {
|
|
456
|
+
// Parse as named reference declaration
|
|
457
|
+
const declParselet = declParselets.get('reference');
|
|
458
|
+
if (declParselet) {
|
|
459
|
+
const decl = declParselet(this);
|
|
460
|
+
if (decl) {
|
|
461
|
+
body.push(decl);
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Otherwise parse as reference block
|
|
467
|
+
const blockParselet = blockParselets.get('reference');
|
|
468
|
+
if (blockParselet) {
|
|
469
|
+
const block = blockParselet(this);
|
|
470
|
+
if (block) {
|
|
471
|
+
body.push(block);
|
|
472
|
+
}
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
// If both failed, report error and skip
|
|
476
|
+
this.reportDiagnostic('error', `Unexpected reference keyword in body`, token.span);
|
|
477
|
+
this.advance();
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
// Check declaration parselets first (for nested decls)
|
|
481
|
+
const declParselet = declParselets.get(keyword);
|
|
482
|
+
if (declParselet) {
|
|
483
|
+
const decl = declParselet(this);
|
|
484
|
+
if (decl) {
|
|
485
|
+
body.push(decl);
|
|
486
|
+
}
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
// Check block parselets
|
|
490
|
+
const blockParselet = blockParselets.get(keyword);
|
|
491
|
+
if (blockParselet) {
|
|
492
|
+
const block = blockParselet(this);
|
|
493
|
+
if (block) {
|
|
494
|
+
body.push(block);
|
|
495
|
+
}
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
// Try aliased container declaration
|
|
499
|
+
const aliasedDecl = this.parseAliasedContainerDeclIfPresent();
|
|
500
|
+
if (aliasedDecl) {
|
|
501
|
+
body.push(aliasedDecl);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
// No implicit relationship usage blocks; require explicit relationships { ... }
|
|
505
|
+
// Unknown keyword - report and skip
|
|
506
|
+
this.reportDiagnostic('error', `Unexpected keyword "${keyword}" in body`, token.span);
|
|
507
|
+
this.advance();
|
|
508
|
+
} else if (token.type === 'IDENTIFIER') {
|
|
509
|
+
// Handle 'repository' and 'reference' even if they're IDENTIFIER tokens
|
|
510
|
+
const identifierValue = token.value;
|
|
511
|
+
if (identifierValue === 'repository') {
|
|
512
|
+
const parselet = declParselets.get('repository');
|
|
513
|
+
if (parselet) {
|
|
514
|
+
const decl = parselet(this);
|
|
515
|
+
if (decl) {
|
|
516
|
+
body.push(decl);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (identifierValue === 'reference') {
|
|
522
|
+
// In body context, 'reference' should be a block, not a named decl
|
|
523
|
+
const blockParselet = blockParselets.get('reference');
|
|
524
|
+
if (blockParselet) {
|
|
525
|
+
const block = blockParselet(this);
|
|
526
|
+
if (block) {
|
|
527
|
+
body.push(block);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
// Try aliased container declaration first
|
|
533
|
+
const aliasedDecl = this.parseAliasedContainerDeclIfPresent();
|
|
534
|
+
if (aliasedDecl) {
|
|
535
|
+
body.push(aliasedDecl);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
// No implicit relationship usage blocks; require explicit relationships { ... }
|
|
539
|
+
// Avoid stalling on bare identifiers.
|
|
540
|
+
this.reportDiagnostic('error', `Unexpected identifier "${identifierValue}" in body`, token.span);
|
|
541
|
+
this.advance();
|
|
542
|
+
} else if (token.type === 'RBRACE') {
|
|
543
|
+
break;
|
|
544
|
+
} else {
|
|
545
|
+
// Unexpected token - report and skip (ensure we advance to avoid infinite loops)
|
|
546
|
+
this.reportDiagnostic('error', `Unexpected token ${token.type} in body`, token.span);
|
|
547
|
+
this.advance();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return body;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Parses raw content block by brace matching (returns spans only, no raw string)
|
|
556
|
+
* @param {string} kind - Block kind ('describe', 'title', 'note')
|
|
557
|
+
* @param {Token} keywordToken - The keyword token
|
|
558
|
+
* @returns {{ kind: string, contentSpan: { startOffset: number, endOffset: number }, span: SourceSpan } | null}
|
|
559
|
+
*/
|
|
560
|
+
ParserCore.prototype.parseRawContentBlock = function (kind, keywordToken) {
|
|
561
|
+
const { token: lbrace, diagnostic } = this.expect('LBRACE');
|
|
562
|
+
if (!lbrace) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Find matching closing brace by tracking depth
|
|
567
|
+
let depth = 1;
|
|
568
|
+
const startOffset = lbrace.span.end.offset;
|
|
569
|
+
let endOffset = startOffset;
|
|
570
|
+
let endToken = null;
|
|
571
|
+
|
|
572
|
+
while (depth > 0 && this.index < this.tokens.length) {
|
|
573
|
+
const token = this.tokens[this.index];
|
|
574
|
+
if (token.type === 'EOF') break;
|
|
575
|
+
|
|
576
|
+
if (token.type === 'LBRACE') {
|
|
577
|
+
depth++;
|
|
578
|
+
this.index++;
|
|
579
|
+
} else if (token.type === 'RBRACE') {
|
|
580
|
+
depth--;
|
|
581
|
+
if (depth === 0) {
|
|
582
|
+
endToken = token;
|
|
583
|
+
endOffset = token.span.start.offset;
|
|
584
|
+
this.index++;
|
|
585
|
+
break;
|
|
586
|
+
} else {
|
|
587
|
+
this.index++;
|
|
588
|
+
}
|
|
589
|
+
} else {
|
|
590
|
+
this.index++;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (depth > 0) {
|
|
595
|
+
this.reportDiagnostic('error', `Unclosed ${kind} block`, keywordToken.span);
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
kind,
|
|
601
|
+
contentSpan: {
|
|
602
|
+
startOffset,
|
|
603
|
+
endOffset,
|
|
604
|
+
},
|
|
605
|
+
span: this.createSpan(keywordToken, endToken || lbrace),
|
|
606
|
+
};
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// ============================================================================
|
|
610
|
+
// Parselet Implementations
|
|
611
|
+
// ============================================================================
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Parses a universe declaration
|
|
615
|
+
* @returns {UniverseDecl | null}
|
|
616
|
+
*/
|
|
617
|
+
ParserCore.prototype.parseUniverseDecl = function () {
|
|
618
|
+
const startToken = this.peek();
|
|
619
|
+
if (!startToken) return null;
|
|
620
|
+
|
|
621
|
+
const { token: kindToken } = this.expectKindToken('universe');
|
|
622
|
+
if (!kindToken) return null;
|
|
623
|
+
|
|
624
|
+
const name = this.consumeIdentifier();
|
|
625
|
+
if (!name) return null;
|
|
626
|
+
|
|
627
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
628
|
+
if (!lbrace) return null;
|
|
629
|
+
|
|
630
|
+
const body = this.parseBodyUntilRBrace();
|
|
631
|
+
|
|
632
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
633
|
+
if (!rbrace) return null;
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
kind: 'universe',
|
|
637
|
+
spelledKind: kindToken.value,
|
|
638
|
+
name,
|
|
639
|
+
body,
|
|
640
|
+
source: this.createSpan(kindToken, rbrace),
|
|
641
|
+
};
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Parses container declarations (anthology, series, book, chapter, concept)
|
|
646
|
+
* @param {string} kind
|
|
647
|
+
* @returns {any | null}
|
|
648
|
+
*/
|
|
649
|
+
ParserCore.prototype.parseContainerDecl = function (kind) {
|
|
650
|
+
const startToken = this.peek();
|
|
651
|
+
if (!startToken) return null;
|
|
652
|
+
|
|
653
|
+
const { token: kindToken } = this.expectKindToken(kind);
|
|
654
|
+
if (!kindToken) return null;
|
|
655
|
+
|
|
656
|
+
const name = this.consumeIdentifier();
|
|
657
|
+
if (!name) return null;
|
|
658
|
+
|
|
659
|
+
// Parse optional "in ParentName" clause
|
|
660
|
+
let parentName = undefined;
|
|
661
|
+
if (this.match('KEYWORD', 'in')) {
|
|
662
|
+
this.advance(); // consume 'in'
|
|
663
|
+
const parentToken = this.peek();
|
|
664
|
+
if (parentToken && (parentToken.type === 'IDENTIFIER' || parentToken.type === 'KEYWORD')) {
|
|
665
|
+
parentName = this.parseIdentifierPath();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
670
|
+
if (!lbrace) return null;
|
|
671
|
+
|
|
672
|
+
const body = this.parseBodyUntilRBrace();
|
|
673
|
+
|
|
674
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
675
|
+
if (!rbrace) return null;
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
kind,
|
|
679
|
+
spelledKind: kindToken.value,
|
|
680
|
+
name,
|
|
681
|
+
parentName,
|
|
682
|
+
body,
|
|
683
|
+
source: this.createSpan(kindToken, rbrace),
|
|
684
|
+
};
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Parses relates declarations: relates A and B { ... }
|
|
689
|
+
* @returns {RelatesDecl | null}
|
|
690
|
+
*/
|
|
691
|
+
ParserCore.prototype.parseRelatesDecl = function () {
|
|
692
|
+
const startToken = this.peek();
|
|
693
|
+
if (!startToken) return null;
|
|
694
|
+
|
|
695
|
+
const { token: kindToken } = this.expectKindToken('relates');
|
|
696
|
+
if (!kindToken) return null;
|
|
697
|
+
|
|
698
|
+
const a = this.consumeIdentifier();
|
|
699
|
+
if (!a) return null;
|
|
700
|
+
|
|
701
|
+
const { token: andToken } = this.expect('KEYWORD', 'and');
|
|
702
|
+
if (!andToken) return null;
|
|
703
|
+
|
|
704
|
+
const b = this.consumeIdentifier();
|
|
705
|
+
if (!b) return null;
|
|
706
|
+
|
|
707
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
708
|
+
if (!lbrace) return null;
|
|
709
|
+
|
|
710
|
+
const body = this.parseBodyUntilRBrace();
|
|
711
|
+
|
|
712
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
713
|
+
if (!rbrace) return null;
|
|
714
|
+
|
|
715
|
+
return {
|
|
716
|
+
kind: 'relates',
|
|
717
|
+
spelledKind: kindToken.value,
|
|
718
|
+
a,
|
|
719
|
+
b,
|
|
720
|
+
body,
|
|
721
|
+
source: this.createSpan(kindToken, rbrace),
|
|
722
|
+
};
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Parses relationship declarations: relationship <id> [and <id2>] { ... }
|
|
727
|
+
* @returns {RelationshipDecl | null}
|
|
728
|
+
*/
|
|
729
|
+
ParserCore.prototype.parseRelationshipDecl = function () {
|
|
730
|
+
const startToken = this.peek();
|
|
731
|
+
if (!startToken) return null;
|
|
732
|
+
|
|
733
|
+
const { token: kindToken } = this.expectKindToken('relationship');
|
|
734
|
+
if (!kindToken) return null;
|
|
735
|
+
|
|
736
|
+
const firstIdToken = this.peek();
|
|
737
|
+
if (!firstIdToken || (firstIdToken.type !== 'IDENTIFIER' && firstIdToken.type !== 'KEYWORD')) {
|
|
738
|
+
this.reportDiagnostic('error', 'Expected identifier for relationship', startToken.span);
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
this.advance();
|
|
742
|
+
const ids = [firstIdToken.value];
|
|
743
|
+
|
|
744
|
+
// Optional "and <id2>" for paired relationships
|
|
745
|
+
if (this.match('KEYWORD', 'and')) {
|
|
746
|
+
this.advance(); // consume 'and'
|
|
747
|
+
const secondIdToken = this.peek();
|
|
748
|
+
if (secondIdToken && (secondIdToken.type === 'IDENTIFIER' || secondIdToken.type === 'KEYWORD')) {
|
|
749
|
+
this.advance();
|
|
750
|
+
ids.push(secondIdToken.value);
|
|
751
|
+
} else {
|
|
752
|
+
this.reportDiagnostic('error', 'Expected identifier after "and"', secondIdToken?.span || startToken.span);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
757
|
+
if (!lbrace) return null;
|
|
758
|
+
|
|
759
|
+
const body = this.parseBodyUntilRBrace();
|
|
760
|
+
|
|
761
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
762
|
+
if (!rbrace) return null;
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
kind: 'relationshipDecl',
|
|
766
|
+
ids,
|
|
767
|
+
body,
|
|
768
|
+
span: this.createSpan(kindToken, rbrace),
|
|
769
|
+
};
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Parses alias declaration: alias Name { TargetKind }
|
|
774
|
+
* @returns {any | null}
|
|
775
|
+
*/
|
|
776
|
+
ParserCore.prototype.parseAliasDecl = function () {
|
|
777
|
+
const startToken = this.peek();
|
|
778
|
+
if (!startToken) return null;
|
|
779
|
+
|
|
780
|
+
const { token: aliasToken } = this.expectIdentifierOrKeyword('alias');
|
|
781
|
+
if (!aliasToken) return null;
|
|
782
|
+
|
|
783
|
+
const name = this.consumeIdentifier();
|
|
784
|
+
if (!name) return null;
|
|
785
|
+
|
|
786
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
787
|
+
if (!lbrace) return null;
|
|
788
|
+
|
|
789
|
+
const targetKind = this.consumeIdentifier();
|
|
790
|
+
if (!targetKind) return null;
|
|
791
|
+
|
|
792
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
793
|
+
if (!rbrace) return null;
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
kind: 'alias',
|
|
797
|
+
name,
|
|
798
|
+
targetKind,
|
|
799
|
+
source: this.createSpan(aliasToken, rbrace),
|
|
800
|
+
};
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Parses aliases block: aliases { Name { TargetKind } ... }
|
|
805
|
+
* @returns {any | null}
|
|
806
|
+
*/
|
|
807
|
+
ParserCore.prototype.parseAliasesBlock = function () {
|
|
808
|
+
const startToken = this.peek();
|
|
809
|
+
if (!startToken) return null;
|
|
810
|
+
|
|
811
|
+
const { token: aliasesToken } = this.expectIdentifierOrKeyword('aliases');
|
|
812
|
+
if (!aliasesToken) return null;
|
|
813
|
+
|
|
814
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
815
|
+
if (!lbrace) return null;
|
|
816
|
+
|
|
817
|
+
const aliases = [];
|
|
818
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
819
|
+
const name = this.consumeIdentifier();
|
|
820
|
+
if (!name) break;
|
|
821
|
+
|
|
822
|
+
const { token: innerLbrace } = this.expect('LBRACE');
|
|
823
|
+
if (!innerLbrace) break;
|
|
824
|
+
|
|
825
|
+
const targetKind = this.consumeIdentifier();
|
|
826
|
+
if (!targetKind) break;
|
|
827
|
+
|
|
828
|
+
const { token: innerRbrace } = this.expect('RBRACE');
|
|
829
|
+
if (!innerRbrace) break;
|
|
830
|
+
|
|
831
|
+
aliases.push({ name, targetKind });
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
835
|
+
if (!rbrace) return null;
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
kind: 'aliases',
|
|
839
|
+
aliases,
|
|
840
|
+
source: this.createSpan(aliasesToken, rbrace),
|
|
841
|
+
};
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Parses a describe block (spans only, no raw string)
|
|
846
|
+
* @returns {DescribeBlock | null}
|
|
847
|
+
*/
|
|
848
|
+
ParserCore.prototype.parseDescribeBlock = function () {
|
|
849
|
+
const startToken = this.peek();
|
|
850
|
+
if (!startToken) return null;
|
|
851
|
+
|
|
852
|
+
const { token: keywordToken } = this.expect('KEYWORD', 'describe');
|
|
853
|
+
if (!keywordToken) return null;
|
|
854
|
+
|
|
855
|
+
const block = this.parseRawContentBlock('describe', keywordToken);
|
|
856
|
+
if (!block) return null;
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
kind: 'describe',
|
|
860
|
+
contentSpan: block.contentSpan,
|
|
861
|
+
span: block.span,
|
|
862
|
+
};
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Parses a title block (spans only, no raw string)
|
|
867
|
+
* @returns {TitleBlock | null}
|
|
868
|
+
*/
|
|
869
|
+
ParserCore.prototype.parseTitleBlock = function () {
|
|
870
|
+
const startToken = this.peek();
|
|
871
|
+
if (!startToken) return null;
|
|
872
|
+
|
|
873
|
+
const { token: keywordToken } = this.expect('KEYWORD', 'title');
|
|
874
|
+
if (!keywordToken) return null;
|
|
875
|
+
|
|
876
|
+
const block = this.parseRawContentBlock('title', keywordToken);
|
|
877
|
+
if (!block) return null;
|
|
878
|
+
|
|
879
|
+
return {
|
|
880
|
+
kind: 'title',
|
|
881
|
+
contentSpan: block.contentSpan,
|
|
882
|
+
span: block.span,
|
|
883
|
+
};
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Parses a note block (spans only, no raw string)
|
|
888
|
+
* @returns {NoteBlock | null}
|
|
889
|
+
*/
|
|
890
|
+
ParserCore.prototype.parseNoteBlock = function () {
|
|
891
|
+
const startToken = this.peek();
|
|
892
|
+
if (!startToken) return null;
|
|
893
|
+
|
|
894
|
+
const { token: keywordToken } = this.expect('KEYWORD', 'note');
|
|
895
|
+
if (!keywordToken) return null;
|
|
896
|
+
|
|
897
|
+
const block = this.parseRawContentBlock('note', keywordToken);
|
|
898
|
+
if (!block) return null;
|
|
899
|
+
|
|
900
|
+
return {
|
|
901
|
+
kind: 'note',
|
|
902
|
+
contentSpan: block.contentSpan,
|
|
903
|
+
span: block.span,
|
|
904
|
+
};
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Parses from block: from Endpoint { ... }
|
|
909
|
+
* @returns {any | null}
|
|
910
|
+
*/
|
|
911
|
+
ParserCore.prototype.parseFromBlock = function () {
|
|
912
|
+
const startToken = this.peek();
|
|
913
|
+
if (!startToken) return null;
|
|
914
|
+
|
|
915
|
+
const { token: fromToken } = this.expect('KEYWORD', 'from');
|
|
916
|
+
if (!fromToken) return null;
|
|
917
|
+
|
|
918
|
+
const endpoint = this.consumeIdentifier();
|
|
919
|
+
if (!endpoint) return null;
|
|
920
|
+
|
|
921
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
922
|
+
if (!lbrace) return null;
|
|
923
|
+
|
|
924
|
+
const body = this.parseBodyUntilRBrace();
|
|
925
|
+
|
|
926
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
927
|
+
if (!rbrace) return null;
|
|
928
|
+
|
|
929
|
+
return {
|
|
930
|
+
kind: 'from',
|
|
931
|
+
endpoint,
|
|
932
|
+
body,
|
|
933
|
+
source: this.createSpan(fromToken, rbrace),
|
|
934
|
+
};
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Parses a label block (placeholder - similar to raw content blocks)
|
|
939
|
+
* @returns {any | null}
|
|
940
|
+
*/
|
|
941
|
+
ParserCore.prototype.parseLabelBlock = function () {
|
|
942
|
+
const startToken = this.peek();
|
|
943
|
+
if (!startToken) return null;
|
|
944
|
+
|
|
945
|
+
const { token: labelToken } = this.expectIdentifierOrKeyword('label');
|
|
946
|
+
if (!labelToken) return null;
|
|
947
|
+
|
|
948
|
+
// Parse as raw content block (it will handle LBRACE)
|
|
949
|
+
const block = this.parseRawContentBlock('label', labelToken);
|
|
950
|
+
if (!block) return null;
|
|
951
|
+
|
|
952
|
+
return {
|
|
953
|
+
kind: 'label',
|
|
954
|
+
contentSpan: block.contentSpan,
|
|
955
|
+
span: block.span,
|
|
956
|
+
};
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Parses a repository declaration: repository <Name> [in <Parent>] { ... }
|
|
961
|
+
* @returns {any | null}
|
|
962
|
+
*/
|
|
963
|
+
ParserCore.prototype.parseRepositoryDecl = function () {
|
|
964
|
+
const startToken = this.peek();
|
|
965
|
+
if (!startToken) return null;
|
|
966
|
+
|
|
967
|
+
const { token: kindToken } = this.expectKindToken('repository');
|
|
968
|
+
if (!kindToken) return null;
|
|
969
|
+
|
|
970
|
+
const name = this.consumeIdentifier();
|
|
971
|
+
if (!name) return null;
|
|
972
|
+
|
|
973
|
+
// Parse optional "in ParentName" clause
|
|
974
|
+
let parentName = undefined;
|
|
975
|
+
if (this.match('KEYWORD', 'in')) {
|
|
976
|
+
this.advance(); // consume 'in'
|
|
977
|
+
const parentToken = this.peek();
|
|
978
|
+
if (parentToken && (parentToken.type === 'IDENTIFIER' || parentToken.type === 'KEYWORD')) {
|
|
979
|
+
parentName = this.parseIdentifierPath();
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
984
|
+
if (!lbrace) return null;
|
|
985
|
+
|
|
986
|
+
const body = this.parseRepositoryBody();
|
|
987
|
+
|
|
988
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
989
|
+
if (!rbrace) return null;
|
|
990
|
+
|
|
991
|
+
return {
|
|
992
|
+
kind: 'repository',
|
|
993
|
+
spelledKind: kindToken.value,
|
|
994
|
+
name,
|
|
995
|
+
parentName,
|
|
996
|
+
children: body,
|
|
997
|
+
source: this.createSpan(kindToken, rbrace),
|
|
998
|
+
};
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Parses a named reference declaration: reference <Name> { ... }
|
|
1003
|
+
* @returns {any | null}
|
|
1004
|
+
*/
|
|
1005
|
+
ParserCore.prototype.parseNamedReferenceDecl = function () {
|
|
1006
|
+
const startToken = this.peek();
|
|
1007
|
+
if (!startToken) return null;
|
|
1008
|
+
|
|
1009
|
+
const { token: kindToken } = this.expectKindToken('reference');
|
|
1010
|
+
if (!kindToken) return null;
|
|
1011
|
+
|
|
1012
|
+
const name = this.consumeIdentifier();
|
|
1013
|
+
if (!name) return null;
|
|
1014
|
+
|
|
1015
|
+
// Optional "in <RepositoryName>" clause
|
|
1016
|
+
let repositoryName = undefined;
|
|
1017
|
+
if (this.match('KEYWORD', 'in')) {
|
|
1018
|
+
this.advance();
|
|
1019
|
+
repositoryName = this.parseIdentifierPath();
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
1023
|
+
if (!lbrace) return null;
|
|
1024
|
+
|
|
1025
|
+
const body = this.parseReferenceBody();
|
|
1026
|
+
|
|
1027
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
1028
|
+
if (!rbrace) return null;
|
|
1029
|
+
|
|
1030
|
+
return {
|
|
1031
|
+
kind: 'referenceDecl',
|
|
1032
|
+
name,
|
|
1033
|
+
repositoryName,
|
|
1034
|
+
children: body,
|
|
1035
|
+
source: this.createSpan(kindToken, rbrace),
|
|
1036
|
+
};
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Parses a reference block: reference [in <RepositoryName>] { ... }
|
|
1041
|
+
* Used inside container bodies (unnamed reference)
|
|
1042
|
+
* @returns {any | null}
|
|
1043
|
+
*/
|
|
1044
|
+
ParserCore.prototype.parseReferenceBlock = function () {
|
|
1045
|
+
const startToken = this.peek();
|
|
1046
|
+
if (!startToken) return null;
|
|
1047
|
+
|
|
1048
|
+
const { token: refToken } = this.expect('KEYWORD', 'reference');
|
|
1049
|
+
if (!refToken) return null;
|
|
1050
|
+
|
|
1051
|
+
// Parse optional "in RepositoryName" clause
|
|
1052
|
+
let repositoryName = undefined;
|
|
1053
|
+
if (this.match('KEYWORD', 'in')) {
|
|
1054
|
+
this.advance(); // consume 'in'
|
|
1055
|
+
const repoToken = this.peek();
|
|
1056
|
+
if (repoToken && (repoToken.type === 'IDENTIFIER' || repoToken.type === 'KEYWORD')) {
|
|
1057
|
+
repositoryName = this.parseIdentifierPath();
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
1062
|
+
if (!lbrace) return null;
|
|
1063
|
+
|
|
1064
|
+
const body = this.parseReferenceBody();
|
|
1065
|
+
|
|
1066
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
1067
|
+
if (!rbrace) return null;
|
|
1068
|
+
|
|
1069
|
+
return {
|
|
1070
|
+
kind: 'reference',
|
|
1071
|
+
repositoryName,
|
|
1072
|
+
children: body,
|
|
1073
|
+
source: this.createSpan(refToken, rbrace),
|
|
1074
|
+
};
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Parses a references block: references { <items> }
|
|
1079
|
+
* Items are identifier paths (reference names)
|
|
1080
|
+
* @returns {any | null}
|
|
1081
|
+
*/
|
|
1082
|
+
ParserCore.prototype.parseReferencesBlock = function () {
|
|
1083
|
+
const startToken = this.peek();
|
|
1084
|
+
if (!startToken) return null;
|
|
1085
|
+
|
|
1086
|
+
const { token: refsToken } = this.expect('KEYWORD', 'references');
|
|
1087
|
+
if (!refsToken) return null;
|
|
1088
|
+
|
|
1089
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
1090
|
+
if (!lbrace) return null;
|
|
1091
|
+
|
|
1092
|
+
const items = [];
|
|
1093
|
+
|
|
1094
|
+
// Parse items until closing brace
|
|
1095
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
1096
|
+
// Skip optional commas
|
|
1097
|
+
if (this.match('COMMA')) {
|
|
1098
|
+
this.advance();
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const itemStartToken = this.peek();
|
|
1103
|
+
if (!itemStartToken) break;
|
|
1104
|
+
|
|
1105
|
+
// Parse reference name as identifier path
|
|
1106
|
+
const refPath = this.parseIdentifierPath();
|
|
1107
|
+
if (!refPath) {
|
|
1108
|
+
// Not a valid item - report and try to recover
|
|
1109
|
+
const token = this.peek();
|
|
1110
|
+
if (token) {
|
|
1111
|
+
this.reportDiagnostic('error', `Expected identifier path in references block`, token.span);
|
|
1112
|
+
this.advance(); // Advance to avoid infinite loop
|
|
1113
|
+
} else {
|
|
1114
|
+
break;
|
|
1115
|
+
}
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// After parseIdentifierPath, previous() gives us the last token of the path
|
|
1120
|
+
let refEndToken = this.previous();
|
|
1121
|
+
if (!refEndToken) refEndToken = itemStartToken;
|
|
1122
|
+
|
|
1123
|
+
// Create item span
|
|
1124
|
+
const itemSpan = itemStartToken && refEndToken
|
|
1125
|
+
? this.createSpan(itemStartToken, refEndToken)
|
|
1126
|
+
: (itemStartToken ? this.spanFromToken(itemStartToken) : undefined);
|
|
1127
|
+
|
|
1128
|
+
items.push({
|
|
1129
|
+
kind: 'ref',
|
|
1130
|
+
ref: refPath,
|
|
1131
|
+
span: itemSpan,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
1136
|
+
if (!rbrace) return null;
|
|
1137
|
+
|
|
1138
|
+
return {
|
|
1139
|
+
kind: 'references',
|
|
1140
|
+
items,
|
|
1141
|
+
source: this.createSpan(refsToken, rbrace),
|
|
1142
|
+
};
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Parses repository declaration body.
|
|
1147
|
+
* @returns {any[]}
|
|
1148
|
+
*/
|
|
1149
|
+
ParserCore.prototype.parseRepositoryBody = function () {
|
|
1150
|
+
const body = [];
|
|
1151
|
+
|
|
1152
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
1153
|
+
const token = this.peek();
|
|
1154
|
+
if (!token) break;
|
|
1155
|
+
|
|
1156
|
+
if (token.type === 'KEYWORD' || token.type === 'IDENTIFIER') {
|
|
1157
|
+
const keyword = token.value;
|
|
1158
|
+
if (keyword === 'url') {
|
|
1159
|
+
const block = this.parseStringValueBlock('url');
|
|
1160
|
+
if (block) body.push(block);
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
if (keyword === 'title') {
|
|
1164
|
+
const block = this.parseTitleBlock();
|
|
1165
|
+
if (block) body.push(block);
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (keyword === 'describe') {
|
|
1169
|
+
const block = this.parseDescribeBlock();
|
|
1170
|
+
if (block) body.push(block);
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
if (keyword === 'note') {
|
|
1174
|
+
const block = this.parseNoteBlock();
|
|
1175
|
+
if (block) body.push(block);
|
|
1176
|
+
continue;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
this.reportDiagnostic('error', `Unexpected token ${token.type} in repository body`, token.span);
|
|
1181
|
+
this.advance();
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
return body;
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Parses reference declaration body.
|
|
1189
|
+
* @returns {any[]}
|
|
1190
|
+
*/
|
|
1191
|
+
ParserCore.prototype.parseReferenceBody = function () {
|
|
1192
|
+
const body = [];
|
|
1193
|
+
|
|
1194
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
1195
|
+
const token = this.peek();
|
|
1196
|
+
if (!token) break;
|
|
1197
|
+
|
|
1198
|
+
if (token.type === 'KEYWORD' || token.type === 'IDENTIFIER') {
|
|
1199
|
+
const keyword = token.value;
|
|
1200
|
+
if (keyword === 'url') {
|
|
1201
|
+
const block = this.parseStringValueBlock('url');
|
|
1202
|
+
if (block) body.push(block);
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (keyword === 'paths') {
|
|
1206
|
+
const block = this.parsePathsBlock();
|
|
1207
|
+
if (block) body.push(block);
|
|
1208
|
+
continue;
|
|
1209
|
+
}
|
|
1210
|
+
if (keyword === 'kind') {
|
|
1211
|
+
const block = this.parseStringValueBlock('kind');
|
|
1212
|
+
if (block) body.push(block);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
if (keyword === 'title') {
|
|
1216
|
+
const block = this.parseTitleBlock();
|
|
1217
|
+
if (block) body.push(block);
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
if (keyword === 'describe') {
|
|
1221
|
+
const block = this.parseDescribeBlock();
|
|
1222
|
+
if (block) body.push(block);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
if (keyword === 'note') {
|
|
1226
|
+
const block = this.parseNoteBlock();
|
|
1227
|
+
if (block) body.push(block);
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
this.reportDiagnostic('error', `Unexpected token ${token.type} in reference body`, token.span);
|
|
1233
|
+
this.advance();
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
return body;
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Parses a string value block like url { '...' } or kind { '...' }.
|
|
1241
|
+
* @param {string} kind
|
|
1242
|
+
* @returns {{ kind: string, value: string, span: SourceSpan } | null}
|
|
1243
|
+
*/
|
|
1244
|
+
ParserCore.prototype.parseStringValueBlock = function (kind) {
|
|
1245
|
+
const startToken = this.peek();
|
|
1246
|
+
if (!startToken) return null;
|
|
1247
|
+
const { token: kindToken } = this.expectIdentifierOrKeyword(kind);
|
|
1248
|
+
if (!kindToken) return null;
|
|
1249
|
+
|
|
1250
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
1251
|
+
if (!lbrace) return null;
|
|
1252
|
+
|
|
1253
|
+
let valueToken = null;
|
|
1254
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
1255
|
+
if (this.match('STRING') || this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
1256
|
+
valueToken = this.advance();
|
|
1257
|
+
break;
|
|
1258
|
+
}
|
|
1259
|
+
this.advance();
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
1263
|
+
if (!rbrace) return null;
|
|
1264
|
+
if (!valueToken) {
|
|
1265
|
+
this.reportDiagnostic('error', `Expected value in ${kind} block`, kindToken.span);
|
|
1266
|
+
return null;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
return {
|
|
1270
|
+
kind,
|
|
1271
|
+
value: valueToken.value,
|
|
1272
|
+
span: this.createSpan(kindToken, rbrace),
|
|
1273
|
+
};
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* Parses a paths block: paths { 'a' 'b' }
|
|
1278
|
+
* @returns {{ kind: string, paths: string[], span: SourceSpan } | null}
|
|
1279
|
+
*/
|
|
1280
|
+
ParserCore.prototype.parsePathsBlock = function () {
|
|
1281
|
+
const startToken = this.peek();
|
|
1282
|
+
if (!startToken) return null;
|
|
1283
|
+
const { token: kindToken } = this.expectIdentifierOrKeyword('paths');
|
|
1284
|
+
if (!kindToken) return null;
|
|
1285
|
+
|
|
1286
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
1287
|
+
if (!lbrace) return null;
|
|
1288
|
+
|
|
1289
|
+
const paths = [];
|
|
1290
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
1291
|
+
if (this.match('COMMA')) {
|
|
1292
|
+
this.advance();
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
if (this.match('STRING')) {
|
|
1296
|
+
const valueToken = this.advance();
|
|
1297
|
+
if (valueToken) {
|
|
1298
|
+
paths.push(valueToken.value);
|
|
1299
|
+
}
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
this.advance();
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
1306
|
+
if (!rbrace) return null;
|
|
1307
|
+
|
|
1308
|
+
return {
|
|
1309
|
+
kind: 'paths',
|
|
1310
|
+
paths,
|
|
1311
|
+
span: this.createSpan(kindToken, rbrace),
|
|
1312
|
+
};
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Parses a relationships block
|
|
1317
|
+
* Supports legacy string syntax and new relationship/targets syntax
|
|
1318
|
+
* @returns {any | null}
|
|
1319
|
+
*/
|
|
1320
|
+
ParserCore.prototype.parseRelationshipsBlock = function () {
|
|
1321
|
+
const startToken = this.peek();
|
|
1322
|
+
if (!startToken) return null;
|
|
1323
|
+
|
|
1324
|
+
const { token: relationshipsToken } = this.expect('KEYWORD', 'relationships');
|
|
1325
|
+
if (!relationshipsToken) return null;
|
|
1326
|
+
|
|
1327
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
1328
|
+
if (!lbrace) return null;
|
|
1329
|
+
|
|
1330
|
+
const source = {
|
|
1331
|
+
file: this.filePath,
|
|
1332
|
+
start: lbrace.span.end,
|
|
1333
|
+
end: lbrace.span.end,
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
// Probe for legacy syntax (string literals only)
|
|
1337
|
+
let foundString = false;
|
|
1338
|
+
let foundIdentifier = false;
|
|
1339
|
+
let probeIndex = this.index;
|
|
1340
|
+
while (probeIndex < this.tokens.length) {
|
|
1341
|
+
const token = this.tokens[probeIndex];
|
|
1342
|
+
if (!token || token.type === 'EOF' || token.type === 'RBRACE') break;
|
|
1343
|
+
if (token.type === 'STRING') foundString = true;
|
|
1344
|
+
if (token.type === 'IDENTIFIER' || token.type === 'KEYWORD') foundIdentifier = true;
|
|
1345
|
+
if (foundString && foundIdentifier) break;
|
|
1346
|
+
probeIndex++;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (foundString && !foundIdentifier) {
|
|
1350
|
+
const values = [];
|
|
1351
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
1352
|
+
if (this.match('COMMA')) {
|
|
1353
|
+
this.advance();
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
if (this.match('STRING')) {
|
|
1357
|
+
const stringToken = this.advance();
|
|
1358
|
+
if (stringToken) {
|
|
1359
|
+
values.push(stringToken.value);
|
|
1360
|
+
source.end = stringToken.span.end;
|
|
1361
|
+
}
|
|
1362
|
+
} else {
|
|
1363
|
+
this.advance();
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
1368
|
+
if (!rbrace) return null;
|
|
1369
|
+
source.end = rbrace.span.start;
|
|
1370
|
+
|
|
1371
|
+
return {
|
|
1372
|
+
kind: 'relationships',
|
|
1373
|
+
values,
|
|
1374
|
+
source,
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
const entries = [];
|
|
1379
|
+
|
|
1380
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
1381
|
+
if (this.match('COMMA')) {
|
|
1382
|
+
this.advance();
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
1387
|
+
const relationshipToken = this.advance();
|
|
1388
|
+
if (!relationshipToken) {
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
const relationshipId = relationshipToken.value;
|
|
1392
|
+
|
|
1393
|
+
if (!this.match('LBRACE')) {
|
|
1394
|
+
this.reportDiagnostic(
|
|
1395
|
+
'error',
|
|
1396
|
+
`Expected '{' after relationship identifier "${relationshipId}"`,
|
|
1397
|
+
relationshipToken.span,
|
|
1398
|
+
);
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const { token: targetLbrace } = this.expect('LBRACE');
|
|
1403
|
+
if (!targetLbrace) return null;
|
|
1404
|
+
|
|
1405
|
+
const targets = [];
|
|
1406
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
1407
|
+
if (this.match('COMMA')) {
|
|
1408
|
+
this.advance();
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
1413
|
+
const targetToken = this.advance();
|
|
1414
|
+
if (!targetToken) {
|
|
1415
|
+
break;
|
|
1416
|
+
}
|
|
1417
|
+
const targetId = targetToken.value;
|
|
1418
|
+
let metadata = undefined;
|
|
1419
|
+
|
|
1420
|
+
if (this.match('LBRACE')) {
|
|
1421
|
+
const { token: metadataLbrace } = this.expect('LBRACE');
|
|
1422
|
+
if (!metadataLbrace) return null;
|
|
1423
|
+
const metadataBody = this.parseBodyUntilRBrace();
|
|
1424
|
+
const { token: metadataRbrace } = this.expect('RBRACE');
|
|
1425
|
+
if (!metadataRbrace) return null;
|
|
1426
|
+
|
|
1427
|
+
const describeBlock = metadataBody.find((b) => b.kind === 'describe');
|
|
1428
|
+
if (describeBlock) {
|
|
1429
|
+
metadata = describeBlock;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
targets.push({
|
|
1434
|
+
id: targetId,
|
|
1435
|
+
metadata,
|
|
1436
|
+
});
|
|
1437
|
+
} else {
|
|
1438
|
+
this.advance();
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
const { token: targetRbrace } = this.expect('RBRACE');
|
|
1443
|
+
if (!targetRbrace) return null;
|
|
1444
|
+
source.end = targetRbrace.span.end;
|
|
1445
|
+
|
|
1446
|
+
entries.push({
|
|
1447
|
+
relationshipId,
|
|
1448
|
+
targets,
|
|
1449
|
+
});
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
this.advance();
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
1457
|
+
if (!rbrace) return null;
|
|
1458
|
+
source.end = rbrace.span.start;
|
|
1459
|
+
|
|
1460
|
+
return {
|
|
1461
|
+
kind: 'relationships',
|
|
1462
|
+
entries,
|
|
1463
|
+
source,
|
|
1464
|
+
};
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Parses aliased container kind declaration if present
|
|
1469
|
+
* Pattern: (IDENTIFIER|KEYWORD) (IDENTIFIER|KEYWORD) [KEYWORD 'in' + identifierPath] LBRACE
|
|
1470
|
+
* Only triggers when first token is NOT a known decl keyword parselet
|
|
1471
|
+
* @returns {any | null}
|
|
1472
|
+
*/
|
|
1473
|
+
ParserCore.prototype.parseAliasedContainerDeclIfPresent = function () {
|
|
1474
|
+
const startToken = this.peek();
|
|
1475
|
+
if (!startToken) return null;
|
|
1476
|
+
|
|
1477
|
+
// Must be IDENTIFIER or KEYWORD
|
|
1478
|
+
if (startToken.type !== 'IDENTIFIER' && startToken.type !== 'KEYWORD') {
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
const kindName = startToken.value;
|
|
1483
|
+
|
|
1484
|
+
// Don't steal known declaration keywords
|
|
1485
|
+
if (declParselets.has(kindName)) {
|
|
1486
|
+
return null;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// Check if pattern matches: kindName name [in parent] {
|
|
1490
|
+
// Need to peek ahead to see if we have: kindName name [in ...] LBRACE
|
|
1491
|
+
const savedIndex = this.index;
|
|
1492
|
+
|
|
1493
|
+
// Consume kindName
|
|
1494
|
+
this.advance();
|
|
1495
|
+
|
|
1496
|
+
// Next must be IDENTIFIER or KEYWORD (the name)
|
|
1497
|
+
const nameToken = this.peek();
|
|
1498
|
+
if (!nameToken || (nameToken.type !== 'IDENTIFIER' && nameToken.type !== 'KEYWORD')) {
|
|
1499
|
+
// Restore position
|
|
1500
|
+
this.index = savedIndex;
|
|
1501
|
+
return null;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// Consume name
|
|
1505
|
+
const name = nameToken.value;
|
|
1506
|
+
this.advance();
|
|
1507
|
+
|
|
1508
|
+
// Check for optional "in ParentName"
|
|
1509
|
+
let parentName = undefined;
|
|
1510
|
+
if (this.match('KEYWORD', 'in')) {
|
|
1511
|
+
this.advance(); // consume 'in'
|
|
1512
|
+
const parentToken = this.peek();
|
|
1513
|
+
if (parentToken && (parentToken.type === 'IDENTIFIER' || parentToken.type === 'KEYWORD')) {
|
|
1514
|
+
parentName = this.parseIdentifierPath();
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Must be followed by LBRACE
|
|
1519
|
+
if (!this.match('LBRACE')) {
|
|
1520
|
+
// Restore position
|
|
1521
|
+
this.index = savedIndex;
|
|
1522
|
+
return null;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Pattern matches - parse the declaration
|
|
1526
|
+
const kindToken = startToken;
|
|
1527
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
1528
|
+
if (!lbrace) {
|
|
1529
|
+
this.index = savedIndex;
|
|
1530
|
+
return null;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const body = this.parseBodyUntilRBrace();
|
|
1534
|
+
|
|
1535
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
1536
|
+
if (!rbrace) {
|
|
1537
|
+
this.index = savedIndex;
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
return {
|
|
1542
|
+
kind: 'container',
|
|
1543
|
+
spelledKind: kindName,
|
|
1544
|
+
name,
|
|
1545
|
+
parentName,
|
|
1546
|
+
body,
|
|
1547
|
+
source: this.createSpan(kindToken, rbrace),
|
|
1548
|
+
};
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* Parses relationship usage block: <identifier|keyword> { <items> }
|
|
1553
|
+
* Grammar: RefPath = (IDENTIFIER|KEYWORD) (DOT (IDENTIFIER|KEYWORD))*
|
|
1554
|
+
* RelItem = STRING | (RefPath [LBRACE body RBRACE])
|
|
1555
|
+
* Items repeat until closing RBRACE, commas optional
|
|
1556
|
+
* @returns {RelUseBlock | null}
|
|
1557
|
+
*/
|
|
1558
|
+
ParserCore.prototype.parseRelUseBlock = function () {
|
|
1559
|
+
const startToken = this.peek();
|
|
1560
|
+
if (!startToken) return null;
|
|
1561
|
+
|
|
1562
|
+
// Name can be IDENTIFIER or KEYWORD
|
|
1563
|
+
const nameToken = this.peek();
|
|
1564
|
+
if (!nameToken || (nameToken.type !== 'IDENTIFIER' && nameToken.type !== 'KEYWORD')) {
|
|
1565
|
+
return null;
|
|
1566
|
+
}
|
|
1567
|
+
const name = nameToken.value;
|
|
1568
|
+
this.advance();
|
|
1569
|
+
|
|
1570
|
+
// Check if followed by brace
|
|
1571
|
+
if (!this.match('LBRACE')) {
|
|
1572
|
+
// Not a block, just an identifier/keyword - report and return null
|
|
1573
|
+
this.reportDiagnostic('error', `Unexpected ${nameToken.type.toLowerCase()} "${name}"`, startToken.span);
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
const { token: lbrace } = this.expect('LBRACE');
|
|
1578
|
+
if (!lbrace) return null;
|
|
1579
|
+
|
|
1580
|
+
const items = [];
|
|
1581
|
+
|
|
1582
|
+
// Parse items until closing brace
|
|
1583
|
+
while (!this.isAtEnd() && !this.match('RBRACE')) {
|
|
1584
|
+
// Skip optional commas
|
|
1585
|
+
if (this.match('COMMA')) {
|
|
1586
|
+
this.advance();
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const itemStartToken = this.peek();
|
|
1591
|
+
if (!itemStartToken) break;
|
|
1592
|
+
|
|
1593
|
+
// Check if it's a STRING item
|
|
1594
|
+
if (itemStartToken.type === 'STRING') {
|
|
1595
|
+
const stringToken = this.advance();
|
|
1596
|
+
if (stringToken) {
|
|
1597
|
+
items.push({
|
|
1598
|
+
kind: 'string',
|
|
1599
|
+
value: stringToken.value,
|
|
1600
|
+
span: this.spanFromToken(stringToken),
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Otherwise parse as reference path
|
|
1607
|
+
const refPath = this.parseIdentifierPath();
|
|
1608
|
+
if (!refPath) {
|
|
1609
|
+
// Not a valid item - report and try to recover
|
|
1610
|
+
const token = this.peek();
|
|
1611
|
+
if (token) {
|
|
1612
|
+
this.reportDiagnostic('error', `Expected string or identifier path in relationship usage block`, token.span);
|
|
1613
|
+
this.advance(); // Advance to avoid infinite loop
|
|
1614
|
+
} else {
|
|
1615
|
+
break;
|
|
1616
|
+
}
|
|
1617
|
+
continue;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
// After parseIdentifierPath, previous() gives us the last token of the path
|
|
1621
|
+
let refEndToken = this.previous();
|
|
1622
|
+
if (!refEndToken) refEndToken = itemStartToken;
|
|
1623
|
+
|
|
1624
|
+
// Check if followed by inline body
|
|
1625
|
+
let body = undefined;
|
|
1626
|
+
if (this.match('LBRACE')) {
|
|
1627
|
+
const bodyLbrace = this.advance();
|
|
1628
|
+
if (bodyLbrace) {
|
|
1629
|
+
// Parse body using existing parseBodyUntilRBrace
|
|
1630
|
+
body = this.parseBodyUntilRBrace();
|
|
1631
|
+
const { token: bodyRbrace } = this.expect('RBRACE');
|
|
1632
|
+
if (bodyRbrace) {
|
|
1633
|
+
refEndToken = bodyRbrace;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Create item span from ref path start to end (or body end if present)
|
|
1639
|
+
const itemSpan = itemStartToken && refEndToken
|
|
1640
|
+
? this.createSpan(itemStartToken, refEndToken)
|
|
1641
|
+
: (itemStartToken ? this.spanFromToken(itemStartToken) : undefined);
|
|
1642
|
+
|
|
1643
|
+
items.push({
|
|
1644
|
+
kind: 'ref',
|
|
1645
|
+
ref: refPath,
|
|
1646
|
+
body,
|
|
1647
|
+
span: itemSpan,
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const { token: rbrace } = this.expect('RBRACE');
|
|
1652
|
+
if (!rbrace) return null;
|
|
1653
|
+
|
|
1654
|
+
return {
|
|
1655
|
+
kind: 'relUse',
|
|
1656
|
+
name,
|
|
1657
|
+
items,
|
|
1658
|
+
span: this.createSpan(startToken, rbrace),
|
|
1659
|
+
};
|
|
1660
|
+
};
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Debug helper: prints AST in stable tree format
|
|
1664
|
+
* @param {any} ast - AST node or FileAST
|
|
1665
|
+
*/
|
|
1666
|
+
export function debugAst(ast) {
|
|
1667
|
+
/**
|
|
1668
|
+
* @param {any} node
|
|
1669
|
+
* @param {number} indent
|
|
1670
|
+
*/
|
|
1671
|
+
function printNode(node, indent = 0) {
|
|
1672
|
+
const prefix = ' '.repeat(indent);
|
|
1673
|
+
if (!node || typeof node !== 'object') {
|
|
1674
|
+
console.log(`${prefix}${node}`);
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
if (node.kind) {
|
|
1679
|
+
// AST node
|
|
1680
|
+
const kind = node.kind;
|
|
1681
|
+
const fields = [];
|
|
1682
|
+
if (node.name !== undefined) fields.push(`name=${node.name}`);
|
|
1683
|
+
if (node.spelledKind !== undefined) fields.push(`spelledKind=${node.spelledKind}`);
|
|
1684
|
+
if (node.a !== undefined) fields.push(`a=${node.a}`);
|
|
1685
|
+
if (node.b !== undefined) fields.push(`b=${node.b}`);
|
|
1686
|
+
if (node.endpoint !== undefined) fields.push(`endpoint=${node.endpoint}`);
|
|
1687
|
+
if (node.ids !== undefined) fields.push(`ids=[${node.ids.join(',')}]`);
|
|
1688
|
+
if (node.ref !== undefined) fields.push(`ref=${node.ref}`);
|
|
1689
|
+
if (node.keyword !== undefined) fields.push(`keyword=${node.keyword}`);
|
|
1690
|
+
if (node.parentName !== undefined) fields.push(`parentName=${node.parentName}`);
|
|
1691
|
+
if (node.repositoryName !== undefined) fields.push(`repositoryName=${node.repositoryName}`);
|
|
1692
|
+
if (node.contentSpan !== undefined) fields.push(`contentSpan=[${node.contentSpan.startOffset}..${node.contentSpan.endOffset}]`);
|
|
1693
|
+
|
|
1694
|
+
const childCount = Array.isArray(node.body) ? node.body.length
|
|
1695
|
+
: Array.isArray(node.children) ? node.children.length
|
|
1696
|
+
: Array.isArray(node.items) ? node.items.length
|
|
1697
|
+
: Array.isArray(node.decls) ? node.decls.length
|
|
1698
|
+
: 0;
|
|
1699
|
+
if (childCount > 0) fields.push(`children=${childCount}`);
|
|
1700
|
+
|
|
1701
|
+
const fieldStr = fields.length > 0 ? ` (${fields.join(', ')})` : '';
|
|
1702
|
+
console.log(`${prefix}${kind}${fieldStr}`);
|
|
1703
|
+
|
|
1704
|
+
if (Array.isArray(node.body)) {
|
|
1705
|
+
node.body.forEach(/** @param {any} child */ (child) => printNode(child, indent + 1));
|
|
1706
|
+
} else if (Array.isArray(node.children)) {
|
|
1707
|
+
node.children.forEach(/** @param {any} child */ (child) => printNode(child, indent + 1));
|
|
1708
|
+
} else if (Array.isArray(node.items)) {
|
|
1709
|
+
// Special handling for references block vs relUse block
|
|
1710
|
+
if (node.kind === 'references') {
|
|
1711
|
+
node.items.forEach(/** @param {any} item */ (item) => {
|
|
1712
|
+
if (item && typeof item === 'object' && item.kind === 'ref') {
|
|
1713
|
+
console.log(`${prefix} item ref=${item.ref}`);
|
|
1714
|
+
} else {
|
|
1715
|
+
printNode(item, indent + 1);
|
|
1716
|
+
}
|
|
1717
|
+
});
|
|
1718
|
+
} else {
|
|
1719
|
+
// relUse block items
|
|
1720
|
+
node.items.forEach(/** @param {any} item */ (item) => {
|
|
1721
|
+
if (item && typeof item === 'object' && item.kind) {
|
|
1722
|
+
if (item.kind === 'string') {
|
|
1723
|
+
console.log(`${prefix} item string='${item.value}'`);
|
|
1724
|
+
} else if (item.kind === 'ref') {
|
|
1725
|
+
const refFields = [`ref=${item.ref}`];
|
|
1726
|
+
if (item.body && Array.isArray(item.body) && item.body.length > 0) {
|
|
1727
|
+
refFields.push(`body=${item.body.length}`);
|
|
1728
|
+
}
|
|
1729
|
+
console.log(`${prefix} item ref (${refFields.join(', ')})`);
|
|
1730
|
+
if (item.body && Array.isArray(item.body)) {
|
|
1731
|
+
item.body.forEach(/** @param {any} child */ (child) => printNode(child, indent + 2));
|
|
1732
|
+
}
|
|
1733
|
+
} else {
|
|
1734
|
+
printNode(item, indent + 1);
|
|
1735
|
+
}
|
|
1736
|
+
} else {
|
|
1737
|
+
printNode(item, indent + 1);
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
} else if (Array.isArray(node.decls)) {
|
|
1742
|
+
node.decls.forEach(/** @param {any} child */ (child) => printNode(child, indent + 1));
|
|
1743
|
+
}
|
|
1744
|
+
} else {
|
|
1745
|
+
// Unknown object
|
|
1746
|
+
console.log(`${prefix}[object]`);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
printNode(ast);
|
|
1751
|
+
}
|