дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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.
Цей коміт міститься в:
1 джерело
c133d2dba9
коміт
75607c0bbf
12 files changed
+213
-7
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in new issue
Заблокувати користувача