Files
openidea/crates/ide-sdk/src/ruby.rs
T
Kerem Bozdas 75607c0bbf feat(debugger): debug Ruby programs and tests with rdbg (#2173)
Ruby files, `rails server`, Rake tasks, RSpec and Minitest configurations
can be debugged with rdbg, using the project's Ruby. In a Bundler project
it runs through the bundle; if the bundle lacks the `debug` gem, debugging
stops with an error asking to add `gem "debug"` to the Gemfile.
2026-11-09 01:41:27 +08:00

302 lines
13 KiB
Rust

//! Ruby interpreters: the ones a version manager installed (rbenv, rvm, chruby,
//! mise, asdf), Homebrew's, and the ones on `PATH`; the project's `.ruby-version`;
//! and the interpreter's standard library and Bundler gems for External Libraries.
use crate::discover::{par_map, subdirs};
use crate::exec::{executables_in, query, query_in, search_path};
use crate::{Sdk, exe, home_dir, pinned};
use ide_core::version_managers::{self, Tool};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::Duration;
const VERSION_TIMEOUT: Duration = Duration::from_secs(4);
const QUERY_TIMEOUT: Duration = Duration::from_secs(20);
fn var_path(key: &str) -> Option<PathBuf> {
ide_core::process::host_var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from)
}
/// Where version managers keep one directory per Ruby, with the manager's name.
fn manager_roots() -> Vec<(PathBuf, &'static str)> {
let mut out = Vec::new();
let Some(home) = home_dir() else { return out };
out.push((var_path("RBENV_ROOT").unwrap_or_else(|| home.join(".rbenv")).join("versions"), "rbenv"));
out.push((var_path("rvm_path").unwrap_or_else(|| home.join(".rvm")).join("rubies"), "rvm"));
out.push((PathBuf::from("/usr/local/rvm/rubies"), "rvm"));
out.push((home.join(".rubies"), "chruby"));
out.push((PathBuf::from("/opt/rubies"), "chruby"));
out
}
/// `bin/ruby` of a Ruby install directory.
pub(crate) fn ruby_in_install(dir: &Path) -> Option<PathBuf> {
let p = dir.join("bin").join(exe("ruby"));
p.is_file().then_some(p)
}
/// Installed Rubies: version-manager installs, Homebrew, then `PATH` (shims excluded).
pub(crate) fn detect(root: Option<&Path>) -> Vec<Sdk> {
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
for (dir, manager) in manager_roots() {
found.extend(subdirs(&dir).into_iter().filter_map(|v| ruby_in_install(&v)).map(|p| (p, manager)));
}
for (dir, manager) in version_managers::installs(Tool::Ruby) {
found.extend(ruby_in_install(&dir).map(|p| (p, manager)));
}
for brew in ["/opt/homebrew/opt/ruby", "/usr/local/opt/ruby", "/home/linuxbrew/.linuxbrew/opt/ruby"] {
found.extend(ruby_in_install(Path::new(brew)).map(|p| (p, "homebrew")));
}
let dirs = std::env::split_paths(&search_path())
.filter(|d| !is_shims_dir(d) && !version_managers::is_shims_dir(d))
.collect::<Vec<_>>();
found.extend(executables_in("ruby", dirs.into_iter()).into_iter().map(|p| (p, "system")));
let mut seen = HashSet::new();
let candidates: Vec<(PathBuf, &'static str)> = found
.into_iter()
.filter(|(p, _)| p.is_file() && seen.insert(p.canonicalize().unwrap_or_else(|_| p.clone())))
.collect();
let mut out = par_map(&candidates, |(path, source)| ruby_sdk(path, source));
if let Some(root) = root
&& let Some(pinned) = pinned_ruby_version(root)
{
// The `.ruby-version` install first, like the project virtualenv for Python.
if let Some(i) = out.iter().position(|s| version_matches(s.version.as_deref().unwrap_or(""), &pinned)) {
let sdk = out.remove(i);
out.insert(0, sdk);
}
}
out
}
/// rbenv's and rvm's shim directories, which only forward to an install.
fn is_shims_dir(dir: &Path) -> bool {
dir.ends_with(".rbenv/shims") || dir.ends_with(".rvm/bin")
}
/// `ruby 3.3.1 (2024-04-23 revision …) [arm64-darwin23]` → `3.3.1`.
pub(crate) fn parse_version(out: &str) -> Option<String> {
out.strip_prefix("ruby ")?
.split_whitespace()
.next()
.map(|v| v.trim_end_matches(|c: char| c.is_ascii_alphabetic() || c == '-').to_owned())
}
pub(crate) fn ruby_sdk(path: &Path, source: &str) -> Option<Sdk> {
let version = query(path, &["--version"], VERSION_TIMEOUT).and_then(|v| parse_version(&v))?;
let short = version.split('.').take(2).collect::<Vec<_>>().join(".");
let name = match source {
"system" => format!("Ruby {short}"),
tool => format!("Ruby {short} ({tool})"),
};
Some(Sdk {
path: path.into(),
name,
version: Some(version),
source: source.to_owned(),
home: path.parent().and_then(Path::parent).map(Path::to_path_buf).map(Into::into),
extra: None,
})
}
/// The version `.ruby-version` pins (rbenv / rvm / chruby read it; `ruby-3.3.1` → `3.3.1`),
/// from `root` up, else the mise / asdf pin.
pub fn pinned_ruby_version(root: &Path) -> Option<String> {
let from_file = root.ancestors().find_map(|dir| {
let text = std::fs::read_to_string(dir.join(".ruby-version")).ok()?;
let v = text.lines().map(str::trim).find(|l| !l.is_empty() && !l.starts_with('#'))?;
Some(v.strip_prefix("ruby-").unwrap_or(v).to_owned())
});
from_file.or_else(|| pinned::pinned_version(root, Tool::Ruby))
}
/// Whether `version` (`3.3.1`) satisfies a pin (`3.3.1`, `3.3`, `3`).
pub(crate) fn version_matches(version: &str, pin: &str) -> bool {
version == pin || version.strip_prefix(pin).is_some_and(|rest| rest.starts_with('.'))
}
/// The Ruby for `root` without an explicit setting: the install matching
/// `.ruby-version` (version managers, then mise / asdf), else `ruby` on PATH.
pub fn project_ruby(root: &Path) -> Option<PathBuf> {
if let Some(pin) = pinned_ruby_version(root) {
let install = manager_roots()
.into_iter()
.flat_map(|(dir, _)| subdirs(&dir))
.chain(version_managers::installs(Tool::Ruby).into_iter().map(|(d, _)| d))
.find(|dir| {
let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
version_matches(name.strip_prefix("ruby-").unwrap_or(&name), &pin)
});
if let Some(ruby) = install.and_then(|d| ruby_in_install(&d)) {
return Some(ruby);
}
}
crate::exec::which("ruby")
}
/// `bin` directory of `ruby` and the gems it was installed with (`gem` and `bundle` next to it).
pub fn bin_dir_of(ruby: &Path) -> Option<PathBuf> {
ruby.parent().map(Path::to_path_buf)
}
/// Where `ruby` installs gem executables (`Gem.bindir`, the `--user-install` one) that exist, asked
/// of the Ruby that version managers select in `root`: Homebrew's keeps its bindir off `PATH`.
pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec<PathBuf> {
query_in(ruby, &["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT], Some(root), &[], QUERY_TIMEOUT)
.map(|out| ide_core::tool_path::gem_bin_dirs(&out))
.unwrap_or_default()
}
/// Prepares a Ruby debug session (rdbg) running in `dir` of the project `root`: the
/// project Ruby's `bin` leads `env`'s `PATH` unless the configuration sets one, and the
/// `bundle` and `rdbg` installed with that Ruby are returned by name, to start the adapter.
pub fn debug_environment(
root: &Path,
dir: &Path,
s: &ide_core::settings::Settings,
env: &mut std::collections::BTreeMap<String, String>,
) -> Vec<(&'static str, PathBuf)> {
let ruby = crate::apply::setting_path(root, &s.languages.ruby.interpreter).or_else(|| project_ruby(dir));
let Some((ruby, bin)) = ruby.and_then(|r| bin_dir_of(&r).map(|b| (r, b))) else { return Vec::new() };
env.entry("PATH".into()).or_insert_with(|| crate::exec::path_with(&[&bin]));
let mut gem_bins: Option<Vec<PathBuf>> = None;
let mut find = |name: &str| {
ide_core::process::find_executable(name, bin.as_os_str()).or_else(|| {
let dirs = gem_bins.get_or_insert_with(|| gem_bin_dirs(&ruby, dir));
ide_core::process::find_executable(name, &std::env::join_paths(dirs.iter()).ok()?)
})
};
["bundle", "rdbg"].into_iter().filter_map(|name| Some((name, find(name)?))).collect()
}
type NamedRoots = Vec<(String, PathBuf)>;
/// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves
/// for `root` (`rails-7.1.3`), each as `(name, path)`.
pub fn library_roots(ruby: &Path, root: Option<&Path>) -> (NamedRoots, NamedRoots) {
const PATHS: &str = "require 'rbconfig'; puts RbConfig::CONFIG['rubylibdir'], RbConfig::CONFIG['sitelibdir'], RbConfig::CONFIG['vendorlibdir']";
let mut stdlib = Vec::new();
let mut gems = Vec::new();
let file_name = |p: &Path| p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if let Some(text) = query(ruby, &["-e", PATHS], QUERY_TIMEOUT) {
for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
let path = PathBuf::from(line);
if path.is_dir() && !stdlib.iter().any(|(_, p)| *p == path) {
let parent = path.parent().map(file_name).unwrap_or_default();
stdlib.push((format!("{parent}/{}", file_name(&path)), path));
}
}
}
if let Some(root) = root.filter(|r| r.join("Gemfile").is_file()) {
let bin = bin_dir_of(ruby);
let bundle = bin.as_ref().map(|b| b.join(exe("bundle"))).filter(|b| b.is_file());
let path = bin.as_ref().map(|b| crate::exec::path_with(&[b.as_path()])).unwrap_or_default();
let env: &[(&str, &str)] = &[("PATH", &path)];
let text = match &bundle {
Some(b) => query_in(b, &["list", "--paths"], Some(root), env, QUERY_TIMEOUT),
None => query_in(ruby, &["-S", "bundle", "list", "--paths"], Some(root), env, QUERY_TIMEOUT),
};
for line in text.unwrap_or_default().lines().map(str::trim).filter(|l| !l.is_empty()) {
let path = PathBuf::from(line);
if path.is_dir() && path != root {
gems.push((file_name(&path), path));
}
}
}
(stdlib, gems)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn includes_bundled_gems_installed_inside_the_project() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("project");
let gem = root.join("vendor/bundle/ruby/4.0.0/gems/rake-13.4.0");
let external = dir.path().join("gems/rspec-3.13.2");
let bin = dir.path().join("bin");
for path in [&gem, &external, &bin] {
std::fs::create_dir_all(path).unwrap();
}
std::fs::write(root.join("Gemfile"), "gem 'rake'\ngem 'rspec'\n").unwrap();
for (name, output) in [
("ruby", String::new()),
("bundle", format!("{}\n{}\n{}", gem.display(), external.display(), root.display())),
] {
let path = bin.join(name);
std::fs::write(&path, format!("#!/bin/sh\nprintf '%s\\n' '{output}'\n")).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let (_, gems) = library_roots(&bin.join("ruby"), Some(&root));
assert_eq!(gems, vec![("rake-13.4.0".into(), gem), ("rspec-3.13.2".into(), external)]);
}
#[cfg(unix)]
#[test]
fn debug_sessions_use_the_rdbg_of_the_project_ruby_and_its_path() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("ruby/bin");
let gem_bin = dir.path().join("gems/bin");
for path in [&bin, &gem_bin] {
std::fs::create_dir_all(path).unwrap();
}
// Homebrew's Ruby installs gem executables outside its `bin`.
let scripts = [
(bin.join("ruby"), format!("#!/bin/sh\necho '{}'\n", gem_bin.display())),
(bin.join("bundle"), String::new()),
(gem_bin.join("rdbg"), String::new()),
];
for (path, text) in scripts {
std::fs::write(&path, text).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut s = ide_core::settings::Settings::default();
s.languages.ruby.interpreter = bin.join("ruby").to_string_lossy().into_owned();
let mut env = std::collections::BTreeMap::new();
let tools = debug_environment(dir.path(), dir.path(), &s, &mut env);
assert_eq!(tools, [("bundle", bin.join("bundle")), ("rdbg", gem_bin.join("rdbg"))]);
assert_eq!(std::env::split_paths(&env["PATH"]).next(), Some(bin.clone()));
// The configuration's own PATH stays.
let mut env = std::collections::BTreeMap::from([("PATH".to_owned(), "/custom".to_owned())]);
debug_environment(dir.path(), dir.path(), &s, &mut env);
assert_eq!(env["PATH"], "/custom");
}
#[test]
fn parses_ruby_version_output() {
assert_eq!(
parse_version("ruby 3.3.1 (2024-04-23 revision c56cd86388) [arm64-darwin23]").as_deref(),
Some("3.3.1")
);
assert_eq!(parse_version("ruby 3.4.0dev (2024-10-01) +PRISM [x86_64-linux]").as_deref(), Some("3.4.0"));
assert_eq!(parse_version("jruby 9.4.5.0"), None);
}
#[test]
fn version_pins_match_prefixes() {
assert!(version_matches("3.3.1", "3.3.1"));
assert!(version_matches("3.3.1", "3.3"));
assert!(!version_matches("3.31.1", "3.3"));
assert!(!version_matches("3.3.1", "3.3.10"));
}
#[test]
fn ruby_version_file_wins_over_managers() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("app/sub");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(dir.path().join("app/.ruby-version"), "ruby-3.2.2\n").unwrap();
assert_eq!(pinned_ruby_version(&project).as_deref(), Some("3.2.2"));
}
}