Skip to content

Commit 155a8af

Browse files
authored
Add cross-platform compatible launcher for rust-analyzer (bazelbuild#4118)
For vscode users who commit `.code-workspace` or `settings.json`, this change introduces a cross-platform compatible entrypoint for rust-analyzer infrastructure. Note that this doesn't mean the binaries are magically platform agnostic. It just means the two files mentioned will be compatible on both Unix and Windows platforms if the vscode config files are source controlled.
1 parent 08d7cc6 commit 155a8af

8 files changed

Lines changed: 916 additions & 309 deletions

File tree

docs/src/rust_analyzer.md

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,55 @@ Existing user keys in `.vscode/settings.json` are preserved on re-runs.
2323
Pass `--dry-run` to preview the JSON without writing it; `--replace` to
2424
overwrite all managed keys (destroys user keys).
2525

26+
#### Committable settings
27+
28+
Managed `rust-analyzer.*` paths use VSCode's `${workspaceFolder}`
29+
variable and point at small launcher shims under
30+
`<workspace>/.rules_rust_analyzer/`, e.g.:
31+
32+
```jsonc
33+
"rust-analyzer.server.path":
34+
"${workspaceFolder}/.rules_rust_analyzer/rust_analyzer.exe"
35+
```
36+
37+
The shims are byte-identical copies of a tiny dispatcher binary;
38+
they read a sibling `launcher_paths.json` (also in the launcher dir)
39+
and `exec` the absolute toolchain binary in the Bazel cache. **The
40+
settings file is safe to commit** — it contains no per-developer or
41+
per-platform paths. Each developer just runs `setup` once on their
42+
machine to populate the launcher dir.
43+
44+
Add the launcher dir to `.gitignore`:
45+
46+
```
47+
.rules_rust_analyzer/
48+
```
49+
50+
Re-run `setup` after a toolchain bump (rustup update, `MODULE.bazel`
51+
change, `bazel clean --expunge`) to refresh the launcher dir's
52+
`launcher_paths.json`. The committed settings file is untouched.
53+
54+
The `.exe` extension on every platform is deliberate: it's the only
55+
extension Node's `child_process.spawn` handles on Windows without
56+
`shell: true`, and POSIX kernels ignore file extensions for `execve`
57+
(a Linux ELF or macOS Mach-O binary named `foo.exe` runs fine).
58+
That's what lets the same committed path work across Linux, macOS,
59+
and Windows.
60+
61+
#### `.code-workspace` files
62+
63+
Projects opened via a `.code-workspace` file need the rust-analyzer
64+
keys inside the workspace file's `settings` object — VSCode answers
65+
rust-analyzer's window-scoped config requests from there, **not** from
66+
any folder's `.vscode/settings.json`. `setup vscode` handles this
67+
automatically: if exactly one `*.code-workspace` exists at the
68+
workspace root, it targets that file and nests under `settings`. Pass
69+
`--output <file>.code-workspace` to disambiguate when multiple exist,
70+
or `--settings-key <key>` to nest under a custom key. With
71+
`--settings-key`, `--replace` overwrites only that nested object —
72+
sibling top-level keys (`folders`, `tasks`, `extensions`) survive
73+
intact.
74+
2675
### Neovim
2776

2877
```
@@ -58,18 +107,23 @@ same keys via plugin-specific config files.
58107

59108
## Flags
60109

61-
Re-runnable at any time. All flags work on any subcommand.
110+
Re-runnable at any time. Global flags work on any subcommand.
62111

63112
| Flag | Effect |
64113
|---|---|
114+
| `--workspace <path>` | Workspace root. Defaults to `$BUILD_WORKSPACE_DIRECTORY` (set by `bazel run`). |
65115
| `--skip-proc-macro-server` | Don't manage the proc-macro key. |
66116
| `--skip-rustfmt` | Don't manage the formatter key (use host rustfmt). |
67-
| `--output-user-root <abs-path>` | `--output_user_root` for flycheck's dedicated Bazel server. Required on Windows for non-trivial workspaces (MAX_PATH). |
68-
| `--cache-dir <abs-path>` | Where discover writes its merged-JSON cache. |
69117
| `--per-package-workspaces` | Opt in to per-package workspace splitting (see below). |
70118

71-
VSCode subcommand also accepts `--dry-run` (preview JSON without writing) and
72-
`--replace` (overwrite all managed keys, destroying user keys).
119+
The `vscode` subcommand adds:
120+
121+
| Flag | Effect |
122+
|---|---|
123+
| `--output <path>` | Settings file to write. Defaults to the unique `*.code-workspace` at the workspace root, falling back to `.vscode/settings.json`. |
124+
| `--settings-key <key>` | Nest the managed `rust-analyzer.*` keys under this top-level key. Auto-defaults to `settings` for `.code-workspace` outputs. |
125+
| `--dry-run` | Preview the JSON without writing. |
126+
| `--replace` | Replace the managed keys instead of merging. With `--settings-key`, only that nested object is replaced — sibling keys (`folders`, `tasks`, `extensions`) survive. |
73127

74128
## What you get
75129

@@ -97,9 +151,6 @@ rm -rf <workspace>/<editor-dir>/.rules_rust_analyzer/cache
97151
Where `<editor-dir>` is `.vscode` for VSCode, `.helix` for Helix, or
98152
empty for Neovim / `print` (cache sits at `<workspace>/.rules_rust_analyzer/cache`).
99153

100-
If you passed `--cache-dir` at setup time, the cache is wherever you
101-
pointed it instead.
102-
103154
The cache survives `bazel clean` by design (it lives in the workspace,
104155
not the Bazel output base) so a full Bazel rebuild won't invalidate
105156
stale entries — that's what the manual `rm -rf` is for.
@@ -111,11 +162,12 @@ wrapper appends one line per internal failure.
111162

112163
### After `bazel clean --expunge` or toolchain changes
113164

114-
Re-run `setup`. The launcher scripts reference the rust-analyzer /
115-
rustfmt / proc-macro-srv binaries by absolute path baked at install
165+
Re-run `setup`. The launcher shims dispatch through
166+
`<launcher_dir>/launcher_paths.json`, which records absolute paths to
167+
the rust-analyzer / rustfmt / proc-macro-srv binaries at install
116168
time. Those binaries live in Bazel's output_base; `--expunge` clears
117169
that, and toolchain changes move them to new paths. Re-running setup
118-
resolves the new paths and refreshes the launchers.
170+
re-resolves and rewrites the JSON.
119171

120172
## Workspace splitting
121173

tools/rust_analyzer/BUILD.bazel

Lines changed: 23 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,6 @@ rust_binary(
6262
],
6363
)
6464

65-
# On-save flycheck wrapper. The assembled rust-project.json wires its
66-
# `flycheck` runnable to `bazel run` this target with `{label}` and
67-
# `{saved_file}` after rust-analyzer substitutes them. It runs
68-
# `bazel build` with rustc diagnostics enabled, harvests the resulting
69-
# `.rustc-output` files via BEP, and streams rustc JSON to stdout for
70-
# rust-analyzer to render as inline squiggles.
7165
rust_binary(
7266
name = "flycheck",
7367
srcs = ["bin/flycheck.rs"],
@@ -89,11 +83,6 @@ rust_test(
8983
crate = ":flycheck",
9084
)
9185

92-
# Opt-mode re-exposures of the two source binaries setup copies into
93-
# the user's launcher dir. discover/flycheck run on every save /
94-
# discovery and pay the fastbuild→opt gap repeatedly; forcing `-c opt`
95-
# here means setup always installs the fastest available build,
96-
# independent of the parent build's `--compilation_mode`.
9786
opt_executable(
9887
name = "discover_bazel_rust_project_opt",
9988
src = ":discover_bazel_rust_project",
@@ -106,35 +95,40 @@ opt_executable(
10695
visibility = ["//visibility:private"],
10796
)
10897

109-
# One-command bootstrap that wires VSCode, Neovim, Helix, or any other
110-
# rust-analyzer-capable editor at the Bazel rust-analyzer toolchain. Copies
111-
# the discover/flycheck binaries to editor-appropriate locations and
112-
# (where the editor's config format is mergeable JSON) writes/merges the
113-
# relevant settings. Re-runnable; preserves user keys.
114-
#
115-
# Invoke with a subcommand per IDE: `vscode`, `neovim`, `helix`, `print`.
98+
rust_binary(
99+
name = "launcher",
100+
srcs = ["bin/launcher.rs"],
101+
edition = "2021",
102+
visibility = ["//visibility:public"],
103+
deps = [
104+
"//tools/rust_analyzer/3rdparty/crates:serde_json",
105+
],
106+
)
107+
108+
rust_test(
109+
name = "launcher_test",
110+
crate = ":launcher",
111+
)
112+
113+
opt_executable(
114+
name = "launcher_opt",
115+
src = ":launcher",
116+
visibility = ["//visibility:private"],
117+
)
118+
116119
rust_binary(
117120
name = "setup",
118121
srcs = ["bin/setup.rs"],
119-
# setup resolves these via runfiles at install time:
120-
# * The three toolchain binaries get baked as absolute
121-
# `output_base/external/...` paths into the editor's
122-
# rust-analyzer settings (they survive `bazel clean`; only
123-
# `--expunge` invalidates them).
124-
# * The two source binaries (discover, flycheck) get copied into
125-
# the launcher dir so the editor's commands stay valid across
126-
# `bazel clean`. The binaries self-locate their config from
127-
# `current_exe()` — no launcher scripts. The toolchain-info
128-
# JSON used to be copied here too; it's now embedded into
129-
# `gen_rust_project_lib` at compile time via `include_str!`.
130122
data = [
131123
":discover_bazel_rust_project_opt",
132124
":flycheck_opt",
125+
":launcher_opt",
133126
"//rust/toolchain:current_rust_analyzer_toolchain",
134127
"//rust/toolchain:current_rustfmt_toolchain",
135128
],
136129
edition = "2021",
137130
rustc_env = {
131+
"LAUNCHER_RLOCATIONPATH": "$(rlocationpath :launcher_opt)",
138132
"RUSTFMT_RLOCATIONPATH": "$(RUSTFMT_RLOCATIONPATH)",
139133
"RUST_ANALYZER_PROC_MACRO_SRV_RLOCATIONPATH": "$(RUST_ANALYZER_PROC_MACRO_SRV_RLOCATIONPATH)",
140134
"RUST_ANALYZER_RLOCATIONPATH": "$(RUST_ANALYZER_RLOCATIONPATH)",
@@ -145,10 +139,6 @@ rust_binary(
145139
],
146140
visibility = ["//visibility:public"],
147141
deps = [
148-
# Source of truth for the on-disk filenames setup copies into
149-
# the launcher dir (`{DISCOVER,FLYCHECK}_BINARY_FILENAME`).
150-
# Sharing them keeps the install side and the consumer side
151-
# (rust_project.rs's flycheck-runnable path emitter) in sync.
152142
":gen_rust_project_lib",
153143
"//rust/runfiles",
154144
"//tools/rust_analyzer/3rdparty/crates:anyhow",
@@ -165,15 +155,6 @@ rust_test(
165155
crate = ":setup",
166156
)
167157

168-
# Pairs with the `compile_data` entry below: the env_file emits
169-
# `RUST_ANALYZER_TOOLCHAIN_JSON=<src.path>` through a `map_each`
170-
# callback so Bazel's path mapping rewrites the path before the action
171-
# runs. `process_wrapper` reads the env file and sets the env var, then
172-
# lib.rs's `include_str!(env!("RUST_ANALYZER_TOOLCHAIN_JSON"))` opens
173-
# the same path-mapped JSON sibling. Going through `rustc_env =`
174-
# directly would bake the analysis-time path that path mapping never
175-
# rewrites — the compile-time read then fails to find the file under
176-
# `--experimental_output_paths=strip`.
177158
env_file(
178159
name = "toolchain_info_env",
179160
src = "//rust/private:rust_analyzer_detect_sysroot",
@@ -186,8 +167,6 @@ rust_library(
186167
["**/*.rs"],
187168
exclude = ["bin"],
188169
),
189-
# The JSON file itself must be available to rustc — `:toolchain_info_env`
190-
# only emits the env-var wrapper pointing at this path.
191170
compile_data = [
192171
"//rust/private:rust_analyzer_detect_sysroot",
193172
],

tools/rust_analyzer/bin/discover_rust_project.rs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,17 +83,10 @@ where
8383
writeln!(writer, "")
8484
}
8585

86-
/// Self-locate the editor-specific install dir from `current_exe()`
87-
/// and publish the two env vars the library reads for per-install
88-
/// state (`RULES_RUST_RA_LAUNCHER_DIR`, `RULES_RUST_RA_CACHE_DIR`).
89-
/// `setup` copies this binary into `<launcher_dir>/discover_bazel_rust_project`;
90-
/// the parent directory of the binary IS the launcher dir.
91-
///
92-
/// Already-set env vars are left alone so users / tests can override.
93-
/// The toolchain-info JSON is NOT plumbed here — its content is baked
94-
/// into the binary at compile time via `env!()` (see
95-
/// `gen_rust_project_lib::TOOLCHAIN_INFO_RAW`), so there's nothing to
96-
/// locate at runtime.
86+
/// Publish per-install state via env vars the library reads.
87+
/// `setup` copies this binary into the launcher dir, so
88+
/// `dirname(current_exe())` IS that dir. Pre-set values win so users
89+
/// and tests can override.
9790
fn self_locate_config() -> anyhow::Result<()> {
9891
let launcher_dir = gen_rust_project_lib::install_dir()?;
9992
for (name, path) in [

tools/rust_analyzer/bin/flycheck.rs

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,11 @@ fn run() -> Result<u8> {
7575
let bep_path = temp_dir.join(format!("flycheck_bep_{}.json", std::process::id()));
7676
let _bep_cleanup = scopeguard(bep_path.clone());
7777

78-
// flycheck spawns `bazel build` internally. That inner build runs
79-
// against a DEDICATED `--output_user_root` so its
80-
// `--error_format=json` / `--rustc_output_diagnostics=true` flags
78+
// Dedicated `--output_user_root` for the inner `bazel build` so
79+
// its `--error_format=json` / `--rustc_output_diagnostics=true`
8180
// don't thrash the user's primary Bazel server's analysis cache.
82-
//
83-
// The flycheck binary lives at `<install_dir>/flycheck` (setup
84-
// copies it there); the dedicated output_user_root sits alongside
85-
// as `<install_dir>/output_user_root`. `install_dir()` resolves
86-
// `current_exe`'s parent — see its docstring for why current_exe
87-
// over argv[0]. CLI flag override is honored for paths that don't
88-
// fit the default (Windows MAX_PATH).
81+
// CLI override exists for Windows MAX_PATH cases where the
82+
// sibling default is too long.
8983
let output_user_root = match args.output_user_root.clone() {
9084
Some(p) => p,
9185
None => gen_rust_project_lib::install_dir()?.join("output_user_root"),
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! Exec-redirect shim that bridges a stable workspace-relative path
2+
//! in the committed settings file (`${workspaceFolder}/.rules_rust_analyzer/<name>.exe`)
3+
//! to the user-specific Bazel-cache path of the real toolchain binary.
4+
//! `setup vscode` copies this binary into the launcher dir once per
5+
//! logical name and writes the sidecar JSON it dispatches against.
6+
//!
7+
//! `.exe` everywhere is required by Node's `child_process.spawn` on
8+
//! Windows (no `shell: true`, no PATHEXT lookup) and harmless on POSIX
9+
//! (kernel ignores file extensions for `execve`). See
10+
//! [`gen_rust_project_lib::DISCOVER_BINARY_FILENAME`] for the same
11+
//! decision on the discover/flycheck side.
12+
13+
use std::{
14+
collections::HashMap,
15+
env, fs,
16+
path::{Path, PathBuf},
17+
process,
18+
};
19+
20+
const PATHS_FILENAME: &str = "launcher_paths.json";
21+
22+
fn main() {
23+
let exe = env::current_exe().unwrap_or_else(|e| die(format!("locating current_exe: {e}")));
24+
let logical = logical_name(&exe)
25+
.unwrap_or_else(|| die(format!("current_exe has no file stem: {}", exe.display())));
26+
let paths_path = exe
27+
.parent()
28+
.unwrap_or_else(|| die(format!("current_exe has no parent: {}", exe.display())))
29+
.join(PATHS_FILENAME);
30+
let target = resolve_target(&paths_path, &logical)
31+
.unwrap_or_else(|e| die(format!("resolving {logical}: {e}")));
32+
exec_replace(&target);
33+
}
34+
35+
fn logical_name(exe: &Path) -> Option<String> {
36+
exe.file_stem().map(|s| s.to_string_lossy().into_owned())
37+
}
38+
39+
/// Borrows strings out of `raw` to skip per-entry heap allocations on
40+
/// the LSP-startup hot path.
41+
fn resolve_target(paths_path: &Path, logical: &str) -> Result<PathBuf, String> {
42+
let raw = fs::read_to_string(paths_path)
43+
.map_err(|e| format!("reading {}: {e}", paths_path.display()))?;
44+
let map: HashMap<&str, &str> =
45+
serde_json::from_str(&raw).map_err(|e| format!("parsing {}: {e}", paths_path.display()))?;
46+
map.get(logical)
47+
.map(PathBuf::from)
48+
.ok_or_else(|| format!("no entry for `{logical}` in {}", paths_path.display()))
49+
}
50+
51+
#[cfg(unix)]
52+
fn exec_replace(target: &Path) -> ! {
53+
use std::os::unix::process::CommandExt;
54+
let err = process::Command::new(target)
55+
.args(env::args_os().skip(1))
56+
.exec();
57+
die(format!("exec {}: {err}", target.display()))
58+
}
59+
60+
#[cfg(not(unix))]
61+
fn exec_replace(target: &Path) -> ! {
62+
let status = process::Command::new(target)
63+
.args(env::args_os().skip(1))
64+
.status()
65+
.unwrap_or_else(|e| die(format!("spawn {}: {e}", target.display())));
66+
process::exit(status.code().unwrap_or(1));
67+
}
68+
69+
fn die(msg: String) -> ! {
70+
eprintln!("rules_rust launcher: {msg}");
71+
process::exit(127);
72+
}
73+
74+
#[cfg(test)]
75+
mod tests {
76+
use super::*;
77+
78+
#[test]
79+
fn logical_name_strips_exe_suffix() {
80+
assert_eq!(
81+
logical_name(Path::new("/x/rust_analyzer.exe")).as_deref(),
82+
Some("rust_analyzer")
83+
);
84+
}
85+
86+
#[test]
87+
fn logical_name_passes_through_when_no_suffix() {
88+
assert_eq!(
89+
logical_name(Path::new("/x/rustfmt")).as_deref(),
90+
Some("rustfmt")
91+
);
92+
}
93+
94+
#[test]
95+
fn resolve_target_returns_mapped_path() {
96+
let tmp = std::env::temp_dir().join(format!("launcher_resolve_ok_{}", process::id()));
97+
let _ = fs::remove_dir_all(&tmp);
98+
fs::create_dir_all(&tmp).unwrap();
99+
let paths = tmp.join(PATHS_FILENAME);
100+
fs::write(
101+
&paths,
102+
r#"{"rust_analyzer": "/abs/rust-analyzer", "rustfmt": "/abs/rustfmt"}"#,
103+
)
104+
.unwrap();
105+
let out = resolve_target(&paths, "rust_analyzer").unwrap();
106+
assert_eq!(out, PathBuf::from("/abs/rust-analyzer"));
107+
let _ = fs::remove_dir_all(&tmp);
108+
}
109+
110+
#[test]
111+
fn resolve_target_errors_on_missing_key_with_path_context() {
112+
let tmp = std::env::temp_dir().join(format!("launcher_resolve_miss_{}", process::id()));
113+
let _ = fs::remove_dir_all(&tmp);
114+
fs::create_dir_all(&tmp).unwrap();
115+
let paths = tmp.join(PATHS_FILENAME);
116+
fs::write(&paths, r#"{"rust_analyzer": "/abs/rust-analyzer"}"#).unwrap();
117+
let err = resolve_target(&paths, "rustfmt").unwrap_err();
118+
assert!(err.contains("rustfmt"), "{err}");
119+
assert!(err.contains(PATHS_FILENAME), "{err}");
120+
let _ = fs::remove_dir_all(&tmp);
121+
}
122+
}

0 commit comments

Comments
 (0)