diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index dae34d238..5b625077d 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -227,7 +227,7 @@ jobs: ruby scripts/test-macos-update-manifest.rb plutil -lint macos/Resources/Info.plist - - name: Build and verify host architecture package + - name: Build and verify default universal package run: ./scripts/verify-macos-package.sh gate: diff --git a/Plugins/mac/Official/GoSupport/Tests/LitheGoSupportModuleTests/GoSupportModuleTests.swift b/Plugins/mac/Official/GoSupport/Tests/LitheGoSupportModuleTests/GoSupportModuleTests.swift index 45eec8c74..9fe2ab08d 100644 --- a/Plugins/mac/Official/GoSupport/Tests/LitheGoSupportModuleTests/GoSupportModuleTests.swift +++ b/Plugins/mac/Official/GoSupport/Tests/LitheGoSupportModuleTests/GoSupportModuleTests.swift @@ -480,6 +480,7 @@ private final class GoTestLanguageServerRuntimeCore: LanguageServerRuntimeCore, workingDirectoryURL _: URL, initializationOptions _: ToolingJSONValue?, runtimeExecutableURL _: URL?, + jdtlsLaunchResources _: JDTLSLaunchResources?, cacheDirectoryURL _: URL?, workspaceFingerprint _: String?, initializeTimeout _: TimeInterval, diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md index 49d22ec9b..6dd75b607 100644 --- a/docs/architecture/language-tooling.md +++ b/docs/architecture/language-tooling.md @@ -127,7 +127,7 @@ 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 和只供语言服务使用的 Temurin JDK 21。发布构建根据 `third_party/jdtls/manifest.json` 下载固定版本,同时校验 JDTLS 归档、EPL-2.0 许可证、Lombok agent 与 MIT 许可证的 SHA-256,再将产物放入应用资源目录的 `LanguageServers/jdtls`。包内启动器通过相对路径加载 Lombok `-javaagent`,确保 JDTLS 能解析注解生成的成员;agent 缺失时启动器会明确失败,而不会产生静默的错误诊断。平台 adapter 优先使用这个包内启动器;开发环境仍保留外部 JDTLS 候选作为构建调试回退,但运行 JDTLS 的 Java runtime 始终只能是内置 JDK 21。下载只发生在构建阶段,应用运行时不会联网安装 JDTLS、Lombok 或 JDK。 +正式 macOS 与 Windows 安装包包含 JDTLS 和只供语言服务使用的 Temurin JDK 21。发布构建根据 `third_party/jdtls/manifest.json` 下载固定版本,同时校验 JDTLS 归档、EPL-2.0 许可证、Lombok agent 与 MIT 许可证的 SHA-256,再将产物放入应用资源目录的 `LanguageServers/jdtls`。平台 adapter 从选中的安装目录稳定解析 Equinox launcher JAR、当前平台的 configuration 目录和 `lombok/lombok.jar`,以结构化 `jdtlsLaunchResources` 提交给 Rust Core;Core 统一构造 `-javaagent`、内存、module/open、Eclipse product/application、`-jar`、`-configuration` 和 workspace `-data` 参数,并直接启动包内 `java`/`java.exe`。因此正式包运行 JDTLS 不依赖 shell、PowerShell 或用户 `PATH`。资源缺失会在进程启动前作为打包故障明确失败,不会产生静默的错误诊断。生成的 `bin/jdtls`、`jdtls.bat` 和 `jdtls.ps1` 只为外部/旧启动计划保留兼容回退;运行 JDTLS 的 Java runtime 始终只能是内置 JDK 21。下载只发生在构建阶段,应用运行时不会联网安装 JDTLS、Lombok 或 JDK。 准备脚本会把经过校验的 JDTLS manifest 一同复制到 `LanguageServers/jdtls/manifest.json`。平台启动 adapter 只观测 workspace 根部的 `pom.xml`、`build.gradle`、`build.gradle.kts` 元数据、直接包含 `pom.xml` 的模块目录和实际选中 JDTLS 的版本;`java.jdtWorkspaceFingerprint` 在 Rust Core 内统一校验、排序、去重并生成非递归结构指纹,平台不得拼接或解析该字符串。Rust Core 将指纹和标准化 workspace 路径共同哈希为 `-data` 目录键;结构变化只会选择新目录,不影响当前会话。缺少指纹时仍使用历史的纯路径键,兼容旧客户端。两端在使用缓存时写入最近使用标记,再把目录元数据交给 `java.jdtCacheRetention`;Core 只返回超过 30 天且非当前活动项的键,平台复核后删除,Core 不执行文件系统操作。 @@ -155,8 +155,8 @@ timeout。Core、service 和 adapter 只返回稳定原因,面向用户的提 启动顺序: -1. Swift 完成可执行文件和环境发现,向 Rust 提交 typed `startServer`; -2. Rust engine 创建 session、启动进程并安装 stdout/stderr reader,再发送 `initialize`; +1. 平台完成可执行文件、运行时和 provider 专用资源发现,向 Rust 提交 typed `startServer`;JDTLS 包内计划必须包含 `jdtlsLaunchResources`; +2. Rust provider adapter 生成最终参数,engine 创建 session、直接启动目标进程并安装 stdout/stderr reader,再发送 `initialize`; 3. 收到响应后,Rust 保存服务器 capability,发送 `initialized` 和 provider adapter 通知;JDTLS 继续等待 `language/status: ServiceReady`,项目导入完成前仍是 `initializing`; 4. manager 只在真实 `ready` 后发布 capability;打开文档发送 `didOpen`,后续编辑优先按服务器协商结果发送增量 `didChange`; 5. Rust 以 LSP request ID 关联 deadline,并用不透明 operation ID 把 terminal result 投影给 Swift。 diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index dca7e4607..d624568f9 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1400,6 +1400,7 @@ struct RustCoreBridge: Sendable { let workingDirectory: String let initializationOptions: ToolingJSONValue? let runtimeExecutablePath: String? + let jdtlsLaunchResources: LspJdtlsLaunchResourcesRequest? let cacheDirectory: String? let workspaceFingerprint: String? let initializeTimeoutMilliseconds: Int @@ -1407,6 +1408,12 @@ struct RustCoreBridge: Sendable { let shutdownTimeoutMilliseconds: Int } + private struct LspJdtlsLaunchResourcesRequest: Encodable { + let launcherJarPath: String + let configurationDirectory: String + let lombokAgentPath: String + } + private struct LspSessionIdentifierRequest: Encodable { let sessionId: String } @@ -2929,6 +2936,7 @@ struct RustCoreBridge: Sendable { workingDirectoryURL: URL, initializationOptions: ToolingJSONValue? = nil, runtimeExecutableURL: URL? = nil, + jdtlsLaunchResources: JDTLSLaunchResources? = nil, cacheDirectoryURL: URL? = nil, workspaceFingerprint: String? = nil, initializeTimeout: TimeInterval = 60, @@ -2946,6 +2954,13 @@ struct RustCoreBridge: Sendable { workingDirectory: workingDirectoryURL.standardizedFileURL.path, initializationOptions: initializationOptions, runtimeExecutablePath: runtimeExecutableURL?.standardizedFileURL.path, + jdtlsLaunchResources: jdtlsLaunchResources.map { + LspJdtlsLaunchResourcesRequest( + launcherJarPath: $0.launcherJarURL.path, + configurationDirectory: $0.configurationDirectoryURL.path, + lombokAgentPath: $0.lombokAgentURL.path + ) + }, cacheDirectory: cacheDirectoryURL?.standardizedFileURL.path, workspaceFingerprint: workspaceFingerprint, initializeTimeoutMilliseconds: Self.milliseconds(initializeTimeout), diff --git a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift index 089846409..935f236bb 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -12,6 +12,7 @@ extension RustCoreBridge: LanguageServerRuntimeCore { workingDirectoryURL: URL, initializationOptions: ToolingJSONValue?, runtimeExecutableURL: URL?, + jdtlsLaunchResources: JDTLSLaunchResources?, cacheDirectoryURL: URL?, workspaceFingerprint: String?, initializeTimeout: TimeInterval, @@ -27,6 +28,7 @@ extension RustCoreBridge: LanguageServerRuntimeCore { workingDirectoryURL: workingDirectoryURL, initializationOptions: initializationOptions, runtimeExecutableURL: runtimeExecutableURL, + jdtlsLaunchResources: jdtlsLaunchResources, cacheDirectoryURL: cacheDirectoryURL, workspaceFingerprint: workspaceFingerprint, initializeTimeout: initializeTimeout, diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index a34677009..90c6ba81c 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -219,6 +219,7 @@ final class MacServiceContainer { core: rustCore, cacheDirectoryURL: languageServerCacheDirectory ) + let jdtlsLaunchResourceResolver = MacJDTLSLaunchResourceResolver() let runtimeFactory = StdioLanguageProviderRuntimeFactory( runtimeService: runtimeService, languageServerCore: rustCore, @@ -233,6 +234,17 @@ final class MacServiceContainer { } return .available(executableURL) }, + jdtlsLaunchResourcesResolver: { descriptor, executableURL in + guard descriptor.id == "java" else { return .notRequired } + switch jdtlsLaunchResourceResolver.resolve(for: executableURL) { + case .direct(let resources): + return .available(resources) + case .wrapperFallback: + return .notRequired + case .unavailable(let message): + return .unavailable(message) + } + }, languageServerCacheDirectory: languageServerCacheDirectory, processRegistry: processRegistry ) diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift new file mode 100644 index 000000000..d1fe54cce --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacJDTLSLaunchResourceResolver.swift @@ -0,0 +1,126 @@ +import Foundation +import LitheCoreContracts + +enum MacJDTLSLaunchResourceResolution { + case direct(JDTLSLaunchResources) + case wrapperFallback + case unavailable(String) +} + +struct MacJDTLSLaunchResourceResolver { + private static let equinoxLauncherPrefix = "org.eclipse.equinox.launcher_" + + private let bundledJdtlsRootURL: URL? + private let fileManager: FileManager + + init( + bundledJdtlsRootURL: URL? = Bundle.main.resourceURL? + .appendingPathComponent("LanguageServers/jdtls", isDirectory: true), + fileManager: FileManager = .default + ) { + self.bundledJdtlsRootURL = bundledJdtlsRootURL?.standardizedFileURL + self.fileManager = fileManager + } + + func resolve(for executableURL: URL) -> MacJDTLSLaunchResourceResolution { + do { + return .direct(try directLaunchResources(for: executableURL)) + } catch { + guard isBundled(executableURL) else { return .wrapperFallback } + return .unavailable( + "Bundled JDTLS direct-launch resources are incomplete. " + + "Reinstall Lithe. \(error.localizedDescription)" + ) + } + } + + private func directLaunchResources(for executableURL: URL) throws -> JDTLSLaunchResources { + for rootURL in installationRoots(for: executableURL) { + let pluginsURL = rootURL.appendingPathComponent("plugins", isDirectory: true) + let configurationURL = configurationDirectory(in: rootURL) + let lombokURL = rootURL.appendingPathComponent("lombok/lombok.jar") + guard let launcherURL = try firstEquinoxLauncher(in: pluginsURL), + let configurationURL, + fileManager.fileExists(atPath: lombokURL.path) else { + continue + } + return JDTLSLaunchResources( + launcherJarURL: launcherURL, + configurationDirectoryURL: configurationURL, + lombokAgentURL: lombokURL + ) + } + throw ResolutionError.incompleteInstallation + } + + private func installationRoots(for executableURL: URL) -> [URL] { + let executableURL = executableURL.standardizedFileURL + var roots = [installationRoot(for: executableURL)] + let resolvedURL = executableURL.resolvingSymlinksInPath().standardizedFileURL + let resolvedRoot = installationRoot(for: resolvedURL) + if !roots.contains(resolvedRoot) { roots.append(resolvedRoot) } + return roots + } + + private func installationRoot(for executableURL: URL) -> URL { + let directoryURL = executableURL.deletingLastPathComponent() + return directoryURL.lastPathComponent == "bin" + ? directoryURL.deletingLastPathComponent() + : directoryURL + } + + private func configurationDirectory(in rootURL: URL) -> URL? { + #if arch(arm64) + let configurationName = "config_mac_arm" + #elseif arch(x86_64) + let configurationName = "config_mac" + #else + return nil + #endif + let configuration = rootURL.appendingPathComponent(configurationName, isDirectory: true) + return isDirectory(configuration) ? configuration : nil + } + + private func firstEquinoxLauncher(in pluginsURL: URL) throws -> URL? { + let entries: [URL] + do { + entries = try fileManager.contentsOfDirectory( + at: pluginsURL, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) + } catch let error as CocoaError where error.code == .fileReadNoSuchFile { + return nil + } + return try entries + .filter { url in + let name = url.lastPathComponent + guard name.hasPrefix(Self.equinoxLauncherPrefix), + name.hasSuffix(".jar") else { return false } + return try url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true + } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + .first + } + + private func isDirectory(_ url: URL) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) + && isDirectory.boolValue + } + + private func isBundled(_ executableURL: URL) -> Bool { + guard let bundledJdtlsRootURL else { return false } + let rootPath = bundledJdtlsRootURL.path + "/" + return executableURL.standardizedFileURL.path.hasPrefix(rootPath) + } + + private enum ResolutionError: LocalizedError { + case incompleteInstallation + + var errorDescription: String? { + "Expected an Equinox launcher JAR, a macOS configuration directory, " + + "and lombok/lombok.jar in the selected JDTLS installation." + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift index 9575d9c97..c90c77c51 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift @@ -1,6 +1,41 @@ import Foundation +enum MacProcessArchitecture: Sendable { + case arm64 + case x86_64 + case unsupported + + static var current: Self { + #if arch(arm64) + .arm64 + #elseif arch(x86_64) + .x86_64 + #else + .unsupported + #endif + } + + var bundledJdkDirectoryName: String? { + switch self { + case .arm64: "jdk-arm64" + case .x86_64: "jdk-x86_64" + case .unsupported: nil + } + } +} + struct MacRuntimeLocator: RuntimeLocator { + private let resourceURL: URL? + private let processArchitecture: MacProcessArchitecture + + init( + resourceURL: URL? = Bundle.main.resourceURL, + processArchitecture: MacProcessArchitecture = .current + ) { + self.resourceURL = resourceURL + self.processArchitecture = processArchitecture + } + func environment() -> [String: String] { ProcessInfo.processInfo.environment } @@ -37,18 +72,45 @@ struct MacRuntimeLocator: RuntimeLocator { MacRuntimeDiscovery.systemJDBExecutable() } - /// Returns the home directory of the JDK bundled under the app's - /// `Contents/Resources/LanguageServers/jdk` directory, if present and - /// executable. Development builds that lack the bundled JDK return `nil` - /// so the caller can fall back to user-discovered runtimes. + /// Returns the bundled JDK matching the current process architecture. + /// Universal apps carry separate runtimes because a JDK contains native + /// libraries throughout its installation. Single-architecture and legacy + /// bundles continue to use `LanguageServers/jdk`. func bundledJdkHome() -> URL? { - guard let resourceURL = Bundle.main.resourceURL else { return nil } - let home = resourceURL - .appendingPathComponent("LanguageServers") - .appendingPathComponent("jdk") + guard let resourceURL else { return nil } + let languageServers = resourceURL.appendingPathComponent( + "LanguageServers", + isDirectory: true + ) + guard let architectureDirectory = processArchitecture.bundledJdkDirectoryName else { + return nil + } + + let architectureHome = languageServers + .appendingPathComponent(architectureDirectory, isDirectory: true) .standardizedFileURL - let java = home.appendingPathComponent("bin/java") - guard FileManager.default.isExecutableFile(atPath: java.path) else { return nil } - return home + if isExecutableJava(in: architectureHome) { + return architectureHome + } + + // The presence of either suffixed directory marks the new universal + // layout. Do not hide a missing runtime by falling back across layouts. + let hasUniversalLayout = ["jdk-arm64", "jdk-x86_64"].contains { directory in + var isDirectory: ObjCBool = false + return FileManager.default.fileExists( + atPath: languageServers.appendingPathComponent(directory).path, + isDirectory: &isDirectory + ) && isDirectory.boolValue + } + guard !hasUniversalLayout else { return nil } + + let legacyHome = languageServers + .appendingPathComponent("jdk", isDirectory: true) + .standardizedFileURL + return isExecutableJava(in: legacyHome) ? legacyHome : nil + } + + private func isExecutableJava(in home: URL) -> Bool { + FileManager.default.isExecutableFile(atPath: home.appendingPathComponent("bin/java").path) } } diff --git a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift index e611a98c9..6fa182467 100644 --- a/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift +++ b/macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -30,6 +30,22 @@ package struct LanguageServerRuntimeStart: Equatable, Sendable { } } +package struct JDTLSLaunchResources: Equatable, Sendable { + package let launcherJarURL: URL + package let configurationDirectoryURL: URL + package let lombokAgentURL: URL + + package init( + launcherJarURL: URL, + configurationDirectoryURL: URL, + lombokAgentURL: URL + ) { + self.launcherJarURL = launcherJarURL.standardizedFileURL + self.configurationDirectoryURL = configurationDirectoryURL.standardizedFileURL + self.lombokAgentURL = lombokAgentURL.standardizedFileURL + } +} + package struct LanguageServerRuntimeOperation: Equatable, Sendable { package let operationID: String @@ -171,6 +187,7 @@ package protocol LanguageServerRuntimeCore: Sendable { workingDirectoryURL: URL, initializationOptions: ToolingJSONValue?, runtimeExecutableURL: URL?, + jdtlsLaunchResources: JDTLSLaunchResources?, cacheDirectoryURL: URL?, workspaceFingerprint: String?, initializeTimeout: TimeInterval, diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift index 67273234d..b2ac7027c 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift @@ -8,6 +8,12 @@ package enum LanguageServerRuntimeResolution: Sendable, Equatable { case unavailable(String) } +package enum JDTLSLaunchResourcesResolution: Sendable, Equatable { + case notRequired + case available(JDTLSLaunchResources) + case unavailable(String) +} + @MainActor package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { package let descriptor: LanguageProviderDescriptor @@ -16,6 +22,10 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { private let languageServerCore: any LanguageServerRuntimeCore private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? + private let jdtlsLaunchResourcesResolver: (( + LanguageProviderDescriptor, + URL + ) -> JDTLSLaunchResourcesResolution)? private let languageServerCacheDirectory: URL? private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID @@ -38,6 +48,10 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { languageServerCore: any LanguageServerRuntimeCore, languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? = nil, + jdtlsLaunchResourcesResolver: (( + LanguageProviderDescriptor, + URL + ) -> JDTLSLaunchResourcesResolution)? = nil, languageServerCacheDirectory: URL? = nil, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence @@ -48,6 +62,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { self.languageServerCore = languageServerCore self.languageServerExecutableResolver = languageServerExecutableResolver self.languageServerRuntimeResolver = languageServerRuntimeResolver + self.jdtlsLaunchResourcesResolver = jdtlsLaunchResourcesResolver self.languageServerCacheDirectory = languageServerCacheDirectory self.processRegistry = processRegistry self.moduleID = moduleID @@ -74,6 +89,16 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { runtimeUnavailableMessage = message return nil } + let jdtlsLaunchResources: JDTLSLaunchResources? + switch jdtlsLaunchResourcesResolver?(descriptor, executableURL) ?? .notRequired { + case .notRequired: + jdtlsLaunchResources = nil + case .available(let resources): + jdtlsLaunchResources = resources + case .unavailable(let message): + runtimeUnavailableMessage = message + return nil + } var environment = runtimeService.languageToolProcessEnvironment() environment.merge(languageServerLaunch.environment) { _, configured in configured } return LanguageServerRuntimeSession( @@ -83,6 +108,7 @@ package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { environment: environment, initializationOptions: languageServerLaunch.initializationOptions, runtimeExecutableURL: runtimeExecutableURL, + jdtlsLaunchResources: jdtlsLaunchResources, cacheDirectoryURL: languageServerCacheDirectory, core: languageServerCore, processRegistry: processRegistry, @@ -98,6 +124,10 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime private let languageServerCore: any LanguageServerRuntimeCore private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? + private let jdtlsLaunchResourcesResolver: (( + LanguageProviderDescriptor, + URL + ) -> JDTLSLaunchResourcesResolution)? private let languageServerCacheDirectory: URL? private weak var processRegistry: (any LanguageServerProcessRegistry)? private let moduleID: ModuleID @@ -107,6 +137,10 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime languageServerCore: any LanguageServerRuntimeCore, languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> LanguageServerRuntimeResolution)? = nil, + jdtlsLaunchResourcesResolver: (( + LanguageProviderDescriptor, + URL + ) -> JDTLSLaunchResourcesResolution)? = nil, languageServerCacheDirectory: URL? = nil, processRegistry: (any LanguageServerProcessRegistry)? = nil, moduleID: ModuleID = .languageIntelligence @@ -115,6 +149,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime self.languageServerCore = languageServerCore self.languageServerExecutableResolver = languageServerExecutableResolver self.languageServerRuntimeResolver = languageServerRuntimeResolver + self.jdtlsLaunchResourcesResolver = jdtlsLaunchResourcesResolver self.languageServerCacheDirectory = languageServerCacheDirectory self.processRegistry = processRegistry self.moduleID = moduleID @@ -134,6 +169,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime languageServerCore: languageServerCore, languageServerExecutableResolver: languageServerExecutableResolver, languageServerRuntimeResolver: languageServerRuntimeResolver, + jdtlsLaunchResourcesResolver: jdtlsLaunchResourcesResolver, languageServerCacheDirectory: languageServerCacheDirectory, processRegistry: processRegistry, moduleID: moduleID @@ -152,6 +188,7 @@ package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntime languageServerCore: languageServerCore, languageServerExecutableResolver: languageServerExecutableResolver, languageServerRuntimeResolver: languageServerRuntimeResolver, + jdtlsLaunchResourcesResolver: jdtlsLaunchResourcesResolver, languageServerCacheDirectory: languageServerCacheDirectory, processRegistry: processRegistry, moduleID: ownerModuleID diff --git a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index 9910dc148..9ab4f21e2 100644 --- a/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift +++ b/macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -22,6 +22,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { private let environment: [String: String] private let initializationOptions: ToolingJSONValue? private let runtimeExecutableURL: URL? + private let jdtlsLaunchResources: JDTLSLaunchResources? private let cacheDirectoryURL: URL? private let initializeTimeout: TimeInterval private let requestTimeout: TimeInterval @@ -52,6 +53,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { environment: [String: String], initializationOptions: ToolingJSONValue? = nil, runtimeExecutableURL: URL? = nil, + jdtlsLaunchResources: JDTLSLaunchResources? = nil, cacheDirectoryURL: URL? = nil, initializeTimeout: TimeInterval = 60, requestTimeout: TimeInterval = 30, @@ -66,6 +68,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { self.environment = environment self.initializationOptions = initializationOptions self.runtimeExecutableURL = runtimeExecutableURL + self.jdtlsLaunchResources = jdtlsLaunchResources self.cacheDirectoryURL = cacheDirectoryURL self.initializeTimeout = initializeTimeout self.requestTimeout = requestTimeout @@ -106,6 +109,7 @@ package final class LanguageServerRuntimeSession: LanguageServerSession { workingDirectoryURL: normalizedRoot, initializationOptions: initializationOptions, runtimeExecutableURL: runtimeExecutableURL, + jdtlsLaunchResources: jdtlsLaunchResources, cacheDirectoryURL: cacheDirectoryURL, workspaceFingerprint: workspaceFingerprint, initializeTimeout: initializeTimeout, diff --git a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift index fb2fd6ed9..11d8a98da 100644 --- a/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift +++ b/macos/Tests/LitheTests/JavaLanguageServerRuntimeTests.swift @@ -4,6 +4,182 @@ import Testing @Suite("Java language server runtime") struct JavaLanguageServerRuntimeTests { + @Test + func macRuntimeLocatorSelectsJdkForRequestedProcessArchitecture() throws { + let fileManager = FileManager.default + let resources = fileManager.temporaryDirectory + .appendingPathComponent("lithe-jdk-resolver-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: resources) } + for directory in ["jdk-arm64", "jdk-x86_64"] { + let java = resources + .appendingPathComponent("LanguageServers/\(directory)/bin/java") + try fileManager.createDirectory( + at: java.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data().write(to: java) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: java.path) + } + + let armHome = MacRuntimeLocator( + resourceURL: resources, + processArchitecture: .arm64 + ).bundledJdkHome() + let intelHome = MacRuntimeLocator( + resourceURL: resources, + processArchitecture: .x86_64 + ).bundledJdkHome() + + #expect(armHome?.lastPathComponent == "jdk-arm64") + #expect(intelHome?.lastPathComponent == "jdk-x86_64") + } + + @Test + func macRuntimeLocatorDoesNotUseLegacyJdkForIncompleteUniversalLayout() throws { + let fileManager = FileManager.default + let resources = fileManager.temporaryDirectory + .appendingPathComponent("lithe-jdk-incomplete-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: resources) } + let languageServers = resources.appendingPathComponent("LanguageServers", isDirectory: true) + let legacyJava = languageServers.appendingPathComponent("jdk/bin/java") + try fileManager.createDirectory( + at: legacyJava.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data().write(to: legacyJava) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: legacyJava.path) + try fileManager.createDirectory( + at: languageServers.appendingPathComponent("jdk-x86_64", isDirectory: true), + withIntermediateDirectories: true + ) + + let home = MacRuntimeLocator( + resourceURL: resources, + processArchitecture: .arm64 + ).bundledJdkHome() + + #expect(home == nil) + } + + @Test + func macRuntimeLocatorSupportsSingleArchitectureJdkLayout() throws { + let fileManager = FileManager.default + let resources = fileManager.temporaryDirectory + .appendingPathComponent("lithe-jdk-legacy-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: resources) } + let java = resources.appendingPathComponent("LanguageServers/jdk/bin/java") + try fileManager.createDirectory( + at: java.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data().write(to: java) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: java.path) + + let home = MacRuntimeLocator(resourceURL: resources).bundledJdkHome() + + #expect(home?.lastPathComponent == "jdk") + } + + @Test + func macJdtlsResolverSelectsDirectLaunchResourcesDeterministically() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("lithe-jdtls-resolver-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + for directory in ["bin", "plugins", "config_mac", "config_mac_arm", "lombok"] { + try fileManager.createDirectory( + at: root.appendingPathComponent(directory, isDirectory: true), + withIntermediateDirectories: true + ) + } + let executable = root.appendingPathComponent("bin/jdtls") + let firstLauncher = root.appendingPathComponent( + "plugins/org.eclipse.equinox.launcher_1.0.0.jar" + ) + for file in [ + executable, + firstLauncher, + root.appendingPathComponent("plugins/org.eclipse.equinox.launcher_2.0.0.jar"), + root.appendingPathComponent("lombok/lombok.jar") + ] { + try Data().write(to: file) + } + let resolver = MacJDTLSLaunchResourceResolver(bundledJdtlsRootURL: root) + + guard case .direct(let resources) = resolver.resolve(for: executable) else { + Issue.record("Expected complete bundled JDTLS resources to use direct Java launch") + return + } + #expect(resources.launcherJarURL == firstLauncher.standardizedFileURL) + #if arch(arm64) + #expect(resources.configurationDirectoryURL.lastPathComponent == "config_mac_arm") + #else + #expect(resources.configurationDirectoryURL.lastPathComponent == "config_mac") + #endif + #expect(resources.lombokAgentURL.lastPathComponent == "lombok.jar") + } + + @Test + func macJdtlsResolverFailsBundledButKeepsExternalWrapperFallback() { + let bundledRoot = URL(fileURLWithPath: "/bundled/jdtls", isDirectory: true) + let resolver = MacJDTLSLaunchResourceResolver(bundledJdtlsRootURL: bundledRoot) + + guard case .unavailable(let message) = resolver.resolve( + for: bundledRoot.appendingPathComponent("bin/jdtls") + ) else { + Issue.record("Expected incomplete bundled JDTLS resources to fail") + return + } + #expect(message.contains("Reinstall Lithe")) + guard case .wrapperFallback = resolver.resolve( + for: URL(fileURLWithPath: "/external/jdtls/bin/jdtls") + ) else { + Issue.record("Expected an external legacy JDTLS launcher to remain compatible") + return + } + } + + @Test + func macJdtlsResolverRejectsConfigurationForTheWrongArchitecture() throws { + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory + .appendingPathComponent("lithe-jdtls-architecture-\(UUID().uuidString)", isDirectory: true) + defer { try? fileManager.removeItem(at: root) } + for directory in ["bin", "plugins", "lombok"] { + try fileManager.createDirectory( + at: root.appendingPathComponent(directory, isDirectory: true), + withIntermediateDirectories: true + ) + } + #if arch(arm64) + let wrongConfigurationName = "config_mac" + #elseif arch(x86_64) + let wrongConfigurationName = "config_mac_arm" + #else + Issue.record("Unsupported macOS test architecture") + return + #endif + try fileManager.createDirectory( + at: root.appendingPathComponent(wrongConfigurationName, isDirectory: true), + withIntermediateDirectories: true + ) + for file in [ + root.appendingPathComponent("bin/jdtls"), + root.appendingPathComponent("plugins/org.eclipse.equinox.launcher_1.0.0.jar"), + root.appendingPathComponent("lombok/lombok.jar") + ] { + try Data().write(to: file) + } + let resolver = MacJDTLSLaunchResourceResolver(bundledJdtlsRootURL: root) + + guard case .unavailable = resolver.resolve( + for: root.appendingPathComponent("bin/jdtls") + ) else { + Issue.record("Expected bundled JDTLS with the wrong architecture configuration to fail") + return + } + } + @Test func parsesModernAndLegacyJavaVersions() { #expect(javaRuntime("/jdk-17", "17.0.18").majorVersion == 17) diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 74dd8177a..717ad55dd 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1184,6 +1184,51 @@ struct RunConfigurationIntegrationTests { #expect(runtime.unavailableToolingMessage == message) } + @Test + func languageToolingForwardsStructuredJdtlsLaunchResources() throws { + 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 core = TestLanguageServerRuntimeCore(providerID: "java") + let resources = JDTLSLaunchResources( + launcherJarURL: URL(fileURLWithPath: "/jdtls/plugins/equinox.jar"), + configurationDirectoryURL: URL(fileURLWithPath: "/jdtls/config_mac"), + lombokAgentURL: URL(fileURLWithPath: "/jdtls/lombok/lombok.jar") + ) + let runtime = StdioLanguageProviderRuntime( + descriptor: descriptor, + runtimeService: runtimeService, + languageServerLaunch: descriptor.languageServerLaunch, + languageServerCore: core, + languageServerExecutableResolver: { _ in URL(fileURLWithPath: "/jdtls/bin/jdtls") }, + languageServerRuntimeResolver: { _ in + .available(URL(fileURLWithPath: "/jdk/bin/java")) + }, + jdtlsLaunchResourcesResolver: { _, _ in .available(resources) } + ) + let session = try #require(runtime.makeLanguageServerSession()) + + try session.start( + rootURL: URL(fileURLWithPath: "/workspace", isDirectory: true), + workspaceFingerprint: nil + ) + + let start = try #require(core.startCalls.last) + #expect(start.runtimeExecutableURL?.path == "/jdk/bin/java") + #expect(start.jdtlsLaunchResources == resources) + session.stop() + } + @Test func languageServerFailureClearsActiveSessionState() async throws { let descriptor = LanguageProviderDescriptor( @@ -4183,6 +4228,7 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u let workingDirectoryURL: URL let initializationOptions: ToolingJSONValue? let runtimeExecutableURL: URL? + let jdtlsLaunchResources: JDTLSLaunchResources? let cacheDirectoryURL: URL? let initializeTimeout: TimeInterval let requestTimeout: TimeInterval @@ -4248,6 +4294,7 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u workingDirectoryURL: URL, initializationOptions: ToolingJSONValue?, runtimeExecutableURL: URL?, + jdtlsLaunchResources: JDTLSLaunchResources?, cacheDirectoryURL: URL?, workspaceFingerprint: String?, initializeTimeout: TimeInterval, @@ -4263,6 +4310,7 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u workingDirectoryURL: workingDirectoryURL, initializationOptions: initializationOptions, runtimeExecutableURL: runtimeExecutableURL, + jdtlsLaunchResources: jdtlsLaunchResources, cacheDirectoryURL: cacheDirectoryURL, initializeTimeout: initializeTimeout, requestTimeout: requestTimeout, diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs index f5aa5e89e..0c2eec8bf 100644 --- a/rust/lithe-core/src/lsp/interface/engine.rs +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -14,8 +14,8 @@ use super::{ use crate::lsp::languages::jdt::{ adapt_initialization_options, adapt_start, initialized_notification, is_virtual_source_uri, normalize_location, readiness_signal, virtual_source_content, virtual_source_resolve_params, - waits_for_service_ready, workspace_configuration, JdtReadinessSignal, JdtStartContext, - ProviderLocation, WorkspaceConfigurationItem, + waits_for_service_ready, workspace_configuration, JdtDirectLaunchResources, JdtReadinessSignal, + JdtStartContext, ProviderLocation, WorkspaceConfigurationItem, }; use crate::lsp::languages::jdt_navigation::{JavaNavigationMarkerBatch, MAX_JAVA_NAVIGATION_TASKS}; use crate::protocol::{CoreError, ErrorCode}; @@ -72,6 +72,9 @@ pub struct StartServerRequest { pub initialization_options: Option, #[serde(default)] pub runtime_executable_path: Option, + /// Files discovered by the platform adapter for shell-free JDT LS startup. + #[serde(default)] + pub jdtls_launch_resources: Option, #[serde(default)] pub cache_directory: Option, /// Platform-computed digest of the workspace's build-system structure. @@ -88,6 +91,18 @@ pub struct StartServerRequest { pub shutdown_timeout_milliseconds: u64, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Platform-resolved files that let Rust launch JDT LS with bundled Java. +pub struct JdtlsLaunchResources { + /// Equinox launcher selected deterministically from the JDT LS installation. + pub launcher_jar_path: String, + /// OS-specific Eclipse configuration directory for the current product. + pub configuration_directory: String, + /// Lombok agent shipped with the selected JDT LS installation. + pub lombok_agent_path: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Identity and initial lifecycle state of a newly created session. @@ -683,6 +698,13 @@ impl LspEngine { workspace_root: workspace_root.clone(), data_root, selected_java_executable, + direct_launch_resources: request.jdtls_launch_resources.as_ref().map(|resources| { + JdtDirectLaunchResources { + launcher_jar_path: PathBuf::from(&resources.launcher_jar_path), + configuration_directory: PathBuf::from(&resources.configuration_directory), + lombok_agent_path: PathBuf::from(&resources.lombok_agent_path), + } + }), arguments: request.arguments.clone(), workspace_fingerprint: request.workspace_fingerprint.clone(), }); @@ -697,7 +719,9 @@ impl LspEngine { } let process = self.launcher.launch(LspProcessSpec { - executable: PathBuf::from(&request.executable_path), + executable: adaptation + .executable + .unwrap_or_else(|| PathBuf::from(&request.executable_path)), arguments: adaptation.arguments, working_directory: workspace_root, environment: request.environment, @@ -2374,9 +2398,25 @@ fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> if !request.root_uri.contains("://") || request.root_uri.contains('\0') { return Err(invalid_field("rootUri")); } + if let Some(resources) = &request.jdtls_launch_resources { + if !request.provider_id.trim().eq_ignore_ascii_case("java") { + return Err(invalid_field("jdtlsLaunchResources/providerId")); + } + if !is_valid_process_path(request.runtime_executable_path.as_deref()) + || !is_valid_process_path(Some(&resources.launcher_jar_path)) + || !is_valid_process_path(Some(&resources.configuration_directory)) + || !is_valid_process_path(Some(&resources.lombok_agent_path)) + { + return Err(invalid_field("jdtlsLaunchResources/runtimeExecutablePath")); + } + } Ok(()) } +fn is_valid_process_path(path: Option<&str>) -> bool { + path.is_some_and(|path| !path.trim().is_empty() && !path.contains('\0')) +} + fn invalid_field(field: &str) -> CoreError { CoreError::new( ErrorCode::InvalidRequest, @@ -3123,6 +3163,7 @@ mod tests { working_directory: "/workspace".to_string(), initialization_options: None, runtime_executable_path: None, + jdtls_launch_resources: None, cache_directory: None, workspace_fingerprint: None, initialize_timeout_milliseconds: 10_000, @@ -4069,6 +4110,55 @@ mod tests { let _ = std::fs::remove_dir_all(&cache); } + #[test] + fn structured_jdtls_resources_launch_the_runtime_executable_directly() { + let server = ScriptedServer::new(); + let engine = LspEngine::with_launcher(server.launcher()); + let cache = std::env::temp_dir().join("lithe-core-direct-jdtls-tests"); + let mut request = start_request(&server); + request.provider_id = "java".to_string(); + request.arguments = vec!["--jvm-arg=-Duser.language=en".to_string()]; + request.runtime_executable_path = Some("/opt/lithe/jdk/bin/java".to_string()); + request.jdtls_launch_resources = Some(JdtlsLaunchResources { + launcher_jar_path: "/opt/lithe/jdtls/plugins/equinox.jar".to_string(), + configuration_directory: "/opt/lithe/jdtls/config_mac".to_string(), + lombok_agent_path: "/opt/lithe/jdtls/lombok/lombok.jar".to_string(), + }); + request.cache_directory = Some(cache.to_string_lossy().into_owned()); + engine + .start_server(request) + .expect("the server should start with direct Java"); + + let spec = server + .launched_spec() + .expect("starting a server must launch a process"); + assert_eq!(spec.executable, "/opt/lithe/jdk/bin/java"); + assert_eq!( + spec.arguments.first().map(String::as_str), + Some("-javaagent:/opt/lithe/jdtls/lombok/lombok.jar") + ); + assert!(spec.arguments.contains(&"-Duser.language=en".to_string())); + assert_eq!( + spec.arguments + .iter() + .position(|argument| argument == "-jar") + .map(|index| spec.arguments[index + 1].as_str()), + Some("/opt/lithe/jdtls/plugins/equinox.jar") + ); + assert_eq!( + spec.arguments + .iter() + .position(|argument| argument == "-configuration") + .map(|index| spec.arguments[index + 1].as_str()), + Some("/opt/lithe/jdtls/config_mac") + ); + assert!(!spec + .arguments + .iter() + .any(|argument| argument.starts_with("--java-executable"))); + let _ = std::fs::remove_dir_all(&cache); + } + /// A write that fails mid-session is a transport failure, not a silent drop. #[test] fn a_broken_stdin_fails_the_session_and_stops_writing() { @@ -4996,6 +5086,7 @@ public class Main { working_directory: workspace.to_string_lossy().into_owned(), initialization_options: None, runtime_executable_path: Some(java_path), + jdtls_launch_resources: None, cache_directory: Some(root.join("cache").to_string_lossy().into_owned()), workspace_fingerprint: None, initialize_timeout_milliseconds: 90_000, @@ -5719,6 +5810,7 @@ public class Main { working_directory: "/workspace".to_string(), initialization_options: None, runtime_executable_path: None, + jdtls_launch_resources: None, cache_directory: None, workspace_fingerprint: None, initialize_timeout_milliseconds: 1, @@ -5727,4 +5819,45 @@ public class Main { }; assert!(validate_start_request(&request).is_err()); } + + #[test] + fn direct_jdtls_contract_requires_a_java_provider_and_runtime() { + let server = ScriptedServer::new(); + let mut request = start_request(&server); + request.jdtls_launch_resources = Some(JdtlsLaunchResources { + launcher_jar_path: "/jdtls/plugins/equinox.jar".to_string(), + configuration_directory: "/jdtls/config_mac".to_string(), + lombok_agent_path: "/jdtls/lombok/lombok.jar".to_string(), + }); + + assert!(validate_start_request(&request).is_err()); + request.provider_id = "java".to_string(); + assert!(validate_start_request(&request).is_err()); + request.runtime_executable_path = Some("/jdk/bin/java".to_string()); + assert!(validate_start_request(&request).is_ok()); + } + + #[test] + fn direct_jdtls_start_request_matches_the_shared_contract_fixture() { + let fixture: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/lsp/jdt-direct-launch-v1.json" + ))) + .expect("direct JDTLS fixture should be valid JSON"); + let request: StartServerRequest = serde_json::from_value(fixture["request"].clone()) + .expect("fixture request should match the Core contract"); + + validate_start_request(&request).expect("fixture request should be valid"); + let resources = request + .jdtls_launch_resources + .expect("fixture should use structured direct launch"); + assert_eq!( + request.runtime_executable_path.as_deref(), + Some("/opt/lithe/jdk/bin/java") + ); + assert_eq!( + resources.configuration_directory, + "/opt/lithe/jdtls/config_mac" + ); + } } diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs index 8f28d13e3..36f723ee7 100644 --- a/rust/lithe-core/src/lsp/languages/jdt.rs +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -41,6 +41,8 @@ pub(crate) struct JdtStartContext { #[serde(default)] pub selected_java_executable: Option, #[serde(default)] + pub direct_launch_resources: Option, + #[serde(default)] pub arguments: Vec, /// Opaque platform-provided digest of the build-system inputs that decide /// how JDT LS will import the workspace: root build files and the set of @@ -57,6 +59,15 @@ pub(crate) struct JdtStartContext { pub workspace_fingerprint: Option, } +/// Platform-resolved files required to launch JDT LS without a shell wrapper. +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct JdtDirectLaunchResources { + pub launcher_jar_path: PathBuf, + pub configuration_directory: PathBuf, + pub lombok_agent_path: PathBuf, +} + #[derive(Debug, Clone, Deserialize, Eq, PartialEq)] #[serde(rename_all = "camelCase")] /// Request for the deterministic directory key used by JDT LS state. @@ -79,6 +90,8 @@ pub(crate) struct JdtWorkspaceKeyResponse { #[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct JdtStartAdaptation { + #[serde(skip_serializing_if = "Option::is_none")] + pub executable: Option, pub arguments: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub data_directory: Option, @@ -125,6 +138,7 @@ pub(crate) struct ExecuteCommandParams { pub(crate) fn adapt_start(context: &JdtStartContext) -> JdtStartAdaptation { if !is_java_provider(&context.provider_id) { return JdtStartAdaptation { + executable: None, arguments: context.arguments.clone(), data_directory: None, }; @@ -137,19 +151,26 @@ pub(crate) fn adapt_start(context: &JdtStartContext) -> JdtStartAdaptation { &context.workspace_root, context.workspace_fingerprint.as_deref(), )); - let mut arguments = without_jdt_owned_arguments(&context.arguments); - if let Some(java_executable) = &context.selected_java_executable { - arguments.push("--java-executable".to_string()); - arguments.push(java_executable.to_string_lossy().into_owned()); - } - arguments.extend([ - "--jvm-arg=-Xms256m".to_string(), - "--jvm-arg=-Xmx1024m".to_string(), - "-data".to_string(), - data_directory.to_string_lossy().into_owned(), - ]); + let (executable, arguments) = match ( + &context.selected_java_executable, + &context.direct_launch_resources, + ) { + (Some(java_executable), Some(resources)) => ( + Some(java_executable.clone()), + direct_java_arguments(&context.arguments, resources, &data_directory), + ), + _ => ( + None, + wrapper_arguments( + &context.arguments, + context.selected_java_executable.as_deref(), + &data_directory, + ), + ), + }; JdtStartAdaptation { + executable, arguments, data_directory: Some(data_directory), } @@ -297,7 +318,172 @@ fn is_java_provider(provider_id: &str) -> bool { provider_id.trim().eq_ignore_ascii_case(JAVA_PROVIDER_ID) } -fn without_jdt_owned_arguments(arguments: &[String]) -> Vec { +fn wrapper_arguments( + arguments: &[String], + java_executable: Option<&Path>, + data_directory: &Path, +) -> Vec { + let mut arguments = without_wrapper_owned_arguments(arguments); + if let Some(java_executable) = java_executable { + arguments.push("--java-executable".to_string()); + arguments.push(java_executable.to_string_lossy().into_owned()); + } + arguments.extend([ + "--jvm-arg=-Xms256m".to_string(), + "--jvm-arg=-Xmx1024m".to_string(), + "-data".to_string(), + data_directory.to_string_lossy().into_owned(), + ]); + arguments +} + +fn direct_java_arguments( + arguments: &[String], + resources: &JdtDirectLaunchResources, + data_directory: &Path, +) -> Vec { + let (custom_jvm_arguments, server_arguments) = + split_direct_launch_arguments(arguments, &resources.launcher_jar_path); + let mut adapted = vec![ + format!( + "-javaagent:{}", + resources.lombok_agent_path.to_string_lossy() + ), + "-Xms256m".to_string(), + "-Xmx1024m".to_string(), + "--add-modules=ALL-SYSTEM".to_string(), + "--add-opens=java.base/java.util=ALL-UNNAMED".to_string(), + "--add-opens=java.base/java.lang=ALL-UNNAMED".to_string(), + "-Declipse.application=org.eclipse.jdt.ls.core.id1".to_string(), + "-Declipse.product=org.eclipse.jdt.ls.core.product".to_string(), + "-Dosgi.bundles.defaultStartLevel=4".to_string(), + "-Dlog.protocol=true".to_string(), + "-Dlog.level=ALL".to_string(), + ]; + adapted.extend(custom_jvm_arguments); + adapted.extend([ + "-jar".to_string(), + resources.launcher_jar_path.to_string_lossy().into_owned(), + "-configuration".to_string(), + resources + .configuration_directory + .to_string_lossy() + .into_owned(), + ]); + adapted.extend(server_arguments); + adapted.extend([ + "-data".to_string(), + data_directory.to_string_lossy().into_owned(), + ]); + adapted +} + +fn split_direct_launch_arguments( + arguments: &[String], + launcher_jar_path: &Path, +) -> (Vec, Vec) { + let launcher_jar_path = launcher_jar_path.to_string_lossy(); + let owned_launcher_index = arguments + .windows(2) + .position(|pair| pair[0] == "-jar" && pair[1] == launcher_jar_path.as_ref()); + let has_direct_layout = owned_launcher_index.is_some(); + let mut custom_jvm_arguments = Vec::new(); + let mut server_arguments = Vec::new(); + let mut after_launcher = false; + let mut index = 0; + while index < arguments.len() { + let argument = &arguments[index]; + if argument == "--java-executable" || argument == "-data" { + index += usize::from(index + 1 < arguments.len()) + 1; + continue; + } + if argument == "--jvm-arg" { + if let Some(value) = arguments.get(index + 1) { + if !is_jdt_owned_jvm_argument(value) { + custom_jvm_arguments.push(value.clone()); + } + } + index += usize::from(index + 1 < arguments.len()) + 1; + continue; + } + if let Some(value) = argument.strip_prefix("--jvm-arg=") { + if !is_jdt_owned_jvm_argument(value) { + custom_jvm_arguments.push(value.to_string()); + } + index += 1; + continue; + } + if owned_launcher_index == Some(index) { + after_launcher = true; + index += usize::from(index + 1 < arguments.len()) + 1; + continue; + } + if argument == "-configuration" && has_direct_layout && after_launcher { + index += usize::from(index + 1 < arguments.len()) + 1; + continue; + } + if has_direct_layout + && !after_launcher + && ((argument == "--add-modules" + && arguments + .get(index + 1) + .is_some_and(|value| value == "ALL-SYSTEM")) + || (argument == "--add-opens" + && arguments.get(index + 1).is_some_and(|value| { + value == "java.base/java.util=ALL-UNNAMED" + || value == "java.base/java.lang=ALL-UNNAMED" + }))) + { + index += 2; + continue; + } + if argument.starts_with("--java-executable=") || argument.starts_with("-data=") { + index += 1; + continue; + } + if has_direct_layout && !after_launcher { + if !is_jdt_owned_jvm_argument(argument) { + custom_jvm_arguments.push(argument.clone()); + } + } else if !is_jdt_owned_server_argument(argument) { + server_arguments.push(argument.clone()); + } + index += 1; + } + (custom_jvm_arguments, server_arguments) +} + +fn is_jdt_owned_jvm_argument(argument: &str) -> bool { + argument.starts_with("-Xms") + || argument.starts_with("-Xmx") + || argument == "--add-modules=ALL-SYSTEM" + || argument == "--add-opens=java.base/java.util=ALL-UNNAMED" + || argument == "--add-opens=java.base/java.lang=ALL-UNNAMED" + || argument.starts_with("-Declipse.application=") + || argument.starts_with("-Declipse.product=") + || argument.starts_with("-Dosgi.bundles.defaultStartLevel=") + || argument.starts_with("-Dlog.protocol=") + || argument.starts_with("-Dlog.level=") + || is_lombok_agent_argument(argument) +} + +fn is_lombok_agent_argument(argument: &str) -> bool { + let Some(path) = argument.strip_prefix("-javaagent:") else { + return false; + }; + path.split_once('=') + .map_or(path, |(path, _)| path) + .replace('\\', "/") + .rsplit('/') + .next() + .is_some_and(|name| name.eq_ignore_ascii_case("lombok.jar")) +} + +fn is_jdt_owned_server_argument(argument: &str) -> bool { + argument == "-data" || argument.starts_with("-data=") +} + +fn without_wrapper_owned_arguments(arguments: &[String]) -> Vec { let mut retained = Vec::with_capacity(arguments.len()); let mut index = 0; while index < arguments.len() { @@ -548,6 +734,7 @@ mod tests { workspace_root: PathBuf::from("/workspace/project"), data_root: PathBuf::from("/cache/Lithe"), selected_java_executable: Some(PathBuf::from("/jdk/bin/java")), + direct_launch_resources: None, arguments: vec!["--stdio".to_string()], workspace_fingerprint: None, } @@ -605,6 +792,62 @@ mod tests { assert!(!first.arguments.contains(&"/old/data".to_string())); } + #[test] + fn java_direct_start_builds_complete_shell_free_arguments_and_is_stable() { + let mut context = java_start_context(); + context.direct_launch_resources = Some(JdtDirectLaunchResources { + launcher_jar_path: PathBuf::from("/jdtls/plugins/equinox.jar"), + configuration_directory: PathBuf::from("/jdtls/config_mac"), + lombok_agent_path: PathBuf::from("/jdtls/lombok/lombok.jar"), + }); + context.arguments = vec![ + "--stdio".to_string(), + "-jar".to_string(), + "/server/extension.jar".to_string(), + "--java-executable=/old/java".to_string(), + "--jvm-arg=-Xmx4g".to_string(), + "--jvm-arg=-Duser.language=en".to_string(), + "-data=/old/data".to_string(), + ]; + + let first = adapt_start(&context); + context.arguments = first.arguments.clone(); + let second = adapt_start(&context); + let data_directory = first.data_directory.as_ref().unwrap(); + + assert_eq!(first, second); + assert_eq!(first.executable, Some(PathBuf::from("/jdk/bin/java"))); + assert_eq!( + first.arguments, + vec![ + "-javaagent:/jdtls/lombok/lombok.jar", + "-Xms256m", + "-Xmx1024m", + "--add-modules=ALL-SYSTEM", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "-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", + "-Duser.language=en", + "-jar", + "/jdtls/plugins/equinox.jar", + "-configuration", + "/jdtls/config_mac", + "--stdio", + "-jar", + "/server/extension.jar", + "-data", + data_directory.to_string_lossy().as_ref(), + ] + ); + assert!(!first.arguments.contains(&"/old/java".to_string())); + assert!(!first.arguments.contains(&"/old/data".to_string())); + assert!(!first.arguments.contains(&"-Xmx4g".to_string())); + } + #[test] fn workspace_identity_is_lexical_cross_platform_and_unique() { assert_eq!( diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 index 2f5be2b61..86215eb26 100644 --- a/scripts/build-windows.ps1 +++ b/scripts/build-windows.ps1 @@ -114,11 +114,16 @@ foreach ($relativePath in @("icon-themes", "themes", "icon-themes/idea/extension } $jdtlsDestination = Copy-ResourceDirectory $preparedJdtlsRoot "LanguageServers/jdtls" -foreach ($relativePath in @("bin/jdtls.bat", "config_win", "plugins")) { +foreach ($relativePath in @("bin/jdtls.bat", "config_win", "plugins", "lombok/lombok.jar")) { if (-not (Test-Path -LiteralPath (Join-Path $jdtlsDestination $relativePath))) { throw "Bundled JDTLS staging is incomplete: $relativePath" } } +$equinoxLauncher = Get-ChildItem -LiteralPath (Join-Path $jdtlsDestination "plugins") ` + -File -Filter "org.eclipse.equinox.launcher_*.jar" | Sort-Object Name | Select-Object -First 1 +if ($null -eq $equinoxLauncher) { + throw "Bundled JDTLS staging has no Equinox launcher JAR." +} $jdkDestination = Copy-ResourceDirectory ` -Source $preparedJdkRoot ` diff --git a/scripts/package-app.sh b/scripts/package-app.sh index b90e1ec7f..b6641c606 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -21,12 +21,26 @@ case "$ARCH" in esac cd "$ROOT_DIR" -JDTLS_ROOT=$("$ROOT_DIR/scripts/prepare-jdtls.sh") -jdk_arch="$ARCH" -if [[ "$jdk_arch" == "universal" ]]; then - jdk_arch="$(uname -m)" +JDTLS_ROOT=$(LITHE_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdtls.sh") +if [[ "$ARCH" == "universal" ]]; then + if [[ -n "${LITHE_JDK_ROOT:-}" ]]; then + print -u2 -- \ + "Universal packaging requires LITHE_JDK_ARM64_ROOT and LITHE_JDK_X86_64_ROOT instead of LITHE_JDK_ROOT" + exit 1 + fi + ARM64_JDK_ROOT=$( \ + LITHE_JDK_ROOT="${LITHE_JDK_ARM64_ROOT:-}" \ + LITHE_JDK_TARGET_ARCH="arm64" \ + "$ROOT_DIR/scripts/prepare-jdk.sh" + ) + X86_64_JDK_ROOT=$( \ + LITHE_JDK_ROOT="${LITHE_JDK_X86_64_ROOT:-}" \ + LITHE_JDK_TARGET_ARCH="x86_64" \ + "$ROOT_DIR/scripts/prepare-jdk.sh" + ) +else + JDK_ROOT=$(LITHE_JDK_TARGET_ARCH="$ARCH" "$ROOT_DIR/scripts/prepare-jdk.sh") fi -JDK_ROOT=$(LITHE_JDK_TARGET_ARCH="$jdk_arch" "$ROOT_DIR/scripts/prepare-jdk.sh") if [[ "$ARCH" == "universal" ]]; then scripts/build-macos.sh --configuration release --triple "$ARM64_TRIPLE" scripts/build-macos.sh --configuration release --triple "$X86_64_TRIPLE" @@ -47,7 +61,8 @@ if [[ -n "$database_sidecar" ]]; then print -u2 -- "LITHE_DB_SIDECAR_EXECUTABLE is not executable: $database_sidecar" exit 1 fi - if [[ "$ARCH" == "universal" ]] && ! lipo "$database_sidecar" -verify_arch arm64 -verify_arch x86_64 >/dev/null 2>&1; then + if [[ "$ARCH" == "universal" ]] && \ + ! lipo "$database_sidecar" -verify_arch arm64 x86_64 >/dev/null 2>&1; then print -u2 -- "Universal packaging requires a fat database helper with arm64 and x86_64 slices" exit 1 fi @@ -63,7 +78,8 @@ if [[ -n "$database_mcp" ]]; then print -u2 -- "LITHE_DB_MCP_EXECUTABLE is not executable: $database_mcp" exit 1 fi - if [[ "$ARCH" == "universal" ]] && ! lipo "$database_mcp" -verify_arch arm64 -verify_arch x86_64 >/dev/null 2>&1; then + if [[ "$ARCH" == "universal" ]] && \ + ! lipo "$database_mcp" -verify_arch arm64 x86_64 >/dev/null 2>&1; then print -u2 -- "Universal packaging requires a fat database MCP helper with arm64 and x86_64 slices" exit 1 fi @@ -103,7 +119,12 @@ for bundle_name in Lithe_Lithe.bundle SwiftTerm_SwiftTerm.bundle; do done mkdir -p "$APP_DIR/Contents/Resources/LanguageServers" cp -R "$JDTLS_ROOT" "$APP_DIR/Contents/Resources/LanguageServers/jdtls" -cp -R "$JDK_ROOT" "$APP_DIR/Contents/Resources/LanguageServers/jdk" +if [[ "$ARCH" == "universal" ]]; then + cp -R "$ARM64_JDK_ROOT" "$APP_DIR/Contents/Resources/LanguageServers/jdk-arm64" + cp -R "$X86_64_JDK_ROOT" "$APP_DIR/Contents/Resources/LanguageServers/jdk-x86_64" +else + cp -R "$JDK_ROOT" "$APP_DIR/Contents/Resources/LanguageServers/jdk" +fi 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 e5447cdb2..a2574dcf4 100644 --- a/scripts/package-windows.ps1 +++ b/scripts/package-windows.ps1 @@ -47,6 +47,8 @@ $preparedJdkRoot = [System.IO.Path]::GetFullPath([string]$preparedJdkOutput[-1]) foreach ($requiredPath in @( (Join-Path $preparedJdtlsRoot "bin/jdtls.bat"), (Join-Path $preparedJdtlsRoot "plugins"), + (Join-Path $preparedJdtlsRoot "config_win"), + (Join-Path $preparedJdtlsRoot "lombok/lombok.jar"), (Join-Path $preparedJdkRoot "bin/java.exe"), (Join-Path $preparedJdkRoot "lib") )) { @@ -54,6 +56,11 @@ foreach ($requiredPath in @( throw "Bundled Java tooling preparation is incomplete: $requiredPath" } } +$equinoxLauncher = Get-ChildItem -LiteralPath (Join-Path $preparedJdtlsRoot "plugins") ` + -File -Filter "org.eclipse.equinox.launcher_*.jar" | Sort-Object Name | Select-Object -First 1 +if ($null -eq $equinoxLauncher) { + throw "Bundled Java tooling preparation has no JDTLS Equinox launcher JAR." +} function Sync-BundleResource { param([string]$Source, [string]$Destination) diff --git a/scripts/prepare-jdk.sh b/scripts/prepare-jdk.sh index 726d24ce9..aed55c1da 100755 --- a/scripts/prepare-jdk.sh +++ b/scripts/prepare-jdk.sh @@ -1,15 +1,15 @@ #!/bin/zsh -# Stages the bundled JDTLS runtime JDK into .artifacts/jdk. +# Stages the bundled JDTLS runtime JDK into an architecture-specific artifact directory. # This JDK only runs the Java language server. Project SDKs stay user-owned. set -euo pipefail ROOT_DIR="${0:A:h:h}" MANIFEST="$ROOT_DIR/third_party/jdk/manifest.json" -OUTPUT_DIR="${LITHE_JDK_ROOT:-$ROOT_DIR/.artifacts/jdk}" CACHE_DIR="$ROOT_DIR/.artifacts/jdk-downloads" TARGET_ARCH="${LITHE_JDK_TARGET_ARCH:-$(uname -m)}" +OUTPUT_DIR="${LITHE_JDK_ROOT:-$ROOT_DIR/.artifacts/jdk-$TARGET_ARCH}" manifest_value() { /usr/bin/plutil -extract "$1" raw -o - "$MANIFEST" diff --git a/scripts/prepare-jdtls.ps1 b/scripts/prepare-jdtls.ps1 index 42d3cae30..a1d62aafe 100644 --- a/scripts/prepare-jdtls.ps1 +++ b/scripts/prepare-jdtls.ps1 @@ -85,11 +85,17 @@ function Get-VerifiedDownload { function Assert-JdtlsOutput { if (-not (Test-Path -LiteralPath (Join-Path $output "plugins") -PathType Container)) { throw "JDTLS plugins directory is missing: $output" } + $equinoxLauncher = Get-ChildItem -LiteralPath (Join-Path $output "plugins") -File -Filter "org.eclipse.equinox.launcher_*.jar" | + Sort-Object Name | Select-Object -First 1 + if ($null -eq $equinoxLauncher) { throw "JDTLS Equinox launcher is missing: $output/plugins" } + if (-not (Test-Path -LiteralPath (Join-Path $output "config_mac") -PathType Container)) { throw "JDTLS macOS configuration 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 (-not (Test-Path -LiteralPath (Join-Path $output "lombok/lombok.jar") -PathType Leaf)) { throw "JDTLS Lombok agent is missing: $output" } if (-not (Test-Path -LiteralPath (Join-Path $output "lombok/LICENSE-MIT.txt") -PathType Leaf)) { throw "JDTLS Lombok license is missing: $output" } + # Wrapper scripts remain for external/legacy launch plans. Packaged JDTLS + # uses the direct-launch resources validated above. $launcher = Get-Content -Raw -LiteralPath (Join-Path $output "bin/jdtls.ps1") if (-not $launcher.Contains("-javaagent:")) { throw "JDTLS launcher does not load the Lombok agent: $output" } } diff --git a/scripts/prepare-jdtls.sh b/scripts/prepare-jdtls.sh index bba1027b6..91023fc7d 100755 --- a/scripts/prepare-jdtls.sh +++ b/scripts/prepare-jdtls.sh @@ -74,12 +74,30 @@ download_verified_file() { 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; } + local launcher_jars=("$OUTPUT_DIR"/plugins/org.eclipse.equinox.launcher_*.jar(N)) + (( ${#launcher_jars[@]} > 0 )) || { print -u2 -- "JDTLS Equinox launcher is missing: $OUTPUT_DIR/plugins"; exit 1; } + local target_arch="${LITHE_ARCH:-$(uname -m)}" + local required_mac_configurations + case "$target_arch" in + arm64) required_mac_configurations=(config_mac_arm) ;; + x86_64) required_mac_configurations=(config_mac) ;; + universal) required_mac_configurations=(config_mac_arm config_mac) ;; + *) print -u2 -- "Unsupported JDTLS target architecture: $target_arch"; exit 1 ;; + esac + local configuration_name + for configuration_name in "${required_mac_configurations[@]}"; do + [[ -d "$OUTPUT_DIR/$configuration_name" ]] || { + print -u2 -- "JDTLS $configuration_name configuration is missing: $OUTPUT_DIR" + exit 1 + } + done [[ -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; } [[ -f "$OUTPUT_DIR/lombok/lombok.jar" ]] || { print -u2 -- "JDTLS Lombok agent is missing: $OUTPUT_DIR"; exit 1; } [[ -f "$OUTPUT_DIR/lombok/LICENSE-MIT.txt" ]] || { print -u2 -- "JDTLS Lombok license is missing: $OUTPUT_DIR"; exit 1; } + # Wrapper scripts remain available for external/legacy launch plans. The + # packaged product launches bundled Java directly with the resources above. grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls" || { print -u2 -- "JDTLS launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; } grep -Fq -- '-javaagent:' "$OUTPUT_DIR/bin/jdtls.ps1" || { print -u2 -- "JDTLS Windows launcher does not load the Lombok agent: $OUTPUT_DIR"; exit 1; } } @@ -159,11 +177,12 @@ 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 +case "$(uname -m)" in + arm64) CONFIGURATION="$SCRIPT_DIR/../config_mac_arm" ;; + x86_64) CONFIGURATION="$SCRIPT_DIR/../config_mac" ;; + *) print -u2 -- "Unsupported macOS architecture: $(uname -m)"; exit 1 ;; +esac +[[ -d "$CONFIGURATION" ]] || { print -u2 -- "JDTLS configuration was not found: $CONFIGURATION"; exit 1; } exec "$JAVA_EXECUTABLE" \ "${JVM_ARGUMENTS[@]}" \ diff --git a/scripts/test-verify-download-cache.mjs b/scripts/test-verify-download-cache.mjs index 847aac2e9..f917b26bc 100644 --- a/scripts/test-verify-download-cache.mjs +++ b/scripts/test-verify-download-cache.mjs @@ -104,7 +104,31 @@ async function assertMacJdkCacheConfiguration() { } const packageScript = await fs.readFile(path.join(repositoryRoot, "scripts/package-app.sh"), "utf8"); - assert.match(packageScript, /LITHE_JDK_TARGET_ARCH="\$jdk_arch"/, "macOS packages must prepare the target-architecture JDK"); + assert.match( + packageScript, + /LITHE_ARCH="\$ARCH".*prepare-jdtls\.sh/, + "macOS packages must validate JDTLS for the package architecture", + ); + assert.match( + packageScript, + /LITHE_JDK_TARGET_ARCH="arm64"/, + "universal macOS packages must prepare an ARM JDK", + ); + assert.match( + packageScript, + /LITHE_JDK_TARGET_ARCH="x86_64"/, + "universal macOS packages must prepare an Intel JDK", + ); + assert.match( + packageScript, + /LanguageServers\/jdk-arm64/, + "universal macOS packages must bundle the ARM JDK separately", + ); + assert.match( + packageScript, + /LanguageServers\/jdk-x86_64/, + "universal macOS packages must bundle the Intel JDK separately", + ); const previewScript = await fs.readFile(path.join(repositoryRoot, "scripts/preview.sh"), "utf8"); assert.match(previewScript, /prepare-jdtls\.sh/, "macOS previews must prepare JDTLS"); assert.match(previewScript, /prepare-jdk\.sh/, "macOS previews must prepare the bundled JDK"); @@ -120,12 +144,27 @@ async function assertMacJdkCacheConfiguration() { ); const prepareScript = await fs.readFile(path.join(repositoryRoot, "scripts/prepare-jdk.sh"), "utf8"); assert.match(prepareScript, /TARGET_ARCH="\$\{LITHE_JDK_TARGET_ARCH:-\$\(uname -m\)\}"/); + assert.match(prepareScript, /\.artifacts\/jdk-\$TARGET_ARCH/, "prepared macOS JDKs must be isolated by architecture"); assert.match(prepareScript, /macos-aarch64/); assert.match(prepareScript, /macos-x86_64/); assert.match(prepareScript, /lipo.*-verify_arch.*TARGET_ARCH/, "macOS JDK validation must verify its Mach-O architecture"); const packageVerifier = await fs.readFile(path.join(repositoryRoot, "scripts/verify-macos-package.sh"), "utf8"); - assert.match(packageVerifier, /LanguageServers\/jdk\/bin\/java/, "macOS package verification must require the bundled JDK"); + assert.match( + packageVerifier, + /env -u LITHE_ARCH/, + "macOS package verification must exercise the default universal path", + ); + assert.match( + packageVerifier, + /LanguageServers\/jdk-arm64\/bin\/java/, + "macOS package verification must require the ARM JDK", + ); + assert.match( + packageVerifier, + /LanguageServers\/jdk-x86_64\/bin\/java/, + "macOS package verification must require the Intel JDK", + ); } try { diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index e6306c467..4c4ba614a 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -4,16 +4,11 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" -case "$(uname -m)" in - arm64) ARCH="arm64" ;; - x86_64) ARCH="x86_64" ;; - *) print -u2 -- "Unsupported host architecture: $(uname -m)"; exit 1 ;; -esac - temporary_directory=$(mktemp -d "${TMPDIR:-/tmp}/lithe-package-verification.XXXXXX") trap 'rm -rf -- "$temporary_directory"' EXIT jdtls_root="$temporary_directory/jdtls" -jdk_root="$temporary_directory/jdk" +arm64_jdk_root="$temporary_directory/jdk-arm64" +x86_64_jdk_root="$temporary_directory/jdk-x86_64" dist_root="$temporary_directory/dist" package_log="$temporary_directory/package.log" dmg_log="$temporary_directory/dmg.log" @@ -23,6 +18,7 @@ dmg_log="$temporary_directory/dmg.log" # keeping CI deterministic and independent of the Eclipse download service. mkdir -p \ "$jdtls_root/plugins" \ + "$jdtls_root/config_mac_arm" \ "$jdtls_root/config_mac" \ "$jdtls_root/config_win" \ "$jdtls_root/bin" \ @@ -39,25 +35,49 @@ Write-Output $javaAgentArgument LAUNCHER : > "$jdtls_root/lombok/lombok.jar" : > "$jdtls_root/lombok/LICENSE-MIT.txt" +: > "$jdtls_root/plugins/org.eclipse.equinox.launcher_1.0.0.jar" + +for missing_configuration in config_mac_arm config_mac; do + broken_jdtls_root="$temporary_directory/jdtls-missing-$missing_configuration" + cp -R "$jdtls_root" "$broken_jdtls_root" + rm -rf -- "$broken_jdtls_root/$missing_configuration" + failure_log="$temporary_directory/missing-$missing_configuration.log" + if env -u LITHE_ARCH \ + LITHE_JDTLS_ROOT="$broken_jdtls_root" \ + LITHE_JDK_ROOT="$temporary_directory/invalid-jdk" \ + scripts/package-app.sh > "$failure_log" 2>&1; then + print -u2 -- "Default universal packaging accepted JDTLS without $missing_configuration" + exit 1 + fi + if ! grep -Fq "JDTLS $missing_configuration configuration is missing" "$failure_log"; then + print -u2 -- "Default universal packaging did not validate $missing_configuration before building" + cat "$failure_log" >&2 + exit 1 + fi +done -mkdir -p "$jdk_root/bin" "$jdk_root/lib" cat > "$temporary_directory/java.c" <<'SOURCE' int main(void) { return 0; } SOURCE -xcrun clang -arch "$ARCH" "$temporary_directory/java.c" -o "$jdk_root/bin/java" -cat > "$jdk_root/release" <<'RELEASE' +for jdk_arch in arm64 x86_64; do + jdk_root="$temporary_directory/jdk-$jdk_arch" + mkdir -p "$jdk_root/bin" "$jdk_root/lib" + xcrun clang -arch "$jdk_arch" "$temporary_directory/java.c" -o "$jdk_root/bin/java" + cat > "$jdk_root/release" <<'RELEASE' JAVA_VERSION="21.0.0" RELEASE +done -LITHE_ARCH="$ARCH" \ -LITHE_DIST_ROOT="$dist_root" \ -LITHE_JDTLS_ROOT="$jdtls_root" \ -LITHE_JDK_ROOT="$jdk_root" \ -LITHE_CODESIGN_IDENTITY="-" \ +env -u LITHE_ARCH -u LITHE_JDK_ROOT \ + LITHE_DIST_ROOT="$dist_root" \ + LITHE_JDTLS_ROOT="$jdtls_root" \ + LITHE_JDK_ARM64_ROOT="$arm64_jdk_root" \ + LITHE_JDK_X86_64_ROOT="$x86_64_jdk_root" \ + LITHE_CODESIGN_IDENTITY="-" \ scripts/package-app.sh | tee "$package_log" app_path="$(tail -n 1 "$package_log")" -expected_app_path="$dist_root/Lithe-$ARCH.app" +expected_app_path="$dist_root/Lithe.app" if [[ "$app_path" != "$expected_app_path" ]]; then print -u2 -- "Unexpected packaged app path: $app_path" exit 1 @@ -80,8 +100,14 @@ required_resources=( "$app_path/Contents/Resources/Lithe_Lithe.bundle" "$app_path/Contents/Resources/SwiftTerm_SwiftTerm.bundle/Shaders.metal" "$app_path/Contents/Resources/LanguageServers/jdtls/bin/jdtls" - "$app_path/Contents/Resources/LanguageServers/jdk/bin/java" - "$app_path/Contents/Resources/LanguageServers/jdk/lib" + "$app_path/Contents/Resources/LanguageServers/jdtls/plugins/org.eclipse.equinox.launcher_1.0.0.jar" + "$app_path/Contents/Resources/LanguageServers/jdtls/config_mac_arm" + "$app_path/Contents/Resources/LanguageServers/jdtls/config_mac" + "$app_path/Contents/Resources/LanguageServers/jdtls/lombok/lombok.jar" + "$app_path/Contents/Resources/LanguageServers/jdk-arm64/bin/java" + "$app_path/Contents/Resources/LanguageServers/jdk-arm64/lib" + "$app_path/Contents/Resources/LanguageServers/jdk-x86_64/bin/java" + "$app_path/Contents/Resources/LanguageServers/jdk-x86_64/lib" ) for resource in "${required_resources[@]}"; do if [[ ! -e "$resource" ]]; then @@ -89,9 +115,13 @@ for resource in "${required_resources[@]}"; do exit 1 fi done +/usr/bin/lipo "$app_path/Contents/MacOS/Lithe" -verify_arch arm64 x86_64 +/usr/bin/lipo \ + "$app_path/Contents/Resources/LanguageServers/jdk-arm64/bin/java" \ + -verify_arch arm64 /usr/bin/lipo \ - "$app_path/Contents/Resources/LanguageServers/jdk/bin/java" \ - -verify_arch "$ARCH" + "$app_path/Contents/Resources/LanguageServers/jdk-x86_64/bin/java" \ + -verify_arch x86_64 plugin_manifests=("$app_path/Contents/Resources/OfficialPlugins"/*/plugin.json(N)) if (( ${#plugin_manifests[@]} == 0 )); then @@ -101,16 +131,16 @@ fi /usr/bin/codesign --verify --deep --strict "$app_path" -LITHE_ARCH="$ARCH" \ -LITHE_DIST_ROOT="$dist_root" \ -LITHE_VERSION="ci-smoke" \ +env -u LITHE_ARCH \ + LITHE_DIST_ROOT="$dist_root" \ + LITHE_VERSION="ci-smoke" \ scripts/create-dmg.sh | tee "$dmg_log" dmg_path="$(tail -n 1 "$dmg_log")" -expected_dmg_path="$dist_root/Lithe-ci-smoke-$ARCH.dmg" +expected_dmg_path="$dist_root/Lithe-ci-smoke.dmg" if [[ "$dmg_path" != "$expected_dmg_path" || ! -s "$dmg_path" ]]; then print -u2 -- "Disk image was not created at the expected path: $dmg_path" exit 1 fi hdiutil imageinfo "$dmg_path" > /dev/null -print -- "macOS package and disk image verification passed for $ARCH" +print -- "macOS package and disk image verification passed for the default universal architecture" diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index d2470fce3..53f5bae9e 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -142,6 +142,14 @@ path. A missing or invalid bundle is a packaging failure. The application shows preparing, ready, failure, and timeout notifications; a navigation command while preparing ends after the notice and is never replayed later. +macOS and Windows adapters discover the selected JDT LS installation's Equinox +launcher JAR, platform configuration directory, Lombok agent, and bundled Java +executable. They submit those paths as structured launch resources; Rust Core +owns the JVM flags and directly starts `java`/`java.exe` with array arguments. +Packaged JDT LS therefore has no runtime dependency on shell wrappers, +PowerShell, or the user's `PATH`. Legacy wrappers are an external-plan +compatibility fallback and are not the packaged execution path. + Platforms observe JDT LS version and non-recursive build-file metadata, while Rust Core alone validates and reduces those observations to the opaque workspace fingerprint. macOS and Windows adapters must not duplicate its ordering, @@ -159,8 +167,9 @@ Language feature clients route through a provider interface rather than depending directly on an LSP session. Process-free providers remain available when an executable is missing. LSP-backed features are enabled only after the server advertises them during initialize or dynamic registration. The shared -core owns JSON-RPC state and normalized results; platform adapters own stdio and -process lifecycle. Detailed invariants are documented in +core owns JSON-RPC state, stdio, process lifecycle, and normalized results; +platform adapters own executable and provider-resource discovery. Detailed +invariants are documented in [`language-tooling.md`](../../docs/architecture/language-tooling.md). Session lifecycle is a single discriminated state, never a set of booleans. diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 91ae46085..fe462fa76 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -344,13 +344,26 @@ The `lsp.*Server`, `lsp.*Document`, `lsp.request`, `lsp.pollEvents`, and `lsp.waitEvents` commands are the semantic LSP runtime boundary. `lsp.startServer` accepts the provider ID, selected executable/arguments/environment, root URI, working -directory, initialization options, optional runtime executable, cache -directory, and `workspaceFingerprint`, plus initialize/request/shutdown -deadlines. Rust owns the returned +directory, initialization options, optional runtime executable, +`jdtlsLaunchResources`, cache directory, and `workspaceFingerprint`, plus +initialize/request/shutdown deadlines. `jdtlsLaunchResources`, when present, +contains `launcherJarPath`, `configurationDirectory`, and `lombokAgentPath`; it +is valid only for the Java provider and requires `runtimeExecutablePath`. Rust +then uses `runtimeExecutablePath` as the process executable and constructs the +complete deterministic JDT LS JVM argument list. When the structured object is +absent, the selected `executablePath` and legacy wrapper arguments remain the +compatibility path. Rust owns the returned session's child process, stdin/stdout/stderr, framing buffer, JSON-RPC request IDs, document versions, pending deadlines, capabilities, diagnostics, and graceful/forced termination. +Platform adapters own filesystem discovery and validate that packaged JDT LS +contains the Equinox launcher, platform configuration directory, Lombok agent, +and bundled Java. They do not construct JVM commands. Packaged macOS and Windows +plans always use structured direct launch, so runtime startup has no shell, +PowerShell, or user-`PATH` dependency. Wrapper launch remains optional only for +external or older plans. + For JDT LS, platform adapters observe root Maven/Gradle descriptor timestamps and sizes, names of direct Maven module directories, and the selected JDT LS version. They submit those raw observations to `java.jdtWorkspaceFingerprint`; diff --git a/shared/fixtures/lsp/jdt-direct-launch-v1.json b/shared/fixtures/lsp/jdt-direct-launch-v1.json new file mode 100644 index 000000000..cb83876bd --- /dev/null +++ b/shared/fixtures/lsp/jdt-direct-launch-v1.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "request": { + "providerId": "java", + "executablePath": "/opt/lithe/jdtls/bin/jdtls", + "arguments": [ + "--jvm-arg=-Duser.language=en" + ], + "environment": { + "JAVA_HOME": "/opt/lithe/jdk" + }, + "rootUri": "file:///workspace/project", + "workingDirectory": "/workspace/project", + "initializationOptions": null, + "runtimeExecutablePath": "/opt/lithe/jdk/bin/java", + "jdtlsLaunchResources": { + "launcherJarPath": "/opt/lithe/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar", + "configurationDirectory": "/opt/lithe/jdtls/config_mac", + "lombokAgentPath": "/opt/lithe/jdtls/lombok/lombok.jar" + }, + "cacheDirectory": "/var/cache/lithe/language-servers", + "workspaceFingerprint": "build=|modules=|jdtls=1.55.0", + "initializeTimeoutMilliseconds": 60000, + "requestTimeoutMilliseconds": 30000, + "shutdownTimeoutMilliseconds": 2000 + } +} diff --git a/windows/tauri/src-tauri/src/lsp.rs b/windows/tauri/src-tauri/src/lsp.rs index df9bdffbe..e6f807c95 100644 --- a/windows/tauri/src-tauri/src/lsp.rs +++ b/windows/tauri/src-tauri/src/lsp.rs @@ -1,7 +1,8 @@ //! Windows discovery for the built-in Java language server. //! //! Shared JDT LS process ownership stays in `lithe-core`. This adapter only -//! finds `jdtls`, the bundled JDTLS runtime JDK, and a cache directory. +//! finds `jdtls`, its direct-launch resources, the bundled runtime JDK, and a +//! cache directory. //! //! The bundled JDK exists solely to run the language server. Project SDKs are //! discovered separately by `run.rs` and remain user-owned. @@ -27,6 +28,7 @@ const JAVA_PROVIDER_ID: &str = "java"; const JDTLS_EXECUTABLE_NAMES: &[&str] = &["jdtls.bat", "jdtls.cmd", "jdtls.exe", "jdtls"]; const MAX_CURRENT_EXE_JDTLS_WALK_DEPTH: usize = 12; const JDTLS_CORE_PLUGIN_PREFIX: &str = "org.eclipse.jdt.ls.core_"; +const EQUINOX_LAUNCHER_PLUGIN_PREFIX: &str = "org.eclipse.equinox.launcher_"; const BUNDLED_JDTLS_MANIFEST: &str = include_str!("../../../../third_party/jdtls/manifest.json"); static JDT_CACHE_OPERATION_ID: AtomicU64 = AtomicU64::new(1); @@ -39,6 +41,7 @@ pub struct JavaLspLaunch { pub executable_path: String, pub arguments: Vec, pub runtime_executable_path: Option, + pub jdtls_launch_resources: Option, pub cache_directory: String, pub environment: JavaLspEnvironment, /// Workspace structure digest forwarded to the Rust core so JDT LS uses a @@ -46,6 +49,15 @@ pub struct JavaLspLaunch { pub workspace_fingerprint: Option, } +/// JDT LS installation resources forwarded to the shared direct-Java launcher. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JdtlsLaunchResources { + pub launcher_jar_path: String, + pub configuration_directory: String, + pub lombok_agent_path: String, +} + /// Environment values the Java language server needs from the host. #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] @@ -58,6 +70,14 @@ pub struct JavaLspEnvironment { struct JavaLspResolution { executable: PathBuf, java_home: PathBuf, + jdtls_launch_resources: Option, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +struct ResolvedJdtlsLaunchResources { + launcher_jar_path: PathBuf, + configuration_directory: PathBuf, + lombok_agent_path: PathBuf, } /// Resolves the built-in Java language-server executable, JDK, and cache directory. @@ -139,6 +159,13 @@ pub fn lsp_resolve_java_launch( runtime_executable_path: run::java_executable(&resolution.java_home) .as_deref() .map(normalize_path), + jdtls_launch_resources: resolution.jdtls_launch_resources.map(|resources| { + JdtlsLaunchResources { + launcher_jar_path: normalize_path(&resources.launcher_jar_path), + configuration_directory: normalize_path(&resources.configuration_directory), + lombok_agent_path: normalize_path(&resources.lombok_agent_path), + } + }), cache_directory: normalize_path(&cache_directory), environment: JavaLspEnvironment { java_home: Some(normalize_path(&resolution.java_home)), @@ -159,12 +186,99 @@ fn resolve_java_lsp_launch( .to_string() })?; let java_home = resolve_java_home(bundled_jdk)?; + let jdtls_launch_resources = match resolve_jdtls_launch_resources(&executable) { + Ok(resources) => Some(resources), + Err(error) if bundled_root.is_some_and(|root| executable.starts_with(root)) => { + return Err(format!( + "Bundled JDTLS direct-launch resources are incomplete. This is a packaging error; please reinstall Lithe. {error}" + )); + } + Err(_) => None, + }; Ok(JavaLspResolution { executable, java_home, + jdtls_launch_resources, }) } +fn resolve_jdtls_launch_resources( + executable: &Path, +) -> Result { + let mut roots = Vec::new(); + push_jdtls_installation_root(&mut roots, executable); + if let Ok(canonical) = std::fs::canonicalize(executable) { + push_jdtls_installation_root(&mut roots, &canonical); + } + let mut unique_roots = Vec::with_capacity(roots.len()); + for root in roots { + if !unique_roots.contains(&root) { + unique_roots.push(root); + } + } + + let mut inspected = Vec::new(); + for root in unique_roots { + inspected.push(root.clone()); + let plugins = root.join("plugins"); + let configuration_directory = root.join("config_win"); + let lombok_agent_path = root.join("lombok").join("lombok.jar"); + let Some(launcher_jar_path) = first_equinox_launcher(&plugins)? else { + continue; + }; + if configuration_directory.is_dir() && lombok_agent_path.is_file() { + return Ok(ResolvedJdtlsLaunchResources { + launcher_jar_path, + configuration_directory, + lombok_agent_path, + }); + } + } + + let roots = inspected + .iter() + .map(|root| root.display().to_string()) + .collect::>() + .join(", "); + Err(format!( + "Expected an Equinox launcher JAR, config_win, and lombok/lombok.jar under: {roots}" + )) +} + +fn first_equinox_launcher(plugins: &Path) -> Result, String> { + let entries = match std::fs::read_dir(plugins) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "Failed to inspect JDTLS plugins at {}: {error}", + plugins.display() + )); + } + }; + let mut launchers = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "Failed to inspect an entry in JDTLS plugins at {}: {error}", + plugins.display() + ) + })?; + let path = entry.path(); + let Some(name) = path.file_name().and_then(OsStr::to_str) else { + continue; + }; + if path.is_file() + && name.starts_with(EQUINOX_LAUNCHER_PLUGIN_PREFIX) + && name.ends_with(".jar") + { + launchers.push(path); + } + } + launchers.sort(); + Ok(launchers.into_iter().next()) +} + fn find_jdtls_executable( path_env: Option<&OsStr>, bundled_root: Option<&Path>, @@ -487,6 +601,26 @@ mod tests { path } + fn create_direct_launch_resources(root: &Path) -> ResolvedJdtlsLaunchResources { + let plugins = root.join("plugins"); + let configuration_directory = root.join("config_win"); + let lombok_directory = root.join("lombok"); + fs::create_dir_all(&plugins).expect("plugins"); + fs::create_dir_all(&configuration_directory).expect("config_win"); + fs::create_dir_all(&lombok_directory).expect("lombok"); + fs::write(plugins.join("org.eclipse.equinox.launcher_2.0.0.jar"), []) + .expect("second Equinox launcher"); + let launcher_jar_path = plugins.join("org.eclipse.equinox.launcher_1.0.0.jar"); + fs::write(&launcher_jar_path, []).expect("first Equinox launcher"); + let lombok_agent_path = lombok_directory.join("lombok.jar"); + fs::write(&lombok_agent_path, []).expect("Lombok agent"); + ResolvedJdtlsLaunchResources { + launcher_jar_path, + configuration_directory, + lombok_agent_path, + } + } + #[test] fn finds_jdtls_bat_in_an_extra_search_root() { let root = temp_dir(); @@ -559,6 +693,7 @@ mod tests { fs::create_dir_all(&jdk_bin).expect("jdk bin"); // Minimal JDTLS executable marker. fs::write(jdtls_bin.join("jdtls.bat"), "@echo off\n").expect("jdtls.bat"); + let expected_resources = create_direct_launch_resources(&root.join("jdtls")); // Minimal java.exe marker so is_jdk_home() returns true. fs::write(jdk_bin.join("java.exe"), "").expect("java.exe"); @@ -567,6 +702,57 @@ mod tests { let resolution = resolve_java_lsp_launch(None, Some(&jdtls_root), &[], Some(&bundled_jdk)) .expect("resolution"); assert_eq!(resolution.java_home, bundled_jdk, "bundled JDK should win"); + assert_eq!(resolution.jdtls_launch_resources, Some(expected_resources)); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn direct_launch_resources_use_the_first_lexical_equinox_jar() { + let root = temp_dir(); + let bin = root.join("bin"); + fs::create_dir_all(&bin).expect("bin"); + let executable = bin.join("jdtls.bat"); + fs::write(&executable, "@echo off\n").expect("jdtls.bat"); + let expected = create_direct_launch_resources(&root); + + assert_eq!( + resolve_jdtls_launch_resources(&executable).expect("direct launch resources"), + expected + ); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn incomplete_bundled_resources_are_reported_as_a_packaging_error() { + let root = temp_dir(); + let jdtls_root = root.join("jdtls"); + let bin = jdtls_root.join("bin"); + let jdk = root.join("jdk"); + fs::create_dir_all(&bin).expect("JDTLS bin"); + fs::create_dir_all(jdk.join("bin")).expect("JDK bin"); + fs::write(bin.join("jdtls.bat"), "@echo off\n").expect("jdtls.bat"); + fs::write(jdk.join("bin/java.exe"), []).expect("java.exe"); + + let error = resolve_java_lsp_launch(None, Some(&jdtls_root), &[], Some(&jdk)) + .expect_err("bundled resources must be complete"); + assert!(error.contains("packaging error"), "{error}"); + assert!(error.contains("Equinox"), "{error}"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn external_wrapper_without_direct_resources_remains_compatible() { + let root = temp_dir(); + let external = root.join("external-jdtls"); + let jdk = root.join("jdk"); + fs::create_dir_all(&external).expect("external JDTLS"); + fs::create_dir_all(jdk.join("bin")).expect("JDK bin"); + fs::write(external.join("jdtls.cmd"), "@echo off\n").expect("jdtls.cmd"); + fs::write(jdk.join("bin/java.exe"), []).expect("java.exe"); + + let resolution = resolve_java_lsp_launch(None, None, &[external], Some(&jdk)) + .expect("external wrapper fallback"); + assert!(resolution.jdtls_launch_resources.is_none()); fs::remove_dir_all(root).ok(); } 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 90980a328..bf08aa16d 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 @@ -1,11 +1,18 @@ import { invoke } from "@/platform/tauri-core"; +export interface JdtlsLaunchResources { + launcherJarPath: string; + configurationDirectory: string; + lombokAgentPath: string; +} + export interface JavaLspLaunch { providerId: string; languageId: string; executablePath: string; arguments: string[]; runtimeExecutablePath?: string | null; + jdtlsLaunchResources?: JdtlsLaunchResources | null; cacheDirectory: string; environment: { JAVA_HOME?: string; diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 8b9f06e2b..89f07cbe3 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -601,6 +601,7 @@ export class LspClient { tools: launch.tools || null, initializationOptions: launch.initializationOptions || null, runtimeExecutablePath: launch.runtimeExecutablePath || null, + jdtlsLaunchResources: launch.jdtlsLaunchResources || null, cacheDirectory: launch.cacheDirectory || null, environment: launch.environment || null, workspaceFingerprint: launch.workspaceFingerprint || null, @@ -828,6 +829,7 @@ export class LspClient { tools: launch.tools || null, initializationOptions: launch.initializationOptions || null, runtimeExecutablePath: launch.runtimeExecutablePath || null, + jdtlsLaunchResources: launch.jdtlsLaunchResources || null, cacheDirectory: launch.cacheDirectory || null, environment: launch.environment || null, workspaceFingerprint: launch.workspaceFingerprint || null, 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 f8c291498..99be53ae4 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,9 @@ 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 { + resolveJavaLspLaunch, + type JdtlsLaunchResources, +} from "./java-lsp-host-api"; export interface EditorLspLaunch { providerId: string; @@ -10,6 +13,7 @@ export interface EditorLspLaunch { initializationOptions?: Record; tools?: BackendLanguageToolConfigSet; runtimeExecutablePath?: string | null; + jdtlsLaunchResources?: JdtlsLaunchResources | null; cacheDirectory?: string; environment?: Record; /** Workspace structure digest forwarded to the Rust core. */ @@ -32,6 +36,7 @@ export async function resolveEditorLspLaunch( serverPath: launch.executablePath, serverArgs: launch.arguments ?? [], runtimeExecutablePath: launch.runtimeExecutablePath, + jdtlsLaunchResources: launch.jdtlsLaunchResources, cacheDirectory: launch.cacheDirectory, environment, workspaceFingerprint: launch.workspaceFingerprint, diff --git a/windows/tauri/src/platform/lsp-core-adapter.test.ts b/windows/tauri/src/platform/lsp-core-adapter.test.ts index 6ca351e03..3a1254ef1 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.test.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.test.ts @@ -365,6 +365,12 @@ describe("Rust Core LSP adapter failures", () => { languageId: "java", providerId: "java", serverPath: "C:/Lithe/jdtls.bat", + runtimeExecutablePath: "C:/Lithe/jdk/bin/java.exe", + jdtlsLaunchResources: { + launcherJarPath: "C:/Lithe/jdtls/plugins/equinox.jar", + configurationDirectory: "C:/Lithe/jdtls/config_win", + lombokAgentPath: "C:/Lithe/jdtls/lombok/lombok.jar", + }, }); } catch (error) { failure = error as Error & { code?: string; details?: string }; @@ -377,6 +383,12 @@ describe("Rust Core LSP adapter failures", () => { expect(failure?.code).toBe("serverExited"); expect(failure?.details).toBe("JVM startup failed; exit code 13"); expect(startPayload?.initializeTimeoutMilliseconds).toBe(30_000); + expect(startPayload?.runtimeExecutablePath).toBe("C:/Lithe/jdk/bin/java.exe"); + expect(startPayload?.jdtlsLaunchResources).toEqual({ + launcherJarPath: "C:/Lithe/jdtls/plugins/equinox.jar", + configurationDirectory: "C:/Lithe/jdtls/config_win", + lombokAgentPath: "C:/Lithe/jdtls/lombok/lombok.jar", + }); expect(frontendTrace).toHaveBeenCalledWith( "warn", "lsp.runtime", diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index 2dff42d56..5d72e923e 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -672,6 +672,7 @@ async function createSession(args: JsonRecord, key: string): Promise { workingDirectory: workspacePath, initializationOptions: args.initializationOptions ?? null, runtimeExecutablePath: args.runtimeExecutablePath ?? null, + jdtlsLaunchResources: args.jdtlsLaunchResources ?? null, cacheDirectory: args.cacheDirectory ?? null, workspaceFingerprint: args.workspaceFingerprint ?? null, initializeTimeoutMilliseconds: INITIALIZE_TIMEOUT_MS,