`rust-analyzer` discoverConfig integration (#3073)

Adds a target that can be used for project auto-discovery by using the
`discoverConfig` settings as described in the `rust-analyzer` user
manual.

Unlike the `gen_rust_project` target, this can be used for dynamic
project discovery, and passing `{arg}` to `discoverConfig.command` can
split big repositories into multiple, smaller workspaces that
`rust-analyzer` switches between as needed. Large repositories can make
it OOM.

At amo, we've used a similar implementation for a while with great
success, which is why we figured we might upstream it. The changes also
include two additional output groups to ensure that proc-macros and
build script targets are built, as `rust-analyzer` depends on these to
provide complete IDE support.

Additionally, the PR makes use of the `output_base` value in `bazel`
invocations. We found it helpful to have tools such as `rust-analyzer`
and `clippy` run on a separate bazel server than the one used for
building. And a `config_group` argument was added to provide the ability
to provide a config group to `bazel` invocations.

An attempt to get codelens actions to work was done as well,
particularly around tests and binaries. They seem to work, but I'm not
100% sure whether the approach taken is the right one.

Closes #2755 .

---------

Co-authored-by: Krasimir Georgiev <krasimir@google.com>
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index 4014ec3..7a931a2 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -885,7 +885,7 @@
     build_flags:
       - "--compile_one_dependency"
     build_targets:
-      - "tools/rust_analyzer/main.rs"
+      - "tools/rust_analyzer/bin/gen_rust_project.rs"
   extensions_bindgen_linux:
     platform: ubuntu2004
     name: Extensions Bindgen
diff --git a/.gitignore b/.gitignore
index 0316dc9..74ef480 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,9 @@
 .vscode
 *.code-workspace
 
+# zed
+.zed
+
 # JetBrains
 .idea
 .idea/**
diff --git a/MODULE.bazel b/MODULE.bazel
index 5171b23..971d086 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -22,6 +22,7 @@
     internal_deps,
     "rrra",
     "rrra__anyhow-1.0.71",
+    "rrra__camino-1.1.9",
     "rrra__clap-4.3.11",
     "rrra__env_logger-0.10.0",
     "rrra__itertools-0.11.0",
diff --git a/docs/rust_analyzer.vm b/docs/rust_analyzer.vm
index b974332..e5f794b 100644
--- a/docs/rust_analyzer.vm
+++ b/docs/rust_analyzer.vm
@@ -2,10 +2,12 @@
 ## Overview
 
 For [non-Cargo projects](https://rust-analyzer.github.io/manual.html#non-cargo-based-projects),
-[rust-analyzer](https://rust-analyzer.github.io/) depends on a `rust-project.json` file at the
-root of the project that describes its structure. The `rust_analyzer` rule facilitates generating
-such a file.
+[rust-analyzer](https://rust-analyzer.github.io/) depends on either a `rust-project.json` file 
+at the root of the project that describes its structure or on build system specific 
+[project auto-discovery](https://rust-analyzer.github.io/manual.html#rust-analyzer.workspace.discoverConfig).
+The `rust_analyzer` rules facilitate both approaches.
 
+## rust-project.json approach
 ### Setup
 
 First, ensure `rules_rust` is setup in your workspace. By default, `rust_register_toolchains` will
@@ -84,4 +86,71 @@
 
 Then you can use a prototype [rust-analyzer plugin](https://marketplace.visualstudio.com/items?itemName=MattStark.bazel-rust-analyzer) that automatically collects the outputs whenever you recompile.
 
-]]#
+## Project auto-discovery
+### Setup
+
+Auto-discovery makes `rust-analyzer` behave in a Bazel project in a similar fashion to how it does
+in a Cargo project. This is achieved by generating a structure similar to what `rust-project.json` 
+contains but, instead of writing that to a file, the data gets piped to `rust-analyzer` directly 
+through `stdout`. To use auto-discovery the `rust-analyzer` IDE settings must be configured similar to:
+
+```json
+"rust-analyzer": {
+    "workspace": {
+        "discoverConfig": {
+            "command": ["discover_bazel_rust_project.sh"],
+            "progressLabel": "rust_analyzer",
+            "filesToWatch": ["BUILD", "BUILD.bazel", "MODULE.bazel"]
+        }
+    }
+}
+```
+
+The shell script passed to `discoverConfig.command` is typically meant to wrap the bazel rule invocation,
+primarily for muting `stderr` (because `rust-analyzer` will consider that an error has occurred if anything 
+is passed through `stderr`) and, additionally, for specifying rule arguments. E.g:
+
+```shell
+#!/usr/bin/bash
+
+bazel \
+    run \
+    @rules_rust//tools/rust_analyzer:discover_bazel_rust_project -- \
+    --bazel_startup_option=--output_base=~/ide_bazel \
+    --bazel_arg=--watchfs \
+    ${1:+"$1"} 2>/dev/null
+```
+
+The script above also handles an optional CLI argument which gets passed when workspace splitting is
+enabled. The script path should be either absolute or relative to the project root.
+
+### Workspace splitting
+
+The above configuration treats the entire project as a single workspace. However, large codebases might be
+too much to handle for `rust-analyzer` all at once. This can be addressed by splitting the codebase in 
+multiple workspaces by extending the `discoverConfig.command` setting:
+
+```json
+"rust-analyzer": {
+    "workspace": {
+        "discoverConfig": {
+            "command": ["discover_bazel_rust_project.sh", "{arg}"],
+            "progressLabel": "rust_analyzer",
+            "filesToWatch": ["BUILD", "BUILD.bazel", "MODULE.bazel"]
+        }
+    }
+}
+```
+
+`{arg}` acts as a placeholder that `rust-analyzer` replaces with the path of the source / build file
+that gets opened.
+
+The root of the workspace will, in this configuration, be the package the crate currently being worked on
+belongs to. This means that only that package and its dependencies get built and indexed by `rust-analyzer`, 
+thus allowing for a smaller footprint. 
+
+`rust-analyzer` will switch workspaces whenever an out-of-tree file gets opened, essentially indexing that
+crate and its dependencies separately. A caveat of this is that *dependents* of the crate currently being 
+worked on are not indexed and won't be tracked by `rust-analyzer`.
+
+]]#
\ No newline at end of file
diff --git a/extensions/prost/private/prost.bzl b/extensions/prost/private/prost.bzl
index 61157e8..c591710 100644
--- a/extensions/prost/private/prost.bzl
+++ b/extensions/prost/private/prost.bzl
@@ -308,6 +308,8 @@
     # https://github.com/rust-analyzer/rust-analyzer/blob/2021-11-15/crates/project_model/src/workspace.rs#L529-L531
     cfgs = ["test", "debug_assertions"]
 
+    build_info_out_dirs = [dep_variant_info.build_info.out_dir] if dep_variant_info.build_info != None and dep_variant_info.build_info.out_dir != None else None
+
     rust_analyzer_info = write_rust_analyzer_spec_file(ctx, ctx.rule.attr, ctx.label, RustAnalyzerInfo(
         aliases = {},
         crate = dep_variant_info.crate_info,
@@ -315,7 +317,9 @@
         env = dep_variant_info.crate_info.rustc_env,
         deps = rust_analyzer_deps,
         crate_specs = depset(transitive = [dep.crate_specs for dep in rust_analyzer_deps]),
-        proc_macro_dylib_path = None,
+        proc_macro_dylibs = depset(transitive = [dep.proc_macro_dylibs for dep in rust_analyzer_deps]),
+        build_info_out_dirs = depset(direct = build_info_out_dirs, transitive = [dep.build_info_out_dirs for dep in rust_analyzer_deps]),
+        proc_macro_dylib = None,
         build_info = dep_variant_info.build_info,
     ))
 
diff --git a/rust/private/providers.bzl b/rust/private/providers.bzl
index 0556ebe..746370b 100644
--- a/rust/private/providers.bzl
+++ b/rust/private/providers.bzl
@@ -159,12 +159,14 @@
     fields = {
         "aliases": "Dict[RustAnalyzerInfo, String]: Replacement names these targets should be known as in Rust code",
         "build_info": "BuildInfo: build info for this crate if present",
+        "build_info_out_dirs": "Depset[File]: transitive closure of build script out dirs",
         "cfgs": "List[String]: features or other compilation `--cfg` settings",
         "crate": "CrateInfo: Crate information.",
-        "crate_specs": "Depset[File]: transitive closure of OutputGroupInfo files",
+        "crate_specs": "Depset[File]: transitive closure of crate spec files",
         "deps": "List[RustAnalyzerInfo]: direct dependencies",
         "env": "Dict[String: String]: Environment variables, used for the `env!` macro",
-        "proc_macro_dylib_path": "File: compiled shared library output of proc-macro rule",
+        "proc_macro_dylib": "File: if this is a proc-macro target, the shared library output",
+        "proc_macro_dylibs": "Depset[File]: transitive closure of proc-macro shared library files",
     },
 )
 
diff --git a/rust/private/rust_analyzer.bzl b/rust/private/rust_analyzer.bzl
index bc12306..a08d50c 100644
--- a/rust/private/rust_analyzer.bzl
+++ b/rust/private/rust_analyzer.bzl
@@ -54,7 +54,9 @@
         env = base_info.env,
         deps = base_info.deps,
         crate_specs = depset(direct = [crate_spec], transitive = [base_info.crate_specs]),
-        proc_macro_dylib_path = base_info.proc_macro_dylib_path,
+        proc_macro_dylibs = depset(transitive = [base_info.proc_macro_dylibs]),
+        build_info_out_dirs = depset(transitive = [base_info.build_info_out_dirs]),
+        proc_macro_dylib = base_info.proc_macro_dylib,
         build_info = base_info.build_info,
     )
 
@@ -135,6 +137,10 @@
         if aliased_target.label in labels_to_rais:
             aliases[labels_to_rais[aliased_target.label]] = aliased_name
 
+    proc_macro_dylib = find_proc_macro_dylib(toolchain, target)
+    proc_macro_dylibs = [proc_macro_dylib] if proc_macro_dylib else None
+    build_info_out_dirs = [build_info.out_dir] if build_info != None and build_info.out_dir != None else None
+
     rust_analyzer_info = write_rust_analyzer_spec_file(ctx, ctx.rule.attr, ctx.label, RustAnalyzerInfo(
         aliases = aliases,
         crate = crate_info,
@@ -142,23 +148,29 @@
         env = crate_info.rustc_env,
         deps = dep_infos,
         crate_specs = depset(transitive = [dep.crate_specs for dep in dep_infos]),
-        proc_macro_dylib_path = find_proc_macro_dylib_path(toolchain, target),
+        proc_macro_dylibs = depset(direct = proc_macro_dylibs, transitive = [dep.proc_macro_dylibs for dep in dep_infos]),
+        build_info_out_dirs = depset(direct = build_info_out_dirs, transitive = [dep.build_info_out_dirs for dep in dep_infos]),
+        proc_macro_dylib = proc_macro_dylib,
         build_info = build_info,
     ))
 
     return [
         rust_analyzer_info,
-        OutputGroupInfo(rust_analyzer_crate_spec = rust_analyzer_info.crate_specs),
+        OutputGroupInfo(
+            rust_analyzer_crate_spec = rust_analyzer_info.crate_specs,
+            rust_analyzer_proc_macro_dylib = rust_analyzer_info.proc_macro_dylibs,
+            rust_analyzer_src = rust_analyzer_info.build_info_out_dirs,
+        ),
     ]
 
-def find_proc_macro_dylib_path(toolchain, target):
-    """Find the proc_macro_dylib_path of target. Returns None if target crate is not type proc-macro.
+def find_proc_macro_dylib(toolchain, target):
+    """Find the proc_macro_dylib of target. Returns None if target crate is not type proc-macro.
 
     Args:
         toolchain: The current rust toolchain.
         target: The current target.
     Returns:
-        (path): The path to the proc macro dylib, or None if this crate is not a proc-macro.
+        (File): The path to the proc macro dylib, or None if this crate is not a proc-macro.
     """
     if rust_common.crate_info in target:
         crate_info = target[rust_common.crate_info]
@@ -174,7 +186,7 @@
     for action in target.actions:
         for output in action.outputs.to_list():
             if output.extension == dylib_ext[1:]:
-                return output.path
+                return output
 
     # Failed to find the dylib path inside a proc-macro crate.
     # TODO: Should this be an error?
@@ -188,7 +200,7 @@
 )
 
 # Paths in the generated JSON file begin with one of these placeholders.
-# The gen_rust_project driver will replace them with absolute paths.
+# The `rust-analyzer` driver will replace them with absolute paths.
 _WORKSPACE_TEMPLATE = "__WORKSPACE__/"
 _EXEC_ROOT_TEMPLATE = "__EXEC_ROOT__/"
 _OUTPUT_BASE_TEMPLATE = "__OUTPUT_BASE__/"
@@ -220,6 +232,7 @@
     crate["edition"] = info.crate.edition
     crate["env"] = {}
     crate["crate_type"] = info.crate.type
+    crate["is_test"] = info.crate.is_test
 
     # Switch on external/ to determine if crates are in the workspace or remote.
     # TODO: Some folks may want to override this for vendored dependencies.
@@ -230,6 +243,14 @@
     crate["root_module"] = path_prefix + info.crate.root.path
     crate["source"] = {"exclude_dirs": [], "include_dirs": []}
 
+    # We're only interested in the build info for local crates as these are the
+    # only ones for which we want build file watching and code lens runnables support.
+    if not is_external and not is_generated:
+        crate["build"] = {
+            "build_file": _WORKSPACE_TEMPLATE + ctx.build_file_path,
+            "label": ctx.label.package + ":" + ctx.label.name,
+        }
+
     if is_generated:
         srcs = getattr(ctx.rule.files, "srcs", [])
         src_map = {src.short_path: src for src in srcs if src.is_source}
@@ -268,8 +289,8 @@
     crate["cfg"] = info.cfgs
     toolchain = find_toolchain(ctx)
     crate["target"] = (_EXEC_ROOT_TEMPLATE + toolchain.target_json.path) if toolchain.target_json else toolchain.target_flag_value
-    if info.proc_macro_dylib_path != None:
-        crate["proc_macro_dylib_path"] = _EXEC_ROOT_TEMPLATE + info.proc_macro_dylib_path
+    if info.proc_macro_dylib != None:
+        crate["proc_macro_dylib_path"] = _EXEC_ROOT_TEMPLATE + info.proc_macro_dylib.path
     return crate
 
 def _rust_analyzer_toolchain_impl(ctx):
diff --git a/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/BUILD.bazel b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/BUILD.bazel
new file mode 100644
index 0000000..c363ad7
--- /dev/null
+++ b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/BUILD.bazel
@@ -0,0 +1,42 @@
+load(
+    "@rules_rust//rust:defs.bzl",
+    "rust_shared_library",
+    "rust_static_library",
+    "rust_test",
+)
+
+rust_shared_library(
+    name = "greeter_cdylib",
+    srcs = [
+        "greeter.rs",
+        "shared_lib.rs",
+    ],
+    crate_root = "shared_lib.rs",
+    edition = "2018",
+)
+
+rust_static_library(
+    name = "greeter_staticlib",
+    srcs = [
+        "greeter.rs",
+        "static_lib.rs",
+    ],
+    crate_root = "static_lib.rs",
+    edition = "2018",
+)
+
+rust_test(
+    name = "auto_discovery_json_test",
+    srcs = ["auto_discovery_json_test.rs"],
+    data = [":auto-discovery.json"],
+    edition = "2018",
+    env = {"AUTO_DISCOVERY_JSON": "$(rootpath :auto-discovery.json)"},
+    # This target is tagged as manual since it's not expected to pass in
+    # contexts outside of `//test/rust_analyzer:rust_analyzer_test`. Run
+    # that target to execute this test.
+    tags = ["manual"],
+    deps = [
+        "//test/rust_analyzer/3rdparty/crates:serde",
+        "//test/rust_analyzer/3rdparty/crates:serde_json",
+    ],
+)
diff --git a/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/auto_discovery_json_test.rs b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/auto_discovery_json_test.rs
new file mode 100644
index 0000000..e015b7c
--- /dev/null
+++ b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/auto_discovery_json_test.rs
@@ -0,0 +1,57 @@
+#[cfg(test)]
+mod tests {
+    use serde::Deserialize;
+    use std::env;
+    use std::path::PathBuf;
+
+    #[derive(Deserialize)]
+    #[serde(tag = "kind")]
+    #[serde(rename_all = "snake_case")]
+    enum DiscoverProject {
+        Finished { project: Project },
+        Progress {},
+    }
+
+    #[derive(Deserialize)]
+    struct Project {
+        crates: Vec<Crate>,
+    }
+
+    #[derive(Deserialize)]
+    struct Crate {
+        display_name: String,
+        root_module: String,
+    }
+
+    #[test]
+    fn test_static_and_shared_lib() {
+        let rust_project_path = PathBuf::from(env::var("AUTO_DISCOVERY_JSON").unwrap());
+        let content = std::fs::read_to_string(&rust_project_path)
+            .unwrap_or_else(|_| panic!("couldn't open {:?}", &rust_project_path));
+        println!("{}", content);
+
+        for line in content.lines() {
+            let discovery: DiscoverProject =
+                serde_json::from_str(line).expect("Failed to deserialize discovery JSON");
+
+            let project = match discovery {
+                DiscoverProject::Finished { project } => project,
+                DiscoverProject::Progress {} => continue,
+            };
+
+            let cdylib = project
+                .crates
+                .iter()
+                .find(|c| &c.display_name == "greeter_cdylib")
+                .unwrap();
+            assert!(cdylib.root_module.ends_with("/shared_lib.rs"));
+
+            let staticlib = project
+                .crates
+                .iter()
+                .find(|c| &c.display_name == "greeter_staticlib")
+                .unwrap();
+            assert!(staticlib.root_module.ends_with("/static_lib.rs"));
+        }
+    }
+}
diff --git a/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/greeter.rs b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/greeter.rs
new file mode 100644
index 0000000..ac87521
--- /dev/null
+++ b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/greeter.rs
@@ -0,0 +1,61 @@
+/// Object that displays a greeting.
+pub struct Greeter {
+    greeting: String,
+}
+
+/// Implementation of Greeter.
+impl Greeter {
+    /// Constructs a new `Greeter`.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use hello_lib::greeter::Greeter;
+    ///
+    /// let greeter = Greeter::new("Hello");
+    /// ```
+    pub fn new(greeting: &str) -> Greeter {
+        Greeter {
+            greeting: greeting.to_string(),
+        }
+    }
+
+    /// Returns the greeting as a string.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use hello_lib::greeter::Greeter;
+    ///
+    /// let greeter = Greeter::new("Hello");
+    /// let greeting = greeter.greeting("World");
+    /// ```
+    pub fn greeting(&self, thing: &str) -> String {
+        format!("{} {}", &self.greeting, thing)
+    }
+
+    /// Prints the greeting.
+    ///
+    /// # Examples
+    ///
+    /// ```
+    /// use hello_lib::greeter::Greeter;
+    ///
+    /// let greeter = Greeter::new("Hello");
+    /// greeter.greet("World");
+    /// ```
+    pub fn greet(&self, thing: &str) {
+        println!("{} {}", &self.greeting, thing);
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::Greeter;
+
+    #[test]
+    fn test_greeting() {
+        let hello = Greeter::new("Hi");
+        assert_eq!("Hi Rust", hello.greeting("Rust"));
+    }
+}
diff --git a/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/shared_lib.rs b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/shared_lib.rs
new file mode 100644
index 0000000..44969c6
--- /dev/null
+++ b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/shared_lib.rs
@@ -0,0 +1 @@
+pub mod greeter;
diff --git a/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/static_lib.rs b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/static_lib.rs
new file mode 100644
index 0000000..44969c6
--- /dev/null
+++ b/test/rust_analyzer/auto_discovery_static_and_shared_lib_test/static_lib.rs
@@ -0,0 +1 @@
+pub mod greeter;
diff --git a/test/rust_analyzer/rust_analyzer_test_runner.sh b/test/rust_analyzer/rust_analyzer_test_runner.sh
index 44c35e0..689d95e 100755
--- a/test/rust_analyzer/rust_analyzer_test_runner.sh
+++ b/test/rust_analyzer/rust_analyzer_test_runner.sh
@@ -102,6 +102,10 @@
     else
         RUST_LOG="${rust_log}" bazel run "@rules_rust//tools/rust_analyzer:gen_rust_project"
     fi
+    
+    echo "Generating auto-discovery.json..."
+    RUST_LOG="${rust_log}" bazel run "@rules_rust//tools/rust_analyzer:discover_bazel_rust_project" > auto-discovery.json
+    
     echo "Building..."
     bazel build //...
     echo "Testing..."
diff --git a/tools/rust_analyzer/3rdparty/BUILD.bazel b/tools/rust_analyzer/3rdparty/BUILD.bazel
index 9739042..6759e52 100644
--- a/tools/rust_analyzer/3rdparty/BUILD.bazel
+++ b/tools/rust_analyzer/3rdparty/BUILD.bazel
@@ -9,6 +9,10 @@
         "anyhow": crate.spec(
             version = "1.0.71",
         ),
+        "camino": crate.spec(
+            features = ["serde1"],
+            version = "1.1.9",
+        ),
         "clap": crate.spec(
             features = [
                 "derive",
diff --git a/tools/rust_analyzer/3rdparty/Cargo.Bazel.lock b/tools/rust_analyzer/3rdparty/Cargo.Bazel.lock
index ab1c359..feef41f 100644
--- a/tools/rust_analyzer/3rdparty/Cargo.Bazel.lock
+++ b/tools/rust_analyzer/3rdparty/Cargo.Bazel.lock
@@ -1,6 +1,6 @@
 # This file is automatically @generated by Cargo.
 # It is not intended for manual editing.
-version = 3
+version = 4
 
 [[package]]
 name = "aho-corasick"
@@ -73,6 +73,15 @@
 checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
 [[package]]
+name = "camino"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
 name = "cc"
 version = "1.0.79"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -130,6 +139,7 @@
 version = "0.0.1"
 dependencies = [
  "anyhow",
+ "camino",
  "clap",
  "env_logger",
  "itertools",
diff --git a/tools/rust_analyzer/3rdparty/crates/BUILD.bazel b/tools/rust_analyzer/3rdparty/crates/BUILD.bazel
index b33911c..a908c68 100644
--- a/tools/rust_analyzer/3rdparty/crates/BUILD.bazel
+++ b/tools/rust_analyzer/3rdparty/crates/BUILD.bazel
@@ -44,6 +44,18 @@
 )
 
 alias(
+    name = "camino-1.1.9",
+    actual = "@rrra__camino-1.1.9//:camino",
+    tags = ["manual"],
+)
+
+alias(
+    name = "camino",
+    actual = "@rrra__camino-1.1.9//:camino",
+    tags = ["manual"],
+)
+
+alias(
     name = "clap-4.3.11",
     actual = "@rrra__clap-4.3.11//:clap",
     tags = ["manual"],
diff --git a/tools/rust_analyzer/3rdparty/crates/BUILD.camino-1.1.9.bazel b/tools/rust_analyzer/3rdparty/crates/BUILD.camino-1.1.9.bazel
new file mode 100644
index 0000000..8f01a81
--- /dev/null
+++ b/tools/rust_analyzer/3rdparty/crates/BUILD.camino-1.1.9.bazel
@@ -0,0 +1,133 @@
+###############################################################################
+# @generated
+# DO NOT MODIFY: This file is auto-generated by a crate_universe tool. To
+# regenerate this file, run the following:
+#
+#     bazel run @@//tools/rust_analyzer/3rdparty:crates_vendor
+###############################################################################
+
+load("@rules_rust//cargo:defs.bzl", "cargo_build_script")
+load("@rules_rust//rust:defs.bzl", "rust_library")
+
+package(default_visibility = ["//visibility:public"])
+
+rust_library(
+    name = "camino",
+    srcs = glob(
+        include = ["**/*.rs"],
+        allow_empty = True,
+    ),
+    compile_data = glob(
+        include = ["**"],
+        allow_empty = True,
+        exclude = [
+            "**/* *",
+            ".tmp_git_root/**/*",
+            "BUILD",
+            "BUILD.bazel",
+            "WORKSPACE",
+            "WORKSPACE.bazel",
+        ],
+    ),
+    crate_features = [
+        "serde",
+        "serde1",
+    ],
+    crate_root = "src/lib.rs",
+    edition = "2018",
+    rustc_flags = [
+        "--cap-lints=allow",
+    ],
+    tags = [
+        "cargo-bazel",
+        "crate-name=camino",
+        "manual",
+        "noclippy",
+        "norustfmt",
+    ],
+    target_compatible_with = select({
+        "@rules_rust//rust/platform:aarch64-apple-darwin": [],
+        "@rules_rust//rust/platform:aarch64-pc-windows-msvc": [],
+        "@rules_rust//rust/platform:aarch64-unknown-linux-gnu": [],
+        "@rules_rust//rust/platform:aarch64-unknown-nixos-gnu": [],
+        "@rules_rust//rust/platform:arm-unknown-linux-gnueabi": [],
+        "@rules_rust//rust/platform:armv7-linux-androideabi": [],
+        "@rules_rust//rust/platform:armv7-unknown-linux-gnueabi": [],
+        "@rules_rust//rust/platform:i686-apple-darwin": [],
+        "@rules_rust//rust/platform:i686-pc-windows-msvc": [],
+        "@rules_rust//rust/platform:i686-unknown-freebsd": [],
+        "@rules_rust//rust/platform:i686-unknown-linux-gnu": [],
+        "@rules_rust//rust/platform:powerpc-unknown-linux-gnu": [],
+        "@rules_rust//rust/platform:s390x-unknown-linux-gnu": [],
+        "@rules_rust//rust/platform:x86_64-apple-darwin": [],
+        "@rules_rust//rust/platform:x86_64-pc-windows-msvc": [],
+        "@rules_rust//rust/platform:x86_64-unknown-freebsd": [],
+        "@rules_rust//rust/platform:x86_64-unknown-linux-gnu": [],
+        "@rules_rust//rust/platform:x86_64-unknown-nixos-gnu": [],
+        "//conditions:default": ["@platforms//:incompatible"],
+    }),
+    version = "1.1.9",
+    deps = [
+        "@rrra__camino-1.1.9//:build_script_build",
+        "@rrra__serde-1.0.171//:serde",
+    ],
+)
+
+cargo_build_script(
+    name = "_bs",
+    srcs = glob(
+        include = ["**/*.rs"],
+        allow_empty = True,
+    ),
+    compile_data = glob(
+        include = ["**"],
+        allow_empty = True,
+        exclude = [
+            "**/* *",
+            "**/*.rs",
+            ".tmp_git_root/**/*",
+            "BUILD",
+            "BUILD.bazel",
+            "WORKSPACE",
+            "WORKSPACE.bazel",
+        ],
+    ),
+    crate_features = [
+        "serde",
+        "serde1",
+    ],
+    crate_name = "build_script_build",
+    crate_root = "build.rs",
+    data = glob(
+        include = ["**"],
+        allow_empty = True,
+        exclude = [
+            "**/* *",
+            ".tmp_git_root/**/*",
+            "BUILD",
+            "BUILD.bazel",
+            "WORKSPACE",
+            "WORKSPACE.bazel",
+        ],
+    ),
+    edition = "2018",
+    pkg_name = "camino",
+    rustc_flags = [
+        "--cap-lints=allow",
+    ],
+    tags = [
+        "cargo-bazel",
+        "crate-name=camino",
+        "manual",
+        "noclippy",
+        "norustfmt",
+    ],
+    version = "1.1.9",
+    visibility = ["//visibility:private"],
+)
+
+alias(
+    name = "build_script_build",
+    actual = ":_bs",
+    tags = ["manual"],
+)
diff --git a/tools/rust_analyzer/3rdparty/crates/defs.bzl b/tools/rust_analyzer/3rdparty/crates/defs.bzl
index bc75a66..081f00b 100644
--- a/tools/rust_analyzer/3rdparty/crates/defs.bzl
+++ b/tools/rust_analyzer/3rdparty/crates/defs.bzl
@@ -296,6 +296,7 @@
     "": {
         _COMMON_CONDITION: {
             "anyhow": Label("@rrra//:anyhow-1.0.71"),
+            "camino": Label("@rrra//:camino-1.1.9"),
             "clap": Label("@rrra//:clap-4.3.11"),
             "env_logger": Label("@rrra//:env_logger-0.10.0"),
             "itertools": Label("@rrra//:itertools-0.11.0"),
@@ -492,6 +493,16 @@
 
     maybe(
         http_archive,
+        name = "rrra__camino-1.1.9",
+        sha256 = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3",
+        type = "tar.gz",
+        urls = ["https://static.crates.io/crates/camino/1.1.9/download"],
+        strip_prefix = "camino-1.1.9",
+        build_file = Label("//tools/rust_analyzer/3rdparty/crates:BUILD.camino-1.1.9.bazel"),
+    )
+
+    maybe(
+        http_archive,
         name = "rrra__cc-1.0.79",
         sha256 = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f",
         type = "tar.gz",
@@ -992,6 +1003,7 @@
 
     return [
         struct(repo = "rrra__anyhow-1.0.71", is_dev_dep = False),
+        struct(repo = "rrra__camino-1.1.9", is_dev_dep = False),
         struct(repo = "rrra__clap-4.3.11", is_dev_dep = False),
         struct(repo = "rrra__env_logger-0.10.0", is_dev_dep = False),
         struct(repo = "rrra__itertools-0.11.0", is_dev_dep = False),
diff --git a/tools/rust_analyzer/BUILD.bazel b/tools/rust_analyzer/BUILD.bazel
index e17691d..018ca09 100644
--- a/tools/rust_analyzer/BUILD.bazel
+++ b/tools/rust_analyzer/BUILD.bazel
@@ -3,8 +3,8 @@
 load("//tools/private:tool_utils.bzl", "aspect_repository")
 
 rust_binary(
-    name = "gen_rust_project",
-    srcs = ["main.rs"],
+    name = "discover_bazel_rust_project",
+    srcs = ["bin/discover_rust_project.rs"],
     edition = "2018",
     rustc_env = {
         "ASPECT_REPOSITORY": aspect_repository(),
@@ -13,10 +13,30 @@
     deps = [
         ":gen_rust_project_lib",
         "//tools/rust_analyzer/3rdparty/crates:anyhow",
+        "//tools/rust_analyzer/3rdparty/crates:camino",
         "//tools/rust_analyzer/3rdparty/crates:clap",
         "//tools/rust_analyzer/3rdparty/crates:env_logger",
         "//tools/rust_analyzer/3rdparty/crates:log",
-        "//util/label",
+        "//tools/rust_analyzer/3rdparty/crates:serde_json",
+    ],
+)
+
+rust_binary(
+    name = "gen_rust_project",
+    srcs = ["bin/gen_rust_project.rs"],
+    edition = "2018",
+    rustc_env = {
+        "ASPECT_REPOSITORY": aspect_repository(),
+    },
+    visibility = ["//visibility:public"],
+    deps = [
+        ":gen_rust_project_lib",
+        "//tools/rust_analyzer/3rdparty/crates:anyhow",
+        "//tools/rust_analyzer/3rdparty/crates:camino",
+        "//tools/rust_analyzer/3rdparty/crates:clap",
+        "//tools/rust_analyzer/3rdparty/crates:env_logger",
+        "//tools/rust_analyzer/3rdparty/crates:log",
+        "//tools/rust_analyzer/3rdparty/crates:serde_json",
     ],
 )
 
@@ -24,7 +44,7 @@
     name = "gen_rust_project_lib",
     srcs = glob(
         ["**/*.rs"],
-        exclude = ["main.rs"],
+        exclude = ["bin"],
     ),
     data = [
         "//rust/private:rust_analyzer_detect_sysroot",
@@ -33,6 +53,8 @@
     deps = [
         "//rust/runfiles",
         "//tools/rust_analyzer/3rdparty/crates:anyhow",
+        "//tools/rust_analyzer/3rdparty/crates:camino",
+        "//tools/rust_analyzer/3rdparty/crates:clap",
         "//tools/rust_analyzer/3rdparty/crates:log",
         "//tools/rust_analyzer/3rdparty/crates:serde",
         "//tools/rust_analyzer/3rdparty/crates:serde_json",
diff --git a/tools/rust_analyzer/aquery.rs b/tools/rust_analyzer/aquery.rs
index 7bef210..f306aa5 100644
--- a/tools/rust_analyzer/aquery.rs
+++ b/tools/rust_analyzer/aquery.rs
@@ -1,11 +1,11 @@
 use std::collections::{BTreeMap, BTreeSet};
-use std::path::Path;
-use std::path::PathBuf;
-use std::process::Command;
 
 use anyhow::Context;
+use camino::{Utf8Path, Utf8PathBuf};
 use serde::Deserialize;
 
+use crate::{bazel_command, deserialize_file_content};
+
 #[derive(Debug, Deserialize)]
 struct AqueryOutput {
     artifacts: Vec<Artifact>,
@@ -50,7 +50,16 @@
     pub cfg: Vec<String>,
     pub env: BTreeMap<String, String>,
     pub target: String,
-    pub crate_type: String,
+    pub crate_type: CrateType,
+    pub is_test: bool,
+    pub build: Option<CrateSpecBuild>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct CrateSpecBuild {
+    pub label: String,
+    pub build_file: String,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
@@ -60,29 +69,38 @@
     pub include_dirs: Vec<String>,
 }
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum CrateType {
+    Bin,
+    Rlib,
+    Lib,
+    Dylib,
+    Cdylib,
+    Staticlib,
+    ProcMacro,
+}
+
+#[allow(clippy::too_many_arguments)]
 pub fn get_crate_specs(
-    bazel: &Path,
-    config: &Option<String>,
-    workspace: &Path,
-    execution_root: &Path,
+    bazel: &Utf8Path,
+    output_base: &Utf8Path,
+    workspace: &Utf8Path,
+    execution_root: &Utf8Path,
+    bazel_startup_options: &[String],
+    bazel_args: &[String],
     targets: &[String],
     rules_rust_name: &str,
 ) -> anyhow::Result<BTreeSet<CrateSpec>> {
+    log::info!("running bazel aquery...");
     log::debug!("Get crate specs with targets: {:?}", targets);
     let target_pattern = format!("deps({})", targets.join("+"));
-    let config_args = match config {
-        Some(config) => vec!["--config", config],
-        None => Vec::new(),
-    };
 
-    let mut aquery_command = Command::new(bazel);
+    let mut aquery_command = bazel_command(bazel, Some(workspace), Some(output_base));
     aquery_command
-        .current_dir(workspace)
-        .env_remove("BAZELISK_SKIP_WRAPPER")
-        .env_remove("BUILD_WORKING_DIRECTORY")
-        .env_remove("BUILD_WORKSPACE_DIRECTORY")
+        .args(bazel_startup_options)
         .arg("aquery")
-        .args(config_args)
+        .args(bazel_args)
         .arg("--include_aspects")
         .arg("--include_artifacts")
         .arg(format!(
@@ -98,6 +116,8 @@
         .output()
         .context("Failed to spawn aquery command")?;
 
+    log::info!("bazel aquery finished; parsing spec files...");
+
     let aquery_results = String::from_utf8(aquery_output.stdout)
         .context("Failed to decode aquery results as utf-8.")?;
 
@@ -107,22 +127,16 @@
 
     let crate_specs = crate_spec_files
         .into_iter()
-        .map(|file| {
-            let content = std::fs::read_to_string(&file)
-                .with_context(|| format!("Failed to read file: {}", file.display()))?;
-            log::trace!("{}\n{}", file.display(), content);
-            serde_json::from_str(&content)
-                .with_context(|| format!("Failed to deserialize file: {}", file.display()))
-        })
+        .map(|file| deserialize_file_content(&file, output_base, workspace, execution_root))
         .collect::<anyhow::Result<Vec<CrateSpec>>>()?;
 
     consolidate_crate_specs(crate_specs)
 }
 
 fn parse_aquery_output_files(
-    execution_root: &Path,
+    execution_root: &Utf8Path,
     aquery_stdout: &str,
-) -> anyhow::Result<Vec<PathBuf>> {
+) -> anyhow::Result<Vec<Utf8PathBuf>> {
     let out: AqueryOutput = serde_json::from_str(aquery_stdout).map_err(|_| {
         // Parsing to `AqueryOutput` failed, try parsing into a `serde_json::Value`:
         match serde_json::from_str::<serde_json::Value>(aquery_stdout) {
@@ -147,7 +161,7 @@
         .map(|pf| (pf.id, pf))
         .collect::<BTreeMap<_, _>>();
 
-    let mut output_files: Vec<PathBuf> = Vec::new();
+    let mut output_files: Vec<Utf8PathBuf> = Vec::new();
     for action in out.actions {
         for output_id in action.output_ids {
             let artifact = artifacts
@@ -169,15 +183,15 @@
 fn path_from_fragments(
     id: u32,
     fragments: &BTreeMap<u32, &PathFragment>,
-) -> anyhow::Result<PathBuf> {
+) -> anyhow::Result<Utf8PathBuf> {
     let path_fragment = fragments
         .get(&id)
         .expect("internal consistency error in bazel output");
 
     let buf = match path_fragment.parent_id {
         Some(parent_id) => path_from_fragments(parent_id, fragments)?
-            .join(PathBuf::from(&path_fragment.label.clone())),
-        None => PathBuf::from(&path_fragment.label.clone()),
+            .join(Utf8PathBuf::from(&path_fragment.label.clone())),
+        None => Utf8PathBuf::from(&path_fragment.label.clone()),
     };
 
     Ok(buf)
@@ -191,6 +205,7 @@
         log::debug!("{:?}", spec);
         if let Some(existing) = consolidated_specs.get_mut(&spec.crate_id) {
             existing.deps.extend(spec.deps);
+            existing.env.extend(spec.env);
             existing.aliases.extend(spec.aliases);
 
             if let Some(source) = &mut existing.source {
@@ -215,9 +230,18 @@
             // seems to use display_name for matching crate entries in rust-project.json
             // against symbols in source files. For more details, see
             // https://github.com/bazelbuild/rules_rust/issues/1032
-            if spec.crate_type == "rlib" {
+            if spec.crate_type == CrateType::Rlib {
                 existing.display_name = spec.display_name;
-                existing.crate_type = "rlib".into();
+                existing.crate_type = CrateType::Rlib;
+                existing.is_test = spec.is_test;
+            }
+
+            // We want to use the test target's build label to provide
+            // unit tests codelens actions for library crates in IDEs.
+            if spec.is_test {
+                if let Some(build) = spec.build {
+                    existing.build = Some(build);
+                }
             }
 
             // For proc-macro crates that exist within the workspace, there will be a
@@ -258,7 +282,12 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: false,
+                build: Some(CrateSpecBuild {
+                    label: "//:mylib".to_owned(),
+                    build_file: "BUILD.bazel".to_owned(),
+                }),
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -273,7 +302,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: false,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -288,7 +319,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: false,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -303,7 +336,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "bin".into(),
+                crate_type: CrateType::Bin,
+                is_test: true,
+                build: None,
             },
         ];
 
@@ -323,7 +358,12 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: false,
+                    build: Some(CrateSpecBuild {
+                        label: "//:mylib".to_owned(),
+                        build_file: "BUILD.bazel".to_owned(),
+                    }),
                 },
                 CrateSpec {
                     aliases: BTreeMap::new(),
@@ -338,7 +378,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: false,
+                    build: None
                 },
                 CrateSpec {
                     aliases: BTreeMap::new(),
@@ -353,7 +395,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: false,
+                    build: None
                 },
             ])
         );
@@ -375,7 +419,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "bin".into(),
+                crate_type: CrateType::Bin,
+                is_test: true,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -390,7 +436,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: false,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -405,7 +453,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: false,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -420,7 +470,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: false,
+                build: None,
             },
         ];
 
@@ -440,7 +492,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: false,
+                    build: None
                 },
                 CrateSpec {
                     aliases: BTreeMap::new(),
@@ -455,7 +509,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: false,
+                    build: None
                 },
                 CrateSpec {
                     aliases: BTreeMap::new(),
@@ -470,7 +526,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: false,
+                    build: None
                 },
             ])
         );
@@ -497,7 +555,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: false,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -512,7 +572,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "bin".into(),
+                crate_type: CrateType::Bin,
+                is_test: true,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -527,7 +589,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "bin".into(),
+                crate_type: CrateType::Bin,
+                is_test: false,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -542,7 +606,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: false,
+                build: None,
             },
         ];
 
@@ -563,7 +629,9 @@
                         cfg: vec!["test".into(), "debug_assertions".into()],
                         env: BTreeMap::new(),
                         target: "x86_64-unknown-linux-gnu".into(),
-                        crate_type: "rlib".into(),
+                        crate_type: CrateType::Rlib,
+                        is_test: false,
+                        build: None,
                     },
                     CrateSpec {
                         aliases: BTreeMap::new(),
@@ -578,7 +646,9 @@
                         cfg: vec!["test".into(), "debug_assertions".into()],
                         env: BTreeMap::new(),
                         target: "x86_64-unknown-linux-gnu".into(),
-                        crate_type: "rlib".into(),
+                        crate_type: CrateType::Rlib,
+                        is_test: false,
+                        build: None
                     },
                 ])
             );
@@ -607,7 +677,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "proc_macro".into(),
+                crate_type: CrateType::ProcMacro,
+                is_test: false,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -624,7 +696,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "proc_macro".into(),
+                crate_type: CrateType::ProcMacro,
+                is_test: false,
+                build: None,
             },
         ];
 
@@ -647,7 +721,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "proc_macro".into(),
+                    crate_type: CrateType::ProcMacro,
+                    is_test: false,
+                    build: None,
                 },])
             );
         }
@@ -669,7 +745,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: true,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::from([("ID-mylib_dep.rs".into(), "aliased_name".into())]),
@@ -684,7 +762,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "bin".into(),
+                crate_type: CrateType::Bin,
+                is_test: true,
+                build: None,
             },
         ];
 
@@ -704,7 +784,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: true,
+                    build: None,
                 }])
             );
         }
@@ -726,7 +808,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: true,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -744,7 +828,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "bin".into(),
+                crate_type: CrateType::Bin,
+                is_test: true,
+                build: None,
             },
         ];
 
@@ -767,7 +853,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: true,
+                    build: None,
                 }])
             );
         }
@@ -792,7 +880,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: true,
+                build: None,
             },
             CrateSpec {
                 aliases: BTreeMap::new(),
@@ -810,7 +900,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "bin".into(),
+                crate_type: CrateType::Bin,
+                is_test: true,
+                build: None,
             },
         ];
 
@@ -833,7 +925,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: true,
+                    build: None,
                 }])
             );
         }
diff --git a/tools/rust_analyzer/bin/discover_rust_project.rs b/tools/rust_analyzer/bin/discover_rust_project.rs
new file mode 100644
index 0000000..9598f05
--- /dev/null
+++ b/tools/rust_analyzer/bin/discover_rust_project.rs
@@ -0,0 +1,211 @@
+//! Binary used for automatic Rust workspace discovery by `rust-analyzer`.
+//! See [rust-analyzer documentation][rd] for a thorough description of this interface.
+//! [rd]: <https://rust-analyzer.github.io/manual.html#rust-analyzer.workspace.discoverConfig>.
+
+use std::{
+    env,
+    io::{self, Write},
+};
+
+use anyhow::Context;
+use camino::{Utf8Path, Utf8PathBuf};
+use clap::Parser;
+use env_logger::{fmt::Formatter, Target, WriteStyle};
+use gen_rust_project_lib::{
+    bazel_info, generate_rust_project, DiscoverProject, RustAnalyzerArg, BUILD_FILE_NAMES,
+    WORKSPACE_ROOT_FILE_NAMES,
+};
+use log::{LevelFilter, Record};
+
+/// Looks within the current directory for a file that marks a bazel workspace.
+///
+/// # Errors
+///
+/// Returns an error if no file from [`WORKSPACE_ROOT_FILE_NAMES`] is found.
+fn find_workspace_root_file(workspace: &Utf8Path) -> anyhow::Result<Utf8PathBuf> {
+    BUILD_FILE_NAMES
+        .iter()
+        .chain(WORKSPACE_ROOT_FILE_NAMES)
+        .map(|file| workspace.join(file))
+        .find(|p| p.exists())
+        .with_context(|| format!("no root file found for bazel workspace {workspace}"))
+}
+
+fn project_discovery() -> anyhow::Result<DiscoverProject<'static>> {
+    let Config {
+        workspace,
+        execution_root,
+        output_base,
+        bazel,
+        bazel_startup_options,
+        bazel_args,
+        rust_analyzer_argument,
+    } = Config::parse()?;
+
+    log::info!("got rust-analyzer argument: {rust_analyzer_argument:?}");
+
+    let ra_arg = match rust_analyzer_argument {
+        Some(ra_arg) => ra_arg,
+        None => RustAnalyzerArg::Buildfile(find_workspace_root_file(&workspace)?),
+    };
+
+    let rules_rust_name = env!("ASPECT_REPOSITORY");
+
+    log::info!("resolved rust-analyzer argument: {ra_arg:?}");
+
+    let (buildfile, targets) = ra_arg.into_target_details(&workspace)?;
+
+    log::debug!("got buildfile: {buildfile}");
+    log::debug!("got targets: {targets}");
+
+    // Use the generated files to print the rust-project.json.
+    let project = generate_rust_project(
+        &bazel,
+        &output_base,
+        &workspace,
+        &execution_root,
+        &bazel_startup_options,
+        &bazel_args,
+        rules_rust_name,
+        &[targets],
+    )?;
+
+    Ok(DiscoverProject::Finished { buildfile, project })
+}
+
+#[allow(clippy::writeln_empty_string)]
+fn write_discovery<W>(mut writer: W, discovery: DiscoverProject) -> std::io::Result<()>
+where
+    W: Write,
+{
+    serde_json::to_writer(&mut writer, &discovery)?;
+    // `rust-analyzer` reads messages line by line, so we must add a newline after each
+    writeln!(writer, "")
+}
+
+fn main() -> anyhow::Result<()> {
+    let log_format_fn = |fmt: &mut Formatter, rec: &Record| {
+        let message = rec.args();
+        let discovery = DiscoverProject::Progress { message };
+        write_discovery(fmt, discovery)
+    };
+
+    // Treat logs as progress messages.
+    env_logger::Builder::from_default_env()
+        // Never write color/styling info
+        .write_style(WriteStyle::Never)
+        // Format logs as progress messages
+        .format(log_format_fn)
+        // `rust-analyzer` reads the stdout
+        .filter_level(LevelFilter::Debug)
+        .target(Target::Stdout)
+        .init();
+
+    let discovery = match project_discovery() {
+        Ok(discovery) => discovery,
+        Err(error) => DiscoverProject::Error {
+            error: error.to_string(),
+            source: error.source().as_ref().map(ToString::to_string),
+        },
+    };
+
+    write_discovery(io::stdout(), discovery)?;
+    Ok(())
+}
+
+#[derive(Debug)]
+pub struct Config {
+    /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`.
+    workspace: Utf8PathBuf,
+
+    /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`.
+    execution_root: Utf8PathBuf,
+
+    /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`.
+    output_base: Utf8PathBuf,
+
+    /// The path to a Bazel binary.
+    bazel: Utf8PathBuf,
+
+    /// Startup options to pass to `bazel` invocations.
+    /// See the [Command-Line Reference](<https://bazel.build/reference/command-line-reference>)
+    /// for more details.
+    bazel_startup_options: Vec<String>,
+
+    /// Arguments to pass to `bazel` invocations.
+    /// See the [Command-Line Reference](<https://bazel.build/reference/command-line-reference>)
+    /// for more details.
+    bazel_args: Vec<String>,
+
+    /// The argument that `rust-analyzer` can pass to the binary.
+    rust_analyzer_argument: Option<RustAnalyzerArg>,
+}
+
+impl Config {
+    // Parse the configuration flags and supplement with bazel info as needed.
+    pub fn parse() -> anyhow::Result<Self> {
+        let ConfigParser {
+            workspace,
+            bazel,
+            bazel_startup_options,
+            bazel_args,
+            rust_analyzer_argument,
+        } = ConfigParser::parse();
+
+        // We need some info from `bazel info`. Fetch it now.
+        let mut info_map = bazel_info(
+            &bazel,
+            workspace.as_deref(),
+            None,
+            &bazel_startup_options,
+            &bazel_args,
+        )?;
+
+        let config = Config {
+            workspace: info_map
+                .remove("workspace")
+                .expect("'workspace' must exist in bazel info")
+                .into(),
+            execution_root: info_map
+                .remove("execution_root")
+                .expect("'execution_root' must exist in bazel info")
+                .into(),
+            output_base: info_map
+                .remove("output_base")
+                .expect("'output_base' must exist in bazel info")
+                .into(),
+            bazel,
+            bazel_startup_options,
+            bazel_args,
+            rust_analyzer_argument,
+        };
+
+        Ok(config)
+    }
+}
+
+#[derive(Debug, Parser)]
+struct ConfigParser {
+    /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`.
+    #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")]
+    workspace: Option<Utf8PathBuf>,
+
+    /// The path to a Bazel binary.
+    #[clap(long, default_value = "bazel")]
+    bazel: Utf8PathBuf,
+
+    /// Startup options to pass to `bazel` invocations.
+    /// See the [Command-Line Reference](<https://bazel.build/reference/command-line-reference>)
+    /// for more details.
+    #[clap(long = "bazel_startup_option")]
+    bazel_startup_options: Vec<String>,
+
+    /// Arguments to pass to `bazel` invocations.
+    /// See the [Command-Line Reference](<https://bazel.build/reference/command-line-reference>)
+    /// for more details.
+    #[clap(long = "bazel_arg")]
+    bazel_args: Vec<String>,
+
+    /// The argument that `rust-analyzer` can pass to the binary.
+    rust_analyzer_argument: Option<RustAnalyzerArg>,
+}
diff --git a/tools/rust_analyzer/bin/gen_rust_project.rs b/tools/rust_analyzer/bin/gen_rust_project.rs
new file mode 100644
index 0000000..e7892e8
--- /dev/null
+++ b/tools/rust_analyzer/bin/gen_rust_project.rs
@@ -0,0 +1,179 @@
+use std::{
+    env,
+    fs::OpenOptions,
+    io::{BufWriter, ErrorKind},
+};
+
+use anyhow::{bail, Context};
+use camino::Utf8PathBuf;
+use clap::Parser;
+use gen_rust_project_lib::{bazel_info, generate_rust_project};
+
+fn write_rust_project() -> anyhow::Result<()> {
+    let Config {
+        workspace,
+        execution_root,
+        output_base,
+        bazel,
+        bazel_args,
+        targets,
+    } = Config::parse()?;
+
+    let rules_rust_name = env!("ASPECT_REPOSITORY");
+
+    let rust_project = generate_rust_project(
+        &bazel,
+        &output_base,
+        &workspace,
+        &execution_root,
+        &[],
+        &bazel_args,
+        rules_rust_name,
+        &targets,
+    )?;
+
+    let rust_project_path = &workspace.join("rust-project.json");
+
+    // Try to remove the existing rust-project.json. It's OK if the file doesn't exist.
+    match std::fs::remove_file(rust_project_path) {
+        Ok(_) => {}
+        Err(err) if err.kind() == ErrorKind::NotFound => {}
+        Err(err) => bail!("Unexpected error removing old rust-project.json: {}", err),
+    }
+
+    // Write the new rust-project.json file.
+    let file = OpenOptions::new()
+        .write(true)
+        .create(true)
+        .truncate(true)
+        .open(rust_project_path)
+        .with_context(|| format!("could not open: {rust_project_path}"))
+        .map(BufWriter::new)?;
+
+    serde_json::to_writer(file, &rust_project)?;
+    Ok(())
+}
+
+// TODO(david): This shells out to an expected rule in the workspace root //:rust_analyzer that the user must define.
+// It would be more convenient if it could automatically discover all the rust code in the workspace if this target
+// does not exist.
+fn main() -> anyhow::Result<()> {
+    env_logger::init();
+
+    // Write rust-project.json.
+    write_rust_project()?;
+    Ok(())
+}
+
+#[derive(Debug)]
+pub struct Config {
+    /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`.
+    workspace: Utf8PathBuf,
+
+    /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`.
+    execution_root: Utf8PathBuf,
+
+    /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`.
+    output_base: Utf8PathBuf,
+
+    /// The path to a Bazel binary.
+    bazel: Utf8PathBuf,
+
+    /// Arguments to pass to `bazel` invocations.
+    /// See the [Command-Line Reference](<https://bazel.build/reference/command-line-reference>)
+    /// for more details.
+    bazel_args: Vec<String>,
+
+    /// Space separated list of target patterns that comes after all other args.
+    targets: Vec<String>,
+}
+
+impl Config {
+    // Parse the configuration flags and supplement with bazel info as needed.
+    pub fn parse() -> anyhow::Result<Self> {
+        let ConfigParser {
+            workspace,
+            execution_root,
+            output_base,
+            bazel,
+            config,
+            targets,
+        } = ConfigParser::parse();
+
+        let bazel_args = config
+            .into_iter()
+            .map(|s| format!("--config={s}"))
+            .collect();
+
+        // Implemented this way instead of a classic `if let` to satisfy the
+        // borrow checker.
+        // See: <https://github.com/rust-lang/rust/issues/54663>
+        #[allow(clippy::unnecessary_unwrap)]
+        if workspace.is_some() && execution_root.is_some() && output_base.is_some() {
+            return Ok(Config {
+                workspace: workspace.unwrap(),
+                execution_root: execution_root.unwrap(),
+                output_base: output_base.unwrap(),
+                bazel,
+                bazel_args,
+                targets,
+            });
+        }
+
+        // We need some info from `bazel info`. Fetch it now.
+        let mut info_map = bazel_info(
+            &bazel,
+            workspace.as_deref(),
+            output_base.as_deref(),
+            &[],
+            &[],
+        )?;
+
+        let config = Config {
+            workspace: info_map
+                .remove("workspace")
+                .expect("'workspace' must exist in bazel info")
+                .into(),
+            execution_root: info_map
+                .remove("execution_root")
+                .expect("'execution_root' must exist in bazel info")
+                .into(),
+            output_base: info_map
+                .remove("output_base")
+                .expect("'output_base' must exist in bazel info")
+                .into(),
+            bazel,
+            bazel_args,
+            targets,
+        };
+
+        Ok(config)
+    }
+}
+
+#[derive(Debug, Parser)]
+struct ConfigParser {
+    /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`.
+    #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")]
+    workspace: Option<Utf8PathBuf>,
+
+    /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`.
+    #[clap(long)]
+    execution_root: Option<Utf8PathBuf>,
+
+    /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`.
+    #[clap(long, env = "OUTPUT_BASE")]
+    output_base: Option<Utf8PathBuf>,
+
+    /// The path to a Bazel binary.
+    #[clap(long, default_value = "bazel")]
+    bazel: Utf8PathBuf,
+
+    /// A config to pass to Bazel invocations with `--config=<config>`.
+    #[clap(long)]
+    config: Option<String>,
+
+    /// Space separated list of target patterns that comes after all other args.
+    #[clap(default_value = "@//...")]
+    targets: Vec<String>,
+}
diff --git a/tools/rust_analyzer/lib.rs b/tools/rust_analyzer/lib.rs
index 6842216..4ea641e 100644
--- a/tools/rust_analyzer/lib.rs
+++ b/tools/rust_analyzer/lib.rs
@@ -1,94 +1,200 @@
-use std::collections::HashMap;
-use std::path::Path;
-use std::process::Command;
-
-use anyhow::anyhow;
-use runfiles::Runfiles;
-
 mod aquery;
 mod rust_project;
 
-pub fn generate_crate_info(
-    bazel: impl AsRef<Path>,
-    config: &Option<String>,
-    workspace: impl AsRef<Path>,
-    rules_rust: impl AsRef<str>,
+use std::{collections::BTreeMap, convert::TryInto, fs, process::Command};
+
+use anyhow::{bail, Context};
+use camino::{Utf8Path, Utf8PathBuf};
+use runfiles::Runfiles;
+use rust_project::RustProject;
+pub use rust_project::{DiscoverProject, RustAnalyzerArg};
+use serde::{de::DeserializeOwned, Deserialize};
+
+pub const WORKSPACE_ROOT_FILE_NAMES: &[&str] =
+    &["MODULE.bazel", "REPO.bazel", "WORKSPACE.bazel", "WORKSPACE"];
+
+pub const BUILD_FILE_NAMES: &[&str] = &["BUILD.bazel", "BUILD"];
+
+#[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> {
+    generate_crate_info(
+        bazel,
+        output_base,
+        workspace,
+        bazel_startup_options,
+        bazel_args,
+        rules_rust_name,
+        targets,
+    )?;
+
+    let crate_specs = aquery::get_crate_specs(
+        bazel,
+        output_base,
+        workspace,
+        execution_root,
+        bazel_startup_options,
+        bazel_args,
+        targets,
+        rules_rust_name,
+    )?;
+
+    let path: Utf8PathBuf = runfiles::rlocation!(
+        Runfiles::create()?,
+        "rules_rust/rust/private/rust_analyzer_detect_sysroot.rust_analyzer_toolchain.json"
+    )
+    .context("toolchain runfile not found")?
+    .try_into()?;
+
+    let toolchain_info = deserialize_file_content(&path, output_base, workspace, execution_root)?;
+
+    rust_project::assemble_rust_project(bazel, workspace, toolchain_info, &crate_specs)
+}
+
+/// 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)
+}
+
+fn generate_crate_info(
+    bazel: &Utf8Path,
+    output_base: &Utf8Path,
+    workspace: &Utf8Path,
+    bazel_startup_options: &[String],
+    bazel_args: &[String],
+    rules_rust: &str,
     targets: &[String],
 ) -> anyhow::Result<()> {
+    log::info!("running bazel build...");
     log::debug!("Building rust_analyzer_crate_spec files for {:?}", targets);
-    let config_args = match config {
-        Some(config) => vec!["--config", config],
-        None => Vec::new(),
-    };
 
-    let output = Command::new(bazel.as_ref())
-        .current_dir(workspace.as_ref())
-        .env_remove("BAZELISK_SKIP_WRAPPER")
-        .env_remove("BUILD_WORKING_DIRECTORY")
-        .env_remove("BUILD_WORKSPACE_DIRECTORY")
+    let output = bazel_command(bazel, Some(workspace), Some(output_base))
+        .args(bazel_startup_options)
         .arg("build")
-        .args(config_args)
+        .args(bazel_args)
         .arg("--norun_validations")
         .arg("--remote_download_all")
         .arg(format!(
-            "--aspects={}//rust:defs.bzl%rust_analyzer_aspect",
-            rules_rust.as_ref()
+            "--aspects={rules_rust}//rust:defs.bzl%rust_analyzer_aspect"
         ))
-        .arg("--output_groups=rust_analyzer_crate_spec,rust_generated_srcs")
+        .arg("--output_groups=rust_analyzer_crate_spec,rust_generated_srcs,rust_analyzer_proc_macro_dylib,rust_analyzer_src")
         .args(targets)
         .output()?;
 
     if !output.status.success() {
-        return Err(anyhow!(
-            "bazel build failed:({})\n{}",
-            output.status,
-            String::from_utf8_lossy(&output.stderr)
-        ));
+        let status = output.status;
+        let stderr = String::from_utf8_lossy(&output.stderr);
+        bail!("bazel build failed: ({status})\n{stderr}");
     }
 
+    log::info!("bazel build finished");
+
     Ok(())
 }
 
-#[allow(clippy::too_many_arguments)]
-pub fn write_rust_project(
-    bazel: impl AsRef<Path>,
-    config: &Option<String>,
-    workspace: impl AsRef<Path>,
-    rules_rust_name: &impl AsRef<str>,
-    targets: &[String],
-    execution_root: impl AsRef<Path>,
-    output_base: impl AsRef<Path>,
-    rust_project_path: impl AsRef<Path>,
-) -> anyhow::Result<()> {
-    let crate_specs = aquery::get_crate_specs(
-        bazel.as_ref(),
-        config,
-        workspace.as_ref(),
-        execution_root.as_ref(),
-        targets,
-        rules_rust_name.as_ref(),
-    )?;
+fn bazel_command(
+    bazel: &Utf8Path,
+    workspace: Option<&Utf8Path>,
+    output_base: Option<&Utf8Path>,
+) -> Command {
+    let mut cmd = Command::new(bazel);
 
-    let path = runfiles::rlocation!(
-        Runfiles::create()?,
-        "rules_rust/rust/private/rust_analyzer_detect_sysroot.rust_analyzer_toolchain.json"
-    )
-    .unwrap();
-    let toolchain_info: HashMap<String, String> =
-        serde_json::from_str(&std::fs::read_to_string(path)?)?;
+    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}")));
 
-    let sysroot_src = &toolchain_info["sysroot_src"];
-    let sysroot = &toolchain_info["sysroot"];
+    cmd
+}
 
-    let rust_project = rust_project::generate_rust_project(sysroot, sysroot_src, &crate_specs)?;
+fn deserialize_file_content<T>(
+    path: &Utf8Path,
+    output_base: &Utf8Path,
+    workspace: &Utf8Path,
+    execution_root: &Utf8Path,
+) -> anyhow::Result<T>
+where
+    T: DeserializeOwned,
+{
+    let content = fs::read_to_string(path)
+        .with_context(|| format!("failed to read file: {path}"))?
+        .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());
 
-    rust_project::write_rust_project(
-        rust_project_path.as_ref(),
-        workspace.as_ref(),
-        execution_root.as_ref(),
-        output_base.as_ref(),
-        &rust_project,
-    )?;
+    log::trace!("{}\n{}", path, content);
 
-    Ok(())
+    serde_json::from_str(&content).with_context(|| format!("failed to deserialize file: {path}"))
+}
+
+/// `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!("//{p}:all"),
+        _ => "//...".to_string(),
+    };
+
+    Ok(targets)
+}
+
+#[derive(Debug, Deserialize)]
+struct ToolchainInfo {
+    sysroot: Utf8PathBuf,
+    sysroot_src: Utf8PathBuf,
 }
diff --git a/tools/rust_analyzer/main.rs b/tools/rust_analyzer/main.rs
deleted file mode 100644
index f1dc014..0000000
--- a/tools/rust_analyzer/main.rs
+++ /dev/null
@@ -1,136 +0,0 @@
-use std::collections::HashMap;
-use std::env;
-use std::path::PathBuf;
-use std::process::Command;
-
-use anyhow::anyhow;
-use clap::Parser;
-use gen_rust_project_lib::generate_crate_info;
-use gen_rust_project_lib::write_rust_project;
-
-// TODO(david): This shells out to an expected rule in the workspace root //:rust_analyzer that the user must define.
-// It would be more convenient if it could automatically discover all the rust code in the workspace if this target
-// does not exist.
-fn main() -> anyhow::Result<()> {
-    env_logger::init();
-
-    let config = parse_config()?;
-
-    let workspace_root = config
-        .workspace
-        .as_ref()
-        .expect("failed to find workspace root, set with --workspace");
-
-    let execution_root = config
-        .execution_root
-        .as_ref()
-        .expect("failed to find execution root, is --execution-root set correctly?");
-
-    let output_base = config
-        .output_base
-        .as_ref()
-        .expect("failed to find output base, is -output-base set correctly?");
-
-    let rules_rust_name = env!("ASPECT_REPOSITORY");
-
-    // Generate the crate specs.
-    generate_crate_info(
-        &config.bazel,
-        &config.config,
-        workspace_root,
-        rules_rust_name,
-        &config.targets,
-    )?;
-
-    // Use the generated files to write rust-project.json.
-    write_rust_project(
-        &config.bazel,
-        &config.config,
-        workspace_root,
-        &rules_rust_name,
-        &config.targets,
-        execution_root,
-        output_base,
-        workspace_root.join("rust-project.json"),
-    )?;
-
-    Ok(())
-}
-
-// Parse the configuration flags and supplement with bazel info as needed.
-fn parse_config() -> anyhow::Result<Config> {
-    let mut config = Config::parse();
-
-    if config.workspace.is_some() && config.execution_root.is_some() {
-        return Ok(config);
-    }
-
-    // We need some info from `bazel info`. Fetch it now.
-    let mut bazel_info_command = Command::new(&config.bazel);
-    bazel_info_command
-        .env_remove("BAZELISK_SKIP_WRAPPER")
-        .env_remove("BUILD_WORKING_DIRECTORY")
-        .env_remove("BUILD_WORKSPACE_DIRECTORY")
-        .arg("info");
-    if let Some(workspace) = &config.workspace {
-        bazel_info_command.current_dir(workspace);
-    }
-
-    // Execute bazel info.
-    let output = bazel_info_command.output()?;
-    if !output.status.success() {
-        return Err(anyhow!(
-            "Failed to run `bazel info` ({:?}): {}",
-            output.status,
-            String::from_utf8_lossy(&output.stderr)
-        ));
-    }
-
-    // Extract the output.
-    let output = String::from_utf8_lossy(output.stdout.as_slice());
-    let bazel_info = output
-        .trim()
-        .split('\n')
-        .map(|line| line.split_at(line.find(':').expect("missing `:` in bazel info output")))
-        .map(|(k, v)| (k, (v[1..]).trim()))
-        .collect::<HashMap<_, _>>();
-
-    if config.workspace.is_none() {
-        config.workspace = bazel_info.get("workspace").map(Into::into);
-    }
-    if config.execution_root.is_none() {
-        config.execution_root = bazel_info.get("execution_root").map(Into::into);
-    }
-    if config.output_base.is_none() {
-        config.output_base = bazel_info.get("output_base").map(Into::into);
-    }
-
-    Ok(config)
-}
-
-#[derive(Debug, Parser)]
-struct Config {
-    /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`.
-    #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")]
-    workspace: Option<PathBuf>,
-
-    /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`.
-    #[clap(long)]
-    execution_root: Option<PathBuf>,
-
-    /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`.
-    #[clap(long, env = "OUTPUT_BASE")]
-    output_base: Option<PathBuf>,
-
-    /// A config to pass to Bazel invocations with `--config=<config>`.
-    #[clap(long)]
-    config: Option<String>,
-
-    /// The path to a Bazel binary
-    #[clap(long, default_value = "bazel")]
-    bazel: PathBuf,
-
-    /// Space separated list of target patterns that comes after all other args.
-    #[clap(default_value = "@//...")]
-    targets: Vec<String>,
-}
diff --git a/tools/rust_analyzer/rust_project.rs b/tools/rust_analyzer/rust_project.rs
index f694267..749347e 100644
--- a/tools/rust_analyzer/rust_project.rs
+++ b/tools/rust_analyzer/rust_project.rs
@@ -1,14 +1,74 @@
 //! Library for generating rust_project.json files from a `Vec<CrateSpec>`
 //! See official documentation of file format at https://rust-analyzer.github.io/manual.html
 
-use std::collections::{BTreeMap, BTreeSet, HashMap};
-use std::io::ErrorKind;
-use std::path::Path;
+use core::fmt;
+use std::{
+    collections::{BTreeMap, BTreeSet, HashMap},
+    str::FromStr,
+};
 
-use anyhow::anyhow;
-use serde::Serialize;
+use anyhow::{anyhow, Context};
+use camino::{Utf8Path, Utf8PathBuf};
+use serde::{Deserialize, Serialize};
 
-use crate::aquery::CrateSpec;
+use crate::{
+    aquery::{CrateSpec, CrateType},
+    buildfile_to_targets, source_file_to_buildfile, ToolchainInfo,
+};
+
+/// The argument that `rust-analyzer` can pass to the workspace discovery command.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum RustAnalyzerArg {
+    Path(Utf8PathBuf),
+    Buildfile(Utf8PathBuf),
+}
+
+impl RustAnalyzerArg {
+    /// Consumes itself to return a build file and the targets to build.
+    pub fn into_target_details(
+        self,
+        workspace: &Utf8Path,
+    ) -> anyhow::Result<(Utf8PathBuf, String)> {
+        match self {
+            Self::Path(file) => {
+                let buildfile = source_file_to_buildfile(&file)?;
+                buildfile_to_targets(workspace, &buildfile).map(|t| (buildfile, t))
+            }
+            Self::Buildfile(buildfile) => {
+                buildfile_to_targets(workspace, &buildfile).map(|t| (buildfile, t))
+            }
+        }
+    }
+}
+
+impl FromStr for RustAnalyzerArg {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        serde_json::from_str(s).context("rust analyzer argument error")
+    }
+}
+
+/// The format that `rust_analyzer` expects as a response when automatically invoked.
+/// See [rust-analyzer documentation][rd] for a thorough description of this interface.
+/// [rd]: <https://rust-analyzer.github.io/manual.html#rust-analyzer.workspace.discoverConfig>.
+#[derive(Debug, Serialize)]
+#[serde(tag = "kind")]
+#[serde(rename_all = "snake_case")]
+pub enum DiscoverProject<'a> {
+    Finished {
+        buildfile: Utf8PathBuf,
+        project: RustProject,
+    },
+    Error {
+        error: String,
+        source: Option<String>,
+    },
+    Progress {
+        message: &'a fmt::Arguments<'a>,
+    },
+}
 
 /// A `rust-project.json` workspace representation. See
 /// [rust-analyzer documentation][rd] for a thorough description of this interface.
@@ -16,24 +76,27 @@
 #[derive(Debug, Serialize)]
 pub struct RustProject {
     /// The path to a Rust sysroot.
-    sysroot: Option<String>,
+    sysroot: Utf8PathBuf,
 
     /// Path to the directory with *source code* of
     /// sysroot crates.
-    sysroot_src: Option<String>,
+    sysroot_src: Utf8PathBuf,
 
     /// The set of crates comprising the current
     /// project. Must include all transitive
     /// dependencies as well as sysroot crate (libstd,
     /// libcore and such).
     crates: Vec<Crate>,
+
+    /// The set of runnables, such as tests or benchmarks,
+    /// that can be found in the crate.
+    runnables: Vec<Runnable>,
 }
 
 /// A `rust-project.json` crate representation. See
 /// [rust-analyzer documentation][rd] for a thorough description of this interface.
 /// [rd]: https://rust-analyzer.github.io/manual.html#non-cargo-based-projects
 #[derive(Debug, Serialize)]
-#[serde(default)]
 pub struct Crate {
     /// A name used in the package's project declaration
     #[serde(skip_serializing_if = "Option::is_none")]
@@ -74,6 +137,10 @@
     /// For proc-macro crates, path to compiled proc-macro (.so file).
     #[serde(skip_serializing_if = "Option::is_none")]
     proc_macro_dylib_path: Option<String>,
+
+    /// Build information for the crate
+    #[serde(skip_serializing_if = "Option::is_none")]
+    build: Option<Build>,
 }
 
 #[derive(Debug, Default, Serialize)]
@@ -83,7 +150,6 @@
 }
 
 impl Source {
-    /// Returns true if no include information has been added.
     fn is_empty(&self) -> bool {
         self.include_dirs.is_empty() && self.exclude_dirs.is_empty()
     }
@@ -99,18 +165,131 @@
     name: String,
 }
 
-pub fn generate_rust_project(
-    sysroot: &str,
-    sysroot_src: &str,
-    crates: &BTreeSet<CrateSpec>,
+#[derive(Debug, Serialize)]
+pub struct Build {
+    /// The name associated with this crate.
+    ///
+    /// This is determined by the build system that produced
+    /// the `rust-project.json` in question. For instance, if bazel were used,
+    /// the label might be something like `//ide/rust/rust-analyzer:rust-analyzer`.
+    ///
+    /// Do not attempt to parse the contents of this string; it is a build system-specific
+    /// identifier similar to [`Crate::display_name`].
+    label: String,
+    /// Path corresponding to the build system-specific file defining the crate.
+    ///
+    /// It is roughly analogous to [`ManifestPath`], but it should *not* be used with
+    /// [`crate::ProjectManifest::from_manifest_file`], as the build file may not be
+    /// be in the `rust-project.json`.
+    build_file: Utf8PathBuf,
+    /// The kind of target.
+    ///
+    /// Examples (non-exhaustively) include [`TargetKind::Bin`], [`TargetKind::Lib`],
+    /// and [`TargetKind::Test`]. This information is used to determine what sort
+    /// of runnable codelens to provide, if any.
+    target_kind: TargetKind,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum TargetKind {
+    Bin,
+    /// Any kind of Cargo lib crate-type (dylib, rlib, proc-macro, ...).
+    Lib,
+    Test,
+}
+
+/// A template-like structure for describing runnables.
+///
+/// These are used for running and debugging binaries and tests without encoding
+/// build system-specific knowledge into rust-analyzer.
+///
+/// # Example
+///
+/// Below is an example of a test runnable. `{label}` and `{test_id}`
+/// are explained in [`Runnable::args`]'s documentation.
+///
+/// ```json
+/// {
+///     "program": "bazel",
+///     "args": [
+///         "test",
+///          "{label}",
+///          "--test_arg",
+///          "{test_id}",
+///     ],
+///     "cwd": "/home/user/repo-root/",
+///     "kind": "testOne"
+/// }
+/// ```
+#[derive(Debug, Serialize)]
+pub struct Runnable {
+    /// The program invoked by the runnable.
+    ///
+    /// For example, this might be `cargo`, `bazel`, etc.
+    program: String,
+    /// The arguments passed to [`Runnable::program`].
+    ///
+    /// The args can contain two template strings: `{label}` and `{test_id}`.
+    /// rust-analyzer will find and replace `{label}` with [`Build::label`] and
+    /// `{test_id}` with the test name.
+    args: Vec<String>,
+    /// The current working directory of the runnable.
+    cwd: Utf8PathBuf,
+    kind: RunnableKind,
+}
+
+/// The kind of runnable.
+#[derive(Debug, Clone, Copy, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum RunnableKind {
+    Check,
+
+    /// Can run a binary.
+    Run,
+
+    /// Run a single test.
+    TestOne,
+}
+
+pub fn assemble_rust_project(
+    bazel: &Utf8Path,
+    workspace: &Utf8Path,
+    toolchain_info: ToolchainInfo,
+    crate_specs: &BTreeSet<CrateSpec>,
 ) -> anyhow::Result<RustProject> {
     let mut project = RustProject {
-        sysroot: Some(sysroot.into()),
-        sysroot_src: Some(sysroot_src.into()),
+        sysroot: toolchain_info.sysroot,
+        sysroot_src: toolchain_info.sysroot_src,
         crates: Vec::new(),
+        runnables: vec![
+            Runnable {
+                program: bazel.to_string(),
+                args: vec!["build".to_owned(), "{label}".to_owned()],
+                cwd: workspace.to_owned(),
+                kind: RunnableKind::Check,
+            },
+            Runnable {
+                program: bazel.to_string(),
+                args: vec![
+                    "test".to_owned(),
+                    "{label}".to_owned(),
+                    "--test_output".to_owned(),
+                    "streamed".to_owned(),
+                    "--test_arg".to_owned(),
+                    "--nocapture".to_owned(),
+                    "--test_arg".to_owned(),
+                    "--exact".to_owned(),
+                    "--test_arg".to_owned(),
+                    "{test_id}".to_owned(),
+                ],
+                cwd: workspace.to_owned(),
+                kind: RunnableKind::TestOne,
+            },
+        ],
     };
 
-    let mut unmerged_crates: Vec<&CrateSpec> = crates.iter().collect();
+    let mut unmerged_crates: Vec<&CrateSpec> = crate_specs.iter().collect();
     let mut skipped_crates: Vec<&CrateSpec> = Vec::new();
     let mut merged_crates_index: HashMap<String, usize> = HashMap::new();
 
@@ -133,6 +312,29 @@
             } else {
                 log::trace!("Merging crate {}", &c.crate_id);
                 merged_crates_index.insert(c.crate_id.clone(), project.crates.len());
+
+                let target_kind = match c.crate_type {
+                    CrateType::Bin if c.is_test => TargetKind::Test,
+                    CrateType::Bin => TargetKind::Bin,
+                    CrateType::Rlib
+                    | CrateType::Lib
+                    | CrateType::Dylib
+                    | CrateType::Cdylib
+                    | CrateType::Staticlib
+                    | CrateType::ProcMacro => TargetKind::Lib,
+                };
+
+                if let Some(build) = &c.build {
+                    if target_kind == TargetKind::Bin {
+                        project.runnables.push(Runnable {
+                            program: bazel.to_string(),
+                            args: vec!["run".to_string(), build.label.to_owned()],
+                            cwd: workspace.to_owned(),
+                            kind: RunnableKind::Run,
+                        });
+                    }
+                }
+
                 project.crates.push(Crate {
                     display_name: Some(c.display_name.clone()),
                     root_module: c.root_module.clone(),
@@ -170,6 +372,11 @@
                     env: Some(c.env.clone()),
                     is_proc_macro: c.proc_macro_dylib_path.is_some(),
                     proc_macro_dylib_path: c.proc_macro_dylib_path.clone(),
+                    build: c.build.as_ref().map(|b| Build {
+                        label: b.label.clone(),
+                        build_file: b.build_file.clone().into(),
+                        target_kind,
+                    }),
                 });
             }
         }
@@ -241,51 +448,6 @@
     None
 }
 
-pub fn write_rust_project(
-    rust_project_path: &Path,
-    workspace: &Path,
-    execution_root: &Path,
-    output_base: &Path,
-    rust_project: &RustProject,
-) -> anyhow::Result<()> {
-    let workspace = workspace
-        .to_str()
-        .ok_or_else(|| anyhow!("workspace is not valid UTF-8"))?;
-
-    let execution_root = execution_root
-        .to_str()
-        .ok_or_else(|| anyhow!("execution_root is not valid UTF-8"))?;
-
-    let output_base = output_base
-        .to_str()
-        .ok_or_else(|| anyhow!("output_base is not valid UTF-8"))?;
-
-    // Try to remove the existing rust-project.json. It's OK if the file doesn't exist.
-    match std::fs::remove_file(rust_project_path) {
-        Ok(_) => {}
-        Err(err) if err.kind() == ErrorKind::NotFound => {}
-        Err(err) => {
-            return Err(anyhow!(
-                "Unexpected error removing old rust-project.json: {}",
-                err
-            ))
-        }
-    }
-
-    // Render the `rust-project.json` file and replace the exec root
-    // placeholders with the path to the local exec root.
-    let rust_project_content = serde_json::to_string_pretty(rust_project)?
-        .replace("${pwd}", execution_root)
-        .replace("__EXEC_ROOT__", execution_root)
-        .replace("__OUTPUT_BASE__", output_base)
-        .replace("__WORKSPACE__", workspace);
-
-    // Write the new rust-project.json file.
-    std::fs::write(rust_project_path, rust_project_content)?;
-
-    Ok(())
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -293,9 +455,13 @@
     /// A simple example with a single crate and no dependencies.
     #[test]
     fn generate_rust_project_single() {
-        let project = generate_rust_project(
-            "sysroot",
-            "sysroot_src",
+        let project = assemble_rust_project(
+            Utf8Path::new("bazel"),
+            Utf8Path::new("workspace"),
+            ToolchainInfo {
+                sysroot: "sysroot".to_owned().into(),
+                sysroot_src: "sysroot_src".to_owned().into(),
+            },
             &BTreeSet::from([CrateSpec {
                 aliases: BTreeMap::new(),
                 crate_id: "ID-example".into(),
@@ -309,7 +475,9 @@
                 cfg: vec!["test".into(), "debug_assertions".into()],
                 env: BTreeMap::new(),
                 target: "x86_64-unknown-linux-gnu".into(),
-                crate_type: "rlib".into(),
+                crate_type: CrateType::Rlib,
+                is_test: false,
+                build: None,
             }]),
         )
         .expect("expect success");
@@ -324,9 +492,13 @@
     /// An example with a one crate having two dependencies.
     #[test]
     fn generate_rust_project_with_deps() {
-        let project = generate_rust_project(
-            "sysroot",
-            "sysroot_src",
+        let project = assemble_rust_project(
+            Utf8Path::new("bazel"),
+            Utf8Path::new("workspace"),
+            ToolchainInfo {
+                sysroot: "sysroot".to_owned().into(),
+                sysroot_src: "sysroot_src".to_owned().into(),
+            },
             &BTreeSet::from([
                 CrateSpec {
                     aliases: BTreeMap::new(),
@@ -341,7 +513,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: false,
+                    build: None,
                 },
                 CrateSpec {
                     aliases: BTreeMap::new(),
@@ -356,7 +530,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: false,
+                    build: None,
                 },
                 CrateSpec {
                     aliases: BTreeMap::new(),
@@ -371,7 +547,9 @@
                     cfg: vec!["test".into(), "debug_assertions".into()],
                     env: BTreeMap::new(),
                     target: "x86_64-unknown-linux-gnu".into(),
-                    crate_type: "rlib".into(),
+                    crate_type: CrateType::Rlib,
+                    is_test: false,
+                    build: None,
                 },
             ]),
         )