@iyulab/m3l 0.1.0 → 0.1.1

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/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { lex } from './lexer.js';
2
2
  export { parseTokens, parseString as parseFileString } from './parser.js';
3
- export { resolve } from './resolver.js';
3
+ export { resolve, AST_VERSION, PARSER_VERSION } from './resolver.js';
4
4
  export { validate } from './validator.js';
5
5
  export { readM3LFiles, readM3LString, readProjectConfig } from './reader.js';
6
6
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { lex } from './lexer.js';
2
2
  export { parseTokens, parseString as parseFileString } from './parser.js';
3
- export { resolve } from './resolver.js';
3
+ export { resolve, AST_VERSION, PARSER_VERSION } from './resolver.js';
4
4
  export { validate } from './validator.js';
5
5
  export { readM3LFiles, readM3LString, readProjectConfig } from './reader.js';
6
6
  import { readM3LFiles, readProjectConfig } from './reader.js';
package/dist/lexer.d.ts CHANGED
@@ -3,3 +3,4 @@ import type { Token } from './types.js';
3
3
  * Tokenize M3L markdown content into a sequence of tokens.
4
4
  */
5
5
  export declare function lex(content: string, file: string): Token[];
6
+ export declare function parseTypeAndAttrs(rest: string, data: Record<string, unknown>): void;
package/dist/lexer.js CHANGED
@@ -11,7 +11,7 @@ const RE_TYPE_INDICATOR = /^([\w][\w.]*(?:\([^)]*\))?)\s*::(\w+)(.*)$/;
11
11
  const RE_MODEL_DEF = /^([\w][\w.]*(?:\([^)]*\))?)\s*(?::\s*(.+?))?(\s+@.+)?$/;
12
12
  // Field line patterns
13
13
  const RE_FIELD_NAME = /^([\w]+)(?:\(([^)]*)\))?\s*(?::\s*(.+))?$/;
14
- const RE_TYPE_PART = /^([\w]+)(?:\(([^)]*)\))?(\?)?(\[\])?/;
14
+ const RE_TYPE_PART = /^([\w]+)(?:<([^>]+)>)?(?:\(([^)]*)\))?(\?)?(\[\])?(\?)?/;
15
15
  const RE_FRAMEWORK_ATTR = /`\[([^\]]+)\]`/g;
16
16
  const RE_INLINE_COMMENT = /\s+#\s+(.+)$/;
17
17
  // Known kind-context H1 headers
@@ -264,7 +264,7 @@ function parseFieldLine(content) {
264
264
  parseTypeAndAttrs(rest, data);
265
265
  return data;
266
266
  }
267
- function parseTypeAndAttrs(rest, data) {
267
+ export function parseTypeAndAttrs(rest, data) {
268
268
  let pos = 0;
269
269
  const len = rest.length;
270
270
  const skipWS = () => { while (pos < len && rest[pos] === ' ')
@@ -277,15 +277,29 @@ function parseTypeAndAttrs(rest, data) {
277
277
  return;
278
278
  }
279
279
  }
280
- // Parse type: word(params)?[]?
280
+ // Parse type: word<generics>?(params)?[]??
281
281
  const typeMatch = rest.match(RE_TYPE_PART);
282
282
  if (typeMatch) {
283
283
  data.type_name = typeMatch[1];
284
+ // Group 2: generic params from <K,V>
284
285
  if (typeMatch[2]) {
285
- data.type_params = typeMatch[2].split(',').map(s => s.trim());
286
+ data.type_generic_params = typeMatch[2].split(',').map(s => s.trim());
287
+ }
288
+ // Group 3: size/type params from (params)
289
+ if (typeMatch[3]) {
290
+ data.type_params = typeMatch[3].split(',').map(s => s.trim());
291
+ }
292
+ // Group 5: array
293
+ data.array = typeMatch[5] === '[]';
294
+ // Group 4: ? before [] = element nullable; Group 6: ? after [] = container nullable
295
+ if (data.array) {
296
+ data.nullable = typeMatch[6] === '?';
297
+ data.arrayItemNullable = typeMatch[4] === '?';
298
+ }
299
+ else {
300
+ data.nullable = typeMatch[4] === '?' || typeMatch[6] === '?';
301
+ data.arrayItemNullable = false;
286
302
  }
287
- data.nullable = typeMatch[3] === '?';
288
- data.array = typeMatch[4] === '[]';
289
303
  pos = typeMatch[0].length;
290
304
  skipWS();
291
305
  }
package/dist/parser.js CHANGED
@@ -1,4 +1,4 @@
1
- import { lex } from './lexer.js';
1
+ import { lex, parseTypeAndAttrs } from './lexer.js';
2
2
  /**
3
3
  * Parse M3L content string into a ParsedFile AST.
4
4
  */
@@ -87,6 +87,7 @@ function handleNamespace(token, state) {
87
87
  function handleModelStart(token, state) {
88
88
  finalizeElement(state);
89
89
  const data = token.data;
90
+ const modelAttrs = parseAttributes(data.attributes);
90
91
  const model = {
91
92
  name: data.name,
92
93
  label: data.label,
@@ -94,6 +95,7 @@ function handleModelStart(token, state) {
94
95
  source: state.file,
95
96
  line: token.line,
96
97
  inherits: data.inherits || [],
98
+ attributes: modelAttrs,
97
99
  fields: [],
98
100
  sections: {
99
101
  indexes: [],
@@ -137,6 +139,7 @@ function handleViewStart(token, state) {
137
139
  source: state.file,
138
140
  line: token.line,
139
141
  inherits: [],
142
+ attributes: [],
140
143
  materialized: data.materialized || false,
141
144
  fields: [],
142
145
  sections: {
@@ -248,15 +251,25 @@ function handleDirective(data, model, token, state) {
248
251
  });
249
252
  }
250
253
  else {
251
- // Generic directive
252
- const sectionName = attr.name;
253
- if (!model.sections[sectionName]) {
254
- model.sections[sectionName] = [];
254
+ // Generic directive — normalize singular form
255
+ let sectionName = attr.name;
256
+ if (sectionName === 'behavior')
257
+ sectionName = 'behaviors';
258
+ if (sectionName === 'behaviors') {
259
+ model.sections.behaviors.push({
260
+ raw: data.raw_content,
261
+ args: attr.args,
262
+ });
263
+ }
264
+ else {
265
+ if (!model.sections[sectionName]) {
266
+ model.sections[sectionName] = [];
267
+ }
268
+ model.sections[sectionName].push({
269
+ raw: data.raw_content,
270
+ args: attr.args,
271
+ });
255
272
  }
256
- model.sections[sectionName].push({
257
- raw: data.raw_content,
258
- args: attr.args,
259
- });
260
273
  }
261
274
  }
262
275
  function handleSectionItem(data, model, token, state) {
@@ -332,10 +345,17 @@ function handleSectionItem(data, model, token, state) {
332
345
  });
333
346
  return;
334
347
  }
335
- // Generic section — treat as fields
336
- const field = buildFieldNode(data, token, state);
337
- model.fields.push(field);
338
- state.lastField = field;
348
+ // Generic section — store as section items, NOT as fields
349
+ if (!model.sections[section]) {
350
+ model.sections[section] = [];
351
+ }
352
+ model.sections[section].push({
353
+ name: data.name,
354
+ raw: token.raw.trim(),
355
+ value: data.type_name || data.description || data.raw_value,
356
+ loc,
357
+ });
358
+ state.lastField = null;
339
359
  }
340
360
  function handleNestedItem(token, state) {
341
361
  if (!state.currentElement)
@@ -402,6 +422,29 @@ function handleNestedItem(token, state) {
402
422
  });
403
423
  return;
404
424
  }
425
+ // Sub-field for object/nested type
426
+ if (key && value) {
427
+ // Walk up to find the nearest object-type ancestor for this indent level
428
+ const parentField = field;
429
+ if (parentField.type === 'object') {
430
+ if (!parentField.fields)
431
+ parentField.fields = [];
432
+ // Re-parse value as type and attributes
433
+ const subData = { name: key };
434
+ parseTypeAndAttrs(value, subData);
435
+ // Only treat as sub-field if a type was extracted
436
+ if (subData.type_name) {
437
+ const subField = buildFieldNode(subData, token, state);
438
+ parentField.fields.push(subField);
439
+ // If the sub-field is also an object, set it as lastField for deeper nesting
440
+ if (subField.type === 'object') {
441
+ state.lastField = subField;
442
+ }
443
+ // Otherwise keep lastField pointing to the parent object for siblings
444
+ return;
445
+ }
446
+ }
447
+ }
405
448
  // Extended format field attributes
406
449
  if (key) {
407
450
  applyExtendedAttribute(field, key, value || '');
@@ -472,6 +515,7 @@ function buildFieldNode(data, token, state) {
472
515
  const lookupAttr = attrs.find(a => a.name === 'lookup');
473
516
  const rollupAttr = attrs.find(a => a.name === 'rollup');
474
517
  const computedAttr = attrs.find(a => a.name === 'computed');
518
+ const computedRawAttr = attrs.find(a => a.name === 'computed_raw');
475
519
  const fromAttr = attrs.find(a => a.name === 'from');
476
520
  if (lookupAttr)
477
521
  kind = 'lookup';
@@ -479,18 +523,22 @@ function buildFieldNode(data, token, state) {
479
523
  kind = 'rollup';
480
524
  else if (computedAttr)
481
525
  kind = 'computed';
526
+ else if (computedRawAttr)
527
+ kind = 'computed';
482
528
  const field = {
483
529
  name: data.name,
484
530
  label: data.label,
485
531
  type: data.type_name,
486
532
  params: parseTypeParams(data.type_params),
533
+ generic_params: data.type_generic_params,
487
534
  nullable: data.nullable || false,
488
535
  array: data.array || false,
536
+ arrayItemNullable: data.arrayItemNullable || false,
489
537
  kind,
490
538
  default_value: data.default_value,
491
539
  description: data.description,
492
540
  attributes: attrs,
493
- framework_attrs: data.framework_attrs,
541
+ framework_attrs: parseCustomAttributes(data.framework_attrs),
494
542
  loc: { file: state.file, line: token.line, col: 1 },
495
543
  };
496
544
  // Parse lookup
@@ -506,6 +554,11 @@ function buildFieldNode(data, token, state) {
506
554
  const expr = computedAttr.args[0].replace(/^["']|["']$/g, '');
507
555
  field.computed = { expression: expr };
508
556
  }
557
+ // Parse computed_raw: @computed_raw("expression", ...)
558
+ if (computedRawAttr && computedRawAttr.args?.[0]) {
559
+ const expr = computedRawAttr.args[0].replace(/^["']|["']$/g, '');
560
+ field.computed = { expression: expr };
561
+ }
509
562
  return field;
510
563
  }
511
564
  function parseAttributes(rawAttrs) {
@@ -699,6 +752,15 @@ function applyExtendedAttribute(field, key, value) {
699
752
  break;
700
753
  }
701
754
  }
755
+ function parseCustomAttributes(rawAttrs) {
756
+ if (!rawAttrs || rawAttrs.length === 0)
757
+ return undefined;
758
+ return rawAttrs.map(raw => {
759
+ // raw is "[MaxLength(100)]" — strip brackets to get content
760
+ const content = raw.replace(/^\[|\]$/g, '');
761
+ return { content, raw };
762
+ });
763
+ }
702
764
  function isEnumNode(el) {
703
765
  return el.type === 'enum' && 'values' in el;
704
766
  }
@@ -1,4 +1,8 @@
1
1
  import type { ParsedFile, M3LAST, ProjectInfo } from './types.js';
2
+ /** AST schema version — bump major on breaking structure changes */
3
+ export declare const AST_VERSION = "1.0";
4
+ /** Parser package version — kept in sync with package.json */
5
+ export declare const PARSER_VERSION = "0.1.1";
2
6
  /**
3
7
  * Resolve and merge multiple parsed file ASTs into a single M3LAST.
4
8
  * Handles: inheritance resolution, duplicate detection, reference validation.
package/dist/resolver.js CHANGED
@@ -1,3 +1,7 @@
1
+ /** AST schema version — bump major on breaking structure changes */
2
+ export const AST_VERSION = '1.0';
3
+ /** Parser package version — kept in sync with package.json */
4
+ export const PARSER_VERSION = '0.1.1';
1
5
  /**
2
6
  * Resolve and merge multiple parsed file ASTs into a single M3LAST.
3
7
  * Handles: inheritance resolution, duplicate detection, reference validation.
@@ -58,6 +62,8 @@ export function resolve(files, project) {
58
62
  projectInfo.name = ns;
59
63
  }
60
64
  return {
65
+ parserVersion: PARSER_VERSION,
66
+ astVersion: AST_VERSION,
61
67
  project: projectInfo,
62
68
  sources,
63
69
  models: allModels,
@@ -72,7 +78,7 @@ function checkDuplicate(name, kind, item, map, allMap, errors) {
72
78
  const existing = allMap.get(name);
73
79
  if (existing) {
74
80
  errors.push({
75
- code: 'E005',
81
+ code: 'M3L-E005',
76
82
  severity: 'error',
77
83
  file: item.source,
78
84
  line: item.line,
@@ -95,7 +101,7 @@ function resolveInheritance(model, modelMap, interfaceMap, allNamedMap, errors)
95
101
  if (!parent) {
96
102
  if (!allNamedMap.has(name)) {
97
103
  errors.push({
98
- code: 'E007',
104
+ code: 'M3L-E007',
99
105
  severity: 'error',
100
106
  file: fromModel.source,
101
107
  line: fromModel.line,
@@ -122,9 +128,21 @@ function resolveInheritance(model, modelMap, interfaceMap, allNamedMap, errors)
122
128
  for (const parentName of model.inherits) {
123
129
  collectFields(parentName, model);
124
130
  }
131
+ // Handle @override: child fields with @override replace inherited fields
132
+ const overrideNames = new Set();
133
+ for (const ownField of model.fields) {
134
+ const overrideIdx = ownField.attributes.findIndex(a => a.name === 'override');
135
+ if (overrideIdx >= 0) {
136
+ overrideNames.add(ownField.name);
137
+ // Remove @override attribute from the child field
138
+ ownField.attributes.splice(overrideIdx, 1);
139
+ }
140
+ }
141
+ // Remove inherited fields that are overridden
142
+ const filteredInherited = inheritedFields.filter(f => !overrideNames.has(f.name));
125
143
  // Prepend inherited fields before model's own fields
126
- if (inheritedFields.length > 0) {
127
- model.fields = [...inheritedFields, ...model.fields];
144
+ if (filteredInherited.length > 0) {
145
+ model.fields = [...filteredInherited, ...model.fields];
128
146
  }
129
147
  }
130
148
  function checkDuplicateFields(model, errors) {
@@ -133,7 +151,7 @@ function checkDuplicateFields(model, errors) {
133
151
  const existing = seen.get(field.name);
134
152
  if (existing) {
135
153
  errors.push({
136
- code: 'E005',
154
+ code: 'M3L-E005',
137
155
  severity: 'error',
138
156
  file: field.loc.file,
139
157
  line: field.loc.line,
package/dist/types.d.ts CHANGED
@@ -15,7 +15,14 @@ export interface Token {
15
15
  export type FieldKind = 'stored' | 'computed' | 'lookup' | 'rollup';
16
16
  export interface FieldAttribute {
17
17
  name: string;
18
- args?: unknown[];
18
+ args?: (string | number | boolean)[];
19
+ }
20
+ /** Structured representation of a backtick-wrapped framework attribute like `[MaxLength(100)]` */
21
+ export interface CustomAttribute {
22
+ /** Content inside brackets, e.g., "MaxLength(100)" for `[MaxLength(100)]` */
23
+ content: string;
24
+ /** Original text including brackets, e.g., "[MaxLength(100)]" */
25
+ raw: string;
19
26
  }
20
27
  export interface EnumValue {
21
28
  name: string;
@@ -28,13 +35,15 @@ export interface FieldNode {
28
35
  label?: string;
29
36
  type?: string;
30
37
  params?: (string | number)[];
38
+ generic_params?: string[];
31
39
  nullable: boolean;
32
40
  array: boolean;
41
+ arrayItemNullable: boolean;
33
42
  kind: FieldKind;
34
43
  default_value?: string;
35
44
  description?: string;
36
45
  attributes: FieldAttribute[];
37
- framework_attrs?: string[];
46
+ framework_attrs?: CustomAttribute[];
38
47
  lookup?: {
39
48
  path: string;
40
49
  };
@@ -60,6 +69,7 @@ export interface ModelNode {
60
69
  line: number;
61
70
  inherits: string[];
62
71
  description?: string;
72
+ attributes: FieldAttribute[];
63
73
  fields: FieldNode[];
64
74
  sections: {
65
75
  indexes: unknown[];
@@ -117,6 +127,10 @@ export interface ParsedFile {
117
127
  views: ModelNode[];
118
128
  }
119
129
  export interface M3LAST {
130
+ /** Parser package version (semver) */
131
+ parserVersion: string;
132
+ /** AST schema version — incremented on breaking AST structure changes */
133
+ astVersion: string;
120
134
  project: ProjectInfo;
121
135
  sources: string[];
122
136
  models: ModelNode[];
package/dist/validator.js CHANGED
@@ -9,7 +9,7 @@ export function validate(ast, options = {}) {
9
9
  for (const m of allModels) {
10
10
  modelMap.set(m.name, m);
11
11
  }
12
- // E001: @rollup FK missing @reference
12
+ // M3L-E001: @rollup FK missing @reference
13
13
  for (const model of allModels) {
14
14
  for (const field of model.fields) {
15
15
  if (field.kind === 'rollup' && field.rollup) {
@@ -17,7 +17,7 @@ export function validate(ast, options = {}) {
17
17
  }
18
18
  }
19
19
  }
20
- // E002: @lookup path FK missing @reference
20
+ // M3L-E002: @lookup path FK missing @reference
21
21
  for (const model of allModels) {
22
22
  for (const field of model.fields) {
23
23
  if (field.kind === 'lookup' && field.lookup) {
@@ -25,12 +25,12 @@ export function validate(ast, options = {}) {
25
25
  }
26
26
  }
27
27
  }
28
- // E004: View @from references model not found
28
+ // M3L-E004: View @from references model not found
29
29
  for (const view of ast.views) {
30
30
  if (view.source_def?.from) {
31
31
  if (!modelMap.has(view.source_def.from)) {
32
32
  errors.push({
33
- code: 'E004',
33
+ code: 'M3L-E004',
34
34
  severity: 'error',
35
35
  file: view.source,
36
36
  line: view.line,
@@ -40,13 +40,13 @@ export function validate(ast, options = {}) {
40
40
  }
41
41
  }
42
42
  }
43
- // E005: Duplicate field names (already checked in resolver, but re-check for safety)
43
+ // M3L-E006: Duplicate field names (already checked in resolver, but re-check for safety)
44
44
  for (const model of allModels) {
45
45
  const seen = new Set();
46
46
  for (const field of model.fields) {
47
47
  if (seen.has(field.name)) {
48
48
  errors.push({
49
- code: 'E005',
49
+ code: 'M3L-E006',
50
50
  severity: 'error',
51
51
  file: field.loc.file,
52
52
  line: field.loc.line,
@@ -61,16 +61,16 @@ export function validate(ast, options = {}) {
61
61
  if (options.strict) {
62
62
  for (const model of allModels) {
63
63
  for (const field of model.fields) {
64
- // W001: Field line length > 80 chars
64
+ // M3L-W001: Field line length > 80 chars
65
65
  // We check the source loc raw length — approximate using field attributes count
66
66
  checkFieldLineLength(field, model, warnings);
67
- // W003: Framework attrs without backtick (already processed in lexer, skip)
68
- // W004: Lookup chain > 3 hops
67
+ // M3L-W003: Framework attrs without backtick (already processed in lexer, skip)
68
+ // M3L-W004: Lookup chain > 3 hops
69
69
  if (field.kind === 'lookup' && field.lookup) {
70
70
  const hops = field.lookup.path.split('.').length;
71
71
  if (hops > 3) {
72
72
  warnings.push({
73
- code: 'W004',
73
+ code: 'M3L-W004',
74
74
  severity: 'warning',
75
75
  file: field.loc.file,
76
76
  line: field.loc.line,
@@ -80,10 +80,10 @@ export function validate(ast, options = {}) {
80
80
  }
81
81
  }
82
82
  }
83
- // W002: Object nesting > 3 levels
83
+ // M3L-W002: Object nesting > 3 levels
84
84
  checkNestingDepth(model.fields, 1, model, warnings);
85
85
  }
86
- // W006: Inline enum missing values: key
86
+ // M3L-W006: Inline enum missing values: key
87
87
  for (const model of allModels) {
88
88
  for (const field of model.fields) {
89
89
  if (field.type === 'enum' && field.enum_values && field.enum_values.length > 0) {
@@ -102,7 +102,7 @@ function validateRollupReference(field, model, modelMap, errors) {
102
102
  const rollup = field.rollup;
103
103
  const targetModel = modelMap.get(rollup.target);
104
104
  if (!targetModel) {
105
- // Target model doesn't exist — this is E007 (already caught in resolver)
105
+ // Target model doesn't exist — this is M3L-E007 (already caught in resolver)
106
106
  return;
107
107
  }
108
108
  // Check that the FK field in the target model has @reference or @fk
@@ -114,7 +114,7 @@ function validateRollupReference(field, model, modelMap, errors) {
114
114
  const hasReference = fkField.attributes.some(a => a.name === 'reference' || a.name === 'fk');
115
115
  if (!hasReference) {
116
116
  errors.push({
117
- code: 'E001',
117
+ code: 'M3L-E001',
118
118
  severity: 'error',
119
119
  file: field.loc.file,
120
120
  line: field.loc.line,
@@ -136,7 +136,7 @@ function validateLookupReference(field, model, modelMap, errors) {
136
136
  const hasReference = fkField.attributes.some(a => a.name === 'reference' || a.name === 'fk');
137
137
  if (!hasReference) {
138
138
  errors.push({
139
- code: 'E002',
139
+ code: 'M3L-E002',
140
140
  severity: 'error',
141
141
  file: field.loc.file,
142
142
  line: field.loc.line,
@@ -168,7 +168,7 @@ function checkFieldLineLength(field, model, warnings) {
168
168
  len += 3 + field.description.length;
169
169
  if (len > 80) {
170
170
  warnings.push({
171
- code: 'W001',
171
+ code: 'M3L-W001',
172
172
  severity: 'warning',
173
173
  file: field.loc.file,
174
174
  line: field.loc.line,
@@ -182,7 +182,7 @@ function checkNestingDepth(fields, depth, model, warnings) {
182
182
  if (field.fields && field.fields.length > 0) {
183
183
  if (depth >= 3) {
184
184
  warnings.push({
185
- code: 'W002',
185
+ code: 'M3L-W002',
186
186
  severity: 'warning',
187
187
  file: field.loc.file,
188
188
  line: field.loc.line,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/m3l",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "M3L parser and CLI tool — parse .m3l.md files into JSON AST",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",