From 5526ed586511e1b0029a988bcdf822aa321b420b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:27:11 +0800 Subject: [PATCH] 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. --- 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();