From 7555e92efa4197ffcf27047d9fdb663e710026a5 Mon Sep 17 00:00:00 2001 From: Vitaly Slobodin Date: Fri, 9 Jan 2026 22:30:02 +0100 Subject: [PATCH] feat(gemset): Install gems per Ruby version to avoid compat issues When users switch Ruby versions (e.g., from 3.2 to 3.3), previously installed gems with native extensions can break due to ABI incompatibilities or something else. This also affects the same Ruby version compiled with different configurations or on different platforms. This change introduces version-specific gem directories by hashing the output of `ruby --version`, which includes version, revision, and platform info. Gems are now installed to `gems//` instead of the extension root, ensuring each Ruby environment gets its own isolated gem set. Closes https://github.com/zed-extensions/ruby/issues/107 --- extension.toml | 5 + src/gemset.rs | 211 +++++++++++++++++++++--- src/language_servers/language_server.rs | 17 +- src/ruby.rs | 5 +- 4 files changed, 203 insertions(+), 35 deletions(-) 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")