From 75607c0bbfebdd2c68ff7d9d5bc7408ed2daec1f Mon Sep 17 00:00:00 2001 From: Kerem Bozdas Date: Sun, 8 Nov 2026 17:41:27 +0000 Subject: [PATCH] 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. --- crates/ide-dap/src/adapter.rs | 1 + crates/ide-dap/src/lib.rs | 48 +++++++++++++++++ crates/ide-dap/src/tests.rs | 65 ++++++++++++++++++++++++ crates/ide-host/src/ops/debug.rs | 14 ++++- crates/ide-run/src/detect.rs | 12 ++++- crates/ide-run/tests/detect.rs | 2 + crates/ide-sdk/src/ruby.rs | 56 ++++++++++++++++++++ languages/ruby.toml | 7 +++ src/features/run/ConfigForm.tsx | 1 + src/features/run/producers.ruby.test.ts | 1 + src/features/run/producers.ts | 12 +++-- src/platform/locales/textCatalog.test.ts | 1 + 12 files changed, 213 insertions(+), 7 deletions(-) diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 458a38eef..9c0431ec6 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -101,6 +101,7 @@ fn builtin(adapter: &str) -> Option { "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", diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index ad1121178..1336de244 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -305,6 +305,51 @@ pub struct DebugManager { stopping: Mutex>>, } +/// 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) -> Option { + 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; } diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 05fc98ed0..f7dd8859b 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -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() { diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index a13284b4e..383cf6fde 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -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)?; diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 3b5b73874..e72423bf1 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -1168,6 +1168,7 @@ fn detect_ruby(root: &Path, out: &mut Vec) { ("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) { 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) { } 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"), + ); } } diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 4bff19d05..e7a4930e9 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -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")); diff --git a/crates/ide-sdk/src/ruby.rs b/crates/ide-sdk/src/ruby.rs index c0bbed7ca..afb44fb5a 100644 --- a/crates/ide-sdk/src/ruby.rs +++ b/crates/ide-sdk/src/ruby.rs @@ -148,6 +148,28 @@ pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec { .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, +) -> 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> = 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!( diff --git a/languages/ruby.toml b/languages/ruby.toml index 5442ed654..486c8acf7 100644 --- a/languages/ruby.toml +++ b/languages/ruby.toml @@ -23,3 +23,10 @@ command = "solargraph" args = ["stdio"] root-markers = ["Gemfile", ".solargraph.yml"] install-hint = "gem install solargraph" + +[debugger] +adapter = "rdbg" +command = "rdbg" +args = ["-O", "--port", "${port}", "-c"] +transport = "tcp" +install-hint = "gem install debug" diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index f1886e501..51ef2ebe4 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -64,6 +64,7 @@ const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [ { id: "codelldb", label: "CodeLLDB (Rust, C, C++)" }, { id: "lldb-dap", label: "lldb-dap (C, C++, Swift)" }, { id: "php-debug", label: "php-debug (PHP)" }, + { id: "rdbg", label: "rdbg (Ruby)" }, ]; /** Options "Modify options" adds to the form: value fields (shown once added) and flags (chips). */ diff --git a/src/features/run/producers.ruby.test.ts b/src/features/run/producers.ruby.test.ts index d37ff00c2..9bc5c1600 100644 --- a/src/features/run/producers.ruby.test.ts +++ b/src/features/run/producers.ruby.test.ts @@ -22,6 +22,7 @@ describe("Ruby file run", () => { it("puts Sources roots on the load path", () => { const p = produceContext(input("/work/app/test/integration/a_test.rb"), env); expect(p?.config?.args).toEqual(["-Ilib", "test/integration/a_test.rb"]); + expect(p?.config?.debugAdapter).toBe("rdbg"); }); it("adds none for files outside the project", () => { diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index df973f223..4bea19ef5 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -887,10 +887,14 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null } case "ruby": // Sources roots go on the load path (`-Ilib`), as `rake test` does; paths are project-relative. - return simple(lang, s, env, lang, () => [ - ...(s.inProject ? rubyLoadPath(currentRoots(), s.cwd, input.root) : []), - s.arg, - ]); + return simple( + lang, + s, + env, + lang, + () => [...(s.inProject ? rubyLoadPath(currentRoots(), s.cwd, input.root) : []), s.arg], + "rdbg", + ); case "php": if (env.php) return { name: s.file, scope: "file", config: fileConfig(s, "php", env.php, [s.arg], "php-debug") }; return simple(lang, s, env, lang, () => [s.arg], "php-debug"); diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index c6c7ce7a2..c6fa6e52d 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -89,6 +89,7 @@ const UNTRANSLATED = new Set([ "dotnet test", "netcoredbg (.NET)", "php-debug (PHP)", + "rdbg (Ruby)", // Product names "Cargo", "CMake",