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.
This commit is contained in:
l0ng-ai committed 2026-10-28 08:28:00 +08:00
1 parent 60722e0cc5
commit 1122d67f7d
19 files changed
+177 -11

No files matched your search

+1
View File
@@ -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<PathBuf> =
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
+4
View File
@@ -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<u32>,
/// 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)]
+20 -6
View File
@@ -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"),
}
}
+9
View File
@@ -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"]);
}
+5 -1
View File
@@ -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)]
+25 -1
View File
@@ -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<Arc<Server>> = 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<ServerInfo> {
self.servers
.lock()
+5
View File
@@ -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<lsp::Diagnostic>)> {
self.diagnostics.lock().iter().map(|(p, d)| (p.clone(), d.clone())).collect()
}
pub fn take_diagnostic_paths(&self) -> Vec<PathBuf> {
self.published.lock().clear();
self.diagnostics.lock().drain().map(|(p, _)| p).collect()
+10
View File
@@ -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!([
+3
View File
@@ -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<serde_json::Valu
install_hint: None,
connect: None,
fallback: false,
max_java: None,
launch_error: None,
}
}
+13
View File
@@ -161,6 +161,19 @@ pub fn jdtls_jdks(min_java: u32) -> 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;
+27
View File
@@ -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());
}
+12
View File
@@ -551,3 +551,15 @@ vulkan
wgpu
woff
woff2
breakpoint
sku
dropdown
navbar
superclass
typedef
eslint
unittest
jvm
jdk
ux
endpoint
+17 -3
View File
@@ -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<HashSet<String>> {
@@ -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();
+5
View File
@@ -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.
+2
View File
@@ -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)"
+8
View File
@@ -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.
+8
View File
@@ -309,6 +309,14 @@ pub fn lsp_servers(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<
ide_host::ops::lsp::lsp_servers(&state.host, window.label())
}
/// Re-emits the language servers' current diagnostics (the UI loaded after they arrived).
#[tauri::command]
#[specta::specta]
pub fn lsp_resend_diagnostics(window: Window, state: State<'_, AppState>) -> 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<()> {
+1
View File
@@ -174,6 +174,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+2
View File
@@ -336,6 +336,8 @@ export const commands = {
lspCallHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError<HierarchyItem[], IdeError>(__TAURI_INVOKE("lsp_call_hierarchy", { doc, pos, direction })),
lspTypeHierarchy: (doc: DocId, pos: Pos, direction: HierarchyDirection) => typedError<HierarchyItem[], IdeError>(__TAURI_INVOKE("lsp_type_hierarchy", { doc, pos, direction })),
lspServers: () => typedError<ServerInfo[], IdeError>(__TAURI_INVOKE("lsp_servers")),
/** Re-emits the language servers' current diagnostics (the UI loaded after they arrived). */
lspResendDiagnostics: () => typedError<null, IdeError>(__TAURI_INVOKE("lsp_resend_diagnostics")),
lspRestart: (server: ServerId) => typedError<null, IdeError>(__TAURI_INVOKE("lsp_restart", { server })),
/** Applies a (previewed) workspace edit, e.g. after rename confirmation. */
applyWorkspaceEdit: (edit: WorkspaceEdit) => typedError<null, IdeError>(__TAURI_INVOKE("apply_workspace_edit", { edit })),