From a3b6780823b6b5f27490c23d2f66a756994719f2 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:59:40 +0800 Subject: [PATCH] fix(java): replace module outputs with launch snapshots on Windows (#1038) Maven launches put a snapshot of each module's class directory on the classpath in place of the original. The originals were built as module.join("target/classes") (`...\app\target/classes` on Windows) while JDT LS reports `...\app\target\classes`; compared as text, the original stayed next to its snapshot, so every resource was on the classpath twice and MyBatis failed with "XML fragments parsed from previous mappers already contains value". Launch class directories are now joined per component, and classpath entries are compared by a key that ignores the verbatim prefix, separators, trailing separators and, on Windows, case. Fixes #704 --- crates/ide-build/src/compile/mod.rs | 35 +++++++++++++-- crates/ide-host/src/ops/java.rs | 68 +++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 9cbd6e48e..58aa72410 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -158,8 +158,11 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option 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 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 = 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 { + use_snapshot_on(info, built, snapshot, cfg!(windows)) +} + +fn use_snapshot_on( + info: &mut JavaLaunchInfo, + built: &[PathBuf], + snapshot: &std::path::Path, + windows: bool, +) -> Vec { 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();