//! Finding installed interpreters and toolchains. use crate::exec::{executables_in, query, search_path, which}; use crate::{Sdk, SdkKind, bin_dir, exe, home_dir, pinned, venv_of}; use ide_core::version_managers::{self, Tool}; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::Duration; const VERSION_TIMEOUT: Duration = Duration::from_secs(4); /// Installed SDKs of `kind`, project-local ones (`root`) first. Blocking: runs /// `--version` queries in parallel. pub fn detect(kind: SdkKind, root: Option<&Path>) -> Vec { let mut out = match kind { SdkKind::Python => python(root), SdkKind::Node => node(), SdkKind::Php => php(), SdkKind::PackageManager => package_managers(), SdkKind::Rust => rust(), SdkKind::Go => go(), SdkKind::TypeScript => typescript(root), SdkKind::Jdk => jdk(), SdkKind::Maven => maven(), SdkKind::Ruby => crate::ruby::detect(root), }; if let Some(root) = root { pinned_first(&mut out, kind, root); } out } fn tool_of(kind: SdkKind) -> Option { match kind { SdkKind::Python => Some(Tool::Python), SdkKind::Node => Some(Tool::Node), SdkKind::Go => Some(Tool::Go), SdkKind::Jdk => Some(Tool::Java), SdkKind::Maven => Some(Tool::Maven), SdkKind::Ruby => Some(Tool::Ruby), SdkKind::Php | SdkKind::PackageManager | SdkKind::Rust | SdkKind::TypeScript => None, } } /// Moves the install the project pins for mise / asdf after the project's own SDKs /// (virtualenvs), ahead of the rest. fn pinned_first(sdks: &mut Vec, kind: SdkKind, root: &Path) { let Some((dir, _)) = tool_of(kind).and_then(|t| pinned::pinned_install(root, t)) else { return }; let Some(i) = sdks.iter().position(|s| s.path.starts_with(&dir)) else { return }; let at = sdks.iter().take_while(|s| s.source == "venv").count().min(i); let sdk = sdks.remove(i); sdks.insert(at, sdk); } /// Runs `f` over `items` on scoped threads (version queries spawn processes). pub(crate) fn par_map(items: &[T], f: impl Fn(&T) -> Option + Sync) -> Vec { std::thread::scope(|s| { let handles: Vec<_> = items.iter().map(|item| s.spawn(|| f(item))).collect(); handles.into_iter().filter_map(|h| h.join().ok().flatten()).collect() }) } fn dedupe_by_real_path(paths: Vec<(PathBuf, &'static str)>) -> Vec<(PathBuf, &'static str)> { let mut seen = HashSet::new(); paths .into_iter() .filter(|(p, _)| p.is_file() && seen.insert(p.canonicalize().unwrap_or_else(|_| p.clone()))) .collect() } // ------------------------------------------------------------------ python /// Project-local virtualenv directories checked by auto-detection, in order. pub(crate) const PROJECT_VENVS: [&str; 4] = [".venv", "venv", "env", ".env"]; /// `//bin/python` of the first project virtualenv. pub(crate) fn project_venv_python(root: &Path) -> Option { PROJECT_VENVS .iter() .map(|v| root.join(v)) .filter(|v| v.join("pyvenv.cfg").is_file()) .map(|v| v.join(bin_dir()).join(exe("python"))) .find(|p| p.is_file()) } /// `/usr/bin/python3` on macOS is a stub that opens an installer dialog when /// the Command Line Tools are missing; never run it then. fn usable(path: &Path) -> bool { if cfg!(target_os = "macos") && path.starts_with("/usr/bin") { return Path::new("/Library/Developer/CommandLineTools/usr/bin").is_dir() || Path::new("/Applications/Xcode.app").is_dir(); } true } fn is_python_name(name: &str) -> bool { let name = name.strip_suffix(".exe").unwrap_or(name); let Some(rest) = name.strip_prefix("python") else { return false }; rest.is_empty() || rest == "3" || rest.strip_prefix("3.").is_some_and(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit())) } fn python(root: Option<&Path>) -> Vec { let mut found: Vec<(PathBuf, &'static str)> = Vec::new(); if let Some(root) = root { for v in PROJECT_VENVS { let dir = root.join(v); if dir.join("pyvenv.cfg").is_file() { found.push((dir.join(bin_dir()).join(exe("python")), "venv")); } } } if let Ok(venv) = std::env::var("VIRTUAL_ENV") { found.push((Path::new(&venv).join(bin_dir()).join(exe("python")), "venv")); } if let Some(home) = home_dir() { // Virtualenvs managed by virtualenvwrapper / pipenv / poetry. for dir in [".virtualenvs", ".local/share/virtualenvs", "Library/Caches/pypoetry/virtualenvs"] { for env in subdirs(&home.join(dir)) { found.push((env.join(bin_dir()).join(exe("python")), "venv")); } } let pyenv = std::env::var_os("PYENV_ROOT").map(PathBuf::from).unwrap_or_else(|| home.join(".pyenv")); for v in subdirs(&pyenv.join("versions")) { found.push((v.join(bin_dir()).join(exe("python")), "pyenv")); } for v in subdirs(&home.join(".local/share/uv/python")) { found.push((v.join(bin_dir()).join(exe("python3")), "uv")); } for (v, manager) in version_managers::installs(Tool::Python) { found.extend(pinned::python_in_install(&v).map(|p| (p, manager))); } let mut conda_roots: Vec = ["miniconda3", "anaconda3", "miniforge3", "mambaforge", "micromamba", "opt/miniconda3"] .iter() .map(|d| home.join(d)) .collect(); conda_roots.push(PathBuf::from("/opt/homebrew/Caskroom/miniconda/base")); conda_roots.push(PathBuf::from("/opt/miniconda3")); conda_roots.push(PathBuf::from("/opt/anaconda3")); if let Some(prefix) = std::env::var_os("CONDA_PREFIX") { conda_roots.push(PathBuf::from(prefix)); } for base in conda_roots { found.push((base.join(bin_dir()).join(exe("python")), "conda")); for env in subdirs(&base.join("envs")) { found.push((env.join(bin_dir()).join(exe("python")), "conda")); } } } for dir in std::env::split_paths(&search_path()) { if dir.ends_with(".pyenv/shims") || version_managers::is_shims_dir(&dir) { continue; } let mut names: Vec = std::fs::read_dir(&dir) .into_iter() .flatten() .flatten() .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| is_python_name(n)) .collect(); names.sort(); for n in names { found.push((dir.join(n), "system")); } } // A virtualenv's python is a symlink to its base interpreter: dedupe venvs by // their own path, the rest by the resolved binary. let mut seen_venv = HashSet::new(); let mut seen_real = HashSet::new(); let candidates: Vec<(PathBuf, &'static str)> = found .into_iter() .filter(|(p, _)| p.is_file() && usable(p)) .filter(|(p, source)| { if *source == "venv" || venv_of(p).is_some() { seen_venv.insert(venv_of(p).unwrap_or_else(|| p.clone())) } else { seen_real.insert(p.canonicalize().unwrap_or_else(|_| p.clone())) } }) .map(|(p, source)| if venv_of(&p).is_some() && source != "conda" { (p, "venv") } else { (p, source) }) .collect(); par_map(&candidates, |(path, source)| python_sdk(path, source, root)) } /// Interpreter `path` (one the project is set to, wherever it lives) as an SDK, named /// like the detected ones. pub fn python_interpreter_sdk(path: &Path, root: Option<&Path>) -> Option { python_sdk(path, if venv_of(path).is_some() { "venv" } else { "system" }, root) } pub(crate) fn python_sdk(path: &Path, source: &str, root: Option<&Path>) -> Option { let version = query(path, &["--version"], VERSION_TIMEOUT) .and_then(|v| v.strip_prefix("Python ").map(str::to_owned)) .or_else(|| pyvenv_version(path))?; let short = version.split('.').take(2).collect::>().join("."); let venv = venv_of(path); let label = match (&venv, source) { (Some(home), _) => { let dir = home.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); // `.venv` inside a project reads better as the folder that holds it (the project, // or a member such as `backend`), like PyCharm. let owner = home.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); match (dir.starts_with('.') || dir == "venv" || dir == "env", owner, root) { (true, Some(owner), Some(root)) if home.starts_with(root) => format!("Python {short} ({owner})"), _ => format!("Python {short} ({dir})"), } } (None, "conda") => { let env = path .parent() .and_then(Path::parent) .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().into_owned()); format!("Python {short} (conda: {})", env.unwrap_or_default()) } (None, "pyenv") => format!("Python {short} (pyenv)"), (None, tool @ ("uv" | "mise" | "asdf")) => format!("Python {short} ({tool})"), _ => format!("Python {short}"), }; Some(Sdk { path: path.into(), name: label, version: Some(version), source: source.to_owned(), home: venv.or_else(|| path.parent().and_then(Path::parent).map(Path::to_path_buf)).map(Into::into), extra: None, }) } fn pyvenv_version(python: &Path) -> Option { let cfg = std::fs::read_to_string(venv_of(python)?.join("pyvenv.cfg")).ok()?; cfg.lines().find_map(|l| { let (k, v) = l.split_once('=')?; matches!(k.trim(), "version" | "version_info").then(|| v.trim().to_owned()) }) } pub(crate) fn subdirs(dir: &Path) -> Vec { let mut out: Vec = std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(); out.sort(); out } // ------------------------------------------------------------------ node /// Node's own version managers, by where they keep installs below the home directory. const NODE_MANAGERS: &[(&str, &str)] = &[(".nvm/versions/node", "nvm"), (".volta/tools/image/node", "volta"), (".local/share/fnm/node-versions", "fnm")]; /// Where a Node on the search path comes from: its version manager, "system" for one /// of the system's own bin directories, "path" for anything else the user put on `PATH`. pub(crate) fn node_source(path: &Path, home: Option<&Path>) -> &'static str { let managed = home.and_then(|home| { NODE_MANAGERS.iter().find(|(dir, _)| path.starts_with(home.join(dir))).map(|(_, manager)| *manager) }); managed.or_else(|| version_managers::manager_of(path)).unwrap_or_else(|| { if path.parent().is_some_and(ide_core::tool_path::is_system_dir) { "system" } else { "path" } }) } /// Where a PHP on the search path comes from: its version manager or distribution, /// "system" for one of the system's own bin directories, "path" for anything else. pub(crate) fn php_source(path: &Path, home: Option<&Path>) -> &'static str { let under = |dir: &str| home.is_some_and(|h| path.starts_with(h.join(dir))); if under(".phpbrew") { "phpbrew" } else if under(".phpenv") { "phpenv" } else if under(".config/herd-lite") || under("Library/Application Support/Herd") { "herd" } else if path.starts_with("/opt/homebrew") || path.starts_with("/usr/local/opt") { "homebrew" } else if let Some(manager) = version_managers::manager_of(path) { manager } else if path.parent().is_some_and(ide_core::tool_path::is_system_dir) { "system" } else { "path" } } /// A PHP CLI named with its version, as Debian / Ubuntu (`ppa:ondrej/php`) install them: /// `php8.1`, `php81`. Not `php-cgi8.1`, `phpize8.1` or `phpdbg8.1`. pub(crate) fn is_versioned_php_name(name: &str) -> bool { let name = name.strip_suffix(".exe").unwrap_or(name); let Some(version) = name.strip_prefix("php") else { return false }; let mut parts = version.splitn(2, '.'); let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); parts.next().is_some_and(digits) && parts.next().is_none_or(digits) } /// The versioned PHP CLIs in `dirs`, in `PATH` order (sorted by name within a directory). pub(crate) fn versioned_php_in(dirs: impl IntoIterator) -> Vec { let mut found = Vec::new(); for dir in dirs { if version_managers::is_shims_dir(&dir) { continue; } let mut names: Vec = std::fs::read_dir(&dir) .into_iter() .flatten() .flatten() .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| is_versioned_php_name(n)) .collect(); names.sort(); found.extend(names.into_iter().map(|n| dir.join(n)).filter(|p| p.is_file())); } found } /// `php` CLIs: every one on `PATH` (also versioned ones, `php8.1`), Homebrew's versioned /// formulae, PHP version managers, Laravel Herd, and the usual Windows distributions (XAMPP, Laragon). fn php() -> Vec { let mut found: Vec<(PathBuf, &'static str)> = Vec::new(); let home = home_dir(); for p in executables_in("php", std::env::split_paths(&search_path())) { if version_managers::is_shim(&p) { continue; } let source = php_source(&p, home.as_deref()); found.push((p, source)); } // After the plain `php`: deduplication keeps `/usr/bin/php` over the `php8.3` it links to. for p in versioned_php_in(std::env::split_paths(&search_path())) { let source = php_source(&p, home.as_deref()); found.push((p, source)); } let named = |dir: &Path, prefix: &str| -> Vec { subdirs(dir) .into_iter() .filter(|d| d.file_name().is_some_and(|n| n.to_string_lossy().starts_with(prefix))) .collect() }; for opt in ["/opt/homebrew/opt", "/usr/local/opt"] { for d in named(Path::new(opt), "php") { found.push((d.join("bin").join(exe("php")), "homebrew")); } } for d in named(Path::new("/usr/local"), "php") { found.push((d.join("bin").join(exe("php")), "system")); } if let Some(home) = &home { for v in subdirs(&home.join(".phpbrew/php")).into_iter().rev() { found.push((v.join("bin").join(exe("php")), "phpbrew")); } for v in subdirs(&home.join(".phpenv/versions")).into_iter().rev() { found.push((v.join("bin").join(exe("php")), "phpenv")); } found.push((home.join(".config/herd-lite/bin").join(exe("php")), "herd")); let herd = home.join("Library/Application Support/Herd/bin"); for entry in std::fs::read_dir(&herd).into_iter().flatten().flatten() { let name = entry.file_name().to_string_lossy().into_owned(); if name.starts_with("php") && !name.contains('-') && entry.path().is_file() { found.push((entry.path(), "herd")); } } } if cfg!(windows) { found.push((PathBuf::from(r"C:\xampp\php\php.exe"), "xampp")); found.push((PathBuf::from(r"C:\php\php.exe"), "system")); for d in subdirs(Path::new(r"C:\laragon\bin\php")) { found.push((d.join("php.exe"), "laragon")); } } let found = dedupe_by_real_path(found); let mut sdks = par_map(&found, |(path, source)| { let version = query(path, &["-r", "echo PHP_VERSION;"], VERSION_TIMEOUT)?; let version = version.trim().to_owned(); if version.is_empty() { return None; } Some(Sdk { path: path.clone().into(), name: format!("PHP {version}"), version: Some(version), source: (*source).to_owned(), home: path.parent().and_then(Path::parent).map(Into::into), extra: None, }) }); newest_first(&mut sdks); sdks } /// Newest version first, number by number (8.3.12 before 8.3.9); stable, so on equal /// versions the `PATH` install stays first. pub(crate) fn newest_first(sdks: &mut [Sdk]) { let key = |s: &Sdk| ide_core::tool_path::version_key(s.version.as_deref().unwrap_or("")); sdks.sort_by_key(|s| std::cmp::Reverse(key(s))); } fn node() -> Vec { let mut found: Vec<(PathBuf, &'static str)> = Vec::new(); // Every Node on the search path, not only the first one `which` finds: an SSH // session's minimal PATH puts /usr/bin ahead of the user's own ~/.local/bin, so // /usr/bin/node would be the only install listed (#440). for p in executables_in("node", std::env::split_paths(&search_path())) { // A mise / asdf shim picks the version by directory: its installs are listed instead. if version_managers::is_shim(&p) { continue; } let source = node_source(&p, home_dir().as_deref()); found.push((p, source)); } if let Some(home) = home_dir() { for v in subdirs(&home.join(".nvm/versions/node")).into_iter().rev() { found.push((v.join("bin").join(exe("node")), "nvm")); } for v in subdirs(&home.join(".volta/tools/image/node")).into_iter().rev() { found.push((v.join("bin").join(exe("node")), "volta")); } for v in subdirs(&home.join(".local/share/fnm/node-versions")).into_iter().rev() { found.push((v.join("installation/bin").join(exe("node")), "fnm")); } } for (v, manager) in version_managers::installs(Tool::Node).into_iter().rev() { found.push((v.join("bin").join(exe("node")), manager)); } let found = dedupe_by_real_path(found); par_map(&found, |(path, source)| { let version = query(path, &["--version"], VERSION_TIMEOUT)?; let version = version.trim_start_matches('v').to_owned(); Some(Sdk { path: path.clone().into(), name: format!("Node.js {version}"), version: Some(version), source: (*source).to_owned(), home: path.parent().and_then(Path::parent).map(Into::into), extra: None, }) }) } fn package_managers() -> Vec { let found: Vec<(&str, PathBuf)> = ["npm", "pnpm", "yarn", "bun"].iter().filter_map(|n| Some((*n, which(n)?))).collect(); par_map(&found, |(name, path)| { let version = query(path, &["--version"], VERSION_TIMEOUT); Some(Sdk { path: path.clone().into(), name: (*name).to_owned(), version, source: "path".into(), home: None, extra: None, }) }) } // ------------------------------------------------------------------ rust fn rust_sdk(bin: &Path, name: String, source: &str, home: Option) -> Option { let rustc = bin.join(exe("rustc")); if !rustc.is_file() { return None; } let version = query(&rustc, &["--version"], VERSION_TIMEOUT) .map(|v| v.trim_start_matches("rustc ").split_whitespace().next().unwrap_or_default().to_owned()); let stdlib = home .as_ref() .map(|h| h.join("lib/rustlib/src/rust/library")) .or_else(|| { let sysroot = query(&rustc, &["--print", "sysroot"], VERSION_TIMEOUT)?; Some(PathBuf::from(sysroot).join("lib/rustlib/src/rust/library")) }) .filter(|p| p.is_dir()); Some(Sdk { path: bin.into(), name, version, source: source.to_owned(), home: home.map(Into::into), extra: stdlib.map(Into::into), }) } fn rust() -> Vec { let rustup_home = std::env::var_os("RUSTUP_HOME").map(PathBuf::from).or_else(|| home_dir().map(|h| h.join(".rustup"))); let toolchains = rustup_home.map(|h| subdirs(&h.join("toolchains"))).unwrap_or_default(); let mut out = par_map(&toolchains, |dir| { let name = dir.file_name()?.to_string_lossy().into_owned(); rust_sdk(&dir.join("bin"), name, "rustup", Some(dir.clone())) }); // A rustc not managed by rustup (Homebrew, distro packages). if let Some(cargo) = which("cargo") { let bin = cargo.parent().map(Path::to_path_buf).unwrap_or_default(); let is_proxy = bin.ends_with(".cargo/bin"); if !is_proxy && !out.iter().any(|s| s.path == bin) { out.extend(rust_sdk(&bin, format!("{}", bin.display()), "path", None)); } } out } // ------------------------------------------------------------------ go fn go_version(goroot: &Path) -> Option { let text = std::fs::read_to_string(goroot.join("VERSION")).ok(); let first = text.as_deref().and_then(|t| t.lines().next()).map(str::to_owned); first .or_else(|| { query(&goroot.join("bin").join(exe("go")), &["version"], VERSION_TIMEOUT)? .split_whitespace() .nth(2) .map(str::to_owned) }) .map(|v| v.trim_start_matches("go").to_owned()) } fn go() -> Vec { let mut roots: Vec<(PathBuf, &'static str)> = Vec::new(); let path_go = which("go"); let default_gopath = path_go.as_ref().and_then(|go| query(go, &["env", "GOPATH"], VERSION_TIMEOUT)).map(PathBuf::from); if let Some(go) = path_go.as_ref().filter(|p| !version_managers::is_shim(p)) && let Some(root) = query(go, &["env", "GOROOT"], VERSION_TIMEOUT) { roots.push((PathBuf::from(root), "path")); } for p in ["/usr/local/go", "/opt/homebrew/opt/go/libexec", "/usr/local/opt/go/libexec", "/usr/lib/go"] { roots.push((PathBuf::from(p), "system")); } if let Some(home) = home_dir() { for d in subdirs(&home.join("sdk")) { roots.push((d, "sdk")); } for d in subdirs(&home.join("go/pkg/mod/golang.org")) { if d.file_name().is_some_and(|n| n.to_string_lossy().starts_with("toolchain@")) { roots.push((d, "toolchain")); } } } for (d, manager) in version_managers::installs(Tool::Go) { roots.extend(pinned::goroot_in_install(&d).map(|r| (r, manager))); } let mut seen = HashSet::new(); roots.retain(|(r, _)| { r.join("bin").join(exe("go")).is_file() && seen.insert(r.canonicalize().unwrap_or_else(|_| r.clone())) }); par_map(&roots, |(root, source)| { let version = go_version(root); Some(Sdk { path: root.clone().into(), name: format!("Go {}", version.clone().unwrap_or_default()).trim().to_owned(), version, source: (*source).to_owned(), home: Some(root.clone().into()), extra: default_gopath.clone().map(Into::into), }) }) } // ------------------------------------------------------------------ typescript /// `node_modules/typescript/lib` of the project (root or a direct workspace member). pub(crate) fn workspace_tsdk(root: &Path) -> Option { let lib = root.join("node_modules/typescript/lib"); lib.join("tsserver.js").is_file().then_some(lib) } fn typescript(root: Option<&Path>) -> Vec { let mut out = Vec::new(); if let Some(lib) = root.and_then(workspace_tsdk) { let version = std::fs::read_to_string(lib.parent().unwrap_or(&lib).join("package.json")) .ok() .and_then(|t| serde_json::from_str::(&t).ok()) .and_then(|v| v.get("version").and_then(|v| v.as_str()).map(str::to_owned)); out.push(Sdk { path: lib.clone().into(), name: format!("TypeScript {}", version.clone().unwrap_or_default()).trim().to_owned(), version, source: "workspace".into(), home: lib.parent().map(Into::into), extra: None, }); } out } // ------------------------------------------------------------------ jdk /// `KEY="value"` of a JDK `release` file. fn release_value(release: &str, key: &str) -> Option { let line = release.lines().find_map(|l| l.trim().strip_prefix(key)?.strip_prefix('='))?; Some(line.trim().trim_matches('"').to_owned()).filter(|v| !v.is_empty()) } fn jdk() -> Vec { crate::java::rescan_jdks().iter().map(jdk_sdk).collect() } /// A JDK as the SDK pickers list it (`Eclipse Adoptium 25.0.1`). pub fn jdk_sdk(j: &crate::java::Jdk) -> Sdk { let java_home = std::env::var_os("JAVA_HOME").and_then(|h| crate::launch_path(Path::new(&h)).ok()); let release = std::fs::read_to_string(j.home.join("release")).unwrap_or_default(); let version = release_value(&release, "JAVA_VERSION").unwrap_or_else(|| j.major.to_string()); let name = match release_value(&release, "IMPLEMENTOR") { Some(vendor) => format!("{vendor} {version}"), None => format!("JDK {version}"), }; let source = if java_home.as_ref() == Some(&j.home) { "JAVA_HOME" } else if crate::java::is_downloaded(&j.home) { "downloaded" } else { version_managers::manager_of(&j.home).unwrap_or("system") }; Sdk { path: j.home.clone().into(), name, version: Some(version), source: source.to_owned(), home: Some(j.home.clone().into()), extra: None, } } // ------------------------------------------------------------------ maven /// Maven version from `lib/maven-core-.jar`. fn maven_version(home: &Path) -> Option { std::fs::read_dir(home.join("lib")).ok()?.flatten().find_map(|e| { let name = e.file_name().to_string_lossy().into_owned(); Some(name.strip_prefix("maven-core-")?.strip_suffix(".jar")?.to_owned()) }) } /// A Maven home (`bin/mvn` and `lib/`); Homebrew's formula keeps it in `libexec`. fn maven_home(dir: &Path) -> Option { let mvn = if cfg!(windows) { "mvn.cmd" } else { "mvn" }; [dir.join("libexec"), dir.to_path_buf()] .into_iter() .find(|h| h.join("bin").join(mvn).is_file() && h.join("lib").is_dir()) } fn maven() -> Vec { let mut found: Vec<(PathBuf, &'static str)> = Vec::new(); // Windows' Maven ships `mvn.cmd` only (`which` looks for `mvn.exe`). let on_path = if cfg!(windows) { std::env::split_paths(&search_path()).map(|d| d.join("mvn.cmd")).find(|p| p.is_file()) } else { which("mvn") }; if let Some(mvn) = on_path.and_then(|p| crate::launch_path(&p).ok()) && let Some(home) = mvn.parent().and_then(Path::parent) { found.push((home.to_path_buf(), "path")); } for var in ["MAVEN_HOME", "M2_HOME"] { if let Some(h) = std::env::var_os(var) { found.push((PathBuf::from(h), "env")); } } for p in ["/opt/homebrew/opt/maven", "/usr/local/opt/maven", "/usr/share/maven", "/opt/maven"] { found.push((PathBuf::from(p), "system")); } if let Some(home) = home_dir() { for d in subdirs(&home.join(".sdkman/candidates/maven")) { found.push((d, "sdkman")); } for (d, manager) in version_managers::installs(Tool::Maven) { found.extend(subdirs(&d).into_iter().map(|s| (s, manager))); found.push((d, manager)); } // Distributions the Maven wrapper downloaded: `dists/apache-maven-3.9.6-bin//apache-maven-3.9.6`, // or since wrapper 3.3 the home itself, `dists/apache-maven-3.9.6/`. for dist in subdirs(&home.join(".m2/wrapper/dists")) { for hash in subdirs(&dist) { found.extend(subdirs(&hash).into_iter().map(|d| (d, "wrapper"))); found.push((hash, "wrapper")); } } } let mut seen = HashSet::new(); let mut out = Vec::new(); for (dir, source) in found { let Some(home) = maven_home(&dir) else { continue }; let home = crate::launch_path(&home).unwrap_or(home); if !seen.insert(home.clone()) { continue; } let version = maven_version(&home); out.push(Sdk { path: home.clone().into(), name: format!("Maven {}", version.clone().unwrap_or_default()).trim().to_owned(), version, source: source.to_owned(), home: Some(home.into()), extra: None, }); } out } #[cfg(all(test, windows))] mod maven_path_tests { use super::*; use crate::launch_path; use std::process::Command; struct Subst { name: String, root: PathBuf, } impl Subst { fn new(target: &Path) -> Self { // Tests run in parallel and may race for the same free letter; only a drive that // shows this target's marker is ours. let marker = format!("{}.subst", target.file_name().unwrap().to_string_lossy()); std::fs::write(target.join(&marker), "").unwrap(); for letter in (b'D'..=b'Z').rev() { let name = format!("{}:", char::from(letter)); let root = PathBuf::from(format!("{name}\\")); if root.exists() { continue; } let output = Command::new("subst").arg(&name).arg(target).output().unwrap(); if output.status.success() && root.join(&marker).exists() { return Self { name, root }; } } panic!("no free drive for Maven path verification"); } } impl Drop for Subst { fn drop(&mut self) { let _ = Command::new("subst").args([&self.name, "/D"]).output(); } } fn install(home: &Path) { std::fs::create_dir_all(home.join("bin")).unwrap(); std::fs::create_dir_all(home.join("lib")).unwrap(); std::fs::write(home.join("bin/mvn.cmd"), "@echo off\n").unwrap(); std::fs::write(home.join("lib/maven-core-3.9.16.jar"), "").unwrap(); } #[test] fn mapped_maven_home_and_launcher_keep_their_drive() { let mapped = tempfile::tempdir().unwrap(); install(&mapped.path().join("apache-maven")); let drive = Subst::new(mapped.path()); let home = drive.root.join("apache-maven"); let launcher = home.join("bin/mvn.cmd"); assert_ne!(dunce::canonicalize(&home).unwrap(), home); assert_eq!(launch_path(&home).unwrap(), home); assert_eq!(launch_path(&launcher).unwrap(), launcher); assert_eq!(maven_home(&home), Some(home.clone())); assert_eq!(maven_version(&home).as_deref(), Some("3.9.16")); let jdk = drive.root.join("jdk"); std::fs::create_dir_all(jdk.join("bin")).unwrap(); std::fs::write(jdk.join("bin/java.exe"), "").unwrap(); std::fs::write(jdk.join("release"), "JAVA_VERSION=\"17.0.9\"\n").unwrap(); assert_eq!(crate::java::jdk_at(&jdk).unwrap().home, jdk); } #[test] fn a_launcher_link_outside_the_mapped_root_still_finds_its_install() { let mapped = tempfile::tempdir().unwrap(); let real = tempfile::tempdir().unwrap(); install(real.path()); let drive = Subst::new(mapped.path()); let link = mapped.path().join("shims"); let output = Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())) .args(["/D", "/C", "mklink", "/J"]) .arg(&link) .arg(real.path().join("bin")) .output() .unwrap(); assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); let launcher = launch_path(&drive.root.join("shims/mvn.cmd")).unwrap(); assert_eq!(launcher, dunce::canonicalize(real.path().join("bin/mvn.cmd")).unwrap()); let home = launcher.parent().and_then(Path::parent).and_then(maven_home).unwrap(); assert_eq!(home, dunce::canonicalize(real.path()).unwrap()); } }