Files
openidea/crates/ide-sdk/src/libraries.rs
l0ng-ai 3407195dc0 fix(libraries): discover dependencies across project modules (#2022)
Fixes #1977. External Libraries only inspected the project root and cached incomplete results, so ecosystems in nested modules/workspace members (e.g. a pnpm frontend under a Cargo + Maven repo) and dependencies installed after the first listing never appeared. Providers now discover module/workspace roots (package.json workspaces, pnpm-workspace.yaml, Cargo members, nested manifests), follow pnpm symlinks, include local Cargo path dependencies, stream groups to the Project view as each provider finishes so a slow cargo metadata doesn't hide the others, log failures instead of dropping them, and don't cache cancelled or failed results.
2026-11-08 00:05:41 +08:00

1112 lines
47 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! "External Libraries" of a project (IDEA's library / SDK roots in the
//! Project view): the folders holding the sources of the project's SDKs and
//! dependencies — the Python interpreter's standard library and site-packages,
//! the JDK, `node_modules` packages, Cargo crates and the Rust standard
//! library, GOROOT and the Go module cache.
//!
//! Ecosystems are detected in the project and its modules. SDK and graph probes
//! are cached until manifests or settings change; Node packages are read on refresh.
//!
//! Archives are listed as browsable roots (`<archive>!`, see `ide_vfs`): the
//! JDK's `src.zip` and the Maven / Gradle dependency jars (source attachments resolved by JDT).
use crate::exe;
use crate::exec::{query_in, query_in_cancellable, which};
use ide_core::CancellationToken;
use ide_core::settings::Settings;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, SystemTime};
/// One SDK or library with its source folders (`< Python 3.12 (.venv) >`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryGroup {
/// Display name, e.g. `Python 3.12 (.venv)`.
pub name: String,
/// `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle`.
pub kind: String,
pub roots: Vec<LibraryRoot>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryRoot {
/// Folder name, e.g. `python3.12` or `site-packages`, the package (`serde 1.0.200`)
/// or the archive (`src.zip`, `guava-33.0.0-jre-sources.jar`).
pub name: String,
/// A folder, or an archive's root (`<archive>!`).
pub path: ide_core::IpcPath,
}
const QUERY_TIMEOUT: Duration = Duration::from_secs(10);
/// `cargo metadata` / `go list` load the whole dependency graph.
const GRAPH_TIMEOUT: Duration = Duration::from_secs(60);
/// Prints the interpreter's library folders as JSON: `[stdlib, platstdlib, purelib, platlib]`.
const PYTHON_PATHS: &str = "import json,sysconfig;p=sysconfig.get_paths();\
print(json.dumps([p.get(k) for k in ('stdlib','platstdlib','purelib','platlib')]))";
const JAVA_MANIFESTS: &[&str] =
&["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"];
const CARGO_MANIFESTS: &[&str] = &["Cargo.toml", "Cargo.lock", "rust-toolchain", "rust-toolchain.toml"];
const GO_MANIFESTS: &[&str] = &["go.mod", "go.sum", "go.work"];
const RUBY_MANIFESTS: &[&str] = &["Gemfile", "Gemfile.lock", ".ruby-version", ".tool-versions", "mise.toml"];
/// A Maven / Gradle dependency of the project, from the build model.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BuildDependency {
/// Resolved by Gradle (else Maven): looked up in the Gradle cache first.
pub gradle: bool,
pub group: String,
pub artifact: String,
pub version: String,
}
/// The project's SDK and dependency library groups. Runs the
/// toolchains (in parallel), so call it off the UI / command thread.
/// `dependencies` (from the build model) are listed when their jars are in the local repositories.
pub fn project_libraries(root: &Path, settings: &Settings, dependencies: &[BuildDependency]) -> Vec<LibraryGroup> {
project_libraries_stream(root, settings, dependencies, &CancellationToken::new(), &|_| {})
}
/// Publishes each ecosystem independently; a failed or slow graph cannot hide other libraries.
pub fn project_libraries_stream(
root: &Path,
settings: &Settings,
dependencies: &[BuildDependency],
cancel: &CancellationToken,
on_groups: &(dyn Fn(Vec<LibraryGroup>) + Sync),
) -> Vec<LibraryGroup> {
type Detect = fn(&Path, &Settings, &CancellationToken) -> Vec<LibraryGroup>;
let detectors: [Detect; 5] = [python_groups, jdk_groups, node_groups, go_groups, ruby_groups];
let roots = library_project_roots(root, cancel);
let settings = module_settings(root, settings);
std::thread::scope(|s| {
let handles: Vec<_> = detectors
.iter()
.map(|d| {
let (roots, settings) = (&roots, &settings);
s.spawn(move || {
let mut groups = Vec::new();
for dir in roots {
if cancel.is_cancelled() {
break;
}
let batch = d(dir, settings, cancel);
if !cancel.is_cancelled() && !batch.is_empty() {
on_groups(batch.clone());
groups.extend(batch);
}
}
groups
})
})
.collect();
let rust = s.spawn(|| {
let mut seen = std::collections::HashSet::new();
let mut groups = Vec::new();
for dir in &roots {
if cancel.is_cancelled() {
break;
}
if !dir.join("Cargo.toml").is_file() {
continue;
}
let bin = crate::apply::rust_bin(dir, &settings).map(|(bin, _)| bin);
let workspace = tool(bin, "cargo")
.and_then(|cargo| {
query_in_cancellable(
&cargo,
&["locate-project", "--workspace", "--message-format", "plain"],
dir,
&[],
QUERY_TIMEOUT,
cancel,
)
})
.map(PathBuf::from)
.filter(|manifest| manifest.is_file())
.and_then(|manifest| manifest.parent().map(Path::to_path_buf))
.unwrap_or_else(|| dir.clone());
if !seen.insert(workspace.clone()) {
continue;
}
let batch = rust_groups(&workspace, &settings, cancel);
if !cancel.is_cancelled() && !batch.is_empty() {
on_groups(batch.clone());
groups.extend(batch);
}
}
groups
});
let deps = s.spawn(|| {
if cancel.is_cancelled() {
return Vec::new();
}
let mut groups = dependency_groups(root, &settings, dependencies);
groups.extend(project_structure_groups(root, &settings));
if !cancel.is_cancelled() && !groups.is_empty() {
on_groups(groups.clone());
}
groups
});
merge_groups(
handles
.into_iter()
.chain([rust, deps])
.flat_map(|h| {
h.join().unwrap_or_else(|error| {
tracing::warn!(?error, "external libraries provider panicked");
Vec::new()
})
})
.collect(),
)
})
}
fn library_project_roots(root: &Path, cancel: &CancellationToken) -> Vec<PathBuf> {
use ide_core::project_roots::{
SEARCH_DEPTH, discover_matching_cancellable, expand_members, node_workspace_members,
};
let mut roots = discover_matching_cancellable(root, SEARCH_DEPTH, false, cancel, |dir| {
[
"package.json",
"pnpm-workspace.yaml",
"Cargo.toml",
"go.mod",
"go.work",
"Gemfile",
".ruby-version",
"pyproject.toml",
"setup.py",
"requirements.txt",
".python-version",
".tool-versions",
"mise.toml",
]
.iter()
.chain(JAVA_MANIFESTS)
.any(|name| dir.join(name).is_file())
|| [".venv", "venv", ".pixi"].iter().any(|name| dir.join(name).is_dir())
|| dir == root
});
let mut scanned = std::collections::HashSet::new();
let mut index = 0;
while index < roots.len() && !cancel.is_cancelled() {
let dir = roots[index].clone();
index += 1;
if !scanned.insert(dir.clone()) {
continue;
}
let mut members = node_workspace_members(&dir);
if let Some(manifest) = std::fs::read_to_string(dir.join("Cargo.toml"))
.ok()
.and_then(|text| toml::from_str::<toml::Value>(&text).ok())
{
let patterns: Vec<String> = manifest
.get("workspace")
.and_then(|w| w.get("members"))
.and_then(toml::Value::as_array)
.into_iter()
.flatten()
.filter_map(toml::Value::as_str)
.map(str::to_owned)
.collect();
members.extend(expand_members(&dir, &patterns, "Cargo.toml"));
}
if let Ok(work) = std::fs::read_to_string(dir.join("go.work")) {
members.extend(ide_run::go::work_modules(&work).into_iter().map(|path| dir.join(path)));
}
roots.extend(
members
.into_iter()
.map(|dir| ide_core::project_roots::resolve(&dir, Path::new("")))
.filter(|dir| !scanned.contains(dir)),
);
}
roots.sort();
roots.dedup();
roots
}
fn module_settings(root: &Path, settings: &Settings) -> Settings {
let mut settings = settings.clone();
let l = &mut settings.languages;
for value in [
&mut l.python.interpreter,
&mut l.ruby.interpreter,
&mut l.node.interpreter,
&mut l.rust.toolchain,
&mut l.rust.stdlib,
&mut l.go.goroot,
&mut l.go.gopath,
&mut l.java.jdk,
] {
if let Some(path) = crate::apply::setting_path(root, value) {
*value = path.to_string_lossy().into_owned();
}
}
settings
}
pub fn merge_groups(groups: Vec<LibraryGroup>) -> Vec<LibraryGroup> {
let mut merged: Vec<LibraryGroup> = Vec::new();
for group in groups {
if let Some(existing) = merged.iter_mut().find(|g| g.kind == group.kind && g.name == group.name) {
for root in group.roots {
if !existing.roots.iter().any(|r| r.path == root.path) {
existing.roots.push(root);
}
}
} else {
merged.push(group);
}
}
merged
}
// ------------------------------------------------------------------ cache
/// Size and modification time of each manifest (`None`: missing).
pub(crate) type Stamp = Vec<(PathBuf, Option<(SystemTime, u64)>)>;
struct Cached {
/// Settings the result depends on.
key: String,
stamp: Stamp,
groups: Vec<LibraryGroup>,
}
static CACHE: LazyLock<Mutex<HashMap<(PathBuf, &'static str), Cached>>> = LazyLock::new(Default::default);
pub fn invalidate_go(root: &Path) {
CACHE.lock().unwrap_or_else(|e| e.into_inner()).remove(&(root.to_path_buf(), "go"));
}
pub(crate) fn stamp(files: &[PathBuf]) -> Stamp {
files
.iter()
.map(|f| {
let meta = std::fs::metadata(f).ok().and_then(|m| Some((m.modified().ok()?, m.len())));
(f.clone(), meta)
})
.collect()
}
/// `detect()` unless the last result for `(root, ecosystem)` was computed with
/// the same `key` and unchanged `manifests`.
pub(crate) fn cached(
root: &Path,
ecosystem: &'static str,
key: String,
manifests: &[PathBuf],
detect: impl FnOnce() -> Vec<LibraryGroup>,
) -> Vec<LibraryGroup> {
cached_optional(root, ecosystem, key, manifests, || Some(detect())).unwrap_or_default()
}
fn cached_cancellable(
root: &Path,
ecosystem: &'static str,
key: String,
manifests: &[PathBuf],
cancel: &CancellationToken,
detect: impl FnOnce() -> Vec<LibraryGroup>,
) -> Vec<LibraryGroup> {
cached_optional(root, ecosystem, key, manifests, || {
let groups = detect();
(!cancel.is_cancelled()).then_some(groups)
})
.unwrap_or_default()
}
fn cached_optional(
root: &Path,
ecosystem: &'static str,
key: String,
manifests: &[PathBuf],
detect: impl FnOnce() -> Option<Vec<LibraryGroup>>,
) -> Option<Vec<LibraryGroup>> {
let id = (root.to_path_buf(), ecosystem);
let now = stamp(manifests);
if let Some(c) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).get(&id)
&& c.key == key
&& c.stamp == now
{
return Some(c.groups.clone());
}
let groups = detect()?;
CACHE.lock().unwrap_or_else(|e| e.into_inner()).insert(id, Cached { key, stamp: now, groups: groups.clone() });
Some(groups)
}
/// `names` below `root` when at least one of them exists (the project uses that ecosystem).
fn manifests(root: &Path, names: &[&str]) -> Option<Vec<PathBuf>> {
let files: Vec<PathBuf> = names.iter().map(|n| root.join(n)).collect();
files.iter().any(|f| f.is_file()).then_some(files)
}
fn group(name: impl Into<String>, kind: &str, roots: Vec<LibraryRoot>) -> Option<LibraryGroup> {
(!roots.is_empty()).then(|| LibraryGroup { name: name.into(), kind: kind.into(), roots })
}
/// A configured toolchain `bin` dir's `name`, else `name` on the search path.
fn tool(bin: Option<PathBuf>, name: &str) -> Option<PathBuf> {
bin.map(|b| b.join(exe(name))).filter(|p| p.is_file()).or_else(|| which(name))
}
// ------------------------------------------------------------------ python
fn python_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<LibraryGroup> {
let Some(python) = crate::apply::python_interpreter(root, s) else { return Vec::new() };
let mut files = vec![python.clone()];
files.extend(crate::venv_of(&python).map(|v| v.join("pyvenv.cfg")));
cached_cancellable(root, "python", python.display().to_string(), &files, cancel, || {
python_libraries(&python, Some(root)).into_iter().collect()
})
}
/// Standard library and site-packages folders of a Python interpreter.
pub fn python_libraries(python: &Path, root: Option<&Path>) -> Option<LibraryGroup> {
// In a neutral directory: the working directory comes first on `sys.path` for `-c`.
let dir = crate::python::neutral_dir();
let out = query_in(python, &["-c", PYTHON_PATHS], dir.as_ref().map(|d| d.path()), &[], QUERY_TIMEOUT)?;
let name = crate::discover::python_sdk(python, "system", root).map(|s| s.name).unwrap_or_else(|| "Python".into());
group(name, "python", library_roots(parse_python_paths(&out)))
}
pub(crate) fn parse_python_paths(out: &str) -> Vec<PathBuf> {
let json = out.lines().rev().find(|l| l.trim_start().starts_with('[')).unwrap_or("[]");
serde_json::from_str::<Vec<Option<String>>>(json)
.unwrap_or_default()
.into_iter()
.flatten()
.map(PathBuf::from)
.collect()
}
/// Existing folders among `paths`, deduplicated (also through symlinks), in order.
pub(crate) fn library_roots(paths: Vec<PathBuf>) -> Vec<LibraryRoot> {
named_roots(
paths
.into_iter()
.map(|p| {
(p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.display().to_string()), p)
})
.collect(),
)
}
/// Existing folders among `(name, path)`, deduplicated (also through symlinks), in order.
pub(crate) fn named_roots(roots: Vec<(String, PathBuf)>) -> Vec<LibraryRoot> {
let mut seen = std::collections::HashSet::new();
roots
.into_iter()
.filter(|(_, p)| p.is_dir() && seen.insert(dunce::canonicalize(p).unwrap_or_else(|_| p.clone())))
.map(|(name, p)| LibraryRoot { name, path: p.into() })
.collect()
}
// ------------------------------------------------------------------ ruby
fn ruby_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<LibraryGroup> {
let Some(files) = manifests(root, RUBY_MANIFESTS) else { return Vec::new() };
if !root.join("Gemfile").is_file() && !root.join(".ruby-version").is_file() {
return Vec::new();
}
let Some(ruby) = crate::apply::ruby_interpreter(root, s) else { return Vec::new() };
cached_cancellable(root, "ruby", ruby.display().to_string(), &files, cancel, || {
let name = crate::ruby::ruby_sdk(&ruby, "system").map(|s| s.name).unwrap_or_else(|| "Ruby".into());
let (stdlib, gems) = crate::ruby::library_roots(&ruby, Some(root));
let mut out = Vec::new();
out.extend(group(name, "ruby", named_roots(stdlib)));
out.extend(group("Gems", "gems", named_roots(gems)));
out
})
}
// ------------------------------------------------------------------ jdk
fn jdk_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<LibraryGroup> {
let files = match manifests(root, JAVA_MANIFESTS) {
Some(files) => files,
None if !s.languages.java.jdk.trim().is_empty() || !s.project_structure.source_roots.is_empty() => Vec::new(),
None => return Vec::new(),
};
cached_cancellable(root, "jdk", s.languages.java.jdk.clone(), &files, cancel, || {
let jdk = crate::apply::project_jdk(root, s).or_else(|| {
crate::java::pick_jdk(&crate::java::installed_jdks(), crate::java::project_java_version(root)).cloned()
});
jdk.and_then(|j| jdk_library(&j)).into_iter().collect()
})
}
fn project_structure_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
let mut out = Vec::new();
for lib in &s.project_structure.libraries {
let mut roots = Vec::new();
for entry in lib.classes.iter().chain(&lib.sources) {
let Some(path) = crate::apply::setting_path(root, entry) else { continue };
let label = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
if path.is_file() {
roots.push(LibraryRoot { name: label, path: ide_vfs::archive_root(&path).into() });
} else if path.is_dir() {
roots.push(LibraryRoot { name: label, path: path.into() });
}
}
if !roots.is_empty() {
let name = if lib.name.trim().is_empty() { roots[0].name.clone() } else { lib.name.trim().to_owned() };
out.push(LibraryGroup { name, kind: "library".into(), roots });
}
}
out
}
/// The JDK home and its sources (`lib/src.zip`, `src.zip` in JDK 8) as library roots.
pub(crate) fn jdk_library(jdk: &crate::java::Jdk) -> Option<LibraryGroup> {
let name = jdk_name(&jdk.home).unwrap_or_else(|| format!("JDK {}", jdk.major));
let mut roots = named_roots(vec![(name.clone(), jdk.home.clone())]);
if let Some(src) = ["lib/src.zip", "src.zip"].iter().map(|r| jdk.home.join(r)).find(|p| p.is_file()) {
roots.push(LibraryRoot { name: "src.zip".into(), path: ide_vfs::archive_root(&src).into() });
}
group(name, "jdk", roots)
}
/// `temurin-21` for `…/temurin-21.jdk/Contents/Home` (macOS bundles) or `…/temurin-21`.
pub(crate) fn jdk_name(home: &Path) -> Option<String> {
let dir = if home.ends_with("Contents/Home") { home.parent()?.parent()? } else { home };
let name = dir.file_name()?.to_string_lossy();
Some(name.strip_suffix(".jdk").unwrap_or(&name).to_owned())
}
// ------------------------------------------------------------------ maven / gradle
pub fn dependency_groups(root: &Path, s: &Settings, deps: &[BuildDependency]) -> Vec<LibraryGroup> {
if deps.is_empty() {
return Vec::new();
}
let maven_repo = crate::apply::maven_local_repository(root, s).or_else(default_maven_repository);
let gradle_home = gradle_user_home();
let files: Vec<PathBuf> = JAVA_MANIFESTS.iter().map(|n| root.join(n)).collect();
let key = format!("{maven_repo:?}\n{gradle_home:?}\n{deps:?}");
cached(root, "buildDependencies", key, &files, || {
dependency_libraries(deps, maven_repo.as_deref(), gradle_home.as_deref())
})
}
/// `~/.m2/repository` (Settings › Maven › Local repository overrides it).
fn default_maven_repository() -> Option<PathBuf> {
Some(crate::home_dir()?.join(".m2/repository"))
}
/// `$GRADLE_USER_HOME`, else `~/.gradle`.
fn gradle_user_home() -> Option<PathBuf> {
std::env::var_os("GRADLE_USER_HOME").map(PathBuf::from).or_else(|| Some(crate::home_dir()?.join(".gradle")))
}
/// One `Maven: g:a:v` / `Gradle: g:a:v` group per dependency whose jar is in a
/// local repository, its root being the binary jar so Java classes open through JDT's source provider.
pub(crate) fn dependency_libraries(
deps: &[BuildDependency],
maven_repo: Option<&Path>,
gradle_home: Option<&Path>,
) -> Vec<LibraryGroup> {
let mut deps: Vec<&BuildDependency> = deps.iter().collect();
deps.sort();
deps.dedup();
deps.into_iter()
.filter_map(|d| {
let maven = || maven_repo.and_then(|r| maven_jar(r, d));
let gradle = || gradle_home.and_then(|h| gradle_jar(h, d));
let jar = if d.gradle { gradle().or_else(maven) } else { maven().or_else(gradle) }?;
let tool = if d.gradle { "Gradle" } else { "Maven" };
let name = jar.file_name()?.to_string_lossy().into_owned();
group(
format!("{tool}: {}:{}:{}", d.group, d.artifact, d.version),
&tool.to_lowercase(),
vec![LibraryRoot { name, path: ide_vfs::archive_root(&jar).into() }],
)
})
.collect()
}
/// The `.toml` of a version catalog published as `group:artifact:version` (Gradle's
/// `version-catalog` plugin) that a build imports with `from("…")`: from Gradle's cache, else
/// the local Maven repository. `None` until a build has downloaded it.
pub fn published_version_catalog(root: &Path, s: &Settings, coordinates: &str) -> Option<PathBuf> {
let mut parts = coordinates.trim().split(':');
let d = BuildDependency {
gradle: true,
group: parts.next()?.to_owned(),
artifact: parts.next()?.to_owned(),
version: parts.next()?.to_owned(),
};
if parts.next().is_some() || !valid_coordinate(&d) {
return None;
}
let file = format!("{}-{}.toml", d.artifact, d.version);
let gradle = crate::apply::setting_path(root, &s.languages.java.gradle.user_home).or_else(gradle_user_home);
let cached = gradle.and_then(|home| {
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
let mut found: Vec<PathBuf> =
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path().join(&file)).filter(|p| p.is_file()).collect();
found.sort();
found.into_iter().next()
});
cached.or_else(|| {
let repo = crate::apply::maven_local_repository(root, s).or_else(default_maven_repository)?;
let path = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version).join(&file);
path.is_file().then_some(path)
})
}
/// No path separators, interpolations or `..` segments (coordinates become paths).
fn valid_coordinate(d: &BuildDependency) -> bool {
[&d.group, &d.artifact, &d.version]
.iter()
.all(|p| !p.is_empty() && !p.contains(['/', '\\', '$']) && !p.split('.').any(str::is_empty))
}
/// `<repo>/<group as path>/<artifact>/<version>/<artifact>-<version>(-sources).jar`.
pub(crate) fn maven_jar(repo: &Path, d: &BuildDependency) -> Option<PathBuf> {
if !valid_coordinate(d) {
return None;
}
let dir = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version);
[".jar", "-sources.jar"]
.iter()
.map(|suffix| dir.join(format!("{}-{}{suffix}", d.artifact, d.version)))
.find(|p| p.is_file())
}
/// `<gradle home>/caches/modules-2/files-2.1/<group>/<artifact>/<version>/<hash>/<artifact>-<version>(-sources).jar`.
pub(crate) fn gradle_jar(home: &Path, d: &BuildDependency) -> Option<PathBuf> {
if !valid_coordinate(d) {
return None;
}
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
let mut hashes: Vec<PathBuf> = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).collect();
hashes.sort();
[".jar", "-sources.jar"].iter().find_map(|suffix| {
let file = format!("{}-{}{suffix}", d.artifact, d.version);
hashes.iter().map(|h| h.join(&file)).find(|p| p.is_file())
})
}
// ------------------------------------------------------------------ node
fn node_groups(root: &Path, s: &Settings, _cancel: &CancellationToken) -> Vec<LibraryGroup> {
if !root.join("package.json").is_file() {
return Vec::new();
}
let node = tool(crate::apply::node_dir(root, s), "node");
node_libraries(root, node.as_deref())
}
/// `node_modules/<dep>` of the direct dependencies in `package.json`, and the
/// Node.js core typings (`@types/node`, from the project or the runtime's global `lib`).
pub(crate) fn node_libraries(root: &Path, node: Option<&Path>) -> Vec<LibraryGroup> {
let manifest = std::fs::read_to_string(root.join("package.json")).unwrap_or_else(|error| {
tracing::warn!(?root, %error, "cannot read package.json for external libraries");
String::new()
});
let resolve =
|name: &str| root.ancestors().map(|dir| dir.join("node_modules").join(name)).find(|path| path.is_dir());
let deps = named_roots(
package_dependencies(&manifest).into_iter().filter_map(|d| Some((d.clone(), resolve(&d)?))).collect(),
);
let mut core: Vec<_> = resolve("@types/node").into_iter().collect();
// `<prefix>/bin/node` → `<prefix>/lib/node_modules`.
if let Some(prefix) =
node.and_then(|n| dunce::canonicalize(n).ok()).and_then(|n| Some(n.parent()?.parent()?.to_path_buf()))
{
core.push(prefix.join("lib/node_modules/@types/node"));
}
let mut core = named_roots(core.into_iter().map(|p| ("@types/node".to_owned(), p)).collect());
core.truncate(1);
[group("Node.js Core", "nodeCore", core), group("node_modules", "nodeModules", deps)]
.into_iter()
.flatten()
.collect()
}
/// Names of the direct dependencies (all dependency kinds), sorted, deduplicated.
pub(crate) fn package_dependencies(package_json: &str) -> Vec<String> {
let json = match serde_json::from_str::<serde_json::Value>(package_json) {
Ok(json) => json,
Err(error) => {
tracing::warn!(%error, "cannot parse package.json for external libraries");
return Vec::new();
}
};
let mut names: Vec<String> = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]
.iter()
.filter_map(|k| json.get(k)?.as_object())
.flat_map(|o| o.keys().cloned())
.filter(|n| !n.is_empty() && !n.contains("..") && !n.starts_with('/'))
.collect();
names.sort();
names.dedup();
names
}
// ------------------------------------------------------------------ rust
fn rust_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<LibraryGroup> {
let Some(files) = manifests(root, CARGO_MANIFESTS) else { return Vec::new() };
if !root.join("Cargo.toml").is_file() {
return Vec::new();
}
let r = &s.languages.rust;
let bin = crate::apply::rust_bin(root, s).map(|(b, _)| b);
let mut out = cached_cancellable(root, "rust", format!("{}\n{}", r.toolchain, r.stdlib), &files, cancel, || {
let mut out = Vec::new();
if let Some(rustc) = tool(bin.clone(), "rustc") {
let version = query_in_cancellable(&rustc, &["--version"], root, &[], QUERY_TIMEOUT, cancel);
let library = crate::apply::setting_path(root, &r.stdlib).or_else(|| {
let sysroot = query_in_cancellable(&rustc, &["--print", "sysroot"], root, &[], QUERY_TIMEOUT, cancel)?;
Some(PathBuf::from(sysroot).join("lib/rustlib/src/rust/library"))
});
let name = match version.as_deref().and_then(|v| v.split_whitespace().nth(1)) {
Some(v) => format!("Rust {v}"),
None => "Rust".into(),
};
out.extend(library.and_then(|l| group(name, "rust", library_roots(vec![l]))));
}
out
});
if let Some(cargo) = tool(bin, "cargo") {
let graph = cached_optional(root, "cargo", r.toolchain.clone(), &files, || {
let json = query_in_cancellable(
&cargo,
&["metadata", "--format-version", "1", "--offline"],
root,
&[],
GRAPH_TIMEOUT,
cancel,
)?;
let roots = parse_cargo_metadata(&json)?;
Some(group("Cargo dependencies", "cargo", named_roots(roots)).into_iter().collect())
});
out.extend(graph.unwrap_or_else(|| {
let home = ide_core::process::host_var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| Some(crate::home_dir()?.join(".cargo")));
let roots = home.map(|home| cargo_lock_roots(root, &root.join(home))).unwrap_or_default();
group("Cargo dependencies", "cargo", named_roots(roots)).into_iter().collect()
}));
}
out
}
/// An incomplete offline graph still has useful downloaded sources recorded in Cargo.lock.
fn cargo_lock_roots(root: &Path, home: &Path) -> Vec<(String, PathBuf)> {
let lock = std::fs::read_to_string(root.join("Cargo.lock")).ok();
let Some(lock) = lock.and_then(|s| toml::from_str::<toml::Value>(&s).ok()) else {
return Vec::new();
};
let Some(packages) = lock.get("package").and_then(toml::Value::as_array) else {
return Vec::new();
};
let sources: Vec<PathBuf> =
std::fs::read_dir(home.join("registry/src")).into_iter().flatten().flatten().map(|e| e.path()).collect();
packages
.iter()
.filter_map(|p| {
let source = p.get("source")?.as_str()?;
// Other registries cannot be identified safely from a hashed cache folder alone.
if source != "registry+https://github.com/rust-lang/crates.io-index" {
return None;
}
let name = p.get("name")?.as_str()?;
let version = p.get("version")?.as_str()?;
let path = sources
.iter()
.filter(|dir| {
dir.file_name().is_some_and(|n| {
let n = n.to_string_lossy();
n.starts_with("index.crates.io-") || n.starts_with("github.com-")
})
})
.map(|dir| dir.join(format!("{name}-{version}")))
.find(|dir| dir.join("Cargo.toml").is_file())?;
Some((format!("{name} {version}"), path))
})
.collect()
}
/// `(name version, source dir)` of the non-workspace packages in `cargo metadata` output, by name.
pub(crate) fn parse_cargo_metadata(json: &str) -> Option<Vec<(String, PathBuf)>> {
#[derive(Deserialize)]
struct Package {
name: String,
version: String,
id: Option<String>,
source: Option<String>,
manifest_path: PathBuf,
}
#[derive(Deserialize)]
struct Metadata {
packages: Vec<Package>,
#[serde(default)]
workspace_members: Vec<String>,
}
let meta = match serde_json::from_str::<Metadata>(json) {
Ok(meta) => meta,
Err(error) => {
tracing::warn!(%error, "cannot parse Cargo metadata for external libraries");
return None;
}
};
let mut out: Vec<(String, PathBuf)> = meta
.packages
.into_iter()
.filter(|p| p.source.is_some() || p.id.as_ref().is_some_and(|id| !meta.workspace_members.contains(id)))
.filter_map(|p| Some((format!("{} {}", p.name, p.version), p.manifest_path.parent()?.to_path_buf())))
.collect();
out.sort_by_cached_key(|(name, _)| name.to_lowercase());
Some(out)
}
// ------------------------------------------------------------------ go
fn go_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<LibraryGroup> {
let Some(mut files) = manifests(root, GO_MANIFESTS) else { return Vec::new() };
if !root.join("go.mod").is_file() && !root.join("go.work").is_file() {
return Vec::new();
}
if let Ok(work) = std::fs::read_to_string(root.join("go.work")) {
for module in ide_run::go::work_modules(&work) {
files.extend([root.join(&module).join("go.mod"), root.join(&module).join("go.sum")]);
}
}
files.extend([root.join("go.work.sum"), root.join("vendor/modules.txt")]);
let g = &s.languages.go;
cached_cancellable(root, "go", serde_json::to_string(g).unwrap_or_default(), &files, cancel, || {
let goroot = crate::apply::goroot(root, s);
let Some(go) = tool(goroot.as_ref().map(|r| r.join("bin")), "go") else { return Vec::new() };
let goroot_s = goroot.as_ref().map(|p| p.to_string_lossy().into_owned());
let gopath = crate::apply::setting_path(root, &g.gopath).map(|p| p.to_string_lossy().into_owned());
// Never download modules or toolchains just to list libraries.
let module_env = crate::go::module_environment(root, &g.modules);
let mut env: Vec<(&str, &str)> = module_env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
// Listing libraries must stay offline even when module commands use a proxy.
env.extend([("GOPROXY", "off"), ("GOTOOLCHAIN", "local")]);
env.extend(goroot_s.as_deref().map(|v| ("GOROOT", v)));
env.extend(gopath.as_deref().map(|v| ("GOPATH", v)));
let mut out = Vec::new();
if let Some(vars) =
query_in_cancellable(&go, &["env", "GOROOT", "GOVERSION"], root, &env, QUERY_TIMEOUT, cancel)
{
let mut lines = vars.lines().map(str::trim);
let (dir, version) = (lines.next().unwrap_or_default(), lines.next().unwrap_or_default());
let name = match version.strip_prefix("go") {
Some(v) if !v.is_empty() => format!("Go SDK {v}"),
_ => "Go SDK".into(),
};
if !dir.is_empty() {
out.extend(group(name, "go", library_roots(vec![Path::new(dir).join("src")])));
}
}
let flags = module_env.get("GOFLAGS").map(String::as_str).unwrap_or("");
let vendor = root.join("vendor");
let use_vendor = vendor.is_dir()
&& (flags.split_whitespace().any(|f| f == "-mod=vendor")
|| (g.modules.auto_vendor && !flags.split_whitespace().any(|f| f.starts_with("-mod="))));
if use_vendor {
if let Ok(text) = std::fs::read_to_string(vendor.join("modules.txt")) {
let modules = vendor_roots(&vendor, &text);
out.extend(group("Go Modules", "goModules", named_roots(modules)));
}
} else if let Some(text) =
query_in_cancellable(&go, &["list", "-m", "-e", "-json", "all"], root, &env, GRAPH_TIMEOUT, cancel)
{
out.extend(group("Go Modules", "goModules", named_roots(parse_go_modules(&text))));
}
out
})
}
fn vendor_roots(vendor: &Path, text: &str) -> Vec<(String, PathBuf)> {
let mut seen = std::collections::HashSet::new();
text.lines()
.filter_map(|line| {
let module = line.strip_prefix("# ")?.split_whitespace().next()?;
if module == "=>" || !seen.insert(module) {
return None;
}
let path = vendor.join(module);
path.is_dir().then(|| (module.to_string(), path))
})
.collect()
}
/// `(path@version, dir)` of the downloaded non-main modules in `go list -m -json` output.
pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> {
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Module {
path: String,
version: Option<String>,
dir: Option<PathBuf>,
#[serde(default)]
main: bool,
}
serde_json::Deserializer::from_str(text)
.into_iter::<Module>()
.map_while(Result::ok)
.filter(|m| !m.main)
.filter_map(|m| {
let name = match &m.version {
Some(v) => format!("{}@{v}", m.path),
None => m.path.clone(),
};
Some((name, m.dir?))
})
.collect()
}
#[cfg(test)]
mod go_cache_tests {
use super::*;
#[test]
fn library_discovery_includes_declared_workspace_members_beyond_the_search_depth() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("package.json"), r#"{"workspaces":["nested/very/deep/packages/*"]}"#).unwrap();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers=['nested/very/deep/crates/*']\n").unwrap();
std::fs::write(root.join("go.work"), "go 1.22\nuse ./nested/very/deep/go\n").unwrap();
for (dir, marker) in [("packages/web", "package.json"), ("crates/api", "Cargo.toml"), ("go", "go.mod")] {
let member = root.join("nested/very/deep").join(dir);
std::fs::create_dir_all(&member).unwrap();
std::fs::write(member.join(marker), "").unwrap();
}
let roots = library_project_roots(root, &CancellationToken::new());
for member in ["packages/web", "crates/api", "go"] {
assert!(roots.contains(&root.join("nested/very/deep").join(member)), "{member}");
}
}
#[cfg(unix)]
#[test]
fn cancelled_sdk_queries_do_not_poison_the_next_refresh() {
use std::os::unix::fs::PermissionsExt;
use std::time::Instant;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Cargo.toml"), "[package]\nname='app'\nversion='0.1.0'\n").unwrap();
let bin = root.join("tools");
std::fs::create_dir(&bin).unwrap();
let library = root.join("stdlib");
std::fs::create_dir(&library).unwrap();
let started = root.join("rustc-started");
for (name, script) in [
("rustc", format!("#!/bin/sh\ntouch '{}'\nexec sleep 60\n", started.display())),
("cargo", "#!/bin/sh\nexit 1\n".into()),
] {
let path = bin.join(name);
std::fs::write(&path, script).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut settings = Settings::default();
settings.languages.rust.toolchain = "tools".into();
settings.languages.rust.stdlib = "stdlib".into();
let cancel = CancellationToken::new();
std::thread::scope(|scope| {
let task = scope.spawn(|| project_libraries_stream(root, &settings, &[], &cancel, &|_| {}));
let deadline = Instant::now() + Duration::from_secs(5);
while !started.is_file() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
cancel.cancel();
task.join().unwrap();
assert!(started.is_file());
});
std::fs::write(bin.join("rustc"), "#!/bin/sh\necho 'rustc 1.2.3'\n").unwrap();
let groups = project_libraries(root, &settings, &[]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].name, "Rust 1.2.3");
assert_eq!(groups[0].roots[0].path, library);
}
#[test]
fn libraries_keep_downloaded_crates_when_an_offline_graph_is_incomplete() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let home = tmp.path().join("cargo");
std::fs::create_dir_all(&root).unwrap();
let serde = home.join("registry/src/index.crates.io-test/serde-1.0.200");
std::fs::create_dir_all(&serde).unwrap();
std::fs::write(serde.join("Cargo.toml"), "").unwrap();
std::fs::write(
root.join("Cargo.lock"),
r#"
version = 4
[[package]]
name = "serde"
version = "1.0.200"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "not-downloaded"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#,
)
.unwrap();
assert_eq!(cargo_lock_roots(&root, &home), [("serde 1.0.200".into(), serde)]);
}
#[cfg(unix)]
#[test]
fn failed_cargo_queries_are_retried_without_manifest_changes() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers=['members/*']\n").unwrap();
let member = root.join("members/app");
std::fs::create_dir_all(&member).unwrap();
std::fs::write(member.join("Cargo.toml"), "[package]\nname='app'\nversion='0.1.0'\n").unwrap();
let bin = root.join("tools");
std::fs::create_dir(&bin).unwrap();
for name in ["cargo", "rustc"] {
let path = bin.join(name);
std::fs::write(&path, "#!/bin/sh\nexit 1\n").unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut settings = Settings::default();
settings.languages.rust.toolchain = "tools".into();
assert!(project_libraries(root, &settings, &[]).is_empty());
let source = root.join("local");
std::fs::create_dir(&source).unwrap();
let json = serde_json::json!({"packages": [{"id":"dep", "name":"dep", "version":"1.0.0", "source":null,
"manifest_path":source.join("Cargo.toml")}], "workspace_members":[]});
let calls = root.join("metadata-calls");
std::fs::write(bin.join("cargo"), format!("#!/bin/sh\nif [ \"$1\" = locate-project ]; then echo '{}'; exit; fi\necho called >> '{}'\ncat <<'JSON'\n{json}\nJSON\n", root.join("Cargo.toml").display(), calls.display())).unwrap();
let groups = project_libraries(root, &settings, &[]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].kind, "cargo");
assert_eq!(groups[0].roots[0].path, source);
assert_eq!(std::fs::read_to_string(calls).unwrap().lines().count(), 1);
}
#[cfg(unix)]
#[test]
fn libraries_publish_node_and_maven_while_cargo_is_running_and_cancel_it() {
use std::os::unix::fs::PermissionsExt;
use std::sync::mpsc;
use std::time::Instant;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("Cargo.toml"), "[package]\nname='app'\nversion='0.1.0'\n").unwrap();
std::fs::write(root.join("package.json"), r#"{"dependencies":{"dep":"1"}}"#).unwrap();
std::fs::create_dir_all(root.join("node_modules/dep")).unwrap();
let bin = root.join("tools");
std::fs::create_dir(&bin).unwrap();
let started = root.join("cargo-started");
for (name, script) in [
("rustc", "#!/bin/sh\nexit 1\n".to_owned()),
(
"cargo",
format!(
"#!/bin/sh\nif [ \"$1\" = locate-project ]; then echo '{}'; exit; fi\ntouch '{}'\nexec sleep 60\n",
root.join("Cargo.toml").display(),
started.display()
),
),
] {
let path = bin.join(name);
std::fs::write(&path, script).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut settings = Settings::default();
settings.languages.rust.toolchain = "tools".into();
settings.languages.java.maven.local_repository = root.join("m2").to_string_lossy().into_owned();
let jar = root.join("m2/g/a/1/a-1.jar");
std::fs::create_dir_all(jar.parent().unwrap()).unwrap();
std::fs::write(jar, "").unwrap();
let deps = [BuildDependency { gradle: false, group: "g".into(), artifact: "a".into(), version: "1".into() }];
let cancel = CancellationToken::new();
let (tx, rx) = mpsc::channel();
std::thread::scope(|scope| {
let task = scope.spawn(|| {
project_libraries_stream(root, &settings, &deps, &cancel, &|g| {
tx.send(g).unwrap();
})
});
let deadline = Instant::now() + Duration::from_secs(5);
let mut kinds = Vec::new();
while Instant::now() < deadline
&& !(kinds.contains(&"nodeModules".to_owned())
&& kinds.contains(&"maven".to_owned())
&& started.is_file())
{
if let Ok(groups) = rx.recv_timeout(Duration::from_millis(20)) {
kinds.extend(groups.into_iter().map(|g| g.kind));
}
}
cancel.cancel();
task.join().unwrap();
assert!(started.is_file(), "cargo query should have started");
assert!(kinds.contains(&"nodeModules".to_owned()));
assert!(kinds.contains(&"maven".to_owned()));
assert!(Instant::now() < deadline, "cancellation should stop the graph query promptly");
});
}
#[test]
fn finds_published_version_catalogs_in_the_gradle_cache_and_maven_repository() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut s = Settings::default();
s.languages.java.gradle.user_home = root.join("gradle").to_string_lossy().into_owned();
s.languages.java.maven.local_repository = root.join("m2").to_string_lossy().into_owned();
let cached = root.join("gradle/caches/modules-2/files-2.1/com.acme/catalog/1.2/abc123/catalog-1.2.toml");
std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
std::fs::write(&cached, "[libraries]").unwrap();
assert_eq!(published_version_catalog(root, &s, "com.acme:catalog:1.2"), Some(cached));
let local = root.join("m2/com/acme/other/2.0/other-2.0.toml");
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
std::fs::write(&local, "[libraries]").unwrap();
assert_eq!(published_version_catalog(root, &s, " com.acme:other:2.0 "), Some(local));
for missing in ["com.acme:catalog:9", "com.acme:catalog", "a:b:c:d", "../x:y:z"] {
assert_eq!(published_version_catalog(root, &s, missing), None, "{missing}");
}
}
#[test]
fn vendor_roots_include_local_replacements_once() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("example.com/local")).unwrap();
let text = "# example.com/local v0.0.0 => ../local\n## explicit; go 1.22\nexample.com/local\n# example.com/local => ../local\n";
assert_eq!(
vendor_roots(dir.path(), text),
vec![("example.com/local".into(), dir.path().join("example.com/local"))]
);
}
#[test]
fn go_commands_invalidate_the_graph_even_when_manifests_are_unchanged() {
let dir = tempfile::tempdir().unwrap();
let count = std::cell::Cell::new(0);
let detect = || {
count.set(count.get() + 1);
Vec::new()
};
cached(dir.path(), "go", "same".into(), &[], detect);
cached(dir.path(), "go", "same".into(), &[], detect);
assert_eq!(count.get(), 1);
invalidate_go(dir.path());
cached(dir.path(), "go", "same".into(), &[], detect);
assert_eq!(count.get(), 2);
}
}