use clap::{Parser, error::ErrorKind};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::{Duration, Instant};
#[derive(Parser)]
#[command(
name = "lini",
version,
about = "Compile Lini diagrams to SVG",
long_about = None,
disable_help_flag = false,
)]
struct Cli {
input: String,
#[arg(short = 'o', long = "output")]
output: Option<PathBuf>,
#[arg(long = "format", default_value = "svg")]
format: String,
#[arg(long = "standalone")]
standalone: bool,
#[arg(long = "bake-vars")]
bake_vars: bool,
#[arg(long = "check")]
check: bool,
#[arg(long = "theme", value_name = "FILE")]
theme: Option<PathBuf>,
#[arg(long = "watch", requires = "output")]
watch: bool,
#[arg(long = "no-warn", conflicts_with = "strict")]
no_warn: bool,
#[arg(long = "strict")]
strict: bool,
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
if args.len() >= 2 && args[1] == "fmt" {
return run_fmt(&args[2..]);
}
if args.len() >= 2 && args[1] == "serve" {
return run_serve(&args[2..]);
}
let cli = match Cli::try_parse() {
Ok(c) => c,
Err(e) => {
let _ = e.print();
return match e.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => ExitCode::SUCCESS,
_ => ExitCode::from(3),
};
}
};
let format = match cli.format.as_str() {
"svg" => lini::OutputFormat::Svg,
"html" => lini::OutputFormat::Html,
other => {
eprintln!("error: invalid --format '{}' (expected svg|html)", other);
return ExitCode::from(3);
}
};
let _ = cli.standalone;
if cli.watch {
let out_path = cli.output.clone().expect("clap enforces -o with --watch");
if cli.input == "-" {
eprintln!("error: --watch cannot read from stdin");
return ExitCode::from(3);
}
let theme_css = match &cli.theme {
Some(path) => match std::fs::read_to_string(path) {
Ok(s) => Some(s),
Err(e) => {
eprintln!("error: {}: {}", path.display(), e);
return ExitCode::from(2);
}
},
None => None,
};
let opts = lini::Options {
bake_vars: cli.bake_vars,
format,
theme_css,
};
return watch_loop(Path::new(&cli.input), &out_path, &opts, cli.check);
}
let (filename, source) = match cli.input.as_str() {
"-" => {
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("error: failed to read stdin: {}", e);
return ExitCode::from(2);
}
("<stdin>".to_string(), buf)
}
path => match std::fs::read_to_string(path) {
Ok(s) => (path.to_string(), s),
Err(e) => {
eprintln!("error: {}: {}", path, e);
return ExitCode::from(2);
}
},
};
let theme_css = match &cli.theme {
Some(path) => match std::fs::read_to_string(path) {
Ok(s) => Some(s),
Err(e) => {
eprintln!("error: {}: {}", path.display(), e);
return ExitCode::from(2);
}
},
None => None,
};
let opts = lini::Options {
bake_vars: cli.bake_vars,
format,
theme_css,
};
if cli.check {
return match lini::check_with(&source, &opts) {
Ok(_) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("{}", e.display_with_source(&source, &filename));
ExitCode::from(1)
}
};
}
let mut warnings_were_emitted = false;
if !cli.no_warn
&& let Ok(diags) = lini::lint_str(&source)
{
for d in &diags {
eprintln!("{}", d.display_with_source(&source, &filename));
}
warnings_were_emitted |= !diags.is_empty();
}
match lini::compile_str_checked(&source, &opts) {
Ok((svg, route_diags)) => {
if !cli.no_warn {
for d in &route_diags {
eprintln!("{}", d.display_with_source(&source, &filename));
}
warnings_were_emitted |= !route_diags.is_empty();
}
if let Some(out_path) = cli.output {
if let Err(e) = std::fs::write(&out_path, svg.as_bytes()) {
eprintln!("error: write {}: {}", out_path.display(), e);
return ExitCode::from(2);
}
} else {
print!("{}", svg);
}
if cli.strict && warnings_were_emitted {
return ExitCode::from(1);
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("{}", e.display_with_source(&source, &filename));
ExitCode::from(1)
}
}
}
fn watch_loop(input: &Path, output: &Path, opts: &lini::Options, check_only: bool) -> ExitCode {
eprintln!("watching {} → {}", input.display(), output.display());
let mut last_signature = None;
loop {
let signature = std::fs::metadata(input)
.and_then(|m| m.modified())
.ok()
.map(|t| (t, std::fs::metadata(input).map(|m| m.len()).unwrap_or(0)));
if signature != last_signature {
last_signature = signature;
recompile(input, output, opts, check_only);
}
std::thread::sleep(Duration::from_millis(200));
}
}
fn run_serve(args: &[String]) -> ExitCode {
let mut port: u16 = 7700;
let mut bake_vars = false;
let mut input: Option<String> = None;
let mut i = 0;
while i < args.len() {
let a = &args[i];
match a.as_str() {
"--port" => {
i += 1;
let p = match args.get(i) {
Some(s) => s,
None => {
eprintln!("error: --port needs a value");
return ExitCode::from(3);
}
};
port = match p.parse() {
Ok(n) => n,
Err(_) => {
eprintln!("error: --port: invalid number '{}'", p);
return ExitCode::from(3);
}
};
}
"--bake-vars" => bake_vars = true,
"-h" | "--help" => {
println!("lini serve <input.lini> [--port N] [--bake-vars]");
println!();
println!(" Serves a live-reloading preview at http://127.0.0.1:<port>/.");
println!(" The page auto-refreshes whenever the input file is saved.");
println!();
println!(" --port N Port to bind (default 7700).");
println!(
" --bake-vars Bake CSS variables as literals (for non-browser renderers)."
);
return ExitCode::SUCCESS;
}
flag if flag.starts_with("--") => {
eprintln!("error: unknown flag for `lini serve`: {}", flag);
return ExitCode::from(3);
}
other => {
if input.is_some() {
eprintln!("error: `lini serve` takes one input file");
return ExitCode::from(3);
}
input = Some(other.to_string());
}
}
i += 1;
}
let input = match input {
Some(p) => PathBuf::from(p),
None => {
eprintln!("error: `lini serve` requires an input file");
return ExitCode::from(3);
}
};
if !input.exists() {
eprintln!("error: {}: file not found", input.display());
return ExitCode::from(2);
}
let opts = lini::Options {
bake_vars,
..Default::default()
};
match lini::serve(input, port, opts) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {}", e);
ExitCode::from(2)
}
}
}
fn run_fmt(args: &[String]) -> ExitCode {
let mut check = false;
let mut to_stdout = false;
let mut input: Option<String> = None;
for a in args {
match a.as_str() {
"--check" => check = true,
"--stdout" => to_stdout = true,
"-h" | "--help" => {
println!("lini fmt [--check] [--stdout] <input.lini>");
println!();
println!(
" --check Exit 1 if reformatting would change the file. Write nothing."
);
println!(" --stdout Print formatted output to stdout instead of rewriting.");
println!(" - Read stdin → stdout.");
return ExitCode::SUCCESS;
}
flag if flag.starts_with("--") => {
eprintln!("error: unknown flag for `lini fmt`: {}", flag);
return ExitCode::from(3);
}
other => {
if input.is_some() {
eprintln!("error: `lini fmt` takes one input file");
return ExitCode::from(3);
}
input = Some(other.to_string());
}
}
}
let input_arg = match input {
Some(p) => p,
None => {
eprintln!("error: `lini fmt` requires an input file (or '-' for stdin)");
return ExitCode::from(3);
}
};
let (filename, source) = if input_arg == "-" {
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("error: failed to read stdin: {}", e);
return ExitCode::from(2);
}
("<stdin>".to_string(), buf)
} else {
match std::fs::read_to_string(&input_arg) {
Ok(s) => (input_arg.clone(), s),
Err(e) => {
eprintln!("error: {}: {}", input_arg, e);
return ExitCode::from(2);
}
}
};
let formatted = match lini::format_source(&source) {
Ok(s) => s,
Err(e) => {
eprintln!("{}", e.display_with_source(&source, &filename));
return ExitCode::from(1);
}
};
if check {
if formatted == source {
return ExitCode::SUCCESS;
}
eprintln!("{}: would reformat", filename);
return ExitCode::from(1);
}
if input_arg == "-" || to_stdout {
print!("{}", formatted);
} else if formatted != source
&& let Err(e) = std::fs::write(&input_arg, formatted.as_bytes())
{
eprintln!("error: write {}: {}", input_arg, e);
return ExitCode::from(2);
}
ExitCode::SUCCESS
}
fn recompile(input: &Path, output: &Path, opts: &lini::Options, check_only: bool) {
let start = Instant::now();
let source = match std::fs::read_to_string(input) {
Ok(s) => s,
Err(e) => {
eprintln!("error: read {}: {}", input.display(), e);
return;
}
};
let filename = input.display().to_string();
let result = if check_only {
lini::check_with(&source, opts).map(|_| String::new())
} else {
lini::compile_str_with(&source, opts)
};
match result {
Ok(_) if check_only => {
eprintln!("ok ({} ms) — check passed", start.elapsed().as_millis());
}
Ok(svg) => match std::fs::write(output, svg.as_bytes()) {
Ok(()) => eprintln!(
"ok ({} ms) → {}",
start.elapsed().as_millis(),
output.display()
),
Err(e) => eprintln!("error: write {}: {}", output.display(), e),
},
Err(e) => eprintln!("{}", e.display_with_source(&source, &filename)),
}
}