rustex_ts_analyzer/
lib.rs1use 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}