@iyulab/m3l 0.1.0 → 0.1.2
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 +1 -1
- package/dist/lexer.d.ts +1 -0
- package/dist/lexer.js +52 -30
- package/dist/parser.js +126 -24
- package/dist/resolver.d.ts +4 -0
- package/dist/resolver.js +22 -5
- package/dist/types.d.ts +18 -2
- package/dist/validator.js +17 -17
- 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` | `Iyulab.M3L` |
|
|
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,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
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
|
|
@@ -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
|
|
@@ -264,7 +257,7 @@ function parseFieldLine(content) {
|
|
|
264
257
|
parseTypeAndAttrs(rest, data);
|
|
265
258
|
return data;
|
|
266
259
|
}
|
|
267
|
-
function parseTypeAndAttrs(rest, data) {
|
|
260
|
+
export function parseTypeAndAttrs(rest, data) {
|
|
268
261
|
let pos = 0;
|
|
269
262
|
const len = rest.length;
|
|
270
263
|
const skipWS = () => { while (pos < len && rest[pos] === ' ')
|
|
@@ -277,15 +270,29 @@ function parseTypeAndAttrs(rest, data) {
|
|
|
277
270
|
return;
|
|
278
271
|
}
|
|
279
272
|
}
|
|
280
|
-
// Parse type: word(params)?[]
|
|
273
|
+
// Parse type: word<generics>?(params)?[]??
|
|
281
274
|
const typeMatch = rest.match(RE_TYPE_PART);
|
|
282
275
|
if (typeMatch) {
|
|
283
276
|
data.type_name = typeMatch[1];
|
|
277
|
+
// Group 2: generic params from <K,V>
|
|
284
278
|
if (typeMatch[2]) {
|
|
285
|
-
data.
|
|
279
|
+
data.type_generic_params = typeMatch[2].split(',').map(s => s.trim());
|
|
280
|
+
}
|
|
281
|
+
// Group 3: size/type params from (params)
|
|
282
|
+
if (typeMatch[3]) {
|
|
283
|
+
data.type_params = typeMatch[3].split(',').map(s => s.trim());
|
|
284
|
+
}
|
|
285
|
+
// Group 5: array
|
|
286
|
+
data.array = typeMatch[5] === '[]';
|
|
287
|
+
// Group 4: ? before [] = element nullable; Group 6: ? after [] = container nullable
|
|
288
|
+
if (data.array) {
|
|
289
|
+
data.nullable = typeMatch[6] === '?';
|
|
290
|
+
data.arrayItemNullable = typeMatch[4] === '?';
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
data.nullable = typeMatch[4] === '?' || typeMatch[6] === '?';
|
|
294
|
+
data.arrayItemNullable = false;
|
|
286
295
|
}
|
|
287
|
-
data.nullable = typeMatch[3] === '?';
|
|
288
|
-
data.array = typeMatch[4] === '[]';
|
|
289
296
|
pos = typeMatch[0].length;
|
|
290
297
|
skipWS();
|
|
291
298
|
}
|
|
@@ -317,9 +324,24 @@ function parseTypeAndAttrs(rest, data) {
|
|
|
317
324
|
skipWS();
|
|
318
325
|
}
|
|
319
326
|
}
|
|
320
|
-
// Parse attributes: @name or @name(balanced_args)
|
|
327
|
+
// Parse attributes: @name or @name(balanced_args), with optional cascade symbols (! !!)
|
|
321
328
|
const attrs = [];
|
|
322
|
-
while (pos < len && rest[pos] === '@') {
|
|
329
|
+
while (pos < len && (rest[pos] === '@' || rest[pos] === '!' || rest[pos] === '?')) {
|
|
330
|
+
// Cascade symbols: !, !!, ? — attach to the previous attribute
|
|
331
|
+
if (rest[pos] === '!' || rest[pos] === '?') {
|
|
332
|
+
let symbol = rest[pos];
|
|
333
|
+
pos++;
|
|
334
|
+
if (symbol === '!' && pos < len && rest[pos] === '!') {
|
|
335
|
+
symbol = '!!';
|
|
336
|
+
pos++;
|
|
337
|
+
}
|
|
338
|
+
// Attach cascade to last attribute
|
|
339
|
+
if (attrs.length > 0) {
|
|
340
|
+
attrs[attrs.length - 1].cascade = symbol;
|
|
341
|
+
}
|
|
342
|
+
skipWS();
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
323
345
|
pos++; // skip @
|
|
324
346
|
const nameStart = pos;
|
|
325
347
|
while (pos < len && /\w/.test(rest[pos]))
|
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
|
*/
|
|
@@ -75,11 +75,6 @@ function processToken(token, state) {
|
|
|
75
75
|
}
|
|
76
76
|
function handleNamespace(token, state) {
|
|
77
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
78
|
if (!state.currentElement) {
|
|
84
79
|
state.namespace = data.name;
|
|
85
80
|
}
|
|
@@ -87,6 +82,7 @@ function handleNamespace(token, state) {
|
|
|
87
82
|
function handleModelStart(token, state) {
|
|
88
83
|
finalizeElement(state);
|
|
89
84
|
const data = token.data;
|
|
85
|
+
const modelAttrs = parseAttributes(data.attributes);
|
|
90
86
|
const model = {
|
|
91
87
|
name: data.name,
|
|
92
88
|
label: data.label,
|
|
@@ -94,6 +90,7 @@ function handleModelStart(token, state) {
|
|
|
94
90
|
source: state.file,
|
|
95
91
|
line: token.line,
|
|
96
92
|
inherits: data.inherits || [],
|
|
93
|
+
attributes: modelAttrs,
|
|
97
94
|
fields: [],
|
|
98
95
|
sections: {
|
|
99
96
|
indexes: [],
|
|
@@ -137,6 +134,7 @@ function handleViewStart(token, state) {
|
|
|
137
134
|
source: state.file,
|
|
138
135
|
line: token.line,
|
|
139
136
|
inherits: [],
|
|
137
|
+
attributes: [],
|
|
140
138
|
materialized: data.materialized || false,
|
|
141
139
|
fields: [],
|
|
142
140
|
sections: {
|
|
@@ -248,15 +246,25 @@ function handleDirective(data, model, token, state) {
|
|
|
248
246
|
});
|
|
249
247
|
}
|
|
250
248
|
else {
|
|
251
|
-
// Generic directive
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
|
|
249
|
+
// Generic directive — normalize singular form
|
|
250
|
+
let sectionName = attr.name;
|
|
251
|
+
if (sectionName === 'behavior')
|
|
252
|
+
sectionName = 'behaviors';
|
|
253
|
+
if (sectionName === 'behaviors') {
|
|
254
|
+
model.sections.behaviors.push({
|
|
255
|
+
raw: data.raw_content,
|
|
256
|
+
args: attr.args,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
if (!model.sections[sectionName]) {
|
|
261
|
+
model.sections[sectionName] = [];
|
|
262
|
+
}
|
|
263
|
+
model.sections[sectionName].push({
|
|
264
|
+
raw: data.raw_content,
|
|
265
|
+
args: attr.args,
|
|
266
|
+
});
|
|
255
267
|
}
|
|
256
|
-
model.sections[sectionName].push({
|
|
257
|
-
raw: data.raw_content,
|
|
258
|
-
args: attr.args,
|
|
259
|
-
});
|
|
260
268
|
}
|
|
261
269
|
}
|
|
262
270
|
function handleSectionItem(data, model, token, state) {
|
|
@@ -332,10 +340,17 @@ function handleSectionItem(data, model, token, state) {
|
|
|
332
340
|
});
|
|
333
341
|
return;
|
|
334
342
|
}
|
|
335
|
-
// Generic section —
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
343
|
+
// Generic section — store as section items, NOT as fields
|
|
344
|
+
if (!model.sections[section]) {
|
|
345
|
+
model.sections[section] = [];
|
|
346
|
+
}
|
|
347
|
+
model.sections[section].push({
|
|
348
|
+
name: data.name,
|
|
349
|
+
raw: token.raw.trim(),
|
|
350
|
+
value: data.type_name || data.description || data.raw_value,
|
|
351
|
+
loc,
|
|
352
|
+
});
|
|
353
|
+
state.lastField = null;
|
|
339
354
|
}
|
|
340
355
|
function handleNestedItem(token, state) {
|
|
341
356
|
if (!state.currentElement)
|
|
@@ -402,6 +417,29 @@ function handleNestedItem(token, state) {
|
|
|
402
417
|
});
|
|
403
418
|
return;
|
|
404
419
|
}
|
|
420
|
+
// Sub-field for object/nested type
|
|
421
|
+
if (key && value) {
|
|
422
|
+
// Walk up to find the nearest object-type ancestor for this indent level
|
|
423
|
+
const parentField = field;
|
|
424
|
+
if (parentField.type === 'object') {
|
|
425
|
+
if (!parentField.fields)
|
|
426
|
+
parentField.fields = [];
|
|
427
|
+
// Re-parse value as type and attributes
|
|
428
|
+
const subData = { name: key };
|
|
429
|
+
parseTypeAndAttrs(value, subData);
|
|
430
|
+
// Only treat as sub-field if a type was extracted
|
|
431
|
+
if (subData.type_name) {
|
|
432
|
+
const subField = buildFieldNode(subData, token, state);
|
|
433
|
+
parentField.fields.push(subField);
|
|
434
|
+
// If the sub-field is also an object, set it as lastField for deeper nesting
|
|
435
|
+
if (subField.type === 'object') {
|
|
436
|
+
state.lastField = subField;
|
|
437
|
+
}
|
|
438
|
+
// Otherwise keep lastField pointing to the parent object for siblings
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
405
443
|
// Extended format field attributes
|
|
406
444
|
if (key) {
|
|
407
445
|
applyExtendedAttribute(field, key, value || '');
|
|
@@ -472,6 +510,7 @@ function buildFieldNode(data, token, state) {
|
|
|
472
510
|
const lookupAttr = attrs.find(a => a.name === 'lookup');
|
|
473
511
|
const rollupAttr = attrs.find(a => a.name === 'rollup');
|
|
474
512
|
const computedAttr = attrs.find(a => a.name === 'computed');
|
|
513
|
+
const computedRawAttr = attrs.find(a => a.name === 'computed_raw');
|
|
475
514
|
const fromAttr = attrs.find(a => a.name === 'from');
|
|
476
515
|
if (lookupAttr)
|
|
477
516
|
kind = 'lookup';
|
|
@@ -479,18 +518,22 @@ function buildFieldNode(data, token, state) {
|
|
|
479
518
|
kind = 'rollup';
|
|
480
519
|
else if (computedAttr)
|
|
481
520
|
kind = 'computed';
|
|
521
|
+
else if (computedRawAttr)
|
|
522
|
+
kind = 'computed';
|
|
482
523
|
const field = {
|
|
483
524
|
name: data.name,
|
|
484
525
|
label: data.label,
|
|
485
526
|
type: data.type_name,
|
|
486
527
|
params: parseTypeParams(data.type_params),
|
|
528
|
+
generic_params: data.type_generic_params,
|
|
487
529
|
nullable: data.nullable || false,
|
|
488
530
|
array: data.array || false,
|
|
531
|
+
arrayItemNullable: data.arrayItemNullable || false,
|
|
489
532
|
kind,
|
|
490
533
|
default_value: data.default_value,
|
|
491
534
|
description: data.description,
|
|
492
535
|
attributes: attrs,
|
|
493
|
-
framework_attrs: data.framework_attrs,
|
|
536
|
+
framework_attrs: parseCustomAttributes(data.framework_attrs),
|
|
494
537
|
loc: { file: state.file, line: token.line, col: 1 },
|
|
495
538
|
};
|
|
496
539
|
// Parse lookup
|
|
@@ -506,15 +549,61 @@ function buildFieldNode(data, token, state) {
|
|
|
506
549
|
const expr = computedAttr.args[0].replace(/^["']|["']$/g, '');
|
|
507
550
|
field.computed = { expression: expr };
|
|
508
551
|
}
|
|
552
|
+
// Parse computed_raw: @computed_raw("expression", platform: "name")
|
|
553
|
+
if (computedRawAttr && computedRawAttr.args?.[0]) {
|
|
554
|
+
const rawArgs = computedRawAttr.args[0];
|
|
555
|
+
const parts = splitComputedRawArgs(rawArgs);
|
|
556
|
+
const expr = parts.expression.replace(/^["']|["']$/g, '');
|
|
557
|
+
field.computed = { expression: expr };
|
|
558
|
+
if (parts.platform) {
|
|
559
|
+
field.computed.platform = parts.platform;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
509
562
|
return field;
|
|
510
563
|
}
|
|
564
|
+
/**
|
|
565
|
+
* Split @computed_raw args: "expr", platform: "name"
|
|
566
|
+
* Returns the expression (first positional arg) and optional named params.
|
|
567
|
+
*/
|
|
568
|
+
function splitComputedRawArgs(raw) {
|
|
569
|
+
// Find the first quoted string as the expression
|
|
570
|
+
const quoteChar = raw[0];
|
|
571
|
+
if (quoteChar === '"' || quoteChar === "'") {
|
|
572
|
+
// Find matching closing quote (not escaped)
|
|
573
|
+
let i = 1;
|
|
574
|
+
while (i < raw.length) {
|
|
575
|
+
if (raw[i] === '\\') {
|
|
576
|
+
i += 2;
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
if (raw[i] === quoteChar)
|
|
580
|
+
break;
|
|
581
|
+
i++;
|
|
582
|
+
}
|
|
583
|
+
const expression = raw.slice(0, i + 1); // includes quotes
|
|
584
|
+
const remainder = raw.slice(i + 1).trim();
|
|
585
|
+
// Parse named params from remainder: , platform: "sqlserver"
|
|
586
|
+
let platform;
|
|
587
|
+
const platformMatch = remainder.match(/platform\s*:\s*["']([^"']+)["']/);
|
|
588
|
+
if (platformMatch) {
|
|
589
|
+
platform = platformMatch[1];
|
|
590
|
+
}
|
|
591
|
+
return { expression, platform };
|
|
592
|
+
}
|
|
593
|
+
// No quotes — treat entire string as expression
|
|
594
|
+
return { expression: raw };
|
|
595
|
+
}
|
|
511
596
|
function parseAttributes(rawAttrs) {
|
|
512
597
|
if (!rawAttrs)
|
|
513
598
|
return [];
|
|
514
|
-
return rawAttrs.map(a =>
|
|
515
|
-
name: a.name
|
|
516
|
-
|
|
517
|
-
|
|
599
|
+
return rawAttrs.map(a => {
|
|
600
|
+
const attr = { name: a.name };
|
|
601
|
+
if (a.args)
|
|
602
|
+
attr.args = [a.args];
|
|
603
|
+
if (a.cascade)
|
|
604
|
+
attr.cascade = a.cascade;
|
|
605
|
+
return attr;
|
|
606
|
+
});
|
|
518
607
|
}
|
|
519
608
|
function parseTypeParams(params) {
|
|
520
609
|
if (!params)
|
|
@@ -644,8 +733,12 @@ function parseMetadataValue(value) {
|
|
|
644
733
|
if (typeof value !== 'string')
|
|
645
734
|
return value;
|
|
646
735
|
const str = value;
|
|
647
|
-
//
|
|
736
|
+
// If explicitly quoted, preserve as string (don't coerce "1.0" to number)
|
|
737
|
+
const wasQuoted = (str.startsWith('"') && str.endsWith('"')) ||
|
|
738
|
+
(str.startsWith("'") && str.endsWith("'"));
|
|
648
739
|
const unquoted = str.replace(/^["']|["']$/g, '');
|
|
740
|
+
if (wasQuoted)
|
|
741
|
+
return unquoted;
|
|
649
742
|
// Try number
|
|
650
743
|
const n = Number(unquoted);
|
|
651
744
|
if (!isNaN(n) && unquoted !== '')
|
|
@@ -699,6 +792,15 @@ function applyExtendedAttribute(field, key, value) {
|
|
|
699
792
|
break;
|
|
700
793
|
}
|
|
701
794
|
}
|
|
795
|
+
function parseCustomAttributes(rawAttrs) {
|
|
796
|
+
if (!rawAttrs || rawAttrs.length === 0)
|
|
797
|
+
return undefined;
|
|
798
|
+
return rawAttrs.map(raw => {
|
|
799
|
+
// raw is "[MaxLength(100)]" — strip brackets to get content
|
|
800
|
+
const content = raw.replace(/^\[|\]$/g, '');
|
|
801
|
+
return { content, raw };
|
|
802
|
+
});
|
|
803
|
+
}
|
|
702
804
|
function isEnumNode(el) {
|
|
703
805
|
return el.type === 'enum' && 'values' in el;
|
|
704
806
|
}
|
package/dist/resolver.d.ts
CHANGED
|
@@ -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.2";
|
|
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.2';
|
|
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,20 @@ 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 hasOverride = ownField.attributes.some(a => a.name === 'override');
|
|
135
|
+
if (hasOverride) {
|
|
136
|
+
overrideNames.add(ownField.name);
|
|
137
|
+
// Preserve @override in attributes so AST consumers can detect it
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Remove inherited fields that are overridden
|
|
141
|
+
const filteredInherited = inheritedFields.filter(f => !overrideNames.has(f.name));
|
|
125
142
|
// Prepend inherited fields before model's own fields
|
|
126
|
-
if (
|
|
127
|
-
model.fields = [...
|
|
143
|
+
if (filteredInherited.length > 0) {
|
|
144
|
+
model.fields = [...filteredInherited, ...model.fields];
|
|
128
145
|
}
|
|
129
146
|
}
|
|
130
147
|
function checkDuplicateFields(model, errors) {
|
|
@@ -133,7 +150,7 @@ function checkDuplicateFields(model, errors) {
|
|
|
133
150
|
const existing = seen.get(field.name);
|
|
134
151
|
if (existing) {
|
|
135
152
|
errors.push({
|
|
136
|
-
code: 'E005',
|
|
153
|
+
code: 'M3L-E005',
|
|
137
154
|
severity: 'error',
|
|
138
155
|
file: field.loc.file,
|
|
139
156
|
line: field.loc.line,
|
package/dist/types.d.ts
CHANGED
|
@@ -15,7 +15,15 @@ export interface Token {
|
|
|
15
15
|
export type FieldKind = 'stored' | 'computed' | 'lookup' | 'rollup';
|
|
16
16
|
export interface FieldAttribute {
|
|
17
17
|
name: string;
|
|
18
|
-
args?:
|
|
18
|
+
args?: (string | number | boolean)[];
|
|
19
|
+
cascade?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Structured representation of a backtick-wrapped framework attribute like `[MaxLength(100)]` */
|
|
22
|
+
export interface CustomAttribute {
|
|
23
|
+
/** Content inside brackets, e.g., "MaxLength(100)" for `[MaxLength(100)]` */
|
|
24
|
+
content: string;
|
|
25
|
+
/** Original text including brackets, e.g., "[MaxLength(100)]" */
|
|
26
|
+
raw: string;
|
|
19
27
|
}
|
|
20
28
|
export interface EnumValue {
|
|
21
29
|
name: string;
|
|
@@ -28,13 +36,15 @@ export interface FieldNode {
|
|
|
28
36
|
label?: string;
|
|
29
37
|
type?: string;
|
|
30
38
|
params?: (string | number)[];
|
|
39
|
+
generic_params?: string[];
|
|
31
40
|
nullable: boolean;
|
|
32
41
|
array: boolean;
|
|
42
|
+
arrayItemNullable: boolean;
|
|
33
43
|
kind: FieldKind;
|
|
34
44
|
default_value?: string;
|
|
35
45
|
description?: string;
|
|
36
46
|
attributes: FieldAttribute[];
|
|
37
|
-
framework_attrs?:
|
|
47
|
+
framework_attrs?: CustomAttribute[];
|
|
38
48
|
lookup?: {
|
|
39
49
|
path: string;
|
|
40
50
|
};
|
|
@@ -47,6 +57,7 @@ export interface FieldNode {
|
|
|
47
57
|
};
|
|
48
58
|
computed?: {
|
|
49
59
|
expression: string;
|
|
60
|
+
platform?: string;
|
|
50
61
|
};
|
|
51
62
|
enum_values?: EnumValue[];
|
|
52
63
|
fields?: FieldNode[];
|
|
@@ -60,6 +71,7 @@ export interface ModelNode {
|
|
|
60
71
|
line: number;
|
|
61
72
|
inherits: string[];
|
|
62
73
|
description?: string;
|
|
74
|
+
attributes: FieldAttribute[];
|
|
63
75
|
fields: FieldNode[];
|
|
64
76
|
sections: {
|
|
65
77
|
indexes: unknown[];
|
|
@@ -117,6 +129,10 @@ export interface ParsedFile {
|
|
|
117
129
|
views: ModelNode[];
|
|
118
130
|
}
|
|
119
131
|
export interface M3LAST {
|
|
132
|
+
/** Parser package version (semver) */
|
|
133
|
+
parserVersion: string;
|
|
134
|
+
/** AST schema version — incremented on breaking AST structure changes */
|
|
135
|
+
astVersion: string;
|
|
120
136
|
project: ProjectInfo;
|
|
121
137
|
sources: string[];
|
|
122
138
|
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
|
-
//
|
|
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: '
|
|
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,
|