@iyulab/m3l 0.1.0

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 ADDED
@@ -0,0 +1,202 @@
1
+ # @iyulab/m3l
2
+
3
+ M3L (Meta Model Markup Language) parser and CLI — parse `.m3l.md` files into a structured JSON AST.
4
+
5
+ M3L is a Markdown-based data modeling language. You write data models in readable Markdown, and this parser converts them into a machine-processable AST with full validation.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @iyulab/m3l
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### As a Library
16
+
17
+ ```typescript
18
+ import { parse, parseString, validateFiles } from '@iyulab/m3l';
19
+
20
+ // Parse a file or directory
21
+ const ast = await parse('./models');
22
+
23
+ // Parse a string
24
+ const ast2 = parseString(`
25
+ ## User
26
+ - name: string(100) @not_null
27
+ - email: string(320)? @unique
28
+
29
+ ## UserRole ::enum
30
+ - admin: "Administrator"
31
+ - user: "Regular User"
32
+ `);
33
+
34
+ console.log(ast2.models); // [{ name: 'User', fields: [...], ... }]
35
+ console.log(ast2.enums); // [{ name: 'UserRole', values: [...], ... }]
36
+
37
+ // Validate with diagnostics
38
+ const { ast: validated, errors, warnings } = await validateFiles('./models');
39
+ ```
40
+
41
+ ### As a CLI
42
+
43
+ ```bash
44
+ # Parse and output JSON AST
45
+ npx m3l parse ./models
46
+
47
+ # Parse a single file
48
+ npx m3l parse ./models/user.m3l.md -o ast.json
49
+
50
+ # Validate
51
+ npx m3l validate ./models
52
+
53
+ # Validate with strict style checks and JSON output
54
+ npx m3l validate ./models --strict --format json
55
+ ```
56
+
57
+ ## M3L Syntax
58
+
59
+ ```markdown
60
+ # Library System
61
+
62
+ ## Author
63
+ - name(Author Name): string(100) @not_null @idx
64
+ - bio(Biography): text?
65
+ - birth_date: date?
66
+
67
+ # Rollup
68
+ - book_count: integer @rollup(BookAuthor.author_id, count)
69
+
70
+ > Stores information about book authors.
71
+
72
+ ## BookStatus ::enum "Status of a book"
73
+ - available: "Available"
74
+ - borrowed: "Borrowed"
75
+ - reserved: "Reserved"
76
+
77
+ ## Book : BaseModel
78
+ - title: string(200) @not_null @idx
79
+ - isbn: string(20) @unique
80
+ - status: enum = "available"
81
+ - available: "Available"
82
+ - borrowed: "Borrowed"
83
+ - publisher_id: identifier @fk(Publisher.id)
84
+
85
+ # Lookup
86
+ - publisher_name: string @lookup(publisher_id.name)
87
+
88
+ # Computed
89
+ - is_available: boolean @computed("status = 'available' AND quantity > 0")
90
+
91
+ ## OverdueLoans ::view @materialized
92
+ > Currently overdue book loans.
93
+ ### Source
94
+ - from: Loan
95
+ - where: "due_date < now() AND status = 'ongoing'"
96
+ - order_by: due_date asc
97
+ - borrower_name: string @lookup(member_id.name)
98
+ ```
99
+
100
+ **Key syntax elements:**
101
+
102
+ | Syntax | Meaning |
103
+ |--------|---------|
104
+ | `# Title` | Document title or namespace (`# Namespace: domain.example`) |
105
+ | `## Name` | Model definition |
106
+ | `## Name : Parent` | Model with inheritance |
107
+ | `## Name ::enum` | Enum definition |
108
+ | `## Name ::interface` | Interface definition |
109
+ | `## Name ::view` | Derived view |
110
+ | `- field: type` | Field definition |
111
+ | `- field: type?` | Nullable field |
112
+ | `- field: type[]` | Array field |
113
+ | `- field: type = val` | Field with default value |
114
+ | `@attr` / `@attr(args)` | Attribute (constraint, index, etc.) |
115
+ | `# Lookup` / `# Rollup` / `# Computed` | Kind section for derived fields |
116
+ | `### Section` | Named section (Indexes, Relations, Metadata, etc.) |
117
+ | `> text` | Model/element description |
118
+ | `"text"` | Inline description on field |
119
+
120
+ ## AST Output
121
+
122
+ The parser produces an `M3LAST` object:
123
+
124
+ ```typescript
125
+ interface M3LAST {
126
+ 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
134
+ }
135
+ ```
136
+
137
+ Each `ModelNode` contains fields, sections (indexes, relations, metadata), inheritance info, and source locations for error reporting.
138
+
139
+ ## Validation
140
+
141
+ The validator checks for semantic errors and style warnings:
142
+
143
+ **Errors:**
144
+ | Code | Description |
145
+ |------|-------------|
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 |
152
+
153
+ **Warnings (--strict):**
154
+ | Code | Description |
155
+ |------|-------------|
156
+ | W001 | Model has no fields |
157
+ | W002 | Model has no description |
158
+ | W003 | Field missing label |
159
+ | W004 | Enum has no values |
160
+
161
+ ## Multi-file Projects
162
+
163
+ Place `.m3l.md` or `.m3l` files in a directory and parse the directory path. The resolver automatically merges all files, resolves inheritance, and detects cross-file references.
164
+
165
+ Optionally, create an `m3l.config.yaml`:
166
+
167
+ ```yaml
168
+ name: my-project
169
+ version: 1.0.0
170
+ sources:
171
+ - "models/**/*.m3l.md"
172
+ - "shared/*.m3l"
173
+ ```
174
+
175
+ ## API Reference
176
+
177
+ ### `parse(inputPath: string): Promise<M3LAST>`
178
+
179
+ Parse M3L files from a file or directory into a merged AST.
180
+
181
+ ### `parseString(content: string, filename?: string): M3LAST`
182
+
183
+ Parse an M3L content string into an AST.
184
+
185
+ ### `validateFiles(inputPath: string, options?): Promise<{ ast, errors, warnings }>`
186
+
187
+ Parse and validate M3L files. Options: `{ strict?: boolean }`.
188
+
189
+ ### Lower-level API
190
+
191
+ ```typescript
192
+ import { lex, parseTokens, resolve, validate } from '@iyulab/m3l';
193
+
194
+ const tokens = lex(content, 'file.m3l.md');
195
+ const parsed = parseTokens(tokens, 'file.m3l.md');
196
+ const ast = resolve([parsed]);
197
+ const diagnostics = validate(ast, { strict: true });
198
+ ```
199
+
200
+ ## License
201
+
202
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import type { ValidateOptions } from './types.js';
3
+ /**
4
+ * Run parse command — also used for testing.
5
+ */
6
+ export declare function runParse(inputPath: string, outputFile?: string): Promise<string>;
7
+ /**
8
+ * Run validate command — also used for testing.
9
+ */
10
+ export declare function runValidate(inputPath: string, options?: ValidateOptions, format?: string): Promise<string>;
11
+ /**
12
+ * Run CLI with args — for testing.
13
+ */
14
+ export declare function runCLI(args: string[]): Promise<string>;
package/dist/cli.js ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { writeFileSync } from 'fs';
4
+ import { resolve as resolvePath } from 'path';
5
+ import { readM3LFiles, readProjectConfig } from './reader.js';
6
+ import { parseString as parseFileContent } from './parser.js';
7
+ import { resolve } from './resolver.js';
8
+ import { validate } from './validator.js';
9
+ const program = new Command()
10
+ .name('m3l')
11
+ .description('M3L parser and validator — parse .m3l.md files into JSON AST')
12
+ .version('0.1.0');
13
+ program
14
+ .command('parse [path]')
15
+ .description('Parse M3L files and output JSON AST')
16
+ .option('-o, --output <file>', 'Write output to file instead of stdout')
17
+ .action(async (inputPath, options) => {
18
+ try {
19
+ const output = await runParse(inputPath || '.', options?.output);
20
+ if (!options?.output) {
21
+ process.stdout.write(output + '\n');
22
+ }
23
+ }
24
+ catch (err) {
25
+ process.stderr.write(`Error: ${err.message}\n`);
26
+ process.exit(1);
27
+ }
28
+ });
29
+ program
30
+ .command('validate [path]')
31
+ .description('Validate M3L files')
32
+ .option('--strict', 'Enable strict style guidelines')
33
+ .option('--format <format>', 'Output format: human (default) or json', 'human')
34
+ .action(async (inputPath, options) => {
35
+ try {
36
+ const output = await runValidate(inputPath || '.', { strict: options?.strict }, options?.format || 'human');
37
+ process.stdout.write(output + '\n');
38
+ // Exit with error code if there are errors
39
+ if (output.includes('"errors":') || output.match(/\d+ error/)) {
40
+ const errorCount = extractErrorCount(output, options?.format || 'human');
41
+ if (errorCount > 0)
42
+ process.exit(1);
43
+ }
44
+ }
45
+ catch (err) {
46
+ process.stderr.write(`Error: ${err.message}\n`);
47
+ process.exit(1);
48
+ }
49
+ });
50
+ // Entry point for direct execution
51
+ const args = process.argv.slice(2);
52
+ if (args.length > 0) {
53
+ program.parse();
54
+ }
55
+ /**
56
+ * Run parse command — also used for testing.
57
+ */
58
+ export async function runParse(inputPath, outputFile) {
59
+ const ast = await buildAST(inputPath);
60
+ const json = JSON.stringify(ast, null, 2);
61
+ if (outputFile) {
62
+ writeFileSync(resolvePath(outputFile), json, 'utf-8');
63
+ return `Written to ${outputFile}`;
64
+ }
65
+ return json;
66
+ }
67
+ /**
68
+ * Run validate command — also used for testing.
69
+ */
70
+ export async function runValidate(inputPath, options = {}, format = 'human') {
71
+ const ast = await buildAST(inputPath);
72
+ const result = validate(ast, options);
73
+ if (format === 'json') {
74
+ return JSON.stringify({
75
+ diagnostics: [...result.errors, ...result.warnings],
76
+ summary: {
77
+ errors: result.errors.length,
78
+ warnings: result.warnings.length,
79
+ files: ast.sources.length,
80
+ },
81
+ }, null, 2);
82
+ }
83
+ // Human-readable format
84
+ return formatHumanDiagnostics(result.errors, result.warnings, ast.sources.length);
85
+ }
86
+ /**
87
+ * Run CLI with args — for testing.
88
+ */
89
+ export async function runCLI(args) {
90
+ const cmd = args[0];
91
+ const rest = args.slice(1);
92
+ if (cmd === 'parse') {
93
+ const inputPath = rest.find(a => !a.startsWith('-')) || '.';
94
+ const outputIdx = rest.indexOf('-o');
95
+ const outputFile = outputIdx >= 0 ? rest[outputIdx + 1] : undefined;
96
+ const outputIdx2 = rest.indexOf('--output');
97
+ const outputFile2 = outputIdx2 >= 0 ? rest[outputIdx2 + 1] : undefined;
98
+ return runParse(inputPath, outputFile || outputFile2);
99
+ }
100
+ if (cmd === 'validate') {
101
+ const inputPath = rest.find(a => !a.startsWith('-')) || '.';
102
+ const strict = rest.includes('--strict');
103
+ const formatIdx = rest.indexOf('--format');
104
+ const format = formatIdx >= 0 ? rest[formatIdx + 1] : 'human';
105
+ return runValidate(inputPath, { strict }, format);
106
+ }
107
+ throw new Error(`Unknown command: ${cmd}`);
108
+ }
109
+ // --- Internal ---
110
+ async function buildAST(inputPath) {
111
+ const resolved = resolvePath(inputPath);
112
+ const files = await readM3LFiles(resolved);
113
+ if (files.length === 0) {
114
+ throw new Error(`No .m3l.md files found at: ${inputPath}`);
115
+ }
116
+ const parsedFiles = files.map(f => parseFileContent(f.content, f.path));
117
+ // Try to read project config
118
+ let projectInfo;
119
+ try {
120
+ const config = await readProjectConfig(resolved);
121
+ if (config)
122
+ projectInfo = config;
123
+ }
124
+ catch {
125
+ // No config — that's fine
126
+ }
127
+ return resolve(parsedFiles, projectInfo || undefined);
128
+ }
129
+ function formatHumanDiagnostics(errors, warnings, fileCount) {
130
+ const lines = [];
131
+ for (const d of [...errors, ...warnings]) {
132
+ const severity = d.severity === 'error' ? 'error' : 'warning';
133
+ lines.push(`${d.file}:${d.line}:${d.col} ${severity}[${d.code}]: ${d.message}`);
134
+ }
135
+ lines.push(`${errors.length} error${errors.length !== 1 ? 's' : ''}, ${warnings.length} warning${warnings.length !== 1 ? 's' : ''} in ${fileCount} file${fileCount !== 1 ? 's' : ''}.`);
136
+ return lines.join('\n');
137
+ }
138
+ function extractErrorCount(output, format) {
139
+ if (format === 'json') {
140
+ try {
141
+ const parsed = JSON.parse(output);
142
+ return parsed.summary?.errors || 0;
143
+ }
144
+ catch {
145
+ return 0;
146
+ }
147
+ }
148
+ const match = output.match(/(\d+) error/);
149
+ return match ? parseInt(match[1], 10) : 0;
150
+ }
@@ -0,0 +1,23 @@
1
+ export { lex } from './lexer.js';
2
+ export { parseTokens, parseString as parseFileString } from './parser.js';
3
+ export { resolve } from './resolver.js';
4
+ export { validate } from './validator.js';
5
+ export { readM3LFiles, readM3LString, readProjectConfig } from './reader.js';
6
+ export type * from './types.js';
7
+ import type { M3LAST, ValidateOptions } from './types.js';
8
+ /**
9
+ * High-level API: Parse M3L files from a path (file or directory) into a merged AST.
10
+ */
11
+ export declare function parse(inputPath: string): Promise<M3LAST>;
12
+ /**
13
+ * High-level API: Parse M3L content string into AST.
14
+ */
15
+ export declare function parseString(content: string, filename?: string): M3LAST;
16
+ /**
17
+ * High-level API: Validate M3L files from a path.
18
+ */
19
+ export declare function validateFiles(inputPath: string, options?: ValidateOptions): Promise<{
20
+ ast: M3LAST;
21
+ errors: import('./types.js').Diagnostic[];
22
+ warnings: import('./types.js').Diagnostic[];
23
+ }>;
package/dist/index.js ADDED
@@ -0,0 +1,46 @@
1
+ export { lex } from './lexer.js';
2
+ export { parseTokens, parseString as parseFileString } from './parser.js';
3
+ export { resolve } from './resolver.js';
4
+ export { validate } from './validator.js';
5
+ export { readM3LFiles, readM3LString, readProjectConfig } from './reader.js';
6
+ import { readM3LFiles, readProjectConfig } from './reader.js';
7
+ import { parseString as parseFileContent } from './parser.js';
8
+ import { resolve } from './resolver.js';
9
+ import { validate as validateAST } from './validator.js';
10
+ import { resolve as resolvePath } from 'path';
11
+ /**
12
+ * High-level API: Parse M3L files from a path (file or directory) into a merged AST.
13
+ */
14
+ export async function parse(inputPath) {
15
+ const resolved = resolvePath(inputPath);
16
+ const files = await readM3LFiles(resolved);
17
+ if (files.length === 0) {
18
+ throw new Error(`No .m3l.md files found at: ${inputPath}`);
19
+ }
20
+ const parsedFiles = files.map(f => parseFileContent(f.content, f.path));
21
+ let projectInfo;
22
+ try {
23
+ const config = await readProjectConfig(resolved);
24
+ if (config)
25
+ projectInfo = config;
26
+ }
27
+ catch {
28
+ // No config
29
+ }
30
+ return resolve(parsedFiles, projectInfo || undefined);
31
+ }
32
+ /**
33
+ * High-level API: Parse M3L content string into AST.
34
+ */
35
+ export function parseString(content, filename = 'inline.m3l.md') {
36
+ const parsed = parseFileContent(content, filename);
37
+ return resolve([parsed]);
38
+ }
39
+ /**
40
+ * High-level API: Validate M3L files from a path.
41
+ */
42
+ export async function validateFiles(inputPath, options) {
43
+ const ast = await parse(inputPath);
44
+ const result = validateAST(ast, options);
45
+ return { ast, ...result };
46
+ }
@@ -0,0 +1,5 @@
1
+ import type { Token } from './types.js';
2
+ /**
3
+ * Tokenize M3L markdown content into a sequence of tokens.
4
+ */
5
+ export declare function lex(content: string, file: string): Token[];