rustex-ts-analyzer 0.1.0

Bundled TypeScript analyzer bridge for Rustex.
Documentation
use anyhow::{Context, Result, bail};
use camino::Utf8Path;
use rustex_ir::IrPackage;
use sha2::{Digest, Sha256};
use std::{fs, path::PathBuf, process::Command};
use tracing::debug;
use walkdir::WalkDir;

static ANALYZER_BUNDLE: &[u8] = include_bytes!(env!("RUSTEX_TS_ANALYZER_BUNDLE"));
const ANALYZER_BUNDLE_SHA256: &str = env!("RUSTEX_TS_ANALYZER_BUNDLE_SHA256");

pub fn analyze(
    project_root: &Utf8Path,
    convex_root: &Utf8Path,
    allow_inferred_returns: bool,
) -> Result<IrPackage> {
    let _span = tracing::info_span!(
        "rustex_ts_analyzer.analyze",
        project_root = %project_root,
        convex_root = %convex_root,
        allow_inferred_returns
    )
    .entered();
    let script = materialize_analyzer_bundle(project_root)?;
    let node = find_node_binary()?;
    let cache_path = project_root.join(".rustex-cache").join("analyzer.json");
    let cache_key = snapshot_key(project_root, convex_root, allow_inferred_returns)?;

    if let Some(package) = load_cached(&cache_path, &cache_key)? {
        debug!(
            cache_path = %display_path(&cache_path, project_root),
            "using cached analyzer output"
        );
        return Ok(package);
    }

    let mut command = Command::new(node);
    command
        .arg(script.as_os_str())
        .arg("--project-root")
        .arg(project_root.as_str())
        .arg("--convex-root")
        .arg(convex_root.as_str());
    if allow_inferred_returns {
        command.arg("--allow-inferred-returns");
    }
    let output = command
        .output()
        .with_context(|| "failed to spawn Node analyzer")?;

    if !output.status.success() {
        bail!(
            "analyzer failed with status {}: {}",
            output.status,
            String::from_utf8_lossy(&output.stderr)
        );
    }

    let mut package: IrPackage = serde_json::from_slice(&output.stdout)
        .with_context(|| "failed to parse analyzer output")?;
    package.project.root = project_root.to_path_buf();
    package.project.convex_root = convex_root.to_path_buf();
    store_cached(&cache_path, &cache_key, &package)?;
    debug!(
        cache_path = %display_path(&cache_path, project_root),
        "stored analyzer output cache"
    );
    Ok(package)
}

fn materialize_analyzer_bundle(project_root: &Utf8Path) -> Result<PathBuf> {
    let bundle_dir = project_root.join(".rustex-cache").join("runtime");
    fs::create_dir_all(&bundle_dir).with_context(|| {
        format!(
            "failed to create analyzer runtime cache directory {}",
            bundle_dir
        )
    })?;
    let bundle_path = bundle_dir.join(format!("analyze-{ANALYZER_BUNDLE_SHA256}.cjs"));
    let bundle_path_std = bundle_path.as_std_path().to_path_buf();

    let should_write = match fs::read(&bundle_path_std) {
        Ok(existing) => existing != ANALYZER_BUNDLE,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
        Err(err) => {
            return Err(err)
                .with_context(|| format!("failed to read cached analyzer bundle {}", bundle_path));
        }
    };

    if should_write {
        fs::write(&bundle_path_std, ANALYZER_BUNDLE)
            .with_context(|| format!("failed to write analyzer bundle to {}", bundle_path))?;
    }

    Ok(bundle_path_std)
}

fn find_node_binary() -> Result<PathBuf> {
    if let Ok(explicit) = std::env::var("RUSTEX_NODE_BIN") {
        return verify_node_binary(PathBuf::from(explicit));
    }
    for candidate in ["node", "nodejs"] {
        if let Ok(path) = verify_node_binary(PathBuf::from(candidate)) {
            return Ok(path);
        }
    }
    bail!("failed to locate a usable Node.js binary; set RUSTEX_NODE_BIN or install node");
}

fn verify_node_binary(path: PathBuf) -> Result<PathBuf> {
    let output = Command::new(&path)
        .arg("--version")
        .output()
        .with_context(|| format!("failed to execute Node.js binary {}", path.display()))?;
    if output.status.success() {
        Ok(path)
    } else {
        bail!(
            "Node.js binary {} exited with status {}",
            path.display(),
            output.status
        )
    }
}

#[derive(serde::Serialize, serde::Deserialize)]
struct AnalyzerCache {
    key: String,
    package: IrPackage,
}

fn load_cached(cache_path: &Utf8Path, expected_key: &str) -> Result<Option<IrPackage>> {
    let Ok(raw) = std::fs::read_to_string(cache_path) else {
        return Ok(None);
    };
    let cache: AnalyzerCache = serde_json::from_str(&raw)?;
    if cache.key == expected_key {
        Ok(Some(cache.package))
    } else {
        Ok(None)
    }
}

fn store_cached(cache_path: &Utf8Path, key: &str, package: &IrPackage) -> Result<()> {
    if let Some(parent) = cache_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let cache = AnalyzerCache {
        key: key.to_string(),
        package: package.clone(),
    };
    std::fs::write(cache_path, serde_json::to_string(&cache)?)?;
    Ok(())
}

fn snapshot_key(
    project_root: &Utf8Path,
    convex_root: &Utf8Path,
    allow_inferred_returns: bool,
) -> Result<String> {
    let mut hasher = Sha256::new();
    hasher.update(project_root.as_str());
    hasher.update(convex_root.as_str());
    hasher.update(if allow_inferred_returns { b"1" } else { b"0" });
    hasher.update(ANALYZER_BUNDLE_SHA256.as_bytes());

    for entry in WalkDir::new(convex_root)
        .sort_by_file_name()
        .into_iter()
        .filter_map(Result::ok)
        .filter(|entry| entry.file_type().is_file())
    {
        let path = entry.path();
        let metadata = entry.metadata()?;
        hasher.update(path.to_string_lossy().as_bytes());
        hasher.update(metadata.len().to_le_bytes());
        if let Ok(modified) = metadata.modified() {
            if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
                hasher.update(duration.as_secs().to_le_bytes());
                hasher.update(duration.subsec_nanos().to_le_bytes());
            }
        }
    }

    let config_path = project_root.join("rustex.toml");
    if let Ok(bytes) = std::fs::read(&config_path) {
        hasher.update(bytes);
    }

    Ok(format!("{:x}", hasher.finalize()))
}

fn display_path(path: &Utf8Path, project_root: &Utf8Path) -> String {
    path.strip_prefix(project_root)
        .map(Utf8Path::to_string)
        .unwrap_or_else(|_| path.to_string())
}