@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 +69 -22
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/lexer.js +36 -27
- package/dist/parser.d.ts +5 -0
- package/dist/parser.js +211 -13
- package/dist/reader.d.ts +1 -1
- package/dist/reader.js +1 -1
- package/dist/resolver.d.ts +1 -1
- package/dist/resolver.js +31 -5
- package/dist/types.d.ts +31 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
85
|
+
### Lookup
|
|
86
86
|
- publisher_name: string @lookup(publisher_id.name)
|
|
87
87
|
|
|
88
|
-
|
|
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
|
-
|
|
|
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[];
|
|
128
|
-
models: ModelNode[];
|
|
129
|
-
enums: EnumNode[];
|
|
130
|
-
interfaces: ModelNode[];
|
|
131
|
-
views: ModelNode[];
|
|
132
|
-
errors: Diagnostic[];
|
|
133
|
-
warnings: Diagnostic[];
|
|
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
|
-
|
|
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
|
-
|
|
|
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 |
|
|
157
|
-
| W002 |
|
|
158
|
-
|
|
|
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
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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) {
|
package/dist/resolver.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
135
|
-
if (
|
|
161
|
+
const hasOverride = ownField.attributes.some(a => a.name === 'override');
|
|
162
|
+
if (hasOverride) {
|
|
136
163
|
overrideNames.add(ownField.name);
|
|
137
|
-
//
|
|
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
|
}
|