diff --git a/src/test/mock_bin_src.rs b/src/test/mock_bin_src.rs index ac90a1e1d2..d85b8002cd 100644 --- a/src/test/mock_bin_src.rs +++ b/src/test/mock_bin_src.rs @@ -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), } } diff --git a/src/toolchain.rs b/src/toolchain.rs index c61dde090d..f3cf17f6cf 100644 --- a/src/toolchain.rs +++ b/src/toolchain.rs @@ -1,3 +1,5 @@ +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt as _; use std::{ env::{self, consts::EXE_SUFFIX}, ffi::{OsStr, OsString}, @@ -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; @@ -329,13 +332,14 @@ impl<'a> Toolchain<'a> { pub(crate) fn command(&self, binary: &str) -> anyhow::Result { // 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) @@ -343,8 +347,10 @@ impl<'a> Toolchain<'a> { // 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> { - if binary != "cargo" && binary != "cargo.exe" { + fn maybe_do_cargo_fallback(&self, binary: &str) -> anyhow::Result> { + if let LocalToolchainName::Named(ToolchainName::Official(_)) = self.name() { + return Ok(None); + } else if binary != "cargo" && binary != "cargo.exe" { return Ok(None); } @@ -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: + /// - + /// - + fn maybe_do_rust_analyzer_fallback(&self, binary: &str) -> anyhow::Result> { + 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 + Debug>(&self, binary: T) -> Result { // Create the path to this binary within the current toolchain sysroot diff --git a/tests/suite/cli_misc.rs b/tests/suite/cli_misc.rs index 1ad12c742c..1d5f401fcd 100644 --- a/tests/suite/cli_misc.rs +++ b/tests/suite/cli_misc.rs @@ -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; +}