diff --git a/Cargo.lock b/Cargo.lock index b73037d5..61acdb2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4380,7 +4380,6 @@ dependencies = [ "tokio", "toml 0.9.12+spec-1.1.0", "tracing", - "which", ] [[package]] @@ -4490,7 +4489,6 @@ dependencies = [ "tempfile", "toml 0.9.12+spec-1.1.0", "tracing", - "which", "zip", ] diff --git a/crates/ide-agent/src/registry.rs b/crates/ide-agent/src/registry.rs index 6de90d78..c9bef601 100644 --- a/crates/ide-agent/src/registry.rs +++ b/crates/ide-agent/src/registry.rs @@ -335,7 +335,7 @@ pub fn mark_updates(paths: &RegistryPaths, providers: &mut [AgentProvider]) { /// HTTPS only, redirects included. Tests serve downloads over `file://`. fn http() -> IdeResult { - let http = Http::new(&ide_tools::default_search_path())?; + let http = Http::new(&ide_core::tool_path::search_path())?; Ok(if cfg!(test) { http } else { http.https_only() }) } diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index 1b58ba68..fcb39a1f 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod remote_settings; pub mod settings; pub mod task; pub mod text; +pub mod tool_path; pub mod version_managers; pub mod workflow_settings; diff --git a/crates/ide-core/src/tool_path.rs b/crates/ide-core/src/tool_path.rs new file mode 100644 index 00000000..6abe16a4 --- /dev/null +++ b/crates/ide-core/src/tool_path.rs @@ -0,0 +1,193 @@ +//! Where the IDE looks up the executables it runs: language servers, the tools +//! their settings come from, installers. An app started from the desktop gets a +//! minimal `PATH`, and version managers set theirs up in shell rc files. + +use crate::process::{host_var_os, login_env}; +use crate::version_managers; +use std::collections::HashSet; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +/// Where executables are looked up: this process's `PATH` (the login shell's in the +/// app), the login shell's `PATH` as last read, then per-user directories and mise's, +/// asdf's and pyenv's shims, for when the shell's setup didn't reach the app. Language server +/// launchers, their settings hooks and tool detection all look here, so a settings +/// hook finds the executable its server is launched from. +pub fn search_path() -> OsString { + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from); + let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone())); + let shims: Vec = version_managers::managers().iter().map(|m| m.shims()).collect(); + let dirs = search_dirs(host_var_os("PATH").as_deref(), shell_path.as_deref(), home.as_deref(), &shims); + std::env::join_paths(dirs).unwrap_or_default() +} + +/// Resolves a command on the shared path, or an explicit path relative to `root`. +pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option { + let path = Path::new(command); + let explicit = path.parent().is_some_and(|p| !p.as_os_str().is_empty()); + let pathext = host_var_os("PATHEXT"); + let names = crate::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(OsStr::to_str)); + let dirs = if explicit { + vec![root.to_path_buf()] + } else { + std::env::split_paths(search).filter(|p| !p.as_os_str().is_empty()).map(|p| root.join(p)).collect() + }; + dirs.into_iter().find_map(|dir| names.iter().map(|name| dir.join(name)).find(|p| is_executable(p))) +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = path.metadata() else { return false }; + if !metadata.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + true +} + +/// [`search_path`]'s directories, in order and once each. Only absolute ones: a +/// relative entry (`.`, `node_modules/.bin`) resolves against the project, which +/// could then supply the tools the IDE runs. One that can't be joined (a `:` in a +/// directory name) is left out rather than failing the whole path. +fn search_dirs( + process_path: Option<&OsStr>, + shell_path: Option<&OsStr>, + home: Option<&Path>, + shims: &[PathBuf], +) -> Vec { + let mut dirs: Vec = process_path.into_iter().chain(shell_path).flat_map(std::env::split_paths).collect(); + if let Some(home) = home { + dirs.extend(USER_DIRS.iter().map(|d| home.join(d))); + dirs.extend(nvm_bins(&home.join(".nvm/versions/node"))); + } + // Version managers' shims after real tool directories: a shim exists for a command any + // installed version has, and fails when the selected one lacks it. Before the system's, + // as the managers' shell setup puts them. + dirs.extend(home.map(|h| h.join(".pyenv/shims"))); + dirs.extend(shims.iter().cloned()); + dirs.extend(SYSTEM_DIRS.iter().map(PathBuf::from)); + let mut seen = HashSet::new(); + dirs.retain(|d| d.is_absolute() && std::env::join_paths([d]).is_ok() && seen.insert(d.clone())); + dirs +} + +/// Tool directories below the home directory. +const USER_DIRS: &[&str] = &[ + ".cargo/bin", + ".local/bin", + "go/bin", + ".bun/bin", + ".deno/bin", + ".npm-global/bin", + ".volta/bin", + // Set up in shell rc files: fnm's default Node and pnpm's global bin (Linux, then macOS). + ".local/share/fnm/aliases/default/bin", + "Library/Application Support/fnm/aliases/default/bin", + ".fnm/aliases/default/bin", + ".local/share/pnpm/bin", + ".local/share/pnpm", + "Library/pnpm/bin", + "Library/pnpm", +]; + +const SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"]; + +/// The `bin` directories of nvm's Node versions in `dir`, newest first. +fn nvm_bins(dir: &Path) -> Vec { + let mut versions: Vec<(Vec, PathBuf)> = std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|e| (version_key(&e.file_name().to_string_lossy()), e.path().join("bin"))) + .collect(); + versions.sort(); + versions.into_iter().rev().map(|(_, bin)| bin).collect() +} + +/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9. +fn version_key(name: &str) -> Vec { + name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn commands_resolve_on_path_and_explicit_paths_under_root() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let name = if cfg!(windows) { "ngserver.cmd" } else { "ngserver" }; + std::fs::write(bin.join(name), "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let search = std::env::join_paths([&bin]).unwrap(); + assert_eq!(find_command("ngserver", &search, dir.path()), Some(bin.join(name))); + assert_eq!(find_command("bin/ngserver", &search, dir.path()), Some(bin.join(name))); + assert_eq!(find_command(bin.join(name).to_str().unwrap(), &search, dir.path()), Some(bin.join(name))); + assert_eq!(find_command("absent", &search, dir.path()), None); + for command in ["bin/ngserver", bin.join(name).to_str().unwrap()] { + assert_eq!(find_command(command, OsStr::new(""), dir.path()), Some(bin.join(name))); + } + } + + #[cfg(unix)] + #[test] + fn non_executable_files_do_not_shadow_commands() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let dirs = [tmp.path().join("first"), tmp.path().join("second")]; + for (dir, mode) in dirs.iter().zip([0o644, 0o755]) { + std::fs::create_dir(dir).unwrap(); + let file = dir.join("ngserver"); + std::fs::write(&file, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(file, std::fs::Permissions::from_mode(mode)).unwrap(); + } + let path = std::env::join_paths(&dirs).unwrap(); + assert_eq!(find_command("ngserver", &path, tmp.path()), Some(dirs[1].join("ngserver"))); + assert_eq!(find_command("first/ngserver", &path, tmp.path()), None); + } + + #[cfg(unix)] + #[test] + fn search_dirs_put_the_process_path_first_and_shims_after_real_tools() { + let shims = [PathBuf::from("/home/me/.local/share/mise/shims"), "/home/me/.asdf/shims".into()]; + let dirs = search_dirs( + Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")), + // nushell prints `$PATH` as is. + Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")), + Some(Path::new("/home/me")), + &shims, + ); + let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap(); + assert_eq!(dirs[..3], [PathBuf::from("/usr/bin"), "/opt/tools".into(), "/home/me/.asdf/shims".into()]); + assert!(at("/home/me/.npm-global/bin") < at("/home/me/.pyenv/shims")); + assert!(at("/home/me/.local/share/mise/shims") < at("/usr/local/bin")); + assert!(dirs.iter().all(|d| d.is_absolute())); + let unique: HashSet<&PathBuf> = dirs.iter().collect(); + assert_eq!(unique.len(), dirs.len()); + // A `:` can't be in a `PATH` entry: those below such a home are left out. + let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, Some(Path::new("/home/a:b")), &[]); + assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home"))); + } + + #[test] + fn nvm_versions_newest_first() { + let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id())); + for v in ["v9.11.2", "v20.9.0", "v22.1.0", "v20.10.0"] { + std::fs::create_dir_all(dir.join(v).join("bin")).unwrap(); + } + let bins = nvm_bins(&dir); + std::fs::remove_dir_all(&dir).unwrap(); + let names: Vec<_> = bins.iter().map(|b| b.parent().unwrap().file_name().unwrap().to_owned()).collect(); + assert_eq!(names, ["v22.1.0", "v20.10.0", "v20.9.0", "v9.11.2"]); + } +} diff --git a/crates/ide-lsp/Cargo.toml b/crates/ide-lsp/Cargo.toml index 317f9319..ed05a90b 100644 --- a/crates/ide-lsp/Cargo.toml +++ b/crates/ide-lsp/Cargo.toml @@ -21,7 +21,6 @@ futures.workspace = true globset.workspace = true toml.workspace = true percent-encoding = "2" -which = "8" [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 782df3b8..3343763d 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -1,11 +1,12 @@ //! Starting server processes: binary resolution and the [`Launcher`] seam. use ide_core::CancellationToken; +use ide_core::tool_path::search_path; use ide_jsonrpc::{Handler, Peer}; use ide_language::LanguageServerConfig; use std::ffi::OsString; use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; #[derive(Debug)] pub enum LaunchError { @@ -72,8 +73,7 @@ impl Launcher for ProcessLauncher { cancel: &CancellationToken, ) -> Result { let search = server_search_path(config); - let binary = which::which_in(&config.command, Some(&search), root) - .ok() + let binary = ide_core::tool_path::find_command(&config.command, &search, root) .and_then(|b| resolve_rustup_proxy(&config.command, b)); let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())]; env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone()))); @@ -95,7 +95,7 @@ impl Launcher for ProcessLauncher { } fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool { - which::which_in(&config.command, Some(server_search_path(config)), root).is_ok() + ide_core::tool_path::find_command(&config.command, &server_search_path(config), root).is_some() } } @@ -104,9 +104,6 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString { config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path) } -/// `PATH` plus the login shell's `PATH` and common tool directories, so -/// servers resolve even when the app is launched from a GUI shell with a -/// minimal environment. /// `~/.cargo/bin/` is usually a rustup proxy that fails when the active /// toolchain lacks the component (common for rust-analyzer). In that case use /// the newest installed toolchain that ships the binary; with none, the tool is @@ -153,74 +150,6 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option { } } -pub fn search_path() -> OsString { - static PATH: OnceLock = OnceLock::new(); - PATH.get_or_init(|| { - let mut dirs: Vec = - std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); - if let Some(shell_path) = login_shell_path() { - dirs.extend(std::env::split_paths(&shell_path)); - } - if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from) { - for rel in [".cargo/bin", ".local/bin", "go/bin", ".bun/bin", ".deno/bin", ".npm-global/bin", ".volta/bin"] - { - dirs.push(home.join(rel)); - } - // nvm: newest installed node's bin. - if let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) { - let mut versions: Vec = entries.flatten().map(|e| e.path().join("bin")).collect(); - versions.sort(); - dirs.extend(versions.into_iter().rev()); - } - } - for d in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] { - dirs.push(PathBuf::from(d)); - } - let mut seen = std::collections::HashSet::new(); - dirs.retain(|d| !d.as_os_str().is_empty() && seen.insert(d.clone())); - std::env::join_paths(dirs).unwrap_or_default() - }) - .clone() -} - -#[cfg(unix)] -fn login_shell_path() -> Option { - use std::io::Read; - use std::process::{Command, Stdio}; - use std::time::{Duration, Instant}; - let shell = std::env::var_os("SHELL")?; - const MARK: &str = "__OPENIDEA_PATH__"; - let mut child = ide_core::process::prepare(&mut Command::new(shell)) - .args(["-l", "-c", &format!("printf '{MARK}%s{MARK}' \"$PATH\"")]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; - let deadline = Instant::now() + Duration::from_secs(3); - loop { - match child.try_wait() { - Ok(Some(_)) => break, - Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), - _ => { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - } - } - let mut out = String::new(); - child.stdout.take()?.read_to_string(&mut out).ok()?; - let start = out.find(MARK)? + MARK.len(); - let end = start + out[start..].find(MARK)?; - Some(OsString::from(&out[start..end])) -} - -#[cfg(not(unix))] -fn login_shell_path() -> Option { - None -} - /// Workspace root for `file`: the outermost ancestor within `project_root` /// containing a root marker (so a Cargo/npm workspace gets one server, not one /// per member), else `project_root`. `None` when `file` is outside the project. @@ -258,6 +187,19 @@ mod tests { std::fs::remove_dir_all(&tmp).unwrap(); } + #[test] + fn marked_root_needs_a_marker() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let file = root.join("apps/web/src/app.ts"); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + let markers = vec!["angular.json".to_string(), "node_modules/@angular/core".to_string()]; + assert_eq!(marked_root(root, &file, &markers), None); + std::fs::create_dir_all(root.join("apps/web/node_modules/@angular/core")).unwrap(); + assert_eq!(marked_root(root, &file, &markers), Some(root.join("apps/web"))); + assert_eq!(marked_root(root, &root.join("apps/api/main.ts"), &markers), None); + } + #[test] fn search_path_contains_env_path() { let p = search_path(); diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 785a1d79..403a8219 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -24,7 +24,7 @@ mod uri; mod virtual_doc; pub use inspect::{FileInspection, InspectHost, InspectOptions}; -pub use launch::{LaunchError, Launcher, ProcessLauncher, search_path}; +pub use launch::{LaunchError, Launcher, ProcessLauncher}; pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES}; pub use uri::{is_virtual_uri, path_to_uri_string, uri_target, uri_to_path}; @@ -1921,9 +1921,22 @@ impl LspManager { /// Servers no longer enabled stop; newly enabled ones attach to open documents. pub async fn reconfigure(&self) { self.stop_unlisted().await; + let Some(me) = self.me.upgrade() else { return }; let servers: Vec> = self.servers.lock().clone(); for s in servers { - let next = self.configured(&s); + // The settings hook may run tools (`npm root -g`), as when the server starts. + let next = match tokio::task::spawn_blocking({ + let (me, s) = (me.clone(), s.clone()); + move || me.configured(&s) + }) + .await + { + Ok(next) => next, + Err(e) => { + tracing::warn!("{}: settings hook failed: {e}", s.name); + continue; + } + }; let current = s.effective_config(); if next == current { continue; diff --git a/crates/ide-sdk/Cargo.toml b/crates/ide-sdk/Cargo.toml index d4225ada..8d9a24e7 100644 --- a/crates/ide-sdk/Cargo.toml +++ b/crates/ide-sdk/Cargo.toml @@ -17,7 +17,6 @@ serde_json.workspace = true specta.workspace = true toml.workspace = true tracing.workspace = true -which = "8" zip.workspace = true [dev-dependencies] diff --git a/crates/ide-sdk/src/angular.rs b/crates/ide-sdk/src/angular.rs index d7566418..54c5591a 100644 --- a/crates/ide-sdk/src/angular.rs +++ b/crates/ide-sdk/src/angular.rs @@ -1,15 +1,18 @@ //! Angular Language Server (`ngserver`): its package, where TypeScript and //! `@angular/language-service` come from, and the project's Angular version. -use crate::npm::package_dir; +use crate::npm::{package_dir, shim_package}; +use std::collections::BTreeMap; +use std::ffi::OsStr; use std::path::{Path, PathBuf}; pub(crate) const SERVER: &str = "angular-language-server"; const PACKAGE: &str = "@angular/language-server"; -/// `@angular/language-server`'s directory for an `ngserver` executable. -pub(crate) fn server_package(bin: &Path) -> Option { - package_dir(bin, PACKAGE) +/// `@angular/language-server`'s directory for an `ngserver` executable found on +/// `path` and run in `cwd` with `env`. +pub(crate) fn server_package(bin: &Path, path: &OsStr, cwd: &Path, env: &BTreeMap) -> Option { + package_dir(bin, PACKAGE).or_else(|| shim_package(bin, PACKAGE, path, cwd, env)) } /// Why an `ngserver` whose package isn't found can't start: it loads TypeScript diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index acd00692..527e1924 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -519,8 +519,8 @@ fn configure_vue_typescript(cfg: &mut LanguageServerConfig, root: &Path, server_ fn configure_angular(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) { // Found as the launcher finds it; missing, it asks to be installed. let path = cfg.env.get("PATH").map_or_else(search_path, OsString::from); - let Ok(bin) = which::which_in(&cfg.command, Some(path), server_root) else { return }; - let Some(package) = angular::server_package(&bin) else { + let Some(bin) = ide_core::tool_path::find_command(&cfg.command, &path, server_root) else { return }; + let Some(package) = angular::server_package(&bin, &path, server_root, &cfg.env) else { cfg.launch_error = Some(angular::missing_package_message(&bin)); return; }; diff --git a/crates/ide-sdk/src/exec.rs b/crates/ide-sdk/src/exec.rs index 03333c50..aef10b05 100644 --- a/crates/ide-sdk/src/exec.rs +++ b/crates/ide-sdk/src/exec.rs @@ -3,35 +3,13 @@ use ide_core::output_encoding::decode_tool_output; use ide_core::{CancellationToken, IdeError, IdeResult}; -use std::ffi::OsString; use std::io::{BufRead, BufReader, Read}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -/// The process `PATH` (already the login shell's in the app) plus common tool dirs. -pub fn search_path() -> OsString { - let mut dirs: Vec = - std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); - if let Some(home) = crate::home_dir() { - for rel in [".cargo/bin", ".local/bin", "go/bin", ".pyenv/shims", ".bun/bin", ".npm-global/bin", ".volta/bin"] { - dirs.push(home.join(rel)); - } - // nvm: newest installed node's bin, as language servers are launched. - if let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) { - let mut versions: Vec = entries.flatten().map(|e| e.path().join("bin")).collect(); - versions.sort(); - dirs.extend(versions.into_iter().rev()); - } - } - for d in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] { - dirs.push(PathBuf::from(d)); - } - let mut seen = std::collections::HashSet::new(); - dirs.retain(|d| !d.as_os_str().is_empty() && seen.insert(d.clone())); - std::env::join_paths(dirs).unwrap_or_default() -} +pub(crate) use ide_core::tool_path::search_path; /// `dir` in front of the search path, as a `PATH` value. pub(crate) fn path_with(dirs: &[&Path]) -> String { @@ -45,7 +23,7 @@ pub(crate) fn path_with(dirs: &[&Path]) -> String { /// Resolves a command name on the search path. pub(crate) fn which(name: &str) -> Option { let path = search_path(); - std::env::split_paths(&path).map(|d| d.join(crate::exe(name))).find(|p| p.is_file()) + ide_core::process::find_executable(name, &path) } /// Runs `program args` and returns trimmed stdout (stderr when stdout is empty) @@ -66,27 +44,41 @@ pub(crate) fn query_in( if let Some(cwd) = cwd { cmd.current_dir(cwd); } + if !env.iter().any(|(k, _)| *k == "PATH") { + cmd.env("PATH", search_path()); + } let mut child = ide_core::process::prepare(&mut cmd) .args(args) - .env("PATH", search_path()) .envs(env.iter().copied()) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .ok()?; - let mut stdout = child.stdout.take()?; - let mut stderr = child.stderr.take()?; - let out = std::thread::spawn(move || { - let mut s = Vec::new(); - let _ = stdout.read_to_end(&mut s); - decode_tool_output(&s) - }); - let err = std::thread::spawn(move || { - let mut s = Vec::new(); - let _ = stderr.read_to_end(&mut s); - decode_tool_output(&s) - }); + // Chunks as they come, `None` at the end of a pipe. A reader stops once the query + // has returned, closing the pipe on whatever still writes to it. + let (tx, rx) = mpsc::channel::<(bool, Option>)>(); + let pipes: [(bool, Box); 2] = + [(true, Box::new(child.stdout.take()?)), (false, Box::new(child.stderr.take()?))]; + for (is_out, mut pipe) in pipes { + let tx = tx.clone(); + std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + let chunk = match pipe.read(&mut buf) { + Ok(0) => None, + Ok(n) => Some(buf[..n].to_vec()), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => None, + }; + let end = chunk.is_none(); + if tx.send((is_out, chunk)).is_err() || end { + return; + } + } + }); + } + drop(tx); let deadline = Instant::now() + timeout; let status = loop { match child.try_wait() { @@ -99,12 +91,27 @@ pub(crate) fn query_in( } } }; - let (out, err) = (out.join().unwrap_or_default(), err.join().unwrap_or_default()); if !status.success() { return None; } - let text = if out.trim().is_empty() { err } else { out }; - Some(text.trim().to_owned()) + // Up to the deadline, or briefly past it for a program that exited just before: + // a process it left running (a wrapper's background job) can hold the pipes + // open, or keep writing to them, after it exits. + let until = deadline.max(Instant::now() + Duration::from_millis(100)); + let (mut out, mut err, mut open) = (Vec::new(), Vec::new(), 2); + while open > 0 { + let left = until.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + match rx.recv_timeout(left) { + Ok((is_out, Some(chunk))) => if is_out { &mut out } else { &mut err }.extend(chunk), + Ok((_, None)) => open -= 1, + Err(_) => break, + } + } + let text = if out.trim_ascii().is_empty() { err } else { out }; + Some(decode_tool_output(&text).trim().to_owned()) } /// Runs a command to completion, reporting every output line (stdout and diff --git a/crates/ide-sdk/src/godot.rs b/crates/ide-sdk/src/godot.rs index 31999cbe..bdc888da 100644 --- a/crates/ide-sdk/src/godot.rs +++ b/crates/ide-sdk/src/godot.rs @@ -23,7 +23,7 @@ pub fn find(custom: Option) -> Option { if let Some(p) = custom { return Some(p); } - find_in(&crate::search_path(), &install_locations(crate::home_dir().as_deref())) + find_in(&crate::exec::search_path(), &install_locations(crate::home_dir().as_deref())) } pub(crate) fn find_in(search: &OsStr, locations: &[PathBuf]) -> Option { diff --git a/crates/ide-sdk/src/lib.rs b/crates/ide-sdk/src/lib.rs index 66ebdbbf..3d178c62 100644 --- a/crates/ide-sdk/src/lib.rs +++ b/crates/ide-sdk/src/lib.rs @@ -25,7 +25,6 @@ use specta::Type; use std::path::{Path, PathBuf}; pub use discover::{detect, jdk_sdk}; -pub use exec::search_path; /// What [`detect`] looks for. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] diff --git a/crates/ide-sdk/src/npm.rs b/crates/ide-sdk/src/npm.rs index 15a58ec2..89c94edb 100644 --- a/crates/ide-sdk/src/npm.rs +++ b/crates/ide-sdk/src/npm.rs @@ -1,7 +1,9 @@ //! Node-based language servers installed with npm: the package behind a command. +use std::collections::BTreeMap; use std::ffi::OsStr; use std::path::{Path, PathBuf}; +use std::time::Duration; /// `command` as given (a path) or found on `path`, npm shims included. pub(crate) fn find_command(command: &str, path: &OsStr) -> Option { @@ -9,15 +11,13 @@ pub(crate) fn find_command(command: &str, path: &OsStr) -> Option { if p.is_absolute() { return p.is_file().then(|| p.to_path_buf()); } - let exts: &[&str] = if cfg!(windows) { &[".cmd", ".exe", ""] } else { &[""] }; - std::env::split_paths(path) - .flat_map(|d| exts.iter().map(move |e| d.join(format!("{command}{e}")))) - .find(|c| c.is_file()) + ide_core::process::find_executable(command, path) } /// The npm package an executable comes from: next to a `node_modules/.bin` shim /// (the IDE's tools dir, a project), a Windows global shim (`%APPDATA%\npm`), a -/// Unix global `/bin` or a symlink into the package. +/// Unix global `/bin`, a symlink into the package, Volta's package image +/// or the package a shim script runs (pnpm). pub(crate) fn package_dir(bin: &Path, package: &str) -> Option { let dir = bin.parent()?; let mut candidates = vec![dir.join("node_modules").join(package)]; @@ -31,6 +31,197 @@ pub(crate) fn package_dir(bin: &Path, package: &str) -> Option { } if let Some(prefix) = dir.parent() { candidates.push(prefix.join("lib").join("node_modules").join(package)); + // Volta: `/bin/` runs `/tools/image/packages/`. + let image = prefix.join("tools").join("image").join("packages").join(package); + candidates.push(image.join("lib").join("node_modules").join(package)); + candidates.push(image.join("node_modules").join(package)); + } + let installed = |c: &PathBuf| c.join("package.json").is_file(); + candidates.into_iter().find(installed).or_else(|| script_targets(bin, package).into_iter().find(installed)) +} + +/// How shim scripts name their own directory: sh and PowerShell `$basedir` (pnpm +/// 11's sh shims also `$basedir_abs` and `$basedir_win`), cmd `%~dp0` (pnpm's +/// `%dp0%` is set from it). +const SCRIPT_DIR: [&str; 8] = + ["${basedir}", "$basedir", "${basedir_abs}", "$basedir_abs", "${basedir_win}", "$basedir_win", "%~dp0", "%dp0%"]; + +/// `path` after the [`SCRIPT_DIR`] it starts with. sh reads `$basedir_abs` as one +/// name, not as `$basedir` followed by `_abs`. +fn after_script_dir(path: &str) -> Option<&str> { + SCRIPT_DIR.iter().find_map(|d| { + let rest = path.strip_prefix(d)?; + let longer_name = d.starts_with('$') + && !d.ends_with('}') + && rest.starts_with(|c: char| c == '_' || c.is_ascii_alphanumeric()); + (!longer_name).then_some(rest) + }) +} + +/// The `package` directories a shim script runs from: relative to the script's +/// directory, e.g. pnpm's `"$basedir/../global/v11//node_modules//bin/"` +/// or `"%~dp0\..\global\..."`, or absolute. +fn script_targets(bin: &Path, package: &str) -> Vec { + const MAX_SCRIPT: u64 = 64 * 1024; + if std::fs::metadata(bin).map_or(true, |m| m.len() > MAX_SCRIPT) { + return Vec::new(); + } + let Ok(bytes) = std::fs::read(bin) else { return Vec::new() }; + let text = String::from_utf8_lossy(&bytes).replace('\\', "/"); + let bases: Vec = [ + bin.parent().map(Path::to_path_buf), + dunce::canonicalize(bin).ok().and_then(|r| r.parent().map(Path::to_path_buf)), + ] + .into_iter() + .flatten() + .collect(); + let needle = format!("node_modules/{package}"); + // What sh and cmd split words on; a folder name may contain a no-break space. + let separator = |c: char| c == '"' || c == '\'' || c.is_ascii_whitespace(); + let mut targets = Vec::new(); + for (at, _) in text.match_indices(&needle) { + let end = at + needle.len(); + if !text[end..].chars().next().is_none_or(|c| c == '/' || separator(c)) { + continue; + } + // Within quotes the path starts at the opening one and may contain spaces. + let line = text[..at].rfind('\n').map_or(0, |i| i + 1); + let start = match ['"', '\''].into_iter().find(|q| text[line..at].matches(*q).count() % 2 == 1) { + Some(quote) => text[..at].rfind(quote), + None => text[..at].rfind(separator), + } + .map_or(0, |i| i + 1); + let path = &text[start..end]; + // `"$basedir"/../...`: the directory quoted on its own. + let after_dir = text[..start] + .strip_suffix(['"', '\'']) + .is_some_and(|before| SCRIPT_DIR.iter().any(|d| before.ends_with(d))); + match after_script_dir(path).or(after_dir.then_some(path)) { + Some(rest) => targets.extend(bases.iter().map(|b| b.join(rest.trim_start_matches('/')))), + None if Path::new(path).is_absolute() => targets.push(PathBuf::from(path)), + // `$HOME/...`, `%APPDATA%\...` or relative to wherever the script runs. + None => {} + } + } + targets.into_iter().map(|t| dunce::canonicalize(&t).unwrap_or(t)).collect() +} + +/// For a version manager's shim (`~/.asdf/shims`, mise's `shims`), which names +/// only the command: `package` in the global `node_modules` of the manager's +/// npm shim, run as the server is (in `cwd`, with its `env` and `path`), so the +/// manager picks the same Node. The shims go first on `path`, as the manager's +/// shell setup puts them: npm (`#!/usr/bin/env node`) then runs on the manager's +/// Node, not on a system one earlier on `path` that has another global root. +pub(crate) fn shim_package( + bin: &Path, + package: &str, + path: &OsStr, + cwd: &Path, + env: &BTreeMap, +) -> Option { + let shims = bin.parent()?; + if shims.file_name() != Some(OsStr::new("shims")) { + return None; + } + // Only the npm shim next to it: any other npm may be another installation's. + let exts: &[&str] = if cfg!(windows) { &[".cmd", ".exe"] } else { &[""] }; + let npm = exts.iter().map(|e| shims.join(format!("npm{e}"))).find(|p| p.is_file())?; + let path = std::env::join_paths(std::iter::once(shims.to_path_buf()).chain(std::env::split_paths(path))).ok()?; + // The login shell's variables (`ASDF_DATA_DIR`, `MISE_ENV`...) under the server's own. + let shell_env: Vec<_> = ide_core::process::shell_env().collect(); + let mut env: Vec<(&str, &str)> = shell_env + .iter() + .filter_map(|(k, v)| Some((k.to_str()?, v.to_str()?))) + .chain(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .collect(); + env.push(("PATH", path.to_str()?)); + let root = crate::exec::query_in(&npm, &["root", "-g"], Some(cwd), &env, Duration::from_secs(10))?; + let dir = PathBuf::from(root.lines().last()?.trim()).join(package); + dir.join("package.json").is_file().then_some(dir) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PACKAGE: &str = "@angular/language-server"; + + /// `/bin/ngserver` running `script`. + fn shim(dir: &Path, script: &str) -> PathBuf { + std::fs::create_dir_all(dir.join("bin")).unwrap(); + let shim = dir.join("bin/ngserver"); + std::fs::write(&shim, script).unwrap(); + shim + } + + /// The package installed in `/node_modules`. + fn install(dir: &Path) -> PathBuf { + let package = dir.join("node_modules").join(PACKAGE); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("package.json"), "{}").unwrap(); + dunce::canonicalize(package).unwrap() + } + + #[test] + fn shim_scripts_name_the_package_relative_to_their_directory() { + let tmp = tempfile::tempdir().unwrap(); + let package = install(&tmp.path().join("global/v11/abc")); + for script in [ + "#!/bin/sh\nexec node \"$basedir/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n", + "#!/bin/sh\nexec node \"${basedir}/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n", + "#!/bin/sh\nexec node \"$basedir\"/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver \"$@\"\n", + // pnpm 11. + "#!/bin/sh\nexec \"$basedir/node\" \"$basedir_abs/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n", + "#!/bin/sh\nexec node \"${basedir_abs}/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n", + "#!/bin/sh\nexec node.exe \"$basedir_win/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n", + "@\"%~dp0\\..\\global\\v11\\abc\\node_modules\\@angular\\language-server\\bin\\ngserver\" %*\r\n", + "@\"%~dp0..\\global\\v11\\abc\\node_modules\\@angular\\language-server\\bin\\ngserver\" %*\r\n", + "@SET dp0=%~dp0\r\n\"%dp0%\\..\\global\\v11\\abc\\node_modules\\@angular\\language-server\\bin\\ngserver\" %*\r\n", + ] { + assert_eq!(package_dir(&shim(tmp.path(), script), PACKAGE), Some(package.clone()), "{script}"); + } + } + + #[test] + fn quoted_shim_targets_keep_their_spaces() { + let tmp = tempfile::tempdir().unwrap(); + let package = install(&tmp.path().join("Global Packages/abc")); + let absolute = package.display().to_string(); + for script in [ + "#!/bin/sh\nexec node \"$basedir/../Global Packages/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n" + .to_owned(), + "@\"%~dp0\\..\\Global Packages\\abc\\node_modules\\@angular\\language-server\\bin\\ngserver\" %*\r\n".to_owned(), + format!("#!/bin/sh\nexec node \"{absolute}/bin/ngserver\" \"$@\"\n"), + format!("#!/bin/sh\nexec node '{absolute}/bin/ngserver' \"$@\"\n"), + ] { + assert_eq!(package_dir(&shim(tmp.path(), &script), PACKAGE), Some(package.clone()), "{script}"); + } + } + + #[test] + fn shim_scripts_with_an_absolute_path_and_non_ascii_spaces() { + let tmp = tempfile::tempdir().unwrap(); + let package = install(&tmp.path().join("山田\u{3000}太郎\u{a0}x")); + let script = format!("#!/bin/sh\nexec node \"{}/bin/ngserver\" \"$@\"\n", package.display()); + assert_eq!(package_dir(&shim(tmp.path(), &script), PACKAGE), Some(package)); + } + + #[test] + fn shim_scripts_skip_other_packages_and_other_directories() { + let tmp = tempfile::tempdir().unwrap(); + install(&tmp.path().join("home")); + for script in [ + "exec node \"$basedir/../home/node_modules/@angular/language-server-next/bin/ngserver\"\n", + "exec node \"$HOME/node_modules/@angular/language-server/bin/ngserver\"\n", + "exec node \"$basedirx/../home/node_modules/@angular/language-server/bin/ngserver\"\n", + "exec node ../home/node_modules/@angular/language-server/bin/ngserver\n", + ] { + assert_eq!(script_targets(&shim(tmp.path(), script), PACKAGE), Vec::::new(), "{script}"); + } + let large = format!( + "{}\n\"$basedir/../home/node_modules/@angular/language-server/bin/ngserver\"\n", + "#".repeat(64 * 1024) + ); + assert_eq!(script_targets(&shim(tmp.path(), &large), PACKAGE), Vec::::new()); } - candidates.into_iter().find(|c| c.join("package.json").is_file()) } diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 92fda6da..7af792e0 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -673,6 +673,160 @@ fn angular_language_server_probes_its_own_package_then_the_project() { assert_eq!((cfg.launch_error, cfg.args), (None, vec!["--stdio".to_owned()])); } +#[test] +fn angular_language_server_is_found_behind_version_manager_shims() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("app"); + std::fs::create_dir_all(&project).unwrap(); + let install = |node_modules: &Path| { + let package = node_modules.join("@angular/language-server"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("package.json"), "{}").unwrap(); + package + }; + let probes = |command: &Path, s: &Settings, env: &[(&str, &str)]| { + let mut cfg = server("angular-language-server"); + cfg.command = command.to_string_lossy().into_owned(); + cfg.env.extend(env.iter().map(|(k, v)| (k.to_string(), v.to_string()))); + configure_server(&mut cfg, &project, &project, s); + cfg.args.iter().position(|a| a == "--ngProbeLocations").map(|i| cfg.args[i + 1].clone()) + }; + let with_project = |package: &Path| Some(format!("{},{}", package.to_string_lossy(), project.to_string_lossy())); + + // pnpm: the shim script runs the package from pnpm's global directory. + let pnpm = tmp.path().join("pnpm"); + let package = install(&pnpm.join("global/v11/abc/node_modules")); + let shim = npm_shim(&pnpm.join("bin"), "ngserver"); + let script = "exec node \"$basedir/../global/v11/abc/node_modules/@angular/language-server/bin/ngserver\" \"$@\"\n"; + std::fs::write(&shim, format!("#!/bin/sh\n{script}")).unwrap(); + assert_eq!(probes(&shim, &Settings::default(), &[]), with_project(&dunce::canonicalize(&package).unwrap())); + + // Volta: the package's image, next to the shim's directory. Joined as the lookup + // joins it: the probe locations compare as text, and Windows keeps a `/` as given. + let volta = tmp.path().join("volta"); + let image = volta.join("tools").join("image").join("packages").join("@angular/language-server"); + let package = install(&image.join("lib").join("node_modules")); + let shim = npm_shim(&volta.join("bin"), "ngserver"); + assert_eq!(probes(&shim, &Settings::default(), &[]), with_project(&package)); + + // asdf, mise: the shim names only the command; the manager's npm shim knows + // where global packages are, run as the server is: in the project (whose + // `.tool-versions` picks the Node), with the login shell's and its own variables. + let root_of = |package: &Path| package.parent().unwrap().parent().unwrap().display().to_string(); + let package = install(&tmp.path().join("asdf/installs/nodejs/24/lib/node_modules")); + std::fs::write(project.join(".tool-versions"), "nodejs 24\n").unwrap(); + // The only writer in this test binary. + ide_core::process::set_shell_env(vec![("OPENIDEA_TEST_SHELL".into(), "1".into())]); + let shims = tmp.path().join(".asdf/shims"); + // The manager's npm runs on the first Node on PATH (`#!/usr/bin/env node`). + std::fs::write( + npm_shim(&shims, "npm"), + if cfg!(windows) { + "@if exist .tool-versions if \"%OPENIDEA_TEST_SHELL%\"==\"1\" call node\r\n" + } else { + "#!/bin/sh\n[ -f .tool-versions ] && [ \"$OPENIDEA_TEST_SHELL\" = 1 ] && exec node\n" + }, + ) + .unwrap(); + std::fs::write( + npm_shim(&shims, "node"), + if cfg!(windows) { + "@echo %OPENIDEA_TEST_NPM_ROOT%\r\n" + } else { + "#!/bin/sh\necho \"$OPENIDEA_TEST_NPM_ROOT\"\n" + }, + ) + .unwrap(); + // Another installation's npm and Node, first on PATH. + let node = tmp.path().join("node/bin"); + let other = root_of(&install(&tmp.path().join("node/lib/node_modules"))); + for name in ["npm", "node"] { + std::fs::write( + npm_shim(&node, name), + if cfg!(windows) { format!("@echo {other}\r\n") } else { format!("#!/bin/sh\necho '{other}'\n") }, + ) + .unwrap(); + } + let mut s = Settings::default(); + s.languages.node.interpreter = node.join("node").to_string_lossy().into_owned(); + let root = root_of(&package); + let env = [("OPENIDEA_TEST_NPM_ROOT", root.as_str())]; + assert_eq!(probes(&npm_shim(&shims, "ngserver"), &s, &env), with_project(&package)); + // Not without the manager's own npm, nor for an executable elsewhere: the npm + // on PATH may be another installation's. + assert_eq!(probes(&npm_shim(&tmp.path().join("mise/shims"), "ngserver"), &s, &env), None); + assert_eq!(probes(&npm_shim(&tmp.path().join("bin"), "ngserver"), &s, &env), None); +} + +#[cfg(unix)] +#[test] +fn query_output_is_read_within_the_deadline() { + // Keep process startup outside a tight timing budget even under workspace load. + let timeout = std::time::Duration::from_secs(60); + let env = [("PATH", "/usr/bin:/bin")]; + let started = std::time::Instant::now(); + let out = crate::exec::query_in(Path::new("/bin/sh"), &["-c", "printf /global/root"], None, &env, timeout); + assert_eq!(out.as_deref(), Some("/global/root")); + // EOF must return promptly, without waiting for the generous deadline. + assert!(started.elapsed() < std::time::Duration::from_secs(30)); +} + +#[cfg(unix)] +#[test] +fn query_drains_both_pipes_before_the_child_exits() { + let out = crate::exec::query_in( + Path::new("/bin/sh"), + &["-c", "head -c 262144 /dev/zero; head -c 262144 /dev/zero >&2"], + None, + &[("PATH", "/usr/bin:/bin")], + std::time::Duration::from_secs(60), + ) + .unwrap(); + assert_eq!(out.len(), 262144); +} + +#[cfg(unix)] +#[test] +fn query_is_bounded_when_descendants_keep_pipes_open() { + let tmp = tempfile::tempdir().unwrap(); + let pid = tmp.path().join("pid"); + // The descendant outlives the query; clean it up even if an assertion fails. + struct Descendant(std::path::PathBuf); + impl Drop for Descendant { + fn drop(&mut self) { + if let Ok(pid) = std::fs::read_to_string(&self.0) { + let _ = std::process::Command::new("/bin/kill").arg(pid.trim()).status(); + } + } + } + let _descendant = Descendant(pid.clone()); + let started = std::time::Instant::now(); + let out = crate::exec::query_in( + Path::new("/bin/sh"), + &["-c", "sleep 120 & echo $! > \"$1\"; printf /global/root", "wrapper", pid.to_str().unwrap()], + None, + &[("PATH", "/usr/bin:/bin")], + std::time::Duration::from_secs(30), + ); + assert_eq!(out.as_deref(), Some("/global/root")); + // The descendant keeps both pipes open for longer than the query deadline. + assert!(started.elapsed() < std::time::Duration::from_secs(90)); +} + +#[cfg(unix)] +#[test] +fn query_times_out_when_the_child_does_not_exit() { + // exec avoids leaving a descendant behind when the timed-out child is killed. + let out = crate::exec::query_in( + Path::new("/bin/sh"), + &["-c", "printf /global/root; exec sleep 120"], + None, + &[("PATH", "/usr/bin:/bin")], + std::time::Duration::from_millis(100), + ); + assert_eq!(out, None); +} + #[test] fn max_java_picks_an_older_jdk() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ide-tools/src/jdk.rs b/crates/ide-tools/src/jdk.rs index 279df197..b5bcf6db 100644 --- a/crates/ide-tools/src/jdk.rs +++ b/crates/ide-tools/src/jdk.rs @@ -448,7 +448,7 @@ mod tests { _ => None, }; if let Some(platform) = other { - let tools = Tools::with_paths(crate::tools_dir(tmp.path()), crate::default_search_path(), platform); + let tools = Tools::with_paths(crate::tools_dir(tmp.path()), ide_core::tool_path::search_path(), platform); let jdk = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &|_| {}).unwrap(); let home = jdk.home.into_path_buf(); eprintln!("{}", home.display()); diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index ccbeb890..e32d1002 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -174,28 +174,6 @@ pub fn path_with_tools(app_data: &Path, path: &std::ffi::OsStr) -> OsString { std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned()) } -/// `PATH` plus the usual per-user tool locations (GUI apps on macOS start with a minimal `PATH`). -pub fn default_search_path() -> OsString { - let mut dirs: Vec = - ide_core::process::host_var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); - if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from) { - for rel in [".cargo/bin", ".local/bin", "go/bin", ".bun/bin", ".volta/bin", ".npm-global/bin", "Library/pnpm"] { - dirs.push(home.join(rel)); - } - if let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) { - let mut versions: Vec = entries.flatten().map(|e| e.path().join("bin")).collect(); - versions.sort(); - dirs.extend(versions.into_iter().rev()); - } - } - for d in ["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"] { - dirs.push(PathBuf::from(d)); - } - let mut seen = HashSet::new(); - dirs.retain(|d| !d.as_os_str().is_empty() && seen.insert(d.clone())); - std::env::join_paths(dirs).unwrap_or_default() -} - static INSTALLING: Mutex>> = Mutex::new(None); struct InstallGuard(String); @@ -331,12 +309,13 @@ pub struct Tools { custom: BTreeMap, /// Executables found outside the search path (app bundles), by command. found: BTreeMap, + version_timeout: Duration, } impl Tools { - /// Tools under `/tools` using [`default_search_path`]. + /// Tools under `/tools` using [`ide_core::tool_path::search_path`]. pub fn new(app_data: &Path) -> Self { - Self::with_paths(tools_dir(app_data), default_search_path(), Platform::current()) + Self::with_paths(tools_dir(app_data), ide_core::tool_path::search_path(), Platform::current()) } pub fn with_paths(root: PathBuf, search_path: OsString, platform: Platform) -> Self { @@ -344,7 +323,15 @@ impl Tools { let system: Vec = std::env::split_paths(&search_path).filter(|d| !ours.contains(d)).collect(); let system_path = std::env::join_paths(&system).unwrap_or_default(); let child_path = std::env::join_paths(ours.iter().chain(system.iter())).unwrap_or_default(); - Self { root, system_path, child_path, platform, custom: BTreeMap::new(), found: BTreeMap::new() } + Self { + root, + system_path, + child_path, + platform, + custom: BTreeMap::new(), + found: BTreeMap::new(), + version_timeout: Duration::from_secs(4), + } } /// Reports the executables chosen in settings (command → path) as the tools' location. @@ -364,7 +351,7 @@ impl Tools { } fn which(&self, cmd: &str) -> Option { - which::which_in(cmd, Some(&self.system_path), &self.root).ok() + ide_core::tool_path::find_command(cmd, &self.system_path, &self.root) } fn which_any(&self, cmds: &[&str]) -> Option { @@ -428,7 +415,7 @@ impl Tools { let program = sibling.or_else(|| self.which(cmd))?; let mut c = self.command(program); c.args(args); - let (ok, out) = run_quiet(c, Duration::from_secs(4))?; + let (ok, out) = run_quiet(c, self.version_timeout)?; if !ok { return None; } @@ -548,7 +535,7 @@ impl Tools { if cmd == spec.commands[0] { path.clone() } else { path.parent().and_then(|d| self.exe_in(d, cmd))? }; let mut c = self.command(program); c.args(args); - let (ok, out) = run_quiet(c, Duration::from_secs(4))?; + let (ok, out) = run_quiet(c, self.version_timeout)?; if ok { parse_version(&out) } else { None } }); info.status = ToolStatus::Custom; diff --git a/crates/ide-tools/src/server.rs b/crates/ide-tools/src/server.rs index 3e3481c4..d62f2af6 100644 --- a/crates/ide-tools/src/server.rs +++ b/crates/ide-tools/src/server.rs @@ -2,7 +2,7 @@ //! version publishes one per supported host platform (see `release.yml`). use crate::process::Http; -use crate::{InstallEvent, default_search_path, fetch_verified, sources}; +use crate::{InstallEvent, fetch_verified, sources}; use ide_core::CancellationToken; use ide_core::{IdeError, IdeResult}; use std::path::{Path, PathBuf}; @@ -46,7 +46,7 @@ pub fn fetch( std::fs::create_dir_all(dir)?; let staging = tempfile_dir(dir)?; let result = (|| { - let http = Http::new(&default_search_path())?; + let http = Http::new(&ide_core::tool_path::search_path())?; // The binary runs on the remote host: never install it unverified. let checksum_url = format!("{url}.sha256"); let sha256 = sources::parse_checksum(&http.get_text(&checksum_url, cancel)?, &name) diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index b9a2ec48..02226cff 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -24,7 +24,10 @@ impl Env { fn tools(&self) -> Tools { let mut dirs = vec![self.sys.clone()]; dirs.extend(bin_dirs_in(&self.root, Platform::current())); - Tools::with_paths(self.root.clone(), std::env::join_paths(dirs).unwrap(), Platform::current()) + let mut tools = Tools::with_paths(self.root.clone(), std::env::join_paths(dirs).unwrap(), Platform::current()); + // These fixtures test detection and version parsing, not process startup latency. + tools.version_timeout = Duration::from_secs(60); + tools } #[cfg(unix)]