use anyhow::{Context, Result, anyhow, bail};
use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
pub fn run_build(crate_dir: Option<PathBuf>, out_dir: Option<PathBuf>) -> Result<()> {
let crate_dir = crate_dir
.unwrap_or_else(|| PathBuf::from("."))
.canonicalize()
.context("failed to resolve crate directory")?;
let out_dir = out_dir.unwrap_or_else(|| crate_dir.join("pkg"));
let cargo_toml_path = crate_dir.join("Cargo.toml");
let cargo_toml = fs::read_to_string(&cargo_toml_path)
.with_context(|| format!("failed to read {}", cargo_toml_path.display()))?;
let crate_name = parse_crate_name(&cargo_toml)
.ok_or_else(|| anyhow!("could not determine crate name from Cargo.toml"))?;
eprintln!("[macroforge build] crate: {crate_name}");
eprintln!("[macroforge build] out: {}", out_dir.display());
eprintln!("[macroforge build] compiling to wasm32-unknown-unknown …");
let status = Command::new("cargo")
.args(["build", "--release", "--target", "wasm32-unknown-unknown"])
.current_dir(&crate_dir)
.status()
.context("failed to run cargo build")?;
if !status.success() {
bail!("cargo build failed");
}
let wasm_file = crate_dir
.join("target/wasm32-unknown-unknown/release")
.join(format!("{}.wasm", crate_name.replace('-', "_")));
if !wasm_file.exists() {
bail!("WASM artifact not found at {}", wasm_file.display());
}
eprintln!("[macroforge build] running wasm-bindgen …");
fs::create_dir_all(&out_dir).context("failed to create output directory")?;
let wasm_bindgen_bin = resolve_wasm_bindgen()?;
let status = Command::new(&wasm_bindgen_bin)
.args([
"--target",
"nodejs",
"--out-dir",
out_dir.to_str().unwrap(),
wasm_file.to_str().unwrap(),
])
.status()
.context("failed to run wasm-bindgen")?;
if !status.success() {
bail!("wasm-bindgen failed");
}
let js_stem = crate_name.replace('-', "_");
let js_path = out_dir.join(format!("{js_stem}.js"));
let dts_path = out_dir.join(format!("{js_stem}.d.ts"));
if !js_path.exists() {
bail!("wasm-bindgen output not found at {}", js_path.display());
}
let call_macros = discover_call_macros(&js_path)?;
if call_macros.is_empty() {
eprintln!("[macroforge build] no Call macros found — skipping $ alias generation");
} else {
eprintln!(
"[macroforge build] adding $ aliases for Call macros: {}",
call_macros.join(", ")
);
append_dollar_aliases(&js_path, &call_macros).context("failed to patch JS output")?;
if dts_path.exists() {
append_dollar_aliases_dts(&dts_path, &call_macros)
.context("failed to patch .d.ts output")?;
}
}
eprintln!("[macroforge build] done ✓");
Ok(())
}
fn discover_call_macros(js_path: &Path) -> Result<Vec<String>> {
let dts_path = js_path.with_extension("d.ts");
if !dts_path.exists() {
bail!(".d.ts file not found at {}", dts_path.display());
}
let dts = fs::read_to_string(&dts_path)?;
let mut call_macros = Vec::new();
for line in dts.lines() {
let line = line.trim();
if let Some(name) = line
.strip_prefix("export function ")
.and_then(|rest| rest.strip_suffix("(value: any): any;"))
{
if !name.starts_with('_') {
call_macros.push(name.to_string());
}
}
}
Ok(call_macros)
}
fn append_dollar_aliases(js_path: &Path, names: &[String]) -> Result<()> {
let mut js = fs::read_to_string(js_path)?;
js.push_str("\n// macroforge: $ aliases for Call macros\n");
for name in names {
js.push_str(&format!(
"module.exports.${name} = module.exports.{name};\n"
));
}
fs::write(js_path, js)?;
Ok(())
}
fn append_dollar_aliases_dts(dts_path: &Path, names: &[String]) -> Result<()> {
let mut dts = fs::read_to_string(dts_path)?;
dts.push_str("\n// macroforge: $ aliases for Call macros\n");
for name in names {
dts.push_str(&format!("export {{ {name} as ${name} }};\n"));
}
fs::write(dts_path, dts)?;
Ok(())
}
fn resolve_wasm_bindgen() -> Result<String> {
if let Ok(from_env) = std::env::var("WASM_BINDGEN") {
return Ok(from_env);
}
if let Ok(output) = Command::new("which").arg("wasm-bindgen").output()
&& output.status.success()
{
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(path);
}
}
let home = std::env::var("HOME").context("HOME not set")?;
let cache_dirs = [
format!("{home}/Library/Caches/.wasm-pack"),
format!("{home}/.cache/.wasm-pack"),
];
for dir in &cache_dirs {
if let Ok(entries) = find_wasm_bindgen_recursive(Path::new(dir))
&& let Some(path) = entries.first()
{
return Ok(path.to_string_lossy().to_string());
}
}
bail!(
"wasm-bindgen not found in PATH or wasm-pack cache. \
Install it with `cargo install wasm-bindgen-cli` or set WASM_BINDGEN env var."
)
}
fn find_wasm_bindgen_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
let mut results = Vec::new();
if !dir.is_dir() {
return Ok(results);
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.file_name().is_some_and(|n| n == "wasm-bindgen") {
results.push(path);
} else if path.is_dir() {
results.extend(find_wasm_bindgen_recursive(&path)?);
}
}
Ok(results)
}
fn parse_crate_name(toml_content: &str) -> Option<String> {
for line in toml_content.lines() {
let line = line.trim();
if line.starts_with("name")
&& let Some(value) = line.split('=').nth(1)
{
return Some(
value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string(),
);
}
}
None
}