use clap::{CommandFactory, Parser, Subcommand};
use std::path::PathBuf;
use std::process::ExitCode;
fn run_script(source: &str, args: &[String]) -> ExitCode {
let dir = match tempfile::tempdir() {
Ok(d) => d,
Err(e) => {
eprintln!("error: cannot create temp dir: {e}");
return ExitCode::from(3);
}
};
let bin = dir.path().join("plg-script");
let src = std::path::Path::new(source);
if let Err(e) = plgc::compile_files(
&[src],
&bin,
false,
plgc::OptLevel::O3,
plgc::Target::Native,
) {
eprintln!("error: {e}");
return ExitCode::from(3);
}
match std::process::Command::new(&bin).args(args).status() {
Ok(status) => ExitCode::from(status.code().unwrap_or(3) as u8),
Err(e) => {
eprintln!("error: failed to run compiled script: {e}");
ExitCode::from(3)
}
}
}
#[derive(Parser)]
#[command(
name = "plgc",
version,
about = "Compile ISO-subset Prolog to standalone native binaries"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Build {
inputs: Vec<PathBuf>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
keep_ir: bool,
#[arg(long)]
debug: bool,
#[arg(long)]
deny_undefined: bool,
#[arg(long)]
target: Option<String>,
},
Run {
inputs: Vec<PathBuf>,
#[arg(long)]
query: String,
#[arg(long)]
limit: Option<usize>,
#[arg(long, default_value = "text")]
format: String,
#[arg(long)]
deny_undefined: bool,
},
Check {
inputs: Vec<PathBuf>,
#[arg(long)]
deny_undefined: bool,
},
Completions {
shell: clap_complete::Shell,
},
}
fn lint_undefined(sources: &[&std::path::Path], deny: bool) -> Result<(), u8> {
let Ok(lints) = plgc::undefined_predicate_lints(sources) else {
return Ok(());
};
let label = if deny { "error" } else { "warning" };
for m in &lints {
eprintln!("{label}: {m}");
}
if deny && !lints.is_empty() {
return Err(2);
}
Ok(())
}
fn is_parse_error(msg: &str) -> bool {
msg.match_indices(": ").any(|(end, _)| {
let head = &msg[..end];
let Some((rest, col)) = head.rsplit_once(':') else {
return false;
};
let Some((_, line)) = rest.rsplit_once(':') else {
return false;
};
!col.is_empty()
&& col.bytes().all(|b| b.is_ascii_digit())
&& !line.is_empty()
&& line.bytes().all(|b| b.is_ascii_digit())
})
}
fn parse_target(target: Option<&str>) -> Result<plgc::Target, String> {
match target {
None | Some("native") => Ok(plgc::Target::Native),
Some("wasm32-wasi") | Some("wasm32-wasip1") => Ok(plgc::Target::Wasm),
Some("worker") | Some("wasm32-unknown-unknown") => Ok(plgc::Target::Worker),
Some(other) => Err(format!(
"unknown target `{other}` (supported: native, wasm32-wasi, worker)"
)),
}
}
fn emit_worker_glue(output: &std::path::Path) {
match plgc::worker_glue::emit(output) {
Ok(written) if written.is_empty() => {
eprintln!("note: kept existing worker glue (worker.js / wrangler.toml / config.capnp)");
}
Ok(written) => {
eprintln!(
"note: wrote {} next to {}",
written.join(", "),
output.display()
);
eprintln!(" serve locally: just wasm-worker-serve <prog.pl>");
eprintln!(" deploy: wrangler deploy");
}
Err(e) => eprintln!("warning: reactor built, but writing worker glue failed: {e}"),
}
}
fn main() -> ExitCode {
let raw: Vec<String> = std::env::args().collect();
if raw.len() >= 2 && raw[1].ends_with(".pl") && std::path::Path::new(&raw[1]).exists() {
return run_script(&raw[1], &raw[2..]);
}
let cli = Cli::parse();
match cli.command {
Commands::Build {
inputs,
output,
keep_ir,
debug,
deny_undefined,
target,
} => {
if inputs.is_empty() {
eprintln!("error: no input files");
return ExitCode::from(3);
}
let target = match parse_target(target.as_deref()) {
Ok(t) => t,
Err(e) => {
eprintln!("error: {e}");
return ExitCode::from(3);
}
};
let output = output.unwrap_or_else(|| {
let stem = PathBuf::from(inputs[0].file_stem().unwrap_or_default());
match target {
plgc::Target::Wasm => stem.with_extension("wasm"),
plgc::Target::Worker => stem.with_extension("worker.wasm"),
plgc::Target::Native => stem,
}
});
let sources: Vec<&std::path::Path> = inputs.iter().map(|p| p.as_path()).collect();
if let Err(code) = lint_undefined(&sources, deny_undefined) {
return ExitCode::from(code);
}
let opt = if debug {
plgc::OptLevel::O0
} else {
plgc::OptLevel::O3
};
match plgc::compile_files(&sources, &output, keep_ir, opt, target) {
Ok(()) => {
if target == plgc::Target::Worker {
emit_worker_glue(&output);
}
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(3)
}
}
}
Commands::Run {
inputs,
query,
limit,
format,
deny_undefined,
} => {
if inputs.is_empty() {
eprintln!("error: no input files");
return ExitCode::from(3);
}
let sources: Vec<&std::path::Path> = inputs.iter().map(|p| p.as_path()).collect();
if let Err(code) = lint_undefined(&sources, deny_undefined) {
return ExitCode::from(code);
}
let dir = match tempfile::tempdir() {
Ok(d) => d,
Err(e) => {
eprintln!("error: cannot create temp dir: {e}");
return ExitCode::from(3);
}
};
let bin = dir.path().join("plg-run");
if let Err(e) = plgc::compile_files(
&sources,
&bin,
false,
plgc::OptLevel::O0,
plgc::Target::Native,
) {
eprintln!("error: {e}");
let code = if is_parse_error(&e) { 2 } else { 3 };
return ExitCode::from(code);
}
let mut cmd = std::process::Command::new(&bin);
cmd.arg("--query").arg(&query).arg("--format").arg(&format);
if let Some(l) = limit {
cmd.arg("--limit").arg(l.to_string());
}
match cmd.status() {
Ok(status) => ExitCode::from(status.code().unwrap_or(3) as u8),
Err(e) => {
eprintln!("error: failed to run compiled binary: {e}");
ExitCode::from(3)
}
}
}
Commands::Check {
inputs,
deny_undefined,
} => {
let sources: Vec<&std::path::Path> = inputs.iter().map(|p| p.as_path()).collect();
match plgc::check_files(&sources) {
Ok(()) => match lint_undefined(&sources, deny_undefined) {
Ok(()) => ExitCode::SUCCESS,
Err(code) => ExitCode::from(code),
},
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(2)
}
}
}
Commands::Completions { shell } => {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout());
ExitCode::SUCCESS
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_target_maps_known_targets() {
assert_eq!(parse_target(None).unwrap(), plgc::Target::Native);
assert_eq!(parse_target(Some("native")).unwrap(), plgc::Target::Native);
assert_eq!(
parse_target(Some("wasm32-wasi")).unwrap(),
plgc::Target::Wasm
);
assert_eq!(
parse_target(Some("wasm32-wasip1")).unwrap(),
plgc::Target::Wasm
);
assert_eq!(parse_target(Some("worker")).unwrap(), plgc::Target::Worker);
assert_eq!(
parse_target(Some("wasm32-unknown-unknown")).unwrap(),
plgc::Target::Worker
);
}
#[test]
fn parse_target_rejects_unknown_and_lists_supported() {
let err = parse_target(Some("wasm64")).unwrap_err();
assert!(
err.contains("worker"),
"supported list must mention worker: {err}"
);
}
}