From 964109670965afeeb53b077fff67b82733fa62be Mon Sep 17 00:00:00 2001 From: UebelAndre Date: Tue, 9 Jun 2026 08:04:47 -0700 Subject: [PATCH 1/4] Add `discoverConfig` Support for rust-analyzer --- docs/src/rust_analyzer.md | 442 +++-- rust/private/rust_analyzer.bzl | 35 +- rust/private/rustfmt.bzl | 1 + tools/rust_analyzer/BUILD.bazel | 136 ++ tools/rust_analyzer/aquery.rs | 151 +- tools/rust_analyzer/bep.rs | 442 +++++ tools/rust_analyzer/bin/flycheck.rs | 291 +++ tools/rust_analyzer/bin/rust_analyzer.rs | 89 + .../bin/rust_analyzer_proc_macro_srv.rs | 88 + tools/rust_analyzer/bin/rustfmt.rs | 77 + tools/rust_analyzer/bin/setup.rs | 1659 +++++++++++++++++ tools/rust_analyzer/cache.rs | 191 ++ .../launcher_discover_bazel_rust_project.bat | 27 + .../launcher_discover_bazel_rust_project.sh | 40 + .../rust_analyzer/data/launcher_flycheck.bat | 28 + tools/rust_analyzer/data/launcher_flycheck.sh | 41 + .../data/launcher_rust_analyzer.bat | 27 + .../data/launcher_rust_analyzer.sh | 37 + .../launcher_rust_analyzer_proc_macro_srv.bat | 24 + .../launcher_rust_analyzer_proc_macro_srv.sh | 26 + tools/rust_analyzer/data/launcher_rustfmt.bat | 24 + tools/rust_analyzer/data/launcher_rustfmt.sh | 25 + tools/rust_analyzer/lib.rs | 205 +- tools/rust_analyzer/rust_project.rs | 703 +++++-- 24 files changed, 4360 insertions(+), 449 deletions(-) create mode 100644 tools/rust_analyzer/bep.rs create mode 100644 tools/rust_analyzer/bin/flycheck.rs create mode 100644 tools/rust_analyzer/bin/rust_analyzer.rs create mode 100644 tools/rust_analyzer/bin/rust_analyzer_proc_macro_srv.rs create mode 100644 tools/rust_analyzer/bin/rustfmt.rs create mode 100644 tools/rust_analyzer/bin/setup.rs create mode 100644 tools/rust_analyzer/cache.rs create mode 100644 tools/rust_analyzer/data/launcher_discover_bazel_rust_project.bat create mode 100644 tools/rust_analyzer/data/launcher_discover_bazel_rust_project.sh create mode 100644 tools/rust_analyzer/data/launcher_flycheck.bat create mode 100644 tools/rust_analyzer/data/launcher_flycheck.sh create mode 100644 tools/rust_analyzer/data/launcher_rust_analyzer.bat create mode 100644 tools/rust_analyzer/data/launcher_rust_analyzer.sh create mode 100644 tools/rust_analyzer/data/launcher_rust_analyzer_proc_macro_srv.bat create mode 100644 tools/rust_analyzer/data/launcher_rust_analyzer_proc_macro_srv.sh create mode 100644 tools/rust_analyzer/data/launcher_rustfmt.bat create mode 100644 tools/rust_analyzer/data/launcher_rustfmt.sh diff --git a/docs/src/rust_analyzer.md b/docs/src/rust_analyzer.md index 55336d1305..8fe6ff46ad 100644 --- a/docs/src/rust_analyzer.md +++ b/docs/src/rust_analyzer.md @@ -1,146 +1,380 @@ # Rust Analyzer +[rust-analyzer](https://rust-analyzer.github.io/) needs a project model to do its job. For [non-Cargo projects](https://rust-analyzer.github.io/manual.html#non-cargo-based-projects), -[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. +that comes from [project auto-discovery](https://rust-analyzer.github.io/manual.html#rust-analyzer.workspace.discoverConfig): +rust-analyzer invokes a build-system-specific command and reads the project description +from its stdout. `rules_rust` provides that command (`discover_bazel_rust_project`) and +a one-shot installer (`setup`) that wires it up for you. -## rust-project.json approach +Performance is good enough on large monorepos: the discover binary reads the per-crate +spec files Bazel's already producing via the [Build Event Protocol][bep] (no separate +`bazel aquery` round-trip), and the assembled project JSON is memoized in a local +content-addressed cache. -### Setup +[bep]: https://bazel.build/remote/bep -First, ensure `rules_rust` is setup in your `MODULE.bazel`: +## Quick start (VSCode) -```python -# MODULE.bazel +Two steps from a clean checkout to a working IDE: -# See releases page for available versions: -# https://github.com/bazelbuild/rules_rust/releases -bazel_dep(name = "rules_rust", version = "{SEE_RELEASES}") -``` +1. Install the [rust-analyzer extension](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer). +2. Write the `.vscode/settings.json` keys that wire everything together: + ``` + bazel run @rules_rust//tools/rust_analyzer:setup -- vscode + ``` + +Reload the VSCode window. rust-analyzer will run the Bazel-bundled LSP server, discover +the project on the fly via `discover_bazel_rust_project`, run on-save flycheck via +`flycheck`, and auto-reload when any of the watched BUILD files change. + +`setup` is the one entry point for every editor — `vscode`, `neovim`, `helix`, `print` +are subcommands; see [Editors other than VSCode](#editors-other-than-vscode) below for +the other three. The shared flags (`--workspace`, `--output-user-root`, +`--skip-proc-macro-server`, `--skip-rustfmt`) accept on any subcommand. + +`setup vscode` writes two artifacts: + +- Managed keys in `.vscode/settings.json`: + `rust-analyzer.workspace.discoverConfig`, `rust-analyzer.server.path`, + `rust-analyzer.procMacro.server`, `rust-analyzer.rustfmt.overrideCommand`, + `rust-analyzer.files.excludeDirs` (auto-populated with every immediate + subdirectory that contains a `Cargo.toml`, so rust-analyzer doesn't load + those as parallel cargo workspaces in addition to the discoverConfig + project), and matching `files.exclude` / `files.watcherExclude` / + `search.exclude` entries for the Bazel convenience symlinks. User keys are + preserved on re-runs. +- Five small launcher scripts under `.vscode/.rules_rust_analyzer/` + (`rust_analyzer`, `rust_analyzer_proc_macro_srv`, `rustfmt`, `flycheck`, + `discover_bazel_rust_project`; `.sh` on POSIX, `.bat` on Windows). The + launchers — not `bazel-bin/` — are what the LSP / proc-macro / + rustfmt / discover / flycheck settings point at, so `bazel clean` doesn't + break the IDE: if the underlying wrapper binary is missing, the next launch + rebuilds it on demand; otherwise the launcher exec's it directly without + touching Bazel at all. That fast path matters because **Bazel serializes + commands per output_base** — wrapping every LSP / discover / format call + in `bazel run` would deadlock the IDE behind any concurrent + `bazel build` / CI run. + +With all keys wired up, **users do not need a host rust install**: rust-analyzer, +proc-macro-srv, and rustfmt all come from the Bazel toolchain. `editor.formatOnSave` +calls rust-analyzer's LSP formatting request, which spawns the rustfmt launcher, +which exec's the Bazel-built rustfmt wrapper. + +Re-run `setup vscode` any time. Flags: + +- `--dry-run` previews the settings JSON. (vscode subcommand only) +- `--replace` starts from scratch. (vscode subcommand only) +- `--skip-proc-macro-server` leaves the proc-macro key alone. +- `--skip-rustfmt` leaves the formatter key alone (use the host rustfmt instead). +- `--output-user-root ` overrides where the flycheck wrapper's + dedicated Bazel server lives (its `--output_user_root`). Default: + `${HOME}/.vscode-server/.rules_rust_analyzer/output_user_root` when that + directory exists (remote-SSH / Codespaces), else + `${HOME}/.vscode/.rules_rust_analyzer/output_user_root`. Required on + Windows for any non-trivial workspace: Bazel's path-length budget plus + the deepest `external/+...//bin/...` paths it generates can blow MAX_PATH; + point this at something short like `C:\ra-ob` or `D:\bzl\ra`. -Bazel will create the target `@rules_rust//tools/rust_analyzer:gen_rust_project`, which you can build -with +### What you get in the editor + +- **`▶ Run Tests`** codelens on every `#[cfg(test)] mod ...` (runs all + tests in the module via `bazel test`). +- **`▶ Run Test`** codelens on every individual `#[test]` function (runs + exactly that test via `bazel test --test_arg=--exact --test_arg=`). +- **On-save squiggles** from rustc diagnostics, via the flycheck wrapper. +- **Format-on-save** via the Bazel-toolchain rustfmt. +- **Workspace reload** when any watched BUILD / MODULE.bazel file changes. + +### Debugging + +The `▶ Debug` codelens that VSCode displays next to `#[test]` functions +**does not work** with this setup, and we can't fix it from the +rules_rust side. The VSCode rust-analyzer extension's debug handler +([`editors/code/src/debug.ts`][ra-debug] in upstream) hard-bails on +shell runnables — it only knows how to debug crates whose runnable is +shaped as a cargo invocation, because it shells out to cargo with +`--message-format=json --no-run` to discover the test binary path. +Bazel projects emit shell runnables (`bazel test ...`), so the extension +silently returns `undefined` and no debug session starts. Lifting this +would need a PR upstream to teach the extension how to extract a binary +path from a shell runnable. + +**The supported debug path is `.vscode/launch.json` + F5**, via the +[`gen_launch_json` tool][gen-launch-json] this repository ships: ``` -bazel run @rules_rust//tools/rust_analyzer:gen_rust_project +bazel run @rules_rust//tools/vscode:gen_launch_json ``` -whenever dependencies change to regenerate the `rust-project.json` file. It -should be added to `.gitignore` because it is effectively a build artifact. -Once the `rust-project.json` has been generated in the project root, -rust-analyzer can pick it up upon restart. +It queries Bazel for every `rust_binary` / `rust_test` target in the +workspace and writes a `.vscode/launch.json` where each entry uses +CodeLLDB's `targetCreateCommands` with a Python script that runs +`bazel run --compilation_mode=dbg --strip=never --run_under=... +` to build + extract the binary path, then attaches LLDB. Pure +Bazel invocation under the hood; works for any target without +custom config. Re-run when targets change. Install +[CodeLLDB][codelldb] (or `lldb-dap` / `vscode-lldb`) first. -#### VSCode +Caveat: per-target only, not per-`#[test]`-fn. Once a debug session is +attached, set a breakpoint in the test you care about and re-run the +target — libtest's test selection happens inside the binary, so a single +launch config covers every test in the target. -To set this up using [VSCode](https://code.visualstudio.com/), users should first install the -[rust_analyzer plugin](https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer). -With that in place, the following task can be added to the `.vscode/tasks.json` file of the workspace -to ensure a `rust-project.json` file is created and up to date when the editor is opened. +[ra-debug]: https://github.com/rust-lang/rust-analyzer/blob/master/editors/code/src/debug.ts +[gen-launch-json]: https://github.com/bazelbuild/rules_rust/blob/main/tools/vscode/src/bin/gen_launch_json.rs +[codelldb]: https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb -```json -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Generate rust-project.json", - "command": "bazel", - "args": [ - "run", - "@rules_rust//tools/rust_analyzer:gen_rust_project" - ], - "options": { - "cwd": "${workspaceFolder}" - }, - "group": "build", - "problemMatcher": [], - "presentation": { - "reveal": "never", - "panel": "dedicated" - }, - "runOptions": { - "runOn": "folderOpen" - } - } - ] -} +## On-save diagnostics (flycheck) + +The assembled `rust-project.json` wires a `flycheck` runnable that +rust-analyzer invokes whenever a file is saved. The runnable points at the +flycheck launcher script (`.vscode/.rules_rust_analyzer/flycheck.sh` on +POSIX, `.bat` on Windows), which exec's the `flycheck` wrapper binary — +`setup` writes both. + +The wrapper runs `bazel build` on the saved file's owning crate with +`error_format=json` and `--keep_going`, harvests every action's +stderr from the build's Build Event Protocol stream, filters for rustc +JSON messages, and streams them to stdout for rust-analyzer to render +as inline diagnostics. + +The wrapper uses a **dedicated `--output_user_root`** (the path baked into +the flycheck launcher at `setup` time; see `--output-user-root` +above) so its `error_format=json` / `rustc_output_diagnostics=true` flags +don't thrash the user's primary `bazel build` analysis cache. The two +Bazel servers — yours and the flycheck one — are fully isolated. + +Failed actions are deliberately supported: rustc still emits its +diagnostics to stderr before exiting non-zero, and BEP captures that +stderr regardless of action outcome. The wrapper forwards Bazel's exit +code so rust-analyzer can distinguish "build succeeded with no errors" +from "build tool itself broke" (e.g. a BUILD-file syntax error). + +No `rust-analyzer.check.overrideCommand` configuration is needed — +flycheck is on by default. + +## Bazel-provided rust-analyzer + +The registered `rust_analyzer_toolchain` ships the rust-analyzer binary and +proc-macro server matched to the toolchain's rustc/sysroot. Pointing your +editor at those binaries — instead of the rust-analyzer extension's bundled +copy — guarantees the LSP behavior agrees with `bazel build` and avoids +proc-macro ABI mismatches. + +Two stable wrapper targets are provided: + +``` +bazel build @rules_rust//tools/rust_analyzer:rust_analyzer +bazel build @rules_rust//tools/rust_analyzer:rust_analyzer_proc_macro_srv ``` -#### Alternative vscode option (prototype) +VSCode users should NOT point `server.path` directly at `bazel-bin/...` — +`bazel clean` would silently break the LSP until the next manual rebuild. +**Use `setup` (see Quick Start above) instead.** It writes a launcher +script at `.vscode/.rules_rust_analyzer/rust_analyzer.sh` that exec's the +wrapper if it's already built and rebuilds it on demand if not, so the IDE +keeps working across `bazel clean`s. -Add the following to your bazelrc: +For editors `setup` doesn't target, the same launcher pattern is the +right shape. A minimal `rust_analyzer.sh`: +```sh +#!/bin/sh +set -e +WORKSPACE="$(cd "$(dirname "$0")/.." && pwd)" +WRAPPER="$WORKSPACE/bazel-bin/tools/rust_analyzer/rust_analyzer" +if [ ! -x "$WRAPPER" ]; then + cd "$WORKSPACE" && bazel build @rules_rust//tools/rust_analyzer:rust_analyzer >&2 +fi +# rust-analyzer is itself a rules_rust binary when it spawns us — strip its +# RUNFILES_* env so our wrapper resolves its OWN runfiles via argv[0]. +unset RUNFILES_DIR RUNFILES_MANIFEST_FILE +exec "$WRAPPER" "$@" ``` -build --@rules_rust//rust/settings:rustc_output_diagnostics=true --output_groups=+rust_lib_rustc_output,+rust_metadata_rustc_output + +Point your editor's `server.path` at that script. Setting `server.path` alone is +usually sufficient because rust-analyzer uses itself as the proc-macro server +when no explicit one is configured — the separate `rust_analyzer_proc_macro_srv` +wrapper is only needed when an editor pins a different rust-analyzer version and +you want the proc-macro ABI to track the Bazel rustc. + +## Editors other than VSCode + +Each non-VSCode IDE has its own subcommand. The subcommand: + +1. Installs the launcher scripts at an IDE-appropriate location (no + `.vscode/` references for non-VSCode editors). +2. Prints a ready-to-paste config snippet to stdout with the launcher + paths baked in. + +You paste the snippet into the editor's config file. The launchers are +self-contained — re-running setup updates them with current workspace / +output-user-root paths but doesn't change the snippet shape, so the +config in your editor file keeps working unless paths actually move. + +### Neovim + +``` +bazel run @rules_rust//tools/rust_analyzer:setup -- neovim ``` -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. +Installs launchers under `/.rules_rust_analyzer/` and prints +an `nvim-lspconfig` Lua snippet to stdout. Pipe it into your config or +copy-paste: -## Project auto-discovery +``` +bazel run @rules_rust//tools/rust_analyzer:setup -- neovim > /tmp/ra.lua +``` -### Setup +The snippet looks like: -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: +```lua +require("lspconfig").rust_analyzer.setup({ + cmd = { "/abs/workspace/.rules_rust_analyzer/rust_analyzer.sh" }, + settings = { + ["rust-analyzer"] = { + workspace = { + discoverConfig = { + command = { "/abs/workspace/.rules_rust_analyzer/discover_bazel_rust_project.sh", "{arg}" }, + progressLabel = "rules_rust", + filesToWatch = { "BUILD", "BUILD.bazel", "MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel" }, + }, + }, + procMacro = { server = "/abs/workspace/.rules_rust_analyzer/rust_analyzer_proc_macro_srv.sh" }, + rustfmt = { overrideCommand = { "/abs/workspace/.rules_rust_analyzer/rustfmt.sh" } }, + files = { excludeDirs = { "cargo", "crate_universe" } }, + lens = { enable = true }, + }, + }, +}) +``` -```json -"rust-analyzer": { - "workspace": { - "discoverConfig": { - "command": ["discover_bazel_rust_project.sh"], - "progressLabel": "rust_analyzer", - "filesToWatch": ["BUILD", "BUILD.bazel", "MODULE.bazel"] - } - } -} +Drop into your `init.lua` (or a plugin module). Absolute paths are +baked at install time — re-run `setup neovim` if the workspace moves. + +For users on [`rustaceanvim`](https://github.com/mrcjkb/rustaceanvim) +instead: pass the same `cmd` and `settings` table via its `server` +option (`vim.g.rustaceanvim = { server = { cmd = ..., settings = ... } }`). + +### Helix + +``` +bazel run @rules_rust//tools/rust_analyzer:setup -- helix ``` -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: +Installs launchers under `/.helix/.rules_rust_analyzer/` +(Helix already conventionally uses `.helix/` for per-project config) and +prints a `languages.toml` snippet for you to paste into +`/.helix/languages.toml`: -```shell -#!/usr/bin/bash +```toml +[language-server.rust-analyzer] +command = "/abs/workspace/.helix/.rules_rust_analyzer/rust_analyzer.sh" -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 +[language-server.rust-analyzer.config.rust-analyzer.workspace.discoverConfig] +command = ["/abs/workspace/.helix/.rules_rust_analyzer/discover_bazel_rust_project.sh", "{arg}"] +progressLabel = "rules_rust" +filesToWatch = ["BUILD", "BUILD.bazel", "MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel"] +# ...etc ``` -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. +### Vim (classic) and other JSON-config LSP clients -### Workspace splitting +``` +bazel run @rules_rust//tools/rust_analyzer:setup -- print +``` -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: +Installs launchers under `/.rules_rust_analyzer/` and prints +a generic JSON snippet using the `rust-analyzer.*` keys VSCode uses — +[`coc.nvim`](https://github.com/neoclide/coc.nvim) reads the same +namespace via `coc-settings.json` (open with `:CocConfig`); `vim-lsp` / +`ALE` / `LanguageClient-neovim` settings are configured in plugin- +specific ways but accept the same keys. Paste the snippet into the +relevant settings file: ```json -"rust-analyzer": { - "workspace": { - "discoverConfig": { - "command": ["discover_bazel_rust_project.sh", "{arg}"], - "progressLabel": "rust_analyzer", - "filesToWatch": ["BUILD", "BUILD.bazel", "MODULE.bazel"] - } - } +{ + "rust-analyzer.server.path": "/abs/workspace/.rules_rust_analyzer/rust_analyzer.sh", + "rust-analyzer.workspace.discoverConfig": { + "command": ["/abs/workspace/.rules_rust_analyzer/discover_bazel_rust_project.sh", "{arg}"], + "progressLabel": "rules_rust", + "filesToWatch": ["BUILD", "BUILD.bazel", "MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel"] + }, + "rust-analyzer.procMacro.server": "/abs/workspace/.rules_rust_analyzer/rust_analyzer_proc_macro_srv.sh", + "rust-analyzer.rustfmt.overrideCommand": ["/abs/workspace/.rules_rust_analyzer/rustfmt.sh"], + "rust-analyzer.files.excludeDirs": ["cargo", "crate_universe"], + "rust-analyzer.lens.enable": true } ``` -`{arg}` acts as a placeholder that `rust-analyzer` replaces with the path of the source / build file -that gets opened. +## How it works + +### Project model + +The discover binary reads each Bazel rust target's per-crate spec file +(produced by `rust_analyzer_aspect` as a side effect of `bazel build`), +consolidates duplicates, absolutizes the `__WORKSPACE__` / `__EXEC_ROOT__` +/ `__OUTPUT_BASE__` templates, and streams a JSON document with the shape +rust-analyzer expects to stdout. The document is the in-memory equivalent +of the `rust-project.json` file rust-analyzer would read from disk in the +manual-config flow. + +### Crate model (cycles, lib/test pairs) + +Each Bazel rust target produces exactly one rust-analyzer crate, keyed by +the target's label. A `rust_library(name = "lib")` and its +`rust_test(name = "lib_test", crate = ":lib")` show up as **two** distinct +crates that share a `root_module` — exactly how cargo models a lib and its +integrated tests. They are not merged into one crate with a union of deps. + +This eliminates the only way Bazel-built projects could exhibit "cycles" +in the rust-analyzer graph: the previous heuristic keyed crates by +`root_module` path, so the lib and test specs would merge, and the test's +test-only deps would end up on the merged "lib" crate. When two packages' +test-only deps reached back into each other's libs, the merged graph +contained a cycle that Bazel's own build graph never had. Project loading +would then fail with `"Failed to make progress on building crate +dependency graph"` and rust-analyzer would show nothing. + +Under the label-keyed scheme this can't happen — the test crate carries +its own deps directly, no merge is performed, and the assembly step +tolerates forward references, missing deps, and even genuine cycles by +silently dropping unresolvable edges instead of bailing out. + +### Performance + +Auto-discovery uses Bazel's [Build Event Protocol][bep] to learn the paths of the per-crate spec +files produced by `rust_analyzer_aspect` — a side-effect of the `bazel build` that's already +running, so there's no separate `bazel aquery` round-trip. Discovery only includes spec files +that the **current** build's action graph actually produced; stale `*.rust_analyzer_crate_spec.json` +files left behind in `bazel-out/` by deleted targets or no-longer-reachable configurations are +correctly ignored. + +The assembled `rust-project.json` is then memoized in a content-addressed local cache under +`/.vscode/.rules_rust_analyzer/cache/`. The key includes the contents of every +input spec, a version constant that's bumped whenever the assembled JSON shape changes, the +toolchain info, and the bazel/workspace/execution-root paths, so the cache is only served +when every input matches byte-for-byte. Living under `.vscode/` (not the Bazel output base) +means it survives `bazel clean` — but clearing it is a single `rm -rf +.vscode/.rules_rust_analyzer/cache/` if a hand-edit ever leaves it inconsistent. + +In practice, a warm-cache discovery on a large workspace runs in the time it takes Bazel to +report its action-cache hits — typically a few seconds. + +### Workspace splitting + +By default — and with `setup`'s generated config — the discover +command is invoked with `{arg}` set to the file rust-analyzer just opened. +The workspace root becomes the package containing that file, so only that +package and its dependencies get built and indexed. rust-analyzer switches +workspaces whenever an out-of-tree file gets opened, essentially indexing +that crate and its dependencies separately. Keeps the LSP footprint small +on monorepos. -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. +Caveat: _dependents_ of the crate currently being worked on are not +indexed and won't be tracked by `rust-analyzer`. -`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`. +To force whole-workspace mode instead, drop the `{arg}` from the +`discoverConfig.command` array. The discover binary falls back to reading +the root buildfile and treating its package as the root. diff --git a/rust/private/rust_analyzer.bzl b/rust/private/rust_analyzer.bzl index c97d1eaca3..3fc14d9fce 100644 --- a/rust/private/rust_analyzer.bzl +++ b/rust/private/rust_analyzer.bzl @@ -120,6 +120,12 @@ def _rust_analyzer_aspect_impl(target, ctx): _accumulate_rust_analyzer_infos(dep_infos, labels_to_rais, getattr(ctx.rule.attr, "deps", [])) _accumulate_rust_analyzer_infos(dep_infos, labels_to_rais, getattr(ctx.rule.attr, "proc_macro_deps", [])) + # For `rust_test(crate = X)` we add X to dep_infos. Since X has the same + # crate_id as us (same root_module), the dep-list filter in + # `_create_single_crate` later drops it as a self-reference, and + # `consolidate_crate_specs` merges X's spec with ours. End result: one + # rust-analyzer crate with the union of deps and the test target's + # build label. _accumulate_rust_analyzer_info(dep_infos, labels_to_rais, getattr(ctx.rule.attr, "crate", None)) _accumulate_rust_analyzer_info(dep_infos, labels_to_rais, getattr(ctx.rule.attr, "actual", None)) @@ -213,7 +219,16 @@ _EXEC_ROOT_TEMPLATE = "__EXEC_ROOT__/" _OUTPUT_BASE_TEMPLATE = "__OUTPUT_BASE__/" def _crate_id(crate_info): - """Returns a unique stable identifier for a crate + """Returns a unique stable identifier for a crate. + + Keyed on the crate's root module path so that `rust_library(name = "lib")` + and `rust_test(name = "lib_test", crate = ":lib")` — which share a root + module — produce specs with the SAME crate_id. `consolidate_crate_specs` + then merges them into one rust-analyzer crate with the union of deps and + the test target's `build.label` (so TestOne runnables work). Without that + merge, rust-analyzer ends up with two crates pointing at the same source + file, which its IDE-side runnable detection doesn't handle well — test + codelens silently vanishes. Returns: (string): This crate's unique stable id. @@ -255,7 +270,12 @@ def _create_single_crate(ctx, attrs, info): 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, + # Emit canonical `//pkg:name` form. Bazel's BEP reports action + # labels in this form, and the flycheck wrapper matches spec + # labels against BEP labels to find each action's stderr for + # diagnostics. Without the leading `//`, the match silently + # fails and the wrapper emits no diagnostics for the crate. + "label": "//" + ctx.label.package + ":" + ctx.label.name, } if is_generated: @@ -310,10 +330,17 @@ def _rlocationpath(file, workspace_name): return "{}/{}".format(workspace_name, file.short_path) def _rust_analyzer_toolchain_impl(ctx): - make_variable_info = platform_common.TemplateVariableInfo({ + make_vars = { "RUST_ANALYZER": ctx.file.rust_analyzer.path, "RUST_ANALYZER_RLOCATIONPATH": _rlocationpath(ctx.file.rust_analyzer, ctx.workspace_name), - }) + } + if ctx.file.proc_macro_srv: + make_vars["RUST_ANALYZER_PROC_MACRO_SRV"] = ctx.file.proc_macro_srv.path + make_vars["RUST_ANALYZER_PROC_MACRO_SRV_RLOCATIONPATH"] = _rlocationpath( + ctx.file.proc_macro_srv, + ctx.workspace_name, + ) + make_variable_info = platform_common.TemplateVariableInfo(make_vars) toolchain = platform_common.ToolchainInfo( proc_macro_srv = ctx.executable.proc_macro_srv, diff --git a/rust/private/rustfmt.bzl b/rust/private/rustfmt.bzl index fe1d0d807c..e0fc37fd7a 100644 --- a/rust/private/rustfmt.bzl +++ b/rust/private/rustfmt.bzl @@ -313,6 +313,7 @@ rustfmt_test = rule( def _rustfmt_toolchain_impl(ctx): make_variables = { "RUSTFMT": ctx.file.rustfmt.path, + "RUSTFMT_RLOCATIONPATH": _rlocationpath(ctx.file.rustfmt, ctx.workspace_name), } if ctx.attr.rustc: diff --git a/tools/rust_analyzer/BUILD.bazel b/tools/rust_analyzer/BUILD.bazel index dbc016a44d..a27fc84508 100644 --- a/tools/rust_analyzer/BUILD.bazel +++ b/tools/rust_analyzer/BUILD.bazel @@ -60,6 +60,142 @@ rust_binary( ], ) +# Stable entry point for editors / LSP clients. Locates the Bazel-built +# rust-analyzer via runfiles and `exec`s it so the LSP server matches the +# Bazel toolchain's rustc, sysroot, and (when invoked as its own proc-macro +# subcommand) proc-macro-srv. Point `rust-analyzer.server.path` at +# `bazel-bin/tools/rust_analyzer/rust_analyzer` after `bazel build`. +rust_binary( + name = "rust_analyzer", + srcs = ["bin/rust_analyzer.rs"], + data = [ + "//rust/toolchain:current_rust_analyzer_toolchain", + ], + edition = "2021", + rustc_env = { + "RUST_ANALYZER_RLOCATIONPATH": "$(RUST_ANALYZER_RLOCATIONPATH)", + }, + toolchains = ["//rust/toolchain:current_rust_analyzer_toolchain"], + visibility = ["//visibility:public"], + deps = [ + "//rust/runfiles", + ], +) + +# Companion wrapper for the proc-macro server. Use when an editor's bundled +# rust-analyzer is a different version than the one in the Bazel toolchain +# and proc-macro ABI mismatches cause silent expansion failures — point +# `rust-analyzer.procMacro.server` at +# `bazel-bin/tools/rust_analyzer/rust_analyzer_proc_macro_srv`. +rust_binary( + name = "rust_analyzer_proc_macro_srv", + srcs = ["bin/rust_analyzer_proc_macro_srv.rs"], + data = [ + "//rust/toolchain:current_rust_analyzer_toolchain", + ], + edition = "2021", + rustc_env = { + "RUST_ANALYZER_PROC_MACRO_SRV_RLOCATIONPATH": "$(RUST_ANALYZER_PROC_MACRO_SRV_RLOCATIONPATH)", + }, + toolchains = ["//rust/toolchain:current_rust_analyzer_toolchain"], + visibility = ["//visibility:public"], + deps = [ + "//rust/runfiles", + ], +) + +# Wrapper around the Bazel rustfmt toolchain's binary. setup_vscode wires +# `rust-analyzer.rustfmt.overrideCommand` at the launcher that exec's this; +# the LSP server pipes file contents on stdin and reads formatted output on +# stdout, so users can format `.rs` files without ever installing rustfmt +# on the host. +rust_binary( + name = "rustfmt", + srcs = ["bin/rustfmt.rs"], + data = [ + "//rust/toolchain:current_rustfmt_toolchain", + ], + edition = "2021", + rustc_env = { + "RUSTFMT_RLOCATIONPATH": "$(RUSTFMT_RLOCATIONPATH)", + }, + toolchains = ["//rust/toolchain:current_rustfmt_toolchain"], + visibility = ["//visibility:public"], + deps = [ + "//rust/runfiles", + ], +) + +# On-save flycheck wrapper. The assembled rust-project.json wires its +# `flycheck` runnable to `bazel run` this target with `{label}` and +# `{saved_file}` after rust-analyzer substitutes them. It runs +# `bazel build` with rustc diagnostics enabled, harvests the resulting +# `.rustc-output` files via BEP, and streams rustc JSON to stdout for +# rust-analyzer to render as inline squiggles. +rust_binary( + name = "flycheck", + srcs = ["bin/flycheck.rs"], + edition = "2021", + 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", + ], +) + +rust_test( + name = "flycheck_test", + crate = ":flycheck", +) + +# One-command bootstrap that wires VSCode, Neovim, Helix, or any other +# rust-analyzer-capable editor at the Bazel rust-analyzer toolchain. Drops +# small launcher scripts at editor-appropriate locations so the LSP / +# discover / flycheck / rustfmt commands keep working across `bazel clean`, +# and (where the editor's config format is mergeable JSON) writes/merges +# the relevant settings. Re-runnable; preserves user keys. +# +# Invoke with a subcommand per IDE: `vscode`, `neovim`, `helix`, `print`. +rust_binary( + name = "setup", + srcs = ["bin/setup.rs"], + # Embedded via `include_str!` into the binary so the launcher templates + # ship inside `setup` itself and need no runfiles resolution. Both POSIX + # and Windows variants ship; setup picks one at runtime. + compile_data = [ + "data/launcher_discover_bazel_rust_project.bat", + "data/launcher_discover_bazel_rust_project.sh", + "data/launcher_flycheck.bat", + "data/launcher_flycheck.sh", + "data/launcher_rust_analyzer.bat", + "data/launcher_rust_analyzer.sh", + "data/launcher_rust_analyzer_proc_macro_srv.bat", + "data/launcher_rust_analyzer_proc_macro_srv.sh", + "data/launcher_rustfmt.bat", + "data/launcher_rustfmt.sh", + ], + edition = "2021", + visibility = ["//visibility:public"], + deps = [ + "//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", + ], +) + +rust_test( + name = "setup_test", + crate = ":setup", +) + rust_library( name = "gen_rust_project_lib", srcs = glob( diff --git a/tools/rust_analyzer/aquery.rs b/tools/rust_analyzer/aquery.rs index 83f8ac2922..9a9371dd8c 100644 --- a/tools/rust_analyzer/aquery.rs +++ b/tools/rust_analyzer/aquery.rs @@ -1,40 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use anyhow::Context; -use camino::{Utf8Path, Utf8PathBuf}; use serde::Deserialize; -use crate::{bazel_command, deserialize_file_content}; - -#[derive(Debug, Deserialize)] -struct AqueryOutput { - artifacts: Vec, - actions: Vec, - #[serde(rename = "pathFragments")] - path_fragments: Vec, -} - -#[derive(Debug, Deserialize)] -struct Artifact { - id: u32, - #[serde(rename = "pathFragmentId")] - path_fragment_id: u32, -} - -#[derive(Debug, Deserialize)] -struct PathFragment { - id: u32, - label: String, - #[serde(rename = "parentId")] - parent_id: Option, -} - -#[derive(Debug, Deserialize)] -struct Action { - #[serde(rename = "outputIds")] - output_ids: Vec, -} - #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize)] #[serde(deny_unknown_fields)] pub struct CrateSpec { @@ -81,125 +48,9 @@ pub enum CrateType { ProcMacro, } -#[allow(clippy::too_many_arguments)] -pub fn get_crate_specs( - 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> { - log::info!("running bazel aquery..."); - log::debug!("Get crate specs with targets: {:?}", targets); - let target_pattern = format!("deps({})", targets.join("+")); - - let mut aquery_command = bazel_command(bazel, Some(workspace), Some(output_base)); - aquery_command - .args(bazel_startup_options) - .arg("aquery") - .args(bazel_args) - .arg("--include_aspects") - .arg("--include_artifacts") - .arg(format!( - "--aspects={rules_rust_name}//rust:defs.bzl%rust_analyzer_aspect" - )) - .arg("--output_groups=rust_analyzer_crate_spec") - .arg(format!( - r#"outputs(".*\.rust_analyzer_crate_spec\.json",{target_pattern})"# - )) - .arg("--output=jsonproto"); - log::trace!("Running aquery: {:#?}", aquery_command); - let aquery_output = aquery_command - .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.")?; - - log::trace!("Aquery results: {}", aquery_results); - - let crate_spec_files = parse_aquery_output_files(execution_root, &aquery_results)?; - - let crate_specs = crate_spec_files - .into_iter() - .map(|file| deserialize_file_content(&file, output_base, workspace, execution_root)) - .collect::>>()?; - - consolidate_crate_specs(crate_specs) -} - -fn parse_aquery_output_files( - execution_root: &Utf8Path, - aquery_stdout: &str, -) -> anyhow::Result> { - 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::(aquery_stdout) { - Ok(serde_json::Value::Object(_)) => { - // If the JSON is an object, it's likely that the aquery command failed. - anyhow::anyhow!("Aquery returned an empty result, are there any Rust targets in the specified paths?.") - } - _ => { - anyhow::anyhow!("Failed to parse aquery output as JSON") - } - } - })?; - - let artifacts = out - .artifacts - .iter() - .map(|a| (a.id, a)) - .collect::>(); - let path_fragments = out - .path_fragments - .iter() - .map(|pf| (pf.id, pf)) - .collect::>(); - - let mut output_files: Vec = Vec::new(); - for action in out.actions { - for output_id in action.output_ids { - let artifact = artifacts - .get(&output_id) - .expect("internal consistency error in bazel output"); - let path = path_from_fragments(artifact.path_fragment_id, &path_fragments)?; - let path = execution_root.join(path); - if path.exists() { - output_files.push(path); - } else { - log::warn!("Skipping missing crate_spec file: {:?}", path); - } - } - } - - Ok(output_files) -} - -fn path_from_fragments( - id: u32, - fragments: &BTreeMap, -) -> anyhow::Result { - 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(Utf8PathBuf::from(&path_fragment.label.clone())), - None => Utf8PathBuf::from(&path_fragment.label.clone()), - }; - - Ok(buf) -} - /// Read all crate specs, deduplicating crates with the same ID. This happens when /// a rust_test depends on a rust_library, for example. -fn consolidate_crate_specs(crate_specs: Vec) -> anyhow::Result> { +pub fn consolidate_crate_specs(crate_specs: Vec) -> anyhow::Result> { let mut consolidated_specs: BTreeMap = BTreeMap::new(); for mut spec in crate_specs.into_iter() { log::debug!("{:?}", spec); diff --git a/tools/rust_analyzer/bep.rs b/tools/rust_analyzer/bep.rs new file mode 100644 index 0000000000..f743e6ea13 --- /dev/null +++ b/tools/rust_analyzer/bep.rs @@ -0,0 +1,442 @@ +//! Parse Bazel's Build Event Protocol (BEP) JSON stream to discover the +//! `rust-analyzer` crate spec files produced by `rust_analyzer_aspect`. +//! +//! BEP replaces a separate `bazel aquery` round-trip with a side-effect of +//! the `bazel build` that's already running. The aspect declares its output +//! group; BEP reports each target/aspect completion with the file sets it +//! produced. Walking those is O(events) — much cheaper than rescanning the +//! action graph for the same data. + +use std::{ + collections::BTreeMap, + fs::File, + io::{BufRead, BufReader}, +}; + +use anyhow::{Context, Result}; +use camino::{Utf8Path, Utf8PathBuf}; +use serde::Deserialize; + +/// Output group name the `rust_analyzer_aspect` puts the per-crate spec files +/// in. Must match the key used in [`OutputGroupInfo`] in +/// `rust/private/rust_analyzer.bzl`. +pub const SPEC_OUTPUT_GROUP: &str = "rust_analyzer_crate_spec"; + +/// Output group rustc-emitted diagnostics land in when +/// `--@rules_rust//rust/settings:rustc_output_diagnostics=true` is set. See +/// [`generate_output_diagnostics`] in `rust/private/utils.bzl`. +pub const RUSTC_OUTPUT_GROUP: &str = "rustc_output"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BuildEvent { + #[serde(default)] + id: Option, + #[serde(default)] + named_set_of_files: Option, + #[serde(default)] + completed: Option, + #[serde(default)] + action: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EventId { + #[serde(default)] + named_set: Option, + #[serde(default)] + action_completed: Option, +} + +#[derive(Debug, Deserialize)] +struct ActionCompletedId { + #[serde(default)] + label: Option, +} + +#[derive(Debug, Deserialize)] +struct ActionPayload { + #[serde(default)] + stderr: Option, +} + +#[derive(Debug, Deserialize)] +struct NamedSetId { + id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct NamedSetOfFiles { + #[serde(default)] + files: Vec, + #[serde(default)] + file_sets: Vec, +} + +#[derive(Debug, Deserialize)] +struct BepFile { + /// Either `uri` or `name`/`pathPrefix` may be populated depending on + /// Bazel version. We prefer `uri` (a `file://` URL) when available. + #[serde(default)] + uri: Option, + #[serde(default)] + name: Option, + #[serde(default, rename = "pathPrefix")] + path_prefix: Vec, +} + +#[derive(Debug, Deserialize)] +struct FileSetRef { + id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Completed { + #[serde(default)] + output_group: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OutputGroup { + name: String, + #[serde(default)] + file_sets: Vec, +} + +/// Read a BEP JSONL file and return every path that appears in the named +/// output group of any completed target or aspect, deduplicated. Paths are +/// absolute on the local filesystem. +pub fn parse_output_group_paths( + bep_path: &Utf8Path, + output_group: &str, +) -> Result> { + let file = File::open(bep_path).with_context(|| format!("opening BEP file {bep_path}"))?; + let reader = BufReader::new(file); + + // First pass: collect every named file set and every matching fileset id. + let mut file_sets: BTreeMap = BTreeMap::new(); + let mut matching_fileset_ids: Vec = Vec::new(); + + for line in reader.lines() { + let line = line.with_context(|| format!("reading BEP file {bep_path}"))?; + if line.is_empty() { + continue; + } + // Skip BEP events we don't recognize rather than failing the whole + // discovery on a forward-compatible field. + let event: BuildEvent = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + + if let Some(named_set) = event.named_set_of_files { + if let Some(EventId { + named_set: Some(NamedSetId { id }), + .. + }) = event.id + { + file_sets.insert(id, named_set); + } + } else if let Some(completed) = event.completed { + for group in completed.output_group { + if group.name == output_group { + for fileset in group.file_sets { + matching_fileset_ids.push(fileset.id); + } + } + } + } + } + + // Walk the named file sets transitively, gathering file URIs. + let mut paths: Vec = Vec::new(); + let mut visited: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut stack: Vec = matching_fileset_ids; + while let Some(id) = stack.pop() { + if !visited.insert(id.clone()) { + continue; + } + let Some(set) = file_sets.get(&id) else { + continue; + }; + for file in &set.files { + if let Some(path) = file_to_path(file) { + paths.push(path); + } + } + for child in &set.file_sets { + stack.push(child.id.clone()); + } + } + + paths.sort(); + paths.dedup(); + Ok(paths) +} + +/// Convenience wrapper for the `rust_analyzer_crate_spec` output group used +/// during project discovery. +pub fn parse_spec_paths(bep_path: &Utf8Path) -> Result> { + parse_output_group_paths(bep_path, SPEC_OUTPUT_GROUP) +} + +/// Compare two Bazel labels for equality, ignoring repo-prefix shorthand +/// differences. BEP reports canonical `//pkg:name` (or `@@repo//pkg:name`) +/// while the spec used to emit non-canonical `pkg:name`; strict string +/// equality silently dropped every match. Normalizing both sides to the +/// trailing `pkg:name` form is robust to either change. +fn labels_match(a: &str, b: &str) -> bool { + fn trim(s: &str) -> &str { + // Strip an optional `@@` or `@` repository sigil, then the `//` + // package separator. Anything left is `pkg:name` (or `:name` for a + // root-package target, which is fine — both sides reduce equally). + let s = s.trim_start_matches("@@").trim_start_matches('@'); + s.trim_start_matches("//") + } + trim(a) == trim(b) +} + +#[cfg(test)] +mod label_match_tests { + use super::labels_match; + + #[test] + fn matches_canonical_vs_short() { + assert!(labels_match("//util/label:label", "util/label:label")); + assert!(labels_match("util/label:label", "//util/label:label")); + } + + #[test] + fn matches_identical() { + assert!(labels_match("//util/label:label", "//util/label:label")); + assert!(labels_match("util/label:label", "util/label:label")); + } + + #[test] + fn handles_external_repo_sigils() { + assert!(labels_match("@@//util/label:label", "//util/label:label")); + assert!(labels_match("@repo//pkg:t", "@@repo//pkg:t")); + } + + #[test] + fn rejects_different_targets() { + assert!(!labels_match("//util/label:label", "//util/label:other")); + assert!(!labels_match("//util/label:label", "//util/other:label")); + } +} + +/// Return the stderr file path captured for each completed Bazel action +/// whose label matches `target_label`. With `error_format=json` set on the +/// build, the file contains rustc's machine-readable diagnostics — the +/// only place to read them when the action fails (failed actions don't +/// produce their declared `.rustc-output` artifacts). +pub fn parse_action_stderr_paths( + bep_path: &Utf8Path, + target_label: &str, +) -> Result> { + let file = File::open(bep_path).with_context(|| format!("opening BEP file {bep_path}"))?; + let reader = BufReader::new(file); + + let mut paths: Vec = Vec::new(); + for line in reader.lines() { + let line = line.with_context(|| format!("reading BEP file {bep_path}"))?; + if line.is_empty() { + continue; + } + let event: BuildEvent = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + let action_id = match event.id.as_ref().and_then(|i| i.action_completed.as_ref()) { + Some(a) => a, + None => continue, + }; + // Only keep actions for the target the user is checking. Aspect + // actions (e.g. clippy) also fire for the same label and get + // included — that's the desirable behavior. + let bep_label = match action_id.label.as_deref() { + Some(l) => l, + None => continue, + }; + if !labels_match(bep_label, target_label) { + continue; + } + let action = match event.action { + Some(a) => a, + None => continue, + }; + if let Some(stderr) = action.stderr { + if let Some(path) = file_to_path(&stderr) { + paths.push(path); + } + } + } + paths.sort(); + paths.dedup(); + Ok(paths) +} + +fn file_to_path(file: &BepFile) -> Option { + if let Some(uri) = &file.uri { + if let Some(rest) = uri.strip_prefix("file://") { + let decoded = percent_decode(rest); + return Some(Utf8PathBuf::from(strip_uri_drive_prefix(&decoded))); + } + } + // Fallback: reconstruct from pathPrefix + name. Bazel uses this form + // when the file lives in bazel-out and the absolute URI isn't reported. + if let Some(name) = &file.name { + if !file.path_prefix.is_empty() { + let mut path = Utf8PathBuf::from(&file.path_prefix[0]); + for segment in file.path_prefix.iter().skip(1) { + path.push(segment); + } + path.push(name); + return Some(path); + } + } + None +} + +/// `file://` URIs on Windows look like `file:///C:/path` — after stripping +/// the `file://` scheme prefix, the result is `/C:/path` where the leading +/// `/` is the URI authority separator, NOT part of the actual filesystem +/// path. Strip it when the next characters are `:` so the +/// resulting `C:/path` parses as a valid Windows path. +/// +/// Safe on POSIX: a real POSIX file URI `file:///foo/bar` strips to +/// `/foo/bar` which doesn't match the `/:` shape, so the path +/// passes through unchanged. +fn strip_uri_drive_prefix(s: &str) -> &str { + let bytes = s.as_bytes(); + if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' { + &s[1..] + } else { + s + } +} + +fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let (Some(hi), Some(lo)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) { + out.push(hi * 16 + lo); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + // The decoded URI path must be valid UTF-8 on the platforms we support. + String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) +} + +fn hex_digit(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn percent_decode_basic() { + assert_eq!(percent_decode("/path/with%20space"), "/path/with space"); + assert_eq!(percent_decode("noop"), "noop"); + assert_eq!(percent_decode("a%2Fb"), "a/b"); + } + + #[test] + fn strip_uri_drive_prefix_handles_windows_and_posix() { + // Windows: `file:///C:/path` → after `file://` strip → `/C:/path`. + // The leading `/` is the URI authority separator and must go. + assert_eq!( + strip_uri_drive_prefix("/C:/path/to/file"), + "C:/path/to/file" + ); + assert_eq!(strip_uri_drive_prefix("/D:/other"), "D:/other"); + // POSIX: `file:///foo/bar` → `/foo/bar`. Leading `/` IS the path root. + assert_eq!(strip_uri_drive_prefix("/foo/bar"), "/foo/bar"); + assert_eq!( + strip_uri_drive_prefix("/abs/lib.spec.json"), + "/abs/lib.spec.json" + ); + // Edge cases. + assert_eq!(strip_uri_drive_prefix(""), ""); + assert_eq!(strip_uri_drive_prefix("/"), "/"); + assert_eq!( + strip_uri_drive_prefix("C:/already_clean"), + "C:/already_clean" + ); + } + + #[test] + fn parse_spec_paths_resolves_nested_filesets() { + let dir = tempdir(); + let bep_path = dir.join("bep.json"); + std::fs::write( + &bep_path, + r#"{"id":{"namedSet":{"id":"0"}},"namedSetOfFiles":{"files":[{"uri":"file:///abs/foo.rust_analyzer_crate_spec.json"}],"fileSets":[{"id":"1"}]}} +{"id":{"namedSet":{"id":"1"}},"namedSetOfFiles":{"files":[{"uri":"file:///abs/bar.rust_analyzer_crate_spec.json"}]}} +{"id":{"targetCompleted":{"label":"//pkg:lib"}},"completed":{"outputGroup":[{"name":"rust_analyzer_crate_spec","fileSets":[{"id":"0"}]}]}} +{"id":{"namedSet":{"id":"2"}},"namedSetOfFiles":{"files":[{"uri":"file:///abs/unrelated.json"}]}} +"#, + ) + .unwrap(); + let paths = parse_spec_paths(&bep_path).unwrap(); + assert_eq!( + paths, + vec![ + Utf8PathBuf::from("/abs/bar.rust_analyzer_crate_spec.json"), + Utf8PathBuf::from("/abs/foo.rust_analyzer_crate_spec.json"), + ] + ); + } + + #[test] + fn parse_output_group_paths_filters_by_group() { + let dir = tempdir(); + let bep_path = dir.join("bep.json"); + std::fs::write( + &bep_path, + r#"{"id":{"namedSet":{"id":"0"}},"namedSetOfFiles":{"files":[{"uri":"file:///abs/lib.rustc-output"}]}} +{"id":{"namedSet":{"id":"1"}},"namedSetOfFiles":{"files":[{"uri":"file:///abs/lib.spec.json"}]}} +{"id":{"targetCompleted":{"label":"//pkg:lib"}},"completed":{"outputGroup":[{"name":"rustc_output","fileSets":[{"id":"0"}]},{"name":"rust_analyzer_crate_spec","fileSets":[{"id":"1"}]}]}} +"#, + ) + .unwrap(); + let rustc = parse_output_group_paths(&bep_path, "rustc_output").unwrap(); + assert_eq!(rustc, vec![Utf8PathBuf::from("/abs/lib.rustc-output")]); + let specs = parse_output_group_paths(&bep_path, "rust_analyzer_crate_spec").unwrap(); + assert_eq!(specs, vec![Utf8PathBuf::from("/abs/lib.spec.json")]); + } + + fn tempdir() -> Utf8PathBuf { + use std::convert::TryFrom; + // Sanitize the thread name: libtest gives us names like + // `bep::tests::foo`, and Windows rejects `:` in filenames. + let raw_name = std::thread::current().name().unwrap_or("anon").to_owned(); + let safe_name: String = raw_name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let dir = + std::env::temp_dir().join(format!("bep_test_{}_{}", std::process::id(), safe_name,)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Utf8PathBuf::try_from(dir).unwrap() + } +} diff --git a/tools/rust_analyzer/bin/flycheck.rs b/tools/rust_analyzer/bin/flycheck.rs new file mode 100644 index 0000000000..76cd469833 --- /dev/null +++ b/tools/rust_analyzer/bin/flycheck.rs @@ -0,0 +1,291 @@ +//! On-save flycheck wrapper invoked by rust-analyzer. +//! +//! rust-analyzer's flycheck runnable spawns this with the saved file's +//! owning Bazel label and (optionally) the saved file path. We then: +//! +//! 1. Invoke `bazel build