diff --git a/Cargo.lock b/Cargo.lock index c52cd7d..7a10a72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -827,6 +827,8 @@ name = "zed_ruby" version = "0.9.0" dependencies = [ "regex", + "serde", + "serde_json", "zed_extension_api", ] diff --git a/Cargo.toml b/Cargo.toml index beba5c1..2924700 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,4 +11,6 @@ crate-type = ["cdylib"] [dependencies] regex = "1.11.1" +serde_json = "1.0" +serde = {version = "1.0", features = ["derive"]} zed_extension_api = "0.6.0" diff --git a/debug_adapter_schemas/rdbg.json b/debug_adapter_schemas/rdbg.json new file mode 100644 index 0000000..9338842 --- /dev/null +++ b/debug_adapter_schemas/rdbg.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Command name (ruby, rake, bin/rails, bundle exec ruby, etc)" + }, + "script": { + "type": "string", + "description": "Absolute path to a Ruby file." + }, + "cwd": { + "type": "string", + "description": "Directory to execute the program in", + "default": "${ZED_WORKTREE_ROOT}" + }, + "args": { + "type": "array", + "description": "Command line arguments passed to the program", + "items": { + "type": "string" + }, + "default": [] + }, + "env": { + "type": "object", + "description": "Additional environment variables to pass to the debugging (and debugged) process", + "default": {} + } + } +} diff --git a/extension.toml b/extension.toml index 61b92f5..a8a01f9 100644 --- a/extension.toml +++ b/extension.toml @@ -6,6 +6,7 @@ schema_version = 1 authors = ["Vitaly Slobodin "] repository = "https://github.com/zed-extensions/ruby" + [language_servers.solargraph] name = "Solargraph" languages = ["Ruby"] @@ -62,3 +63,5 @@ args = ["outdated", "--norc"] kind = "process:exec" command = "gem" args = ["update", "--norc", "*"] + +[debug_adapters.RDBG] diff --git a/languages/ruby/config.toml b/languages/ruby/config.toml index 8c084e1..26692a8 100644 --- a/languages/ruby/config.toml +++ b/languages/ruby/config.toml @@ -61,6 +61,7 @@ collapsed_placeholder = "# ..." tab_size = 2 scope_opt_in_language_servers = ["tailwindcss-language-server"] word_characters = ["?", "!"] +debuggers = ["RDBG"] [overrides.string] completion_query_characters = ["-", "."] diff --git a/src/ruby.rs b/src/ruby.rs index dbc9bb3..5c685c9 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -3,8 +3,15 @@ mod command_executor; mod gemset; mod language_servers; +use std::{collections::HashMap, path::Path}; + use language_servers::{LanguageServer, Rubocop, RubyLsp, Solargraph, Sorbet, Steep}; -use zed_extension_api::{self as zed}; +use serde::{Deserialize, Serialize}; +use zed_extension_api::{ + self as zed, resolve_tcp_template, Command, DebugAdapterBinary, DebugConfig, DebugRequest, + DebugScenario, DebugTaskDefinition, StartDebuggingRequestArguments, + StartDebuggingRequestArgumentsRequest, TcpArgumentsTemplate, Worktree, +}; #[derive(Default)] struct RubyExtension { @@ -15,6 +22,18 @@ struct RubyExtension { steep: Option, } +#[derive(Serialize, Deserialize)] +struct RubyDebugConfig { + script_or_command: Option, + script: Option, + command: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, + cwd: Option, +} + impl zed::Extension for RubyExtension { fn new() -> Self { Self::default() @@ -87,6 +106,138 @@ impl zed::Extension for RubyExtension { _ => None, } } + + fn get_dap_binary( + &mut self, + adapter_name: String, + config: DebugTaskDefinition, + _: Option, + worktree: &Worktree, + ) -> Result { + let mut rdbg_path = Path::new(&adapter_name) + .join("rdbg") + .to_string_lossy() + .into_owned(); + + if worktree.which(&rdbg_path).is_none() { + match worktree.which("rdbg".as_ref()) { + Some(path) => rdbg_path = path, + None => { + let output = Command::new("gem") + .arg("install") + .arg("--no-document") + .arg("--bindir") + .arg(&adapter_name) + .arg("debug") + .output()?; + if output.status.is_some_and(|status| status == 0) { + return Err(format!( + "Failed to install rdbg:\n{}", + String::from_utf8_lossy(&output.stderr).into_owned() + )); + } + } + } + } + + let tcp_connection = config.tcp_connection.unwrap_or(TcpArgumentsTemplate { + port: None, + host: None, + timeout: None, + }); + let connection = resolve_tcp_template(tcp_connection)?; + let mut configuration: serde_json::Value = serde_json::from_str(&config.config) + .map_err(|e| format!("`config` is not a valid JSON: {e}"))?; + + let ruby_config: RubyDebugConfig = serde_json::from_value(configuration.clone()) + .map_err(|e| format!("`config` is not a valid rdbg config: {e}"))?; + let mut arguments = vec![ + "--open".to_string(), + format!("--port={}", connection.port), + format!("--host={}", connection.host), + ]; + if let Some(script) = &ruby_config.script { + arguments.push(script.clone()); + } else if let Some(command) = &ruby_config.command { + arguments.push("--command".to_string()); + arguments.push(command.clone()); + } else if let Some(command_or_script) = &ruby_config.script_or_command { + if worktree.which(command_or_script).is_some() { + arguments.push("--command".to_string()); + } + arguments.push(command_or_script.clone()); + } else { + return Err("Ruby debug config must have 'script' or 'command' args".into()); + } + if let Some(configuration) = configuration.as_object_mut() { + configuration + .entry("cwd") + .or_insert_with(|| worktree.root_path().into()); + } + arguments.extend(ruby_config.args); + + Ok(DebugAdapterBinary { + command: Some(rdbg_path.to_string()), + arguments, + connection: Some(connection), + cwd: ruby_config.cwd, + envs: ruby_config.env.into_iter().collect(), + request_args: StartDebuggingRequestArguments { + configuration: configuration.to_string(), + request: StartDebuggingRequestArgumentsRequest::Launch, + }, + }) + } + + fn dap_request_kind( + &mut self, + _: String, + value: serde_json::Value, + ) -> zed_extension_api::Result { + value + .get("request") + .and_then(|request| { + request.as_str().and_then(|s| match s { + "launch" => Some(StartDebuggingRequestArgumentsRequest::Launch), + "attach" => Some(StartDebuggingRequestArgumentsRequest::Attach), + _ => None, + }) + }) + .ok_or_else(|| { + "Invalid request, expected `request` to be either `launch` or `attach`".into() + }) + } + + fn dap_config_to_scenario( + &mut self, + zed_scenario: DebugConfig, + ) -> Result { + match zed_scenario.request { + DebugRequest::Launch(launch) => { + let config = RubyDebugConfig { + script_or_command: Some(launch.program), + script: None, + command: None, + args: launch.args, + env: launch.envs.into_iter().collect(), + cwd: launch.cwd.clone(), + }; + + let config = serde_json::to_value(config) + .map_err(|e| e.to_string())? + .to_string(); + + Ok(DebugScenario { + adapter: zed_scenario.adapter, + label: zed_scenario.label, + config, + tcp_connection: None, + build: None, + }) + } + DebugRequest::Attach(_) => Err("Attach requests are unsupported".into()), + } + } } zed_extension_api::register_extension!(RubyExtension);