@sprig-and-prose/sprig-universe 0.1.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/PHILOSOPHY.md +201 -0
- package/README.md +168 -0
- package/REFERENCE.md +355 -0
- package/biome.json +24 -0
- package/package.json +30 -0
- package/repositories/sprig-repository-github/index.js +29 -0
- package/src/ast.js +257 -0
- package/src/cli.js +1510 -0
- package/src/graph.js +950 -0
- package/src/index.js +46 -0
- package/src/ir.js +121 -0
- package/src/parser.js +1656 -0
- package/src/scanner.js +255 -0
- package/src/scene-manifest.js +856 -0
- package/src/util/span.js +46 -0
- package/src/util/text.js +126 -0
- package/src/validator.js +862 -0
- package/src/validators/mysql/connection.js +154 -0
- package/src/validators/mysql/schema.js +209 -0
- package/src/validators/mysql/type-compat.js +219 -0
- package/src/validators/mysql/validator.js +332 -0
- package/test/fixtures/amaranthine-mini.prose +53 -0
- package/test/fixtures/conflicting-universes-a.prose +8 -0
- package/test/fixtures/conflicting-universes-b.prose +8 -0
- package/test/fixtures/duplicate-names.prose +20 -0
- package/test/fixtures/first-line-aware.prose +32 -0
- package/test/fixtures/indented-describe.prose +18 -0
- package/test/fixtures/multi-file-universe-a.prose +15 -0
- package/test/fixtures/multi-file-universe-b.prose +15 -0
- package/test/fixtures/multi-file-universe-conflict-desc.prose +12 -0
- package/test/fixtures/multi-file-universe-conflict-title.prose +4 -0
- package/test/fixtures/multi-file-universe-with-title.prose +10 -0
- package/test/fixtures/named-document.prose +17 -0
- package/test/fixtures/named-duplicate.prose +22 -0
- package/test/fixtures/named-reference.prose +17 -0
- package/test/fixtures/relates-errors.prose +38 -0
- package/test/fixtures/relates-tier1.prose +14 -0
- package/test/fixtures/relates-tier2.prose +16 -0
- package/test/fixtures/relates-tier3.prose +21 -0
- package/test/fixtures/sprig-meta-mini.prose +62 -0
- package/test/fixtures/unresolved-relates.prose +15 -0
- package/test/fixtures/using-in-references.prose +35 -0
- package/test/fixtures/using-unknown.prose +8 -0
- package/test/universe-basic.test.js +804 -0
- package/tsconfig.json +15 -0
package/src/parser.js
ADDED
|
@@ -0,0 +1,1656 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Recursive descent parser for Sprig universe syntax
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mergeSpans } from './util/span.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {import('./ast.js').FileAST} FileAST
|
|
9
|
+
* @typedef {import('./ast.js').UniverseDecl} UniverseDecl
|
|
10
|
+
* @typedef {import('./ast.js').AnthologyDecl} AnthologyDecl
|
|
11
|
+
* @typedef {import('./ast.js').SeriesDecl} SeriesDecl
|
|
12
|
+
* @typedef {import('./ast.js').BookDecl} BookDecl
|
|
13
|
+
* @typedef {import('./ast.js').ChapterDecl} ChapterDecl
|
|
14
|
+
* @typedef {import('./ast.js').ConceptDecl} ConceptDecl
|
|
15
|
+
* @typedef {import('./ast.js').RelatesDecl} RelatesDecl
|
|
16
|
+
* @typedef {import('./ast.js').DescribeBlock} DescribeBlock
|
|
17
|
+
* @typedef {import('./ast.js').UnknownBlock} UnknownBlock
|
|
18
|
+
* @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
|
|
19
|
+
* @typedef {import('./ast.js').ReferenceBlock} ReferenceBlock
|
|
20
|
+
* @typedef {import('./ast.js').NamedReferenceBlock} NamedReferenceBlock
|
|
21
|
+
* @typedef {import('./ast.js').UsingInReferencesBlock} UsingInReferencesBlock
|
|
22
|
+
* @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
|
|
23
|
+
* @typedef {import('./ast.js').DocumentBlock} DocumentBlock
|
|
24
|
+
* @typedef {import('./ast.js').NamedDocumentBlock} NamedDocumentBlock
|
|
25
|
+
* @typedef {import('./scanner.js').Token} Token
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parses tokens into an AST
|
|
30
|
+
* @param {Token[]} tokens - Tokens from scanner
|
|
31
|
+
* @param {string} file - File path
|
|
32
|
+
* @param {string} sourceText - Original source text for raw content extraction
|
|
33
|
+
* @returns {FileAST}
|
|
34
|
+
*/
|
|
35
|
+
export function parse(tokens, file, sourceText) {
|
|
36
|
+
const parser = new Parser(tokens, file, sourceText);
|
|
37
|
+
return parser.parseFile();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class Parser {
|
|
41
|
+
/**
|
|
42
|
+
* @param {Token[]} tokens
|
|
43
|
+
* @param {string} file
|
|
44
|
+
* @param {string} sourceText
|
|
45
|
+
*/
|
|
46
|
+
constructor(tokens, file, sourceText) {
|
|
47
|
+
this.tokens = tokens;
|
|
48
|
+
this.file = file;
|
|
49
|
+
this.sourceText = sourceText;
|
|
50
|
+
this.pos = 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @returns {Token | null}
|
|
55
|
+
*/
|
|
56
|
+
peek() {
|
|
57
|
+
if (this.pos >= this.tokens.length) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return this.tokens[this.pos];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @returns {Token | null}
|
|
65
|
+
*/
|
|
66
|
+
advance() {
|
|
67
|
+
if (this.pos >= this.tokens.length) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return this.tokens[this.pos++];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} type
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
match(type) {
|
|
78
|
+
const token = this.peek();
|
|
79
|
+
return token !== null && token.type === type;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} type
|
|
84
|
+
* @param {string} [value]
|
|
85
|
+
* @returns {Token}
|
|
86
|
+
* @throws {Error}
|
|
87
|
+
*/
|
|
88
|
+
expect(type, value) {
|
|
89
|
+
const token = this.advance();
|
|
90
|
+
if (!token || token.type !== type) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Expected ${type}, got ${token ? token.type : 'EOF'} at ${this.file}:${token ? token.span.start.line : '?'}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (value !== undefined && token.value !== value) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Expected ${type} with value "${value}", got "${token.value}" at ${this.file}:${token.span.start.line}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return token;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @returns {FileAST}
|
|
105
|
+
*/
|
|
106
|
+
parseFile() {
|
|
107
|
+
const universes = [];
|
|
108
|
+
const scenes = [];
|
|
109
|
+
const startToken = this.peek();
|
|
110
|
+
|
|
111
|
+
while (!this.match('EOF')) {
|
|
112
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'universe') {
|
|
113
|
+
universes.push(this.parseUniverse());
|
|
114
|
+
} else if (this.match('KEYWORD') && this.peek()?.value === 'scene') {
|
|
115
|
+
scenes.push(this.parseScene());
|
|
116
|
+
} else {
|
|
117
|
+
// Skip unknown top-level content (tolerant parsing)
|
|
118
|
+
const token = this.advance();
|
|
119
|
+
if (token && token.type !== 'EOF') {
|
|
120
|
+
// Could emit warning here, but for now just skip
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
file: this.file,
|
|
127
|
+
universes,
|
|
128
|
+
scenes,
|
|
129
|
+
source: startToken
|
|
130
|
+
? {
|
|
131
|
+
file: this.file,
|
|
132
|
+
start: startToken.span.start,
|
|
133
|
+
end: this.tokens[this.tokens.length - 1]?.span.end || startToken.span.end,
|
|
134
|
+
}
|
|
135
|
+
: undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @returns {UniverseDecl}
|
|
141
|
+
*/
|
|
142
|
+
parseUniverse() {
|
|
143
|
+
const startToken = this.expect('KEYWORD', 'universe');
|
|
144
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
145
|
+
const lbrace = this.expect('LBRACE');
|
|
146
|
+
const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository']);
|
|
147
|
+
const rbrace = this.expect('RBRACE');
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
kind: 'universe',
|
|
151
|
+
name: nameToken.value,
|
|
152
|
+
body,
|
|
153
|
+
source: {
|
|
154
|
+
file: this.file,
|
|
155
|
+
start: startToken.span.start,
|
|
156
|
+
end: rbrace.span.end,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {string[]} allowedKeywords - Keywords allowed in this body
|
|
163
|
+
* @returns {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | DocumentationBlock | NamedReferenceBlock | NamedDocumentBlock | UnknownBlock>}
|
|
164
|
+
*/
|
|
165
|
+
parseBlockBody(allowedKeywords) {
|
|
166
|
+
const body = [];
|
|
167
|
+
|
|
168
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
169
|
+
// Check for keywords or identifiers that might start a block
|
|
170
|
+
if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
|
|
171
|
+
const keyword = this.peek()?.value;
|
|
172
|
+
if (!keyword) break;
|
|
173
|
+
|
|
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
|
+
// Check for named document: document <IDENTIFIER> { ... }
|
|
190
|
+
if (keyword === 'document') {
|
|
191
|
+
const nextPos = this.pos + 1;
|
|
192
|
+
if (nextPos < this.tokens.length &&
|
|
193
|
+
this.tokens[nextPos].type === 'IDENTIFIER' &&
|
|
194
|
+
nextPos + 1 < this.tokens.length &&
|
|
195
|
+
this.tokens[nextPos + 1].type === 'LBRACE') {
|
|
196
|
+
// This is a named document block
|
|
197
|
+
body.push(this.parseNamedDocument());
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// Otherwise, it's an inline document (only valid inside documentation block)
|
|
201
|
+
// Fall through to unknown block handling
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (keyword === 'anthology' && allowedKeywords.includes('anthology')) {
|
|
205
|
+
body.push(this.parseAnthology());
|
|
206
|
+
} else if (keyword === 'series' && allowedKeywords.includes('series')) {
|
|
207
|
+
body.push(this.parseSeries());
|
|
208
|
+
} else if (keyword === 'book' && allowedKeywords.includes('book')) {
|
|
209
|
+
body.push(this.parseBook());
|
|
210
|
+
} else if (keyword === 'chapter' && allowedKeywords.includes('chapter')) {
|
|
211
|
+
body.push(this.parseChapter());
|
|
212
|
+
} else if (keyword === 'concept' && allowedKeywords.includes('concept')) {
|
|
213
|
+
body.push(this.parseConcept());
|
|
214
|
+
} else if (keyword === 'relates' && allowedKeywords.includes('relates')) {
|
|
215
|
+
body.push(this.parseRelates());
|
|
216
|
+
} else if (keyword === 'describe' && allowedKeywords.includes('describe')) {
|
|
217
|
+
body.push(this.parseDescribe());
|
|
218
|
+
} else if (keyword === 'title' && allowedKeywords.includes('title')) {
|
|
219
|
+
body.push(this.parseTitle());
|
|
220
|
+
} else if (keyword === 'repository' && allowedKeywords.includes('repository')) {
|
|
221
|
+
body.push(this.parseRepository());
|
|
222
|
+
} else if (keyword === 'from' && allowedKeywords.includes('from')) {
|
|
223
|
+
body.push(this.parseFrom());
|
|
224
|
+
} else if (keyword === 'relationships' && allowedKeywords.includes('relationships')) {
|
|
225
|
+
body.push(this.parseRelationships());
|
|
226
|
+
} else if (keyword === 'references') {
|
|
227
|
+
body.push(this.parseReferences());
|
|
228
|
+
} else if (keyword === 'documentation') {
|
|
229
|
+
body.push(this.parseDocumentation());
|
|
230
|
+
} else {
|
|
231
|
+
// Unknown keyword/identifier followed by brace - parse as UnknownBlock
|
|
232
|
+
// Check if next token is LBRACE
|
|
233
|
+
const nextPos = this.pos + 1;
|
|
234
|
+
if (nextPos < this.tokens.length && this.tokens[nextPos].type === 'LBRACE') {
|
|
235
|
+
body.push(this.parseUnknownBlock());
|
|
236
|
+
} else {
|
|
237
|
+
// Not a block, just skip this token (tolerant parsing)
|
|
238
|
+
this.advance();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// Unexpected token in body - skip (tolerant)
|
|
243
|
+
this.advance();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return body;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @returns {AnthologyDecl}
|
|
252
|
+
*/
|
|
253
|
+
parseAnthology() {
|
|
254
|
+
const startToken = this.expect('KEYWORD', 'anthology');
|
|
255
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
256
|
+
const lbrace = this.expect('LBRACE');
|
|
257
|
+
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
|
|
258
|
+
const rbrace = this.expect('RBRACE');
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
kind: 'anthology',
|
|
262
|
+
name: nameToken.value,
|
|
263
|
+
body,
|
|
264
|
+
source: {
|
|
265
|
+
file: this.file,
|
|
266
|
+
start: startToken.span.start,
|
|
267
|
+
end: rbrace.span.end,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @returns {SeriesDecl}
|
|
274
|
+
*/
|
|
275
|
+
parseSeries() {
|
|
276
|
+
const startToken = this.expect('KEYWORD', 'series');
|
|
277
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
278
|
+
|
|
279
|
+
// Optional "in <AnthologyName>" syntax
|
|
280
|
+
let parentName = undefined;
|
|
281
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'in') {
|
|
282
|
+
this.expect('KEYWORD', 'in');
|
|
283
|
+
const parentToken = this.expect('IDENTIFIER');
|
|
284
|
+
parentName = parentToken.value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const lbrace = this.expect('LBRACE');
|
|
288
|
+
const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation']);
|
|
289
|
+
const rbrace = this.expect('RBRACE');
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
kind: 'series',
|
|
293
|
+
name: nameToken.value,
|
|
294
|
+
parentName,
|
|
295
|
+
body,
|
|
296
|
+
source: {
|
|
297
|
+
file: this.file,
|
|
298
|
+
start: startToken.span.start,
|
|
299
|
+
end: rbrace.span.end,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @returns {BookDecl}
|
|
306
|
+
*/
|
|
307
|
+
parseBook() {
|
|
308
|
+
const startToken = this.expect('KEYWORD', 'book');
|
|
309
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
310
|
+
this.expect('KEYWORD', 'in');
|
|
311
|
+
const parentToken = this.expect('IDENTIFIER');
|
|
312
|
+
const lbrace = this.expect('LBRACE');
|
|
313
|
+
const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation']);
|
|
314
|
+
const rbrace = this.expect('RBRACE');
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
kind: 'book',
|
|
318
|
+
name: nameToken.value,
|
|
319
|
+
parentName: parentToken.value,
|
|
320
|
+
body,
|
|
321
|
+
source: {
|
|
322
|
+
file: this.file,
|
|
323
|
+
start: startToken.span.start,
|
|
324
|
+
end: rbrace.span.end,
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* @returns {ChapterDecl}
|
|
331
|
+
*/
|
|
332
|
+
parseChapter() {
|
|
333
|
+
const startToken = this.expect('KEYWORD', 'chapter');
|
|
334
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
335
|
+
this.expect('KEYWORD', 'in');
|
|
336
|
+
const parentToken = this.expect('IDENTIFIER');
|
|
337
|
+
const lbrace = this.expect('LBRACE');
|
|
338
|
+
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
|
|
339
|
+
const rbrace = this.expect('RBRACE');
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
kind: 'chapter',
|
|
343
|
+
name: nameToken.value,
|
|
344
|
+
parentName: parentToken.value,
|
|
345
|
+
body,
|
|
346
|
+
source: {
|
|
347
|
+
file: this.file,
|
|
348
|
+
start: startToken.span.start,
|
|
349
|
+
end: rbrace.span.end,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @returns {ConceptDecl}
|
|
356
|
+
*/
|
|
357
|
+
parseConcept() {
|
|
358
|
+
const startToken = this.expect('KEYWORD', 'concept');
|
|
359
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
360
|
+
|
|
361
|
+
// Optional "in <ParentName>" syntax
|
|
362
|
+
let parentName = undefined;
|
|
363
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'in') {
|
|
364
|
+
this.expect('KEYWORD', 'in');
|
|
365
|
+
const parentToken = this.expect('IDENTIFIER');
|
|
366
|
+
parentName = parentToken.value;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const lbrace = this.expect('LBRACE');
|
|
370
|
+
const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
|
|
371
|
+
const rbrace = this.expect('RBRACE');
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
kind: 'concept',
|
|
375
|
+
name: nameToken.value,
|
|
376
|
+
parentName,
|
|
377
|
+
body,
|
|
378
|
+
source: {
|
|
379
|
+
file: this.file,
|
|
380
|
+
start: startToken.span.start,
|
|
381
|
+
end: rbrace.span.end,
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* @returns {RelatesDecl}
|
|
388
|
+
*/
|
|
389
|
+
parseRelates() {
|
|
390
|
+
const startToken = this.expect('KEYWORD', 'relates');
|
|
391
|
+
const aToken = this.expect('IDENTIFIER');
|
|
392
|
+
this.expect('KEYWORD', 'and');
|
|
393
|
+
const bToken = this.expect('IDENTIFIER');
|
|
394
|
+
const lbrace = this.expect('LBRACE');
|
|
395
|
+
const body = this.parseBlockBody(['describe', 'title', 'from']);
|
|
396
|
+
const rbrace = this.expect('RBRACE');
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
kind: 'relates',
|
|
400
|
+
a: aToken.value,
|
|
401
|
+
b: bToken.value,
|
|
402
|
+
body,
|
|
403
|
+
source: {
|
|
404
|
+
file: this.file,
|
|
405
|
+
start: startToken.span.start,
|
|
406
|
+
end: rbrace.span.end,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* @returns {FromBlock}
|
|
413
|
+
*/
|
|
414
|
+
parseFrom() {
|
|
415
|
+
const startToken = this.expect('KEYWORD', 'from');
|
|
416
|
+
const endpointToken = this.expect('IDENTIFIER');
|
|
417
|
+
const lbrace = this.expect('LBRACE');
|
|
418
|
+
const body = this.parseBlockBody(['relationships', 'describe', 'title']);
|
|
419
|
+
const rbrace = this.expect('RBRACE');
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
kind: 'from',
|
|
423
|
+
endpoint: endpointToken.value,
|
|
424
|
+
body,
|
|
425
|
+
source: {
|
|
426
|
+
file: this.file,
|
|
427
|
+
start: startToken.span.start,
|
|
428
|
+
end: rbrace.span.end,
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* @returns {RelationshipsBlock}
|
|
435
|
+
*/
|
|
436
|
+
parseRelationships() {
|
|
437
|
+
const startToken = this.expect('KEYWORD', 'relationships');
|
|
438
|
+
const lbrace = this.expect('LBRACE');
|
|
439
|
+
const values = [];
|
|
440
|
+
let relationshipsSource = {
|
|
441
|
+
file: this.file,
|
|
442
|
+
start: lbrace.span.end,
|
|
443
|
+
end: lbrace.span.end,
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Parse string literals (commas are whitespace, so scanner skips them)
|
|
447
|
+
// We parse consecutive STRING tokens until we hit RBRACE
|
|
448
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
449
|
+
if (this.match('STRING')) {
|
|
450
|
+
const stringToken = this.advance();
|
|
451
|
+
if (stringToken) {
|
|
452
|
+
values.push(stringToken.value);
|
|
453
|
+
relationshipsSource.end = stringToken.span.end;
|
|
454
|
+
}
|
|
455
|
+
} else {
|
|
456
|
+
// Skip non-string tokens (whitespace/comments already skipped by scanner)
|
|
457
|
+
// This handles commas and other unexpected tokens gracefully
|
|
458
|
+
this.advance();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const rbrace = this.expect('RBRACE');
|
|
463
|
+
relationshipsSource.end = rbrace.span.start;
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
kind: 'relationships',
|
|
467
|
+
values,
|
|
468
|
+
source: relationshipsSource,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Parses a describe block, consuming raw text until matching closing brace
|
|
474
|
+
* Treats braces inside as plain text (doesn't parse nested structures)
|
|
475
|
+
* @returns {DescribeBlock}
|
|
476
|
+
*/
|
|
477
|
+
parseDescribe() {
|
|
478
|
+
const startToken = this.expect('KEYWORD', 'describe');
|
|
479
|
+
const lbrace = this.expect('LBRACE');
|
|
480
|
+
|
|
481
|
+
// Find the matching closing brace by tracking depth
|
|
482
|
+
// We start at depth 1 (the opening brace we just consumed)
|
|
483
|
+
let depth = 1;
|
|
484
|
+
const startOffset = lbrace.span.end.offset;
|
|
485
|
+
let endOffset = startOffset;
|
|
486
|
+
let endToken = null;
|
|
487
|
+
|
|
488
|
+
// Consume tokens until we find the matching closing brace
|
|
489
|
+
while (depth > 0 && this.pos < this.tokens.length) {
|
|
490
|
+
const token = this.tokens[this.pos];
|
|
491
|
+
if (token.type === 'EOF') break;
|
|
492
|
+
|
|
493
|
+
if (token.type === 'LBRACE') {
|
|
494
|
+
depth++;
|
|
495
|
+
this.pos++;
|
|
496
|
+
} else if (token.type === 'RBRACE') {
|
|
497
|
+
depth--;
|
|
498
|
+
if (depth === 0) {
|
|
499
|
+
// This is our closing brace
|
|
500
|
+
endToken = token;
|
|
501
|
+
endOffset = token.span.start.offset;
|
|
502
|
+
this.pos++;
|
|
503
|
+
break;
|
|
504
|
+
} else {
|
|
505
|
+
this.pos++;
|
|
506
|
+
}
|
|
507
|
+
} else {
|
|
508
|
+
this.pos++;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (depth > 0) {
|
|
513
|
+
throw new Error(`Unclosed describe block at ${this.file}:${startToken.span.start.line}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Extract raw content from source text
|
|
517
|
+
const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
kind: 'describe',
|
|
521
|
+
raw: rawContent,
|
|
522
|
+
source: {
|
|
523
|
+
file: this.file,
|
|
524
|
+
start: lbrace.span.end,
|
|
525
|
+
end: endToken ? endToken.span.start : lbrace.span.end,
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Parses a title block containing a string literal
|
|
532
|
+
* @returns {TitleBlock}
|
|
533
|
+
*/
|
|
534
|
+
parseTitle() {
|
|
535
|
+
const startToken = this.expect('IDENTIFIER', 'title');
|
|
536
|
+
const lbrace = this.expect('LBRACE');
|
|
537
|
+
|
|
538
|
+
// Find the matching closing brace by tracking depth
|
|
539
|
+
// We start at depth 1 (the opening brace we just consumed)
|
|
540
|
+
let depth = 1;
|
|
541
|
+
const startOffset = lbrace.span.end.offset;
|
|
542
|
+
let endOffset = startOffset;
|
|
543
|
+
let endToken = null;
|
|
544
|
+
|
|
545
|
+
// Consume tokens until we find the matching closing brace
|
|
546
|
+
while (depth > 0 && this.pos < this.tokens.length) {
|
|
547
|
+
const token = this.tokens[this.pos];
|
|
548
|
+
if (token.type === 'EOF') break;
|
|
549
|
+
|
|
550
|
+
if (token.type === 'LBRACE') {
|
|
551
|
+
depth++;
|
|
552
|
+
this.pos++;
|
|
553
|
+
} else if (token.type === 'RBRACE') {
|
|
554
|
+
depth--;
|
|
555
|
+
if (depth === 0) {
|
|
556
|
+
// This is our closing brace
|
|
557
|
+
endToken = token;
|
|
558
|
+
endOffset = token.span.start.offset;
|
|
559
|
+
this.pos++;
|
|
560
|
+
break;
|
|
561
|
+
} else {
|
|
562
|
+
this.pos++;
|
|
563
|
+
}
|
|
564
|
+
} else {
|
|
565
|
+
this.pos++;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (depth > 0) {
|
|
570
|
+
throw new Error(`Unclosed title block at ${this.file}:${startToken.span.start.line}`);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Extract raw content from source text
|
|
574
|
+
const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
kind: 'title',
|
|
578
|
+
raw: rawContent,
|
|
579
|
+
source: {
|
|
580
|
+
file: this.file,
|
|
581
|
+
start: lbrace.span.end,
|
|
582
|
+
end: endToken ? endToken.span.start : lbrace.span.end,
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Parses a references block containing nested reference blocks and using blocks
|
|
589
|
+
* @returns {ReferencesBlock}
|
|
590
|
+
*/
|
|
591
|
+
parseReferences() {
|
|
592
|
+
const startToken = this.expect('IDENTIFIER', 'references');
|
|
593
|
+
const lbrace = this.expect('LBRACE');
|
|
594
|
+
const references = [];
|
|
595
|
+
|
|
596
|
+
// Parse nested reference blocks and using blocks
|
|
597
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
598
|
+
// Check for 'using' keyword first (consistent with Scene layer)
|
|
599
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'using') {
|
|
600
|
+
references.push(this.parseUsingInReferences());
|
|
601
|
+
} else if ((this.match('IDENTIFIER') || this.match('KEYWORD')) && this.peek()?.value === 'reference') {
|
|
602
|
+
// Look for 'reference' identifier
|
|
603
|
+
references.push(this.parseReference());
|
|
604
|
+
} else {
|
|
605
|
+
// Skip unexpected tokens (tolerant parsing)
|
|
606
|
+
this.advance();
|
|
607
|
+
}
|
|
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
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Parses a using block inside a references block: using { IdentifierList }
|
|
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)
|
|
644
|
+
this.advance();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const rbrace = this.expect('RBRACE');
|
|
649
|
+
|
|
650
|
+
return {
|
|
651
|
+
kind: 'using-in-references',
|
|
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;
|
|
672
|
+
|
|
673
|
+
// Parse repository, paths, optional kind, and optional describe
|
|
674
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
675
|
+
if (this.match('RBRACE')) break;
|
|
676
|
+
|
|
677
|
+
// Check for identifier or keyword (describe is a keyword)
|
|
678
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
679
|
+
const identifier = this.peek()?.value;
|
|
680
|
+
if (identifier === 'repository') {
|
|
681
|
+
repository = this.parseStringBlock('repository');
|
|
682
|
+
} else if (identifier === 'paths') {
|
|
683
|
+
paths = this.parsePathsBlock();
|
|
684
|
+
} else if (identifier === 'kind') {
|
|
685
|
+
kind = this.parseStringBlock('kind');
|
|
686
|
+
} else if (identifier === 'describe') {
|
|
687
|
+
describe = this.parseDescribe();
|
|
688
|
+
} else {
|
|
689
|
+
// Skip unknown identifier/keyword
|
|
690
|
+
this.advance();
|
|
691
|
+
}
|
|
692
|
+
} else {
|
|
693
|
+
// Skip unexpected tokens
|
|
694
|
+
this.advance();
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const rbrace = this.expect('RBRACE');
|
|
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}`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
kind: 'reference',
|
|
709
|
+
repository,
|
|
710
|
+
paths,
|
|
711
|
+
referenceKind: kind,
|
|
712
|
+
describe,
|
|
713
|
+
source: {
|
|
714
|
+
file: this.file,
|
|
715
|
+
start: startToken.span.start,
|
|
716
|
+
end: rbrace.span.end,
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Parses a named reference block: reference <Name> { ... }
|
|
723
|
+
* @returns {NamedReferenceBlock}
|
|
724
|
+
*/
|
|
725
|
+
parseNamedReference() {
|
|
726
|
+
const startToken = this.expect('IDENTIFIER', 'reference');
|
|
727
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
728
|
+
const lbrace = this.expect('LBRACE');
|
|
729
|
+
let repository = null;
|
|
730
|
+
let paths = [];
|
|
731
|
+
let describe = null;
|
|
732
|
+
let kind = null;
|
|
733
|
+
|
|
734
|
+
// Parse repository, paths, optional kind, and optional describe (same as parseReference)
|
|
735
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
736
|
+
if (this.match('RBRACE')) break;
|
|
737
|
+
|
|
738
|
+
// Check for identifier or keyword (describe is a keyword)
|
|
739
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
740
|
+
const identifier = this.peek()?.value;
|
|
741
|
+
if (identifier === 'repository') {
|
|
742
|
+
repository = this.parseStringBlock('repository');
|
|
743
|
+
} else if (identifier === 'paths') {
|
|
744
|
+
paths = this.parsePathsBlock();
|
|
745
|
+
} else if (identifier === 'kind') {
|
|
746
|
+
kind = this.parseStringBlock('kind');
|
|
747
|
+
} else if (identifier === 'describe') {
|
|
748
|
+
describe = this.parseDescribe();
|
|
749
|
+
} else {
|
|
750
|
+
// Skip unknown identifier/keyword
|
|
751
|
+
this.advance();
|
|
752
|
+
}
|
|
753
|
+
} else {
|
|
754
|
+
// Skip unexpected tokens
|
|
755
|
+
this.advance();
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const rbrace = this.expect('RBRACE');
|
|
760
|
+
|
|
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
|
+
return {
|
|
769
|
+
kind: 'named-reference',
|
|
770
|
+
name: nameToken.value,
|
|
771
|
+
repository,
|
|
772
|
+
paths,
|
|
773
|
+
referenceKind: kind,
|
|
774
|
+
describe,
|
|
775
|
+
source: {
|
|
776
|
+
file: this.file,
|
|
777
|
+
start: startToken.span.start,
|
|
778
|
+
end: rbrace.span.end,
|
|
779
|
+
},
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Parses a string block (e.g., repository { 'string' } or repository { Identifier })
|
|
785
|
+
* @param {string} fieldName - Field name for error messages
|
|
786
|
+
* @returns {string}
|
|
787
|
+
*/
|
|
788
|
+
parseStringBlock(fieldName) {
|
|
789
|
+
// Accept either IDENTIFIER or KEYWORD (since 'repository' is now a keyword)
|
|
790
|
+
if (!this.match('IDENTIFIER') && !this.match('KEYWORD')) {
|
|
791
|
+
throw new Error(`Expected IDENTIFIER or KEYWORD '${fieldName}' at ${this.file}:${this.peek()?.span.start.line}`);
|
|
792
|
+
}
|
|
793
|
+
const fieldToken = this.advance();
|
|
794
|
+
if (fieldToken.value !== fieldName) {
|
|
795
|
+
throw new Error(`Expected '${fieldName}', got '${fieldToken.value}' at ${this.file}:${fieldToken.span.start.line}`);
|
|
796
|
+
}
|
|
797
|
+
const lbrace = this.expect('LBRACE');
|
|
798
|
+
|
|
799
|
+
// Find string literal or identifier
|
|
800
|
+
let stringValue = null;
|
|
801
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
802
|
+
if (this.match('STRING')) {
|
|
803
|
+
const stringToken = this.advance();
|
|
804
|
+
if (stringValue !== null) {
|
|
805
|
+
throw new Error(`Multiple values in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
|
|
806
|
+
}
|
|
807
|
+
stringValue = stringToken.value;
|
|
808
|
+
} else if (this.match('IDENTIFIER')) {
|
|
809
|
+
const identifierToken = this.advance();
|
|
810
|
+
if (stringValue !== null) {
|
|
811
|
+
throw new Error(`Multiple values in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
|
|
812
|
+
}
|
|
813
|
+
stringValue = identifierToken.value;
|
|
814
|
+
} else {
|
|
815
|
+
// Skip whitespace and other tokens
|
|
816
|
+
this.advance();
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const rbrace = this.expect('RBRACE');
|
|
821
|
+
|
|
822
|
+
if (stringValue === null) {
|
|
823
|
+
throw new Error(`Expected string literal or identifier in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return stringValue;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Parses a value block that can contain a string, identifier, or number
|
|
831
|
+
* @param {string} fieldName - Field name for error messages
|
|
832
|
+
* @returns {string | number}
|
|
833
|
+
*/
|
|
834
|
+
parseValueBlock(fieldName) {
|
|
835
|
+
this.expect('IDENTIFIER', fieldName);
|
|
836
|
+
const lbrace = this.expect('LBRACE');
|
|
837
|
+
|
|
838
|
+
let value = null;
|
|
839
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
840
|
+
if (this.match('STRING')) {
|
|
841
|
+
const stringToken = this.advance();
|
|
842
|
+
if (value !== null) {
|
|
843
|
+
throw new Error(`Multiple values in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
|
|
844
|
+
}
|
|
845
|
+
value = stringToken.value;
|
|
846
|
+
} else if (this.match('IDENTIFIER')) {
|
|
847
|
+
const identifierToken = this.advance();
|
|
848
|
+
if (value !== null) {
|
|
849
|
+
throw new Error(`Multiple values in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
|
|
850
|
+
}
|
|
851
|
+
value = identifierToken.value;
|
|
852
|
+
} else if (this.match('NUMBER')) {
|
|
853
|
+
const numberToken = this.advance();
|
|
854
|
+
if (value !== null) {
|
|
855
|
+
throw new Error(`Multiple values in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
|
|
856
|
+
}
|
|
857
|
+
value = numberToken.value;
|
|
858
|
+
} else {
|
|
859
|
+
// Skip whitespace and other tokens
|
|
860
|
+
this.advance();
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const rbrace = this.expect('RBRACE');
|
|
865
|
+
|
|
866
|
+
if (value === null) {
|
|
867
|
+
throw new Error(`Expected value (string, identifier, or number) in ${fieldName} block at ${this.file}:${lbrace.span.start.line}`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return value;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Parses an options block containing key-value pairs
|
|
875
|
+
* @returns {Record<string, string | number>}
|
|
876
|
+
*/
|
|
877
|
+
parseOptionsBlock() {
|
|
878
|
+
this.expect('IDENTIFIER', 'options');
|
|
879
|
+
const lbrace = this.expect('LBRACE');
|
|
880
|
+
const options = {};
|
|
881
|
+
|
|
882
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
883
|
+
if (this.match('IDENTIFIER')) {
|
|
884
|
+
const keyToken = this.advance();
|
|
885
|
+
const key = keyToken.value;
|
|
886
|
+
|
|
887
|
+
// Expect LBRACE for the value block
|
|
888
|
+
const valueLbrace = this.expect('LBRACE');
|
|
889
|
+
let value = null;
|
|
890
|
+
|
|
891
|
+
// Parse value (string, identifier, or number)
|
|
892
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
893
|
+
if (this.match('STRING')) {
|
|
894
|
+
const stringToken = this.advance();
|
|
895
|
+
if (value !== null) {
|
|
896
|
+
throw new Error(`Multiple values in ${key} block at ${this.file}:${valueLbrace.span.start.line}`);
|
|
897
|
+
}
|
|
898
|
+
value = stringToken.value;
|
|
899
|
+
} else if (this.match('IDENTIFIER')) {
|
|
900
|
+
const identifierToken = this.advance();
|
|
901
|
+
if (value !== null) {
|
|
902
|
+
throw new Error(`Multiple values in ${key} block at ${this.file}:${valueLbrace.span.start.line}`);
|
|
903
|
+
}
|
|
904
|
+
value = identifierToken.value;
|
|
905
|
+
} else if (this.match('NUMBER')) {
|
|
906
|
+
const numberToken = this.advance();
|
|
907
|
+
if (value !== null) {
|
|
908
|
+
throw new Error(`Multiple values in ${key} block at ${this.file}:${valueLbrace.span.start.line}`);
|
|
909
|
+
}
|
|
910
|
+
value = numberToken.value;
|
|
911
|
+
} else {
|
|
912
|
+
// Skip whitespace and other tokens
|
|
913
|
+
this.advance();
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const valueRbrace = this.expect('RBRACE');
|
|
918
|
+
|
|
919
|
+
if (value === null) {
|
|
920
|
+
throw new Error(`Expected value (string, identifier, or number) in ${key} block at ${this.file}:${valueLbrace.span.start.line}`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
options[key] = value;
|
|
924
|
+
} else {
|
|
925
|
+
// Skip unexpected tokens
|
|
926
|
+
this.advance();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const rbrace = this.expect('RBRACE');
|
|
931
|
+
return options;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Parses a repository block: repository <Identifier> { kind { ... } options { ... } }
|
|
936
|
+
* @returns {RepositoryDecl}
|
|
937
|
+
*/
|
|
938
|
+
parseRepository() {
|
|
939
|
+
const startToken = this.expect('KEYWORD', 'repository');
|
|
940
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
941
|
+
const lbrace = this.expect('LBRACE');
|
|
942
|
+
|
|
943
|
+
let kind = null;
|
|
944
|
+
let options = null;
|
|
945
|
+
|
|
946
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
947
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
948
|
+
const identifier = this.peek()?.value;
|
|
949
|
+
if (identifier === 'kind') {
|
|
950
|
+
kind = this.parseValueBlock('kind');
|
|
951
|
+
} else if (identifier === 'options') {
|
|
952
|
+
options = this.parseOptionsBlock();
|
|
953
|
+
} else {
|
|
954
|
+
// Skip unknown identifier/keyword
|
|
955
|
+
this.advance();
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
// Skip unexpected tokens
|
|
959
|
+
this.advance();
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const rbrace = this.expect('RBRACE');
|
|
964
|
+
|
|
965
|
+
if (!kind) {
|
|
966
|
+
throw new Error(`Missing 'kind' field in repository block at ${this.file}:${startToken.span.start.line}`);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
return {
|
|
970
|
+
kind: 'repository',
|
|
971
|
+
name: nameToken.value,
|
|
972
|
+
repositoryKind: kind,
|
|
973
|
+
options: options || {},
|
|
974
|
+
source: {
|
|
975
|
+
file: this.file,
|
|
976
|
+
start: startToken.span.start,
|
|
977
|
+
end: rbrace.span.end,
|
|
978
|
+
},
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Parses a paths block (e.g., paths { 'path1', 'path2' })
|
|
984
|
+
* @returns {string[]}
|
|
985
|
+
*/
|
|
986
|
+
parsePathsBlock() {
|
|
987
|
+
this.expect('IDENTIFIER', 'paths');
|
|
988
|
+
const lbrace = this.expect('LBRACE');
|
|
989
|
+
const paths = [];
|
|
990
|
+
|
|
991
|
+
// Parse string literals (commas are whitespace, so scanner skips them)
|
|
992
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
993
|
+
if (this.match('STRING')) {
|
|
994
|
+
const stringToken = this.advance();
|
|
995
|
+
paths.push(stringToken.value);
|
|
996
|
+
} else {
|
|
997
|
+
// Skip non-string tokens (whitespace/comments already skipped by scanner)
|
|
998
|
+
this.advance();
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const rbrace = this.expect('RBRACE');
|
|
1003
|
+
|
|
1004
|
+
return paths;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Parses a documentation block containing nested document blocks
|
|
1009
|
+
* @returns {DocumentationBlock}
|
|
1010
|
+
*/
|
|
1011
|
+
parseDocumentation() {
|
|
1012
|
+
const startToken = this.expect('IDENTIFIER', 'documentation');
|
|
1013
|
+
const lbrace = this.expect('LBRACE');
|
|
1014
|
+
const documents = [];
|
|
1015
|
+
|
|
1016
|
+
// Parse nested document blocks
|
|
1017
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
1018
|
+
// Look for 'document' identifier
|
|
1019
|
+
if ((this.match('IDENTIFIER') || this.match('KEYWORD')) && this.peek()?.value === 'document') {
|
|
1020
|
+
documents.push(this.parseDocument());
|
|
1021
|
+
} else {
|
|
1022
|
+
// Skip unexpected tokens (tolerant parsing)
|
|
1023
|
+
this.advance();
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const rbrace = this.expect('RBRACE');
|
|
1028
|
+
|
|
1029
|
+
return {
|
|
1030
|
+
kind: 'documentation',
|
|
1031
|
+
documents,
|
|
1032
|
+
source: {
|
|
1033
|
+
file: this.file,
|
|
1034
|
+
start: startToken.span.start,
|
|
1035
|
+
end: rbrace.span.end,
|
|
1036
|
+
},
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Parses a single document block
|
|
1042
|
+
* Supports both forms: `document Title { ... }` and `document { ... }`
|
|
1043
|
+
* @returns {DocumentBlock}
|
|
1044
|
+
*/
|
|
1045
|
+
parseDocument() {
|
|
1046
|
+
const startToken = this.expect('IDENTIFIER', 'document');
|
|
1047
|
+
let title = null;
|
|
1048
|
+
|
|
1049
|
+
// Check if there's an optional title identifier before the brace
|
|
1050
|
+
if (this.match('IDENTIFIER')) {
|
|
1051
|
+
// Peek ahead to see if next token is LBRACE (meaning this is a title)
|
|
1052
|
+
const nextPos = this.pos + 1;
|
|
1053
|
+
if (nextPos < this.tokens.length && this.tokens[nextPos].type === 'LBRACE') {
|
|
1054
|
+
const titleToken = this.expect('IDENTIFIER');
|
|
1055
|
+
title = titleToken.value;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const lbrace = this.expect('LBRACE');
|
|
1060
|
+
let kind = null;
|
|
1061
|
+
let path = null;
|
|
1062
|
+
let describe = null;
|
|
1063
|
+
|
|
1064
|
+
// Parse kind, path (both required), and optional describe
|
|
1065
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
1066
|
+
if (this.match('RBRACE')) break;
|
|
1067
|
+
|
|
1068
|
+
// Check for identifier or keyword (describe is a keyword)
|
|
1069
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
1070
|
+
const identifier = this.peek()?.value;
|
|
1071
|
+
if (identifier === 'kind') {
|
|
1072
|
+
kind = this.parseStringBlock('kind');
|
|
1073
|
+
} else if (identifier === 'path') {
|
|
1074
|
+
path = this.parseStringBlock('path');
|
|
1075
|
+
} else if (identifier === 'describe') {
|
|
1076
|
+
describe = this.parseDescribe();
|
|
1077
|
+
} else {
|
|
1078
|
+
// Skip unknown identifier/keyword
|
|
1079
|
+
this.advance();
|
|
1080
|
+
}
|
|
1081
|
+
} else {
|
|
1082
|
+
// Skip unexpected tokens
|
|
1083
|
+
this.advance();
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const rbrace = this.expect('RBRACE');
|
|
1088
|
+
|
|
1089
|
+
if (!kind) {
|
|
1090
|
+
throw new Error(`Missing 'kind' field in document block at ${this.file}:${startToken.span.start.line}`);
|
|
1091
|
+
}
|
|
1092
|
+
if (!path) {
|
|
1093
|
+
throw new Error(`Missing 'path' field in document block at ${this.file}:${startToken.span.start.line}`);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
return {
|
|
1097
|
+
kind: 'document',
|
|
1098
|
+
title: title || undefined,
|
|
1099
|
+
documentKind: kind,
|
|
1100
|
+
path,
|
|
1101
|
+
describe,
|
|
1102
|
+
source: {
|
|
1103
|
+
file: this.file,
|
|
1104
|
+
start: startToken.span.start,
|
|
1105
|
+
end: rbrace.span.end,
|
|
1106
|
+
},
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Parses a named document block: document <Name> { ... }
|
|
1112
|
+
* @returns {NamedDocumentBlock}
|
|
1113
|
+
*/
|
|
1114
|
+
parseNamedDocument() {
|
|
1115
|
+
const startToken = this.expect('IDENTIFIER', 'document');
|
|
1116
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
1117
|
+
const lbrace = this.expect('LBRACE');
|
|
1118
|
+
let kind = null;
|
|
1119
|
+
let path = null;
|
|
1120
|
+
let describe = null;
|
|
1121
|
+
|
|
1122
|
+
// Parse kind, path (both required), and optional describe (same as parseDocument, but no title)
|
|
1123
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
1124
|
+
if (this.match('RBRACE')) break;
|
|
1125
|
+
|
|
1126
|
+
// Check for identifier or keyword (describe is a keyword)
|
|
1127
|
+
if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
|
|
1128
|
+
const identifier = this.peek()?.value;
|
|
1129
|
+
if (identifier === 'kind') {
|
|
1130
|
+
kind = this.parseStringBlock('kind');
|
|
1131
|
+
} else if (identifier === 'path') {
|
|
1132
|
+
path = this.parseStringBlock('path');
|
|
1133
|
+
} else if (identifier === 'describe') {
|
|
1134
|
+
describe = this.parseDescribe();
|
|
1135
|
+
} else {
|
|
1136
|
+
// Skip unknown identifier/keyword
|
|
1137
|
+
this.advance();
|
|
1138
|
+
}
|
|
1139
|
+
} else {
|
|
1140
|
+
// Skip unexpected tokens
|
|
1141
|
+
this.advance();
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const rbrace = this.expect('RBRACE');
|
|
1146
|
+
|
|
1147
|
+
if (!kind) {
|
|
1148
|
+
throw new Error(`Missing 'kind' field in named document block at ${this.file}:${startToken.span.start.line}`);
|
|
1149
|
+
}
|
|
1150
|
+
if (!path) {
|
|
1151
|
+
throw new Error(`Missing 'path' field in named document block at ${this.file}:${startToken.span.start.line}`);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return {
|
|
1155
|
+
kind: 'named-document',
|
|
1156
|
+
name: nameToken.value,
|
|
1157
|
+
documentKind: kind,
|
|
1158
|
+
path,
|
|
1159
|
+
describe,
|
|
1160
|
+
source: {
|
|
1161
|
+
file: this.file,
|
|
1162
|
+
start: startToken.span.start,
|
|
1163
|
+
end: rbrace.span.end,
|
|
1164
|
+
},
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* @returns {SceneDecl}
|
|
1170
|
+
*/
|
|
1171
|
+
parseScene() {
|
|
1172
|
+
const startToken = this.expect('KEYWORD', 'scene');
|
|
1173
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
1174
|
+
this.expect('KEYWORD', 'for');
|
|
1175
|
+
// Target can be a dot-separated path like "Amaranthine.Items"
|
|
1176
|
+
// Parse as: IDENTIFIER (DOT IDENTIFIER)*
|
|
1177
|
+
const targetParts = [];
|
|
1178
|
+
targetParts.push(this.expect('IDENTIFIER').value);
|
|
1179
|
+
|
|
1180
|
+
// Consume dot-separated identifiers
|
|
1181
|
+
while (this.match('DOT')) {
|
|
1182
|
+
this.expect('DOT'); // consume dot
|
|
1183
|
+
if (this.match('IDENTIFIER')) {
|
|
1184
|
+
targetParts.push(this.expect('IDENTIFIER').value);
|
|
1185
|
+
} else {
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const target = targetParts.join('.');
|
|
1191
|
+
const lbrace = this.expect('LBRACE');
|
|
1192
|
+
const body = this.parseSceneBody();
|
|
1193
|
+
const rbrace = this.expect('RBRACE');
|
|
1194
|
+
|
|
1195
|
+
return {
|
|
1196
|
+
kind: 'scene',
|
|
1197
|
+
name: nameToken.value,
|
|
1198
|
+
target,
|
|
1199
|
+
body,
|
|
1200
|
+
source: {
|
|
1201
|
+
file: this.file,
|
|
1202
|
+
start: startToken.span.start,
|
|
1203
|
+
end: rbrace.span.end,
|
|
1204
|
+
},
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Parses scene body blocks
|
|
1210
|
+
* @returns {Array<UsingBlock | ActorDecl | DescribeBlock | UnknownBlock>}
|
|
1211
|
+
*/
|
|
1212
|
+
parseSceneBody() {
|
|
1213
|
+
const body = [];
|
|
1214
|
+
|
|
1215
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
1216
|
+
if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
|
|
1217
|
+
const keyword = this.peek()?.value;
|
|
1218
|
+
if (!keyword) break;
|
|
1219
|
+
|
|
1220
|
+
if (keyword === 'using' && this.match('KEYWORD')) {
|
|
1221
|
+
body.push(this.parseUsing());
|
|
1222
|
+
} else if (keyword === 'actor' && this.match('KEYWORD')) {
|
|
1223
|
+
body.push(this.parseActor());
|
|
1224
|
+
} else if (keyword === 'describe' && this.match('KEYWORD')) {
|
|
1225
|
+
body.push(this.parseDescribe());
|
|
1226
|
+
} else {
|
|
1227
|
+
// Unknown keyword/identifier followed by brace - parse as UnknownBlock
|
|
1228
|
+
const nextPos = this.pos + 1;
|
|
1229
|
+
if (nextPos < this.tokens.length && this.tokens[nextPos].type === 'LBRACE') {
|
|
1230
|
+
body.push(this.parseUnknownBlock());
|
|
1231
|
+
} else {
|
|
1232
|
+
// Not a block, just skip this token (tolerant parsing)
|
|
1233
|
+
this.advance();
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
} else {
|
|
1237
|
+
// Unexpected token in body - skip (tolerant)
|
|
1238
|
+
this.advance();
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return body;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* @returns {UsingBlock}
|
|
1247
|
+
*/
|
|
1248
|
+
parseUsing() {
|
|
1249
|
+
const startToken = this.expect('KEYWORD', 'using');
|
|
1250
|
+
const lbrace = this.expect('LBRACE');
|
|
1251
|
+
const identifiers = [];
|
|
1252
|
+
|
|
1253
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
1254
|
+
if (this.match('IDENTIFIER')) {
|
|
1255
|
+
identifiers.push(this.expect('IDENTIFIER').value);
|
|
1256
|
+
// Skip optional comma
|
|
1257
|
+
if (this.match('COMMA')) {
|
|
1258
|
+
this.expect('COMMA');
|
|
1259
|
+
}
|
|
1260
|
+
} else if (this.match('COMMA')) {
|
|
1261
|
+
// Skip stray commas
|
|
1262
|
+
this.expect('COMMA');
|
|
1263
|
+
} else {
|
|
1264
|
+
// Skip non-identifiers (tolerant parsing)
|
|
1265
|
+
this.advance();
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const rbrace = this.expect('RBRACE');
|
|
1270
|
+
|
|
1271
|
+
return {
|
|
1272
|
+
kind: 'using',
|
|
1273
|
+
identifiers,
|
|
1274
|
+
source: {
|
|
1275
|
+
file: this.file,
|
|
1276
|
+
start: startToken.span.start,
|
|
1277
|
+
end: rbrace.span.end,
|
|
1278
|
+
},
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* @returns {ActorDecl}
|
|
1284
|
+
*/
|
|
1285
|
+
parseActor() {
|
|
1286
|
+
const startToken = this.expect('KEYWORD', 'actor');
|
|
1287
|
+
const nameToken = this.expect('IDENTIFIER');
|
|
1288
|
+
const lbrace = this.expect('LBRACE');
|
|
1289
|
+
const body = this.parseActorBody();
|
|
1290
|
+
const rbrace = this.expect('RBRACE');
|
|
1291
|
+
|
|
1292
|
+
return {
|
|
1293
|
+
kind: 'actor',
|
|
1294
|
+
name: nameToken.value,
|
|
1295
|
+
body,
|
|
1296
|
+
source: {
|
|
1297
|
+
file: this.file,
|
|
1298
|
+
start: startToken.span.start,
|
|
1299
|
+
end: rbrace.span.end,
|
|
1300
|
+
},
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/**
|
|
1305
|
+
* Parses actor body blocks
|
|
1306
|
+
* @returns {Array<DescribeBlock | TypeBlock | IdentityBlock | SourceBlock | TransformsBlock | UnknownBlock>}
|
|
1307
|
+
*/
|
|
1308
|
+
parseActorBody() {
|
|
1309
|
+
const body = [];
|
|
1310
|
+
|
|
1311
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
1312
|
+
if (this.match('KEYWORD') || this.match('IDENTIFIER')) {
|
|
1313
|
+
const keyword = this.peek()?.value;
|
|
1314
|
+
if (!keyword) break;
|
|
1315
|
+
|
|
1316
|
+
if (keyword === 'describe' && this.match('KEYWORD')) {
|
|
1317
|
+
body.push(this.parseDescribe());
|
|
1318
|
+
} else if (keyword === 'type' && this.match('KEYWORD')) {
|
|
1319
|
+
body.push(this.parseTypeBlock());
|
|
1320
|
+
} else if (keyword === 'identity' && this.match('KEYWORD')) {
|
|
1321
|
+
body.push(this.parseIdentityBlock());
|
|
1322
|
+
} else if (keyword === 'file' && (this.match('KEYWORD') || this.match('IDENTIFIER'))) {
|
|
1323
|
+
body.push(this.parseSourceBlock('file'));
|
|
1324
|
+
} else if (keyword === 'sqlite' && (this.match('KEYWORD') || this.match('IDENTIFIER'))) {
|
|
1325
|
+
body.push(this.parseSourceBlock('sqlite'));
|
|
1326
|
+
} else if (keyword === 'mysql' && (this.match('KEYWORD') || this.match('IDENTIFIER'))) {
|
|
1327
|
+
body.push(this.parseSourceBlock('mysql'));
|
|
1328
|
+
} else if (keyword === 'transforms' && this.match('KEYWORD')) {
|
|
1329
|
+
body.push(this.parseTransformsBlock());
|
|
1330
|
+
} else {
|
|
1331
|
+
// Unknown keyword/identifier followed by brace - parse as UnknownBlock
|
|
1332
|
+
const nextPos = this.pos + 1;
|
|
1333
|
+
if (nextPos < this.tokens.length && this.tokens[nextPos].type === 'LBRACE') {
|
|
1334
|
+
body.push(this.parseUnknownBlock());
|
|
1335
|
+
} else {
|
|
1336
|
+
// Not a block, just skip this token (tolerant parsing)
|
|
1337
|
+
this.advance();
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
} else {
|
|
1341
|
+
// Unexpected token in body - skip (tolerant)
|
|
1342
|
+
this.advance();
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
return body;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* @returns {TypeBlock}
|
|
1351
|
+
*/
|
|
1352
|
+
parseTypeBlock() {
|
|
1353
|
+
const startToken = this.expect('KEYWORD', 'type');
|
|
1354
|
+
const lbrace = this.expect('LBRACE');
|
|
1355
|
+
|
|
1356
|
+
// Track brace depth to find matching closing brace
|
|
1357
|
+
let depth = 1;
|
|
1358
|
+
const startOffset = lbrace.span.end.offset;
|
|
1359
|
+
let endOffset = startOffset;
|
|
1360
|
+
let endToken = null;
|
|
1361
|
+
|
|
1362
|
+
while (depth > 0 && this.pos < this.tokens.length) {
|
|
1363
|
+
const token = this.tokens[this.pos];
|
|
1364
|
+
if (token.type === 'EOF') break;
|
|
1365
|
+
|
|
1366
|
+
if (token.type === 'LBRACE') {
|
|
1367
|
+
depth++;
|
|
1368
|
+
this.pos++;
|
|
1369
|
+
} else if (token.type === 'RBRACE') {
|
|
1370
|
+
depth--;
|
|
1371
|
+
if (depth === 0) {
|
|
1372
|
+
endToken = token;
|
|
1373
|
+
endOffset = token.span.start.offset;
|
|
1374
|
+
this.pos++;
|
|
1375
|
+
break;
|
|
1376
|
+
} else {
|
|
1377
|
+
this.pos++;
|
|
1378
|
+
}
|
|
1379
|
+
} else {
|
|
1380
|
+
this.pos++;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if (depth > 0) {
|
|
1385
|
+
throw new Error(`Unclosed type block at ${this.file}:${startToken.span.start.line}`);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// Extract raw content from source text
|
|
1389
|
+
const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
|
|
1390
|
+
|
|
1391
|
+
return {
|
|
1392
|
+
kind: 'type',
|
|
1393
|
+
raw: rawContent,
|
|
1394
|
+
source: {
|
|
1395
|
+
file: this.file,
|
|
1396
|
+
start: lbrace.span.end,
|
|
1397
|
+
end: endToken ? endToken.span.start : lbrace.span.end,
|
|
1398
|
+
},
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* @returns {IdentityBlock}
|
|
1404
|
+
*/
|
|
1405
|
+
parseIdentityBlock() {
|
|
1406
|
+
const startToken = this.expect('KEYWORD', 'identity');
|
|
1407
|
+
const lbrace = this.expect('LBRACE');
|
|
1408
|
+
|
|
1409
|
+
// Track brace depth to find matching closing brace
|
|
1410
|
+
let depth = 1;
|
|
1411
|
+
const startOffset = lbrace.span.end.offset;
|
|
1412
|
+
let endOffset = startOffset;
|
|
1413
|
+
let endToken = null;
|
|
1414
|
+
|
|
1415
|
+
while (depth > 0 && this.pos < this.tokens.length) {
|
|
1416
|
+
const token = this.tokens[this.pos];
|
|
1417
|
+
if (token.type === 'EOF') break;
|
|
1418
|
+
|
|
1419
|
+
if (token.type === 'LBRACE') {
|
|
1420
|
+
depth++;
|
|
1421
|
+
this.pos++;
|
|
1422
|
+
} else if (token.type === 'RBRACE') {
|
|
1423
|
+
depth--;
|
|
1424
|
+
if (depth === 0) {
|
|
1425
|
+
endToken = token;
|
|
1426
|
+
endOffset = token.span.start.offset;
|
|
1427
|
+
this.pos++;
|
|
1428
|
+
break;
|
|
1429
|
+
} else {
|
|
1430
|
+
this.pos++;
|
|
1431
|
+
}
|
|
1432
|
+
} else {
|
|
1433
|
+
this.pos++;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
if (depth > 0) {
|
|
1438
|
+
throw new Error(`Unclosed identity block at ${this.file}:${startToken.span.start.line}`);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Extract raw content from source text
|
|
1442
|
+
const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
|
|
1443
|
+
|
|
1444
|
+
return {
|
|
1445
|
+
kind: 'identity',
|
|
1446
|
+
raw: rawContent,
|
|
1447
|
+
source: {
|
|
1448
|
+
file: this.file,
|
|
1449
|
+
start: lbrace.span.end,
|
|
1450
|
+
end: endToken ? endToken.span.start : lbrace.span.end,
|
|
1451
|
+
},
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* @param {string} sourceType - 'file', 'sqlite', or 'mysql'
|
|
1457
|
+
* @returns {SourceBlock}
|
|
1458
|
+
*/
|
|
1459
|
+
parseSourceBlock(sourceType) {
|
|
1460
|
+
const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', sourceType) : this.expect('IDENTIFIER');
|
|
1461
|
+
const lbrace = this.expect('LBRACE');
|
|
1462
|
+
|
|
1463
|
+
// Track brace depth to find matching closing brace
|
|
1464
|
+
let depth = 1;
|
|
1465
|
+
const startOffset = lbrace.span.end.offset;
|
|
1466
|
+
let endOffset = startOffset;
|
|
1467
|
+
let endToken = null;
|
|
1468
|
+
|
|
1469
|
+
while (depth > 0 && this.pos < this.tokens.length) {
|
|
1470
|
+
const token = this.tokens[this.pos];
|
|
1471
|
+
if (token.type === 'EOF') break;
|
|
1472
|
+
|
|
1473
|
+
if (token.type === 'LBRACE') {
|
|
1474
|
+
depth++;
|
|
1475
|
+
this.pos++;
|
|
1476
|
+
} else if (token.type === 'RBRACE') {
|
|
1477
|
+
depth--;
|
|
1478
|
+
if (depth === 0) {
|
|
1479
|
+
endToken = token;
|
|
1480
|
+
endOffset = token.span.start.offset;
|
|
1481
|
+
this.pos++;
|
|
1482
|
+
break;
|
|
1483
|
+
} else {
|
|
1484
|
+
this.pos++;
|
|
1485
|
+
}
|
|
1486
|
+
} else {
|
|
1487
|
+
this.pos++;
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (depth > 0) {
|
|
1492
|
+
throw new Error(`Unclosed ${sourceType} block at ${this.file}:${startToken.span.start.line}`);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Extract raw content from source text
|
|
1496
|
+
const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
|
|
1497
|
+
|
|
1498
|
+
return {
|
|
1499
|
+
kind: 'source',
|
|
1500
|
+
sourceType,
|
|
1501
|
+
raw: rawContent,
|
|
1502
|
+
source: {
|
|
1503
|
+
file: this.file,
|
|
1504
|
+
start: lbrace.span.end,
|
|
1505
|
+
end: endToken ? endToken.span.start : lbrace.span.end,
|
|
1506
|
+
},
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* @returns {TransformsBlock}
|
|
1512
|
+
*/
|
|
1513
|
+
parseTransformsBlock() {
|
|
1514
|
+
const startToken = this.expect('KEYWORD', 'transforms');
|
|
1515
|
+
const lbrace = this.expect('LBRACE');
|
|
1516
|
+
const transforms = [];
|
|
1517
|
+
|
|
1518
|
+
while (!this.match('RBRACE') && !this.match('EOF')) {
|
|
1519
|
+
if (this.match('KEYWORD') && this.peek()?.value === 'transform') {
|
|
1520
|
+
transforms.push(this.parseTransformBlock());
|
|
1521
|
+
} else {
|
|
1522
|
+
// Skip non-transform blocks (tolerant parsing)
|
|
1523
|
+
this.advance();
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
const rbrace = this.expect('RBRACE');
|
|
1528
|
+
|
|
1529
|
+
return {
|
|
1530
|
+
kind: 'transforms',
|
|
1531
|
+
transforms,
|
|
1532
|
+
source: {
|
|
1533
|
+
file: this.file,
|
|
1534
|
+
start: startToken.span.start,
|
|
1535
|
+
end: rbrace.span.end,
|
|
1536
|
+
},
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/**
|
|
1541
|
+
* @returns {TransformBlock}
|
|
1542
|
+
*/
|
|
1543
|
+
parseTransformBlock() {
|
|
1544
|
+
const startToken = this.expect('KEYWORD', 'transform');
|
|
1545
|
+
const lbrace = this.expect('LBRACE');
|
|
1546
|
+
|
|
1547
|
+
// Track brace depth to find matching closing brace
|
|
1548
|
+
let depth = 1;
|
|
1549
|
+
const startOffset = lbrace.span.end.offset;
|
|
1550
|
+
let endOffset = startOffset;
|
|
1551
|
+
let endToken = null;
|
|
1552
|
+
|
|
1553
|
+
while (depth > 0 && this.pos < this.tokens.length) {
|
|
1554
|
+
const token = this.tokens[this.pos];
|
|
1555
|
+
if (token.type === 'EOF') break;
|
|
1556
|
+
|
|
1557
|
+
if (token.type === 'LBRACE') {
|
|
1558
|
+
depth++;
|
|
1559
|
+
this.pos++;
|
|
1560
|
+
} else if (token.type === 'RBRACE') {
|
|
1561
|
+
depth--;
|
|
1562
|
+
if (depth === 0) {
|
|
1563
|
+
endToken = token;
|
|
1564
|
+
endOffset = token.span.start.offset;
|
|
1565
|
+
this.pos++;
|
|
1566
|
+
break;
|
|
1567
|
+
} else {
|
|
1568
|
+
this.pos++;
|
|
1569
|
+
}
|
|
1570
|
+
} else {
|
|
1571
|
+
this.pos++;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
if (depth > 0) {
|
|
1576
|
+
throw new Error(`Unclosed transform block at ${this.file}:${startToken.span.start.line}`);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// Extract raw content from source text
|
|
1580
|
+
const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
|
|
1581
|
+
|
|
1582
|
+
return {
|
|
1583
|
+
kind: 'transform',
|
|
1584
|
+
raw: rawContent,
|
|
1585
|
+
source: {
|
|
1586
|
+
file: this.file,
|
|
1587
|
+
start: lbrace.span.end,
|
|
1588
|
+
end: endToken ? endToken.span.start : lbrace.span.end,
|
|
1589
|
+
},
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Parses an unknown block with balanced brace scanning
|
|
1595
|
+
* @returns {UnknownBlock}
|
|
1596
|
+
*/
|
|
1597
|
+
parseUnknownBlock() {
|
|
1598
|
+
// Can be either KEYWORD or IDENTIFIER
|
|
1599
|
+
const token = this.peek();
|
|
1600
|
+
if (!token || (token.type !== 'KEYWORD' && token.type !== 'IDENTIFIER')) {
|
|
1601
|
+
throw new Error(
|
|
1602
|
+
`Expected keyword or identifier for unknown block at ${this.file}:${token?.span.start.line || '?'}`,
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
const keywordToken = this.advance();
|
|
1606
|
+
const lbrace = this.expect('LBRACE');
|
|
1607
|
+
|
|
1608
|
+
// Track brace depth to find matching closing brace
|
|
1609
|
+
let depth = 1;
|
|
1610
|
+
const startOffset = lbrace.span.end.offset;
|
|
1611
|
+
let endOffset = startOffset;
|
|
1612
|
+
let endToken = null;
|
|
1613
|
+
|
|
1614
|
+
while (depth > 0 && this.pos < this.tokens.length) {
|
|
1615
|
+
const token = this.tokens[this.pos];
|
|
1616
|
+
if (token.type === 'EOF') break;
|
|
1617
|
+
|
|
1618
|
+
if (token.type === 'LBRACE') {
|
|
1619
|
+
depth++;
|
|
1620
|
+
this.pos++;
|
|
1621
|
+
} else if (token.type === 'RBRACE') {
|
|
1622
|
+
depth--;
|
|
1623
|
+
if (depth === 0) {
|
|
1624
|
+
endToken = token;
|
|
1625
|
+
endOffset = token.span.start.offset;
|
|
1626
|
+
this.pos++;
|
|
1627
|
+
break;
|
|
1628
|
+
} else {
|
|
1629
|
+
this.pos++;
|
|
1630
|
+
}
|
|
1631
|
+
} else {
|
|
1632
|
+
this.pos++;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
if (depth > 0) {
|
|
1637
|
+
const keywordValue = keywordToken ? keywordToken.value : 'unknown';
|
|
1638
|
+
const line = keywordToken ? keywordToken.span.start.line : '?';
|
|
1639
|
+
throw new Error(`Unclosed block "${keywordValue}" at ${this.file}:${line}`);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Extract raw content from source text
|
|
1643
|
+
const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
|
|
1644
|
+
|
|
1645
|
+
return {
|
|
1646
|
+
keyword: keywordToken.value,
|
|
1647
|
+
raw: rawContent,
|
|
1648
|
+
source: {
|
|
1649
|
+
file: this.file,
|
|
1650
|
+
start: lbrace.span.end,
|
|
1651
|
+
end: endToken ? endToken.span.start : lbrace.span.end,
|
|
1652
|
+
},
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|