rustex-ts-analyzer 0.1.0

Bundled TypeScript analyzer bridge for Rustex.
Documentation
use std::{
    env,
    ffi::OsStr,
    fs,
    path::{Path, PathBuf},
    process::Command,
};

fn main() {
    if let Err(error) = build() {
        panic!("failed to build TypeScript analyzer: {error}");
    }
}

fn build() -> Result<(), String> {
    let manifest_dir =
        PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?);
    let package_dir = manifest_dir.join("../../packages/ts-analyzer");
    let vendored_entrypoint = manifest_dir.join("vendor/analyze.cjs");
    let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?);
    let analyzer_out_dir = out_dir.join("ts-analyzer");

    if !package_dir.join("src/analyze.ts").is_file() {
        return use_vendored_bundle(&vendored_entrypoint);
    }

    println!(
        "cargo:rerun-if-changed={}",
        package_dir.join("src/analyze.ts").display()
    );
    println!(
        "cargo:rerun-if-changed={}",
        package_dir.join("package.json").display()
    );
    println!(
        "cargo:rerun-if-changed={}",
        package_dir.join("pnpm-lock.yaml").display()
    );
    println!(
        "cargo:rerun-if-changed={}",
        package_dir.join("tsconfig.build.json").display()
    );
    println!("cargo:rerun-if-changed={}", vendored_entrypoint.display());

    let _node = find_command("RUSTEX_NODE_BIN", &["node", "nodejs"])?;
    ensure_node_modules(&package_dir)?;
    let esbuild = esbuild_path(&package_dir);
    if !esbuild.is_file() {
        return Err(format!(
            "esbuild executable not found at {} after dependency install",
            esbuild.display()
        ));
    }

    std::fs::create_dir_all(&analyzer_out_dir)
        .map_err(|err| format!("failed to create analyzer output directory: {err}"))?;
    let entrypoint = analyzer_out_dir.join("analyze.cjs");

    run_command(
        &esbuild,
        &[
            package_dir.join("src/analyze.ts").as_os_str(),
            OsStr::new("--bundle"),
            OsStr::new("--minify"),
            OsStr::new("--platform=node"),
            OsStr::new("--format=cjs"),
            OsStr::new("--target=node20"),
            OsStr::new(&format!("--outfile={}", entrypoint.display())),
        ],
        Some(&package_dir),
        "failed to bundle TypeScript analyzer",
    )?;

    if !entrypoint.is_file() {
        return Err(format!(
            "expected bundled analyzer entrypoint at {}",
            entrypoint.display()
        ));
    }
    let bundle =
        fs::read(&entrypoint).map_err(|err| format!("failed to read bundled analyzer: {err}"))?;
    let bundle_hash = sha256_hex(&bundle);

    println!(
        "cargo:rustc-env=RUSTEX_TS_ANALYZER_BUNDLE={}",
        entrypoint.display()
    );
    println!("cargo:rustc-env=RUSTEX_TS_ANALYZER_BUNDLE_SHA256={bundle_hash}");
    Ok(())
}

fn use_vendored_bundle(entrypoint: &Path) -> Result<(), String> {
    println!("cargo:rerun-if-changed={}", entrypoint.display());
    if !entrypoint.is_file() {
        return Err(format!(
            "vendored TypeScript analyzer bundle is missing at {}",
            entrypoint.display()
        ));
    }
    let bundle =
        fs::read(entrypoint).map_err(|err| format!("failed to read vendored analyzer: {err}"))?;
    let bundle_hash = sha256_hex(&bundle);
    println!(
        "cargo:rustc-env=RUSTEX_TS_ANALYZER_BUNDLE={}",
        entrypoint.display()
    );
    println!("cargo:rustc-env=RUSTEX_TS_ANALYZER_BUNDLE_SHA256={bundle_hash}");
    Ok(())
}

fn ensure_node_modules(package_dir: &Path) -> Result<(), String> {
    let required = [
        package_dir.join("node_modules/typescript/lib/tsc.js"),
        esbuild_path(package_dir),
        package_dir.join("node_modules/effect/package.json"),
        package_dir.join("node_modules/@effect/cli/package.json"),
        package_dir.join("node_modules/@effect/printer/package.json"),
        package_dir.join("node_modules/@effect/printer-ansi/package.json"),
    ];
    if required.iter().all(|path| path.is_file()) {
        return Ok(());
    }

    let pnpm = find_command("RUSTEX_PNPM_BIN", &["pnpm", "pnpm.cmd"])?;
    run_command(
        &pnpm,
        &[
            OsStr::new("install"),
            OsStr::new("--frozen-lockfile"),
            OsStr::new("--ignore-workspace"),
        ],
        Some(package_dir),
        "failed to install ts-analyzer pnpm dependencies",
    )?;

    if required.iter().all(|path| path.is_file()) {
        Ok(())
    } else {
        Err(format!(
            "pnpm install completed but ts-analyzer dependencies are still missing under {}",
            package_dir.join("node_modules").display()
        ))
    }
}

fn esbuild_path(package_dir: &Path) -> PathBuf {
    if cfg!(windows) {
        package_dir.join("node_modules/.bin/esbuild.cmd")
    } else {
        package_dir.join("node_modules/.bin/esbuild")
    }
}

fn find_command(env_var: &str, candidates: &[&str]) -> Result<PathBuf, String> {
    if let Ok(explicit) = env::var(env_var) {
        let path = PathBuf::from(explicit);
        return verify_command(&path)
            .map(|_| path)
            .map_err(|err| format!("{env_var} points to an unusable executable: {err}"));
    }

    for candidate in candidates {
        let path = PathBuf::from(candidate);
        if verify_command(&path).is_ok() {
            return Ok(path);
        }
    }

    Err(format!(
        "could not find a usable command for {} (tried: {})",
        env_var,
        candidates.join(", ")
    ))
}

fn verify_command(command: &Path) -> Result<(), String> {
    let output = Command::new(command)
        .arg("--version")
        .output()
        .map_err(|err| err.to_string())?;
    if output.status.success() {
        Ok(())
    } else {
        Err(format!("command exited with status {}", output.status))
    }
}

fn run_command(
    program: &Path,
    args: &[&OsStr],
    cwd: Option<&Path>,
    context: &str,
) -> Result<(), String> {
    let mut command = Command::new(program);
    command.args(args);
    if let Some(cwd) = cwd {
        command.current_dir(cwd);
    }
    let output = command
        .output()
        .map_err(|err| format!("{context}: {err}"))?;
    if output.status.success() {
        return Ok(());
    }

    Err(format!(
        "{context}: status {}\nstdout:\n{}\nstderr:\n{}",
        output.status,
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    ))
}

fn sha256_hex(bytes: &[u8]) -> String {
    use std::fmt::Write as _;

    let digest = {
        use sha2::Digest as _;
        sha2::Sha256::digest(bytes)
    };
    let mut out = String::with_capacity(digest.len() * 2);
    for byte in digest {
        let _ = write!(&mut out, "{byte:02x}");
    }
    out
}