diff --git a/crates/ide-tools/src/archive.rs b/crates/ide-tools/src/archive.rs index f9dd56acb..539bb29f1 100644 --- a/crates/ide-tools/src/archive.rs +++ b/crates/ide-tools/src/archive.rs @@ -177,6 +177,27 @@ pub fn locate(root: &Path, names: &[String]) -> Option { /// Creates `bin_dir/` 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 { + 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 { fs::create_dir_all(bin_dir)?; #[cfg(unix)] diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 39442da0c..d09ed65b4 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -120,7 +120,9 @@ fn bin_dirs_in(root: &Path, platform: Platform) -> Vec { fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option { 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/-/` + 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 [/shared/Microsoft.NETCore.App]`. + fn dotnet_root(&self, dotnet: &Path) -> Option { + 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)?; diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index faaa72dc0..bd25e806f 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -44,6 +44,11 @@ pub enum Method { /// A pure-Python package unpacked into `//lib` (`pip install --target`, /// universal wheel), put on `PYTHONPATH` of whichever interpreter runs it. PipTarget { package: &'static str, module: &'static str }, + /// `gem install` into `//gems`, launched through `GEM_HOME` wrappers in `bin/`. + Gem { gem: &'static str }, + /// `dotnet tool install --tool-path //tool `, launched + /// through `DOTNET_ROOT` wrappers in `bin/`. + DotnetTool { package: &'static str }, /// Platform archive download into `//`. 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. diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 51f96c88a..2edf2507e 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -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 --bindir 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"); +}