rust_analyzer_discover/
main.rs

1//! Binary used for automatic Rust workspace discovery by `rust-analyzer`.
2//! See [rust-analyzer documentation][rd] for a thorough description of this interface.
3//!
4//! [rd]: <https://rust-analyzer.github.io/manual.html#rust-analyzer.workspace.discoverConfig>
5
6mod query;
7mod rust_project;
8
9use std::collections::BTreeMap;
10use std::fs;
11use std::io::{self, Write};
12use std::process::Command;
13
14use anyhow::{Context, bail};
15use camino::{Utf8Path, Utf8PathBuf};
16use clap::Parser;
17use env_logger::fmt::Formatter;
18use env_logger::{Target, WriteStyle};
19use log::{LevelFilter, Record};
20use rust_project::RustProject;
21pub use rust_project::{DiscoverProject, RustAnalyzerArg};
22use serde::de::DeserializeOwned;
23
24pub const WORKSPACE_ROOT_FILE_NAMES: &[&str] = &["MODULE.bazel", "REPO.bazel", "WORKSPACE.bazel", "WORKSPACE"];
25
26pub const BUILD_FILE_NAMES: &[&str] = &["BUILD.bazel", "BUILD"];
27
28/// Looks within the current directory for a file that marks a bazel workspace.
29///
30/// # Errors
31///
32/// Returns an error if no file from [`WORKSPACE_ROOT_FILE_NAMES`] is found.
33fn find_workspace_root_file(workspace: &Utf8Path) -> anyhow::Result<Utf8PathBuf> {
34    BUILD_FILE_NAMES
35        .iter()
36        .chain(WORKSPACE_ROOT_FILE_NAMES)
37        .map(|file| workspace.join(file))
38        .find(|p| p.exists())
39        .with_context(|| format!("no root file found for bazel workspace {workspace}"))
40}
41
42fn project_discovery() -> anyhow::Result<DiscoverProject<'static>> {
43    let Config {
44        workspace,
45        execution_root,
46        output_base,
47        bazel,
48        bazel_startup_options,
49        bazel_args,
50        rust_analyzer_argument,
51    } = Config::parse()?;
52
53    log::info!("got rust-analyzer argument: {rust_analyzer_argument:?}");
54
55    let ra_arg = match rust_analyzer_argument {
56        Some(ra_arg) => ra_arg,
57        None => RustAnalyzerArg::Buildfile(find_workspace_root_file(&workspace)?),
58    };
59
60    log::info!("resolved rust-analyzer argument: {ra_arg:?}");
61
62    let (buildfile, targets) = ra_arg.into_target_details(&workspace)?;
63
64    log::debug!("got buildfile: {buildfile}");
65    log::debug!("got targets: {targets}");
66
67    // Use the generated files to print the rust-project.json.
68    let project = generate_rust_project(
69        &bazel,
70        &output_base,
71        &workspace,
72        &execution_root,
73        &bazel_startup_options,
74        &bazel_args,
75        &[targets],
76    )?;
77
78    std::fs::write(
79        workspace.join(".rust-project.bazel.json"),
80        serde_json::to_string_pretty(&project).unwrap(),
81    )
82    .context("failed to write output")?;
83
84    Ok(DiscoverProject::Finished { buildfile, project })
85}
86
87#[allow(clippy::writeln_empty_string)]
88fn write_discovery<W>(mut writer: W, discovery: DiscoverProject) -> std::io::Result<()>
89where
90    W: Write,
91{
92    serde_json::to_writer(&mut writer, &discovery)?;
93    // `rust-analyzer` reads messages line by line, so we must add a newline after each
94    writeln!(writer, "")
95}
96
97fn main() -> anyhow::Result<()> {
98    let log_format_fn = |fmt: &mut Formatter, rec: &Record| {
99        let message = rec.args();
100        let discovery = DiscoverProject::Progress { message };
101        write_discovery(fmt, discovery)
102    };
103
104    // Treat logs as progress messages.
105    env_logger::Builder::from_default_env()
106        // Never write color/styling info
107        .write_style(WriteStyle::Never)
108        // Format logs as progress messages
109        .format(log_format_fn)
110        // `rust-analyzer` reads the stdout
111        .filter_level(LevelFilter::Trace)
112        .target(Target::Stdout)
113        .init();
114
115    let discovery = match project_discovery() {
116        Ok(discovery) => discovery,
117        Err(error) => DiscoverProject::Error {
118            error: error.to_string(),
119            source: error.source().as_ref().map(ToString::to_string),
120        },
121    };
122
123    write_discovery(io::stdout(), discovery)?;
124    Ok(())
125}
126
127#[derive(Debug)]
128pub struct Config {
129    /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`.
130    workspace: Utf8PathBuf,
131
132    /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`.
133    execution_root: Utf8PathBuf,
134
135    /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`.
136    output_base: Utf8PathBuf,
137
138    /// The path to a Bazel binary.
139    bazel: Utf8PathBuf,
140
141    /// Startup options to pass to `bazel` invocations.
142    /// See the [Command-Line Reference](<https://bazel.build/reference/command-line-reference>)
143    /// for more details.
144    bazel_startup_options: Vec<String>,
145
146    /// Arguments to pass to `bazel` invocations.
147    /// See the [Command-Line Reference](<https://bazel.build/reference/command-line-reference>)
148    /// for more details.
149    bazel_args: Vec<String>,
150
151    /// The argument that `rust-analyzer` can pass to the binary.
152    rust_analyzer_argument: Option<RustAnalyzerArg>,
153}
154
155impl Config {
156    // Parse the configuration flags and supplement with bazel info as needed.
157    pub fn parse() -> anyhow::Result<Self> {
158        let ConfigParser {
159            workspace,
160            bazel,
161            bazel_startup_options,
162            bazel_args,
163            rust_analyzer_argument,
164        } = ConfigParser::parse();
165
166        // We need some info from `bazel info`. Fetch it now.
167        let mut info_map = bazel_info(&bazel, workspace.as_deref(), None, &bazel_startup_options, &bazel_args)?;
168
169        let config = Config {
170            workspace: info_map
171                .remove("workspace")
172                .expect("'workspace' must exist in bazel info")
173                .into(),
174            execution_root: info_map
175                .remove("execution_root")
176                .expect("'execution_root' must exist in bazel info")
177                .into(),
178            output_base: info_map
179                .remove("output_base")
180                .expect("'output_base' must exist in bazel info")
181                .into(),
182            bazel,
183            bazel_startup_options,
184            bazel_args,
185            rust_analyzer_argument,
186        };
187
188        Ok(config)
189    }
190}
191
192#[derive(Debug, Parser)]
193struct ConfigParser {
194    /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`.
195    #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")]
196    workspace: Option<Utf8PathBuf>,
197
198    /// The path to a Bazel binary.
199    #[clap(long, default_value = "bazel", env = "BAZEL")]
200    bazel: Utf8PathBuf,
201
202    /// Startup options to pass to `bazel` invocations.
203    /// See the [Command-Line Reference](<https://bazel.build/reference/command-line-reference>)
204    /// for more details.
205    #[clap(long = "bazel_startup_option")]
206    bazel_startup_options: Vec<String>,
207
208    /// Arguments to pass to `bazel` invocations.
209    /// See the [Command-Line Reference](<https://bazel.build/reference/command-line-reference>)
210    /// for more details.
211    #[clap(long = "bazel_arg")]
212    bazel_args: Vec<String>,
213
214    /// The argument that `rust-analyzer` can pass to the binary.
215    rust_analyzer_argument: Option<RustAnalyzerArg>,
216}
217
218#[allow(clippy::too_many_arguments)]
219pub fn generate_rust_project(
220    bazel: &Utf8Path,
221    output_base: &Utf8Path,
222    workspace: &Utf8Path,
223    execution_root: &Utf8Path,
224    bazel_startup_options: &[String],
225    bazel_args: &[String],
226    targets: &[String],
227) -> anyhow::Result<RustProject> {
228    let crate_specs = query::get_crate_specs(
229        bazel,
230        output_base,
231        workspace,
232        execution_root,
233        bazel_startup_options,
234        bazel_args,
235        targets,
236    )?;
237
238    let path = std::env::var("RUST_ANALYZER_TOOLCHAIN_PATH").context("MISSING RUST_ANALYZER_TOOLCHAIN_PATH")?;
239
240    #[cfg(bazel_runfiles)]
241    let path: Utf8PathBuf = runfiles::rlocation!(runfiles::Runfiles::create()?, path)
242        .context("toolchain runfile not found")?
243        .try_into()?;
244
245    #[cfg(not(bazel_runfiles))]
246    let path = Utf8PathBuf::from(path);
247
248    let toolchain_info = deserialize_file_content(&path, output_base, workspace, execution_root)?;
249
250    rust_project::assemble_rust_project(bazel, workspace, output_base, toolchain_info, crate_specs)
251}
252
253/// Executes `bazel info` to get a map of context information.
254pub fn bazel_info(
255    bazel: &Utf8Path,
256    workspace: Option<&Utf8Path>,
257    output_base: Option<&Utf8Path>,
258    bazel_startup_options: &[String],
259    bazel_args: &[String],
260) -> anyhow::Result<BTreeMap<String, String>> {
261    let output = bazel_command(bazel, workspace, output_base)
262        .args(bazel_startup_options)
263        .arg("info")
264        .args(bazel_args)
265        .output()?;
266
267    if !output.status.success() {
268        let status = output.status;
269        let stderr = String::from_utf8_lossy(&output.stderr);
270        bail!("bazel info failed: ({status:?})\n{stderr}");
271    }
272
273    // Extract and parse the output.
274    let info_map = String::from_utf8(output.stdout)?
275        .trim()
276        .split('\n')
277        .filter_map(|line| line.split_once(':'))
278        .map(|(k, v)| (k.to_owned(), v.trim().to_owned()))
279        .collect();
280
281    Ok(info_map)
282}
283
284fn bazel_command(bazel: &Utf8Path, workspace: Option<&Utf8Path>, output_base: Option<&Utf8Path>) -> Command {
285    let mut cmd = Command::new(bazel);
286
287    cmd
288        // Switch to the workspace directory if one was provided.
289        .current_dir(workspace.unwrap_or(Utf8Path::new(".")))
290        .env_remove("BAZELISK_SKIP_WRAPPER")
291        .env_remove("BUILD_WORKING_DIRECTORY")
292        .env_remove("BUILD_WORKSPACE_DIRECTORY")
293        // Set the output_base if one was provided.
294        .args(output_base.map(|s| format!("--output_base={s}")));
295
296    cmd
297}
298
299fn deserialize_file_content<T>(
300    path: &Utf8Path,
301    output_base: &Utf8Path,
302    workspace: &Utf8Path,
303    execution_root: &Utf8Path,
304) -> anyhow::Result<T>
305where
306    T: DeserializeOwned,
307{
308    let content = fs::read_to_string(path)
309        .with_context(|| format!("failed to read file: {path}"))?
310        .replace("__WORKSPACE__", workspace.as_str())
311        .replace("${pwd}", execution_root.as_str())
312        .replace("__EXEC_ROOT__", execution_root.as_str())
313        .replace("__OUTPUT_BASE__", output_base.as_str());
314
315    log::trace!("{path}\n{content}");
316
317    serde_json::from_str(&content).with_context(|| format!("failed to deserialize file: {path}"))
318}
319
320/// `rust-analyzer` associates workspaces with buildfiles. Therefore, when it passes in a
321/// source file path, we use this function to identify the buildfile the file belongs to.
322fn source_file_to_buildfile(file: &Utf8Path) -> anyhow::Result<Utf8PathBuf> {
323    // Skip the first element as it's always the full file path.
324    file.ancestors()
325        .skip(1)
326        .flat_map(|dir| BUILD_FILE_NAMES.iter().map(move |build| dir.join(build)))
327        .find(|p| p.exists())
328        .with_context(|| format!("no buildfile found for {file}"))
329}
330
331fn buildfile_to_targets(workspace: &Utf8Path, buildfile: &Utf8Path) -> anyhow::Result<String> {
332    log::info!("getting targets for buildfile: {buildfile}");
333
334    let parent_dir = buildfile
335        .strip_prefix(workspace)
336        .with_context(|| format!("{buildfile} not part of workspace"))?
337        .parent();
338
339    let targets = match parent_dir {
340        Some(p) if !p.as_str().is_empty() => format!("//{p}:all"),
341        _ => "//...".to_string(),
342    };
343
344    Ok(targets)
345}
346
347#[derive(Debug, serde_derive::Deserialize)]
348struct ToolchainInfo {
349    sysroot: Utf8PathBuf,
350    sysroot_src: Utf8PathBuf,
351}