Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ private final class GoTestLanguageServerRuntimeCore: LanguageServerRuntimeCore,
workingDirectoryURL _: URL,
initializationOptions _: ToolingJSONValue?,
runtimeExecutableURL _: URL?,
jdtlsLaunchResources _: JDTLSLaunchResources?,
cacheDirectoryURL _: URL?,
workspaceFingerprint _: String?,
initializeTimeout _: TimeInterval,
Expand Down
6 changes: 3 additions & 3 deletions docs/architecture/language-tooling.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和

macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE_<TOOL>_PATH`/`LITHE_TOOL_<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 不执行文件系统操作。

Expand Down Expand Up @@ -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。
Expand Down
15 changes: 15 additions & 0 deletions macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1400,13 +1400,20 @@ struct RustCoreBridge: Sendable {
let workingDirectory: String
let initializationOptions: ToolingJSONValue?
let runtimeExecutablePath: String?
let jdtlsLaunchResources: LspJdtlsLaunchResourcesRequest?
let cacheDirectory: String?
let workspaceFingerprint: String?
let initializeTimeoutMilliseconds: Int
let requestTimeoutMilliseconds: Int
let shutdownTimeoutMilliseconds: Int
}

private struct LspJdtlsLaunchResourcesRequest: Encodable {
let launcherJarPath: String
let configurationDirectory: String
let lombokAgentPath: String
}

private struct LspSessionIdentifierRequest: Encodable {
let sessionId: String
}
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ extension RustCoreBridge: LanguageServerRuntimeCore {
workingDirectoryURL: URL,
initializationOptions: ToolingJSONValue?,
runtimeExecutableURL: URL?,
jdtlsLaunchResources: JDTLSLaunchResources?,
cacheDirectoryURL: URL?,
workspaceFingerprint: String?,
initializeTimeout: TimeInterval,
Expand All @@ -27,6 +28,7 @@ extension RustCoreBridge: LanguageServerRuntimeCore {
workingDirectoryURL: workingDirectoryURL,
initializationOptions: initializationOptions,
runtimeExecutableURL: runtimeExecutableURL,
jdtlsLaunchResources: jdtlsLaunchResources,
cacheDirectoryURL: cacheDirectoryURL,
workspaceFingerprint: workspaceFingerprint,
initializeTimeout: initializeTimeout,
Expand Down
12 changes: 12 additions & 0 deletions macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ final class MacServiceContainer {
core: rustCore,
cacheDirectoryURL: languageServerCacheDirectory
)
let jdtlsLaunchResourceResolver = MacJDTLSLaunchResourceResolver()
let runtimeFactory = StdioLanguageProviderRuntimeFactory(
runtimeService: runtimeService,
languageServerCore: rustCore,
Expand All @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -171,6 +187,7 @@ package protocol LanguageServerRuntimeCore: Sendable {
workingDirectoryURL: URL,
initializationOptions: ToolingJSONValue?,
runtimeExecutableURL: URL?,
jdtlsLaunchResources: JDTLSLaunchResources?,
cacheDirectoryURL: URL?,
workspaceFingerprint: String?,
initializeTimeout: TimeInterval,
Expand Down
Loading
Loading