mod commands;
mod reader;
use std::path::{Path, PathBuf};
use std::process;
use clap::{Parser, Subcommand};
use m3l_core::{parse_string, resolve, validate, ProjectInfo, ValidateOptions};
use reader::{read_m3l_files, read_project_config};
#[derive(Parser)]
#[command(
name = "m3l",
version,
about = "M3L parser and validator — parse .m3l, .m3l.md, .md files into JSON AST"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Parse {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
Analyze {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long, default_value = "mermaid")]
format: String,
},
Diff {
left: PathBuf,
right: PathBuf,
},
Format {
#[arg(default_value = ".")]
path: PathBuf,
},
Lint {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long, default_value = "human")]
format: String,
},
Validate {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
strict: bool,
#[arg(long, default_value = "human")]
format: String,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Parse { path, output } => match run_parse(&path, output.as_deref()) {
Ok(json) => {
if output.is_none() {
println!("{json}");
}
}
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
},
Commands::Analyze { path, format } => {
match commands::analyze::run_analyze(&path, &format) {
Ok(output) => {
println!("{output}");
}
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
}
}
Commands::Diff { left, right } => match run_diff(&left, &right) {
Ok(output) => {
println!("{output}");
}
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
},
Commands::Format { path } => match commands::format::run_format(&path) {
Ok(output) => {
println!("{output}");
}
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
},
Commands::Lint { path, format } => match commands::lint::run_lint(&path, &format) {
Ok(output) => {
println!("{output}");
}
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
},
Commands::Validate {
path,
strict,
format,
} => match run_validate(&path, strict, &format) {
Ok((output, error_count)) => {
println!("{output}");
if error_count > 0 {
process::exit(1);
}
}
Err(e) => {
eprintln!("Error: {e}");
process::exit(1);
}
},
}
}
pub fn build_ast(input_path: &Path) -> Result<m3l_core::M3lAst, String> {
let files = read_m3l_files(input_path)?;
if files.is_empty() {
return Err(format!(
"No M3L files (.m3l, .m3l.md, .md) found at: {}",
input_path.display()
));
}
let parsed_files: Vec<_> = files
.iter()
.map(|f| parse_string(&f.content, &f.path))
.collect();
let project_info = if input_path.is_dir() {
read_project_config(input_path).map(|c| ProjectInfo {
name: c.name,
version: c.version,
})
} else {
None
};
let ast = resolve(&parsed_files, project_info);
Ok(ast)
}
fn run_parse(input_path: &Path, output_file: Option<&Path>) -> Result<String, String> {
let ast = build_ast(input_path)?;
let json =
serde_json::to_string_pretty(&ast).map_err(|e| format!("JSON serialization error: {e}"))?;
if let Some(out_path) = output_file {
std::fs::write(out_path, &json)
.map_err(|e| format!("Failed to write {}: {e}", out_path.display()))?;
return Ok(format!("Written to {}", out_path.display()));
}
Ok(json)
}
fn run_diff(left_path: &Path, right_path: &Path) -> Result<String, String> {
let left_ast = build_ast(left_path)?;
let right_ast = build_ast(right_path)?;
let mut lines: Vec<String> = Vec::new();
let left_models: std::collections::HashMap<&str, &m3l_core::ModelNode> = left_ast
.models
.iter()
.chain(left_ast.views.iter())
.map(|m| (m.name.as_str(), m))
.collect();
let right_models: std::collections::HashMap<&str, &m3l_core::ModelNode> = right_ast
.models
.iter()
.chain(right_ast.views.iter())
.map(|m| (m.name.as_str(), m))
.collect();
for name in right_models.keys() {
if !left_models.contains_key(name) {
lines.push(format!("+ model {name}"));
}
}
for name in left_models.keys() {
if !right_models.contains_key(name) {
lines.push(format!("- model {name}"));
}
}
for (name, left_model) in &left_models {
if let Some(right_model) = right_models.get(name) {
let left_fields: std::collections::HashMap<&str, &m3l_core::FieldNode> = left_model
.fields
.iter()
.map(|f| (f.name.as_str(), f))
.collect();
let right_fields: std::collections::HashMap<&str, &m3l_core::FieldNode> = right_model
.fields
.iter()
.map(|f| (f.name.as_str(), f))
.collect();
for fname in right_fields.keys() {
if !left_fields.contains_key(fname) {
lines.push(format!("+ {name}.{fname}"));
}
}
for fname in left_fields.keys() {
if !right_fields.contains_key(fname) {
lines.push(format!("- {name}.{fname}"));
}
}
for (fname, lf) in &left_fields {
if let Some(rf) = right_fields.get(fname) {
let mut changes = Vec::new();
if lf.field_type != rf.field_type {
changes.push(format!(
"type: {} → {}",
lf.field_type.as_deref().unwrap_or("none"),
rf.field_type.as_deref().unwrap_or("none")
));
}
if lf.nullable != rf.nullable {
changes.push(format!("nullable: {} → {}", lf.nullable, rf.nullable));
}
if lf.array != rf.array {
changes.push(format!("array: {} → {}", lf.array, rf.array));
}
if !changes.is_empty() {
lines.push(format!("~ {name}.{fname}: {}", changes.join(", ")));
}
}
}
}
}
let left_enums: std::collections::HashMap<&str, &m3l_core::EnumNode> = left_ast
.enums
.iter()
.map(|e| (e.name.as_str(), e))
.collect();
let right_enums: std::collections::HashMap<&str, &m3l_core::EnumNode> = right_ast
.enums
.iter()
.map(|e| (e.name.as_str(), e))
.collect();
for name in right_enums.keys() {
if !left_enums.contains_key(name) {
lines.push(format!("+ enum {name}"));
}
}
for name in left_enums.keys() {
if !right_enums.contains_key(name) {
lines.push(format!("- enum {name}"));
}
}
if lines.is_empty() {
lines.push("No differences found.".into());
} else {
lines.sort();
let add_count = lines.iter().filter(|l| l.starts_with('+')).count();
let rem_count = lines.iter().filter(|l| l.starts_with('-')).count();
let mod_count = lines.iter().filter(|l| l.starts_with('~')).count();
lines.push(format!(
"\n{} added, {} removed, {} modified",
add_count, rem_count, mod_count
));
}
Ok(lines.join("\n"))
}
fn run_validate(input_path: &Path, strict: bool, format: &str) -> Result<(String, usize), String> {
let files = read_m3l_files(input_path)?;
if files.is_empty() {
return Err(format!(
"No M3L files (.m3l, .m3l.md, .md) found at: {}",
input_path.display()
));
}
let parsed_files: Vec<_> = files
.iter()
.map(|f| parse_string(&f.content, &f.path))
.collect();
let project_info = if input_path.is_dir() {
read_project_config(input_path).map(|c| ProjectInfo {
name: c.name,
version: c.version,
})
} else {
None
};
let ast = resolve(&parsed_files, project_info);
let result = validate(&ast, &ValidateOptions { strict });
let error_count = result.errors.len();
let warning_count = result.warnings.len();
let file_count = ast.sources.len();
if format == "json" {
let diagnostics: Vec<&m3l_core::Diagnostic> =
result.errors.iter().chain(result.warnings.iter()).collect();
let output = serde_json::json!({
"diagnostics": diagnostics,
"summary": {
"errors": error_count,
"warnings": warning_count,
"files": file_count,
}
});
let json = serde_json::to_string_pretty(&output)
.map_err(|e| format!("JSON serialization error: {e}"))?;
return Ok((json, error_count));
}
let mut lines: Vec<String> = Vec::new();
for d in result.errors.iter().chain(result.warnings.iter()) {
let severity = match d.severity {
m3l_core::DiagnosticSeverity::Error => "error",
m3l_core::DiagnosticSeverity::Warning => "warning",
};
lines.push(format!(
"{}:{}:{} {}[{}]: {}",
d.file, d.line, d.col, severity, d.code, d.message
));
}
let error_word = if error_count == 1 { "error" } else { "errors" };
let warning_word = if warning_count == 1 {
"warning"
} else {
"warnings"
};
let file_word = if file_count == 1 { "file" } else { "files" };
lines.push(format!(
"{error_count} {error_word}, {warning_count} {warning_word} in {file_count} {file_word}."
));
Ok((lines.join("\n"), error_count))
}