macroforge_ts 0.1.80

TypeScript macro expansion engine - write compile-time macros in Rust
Documentation
use anyhow::{Context, Result, anyhow, bail};
use std::{
    fs,
    path::{Path, PathBuf},
    process::Command,
};

/// Build a macro crate to WASM and post-process the output.
///
/// Steps:
/// 1. `cargo build --release --target wasm32-unknown-unknown`
/// 2. `wasm-bindgen --target nodejs --out-dir <pkg>`
/// 3. Query the manifest via a Deno/Node subprocess to discover Call macros
/// 4. Append `$`-prefixed re-exports for Call macros to the JS and .d.ts
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"));

    // Resolve the crate name from Cargo.toml for the WASM artifact path
    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());

    // Step 1: cargo build
    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");
    }

    // Step 2: wasm-bindgen
    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");
    }

    // Step 3: Discover Call macros from the manifest
    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(())
}

/// Discover Call macro names by parsing the generated `.d.ts` file.
///
/// Call macro no-ops are generated with signature `(value: any): any` while
/// Derive/Attribute macros have `(): void`. We also exclude internal exports
/// (prefixed with `__`) and known non-macro exports.
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();

    // Match: `export function name(value: any): any;`
    // This is the signature generated by the proc macro for Call macro no-ops.
    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;"))
        {
            // Skip internal exports
            if !name.starts_with('_') {
                call_macros.push(name.to_string());
            }
        }
    }

    Ok(call_macros)
}

/// Append `module.exports.$name = module.exports.name;` for each Call macro to the JS file.
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(())
}

/// Append `export { name as $name };` declarations to the .d.ts file.
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 {
        // Re-export with $ prefix and mirror the signature
        dts.push_str(&format!("export {{ {name} as ${name} }};\n"));
    }

    fs::write(dts_path, dts)?;
    Ok(())
}

/// Resolve the wasm-bindgen binary from PATH or wasm-pack cache.
fn resolve_wasm_bindgen() -> Result<String> {
    if let Ok(from_env) = std::env::var("WASM_BINDGEN") {
        return Ok(from_env);
    }

    // Check PATH
    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);
        }
    }

    // Check wasm-pack cache
    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)
}

/// Parse the crate name from a Cargo.toml string.
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
}