Skip to content

Commit 995e504

Browse files
authored
feat(gemset): Install gems per Ruby version to avoid compat issues (#231)
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/<hash>/` instead of the extension root, ensuring each Ruby environment gets its own isolated gem set. Closes #107
1 parent 7ce4e15 commit 995e504

4 files changed

Lines changed: 203 additions & 35 deletions

File tree

extension.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,10 @@ kind = "process:exec"
8080
command = "gem"
8181
args = ["update", "--norc", "*"]
8282

83+
[[capabilities]]
84+
kind = "process:exec"
85+
command = "ruby"
86+
args = ["--version"]
87+
8388
[debug_adapters.rdbg]
8489
[debug_locators.ruby]

src/gemset.rs

Lines changed: 184 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,34 @@
11
use crate::command_executor::CommandExecutor;
22
use regex::Regex;
33
use std::{
4-
path::PathBuf,
4+
collections::hash_map::DefaultHasher,
5+
hash::{Hash, Hasher},
6+
path::{Path, PathBuf},
57
sync::{LazyLock, OnceLock},
68
};
79

10+
pub fn versioned_gem_home(
11+
base_dir: &Path,
12+
envs: &[(&str, &str)],
13+
executor: &dyn CommandExecutor,
14+
) -> Result<PathBuf, String> {
15+
let output = executor
16+
.execute("ruby", &["--version"], envs)
17+
.map_err(|e| format!("Failed to detect Ruby version: {e}"))?;
18+
19+
match output.status {
20+
Some(0) => {
21+
let version_string = String::from_utf8_lossy(&output.stdout);
22+
let mut hasher = DefaultHasher::new();
23+
version_string.trim().hash(&mut hasher);
24+
let version_hash = format!("{:x}", hasher.finish());
25+
Ok(base_dir.join("gems").join(version_hash))
26+
}
27+
Some(status) => Err(format!("Ruby version check failed with status {status}")),
28+
None => Err("Failed to execute ruby --version".to_string()),
29+
}
30+
}
31+
832
/// A simple wrapper around the `gem` command.
933
pub struct Gemset {
1034
gem_home: PathBuf,
@@ -176,6 +200,7 @@ mod tests {
176200
use super::*;
177201
use crate::command_executor::CommandExecutor;
178202
use std::cell::RefCell;
203+
use std::path::Path;
179204
use zed_extension_api::process::Output;
180205

181206
struct MockExecutorConfig {
@@ -185,13 +210,13 @@ mod tests {
185210
output_to_return: Option<Result<Output, String>>,
186211
}
187212

188-
struct MockGemCommandExecutor {
213+
struct MockCommandExecutor {
189214
config: RefCell<MockExecutorConfig>,
190215
}
191216

192-
impl MockGemCommandExecutor {
217+
impl MockCommandExecutor {
193218
fn new() -> Self {
194-
MockGemCommandExecutor {
219+
MockCommandExecutor {
195220
config: RefCell::new(MockExecutorConfig {
196221
expected_command_name: None,
197222
expected_args: None,
@@ -221,7 +246,7 @@ mod tests {
221246
}
222247
}
223248

224-
impl CommandExecutor for MockGemCommandExecutor {
249+
impl CommandExecutor for MockCommandExecutor {
225250
fn execute(
226251
&self,
227252
command_name: &str,
@@ -247,26 +272,158 @@ mod tests {
247272
config
248273
.output_to_return
249274
.take()
250-
.expect("MockGemCommandExecutor: output_to_return was not set or already consumed")
275+
.expect("MockCommandExecutor: output_to_return was not set or already consumed")
251276
}
252277
}
253278

254279
const TEST_GEM_HOME: &str = "/test/gem_home";
255280
const TEST_GEM_PATH: &str = "/test/gem_path";
256281

257-
fn create_gemset(
258-
envs: Option<&[(&str, &str)]>,
259-
mock_executor: MockGemCommandExecutor,
260-
) -> Gemset {
282+
fn create_gemset(envs: Option<&[(&str, &str)]>, mock_executor: MockCommandExecutor) -> Gemset {
261283
Gemset::new(TEST_GEM_HOME.into(), envs, Box::new(mock_executor))
262284
}
263285

286+
#[test]
287+
fn test_versioned_gem_home_success() {
288+
let executor = MockCommandExecutor::new();
289+
executor.expect(
290+
"ruby",
291+
&["--version"],
292+
&[],
293+
Ok(Output {
294+
status: Some(0),
295+
stdout: "ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23]\n"
296+
.as_bytes()
297+
.to_vec(),
298+
stderr: Vec::new(),
299+
}),
300+
);
301+
302+
let result = versioned_gem_home(Path::new("/extension"), &[], &executor);
303+
assert!(result.is_ok());
304+
let path = result.expect("should return path");
305+
assert!(path.starts_with("/extension/gems/"));
306+
assert_eq!(path.components().count(), 4);
307+
}
308+
309+
#[test]
310+
fn test_versioned_gem_home_different_versions_produce_different_hashes() {
311+
let executor1 = MockCommandExecutor::new();
312+
executor1.expect(
313+
"ruby",
314+
&["--version"],
315+
&[],
316+
Ok(Output {
317+
status: Some(0),
318+
stdout: "ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23]\n"
319+
.as_bytes()
320+
.to_vec(),
321+
stderr: Vec::new(),
322+
}),
323+
);
324+
325+
let executor2 = MockCommandExecutor::new();
326+
executor2.expect(
327+
"ruby",
328+
&["--version"],
329+
&[],
330+
Ok(Output {
331+
status: Some(0),
332+
stdout: "ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin23]\n"
333+
.as_bytes()
334+
.to_vec(),
335+
stderr: Vec::new(),
336+
}),
337+
);
338+
339+
let path1 = versioned_gem_home(Path::new("/extension"), &[], &executor1)
340+
.expect("should return path");
341+
let path2 = versioned_gem_home(Path::new("/extension"), &[], &executor2)
342+
.expect("should return path");
343+
344+
assert_ne!(path1, path2);
345+
}
346+
347+
#[test]
348+
fn test_versioned_gem_home_same_version_produces_same_hash() {
349+
let version_output = "ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23]\n";
350+
351+
let executor1 = MockCommandExecutor::new();
352+
executor1.expect(
353+
"ruby",
354+
&["--version"],
355+
&[],
356+
Ok(Output {
357+
status: Some(0),
358+
stdout: version_output.as_bytes().to_vec(),
359+
stderr: Vec::new(),
360+
}),
361+
);
362+
363+
let executor2 = MockCommandExecutor::new();
364+
executor2.expect(
365+
"ruby",
366+
&["--version"],
367+
&[],
368+
Ok(Output {
369+
status: Some(0),
370+
stdout: version_output.as_bytes().to_vec(),
371+
stderr: Vec::new(),
372+
}),
373+
);
374+
375+
let path1 = versioned_gem_home(Path::new("/extension"), &[], &executor1)
376+
.expect("should return path");
377+
let path2 = versioned_gem_home(Path::new("/extension"), &[], &executor2)
378+
.expect("should return path");
379+
380+
assert_eq!(path1, path2);
381+
}
382+
383+
#[test]
384+
fn test_versioned_gem_home_command_failure() {
385+
let executor = MockCommandExecutor::new();
386+
executor.expect(
387+
"ruby",
388+
&["--version"],
389+
&[],
390+
Ok(Output {
391+
status: Some(127),
392+
stdout: Vec::new(),
393+
stderr: "ruby: command not found".as_bytes().to_vec(),
394+
}),
395+
);
396+
397+
let result = versioned_gem_home(Path::new("/extension"), &[], &executor);
398+
assert!(result.is_err());
399+
assert!(result
400+
.expect_err("should return error")
401+
.contains("Ruby version check failed with status 127"));
402+
}
403+
404+
#[test]
405+
fn test_versioned_gem_home_execution_error() {
406+
let executor = MockCommandExecutor::new();
407+
executor.expect(
408+
"ruby",
409+
&["--version"],
410+
&[],
411+
Err("Failed to spawn process".to_string()),
412+
);
413+
414+
let result = versioned_gem_home(Path::new("/extension"), &[], &executor);
415+
assert!(result.is_err());
416+
assert!(result
417+
.expect_err("should return error")
418+
.contains("Failed to detect Ruby version"));
419+
}
420+
264421
#[test]
265422
fn test_gem_bin_path() {
266423
let gemset = Gemset::new(
267424
TEST_GEM_HOME.into(),
268425
None,
269-
Box::new(MockGemCommandExecutor::new()),
426+
Box::new(MockCommandExecutor::new()),
270427
);
271428
let path = gemset.gem_bin_path("ruby-lsp").unwrap();
272429
assert_eq!(path, "/test/gem_home/bin/ruby-lsp");
@@ -277,7 +434,7 @@ mod tests {
277434
let gemset = Gemset::new(
278435
TEST_GEM_HOME.into(),
279436
Some(&[("GEM_PATH", TEST_GEM_PATH), ("PATH", "/usr/bin")]),
280-
Box::new(MockGemCommandExecutor::new()),
437+
Box::new(MockCommandExecutor::new()),
281438
);
282439
let env: std::collections::HashMap<String, String> = gemset.env().iter().cloned().collect();
283440

@@ -291,7 +448,7 @@ mod tests {
291448

292449
#[test]
293450
fn test_install_gem_success() {
294-
let mock_executor = MockGemCommandExecutor::new();
451+
let mock_executor = MockCommandExecutor::new();
295452
let gem_name = "ruby-lsp";
296453
mock_executor.expect(
297454
"gem",
@@ -316,7 +473,7 @@ mod tests {
316473

317474
#[test]
318475
fn test_install_gem_with_custom_env() {
319-
let mock_executor = MockGemCommandExecutor::new();
476+
let mock_executor = MockCommandExecutor::new();
320477
let gem_name = "ruby-lsp";
321478
mock_executor.expect(
322479
"gem",
@@ -345,7 +502,7 @@ mod tests {
345502

346503
#[test]
347504
fn test_install_gem_failure() {
348-
let mock_executor = MockGemCommandExecutor::new();
505+
let mock_executor = MockCommandExecutor::new();
349506
let gem_name = "ruby-lsp";
350507
mock_executor.expect(
351508
"gem",
@@ -374,7 +531,7 @@ mod tests {
374531

375532
#[test]
376533
fn test_update_gem_success() {
377-
let mock_executor = MockGemCommandExecutor::new();
534+
let mock_executor = MockCommandExecutor::new();
378535
let gem_name = "ruby-lsp";
379536
mock_executor.expect(
380537
"gem",
@@ -392,7 +549,7 @@ mod tests {
392549

393550
#[test]
394551
fn test_update_gem_failure() {
395-
let mock_executor = MockGemCommandExecutor::new();
552+
let mock_executor = MockCommandExecutor::new();
396553
let gem_name = "ruby-lsp";
397554
mock_executor.expect(
398555
"gem",
@@ -414,7 +571,7 @@ mod tests {
414571

415572
#[test]
416573
fn test_installed_gem_version_found() {
417-
let mock_executor = MockGemCommandExecutor::new();
574+
let mock_executor = MockCommandExecutor::new();
418575
let gem_name = "ruby-lsp";
419576
let expected_version = "1.2.3";
420577
let gem_list_output = format!(
@@ -439,7 +596,7 @@ mod tests {
439596

440597
#[test]
441598
fn test_installed_gem_version_found_with_default() {
442-
let mock_executor = MockGemCommandExecutor::new();
599+
let mock_executor = MockCommandExecutor::new();
443600
let gem_name = "prism";
444601
let version_in_output = "default: 1.2.0";
445602
let gem_list_output = format!(
@@ -464,7 +621,7 @@ mod tests {
464621

465622
#[test]
466623
fn test_installed_gem_version_not_found() {
467-
let mock_executor = MockGemCommandExecutor::new();
624+
let mock_executor = MockCommandExecutor::new();
468625
let gem_name = "non_existent_gem";
469626
let gem_list_output = "other_gem (1.0.0)\nanother_gem (2.0.0)";
470627

@@ -485,7 +642,7 @@ mod tests {
485642

486643
#[test]
487644
fn test_installed_gem_version_command_failure() {
488-
let mock_executor = MockGemCommandExecutor::new();
645+
let mock_executor = MockCommandExecutor::new();
489646
let gem_name = "ruby-lsp";
490647
mock_executor.expect(
491648
"gem",
@@ -507,7 +664,7 @@ mod tests {
507664

508665
#[test]
509666
fn test_is_outdated_gem_true() {
510-
let mock_executor = MockGemCommandExecutor::new();
667+
let mock_executor = MockCommandExecutor::new();
511668
let gem_name = "ruby-lsp";
512669
let outdated_output = format!(
513670
"{} (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 {
531688

532689
#[test]
533690
fn test_is_outdated_gem_false() {
534-
let mock_executor = MockGemCommandExecutor::new();
691+
let mock_executor = MockCommandExecutor::new();
535692
let gem_name = "ruby-lsp";
536693
let outdated_output = "csv (3.3.2 < 3.3.4)";
537694

@@ -552,7 +709,7 @@ mod tests {
552709

553710
#[test]
554711
fn test_is_outdated_gem_command_failure() {
555-
let mock_executor = MockGemCommandExecutor::new();
712+
let mock_executor = MockCommandExecutor::new();
556713
let gem_name = "ruby-lsp";
557714
mock_executor.expect(
558715
"gem",
@@ -574,7 +731,7 @@ mod tests {
574731

575732
#[test]
576733
fn test_uninstall_gem_success() {
577-
let mock_executor = MockGemCommandExecutor::new();
734+
let mock_executor = MockCommandExecutor::new();
578735
let gem_name = "solargraph";
579736
let gem_version = "0.55.1";
580737

@@ -596,7 +753,7 @@ mod tests {
596753

597754
#[test]
598755
fn test_uninstall_gem_failure() {
599-
let mock_executor = MockGemCommandExecutor::new();
756+
let mock_executor = MockCommandExecutor::new();
600757
let gem_name = "solargraph";
601758
let gem_version = "0.55.1";
602759

@@ -622,7 +779,7 @@ mod tests {
622779

623780
#[test]
624781
fn test_uninstall_gem_command_execution_error() {
625-
let mock_executor = MockGemCommandExecutor::new();
782+
let mock_executor = MockCommandExecutor::new();
626783
let gem_name = "solargraph";
627784
let gem_version = "0.55.1";
628785

0 commit comments

Comments
 (0)