diff --git a/Cargo.lock b/Cargo.lock index 4aa0033d6..965879525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4746,6 +4746,7 @@ dependencies = [ "ide-run", "ide-vfs", "ignore", + "quick-xml 0.42.0", "serde", "serde_json", "specta", diff --git a/crates/ide-sdk/Cargo.toml b/crates/ide-sdk/Cargo.toml index 374bfee6d..8d1b79a3d 100644 --- a/crates/ide-sdk/Cargo.toml +++ b/crates/ide-sdk/Cargo.toml @@ -12,6 +12,7 @@ ide-core.workspace = true ide-language.workspace = true ide-run.workspace = true ide-vfs.workspace = true +quick-xml = "0.42" serde.workspace = true serde_json.workspace = true specta.workspace = true diff --git a/crates/ide-sdk/src/libraries.rs b/crates/ide-sdk/src/libraries.rs index 0e8c3e403..db48e264c 100644 --- a/crates/ide-sdk/src/libraries.rs +++ b/crates/ide-sdk/src/libraries.rs @@ -14,6 +14,7 @@ use crate::exe; use crate::exec::{query_in, query_in_cancellable, which}; use ide_core::CancellationToken; use ide_core::settings::Settings; +use quick_xml::{Reader, XmlVersion, events::Event}; use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::HashMap; @@ -27,7 +28,7 @@ use std::time::{Duration, SystemTime}; pub struct LibraryGroup { /// Display name, e.g. `Python 3.12 (.venv)`. pub name: String, - /// `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle`. + /// `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle` | `classpath`. pub kind: String, pub roots: Vec, } @@ -148,6 +149,7 @@ pub fn project_libraries_stream( return Vec::new(); } let mut groups = dependency_groups(root, &settings, dependencies); + groups.extend(classpath_groups(&roots)); groups.extend(project_structure_groups(root, &settings)); if !cancel.is_cancelled() && !groups.is_empty() { on_groups(groups.clone()); @@ -433,10 +435,20 @@ fn ruby_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec Vec { + // Java modules without a build manifest carry `.classpath` / `.project` / + // `.idea` instead; a configured JDK or declared source roots mark one too. 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(), + None => { + let java = [".classpath", ".project"].iter().any(|f| root.join(f).is_file()) + || root.join(".idea").is_dir() + || !s.languages.java.jdk.trim().is_empty() + || !s.project_structure.source_roots.is_empty(); + if !java { + return Vec::new(); + } + [".classpath", ".project"].iter().map(|f| root.join(f)).collect() + } }; cached_cancellable(root, "jdk", s.languages.java.jdk.clone(), &files, cancel, || { let jdk = crate::apply::project_jdk(root, s).or_else(|| { @@ -470,6 +482,84 @@ fn project_structure_groups(root: &Path, s: &Settings) -> Vec { out } +/// JDT libraries of modules without a Maven / Gradle manifest: each `.classpath` +/// in `roots` (the project root and its discovered modules) becomes a group. +fn classpath_groups(roots: &[PathBuf]) -> Vec { + let mut out = Vec::new(); + for dir in roots { + let file = dir.join(".classpath"); + let Ok(text) = std::fs::read_to_string(&file) else { continue }; + let mut seen = std::collections::HashSet::new(); + let entries: Vec = classpath_entries(dir, &text) + .into_iter() + .filter(|path| seen.insert(dunce::canonicalize(path).unwrap_or_else(|_| path.clone()))) + .filter_map(|path| { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + if path.is_file() { + Some(LibraryRoot { name, path: ide_vfs::archive_root(&path).into() }) + } else { + path.is_dir().then(|| LibraryRoot { name, path: path.into() }) + } + }) + .collect(); + if !entries.is_empty() { + let name = dir + .file_name() + .map(|n| format!(".classpath ({})", n.to_string_lossy())) + .unwrap_or_else(|| ".classpath".into()); + out.push(LibraryGroup { name, kind: "classpath".into(), roots: entries }); + } + } + out +} + +/// `path` (or its `sourcepath`, when it exists) of `.classpath` `kind="lib"` +/// entries resolved against the module dir; entries pointing nowhere are dropped. +pub(crate) fn classpath_entries(dir: &Path, text: &str) -> Vec { + let mut out = Vec::new(); + let mut reader = Reader::from_str(text); + loop { + match reader.read_event() { + Ok(Event::Empty(ref tag)) | Ok(Event::Start(ref tag)) => { + if !tag.local_name().as_ref().eq_ignore_ascii_case("classpathentry") { + continue; + } + let (mut kind, mut path, mut srcpath) = (String::new(), String::new(), String::new()); + for a in tag.attributes().flatten() { + let Ok(value) = a.normalized_value(XmlVersion::Implicit1_0) else { continue }; + match a.key.as_ref() { + "kind" => kind = value.into_owned(), + "path" => path = value.into_owned(), + "sourcepath" => srcpath = value.into_owned(), + _ => {} + } + } + if kind != "lib" { + continue; + } + for p in [&srcpath, &path] { + let p = p.trim_matches('/'); + if p.is_empty() { + continue; + } + let candidate = dir.join(p); + if candidate.exists() { + out.push(candidate); + break; + } + } + } + Ok(Event::Eof) => break, + Err(_) => break, + _ => {} + } + } + 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 { let name = jdk_name(&jdk.home).unwrap_or_else(|| format!("JDK {}", jdk.major)); @@ -1108,4 +1198,52 @@ mod go_cache_tests { cached(dir.path(), "go", "same".into(), &[], detect); assert_eq!(count.get(), 2); } + + #[test] + fn classpath_entries_resolve_lib_paths_against_the_module() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("guava.jar"), "").unwrap(); + std::fs::create_dir_all(root.join("classes")).unwrap(); + std::fs::create_dir_all(root.join("lib")).unwrap(); + std::fs::write(root.join("lib/servlet.jar"), "").unwrap(); + let text = r#" + + + + + + + + +"#; + std::fs::write(root.join("sources.zip"), "").unwrap(); + assert_eq!( + classpath_entries(root, text), + vec![root.join("sources.zip"), root.join("classes"), root.join("lib/servlet.jar")] + ); + } + + #[test] + fn classpath_groups_lists_jars_per_module() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join(".classpath"), r#""#) + .unwrap(); + std::fs::write(root.join("a.jar"), "").unwrap(); + let module = root.join("module-b"); + std::fs::create_dir_all(&module).unwrap(); + std::fs::write( + module.join(".classpath"), + r#""#, + ) + .unwrap(); + std::fs::write(root.join("shared.jar"), "").unwrap(); + let groups = classpath_groups(&[root.to_path_buf(), module]); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].kind, "classpath"); + assert_eq!(groups[0].roots.len(), 1); + assert!(groups[0].roots[0].path.to_string_lossy().ends_with("a.jar!")); + assert_eq!(groups[1].roots.len(), 1); + } } diff --git a/src/bindings.ts b/src/bindings.ts index 6c53018d3..35832e812 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5552,7 +5552,7 @@ export type LaunchConfig = { export type LibraryGroup = { /** Display name, e.g. `Python 3.12 (.venv)`. */ name: string, - /** `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle`. */ + /** `python` | `jdk` | `nodeModules` | `nodeCore` | `rust` | `cargo` | `go` | `goModules` | `maven` | `gradle` | `classpath`. */ kind: string, roots: LibraryRoot[], }; diff --git a/src/features/libraries/index.ts b/src/features/libraries/index.ts index 2cedfa775..a4c3ebe68 100644 --- a/src/features/libraries/index.ts +++ b/src/features/libraries/index.ts @@ -29,6 +29,7 @@ const GROUP_ICONS: Record = { goModules: "file-gomod", maven: "maven-project", gradle: "file-gradle", + classpath: "file-java", ruby: "file-ruby", gems: "file-ruby", }; @@ -58,6 +59,7 @@ const MANIFESTS = new Set([ "go.mod", "go.sum", "go.work", + ".classpath", "pyproject.toml", "setup.py", "requirements.txt",