@iyulab/m3l 0.1.1 → 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/lexer.js +32 -24
- package/dist/parser.js +52 -12
- package/dist/resolver.d.ts +1 -1
- package/dist/resolver.js +4 -5
- package/dist/types.d.ts +2 -0
- 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/lexer.js
CHANGED
|
@@ -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
|
|
@@ -331,9 +324,24 @@ export function parseTypeAndAttrs(rest, data) {
|
|
|
331
324
|
skipWS();
|
|
332
325
|
}
|
|
333
326
|
}
|
|
334
|
-
// Parse attributes: @name or @name(balanced_args)
|
|
327
|
+
// Parse attributes: @name or @name(balanced_args), with optional cascade symbols (! !!)
|
|
335
328
|
const attrs = [];
|
|
336
|
-
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
|
+
}
|
|
337
345
|
pos++; // skip @
|
|
338
346
|
const nameStart = pos;
|
|
339
347
|
while (pos < len && /\w/.test(rest[pos]))
|
package/dist/parser.js
CHANGED
|
@@ -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
|
}
|
|
@@ -554,20 +549,61 @@ function buildFieldNode(data, token, state) {
|
|
|
554
549
|
const expr = computedAttr.args[0].replace(/^["']|["']$/g, '');
|
|
555
550
|
field.computed = { expression: expr };
|
|
556
551
|
}
|
|
557
|
-
// Parse computed_raw: @computed_raw("expression",
|
|
552
|
+
// Parse computed_raw: @computed_raw("expression", platform: "name")
|
|
558
553
|
if (computedRawAttr && computedRawAttr.args?.[0]) {
|
|
559
|
-
const
|
|
554
|
+
const rawArgs = computedRawAttr.args[0];
|
|
555
|
+
const parts = splitComputedRawArgs(rawArgs);
|
|
556
|
+
const expr = parts.expression.replace(/^["']|["']$/g, '');
|
|
560
557
|
field.computed = { expression: expr };
|
|
558
|
+
if (parts.platform) {
|
|
559
|
+
field.computed.platform = parts.platform;
|
|
560
|
+
}
|
|
561
561
|
}
|
|
562
562
|
return field;
|
|
563
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
|
+
}
|
|
564
596
|
function parseAttributes(rawAttrs) {
|
|
565
597
|
if (!rawAttrs)
|
|
566
598
|
return [];
|
|
567
|
-
return rawAttrs.map(a =>
|
|
568
|
-
name: a.name
|
|
569
|
-
|
|
570
|
-
|
|
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
|
+
});
|
|
571
607
|
}
|
|
572
608
|
function parseTypeParams(params) {
|
|
573
609
|
if (!params)
|
|
@@ -697,8 +733,12 @@ function parseMetadataValue(value) {
|
|
|
697
733
|
if (typeof value !== 'string')
|
|
698
734
|
return value;
|
|
699
735
|
const str = value;
|
|
700
|
-
//
|
|
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("'"));
|
|
701
739
|
const unquoted = str.replace(/^["']|["']$/g, '');
|
|
740
|
+
if (wasQuoted)
|
|
741
|
+
return unquoted;
|
|
702
742
|
// Try number
|
|
703
743
|
const n = Number(unquoted);
|
|
704
744
|
if (!isNaN(n) && unquoted !== '')
|
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.2";
|
|
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.2';
|
|
5
5
|
/**
|
|
6
6
|
* Resolve and merge multiple parsed file ASTs into a single M3LAST.
|
|
7
7
|
* Handles: inheritance resolution, duplicate detection, reference validation.
|
|
@@ -131,11 +131,10 @@ function resolveInheritance(model, modelMap, interfaceMap, allNamedMap, errors)
|
|
|
131
131
|
// Handle @override: child fields with @override replace inherited fields
|
|
132
132
|
const overrideNames = new Set();
|
|
133
133
|
for (const ownField of model.fields) {
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
134
|
+
const hasOverride = ownField.attributes.some(a => a.name === 'override');
|
|
135
|
+
if (hasOverride) {
|
|
136
136
|
overrideNames.add(ownField.name);
|
|
137
|
-
//
|
|
138
|
-
ownField.attributes.splice(overrideIdx, 1);
|
|
137
|
+
// Preserve @override in attributes so AST consumers can detect it
|
|
139
138
|
}
|
|
140
139
|
}
|
|
141
140
|
// Remove inherited fields that are overridden
|
package/dist/types.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ 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;
|
|
19
20
|
}
|
|
20
21
|
/** Structured representation of a backtick-wrapped framework attribute like `[MaxLength(100)]` */
|
|
21
22
|
export interface CustomAttribute {
|
|
@@ -56,6 +57,7 @@ export interface FieldNode {
|
|
|
56
57
|
};
|
|
57
58
|
computed?: {
|
|
58
59
|
expression: string;
|
|
60
|
+
platform?: string;
|
|
59
61
|
};
|
|
60
62
|
enum_values?: EnumValue[];
|
|
61
63
|
fields?: FieldNode[];
|