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 = "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 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..]);
}
if args.len() >= 2 && args[1] == "desugar" {
return run_desugar(&args[2..]);
}
if args.len() >= 2 && args[1] == "theme" {
return run_theme(&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;
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 {
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 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 [PATH] [--port N] [--bake-vars]");
println!();
println!(" Serves a live preview at http://127.0.0.1:<port>/.");
println!();
println!(" PATH a .lini file Preview that one file; reloads on every save.");
println!(" PATH a directory Open the playground over its .lini files.");
println!(" PATH omitted Playground over the current directory.");
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 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 {
bake_vars,
..Default::default()
};
match lini::serve(target, 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 run_desugar(args: &[String]) -> ExitCode {
let mut input: Option<String> = None;
for a in args {
match a.as_str() {
"-h" | "--help" => {
println!("lini desugar <input.lini>");
println!();
println!(" Lower the file to primitives + .lini-* classes and print to stdout:");
println!(" templates/defines become a base shape wearing a .lini-* class chain,");
println!(" each type's defaults become a generated .lini-<type> class, scene and");
println!(
" wire defaults fill the global block, and labels/along become explicit."
);
println!(
" The engine's true input; re-renders identically. Use '-' to read stdin."
);
return ExitCode::SUCCESS;
}
flag if flag.starts_with('-') && flag != "-" => {
eprintln!("error: unknown flag for `lini desugar`: {}", flag);
return ExitCode::from(3);
}
other => {
if input.is_some() {
eprintln!("error: `lini desugar` 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 desugar` 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);
}
}
};
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(args: &[String]) -> ExitCode {
let mut name: Option<&str> = None;
for a in args {
match a.as_str() {
"-h" | "--help" => {
println!("lini theme [NAME]");
println!();
println!(" No NAME List the built-in themes.");
println!(" NAME Print that theme as a --lini-* CSS file (copy as a start).");
return ExitCode::SUCCESS;
}
flag if flag.starts_with('-') => {
eprintln!("error: unknown flag for `lini theme`: {}", flag);
return ExitCode::from(3);
}
other => {
if name.is_some() {
eprintln!("error: `lini theme` takes one theme name");
return ExitCode::from(3);
}
name = Some(other);
}
}
}
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)
}
},
}
}