Files
pallet/src/app.rs
2026-03-23 12:35:09 -05:00

575 lines
17 KiB
Rust

use std::{
env::set_current_dir,
path::{Path, PathBuf},
process::Command,
};
use clap::CommandFactory;
use colored::Colorize;
use glob::glob;
const MAIN_C: &str = include_str!("templates/main.c");
const GITIGNORE: &str = "target/\ncompile_commands.json\n";
#[derive(clap::Parser)]
#[clap(version, about)]
pub struct App {
#[clap(subcommand)]
command: Subcommand,
}
#[derive(clap::Subcommand)]
enum Subcommand {
/// Create a new C project
New {
/// The name of the new project
name: String,
},
/// Initialize a new C project
Init,
/// Run the local project
Run {
/// Force recompilation of the project
#[arg(long, short)]
force_recompile: bool,
/// The build mode to use
mode: Option<String>,
/// Arguments to pass to the project binary
#[arg(long, short)]
args: Option<Vec<String>>,
},
/// Build the local project
Build {
/// Force recompilation of the project
#[arg(long, short)]
force_recompile: bool,
/// The build mode to use
mode: Option<String>,
},
/// Clean all in progress files
Clean,
/// Utility functions
Utils {
#[clap(subcommand)]
command: UtilSubcommand,
},
/// List available build modes
List,
/// Add a new package to the project (throguh pkg-config)
Add {
/// The package to be added
package: String,
},
}
#[derive(clap::Subcommand)]
enum UtilSubcommand {
/// Generate shell completions
Completions {
/// The shell to generate completions for
#[arg(value_enum, long, short)]
shell: ShellCompletions,
},
/// Generate compile_commands.json for IDE support
GenCompileCommands {
/// The build mode to generate for
mode: Option<String>,
},
}
#[derive(Clone, clap::ValueEnum)]
enum ShellCompletions {
Bash,
Fish,
PowerShell,
Zsh,
}
impl App {
pub fn run(self) {
match self.command {
Subcommand::New { name } => {
if let Err(e) = create_project(&name) {
eprintln!("Error creating project '{name}': {e}");
std::process::exit(1);
}
}
Subcommand::Init => {
if let Err(e) = create_project(".") {
eprintln!("Error initializing project: {e}");
std::process::exit(1);
}
}
Subcommand::Run {
mode,
args,
force_recompile,
} => {
if let Err(e) = build(&mode, force_recompile) {
eprintln!("Error building project: {e}");
std::process::exit(1);
};
if let Err(e) = run(&mode, args) {
eprintln!("Error running project: {e}");
std::process::exit(1);
}
}
Subcommand::Build {
mode,
force_recompile,
} => {
if let Err(e) = build(&mode, force_recompile) {
eprintln!("Error building project: {e}");
std::process::exit(1);
}
}
Subcommand::Clean => {
if let Err(e) = clean() {
eprintln!("Error cleaning project: {e}");
std::process::exit(1);
}
}
Subcommand::Utils { command } => match command {
UtilSubcommand::Completions { shell } => {
let name = env!("CARGO_PKG_NAME");
let mut command = App::command();
match shell {
ShellCompletions::Bash => clap_complete::generate(
clap_complete::shells::Bash,
&mut command,
name,
&mut std::io::stdout(),
),
ShellCompletions::Fish => clap_complete::generate(
clap_complete::shells::Fish,
&mut command,
name,
&mut std::io::stdout(),
),
ShellCompletions::PowerShell => clap_complete::generate(
clap_complete::shells::PowerShell,
&mut command,
name,
&mut std::io::stdout(),
),
ShellCompletions::Zsh => clap_complete::generate(
clap_complete::shells::Zsh,
&mut command,
name,
&mut std::io::stdout(),
),
}
}
UtilSubcommand::GenCompileCommands { mode } => {
if let Err(e) = gen_compile_commands(&mode) {
eprintln!("Error generating compile commands: {e}");
std::process::exit(1);
}
}
},
Subcommand::List => {
if let Err(e) = list() {
eprintln!("Error listing build profiles: {e}");
std::process::exit(1);
}
}
Subcommand::Add { package } => {
if let Err(e) = add_package(&package) {
eprintln!("Error adding package {package} to project: {e}");
std::process::exit(1);
}
}
}
}
}
fn add_package(package: &str) -> std::io::Result<()> {
let status = Command::new("pkg-config")
.arg("--exists")
.arg(&package)
.status()
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"pkg-config not found - please install it first",
)
})?;
if !status.success() {
eprintln!(
" {} pkg-config could not find package '{package}'",
"Error".red().bold()
);
eprintln!(
" {} try installing the development package for '{package}', e.g.:",
"Hint".yellow().bold()
);
eprintln!(" Arch: sudo pacman -S {package}");
eprintln!(" Debian: sudo apt install lib{package}-dev");
std::process::exit(1);
}
let mut conf = get_config().ok_or(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no Pallet.toml found in local directory",
))?;
let deps = conf.dependencies.get_or_insert_with(Vec::new);
if deps.contains(&package.to_owned()) {
println!(
" {} '{package}' is already a dependency",
"Warning".yellow().bold()
);
return Ok(());
}
deps.push(package.to_owned());
std::fs::write(
"Pallet.toml",
toml::to_string_pretty(&conf).expect("valid TOML"),
)?;
println!(" {} {package}", "Added".green().bold());
Ok(())
}
fn gen_compile_commands(mode: &Option<String>) -> std::io::Result<()> {
let conf = get_config().ok_or(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no Pallet.toml found in current directory",
))?;
let build_config = conf.get_or_default(mode).ok_or(std::io::Error::new(
std::io::ErrorKind::NotFound,
"build layout not found",
))?;
let compiler = conf.compiler.as_deref().unwrap_or("gcc");
let cwd = std::env::current_dir()?;
let obj_dir = format!("target/{}/obj", build_config.name);
let source_files: Vec<PathBuf> = glob("src/*.c")
.map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, format!("{e}")))?
.filter_map(|e| e.ok())
.collect();
let entries: Vec<serde_json::Value> = source_files
.iter()
.map(|src| {
let stem = src.file_stem().unwrap().to_string_lossy();
let obj = format!("{}/{}.o", obj_dir, stem);
let command = std::iter::once(compiler.to_string())
.chain(build_config.args.iter().cloned())
.chain(["-c".to_string(), src.to_string_lossy().to_string()])
.chain(["-o".to_string(), obj])
.collect::<Vec<_>>()
.join(" ");
serde_json::json!({
"directory": cwd.to_string_lossy(),
"file": cwd.join(src).to_string_lossy(),
"command": command
})
})
.collect();
let json = serde_json::to_string_pretty(&entries)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("{e}")))?;
std::fs::write("compile_commands.json", json)?;
println!(" {} compile_commands.json", "Generated".green().bold());
Ok(())
}
fn list() -> std::io::Result<()> {
let conf = get_config().ok_or(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no Pallet.toml found in local directory",
))?;
for build in conf.build {
println!(" - {}: {:?}", build.name.green().bold(), build.args);
}
Ok(())
}
fn create_project<P: AsRef<Path>>(directory: P) -> std::io::Result<()> {
let name = if directory.as_ref().to_string_lossy() == "." {
String::new()
} else {
format!(" '{}'", directory.as_ref().to_string_lossy())
};
println!(
" {} binary (application){}",
"Creating".green().bold(),
name
);
let pathdir = directory.as_ref();
if pathdir.exists() && pathdir.to_string_lossy() != "." {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"specified directory already exists",
));
}
if !pathdir.exists() && pathdir.to_string_lossy() != "." {
std::fs::create_dir(pathdir)?;
}
set_current_dir(pathdir)?;
std::fs::create_dir("src")?;
std::fs::write("src/main.c", MAIN_C)?;
std::fs::write(".gitignore", GITIGNORE)?;
let config = crate::config::Config::new(&pathdir.to_string_lossy());
let serial = toml::to_string_pretty(&config).expect("a valid TOML structure");
std::fs::write("Pallet.toml", &serial)?;
Ok(())
}
fn get_config() -> Option<crate::config::Config> {
let p = PathBuf::from("Pallet.toml");
if !p.exists() {
return None;
}
let raw = std::fs::read_to_string(&p).expect("A valid config file");
toml::from_str(&raw).ok()
}
fn build(mode: &Option<String>, force_recompile: bool) -> std::io::Result<()> {
let conf = match get_config() {
Some(conf) => conf,
None => {
eprintln!("Error opening config file. No Pallet.toml in current directory.");
std::process::exit(1);
}
};
let build_config = conf.get_or_default(mode).ok_or(std::io::Error::new(
std::io::ErrorKind::NotFound,
"build layout not found",
))?;
println!(
" {} '{}' profile for project '{}'",
"Building".green().bold(),
build_config.name,
conf.name
);
let start = std::time::Instant::now();
let obj_dir = format!("target/{}/obj", build_config.name);
std::fs::create_dir_all(&obj_dir)?;
let compiler = conf.compiler.as_deref().unwrap_or("gcc");
let mut extra_compile_flags: Vec<String> = Vec::new();
let mut extra_link_flags: Vec<String> = Vec::new();
if let Some(deps) = &conf.dependencies {
if !deps.is_empty() {
let cflags = Command::new("pkg-config")
.arg("--cflags")
.args(deps)
.output()
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::NotFound, "pkg-config not found")
})?;
if !cflags.status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"pkg-config --cflags failed - is the package installed?",
));
}
let libs = Command::new("pkg-config")
.arg("--libs")
.args(deps)
.output()
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::NotFound, "pkg-config not found")
})?;
if !libs.status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"pkg-config --libs failed - is the pacakge installed?",
));
}
extra_compile_flags = String::from_utf8_lossy(&cflags.stdout)
.split_whitespace()
.map(str::to_owned)
.collect();
extra_link_flags = String::from_utf8_lossy(&libs.stdout)
.split_whitespace()
.map(str::to_owned)
.collect();
}
}
let source_files: Vec<PathBuf> = glob("src/*.c")
.map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, format!("{e}")))?
.filter_map(|e| e.ok())
.collect();
let mut any_changed = false;
for src in &source_files {
let stem = src.file_stem().unwrap().to_string_lossy();
let obj_path = format!("{}/{}.o", obj_dir, stem);
let hash_path = format!("{}/{}.c.hash", obj_dir, stem);
let hash = hash_file(src)?;
let needs_compile = force_recompile
|| match std::fs::read_to_string(&hash_path) {
Ok(old) => old.trim() != hash.trim(),
Err(_) => true,
};
if needs_compile {
println!(" {} {}", "Compiling".green().bold(), src.display());
let status = Command::new(compiler)
.args(&build_config.args)
.args(&extra_compile_flags)
.arg("-c")
.arg(src)
.arg("-o")
.arg(&obj_path)
.status()?;
if !status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("failed to compile {}", src.display()),
));
}
std::fs::write(&hash_path, &hash)?;
any_changed = true;
}
}
let binary = format!("target/{}/{}", build_config.name, conf.name);
let binary_exists = PathBuf::from(&binary).exists();
if !any_changed && binary_exists {
println!(
" {} (code not changed, recompile not made)",
"Finished".green().bold()
);
return Ok(());
}
let obj_files: Vec<String> = source_files
.iter()
.map(|src| {
let stem = src.file_stem().unwrap().to_string_lossy();
format!("{}/{}.o", obj_dir, stem)
})
.collect();
let status = Command::new(compiler)
.args(&obj_files)
.args(&extra_link_flags)
.arg("-o")
.arg(&binary)
.status()?;
if !status.success() {
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
"linking failed",
));
}
let stop = start.elapsed();
gen_compile_commands(&mode)?;
println!(
" {} '{}' profile for project '{}' in {:.2}s",
"Finished".green().bold(),
build_config.name,
conf.name,
stop.as_secs_f64()
);
Ok(())
}
fn hash_file(path: &Path) -> std::io::Result<String> {
let contents = std::fs::read_to_string(path)?;
Ok(sha256::digest(contents))
}
fn run(mode: &Option<String>, args: Option<Vec<String>>) -> std::io::Result<()> {
let conf = match get_config() {
Some(conf) => conf,
None => {
eprintln!("Error opening config file. No Pallet.toml in current directory.");
std::process::exit(1);
}
};
let build_config = conf.get_or_default(mode).ok_or(std::io::Error::new(
std::io::ErrorKind::NotFound,
"build layout not found",
))?;
println!(
" {} '{}' profile for project '{}'",
"Running".green().bold(),
build_config.name,
conf.name
);
let mut command = Command::new(format!("target/{}/{}", build_config.name, conf.name));
if let Some(args) = args {
for arg in args {
command.arg(arg);
}
}
let mut child = command.spawn()?;
child.wait()?;
Ok(())
}
fn clean() -> std::io::Result<()> {
if get_config().is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no Pallet.toml found in local dir",
));
}
std::fs::remove_dir_all("target/")?;
println!(
" {} removed files in target/ directory",
"Successfully".green().bold()
);
Ok(())
}