use clap::{Args, Parser, Subcommand, 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,
args_conflicts_with_subcommands = true,
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[command(flatten)]
compile: Option<CompileArgs>,
}
#[derive(Subcommand)]
enum Command {
Fmt {
input: String,
#[arg(long = "check")]
check: bool,
#[arg(long = "stdout")]
stdout: bool,
},
Serve {
path: Option<String>,
#[arg(long = "port", default_value_t = 7700)]
port: u16,
#[arg(long = "static")]
static_mode: bool,
},
Desugar {
input: String,
},
Theme {
name: Option<String>,
},
}
#[derive(Args)]
struct CompileArgs {
input: String,
#[arg(short = 'o', long = "output")]
output: Option<PathBuf>,
#[arg(long = "format", default_value = "svg")]
format: String,
#[arg(long = "static")]
static_mode: bool,
#[arg(long = "embed-font")]
embed_font: bool,
#[arg(long = "check")]
check: bool,
#[arg(long = "theme", value_name = "NAME|FILE|A/B")]
theme: Option<String>,
#[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 parsed = 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 cli = match parsed.command {
Some(Command::Fmt {
input,
check,
stdout,
}) => return run_fmt(&input, check, stdout),
Some(Command::Serve {
path,
port,
static_mode,
}) => return run_serve(path, port, static_mode),
Some(Command::Desugar { input }) => return run_desugar(&input),
Some(Command::Theme { name }) => return run_theme(name.as_deref()),
None => match parsed.compile {
Some(c) => c,
None => {
eprintln!("error: an input file (or a subcommand) is required — see 'lini --help'");
return ExitCode::from(3);
}
},
};
if (cli.static_mode || cli.embed_font) && !lini::font_support() {
eprintln!(
"error: --static and --embed-font need the bundled fonts — rebuild with the `font` feature (on by default)"
);
return 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 theme_css = match &cli.theme {
Some(arg) => match theme_css_for(arg) {
Ok(css) => Some(css),
Err(code) => return code,
},
None => None,
};
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 opts = lini::Options {
static_mode: cli.static_mode,
embed_font: cli.embed_font,
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 opts = lini::Options {
static_mode: cli.static_mode,
embed_font: cli.embed_font,
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;
let mut validation_failed = false;
if let Ok(diags) = lini::lint_str(&source) {
for d in &diags {
let is_error = d.level == lini::Level::Error;
if is_error || !cli.no_warn {
eprintln!("{}", d.display_with_source(&source, &filename));
}
validation_failed |= is_error;
warnings_were_emitted |= !is_error && !cli.no_warn;
}
}
if validation_failed {
return ExitCode::from(1);
}
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(input: Option<String>, port: u16, static_mode: bool) -> ExitCode {
let target = match input {
Some(p) => {
let path = PathBuf::from(&p);
if !path.exists() {
eprintln!("error: {}: no such file or directory", path.display());
return ExitCode::from(2);
}
if path.is_dir() {
lini::ServeTarget::Dir(path)
} else {
lini::ServeTarget::File(path)
}
}
None => {
lini::ServeTarget::Dir(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
};
let opts = lini::Options {
static_mode,
..Default::default()
};
match lini::serve(target, port, opts) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(2)
}
}
}
fn run_fmt(input_arg: &str, check: bool, to_stdout: bool) -> ExitCode {
let (filename, source) = match read_input(input_arg) {
Ok(fs) => fs,
Err(code) => return code,
};
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 read_input(input_arg: &str) -> Result<(String, String), ExitCode> {
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 Err(ExitCode::from(2));
}
Ok(("<stdin>".to_string(), buf))
} else {
match std::fs::read_to_string(input_arg) {
Ok(s) => Ok((input_arg.to_string(), s)),
Err(e) => {
eprintln!("error: {}: {}", input_arg, e);
Err(ExitCode::from(2))
}
}
}
}
fn run_desugar(input_arg: &str) -> ExitCode {
let (filename, source) = match read_input(input_arg) {
Ok(fs) => fs,
Err(code) => return code,
};
match lini::desugar_source(&source) {
Ok(s) => {
print!("{}", s);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("{}", e.display_with_source(&source, &filename));
ExitCode::from(1)
}
}
}
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)),
}
}
fn theme_css_for(arg: &str) -> Result<String, ExitCode> {
if let Some(css) = lini::builtin_css(arg) {
return Ok(css);
}
if let Some((l, r)) = arg.split_once('/')
&& let Some(css) = lini::pair_css(l.trim(), r.trim())
{
return Ok(css);
}
match std::fs::read_to_string(arg) {
Ok(s) => Ok(s),
Err(e) => {
eprintln!(
"error: theme '{}': not a built-in (try: {}), and reading it as a file failed: {}",
arg,
theme_names(),
e
);
Err(ExitCode::from(2))
}
}
}
fn theme_names() -> String {
lini::list_themes()
.iter()
.map(|(n, _)| *n)
.collect::<Vec<_>>()
.join(", ")
}
fn run_theme(name: Option<&str>) -> ExitCode {
match name {
None => {
for (n, desc) in lini::list_themes() {
println!("{:15} {}", n, desc);
}
ExitCode::SUCCESS
}
Some(n) => match lini::builtin_css(n) {
Some(css) => {
print!("{}", css);
ExitCode::SUCCESS
}
None => {
eprintln!(
"error: unknown theme '{}' (try one of: {})",
n,
theme_names()
);
ExitCode::from(3)
}
},
}
}