mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
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:
1 parent
60722e0cc5
commit
1122d67f7d
19 files changed
+177
-11
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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!([
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -551,3 +551,15 @@ vulkan
|
||||
wgpu
|
||||
woff
|
||||
woff2
|
||||
breakpoint
|
||||
sku
|
||||
dropdown
|
||||
navbar
|
||||
superclass
|
||||
typedef
|
||||
eslint
|
||||
unittest
|
||||
jvm
|
||||
jdk
|
||||
ux
|
||||
endpoint
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
Reference in new issue
Block a user