Skip to content

feat(toolchain): consider external rust-analyzer when calling a proxy #4324

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/test/mock_bin_src.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ fn main() {
panic!("CARGO environment variable not set");
}
}
Some("--echo-current-exe") => {
let mut out = io::stderr();
writeln!(out, "{}", std::env::current_exe().unwrap().display()).unwrap();
}
arg => panic!("bad mock proxy commandline: {:?}", arg),
}
}
Expand Down
70 changes: 61 additions & 9 deletions src/toolchain.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::{
env::{self, consts::EXE_SUFFIX},
ffi::{OsStr, OsString},
Expand All @@ -12,6 +14,7 @@ use std::{

use anyhow::{Context, anyhow, bail};
use fs_at::OpenOptions;
use same_file::is_same_file;
use tracing::info;
use url::Url;
use wait_timeout::ChildExt;
Expand Down Expand Up @@ -329,22 +332,25 @@ impl<'a> Toolchain<'a> {
pub(crate) fn command(&self, binary: &str) -> anyhow::Result<Command> {
// Should push the cargo fallback into a custom toolchain type? And then
// perhaps a trait that create command layers on?
if !matches!(
self.name(),
LocalToolchainName::Named(ToolchainName::Official(_))
) {
if let Some(cmd) = self.maybe_do_cargo_fallback(binary)? {
return Ok(cmd);
}
if let Some(cmd) = self.maybe_do_cargo_fallback(binary)? {
info!("`cargo` is unavailable for the active toolchain");
info!("falling back to {:?}", cmd.get_program());
return Ok(cmd);
} else if let Some(cmd) = self.maybe_do_rust_analyzer_fallback(binary)? {
info!("`rust-analyzer` is unavailable for the active toolchain");
info!("falling back to {:?}", cmd.get_program());
return Ok(cmd);
}

self.create_command(binary)
}

// Custom toolchains don't have cargo, so here we detect that situation and
// try to find a different cargo.
pub(crate) fn maybe_do_cargo_fallback(&self, binary: &str) -> anyhow::Result<Option<Command>> {
if binary != "cargo" && binary != "cargo.exe" {
fn maybe_do_cargo_fallback(&self, binary: &str) -> anyhow::Result<Option<Command>> {
if let LocalToolchainName::Named(ToolchainName::Official(_)) = self.name() {
return Ok(None);
} else if binary != "cargo" && binary != "cargo.exe" {
return Ok(None);
}

Expand Down Expand Up @@ -374,6 +380,52 @@ impl<'a> Toolchain<'a> {
Ok(None)
}

/// Tries to find `rust-analyzer` on the PATH when the active toolchain does
/// not have `rust-analyzer` installed.
///
/// This happens from time to time often because the user wants to use a
/// more recent build of RA than the one shipped with rustup, or because
/// rustup isn't shipping RA on their host platform at all.
///
/// See the following issues for more context:
/// - <https://github.com/rust-lang/rustup/issues/3299>
/// - <https://github.com/rust-lang/rustup/issues/3846>
fn maybe_do_rust_analyzer_fallback(&self, binary: &str) -> anyhow::Result<Option<Command>> {
if binary != "rust-analyzer" && binary != "rust-analyzer.exe"
|| self.binary_file("rust-analyzer").exists()
{
return Ok(None);
}

let proc = self.cfg.process;
let Some(path) = proc.var_os("PATH") else {
return Ok(None);
};

let me = env::current_exe()?;

// Try to find the first `rust-analyzer` under the `$PATH` that is both
// an existing file and not the same file as `me`, i.e. not a rustup proxy.
for mut p in env::split_paths(&path) {
p.push(binary);
let is_external_ra = p.is_file()
// We report `true` on `is_same_file()` error to prevent an invalid `p`
// from becoming the candidate.
&& !is_same_file(&me, &p).unwrap_or(true);
// On Unix, we additionally check if the file is executable.
#[cfg(unix)]
let is_external_ra = is_external_ra
&& p.metadata()
.is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0);
if is_external_ra {
let mut ra = Command::new(p);
self.set_env(&mut ra);
return Ok(Some(ra));
}
}
Ok(None)
}

#[cfg_attr(feature="otel", tracing::instrument(err, fields(binary, recursion = self.cfg.process.var("RUST_RECURSION_COUNT").ok())))]
fn create_command<T: AsRef<OsStr> + Debug>(&self, binary: T) -> Result<Command, anyhow::Error> {
// Create the path to this binary within the current toolchain sysroot
Expand Down
73 changes: 73 additions & 0 deletions tests/suite/cli_misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1277,3 +1277,76 @@ async fn rustup_updates_cargo_env_if_proxy() {
)
.await;
}

#[tokio::test]
async fn rust_analyzer_proxy_falls_back_external() {
let mut cx = CliTestContext::new(Scenario::SimpleV2).await;
cx.config
.expect_ok(&[
"rustup",
"toolchain",
"install",
"stable",
"--profile=minimal",
"--component=rls",
])
.await;
cx.config.expect_ok(&["rustup", "default", "stable"]).await;

// We pretend to have a `rust-analyzer` installation by reusing the `rls`
// proxy and mock binary.
let rls = format!("rls{EXE_SUFFIX}");
let ra = format!("rust-analyzer{EXE_SUFFIX}");
let exedir = &cx.config.exedir;
let bindir = &cx
.config
.rustupdir
.join("toolchains")
.join(for_host!("stable-{0}"))
.join("bin");
for dir in [exedir, bindir] {
fs::rename(dir.join(&rls), dir.join(&ra)).unwrap();
}

// Base case: rustup-hosted RA installed, external RA unavailable,
// use the former.
let real_path = cx
.config
.run("rust-analyzer", &["--echo-current-exe"], &[])
.await;
assert!(real_path.ok);
let real_path_str = real_path.stderr.lines().next().unwrap();
let real_path = Path::new(real_path_str);

assert!(real_path.is_file());

let tempdir = tempfile::Builder::new().prefix("rustup").tempdir().unwrap();
let extern_dir = tempdir.path();
let extern_path = &extern_dir.join("rust-analyzer");
fs::copy(real_path, extern_path).unwrap();

// First case: rustup-hosted and external RA both installed,
// prioritize the former.
cx.config
.expect_ok_ex_env(
&["rust-analyzer", "--echo-current-exe"],
&[("PATH", &extern_dir.to_string_lossy())],
"",
&format!("{real_path_str}\n"),
)
.await;

// Second case: rustup-hosted RA unavailable, fallback on the external RA.
fs::remove_file(bindir.join(&ra)).unwrap();
cx.config
.expect_ok_ex_env(
&["rust-analyzer", "--echo-current-exe"],
&[("PATH", &extern_dir.to_string_lossy())],
"",
&format!(
"info: `rust-analyzer` is unavailable for the active toolchain\ninfo: falling back to {:?}\n{}\n",
extern_path.as_os_str(), extern_path.display()
),
)
.await;
}
Loading