diff --git a/extension.toml b/extension.toml index e120ced..f27675f 100644 --- a/extension.toml +++ b/extension.toml @@ -80,5 +80,10 @@ kind = "process:exec" command = "gem" args = ["update", "--norc", "*"] +[[capabilities]] +kind = "process:exec" +command = "ruby" +args = ["--version"] + [debug_adapters.rdbg] [debug_locators.ruby] diff --git a/src/gemset.rs b/src/gemset.rs index ac4c4d2..4aa59c4 100644 --- a/src/gemset.rs +++ b/src/gemset.rs @@ -1,10 +1,34 @@ use crate::command_executor::CommandExecutor; use regex::Regex; use std::{ - path::PathBuf, + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + path::{Path, PathBuf}, sync::{LazyLock, OnceLock}, }; +pub fn versioned_gem_home( + base_dir: &Path, + envs: &[(&str, &str)], + executor: &dyn CommandExecutor, +) -> Result { + let output = executor + .execute("ruby", &["--version"], envs) + .map_err(|e| format!("Failed to detect Ruby version: {e}"))?; + + match output.status { + Some(0) => { + let version_string = String::from_utf8_lossy(&output.stdout); + let mut hasher = DefaultHasher::new(); + version_string.trim().hash(&mut hasher); + let version_hash = format!("{:x}", hasher.finish()); + Ok(base_dir.join("gems").join(version_hash)) + } + Some(status) => Err(format!("Ruby version check failed with status {status}")), + None => Err("Failed to execute ruby --version".to_string()), + } +} + /// A simple wrapper around the `gem` command. pub struct Gemset { gem_home: PathBuf, @@ -176,6 +200,7 @@ mod tests { use super::*; use crate::command_executor::CommandExecutor; use std::cell::RefCell; + use std::path::Path; use zed_extension_api::process::Output; struct MockExecutorConfig { @@ -185,13 +210,13 @@ mod tests { output_to_return: Option>, } - struct MockGemCommandExecutor { + struct MockCommandExecutor { config: RefCell, } - impl MockGemCommandExecutor { + impl MockCommandExecutor { fn new() -> Self { - MockGemCommandExecutor { + MockCommandExecutor { config: RefCell::new(MockExecutorConfig { expected_command_name: None, expected_args: None, @@ -221,7 +246,7 @@ mod tests { } } - impl CommandExecutor for MockGemCommandExecutor { + impl CommandExecutor for MockCommandExecutor { fn execute( &self, command_name: &str, @@ -247,26 +272,158 @@ mod tests { config .output_to_return .take() - .expect("MockGemCommandExecutor: output_to_return was not set or already consumed") + .expect("MockCommandExecutor: output_to_return was not set or already consumed") } } const TEST_GEM_HOME: &str = "/test/gem_home"; const TEST_GEM_PATH: &str = "/test/gem_path"; - fn create_gemset( - envs: Option<&[(&str, &str)]>, - mock_executor: MockGemCommandExecutor, - ) -> Gemset { + fn create_gemset(envs: Option<&[(&str, &str)]>, mock_executor: MockCommandExecutor) -> Gemset { Gemset::new(TEST_GEM_HOME.into(), envs, Box::new(mock_executor)) } + #[test] + fn test_versioned_gem_home_success() { + let executor = MockCommandExecutor::new(); + executor.expect( + "ruby", + &["--version"], + &[], + Ok(Output { + status: Some(0), + stdout: "ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23]\n" + .as_bytes() + .to_vec(), + stderr: Vec::new(), + }), + ); + + let result = versioned_gem_home(Path::new("/extension"), &[], &executor); + assert!(result.is_ok()); + let path = result.expect("should return path"); + assert!(path.starts_with("/extension/gems/")); + assert_eq!(path.components().count(), 4); + } + + #[test] + fn test_versioned_gem_home_different_versions_produce_different_hashes() { + let executor1 = MockCommandExecutor::new(); + executor1.expect( + "ruby", + &["--version"], + &[], + Ok(Output { + status: Some(0), + stdout: "ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23]\n" + .as_bytes() + .to_vec(), + stderr: Vec::new(), + }), + ); + + let executor2 = MockCommandExecutor::new(); + executor2.expect( + "ruby", + &["--version"], + &[], + Ok(Output { + status: Some(0), + stdout: "ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin23]\n" + .as_bytes() + .to_vec(), + stderr: Vec::new(), + }), + ); + + let path1 = versioned_gem_home(Path::new("/extension"), &[], &executor1) + .expect("should return path"); + let path2 = versioned_gem_home(Path::new("/extension"), &[], &executor2) + .expect("should return path"); + + assert_ne!(path1, path2); + } + + #[test] + fn test_versioned_gem_home_same_version_produces_same_hash() { + let version_output = "ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23]\n"; + + let executor1 = MockCommandExecutor::new(); + executor1.expect( + "ruby", + &["--version"], + &[], + Ok(Output { + status: Some(0), + stdout: version_output.as_bytes().to_vec(), + stderr: Vec::new(), + }), + ); + + let executor2 = MockCommandExecutor::new(); + executor2.expect( + "ruby", + &["--version"], + &[], + Ok(Output { + status: Some(0), + stdout: version_output.as_bytes().to_vec(), + stderr: Vec::new(), + }), + ); + + let path1 = versioned_gem_home(Path::new("/extension"), &[], &executor1) + .expect("should return path"); + let path2 = versioned_gem_home(Path::new("/extension"), &[], &executor2) + .expect("should return path"); + + assert_eq!(path1, path2); + } + + #[test] + fn test_versioned_gem_home_command_failure() { + let executor = MockCommandExecutor::new(); + executor.expect( + "ruby", + &["--version"], + &[], + Ok(Output { + status: Some(127), + stdout: Vec::new(), + stderr: "ruby: command not found".as_bytes().to_vec(), + }), + ); + + let result = versioned_gem_home(Path::new("/extension"), &[], &executor); + assert!(result.is_err()); + assert!(result + .expect_err("should return error") + .contains("Ruby version check failed with status 127")); + } + + #[test] + fn test_versioned_gem_home_execution_error() { + let executor = MockCommandExecutor::new(); + executor.expect( + "ruby", + &["--version"], + &[], + Err("Failed to spawn process".to_string()), + ); + + let result = versioned_gem_home(Path::new("/extension"), &[], &executor); + assert!(result.is_err()); + assert!(result + .expect_err("should return error") + .contains("Failed to detect Ruby version")); + } + #[test] fn test_gem_bin_path() { let gemset = Gemset::new( TEST_GEM_HOME.into(), None, - Box::new(MockGemCommandExecutor::new()), + Box::new(MockCommandExecutor::new()), ); let path = gemset.gem_bin_path("ruby-lsp").unwrap(); assert_eq!(path, "/test/gem_home/bin/ruby-lsp"); @@ -277,7 +434,7 @@ mod tests { let gemset = Gemset::new( TEST_GEM_HOME.into(), Some(&[("GEM_PATH", TEST_GEM_PATH), ("PATH", "/usr/bin")]), - Box::new(MockGemCommandExecutor::new()), + Box::new(MockCommandExecutor::new()), ); let env: std::collections::HashMap = gemset.env().iter().cloned().collect(); @@ -291,7 +448,7 @@ mod tests { #[test] fn test_install_gem_success() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "ruby-lsp"; mock_executor.expect( "gem", @@ -316,7 +473,7 @@ mod tests { #[test] fn test_install_gem_with_custom_env() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "ruby-lsp"; mock_executor.expect( "gem", @@ -345,7 +502,7 @@ mod tests { #[test] fn test_install_gem_failure() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "ruby-lsp"; mock_executor.expect( "gem", @@ -374,7 +531,7 @@ mod tests { #[test] fn test_update_gem_success() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "ruby-lsp"; mock_executor.expect( "gem", @@ -392,7 +549,7 @@ mod tests { #[test] fn test_update_gem_failure() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "ruby-lsp"; mock_executor.expect( "gem", @@ -414,7 +571,7 @@ mod tests { #[test] fn test_installed_gem_version_found() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "ruby-lsp"; let expected_version = "1.2.3"; let gem_list_output = format!( @@ -439,7 +596,7 @@ mod tests { #[test] fn test_installed_gem_version_found_with_default() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "prism"; let version_in_output = "default: 1.2.0"; let gem_list_output = format!( @@ -464,7 +621,7 @@ mod tests { #[test] fn test_installed_gem_version_not_found() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "non_existent_gem"; let gem_list_output = "other_gem (1.0.0)\nanother_gem (2.0.0)"; @@ -485,7 +642,7 @@ mod tests { #[test] fn test_installed_gem_version_command_failure() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "ruby-lsp"; mock_executor.expect( "gem", @@ -507,7 +664,7 @@ mod tests { #[test] fn test_is_outdated_gem_true() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "ruby-lsp"; let outdated_output = format!( "{} (3.3.2 < 3.3.4)\n{} (2.9.1 < 2.11.3)\n{} (0.5.6 < 0.5.8)", @@ -531,7 +688,7 @@ mod tests { #[test] fn test_is_outdated_gem_false() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "ruby-lsp"; let outdated_output = "csv (3.3.2 < 3.3.4)"; @@ -552,7 +709,7 @@ mod tests { #[test] fn test_is_outdated_gem_command_failure() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "ruby-lsp"; mock_executor.expect( "gem", @@ -574,7 +731,7 @@ mod tests { #[test] fn test_uninstall_gem_success() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "solargraph"; let gem_version = "0.55.1"; @@ -596,7 +753,7 @@ mod tests { #[test] fn test_uninstall_gem_failure() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "solargraph"; let gem_version = "0.55.1"; @@ -622,7 +779,7 @@ mod tests { #[test] fn test_uninstall_gem_command_execution_error() { - let mock_executor = MockGemCommandExecutor::new(); + let mock_executor = MockCommandExecutor::new(); let gem_name = "solargraph"; let gem_version = "0.55.1"; diff --git a/src/language_servers/language_server.rs b/src/language_servers/language_server.rs index 251615a..d529737 100644 --- a/src/language_servers/language_server.rs +++ b/src/language_servers/language_server.rs @@ -1,7 +1,11 @@ #[cfg(test)] use std::collections::HashMap; -use crate::{bundler::Bundler, command_executor::RealCommandExecutor, gemset::Gemset}; +use crate::{ + bundler::Bundler, + command_executor::RealCommandExecutor, + gemset::{versioned_gem_home, Gemset}, +}; use std::path::PathBuf; use zed_extension_api::{self as zed}; @@ -214,10 +218,8 @@ pub trait LanguageServer { language_server_id: &zed::LanguageServerId, worktree: &zed::Worktree, ) -> zed::Result { - let gem_home = std::env::current_dir() - .map_err(|e| format!("Failed to get extension directory: {e}"))? - .to_string_lossy() - .to_string(); + 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_shell_env_vars: Vec<(&str, &str)> = worktree_shell_env @@ -225,8 +227,11 @@ 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)?; + let gemset = Gemset::new( - PathBuf::from(&gem_home), + gem_home, Some(&worktree_shell_env_vars), Box::new(RealCommandExecutor), ); diff --git a/src/ruby.rs b/src/ruby.rs index e1b1a70..9a77886 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -7,7 +7,7 @@ use std::{collections::HashMap, path::PathBuf}; use bundler::Bundler; use command_executor::RealCommandExecutor; -use gemset::Gemset; +use gemset::{versioned_gem_home, Gemset}; use language_servers::{Herb, LanguageServer, Rubocop, RubyLsp, Solargraph, Sorbet, Steep}; use serde::{Deserialize, Serialize}; use zed_extension_api::{ @@ -143,8 +143,9 @@ impl zed::Extension for RubyExtension { } else if let Some(path) = worktree.which(&adapter_name) { (path, Vec::new()) } else { - let gem_home = std::env::current_dir() + 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)?; let gemset = Gemset::new(gem_home, Some(&env_vars), Box::new(RealCommandExecutor)); gemset .install_gem("debug")