fix(java): list JDT .classpath libraries and the JDK for manifest-less projects (#2300) (#2304)

* fix(java): list JDT .classpath libraries and the JDK for manifest-less projects (#2300)

External Libraries was empty for a pure-Java project (only .classpath /
.idea/libraries, no pom.xml or build.gradle):

- jdk_groups returned early without a build manifest, so even a
  configured JDK never appeared. It now also treats .classpath, .project,
  .idea, a configured JDK and declared source roots as Java markers.
- The JDT model was never a source of libraries. classpath_groups parses
  each module's .classpath kind="lib" entries (sourcepath preferred when
  it exists) into a per-module group, discovered through the same module
  scan the other ecosystems use.

Adds quick-xml to ide-sdk (already used by ide-run / ide-host) and the
.classpath file to the External Libraries refresh triggers.

* chore: regenerate bindings for classpath library kind
Цей коміт міститься в:
arookieofc authored and GitHub committed 2026-11-09 17:32:07 +08:00
1 джерело 2955cdcf9c
коміт 660587a6fe
5 files changed
+146 -4

No files matched your search

Створено
+1
Переглянути файл
@@ -4746,6 +4746,7 @@ dependencies = [
"ide-run",
"ide-vfs",
"ignore",
"quick-xml 0.42.0",
"serde",
"serde_json",
"specta",
+1
Переглянути файл
@@ -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
+141 -3
Переглянути файл
@@ -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<LibraryRoot>,
}
@@ -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<Lib
// ------------------------------------------------------------------ jdk
fn jdk_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<LibraryGroup> {
// 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<LibraryGroup> {
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<LibraryGroup> {
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<LibraryRoot> = 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<PathBuf> {
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<LibraryGroup> {
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#"<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" path="src"/>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER"/>
<classpathentry kind="lib" path="guava.jar" sourcepath="sources.zip"/>
<classpathentry kind="lib" path="classes"/>
<classpathentry kind="lib" path="/lib/servlet.jar"/>
<classpathentry kind="lib" path="missing.jar"/>
<classpathentry kind="output" path="bin"/>
</classpath>"#;
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#"<classpath><classpathentry kind="lib" path="a.jar"/></classpath>"#)
.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#"<classpath><classpathentry kind="lib" path="../shared.jar"/></classpath>"#,
)
.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);
}
}
+1 -1
Переглянути файл
@@ -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[],
};
+2
Переглянути файл
@@ -29,6 +29,7 @@ const GROUP_ICONS: Record<string, string> = {
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",