From 43052c0c885d76a8f1c295f52983c3adfe49833c Mon Sep 17 00:00:00 2001 From: Simon Willshire Date: Tue, 3 Feb 2026 09:17:51 +0100 Subject: [PATCH] Add zed-netcoredbg to csharp extension, add runnables - Use extension from https://github.com/qwadrox/zed-netcoredbg to have basic debug functionality with DAP and download netcoredbg for the running platform. - Add runnables and tasks to debug from test files. Captures for nunit, xunit, vstest. Not strict, purely functional (does not pair keywords) to test framework. - Currently unable to fully attach to runnable, but starts the vstest process so that it can be easily attached with PID listed in console --- Cargo.lock | 8 + Cargo.toml | 2 + debug_adapter_schemas/netcoredbg.json | 116 +++++++++++ extension.toml | 15 ++ languages/csharp/config.toml | 1 + languages/csharp/runnables.scm | 100 +++++++++ languages/csharp/tasks.json | 60 ++++++ src/binary_manager.rs | 283 +++++++++++++++++++++++++ src/csharp.rs | 285 +++++++++++++++++++++++++- src/simple_temp_dir.rs | 32 +++ 10 files changed, 900 insertions(+), 2 deletions(-) create mode 100644 debug_adapter_schemas/netcoredbg.json create mode 100644 languages/csharp/runnables.scm create mode 100644 languages/csharp/tasks.json create mode 100644 src/binary_manager.rs create mode 100644 src/simple_temp_dir.rs diff --git a/Cargo.lock b/Cargo.lock index d34acfb..8861f06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.31" @@ -741,6 +747,8 @@ dependencies = [ name = "zed_csharp" version = "1.0.1" dependencies = [ + "fs_extra", + "serde", "zed_extension_api", ] diff --git a/Cargo.toml b/Cargo.toml index 919c7f5..3f6fe21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,5 @@ crate-type = ["cdylib"] [dependencies] zed_extension_api = "0.7.0" +serde = { version = "1.0", features = ["derive"] } +fs_extra = "1.3.0" diff --git a/debug_adapter_schemas/netcoredbg.json b/debug_adapter_schemas/netcoredbg.json new file mode 100644 index 0000000..bcbf4a2 --- /dev/null +++ b/debug_adapter_schemas/netcoredbg.json @@ -0,0 +1,116 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Zed NetCoreDbg Debug Adapter Protocol Configuration", + "description": "JSON schema for netcoredbg debug adapter protocol launch and attach configurations", + "type": "object", + "properties": { + "request": { + "type": "string", + "enum": [ + "launch", + "attach" + ], + "description": "The request type - either 'launch' to start a new process or 'attach' to connect to an existing process" + } + }, + "required": [ + "request" + ], + "allOf": [ + { + "if": { + "properties": { + "request": { + "const": "launch" + } + } + }, + "then": { + "properties": { + "request": true, + "program": { + "type": "string", + "pattern": "\\.(dll|exe)$", + "description": "Path to the executable assembly (.dll or .exe) to launch. This is the main entry point of your .NET application. NetCoreDbg will use 'dotnet' as the runtime and pass this as the first argument." + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "description": "Command line arguments to pass to the program. These arguments are appended after the program path when launching with 'dotnet'." + }, + "cwd": { + "type": "string", + "description": "Working directory for the launched process. This is crucial for .NET applications as it determines where configuration files (like appsettings.json), relative file paths, and other resources are resolved from. For ASP.NET Core apps, this affects content root discovery and static file serving. If not specified, defaults to the workspace root directory.", + "default": "${ZED_WORKTREE_ROOT}" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": {}, + "description": "Environment variables to set for the launched process. These are key-value pairs that will be available to your application at runtime." + }, + "stopAtEntry": { + "type": "boolean", + "default": false, + "description": "Whether to stop at the entry point (main method) of the program. When true, the debugger will break at the first line of user code, allowing you to step through from the very beginning." + }, + "justMyCode": { + "type": "boolean", + "default": true, + "description": "Enable Just My Code debugging. When true, the debugger will only step through and break in user-written code, skipping framework and library code. This matches the default behavior of Microsoft's vsdbg." + }, + "enableStepFiltering": { + "type": "boolean", + "default": true, + "description": "Enable step filtering to automatically step over properties, operators, and other code constructs that are typically not interesting during debugging. This matches the default behavior of Microsoft's vsdbg." + }, + "tcp_connection": { + "type": "string", + "default": "localhost:4711", + "description": "TCP Connection to connect with to netcoredbg" + } + }, + "required": [ + "program" + ] + } + }, + { + "if": { + "properties": { + "request": { + "const": "attach" + } + } + }, + "then": { + "properties": { + "request": true, + "processId": { + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "description": "Numeric process ID to attach to" + }, + { + "type": "string", + "pattern": "^[0-9]+$", + "description": "String representation of process ID to attach to" + } + ], + "description": "The process ID of the running .NET application to attach to. Can be specified as a number or string representation of a number. The target process must be a .NET Core application with debugging enabled." + } + }, + "required": [ + "processId" + ] + } + } + ] +} diff --git a/extension.toml b/extension.toml index f68bded..21fbb80 100644 --- a/extension.toml +++ b/extension.toml @@ -21,8 +21,23 @@ language = "CSharp" repository = "https://github.com/tree-sitter/tree-sitter-c-sharp" commit = "dd5e59721a5f8dae34604060833902b882023aaf" +[debug_adapters.netcoredbg] +schema_path = "debug_adapter_schemas/netcoredbg.json" + +[debug_locators.csharp-test-runner] + # Used to run `csharp-language-server --download` after an update [[capabilities]] kind = "process:exec" command = "*" args = ["--download"] + +[[capabilities]] +kind = "download_file" +host = "github.com" +path = ["qwadrox", "netcoredbg", "**"] + +[[capabilities]] +kind = "download_file" +host = "objects.githubusercontent.com" +path = ["**"] diff --git a/languages/csharp/config.toml b/languages/csharp/config.toml index 8f07b45..94e4c60 100644 --- a/languages/csharp/config.toml +++ b/languages/csharp/config.toml @@ -12,3 +12,4 @@ brackets = [ { start = "'", end = "'", close = true, newline = false, not_in = ["string", "comment"] }, { start = "/*", end = " */", close = true, newline = false, not_in = ["string", "comment"] }, ] +debuggers = ["netcoredbg"] diff --git a/languages/csharp/runnables.scm b/languages/csharp/runnables.scm new file mode 100644 index 0000000..89c8634 --- /dev/null +++ b/languages/csharp/runnables.scm @@ -0,0 +1,100 @@ +; ========================= +; Test method with namespace +; ========================= +( + (namespace_declaration + name: (_) @csharp_namespace + body: (declaration_list + (class_declaration + name: (identifier) @csharp_class_name @csharp_csproj_hint + body: (declaration_list + (method_declaration + (attribute_list + (attribute + name: (_) @attribute_name + (#match? @attribute_name "^(Fact|Theory|Test|TestCase|TestCaseSource|TestMethod|DataTestMethod)$"))) + name: (identifier) @run @csharp_method_name))))) + (#set! tag csharp-test-method) +) + +; ====================================== +; Test method with file-scoped namespace +; ====================================== +( + (file_scoped_namespace_declaration + name: (_) @csharp_namespace + (class_declaration + name: (identifier) @csharp_class_name @csharp_csproj_hint + body: (declaration_list + (method_declaration + (attribute_list + (attribute + name: (_) @attribute_name + (#match? @attribute_name "^(Fact|Theory|Test|TestCase|TestCaseSource|TestMethod|DataTestMethod)$"))) + name: (identifier) @run @csharp_method_name)))) + (#set! tag csharp-test-method) +) + +; ============================ +; Test method without namespace +; (anchored to top-level only) +; ============================ +( + (compilation_unit + (class_declaration + name: (identifier) @csharp_class_name @csharp_csproj_hint + body: (declaration_list + (method_declaration + (attribute_list + (attribute + name: (_) @attribute_name + (#match? @attribute_name "^(Fact|Theory|Test|TestCase|TestCaseSource|TestMethod|DataTestMethod)$"))) + name: (identifier) @run @csharp_method_name)))) + (#set! tag csharp-test-method) +) + +; =============================== +; Test class with namespace +; =============================== +( + (namespace_declaration + name: (_) @csharp_namespace + body: (declaration_list + (class_declaration + (attribute_list + (attribute + name: (_) @class_attribute + (#match? @class_attribute "^(TestFixture|TestClass)$"))) + name: (identifier) @run @csharp_class_name))) + (#set! tag csharp-test-class) +) + +; ===================================== +; Test class with file-scoped namespace +; ===================================== +( + (file_scoped_namespace_declaration + name: (_) @csharp_namespace + (class_declaration + (attribute_list + (attribute + name: (_) @class_attribute + (#match? @class_attribute "^(TestFixture|TestClass)$"))) + name: (identifier) @run @csharp_class_name)) + (#set! tag csharp-test-class) +) + +; ============================== +; Test class without namespace +; (anchored to top-level only) +; ============================== +( + (compilation_unit + (class_declaration + (attribute_list + (attribute + name: (_) @class_attribute + (#match? @class_attribute "^(TestFixture|TestClass)$"))) + name: (identifier) @run @csharp_class_name)) + (#set! tag csharp-test-class) +) diff --git a/languages/csharp/tasks.json b/languages/csharp/tasks.json new file mode 100644 index 0000000..06fd491 --- /dev/null +++ b/languages/csharp/tasks.json @@ -0,0 +1,60 @@ +[ + { + "label": "Test ${ZED_CUSTOM_csharp_namespace:}.${ZED_CUSTOM_csharp_class_name:}.${ZED_CUSTOM_csharp_method_name:}", + "command": "dotnet", + "args": [ + "test", + "--filter", + "FullyQualifiedName=${ZED_CUSTOM_csharp_namespace:}.${ZED_CUSTOM_csharp_class_name:}.${ZED_CUSTOM_csharp_method_name:}" + ], + "cwd": "$ZED_DIRNAME", + "use_new_terminal": false, + "reveal": "always", + "tags": [ + "csharp-test-method" + ], + "env": { + "CSHARP_TEST_NAMESPACE": "${ZED_CUSTOM_csharp_namespace:}", + "CSHARP_TEST_CLASS": "${ZED_CUSTOM_csharp_class_name:}", + "CSHARP_TEST_METHOD": "${ZED_CUSTOM_csharp_method_name:}", + "CSHARP_TEST_FILE_DIR": "$ZED_DIRNAME" + } + }, + { + "label": "Test class ${ZED_CUSTOM_csharp_namespace:}.${ZED_CUSTOM_csharp_class_name:}", + "command": "dotnet", + "args": [ + "test", + "--filter", + "FullyQualifiedName~${ZED_CUSTOM_csharp_namespace:}.${ZED_CUSTOM_csharp_class_name:}" + ], + "cwd": "$ZED_DIRNAME", + "use_new_terminal": false, + "reveal": "always", + "tags": [ + "csharp-test-class" + ], + "env": { + "CSHARP_TEST_NAMESPACE": "${ZED_CUSTOM_csharp_namespace:}", + "CSHARP_TEST_CLASS": "${ZED_CUSTOM_csharp_class_name:}", + "CSHARP_TEST_METHOD": "", + "CSHARP_TEST_FILE_DIR": "$ZED_DIRNAME" + } + }, + { + "label": "Test project", + "command": "dotnet", + "args": [ + "test" + ], + "cwd": "$ZED_DIRNAME", + "use_new_terminal": false, + "reveal": "always", + "tags": [ + "csharp-test-all" + ], + "env": { + "CSHARP_TEST_FILE_DIR": "$ZED_DIRNAME" + } + } +] diff --git a/src/binary_manager.rs b/src/binary_manager.rs new file mode 100644 index 0000000..ee2451f --- /dev/null +++ b/src/binary_manager.rs @@ -0,0 +1,283 @@ +use crate::simple_temp_dir::SimpleTempDir; +use fs_extra::dir; +use std::sync::OnceLock; +use zed_extension_api::{self as zed, DownloadedFileType, GithubReleaseOptions}; + +/// GitHub release version information +#[derive(Debug, Clone)] +pub struct AdapterVersion { + /// Release tag name (version) + pub tag_name: String, + /// Download URL for the release asset + pub download_url: String, +} + +pub struct BinaryManager { + /// Cached path to the netcoredbg binary - set once and reused + cached_binary_path: OnceLock, +} + +impl Default for BinaryManager { + fn default() -> Self { + Self::new() + } +} + +impl BinaryManager { + const GITHUB_OWNER: &str = "qwadrox"; + const GITHUB_REPO: &str = "netcoredbg"; + + pub fn new() -> Self { + Self { + cached_binary_path: OnceLock::new(), + } + } + + fn get_executable_name() -> &'static str { + if zed::current_platform().0 == zed::Os::Windows { + "netcoredbg.exe" + } else { + "netcoredbg" + } + } + + /// Determines the appropriate asset name for the current platform + /// Supported assets: + /// - netcoredbg-linux-amd64.tar.gz + /// - netcoredbg-linux-arm64.tar.gz + /// - netcoredbg-osx-amd64.tar.gz + /// - netcoredbg-osx-arm64.tar.gz + /// - netcoredbg-win64.zip + fn get_platform_asset_name() -> Result { + let (platform, arch) = zed::current_platform(); + + let (platform_arch, extension) = match (platform, arch) { + (zed::Os::Linux, zed::Architecture::X8664) => ("linux-amd64", ".tar.gz"), + (zed::Os::Linux, zed::Architecture::Aarch64) => ("linux-arm64", ".tar.gz"), + (zed::Os::Mac, zed::Architecture::X8664) => ("osx-amd64", ".tar.gz"), + (zed::Os::Mac, zed::Architecture::Aarch64) => ("osx-arm64", ".tar.gz"), + (zed::Os::Windows, zed::Architecture::X8664) => ("win64", ".zip"), + (zed::Os::Windows, zed::Architecture::Aarch64) => { + // Windows ARM64 is not officially supported by netcoredbg, + // but we can try the x64 version as a fallback + ("win64", ".zip") + } + (_, zed::Architecture::X86) => { + return Err( + "Unsupported architecture: x86 (32-bit). NetCoreDbg only supports 64-bit architectures (amd64/arm64).".to_string(), + ); + } + }; + + Ok(format!("netcoredbg-{}{}", platform_arch, extension)) + } + + /// Fetches the latest release information from GitHub + fn fetch_latest_release(&self) -> Result { + let release = zed::latest_github_release( + &format!("{}/{}", Self::GITHUB_OWNER, Self::GITHUB_REPO), + GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + ) + .map_err(|e| format!("Failed to fetch latest release: {}", e))?; + + let asset_name = Self::get_platform_asset_name()?; + + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| { + format!( + "No compatible asset found for platform. Looking for: '{}'. Available assets: [{}]", + asset_name, + release + .assets + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", ") + ) + })?; + + Ok(AdapterVersion { + tag_name: release.version, + download_url: asset.download_url.clone(), + }) + } + + /// Creates a temporary directory for extraction + fn create_temp_dir(&self, version: &str) -> Result { + SimpleTempDir::new(&format!("netcoredbg_v{}_", version)) + } + + /// Downloads and extracts the netcoredbg binary, returning the path to the executable + fn download_and_extract_binary(&self) -> Result { + let version = self.fetch_latest_release()?; + let asset_name = Self::get_platform_asset_name()?; + + let file_type = if asset_name.ends_with(".zip") { + DownloadedFileType::Zip + } else if asset_name.ends_with(".tar.gz") { + DownloadedFileType::GzipTar + } else { + return Err(format!("Unsupported file type for asset: {}", asset_name)); + }; + + // Version-specific directory in current working directory + let version_dir = std::path::PathBuf::from(format!("netcoredbg_v{}", version.tag_name)); + + let temp_dir = self.create_temp_dir(&version.tag_name)?; + zed::download_file( + &version.download_url, + &temp_dir.path().to_string_lossy(), + file_type, + ) + .map_err(|e| format!("Failed to download netcoredbg: {}", e))?; + + std::fs::create_dir_all(&version_dir) + .map_err(|e| format!("Failed to create version directory: {}", e))?; + + self.copy_extracted_content(temp_dir.path(), &version_dir)?; + + let exe_name = Self::get_executable_name(); + + let binary_path = version_dir.join(exe_name); + + if !binary_path.exists() { + return Err(format!( + "netcoredbg executable not found at: {}", + binary_path.display() + )); + } + + zed::make_file_executable(&binary_path.to_string_lossy()) + .map_err(|e| format!("Failed to make file executable: {}", e))?; + + let current_dir = std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))?; + let absolute_path = current_dir.join(&binary_path); + Ok(absolute_path.to_string_lossy().to_string()) + } + + /// Copies extracted content from temp_dir into version_dir, handling nested directory structure + fn copy_extracted_content( + &self, + temp_dir: &std::path::Path, + version_dir: &std::path::Path, + ) -> Result<(), String> { + let exe_name = Self::get_executable_name(); + + let binary_source_path = self.find_binary_in_extracted_content(temp_dir, exe_name)?; + + let source_dir = binary_source_path + .parent() + .ok_or_else(|| "Binary has no parent directory".to_string())?; + + let copy_options = dir::CopyOptions::new().content_only(true); + + dir::copy(source_dir, version_dir, ©_options).map_err(|e| { + format!( + "Failed to copy extracted content from {}: {}", + source_dir.display(), + e + ) + })?; + + Ok(()) + } + + /// Recursively searches for the netcoredbg binary in the extracted content + fn find_binary_in_extracted_content( + &self, + search_dir: &std::path::Path, + exe_name: &str, + ) -> Result { + fn find_binary_recursive( + dir: &std::path::Path, + exe_name: &str, + ) -> Result, String> { + let entries = std::fs::read_dir(dir) + .map_err(|e| format!("Failed to read directory {}: {}", dir.display(), e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if path.is_file() && path.file_name().is_some_and(|name| name == exe_name) { + return Ok(Some(path)); + } else if path.is_dir() { + if let Some(found) = find_binary_recursive(&path, exe_name)? { + return Ok(Some(found)); + } + } + } + Ok(None) + } + + find_binary_recursive(search_dir, exe_name)?.ok_or_else(|| { + format!( + "Could not find {} binary in extracted content at {}", + exe_name, + search_dir.display() + ) + }) + } + + /// Gets the netcoredbg binary path, downloading if necessary + pub fn get_binary_path(&self, user_provided_path: Option) -> Result { + // Priority 1: User-provided path return as is without any validation + if let Some(user_path) = user_provided_path { + return Ok(user_path); + } + + // Priority 2: Check in-memory cache + if let Some(cached_path) = self.cached_binary_path.get() { + if std::path::Path::new(cached_path).exists() { + return Ok(cached_path.clone()); + } + } + + // Priority 3: Check existing binary on disk before downloading + let version = self.fetch_latest_release()?; + + // Version-specific directory in current working directory + let version_dir = std::path::PathBuf::from(format!("netcoredbg_v{}", version.tag_name)); + let exe_name = Self::get_executable_name(); + let existing_binary_path = version_dir.join(exe_name); + if existing_binary_path.exists() { + let current_dir = std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))?; + let absolute_path = current_dir.join(&existing_binary_path); + let path_str = absolute_path.to_string_lossy().to_string(); + let _ = self.cached_binary_path.set(path_str.clone()); + return Ok(path_str); + } + + // Priority 4: Download and extract from GitHub releases + let binary_path = self.download_and_extract_binary()?; + + let _ = self.cached_binary_path.set(binary_path.clone()); + + self.validate_binary(&binary_path)?; + + Ok(binary_path) + } + + /// Validates that the binary exists + fn validate_binary(&self, binary_path: &str) -> Result<(), String> { + let path = std::path::Path::new(binary_path); + + if !path.exists() { + return Err(format!("netcoredbg binary not found at: {}", binary_path)); + } + + if !path.is_file() { + return Err(format!("netcoredbg path is not a file: {}", binary_path)); + } + + Ok(()) + } +} diff --git a/src/csharp.rs b/src/csharp.rs index c39bc2c..d48f9fa 100644 --- a/src/csharp.rs +++ b/src/csharp.rs @@ -1,22 +1,66 @@ +mod binary_manager; mod language_servers; +mod simple_temp_dir; +use binary_manager::BinaryManager; use language_servers::Roslyn; -use zed_extension_api::{self as zed, Result}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, net::Ipv4Addr}; +use zed_extension_api::{ + self as zed, resolve_tcp_template, DebugAdapterBinary, DebugConfig, DebugRequest, + DebugScenario, DebugTaskDefinition, Result, StartDebuggingRequestArguments, + StartDebuggingRequestArgumentsRequest, TcpArgumentsTemplate, Worktree, +}; use crate::language_servers::Omnisharp; struct CsharpExtension { omnisharp: Option, roslyn: Option, + binary_manager: BinaryManager, } -impl CsharpExtension {} +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct NetCoreDbgDebugConfig { + pub request: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub program: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default)] + pub env: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stop_at_entry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub just_my_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_step_filtering: Option, +} + +/// Represents a process id that can be either an integer or a string (containing a number) +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(untagged)] +pub enum ProcessId { + Int(i32), + String(String), +} + +impl CsharpExtension { + const ADAPTER_NAME: &str = "netcoredbg"; + const LOCATOR_NAME: &str = "csharp-test-runner"; +} impl zed::Extension for CsharpExtension { fn new() -> Self { Self { omnisharp: None, roslyn: None, + binary_manager: BinaryManager::new(), } } @@ -55,6 +99,243 @@ impl zed::Extension for CsharpExtension { } Ok(None) } + + fn get_dap_binary( + &mut self, + adapter_name: String, + debug_task_definition: DebugTaskDefinition, + user_provided_debug_adapter_path: Option, + worktree: &Worktree, + ) -> Result { + if adapter_name != Self::ADAPTER_NAME { + return Err(format!("Cannot create binary for adapter: {adapter_name}")); + } + + let configuration = debug_task_definition.config.to_string(); + let parsed_config: NetCoreDbgDebugConfig = + zed::serde_json::from_str(&configuration).map_err(|e| { + format!( + "Failed to parse debug configuration: {}. Expected NetCoreDbg configuration format.", + e + ) + })?; + + let request = match parsed_config.request.as_str() { + "launch" => StartDebuggingRequestArgumentsRequest::Launch, + "attach" => StartDebuggingRequestArgumentsRequest::Attach, + other => { + return Err(format!( + "Invalid 'request' value: '{}'. Expected 'launch' or 'attach'", + other + )) + } + }; + + let tcp_connection = debug_task_definition + .tcp_connection + .unwrap_or(TcpArgumentsTemplate { + port: None, + host: None, + timeout: None, + }); + + if request == StartDebuggingRequestArgumentsRequest::Attach { + if parsed_config.process_id.is_none() { + return Err("Attach request missing 'processId'".to_string()); + } + } + + let binary_path = self + .binary_manager + .get_binary_path(user_provided_debug_adapter_path)?; + + let mut envs = parsed_config.env.clone(); + for (key, value) in worktree.shell_env() { + envs.insert(key, value); + } + + let mut args: Vec = vec!["--interpreter=vscode".into()]; + if tcp_connection.host.is_some() { + args.push("--server".into()); + } + + let connection = resolve_tcp_template(tcp_connection)?; + + Ok(DebugAdapterBinary { + command: Some(binary_path), + arguments: args, + envs: envs.into_iter().collect(), + cwd: Some(parsed_config.cwd.unwrap_or_else(|| worktree.root_path())), + connection: Some(connection), + request_args: StartDebuggingRequestArguments { + configuration, + request, + }, + }) + } + + fn dap_request_kind( + &mut self, + adapter_name: String, + config: zed::serde_json::Value, + ) -> Result { + if adapter_name != Self::ADAPTER_NAME { + return Err(format!("Unknown adapter: {}", adapter_name)); + } + + match config.get("request").and_then(|v| v.as_str()) { + Some("launch") => Ok(StartDebuggingRequestArgumentsRequest::Launch), + Some("attach") => { + if config.get("processId").is_none() + || config.get("processId").is_some_and(|v| v.is_null()) + { + return Err("Attach request missing 'processId'".to_string()); + } + Ok(StartDebuggingRequestArgumentsRequest::Attach) + } + Some(other) => Err(format!( + "Invalid 'request' value: '{}'. Expected 'launch' or 'attach'", + other + )), + None => Err("Missing 'request' field. Expected 'launch' or 'attach'".to_string()), + } + } + + fn dap_config_to_scenario(&mut self, config: DebugConfig) -> Result { + match config.request { + DebugRequest::Launch(launch) => { + let adapter_config = NetCoreDbgDebugConfig { + request: "launch".to_string(), + program: Some(launch.program), + args: if launch.args.is_empty() { + None + } else { + Some(launch.args) + }, + cwd: launch.cwd, + env: launch.envs.into_iter().collect(), + stop_at_entry: config.stop_on_entry, + process_id: None, + just_my_code: Some(false), + enable_step_filtering: Some(true), + }; + + let config_json = zed::serde_json::to_string(&adapter_config) + .map_err(|e| format!("Failed to serialize launch config: {}", e))?; + Ok(DebugScenario { + label: config.label, + adapter: config.adapter, + build: None, + config: config_json, + tcp_connection: None, + }) + } + DebugRequest::Attach(attach) => { + let process_id = attach.process_id.ok_or_else(|| { + "Attach mode requires a process ID. Please select a process from the attach modal.".to_string() + })?; + + let adapter_config = NetCoreDbgDebugConfig { + request: "attach".to_string(), + program: None, + args: None, + cwd: None, + env: HashMap::new(), + stop_at_entry: config.stop_on_entry, + process_id: Some(ProcessId::Int(process_id as i32)), + just_my_code: Some(false), + enable_step_filtering: Some(true), + }; + + let config_json = zed::serde_json::to_string(&adapter_config) + .map_err(|e| format!("Failed to serialize attach config: {}", e))?; + + Ok(DebugScenario { + label: config.label, + adapter: config.adapter, + build: None, + config: config_json, + tcp_connection: None, + }) + } + } + } + + fn dap_locator_create_scenario( + &mut self, + locator_name: String, + build_task: zed::TaskTemplate, + resolved_label: String, + debug_adapter_name: String, + ) -> Option { + if debug_adapter_name != Self::ADAPTER_NAME || locator_name != Self::LOCATOR_NAME { + return None; + } + + let mut args_iter = build_task.args.iter(); + let subcommand = args_iter.next()?; + if subcommand != "test" { + return None; + } + + if build_task.command != "dotnet" && !build_task.command.starts_with("$ZED_") { + return None; + } + + let file_dir = env_value(&build_task.env, "CSHARP_TEST_FILE_DIR") + .or_else(|| build_task.cwd.clone()) + .unwrap_or_else(|| "ZED_DIRNAME".to_string()); + + let mut env: Vec<(String, String)> = build_task.env.into(); + + // TODO: Currently we halt the test process to wait for attach. + // Zed extension API does not currently allow parallel process or process inspection to find what the build template PID is + env.push(("VSTEST_HOST_DEBUG".to_string(), "1".to_string())); + + let args = vec!["test".into()]; + + let template = zed::BuildTaskTemplate { + label: "dotnet test (debug)".into(), + command: "dotnet".into(), + cwd: Some(file_dir), + args, + env, + }; + + let build_template = + zed::BuildTaskDefinition::Template(zed::BuildTaskDefinitionTemplatePayload { + template, + locator_name: Some(locator_name), + }); + + Some(DebugScenario { + adapter: debug_adapter_name, + label: resolved_label, + build: Some(build_template), + // No config, this will trigger `run_dap_locator` so we can do the build step first + config: "{}".into(), + tcp_connection: None, + }) + } + + fn run_dap_locator( + &mut self, + locator_name: String, + _build_task: zed::TaskTemplate, + ) -> Result { + if locator_name != Self::LOCATOR_NAME { + return Err(format!("Unknown locator: {locator_name}")); + } + + // TODO: Currently this fails, but the user can read from debug outputs for the waiting build PID + Ok(DebugRequest::Attach(zed::AttachRequest { + process_id: None, + })) + } } zed::register_extension!(CsharpExtension); + +fn env_value(env: &Vec<(String, String)>, key: &str) -> Option { + env.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()) +} diff --git a/src/simple_temp_dir.rs b/src/simple_temp_dir.rs new file mode 100644 index 0000000..3aaf369 --- /dev/null +++ b/src/simple_temp_dir.rs @@ -0,0 +1,32 @@ +pub struct SimpleTempDir { + path: std::path::PathBuf, +} + +impl SimpleTempDir { + pub fn new(prefix: &str) -> Result { + let temp_base = std::env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("Failed to get timestamp: {}", e))? + .as_nanos(); + + let dir_name = format!("{}{}", prefix, timestamp); + let temp_path = temp_base.join(dir_name); + + std::fs::create_dir_all(&temp_path) + .map_err(|e| format!("Failed to create temp directory: {}", e))?; + + Ok(SimpleTempDir { path: temp_path }) + } + + pub fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl Drop for SimpleTempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +}