blob: bc487b9f74cf272ba5f2bbd6fc41ed79d49d7dc7 [file]
//! Parse Bazel's Build Event Protocol (BEP) JSON stream to discover the
//! `rust-analyzer` crate spec files produced by `rust_analyzer_aspect`.
//!
//! BEP replaces a separate `bazel aquery` round-trip with a side-effect of
//! the `bazel build` that's already running. The aspect declares its output
//! group; BEP reports each target/aspect completion with the file sets it
//! produced. Walking those is O(events) — much cheaper than rescanning the
//! action graph for the same data.
use std::{
collections::BTreeMap,
fs::File,
io::{BufRead, BufReader},
};
use anyhow::{Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use serde::Deserialize;
/// Output group name the `rust_analyzer_aspect` puts the per-crate spec files
/// in. Must match the key used in [`OutputGroupInfo`] in
/// `rust/private/rust_analyzer.bzl`.
pub const SPEC_OUTPUT_GROUP: &str = "rust_analyzer_crate_spec";
/// Output group rustc-emitted diagnostics land in when
/// `--@rules_rust//rust/settings:rustc_output_diagnostics=true` is set. See
/// [`generate_output_diagnostics`] in `rust/private/utils.bzl`.
pub const RUSTC_OUTPUT_GROUP: &str = "rustc_output";
/// Output group clippy-emitted diagnostics land in when
/// `--@rules_rust//rust/settings:clippy_output_diagnostics=true` is set,
/// under the `rust_clippy_aspect`. Each entry is a per-crate
/// `.clippy.diagnostics` file. See `rust/private/clippy.bzl`.
pub const CLIPPY_OUTPUT_GROUP: &str = "clippy_output";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BuildEvent {
#[serde(default)]
id: Option<EventId>,
#[serde(default)]
named_set_of_files: Option<NamedSetOfFiles>,
#[serde(default)]
completed: Option<Completed>,
#[serde(default)]
action: Option<ActionPayload>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EventId {
#[serde(default)]
named_set: Option<NamedSetId>,
#[serde(default)]
action_completed: Option<ActionCompletedId>,
}
#[derive(Debug, Deserialize)]
struct ActionCompletedId {}
#[derive(Debug, Deserialize)]
struct ActionPayload {
#[serde(default)]
stderr: Option<BepFile>,
}
#[derive(Debug, Deserialize)]
struct NamedSetId {
id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NamedSetOfFiles {
#[serde(default)]
files: Vec<BepFile>,
#[serde(default)]
file_sets: Vec<FileSetRef>,
}
#[derive(Debug, Deserialize)]
struct BepFile {
/// Either `uri` or `name`/`pathPrefix` may be populated depending on
/// Bazel version. We prefer `uri` (a `file://` URL) when available.
#[serde(default)]
uri: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default, rename = "pathPrefix")]
path_prefix: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct FileSetRef {
id: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Completed {
#[serde(default)]
output_group: Vec<OutputGroup>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OutputGroup {
name: String,
#[serde(default)]
file_sets: Vec<FileSetRef>,
}
/// Read a BEP JSONL file and return every path that appears in the named
/// output group of any completed target or aspect, deduplicated. Paths are
/// absolute on the local filesystem.
pub fn parse_output_group_paths(
bep_path: &Utf8Path,
output_group: &str,
execution_root: &Utf8Path,
) -> Result<Vec<Utf8PathBuf>> {
let file = File::open(bep_path).with_context(|| format!("opening BEP file {bep_path}"))?;
let reader = BufReader::new(file);
// First pass: collect every named file set and every matching fileset id.
let mut file_sets: BTreeMap<String, NamedSetOfFiles> = BTreeMap::new();
let mut matching_fileset_ids: Vec<String> = Vec::new();
for line in reader.lines() {
let line = line.with_context(|| format!("reading BEP file {bep_path}"))?;
if line.is_empty() {
continue;
}
// Skip BEP events we don't recognize rather than failing the whole
// discovery on a forward-compatible field.
let event: BuildEvent = match serde_json::from_str(&line) {
Ok(e) => e,
Err(_) => continue,
};
if let Some(named_set) = event.named_set_of_files {
if let Some(EventId {
named_set: Some(NamedSetId { id }),
..
}) = event.id
{
file_sets.insert(id, named_set);
}
} else if let Some(completed) = event.completed {
for group in completed.output_group {
if group.name == output_group {
for fileset in group.file_sets {
matching_fileset_ids.push(fileset.id);
}
}
}
}
}
// Walk the named file sets transitively, gathering file URIs.
let mut paths: Vec<Utf8PathBuf> = Vec::new();
let mut visited: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut stack: Vec<String> = matching_fileset_ids;
while let Some(id) = stack.pop() {
if !visited.insert(id.clone()) {
continue;
}
let Some(set) = file_sets.get(&id) else {
continue;
};
for file in &set.files {
if let Some(path) = file_to_path(file) {
let mut absolute_path = path;
if !absolute_path.is_absolute() {
absolute_path = execution_root.join(absolute_path);
}
paths.push(absolute_path);
}
}
for child in &set.file_sets {
stack.push(child.id.clone());
}
}
paths.sort();
paths.dedup();
Ok(paths)
}
/// Convenience wrapper for the `rust_analyzer_crate_spec` output group used
/// during project discovery.
pub fn parse_spec_paths(
bep_path: &Utf8Path,
execution_root: &Utf8Path,
) -> Result<Vec<Utf8PathBuf>> {
parse_output_group_paths(bep_path, SPEC_OUTPUT_GROUP, execution_root)
}
/// Return the stderr file path captured for every completed Bazel action
/// in the BEP, regardless of label. With `error_format=json` set on the
/// build, those files contain rustc's machine-readable diagnostics — the
/// only place to read them when the action fails (failed actions don't
/// produce their declared `.rustc-output` artifacts).
///
/// We deliberately do NOT filter by the saved-file's target label.
/// That matches `cargo check`'s behavior: when a dep of the edited
/// crate fails, cargo surfaces the dep's rustc errors at the dep's
/// file paths, and rust-analyzer renders them there. Filtering by the
/// saved-file's label silently drops every upstream failure, which
/// surfaces in the editor as "no diagnostics anywhere" — the user has
/// no idea why their save produced nothing.
pub fn parse_action_stderr_paths(bep_path: &Utf8Path) -> Result<Vec<Utf8PathBuf>> {
let file = File::open(bep_path).with_context(|| format!("opening BEP file {bep_path}"))?;
let reader = BufReader::new(file);
let mut paths: Vec<Utf8PathBuf> = Vec::new();
for line in reader.lines() {
let line = line.with_context(|| format!("reading BEP file {bep_path}"))?;
if line.is_empty() {
continue;
}
let event: BuildEvent = match serde_json::from_str(&line) {
Ok(e) => e,
Err(_) => continue,
};
if event
.id
.as_ref()
.and_then(|i| i.action_completed.as_ref())
.is_none()
{
continue;
}
let action = match event.action {
Some(a) => a,
None => continue,
};
if let Some(stderr) = action.stderr {
if let Some(path) = file_to_path(&stderr) {
paths.push(path);
}
}
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn file_to_path(file: &BepFile) -> Option<Utf8PathBuf> {
if let Some(uri) = &file.uri {
if let Some(rest) = uri.strip_prefix("file://") {
let decoded = percent_decode(rest);
return Some(Utf8PathBuf::from(strip_uri_drive_prefix(&decoded)));
}
}
// Fallback: reconstruct from pathPrefix + name. Bazel uses this form
// when the file lives in bazel-out and the absolute URI isn't reported.
if let Some(name) = &file.name {
if !file.path_prefix.is_empty() {
let mut path = Utf8PathBuf::from(&file.path_prefix[0]);
for segment in file.path_prefix.iter().skip(1) {
path.push(segment);
}
path.push(name);
return Some(path);
}
}
None
}
/// `file://` URIs on Windows look like `file:///C:/path` — after stripping
/// the `file://` scheme prefix, the result is `/C:/path` where the leading
/// `/` is the URI authority separator, NOT part of the actual filesystem
/// path. Strip it when the next characters are `<drive-letter>:` so the
/// resulting `C:/path` parses as a valid Windows path.
///
/// Safe on POSIX: a real POSIX file URI `file:///foo/bar` strips to
/// `/foo/bar` which doesn't match the `/<letter>:` shape, so the path
/// passes through unchanged.
fn strip_uri_drive_prefix(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' {
&s[1..]
} else {
s
}
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(hi), Some(lo)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
out.push(hi * 16 + lo);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
// The decoded URI path must be valid UTF-8 on the platforms we support.
String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_decode_basic() {
assert_eq!(percent_decode("/path/with%20space"), "/path/with space");
assert_eq!(percent_decode("noop"), "noop");
assert_eq!(percent_decode("a%2Fb"), "a/b");
}
#[test]
fn strip_uri_drive_prefix_handles_windows_and_posix() {
// Windows: `file:///C:/path` → after `file://` strip → `/C:/path`.
// The leading `/` is the URI authority separator and must go.
assert_eq!(
strip_uri_drive_prefix("/C:/path/to/file"),
"C:/path/to/file"
);
assert_eq!(strip_uri_drive_prefix("/D:/other"), "D:/other");
// POSIX: `file:///foo/bar` → `/foo/bar`. Leading `/` IS the path root.
assert_eq!(strip_uri_drive_prefix("/foo/bar"), "/foo/bar");
assert_eq!(
strip_uri_drive_prefix("/abs/lib.spec.json"),
"/abs/lib.spec.json"
);
// Edge cases.
assert_eq!(strip_uri_drive_prefix(""), "");
assert_eq!(strip_uri_drive_prefix("/"), "/");
assert_eq!(
strip_uri_drive_prefix("C:/already_clean"),
"C:/already_clean"
);
}
#[test]
fn parse_spec_paths_resolves_nested_filesets() {
let dir = tempdir();
let bep_path = dir.join("bep.json");
std::fs::write(
&bep_path,
r#"{"id":{"namedSet":{"id":"0"}},"namedSetOfFiles":{"files":[{"uri":"file:///abs/foo.rust_analyzer_crate_spec.json"}],"fileSets":[{"id":"1"}]}}
{"id":{"namedSet":{"id":"1"}},"namedSetOfFiles":{"files":[{"uri":"file:///abs/bar.rust_analyzer_crate_spec.json"}]}}
{"id":{"targetCompleted":{"label":"//pkg:lib"}},"completed":{"outputGroup":[{"name":"rust_analyzer_crate_spec","fileSets":[{"id":"0"}]}]}}
{"id":{"namedSet":{"id":"2"}},"namedSetOfFiles":{"files":[{"uri":"file:///abs/unrelated.json"}]}}
"#,
)
.unwrap();
let paths = parse_spec_paths(&bep_path, Utf8Path::new("/execroot")).unwrap();
assert_eq!(
paths,
vec![
Utf8PathBuf::from("/abs/bar.rust_analyzer_crate_spec.json"),
Utf8PathBuf::from("/abs/foo.rust_analyzer_crate_spec.json"),
]
);
}
#[test]
fn parse_spec_paths_handles_relative_paths() {
let dir = tempdir();
let bep_path = dir.join("bep.json");
std::fs::write(
&bep_path,
r#"{"id":{"namedSet":{"id":"0"}},"namedSetOfFiles":{"files":[{"uri":"bytestream://remote/blob","pathPrefix":["bazel-out","k8-fastbuild","bin"],"name":"pkg/lib.rust_analyzer_crate_spec.json"}]}}
{"id":{"targetCompleted":{"label":"//pkg:lib"}},"completed":{"outputGroup":[{"name":"rust_analyzer_crate_spec","fileSets":[{"id":"0"}]}]}}
"#,
)
.unwrap();
let paths = parse_spec_paths(&bep_path, Utf8Path::new("/execroot")).unwrap();
assert_eq!(
paths,
vec![Utf8PathBuf::from(
"/execroot/bazel-out/k8-fastbuild/bin/pkg/lib.rust_analyzer_crate_spec.json",
)]
);
}
#[test]
fn parse_output_group_paths_filters_by_group() {
let dir = tempdir();
let bep_path = dir.join("bep.json");
std::fs::write(
&bep_path,
r#"{"id":{"namedSet":{"id":"0"}},"namedSetOfFiles":{"files":[{"uri":"file:///abs/lib.rustc-output"}]}}
{"id":{"namedSet":{"id":"1"}},"namedSetOfFiles":{"files":[{"uri":"file:///abs/lib.spec.json"}]}}
{"id":{"targetCompleted":{"label":"//pkg:lib"}},"completed":{"outputGroup":[{"name":"rustc_output","fileSets":[{"id":"0"}]},{"name":"rust_analyzer_crate_spec","fileSets":[{"id":"1"}]}]}}
"#,
)
.unwrap();
let rustc =
parse_output_group_paths(&bep_path, RUSTC_OUTPUT_GROUP, Utf8Path::new("/execroot"))
.unwrap();
assert_eq!(rustc, vec![Utf8PathBuf::from("/abs/lib.rustc-output")]);
let specs = parse_output_group_paths(
&bep_path,
"rust_analyzer_crate_spec",
Utf8Path::new("/execroot"),
)
.unwrap();
assert_eq!(specs, vec![Utf8PathBuf::from("/abs/lib.spec.json")]);
}
fn tempdir() -> Utf8PathBuf {
use std::convert::TryFrom;
// Sanitize the thread name: libtest gives us names like
// `bep::tests::foo`, and Windows rejects `:` in filenames.
let raw_name = std::thread::current().name().unwrap_or("anon").to_owned();
let safe_name: String = raw_name
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
let dir =
std::env::temp_dir().join(format!("bep_test_{}_{}", std::process::id(), safe_name,));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Utf8PathBuf::try_from(dir).unwrap()
}
}