Compare commits

...
Author SHA1 Message Date
l0ng-ai 5526ed5865 fix(java): replace module outputs with launch snapshots on Windows
Maven launches copy each module's class directory into a snapshot and put
the snapshot on the classpath in place of the original. The originals came
from `module.join("target/classes")`, which on Windows reads
`...\app\target/classes`, while JDT LS reports `...\app\target\classes`.
Compared as text, the original was never removed, so every resource was on
the classpath twice; MyBatis then failed with "XML fragments parsed from
previous mappers already contains value", and any classpath* scan saw
duplicates. Mixed-language builds deduplicated their outputs the same way.

Launch class directories are joined per component, and classpath entries
are compared by a key that ignores the verbatim prefix, the separator,
trailing separators and, on Windows, case.
2026-11-03 20:27:11 +08:00
2 changed files with 96 additions and 7 deletions

No files matched your search

+31 -4
View File
@@ -158,8 +158,11 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
}
/// `root/rel`, without a trailing separator for the root itself.
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
/// as other tools (JDT LS) spell it.
fn subdir(root: &Path, rel: &str) -> PathBuf {
if rel.is_empty() { root.to_path_buf() } else { root.join(rel) }
rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
}
fn rel_str(root: &Path, dir: &Path) -> String {
@@ -737,11 +740,11 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
.iter()
.flat_map(|s| {
["classes/kotlin", "classes/groovy", "classes/scala", "classes/java", "resources"]
.map(|d| module.join("build").join(d).join(s))
.map(|d| subdir(module, &format!("build/{d}/{s}")))
})
.collect(),
BuildSystem::Maven if test => vec![module.join("target/test-classes"), module.join("target/classes")],
BuildSystem::Maven => vec![module.join("target/classes")],
BuildSystem::Maven if test => vec![subdir(module, "target/test-classes"), subdir(module, "target/classes")],
BuildSystem::Maven => vec![subdir(module, "target/classes")],
_ => Vec::new(),
};
dirs.into_iter().filter(|d| d.is_dir()).collect()
@@ -751,6 +754,30 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
mod tests {
use super::*;
/// Launch outputs use the platform separator throughout, so they compare equal to
/// the same directories as JDT LS reports them.
#[test]
fn launch_dirs_use_one_separator() {
let root = tempfile::tempdir().unwrap();
let module = subdir(root.path(), "parent/app");
for dir in ["target/classes", "target/test-classes", "build/classes/java/main", "build/resources/test"] {
std::fs::create_dir_all(subdir(&module, dir)).unwrap();
}
let foreign = if std::path::MAIN_SEPARATOR == '\\' { '/' } else { '\\' };
let dirs = [
launch_class_dirs(BuildSystem::Maven, &module, true),
launch_class_dirs(BuildSystem::Gradle, &module, true),
]
.concat();
assert_eq!(dirs.len(), 4, "{dirs:?}");
for dir in &dirs {
let suffix = dir.strip_prefix(root.path()).unwrap().to_string_lossy().into_owned();
assert!(!suffix.contains(foreign), "{suffix}");
}
assert_eq!(subdir(root.path(), ""), root.path());
assert_eq!(subdir(root.path(), "a//b/"), root.path().join("a").join("b"));
}
fn write(root: &Path, rel: &str, text: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+65 -3
View File
@@ -86,7 +86,7 @@ async fn resolve_launch(
};
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
for p in std::mem::take(&mut info.class_paths) {
if !class_paths.contains(&p) {
if !class_paths.iter().any(|c| same_entry(c, &p, cfg!(windows))) {
class_paths.push(p);
}
}
@@ -131,15 +131,44 @@ async fn resolve_launch(
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
/// classpath next to its launch snapshot and every resource was found twice.
fn entry_key(path: &str, windows: bool) -> String {
let path = match path.strip_prefix(r"\\?\UNC\") {
Some(share) => format!(r"\\{share}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(),
};
let mut key = if windows { path.replace('\\', "/") } else { path };
while key.len() > 1 && key.ends_with('/') {
key.pop();
}
if windows { key.to_lowercase() } else { key }
}
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
a == b || entry_key(a, windows) == entry_key(b, windows)
}
fn use_snapshot(info: &mut JavaLaunchInfo, built: &[PathBuf], snapshot: &std::path::Path) -> Vec<PathBuf> {
use_snapshot_on(info, built, snapshot, cfg!(windows))
}
fn use_snapshot_on(
info: &mut JavaLaunchInfo,
built: &[PathBuf],
snapshot: &std::path::Path,
windows: bool,
) -> Vec<PathBuf> {
let mut copies = Vec::new();
for (index, source) in built.iter().enumerate() {
let dest = snapshot.join(index.to_string());
let original = source.to_string_lossy();
info.class_paths.retain(|p| p != original.as_ref());
info.class_paths.retain(|p| !same_entry(p, &original, windows));
let mut modular = false;
for path in &mut info.module_paths {
if path == original.as_ref() {
if same_entry(path, &original, windows) {
*path = dest.to_string_lossy().into_owned();
modular = true;
}
@@ -185,6 +214,39 @@ pub(crate) fn register(d: &mut Dispatcher) {
mod tests {
use super::*;
/// JDT LS and our build model spell a module output differently on Windows; the
/// snapshot must still replace it, or resources (MyBatis mapper XML) load twice.
#[test]
fn snapshots_replace_outputs_however_windows_spells_them() {
let built = PathBuf::from(r"D:\workspace\arm\arm-web\target/classes");
let jdtls = [
r"D:\workspace\arm\arm-web\target\classes",
r"d:\workspace\arm\arm-web\target\classes",
r"D:/workspace/arm/arm-web/target/classes/",
r"\\?\D:\workspace\arm\arm-web\target\classes",
];
for spelled in jdtls {
let mut info = JavaLaunchInfo {
class_paths: vec![spelled.into(), r"D:\m2\mybatis.jar".into()],
module_paths: vec![spelled.into()],
..Default::default()
};
use_snapshot_on(&mut info, std::slice::from_ref(&built), std::path::Path::new(r"C:\cache\launch-x"), true);
assert_eq!(info.class_paths, [r"D:\m2\mybatis.jar"], "{spelled}");
assert_eq!(
info.module_paths,
[std::path::Path::new(r"C:\cache\launch-x").join("0").to_string_lossy()],
"{spelled}"
);
}
assert!(same_entry(r"\\?\UNC\server\share\app\target\classes", r"\\server\share\app\target/classes", true));
// Different directories stay different, and other platforms keep case and `\`.
assert!(!same_entry(r"D:\a\target\classes", r"D:\a\target\test-classes", true));
assert!(!same_entry("/work/App/target/classes", "/work/app/target/classes", false));
assert!(!same_entry(r"/work/a\b", "/work/a/b", false));
assert!(same_entry("/work/app/target/classes/", "/work/app/target/classes", false));
}
#[test]
fn launch_snapshots_survive_jdt_overwrites_and_replace_module_outputs() {
let root = tempfile::tempdir().unwrap();