blob: 8dbfbe76bf5a1a00309ff1e0ef296bef1648e8aa [file]
//! 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",
),
);
}
}