pub mod config;
use clap::{Parser, Subcommand};
use config::PackageConfig;
use miette::{Context, IntoDiagnostic, Result};
use std::{
env::{current_dir, current_exe},
fs::{Metadata, create_dir_all, read_dir, write},
io::ErrorKind,
path::{Path, PathBuf},
process::{Command as ProcessCommand, ExitCode, ExitStatus, exit},
time::SystemTime,
};
#[derive(Debug, Parser)]
#[command(name = "lumer", about = "Lumi language toolchain (build & run)")]
pub struct Cli {
#[command(subcommand)]
cmd: CommandLine,
}
fn should_rebuild(input: &Path, output: &Path) -> bool {
if !output.exists() {
return true;
}
let Ok(out_meta) = output.metadata() else {
return true;
};
let Ok(out_mtime) = out_meta.modified() else {
return true;
};
let mut newest = latest_mtime(input);
if let Some(root) = find_project_root() {
let src_dir = root.join("src");
if src_dir.exists() {
newest = max_time(newest, latest_mtime_in_dir(&src_dir));
}
}
newest.map(|t| t > out_mtime).unwrap_or(true)
}
fn latest_mtime(path: &Path) -> Option<SystemTime> {
let meta = path.metadata().ok()?;
meta.modified().ok()
}
fn latest_mtime_in_dir(dir: &Path) -> Option<SystemTime> {
let mut newest: Option<SystemTime> = None;
let entries = read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else {
continue;
};
newest = max_time(newest, newest_from_path(&path, &meta));
}
newest
}
fn newest_from_path(path: &Path, meta: &Metadata) -> Option<SystemTime> {
if meta.is_dir() {
return latest_mtime_in_dir(path);
}
if path.extension().and_then(|e| e.to_str()) != Some("lumi") {
return None;
}
meta.modified().ok()
}
fn max_time(current: Option<SystemTime>, candidate: Option<SystemTime>) -> Option<SystemTime> {
match (current, candidate) {
(None, x) => x,
(x, None) => x,
(Some(x), Some(y)) => Some(if x > y { x } else { y }),
}
}
#[derive(Debug, Clone, Subcommand)]
pub enum CommandLine {
Build {
#[arg(value_name = "INPUT")]
input: Option<PathBuf>,
#[arg(short, long, value_name = "OUTPUT")]
output: Option<PathBuf>,
#[arg(short, long)]
release: bool,
#[arg(short, long)]
verbose: bool,
},
Run {
#[arg(value_name = "INPUT")]
input: Option<PathBuf>,
#[arg(value_name = "ARGS", trailing_var_arg = true)]
args: Vec<String>,
#[arg(short, long)]
release: bool,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
force: bool,
},
New {
#[arg(value_name = "NAME")]
name: String,
},
Init {
#[arg(long)]
name: Option<String>,
},
}
pub fn parse_cli() -> Cli {
let mut cli = Cli::parse();
match &mut cli.cmd {
CommandLine::Build { input, .. } => {
if input.is_none() {
*input = default_main_path().or_else(|| {
eprintln!(
"Missing input file. Provide a file or run inside a Lumi package with src/main.lumi"
);
exit(2);
});
}
}
CommandLine::Run { input, .. } => {
if input.is_none() {
*input = default_main_path().or_else(|| {
eprintln!(
"Missing input file. Provide a file or run inside a Lumi package with src/main.lumi"
);
exit(2);
});
}
}
_ => {}
}
cli
}
pub async fn run() -> Result<ExitCode> {
let cli = parse_cli();
match cli.cmd {
CommandLine::Build {
input,
output,
release,
verbose,
} => {
let input = input.expect("input must be set by parse_cli");
let out_path = output.unwrap_or_else(|| compute_output_path(&input, release));
let in_string = input.to_string_lossy().into_owned();
let out_string = out_path.to_string_lossy().into_owned();
let mut args = vec![in_string, "-o".to_string(), out_string];
if verbose {
args.push("-v".to_string());
}
let status = invoke_lumic(args)?;
Ok(if status.success() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
})
}
CommandLine::Run {
input,
args,
release,
verbose,
force,
} => {
let input = input.as_ref().expect("input must be set by parse_cli");
let out_path = compute_output_path(input, release);
if force || should_rebuild(input, &out_path) {
let in_string = input.to_string_lossy().into_owned();
let out_string = out_path.to_string_lossy().into_owned();
let mut build_args = vec![in_string, "-o".to_string(), out_string];
if verbose {
build_args.push("-v".to_string());
}
let status_build = invoke_lumic(build_args)?;
if !status_build.success() {
return Ok(ExitCode::FAILURE);
}
}
let status = ProcessCommand::new(&out_path)
.args(&args)
.status()
.into_diagnostic()
.wrap_err_with(|| format!("Failed to run {}", out_path.display()))?;
Ok(if status.success() {
ExitCode::SUCCESS
} else {
ExitCode::from(status.code().unwrap_or(1) as u8)
})
}
CommandLine::New { name } => {
init_package_at(Path::new(&name), &name)?;
println!("Initialized new Lumi package: {}", name);
Ok(ExitCode::SUCCESS)
}
CommandLine::Init { name } => {
let package_name = name.unwrap_or_else(|| {
current_dir()
.expect("Failed to get current directory")
.file_name()
.expect("Failed to get package name")
.to_string_lossy()
.to_string()
});
init_package(&package_name)?;
Ok(ExitCode::SUCCESS)
}
}
}
fn default_main_path() -> Option<PathBuf> {
let root = find_project_root()?;
let main = root.join("src").join("main.lumi");
if main.exists() { Some(main) } else { None }
}
fn compute_output_path(input: &Path, release: bool) -> PathBuf {
let profile = if release { "release" } else { "debug" };
if let Some(root) = find_project_root() {
let name = PackageConfig::load(root.join("Package.toml"))
.map(|c| c.name)
.unwrap_or_else(|_| "app".to_string());
let out = root.join("target").join(profile).join(name);
if let Some(parent) = out.parent() {
let _ = create_dir_all(parent);
}
out
} else {
let base = input.parent().unwrap_or_else(|| Path::new("."));
let stem = input.file_stem().and_then(|s| s.to_str()).unwrap_or("app");
let out = base.join("target").join(profile).join(stem);
if let Some(parent) = out.parent() {
let _ = create_dir_all(parent);
}
out
}
}
fn find_project_root() -> Option<PathBuf> {
let mut dir = current_dir().ok()?;
loop {
if dir.join("Package.toml").exists() {
return Some(dir);
}
if !dir.pop() {
break;
}
}
None
}
fn invoke_lumic(args: Vec<String>) -> Result<ExitStatus> {
let sibling_lumic = current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join("lumic")));
if let Some(path) = sibling_lumic
&& path.exists()
{
return ProcessCommand::new(&path)
.args(&args)
.status()
.into_diagnostic()
.wrap_err_with(|| format!("Failed to run sibling lumic at {}", path.display()));
}
match ProcessCommand::new("lumic").args(&args).status() {
Ok(status) => Ok(status),
Err(error) => {
if error.kind() == ErrorKind::NotFound {
let mut cargo_args: Vec<String> =
vec!["run", "-q", "-p", "lumic", "--bin", "lumic", "--"]
.into_iter()
.map(|s| s.to_string())
.collect();
cargo_args.extend(args);
let status = ProcessCommand::new("cargo")
.args(&cargo_args)
.status()
.into_diagnostic()
.wrap_err("Failed to run cargo to launch 'lumic'")?;
Ok(status)
} else {
Err(error)
.into_diagnostic()
.wrap_err("Failed to launch 'lumic'")
}
}
}
}
fn init_package(name: &str) -> Result<()> {
let config = PackageConfig::new(name);
config.save("Package.toml")?;
create_dir_all("src").into_diagnostic()?;
create_dir_all("tests").into_diagnostic()?;
write(
"src/main.lumi",
format!(
r#"fn main() {{
println("Hello from {name}!");
}}
"#
),
)
.into_diagnostic()?;
println!("Initialized new Lumi package: {name}");
Ok(())
}
fn init_package_at(path: &Path, name: &str) -> Result<()> {
create_dir_all(path.join("src")).into_diagnostic()?;
let config = PackageConfig::new(name);
config.save(path.join("Package.toml"))?;
write(
path.join("src").join("main.lumi"),
format!(
r#"fn main() {{
println("Hello from {name}!");
}}
"#
),
)
.into_diagnostic()?;
Ok(())
}