@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,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Converter from AST to Scene Manifest JSON
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import('../ast.js').FileAST} FileAST
|
|
7
|
+
* @typedef {import('../ast.js').SceneDecl} SceneDecl
|
|
8
|
+
* @typedef {import('../ast.js').ActorDecl} ActorDecl
|
|
9
|
+
* @typedef {import('../ast.js').UsingBlock} UsingBlock
|
|
10
|
+
* @typedef {import('../ast.js').DescribeBlock} DescribeBlock
|
|
11
|
+
* @typedef {import('../ast.js').TypeBlock} TypeBlock
|
|
12
|
+
* @typedef {import('../ast.js').IdentityBlock} IdentityBlock
|
|
13
|
+
* @typedef {import('../ast.js').SourceBlock} SourceBlock
|
|
14
|
+
* @typedef {import('../ast.js').TransformsBlock} TransformsBlock
|
|
15
|
+
* @typedef {import('../ast.js').TransformBlock} TransformBlock
|
|
16
|
+
* @typedef {import('../ast.js').UnknownBlock} UnknownBlock
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalizes describe block text (similar to universe layer)
|
|
21
|
+
* @param {string} raw - Raw text content
|
|
22
|
+
* @returns {string} Normalized text
|
|
23
|
+
*/
|
|
24
|
+
function normalizeDescribe(raw) {
|
|
25
|
+
// Trim leading/trailing whitespace and normalize internal whitespace
|
|
26
|
+
return raw
|
|
27
|
+
.split('\n')
|
|
28
|
+
.map((line) => line.trim())
|
|
29
|
+
.filter((line) => line.length > 0)
|
|
30
|
+
.join(' ')
|
|
31
|
+
.replace(/\s+/g, ' ')
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Converts a source span to location format
|
|
37
|
+
* @param {import('../ast.js').SourceSpan} source
|
|
38
|
+
* @returns {{ file: string, start: { line: number, col: number, offset: number }, end: { line: number, col: number, offset: number } }}
|
|
39
|
+
*/
|
|
40
|
+
function sourceToLocation(source) {
|
|
41
|
+
return {
|
|
42
|
+
file: source.file,
|
|
43
|
+
start: source.start,
|
|
44
|
+
end: source.end,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parses a type expression into a structured AST
|
|
50
|
+
* @param {string} typeExpr - Type expression string
|
|
51
|
+
* @returns {Object} Structured type AST
|
|
52
|
+
*/
|
|
53
|
+
function parseTypeExpression(typeExpr) {
|
|
54
|
+
const trimmed = typeExpr.trim();
|
|
55
|
+
|
|
56
|
+
// Handle optional wrapper: "optional T"
|
|
57
|
+
const optionalMatch = trimmed.match(/^optional\s+(.+)$/i);
|
|
58
|
+
if (optionalMatch) {
|
|
59
|
+
return {
|
|
60
|
+
kind: 'optional',
|
|
61
|
+
of: parseTypeExpression(optionalMatch[1]),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Handle array: "[ T ]"
|
|
66
|
+
const arrayMatch = trimmed.match(/^\[\s*(.+?)\s*\]$/);
|
|
67
|
+
if (arrayMatch) {
|
|
68
|
+
return {
|
|
69
|
+
kind: 'array',
|
|
70
|
+
elementType: parseTypeExpression(arrayMatch[1]),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle enum/union: "one of { ... }"
|
|
75
|
+
const oneOfMatch = trimmed.match(/^one\s+of\s+\{\s*(.+?)\s*\}$/i);
|
|
76
|
+
if (oneOfMatch) {
|
|
77
|
+
const valuesStr = oneOfMatch[1];
|
|
78
|
+
// Parse values (can be strings, numbers, or identifiers)
|
|
79
|
+
const values = [];
|
|
80
|
+
const valueParts = valuesStr.split(',').map((v) => v.trim());
|
|
81
|
+
|
|
82
|
+
for (const part of valueParts) {
|
|
83
|
+
// Check if it's a quoted string
|
|
84
|
+
const stringMatch = part.match(/^['"](.+?)['"]$/);
|
|
85
|
+
if (stringMatch) {
|
|
86
|
+
values.push({ kind: 'string', value: stringMatch[1] });
|
|
87
|
+
} else if (/^\d+$/.test(part)) {
|
|
88
|
+
// Number
|
|
89
|
+
values.push({ kind: 'number', value: parseInt(part, 10) });
|
|
90
|
+
} else {
|
|
91
|
+
// Identifier or other
|
|
92
|
+
values.push({ kind: 'identifier', value: part });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Infer value type from first value
|
|
97
|
+
const valueType = values.length > 0 ? values[0].kind : 'unknown';
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
kind: 'oneOf',
|
|
101
|
+
values: values.map((v) => v.value),
|
|
102
|
+
valueType,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Handle primitive types or type references
|
|
107
|
+
const primitiveTypes = new Set(['integer', 'string', 'boolean', 'number', 'float', 'double']);
|
|
108
|
+
if (primitiveTypes.has(trimmed.toLowerCase())) {
|
|
109
|
+
return {
|
|
110
|
+
kind: 'primitive',
|
|
111
|
+
name: trimmed.toLowerCase(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Type reference (e.g., "Tool", "Item")
|
|
116
|
+
if (/^[A-Z][A-Za-z0-9_]*$/.test(trimmed)) {
|
|
117
|
+
return {
|
|
118
|
+
kind: 'reference',
|
|
119
|
+
name: trimmed,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Fallback: unknown type
|
|
124
|
+
return {
|
|
125
|
+
kind: 'unknown',
|
|
126
|
+
raw: trimmed,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Extracts a light structure from type block raw content
|
|
132
|
+
* Attempts to parse simple field definitions but keeps it minimal
|
|
133
|
+
* @param {string} raw - Raw type block content
|
|
134
|
+
* @param {import('../ast.js').SourceSpan} typeBlockLocation - Location of the type block
|
|
135
|
+
* @returns {{ fields?: Array<{ name: string, typeExpr: string, type: Object, required: boolean, location?: Object }> } | null}
|
|
136
|
+
*/
|
|
137
|
+
function extractTypeFields(raw, typeBlockLocation) {
|
|
138
|
+
// Very light extraction - just try to find field: type patterns
|
|
139
|
+
// This is forward-compatible and doesn't do full type parsing
|
|
140
|
+
const fields = [];
|
|
141
|
+
const lines = raw.split('\n');
|
|
142
|
+
|
|
143
|
+
// Track line offset relative to type block start
|
|
144
|
+
// The start line is the opening brace line, and the first field is on the next line
|
|
145
|
+
// So we initialize to start.line and increment before processing each line
|
|
146
|
+
let currentLine = typeBlockLocation.start.line;
|
|
147
|
+
let lineOffset = 0;
|
|
148
|
+
|
|
149
|
+
// Track indentation levels to detect nested structures
|
|
150
|
+
const getIndentLevel = (line) => {
|
|
151
|
+
const match = line.match(/^(\s*)/);
|
|
152
|
+
return match ? match[1].length : 0;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Find the minimum indentation level (base indent)
|
|
156
|
+
// This handles cases where the first line has no indent but subsequent lines do
|
|
157
|
+
let baseIndent = Infinity;
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
const trimmed = line.trim();
|
|
160
|
+
if (trimmed && !trimmed.startsWith('//') && !trimmed.startsWith('--')) {
|
|
161
|
+
const indent = getIndentLevel(line);
|
|
162
|
+
if (indent < baseIndent) {
|
|
163
|
+
baseIndent = indent;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// If we couldn't find a base indent, default to 0
|
|
169
|
+
if (baseIndent === Infinity) {
|
|
170
|
+
baseIndent = 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let skipUntilIndent = -1; // Track when to skip nested content
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < lines.length; i++) {
|
|
176
|
+
const line = lines[i];
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
|
|
179
|
+
// Update current line for each line we process
|
|
180
|
+
// This ensures the first field gets the correct line number
|
|
181
|
+
currentLine++;
|
|
182
|
+
|
|
183
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('--')) {
|
|
184
|
+
// Update line offset even for empty lines
|
|
185
|
+
lineOffset += line.length + 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const indentLevel = getIndentLevel(line);
|
|
190
|
+
|
|
191
|
+
// Skip nested content (indented more than base)
|
|
192
|
+
if (skipUntilIndent >= 0) {
|
|
193
|
+
if (indentLevel > skipUntilIndent) {
|
|
194
|
+
// Still in nested content, skip
|
|
195
|
+
lineOffset += line.length + 1;
|
|
196
|
+
continue;
|
|
197
|
+
} else {
|
|
198
|
+
// Back to base level, stop skipping
|
|
199
|
+
skipUntilIndent = -1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Look for pattern: identifier { typeExpr } or identifier : typeExpr
|
|
204
|
+
// Pattern 1: fieldName { typeExpr } - handle nested braces
|
|
205
|
+
const braceStartMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*/);
|
|
206
|
+
if (braceStartMatch) {
|
|
207
|
+
const fieldName = braceStartMatch[1];
|
|
208
|
+
const fieldStartCol = line.indexOf(fieldName) + 1; // 1-indexed
|
|
209
|
+
const startPos = braceStartMatch[0].length;
|
|
210
|
+
|
|
211
|
+
// Find matching closing brace by tracking depth on the same line
|
|
212
|
+
let depth = 1;
|
|
213
|
+
let pos = startPos;
|
|
214
|
+
let endPos = startPos;
|
|
215
|
+
|
|
216
|
+
while (pos < trimmed.length && depth > 0) {
|
|
217
|
+
if (trimmed[pos] === '{') {
|
|
218
|
+
depth++;
|
|
219
|
+
} else if (trimmed[pos] === '}') {
|
|
220
|
+
depth--;
|
|
221
|
+
if (depth === 0) {
|
|
222
|
+
endPos = pos;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
pos++;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// If not closed on same line, check if next line has nested content
|
|
230
|
+
if (depth > 0 && i + 1 < lines.length) {
|
|
231
|
+
const nextLine = lines[i + 1];
|
|
232
|
+
const nextIndent = getIndentLevel(nextLine);
|
|
233
|
+
const nextTrimmed = nextLine.trim();
|
|
234
|
+
|
|
235
|
+
// If next line is more indented than current line and not empty, it's a nested object
|
|
236
|
+
if (nextIndent > indentLevel && nextTrimmed && !nextTrimmed.startsWith('//') && !nextTrimmed.startsWith('--')) {
|
|
237
|
+
// Check if the nested content starts with "optional {"
|
|
238
|
+
// This indicates the field itself is optional, not just a nested object
|
|
239
|
+
const isOptionalField = nextTrimmed.match(/^optional\s*\{/i) !== null;
|
|
240
|
+
|
|
241
|
+
// Extract nested fields from the nested object structure
|
|
242
|
+
// Find the content inside the nested braces (or inside optional { ... })
|
|
243
|
+
let nestedContentStart = i + 1;
|
|
244
|
+
let nestedContentEnd = i + 1;
|
|
245
|
+
let nestedIndent = nextIndent;
|
|
246
|
+
|
|
247
|
+
// If it starts with "optional {", find the content inside optional block
|
|
248
|
+
if (isOptionalField) {
|
|
249
|
+
// Find the closing brace of the optional block
|
|
250
|
+
let optionalDepth = 1;
|
|
251
|
+
nestedContentStart = i + 1;
|
|
252
|
+
let foundClosingBrace = false;
|
|
253
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
254
|
+
const testLine = lines[j];
|
|
255
|
+
const testTrimmed = testLine.trim();
|
|
256
|
+
|
|
257
|
+
// Count braces to find end of optional block (check this first)
|
|
258
|
+
for (const ch of testTrimmed) {
|
|
259
|
+
if (ch === '{') optionalDepth++;
|
|
260
|
+
if (ch === '}') {
|
|
261
|
+
optionalDepth--;
|
|
262
|
+
if (optionalDepth === 0) {
|
|
263
|
+
// Found the closing brace of the optional block
|
|
264
|
+
nestedContentEnd = j;
|
|
265
|
+
foundClosingBrace = true;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (foundClosingBrace) break;
|
|
272
|
+
|
|
273
|
+
// Check if we've gone back to base level (only after checking for closing brace)
|
|
274
|
+
const testIndent = getIndentLevel(testLine);
|
|
275
|
+
if (testIndent <= indentLevel && testTrimmed && !testTrimmed.startsWith('//') && !testTrimmed.startsWith('--')) {
|
|
276
|
+
// Back to base level without finding closing brace - this shouldn't happen normally
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The nested fields are inside the optional block, starting from the line after "optional {"
|
|
282
|
+
nestedContentStart = i + 2; // Skip "optional {" line
|
|
283
|
+
|
|
284
|
+
// If nestedContentEnd points to lines that only contain closing braces,
|
|
285
|
+
// exclude them from the content to extract (work backwards)
|
|
286
|
+
while (nestedContentEnd >= nestedContentStart && nestedContentEnd < lines.length) {
|
|
287
|
+
const endLineTrimmed = lines[nestedContentEnd]?.trim() || '';
|
|
288
|
+
// If the end line is just a closing brace (possibly with whitespace), exclude it
|
|
289
|
+
if (endLineTrimmed === '}' || endLineTrimmed.match(/^\s*\}\s*$/)) {
|
|
290
|
+
nestedContentEnd = nestedContentEnd - 1;
|
|
291
|
+
} else {
|
|
292
|
+
break; // Found a line with actual content
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
nestedIndent = getIndentLevel(lines[nestedContentStart] || '');
|
|
297
|
+
} else {
|
|
298
|
+
// Find where the nested object block ends
|
|
299
|
+
let objectDepth = 1;
|
|
300
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
301
|
+
const testLine = lines[j];
|
|
302
|
+
const testIndent = getIndentLevel(testLine);
|
|
303
|
+
const testTrimmed = testLine.trim();
|
|
304
|
+
|
|
305
|
+
if (testIndent <= indentLevel && testTrimmed && !testTrimmed.startsWith('//') && !testTrimmed.startsWith('--')) {
|
|
306
|
+
nestedContentEnd = j - 1;
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Count braces
|
|
311
|
+
for (const ch of testTrimmed) {
|
|
312
|
+
if (ch === '{') objectDepth++;
|
|
313
|
+
if (ch === '}') {
|
|
314
|
+
objectDepth--;
|
|
315
|
+
if (objectDepth === 0) {
|
|
316
|
+
nestedContentEnd = j;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (objectDepth === 0) {
|
|
323
|
+
nestedContentEnd = j;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Extract nested fields from the nested content
|
|
330
|
+
let nestedFields = null;
|
|
331
|
+
if (nestedContentStart <= nestedContentEnd && nestedContentStart < lines.length) {
|
|
332
|
+
// Build a sub-raw content for the nested object
|
|
333
|
+
const nestedLines = [];
|
|
334
|
+
for (let j = nestedContentStart; j <= nestedContentEnd && j < lines.length; j++) {
|
|
335
|
+
nestedLines.push(lines[j]);
|
|
336
|
+
}
|
|
337
|
+
const nestedRaw = nestedLines.join('\n');
|
|
338
|
+
|
|
339
|
+
// Create a location for the nested type block (relative to parent)
|
|
340
|
+
const nestedLocation = {
|
|
341
|
+
file: typeBlockLocation.file,
|
|
342
|
+
start: {
|
|
343
|
+
line: currentLine + (nestedContentStart - i),
|
|
344
|
+
col: 1,
|
|
345
|
+
offset: typeBlockLocation.start.offset + lineOffset + (nestedContentStart > i ? lines.slice(i + 1, nestedContentStart).reduce((sum, l) => sum + l.length + 1, 0) : 0),
|
|
346
|
+
},
|
|
347
|
+
end: {
|
|
348
|
+
line: currentLine + (nestedContentEnd - i),
|
|
349
|
+
col: 1,
|
|
350
|
+
offset: typeBlockLocation.start.offset + lineOffset + nestedLines.reduce((sum, l) => sum + l.length + 1, 0),
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// Recursively extract fields from nested content
|
|
355
|
+
nestedFields = extractTypeFields(nestedRaw, nestedLocation);
|
|
356
|
+
// Note: The optional { } wrapper makes the parent field optional,
|
|
357
|
+
// but nested fields inside are still required when the parent is present
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// This is a nested object structure - treat as object type
|
|
361
|
+
const typeExpr = isOptionalField ? 'optional { ... }' : '{ ... }';
|
|
362
|
+
const fieldEndCol = fieldStartCol + trimmed.length;
|
|
363
|
+
|
|
364
|
+
const fieldDef = {
|
|
365
|
+
name: fieldName,
|
|
366
|
+
typeExpr: typeExpr,
|
|
367
|
+
type: { kind: 'object', nested: true },
|
|
368
|
+
required: !isOptionalField, // Optional if it starts with "optional {"
|
|
369
|
+
location: {
|
|
370
|
+
file: typeBlockLocation.file,
|
|
371
|
+
start: {
|
|
372
|
+
line: currentLine,
|
|
373
|
+
col: fieldStartCol,
|
|
374
|
+
offset: typeBlockLocation.start.offset + lineOffset + line.indexOf(fieldName),
|
|
375
|
+
},
|
|
376
|
+
end: {
|
|
377
|
+
line: currentLine,
|
|
378
|
+
col: fieldEndCol,
|
|
379
|
+
offset: typeBlockLocation.start.offset + lineOffset + trimmed.length,
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Add nested fields if we extracted them
|
|
385
|
+
if (nestedFields && nestedFields.fields) {
|
|
386
|
+
fieldDef.nestedFields = nestedFields.fields;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
fields.push(fieldDef);
|
|
390
|
+
|
|
391
|
+
// Skip nested content until we're back at this indent level or less
|
|
392
|
+
skipUntilIndent = indentLevel;
|
|
393
|
+
lineOffset += line.length + 1;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (depth === 0) {
|
|
399
|
+
// Simple type on same line
|
|
400
|
+
const typeExpr = trimmed.substring(startPos, endPos).trim();
|
|
401
|
+
const fieldEndCol = fieldStartCol + trimmed.length;
|
|
402
|
+
|
|
403
|
+
// Determine if field is required (not optional)
|
|
404
|
+
const isOptional = /^optional\s+/i.test(typeExpr);
|
|
405
|
+
|
|
406
|
+
// Parse type expression into structured AST
|
|
407
|
+
const typeAst = parseTypeExpression(typeExpr);
|
|
408
|
+
|
|
409
|
+
fields.push({
|
|
410
|
+
name: fieldName,
|
|
411
|
+
typeExpr: typeExpr || 'unknown',
|
|
412
|
+
type: typeAst,
|
|
413
|
+
required: !isOptional,
|
|
414
|
+
location: {
|
|
415
|
+
file: typeBlockLocation.file,
|
|
416
|
+
start: {
|
|
417
|
+
line: currentLine,
|
|
418
|
+
col: fieldStartCol,
|
|
419
|
+
offset: typeBlockLocation.start.offset + lineOffset + line.indexOf(fieldName),
|
|
420
|
+
},
|
|
421
|
+
end: {
|
|
422
|
+
line: currentLine,
|
|
423
|
+
col: fieldEndCol,
|
|
424
|
+
offset: typeBlockLocation.start.offset + lineOffset + trimmed.length,
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
lineOffset += line.length + 1;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Pattern 2: fieldName : typeExpr (colon syntax)
|
|
434
|
+
const colonMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.+)/);
|
|
435
|
+
if (colonMatch) {
|
|
436
|
+
const fieldName = colonMatch[1];
|
|
437
|
+
const typeExpr = colonMatch[2].trim();
|
|
438
|
+
const fieldStartCol = line.indexOf(fieldName) + 1;
|
|
439
|
+
const fieldEndCol = fieldStartCol + trimmed.length;
|
|
440
|
+
|
|
441
|
+
const isOptional = /^optional\s+/i.test(typeExpr);
|
|
442
|
+
const typeAst = parseTypeExpression(typeExpr);
|
|
443
|
+
|
|
444
|
+
fields.push({
|
|
445
|
+
name: fieldName,
|
|
446
|
+
typeExpr: typeExpr || 'unknown',
|
|
447
|
+
type: typeAst,
|
|
448
|
+
required: !isOptional,
|
|
449
|
+
location: {
|
|
450
|
+
file: typeBlockLocation.file,
|
|
451
|
+
start: {
|
|
452
|
+
line: currentLine,
|
|
453
|
+
col: fieldStartCol,
|
|
454
|
+
offset: typeBlockLocation.start.offset + lineOffset + line.indexOf(fieldName),
|
|
455
|
+
},
|
|
456
|
+
end: {
|
|
457
|
+
line: currentLine,
|
|
458
|
+
col: fieldEndCol,
|
|
459
|
+
offset: typeBlockLocation.start.offset + lineOffset + trimmed.length,
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Update line offset for next iteration
|
|
466
|
+
lineOffset += line.length + 1; // +1 for newline
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return fields.length > 0 ? { fields } : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Converts a describe block to manifest format
|
|
474
|
+
* @param {DescribeBlock} describeBlock
|
|
475
|
+
* @returns {{ raw: string, normalized: string }}
|
|
476
|
+
*/
|
|
477
|
+
function convertDescribe(describeBlock) {
|
|
478
|
+
return {
|
|
479
|
+
raw: describeBlock.raw,
|
|
480
|
+
normalized: normalizeDescribe(describeBlock.raw),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Converts a source block to manifest format
|
|
486
|
+
* @param {SourceBlock} sourceBlock
|
|
487
|
+
* @returns {Object}
|
|
488
|
+
*/
|
|
489
|
+
function convertSourceBlock(sourceBlock) {
|
|
490
|
+
const base = {
|
|
491
|
+
ast: sourceBlock.raw,
|
|
492
|
+
location: sourceToLocation(sourceBlock.source),
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
// Try to extract common properties (repository, paths, name, format, connection, table)
|
|
496
|
+
// This is a light extraction - we preserve the raw AST for full parsing later
|
|
497
|
+
const extracted = {};
|
|
498
|
+
|
|
499
|
+
// Simple extraction: look for key: value patterns
|
|
500
|
+
const lines = sourceBlock.raw.split('\n');
|
|
501
|
+
for (const line of lines) {
|
|
502
|
+
const trimmed = line.trim();
|
|
503
|
+
if (!trimmed) continue;
|
|
504
|
+
|
|
505
|
+
// Match: key { value } or key { 'value' }
|
|
506
|
+
const keyMatch = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*\{/);
|
|
507
|
+
if (keyMatch) {
|
|
508
|
+
const key = keyMatch[1];
|
|
509
|
+
// Extract value (simplified - just get content between braces)
|
|
510
|
+
const braceStart = trimmed.indexOf('{');
|
|
511
|
+
const braceEnd = trimmed.lastIndexOf('}');
|
|
512
|
+
if (braceStart >= 0 && braceEnd > braceStart) {
|
|
513
|
+
const valueContent = trimmed.substring(braceStart + 1, braceEnd).trim();
|
|
514
|
+
// Remove quotes if present
|
|
515
|
+
const value = valueContent.replace(/^['"]|['"]$/g, '');
|
|
516
|
+
|
|
517
|
+
if (key === 'paths') {
|
|
518
|
+
// Handle paths - can be single value or comma-separated
|
|
519
|
+
if (value.includes(',')) {
|
|
520
|
+
// Handle array-like paths
|
|
521
|
+
extracted.paths = value.split(',').map((p) => p.trim().replace(/^['"]|['"]$/g, ''));
|
|
522
|
+
} else {
|
|
523
|
+
// Single path value
|
|
524
|
+
extracted.paths = [value];
|
|
525
|
+
}
|
|
526
|
+
} else if (key === 'shape') {
|
|
527
|
+
// Validate shape is either 'list' or 'record'
|
|
528
|
+
if (value === 'list' || value === 'record') {
|
|
529
|
+
extracted.shape = value;
|
|
530
|
+
} else {
|
|
531
|
+
// Invalid shape value - preserve it but could emit warning
|
|
532
|
+
extracted.shape = value;
|
|
533
|
+
}
|
|
534
|
+
} else if (key === 'repository' || key === 'name' || key === 'format' || key === 'connection' || key === 'table') {
|
|
535
|
+
extracted[key] = value;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
...extracted,
|
|
543
|
+
...base,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Converts an actor to manifest format
|
|
549
|
+
* @param {ActorDecl} actor
|
|
550
|
+
* @returns {Object}
|
|
551
|
+
*/
|
|
552
|
+
function convertActor(actor) {
|
|
553
|
+
const result = {
|
|
554
|
+
name: actor.name,
|
|
555
|
+
location: sourceToLocation(actor.source),
|
|
556
|
+
sources: {},
|
|
557
|
+
transforms: [],
|
|
558
|
+
unknown: [],
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
for (const block of actor.body) {
|
|
562
|
+
if (block.kind === 'describe') {
|
|
563
|
+
result.describe = convertDescribe(block);
|
|
564
|
+
} else if (block.kind === 'type') {
|
|
565
|
+
const typeFields = extractTypeFields(block.raw, block.source);
|
|
566
|
+
result.type = {
|
|
567
|
+
ast: block.raw,
|
|
568
|
+
...(typeFields || {}),
|
|
569
|
+
location: sourceToLocation(block.source),
|
|
570
|
+
};
|
|
571
|
+
} else if (block.kind === 'identity') {
|
|
572
|
+
result.identity = {
|
|
573
|
+
ast: block.raw,
|
|
574
|
+
location: sourceToLocation(block.source),
|
|
575
|
+
};
|
|
576
|
+
} else if (block.kind === 'source') {
|
|
577
|
+
const sourceType = block.sourceType;
|
|
578
|
+
if (!result.sources[sourceType]) {
|
|
579
|
+
result.sources[sourceType] = [];
|
|
580
|
+
}
|
|
581
|
+
result.sources[sourceType].push(convertSourceBlock(block));
|
|
582
|
+
} else if (block.kind === 'transforms') {
|
|
583
|
+
for (const transform of block.transforms) {
|
|
584
|
+
result.transforms.push({
|
|
585
|
+
ast: transform.raw,
|
|
586
|
+
location: sourceToLocation(transform.source),
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
} else {
|
|
590
|
+
// Unknown block
|
|
591
|
+
result.unknown.push({
|
|
592
|
+
keyword: block.keyword || 'unknown',
|
|
593
|
+
ast: block.raw,
|
|
594
|
+
location: sourceToLocation(block.source),
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Convert sources object to have single entries (not arrays) if only one of each type
|
|
600
|
+
const normalizedSources = {};
|
|
601
|
+
for (const [sourceType, entries] of Object.entries(result.sources)) {
|
|
602
|
+
if (entries.length === 1) {
|
|
603
|
+
normalizedSources[sourceType] = entries[0];
|
|
604
|
+
} else {
|
|
605
|
+
normalizedSources[sourceType] = entries;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
result.sources = normalizedSources;
|
|
609
|
+
|
|
610
|
+
return result;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Converts a scene to manifest format
|
|
615
|
+
* @param {SceneDecl} scene
|
|
616
|
+
* @returns {Object}
|
|
617
|
+
*/
|
|
618
|
+
function convertScene(scene) {
|
|
619
|
+
const result = {
|
|
620
|
+
name: scene.name,
|
|
621
|
+
target: scene.target,
|
|
622
|
+
location: sourceToLocation(scene.source),
|
|
623
|
+
using: [],
|
|
624
|
+
actors: [],
|
|
625
|
+
unknown: [],
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
for (const block of scene.body) {
|
|
629
|
+
if (block.kind === 'using') {
|
|
630
|
+
result.using = [...block.identifiers];
|
|
631
|
+
} else if (block.kind === 'actor') {
|
|
632
|
+
result.actors.push(convertActor(block));
|
|
633
|
+
} else if (block.kind === 'describe') {
|
|
634
|
+
result.describe = convertDescribe(block);
|
|
635
|
+
} else {
|
|
636
|
+
// Unknown block
|
|
637
|
+
result.unknown.push({
|
|
638
|
+
keyword: block.keyword || 'unknown',
|
|
639
|
+
ast: block.raw,
|
|
640
|
+
location: sourceToLocation(block.source),
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return result;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Converts a FileAST to Scene Manifest JSON
|
|
650
|
+
* @param {FileAST} fileAST
|
|
651
|
+
* @returns {Object}
|
|
652
|
+
*/
|
|
653
|
+
export function convertToSceneManifest(fileAST) {
|
|
654
|
+
const scenes = fileAST.scenes.map(convertScene);
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
kind: 'scene-manifest',
|
|
658
|
+
scenes,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Builds an actor map across all scenes and resolves using blocks
|
|
664
|
+
* @param {Object} manifest - Scene manifest
|
|
665
|
+
* @returns {Map<string, Object>} Map of actor name to actor definition
|
|
666
|
+
*/
|
|
667
|
+
function buildActorMap(manifest) {
|
|
668
|
+
const actorMap = new Map();
|
|
669
|
+
const sceneMap = new Map(); // scene name -> scene
|
|
670
|
+
|
|
671
|
+
// First pass: build scene map and collect all actors
|
|
672
|
+
for (const scene of manifest.scenes) {
|
|
673
|
+
sceneMap.set(scene.name, scene);
|
|
674
|
+
for (const actor of scene.actors || []) {
|
|
675
|
+
actorMap.set(actor.name, actor);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Second pass: resolve using blocks
|
|
680
|
+
// For each scene, make actors from used scenes available
|
|
681
|
+
// Note: This doesn't modify the scene structure, just makes actors findable
|
|
682
|
+
// The actual resolution happens when resolving type references
|
|
683
|
+
|
|
684
|
+
return actorMap;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Resolves a type reference to its actual type
|
|
689
|
+
* @param {Object} typeAst - Type AST to resolve
|
|
690
|
+
* @param {Map<string, Object>} actorMap - Map of actor names to actor definitions
|
|
691
|
+
* @param {Set<string>} visited - Set of visited type names (for cycle detection)
|
|
692
|
+
* @returns {Object} Resolved type AST
|
|
693
|
+
*/
|
|
694
|
+
function resolveTypeReference(typeAst, actorMap, visited = new Set()) {
|
|
695
|
+
if (!typeAst) {
|
|
696
|
+
return { kind: 'unknown' };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Handle optional wrapper
|
|
700
|
+
if (typeAst.kind === 'optional') {
|
|
701
|
+
return {
|
|
702
|
+
kind: 'optional',
|
|
703
|
+
of: resolveTypeReference(typeAst.of, actorMap, visited),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Handle array
|
|
708
|
+
if (typeAst.kind === 'array') {
|
|
709
|
+
return {
|
|
710
|
+
kind: 'array',
|
|
711
|
+
elementType: resolveTypeReference(typeAst.elementType, actorMap, visited),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Primitives and oneOf are already resolved
|
|
716
|
+
if (typeAst.kind === 'primitive' || typeAst.kind === 'oneOf' || typeAst.kind === 'unknown') {
|
|
717
|
+
return typeAst;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Handle reference
|
|
721
|
+
if (typeAst.kind === 'reference') {
|
|
722
|
+
const refName = typeAst.name;
|
|
723
|
+
|
|
724
|
+
// Cycle detection
|
|
725
|
+
if (visited.has(refName)) {
|
|
726
|
+
// Circular reference - return as reference (it's an object type)
|
|
727
|
+
return typeAst;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
visited.add(refName);
|
|
731
|
+
|
|
732
|
+
// Look up the actor
|
|
733
|
+
const referencedActor = actorMap.get(refName);
|
|
734
|
+
if (!referencedActor) {
|
|
735
|
+
// Actor not found - return unknown
|
|
736
|
+
visited.delete(refName);
|
|
737
|
+
return { kind: 'unknown', raw: refName };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Check if actor has a type definition
|
|
741
|
+
if (!referencedActor.type) {
|
|
742
|
+
visited.delete(refName);
|
|
743
|
+
return { kind: 'unknown', raw: refName };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// If actor has fields, it's an object type - return as reference
|
|
747
|
+
const hasFields = referencedActor.type.fields &&
|
|
748
|
+
Array.isArray(referencedActor.type.fields) &&
|
|
749
|
+
referencedActor.type.fields.length > 0;
|
|
750
|
+
if (hasFields) {
|
|
751
|
+
visited.delete(refName);
|
|
752
|
+
return typeAst;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// If actor has no fields but has a type AST, it's a primitive type alias
|
|
756
|
+
// Parse the type expression to get the primitive
|
|
757
|
+
let typeExpr = referencedActor.type.ast || '';
|
|
758
|
+
if (!typeExpr.trim()) {
|
|
759
|
+
visited.delete(refName);
|
|
760
|
+
return { kind: 'unknown', raw: refName };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Extract the type expression from the AST
|
|
764
|
+
// The AST might be just "integer" or "{ integer }" or have newlines
|
|
765
|
+
typeExpr = typeExpr.trim();
|
|
766
|
+
// Remove outer braces if present
|
|
767
|
+
if (typeExpr.startsWith('{') && typeExpr.endsWith('}')) {
|
|
768
|
+
typeExpr = typeExpr.slice(1, -1).trim();
|
|
769
|
+
}
|
|
770
|
+
// Take the first line if there are multiple lines (for simple types)
|
|
771
|
+
const firstLine = typeExpr.split('\n')[0].trim();
|
|
772
|
+
typeExpr = firstLine;
|
|
773
|
+
|
|
774
|
+
// Parse the type expression - it should be a simple type like "integer"
|
|
775
|
+
const parsedType = parseTypeExpression(typeExpr);
|
|
776
|
+
|
|
777
|
+
// If it resolved to a primitive, return that
|
|
778
|
+
if (parsedType.kind === 'primitive') {
|
|
779
|
+
visited.delete(refName);
|
|
780
|
+
return parsedType;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// If it's another reference, resolve recursively
|
|
784
|
+
if (parsedType.kind === 'reference') {
|
|
785
|
+
const resolved = resolveTypeReference(parsedType, actorMap, visited);
|
|
786
|
+
visited.delete(refName);
|
|
787
|
+
return resolved;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// If it's optional, unwrap and resolve
|
|
791
|
+
if (parsedType.kind === 'optional') {
|
|
792
|
+
const resolved = resolveTypeReference(parsedType.of, actorMap, visited);
|
|
793
|
+
visited.delete(refName);
|
|
794
|
+
return {
|
|
795
|
+
kind: 'optional',
|
|
796
|
+
of: resolved,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Otherwise return the parsed type
|
|
801
|
+
visited.delete(refName);
|
|
802
|
+
return parsedType;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Unknown kind - return as-is
|
|
806
|
+
return typeAst;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Resolves all type references in a scene manifest
|
|
811
|
+
* @param {Object} manifest - Scene manifest
|
|
812
|
+
* @returns {Object} Manifest with resolved types
|
|
813
|
+
*/
|
|
814
|
+
function resolveTypesInManifest(manifest) {
|
|
815
|
+
// Build actor map
|
|
816
|
+
const actorMap = buildActorMap(manifest);
|
|
817
|
+
|
|
818
|
+
// Resolve types in all actors
|
|
819
|
+
for (const scene of manifest.scenes) {
|
|
820
|
+
for (const actor of scene.actors || []) {
|
|
821
|
+
if (actor.type && actor.type.fields && Array.isArray(actor.type.fields)) {
|
|
822
|
+
for (const field of actor.type.fields) {
|
|
823
|
+
if (field.type && field.type.kind === 'reference') {
|
|
824
|
+
// Only resolve references, leave other types as-is
|
|
825
|
+
const resolved = resolveTypeReference(field.type, actorMap);
|
|
826
|
+
// Replace with resolved type (even if it's still a reference for object types)
|
|
827
|
+
field.type = resolved;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return manifest;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Converts multiple FileASTs to a single Scene Manifest JSON
|
|
839
|
+
* @param {FileAST[]} fileASTs
|
|
840
|
+
* @returns {Object}
|
|
841
|
+
*/
|
|
842
|
+
export function convertFilesToSceneManifest(fileASTs) {
|
|
843
|
+
const allScenes = [];
|
|
844
|
+
for (const fileAST of fileASTs) {
|
|
845
|
+
allScenes.push(...fileAST.scenes.map(convertScene));
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const manifest = {
|
|
849
|
+
kind: 'scene-manifest',
|
|
850
|
+
scenes: allScenes,
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// Resolve type references
|
|
854
|
+
return resolveTypesInManifest(manifest);
|
|
855
|
+
}
|
|
856
|
+
|