diff --git a/Cargo.toml b/Cargo.toml index 69396b8..e636bb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,10 @@ 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 38a1209..cec4818 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ # Ruby extension for Zed [Documentation](https://zed.dev/docs/languages/ruby) + +## Command-free LSP build + +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. + +```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. diff --git a/src/command_executor.rs b/src/command_executor.rs index 8bcad66..e74cb85 100644 --- a/src/command_executor.rs +++ b/src/command_executor.rs @@ -26,9 +26,11 @@ pub trait CommandExecutor { /// 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")] impl CommandExecutor for RealCommandExecutor { fn execute( &self, diff --git a/src/language_servers/kanayago.rs b/src/language_servers/kanayago.rs index 4f47d7a..877ba91 100644 --- a/src/language_servers/kanayago.rs +++ b/src/language_servers/kanayago.rs @@ -7,6 +7,10 @@ impl LanguageServer for Kanayago { const EXECUTABLE_NAME: &str = "kanayago"; const GEM_NAME: &str = "kanayago"; + fn default_use_bundler() -> bool { + false + } + fn get_executable_args(&self, _worktree: &T) -> Vec { vec!["--lsp".to_string()] } @@ -39,4 +43,9 @@ mod tests { assert_eq!(kanayago.get_executable_args(&mock_worktree), vec!["--lsp"]); } + + #[test] + fn test_default_use_bundler() { + assert!(!Kanayago::default_use_bundler()); + } } diff --git a/src/language_servers/language_server.rs b/src/language_servers/language_server.rs index 8542ebc..7ec25a8 100644 --- a/src/language_servers/language_server.rs +++ b/src/language_servers/language_server.rs @@ -1,11 +1,13 @@ #[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}; @@ -30,6 +32,8 @@ 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; } @@ -56,6 +60,16 @@ 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) } @@ -67,6 +81,7 @@ pub struct FakeWorktree { shell_env: Vec<(String, String)>, files: HashMap>, lsp_binary_settings_map: HashMap, String>>, + use_bundler_map: HashMap, String>>, which_map: HashMap>, } @@ -78,6 +93,7 @@ impl FakeWorktree { shell_env: Vec::new(), files: HashMap::new(), lsp_binary_settings_map: HashMap::new(), + use_bundler_map: HashMap::new(), which_map: HashMap::new(), } } @@ -94,6 +110,10 @@ 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); } @@ -123,6 +143,13 @@ 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() } @@ -131,8 +158,13 @@ impl WorktreeLike for FakeWorktree { pub trait LanguageServer { const SERVER_ID: &str; const EXECUTABLE_NAME: &str; + #[allow(dead_code)] const GEM_NAME: &str; + fn default_use_bundler() -> bool { + true + } + fn get_executable_args(&self, _worktree: &T) -> Vec { Vec::new() } @@ -161,43 +193,88 @@ pub trait LanguageServer { language_server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> zed::Result { - let lsp_settings = - zed::settings::LspSettings::for_worktree(language_server_id.as_ref(), worktree)?; + #[cfg(not(feature = "command_api"))] + { + return self.command_free_language_server_binary(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()), - }); + #[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(true); + 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); + } - 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), + } } + } - 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(); + #[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 { + return Ok(LanguageServerBinary { + path, + args: binary_settings.arguments, + env: Some(worktree.shell_env()), + }); + } + } - 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())?; + let use_bundler = worktree + .use_bundler(server_id)? + .unwrap_or_else(Self::default_use_bundler); - Ok(LanguageServerBinary { + 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()] @@ -205,13 +282,26 @@ pub trait LanguageServer { .chain(self.get_executable_args(worktree)) .collect(), ), - env: Some(shell_env), - }) + env: Some(worktree.shell_env()), + }); } - Err(_e) => 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()), + }); + } + + 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 + )) } + #[cfg(feature = "command_api")] fn try_find_on_path_or_extension_gemset( &self, language_server_id: &zed::LanguageServerId, @@ -228,6 +318,7 @@ pub trait LanguageServer { } } + #[cfg(feature = "command_api")] fn extension_gemset_language_server_binary( &self, language_server_id: &zed::LanguageServerId, @@ -363,4 +454,94 @@ mod tests { let mock_worktree = FakeWorktree::new("/path/to/project".to_string()); 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"); + + assert_eq!(binary.path, "/bin/test-exe"); + assert_eq!(binary.args, Some(vec!["--test-arg".to_string()])); + } + + #[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()])); + } + + #[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())); + + let binary = test_server + .command_free_language_server_binary(TestServer::SERVER_ID, &mock_worktree) + .expect("command-free resolver should find server executable"); + + assert_eq!(binary.path, "/bin/test-exe"); + assert_eq!(binary.args, Some(vec!["--test-arg".to_string()])); + } + + #[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); + + 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"); + + assert!(error.contains("Unable to find 'bundle' or 'test-exe' command")); + assert!(error.contains("lsp.test-server.binary.path")); + } } diff --git a/src/language_servers/sorbet.rs b/src/language_servers/sorbet.rs index a6c7cdc..25951ac 100644 --- a/src/language_servers/sorbet.rs +++ b/src/language_servers/sorbet.rs @@ -63,6 +63,11 @@ mod tests { assert_eq!(Sorbet::EXECUTABLE_NAME, "srb"); } + #[test] + fn test_default_use_bundler() { + assert!(Sorbet::default_use_bundler()); + } + #[test] fn test_executable_args_no_config_file() { let sorbet = Sorbet::new(); diff --git a/src/language_servers/steep.rs b/src/language_servers/steep.rs index c163c19..abe120d 100644 --- a/src/language_servers/steep.rs +++ b/src/language_servers/steep.rs @@ -65,6 +65,11 @@ mod tests { assert_eq!(Steep::EXECUTABLE_NAME, "steep"); } + #[test] + fn test_default_use_bundler() { + assert!(Steep::default_use_bundler()); + } + #[test] fn test_executable_args() { let steep = Steep::new(); diff --git a/src/ruby.rs b/src/ruby.rs index 4cd034f..dffc759 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -1,12 +1,20 @@ +#[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, path::PathBuf}; +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, @@ -141,13 +149,14 @@ impl zed::Extension for RubyExtension { _: Option, worktree: &Worktree, ) -> Result { - 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(); - + #[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(|| { @@ -172,6 +181,15 @@ impl zed::Extension for RubyExtension { } }; + #[cfg(not(feature = "command_api"))] + let (command, mut arguments) = if let Some(path) = worktree.which(&adapter_name) { + (path, Vec::new()) + } else { + return Err(format!( + "Unable to find '{adapter_name}' command in the project environment" + )); + }; + let tcp_connection = config.tcp_connection.unwrap_or(TcpArgumentsTemplate { port: None, host: None,