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 4e7e214..37ac4a2 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -45,16 +45,35 @@ 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 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(); + 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())) + .collect::>(); let output = self .command_executor - .execute("bundle", &full_args, &command_envs) + .execute_in_dir("bundle", &full_args, &command_env_refs, working_dir_str) .map_err(|e| anyhow::anyhow!(e))?; match output.status { @@ -151,20 +170,34 @@ 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)] + } + + 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 +217,47 @@ mod tests { assert_eq!(version, "8.0.0", "Installed gem version should match"); } + #[test] + 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")); + 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", + &[("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 +288,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/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/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 816d8e6..8310770 100644 --- a/src/language_servers/language_server.rs +++ b/src/language_servers/language_server.rs @@ -149,12 +149,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 = worktree.shell_env(); + 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 +172,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())) @@ -193,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) + } } } @@ -202,11 +212,13 @@ pub trait LanguageServer { language_server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> zed::Result { + let shell_env = worktree.shell_env(); + 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) @@ -231,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 2ee387a..0a07eab 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -134,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() @@ -141,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() @@ -176,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()) @@ -239,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(),