blob: 71691e7fa9d603970e6057d27022da09edd7f731 [file]
mod aquery;
pub mod bep;
mod cache;
mod rust_project;
pub mod user_config;
use std::{collections::BTreeMap, convert::TryFrom, fs, process::Command};
use anyhow::{bail, Context};
use camino::{Utf8Path, Utf8PathBuf};
use rust_project::RustProject;
pub use rust_project::{
assemble_rust_project, diagnose, format_diagnostics, AssemblyDiagnostics, DiscoverProject,
RustAnalyzerArg,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
pub use aquery::{consolidate_crate_specs, CrateSpec};
pub use cache::CACHE_SUBDIR;
/// Written by discover, read by `bin/flycheck.rs`.
pub const TOOLCHAIN_INFO_SIDECAR: &str = "toolchain_info.json";
/// Suffix appended to the outer server's `output_base` to derive the
/// flycheck server's own `--output_base` sibling. Kept as a shared
/// constant so `flycheck.rs`'s derivation and `setup.rs`'s
/// `--clean --expunge` target can never drift out of sync.
pub const RRA_OUTPUT_BASE_SUFFIX: &str = "_rra";
/// The `--output_base` flycheck should use given the outer server's
/// `output_base`. Both bases live under the same `output_user_root`
/// and share its `install/` extraction.
pub fn flycheck_output_base(outer: &Utf8Path) -> Utf8PathBuf {
let mut sibling = outer.as_str().to_owned();
sibling.push_str(RRA_OUTPUT_BASE_SUFFIX);
Utf8PathBuf::from(sibling)
}
/// Every field is `Option` / `default` so a newer flycheck reading
/// an older sidecar falls back to slow paths instead of failing.
#[derive(Debug, Serialize, Deserialize)]
pub struct ToolchainInfoSidecar {
pub sysroot_src: Utf8PathBuf,
#[serde(default)]
pub workspace: Option<Utf8PathBuf>,
/// The outer Bazel server's `output_base` at the time discover
/// last ran. Flycheck uses `<output_base>_rra` as its own
/// `--output_base`, so the flycheck server sits next to the user's
/// primary server and shares its `install/` extraction while still
/// holding a distinct server lock.
#[serde(default)]
pub output_base: Option<Utf8PathBuf>,
/// Saved-file → label map for flycheck's `--saved-file` mode.
/// Missing entries fall back to `bazel query`.
#[serde(default)]
pub file_labels: BTreeMap<Utf8PathBuf, String>,
}
/// Cached `bazel info` snapshot against flycheck's dedicated server.
/// Setup pre-populates on install (one `bazel info` per setup run);
/// flycheck reads and refreshes only when the cache key (`output_base`)
/// no longer matches the currently-derived flycheck output_base — so
/// on the steady-state clippy path, saves cost zero bazel invocations.
///
/// Shape mirrors `tools/vscode/src/lib.rs::BazelInfo` (intentionally
/// duplicated so the two tools stay decoupled), plus `execution_root`
/// which flycheck needs to resolve BEP-relative clippy output paths
/// (rules_rust#4130).
pub const BAZEL_INFO_FILENAME: &str = "bazel_info.json";
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct BazelInfo {
pub output_base: Utf8PathBuf,
pub workspace: Utf8PathBuf,
pub execution_root: Utf8PathBuf,
}
impl BazelInfo {
/// Populate by invoking `bazel info` once against the server at
/// `output_base` (typically flycheck's `_rra` base). Fails if
/// `bazel info` doesn't return all three expected fields — that
/// would leave `execution_root` unresolvable and the clippy path
/// silently broken.
pub fn try_new(
bazel: &Utf8Path,
workspace: &Utf8Path,
output_base: &Utf8Path,
) -> anyhow::Result<Self> {
let mut info = bazel_info(bazel, Some(workspace), Some(output_base), &[], &[])
.context("bazel info for flycheck server cache")?;
Ok(Self {
output_base: info
.remove("output_base")
.context("`bazel info` returned no `output_base` line")?
.into(),
workspace: info
.remove("workspace")
.context("`bazel info` returned no `workspace` line")?
.into(),
execution_root: info
.remove("execution_root")
.context("`bazel info` returned no `execution_root` line")?
.into(),
})
}
/// Read the cache; missing / malformed → `None`.
pub fn load(launcher_dir: &Utf8Path) -> Option<Self> {
let path = launcher_dir.join(BAZEL_INFO_FILENAME);
let bytes = fs::read(&path).ok()?;
serde_json::from_slice(&bytes).ok()
}
/// Best-effort persist; a write failure just means the next
/// consumer refreshes from `bazel info` again.
pub fn save(&self, launcher_dir: &Utf8Path) {
let path = launcher_dir.join(BAZEL_INFO_FILENAME);
if let Ok(bytes) = serde_json::to_vec(self) {
let _ = fs::write(&path, &bytes);
}
}
}
pub const WORKSPACE_ROOT_FILE_NAMES: &[&str] =
&["MODULE.bazel", "REPO.bazel", "WORKSPACE.bazel", "WORKSPACE"];
pub const BUILD_FILE_NAMES: &[&str] = &["BUILD.bazel", "BUILD"];
/// `.exe` on every platform: Node spawn (the RA VS Code extension)
/// can't execute extensionless files on Windows without `shell: true`;
/// POSIX `execve` ignores extensions.
pub const FLYCHECK_BINARY_FILENAME: &str = "flycheck.exe";
pub const DISCOVER_BINARY_FILENAME: &str = "discover_bazel_rust_project.exe";
/// Env var wired via the `:toolchain_info_env` rule so the JSON path
/// goes through Bazel's `Args` (path-mapping-aware); a plain
/// `rustc_env` `$(execpath ...)` misses the rewrite under
/// `--experimental_output_paths=strip`. Placeholders resolved by
/// `deserialize_with_substitution`.
const TOOLCHAIN_INFO_RAW: &str = include_str!(env!("RUST_ANALYZER_TOOLCHAIN_JSON"));
/// `dirname(current_exe())`. Uses `current_exe` not `argv[0]` because
/// the install is a real `fs::copy` — the runfiles crate's
/// argv[0]-first policy doesn't apply post-install.
pub fn install_dir() -> anyhow::Result<Utf8PathBuf> {
let exe = std::env::current_exe().context("locating current_exe")?;
let parent = exe
.parent()
.with_context(|| format!("current_exe has no parent: {}", exe.display()))?;
Utf8PathBuf::from_path_buf(parent.to_path_buf())
.map_err(|p| anyhow::anyhow!("install dir is not valid UTF-8: {}", p.display()))
}
#[allow(clippy::too_many_arguments)]
pub fn generate_rust_project(
bazel: &Utf8Path,
output_base: &Utf8Path,
workspace: &Utf8Path,
execution_root: &Utf8Path,
bazel_startup_options: &[String],
bazel_args: &[String],
rules_rust_name: &str,
targets: &[String],
) -> anyhow::Result<RustProject> {
// Discover specs as a build side-effect via BEP. Replaces a
// separate `bazel aquery` — the dominant cost in a large monorepo.
let bep_file = output_base.join(format!("rules_rust_ra_bep_{}.json", std::process::id()));
let _bep_cleanup = BepCleanup(bep_file.clone());
let build = generate_crate_info(
bazel,
output_base,
workspace,
bazel_startup_options,
bazel_args,
rules_rust_name,
targets,
&bep_file,
)?;
let spec_paths = match bep::parse_spec_paths(&bep_file, execution_root) {
Ok(paths) => paths,
// Missing/partial BEP usually means the build failed; surface
// that rather than the parse error.
Err(_) if !build.success => {
bail!(
"bazel build failed and produced no usable output:\n{}",
build.stderr
)
}
Err(e) => return Err(e).with_context(|| format!("parsing BEP file {bep_file}")),
};
if assess_discovery(build.success, spec_paths.len(), &build.stderr)? {
log::warn!(
"some targets failed to build; the rust-analyzer project may be \
incomplete. Run `bazel build //...` to see the errors."
);
}
log::info!("discovered {} crate spec files via BEP", spec_paths.len());
let toolchain_info_raw = TOOLCHAIN_INFO_RAW;
let spec_contents = read_specs(&spec_paths)?;
let launcher_dir = std::env::var(cache::LAUNCHER_DIR_ENV_VAR).unwrap_or_default();
let cache_key = cache::compute_key(
&spec_contents,
toolchain_info_raw,
bazel,
workspace,
execution_root,
&launcher_dir,
);
if let Some(bytes) = cache::get(workspace, &cache_key)? {
match serde_json::from_slice::<RustProject>(&bytes) {
Ok(project) => {
log::info!("merge cache hit ({cache_key}); skipping assembly");
return Ok(project);
}
Err(e) => {
log::warn!("merge cache entry {cache_key} corrupted ({e}); recomputing");
}
}
} else {
log::info!("merge cache miss ({cache_key}); assembling");
}
let toolchain_info: ToolchainInfo =
deserialize_with_substitution(toolchain_info_raw, output_base, workspace, execution_root)
.context("parsing embedded toolchain info JSON")?;
let crate_specs =
parse_and_consolidate(&spec_contents, output_base, workspace, execution_root)?;
// Publish sysroot_src + workspace + output_base + saved-file→label
// map to `<launcher_dir>/toolchain_info.json`. Flycheck reads this
// to un-remap rustc's `/rustc/<sha>/library/...` diagnostic paths,
// pick the right workspace root when rust-analyzer spawns it with
// cwd inside a package, derive its `<output_base>_rra` server
// location, and skip a `bazel query` when it already knows which
// target owns the saved file. Best-effort — a write failure just
// means flycheck falls back to the slow paths.
if !launcher_dir.is_empty() {
write_toolchain_info_sidecar(
Utf8Path::new(&launcher_dir),
&toolchain_info.sysroot_src,
workspace,
output_base,
build_file_label_map(&crate_specs),
);
}
let project =
rust_project::assemble_rust_project(bazel, workspace, toolchain_info, &crate_specs)?;
// Log warnings AND persist to disk — progress events scroll off
// the status bar before anyone reads them.
report_diagnostics(workspace, &crate_specs);
match serde_json::to_vec(&project) {
Ok(bytes) => cache::put(workspace, &cache_key, &bytes),
Err(e) => log::warn!("merge cache: serializing project failed ({e}); not caching"),
}
Ok(project)
}
const WARNINGS_LOG_REL: &str = ".vscode/.rules_rust_analyzer/last_warnings.log";
/// Publish sysroot_src, workspace, output_base, and the saved-file→
/// label map to `<launcher_dir>/toolchain_info.json` for flycheck.
/// Best-effort: a write failure just means flycheck falls back to
/// slow per-save paths (bazel query, cwd guess, `bazel info output_base`).
fn write_toolchain_info_sidecar(
launcher_dir: &Utf8Path,
sysroot_src: &Utf8Path,
workspace: &Utf8Path,
output_base: &Utf8Path,
file_labels: BTreeMap<Utf8PathBuf, String>,
) {
let sidecar = ToolchainInfoSidecar {
sysroot_src: sysroot_src.to_path_buf(),
workspace: Some(workspace.to_path_buf()),
output_base: Some(output_base.to_path_buf()),
file_labels,
};
let path = launcher_dir.join(TOOLCHAIN_INFO_SIDECAR);
match serde_json::to_vec(&sidecar) {
Ok(bytes) => {
if let Err(e) = fs::write(&path, &bytes) {
log::warn!("toolchain_info sidecar: writing {path}: {e}");
}
}
Err(e) => log::warn!("toolchain_info sidecar: serializing failed: {e}"),
}
}
/// Map each spec's `root_module` to its label. Multi-file siblings
/// fall back to `bazel query`.
fn build_file_label_map(
crate_specs: &std::collections::BTreeSet<CrateSpec>,
) -> BTreeMap<Utf8PathBuf, String> {
let mut out = BTreeMap::new();
for spec in crate_specs {
let Some(build) = spec.build.as_ref() else {
continue;
};
out.insert(Utf8PathBuf::from(&spec.root_module), build.label.clone());
}
out
}
fn report_diagnostics(workspace: &Utf8Path, crate_specs: &std::collections::BTreeSet<CrateSpec>) {
let diag = rust_project::diagnose(crate_specs);
let log_path = workspace.join(WARNINGS_LOG_REL);
if diag.is_empty() {
// Absence-of-file signals "clean this round".
let _ = fs::remove_file(&log_path);
return;
}
let report = rust_project::format_diagnostics(&diag);
for line in report.lines() {
log::warn!("{line}");
}
if let Some(parent) = log_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::write(&log_path, &report) {
log::warn!("could not write {log_path}: {e}");
}
}
fn read_specs(spec_paths: &[Utf8PathBuf]) -> anyhow::Result<Vec<(Utf8PathBuf, String)>> {
spec_paths
.iter()
.map(|p| {
let content =
fs::read_to_string(p).with_context(|| format!("reading crate spec {p}"))?;
Ok((p.clone(), content))
})
.collect()
}
fn parse_and_consolidate(
spec_contents: &[(Utf8PathBuf, String)],
output_base: &Utf8Path,
workspace: &Utf8Path,
execution_root: &Utf8Path,
) -> anyhow::Result<std::collections::BTreeSet<CrateSpec>> {
let crate_specs = spec_contents
.iter()
.map(|(path, content)| {
deserialize_with_substitution::<CrateSpec>(
content,
output_base,
workspace,
execution_root,
)
.with_context(|| format!("deserializing crate spec {path}"))
})
.collect::<anyhow::Result<Vec<_>>>()?;
consolidate_crate_specs(crate_specs)
}
/// Best-effort cleanup of the temporary BEP file Bazel wrote.
struct BepCleanup(Utf8PathBuf);
impl Drop for BepCleanup {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
/// Executes `bazel info` to get a map of context information.
pub fn bazel_info(
bazel: &Utf8Path,
workspace: Option<&Utf8Path>,
output_base: Option<&Utf8Path>,
bazel_startup_options: &[String],
bazel_args: &[String],
) -> anyhow::Result<BTreeMap<String, String>> {
let output = bazel_command(bazel, workspace, output_base)
.args(bazel_startup_options)
.arg("info")
.args(bazel_args)
.output()?;
if !output.status.success() {
let status = output.status;
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("bazel info failed: ({status:?})\n{stderr}");
}
// Extract and parse the output.
let info_map = String::from_utf8(output.stdout)?
.trim()
.split('\n')
.filter_map(|line| line.split_once(':'))
.map(|(k, v)| (k.to_owned(), v.trim().to_owned()))
.collect();
Ok(info_map)
}
/// Result of the discovery build. With `--keep_going` a non-`success` exit
/// isn't fatal — the caller proceeds if the BEP still yielded specs.
struct DiscoveryBuild {
success: bool,
stderr: String,
}
#[allow(clippy::too_many_arguments)]
fn generate_crate_info(
bazel: &Utf8Path,
output_base: &Utf8Path,
workspace: &Utf8Path,
bazel_startup_options: &[String],
bazel_args: &[String],
rules_rust: &str,
targets: &[String],
bep_file: &Utf8Path,
) -> anyhow::Result<DiscoveryBuild> {
log::info!("running bazel build with BEP discovery...");
log::debug!("Building rust_analyzer_crate_spec files for {:?}", targets);
let output = bazel_command(bazel, Some(workspace), Some(output_base))
.args(bazel_startup_options)
.arg("build")
.args(bazel_args)
// Don't let one broken target abort discovery for the whole
// workspace; the caller decides if the partial result is usable.
.arg("--keep_going")
.arg("--norun_validations")
.arg("--remote_download_all")
.arg(format!(
"--aspects={rules_rust}//rust:defs.bzl%rust_analyzer_aspect"
))
.arg("--output_groups=rust_analyzer_crate_spec,rust_generated_srcs,rust_analyzer_proc_macro_dylib,rust_analyzer_src")
.arg(format!("--build_event_json_file={bep_file}"))
// Make BEP report URIs whether or not the file lives in the local
// execution root, so bep.rs has a consistent shape to parse.
.arg("--build_event_publish_all_actions")
.args(targets)
.output()?;
let success = output.status.success();
if success {
log::info!("bazel build finished");
}
Ok(DiscoveryBuild {
success,
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
/// `Ok(true)` = usable but some targets failed (caller warns); `Ok(false)` =
/// clean; `Err` = nothing usable was produced.
fn assess_discovery(success: bool, spec_count: usize, stderr: &str) -> anyhow::Result<bool> {
match (success, spec_count) {
(true, _) => Ok(false),
(false, 0) => bail!("bazel build failed and produced no crate specs:\n{stderr}"),
(false, _) => Ok(true),
}
}
pub fn bazel_command(
bazel: &Utf8Path,
workspace: Option<&Utf8Path>,
output_base: Option<&Utf8Path>,
) -> Command {
let mut cmd = Command::new(bazel);
cmd
// Switch to the workspace directory if one was provided.
.current_dir(workspace.unwrap_or(Utf8Path::new(".")))
.env_remove("BAZELISK_SKIP_WRAPPER")
.env_remove("BUILD_WORKING_DIRECTORY")
.env_remove("BUILD_WORKSPACE_DIRECTORY")
// Set the output_base if one was provided.
.args(output_base.map(|s| format!("--output_base={s}")));
cmd
}
fn deserialize_with_substitution<T>(
content: &str,
output_base: &Utf8Path,
workspace: &Utf8Path,
execution_root: &Utf8Path,
) -> anyhow::Result<T>
where
T: DeserializeOwned,
{
let content = content
.replace("__WORKSPACE__", workspace.as_str())
.replace("${pwd}", execution_root.as_str())
.replace("__EXEC_ROOT__", execution_root.as_str())
.replace("__OUTPUT_BASE__", output_base.as_str())
// exec_root/external contains symlinks into the output_base/external
// but those symlinks are ephemeral. if we do not replace here, goto
// definition can fail because those symlinks would no longer be found.
.replace(
&format!("{execution_root}/external/"),
&format!("{output_base}/external/"),
);
serde_json::from_str(&content).context("failed to deserialize after template substitution")
}
/// `rust-analyzer` associates workspaces with buildfiles. Therefore, when it passes in a
/// source file path, we use this function to identify the buildfile the file belongs to.
fn source_file_to_buildfile(file: &Utf8Path) -> anyhow::Result<Utf8PathBuf> {
// Skip the first element as it's always the full file path.
file.ancestors()
.skip(1)
.flat_map(|dir| BUILD_FILE_NAMES.iter().map(move |build| dir.join(build)))
.find(|p| p.exists())
.with_context(|| format!("no buildfile found for {file}"))
}
fn buildfile_to_targets(workspace: &Utf8Path, buildfile: &Utf8Path) -> anyhow::Result<String> {
log::info!("getting targets for buildfile: {buildfile}");
let parent_dir = buildfile
.strip_prefix(workspace)
.with_context(|| format!("{buildfile} not part of workspace"))?
.parent();
let targets = match parent_dir {
Some(p) if !p.as_str().is_empty() => format!("//{}:all", dir_to_bazel_package(p.as_str())),
_ => "//...".to_string(),
};
Ok(targets)
}
/// Convert a workspace-relative directory path to a Bazel package
/// segment (always `/`-separated).
///
/// On Windows, `Utf8Path::strip_prefix(...).parent()` returns a path
/// with native `\` separators, which Bazel rejects with "invalid
/// package name 'foo\\bar\\baz'". This normalization makes the resulting
/// Bazel target pattern valid on both platforms.
fn dir_to_bazel_package(dir: &str) -> String {
dir.replace('\\', "/")
}
#[derive(Debug, Deserialize)]
pub struct ToolchainInfo {
pub sysroot: Utf8PathBuf,
pub sysroot_src: Utf8PathBuf,
/// Declared rust-analyzer version (e.g. `"1.96.0"`). Empty when the
/// toolchain rule didn't set it (user-supplied
/// `rust_analyzer_toolchain` that omits the attribute). Consumers
/// should treat empty as "assume oldest supported" to avoid emitting
/// features that newer rust-analyzer versions added but older ones
/// reject.
#[serde(default)]
pub version: String,
}
/// Build a workspace dir in $TMPDIR, populated with the listed
/// files. Returns the dir path; caller is responsible for cleanup
/// (use `remove_dir_all` in a `_guard`-style drop, or accept the
/// leak — TMPDIR gets cleaned eventually).
pub fn make_workspace(tag: &str, files: &[(&str, &str)]) -> Utf8PathBuf {
let tmp = std::env::temp_dir().join(format!("setup_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
for (name, content) in files {
let abs_path = tmp.join(name);
std::fs::create_dir_all(abs_path.parent().unwrap()).unwrap();
std::fs::write(abs_path, content).unwrap();
}
Utf8PathBuf::try_from(tmp).unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dir_to_bazel_package_normalizes_backslashes() {
// Windows-shaped input (post-strip_prefix.parent()): backslashes.
assert_eq!(dir_to_bazel_package(r"foo\bar\baz"), "foo/bar/baz");
assert_eq!(dir_to_bazel_package(r"util\label"), "util/label");
// POSIX-shaped input: pass through unchanged.
assert_eq!(dir_to_bazel_package("foo/bar"), "foo/bar");
assert_eq!(dir_to_bazel_package(""), "");
// Mixed (defense in depth).
assert_eq!(dir_to_bazel_package(r"a/b\c/d"), "a/b/c/d");
}
#[test]
fn flycheck_output_base_appends_suffix_at_leaf() {
// Sibling must land under the same output_user_root parent so
// the two servers share `install/`. Appending to the leaf is
// the whole point — a trailing slash would push us into a
// subdirectory instead.
assert_eq!(
flycheck_output_base(Utf8Path::new("/home/u/.cache/bazel/_bazel_u/abc123")),
Utf8PathBuf::from("/home/u/.cache/bazel/_bazel_u/abc123_rra"),
);
}
#[test]
fn assess_discovery_clean_build_is_complete() {
// Success → never incomplete, regardless of spec count.
assert!(!assess_discovery(true, 0, "").unwrap());
assert!(!assess_discovery(true, 42, "").unwrap());
}
#[test]
fn assess_discovery_partial_failure_is_usable_but_incomplete() {
// Some targets failed but specs were produced (e.g. an unrelated
// broken target in a large monorepo) → proceed, but flag incomplete.
assert!(assess_discovery(false, 2712, "boom").unwrap());
}
#[test]
fn assess_discovery_total_failure_errors() {
// Failed build with nothing usable → fatal, and the message carries
// the captured stderr so the user sees the real cause.
let err = assess_discovery(false, 0, "the real bazel error")
.unwrap_err()
.to_string();
assert!(err.contains("the real bazel error"), "got: {}", err);
}
}