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.
Цей коміт міститься в:
Kerem Bozdas authored and GitHub committed 2026-11-09 01:41:27 +08:00
1 джерело c133d2dba9
коміт 75607c0bbf
12 files changed
+213 -7

No files matched your search

+1
Переглянути файл
@@ -101,6 +101,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
}
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
"java-debug" => cfg(
"com.microsoft.java.debug.plugin.jar",
+48
Переглянути файл
@@ -305,6 +305,51 @@ pub struct DebugManager {
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
}
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
/// arguments and environment: they go on its command line after `--` and into its process.
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
if spec.command == "rdbg"
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
{
if !bundles_debug(&gemfile) {
return Err(IdeError::unavailable(format!(
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
gemfile.display()
)));
}
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
spec.command = "bundle".into();
}
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
spec.args.push("--".into());
spec.args.extend(config.program.iter().cloned());
spec.args.extend(config.args.iter().cloned());
Ok(())
}
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
match env.get("BUNDLE_GEMFILE").filter(|g| !g.is_empty()) {
Some(gemfile) => Some(dir.join(gemfile)),
None => dir.ancestors().flat_map(|d| [d.join("Gemfile"), d.join("gems.rb")]).find(|g| g.is_file()),
}
}
/// Whether the lockfile of `gemfile` lists the `debug` gem.
fn bundles_debug(gemfile: &Path) -> bool {
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
gemfile.with_file_name("gems.locked")
} else {
let mut name = gemfile.as_os_str().to_owned();
name.push(".lock");
PathBuf::from(name)
};
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
}
/// Whether the debugged project depends on the Flutter SDK, so it must go
/// through `flutter debug_adapter` instead of the plain Dart one.
///
@@ -651,6 +696,9 @@ impl DebugManager {
{
spec.env.push(("JAVA_HOME".into(), home));
}
if config.adapter == "rdbg" {
rdbg_command_line(&mut spec, &config, &cwd)?;
}
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
+65
Переглянути файл
@@ -1391,6 +1391,71 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() {
let dir = tempfile::tempdir().unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig {
adapter: "rdbg".into(),
program: Some("ruby".into()),
args: ["-Ilib", "app.rb", "--verbose"].map(String::from).to_vec(),
env: BTreeMap::from([("RAILS_ENV".into(), "test".into())]),
extra_json: None,
..config()
};
let mut spec = adapter::resolve(&rdbg, dir.path().into());
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
assert_eq!(spec.command, "rdbg");
assert_eq!(spec.args, ["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app.rb", "--verbose"]);
assert!(spec.env.contains(&("RAILS_ENV".into(), "test".into())));
}
#[test]
fn rdbg_runs_from_a_bundle_that_has_debug_and_refuses_one_without() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("app");
std::fs::create_dir_all(app.join("lib")).unwrap();
std::fs::write(app.join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
// A dependency of another gem is not a gem of the bundle.
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n irb (1.14.0)\n debug (>= 1)\n").unwrap();
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
let config = LaunchConfig {
adapter: "rdbg".into(),
program: Some("bundle".into()),
args: ["exec", "rspec"].map(String::from).to_vec(),
extra_json: None,
..config()
};
let mut spec = adapter::resolve(&rdbg, app.clone());
let err = rdbg_command_line(&mut spec, &config, &app).unwrap_err();
assert!(err.message.contains("gem \"debug\""), "{}", err.message);
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n debug (1.9.2)\n irb (~> 1.10)\n").unwrap();
// From a subdirectory too: Bundler finds the Gemfile above it.
for cwd in [app.clone(), app.join("lib")] {
let mut spec = adapter::resolve(&rdbg, cwd.clone());
rdbg_command_line(&mut spec, &config, &cwd).unwrap();
assert_eq!(spec.command, "bundle");
assert_eq!(spec.args, ["exec", "rdbg", "-O", "--port", "${port}", "-c", "--", "bundle", "exec", "rspec"]);
}
}
#[test]
fn bundler_gemfile_and_lockfile_follow_bundle_gemfile_and_gems_rb() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("gems")).unwrap();
let none = BTreeMap::new();
assert_eq!(bundler_gemfile(root, &none), None);
std::fs::write(root.join("gems/gems.rb"), "").unwrap();
std::fs::write(root.join("gems/gems.locked"), "GEM\n specs:\n debug (1.9.2)\n").unwrap();
let gems_rb = bundler_gemfile(&root.join("gems"), &none).unwrap();
assert_eq!(gems_rb, root.join("gems/gems.rb"));
assert!(bundles_debug(&gems_rb));
let env = BTreeMap::from([("BUNDLE_GEMFILE".into(), "gems/gems.rb".into())]);
assert_eq!(bundler_gemfile(root, &env), Some(gems_rb));
}
#[cfg(unix)]
#[tokio::test]
async fn stop_waits_for_the_launched_program_to_exit() {
+13 -1
Переглянути файл
@@ -75,8 +75,20 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
config.extra_json = Some(extra.to_string());
}
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let mut ruby_tools = Vec::new();
if config.adapter == "rdbg" {
let project_root = host.project(window).map(|p| p.root.clone()).unwrap_or_else(|_| root.clone());
let (dir, settings) = (root.clone(), settings.clone());
(config, ruby_tools) = host
.blocking(move || {
let tools = ide_sdk::ruby::debug_environment(&project_root, &dir, &settings, &mut config.env);
Ok((config, tools))
})
.await?;
}
let custom = |command: &str| {
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
let path = ide_sdk::apply::tool_command(&root, &settings, command)
.or_else(|| ruby_tools.into_iter().find(|(name, _)| *name == command).map(|(_, path)| path))?;
Some(path.to_string_lossy().into_owned())
};
host.require_trust(window)?;
+10 -2
Переглянути файл
@@ -1168,6 +1168,7 @@ fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
("ruby".into(), Vec::new()),
&["bin/rails", "server"],
);
c = c.debug("rdbg");
c.extras.folder = Some("Rails".into());
out.push(c);
}
@@ -1180,12 +1181,15 @@ fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
if task == "test" || task == "spec" || task.ends_with(":test") || task.ends_with(":spec") {
c = c.test();
}
c = c.debug("rdbg");
c.extras.folder = Some("Rake".into());
out.push(c);
}
}
if uses_rspec(root) {
out.push(ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test());
out.push(
ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test().debug("rdbg"),
);
} else if root.join("test").is_dir() && !out.iter().any(|c| c.id == "rake:test") {
// Minitest / Test::Unit without a `test` Rake task: load every test file under `test/`.
let mut args = Vec::new();
@@ -1196,7 +1200,11 @@ fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
}
args.extend(["-e".to_owned(), MINITEST_RUNNER.to_owned(), "test".to_owned()]);
let extra: Vec<&str> = args.iter().map(String::as_str).collect();
out.push(ruby_config("minitest".into(), "minitest".into(), "ruby", ruby_tool(root, "ruby"), &extra).test());
out.push(
ruby_config("minitest".into(), "minitest".into(), "ruby", ruby_tool(root, "ruby"), &extra)
.test()
.debug("rdbg"),
);
}
}
+2
Переглянути файл
@@ -1063,6 +1063,7 @@ task default: :build
assert_eq!(cmdline(find(&c, "rspec")), "bundle exec rspec");
assert!(find(&c, "rspec").is_test);
assert_eq!(find(&c, "rake:build").extras.folder.as_deref(), Some("Rake"));
assert!(c.iter().all(|c| c.debug_adapter.as_deref() == Some("rdbg")));
}
#[test]
@@ -1077,6 +1078,7 @@ fn ruby_minitest_projects_get_a_test_runner() {
("bundle", &["exec", "ruby", "-Ilib", "-Itest"].map(String::from)[..])
);
assert_eq!(m.args.last().map(String::as_str), Some("test"));
assert_eq!(m.debug_adapter.as_deref(), Some("rdbg"));
// A `test` Rake task or RSpec already runs the tests.
let dir = project(&[("Rakefile", "task :test\n"), ("test/a_test.rb", "")]);
assert!(!ids(&detect(dir.path())).contains(&"minitest"));
+56
Переглянути файл
@@ -148,6 +148,28 @@ pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec<PathBuf> {
.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
@@ -216,6 +238,40 @@ mod tests {
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!(