From dcd8ecc5aeb531fd5fbf7fee46df4826bc20087b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:09:03 +0800 Subject: [PATCH] fix(java): name reserved Windows device files that abort the Java import (#1032) On Windows, a project file named after a device (nul, con, aux, prn, com1-9, lpt1-9; nul is typically left by a 2>nul redirect in Git Bash) aborts the Java import: Files.exists reports it missing, Eclipse's refresh builds a child path with an empty name and throws "Path must include project and resource name", so m2e never creates the Java project. When JDT LS reports that failure on Windows, the server root is scanned for reserved device names (bounded, ignore files not honoured) and a one-time error notification lists them with how to recover. The name check covers COM0-9/LPT0-9 including superscript digits, case-insensitive, with or without extension or trailing spaces. Backend notification texts now go through txTemplate so templated catalog entries translate; zh text added. Refs #976 --- crates/ide-lsp/src/handler.rs | 4 + crates/ide-lsp/src/jdtls.rs | 19 +++ crates/ide-lsp/src/jdtls/reserved_names.rs | 180 +++++++++++++++++++++ crates/ide-lsp/tests/mock_server.rs | 44 +++++ src/platform/locales/text/zh.json | 4 +- src/workbench/notificationStore.ts | 5 +- 6 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 crates/ide-lsp/src/jdtls/reserved_names.rs diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 2c1d4532..fec8a9a7 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -228,6 +228,10 @@ impl Handler for ClientHandler { "window/logMessage" => { let text = params.get("message").and_then(Value::as_str).unwrap_or_default(); match params.get("type").and_then(Value::as_u64) { + Some(1) if self.server.name == "jdtls" && text.contains(crate::jdtls::reserved_names::IMPORT_FAILURE) => { + tracing::warn!("[{}] {text}", self.server.name); + mgr.report_import_failure(self.server.root.clone(), text.to_owned()); + } Some(1) => tracing::warn!("[{}] {text}", self.server.name), Some(2 | 3) => tracing::info!("[{}] {text}", self.server.name), _ => tracing::debug!("[{}] {text}", self.server.name), diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index 3b84f156..fab72d9e 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -20,6 +20,7 @@ const SERVER: &str = "jdtls"; pub(crate) mod configuration; mod formatter; +pub(crate) mod reserved_names; pub use formatter::configure_formatter; /// First start imports the Maven / Gradle model, which can take minutes. const READY_TIMEOUT: Duration = Duration::from_secs(240); @@ -664,6 +665,24 @@ impl LspManager { } } +impl LspManager { + /// JDT LS aborted its import with `message`: when reserved Windows device names + /// below `root` are the cause, say which (once). The walk runs off the RPC thread. + pub(crate) fn report_import_failure(&self, root: PathBuf, message: String) { + let Some(me) = self.me.upgrade() else { return }; + self.runtime.spawn_blocking(move || { + let Some(body) = reserved_names::import_failure_hint(&root, &message, cfg!(windows)) else { return }; + if me.first_message(SERVER, &body) { + me.sink().emit(ide_core::IdeEvent::Notify { + level: ide_core::event::NotifyLevel::Error, + title: "Java project import failed".into(), + body: Some(body), + }); + } + }); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ide-lsp/src/jdtls/reserved_names.rs b/crates/ide-lsp/src/jdtls/reserved_names.rs new file mode 100644 index 00000000..de4cd816 --- /dev/null +++ b/crates/ide-lsp/src/jdtls/reserved_names.rs @@ -0,0 +1,180 @@ +//! Windows reserved device names (`nul`, `con`, …) inside a Java project. +//! +//! On Windows, a directory listing returns such a file (often left by a shell +//! redirect like `2>nul` in Git Bash), but `Files.exists` reports it missing as the +//! name resolves to the device. Eclipse's resource refresh then builds a child path +//! with an empty name and the Maven / Gradle import aborts during JDT LS +//! initialization with "Path must include project and resource name", so no Java +//! project is created. The user only sees every run fail with "module is not +//! imported"; this names the files that cause it. + +use std::path::{Path, PathBuf}; + +/// What JDT LS logs when the import aborts on such an entry. +pub(crate) const IMPORT_FAILURE: &str = "Path must include project and resource name"; + +/// Directory entries visited per scan, and reserved names reported at most. +const MAX_VISITED: usize = 200_000; +const MAX_REPORTED: usize = 20; + +/// Whether Windows treats `name` as a device: CON, PRN, AUX, NUL, COM0–9 or LPT0–9 +/// (also with superscript digits), in any case, with or without an extension and +/// trailing spaces or dots. +pub(crate) fn is_reserved_device_name(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' '); + let upper = stem.to_ascii_uppercase(); + if ["CON", "PRN", "AUX", "NUL"].contains(&upper.as_str()) { + return true; + } + let mut chars = upper.chars(); + let prefix: String = chars.by_ref().take(3).collect(); + let digit: Vec = chars.collect(); + (prefix == "COM" || prefix == "LPT") + && matches!(digit.as_slice(), [d] if d.is_ascii_digit() || matches!(d, '¹' | '²' | '³')) +} + +/// Entries below `root` with a reserved device name, in walk order (bounded). +/// Ignore files are not honoured: Eclipse's refresh does not honour them either. +pub(crate) fn reserved_entries(root: &Path) -> Vec { + ignore::WalkBuilder::new(root) + .standard_filters(false) + .follow_links(false) + .build() + .take(MAX_VISITED) + .filter_map(Result::ok) + .filter(|entry| entry.depth() > 0 && entry.file_name().to_str().is_some_and(is_reserved_device_name)) + .map(ignore::DirEntry::into_path) + .take(MAX_REPORTED) + .collect() +} + +/// The notification body for a JDT LS log `message` from the server at `root`: +/// the reserved-name entries that abort the import, when the message is that +/// failure and the host is Windows (elsewhere such names are ordinary files). +pub(crate) fn import_failure_hint(root: &Path, message: &str, windows: bool) -> Option { + if !windows || !message.contains(IMPORT_FAILURE) { + return None; + } + let entries = reserved_entries(root); + if entries.is_empty() { + return None; + } + let paths = entries + .iter() + .map(|path| path.strip_prefix(root).unwrap_or(path).display().to_string()) + .collect::>() + .join(", "); + Some(format!( + "The Java project cannot be imported because these files use a name Windows reserves for devices: {paths}. Delete or rename them, then restart the Java language server." + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A path Windows does not resolve to a device (`\\?\`): the only way to create a + /// file named `nul` there. Elsewhere such names are ordinary files. + fn verbatim(path: &Path) -> PathBuf { + let text = path.to_string_lossy(); + if cfg!(windows) && !text.starts_with(r"\\?\") { + PathBuf::from(format!(r"\\?\{text}")) + } else { + path.to_path_buf() + } + } + + /// Writes `root` / `parts` (joined per component: verbatim paths take no `/`). + fn write(root: &Path, parts: &[&str], text: &str) { + let path = parts.iter().fold(root.to_path_buf(), |path, part| path.join(part)); + std::fs::create_dir_all(verbatim(path.parent().unwrap())).unwrap(); + std::fs::write(verbatim(&path), text).unwrap(); + } + + /// Removes a tree that may hold reserved names, which a plain path cannot delete on Windows. + fn remove(dir: tempfile::TempDir) { + let _ = std::fs::remove_dir_all(verbatim(&dir.keep())); + } + + #[test] + fn reserved_device_names() { + for name in [ + "nul", + "NUL", + "Nul", + "nul.txt", + "nul.tar.gz", + "nul ", + "con", + "prn", + "aux", + "com1", + "COM9", + "com0", + "lpt1", + "LPT9", + "com¹", + "lpt³", + "aux.java", + ] { + assert!(is_reserved_device_name(name), "{name}"); + } + for name in [ + "null", + "nuls", + "anul", + "console", + "auxiliary", + "com", + "com10", + "lpt", + "lptx", + "comA", + ".nul", + "", + "src", + "pom.xml", + "nul_file", + ] { + assert!(!is_reserved_device_name(name), "{name}"); + } + } + + #[test] + fn import_failure_names_the_reserved_entries() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(root, &["nul"], "54 bytes of shell output"); + write(root, &["module", "aux.txt"], ""); + write(root, &["node_modules", "x", "con"], ""); + write(root, &["module", "src", "Null.java"], "class Null {}"); + write(root, &[".gitignore"], "nul\nnode_modules/\n"); + let message = "Initialization failed | java.lang.IllegalArgumentException: Path must include project and resource name: /bkbits-parent | at UnifiedTree.createChildNodeFromFileSystem(UnifiedTree.java:320)"; + + let hint = import_failure_hint(root, message, true).unwrap(); + let sep = std::path::MAIN_SEPARATOR; + for path in ["nul".to_owned(), format!("module{sep}aux.txt"), format!("node_modules{sep}x{sep}con")] { + assert!(hint.contains(&path), "{path} missing from {hint}"); + } + assert!(!hint.contains("Null.java")); + assert!(hint.ends_with("then restart the Java language server.")); + + // Other failures, other hosts, and projects without such names stay silent. + assert_eq!(import_failure_hint(root, "Initialization failed | java.lang.NullPointerException", true), None); + assert_eq!(import_failure_hint(root, message, false), None); + let clean = tempfile::tempdir().unwrap(); + write(clean.path(), &["pom.xml"], ""); + assert_eq!(import_failure_hint(clean.path(), message, true), None); + remove(dir); + } + + #[test] + fn reports_a_bounded_number_of_entries() { + let dir = tempfile::tempdir().unwrap(); + for i in 0..30 { + write(dir.path(), &[&format!("m{i}"), "nul"], ""); + } + assert_eq!(reserved_entries(dir.path()).len(), MAX_REPORTED); + remove(dir); + } +} diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 753db477..147d6351 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -3072,3 +3072,47 @@ async fn java_definition_consults_configuration_only_inside_key_arguments() { } assert_eq!(s.requested("workspace/executeCommand").len(), 2); } + +/// JDT LS aborts its Maven import on Windows when the project holds a file with a +/// reserved device name (`nul` left by `2>nul`); the error names those files there +/// and stays silent elsewhere, where such names are ordinary files. +#[tokio::test(flavor = "multi_thread")] +async fn jdtls_import_failure_names_reserved_device_files_on_windows() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let s = h.launcher.state("jdtls"); + std::fs::write(h.root.join("pom.xml"), "").unwrap(); + // A file named `nul` cannot be created through the Win32 namespace; on Windows use the verbatim path. + let nul = if cfg!(windows) && !h.root.to_string_lossy().starts_with(r"\\?\") { + PathBuf::from(format!(r"\\?\{}", h.root.join("nul").display())) + } else { + h.root.join("nul") + }; + std::fs::write(&nul, "54 bytes").unwrap(); + h.open("A.java", "class A {}"); + h.wait_status("jdtls", ServerStatus::Ready).await; + let failure = "Initialization failed | java.lang.IllegalArgumentException: Path must include project and resource name: /demo | at UnifiedTree.createChildNodeFromFileSystem(UnifiedTree.java:320)"; + for _ in 0..2 { + s.peer().notify("window/logMessage", json!({ "type": 1, "message": failure })).unwrap(); + } + s.peer().notify("window/showMessage", json!({ "type": 2, "message": "after" })).unwrap(); + eventually(|| h.events.lock().iter().any(|e| matches!(e, IdeEvent::Notify { body: Some(b), .. } if b == "after"))) + .await; + // The scan runs off the RPC thread; give it time to report. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let failures: Vec = h + .events + .lock() + .iter() + .filter_map(|e| match e { + IdeEvent::Notify { title, body: Some(b), .. } if title == "Java project import failed" => Some(b.clone()), + _ => None, + }) + .collect(); + if cfg!(windows) { + assert_eq!(failures.len(), 1, "reported once: {failures:?}"); + assert!(failures[0].contains("nul"), "{failures:?}"); + } else { + assert!(failures.is_empty(), "{failures:?}"); + } + let _ = std::fs::remove_file(&nul); +} diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 28f805a2..14586338 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5552,5 +5552,7 @@ "select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改", "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改", "External file changes sync may be slow": "外部文件更改同步可能较慢", - "recursive": "递归" + "recursive": "递归", + "Java project import failed": "Java 项目导入失败", + "The Java project cannot be imported because these files use a name Windows reserves for devices: {paths}. Delete or rename them, then restart the Java language server.": "无法导入 Java 项目,因为这些文件使用了 Windows 保留的设备名:{paths}。请删除或重命名这些文件,然后重启 Java 语言服务器。" } diff --git a/src/workbench/notificationStore.ts b/src/workbench/notificationStore.ts index 2a0b4e26..55da9efd 100644 --- a/src/workbench/notificationStore.ts +++ b/src/workbench/notificationStore.ts @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { create } from "zustand"; import { call, commands, onIdeEvent } from "@/platform/ipc"; -import { tx } from "@/platform/i18n"; +import { txTemplate } from "@/platform/i18n"; import { useProject } from "@/platform/project"; import type { NotificationService } from "./contributions"; @@ -197,6 +197,7 @@ export const notificationServiceImpl: NotificationService = { export function installNotifyEvents(): () => void { return onIdeEvent( "notify", - (e) => void notify({ level: e.level, title: tx(e.title), body: e.body ? tx(e.body) : undefined }), + // Backend text may carry values (paths, names); `{name}` catalog templates translate it. + (e) => void notify({ level: e.level, title: txTemplate(e.title), body: e.body ? txTemplate(e.body) : undefined }), ); }