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 }), ); }