| //! Content-addressed cache for the assembled `rust-project.json`. |
| //! |
| //! Discovery has to read every per-crate spec file regardless, but the |
| //! consolidate + assemble + serialize step is short-circuited on a cache |
| //! hit. The key hashes every input — spec contents, toolchain info, |
| //! bazel/workspace/exec-root paths — so an entry is only served when |
| //! every input matches byte-for-byte. |
| |
| use std::{ |
| collections::hash_map::DefaultHasher, |
| fs, |
| hash::{Hash, Hasher}, |
| }; |
| |
| use anyhow::{Context, Result}; |
| use camino::{Utf8Path, Utf8PathBuf}; |
| |
| /// Cache subdir name, relative to the launcher dir. `setup --clean` |
| /// deletes `<launcher_dir>/CACHE_SUBDIR/` — importing the constant |
| /// keeps that path in sync if we ever rename. |
| pub const CACHE_SUBDIR: &str = "cache"; |
| |
| /// Where cache entries live, relative to the workspace root, when |
| /// discover falls back (no `$RULES_RUST_RA_CACHE_DIR` set — see |
| /// [`cache_dir`]). |
| /// |
| /// Editor-agnostic by design: the discover binary that produces these |
| /// entries is invoked the same way regardless of which editor's launcher |
| /// kicked it off (`.vscode/`, `.helix/`, `.rules_rust_analyzer/` — all |
| /// dispatch to the same `bazel-bin/.../discover_bazel_rust_project`), so |
| /// they share one cache. |
| /// |
| /// Living inside the workspace (not under Bazel's `output_base`) keeps |
| /// the cache visible to `ls`, easy to clear with `rm -rf`, and immune to |
| /// `bazel clean`. Making Bazel manage it would require turning it into |
| /// an action output, which has its own correctness headaches. |
| const CACHE_DIR_REL_PREFIX: &str = ".rules_rust_analyzer"; |
| |
| /// Schema version mixed into every cache key. Bump on any change to |
| /// the assembled `rust-project.json` shape — old cache entries become |
| /// ignored rather than served as stale. The cache key is content-keyed |
| /// on inputs (not on rules_rust source), so without this bump an |
| /// unchanged workspace would keep serving JSON assembled by an older |
| /// version of the assembler. See the explanatory comment on |
| /// `compute_key` for the incident that motivated this. |
| const CACHE_SCHEMA_VERSION: u32 = 1; |
| |
| /// Compute the cache key for an assembled rust-project.json from the raw |
| /// spec contents plus every auxiliary input the assembler bakes into its |
| /// output. Anything that changes the bytes of the JSON must factor into the |
| /// key, otherwise a stale entry could be served after the user moves to a |
| /// different bazel binary, workspace, or execution root. |
| /// |
| /// `spec_contents` should already be sorted by spec path so the key is |
| /// independent of file-system enumeration order. |
| pub fn compute_key( |
| spec_contents: &[(Utf8PathBuf, String)], |
| toolchain_info: &str, |
| bazel: &Utf8Path, |
| workspace: &Utf8Path, |
| execution_root: &Utf8Path, |
| launcher_dir: &str, |
| ) -> String { |
| let mut hasher = DefaultHasher::new(); |
| CACHE_SCHEMA_VERSION.hash(&mut hasher); |
| bazel.as_str().hash(&mut hasher); |
| workspace.as_str().hash(&mut hasher); |
| execution_root.as_str().hash(&mut hasher); |
| toolchain_info.hash(&mut hasher); |
| // Hashed because `flycheck_launcher_path` in `rust_project.rs` reads |
| // `$RULES_RUST_RA_LAUNCHER_DIR` to bake an absolute path into the |
| // assembled JSON. If we left this out, switching editors |
| // (vscode→neovim→helix) on the same workspace would silently serve |
| // the previously-cached editor's launcher path. |
| launcher_dir.hash(&mut hasher); |
| for (path, content) in spec_contents { |
| path.as_str().hash(&mut hasher); |
| content.hash(&mut hasher); |
| } |
| format!("{:016x}", hasher.finish()) |
| } |
| |
| /// Env var carrying the editor-specific install dir. Published by |
| /// `discover_rust_project::self_locate_config` from `current_exe()`'s |
| /// dirname; consumed by both `compute_key` (cache shard) and |
| /// `rust_project::flycheck_launcher_path` (path embedded in the |
| /// assembled JSON). Reading it once at the call site keeps both |
| /// readers consistent. |
| pub const LAUNCHER_DIR_ENV_VAR: &str = "RULES_RUST_RA_LAUNCHER_DIR"; |
| |
| /// Env var that overrides the cache directory location. Published by |
| /// `discover_rust_project::self_locate_config`; can also be set |
| /// manually for one-off runs. |
| const CACHE_DIR_ENV_VAR: &str = "RULES_RUST_RA_CACHE_DIR"; |
| |
| /// Resolve the cache directory. `$RULES_RUST_RA_CACHE_DIR` wins if set; |
| /// otherwise we fall back to `<workspace>/.rules_rust_analyzer/cache`. |
| /// |
| /// `discover_rust_project::self_locate_config` publishes the env var |
| /// from `current_exe()`'s dirname (the install dir setup copied us |
| /// into). This fallback only fires when discover is invoked from |
| /// somewhere other than an install dir — direct exec for debugging, |
| /// or the legacy `gen_rust_project` binary which doesn't self-locate. |
| pub fn cache_dir(workspace: &Utf8Path) -> Utf8PathBuf { |
| if let Ok(s) = std::env::var(CACHE_DIR_ENV_VAR) { |
| if !s.is_empty() { |
| return Utf8PathBuf::from(s); |
| } |
| } |
| workspace.join(CACHE_DIR_REL_PREFIX).join(CACHE_SUBDIR) |
| } |
| |
| fn cache_file(workspace: &Utf8Path, key: &str) -> Utf8PathBuf { |
| cache_dir(workspace).join(format!("{key}.json")) |
| } |
| |
| /// Returns the cached JSON bytes if the key is present. A miss returns |
| /// `Ok(None)`; only IO errors propagate. |
| pub fn get(workspace: &Utf8Path, key: &str) -> Result<Option<Vec<u8>>> { |
| let path = cache_file(workspace, key); |
| match fs::read(&path) { |
| Ok(bytes) => { |
| log::debug!("rust-analyzer merge cache hit: {path}"); |
| Ok(Some(bytes)) |
| } |
| Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), |
| Err(e) => Err(e).with_context(|| format!("reading cache file {path}")), |
| } |
| } |
| |
| /// Persist the assembled JSON bytes for the given key. Cache writes are |
| /// best-effort: a failure is logged but does not fail the caller, since the |
| /// JSON is also being returned to `rust-analyzer`. |
| pub fn put(workspace: &Utf8Path, key: &str, bytes: &[u8]) { |
| let dir = cache_dir(workspace); |
| if let Err(e) = fs::create_dir_all(&dir) { |
| log::warn!("rust-analyzer merge cache: create_dir_all {dir} failed: {e}"); |
| return; |
| } |
| let path = cache_file(workspace, key); |
| // Write to a temp file and rename so concurrent invocations don't observe |
| // a half-written cache entry. |
| let tmp = path.with_extension("json.tmp"); |
| if let Err(e) = fs::write(&tmp, bytes) { |
| log::warn!("rust-analyzer merge cache: write {tmp} failed: {e}"); |
| return; |
| } |
| if let Err(e) = fs::rename(&tmp, &path) { |
| log::warn!("rust-analyzer merge cache: rename {tmp} -> {path} failed: {e}"); |
| let _ = fs::remove_file(&tmp); |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| |
| #[test] |
| fn compute_key_is_stable() { |
| let bazel = Utf8Path::new("/usr/bin/bazel"); |
| let ws = Utf8Path::new("/ws"); |
| let er = Utf8Path::new("/er"); |
| let toolchain = "{\"sysroot\":\"a\"}"; |
| let specs = vec![ |
| ( |
| Utf8PathBuf::from("a/foo.json"), |
| String::from("{\"id\":\"a\"}"), |
| ), |
| ( |
| Utf8PathBuf::from("b/bar.json"), |
| String::from("{\"id\":\"b\"}"), |
| ), |
| ]; |
| let k1 = compute_key(&specs, toolchain, bazel, ws, er, ""); |
| let k2 = compute_key(&specs, toolchain, bazel, ws, er, ""); |
| assert_eq!(k1, k2); |
| } |
| |
| #[test] |
| fn compute_key_changes_with_content() { |
| let bazel = Utf8Path::new("/usr/bin/bazel"); |
| let ws = Utf8Path::new("/ws"); |
| let er = Utf8Path::new("/er"); |
| let toolchain = "{}"; |
| let base = vec![(Utf8PathBuf::from("a.json"), "a".to_string())]; |
| let mutated = vec![(Utf8PathBuf::from("a.json"), "b".to_string())]; |
| assert_ne!( |
| compute_key(&base, toolchain, bazel, ws, er, ""), |
| compute_key(&mutated, toolchain, bazel, ws, er, "") |
| ); |
| } |
| |
| #[test] |
| fn compute_key_changes_with_toolchain() { |
| let bazel = Utf8Path::new("/usr/bin/bazel"); |
| let ws = Utf8Path::new("/ws"); |
| let er = Utf8Path::new("/er"); |
| let specs = vec![(Utf8PathBuf::from("a.json"), "a".to_string())]; |
| assert_ne!( |
| compute_key(&specs, "{\"sysroot\":\"a\"}", bazel, ws, er, ""), |
| compute_key(&specs, "{\"sysroot\":\"b\"}", bazel, ws, er, ""), |
| ); |
| } |
| |
| #[test] |
| fn compute_key_changes_with_workspace() { |
| let bazel = Utf8Path::new("/usr/bin/bazel"); |
| let er = Utf8Path::new("/er"); |
| let specs = vec![(Utf8PathBuf::from("a.json"), "a".to_string())]; |
| assert_ne!( |
| compute_key(&specs, "{}", bazel, Utf8Path::new("/ws1"), er, ""), |
| compute_key(&specs, "{}", bazel, Utf8Path::new("/ws2"), er, ""), |
| ); |
| } |
| |
| #[test] |
| fn compute_key_changes_with_launcher_dir() { |
| // Editor switch on the same workspace must invalidate the |
| // cache, otherwise the assembled rust-project.json would still |
| // contain the previously-cached launcher path (see comment in |
| // `compute_key`). |
| let bazel = Utf8Path::new("/usr/bin/bazel"); |
| let ws = Utf8Path::new("/ws"); |
| let er = Utf8Path::new("/er"); |
| let specs = vec![(Utf8PathBuf::from("a.json"), "a".to_string())]; |
| assert_ne!( |
| compute_key( |
| &specs, |
| "{}", |
| bazel, |
| ws, |
| er, |
| "/ws/.vscode/.rules_rust_analyzer", |
| ), |
| compute_key( |
| &specs, |
| "{}", |
| bazel, |
| ws, |
| er, |
| "/ws/.helix/.rules_rust_analyzer", |
| ), |
| ); |
| } |
| } |