From 7326557682c43729049e8193ac5e5ca41cf72af5 Mon Sep 17 00:00:00 2001 From: Roberto Lozada Date: Wed, 29 Jul 2026 21:43:33 -0500 Subject: [PATCH] Add netcoredbg debug adapter support --- README.md | 57 +++++ debug_adapter_schemas/netcoredbg.json | 81 +++++++ extension.toml | 25 ++ languages/csharp/config.toml | 1 + languages/csharp/debugger.scm | 73 ++++++ languages/csharp/runnables.scm | 35 +++ languages/csharp/tasks.json | 25 ++ src/csharp.rs | 68 +++++- src/debuggers/mod.rs | 3 + src/debuggers/netcoredbg.rs | 323 ++++++++++++++++++++++++++ src/language_servers/util.rs | 4 +- 11 files changed, 692 insertions(+), 3 deletions(-) create mode 100644 debug_adapter_schemas/netcoredbg.json create mode 100644 languages/csharp/debugger.scm create mode 100644 languages/csharp/runnables.scm create mode 100644 languages/csharp/tasks.json create mode 100644 src/debuggers/mod.rs create mode 100644 src/debuggers/netcoredbg.rs diff --git a/README.md b/README.md index c87fdc0..2cdfc66 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,63 @@ A [C#](https://learn.microsoft.com/en-us/dotnet/csharp/) extension for [Zed](https://zed.dev). +## Debugging + +The extension provides the [netcoredbg](https://github.com/Samsung/netcoredbg) +debug adapter. The adapter binary is downloaded automatically on first use; to +use your own build instead: + +```jsonc +{ + "dap": { + "netcoredbg": { + "binary": "/usr/local/bin/netcoredbg" + } + } +} +``` + +Create `.zed/debug.json` in your project for explicit configurations: + +```jsonc +[ + { + "label": "Debug console app", + "adapter": "netcoredbg", + "request": "launch", + "program": "$ZED_WORKTREE_ROOT/bin/Debug/net10.0/App.dll", + "cwd": "$ZED_WORKTREE_ROOT", + "stopAtEntry": false, + "justMyCode": true + }, + { + "label": "Attach to process", + "adapter": "netcoredbg", + "request": "attach", + "processId": 0 + } +] +``` + +Starting a debug session from a `dotnet run` task uses the bundled `dotnet` +locator: it runs the build first, asks MSBuild for the produced assembly with +`dotnet msbuild -getProperty:TargetPath`, and launches netcoredbg against it. + +### Debugging a single test + +The locator deliberately does not handle `dotnet test`: the test assembly is a +library driven by VSTest, and launching it directly exits without running +anything. Instead, run the **"dotnet test $ZED_SYMBOL (wait for debugger)"** +task from the gutter button on any test. The test host prints its process id +and waits; start an `attach` configuration with that id and breakpoints inside +the test are hit normally. + +## Runnables + +Test methods (`[Fact]`, `[Theory]`, `[Test]`, `[TestMethod]`, `[TestCase]`), +test classes (including xUnit classes, which carry no class-level attribute), +and `Main` methods get a run button in the gutter. + ## Development To develop this extension, see the [Developing Extensions](https://zed.dev/docs/extensions/developing-extensions) section of the Zed docs. diff --git a/debug_adapter_schemas/netcoredbg.json b/debug_adapter_schemas/netcoredbg.json new file mode 100644 index 0000000..507659c --- /dev/null +++ b/debug_adapter_schemas/netcoredbg.json @@ -0,0 +1,81 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "netcoredbg debug configuration", + "description": "Debug configuration for .NET applications, handled by netcoredbg.", + "oneOf": [ + { + "type": "object", + "required": ["request", "program"], + "properties": { + "request": { + "type": "string", + "const": "launch", + "description": "Start a new .NET process under the debugger." + }, + "program": { + "type": "string", + "description": "Absolute path to the managed assembly to debug, e.g. bin/Debug/net10.0/App.dll." + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Command-line arguments passed to the program." + }, + "cwd": { + "type": "string", + "description": "Working directory for the debuggee. Defaults to the worktree root." + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Environment variables set for the debuggee." + }, + "stopAtEntry": { + "type": "boolean", + "default": false, + "description": "Break as soon as the entry point is reached." + }, + "justMyCode": { + "type": "boolean", + "default": true, + "description": "Restrict stepping and breakpoints to code built from this solution." + }, + "enableStepFiltering": { + "type": "boolean", + "default": true, + "description": "Step over property getters, setters, and operators." + }, + "console": { + "type": "string", + "enum": ["internalConsole", "integratedTerminal", "externalTerminal"], + "default": "internalConsole", + "description": "Where the debuggee's stdio is attached." + } + }, + "additionalProperties": true + }, + { + "type": "object", + "required": ["request"], + "properties": { + "request": { + "type": "string", + "const": "attach", + "description": "Attach to an already running .NET process." + }, + "processId": { + "type": ["integer", "string"], + "description": "Process id to attach to." + }, + "justMyCode": { + "type": "boolean", + "default": true, + "description": "Restrict stepping and breakpoints to code built from this solution." + } + }, + "additionalProperties": true + } + ] +} diff --git a/extension.toml b/extension.toml index 5206594..2b82118 100644 --- a/extension.toml +++ b/extension.toml @@ -21,6 +21,31 @@ language = "CSharp" name = "csharp-ls" language = "CSharp" +[debug_adapters.netcoredbg] +schema_path = "debug_adapter_schemas/netcoredbg.json" + +# Turns a `dotnet run` task into a build-then-debug scenario. +[debug_locators.dotnet] + +# netcoredbg release archives; GitHub asset downloads redirect to +# objects.githubusercontent.com. +[[capabilities]] +kind = "download_file" +host = "github.com" +path = ["Samsung", "netcoredbg", "releases", "**"] + +[[capabilities]] +kind = "download_file" +host = "objects.githubusercontent.com" +path = ["**"] + +# Used by the `dotnet` debug locator to ask MSBuild where the build output +# landed (`dotnet msbuild -getProperty:TargetPath`). +[[capabilities]] +kind = "process:exec" +command = "dotnet" +args = ["**"] + [grammars.c_sharp] repository = "https://github.com/tree-sitter/tree-sitter-c-sharp" commit = "485f0bae0274ac9114797fc10db6f7034e4086e3" diff --git a/languages/csharp/config.toml b/languages/csharp/config.toml index 8f07b45..17ded9c 100644 --- a/languages/csharp/config.toml +++ b/languages/csharp/config.toml @@ -3,6 +3,7 @@ code_fence_block_name = "csharp" grammar = "c_sharp" path_suffixes = ["cs"] line_comments = ["// ", "/// "] +debuggers = ["netcoredbg"] autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, diff --git a/languages/csharp/debugger.scm b/languages/csharp/debugger.scm new file mode 100644 index 0000000..687b04a --- /dev/null +++ b/languages/csharp/debugger.scm @@ -0,0 +1,73 @@ +; Identifiers whose value is worth showing while a debug session is paused. +; +; The `#not-match?` guards drop PascalCase identifiers in positions where a +; type name is as likely as a variable, since evaluating a type produces noise +; rather than a value. + +(parameter + name: (identifier) @debug-variable) + +(variable_declarator + name: (identifier) @debug-variable) + +(declaration_expression + name: (identifier) @debug-variable) + +(catch_declaration + name: (identifier) @debug-variable) + +(foreach_statement + left: (identifier) @debug-variable) + +(assignment_expression + left: (identifier) @debug-variable) + +(assignment_expression + left: (member_access_expression) @debug-variable) + +(element_access_expression + expression: (identifier) @debug-variable) + +(member_access_expression + expression: (identifier) @debug-variable + (#not-match? @debug-variable "^[A-Z]")) + +(argument + (identifier) @debug-variable + (#not-match? @debug-variable "^[A-Z]")) + +(binary_expression + (identifier) @debug-variable + (#not-match? @debug-variable "^[A-Z]")) + +(prefix_unary_expression + (identifier) @debug-variable) + +(postfix_unary_expression + (identifier) @debug-variable) + +(return_statement + (identifier) @debug-variable) + +(interpolation + (identifier) @debug-variable) + +(if_statement + condition: (identifier) @debug-variable) + +(while_statement + condition: (identifier) @debug-variable) + +(switch_statement + value: (identifier) @debug-variable) + +(conditional_expression + condition: (identifier) @debug-variable) + +(await_expression + (identifier) @debug-variable) + +[ + (block) + (declaration_list) +] @debug-scope diff --git a/languages/csharp/runnables.scm b/languages/csharp/runnables.scm new file mode 100644 index 0000000..293d3fa --- /dev/null +++ b/languages/csharp/runnables.scm @@ -0,0 +1,35 @@ +; A test method: xUnit `[Fact]`/`[Theory]`, NUnit `[Test]`, MSTest `[TestMethod]`. +((method_declaration + (attribute_list + (attribute + name: (identifier) @_attribute)) + name: (_) @run) + (#match? @_attribute "^(Fact|Theory|Test|TestMethod|TestCase)$") + (#set! tag csharp-test)) + +; A test class declared as such: MSTest `[TestClass]`, NUnit `[TestFixture]`. +((class_declaration + (attribute_list + (attribute + name: (identifier) @_attribute)) + name: (_) @run) + (#match? @_attribute "^(TestClass|TestFixture)$") + (#set! tag csharp-test-class)) + +; A test class recognised by its contents. xUnit marks no class-level +; attribute, so the only way to spot one is that it holds a test method. +((class_declaration + name: (_) @run + body: (declaration_list + (method_declaration + (attribute_list + (attribute + name: (identifier) @_attribute))))) + (#match? @_attribute "^(Fact|Theory|Test|TestMethod|TestCase)$") + (#set! tag csharp-test-class)) + +; The entry point of an executable project. +((method_declaration + name: (_) @run) + (#eq? @run "Main") + (#set! tag csharp-main)) diff --git a/languages/csharp/tasks.json b/languages/csharp/tasks.json new file mode 100644 index 0000000..c8ebff5 --- /dev/null +++ b/languages/csharp/tasks.json @@ -0,0 +1,25 @@ +[ + { + "label": "dotnet test $ZED_SYMBOL", + "command": "dotnet", + "args": ["test", "--filter", "FullyQualifiedName~$ZED_SYMBOL"], + "cwd": "$ZED_WORKTREE_ROOT", + "tags": ["csharp-test", "csharp-test-class"] + }, + { + "label": "dotnet test $ZED_SYMBOL (wait for debugger)", + "command": "dotnet", + "args": ["test", "--filter", "FullyQualifiedName~$ZED_SYMBOL"], + "env": { "VSTEST_HOST_DEBUG": "1" }, + "cwd": "$ZED_WORKTREE_ROOT", + "reveal": "always", + "tags": ["csharp-test", "csharp-test-class"] + }, + { + "label": "dotnet run", + "command": "dotnet", + "args": ["run"], + "cwd": "$ZED_DIRNAME", + "tags": ["csharp-main"] + } +] diff --git a/src/csharp.rs b/src/csharp.rs index 3ee0e71..a659dc6 100644 --- a/src/csharp.rs +++ b/src/csharp.rs @@ -1,13 +1,19 @@ +mod debuggers; mod language_servers; -use zed_extension_api::{self as zed, Result}; +use zed_extension_api::{ + self as zed, DebugAdapterBinary, DebugConfig, DebugRequest, DebugScenario, DebugTaskDefinition, + Result, StartDebuggingRequestArgumentsRequest, TaskTemplate, +}; +use crate::debuggers::{netcoredbg, Netcoredbg}; use crate::language_servers::{CsharpLs, Omnisharp, Roslyn}; struct CsharpExtension { omnisharp: Option, roslyn: Option, csharp_ls: Option, + netcoredbg: Option, } impl CsharpExtension {} @@ -18,6 +24,7 @@ impl zed::Extension for CsharpExtension { omnisharp: None, roslyn: None, csharp_ls: None, + netcoredbg: None, } } @@ -61,6 +68,65 @@ impl zed::Extension for CsharpExtension { _ => Ok(None), } } + + fn get_dap_binary( + &mut self, + adapter_name: String, + config: DebugTaskDefinition, + user_provided_debug_adapter_path: Option, + _worktree: &zed::Worktree, + ) -> Result { + match adapter_name.as_str() { + Netcoredbg::ADAPTER_NAME => self + .netcoredbg + .get_or_insert_with(Netcoredbg::new) + .dap_binary(config, user_provided_debug_adapter_path), + adapter_name => Err(format!("unknown debug adapter: {adapter_name}")), + } + } + + fn dap_request_kind( + &mut self, + adapter_name: String, + config: zed::serde_json::Value, + ) -> Result { + match adapter_name.as_str() { + Netcoredbg::ADAPTER_NAME => netcoredbg::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() { + Netcoredbg::ADAPTER_NAME => Netcoredbg::config_to_scenario(config), + adapter_name => Err(format!("unknown debug adapter: {adapter_name}")), + } + } + + fn dap_locator_create_scenario( + &mut self, + locator_name: String, + build_task: TaskTemplate, + resolved_label: String, + debug_adapter_name: String, + ) -> Option { + if locator_name != netcoredbg::locator::NAME { + return None; + } + + netcoredbg::locator::create_scenario(build_task, resolved_label, debug_adapter_name) + } + + fn run_dap_locator( + &mut self, + locator_name: String, + build_task: TaskTemplate, + ) -> Result { + match locator_name.as_str() { + netcoredbg::locator::NAME => netcoredbg::locator::run(build_task), + locator_name => Err(format!("unknown debug locator: {locator_name}")), + } + } } zed::register_extension!(CsharpExtension); diff --git a/src/debuggers/mod.rs b/src/debuggers/mod.rs new file mode 100644 index 0000000..7d58b55 --- /dev/null +++ b/src/debuggers/mod.rs @@ -0,0 +1,3 @@ +pub mod netcoredbg; + +pub use netcoredbg::*; diff --git a/src/debuggers/netcoredbg.rs b/src/debuggers/netcoredbg.rs new file mode 100644 index 0000000..55bc8eb --- /dev/null +++ b/src/debuggers/netcoredbg.rs @@ -0,0 +1,323 @@ +//! Debug adapter support, backed by [netcoredbg]. +//! +//! netcoredbg speaks DAP when started with `--interpreter=vscode`, which is +//! what Zed's debugger expects. Releases are published as per-platform +//! archives on GitHub, so the adapter is downloaded on first use in the same +//! way the language servers are. +//! +//! [netcoredbg]: https://github.com/Samsung/netcoredbg + +use std::fs; + +use zed_extension_api::{ + self as zed, serde_json, + serde_json::{json, Value}, + DebugAdapterBinary, DebugConfig, DebugRequest, DebugScenario, DebugTaskDefinition, + LaunchRequest, Result, StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, + TaskTemplate, +}; + +use crate::language_servers::util; + +const REPOSITORY: &str = "Samsung/netcoredbg"; +/// Prefix of the directory each downloaded release is extracted into. +const INSTALL_PREFIX: &str = "netcoredbg"; + +pub struct Netcoredbg { + /// Path to an adapter binary that was already resolved this session. + cached_binary_path: Option, +} + +impl Netcoredbg { + pub const ADAPTER_NAME: &'static str = "netcoredbg"; + + pub fn new() -> Self { + Self { + cached_binary_path: None, + } + } + + pub fn dap_binary( + &mut self, + definition: DebugTaskDefinition, + user_provided_path: Option, + ) -> Result { + let configuration: Value = serde_json::from_str(&definition.config) + .map_err(|err| format!("invalid debug configuration: {err}"))?; + + let command = match user_provided_path { + Some(path) => path, + None => self.resolve_binary_path()?, + }; + + let cwd = configuration + .get("cwd") + .and_then(Value::as_str) + .map(str::to_owned); + + Ok(DebugAdapterBinary { + command: Some(command), + arguments: vec!["--interpreter=vscode".into()], + envs: Vec::new(), + cwd, + connection: None, + request_args: StartDebuggingRequestArguments { + request: request_kind(&configuration)?, + configuration: definition.config, + }, + }) + } + + /// Returns the path to a usable adapter binary, downloading one if needed. + fn resolve_binary_path(&mut self) -> Result { + if let Some(path) = self.cached_binary_path.as_ref() { + if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { + return Ok(path.clone()); + } + } + + let release = zed::latest_github_release( + REPOSITORY, + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let asset_name = asset_name()?; + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| { + format!( + "netcoredbg {} does not ship a '{asset_name}' build; \ + set `dap.netcoredbg.binary` to an adapter you built yourself", + release.version + ) + })?; + + let install_dir = format!("{INSTALL_PREFIX}-{}", release.version); + // Both archive kinds extract to a `netcoredbg/` directory. + let binary_path = format!("{install_dir}/netcoredbg/{}", binary_name()); + + if !fs::metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { + let file_type = if asset_name.ends_with(".zip") { + zed::DownloadedFileType::Zip + } else { + zed::DownloadedFileType::GzipTar + }; + + zed::download_file(&asset.download_url, &install_dir, file_type) + .map_err(|err| format!("failed to download netcoredbg: {err}"))?; + + util::remove_outdated_versions(INSTALL_PREFIX, &install_dir)?; + } + + zed::make_file_executable(&binary_path)?; + + let binary_path = util::absolute_path(&binary_path)?; + self.cached_binary_path = Some(binary_path.clone()); + + Ok(binary_path) + } + + /// Translates Zed's adapter-agnostic debug configuration into netcoredbg's + /// own launch/attach schema. + pub fn config_to_scenario(config: DebugConfig) -> Result { + let configuration = match config.request { + DebugRequest::Launch(launch) => json!({ + "request": "launch", + "program": launch.program, + "args": launch.args, + "cwd": launch.cwd, + "env": launch + .envs + .into_iter() + .map(|(key, value)| (key, Value::String(value))) + .collect::>(), + "stopAtEntry": config.stop_on_entry.unwrap_or(false), + "justMyCode": true, + }), + DebugRequest::Attach(attach) => json!({ + "request": "attach", + "processId": attach.process_id, + }), + }; + + Ok(DebugScenario { + label: config.label, + adapter: config.adapter, + build: None, + config: configuration.to_string(), + tcp_connection: None, + }) + } +} + +/// Reads the `request` field of a debug configuration. +pub fn request_kind(configuration: &Value) -> Result { + match configuration.get("request").and_then(Value::as_str) { + Some("launch") => Ok(StartDebuggingRequestArgumentsRequest::Launch), + Some("attach") => Ok(StartDebuggingRequestArgumentsRequest::Attach), + Some(other) => Err(format!( + "unsupported debug request '{other}'; expected \"launch\" or \"attach\"" + )), + None => Err("debug configuration is missing a \"request\" field".into()), + } +} + +/// Name of the release asset for the current platform. +fn asset_name() -> Result { + let name = match zed::current_platform() { + (zed::Os::Mac, zed::Architecture::Aarch64) => "netcoredbg-osx-arm64.zip", + (zed::Os::Mac, zed::Architecture::X8664) => "netcoredbg-osx-amd64.zip", + (zed::Os::Linux, zed::Architecture::Aarch64) => "netcoredbg-linux-arm64.tar.gz", + (zed::Os::Linux, zed::Architecture::X8664) => "netcoredbg-linux-amd64.tar.gz", + (zed::Os::Windows, _) => "netcoredbg-win64.zip", + (os, architecture) => { + return Err(format!( + "netcoredbg does not publish a build for {os:?} {architecture:?}" + )) + } + }; + + Ok(name.to_owned()) +} + +fn binary_name() -> &'static str { + match zed::current_platform().0 { + zed::Os::Windows => "netcoredbg.exe", + _ => "netcoredbg", + } +} + +/// The `dotnet` debug locator. +/// +/// A `dotnet run` task does not name the assembly Zed needs to hand to the +/// debugger, so the task is rewritten as a `dotnet build` and the assembly path +/// is recovered afterwards by asking MSBuild for `TargetPath`. +pub mod locator { + use super::*; + use zed_extension_api::{BuildTaskDefinition, BuildTaskDefinitionTemplatePayload}; + + pub const NAME: &str = "dotnet"; + + /// Verbs whose `TargetPath` is an assembly that can be launched directly. + /// + /// `test` is deliberately absent. MSBuild happily reports a `TargetPath` + /// for a test project, but that assembly is a library driven by VSTest — + /// handing it to the debugger produces a session that exits immediately + /// without running a single test. Debugging an individual test needs the + /// test host to be started under the debugger instead (see the + /// `VSTEST_HOST_DEBUG` task), which a locator cannot arrange. `watch` is + /// absent for the same reason: the process the debugger would need is a + /// child of `dotnet watch`, not the assembly MSBuild names. + const DEBUGGABLE_VERBS: &[&str] = &["run"]; + + pub fn create_scenario( + build_task: TaskTemplate, + resolved_label: String, + adapter_name: String, + ) -> Option { + if build_task.command != "dotnet" { + return None; + } + + let verb = build_task.args.first()?; + if !DEBUGGABLE_VERBS.contains(&verb.as_str()) { + return None; + } + + Some(DebugScenario { + label: resolved_label, + adapter: adapter_name, + build: Some(BuildTaskDefinition::Template( + BuildTaskDefinitionTemplatePayload { + locator_name: Some(NAME.to_owned()), + template: to_build_task(build_task), + }, + )), + config: String::new(), + tcp_connection: None, + }) + } + + pub fn run(build_task: TaskTemplate) -> Result { + let target = msbuild_target(&build_task) + .ok_or_else(|| "could not determine which project to debug".to_string())?; + + let output = zed::process::Command::new("dotnet") + .args([ + "msbuild", + target.as_str(), + "-getProperty:TargetPath", + "-nologo", + "-verbosity:quiet", + ]) + .output()?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + + if output.status != Some(0) { + // MSBuild reports failures such as MSB1003 on stdout and leaves + // stderr empty, so both streams have to be considered to produce a + // message the user can act on. + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let reason = [stderr, stdout] + .into_iter() + .find(|stream| !stream.is_empty()) + .unwrap_or_else(|| "no output".to_string()); + + return Err(format!( + "`dotnet msbuild {target} -getProperty:TargetPath` failed: {reason}" + )); + } + + if stdout.is_empty() { + return Err(format!("MSBuild reported no TargetPath for '{target}'")); + } + + Ok(DebugRequest::Launch(LaunchRequest { + program: stdout, + cwd: build_task.cwd, + args: Vec::new(), + envs: build_task.env, + })) + } + + /// Rewrites `dotnet ...` as `dotnet build ...`, dropping the + /// arguments that only make sense for the original verb. + fn to_build_task(mut template: TaskTemplate) -> TaskTemplate { + let mut args = vec!["build".to_owned()]; + + // Everything after `--` is passed to the program being run, not to the + // SDK, so it has no meaning for a build. + let sdk_args = template + .args + .iter() + .skip(1) + .take_while(|arg| arg.as_str() != "--"); + + args.extend(sdk_args.cloned()); + template.args = args; + template + } + + /// Picks the project or solution to query MSBuild about. + fn msbuild_target(build_task: &TaskTemplate) -> Option { + let explicit = build_task + .args + .iter() + .find(|arg| is_project_file(arg)) + .cloned(); + + explicit.or_else(|| build_task.cwd.clone()) + } + + fn is_project_file(arg: &str) -> bool { + [".csproj", ".fsproj", ".vbproj"] + .iter() + .any(|suffix| arg.ends_with(suffix)) + } +} diff --git a/src/language_servers/util.rs b/src/language_servers/util.rs index b9a294c..72bfebe 100644 --- a/src/language_servers/util.rs +++ b/src/language_servers/util.rs @@ -2,13 +2,13 @@ use std::fs; use zed_extension_api::Result; -pub(super) fn absolute_path(path: &str) -> Result { +pub(crate) fn absolute_path(path: &str) -> Result { let cwd = std::env::current_dir() .map_err(|e| format!("failed to resolve extension working directory: {e}"))?; Ok(cwd.join(path).to_string_lossy().into_owned()) } -pub(super) fn remove_outdated_versions( +pub(crate) fn remove_outdated_versions( language_server_id: &'static str, version_dir: &str, ) -> Result<()> {