mod explain;
use clap::{Parser, Subcommand};
use drawlang_syntax::diag::{self, Styles};
use drawlang_syntax::{Severity, SourceFile};
use std::io::IsTerminal;
use std::path::PathBuf;
use std::process::ExitCode;
const EXIT_CLEAN: u8 = 0;
const EXIT_ERRORS: u8 = 2;
const GUIDE_HINT: &str = "hint: `drawlang cheatsheet` prints the full syntax reference; `drawlang explain <CODE>` details any code above";
#[derive(Parser)]
#[command(
name = "drawlang",
version,
about = "Precision diagrams as code, built for AI-agent authors",
after_help = "Learning the language:\n \
drawlang cheatsheet full syntax cheat sheet, sized for an LLM context window\n \
drawlang explain <CODE> what any error/lint code means and how to fix it\n\n\
Typical loop: edit -> `check` -> `render --report` -> fix lints -> repeat.",
propagate_version = true
)]
struct Cli {
#[command(subcommand)]
command: Command,
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true)]
no_color: bool,
}
#[derive(Subcommand)]
enum Command {
Check {
file: PathBuf,
},
Render {
file: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(long)]
report: bool,
#[arg(long, default_value_t = 2.0)]
scale: f32,
#[arg(long)]
theme: Option<String>,
},
Query {
file: PathBuf,
path: String,
},
Fmt {
file: PathBuf,
#[arg(long)]
check: bool,
},
Freeze { file: PathBuf },
Explain { code: String },
Cheatsheet,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let code = run(cli);
ExitCode::from(code)
}
fn styles(cli_no_color: bool) -> Styles {
let want_color =
!cli_no_color && std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal();
if want_color {
Styles::colored()
} else {
Styles::plain()
}
}
fn read_source(path: &PathBuf) -> Result<SourceFile, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read `{}`: {e}", path.display()))?;
Ok(SourceFile::new(path.display().to_string(), text))
}
fn run(cli: Cli) -> u8 {
match &cli.command {
Command::Check { file } => cmd_check(file, &cli),
Command::Render {
file,
out,
report,
scale,
theme,
} => cmd_render(file, out.as_ref(), *report, *scale, theme.as_deref(), &cli),
Command::Query { file, path } => cmd_query(file, path, &cli),
Command::Fmt { file, check } => cmd_fmt(file, *check, &cli),
Command::Freeze { file } => cmd_freeze(file, &cli),
Command::Explain { code } => explain::cmd_explain(code),
Command::Cheatsheet => {
print!("{}", include_str!("cheatsheet.md"));
EXIT_CLEAN
}
}
}
fn cmd_render(
file: &PathBuf,
out: Option<&PathBuf>,
report: bool,
scale: f32,
theme: Option<&str>,
cli: &Cli,
) -> u8 {
let src = match read_source(file) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERRORS;
}
};
let compiled = drawlang_core::compile(&src.text);
let mut doc = compiled.doc;
let mut diagnostics = compiled.diagnostics;
let has_compile_errors = diagnostics.iter().any(|d| d.severity == Severity::Error);
if !has_compile_errors {
apply_lock(file, &mut doc);
}
match theme {
Some("paper") => doc.canvas.theme = drawlang_core::model::Theme::Paper,
Some("dark") => doc.canvas.theme = drawlang_core::model::Theme::Dark,
Some(other) => {
eprintln!("error: unknown theme `{other}` (expected `paper` or `dark`)");
return EXIT_ERRORS;
}
None => {}
}
let layout = if has_compile_errors {
None
} else {
Some(drawlang_core::layout(&doc))
};
if let Some(l) = &layout {
diagnostics.extend(l.diagnostics.clone());
}
if cli.json {
let resolved: Vec<_> = diagnostics.iter().map(|d| diag::resolve(d, &src)).collect();
println!("{}", serde_json::to_string_pretty(&resolved).unwrap());
} else if !diagnostics.is_empty() {
eprint!(
"{}",
diag::render_all(&diagnostics, &src, &styles(cli.no_color))
);
}
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
if !cli.json {
eprintln!("{}", GUIDE_HINT);
}
return EXIT_ERRORS;
}
let layout = layout.expect("no errors, layout ran");
let svg = drawlang_render::render_svg(&doc, &layout.geometry);
let out_path = out.cloned().unwrap_or_else(|| file.with_extension("svg"));
let is_png = out_path.extension().and_then(|e| e.to_str()) == Some("png");
let bytes = if is_png {
match drawlang_render::render_png(&svg, scale) {
Ok(b) => b,
Err(e) => {
eprintln!("error: PNG rasterization failed: {e}");
return EXIT_ERRORS;
}
}
} else {
svg.clone().into_bytes()
};
if let Err(e) = std::fs::write(&out_path, &bytes) {
eprintln!("error: cannot write `{}`: {e}", out_path.display());
return EXIT_ERRORS;
}
if !cli.json {
eprintln!("wrote {}", out_path.display());
}
if report {
let rep = drawlang_core::report::report(&doc, &layout.geometry);
println!("{}", serde_json::to_string_pretty(&rep).unwrap());
}
EXIT_CLEAN
}
fn cmd_query(file: &PathBuf, path: &str, cli: &Cli) -> u8 {
let src = match read_source(file) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERRORS;
}
};
let compiled = drawlang_core::compile(&src.text);
if compiled
.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
{
eprint!(
"{}",
diag::render_all(&compiled.diagnostics, &src, &styles(cli.no_color))
);
return EXIT_ERRORS;
}
let layout = drawlang_core::layout(&compiled.doc);
let rep = drawlang_core::report::report(&compiled.doc, &layout.geometry);
if let Some(entry) = rep.elements.iter().find(|e| e.path == path) {
println!("{}", serde_json::to_string_pretty(entry).unwrap());
return EXIT_CLEAN;
}
if let Some((parent, port)) = path.rsplit_once('.') {
if let Some(entry) = rep.elements.iter().find(|e| e.path == parent) {
if let Some(pos) = entry.ports.get(port) {
println!(
"{}",
serde_json::json!({ "path": path, "port": port, "position": pos })
);
return EXIT_CLEAN;
}
}
}
let known: Vec<&str> = rep.elements.iter().map(|e| e.path.as_str()).collect();
let suggestion = drawlang_syntax::diag::suggest(path, known.iter().copied())
.map(|s| format!(" — did you mean `{s}`?"))
.unwrap_or_default();
eprintln!("error: no element `{path}` in `{}`{suggestion}", src.name);
eprintln!(
"hint: `drawlang render {} --report` lists every element path",
src.name
);
EXIT_ERRORS
}
fn cmd_fmt(file: &PathBuf, check_only: bool, cli: &Cli) -> u8 {
let src = match read_source(file) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERRORS;
}
};
let parsed = drawlang_syntax::parse_source(&src.text);
if parsed.has_errors() {
eprintln!(
"error: cannot format `{}` — fix syntax errors first:",
src.name
);
eprint!(
"{}",
diag::render_all(&parsed.diagnostics, &src, &styles(cli.no_color))
);
return EXIT_ERRORS;
}
let formatted = drawlang_syntax::fmt::format(&parsed.file, &src.text);
if check_only {
if formatted == src.text {
eprintln!("{}: already formatted", src.name);
EXIT_CLEAN
} else {
eprintln!("{}: would be reformatted", src.name);
1
}
} else if formatted == src.text {
eprintln!("{}: unchanged", src.name);
EXIT_CLEAN
} else if let Err(e) = std::fs::write(file, &formatted) {
eprintln!("error: cannot write `{}`: {e}", file.display());
EXIT_ERRORS
} else {
eprintln!("formatted {}", src.name);
EXIT_CLEAN
}
}
fn lock_path(file: &std::path::Path) -> PathBuf {
let mut p = file.to_path_buf();
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("drawl");
p.set_extension(format!("{ext}.lock"));
p
}
fn cmd_freeze(file: &PathBuf, cli: &Cli) -> u8 {
let src = match read_source(file) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERRORS;
}
};
let compiled = drawlang_core::compile(&src.text);
if compiled
.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
{
eprint!(
"{}",
diag::render_all(&compiled.diagnostics, &src, &styles(cli.no_color))
);
return EXIT_ERRORS;
}
let layout = drawlang_core::layout(&compiled.doc);
let geo = &layout.geometry;
let doc = &compiled.doc;
let mut min_x = f64::INFINITY;
let mut min_y = f64::INFINITY;
for &c in doc.children(doc.root) {
let r = geo.rect(c);
min_x = min_x.min(r.x);
min_y = min_y.min(r.y);
}
let mut pins = serde_json::Map::new();
for &c in doc.children(doc.root) {
let r = geo.rect(c);
pins.insert(
doc.el(c).path.clone(),
serde_json::json!([
((r.x - min_x) * 100.0).round() / 100.0,
((r.y - min_y) * 100.0).round() / 100.0
]),
);
}
let lock = serde_json::json!({ "version": 1, "pins": pins });
let out = lock_path(file);
if let Err(e) = std::fs::write(&out, serde_json::to_string_pretty(&lock).unwrap()) {
eprintln!("error: cannot write `{}`: {e}", out.display());
return EXIT_ERRORS;
}
eprintln!(
"froze {} top-level positions to {} (renders now reuse them; delete the file to re-layout)",
pins.len(),
out.display()
);
EXIT_CLEAN
}
fn apply_lock(file: &std::path::Path, doc: &mut drawlang_core::model::Document) {
let path = lock_path(file);
let Ok(text) = std::fs::read_to_string(&path) else {
return;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
eprintln!("warning: ignoring malformed lock file `{}`", path.display());
return;
};
let Some(pins) = value.get("pins").and_then(|p| p.as_object()) else {
return;
};
let mut applied = 0;
for (path_str, pos) in pins {
let (Some(x), Some(y)) = (
pos.get(0).and_then(|v| v.as_f64()),
pos.get(1).and_then(|v| v.as_f64()),
) else {
continue;
};
if let Some(id) = doc.lookup_path(path_str) {
if doc.el(id).parent == Some(doc.root) {
doc.pins.push(drawlang_core::model::PinDecl {
target: id,
x,
y,
span: drawlang_syntax::Span::DUMMY,
});
applied += 1;
}
}
}
if applied > 0 {
eprintln!(
"note: applied {applied} pinned positions from `{}`",
path.display()
);
}
}
fn cmd_check(file: &PathBuf, cli: &Cli) -> u8 {
let src = match read_source(file) {
Ok(s) => s,
Err(e) => {
eprintln!("error: {e}");
return EXIT_ERRORS;
}
};
let compiled = drawlang_core::compile(&src.text);
let mut diagnostics = compiled.diagnostics;
if !diagnostics.iter().any(|d| d.severity == Severity::Error) {
diagnostics.extend(drawlang_core::layout(&compiled.doc).diagnostics);
}
if cli.json {
let resolved: Vec<_> = diagnostics.iter().map(|d| diag::resolve(d, &src)).collect();
println!("{}", serde_json::to_string_pretty(&resolved).unwrap());
} else if !diagnostics.is_empty() {
eprint!(
"{}",
diag::render_all(&diagnostics, &src, &styles(cli.no_color))
);
}
let has_errors = diagnostics.iter().any(|d| d.severity == Severity::Error);
if has_errors {
if !cli.json {
eprintln!("{}", GUIDE_HINT);
}
EXIT_ERRORS
} else {
if !cli.json && diagnostics.is_empty() {
eprintln!("{}: no problems found", src.name);
}
EXIT_CLEAN
}
}