feat(tools): Install 15 more language servers and debug adapters

Release downloads: buf, clojure-lsp, texlab, neocmakelsp, LemMinX, Perl
Navigator, kotlin-language-server, kotlin-debug-adapter and netcoredbg;
Open VSX: bash-debug and php-debug (run through node). Two new methods:
`gem` (ruby-lsp, solargraph), installed into the tool's folder and
launched with GEM_HOME, and `dotnet tool` (csharp-ls, fsautocomplete),
launched with DOTNET_ROOT so a Homebrew / user-local .NET is found.
Tools declare the runtime they need (Java, Node.js, Perl).

Each was installed from its real source and started on macOS arm64.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
l0ng-ai committed 2026-10-24 11:26:31 +08:00
1 джерело fde075457b
коміт c3ea376899
4 files changed
+429 -7

No files matched your search

+21
Переглянути файл
@@ -177,6 +177,27 @@ pub fn locate(root: &Path, names: &[String]) -> Option<PathBuf> {
/// Creates `bin_dir/<command>` that runs `target`: a shell wrapper on Unix
/// (keeps the target's own directory layout intact), a `.cmd` shim on Windows.
/// Like [`link_executable`], with `env` set for `target`.
pub fn env_wrapper(bin_dir: &Path, command: &str, target: &Path, env: &[(&str, &Path)]) -> IdeResult<PathBuf> {
fs::create_dir_all(bin_dir)?;
#[cfg(unix)]
{
let quote = |p: &Path| format!("'{}'", p.to_string_lossy().replace('\'', r"'\''"));
let vars: String = env.iter().map(|(k, v)| format!("{k}={} ", quote(v))).collect();
let link = bin_dir.join(command);
fs::write(&link, format!("#!/bin/sh\n{vars}exec {} \"$@\"\n", quote(target)))?;
make_executable(&link)?;
Ok(link)
}
#[cfg(not(unix))]
{
let link = bin_dir.join(format!("{command}.cmd"));
let vars: String = env.iter().map(|(k, v)| format!("set \"{k}={}\"\r\n", v.display())).collect();
fs::write(&link, format!("@echo off\r\nsetlocal\r\n{vars}\"{}\" %*\r\n", target.display()))?;
Ok(link)
}
}
pub fn link_executable(bin_dir: &Path, command: &str, target: &Path) -> IdeResult<PathBuf> {
fs::create_dir_all(bin_dir)?;
#[cfg(unix)]
+105 -2
Переглянути файл
@@ -120,7 +120,9 @@ fn bin_dirs_in(root: &Path, platform: Platform) -> Vec<PathBuf> {
fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<PathBuf> {
match spec.method {
Method::Npm { .. } => Some(root.join("node_modules").join(".bin")),
Method::Go { .. } | Method::Release { .. } => Some(root.join(spec.name).join("bin")),
Method::Go { .. } | Method::Gem { .. } | Method::DotnetTool { .. } | Method::Release { .. } => {
Some(root.join(spec.name).join("bin"))
}
Method::PipVenv { .. } => {
Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" }))
}
@@ -376,6 +378,10 @@ impl Tools {
"npm or pnpm is required: install Node.js (https://nodejs.org)",
),
Method::Go { .. } => need(self.which("go").is_some(), "Go is required (https://go.dev/dl)"),
Method::Gem { .. } => need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)"),
Method::DotnetTool { .. } => {
need(self.which("dotnet").is_some(), ".NET SDK is required (https://dotnet.microsoft.com/download)")
}
Method::PipVenv { .. } => need(
self.which("uv").is_some() || self.python().is_some(),
"Python 3 or uv is required (https://www.python.org)",
@@ -593,6 +599,8 @@ impl Tools {
}
Method::Npm { packages } => self.install_npm(packages, cancel, emit)?,
Method::Go { module } => self.install_go(spec, module, cancel, emit)?,
Method::Gem { gem } => self.install_gem(spec, gem, cancel, emit)?,
Method::DotnetTool { package } => self.install_dotnet_tool(spec, package, cancel, emit)?,
Method::PipVenv { package } => self.install_venv(spec, package, cancel, emit)?,
Method::PipUser { package, .. } => self.install_pip_user(package, cancel, emit)?,
Method::PipTarget { package, .. } => self.install_pip_target(spec, package, cancel, emit)?,
@@ -672,6 +680,96 @@ impl Tools {
self.write_manifest(spec.name, &Manifest { version, source: Some(module.to_string()) })
}
fn install_gem(&self, spec: &ToolSpec, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
let dir = self.root.join(spec.name);
let _ = std::fs::remove_dir_all(&dir);
let home = dir.join("gems");
std::fs::create_dir_all(&home)?;
let mut c = self.command(self.which("gem").expect("checked"));
c.args(["install", "--no-document", "--install-dir"]).arg(&home).arg("--bindir").arg(home.join("bin")).arg(gem);
let result = run_streamed(c, cancel, emit).and_then(|_| {
// RubyGems' stubs only find their gems through GEM_HOME / GEM_PATH.
let env = [("GEM_HOME", home.as_path()), ("GEM_PATH", home.as_path())];
for cmd in spec.commands {
let stub = self
.exe_in(&home.join("bin"), cmd)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("gem {gem} did not install `{cmd}`")))?;
archive::env_wrapper(&dir.join("bin"), cmd, &stub, &env)?;
}
Ok(())
});
if let Err(e) = result {
let _ = std::fs::remove_dir_all(&dir);
return Err(e);
}
// `gems/gems/<gem>-<version>/`
let version = std::fs::read_dir(home.join("gems")).ok().and_then(|entries| {
entries.flatten().find_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
name.strip_prefix(&format!("{gem}-"))
.filter(|v| v.starts_with(|c: char| c.is_ascii_digit()))
.map(String::from)
})
});
self.write_manifest(spec.name, &Manifest { version, source: Some(format!("rubygems:{gem}")) })
}
fn install_dotnet_tool(
&self,
spec: &ToolSpec,
package: &str,
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<()> {
let dir = self.root.join(spec.name);
let _ = std::fs::remove_dir_all(&dir);
let tool = dir.join("tool");
std::fs::create_dir_all(&tool)?;
let dotnet = self.which("dotnet").expect("checked");
let mut c = self.command(&dotnet);
c.args(["tool", "install", package, "--tool-path"]).arg(&tool);
let result = run_streamed(c, cancel, emit).and_then(|_| {
// The tool's apphost looks for .NET in the default location only; a
// Homebrew / user-local SDK must be named through DOTNET_ROOT.
let root = self.dotnet_root(&dotnet);
for cmd in spec.commands {
let exe = self
.exe_in(&tool, cmd)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("{package} did not install `{cmd}`")))?;
match &root {
Some(root) => archive::env_wrapper(&dir.join("bin"), cmd, &exe, &[("DOTNET_ROOT", root)])?,
None => archive::link_executable(&dir.join("bin"), cmd, &exe)?,
};
}
Ok(())
});
if let Err(e) = result {
let _ = std::fs::remove_dir_all(&dir);
return Err(e);
}
// `Package Id Version Commands` table.
let mut c = self.command(&dotnet);
c.args(["tool", "list", "--tool-path"]).arg(&tool);
let version = run_quiet(c, Duration::from_secs(20)).and_then(|(_, out)| {
out.lines().find_map(|l| {
let mut cols = l.split_whitespace();
cols.next().filter(|id| id.eq_ignore_ascii_case(package))?;
cols.next().map(String::from)
})
});
self.write_manifest(spec.name, &Manifest { version, source: Some(format!("nuget:{package}")) })
}
/// The .NET install `dotnet` runs from: `Microsoft.NETCore.App 9.0.1 [<root>/shared/Microsoft.NETCore.App]`.
fn dotnet_root(&self, dotnet: &Path) -> Option<PathBuf> {
let mut c = self.command(dotnet);
c.arg("--list-runtimes");
let (_, out) = run_quiet(c, Duration::from_secs(20))?;
let line = out.lines().find(|l| l.starts_with("Microsoft.NETCore.App "))?;
let shared = line.rsplit_once('[')?.1.trim_end().strip_suffix(']')?;
Path::new(shared).parent()?.parent().map(Path::to_path_buf)
}
fn install_venv(&self, spec: &ToolSpec, package: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
let dir = self.root.join(spec.name);
let _ = std::fs::remove_dir_all(&dir);
@@ -951,7 +1049,12 @@ impl Tools {
run_streamed(c, &cancel, &quiet)?;
}
}
Method::Go { .. } | Method::PipVenv { .. } | Method::PipTarget { .. } | Method::Release { .. } => {
Method::Go { .. }
| Method::Gem { .. }
| Method::DotnetTool { .. }
| Method::PipVenv { .. }
| Method::PipTarget { .. }
| Method::Release { .. } => {
let dir = self.root.join(spec.name);
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
+252
Переглянути файл
@@ -44,6 +44,11 @@ pub enum Method {
/// A pure-Python package unpacked into `<tools>/<name>/lib` (`pip install --target`,
/// universal wheel), put on `PYTHONPATH` of whichever interpreter runs it.
PipTarget { package: &'static str, module: &'static str },
/// `gem install` into `<tools>/<name>/gems`, launched through `GEM_HOME` wrappers in `bin/`.
Gem { gem: &'static str },
/// `dotnet tool install --tool-path <tools>/<name>/tool <package>`, launched
/// through `DOTNET_ROOT` wrappers in `bin/`.
DotnetTool { package: &'static str },
/// Platform archive download into `<tools>/<name>/`.
Release { source: ReleaseSource, assets: &'static [AssetRule], locate: &'static [&'static str] },
}
@@ -54,6 +59,8 @@ impl Method {
Method::Rustup { .. } => "rustup",
Method::Npm { .. } => "npm",
Method::Go { .. } => "go install",
Method::Gem { .. } => "gem",
Method::DotnetTool { .. } => "dotnet tool",
Method::PipVenv { .. } => "pip (virtualenv)",
Method::PipUser { .. } => "pip --user",
Method::PipTarget { .. } => "pip (private copy)",
@@ -96,6 +103,8 @@ use Os::{Linux, Mac, Windows};
use ToolKind::{Debugger, LanguageServer, Plugin};
const V: Option<(&str, &[&str])> = None;
const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")];
const NODE: &[(&str, &str)] = &[("node", "Node.js is required (https://nodejs.org)")];
const ELIXIR: &[(&str, &str)] = &[("elixir", "Elixir is required (https://elixir-lang.org/install.html)")];
const ELIXIR_LS_ASSETS: &[AssetRule] =
&[any_arch(Mac, "elixir-ls-v*.zip"), any_arch(Linux, "elixir-ls-v*.zip"), any_arch(Windows, "elixir-ls-v*.zip")];
@@ -565,6 +574,249 @@ pub static TOOLS: &[ToolSpec] = &[
homepage: "https://github.com/elixir-lsp/elixir-ls",
needs: ELIXIR,
},
ToolSpec {
name: "buf",
display: "Buf (Protocol Buffers)",
kind: LanguageServer,
commands: &["buf"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "bufbuild/buf" },
assets: &[
rule(Mac, Arm64, "buf-Darwin-arm64.tar.gz"),
rule(Mac, X64, "buf-Darwin-x86_64.tar.gz"),
rule(Linux, X64, "buf-Linux-x86_64.tar.gz"),
rule(Linux, Arm64, "buf-Linux-aarch64.tar.gz"),
rule(Windows, X64, "buf-Windows-x86_64.zip"),
rule(Windows, Arm64, "buf-Windows-arm64.zip"),
],
locate: &["buf"],
},
version: ver("buf"),
homepage: "https://buf.build",
needs: &[],
},
ToolSpec {
name: "clojure-lsp",
display: "clojure-lsp",
kind: LanguageServer,
commands: &["clojure-lsp"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "clojure-lsp/clojure-lsp" },
assets: &[
rule(Mac, Arm64, "clojure-lsp-native-macos-aarch64.zip"),
rule(Mac, X64, "clojure-lsp-native-macos-amd64.zip"),
rule(Linux, X64, "clojure-lsp-native-linux-amd64.zip"),
rule(Linux, Arm64, "clojure-lsp-native-linux-aarch64.zip"),
rule(Windows, X64, "clojure-lsp-native-windows-amd64.zip"),
],
locate: &["clojure-lsp"],
},
version: V,
homepage: "https://clojure-lsp.io",
needs: &[],
},
ToolSpec {
name: "texlab",
display: "TexLab (LaTeX)",
kind: LanguageServer,
commands: &["texlab"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "latex-lsp/texlab" },
assets: &[
rule(Mac, Arm64, "texlab-aarch64-macos.tar.gz"),
rule(Mac, X64, "texlab-x86_64-macos.tar.gz"),
rule(Linux, X64, "texlab-x86_64-linux.tar.gz"),
rule(Linux, Arm64, "texlab-aarch64-linux.tar.gz"),
rule(Windows, X64, "texlab-x86_64-windows.zip"),
rule(Windows, Arm64, "texlab-aarch64-windows.zip"),
],
locate: &["texlab"],
},
version: ver("texlab"),
homepage: "https://github.com/latex-lsp/texlab",
needs: &[],
},
ToolSpec {
name: "neocmakelsp",
display: "neocmakelsp (CMake)",
kind: LanguageServer,
commands: &["neocmakelsp"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "Decodetalkers/neocmakelsp" },
assets: &[
any_arch(Mac, "neocmakelsp-universal-apple-darwin.tar.gz"),
rule(Linux, X64, "neocmakelsp-x86_64-unknown-linux-gnu.tar.gz"),
rule(Linux, Arm64, "neocmakelsp-aarch64-unknown-linux-gnu.tar.gz"),
rule(Windows, X64, "neocmakelsp-x86_64-pc-windows-msvc.zip"),
rule(Windows, Arm64, "neocmakelsp-aarch64-pc-windows-msvc.zip"),
],
locate: &["neocmakelsp"],
},
version: ver("neocmakelsp"),
homepage: "https://github.com/Decodetalkers/neocmakelsp",
needs: &[],
},
ToolSpec {
name: "lemminx",
display: "LemMinX (XML)",
kind: LanguageServer,
commands: &["lemminx"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "redhat-developer/vscode-xml" },
assets: &[
rule(Mac, Arm64, "lemminx-osx-aarch_64.zip"),
rule(Mac, X64, "lemminx-osx-x86_64.zip"),
rule(Linux, X64, "lemminx-linux-x86_64.zip"),
rule(Linux, Arm64, "lemminx-linux-aarch_64.zip"),
rule(Windows, X64, "lemminx-win32.zip"),
],
locate: &["lemminx-*"],
},
version: V,
homepage: "https://github.com/eclipse/lemminx",
needs: &[],
},
ToolSpec {
name: "perlnavigator",
display: "Perl Navigator",
kind: LanguageServer,
commands: &["perlnavigator"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "bscan/PerlNavigator" },
assets: &[
any_arch(Mac, "perlnavigator-macos-x86_64.zip"),
rule(Linux, X64, "perlnavigator-linux-x86_64.zip"),
rule(Windows, X64, "perlnavigator-win-x86_64.zip"),
],
locate: &["perlnavigator"],
},
version: V,
homepage: "https://github.com/bscan/PerlNavigator",
needs: &[("perl", "Perl is required (https://www.perl.org/get.html)")],
},
ToolSpec {
name: "kotlin-language-server",
display: "Kotlin Language Server (fwcd)",
kind: LanguageServer,
commands: &["kotlin-language-server"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "fwcd/kotlin-language-server" },
assets: &[any_arch(Mac, "server.zip"), any_arch(Linux, "server.zip"), any_arch(Windows, "server.zip")],
locate: &["kotlin-language-server"],
},
version: V,
homepage: "https://github.com/fwcd/kotlin-language-server",
needs: JAVA,
},
ToolSpec {
name: "kotlin-debug-adapter",
display: "Kotlin Debug Adapter",
kind: Debugger,
commands: &["kotlin-debug-adapter"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "fwcd/kotlin-debug-adapter" },
assets: &[any_arch(Mac, "adapter.zip"), any_arch(Linux, "adapter.zip"), any_arch(Windows, "adapter.zip")],
locate: &["kotlin-debug-adapter"],
},
version: V,
homepage: "https://github.com/fwcd/kotlin-debug-adapter",
needs: JAVA,
},
ToolSpec {
name: "netcoredbg",
display: "netcoredbg (.NET debugger)",
kind: Debugger,
commands: &["netcoredbg"],
method: Method::Release {
source: ReleaseSource::GitHub { repo: "Samsung/netcoredbg" },
assets: &[
rule(Mac, Arm64, "netcoredbg-osx-arm64.zip"),
rule(Linux, X64, "netcoredbg-linux-amd64.tar.gz"),
rule(Linux, Arm64, "netcoredbg-linux-arm64.tar.gz"),
rule(Windows, X64, "netcoredbg-win64.zip"),
],
locate: &["netcoredbg"],
},
version: V,
homepage: "https://github.com/Samsung/netcoredbg",
needs: &[],
},
ToolSpec {
name: "bash-debug",
display: "Bash Debug",
kind: Debugger,
commands: &["bash-debug-adapter"],
method: Method::Release {
source: ReleaseSource::OpenVsx { namespace: "rogalmic", extension: "bash-debug" },
assets: &[
any_arch(Mac, "rogalmic.bash-debug-*.vsix"),
any_arch(Linux, "rogalmic.bash-debug-*.vsix"),
any_arch(Windows, "rogalmic.bash-debug-*.vsix"),
],
locate: &["bashDebug.js"],
},
version: V,
homepage: "https://github.com/rogalmic/vscode-bash-debug",
needs: NODE,
},
ToolSpec {
name: "php-debug",
display: "PHP Debug (Xdebug)",
kind: Debugger,
commands: &["php-debug-adapter"],
method: Method::Release {
source: ReleaseSource::OpenVsx { namespace: "xdebug", extension: "php-debug" },
assets: &[
any_arch(Mac, "xdebug.php-debug-*.vsix"),
any_arch(Linux, "xdebug.php-debug-*.vsix"),
any_arch(Windows, "xdebug.php-debug-*.vsix"),
],
locate: &["phpDebug.js"],
},
version: V,
homepage: "https://github.com/xdebug/vscode-php-debug",
needs: NODE,
},
ToolSpec {
name: "ruby-lsp",
display: "Ruby LSP (Shopify)",
kind: LanguageServer,
commands: &["ruby-lsp"],
method: Method::Gem { gem: "ruby-lsp" },
version: V,
homepage: "https://shopify.github.io/ruby-lsp",
needs: &[],
},
ToolSpec {
name: "solargraph",
display: "Solargraph (Ruby)",
kind: LanguageServer,
commands: &["solargraph"],
method: Method::Gem { gem: "solargraph" },
version: ver("solargraph"),
homepage: "https://solargraph.org",
needs: &[],
},
ToolSpec {
name: "csharp-ls",
display: "csharp-ls",
kind: LanguageServer,
commands: &["csharp-ls"],
method: Method::DotnetTool { package: "csharp-ls" },
version: V,
homepage: "https://github.com/razzmatazz/csharp-language-server",
needs: &[],
},
ToolSpec {
name: "fsautocomplete",
display: "FsAutoComplete (F#)",
kind: LanguageServer,
commands: &["fsautocomplete"],
method: Method::DotnetTool { package: "fsautocomplete" },
version: V,
homepage: "https://github.com/ionide/FsAutoComplete",
needs: &[],
},
];
/// Plugin tools and the tool that loads them.
+51 -5
Переглянути файл
@@ -68,10 +68,12 @@ fn everything_missing_on_an_empty_system() {
assert!(vtsls.languages.iter().any(|l| l == "TypeScript"));
assert!(vtsls.unavailable_reason.as_deref().unwrap().contains("npm"));
// Servers without an installer are listed with their manual hint.
let ruby = find(&list, "solargraph");
assert_eq!(ruby.install_method, None);
assert!(!ruby.can_install);
assert_eq!(ruby.status, ToolStatus::Missing);
let hls = find(&list, "haskell-language-server");
assert_eq!(hls.install_method, None);
assert!(!hls.can_install);
assert_eq!(hls.status, ToolStatus::Missing);
// Gem tools need Ruby.
assert!(find(&list, "solargraph").unavailable_reason.as_deref().unwrap().contains("Ruby"));
// Shared npm package: each server is its own row.
assert_eq!(find(&list, "vscode-css-language-server").install_method.as_deref(), Some("npm"));
assert!(find(&list, "delve").kind == ToolKind::Debugger);
@@ -155,7 +157,7 @@ fn bin_dirs_cover_every_managed_layout() {
fn unknown_or_manual_tools_cannot_be_installed() {
let env = Env::new();
let tools = env.tools();
let err = tools.install("solargraph", &langs(), &CancellationToken::new(), &|_| {}).unwrap_err();
let err = tools.install("haskell-language-server", &langs(), &CancellationToken::new(), &|_| {}).unwrap_err();
assert_eq!(err.kind, ErrorKind::Unavailable);
assert!(err.message.contains("cannot be installed automatically"));
assert_eq!(tools.info("nope", &langs()).unwrap_err().kind, ErrorKind::NotFound);
@@ -336,3 +338,47 @@ fn elixir_ls_needs_elixir_and_lists_its_debugger() {
assert!(find(&list, "elixir-ls").can_install);
assert!(find(&list, "elixir-ls-debugger").can_install);
}
#[cfg(unix)]
#[test]
fn gem_install_wraps_stubs_with_gem_home() {
let env = Env::new();
// `gem install --no-document --install-dir <home> --bindir <bin> solargraph`
env.script(
&env.sys,
"gem",
r#"home="$4"; bin="$6"; /bin/mkdir -p "$home/gems/solargraph-0.50.0" "$bin"
printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/solargraph""#,
);
let tools = env.tools();
let info = tools.install("solargraph", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(info.status, ToolStatus::Installed);
assert_eq!(info.version.as_deref(), Some("0.50.0"));
let wrapper = env.root.join("solargraph/bin/solargraph");
assert_eq!(info.path.as_deref(), Some(wrapper.as_path()));
let out = std::process::Command::new(&wrapper).output().unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), env.root.join("solargraph/gems").to_string_lossy());
}
#[cfg(unix)]
#[test]
fn dotnet_tool_install_into_tool_path() {
let env = Env::new();
env.script(
&env.sys,
"dotnet",
r#"case "$2" in
install) /bin/mkdir -p "$5"; printf '#!/bin/sh\necho "$DOTNET_ROOT"\n' > "$5/csharp-ls"; /bin/chmod +x "$5/csharp-ls" ;;
list) printf 'Package Id Version Commands\n----\ncsharp-ls 0.15.0 csharp-ls\n' ;;
esac
[ "$1" = --list-runtimes ] && echo 'Microsoft.NETCore.App 9.0.1 [/opt/dotnet/shared/Microsoft.NETCore.App]'
exit 0"#,
);
let info = env.tools().install("csharp-ls", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(info.status, ToolStatus::Installed);
assert_eq!(info.version.as_deref(), Some("0.15.0"));
let wrapper = env.root.join("csharp-ls/bin/csharp-ls");
assert_eq!(info.path.as_deref(), Some(wrapper.as_path()));
let out = std::process::Command::new(&wrapper).output().unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "/opt/dotnet");
}