blob: e05f499a0ba60469d99481967b53fe8790343b89 [file]
//! On-save flycheck wrapper invoked by rust-analyzer.
//!
//! rust-analyzer's flycheck runnable spawns this with the saved file's
//! owning Bazel label and (optionally) the saved file path. We then:
//!
//! 1. Invoke `bazel build <label>` with rustc diagnostics turned on and
//! `--build_event_json_file=<tmp>` so BEP can tell us where rustc
//! wrote its JSON output.
//! 2. Parse BEP for the `rustc_output` output group, collecting every
//! `.rustc-output` artifact produced by the build (one per rust
//! action — bin, lib, test compilations are all separate).
//! 3. Concatenate the JSON contents to stdout for rust-analyzer to
//! render as inline diagnostics.
//!
//! `--keep_going` keeps Bazel building even when rustc emits errors so all
//! diagnostics surface in one pass. The wrapper always emits whatever
//! `.rustc-output` files exist and forwards Bazel's exit code so
//! rust-analyzer can distinguish "build succeeded" from "build itself
//! failed" (e.g. BUILD-file syntax error).
use std::{
env, fs,
io::{self, Write},
process::{Command, ExitCode},
};
use anyhow::{Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use clap::Parser;
use gen_rust_project_lib::bep;
use serde_json::Value;
#[derive(Parser, Debug)]
#[command(about = "rust-analyzer flycheck wrapper backed by `bazel build`")]
struct Args {
/// Bazel label of the crate whose owning file rust-analyzer just saved.
label: String,
/// The file rust-analyzer just saved. Currently unused — we check the
/// whole crate, matching cargo check's per-crate semantics. Accepted
/// so the runnable template's `{saved_file}` placeholder has somewhere
/// to land without errors.
#[clap(default_value = "")]
saved_file: String,
/// Path to the bazel binary.
#[clap(long, default_value = "bazel")]
bazel: Utf8PathBuf,
/// Bazel `--output_user_root` for the flycheck server. Overrides
/// the default (`<install_dir>/output_user_root`, derived from
/// `current_exe()`). Useful on Windows where MAX_PATH limits make
/// the in-launcher-dir default impractical.
#[clap(long)]
output_user_root: Option<Utf8PathBuf>,
}
fn main() -> ExitCode {
env_logger::init();
match run() {
Ok(code) => ExitCode::from(code),
Err(e) => {
eprintln!("flycheck: {e:#}");
ExitCode::from(1)
}
}
}
fn run() -> Result<u8> {
let args = Args::parse();
let workspace = workspace_dir()?;
let temp_dir = Utf8PathBuf::try_from(env::temp_dir()).context("$TMPDIR was not valid UTF-8")?;
let bep_path = temp_dir.join(format!("flycheck_bep_{}.json", std::process::id()));
let _bep_cleanup = scopeguard(bep_path.clone());
// Dedicated `--output_user_root` for the inner `bazel build` so
// its `--error_format=json` / `--rustc_output_diagnostics=true`
// don't thrash the user's primary Bazel server's analysis cache.
// CLI override exists for Windows MAX_PATH cases where the
// sibling default is too long.
let output_user_root = match args.output_user_root.clone() {
Some(p) => p,
None => gen_rust_project_lib::install_dir()?.join("output_user_root"),
};
std::fs::create_dir_all(&output_user_root)
.with_context(|| format!("creating output_user_root {output_user_root}"))?;
let status = Command::new(args.bazel.as_str())
.current_dir(&workspace)
// BUILD_WORKSPACE_DIRECTORY / BUILD_WORKING_DIRECTORY leak in from
// the outer `bazel run` invocation and would confuse the nested
// bazel client; clear them so the nested call rediscovers the
// workspace from cwd.
.env_remove("BAZELISK_SKIP_WRAPPER")
.env_remove("BUILD_WORKING_DIRECTORY")
.env_remove("BUILD_WORKSPACE_DIRECTORY")
// `--output_user_root` is a STARTUP option — must precede the
// command (`build`). Bazel rejects it elsewhere.
.arg(format!("--output_user_root={output_user_root}"))
.arg("build")
.arg(&args.label)
// `error_format=json` makes rustc emit machine-readable diagnostics
// and tells process_wrapper to capture them verbatim; without it the
// `.rustc-output` files are pre-rendered ANSI strings that
// rust-analyzer can't parse.
.arg("--@rules_rust//rust/settings:error_format=json")
.arg("--@rules_rust//rust/settings:rustc_output_diagnostics=true")
.arg(format!("--output_groups=+{}", bep::RUSTC_OUTPUT_GROUP))
.arg("--keep_going")
.arg(format!("--build_event_json_file={bep_path}"))
.status()
.with_context(|| format!("invoking {}", args.bazel))?;
let stderr_files = match bep::parse_action_stderr_paths(&bep_path) {
Ok(paths) => paths,
Err(e) => {
eprintln!("flycheck: parsing BEP failed: {e:#}");
Vec::new()
}
};
emit_diagnostics(&stderr_files, &workspace)?;
// Forward Bazel's exit code so rust-analyzer can tell apart "build
// succeeded with diagnostics" from "build tool itself broke".
Ok(status.code().unwrap_or(1) as u8)
}
/// Stream each action-stderr file to stdout, keeping only lines that look
/// like rustc JSON messages. For each diagnostic line, recursively rewrite
/// `file_name` fields to absolute paths anchored at the workspace root —
/// rustc emits them relative to the Bazel exec root (structurally the
/// workspace layout), and rust-analyzer otherwise tries to resolve them
/// relative to the saved file's directory, producing nonsense like
/// `<workspace>/util/label/util/label/label.rs`.
///
/// Non-JSON lines (sandbox warnings, env dumps) are dropped so the LSP
/// only sees parseable rustc messages.
///
/// Errors writing to stdout bubble up — rust-analyzer parses our stdout
/// as the diagnostic stream, so silently dropping writes would surface as
/// "no squiggles after save" with no clue why. A failed write is almost
/// always "rust-analyzer closed the pipe" which is also worth surfacing
/// (the editor is gone; flycheck has no consumer).
fn emit_diagnostics(files: &[Utf8PathBuf], workspace: &Utf8Path) -> Result<()> {
let stdout = io::stdout();
let mut out = stdout.lock();
for path in files {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
log::warn!("could not read {path}: {e}");
continue;
}
};
for line in content.lines() {
let trimmed = line.trim_start();
if !trimmed.starts_with('{') {
continue;
}
match serde_json::from_str::<Value>(trimmed) {
Ok(mut value) => {
absolutize_file_names(&mut value, workspace);
serde_json::to_writer(&mut out, &value)
.context("writing rewritten rustc JSON to stdout")?;
out.write_all(b"\n").context("writing newline to stdout")?;
}
Err(_) => {
// Not strict JSON — pass through unmodified so we don't
// silently drop a diagnostic format we don't recognize.
out.write_all(line.as_bytes())
.context("passing through non-JSON line to stdout")?;
out.write_all(b"\n").context("writing newline to stdout")?;
}
}
}
}
out.flush().context("flushing stdout")?;
Ok(())
}
/// Walk a rustc-diagnostic JSON value and rewrite every `"file_name"`
/// string field to an absolute path under `workspace`. file_name appears
/// in `spans[*].file_name`, in nested `expansion.span.file_name` chains
/// for macro expansions, and inside `children[*].spans[*]…` for
/// sub-diagnostics — recursion catches all of them.
fn absolutize_file_names(value: &mut Value, workspace: &Utf8Path) {
match value {
Value::Object(map) => {
for (key, child) in map.iter_mut() {
if key == "file_name" {
if let Value::String(s) = child {
if !s.is_empty() && !std::path::Path::new(s).is_absolute() {
*s = workspace.join(&*s).to_string();
}
}
} else {
absolutize_file_names(child, workspace);
}
}
}
Value::Array(items) => {
for item in items.iter_mut() {
absolutize_file_names(item, workspace);
}
}
_ => {}
}
}
fn workspace_dir() -> Result<Utf8PathBuf> {
if let Ok(dir) = env::var("BUILD_WORKSPACE_DIRECTORY") {
return Utf8PathBuf::try_from(std::path::PathBuf::from(dir))
.context("BUILD_WORKSPACE_DIRECTORY was not valid UTF-8");
}
let cwd = env::current_dir().context("current_dir")?;
Utf8PathBuf::try_from(cwd).context("current_dir was not valid UTF-8")
}
/// Best-effort cleanup of the temporary BEP file.
fn scopeguard(path: Utf8PathBuf) -> impl Drop {
struct Guard(Utf8PathBuf);
impl Drop for Guard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
Guard(path)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn relative_file_names_become_absolute() {
let workspace = Utf8Path::new("/abs/ws");
let mut v = json!({
"$message_type": "diagnostic",
"spans": [
{"file_name": "util/label/label.rs", "byte_start": 0},
{"file_name": "/already/absolute.rs", "byte_start": 1},
{
"file_name": "src/lib.rs",
"expansion": {
"span": {"file_name": "src/macro.rs"}
}
}
],
"children": [
{"spans": [{"file_name": "src/inner.rs"}]}
]
});
absolutize_file_names(&mut v, workspace);
// Construct expected paths via Utf8Path::join so the test passes on
// both POSIX and Windows (Windows uses `\` as the separator).
let expect = |rel: &str| Value::String(workspace.join(rel).to_string());
let spans = v["spans"].as_array().unwrap();
assert_eq!(spans[0]["file_name"], expect("util/label/label.rs"));
// Absolute paths must be left untouched.
assert_eq!(spans[1]["file_name"], json!("/already/absolute.rs"));
assert_eq!(spans[2]["file_name"], expect("src/lib.rs"));
// Recursive descent reaches the expansion span.
assert_eq!(
spans[2]["expansion"]["span"]["file_name"],
expect("src/macro.rs"),
);
// And children's spans.
assert_eq!(
v["children"][0]["spans"][0]["file_name"],
expect("src/inner.rs"),
);
}
#[test]
fn empty_file_name_is_left_alone() {
let workspace = Utf8Path::new("/ws");
let mut v = json!({"spans": [{"file_name": ""}]});
absolutize_file_names(&mut v, workspace);
assert_eq!(v["spans"][0]["file_name"], json!(""));
}
}