@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/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
+ }