From 4976d2d3b25cd1d2fe9a7fff26fb65e3aff3173d Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Tue, 18 Aug 2026 15:41:31 +0800 Subject: [PATCH 1/3] Bundle JDTLS with packaged applications --- .gitignore | 1 + README.md | 2 +- README.zh-CN.md | 2 +- .../Runtime/MacRuntimeToolDiscovery.swift | 11 ++ .../LanguageToolServiceContracts.swift | 2 + .../RunConfigurationIntegrationTests.swift | 18 ++ docs/architecture/language-tooling.md | 2 + rust/lithe-core/src/lsp/interface/process.rs | 164 ++++++++++++++++- scripts/build-windows.ps1 | 1 + scripts/package-app.sh | 3 + scripts/package-windows.ps1 | 1 + scripts/prepare-jdtls.ps1 | 80 +++++++++ scripts/prepare-jdtls.sh | 170 ++++++++++++++++++ third_party/jdtls/manifest.json | 8 + windows/tauri/src-tauri/src/lsp.rs | 77 ++++++-- windows/tauri/src-tauri/tauri.conf.json | 3 +- 16 files changed, 527 insertions(+), 18 deletions(-) create mode 100644 scripts/prepare-jdtls.ps1 create mode 100755 scripts/prepare-jdtls.sh create mode 100644 third_party/jdtls/manifest.json diff --git a/.gitignore b/.gitignore index b76a6f4d3..b49666a61 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ rust/target/ DerivedData/ /windows/build*/ dist/ +.artifacts/ Fixtures/**/target/ *.xcuserstate *.xcuserdata/ diff --git a/README.md b/README.md index 126207abe..a8271ace7 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ When an external AI tool changes a project, Lithe helps you locate the affected ## Use Lithe -Lithe requires macOS 13 or later. Java project features require a JDK; JDK 17 or JDK 21 is recommended. Maven projects need either a project `mvnw` or a system Maven installation. Lightweight completion does not start an external process; when a matching language server is installed, Lithe routes the capabilities it actually advertises through the shared Rust LSP Core. See the [language tooling and LSP architecture](./docs/architecture/language-tooling.md) for provider configuration and compatibility details. +Lithe requires macOS 13 or later. Java project features require JDK 17 or newer; JDK 17 or JDK 21 is recommended. Release packages include Eclipse JDT Language Server for Java completion, navigation, references, and diagnostics, so JDTLS does not need to be installed separately. Maven projects need either a project `mvnw` or a system Maven installation. Lightweight completion does not start an external process; Lithe routes the capabilities a running language server actually advertises through the shared Rust LSP Core. See the [language tooling and LSP architecture](./docs/architecture/language-tooling.md) for provider configuration and compatibility details. Download the latest macOS `.dmg` from [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest). If a release provides architecture-specific installers, choose `arm64` for Apple silicon or `x86_64` for an Intel Mac. Open the disk image, drag `Lithe.app` into `/Applications`, and launch it. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0a1415442..6e486b8fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -118,7 +118,7 @@ Lithe 是一款面向 AI 时代打造的轻量通用型 IDE。它面向多语言 ## 如何使用 -Lithe 需要 macOS 13 或更高版本。Java 项目功能需要 JDK,推荐使用 JDK 17 或 JDK 21;Maven 项目需要项目自带 `mvnw` 或系统中可用的 Maven。轻量补全无需启动外部进程;安装相应语言服务器后,Lithe 会通过共享 Rust LSP Core 按服务器实际声明的能力提供语义功能。详细设计与自定义 provider 配置见[语言工具与 LSP 架构](./docs/architecture/language-tooling.md)。 +Lithe 需要 macOS 13 或更高版本。Java 项目功能需要 JDK 17 或更高版本,推荐使用 JDK 17 或 JDK 21;正式安装包已包含 Eclipse JDT Language Server,可直接提供 Java 补全、跳转、引用和诊断,无需单独安装 JDTLS。Maven 项目需要项目自带 `mvnw` 或系统中可用的 Maven。轻量补全无需启动外部进程;Lithe 会通过共享 Rust LSP Core 按运行中服务器实际声明的能力提供语义功能。详细设计与自定义 provider 配置见[语言工具与 LSP 架构](./docs/architecture/language-tooling.md)。 从 [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest) 下载最新的 macOS `.dmg`。如果该版本提供独立架构安装包,M 系列芯片选择 `arm64`,Intel 芯片选择 `x86_64`。打开磁盘映像,将 `Lithe.app` 拖入 `/Applications` 后启动。 diff --git a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift index 19598ab01..8c7c70bab 100644 --- a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift +++ b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift @@ -5,15 +5,18 @@ import Foundation /// package manager or installs anything on the user's behalf. struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { private let homeDirectoryURL: URL + private let resourceDirectoryURL: URL? private let isExecutable: @Sendable (URL) -> Bool init( homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, + resourceDirectoryURL: URL? = Bundle.main.resourceURL, isExecutable: @escaping @Sendable (URL) -> Bool = { FileManager.default.isExecutableFile(atPath: $0.path) } ) { self.homeDirectoryURL = homeDirectoryURL.standardizedFileURL + self.resourceDirectoryURL = resourceDirectoryURL?.standardizedFileURL self.isExecutable = isExecutable } @@ -38,6 +41,14 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery { )) } + if command == "jdtls", let resourceDirectoryURL { + add( + resourceDirectoryURL.appendingPathComponent("LanguageServers/jdtls/bin/jdtls"), + source: .bundled, + detail: "Bundled with Lithe" + ) + } + // Project-local toolchains are preferred because they are reproducible // and do not alter the user's global environment. if let projectURL { diff --git a/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift b/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift index 094dd47c2..b94bc672e 100644 --- a/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift +++ b/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift @@ -1,6 +1,7 @@ import Foundation package enum RuntimeToolSource: String, Codable, Hashable, Sendable { + case bundled case project case environment case path @@ -11,6 +12,7 @@ package enum RuntimeToolSource: String, Codable, Hashable, Sendable { package var displayName: String { switch self { + case .bundled: "Bundled" case .project: "Project" case .environment: "Environment" case .path: "PATH" diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 3aacf6c28..4784ee5fc 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -347,6 +347,24 @@ struct RunConfigurationIntegrationTests { #expect(candidates.first?.source == .environment) } + @Test + func macToolDiscoveryPrefersBundledJDTLS() { + let resources = URL(fileURLWithPath: "/Applications/Lithe.app/Contents/Resources", isDirectory: true) + let root = URL(fileURLWithPath: "/tmp/mac-java-project", isDirectory: true) + let bundled = resources.appendingPathComponent("LanguageServers/jdtls/bin/jdtls") + let project = root.appendingPathComponent(".lithe/toolchains/bin/jdtls") + let discovery = MacRuntimeToolDiscovery( + homeDirectoryURL: URL(fileURLWithPath: "/tmp/home", isDirectory: true), + resourceDirectoryURL: resources, + isExecutable: { $0 == bundled || $0 == project } + ) + + let candidates = discovery.candidates(for: "jdtls", projectURL: root, environment: [:]) + + #expect(candidates.map(\.source) == [.bundled, .project]) + #expect(candidates.first?.executableURL == bundled) + } + @Test func legacyJavaDoesNotAcceptGenericDAPBreakpointsWithoutAnAdapter() throws { let source = URL(fileURLWithPath: "/tmp/Main.java") diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index f9d46d355..707370cbd 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -125,6 +125,8 @@ Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和 macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE__PATH`/`LITHE_TOOL__PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。 +正式 macOS 与 Windows 安装包包含 JDTLS。发布构建根据 `third_party/jdtls/manifest.json` 下载固定版本,同时校验归档与 EPL-2.0 许可证的 SHA-256,再将产物放入应用资源目录的 `LanguageServers/jdtls`。平台 adapter 优先使用这个包内启动器;开发环境仍保留项目工具目录、显式覆盖和 `PATH` 等外部候选作为回退。下载只发生在构建阶段,应用运行时不会联网安装 JDTLS;Java 语义功能仍要求系统提供 JDK 17 或更高版本。 + LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provider 的可执行文件覆盖路径。session 创建时先验证并使用该路径,路径失效时继续使用 catalog 候选进行自动探测。Homebrew formula 和官方兜底地址都来自 `languageServerInstallation`,Swift 不维护 provider ID 映射。安装仍由平台层以参数数组直接执行 `brew install`,不经过 shell;没有 Homebrew/formula 时只打开对应项目的 HTTPS 官方发布或安装页面,避免用一套不安全的通用解压逻辑处理不同项目的签名和包结构。 项目配置是可执行工具配置,只有打开受信任项目时才应启用。JSON 可以声明 executable name 和参数,但不能声明 shell、任意安装命令或关闭路径/URL 校验;进程创建、超时、可执行文件验证、Homebrew 调用方式和 HTTPS 限制仍属于平台安全边界。 diff --git a/rust/lithe-core/src/lsp/interface/process.rs b/rust/lithe-core/src/lsp/interface/process.rs index ac5f4df73..d557d1305 100644 --- a/rust/lithe-core/src/lsp/interface/process.rs +++ b/rust/lithe-core/src/lsp/interface/process.rs @@ -9,6 +9,8 @@ use crate::protocol::{CoreError, ErrorCode}; use std::collections::BTreeMap; use std::io::{Read, Write}; +#[cfg(target_os = "windows")] +use std::path::Path; use std::path::PathBuf; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::{Arc, Mutex}; @@ -70,11 +72,9 @@ pub struct SystemProcessLauncher; impl LspProcessLauncher for SystemProcessLauncher { fn launch(&self, spec: LspProcessSpec) -> Result { - let mut command = Command::new(&spec.executable); + let mut command = language_server_command(&spec); command - .args(&spec.arguments) .current_dir(&spec.working_directory) - .envs(&spec.environment) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -106,6 +106,84 @@ impl LspProcessLauncher for SystemProcessLauncher { } } +fn language_server_command(spec: &LspProcessSpec) -> Command { + #[cfg(target_os = "windows")] + if is_windows_batch_script(&spec.executable) { + let mut command = Command::new("cmd.exe"); + command.envs(&spec.environment); + // Environment expansion is single-pass, so quoted values reach the batch + // file without cmd.exe interpreting path metacharacters or percent pairs. + command.env( + WINDOWS_BATCH_EXECUTABLE_ENV, + windows_batch_env_value(&spec.executable.to_string_lossy()), + ); + for (index, argument) in spec.arguments.iter().enumerate() { + command.env( + windows_batch_argument_env(index), + windows_batch_env_value(argument), + ); + } + command.raw_arg("/D"); + command.raw_arg("/S"); + command.raw_arg("/V:OFF"); + command.raw_arg("/C"); + command.raw_arg(windows_batch_command_line(spec.arguments.len())); + return command; + } + + let mut command = Command::new(&spec.executable); + command.args(&spec.arguments).envs(&spec.environment); + command +} + +#[cfg(target_os = "windows")] +fn is_windows_batch_script(executable: &Path) -> bool { + matches!( + executable.extension().and_then(|extension| extension.to_str()), + Some(extension) if extension.eq_ignore_ascii_case("bat") || extension.eq_ignore_ascii_case("cmd") + ) +} + +#[cfg(target_os = "windows")] +const WINDOWS_BATCH_EXECUTABLE_ENV: &str = "LITHE_LSP_BATCH_EXECUTABLE"; + +#[cfg(target_os = "windows")] +fn windows_batch_argument_env(index: usize) -> String { + format!("LITHE_LSP_BATCH_ARGUMENT_{index}") +} + +#[cfg(target_os = "windows")] +fn windows_batch_command_line(argument_count: usize) -> String { + let mut command_line = format!("\"%{WINDOWS_BATCH_EXECUTABLE_ENV}%\""); + for index in 0..argument_count { + command_line.push_str(&format!(" \"%{}%\"", windows_batch_argument_env(index))); + } + format!("\"{command_line}\"") +} + +#[cfg(target_os = "windows")] +fn windows_batch_env_value(value: &str) -> String { + let mut escaped = String::new(); + let mut backslashes = 0; + for character in value.chars() { + match character { + '\\' => backslashes += 1, + '"' => { + escaped.push_str(&"\\".repeat(backslashes * 2 + 1)); + escaped.push('"'); + backslashes = 0; + } + _ => { + escaped.push_str(&"\\".repeat(backslashes)); + escaped.push(character); + backslashes = 0; + } + } + } + escaped.push_str(&"\\".repeat(backslashes * 2)); + escaped +} + fn apply_language_server_creation_flags(command: &mut Command) { #[cfg(target_os = "windows")] command.creation_flags(language_server_process_creation_flags()); @@ -187,8 +265,88 @@ fn missing_stream(stream: &str) -> CoreError { #[cfg(all(test, target_os = "windows"))] mod tests { + use super::{ + language_server_command, windows_batch_command_line, LspProcessLauncher, LspProcessSpec, + SystemProcessLauncher, WINDOWS_BATCH_EXECUTABLE_ENV, + }; + use std::collections::BTreeMap; + use std::ffi::OsStr; + use std::fs; + use std::io::Read; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + #[test] fn background_language_servers_do_not_create_windows_console() { assert_eq!(super::language_server_process_creation_flags(), 0x0800_0000); } + + #[test] + fn batch_language_server_uses_cmd_exe() { + let spec = LspProcessSpec { + executable: PathBuf::from(r"C:\Program Files\Lithe\jdtls.bat"), + arguments: vec!["-data".to_string(), r"C:\workspace data".to_string()], + working_directory: PathBuf::from(r"C:\workspace"), + environment: BTreeMap::new(), + }; + + let command = language_server_command(&spec); + + assert_eq!(command.get_program(), OsStr::new("cmd.exe")); + assert_eq!( + windows_batch_command_line(spec.arguments.len()), + r#"""%LITHE_LSP_BATCH_EXECUTABLE%" "%LITHE_LSP_BATCH_ARGUMENT_0%" "%LITHE_LSP_BATCH_ARGUMENT_1%"""# + ); + assert!(command.get_envs().any(|(name, value)| { + name == OsStr::new(WINDOWS_BATCH_EXECUTABLE_ENV) + && value == Some(spec.executable.as_os_str()) + })); + } + + #[test] + fn batch_language_server_preserves_spaced_paths_and_shell_metacharacters() { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!("lithe lsp batch {stamp}")); + fs::create_dir_all(&root).expect("temp directory"); + let executable = root.join("scripted server.cmd"); + let argument_writer = root.join("write-argument.ps1"); + fs::write( + &argument_writer, + "[Console]::Out.Write(($args -join [Environment]::NewLine))\r\n", + ) + .expect("PowerShell argument writer"); + fs::write( + &executable, + "@echo off\r\npowershell.exe -NoLogo -NoProfile -File \"%~dp0write-argument.ps1\" %*\r\n", + ) + .expect("batch script"); + let arguments = vec![ + "workspace&echo_injected".to_string(), + "percent%PATH%value".to_string(), + "bang!value".to_string(), + "caret^value".to_string(), + "paren(value)".to_string(), + "quoted\"value".to_string(), + r"C:\workspace data\".to_string(), + ]; + let spec = LspProcessSpec { + executable, + arguments: arguments.clone(), + working_directory: root.clone(), + environment: BTreeMap::new(), + }; + + let mut streams = SystemProcessLauncher + .launch(spec) + .expect("launch batch script"); + streams.handle.close_input(); + let mut output = String::new(); + streams.output.read_to_string(&mut output).expect("stdout"); + + assert_eq!(output, arguments.join("\r\n")); + fs::remove_dir_all(root).ok(); + } } diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index b898ac77d..b3685a676 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -8,6 +8,7 @@ param( $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot $windowsApp = Join-Path $root "windows/tauri" +& (Join-Path $root "scripts/prepare-jdtls.ps1") | Out-Null Set-Location $windowsApp if ($null -eq (Get-Command bun -ErrorAction SilentlyContinue)) { diff --git a/scripts/package-app.sh b/scripts/package-app.sh index 7eab6d51d..385a95242 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -20,6 +20,7 @@ case "$ARCH" in esac cd "$ROOT_DIR" +JDTLS_ROOT=$("$ROOT_DIR/scripts/prepare-jdtls.sh") if [[ "$ARCH" == "universal" ]]; then scripts/build-macos.sh --configuration release --triple "$ARM64_TRIPLE" scripts/build-macos.sh --configuration release --triple "$X86_64_TRIPLE" @@ -89,6 +90,8 @@ if [[ ! -d "$resource_bundle" ]]; then exit 1 fi cp -R "$resource_bundle" "$APP_DIR/Contents/Resources/Lithe_Lithe.bundle" +mkdir -p "$APP_DIR/Contents/Resources/LanguageServers" +cp -R "$JDTLS_ROOT" "$APP_DIR/Contents/Resources/LanguageServers/jdtls" OFFICIAL_PLUGIN_DESTINATION="$APP_DIR/Contents/Resources/OfficialPlugins" mkdir -p "$OFFICIAL_PLUGIN_DESTINATION" diff --git a/scripts/package-windows.ps1 b/scripts/package-windows.ps1 index 601bd8a2a..59d7f250a 100644 --- a/scripts/package-windows.ps1 +++ b/scripts/package-windows.ps1 @@ -15,6 +15,7 @@ $windowsApp = Join-Path $root "windows/tauri" $output = Join-Path $root $OutputDirectory $versionConfig = Join-Path $env:RUNNER_TEMP "lithe-tauri-version.json" +& (Join-Path $root "scripts/prepare-jdtls.ps1") | Out-Null @{ version = $Version } | ConvertTo-Json | Set-Content -Encoding utf8 $versionConfig Set-Location $windowsApp & bun install --frozen-lockfile diff --git a/scripts/prepare-jdtls.ps1 b/scripts/prepare-jdtls.ps1 new file mode 100644 index 000000000..11857d3d7 --- /dev/null +++ b/scripts/prepare-jdtls.ps1 @@ -0,0 +1,80 @@ +[CmdletBinding()] +param( + [string]$OutputDirectory = "" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$manifestPath = Join-Path $root "third_party/jdtls/manifest.json" +$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json +$usesExistingRoot = [string]::IsNullOrWhiteSpace($OutputDirectory) -and + -not [string]::IsNullOrWhiteSpace($env:LITHE_JDTLS_ROOT) +$requestedOutput = if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { + if ($usesExistingRoot) { $env:LITHE_JDTLS_ROOT } else { Join-Path $root ".artifacts/jdtls" } +} else { + $OutputDirectory +} +$output = [System.IO.Path]::GetFullPath($requestedOutput) +$artifactsRoot = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts")) +$artifactsPrefix = $artifactsRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar +if (-not $usesExistingRoot -and + -not $output.StartsWith($artifactsPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "JDTLS output must be inside the repository .artifacts directory: $output" +} +$cache = Join-Path $root ".artifacts/jdtls-downloads" +$archive = if ([string]::IsNullOrWhiteSpace($env:LITHE_JDTLS_ARCHIVE)) { Join-Path $cache "jdtls.tar.gz" } else { $env:LITHE_JDTLS_ARCHIVE } +$license = Join-Path $cache "EPL-2.0.txt" + +function Assert-JdtlsOutput { + if (-not (Test-Path -LiteralPath (Join-Path $output "plugins") -PathType Container)) { throw "JDTLS plugins directory is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "config_win") -PathType Container)) { throw "JDTLS Windows configuration is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "bin/jdtls.ps1") -PathType Leaf)) { throw "JDTLS PowerShell launcher is missing: $output" } + if (-not (Test-Path -LiteralPath (Join-Path $output "bin/jdtls.bat") -PathType Leaf)) { throw "JDTLS batch launcher is missing: $output" } +} + +if ($usesExistingRoot) { + Assert-JdtlsOutput + Write-Output $output + exit 0 +} + +New-Item -ItemType Directory -Force -Path $cache | Out-Null +if (-not (Test-Path -LiteralPath $archive -PathType Leaf)) { Invoke-WebRequest -Uri $manifest.archiveURL -OutFile $archive } +$actualArchiveHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() +if ($actualArchiveHash -ne $manifest.archiveSHA256.ToLowerInvariant()) { throw "JDTLS archive checksum mismatch: expected $($manifest.archiveSHA256), got $actualArchiveHash" } +if (-not (Test-Path -LiteralPath $license -PathType Leaf)) { Invoke-WebRequest -Uri $manifest.licenseURL -OutFile $license } +$actualLicenseHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $license).Hash.ToLowerInvariant() +if ($actualLicenseHash -ne $manifest.licenseSHA256.ToLowerInvariant()) { throw "JDTLS license checksum mismatch: expected $($manifest.licenseSHA256), got $actualLicenseHash" } + +if (Test-Path -LiteralPath $output) { Remove-Item -Recurse -Force -LiteralPath $output } +New-Item -ItemType Directory -Force -Path $output | Out-Null +tar.exe -xzf $archive -C $output +Copy-Item -LiteralPath $license -Destination (Join-Path $output "LICENSE-EPL-2.0.txt") -Force + +$windowsLauncher = @' +$ErrorActionPreference = "Stop" +$javaExecutable = if ($env:JAVA_HOME) { Join-Path $env:JAVA_HOME "bin\java.exe" } else { "java" } +$jvmArguments = [System.Collections.Generic.List[string]]::new() +$jvmArguments.Add("--add-modules=ALL-SYSTEM") +$jvmArguments.Add("--add-opens=java.base/java.util=ALL-UNNAMED") +$jvmArguments.Add("--add-opens=java.base/java.lang=ALL-UNNAMED") +$serverArguments = [System.Collections.Generic.List[string]]::new() +for ($index = 0; $index -lt $args.Count; $index++) { + $argument = [string]$args[$index] + if ($argument -eq "--java-executable") { if ($index + 1 -ge $args.Count) { throw "--java-executable requires a path" }; $javaExecutable = [string]$args[++$index] } + elseif ($argument.StartsWith("--jvm-arg=")) { $jvmArguments.Add($argument.Substring("--jvm-arg=".Length)) } + elseif ($argument -eq "--jvm-arg") { if ($index + 1 -ge $args.Count) { throw "--jvm-arg requires a value" }; $jvmArguments.Add([string]$args[++$index]) } + else { $serverArguments.Add($argument) } +} +$launcherJar = Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot "..\plugins") -Filter "org.eclipse.equinox.launcher_*.jar" | Sort-Object Name | Select-Object -First 1 +if ($null -eq $launcherJar) { throw "JDTLS Equinox launcher was not found" } +$configuration = Join-Path $PSScriptRoot "..\config_win" +& $javaExecutable @jvmArguments "-Declipse.application=org.eclipse.jdt.ls.core.id1" "-Declipse.product=org.eclipse.jdt.ls.core.product" "-Dosgi.bundles.defaultStartLevel=4" "-Dlog.protocol=true" "-Dlog.level=ALL" "-jar" $launcherJar.FullName "-configuration" $configuration @serverArguments +exit $LASTEXITCODE +'@ +Set-Content -LiteralPath (Join-Path $output "bin/jdtls.ps1") -Value $windowsLauncher -Encoding ascii + +$batchLauncher = "@echo off`r`npowershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File `"%~dp0jdtls.ps1`" %*`r`nexit /b %ERRORLEVEL%`r`n" +Set-Content -LiteralPath (Join-Path $output "bin/jdtls.bat") -Value $batchLauncher -Encoding ascii +Assert-JdtlsOutput +Write-Output $output diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh new file mode 100755 index 000000000..e5a115038 --- /dev/null +++ b/scripts/prepare-jdtls.sh @@ -0,0 +1,170 @@ +#!/bin/zsh + +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +MANIFEST="$ROOT_DIR/third_party/jdtls/manifest.json" +OUTPUT_DIR="${LITHE_JDTLS_ROOT:-$ROOT_DIR/.artifacts/jdtls}" +CACHE_DIR="$ROOT_DIR/.artifacts/jdtls-downloads" + +manifest_value() { + /usr/bin/plutil -extract "$1" raw -o - "$MANIFEST" +} + +archive_url="$(manifest_value archiveURL)" +archive_sha256="$(manifest_value archiveSHA256)" +license_url="$(manifest_value licenseURL)" +license_sha256="$(manifest_value licenseSHA256)" +archive_path="${LITHE_JDTLS_ARCHIVE:-$CACHE_DIR/jdtls.tar.gz}" +license_path="$CACHE_DIR/EPL-2.0.txt" + +validate_output() { + [[ -d "$OUTPUT_DIR/plugins" ]] || { print -u2 -- "JDTLS plugins directory is missing: $OUTPUT_DIR"; exit 1; } + [[ -d "$OUTPUT_DIR/config_mac" ]] || { print -u2 -- "JDTLS macOS configuration is missing: $OUTPUT_DIR"; exit 1; } + [[ -d "$OUTPUT_DIR/config_win" ]] || { print -u2 -- "JDTLS Windows configuration is missing: $OUTPUT_DIR"; exit 1; } + [[ -x "$OUTPUT_DIR/bin/jdtls" ]] || { print -u2 -- "JDTLS launcher is missing: $OUTPUT_DIR/bin/jdtls"; exit 1; } + [[ -f "$OUTPUT_DIR/bin/jdtls.ps1" ]] || { print -u2 -- "JDTLS Windows launcher is missing: $OUTPUT_DIR"; exit 1; } +} + +if [[ -n "${LITHE_JDTLS_ROOT:-}" ]]; then + validate_output + print -r -- "$OUTPUT_DIR" + exit 0 +fi + +mkdir -p "$CACHE_DIR" +if [[ ! -f "$archive_path" ]]; then + curl --fail --location --retry 3 --output "$archive_path" "$archive_url" +fi +actual_archive_sha256="$(shasum -a 256 "$archive_path" | awk '{print tolower($1)}')" +if [[ "$actual_archive_sha256" != "$archive_sha256" ]]; then + print -u2 -- "JDTLS archive checksum mismatch: expected $archive_sha256, got $actual_archive_sha256" + exit 1 +fi + +if [[ ! -f "$license_path" ]]; then + curl --fail --location --retry 3 --output "$license_path" "$license_url" +fi +actual_license_sha256="$(shasum -a 256 "$license_path" | awk '{print tolower($1)}')" +if [[ "$actual_license_sha256" != "$license_sha256" ]]; then + print -u2 -- "JDTLS license checksum mismatch: expected $license_sha256, got $actual_license_sha256" + exit 1 +fi + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" +tar -xzf "$archive_path" -C "$OUTPUT_DIR" +cp "$license_path" "$OUTPUT_DIR/LICENSE-EPL-2.0.txt" + +cat > "$OUTPUT_DIR/bin/jdtls" <<'EOF' +#!/bin/zsh + +set -euo pipefail + +SCRIPT_DIR="${0:A:h}" +JAVA_EXECUTABLE="${JAVA_HOME:-}/bin/java" +if [[ ! -x "$JAVA_EXECUTABLE" ]]; then + JAVA_EXECUTABLE="${JAVA:-java}" +fi + +JVM_ARGUMENTS=( + "--add-modules=ALL-SYSTEM" + "--add-opens=java.base/java.util=ALL-UNNAMED" + "--add-opens=java.base/java.lang=ALL-UNNAMED" +) +SERVER_ARGUMENTS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --java-executable) + [[ $# -ge 2 ]] || { print -u2 -- "--java-executable requires a path"; exit 2; } + JAVA_EXECUTABLE="$2" + shift 2 + ;; + --jvm-arg=*) + JVM_ARGUMENTS+=("${1#--jvm-arg=}") + shift + ;; + --jvm-arg) + [[ $# -ge 2 ]] || { print -u2 -- "--jvm-arg requires a value"; exit 2; } + JVM_ARGUMENTS+=("$2") + shift 2 + ;; + *) + SERVER_ARGUMENTS+=("$1") + shift + ;; + esac +done + +LAUNCHER_JAR=$(find "$SCRIPT_DIR/../plugins" -maxdepth 1 -name 'org.eclipse.equinox.launcher_*.jar' -print | sort | head -n 1) +[[ -n "$LAUNCHER_JAR" ]] || { print -u2 -- "JDTLS Equinox launcher was not found"; exit 1; } +if [[ "$(uname -m)" == "arm64" && -d "$SCRIPT_DIR/../config_mac_arm" ]]; then + CONFIGURATION="$SCRIPT_DIR/../config_mac_arm" +else + CONFIGURATION="$SCRIPT_DIR/../config_mac" +fi + +exec "$JAVA_EXECUTABLE" \ + "${JVM_ARGUMENTS[@]}" \ + -Declipse.application=org.eclipse.jdt.ls.core.id1 \ + -Declipse.product=org.eclipse.jdt.ls.core.product \ + -Dosgi.bundles.defaultStartLevel=4 \ + -Dlog.protocol=true \ + -Dlog.level=ALL \ + -jar "$LAUNCHER_JAR" \ + -configuration "$CONFIGURATION" \ + "${SERVER_ARGUMENTS[@]}" +EOF + +cat > "$OUTPUT_DIR/bin/jdtls.ps1" <<'EOF' +$ErrorActionPreference = "Stop" + +$javaExecutable = if ($env:JAVA_HOME) { Join-Path $env:JAVA_HOME "bin\java.exe" } else { "java" } +$jvmArguments = [System.Collections.Generic.List[string]]::new() +$jvmArguments.Add("--add-modules=ALL-SYSTEM") +$jvmArguments.Add("--add-opens=java.base/java.util=ALL-UNNAMED") +$jvmArguments.Add("--add-opens=java.base/java.lang=ALL-UNNAMED") +$serverArguments = [System.Collections.Generic.List[string]]::new() + +for ($index = 0; $index -lt $args.Count; $index++) { + $argument = [string]$args[$index] + if ($argument -eq "--java-executable") { + if ($index + 1 -ge $args.Count) { throw "--java-executable requires a path" } + $javaExecutable = [string]$args[++$index] + } elseif ($argument.StartsWith("--jvm-arg=")) { + $jvmArguments.Add($argument.Substring("--jvm-arg=".Length)) + } elseif ($argument -eq "--jvm-arg") { + if ($index + 1 -ge $args.Count) { throw "--jvm-arg requires a value" } + $jvmArguments.Add([string]$args[++$index]) + } else { + $serverArguments.Add($argument) + } +} + +$launcherJar = Get-ChildItem -LiteralPath (Join-Path $PSScriptRoot "..\plugins") -Filter "org.eclipse.equinox.launcher_*.jar" | + Sort-Object Name | + Select-Object -First 1 +if ($null -eq $launcherJar) { throw "JDTLS Equinox launcher was not found" } +$configuration = Join-Path $PSScriptRoot "..\config_win" + +& $javaExecutable @jvmArguments ` + "-Declipse.application=org.eclipse.jdt.ls.core.id1" ` + "-Declipse.product=org.eclipse.jdt.ls.core.product" ` + "-Dosgi.bundles.defaultStartLevel=4" ` + "-Dlog.protocol=true" ` + "-Dlog.level=ALL" ` + "-jar" $launcherJar.FullName ` + "-configuration" $configuration ` + @serverArguments +exit $LASTEXITCODE +EOF + +cat > "$OUTPUT_DIR/bin/jdtls.bat" <<'EOF' +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0jdtls.ps1" %* +exit /b %ERRORLEVEL% +EOF + +chmod +x "$OUTPUT_DIR/bin/jdtls" +validate_output +print -r -- "$OUTPUT_DIR" diff --git a/third_party/jdtls/manifest.json b/third_party/jdtls/manifest.json new file mode 100644 index 000000000..2c526d0ed --- /dev/null +++ b/third_party/jdtls/manifest.json @@ -0,0 +1,8 @@ +{ + "version": "1.38.0", + "archiveURL": "https://download.eclipse.org/jdtls/milestones/1.38.0/jdt-language-server-1.38.0-202408011337.tar.gz", + "archiveSHA256": "ba697788a19f2ba57b16302aba6b343c649928c95f76b0d170494ac12d17ac78", + "licenseURL": "https://www.eclipse.org/org/documents/epl-2.0/EPL-2.0.txt", + "licenseSHA256": "0becf16567beb77fa252b7664631dd177c8f9a1889e48995b45379c7130e5303", + "minimumJavaVersion": 17 +} diff --git a/windows/tauri/src-tauri/src/lsp.rs b/windows/tauri/src-tauri/src/lsp.rs index 41b22adb3..3c0e68d8c 100644 --- a/windows/tauri/src-tauri/src/lsp.rs +++ b/windows/tauri/src-tauri/src/lsp.rs @@ -6,7 +6,6 @@ use crate::run; use serde::Serialize; use std::ffi::OsStr; -use std::fs; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager}; @@ -49,8 +48,10 @@ pub fn lsp_resolve_java_launch( ) -> Result { let workspace = PathBuf::from(&workspace_path); let project_root = workspace.is_dir().then_some(workspace.as_path()); + let bundled_root = bundled_jdtls_root(&app); let resolution = resolve_java_lsp_launch( std::env::var_os("PATH").as_deref(), + bundled_root.as_deref(), &jdtls_search_roots(project_root), project_root, java_home_path.as_deref(), @@ -76,13 +77,16 @@ pub fn lsp_resolve_java_launch( fn resolve_java_lsp_launch( path_env: Option<&OsStr>, + bundled_root: Option<&Path>, extra_roots: &[PathBuf], project_root: Option<&Path>, java_home_override: Option<&str>, ) -> Result { - let executable = find_jdtls_executable(path_env, extra_roots).ok_or_else(|| { - "Could not find jdtls. Install Eclipse JDT Language Server and add it to PATH.".to_string() - })?; + let executable = + find_jdtls_executable(path_env, bundled_root, extra_roots).ok_or_else(|| { + "Could not find jdtls. Install Eclipse JDT Language Server and add it to PATH." + .to_string() + })?; let java_home = resolve_java_home(project_root, java_home_override); Ok(JavaLspResolution { executable, @@ -90,26 +94,41 @@ fn resolve_java_lsp_launch( }) } -fn find_jdtls_executable(path_env: Option<&OsStr>, extra_roots: &[PathBuf]) -> Option { - jdtls_candidates(path_env, extra_roots) +fn find_jdtls_executable( + path_env: Option<&OsStr>, + bundled_root: Option<&Path>, + extra_roots: &[PathBuf], +) -> Option { + jdtls_candidates(path_env, bundled_root, extra_roots) .into_iter() .find(|candidate| candidate.is_file()) } -fn jdtls_candidates(path_env: Option<&OsStr>, extra_roots: &[PathBuf]) -> Vec { +fn jdtls_candidates( + path_env: Option<&OsStr>, + bundled_root: Option<&Path>, + extra_roots: &[PathBuf], +) -> Vec { let mut candidates = Vec::new(); + if let Some(root) = bundled_root { + push_jdtls_root(&mut candidates, root); + } if let Some(path) = path_env { for directory in std::env::split_paths(path) { push_jdtls_names(&mut candidates, &directory); } } for root in extra_roots { - push_jdtls_names(&mut candidates, root); - push_jdtls_names(&mut candidates, &root.join("bin")); + push_jdtls_root(&mut candidates, root); } candidates } +fn push_jdtls_root(candidates: &mut Vec, root: &Path) { + push_jdtls_names(candidates, root); + push_jdtls_names(candidates, &root.join("bin")); +} + fn push_jdtls_names(candidates: &mut Vec, directory: &Path) { for name in JDTLS_EXECUTABLE_NAMES { candidates.push(directory.join(name)); @@ -147,6 +166,13 @@ fn jdtls_search_roots(project_root: Option<&Path>) -> Vec { roots } +fn bundled_jdtls_root(app: &AppHandle) -> Option { + app.path() + .resource_dir() + .ok() + .map(|directory| directory.join("LanguageServers").join("jdtls")) +} + fn resolve_java_home( project_root: Option<&Path>, java_home_override: Option<&str>, @@ -181,6 +207,7 @@ fn normalize_path(path: &Path) -> String { #[cfg(test)] mod tests { use super::*; + use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_dir() -> PathBuf { @@ -201,7 +228,7 @@ mod tests { let executable = bin.join("jdtls.bat"); fs::write(&executable, "@echo off\n").expect("jdtls"); - let found = find_jdtls_executable(None, &[root.clone()]).expect("found"); + let found = find_jdtls_executable(None, None, &[root.clone()]).expect("found"); assert_eq!(found, executable); fs::remove_dir_all(root).ok(); } @@ -215,18 +242,44 @@ mod tests { fs::write(&path_executable, "@echo off\n").expect("path jdtls"); fs::write(&extra_executable, "@echo off\n").expect("extra jdtls"); - let found = find_jdtls_executable(Some(path_root.as_os_str()), &[extra_root.clone()]) + let found = find_jdtls_executable(Some(path_root.as_os_str()), None, &[extra_root.clone()]) .expect("found"); assert_eq!(found, path_executable); fs::remove_dir_all(path_root).ok(); fs::remove_dir_all(extra_root).ok(); } + #[test] + fn prefers_bundled_jdtls_before_path_and_external_roots() { + let bundled_root = temp_dir(); + let bundled_bin = bundled_root.join("bin"); + let path_root = temp_dir(); + let external_root = temp_dir(); + fs::create_dir_all(&bundled_bin).expect("bundled bin"); + let bundled_executable = bundled_bin.join("jdtls.bat"); + fs::write(&bundled_executable, "@echo off\n").expect("bundled jdtls"); + fs::write(path_root.join("jdtls.cmd"), "@echo off\n").expect("path jdtls"); + fs::write(external_root.join("jdtls.exe"), []).expect("external jdtls"); + + let found = find_jdtls_executable( + Some(path_root.as_os_str()), + Some(&bundled_root), + &[external_root.clone()], + ) + .expect("found"); + + assert_eq!(found, bundled_executable); + fs::remove_dir_all(bundled_root).ok(); + fs::remove_dir_all(path_root).ok(); + fs::remove_dir_all(external_root).ok(); + } + #[test] fn reports_a_stable_error_when_jdtls_is_missing() { let missing = temp_dir().join("empty-jdtls-root"); fs::create_dir_all(&missing).expect("missing root"); - let error = resolve_java_lsp_launch(None, &[missing.clone()], None, None).unwrap_err(); + let error = + resolve_java_lsp_launch(None, None, &[missing.clone()], None, None).unwrap_err(); assert!(error.contains("jdtls"), "{error}"); fs::remove_dir_all(missing).ok(); } diff --git a/windows/tauri/src-tauri/tauri.conf.json b/windows/tauri/src-tauri/tauri.conf.json index 78c28bd64..7f00b38a8 100644 --- a/windows/tauri/src-tauri/tauri.conf.json +++ b/windows/tauri/src-tauri/tauri.conf.json @@ -22,7 +22,8 @@ "active": true, "targets": ["nsis", "msi"], "resources": { - "../src/extensions/bundled/**/*": "extensions/bundled/" + "../src/extensions/bundled/**/*": "extensions/bundled/", + "../../../.artifacts/jdtls": "LanguageServers/jdtls" }, "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico"] }, From 4087b2bcf9e2c875d35b88432ba7df72e1837a74 Mon Sep 17 00:00:00 2001 From: Mucheen <1528136628@qq.com> Date: Tue, 18 Aug 2026 21:10:49 +0800 Subject: [PATCH 2/3] Configure an independent JDK for JDTLS Add automatic and manual JDK 17+ resolution on macOS and Windows while keeping project runtimes independent. Recover invalid JDTLS caches and package the server only for Release builds.\n\nRefs #149 --- .../RuntimeSettingsFeatureModel.swift | 4 + Sources/Lithe/Models/AppModel/AppModel.swift | 16 +- .../Models/Runtime/ProjectRuntimeModels.swift | 20 +++ .../Platform/MacOS/MacServiceContainer.swift | 12 +- .../Services/Java/ProjectRuntimeService.swift | 28 ++++ .../Views/Language/LSPControlCenterView.swift | 24 ++- .../Runtime/LanguageProviderRuntime.swift | 29 +++- .../JavaLanguageServerRuntimeTests.swift | 103 +++++++++++++ .../RunConfigurationIntegrationTests.swift | 29 ++++ docs/architecture/language-tooling.md | 2 + scripts/build-windows.ps1 | 10 +- scripts/package-windows.ps1 | 1 + scripts/prepare-jdtls.ps1 | 61 +++++++- scripts/prepare-jdtls.sh | 66 +++++--- windows/tauri/src-tauri/src/lsp.rs | 145 +++++++++++++++--- windows/tauri/src-tauri/src/main.rs | 1 + windows/tauri/src-tauri/src/run.rs | 2 +- windows/tauri/src-tauri/tauri.conf.json | 3 +- windows/tauri/src-tauri/tauri.jdtls.conf.json | 8 + .../features/editor/lsp/java-lsp-host-api.ts | 10 ++ .../editor/lsp/resolve-editor-lsp-launch.ts | 4 +- .../components/tabs/editor-settings.tsx | 126 ++++++++++++++- .../settings/config/default-settings.ts | 1 + .../features/settings/config/search-index.ts | 8 + .../lib/settings-normalization.test.ts | 25 +++ .../settings/lib/settings-normalization.ts | 8 + .../features/settings/types/settings.types.ts | 2 + windows/tauri/src/i18n/locale.ts | 25 +++ 28 files changed, 702 insertions(+), 71 deletions(-) create mode 100644 Tests/LitheTests/JavaLanguageServerRuntimeTests.swift create mode 100644 windows/tauri/src-tauri/tauri.jdtls.conf.json create mode 100644 windows/tauri/src/features/settings/lib/settings-normalization.test.ts diff --git a/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift b/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift index bf081aec3..d32dc7ce9 100644 --- a/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift +++ b/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift @@ -12,6 +12,10 @@ final class RuntimeSettingsFeatureModel: ObservableObject { @Published private(set) var javaEnvironmentReport: JavaEnvironmentReport? @Published private(set) var isDiscovering: Bool + var javaLanguageServerRuntimes: [JavaRuntimeCandidate] { + service.javaLanguageServerRuntimes + } + init(service: ProjectRuntimeService) { self.service = service _javaRuntimes = Published(initialValue: service.javaRuntimes) diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift index 46db8cc57..5ad62743f 100644 --- a/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -314,7 +314,7 @@ final class AppModel: ObservableObject, Identifiable { } var detectedJavaLanguageServerJDKs: [JavaRuntimeCandidate] { - runtimeFeature.javaRuntimes + runtimeFeature.javaLanguageServerRuntimes } func selectJavaLanguageServerJDK(_ runtime: JavaRuntimeCandidate) { @@ -325,17 +325,29 @@ final class AppModel: ObservableObject, Identifiable { await runtimeFeature.refreshAvailableRuntimes() } + func useAutomaticJavaLanguageServerJDK() { + applyJavaLanguageServerJDKPath("") + } + func chooseJavaLanguageServerJDK() { guard let url = platformUI.chooseDirectory( title: settings.language == .simplifiedChinese ? "选择 LSP 运行 JDK" : "Choose LSP Runtime JDK", prompt: settings.language == .simplifiedChinese ? "选择" : "Choose" ) else { return } - guard services.projectRuntimeService.configuredJavaExecutableURL(overridePath: url.path) != nil else { + guard let runtime = services.projectRuntimeService.inspectJavaLanguageServerRuntime( + atPath: url.path + ) else { showNotification(settings.language == .simplifiedChinese ? "所选目录不是有效的 JDK Home" : "The selected directory is not a valid JDK Home") return } + guard runtime.supportsJDTLS else { + showNotification(settings.language == .simplifiedChinese + ? "JDTLS 需要 JDK 17 或更高版本;所选版本为 \(runtime.version)" + : "JDTLS requires JDK 17 or newer; the selected version is \(runtime.version)") + return + } applyJavaLanguageServerJDKPath(url.standardizedFileURL.path) } diff --git a/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift b/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift index d51678957..c898ae41c 100644 --- a/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift +++ b/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift @@ -26,6 +26,8 @@ struct ProjectRuntimeSettings: Codable, Hashable, Sendable { } struct JavaRuntimeCandidate: Identifiable, Hashable, Sendable { + static let minimumJDTLSMajorVersion = 17 + let homePath: String let version: String let vendor: String @@ -36,6 +38,24 @@ struct JavaRuntimeCandidate: Identifiable, Hashable, Sendable { let vendor = vendor.isEmpty ? "JDK" : vendor return "\(vendor) \(version)" } + + var majorVersion: Int? { + let components = version + .split(whereSeparator: { !$0.isNumber }) + .compactMap { Int($0) } + switch components.first { + case 1: + return components.count > 1 ? components[1] : nil + case let major?: + return major + case nil: + return nil + } + } + + var supportsJDTLS: Bool { + majorVersion.map { $0 >= Self.minimumJDTLSMajorVersion } ?? false + } } struct MavenRuntimeCandidate: Identifiable, Hashable, Sendable { diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 0d88d68f1..40fb3ca66 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -210,11 +210,15 @@ final class MacServiceContainer { languageServerCore: rustCore, languageServerExecutableResolver: { tools.executableURL(for: $0) }, languageServerRuntimeResolver: { descriptor in - descriptor.id == "java" - ? runtimeService.configuredJavaExecutableURL( - overridePath: settings.javaLanguageServerJDKPath + guard descriptor.id == "java" else { return .notRequired } + guard let executableURL = runtimeService.javaLanguageServerExecutableURL( + overridePath: settings.javaLanguageServerJDKPath + ) else { + return .unavailable( + "JDTLS requires JDK 17 or newer. Configure a compatible JDK in Language Server settings." ) - : nil + } + return .available(executableURL) }, languageServerCacheDirectory: fileStorage.cacheDirectory() .appendingPathComponent("Lithe/language-servers", isDirectory: true), diff --git a/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/Sources/Lithe/Services/Java/ProjectRuntimeService.swift index 598cf31ac..cda40908e 100644 --- a/Sources/Lithe/Services/Java/ProjectRuntimeService.swift +++ b/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -153,6 +153,34 @@ final class ProjectRuntimeService: ObservableObject { return nil } + var javaLanguageServerRuntimes: [JavaRuntimeCandidate] { + javaRuntimes.filter(\.supportsJDTLS) + } + + func inspectJavaLanguageServerRuntime(atPath path: String) -> JavaRuntimeCandidate? { + let normalized = normalizedPath(path.trimmingCharacters(in: .whitespacesAndNewlines)) + guard !normalized.isEmpty, + (normalized as NSString).isAbsolutePath, + let home = runtimeLocator.validJavaHome(path: normalized) else { return nil } + return runtimeLocator.javaRuntime(at: home) + } + + func javaLanguageServerExecutableURL(overridePath: String? = nil) -> URL? { + let configured = overridePath? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let runtime: JavaRuntimeCandidate? + if configured.isEmpty { + runtime = runtimeLocator.discover().javaRuntimes.first(where: \.supportsJDTLS) + } else { + runtime = inspectJavaLanguageServerRuntime(atPath: configured) + } + + guard let runtime, + runtime.supportsJDTLS, + let home = runtimeLocator.validJavaHome(path: runtime.homePath) else { return nil } + return home.appendingPathComponent("bin/java") + } + func jdbExecutableURL( overridePath: String? = nil, for processKind: ProjectRuntimeProcessKind = .java diff --git a/Sources/Lithe/Views/Language/LSPControlCenterView.swift b/Sources/Lithe/Views/Language/LSPControlCenterView.swift index b5d9cdc9f..df6eaef6a 100644 --- a/Sources/Lithe/Views/Language/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -30,6 +30,9 @@ struct LSPControlCenterView: View { .background(LitheTheme.settingsSurface) } .background(LitheTheme.settingsSurface) + .task { + await model.refreshJavaLanguageServerJDKs() + } } private var header: some View { @@ -111,8 +114,18 @@ struct LSPControlCenterView: View { .font(.system(size: 11, weight: .semibold)) .foregroundStyle(LitheTheme.secondaryText) Menu { + Button { + model.useAutomaticJavaLanguageServerJDK() + } label: { + if model.javaLanguageServerJDKPath.isEmpty { + Label(usesChinese ? "自动检测" : "Automatic", systemImage: "checkmark") + } else { + Text(usesChinese ? "自动检测" : "Automatic") + } + } + Divider() if model.detectedJavaLanguageServerJDKs.isEmpty { - Text(usesChinese ? "未检测到 JDK" : "No JDKs detected") + Text(usesChinese ? "未检测到 JDK 17+" : "No JDK 17+ detected") } else { ForEach(model.detectedJavaLanguageServerJDKs) { runtime in Button { @@ -169,8 +182,8 @@ struct LSPControlCenterView: View { .menuIndicator(.hidden) .lithePointer() Text(usesChinese - ? "仅用于启动 Java 语言服务器,不影响项目使用的 JDK。" - : "Used only to start the Java language server; it does not affect the project JDK.") + ? "自动检测 JDK 17+,或选择仅用于 Java 语言服务器的 JDK;不影响项目 JDK。" + : "Automatically detects JDK 17+, or uses a JDK only for the Java language server; project JDK settings are unchanged.") .font(.system(size: 10.5)) .foregroundStyle(LitheTheme.secondaryText) } @@ -225,7 +238,10 @@ struct LSPControlCenterView: View { private var javaJDKDisplayPath: String { let path = model.javaLanguageServerJDKPath.trimmingCharacters(in: .whitespacesAndNewlines) if !path.isEmpty { return path } - return usesChinese ? "未配置" : "Not configured" + if let runtime = model.detectedJavaLanguageServerJDKs.first { + return (usesChinese ? "自动:" : "Automatic: ") + javaRuntimeTitle(runtime) + } + return usesChinese ? "自动检测" : "Automatic" } private var selectedJavaRuntime: JavaRuntimeCandidate? { diff --git a/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift b/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift index 92c0d6c43..67273234d 100644 --- a/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift +++ b/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift @@ -2,6 +2,12 @@ import Foundation import LitheCoreContracts import LitheModuleAPI +package enum LanguageServerRuntimeResolution: Sendable, Equatable { + case notRequired + case available(URL) + case unavailable(String) +} + @MainActor package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { package let descriptor: LanguageProviderDescriptor @@ -9,16 +15,18 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { private let languageServerLaunch: LanguageServerLaunchDescriptor? private let languageServerCore: any LanguageServerRuntimeCore private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? - private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? + private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? private let languageServerCacheDirectory: URL? private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID + private var runtimeUnavailableMessage: String? package var supportsLanguageServerSession: Bool { languageServerLaunch != nil } package var unavailableToolingMessage: String? { + if let runtimeUnavailableMessage { return runtimeUnavailableMessage } guard let command = languageServerLaunch?.executableNames.first else { return nil } return runtimeService.missingLanguageToolMessage(command) } @@ -29,7 +37,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { languageServerLaunch: LanguageServerLaunchDescriptor? = nil, languageServerCore: any LanguageServerRuntimeCore, languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, + languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? = nil, languageServerCacheDirectory: URL? = nil, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence @@ -46,6 +54,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { } package func makeLanguageServerSession() -> (any LanguageServerSession)? { + runtimeUnavailableMessage = nil guard let languageServerLaunch else { return nil } let executableURL = if let languageServerExecutableResolver { languageServerExecutableResolver(descriptor) @@ -55,6 +64,16 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { }).first } guard let executableURL else { return nil } + let runtimeExecutableURL: URL? + switch languageServerRuntimeResolver?(descriptor) ?? .notRequired { + case .notRequired: + runtimeExecutableURL = nil + case .available(let executableURL): + runtimeExecutableURL = executableURL + case .unavailable(let message): + runtimeUnavailableMessage = message + return nil + } var environment = runtimeService.languageToolProcessEnvironment() environment.merge(languageServerLaunch.environment) { _, configured in configured } return LanguageServerRuntimeSession( @@ -63,7 +82,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { arguments: languageServerLaunch.arguments, environment: environment, initializationOptions: languageServerLaunch.initializationOptions, - runtimeExecutableURL: languageServerRuntimeResolver?(descriptor), + runtimeExecutableURL: runtimeExecutableURL, cacheDirectoryURL: languageServerCacheDirectory, core: languageServerCore, processRegistry: processRegistry, @@ -78,7 +97,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime private let runtimeService: any LanguageToolRuntimePort private let languageServerCore: any LanguageServerRuntimeCore private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? - private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? + private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? private let languageServerCacheDirectory: URL? private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID @@ -87,7 +106,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime runtimeService: any LanguageToolRuntimePort, languageServerCore: any LanguageServerRuntimeCore, languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, + languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? = nil, languageServerCacheDirectory: URL? = nil, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence diff --git a/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift b/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift new file mode 100644 index 000000000..2057ec117 --- /dev/null +++ b/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Java language server runtime") +struct JavaLanguageServerRuntimeTests { + @Test + func parsesModernAndLegacyJavaVersions() { + #expect(javaRuntime("/jdk-17", "17.0.18").majorVersion == 17) + #expect(javaRuntime("/jdk-21", "21-ea").majorVersion == 21) + #expect(javaRuntime("/jdk-8", "1.8.0_442").majorVersion == 8) + #expect(javaRuntime("/jdk-unknown", "unknown").majorVersion == nil) + } + + @Test + func jdtlsCompatibilityRequiresJava17OrNewer() { + #expect(!javaRuntime("/jdk-11", "11.0.26").supportsJDTLS) + #expect(javaRuntime("/jdk-17", "17.0.18").supportsJDTLS) + #expect(javaRuntime("/jdk-21", "21.0.10").supportsJDTLS) + } + + @Test + @MainActor + func automaticJdtlsRuntimeIgnoresProjectRunJDK() { + let projectRuntime = javaRuntime("/project/jdk-11", "11.0.26") + let jdtlsRuntime = javaRuntime("/language-server/jdk-21", "21.0.10") + let service = ProjectRuntimeService( + runtimeLocator: JavaLanguageServerTestRuntimeLocator( + runtimes: [projectRuntime, jdtlsRuntime] + ), + store: JavaLanguageServerTestStore() + ) + + #expect( + service.javaHomeURL(overridePath: projectRuntime.homePath)?.path + == projectRuntime.homePath + ) + #expect( + service.javaLanguageServerExecutableURL()?.path + == "/language-server/jdk-21/bin/java" + ) + } + + @Test + @MainActor + func manualJdtlsRuntimeRejectsOldJavaAndAcceptsJava17() { + let oldRuntime = javaRuntime("/jdk-11", "11.0.26") + let supportedRuntime = javaRuntime("/jdk-17", "17.0.18") + let service = ProjectRuntimeService( + runtimeLocator: JavaLanguageServerTestRuntimeLocator( + runtimes: [supportedRuntime, oldRuntime] + ), + store: JavaLanguageServerTestStore() + ) + + #expect(service.inspectJavaLanguageServerRuntime(atPath: oldRuntime.homePath) == oldRuntime) + #expect(service.javaLanguageServerExecutableURL(overridePath: oldRuntime.homePath) == nil) + #expect( + service.javaLanguageServerExecutableURL(overridePath: supportedRuntime.homePath)?.path + == "/jdk-17/bin/java" + ) + } + + private func javaRuntime(_ homePath: String, _ version: String) -> JavaRuntimeCandidate { + JavaRuntimeCandidate(homePath: homePath, version: version, vendor: "Test JDK") + } +} + +private struct JavaLanguageServerTestRuntimeLocator: RuntimeLocator { + let runtimes: [JavaRuntimeCandidate] + + func environment() -> [String: String] { [:] } + + func discover() -> RuntimeDiscoveryResult { + RuntimeDiscoveryResult(javaRuntimes: runtimes, mavenRuntimes: []) + } + + func validJavaHome(path: String) -> URL? { + runtimes.contains(where: { $0.homePath == path }) + ? URL(fileURLWithPath: path, isDirectory: true) + : nil + } + + func javaRuntime(at homeURL: URL) -> JavaRuntimeCandidate? { + runtimes.first(where: { $0.homePath == homeURL.standardizedFileURL.path }) + } + + func isExecutable(at url: URL) -> Bool { false } + func systemMavenExecutable() -> URL? { nil } + func mavenExecutable(forHomePath path: String) -> URL? { nil } + func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil } + func systemJDBExecutable() -> URL? { nil } + func javaLanguageServerExecutable() -> URL? { nil } +} + +private struct JavaLanguageServerTestStore: KeyValueStore { + func data(forKey key: String) -> Data? { nil } + func object(forKey key: String) -> Any? { nil } + func string(forKey key: String) -> String? { nil } + func stringArray(forKey key: String) -> [String]? { nil } + func set(_ value: Any?, forKey key: String) {} + func removeObject(forKey key: String) {} +} diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 4784ee5fc..613b8036e 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1155,6 +1155,35 @@ struct RunConfigurationIntegrationTests { #expect(manager.activeLanguageServerIDs.isEmpty) } + @Test + func languageToolingRejectsUnavailableRequiredRuntime() { + let descriptor = LanguageProviderDescriptor( + id: "java", + displayName: "Java", + fileExtensions: ["java"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageIdentifier: "java", + languageServerLaunch: LanguageServerLaunchDescriptor(executableNames: ["jdtls"]) + ) + let runtimeService = ProjectRuntimeService( + runtimeLocator: RunTestRuntimeLocator(), + store: RunTestKeyValueStore() + ) + let message = "JDTLS requires JDK 17 or newer." + let runtime = StdioLanguageProviderRuntime( + descriptor: descriptor, + runtimeService: runtimeService, + languageServerLaunch: descriptor.languageServerLaunch, + languageServerCore: TestLanguageServerRuntimeCore(providerID: "java"), + languageServerExecutableResolver: { _ in URL(fileURLWithPath: "/usr/bin/jdtls") }, + languageServerRuntimeResolver: { _ in .unavailable(message) } + ) + + #expect(runtime.makeLanguageServerSession() == nil) + #expect(runtime.unavailableToolingMessage == message) + } + @Test func languageServerFailureClearsActiveSessionState() async throws { let descriptor = LanguageProviderDescriptor( diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 707370cbd..6c7da10c0 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -127,6 +127,8 @@ macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE_, + java_home: PathBuf, +} + +/// Validates a user-selected JDK home before it is stored for JDTLS. +#[tauri::command] +pub fn lsp_validate_java_home(java_home_path: String) -> Result { + validate_configured_java_home(&java_home_path) } /// Resolves the built-in Java language-server executable, JDK, and cache directory. @@ -53,7 +60,6 @@ pub fn lsp_resolve_java_launch( std::env::var_os("PATH").as_deref(), bundled_root.as_deref(), &jdtls_search_roots(project_root), - project_root, java_home_path.as_deref(), )?; @@ -62,15 +68,12 @@ pub fn lsp_resolve_java_launch( language_id: JAVA_PROVIDER_ID.to_string(), executable_path: normalize_path(&resolution.executable), arguments: Vec::new(), - runtime_executable_path: resolution - .java_home - .as_deref() - .and_then(run::java_executable) + runtime_executable_path: run::java_executable(&resolution.java_home) .as_deref() .map(normalize_path), cache_directory: normalize_path(&language_server_cache_directory(&app)), environment: JavaLspEnvironment { - java_home: resolution.java_home.as_deref().map(normalize_path), + java_home: Some(normalize_path(&resolution.java_home)), }, }) } @@ -79,7 +82,6 @@ fn resolve_java_lsp_launch( path_env: Option<&OsStr>, bundled_root: Option<&Path>, extra_roots: &[PathBuf], - project_root: Option<&Path>, java_home_override: Option<&str>, ) -> Result { let executable = @@ -87,7 +89,7 @@ fn resolve_java_lsp_launch( "Could not find jdtls. Install Eclipse JDT Language Server and add it to PATH." .to_string() })?; - let java_home = resolve_java_home(project_root, java_home_override); + let java_home = resolve_java_home(java_home_override)?; Ok(JavaLspResolution { executable, java_home, @@ -173,24 +175,85 @@ fn bundled_jdtls_root(app: &AppHandle) -> Option { .map(|directory| directory.join("LanguageServers").join("jdtls")) } -fn resolve_java_home( - project_root: Option<&Path>, - java_home_override: Option<&str>, -) -> Option { +fn resolve_java_home(java_home_override: Option<&str>) -> Result { if let Some(configured) = java_home_override .map(str::trim) .filter(|value| !value.is_empty()) { - let path = PathBuf::from(configured); - if run::java_executable(&path).is_some() { - return Some(path); - } + return validate_configured_java_home(configured) + .map(|runtime| PathBuf::from(runtime.home_path)); } - run::discover_toolchains(project_root) - .java - .into_iter() - .next() + + select_compatible_java_runtime(run::discover_toolchains(None).java) .map(|runtime| PathBuf::from(runtime.home_path)) + .ok_or_else(|| { + "Could not find JDK 17 or newer for JDTLS. Install a compatible JDK or select one in Settings > Editor." + .to_string() + }) +} + +fn validate_configured_java_home(configured: &str) -> Result { + let configured = configured.trim(); + if configured.is_empty() { + return Err("Select a JDK home directory for JDTLS.".to_string()); + } + + let path = PathBuf::from(configured); + if !path.is_dir() { + return Err(format!( + "The selected JDTLS JDK home is not a directory: {configured}" + )); + } + + let runtime = run::probe_java_home(&path).ok_or_else(|| { + format!("The selected JDTLS JDK home does not contain a working Java runtime: {configured}") + })?; + ensure_supported_java_runtime(runtime) +} + +fn ensure_supported_java_runtime(runtime: run::JavaRuntime) -> Result { + let major_version = java_major_version(&runtime.version).ok_or_else(|| { + format!( + "Could not determine the Java version for the selected JDTLS JDK: {}", + runtime.home_path + ) + })?; + if major_version < MIN_JDTLS_JAVA_MAJOR_VERSION { + return Err(format!( + "JDTLS requires JDK 17 or newer; the selected JDK is version {}.", + runtime.version + )); + } + Ok(runtime) +} + +fn select_compatible_java_runtime(runtimes: Vec) -> Option { + runtimes + .into_iter() + .filter(|runtime| { + java_major_version(&runtime.version) + .is_some_and(|major| major >= MIN_JDTLS_JAVA_MAJOR_VERSION) + }) + .max_by(|left, right| { + java_major_version(&left.version) + .cmp(&java_major_version(&right.version)) + .then_with(|| left.version.cmp(&right.version)) + .then_with(|| right.home_path.cmp(&left.home_path)) + }) +} + +fn java_major_version(version: &str) -> Option { + let components = version + .split(|character: char| !character.is_ascii_digit()) + .filter(|component| !component.is_empty()) + .filter_map(|component| component.parse::().ok()) + .collect::>(); + + match components.as_slice() { + [1, legacy_major, ..] => Some(*legacy_major), + [major, ..] => Some(*major), + [] => None, + } } fn language_server_cache_directory(app: &AppHandle) -> PathBuf { @@ -278,9 +341,45 @@ mod tests { fn reports_a_stable_error_when_jdtls_is_missing() { let missing = temp_dir().join("empty-jdtls-root"); fs::create_dir_all(&missing).expect("missing root"); - let error = - resolve_java_lsp_launch(None, None, &[missing.clone()], None, None).unwrap_err(); + let error = resolve_java_lsp_launch(None, None, &[missing.clone()], None).unwrap_err(); assert!(error.contains("jdtls"), "{error}"); fs::remove_dir_all(missing).ok(); } + + #[test] + fn parses_modern_and_legacy_java_major_versions() { + assert_eq!(java_major_version("17.0.18"), Some(17)); + assert_eq!(java_major_version("21-ea"), Some(21)); + assert_eq!(java_major_version("1.8.0_442"), Some(8)); + assert_eq!(java_major_version("unknown"), None); + } + + #[test] + fn automatic_jdtls_runtime_ignores_old_jdks_and_prefers_the_newest() { + let selected = select_compatible_java_runtime(vec![ + java_runtime("C:/jdk-11", "11.0.26"), + java_runtime("C:/jdk-17", "17.0.18"), + java_runtime("C:/jdk-21", "21.0.10"), + ]) + .expect("compatible runtime"); + + assert_eq!(selected.home_path, "C:/jdk-21"); + } + + #[test] + fn configured_jdtls_runtime_rejects_java_older_than_17() { + let error = + ensure_supported_java_runtime(java_runtime("C:/jdk-11", "11.0.26")).unwrap_err(); + + assert!(error.contains("JDK 17 or newer"), "{error}"); + assert!(error.contains("11.0.26"), "{error}"); + } + + fn java_runtime(home_path: &str, version: &str) -> run::JavaRuntime { + run::JavaRuntime { + home_path: home_path.to_string(), + version: version.to_string(), + vendor: "Test JDK".to_string(), + } + } } diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 9819f9f54..22b9c3f28 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -94,6 +94,7 @@ fn main() { host::clipboard_clear, host::create_app_window, lsp::lsp_resolve_java_launch, + lsp::lsp_validate_java_home, run::run_list_java_sources, run::run_write_generated, run::run_write_document, diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index d4cc07b0d..c15aab7ff 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -484,7 +484,7 @@ fn maven_executable_candidates(project_root: Option<&Path>) -> Vec { executables } -fn probe_java_home(home: &Path) -> Option { +pub(crate) fn probe_java_home(home: &Path) -> Option { let java = java_executable(home)?; let output = command_output(&java, &["-version"]); let version = java_version(&output)?; diff --git a/windows/tauri/src-tauri/tauri.conf.json b/windows/tauri/src-tauri/tauri.conf.json index 7f00b38a8..78c28bd64 100644 --- a/windows/tauri/src-tauri/tauri.conf.json +++ b/windows/tauri/src-tauri/tauri.conf.json @@ -22,8 +22,7 @@ "active": true, "targets": ["nsis", "msi"], "resources": { - "../src/extensions/bundled/**/*": "extensions/bundled/", - "../../../.artifacts/jdtls": "LanguageServers/jdtls" + "../src/extensions/bundled/**/*": "extensions/bundled/" }, "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico"] }, diff --git a/windows/tauri/src-tauri/tauri.jdtls.conf.json b/windows/tauri/src-tauri/tauri.jdtls.conf.json new file mode 100644 index 000000000..a902ed33d --- /dev/null +++ b/windows/tauri/src-tauri/tauri.jdtls.conf.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "resources": { + "../../../.artifacts/jdtls": "LanguageServers/jdtls" + } + } +} diff --git a/windows/tauri/src/features/editor/lsp/java-lsp-host-api.ts b/windows/tauri/src/features/editor/lsp/java-lsp-host-api.ts index 112f78513..b9f52afc9 100644 --- a/windows/tauri/src/features/editor/lsp/java-lsp-host-api.ts +++ b/windows/tauri/src/features/editor/lsp/java-lsp-host-api.ts @@ -12,9 +12,19 @@ export interface JavaLspLaunch { }; } +export interface JavaRuntime { + homePath: string; + version: string; + vendor: string; +} + export function resolveJavaLspLaunch(workspacePath: string, javaHomePath?: string) { return invoke("lsp_resolve_java_launch", { workspacePath, javaHomePath: javaHomePath ?? null, }); } + +export function validateJavaLspJavaHome(javaHomePath: string) { + return invoke("lsp_validate_java_home", { javaHomePath }); +} diff --git a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts index 406ec5fe4..e90e7872c 100644 --- a/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts +++ b/windows/tauri/src/features/editor/lsp/resolve-editor-lsp-launch.ts @@ -1,6 +1,7 @@ import type { BackendLanguageToolConfigSet } from "@/extensions/registry/extension-store-runtime"; import { isJavaSourcePath, JAVA_LANGUAGE_ID, JAVA_PROVIDER_ID } from "./built-in-language-support"; import { resolveJavaLspLaunch } from "./java-lsp-host-api"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; export interface EditorLspLaunch { providerId: string; @@ -19,7 +20,8 @@ export async function resolveEditorLspLaunch( workspacePath: string, ): Promise { if (isJavaSourcePath(filePath)) { - const launch = await resolveJavaLspLaunch(workspacePath); + const javaHomePath = useSettingsStore.getState().settings.jdtlsJavaHomePath.trim(); + const launch = await resolveJavaLspLaunch(workspacePath, javaHomePath || undefined); const environment: Record = {}; if (launch.environment.JAVA_HOME) { environment.JAVA_HOME = launch.environment.JAVA_HOME; diff --git a/windows/tauri/src/features/settings/components/tabs/editor-settings.tsx b/windows/tauri/src/features/settings/components/tabs/editor-settings.tsx index e73153342..28d25a9f8 100644 --- a/windows/tauri/src/features/settings/components/tabs/editor-settings.tsx +++ b/windows/tauri/src/features/settings/components/tabs/editor-settings.tsx @@ -1,7 +1,14 @@ -import { useMemo } from "react"; +import { useMemo, useRef, useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; import { useShallow } from "zustand/react/shallow"; import { getAllLanguages } from "@/features/editor/utils/language-id"; +import { validateJavaLspJavaHome } from "@/features/editor/lsp/java-lsp-host-api"; import { getDefaultSetting, useSettingsStore } from "@/features/settings/stores/settings.store"; +import { useToast } from "@/features/layout/contexts/toast-context"; +import { Button } from "@/ui/button"; +import { ButtonGroup } from "@/ui/button-group"; +import Input from "@/ui/input"; +import { FolderIcon } from "@/ui/icons"; import NumberInput from "@/ui/number-input"; import Section, { SETTINGS_CONTROL_WIDTHS, SettingsView, SettingRow } from "../settings-section"; import Select from "@/ui/select"; @@ -11,6 +18,9 @@ import { useTranslation } from "@/i18n/locale-provider"; export const EditorSettings = () => { const { t } = useTranslation(); + const { showToast } = useToast(); + const [isValidatingJdtlsJdk, setIsValidatingJdtlsJdk] = useState(false); + const jdtlsJdkRequest = useRef(0); const settings = useSettingsStore( useShallow((state) => ({ autoCompletion: state.settings.autoCompletion, @@ -34,6 +44,7 @@ export const EditorSettings = () => { horizontalTabScroll: state.settings.horizontalTabScroll, codeLens: state.settings.codeLens, inlayHints: state.settings.inlayHints, + jdtlsJavaHomePath: state.settings.jdtlsJavaHomePath, lineNumbers: state.settings.lineNumbers, lintOnSave: state.settings.lintOnSave, maxOpenTabs: state.settings.maxOpenTabs, @@ -64,6 +75,60 @@ export const EditorSettings = () => { { value: "trailing", label: t("settings.editor.whitespaceTrailing") }, { value: "all", label: t("settings.editor.whitespaceAll") }, ]; + const usesAutomaticJdtlsJdk = settings.jdtlsJavaHomePath.length === 0; + + const applyJdtlsJavaHomePath = async (javaHomePath: string) => { + if (settings.jdtlsJavaHomePath === javaHomePath) return; + + await updateSetting("jdtlsJavaHomePath", javaHomePath); + try { + const { LspClient } = await import("@/features/editor/lsp/lsp-client"); + const client = LspClient.getInstance(); + const javaServers = client + .getActiveServerEntries() + .filter((entry) => entry.languageId === "java"); + await Promise.all(javaServers.map((entry) => client.restartTrackedServer(entry.key))); + } catch (error) { + console.error("Failed to restart Java language server after JDK change:", error); + } + }; + + const useAutomaticJdtlsJdk = () => { + jdtlsJdkRequest.current += 1; + setIsValidatingJdtlsJdk(false); + void applyJdtlsJavaHomePath(""); + }; + + const chooseJdtlsJdk = async () => { + const request = ++jdtlsJdkRequest.current; + setIsValidatingJdtlsJdk(true); + + try { + const selected = await open({ directory: true, multiple: false }); + if (request !== jdtlsJdkRequest.current || typeof selected !== "string" || !selected) return; + + const runtime = await validateJavaLspJavaHome(selected); + if (request !== jdtlsJdkRequest.current) return; + + await applyJdtlsJavaHomePath(runtime.homePath); + if (request !== jdtlsJdkRequest.current) return; + + showToast({ + message: t("settings.editor.jdtlsJdkSelected", { version: runtime.version }), + type: "success", + }); + } catch (error) { + if (request !== jdtlsJdkRequest.current) return; + + showToast({ + message: t("settings.editor.jdtlsJdkInvalid", { error: String(error) }), + type: "error", + }); + } finally { + if (request === jdtlsJdkRequest.current) setIsValidatingJdtlsJdk(false); + } + }; + return (
@@ -565,6 +630,65 @@ export const EditorSettings = () => { />
+
+ +
+ + + + + {!usesAutomaticJdtlsJdk ? ( +
+ + +
+ ) : null} +
+
+
); }; diff --git a/windows/tauri/src/features/settings/config/default-settings.ts b/windows/tauri/src/features/settings/config/default-settings.ts index 9583138cf..356f7ba4a 100644 --- a/windows/tauri/src/features/settings/config/default-settings.ts +++ b/windows/tauri/src/features/settings/config/default-settings.ts @@ -127,6 +127,7 @@ export const defaultSettings: Settings = { lintOnSave: false, autoCompletion: true, parameterHints: true, + jdtlsJavaHomePath: "", // External Editor externalEditor: "none", customEditorCommand: "", diff --git a/windows/tauri/src/features/settings/config/search-index.ts b/windows/tauri/src/features/settings/config/search-index.ts index 80077cfb2..cacc7032e 100644 --- a/windows/tauri/src/features/settings/config/search-index.ts +++ b/windows/tauri/src/features/settings/config/search-index.ts @@ -865,6 +865,14 @@ export const settingsSearchIndex: SettingSearchRecord[] = [ description: "Use language server semantic highlighting", keywords: ["semantic", "tokens", "highlighting", "lsp"], }, + { + id: "language-jdtls-jdk", + tab: "editor", + section: "Java Language Server", + label: "JDTLS Runtime JDK", + description: "Choose automatic JDK 17+ discovery or a separate JDK home for JDTLS", + keywords: ["java", "jdtls", "jdk", "language", "server", "runtime", "automatic"], + }, // Features Settings { diff --git a/windows/tauri/src/features/settings/lib/settings-normalization.test.ts b/windows/tauri/src/features/settings/lib/settings-normalization.test.ts new file mode 100644 index 000000000..9bf0590dc --- /dev/null +++ b/windows/tauri/src/features/settings/lib/settings-normalization.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { getDefaultSettingsSnapshot } from "@/features/settings/config/default-settings"; +import { + normalizeSettings, + normalizeSettingValue, +} from "@/features/settings/lib/settings-normalization"; + +describe("JDTLS JDK setting normalization", () => { + test("defaults to automatic discovery", () => { + expect(getDefaultSettingsSnapshot().jdtlsJavaHomePath).toBe(""); + }); + + test("trims a manually selected JDK home", () => { + expect(normalizeSettingValue("jdtlsJavaHomePath", " C:/Java/jdk-21 ")).toBe( + "C:/Java/jdk-21", + ); + }); + + test("discards a persisted non-string JDK home", () => { + const settings = getDefaultSettingsSnapshot(); + (settings as unknown as { jdtlsJavaHomePath: unknown }).jdtlsJavaHomePath = 21; + + expect(normalizeSettings(settings).jdtlsJavaHomePath).toBe(""); + }); +}); diff --git a/windows/tauri/src/features/settings/lib/settings-normalization.ts b/windows/tauri/src/features/settings/lib/settings-normalization.ts index 2f471c54b..767c0b74e 100644 --- a/windows/tauri/src/features/settings/lib/settings-normalization.ts +++ b/windows/tauri/src/features/settings/lib/settings-normalization.ts @@ -524,6 +524,10 @@ export function normalizeSettings(settings: Settings): Settings { normalizedSettings.lastSettingsTab = normalizeSettingsSection( (normalizedSettings as { lastSettingsTab?: unknown }).lastSettingsTab, ); + normalizedSettings.jdtlsJavaHomePath = + typeof normalizedSettings.jdtlsJavaHomePath === "string" + ? normalizedSettings.jdtlsJavaHomePath.trim() + : ""; if (!isKeybindingPreset(normalizedSettings.keybindingPreset)) { normalizedSettings.keybindingPreset = "none"; @@ -663,6 +667,10 @@ export function normalizeSettingValue( return ((value as string)?.trim() || "") as Settings[K]; } + if (key === "jdtlsJavaHomePath") { + return (value as string).trim() as Settings[K]; + } + if (key === "aiCustomBaseUrl") { return normalizeBaseUrl(value as string) as Settings[K]; } diff --git a/windows/tauri/src/features/settings/types/settings.types.ts b/windows/tauri/src/features/settings/types/settings.types.ts index fa8f5a08d..13e1e4dc5 100644 --- a/windows/tauri/src/features/settings/types/settings.types.ts +++ b/windows/tauri/src/features/settings/types/settings.types.ts @@ -153,6 +153,8 @@ export interface Settings { lintOnSave: boolean; autoCompletion: boolean; parameterHints: boolean; + /** Empty uses automatic JDK 17+ discovery for JDTLS. */ + jdtlsJavaHomePath: string; // External Editor externalEditor: "none" | "nvim" | "helix" | "vim" | "custom"; customEditorCommand: string; diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 69013613f..93b36a008 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -418,6 +418,19 @@ const catalogs = { "settings.editor.symbolBreadcrumb": "Show Symbol in Breadcrumb", "settings.editor.symbolBreadcrumbDescription": "Show the containing function/class for the cursor position in the breadcrumb bar", + "settings.editor.jdtlsSection": "Java Language Server", + "settings.editor.jdtlsSectionDescription": + "Configure the JDK used only to run the bundled Java language server.", + "settings.editor.jdtlsJdk": "JDTLS Runtime JDK", + "settings.editor.jdtlsJdkDescription": + "Automatically detect JDK 17 or newer, or select a separate JDK without changing project run settings.", + "settings.editor.jdtlsJdkMode": "JDTLS JDK selection mode", + "settings.editor.jdtlsJdkAutomatic": "Automatic", + "settings.editor.jdtlsJdkManual": "Manual", + "settings.editor.jdtlsJdkPath": "Selected JDTLS JDK home", + "settings.editor.jdtlsJdkChoose": "Choose JDTLS JDK home", + "settings.editor.jdtlsJdkSelected": "Using JDK {version} for JDTLS", + "settings.editor.jdtlsJdkInvalid": "Could not use this JDK for JDTLS: {error}", "settings.appearance.theme": "Theme", "settings.appearance.syncWithOs": "Sync With OS", "settings.appearance.syncWithOsDescription": @@ -1454,6 +1467,18 @@ const catalogs = { "settings.editor.semanticTokensDescription": "使用语言服务器的语义高亮", "settings.editor.symbolBreadcrumb": "在面包屑中显示符号", "settings.editor.symbolBreadcrumbDescription": "在面包屑栏中显示光标位置所在的函数或类", + "settings.editor.jdtlsSection": "Java 语言服务器", + "settings.editor.jdtlsSectionDescription": "配置仅用于运行内置 Java 语言服务器的 JDK。", + "settings.editor.jdtlsJdk": "JDTLS 运行 JDK", + "settings.editor.jdtlsJdkDescription": + "自动检测 JDK 17 或更高版本,或选择独立 JDK;不会改变项目运行设置。", + "settings.editor.jdtlsJdkMode": "JDTLS JDK 选择模式", + "settings.editor.jdtlsJdkAutomatic": "自动", + "settings.editor.jdtlsJdkManual": "手动", + "settings.editor.jdtlsJdkPath": "已选择的 JDTLS JDK Home", + "settings.editor.jdtlsJdkChoose": "选择 JDTLS JDK Home", + "settings.editor.jdtlsJdkSelected": "JDTLS 将使用 JDK {version}", + "settings.editor.jdtlsJdkInvalid": "无法将此 JDK 用于 JDTLS:{error}", "settings.appearance.theme": "主题", "settings.appearance.syncWithOs": "与操作系统同步", "settings.appearance.syncWithOsDescription": "在偏好的浅色和深色主题之间自动切换", From 60adbd0f3ba3a1534c031ab2453df2b38a3fb220 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 19 Aug 2026 09:43:01 +0800 Subject: [PATCH 3/3] fix(macOS): move JDTLS runtime discovery off main actor --- .../AppModel+LanguageServerRuntime.swift | 108 ++++++++++++++++++ Sources/Lithe/Models/AppModel/AppModel.swift | 86 +------------- .../Services/Java/ProjectRuntimeService.swift | 63 ++++++++-- .../JavaLanguageServerRuntimeTests.swift | 58 +++++++++- 4 files changed, 219 insertions(+), 96 deletions(-) create mode 100644 Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift diff --git a/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift b/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift new file mode 100644 index 000000000..2e5022f07 --- /dev/null +++ b/Sources/Lithe/Models/AppModel/AppModel+LanguageServerRuntime.swift @@ -0,0 +1,108 @@ +import Foundation + +@MainActor +extension AppModel { + func chooseLanguageServerExecutable(providerName: String) -> URL? { + platformUI.chooseFile( + title: settings.language == .simplifiedChinese + ? "选择 \(providerName) 语言服务器" + : "Choose \(providerName) language server", + prompt: settings.language == .simplifiedChinese ? "选择" : "Choose" + ) + } + + func openLanguageServerDownload(_ url: URL) { + platformUI.open(url) + } + + func languageServerToolConfigurationDidChange(providerID: String) { + languageToolingFeature.toolConfigurationDidChange(providerID: providerID) + } + + func isLanguageServerDisabledInCurrentWorkspace(providerID: String) -> Bool { + languageToolingFeature.isDisabled(providerID) + } + + func setLanguageServerEnabled(_ enabled: Bool, providerID: String) { + if enabled { + languageToolingFeature.setEnabled(true, providerID: providerID) + } else { + languageToolingFeature.setEnabled(false, providerID: providerID) + } + } + + var javaLanguageServerJDKPath: String { + settings.javaLanguageServerJDKPath + } + + var detectedJavaLanguageServerJDKs: [JavaRuntimeCandidate] { + runtimeFeature.javaLanguageServerRuntimes + } + + func selectJavaLanguageServerJDK(_ runtime: JavaRuntimeCandidate) { + applyJavaLanguageServerJDKPath(runtime.homePath) + } + + func refreshJavaLanguageServerJDKs() async { + await runtimeFeature.refreshAvailableRuntimes() + } + + func useAutomaticJavaLanguageServerJDK() { + applyJavaLanguageServerJDKPath("") + } + + func chooseJavaLanguageServerJDK() { + guard let url = platformUI.chooseDirectory( + title: settings.language == .simplifiedChinese ? "选择 LSP 运行 JDK" : "Choose LSP Runtime JDK", + prompt: settings.language == .simplifiedChinese ? "选择" : "Choose" + ) else { return } + Task { [weak self] in + guard let self else { return } + guard let runtime = await self.services.projectRuntimeService + .inspectJavaLanguageServerRuntime(atPath: url.path) else { + self.showNotification(self.settings.language == .simplifiedChinese + ? "所选目录不是有效的 JDK Home" + : "The selected directory is not a valid JDK Home") + return + } + guard runtime.supportsJDTLS else { + self.showNotification(self.settings.language == .simplifiedChinese + ? "JDTLS 需要 JDK 17 或更高版本;所选版本为 \(runtime.version)" + : "JDTLS requires JDK 17 or newer; the selected version is \(runtime.version)") + return + } + self.applyJavaLanguageServerJDKPath(url.standardizedFileURL.path) + } + } + + func disableLanguageServerForCurrentWorkspace(providerID: String) { + languageToolingFeature.setEnabled(false, providerID: providerID) + } + + func prepareJavaLanguageServerRuntimeIfNeeded(for document: EditorDocument) -> Bool { + let path = settings.javaLanguageServerJDKPath.trimmingCharacters(in: .whitespacesAndNewlines) + if services.projectRuntimeService.isJavaLanguageServerRuntimePrepared(overridePath: path) { + return true + } + guard javaLanguageServerRuntimePreparationPath != path else { return false } + + javaLanguageServerRuntimePreparationTask?.cancel() + javaLanguageServerRuntimePreparationPath = path + javaLanguageServerRuntimePreparationTask = Task { [weak self, weak document] in + guard let self, let document else { return } + await self.services.projectRuntimeService.prepareJavaLanguageServerRuntime( + overridePath: path + ) + guard !Task.isCancelled, + self.javaLanguageServerRuntimePreparationPath == path else { return } + self.javaLanguageServerRuntimePreparationTask = nil + self.javaLanguageServerRuntimePreparationPath = nil + _ = self.activateLanguageServerIfAvailable(for: document) + } + return false + } + + private func applyJavaLanguageServerJDKPath(_ path: String) { + languageToolingFeature.selectJavaJDK(path) + } +} diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift index 0c3d46b5e..fe268aabf 100644 --- a/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -280,91 +280,14 @@ final class AppModel: ObservableObject, Identifiable { isSettingsPresented = true } - func chooseLanguageServerExecutable(providerName: String) -> URL? { - platformUI.chooseFile( - title: settings.language == .simplifiedChinese - ? "选择 \(providerName) 语言服务器" - : "Choose \(providerName) language server", - prompt: settings.language == .simplifiedChinese ? "选择" : "Choose" - ) - } - - func openLanguageServerDownload(_ url: URL) { - platformUI.open(url) - } - - func languageServerToolConfigurationDidChange(providerID: String) { - languageToolingFeature.toolConfigurationDidChange(providerID: providerID) - } - - func isLanguageServerDisabledInCurrentWorkspace(providerID: String) -> Bool { - languageToolingFeature.isDisabled(providerID) - } - - func setLanguageServerEnabled(_ enabled: Bool, providerID: String) { - if enabled { - languageToolingFeature.setEnabled(true, providerID: providerID) - } else { - languageToolingFeature.setEnabled(false, providerID: providerID) - } - } - - var javaLanguageServerJDKPath: String { - settings.javaLanguageServerJDKPath - } - - var detectedJavaLanguageServerJDKs: [JavaRuntimeCandidate] { - runtimeFeature.javaLanguageServerRuntimes - } - - func selectJavaLanguageServerJDK(_ runtime: JavaRuntimeCandidate) { - applyJavaLanguageServerJDKPath(runtime.homePath) - } - - func refreshJavaLanguageServerJDKs() async { - await runtimeFeature.refreshAvailableRuntimes() - } - - func useAutomaticJavaLanguageServerJDK() { - applyJavaLanguageServerJDKPath("") - } - - func chooseJavaLanguageServerJDK() { - guard let url = platformUI.chooseDirectory( - title: settings.language == .simplifiedChinese ? "选择 LSP 运行 JDK" : "Choose LSP Runtime JDK", - prompt: settings.language == .simplifiedChinese ? "选择" : "Choose" - ) else { return } - guard let runtime = services.projectRuntimeService.inspectJavaLanguageServerRuntime( - atPath: url.path - ) else { - showNotification(settings.language == .simplifiedChinese - ? "所选目录不是有效的 JDK Home" - : "The selected directory is not a valid JDK Home") - return - } - guard runtime.supportsJDTLS else { - showNotification(settings.language == .simplifiedChinese - ? "JDTLS 需要 JDK 17 或更高版本;所选版本为 \(runtime.version)" - : "JDTLS requires JDK 17 or newer; the selected version is \(runtime.version)") - return - } - applyJavaLanguageServerJDKPath(url.standardizedFileURL.path) - } - - private func applyJavaLanguageServerJDKPath(_ path: String) { - languageToolingFeature.selectJavaJDK(path) - } - - func disableLanguageServerForCurrentWorkspace(providerID: String) { - languageToolingFeature.setEnabled(false, providerID: providerID) - } - private var documentFeatureObservation: AnyCancellable? private var javaFeatureObservation: AnyCancellable? private var springFeatureObservation: AnyCancellable? private var navigationHistoryFeatureObservation: AnyCancellable? private var isObjectWillChangeRelayScheduled = false private var languageToolingObservation: AnyCancellable? + var javaLanguageServerRuntimePreparationTask: Task? + var javaLanguageServerRuntimePreparationPath: String? private var recentProjectsStore: RecentProjectsStore { services.recentProjectsStore } private var workbenchLayoutStore: WorkbenchLayoutStore { services.workbenchLayoutStore } @@ -1353,9 +1276,12 @@ final class AppModel: ObservableObject, Identifiable { } @discardableResult - private func activateLanguageServerIfAvailable(for document: EditorDocument) -> Bool { + func activateLanguageServerIfAvailable(for document: EditorDocument) -> Bool { guard let workspaceURL, let descriptor = languageProviderCatalog.provider(for: document.url) else { return false } + if descriptor.id == "java", !prepareJavaLanguageServerRuntimeIfNeeded(for: document) { + return false + } if let ownership = services.pluginCatalog.languageSupport(for: document.url), ownership.declaration.languageServerModuleID != nil { let support = ownership.declaration diff --git a/Sources/Lithe/Services/Java/ProjectRuntimeService.swift b/Sources/Lithe/Services/Java/ProjectRuntimeService.swift index cda40908e..a0173b86d 100644 --- a/Sources/Lithe/Services/Java/ProjectRuntimeService.swift +++ b/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -30,6 +30,11 @@ extension ProjectRuntimeService: MavenRuntimePort { @MainActor final class ProjectRuntimeService: ObservableObject { + private struct JavaLanguageServerRuntimePreparation { + let configuration: String + let runtime: JavaRuntimeCandidate? + } + @Published private(set) var projectURL: URL? @Published private(set) var javaRuntimes: [JavaRuntimeCandidate] = [] @Published private(set) var mavenRuntimes: [MavenRuntimeCandidate] = [] @@ -41,6 +46,7 @@ final class ProjectRuntimeService: ObservableObject { private let toolDiscovery: any RuntimeToolDiscovery private var discoveryTask: Task? private var activeDiscoveryID: UUID? + private var javaLanguageServerRuntimePreparation: JavaLanguageServerRuntimePreparation? init( runtimeLocator: any RuntimeLocator, @@ -157,30 +163,63 @@ final class ProjectRuntimeService: ObservableObject { javaRuntimes.filter(\.supportsJDTLS) } - func inspectJavaLanguageServerRuntime(atPath path: String) -> JavaRuntimeCandidate? { + func inspectJavaLanguageServerRuntime(atPath path: String) async -> JavaRuntimeCandidate? { let normalized = normalizedPath(path.trimmingCharacters(in: .whitespacesAndNewlines)) - guard !normalized.isEmpty, - (normalized as NSString).isAbsolutePath, - let home = runtimeLocator.validJavaHome(path: normalized) else { return nil } - return runtimeLocator.javaRuntime(at: home) + guard !normalized.isEmpty, (normalized as NSString).isAbsolutePath else { return nil } + let runtimeLocator = runtimeLocator + return await Task.detached(priority: .utility) { + guard let home = runtimeLocator.validJavaHome(path: normalized) else { return nil } + return runtimeLocator.javaRuntime(at: home) + }.value } - func javaLanguageServerExecutableURL(overridePath: String? = nil) -> URL? { - let configured = overridePath? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + func isJavaLanguageServerRuntimePrepared(overridePath: String? = nil) -> Bool { + javaLanguageServerRuntimePreparation?.configuration + == normalizedJavaLanguageServerConfiguration(overridePath) + } + + // Session creation reads only this cache. Process-backed discovery and + // version probing must finish off the main actor before AppModel retries it. + func prepareJavaLanguageServerRuntime(overridePath: String? = nil) async { + let configuration = normalizedJavaLanguageServerConfiguration(overridePath) + guard javaLanguageServerRuntimePreparation?.configuration != configuration else { return } + let runtime: JavaRuntimeCandidate? - if configured.isEmpty { - runtime = runtimeLocator.discover().javaRuntimes.first(where: \.supportsJDTLS) + if configuration.isEmpty { + if let cached = javaLanguageServerRuntimes.first { + runtime = cached + } else { + let runtimeLocator = runtimeLocator + runtime = await Task.detached(priority: .utility) { + runtimeLocator.discover().javaRuntimes.first(where: \.supportsJDTLS) + }.value + } } else { - runtime = inspectJavaLanguageServerRuntime(atPath: configured) + runtime = await inspectJavaLanguageServerRuntime(atPath: configuration) } - guard let runtime, + guard !Task.isCancelled else { return } + javaLanguageServerRuntimePreparation = JavaLanguageServerRuntimePreparation( + configuration: configuration, + runtime: runtime + ) + } + + func javaLanguageServerExecutableURL(overridePath: String? = nil) -> URL? { + let configuration = normalizedJavaLanguageServerConfiguration(overridePath) + guard let preparation = javaLanguageServerRuntimePreparation, + preparation.configuration == configuration, + let runtime = preparation.runtime, runtime.supportsJDTLS, let home = runtimeLocator.validJavaHome(path: runtime.homePath) else { return nil } return home.appendingPathComponent("bin/java") } + private func normalizedJavaLanguageServerConfiguration(_ overridePath: String?) -> String { + let configuration = overridePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return configuration.isEmpty ? "" : normalizedPath(configuration) + } + func jdbExecutableURL( overridePath: String? = nil, for processKind: ProjectRuntimeProcessKind = .java diff --git a/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift b/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift index 2057ec117..043321d99 100644 --- a/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift +++ b/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift @@ -21,7 +21,7 @@ struct JavaLanguageServerRuntimeTests { @Test @MainActor - func automaticJdtlsRuntimeIgnoresProjectRunJDK() { + func automaticJdtlsRuntimeIgnoresProjectRunJDK() async { let projectRuntime = javaRuntime("/project/jdk-11", "11.0.26") let jdtlsRuntime = javaRuntime("/language-server/jdk-21", "21.0.10") let service = ProjectRuntimeService( @@ -35,6 +35,8 @@ struct JavaLanguageServerRuntimeTests { service.javaHomeURL(overridePath: projectRuntime.homePath)?.path == projectRuntime.homePath ) + #expect(service.javaLanguageServerExecutableURL() == nil) + await service.prepareJavaLanguageServerRuntime() #expect( service.javaLanguageServerExecutableURL()?.path == "/language-server/jdk-21/bin/java" @@ -43,7 +45,7 @@ struct JavaLanguageServerRuntimeTests { @Test @MainActor - func manualJdtlsRuntimeRejectsOldJavaAndAcceptsJava17() { + func manualJdtlsRuntimeRejectsOldJavaAndAcceptsJava17() async { let oldRuntime = javaRuntime("/jdk-11", "11.0.26") let supportedRuntime = javaRuntime("/jdk-17", "17.0.18") let service = ProjectRuntimeService( @@ -53,14 +55,35 @@ struct JavaLanguageServerRuntimeTests { store: JavaLanguageServerTestStore() ) - #expect(service.inspectJavaLanguageServerRuntime(atPath: oldRuntime.homePath) == oldRuntime) + #expect(await service.inspectJavaLanguageServerRuntime(atPath: oldRuntime.homePath) == oldRuntime) + await service.prepareJavaLanguageServerRuntime(overridePath: oldRuntime.homePath) #expect(service.javaLanguageServerExecutableURL(overridePath: oldRuntime.homePath) == nil) + await service.prepareJavaLanguageServerRuntime(overridePath: supportedRuntime.homePath) #expect( service.javaLanguageServerExecutableURL(overridePath: supportedRuntime.homePath)?.path == "/jdk-17/bin/java" ) } + @Test + @MainActor + func automaticJdtlsRuntimeDiscoveryRunsOffTheMainThread() async { + let recorder = JavaLanguageServerDiscoveryThreadRecorder() + let runtime = javaRuntime("/jdk-21", "21.0.10") + let service = ProjectRuntimeService( + runtimeLocator: JavaLanguageServerTestRuntimeLocator( + runtimes: [runtime], + threadRecorder: recorder + ), + store: JavaLanguageServerTestStore() + ) + + await service.prepareJavaLanguageServerRuntime() + + #expect(recorder.discoveryWasOnMainThread == false) + #expect(service.javaLanguageServerExecutableURL()?.path == "/jdk-21/bin/java") + } + private func javaRuntime(_ homePath: String, _ version: String) -> JavaRuntimeCandidate { JavaRuntimeCandidate(homePath: homePath, version: version, vendor: "Test JDK") } @@ -68,11 +91,21 @@ struct JavaLanguageServerRuntimeTests { private struct JavaLanguageServerTestRuntimeLocator: RuntimeLocator { let runtimes: [JavaRuntimeCandidate] + var threadRecorder: JavaLanguageServerDiscoveryThreadRecorder? + + init( + runtimes: [JavaRuntimeCandidate], + threadRecorder: JavaLanguageServerDiscoveryThreadRecorder? = nil + ) { + self.runtimes = runtimes + self.threadRecorder = threadRecorder + } func environment() -> [String: String] { [:] } func discover() -> RuntimeDiscoveryResult { - RuntimeDiscoveryResult(javaRuntimes: runtimes, mavenRuntimes: []) + threadRecorder?.recordDiscoveryThread() + return RuntimeDiscoveryResult(javaRuntimes: runtimes, mavenRuntimes: []) } func validJavaHome(path: String) -> URL? { @@ -93,6 +126,23 @@ private struct JavaLanguageServerTestRuntimeLocator: RuntimeLocator { func javaLanguageServerExecutable() -> URL? { nil } } +private final class JavaLanguageServerDiscoveryThreadRecorder: @unchecked Sendable { + private let lock = NSLock() + private var discoveryWasOnMainThreadValue: Bool? + + var discoveryWasOnMainThread: Bool? { + lock.lock() + defer { lock.unlock() } + return discoveryWasOnMainThreadValue + } + + func recordDiscoveryThread() { + lock.lock() + discoveryWasOnMainThreadValue = Thread.isMainThread + lock.unlock() + } +} + private struct JavaLanguageServerTestStore: KeyValueStore { func data(forKey key: String) -> Data? { nil } func object(forKey key: String) -> Any? { nil }