@iyulab/m3l 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/README.md +202 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +150 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +46 -0
- package/dist/lexer.d.ts +5 -0
- package/dist/lexer.js +421 -0
- package/dist/parser.d.ts +9 -0
- package/dist/parser.js +704 -0
- package/dist/reader.d.ts +21 -0
- package/dist/reader.js +84 -0
- package/dist/resolver.d.ts +6 -0
- package/dist/resolver.js +148 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.js +1 -0
- package/dist/validator.d.ts +5 -0
- package/dist/validator.js +196 -0
- package/package.json +34 -0
package/dist/parser.js
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { lex } from './lexer.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parse M3L content string into a ParsedFile AST.
|
|
4
|
+
*/
|
|
5
|
+
export function parseString(content, file) {
|
|
6
|
+
const tokens = lex(content, file);
|
|
7
|
+
return parseTokens(tokens, file);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Parse a token sequence into a ParsedFile AST.
|
|
11
|
+
*/
|
|
12
|
+
export function parseTokens(tokens, file) {
|
|
13
|
+
const state = {
|
|
14
|
+
file,
|
|
15
|
+
namespace: undefined,
|
|
16
|
+
currentElement: null,
|
|
17
|
+
currentSection: null,
|
|
18
|
+
currentKind: 'stored',
|
|
19
|
+
lastField: null,
|
|
20
|
+
models: [],
|
|
21
|
+
enums: [],
|
|
22
|
+
interfaces: [],
|
|
23
|
+
views: [],
|
|
24
|
+
sourceDirectivesDone: false,
|
|
25
|
+
};
|
|
26
|
+
for (const token of tokens) {
|
|
27
|
+
processToken(token, state);
|
|
28
|
+
}
|
|
29
|
+
// Finalize last element
|
|
30
|
+
finalizeElement(state);
|
|
31
|
+
return {
|
|
32
|
+
source: file,
|
|
33
|
+
namespace: state.namespace,
|
|
34
|
+
models: state.models,
|
|
35
|
+
enums: state.enums,
|
|
36
|
+
interfaces: state.interfaces,
|
|
37
|
+
views: state.views,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function processToken(token, state) {
|
|
41
|
+
switch (token.type) {
|
|
42
|
+
case 'namespace':
|
|
43
|
+
handleNamespace(token, state);
|
|
44
|
+
break;
|
|
45
|
+
case 'model':
|
|
46
|
+
case 'interface':
|
|
47
|
+
handleModelStart(token, state);
|
|
48
|
+
break;
|
|
49
|
+
case 'enum':
|
|
50
|
+
handleEnumStart(token, state);
|
|
51
|
+
break;
|
|
52
|
+
case 'view':
|
|
53
|
+
handleViewStart(token, state);
|
|
54
|
+
break;
|
|
55
|
+
case 'section':
|
|
56
|
+
handleSection(token, state);
|
|
57
|
+
break;
|
|
58
|
+
case 'field':
|
|
59
|
+
handleField(token, state);
|
|
60
|
+
break;
|
|
61
|
+
case 'nested_item':
|
|
62
|
+
handleNestedItem(token, state);
|
|
63
|
+
break;
|
|
64
|
+
case 'blockquote':
|
|
65
|
+
handleBlockquote(token, state);
|
|
66
|
+
break;
|
|
67
|
+
case 'text':
|
|
68
|
+
handleText(token, state);
|
|
69
|
+
break;
|
|
70
|
+
case 'horizontal_rule':
|
|
71
|
+
case 'blank':
|
|
72
|
+
// Ignored
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function handleNamespace(token, state) {
|
|
77
|
+
const data = token.data;
|
|
78
|
+
// Check if this is a kind section (# Lookup, # Rollup, etc.)
|
|
79
|
+
if (data.kind_section) {
|
|
80
|
+
handleSection(token, state);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!state.currentElement) {
|
|
84
|
+
state.namespace = data.name;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function handleModelStart(token, state) {
|
|
88
|
+
finalizeElement(state);
|
|
89
|
+
const data = token.data;
|
|
90
|
+
const model = {
|
|
91
|
+
name: data.name,
|
|
92
|
+
label: data.label,
|
|
93
|
+
type: token.type,
|
|
94
|
+
source: state.file,
|
|
95
|
+
line: token.line,
|
|
96
|
+
inherits: data.inherits || [],
|
|
97
|
+
fields: [],
|
|
98
|
+
sections: {
|
|
99
|
+
indexes: [],
|
|
100
|
+
relations: [],
|
|
101
|
+
behaviors: [],
|
|
102
|
+
metadata: {},
|
|
103
|
+
},
|
|
104
|
+
loc: { file: state.file, line: token.line, col: 1 },
|
|
105
|
+
};
|
|
106
|
+
state.currentElement = model;
|
|
107
|
+
state.currentSection = null;
|
|
108
|
+
state.currentKind = 'stored';
|
|
109
|
+
state.lastField = null;
|
|
110
|
+
state.sourceDirectivesDone = false;
|
|
111
|
+
}
|
|
112
|
+
function handleEnumStart(token, state) {
|
|
113
|
+
finalizeElement(state);
|
|
114
|
+
const data = token.data;
|
|
115
|
+
const enumNode = {
|
|
116
|
+
name: data.name,
|
|
117
|
+
label: data.label,
|
|
118
|
+
type: 'enum',
|
|
119
|
+
source: state.file,
|
|
120
|
+
line: token.line,
|
|
121
|
+
description: data.description,
|
|
122
|
+
values: [],
|
|
123
|
+
loc: { file: state.file, line: token.line, col: 1 },
|
|
124
|
+
};
|
|
125
|
+
state.currentElement = enumNode;
|
|
126
|
+
state.currentSection = null;
|
|
127
|
+
state.currentKind = 'stored';
|
|
128
|
+
state.lastField = null;
|
|
129
|
+
}
|
|
130
|
+
function handleViewStart(token, state) {
|
|
131
|
+
finalizeElement(state);
|
|
132
|
+
const data = token.data;
|
|
133
|
+
const view = {
|
|
134
|
+
name: data.name,
|
|
135
|
+
label: data.label,
|
|
136
|
+
type: 'view',
|
|
137
|
+
source: state.file,
|
|
138
|
+
line: token.line,
|
|
139
|
+
inherits: [],
|
|
140
|
+
materialized: data.materialized || false,
|
|
141
|
+
fields: [],
|
|
142
|
+
sections: {
|
|
143
|
+
indexes: [],
|
|
144
|
+
relations: [],
|
|
145
|
+
behaviors: [],
|
|
146
|
+
metadata: {},
|
|
147
|
+
},
|
|
148
|
+
loc: { file: state.file, line: token.line, col: 1 },
|
|
149
|
+
};
|
|
150
|
+
state.currentElement = view;
|
|
151
|
+
state.currentSection = null;
|
|
152
|
+
state.currentKind = 'stored';
|
|
153
|
+
state.lastField = null;
|
|
154
|
+
state.sourceDirectivesDone = false;
|
|
155
|
+
}
|
|
156
|
+
function handleSection(token, state) {
|
|
157
|
+
const data = token.data;
|
|
158
|
+
const sectionName = data.name;
|
|
159
|
+
// Kind-context sections (# Lookup, # Rollup, # Computed)
|
|
160
|
+
if (data.kind_section) {
|
|
161
|
+
if (!state.currentElement)
|
|
162
|
+
return;
|
|
163
|
+
const lower = sectionName.toLowerCase();
|
|
164
|
+
if (lower.startsWith('lookup')) {
|
|
165
|
+
state.currentKind = 'lookup';
|
|
166
|
+
}
|
|
167
|
+
else if (lower.startsWith('rollup')) {
|
|
168
|
+
state.currentKind = 'rollup';
|
|
169
|
+
}
|
|
170
|
+
else if (lower.startsWith('computed')) {
|
|
171
|
+
state.currentKind = 'computed';
|
|
172
|
+
}
|
|
173
|
+
state.currentSection = null;
|
|
174
|
+
state.lastField = null;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// ### sections
|
|
178
|
+
state.currentSection = sectionName;
|
|
179
|
+
state.lastField = null;
|
|
180
|
+
// Reset source directives tracking for views
|
|
181
|
+
if (sectionName === 'Source' && state.currentElement?.type === 'view') {
|
|
182
|
+
state.sourceDirectivesDone = false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function handleField(token, state) {
|
|
186
|
+
if (!state.currentElement)
|
|
187
|
+
return;
|
|
188
|
+
const data = token.data;
|
|
189
|
+
// Handle enum element
|
|
190
|
+
if (isEnumNode(state.currentElement)) {
|
|
191
|
+
const enumVal = {
|
|
192
|
+
name: data.name,
|
|
193
|
+
description: data.description,
|
|
194
|
+
};
|
|
195
|
+
if (data.type_name && data.type_name !== 'enum') {
|
|
196
|
+
enumVal.type = data.type_name;
|
|
197
|
+
}
|
|
198
|
+
if (data.default_value !== undefined) {
|
|
199
|
+
enumVal.value = data.default_value;
|
|
200
|
+
}
|
|
201
|
+
// If no explicit type but has a quoted string after colon, treat as description
|
|
202
|
+
if (!enumVal.description && data.type_name) {
|
|
203
|
+
const raw = data.type_name;
|
|
204
|
+
const strMatch = raw.match(/^"(.*)"$/);
|
|
205
|
+
if (strMatch) {
|
|
206
|
+
enumVal.description = strMatch[1];
|
|
207
|
+
enumVal.type = undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
state.currentElement.values.push(enumVal);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const model = state.currentElement;
|
|
214
|
+
// Handle directive-only lines (@index, @relation, etc.)
|
|
215
|
+
if (data.is_directive) {
|
|
216
|
+
handleDirective(data, model, token, state);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Handle section-specific items
|
|
220
|
+
if (state.currentSection) {
|
|
221
|
+
handleSectionItem(data, model, token, state);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// Regular field
|
|
225
|
+
const field = buildFieldNode(data, token, state);
|
|
226
|
+
model.fields.push(field);
|
|
227
|
+
state.lastField = field;
|
|
228
|
+
}
|
|
229
|
+
function handleDirective(data, model, token, state) {
|
|
230
|
+
const attrs = data.attributes;
|
|
231
|
+
if (!attrs || attrs.length === 0)
|
|
232
|
+
return;
|
|
233
|
+
const attr = attrs[0];
|
|
234
|
+
if (attr.name === 'index') {
|
|
235
|
+
model.sections.indexes.push({
|
|
236
|
+
type: 'directive',
|
|
237
|
+
raw: data.raw_content,
|
|
238
|
+
args: attr.args,
|
|
239
|
+
loc: { file: state.file, line: token.line, col: 1 },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
else if (attr.name === 'relation') {
|
|
243
|
+
model.sections.relations.push({
|
|
244
|
+
type: 'directive',
|
|
245
|
+
raw: data.raw_content,
|
|
246
|
+
args: attr.args,
|
|
247
|
+
loc: { file: state.file, line: token.line, col: 1 },
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
// Generic directive
|
|
252
|
+
const sectionName = attr.name;
|
|
253
|
+
if (!model.sections[sectionName]) {
|
|
254
|
+
model.sections[sectionName] = [];
|
|
255
|
+
}
|
|
256
|
+
model.sections[sectionName].push({
|
|
257
|
+
raw: data.raw_content,
|
|
258
|
+
args: attr.args,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function handleSectionItem(data, model, token, state) {
|
|
263
|
+
const section = state.currentSection;
|
|
264
|
+
const loc = { file: state.file, line: token.line, col: 1 };
|
|
265
|
+
// View Source section — handle directives and fields
|
|
266
|
+
if (section === 'Source' && model.type === 'view') {
|
|
267
|
+
const name = data.name;
|
|
268
|
+
// Source directives: from, where, order_by, group_by, join
|
|
269
|
+
if (isSourceDirective(name) && !state.sourceDirectivesDone) {
|
|
270
|
+
if (!model.source_def) {
|
|
271
|
+
model.source_def = { from: '' };
|
|
272
|
+
}
|
|
273
|
+
setSourceDirective(model.source_def, data);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Once we hit a non-directive field, mark source directives as done
|
|
277
|
+
state.sourceDirectivesDone = true;
|
|
278
|
+
// View field
|
|
279
|
+
const field = buildFieldNode(data, token, state);
|
|
280
|
+
model.fields.push(field);
|
|
281
|
+
state.lastField = field;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Refresh section
|
|
285
|
+
if (section === 'Refresh' && model.type === 'view') {
|
|
286
|
+
if (!model.refresh) {
|
|
287
|
+
model.refresh = { strategy: '' };
|
|
288
|
+
}
|
|
289
|
+
const name = data.name;
|
|
290
|
+
const typeName = data.type_name;
|
|
291
|
+
const desc = data.description;
|
|
292
|
+
// Parse key: value pattern — these come as name: type_name in the lexer
|
|
293
|
+
if (name === 'strategy') {
|
|
294
|
+
model.refresh.strategy = typeName || '';
|
|
295
|
+
}
|
|
296
|
+
else if (name === 'interval') {
|
|
297
|
+
model.refresh.interval = desc || typeName || '';
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Indexes section
|
|
302
|
+
if (section === 'Indexes') {
|
|
303
|
+
model.sections.indexes.push({
|
|
304
|
+
name: data.name,
|
|
305
|
+
label: data.label,
|
|
306
|
+
loc,
|
|
307
|
+
});
|
|
308
|
+
state.lastField = { name: data.name };
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Relations section
|
|
312
|
+
if (section === 'Relations') {
|
|
313
|
+
model.sections.relations.push({
|
|
314
|
+
raw: token.raw.trim().replace(/^- /, ''),
|
|
315
|
+
loc,
|
|
316
|
+
});
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// Metadata section
|
|
320
|
+
if (section === 'Metadata') {
|
|
321
|
+
const name = data.name;
|
|
322
|
+
const value = data.type_name ?? data.description;
|
|
323
|
+
model.sections.metadata[name] = parseMetadataValue(value);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Behaviors section
|
|
327
|
+
if (section === 'Behaviors') {
|
|
328
|
+
model.sections.behaviors.push({
|
|
329
|
+
name: data.name,
|
|
330
|
+
raw: token.raw.trim(),
|
|
331
|
+
loc,
|
|
332
|
+
});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
// Generic section — treat as fields
|
|
336
|
+
const field = buildFieldNode(data, token, state);
|
|
337
|
+
model.fields.push(field);
|
|
338
|
+
state.lastField = field;
|
|
339
|
+
}
|
|
340
|
+
function handleNestedItem(token, state) {
|
|
341
|
+
if (!state.currentElement)
|
|
342
|
+
return;
|
|
343
|
+
const data = token.data;
|
|
344
|
+
const key = data.key;
|
|
345
|
+
const value = data.value;
|
|
346
|
+
// Nested items under an enum — enum values
|
|
347
|
+
if (isEnumNode(state.currentElement)) {
|
|
348
|
+
if (key) {
|
|
349
|
+
const val = { name: key };
|
|
350
|
+
const strMatch = value?.match(/^"(.*)"$/);
|
|
351
|
+
if (strMatch) {
|
|
352
|
+
val.description = strMatch[1];
|
|
353
|
+
}
|
|
354
|
+
else if (value) {
|
|
355
|
+
val.value = value;
|
|
356
|
+
}
|
|
357
|
+
state.currentElement.values.push(val);
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const model = state.currentElement;
|
|
362
|
+
// Nested items under index in Indexes section
|
|
363
|
+
if (state.currentSection === 'Indexes' && state.lastField) {
|
|
364
|
+
const lastIndex = model.sections.indexes[model.sections.indexes.length - 1];
|
|
365
|
+
if (lastIndex && typeof lastIndex === 'object') {
|
|
366
|
+
if (key) {
|
|
367
|
+
lastIndex[key] = parseNestedValue(value || '');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
// Nested items under a field
|
|
373
|
+
if (state.lastField) {
|
|
374
|
+
const field = state.lastField;
|
|
375
|
+
// values: key for inline enum
|
|
376
|
+
if (key === 'values' && !value) {
|
|
377
|
+
// Next nested items will be enum values — mark field
|
|
378
|
+
if (!field.enum_values) {
|
|
379
|
+
field.enum_values = [];
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// If field has enum_values (after values: key), add to it
|
|
384
|
+
if (field.enum_values && key) {
|
|
385
|
+
const strMatch = value?.match(/^"(.*)"$/);
|
|
386
|
+
field.enum_values.push({
|
|
387
|
+
name: key,
|
|
388
|
+
description: strMatch ? strMatch[1] : undefined,
|
|
389
|
+
value: strMatch ? undefined : value,
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
// Inline enum without values: key (legacy)
|
|
394
|
+
if (field.type === 'enum' && key && !value?.includes(':')) {
|
|
395
|
+
if (!field.enum_values) {
|
|
396
|
+
field.enum_values = [];
|
|
397
|
+
}
|
|
398
|
+
const strMatch = value?.match(/^"(.*)"$/);
|
|
399
|
+
field.enum_values.push({
|
|
400
|
+
name: key,
|
|
401
|
+
description: strMatch ? strMatch[1] : undefined,
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Extended format field attributes
|
|
406
|
+
if (key) {
|
|
407
|
+
applyExtendedAttribute(field, key, value || '');
|
|
408
|
+
}
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// Source section nested items for views
|
|
412
|
+
if (state.currentSection === 'Source' && model.type === 'view') {
|
|
413
|
+
if (key && model.source_def) {
|
|
414
|
+
setSourceDirective(model.source_def, { name: key, type_name: value });
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function handleBlockquote(token, state) {
|
|
420
|
+
if (!state.currentElement)
|
|
421
|
+
return;
|
|
422
|
+
const text = token.data.text;
|
|
423
|
+
if (state.currentElement.description) {
|
|
424
|
+
state.currentElement.description += '\n' + text;
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
state.currentElement.description = text;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
function handleText(token, state) {
|
|
431
|
+
// Plain text before fields — model description
|
|
432
|
+
if (state.currentElement && !isEnumNode(state.currentElement)) {
|
|
433
|
+
const model = state.currentElement;
|
|
434
|
+
if (model.fields.length === 0) {
|
|
435
|
+
const text = token.data.text || '';
|
|
436
|
+
if (text && !model.description) {
|
|
437
|
+
model.description = text;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function finalizeElement(state) {
|
|
443
|
+
if (!state.currentElement)
|
|
444
|
+
return;
|
|
445
|
+
if (isEnumNode(state.currentElement)) {
|
|
446
|
+
state.enums.push(state.currentElement);
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
const model = state.currentElement;
|
|
450
|
+
switch (model.type) {
|
|
451
|
+
case 'interface':
|
|
452
|
+
state.interfaces.push(model);
|
|
453
|
+
break;
|
|
454
|
+
case 'view':
|
|
455
|
+
state.views.push(model);
|
|
456
|
+
break;
|
|
457
|
+
default:
|
|
458
|
+
state.models.push(model);
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
state.currentElement = null;
|
|
463
|
+
state.currentSection = null;
|
|
464
|
+
state.currentKind = 'stored';
|
|
465
|
+
state.lastField = null;
|
|
466
|
+
}
|
|
467
|
+
// --- Helpers ---
|
|
468
|
+
function buildFieldNode(data, token, state) {
|
|
469
|
+
const attrs = parseAttributes(data.attributes);
|
|
470
|
+
let kind = state.currentKind;
|
|
471
|
+
// Detect kind from attributes
|
|
472
|
+
const lookupAttr = attrs.find(a => a.name === 'lookup');
|
|
473
|
+
const rollupAttr = attrs.find(a => a.name === 'rollup');
|
|
474
|
+
const computedAttr = attrs.find(a => a.name === 'computed');
|
|
475
|
+
const fromAttr = attrs.find(a => a.name === 'from');
|
|
476
|
+
if (lookupAttr)
|
|
477
|
+
kind = 'lookup';
|
|
478
|
+
else if (rollupAttr)
|
|
479
|
+
kind = 'rollup';
|
|
480
|
+
else if (computedAttr)
|
|
481
|
+
kind = 'computed';
|
|
482
|
+
const field = {
|
|
483
|
+
name: data.name,
|
|
484
|
+
label: data.label,
|
|
485
|
+
type: data.type_name,
|
|
486
|
+
params: parseTypeParams(data.type_params),
|
|
487
|
+
nullable: data.nullable || false,
|
|
488
|
+
array: data.array || false,
|
|
489
|
+
kind,
|
|
490
|
+
default_value: data.default_value,
|
|
491
|
+
description: data.description,
|
|
492
|
+
attributes: attrs,
|
|
493
|
+
framework_attrs: data.framework_attrs,
|
|
494
|
+
loc: { file: state.file, line: token.line, col: 1 },
|
|
495
|
+
};
|
|
496
|
+
// Parse lookup
|
|
497
|
+
if (lookupAttr && lookupAttr.args?.[0]) {
|
|
498
|
+
field.lookup = { path: lookupAttr.args[0] };
|
|
499
|
+
}
|
|
500
|
+
// Parse rollup: @rollup(Target.fk, aggregate) or @rollup(Target.fk, aggregate(field))
|
|
501
|
+
if (rollupAttr && rollupAttr.args?.[0]) {
|
|
502
|
+
field.rollup = parseRollupArgs(rollupAttr.args[0]);
|
|
503
|
+
}
|
|
504
|
+
// Parse computed: @computed("expression")
|
|
505
|
+
if (computedAttr && computedAttr.args?.[0]) {
|
|
506
|
+
const expr = computedAttr.args[0].replace(/^["']|["']$/g, '');
|
|
507
|
+
field.computed = { expression: expr };
|
|
508
|
+
}
|
|
509
|
+
return field;
|
|
510
|
+
}
|
|
511
|
+
function parseAttributes(rawAttrs) {
|
|
512
|
+
if (!rawAttrs)
|
|
513
|
+
return [];
|
|
514
|
+
return rawAttrs.map(a => ({
|
|
515
|
+
name: a.name,
|
|
516
|
+
args: a.args ? [a.args] : undefined,
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
function parseTypeParams(params) {
|
|
520
|
+
if (!params)
|
|
521
|
+
return undefined;
|
|
522
|
+
return params.map(p => {
|
|
523
|
+
const n = Number(p);
|
|
524
|
+
return isNaN(n) ? p : n;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
function parseRollupArgs(argsStr) {
|
|
528
|
+
// Pattern: Target.fk, aggregate(field)?, where: "condition"
|
|
529
|
+
const parts = splitRollupArgs(argsStr);
|
|
530
|
+
const targetFk = parts[0] || '';
|
|
531
|
+
const dotIdx = targetFk.indexOf('.');
|
|
532
|
+
const target = dotIdx >= 0 ? targetFk.substring(0, dotIdx) : targetFk;
|
|
533
|
+
const fk = dotIdx >= 0 ? targetFk.substring(dotIdx + 1) : '';
|
|
534
|
+
let aggregate = '';
|
|
535
|
+
let field;
|
|
536
|
+
if (parts.length > 1) {
|
|
537
|
+
const aggPart = parts[1].trim();
|
|
538
|
+
const aggMatch = aggPart.match(/^(\w+)(?:\((\w+)\))?$/);
|
|
539
|
+
if (aggMatch) {
|
|
540
|
+
aggregate = aggMatch[1];
|
|
541
|
+
field = aggMatch[2];
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
aggregate = aggPart;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
let where;
|
|
548
|
+
for (let i = 2; i < parts.length; i++) {
|
|
549
|
+
const part = parts[i].trim();
|
|
550
|
+
const whereMatch = part.match(/^where:\s*"(.*)"$/);
|
|
551
|
+
if (whereMatch) {
|
|
552
|
+
where = whereMatch[1];
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return { target, fk, aggregate, field, where };
|
|
556
|
+
}
|
|
557
|
+
function splitRollupArgs(argsStr) {
|
|
558
|
+
const parts = [];
|
|
559
|
+
let current = '';
|
|
560
|
+
let depth = 0;
|
|
561
|
+
let inQuote = false;
|
|
562
|
+
let quoteChar = '';
|
|
563
|
+
for (const ch of argsStr) {
|
|
564
|
+
if (inQuote) {
|
|
565
|
+
current += ch;
|
|
566
|
+
if (ch === quoteChar)
|
|
567
|
+
inQuote = false;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (ch === '"' || ch === "'") {
|
|
571
|
+
inQuote = true;
|
|
572
|
+
quoteChar = ch;
|
|
573
|
+
current += ch;
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (ch === '(') {
|
|
577
|
+
depth++;
|
|
578
|
+
current += ch;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (ch === ')') {
|
|
582
|
+
depth--;
|
|
583
|
+
current += ch;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (ch === ',' && depth === 0) {
|
|
587
|
+
parts.push(current.trim());
|
|
588
|
+
current = '';
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
current += ch;
|
|
592
|
+
}
|
|
593
|
+
if (current.trim()) {
|
|
594
|
+
parts.push(current.trim());
|
|
595
|
+
}
|
|
596
|
+
return parts;
|
|
597
|
+
}
|
|
598
|
+
function isSourceDirective(name) {
|
|
599
|
+
return ['from', 'where', 'order_by', 'group_by', 'join'].includes(name);
|
|
600
|
+
}
|
|
601
|
+
function setSourceDirective(def, data) {
|
|
602
|
+
const name = data.name;
|
|
603
|
+
const typeName = data.type_name;
|
|
604
|
+
const desc = data.description;
|
|
605
|
+
const rawValue = data.raw_value;
|
|
606
|
+
// desc: quoted values with quotes stripped by lexer
|
|
607
|
+
// rawValue: full rest-of-line preserving spaces (e.g., "due_date asc")
|
|
608
|
+
// typeName: first word parsed as type
|
|
609
|
+
const value = desc || rawValue || typeName || '';
|
|
610
|
+
switch (name) {
|
|
611
|
+
case 'from':
|
|
612
|
+
def.from = value;
|
|
613
|
+
break;
|
|
614
|
+
case 'where':
|
|
615
|
+
def.where = value;
|
|
616
|
+
break;
|
|
617
|
+
case 'order_by':
|
|
618
|
+
def.order_by = value;
|
|
619
|
+
break;
|
|
620
|
+
case 'group_by':
|
|
621
|
+
def.group_by = parseArrayValue(value);
|
|
622
|
+
break;
|
|
623
|
+
case 'join':
|
|
624
|
+
if (!def.joins)
|
|
625
|
+
def.joins = [];
|
|
626
|
+
def.joins.push(parseJoinValue(value));
|
|
627
|
+
break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function parseJoinValue(value) {
|
|
631
|
+
// "Model on condition"
|
|
632
|
+
const parts = value.split(/\s+on\s+/i);
|
|
633
|
+
return {
|
|
634
|
+
model: parts[0]?.trim() || '',
|
|
635
|
+
on: parts[1]?.trim() || '',
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function parseArrayValue(value) {
|
|
639
|
+
// [a, b, c] or a, b, c
|
|
640
|
+
const cleaned = value.replace(/^\[|\]$/g, '');
|
|
641
|
+
return cleaned.split(',').map(s => s.trim()).filter(Boolean);
|
|
642
|
+
}
|
|
643
|
+
function parseMetadataValue(value) {
|
|
644
|
+
if (typeof value !== 'string')
|
|
645
|
+
return value;
|
|
646
|
+
const str = value;
|
|
647
|
+
// Remove surrounding quotes
|
|
648
|
+
const unquoted = str.replace(/^["']|["']$/g, '');
|
|
649
|
+
// Try number
|
|
650
|
+
const n = Number(unquoted);
|
|
651
|
+
if (!isNaN(n) && unquoted !== '')
|
|
652
|
+
return n;
|
|
653
|
+
// Try boolean
|
|
654
|
+
if (unquoted === 'true')
|
|
655
|
+
return true;
|
|
656
|
+
if (unquoted === 'false')
|
|
657
|
+
return false;
|
|
658
|
+
return unquoted;
|
|
659
|
+
}
|
|
660
|
+
function parseNestedValue(value) {
|
|
661
|
+
const str = value.trim();
|
|
662
|
+
// Array: [a, b, c]
|
|
663
|
+
if (str.startsWith('[') && str.endsWith(']')) {
|
|
664
|
+
return parseArrayValue(str);
|
|
665
|
+
}
|
|
666
|
+
// Boolean
|
|
667
|
+
if (str === 'true')
|
|
668
|
+
return true;
|
|
669
|
+
if (str === 'false')
|
|
670
|
+
return false;
|
|
671
|
+
// Number
|
|
672
|
+
const n = Number(str);
|
|
673
|
+
if (!isNaN(n) && str !== '')
|
|
674
|
+
return n;
|
|
675
|
+
// String (strip quotes)
|
|
676
|
+
return str.replace(/^["']|["']$/g, '');
|
|
677
|
+
}
|
|
678
|
+
function applyExtendedAttribute(field, key, value) {
|
|
679
|
+
const parsed = parseNestedValue(value);
|
|
680
|
+
switch (key) {
|
|
681
|
+
case 'type':
|
|
682
|
+
field.type = value.replace(/\?$/, '').replace(/\[\]$/, '');
|
|
683
|
+
if (value.endsWith('?'))
|
|
684
|
+
field.nullable = true;
|
|
685
|
+
if (value.endsWith('[]'))
|
|
686
|
+
field.array = true;
|
|
687
|
+
break;
|
|
688
|
+
case 'description':
|
|
689
|
+
field.description = typeof parsed === 'string' ? parsed : String(parsed);
|
|
690
|
+
break;
|
|
691
|
+
case 'reference':
|
|
692
|
+
field.attributes.push({ name: 'reference', args: [value] });
|
|
693
|
+
break;
|
|
694
|
+
case 'on_delete':
|
|
695
|
+
field.attributes.push({ name: 'on_delete', args: [value] });
|
|
696
|
+
break;
|
|
697
|
+
default:
|
|
698
|
+
field.attributes.push({ name: key, args: [parsed] });
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function isEnumNode(el) {
|
|
703
|
+
return el.type === 'enum' && 'values' in el;
|
|
704
|
+
}
|