use clap::{Parser, Subcommand};
use mimispec::format::format_diagnostic;
use mimispec::latex::render_file_latex;
use mimispec::parse;
use mimispec::resolver::Resolver;
use mimispec::symbol::SymbolTable;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Parser, Debug)]
#[command(name = "mimispec", version, about = "MimiSpec parser CLI", args_conflicts_with_subcommands = true)]
struct Cli {
#[arg(default_value = "-")]
files: Vec<PathBuf>,
#[arg(short, long, global = true)]
ast: bool,
#[arg(short, long, global = true)]
json: bool,
#[arg(short, long, global = true)]
render: bool,
#[arg(short, long, global = true)]
latex: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
Parse {
#[arg(default_value = "-")]
files: Vec<PathBuf>,
},
Build {
dir: PathBuf,
},
}
#[derive(Serialize)]
struct JsonError {
code: String,
line: usize,
col: usize,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
help: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
suggestion: Option<String>,
}
#[derive(Serialize)]
struct JsonResult {
path: String,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
ast: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
render: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
latex: Option<String>,
errors: Vec<JsonError>,
}
#[derive(Serialize)]
struct JsonOutput {
results: Vec<JsonResult>,
}
fn read_source(path: &Path) -> Option<String> {
if path == Path::new("-") {
use std::io::Read;
let mut input = String::new();
std::io::stdin().read_to_string(&mut input).ok()?;
Some(input)
} else {
fs::read_to_string(path).ok()
}
}
fn build_json_result(
path: &Path,
result: &mimispec::error::ParseResult,
ast: bool,
render: bool,
latex: bool,
) -> JsonResult {
let ast_value = if ast {
serde_json::to_value(&result.file).ok()
} else {
None
};
let rendered = if render {
Some(mimispec::render::render_file(&result.file))
} else {
None
};
let latex_rendered = if latex {
Some(render_file_latex(&result.file))
} else {
None
};
let errors: Vec<JsonError> = result
.errors
.iter()
.map(|err| JsonError {
code: err.code.to_string(),
line: err.line,
col: err.col,
message: err.message.clone(),
help: err.help.clone(),
suggestion: err.suggestion.clone(),
})
.collect();
JsonResult {
path: path.display().to_string(),
success: result.errors.is_empty(),
ast: ast_value,
render: rendered,
latex: latex_rendered,
errors,
}
}
fn print_cli_output(
path: &Path,
result: &mimispec::error::ParseResult,
json_result: &JsonResult,
source: &str,
) {
if result.errors.is_empty() {
if json_result.render.is_some()
&& json_result.ast.is_none()
&& json_result.latex.is_none()
{
if let Some(source) = &json_result.render {
print!("{}", source);
}
} else if json_result.latex.is_some()
&& json_result.ast.is_none()
&& json_result.render.is_none()
{
if let Some(source) = &json_result.latex {
println!("{}", source);
}
} else {
println!("✓ Parsing successful: {}", path.display());
if json_result.ast.is_some() {
println!("{:#?}", result.file);
}
if let Some(source) = &json_result.render {
println!("{}", source);
}
if let Some(source) = &json_result.latex {
println!("LaTeX:\n{}", source);
}
}
} else {
eprintln!(
"✗ Parsing failed for {} with {} error(s)",
path.display(),
result.errors.len()
);
for err in &result.errors {
eprintln!("{}", format_diagnostic(err, source));
}
}
}
fn parse_one(
path: &Path,
ast: bool,
json: bool,
render: bool,
latex: bool,
) -> (bool, usize, JsonResult) {
let source = match read_source(path) {
Some(s) => s,
None => {
let message = format!("Failed to read {}", path.display());
if !json {
eprintln!("✗ {}", message);
}
let json_result = JsonResult {
path: path.display().to_string(),
success: false,
ast: None,
render: None,
latex: None,
errors: vec![JsonError {
code: "E0000".into(),
line: 0,
col: 0,
message,
help: None,
suggestion: None,
}],
};
return (false, 1, json_result);
}
};
let result = parse(&source);
let json_result =
build_json_result(path, &result, ast || json, render || json, latex || json);
if !json {
print_cli_output(path, &result, &json_result, &source);
}
(json_result.success, result.errors.len(), json_result)
}
fn run_parse(
files: &[PathBuf],
ast: bool,
json: bool,
render: bool,
latex: bool,
) {
let mut total_errors = 0usize;
let mut any_failure = false;
let mut json_results = Vec::new();
for path in files {
let (ok, errs, json_result) =
parse_one(path, ast, json, render, latex);
if !ok {
any_failure = true;
}
total_errors += errs;
json_results.push(json_result);
}
if json {
let output = JsonOutput {
results: json_results,
};
println!(
"{}",
serde_json::to_string_pretty(&output).unwrap_or_else(|e| {
format!("{{\"error\": \"JSON serialization failed: {}\"}}", e)
})
);
}
if any_failure {
if !json {
eprintln!("\nTotal error(s): {}", total_errors);
}
std::process::exit(1);
}
}
fn run_build(dir: &Path) {
let mut total_errors = 0usize;
let mut any_failure = false;
let mms_files = match find_mms_files(dir) {
Ok(files) => files,
Err(e) => {
eprintln!("Error scanning directory: {}", e);
std::process::exit(1);
}
};
if mms_files.is_empty() {
eprintln!("No .mms files found in {}", dir.display());
std::process::exit(0);
}
let mut resolver = Resolver::new(dir.to_path_buf());
for path in &mms_files {
resolver.resolve(path);
}
let resolve_errors = resolver.take_errors();
let files = resolver.take_files();
for (_path, _err) in &resolve_errors {
any_failure = true;
total_errors += 1;
}
let symbols = SymbolTable::build(&files);
if symbols.has_conflicts() {
println!("\n⚠ Name conflicts detected:");
for conflict in symbols.conflicts() {
println!(
" '{}' defined in {} locations:",
conflict.name,
conflict.entries.len()
);
for entry in &conflict.entries {
println!(" - {} ({})", entry.file.display(), entry.kind);
}
}
any_failure = true;
}
let success_count = mms_files.len() - resolve_errors.len();
println!(
"✓ Build complete: {} file(s) parsed, {} error(s)",
success_count, total_errors
);
if !symbols.all_names().is_empty() {
println!("\nDefined names ({}):", symbols.all_names().len());
for name in symbols.all_names() {
let entries = symbols.lookup(name);
let kinds: Vec<String> = entries.iter().map(|e| e.kind.to_string()).collect();
println!(" {} [{}]", name, kinds.join(", "));
}
}
if any_failure {
println!("\nResolve errors:");
for (path, err) in &resolve_errors {
println!(" {}: {}", path.display(), err);
}
std::process::exit(1);
}
}
fn find_mms_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
let mut files = Vec::new();
let mut visited = std::collections::HashSet::new();
if !dir.is_dir() {
return Err(format!("{} is not a directory", dir.display()));
}
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
let canonical = match current.canonicalize() {
Ok(p) => p,
Err(_) => continue,
};
if !visited.insert(canonical) {
continue;
}
let entries = match fs::read_dir(¤t) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if path.is_dir() {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.starts_with('.') && name != "target" && name != "node_modules" {
stack.push(path);
}
} else if path.extension().and_then(|e| e.to_str()) == Some("mms") {
files.push(path);
}
}
}
files.sort();
Ok(files)
}
fn main() {
let cli = Cli::parse();
match &cli.command {
Some(Commands::Parse { files }) => {
run_parse(files, cli.ast, cli.json, cli.render, cli.latex);
}
Some(Commands::Build { dir }) => {
run_build(dir);
}
None => {
run_parse(&cli.files, cli.ast, cli.json, cli.render, cli.latex);
}
}
}