@iyulab/m3l 0.1.1 → 0.1.3

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 CHANGED
@@ -64,7 +64,7 @@ npx m3l validate ./models --strict --format json
64
64
  - bio(Biography): text?
65
65
  - birth_date: date?
66
66
 
67
- # Rollup
67
+ ### Rollup
68
68
  - book_count: integer @rollup(BookAuthor.author_id, count)
69
69
 
70
70
  > Stores information about book authors.
@@ -82,10 +82,10 @@ npx m3l validate ./models --strict --format json
82
82
  - borrowed: "Borrowed"
83
83
  - publisher_id: identifier @fk(Publisher.id)
84
84
 
85
- # Lookup
85
+ ### Lookup
86
86
  - publisher_name: string @lookup(publisher_id.name)
87
87
 
88
- # Computed
88
+ ### Computed
89
89
  - is_available: boolean @computed("status = 'available' AND quantity > 0")
90
90
 
91
91
  ## OverdueLoans ::view @materialized
@@ -110,9 +110,11 @@ npx m3l validate ./models --strict --format json
110
110
  | `- field: type` | Field definition |
111
111
  | `- field: type?` | Nullable field |
112
112
  | `- field: type[]` | Array field |
113
+ | `- field: type?[]` | Array of nullable items |
113
114
  | `- field: type = val` | Field with default value |
114
115
  | `@attr` / `@attr(args)` | Attribute (constraint, index, etc.) |
115
- | `# Lookup` / `# Rollup` / `# Computed` | Kind section for derived fields |
116
+ | `` `[FrameworkAttr]` `` | Custom framework attribute |
117
+ | `### Lookup` / `### Rollup` / `### Computed` | Kind section for derived fields |
116
118
  | `### Section` | Named section (Indexes, Relations, Metadata, etc.) |
117
119
  | `> text` | Model/element description |
118
120
  | `"text"` | Inline description on field |
@@ -123,18 +125,54 @@ The parser produces an `M3LAST` object:
123
125
 
124
126
  ```typescript
125
127
  interface M3LAST {
128
+ parserVersion: string; // Parser package version (semver)
129
+ astVersion: string; // AST schema version
126
130
  project: { name?: string; version?: string };
127
- sources: string[]; // parsed file paths
128
- models: ModelNode[]; // models and interfaces
129
- enums: EnumNode[]; // enum definitions
130
- interfaces: ModelNode[]; // interface definitions
131
- views: ModelNode[]; // derived views
132
- errors: Diagnostic[]; // parse/resolve errors
133
- warnings: Diagnostic[]; // validation warnings
131
+ sources: string[];
132
+ models: ModelNode[];
133
+ enums: EnumNode[];
134
+ interfaces: ModelNode[];
135
+ views: ModelNode[];
136
+ errors: Diagnostic[];
137
+ warnings: Diagnostic[];
134
138
  }
135
139
  ```
136
140
 
137
- Each `ModelNode` contains fields, sections (indexes, relations, metadata), inheritance info, and source locations for error reporting.
141
+ ### Key AST types
142
+
143
+ ```typescript
144
+ interface FieldNode {
145
+ name: string;
146
+ label?: string;
147
+ type?: string;
148
+ params?: (string | number)[];
149
+ generic_params?: string[]; // map<K,V> → ["K", "V"]
150
+ nullable: boolean;
151
+ array: boolean;
152
+ arrayItemNullable: boolean; // string?[] → true
153
+ kind: 'stored' | 'computed' | 'lookup' | 'rollup';
154
+ default_value?: string;
155
+ description?: string;
156
+ attributes: FieldAttribute[];
157
+ framework_attrs?: CustomAttribute[];
158
+ lookup?: { path: string };
159
+ rollup?: { target: string; fk: string; aggregate: string; field?: string; where?: string };
160
+ computed?: { expression: string };
161
+ enum_values?: EnumValue[];
162
+ fields?: FieldNode[]; // sub-fields for object type
163
+ loc: SourceLocation;
164
+ }
165
+
166
+ interface ModelNode {
167
+ name: string;
168
+ type: 'model' | 'enum' | 'interface' | 'view';
169
+ inherits: string[];
170
+ attributes: FieldAttribute[]; // model-level attributes (@public, etc.)
171
+ fields: FieldNode[];
172
+ sections: { indexes; relations; behaviors; metadata; [key: string]: unknown };
173
+ // ...
174
+ }
175
+ ```
138
176
 
139
177
  ## Validation
140
178
 
@@ -143,20 +181,17 @@ The validator checks for semantic errors and style warnings:
143
181
  **Errors:**
144
182
  | Code | Description |
145
183
  |------|-------------|
146
- | E001 | Rollup FK field missing `@reference` |
147
- | E002 | Lookup FK field missing `@reference` |
148
- | E004 | View references non-existent model |
149
- | E005 | Duplicate model/enum name |
150
- | E006 | Duplicate field name within model |
151
- | E007 | Unresolved parent in inheritance |
184
+ | M3L-E001 | Rollup FK field missing `@reference` |
185
+ | M3L-E002 | Lookup FK field missing `@reference` |
186
+ | M3L-E004 | View references non-existent model |
187
+ | M3L-E006 | Duplicate field name within model |
152
188
 
153
189
  **Warnings (--strict):**
154
190
  | Code | Description |
155
191
  |------|-------------|
156
- | W001 | Model has no fields |
157
- | W002 | Model has no description |
158
- | W003 | Field missing label |
159
- | W004 | Enum has no values |
192
+ | M3L-W001 | Field line exceeds 80 characters |
193
+ | M3L-W002 | Object nesting exceeds 3 levels |
194
+ | M3L-W004 | Lookup chain exceeds 3 hops |
160
195
 
161
196
  ## Multi-file Projects
162
197
 
@@ -197,6 +232,18 @@ const ast = resolve([parsed]);
197
232
  const diagnostics = validate(ast, { strict: true });
198
233
  ```
199
234
 
235
+ ## Compatibility
236
+
237
+ This TypeScript parser and the [C# parser](../csharp/) produce equivalent AST structures. Both share the same conformance test suite.
238
+
239
+ | | TypeScript | C# |
240
+ |---|---|---|
241
+ | Package | `@iyulab/m3l` | `M3LParser` |
242
+ | Runtime | Node.js 20+ | .NET 8.0+ |
243
+ | AST Version | 1.0 | 1.0 |
244
+
245
+ Version is managed centrally via the root `VERSION` file.
246
+
200
247
  ## License
201
248
 
202
249
  MIT
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { lex } from './lexer.js';
2
- export { parseTokens, parseString as parseFileString } from './parser.js';
2
+ export { parseTokens, parseString as parseFileString, STANDARD_ATTRIBUTES } from './parser.js';
3
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';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { lex } from './lexer.js';
2
- export { parseTokens, parseString as parseFileString } from './parser.js';
2
+ export { parseTokens, parseString as parseFileString, STANDARD_ATTRIBUTES } from './parser.js';
3
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';
@@ -15,7 +15,7 @@ export async function parse(inputPath) {
15
15
  const resolved = resolvePath(inputPath);
16
16
  const files = await readM3LFiles(resolved);
17
17
  if (files.length === 0) {
18
- throw new Error(`No .m3l.md files found at: ${inputPath}`);
18
+ throw new Error(`No .m3l.md or .m3l files found at: ${inputPath}`);
19
19
  }
20
20
  const parsedFiles = files.map(f => parseFileContent(f.content, f.path));
21
21
  let projectInfo;
package/dist/lexer.js CHANGED
@@ -7,7 +7,7 @@ const RE_BLOCKQUOTE = /^> (.+)$/;
7
7
  const RE_LIST_ITEM = /^(\s*)- (.+)$/;
8
8
  const RE_BLANK = /^\s*$/;
9
9
  // H2 sub-patterns
10
- const RE_TYPE_INDICATOR = /^([\w][\w.]*(?:\([^)]*\))?)\s*::(\w+)(.*)$/;
10
+ 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*(.+))?$/;
@@ -35,15 +35,20 @@ export function lex(content, file) {
35
35
  tokens.push({ type: 'horizontal_rule', raw, line: lineNum, indent: 0 });
36
36
  continue;
37
37
  }
38
- // H3 — Section header
38
+ // H3 — Section header (including kind sections: ### Lookup, ### Rollup, ### Computed)
39
39
  const h3Match = raw.match(RE_H3);
40
40
  if (h3Match) {
41
+ const h3Name = h3Match[1].trim();
42
+ const data = { name: h3Name };
43
+ if (KIND_SECTIONS.has(h3Name)) {
44
+ data.kind_section = true;
45
+ }
41
46
  tokens.push({
42
47
  type: 'section',
43
48
  raw,
44
49
  line: lineNum,
45
50
  indent: 0,
46
- data: { name: h3Match[1].trim() },
51
+ data,
47
52
  });
48
53
  continue;
49
54
  }
@@ -54,29 +59,17 @@ export function lex(content, file) {
54
59
  tokens.push(tokenizeH2(h2Content, raw, lineNum));
55
60
  continue;
56
61
  }
57
- // H1 — Namespace or kind-section context
62
+ // H1 — Namespace / Document title only
58
63
  const h1Match = raw.match(RE_H1);
59
64
  if (h1Match) {
60
65
  const h1Content = h1Match[1].trim();
61
- // Check if this is a kind section (# Lookup, # Rollup, # Computed)
62
- if (KIND_SECTIONS.has(h1Content)) {
63
- tokens.push({
64
- type: 'section',
65
- raw,
66
- line: lineNum,
67
- indent: 0,
68
- data: { name: h1Content, kind_section: true },
69
- });
70
- }
71
- else {
72
- tokens.push({
73
- type: 'namespace',
74
- raw,
75
- line: lineNum,
76
- indent: 0,
77
- data: parseNamespace(h1Content),
78
- });
79
- }
66
+ tokens.push({
67
+ type: 'namespace',
68
+ raw,
69
+ line: lineNum,
70
+ indent: 0,
71
+ data: parseNamespace(h1Content),
72
+ });
80
73
  continue;
81
74
  }
82
75
  // Blockquote
@@ -142,7 +135,7 @@ export function lex(content, file) {
142
135
  return tokens;
143
136
  }
144
137
  function tokenizeH2(content, raw, line) {
145
- // Check for type indicator: ## Name ::enum, ::interface, ::view
138
+ // Check for type indicator: ## Name ::enum, ::interface, ::view, ::attribute
146
139
  const typeMatch = content.match(RE_TYPE_INDICATOR);
147
140
  if (typeMatch) {
148
141
  const namepart = typeMatch[1];
@@ -158,7 +151,8 @@ function tokenizeH2(content, raw, line) {
158
151
  if (descMatch) {
159
152
  data.description = descMatch[1];
160
153
  }
161
- return { type: typeIndicator, raw, line, indent: 0, data };
154
+ const tokenType = typeIndicator === 'attribute' ? 'attribute_def' : typeIndicator;
155
+ return { type: tokenType, raw, line, indent: 0, data };
162
156
  }
163
157
  // Regular model: ## Name : Parent1, Parent2
164
158
  const modelMatch = content.match(RE_MODEL_DEF);
@@ -331,9 +325,24 @@ export function parseTypeAndAttrs(rest, data) {
331
325
  skipWS();
332
326
  }
333
327
  }
334
- // Parse attributes: @name or @name(balanced_args)
328
+ // Parse attributes: @name or @name(balanced_args), with optional cascade symbols (! !!)
335
329
  const attrs = [];
336
- while (pos < len && rest[pos] === '@') {
330
+ while (pos < len && (rest[pos] === '@' || rest[pos] === '!' || rest[pos] === '?')) {
331
+ // Cascade symbols: !, !!, ? — attach to the previous attribute
332
+ if (rest[pos] === '!' || rest[pos] === '?') {
333
+ let symbol = rest[pos];
334
+ pos++;
335
+ if (symbol === '!' && pos < len && rest[pos] === '!') {
336
+ symbol = '!!';
337
+ pos++;
338
+ }
339
+ // Attach cascade to last attribute
340
+ if (attrs.length > 0) {
341
+ attrs[attrs.length - 1].cascade = symbol;
342
+ }
343
+ skipWS();
344
+ continue;
345
+ }
337
346
  pos++; // skip @
338
347
  const nameStart = pos;
339
348
  while (pos < len && /\w/.test(rest[pos]))
package/dist/parser.d.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import type { Token, ParsedFile } from './types.js';
2
+ /**
3
+ * Standard M3L attribute catalog.
4
+ * These are the officially defined attributes in the M3L specification.
5
+ */
6
+ export declare const STANDARD_ATTRIBUTES: Set<string>;
2
7
  /**
3
8
  * Parse M3L content string into a ParsedFile AST.
4
9
  */
package/dist/parser.js CHANGED
@@ -1,4 +1,22 @@
1
1
  import { lex, parseTypeAndAttrs } from './lexer.js';
2
+ /**
3
+ * Standard M3L attribute catalog.
4
+ * These are the officially defined attributes in the M3L specification.
5
+ */
6
+ export const STANDARD_ATTRIBUTES = new Set([
7
+ // Field constraints
8
+ 'primary', 'unique', 'required', 'index', 'generated', 'immutable',
9
+ // References / relations
10
+ 'reference', 'fk', 'relation', 'on_update', 'on_delete',
11
+ // Search / display
12
+ 'searchable', 'description', 'visibility',
13
+ // Validation
14
+ 'min', 'max', 'validate', 'not_null',
15
+ // Derived fields
16
+ 'computed', 'computed_raw', 'lookup', 'rollup', 'from', 'persisted',
17
+ // Model-level
18
+ 'public', 'private', 'materialized', 'meta', 'behavior', 'override', 'default_attribute',
19
+ ]);
2
20
  /**
3
21
  * Parse M3L content string into a ParsedFile AST.
4
22
  */
@@ -21,6 +39,8 @@ export function parseTokens(tokens, file) {
21
39
  enums: [],
22
40
  interfaces: [],
23
41
  views: [],
42
+ attributeRegistry: [],
43
+ currentAttrDef: null,
24
44
  sourceDirectivesDone: false,
25
45
  };
26
46
  for (const token of tokens) {
@@ -35,6 +55,7 @@ export function parseTokens(tokens, file) {
35
55
  enums: state.enums,
36
56
  interfaces: state.interfaces,
37
57
  views: state.views,
58
+ attributeRegistry: state.attributeRegistry,
38
59
  };
39
60
  }
40
61
  function processToken(token, state) {
@@ -52,6 +73,9 @@ function processToken(token, state) {
52
73
  case 'view':
53
74
  handleViewStart(token, state);
54
75
  break;
76
+ case 'attribute_def':
77
+ handleAttributeDefStart(token, state);
78
+ break;
55
79
  case 'section':
56
80
  handleSection(token, state);
57
81
  break;
@@ -75,11 +99,6 @@ function processToken(token, state) {
75
99
  }
76
100
  function handleNamespace(token, state) {
77
101
  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
102
  if (!state.currentElement) {
84
103
  state.namespace = data.name;
85
104
  }
@@ -186,6 +205,18 @@ function handleSection(token, state) {
186
205
  }
187
206
  }
188
207
  function handleField(token, state) {
208
+ // Handle attribute definition fields (- target: [field, model])
209
+ if (state.currentAttrDef) {
210
+ const data = token.data;
211
+ const name = data.name;
212
+ const raw = token.raw.trim().replace(/^-\s*/, '');
213
+ const colonIdx = raw.indexOf(':');
214
+ if (colonIdx >= 0) {
215
+ const value = raw.substring(colonIdx + 1).trim();
216
+ state.currentAttrDef.fields.set(name, value);
217
+ }
218
+ return;
219
+ }
189
220
  if (!state.currentElement)
190
221
  return;
191
222
  const data = token.data;
@@ -460,6 +491,12 @@ function handleNestedItem(token, state) {
460
491
  }
461
492
  }
462
493
  function handleBlockquote(token, state) {
494
+ // Handle attribute definition description
495
+ if (state.currentAttrDef) {
496
+ const text = token.data.text;
497
+ state.currentAttrDef.description = text;
498
+ return;
499
+ }
463
500
  if (!state.currentElement)
464
501
  return;
465
502
  const text = token.data.text;
@@ -483,6 +520,8 @@ function handleText(token, state) {
483
520
  }
484
521
  }
485
522
  function finalizeElement(state) {
523
+ // Finalize pending attribute definition
524
+ finalizeAttrDef(state);
486
525
  if (!state.currentElement)
487
526
  return;
488
527
  if (isEnumNode(state.currentElement)) {
@@ -507,6 +546,70 @@ function finalizeElement(state) {
507
546
  state.currentKind = 'stored';
508
547
  state.lastField = null;
509
548
  }
549
+ function handleAttributeDefStart(token, state) {
550
+ finalizeElement(state);
551
+ const data = token.data || {};
552
+ const name = (data.name || '').replace(/^@/, '');
553
+ state.currentAttrDef = {
554
+ name,
555
+ description: data.description,
556
+ fields: new Map(),
557
+ };
558
+ state.currentElement = null;
559
+ }
560
+ function finalizeAttrDef(state) {
561
+ if (!state.currentAttrDef)
562
+ return;
563
+ const def = state.currentAttrDef;
564
+ const fields = def.fields;
565
+ const targetRaw = fields.get('target');
566
+ const target = [];
567
+ if (targetRaw) {
568
+ const cleaned = targetRaw.replace(/^\[|\]$/g, '').split(',').map(s => s.trim());
569
+ for (const t of cleaned) {
570
+ if (t === 'field' || t === 'model')
571
+ target.push(t);
572
+ }
573
+ }
574
+ const rangeRaw = fields.get('range');
575
+ let range;
576
+ if (rangeRaw) {
577
+ const nums = rangeRaw.replace(/^\[|\]$/g, '').split(',').map(s => Number(s.trim()));
578
+ if (nums.length === 2 && !isNaN(nums[0]) && !isNaN(nums[1])) {
579
+ range = [nums[0], nums[1]];
580
+ }
581
+ }
582
+ const requiredRaw = fields.get('required');
583
+ const required = requiredRaw === 'true' || requiredRaw === true;
584
+ const defaultRaw = fields.get('default');
585
+ let defaultValue;
586
+ if (defaultRaw !== undefined) {
587
+ if (defaultRaw === 'true')
588
+ defaultValue = true;
589
+ else if (defaultRaw === 'false')
590
+ defaultValue = false;
591
+ else if (typeof defaultRaw === 'string' && !isNaN(Number(defaultRaw)))
592
+ defaultValue = Number(defaultRaw);
593
+ else if (typeof defaultRaw === 'string')
594
+ defaultValue = defaultRaw;
595
+ else if (typeof defaultRaw === 'number' || typeof defaultRaw === 'boolean')
596
+ defaultValue = defaultRaw;
597
+ }
598
+ const entry = {
599
+ name: def.name,
600
+ target: target.length > 0 ? target : ['field'],
601
+ type: fields.get('type') || 'boolean',
602
+ required,
603
+ };
604
+ if (def.description)
605
+ entry.description = def.description;
606
+ if (range)
607
+ entry.range = range;
608
+ if (defaultValue !== undefined)
609
+ entry.defaultValue = defaultValue;
610
+ state.attributeRegistry.push(entry);
611
+ state.currentAttrDef = null;
612
+ }
510
613
  // --- Helpers ---
511
614
  function buildFieldNode(data, token, state) {
512
615
  const attrs = parseAttributes(data.attributes);
@@ -554,20 +657,63 @@ function buildFieldNode(data, token, state) {
554
657
  const expr = computedAttr.args[0].replace(/^["']|["']$/g, '');
555
658
  field.computed = { expression: expr };
556
659
  }
557
- // Parse computed_raw: @computed_raw("expression", ...)
660
+ // Parse computed_raw: @computed_raw("expression", platform: "name")
558
661
  if (computedRawAttr && computedRawAttr.args?.[0]) {
559
- const expr = computedRawAttr.args[0].replace(/^["']|["']$/g, '');
662
+ const rawArgs = computedRawAttr.args[0];
663
+ const parts = splitComputedRawArgs(rawArgs);
664
+ const expr = parts.expression.replace(/^["']|["']$/g, '');
560
665
  field.computed = { expression: expr };
666
+ if (parts.platform) {
667
+ field.computed.platform = parts.platform;
668
+ }
561
669
  }
562
670
  return field;
563
671
  }
672
+ /**
673
+ * Split @computed_raw args: "expr", platform: "name"
674
+ * Returns the expression (first positional arg) and optional named params.
675
+ */
676
+ function splitComputedRawArgs(raw) {
677
+ // Find the first quoted string as the expression
678
+ const quoteChar = raw[0];
679
+ if (quoteChar === '"' || quoteChar === "'") {
680
+ // Find matching closing quote (not escaped)
681
+ let i = 1;
682
+ while (i < raw.length) {
683
+ if (raw[i] === '\\') {
684
+ i += 2;
685
+ continue;
686
+ }
687
+ if (raw[i] === quoteChar)
688
+ break;
689
+ i++;
690
+ }
691
+ const expression = raw.slice(0, i + 1); // includes quotes
692
+ const remainder = raw.slice(i + 1).trim();
693
+ // Parse named params from remainder: , platform: "sqlserver"
694
+ let platform;
695
+ const platformMatch = remainder.match(/platform\s*:\s*["']([^"']+)["']/);
696
+ if (platformMatch) {
697
+ platform = platformMatch[1];
698
+ }
699
+ return { expression, platform };
700
+ }
701
+ // No quotes — treat entire string as expression
702
+ return { expression: raw };
703
+ }
564
704
  function parseAttributes(rawAttrs) {
565
705
  if (!rawAttrs)
566
706
  return [];
567
- return rawAttrs.map(a => ({
568
- name: a.name,
569
- args: a.args ? [a.args] : undefined,
570
- }));
707
+ return rawAttrs.map(a => {
708
+ const attr = { name: a.name };
709
+ if (a.args)
710
+ attr.args = [a.args];
711
+ if (a.cascade)
712
+ attr.cascade = a.cascade;
713
+ if (STANDARD_ATTRIBUTES.has(a.name))
714
+ attr.isStandard = true;
715
+ return attr;
716
+ });
571
717
  }
572
718
  function parseTypeParams(params) {
573
719
  if (!params)
@@ -697,8 +843,12 @@ function parseMetadataValue(value) {
697
843
  if (typeof value !== 'string')
698
844
  return value;
699
845
  const str = value;
700
- // Remove surrounding quotes
846
+ // If explicitly quoted, preserve as string (don't coerce "1.0" to number)
847
+ const wasQuoted = (str.startsWith('"') && str.endsWith('"')) ||
848
+ (str.startsWith("'") && str.endsWith("'"));
701
849
  const unquoted = str.replace(/^["']|["']$/g, '');
850
+ if (wasQuoted)
851
+ return unquoted;
702
852
  // Try number
703
853
  const n = Number(unquoted);
704
854
  if (!isNaN(n) && unquoted !== '')
@@ -758,9 +908,57 @@ function parseCustomAttributes(rawAttrs) {
758
908
  return rawAttrs.map(raw => {
759
909
  // raw is "[MaxLength(100)]" — strip brackets to get content
760
910
  const content = raw.replace(/^\[|\]$/g, '');
761
- return { content, raw };
911
+ const attr = { content, raw };
912
+ // Try to parse "Name(arg1, arg2)" or "Name" pattern
913
+ const match = content.match(/^([A-Za-z_][\w.]*)(?:\((.+)\))?$/);
914
+ if (match) {
915
+ const name = match[1];
916
+ const argsStr = match[2];
917
+ const args = [];
918
+ if (argsStr) {
919
+ for (const part of splitBalanced(argsStr)) {
920
+ const trimmed = part.trim();
921
+ args.push(parseArgValue(trimmed));
922
+ }
923
+ }
924
+ attr.parsed = { name, arguments: args };
925
+ }
926
+ return attr;
762
927
  });
763
928
  }
929
+ /** Split a string by commas, respecting balanced parentheses */
930
+ function splitBalanced(s) {
931
+ const parts = [];
932
+ let depth = 0;
933
+ let start = 0;
934
+ for (let i = 0; i < s.length; i++) {
935
+ if (s[i] === '(')
936
+ depth++;
937
+ else if (s[i] === ')')
938
+ depth--;
939
+ else if (s[i] === ',' && depth === 0) {
940
+ parts.push(s.substring(start, i));
941
+ start = i + 1;
942
+ }
943
+ }
944
+ parts.push(s.substring(start));
945
+ return parts;
946
+ }
947
+ /** Parse a single argument value: numbers, booleans, or strings */
948
+ function parseArgValue(s) {
949
+ if (s === 'true')
950
+ return true;
951
+ if (s === 'false')
952
+ return false;
953
+ const n = Number(s);
954
+ if (!isNaN(n) && s.length > 0)
955
+ return n;
956
+ // Strip surrounding quotes if present
957
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
958
+ return s.slice(1, -1);
959
+ }
960
+ return s;
961
+ }
764
962
  function isEnumNode(el) {
765
963
  return el.type === 'enum' && 'values' in el;
766
964
  }
package/dist/reader.d.ts CHANGED
@@ -4,7 +4,7 @@ export interface M3LFile {
4
4
  }
5
5
  /**
6
6
  * Read M3L files from a path (file or directory).
7
- * If path is a directory, scans for **\/*.m3l.md files.
7
+ * If path is a directory, scans for **\/*.m3l.md and **\/*.m3l files.
8
8
  * If an m3l.config.yaml exists in the directory, uses its sources patterns.
9
9
  */
10
10
  export declare function readM3LFiles(inputPath: string): Promise<M3LFile[]>;
package/dist/reader.js CHANGED
@@ -3,7 +3,7 @@ import { join, resolve as resolvePath } from 'path';
3
3
  import fg from 'fast-glob';
4
4
  /**
5
5
  * Read M3L files from a path (file or directory).
6
- * If path is a directory, scans for **\/*.m3l.md files.
6
+ * If path is a directory, scans for **\/*.m3l.md and **\/*.m3l files.
7
7
  * If an m3l.config.yaml exists in the directory, uses its sources patterns.
8
8
  */
9
9
  export async function readM3LFiles(inputPath) {
@@ -2,7 +2,7 @@ import type { ParsedFile, M3LAST, ProjectInfo } from './types.js';
2
2
  /** AST schema version — bump major on breaking structure changes */
3
3
  export declare const AST_VERSION = "1.0";
4
4
  /** Parser package version — kept in sync with package.json */
5
- export declare const PARSER_VERSION = "0.1.1";
5
+ export declare const PARSER_VERSION = "0.1.3";
6
6
  /**
7
7
  * Resolve and merge multiple parsed file ASTs into a single M3LAST.
8
8
  * Handles: inheritance resolution, duplicate detection, reference validation.
package/dist/resolver.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** AST schema version — bump major on breaking structure changes */
2
2
  export const AST_VERSION = '1.0';
3
3
  /** Parser package version — kept in sync with package.json */
4
- export const PARSER_VERSION = '0.1.1';
4
+ export const PARSER_VERSION = '0.1.3';
5
5
  /**
6
6
  * Resolve and merge multiple parsed file ASTs into a single M3LAST.
7
7
  * Handles: inheritance resolution, duplicate detection, reference validation.
@@ -14,6 +14,7 @@ export function resolve(files, project) {
14
14
  const allEnums = [];
15
15
  const allInterfaces = [];
16
16
  const allViews = [];
17
+ const allAttrRegistry = [];
17
18
  const sources = [];
18
19
  for (const file of files) {
19
20
  sources.push(file.source);
@@ -21,6 +22,8 @@ export function resolve(files, project) {
21
22
  allEnums.push(...file.enums);
22
23
  allInterfaces.push(...file.interfaces);
23
24
  allViews.push(...file.views);
25
+ if (file.attributeRegistry)
26
+ allAttrRegistry.push(...file.attributeRegistry);
24
27
  }
25
28
  // Build name maps
26
29
  const modelMap = new Map();
@@ -54,6 +57,29 @@ export function resolve(files, project) {
54
57
  for (const model of [...allModels, ...allViews]) {
55
58
  checkDuplicateFields(model, errors);
56
59
  }
60
+ // Tag isRegistered on attributes matching the registry
61
+ if (allAttrRegistry.length > 0) {
62
+ const registeredNames = new Set(allAttrRegistry.map(r => r.name));
63
+ const tagAttrs = (attrs) => {
64
+ for (const a of attrs) {
65
+ if (registeredNames.has(a.name)) {
66
+ a.isRegistered = true;
67
+ }
68
+ }
69
+ };
70
+ const tagModel = (m) => {
71
+ tagAttrs(m.attributes);
72
+ for (const f of m.fields) {
73
+ tagAttrs(f.attributes);
74
+ }
75
+ };
76
+ for (const m of allModels)
77
+ tagModel(m);
78
+ for (const v of allViews)
79
+ tagModel(v);
80
+ for (const i of allInterfaces)
81
+ tagModel(i);
82
+ }
57
83
  // Detect namespace from first file if available
58
84
  const projectInfo = project || {};
59
85
  if (!projectInfo.name) {
@@ -70,6 +96,7 @@ export function resolve(files, project) {
70
96
  enums: allEnums,
71
97
  interfaces: allInterfaces,
72
98
  views: allViews,
99
+ attributeRegistry: allAttrRegistry,
73
100
  errors,
74
101
  warnings,
75
102
  };
@@ -131,11 +158,10 @@ function resolveInheritance(model, modelMap, interfaceMap, allNamedMap, errors)
131
158
  // Handle @override: child fields with @override replace inherited fields
132
159
  const overrideNames = new Set();
133
160
  for (const ownField of model.fields) {
134
- const overrideIdx = ownField.attributes.findIndex(a => a.name === 'override');
135
- if (overrideIdx >= 0) {
161
+ const hasOverride = ownField.attributes.some(a => a.name === 'override');
162
+ if (hasOverride) {
136
163
  overrideNames.add(ownField.name);
137
- // Remove @override attribute from the child field
138
- ownField.attributes.splice(overrideIdx, 1);
164
+ // Preserve @override in attributes so AST consumers can detect it
139
165
  }
140
166
  }
141
167
  // Remove inherited fields that are overridden
package/dist/types.d.ts CHANGED
@@ -4,7 +4,7 @@ export interface SourceLocation {
4
4
  line: number;
5
5
  col: number;
6
6
  }
7
- export type TokenType = 'namespace' | 'model' | 'enum' | 'interface' | 'view' | 'section' | 'field' | 'nested_item' | 'blockquote' | 'horizontal_rule' | 'blank' | 'text';
7
+ export type TokenType = 'namespace' | 'model' | 'enum' | 'interface' | 'view' | 'attribute_def' | 'section' | 'field' | 'nested_item' | 'blockquote' | 'horizontal_rule' | 'blank' | 'text';
8
8
  export interface Token {
9
9
  type: TokenType;
10
10
  raw: string;
@@ -16,6 +16,11 @@ export type FieldKind = 'stored' | 'computed' | 'lookup' | 'rollup';
16
16
  export interface FieldAttribute {
17
17
  name: string;
18
18
  args?: (string | number | boolean)[];
19
+ cascade?: string;
20
+ /** Whether this is a standard M3L attribute (from the official catalog) */
21
+ isStandard?: boolean;
22
+ /** Whether this attribute is registered in an Attribute Registry (::attribute definition) */
23
+ isRegistered?: boolean;
19
24
  }
20
25
  /** Structured representation of a backtick-wrapped framework attribute like `[MaxLength(100)]` */
21
26
  export interface CustomAttribute {
@@ -23,6 +28,11 @@ export interface CustomAttribute {
23
28
  content: string;
24
29
  /** Original text including brackets, e.g., "[MaxLength(100)]" */
25
30
  raw: string;
31
+ /** Parsed structure — name and arguments extracted from the content */
32
+ parsed?: {
33
+ name: string;
34
+ arguments: (string | number | boolean)[];
35
+ };
26
36
  }
27
37
  export interface EnumValue {
28
38
  name: string;
@@ -56,6 +66,7 @@ export interface FieldNode {
56
66
  };
57
67
  computed?: {
58
68
  expression: string;
69
+ platform?: string;
59
70
  };
60
71
  enum_values?: EnumValue[];
61
72
  fields?: FieldNode[];
@@ -125,6 +136,23 @@ export interface ParsedFile {
125
136
  enums: EnumNode[];
126
137
  interfaces: ModelNode[];
127
138
  views: ModelNode[];
139
+ attributeRegistry: AttributeRegistryEntry[];
140
+ }
141
+ export interface AttributeRegistryEntry {
142
+ /** Attribute name (without @) */
143
+ name: string;
144
+ /** Description */
145
+ description?: string;
146
+ /** Valid targets: 'field', 'model' */
147
+ target: ('field' | 'model')[];
148
+ /** Value type: 'boolean', 'integer', 'string', etc. */
149
+ type: string;
150
+ /** Valid range for numeric types */
151
+ range?: [number, number];
152
+ /** Whether the attribute is required */
153
+ required: boolean;
154
+ /** Default value */
155
+ defaultValue?: string | number | boolean;
128
156
  }
129
157
  export interface M3LAST {
130
158
  /** Parser package version (semver) */
@@ -137,6 +165,8 @@ export interface M3LAST {
137
165
  enums: EnumNode[];
138
166
  interfaces: ModelNode[];
139
167
  views: ModelNode[];
168
+ /** Attribute registry entries parsed from ::attribute definitions */
169
+ attributeRegistry: AttributeRegistryEntry[];
140
170
  errors: Diagnostic[];
141
171
  warnings: Diagnostic[];
142
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/m3l",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",