Skip to main content

rustex_ts_analyzer/
lib.rs

1use anyhow::{Context, Result, bail};
2use camino::Utf8Path;
3use rustex_ir::IrPackage;
4use sha2::{Digest, Sha256};
5use std::{fs, path::PathBuf, process::Command};
6use tracing::debug;
7use walkdir::WalkDir;
8
9static ANALYZER_BUNDLE: &[u8] = include_bytes!(env!("RUSTEX_TS_ANALYZER_BUNDLE"));
10const ANALYZER_BUNDLE_SHA256: &str = env!("RUSTEX_TS_ANALYZER_BUNDLE_SHA256");
11
12pub fn analyze(
13    project_root: &Utf8Path,
14    convex_root: &Utf8Path,
15    allow_inferred_returns: bool,
16) -> Result<IrPackage> {
17    let _span = tracing::info_span!(
18        "rustex_ts_analyzer.analyze",
19        project_root = %project_root,
20        convex_root = %convex_root,
21        allow_inferred_returns
22    )
23    .entered();
24    let script = materialize_analyzer_bundle(project_root)?;
25    let node = find_node_binary()?;
26    let cache_path = project_root.join(".rustex-cache").join("analyzer.json");
27    let cache_key = snapshot_key(project_root, convex_root, allow_inferred_returns)?;
28
29    if let Some(package) = load_cached(&cache_path, &cache_key)? {
30        debug!(
31            cache_path = %display_path(&cache_path, project_root),
32            "using cached analyzer output"
33        );
34        return Ok(package);
35    }
36
37    let mut command = Command::new(node);
38    command
39        .arg(script.as_os_str())
40        .arg("--project-root")
41        .arg(project_root.as_str())
42        .arg("--convex-root")
43        .arg(convex_root.as_str());
44    if allow_inferred_returns {
45        command.arg("--allow-inferred-returns");
46    }
47    let output = command
48        .output()
49        .with_context(|| "failed to spawn Node analyzer")?;
50
51    if !output.status.success() {
52        bail!(
53            "analyzer failed with status {}: {}",
54            output.status,
55            String::from_utf8_lossy(&output.stderr)
56        );
57    }
58
59    let mut package: IrPackage = serde_json::from_slice(&output.stdout)
60        .with_context(|| "failed to parse analyzer output")?;
61    package.project.root = project_root.to_path_buf();
62    package.project.convex_root = convex_root.to_path_buf();
63    store_cached(&cache_path, &cache_key, &package)?;
64    debug!(
65        cache_path = %display_path(&cache_path, project_root),
66        "stored analyzer output cache"
67    );
68    Ok(package)
69}
70
71fn materialize_analyzer_bundle(project_root: &Utf8Path) -> Result<PathBuf> {
72    let bundle_dir = project_root.join(".rustex-cache").join("runtime");
73    fs::create_dir_all(&bundle_dir).with_context(|| {
74        format!(
75            "failed to create analyzer runtime cache directory {}",
76            bundle_dir
77        )
78    })?;
79    let bundle_path = bundle_dir.join(format!("analyze-{ANALYZER_BUNDLE_SHA256}.cjs"));
80    let bundle_path_std = bundle_path.as_std_path().to_path_buf();
81
82    let should_write = match fs::read(&bundle_path_std) {
83        Ok(existing) => existing != ANALYZER_BUNDLE,
84        Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
85        Err(err) => {
86            return Err(err)
87                .with_context(|| format!("failed to read cached analyzer bundle {}", bundle_path));
88        }
89    };
90
91    if should_write {
92        fs::write(&bundle_path_std, ANALYZER_BUNDLE)
93            .with_context(|| format!("failed to write analyzer bundle to {}", bundle_path))?;
94    }
95
96    Ok(bundle_path_std)
97}
98
99fn find_node_binary() -> Result<PathBuf> {
100    if let Ok(explicit) = std::env::var("RUSTEX_NODE_BIN") {
101        return verify_node_binary(PathBuf::from(explicit));
102    }
103    for candidate in ["node", "nodejs"] {
104        if let Ok(path) = verify_node_binary(PathBuf::from(candidate)) {
105            return Ok(path);
106        }
107    }
108    bail!("failed to locate a usable Node.js binary; set RUSTEX_NODE_BIN or install node");
109}
110
111fn verify_node_binary(path: PathBuf) -> Result<PathBuf> {
112    let output = Command::new(&path)
113        .arg("--version")
114        .output()
115        .with_context(|| format!("failed to execute Node.js binary {}", path.display()))?;
116    if output.status.success() {
117        Ok(path)
118    } else {
119        bail!(
120            "Node.js binary {} exited with status {}",
121            path.display(),
122            output.status
123        )
124    }
125}
126
127#[derive(serde::Serialize, serde::Deserialize)]
128struct AnalyzerCache {
129    key: String,
130    package: IrPackage,
131}
132
133fn load_cached(cache_path: &Utf8Path, expected_key: &str) -> Result<Option<IrPackage>> {
134    let Ok(raw) = std::fs::read_to_string(cache_path) else {
135        return Ok(None);
136    };
137    let cache: AnalyzerCache = serde_json::from_str(&raw)?;
138    if cache.key == expected_key {
139        Ok(Some(cache.package))
140    } else {
141        Ok(None)
142    }
143}
144
145fn store_cached(cache_path: &Utf8Path, key: &str, package: &IrPackage) -> Result<()> {
146    if let Some(parent) = cache_path.parent() {
147        std::fs::create_dir_all(parent)?;
148    }
149    let cache = AnalyzerCache {
150        key: key.to_string(),
151        package: package.clone(),
152    };
153    std::fs::write(cache_path, serde_json::to_string(&cache)?)?;
154    Ok(())
155}
156
157fn snapshot_key(
158    project_root: &Utf8Path,
159    convex_root: &Utf8Path,
160    allow_inferred_returns: bool,
161) -> Result<String> {
162    let mut hasher = Sha256::new();
163    hasher.update(project_root.as_str());
164    hasher.update(convex_root.as_str());
165    hasher.update(if allow_inferred_returns { b"1" } else { b"0" });
166    hasher.update(ANALYZER_BUNDLE_SHA256.as_bytes());
167
168    for entry in WalkDir::new(convex_root)
169        .sort_by_file_name()
170        .into_iter()
171        .filter_map(Result::ok)
172        .filter(|entry| entry.file_type().is_file())
173    {
174        let path = entry.path();
175        let metadata = entry.metadata()?;
176        hasher.update(path.to_string_lossy().as_bytes());
177        hasher.update(metadata.len().to_le_bytes());
178        if let Ok(modified) = metadata.modified() {
179            if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
180                hasher.update(duration.as_secs().to_le_bytes());
181                hasher.update(duration.subsec_nanos().to_le_bytes());
182            }
183        }
184    }
185
186    let config_path = project_root.join("rustex.toml");
187    if let Ok(bytes) = std::fs::read(&config_path) {
188        hasher.update(bytes);
189    }
190
191    Ok(format!("{:x}", hasher.finalize()))
192}
193
194fn display_path(path: &Utf8Path, project_root: &Utf8Path) -> String {
195    path.strip_prefix(project_root)
196        .map(Utf8Path::to_string)
197        .unwrap_or_else(|_| path.to_string())
198}