diff --git a/Cargo.toml b/Cargo.toml index 64b551b..acd202f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,6 @@ license = "Apache-2.0" path = "src/ruby.rs" crate-type = ["cdylib"] -[features] -default = [] -command_api = [] - [dependencies] regex = "1.11.1" serde_json = "1.0" diff --git a/README.md b/README.md index cec4818..34820f5 100644 --- a/README.md +++ b/README.md @@ -2,26 +2,23 @@ [Documentation](https://zed.dev/docs/languages/ruby) -## Command-free LSP build +## Ruby command resolution -The default build does not run extension-side process commands for Ruby LSP -startup. It uses configured `lsp..binary.path` values first, then falls -back to `worktree.which`. If `use_bundler` is enabled, it launches through -`bundle exec ` without probing Bundler. +The extension uses configured `lsp..binary.path` values first. If +`use_bundler` is enabled, it checks Bundler and launches through +`bundle exec `. Otherwise it falls back to `worktree.which` or the +extension-managed gemset. ```sh cargo test ``` -To enable the command API path for project gem detection and extension-managed -language server/debug gem installation, build with: - -```sh -cargo test --features command_api -``` - This is not a replacement for fixing Zed's command spawning behavior: -https://github.com/zed-industries/zed/issues/57170. The command-free profile -expects `bundle` or the language server executable to be available from the -project environment. Debugging expects `rdbg` to be available from that same -environment. +https://github.com/zed-industries/zed/issues/57170. Debugging expects `rdbg` to +be available from the project environment. + +On macOS, extension-side Ruby, Bundler, and gem probes run through +`/bin/sh -c 'exec "$0" "$@"' ...`. Bundler-mode LSP startup likewise uses shell +resolution so version managers (rbenv, chruby, mise, asdf) are honored. Gemset +LSP startup runs `ruby ` from the worktree root so the project +Ruby is activated; DAP startup uses Zed worktree command resolution directly. diff --git a/extension.toml b/extension.toml index e43818d..ad7d83d 100644 --- a/extension.toml +++ b/extension.toml @@ -58,40 +58,79 @@ commit = "c70c1de07dedd532089c0c90835c8ed9fa694f5c" repository = "https://github.com/joker1007/tree-sitter-rbs" commit = "5282e2f36d4109f5315c1d9486b5b0c2044622bb" +# Each command below appears twice: once for direct execution (non-macOS), and once wrapped via +# /bin/sh (macOS). The shell-wrapper form is required on macOS so version managers (rbenv, chruby, +# mise, asdf) can resolve executables from the correct PATH at spawn time. Both entries must stay +# in sync when a command's args change in code. [[capabilities]] kind = "process:exec" command = "gem" args = ["install", "--norc", "--no-user-install", "--no-format-executable", "--no-document", "*"] +[[capabilities]] +kind = "process:exec" +command = "/bin/sh" +args = ["-c", "exec \"$0\" \"$@\"", "gem", "install", "--norc", "--no-user-install", "--no-format-executable", "--no-document", "*"] + [[capabilities]] kind = "process:exec" command = "gem" args = ["uninstall", "--norc", "*", "--version", "*"] +[[capabilities]] +kind = "process:exec" +command = "/bin/sh" +args = ["-c", "exec \"$0\" \"$@\"", "gem", "uninstall", "--norc", "*", "--version", "*"] + [[capabilities]] kind = "process:exec" command = "gem" args = ["list", "--norc", "--exact", "*"] +[[capabilities]] +kind = "process:exec" +command = "/bin/sh" +args = ["-c", "exec \"$0\" \"$@\"", "gem", "list", "--norc", "--exact", "*"] + [[capabilities]] kind = "process:exec" command = "bundle" args = ["info", "--version", "*"] +[[capabilities]] +kind = "process:exec" +command = "/bin/sh" +args = ["-c", "exec \"$0\" \"$@\"", "bundle", "info", "--version", "*"] + [[capabilities]] kind = "process:exec" command = "gem" args = ["outdated", "--norc"] +[[capabilities]] +kind = "process:exec" +command = "/bin/sh" +args = ["-c", "exec \"$0\" \"$@\"", "gem", "outdated", "--norc"] + [[capabilities]] kind = "process:exec" command = "gem" args = ["update", "--norc", "*"] +[[capabilities]] +kind = "process:exec" +command = "/bin/sh" +args = ["-c", "exec \"$0\" \"$@\"", "gem", "update", "--norc", "*"] + [[capabilities]] kind = "process:exec" command = "ruby" args = ["--version"] +[[capabilities]] +kind = "process:exec" +command = "/bin/sh" +args = ["-c", "cd \"$0\" && command=\"$1\" && shift && exec \"$command\" \"$@\"", "*", "ruby", "--version"] + [debug_adapters.rdbg] [debug_locators.ruby] diff --git a/src/bundler.rs b/src/bundler.rs index 4e7e214..07987f0 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -54,7 +54,14 @@ impl Bundler { let output = self .command_executor - .execute("bundle", &full_args, &command_envs) + .execute_in_dir( + "bundle", + &full_args, + &command_envs, + self.working_dir.to_str().with_context(|| { + format!("Invalid working directory: {}", self.working_dir.display()) + })?, + ) .map_err(|e| anyhow::anyhow!(e))?; match output.status { @@ -84,6 +91,7 @@ mod tests { expected_command_name: Option, expected_args: Option>, expected_envs: Option>, + expected_cwd: Option, } struct MockCommandExecutor { @@ -98,6 +106,7 @@ mod tests { expected_command_name: None, expected_args: None, expected_envs: None, + expected_cwd: None, }), } } @@ -107,6 +116,7 @@ mod tests { command_name: &str, full_args: &[&str], final_envs: &[(&str, &str)], + cwd: &str, output: Result, ) { let mut config = self.config.borrow_mut(); @@ -118,6 +128,7 @@ mod tests { .map(|&(k, v)| (k.to_string(), v.to_string())) .collect(), ); + config.expected_cwd = Some(cwd.to_string()); config.output_to_return = Some(output); } } @@ -149,6 +160,23 @@ mod tests { "MockCommandExecutor: output_to_return was not set or already consumed for the test", ) } + + fn execute_in_dir( + &self, + command_name: &str, + args: &[&str], + envs: &[(&str, &str)], + cwd: &str, + ) -> Result { + { + let config = self.config.borrow(); + if let Some(expected_cwd) = &config.expected_cwd { + assert_eq!(cwd, expected_cwd, "Mock: Cwd mismatch"); + } + } + + self.execute(command_name, args, envs) + } } fn create_mock_executor_for_success( @@ -165,6 +193,7 @@ mod tests { "bundle", &["info", "--version", gem], &[("BUNDLE_GEMFILE", &gemfile_path)], + dir, Ok(Output { status: Some(0), stdout: version.as_bytes().to_vec(), @@ -198,6 +227,7 @@ mod tests { "bundle", &["info", "--version", gem_name], &[("BUNDLE_GEMFILE", &gemfile_path)], + "test_dir", Ok(Output { status: Some(1), stdout: Vec::new(), @@ -237,6 +267,7 @@ mod tests { "bundle", &["info", "--version", gem_name], &[("BUNDLE_GEMFILE", &gemfile_path)], + "test_dir", Err(specific_error_msg.to_string()), ); diff --git a/src/command_executor.rs b/src/command_executor.rs index e74cb85..83b730f 100644 --- a/src/command_executor.rs +++ b/src/command_executor.rs @@ -22,15 +22,71 @@ pub trait CommandExecutor { args: &[&str], envs: &[(&str, &str)], ) -> zed::Result; + + fn execute_in_dir( + &self, + cmd: &str, + args: &[&str], + envs: &[(&str, &str)], + _cwd: &str, + ) -> zed::Result { + self.execute(cmd, args, envs) + } } /// An implementation of `CommandExecutor` that executes commands /// using the `zed_extension_api::Command`. -#[cfg(feature = "command_api")] #[derive(Clone)] pub struct RealCommandExecutor; -#[cfg(feature = "command_api")] +const MACOS_SHELL_COMMAND: &str = "/bin/sh"; + +const MACOS_SHELL_SCRIPT: &str = "exec \"$0\" \"$@\""; + +const MACOS_SHELL_SCRIPT_IN_DIR: &str = + "cd \"$0\" && command=\"$1\" && shift && exec \"$command\" \"$@\""; + +#[derive(Debug, PartialEq, Eq)] +struct CommandInvocation { + command: String, + args: Vec, +} + +fn command_invocation( + is_macos: bool, + cwd: Option<&str>, + cmd: &str, + args: &[&str], +) -> CommandInvocation { + if !is_macos { + return CommandInvocation { + command: cmd.to_string(), + args: args.iter().map(|arg| arg.to_string()).collect(), + }; + } + + let mut wrapped_args = if let Some(cwd) = cwd { + vec![ + "-c".to_string(), + MACOS_SHELL_SCRIPT_IN_DIR.to_string(), + cwd.to_string(), + cmd.to_string(), + ] + } else { + vec![ + "-c".to_string(), + MACOS_SHELL_SCRIPT.to_string(), + cmd.to_string(), + ] + }; + wrapped_args.extend(args.iter().map(|arg| arg.to_string())); + + CommandInvocation { + command: MACOS_SHELL_COMMAND.to_string(), + args: wrapped_args, + } +} + impl CommandExecutor for RealCommandExecutor { fn execute( &self, @@ -38,9 +94,103 @@ impl CommandExecutor for RealCommandExecutor { args: &[&str], envs: &[(&str, &str)], ) -> zed::Result { - zed::Command::new(cmd) - .args(args.iter().copied()) + let invocation = command_invocation( + matches!(zed::current_platform().0, zed::Os::Mac), + None, + cmd, + args, + ); + + zed::Command::new(invocation.command) + .args(invocation.args.iter().map(String::as_str)) .envs(envs.iter().copied()) .output() } + + fn execute_in_dir( + &self, + cmd: &str, + args: &[&str], + envs: &[(&str, &str)], + cwd: &str, + ) -> zed::Result { + let invocation = command_invocation( + matches!(zed::current_platform().0, zed::Os::Mac), + Some(cwd), + cmd, + args, + ); + + zed::Command::new(invocation.command) + .args(invocation.args.iter().map(String::as_str)) + .envs(envs.iter().copied()) + .output() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wraps_macos_commands_through_shell() { + assert_eq!( + command_invocation(true, None, "ruby", &["--version"]), + CommandInvocation { + command: "/bin/sh".to_string(), + args: vec![ + "-c".to_string(), + "exec \"$0\" \"$@\"".to_string(), + "ruby".to_string(), + "--version".to_string(), + ], + } + ); + } + + #[test] + fn preserves_macos_args_positionally() { + assert_eq!( + command_invocation(true, None, "bundle", &["info", "--version", "ruby-lsp"]), + CommandInvocation { + command: "/bin/sh".to_string(), + args: vec![ + "-c".to_string(), + "exec \"$0\" \"$@\"".to_string(), + "bundle".to_string(), + "info".to_string(), + "--version".to_string(), + "ruby-lsp".to_string(), + ], + } + ); + } + + #[test] + fn keeps_non_macos_commands_direct() { + assert_eq!( + command_invocation(false, None, "gem", &["outdated", "--norc"]), + CommandInvocation { + command: "gem".to_string(), + args: vec!["outdated".to_string(), "--norc".to_string()], + } + ); + } + + #[test] + fn wraps_macos_commands_through_shell_in_dir() { + assert_eq!( + command_invocation(true, Some("/project"), "ruby", &["--version"]), + CommandInvocation { + command: "/bin/sh".to_string(), + args: vec![ + "-c".to_string(), + "cd \"$0\" && command=\"$1\" && shift && exec \"$command\" \"$@\"".to_string(), + "/project".to_string(), + "ruby".to_string(), + "--version".to_string(), + ], + } + ); + } } diff --git a/src/gemset.rs b/src/gemset.rs index e3bd387..2414be7 100644 --- a/src/gemset.rs +++ b/src/gemset.rs @@ -10,11 +10,12 @@ use std::{ pub fn versioned_gem_home( base_dir: &Path, + worktree_root: &str, envs: &[(&str, &str)], executor: &dyn CommandExecutor, ) -> Result { let output = executor - .execute("ruby", &["--version"], envs) + .execute_in_dir("ruby", &["--version"], envs, worktree_root) .map_err(|e| anyhow::anyhow!(e)) .context("Failed to detect Ruby version")?; @@ -34,6 +35,7 @@ pub fn versioned_gem_home( /// A simple wrapper around the `gem` command. pub struct Gemset { gem_home: PathBuf, + worktree_root: String, envs: Vec<(String, String)>, cached_env: OnceLock>, command_executor: Box, @@ -42,11 +44,13 @@ pub struct Gemset { impl Gemset { pub fn new( gem_home: PathBuf, + worktree_root: String, envs: Option<&[(&str, &str)]>, command_executor: Box, ) -> Self { Self { gem_home, + worktree_root, envs: envs.map_or(Vec::new(), |envs| { envs.iter() .map(|&(k, v)| (k.to_string(), v.to_string())) @@ -181,7 +185,7 @@ impl Gemset { let output = self .command_executor - .execute("gem", &full_args, &merged_envs) + .execute_in_dir("gem", &full_args, &merged_envs, &self.worktree_root) .map_err(|e| anyhow!(e))?; match output.status { @@ -210,6 +214,7 @@ mod tests { expected_command_name: Option, expected_args: Option>, expected_envs: Option>, + expected_cwd: Option, output_to_return: Option>, } @@ -224,6 +229,7 @@ mod tests { expected_command_name: None, expected_args: None, expected_envs: None, + expected_cwd: None, output_to_return: None, }), } @@ -245,6 +251,7 @@ mod tests { .map(|&(k, v)| (k.to_string(), v.to_string())) .collect(), ); + config.expected_cwd = Some(TEST_WORKTREE_ROOT.to_string()); config.output_to_return = Some(output); } } @@ -277,13 +284,36 @@ mod tests { .take() .expect("MockCommandExecutor: output_to_return was not set or already consumed") } + + fn execute_in_dir( + &self, + command_name: &str, + args: &[&str], + envs: &[(&str, &str)], + cwd: &str, + ) -> Result { + { + let config = self.config.borrow(); + if let Some(expected_cwd) = &config.expected_cwd { + assert_eq!(cwd, expected_cwd, "Mock: Cwd mismatch"); + } + } + + self.execute(command_name, args, envs) + } } const TEST_GEM_HOME: &str = "/test/gem_home"; const TEST_GEM_PATH: &str = "/test/gem_path"; + const TEST_WORKTREE_ROOT: &str = "/project"; fn create_gemset(envs: Option<&[(&str, &str)]>, mock_executor: MockCommandExecutor) -> Gemset { - Gemset::new(TEST_GEM_HOME.into(), envs, Box::new(mock_executor)) + Gemset::new( + TEST_GEM_HOME.into(), + TEST_WORKTREE_ROOT.to_string(), + envs, + Box::new(mock_executor), + ) } #[test] @@ -302,7 +332,7 @@ mod tests { }), ); - let result = versioned_gem_home(Path::new("/extension"), &[], &executor); + let result = versioned_gem_home(Path::new("/extension"), "/project", &[], &executor); assert!(result.is_ok()); let path = result.expect("should return path"); assert!(path.starts_with("/extension/gems/")); @@ -339,9 +369,9 @@ mod tests { }), ); - let path1 = versioned_gem_home(Path::new("/extension"), &[], &executor1) + let path1 = versioned_gem_home(Path::new("/extension"), "/project", &[], &executor1) .expect("should return path"); - let path2 = versioned_gem_home(Path::new("/extension"), &[], &executor2) + let path2 = versioned_gem_home(Path::new("/extension"), "/project", &[], &executor2) .expect("should return path"); assert_ne!(path1, path2); @@ -375,9 +405,9 @@ mod tests { }), ); - let path1 = versioned_gem_home(Path::new("/extension"), &[], &executor1) + let path1 = versioned_gem_home(Path::new("/extension"), "/project", &[], &executor1) .expect("should return path"); - let path2 = versioned_gem_home(Path::new("/extension"), &[], &executor2) + let path2 = versioned_gem_home(Path::new("/extension"), "/project", &[], &executor2) .expect("should return path"); assert_eq!(path1, path2); @@ -397,7 +427,7 @@ mod tests { }), ); - let result = versioned_gem_home(Path::new("/extension"), &[], &executor); + let result = versioned_gem_home(Path::new("/extension"), "/project", &[], &executor); assert!(result.is_err()); let error_message = format!("{:#}", result.expect_err("should return error")); assert!(error_message.contains("Ruby version check failed with status 127")); @@ -413,7 +443,7 @@ mod tests { Err("Failed to spawn process".to_string()), ); - let result = versioned_gem_home(Path::new("/extension"), &[], &executor); + let result = versioned_gem_home(Path::new("/extension"), "/project", &[], &executor); assert!(result.is_err()); let error_message = format!("{:#}", result.expect_err("should return error")); assert!(error_message.contains("Failed to detect Ruby version")); @@ -423,6 +453,7 @@ mod tests { fn test_gem_bin_path() { let gemset = Gemset::new( TEST_GEM_HOME.into(), + TEST_WORKTREE_ROOT.to_string(), None, Box::new(MockCommandExecutor::new()), ); @@ -439,6 +470,7 @@ mod tests { fn test_gem_env() { let gemset = Gemset::new( TEST_GEM_HOME.into(), + TEST_WORKTREE_ROOT.to_string(), Some(&[("GEM_PATH", TEST_GEM_PATH), ("PATH", "/usr/bin")]), Box::new(MockCommandExecutor::new()), ); @@ -503,6 +535,7 @@ mod tests { ); let gemset = Gemset::new( TEST_GEM_HOME.into(), + TEST_WORKTREE_ROOT.to_string(), Some(&[("CUSTOM_VAR", "custom_value")]), Box::new(mock_executor), ); diff --git a/src/language_servers/language_server.rs b/src/language_servers/language_server.rs index 7ec25a8..e5442f6 100644 --- a/src/language_servers/language_server.rs +++ b/src/language_servers/language_server.rs @@ -1,13 +1,11 @@ #[cfg(test)] use std::collections::HashMap; -#[cfg(feature = "command_api")] use crate::{ bundler::Bundler, command_executor::RealCommandExecutor, gemset::{versioned_gem_home, Gemset}, }; -#[cfg(feature = "command_api")] use std::path::PathBuf; use zed_extension_api::{self as zed}; @@ -32,8 +30,6 @@ pub trait WorktreeLike { fn shell_env(&self) -> Vec<(String, String)>; fn read_text_file(&self, path: &str) -> Result; fn lsp_binary_settings(&self, server_id: &str) -> Result, String>; - #[cfg(any(test, not(feature = "command_api")))] - fn use_bundler(&self, server_id: &str) -> Result, String>; fn which(&self, name: &str) -> Option; } @@ -60,16 +56,6 @@ impl WorktreeLike for zed::Worktree { } } - #[cfg(any(test, not(feature = "command_api")))] - fn use_bundler(&self, server_id: &str) -> Result, String> { - zed::settings::LspSettings::for_worktree(server_id, self).map(|lsp_settings| { - lsp_settings - .settings - .as_ref() - .and_then(|settings| settings["use_bundler"].as_bool()) - }) - } - fn which(&self, name: &str) -> Option { zed::Worktree::which(self, name) } @@ -81,7 +67,6 @@ pub struct FakeWorktree { shell_env: Vec<(String, String)>, files: HashMap>, lsp_binary_settings_map: HashMap, String>>, - use_bundler_map: HashMap, String>>, which_map: HashMap>, } @@ -93,7 +78,6 @@ impl FakeWorktree { shell_env: Vec::new(), files: HashMap::new(), lsp_binary_settings_map: HashMap::new(), - use_bundler_map: HashMap::new(), which_map: HashMap::new(), } } @@ -110,10 +94,6 @@ impl FakeWorktree { self.lsp_binary_settings_map.insert(server_id, settings); } - pub fn set_use_bundler(&mut self, server_id: String, value: Result, String>) { - self.use_bundler_map.insert(server_id, value); - } - pub fn set_which(&mut self, name: String, result: Option) { self.which_map.insert(name, result); } @@ -143,13 +123,6 @@ impl WorktreeLike for FakeWorktree { .unwrap_or(Ok(None)) } - fn use_bundler(&self, server_id: &str) -> Result, String> { - self.use_bundler_map - .get(server_id) - .cloned() - .unwrap_or(Ok(None)) - } - fn which(&self, name: &str) -> Option { self.which_map.get(name).cloned().flatten() } @@ -193,115 +166,48 @@ pub trait LanguageServer { language_server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> zed::Result { - #[cfg(not(feature = "command_api"))] - { - return self.command_free_language_server_binary(language_server_id.as_ref(), worktree); - } + let lsp_settings = + zed::settings::LspSettings::for_worktree(language_server_id.as_ref(), worktree)?; - #[cfg(feature = "command_api")] - { - let lsp_settings = - zed::settings::LspSettings::for_worktree(language_server_id.as_ref(), worktree)?; - - 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()), - }); - } - } - - let use_bundler = lsp_settings - .settings - .as_ref() - .and_then(|settings| settings["use_bundler"].as_bool()) - .unwrap_or_else(Self::default_use_bundler); - - if !use_bundler { - 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 env_vars: Vec<(&str, &str)> = shell_env - .iter() - .map(|(key, value)| (key.as_str(), value.as_str())) - .collect(); - - match bundler.installed_gem_version(Self::GEM_NAME, &env_vars) { - Ok(_version) => { - let bundle_path = worktree - .which("bundle") - .ok_or_else(|| "Unable to find 'bundle' command".to_string())?; - - Ok(LanguageServerBinary { - path: bundle_path, - args: Some( - vec!["exec".into(), Self::EXECUTABLE_NAME.into()] - .into_iter() - .chain(self.get_executable_args(worktree)) - .collect(), - ), - env: Some(shell_env), - }) - } - Err(_e) => self.try_find_on_path_or_extension_gemset(language_server_id, worktree), - } - } - } - - #[cfg(any(test, not(feature = "command_api")))] - fn command_free_language_server_binary( - &self, - server_id: &str, - worktree: &T, - ) -> zed::Result { - if let Some(binary_settings) = worktree.lsp_binary_settings(server_id)? { - if let Some(path) = binary_settings.path { + if let Some(binary_settings) = &lsp_settings.binary { + if let Some(path) = &binary_settings.path { return Ok(LanguageServerBinary { - path, - args: binary_settings.arguments, + path: path.clone(), + args: binary_settings.arguments.clone(), env: Some(worktree.shell_env()), }); } } - let use_bundler = worktree - .use_bundler(server_id)? + let use_bundler = lsp_settings + .settings + .as_ref() + .and_then(|settings| settings["use_bundler"].as_bool()) .unwrap_or_else(Self::default_use_bundler); - if use_bundler { - if let Some(bundle_path) = worktree.which("bundle") { - return Ok(LanguageServerBinary { - path: bundle_path, - args: Some( - vec!["exec".into(), Self::EXECUTABLE_NAME.into()] - .into_iter() - .chain(self.get_executable_args(worktree)) - .collect(), - ), - env: Some(worktree.shell_env()), - }); - } + if !use_bundler { + return self.try_find_on_path_or_extension_gemset(language_server_id, worktree); } - if let Some(path) = worktree.which(Self::EXECUTABLE_NAME) { - return Ok(LanguageServerBinary { - path, - args: Some(self.get_executable_args(worktree)), - env: Some(worktree.shell_env()), - }); - } + let bundler = Bundler::new(PathBuf::from(worktree.root_path()), RealCommandExecutor); + let shell_env = worktree.shell_env(); + let env_vars: Vec<(&str, &str)> = shell_env + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); - Err(format!( - "Unable to find 'bundle' or '{}' command for {server_id}. Install one in the project environment or configure lsp.{server_id}.binary.path.", - Self::EXECUTABLE_NAME - )) + match bundler.installed_gem_version(Self::GEM_NAME, &env_vars) { + Ok(_version) => bundle_exec_binary( + matches!(zed::current_platform().0, zed::Os::Mac), + worktree.which("bundle"), + Self::EXECUTABLE_NAME, + self.get_executable_args(worktree), + shell_env, + ), + Err(_e) => self.try_find_on_path_or_extension_gemset(language_server_id, worktree), + } } - #[cfg(feature = "command_api")] fn try_find_on_path_or_extension_gemset( &self, language_server_id: &zed::LanguageServerId, @@ -318,7 +224,6 @@ pub trait LanguageServer { } } - #[cfg(feature = "command_api")] fn extension_gemset_language_server_binary( &self, language_server_id: &zed::LanguageServerId, @@ -333,12 +238,17 @@ pub trait LanguageServer { .map(|(key, value)| (key.as_str(), value.as_str())) .collect(); - let gem_home = - versioned_gem_home(&base_dir, &worktree_shell_env_vars, &RealCommandExecutor) - .map_err(|e| format!("{:#}", e))?; + let gem_home = versioned_gem_home( + &base_dir, + &worktree.root_path(), + &worktree_shell_env_vars, + &RealCommandExecutor, + ) + .map_err(|e| format!("{:#}", e))?; let gemset = Gemset::new( gem_home, + worktree.root_path(), Some(&worktree_shell_env_vars), Box::new(RealCommandExecutor), ); @@ -347,7 +257,7 @@ pub trait LanguageServer { &zed::LanguageServerInstallationStatus::CheckingForUpdate, ); - match gemset.installed_gem_version(Self::GEM_NAME) { + let executable_path = match gemset.installed_gem_version(Self::GEM_NAME) { Ok(Some(version)) => { if gemset .is_outdated_gem(Self::GEM_NAME) @@ -362,8 +272,6 @@ pub trait LanguageServer { .update_gem(Self::GEM_NAME) .map_err(|e| format!("{:#}", e))?; - // Try to uninstall old version, but don't fail if it errors - // The new version is already installed and working if let Err(e) = gemset.uninstall_gem(Self::GEM_NAME, &version) { eprintln!( "Warning: Failed to uninstall old version {} of {}: {:#}", @@ -374,15 +282,9 @@ pub trait LanguageServer { } } - let executable_path = gemset + gemset .gem_bin_path(Self::EXECUTABLE_NAME) - .map_err(|e| format!("{:#}", e))?; - - Ok(LanguageServerBinary { - path: executable_path, - args: Some(self.get_executable_args(worktree)), - env: Some(gemset.env().to_vec()), - }) + .map_err(|e| format!("{:#}", e))? } Ok(None) => { zed::set_language_server_installation_status( @@ -394,18 +296,92 @@ pub trait LanguageServer { .install_gem(Self::GEM_NAME) .map_err(|e| format!("{:#}", e))?; - let executable_path = gemset + gemset .gem_bin_path(Self::EXECUTABLE_NAME) - .map_err(|e| format!("{:#}", e))?; - - Ok(LanguageServerBinary { - path: executable_path, - args: Some(self.get_executable_args(worktree)), - env: Some(gemset.env().to_vec()), - }) + .map_err(|e| format!("{:#}", e))? } - Err(e) => Err(format!("{:#}", e)), - } + Err(e) => return Err(format!("{:#}", e)), + }; + + gemset_binary( + matches!(zed::current_platform().0, zed::Os::Mac), + worktree.root_path(), + executable_path, + self.get_executable_args(worktree), + gemset.env().to_vec(), + ) + } +} + +fn gemset_binary( + is_macos: bool, + worktree_root: String, + executable_path: String, + extra_args: Vec, + env: Vec<(String, String)>, +) -> zed::Result { + if is_macos { + Ok(LanguageServerBinary { + path: "/bin/sh".to_string(), + args: Some( + vec![ + "-c".to_string(), + "cd \"$0\" && command=\"$1\" && shift && exec \"$command\" \"$@\"".to_string(), + worktree_root, + "ruby".to_string(), + executable_path, + ] + .into_iter() + .chain(extra_args) + .collect(), + ), + env: Some(env), + }) + } else { + Ok(LanguageServerBinary { + path: executable_path, + args: Some(extra_args), + env: Some(env), + }) + } +} + +fn bundle_exec_binary( + is_macos: bool, + bundle_path: Option, + executable_name: &str, + extra_args: Vec, + shell_env: Vec<(String, String)>, +) -> zed::Result { + if is_macos { + Ok(LanguageServerBinary { + path: "/bin/sh".to_string(), + args: Some( + vec![ + "-c".to_string(), + "exec \"$0\" \"$@\"".to_string(), + "bundle".to_string(), + "exec".to_string(), + executable_name.to_string(), + ] + .into_iter() + .chain(extra_args) + .collect(), + ), + env: Some(shell_env), + }) + } else { + let path = bundle_path.ok_or_else(|| "Unable to find 'bundle' command".to_string())?; + Ok(LanguageServerBinary { + path, + args: Some( + vec!["exec".to_string(), executable_name.to_string()] + .into_iter() + .chain(extra_args) + .collect(), + ), + env: Some(shell_env), + }) } } @@ -455,93 +431,122 @@ mod tests { assert_eq!(mock_worktree.shell_env(), Vec::<(String, String)>::new()); } - #[test] - fn test_command_free_uses_bundle_exec_when_use_bundler_enabled() { - let test_server = TestServer::new(); - let mut mock_worktree = FakeWorktree::new("/path/to/project".to_string()); - mock_worktree.set_use_bundler(TestServer::SERVER_ID.to_string(), Ok(Some(true))); - mock_worktree.set_which("bundle".to_string(), Some("/bin/bundle".to_string())); - - let binary = test_server - .command_free_language_server_binary(TestServer::SERVER_ID, &mock_worktree) - .expect("command-free resolver should find bundle"); - - assert_eq!(binary.path, "/bin/bundle"); - assert_eq!( - binary.args, - Some(vec![ - "exec".to_string(), - "test-exe".to_string(), - "--test-arg".to_string() - ]) - ); - } - - #[test] - fn test_command_free_falls_back_to_executable_when_bundle_missing() { - let test_server = TestServer::new(); - let mut mock_worktree = FakeWorktree::new("/path/to/project".to_string()); - mock_worktree.set_use_bundler(TestServer::SERVER_ID.to_string(), Ok(Some(true))); - mock_worktree.set_which("bundle".to_string(), None); - mock_worktree.set_which("test-exe".to_string(), Some("/bin/test-exe".to_string())); - - let binary = test_server - .command_free_language_server_binary(TestServer::SERVER_ID, &mock_worktree) - .expect("command-free resolver should fall back to executable"); + mod gemset_binary_tests { + use super::super::gemset_binary; + + #[test] + fn test_macos_runs_project_ruby_from_worktree_root() { + let result = gemset_binary( + true, + "/project".to_string(), + "/zed/gems/bin/ruby-lsp".to_string(), + vec!["--stdio".to_string()], + vec![("PATH".to_string(), "/project/bin".to_string())], + ) + .unwrap(); + + assert_eq!(result.path, "/bin/sh"); + assert_eq!( + result.args.unwrap(), + vec![ + "-c", + "cd \"$0\" && command=\"$1\" && shift && exec \"$command\" \"$@\"", + "/project", + "ruby", + "/zed/gems/bin/ruby-lsp", + "--stdio", + ] + ); + } - assert_eq!(binary.path, "/bin/test-exe"); - assert_eq!(binary.args, Some(vec!["--test-arg".to_string()])); + #[test] + fn test_non_macos_runs_gemset_binstub_directly() { + let result = gemset_binary( + false, + "/project".to_string(), + "/zed/gems/bin/ruby-lsp".to_string(), + vec!["--stdio".to_string()], + vec![], + ) + .unwrap(); + + assert_eq!(result.path, "/zed/gems/bin/ruby-lsp"); + assert_eq!(result.args.unwrap(), vec!["--stdio"]); + } } - #[test] - fn test_command_free_uses_configured_binary_before_bundler() { - let test_server = TestServer::new(); - let mut mock_worktree = FakeWorktree::new("/path/to/project".to_string()); - mock_worktree.set_use_bundler(TestServer::SERVER_ID.to_string(), Ok(Some(true))); - mock_worktree.add_lsp_binary_setting( - TestServer::SERVER_ID.to_string(), - Ok(Some(super::LspBinarySettings { - path: Some("/custom/test-exe".to_string()), - arguments: Some(vec!["--custom".to_string()]), - })), - ); - - let binary = test_server - .command_free_language_server_binary(TestServer::SERVER_ID, &mock_worktree) - .expect("command-free resolver should use configured binary"); - - assert_eq!(binary.path, "/custom/test-exe"); - assert_eq!(binary.args, Some(vec!["--custom".to_string()])); - } + mod bundle_exec_binary_tests { + use super::super::bundle_exec_binary; - #[test] - fn test_command_free_uses_path_lookup_when_use_bundler_disabled() { - let test_server = TestServer::new(); - let mut mock_worktree = FakeWorktree::new("/path/to/project".to_string()); - mock_worktree.set_use_bundler(TestServer::SERVER_ID.to_string(), Ok(Some(false))); - mock_worktree.set_which("test-exe".to_string(), Some("/bin/test-exe".to_string())); + #[test] + fn test_macos_uses_sh_as_path() { + let result = bundle_exec_binary(true, None, "ruby-lsp", vec![], vec![]).unwrap(); + assert_eq!(result.path, "/bin/sh"); + } - let binary = test_server - .command_free_language_server_binary(TestServer::SERVER_ID, &mock_worktree) - .expect("command-free resolver should find server executable"); + #[test] + fn test_macos_args_shape() { + let result = bundle_exec_binary(true, None, "ruby-lsp", vec![], vec![]).unwrap(); + let args = result.args.unwrap(); + assert_eq!( + args, + vec!["-c", "exec \"$0\" \"$@\"", "bundle", "exec", "ruby-lsp"] + ); + } - assert_eq!(binary.path, "/bin/test-exe"); - assert_eq!(binary.args, Some(vec!["--test-arg".to_string()])); - } + #[test] + fn test_macos_with_extra_args() { + let result = + bundle_exec_binary(true, None, "ruby-lsp", vec!["--stdio".to_string()], vec![]) + .unwrap(); + let args = result.args.unwrap(); + assert_eq!( + args, + vec![ + "-c", + "exec \"$0\" \"$@\"", + "bundle", + "exec", + "ruby-lsp", + "--stdio" + ] + ); + } - #[test] - fn test_command_free_missing_executable_errors() { - let test_server = TestServer::new(); - let mut mock_worktree = FakeWorktree::new("/path/to/project".to_string()); - mock_worktree.set_use_bundler(TestServer::SERVER_ID.to_string(), Ok(Some(true))); - mock_worktree.set_which("bundle".to_string(), None); - mock_worktree.set_which("test-exe".to_string(), None); + #[test] + fn test_non_macos_uses_bundle_path() { + let result = bundle_exec_binary( + false, + Some("/usr/local/bin/bundle".to_string()), + "ruby-lsp", + vec![], + vec![], + ) + .unwrap(); + assert_eq!(result.path, "/usr/local/bin/bundle"); + assert_eq!(result.args.unwrap(), vec!["exec", "ruby-lsp"]); + } - let error = test_server - .command_free_language_server_binary(TestServer::SERVER_ID, &mock_worktree) - .expect_err("command-free resolver should fail when executable is missing"); + #[test] + fn test_non_macos_no_bundle_path_errors() { + let result = bundle_exec_binary(false, None, "ruby-lsp", vec![], vec![]); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Unable to find 'bundle' command")); + } - assert!(error.contains("Unable to find 'bundle' or 'test-exe' command")); - assert!(error.contains("lsp.test-server.binary.path")); + #[test] + fn test_non_macos_with_extra_args() { + let result = bundle_exec_binary( + false, + Some("/usr/local/bin/bundle".to_string()), + "ruby-lsp", + vec!["--stdio".to_string()], + vec![], + ) + .unwrap(); + assert_eq!(result.args.unwrap(), vec!["exec", "ruby-lsp", "--stdio"]); + } } } diff --git a/src/language_servers/ruby_lsp.rs b/src/language_servers/ruby_lsp.rs index 6b733ab..b4d83aa 100644 --- a/src/language_servers/ruby_lsp.rs +++ b/src/language_servers/ruby_lsp.rs @@ -168,4 +168,9 @@ mod tests { vec![] as Vec ); } + + #[test] + fn test_default_use_bundler() { + assert!(RubyLsp::default_use_bundler()); + } } diff --git a/src/ruby.rs b/src/ruby.rs index dffc759..ac27969 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -1,21 +1,10 @@ -#[cfg(feature = "command_api")] mod bundler; -#[cfg(feature = "command_api")] mod command_executor; -#[cfg(feature = "command_api")] mod gemset; mod language_servers; use std::collections::HashMap; -#[cfg(feature = "command_api")] -use std::path::PathBuf; -#[cfg(feature = "command_api")] -use bundler::Bundler; -#[cfg(feature = "command_api")] -use command_executor::RealCommandExecutor; -#[cfg(feature = "command_api")] -use gemset::{versioned_gem_home, Gemset}; use language_servers::{ FuzzyRubyServer, Herb, Kanayago, LanguageServer, Rubocop, RubyLsp, Solargraph, Sorbet, Steep, }; @@ -149,39 +138,6 @@ impl zed::Extension for RubyExtension { _: Option, worktree: &Worktree, ) -> Result { - #[cfg(feature = "command_api")] - let (command, mut arguments) = { - let shell_env = worktree.shell_env(); - let env_vars: Vec<(&str, &str)> = shell_env - .iter() - .map(|(key, value)| (key.as_str(), value.as_str())) - .collect(); - - let bundler = Bundler::new(PathBuf::from(worktree.root_path()), 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() - })?; - (bundle, vec!["exec".to_string(), "rdbg".to_string()]) - } else if let Some(path) = worktree.which(&adapter_name) { - (path, Vec::new()) - } else { - let base_dir = std::env::current_dir() - .map_err(|e| format!("Failed to get extension directory: {e:#}"))?; - let gem_home = versioned_gem_home(&base_dir, &env_vars, &RealCommandExecutor) - .map_err(|e| format!("{:#}", e))?; - let gemset = Gemset::new(gem_home, Some(&env_vars), Box::new(RealCommandExecutor)); - gemset - .install_gem("debug") - .map_err(|e| format!("Failed to install debug gem: {e:#}"))?; - let rdbg = gemset - .gem_bin_path("rdbg") - .map_err(|e| format!("{:#}", e))?; - (rdbg, Vec::new()) - } - }; - - #[cfg(not(feature = "command_api"))] let (command, mut arguments) = if let Some(path) = worktree.which(&adapter_name) { (path, Vec::new()) } else {