From 04a6a51f662283ee1c38731d4c99f6c753b1433b Mon Sep 17 00:00:00 2001 From: Vitaly Slobodin Date: Mon, 11 May 2026 10:56:35 +0200 Subject: [PATCH 1/2] fix(ruby): Normalize Ruby command PWD to the worktree root This change makes Ruby-related commands run with a consistent PWD derived from the current worktree root. It adds a shared environment helper and uses it when: - invoking Bundler - resolving Ruby language server binaries - launching language servers from configured binaries, PATH, and the extension gemset Why: - Zed does not guarantee commands run from the project root - Ruby version managers like asdf and mise rely on PWD to detect the correct project Ruby - without this normalization, Bundler and language servers can resolve the wrong Ruby or gem context This is a hack, yes. And I am sorry about that. --- src/bundler.rs | 100 +++++++++++++++++++----- src/environment.rs | 40 ++++++++++ src/language_servers/language_server.rs | 18 +++-- src/ruby.rs | 1 + 4 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 src/environment.rs diff --git a/src/bundler.rs b/src/bundler.rs index 4e7e214..32774bd 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -1,4 +1,4 @@ -use crate::command_executor::CommandExecutor; +use crate::{command_executor::CommandExecutor, environment::shell_env_with_pwd}; use anyhow::{bail, Context, Result}; use std::path::PathBuf; @@ -45,16 +45,30 @@ impl Bundler { format!("Invalid path to Gemfile: {}", bundle_gemfile_path.display()) })?; + let working_dir_str = self.working_dir.to_str().with_context(|| { + format!( + "Invalid working directory path: {}", + self.working_dir.display() + ) + })?; + let full_args: Vec<&str> = std::iter::once(cmd).chain(args.iter().copied()).collect(); - let command_envs: Vec<(&str, &str)> = envs + let mut command_envs: Vec<(String, String)> = envs .iter() - .cloned() - .chain(std::iter::once(("BUNDLE_GEMFILE", bundle_gemfile))) + .copied() + .filter(|(key, _)| *key != "BUNDLE_GEMFILE") + .map(|(key, value)| (key.to_string(), value.to_string())) .collect(); + command_envs.push(("BUNDLE_GEMFILE".to_string(), bundle_gemfile.to_string())); + let command_envs = shell_env_with_pwd(command_envs, working_dir_str); + let command_env_refs = command_envs + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect::>(); let output = self .command_executor - .execute("bundle", &full_args, &command_envs) + .execute("bundle", &full_args, &command_env_refs) .map_err(|e| anyhow::anyhow!(e))?; match output.status { @@ -151,20 +165,37 @@ mod tests { } } + fn expected_envs(dir: &str) -> Vec<(String, String)> { + let gemfile_path = Path::new(dir) + .join("Gemfile") + .to_string_lossy() + .into_owned(); + + vec![ + ("BUNDLE_GEMFILE".to_string(), gemfile_path), + ("PWD".to_string(), dir.to_string()), + ] + } + + fn env_refs(envs: &[(String, String)]) -> Vec<(&str, &str)> { + envs.iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect() + } + fn create_mock_executor_for_success( version: &str, dir: &str, gem: &str, ) -> MockCommandExecutor { let mock = MockCommandExecutor::new(); - let gemfile_path = Path::new(dir) - .join("Gemfile") - .to_string_lossy() - .into_owned(); + let expected_envs = expected_envs(dir); + let expected_envs = env_refs(&expected_envs); + mock.expect( "bundle", &["info", "--version", gem], - &[("BUNDLE_GEMFILE", &gemfile_path)], + &expected_envs, Ok(Output { status: Some(0), stdout: version.as_bytes().to_vec(), @@ -184,20 +215,51 @@ mod tests { assert_eq!(version, "8.0.0", "Installed gem version should match"); } + #[test] + fn test_installed_gem_version_overrides_pwd_and_bundle_gemfile_envs() { + let mock_executor = MockCommandExecutor::new(); + let mut expected_command_envs = vec![("PATH".to_string(), "/usr/bin".to_string())]; + expected_command_envs.extend(expected_envs("test_dir")); + let expected_command_envs = env_refs(&expected_command_envs); + + mock_executor.expect( + "bundle", + &["info", "--version", "rails"], + &expected_command_envs, + Ok(Output { + status: Some(0), + stdout: b"8.0.0".to_vec(), + stderr: Vec::new(), + }), + ); + + let bundler = Bundler::new("test_dir".into(), mock_executor); + let version = bundler + .installed_gem_version( + "rails", + &[ + ("PWD", "wrong_dir"), + ("BUNDLE_GEMFILE", "wrong/Gemfile"), + ("PATH", "/usr/bin"), + ], + ) + .expect("Expected successful version"); + + assert_eq!(version, "8.0.0", "Installed gem version should match"); + } + #[test] fn test_installed_gem_version_command_error() { let mock_executor = MockCommandExecutor::new(); let gem_name = "unknown_gem"; let error_output = "Could not find gem 'unknown_gem'."; - let gemfile_path = Path::new("test_dir") - .join("Gemfile") - .to_string_lossy() - .into_owned(); + let expected_envs = expected_envs("test_dir"); + let expected_envs = env_refs(&expected_envs); mock_executor.expect( "bundle", &["info", "--version", gem_name], - &[("BUNDLE_GEMFILE", &gemfile_path)], + &expected_envs, Ok(Output { status: Some(1), stdout: Vec::new(), @@ -228,15 +290,13 @@ mod tests { let mock_executor = MockCommandExecutor::new(); let gem_name = "critical_gem"; let specific_error_msg = "Mocked execution failure"; - let gemfile_path = Path::new("test_dir") - .join("Gemfile") - .to_string_lossy() - .into_owned(); + let expected_envs = expected_envs("test_dir"); + let expected_envs = env_refs(&expected_envs); mock_executor.expect( "bundle", &["info", "--version", gem_name], - &[("BUNDLE_GEMFILE", &gemfile_path)], + &expected_envs, Err(specific_error_msg.to_string()), ); diff --git a/src/environment.rs b/src/environment.rs new file mode 100644 index 0000000..a57d60d --- /dev/null +++ b/src/environment.rs @@ -0,0 +1,40 @@ +/// Returns a copy of `shell_env` with `PWD` normalized to `pwd`. +/// +/// Zed's Extensions API does not guarantee where `Command` will be invoked. It could be +/// the extension root directory or the user's home directory. Normalizing `PWD` to the +/// worktree root helps Ruby version managers like asdf/mise find and activate the correct +/// Ruby version. +pub(crate) fn shell_env_with_pwd( + shell_env: Vec<(String, String)>, + pwd: impl Into, +) -> Vec<(String, String)> { + shell_env + .into_iter() + .filter(|(key, _)| key != "PWD") + .chain(std::iter::once(("PWD".to_string(), pwd.into()))) + .collect() +} + +#[cfg(test)] +mod tests { + use super::shell_env_with_pwd; + + #[test] + fn test_shell_env_with_pwd_overrides_existing_pwd() { + let env = shell_env_with_pwd( + vec![ + ("PWD".to_string(), "/wrong/path".to_string()), + ("PATH".to_string(), "/usr/bin".to_string()), + ], + "/path/to/project", + ); + + assert_eq!( + env, + vec![ + ("PATH".to_string(), "/usr/bin".to_string()), + ("PWD".to_string(), "/path/to/project".to_string()), + ] + ); + } +} diff --git a/src/language_servers/language_server.rs b/src/language_servers/language_server.rs index 816d8e6..98ecdd3 100644 --- a/src/language_servers/language_server.rs +++ b/src/language_servers/language_server.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use crate::{ bundler::Bundler, command_executor::RealCommandExecutor, + environment::shell_env_with_pwd, gemset::{versioned_gem_home, Gemset}, }; use std::path::PathBuf; @@ -149,12 +150,15 @@ pub trait LanguageServer { let lsp_settings = zed::settings::LspSettings::for_worktree(language_server_id.as_ref(), worktree)?; + let worktree_root = worktree.root_path(); + let shell_env = shell_env_with_pwd(worktree.shell_env(), worktree_root.clone()); + if let Some(binary_settings) = &lsp_settings.binary { if let Some(path) = &binary_settings.path { return Ok(LanguageServerBinary { path: path.clone(), args: binary_settings.arguments.clone(), - env: Some(worktree.shell_env()), + env: Some(shell_env), }); } } @@ -169,8 +173,8 @@ pub trait LanguageServer { return self.try_find_on_path_or_extension_gemset(language_server_id, worktree); } - let bundler = Bundler::new(PathBuf::from(worktree.root_path()), RealCommandExecutor); - let shell_env = worktree.shell_env(); + let bundler = Bundler::new(PathBuf::from(&worktree_root), RealCommandExecutor); + let env_vars: Vec<(&str, &str)> = shell_env .iter() .map(|(key, value)| (key.as_str(), value.as_str())) @@ -202,11 +206,14 @@ pub trait LanguageServer { language_server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> zed::Result { + let worktree_root = worktree.root_path(); + let shell_env = shell_env_with_pwd(worktree.shell_env(), worktree_root); + if let Some(path) = worktree.which(Self::EXECUTABLE_NAME) { Ok(LanguageServerBinary { path, args: Some(self.get_executable_args(worktree)), - env: Some(worktree.shell_env()), + env: Some(shell_env), }) } else { self.extension_gemset_language_server_binary(language_server_id, worktree) @@ -221,7 +228,8 @@ pub trait LanguageServer { let base_dir = std::env::current_dir() .map_err(|e| format!("Failed to get extension directory: {e:#}"))?; - let worktree_shell_env = worktree.shell_env(); + let worktree_root = worktree.root_path(); + let worktree_shell_env = shell_env_with_pwd(worktree.shell_env(), worktree_root); let worktree_shell_env_vars: Vec<(&str, &str)> = worktree_shell_env .iter() .map(|(key, value)| (key.as_str(), value.as_str())) diff --git a/src/ruby.rs b/src/ruby.rs index 2ee387a..2c7a7eb 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -1,5 +1,6 @@ mod bundler; mod command_executor; +mod environment; mod gemset; mod language_servers; From d11bc41cc06cac430e41551ec6320c1058c7cd5b Mon Sep 17 00:00:00 2001 From: Vitaly Slobodin Date: Mon, 11 May 2026 21:08:19 +0200 Subject: [PATCH 2/2] fix(ruby): retry Ruby tool resolution with a real cwd The previous PWD-based attempt did not change the child process working directory, so Bundler and RubyGems could still resolve the wrong Ruby. Run extension-side bundle/ruby/gem probes through a worktree-root shell wrapper instead, and allow that wrapper in the manifest so fallback installation uses the same project context as the final language server. --- extension.toml | 5 +++ src/bundler.rs | 28 ++++++++-------- src/command_executor.rs | 43 +++++++++++++++++++++++++ src/environment.rs | 40 ----------------------- src/gemset.rs | 25 ++++++++++++-- src/language_servers/language_server.rs | 24 ++++++++++---- src/ruby.rs | 8 ++--- 7 files changed, 105 insertions(+), 68 deletions(-) delete mode 100644 src/environment.rs diff --git a/extension.toml b/extension.toml index 978ade5..8b2ae8e 100644 --- a/extension.toml +++ b/extension.toml @@ -89,5 +89,10 @@ kind = "process:exec" command = "ruby" args = ["--version"] +[[capabilities]] +kind = "process:exec" +command = "sh" +args = ["-c", "*"] + [debug_adapters.rdbg] [debug_locators.ruby] diff --git a/src/bundler.rs b/src/bundler.rs index 32774bd..37ac4a2 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -1,4 +1,4 @@ -use crate::{command_executor::CommandExecutor, environment::shell_env_with_pwd}; +use crate::command_executor::CommandExecutor; use anyhow::{bail, Context, Result}; use std::path::PathBuf; @@ -53,14 +53,19 @@ impl Bundler { })?; let full_args: Vec<&str> = std::iter::once(cmd).chain(args.iter().copied()).collect(); - let mut command_envs: Vec<(String, String)> = envs + let command_envs: Vec<(String, String)> = envs .iter() .copied() .filter(|(key, _)| *key != "BUNDLE_GEMFILE") .map(|(key, value)| (key.to_string(), value.to_string())) .collect(); - command_envs.push(("BUNDLE_GEMFILE".to_string(), bundle_gemfile.to_string())); - let command_envs = shell_env_with_pwd(command_envs, working_dir_str); + let command_envs = command_envs + .into_iter() + .chain(std::iter::once(( + "BUNDLE_GEMFILE".to_string(), + bundle_gemfile.to_string(), + ))) + .collect::>(); let command_env_refs = command_envs .iter() .map(|(key, value)| (key.as_str(), value.as_str())) @@ -68,7 +73,7 @@ impl Bundler { let output = self .command_executor - .execute("bundle", &full_args, &command_env_refs) + .execute_in_dir("bundle", &full_args, &command_env_refs, working_dir_str) .map_err(|e| anyhow::anyhow!(e))?; match output.status { @@ -171,10 +176,7 @@ mod tests { .to_string_lossy() .into_owned(); - vec![ - ("BUNDLE_GEMFILE".to_string(), gemfile_path), - ("PWD".to_string(), dir.to_string()), - ] + vec![("BUNDLE_GEMFILE".to_string(), gemfile_path)] } fn env_refs(envs: &[(String, String)]) -> Vec<(&str, &str)> { @@ -216,7 +218,7 @@ mod tests { } #[test] - fn test_installed_gem_version_overrides_pwd_and_bundle_gemfile_envs() { + fn test_installed_gem_version_overrides_bundle_gemfile_env() { let mock_executor = MockCommandExecutor::new(); let mut expected_command_envs = vec![("PATH".to_string(), "/usr/bin".to_string())]; expected_command_envs.extend(expected_envs("test_dir")); @@ -237,11 +239,7 @@ mod tests { let version = bundler .installed_gem_version( "rails", - &[ - ("PWD", "wrong_dir"), - ("BUNDLE_GEMFILE", "wrong/Gemfile"), - ("PATH", "/usr/bin"), - ], + &[("BUNDLE_GEMFILE", "wrong/Gemfile"), ("PATH", "/usr/bin")], ) .expect("Expected successful version"); diff --git a/src/command_executor.rs b/src/command_executor.rs index 8bcad66..7389c79 100644 --- a/src/command_executor.rs +++ b/src/command_executor.rs @@ -22,6 +22,17 @@ pub trait CommandExecutor { args: &[&str], envs: &[(&str, &str)], ) -> zed::Result; + + fn execute_in_dir( + &self, + cmd: &str, + args: &[&str], + envs: &[(&str, &str)], + working_dir: &str, + ) -> zed::Result { + let _ = working_dir; + self.execute(cmd, args, envs) + } } /// An implementation of `CommandExecutor` that executes commands @@ -41,4 +52,36 @@ impl CommandExecutor for RealCommandExecutor { .envs(envs.iter().copied()) .output() } + + fn execute_in_dir( + &self, + cmd: &str, + args: &[&str], + envs: &[(&str, &str)], + working_dir: &str, + ) -> zed::Result { + let script = sh_command_in_dir(working_dir, cmd, args); + + eprintln!("Executing in dir via sh: {script}"); + + zed::Command::new("sh") + .args(["-c", script.as_str()]) + .envs(envs.iter().copied()) + .output() + } +} + +fn sh_command_in_dir(working_dir: &str, cmd: &str, args: &[&str]) -> String { + format!( + "cd -- {} && exec {}{}", + sh_quote(working_dir), + sh_quote(cmd), + args.iter() + .map(|arg| format!(" {}", sh_quote(arg))) + .collect::() + ) +} + +fn sh_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) } diff --git a/src/environment.rs b/src/environment.rs deleted file mode 100644 index a57d60d..0000000 --- a/src/environment.rs +++ /dev/null @@ -1,40 +0,0 @@ -/// Returns a copy of `shell_env` with `PWD` normalized to `pwd`. -/// -/// Zed's Extensions API does not guarantee where `Command` will be invoked. It could be -/// the extension root directory or the user's home directory. Normalizing `PWD` to the -/// worktree root helps Ruby version managers like asdf/mise find and activate the correct -/// Ruby version. -pub(crate) fn shell_env_with_pwd( - shell_env: Vec<(String, String)>, - pwd: impl Into, -) -> Vec<(String, String)> { - shell_env - .into_iter() - .filter(|(key, _)| key != "PWD") - .chain(std::iter::once(("PWD".to_string(), pwd.into()))) - .collect() -} - -#[cfg(test)] -mod tests { - use super::shell_env_with_pwd; - - #[test] - fn test_shell_env_with_pwd_overrides_existing_pwd() { - let env = shell_env_with_pwd( - vec![ - ("PWD".to_string(), "/wrong/path".to_string()), - ("PATH".to_string(), "/usr/bin".to_string()), - ], - "/path/to/project", - ); - - assert_eq!( - env, - vec![ - ("PATH".to_string(), "/usr/bin".to_string()), - ("PWD".to_string(), "/path/to/project".to_string()), - ] - ); - } -} diff --git a/src/gemset.rs b/src/gemset.rs index e3bd387..736fdae 100644 --- a/src/gemset.rs +++ b/src/gemset.rs @@ -13,8 +13,17 @@ pub fn versioned_gem_home( envs: &[(&str, &str)], executor: &dyn CommandExecutor, ) -> Result { + let working_dir = envs + .iter() + .find_map(|(key, value)| (*key == "PWD").then_some(*value)); + let output = executor - .execute("ruby", &["--version"], envs) + .execute_in_dir( + "ruby", + &["--version"], + envs, + working_dir.unwrap_or(base_dir.to_string_lossy().as_ref()), + ) .map_err(|e| anyhow::anyhow!(e)) .context("Failed to detect Ruby version")?; @@ -34,6 +43,7 @@ pub fn versioned_gem_home( /// A simple wrapper around the `gem` command. pub struct Gemset { gem_home: PathBuf, + working_dir: Option, envs: Vec<(String, String)>, cached_env: OnceLock>, command_executor: Box, @@ -47,6 +57,10 @@ impl Gemset { ) -> Self { Self { gem_home, + working_dir: envs.and_then(|envs| { + envs.iter() + .find_map(|(key, value)| (*key == "PWD").then_some((*value).to_string())) + }), envs: envs.map_or(Vec::new(), |envs| { envs.iter() .map(|&(k, v)| (k.to_string(), v.to_string())) @@ -181,7 +195,14 @@ impl Gemset { let output = self .command_executor - .execute("gem", &full_args, &merged_envs) + .execute_in_dir( + "gem", + &full_args, + &merged_envs, + self.working_dir + .as_deref() + .unwrap_or(self.gem_home.to_string_lossy().as_ref()), + ) .map_err(|e| anyhow!(e))?; match output.status { diff --git a/src/language_servers/language_server.rs b/src/language_servers/language_server.rs index 98ecdd3..8310770 100644 --- a/src/language_servers/language_server.rs +++ b/src/language_servers/language_server.rs @@ -4,7 +4,6 @@ use std::collections::HashMap; use crate::{ bundler::Bundler, command_executor::RealCommandExecutor, - environment::shell_env_with_pwd, gemset::{versioned_gem_home, Gemset}, }; use std::path::PathBuf; @@ -151,7 +150,7 @@ pub trait LanguageServer { zed::settings::LspSettings::for_worktree(language_server_id.as_ref(), worktree)?; let worktree_root = worktree.root_path(); - let shell_env = shell_env_with_pwd(worktree.shell_env(), worktree_root.clone()); + let shell_env = worktree.shell_env(); if let Some(binary_settings) = &lsp_settings.binary { if let Some(path) = &binary_settings.path { @@ -197,7 +196,14 @@ pub trait LanguageServer { env: Some(shell_env), }) } - Err(_e) => self.try_find_on_path_or_extension_gemset(language_server_id, worktree), + Err(e) => { + eprintln!( + "Bundler probe failed for {} in {}: {e:#}", + Self::GEM_NAME, + worktree_root + ); + self.try_find_on_path_or_extension_gemset(language_server_id, worktree) + } } } @@ -206,8 +212,7 @@ pub trait LanguageServer { language_server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> zed::Result { - let worktree_root = worktree.root_path(); - let shell_env = shell_env_with_pwd(worktree.shell_env(), worktree_root); + let shell_env = worktree.shell_env(); if let Some(path) = worktree.which(Self::EXECUTABLE_NAME) { Ok(LanguageServerBinary { @@ -228,8 +233,7 @@ pub trait LanguageServer { let base_dir = std::env::current_dir() .map_err(|e| format!("Failed to get extension directory: {e:#}"))?; - let worktree_root = worktree.root_path(); - let worktree_shell_env = shell_env_with_pwd(worktree.shell_env(), worktree_root); + let worktree_shell_env = worktree.shell_env(); let worktree_shell_env_vars: Vec<(&str, &str)> = worktree_shell_env .iter() .map(|(key, value)| (key.as_str(), value.as_str())) @@ -239,6 +243,12 @@ pub trait LanguageServer { versioned_gem_home(&base_dir, &worktree_shell_env_vars, &RealCommandExecutor) .map_err(|e| format!("{:#}", e))?; + eprintln!( + "Using extension gemset for {} with gem home {}", + Self::GEM_NAME, + gem_home.display() + ); + let gemset = Gemset::new( gem_home, Some(&worktree_shell_env_vars), diff --git a/src/ruby.rs b/src/ruby.rs index 2c7a7eb..0a07eab 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -1,6 +1,5 @@ mod bundler; mod command_executor; -mod environment; mod gemset; mod language_servers; @@ -135,6 +134,7 @@ impl zed::Extension for RubyExtension { _: Option, worktree: &Worktree, ) -> Result { + let worktree_root = worktree.root_path(); let shell_env = worktree.shell_env(); let env_vars: Vec<(&str, &str)> = shell_env .iter() @@ -142,7 +142,7 @@ impl zed::Extension for RubyExtension { .collect(); let (command, mut arguments) = { - let bundler = Bundler::new(PathBuf::from(worktree.root_path()), RealCommandExecutor); + let bundler = Bundler::new(PathBuf::from(&worktree_root), RealCommandExecutor); if bundler.installed_gem_version("debug", &env_vars).is_ok() { let bundle = worktree.which("bundle").ok_or_else(|| { "debug gem present, but unable to find 'bundle' command".to_string() @@ -177,7 +177,7 @@ impl zed::Extension for RubyExtension { if let Some(configuration) = configuration.as_object_mut() { configuration .entry("cwd") - .or_insert_with(|| worktree.root_path().into()); + .or_insert_with(|| worktree_root.clone().into()); } let ruby_config: RubyDebugConfig = serde_json::from_value(configuration.clone()) @@ -240,7 +240,7 @@ impl zed::Extension for RubyExtension { command: Some(command), arguments, connection: Some(connection), - cwd: ruby_config.cwd.or(Some(worktree.root_path())), + cwd: ruby_config.cwd.or(Some(worktree_root)), envs: ruby_config.env.into_iter().collect(), request_args: StartDebuggingRequestArguments { configuration: configuration.to_string(),