diff --git a/debug_adapter_schemas/ElixirLS.json b/debug_adapter_schemas/ElixirLS.json new file mode 100644 index 0000000..640ecf9 --- /dev/null +++ b/debug_adapter_schemas/ElixirLS.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ElixirLS Debug Adapter Configuration", + "type": "object", + "properties": { + "request": { + "type": "string", + "enum": ["launch", "attach"], + "default": "launch", + "description": "Whether to launch a new process or attach to an existing one" + }, + "task": { + "type": "string", + "examples": ["run", "test", "phx.server"], + "description": "Mix task to invoke (e.g. `run`, `test`, `phx.server`)" + }, + "taskArgs": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Arguments passed to the Mix task" + }, + "projectDir": { + "type": "string", + "default": "$ZED_WORKTREE_ROOT", + "description": "Absolute path to the directory containing `mix.exs`" + }, + "startApps": { + "type": "boolean", + "description": "Run `mix app.start` before the debugger (default: false)" + }, + "requireFiles": { + "type": "array", + "items": { "type": "string" }, + "default": ["test/**/test_helper.exs", "test/**/*_test.exs"], + "description": "Extra `.exs` files to require and interpret before debugging" + }, + "excludeModules": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Modules that should NOT be interpreted (e.g. NIFs)" + }, + "debugAutoInterpretAllModules": { + "type": "boolean", + "description": "Automatically interpret all loaded modules (default: true)" + }, + "debugInterpretModulesPatterns": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Glob patterns for modules to interpret" + }, + "exitAfterTaskReturns": { + "type": "boolean", + "description": "End the debug session when the task returns (default: true)" + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Environment variables to set for the debugged process" + }, + "stackTraceMode": { + "type": "string", + "enum": ["all", "no_tail", "false"], + "description": "Argument passed to `:int.stack_trace/1` (default: no_tail)" + }, + "noDebug": { + "type": "boolean", + "description": "Run the Mix task without debugging (default: false)" + }, + "breakOnDbg": { + "type": "boolean", + "description": "Automatically break on `Kernel.dbg/2` calls (default: true)" + } + }, + "required": ["request", "task", "projectDir"] +} diff --git a/extension.toml b/extension.toml index 2b8504a..8910411 100644 --- a/extension.toml +++ b/extension.toml @@ -74,6 +74,8 @@ languages = [ "EEx" = "eex" "HEEx" = "heex" +[debug_adapters.ElixirLS] + [grammars.elixir] repository = "https://github.com/elixir-lang/tree-sitter-elixir" commit = "450a8194f5a66561135962cfc8d7545a27b61c4c" diff --git a/src/elixir.rs b/src/elixir.rs index 35f1868..dd9f5da 100644 --- a/src/elixir.rs +++ b/src/elixir.rs @@ -1,7 +1,8 @@ mod language_servers; use zed_extension_api::{ - self as zed, CodeLabel, LanguageServerId, Result, Worktree, + self as zed, CodeLabel, DebugAdapterBinary, DebugConfig, DebugScenario, DebugTaskDefinition, + LanguageServerId, Result, StartDebuggingRequestArgumentsRequest, Worktree, lsp::{Completion, Symbol}, serde_json::Value, }; @@ -148,6 +149,46 @@ impl zed::Extension for ElixirExtension { _ => None, } } + + fn get_dap_binary( + &mut self, + adapter_name: String, + config: DebugTaskDefinition, + user_provided_debug_adapter_path: Option, + worktree: &Worktree, + ) -> Result { + match adapter_name.as_str() { + ElixirLs::DEBUG_ADAPTER_NAME => self + .elixir_ls + .get_or_insert_with(ElixirLs::new) + .get_dap_binary(config, user_provided_debug_adapter_path, worktree), + adapter_name => Err(format!("unknown debug adapter: {adapter_name}")), + } + } + + fn dap_request_kind( + &mut self, + adapter_name: String, + config: Value, + ) -> Result { + match adapter_name.as_str() { + ElixirLs::DEBUG_ADAPTER_NAME => self + .elixir_ls + .get_or_insert_with(ElixirLs::new) + .dap_request_kind(config), + adapter_name => Err(format!("unknown debug adapter: {adapter_name}")), + } + } + + fn dap_config_to_scenario(&mut self, config: DebugConfig) -> Result { + match config.adapter.as_str() { + ElixirLs::DEBUG_ADAPTER_NAME => self + .elixir_ls + .get_or_insert_with(ElixirLs::new) + .dap_config_to_scenario(config), + adapter_name => Err(format!("unknown debug adapter: {adapter_name}")), + } + } } zed::register_extension!(ElixirExtension); diff --git a/src/language_servers/elixir_ls.rs b/src/language_servers/elixir_ls.rs index bfba8f4..14fa884 100644 --- a/src/language_servers/elixir_ls.rs +++ b/src/language_servers/elixir_ls.rs @@ -1,9 +1,11 @@ -use std::fs; +use std::{fs, str::FromStr}; use zed_extension_api::{ - self as zed, CodeLabel, CodeLabelSpan, LanguageServerId, Result, Worktree, + self as zed, CodeLabel, CodeLabelSpan, DebugAdapterBinary, DebugConfig, DebugRequest, + DebugScenario, DebugTaskDefinition, LanguageServerId, Result, StartDebuggingRequestArguments, + StartDebuggingRequestArgumentsRequest, Worktree, lsp::{Completion, CompletionKind, Symbol, SymbolKind}, - serde_json::{Value, json}, + serde_json::{Map, Value, json}, }; use crate::language_servers::{config, util}; @@ -14,77 +16,42 @@ struct ElixirLsBinary { } pub struct ElixirLs { - cached_binary_path: Option, + cached_lsp_binary_path: Option, + cached_dap_binary_path: Option, } impl ElixirLs { pub const LANGUAGE_SERVER_ID: &'static str = "elixir-ls"; + pub const DEBUG_ADAPTER_NAME: &'static str = "ElixirLS"; pub fn new() -> Self { Self { - cached_binary_path: None, + cached_lsp_binary_path: None, + cached_dap_binary_path: None, } } - pub fn language_server_command( - &mut self, - language_server_id: &LanguageServerId, - worktree: &Worktree, - ) -> Result { - let elixir_ls = self.language_server_binary(language_server_id, worktree)?; - - Ok(zed::Command { - command: elixir_ls.path, - args: elixir_ls.args, - env: Default::default(), - }) - } - - fn language_server_binary( + fn download_elixir_ls( &mut self, - language_server_id: &LanguageServerId, - worktree: &Worktree, - ) -> Result { + language_server_id: Option<&LanguageServerId>, + ) -> Result<(String, String)> { let (platform, _arch) = zed::current_platform(); let extension = match platform { - zed::Os::Mac | zed::Os::Linux => "sh", - zed::Os::Windows => "bat", + zed::Os::Mac | zed::Os::Linux => ".sh", + zed::Os::Windows => ".bat", }; - let binary_name = format!("language_server.{extension}"); - let binary_settings = config::get_binary_settings(Self::LANGUAGE_SERVER_ID, worktree); - let binary_args = config::get_binary_args(&binary_settings).unwrap_or_default(); - let launch_script = format!("launch.{extension}"); - let debug_adapter = format!("debug_adapter.{extension}"); - - if let Some(binary_path) = config::get_binary_path(&binary_settings) { - return Ok(ElixirLsBinary { - path: binary_path, - args: binary_args, - }); - } + let language_server = format!("language_server{extension}"); + let launch_script = format!("launch{extension}"); + let debug_adapter = format!("debug_adapter{extension}"); - if let Some(binary_path) = worktree.which(Self::LANGUAGE_SERVER_ID) { - return Ok(ElixirLsBinary { - path: binary_path, - args: binary_args, - }); - } - - if let Some(binary_path) = &self.cached_binary_path - && fs::metadata(binary_path).is_ok_and(|stat| stat.is_file()) - { - return Ok(ElixirLsBinary { - path: binary_path.clone(), - args: binary_args, - }); + if let Some(language_server_id) = language_server_id { + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); } - zed::set_language_server_installation_status( - language_server_id, - &zed::LanguageServerInstallationStatus::CheckingForUpdate, - ); - let release = match zed::latest_github_release( "elixir-lsp/elixir-ls", zed::GithubReleaseOptions { @@ -94,14 +61,17 @@ impl ElixirLs { ) { Ok(release) => release, Err(_) => { - if let Some(binary_path) = - util::find_existing_binary(Self::LANGUAGE_SERVER_ID, &binary_name) + if let Some(lsp_binary_path) = + util::find_existing_binary(Self::LANGUAGE_SERVER_ID, &language_server) + && let Some(dap_binary_path) = + fs::canonicalize(format!("./{}", lsp_binary_path)) + .map_err(|e| format!("failed to resolve debug adapter path: {e}"))? + .parent() + .map(|path| path.join(debug_adapter).to_string_lossy().to_string()) { - self.cached_binary_path = Some(binary_path.clone()); - return Ok(ElixirLsBinary { - path: binary_path, - args: binary_args, - }); + self.cached_lsp_binary_path = Some(lsp_binary_path.clone()); + self.cached_dap_binary_path = Some(dap_binary_path.clone()); + return Ok((lsp_binary_path, dap_binary_path)); } return Err("failed to download latest github release".to_string()); } @@ -120,15 +90,17 @@ impl ElixirLs { .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?; let version_dir = format!("{}-{}", Self::LANGUAGE_SERVER_ID, release.version); - let binary_path = format!("{}/{}", version_dir, binary_name); - let launch_path = format!("{}/{}", version_dir, launch_script); - let debug_path = format!("{}/{}", version_dir, debug_adapter); - - if !fs::metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { - zed::set_language_server_installation_status( - language_server_id, - &zed::LanguageServerInstallationStatus::Downloading, - ); + let lsp_binary_path = format!("{}/{}", version_dir, language_server); + let launch_binary_path = format!("{}/{}", version_dir, launch_script); + let dap_binary_path = format!("{}/{}", version_dir, debug_adapter); + + if !fs::metadata(&lsp_binary_path).is_ok_and(|stat| stat.is_file()) { + if let Some(language_server_id) = language_server_id { + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + } zed::download_file( &asset.download_url, @@ -137,14 +109,68 @@ impl ElixirLs { ) .map_err(|e| format!("failed to download file: {e}"))?; - zed::make_file_executable(&binary_path)?; - zed::make_file_executable(&launch_path)?; - zed::make_file_executable(&debug_path)?; + zed::make_file_executable(&lsp_binary_path)?; + zed::make_file_executable(&launch_binary_path)?; + zed::make_file_executable(&dap_binary_path)?; util::remove_outdated_versions(Self::LANGUAGE_SERVER_ID, &version_dir)?; } - self.cached_binary_path = Some(binary_path.clone()); + let dap_binary_path = fs::canonicalize(format!("./{}", dap_binary_path)) + .map_err(|e| format!("failed to resolve debug adapter path: {e}"))? + .to_string_lossy() + .to_string(); + self.cached_lsp_binary_path = Some(lsp_binary_path.clone()); + self.cached_dap_binary_path = Some(dap_binary_path.clone()); + Ok((lsp_binary_path, dap_binary_path)) + } + + pub fn language_server_command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &Worktree, + ) -> Result { + let elixir_ls = self.language_server_binary(language_server_id, worktree)?; + + Ok(zed::Command { + command: elixir_ls.path, + args: elixir_ls.args, + env: Default::default(), + }) + } + + fn language_server_binary( + &mut self, + language_server_id: &LanguageServerId, + worktree: &Worktree, + ) -> Result { + let binary_settings = config::get_binary_settings(Self::LANGUAGE_SERVER_ID, worktree); + let binary_args = config::get_binary_args(&binary_settings).unwrap_or_default(); + + if let Some(binary_path) = config::get_binary_path(&binary_settings) { + return Ok(ElixirLsBinary { + path: binary_path, + args: binary_args, + }); + } + + if let Some(binary_path) = worktree.which(Self::LANGUAGE_SERVER_ID) { + return Ok(ElixirLsBinary { + path: binary_path, + args: binary_args, + }); + } + + if let Some(binary_path) = &self.cached_lsp_binary_path + && fs::metadata(binary_path).is_ok_and(|stat| stat.is_file()) + { + return Ok(ElixirLsBinary { + path: binary_path.clone(), + args: binary_args, + }); + } + + let (binary_path, _) = self.download_elixir_ls(Some(language_server_id))?; Ok(ElixirLsBinary { path: binary_path, args: binary_args, @@ -292,4 +318,103 @@ impl ElixirLs { code, }) } + + pub fn get_dap_binary( + &mut self, + config: DebugTaskDefinition, + user_provided_debug_adapter_path: Option, + _worktree: &Worktree, + ) -> Result { + let elixir_ls = self.debug_adapter_binary(user_provided_debug_adapter_path)?; + + let request = self + .dap_request_kind( + Value::from_str(&config.config) + .map_err(|err| format!("Invalid JSON configuration: {err}"))?, + ) + .map_err(|err| format!("Failed to determine debug request kind: {err}"))?; + + Ok(DebugAdapterBinary { + command: Some(elixir_ls), + arguments: vec![], + envs: vec![], + cwd: None, + connection: None, + request_args: StartDebuggingRequestArguments { + configuration: config.config, + request, + }, + }) + } + + fn debug_adapter_binary( + &mut self, + user_provided_debug_adapter_path: Option, + ) -> Result { + if let Some(binary_path) = user_provided_debug_adapter_path { + return Ok(binary_path); + } + + if let Some(binary_path) = &self.cached_dap_binary_path + && fs::metadata(binary_path).is_ok_and(|stat| stat.is_file()) + { + return Ok(binary_path.clone()); + } + + let (_, binary_path) = self.download_elixir_ls(None)?; + Ok(binary_path) + } + + pub fn dap_request_kind( + &mut self, + config: Value, + ) -> Result { + match config.get("request").and_then(|v| v.as_str()) { + Some("attach") => Ok(StartDebuggingRequestArgumentsRequest::Attach), + Some("launch") => Ok(StartDebuggingRequestArgumentsRequest::Launch), + Some(value) => Err(format!( + "Unexpected value for `request` key in ElixirLS debug adapter configuration: {value:?}" + )), + None => Err( + "Missing required `request` field in ElixirLS debug adapter configuration" + .to_string(), + ), + } + } + + pub fn dap_config_to_scenario(&mut self, config: DebugConfig) -> Result { + let adapter_config = match config.request { + DebugRequest::Launch(launch) => { + let env = launch + .envs + .into_iter() + .map(|(k, v)| (k, Value::String(v))) + .collect::>(); + + let mut cfg = json!({ + "request": "launch", + "task": launch.program, + "taskArgs": launch.args, + "env": env, + }); + + if let Some(cwd) = launch.cwd { + cfg["projectDir"] = Value::String(cwd); + } + + cfg + } + DebugRequest::Attach(_) => json!({ + "request": "attach", + }), + }; + + Ok(DebugScenario { + label: config.label, + adapter: config.adapter, + build: None, + config: adapter_config.to_string(), + tcp_connection: None, + }) + } }