diff --git a/crates/ide-core/src/tool_path.rs b/crates/ide-core/src/tool_path.rs index 266f5bae0..bc25dd6d2 100644 --- a/crates/ide-core/src/tool_path.rs +++ b/crates/ide-core/src/tool_path.rs @@ -30,6 +30,21 @@ pub fn search_path() -> OsString { std::env::join_paths(dirs).unwrap_or_default() } +/// Ruby code printing where `gem install` puts executables: the Ruby's own `Gem.bindir`, which +/// Homebrew's Ruby keeps off `PATH`, and the `--user-install` one. +pub const RUBY_GEM_BIN_SCRIPT: &str = "puts Gem.bindir, File.join(Gem.user_dir, 'bin')"; + +/// The directories [`RUBY_GEM_BIN_SCRIPT`] printed that exist. +pub fn gem_bin_dirs(output: &str) -> Vec { + let mut dirs: Vec = Vec::new(); + for dir in output.lines().map(str::trim).map(PathBuf::from) { + if dir.is_absolute() && dir.is_dir() && !dirs.contains(&dir) { + dirs.push(dir); + } + } + dirs +} + /// Resolves a command on the shared path, or an explicit path relative to `root`. pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option { let path = Path::new(command); diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index b92399c2f..d3188b77a 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -24,7 +24,11 @@ fn tools(host: &Host, window: &str) -> IdeResult { .collect(); // Godot is usually an app bundle / Flatpak outside the search path. let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p)); - Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect())) + let work_dir = (!root.as_os_str().is_empty()).then_some(root); + Ok(Tools::new(&data) + .with_custom_paths(custom) + .with_found_paths(found.into_iter().collect()) + .with_work_dir(work_dir)) } /// The language definitions plus the servers defined in the window's settings. diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index c8f68f0e0..e7ce586b5 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -4,7 +4,7 @@ use ide_core::CancellationToken; use ide_core::tool_path::search_path; use ide_jsonrpc::{Handler, Peer}; use ide_language::LanguageServerConfig; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -100,7 +100,8 @@ impl Launcher for ProcessLauncher { // A plugin's server: the approved program, unchanged since. Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()), None => ide_core::tool_path::find_command(&config.command, &search, root) - .and_then(|b| resolve_rustup_proxy(&config.command, b)), + .and_then(|b| resolve_rustup_proxy(&config.command, b)) + .and_then(|b| reject_dead_shim(b, &search, root)), }; let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())]; env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone()))); @@ -136,6 +137,60 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString { config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path) } +/// What a version manager's shim says when the active version has no such tool. +const DEAD_SHIM_MESSAGES: &[&str] = + &["no version is set for", "no preset version", "command not found", "exists in these", "is not installed"]; + +/// `~/.local/share/mise/shims/ruby-lsp` exists for every version of the runtime that ever +/// had the tool, but exits with an error when the version active in `root` lacks it. A +/// server that dies like that on every start is missing (an install hint), not crashing. +/// Anything but a clear "not installed" answer from the shim counts as working. +fn reject_dead_shim(binary: PathBuf, search: &OsStr, root: &Path) -> Option { + if !binary.parent().is_some_and(|d| d.file_name().is_some_and(|n| n == "shims")) { + return Some(binary); + } + let Ok(mut child) = ide_core::process::prepare(&mut std::process::Command::new(&binary)) + .arg("--version") + .current_dir(root) + .env("PATH", search) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + else { + return Some(binary); + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(20)) + } + // Slow or unwaitable: let the real start decide. + _ => { + let _ = child.kill(); + let _ = child.wait(); + return Some(binary); + } + } + }; + if status.success() { + return Some(binary); + } + let mut stderr = String::new(); + if let Some(mut pipe) = child.stderr.take() { + let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr); + } + let stderr = stderr.to_lowercase(); + if DEAD_SHIM_MESSAGES.iter().any(|m| stderr.contains(m)) { + tracing::info!("{} is a version-manager shim without the tool: {}", binary.display(), stderr.trim()); + None + } else { + Some(binary) + } +} + /// `~/.cargo/bin/` is usually a rustup proxy that fails when the active /// toolchain lacks the component (common for rust-analyzer). In that case use /// the newest installed toolchain that ships the binary; with none, the tool is @@ -219,6 +274,34 @@ mod tests { std::fs::remove_dir_all(&tmp).unwrap(); } + #[cfg(unix)] + #[test] + fn shims_without_the_tool_are_missing() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let shims = tmp.path().join("mise/shims"); + std::fs::create_dir_all(&shims).unwrap(); + let script = |name: &str, body: &str| { + let p = shims.join(name); + std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p + }; + let search = OsString::from("/usr/bin:/bin"); + let dead = script("ruby-lsp", "echo 'mise ERROR No version is set for shim: ruby-lsp' >&2; exit 1"); + assert_eq!(reject_dead_shim(dead, &search, tmp.path()), None); + let works = script("good", "echo 1.0"); + assert_eq!(reject_dead_shim(works.clone(), &search, tmp.path()), Some(works)); + // A tool with no `--version` that fails for its own reasons still starts. + let odd = script("odd", "echo 'usage: odd' >&2; exit 2"); + assert_eq!(reject_dead_shim(odd.clone(), &search, tmp.path()), Some(odd)); + // Only shims are probed. + let plain = tmp.path().join("ruby-lsp"); + std::fs::write(&plain, "#!/bin/sh\necho 'No version is set for' >&2; exit 1\n").unwrap(); + std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(reject_dead_shim(plain.clone(), &search, tmp.path()), Some(plain)); + } + #[test] fn marked_root_needs_a_marker() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 11888554c..2ee3a07e2 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -691,6 +691,13 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root path_dirs.push(dir); } + // Gems install to the Ruby's bindir, which is not always on `PATH` (Homebrew). + if name == "ruby-lsp" + && let Some(ruby) = ruby_interpreter(root, s) + { + path_dirs.extend(crate::ruby::gem_bin_dirs(&ruby, server_root)); + } + if !path_dirs.is_empty() { let dirs: Vec<&Path> = path_dirs.iter().map(PathBuf::as_path).collect(); cfg.env.insert("PATH".into(), path_with(&dirs)); @@ -1301,3 +1308,29 @@ mod maven_linkage_tests { assert!(serde_json::to_value(cfg.initialization_options).unwrap().get("projectConfigurations").is_none()); } } + +#[cfg(all(test, unix))] +mod ruby_lsp_tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn ruby_lsp_is_found_in_the_rubys_gem_bindir_when_that_is_off_path() { + let tmp = tempfile::tempdir().unwrap(); + let bindir = tmp.path().join("gems/bin"); + std::fs::create_dir_all(&bindir).unwrap(); + // Homebrew's Ruby: `Gem.bindir` is not on `PATH`. + let ruby = tmp.path().join("ruby"); + std::fs::write(&ruby, format!("#!/bin/sh\necho '{}'\necho '/nonexistent/user/bin'\n", bindir.display())) + .unwrap(); + std::fs::set_permissions(&ruby, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut s = Settings::default(); + s.languages.ruby.interpreter = ruby.to_string_lossy().into_owned(); + let mut cfg = custom_server("ruby-lsp", &LanguageServerSettings::default()); + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + let path = cfg.env.get("PATH").expect("PATH set"); + let dirs: Vec = std::env::split_paths(path).collect(); + assert!(dirs.contains(&bindir), "{dirs:?}"); + assert!(!dirs.iter().any(|d| d.starts_with("/nonexistent")), "{dirs:?}"); + } +} diff --git a/crates/ide-sdk/src/ruby.rs b/crates/ide-sdk/src/ruby.rs index b7ce8df4b..c0bbed7ca 100644 --- a/crates/ide-sdk/src/ruby.rs +++ b/crates/ide-sdk/src/ruby.rs @@ -140,6 +140,14 @@ pub fn bin_dir_of(ruby: &Path) -> Option { ruby.parent().map(Path::to_path_buf) } +/// Where `ruby` installs gem executables (`Gem.bindir`, the `--user-install` one) that exist, asked +/// of the Ruby that version managers select in `root`: Homebrew's keeps its bindir off `PATH`. +pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec { + query_in(ruby, &["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT], Some(root), &[], QUERY_TIMEOUT) + .map(|out| ide_core::tool_path::gem_bin_dirs(&out)) + .unwrap_or_default() +} + type NamedRoots = Vec<(String, PathBuf)>; /// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 881393d6c..d0a8b65d5 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -154,7 +154,11 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option

{ Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" })) } - Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None, + Method::Bundled { .. } + | Method::Rustup { .. } + | Method::PipUser { .. } + | Method::PipTarget { .. } + | Method::GemActive { .. } => None, } } @@ -429,6 +433,9 @@ pub struct Tools { /// Executables found outside the search path (app bundles), by command. found: BTreeMap, version_timeout: Duration, + /// The project the tools are used for: tools that install into "the active runtime" (gems) run here, + /// so a version manager picks the project's Ruby. + work_dir: Option, } impl Tools { @@ -450,6 +457,7 @@ impl Tools { custom: BTreeMap::new(), found: BTreeMap::new(), version_timeout: Duration::from_secs(4), + work_dir: None, } } @@ -465,10 +473,25 @@ impl Tools { self } + /// Runs per-project installs (see [`registry::Method::GemActive`]) in `dir`. + pub fn with_work_dir(mut self, dir: Option) -> Self { + self.work_dir = dir.filter(|d| d.is_dir()); + self + } + pub fn root(&self) -> &Path { &self.root } + /// [`Tools::command`] in the project directory. + fn command_in_project(&self, program: impl AsRef) -> Command { + let mut cmd = self.command(program); + if let Some(dir) = &self.work_dir { + cmd.current_dir(dir); + } + cmd + } + fn which(&self, cmd: &str) -> Option { ide_core::tool_path::find_command(cmd, &self.system_path, &self.root) } @@ -614,7 +637,9 @@ impl Tools { "npm or pnpm is required: install Node.js (https://nodejs.org)", ), Method::Go { .. } => need(self.which("go").is_some(), "Go is required (https://go.dev/dl)"), - Method::Gem { .. } => need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)"), + Method::Gem { .. } | Method::GemActive { .. } => { + need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)") + } Method::DotnetTool { .. } => { need(self.which("dotnet").is_some(), ".NET SDK is required (https://dotnet.microsoft.com/download)") } @@ -772,6 +797,22 @@ impl Tools { _ => (ToolStatus::Missing, None, None), } } + Method::GemActive { gem } => { + // Installed for the Ruby active in the project, not merely a shim of some other version's. + let Some(gem_cmd) = self.which("gem") else { return (ToolStatus::Missing, None, None) }; + let mut c = self.command_in_project(&gem_cmd); + c.args(["list", "--exact", gem]); + match run_quiet(c, Duration::from_secs(10)) { + Some((true, out)) if out.contains(&format!("{gem} (")) => { + // Homebrew's Ruby keeps its gem bindir off PATH: ask the Ruby where it is. + match self.gem_executable(main) { + Some(path) => (ToolStatus::OnPath, Some(path), parse_version(&out)), + None => (ToolStatus::Missing, None, None), + } + } + _ => (ToolStatus::Missing, None, None), + } + } _ => match self.which(main) { Some(path) => { let version = self.query_version(spec, &path); @@ -868,6 +909,7 @@ impl Tools { Method::Npm { packages } => self.install_npm(packages, cancel, emit)?, Method::Go { module } => self.install_go(spec, module, cancel, emit)?, Method::Gem { gem } => self.install_gem(spec, gem, cancel, emit)?, + Method::GemActive { gem } => self.install_gem_active(gem, cancel, emit)?, Method::DotnetTool { package } => self.install_dotnet_tool(spec, package, cancel, emit)?, Method::PipVenv { package } => self.install_venv(spec, package, cancel, emit)?, Method::PipUser { package, .. } => self.install_pip_user(package, cancel, emit)?, @@ -953,6 +995,41 @@ impl Tools { self.write_manifest(spec.name, &Manifest { version, source: Some(module.to_string()) }) } + /// Where the project's Ruby installs gem executables (`Gem.bindir`, `Gem.user_dir`'s `bin`). + fn gem_bin_dirs(&self) -> Vec { + let Some(ruby) = self.which("ruby") else { return Vec::new() }; + let mut c = self.command_in_project(ruby); + c.args(["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT]); + match run_quiet(c, Duration::from_secs(10)) { + Some((true, out)) => ide_core::tool_path::gem_bin_dirs(&out), + _ => Vec::new(), + } + } + + /// An installed gem's executable: on the search path, else in one of the Ruby's gem bin directories. + fn gem_executable(&self, cmd: &str) -> Option { + self.which(cmd).or_else(|| self.gem_bin_dirs().iter().find_map(|d| self.exe_in(d, cmd))) + } + + /// `gem install` into the active Ruby, then makes version-manager shims see the new executable. + fn install_gem_active(&self, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> { + let mut c = self.command_in_project(self.which("gem").expect("checked")); + c.args(["install", "--no-document", gem]); + run_streamed(c, cancel, emit)?; + // mise / asdf / rbenv list executables in shims written at install time; a gem installed + // afterwards has none until they are regenerated. Best effort: the tool may not be installed. + for (manager, args) in + [("mise", &["reshim"][..]), ("asdf", &["reshim", "ruby"][..]), ("rbenv", &["rehash"][..])] + { + if let Some(path) = self.which(manager) { + let mut c = self.command_in_project(path); + c.args(args); + let _ = run_quiet(c, Duration::from_secs(30)); + } + } + Ok(()) + } + fn install_gem(&self, spec: &ToolSpec, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> { let dir = self.root.join(spec.name); let _ = std::fs::remove_dir_all(&dir); @@ -1310,6 +1387,18 @@ impl Tools { run_streamed(c, &cancel, &quiet)?; } } + Method::GemActive { gem } => { + if let Some(gem_cmd) = self.which("gem") { + let mut c = self.command_in_project(&gem_cmd); + c.args(["uninstall", "--all", "--executables", "--ignore-dependencies", gem]); + run_streamed(c, &cancel, &quiet)?; + } + // A copy an older version installed under the tools directory. + let dir = self.root.join(spec.name); + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + } + } Method::Go { .. } | Method::Gem { .. } | Method::DotnetTool { .. } diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 494482f76..50b503ca7 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -49,6 +49,11 @@ pub enum Method { PipTarget { package: &'static str, module: &'static str }, /// `gem install` into `//gems`, launched through `GEM_HOME` wrappers in `bin/`. Gem { gem: &'static str }, + /// `gem install ` into the active Ruby (the project's, installed from a project window), + /// launched through that Ruby's own executables. For tools that re-launch themselves under + /// Bundler (ruby-lsp), which breaks in a gem home under a path with spaces + /// (`~/Library/Application Support`: Bundler `exec`s a lone path string, and Ruby splits it). + GemActive { gem: &'static str }, /// `dotnet tool install --tool-path //tool `, launched /// through `DOTNET_ROOT` wrappers in `bin/`. DotnetTool { package: &'static str }, @@ -64,6 +69,7 @@ impl Method { Method::Npm { .. } => "npm", Method::Go { .. } => "go install", Method::Gem { .. } => "gem", + Method::GemActive { .. } => "gem (project Ruby)", Method::DotnetTool { .. } => "dotnet tool", Method::PipVenv { .. } => "pip (virtualenv)", Method::PipUser { .. } => "pip --user", @@ -940,7 +946,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "Ruby LSP (Shopify)", kind: LanguageServer, commands: &["ruby-lsp"], - method: Method::Gem { gem: "ruby-lsp" }, + method: Method::GemActive { gem: "ruby-lsp" }, fallback: None, version: V, homepage: "https://shopify.github.io/ruby-lsp", diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index de4ff670d..f3ab5530d 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -590,6 +590,51 @@ printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/ assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), env.root.join("solargraph/gems").to_string_lossy()); } +#[cfg(unix)] +#[test] +fn ruby_lsp_installs_into_the_project_ruby_not_a_private_gem_home() { + let env = Env::new(); + let project = env._tmp.path().join("project"); + fs::create_dir_all(&project).unwrap(); + let marker = env._tmp.path().join("installed"); + // Homebrew's Ruby: the gem bindir is not on the search path; only the Ruby knows it. + let bindir = env._tmp.path().join("gems/bin"); + fs::create_dir_all(&bindir).unwrap(); + env.script(&env.sys, "ruby", &format!("echo '{}'", bindir.display())); + // `gem list --exact ruby-lsp` knows the gem once `gem install ruby-lsp` ran, in the cwd it was given. + env.script( + &env.sys, + "gem", + &format!( + r#"case "$1" in +install) echo "$PWD $@" > '{m}'; printf '#!/bin/sh\n' > '{b}/ruby-lsp'; /bin/chmod +x '{b}/ruby-lsp' ;; +list) [ -f '{m}' ] && echo "ruby-lsp (0.26.11)" ;; +uninstall) /bin/rm -f '{m}' ;; +esac"#, + m = marker.display(), + b = bindir.display() + ), + ); + let tools = env.tools().with_work_dir(Some(project.clone())); + assert_eq!(find(&tools.list(&langs()), "ruby-lsp").status, ToolStatus::Missing); + let info = tools.install("ruby-lsp", &langs(), &CancellationToken::new(), &|_| {}).unwrap(); + assert_eq!(info.status, ToolStatus::OnPath); + assert_eq!(info.version.as_deref(), Some("0.26.11")); + // Found in the Ruby's own bindir, not reported as the `gem` command. + assert_eq!(info.path.as_deref(), Some(bindir.join("ruby-lsp").as_path())); + // Run in the project (so a version manager picks its Ruby), into the Ruby's own gem home. + let log = fs::read_to_string(&marker).unwrap(); + assert!(log.starts_with(&*project.canonicalize().unwrap().to_string_lossy()), "{log}"); + assert!(log.contains("install --no-document ruby-lsp") && !log.contains("--install-dir"), "{log}"); + // No private gem home, no wrapper directory on the search path. + assert!(!env.root.join("ruby-lsp").exists()); + // Uninstalling removes the gem, and an older managed copy. + fs::create_dir_all(env.root.join("ruby-lsp/gems")).unwrap(); + let info = tools.uninstall("ruby-lsp", &langs()).unwrap(); + assert_eq!(info.status, ToolStatus::Missing); + assert!(!env.root.join("ruby-lsp").exists()); +} + #[cfg(unix)] #[test] fn dotnet_tool_install_into_tool_path() {