From 1122d67f7df989898b1184bdb4d53b97317c7ff3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:23:58 +0800 Subject: [PATCH] fix(lsp): Deliver server settings and start servers on a supported JDK - workspace/configuration answers a server's own section (basedpyright) from its settings block; basedpyright runs in "standard" mode and keeps auto search paths on, so `src/` layouts resolve from `tests/`. - jdtls: formatting follows the editor indent, calls complete without guessed arguments, and JDK internals stay out of completion. - `max-java` in a language server config picks an installed JDK no newer than that (kotlin-language-server fails on JDK 25). - Server initialization may take up to 5 minutes (first Gradle import). - `lsp_resend_diagnostics` re-emits stored diagnostics after a reload. - Spelling only checks declared names (not called methods like `substring`) and accepts plurals of known software terms. --- crates/ide-host/src/project.rs | 1 + crates/ide-language/src/lib.rs | 4 ++++ crates/ide-language/src/syntax/literals.rs | 26 ++++++++++++++++----- crates/ide-language/src/syntax/tests.rs | 9 ++++++++ crates/ide-lsp/src/handler.rs | 6 ++++- crates/ide-lsp/src/lib.rs | 26 ++++++++++++++++++++- crates/ide-lsp/src/server.rs | 5 ++++ crates/ide-lsp/tests/mock_server.rs | 10 ++++++++ crates/ide-lsp/tests/real_servers.rs | 3 +++ crates/ide-sdk/src/java.rs | 13 +++++++++++ crates/ide-sdk/src/tests.rs | 27 ++++++++++++++++++++++ crates/ide-spell/src/dict/software.txt | 12 ++++++++++ crates/ide-spell/src/lib.rs | 20 +++++++++++++--- languages/java.toml | 5 ++++ languages/kotlin.toml | 2 ++ languages/python.toml | 8 +++++++ src-tauri/src/commands/lsp.rs | 8 +++++++ src-tauri/src/commands/mod.rs | 1 + src/bindings.ts | 2 ++ 19 files changed, 177 insertions(+), 11 deletions(-) diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index ee92e219c..53a36bcea 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -119,6 +119,7 @@ impl Host { let effective = settings.effective(); ide_sdk::apply::server_launch_overrides(cfg, &effective); ide_sdk::apply::configure_server(cfg, &root, server_root, &effective); + ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks()); if cfg.name == "jdtls" { let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect(); diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 0a016a6d1..ac0a3cf8f 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -91,6 +91,10 @@ pub struct LanguageServerConfig { /// basedpyright): only used while none of those is installed. #[serde(default)] pub fallback: bool, + /// Newest Java release the server runs on (kotlin-language-server: 21). A + /// newer `JAVA_HOME` is replaced by the newest installed JDK that fits. + #[serde(default)] + pub max_java: Option, /// Set by the settings hook when the server can't run here (jdtls without /// a JDK 21+); reported as the failure instead of launching it. #[serde(skip)] diff --git a/crates/ide-language/src/syntax/literals.rs b/crates/ide-language/src/syntax/literals.rs index 5914a9c11..99438d97f 100644 --- a/crates/ide-language/src/syntax/literals.rs +++ b/crates/ide-language/src/syntax/literals.rs @@ -79,20 +79,34 @@ fn is_name_leaf(kind: &str) -> bool { kind.contains("identifier") || kind == "name" } +/// Node kinds whose `name` field is a use, not a declaration. +const USE_PARENTS: &[&str] = &[ + "invocation", + "call", + "access", + "scoped", + "reference", + "expression", + "selector", + "qualified", + "use_", + "import", +]; + /// Whether the identifier leaf under `cursor` is the name a declaration /// introduces (`fn name`, `let pattern`, `int *declarator`, parameters, /// `name = …` assignments) rather than a use of one. fn declares(cursor: &TreeCursor<'_>, node: &Node<'_>) -> bool { let parent = node.parent().map(|p| p.kind()).unwrap_or_default(); - // Paths and calls name what is declared elsewhere (`use a::b::name`, `a.name()`). - let reference = ["scoped", "qualified", "invocation", "member_access", "use_", "import", "call"] - .iter() - .any(|k| parent.contains(k)); match cursor.field_name() { - Some("name") => !reference, + // Uses name their target with a `name` field too: Java `s.substring()`, + // C# `a.Name`, Rust `use a::b::name`. + Some("name") => !USE_PARENTS.iter().any(|p| parent.contains(p)), Some("pattern" | "declarator") => true, Some("left") => parent.contains("assignment") || parent.contains("declarator"), - _ => parent.contains("parameter"), + // A parameter's name, not its type (`String text`). + Some("type") => false, + _ => parent.contains("parameter") && !node.kind().contains("type"), } } diff --git a/crates/ide-language/src/syntax/tests.rs b/crates/ide-language/src/syntax/tests.rs index fb344abf4..02c0ff6de 100644 --- a/crates/ide-language/src/syntax/tests.rs +++ b/crates/ide-language/src/syntax/tests.rs @@ -1320,4 +1320,13 @@ fn spelling_regions_cover_declared_names_only() { .map(|r| &rs[r.start..r.end]) .collect(); assert_eq!(names, ["run", "n"]); + let java = "class Bird { String fly(String text) { return text.substring(1); } }\n"; + let names: Vec<&str> = syntax() + .spelling_regions(&lang("java"), java) + .unwrap() + .iter() + .filter(|r| r.kind == SpellingKind::Name) + .map(|r| &java[r.start..r.end]) + .collect(); + assert_eq!(names, ["Bird", "fly", "text"]); } diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 35be7f5b8..0a235a152 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -132,7 +132,11 @@ impl ClientHandler { .iter() // vtsls asks for the whole configuration as section `""`. .map(|item| match item.get("section").and_then(Value::as_str).filter(|s| !s.is_empty()) { - Some(section) if section == self.server.name => options.clone(), + // The server's own section: its block under `settings` (basedpyright asks + // `basedpyright` and `basedpyright.analysis`), else the options themselves. + Some(section) if section == self.server.name => { + options.get("settings").and_then(|s| s.get(section)).unwrap_or(&options).clone() + } // Servers that take settings under `initializationOptions.settings` // (jdtls) ask for the same sections later. Some(section) => [&options, options.get("settings").unwrap_or(&Value::Null)] diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index f003d36df..d313d417b 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -56,6 +56,12 @@ use types::*; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); const COMPLETION_TIMEOUT: Duration = Duration::from_secs(5); const LONG_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long a server may take to answer `initialize`. Some import the whole +/// build there (kotlin-lsp, jdtls on a large Gradle project) and need minutes +/// on a cold start; the status shows "starting" meanwhile, and a crash still +/// ends the wait at once. +const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(300); /// How long a request waits for a server that is still initializing. const START_WAIT: Duration = Duration::from_secs(10); /// File moves may start a server (jdtls takes a while to import). @@ -421,7 +427,7 @@ impl LspManager { }; let init = self.initialize_params(&server); - let result = tokio::time::timeout(Duration::from_secs(60), peer.request::<_, Value>("initialize", init, None)) + let result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, None)) .await .unwrap_or(Err(RpcError::Timeout)); let caps = match result { @@ -1953,6 +1959,24 @@ impl LspManager { *self.apply_edit_hook.write() = Some(Arc::from(hook)); } + /// Emits every server's current diagnostics again: a UI that (re)loaded + /// after they were published has missed those events. + pub fn resend_diagnostics(&self) { + let servers: Vec> = self.servers.lock().clone(); + for server in servers { + let encoding = server.running().map(|r| r.caps.read().encoding).unwrap_or_default(); + let conv = self.conv(encoding); + for (path, items) in server.diagnostics_by_path() { + let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect(); + self.sink.emit(ide_core::IdeEvent::Diagnostics { + path: path.into(), + source: server.name.clone(), + items, + }); + } + } + } + pub fn servers(&self) -> Vec { self.servers .lock() diff --git a/crates/ide-lsp/src/server.rs b/crates/ide-lsp/src/server.rs index 04cbb9f84..43d522f72 100644 --- a/crates/ide-lsp/src/server.rs +++ b/crates/ide-lsp/src/server.rs @@ -461,6 +461,11 @@ impl Server { d.iter().flat_map(|(p, items)| items.iter().map(move |i| (p.clone(), i.clone()))).collect() } + /// The current diagnostics of each file that has some. + pub fn diagnostics_by_path(&self) -> Vec<(PathBuf, Vec)> { + self.diagnostics.lock().iter().map(|(p, d)| (p.clone(), d.clone())).collect() + } + pub fn take_diagnostic_paths(&self) -> Vec { self.published.lock().clear(); self.diagnostics.lock().drain().map(|(p, _)| p).collect() diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 9ca8e202a..4f0da19c6 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -207,6 +207,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { install_hint: Some(format!("install {name}")), connect: None, fallback: false, + max_java: None, launch_error: None, } } @@ -522,6 +523,15 @@ async fn diagnostics_become_events_and_feed_code_actions() { assert_eq!(items[0].range, rng(0, 4, 0, 10)); } + // A reloaded UI asks for them again. + h.events.lock().clear(); + h.mgr.resend_diagnostics(); + let again = h.events.lock().iter().find_map(|e| match e { + IdeEvent::Diagnostics { path, items, .. } => Some((path.clone(), items.len())), + _ => None, + }); + assert_eq!(again, Some((path.clone().into(), 2))); + s.respond( "textDocument/codeAction", json!([ diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 121bb0279..b0ea17015 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -74,6 +74,7 @@ async fn missing_binary_is_reported_with_install_hint() { install_hint: Some("npm i -g nope-ls".into()), connect: None, fallback: false, + max_java: None, launch_error: None, }; let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)])); @@ -203,6 +204,7 @@ async fn typescript_language_server_end_to_end() { install_hint: None, connect: None, fallback: false, + max_java: None, launch_error: None, }; let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)])); @@ -246,6 +248,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option Arc<[Jdk]> { } } +/// For a server with `max-java`: when the `JAVA_HOME` it would get (its env, +/// else the IDE's) is a newer JDK, points it at the newest of `jdks` that fits. +pub fn cap_java_home(cfg: &mut LanguageServerConfig, jdks: &[Jdk]) { + let Some(max) = cfg.max_java else { return }; + let current = cfg.env.get("JAVA_HOME").cloned().or_else(|| std::env::var("JAVA_HOME").ok()); + if current.as_deref().and_then(|h| jdk_at(Path::new(h))).is_some_and(|j| j.major <= max) { + return; + } + if let Some(jdk) = jdks.iter().filter(|j| j.major <= max).max_by_key(|j| j.major) { + cfg.env.insert("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned()); + } +} + /// Oldest Java version jdtls runs on when its install can't tell (its launcher's check). pub const JDTLS_MIN_JAVA: u32 = 21; diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index fb6699609..ff7e157e1 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -32,6 +32,7 @@ fn server(name: &str) -> LanguageServerConfig { install_hint: None, connect: None, fallback: false, + max_java: None, launch_error: None, } } @@ -598,3 +599,29 @@ fn vue_language_server_gets_a_typescript_with_the_language_service_api() { let lib = project.join("node_modules/typescript/lib"); assert_eq!(cfg.args, [format!("--tsdk={}", lib.to_string_lossy())]); } + +#[test] +fn max_java_picks_an_older_jdk() { + let dir = tempfile::tempdir().unwrap(); + let jdk = |major: u32| { + let home = dir.path().join(format!("jdk-{major}")); + std::fs::create_dir_all(home.join("bin")).unwrap(); + std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{major}.0.1\"\n")).unwrap(); + std::fs::write(home.join("bin").join(exe("java")), "").unwrap(); + crate::java::jdk_at(&home).unwrap() + }; + let (j17, j21, j25) = (jdk(17), jdk(21), jdk(25)); + let jdks = [j25.clone(), j21.clone(), j17]; + let mut cfg = LanguageServerConfig { max_java: Some(21), ..server("kotlin-language-server") }; + cfg.env.insert("JAVA_HOME".into(), j25.home.to_string_lossy().into_owned()); + crate::java::cap_java_home(&mut cfg, &jdks); + assert_eq!(cfg.env["JAVA_HOME"], j21.home.to_string_lossy()); + + // A JAVA_HOME that fits, or no limit: left alone. + crate::java::cap_java_home(&mut cfg, &[]); + assert_eq!(cfg.env["JAVA_HOME"], j21.home.to_string_lossy()); + let mut free = server("other"); + free.env.insert("JAVA_HOME".into(), j25.home.to_string_lossy().into_owned()); + crate::java::cap_java_home(&mut free, &jdks); + assert_eq!(free.env["JAVA_HOME"], j25.home.to_string_lossy()); +} diff --git a/crates/ide-spell/src/dict/software.txt b/crates/ide-spell/src/dict/software.txt index 478f3ddb6..2ab77be67 100644 --- a/crates/ide-spell/src/dict/software.txt +++ b/crates/ide-spell/src/dict/software.txt @@ -551,3 +551,15 @@ vulkan wgpu woff woff2 +breakpoint +sku +dropdown +navbar +superclass +typedef +eslint +unittest +jvm +jdk +ux +endpoint diff --git a/crates/ide-spell/src/lib.rs b/crates/ide-spell/src/lib.rs index 0ac12038b..67d54fa1f 100644 --- a/crates/ide-spell/src/lib.rs +++ b/crates/ide-spell/src/lib.rs @@ -162,6 +162,14 @@ fn software_terms() -> &'static HashSet<&'static str> { }) } +/// A software term, or its plural (`endpoints`, `regexes`). +fn software_term(lower: &str) -> bool { + let terms = software_terms(); + terms.contains(lower) + || lower.strip_suffix('s').is_some_and(|w| terms.contains(w)) + || lower.strip_suffix("es").is_some_and(|w| terms.contains(w)) +} + /// Words of a custom dictionary file: a plain word list or a Hunspell `.dic` /// (leading count line and `/FLAGS` ignored). Cached by modification time. fn dictionary_file(path: &Path) -> Arc> { @@ -230,9 +238,7 @@ impl Speller { } fn user_word(&self, lower: &str) -> bool { - self.accepted.contains(lower) - || software_terms().contains(lower) - || self.custom.iter().any(|d| d.contains(lower)) + self.accepted.contains(lower) || software_term(lower) || self.custom.iter().any(|d| d.contains(lower)) } /// In a dictionary, in any case style. @@ -439,6 +445,14 @@ mod tests { assert!(!s.is_correct("lenght")); } + #[test] + fn software_terms_and_their_plurals() { + let s = Speller::default(); + for ok in ["breakpoint", "breakpoints", "endpoints", "datasets", "regexes"] { + assert!(s.is_correct(ok), "{ok}"); + } + } + #[test] fn agent_nouns_of_known_words() { let s = Speller::default(); diff --git a/languages/java.toml b/languages/java.toml index 6dd7660f4..e8ec0caa5 100644 --- a/languages/java.toml +++ b/languages/java.toml @@ -38,6 +38,11 @@ eclipse = { downloadSources = true } references = { includeDecompiledSources = true } signatureHelp = { enabled = true } configuration = { updateBuildConfiguration = "automatic" } +# Generated code (implement methods, getters, toString…) indents like the editor (`indent` above). +format = { insertSpaces = true, tabSize = 4 } +# Calls complete as `name()` with the caret inside and parameter info, not guessed arguments; +# JDK internals (`sun.*`, `com.sun.*`, `jdk.*`) and AWT's `List` stay out of completion and auto-import. +completion = { guessMethodArguments = "off", filteredTypes = ["java.awt.List", "com.sun.*", "sun.*", "jdk.*", "org.graalvm.*", "io.micrometer.shaded.*"] } [language-servers.initialization-options.settings.java.import] # Keep Eclipse metadata (.project, .classpath, .settings) out of the project. diff --git a/languages/kotlin.toml b/languages/kotlin.toml index 74651b31d..212bb9505 100644 --- a/languages/kotlin.toml +++ b/languages/kotlin.toml @@ -19,6 +19,8 @@ name = "kotlin-language-server" # Alternative: used only when the server above is not installed. fallback = true command = "kotlin-language-server" +# Its bundled Kotlin compiler can't parse newer JDK versions ("25.0.4.1"). +max-java = 21 root-markers = ["settings.gradle.kts", "settings.gradle", "build.gradle.kts", "build.gradle", "pom.xml"] install-hint = "brew install kotlin-language-server (https://github.com/fwcd/kotlin-language-server)" diff --git a/languages/python.toml b/languages/python.toml index 4f6a9a62c..3dd270bc8 100644 --- a/languages/python.toml +++ b/languages/python.toml @@ -15,6 +15,14 @@ args = ["--stdio"] root-markers = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"] install-hint = "pip install basedpyright (or: uv tool install basedpyright)" +# basedpyright defaults to "recommended", which flags unannotated attributes and +# every unknown type. "standard" reports real problems; a project's own +# `[tool.basedpyright]` / pyrightconfig.json still takes precedence. +[language-servers.initialization-options.settings.basedpyright.analysis] +typeCheckingMode = "standard" +# Off whenever analysis settings are sent without it: `src/` layouts would stop resolving. +autoSearchPaths = true + [[language-servers]] name = "pyright" # Alternative: used only when the server above is not installed. diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 44e8a4a71..dc218ef4d 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -309,6 +309,14 @@ pub fn lsp_servers(window: Window, state: State<'_, AppState>) -> IdeResult) -> IdeResult<()> { + project!(window, state).lsp.resend_diagnostics(); + Ok(()) +} + #[tauri::command] #[specta::specta] pub async fn lsp_restart(window: Window, state: State<'_, AppState>, server: ServerId) -> IdeResult<()> { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7048be4a1..e60f2d4aa 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -174,6 +174,7 @@ pub fn builder() -> tauri_specta::Builder { lsp::lsp_call_hierarchy, lsp::lsp_type_hierarchy, lsp::lsp_servers, + lsp::lsp_resend_diagnostics, lsp::lsp_restart, lsp::apply_workspace_edit, lsp::lsp_execute_command, diff --git a/src/bindings.ts b/src/bindings.ts index f8e6e914f..ab7bb6fba 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -336,6 +336,8 @@ export const commands = { lspCallHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError(__TAURI_INVOKE("lsp_call_hierarchy", { doc, pos, direction })), lspTypeHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError(__TAURI_INVOKE("lsp_type_hierarchy", { doc, pos, direction })), lspServers: () => typedError(__TAURI_INVOKE("lsp_servers")), + /** Re-emits the language servers' current diagnostics (the UI loaded after they arrived). */ + lspResendDiagnostics: () => typedError(__TAURI_INVOKE("lsp_resend_diagnostics")), lspRestart: (server: ServerId) => typedError(__TAURI_INVOKE("lsp_restart", { server })), /** Applies a (previewed) workspace edit, e.g. after rename confirmation. */ applyWorkspaceEdit: (edit: WorkspaceEdit) => typedError(__TAURI_INVOKE("apply_workspace_edit", { edit })),