Файли
openidea/crates/ide-sdk/src/ruby.rs
T
Roberto Scinoccaтаretsef 002401e8a7 feat(languages): add ERB with an HTML + Ruby editor mode (#720)
Ruby gets an ERB editor mode (HTML with embedded Ruby), Ruby / Rake / RSpec run configuration types, and a Settings › Languages & Frameworks › Ruby page for choosing the interpreter (Auto picks the `.ruby-version` / mise / asdf install, else `ruby` on PATH).

- Run: `.rb` scripts run from the editor context menu, `_spec.rb` / `_test.rb` files narrow an RSpec or Rake (`SPEC=` / `TEST=`) configuration, and Rakefile tasks are detected; Rake and RSpec go through `bundle exec` when the project has a Gemfile. The chosen interpreter's `ruby`, `bundle`, `rake` and `rspec` are used and its bin directory is put on PATH.
- Configuration forms rebuild from a raw command line keeping option values with their options (`--format documentation`, `-I lib`), and a new Ruby configuration opens in the Ruby form with the script still to fill in.
- External Libraries lists the Ruby standard library and the bundle's gems, including those Bundler installs inside the project (`vendor/bundle`).
- Ruby is part of the frontend settings defaults and normalization, so the selected interpreter survives a settings round-trip.

Closes #719

Co-authored-by: retsef <[email protected]>
2026-11-03 15:05:09 +08:00

238 рядки
10 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)
}
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)]);
}
#[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"));
}
}