diff --git a/README.md b/README.md index b47abd886..843b88ce3 100644 --- a/README.md +++ b/README.md @@ -78,5 +78,6 @@ the [official plugins](plugins/official). The plugin API is still a preview. Database tool window notes, including Oracle Instant Client setup, are in [`docs/databases.md`](docs/databases.md). +Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md). Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o. diff --git a/crates/ide-language/src/registry.rs b/crates/ide-language/src/registry.rs index f8546aaaf..5927d53d0 100644 --- a/crates/ide-language/src/registry.rs +++ b/crates/ide-language/src/registry.rs @@ -639,6 +639,7 @@ mod tests { for (lang, servers) in [ ("python", vec!["basedpyright", "pyright", "ty", "pyrefly"]), ("typescript", vec!["vtsls", "typescript-language-server"]), + ("java", vec!["jdtls", "kmp-lsp"]), ] { let cfg = reg.get(&LanguageId::new(lang)).unwrap(); let got: Vec<(&str, bool)> = cfg diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index b7c1f94fb..2eb71d768 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -1673,6 +1673,18 @@ async fn library_classes_in_jars_open_as_their_source_or_decompiled() { assert_eq!(asked[0], json!({ "command": "decompile", "arguments": [thing] })); } +#[tokio::test(flavor = "multi_thread")] +async fn alternative_java_server_uses_lsp_without_jdt_commands() { + let h = harness_for("java", "java", &[("kmp-lsp", incremental_caps())], &[]); + let (doc, path) = h.open("Sample.java", "class Sample {}"); + h.wait_status("kmp-lsp", ServerStatus::Ready).await; + assert!(h.mgr.hover(doc, Pos::new(0, 7), Default::default()).await.is_ok()); + let error = h.mgr.java_debug_server(&path).await.unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable); + assert!(error.message.contains("jdtls")); + assert!(h.launcher.state("kmp-lsp").requested("workspace/executeCommand").is_empty()); +} + #[tokio::test(flavor = "multi_thread")] async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { // Preferred installed: the alternative is never started. diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 082df9aa2..a2ad393c4 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -63,6 +63,55 @@ fn lang(id: &str, ext: &str, server: LanguageServerConfig) -> LanguageConfig { } } +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires kmp-lsp on PATH (or OPENIDEA_TEST_KMP_LSP)"] +async fn kmp_lsp_java_end_to_end() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().canonicalize().unwrap(); + let path = root.join("Sample.java"); + let source = "class Sample {\n static String greet() { return \"Hello\"; }\n void run() { greet(); }\n}\n"; + std::fs::write(&path, source).unwrap(); + let mut java = LanguageRegistry::load(None).unwrap().get(&LanguageId::new("java")).unwrap(); + java.language_servers.retain(|s| s.name == "kmp-lsp"); + assert_eq!(java.language_servers.len(), 1); + if let Ok(bin) = std::env::var("OPENIDEA_TEST_KMP_LSP") { + java.language_servers[0].command = bin; + } + let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![java])); + let doc = DocId::next(); + mgr.did_open(doc, &path, &LanguageId::new("java"), 1, source); + retry("kmp-lsp ready", Duration::from_secs(30), || { + let status = last_status(&events, "kmp-lsp"); + if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &status { + panic!("kmp-lsp failed: {msg:?}"); + } + async move { matches!(status, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } + }) + .await; + let hover = retry("Java hover", Duration::from_secs(30), || async { + mgr.hover(doc, Pos::new(2, 18), Default::default()).await.ok().flatten() + }) + .await; + assert!(hover.contents.value.contains("greet"), "{}", hover.contents.value); + let defs = retry("Java definition", Duration::from_secs(30), || async { + mgr.goto(doc, Pos::new(2, 18), GotoKind::Definition).await.ok().filter(|d| !d.is_empty()) + }) + .await; + assert_eq!(defs[0].path, path); + assert_eq!(defs[0].range.start.line, 1); + retry("Java completion", Duration::from_secs(30), || async { + let list = mgr.completion(doc, Pos::new(2, 20), None, Default::default()).await.ok()?; + list.items.iter().any(|i| i.label.starts_with("greet")).then_some(()) + }) + .await; + let tokens = mgr.semantic_tokens(doc).await.unwrap().unwrap(); + assert!(!tokens.data.is_empty() && tokens.data.len() % 5 == 0); + let error = mgr.java_debug_server(&path).await.unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable); + assert!(error.message.contains("jdtls")); + tokio::time::timeout(Duration::from_secs(10), mgr.shutdown_all()).await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] #[ignore = "requires jdtls on PATH and a compatible JDK"] async fn intellij_java_formatter_preserves_manual_breaks_and_wraps_long_lines() { diff --git a/crates/ide-tools/src/platform.rs b/crates/ide-tools/src/platform.rs index d32b07ac7..d27e89cec 100644 --- a/crates/ide-tools/src/platform.rs +++ b/crates/ide-tools/src/platform.rs @@ -151,6 +151,36 @@ mod tests { assert_eq!(select_asset(&names, rules, WIN), Some(names[4])); } + #[test] + fn kmp_lsp_assets_include_the_native_jar_indexer() { + let names = [ + "kmp-lsp-darwin-aarch64.gz", + "kmp-lsp-darwin-aarch64.tar.gz", + "kmp-lsp-darwin-x86_64.tar.gz", + "kmp-lsp-linux-aarch64.tar.gz", + "kmp-lsp-linux-x86_64.tar.gz", + "kmp-lsp-windows-x86_64.zip", + "kmp-lsp-windows-aarch64.zip", + "kmp-lsp-v0.27.0.vsix", + "kmp-lsp-win32-x64-v0.27.0.vsix", + "kmp-lsp-win32-arm64-v0.27.0.vsix", + ]; + let rules = crate::registry::find("kmp-lsp").unwrap().asset_rules(); + for (os, arch, expected) in [ + (Os::Mac, Arch::Arm64, names[1]), + (Os::Mac, Arch::X64, names[2]), + (Os::Linux, Arch::Arm64, names[3]), + (Os::Linux, Arch::X64, names[4]), + (Os::Windows, Arch::X64, names[8]), + (Os::Windows, Arch::Arm64, names[9]), + ] { + assert_eq!(select_asset(&names, rules, Platform { os, arch }), Some(expected)); + } + // The plain gzip, Windows zip and universal VSIX omit the sidecar or server. + assert_eq!(select_asset(&[names[0]], rules, MAC_ARM), None); + assert_eq!(select_asset(&names[5..8], rules, WIN), None); + } + #[test] fn rosetta_fallback_and_missing() { let names = ["zls-x86_64-macos.tar.xz", "zls-x86_64-linux.tar.xz", "zls-x86_64-windows.zip"]; diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 766c461dd..bbb9b5a36 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -618,6 +618,30 @@ pub static TOOLS: &[ToolSpec] = &[ homepage: "https://github.com/eclipse-jdtls/eclipse.jdt.ls", needs: &[], }, + ToolSpec { + name: "kmp-lsp", + display: "kmp-lsp", + kind: LanguageServer, + commands: &["kmp-lsp"], + method: Method::Release { + source: ReleaseSource::GitHub { repo: "Hessesian/kmp-lsp" }, + // These distributions include the native JAR indexer next to the server. + // The single-binary .gz and Windows .zip assets do not. + assets: &[ + rule(Mac, Arm64, "kmp-lsp-darwin-aarch64.tar.gz"), + rule(Mac, X64, "kmp-lsp-darwin-x86_64.tar.gz"), + rule(Linux, Arm64, "kmp-lsp-linux-aarch64.tar.gz"), + rule(Linux, X64, "kmp-lsp-linux-x86_64.tar.gz"), + rule(Windows, Arm64, "kmp-lsp-win32-arm64-*.vsix"), + rule(Windows, X64, "kmp-lsp-win32-x64-*.vsix"), + ], + locate: &["kmp-lsp"], + }, + fallback: None, + version: ver("kmp-lsp"), + homepage: "https://github.com/Hessesian/kmp-lsp", + needs: &[], + }, ToolSpec { name: "java-debug", display: "Java Debugger (Microsoft java-debug)", diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 9a6b4429a..1d606fe21 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -170,6 +170,10 @@ fn interchangeable_servers_list_their_alternatives() { assert_eq!(find(&list, name).alternatives, python); } assert_eq!(find(&list, "vtsls").alternatives, ["vtsls", "typescript-language-server"]); + for name in ["jdtls", "kmp-lsp"] { + assert_eq!(find(&list, name).alternatives, ["jdtls", "kmp-lsp"]); + assert_eq!(find(&list, name).languages, ["Java"]); + } // A regular server after a fallback starts no group with it. assert!(find(&list, "angular-language-server").alternatives.is_empty()); assert!(find(&list, "ruff").alternatives.is_empty()); diff --git a/docs/java.md b/docs/java.md new file mode 100644 index 000000000..182788960 --- /dev/null +++ b/docs/java.md @@ -0,0 +1,21 @@ +# Java language servers + +Settings › Languages & Frameworks › Language Servers lists Eclipse JDT Language +Server (`jdtls`) and [kmp-lsp](https://github.com/Hessesian/kmp-lsp). Select either +tool and use **Language server for Java** to choose one. The choice can be global +or limited to the current project. **Automatic** prefers jdtls and falls back to +kmp-lsp when jdtls is not installed; **None** disables both. + +Install kmp-lsp from that page, or point its executable path at an existing +installation. The IDE downloads a platform distribution containing both kmp-lsp +and its native JAR indexer. Reinstall and uninstall manage both together. + +kmp-lsp offers syntax-based Java code insight without running a JVM. It does not +provide JDT's compiler analysis, Lombok integration, or JDT-specific refactorings. +Java Application builds, classpath discovery and the Microsoft Java debugger still +require jdtls; use jdtls for those flows. Maven and Gradle build tasks can run +independently of the selected code-insight server. + +This built-in choice applies to Java. Kotlin and Swift keep their existing server +choices. To use another server, add it with **New Language Server**, assign its +languages, and disable the servers it replaces. diff --git a/languages/java.toml b/languages/java.toml index af9967e9c..e7997db1a 100644 --- a/languages/java.toml +++ b/languages/java.toml @@ -58,3 +58,10 @@ generatesMetadataFilesAtProjectRoot = false exclusions = ["**/node_modules/**", "**/.metadata/**", "**/archetype-resources/**", "**/META-INF/maven/**"] maven = { enabled = true } gradle = { enabled = true, wrapper = { enabled = true } } + +[[language-servers]] +name = "kmp-lsp" +fallback = true +command = "kmp-lsp" +root-markers = ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "build.xml", ".project"] +install-hint = "Install kmp-lsp in Settings › Languages & Frameworks › Language Servers (https://github.com/Hessesian/kmp-lsp)"