diff --git a/README.md b/README.md
index 5477f8b71..29a8686d1 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,7 @@
## About Lithe
-Lithe is a native macOS IDE built for AI-assisted development. It preserves the project browsing, editing, search, code navigation, Git, run, and debug workflows familiar to IntelliJ IDEA users while starting Java language services, terminals, Maven, and debug processes only when needed.
+Lithe is a native macOS IDE built for AI-assisted development. It preserves the project browsing, editing, search, code navigation, Git, run, and debug workflows familiar to IntelliJ IDEA users while starting language servers, terminals, Maven, and debug processes only when needed.
When an external AI tool changes a project, Lithe helps you locate the affected code, run the project, review the diff, and decide which changes to stage, undo, or commit.
@@ -61,7 +61,7 @@ When an external AI tool changes a project, Lithe helps you locate the affected
2. Maven management, breakpoint debugging, and custom run configurations.
3. Git management and side-by-side diff review.
4. Double-Shift search and `Command + Shift + F` project-wide search.
-5. Code navigation and reference lookup.
+5. Process-free lightweight completion and current-file navigation, with on-demand language servers for richer completion, hover, and semantic navigation.
6. Local snapshot history.
7. Multiple projects open within the app.
8. Multiple files open independently in the same window.
@@ -104,7 +104,7 @@ When an external AI tool changes a project, Lithe helps you locate the affected
## Use Lithe
-Lithe requires macOS 14 or later. Java project features require a JDK; JDK 17 or JDK 21 is recommended. Semantic navigation requires Eclipse JDT LS. Maven projects need either a project `mvnw` or a system Maven installation.
+Lithe requires macOS 14 or later. Java project features require a JDK; JDK 17 or JDK 21 is recommended. Maven projects need either a project `mvnw` or a system Maven installation. Lightweight completion does not start an external process; when a matching language server is installed, Lithe routes the capabilities it actually advertises through the shared Rust LSP Core. See the [language tooling and LSP architecture](./docs/architecture/language-tooling.md) for provider configuration and compatibility details.
Download the latest macOS `.dmg` from [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest). If a release provides architecture-specific installers, choose `arm64` for Apple silicon or `x86_64` for an Intel Mac. Open the disk image, drag `Lithe.app` into `/Applications`, and launch it.
@@ -129,12 +129,6 @@ xattr -dr com.apple.quarantine "/Applications/Lithe.app"
open "/Applications/Lithe.app"
```
-Install JDT LS with Homebrew:
-
-```bash
-brew install jdtls
-```
-
After opening a project, use **Settings → Project** to configure the project JDK, Maven, and the JDK used by Maven. Lithe also detects Java and Maven from common system locations.
## Architecture
@@ -268,12 +262,10 @@ Lithe is licensed under the [Apache License 2.0](./LICENSE).
## Star History
+Each point shows the repository's cumulative Star count at `00:00` Beijing time on that date. The chart starts at zero on August 2, 2026.
+
-
-
-
-
-
+
## Contact us
diff --git a/README.zh-CN.md b/README.zh-CN.md
index c0f885747..f8e7f0212 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -49,7 +49,7 @@
## 项目简介
-Lithe 是一款面向 AI 辅助开发的原生 macOS IDE。它保留 IntelliJ IDEA 用户熟悉的项目浏览、编辑、搜索、代码导航、Git、运行和调试工作流,同时让 Java 语言服务、终端、Maven 和调试进程只在需要时启动。
+Lithe 是一款面向 AI 辅助开发的原生 macOS IDE。它保留 IntelliJ IDEA 用户熟悉的项目浏览、编辑、搜索、代码导航、Git、运行和调试工作流,同时让语言服务器、终端、Maven 和调试进程只在需要时启动。
当外部 AI 工具修改项目后,你可以用 Lithe 定位受影响的代码、运行项目、审查 Diff,并决定暂存、撤销或提交哪些修改。
@@ -61,7 +61,7 @@ Lithe 是一款面向 AI 辅助开发的原生 macOS IDE。它保留 IntelliJ ID
2. 支持 Maven 管理、断点调试和自定义启动配置。
3. 支持 Git 管理和 Diff 审查。
4. 支持双击 Shift 搜索,以及 `Command + Shift + F` 全局搜索。
-5. 支持代码跳转和代码引用查找。
+5. 支持无需 LSP 的轻量补全和当前文件导航,并可按需使用语言服务器增强补全、悬浮与语义导航。
6. 支持本地快照保存。
7. 支持在应用内打开多个项目。
8. 支持在同一窗口打开多个文件,各文件相互独立。
@@ -104,7 +104,7 @@ Lithe 是一款面向 AI 辅助开发的原生 macOS IDE。它保留 IntelliJ ID
## 如何使用
-Lithe 需要 macOS 14 或更高版本。Java 项目功能需要 JDK,推荐使用 JDK 17 或 JDK 21;语义导航需要 Eclipse JDT LS;Maven 项目需要项目自带 `mvnw` 或系统中可用的 Maven。
+Lithe 需要 macOS 14 或更高版本。Java 项目功能需要 JDK,推荐使用 JDK 17 或 JDK 21;Maven 项目需要项目自带 `mvnw` 或系统中可用的 Maven。轻量补全无需启动外部进程;安装相应语言服务器后,Lithe 会通过共享 Rust LSP Core 按服务器实际声明的能力提供语义功能。详细设计与自定义 provider 配置见[语言工具与 LSP 架构](./docs/architecture/language-tooling.md)。
从 [GitHub Releases](https://github.com/1lck/Lithe-IDEA/releases/latest) 下载最新的 macOS `.dmg`。如果该版本提供独立架构安装包,M 系列芯片选择 `arm64`,Intel 芯片选择 `x86_64`。打开磁盘映像,将 `Lithe.app` 拖入 `/Applications` 后启动。
@@ -129,12 +129,6 @@ xattr -dr com.apple.quarantine "/Applications/Lithe.app"
open "/Applications/Lithe.app"
```
-使用 Homebrew 安装 JDT LS:
-
-```bash
-brew install jdtls
-```
-
打开项目后,在 **Settings → Project** 中配置 Project JDK、Maven 和 Maven 使用的 JDK。Lithe 也会从常见的系统位置自动探测 Java 与 Maven。
## 架构图
@@ -268,12 +262,10 @@ Lithe 采用 [Apache License 2.0](./LICENSE) 授权。
## Star History
+每个日期点表示北京时间当天 `00:00` 时仓库的累计 Star 数。图表从 2026 年 8 月 2 日的 0 开始。
+
-
-
-
-
-
+
## 联系我们
diff --git a/Sources/Lithe/Application/AppServices.swift b/Sources/Lithe/Application/AppServices.swift
index d15c5aa51..33853b097 100644
--- a/Sources/Lithe/Application/AppServices.swift
+++ b/Sources/Lithe/Application/AppServices.swift
@@ -16,10 +16,15 @@ final class AppServices {
/// registries remain exposed below for source compatibility with existing
/// feature models while new composition should use this value.
let languagePacks: LanguagePackRegistry
+ let languageProviderCatalogSource: any LanguageProviderCatalogSource
+ /// Initial catalog load outcome, including whether startup fell back to a
+ /// compatibility catalog or rejected a workspace override.
+ let languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot
/// Metadata-only provider catalog; providers are activated on demand.
let languageProviderCatalog: LanguageProviderCatalog
let runToolchainRegistry: RunToolchainRegistry
let languageToolingSessions: LanguageToolingSessionManager
+ let languageServerTools: LanguageServerToolService
let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver
let languageTestService: LanguageTestService
let workspaceOperations: any WorkspaceOperations
@@ -31,8 +36,6 @@ final class AppServices {
let fileStorage: any FileStorage
let fileOperations: any WorkspaceFileOperations
let projectRuntimeService: ProjectRuntimeService
- let javaLanguageService: JavaLanguageService
- let javaImplementationMarkerService: JavaImplementationMarkerService
let mavenService: MavenService
let runService: RunService
let javaDebugService: JavaDebugService
@@ -51,10 +54,12 @@ final class AppServices {
let shortcutDetectorFactory: any ShortcutDetectorFactory
init(
- languageProviderCatalog: LanguageProviderCatalog = .standard,
+ languageProviderCatalogSource: any LanguageProviderCatalogSource,
+ languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot? = nil,
languagePacks: LanguagePackRegistry? = nil,
runToolchainRegistry: RunToolchainRegistry? = nil,
languageToolingSessions: LanguageToolingSessionManager? = nil,
+ languageServerTools: LanguageServerToolService,
debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver? = nil,
languageTestService: LanguageTestService,
workspaceOperations: any WorkspaceOperations,
@@ -66,8 +71,6 @@ final class AppServices {
fileStorage: any FileStorage,
fileOperations: any WorkspaceFileOperations,
projectRuntimeService: ProjectRuntimeService,
- javaLanguageService: JavaLanguageService,
- javaImplementationMarkerService: JavaImplementationMarkerService,
mavenService: MavenService,
runService: RunService,
javaDebugService: JavaDebugService,
@@ -85,14 +88,13 @@ final class AppServices {
platformUI: any PlatformUI,
shortcutDetectorFactory: any ShortcutDetectorFactory
) {
+ self.languageProviderCatalogSource = languageProviderCatalogSource
+ let resolvedCatalogSnapshot = languageProviderCatalogSnapshot
+ ?? languageProviderCatalogSource.load(workspaceURL: nil)
+ self.languageProviderCatalogSnapshot = resolvedCatalogSnapshot
+ let resolvedCatalog = resolvedCatalogSnapshot.catalog
let resolvedLanguagePacks = languagePacks ?? LanguagePackRegistry.standard(
- catalog: languageProviderCatalog,
- runtimes: [
- JavaLanguageProviderRuntime(
- service: javaLanguageService,
- catalog: languageProviderCatalog
- )
- ]
+ catalog: resolvedCatalog
)
self.languagePacks = resolvedLanguagePacks
self.languageProviderCatalog = resolvedLanguagePacks.catalog
@@ -100,6 +102,7 @@ final class AppServices {
self.languageToolingSessions = languageToolingSessions ?? LanguageToolingSessionManager(
registry: resolvedLanguagePacks
)
+ self.languageServerTools = languageServerTools
self.debugLaunchConfigurationResolver = debugLaunchConfigurationResolver
?? DebugLaunchConfigurationResolver(fileStorage: fileStorage)
self.languageTestService = languageTestService
@@ -112,8 +115,6 @@ final class AppServices {
self.fileStorage = fileStorage
self.fileOperations = fileOperations
self.projectRuntimeService = projectRuntimeService
- self.javaLanguageService = javaLanguageService
- self.javaImplementationMarkerService = javaImplementationMarkerService
self.mavenService = mavenService
self.runService = runService
self.javaDebugService = javaDebugService
diff --git a/Sources/Lithe/Application/JavaFeatureModel.swift b/Sources/Lithe/Application/JavaFeatureModel.swift
index d754a9753..0bab15582 100644
--- a/Sources/Lithe/Application/JavaFeatureModel.swift
+++ b/Sources/Lithe/Application/JavaFeatureModel.swift
@@ -1,17 +1,14 @@
import Combine
import Foundation
-/// Owns Java-only code vision, inlay hints, Maven integration, and legacy Java
-/// debug behavior. Shared LSP navigation and editing live in the generic
-/// language-tooling pipeline.
+/// Owns Java-only code vision, fallback inlay hints, Maven integration, and
+/// legacy Java debug behavior. Java LSP navigation and editing are delegated
+/// to the Rust host.
@MainActor
final class JavaFeatureModel: ObservableObject {
- @Published private(set) var javaDiagnostics: [URL: [JavaDiagnostic]] = [:]
@Published private(set) var javaCodeVisionHints: [URL: [JavaCodeVisionHint]] = [:]
@Published private(set) var javaInlayHints: [URL: [JavaInlayHint]] = [:]
- private let service: JavaLanguageService
- private let markerService: JavaImplementationMarkerService
private let operations: any JavaMavenOperations
private let workspaceOperations: any WorkspaceOperations
private var documentProvider: (@MainActor () -> EditorDocument?)?
@@ -23,18 +20,11 @@ final class JavaFeatureModel: ObservableObject {
private var debugFeature: JavaDebugFeatureModel?
init(
- service: JavaLanguageService,
- markerService: JavaImplementationMarkerService,
operations: any JavaMavenOperations,
workspaceOperations: any WorkspaceOperations
) {
- self.service = service
- self.markerService = markerService
self.operations = operations
self.workspaceOperations = workspaceOperations
- service.onDiagnostics = { [weak self] fileURL, diagnostics in
- self?.javaDiagnostics[fileURL.standardizedFileURL] = diagnostics
- }
}
func configure(
@@ -57,8 +47,6 @@ final class JavaFeatureModel: ObservableObject {
self.debugFeature = debugFeature
}
- var statusMessage: String { service.statusMessage }
-
/// Explicit boundary for Java-only editor adornments and legacy services.
/// Callers can avoid scheduling Java work for every supported language.
func handles(fileURL: URL) -> Bool {
@@ -69,15 +57,9 @@ final class JavaFeatureModel: ObservableObject {
handles(fileURL: fileURL)
}
- func configureProjectRoot(_ url: URL) {
- service.configureProjectRoot(url)
- }
-
func stop() {
inlayHintTasks.values.forEach { $0.cancel() }
inlayHintTasks.removeAll()
- service.stop()
- javaDiagnostics = [:]
javaCodeVisionHints = [:]
javaInlayHints = [:]
}
@@ -163,10 +145,8 @@ final class JavaFeatureModel: ObservableObject {
}
func close(_ document: EditorDocument) {
- javaDiagnostics[document.url.standardizedFileURL] = nil
javaCodeVisionHints[document.url.standardizedFileURL] = nil
javaInlayHints[document.url.standardizedFileURL] = nil
- markerService.invalidate(document)
}
func refreshCodeVision(
@@ -184,10 +164,9 @@ final class JavaFeatureModel: ObservableObject {
blameLines: blame
)
let candidates = structure(source: document.text)?.implementationMarkers ?? []
- let markers = await implementationMarkers(for: document, candidates: candidates)
- let implementationCounts = Dictionary(
- uniqueKeysWithValues: markers.map { ($0.line, $0.implementationCount) }
- )
+ let implementationCounts = candidates.reduce(into: [Int: Int]()) { counts, marker in
+ counts[marker.line] = max(counts[marker.line] ?? 0, marker.implementationCount)
+ }
guard documentProvider?()?.id == document.id else { return }
javaCodeVisionHints[normalizedURL] = baseHints.map { hint in
JavaCodeVisionHint(
@@ -214,8 +193,7 @@ final class JavaFeatureModel: ObservableObject {
await self.requestInlayHints(
for: document,
projectFiles: projectFiles,
- workspaceRoot: workspaceRoot,
- attempt: 0
+ workspaceRoot: workspaceRoot
)
self.inlayHintTasks[document.id] = nil
}
@@ -224,50 +202,15 @@ final class JavaFeatureModel: ObservableObject {
private func requestInlayHints(
for document: EditorDocument,
projectFiles: [URL],
- workspaceRoot: URL?,
- attempt: Int
+ workspaceRoot: URL?
) async {
guard !Task.isCancelled,
documentProvider?()?.id == document.id else { return }
- await withCheckedContinuation { continuation in
- inlayHints(for: document) { [weak self] result in
- guard let self else {
- continuation.resume()
- return
- }
- if case .success(let hints) = result {
- self.javaInlayHints[document.url.standardizedFileURL] = hints
- Task { @MainActor [weak self, weak document] in
- guard let self, let document else {
- continuation.resume()
- return
- }
- if hints.isEmpty, attempt < 3 {
- try? await Task.sleep(for: .milliseconds(900 * (attempt + 1)))
- guard !Task.isCancelled else {
- continuation.resume()
- return
- }
- await self.requestInlayHints(
- for: document,
- projectFiles: projectFiles,
- workspaceRoot: workspaceRoot,
- attempt: attempt + 1
- )
- } else if hints.isEmpty {
- await self.applyInlayFallback(
- for: document,
- projectFiles: projectFiles,
- workspaceRoot: workspaceRoot
- )
- }
- continuation.resume()
- }
- } else {
- continuation.resume()
- }
- }
- }
+ await applyInlayFallback(
+ for: document,
+ projectFiles: projectFiles,
+ workspaceRoot: workspaceRoot
+ )
}
private func applyInlayFallback(
@@ -302,13 +245,6 @@ final class JavaFeatureModel: ObservableObject {
return String(path.dropFirst(rootPath.count + 1))
}
- func implementationMarkers(
- for document: EditorDocument,
- candidates: [JavaImplementationMarker]
- ) async -> [JavaImplementationMarker] {
- await markerService.markers(for: document, candidates: candidates)
- }
-
func structure(source: String, declarationSources: [String] = []) -> JavaStructureResult? {
operations.structure(source: source, declarationSources: declarationSources)
}
@@ -328,11 +264,4 @@ final class JavaFeatureModel: ObservableObject {
)
}
- func inlayHints(
- for document: EditorDocument,
- completion: @escaping (Result<[JavaInlayHint], Error>) -> Void
- ) {
- service.inlayHints(document: document, completion: completion)
- }
-
}
diff --git a/Sources/Lithe/Application/LSPControlCenterPresentation.swift b/Sources/Lithe/Application/LSPControlCenterPresentation.swift
new file mode 100644
index 000000000..96d72f8c6
--- /dev/null
+++ b/Sources/Lithe/Application/LSPControlCenterPresentation.swift
@@ -0,0 +1,72 @@
+import Foundation
+
+enum LSPServerStatus: Equatable, Sendable {
+ case starting
+ case initializing
+ case active
+ case stopping
+ case stopped
+ case disabled
+ case error
+}
+
+enum LSPCapabilityPresentationState: Equatable, Sendable {
+ case unknown
+ case unsupported
+ case available
+ case active
+}
+
+enum LSPControlCenterPresenter {
+ static func serverStatus(
+ isDisabled: Bool,
+ sessionState: LanguageServerSessionState?
+ ) -> LSPServerStatus {
+ if isDisabled {
+ return .disabled
+ }
+
+ switch sessionState {
+ case .startingProcess:
+ return .starting
+ case .initializing:
+ return .initializing
+ case .ready:
+ return .active
+ case .stopping:
+ return .stopping
+ case .stopped, nil:
+ return .stopped
+ case .failed:
+ return .error
+ }
+ }
+
+ static func negotiatedCapabilityState(
+ _ feature: LanguageServerFeatureSet,
+ sessionState: LanguageServerSessionState?,
+ features: LanguageServerFeatureSet?
+ ) -> LSPCapabilityPresentationState {
+ guard sessionState == .ready else {
+ return .unknown
+ }
+ return features?.contains(feature) == true ? .available : .unsupported
+ }
+
+ static func reportedServerVersion(_ serverInfo: LanguageServerInfo?) -> String? {
+ guard let version = serverInfo?.version?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !version.isEmpty else { return nil }
+ return version
+ }
+
+ static func integrationState(
+ isAvailable: Bool,
+ isActive: Bool = false
+ ) -> LSPCapabilityPresentationState {
+ guard isAvailable else {
+ return .unsupported
+ }
+ return isActive ? .active : .available
+ }
+}
diff --git a/Sources/Lithe/Application/UIFeatureModels.swift b/Sources/Lithe/Application/UIFeatureModels.swift
index 8bcf07b69..1bec16d3e 100644
--- a/Sources/Lithe/Application/UIFeatureModels.swift
+++ b/Sources/Lithe/Application/UIFeatureModels.swift
@@ -354,4 +354,10 @@ final class RuntimeSettingsFeatureModel: ObservableObject {
func mavenExecutable(for project: MavenProject) -> URL? {
service.mavenExecutable(for: project)
}
+ func executableCandidates(_ command: String) -> [RuntimeToolCandidate] {
+ service.executableCandidates(command)
+ }
+ func toolGuidance(_ command: String) -> RuntimeToolGuidance {
+ service.toolGuidance(command)
+ }
}
diff --git a/Sources/Lithe/Core/Ports/ArchiveEntryReader.swift b/Sources/Lithe/Core/Ports/ArchiveEntryReader.swift
deleted file mode 100644
index df792fadc..000000000
--- a/Sources/Lithe/Core/Ports/ArchiveEntryReader.swift
+++ /dev/null
@@ -1,5 +0,0 @@
-import Foundation
-
-protocol ArchiveEntryReader: Sendable {
- func read(entry: String, from archive: URL) -> String?
-}
diff --git a/Sources/Lithe/Core/Ports/LanguagePacks.swift b/Sources/Lithe/Core/Ports/LanguagePacks.swift
index 0867e228f..7bf6815e7 100644
--- a/Sources/Lithe/Core/Ports/LanguagePacks.swift
+++ b/Sources/Lithe/Core/Ports/LanguagePacks.swift
@@ -1,13 +1,5 @@
import Foundation
-/// Process launch metadata shared by the platform runtimes. These values are
-/// intentionally just names and arguments; resolving an executable and
-/// starting a process remains a platform responsibility.
-struct StdioLanguageServerLaunch: Sendable, Equatable {
- let executableNames: [String]
- let arguments: [String]
-}
-
struct StdioDebugAdapterLaunch: Sendable, Equatable {
struct Fallback: Sendable, Equatable {
let executableName: String
@@ -42,7 +34,6 @@ struct LanguagePack {
let descriptor: LanguageProviderDescriptor
let runProvider: (any LanguageRunProvider)?
let toolchainProviders: [any RunToolchainProvider]
- let languageServerLaunch: StdioLanguageServerLaunch?
let debugAdapterLaunch: StdioDebugAdapterLaunch?
let toolingRuntime: (any LanguageProviderRuntime)?
let testProviders: [any LanguageTestProvider]
@@ -51,7 +42,6 @@ struct LanguagePack {
descriptor: LanguageProviderDescriptor,
runProvider: (any LanguageRunProvider)? = nil,
toolchainProviders: [any RunToolchainProvider] = [],
- languageServerLaunch: StdioLanguageServerLaunch? = nil,
debugAdapterLaunch: StdioDebugAdapterLaunch? = nil,
toolingRuntime: (any LanguageProviderRuntime)? = nil,
testProviders: [any LanguageTestProvider] = []
@@ -59,7 +49,6 @@ struct LanguagePack {
self.descriptor = descriptor
self.runProvider = runProvider
self.toolchainProviders = toolchainProviders
- self.languageServerLaunch = languageServerLaunch
self.debugAdapterLaunch = debugAdapterLaunch
self.toolingRuntime = toolingRuntime
self.testProviders = testProviders
@@ -70,7 +59,6 @@ struct LanguagePack {
descriptor: descriptor,
runProvider: runProvider,
toolchainProviders: toolchainProviders,
- languageServerLaunch: languageServerLaunch,
debugAdapterLaunch: debugAdapterLaunch,
toolingRuntime: runtime,
testProviders: testProviders
diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift
index 726ad3de2..31ad85570 100644
--- a/Sources/Lithe/Core/Ports/LanguageTooling.swift
+++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift
@@ -8,6 +8,25 @@ struct LanguageToolingCapability: OptionSet, Hashable, Sendable {
static let debugAdapter = Self(rawValue: 1 << 2)
static let formatting = Self(rawValue: 1 << 3)
static let testing = Self(rawValue: 1 << 4)
+
+ static func named(_ name: String) -> Self? {
+ switch name {
+ case "run": .run
+ case "languageServer": .languageServer
+ case "debugAdapter": .debugAdapter
+ case "formatting": .formatting
+ case "testing": .testing
+ default: nil
+ }
+ }
+
+ static func names(_ names: [String]) -> Self {
+ names.reduce(into: Self()) { capabilities, name in
+ if let capability = Self.named(name) {
+ capabilities.insert(capability)
+ }
+ }
+ }
}
struct LanguageServerFeatureSet: OptionSet, Hashable, Sendable {
@@ -33,46 +52,112 @@ struct LanguageServerFeatureSet: OptionSet, Hashable, Sendable {
}
enum ToolingActivationPolicy: String, Codable, Hashable, Sendable {
- /// Descriptor-only. No runtime process is created until a file or command
- /// explicitly asks for this provider.
case onDemand
case always
}
+struct LanguageServerLaunchDescriptor: Hashable, Sendable {
+ let executableNames: [String]
+ let arguments: [String]
+ let validationArguments: [String]
+ let environment: [String: String]
+ let initializationOptions: ToolingJSONValue?
+
+ init(
+ executableNames: [String],
+ arguments: [String] = [],
+ validationArguments: [String] = [],
+ environment: [String: String] = [:],
+ initializationOptions: ToolingJSONValue? = nil
+ ) {
+ self.executableNames = executableNames
+ self.arguments = arguments
+ self.validationArguments = validationArguments
+ self.environment = environment
+ self.initializationOptions = initializationOptions
+ }
+}
+
+struct LanguageServerInstallationDescriptor: Hashable, Sendable {
+ let homebrewFormula: String?
+ let officialDownloadURL: URL?
+}
+
struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable {
let id: String
let displayName: String
let fileExtensions: Set
+ let fileNames: Set
+ let fileNamePrefixes: Set
let capabilities: LanguageToolingCapability
let activationPolicy: ToolingActivationPolicy
+ let languageIdentifier: String?
+ let languageIdentifiersByExtension: [String: String]
+ let languageIdentifiersByFileName: [String: String]
+ let languageServerLaunch: LanguageServerLaunchDescriptor?
+ let languageServerInstallation: LanguageServerInstallationDescriptor?
+
+ init(
+ id: String,
+ displayName: String,
+ fileExtensions: Set,
+ fileNames: Set = [],
+ fileNamePrefixes: Set = [],
+ capabilities: LanguageToolingCapability,
+ activationPolicy: ToolingActivationPolicy,
+ languageIdentifier: String? = nil,
+ languageIdentifiersByExtension: [String: String] = [:],
+ languageIdentifiersByFileName: [String: String] = [:],
+ languageServerLaunch: LanguageServerLaunchDescriptor? = nil,
+ languageServerInstallation: LanguageServerInstallationDescriptor? = nil
+ ) {
+ self.id = id
+ self.displayName = displayName
+ self.fileExtensions = Set(fileExtensions.map { $0.lowercased() })
+ self.fileNames = Set(fileNames.map { $0.lowercased() })
+ self.fileNamePrefixes = Set(fileNamePrefixes.map { $0.lowercased() })
+ self.capabilities = capabilities
+ self.activationPolicy = activationPolicy
+ self.languageIdentifier = languageIdentifier
+ self.languageIdentifiersByExtension = Dictionary(
+ uniqueKeysWithValues: languageIdentifiersByExtension.map {
+ ($0.key.lowercased(), $0.value)
+ }
+ )
+ self.languageIdentifiersByFileName = Dictionary(
+ uniqueKeysWithValues: languageIdentifiersByFileName.map {
+ ($0.key.lowercased(), $0.value)
+ }
+ )
+ self.languageServerLaunch = languageServerLaunch
+ self.languageServerInstallation = languageServerInstallation
+ }
func handles(fileURL: URL) -> Bool {
- fileExtensions.contains(fileURL.pathExtension.lowercased())
+ let fileName = fileURL.lastPathComponent.lowercased()
+ return fileExtensions.contains(fileURL.pathExtension.lowercased())
+ || fileNames.contains(fileName)
+ || fileNamePrefixes.contains { fileName.hasPrefix($0) }
}
func languageIdentifier(for fileURL: URL) -> String {
- switch (id, fileURL.pathExtension.lowercased()) {
- case ("node", "ts"): "typescript"
- case ("node", "tsx"): "typescriptreact"
- case ("node", "jsx"): "javascriptreact"
- case ("node", _): "javascript"
- default: id
- }
+ let extensionName = fileURL.pathExtension.lowercased()
+ let fileName = fileURL.lastPathComponent.lowercased()
+ return languageIdentifiersByFileName[fileName]
+ ?? languageIdentifiersByExtension[extensionName]
+ ?? languageIdentifier
+ ?? id
}
}
-/// The catalog is metadata only. Concrete LSP and DAP sessions are injected
-/// by a platform/provider adapter when a capability is used.
struct LanguageProviderCatalog: Sendable {
let descriptors: [LanguageProviderDescriptor]
- static let standard = LanguageProviderCatalog(descriptors: [
+ /// Minimal fallback used only when the Rust core is not linked. The full
+ /// market language catalog is registered by Rust's dedicated LSP config.
+ static let compatibilityFallback = LanguageProviderCatalog(descriptors: [
LanguageProviderDescriptor(
id: "java", displayName: "Java", fileExtensions: ["java"],
- // Java debugging still uses the legacy JDB integration. Keep it
- // out of the generic DAP capability until a real Java DAP runtime
- // is injected, so metadata cannot promise a session that does not
- // exist.
capabilities: [.run, .languageServer, .formatting, .testing],
activationPolicy: .onDemand
),
@@ -89,13 +174,19 @@ struct LanguageProviderCatalog: Sendable {
LanguageProviderDescriptor(
id: "node", displayName: "Node.js", fileExtensions: ["js", "jsx", "ts", "tsx", "mjs", "cjs"],
capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing],
- activationPolicy: .onDemand
+ activationPolicy: .onDemand,
+ languageIdentifier: "javascript",
+ languageIdentifiersByExtension: [
+ "ts": "typescript",
+ "tsx": "typescriptreact",
+ "jsx": "javascriptreact"
+ ]
),
LanguageProviderDescriptor(
id: "rust", displayName: "Rust", fileExtensions: ["rs"],
capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing],
activationPolicy: .onDemand
- )
+ ),
])
func provider(for fileURL: URL) -> LanguageProviderDescriptor? {
@@ -103,24 +194,6 @@ struct LanguageProviderCatalog: Sendable {
}
}
-@MainActor
-protocol LanguageServerSession: AnyObject {
- var isRunning: Bool { get }
- var isReady: Bool { get }
- func start(rootURL: URL) throws
- func stop()
-}
-
-@MainActor
-protocol LanguageServerFeatureReportingSession: LanguageServerSession {
- var supportedFeatures: LanguageServerFeatureSet { get }
- var onSupportedFeaturesChange: ((LanguageServerFeatureSet) -> Void)? { get set }
-}
-
-extension LanguageServerSession {
- var isReady: Bool { isRunning }
-}
-
struct LanguageServerPosition: Equatable, Sendable {
let line: Int
let utf16Column: Int
@@ -131,12 +204,38 @@ struct LanguageServerRange: Equatable, Sendable {
let end: LanguageServerPosition
}
+struct LanguageServerDiagnosticRelatedInformation: Equatable, Sendable {
+ let fileURL: URL
+ let range: LanguageServerRange
+ let message: String
+}
+
struct LanguageServerDiagnostic: Equatable, Sendable {
let range: LanguageServerRange
let severity: Int?
let message: String
let source: String?
let code: String?
+ let tags: [Int]
+ let relatedInformation: [LanguageServerDiagnosticRelatedInformation]
+
+ init(
+ range: LanguageServerRange,
+ severity: Int?,
+ message: String,
+ source: String?,
+ code: String?,
+ tags: [Int] = [],
+ relatedInformation: [LanguageServerDiagnosticRelatedInformation] = []
+ ) {
+ self.range = range
+ self.severity = severity
+ self.message = message
+ self.source = source
+ self.code = code
+ self.tags = tags
+ self.relatedInformation = relatedInformation
+ }
}
struct LanguageServerLocation: Equatable, Sendable {
@@ -187,6 +286,51 @@ struct LanguageServerCommand: Equatable, Sendable {
let arguments: [ToolingJSONValue]
}
+enum LanguageServerLogLevel: String, Sendable {
+ case info
+ case warning
+ case error
+}
+
+enum LanguageServerSessionState: Equatable, Sendable {
+ case startingProcess
+ case initializing
+ case ready
+ case stopping
+ case stopped
+ case failed(exitCode: Int32?, message: String?)
+}
+
+struct LanguageServerInfo: Equatable, Sendable {
+ let name: String
+ let version: String?
+}
+
+struct LanguageServerLogEntry: Identifiable, Equatable, Sendable {
+ let id: UUID
+ let timestamp: Date
+ let providerID: String
+ let level: LanguageServerLogLevel
+ let message: String
+ let detail: String?
+
+ init(
+ id: UUID = UUID(),
+ timestamp: Date = Date(),
+ providerID: String,
+ level: LanguageServerLogLevel,
+ message: String,
+ detail: String? = nil
+ ) {
+ self.id = id
+ self.timestamp = timestamp
+ self.providerID = providerID
+ self.level = level
+ self.message = message
+ self.detail = detail
+ }
+}
+
struct LanguageServerTextEdit: Equatable, Sendable {
let range: LanguageServerRange
let newText: String
@@ -231,9 +375,6 @@ enum LanguageTestScope: Equatable, Sendable {
case testCase(identifier: String, fileURL: URL?)
}
-/// Inputs available to a test provider when it selects a framework-specific
-/// runner. The provider receives metadata only; reading files or starting a
-/// process remains the responsibility of the injected platform services.
struct LanguageTestContext: Equatable, Sendable {
let workspaceURL: URL
let projectFiles: [URL]
@@ -275,9 +416,6 @@ protocol LanguageTestProvider: Sendable {
}
extension LanguageTestProvider {
- /// Context-aware discovery is optional for existing Providers. Providers
- /// that need build-system markers can override this without forcing every
- /// language implementation to change its public contract at once.
func discoverTests(context: LanguageTestContext) -> [LanguageTestItem] {
discoverTests(workspaceURL: context.workspaceURL, files: context.projectFiles)
}
@@ -291,67 +429,88 @@ extension LanguageTestProvider {
}
@MainActor
-protocol LanguageServerDocumentSession: LanguageServerSession {
+protocol LanguageServerSession: AnyObject {
+ var isRunning: Bool { get }
var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { get set }
- func synchronizeDocument(url: URL, languageIdentifier: String, text: String)
- func closeDocument(url: URL)
-}
-
-@MainActor
-protocol LanguageServerNavigationSession: LanguageServerDocumentSession {
- func locations(
- method: String,
- documentURL: URL,
+ var onLog: ((LanguageServerLogLevel, String, String?) -> Void)? { get set }
+ var onStateChange: ((LanguageServerSessionState) -> Void)? { get set }
+ var features: LanguageServerFeatureSet { get }
+ var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? { get set }
+ var serverInfo: LanguageServerInfo? { get }
+ var onServerInfoChange: ((LanguageServerInfo?) -> Void)? { get set }
+ func start(rootURL: URL) throws
+ func synchronize(fileURL: URL, text: String, languageID: String) throws
+ func closeDocument(_ fileURL: URL)
+ func completions(
+ fileURL: URL,
position: LanguageServerPosition,
- completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
- )
-}
-
-@MainActor
-protocol LanguageServerCodeIntelligenceSession: LanguageServerNavigationSession {
+ completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
+ ) throws
func hover(
- documentURL: URL,
+ fileURL: URL,
position: LanguageServerPosition,
completion: @escaping (Result) -> Void
- )
- func completions(
- documentURL: URL,
+ ) throws
+ func navigate(
+ method: String,
+ fileURL: URL,
position: LanguageServerPosition,
- completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
- )
-}
-
-@MainActor
-protocol LanguageServerEditingSession: LanguageServerCodeIntelligenceSession {
+ completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
+ ) throws
func rename(
- documentURL: URL,
+ fileURL: URL,
position: LanguageServerPosition,
newName: String,
completion: @escaping (Result) -> Void
- )
- func formatting(
- documentURL: URL,
- options: [String: Any],
+ ) throws
+ func format(
+ fileURL: URL,
completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void
- )
+ ) throws
func codeActions(
- documentURL: URL,
+ fileURL: URL,
range: LanguageServerRange,
diagnostics: [LanguageServerDiagnostic],
completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void
- )
- func execute(
- command: LanguageServerCommand,
- completion: @escaping (Result) -> Void
- )
+ ) throws
func resolveCompletion(
_ item: LanguageServerCompletionItem,
+ fileURL: URL,
completion: @escaping (Result) -> Void
- )
+ ) throws
func resolveCodeAction(
_ action: LanguageServerCodeAction,
+ fileURL: URL,
completion: @escaping (Result) -> Void
- )
+ ) throws
+ func execute(
+ _ command: LanguageServerCommand,
+ fileURL: URL,
+ completion: @escaping (Result) -> Void
+ ) throws
+ func stop()
+}
+
+extension LanguageServerSession {
+ var features: LanguageServerFeatureSet { [] }
+ var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? {
+ get { nil }
+ set {}
+ }
+ var onLog: ((LanguageServerLogLevel, String, String?) -> Void)? {
+ get { nil }
+ set {}
+ }
+ var onStateChange: ((LanguageServerSessionState) -> Void)? {
+ get { nil }
+ set {}
+ }
+ var serverInfo: LanguageServerInfo? { nil }
+ var onServerInfoChange: ((LanguageServerInfo?) -> Void)? {
+ get { nil }
+ set {}
+ }
+ func closeDocument(_: URL) {}
}
@MainActor
@@ -362,9 +521,6 @@ protocol DebugAdapterSession: AnyObject {
func stop()
}
-/// Byte transport used by the language-neutral DAP state machine. Adapters may
-/// use a child process' stdio, a TCP socket, or another platform implementation
-/// without changing protocol sequencing and inspection behavior.
@MainActor
protocol DebugAdapterTransport: AnyObject {
var isRunning: Bool { get }
@@ -376,10 +532,6 @@ protocol DebugAdapterTransport: AnyObject {
func stop()
}
-/// Server-style adapters can ask the client to start a child DAP session (for
-/// example a Node process, browser target, worker, or subprocess). The parent
-/// transport supplies another connection to the same adapter server without
-/// exposing platform sockets to the protocol state machine.
@MainActor
protocol DebugAdapterChildTransportProviding: AnyObject {
func makeChildTransport() -> (any DebugAdapterTransport)?
@@ -389,7 +541,7 @@ extension DebugAdapterSession {
var state: DebugAdapterState { isRunning ? .running : .idle }
}
-enum ToolingJSONValue: Equatable, Sendable {
+enum ToolingJSONValue: Codable, Equatable, Hashable, Sendable {
case string(String)
case integer(Int)
case number(Double)
@@ -427,6 +579,45 @@ enum ToolingJSONValue: Equatable, Sendable {
}
return nil
}
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if container.decodeNil() {
+ self = .null
+ } else if let value = try? container.decode(Bool.self) {
+ self = .bool(value)
+ } else if let value = try? container.decode(Int.self) {
+ self = .integer(value)
+ } else if let value = try? container.decode(Double.self) {
+ self = .number(value)
+ } else if let value = try? container.decode(String.self) {
+ self = .string(value)
+ } else if let value = try? container.decode([ToolingJSONValue].self) {
+ self = .array(value)
+ } else {
+ self = .object(try container.decode([String: ToolingJSONValue].self))
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .string(let value):
+ try container.encode(value)
+ case .integer(let value):
+ try container.encode(value)
+ case .number(let value):
+ try container.encode(value)
+ case .bool(let value):
+ try container.encode(value)
+ case .object(let value):
+ try container.encode(value)
+ case .array(let value):
+ try container.encode(value)
+ case .null:
+ try container.encodeNil()
+ }
+ }
}
enum DebugAdapterState: String, Equatable, Sendable {
@@ -550,27 +741,24 @@ protocol DebugAdapterControllingSession: DebugAdapterSession {
@MainActor
protocol LanguageProviderRuntime: AnyObject {
var descriptor: LanguageProviderDescriptor { get }
- /// Metadata-only indication that this provider exposes the shared editing
- /// LSP contract. It must not start or probe a process.
- var supportsEditingSession: Bool { get }
- /// Metadata-only indication that this runtime has a configured debug
- /// adapter factory. Executable discovery still happens lazily on launch.
+ var supportsLanguageServerSession: Bool { get }
var supportsDebugAdapterSession: Bool { get }
- /// Optional actionable guidance when a lazy runtime cannot be created.
- /// This keeps installation details in the platform/provider adapter while
- /// allowing the shared manager to present a useful error.
var unavailableToolingMessage: String? { get }
- var declaredLanguageServerFeatures: LanguageServerFeatureSet { get }
func makeLanguageServerSession() -> (any LanguageServerSession)?
func makeDebugAdapterSession() -> (any DebugAdapterSession)?
func makeDebugAdapterSession(rootURL: URL) -> (any DebugAdapterSession)?
}
+@MainActor
+protocol LanguageProviderRuntimeFactory: AnyObject {
+ func makeRuntime(for descriptor: LanguageProviderDescriptor) -> (any LanguageProviderRuntime)?
+}
+
extension LanguageProviderRuntime {
- var supportsEditingSession: Bool { false }
+ var supportsLanguageServerSession: Bool { false }
var supportsDebugAdapterSession: Bool { false }
var unavailableToolingMessage: String? { nil }
- var declaredLanguageServerFeatures: LanguageServerFeatureSet { [] }
+ func makeLanguageServerSession() -> (any LanguageServerSession)? { nil }
func makeDebugAdapterSession(rootURL: URL) -> (any DebugAdapterSession)? {
makeDebugAdapterSession()
}
diff --git a/Sources/Lithe/Core/Ports/PlatformUI.swift b/Sources/Lithe/Core/Ports/PlatformUI.swift
index a7ae60886..ef40d7271 100644
--- a/Sources/Lithe/Core/Ports/PlatformUI.swift
+++ b/Sources/Lithe/Core/Ports/PlatformUI.swift
@@ -5,6 +5,7 @@ import Foundation
@MainActor
protocol PlatformUI: AnyObject {
func chooseDirectory(title: String, prompt: String) -> URL?
+ func chooseFile(title: String, prompt: String) -> URL?
func revealInFileBrowser(_ url: URL)
func open(_ url: URL)
func copyToClipboard(_ value: String)
diff --git a/Sources/Lithe/Core/Ports/RawProcessSession.swift b/Sources/Lithe/Core/Ports/RawProcessSession.swift
index 7081bb36e..88487ddb7 100644
--- a/Sources/Lithe/Core/Ports/RawProcessSession.swift
+++ b/Sources/Lithe/Core/Ports/RawProcessSession.swift
@@ -1,5 +1,19 @@
import Foundation
+enum RawProcessSessionError: LocalizedError, Equatable, Sendable {
+ case notRunning
+ case standardInputUnavailable
+
+ var errorDescription: String? {
+ switch self {
+ case .notRunning:
+ "The process is not running."
+ case .standardInputUnavailable:
+ "The process does not have an open standard-input pipe."
+ }
+ }
+}
+
protocol RawProcessSession: AnyObject, Sendable {
var isRunning: Bool { get }
var onOutput: (@Sendable (Data) -> Void)? { get set }
diff --git a/Sources/Lithe/Core/Ports/RuntimeLocator.swift b/Sources/Lithe/Core/Ports/RuntimeLocator.swift
index 7f6358ab1..766499736 100644
--- a/Sources/Lithe/Core/Ports/RuntimeLocator.swift
+++ b/Sources/Lithe/Core/Ports/RuntimeLocator.swift
@@ -1,8 +1,8 @@
import Foundation
/// Where a tool candidate came from. The value is intentionally platform
-/// neutral so the same run/LSP/DAP UI can explain a Windows registry entry or
-/// a macOS Homebrew/Xcode candidate without importing platform frameworks.
+/// neutral so the same run/DAP UI can explain a Windows registry entry or a
+/// macOS Homebrew/Xcode candidate without importing platform frameworks.
enum RuntimeToolSource: String, Codable, Hashable, Sendable {
case project
case environment
@@ -117,5 +117,4 @@ protocol RuntimeLocator: Sendable {
func mavenExecutable(forHomePath path: String) -> URL?
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate?
func systemJDBExecutable() -> URL?
- func javaLanguageServerExecutable() -> URL?
}
diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/RustCoreBridge.swift
index 9d2051947..3983c4559 100644
--- a/Sources/Lithe/Core/RustCoreBridge.swift
+++ b/Sources/Lithe/Core/RustCoreBridge.swift
@@ -384,6 +384,198 @@ struct RustCoreBridge: Sendable {
let port: Int?
}
+ struct TextPayload: Decodable, Sendable {
+ let text: String
+ }
+
+ struct LspPositionPayload: Decodable, Sendable {
+ let line: Int
+ let utf16Column: Int
+
+ func makeModel() -> LanguageServerPosition {
+ LanguageServerPosition(line: line, utf16Column: utf16Column)
+ }
+ }
+
+ struct LspRangePayload: Decodable, Sendable {
+ let start: LspPositionPayload
+ let end: LspPositionPayload
+
+ func makeModel() -> LanguageServerRange {
+ LanguageServerRange(start: start.makeModel(), end: end.makeModel())
+ }
+ }
+
+ struct LspTextEditPayload: Decodable, Sendable {
+ let range: LspRangePayload
+ let newText: String
+
+ func makeModel() -> LanguageServerTextEdit {
+ LanguageServerTextEdit(range: range.makeModel(), newText: newText)
+ }
+ }
+
+ struct BuiltinCompletionPayload: Decodable, Sendable {
+ struct Item: Decodable, Sendable {
+ let label: String
+ let insertText: String
+ let kind: Int?
+ let detail: String?
+ let documentation: String?
+ let sortText: String?
+ let filterText: String?
+ let textEdit: LspTextEditPayload?
+ let additionalTextEdits: [LspTextEditPayload]?
+ let data: ToolingJSONValue?
+
+ func makeModel() -> LanguageServerCompletionItem {
+ LanguageServerCompletionItem(
+ label: label,
+ detail: detail,
+ documentation: documentation,
+ insertText: insertText,
+ sortText: sortText,
+ filterText: filterText,
+ kind: kind,
+ textEdit: textEdit?.makeModel(),
+ additionalTextEdits: additionalTextEdits?.map { $0.makeModel() } ?? [],
+ data: data
+ )
+ }
+ }
+
+ let items: [Item]
+
+ func makeModels() -> [LanguageServerCompletionItem] {
+ items.map { $0.makeModel() }
+ }
+ }
+
+ struct LspCompletionResolvePayload: Decodable, Sendable {
+ let item: BuiltinCompletionPayload.Item
+
+ func makeModel() -> LanguageServerCompletionItem {
+ item.makeModel()
+ }
+ }
+
+ struct BuiltinHoverPayload: Decodable, Sendable {
+ struct Hover: Decodable, Sendable {
+ let contents: String
+ let isMarkdown: Bool
+ let range: LspRangePayload?
+
+ func makeModel() -> LanguageServerHover {
+ LanguageServerHover(
+ contents: contents,
+ isMarkdown: isMarkdown,
+ range: range?.makeModel()
+ )
+ }
+ }
+
+ let hover: Hover?
+ }
+
+ struct BuiltinNavigationPayload: Decodable, Sendable {
+ struct Location: Decodable, Sendable {
+ let filePath: String
+ let range: LspRangePayload
+ let isReadOnly: Bool
+ let displayPath: String?
+
+ func makeModel() -> LanguageServerLocation {
+ LanguageServerLocation(
+ url: URL(fileURLWithPath: filePath),
+ range: range.makeModel(),
+ isReadOnly: isReadOnly,
+ displayPath: displayPath
+ )
+ }
+ }
+
+ let locations: [Location]
+
+ func makeModels() -> [LanguageServerLocation] {
+ locations.map { $0.makeModel() }
+ }
+ }
+
+ struct LspWorkspaceEditPayload: Decodable, Sendable {
+ let changes: [String: [LspTextEditPayload]]
+
+ func makeModel() -> LanguageServerWorkspaceEdit {
+ LanguageServerWorkspaceEdit(
+ changes: Dictionary(
+ uniqueKeysWithValues: changes.map { path, edits in
+ (
+ URL(fileURLWithPath: path).standardizedFileURL,
+ edits.map { $0.makeModel() }
+ )
+ }
+ )
+ )
+ }
+ }
+
+ struct LspFormattingPayload: Decodable, Sendable {
+ let edits: [LspTextEditPayload]
+
+ func makeModels() -> [LanguageServerTextEdit] {
+ edits.map { $0.makeModel() }
+ }
+ }
+
+ struct LspCommandPayload: Decodable, Sendable {
+ let title: String
+ let command: String
+ let arguments: [ToolingJSONValue]?
+
+ func makeModel() -> LanguageServerCommand {
+ LanguageServerCommand(
+ title: title,
+ command: command,
+ arguments: arguments ?? []
+ )
+ }
+ }
+
+ struct LspCodeActionsPayload: Decodable, Sendable {
+ struct Action: Decodable, Sendable {
+ let title: String
+ let kind: String?
+ let isPreferred: Bool
+ let edit: LspWorkspaceEditPayload?
+ let command: LspCommandPayload?
+ let data: ToolingJSONValue?
+
+ func makeModel() -> LanguageServerCodeAction {
+ LanguageServerCodeAction(
+ title: title,
+ kind: kind,
+ isPreferred: isPreferred,
+ edit: edit?.makeModel(),
+ command: command?.makeModel(),
+ data: data
+ )
+ }
+ }
+
+ let actions: [Action]
+
+ func makeModels() -> [LanguageServerCodeAction] {
+ actions.map { $0.makeModel() }
+ }
+ }
+
+ struct LspCodeActionResolvePayload: Decodable, Sendable {
+ let action: LspCodeActionsPayload.Action
+
+ func makeModel() -> LanguageServerCodeAction {
+ action.makeModel()
+ }
+ }
+
struct JavaStructurePayload: Decodable, Sendable {
struct FoldRegion: Decodable, Sendable {
let kind: String
@@ -792,6 +984,305 @@ struct RustCoreBridge: Sendable {
let source: String
}
+ private struct LspTextEditsRequest: Encodable {
+ struct TextEdit: Encodable {
+ struct Range: Encodable {
+ struct Position: Encodable {
+ let line: Int
+ let utf16Column: Int
+ }
+
+ let start: Position
+ let end: Position
+ }
+
+ let range: Range
+ let newText: String
+ }
+
+ let text: String
+ let edits: [TextEdit]
+ }
+
+ private struct LspPlainSnippetRequest: Encodable {
+ let value: String
+ }
+
+ private struct LspBuiltinRequest: Encodable {
+ let filePath: String
+ let text: String
+ let position: LspTextEditsRequest.TextEdit.Range.Position
+ }
+
+ private struct LspBuiltinNavigationRequest: Encodable {
+ let filePath: String
+ let text: String
+ let position: LspTextEditsRequest.TextEdit.Range.Position
+ let method: String
+ }
+
+ struct LspClientResponsePayload: Decodable, Sendable {
+ let state: ToolingJSONValue
+ let messages: [String]
+ let events: [LspClientEventPayload]
+ }
+
+ struct LspSessionResponsePayload: Decodable, Sendable {
+ let sessionId: String
+ let serverCapabilities: [String]
+ let messages: [String]
+ let events: [LspClientEventPayload]
+ }
+
+ struct LspClientEventPayload: Decodable, Sendable {
+ let kind: String
+ let requestId: String?
+ let method: String?
+ let uri: String?
+ let diagnostics: [LspClientDiagnosticPayload]?
+ let result: ToolingJSONValue?
+ let error: String?
+ }
+
+ struct LspClientDiagnosticPayload: Decodable, Sendable {
+ let range: LspRangePayload
+ let severity: Int?
+ let message: String
+ let source: String?
+ let code: String?
+ let tags: [Int]?
+ let relatedInformation: [LspClientDiagnosticRelatedInformationPayload]?
+
+ init(
+ range: LspRangePayload,
+ severity: Int?,
+ message: String,
+ source: String?,
+ code: String?,
+ tags: [Int]? = nil,
+ relatedInformation: [LspClientDiagnosticRelatedInformationPayload]? = nil
+ ) {
+ self.range = range
+ self.severity = severity
+ self.message = message
+ self.source = source
+ self.code = code
+ self.tags = tags
+ self.relatedInformation = relatedInformation
+ }
+
+ func makeModel() -> LanguageServerDiagnostic {
+ LanguageServerDiagnostic(
+ range: range.makeModel(),
+ severity: severity,
+ message: message,
+ source: source,
+ code: code,
+ tags: tags ?? [],
+ relatedInformation: (relatedInformation ?? []).compactMap { $0.makeModel() }
+ )
+ }
+ }
+
+ struct LspClientDiagnosticRelatedInformationPayload: Decodable, Sendable {
+ let location: LspClientDiagnosticLocationPayload
+ let message: String
+
+ func makeModel() -> LanguageServerDiagnosticRelatedInformation? {
+ guard let fileURL = URL(string: location.uri) else { return nil }
+ return LanguageServerDiagnosticRelatedInformation(
+ fileURL: fileURL.standardizedFileURL,
+ range: location.range.makeModel(),
+ message: message
+ )
+ }
+ }
+
+ struct LspClientDiagnosticLocationPayload: Decodable, Sendable {
+ let uri: String
+ let range: LspRangePayload
+ }
+
+ private struct LspClientInitializeRequest: Encodable {
+ let state: ToolingJSONValue?
+ let rootUri: String
+ let processId: Int?
+ let initializationOptions: ToolingJSONValue?
+ }
+
+ private struct LspClientOpenDocumentRequest: Encodable {
+ let state: ToolingJSONValue
+ let uri: String
+ let languageId: String
+ let text: String
+ }
+
+ private struct LspClientChangeDocumentRequest: Encodable {
+ let state: ToolingJSONValue
+ let uri: String
+ let text: String
+ }
+
+ private struct LspClientCloseDocumentRequest: Encodable {
+ let state: ToolingJSONValue
+ let uri: String
+ }
+
+ private struct LspClientShutdownRequest: Encodable {
+ let state: ToolingJSONValue
+ }
+
+ private struct LspClientFeatureRequest: Encodable {
+ let state: ToolingJSONValue
+ let uri: String
+ let method: String
+ let position: LspTextEditsRequest.TextEdit.Range.Position?
+ let newName: String?
+ let range: LspTextEditsRequest.TextEdit.Range?
+ let diagnostics: [LspClientDiagnosticRequest]
+ let completionItem: LspClientCompletionItemRequest?
+ let codeAction: LspClientCodeActionRequest?
+ let command: LspClientCommandRequest?
+ }
+
+ private struct LspClientDiagnosticRequest: Encodable {
+ let range: LspTextEditsRequest.TextEdit.Range
+ let severity: Int?
+ let message: String
+ let source: String?
+ let code: String?
+ let tags: [Int]
+ let relatedInformation: [LspClientDiagnosticRelatedInformationRequest]
+ }
+
+ private struct LspClientDiagnosticRelatedInformationRequest: Encodable {
+ let location: LspClientDiagnosticLocationRequest
+ let message: String
+ }
+
+ private struct LspClientDiagnosticLocationRequest: Encodable {
+ let uri: String
+ let range: LspTextEditsRequest.TextEdit.Range
+ }
+
+ private struct LspClientCompletionItemRequest: Encodable {
+ let label: String
+ let detail: String?
+ let documentation: String?
+ let insertText: String
+ let sortText: String?
+ let filterText: String?
+ let kind: Int?
+ let textEdit: LspClientTextEditRequest?
+ let additionalTextEdits: [LspClientTextEditRequest]
+ let data: ToolingJSONValue?
+ }
+
+ private struct LspClientTextEditRequest: Encodable {
+ let range: LspTextEditsRequest.TextEdit.Range
+ let newText: String
+ }
+
+ private struct LspClientWorkspaceEditRequest: Encodable {
+ let changes: [String: [LspClientTextEditRequest]]
+ }
+
+ private struct LspClientCommandRequest: Encodable {
+ let title: String
+ let command: String
+ let arguments: [ToolingJSONValue]
+ }
+
+ private struct LspClientCodeActionRequest: Encodable {
+ let title: String
+ let kind: String?
+ let isPreferred: Bool
+ let edit: LspClientWorkspaceEditRequest?
+ let command: LspClientCommandRequest?
+ let data: ToolingJSONValue?
+ }
+
+ private struct LspClientApplyServerMessageRequest: Encodable {
+ let state: ToolingJSONValue
+ let message: String
+ }
+
+ private struct LspSessionCommandRequest: Encodable {
+ let action: String
+ let sessionId: String?
+ let rootUri: String?
+ let processId: Int?
+ let initializationOptions: ToolingJSONValue?
+ let uri: String?
+ let languageId: String?
+ let text: String?
+ let method: String?
+ let position: LspTextEditsRequest.TextEdit.Range.Position?
+ let newName: String?
+ let range: LspTextEditsRequest.TextEdit.Range?
+ let diagnostics: [LspClientDiagnosticRequest]
+ let completionItem: LspClientCompletionItemRequest?
+ let codeAction: LspClientCodeActionRequest?
+ let command: LspClientCommandRequest?
+ let message: String?
+
+ init(
+ action: String,
+ sessionId: String? = nil,
+ rootUri: String? = nil,
+ processId: Int? = nil,
+ initializationOptions: ToolingJSONValue? = nil,
+ uri: String? = nil,
+ languageId: String? = nil,
+ text: String? = nil,
+ method: String? = nil,
+ position: LspTextEditsRequest.TextEdit.Range.Position? = nil,
+ newName: String? = nil,
+ range: LspTextEditsRequest.TextEdit.Range? = nil,
+ diagnostics: [LspClientDiagnosticRequest] = [],
+ completionItem: LspClientCompletionItemRequest? = nil,
+ codeAction: LspClientCodeActionRequest? = nil,
+ command: LspClientCommandRequest? = nil,
+ message: String? = nil
+ ) {
+ self.action = action
+ self.sessionId = sessionId
+ self.rootUri = rootUri
+ self.processId = processId
+ self.initializationOptions = initializationOptions
+ self.uri = uri
+ self.languageId = languageId
+ self.text = text
+ self.method = method
+ self.position = position
+ self.newName = newName
+ self.range = range
+ self.diagnostics = diagnostics
+ self.completionItem = completionItem
+ self.codeAction = codeAction
+ self.command = command
+ self.message = message
+ }
+ }
+
+ struct LspFramePayload: Decodable, Sendable {
+ let frame: String
+ }
+
+ private struct LspFrameRequest: Encodable {
+ let message: String
+ }
+
+ struct LspParsedMessagesPayload: Decodable, Sendable {
+ let buffer: [UInt8]
+ let messages: [String]
+ }
+
+ private struct LspParseServerMessagesRequest: Encodable {
+ let buffer: [UInt8]
+ let chunk: [UInt8]
+ }
+
private struct MavenDiagnosticsRequest: Encodable {
let root: String
let output: String
@@ -1639,6 +2130,451 @@ struct RustCoreBridge: Sendable {
)
}
+ func applyLanguageServerTextEdits(
+ _ edits: [LanguageServerTextEdit],
+ to text: String
+ ) -> Result {
+ executeResult(
+ command: "lsp.applyTextEdits",
+ payload: LspTextEditsRequest(
+ text: text,
+ edits: edits.map { edit in
+ LspTextEditsRequest.TextEdit(
+ range: LspTextEditsRequest.TextEdit.Range(
+ start: LspTextEditsRequest.TextEdit.Range.Position(
+ line: edit.range.start.line,
+ utf16Column: edit.range.start.utf16Column
+ ),
+ end: LspTextEditsRequest.TextEdit.Range.Position(
+ line: edit.range.end.line,
+ utf16Column: edit.range.end.utf16Column
+ )
+ ),
+ newText: edit.newText
+ )
+ }
+ )
+ )
+ }
+
+ func plainLanguageServerSnippet(_ value: String) -> String? {
+ let response: TextPayload? = execute(
+ command: "lsp.plainSnippet",
+ payload: LspPlainSnippetRequest(value: value)
+ )
+ return response?.text
+ }
+
+ func builtinLanguageCompletions(
+ fileURL: URL,
+ text: String,
+ position: LanguageServerPosition
+ ) -> [LanguageServerCompletionItem]? {
+ let response: BuiltinCompletionPayload? = execute(
+ command: "lsp.builtinCompletions",
+ payload: LspBuiltinRequest(
+ filePath: fileURL.standardizedFileURL.path,
+ text: text,
+ position: .init(line: position.line, utf16Column: position.utf16Column)
+ )
+ )
+ return response?.makeModels()
+ }
+
+ func builtinLanguageHover(
+ fileURL: URL,
+ text: String,
+ position: LanguageServerPosition
+ ) -> LanguageServerHover? {
+ let response: BuiltinHoverPayload? = execute(
+ command: "lsp.builtinHover",
+ payload: LspBuiltinRequest(
+ filePath: fileURL.standardizedFileURL.path,
+ text: text,
+ position: .init(line: position.line, utf16Column: position.utf16Column)
+ )
+ )
+ return response?.hover?.makeModel()
+ }
+
+ func builtinLanguageNavigation(
+ method: String,
+ fileURL: URL,
+ text: String,
+ position: LanguageServerPosition
+ ) -> [LanguageServerLocation]? {
+ let response: BuiltinNavigationPayload? = execute(
+ command: "lsp.builtinNavigation",
+ payload: LspBuiltinNavigationRequest(
+ filePath: fileURL.standardizedFileURL.path,
+ text: text,
+ position: .init(line: position.line, utf16Column: position.utf16Column),
+ method: method
+ )
+ )
+ return response?.makeModels()
+ }
+
+ func lspClientInitialize(rootURL: URL) -> LspClientResponsePayload? {
+ lspClientInitialize(rootURL: rootURL, initializationOptions: nil)
+ }
+
+ func lspSessionCreate(
+ rootURL: URL,
+ initializationOptions: ToolingJSONValue?
+ ) -> LspSessionResponsePayload? {
+ execute(
+ command: "lsp.sessionExecute",
+ payload: LspSessionCommandRequest(
+ action: "create",
+ rootUri: rootURL.standardizedFileURL.absoluteString,
+ processId: Int(ProcessInfo.processInfo.processIdentifier),
+ initializationOptions: initializationOptions
+ )
+ )
+ }
+
+ func lspSessionOpenDocument(
+ sessionID: String,
+ fileURL: URL,
+ languageID: String,
+ text: String
+ ) -> LspSessionResponsePayload? {
+ execute(
+ command: "lsp.sessionExecute",
+ payload: LspSessionCommandRequest(
+ action: "openDocument",
+ sessionId: sessionID,
+ uri: fileURL.standardizedFileURL.absoluteString,
+ languageId: languageID,
+ text: text
+ )
+ )
+ }
+
+ func lspSessionChangeDocument(
+ sessionID: String,
+ fileURL: URL,
+ text: String
+ ) -> LspSessionResponsePayload? {
+ execute(
+ command: "lsp.sessionExecute",
+ payload: LspSessionCommandRequest(
+ action: "changeDocument",
+ sessionId: sessionID,
+ uri: fileURL.standardizedFileURL.absoluteString,
+ text: text
+ )
+ )
+ }
+
+ func lspSessionCloseDocument(
+ sessionID: String,
+ fileURL: URL
+ ) -> LspSessionResponsePayload? {
+ execute(
+ command: "lsp.sessionExecute",
+ payload: LspSessionCommandRequest(
+ action: "closeDocument",
+ sessionId: sessionID,
+ uri: fileURL.standardizedFileURL.absoluteString
+ )
+ )
+ }
+
+ func lspSessionShutdown(sessionID: String) -> LspSessionResponsePayload? {
+ execute(
+ command: "lsp.sessionExecute",
+ payload: LspSessionCommandRequest(action: "shutdown", sessionId: sessionID)
+ )
+ }
+
+ func lspSessionRequest(
+ sessionID: String,
+ fileURL: URL,
+ method: String,
+ position: LanguageServerPosition? = nil,
+ newName: String? = nil,
+ range: LanguageServerRange? = nil,
+ diagnostics: [LanguageServerDiagnostic] = [],
+ completionItem: LanguageServerCompletionItem? = nil,
+ codeAction: LanguageServerCodeAction? = nil,
+ command: LanguageServerCommand? = nil
+ ) -> LspSessionResponsePayload? {
+ execute(
+ command: "lsp.sessionExecute",
+ payload: LspSessionCommandRequest(
+ action: "request",
+ sessionId: sessionID,
+ uri: fileURL.standardizedFileURL.absoluteString,
+ method: method,
+ position: position.map {
+ .init(line: $0.line, utf16Column: $0.utf16Column)
+ },
+ newName: newName,
+ range: range.map(Self.makeRangeRequest),
+ diagnostics: diagnostics.map(Self.makeDiagnosticRequest),
+ completionItem: completionItem.map(Self.makeCompletionItemRequest),
+ codeAction: codeAction.map(Self.makeCodeActionRequest),
+ command: command.map(Self.makeCommandRequest)
+ )
+ )
+ }
+
+ func lspSessionApplyServerMessage(
+ sessionID: String,
+ message: String
+ ) -> LspSessionResponsePayload? {
+ execute(
+ command: "lsp.sessionExecute",
+ payload: LspSessionCommandRequest(
+ action: "applyServerMessage",
+ sessionId: sessionID,
+ message: message
+ )
+ )
+ }
+
+ func lspSessionDestroy(sessionID: String) {
+ let _: LspSessionResponsePayload? = execute(
+ command: "lsp.sessionExecute",
+ payload: LspSessionCommandRequest(action: "destroy", sessionId: sessionID)
+ )
+ }
+
+ func lspClientInitialize(
+ rootURL: URL,
+ initializationOptions: ToolingJSONValue?
+ ) -> LspClientResponsePayload? {
+ execute(
+ command: "lsp.clientInitialize",
+ payload: LspClientInitializeRequest(
+ state: nil,
+ rootUri: rootURL.standardizedFileURL.absoluteString,
+ processId: Int(ProcessInfo.processInfo.processIdentifier),
+ initializationOptions: initializationOptions
+ )
+ )
+ }
+
+ func lspClientOpenDocument(
+ state: ToolingJSONValue,
+ fileURL: URL,
+ languageID: String,
+ text: String
+ ) -> LspClientResponsePayload? {
+ execute(
+ command: "lsp.clientOpenDocument",
+ payload: LspClientOpenDocumentRequest(
+ state: state,
+ uri: fileURL.standardizedFileURL.absoluteString,
+ languageId: languageID,
+ text: text
+ )
+ )
+ }
+
+ func lspClientChangeDocument(
+ state: ToolingJSONValue,
+ fileURL: URL,
+ text: String
+ ) -> LspClientResponsePayload? {
+ execute(
+ command: "lsp.clientChangeDocument",
+ payload: LspClientChangeDocumentRequest(
+ state: state,
+ uri: fileURL.standardizedFileURL.absoluteString,
+ text: text
+ )
+ )
+ }
+
+ func lspClientCloseDocument(
+ state: ToolingJSONValue,
+ fileURL: URL
+ ) -> LspClientResponsePayload? {
+ execute(
+ command: "lsp.clientCloseDocument",
+ payload: LspClientCloseDocumentRequest(
+ state: state,
+ uri: fileURL.standardizedFileURL.absoluteString
+ )
+ )
+ }
+
+ func lspClientShutdown(state: ToolingJSONValue) -> LspClientResponsePayload? {
+ execute(
+ command: "lsp.clientShutdown",
+ payload: LspClientShutdownRequest(state: state)
+ )
+ }
+
+ func lspClientRequest(
+ state: ToolingJSONValue,
+ fileURL: URL,
+ method: String,
+ position: LanguageServerPosition? = nil,
+ newName: String? = nil,
+ range: LanguageServerRange? = nil,
+ diagnostics: [LanguageServerDiagnostic] = [],
+ completionItem: LanguageServerCompletionItem? = nil,
+ codeAction: LanguageServerCodeAction? = nil,
+ command: LanguageServerCommand? = nil
+ ) -> LspClientResponsePayload? {
+ execute(
+ command: "lsp.clientRequest",
+ payload: LspClientFeatureRequest(
+ state: state,
+ uri: fileURL.standardizedFileURL.absoluteString,
+ method: method,
+ position: position.map {
+ .init(line: $0.line, utf16Column: $0.utf16Column)
+ },
+ newName: newName,
+ range: range.map {
+ .init(
+ start: .init(line: $0.start.line, utf16Column: $0.start.utf16Column),
+ end: .init(line: $0.end.line, utf16Column: $0.end.utf16Column)
+ )
+ },
+ diagnostics: diagnostics.map(Self.makeDiagnosticRequest),
+ completionItem: completionItem.map(Self.makeCompletionItemRequest),
+ codeAction: codeAction.map(Self.makeCodeActionRequest),
+ command: command.map(Self.makeCommandRequest)
+ )
+ )
+ }
+
+ private static func makeCompletionItemRequest(
+ _ item: LanguageServerCompletionItem
+ ) -> LspClientCompletionItemRequest {
+ LspClientCompletionItemRequest(
+ label: item.label,
+ detail: item.detail,
+ documentation: item.documentation,
+ insertText: item.insertText,
+ sortText: item.sortText,
+ filterText: item.filterText,
+ kind: item.kind,
+ textEdit: item.textEdit.map(makeTextEditRequest),
+ additionalTextEdits: item.additionalTextEdits.map(makeTextEditRequest),
+ data: item.data
+ )
+ }
+
+ private static func makeRangeRequest(
+ _ range: LanguageServerRange
+ ) -> LspTextEditsRequest.TextEdit.Range {
+ .init(
+ start: .init(
+ line: range.start.line,
+ utf16Column: range.start.utf16Column
+ ),
+ end: .init(
+ line: range.end.line,
+ utf16Column: range.end.utf16Column
+ )
+ )
+ }
+
+ private static func makeDiagnosticRequest(
+ _ diagnostic: LanguageServerDiagnostic
+ ) -> LspClientDiagnosticRequest {
+ LspClientDiagnosticRequest(
+ range: makeRangeRequest(diagnostic.range),
+ severity: diagnostic.severity,
+ message: diagnostic.message,
+ source: diagnostic.source,
+ code: diagnostic.code,
+ tags: diagnostic.tags,
+ relatedInformation: diagnostic.relatedInformation.map {
+ LspClientDiagnosticRelatedInformationRequest(
+ location: LspClientDiagnosticLocationRequest(
+ uri: $0.fileURL.standardizedFileURL.absoluteString,
+ range: makeRangeRequest($0.range)
+ ),
+ message: $0.message
+ )
+ }
+ )
+ }
+
+ private static func makeTextEditRequest(
+ _ edit: LanguageServerTextEdit
+ ) -> LspClientTextEditRequest {
+ LspClientTextEditRequest(
+ range: .init(
+ start: .init(line: edit.range.start.line, utf16Column: edit.range.start.utf16Column),
+ end: .init(line: edit.range.end.line, utf16Column: edit.range.end.utf16Column)
+ ),
+ newText: edit.newText
+ )
+ }
+
+ private static func makeCodeActionRequest(
+ _ action: LanguageServerCodeAction
+ ) -> LspClientCodeActionRequest {
+ LspClientCodeActionRequest(
+ title: action.title,
+ kind: action.kind,
+ isPreferred: action.isPreferred,
+ edit: action.edit.map(makeWorkspaceEditRequest),
+ command: action.command.map {
+ makeCommandRequest($0)
+ },
+ data: action.data
+ )
+ }
+
+ private static func makeCommandRequest(
+ _ command: LanguageServerCommand
+ ) -> LspClientCommandRequest {
+ LspClientCommandRequest(
+ title: command.title,
+ command: command.command,
+ arguments: command.arguments
+ )
+ }
+
+ private static func makeWorkspaceEditRequest(
+ _ edit: LanguageServerWorkspaceEdit
+ ) -> LspClientWorkspaceEditRequest {
+ LspClientWorkspaceEditRequest(
+ changes: Dictionary(
+ uniqueKeysWithValues: edit.changes.map { url, edits in
+ (url.standardizedFileURL.path, edits.map(makeTextEditRequest))
+ }
+ )
+ )
+ }
+
+ func lspClientApplyServerMessage(
+ state: ToolingJSONValue,
+ message: String
+ ) -> LspClientResponsePayload? {
+ execute(
+ command: "lsp.clientApplyServerMessage",
+ payload: LspClientApplyServerMessageRequest(state: state, message: message)
+ )
+ }
+
+ func lspFrameMessage(_ message: String) -> LspFramePayload? {
+ execute(
+ command: "lsp.frameMessage",
+ payload: LspFrameRequest(message: message)
+ )
+ }
+
+ func lspParseServerMessages(
+ buffer: [UInt8],
+ chunk: [UInt8]
+ ) -> LspParsedMessagesPayload? {
+ execute(
+ command: "lsp.parseServerMessages",
+ payload: LspParseServerMessagesRequest(buffer: buffer, chunk: chunk)
+ )
+ }
+
private func execute(
command: String,
payload: Payload
diff --git a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift
new file mode 100644
index 000000000..ce0d9a4bd
--- /dev/null
+++ b/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift
@@ -0,0 +1,246 @@
+import Foundation
+import LitheRustCore
+
+enum LanguageProviderCatalogOrigin: Equatable, Sendable {
+ case builtin
+ /// The associated URL is the accepted workspace configuration file.
+ case workspaceOverride(URL)
+ case compatibilityFallback
+}
+
+enum LanguageProviderCatalogStatus: Equatable, Sendable {
+ case loaded
+ case degraded
+}
+
+struct LanguageProviderCatalogIssue: Equatable, Sendable {
+ let path: String
+ let message: String
+}
+
+struct LanguageProviderCatalogSnapshot: Sendable {
+ let catalog: LanguageProviderCatalog
+ let schemaVersion: Int?
+ let origin: LanguageProviderCatalogOrigin
+ let status: LanguageProviderCatalogStatus
+ let issues: [LanguageProviderCatalogIssue]
+
+ var isDegraded: Bool { status == .degraded }
+
+ init(
+ catalog: LanguageProviderCatalog,
+ schemaVersion: Int?,
+ origin: LanguageProviderCatalogOrigin,
+ issues: [LanguageProviderCatalogIssue]
+ ) {
+ self.catalog = catalog
+ self.schemaVersion = schemaVersion
+ self.origin = origin
+ self.issues = issues
+ if case .compatibilityFallback = origin {
+ status = .degraded
+ } else {
+ status = issues.isEmpty ? .loaded : .degraded
+ }
+ }
+}
+
+protocol LanguageProviderCatalogSource: Sendable {
+ func load(workspaceURL: URL?) -> LanguageProviderCatalogSnapshot
+}
+
+extension LanguageProviderCatalogSource {
+ func catalog(workspaceURL: URL?) -> LanguageProviderCatalog {
+ load(workspaceURL: workspaceURL).catalog
+ }
+}
+
+protocol RustLanguageProviderCatalogLoading: Sendable {
+ var isAvailable: Bool { get }
+ func languageProviderCatalogData(workspaceURL: URL?) -> Data?
+}
+
+extension RustCoreBridge: RustLanguageProviderCatalogLoading {
+ func languageProviderCatalogData(workspaceURL: URL?) -> Data? {
+ let responsePointer: UnsafeMutablePointer?
+ if let workspaceURL {
+ responsePointer = workspaceURL.standardizedFileURL.path.withCString {
+ lithe_bridge_lsp_provider_catalog_json($0)
+ }
+ } else {
+ responsePointer = lithe_bridge_lsp_provider_catalog_json(nil)
+ }
+ guard let responsePointer else { return nil }
+ defer { lithe_bridge_free_string(responsePointer) }
+ guard let response = String(validatingUTF8: responsePointer) else { return nil }
+ return response.data(using: .utf8)
+ }
+}
+
+struct RustLanguageProviderCatalogSource: LanguageProviderCatalogSource {
+ private enum CatalogOriginPayload: String, Decodable {
+ case builtin
+ case workspaceOverride
+ }
+
+ private struct CatalogPayload: Decodable {
+ let version: Int
+ let origin: CatalogOriginPayload
+ let providers: [ProviderPayload]
+ let diagnostics: [CatalogDiagnosticPayload]?
+ }
+
+ private struct CatalogDiagnosticPayload: Decodable {
+ let path: String
+ let message: String
+
+ func makeIssue() -> LanguageProviderCatalogIssue {
+ LanguageProviderCatalogIssue(path: path, message: message)
+ }
+ }
+
+ private struct LanguageServerLaunchPayload: Decodable {
+ let executableNames: [String]
+ let arguments: [String]
+ let validationArguments: [String]?
+ let environment: [String: String]
+ let initializationOptions: ToolingJSONValue?
+
+ func makeDescriptor() -> LanguageServerLaunchDescriptor {
+ LanguageServerLaunchDescriptor(
+ executableNames: executableNames,
+ arguments: arguments,
+ validationArguments: validationArguments ?? [],
+ environment: environment,
+ initializationOptions: initializationOptions
+ )
+ }
+ }
+
+ private struct LanguageServerInstallationPayload: Decodable {
+ let homebrewFormula: String?
+ let officialDownloadURL: String?
+
+ func makeDescriptor() -> LanguageServerInstallationDescriptor {
+ LanguageServerInstallationDescriptor(
+ homebrewFormula: homebrewFormula,
+ officialDownloadURL: officialDownloadURL.flatMap(URL.init(string:))
+ )
+ }
+ }
+
+ private struct ProviderPayload: Decodable {
+ let id: String
+ let displayName: String
+ let fileExtensions: [String]
+ let fileNames: [String]
+ let fileNamePrefixes: [String]
+ let capabilities: [String]
+ let activationPolicy: ToolingActivationPolicy
+ let languageId: String?
+ let languageIdsByExtension: [String: String]
+ let languageIdsByFileName: [String: String]
+ let languageServerLaunch: LanguageServerLaunchPayload?
+ let languageServerInstallation: LanguageServerInstallationPayload?
+
+ func makeDescriptor() -> LanguageProviderDescriptor {
+ LanguageProviderDescriptor(
+ id: id,
+ displayName: displayName,
+ fileExtensions: Set(fileExtensions),
+ fileNames: Set(fileNames),
+ fileNamePrefixes: Set(fileNamePrefixes),
+ capabilities: LanguageToolingCapability.names(capabilities),
+ activationPolicy: activationPolicy,
+ languageIdentifier: languageId,
+ languageIdentifiersByExtension: languageIdsByExtension,
+ languageIdentifiersByFileName: languageIdsByFileName,
+ languageServerLaunch: languageServerLaunch?.makeDescriptor(),
+ languageServerInstallation: languageServerInstallation?.makeDescriptor()
+ )
+ }
+ }
+
+ private let loader: any RustLanguageProviderCatalogLoading
+
+ init(core: RustCoreBridge = RustCoreBridge()) {
+ loader = core
+ }
+
+ init(loader: any RustLanguageProviderCatalogLoading) {
+ self.loader = loader
+ }
+
+ func load(workspaceURL: URL? = nil) -> LanguageProviderCatalogSnapshot {
+ guard loader.isAvailable else {
+ return compatibilityFallback(
+ message: "The Rust core is unavailable. Lithe is using its compatibility language-provider catalog."
+ )
+ }
+ guard let data = loader.languageProviderCatalogData(workspaceURL: workspaceURL) else {
+ return compatibilityFallback(
+ message: "The Rust core did not return a valid UTF-8 language-provider catalog."
+ )
+ }
+
+ let payload: CatalogPayload
+ do {
+ payload = try JSONDecoder().decode(CatalogPayload.self, from: data)
+ } catch {
+ return compatibilityFallback(
+ message: "The Rust language-provider catalog could not be decoded: \(error.localizedDescription)"
+ )
+ }
+
+ let issues = (payload.diagnostics ?? []).map { $0.makeIssue() }
+ return LanguageProviderCatalogSnapshot(
+ catalog: LanguageProviderCatalog(
+ descriptors: payload.providers.map { $0.makeDescriptor() }
+ ),
+ schemaVersion: payload.version,
+ origin: resolvedOrigin(
+ payloadOrigin: payload.origin,
+ workspaceURL: workspaceURL
+ ),
+ issues: issues
+ )
+ }
+
+ private func resolvedOrigin(
+ payloadOrigin: CatalogOriginPayload,
+ workspaceURL: URL?
+ ) -> LanguageProviderCatalogOrigin {
+ switch payloadOrigin {
+ case .workspaceOverride:
+ if let workspaceURL {
+ return .workspaceOverride(
+ workspaceURL.standardizedFileURL
+ .appendingPathComponent(".lithe")
+ .appendingPathComponent("lsp")
+ .appendingPathComponent("language-providers.json")
+ )
+ }
+ return .builtin
+ case .builtin:
+ return .builtin
+ }
+ }
+
+ private func compatibilityFallback(message: String) -> LanguageProviderCatalogSnapshot {
+ LanguageProviderCatalogSnapshot(
+ catalog: .compatibilityFallback,
+ schemaVersion: nil,
+ origin: .compatibilityFallback,
+ issues: [LanguageProviderCatalogIssue(
+ path: "rust:lsp-provider-catalog",
+ message: message
+ )]
+ )
+ }
+}
+
+extension LanguageProviderCatalog {
+ static var standard: Self {
+ RustLanguageProviderCatalogSource().load().catalog
+ }
+}
diff --git a/Sources/Lithe/Models/AppModel+Development.swift b/Sources/Lithe/Models/AppModel+Development.swift
index db66fc998..ac6b2bcc8 100644
--- a/Sources/Lithe/Models/AppModel+Development.swift
+++ b/Sources/Lithe/Models/AppModel+Development.swift
@@ -101,10 +101,6 @@ extension AppModel {
)
}
- func openJavaDiagnostic(_ diagnostic: JavaDiagnostic) {
- openDiagnostic(diagnostic)
- }
-
func selectRunConfiguration(_ configuration: RunConfiguration) {
runFeature.select(configuration)
}
@@ -803,6 +799,7 @@ extension AppModel {
self.isLoadingLanguageNavigation = false
switch result {
case .failure(let error):
+ self.languageNavigationProviderID = nil
self.showNotification(error.localizedDescription)
case .success(let values):
if fallbackToImplementationsIfSelf,
@@ -828,6 +825,7 @@ extension AppModel {
}
} catch {
isLoadingLanguageNavigation = false
+ languageNavigationProviderID = nil
showNotification(error.localizedDescription)
}
}
diff --git a/Sources/Lithe/Models/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel+FeatureState.swift
index fc4309c54..c66cd23c7 100644
--- a/Sources/Lithe/Models/AppModel+FeatureState.swift
+++ b/Sources/Lithe/Models/AppModel+FeatureState.swift
@@ -24,7 +24,12 @@ extension AppModel {
var openDocuments: [EditorDocument] { documentFeature.openDocuments }
var activeDocumentID: UUID? {
get { documentFeature.activeDocumentID }
- set { documentFeature.activeDocumentID = newValue }
+ set {
+ let previousDocumentID = documentFeature.activeDocumentID
+ documentFeature.activeDocumentID = newValue
+ guard previousDocumentID != newValue else { return }
+ activateCurrentDocumentLanguageServerIfAvailable()
+ }
}
var pendingCloseDocument: EditorDocument? { documentFeature.pendingCloseDocument }
var isPendingProjectClose: Bool { documentFeature.isPendingProjectClose }
@@ -131,8 +136,6 @@ extension AppModel {
var isLoadingNavigation: Bool {
isLoadingLanguageNavigation
}
- var javaDiagnostics: [URL: [JavaDiagnostic]] { javaFeature.javaDiagnostics }
-
var isLoadingWorkspace: Bool { workspaceFeature.isLoadingWorkspace }
var isRefreshingWorkspace: Bool { workspaceFeature.isRefreshingWorkspace }
var searchResults: [FileSearchResult] { searchFeature.searchResults }
diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel.swift
index 3efc49462..8b4d8796f 100644
--- a/Sources/Lithe/Models/AppModel.swift
+++ b/Sources/Lithe/Models/AppModel.swift
@@ -74,7 +74,10 @@ final class AppModel: ObservableObject, Identifiable {
@Published var isProblemsVisible = false
@Published var isMavenVisible = false
@Published var isDebugVisible = false
+ @Published var isLSPControlCenterVisible = true
@Published var isImplementationChooserVisible = false
+ @Published private(set) var languageProviderCatalog: LanguageProviderCatalog
+ @Published private(set) var languageProviderCatalogSnapshot: LanguageProviderCatalogSnapshot
@Published var languageNavigationProviderID: String?
@Published var languageNavigationLocations: [LanguageNavigationLocation] = []
@Published var languageNavigationResultKind: LanguageNavigationResultKind = .definitions
@@ -111,18 +114,15 @@ final class AppModel: ObservableObject, Identifiable {
let gitFeature: GitFeatureModel
let documentFeature: DocumentFeatureModel
let javaFeature: JavaFeatureModel
- var languageProviderCatalog: LanguageProviderCatalog { services.languageProviderCatalog }
var workspaceFileOperations: any WorkspaceFileOperations { services.fileOperations }
var languageToolingSessions: LanguageToolingSessionManager { services.languageToolingSessions }
+ var languageServerTools: LanguageServerToolService { services.languageServerTools }
var languageTestService: LanguageTestService { services.languageTestService }
var languageDiagnostics: [URL: [LanguageServerDiagnostic]] {
languageToolingSessions.diagnostics
}
var editorDiagnostics: [URL: [EditorDiagnostic]] {
- EditorDiagnostic.merging(
- javaDiagnostics,
- languageServerDiagnostics: languageDiagnostics
- )
+ EditorDiagnostic.fromLanguageServerDiagnostics(languageDiagnostics)
}
private var workspaceFeatureObservation: AnyCancellable?
private var runtimeFeatureObservation: AnyCancellable?
@@ -142,17 +142,61 @@ final class AppModel: ObservableObject, Identifiable {
requestedSettingsCategory = category
isSettingsPresented = true
}
+
+ func chooseLanguageServerExecutable(providerName: String) -> URL? {
+ platformUI.chooseFile(
+ title: settings.language == .simplifiedChinese
+ ? "选择 \(providerName) 语言服务器"
+ : "Choose \(providerName) language server",
+ prompt: settings.language == .simplifiedChinese ? "选择" : "Choose"
+ )
+ }
+
+ func openLanguageServerDownload(_ url: URL) {
+ platformUI.open(url)
+ }
+
+ func languageServerToolConfigurationDidChange(providerID: String) {
+ disabledLanguageServerProviderIDs.remove(providerID)
+ languageToolingSessions.stopLanguageServer(providerID: providerID)
+ languageToolingSessions.recordLanguageServerLog(
+ providerID: providerID,
+ level: .info,
+ message: "Language server tool configuration changed",
+ detail: "Workspace disable state cleared"
+ )
+ }
+
+ func isLanguageServerDisabledInCurrentWorkspace(providerID: String) -> Bool {
+ disabledLanguageServerProviderIDs.contains(providerID)
+ }
+
+ func disableLanguageServerForCurrentWorkspace(providerID: String) {
+ disabledLanguageServerProviderIDs.insert(providerID)
+ languageToolingSessions.recordLanguageServerLog(
+ providerID: providerID,
+ level: .warning,
+ message: "Language server disabled in this workspace",
+ detail: "Manual stop"
+ )
+ languageToolingSessions.stopLanguageServer(providerID: providerID)
+ }
+
private var gitFeatureObservation: AnyCancellable?
private var documentFeatureObservation: AnyCancellable?
private var javaFeatureObservation: AnyCancellable?
private var languageToolingObservation: AnyCancellable?
private var languageTestObservation: AnyCancellable?
+ private var languageServerStartupFailures: [String: String] = [:]
+ private var disabledLanguageServerProviderIDs: Set = []
private var recentProjectsStore: RecentProjectsStore { services.recentProjectsStore }
private var workbenchLayoutStore: WorkbenchLayoutStore { services.workbenchLayoutStore }
init(settings: AppSettings, services: AppServices) {
self.settings = settings
self.services = services
+ languageProviderCatalog = services.languageProviderCatalog
+ languageProviderCatalogSnapshot = services.languageProviderCatalogSnapshot
platformUI = services.platformUI
workspaceFeature = WorkspaceFeatureModel(
operations: services.workspaceOperations,
@@ -187,8 +231,6 @@ final class AppModel: ObservableObject, Identifiable {
fileOperations: services.fileOperations
)
javaFeature = JavaFeatureModel(
- service: services.javaLanguageService,
- markerService: services.javaImplementationMarkerService,
operations: services.javaMavenOperations,
workspaceOperations: services.workspaceOperations
)
@@ -460,24 +502,63 @@ final class AppModel: ObservableObject, Identifiable {
}
var languageServerStatusMessage: String {
- if isLoadingLanguageNavigation { return "Loading language navigation…" }
- if languageNavigationProviderID != nil { return "Language server ready" }
- if let document = activeDocument,
- let descriptor = languageProviderCatalog.provider(for: document.url),
- descriptor.capabilities.contains(.languageServer) {
- if languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) {
- return descriptor.displayName + " language server ready"
- }
- return descriptor.displayName + " language server available on demand"
+ let usesChinese = settings.language == .simplifiedChinese
+ guard let document = activeDocument,
+ let descriptor = languageProviderCatalog.provider(for: document.url),
+ descriptor.capabilities.contains(.languageServer) else {
+ return usesChinese ? "打开一个受支持的源码文件" : "Open a supported source file"
}
- return "Open a supported source file"
+
+ let status = LSPControlCenterPresenter.serverStatus(
+ isDisabled: disabledLanguageServerProviderIDs.contains(descriptor.id),
+ sessionState: languageToolingSessions.languageServerStates[descriptor.id]
+ )
+ switch status {
+ case .starting:
+ return usesChinese
+ ? "正在启动 \(descriptor.displayName) LSP 进程"
+ : "Starting the \(descriptor.displayName) LSP process"
+ case .initializing:
+ return usesChinese
+ ? "正在初始化 \(descriptor.displayName) LSP"
+ : "Initializing \(descriptor.displayName) LSP"
+ case .active:
+ return usesChinese
+ ? "\(descriptor.displayName) 语言服务器已就绪"
+ : "\(descriptor.displayName) language server ready"
+ case .stopping:
+ return usesChinese
+ ? "正在停止 \(descriptor.displayName) LSP"
+ : "Stopping \(descriptor.displayName) LSP"
+ case .stopped:
+ return usesChinese
+ ? "\(descriptor.displayName) 已由 catalog 声明,但当前没有运行中的 LSP 会话"
+ : "\(descriptor.displayName) is declared by the catalog, but no LSP session is running"
+ case .disabled:
+ return usesChinese
+ ? "\(descriptor.displayName) LSP 已在当前工作区禁用"
+ : "\(descriptor.displayName) LSP is disabled in this workspace"
+ case .error:
+ return usesChinese
+ ? "\(descriptor.displayName) LSP 异常退出"
+ : "\(descriptor.displayName) LSP exited unexpectedly"
+ }
+ }
+
+ func restartLanguageServers() {
+ languageToolingSessions.stopAllLanguageServers()
+ disabledLanguageServerProviderIDs.removeAll()
+ let didStart = activateCurrentDocumentLanguageServerIfAvailable()
+ showNotification(
+ didStart
+ ? (settings.language == .simplifiedChinese ? "语言服务器已启动" : "Language server started")
+ : (settings.language == .simplifiedChinese ? "当前没有运行中的 LSP 会话" : "No LSP session is running")
+ )
}
- func implementationMarkers(
- for document: EditorDocument,
- candidates: [JavaImplementationMarker]
- ) async -> [JavaImplementationMarker] {
- await javaFeature.implementationMarkers(for: document, candidates: candidates)
+ func clearLanguageServerDiagnostics() {
+ languageToolingSessions.clearDiagnostics()
+ showNotification(settings.language == .simplifiedChinese ? "语言服务器诊断已清空" : "Language server diagnostics cleared")
}
func javaStructure(source: String, declarationSources: [String] = []) -> JavaStructureResult? {
@@ -559,8 +640,16 @@ final class AppModel: ObservableObject, Identifiable {
if let previousWorkspaceURL = workspaceURL {
workspaceFeature.persistWorkspaceSession(for: previousWorkspaceURL)
}
+ // A workspace root is a hard language-server ownership boundary. Stop
+ // every provider session before replacing the catalog or clearing the
+ // document projection so no old-root documents, diagnostics, or
+ // responses can survive into the next workspace.
+ languageToolingSessions.stopAll()
+ reloadLanguageProviderCatalog(for: normalizedURL)
stopTerminalSessions()
languageTestService.reset()
+ disabledLanguageServerProviderIDs.removeAll()
+ languageServerStartupFailures.removeAll()
runtimeFeature.openProject(at: normalizedURL)
mavenFeature.reset()
runFeature.reset()
@@ -568,7 +657,6 @@ final class AppModel: ObservableObject, Identifiable {
genericDebugFeature.reset()
clearLanguageNavigationProjection()
javaFeature.stop()
- javaFeature.configureProjectRoot(normalizedURL)
workspaceFeature.reset()
searchFeature.reset()
isTerminalVisible = false
@@ -616,6 +704,7 @@ final class AppModel: ObservableObject, Identifiable {
workspaceFeature.persistWorkspaceSession(for: workspaceURL)
}
workspaceURL = nil
+ reloadLanguageProviderCatalog(for: nil)
selectedSidebar = .project
workspaceFeature.reset()
documentFeature.reset()
@@ -677,6 +766,13 @@ final class AppModel: ObservableObject, Identifiable {
workbenchLayoutStore.save(layout, for: workspaceURL)
}
+ private func reloadLanguageProviderCatalog(for workspaceURL: URL?) {
+ let snapshot = services.languageProviderCatalogSource.load(workspaceURL: workspaceURL)
+ languageProviderCatalogSnapshot = snapshot
+ languageProviderCatalog = snapshot.catalog
+ languageToolingSessions.updateCatalog(snapshot.catalog)
+ }
+
func openFile(
_ url: URL,
isReadOnly: Bool = false,
@@ -857,14 +953,47 @@ final class AppModel: ObservableObject, Identifiable {
}
}
- private func activateLanguageServerIfAvailable(for document: EditorDocument) {
+ @discardableResult
+ func activateCurrentDocumentLanguageServerIfAvailable() -> Bool {
+ guard let activeDocument else { return false }
+ return activateLanguageServerIfAvailable(for: activeDocument)
+ }
+
+ @discardableResult
+ private func activateLanguageServerIfAvailable(for document: EditorDocument) -> Bool {
guard let workspaceURL,
- languageProviderCatalog.provider(for: document.url) != nil else { return }
- try? languageToolingSessions.synchronizeLanguageServer(
- for: document.url,
- text: document.text,
- rootURL: workspaceURL
- )
+ let descriptor = languageProviderCatalog.provider(for: document.url) else { return false }
+ guard !disabledLanguageServerProviderIDs.contains(descriptor.id) else {
+ languageToolingSessions.recordLanguageServerLog(
+ providerID: descriptor.id,
+ level: .info,
+ message: "Language server activation skipped",
+ detail: "Disabled in this workspace"
+ )
+ return false
+ }
+ do {
+ try languageToolingSessions.synchronizeLanguageServer(
+ for: document.url,
+ text: document.text,
+ rootURL: workspaceURL
+ )
+ languageServerStartupFailures[descriptor.id] = nil
+ return languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id)
+ } catch {
+ let message = error.localizedDescription
+ if languageServerStartupFailures[descriptor.id] != message {
+ languageServerStartupFailures[descriptor.id] = message
+ languageToolingSessions.recordLanguageServerLog(
+ providerID: descriptor.id,
+ level: .error,
+ message: "Language server activation failed",
+ detail: message
+ )
+ showNotification("Could not start \(descriptor.displayName) language server: \(message)")
+ }
+ return false
+ }
}
func searchProject(options: ProjectSearchOptions = .default) async {
diff --git a/Sources/Lithe/Models/JavaDiagnosticModels.swift b/Sources/Lithe/Models/JavaDiagnosticModels.swift
index 7cbd95a8e..c06195441 100644
--- a/Sources/Lithe/Models/JavaDiagnosticModels.swift
+++ b/Sources/Lithe/Models/JavaDiagnosticModels.swift
@@ -1,6 +1,7 @@
import Foundation
enum DiagnosticSeverity: Int, CaseIterable, Hashable, Sendable {
+ case unknown = 0
case error = 1
case warning = 2
case information = 3
@@ -8,6 +9,7 @@ enum DiagnosticSeverity: Int, CaseIterable, Hashable, Sendable {
var title: String {
switch self {
+ case .unknown: "Unknown"
case .error: "Error"
case .warning: "Warning"
case .information: "Information"
@@ -17,6 +19,7 @@ enum DiagnosticSeverity: Int, CaseIterable, Hashable, Sendable {
var systemImage: String {
switch self {
+ case .unknown: "questionmark.circle.fill"
case .error: "xmark.octagon.fill"
case .warning: "exclamationmark.triangle.fill"
case .information: "info.circle.fill"
@@ -134,46 +137,31 @@ struct EditorDiagnostic: Identifiable, Hashable, Sendable {
utf16Column: column,
endLine: endLine,
endUTF16Column: endColumn,
- severity: DiagnosticSeverity(rawValue: diagnostic.severity ?? 1) ?? .error,
+ severity: diagnostic.severity.flatMap(DiagnosticSeverity.init(rawValue:)) ?? .unknown,
message: diagnostic.message,
source: diagnostic.source,
code: diagnostic.code,
- tags: [],
- relatedInformation: []
+ tags: Set(diagnostic.tags.compactMap(DiagnosticTag.init(rawValue:))),
+ relatedInformation: diagnostic.relatedInformation.map {
+ DiagnosticRelatedInformation(
+ fileURL: $0.fileURL.standardizedFileURL,
+ line: max(0, $0.range.start.line),
+ utf16Column: max(0, $0.range.start.utf16Column),
+ message: $0.message
+ )
+ }
)
}
- static func merging(
- _ editorDiagnostics: [URL: [EditorDiagnostic]],
- languageServerDiagnostics: [URL: [LanguageServerDiagnostic]]
+ static func fromLanguageServerDiagnostics(
+ _ diagnosticsByFile: [URL: [LanguageServerDiagnostic]]
) -> [URL: [EditorDiagnostic]] {
- var merged = Dictionary(uniqueKeysWithValues: editorDiagnostics.map {
- ($0.key.standardizedFileURL, $0.value)
- })
- for (fileURL, diagnostics) in languageServerDiagnostics {
+ diagnosticsByFile.reduce(into: [URL: [EditorDiagnostic]]()) { mapped, entry in
+ let (fileURL, diagnostics) = entry
let normalizedURL = fileURL.standardizedFileURL
- var existing = merged[normalizedURL] ?? []
- for diagnostic in diagnostics.map({
+ mapped[normalizedURL, default: []].append(contentsOf: diagnostics.map {
EditorDiagnostic(languageServerDiagnostic: $0, fileURL: normalizedURL)
- }) where !existing.contains(where: { $0.semanticIdentity == diagnostic.semanticIdentity }) {
- existing.append(diagnostic)
- }
- merged[normalizedURL] = existing
+ })
}
- return merged
- }
-
- private var semanticIdentity: String {
- [
- String(line), String(utf16Column), String(endLine), String(endUTF16Column),
- source ?? "", code ?? "", message
- ].joined(separator: "\u{1F}")
}
}
-
-// Transitional source compatibility while the Java feature moves onto the
-// editor-wide diagnostics capability.
-typealias JavaDiagnosticSeverity = DiagnosticSeverity
-typealias JavaDiagnosticTag = DiagnosticTag
-typealias JavaDiagnosticRelatedInformation = DiagnosticRelatedInformation
-typealias JavaDiagnostic = EditorDiagnostic
diff --git a/Sources/Lithe/Models/ProjectRuntimeModels.swift b/Sources/Lithe/Models/ProjectRuntimeModels.swift
index 972bbc887..6592ddf1e 100644
--- a/Sources/Lithe/Models/ProjectRuntimeModels.swift
+++ b/Sources/Lithe/Models/ProjectRuntimeModels.swift
@@ -61,7 +61,6 @@ enum JavaEnvironmentStatus: Equatable, Sendable {
case jdkMissing
case configuredJDKInvalid(path: String)
case jdbMissing
- case languageServerMissing
var requiresAttention: Bool {
self != .checking && self != .ready
@@ -70,14 +69,7 @@ enum JavaEnvironmentStatus: Equatable, Sendable {
var blocksJavaRun: Bool {
switch self {
case .jdkMissing, .configuredJDKInvalid, .jdbMissing: true
- case .checking, .ready, .languageServerMissing: false
- }
- }
-
- var blocksJavaEditing: Bool {
- switch self {
case .checking, .ready: false
- case .jdkMissing, .configuredJDKInvalid, .languageServerMissing, .jdbMissing: true
}
}
}
@@ -88,7 +80,6 @@ struct JavaEnvironmentReport: Equatable, Sendable {
let javaHomePath: String?
let javaExecutablePath: String?
let jdbExecutablePath: String?
- let languageServerExecutablePath: String?
static func checking(for projectURL: URL) -> Self {
Self(
@@ -96,8 +87,7 @@ struct JavaEnvironmentReport: Equatable, Sendable {
projectURL: projectURL.standardizedFileURL,
javaHomePath: nil,
javaExecutablePath: nil,
- jdbExecutablePath: nil,
- languageServerExecutablePath: nil
+ jdbExecutablePath: nil
)
}
@@ -108,24 +98,21 @@ struct JavaEnvironmentReport: Equatable, Sendable {
case .jdkMissing: "JDK not found"
case .configuredJDKInvalid: "Configured JDK is invalid"
case .jdbMissing: "Java debugger is incomplete"
- case .languageServerMissing: "Java language server not found"
}
}
var message: String {
switch status {
case .checking:
- "Lithe is checking the JDK, Java debugger, and Java language server."
+ "Lithe is checking the JDK and Java debugger."
case .ready:
- "JDK, JDB, and the Java language server are available for this project."
+ "JDK and JDB are available for this project."
case .jdkMissing:
"This project contains Java sources, but no usable JDK was detected."
case .configuredJDKInvalid(let path):
"The configured JDK path is not a valid JDK: \(path)"
case .jdbMissing:
"A JDK was found, but its bin/jdb debugger is unavailable."
- case .languageServerMissing:
- "The JDK is available, but JDT LS is not installed or configured."
}
}
@@ -138,8 +125,6 @@ struct JavaEnvironmentReport: Equatable, Sendable {
"Choose another JDK in Project Settings or clear the invalid path."
case .jdbMissing:
"Use a full JDK distribution instead of a JRE or minimal runtime."
- case .languageServerMissing:
- "Install/configure jdtls in Project Settings; Java run/debug remains available where the JDK supports it."
}
}
}
diff --git a/Sources/Lithe/Models/ProjectSessionManager.swift b/Sources/Lithe/Models/ProjectSessionManager.swift
index 8bc026780..8282f3b42 100644
--- a/Sources/Lithe/Models/ProjectSessionManager.swift
+++ b/Sources/Lithe/Models/ProjectSessionManager.swift
@@ -149,7 +149,7 @@ final class ProjectSessionManager: ObservableObject {
func stopAllSessions() {
for model in sessions {
- model.stopTerminalSessions()
+ model.shutdownProjectSession()
}
}
diff --git a/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterLocator.swift b/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterLocator.swift
deleted file mode 100644
index ecef78aee..000000000
--- a/Sources/Lithe/Platform/MacOS/Debug/MacJavaDebugAdapterLocator.swift
+++ /dev/null
@@ -1,134 +0,0 @@
-import Foundation
-
-struct JavaDebugAdapterProcessLaunch: Sendable {
- let executableURL: URL
- let arguments: [String]
- let environment: [String: String]
-}
-
-/// Locates an optional stdio Java DAP adapter without installing or starting
-/// it. Java's JDB integration remains the fallback when this adapter is not
-/// present, so a clean machine keeps the existing debugging workflow.
-struct MacJavaDebugAdapterLocator {
- private let environment: [String: String]
- private let homeDirectoryURL: URL
- private let launchDefinition: StdioDebugAdapterLaunch?
-
- init(
- environment: [String: String],
- homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser,
- launchDefinition: StdioDebugAdapterLaunch? = nil
- ) {
- self.environment = environment
- self.homeDirectoryURL = homeDirectoryURL.standardizedFileURL
- self.launchDefinition = launchDefinition
- }
-
- func resolve(
- rootURL: URL,
- javaExecutableURL: URL?
- ) -> JavaDebugAdapterProcessLaunch? {
- let configured = environment["LITHE_JAVA_DEBUG_PATH"]
- .flatMap { configuredURL($0, relativeTo: rootURL) }
- let roots = [
- configured,
- rootURL.appendingPathComponent(".lithe/toolchains/java-debug", isDirectory: true),
- homeDirectoryURL.appendingPathComponent("Library/Application Support/Lithe/java-debug", isDirectory: true),
- homeDirectoryURL.appendingPathComponent(".local/share/lithe/java-debug", isDirectory: true)
- ].compactMap { $0 }
-
- for root in roots {
- if let launch = launch(for: root, javaExecutableURL: javaExecutableURL) {
- return launch
- }
- }
-
- for command in launchDefinition?.executableNames
- ?? ["java-debug-adapter", "java-debug", "jdtls-debug"] {
- if let executable = executableOnPath(command) {
- return JavaDebugAdapterProcessLaunch(
- executableURL: executable,
- arguments: arguments(for: executable, defaultArguments: launchDefinition?.arguments ?? []),
- environment: environment
- )
- }
- }
- return nil
- }
-
- var unavailableMessage: String {
- "Java generic debugging requires a stdio Debug Adapter. Set LITHE_JAVA_DEBUG_PATH to the adapter executable or JAR; JDB remains available as the fallback."
- }
-
- private func launch(
- for candidate: URL,
- javaExecutableURL: URL?
- ) -> JavaDebugAdapterProcessLaunch? {
- var isDirectory: ObjCBool = false
- guard FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory) else {
- return nil
- }
- if isDirectory.boolValue {
- let names = (launchDefinition?.executableNames ?? [
- "java-debug-adapter",
- "java-debug",
- "jdtls-debug",
- "java-debug-adapter.jar",
- "java-debug-server.jar"
- ]) + ["java-debug-adapter.jar", "java-debug-server.jar"]
- for name in names {
- if let launch = launch(
- for: candidate.appendingPathComponent(name),
- javaExecutableURL: javaExecutableURL
- ) {
- return launch
- }
- }
- return nil
- }
-
- if candidate.pathExtension.lowercased() == "jar" {
- guard let javaExecutableURL else { return nil }
- return JavaDebugAdapterProcessLaunch(
- executableURL: javaExecutableURL,
- arguments: arguments(
- for: candidate,
- defaultArguments: ["-jar", candidate.path] + (launchDefinition?.arguments ?? ["--stdio"])
- ),
- environment: environment
- )
- }
- guard FileManager.default.isExecutableFile(atPath: candidate.path) else { return nil }
- return JavaDebugAdapterProcessLaunch(
- executableURL: candidate,
- arguments: arguments(for: candidate, defaultArguments: launchDefinition?.arguments ?? []),
- environment: environment
- )
- }
-
- private func arguments(for _: URL, defaultArguments: [String]) -> [String] {
- guard let raw = environment["LITHE_JAVA_DEBUG_ARGS"] else { return defaultArguments }
- return RunArgumentParser.parse(raw)
- }
-
- private func executableOnPath(_ command: String) -> URL? {
- for directory in (environment["PATH"] ?? "").split(separator: ":") where !directory.isEmpty {
- let candidate = URL(fileURLWithPath: String(directory))
- .appendingPathComponent(command)
- .standardizedFileURL
- if FileManager.default.isExecutableFile(atPath: candidate.path) {
- return candidate
- }
- }
- return nil
- }
-
- private func configuredURL(_ value: String, relativeTo rootURL: URL) -> URL? {
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nil }
- if trimmed.hasPrefix("/") || trimmed.hasPrefix("~") {
- return URL(fileURLWithPath: (trimmed as NSString).expandingTildeInPath)
- }
- return rootURL.appendingPathComponent(trimmed)
- }
-}
diff --git a/Sources/Lithe/Platform/MacOS/FileSystem/MacArchiveEntryReader.swift b/Sources/Lithe/Platform/MacOS/FileSystem/MacArchiveEntryReader.swift
deleted file mode 100644
index 2fd5b21a7..000000000
--- a/Sources/Lithe/Platform/MacOS/FileSystem/MacArchiveEntryReader.swift
+++ /dev/null
@@ -1,18 +0,0 @@
-import Foundation
-
-struct MacArchiveEntryReader: ArchiveEntryReader {
- private let processRunner: any ProcessRunner
-
- init(processRunner: any ProcessRunner = MacProcessRunner()) {
- self.processRunner = processRunner
- }
-
- func read(entry: String, from archive: URL) -> String? {
- let result = processRunner.run(ProcessRequest(
- executablePath: "/usr/bin/unzip",
- arguments: ["-p", archive.path, entry]
- ))
- guard result.succeeded else { return nil }
- return result.output
- }
-}
diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
index 86be20a7b..4d7a25bbe 100644
--- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
+++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
@@ -41,101 +41,75 @@ final class MacServiceContainer {
toolchainSource: runConfigurationStore,
toolDiscovery: MacRuntimeToolDiscovery()
)
- let languageProviderCatalog = LanguageProviderCatalog.standard
+ let languageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore)
+ let languageProviderCatalogSnapshot = languageProviderCatalogSource.load()
+ let languageProviderCatalog = languageProviderCatalogSnapshot.catalog
+ let languageServerTools = LanguageServerToolService(
+ runtimeService: runtimeService,
+ processRunner: processRunner,
+ store: store
+ )
// Build the catalog once so every standard runtime consumes the
// language-pack launch metadata instead of maintaining a second map.
let languagePackDefinitions = LanguagePackRegistry.standard(
catalog: languageProviderCatalog
)
- let languageService = JavaLanguageService(
- runtimeService: runtimeService,
- process: MacRawProcessSession(),
- archiveReader: MacArchiveEntryReader(processRunner: processRunner),
- fileStorage: fileStorage,
- javaMavenOperations: javaMavenOperations
- )
- let javaDebugLaunch = languagePackDefinitions.pack(id: "java")?.debugAdapterLaunch
- let javaRuntime = JavaLanguageProviderRuntime(
- service: languageService,
- catalog: languageProviderCatalog,
- debugAdapterAvailability: {
- guard let projectURL = runtimeService.projectURL else { return false }
- let locator = MacJavaDebugAdapterLocator(
- environment: runtimeService.processEnvironment(),
- launchDefinition: javaDebugLaunch
+ let debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [
+ "go": {
+ guard let dlv = runtimeService.executableOnPath("dlv") else { return nil }
+ return DebugAdapterProtocolSession(
+ adapterID: "go",
+ transport: MacDlvDebugAdapterTransport(
+ executableURL: dlv,
+ environment: runtimeService.processEnvironment(),
+ process: MacRawProcessSession()
+ )
)
- return locator.resolve(
- rootURL: projectURL,
- javaExecutableURL: runtimeService.configuredJavaExecutableURL()
- ) != nil
},
- debugAdapterUnavailableMessage: {
- MacJavaDebugAdapterLocator(
- environment: runtimeService.processEnvironment(),
- launchDefinition: javaDebugLaunch
- ).unavailableMessage
- },
- debugAdapterFactory: { rootURL in
- let locator = MacJavaDebugAdapterLocator(
- environment: runtimeService.environment(for: .java),
- launchDefinition: javaDebugLaunch
+ "node": {
+ guard let node = runtimeService.executableOnPath("node") else { return nil }
+ let environment = runtimeService.processEnvironment()
+ let locator = MacJavaScriptDebugAdapterLocator(
+ environment: environment,
+ executableOnPath: { runtimeService.executableOnPath($0) }
)
- guard let launch = locator.resolve(
- rootURL: rootURL,
- javaExecutableURL: runtimeService.javaExecutableURL()
- ) else { return nil }
return DebugAdapterProtocolSession(
- adapterID: "java",
- executableURL: launch.executableURL,
- arguments: launch.arguments,
- environment: launch.environment,
- process: MacRawProcessSession()
+ adapterID: "pwa-node",
+ transport: MacNodeDebugAdapterTransport(
+ nodeExecutableURL: node,
+ locator: locator,
+ process: MacRawProcessSession()
+ )
)
}
+ ]
+ let debugLaunches = Dictionary(
+ uniqueKeysWithValues: languagePackDefinitions.packs.compactMap { pack in
+ pack.debugAdapterLaunch.map { (pack.descriptor.id, $0) }
+ }
)
- let languageToolingRuntimes: [any LanguageProviderRuntime] = [
- javaRuntime
- ] + StdioLanguageProviderRuntime.standard(
- packs: languagePackDefinitions.packs,
+ let languageToolingRuntimeFactory = StdioLanguageProviderRuntimeFactory(
runtimeService: runtimeService,
processFactory: { MacRawProcessSession() },
- debugSessionFactories: [
- "go": {
- guard let dlv = runtimeService.executableOnPath("dlv") else { return nil }
- return DebugAdapterProtocolSession(
- adapterID: "go",
- transport: MacDlvDebugAdapterTransport(
- executableURL: dlv,
- environment: runtimeService.processEnvironment(),
- process: MacRawProcessSession()
- )
- )
- },
- "node": {
- guard let node = runtimeService.executableOnPath("node") else { return nil }
- let environment = runtimeService.processEnvironment()
- let locator = MacJavaScriptDebugAdapterLocator(
- environment: environment,
- executableOnPath: { runtimeService.executableOnPath($0) }
- )
- return DebugAdapterProtocolSession(
- adapterID: "pwa-node",
- transport: MacNodeDebugAdapterTransport(
- nodeExecutableURL: node,
- locator: locator,
- process: MacRawProcessSession()
- )
- )
- }
- ]
+ languageServerCore: rustCore,
+ languageServerExecutableResolver: { descriptor in
+ languageServerTools.executableURL(for: descriptor)
+ },
+ debugLaunches: debugLaunches,
+ debugSessionFactories: debugSessionFactories
)
+ let languageToolingRuntimes: [any LanguageProviderRuntime] = languagePackDefinitions.packs
+ .compactMap { languageToolingRuntimeFactory.makeRuntime(for: $0.descriptor) }
let languagePackRegistry = LanguagePackRegistry.standard(
catalog: languageProviderCatalog,
runtimes: languageToolingRuntimes
)
let runToolchainRegistry = languagePackRegistry.toolchainRegistry
let languageToolingSessions = LanguageToolingSessionManager(
- registry: languagePackRegistry
+ catalog: languagePackRegistry.catalog,
+ runtimes: languagePackRegistry.toolingRuntimes,
+ runtimeFactory: languageToolingRuntimeFactory,
+ core: rustCore
)
let testExecutableResolver = RunExecutableResolver(
runtimeService: runtimeService,
@@ -175,9 +149,6 @@ final class MacServiceContainer {
javaMavenOperations: javaMavenOperations,
runConfigurationOperations: runConfigurationStore
)
- let javaImplementationMarkerService = JavaImplementationMarkerService(
- languageService: languageService
- )
let gitOperations = RustGitOperations(core: rustCore)
let workspaceOperations = RustWorkspaceOperations(core: rustCore)
let localHistoryOperations = RustLocalHistoryOperations(core: rustCore)
@@ -201,10 +172,12 @@ final class MacServiceContainer {
credentialResolver: credentialResolver
)
services = AppServices(
- languageProviderCatalog: languagePackRegistry.catalog,
+ languageProviderCatalogSource: languageProviderCatalogSource,
+ languageProviderCatalogSnapshot: languageProviderCatalogSnapshot,
languagePacks: languagePackRegistry,
runToolchainRegistry: runToolchainRegistry,
languageToolingSessions: languageToolingSessions,
+ languageServerTools: languageServerTools,
languageTestService: languageTestService,
workspaceOperations: workspaceOperations,
localHistoryOperations: localHistoryOperations,
@@ -215,8 +188,6 @@ final class MacServiceContainer {
fileStorage: fileStorage,
fileOperations: fileOperations,
projectRuntimeService: runtimeService,
- javaLanguageService: languageService,
- javaImplementationMarkerService: javaImplementationMarkerService,
mavenService: mavenService,
runService: runService,
javaDebugService: javaDebugService,
diff --git a/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift b/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift
index 731b201d1..d96d3e7c7 100644
--- a/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift
+++ b/Sources/Lithe/Platform/MacOS/Process/MacRawProcessSession.swift
@@ -74,9 +74,19 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable {
self.onTermination?(terminatedProcess.terminationStatus)
}
+ self.process = process
+ self.inputPipe = inputPipe
+ self.outputPipe = outputPipe
+ self.errorPipe = errorPipe
do {
try process.run()
} catch {
+ outputPipe.fileHandleForReading.readabilityHandler = nil
+ errorPipe.fileHandleForReading.readabilityHandler = nil
+ self.process = nil
+ self.inputPipe = nil
+ self.outputPipe = nil
+ self.errorPipe = nil
onStateChange?(ProcessLifecycleEvent(
operationID: request.operationID,
state: .failed,
@@ -86,10 +96,6 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable {
activeOperationID = nil
throw error
}
- self.process = process
- self.inputPipe = inputPipe
- self.outputPipe = outputPipe
- self.errorPipe = errorPipe
if let input = request.standardInput, let inputPipe {
try inputPipe.fileHandleForWriting.write(contentsOf: input)
if !request.keepsStandardInputOpen {
@@ -106,7 +112,13 @@ final class MacRawProcessSession: RawProcessSession, @unchecked Sendable {
}
func send(_ input: Data) throws {
- try inputPipe?.fileHandleForWriting.write(contentsOf: input)
+ guard process?.isRunning == true else {
+ throw RawProcessSessionError.notRunning
+ }
+ guard let inputPipe else {
+ throw RawProcessSessionError.standardInputUnavailable
+ }
+ try inputPipe.fileHandleForWriting.write(contentsOf: input)
}
func stop() {
diff --git a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift
index b1ea9ee8c..de6fd4c99 100644
--- a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift
+++ b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeDiscovery.swift
@@ -25,16 +25,6 @@ enum MacRuntimeDiscovery {
.first(where: { FileManager.default.isExecutableFile(atPath: $0.path) })
}
- static func javaLanguageServerExecutable() -> URL? {
- [
- "/opt/homebrew/bin/jdtls",
- "/usr/local/bin/jdtls",
- "/usr/bin/jdtls"
- ]
- .map(URL.init(fileURLWithPath:))
- .first(where: { FileManager.default.isExecutableFile(atPath: $0.path) })
- }
-
static func systemMavenExecutable(environment: [String: String]) -> URL? {
discoverMavenExecutables(environment: environment).first
}
diff --git a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift
index 808141cf4..70c89b84c 100644
--- a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift
+++ b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeLocator.swift
@@ -36,8 +36,4 @@ struct MacRuntimeLocator: RuntimeLocator {
func systemJDBExecutable() -> URL? {
MacRuntimeDiscovery.systemJDBExecutable()
}
-
- func javaLanguageServerExecutable() -> URL? {
- MacRuntimeDiscovery.javaLanguageServerExecutable()
- }
}
diff --git a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift
index a1aa43504..19598ab01 100644
--- a/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift
+++ b/Sources/Lithe/Platform/MacOS/Runtime/MacRuntimeToolDiscovery.swift
@@ -70,6 +70,16 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery {
)
}
+ if shouldSearchGoUserBin(for: command) {
+ for directory in goUserBinDirectories(environment: environment) {
+ add(
+ directory.appendingPathComponent(command),
+ source: .environment,
+ detail: "Go user bin: \(directory.path)"
+ )
+ }
+ }
+
let homebrewRoots = [
"/opt/homebrew/bin",
"/usr/local/bin",
@@ -115,28 +125,28 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery {
environment: [String: String]
) -> RuntimeToolGuidance {
switch command {
- case "go", "gopls", "dlv":
+ case "go", "dlv":
return RuntimeToolGuidance(
command: command,
displayName: "Go toolchain",
summary: "Go tooling (\(command)) was not found.",
recovery: "Install Go, then install the missing tool with `go install` or add its bin directory to PATH."
)
- case "python", "python3", "basedpyright-langserver", "pyright-langserver":
+ case "python", "python3":
return RuntimeToolGuidance(
command: command,
displayName: "Python toolchain",
summary: "Python tooling (\(command)) was not found.",
recovery: "Select a Python interpreter or virtual environment, install the provider there, and ensure its bin directory is on PATH."
)
- case "node", "npm", "npx", "typescript-language-server", "tsx", "ts-node":
+ case "node", "npm", "npx", "tsx", "ts-node":
return RuntimeToolGuidance(
command: command,
displayName: "Node.js toolchain",
summary: "Node.js tooling (\(command)) was not found.",
recovery: "Install Node.js and the required npm package, then restart Lithe or add the Node bin directory to PATH."
)
- case "rust-analyzer", "cargo", "rustc", "lldb-dap":
+ case "cargo", "rustc", "lldb-dap":
return RuntimeToolGuidance(
command: command,
displayName: "Rust/Xcode toolchain",
@@ -145,7 +155,7 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery {
? "Install or select Xcode Command Line Tools, or configure an lldb-dap path explicitly."
: "Install Rust with rustup and ensure Cargo's bin directory is on PATH."
)
- case "java-debug-adapter", "java-debug", "jdtls-debug":
+ case "java-debug-adapter", "java-debug":
return RuntimeToolGuidance(
command: command,
displayName: "Java Debug Adapter",
@@ -180,14 +190,11 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery {
private func homebrewFormula(for command: String) -> String {
switch command {
- case "gopls": "go"
case "dlv": "delve"
- case "basedpyright-langserver": "basedpyright"
- case "pyright-langserver": "pyright"
- case "typescript-language-server": "typescript-language-server"
case "tsx": "tsx"
case "ts-node": "ts-node"
- case "rust-analyzer", "cargo", "rustc": "rust"
+ case "cargo", "rustc": "rust"
+ case "clangd": "llvm"
case "lldb-dap": "llvm"
default: command
}
@@ -199,4 +206,26 @@ struct MacRuntimeToolDiscovery: RuntimeToolDiscovery {
|| path.hasPrefix("/opt/homebrew/opt/")
|| path.hasPrefix("/usr/local/opt/")
}
+
+ private func goUserBinDirectories(environment: [String: String]) -> [URL] {
+ var directories: [URL] = []
+ if let goBin = configuredURL(environment["GOBIN"] ?? "", projectURL: nil) {
+ directories.append(goBin)
+ }
+ for goPath in (environment["GOPATH"] ?? "").split(separator: ":") where !goPath.isEmpty {
+ directories.append(URL(fileURLWithPath: String(goPath)).appendingPathComponent("bin"))
+ }
+ directories.append(homeDirectoryURL.appendingPathComponent("go/bin"))
+ directories.append(homeDirectoryURL.appendingPathComponent(".go/bin"))
+ return directories
+ }
+
+ private func shouldSearchGoUserBin(for command: String) -> Bool {
+ switch command {
+ case "dlv", "gofumpt", "goimports", "gomodifytags", "gopls", "staticcheck":
+ return true
+ default:
+ return false
+ }
+ }
}
diff --git a/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift b/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift
index ea86488bb..662b9b308 100644
--- a/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift
+++ b/Sources/Lithe/Platform/MacOS/UI/MacPlatformUI.swift
@@ -14,6 +14,17 @@ final class MacPlatformUI: PlatformUI {
return panel.runModal() == .OK ? panel.url : nil
}
+ func chooseFile(title: String, prompt: String) -> URL? {
+ let panel = NSOpenPanel()
+ panel.title = title
+ panel.prompt = prompt
+ panel.canChooseDirectories = false
+ panel.canChooseFiles = true
+ panel.allowsMultipleSelection = false
+ panel.resolvesAliases = true
+ return panel.runModal() == .OK ? panel.url : nil
+ }
+
func revealInFileBrowser(_ url: URL) {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
diff --git a/Sources/Lithe/Services/JavaImplementationMarkerService.swift b/Sources/Lithe/Services/JavaImplementationMarkerService.swift
deleted file mode 100644
index 122c49c92..000000000
--- a/Sources/Lithe/Services/JavaImplementationMarkerService.swift
+++ /dev/null
@@ -1,110 +0,0 @@
-import Foundation
-
-/// Resolves Core-produced Java implementation candidates through JDT LS. The
-/// editor only draws markers that represent a real implementation relationship.
-@MainActor
-final class JavaImplementationMarkerService: @unchecked Sendable {
- private struct CacheEntry {
- let fingerprint: Int
- let markers: [JavaImplementationMarker]
- }
-
- private let languageService: JavaLanguageService
- private var cache: [URL: CacheEntry] = [:]
-
- init(languageService: JavaLanguageService) {
- self.languageService = languageService
- }
-
- func invalidate(_ document: EditorDocument) {
- cache[document.url.standardizedFileURL] = nil
- }
-
- func markers(
- for document: EditorDocument,
- candidates: [JavaImplementationMarker]
- ) async -> [JavaImplementationMarker] {
- guard document.url.pathExtension.lowercased() == "java" else { return [] }
-
- let url = document.url.standardizedFileURL
- let fingerprint = document.text.hashValue
- if let cached = cache[url], cached.fingerprint == fingerprint {
- return cached.markers
- }
-
- let limitedCandidates = Array(candidates.prefix(60))
- guard !limitedCandidates.isEmpty else {
- cache[url] = CacheEntry(fingerprint: fingerprint, markers: [])
- return []
- }
-
- var resolved: [JavaImplementationMarker] = []
- // Four requests at a time keeps JDT LS responsive while avoiding a
- // burst of requests for large interfaces.
- for start in stride(from: 0, to: limitedCandidates.count, by: 4) {
- let end = min(start + 4, limitedCandidates.count)
- let batch = Array(limitedCandidates[start.. [LanguageNavigationLocation] {
- await withCheckedContinuation { continuation in
- languageService.locations(
- method: "textDocument/implementation",
- document: document,
- line: candidate.line,
- utf16Column: candidate.utf16Column
- ) { result in
- switch result {
- case .success(let locations): continuation.resume(returning: locations)
- case .failure: continuation.resume(returning: [])
- }
- }
- }
- }
-}
diff --git a/Sources/Lithe/Services/JavaLanguageProviderRuntime.swift b/Sources/Lithe/Services/JavaLanguageProviderRuntime.swift
deleted file mode 100644
index 02869e8c7..000000000
--- a/Sources/Lithe/Services/JavaLanguageProviderRuntime.swift
+++ /dev/null
@@ -1,304 +0,0 @@
-import Foundation
-
-/// Bridges the existing JDT LS client into the language-neutral lifecycle.
-/// Java navigation keeps its mature protocol implementation while workspace
-/// ownership and shutdown move to the shared provider manager.
-@MainActor
-final class JavaLanguageProviderRuntime: LanguageProviderRuntime {
- let descriptor: LanguageProviderDescriptor
- private let service: JavaLanguageService
- private let debugAdapterFactory: (@MainActor (URL) -> (any DebugAdapterSession)?)?
- private let debugAdapterAvailability: @MainActor () -> Bool
- private let debugAdapterUnavailableMessage: @MainActor () -> String?
- var supportsEditingSession: Bool { true }
- var supportsDebugAdapterSession: Bool {
- debugAdapterFactory != nil && debugAdapterAvailability()
- }
- var unavailableToolingMessage: String? { debugAdapterUnavailableMessage() }
- var declaredLanguageServerFeatures: LanguageServerFeatureSet { .standardEditing }
-
- init(
- service: JavaLanguageService,
- catalog: LanguageProviderCatalog = .standard,
- debugAdapterAvailability: @escaping @MainActor () -> Bool = { false },
- debugAdapterUnavailableMessage: @escaping @MainActor () -> String? = { nil },
- debugAdapterFactory: (@MainActor (URL) -> (any DebugAdapterSession)?)? = nil
- ) {
- precondition(
- catalog.descriptors.contains(where: { $0.id == "java" }),
- "The language provider catalog must contain Java"
- )
- descriptor = catalog.descriptors.first { $0.id == "java" }!
- self.service = service
- self.debugAdapterFactory = debugAdapterFactory
- self.debugAdapterAvailability = debugAdapterAvailability
- self.debugAdapterUnavailableMessage = debugAdapterUnavailableMessage
- }
-
- func makeLanguageServerSession() -> (any LanguageServerSession)? {
- JavaLanguageServerLifecycleSession(service: service)
- }
-
- func makeDebugAdapterSession() -> (any DebugAdapterSession)? {
- debugAdapterFactory?(URL(fileURLWithPath: "."))
- }
-
- func makeDebugAdapterSession(rootURL: URL) -> (any DebugAdapterSession)? {
- debugAdapterFactory?(rootURL.standardizedFileURL)
- }
-}
-
-@MainActor
-private final class JavaLanguageServerLifecycleSession: LanguageServerEditingSession, LanguageServerFeatureReportingSession {
- private let service: JavaLanguageService
- private var documents: [URL: EditorDocument] = [:]
- var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? {
- didSet { configureDiagnostics() }
- }
- private(set) var supportedFeatures: LanguageServerFeatureSet = .standardEditing
- var onSupportedFeaturesChange: ((LanguageServerFeatureSet) -> Void)?
-
- init(service: JavaLanguageService) {
- self.service = service
- service.onLanguageServerFeatures = { [weak self] features in
- self?.supportedFeatures = features
- self?.onSupportedFeaturesChange?(features)
- }
- }
-
- var isRunning: Bool { service.isStarting || service.isReady }
- var isReady: Bool { service.isReady }
-
- func start(rootURL: URL) throws {
- service.configureProjectRoot(rootURL)
- service.prepare(for: rootURL)
- }
-
- func stop() {
- documents = [:]
- service.onLanguageServerDiagnostics = nil
- service.onLanguageServerFeatures = nil
- service.stop()
- }
-
- func synchronizeDocument(url: URL, languageIdentifier: String, text: String) {
- let normalizedURL = url.standardizedFileURL
- let document: EditorDocument
- if let existing = documents[normalizedURL] {
- existing.text = text
- document = existing
- } else {
- document = EditorDocument(url: normalizedURL, text: text, modificationDate: nil)
- documents[normalizedURL] = document
- }
- service.update(document)
- }
-
- func closeDocument(url: URL) {
- let normalizedURL = url.standardizedFileURL
- guard let document = documents.removeValue(forKey: normalizedURL) else { return }
- service.close(document)
- }
-
- func locations(
- method: String,
- documentURL: URL,
- position: LanguageServerPosition,
- completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
- ) {
- guard let document = documents[documentURL.standardizedFileURL] else {
- completion(.failure(JavaLanguageService.ServiceError.invalidResponse))
- return
- }
- service.locations(
- method: method,
- document: document,
- line: position.line,
- utf16Column: position.utf16Column
- ) { result in
- completion(result.map { locations in
- locations.map { location in
- let point = LanguageServerPosition(
- line: location.line,
- utf16Column: location.utf16Column
- )
- return LanguageServerLocation(
- url: location.url,
- range: LanguageServerRange(start: point, end: point),
- isReadOnly: location.isReadOnly,
- displayPath: location.displayPath
- )
- }
- })
- }
- }
-
- func hover(
- documentURL: URL,
- position: LanguageServerPosition,
- completion: @escaping (Result) -> Void
- ) {
- request(
- method: "textDocument/hover",
- documentURL: documentURL,
- parameters: positionParameters(documentURL, position)
- ) { completion($0.map(LanguageServerResponseParser.hover)) }
- }
-
- func completions(
- documentURL: URL,
- position: LanguageServerPosition,
- completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
- ) {
- var parameters = positionParameters(documentURL, position)
- parameters["context"] = ["triggerKind": 1]
- request(method: "textDocument/completion", documentURL: documentURL, parameters: parameters) {
- completion($0.map(LanguageServerResponseParser.completionItems))
- }
- }
-
- func rename(
- documentURL: URL,
- position: LanguageServerPosition,
- newName: String,
- completion: @escaping (Result) -> Void
- ) {
- var parameters = positionParameters(documentURL, position)
- parameters["newName"] = newName
- request(method: "textDocument/rename", documentURL: documentURL, parameters: parameters) {
- completion($0.map(LanguageServerResponseParser.workspaceEdit))
- }
- }
-
- func formatting(
- documentURL: URL,
- options: [String: Any],
- completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void
- ) {
- request(
- method: "textDocument/formatting",
- documentURL: documentURL,
- parameters: ["textDocument": ["uri": documentURL.standardizedFileURL.absoluteString], "options": options]
- ) { completion($0.map(LanguageServerResponseParser.textEdits)) }
- }
-
- func codeActions(
- documentURL: URL,
- range: LanguageServerRange,
- diagnostics: [LanguageServerDiagnostic],
- completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void
- ) {
- let parameters: [String: Any] = [
- "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString],
- "range": Self.foundationRange(range),
- "context": [
- "diagnostics": diagnostics.map(Self.foundationDiagnostic),
- "only": ["quickfix", "refactor"]
- ]
- ]
- request(method: "textDocument/codeAction", documentURL: documentURL, parameters: parameters) {
- completion($0.map(LanguageServerResponseParser.codeActions))
- }
- }
-
- func execute(
- command: LanguageServerCommand,
- completion: @escaping (Result) -> Void
- ) {
- service.executeLanguageServerCommand(command, completion: completion)
- }
-
- func resolveCompletion(
- _ item: LanguageServerCompletionItem,
- completion: @escaping (Result) -> Void
- ) {
- service.languageServerRequest(
- method: "completionItem/resolve",
- parameters: LanguageServerResponseParser.foundationCompletionItem(item)
- ) { result in
- completion(result.flatMap { value in
- guard let item = LanguageServerResponseParser.completionItem(value) else {
- return .failure(JavaLanguageService.ServiceError.invalidResponse)
- }
- return .success(item)
- })
- }
- }
-
- func resolveCodeAction(
- _ action: LanguageServerCodeAction,
- completion: @escaping (Result) -> Void
- ) {
- service.languageServerRequest(
- method: "codeAction/resolve",
- parameters: LanguageServerResponseParser.foundationCodeAction(action)
- ) { result in
- completion(result.flatMap { value in
- guard let action = LanguageServerResponseParser.codeAction(value) else {
- return .failure(JavaLanguageService.ServiceError.invalidResponse)
- }
- return .success(action)
- })
- }
- }
-
- private func request(
- method: String,
- documentURL: URL,
- parameters: [String: Any],
- completion: @escaping (Result) -> Void
- ) {
- guard let document = documents[documentURL.standardizedFileURL] else {
- completion(.failure(JavaLanguageService.ServiceError.invalidResponse))
- return
- }
- service.languageServerRequest(
- method: method,
- document: document,
- parameters: parameters,
- completion: completion
- )
- }
-
- private func positionParameters(
- _ documentURL: URL,
- _ position: LanguageServerPosition
- ) -> [String: Any] {
- [
- "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString],
- "position": ["line": position.line, "character": position.utf16Column]
- ]
- }
-
- private func configureDiagnostics() {
- service.onLanguageServerDiagnostics = { [weak self] url, diagnostics in
- self?.onDiagnostics?(url, diagnostics.map {
- LanguageServerDiagnostic(
- range: LanguageServerRange(
- start: LanguageServerPosition(line: $0.line, utf16Column: $0.utf16Column),
- end: LanguageServerPosition(line: $0.endLine, utf16Column: $0.endUTF16Column)
- ),
- severity: $0.severity.rawValue,
- message: $0.message,
- source: $0.source,
- code: $0.code
- )
- })
- }
- }
-
- private static func foundationRange(_ range: LanguageServerRange) -> [String: Any] {
- [
- "start": ["line": range.start.line, "character": range.start.utf16Column],
- "end": ["line": range.end.line, "character": range.end.utf16Column]
- ]
- }
-
- private static func foundationDiagnostic(_ diagnostic: LanguageServerDiagnostic) -> [String: Any] {
- var value: [String: Any] = ["range": foundationRange(diagnostic.range), "message": diagnostic.message]
- if let severity = diagnostic.severity { value["severity"] = severity }
- if let source = diagnostic.source { value["source"] = source }
- if let code = diagnostic.code { value["code"] = code }
- return value
- }
-}
diff --git a/Sources/Lithe/Services/JavaLanguageService.swift b/Sources/Lithe/Services/JavaLanguageService.swift
deleted file mode 100644
index 5eb3cffad..000000000
--- a/Sources/Lithe/Services/JavaLanguageService.swift
+++ /dev/null
@@ -1,1085 +0,0 @@
-import Foundation
-
-@MainActor
-final class JavaLanguageService: ObservableObject {
- enum ServiceError: LocalizedError {
- case serverNotInstalled
- case serverStopped
- case invalidResponse
- case capabilityUnavailable(String)
-
- var errorDescription: String? {
- switch self {
- case .serverNotInstalled:
- "Java language server is not installed. Run: brew install jdtls"
- case .serverStopped:
- "Java language server stopped unexpectedly"
- case .invalidResponse:
- "Java language server returned an invalid response"
- case .capabilityUnavailable(let method):
- "Java language server does not support \(method)"
- }
- }
- }
-
- @Published private(set) var isStarting = false
- @Published private(set) var isReady = false
- @Published private(set) var statusMessage = "Java navigation is idle"
- @Published private(set) var diagnostics: [URL: [JavaDiagnostic]] = [:]
-
- var onDiagnostics: ((URL, [JavaDiagnostic]) -> Void)?
- var onLanguageServerDiagnostics: ((URL, [JavaDiagnostic]) -> Void)?
- var onLanguageServerFeatures: ((LanguageServerFeatureSet) -> Void)?
-
- private let process: any RawProcessSession
- private var readBuffer = Data()
- private var nextRequestID = 1
- private var responseHandlers: [Int: (Result) -> Void] = [:]
- private var initializedLanguageServerFeatures: LanguageServerFeatureSet = .standardEditing
- private var dynamicallyRegisteredLanguageServerFeatures: [String: LanguageServerFeatureSet] = [:]
- private var readyHandlers: [(Result) -> Void] = []
- private var openedDocumentVersions: [String: Int] = [:]
- private var lastSentDocumentTextByURI: [String: String] = [:]
- private var projectURL: URL?
- private let runtimeService: ProjectRuntimeService
- private let archiveReader: any ArchiveEntryReader
- private let fileStorage: any FileStorage
- private let javaMavenOperations: any JavaMavenOperations
- private var activeOperationID: String?
-
- init(
- runtimeService: ProjectRuntimeService,
- process: any RawProcessSession,
- archiveReader: any ArchiveEntryReader,
- fileStorage: any FileStorage,
- javaMavenOperations: any JavaMavenOperations
- ) {
- self.runtimeService = runtimeService
- self.process = process
- self.archiveReader = archiveReader
- self.fileStorage = fileStorage
- self.javaMavenOperations = javaMavenOperations
- process.onOutput = { [weak self] data in
- Task { @MainActor [weak self] in
- self?.receive(data)
- }
- }
- process.onError = { _ in
- // JDT LS writes normal JVM diagnostics to stderr. The adapter drains
- // the stream without exposing it as navigation status.
- }
- process.onTermination = { [weak self] _ in
- Task { @MainActor [weak self] in
- guard let self, self.process.isRunning == false else { return }
- self.stop()
- }
- }
- process.onStateChange = { [weak self] event in
- Task { @MainActor [weak self] in
- self?.consumeLifecycle(event)
- }
- }
- }
-
- func configureProjectRoot(_ url: URL) {
- projectURL = url.standardizedFileURL
- }
-
- /// Starts JDT LS in the background while the workspace finishes opening.
- func prepare(for rootURL: URL) {
- guard !isReady, !isStarting else { return }
- ensureReady(for: rootURL) { _ in }
- }
-
- func locations(
- method: String,
- document: EditorDocument,
- line: Int,
- utf16Column: Int,
- completion: @escaping (Result<[LanguageNavigationLocation], Error>) -> Void
- ) {
- guard document.url.pathExtension.lowercased() == "java" else {
- completion(.failure(ServiceError.invalidResponse))
- return
- }
-
- ensureReady(for: document.url.deletingLastPathComponent()) { [weak self, weak document] result in
- guard let self, let document else { return }
- switch result {
- case .failure(let error):
- completion(.failure(error))
- case .success:
- self.synchronize(document)
- var parameters: [String: Any] = [
- "textDocument": ["uri": document.url.absoluteString],
- "position": ["line": line, "character": utf16Column]
- ]
- if method == "textDocument/references" {
- parameters["context"] = ["includeDeclaration": false]
- }
- self.sendRequest(method: method, parameters: parameters) { response in
- switch response {
- case .failure(let error):
- completion(.failure(error))
- case .success(let value):
- let locations = Self.parseLocations(value)
- if locations.isEmpty, method == "textDocument/definition" {
- self.resolveMissingJDKDefinition(
- document: document,
- line: line,
- utf16Column: utf16Column,
- parameters: parameters,
- completion: completion
- )
- } else {
- self.resolveExternalLocations(locations, completion: completion)
- }
- }
- }
- }
- }
- }
-
- func workspaceSymbols(
- query: String,
- rootURL: URL,
- documents: [EditorDocument],
- completion: @escaping (Result<[JavaWorkspaceSymbol], Error>) -> Void
- ) {
- let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !normalizedQuery.isEmpty else {
- completion(.success([]))
- return
- }
-
- ensureReady(for: rootURL) { [weak self] result in
- guard let self else { return }
- switch result {
- case .failure(let error):
- completion(.failure(error))
- case .success:
- for document in documents where document.url.pathExtension.lowercased() == "java" {
- self.synchronize(document)
- }
- self.sendRequest(method: "workspace/symbol", parameters: [
- "query": normalizedQuery
- ]) { response in
- switch response {
- case .failure(let error): completion(.failure(error))
- case .success(let value): completion(.success(Self.parseWorkspaceSymbols(value)))
- }
- }
- }
- }
- }
-
- func inlayHints(
- document: EditorDocument,
- completion: @escaping (Result<[JavaInlayHint], Error>) -> Void
- ) {
- guard document.url.pathExtension.lowercased() == "java" else {
- completion(.success([]))
- return
- }
- ensureReady(for: document.url.deletingLastPathComponent()) { [weak self, weak document] result in
- guard let self, let document else { return }
- switch result {
- case .failure(let error): completion(.failure(error))
- case .success:
- self.synchronize(document)
- let lines = max(0, document.text.reduce(0) { $1 == "\n" ? $0 + 1 : $0 })
- self.sendRequest(method: "textDocument/inlayHint", parameters: [
- "textDocument": ["uri": document.url.absoluteString],
- "range": [
- "start": ["line": 0, "character": 0],
- "end": ["line": lines + 1, "character": 0]
- ]
- ]) { response in
- switch response {
- case .failure(let error): completion(.failure(error))
- case .success(let value): completion(.success(Self.parseInlayHints(value)))
- }
- }
- }
- }
- }
-
- func update(_ document: EditorDocument) {
- guard document.url.pathExtension.lowercased() == "java" else { return }
- ensureReady(for: document.url.deletingLastPathComponent()) { [weak self, weak document] result in
- guard let self, let document else { return }
- guard case .success = result else { return }
- self.synchronize(document)
- }
- }
-
- func languageServerRequest(
- method: String,
- document: EditorDocument,
- parameters: [String: Any],
- completion: @escaping (Result) -> Void
- ) {
- guard document.url.pathExtension.lowercased() == "java" else {
- completion(.failure(ServiceError.invalidResponse))
- return
- }
- ensureReady(for: document.url.deletingLastPathComponent()) { [weak self, weak document] result in
- guard let self, let document else { return }
- switch result {
- case .failure(let error): completion(.failure(error))
- case .success:
- self.synchronize(document)
- self.sendRequest(method: method, parameters: parameters, completion: completion)
- }
- }
- }
-
- func languageServerRequest(
- method: String,
- parameters: [String: Any],
- completion: @escaping (Result) -> Void
- ) {
- ensureReady(for: projectURL ?? fileStorage.homeDirectory()) { [weak self] result in
- guard let self else { return }
- switch result {
- case .failure(let error): completion(.failure(error))
- case .success: self.sendRequest(method: method, parameters: parameters, completion: completion)
- }
- }
- }
-
- func executeLanguageServerCommand(
- _ command: LanguageServerCommand,
- completion: @escaping (Result) -> Void
- ) {
- executeCommand(
- command: command.command,
- arguments: command.arguments.map(\.foundationObject)
- ) { result in
- completion(result.map { _ in () })
- }
- }
-
- func close(_ document: EditorDocument) {
- guard document.url.pathExtension.lowercased() == "java" else { return }
- let uri = document.url.absoluteString
- if openedDocumentVersions[uri] != nil, isReady, !document.isReadOnly {
- sendNotification(method: "textDocument/didClose", parameters: [
- "textDocument": ["uri": uri]
- ])
- }
- openedDocumentVersions[uri] = nil
- lastSentDocumentTextByURI[uri] = nil
- diagnostics[document.url.standardizedFileURL] = nil
- onDiagnostics?(document.url.standardizedFileURL, [])
- onLanguageServerDiagnostics?(document.url.standardizedFileURL, [])
- }
-
- func stop() {
- process.stop()
- let error = ServiceError.serverStopped
- for handler in responseHandlers.values {
- handler(.failure(error))
- }
- for handler in readyHandlers {
- handler(.failure(error))
- }
- responseHandlers = [:]
- readyHandlers = []
- openedDocumentVersions = [:]
- lastSentDocumentTextByURI = [:]
- diagnostics = [:]
- initializedLanguageServerFeatures = .standardEditing
- dynamicallyRegisteredLanguageServerFeatures = [:]
- readBuffer = Data()
- isStarting = false
- isReady = false
- statusMessage = "Java navigation is idle"
- activeOperationID = nil
- }
-
- private func ensureReady(
- for fileDirectory: URL,
- completion: @escaping (Result) -> Void
- ) {
- if isReady {
- completion(.success(()))
- return
- }
- readyHandlers.append(completion)
- guard !isStarting else { return }
-
- guard let executableURL = runtimeService.javaLanguageServerExecutable() else {
- finishStartup(.failure(ServiceError.serverNotInstalled))
- return
- }
-
- let root = projectRoot(containing: fileDirectory)
- projectURL = root
- isStarting = true
- statusMessage = "Starting Java language server..."
-
- var arguments: [String] = []
- if let javaExecutable = runtimeService.javaExecutableURL() {
- arguments.append(contentsOf: ["--java-executable", javaExecutable.path])
- }
- // jdtls defaults to a 1 GiB initial heap. Keep the on-demand language
- // service lightweight for ordinary projects while leaving room for
- // larger workspaces to grow when needed.
- arguments.append(contentsOf: [
- "--jvm-arg=-Xms256m",
- "--jvm-arg=-Xmx1024m"
- ])
- let dataDirectory = dataDirectory(for: root)
- arguments.append(contentsOf: ["-data", dataDirectory.path])
- let operationID = UUID().uuidString
- activeOperationID = operationID
- do {
- try fileStorage.createDirectory(at: dataDirectory, withIntermediateDirectories: true)
- try process.start(ProcessRequest(
- operationID: operationID,
- executablePath: executableURL.path,
- arguments: arguments,
- workingDirectory: root.path,
- keepsStandardInputOpen: true
- ))
- initialize(root: root)
- } catch {
- finishStartup(.failure(error))
- }
- }
-
- private func consumeLifecycle(_ event: ProcessLifecycleEvent) {
- guard event.operationID == activeOperationID else { return }
- switch event.state {
- case .starting:
- isStarting = true
- case .running:
- isStarting = true
- case .stopping:
- statusMessage = event.message ?? "Stopping Java language server..."
- case .finished:
- break
- case .failed:
- isStarting = false
- isReady = false
- statusMessage = event.message ?? "Java language server failed to start"
- }
- }
-
- private func initialize(root: URL) {
- let capabilities: [String: Any] = [
- "textDocument": [
- "definition": ["dynamicRegistration": true, "linkSupport": true],
- "references": ["dynamicRegistration": true],
- "implementation": ["dynamicRegistration": true, "linkSupport": true],
- "hover": ["dynamicRegistration": true, "contentFormat": ["markdown", "plaintext"]],
- "completion": ["dynamicRegistration": true, "completionItem": [
- "documentationFormat": ["markdown", "plaintext"],
- "snippetSupport": true,
- "resolveSupport": ["properties": ["detail", "documentation", "textEdit", "additionalTextEdits"]]
- ]],
- "rename": ["dynamicRegistration": true, "prepareSupport": true],
- "formatting": ["dynamicRegistration": true],
- "codeAction": [
- "dynamicRegistration": true,
- "resolveSupport": ["properties": ["edit", "command"]],
- "codeActionLiteralSupport": [
- "codeActionKind": ["valueSet": ["quickfix", "refactor", "source"]]
- ]
- ],
- "inlayHint": ["dynamicRegistration": false, "resolveSupport": ["properties": []]],
- "publishDiagnostics": ["relatedInformation": true],
- "synchronization": ["dynamicRegistration": false, "didSave": true]
- ],
- "workspace": [
- "workspaceFolders": true,
- "configuration": true,
- "applyEdit": true,
- "executeCommand": ["dynamicRegistration": true],
- "symbol": ["dynamicRegistration": false]
- ],
- "window": ["workDoneProgress": true]
- ]
- let parameters: [String: Any] = [
- "processId": ProcessInfo.processInfo.processIdentifier,
- "clientInfo": ["name": "Lithe", "version": "0.1.0"],
- "rootUri": root.absoluteString,
- "capabilities": capabilities,
- "workspaceFolders": [["uri": root.absoluteString, "name": root.lastPathComponent]]
- ]
- sendRequest(method: "initialize", parameters: parameters) { [weak self] result in
- guard let self else { return }
- switch result {
- case .failure(let error):
- self.finishStartup(.failure(error))
- case .success(let value):
- self.initializedLanguageServerFeatures = LanguageServerResponseParser.serverFeatures(
- fromInitializeResult: value
- )
- self.dynamicallyRegisteredLanguageServerFeatures = [:]
- self.publishLanguageServerFeatures()
- self.sendNotification(method: "initialized", parameters: [:])
- self.sendNotification(method: "workspace/didChangeConfiguration", parameters: [
- "settings": [
- "java": [
- "inlayHints": [
- "parameterNames": ["enabled": "all"]
- ]
- ]
- ]
- ])
- self.isReady = true
- self.isStarting = false
- self.statusMessage = "Java navigation ready"
- self.finishReadyHandlers(.success(()))
- }
- }
- }
-
- private func synchronize(_ document: EditorDocument) {
- let uri = document.url.absoluteString
- if let version = openedDocumentVersions[uri] {
- guard lastSentDocumentTextByURI[uri] != document.text else { return }
- let nextVersion = version + 1
- openedDocumentVersions[uri] = nextVersion
- lastSentDocumentTextByURI[uri] = document.text
- sendNotification(method: "textDocument/didChange", parameters: [
- "textDocument": ["uri": uri, "version": nextVersion],
- "contentChanges": [["text": document.text]]
- ])
- } else {
- openedDocumentVersions[uri] = 1
- lastSentDocumentTextByURI[uri] = document.text
- sendNotification(method: "textDocument/didOpen", parameters: [
- "textDocument": [
- "uri": uri,
- "languageId": "java",
- "version": 1,
- "text": document.text
- ]
- ])
- }
- }
-
- private func sendRequest(
- method: String,
- parameters: [String: Any],
- completion: @escaping (Result) -> Void
- ) {
- let requiredFeature = LanguageServerResponseParser.requiredFeature(forRequestMethod: method)
- let supportedFeatures = dynamicallyRegisteredLanguageServerFeatures.values.reduce(
- initializedLanguageServerFeatures
- ) { $0.union($1) }
- guard requiredFeature.isEmpty || supportedFeatures.contains(requiredFeature) else {
- completion(.failure(ServiceError.capabilityUnavailable(method)))
- return
- }
- let id = nextRequestID
- nextRequestID += 1
- responseHandlers[id] = completion
- send(["jsonrpc": "2.0", "id": id, "method": method, "params": parameters])
- }
-
- private func executeCommand(
- command: String,
- arguments: [Any],
- completion: @escaping (Result) -> Void
- ) {
- ensureReady(for: projectURL ?? fileStorage.homeDirectory()) { [weak self] result in
- guard let self else { return }
- switch result {
- case .failure(let error):
- completion(.failure(error))
- case .success:
- self.sendRequest(method: "workspace/executeCommand", parameters: [
- "command": command,
- "arguments": arguments
- ], completion: completion)
- }
- }
- }
-
- private func sendNotification(method: String, parameters: [String: Any]) {
- send(["jsonrpc": "2.0", "method": method, "params": parameters])
- }
-
- private func sendResponse(id: Any, result: Any) {
- send(["jsonrpc": "2.0", "id": id, "result": result])
- }
-
- private func send(_ message: [String: Any]) {
- guard JSONSerialization.isValidJSONObject(message),
- let body = try? JSONSerialization.data(withJSONObject: message) else { return }
- var framed = Data("Content-Length: \(body.count)\r\n\r\n".utf8)
- framed.append(body)
- try? process.send(framed)
- }
-
- private func receive(_ data: Data) {
- readBuffer.append(data)
- while let headerEnd = readBuffer.range(of: Data("\r\n\r\n".utf8)) {
- let headerData = readBuffer[..= bodyStart + contentLength else { return }
- let body = readBuffer.subdata(in: bodyStart..<(bodyStart + contentLength))
- readBuffer.removeSubrange(0..<(bodyStart + contentLength))
- if let object = try? JSONSerialization.jsonObject(with: body) as? [String: Any] {
- handle(object)
- }
- }
- }
-
- private func handle(_ message: [String: Any]) {
- if let id = message["id"] as? Int, message["method"] == nil {
- guard let handler = responseHandlers.removeValue(forKey: id) else { return }
- if let error = message["error"] as? [String: Any] {
- let detail = error["message"] as? String ?? ServiceError.invalidResponse.localizedDescription
- handler(.failure(NSError(domain: "JavaLanguageService", code: 1, userInfo: [NSLocalizedDescriptionKey: detail])))
- } else {
- handler(.success(message["result"] ?? NSNull()))
- }
- return
- }
-
- guard let method = message["method"] as? String else { return }
- if message["id"] == nil {
- handleNotification(method: method, parameters: message["params"] as? [String: Any])
- return
- }
- guard let id = message["id"] else { return }
- switch method {
- case "client/registerCapability":
- registerCapabilities(message["params"] as? [String: Any])
- sendResponse(id: id, result: NSNull())
- case "client/unregisterCapability":
- unregisterCapabilities(message["params"] as? [String: Any])
- sendResponse(id: id, result: NSNull())
- case "workspace/configuration":
- let items = ((message["params"] as? [String: Any])?["items"] as? [[String: Any]]) ?? []
- sendResponse(id: id, result: items.map { item -> Any in
- switch item["section"] as? String {
- case "java":
- return ["inlayHints": ["parameterNames": ["enabled": "all"]]]
- case "java.inlayHints":
- return ["parameterNames": ["enabled": "all"]]
- case "java.inlayHints.parameterNames":
- return ["enabled": "all"]
- case "java.inlayHints.parameterNames.enabled":
- return "all"
- default:
- return NSNull()
- }
- })
- default:
- sendResponse(id: id, result: NSNull())
- }
- }
-
- private func registerCapabilities(_ parameters: [String: Any]?) {
- let registrations = parameters?["registrations"] as? [[String: Any]] ?? []
- for registration in registrations {
- guard let id = registration["id"] as? String,
- let method = registration["method"] as? String else { continue }
- dynamicallyRegisteredLanguageServerFeatures[id] = LanguageServerResponseParser.registeredFeatures(
- for: method,
- registerOptions: registration["registerOptions"]
- )
- }
- publishLanguageServerFeatures()
- }
-
- private func unregisterCapabilities(_ parameters: [String: Any]?) {
- let registrations = (parameters?["unregisterations"] as? [[String: Any]])
- ?? (parameters?["unregistrations"] as? [[String: Any]])
- ?? []
- for registration in registrations {
- guard let id = registration["id"] as? String else { continue }
- dynamicallyRegisteredLanguageServerFeatures[id] = nil
- }
- publishLanguageServerFeatures()
- }
-
- private func publishLanguageServerFeatures() {
- let features = dynamicallyRegisteredLanguageServerFeatures.values.reduce(
- initializedLanguageServerFeatures
- ) { $0.union($1) }
- onLanguageServerFeatures?(features)
- }
-
- private func handleNotification(method: String, parameters: [String: Any]?) {
- guard method == "textDocument/publishDiagnostics",
- let parameters,
- let uri = parameters["uri"] as? String,
- let url = URL(string: uri) else { return }
- let normalizedURL = url.standardizedFileURL
- let parsed = Self.parseDiagnostics(
- parameters["diagnostics"] as? [[String: Any]] ?? [],
- fileURL: normalizedURL
- )
- diagnostics[normalizedURL] = parsed
- onDiagnostics?(normalizedURL, parsed)
- onLanguageServerDiagnostics?(normalizedURL, parsed)
- }
-
- private func finishStartup(_ result: Result) {
- isStarting = false
- if case .failure(let error) = result {
- statusMessage = error.localizedDescription
- }
- finishReadyHandlers(result)
- }
-
- private func finishReadyHandlers(_ result: Result) {
- let handlers = readyHandlers
- readyHandlers = []
- handlers.forEach { $0(result) }
- }
-
- private func projectRoot(containing directory: URL) -> URL {
- var current = directory.standardizedFileURL
- while current.path != "/" {
- if fileStorage.fileExists(at: current.appendingPathComponent("pom.xml")) ||
- fileStorage.fileExists(at: current.appendingPathComponent("build.gradle")) ||
- fileStorage.fileExists(at: current.appendingPathComponent("build.gradle.kts")) ||
- fileStorage.fileExists(at: current.appendingPathComponent(".git")) {
- return current
- }
- current.deleteLastPathComponent()
- }
- return projectURL ?? directory
- }
-
- private func dataDirectory(for root: URL) -> URL {
- let key = String(root.path.utf8.reduce(UInt64(5381)) { ($0 &* 33) &+ UInt64($1) }, radix: 16)
- return fileStorage.cacheDirectory()
- .appendingPathComponent("Lithe/jdtls", isDirectory: true)
- .appendingPathComponent(key, isDirectory: true)
- }
-
- private func resolveExternalLocations(
- _ locations: [LanguageNavigationLocation],
- completion: @escaping (Result<[LanguageNavigationLocation], Error>) -> Void
- ) {
- func resolveNext(
- at index: Int,
- resolved: [LanguageNavigationLocation]
- ) {
- guard index < locations.count else {
- completion(.success(resolved))
- return
- }
-
- let location = locations[index]
- guard location.url.scheme?.lowercased() != "file" else {
- resolveNext(at: index + 1, resolved: resolved + [location])
- return
- }
-
- if let source = jdkSource(for: location.url),
- let sourceURL = materializeLibrarySource(source, for: location.url) {
- let materialized = LanguageNavigationLocation(
- url: sourceURL,
- line: location.line,
- utf16Column: location.utf16Column,
- isReadOnly: true,
- displayPath: Self.displayPath(for: location.url)
- )
- resolveNext(at: index + 1, resolved: resolved + [materialized])
- return
- }
-
- executeCommand(command: "java.decompile", arguments: [location.url.absoluteString]) { result in
- switch result {
- case .success(let value) where value is String:
- let content = value as! String
- if let sourceURL = self.materializeLibrarySource(content, for: location.url) {
- let materialized = LanguageNavigationLocation(
- url: sourceURL,
- line: location.line,
- utf16Column: location.utf16Column,
- isReadOnly: true,
- displayPath: Self.displayPath(for: location.url)
- )
- resolveNext(at: index + 1, resolved: resolved + [materialized])
- } else {
- resolveNext(at: index + 1, resolved: resolved)
- }
- case .success, .failure:
- resolveNext(at: index + 1, resolved: resolved)
- }
- }
- }
-
- resolveNext(at: 0, resolved: [])
- }
-
- private func resolveMissingJDKDefinition(
- document: EditorDocument,
- line: Int,
- utf16Column: Int,
- parameters: [String: Any],
- completion: @escaping (Result<[LanguageNavigationLocation], Error>) -> Void
- ) {
- let finish: (String?) -> Void = { qualifiedName in
- guard let qualifiedName,
- let symbol = Self.identifier(at: line, utf16Column: utf16Column, in: document.text),
- let location = self.jdkDefinitionLocation(
- for: qualifiedName,
- symbol: symbol
- ) else {
- completion(.success([]))
- return
- }
- completion(.success([location]))
- }
-
- executeCommand(command: "java.getFullyQualifiedName", arguments: [parameters]) { [weak self] result in
- guard let self else { return }
- if case .success(let value) = result,
- let qualifiedName = value as? String,
- !qualifiedName.isEmpty {
- finish(qualifiedName)
- return
- }
-
- self.sendRequest(method: "textDocument/hover", parameters: parameters) { hoverResult in
- switch hoverResult {
- case .success(let value):
- finish(Self.qualifiedName(fromHover: value, symbol: Self.identifier(
- at: line,
- utf16Column: utf16Column,
- in: document.text
- )))
- case .failure:
- completion(.success([]))
- }
- }
- }
- }
-
- private static func parseLocations(_ value: Any) -> [LanguageNavigationLocation] {
- let rawLocations: [[String: Any]]
- if let array = value as? [[String: Any]] {
- rawLocations = array
- } else if let object = value as? [String: Any] {
- rawLocations = [object]
- } else {
- return []
- }
-
- return rawLocations.compactMap { object in
- let uri = (object["uri"] as? String) ?? (object["targetUri"] as? String)
- let range = (object["range"] as? [String: Any]) ??
- (object["targetSelectionRange"] as? [String: Any]) ??
- (object["targetRange"] as? [String: Any])
- let start = range?["start"] as? [String: Any]
- guard let uri, let url = URL(string: uri),
- let line = start?["line"] as? Int,
- let column = start?["character"] as? Int else { return nil }
- return LanguageNavigationLocation(url: url, line: line, utf16Column: column)
- }
- }
-
- private func javaHomeURL() -> URL? {
- guard let javaExecutable = runtimeService.javaExecutableURL() else { return nil }
- return javaExecutable
- .deletingLastPathComponent()
- .deletingLastPathComponent()
- .resolvingSymlinksInPath()
- }
-
- private func jdkSource(for uri: URL) -> String? {
- guard uri.scheme?.lowercased() == "jdt",
- let entry = Self.jdkSourceEntry(for: uri),
- let javaHome = javaHomeURL() else { return nil }
-
- let archives = [
- javaHome.appendingPathComponent("lib/src.zip"),
- javaHome.appendingPathComponent("src.zip")
- ]
- let entries = [entry, entry.hasPrefix("java.base/") ? String(entry.dropFirst("java.base/".count)) : "java.base/\(entry)"]
- for archive in archives where fileStorage.fileExists(at: archive) {
- for candidate in entries where !candidate.isEmpty {
- if let source = readZipEntry(candidate, from: archive), !source.isEmpty {
- return source
- }
- }
- }
- return nil
- }
-
- private static func jdkSourceEntry(for uri: URL) -> String? {
- let components = uri.path
- .split(separator: "/")
- .map(String.init)
- guard let last = components.last,
- last.hasSuffix(".class") else { return nil }
- var sourceComponents = components
- sourceComponents[sourceComponents.count - 1] = String(last.dropLast(".class".count)) + ".java"
- return sourceComponents.joined(separator: "/")
- }
-
- private func readZipEntry(_ entry: String, from archive: URL) -> String? {
- archiveReader.read(entry: entry, from: archive)
- }
-
- private func materializeLibrarySource(_ content: String, for uri: URL) -> URL? {
- guard !content.isEmpty else { return nil }
- let key = String(uri.absoluteString.utf8.reduce(UInt64(5381)) { ($0 &* 33) &+ UInt64($1) }, radix: 16)
- let baseName = uri.deletingPathExtension().lastPathComponent
- .replacingOccurrences(of: "[^A-Za-z0-9_$-]", with: "_", options: .regularExpression)
- let fileName = "\(baseName.isEmpty ? "JavaLibrary" : baseName)-\(key).java"
- let directory = fileStorage.cacheDirectory()
- .appendingPathComponent("Lithe/java-sources", isDirectory: true)
- let destination = directory.appendingPathComponent(fileName)
- do {
- try fileStorage.createDirectory(at: directory, withIntermediateDirectories: true)
- try fileStorage.writeData(Data(content.utf8), to: destination, options: [])
- return destination
- } catch {
- return nil
- }
- }
-
- private static func displayPath(for uri: URL) -> String {
- let components = uri.path.split(separator: "/").map(String.init)
- guard let last = components.last else { return uri.lastPathComponent }
- var displayComponents = components
- if last.hasSuffix(".class") {
- displayComponents[displayComponents.count - 1] = String(last.dropLast(".class".count)) + ".java"
- }
- return displayComponents.joined(separator: "/")
- }
-
- private static func identifier(at line: Int, utf16Column: Int, in text: String) -> String? {
- let lines = text.components(separatedBy: .newlines)
- guard lines.indices.contains(line) else { return nil }
- let units = Array(lines[line].utf16)
- guard !units.isEmpty else { return nil }
- var index = min(max(0, utf16Column), units.count - 1)
- if !isJavaIdentifierUnit(units[index]), index > 0,
- isJavaIdentifierUnit(units[index - 1]) {
- index -= 1
- }
- guard isJavaIdentifierUnit(units[index]) else { return nil }
- var start = index
- while start > 0, isJavaIdentifierUnit(units[start - 1]) { start -= 1 }
- var end = index + 1
- while end < units.count, isJavaIdentifierUnit(units[end]) { end += 1 }
- return String(decoding: units[start.. Bool {
- (unit >= 48 && unit <= 57) ||
- (unit >= 65 && unit <= 90) ||
- (unit >= 97 && unit <= 122) ||
- unit == 95 || unit == 36
- }
-
- private static func qualifiedName(fromHover value: Any, symbol: String?) -> String? {
- guard let object = value as? [String: Any],
- let contents = object["contents"] as? [Any] else { return nil }
- let text = contents.compactMap { item -> String? in
- if let string = item as? String { return string }
- return (item as? [String: Any])?["value"] as? String
- }.joined(separator: "\n")
- guard !text.isEmpty,
- let expression = try? NSRegularExpression(
- pattern: "\\b(?:java|javax|jdk|sun)\\.[A-Za-z0-9_$.]+"
- ) else { return nil }
- let fullRange = NSRange(location: 0, length: (text as NSString).length)
- let matches = expression.matches(in: text, range: fullRange).compactMap {
- Range($0.range, in: text).map { String(text[$0]) }
- }
- guard !matches.isEmpty else { return nil }
- if let symbol {
- if let matching = matches.last(where: { $0.split(separator: ".").last.map(String.init) == symbol }) {
- return matching
- }
- }
- return matches.first
- }
-
- private func jdkDefinitionLocation(
- for qualifiedName: String,
- symbol: String
- ) -> LanguageNavigationLocation? {
- let parts = qualifiedName.split(separator: ".").map(String.init)
- guard parts.count >= 2,
- ["java", "javax", "jdk", "sun"].contains(parts[0]) else { return nil }
-
- guard let typeIndex = parts.firstIndex(where: { part in
- guard let first = part.first else { return false }
- return first.isUppercase || part.contains("$")
- }), typeIndex > 0 else { return nil }
-
- let packageParts = Array(parts[.. [JavaWorkspaceSymbol] {
- guard let objects = value as? [[String: Any]] else { return [] }
- return objects.compactMap { object in
- guard let name = object["name"] as? String,
- let kind = object["kind"] as? Int,
- let location = object["location"] as? [String: Any] else { return nil }
- let uri = (location["uri"] as? String) ?? (location["targetUri"] as? String)
- let range = (location["range"] as? [String: Any]) ??
- (location["targetSelectionRange"] as? [String: Any])
- let start = range?["start"] as? [String: Any]
- guard let uri,
- let url = URL(string: uri),
- let line = start?["line"] as? Int,
- let column = start?["character"] as? Int else { return nil }
- return JavaWorkspaceSymbol(
- name: name,
- containerName: object["containerName"] as? String,
- url: url.standardizedFileURL,
- line: line,
- utf16Column: column,
- kind: kind
- )
- }
- }
-
- private static func parseInlayHints(_ value: Any) -> [JavaInlayHint] {
- guard let objects = value as? [[String: Any]] else { return [] }
- return objects.compactMap { object in
- guard let position = object["position"] as? [String: Any],
- let line = position["line"] as? Int,
- let column = position["character"] as? Int else { return nil }
- let label: String
- if let raw = object["label"] as? String {
- label = raw
- } else if let parts = object["label"] as? [[String: Any]] {
- label = parts.compactMap { $0["value"] as? String }.joined()
- } else {
- return nil
- }
- guard !label.isEmpty else { return nil }
- return JavaInlayHint(line: line, utf16Column: column, label: label)
- }
- }
-
- private static func parseDiagnostics(
- _ objects: [[String: Any]],
- fileURL: URL
- ) -> [JavaDiagnostic] {
- objects.compactMap { parseDiagnostic($0, fileURL: fileURL) }
- }
-
- private static func parseDiagnostic(
- _ object: [String: Any],
- fileURL: URL
- ) -> JavaDiagnostic? {
- guard let range = object["range"] as? [String: Any],
- let start = range["start"] as? [String: Any],
- let line = start["line"] as? Int,
- let column = start["character"] as? Int,
- let message = object["message"] as? String,
- !message.isEmpty else { return nil }
- let end = range["end"] as? [String: Any]
- let endLine = end?["line"] as? Int ?? line
- let endColumn = end?["character"] as? Int ?? column + 1
- let severity = JavaDiagnosticSeverity(rawValue: object["severity"] as? Int ?? 1) ?? .error
- let source = object["source"] as? String
- let code = Self.parseDiagnosticCode(object["code"])
- let tags = Self.parseDiagnosticTags(object["tags"])
- let relatedInformation = Self.parseRelatedInformation(object["relatedInformation"])
- let id = fileURL.path + ":" + String(line) + ":" + String(column) + ":" + message + ":" + (code ?? "")
- return JavaDiagnostic(
- id: id,
- fileURL: fileURL,
- line: max(0, line),
- utf16Column: max(0, column),
- endLine: max(0, endLine),
- endUTF16Column: max(0, endColumn),
- severity: severity,
- message: message,
- source: source,
- code: code,
- tags: tags,
- relatedInformation: relatedInformation
- )
- }
-
- private static func parseDiagnosticCode(_ value: Any?) -> String? {
- if let value = value as? String, !value.isEmpty { return value }
- if let value = value as? Int { return String(value) }
- return nil
- }
-
- private static func parseDiagnosticTags(_ value: Any?) -> Set {
- guard let values = value as? [Any] else { return [] }
- return Set(values.compactMap { value in
- guard let rawValue = value as? Int else { return nil }
- return JavaDiagnosticTag(rawValue: rawValue)
- })
- }
-
- private static func parseRelatedInformation(_ value: Any?) -> [JavaDiagnosticRelatedInformation] {
- guard let objects = value as? [[String: Any]] else { return [] }
- return objects.compactMap { object in
- guard let location = object["location"] as? [String: Any],
- let uri = location["uri"] as? String,
- let fileURL = URL(string: uri),
- let range = location["range"] as? [String: Any],
- let start = range["start"] as? [String: Any],
- let line = start["line"] as? Int,
- let column = start["character"] as? Int,
- let message = object["message"] as? String,
- !message.isEmpty else { return nil }
- return JavaDiagnosticRelatedInformation(
- fileURL: fileURL.standardizedFileURL,
- line: max(0, line),
- utf16Column: max(0, column),
- message: message
- )
- }
- }
-}
diff --git a/Sources/Lithe/Services/LanguageFeatureProvider.swift b/Sources/Lithe/Services/LanguageFeatureProvider.swift
new file mode 100644
index 000000000..7eb532d2f
--- /dev/null
+++ b/Sources/Lithe/Services/LanguageFeatureProvider.swift
@@ -0,0 +1,369 @@
+import Foundation
+
+enum LanguageFeature: Hashable, Sendable {
+ case completion
+ case hover
+ case navigation(method: String)
+}
+
+struct LanguageFeatureProviderPriority: RawRepresentable, Comparable, Hashable, Sendable {
+ let rawValue: Int
+
+ init(rawValue: Int) {
+ self.rawValue = rawValue
+ }
+
+ static let builtin = Self(rawValue: 0)
+ static let projectSymbols = Self(rawValue: 100)
+ static let languageServer = Self(rawValue: 200)
+
+ static func < (lhs: Self, rhs: Self) -> Bool {
+ lhs.rawValue < rhs.rawValue
+ }
+}
+
+struct LanguageFeatureRequestContext: Sendable {
+ let fileURL: URL
+ let text: String
+ let position: LanguageServerPosition
+ let languageID: String?
+ let workspaceURL: URL?
+
+ init(
+ fileURL: URL,
+ text: String,
+ position: LanguageServerPosition,
+ languageID: String? = nil,
+ workspaceURL: URL? = nil
+ ) {
+ self.fileURL = fileURL.standardizedFileURL
+ self.text = text
+ self.position = position
+ self.languageID = languageID
+ self.workspaceURL = workspaceURL?.standardizedFileURL
+ }
+}
+
+@MainActor
+protocol LanguageFeatureProvider: AnyObject {
+ var id: String { get }
+ var priority: LanguageFeatureProviderPriority { get }
+
+ func supports(_ feature: LanguageFeature, in context: LanguageFeatureRequestContext) -> Bool
+ func completions(
+ in context: LanguageFeatureRequestContext,
+ completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
+ ) throws
+ func hover(
+ in context: LanguageFeatureRequestContext,
+ completion: @escaping (Result) -> Void
+ ) throws
+ func navigate(
+ method: String,
+ in context: LanguageFeatureRequestContext,
+ completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
+ ) throws
+}
+
+@MainActor
+final class BuiltinLanguageFeatureProvider: LanguageFeatureProvider {
+ let id = "builtin"
+ let priority: LanguageFeatureProviderPriority = .builtin
+
+ private let core: RustCoreBridge
+
+ init(core: RustCoreBridge = RustCoreBridge()) {
+ self.core = core
+ }
+
+ func supports(_ feature: LanguageFeature, in context: LanguageFeatureRequestContext) -> Bool {
+ switch feature {
+ case .completion:
+ return core.isAvailable || Self.keywordLanguage(for: context) != nil
+ case .hover, .navigation:
+ return core.isAvailable
+ }
+ }
+
+ func completions(
+ in context: LanguageFeatureRequestContext,
+ completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
+ ) throws {
+ let symbols = core.isAvailable
+ ? core.builtinLanguageCompletions(
+ fileURL: context.fileURL,
+ text: context.text,
+ position: context.position
+ ) ?? []
+ : []
+ let keywords = Self.keywordCompletions(in: context)
+
+ var seenLabels = Set()
+ let merged = (symbols + keywords).filter { seenLabels.insert($0.label).inserted }
+ completion(.success(merged))
+ }
+
+ func hover(
+ in context: LanguageFeatureRequestContext,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ completion(.success(
+ core.isAvailable
+ ? core.builtinLanguageHover(
+ fileURL: context.fileURL,
+ text: context.text,
+ position: context.position
+ )
+ : nil
+ ))
+ }
+
+ func navigate(
+ method: String,
+ in context: LanguageFeatureRequestContext,
+ completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
+ ) throws {
+ completion(.success(
+ core.isAvailable
+ ? core.builtinLanguageNavigation(
+ method: method,
+ fileURL: context.fileURL,
+ text: context.text,
+ position: context.position
+ ) ?? []
+ : []
+ ))
+ }
+}
+
+@MainActor
+final class LanguageServerFeatureProvider: LanguageFeatureProvider {
+ let id: String
+ let priority: LanguageFeatureProviderPriority = .languageServer
+
+ private let session: any LanguageServerSession
+ private(set) var features: LanguageServerFeatureSet
+
+ init(
+ providerID: String,
+ session: any LanguageServerSession,
+ features: LanguageServerFeatureSet = []
+ ) {
+ id = "lsp:\(providerID)"
+ self.session = session
+ self.features = features
+ }
+
+ func updateFeatures(_ features: LanguageServerFeatureSet) {
+ self.features = features
+ }
+
+ func supports(_ feature: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool {
+ guard session.isRunning else { return false }
+ switch feature {
+ case .completion:
+ return features.contains(.completion)
+ case .hover:
+ return features.contains(.hover)
+ case .navigation(let method):
+ return features.contains(Self.navigationFeature(for: method))
+ }
+ }
+
+ func completions(
+ in context: LanguageFeatureRequestContext,
+ completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
+ ) throws {
+ try session.completions(
+ fileURL: context.fileURL,
+ position: context.position,
+ completion: completion
+ )
+ }
+
+ func hover(
+ in context: LanguageFeatureRequestContext,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ try session.hover(
+ fileURL: context.fileURL,
+ position: context.position,
+ completion: completion
+ )
+ }
+
+ func navigate(
+ method: String,
+ in context: LanguageFeatureRequestContext,
+ completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
+ ) throws {
+ try session.navigate(
+ method: method,
+ fileURL: context.fileURL,
+ position: context.position,
+ completion: completion
+ )
+ }
+
+ private static func navigationFeature(for method: String) -> LanguageServerFeatureSet {
+ switch method {
+ case "textDocument/references": .references
+ case "textDocument/implementation": .implementation
+ default: .definition
+ }
+ }
+}
+
+private extension BuiltinLanguageFeatureProvider {
+ enum KeywordLanguage {
+ case go
+ case swift
+ case rust
+ case python
+ case javaScript
+ case typeScript
+
+ var displayName: String {
+ switch self {
+ case .go: "Go"
+ case .swift: "Swift"
+ case .rust: "Rust"
+ case .python: "Python"
+ case .javaScript: "JavaScript"
+ case .typeScript: "TypeScript"
+ }
+ }
+
+ var keywords: Set {
+ switch self {
+ case .go:
+ return [
+ "break", "case", "chan", "const", "continue", "default", "defer", "else",
+ "fallthrough", "for", "func", "go", "goto", "if", "import", "interface",
+ "map", "package", "range", "return", "select", "struct", "switch", "type", "var"
+ ]
+ case .swift:
+ return [
+ "actor", "any", "as", "associatedtype", "async", "await", "break", "case",
+ "catch", "class", "continue", "convenience", "default", "defer", "deinit", "didSet",
+ "distributed", "do", "dynamic", "else", "enum", "extension", "fallthrough", "fileprivate",
+ "final", "for", "func", "get", "guard", "if", "import", "in", "indirect", "infix",
+ "init", "inout", "internal", "is", "isolated", "lazy", "let", "macro", "mutating",
+ "nonisolated", "nonmutating", "open", "operator", "optional", "override", "package",
+ "postfix", "precedencegroup", "prefix", "private", "protocol", "public", "repeat", "required",
+ "rethrows", "return", "set", "some", "static", "struct", "subscript", "super", "switch",
+ "throws", "try", "typealias", "unowned", "var", "weak", "where", "while", "willSet"
+ ]
+ case .rust:
+ return [
+ "Self", "abstract", "as", "async", "await", "become", "box", "break", "const", "continue",
+ "crate", "do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen",
+ "if", "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override",
+ "priv", "pub", "ref", "return", "self", "static", "struct", "super", "trait", "true",
+ "try", "type", "typeof", "union", "unsafe", "unsized", "use", "virtual", "where", "while", "yield"
+ ]
+ case .python:
+ return [
+ "False", "None", "True", "and", "as", "assert", "async", "await", "break", "case",
+ "class", "continue", "def", "del", "elif", "else", "except", "finally", "for", "from",
+ "global", "if", "import", "in", "is", "lambda", "match", "nonlocal", "not", "or", "pass",
+ "raise", "return", "try", "while", "with", "yield"
+ ]
+ case .javaScript:
+ return Self.javaScriptKeywords
+ case .typeScript:
+ return Self.javaScriptKeywords.union([
+ "abstract", "any", "as", "asserts", "bigint", "boolean", "constructor", "declare", "enum",
+ "from", "get", "implements", "infer", "interface", "is", "keyof", "module", "namespace",
+ "never", "number", "object", "override", "private", "protected", "public", "readonly", "require",
+ "set", "string", "symbol", "type", "undefined", "unique", "unknown"
+ ])
+ }
+ }
+
+ private static let javaScriptKeywords: Set = [
+ "async", "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default",
+ "delete", "do", "else", "export", "extends", "false", "finally", "for", "function", "if", "import",
+ "in", "instanceof", "let", "new", "null", "of", "return", "static", "super", "switch", "this",
+ "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield"
+ ]
+ }
+
+ static func keywordLanguage(for context: LanguageFeatureRequestContext) -> KeywordLanguage? {
+ if let languageID = context.languageID?.lowercased() {
+ switch languageID {
+ case "go", "golang": return .go
+ case "swift": return .swift
+ case "rust": return .rust
+ case "python": return .python
+ case "javascript", "javascriptreact", "jsx": return .javaScript
+ case "typescript", "typescriptreact", "tsx": return .typeScript
+ default: break
+ }
+ }
+
+ switch context.fileURL.pathExtension.lowercased() {
+ case "go": return .go
+ case "swift": return .swift
+ case "rs": return .rust
+ case "py", "pyw": return .python
+ case "js", "jsx", "mjs", "cjs": return .javaScript
+ case "ts", "tsx", "mts", "cts": return .typeScript
+ default: return nil
+ }
+ }
+
+ static func keywordCompletions(
+ in context: LanguageFeatureRequestContext
+ ) -> [LanguageServerCompletionItem] {
+ guard let language = keywordLanguage(for: context) else { return [] }
+ let prefix = identifierPrefix(in: context.text, at: context.position)
+ let startColumn = max(0, context.position.utf16Column - prefix.utf16.count)
+ let editRange = LanguageServerRange(
+ start: LanguageServerPosition(line: context.position.line, utf16Column: startColumn),
+ end: context.position
+ )
+
+ return language.keywords
+ .filter { prefix.isEmpty || $0.hasPrefix(prefix) }
+ .sorted()
+ .map { keyword in
+ LanguageServerCompletionItem(
+ label: keyword,
+ detail: "\(language.displayName) keyword",
+ documentation: nil,
+ insertText: keyword,
+ sortText: "zz_keyword_\(keyword)",
+ filterText: keyword,
+ kind: 14,
+ textEdit: LanguageServerTextEdit(range: editRange, newText: keyword),
+ additionalTextEdits: [],
+ data: nil
+ )
+ }
+ }
+
+ static func identifierPrefix(in text: String, at position: LanguageServerPosition) -> String {
+ guard position.line >= 0, position.utf16Column >= 0 else { return "" }
+ let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
+ guard position.line < lines.count else { return "" }
+
+ let line = lines[position.line]
+ let utf16 = line.utf16
+ guard position.utf16Column <= utf16.count else { return "" }
+ let utf16Index = utf16.index(utf16.startIndex, offsetBy: position.utf16Column)
+ guard let cursor = String.Index(utf16Index, within: line) else { return "" }
+
+ var start = cursor
+ while start > line.startIndex {
+ let previous = line.index(before: start)
+ guard isIdentifierCharacter(line[previous]) else { break }
+ start = previous
+ }
+ return String(line[start.. Bool {
+ character == "_" || character == "$" || character.isLetter || character.isNumber
+ }
+}
diff --git a/Sources/Lithe/Services/LanguagePackRegistry.swift b/Sources/Lithe/Services/LanguagePackRegistry.swift
index 13d87632d..f6c238169 100644
--- a/Sources/Lithe/Services/LanguagePackRegistry.swift
+++ b/Sources/Lithe/Services/LanguagePackRegistry.swift
@@ -4,8 +4,8 @@ import Foundation
///
/// Existing registries remain available as focused implementation details, but
/// the application composition root should create this registry once and pass
-/// its derived views to Run, LSP/DAP, and Test services. Adding a language then
-/// means adding one pack instead of editing every service initializer.
+/// its derived views to Run, Debug, and Test services. LSP metadata is resolved
+/// by the Rust language-server host, not by Swift language packs.
@MainActor
final class LanguagePackRegistry {
let packs: [LanguagePack]
@@ -65,16 +65,13 @@ final class LanguagePackRegistry {
let testProviders: [any LanguageTestProvider] = descriptor.capabilities.contains(.testing)
? [StandardLanguageTestProvider(descriptor: descriptor)]
: []
- let tooling = Self.standardToolingDefinition(for: descriptor.id)
-
return LanguagePack(
descriptor: descriptor,
runProvider: runProvider,
toolchainProviders: standardToolchains.filter {
$0.languageProviderID == descriptor.id
},
- languageServerLaunch: tooling.languageServer,
- debugAdapterLaunch: tooling.debugAdapter,
+ debugAdapterLaunch: Self.standardDebugAdapterDefinition(for: descriptor.id),
toolingRuntime: runtimeByID[descriptor.id],
testProviders: testProviders
)
@@ -82,80 +79,45 @@ final class LanguagePackRegistry {
return Self(packs: packs)
}
- private struct StandardToolingDefinition {
- let languageServer: StdioLanguageServerLaunch?
- let debugAdapter: StdioDebugAdapterLaunch?
- }
-
- /// The standard catalog is intentionally assembled as complete packs.
- /// Runtime implementations consume these definitions but never duplicate
- /// language-name-to-executable maps of their own.
- private static func standardToolingDefinition(for id: String) -> StandardToolingDefinition {
+ private static func standardDebugAdapterDefinition(for id: String) -> StdioDebugAdapterLaunch? {
switch id {
case "java":
- return StandardToolingDefinition(
- languageServer: nil,
- debugAdapter: StdioDebugAdapterLaunch(
- adapterID: "java",
- executableNames: ["java-debug-adapter", "java-debug", "jdtls-debug"],
- arguments: ["--stdio"]
- )
+ return StdioDebugAdapterLaunch(
+ adapterID: "java",
+ executableNames: ["java-debug-adapter", "java-debug"],
+ arguments: ["--stdio"]
)
case "go":
- return StandardToolingDefinition(
- languageServer: StdioLanguageServerLaunch(
- executableNames: ["gopls"],
- arguments: []
- ),
- debugAdapter: StdioDebugAdapterLaunch(
- adapterID: "go",
- executableNames: ["dlv"],
- arguments: ["dap"]
- )
+ return StdioDebugAdapterLaunch(
+ adapterID: "go",
+ executableNames: ["dlv"],
+ arguments: ["dap"]
)
case "python":
- return StandardToolingDefinition(
- languageServer: StdioLanguageServerLaunch(
- executableNames: ["basedpyright-langserver", "pyright-langserver"],
- arguments: ["--stdio"]
- ),
- debugAdapter: StdioDebugAdapterLaunch(
- adapterID: "python",
- executableNames: ["python3", "python"],
- arguments: ["-m", "debugpy.adapter"]
- )
+ return StdioDebugAdapterLaunch(
+ adapterID: "python",
+ executableNames: ["python3", "python"],
+ arguments: ["-m", "debugpy.adapter"]
)
case "node":
- return StandardToolingDefinition(
- languageServer: StdioLanguageServerLaunch(
- executableNames: ["typescript-language-server"],
- arguments: ["--stdio"]
- ),
- debugAdapter: StdioDebugAdapterLaunch(
- adapterID: "pwa-node",
- executableNames: ["js-debug-dap"],
- arguments: []
- )
+ return StdioDebugAdapterLaunch(
+ adapterID: "pwa-node",
+ executableNames: ["js-debug-dap"],
+ arguments: []
)
case "rust":
- return StandardToolingDefinition(
- languageServer: StdioLanguageServerLaunch(
- executableNames: ["rust-analyzer"],
- arguments: []
- ),
- debugAdapter: StdioDebugAdapterLaunch(
- adapterID: "lldb",
- executableNames: ["lldb-dap"],
- arguments: [],
- fallbacks: [
- // Xcode exposes lldb-dap through xcrun even when the
- // app's inherited PATH does not contain the tool.
- .init(executableName: "xcrun", argumentPrefix: ["lldb-dap"])
- ]
- )
+ return StdioDebugAdapterLaunch(
+ adapterID: "lldb",
+ executableNames: ["lldb-dap"],
+ arguments: [],
+ fallbacks: [
+ // Xcode exposes lldb-dap through xcrun even when the
+ // app's inherited PATH does not contain the tool.
+ .init(executableName: "xcrun", argumentPrefix: ["lldb-dap"])
+ ]
)
default:
- return StandardToolingDefinition(languageServer: nil, debugAdapter: nil)
+ return nil
}
}
}
diff --git a/Sources/Lithe/Services/LanguageServerResponseParser.swift b/Sources/Lithe/Services/LanguageServerResponseParser.swift
deleted file mode 100644
index ad203b299..000000000
--- a/Sources/Lithe/Services/LanguageServerResponseParser.swift
+++ /dev/null
@@ -1,287 +0,0 @@
-import Foundation
-
-enum LanguageServerResponseParser {
- static func requiredFeature(forRequestMethod method: String) -> LanguageServerFeatureSet {
- switch method {
- case "textDocument/definition": .definition
- case "textDocument/references": .references
- case "textDocument/implementation": .implementation
- case "textDocument/hover": .hover
- case "textDocument/completion": .completion
- case "completionItem/resolve": .completionResolve
- case "textDocument/rename": .rename
- case "textDocument/formatting": .formatting
- case "textDocument/codeAction": .codeActions
- case "codeAction/resolve": .codeActionResolve
- case "workspace/executeCommand": .executeCommand
- default: []
- }
- }
-
- static func registeredFeatures(
- for method: String,
- registerOptions: Any? = nil
- ) -> LanguageServerFeatureSet {
- let options = registerOptions as? [String: Any]
- switch method {
- case "textDocument/definition":
- return .definition
- case "textDocument/references":
- return .references
- case "textDocument/implementation":
- return .implementation
- case "textDocument/hover":
- return .hover
- case "textDocument/completion":
- var features: LanguageServerFeatureSet = .completion
- if options?["resolveProvider"] as? Bool == true {
- features.insert(.completionResolve)
- }
- return features
- case "textDocument/rename":
- return .rename
- case "textDocument/formatting":
- return .formatting
- case "textDocument/codeAction":
- var features: LanguageServerFeatureSet = .codeActions
- if options?["resolveProvider"] as? Bool == true {
- features.insert(.codeActionResolve)
- }
- return features
- case "workspace/executeCommand":
- return .executeCommand
- default:
- return []
- }
- }
-
- static func serverFeatures(fromInitializeResult value: Any) -> LanguageServerFeatureSet {
- guard let result = value as? [String: Any],
- let capabilities = result["capabilities"] as? [String: Any] else { return [] }
- var features: LanguageServerFeatureSet = []
- func supports(_ key: String) -> Bool {
- if let value = capabilities[key] as? Bool { return value }
- return capabilities[key] is [String: Any]
- }
- if supports("definitionProvider") { features.insert(.definition) }
- if supports("referencesProvider") { features.insert(.references) }
- if supports("implementationProvider") { features.insert(.implementation) }
- if supports("hoverProvider") { features.insert(.hover) }
- if supports("completionProvider") {
- features.insert(.completion)
- if (capabilities["completionProvider"] as? [String: Any])?["resolveProvider"] as? Bool == true {
- features.insert(.completionResolve)
- }
- }
- if supports("renameProvider") { features.insert(.rename) }
- if supports("documentFormattingProvider") { features.insert(.formatting) }
- if supports("codeActionProvider") {
- features.insert(.codeActions)
- if (capabilities["codeActionProvider"] as? [String: Any])?["resolveProvider"] as? Bool == true {
- features.insert(.codeActionResolve)
- }
- }
- if supports("executeCommandProvider") { features.insert(.executeCommand) }
- return features
- }
-
- static func locations(_ value: Any) -> [LanguageServerLocation] {
- let values = (value as? [[String: Any]]) ?? (value as? [String: Any]).map { [$0] } ?? []
- return values.compactMap { value in
- let uri = (value["uri"] as? String) ?? (value["targetUri"] as? String)
- let rawRange = (value["range"] as? [String: Any])
- ?? (value["targetSelectionRange"] as? [String: Any])
- ?? (value["targetRange"] as? [String: Any])
- guard let uri, let url = URL(string: uri), let rawRange,
- let range = range(rawRange) else { return nil }
- return LanguageServerLocation(url: url.standardizedFileURL, range: range)
- }
- }
-
- static func hover(_ value: Any) -> LanguageServerHover? {
- guard !(value is NSNull), let object = value as? [String: Any] else { return nil }
- let parsed = markup(object["contents"])
- guard !parsed.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
- return LanguageServerHover(
- contents: parsed.text,
- isMarkdown: parsed.markdown,
- range: (object["range"] as? [String: Any]).flatMap(range)
- )
- }
-
- static func completionItems(_ value: Any) -> [LanguageServerCompletionItem] {
- let values = (value as? [[String: Any]])
- ?? ((value as? [String: Any])?["items"] as? [[String: Any]])
- ?? []
- return values.compactMap { item in
- guard let label = item["label"] as? String else { return nil }
- let rawTextEdit = item["textEdit"] as? [String: Any]
- let textEdit = rawTextEdit.flatMap(textEdit)
- let documentation = markup(item["documentation"])
- return LanguageServerCompletionItem(
- label: label,
- detail: item["detail"] as? String,
- documentation: documentation.text.isEmpty ? nil : documentation.text,
- insertText: (rawTextEdit?["newText"] as? String) ?? (item["insertText"] as? String) ?? label,
- sortText: item["sortText"] as? String,
- filterText: item["filterText"] as? String,
- kind: item["kind"] as? Int,
- textEdit: textEdit,
- additionalTextEdits: textEdits(item["additionalTextEdits"] as Any),
- data: item["data"].flatMap(ToolingJSONValue.fromFoundation)
- )
- }.sorted {
- ($0.sortText ?? $0.label).localizedStandardCompare($1.sortText ?? $1.label) == .orderedAscending
- }
- }
-
- static func textEdits(_ value: Any) -> [LanguageServerTextEdit] {
- guard let values = value as? [[String: Any]] else { return [] }
- return values.compactMap { value in
- guard let rawRange = value["range"] as? [String: Any],
- let range = range(rawRange),
- let newText = value["newText"] as? String else { return nil }
- return LanguageServerTextEdit(range: range, newText: newText)
- }
- }
-
- static func workspaceEdit(_ value: Any) -> LanguageServerWorkspaceEdit {
- guard let object = value as? [String: Any] else { return LanguageServerWorkspaceEdit() }
- var changes: [URL: [LanguageServerTextEdit]] = [:]
- if let rawChanges = object["changes"] as? [String: Any] {
- for (uri, rawEdits) in rawChanges {
- guard let url = URL(string: uri) else { continue }
- changes[url.standardizedFileURL] = textEdits(rawEdits)
- }
- }
- if let documentChanges = object["documentChanges"] as? [[String: Any]] {
- for change in documentChanges {
- guard let document = change["textDocument"] as? [String: Any],
- let uri = document["uri"] as? String,
- let url = URL(string: uri) else { continue }
- changes[url.standardizedFileURL, default: []].append(contentsOf: textEdits(change["edits"] as Any))
- }
- }
- return LanguageServerWorkspaceEdit(changes: changes)
- }
-
- static func codeActions(_ value: Any) -> [LanguageServerCodeAction] {
- guard let values = value as? [Any] else { return [] }
- return values.compactMap { raw in
- guard let action = raw as? [String: Any], let title = action["title"] as? String else { return nil }
- return LanguageServerCodeAction(
- title: title,
- kind: action["kind"] as? String,
- isPreferred: action["isPreferred"] as? Bool ?? false,
- edit: action["edit"].map(workspaceEdit),
- command: command(action["command"]) ?? command(action),
- data: action["data"].flatMap(ToolingJSONValue.fromFoundation)
- )
- }.sorted {
- if $0.isPreferred != $1.isPreferred { return $0.isPreferred }
- return $0.title.localizedStandardCompare($1.title) == .orderedAscending
- }
- }
-
- static func range(_ value: [String: Any]) -> LanguageServerRange? {
- guard let start = value["start"] as? [String: Any],
- let end = value["end"] as? [String: Any],
- let startLine = start["line"] as? Int,
- let startColumn = start["character"] as? Int,
- let endLine = end["line"] as? Int,
- let endColumn = end["character"] as? Int else { return nil }
- return LanguageServerRange(
- start: LanguageServerPosition(line: startLine, utf16Column: startColumn),
- end: LanguageServerPosition(line: endLine, utf16Column: endColumn)
- )
- }
-
- static func completionItem(_ value: Any) -> LanguageServerCompletionItem? {
- completionItems([value]).first
- }
-
- static func codeAction(_ value: Any) -> LanguageServerCodeAction? {
- codeActions([value]).first
- }
-
- static func foundationCompletionItem(_ item: LanguageServerCompletionItem) -> [String: Any] {
- var value: [String: Any] = ["label": item.label, "insertText": item.insertText]
- if let detail = item.detail { value["detail"] = detail }
- if let documentation = item.documentation {
- value["documentation"] = ["kind": "markdown", "value": documentation]
- }
- if let sortText = item.sortText { value["sortText"] = sortText }
- if let filterText = item.filterText { value["filterText"] = filterText }
- if let kind = item.kind { value["kind"] = kind }
- if let textEdit = item.textEdit { value["textEdit"] = foundationTextEdit(textEdit) }
- if !item.additionalTextEdits.isEmpty {
- value["additionalTextEdits"] = item.additionalTextEdits.map(foundationTextEdit)
- }
- if let data = item.data { value["data"] = data.foundationObject }
- return value
- }
-
- static func foundationCodeAction(_ action: LanguageServerCodeAction) -> [String: Any] {
- var value: [String: Any] = ["title": action.title, "isPreferred": action.isPreferred]
- if let kind = action.kind { value["kind"] = kind }
- if let edit = action.edit { value["edit"] = foundationWorkspaceEdit(edit) }
- if let command = action.command { value["command"] = foundationCommand(command) }
- if let data = action.data { value["data"] = data.foundationObject }
- return value
- }
-
- private static func textEdit(_ value: [String: Any]) -> LanguageServerTextEdit? {
- guard let rawRange = value["range"] as? [String: Any],
- let range = range(rawRange),
- let newText = value["newText"] as? String else { return nil }
- return LanguageServerTextEdit(range: range, newText: newText)
- }
-
- private static func command(_ value: Any?) -> LanguageServerCommand? {
- guard let object = value as? [String: Any],
- let title = object["title"] as? String,
- let identifier = object["command"] as? String else { return nil }
- return LanguageServerCommand(
- title: title,
- command: identifier,
- arguments: (object["arguments"] as? [Any] ?? []).compactMap(ToolingJSONValue.fromFoundation)
- )
- }
-
- private static func foundationRange(_ range: LanguageServerRange) -> [String: Any] {
- [
- "start": ["line": range.start.line, "character": range.start.utf16Column],
- "end": ["line": range.end.line, "character": range.end.utf16Column]
- ]
- }
-
- private static func foundationTextEdit(_ edit: LanguageServerTextEdit) -> [String: Any] {
- ["range": foundationRange(edit.range), "newText": edit.newText]
- }
-
- private static func foundationWorkspaceEdit(_ edit: LanguageServerWorkspaceEdit) -> [String: Any] {
- ["changes": Dictionary(uniqueKeysWithValues: edit.changes.map {
- ($0.key.absoluteString, $0.value.map(foundationTextEdit))
- })]
- }
-
- private static func foundationCommand(_ command: LanguageServerCommand) -> [String: Any] {
- [
- "title": command.title,
- "command": command.command,
- "arguments": command.arguments.map(\.foundationObject)
- ]
- }
-
- private static func markup(_ value: Any?) -> (text: String, markdown: Bool) {
- if let text = value as? String { return (text, false) }
- if let object = value as? [String: Any], let text = object["value"] as? String {
- return (text, object["kind"] as? String == "markdown" || object["language"] != nil)
- }
- if let values = value as? [Any] {
- let parsed = values.map(markup).filter { !$0.text.isEmpty }
- return (parsed.map(\.text).joined(separator: "\n\n"), parsed.contains(where: \.markdown))
- }
- return ("", false)
- }
-}
diff --git a/Sources/Lithe/Services/LanguageServerTextEditApplicator.swift b/Sources/Lithe/Services/LanguageServerTextEditApplicator.swift
index 1b7346536..2de10d616 100644
--- a/Sources/Lithe/Services/LanguageServerTextEditApplicator.swift
+++ b/Sources/Lithe/Services/LanguageServerTextEditApplicator.swift
@@ -14,6 +14,22 @@ enum LanguageServerTextEditApplicator {
}
static func apply(_ edits: [LanguageServerTextEdit], to text: String) throws -> String {
+ let core = RustCoreBridge()
+ if core.isAvailable {
+ switch core.applyLanguageServerTextEdits(edits, to: text) {
+ case .success(let payload):
+ return payload.text
+ case .failure(let error):
+ switch error.details {
+ case "overlappingEdits": throw Error.overlappingEdits
+ default: throw Error.invalidRange
+ }
+ }
+ }
+ return try applyFallback(edits, to: text)
+ }
+
+ private static func applyFallback(_ edits: [LanguageServerTextEdit], to text: String) throws -> String {
let source = text as NSString
var replacements: [(NSRange, String)] = []
for edit in edits {
@@ -59,6 +75,9 @@ enum LanguageServerTextEditApplicator {
enum LanguageServerSnippet {
static func plainText(_ value: String) -> String {
+ if let text = RustCoreBridge().plainLanguageServerSnippet(value) {
+ return text
+ }
var result = value
if let placeholders = try? NSRegularExpression(pattern: #"\$\{\d+:([^}]*)\}"#) {
result = placeholders.stringByReplacingMatches(
diff --git a/Sources/Lithe/Services/LanguageServerToolService.swift b/Sources/Lithe/Services/LanguageServerToolService.swift
new file mode 100644
index 000000000..48715bc0b
--- /dev/null
+++ b/Sources/Lithe/Services/LanguageServerToolService.swift
@@ -0,0 +1,299 @@
+import Foundation
+
+struct LanguageServerInstallPlan: Equatable, Sendable {
+ let homebrewFormula: String?
+ let officialDownloadURL: URL?
+
+ static func plan(for descriptor: LanguageProviderDescriptor) -> Self {
+ let installation = descriptor.languageServerInstallation
+ return Self(
+ homebrewFormula: installation?.homebrewFormula.flatMap {
+ isSafeHomebrewFormula($0) ? $0 : nil
+ },
+ officialDownloadURL: installation?.officialDownloadURL.flatMap {
+ $0.scheme?.lowercased() == "https" && $0.host != nil ? $0 : nil
+ }
+ )
+ }
+
+ private static func isSafeHomebrewFormula(_ formula: String) -> Bool {
+ guard !formula.isEmpty,
+ formula.count <= 200,
+ !formula.hasPrefix("/"),
+ !formula.hasSuffix("/"),
+ !formula.contains("//"),
+ !formula.contains("..") else { return false }
+ let allowed = CharacterSet.alphanumerics.union(
+ CharacterSet(charactersIn: "@+._-/")
+ )
+ return formula.unicodeScalars.allSatisfy { allowed.contains($0) }
+ }
+}
+
+enum LanguageServerInstallationState: Equatable, Sendable {
+ case idle
+ case installing
+ case installed(String)
+ case failed(String)
+}
+
+enum LanguageServerExecutableVerificationState: Equatable, Sendable {
+ case unavailable
+ case foundUnverified
+ case executableVerified
+}
+
+enum LanguageServerToolConfigurationError: LocalizedError, Equatable {
+ case executableRequired
+ case executableInvalid(String)
+ case executableValidationFailed(path: String, message: String)
+ case homebrewUnavailable
+ case homebrewUnsupported(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .executableRequired:
+ "Choose a language-server executable."
+ case .executableInvalid(let path):
+ "The selected language-server path is not executable: \(path)"
+ case .executableValidationFailed(let path, let message):
+ "The selected language server could not run: \(path)\n\(message)"
+ case .homebrewUnavailable:
+ "Homebrew is not installed or is not available to Lithe."
+ case .homebrewUnsupported(let provider):
+ "No verified Homebrew formula is configured for \(provider)."
+ }
+ }
+}
+
+@MainActor
+final class LanguageServerToolService: ObservableObject {
+ @Published private(set) var customExecutablePaths: [String: String]
+ @Published private(set) var installationStates: [String: LanguageServerInstallationState] = [:]
+
+ private let runtimeService: ProjectRuntimeService
+ private let processRunner: any ProcessRunner
+ private let settingsStore: LanguageServerToolSettingsStore
+ private var validationCache: [ExecutableValidationKey: ExecutableValidationResult] = [:]
+
+ init(
+ runtimeService: ProjectRuntimeService,
+ processRunner: any ProcessRunner,
+ store: any KeyValueStore
+ ) {
+ self.runtimeService = runtimeService
+ self.processRunner = processRunner
+ settingsStore = LanguageServerToolSettingsStore(store: store)
+ customExecutablePaths = settingsStore.load()
+ }
+
+ func installPlan(for descriptor: LanguageProviderDescriptor) -> LanguageServerInstallPlan {
+ LanguageServerInstallPlan.plan(for: descriptor)
+ }
+
+ func customExecutablePath(for providerID: String) -> String? {
+ customExecutablePaths[providerID]
+ }
+
+ func installationState(for providerID: String) -> LanguageServerInstallationState {
+ installationStates[providerID] ?? .idle
+ }
+
+ func isHomebrewAvailable() -> Bool {
+ runtimeService.executableOnPath("brew") != nil
+ }
+
+ func candidates(for descriptor: LanguageProviderDescriptor) -> [RuntimeToolCandidate] {
+ var result: [RuntimeToolCandidate] = []
+ var seen = Set()
+
+ if let path = customExecutablePath(for: descriptor.id),
+ let executableURL = runtimeService.executableURL(at: path) {
+ let candidate = RuntimeToolCandidate(
+ command: descriptor.languageServerLaunch?.executableNames.first ?? descriptor.id,
+ executableURL: executableURL,
+ source: .custom,
+ detail: "Lithe override"
+ )
+ if validate(candidate, for: descriptor).isUsable {
+ result.append(candidate)
+ seen.insert(executableURL.path)
+ }
+ }
+
+ for command in descriptor.languageServerLaunch?.executableNames ?? [] {
+ for candidate in runtimeService.executableCandidates(command) {
+ guard seen.insert(candidate.executableURL.path).inserted else { continue }
+ guard validate(candidate, for: descriptor).isUsable else { continue }
+ result.append(candidate)
+ }
+ }
+ return result
+ }
+
+ func executableURL(for descriptor: LanguageProviderDescriptor) -> URL? {
+ candidates(for: descriptor).first?.executableURL
+ }
+
+ func executableVerificationState(
+ for descriptor: LanguageProviderDescriptor
+ ) -> LanguageServerExecutableVerificationState {
+ guard let candidate = candidates(for: descriptor).first else {
+ return .unavailable
+ }
+ return validate(candidate, for: descriptor).didExecute
+ ? .executableVerified
+ : .foundUnverified
+ }
+
+ func setCustomExecutablePath(
+ _ path: String,
+ for descriptor: LanguageProviderDescriptor
+ ) throws {
+ let normalized = (path as NSString)
+ .expandingTildeInPath
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalized.isEmpty else {
+ throw LanguageServerToolConfigurationError.executableRequired
+ }
+ guard let executableURL = runtimeService.executableURL(at: normalized) else {
+ throw LanguageServerToolConfigurationError.executableInvalid(normalized)
+ }
+ validationCache.removeAll()
+ let candidate = RuntimeToolCandidate(
+ command: descriptor.languageServerLaunch?.executableNames.first ?? descriptor.id,
+ executableURL: executableURL,
+ source: .custom,
+ detail: "Lithe override"
+ )
+ let validation = validate(candidate, for: descriptor)
+ guard validation.isUsable else {
+ throw LanguageServerToolConfigurationError.executableValidationFailed(
+ path: executableURL.path,
+ message: validation.message
+ )
+ }
+ customExecutablePaths[descriptor.id] = executableURL.path
+ settingsStore.save(customExecutablePaths)
+ }
+
+ func clearCustomExecutablePath(for providerID: String) {
+ customExecutablePaths[providerID] = nil
+ settingsStore.save(customExecutablePaths)
+ }
+
+ func installWithHomebrew(_ descriptor: LanguageProviderDescriptor) async {
+ let plan = installPlan(for: descriptor)
+ guard let formula = plan.homebrewFormula else {
+ installationStates[descriptor.id] = .failed(
+ LanguageServerToolConfigurationError.homebrewUnsupported(descriptor.displayName)
+ .localizedDescription
+ )
+ return
+ }
+ guard let brewURL = runtimeService.executableOnPath("brew") else {
+ installationStates[descriptor.id] = .failed(
+ LanguageServerToolConfigurationError.homebrewUnavailable.localizedDescription
+ )
+ return
+ }
+
+ installationStates[descriptor.id] = .installing
+ let runner = processRunner
+ let request = ProcessRequest(
+ operationID: "lsp-install-\(descriptor.id)-\(UUID().uuidString)",
+ executablePath: brewURL.path,
+ arguments: ["install", formula],
+ environment: runtimeService.processEnvironment(),
+ timeoutMilliseconds: 10 * 60 * 1_000
+ )
+ let result = await Task.detached(priority: .userInitiated) {
+ runner.run(request)
+ }.value
+
+ let output = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
+ if result.succeeded {
+ validationCache.removeAll()
+ installationStates[descriptor.id] = .installed(
+ output.isEmpty ? "brew install \(formula) completed." : output
+ )
+ } else {
+ installationStates[descriptor.id] = .failed(
+ output.isEmpty ? "brew install \(formula) failed with exit code \(result.exitCode)." : output
+ )
+ }
+ }
+
+ private func validate(
+ _ candidate: RuntimeToolCandidate,
+ for descriptor: LanguageProviderDescriptor
+ ) -> ExecutableValidationResult {
+ let arguments = descriptor.languageServerLaunch?.validationArguments ?? []
+ guard !arguments.isEmpty else { return .unverifiedUsable }
+ let key = ExecutableValidationKey(
+ executablePath: candidate.executableURL.standardizedFileURL.path,
+ arguments: arguments
+ )
+ if let cached = validationCache[key],
+ Date().timeIntervalSince(cached.checkedAt) < 30 {
+ return cached
+ }
+ let result = processRunner.run(ProcessRequest(
+ operationID: "lsp-validate-\(descriptor.id)-\(UUID().uuidString)",
+ executablePath: key.executablePath,
+ arguments: arguments,
+ environment: runtimeService.processEnvironment(),
+ timeoutMilliseconds: 5_000
+ ))
+ let output = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
+ let validation = ExecutableValidationResult(
+ isUsable: result.succeeded,
+ message: output.isEmpty ? "Exited with code \(result.exitCode)." : output,
+ didExecute: true,
+ checkedAt: Date()
+ )
+ validationCache[key] = validation
+ return validation
+ }
+}
+
+private struct ExecutableValidationKey: Hashable {
+ let executablePath: String
+ let arguments: [String]
+}
+
+private struct ExecutableValidationResult {
+ let isUsable: Bool
+ let message: String
+ let didExecute: Bool
+ let checkedAt: Date
+
+ static let unverifiedUsable = Self(
+ isUsable: true,
+ message: "",
+ didExecute: false,
+ checkedAt: .distantFuture
+ )
+}
+
+private struct LanguageServerToolSettingsStore {
+ private static let key = "lithe.language-server-tools.executable-paths"
+ private let store: any KeyValueStore
+
+ init(store: any KeyValueStore) {
+ self.store = store
+ }
+
+ func load() -> [String: String] {
+ guard let data = store.data(forKey: Self.key),
+ let value = try? JSONDecoder().decode([String: String].self, from: data) else {
+ return [:]
+ }
+ return value
+ }
+
+ func save(_ paths: [String: String]) {
+ guard let data = try? JSONEncoder().encode(paths) else { return }
+ store.set(data, forKey: Self.key)
+ }
+}
diff --git a/Sources/Lithe/Services/LanguageToolingSessionManager.swift b/Sources/Lithe/Services/LanguageToolingSessionManager.swift
index 264e8a838..8426c0b32 100644
--- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift
+++ b/Sources/Lithe/Services/LanguageToolingSessionManager.swift
@@ -20,31 +20,47 @@ enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable {
}
}
-/// Owns only active language-tooling processes. The catalog is metadata and
-/// providers are created lazily on first use, so opening a workspace does not
-/// start five LSPs or Debug Adapters just because they are supported.
+/// UI-facing façade that routes language features across active LSP sessions
+/// and lightweight local providers without exposing either implementation.
@MainActor
final class LanguageToolingSessionManager: ObservableObject {
@Published private(set) var diagnostics: [URL: [LanguageServerDiagnostic]] = [:]
@Published private(set) var languageServerFeatures: [String: LanguageServerFeatureSet] = [:]
+ @Published private(set) var languageServerLogs: [LanguageServerLogEntry] = []
+ @Published private(set) var languageServerStates: [String: LanguageServerSessionState] = [:]
+ @Published private(set) var languageServerInfos: [String: LanguageServerInfo] = [:]
@Published private(set) var debugStates: [String: DebugAdapterState] = [:]
@Published private(set) var lastDebugEvents: [String: DebugAdapterEvent] = [:]
@Published private(set) var verifiedBreakpoints: [String: [DebugBreakpoint]] = [:]
+
var onDebugStateChange: ((String, DebugAdapterState) -> Void)?
var onDebugEvent: ((String, DebugAdapterEvent) -> Void)?
- private let catalog: LanguageProviderCatalog
+
+ private var catalog: LanguageProviderCatalog
private var runtimesByID: [String: any LanguageProviderRuntime]
+ private let runtimeFactory: (any LanguageProviderRuntimeFactory)?
private var languageServers: [String: any LanguageServerSession] = [:]
private var languageServerRoots: [String: URL] = [:]
+ private var languageServerSessionIdentities: [String: ObjectIdentifier] = [:]
+ private var diagnosticsByProviderID: [String: [URL: [LanguageServerDiagnostic]]] = [:]
+ private var languageFeatureProviders: [any LanguageFeatureProvider]
+ private var languageServerFeatureProviders: [String: LanguageServerFeatureProvider] = [:]
private var debugAdapters: [String: any DebugAdapterSession] = [:]
private var debugAdapterRoots: [String: URL] = [:]
private var requestedBreakpoints: [String: [URL: [DebugSourceBreakpoint]]] = [:]
init(
catalog: LanguageProviderCatalog = .standard,
- runtimes: [any LanguageProviderRuntime] = []
+ runtimes: [any LanguageProviderRuntime] = [],
+ runtimeFactory: (any LanguageProviderRuntimeFactory)? = nil,
+ core: RustCoreBridge = RustCoreBridge(),
+ languageFeatureProviders: [any LanguageFeatureProvider] = []
) {
self.catalog = catalog
+ self.runtimeFactory = runtimeFactory
+ self.languageFeatureProviders = languageFeatureProviders + [
+ BuiltinLanguageFeatureProvider(core: core)
+ ]
runtimesByID = Dictionary(uniqueKeysWithValues: runtimes.map { ($0.descriptor.id, $0) })
}
@@ -52,75 +68,466 @@ final class LanguageToolingSessionManager: ObservableObject {
self.init(catalog: registry.catalog, runtimes: registry.toolingRuntimes)
}
- var activeLanguageServerIDs: Set { Set(languageServers.keys) }
+ var activeLanguageServerIDs: Set {
+ Set(languageServers.compactMap { providerID, session in
+ guard session.isRunning,
+ languageServerStates[providerID] == .ready else { return nil }
+ return providerID
+ })
+ }
var activeDebugAdapterIDs: Set { Set(debugAdapters.keys) }
+ func updateCatalog(_ catalog: LanguageProviderCatalog) {
+ let previousDescriptors = Dictionary(
+ uniqueKeysWithValues: self.catalog.descriptors.map { ($0.id, $0) }
+ )
+ let updatedDescriptors = Dictionary(
+ uniqueKeysWithValues: catalog.descriptors.map { ($0.id, $0) }
+ )
+ let changedProviderIDs = Set(previousDescriptors.keys)
+ .union(updatedDescriptors.keys)
+ .filter { previousDescriptors[$0] != updatedDescriptors[$0] }
+
+ self.catalog = catalog
+ let validProviderIDs = Set(catalog.descriptors.map(\.id))
+ languageServerFeatures = languageServerFeatures.filter { validProviderIDs.contains($0.key) }
+ languageServerStates = languageServerStates.filter { validProviderIDs.contains($0.key) }
+ languageServerInfos = languageServerInfos.filter { validProviderIDs.contains($0.key) }
+ diagnosticsByProviderID = diagnosticsByProviderID.filter {
+ validProviderIDs.contains($0.key)
+ }
+ rebuildDiagnostics()
+ languageServerLogs = languageServerLogs.filter { validProviderIDs.contains($0.providerID) }
+ for providerID in changedProviderIDs {
+ stopLanguageServer(providerID: providerID)
+ stopDebugAdapter(providerID: providerID)
+ if updatedDescriptors[providerID] == nil {
+ languageServerStates[providerID] = nil
+ }
+ if runtimeFactory != nil {
+ runtimesByID[providerID] = nil
+ }
+ }
+ }
+
func provider(for fileURL: URL) -> LanguageProviderDescriptor? {
catalog.provider(for: fileURL)
}
func supportsGenericEditing(for fileURL: URL) -> Bool {
- guard let descriptor = catalog.provider(for: fileURL),
- descriptor.capabilities.contains(.languageServer) else { return false }
- guard runtimesByID[descriptor.id]?.supportsEditingSession == true else { return false }
return !features(for: fileURL).isEmpty
}
func supportsGenericDebugging(for fileURL: URL) -> Bool {
- guard let descriptor = catalog.provider(for: fileURL) else { return false }
- return runtimesByID[descriptor.id]?.supportsDebugAdapterSession == true
+ guard let descriptor = catalog.provider(for: fileURL),
+ descriptor.capabilities.contains(.debugAdapter) else { return false }
+ return runtime(for: descriptor)?.supportsDebugAdapterSession == true
}
func features(for fileURL: URL) -> LanguageServerFeatureSet {
- guard let descriptor = catalog.provider(for: fileURL) else { return [] }
- return languageServerFeatures[descriptor.id]
- ?? runtimesByID[descriptor.id]?.declaredLanguageServerFeatures
- ?? []
+ let context = featureContext(
+ fileURL: fileURL,
+ text: "",
+ position: LanguageServerPosition(line: 0, utf16Column: 0),
+ rootURL: nil
+ )
+ var result = catalog.provider(for: fileURL).flatMap {
+ languageServerFeatures[$0.id]
+ } ?? []
+ for provider in languageFeatureProviders {
+ if provider.supports(.completion, in: context) { result.insert(.completion) }
+ if provider.supports(.hover, in: context) { result.insert(.hover) }
+ if provider.supports(.navigation(method: "textDocument/definition"), in: context) {
+ result.insert(.definition)
+ }
+ if provider.supports(.navigation(method: "textDocument/references"), in: context) {
+ result.insert(.references)
+ }
+ if provider.supports(.navigation(method: "textDocument/implementation"), in: context) {
+ result.insert(.implementation)
+ }
+ }
+ return result
}
- @discardableResult
- func activateLanguageServer(for fileURL: URL, rootURL: URL) throws -> any LanguageServerSession {
+ func synchronizeLanguageServer(
+ for fileURL: URL,
+ text: String,
+ rootURL: URL
+ ) throws {
guard let descriptor = catalog.provider(for: fileURL) else {
throw LanguageToolingSessionError.noProvider(fileExtension: fileURL.pathExtension.lowercased())
}
- guard descriptor.capabilities.contains(.languageServer) else {
- throw LanguageToolingSessionError.capabilityUnavailable(
- provider: descriptor.displayName,
- capability: "language server"
- )
+ guard descriptor.capabilities.contains(.languageServer) else { return }
+ guard let runtime = runtime(for: descriptor),
+ runtime.supportsLanguageServerSession else {
+ return
}
let normalizedRoot = rootURL.standardizedFileURL
- if let active = languageServers[descriptor.id] {
- if active.isRunning, languageServerRoots[descriptor.id] == normalizedRoot {
- return active
+ let session: any LanguageServerSession
+ if let active = languageServers[descriptor.id],
+ active.isRunning,
+ languageServerRoots[descriptor.id] == normalizedRoot {
+ session = active
+ } else {
+ // Retire every projection of the previous root before resolving a
+ // replacement. If executable resolution or construction fails,
+ // the UI must not retain a stale ready session from the old root.
+ stopLanguageServer(providerID: descriptor.id)
+ recordLanguageServerLog(
+ providerID: descriptor.id,
+ level: .info,
+ message: "Resolving language server",
+ detail: descriptor.languageServerLaunch?.executableNames.joined(separator: ", ")
+ )
+ guard let created = runtime.makeLanguageServerSession() else {
+ let message = runtime.unavailableToolingMessage ?? descriptor.displayName
+ languageServerStates[descriptor.id] = .failed(
+ exitCode: nil,
+ message: message
+ )
+ recordLanguageServerLog(
+ providerID: descriptor.id,
+ level: .error,
+ message: "Language server executable was not found",
+ detail: message
+ )
+ throw LanguageToolingSessionError.toolingUnavailable(message)
}
- active.stop()
- languageServers[descriptor.id] = nil
- languageServerRoots[descriptor.id] = nil
+ let featureProvider = LanguageServerFeatureProvider(
+ providerID: descriptor.id,
+ session: created,
+ features: created.features
+ )
+ let sessionIdentity = ObjectIdentifier(created)
+ languageServerSessionIdentities[descriptor.id] = sessionIdentity
+ languageServerStates[descriptor.id] = .startingProcess
+ languageServerFeatures[descriptor.id] = nil
+ languageServerInfos[descriptor.id] = nil
+ languageServerFeatureProviders[descriptor.id] = featureProvider
+ configureLanguageServerCallbacks(
+ created,
+ providerID: descriptor.id,
+ sessionIdentity: sessionIdentity
+ )
+ do {
+ try created.start(rootURL: normalizedRoot)
+ } catch {
+ clearDiagnostics(providerID: descriptor.id)
+ languageServerSessionIdentities[descriptor.id] = nil
+ languageServerStates[descriptor.id] = .failed(
+ exitCode: nil,
+ message: error.localizedDescription
+ )
+ languageServerFeatures[descriptor.id] = nil
+ languageServerInfos[descriptor.id] = nil
+ languageServerFeatureProviders[descriptor.id] = nil
+ recordLanguageServerLog(
+ providerID: descriptor.id,
+ level: .error,
+ message: "Language server failed to start",
+ detail: error.localizedDescription
+ )
+ throw error
+ }
+ languageServers[descriptor.id] = created
+ languageServerRoots[descriptor.id] = normalizedRoot
+ recordLanguageServerLog(
+ providerID: descriptor.id,
+ level: .info,
+ message: "Language server session registered",
+ detail: normalizedRoot.path
+ )
+ session = created
}
- guard let runtime = runtimesByID[descriptor.id] else {
- throw LanguageToolingSessionError.providerNotInstalled(descriptor.displayName)
+ try session.synchronize(
+ fileURL: fileURL,
+ text: text,
+ languageID: descriptor.languageIdentifier(for: fileURL)
+ )
+ }
+
+ func closeDocument(_ fileURL: URL) {
+ let standardizedURL = fileURL.standardizedFileURL
+ clearDiagnostics(for: standardizedURL)
+ languageServerSession(for: standardizedURL)?.closeDocument(standardizedURL)
+ }
+
+ func clearDiagnostics() {
+ diagnosticsByProviderID = [:]
+ diagnostics = [:]
+ }
+
+ func diagnostics(for providerID: String) -> [URL: [LanguageServerDiagnostic]] {
+ diagnosticsByProviderID[providerID] ?? [:]
+ }
+
+ func clearDiagnostics(providerID: String) {
+ guard diagnosticsByProviderID.removeValue(forKey: providerID) != nil else { return }
+ rebuildDiagnostics()
+ }
+
+ func clearLanguageServerLogs() {
+ languageServerLogs = []
+ }
+
+ func recordLanguageServerLog(
+ providerID: String,
+ level: LanguageServerLogLevel,
+ message: String,
+ detail: String? = nil
+ ) {
+ languageServerLogs.insert(LanguageServerLogEntry(
+ providerID: providerID,
+ level: level,
+ message: message,
+ detail: detail
+ ), at: 0)
+ if languageServerLogs.count > 100 {
+ languageServerLogs.removeLast(languageServerLogs.count - 100)
}
- guard let session = runtime.makeLanguageServerSession() else {
- throw LanguageToolingSessionError.toolingUnavailable(
- runtime.unavailableToolingMessage ?? descriptor.displayName
+ }
+
+ func stopLanguageServer(providerID: String) {
+ if languageServers[providerID] != nil {
+ recordLanguageServerLog(
+ providerID: providerID,
+ level: .info,
+ message: "Stopping language server",
+ detail: nil
)
}
- languageServerFeatures[descriptor.id] = runtime.declaredLanguageServerFeatures
- if let reporting = session as? any LanguageServerFeatureReportingSession {
- reporting.onSupportedFeaturesChange = { [weak self] features in
- self?.languageServerFeatures[descriptor.id] = features
- }
+ clearDiagnostics(providerID: providerID)
+ languageServerSessionIdentities[providerID] = nil
+ languageServers.removeValue(forKey: providerID)?.stop()
+ languageServerRoots[providerID] = nil
+ languageServerFeatures[providerID] = nil
+ languageServerInfos[providerID] = nil
+ languageServerFeatureProviders[providerID] = nil
+ languageServerStates[providerID] = .stopped
+ }
+
+ func stopAllLanguageServers() {
+ for providerID in languageServers.keys {
+ recordLanguageServerLog(
+ providerID: providerID,
+ level: .info,
+ message: "Stopping language server",
+ detail: "Stop all"
+ )
}
- do {
- try session.start(rootURL: normalizedRoot)
- } catch {
- languageServerFeatures[descriptor.id] = nil
- throw error
+ let sessions = Array(languageServers.values)
+ clearDiagnostics()
+ languageServerFeatures = [:]
+ languageServerInfos = [:]
+ languageServers.removeAll()
+ languageServerRoots.removeAll()
+ languageServerSessionIdentities.removeAll()
+ languageServerFeatureProviders.removeAll()
+ languageServerStates = [:]
+ for session in sessions { session.stop() }
+ }
+
+ func navigate(
+ method: String,
+ fileURL: URL,
+ text: String,
+ position: LanguageServerPosition,
+ rootURL: URL,
+ completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
+ ) throws {
+ let context = featureContext(
+ fileURL: fileURL,
+ text: text,
+ position: position,
+ rootURL: rootURL
+ )
+ let providers = featureProviders(
+ for: .navigation(method: method),
+ context: context
+ )
+ guard !providers.isEmpty else {
+ throw unavailableLanguageServerError(for: fileURL)
}
- languageServers[descriptor.id] = session
- languageServerRoots[descriptor.id] = normalizedRoot
- return session
+ routeNavigation(
+ providers: providers,
+ index: 0,
+ method: method,
+ context: context,
+ completion: completion
+ )
+ }
+
+ func hover(
+ fileURL: URL,
+ text: String,
+ position: LanguageServerPosition,
+ rootURL: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ let context = featureContext(
+ fileURL: fileURL,
+ text: text,
+ position: position,
+ rootURL: rootURL
+ )
+ let providers = featureProviders(for: .hover, context: context)
+ guard !providers.isEmpty else {
+ throw unavailableLanguageServerError(for: fileURL)
+ }
+ routeHover(
+ providers: providers,
+ index: 0,
+ context: context,
+ completion: completion
+ )
+ }
+
+ func completions(
+ fileURL: URL,
+ text: String,
+ position: LanguageServerPosition,
+ rootURL: URL,
+ completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
+ ) throws {
+ let context = featureContext(
+ fileURL: fileURL,
+ text: text,
+ position: position,
+ rootURL: rootURL
+ )
+ let providers = featureProviders(for: .completion, context: context)
+ guard !providers.isEmpty else {
+ throw unavailableLanguageServerError(for: fileURL)
+ }
+ routeCompletions(
+ providers: providers,
+ index: 0,
+ context: context,
+ items: [],
+ seenLabels: [],
+ completion: completion
+ )
+ }
+
+ func rename(
+ fileURL: URL,
+ text _: String,
+ position: LanguageServerPosition,
+ newName: String,
+ rootURL _: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ if let session = readyLanguageServerSession(for: fileURL) {
+ try session.rename(
+ fileURL: fileURL,
+ position: position,
+ newName: newName,
+ completion: completion
+ )
+ return
+ }
+ throw unavailableLanguageServerError(for: fileURL)
+ }
+
+ func format(
+ fileURL: URL,
+ text _: String,
+ rootURL _: URL,
+ options _: [String: Any] = [
+ "tabSize": 4,
+ "insertSpaces": true,
+ "trimTrailingWhitespace": true,
+ "insertFinalNewline": true,
+ "trimFinalNewlines": true
+ ],
+ completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void
+ ) throws {
+ if let session = readyLanguageServerSession(for: fileURL) {
+ try session.format(fileURL: fileURL, completion: completion)
+ return
+ }
+ throw unavailableLanguageServerError(for: fileURL)
+ }
+
+ func codeActions(
+ fileURL: URL,
+ text _: String,
+ range: LanguageServerRange,
+ diagnostics: [LanguageServerDiagnostic],
+ rootURL _: URL,
+ completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void
+ ) throws {
+ if let session = readyLanguageServerSession(for: fileURL) {
+ try session.codeActions(
+ fileURL: fileURL,
+ range: range,
+ diagnostics: diagnostics,
+ completion: completion
+ )
+ return
+ }
+ throw unavailableLanguageServerError(for: fileURL)
+ }
+
+ func execute(
+ _ command: LanguageServerCommand,
+ fileURL: URL,
+ text _: String,
+ rootURL _: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ guard command.command.isEmpty == false else {
+ throw LanguageToolingSessionError.capabilityUnavailable(
+ provider: catalog.provider(for: fileURL)?.displayName ?? fileURL.pathExtension,
+ capability: "execute command"
+ )
+ }
+ if let session = readyLanguageServerSession(for: fileURL) {
+ try session.execute(command, fileURL: fileURL, completion: completion)
+ return
+ }
+ throw unavailableLanguageServerError(for: fileURL)
+ }
+
+ func resolveCompletion(
+ _ item: LanguageServerCompletionItem,
+ fileURL: URL,
+ text _: String,
+ rootURL _: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ guard item.label.isEmpty == false else {
+ throw LanguageToolingSessionError.capabilityUnavailable(
+ provider: catalog.provider(for: fileURL)?.displayName ?? fileURL.pathExtension,
+ capability: "completion item resolve"
+ )
+ }
+ if let session = readyLanguageServerSession(for: fileURL) {
+ try session.resolveCompletion(item, fileURL: fileURL, completion: completion)
+ return
+ }
+ throw unavailableLanguageServerError(for: fileURL)
+ }
+
+ func resolveCodeAction(
+ _ action: LanguageServerCodeAction,
+ fileURL: URL,
+ text _: String,
+ rootURL _: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ guard action.title.isEmpty == false else {
+ throw LanguageToolingSessionError.capabilityUnavailable(
+ provider: catalog.provider(for: fileURL)?.displayName ?? fileURL.pathExtension,
+ capability: "code action resolve"
+ )
+ }
+ if let session = readyLanguageServerSession(for: fileURL) {
+ try session.resolveCodeAction(action, fileURL: fileURL, completion: completion)
+ return
+ }
+ throw unavailableLanguageServerError(for: fileURL)
}
@discardableResult
@@ -128,8 +535,7 @@ final class LanguageToolingSessionManager: ObservableObject {
guard let descriptor = catalog.provider(for: fileURL) else {
throw LanguageToolingSessionError.noProvider(fileExtension: fileURL.pathExtension.lowercased())
}
- guard descriptor.capabilities.contains(.debugAdapter)
- || runtimesByID[descriptor.id]?.supportsDebugAdapterSession == true else {
+ guard descriptor.capabilities.contains(.debugAdapter) else {
throw LanguageToolingSessionError.capabilityUnavailable(
provider: descriptor.displayName,
capability: "debug adapter"
@@ -144,7 +550,7 @@ final class LanguageToolingSessionManager: ObservableObject {
debugAdapters[descriptor.id] = nil
debugAdapterRoots[descriptor.id] = nil
}
- guard let runtime = runtimesByID[descriptor.id] else {
+ guard let runtime = runtime(for: descriptor) else {
throw LanguageToolingSessionError.providerNotInstalled(descriptor.displayName)
}
guard let session = runtime.makeDebugAdapterSession(rootURL: normalizedRoot) else {
@@ -165,10 +571,19 @@ final class LanguageToolingSessionManager: ObservableObject {
return session
}
- func stopLanguageServer(providerID: String) {
- languageServers.removeValue(forKey: providerID)?.stop()
- languageServerRoots[providerID] = nil
- languageServerFeatures[providerID] = nil
+ private func runtime(
+ for descriptor: LanguageProviderDescriptor
+ ) -> (any LanguageProviderRuntime)? {
+ if let existing = runtimesByID[descriptor.id],
+ existing.descriptor == descriptor {
+ return existing
+ }
+ guard let runtimeFactory,
+ let runtime = runtimeFactory.makeRuntime(for: descriptor) else {
+ return runtimesByID[descriptor.id]
+ }
+ runtimesByID[descriptor.id] = runtime
+ return runtime
}
func stopDebugAdapter(providerID: String) {
@@ -178,18 +593,23 @@ final class LanguageToolingSessionManager: ObservableObject {
}
func stopAll() {
- for session in languageServers.values { session.stop() }
+ let languageServerSessions = Array(languageServers.values)
for session in debugAdapters.values { session.stop() }
+ clearDiagnostics()
+ languageServerFeatures = [:]
+ languageServerInfos = [:]
languageServers.removeAll()
languageServerRoots.removeAll()
- languageServerFeatures = [:]
+ languageServerSessionIdentities.removeAll()
+ languageServerFeatureProviders.removeAll()
+ languageServerStates = [:]
+ for session in languageServerSessions { session.stop() }
debugAdapters.removeAll()
debugAdapterRoots.removeAll()
debugStates = [:]
lastDebugEvents = [:]
verifiedBreakpoints = [:]
requestedBreakpoints = [:]
- diagnostics = [:]
}
@discardableResult
@@ -219,8 +639,7 @@ final class LanguageToolingSessionManager: ObservableObject {
fileExtension: fileURL.pathExtension.lowercased()
)
}
- guard descriptor.capabilities.contains(.debugAdapter)
- || runtimesByID[descriptor.id]?.supportsDebugAdapterSession == true else {
+ guard descriptor.capabilities.contains(.debugAdapter) else {
throw LanguageToolingSessionError.capabilityUnavailable(
provider: descriptor.displayName,
capability: "debug adapter breakpoints"
@@ -237,320 +656,300 @@ final class LanguageToolingSessionManager: ObservableObject {
debugAdapters[providerID] as? any DebugAdapterControllingSession
}
- func synchronizeLanguageServer(
- for fileURL: URL,
- text: String,
- rootURL: URL
- ) throws {
+ private func unavailableLanguageServerError(for fileURL: URL) -> LanguageToolingSessionError {
guard let descriptor = catalog.provider(for: fileURL) else {
- throw LanguageToolingSessionError.noProvider(
- fileExtension: fileURL.pathExtension.lowercased()
- )
- }
- let session = try activateLanguageServer(for: fileURL, rootURL: rootURL)
- guard let documentSession = session as? any LanguageServerDocumentSession else { return }
- documentSession.onDiagnostics = { [weak self] url, diagnostics in
- self?.diagnostics[url.standardizedFileURL] = diagnostics
+ return .noProvider(fileExtension: fileURL.pathExtension.lowercased())
+ }
+ let provider = descriptor.displayName
+ if let state = languageServerStates[descriptor.id] {
+ switch state {
+ case .startingProcess:
+ return .toolingUnavailable("\(provider) language server process is starting.")
+ case .initializing:
+ return .toolingUnavailable("\(provider) language server is initializing.")
+ case .failed(_, let message):
+ return .toolingUnavailable(message ?? "\(provider) language server failed.")
+ case .stopping:
+ return .toolingUnavailable("\(provider) language server is stopping.")
+ case .stopped:
+ break
+ case .ready:
+ return .capabilityUnavailable(provider: provider, capability: "this language feature")
+ }
}
- documentSession.synchronizeDocument(
- url: fileURL,
- languageIdentifier: descriptor.languageIdentifier(for: fileURL),
- text: text
+ return .toolingUnavailable(
+ "\(provider) language server is not ready."
)
}
- func closeDocument(_ fileURL: URL) {
- guard let descriptor = catalog.provider(for: fileURL),
- let session = languageServers[descriptor.id] as? any LanguageServerDocumentSession
- else { return }
- session.closeDocument(url: fileURL)
- diagnostics[fileURL.standardizedFileURL] = nil
- }
-
- func navigate(
- method: String,
- fileURL: URL,
- text: String,
- position: LanguageServerPosition,
- rootURL: URL,
- completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
- ) throws {
- guard let descriptor = catalog.provider(for: fileURL) else {
- throw LanguageToolingSessionError.noProvider(
- fileExtension: fileURL.pathExtension.lowercased()
- )
- }
- let feature: LanguageServerFeatureSet
- let capability: String
- switch method {
- case "textDocument/definition":
- feature = .definition
- capability = "go to definition"
- case "textDocument/references":
- feature = .references
- capability = "find references"
- case "textDocument/implementation":
- feature = .implementation
- capability = "go to implementation"
- default:
- throw LanguageToolingSessionError.capabilityUnavailable(
- provider: descriptor.displayName,
- capability: method
- )
- }
- let session = try activateLanguageServer(for: fileURL, rootURL: rootURL)
- try requireFeature(feature, descriptor: descriptor, capability: capability)
- guard let navigation = session as? any LanguageServerNavigationSession else {
- throw LanguageToolingSessionError.capabilityUnavailable(
- provider: descriptor.displayName,
- capability: "definition and reference navigation"
- )
- }
- navigation.onDiagnostics = { [weak self] url, diagnostics in
- self?.diagnostics[url.standardizedFileURL] = diagnostics
- }
- navigation.synchronizeDocument(
- url: fileURL,
- languageIdentifier: descriptor.languageIdentifier(for: fileURL),
- text: text
- )
- navigation.locations(
- method: method,
- documentURL: fileURL,
- position: position,
- completion: completion
- )
+ private func languageServerSession(for fileURL: URL) -> (any LanguageServerSession)? {
+ guard let descriptor = catalog.provider(for: fileURL) else { return nil }
+ return languageServers[descriptor.id]
}
- func hover(
- fileURL: URL,
- text: String,
- position: LanguageServerPosition,
- rootURL: URL,
- completion: @escaping (Result) -> Void
- ) throws {
- let session = try codeIntelligenceSession(
- fileURL: fileURL,
- text: text,
- rootURL: rootURL,
- requiredFeature: .hover,
- capability: "hover"
- )
- session.hover(documentURL: fileURL, position: position, completion: completion)
- }
-
- func completions(
- fileURL: URL,
- text: String,
- position: LanguageServerPosition,
- rootURL: URL,
- completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
- ) throws {
- let session = try codeIntelligenceSession(
- fileURL: fileURL,
- text: text,
- rootURL: rootURL,
- requiredFeature: .completion,
- capability: "completion"
- )
- session.completions(documentURL: fileURL, position: position, completion: completion)
+ private func readyLanguageServerSession(for fileURL: URL) -> (any LanguageServerSession)? {
+ guard let descriptor = catalog.provider(for: fileURL),
+ languageServerStates[descriptor.id] == .ready,
+ let session = languageServers[descriptor.id],
+ session.isRunning else { return nil }
+ return session
}
- func rename(
+ private func featureContext(
fileURL: URL,
text: String,
position: LanguageServerPosition,
- newName: String,
- rootURL: URL,
- completion: @escaping (Result) -> Void
- ) throws {
- let session = try editingSession(
+ rootURL: URL?
+ ) -> LanguageFeatureRequestContext {
+ let descriptor = catalog.provider(for: fileURL)
+ return LanguageFeatureRequestContext(
fileURL: fileURL,
text: text,
- rootURL: rootURL,
- requiredFeature: .rename,
- capability: "rename"
- )
- session.rename(
- documentURL: fileURL,
position: position,
- newName: newName,
- completion: completion
+ languageID: descriptor?.languageIdentifier(for: fileURL),
+ workspaceURL: rootURL
)
}
- func format(
- fileURL: URL,
- text: String,
- rootURL: URL,
- options: [String: Any] = [
- "tabSize": 4,
- "insertSpaces": true,
- "trimTrailingWhitespace": true,
- "insertFinalNewline": true,
- "trimFinalNewlines": true
- ],
- completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void
- ) throws {
- let session = try editingSession(
- fileURL: fileURL,
- text: text,
- rootURL: rootURL,
- requiredFeature: .formatting,
- capability: "document formatting"
- )
- session.formatting(documentURL: fileURL, options: options, completion: completion)
- }
-
- func codeActions(
- fileURL: URL,
- text: String,
- range: LanguageServerRange,
- diagnostics: [LanguageServerDiagnostic],
- rootURL: URL,
- completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void
- ) throws {
- let session = try editingSession(
- fileURL: fileURL,
- text: text,
- rootURL: rootURL,
- requiredFeature: .codeActions,
- capability: "code actions"
- )
- session.codeActions(
- documentURL: fileURL,
- range: range,
- diagnostics: diagnostics,
- completion: completion
- )
+ private func featureProviders(
+ for feature: LanguageFeature,
+ context: LanguageFeatureRequestContext
+ ) -> [any LanguageFeatureProvider] {
+ var providers = languageFeatureProviders
+ if let descriptor = catalog.provider(for: context.fileURL),
+ let languageServerProvider = languageServerFeatureProviders[descriptor.id] {
+ providers.append(languageServerProvider)
+ }
+ return providers
+ .filter { $0.supports(feature, in: context) }
+ .sorted { $0.priority > $1.priority }
}
- func execute(
- _ command: LanguageServerCommand,
- fileURL: URL,
- text: String,
- rootURL: URL,
- completion: @escaping (Result) -> Void
- ) throws {
- let session = try editingSession(
- fileURL: fileURL,
- text: text,
- rootURL: rootURL,
- requiredFeature: .executeCommand,
- capability: "execute command"
- )
- session.execute(command: command, completion: completion)
+ private func routeCompletions(
+ providers: [any LanguageFeatureProvider],
+ index: Int,
+ context: LanguageFeatureRequestContext,
+ items: [LanguageServerCompletionItem],
+ seenLabels: Set,
+ completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
+ ) {
+ guard index < providers.count else {
+ completion(.success(items))
+ return
+ }
+ do {
+ try providers[index].completions(in: context) { [self] result in
+ switch result {
+ case .success(let providerItems):
+ var merged = items
+ var labels = seenLabels
+ for item in providerItems where labels.insert(item.label).inserted {
+ merged.append(item)
+ }
+ routeCompletions(
+ providers: providers,
+ index: index + 1,
+ context: context,
+ items: merged,
+ seenLabels: labels,
+ completion: completion
+ )
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ } catch {
+ completion(.failure(error))
+ }
}
- func resolveCompletion(
- _ item: LanguageServerCompletionItem,
- fileURL: URL,
- text: String,
- rootURL: URL,
- completion: @escaping (Result) -> Void
- ) throws {
- let session = try editingSession(
- fileURL: fileURL,
- text: text,
- rootURL: rootURL,
- requiredFeature: .completionResolve,
- capability: "completion item resolve"
- )
- session.resolveCompletion(item, completion: completion)
+ private func routeHover(
+ providers: [any LanguageFeatureProvider],
+ index: Int,
+ context: LanguageFeatureRequestContext,
+ completion: @escaping (Result) -> Void
+ ) {
+ guard index < providers.count else {
+ completion(.success(nil))
+ return
+ }
+ do {
+ try providers[index].hover(in: context) { [self] result in
+ switch result {
+ case .success(let hover?):
+ completion(.success(hover))
+ case .success(nil):
+ routeHover(
+ providers: providers,
+ index: index + 1,
+ context: context,
+ completion: completion
+ )
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ } catch {
+ completion(.failure(error))
+ }
}
- func resolveCodeAction(
- _ action: LanguageServerCodeAction,
- fileURL: URL,
- text: String,
- rootURL: URL,
- completion: @escaping (Result) -> Void
- ) throws {
- let session = try editingSession(
- fileURL: fileURL,
- text: text,
- rootURL: rootURL,
- requiredFeature: .codeActionResolve,
- capability: "code action resolve"
- )
- session.resolveCodeAction(action, completion: completion)
+ private func routeNavigation(
+ providers: [any LanguageFeatureProvider],
+ index: Int,
+ method: String,
+ context: LanguageFeatureRequestContext,
+ completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
+ ) {
+ guard index < providers.count else {
+ completion(.success([]))
+ return
+ }
+ do {
+ try providers[index].navigate(method: method, in: context) { [self] result in
+ switch result {
+ case .success(let locations):
+ if locations.isEmpty {
+ routeNavigation(
+ providers: providers,
+ index: index + 1,
+ method: method,
+ context: context,
+ completion: completion
+ )
+ } else {
+ completion(.success(locations))
+ }
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ } catch {
+ completion(.failure(error))
+ }
}
- private func codeIntelligenceSession(
- fileURL: URL,
- text: String,
- rootURL: URL,
- requiredFeature: LanguageServerFeatureSet,
- capability: String
- ) throws -> any LanguageServerCodeIntelligenceSession {
- guard let descriptor = catalog.provider(for: fileURL) else {
- throw LanguageToolingSessionError.noProvider(
- fileExtension: fileURL.pathExtension.lowercased()
+ private func configureLanguageServerCallbacks(
+ _ session: any LanguageServerSession,
+ providerID: String,
+ sessionIdentity: ObjectIdentifier
+ ) {
+ session.onDiagnostics = { [weak self] fileURL, diagnostics in
+ guard let self else { return }
+ guard self.languageServerSessionIdentities[providerID] == sessionIdentity else { return }
+ self.replaceDiagnostics(
+ diagnostics,
+ for: fileURL.standardizedFileURL,
+ providerID: providerID
)
}
- let session = try activateLanguageServer(for: fileURL, rootURL: rootURL)
- try requireFeature(requiredFeature, descriptor: descriptor, capability: capability)
- guard let intelligence = session as? any LanguageServerCodeIntelligenceSession else {
- throw LanguageToolingSessionError.capabilityUnavailable(
- provider: descriptor.displayName,
- capability: "hover and completion"
+ session.onFeaturesChange = { [weak self] features in
+ guard let self else { return }
+ guard self.languageServerSessionIdentities[providerID] == sessionIdentity else { return }
+ self.languageServerFeatureProviders[providerID]?.updateFeatures(features)
+ if self.languageServerStates[providerID] == .ready {
+ self.languageServerFeatures[providerID] = features
+ } else {
+ self.languageServerFeatures[providerID] = nil
+ }
+ self.recordLanguageServerLog(
+ providerID: providerID,
+ level: .info,
+ message: features.isEmpty ? "Language server features cleared" : "Language server features updated",
+ detail: features.isEmpty ? nil : "\(features.rawValue)"
)
}
- intelligence.onDiagnostics = { [weak self] url, diagnostics in
- self?.diagnostics[url.standardizedFileURL] = diagnostics
+ session.onServerInfoChange = { [weak self] info in
+ guard let self else { return }
+ guard self.languageServerSessionIdentities[providerID] == sessionIdentity else { return }
+ if self.languageServerStates[providerID] == .ready {
+ self.languageServerInfos[providerID] = info
+ }
}
- intelligence.synchronizeDocument(
- url: fileURL,
- languageIdentifier: descriptor.languageIdentifier(for: fileURL),
- text: text
- )
- return intelligence
- }
-
- private func editingSession(
- fileURL: URL,
- text: String,
- rootURL: URL,
- requiredFeature: LanguageServerFeatureSet,
- capability: String
- ) throws -> any LanguageServerEditingSession {
- guard let descriptor = catalog.provider(for: fileURL) else {
- throw LanguageToolingSessionError.noProvider(
- fileExtension: fileURL.pathExtension.lowercased()
+ session.onLog = { [weak self] level, message, detail in
+ self?.recordLanguageServerLog(
+ providerID: providerID,
+ level: level,
+ message: message,
+ detail: detail
)
}
- let session = try activateLanguageServer(for: fileURL, rootURL: rootURL)
- try requireFeature(requiredFeature, descriptor: descriptor, capability: capability)
- guard let editing = session as? any LanguageServerEditingSession else {
- throw LanguageToolingSessionError.capabilityUnavailable(
- provider: descriptor.displayName,
- capability: "rename, formatting and code actions"
+ session.onStateChange = { [weak self] state in
+ self?.handleLanguageServerState(
+ state,
+ providerID: providerID,
+ sessionIdentity: sessionIdentity,
+ session: session
)
}
- editing.onDiagnostics = { [weak self] url, diagnostics in
- self?.diagnostics[url.standardizedFileURL] = diagnostics
+ }
+
+ private func handleLanguageServerState(
+ _ state: LanguageServerSessionState,
+ providerID: String,
+ sessionIdentity: ObjectIdentifier,
+ session: any LanguageServerSession
+ ) {
+ guard languageServerSessionIdentities[providerID] == sessionIdentity else { return }
+ languageServerStates[providerID] = state
+ switch state {
+ case .stopped, .failed:
+ clearDiagnostics(providerID: providerID)
+ languageServerSessionIdentities[providerID] = nil
+ languageServers[providerID] = nil
+ languageServerRoots[providerID] = nil
+ languageServerFeatures[providerID] = nil
+ languageServerInfos[providerID] = nil
+ languageServerFeatureProviders[providerID] = nil
+ case .startingProcess, .initializing, .stopping:
+ languageServerFeatures[providerID] = nil
+ languageServerInfos[providerID] = nil
+ case .ready:
+ languageServerFeatures[providerID] = session.features
+ languageServerInfos[providerID] = session.serverInfo
}
- editing.synchronizeDocument(
- url: fileURL,
- languageIdentifier: descriptor.languageIdentifier(for: fileURL),
- text: text
- )
- return editing
}
- private func requireFeature(
- _ feature: LanguageServerFeatureSet,
- descriptor: LanguageProviderDescriptor,
- capability: String
- ) throws {
- guard (languageServerFeatures[descriptor.id]
- ?? runtimesByID[descriptor.id]?.declaredLanguageServerFeatures
- ?? []).contains(feature) else {
- throw LanguageToolingSessionError.capabilityUnavailable(
- provider: descriptor.displayName,
- capability: capability
- )
+ private func replaceDiagnostics(
+ _ updatedDiagnostics: [LanguageServerDiagnostic],
+ for fileURL: URL,
+ providerID: String
+ ) {
+ let standardizedURL = fileURL.standardizedFileURL
+ var providerDiagnostics = diagnosticsByProviderID[providerID] ?? [:]
+ if updatedDiagnostics.isEmpty {
+ providerDiagnostics[standardizedURL] = nil
+ } else {
+ providerDiagnostics[standardizedURL] = updatedDiagnostics
+ }
+ diagnosticsByProviderID[providerID] = providerDiagnostics.isEmpty
+ ? nil
+ : providerDiagnostics
+ rebuildDiagnostics()
+ }
+
+ private func clearDiagnostics(for fileURL: URL) {
+ let standardizedURL = fileURL.standardizedFileURL
+ var didChange = false
+ for providerID in diagnosticsByProviderID.keys.sorted() {
+ guard var providerDiagnostics = diagnosticsByProviderID[providerID],
+ providerDiagnostics.removeValue(forKey: standardizedURL) != nil else { continue }
+ diagnosticsByProviderID[providerID] = providerDiagnostics.isEmpty
+ ? nil
+ : providerDiagnostics
+ didChange = true
+ }
+ if didChange { rebuildDiagnostics() }
+ }
+
+ private func rebuildDiagnostics() {
+ var flattened: [URL: [LanguageServerDiagnostic]] = [:]
+ for providerID in diagnosticsByProviderID.keys.sorted() {
+ for (fileURL, values) in diagnosticsByProviderID[providerID] ?? [:] {
+ flattened[fileURL, default: []].append(contentsOf: values)
+ }
}
+ diagnostics = flattened
}
private func configureDebugCallbacks(
diff --git a/Sources/Lithe/Services/ProjectRuntimeService.swift b/Sources/Lithe/Services/ProjectRuntimeService.swift
index 128c7a090..751f8f65c 100644
--- a/Sources/Lithe/Services/ProjectRuntimeService.swift
+++ b/Sources/Lithe/Services/ProjectRuntimeService.swift
@@ -272,8 +272,7 @@ final class ProjectRuntimeService: ObservableObject {
projectURL: projectURL,
javaHomePath: configuredPath,
javaExecutablePath: nil,
- jdbExecutablePath: nil,
- languageServerExecutablePath: runtimeLocator.javaLanguageServerExecutable()?.path
+ jdbExecutablePath: nil
)
return
}
@@ -289,8 +288,7 @@ final class ProjectRuntimeService: ObservableObject {
projectURL: projectURL,
javaHomePath: nil,
javaExecutablePath: nil,
- jdbExecutablePath: runtimeLocator.systemJDBExecutable()?.path,
- languageServerExecutablePath: runtimeLocator.javaLanguageServerExecutable()?.path
+ jdbExecutablePath: runtimeLocator.systemJDBExecutable()?.path
)
return
}
@@ -306,20 +304,17 @@ final class ProjectRuntimeService: ObservableObject {
projectURL: projectURL,
javaHomePath: javaHome.path,
javaExecutablePath: javaExecutable.path,
- jdbExecutablePath: nil,
- languageServerExecutablePath: runtimeLocator.javaLanguageServerExecutable()?.path
+ jdbExecutablePath: nil
)
return
}
- let languageServer = runtimeLocator.javaLanguageServerExecutable()
javaEnvironmentReport = JavaEnvironmentReport(
- status: languageServer == nil ? .languageServerMissing : .ready,
+ status: .ready,
projectURL: projectURL,
javaHomePath: javaHome.path,
javaExecutablePath: javaExecutable.path,
- jdbExecutablePath: jdbExecutable.path,
- languageServerExecutablePath: languageServer?.path
+ jdbExecutablePath: jdbExecutable.path
)
}
@@ -329,6 +324,15 @@ final class ProjectRuntimeService: ObservableObject {
executableCandidates(command).first?.executableURL
}
+ func executableURL(at path: String) -> URL? {
+ let normalized = (path as NSString)
+ .expandingTildeInPath
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalized.isEmpty else { return nil }
+ let url = URL(fileURLWithPath: normalized).standardizedFileURL
+ return runtimeLocator.isExecutable(at: url) ? url : nil
+ }
+
func mavenExecutable(for project: MavenProject) -> URL? {
mavenExecutable(at: project.rootURL)
}
@@ -411,10 +415,6 @@ final class ProjectRuntimeService: ObservableObject {
return result
}
- func javaLanguageServerExecutable() -> URL? {
- runtimeLocator.javaLanguageServerExecutable()
- }
-
private func normalizedPath(_ path: String) -> String {
((path as NSString).expandingTildeInPath as NSString).standardizingPath
}
diff --git a/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift b/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift
index 4b7a6abfa..a3798ff67 100644
--- a/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift
+++ b/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift
@@ -5,44 +5,62 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime {
let descriptor: LanguageProviderDescriptor
private let runtimeService: ProjectRuntimeService
private let processFactory: () -> any RawProcessSession
- private let launch: StdioLanguageServerLaunch
+ private let languageServerLaunch: LanguageServerLaunchDescriptor?
+ private let languageServerCore: any LspClientCore
+ private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)?
private let debugLaunch: StdioDebugAdapterLaunch?
private let debugSessionFactory: (() -> (any DebugAdapterSession)?)?
- var supportsEditingSession: Bool { true }
- var supportsDebugAdapterSession: Bool { debugLaunch != nil || debugSessionFactory != nil }
+ var supportsLanguageServerSession: Bool {
+ languageServerLaunch != nil
+ }
+
+ var supportsDebugAdapterSession: Bool {
+ debugLaunch != nil || debugSessionFactory != nil
+ }
+
var unavailableToolingMessage: String? {
- let command = debugLaunch?.executableNames.first ?? launch.executableNames.first
- guard let command else { return nil }
+ guard let command = languageServerLaunch?.executableNames.first
+ ?? debugLaunch?.executableNames.first else { return nil }
return runtimeService.missingToolMessage(command)
}
- var declaredLanguageServerFeatures: LanguageServerFeatureSet { .standardEditing }
init(
descriptor: LanguageProviderDescriptor,
runtimeService: ProjectRuntimeService,
processFactory: @escaping () -> any RawProcessSession,
- launch: StdioLanguageServerLaunch,
+ languageServerLaunch: LanguageServerLaunchDescriptor? = nil,
+ languageServerCore: any LspClientCore = RustCoreBridge(),
+ languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil,
debugLaunch: StdioDebugAdapterLaunch? = nil,
debugSessionFactory: (() -> (any DebugAdapterSession)?)? = nil
) {
self.descriptor = descriptor
self.runtimeService = runtimeService
self.processFactory = processFactory
- self.launch = launch
+ self.languageServerLaunch = languageServerLaunch
+ self.languageServerCore = languageServerCore
+ self.languageServerExecutableResolver = languageServerExecutableResolver
self.debugLaunch = debugLaunch
self.debugSessionFactory = debugSessionFactory
}
func makeLanguageServerSession() -> (any LanguageServerSession)? {
- guard let executableURL = launch.executableNames.lazy.compactMap({
- self.runtimeService.executableOnPath($0)
- }).first else { return nil }
+ guard let languageServerLaunch else { return nil }
+ let configuredExecutable = languageServerExecutableResolver?(descriptor)
+ guard let executableURL = configuredExecutable
+ ?? languageServerLaunch.executableNames.lazy.compactMap({
+ self.runtimeService.executableOnPath($0)
+ }).first else { return nil }
+ var environment = runtimeService.processEnvironment()
+ environment.merge(languageServerLaunch.environment) { _, configured in configured }
return StdioLanguageServerSession(
executableURL: executableURL,
- arguments: launch.arguments,
- environment: runtimeService.processEnvironment(),
- process: processFactory()
+ arguments: languageServerLaunch.arguments,
+ environment: environment,
+ initializationOptions: languageServerLaunch.initializationOptions,
+ process: processFactory(),
+ core: languageServerCore
)
}
@@ -67,22 +85,25 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime {
)
}
- /// Builds standard stdio runtimes from language-pack metadata. No
- /// language identifiers or executable maps live in this runtime anymore;
- /// adding a provider means adding its launch definition to its pack.
static func standard(
packs: [LanguagePack],
runtimeService: ProjectRuntimeService,
processFactory: @escaping () -> any RawProcessSession,
+ languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil,
debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:]
) -> [any LanguageProviderRuntime] {
packs.compactMap { pack in
- guard let launch = pack.languageServerLaunch else { return nil }
+ let hasLanguageServer = pack.descriptor.capabilities.contains(.languageServer)
+ && pack.descriptor.languageServerLaunch != nil
+ let hasDebugAdapter = pack.descriptor.capabilities.contains(.debugAdapter)
+ && (pack.debugAdapterLaunch != nil || debugSessionFactories[pack.descriptor.id] != nil)
+ guard hasLanguageServer || hasDebugAdapter else { return nil }
return StdioLanguageProviderRuntime(
descriptor: pack.descriptor,
runtimeService: runtimeService,
processFactory: processFactory,
- launch: launch,
+ languageServerLaunch: pack.descriptor.languageServerLaunch,
+ languageServerExecutableResolver: languageServerExecutableResolver,
debugLaunch: pack.debugAdapterLaunch,
debugSessionFactory: debugSessionFactories[pack.descriptor.id]
)
@@ -93,592 +114,68 @@ final class StdioLanguageProviderRuntime: LanguageProviderRuntime {
catalog: LanguageProviderCatalog,
runtimeService: ProjectRuntimeService,
processFactory: @escaping () -> any RawProcessSession,
+ languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil,
debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:]
) -> [any LanguageProviderRuntime] {
standard(
packs: LanguagePackRegistry.standard(catalog: catalog).packs,
runtimeService: runtimeService,
processFactory: processFactory,
+ languageServerExecutableResolver: languageServerExecutableResolver,
debugSessionFactories: debugSessionFactories
)
}
}
@MainActor
-final class StdioLanguageServerSession: LanguageServerEditingSession, LanguageServerFeatureReportingSession {
- enum SessionError: LocalizedError {
- case launchFailed(String)
- case stopped
- case requestFailed(String)
-
- var errorDescription: String? {
- switch self {
- case .launchFailed(let message): message
- case .stopped: "The language server stopped before answering."
- case .requestFailed(let message): message
- }
- }
- }
-
- private typealias ResponseHandler = (Result) -> Void
-
- private let executableURL: URL
- private let arguments: [String]
- private let environment: [String: String]
- private let process: any RawProcessSession
- private var readBuffer = Data()
- private var initializeRequestID: Int?
- private var nextRequestID = 1
- private var responseHandlers: [Int: ResponseHandler] = [:]
- private var pendingRequests: [(String, [String: Any], ResponseHandler)] = []
- private var initializedFeatures: LanguageServerFeatureSet = .standardEditing
- private var dynamicallyRegisteredFeatures: [String: LanguageServerFeatureSet] = [:]
- private var documentsByURI: [String: PendingDocument] = [:]
- private var openedDocumentVersions: [String: Int] = [:]
- private var lastSentDocumentTextByURI: [String: String] = [:]
- private(set) var isReady = false
- private(set) var supportedFeatures: LanguageServerFeatureSet = .standardEditing
- var onSupportedFeaturesChange: ((LanguageServerFeatureSet) -> Void)?
- var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)?
-
- private struct PendingDocument {
- let url: URL
- let languageIdentifier: String
- let text: String
- }
+final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntimeFactory {
+ private let runtimeService: ProjectRuntimeService
+ private let processFactory: () -> any RawProcessSession
+ private let languageServerCore: any LspClientCore
+ private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)?
+ private let debugLaunches: [String: StdioDebugAdapterLaunch]
+ private let debugSessionFactories: [String: () -> (any DebugAdapterSession)?]
init(
- executableURL: URL,
- arguments: [String],
- environment: [String: String],
- process: any RawProcessSession
- ) {
- self.executableURL = executableURL
- self.arguments = arguments
- self.environment = environment
- self.process = process
- process.onOutput = { [weak self] data in
- Task { @MainActor [weak self] in self?.receive(data) }
- }
- process.onTermination = { [weak self] _ in
- Task { @MainActor [weak self] in
- self?.isReady = false
- self?.initializeRequestID = nil
- self?.failPendingRequests(SessionError.stopped)
- }
- }
- }
-
- var isRunning: Bool { process.isRunning }
-
- func start(rootURL: URL) throws {
- if process.isRunning { return }
- isReady = false
- initializedFeatures = .standardEditing
- dynamicallyRegisteredFeatures = [:]
- publishSupportedFeatures()
- readBuffer = Data()
- openedDocumentVersions = [:]
- let operationID = UUID().uuidString
- do {
- try process.start(ProcessRequest(
- operationID: operationID,
- executablePath: executableURL.path,
- arguments: arguments,
- workingDirectory: rootURL.standardizedFileURL.path,
- environment: environment,
- keepsStandardInputOpen: true
- ))
- let id = nextRequestID
- nextRequestID += 1
- initializeRequestID = id
- send([
- "jsonrpc": "2.0",
- "id": id,
- "method": "initialize",
- "params": [
- "processId": ProcessInfo.processInfo.processIdentifier,
- "clientInfo": ["name": "Lithe", "version": "0.1.0"],
- "rootUri": rootURL.standardizedFileURL.absoluteString,
- "capabilities": [
- "workspace": [
- "workspaceFolders": true,
- "configuration": true,
- "applyEdit": true
- ],
- "textDocument": [
- "synchronization": ["didSave": true],
- "publishDiagnostics": ["relatedInformation": true],
- "definition": ["dynamicRegistration": true, "linkSupport": true],
- "references": ["dynamicRegistration": true],
- "implementation": ["dynamicRegistration": true, "linkSupport": true],
- "hover": ["dynamicRegistration": true, "contentFormat": ["markdown", "plaintext"]],
- "completion": ["dynamicRegistration": true, "completionItem": [
- "documentationFormat": ["markdown", "plaintext"],
- "snippetSupport": true,
- "resolveSupport": ["properties": ["detail", "documentation", "textEdit", "additionalTextEdits"]]
- ]],
- "rename": ["dynamicRegistration": true, "prepareSupport": true],
- "formatting": ["dynamicRegistration": true],
- "codeAction": [
- "dynamicRegistration": true,
- "resolveSupport": ["properties": ["edit", "command"]],
- "codeActionLiteralSupport": [
- "codeActionKind": ["valueSet": ["quickfix", "refactor", "source"]]
- ]
- ]
- ],
- "executeCommand": ["dynamicRegistration": true]
- ],
- "workspaceFolders": [[
- "uri": rootURL.standardizedFileURL.absoluteString,
- "name": rootURL.lastPathComponent
- ]]
- ]
- ])
- } catch {
- process.stop()
- throw SessionError.launchFailed(error.localizedDescription)
- }
- }
-
- func stop() {
- if process.isRunning {
- send(["jsonrpc": "2.0", "method": "exit", "params": [:]])
- }
- process.stop()
- isReady = false
- initializeRequestID = nil
- readBuffer = Data()
- openedDocumentVersions = [:]
- initializedFeatures = .standardEditing
- dynamicallyRegisteredFeatures = [:]
- lastSentDocumentTextByURI = [:]
- documentsByURI = [:]
- failPendingRequests(SessionError.stopped)
- }
-
- func synchronizeDocument(url: URL, languageIdentifier: String, text: String) {
- let normalizedURL = url.standardizedFileURL
- let uri = normalizedURL.absoluteString
- documentsByURI[uri] = PendingDocument(
- url: normalizedURL,
- languageIdentifier: languageIdentifier,
- text: text
- )
- guard isReady else { return }
- synchronize(uri: uri)
- }
-
- func closeDocument(url: URL) {
- let normalizedURL = url.standardizedFileURL
- let uri = normalizedURL.absoluteString
- documentsByURI[uri] = nil
- lastSentDocumentTextByURI[uri] = nil
- if openedDocumentVersions.removeValue(forKey: uri) != nil, isReady {
- send([
- "jsonrpc": "2.0",
- "method": "textDocument/didClose",
- "params": ["textDocument": ["uri": uri]]
- ])
- }
- onDiagnostics?(normalizedURL, [])
- }
-
- func locations(
- method: String,
- documentURL: URL,
- position: LanguageServerPosition,
- completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
- ) {
- var parameters: [String: Any] = [
- "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString],
- "position": ["line": position.line, "character": position.utf16Column]
- ]
- if method == "textDocument/references" {
- parameters["context"] = ["includeDeclaration": true]
- }
- request(method: method, parameters: parameters) { result in
- completion(result.map(LanguageServerResponseParser.locations))
- }
- }
-
- func hover(
- documentURL: URL,
- position: LanguageServerPosition,
- completion: @escaping (Result) -> Void
- ) {
- request(
- method: "textDocument/hover",
- parameters: positionParameters(documentURL: documentURL, position: position)
- ) { result in
- completion(result.map(LanguageServerResponseParser.hover))
- }
- }
-
- func completions(
- documentURL: URL,
- position: LanguageServerPosition,
- completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
- ) {
- var parameters = positionParameters(documentURL: documentURL, position: position)
- parameters["context"] = ["triggerKind": 1]
- request(method: "textDocument/completion", parameters: parameters) { result in
- completion(result.map(LanguageServerResponseParser.completionItems))
- }
- }
-
- func rename(
- documentURL: URL,
- position: LanguageServerPosition,
- newName: String,
- completion: @escaping (Result) -> Void
- ) {
- var parameters = positionParameters(documentURL: documentURL, position: position)
- parameters["newName"] = newName
- request(method: "textDocument/rename", parameters: parameters) { result in
- completion(result.map(LanguageServerResponseParser.workspaceEdit))
- }
- }
-
- func formatting(
- documentURL: URL,
- options: [String: Any],
- completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void
- ) {
- let parameters: [String: Any] = [
- "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString],
- "options": options
- ]
- request(method: "textDocument/formatting", parameters: parameters) { result in
- completion(result.map(LanguageServerResponseParser.textEdits))
- }
- }
-
- func codeActions(
- documentURL: URL,
- range: LanguageServerRange,
- diagnostics: [LanguageServerDiagnostic],
- completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void
- ) {
- let diagnosticValues: [[String: Any]] = diagnostics.map { diagnostic in
- var value: [String: Any] = [
- "range": Self.foundationRange(diagnostic.range),
- "message": diagnostic.message
- ]
- if let severity = diagnostic.severity { value["severity"] = severity }
- if let source = diagnostic.source { value["source"] = source }
- if let code = diagnostic.code { value["code"] = code }
- return value
- }
- let parameters: [String: Any] = [
- "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString],
- "range": Self.foundationRange(range),
- "context": ["diagnostics": diagnosticValues, "only": ["quickfix", "refactor"]]
- ]
- request(method: "textDocument/codeAction", parameters: parameters) { result in
- completion(result.map(LanguageServerResponseParser.codeActions))
- }
- }
-
- func execute(
- command: LanguageServerCommand,
- completion: @escaping (Result) -> Void
- ) {
- request(method: "workspace/executeCommand", parameters: [
- "command": command.command,
- "arguments": command.arguments.map(\.foundationObject)
- ]) { result in
- completion(result.map { _ in () })
- }
- }
-
- func resolveCompletion(
- _ item: LanguageServerCompletionItem,
- completion: @escaping (Result) -> Void
- ) {
- request(
- method: "completionItem/resolve",
- parameters: LanguageServerResponseParser.foundationCompletionItem(item)
- ) { result in
- completion(result.flatMap { value in
- guard let item = LanguageServerResponseParser.completionItem(value) else {
- return .failure(SessionError.requestFailed("The language server returned an invalid completion item."))
- }
- return .success(item)
- })
- }
- }
-
- func resolveCodeAction(
- _ action: LanguageServerCodeAction,
- completion: @escaping (Result) -> Void
- ) {
- request(
- method: "codeAction/resolve",
- parameters: LanguageServerResponseParser.foundationCodeAction(action)
- ) { result in
- completion(result.flatMap { value in
- guard let action = LanguageServerResponseParser.codeAction(value) else {
- return .failure(SessionError.requestFailed("The language server returned an invalid code action."))
- }
- return .success(action)
- })
- }
- }
-
- private func positionParameters(
- documentURL: URL,
- position: LanguageServerPosition
- ) -> [String: Any] {
- [
- "textDocument": ["uri": documentURL.standardizedFileURL.absoluteString],
- "position": ["line": position.line, "character": position.utf16Column]
- ]
- }
-
- private func send(_ message: [String: Any]) {
- guard JSONSerialization.isValidJSONObject(message),
- let body = try? JSONSerialization.data(withJSONObject: message) else { return }
- var framed = Data("Content-Length: \(body.count)\r\n\r\n".utf8)
- framed.append(body)
- try? process.send(framed)
- }
-
- private func receive(_ data: Data) {
- readBuffer.append(data)
- while let headerEnd = readBuffer.range(of: Data("\r\n\r\n".utf8)) {
- let headerData = readBuffer[..= bodyStart + contentLength else { return }
- let body = readBuffer.subdata(in: bodyStart..<(bodyStart + contentLength))
- readBuffer.removeSubrange(0..<(bodyStart + contentLength))
- guard let message = try? JSONSerialization.jsonObject(with: body) as? [String: Any]
- else { continue }
- handle(message)
- }
- }
-
- private func handle(_ message: [String: Any]) {
- if let id = message["id"] as? Int,
- id == initializeRequestID,
- message["method"] == nil {
- initializeRequestID = nil
- guard message["error"] == nil else {
- process.stop()
- isReady = false
- return
- }
- initializedFeatures = LanguageServerResponseParser.serverFeatures(
- fromInitializeResult: message["result"] ?? NSNull()
- )
- dynamicallyRegisteredFeatures = [:]
- publishSupportedFeatures()
- send(["jsonrpc": "2.0", "method": "initialized", "params": [:]])
- isReady = true
- for uri in documentsByURI.keys.sorted() {
- synchronize(uri: uri)
- }
- flushPendingRequests()
- return
- }
- if let id = message["id"] as? Int,
- message["method"] == nil,
- let handler = responseHandlers.removeValue(forKey: id) {
- if let error = message["error"] as? [String: Any] {
- handler(.failure(SessionError.requestFailed(
- error["message"] as? String ?? "The language server request failed."
- )))
- } else {
- handler(.success(message["result"] ?? NSNull()))
- }
- return
- }
- if message["method"] as? String == "textDocument/publishDiagnostics",
- let parameters = message["params"] as? [String: Any] {
- publishDiagnostics(parameters)
- return
- }
- if let method = message["method"] as? String,
- let id = message["id"],
- method == "client/registerCapability" {
- registerCapabilities(message["params"] as? [String: Any])
- send(["jsonrpc": "2.0", "id": id, "result": NSNull()])
- return
- }
- if let method = message["method"] as? String,
- let id = message["id"],
- method == "client/unregisterCapability" {
- unregisterCapabilities(message["params"] as? [String: Any])
- send(["jsonrpc": "2.0", "id": id, "result": NSNull()])
- return
- }
- guard message["method"] != nil, let id = message["id"] else { return }
- send(["jsonrpc": "2.0", "id": id, "result": NSNull()])
- }
-
- private func registerCapabilities(_ parameters: [String: Any]?) {
- let registrations = parameters?["registrations"] as? [[String: Any]] ?? []
- for registration in registrations {
- guard let id = registration["id"] as? String,
- let method = registration["method"] as? String else { continue }
- dynamicallyRegisteredFeatures[id] = LanguageServerResponseParser.registeredFeatures(
- for: method,
- registerOptions: registration["registerOptions"]
- )
- }
- publishSupportedFeatures()
- }
-
- private func unregisterCapabilities(_ parameters: [String: Any]?) {
- let registrations = (parameters?["unregisterations"] as? [[String: Any]])
- ?? (parameters?["unregistrations"] as? [[String: Any]])
- ?? []
- for registration in registrations {
- guard let id = registration["id"] as? String else { continue }
- dynamicallyRegisteredFeatures[id] = nil
- }
- publishSupportedFeatures()
- }
-
- private func publishSupportedFeatures() {
- supportedFeatures = dynamicallyRegisteredFeatures.values.reduce(initializedFeatures) {
- $0.union($1)
- }
- onSupportedFeaturesChange?(supportedFeatures)
- }
-
- private func request(
- method: String,
- parameters: [String: Any],
- completion: @escaping ResponseHandler
- ) {
- guard process.isRunning else {
- completion(.failure(SessionError.stopped))
- return
- }
- guard isReady else {
- pendingRequests.append((method, parameters, completion))
- return
- }
- sendRequest(method: method, parameters: parameters, completion: completion)
- }
-
- private func sendRequest(
- method: String,
- parameters: [String: Any],
- completion: @escaping ResponseHandler
+ runtimeService: ProjectRuntimeService,
+ processFactory: @escaping () -> any RawProcessSession,
+ languageServerCore: any LspClientCore = RustCoreBridge(),
+ languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil,
+ debugLaunches: [String: StdioDebugAdapterLaunch] = [:],
+ debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:]
) {
- let requiredFeature = LanguageServerResponseParser.requiredFeature(forRequestMethod: method)
- guard requiredFeature.isEmpty || supportedFeatures.contains(requiredFeature) else {
- completion(.failure(SessionError.requestFailed(
- "The language server does not support \(method)."
- )))
- return
- }
- let id = nextRequestID
- nextRequestID += 1
- responseHandlers[id] = completion
- send(["jsonrpc": "2.0", "id": id, "method": method, "params": parameters])
- }
-
- private func flushPendingRequests() {
- let requests = pendingRequests
- pendingRequests = []
- for (method, parameters, completion) in requests {
- sendRequest(method: method, parameters: parameters, completion: completion)
- }
- }
-
- private func failPendingRequests(_ error: Error) {
- let handlers = responseHandlers.values
- let queued = pendingRequests.map(\.2)
- responseHandlers = [:]
- pendingRequests = []
- handlers.forEach { $0(.failure(error)) }
- queued.forEach { $0(.failure(error)) }
- }
-
- private func synchronize(uri: String) {
- guard let document = documentsByURI[uri] else { return }
- if let version = openedDocumentVersions[uri] {
- guard lastSentDocumentTextByURI[uri] != document.text else { return }
- let nextVersion = version + 1
- openedDocumentVersions[uri] = nextVersion
- lastSentDocumentTextByURI[uri] = document.text
- send([
- "jsonrpc": "2.0",
- "method": "textDocument/didChange",
- "params": [
- "textDocument": ["uri": uri, "version": nextVersion],
- "contentChanges": [["text": document.text]]
- ]
- ])
- } else {
- openedDocumentVersions[uri] = 1
- lastSentDocumentTextByURI[uri] = document.text
- send([
- "jsonrpc": "2.0",
- "method": "textDocument/didOpen",
- "params": [
- "textDocument": [
- "uri": uri,
- "languageId": document.languageIdentifier,
- "version": 1,
- "text": document.text
- ]
- ]
- ])
- }
- }
-
- private func publishDiagnostics(_ parameters: [String: Any]) {
- guard let uri = parameters["uri"] as? String,
- let url = URL(string: uri) else { return }
- let diagnostics = (parameters["diagnostics"] as? [[String: Any]] ?? []).compactMap {
- Self.parseDiagnostic($0)
- }
- onDiagnostics?(url.standardizedFileURL, diagnostics)
- }
-
- private static func parseDiagnostic(_ value: [String: Any]) -> LanguageServerDiagnostic? {
- guard let range = value["range"] as? [String: Any],
- let start = range["start"] as? [String: Any],
- let end = range["end"] as? [String: Any],
- let startLine = start["line"] as? Int,
- let startColumn = start["character"] as? Int,
- let endLine = end["line"] as? Int,
- let endColumn = end["character"] as? Int,
- let message = value["message"] as? String else { return nil }
- let code = (value["code"] as? String)
- ?? (value["code"] as? Int).map(String.init)
- return LanguageServerDiagnostic(
- range: LanguageServerRange(
- start: LanguageServerPosition(line: startLine, utf16Column: startColumn),
- end: LanguageServerPosition(line: endLine, utf16Column: endColumn)
- ),
- severity: value["severity"] as? Int,
- message: message,
- source: value["source"] as? String,
- code: code
+ self.runtimeService = runtimeService
+ self.processFactory = processFactory
+ self.languageServerCore = languageServerCore
+ self.languageServerExecutableResolver = languageServerExecutableResolver
+ self.debugLaunches = debugLaunches
+ self.debugSessionFactories = debugSessionFactories
+ }
+
+ func makeRuntime(
+ for descriptor: LanguageProviderDescriptor
+ ) -> (any LanguageProviderRuntime)? {
+ let languageServerLaunch = descriptor.capabilities.contains(.languageServer)
+ ? descriptor.languageServerLaunch
+ : nil
+ let debugLaunch = descriptor.capabilities.contains(.debugAdapter)
+ ? debugLaunches[descriptor.id]
+ : nil
+ let debugSessionFactory = descriptor.capabilities.contains(.debugAdapter)
+ ? debugSessionFactories[descriptor.id]
+ : nil
+ guard languageServerLaunch != nil || debugLaunch != nil || debugSessionFactory != nil else {
+ return nil
+ }
+ return StdioLanguageProviderRuntime(
+ descriptor: descriptor,
+ runtimeService: runtimeService,
+ processFactory: processFactory,
+ languageServerLaunch: languageServerLaunch,
+ languageServerCore: languageServerCore,
+ languageServerExecutableResolver: languageServerExecutableResolver,
+ debugLaunch: debugLaunch,
+ debugSessionFactory: debugSessionFactory
)
}
-
- private static func foundationRange(_ range: LanguageServerRange) -> [String: Any] {
- [
- "start": ["line": range.start.line, "character": range.start.utf16Column],
- "end": ["line": range.end.line, "character": range.end.utf16Column]
- ]
- }
-
}
diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/Lithe/Services/StdioLanguageServerSession.swift
new file mode 100644
index 000000000..2ee7564b3
--- /dev/null
+++ b/Sources/Lithe/Services/StdioLanguageServerSession.swift
@@ -0,0 +1,1127 @@
+import Foundation
+
+protocol LspClientCore: Sendable {
+ func lspClientInitialize(rootURL: URL) -> RustCoreBridge.LspClientResponsePayload?
+ func lspClientInitialize(
+ rootURL: URL,
+ initializationOptions: ToolingJSONValue?
+ ) -> RustCoreBridge.LspClientResponsePayload?
+ func lspClientOpenDocument(
+ state: ToolingJSONValue,
+ fileURL: URL,
+ languageID: String,
+ text: String
+ ) -> RustCoreBridge.LspClientResponsePayload?
+ func lspClientChangeDocument(
+ state: ToolingJSONValue,
+ fileURL: URL,
+ text: String
+ ) -> RustCoreBridge.LspClientResponsePayload?
+ func lspClientCloseDocument(
+ state: ToolingJSONValue,
+ fileURL: URL
+ ) -> RustCoreBridge.LspClientResponsePayload?
+ func lspClientShutdown(
+ state: ToolingJSONValue
+ ) -> RustCoreBridge.LspClientResponsePayload?
+ func lspClientRequest(
+ state: ToolingJSONValue,
+ fileURL: URL,
+ method: String,
+ position: LanguageServerPosition?,
+ newName: String?,
+ range: LanguageServerRange?,
+ diagnostics: [LanguageServerDiagnostic],
+ completionItem: LanguageServerCompletionItem?,
+ codeAction: LanguageServerCodeAction?,
+ command: LanguageServerCommand?
+ ) -> RustCoreBridge.LspClientResponsePayload?
+ func lspClientApplyServerMessage(
+ state: ToolingJSONValue,
+ message: String
+ ) -> RustCoreBridge.LspClientResponsePayload?
+ func lspFrameMessage(_ message: String) -> RustCoreBridge.LspFramePayload?
+ func lspParseServerMessages(
+ buffer: [UInt8],
+ chunk: [UInt8]
+ ) -> RustCoreBridge.LspParsedMessagesPayload?
+}
+
+extension RustCoreBridge: LspClientCore {}
+
+protocol LspSessionCore: LspClientCore {
+ func lspSessionCreate(
+ rootURL: URL,
+ initializationOptions: ToolingJSONValue?
+ ) -> RustCoreBridge.LspSessionResponsePayload?
+ func lspSessionOpenDocument(
+ sessionID: String,
+ fileURL: URL,
+ languageID: String,
+ text: String
+ ) -> RustCoreBridge.LspSessionResponsePayload?
+ func lspSessionChangeDocument(
+ sessionID: String,
+ fileURL: URL,
+ text: String
+ ) -> RustCoreBridge.LspSessionResponsePayload?
+ func lspSessionCloseDocument(
+ sessionID: String,
+ fileURL: URL
+ ) -> RustCoreBridge.LspSessionResponsePayload?
+ func lspSessionShutdown(sessionID: String) -> RustCoreBridge.LspSessionResponsePayload?
+ func lspSessionRequest(
+ sessionID: String,
+ fileURL: URL,
+ method: String,
+ position: LanguageServerPosition?,
+ newName: String?,
+ range: LanguageServerRange?,
+ diagnostics: [LanguageServerDiagnostic],
+ completionItem: LanguageServerCompletionItem?,
+ codeAction: LanguageServerCodeAction?,
+ command: LanguageServerCommand?
+ ) -> RustCoreBridge.LspSessionResponsePayload?
+ func lspSessionApplyServerMessage(
+ sessionID: String,
+ message: String
+ ) -> RustCoreBridge.LspSessionResponsePayload?
+ func lspSessionDestroy(sessionID: String)
+}
+
+extension RustCoreBridge: LspSessionCore {}
+
+extension LspClientCore {
+ func lspClientInitialize(
+ rootURL: URL,
+ initializationOptions _: ToolingJSONValue?
+ ) -> RustCoreBridge.LspClientResponsePayload? {
+ lspClientInitialize(rootURL: rootURL)
+ }
+
+ func lspClientCloseDocument(
+ state _: ToolingJSONValue,
+ fileURL _: URL
+ ) -> RustCoreBridge.LspClientResponsePayload? { nil }
+
+ func lspClientShutdown(
+ state _: ToolingJSONValue
+ ) -> RustCoreBridge.LspClientResponsePayload? { nil }
+}
+
+@MainActor
+final class StdioLanguageServerSession: LanguageServerSession {
+ private let executableURL: URL
+ private let arguments: [String]
+ private let environment: [String: String]
+ private let initializationOptions: ToolingJSONValue?
+ private let initializeTimeoutNanoseconds: UInt64
+ private let requestTimeoutNanoseconds: UInt64
+ private let process: any RawProcessSession
+ private let core: any LspClientCore
+ private var legacyState: ToolingJSONValue?
+ private var sessionID: String?
+ private var readBuffer = Data()
+ private var openedDocumentURIs: Set = []
+ private var pendingDocuments: [String: PendingDocument] = [:]
+ private var responseHandlers: [String: PendingResponse] = [:]
+ private var responseTimeoutTasks: [String: Task] = [:]
+ private var isInitialized = false
+ private var isStopping = false
+ private var state: LanguageServerSessionState = .stopped
+ private var initializeTimeoutTask: Task?
+ private var shutdownFallbackTask: Task?
+
+ var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)?
+ var onLog: ((LanguageServerLogLevel, String, String?) -> Void)?
+ var onStateChange: ((LanguageServerSessionState) -> Void)?
+ private(set) var features: LanguageServerFeatureSet = []
+ var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)?
+ private(set) var serverInfo: LanguageServerInfo?
+ var onServerInfoChange: ((LanguageServerInfo?) -> Void)?
+
+ init(
+ executableURL: URL,
+ arguments: [String],
+ environment: [String: String],
+ initializationOptions: ToolingJSONValue? = nil,
+ initializeTimeoutNanoseconds: UInt64 = 10_000_000_000,
+ requestTimeoutNanoseconds: UInt64 = 30_000_000_000,
+ process: any RawProcessSession,
+ core: any LspClientCore = RustCoreBridge()
+ ) {
+ self.executableURL = executableURL
+ self.arguments = arguments
+ self.environment = environment
+ self.initializationOptions = initializationOptions
+ self.initializeTimeoutNanoseconds = initializeTimeoutNanoseconds
+ self.requestTimeoutNanoseconds = requestTimeoutNanoseconds
+ self.process = process
+ self.core = core
+ process.onOutput = { [weak self] data in
+ Task { @MainActor [weak self] in self?.receive(data) }
+ }
+ process.onError = { [weak self] data in
+ Task { @MainActor [weak self] in self?.receiveError(data) }
+ }
+ process.onStateChange = { [weak self] event in
+ Task { @MainActor [weak self] in self?.receiveStateChange(event) }
+ }
+ process.onTermination = { [weak self] exitCode in
+ Task { @MainActor [weak self] in
+ self?.recordTermination(exitCode: exitCode)
+ self?.resetTransientState()
+ }
+ }
+ }
+
+ var isRunning: Bool { process.isRunning }
+
+ func start(rootURL: URL) throws {
+ transition(to: .startingProcess)
+ onLog?(
+ .info,
+ "Starting language server",
+ ([executableURL.path] + arguments).joined(separator: " ")
+ )
+ do {
+ try process.start(ProcessRequest(
+ operationID: UUID().uuidString,
+ executablePath: executableURL.path,
+ arguments: arguments,
+ workingDirectory: rootURL.standardizedFileURL.path,
+ environment: environment,
+ keepsStandardInputOpen: true
+ ))
+ transition(to: .initializing)
+ if let sessionCore = core as? any LspSessionCore {
+ guard let response = sessionCore.lspSessionCreate(
+ rootURL: rootURL,
+ initializationOptions: initializationOptions
+ ) else {
+ throw StdioLanguageServerSessionError.coreRejected("initialize")
+ }
+ sessionID = response.sessionId
+ try apply(response)
+ } else {
+ guard let response = core.lspClientInitialize(
+ rootURL: rootURL,
+ initializationOptions: initializationOptions
+ ) else {
+ throw StdioLanguageServerSessionError.coreRejected("initialize")
+ }
+ try apply(response)
+ }
+ if !isInitialized {
+ scheduleInitializeTimeout()
+ }
+ } catch {
+ failSession(error)
+ throw error
+ }
+ }
+
+ func synchronize(fileURL: URL, text: String, languageID: String) throws {
+ guard sessionID != nil || legacyState != nil else {
+ throw StdioLanguageServerSessionError.notReady
+ }
+ let standardizedURL = fileURL.standardizedFileURL
+ let uri = standardizedURL.absoluteString
+ guard isInitialized else {
+ pendingDocuments[uri] = PendingDocument(
+ fileURL: standardizedURL,
+ text: text,
+ languageID: languageID
+ )
+ return
+ }
+ if let sessionCore = core as? any LspSessionCore,
+ let sessionID {
+ let wasOpen = openedDocumentURIs.contains(uri)
+ let response = wasOpen
+ ? sessionCore.lspSessionChangeDocument(
+ sessionID: sessionID,
+ fileURL: standardizedURL,
+ text: text
+ )
+ : sessionCore.lspSessionOpenDocument(
+ sessionID: sessionID,
+ fileURL: standardizedURL,
+ languageID: languageID,
+ text: text
+ )
+ guard let response else {
+ let error = StdioLanguageServerSessionError.coreRejected(
+ wasOpen ? "textDocument/didChange" : "textDocument/didOpen"
+ )
+ failSession(error)
+ throw error
+ }
+ do {
+ try apply(response)
+ if !wasOpen { openedDocumentURIs.insert(uri) }
+ } catch {
+ failSession(error)
+ throw error
+ }
+ } else if let legacyState {
+ let response: RustCoreBridge.LspClientResponsePayload?
+ let wasOpen = openedDocumentURIs.contains(uri)
+ if wasOpen {
+ response = core.lspClientChangeDocument(
+ state: legacyState,
+ fileURL: standardizedURL,
+ text: text
+ )
+ } else {
+ response = core.lspClientOpenDocument(
+ state: legacyState,
+ fileURL: standardizedURL,
+ languageID: languageID,
+ text: text
+ )
+ }
+ guard let response else {
+ let error = StdioLanguageServerSessionError.coreRejected(
+ wasOpen ? "textDocument/didChange" : "textDocument/didOpen"
+ )
+ failSession(error)
+ throw error
+ }
+ do {
+ try apply(response)
+ if !wasOpen { openedDocumentURIs.insert(uri) }
+ } catch {
+ failSession(error)
+ throw error
+ }
+ }
+ }
+
+ func closeDocument(_ fileURL: URL) {
+ let standardizedURL = fileURL.standardizedFileURL
+ let uri = standardizedURL.absoluteString
+ pendingDocuments[uri] = nil
+ guard openedDocumentURIs.contains(uri) else { return }
+ if let sessionCore = core as? any LspSessionCore,
+ let sessionID {
+ guard let response = sessionCore.lspSessionCloseDocument(
+ sessionID: sessionID,
+ fileURL: standardizedURL
+ ) else {
+ failSession(StdioLanguageServerSessionError.coreRejected("textDocument/didClose"))
+ return
+ }
+ do {
+ try apply(response)
+ openedDocumentURIs.remove(uri)
+ } catch {
+ failSession(error)
+ }
+ } else if let legacyState {
+ guard let response = core.lspClientCloseDocument(
+ state: legacyState,
+ fileURL: standardizedURL
+ ) else {
+ failSession(StdioLanguageServerSessionError.coreRejected("textDocument/didClose"))
+ return
+ }
+ do {
+ try apply(response)
+ openedDocumentURIs.remove(uri)
+ } catch {
+ failSession(error)
+ }
+ }
+ }
+
+ func completions(
+ fileURL: URL,
+ position: LanguageServerPosition,
+ completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
+ ) throws {
+ try requestFeature(
+ method: "textDocument/completion",
+ fileURL: fileURL,
+ position: position
+ ) { result in
+ completion(result.flatMap {
+ Self.decodeEventResult($0, as: RustCoreBridge.BuiltinCompletionPayload.self)
+ }.map { $0.makeModels() })
+ }
+ }
+
+ func hover(
+ fileURL: URL,
+ position: LanguageServerPosition,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ try requestFeature(
+ method: "textDocument/hover",
+ fileURL: fileURL,
+ position: position
+ ) { result in
+ completion(result.flatMap {
+ Self.decodeEventResult($0, as: RustCoreBridge.BuiltinHoverPayload.self)
+ }.map { $0.hover?.makeModel() })
+ }
+ }
+
+ func navigate(
+ method: String,
+ fileURL: URL,
+ position: LanguageServerPosition,
+ completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
+ ) throws {
+ try requestFeature(method: method, fileURL: fileURL, position: position) { result in
+ completion(result.flatMap {
+ Self.decodeEventResult($0, as: RustCoreBridge.BuiltinNavigationPayload.self)
+ }.map { $0.makeModels() })
+ }
+ }
+
+ func rename(
+ fileURL: URL,
+ position: LanguageServerPosition,
+ newName: String,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ try requestFeature(
+ method: "textDocument/rename",
+ fileURL: fileURL,
+ position: position,
+ newName: newName
+ ) { result in
+ completion(result.flatMap {
+ Self.decodeEventResult($0, as: RustCoreBridge.LspWorkspaceEditPayload.self)
+ }.map { $0.makeModel() })
+ }
+ }
+
+ func format(
+ fileURL: URL,
+ completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void
+ ) throws {
+ try requestFeature(
+ method: "textDocument/formatting",
+ fileURL: fileURL,
+ position: nil
+ ) { result in
+ completion(result.flatMap {
+ Self.decodeEventResult($0, as: RustCoreBridge.LspFormattingPayload.self)
+ }.map { $0.makeModels() })
+ }
+ }
+
+ func codeActions(
+ fileURL: URL,
+ range: LanguageServerRange,
+ diagnostics: [LanguageServerDiagnostic],
+ completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> Void
+ ) throws {
+ try requestFeature(
+ method: "textDocument/codeAction",
+ fileURL: fileURL,
+ position: nil,
+ range: range,
+ diagnostics: diagnostics
+ ) { result in
+ completion(result.flatMap {
+ Self.decodeEventResult($0, as: RustCoreBridge.LspCodeActionsPayload.self)
+ }.map { $0.makeModels() })
+ }
+ }
+
+ func resolveCompletion(
+ _ item: LanguageServerCompletionItem,
+ fileURL: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ try requestFeature(
+ method: "completionItem/resolve",
+ fileURL: fileURL,
+ position: nil,
+ completionItem: item
+ ) { result in
+ completion(result.flatMap {
+ Self.decodeEventResult($0, as: RustCoreBridge.LspCompletionResolvePayload.self)
+ }.map { $0.makeModel() })
+ }
+ }
+
+ func resolveCodeAction(
+ _ action: LanguageServerCodeAction,
+ fileURL: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ try requestFeature(
+ method: "codeAction/resolve",
+ fileURL: fileURL,
+ position: nil,
+ codeAction: action
+ ) { result in
+ completion(result.flatMap {
+ Self.decodeEventResult($0, as: RustCoreBridge.LspCodeActionResolvePayload.self)
+ }.map { $0.makeModel() })
+ }
+ }
+
+ func execute(
+ _ command: LanguageServerCommand,
+ fileURL: URL,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ try requestFeature(
+ method: "workspace/executeCommand",
+ fileURL: fileURL,
+ position: nil,
+ command: command
+ ) { result in
+ switch result {
+ case .success(let event):
+ if let error = event.error {
+ completion(.failure(StdioLanguageServerSessionError.serverError(error)))
+ } else {
+ completion(.success(()))
+ }
+ case .failure(let error):
+ completion(.failure(error))
+ }
+ }
+ }
+
+ func stop() {
+ guard process.isRunning else {
+ failPendingRequests(with: StdioLanguageServerSessionError.sessionStopped)
+ resetTransientState()
+ transition(to: .stopped)
+ return
+ }
+ guard !isStopping, isInitialized else {
+ forceStop(pendingError: StdioLanguageServerSessionError.sessionStopped)
+ return
+ }
+ failPendingRequests(with: StdioLanguageServerSessionError.sessionStopped)
+ isStopping = true
+ transition(to: .stopping)
+ do {
+ if let sessionCore = core as? any LspSessionCore,
+ let sessionID,
+ let response = sessionCore.lspSessionShutdown(sessionID: sessionID) {
+ try apply(response)
+ } else if let legacyState,
+ let response = core.lspClientShutdown(state: legacyState) {
+ try apply(response)
+ } else {
+ forceStop(pendingError: StdioLanguageServerSessionError.sessionStopped)
+ return
+ }
+ } catch {
+ failSession(error)
+ return
+ }
+ shutdownFallbackTask?.cancel()
+ // The task intentionally retains the session after its manager removes it.
+ shutdownFallbackTask = Task { @MainActor [self] in
+ do {
+ try await Task.sleep(nanoseconds: 1_000_000_000)
+ } catch {
+ return
+ }
+ forceStop(pendingError: StdioLanguageServerSessionError.sessionStopped)
+ }
+ }
+
+ private func apply(_ response: RustCoreBridge.LspClientResponsePayload) throws {
+ try validateInitializeEvents(response.events)
+ for message in response.messages {
+ try sendRawJSON(message)
+ }
+ legacyState = response.state
+ updateFeatures(from: response.state)
+ try handle(response.events)
+ }
+
+ private func apply(_ response: RustCoreBridge.LspSessionResponsePayload) throws {
+ try validateInitializeEvents(response.events)
+ for message in response.messages {
+ try sendRawJSON(message)
+ }
+ updateFeatures(capabilityNames: response.serverCapabilities)
+ try handle(response.events)
+ }
+
+ private func handle(_ events: [RustCoreBridge.LspClientEventPayload]) throws {
+ for event in events {
+ if let requestID = event.requestId,
+ let pending = responseHandlers.removeValue(forKey: requestID) {
+ responseTimeoutTasks.removeValue(forKey: requestID)?.cancel()
+ pending.completion(.success(event))
+ }
+ if event.method == "initialize" {
+ try handleInitialize(event)
+ }
+ if event.method == "shutdown" {
+ forceStop(pendingError: StdioLanguageServerSessionError.sessionStopped)
+ return
+ }
+ if event.kind == "diagnostics",
+ let uri = event.uri,
+ let url = URL(string: uri),
+ let diagnostics = event.diagnostics {
+ onDiagnostics?(url.standardizedFileURL, diagnostics.map { $0.makeModel() })
+ }
+ }
+ }
+
+ private func requestFeature(
+ method: String,
+ fileURL: URL,
+ position: LanguageServerPosition?,
+ newName: String? = nil,
+ range: LanguageServerRange? = nil,
+ diagnostics: [LanguageServerDiagnostic] = [],
+ completionItem: LanguageServerCompletionItem? = nil,
+ codeAction: LanguageServerCodeAction? = nil,
+ command: LanguageServerCommand? = nil,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ guard isInitialized else {
+ throw StdioLanguageServerSessionError.notReady
+ }
+ guard openedDocumentURIs.contains(fileURL.standardizedFileURL.absoluteString) else {
+ throw StdioLanguageServerSessionError.documentNotOpen
+ }
+ let messages: [String]
+ let events: [RustCoreBridge.LspClientEventPayload]
+ var updatedLegacyState: ToolingJSONValue?
+ var updatedCapabilityNames: [String]?
+ if let sessionCore = core as? any LspSessionCore,
+ let sessionID,
+ let response = sessionCore.lspSessionRequest(
+ sessionID: sessionID,
+ fileURL: fileURL,
+ method: method,
+ position: position,
+ newName: newName,
+ range: range,
+ diagnostics: diagnostics,
+ completionItem: completionItem,
+ codeAction: codeAction,
+ command: command
+ ) {
+ updatedCapabilityNames = response.serverCapabilities
+ messages = response.messages
+ events = response.events
+ } else if let legacyState,
+ let response = core.lspClientRequest(
+ state: legacyState,
+ fileURL: fileURL,
+ method: method,
+ position: position,
+ newName: newName,
+ range: range,
+ diagnostics: diagnostics,
+ completionItem: completionItem,
+ codeAction: codeAction,
+ command: command
+ ) {
+ updatedLegacyState = response.state
+ messages = response.messages
+ events = response.events
+ } else {
+ throw StdioLanguageServerSessionError.requestRejected
+ }
+ guard let requestID = messages.lazy.compactMap({ Self.requestID(from: $0) }).first else {
+ throw StdioLanguageServerSessionError.missingRequestID
+ }
+ responseHandlers[requestID] = PendingResponse(
+ method: method,
+ fileURL: fileURL.standardizedFileURL,
+ completion: completion
+ )
+ do {
+ for message in messages {
+ try sendRawJSON(message)
+ }
+ if let updatedLegacyState {
+ legacyState = updatedLegacyState
+ updateFeatures(from: updatedLegacyState)
+ } else if let updatedCapabilityNames {
+ updateFeatures(capabilityNames: updatedCapabilityNames)
+ }
+ try handle(events)
+ } catch {
+ responseHandlers[requestID] = nil
+ responseTimeoutTasks.removeValue(forKey: requestID)?.cancel()
+ failSession(error)
+ throw error
+ }
+ if responseHandlers[requestID] != nil {
+ scheduleRequestTimeout(requestID: requestID)
+ }
+ }
+
+ private func flushPendingDocuments() throws {
+ let documents = pendingDocuments.sorted { $0.key < $1.key }
+ for (uri, document) in documents {
+ try synchronize(
+ fileURL: document.fileURL,
+ text: document.text,
+ languageID: document.languageID
+ )
+ pendingDocuments[uri] = nil
+ }
+ }
+
+ private func sendRawJSON(_ message: String) throws {
+ guard process.isRunning else {
+ throw StdioLanguageServerSessionError.transportFailure("Language server process is not running.")
+ }
+ if let frame = core.lspFrameMessage(message)?.frame,
+ let data = frame.data(using: .utf8) {
+ do {
+ try process.send(data)
+ } catch {
+ throw StdioLanguageServerSessionError.transportFailure(error.localizedDescription)
+ }
+ return
+ }
+ guard let body = message.data(using: .utf8) else {
+ throw StdioLanguageServerSessionError.transportFailure("Could not encode LSP message as UTF-8.")
+ }
+ var fallback = Data("Content-Length: \(body.count)\r\n\r\n".utf8)
+ fallback.append(body)
+ do {
+ try process.send(fallback)
+ } catch {
+ throw StdioLanguageServerSessionError.transportFailure(error.localizedDescription)
+ }
+ }
+
+ private func receive(_ data: Data) {
+ guard !isFailed else { return }
+ receiveServerData(data)
+ }
+
+ private func receiveServerData(_ data: Data) {
+ guard let parsed = core.lspParseServerMessages(
+ buffer: Array(readBuffer),
+ chunk: Array(data)
+ ) else {
+ failSession(StdioLanguageServerSessionError.protocolFailure(
+ "Rust core could not parse the language server output."
+ ))
+ return
+ }
+ readBuffer = Data(parsed.buffer)
+ do {
+ for message in parsed.messages {
+ if let sessionCore = core as? any LspSessionCore,
+ let sessionID {
+ guard let response = sessionCore.lspSessionApplyServerMessage(
+ sessionID: sessionID,
+ message: message
+ ) else {
+ throw StdioLanguageServerSessionError.coreRejected("server message")
+ }
+ try apply(response)
+ } else if let legacyState {
+ guard let response = core.lspClientApplyServerMessage(
+ state: legacyState,
+ message: message
+ ) else {
+ throw StdioLanguageServerSessionError.coreRejected("server message")
+ }
+ try apply(response)
+ } else {
+ throw StdioLanguageServerSessionError.notReady
+ }
+ }
+ } catch {
+ failSession(error)
+ }
+ }
+
+ private func receiveError(_ data: Data) {
+ let raw = String(decoding: data, as: UTF8.self)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !raw.isEmpty else { return }
+ onLog?(.warning, "Language server stderr", raw)
+ }
+
+ private func receiveStateChange(_ event: ProcessLifecycleEvent) {
+ switch event.state {
+ case .starting:
+ if state == .stopped { transition(to: .startingProcess) }
+ onLog?(.info, "Language server process is starting", nil)
+ case .running:
+ if state == .startingProcess { transition(to: .initializing) }
+ onLog?(.info, "Language server process is running", nil)
+ case .stopping:
+ if !isFailed { transition(to: .stopping) }
+ onLog?(.info, "Language server process is stopping", event.message)
+ case .finished:
+ onLog?(
+ isStopping ? .info : .warning,
+ "Language server process finished",
+ event.message ?? exitCodeDetail(event.exitCode)
+ )
+ if let exitCode = event.exitCode {
+ recordTermination(exitCode: exitCode)
+ }
+ case .failed:
+ failSession(
+ StdioLanguageServerSessionError.transportFailure(
+ event.message ?? "Language server process failed to start."
+ ),
+ exitCode: event.exitCode,
+ stopProcess: false
+ )
+ }
+ }
+
+ private func recordTermination(exitCode: Int32) {
+ guard !isFailed, state != .stopped else { return }
+ if isStopping {
+ failPendingRequests(with: StdioLanguageServerSessionError.sessionStopped)
+ resetTransientState()
+ transition(to: .stopped)
+ onLog?(.info, "Language server terminated", exitCodeDetail(exitCode))
+ } else {
+ failSession(
+ StdioLanguageServerSessionError.sessionTerminated(exitCode),
+ exitCode: exitCode,
+ stopProcess: false
+ )
+ }
+ }
+
+ private func exitCodeDetail(_ exitCode: Int32?) -> String? {
+ guard let exitCode else { return nil }
+ return "exit code \(exitCode)"
+ }
+
+ private func resetTransientState() {
+ initializeTimeoutTask?.cancel()
+ initializeTimeoutTask = nil
+ shutdownFallbackTask?.cancel()
+ shutdownFallbackTask = nil
+ responseTimeoutTasks.values.forEach { $0.cancel() }
+ responseTimeoutTasks = [:]
+ if !responseHandlers.isEmpty {
+ failPendingRequests(with: StdioLanguageServerSessionError.sessionStopped)
+ }
+ if let sessionCore = core as? any LspSessionCore,
+ let sessionID {
+ sessionCore.lspSessionDestroy(sessionID: sessionID)
+ }
+ sessionID = nil
+ legacyState = nil
+ readBuffer = Data()
+ openedDocumentURIs = []
+ pendingDocuments = [:]
+ isInitialized = false
+ isStopping = false
+ if !features.isEmpty {
+ features = []
+ onFeaturesChange?([])
+ }
+ if serverInfo != nil {
+ serverInfo = nil
+ onServerInfoChange?(nil)
+ }
+ }
+
+ private func forceStop(pendingError: Error) {
+ shutdownFallbackTask?.cancel()
+ shutdownFallbackTask = nil
+ failPendingRequests(with: pendingError)
+ isStopping = true
+ if !isFailed { transition(to: .stopping) }
+ process.stop()
+ resetTransientState()
+ if !isFailed { transition(to: .stopped) }
+ }
+
+ private var isFailed: Bool {
+ if case .failed = state { return true }
+ return false
+ }
+
+ private func transition(to updatedState: LanguageServerSessionState) {
+ guard state != updatedState else { return }
+ state = updatedState
+ onStateChange?(updatedState)
+ }
+
+ private func failSession(
+ _ error: Error,
+ exitCode: Int32? = nil,
+ stopProcess: Bool = true
+ ) {
+ guard !isFailed else { return }
+ initializeTimeoutTask?.cancel()
+ initializeTimeoutTask = nil
+ failPendingRequests(with: error)
+ let message = error.localizedDescription
+ transition(to: .failed(exitCode: exitCode, message: message))
+ onLog?(.error, "Language server session failed", message)
+ if stopProcess, process.isRunning {
+ process.stop()
+ }
+ resetTransientState()
+ }
+
+ private func failPendingRequests(with error: Error) {
+ let pending = responseHandlers
+ responseHandlers = [:]
+ let timeoutTasks = responseTimeoutTasks.values
+ responseTimeoutTasks = [:]
+ timeoutTasks.forEach { $0.cancel() }
+ for response in pending.values {
+ response.completion(.failure(error))
+ }
+ }
+
+ private func handleInitialize(_ event: RustCoreBridge.LspClientEventPayload) throws {
+ let result = try validatedInitializeResult(event)
+ initializeTimeoutTask?.cancel()
+ initializeTimeoutTask = nil
+ isInitialized = true
+ let initializedServerInfo = Self.serverInfo(from: result["serverInfo"])
+ transition(to: .ready)
+ if initializedServerInfo != serverInfo {
+ serverInfo = initializedServerInfo
+ onServerInfoChange?(initializedServerInfo)
+ }
+ onLog?(.info, "Language server is ready", initializedServerInfo?.name)
+ try flushPendingDocuments()
+ }
+
+ private func validateInitializeEvents(
+ _ events: [RustCoreBridge.LspClientEventPayload]
+ ) throws {
+ for event in events where event.method == "initialize" {
+ _ = try validatedInitializeResult(event)
+ }
+ }
+
+ private func validatedInitializeResult(
+ _ event: RustCoreBridge.LspClientEventPayload
+ ) throws -> [String: ToolingJSONValue] {
+ if event.kind == "error" || event.error != nil {
+ throw StdioLanguageServerSessionError.initializeFailed(
+ event.error ?? "Language server rejected initialize."
+ )
+ }
+ guard event.kind == "response",
+ case .object(let result)? = event.result,
+ case .object = result["capabilities"] else {
+ throw StdioLanguageServerSessionError.invalidInitializeResult
+ }
+ return result
+ }
+
+ private func scheduleInitializeTimeout() {
+ initializeTimeoutTask?.cancel()
+ let timeout = initializeTimeoutNanoseconds
+ initializeTimeoutTask = Task { @MainActor [weak self] in
+ do {
+ try await Task.sleep(nanoseconds: timeout)
+ } catch {
+ return
+ }
+ guard let self, !self.isInitialized, !self.isStopping, !self.isFailed else { return }
+ self.failSession(StdioLanguageServerSessionError.initializeTimedOut)
+ }
+ }
+
+ private func scheduleRequestTimeout(requestID: String) {
+ responseTimeoutTasks[requestID]?.cancel()
+ let timeout = requestTimeoutNanoseconds
+ responseTimeoutTasks[requestID] = Task { @MainActor [weak self] in
+ do {
+ try await Task.sleep(nanoseconds: timeout)
+ } catch {
+ return
+ }
+ guard let self,
+ let pending = self.responseHandlers.removeValue(forKey: requestID) else { return }
+ self.responseTimeoutTasks[requestID] = nil
+ let timeoutError = StdioLanguageServerSessionError.requestTimedOut(
+ method: pending.method,
+ fileURL: pending.fileURL
+ )
+ var transportError: Error?
+ do {
+ try self.sendCancellation(requestID: requestID)
+ } catch {
+ transportError = error
+ }
+ pending.completion(.failure(timeoutError))
+ if let transportError {
+ self.failSession(transportError)
+ }
+ }
+ }
+
+ private func sendCancellation(requestID: String) throws {
+ let object: [String: Any] = [
+ "jsonrpc": "2.0",
+ "method": "$/cancelRequest",
+ "params": ["id": requestID]
+ ]
+ let data: Data
+ do {
+ data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
+ } catch {
+ throw StdioLanguageServerSessionError.protocolFailure(
+ "Could not encode cancellation for LSP request \(requestID)."
+ )
+ }
+ guard let message = String(data: data, encoding: .utf8) else {
+ throw StdioLanguageServerSessionError.protocolFailure(
+ "Could not encode cancellation for LSP request \(requestID)."
+ )
+ }
+ try sendRawJSON(message)
+ }
+
+ private static func serverInfo(from value: ToolingJSONValue?) -> LanguageServerInfo? {
+ guard case .object(let object)? = value,
+ case .string(let name)? = object["name"],
+ !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
+ let version: String?
+ if case .string(let value)? = object["version"], !value.isEmpty {
+ version = value
+ } else {
+ version = nil
+ }
+ return LanguageServerInfo(name: name, version: version)
+ }
+
+ private func updateFeatures(from state: ToolingJSONValue) {
+ guard case .object(let object) = state,
+ case .array(let capabilityValues)? = object["serverCapabilities"] else { return }
+ let names = capabilityValues.compactMap { value -> String? in
+ guard case .string(let name) = value else { return nil }
+ return name
+ }
+ updateFeatures(capabilityNames: names)
+ }
+
+ private func updateFeatures(capabilityNames names: [String]) {
+ let updated = names.reduce(into: LanguageServerFeatureSet()) { result, name in
+ switch name {
+ case "definition": result.insert(.definition)
+ case "references": result.insert(.references)
+ case "implementation": result.insert(.implementation)
+ case "hover": result.insert(.hover)
+ case "completion": result.insert(.completion)
+ case "rename": result.insert(.rename)
+ case "formatting": result.insert(.formatting)
+ case "codeActions": result.insert(.codeActions)
+ case "completionResolve": result.insert(.completionResolve)
+ case "codeActionResolve": result.insert(.codeActionResolve)
+ case "executeCommand": result.insert(.executeCommand)
+ default: break
+ }
+ }
+ guard updated != features else { return }
+ features = updated
+ onFeaturesChange?(updated)
+ }
+
+ private static func requestID(from message: String) -> String? {
+ guard let data = message.data(using: .utf8),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let id = object["id"] else { return nil }
+ if let string = id as? String { return string }
+ if let number = id as? NSNumber { return number.stringValue }
+ return nil
+ }
+
+ private static func decodeEventResult(
+ _ event: RustCoreBridge.LspClientEventPayload,
+ as _: Payload.Type
+ ) -> Result {
+ if let error = event.error {
+ return .failure(StdioLanguageServerSessionError.serverError(error))
+ }
+ guard let result = event.result else {
+ return .failure(StdioLanguageServerSessionError.missingResult)
+ }
+ do {
+ let data = try JSONSerialization.data(withJSONObject: result.foundationObject)
+ return .success(try JSONDecoder().decode(Payload.self, from: data))
+ } catch {
+ return .failure(error)
+ }
+ }
+
+ private struct PendingDocument {
+ let fileURL: URL
+ let text: String
+ let languageID: String
+ }
+
+ private struct PendingResponse {
+ let method: String
+ let fileURL: URL
+ let completion: (Result) -> Void
+ }
+
+ private enum StdioLanguageServerSessionError: LocalizedError {
+ case notReady
+ case documentNotOpen
+ case requestRejected
+ case missingRequestID
+ case missingResult
+ case coreRejected(String)
+ case initializeFailed(String)
+ case invalidInitializeResult
+ case initializeTimedOut
+ case transportFailure(String)
+ case protocolFailure(String)
+ case requestTimedOut(method: String, fileURL: URL)
+ case sessionTerminated(Int32)
+ case sessionStopped
+ case serverError(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .notReady:
+ "Language server is not ready."
+ case .documentNotOpen:
+ "Document is not open in the language server."
+ case .requestRejected:
+ "Language server request was rejected by Rust core."
+ case .missingRequestID:
+ "Language server request did not include a JSON-RPC request ID."
+ case .missingResult:
+ "Language server response did not include a result."
+ case .coreRejected(let operation):
+ "Rust core rejected the LSP \(operation) operation."
+ case .initializeFailed(let message):
+ "Language server initialize failed: \(message)"
+ case .invalidInitializeResult:
+ "Language server initialize returned an invalid result."
+ case .initializeTimedOut:
+ "Language server initialize timed out."
+ case .transportFailure(let message):
+ "Language server transport failed: \(message)"
+ case .protocolFailure(let message):
+ "Language server protocol failed: \(message)"
+ case .requestTimedOut(let method, let fileURL):
+ "Language server request \(method) timed out for \(fileURL.lastPathComponent)."
+ case .sessionTerminated(let exitCode):
+ "Language server terminated unexpectedly with exit code \(exitCode)."
+ case .sessionStopped:
+ "Language server session stopped before the request completed."
+ case .serverError(let message):
+ message
+ }
+ }
+ }
+}
diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/CodeEditorView.swift
index 22ec01e5d..66ff3e2d5 100644
--- a/Sources/Lithe/Views/CodeEditorView.swift
+++ b/Sources/Lithe/Views/CodeEditorView.swift
@@ -219,7 +219,6 @@ struct CodeEditorView: NSViewRepresentable {
var appliedNavigationTargetID: UUID?
var foldRegions: [JavaFoldRegion] = []
var collapsedFoldIDs: Set = []
- private var implementationValidationTask: Task?
private var markdownImagePasteMonitor: Any?
private weak var markdownScrollView: NSScrollView?
private var markdownScrollObserver: NSObjectProtocol?
@@ -469,8 +468,17 @@ struct CodeEditorView: NSViewRepresentable {
collapsedIDs: collapsedFoldIDs,
onToggle: { [weak self] region in self?.toggleFold(region) }
)
- implementationValidationTask?.cancel()
- gutter?.updateImplementationMarkers([]) { [weak model, weak document] marker in
+ // `java.structure` is an explicit local editor fallback. It does
+ // not validate markers or own any language-server lifecycle.
+ let markers: [JavaImplementationMarker]
+ if let document,
+ fileExtension.lowercased() == "java",
+ let model {
+ markers = model.javaStructure(source: document.text)?.implementationMarkers ?? []
+ } else {
+ markers = []
+ }
+ gutter?.updateImplementationMarkers(markers) { [weak model, weak document] marker in
guard let document else { return }
model?.findJavaImplementations(
line: marker.line,
@@ -478,30 +486,6 @@ struct CodeEditorView: NSViewRepresentable {
in: document.url
)
}
- guard let document,
- fileExtension.lowercased() == "java",
- let model else { return }
- let candidates = model.javaStructure(source: document.text)?.implementationMarkers ?? []
- guard !candidates.isEmpty else { return }
- implementationValidationTask = Task { @MainActor [weak self, weak document, weak model] in
- guard let self,
- let document,
- let model else { return }
- let markers = await model.implementationMarkers(
- for: document,
- candidates: candidates
- )
- guard !Task.isCancelled,
- self.document?.id == document.id else { return }
- self.gutter?.updateImplementationMarkers(markers) { [weak model, weak document] marker in
- guard let document else { return }
- model?.findJavaImplementations(
- line: marker.line,
- utf16Column: marker.utf16Column,
- in: document.url
- )
- }
- }
}
func updateCodeVisionAndBlame() {
@@ -695,7 +679,7 @@ final class CodeTextView: NSTextView, @preconcurrency NSLayoutManagerDelegate {
private var foldRegions: [JavaFoldRegion] = []
private var collapsedFoldIDs: Set = []
private var onToggleFold: ((JavaFoldRegion) -> Void)?
- private var diagnostics: [JavaDiagnostic] = []
+ private var diagnostics: [EditorDiagnostic] = []
private var fadedCodeRanges: [NSRange] = []
private var linkRange: NSRange?
private var trackingArea: NSTrackingArea?
@@ -746,7 +730,7 @@ final class CodeTextView: NSTextView, @preconcurrency NSLayoutManagerDelegate {
lineIndex.lineRange(forLine: line)
}
- func updateDiagnostics(_ diagnostics: [JavaDiagnostic]) {
+ func updateDiagnostics(_ diagnostics: [EditorDiagnostic]) {
self.diagnostics = diagnostics
updateEditorDecorations()
}
@@ -1467,7 +1451,7 @@ final class CodeTextView: NSTextView, @preconcurrency NSLayoutManagerDelegate {
return true
}
- private func diagnosticRange(for diagnostic: JavaDiagnostic, in source: NSString) -> NSRange? {
+ private func diagnosticRange(for diagnostic: EditorDiagnostic, in source: NSString) -> NSRange? {
guard source.length > 0 else { return nil }
let lastLine = max(0, lineIndex.lineCount - 1)
let startLine = min(max(0, diagnostic.line), lastLine)
@@ -1489,8 +1473,9 @@ final class CodeTextView: NSTextView, @preconcurrency NSLayoutManagerDelegate {
lineIndex.lineCount
}
- private func diagnosticColor(for severity: JavaDiagnosticSeverity) -> NSColor {
+ private func diagnosticColor(for severity: DiagnosticSeverity) -> NSColor {
switch severity {
+ case .unknown: NSColor.systemGray
case .error: NSColor.systemRed
case .warning: NSColor.systemOrange
case .information: NSColor.systemBlue
diff --git a/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift b/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift
index b3e37776a..e4d3ba979 100644
--- a/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift
+++ b/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift
@@ -8,21 +8,27 @@ import SwiftUI
/// no visible content of its own.
struct LitheScrollViewChrome: NSViewRepresentable {
var hideHorizontal = false
+ var alwaysShowVertical = false
func makeNSView(context: Context) -> ScrollViewProbe {
- ScrollViewProbe(hideHorizontal: hideHorizontal)
+ ScrollViewProbe(hideHorizontal: hideHorizontal, alwaysShowVertical: alwaysShowVertical)
}
func updateNSView(_ nsView: ScrollViewProbe, context: Context) {
nsView.hideHorizontal = hideHorizontal
+ nsView.alwaysShowVertical = alwaysShowVertical
nsView.configureEnclosingScrollView()
}
final class ScrollViewProbe: NSView {
var hideHorizontal: Bool
+ var alwaysShowVertical: Bool
+ private weak var configuredScrollView: NSScrollView?
+ private var scrollWheelMonitor: Any?
- init(hideHorizontal: Bool) {
+ init(hideHorizontal: Bool, alwaysShowVertical: Bool) {
self.hideHorizontal = hideHorizontal
+ self.alwaysShowVertical = alwaysShowVertical
super.init(frame: .zero)
}
@@ -33,6 +39,9 @@ struct LitheScrollViewChrome: NSViewRepresentable {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
+ if window == nil {
+ removeScrollWheelMonitor()
+ }
configureEnclosingScrollView()
}
@@ -44,8 +53,9 @@ struct LitheScrollViewChrome: NSViewRepresentable {
func configureEnclosingScrollView() {
guard let scrollView = enclosingScrollView else { return }
- scrollView.scrollerStyle = .overlay
- scrollView.autohidesScrollers = true
+ scrollView.scrollerStyle = alwaysShowVertical ? .legacy : .overlay
+ scrollView.autohidesScrollers = !alwaysShowVertical
+ scrollView.hasVerticalScroller = true
scrollView.verticalScroller?.knobStyle = .dark
scrollView.horizontalScroller?.knobStyle = .dark
@@ -53,12 +63,60 @@ struct LitheScrollViewChrome: NSViewRepresentable {
scrollView.hasHorizontalScroller = false
scrollView.horizontalScrollElasticity = .none
}
+ configureScrollWheelMonitor(for: scrollView)
+ }
+
+ deinit {
+ removeScrollWheelMonitor()
+ }
+
+ private func configureScrollWheelMonitor(for scrollView: NSScrollView) {
+ guard alwaysShowVertical else {
+ removeScrollWheelMonitor()
+ configuredScrollView = nil
+ return
+ }
+ guard configuredScrollView !== scrollView || scrollWheelMonitor == nil else { return }
+ removeScrollWheelMonitor()
+ configuredScrollView = scrollView
+ scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self, weak scrollView] event in
+ guard let self,
+ let scrollView,
+ self.isEvent(event, inside: scrollView),
+ self.canScrollVertically(scrollView) else { return event }
+ scrollView.scrollWheel(with: event)
+ return nil
+ }
+ }
+
+ private func removeScrollWheelMonitor() {
+ if let scrollWheelMonitor {
+ NSEvent.removeMonitor(scrollWheelMonitor)
+ self.scrollWheelMonitor = nil
+ }
+ }
+
+ private func isEvent(_ event: NSEvent, inside scrollView: NSScrollView) -> Bool {
+ guard event.window === scrollView.window else { return false }
+ let point = scrollView.convert(event.locationInWindow, from: nil)
+ return scrollView.bounds.contains(point)
+ }
+
+ private func canScrollVertically(_ scrollView: NSScrollView) -> Bool {
+ guard let documentView = scrollView.documentView else { return false }
+ return documentView.bounds.height > scrollView.contentView.bounds.height + 0.5
}
}
}
extension View {
- func litheScrollViewChrome(hideHorizontal: Bool = false) -> some View {
- background(LitheScrollViewChrome(hideHorizontal: hideHorizontal))
+ func litheScrollViewChrome(
+ hideHorizontal: Bool = false,
+ alwaysShowVertical: Bool = false
+ ) -> some View {
+ background(LitheScrollViewChrome(
+ hideHorizontal: hideHorizontal,
+ alwaysShowVertical: alwaysShowVertical
+ ))
}
}
diff --git a/Sources/Lithe/Views/JavaProblemsView.swift b/Sources/Lithe/Views/JavaProblemsView.swift
index edbf43bba..6a6652c25 100644
--- a/Sources/Lithe/Views/JavaProblemsView.swift
+++ b/Sources/Lithe/Views/JavaProblemsView.swift
@@ -180,6 +180,7 @@ struct ProblemsView: View {
private func color(for severity: DiagnosticSeverity) -> Color {
switch severity {
+ case .unknown: LitheTheme.secondaryText
case .error: LitheTheme.error
case .warning: LitheTheme.warning
case .information: LitheTheme.accent
@@ -187,6 +188,3 @@ struct ProblemsView: View {
}
}
}
-
-/// Compatibility alias for older call sites and persisted UI integrations.
-typealias JavaProblemsView = ProblemsView
diff --git a/Sources/Lithe/Views/JavaReferencesView.swift b/Sources/Lithe/Views/JavaReferencesView.swift
index ed2910467..043a70278 100644
--- a/Sources/Lithe/Views/JavaReferencesView.swift
+++ b/Sources/Lithe/Views/JavaReferencesView.swift
@@ -106,9 +106,8 @@ struct LanguageImplementationChooserView: View {
model.navigate(to: location)
} label: {
HStack(spacing: 9) {
- Image(systemName: "c.circle")
- .foregroundStyle(Color(red: 0.42, green: 0.66, blue: 0.95))
- Text(location.displayName.replacingOccurrences(of: ".java", with: ""))
+ LitheIcon(kind: LitheIcons.kind(for: location.url, isDirectory: false), size: 15)
+ Text(location.displayName)
.font(.system(size: 12.5, weight: .medium, design: .monospaced))
.foregroundStyle(LitheTheme.primaryText)
Text(location.displayPath ?? model.relativePath(for: location.url))
diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/LSPControlCenterView.swift
new file mode 100644
index 000000000..cc5240d95
--- /dev/null
+++ b/Sources/Lithe/Views/LSPControlCenterView.swift
@@ -0,0 +1,1084 @@
+import SwiftUI
+
+struct LSPControlCenterView: View {
+ @EnvironmentObject private var model: AppModel
+ @EnvironmentObject private var settings: AppSettings
+ @State private var selectedProviderID: String?
+ @State private var isToolSetupExpanded = false
+
+ private let metricColumns = [
+ GridItem(.flexible(), spacing: 8),
+ GridItem(.flexible(), spacing: 8)
+ ]
+ private let serverListHeight: CGFloat = 176
+
+ private var copy: LSPControlCenterCopy {
+ LSPControlCenterCopy(language: settings.language)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ header
+
+ ScrollView(.vertical) {
+ VStack(spacing: 8) {
+ globalControls
+ if model.languageProviderCatalogSnapshot.isDegraded {
+ catalogDegradedBanner
+ }
+ if isToolSetupExpanded {
+ languageServerSetupPanel
+ } else {
+ serverList
+ if let selected = selectedDescriptor {
+ serverDetail(selected)
+ } else {
+ emptyDetail
+ }
+ eventLog
+ diagnosticLog
+ }
+ }
+ .padding(8)
+ }
+ .background(LitheTheme.editor)
+ }
+ .background(LitheTheme.editor)
+ }
+
+ private var header: some View {
+ HStack(spacing: 8) {
+ Text(copy.title)
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(LitheTheme.primaryText)
+ Spacer(minLength: 0)
+ Button {
+ isToolSetupExpanded.toggle()
+ } label: {
+ LitheIDEAIcon(
+ resourcePath: "general/gear.svg",
+ size: 15,
+ fallbackSystemImage: "gearshape"
+ )
+ }
+ .litheIconButton()
+ .help(copy.configureLanguageServers)
+ Button {
+ model.isLSPControlCenterVisible = false
+ } label: {
+ Image(systemName: "xmark")
+ }
+ .litheIconButton()
+ .help(copy.hideControlCenter)
+ }
+ .padding(.leading, 12)
+ .padding(.trailing, 5)
+ .frame(height: 34)
+ .background(LitheTheme.toolHeader)
+ }
+
+ private var languageServerSetupPanel: some View {
+ VStack(spacing: 8) {
+ HStack(spacing: 8) {
+ sectionTitle(copy.configureLanguageServers)
+ Spacer(minLength: 0)
+ Button {
+ isToolSetupExpanded = false
+ } label: {
+ Label(copy.backToOverview, systemImage: "chevron.left")
+ .font(.system(size: 11, weight: .medium))
+ }
+ .buttonStyle(LitheSecondaryButtonStyle())
+ }
+
+ LanguageServerSetupView(
+ tools: model.languageServerTools,
+ providers: configurableLanguageServerDescriptors,
+ initialProviderID: selectedDescriptor?.id,
+ language: settings.language,
+ chooseExecutable: { descriptor in
+ model.chooseLanguageServerExecutable(providerName: descriptor.displayName)
+ },
+ openOfficialDownload: { url in
+ model.openLanguageServerDownload(url)
+ },
+ configurationChanged: { providerID in
+ model.languageServerToolConfigurationDidChange(providerID: providerID)
+ },
+ isEmbedded: true
+ )
+ }
+ .padding(10)
+ .panelChrome()
+ }
+
+ private var globalControls: some View {
+ VStack(spacing: 8) {
+ HStack(spacing: 8) {
+ Text(copy.currentProject)
+ .font(.system(size: 11.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Text(model.projectName)
+ .font(.system(size: 11.5, weight: .medium))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ Spacer(minLength: 0)
+ statusPill(
+ title: activeServerCount > 0 ? copy.lspActive : copy.noRunningSession,
+ color: activeServerCount > 0 ? LitheTheme.success : LitheTheme.secondaryText
+ )
+ }
+
+ HStack(spacing: 8) {
+ Button {
+ model.restartLanguageServers()
+ } label: {
+ Label(copy.restartAll, systemImage: "arrow.clockwise")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(LithePrimaryButtonStyle())
+
+ Button {
+ model.clearLanguageServerDiagnostics()
+ } label: {
+ Label(copy.clearDiagnostics, systemImage: "trash")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(LitheSecondaryButtonStyle())
+ }
+ }
+ .padding(10)
+ .panelChrome()
+ }
+
+ private var catalogDegradedBanner: some View {
+ let snapshot = model.languageProviderCatalogSnapshot
+ return VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 7) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(LitheTheme.warning)
+ Text(copy.catalogDegraded)
+ .font(.system(size: 11.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.primaryText)
+ Spacer(minLength: 0)
+ Text(copy.catalogOrigin(snapshot.origin))
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.warning)
+ }
+ ForEach(Array(snapshot.issues.enumerated()), id: \.offset) { _, issue in
+ Text("\(issue.path): \(issue.message)")
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(3)
+ .textSelection(.enabled)
+ }
+ }
+ .padding(10)
+ .background(
+ RoundedRectangle(cornerRadius: 7)
+ .fill(LitheTheme.warning.opacity(0.10))
+ .overlay(
+ RoundedRectangle(cornerRadius: 7)
+ .stroke(LitheTheme.warning.opacity(0.35), lineWidth: 1)
+ )
+ )
+ }
+
+ private var serverList: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ sectionTitle(copy.languageServers)
+
+ ScrollView(.vertical) {
+ if languageServerDescriptors.isEmpty {
+ Text(copy.noProjectLanguageServers)
+ .font(.system(size: 11.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .frame(maxWidth: .infinity, minHeight: serverListHeight, alignment: .leading)
+ } else {
+ VStack(spacing: 1) {
+ ForEach(languageServerDescriptors) { descriptor in
+ serverRow(descriptor)
+ }
+ }
+ }
+ }
+ .frame(height: serverListHeight)
+ .litheScrollViewChrome(hideHorizontal: true, alwaysShowVertical: true)
+ }
+ .padding(10)
+ .panelChrome()
+ }
+
+ private func serverRow(_ descriptor: LanguageProviderDescriptor) -> some View {
+ let metrics = providerMetrics(for: descriptor)
+ let isSelected = descriptor.id == selectedDescriptor?.id
+
+ return HStack(spacing: 8) {
+ Circle()
+ .fill(statusColor(metrics.status))
+ .frame(width: 8, height: 8)
+ providerIcon(for: descriptor)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(descriptor.displayName)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ Text(metrics.subtitle)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 0)
+ Text(copy.title(for: metrics.status))
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(statusColor(metrics.status))
+ if [.starting, .initializing, .active, .error].contains(metrics.status) {
+ Button {
+ model.disableLanguageServerForCurrentWorkspace(providerID: descriptor.id)
+ } label: {
+ Image(systemName: "stop")
+ }
+ .litheIconButton()
+ .help(copy.disableProvider(descriptor.displayName))
+ }
+ }
+ .padding(.horizontal, 7)
+ .frame(height: 38)
+ .contentShape(Rectangle())
+ .litheRowHover(isActive: isSelected, cornerRadius: 6, activeBackground: LitheTheme.subtleSelection)
+ .onTapGesture {
+ selectedProviderID = descriptor.id
+ }
+ }
+
+ private func serverDetail(_ descriptor: LanguageProviderDescriptor) -> some View {
+ let metrics = providerMetrics(for: descriptor)
+ return VStack(alignment: .leading, spacing: 10) {
+ HStack(spacing: 9) {
+ providerIcon(for: descriptor)
+ .frame(width: 30, height: 30)
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 7) {
+ Text(descriptor.displayName)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(LitheTheme.primaryText)
+ statusPill(title: copy.title(for: metrics.status), color: statusColor(metrics.status))
+ }
+ Text(metrics.workspacePath)
+ .font(.system(size: 11))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 0)
+ Text(metrics.version)
+ .font(.system(size: 11.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+
+ HStack(spacing: 0) {
+ summaryButton(copy.rootFiles, value: "\(metrics.fileCount)") {
+ openFirstProjectFile(for: descriptor)
+ }
+ summaryButton(copy.openFiles, value: "\(metrics.openFileCount)") {
+ openFirstOpenDocument(for: descriptor)
+ }
+ summaryButton(copy.diagnostics, value: "\(metrics.diagnosticCount)") {
+ openFirstDiagnostic(for: descriptor)
+ }
+ }
+
+ capabilityGrid(descriptor)
+ configurationSection(descriptor)
+ }
+ .padding(10)
+ .panelChrome()
+ }
+
+ private var emptyDetail: some View {
+ VStack(spacing: 8) {
+ Image(systemName: "chevron.left.forwardslash.chevron.right")
+ .font(.system(size: 26, weight: .light))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Text(copy.openSupportedFile)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(LitheTheme.primaryText)
+ Text(copy.matchingServerWillAppear)
+ .font(.system(size: 11.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 28)
+ .panelChrome()
+ }
+
+ private var diagnosticLog: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ HStack {
+ sectionTitle(copy.diagnostics)
+ Spacer(minLength: 0)
+ Text("\(allDiagnostics.count)")
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+
+ if allDiagnostics.isEmpty {
+ Text(copy.noLanguageServerDiagnostics)
+ .font(.system(size: 11.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading)
+ } else {
+ VStack(spacing: 1) {
+ ForEach(Array(allDiagnostics.prefix(5).enumerated()), id: \.offset) { _, diagnostic in
+ Button {
+ model.openDiagnostic(diagnostic)
+ } label: {
+ HStack(spacing: 7) {
+ Image(systemName: diagnostic.severity.systemImage)
+ .foregroundStyle(color(for: diagnostic.severity))
+ .frame(width: 14)
+ Text(diagnostic.locationTitle)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ Text(diagnostic.message)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 6)
+ .frame(height: 25)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ }
+ }
+ }
+ }
+ .padding(10)
+ .panelChrome()
+ }
+
+ private var eventLog: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ HStack {
+ sectionTitle(copy.eventLog)
+ Spacer(minLength: 0)
+ Button {
+ model.languageToolingSessions.clearLanguageServerLogs()
+ } label: {
+ Image(systemName: "trash")
+ }
+ .litheIconButton()
+ .help(copy.clearEventLog)
+ }
+
+ if model.languageToolingSessions.languageServerLogs.isEmpty {
+ Text(copy.noLanguageServerEvents)
+ .font(.system(size: 11.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .frame(maxWidth: .infinity, minHeight: 42, alignment: .leading)
+ } else {
+ VStack(spacing: 1) {
+ ForEach(model.languageToolingSessions.languageServerLogs.prefix(6)) { entry in
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 7) {
+ Image(systemName: logIcon(for: entry.level))
+ .foregroundStyle(logColor(for: entry.level))
+ .frame(width: 14)
+ Text(providerName(for: entry.providerID))
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ Text(entry.timestamp.formatted(date: .omitted, time: .standard))
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Spacer(minLength: 0)
+ }
+ Text(entry.message)
+ .font(.system(size: 11))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ if let detail = entry.detail, !detail.isEmpty {
+ Text(detail)
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(2)
+ .truncationMode(.middle)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 5)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(LitheTheme.raised.opacity(0.38))
+ )
+ }
+ }
+ }
+ }
+ .padding(10)
+ .panelChrome()
+ }
+
+ private func capabilityGrid(_ descriptor: LanguageProviderDescriptor) -> some View {
+ let sessionState = model.languageToolingSessions.languageServerStates[descriptor.id]
+ let features = model.languageToolingSessions.languageServerFeatures[descriptor.id]
+ let negotiatedRows: [LSPCapabilityRow] = [
+ LSPCapabilityRow(
+ title: copy.definition,
+ icon: "arrow.turn.down.right",
+ state: LSPControlCenterPresenter.negotiatedCapabilityState(
+ .definition,
+ sessionState: sessionState,
+ features: features
+ )
+ ),
+ LSPCapabilityRow(
+ title: copy.completion,
+ icon: "text.badge.plus",
+ state: LSPControlCenterPresenter.negotiatedCapabilityState(
+ .completion,
+ sessionState: sessionState,
+ features: features
+ )
+ ),
+ LSPCapabilityRow(
+ title: copy.hover,
+ icon: "text.bubble",
+ state: LSPControlCenterPresenter.negotiatedCapabilityState(
+ .hover,
+ sessionState: sessionState,
+ features: features
+ )
+ ),
+ LSPCapabilityRow(
+ title: copy.formatting,
+ icon: "text.alignleft",
+ state: LSPControlCenterPresenter.negotiatedCapabilityState(
+ .formatting,
+ sessionState: sessionState,
+ features: features
+ )
+ )
+ ]
+ let integrationRows: [LSPCapabilityRow] = [
+ LSPCapabilityRow(
+ title: copy.run,
+ icon: "play",
+ state: LSPControlCenterPresenter.integrationState(
+ isAvailable: descriptor.capabilities.contains(.run)
+ )
+ ),
+ LSPCapabilityRow(
+ title: copy.testing,
+ icon: "checkmark.seal",
+ state: LSPControlCenterPresenter.integrationState(
+ isAvailable: descriptor.capabilities.contains(.testing)
+ )
+ ),
+ LSPCapabilityRow(
+ title: copy.debug,
+ icon: "ladybug",
+ state: LSPControlCenterPresenter.integrationState(
+ isAvailable: descriptor.capabilities.contains(.debugAdapter),
+ isActive: model.languageToolingSessions.activeDebugAdapterIDs.contains(descriptor.id)
+ )
+ )
+ ]
+
+ return VStack(alignment: .leading, spacing: 7) {
+ sectionTitle(copy.negotiatedCapabilities)
+ capabilityRows(negotiatedRows)
+ sectionTitle(copy.litheIntegrations)
+ .padding(.top, 3)
+ capabilityRows(integrationRows)
+ }
+ }
+
+ private func capabilityRows(_ rows: [LSPCapabilityRow]) -> some View {
+ LazyVGrid(columns: metricColumns, spacing: 6) {
+ ForEach(rows) { row in
+ Button {
+ model.showNotification(copy.capabilityState(row.title, state: row.state))
+ } label: {
+ HStack(spacing: 7) {
+ Image(systemName: row.icon)
+ .frame(width: 15)
+ Text(row.title)
+ .lineLimit(1)
+ Spacer(minLength: 0)
+ Image(systemName: capabilityIcon(for: row.state))
+ .foregroundStyle(capabilityColor(for: row.state))
+ }
+ .font(.system(size: 11.5))
+ .foregroundStyle(
+ row.state == .unknown || row.state == .unsupported
+ ? LitheTheme.secondaryText
+ : LitheTheme.primaryText
+ )
+ .padding(.horizontal, 7)
+ .frame(height: 30)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 6)
+ .fill(LitheTheme.raised.opacity(0.55))
+ )
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ }
+ }
+ }
+
+ private func configurationSection(_ descriptor: LanguageProviderDescriptor) -> some View {
+ return VStack(alignment: .leading, spacing: 7) {
+ sectionTitle(copy.providerConfiguration)
+
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 7) {
+ Image(systemName: "curlybraces.square")
+ .foregroundStyle(LitheTheme.accent)
+ Text(copy.rustOwnedConfiguration)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ Spacer(minLength: 0)
+ }
+ configActionRow(title: copy.providerID, value: descriptor.id, systemImage: "doc.on.doc") {
+ model.showNotification(copy.providerIDCopied(descriptor.id))
+ }
+ configActionRow(title: copy.activation, value: copy.activationPolicy(descriptor.activationPolicy), systemImage: "bolt.badge.clock") {
+ model.showNotification(copy.activationPolicy(descriptor.activationPolicy))
+ }
+ configActionRow(title: copy.builtinCatalog, value: builtinCatalogPath, systemImage: "curlybraces") {
+ openFileIfPresent(URL(fileURLWithPath: builtinCatalogPath), missingMessage: copy.builtinCatalogUnavailable)
+ }
+ configActionRow(title: copy.projectOverride, value: projectLSPConfigPath, systemImage: "folder.badge.gearshape") {
+ openFileIfPresent(URL(fileURLWithPath: projectLSPConfigPath), missingMessage: copy.projectOverrideMissing)
+ }
+ }
+ .padding(8)
+ .background(
+ RoundedRectangle(cornerRadius: 7)
+ .fill(LitheTheme.raised.opacity(0.55))
+ )
+
+ Button {
+ selectedProviderID = descriptor.id
+ isToolSetupExpanded = true
+ } label: {
+ Label(copy.editToolPath, systemImage: "wrench.and.screwdriver")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(LithePrimaryButtonStyle())
+
+ Text(copy.configurationHint)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(2)
+ }
+ }
+
+ private func configActionRow(
+ title: String,
+ value: String,
+ systemImage: String,
+ action: @escaping () -> Void
+ ) -> some View {
+ Button(action: action) {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(title)
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .frame(width: 82, alignment: .leading)
+ Image(systemName: systemImage)
+ .font(.system(size: 10))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .frame(width: 14)
+ Text(value)
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ }
+
+ private func summaryButton(_ title: String, value: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(title)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Text(value)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundStyle(LitheTheme.primaryText)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, 10)
+ .contentShape(Rectangle())
+ .overlay(alignment: .leading) {
+ Rectangle()
+ .fill(LitheTheme.divider)
+ .frame(width: 1)
+ }
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ }
+
+ private func sectionTitle(_ title: String) -> some View {
+ Text(title)
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+
+ private func statusPill(title: String, color: Color) -> some View {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(color)
+ .frame(width: 6, height: 6)
+ Text(title)
+ .lineLimit(1)
+ }
+ .font(.system(size: 10.5, weight: .medium))
+ .foregroundStyle(color)
+ .padding(.horizontal, 8)
+ .frame(height: 22)
+ .background(
+ Capsule()
+ .fill(color.opacity(0.16))
+ )
+ }
+
+ private func providerIcon(for _: LanguageProviderDescriptor) -> some View {
+ LitheIcon(kind: .generic, size: 17)
+ }
+
+ private func color(for severity: DiagnosticSeverity) -> Color {
+ switch severity {
+ case .unknown: LitheTheme.secondaryText
+ case .error: LitheTheme.error
+ case .warning: LitheTheme.warning
+ case .information: LitheTheme.accent
+ case .hint: LitheTheme.secondaryText
+ }
+ }
+
+ private func logColor(for level: LanguageServerLogLevel) -> Color {
+ switch level {
+ case .info: LitheTheme.accent
+ case .warning: LitheTheme.warning
+ case .error: LitheTheme.error
+ }
+ }
+
+ private func logIcon(for level: LanguageServerLogLevel) -> String {
+ switch level {
+ case .info: "info.circle"
+ case .warning: "exclamationmark.triangle"
+ case .error: "xmark.octagon"
+ }
+ }
+
+ private func providerName(for providerID: String) -> String {
+ model.languageProviderCatalog.descriptors.first { $0.id == providerID }?.displayName
+ ?? providerID
+ }
+
+ private func statusColor(_ status: LSPServerStatus) -> Color {
+ switch status {
+ case .starting, .initializing: LitheTheme.accent
+ case .active: LitheTheme.success
+ case .stopping, .disabled: LitheTheme.warning
+ case .stopped: LitheTheme.secondaryText
+ case .error: LitheTheme.error
+ }
+ }
+
+ private func capabilityIcon(for state: LSPCapabilityPresentationState) -> String {
+ switch state {
+ case .unknown: "questionmark.circle"
+ case .unsupported: "xmark.circle"
+ case .available: "checkmark.circle"
+ case .active: "bolt.fill"
+ }
+ }
+
+ private func capabilityColor(for state: LSPCapabilityPresentationState) -> Color {
+ switch state {
+ case .unknown, .unsupported: LitheTheme.secondaryText
+ case .available: LitheTheme.accent
+ case .active: LitheTheme.success
+ }
+ }
+
+ private var languageServerDescriptors: [LanguageProviderDescriptor] {
+ model.languageProviderCatalog.descriptors
+ .filter { $0.capabilities.contains(.languageServer) }
+ .filter { descriptor in
+ model.projectFiles.contains { descriptor.handles(fileURL: $0) }
+ }
+ }
+
+ private var configurableLanguageServerDescriptors: [LanguageProviderDescriptor] {
+ model.languageProviderCatalog.descriptors
+ .filter { $0.capabilities.contains(.languageServer) }
+ .filter { $0.languageServerLaunch != nil }
+ }
+
+ private var selectedDescriptor: LanguageProviderDescriptor? {
+ if let selectedProviderID,
+ let selected = languageServerDescriptors.first(where: { $0.id == selectedProviderID }) {
+ return selected
+ }
+ if let document = model.activeDocument,
+ let descriptor = languageServerDescriptors.first(where: { $0.handles(fileURL: document.url) }) {
+ return descriptor
+ }
+ return languageServerDescriptors.first
+ }
+
+ private var activeServerCount: Int {
+ languageServerDescriptors.filter { providerMetrics(for: $0).status == .active }.count
+ }
+
+ private var builtinCatalogPath: String {
+ URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .appendingPathComponent("rust")
+ .appendingPathComponent("lithe-core")
+ .appendingPathComponent("resources")
+ .appendingPathComponent("lsp")
+ .appendingPathComponent("language-providers.json")
+ .path
+ }
+
+ private var projectLSPConfigPath: String {
+ guard let workspaceURL = model.workspaceURL else {
+ return ".lithe/lsp/language-providers.json"
+ }
+ return workspaceURL
+ .appendingPathComponent(".lithe")
+ .appendingPathComponent("lsp")
+ .appendingPathComponent("language-providers.json")
+ .path
+ }
+
+ private func matchingProjectFiles(for descriptor: LanguageProviderDescriptor) -> [URL] {
+ model.projectFiles.filter { descriptor.handles(fileURL: $0) }
+ }
+
+ private func matchingOpenDocuments(for descriptor: LanguageProviderDescriptor) -> [EditorDocument] {
+ model.openDocuments.filter { descriptor.handles(fileURL: $0.url) }
+ }
+
+ private func matchingDiagnostics(for descriptor: LanguageProviderDescriptor) -> [EditorDiagnostic] {
+ model.languageToolingSessions.diagnostics(for: descriptor.id)
+ .flatMap { fileURL, diagnostics in
+ diagnostics.map {
+ EditorDiagnostic(languageServerDiagnostic: $0, fileURL: fileURL)
+ }
+ }
+ .sorted {
+ if $0.severity != $1.severity { return $0.severity.sortOrder < $1.severity.sortOrder }
+ if $0.line != $1.line { return $0.line < $1.line }
+ return $0.message < $1.message
+ }
+ }
+
+ private var allDiagnostics: [EditorDiagnostic] {
+ model.languageToolingSessions.diagnostics
+ .flatMap { fileURL, diagnostics in
+ diagnostics.map {
+ EditorDiagnostic(languageServerDiagnostic: $0, fileURL: fileURL)
+ }
+ }
+ .sorted {
+ if $0.severity != $1.severity { return $0.severity.sortOrder < $1.severity.sortOrder }
+ if $0.line != $1.line { return $0.line < $1.line }
+ return $0.message < $1.message
+ }
+ }
+
+ private func openFirstProjectFile(for descriptor: LanguageProviderDescriptor) {
+ guard let fileURL = matchingProjectFiles(for: descriptor).first else {
+ model.showNotification(copy.noMatchingFiles)
+ return
+ }
+ model.openFile(fileURL)
+ }
+
+ private func openFirstOpenDocument(for descriptor: LanguageProviderDescriptor) {
+ guard let document = matchingOpenDocuments(for: descriptor).first else {
+ model.showNotification(copy.noOpenFilesForProvider)
+ return
+ }
+ model.openFile(document.url)
+ }
+
+ private func openFirstDiagnostic(for descriptor: LanguageProviderDescriptor) {
+ guard let diagnostic = matchingDiagnostics(for: descriptor).first else {
+ model.showNotification(copy.noLanguageServerDiagnostics)
+ return
+ }
+ model.openDiagnostic(diagnostic)
+ }
+
+ private func openFileIfPresent(_ url: URL, missingMessage: String) {
+ if model.workspaceFileOperations.fileExists(at: url) {
+ model.openFile(url)
+ } else {
+ model.showNotification(missingMessage)
+ }
+ }
+
+ private func providerMetrics(for descriptor: LanguageProviderDescriptor) -> LSPProviderMetrics {
+ let files = matchingProjectFiles(for: descriptor)
+ let openFiles = matchingOpenDocuments(for: descriptor)
+ let diagnostics = matchingDiagnostics(for: descriptor)
+ let status = LSPControlCenterPresenter.serverStatus(
+ isDisabled: model.isLanguageServerDisabledInCurrentWorkspace(providerID: descriptor.id),
+ sessionState: model.languageToolingSessions.languageServerStates[descriptor.id]
+ )
+ let version = LSPControlCenterPresenter.reportedServerVersion(
+ model.languageToolingSessions.languageServerInfos[descriptor.id]
+ ) ?? copy.unknown
+
+ return LSPProviderMetrics(
+ status: status,
+ subtitle: files.isEmpty ? copy.noMatchingFiles : copy.matchingFiles(files.count),
+ workspacePath: model.workspaceURL?.path ?? copy.noWorkspace,
+ version: version,
+ fileCount: files.count,
+ openFileCount: openFiles.count,
+ diagnosticCount: diagnostics.count
+ )
+ }
+
+}
+
+private struct LSPCapabilityRow: Identifiable {
+ let title: String
+ let icon: String
+ let state: LSPCapabilityPresentationState
+
+ var id: String { title }
+}
+
+private struct LSPControlCenterCopy {
+ let language: AppLanguage
+
+ private var usesChinese: Bool {
+ language == .simplifiedChinese
+ }
+
+ var title: String { usesChinese ? "LSP 控制中心" : "LSP Control Center" }
+ var hideControlCenter: String { usesChinese ? "隐藏 LSP 控制中心" : "Hide LSP Control Center" }
+ var configureLanguageServers: String { usesChinese ? "配置语言服务器" : "Configure language servers" }
+ var backToOverview: String { usesChinese ? "返回概览" : "Back to overview" }
+ var currentProject: String { usesChinese ? "当前项目:" : "Current project:" }
+ var lspActive: String { usesChinese ? "LSP 运行中" : "LSP active" }
+ var noRunningSession: String { usesChinese ? "无运行会话" : "No running session" }
+ var restartAll: String { usesChinese ? "全部重启" : "Restart all" }
+ var clearDiagnostics: String { usesChinese ? "清空诊断" : "Clear diagnostics" }
+ var languageServers: String { usesChinese ? "语言服务器" : "Language Servers" }
+ var noProjectLanguageServers: String {
+ usesChinese ? "当前项目没有匹配的语言服务器。" : "No matching language servers in this project."
+ }
+ var rootFiles: String { usesChinese ? "项目文件" : "Root files" }
+ var openFiles: String { usesChinese ? "打开文件" : "Open files" }
+ var diagnostics: String { usesChinese ? "诊断" : "Diagnostics" }
+ var openSupportedFile: String { usesChinese ? "打开一个受支持的源码文件" : "Open a supported source file" }
+ var matchingServerWillAppear: String {
+ usesChinese ? "匹配的语言服务器会显示在这里。" : "The matching language server will appear here."
+ }
+ var noLanguageServerDiagnostics: String {
+ usesChinese ? "暂无语言服务器诊断。" : "No language server diagnostics."
+ }
+ var eventLog: String { usesChinese ? "事件日志" : "Event Log" }
+ var clearEventLog: String { usesChinese ? "清空事件日志" : "Clear event log" }
+ var noLanguageServerEvents: String {
+ usesChinese ? "暂无 LSP 事件。" : "No LSP events."
+ }
+ var capabilities: String { usesChinese ? "能力" : "Capabilities" }
+ var negotiatedCapabilities: String { usesChinese ? "LSP 协商能力" : "LSP Negotiated Capabilities" }
+ var litheIntegrations: String { usesChinese ? "Lithe 集成" : "Lithe Integrations" }
+ var languageServerCapability: String { usesChinese ? "语言服务器" : "Language Server" }
+ var definition: String { usesChinese ? "定义" : "Definition" }
+ var completion: String { usesChinese ? "补全" : "Completion" }
+ var hover: String { usesChinese ? "悬停信息" : "Hover" }
+ var formatting: String { usesChinese ? "格式化" : "Formatting" }
+ var testing: String { usesChinese ? "测试" : "Testing" }
+ var debug: String { usesChinese ? "调试" : "Debug" }
+ var run: String { usesChinese ? "运行" : "Run" }
+ var noMatchingFiles: String { usesChinese ? "没有匹配文件" : "No matching files" }
+ var noWorkspace: String { usesChinese ? "未打开工作区" : "No workspace" }
+ var unknown: String { usesChinese ? "未知" : "Unknown" }
+ var notRunning: String { usesChinese ? "未运行" : "Not running" }
+ var providerConfiguration: String { usesChinese ? "Provider 配置" : "Provider Configuration" }
+ var catalogDegraded: String {
+ usesChinese ? "语言 Provider Catalog 正在降级运行" : "Language Provider Catalog is degraded"
+ }
+ var rustOwnedConfiguration: String {
+ usesChinese ? "由 Rust LSP 配置加载" : "Loaded by Rust LSP configuration"
+ }
+ var providerID: String { usesChinese ? "Provider ID" : "Provider ID" }
+ var activation: String { usesChinese ? "启动策略" : "Activation" }
+ var builtinCatalog: String { usesChinese ? "内置 JSON" : "Built-in JSON" }
+ var projectOverride: String { usesChinese ? "项目覆盖" : "Project override" }
+ var editToolPath: String { usesChinese ? "编辑 LSP 工具路径" : "Edit LSP tool path" }
+ var noOpenFilesForProvider: String {
+ usesChinese ? "这个语言服务器当前没有打开的文件。" : "No open files for this language server."
+ }
+ var builtinCatalogUnavailable: String {
+ usesChinese ? "内置 LSP catalog 文件不可用。" : "The built-in LSP catalog file is unavailable."
+ }
+ var projectOverrideMissing: String {
+ usesChinese ? "当前项目还没有 LSP 覆盖配置。" : "This project has no LSP override configuration yet."
+ }
+ var configurationHint: String {
+ usesChinese
+ ? "语言、扩展名、能力、命令和平台覆盖只允许写入独立 LSP JSON,由 Rust 兼容层注册。"
+ : "Languages, extensions, capabilities, commands, and platform overrides belong in standalone LSP JSON registered by Rust."
+ }
+
+ func matchingFiles(_ count: Int) -> String {
+ if usesChinese { return "\(count) 个匹配文件" }
+ return "\(count) matching file\(count == 1 ? "" : "s")"
+ }
+
+ func catalogOrigin(_ origin: LanguageProviderCatalogOrigin) -> String {
+ switch origin {
+ case .builtin:
+ usesChinese ? "内置 Catalog" : "Built-in catalog"
+ case .workspaceOverride:
+ usesChinese ? "工作区覆盖" : "Workspace override"
+ case .compatibilityFallback:
+ usesChinese ? "兼容配置 fallback" : "Compatibility fallback"
+ }
+ }
+
+ func activationPolicy(_ policy: ToolingActivationPolicy) -> String {
+ switch policy {
+ case .always:
+ usesChinese ? "始终启动" : "Always"
+ case .onDemand:
+ usesChinese ? "按需激活" : "On demand"
+ }
+ }
+
+ func disableProvider(_ name: String) -> String {
+ usesChinese
+ ? "在当前工作区禁用 \(name) LSP"
+ : "Disable \(name) LSP in this workspace"
+ }
+
+ func providerIDCopied(_ id: String) -> String {
+ usesChinese ? "Provider ID:\(id)" : "Provider ID: \(id)"
+ }
+
+ func capabilityState(_ name: String, state: LSPCapabilityPresentationState) -> String {
+ if usesChinese {
+ switch state {
+ case .unknown: return "\(name) 尚未完成 LSP 初始化协商,当前状态未知。"
+ case .unsupported: return "\(name) 不受当前服务器或 Lithe 集成支持。"
+ case .available: return "\(name) 可用。"
+ case .active: return "\(name) 当前已激活。"
+ }
+ }
+ switch state {
+ case .unknown: return "\(name) is unknown until LSP initialization completes."
+ case .unsupported: return "\(name) is not supported by the current server or Lithe integration."
+ case .available: return "\(name) is available."
+ case .active: return "\(name) is currently active."
+ }
+ }
+
+ func title(for status: LSPServerStatus) -> String {
+ if usesChinese {
+ switch status {
+ case .starting: "启动进程中"
+ case .initializing: "初始化中"
+ case .active: "已就绪"
+ case .stopping: "停止中"
+ case .stopped: "未运行"
+ case .disabled: "已禁用"
+ case .error: "错误"
+ }
+ } else {
+ switch status {
+ case .starting: "Starting"
+ case .initializing: "Initializing"
+ case .active: "Ready"
+ case .stopping: "Stopping"
+ case .stopped: "Not running"
+ case .disabled: "Disabled"
+ case .error: "Error"
+ }
+ }
+ }
+}
+
+private struct LSPProviderMetrics {
+ let status: LSPServerStatus
+ let subtitle: String
+ let workspacePath: String
+ let version: String
+ let fileCount: Int
+ let openFileCount: Int
+ let diagnosticCount: Int
+}
+
+private extension DiagnosticSeverity {
+ var sortOrder: Int {
+ switch self {
+ case .error: 0
+ case .warning: 1
+ case .information: 2
+ case .hint: 3
+ case .unknown: 4
+ }
+ }
+}
+
+private extension LanguageServerFeatureSet {
+ var enabledFeatureCount: Int {
+ [
+ .definition,
+ .references,
+ .implementation,
+ .hover,
+ .completion,
+ .rename,
+ .formatting,
+ .codeActions,
+ .completionResolve,
+ .codeActionResolve,
+ .executeCommand
+ ].filter { contains($0) }.count
+ }
+}
+
+private extension View {
+ func panelChrome() -> some View {
+ background(
+ RoundedRectangle(cornerRadius: 8)
+ .fill(LitheTheme.sidebar)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(LitheTheme.panelBorder, lineWidth: 1)
+ }
+ }
+}
diff --git a/Sources/Lithe/Views/LanguageServerSetupView.swift b/Sources/Lithe/Views/LanguageServerSetupView.swift
new file mode 100644
index 000000000..a1a15393b
--- /dev/null
+++ b/Sources/Lithe/Views/LanguageServerSetupView.swift
@@ -0,0 +1,434 @@
+import SwiftUI
+
+struct LanguageServerSetupView: View {
+ @ObservedObject var tools: LanguageServerToolService
+
+ let providers: [LanguageProviderDescriptor]
+ let language: AppLanguage
+ let chooseExecutable: (LanguageProviderDescriptor) -> URL?
+ let openOfficialDownload: (URL) -> Void
+ let configurationChanged: (String) -> Void
+ let isEmbedded: Bool
+
+ @State private var selectedProviderID: String
+ @State private var executablePathDraft = ""
+ @State private var validationMessage: String?
+
+ init(
+ tools: LanguageServerToolService,
+ providers: [LanguageProviderDescriptor],
+ initialProviderID: String?,
+ language: AppLanguage,
+ chooseExecutable: @escaping (LanguageProviderDescriptor) -> URL?,
+ openOfficialDownload: @escaping (URL) -> Void,
+ configurationChanged: @escaping (String) -> Void,
+ isEmbedded: Bool = false
+ ) {
+ self.tools = tools
+ self.providers = providers
+ self.language = language
+ self.chooseExecutable = chooseExecutable
+ self.openOfficialDownload = openOfficialDownload
+ self.configurationChanged = configurationChanged
+ self.isEmbedded = isEmbedded
+ let initialID = initialProviderID.flatMap { id in
+ providers.contains(where: { $0.id == id }) ? id : nil
+ } ?? providers.first?.id ?? ""
+ _selectedProviderID = State(initialValue: initialID)
+ _executablePathDraft = State(initialValue: tools.customExecutablePath(for: initialID) ?? "")
+ }
+
+ private var copy: LanguageServerSetupCopy {
+ LanguageServerSetupCopy(language: language)
+ }
+
+ private var selectedDescriptor: LanguageProviderDescriptor? {
+ providers.first { $0.id == selectedProviderID }
+ }
+
+ private var candidates: [RuntimeToolCandidate] {
+ selectedDescriptor.map(tools.candidates(for:)) ?? []
+ }
+
+ private var resolvedExecutable: RuntimeToolCandidate? {
+ candidates.first
+ }
+
+ private var executableVerificationState: LanguageServerExecutableVerificationState {
+ guard let selectedDescriptor else { return .unavailable }
+ return tools.executableVerificationState(for: selectedDescriptor)
+ }
+
+ private var installPlan: LanguageServerInstallPlan? {
+ selectedDescriptor.map(tools.installPlan(for:))
+ }
+
+ private var installationState: LanguageServerInstallationState {
+ selectedDescriptor.map { tools.installationState(for: $0.id) } ?? .idle
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ setupHeader
+ Divider().overlay(LitheTheme.divider)
+
+ ScrollView(.vertical) {
+ VStack(alignment: .leading, spacing: 14) {
+ providerPicker
+ detectionSection
+ pathSection
+ installSection
+ }
+ .padding(14)
+ }
+ .litheScrollViewChrome(hideHorizontal: true)
+ }
+ .frame(width: isEmbedded ? nil : 430, height: isEmbedded ? nil : 510)
+ .frame(maxWidth: isEmbedded ? .infinity : nil, minHeight: isEmbedded ? 430 : nil)
+ .background(LitheTheme.sidebar)
+ .onChange(of: selectedProviderID) { _, providerID in
+ executablePathDraft = tools.customExecutablePath(for: providerID) ?? ""
+ validationMessage = nil
+ }
+ }
+
+ private var setupHeader: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "wrench.and.screwdriver")
+ .font(.system(size: 15, weight: .medium))
+ .foregroundStyle(LitheTheme.accent)
+ .frame(width: 28, height: 28)
+ .background(RoundedRectangle(cornerRadius: 6).fill(LitheTheme.subtleSelection))
+ VStack(alignment: .leading, spacing: 1) {
+ Text(copy.title)
+ .font(.system(size: 13.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.primaryText)
+ Text(copy.subtitle)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal, 14)
+ .frame(height: 50)
+ .background(LitheTheme.toolHeader)
+ }
+
+ private var providerPicker: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ sectionTitle(copy.languageServer)
+ Picker(copy.languageServer, selection: $selectedProviderID) {
+ ForEach(providers) { descriptor in
+ Text(descriptor.displayName).tag(descriptor.id)
+ }
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+
+ private var detectionSection: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ sectionTitle(copy.detectedExecutable)
+ HStack(alignment: .top, spacing: 9) {
+ Circle()
+ .fill(executableStatusColor)
+ .frame(width: 8, height: 8)
+ .padding(.top, 4)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(executableStatusTitle)
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(LitheTheme.primaryText)
+ Text(resolvedExecutable?.executableURL.path ?? expectedCommands)
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(2)
+ .truncationMode(.middle)
+ }
+ Spacer(minLength: 0)
+ if let source = resolvedExecutable?.source {
+ Text(source.displayName)
+ .font(.system(size: 9.5, weight: .medium))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .padding(.horizontal, 7)
+ .frame(height: 20)
+ .background(Capsule().fill(LitheTheme.raised))
+ }
+ }
+ .padding(10)
+ .background(RoundedRectangle(cornerRadius: 7).fill(LitheTheme.raised.opacity(0.55)))
+
+ ForEach(Array(candidates.dropFirst().prefix(2))) { candidate in
+ HStack(spacing: 7) {
+ Image(systemName: "arrow.turn.down.right")
+ .font(.system(size: 9))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Text(candidate.executableURL.path)
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Spacer(minLength: 0)
+ Text(candidate.source.displayName)
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ }
+ }
+ }
+
+ private var pathSection: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ HStack {
+ sectionTitle(copy.executablePath)
+ Spacer(minLength: 0)
+ Button(copy.useAutomatic) {
+ clearOverride()
+ }
+ .buttonStyle(.plain)
+ .font(.system(size: 10.5, weight: .medium))
+ .foregroundStyle(LitheTheme.accent)
+ .disabled(tools.customExecutablePath(for: selectedProviderID) == nil)
+ }
+
+ HStack(spacing: 7) {
+ TextField(copy.pathPlaceholder, text: $executablePathDraft)
+ .textFieldStyle(.roundedBorder)
+ .font(.system(size: 11, design: .monospaced))
+ Button {
+ browseForExecutable()
+ } label: {
+ Image(systemName: "folder")
+ }
+ .litheIconButton()
+ .help(copy.chooseExecutable)
+ Button(copy.savePath) {
+ savePath()
+ }
+ .buttonStyle(LitheSecondaryButtonStyle())
+ .disabled(executablePathDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+
+ Text(validationMessage ?? copy.pathHint)
+ .font(.system(size: 10.5))
+ .foregroundStyle(validationMessage == nil ? LitheTheme.secondaryText : LitheTheme.error)
+ .lineLimit(2)
+ }
+ }
+
+ private var installSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ sectionTitle(copy.installation)
+ Text(copy.installationHint)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+
+ HStack(spacing: 8) {
+ Button {
+ installWithHomebrew()
+ } label: {
+ HStack(spacing: 7) {
+ if case .installing = installationState {
+ ProgressView().controlSize(.small)
+ } else {
+ Image(systemName: "shippingbox")
+ }
+ Text(homebrewButtonTitle)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(LithePrimaryButtonStyle())
+ .disabled(!canInstallWithHomebrew)
+
+ Button {
+ guard let url = installPlan?.officialDownloadURL else { return }
+ openOfficialDownload(url)
+ } label: {
+ Label(copy.officialDownload, systemImage: "arrow.up.right.square")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(LitheSecondaryButtonStyle())
+ .disabled(installPlan?.officialDownloadURL == nil)
+ }
+
+ installationMessage
+ }
+ }
+
+ @ViewBuilder
+ private var installationMessage: some View {
+ switch installationState {
+ case .idle:
+ if installPlan?.homebrewFormula != nil, !tools.isHomebrewAvailable() {
+ statusMessage(copy.homebrewUnavailable, color: LitheTheme.warning)
+ }
+ case .installing:
+ statusMessage(copy.installing, color: LitheTheme.accent)
+ case .installed(let output):
+ statusMessage(copy.installComplete(output), color: LitheTheme.success)
+ case .failed(let message):
+ statusMessage(message, color: LitheTheme.error)
+ }
+ }
+
+ private func statusMessage(_ message: String, color: Color) -> some View {
+ Text(message)
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(color)
+ .lineLimit(3)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(8)
+ .background(RoundedRectangle(cornerRadius: 6).fill(LitheTheme.raised.opacity(0.55)))
+ }
+
+ private func sectionTitle(_ title: String) -> some View {
+ Text(title)
+ .font(.system(size: 10.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+
+ private var expectedCommands: String {
+ guard let commands = selectedDescriptor?.languageServerLaunch?.executableNames,
+ !commands.isEmpty else { return copy.noLaunchCommand }
+ return copy.expectedCommands(commands.joined(separator: ", "))
+ }
+
+ private var executableStatusTitle: String {
+ switch executableVerificationState {
+ case .unavailable:
+ copy.notFound
+ case .foundUnverified:
+ copy.executableFoundUnverified
+ case .executableVerified:
+ copy.executableVerified
+ }
+ }
+
+ private var executableStatusColor: Color {
+ switch executableVerificationState {
+ case .unavailable:
+ LitheTheme.warning
+ case .foundUnverified:
+ LitheTheme.accent
+ case .executableVerified:
+ LitheTheme.success
+ }
+ }
+
+ private var canInstallWithHomebrew: Bool {
+ guard installPlan?.homebrewFormula != nil,
+ tools.isHomebrewAvailable() else { return false }
+ if case .installing = installationState { return false }
+ return true
+ }
+
+ private var homebrewButtonTitle: String {
+ guard let formula = installPlan?.homebrewFormula else { return copy.noHomebrewFormula }
+ return copy.installWithHomebrew(formula)
+ }
+
+ private func browseForExecutable() {
+ guard let descriptor = selectedDescriptor,
+ let url = chooseExecutable(descriptor) else { return }
+ executablePathDraft = url.path
+ savePath()
+ }
+
+ private func savePath() {
+ guard let descriptor = selectedDescriptor else { return }
+ do {
+ try tools.setCustomExecutablePath(executablePathDraft, for: descriptor)
+ executablePathDraft = tools.customExecutablePath(for: descriptor.id) ?? executablePathDraft
+ validationMessage = nil
+ configurationChanged(descriptor.id)
+ } catch {
+ validationMessage = if let configurationError = error as? LanguageServerToolConfigurationError {
+ copy.message(for: configurationError)
+ } else {
+ error.localizedDescription
+ }
+ }
+ }
+
+ private func clearOverride() {
+ guard let descriptor = selectedDescriptor else { return }
+ tools.clearCustomExecutablePath(for: descriptor.id)
+ executablePathDraft = ""
+ validationMessage = nil
+ configurationChanged(descriptor.id)
+ }
+
+ private func installWithHomebrew() {
+ guard let descriptor = selectedDescriptor else { return }
+ Task {
+ await tools.installWithHomebrew(descriptor)
+ if case .installed = tools.installationState(for: descriptor.id) {
+ configurationChanged(descriptor.id)
+ }
+ }
+ }
+}
+
+private struct LanguageServerSetupCopy {
+ let language: AppLanguage
+
+ private var usesChinese: Bool { language == .simplifiedChinese }
+
+ var title: String { usesChinese ? "语言服务器工具" : "Language Server Tools" }
+ var subtitle: String { usesChinese ? "安装、探测并指定 LSP 可执行文件" : "Install, detect, and select LSP executables" }
+ var languageServer: String { usesChinese ? "语言服务器" : "Language server" }
+ var detectedExecutable: String { usesChinese ? "当前解析结果" : "Resolved executable" }
+ var executableFoundUnverified: String {
+ usesChinese ? "已找到可执行文件(未验证)" : "Executable found (not verified)"
+ }
+ var executableVerified: String {
+ usesChinese ? "可执行文件已验证" : "Executable verified"
+ }
+ var notFound: String { usesChinese ? "未找到可执行文件" : "Executable not found" }
+ var executablePath: String { usesChinese ? "自定义路径" : "Custom path" }
+ var useAutomatic: String { usesChinese ? "恢复自动探测" : "Use automatic detection" }
+ var pathPlaceholder: String { usesChinese ? "选择或输入绝对路径" : "Choose or enter an absolute path" }
+ var chooseExecutable: String { usesChinese ? "选择可执行文件" : "Choose executable" }
+ var savePath: String { usesChinese ? "保存" : "Save" }
+ var pathHint: String { usesChinese ? "保存后,下一次启动该 LSP 时使用此路径。" : "The next LSP session will use this path." }
+ var installation: String { usesChinese ? "安装" : "Installation" }
+ var installationHint: String {
+ usesChinese
+ ? "优先使用 Homebrew;没有可用 formula 或 Homebrew 时,从官方页面下载安装。"
+ : "Homebrew is preferred. Use the official download when Homebrew or a formula is unavailable."
+ }
+ var officialDownload: String { usesChinese ? "官方下载" : "Official download" }
+ var homebrewUnavailable: String { usesChinese ? "未找到 Homebrew,请使用官方下载或手动指定路径。" : "Homebrew was not found. Use the official download or choose an executable." }
+ var installing: String { usesChinese ? "Homebrew 正在安装…" : "Installing with Homebrew..." }
+ var noHomebrewFormula: String { usesChinese ? "无 Brew formula" : "No Brew formula" }
+ var noLaunchCommand: String { usesChinese ? "Provider 未配置启动命令" : "No launch command is configured" }
+
+ func expectedCommands(_ commands: String) -> String {
+ usesChinese ? "等待探测:\(commands)" : "Expected: \(commands)"
+ }
+
+ func installWithHomebrew(_ formula: String) -> String {
+ usesChinese ? "Brew 安装 \(formula)" : "Install \(formula)"
+ }
+
+ func installComplete(_ output: String) -> String {
+ let firstLine = output.split(separator: "\n", maxSplits: 1).first.map(String.init) ?? output
+ return usesChinese ? "安装完成:\(firstLine)" : "Installed: \(firstLine)"
+ }
+
+ func message(for error: LanguageServerToolConfigurationError) -> String {
+ switch error {
+ case .executableRequired:
+ usesChinese ? "请选择语言服务器可执行文件。" : error.localizedDescription
+ case .executableInvalid(let path):
+ usesChinese ? "该路径不是可执行文件:\(path)" : error.localizedDescription
+ case .executableValidationFailed(let path, let message):
+ usesChinese ? "语言服务器无法运行:\(path)\n\(message)" : error.localizedDescription
+ case .homebrewUnavailable:
+ usesChinese ? "Lithe 无法找到 Homebrew。" : error.localizedDescription
+ case .homebrewUnsupported(let provider):
+ usesChinese ? "\(provider) 没有已验证的 Homebrew formula。" : error.localizedDescription
+ }
+ }
+}
diff --git a/Sources/Lithe/Views/WorkbenchView.swift b/Sources/Lithe/Views/WorkbenchView.swift
index c8fda6c99..63781def9 100644
--- a/Sources/Lithe/Views/WorkbenchView.swift
+++ b/Sources/Lithe/Views/WorkbenchView.swift
@@ -8,6 +8,8 @@ struct WorkbenchView: View {
@EnvironmentObject private var runFeature: RunFeatureModel
@State private var sidebarWidth: CGFloat = 320
@State private var sidebarDragStart: CGFloat = 320
+ @State private var lspPanelWidth: CGFloat = 420
+ @State private var lspPanelDragStart: CGFloat = 420
@State private var topPaneHeight: CGFloat?
@State private var topPaneDragStart: CGFloat = 0
@State private var isBranchSwitcherPresented = false
@@ -15,7 +17,6 @@ struct WorkbenchView: View {
@State private var isCheckoutRevisionPresented = false
@State private var pendingTopBarPushReference: GitReference?
@State private var isRunConfigurationPickerPresented = false
- @State private var isRunConfigurationEditorPresented = false
@State private var isNewRunConfigurationPresented = false
@State private var isProjectSwitcherPresented = false
@State private var isMemoryUsagePopoverPresented = false
@@ -596,6 +597,14 @@ struct WorkbenchView: View {
model.toggleTests()
}
+ activityToolButton(
+ systemImage: "server.rack",
+ help: lspControlCenterTitle,
+ isSelected: model.isLSPControlCenterVisible
+ ) {
+ model.isLSPControlCenterVisible.toggle()
+ }
+
activityToolButton(
systemImage: "ladybug",
ideaAssetPath: "toolwindows/toolWindowDebugger.svg",
@@ -671,23 +680,6 @@ struct WorkbenchView: View {
)
}
- Button {
- isRunConfigurationEditorPresented = true
- } label: {
- LitheIDEAIcon(resourcePath: "general/gear.svg", size: 15, fallbackSystemImage: "gearshape")
- }
- .litheIconButton()
- .help("Edit run configuration")
- .disabled(runFeature.selectedConfiguration == nil || runFeature.configurationStatus != .ready)
- .popover(isPresented: $isRunConfigurationEditorPresented, arrowEdge: .bottom) {
- if let configuration = runFeature.selectedConfiguration {
- RunConfigurationEditorView(
- feature: runFeature,
- configuration: configuration
- )
- }
- }
-
Button {
if runFeature.isRunning {
model.stopSelectedRun()
@@ -710,6 +702,10 @@ struct WorkbenchView: View {
}
}
+ private var lspControlCenterTitle: String {
+ settings.language == .simplifiedChinese ? "LSP 控制中心" : "LSP Control Center"
+ }
+
private var runConfigurationSetupTitle: String {
switch runFeature.configurationStatus {
case .missing:
@@ -792,9 +788,18 @@ struct WorkbenchView: View {
let availableTopWidth = max(0, geometry.size.width - (horizontalPadding * 2))
let minimumSidebarWidth: CGFloat = 220
let minimumEditorWidth: CGFloat = 400
+ let minimumLSPPanelWidth: CGFloat = 320
+ let lspPanelHandleWidth = model.isLSPControlCenterVisible ? SplitHandleView.thickness : 0
+ let resolvedLSPPanelWidth = model.isLSPControlCenterVisible
+ ? constrained(
+ lspPanelWidth,
+ minimum: minimumLSPPanelWidth,
+ maximum: max(minimumLSPPanelWidth, min(540, availableTopWidth - SplitHandleView.thickness - minimumSidebarWidth - SplitHandleView.thickness - minimumEditorWidth))
+ )
+ : 0
let maximumSidebarWidth = max(
minimumSidebarWidth,
- min(520, availableTopWidth - SplitHandleView.thickness - minimumEditorWidth)
+ min(520, availableTopWidth - SplitHandleView.thickness - minimumEditorWidth - lspPanelHandleWidth - resolvedLSPPanelWidth)
)
let resolvedSidebarWidth = constrained(
sidebarWidth,
@@ -836,6 +841,27 @@ struct WorkbenchView: View {
EditorAreaView()
.clipShape(RoundedRectangle(cornerRadius: 10))
+
+ if model.isLSPControlCenterVisible {
+ SplitHandleView(
+ axis: .horizontal,
+ onDragStarted: {
+ lspPanelDragStart = resolvedLSPPanelWidth
+ },
+ onDragChanged: { translation in
+ lspPanelWidth = constrained(
+ lspPanelDragStart - translation,
+ minimum: minimumLSPPanelWidth,
+ maximum: max(minimumLSPPanelWidth, min(540, availableTopWidth - SplitHandleView.thickness - minimumSidebarWidth - SplitHandleView.thickness - minimumEditorWidth))
+ )
+ },
+ onDragEnded: {}
+ )
+
+ LSPControlCenterView()
+ .frame(width: resolvedLSPPanelWidth)
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ }
}
.padding(.top, 6)
.padding(.horizontal, 6)
@@ -953,24 +979,7 @@ struct WorkbenchView: View {
) {
model.selectedSidebar = .project
}
- if index < components.count - 1 || !activeJavaBreadcrumbs.isEmpty {
- breadcrumbSeparator
- }
- }
-
- ForEach(Array(activeJavaBreadcrumbs.enumerated()), id: \.offset) { index, hint in
- breadcrumbItem(
- title: hint.symbol,
- iconKind: index == activeJavaBreadcrumbs.count - 1 ? .javaGeneric : .javaClass,
- isEmphasized: index == activeJavaBreadcrumbs.count - 1
- ) {
- model.editorNavigationTarget = EditorNavigationTarget(
- url: document.url,
- line: hint.line,
- utf16Column: hint.utf16Column
- )
- }
- if index < activeJavaBreadcrumbs.count - 1 {
+ if index < components.count - 1 {
breadcrumbSeparator
}
}
@@ -984,19 +993,6 @@ struct WorkbenchView: View {
}
}
- private var activeJavaBreadcrumbs: [JavaCodeVisionHint] {
- guard let document = model.activeDocument,
- document.url.pathExtension.lowercased() == "java" else { return [] }
- let caretLine = model.editorCaret?.url.standardizedFileURL == document.url.standardizedFileURL
- ? model.editorCaret?.line ?? Int.max
- : Int.max
- return Array(
- (model.javaCodeVisionHints[document.url] ?? [])
- .filter { $0.line <= caretLine }
- .suffix(2)
- )
- }
-
private func breadcrumbItem(
title: String,
iconKind: LitheIconKind?,
diff --git a/Sources/LitheRustCore/bridge.c b/Sources/LitheRustCore/bridge.c
index e85dfe386..9ff230bbc 100644
--- a/Sources/LitheRustCore/bridge.c
+++ b/Sources/LitheRustCore/bridge.c
@@ -11,6 +11,11 @@ __attribute__((weak)) char *lithe_core_execute_json(const char *request) {
return NULL;
}
+__attribute__((weak)) char *lithe_core_lsp_provider_catalog_json(const char *workspace_root) {
+ (void)workspace_root;
+ return NULL;
+}
+
__attribute__((weak)) int32_t lithe_core_cancel(const char *operation_id) {
(void)operation_id;
return 0;
@@ -28,6 +33,10 @@ char *lithe_bridge_execute_json(const char *request) {
return lithe_core_execute_json(request);
}
+char *lithe_bridge_lsp_provider_catalog_json(const char *workspace_root) {
+ return lithe_core_lsp_provider_catalog_json(workspace_root);
+}
+
int32_t lithe_bridge_cancel(const char *operation_id) {
return lithe_core_cancel(operation_id);
}
diff --git a/Sources/LitheRustCore/include/lithe_bridge.h b/Sources/LitheRustCore/include/lithe_bridge.h
index 8f7796e27..da17a6589 100644
--- a/Sources/LitheRustCore/include/lithe_bridge.h
+++ b/Sources/LitheRustCore/include/lithe_bridge.h
@@ -9,6 +9,7 @@ extern "C" {
const char *lithe_bridge_version(void);
char *lithe_bridge_execute_json(const char *request);
+char *lithe_bridge_lsp_provider_catalog_json(const char *workspace_root);
int32_t lithe_bridge_cancel(const char *operation_id);
void lithe_bridge_free_string(char *value);
diff --git a/Tests/LitheTests/LSPControlCenterPresentationTests.swift b/Tests/LitheTests/LSPControlCenterPresentationTests.swift
new file mode 100644
index 000000000..05936cb89
--- /dev/null
+++ b/Tests/LitheTests/LSPControlCenterPresentationTests.swift
@@ -0,0 +1,94 @@
+import Testing
+@testable import Lithe
+
+@Suite("LSP Control Center presentation")
+struct LSPControlCenterPresentationTests {
+ @Test
+ func runtimeStateAloneDeterminesServerStatus() {
+ // Code diagnostics are deliberately unrelated to this resolver. A ready
+ // session remains active even when the editor has an error diagnostic.
+ let unrelatedEditorSeverity = DiagnosticSeverity.error
+
+ #expect(unrelatedEditorSeverity == .error)
+ #expect(LSPControlCenterPresenter.serverStatus(
+ isDisabled: false,
+ sessionState: .startingProcess
+ ) == .starting)
+ #expect(LSPControlCenterPresenter.serverStatus(
+ isDisabled: false,
+ sessionState: .initializing
+ ) == .initializing)
+ #expect(LSPControlCenterPresenter.serverStatus(
+ isDisabled: false,
+ sessionState: .ready
+ ) == .active)
+ #expect(LSPControlCenterPresenter.serverStatus(
+ isDisabled: false,
+ sessionState: .stopping
+ ) == .stopping)
+ #expect(LSPControlCenterPresenter.serverStatus(
+ isDisabled: false,
+ sessionState: .stopped
+ ) == .stopped)
+ #expect(LSPControlCenterPresenter.serverStatus(
+ isDisabled: false,
+ sessionState: nil
+ ) == .stopped)
+ #expect(LSPControlCenterPresenter.serverStatus(
+ isDisabled: false,
+ sessionState: .failed(exitCode: 1, message: "crashed")
+ ) == .error)
+ }
+
+ @Test
+ func disabledWorkspaceSettingTakesPrecedenceOverRuntimeState() {
+ #expect(LSPControlCenterPresenter.serverStatus(
+ isDisabled: true,
+ sessionState: .ready
+ ) == .disabled)
+ }
+
+ @Test
+ func negotiatedCapabilitiesAreUnknownUntilInitializationCompletes() {
+ #expect(LSPControlCenterPresenter.negotiatedCapabilityState(
+ .completion,
+ sessionState: .initializing,
+ features: .completion
+ ) == .unknown)
+ #expect(LSPControlCenterPresenter.negotiatedCapabilityState(
+ .completion,
+ sessionState: .ready,
+ features: []
+ ) == .unsupported)
+ #expect(LSPControlCenterPresenter.negotiatedCapabilityState(
+ .completion,
+ sessionState: .ready,
+ features: .completion
+ ) == .available)
+ }
+
+ @Test
+ func versionComesOnlyFromInitializeServerInfo() {
+ #expect(LSPControlCenterPresenter.reportedServerVersion(nil) == nil)
+ #expect(LSPControlCenterPresenter.reportedServerVersion(
+ LanguageServerInfo(name: "gopls", version: nil)
+ ) == nil)
+ #expect(LSPControlCenterPresenter.reportedServerVersion(
+ LanguageServerInfo(name: "gopls", version: " 0.20.0 ")
+ ) == "0.20.0")
+ }
+
+ @Test
+ func integrationAvailabilityIsNotReportedAsAFalseServerCapability() {
+ #expect(LSPControlCenterPresenter.integrationState(
+ isAvailable: false
+ ) == .unsupported)
+ #expect(LSPControlCenterPresenter.integrationState(
+ isAvailable: true
+ ) == .available)
+ #expect(LSPControlCenterPresenter.integrationState(
+ isAvailable: true,
+ isActive: true
+ ) == .active)
+ }
+}
diff --git a/Tests/LitheTests/LanguageFeatureProviderTests.swift b/Tests/LitheTests/LanguageFeatureProviderTests.swift
new file mode 100644
index 000000000..414f338cd
--- /dev/null
+++ b/Tests/LitheTests/LanguageFeatureProviderTests.swift
@@ -0,0 +1,106 @@
+import Foundation
+import Testing
+@testable import Lithe
+
+@Suite("Language feature providers")
+@MainActor
+struct LanguageFeatureProviderTests {
+ @Test
+ func builtinCompletionIncludesLanguageKeywords() throws {
+ let provider = BuiltinLanguageFeatureProvider()
+ let context = LanguageFeatureRequestContext(
+ fileURL: URL(fileURLWithPath: "/tmp/main.go"),
+ text: "fu",
+ position: LanguageServerPosition(line: 0, utf16Column: 2),
+ languageID: "go"
+ )
+ var result: Result<[LanguageServerCompletionItem], Error>?
+
+ try provider.completions(in: context) { result = $0 }
+
+ let resolved = try #require(result)
+ let items = try resolved.get()
+ let item = try #require(items.first { $0.label == "func" })
+ #expect(item.detail == "Go keyword")
+ #expect(item.textEdit?.range.start.utf16Column == 0)
+ #expect(item.textEdit?.range.end.utf16Column == 2)
+ }
+
+ @Test
+ func managerMergesHigherPriorityProviderWithBuiltinFallback() throws {
+ let remote = CompletionFeatureProvider(items: [
+ Self.item(label: "format", detail: "LSP"),
+ Self.item(label: "func", detail: "LSP")
+ ])
+ let manager = LanguageToolingSessionManager(
+ languageFeatureProviders: [remote]
+ )
+ let fileURL = URL(fileURLWithPath: "/tmp/main.go")
+ var result: Result<[LanguageServerCompletionItem], Error>?
+
+ try manager.completions(
+ fileURL: fileURL,
+ text: "f",
+ position: LanguageServerPosition(line: 0, utf16Column: 1),
+ rootURL: fileURL.deletingLastPathComponent()
+ ) { result = $0 }
+
+ let resolved = try #require(result)
+ let items = try resolved.get()
+ #expect(items.first?.label == "format")
+ #expect(items.filter { $0.label == "func" }.count == 1)
+ #expect(items.contains { $0.label == "for" && $0.detail == "Go keyword" })
+ }
+
+ private static func item(label: String, detail: String) -> LanguageServerCompletionItem {
+ LanguageServerCompletionItem(
+ label: label,
+ detail: detail,
+ documentation: nil,
+ insertText: label,
+ sortText: nil,
+ filterText: nil,
+ kind: nil,
+ textEdit: nil,
+ additionalTextEdits: [],
+ data: nil
+ )
+ }
+}
+
+@MainActor
+private final class CompletionFeatureProvider: LanguageFeatureProvider {
+ let id = "test.remote"
+ let priority: LanguageFeatureProviderPriority = .languageServer
+ private let items: [LanguageServerCompletionItem]
+
+ init(items: [LanguageServerCompletionItem]) {
+ self.items = items
+ }
+
+ func supports(_ feature: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool {
+ feature == .completion
+ }
+
+ func completions(
+ in _: LanguageFeatureRequestContext,
+ completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void
+ ) throws {
+ completion(.success(items))
+ }
+
+ func hover(
+ in _: LanguageFeatureRequestContext,
+ completion: @escaping (Result) -> Void
+ ) throws {
+ completion(.success(nil))
+ }
+
+ func navigate(
+ method _: String,
+ in _: LanguageFeatureRequestContext,
+ completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void
+ ) throws {
+ completion(.success([]))
+ }
+}
diff --git a/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift b/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift
new file mode 100644
index 000000000..cc08b623a
--- /dev/null
+++ b/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift
@@ -0,0 +1,118 @@
+import Foundation
+import Testing
+@testable import Lithe
+
+@Suite("Language provider catalog source")
+struct LanguageProviderCatalogSourceTests {
+ @Test
+ func unavailableRustCoreUsesAnExplicitDegradedCompatibilityFallback() {
+ let source = RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader(
+ isAvailable: false,
+ data: nil
+ ))
+
+ let snapshot = source.load()
+
+ #expect(snapshot.origin == .compatibilityFallback)
+ #expect(snapshot.status == .degraded)
+ #expect(snapshot.isDegraded)
+ #expect(snapshot.schemaVersion == nil)
+ #expect(snapshot.issues.count == 1)
+ #expect(snapshot.issues[0].message.contains("compatibility"))
+ #expect(snapshot.catalog.provider(for: URL(fileURLWithPath: "/tmp/main.go"))?.id == "go")
+ }
+
+ @Test
+ func invalidRustPayloadUsesAnExplicitDegradedCompatibilityFallback() {
+ let source = RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader(
+ isAvailable: true,
+ data: Data("{".utf8)
+ ))
+
+ let snapshot = source.load()
+
+ #expect(snapshot.origin == .compatibilityFallback)
+ #expect(snapshot.status == .degraded)
+ #expect(snapshot.issues.count == 1)
+ #expect(snapshot.issues[0].message.contains("could not be decoded"))
+ }
+
+ @Test
+ func rejectedWorkspaceOverridePreservesIssuesAndBuiltinCatalog() {
+ let workspaceURL = URL(fileURLWithPath: "/tmp/catalog-workspace", isDirectory: true)
+ let issuePath = workspaceURL
+ .appendingPathComponent(".lithe/lsp/language-providers.json")
+ .path
+ let source = RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader(
+ isAvailable: true,
+ data: catalogPayload(
+ origin: "builtin",
+ diagnostics: """
+ [{"path":"\(issuePath)","message":"expected value at line 1 column 20"}]
+ """
+ )
+ ))
+
+ let snapshot = source.load(workspaceURL: workspaceURL)
+
+ #expect(snapshot.origin == .builtin)
+ #expect(snapshot.status == .degraded)
+ #expect(snapshot.schemaVersion == 2)
+ #expect(snapshot.issues == [LanguageProviderCatalogIssue(
+ path: issuePath,
+ message: "expected value at line 1 column 20"
+ )])
+ #expect(snapshot.catalog.provider(for: workspaceURL.appendingPathComponent("main.go"))?.id == "go")
+ }
+
+ @Test
+ func acceptedWorkspaceOverrideReportsItsOriginWithoutDegradation() {
+ let workspaceURL = URL(fileURLWithPath: "/tmp/catalog-workspace", isDirectory: true)
+ let source = RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader(
+ isAvailable: true,
+ data: catalogPayload(origin: "workspaceOverride")
+ ))
+
+ let snapshot = source.load(workspaceURL: workspaceURL)
+
+ #expect(snapshot.origin == .workspaceOverride(
+ workspaceURL.standardizedFileURL
+ .appendingPathComponent(".lithe")
+ .appendingPathComponent("lsp")
+ .appendingPathComponent("language-providers.json")
+ ))
+ #expect(snapshot.status == .loaded)
+ #expect(!snapshot.isDegraded)
+ #expect(snapshot.issues.isEmpty)
+ #expect(snapshot.schemaVersion == 2)
+ }
+
+ private func catalogPayload(origin: String, diagnostics: String = "[]") -> Data {
+ Data("""
+ {
+ "version": 2,
+ "origin": "\(origin)",
+ "providers": [{
+ "id": "go",
+ "displayName": "Go",
+ "fileExtensions": ["go"],
+ "fileNames": [],
+ "fileNamePrefixes": [],
+ "capabilities": ["languageServer"],
+ "activationPolicy": "onDemand",
+ "languageId": "go",
+ "languageIdsByExtension": {},
+ "languageIdsByFileName": {}
+ }],
+ "diagnostics": \(diagnostics)
+ }
+ """.utf8)
+ }
+}
+
+private struct CatalogPayloadLoader: RustLanguageProviderCatalogLoading {
+ let isAvailable: Bool
+ let data: Data?
+
+ func languageProviderCatalogData(workspaceURL _: URL?) -> Data? { data }
+}
diff --git a/Tests/LitheTests/LanguageServerDiagnosticTests.swift b/Tests/LitheTests/LanguageServerDiagnosticTests.swift
new file mode 100644
index 000000000..a3f2273c6
--- /dev/null
+++ b/Tests/LitheTests/LanguageServerDiagnosticTests.swift
@@ -0,0 +1,107 @@
+import Foundation
+import Testing
+@testable import Lithe
+
+@Suite("Language server diagnostics")
+struct LanguageServerDiagnosticTests {
+ @Test
+ func bridgePreservesMissingSeverityTagsAndRelatedInformation() throws {
+ let payload = try JSONDecoder().decode(
+ RustCoreBridge.LspClientDiagnosticPayload.self,
+ from: Data(#"""
+ {
+ "range": {
+ "start": { "line": 2, "utf16Column": 4 },
+ "end": { "line": 2, "utf16Column": 9 }
+ },
+ "message": "Example diagnostic",
+ "source": "pyright",
+ "code": "reportGeneralTypeIssues",
+ "tags": [1, 2, 99],
+ "relatedInformation": [{
+ "location": {
+ "uri": "file:///tmp/project/types.py",
+ "range": {
+ "start": { "line": 8, "utf16Column": 2 },
+ "end": { "line": 8, "utf16Column": 7 }
+ }
+ },
+ "message": "Type declared here"
+ }]
+ }
+ """#.utf8)
+ )
+
+ let diagnostic = payload.makeModel()
+
+ #expect(diagnostic.severity == nil)
+ #expect(diagnostic.tags == [1, 2, 99])
+ #expect(diagnostic.relatedInformation.count == 1)
+ #expect(diagnostic.relatedInformation[0].fileURL.path == "/tmp/project/types.py")
+ #expect(diagnostic.relatedInformation[0].range.start.line == 8)
+ #expect(diagnostic.relatedInformation[0].range.start.utf16Column == 2)
+ #expect(diagnostic.relatedInformation[0].message == "Type declared here")
+ }
+
+ @Test
+ func editorDiagnosticMapsMissingSeverityToUnknownAndKnownTagsToUIModels() {
+ let fileURL = URL(fileURLWithPath: "/tmp/project/main.py")
+ let relatedURL = URL(fileURLWithPath: "/tmp/project/types.py")
+ let diagnostic = LanguageServerDiagnostic(
+ range: LanguageServerRange(
+ start: LanguageServerPosition(line: 2, utf16Column: 4),
+ end: LanguageServerPosition(line: 2, utf16Column: 9)
+ ),
+ severity: nil,
+ message: "Example diagnostic",
+ source: "pyright",
+ code: "reportGeneralTypeIssues",
+ tags: [1, 2, 99],
+ relatedInformation: [
+ LanguageServerDiagnosticRelatedInformation(
+ fileURL: relatedURL,
+ range: LanguageServerRange(
+ start: LanguageServerPosition(line: 8, utf16Column: 2),
+ end: LanguageServerPosition(line: 8, utf16Column: 7)
+ ),
+ message: "Type declared here"
+ )
+ ]
+ )
+
+ let editorDiagnostic = EditorDiagnostic(
+ languageServerDiagnostic: diagnostic,
+ fileURL: fileURL
+ )
+
+ #expect(editorDiagnostic.severity == .unknown)
+ #expect(editorDiagnostic.tags == [.unnecessary, .deprecated])
+ #expect(editorDiagnostic.relatedInformation.count == 1)
+ #expect(editorDiagnostic.relatedInformation[0].fileURL == relatedURL.standardizedFileURL)
+ #expect(editorDiagnostic.relatedInformation[0].line == 8)
+ #expect(editorDiagnostic.relatedInformation[0].utf16Column == 2)
+ #expect(editorDiagnostic.relatedInformation[0].message == "Type declared here")
+ }
+
+ @Test(arguments: [Optional.none, .some(0), .some(5), .some(99)])
+ func missingAndUnsupportedSeveritiesNeverBecomeErrors(_ rawSeverity: Int?) {
+ let diagnostic = LanguageServerDiagnostic(
+ range: LanguageServerRange(
+ start: LanguageServerPosition(line: 0, utf16Column: 0),
+ end: LanguageServerPosition(line: 0, utf16Column: 1)
+ ),
+ severity: rawSeverity,
+ message: "Unknown severity",
+ source: "test",
+ code: nil
+ )
+
+ let editorDiagnostic = EditorDiagnostic(
+ languageServerDiagnostic: diagnostic,
+ fileURL: URL(fileURLWithPath: "/tmp/project/main.py")
+ )
+
+ #expect(editorDiagnostic.severity == .unknown)
+ #expect(editorDiagnostic.severity != .error)
+ }
+}
diff --git a/Tests/LitheTests/LanguageServerToolServiceTests.swift b/Tests/LitheTests/LanguageServerToolServiceTests.swift
new file mode 100644
index 000000000..b21d80fb8
--- /dev/null
+++ b/Tests/LitheTests/LanguageServerToolServiceTests.swift
@@ -0,0 +1,377 @@
+import Foundation
+import Testing
+@testable import Lithe
+
+@Suite("Language server tool service")
+@MainActor
+struct LanguageServerToolServiceTests {
+ @Test
+ func customExecutablePersistsAndOverridesAutomaticDiscovery() throws {
+ let customURL = URL(fileURLWithPath: "/custom/bin/gopls")
+ let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/gopls")
+ let store = LanguageServerToolTestStore()
+ let runtime = makeRuntime(
+ executablePaths: [customURL.path, brewURL.path],
+ candidates: [
+ "gopls": [RuntimeToolCandidate(
+ command: "gopls",
+ executableURL: brewURL,
+ source: .homebrew
+ )]
+ ],
+ store: store
+ )
+ let descriptor = goDescriptor()
+ let service = LanguageServerToolService(
+ runtimeService: runtime,
+ processRunner: LanguageServerToolTestProcessRunner(),
+ store: store
+ )
+
+ try service.setCustomExecutablePath(customURL.path, for: descriptor)
+ #expect(service.executableURL(for: descriptor) == customURL)
+ #expect(service.candidates(for: descriptor).map(\.source) == [.custom, .homebrew])
+
+ let restored = LanguageServerToolService(
+ runtimeService: runtime,
+ processRunner: LanguageServerToolTestProcessRunner(),
+ store: store
+ )
+ #expect(restored.customExecutablePath(for: descriptor.id) == customURL.path)
+ restored.clearCustomExecutablePath(for: descriptor.id)
+ #expect(restored.executableURL(for: descriptor) == brewURL)
+ }
+
+ @Test
+ func rejectsNonExecutableCustomPath() {
+ let store = LanguageServerToolTestStore()
+ let service = LanguageServerToolService(
+ runtimeService: makeRuntime(executablePaths: [], candidates: [:], store: store),
+ processRunner: LanguageServerToolTestProcessRunner(),
+ store: store
+ )
+
+ #expect(throws: LanguageServerToolConfigurationError.executableInvalid("/missing/gopls")) {
+ try service.setCustomExecutablePath("/missing/gopls", for: goDescriptor())
+ }
+ }
+
+ @Test
+ func rejectsBrokenProxyAndFallsBackToHomebrewCandidate() {
+ let proxyURL = URL(fileURLWithPath: "/Users/test/.cargo/bin/rust-analyzer")
+ let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/rust-analyzer")
+ let runner = LanguageServerToolTestProcessRunner(resultsByExecutablePath: [
+ proxyURL.path: ProcessResult(
+ output: "error: Unknown binary 'rust-analyzer' in official toolchain",
+ exitCode: 1
+ ),
+ brewURL.path: ProcessResult(output: "rust-analyzer 1.0.0", exitCode: 0)
+ ])
+ let store = LanguageServerToolTestStore()
+ let service = LanguageServerToolService(
+ runtimeService: makeRuntime(
+ executablePaths: [proxyURL.path, brewURL.path],
+ candidates: [
+ "rust-analyzer": [
+ RuntimeToolCandidate(
+ command: "rust-analyzer",
+ executableURL: proxyURL,
+ source: .path
+ ),
+ RuntimeToolCandidate(
+ command: "rust-analyzer",
+ executableURL: brewURL,
+ source: .homebrew
+ )
+ ]
+ ],
+ store: store
+ ),
+ processRunner: runner,
+ store: store
+ )
+
+ let candidates = service.candidates(for: rustDescriptor())
+
+ #expect(candidates.map(\.executableURL) == [brewURL])
+ #expect(service.executableURL(for: rustDescriptor()) == brewURL)
+ #expect(runner.requests.count == 2)
+ #expect(runner.requests.allSatisfy { $0.arguments == ["--version"] })
+ }
+
+ @Test
+ func rejectsCustomExecutableThatFailsCatalogValidation() {
+ let proxyURL = URL(fileURLWithPath: "/Users/test/.cargo/bin/rust-analyzer")
+ let store = LanguageServerToolTestStore()
+ let runner = LanguageServerToolTestProcessRunner(resultsByExecutablePath: [
+ proxyURL.path: ProcessResult(output: "unknown rustup proxy", exitCode: 1)
+ ])
+ let service = LanguageServerToolService(
+ runtimeService: makeRuntime(
+ executablePaths: [proxyURL.path],
+ candidates: [:],
+ store: store
+ ),
+ processRunner: runner,
+ store: store
+ )
+
+ #expect(throws: LanguageServerToolConfigurationError.executableValidationFailed(
+ path: proxyURL.path,
+ message: "unknown rustup proxy"
+ )) {
+ try service.setCustomExecutablePath(proxyURL.path, for: rustDescriptor())
+ }
+ }
+
+ @Test
+ func reportsFoundButUnverifiedWhenCatalogHasNoValidationCommand() {
+ let executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/gopls")
+ let store = LanguageServerToolTestStore()
+ let runner = LanguageServerToolTestProcessRunner()
+ let service = LanguageServerToolService(
+ runtimeService: makeRuntime(
+ executablePaths: [executableURL.path],
+ candidates: [
+ "gopls": [RuntimeToolCandidate(
+ command: "gopls",
+ executableURL: executableURL,
+ source: .homebrew
+ )]
+ ],
+ store: store
+ ),
+ processRunner: runner,
+ store: store
+ )
+
+ #expect(service.executableVerificationState(for: goDescriptor()) == .foundUnverified)
+ #expect(runner.requests.isEmpty)
+ }
+
+ @Test
+ func reportsExecutableVerifiedOnlyAfterValidationCommandSucceeds() {
+ let executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/rust-analyzer")
+ let store = LanguageServerToolTestStore()
+ let runner = LanguageServerToolTestProcessRunner(
+ result: ProcessResult(output: "rust-analyzer 1.0.0", exitCode: 0)
+ )
+ let service = LanguageServerToolService(
+ runtimeService: makeRuntime(
+ executablePaths: [executableURL.path],
+ candidates: [
+ "rust-analyzer": [RuntimeToolCandidate(
+ command: "rust-analyzer",
+ executableURL: executableURL,
+ source: .homebrew
+ )]
+ ],
+ store: store
+ ),
+ processRunner: runner,
+ store: store
+ )
+
+ #expect(service.executableVerificationState(for: rustDescriptor()) == .executableVerified)
+ #expect(runner.requests.count == 1)
+ #expect(runner.requests.first?.arguments == ["--version"])
+ }
+
+ @Test
+ func installsVerifiedFormulaWithArgumentBasedProcessRequest() async throws {
+ let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew")
+ let store = LanguageServerToolTestStore()
+ let runner = LanguageServerToolTestProcessRunner(
+ result: ProcessResult(output: "installed gopls", exitCode: 0)
+ )
+ let runtime = makeRuntime(
+ executablePaths: [brewURL.path],
+ candidates: [
+ "brew": [RuntimeToolCandidate(
+ command: "brew",
+ executableURL: brewURL,
+ source: .homebrew
+ )]
+ ],
+ store: store
+ )
+ let service = LanguageServerToolService(
+ runtimeService: runtime,
+ processRunner: runner,
+ store: store
+ )
+
+ await service.installWithHomebrew(goDescriptor())
+
+ let request = try #require(runner.lastRequest)
+ #expect(request.executablePath == brewURL.path)
+ #expect(request.arguments == ["install", "gopls"])
+ #expect(service.installationState(for: "go") == .installed("installed gopls"))
+ }
+
+ @Test
+ func installationCatalogUsesOfficialFallbacks() {
+ let go = LanguageServerInstallPlan.plan(for: goDescriptor())
+ #expect(go.homebrewFormula == "gopls")
+ #expect(go.officialDownloadURL?.host == "go.dev")
+
+ let swift = LanguageServerInstallPlan.plan(for: LanguageProviderDescriptor(
+ id: "swift",
+ displayName: "Swift",
+ fileExtensions: ["swift"],
+ capabilities: [.languageServer],
+ activationPolicy: .onDemand,
+ languageServerInstallation: LanguageServerInstallationDescriptor(
+ homebrewFormula: nil,
+ officialDownloadURL: URL(string: "https://github.com/swiftlang/sourcekit-lsp")
+ )
+ ))
+ #expect(swift.homebrewFormula == nil)
+ #expect(swift.officialDownloadURL?.host == "github.com")
+ }
+
+ @Test
+ func installationCatalogRejectsUnsafeExecutableMetadata() {
+ let descriptor = LanguageProviderDescriptor(
+ id: "unsafe",
+ displayName: "Unsafe",
+ fileExtensions: ["unsafe"],
+ capabilities: [.languageServer],
+ activationPolicy: .onDemand,
+ languageServerInstallation: LanguageServerInstallationDescriptor(
+ homebrewFormula: "../../bin/tool",
+ officialDownloadURL: URL(string: "http://example.com/tool")
+ )
+ )
+
+ let plan = LanguageServerInstallPlan.plan(for: descriptor)
+ #expect(plan.homebrewFormula == nil)
+ #expect(plan.officialDownloadURL == nil)
+ }
+
+ private func goDescriptor() -> LanguageProviderDescriptor {
+ LanguageProviderDescriptor(
+ id: "go",
+ displayName: "Go",
+ fileExtensions: ["go"],
+ capabilities: [.languageServer],
+ activationPolicy: .onDemand,
+ languageIdentifier: "go",
+ languageServerLaunch: LanguageServerLaunchDescriptor(
+ executableNames: ["gopls"],
+ arguments: []
+ ),
+ languageServerInstallation: LanguageServerInstallationDescriptor(
+ homebrewFormula: "gopls",
+ officialDownloadURL: URL(string: "https://go.dev/gopls/")
+ )
+ )
+ }
+
+ private func rustDescriptor() -> LanguageProviderDescriptor {
+ LanguageProviderDescriptor(
+ id: "rust",
+ displayName: "Rust",
+ fileExtensions: ["rs"],
+ capabilities: [.languageServer],
+ activationPolicy: .onDemand,
+ languageIdentifier: "rust",
+ languageServerLaunch: LanguageServerLaunchDescriptor(
+ executableNames: ["rust-analyzer"],
+ validationArguments: ["--version"]
+ ),
+ languageServerInstallation: LanguageServerInstallationDescriptor(
+ homebrewFormula: "rust-analyzer",
+ officialDownloadURL: URL(string: "https://rust-analyzer.github.io/manual.html#installation")
+ )
+ )
+ }
+
+ private func makeRuntime(
+ executablePaths: Set,
+ candidates: [String: [RuntimeToolCandidate]],
+ store: any KeyValueStore
+ ) -> ProjectRuntimeService {
+ ProjectRuntimeService(
+ runtimeLocator: LanguageServerToolTestRuntimeLocator(executablePaths: executablePaths),
+ store: store,
+ toolDiscovery: LanguageServerToolTestDiscovery(candidatesByCommand: candidates)
+ )
+ }
+}
+
+private struct LanguageServerToolTestRuntimeLocator: RuntimeLocator {
+ let executablePaths: Set
+
+ func environment() -> [String: String] { ["PATH": ""] }
+ func discover() -> RuntimeDiscoveryResult { RuntimeDiscoveryResult(javaRuntimes: [], mavenRuntimes: []) }
+ func validJavaHome(path _: String) -> URL? { nil }
+ func javaRuntime(at _: URL) -> JavaRuntimeCandidate? { nil }
+ func isExecutable(at url: URL) -> Bool { executablePaths.contains(url.standardizedFileURL.path) }
+ func systemMavenExecutable() -> URL? { nil }
+ func mavenExecutable(forHomePath _: String) -> URL? { nil }
+ func mavenRuntime(at _: URL) -> MavenRuntimeCandidate? { nil }
+ func systemJDBExecutable() -> URL? { nil }
+}
+
+private struct LanguageServerToolTestDiscovery: RuntimeToolDiscovery {
+ let candidatesByCommand: [String: [RuntimeToolCandidate]]
+
+ func candidates(
+ for command: String,
+ projectURL _: URL?,
+ environment _: [String: String]
+ ) -> [RuntimeToolCandidate] {
+ candidatesByCommand[command] ?? []
+ }
+
+ func guidance(
+ for command: String,
+ projectURL _: URL?,
+ environment _: [String: String]
+ ) -> RuntimeToolGuidance {
+ RuntimeToolGuidance(
+ command: command,
+ summary: "Missing \(command).",
+ recovery: "Install it."
+ )
+ }
+}
+
+private final class LanguageServerToolTestProcessRunner: ProcessRunner, @unchecked Sendable {
+ private let lock = NSLock()
+ private let result: ProcessResult
+ private let resultsByExecutablePath: [String: ProcessResult]
+ private var recordedRequests: [ProcessRequest] = []
+
+ init(
+ result: ProcessResult = ProcessResult(output: "", exitCode: 0),
+ resultsByExecutablePath: [String: ProcessResult] = [:]
+ ) {
+ self.result = result
+ self.resultsByExecutablePath = resultsByExecutablePath
+ }
+
+ var lastRequest: ProcessRequest? {
+ lock.withLock { recordedRequests.last }
+ }
+
+ var requests: [ProcessRequest] {
+ lock.withLock { recordedRequests }
+ }
+
+ func run(_ request: ProcessRequest) -> ProcessResult {
+ lock.withLock { recordedRequests.append(request) }
+ return resultsByExecutablePath[request.executablePath] ?? result
+ }
+}
+
+private final class LanguageServerToolTestStore: KeyValueStore {
+ private var values: [String: Any] = [:]
+
+ func data(forKey key: String) -> Data? { values[key] as? Data }
+ func object(forKey key: String) -> Any? { values[key] }
+ func string(forKey key: String) -> String? { values[key] as? String }
+ func stringArray(forKey key: String) -> [String]? { values[key] as? [String] }
+ func set(_ value: Any?, forKey key: String) { values[key] = value }
+}
diff --git a/Tests/LitheTests/RealGoplsIntegrationTests.swift b/Tests/LitheTests/RealGoplsIntegrationTests.swift
new file mode 100644
index 000000000..56311101f
--- /dev/null
+++ b/Tests/LitheTests/RealGoplsIntegrationTests.swift
@@ -0,0 +1,146 @@
+import Foundation
+import Testing
+@testable import Lithe
+
+@Suite("Real gopls integration")
+@MainActor
+struct RealGoplsIntegrationTests {
+ @Test
+ func goplsRunsThroughLanguageToolingSessionManager() async throws {
+ let environment = ProcessInfo.processInfo.environment
+ guard environment["LITHE_RUN_GOPLS_INTEGRATION"] == "1" else { return }
+
+ let goplsURL = URL(fileURLWithPath: environment["LITHE_GOPLS_PATH"]
+ ?? FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent(".go/bin/gopls").path)
+ #expect(FileManager.default.isExecutableFile(atPath: goplsURL.path))
+ guard FileManager.default.isExecutableFile(atPath: goplsURL.path) else { return }
+
+ let core = RustCoreBridge()
+ #expect(core.isAvailable)
+ guard core.isAvailable else { return }
+
+ let rootURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("lithe-real-gopls-\(UUID().uuidString)", isDirectory: true)
+ let sourceURL = rootURL.appendingPathComponent("main.go")
+ let source = """
+ package main
+
+ import "fmt"
+
+ func main() {
+ fmt.Pr
+ }
+
+ """
+ try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
+ try "module example.com/lithegopls\n\ngo 1.24\n".write(
+ to: rootURL.appendingPathComponent("go.mod"),
+ atomically: true,
+ encoding: .utf8
+ )
+ try source.write(to: sourceURL, atomically: true, encoding: .utf8)
+
+ let descriptor = LanguageProviderDescriptor(
+ id: "go",
+ displayName: "Go",
+ fileExtensions: ["go"],
+ capabilities: [.languageServer],
+ activationPolicy: .onDemand,
+ languageIdentifier: "go"
+ )
+ let session = StdioLanguageServerSession(
+ executableURL: goplsURL,
+ arguments: [],
+ environment: environment,
+ process: MacRawProcessSession(),
+ core: core
+ )
+ let runtime = RealGoplsLanguageRuntime(descriptor: descriptor, session: session)
+ let manager = LanguageToolingSessionManager(
+ catalog: LanguageProviderCatalog(descriptors: [descriptor]),
+ runtimes: [runtime],
+ core: core
+ )
+ defer {
+ manager.stopAll()
+ try? FileManager.default.removeItem(at: rootURL)
+ }
+
+ try manager.synchronizeLanguageServer(
+ for: sourceURL,
+ text: source,
+ rootURL: rootURL
+ )
+ let initialized = await waitUntil {
+ manager.languageServerFeatures["go"]?.contains(.completion) == true
+ && manager.languageServerFeatures["go"]?.contains(.hover) == true
+ }
+ #expect(initialized)
+ guard initialized else { return }
+
+ var completionResult: Result<[LanguageServerCompletionItem], Error>?
+ try manager.completions(
+ fileURL: sourceURL,
+ text: source,
+ position: LanguageServerPosition(line: 5, utf16Column: 10),
+ rootURL: rootURL
+ ) { completionResult = $0 }
+ let completed = await waitUntil { completionResult != nil }
+ #expect(completed)
+ let resolvedCompletions = try #require(completionResult)
+ let completionItems = try resolvedCompletions.get()
+ #expect(!completionItems.isEmpty)
+ #expect(completionItems.contains { $0.label.lowercased().contains("print") })
+
+ var hoverResult: Result?
+ try manager.hover(
+ fileURL: sourceURL,
+ text: source,
+ position: LanguageServerPosition(line: 5, utf16Column: 5),
+ rootURL: rootURL
+ ) { hoverResult = $0 }
+ let hovered = await waitUntil { hoverResult != nil }
+ #expect(hovered)
+ let resolvedHover = try #require(hoverResult)
+ let hover = try resolvedHover.get()
+ #expect(hover?.contents.isEmpty == false)
+
+ manager.closeDocument(sourceURL)
+ try manager.synchronizeLanguageServer(
+ for: sourceURL,
+ text: source,
+ rootURL: rootURL
+ )
+ manager.stopLanguageServer(providerID: "go")
+ let stopped = await waitUntil { !session.isRunning }
+ #expect(stopped)
+ }
+
+ private func waitUntil(
+ timeout: TimeInterval = 15,
+ condition: @escaping @MainActor () -> Bool
+ ) async -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if condition() { return true }
+ try? await Task.sleep(nanoseconds: 50_000_000)
+ }
+ return condition()
+ }
+}
+
+@MainActor
+private final class RealGoplsLanguageRuntime: LanguageProviderRuntime {
+ let descriptor: LanguageProviderDescriptor
+ let supportsLanguageServerSession = true
+ private let session: any LanguageServerSession
+
+ init(descriptor: LanguageProviderDescriptor, session: any LanguageServerSession) {
+ self.descriptor = descriptor
+ self.session = session
+ }
+
+ func makeLanguageServerSession() -> (any LanguageServerSession)? { session }
+ func makeDebugAdapterSession() -> (any DebugAdapterSession)? { nil }
+}
diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift
index eaad60751..af62ff5f6 100644
--- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift
+++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift
@@ -83,24 +83,33 @@ struct RunConfigurationIntegrationTests {
}
@Test
- func languageProviderCatalogIsOnDemandAndDescribesCrossLanguageCapabilities() {
+ func languageProviderCatalogKeepsOnlyCompatibilityFallbackInSwiftWhenRustCoreIsUnavailable() {
let catalog = LanguageProviderCatalog.standard
let go = catalog.provider(for: URL(fileURLWithPath: "/tmp/cmd/main.go"))
let python = catalog.provider(for: URL(fileURLWithPath: "/tmp/api/server.py"))
+ let node = catalog.provider(for: URL(fileURLWithPath: "/tmp/web/App.tsx"))
#expect(go?.id == "go")
#expect(python?.id == "python")
+ #expect(node?.id == "node")
+ #expect(node?.languageIdentifier(for: URL(fileURLWithPath: "/tmp/web/App.tsx")) == "typescriptreact")
#expect(go?.activationPolicy == .onDemand)
#expect(go?.capabilities.contains(.languageServer) == true)
#expect(go?.capabilities.contains(.debugAdapter) == true)
#expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java"))?.capabilities.contains(.debugAdapter) == false)
+ if !RustCoreBridge().isAvailable {
+ #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Package.swift")) == nil)
+ #expect(catalog.provider(for: URL(fileURLWithPath: "/tmp/Dockerfile")) == nil)
+ }
}
@Test
func standardLanguagePackRegistryDerivesAllFocusedRegistries() {
let registry = LanguagePackRegistry.standard()
+ let providerIDs = registry.packs.map(\.descriptor.id)
+ let providerIDSet = Set(providerIDs)
- #expect(registry.packs.map { $0.descriptor.id } == ["java", "go", "python", "node", "rust"])
+ #expect(providerIDs.starts(with: ["java", "go", "python", "node", "rust"]))
#expect(registry.catalog.provider(for: URL(fileURLWithPath: "/tmp/main.go"))?.id == "go")
#expect(registry.runProviders.provider(for: URL(fileURLWithPath: "/tmp/main.py"))?.descriptor.id == "python")
#expect(registry.testProviders.provider(id: "python")?.descriptor.id == "python")
@@ -111,11 +120,14 @@ struct RunConfigurationIntegrationTests {
#expect(registry.pack(id: "go")?.toolchainProviders.contains {
$0.languageProviderID == "go" && $0.identifiers.contains("project-go")
} == true)
- #expect(registry.pack(id: "go")?.languageServerLaunch?.executableNames == ["gopls"])
#expect(registry.pack(id: "go")?.debugAdapterLaunch?.executableNames == ["dlv"])
#expect(registry.pack(id: "python")?.debugAdapterLaunch?.adapterID == "python")
#expect(registry.pack(id: "rust")?.debugAdapterLaunch?.fallbacks.first?.executableName == "xcrun")
#expect(registry.pack(id: "java")?.debugAdapterLaunch?.adapterID == "java")
+ if !RustCoreBridge().isAvailable {
+ #expect(providerIDSet == ["java", "go", "python", "node", "rust"])
+ #expect(registry.catalog.provider(for: URL(fileURLWithPath: "/tmp/Dockerfile")) == nil)
+ }
}
@Test
@@ -140,6 +152,32 @@ struct RunConfigurationIntegrationTests {
#expect(registry.testProviders.provider(id: "ruby")?.descriptor.id == "ruby")
}
+ @Test
+ func projectCatalogCanCreateRuntimeForANewProviderDynamically() {
+ let factory = TestLanguageProviderRuntimeFactory()
+ let manager = LanguageToolingSessionManager(
+ catalog: LanguageProviderCatalog(descriptors: []),
+ runtimeFactory: factory
+ )
+ let descriptor = LanguageProviderDescriptor(
+ id: "roc",
+ displayName: "Roc",
+ fileExtensions: ["roc"],
+ capabilities: [.languageServer, .debugAdapter],
+ activationPolicy: .onDemand,
+ languageIdentifier: "roc",
+ languageServerLaunch: LanguageServerLaunchDescriptor(
+ executableNames: ["roc_language_server"],
+ arguments: []
+ )
+ )
+
+ manager.updateCatalog(LanguageProviderCatalog(descriptors: [descriptor]))
+
+ #expect(manager.supportsGenericDebugging(for: URL(fileURLWithPath: "/tmp/main.roc")))
+ #expect(factory.createdDescriptors == [descriptor])
+ }
+
@Test
func genericDebugCapabilityReflectsTheRuntimeFactoryWithoutStartingIt() throws {
let catalog = LanguageProviderCatalog.standard
@@ -158,7 +196,7 @@ struct RunConfigurationIntegrationTests {
}]
).first { $0.descriptor.id == "node" })
let javaDescriptor = try #require(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java")))
- let javaRuntime = TestLanguageProviderRuntime(descriptor: javaDescriptor)
+ let javaRuntime = TestDebugLanguageProviderRuntime(descriptor: javaDescriptor)
let manager = LanguageToolingSessionManager(
catalog: catalog,
runtimes: [nodeRuntime, javaRuntime]
@@ -172,8 +210,14 @@ struct RunConfigurationIntegrationTests {
@Test
func aFutureJavaDAPRuntimeCanOverrideTheLegacyDebugBoundary() throws {
- let catalog = LanguageProviderCatalog.standard
- let javaDescriptor = try #require(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java")))
+ let javaDescriptor = LanguageProviderDescriptor(
+ id: "java",
+ displayName: "Java",
+ fileExtensions: ["java"],
+ capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing],
+ activationPolicy: .onDemand
+ )
+ let catalog = LanguageProviderCatalog(descriptors: [javaDescriptor])
let runtime = TestDebugLanguageProviderRuntime(
descriptor: javaDescriptor,
supportsDebugAdapter: true
@@ -217,9 +261,9 @@ struct RunConfigurationIntegrationTests {
func macToolDiscoveryReportsProjectHomebrewAndXcodeSources() {
let root = URL(fileURLWithPath: "/tmp/mac-tool-project", isDirectory: true)
let executablePaths: Set = [
- root.appendingPathComponent(".lithe/toolchains/bin/gopls").path,
- "/custom/bin/gopls",
- "/opt/homebrew/bin/gopls",
+ root.appendingPathComponent(".lithe/toolchains/bin/dlv").path,
+ "/custom/bin/dlv",
+ "/opt/homebrew/bin/dlv",
"/Library/Developer/CommandLineTools/usr/bin/lldb-dap"
]
let discovery = MacRuntimeToolDiscovery(
@@ -228,15 +272,15 @@ struct RunConfigurationIntegrationTests {
)
let goCandidates = discovery.candidates(
- for: "gopls",
+ for: "dlv",
projectURL: root,
environment: ["PATH": "/custom/bin"]
)
#expect(goCandidates.map(\.source) == [.project, .path, .homebrew])
#expect(goCandidates.map(\.executableURL.path) == [
- root.appendingPathComponent(".lithe/toolchains/bin/gopls").path,
- "/custom/bin/gopls",
- "/opt/homebrew/bin/gopls"
+ root.appendingPathComponent(".lithe/toolchains/bin/dlv").path,
+ "/custom/bin/dlv",
+ "/opt/homebrew/bin/dlv"
])
let lldbCandidates = discovery.candidates(
@@ -249,6 +293,23 @@ struct RunConfigurationIntegrationTests {
.recovery.contains("LITHE_JAVA_DEBUG_PATH"))
}
+ @Test
+ func macToolDiscoveryFindsGoLanguageServerInUserBin() {
+ let discovery = MacRuntimeToolDiscovery(
+ homeDirectoryURL: URL(fileURLWithPath: "/tmp/home", isDirectory: true),
+ isExecutable: { $0.path == "/tmp/home/.go/bin/gopls" }
+ )
+
+ let candidates = discovery.candidates(
+ for: "gopls",
+ projectURL: nil,
+ environment: ["PATH": "/usr/bin"]
+ )
+
+ #expect(candidates.first?.executableURL.path == "/tmp/home/.go/bin/gopls")
+ #expect(candidates.first?.source == .environment)
+ }
+
@Test
func legacyJavaDoesNotAcceptGenericDAPBreakpointsWithoutAnAdapter() throws {
let source = URL(fileURLWithPath: "/tmp/Main.java")
@@ -731,17 +792,15 @@ struct RunConfigurationIntegrationTests {
}
@Test
- func editorDiagnosticsMergeLanguagesAndMapLSPSeverities() throws {
+ func editorDiagnosticsMapLanguageServerFilesAndSeverities() throws {
let javaURL = URL(fileURLWithPath: "/tmp/mixed/src/Main.java")
let pythonURL = URL(fileURLWithPath: "/tmp/mixed/api/main.py")
- let javaDiagnostic = EditorDiagnostic(
- id: "java-warning",
- fileURL: javaURL,
- line: 2,
- utf16Column: 4,
- endLine: 2,
- endUTF16Column: 8,
- severity: .warning,
+ let javaDiagnostic = LanguageServerDiagnostic(
+ range: LanguageServerRange(
+ start: LanguageServerPosition(line: 2, utf16Column: 4),
+ end: LanguageServerPosition(line: 2, utf16Column: 8)
+ ),
+ severity: 2,
message: "Java warning",
source: "jdtls",
code: "java-warning",
@@ -762,19 +821,21 @@ struct RunConfigurationIntegrationTests {
)
}
- let merged = EditorDiagnostic.merging(
- [javaURL: [javaDiagnostic]],
- languageServerDiagnostics: [pythonURL: languageDiagnostics]
+ let mapped = EditorDiagnostic.fromLanguageServerDiagnostics(
+ [javaURL: [javaDiagnostic], pythonURL: languageDiagnostics]
)
- #expect(merged[javaURL.standardizedFileURL] == [javaDiagnostic])
- let python = try #require(merged[pythonURL.standardizedFileURL])
+ let java = try #require(mapped[javaURL.standardizedFileURL])
+ #expect(java.count == 1)
+ #expect(java[0].severity == .warning)
+ #expect(java[0].source == "jdtls")
+ let python = try #require(mapped[pythonURL.standardizedFileURL])
#expect(python.map(\.severity) == [.error, .warning, .information, .hint])
#expect(python.allSatisfy { $0.source == "pyright" })
}
@Test
- func editorDiagnosticsDeduplicateTheSameProviderResult() throws {
+ func editorDiagnosticsPreserveTheAuthoritativeLanguageServerResult() throws {
let fileURL = URL(fileURLWithPath: "/tmp/mixed/src/Main.java")
let lsp = LanguageServerDiagnostic(
range: LanguageServerRange(
@@ -783,17 +844,17 @@ struct RunConfigurationIntegrationTests {
),
severity: 1,
message: "Cannot resolve symbol",
- source: "jdtls",
+ source: "java",
code: "resolve"
)
- let existing = EditorDiagnostic(languageServerDiagnostic: lsp, fileURL: fileURL)
- let merged = EditorDiagnostic.merging(
- [fileURL: [existing]],
- languageServerDiagnostics: [fileURL: [lsp]]
+ let mapped = EditorDiagnostic.fromLanguageServerDiagnostics(
+ [fileURL: [lsp]]
)
- #expect(merged[fileURL.standardizedFileURL] == [existing])
+ let diagnostics = try #require(mapped[fileURL.standardizedFileURL])
+ #expect(diagnostics.count == 1)
+ #expect(diagnostics[0] == EditorDiagnostic(languageServerDiagnostic: lsp, fileURL: fileURL))
}
@Test
@@ -928,581 +989,781 @@ struct RunConfigurationIntegrationTests {
}
@Test
- func toolingSessionsStartLazilyAndReuseOneSessionPerProvider() throws {
+ func languageToolingSessionsKeepSwiftLSPAtTheRustHostBoundary() throws {
let descriptor = try #require(LanguageProviderCatalog.standard.provider(
for: URL(fileURLWithPath: "/tmp/main.go")
))
- let runtime = TestLanguageProviderRuntime(descriptor: descriptor)
- let manager = LanguageToolingSessionManager(
- catalog: .standard,
- runtimes: [runtime]
+ let runtimeService = ProjectRuntimeService(
+ runtimeLocator: RunTestRuntimeLocator(),
+ store: RunTestKeyValueStore()
+ )
+ let process = RecordingRawProcessSession()
+ let runtime = StdioLanguageProviderRuntime(
+ descriptor: descriptor,
+ runtimeService: runtimeService,
+ processFactory: { process }
+ )
+ let manager = LanguageToolingSessionManager(runtimes: [runtime])
+ let root = URL(fileURLWithPath: "/tmp/go-project", isDirectory: true)
+ let source = root.appendingPathComponent("main.go")
+
+ #expect(manager.activeLanguageServerIDs.isEmpty)
+ #expect(manager.features(for: source).contains(.completion))
+ #expect(manager.supportsGenericEditing(for: source))
+ try manager.synchronizeLanguageServer(
+ for: source,
+ text: "package main\nfunc main() {}\n",
+ rootURL: root
)
+ #expect(process.requests.isEmpty)
+ #expect(process.sentData.isEmpty)
+
+ var completionResult: Result<[LanguageServerCompletionItem], Error>?
+ try manager.completions(
+ fileURL: source,
+ text: "fu",
+ position: LanguageServerPosition(line: 0, utf16Column: 2),
+ rootURL: root
+ ) { result in
+ completionResult = result
+ }
+ #expect(try completionResult?.get().contains { $0.label == "func" } == true)
+
+ #expect(throws: LanguageToolingSessionError.self) {
+ try manager.hover(
+ fileURL: source,
+ text: "package main\n",
+ position: LanguageServerPosition(line: 0, utf16Column: 1),
+ rootURL: root
+ ) { _ in }
+ }
+ #expect(throws: LanguageToolingSessionError.self) {
+ try manager.format(fileURL: source, text: "package main\n", rootURL: root) { _ in }
+ }
#expect(manager.activeLanguageServerIDs.isEmpty)
+ }
- let first = try manager.activateLanguageServer(
- for: URL(fileURLWithPath: "/tmp/main.go"),
- rootURL: URL(fileURLWithPath: "/tmp/project")
+ @Test
+ func languageServerFailureClearsActiveSessionState() async throws {
+ let descriptor = LanguageProviderDescriptor(
+ id: "swift",
+ displayName: "Swift",
+ fileExtensions: ["swift"],
+ capabilities: [.languageServer, .formatting],
+ activationPolicy: .onDemand,
+ languageIdentifier: "swift",
+ languageServerLaunch: LanguageServerLaunchDescriptor(
+ executableNames: ["sourcekit-lsp"]
+ )
+ )
+ let runtimeService = ProjectRuntimeService(
+ runtimeLocator: RunTestRuntimeLocator(),
+ store: RunTestKeyValueStore()
+ )
+ let process = RecordingRawProcessSession()
+ let source = URL(fileURLWithPath: "/tmp/swift-project/App.swift")
+ let runtime = StdioLanguageProviderRuntime(
+ descriptor: descriptor,
+ runtimeService: runtimeService,
+ processFactory: { process },
+ languageServerLaunch: descriptor.languageServerLaunch,
+ languageServerCore: TestLspClientCore(diagnosticURL: source)
)
- let second = try manager.activateLanguageServer(
- for: URL(fileURLWithPath: "/tmp/other.go"),
- rootURL: URL(fileURLWithPath: "/tmp/project")
+ let manager = LanguageToolingSessionManager(
+ catalog: LanguageProviderCatalog(descriptors: [descriptor]),
+ runtimes: [runtime]
)
- _ = first
- _ = second
- #expect(runtime.makeCount == 1)
- #expect(runtime.languageServer.startCount == 1)
- #expect(manager.activeLanguageServerIDs == ["go"])
+ try manager.synchronizeLanguageServer(
+ for: source,
+ text: "struct App {}\n",
+ rootURL: source.deletingLastPathComponent()
+ )
+ #expect(manager.activeLanguageServerIDs.isEmpty)
+ #expect(manager.languageServerStates["swift"] == .initializing)
+
+ process.emitJSON([
+ "jsonrpc": "2.0",
+ "id": "1",
+ "result": ["capabilities": [:]]
+ ])
+ await Self.drainMainActorTasks()
+
+ #expect(manager.activeLanguageServerIDs == ["swift"])
+ #expect(manager.languageServerStates["swift"] == .ready)
+
+ process.terminate(exitCode: 1)
+ await Self.drainMainActorTasks()
- manager.stopAll()
- #expect(runtime.languageServer.stopCount == 1)
#expect(manager.activeLanguageServerIDs.isEmpty)
+ #expect(manager.languageServerFeatures["swift"] == nil)
+ guard case .failed(let exitCode, let message)? = manager.languageServerStates["swift"] else {
+ Issue.record("Expected the Swift language server to fail after process termination")
+ return
+ }
+ #expect(exitCode == 1)
+ #expect(message?.contains("exit code 1") == true)
}
@Test
- func toolingSessionsReplaceExitedOrWrongWorkspaceProcesses() throws {
- let descriptor = try #require(LanguageProviderCatalog.standard.provider(
- for: URL(fileURLWithPath: "/tmp/main.go")
- ))
- let runtime = TestLanguageProviderRuntime(descriptor: descriptor)
- let manager = LanguageToolingSessionManager(catalog: .standard, runtimes: [runtime])
- let firstRoot = URL(fileURLWithPath: "/tmp/first-project")
- let secondRoot = URL(fileURLWithPath: "/tmp/second-project")
+ func initializeErrorNeverActivatesLanguageServer() async throws {
+ let harness = makeLanguageServerHarness()
- let first = try manager.activateLanguageServer(
- for: firstRoot.appendingPathComponent("main.go"),
- rootURL: firstRoot
+ try harness.manager.synchronizeLanguageServer(
+ for: harness.source,
+ text: "struct App {}\n",
+ rootURL: harness.root
)
- first.stop()
- let restarted = try manager.activateLanguageServer(
- for: firstRoot.appendingPathComponent("other.go"),
- rootURL: firstRoot
+ #expect(harness.manager.languageServerStates["swift"] == .initializing)
+ #expect(harness.manager.activeLanguageServerIDs.isEmpty)
+
+ harness.process.emitJSON([
+ "jsonrpc": "2.0",
+ "id": "1",
+ "error": ["code": -32603, "message": "initialize rejected"]
+ ])
+ await Self.drainMainActorTasks()
+
+ #expect(harness.manager.activeLanguageServerIDs.isEmpty)
+ #expect(!harness.process.isRunning)
+ guard case .failed(_, let message)? = harness.manager.languageServerStates["swift"] else {
+ Issue.record("Expected initialize error to fail the language server")
+ return
+ }
+ #expect(message?.contains("initialize failed") == true)
+ #expect(!Self.framedMessages(harness.process.sentData).contains {
+ $0.contains("textDocument/didOpen")
+ })
+ }
+
+ @Test
+ func invalidInitializeResultNeverMarksLanguageServerReady() async throws {
+ let harness = makeLanguageServerHarness()
+
+ try harness.manager.synchronizeLanguageServer(
+ for: harness.source,
+ text: "struct App {}\n",
+ rootURL: harness.root
)
- let moved = try manager.activateLanguageServer(
- for: secondRoot.appendingPathComponent("main.go"),
- rootURL: secondRoot
+ harness.process.emitJSON([
+ "jsonrpc": "2.0",
+ "id": "1",
+ "result": NSNull()
+ ])
+ await Self.drainMainActorTasks()
+
+ #expect(harness.manager.activeLanguageServerIDs.isEmpty)
+ #expect(!harness.process.isRunning)
+ guard case .failed(_, let message)? = harness.manager.languageServerStates["swift"] else {
+ Issue.record("Expected invalid initialize result to fail the language server")
+ return
+ }
+ #expect(message?.contains("invalid result") == true)
+ }
+
+ @Test
+ func initializeTimeoutTransitionsInitializingSessionToFailed() async throws {
+ let harness = makeLanguageServerHarness(initializeTimeoutNanoseconds: 2_000_000)
+
+ try harness.manager.synchronizeLanguageServer(
+ for: harness.source,
+ text: "struct App {}\n",
+ rootURL: harness.root
)
+ #expect(harness.manager.languageServerStates["swift"] == .initializing)
+ #expect(harness.manager.activeLanguageServerIDs.isEmpty)
- #expect(first !== restarted)
- #expect(restarted !== moved)
- #expect(runtime.makeCount == 3)
- #expect(runtime.languageServers.map(\.startCount) == [1, 1, 1])
- #expect(runtime.languageServers[1].stopCount == 1)
+ let didFail = await Self.waitForMainActorCondition {
+ if case .failed = harness.manager.languageServerStates["swift"] {
+ return true
+ }
+ return false
+ }
+
+ #expect(didFail)
+ #expect(harness.manager.activeLanguageServerIDs.isEmpty)
+ #expect(!harness.process.isRunning)
+ guard case .failed(_, let message)? = harness.manager.languageServerStates["swift"] else {
+ Issue.record("Expected initialize timeout to fail the language server")
+ return
+ }
+ #expect(message?.contains("initialize timed out") == true)
}
@Test
- func stoppingToolingSessionsClearsProjectScopedBreakpoints() throws {
- let descriptor = try #require(LanguageProviderCatalog.standard.provider(
- for: URL(fileURLWithPath: "/tmp/main.py")
- ))
- let runtime = TestDebugLanguageProviderRuntime(descriptor: descriptor)
- let manager = LanguageToolingSessionManager(catalog: .standard, runtimes: [runtime])
- let firstRoot = URL(fileURLWithPath: "/tmp/first-python-project")
- let firstSource = firstRoot.appendingPathComponent("main.py")
- try manager.setDebugBreakpoints([DebugSourceBreakpoint(line: 12)], in: firstSource)
+ func pendingCompletionFailsExactlyOnceWhenServerTerminates() async throws {
+ let harness = makeLanguageServerHarness()
+ try harness.manager.synchronizeLanguageServer(
+ for: harness.source,
+ text: "struct App { let title = 1 }\n",
+ rootURL: harness.root
+ )
+ Self.emitSuccessfulInitialize(on: harness.process)
+ await Self.drainMainActorTasks()
- _ = try manager.activateDebugAdapter(for: firstSource, rootURL: firstRoot)
- #expect(runtime.debugAdapters.first?.breakpointUpdates.count == 1)
- manager.stopAll()
+ var completionResult: Result<[LanguageServerCompletionItem], Error>?
+ var completionCount = 0
+ try harness.manager.completions(
+ fileURL: harness.source,
+ text: "struct App { let ti = 1 }\n",
+ position: LanguageServerPosition(line: 0, utf16Column: 19),
+ rootURL: harness.root
+ ) { result in
+ completionCount += 1
+ completionResult = result
+ }
- let secondRoot = URL(fileURLWithPath: "/tmp/second-python-project")
- _ = try manager.activateDebugAdapter(
- for: secondRoot.appendingPathComponent("main.py"),
- rootURL: secondRoot
+ harness.process.terminate(exitCode: 9)
+ await Self.drainMainActorTasks()
+
+ #expect(completionCount == 1)
+ guard case .failure(let error)? = completionResult else {
+ Issue.record("Expected pending completion to fail when the server terminates")
+ return
+ }
+ #expect(error.localizedDescription.contains("exit code 9"))
+ #expect(harness.manager.activeLanguageServerIDs.isEmpty)
+ }
+
+ @Test
+ func requestTimeoutSendsCancellationAndReturnsFailure() async throws {
+ let harness = makeLanguageServerHarness(requestTimeoutNanoseconds: 2_000_000)
+ try harness.manager.synchronizeLanguageServer(
+ for: harness.source,
+ text: "struct App { let title = 1 }\n",
+ rootURL: harness.root
+ )
+ Self.emitSuccessfulInitialize(on: harness.process)
+ await Self.drainMainActorTasks()
+
+ var completionResult: Result<[LanguageServerCompletionItem], Error>?
+ try harness.manager.completions(
+ fileURL: harness.source,
+ text: "struct App { let ti = 1 }\n",
+ position: LanguageServerPosition(line: 0, utf16Column: 19),
+ rootURL: harness.root
+ ) { completionResult = $0 }
+
+ let didComplete = await Self.waitForMainActorCondition {
+ completionResult != nil
+ }
+
+ #expect(didComplete)
+ guard case .failure(let error)? = completionResult else {
+ Issue.record("Expected completion timeout to return failure")
+ return
+ }
+ #expect(error.localizedDescription.contains("timed out"))
+ #expect(harness.process.sentData.compactMap(Self.framedJSON).contains { message in
+ message["method"] as? String == "$/cancelRequest"
+ && (message["params"] as? [String: Any])?["id"] as? String == "2"
+ })
+ #expect(harness.manager.languageServerStates["swift"] == .ready)
+ }
+
+ @Test
+ func didOpenWriteFailureFailsSessionAndRetriesWithFullOpen() async throws {
+ let harness = makeLanguageServerHarness()
+ harness.process.sendFailurePredicate = { data in
+ String(decoding: data, as: UTF8.self).contains("textDocument/didOpen")
+ }
+
+ try harness.manager.synchronizeLanguageServer(
+ for: harness.source,
+ text: "struct App {}\n",
+ rootURL: harness.root
)
- #expect(runtime.debugAdapters.count == 2)
- #expect(runtime.debugAdapters.last?.breakpointUpdates.isEmpty == true)
+ Self.emitSuccessfulInitialize(on: harness.process)
+ await Self.drainMainActorTasks()
+
+ #expect(harness.manager.activeLanguageServerIDs.isEmpty)
+ #expect(!harness.process.isRunning)
+ guard case .failed(_, let message)? = harness.manager.languageServerStates["swift"] else {
+ Issue.record("Expected didOpen transport failure to fail the session")
+ return
+ }
+ #expect(message?.contains("transport failed") == true)
+ #expect(!Self.framedMessages(harness.process.sentData).contains {
+ $0.contains("textDocument/didOpen")
+ })
+
+ harness.process.sendFailurePredicate = nil
+ try harness.manager.synchronizeLanguageServer(
+ for: harness.source,
+ text: "struct App { let retried = true }\n",
+ rootURL: harness.root
+ )
+ Self.emitSuccessfulInitialize(on: harness.process)
+ await Self.drainMainActorTasks()
+
+ let openMessages = Self.framedMessages(harness.process.sentData).filter {
+ $0.contains("textDocument/didOpen")
+ }
+ #expect(openMessages.count == 1)
+ #expect(openMessages[0].contains(#""version":1"#))
+ #expect(openMessages[0].contains("retried"))
+ #expect(harness.manager.languageServerStates["swift"] == .ready)
}
@Test
- func stdioLanguageProvidersInitializeOnlyWhenActivated() async throws {
- let descriptor = try #require(LanguageProviderCatalog.standard.provider(
- for: URL(fileURLWithPath: "/tmp/main.go")
- ))
+ func providerStopAndReconfigurationClearOnlyOwnedDiagnostics() async throws {
+ let root = URL(fileURLWithPath: "/tmp/lithe-owned-diagnostics", isDirectory: true)
+ let swiftSource = root.appendingPathComponent("App.swift")
+ let goSource = root.appendingPathComponent("main.go")
+ let swiftDescriptor = LanguageProviderDescriptor(
+ id: "swift",
+ displayName: "Swift",
+ fileExtensions: ["swift"],
+ capabilities: [.languageServer],
+ activationPolicy: .onDemand,
+ languageIdentifier: "swift"
+ )
+ let goDescriptor = LanguageProviderDescriptor(
+ id: "go",
+ displayName: "Go",
+ fileExtensions: ["go"],
+ capabilities: [.languageServer],
+ activationPolicy: .onDemand,
+ languageIdentifier: "go"
+ )
+ let swiftProcess = RecordingRawProcessSession()
+ let goProcess = RecordingRawProcessSession()
+ let swiftSession = StdioLanguageServerSession(
+ executableURL: URL(fileURLWithPath: "/usr/bin/sourcekit-lsp"),
+ arguments: [],
+ environment: [:],
+ process: swiftProcess,
+ core: TestLspClientCore(diagnosticURL: swiftSource)
+ )
+ let goSession = StdioLanguageServerSession(
+ executableURL: URL(fileURLWithPath: "/usr/bin/gopls"),
+ arguments: [],
+ environment: [:],
+ process: goProcess,
+ core: TestLspClientCore(diagnosticURL: goSource)
+ )
+ let manager = LanguageToolingSessionManager(
+ catalog: LanguageProviderCatalog(descriptors: [swiftDescriptor, goDescriptor]),
+ runtimes: [
+ TestLanguageServerRuntime(descriptor: swiftDescriptor, session: swiftSession),
+ TestLanguageServerRuntime(descriptor: goDescriptor, session: goSession)
+ ]
+ )
+
+ try manager.synchronizeLanguageServer(for: swiftSource, text: "struct App {}\n", rootURL: root)
+ Self.emitSuccessfulInitialize(on: swiftProcess)
+ try manager.synchronizeLanguageServer(for: goSource, text: "package main\n", rootURL: root)
+ Self.emitSuccessfulInitialize(on: goProcess)
+ await Self.drainMainActorTasks()
+ Self.emitPublishDiagnostics(on: swiftProcess)
+ Self.emitPublishDiagnostics(on: goProcess)
+ await Self.drainMainActorTasks()
+
+ #expect(manager.diagnostics(for: "swift")[swiftSource]?.count == 1)
+ #expect(manager.diagnostics(for: "go")[goSource]?.count == 1)
+ #expect(manager.diagnostics.count == 2)
+
+ manager.stopLanguageServer(providerID: "swift")
+ #expect(manager.diagnostics(for: "swift").isEmpty)
+ #expect(manager.diagnostics(for: "go")[goSource]?.count == 1)
+ #expect(manager.diagnostics.count == 1)
+
+ goProcess.terminate(exitCode: 7)
+ await Self.drainMainActorTasks()
+ #expect(manager.diagnostics(for: "go").isEmpty)
+ #expect(manager.diagnostics.isEmpty)
+
+ try manager.synchronizeLanguageServer(for: goSource, text: "package main\n", rootURL: root)
+ Self.emitSuccessfulInitialize(on: goProcess)
+ await Self.drainMainActorTasks()
+ Self.emitPublishDiagnostics(on: goProcess)
+ await Self.drainMainActorTasks()
+ #expect(manager.diagnostics(for: "go")[goSource]?.count == 1)
+
+ let reconfiguredGo = LanguageProviderDescriptor(
+ id: goDescriptor.id,
+ displayName: "Go (workspace override)",
+ fileExtensions: goDescriptor.fileExtensions,
+ fileNames: goDescriptor.fileNames,
+ fileNamePrefixes: goDescriptor.fileNamePrefixes,
+ capabilities: goDescriptor.capabilities,
+ activationPolicy: goDescriptor.activationPolicy,
+ languageIdentifier: goDescriptor.languageIdentifier,
+ languageIdentifiersByExtension: goDescriptor.languageIdentifiersByExtension,
+ languageIdentifiersByFileName: goDescriptor.languageIdentifiersByFileName,
+ languageServerLaunch: goDescriptor.languageServerLaunch,
+ languageServerInstallation: goDescriptor.languageServerInstallation
+ )
+ manager.updateCatalog(LanguageProviderCatalog(descriptors: [swiftDescriptor, reconfiguredGo]))
+
+ #expect(manager.diagnostics(for: "go").isEmpty)
+ #expect(manager.diagnostics.isEmpty)
+ }
+
+ @Test
+ func languageServerRuntimeStartsFromRustCatalogLaunchMetadata() async throws {
+ let descriptor = LanguageProviderDescriptor(
+ id: "swift",
+ displayName: "Swift",
+ fileExtensions: ["swift"],
+ capabilities: [.languageServer, .formatting],
+ activationPolicy: .onDemand,
+ languageIdentifier: "swift",
+ languageServerLaunch: LanguageServerLaunchDescriptor(
+ executableNames: ["sourcekit-lsp"],
+ arguments: [],
+ environment: ["SOURCEKIT_TOOLCHAIN": "custom"],
+ initializationOptions: .object(["indexing": .bool(true)])
+ )
+ )
let runtimeService = ProjectRuntimeService(
runtimeLocator: RunTestRuntimeLocator(),
store: RunTestKeyValueStore()
)
let process = RecordingRawProcessSession()
+ let initializationRecorder = TestLspInitializationRecorder()
+ let root = URL(fileURLWithPath: "/tmp/swift-project", isDirectory: true)
+ let source = root.appendingPathComponent("App.swift")
let runtime = StdioLanguageProviderRuntime(
descriptor: descriptor,
runtimeService: runtimeService,
processFactory: { process },
- launch: StdioLanguageServerLaunch(executableNames: ["gopls"], arguments: [])
+ languageServerLaunch: descriptor.languageServerLaunch,
+ languageServerCore: TestLspClientCore(
+ diagnosticURL: source,
+ initializationRecorder: initializationRecorder
+ )
)
- let manager = LanguageToolingSessionManager(runtimes: [runtime])
- let root = URL(fileURLWithPath: "/tmp/go-project", isDirectory: true)
-
- #expect(process.requests.isEmpty)
- #expect(manager.supportsGenericEditing(for: root.appendingPathComponent("main.go")))
- #expect(process.sentData.isEmpty)
- let session = try manager.activateLanguageServer(
- for: root.appendingPathComponent("main.go"),
- rootURL: root
+ let manager = LanguageToolingSessionManager(
+ catalog: LanguageProviderCatalog(descriptors: [descriptor]),
+ runtimes: [runtime]
)
- let request = try #require(process.requests.first)
- #expect(request.executablePath == "/usr/bin/gopls")
- #expect(request.keepsStandardInputOpen)
- let initialize = try #require(Self.framedJSON(process.sentData.first))
- #expect(initialize["method"] as? String == "initialize")
- #expect(initialize["id"] as? Int == 1)
- #expect(!session.isReady)
+
try manager.synchronizeLanguageServer(
- for: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() {}\n",
+ for: source,
+ text: "struct App {}\n",
rootURL: root
)
+ let startRequest = try #require(process.requests.first)
+ #expect(startRequest.executablePath == "/usr/bin/sourcekit-lsp")
+ #expect(startRequest.arguments.isEmpty)
+ #expect(startRequest.environment?["SOURCEKIT_TOOLCHAIN"] == "custom")
+ #expect(initializationRecorder.options == .object(["indexing": .bool(true)]))
+ #expect(initializationRecorder.actions == ["create"])
+ #expect(manager.activeLanguageServerIDs.isEmpty)
+ #expect(manager.languageServerStates["swift"] == .initializing)
+ let firstFrameData = try #require(process.sentData.first)
+ let firstFrame = try #require(String(data: firstFrameData, encoding: .utf8))
+ #expect(firstFrame.hasPrefix("Content-Length: "))
+ #expect(firstFrame.contains("\r\n\r\n{\"jsonrpc\""))
+ #expect(firstFrame.contains("\"method\":\"initialize\""))
process.emitJSON([
"jsonrpc": "2.0",
- "id": 1,
- "result": ["capabilities": [
- "definitionProvider": true,
- "referencesProvider": true,
- "implementationProvider": true,
- "hoverProvider": true,
- "completionProvider": ["resolveProvider": true],
- "renameProvider": ["prepareProvider": true],
- "documentFormattingProvider": true,
- "codeActionProvider": ["resolveProvider": true],
- "executeCommandProvider": ["commands": ["gopls.organizeImports"]]
- ]]
- ], splitAt: 12)
- await Task.yield()
- await Task.yield()
-
- #expect(session.isReady)
- let features = manager.features(for: root.appendingPathComponent("main.go"))
- #expect(features.contains([.definition, .completion, .completionResolve]))
- #expect(features.contains([.rename, .formatting, .codeActions, .codeActionResolve]))
- let initialized = try #require(Self.framedJSON(process.sentData.dropFirst().first))
- #expect(initialized["method"] as? String == "initialized")
- let didOpen = try #require(Self.framedJSON(process.sentData.last))
- #expect(didOpen["method"] as? String == "textDocument/didOpen")
- let openParameters = try #require(didOpen["params"] as? [String: Any])
- let openedDocument = try #require(openParameters["textDocument"] as? [String: Any])
- #expect(openedDocument["languageId"] as? String == "go")
+ "id": "1",
+ "result": [
+ "capabilities": [
+ "hoverProvider": true,
+ "completionProvider": [:]
+ ],
+ "serverInfo": [
+ "name": "sourcekit-lsp",
+ "version": "6.2"
+ ]
+ ]
+ ])
+ await Self.drainMainActorTasks()
+ #expect(manager.activeLanguageServerIDs == ["swift"])
+ #expect(manager.languageServerStates["swift"] == .ready)
+ #expect(manager.languageServerInfos["swift"] == LanguageServerInfo(
+ name: "sourcekit-lsp",
+ version: "6.2"
+ ))
+ #expect(manager.languageServerFeatures["swift"]?.contains(.completion) == true)
+ #expect(manager.languageServerFeatures["swift"]?.contains(.hover) == true)
+ #expect(initializationRecorder.actions.contains("applyServerMessage"))
+ #expect(initializationRecorder.actions.contains("openDocument"))
- try manager.synchronizeLanguageServer(
- for: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() { broken }\n",
- rootURL: root
- )
- let didChange = try #require(Self.framedJSON(process.sentData.last))
- #expect(didChange["method"] as? String == "textDocument/didChange")
- let changeParameters = try #require(didChange["params"] as? [String: Any])
- let changedDocument = try #require(changeParameters["textDocument"] as? [String: Any])
- #expect(changedDocument["version"] as? Int == 2)
+ let framedOutput = process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined()
+ #expect(framedOutput.contains("\"method\":\"initialized\""))
+ #expect(framedOutput.contains("\"method\":\"textDocument/didOpen\""))
process.emitJSON([
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": [
- "uri": root.appendingPathComponent("main.go").absoluteString,
+ "uri": source.standardizedFileURL.absoluteString,
"diagnostics": [[
"range": [
- "start": ["line": 1, "character": 14],
- "end": ["line": 1, "character": 20]
+ "start": ["line": 0, "character": 7],
+ "end": ["line": 0, "character": 10]
],
- "severity": 1,
- "source": "gopls",
- "message": "undefined: broken"
+ "severity": 2,
+ "source": "sourcekit-lsp",
+ "message": "example warning"
]]
]
])
- await Task.yield()
- await Task.yield()
- let diagnostics = manager.diagnostics[root.appendingPathComponent("main.go").standardizedFileURL]
- #expect(diagnostics?.first?.message == "undefined: broken")
-
- var navigationResult: Result<[LanguageServerLocation], Error>?
- try manager.navigate(
- method: "textDocument/definition",
- fileURL: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() { broken }\n",
- position: LanguageServerPosition(line: 1, utf16Column: 16),
- rootURL: root
- ) { navigationResult = $0 }
- let definitionRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(definitionRequest["method"] as? String == "textDocument/definition")
- let definitionID = try #require(definitionRequest["id"] as? Int)
- process.emitJSON([
- "jsonrpc": "2.0",
- "id": definitionID,
- "result": [[
- "targetUri": root.appendingPathComponent("helper.go").absoluteString,
- "targetRange": [
- "start": ["line": 4, "character": 0],
- "end": ["line": 4, "character": 6]
- ],
- "targetSelectionRange": [
- "start": ["line": 4, "character": 2],
- "end": ["line": 4, "character": 6]
- ]
- ]]
- ])
- await Self.drainMainActorTasks()
- let definition = try #require(try navigationResult?.get().first)
- #expect(definition.url == root.appendingPathComponent("helper.go").standardizedFileURL)
- #expect(definition.range.start == LanguageServerPosition(line: 4, utf16Column: 2))
-
- var hoverResult: Result?
- try manager.hover(
- fileURL: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() { broken }\n",
- position: LanguageServerPosition(line: 1, utf16Column: 16),
- rootURL: root
- ) { hoverResult = $0 }
- let hoverRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(hoverRequest["method"] as? String == "textDocument/hover")
- let hoverID = try #require(hoverRequest["id"] as? Int)
- process.emitJSON([
- "jsonrpc": "2.0",
- "id": hoverID,
- "result": [
- "contents": ["kind": "markdown", "value": "```go\nfunc broken()\n```"],
- "range": [
- "start": ["line": 1, "character": 14],
- "end": ["line": 1, "character": 20]
- ]
- ]
- ])
await Self.drainMainActorTasks()
- let hover = try #require(try hoverResult?.get())
- #expect(hover.isMarkdown)
- #expect(hover.contents.contains("func broken()"))
- #expect(hover.range?.start == LanguageServerPosition(line: 1, utf16Column: 14))
+ #expect(manager.diagnostics[source.standardizedFileURL]?.first?.message == "example warning")
- var completionResult: Result<[LanguageServerCompletionItem], Error>?
+ var completionsResult: Result<[LanguageServerCompletionItem], Error>?
try manager.completions(
- fileURL: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() { bro }\n",
- position: LanguageServerPosition(line: 1, utf16Column: 17),
+ fileURL: source,
+ text: "struct App { let tit }\n",
+ position: LanguageServerPosition(line: 0, utf16Column: 20),
rootURL: root
- ) { completionResult = $0 }
- let completionRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(completionRequest["method"] as? String == "textDocument/completion")
- let completionID = try #require(completionRequest["id"] as? Int)
+ ) { result in
+ completionsResult = result
+ }
+ #expect(process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined()
+ .contains("\"method\":\"textDocument/completion\""))
process.emitJSON([
"jsonrpc": "2.0",
- "id": completionID,
+ "id": "2",
"result": [
- "isIncomplete": false,
- "items": [
- [
- "label": "brokenValue",
- "detail": "var brokenValue int",
- "documentation": ["kind": "markdown", "value": "A test value"],
- "insertText": "brokenValue",
- "sortText": "2"
- ],
- [
- "label": "broken",
- "textEdit": [
- "range": [
- "start": ["line": 1, "character": 14],
- "end": ["line": 1, "character": 17]
- ],
- "newText": "broken()"
- ],
- "additionalTextEdits": [[
- "range": [
- "start": ["line": 1, "character": 0],
- "end": ["line": 1, "character": 0]
- ],
- "newText": "// imported\n"
- ]],
- "data": ["token": 17, "lazy": true],
- "sortText": "1"
- ]
- ]
+ "items": [[
+ "label": "title",
+ "insertText": "title",
+ "kind": 6,
+ "detail": "String"
+ ]]
]
])
await Self.drainMainActorTasks()
- let completions = try #require(try completionResult?.get())
- #expect(completions.map(\.label) == ["broken", "brokenValue"])
- #expect(completions.first?.insertText == "broken()")
- #expect(completions.first?.textEdit?.range.start.utf16Column == 14)
- #expect(completions.first?.additionalTextEdits.first?.newText == "// imported\n")
- #expect(completions.first?.data == .object(["token": .integer(17), "lazy": .bool(true)]))
- #expect(completions.last?.documentation == "A test value")
-
- let unresolvedCompletion = try #require(completions.first)
- var resolvedCompletionResult: Result?
+ #expect(try completionsResult?.get().first?.label == "title")
+
+ var completionResolveResult: Result?
try manager.resolveCompletion(
- unresolvedCompletion,
- fileURL: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() { bro }\n",
+ LanguageServerCompletionItem(
+ label: "title",
+ detail: nil,
+ documentation: nil,
+ insertText: "title",
+ sortText: nil,
+ filterText: nil,
+ kind: 6,
+ textEdit: nil,
+ additionalTextEdits: [],
+ data: .object(["id": .string("completion-1")])
+ ),
+ fileURL: source,
+ text: "struct App { let ti = 1 }\n",
rootURL: root
- ) { resolvedCompletionResult = $0 }
- let resolveCompletionRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(resolveCompletionRequest["method"] as? String == "completionItem/resolve")
- let resolveCompletionID = try #require(resolveCompletionRequest["id"] as? Int)
+ ) { result in
+ completionResolveResult = result
+ }
process.emitJSON([
- "jsonrpc": "2.0", "id": resolveCompletionID,
+ "jsonrpc": "2.0",
+ "id": "6",
"result": [
- "label": "broken",
- "detail": "resolved function",
- "textEdit": [
- "range": [
- "start": ["line": 1, "character": 14],
- "end": ["line": 1, "character": 17]
- ],
- "newText": "broken()"
- ],
- "data": ["token": 17, "lazy": true]
+ "label": "title",
+ "insertText": "title",
+ "kind": 6,
+ "detail": "String",
+ "documentation": "Resolved docs"
]
])
await Self.drainMainActorTasks()
- #expect(try resolvedCompletionResult?.get().detail == "resolved function")
+ #expect(try completionResolveResult?.get().documentation == "Resolved docs")
var renameResult: Result?
try manager.rename(
- fileURL: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() { broken() }\n",
- position: LanguageServerPosition(line: 1, utf16Column: 19),
- newName: "fixed",
+ fileURL: source,
+ text: "struct App { let title = 1 }\n",
+ position: LanguageServerPosition(line: 0, utf16Column: 17),
+ newName: "headline",
rootURL: root
- ) { renameResult = $0 }
- let renameRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(renameRequest["method"] as? String == "textDocument/rename")
- let renameID = try #require(renameRequest["id"] as? Int)
+ ) { result in
+ renameResult = result
+ }
process.emitJSON([
- "jsonrpc": "2.0", "id": renameID,
- "result": ["changes": [
- root.appendingPathComponent("main.go").absoluteString: [[
- "range": [
- "start": ["line": 1, "character": 17],
- "end": ["line": 1, "character": 23]
- ],
- "newText": "fixed"
- ]]
- ]]
+ "jsonrpc": "2.0",
+ "id": "3",
+ "result": [
+ "changes": [
+ source.standardizedFileURL.absoluteString: [[
+ "range": [
+ "start": ["line": 0, "character": 17],
+ "end": ["line": 0, "character": 22]
+ ],
+ "newText": "headline"
+ ]]
+ ]
+ ]
])
await Self.drainMainActorTasks()
- let rename = try #require(try renameResult?.get())
- #expect(rename.changes[root.appendingPathComponent("main.go").standardizedFileURL]?.first?.newText == "fixed")
+ #expect(try renameResult?.get().changes[source.standardizedFileURL]?.first?.newText == "headline")
- var formattingResult: Result<[LanguageServerTextEdit], Error>?
+ var formatResult: Result<[LanguageServerTextEdit], Error>?
try manager.format(
- fileURL: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() { broken() }\n",
+ fileURL: source,
+ text: "struct App{ }\n",
rootURL: root
- ) { formattingResult = $0 }
- let formattingRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(formattingRequest["method"] as? String == "textDocument/formatting")
- let formattingID = try #require(formattingRequest["id"] as? Int)
+ ) { result in
+ formatResult = result
+ }
process.emitJSON([
- "jsonrpc": "2.0", "id": formattingID,
+ "jsonrpc": "2.0",
+ "id": "4",
"result": [[
"range": [
- "start": ["line": 1, "character": 0],
- "end": ["line": 1, "character": 29]
+ "start": ["line": 0, "character": 10],
+ "end": ["line": 0, "character": 10]
],
- "newText": "func main() {\n\tbroken()\n}"
+ "newText": " "
]]
])
await Self.drainMainActorTasks()
- #expect(try formattingResult?.get().first?.newText.contains("broken()") == true)
+ #expect(try formatResult?.get().first?.newText == " ")
var actionsResult: Result<[LanguageServerCodeAction], Error>?
try manager.codeActions(
- fileURL: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() { broken() }\n",
+ fileURL: source,
+ text: "struct App{ }\n",
range: LanguageServerRange(
- start: LanguageServerPosition(line: 1, utf16Column: 17),
- end: LanguageServerPosition(line: 1, utf16Column: 23)
+ start: LanguageServerPosition(line: 0, utf16Column: 0),
+ end: LanguageServerPosition(line: 0, utf16Column: 0)
),
- diagnostics: [LanguageServerDiagnostic(
- range: LanguageServerRange(
- start: LanguageServerPosition(line: 1, utf16Column: 17),
- end: LanguageServerPosition(line: 1, utf16Column: 23)
- ),
- severity: 1,
- message: "undefined: broken",
- source: "gopls",
- code: nil
- )],
+ diagnostics: [
+ LanguageServerDiagnostic(
+ range: LanguageServerRange(
+ start: LanguageServerPosition(line: 0, utf16Column: 7),
+ end: LanguageServerPosition(line: 0, utf16Column: 10)
+ ),
+ severity: 2,
+ message: "example warning",
+ source: "sourcekit-lsp",
+ code: nil
+ )
+ ],
rootURL: root
- ) { actionsResult = $0 }
- let actionsRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(actionsRequest["method"] as? String == "textDocument/codeAction")
- let actionsID = try #require(actionsRequest["id"] as? Int)
+ ) { result in
+ actionsResult = result
+ }
process.emitJSON([
- "jsonrpc": "2.0", "id": actionsID,
+ "jsonrpc": "2.0",
+ "id": "5",
"result": [[
- "title": "Fix symbol",
+ "title": "Fix warning",
"kind": "quickfix",
"isPreferred": true,
- "edit": ["changes": [
- root.appendingPathComponent("main.go").absoluteString: []
- ]],
"command": [
- "title": "Organize imports",
- "command": "gopls.organizeImports",
- "arguments": [["uri": root.appendingPathComponent("main.go").absoluteString]]
+ "title": "Apply fix",
+ "command": "source.fix",
+ "arguments": [["uri": source.standardizedFileURL.absoluteString]]
],
- "data": ["action": 4]
+ "data": ["token": "fix-1"]
]]
])
await Self.drainMainActorTasks()
- let action = try #require(try actionsResult?.get().first)
- #expect(action.isPreferred)
- #expect(action.data == .object(["action": .integer(4)]))
+ let actions = try actionsResult?.get()
+ #expect(actions?.first?.title == "Fix warning")
+ #expect(actions?.first?.command?.command == "source.fix")
- var resolvedActionResult: Result?
+ var actionResolveResult: Result?
try manager.resolveCodeAction(
- action,
- fileURL: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() { broken() }\n",
+ LanguageServerCodeAction(
+ title: "Fix warning",
+ kind: "quickfix",
+ isPreferred: true,
+ edit: nil,
+ command: nil,
+ data: .object(["token": .string("fix-1")])
+ ),
+ fileURL: source,
+ text: "struct App{ }\n",
rootURL: root
- ) { resolvedActionResult = $0 }
- let resolveActionRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(resolveActionRequest["method"] as? String == "codeAction/resolve")
- let resolveActionID = try #require(resolveActionRequest["id"] as? Int)
+ ) { result in
+ actionResolveResult = result
+ }
process.emitJSON([
- "jsonrpc": "2.0", "id": resolveActionID,
+ "jsonrpc": "2.0",
+ "id": "7",
"result": [
- "title": "Fix symbol",
+ "title": "Fix warning",
"kind": "quickfix",
- "command": [
- "title": "Organize imports",
- "command": "gopls.organizeImports",
- "arguments": [["uri": root.appendingPathComponent("main.go").absoluteString]]
+ "edit": [
+ "changes": [
+ source.standardizedFileURL.absoluteString: [[
+ "range": [
+ "start": ["line": 0, "character": 10],
+ "end": ["line": 0, "character": 10]
+ ],
+ "newText": " "
+ ]]
+ ]
],
- "data": ["action": 4]
+ "data": ["token": "fix-1"]
]
])
await Self.drainMainActorTasks()
- let resolvedAction = try #require(try resolvedActionResult?.get())
- let actionCommand = try #require(resolvedAction.command)
- #expect(actionCommand.command == "gopls.organizeImports")
- #expect(actionCommand.arguments.first == .object([
- "uri": .string(root.appendingPathComponent("main.go").absoluteString)
- ]))
+ #expect(try actionResolveResult?.get().edit?.changes[source.standardizedFileURL]?.first?.newText == " ")
- var commandResult: Result?
+ var executeResult: Result?
try manager.execute(
- actionCommand,
- fileURL: root.appendingPathComponent("main.go"),
- text: "package main\nfunc main() { broken() }\n",
- rootURL: root
- ) { commandResult = $0 }
- let commandRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(commandRequest["method"] as? String == "workspace/executeCommand")
- let commandID = try #require(commandRequest["id"] as? Int)
- process.emitJSON(["jsonrpc": "2.0", "id": commandID, "result": NSNull()])
- await Self.drainMainActorTasks()
- #expect(commandResult != nil)
- _ = try commandResult?.get()
-
- manager.closeDocument(root.appendingPathComponent("main.go"))
- let didClose = try #require(Self.framedJSON(process.sentData.last))
- #expect(didClose["method"] as? String == "textDocument/didClose")
- #expect(manager.diagnostics[root.appendingPathComponent("main.go").standardizedFileURL] == nil)
- }
-
- @Test
- func languageServerDynamicRegistrationsUpdateFeaturesAndRequestGuards() async throws {
- let source = URL(fileURLWithPath: "/tmp/dynamic-go-project/main.go")
- let root = source.deletingLastPathComponent()
- let descriptor = try #require(LanguageProviderCatalog.standard.provider(for: source))
- let process = RecordingRawProcessSession()
- let runtime = StdioLanguageProviderRuntime(
- descriptor: descriptor,
- runtimeService: ProjectRuntimeService(
- runtimeLocator: RunTestRuntimeLocator(),
- store: RunTestKeyValueStore()
+ LanguageServerCommand(
+ title: "Apply fix",
+ command: "source.fix",
+ arguments: [.object(["uri": .string(source.standardizedFileURL.absoluteString)])]
),
- processFactory: { process },
- launch: StdioLanguageServerLaunch(executableNames: ["gopls"], arguments: [])
- )
- let manager = LanguageToolingSessionManager(runtimes: [runtime])
-
- _ = try manager.activateLanguageServer(for: source, rootURL: root)
- let initialize = try #require(Self.framedJSON(process.sentData.first))
- let capabilities = try #require(initialize["params"] as? [String: Any])
- let clientCapabilities = try #require(capabilities["capabilities"] as? [String: Any])
- let textDocument = try #require(clientCapabilities["textDocument"] as? [String: Any])
- let formatting = try #require(textDocument["formatting"] as? [String: Any])
- #expect(formatting["dynamicRegistration"] as? Bool == true)
- let initializeID = try #require(initialize["id"] as? Int)
-
- var earlyFormattingResult: Result<[LanguageServerTextEdit], Error>?
- try manager.format(fileURL: source, text: "package main\n", rootURL: root) {
- earlyFormattingResult = $0
+ fileURL: source,
+ text: "struct App{ }\n",
+ rootURL: root
+ ) { result in
+ executeResult = result
}
- #expect(process.sentData.count == 1)
-
process.emitJSON([
"jsonrpc": "2.0",
- "id": initializeID,
- "result": ["capabilities": ["definitionProvider": true]]
+ "id": "8",
+ "result": NSNull()
])
await Self.drainMainActorTasks()
- #expect(manager.features(for: source) == .definition)
- #expect(throws: (any Error).self) {
- _ = try earlyFormattingResult?.get()
- }
- #expect(!process.sentData.compactMap(Self.framedJSON).contains {
- $0["method"] as? String == "textDocument/formatting"
- })
-
- let countBeforeRejectedFormat = process.sentData.count
- #expect(throws: LanguageToolingSessionError.self) {
- try manager.format(fileURL: source, text: "package main\n", rootURL: root) { _ in }
- }
- #expect(process.sentData.count == countBeforeRejectedFormat)
-
+ #expect(executeResult != nil)
+ try executeResult?.get()
+
+ manager.closeDocument(source)
+ #expect(process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined()
+ .contains("\"method\":\"textDocument/didClose\""))
+ manager.stopLanguageServer(providerID: "swift")
+ #expect(process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined()
+ .contains("\"method\":\"shutdown\""))
process.emitJSON([
"jsonrpc": "2.0",
- "id": 70,
- "method": "client/registerCapability",
- "params": ["registrations": [
- [
- "id": "formatting-registration",
- "method": "textDocument/formatting",
- "registerOptions": [:]
- ],
- [
- "id": "completion-registration",
- "method": "textDocument/completion",
- "registerOptions": ["resolveProvider": true]
- ]
- ]]
+ "id": "9",
+ "result": NSNull()
])
await Self.drainMainActorTasks()
- let registeredFeatures = manager.features(for: source)
- #expect(registeredFeatures.contains([.definition, .formatting, .completion, .completionResolve]))
- let registrationResponse = try #require(Self.framedJSON(process.sentData.last))
- #expect(registrationResponse["id"] as? Int == 70)
- #expect(registrationResponse["result"] is NSNull)
+ let shutdownOutput = process.sentData.compactMap { String(data: $0, encoding: .utf8) }.joined()
+ #expect(shutdownOutput.contains("\"method\":\"exit\""))
+ #expect(!process.isRunning)
+ #expect(initializationRecorder.actions.contains("closeDocument"))
+ #expect(initializationRecorder.actions.contains("shutdown"))
+ #expect(initializationRecorder.actions.filter { $0 == "destroy" }.count == 1)
+ }
- try manager.format(fileURL: source, text: "package main\n", rootURL: root) { _ in }
- let formattingRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(formattingRequest["method"] as? String == "textDocument/formatting")
+ @Test
+ func stoppingToolingSessionsClearsProjectScopedBreakpoints() throws {
+ let descriptor = try #require(LanguageProviderCatalog.standard.provider(
+ for: URL(fileURLWithPath: "/tmp/main.py")
+ ))
+ let runtime = TestDebugLanguageProviderRuntime(descriptor: descriptor)
+ let manager = LanguageToolingSessionManager(catalog: .standard, runtimes: [runtime])
+ let firstRoot = URL(fileURLWithPath: "/tmp/first-python-project")
+ let firstSource = firstRoot.appendingPathComponent("main.py")
+ try manager.setDebugBreakpoints([DebugSourceBreakpoint(line: 12)], in: firstSource)
- process.emitJSON([
- "jsonrpc": "2.0",
- "id": 71,
- "method": "client/unregisterCapability",
- "params": ["unregisterations": [[
- "id": "formatting-registration",
- "method": "textDocument/formatting"
- ]]]
- ])
- await Self.drainMainActorTasks()
- let unregisteredFeatures = manager.features(for: source)
- #expect(!unregisteredFeatures.contains(.formatting))
- #expect(unregisteredFeatures.contains([.definition, .completion, .completionResolve]))
- let unregistrationResponse = try #require(Self.framedJSON(process.sentData.last))
- #expect(unregistrationResponse["id"] as? Int == 71)
+ _ = try manager.activateDebugAdapter(for: firstSource, rootURL: firstRoot)
+ #expect(runtime.debugAdapters.first?.breakpointUpdates.count == 1)
+ manager.stopAll()
- let countBeforeSecondRejectedFormat = process.sentData.count
- #expect(throws: LanguageToolingSessionError.self) {
- try manager.format(fileURL: source, text: "package main\n", rootURL: root) { _ in }
- }
- #expect(process.sentData.count == countBeforeSecondRejectedFormat)
+ let secondRoot = URL(fileURLWithPath: "/tmp/second-python-project")
+ _ = try manager.activateDebugAdapter(
+ for: secondRoot.appendingPathComponent("main.py"),
+ rootURL: secondRoot
+ )
+ #expect(runtime.debugAdapters.count == 2)
+ #expect(runtime.debugAdapters.last?.breakpointUpdates.isEmpty == true)
}
@Test
@@ -1519,10 +1780,6 @@ struct RunConfigurationIntegrationTests {
descriptor: descriptor,
runtimeService: runtimeService,
processFactory: { process },
- launch: StdioLanguageServerLaunch(
- executableNames: ["pyright-langserver"],
- arguments: ["--stdio"]
- ),
debugLaunch: StdioDebugAdapterLaunch(
adapterID: "python",
executableNames: ["python3"],
@@ -1821,7 +2078,6 @@ struct RunConfigurationIntegrationTests {
descriptor: descriptor,
runtimeService: runtimeService,
processFactory: { process },
- launch: StdioLanguageServerLaunch(executableNames: ["pyright-langserver"], arguments: ["--stdio"]),
debugLaunch: StdioDebugAdapterLaunch(
adapterID: "python",
executableNames: ["python3"],
@@ -2251,7 +2507,6 @@ struct RunConfigurationIntegrationTests {
#expect(report.javaHomePath == "/toolchains/jdk")
#expect(report.jdbExecutablePath == "/toolchains/jdk/bin/jdb")
#expect(!report.status.blocksJavaRun)
- #expect(!report.status.blocksJavaEditing)
}
@Test
@@ -2267,187 +2522,9 @@ struct RunConfigurationIntegrationTests {
let report = try #require(runtime.javaEnvironmentReport)
#expect(report.status == .jdkMissing)
#expect(report.status.blocksJavaRun)
- #expect(report.status.blocksJavaEditing)
#expect(report.recovery.contains("JAVA_HOME"))
}
- @Test
- func javaLanguageServerUsesTheSameProjectJDKWithSpaces() throws {
- let root = URL(fileURLWithPath: "/tmp/lithe-language-toolchain", isDirectory: true)
- let runtime = ProjectRuntimeService(
- runtimeLocator: RunTestRuntimeLocator(),
- store: RunTestKeyValueStore(),
- toolchainSource: RecordingToolchainSource(selection: ProjectToolchainSelection(
- javaHomePath: "/local/JDK 21",
- mavenExecutablePath: "",
- mavenJavaHomePath: ""
- ))
- )
- runtime.openProject(at: root)
- let process = RecordingRawProcessSession()
- let service = JavaLanguageService(
- runtimeService: runtime,
- process: process,
- archiveReader: EmptyArchiveEntryReader(),
- fileStorage: RunTestFileStorage(),
- javaMavenOperations: RunTestJavaMavenOperations()
- )
-
- service.prepare(for: root)
-
- let request = try #require(process.requests.first)
- #expect(request.executablePath == "/toolchains/jdtls")
- #expect(request.arguments.contains("--java-executable"))
- #expect(request.arguments.contains("/local/JDK 21/bin/java"))
- }
-
- @Test
- func javaLanguageServerParticipatesInTheGenericOnDemandLifecycle() async throws {
- let root = URL(fileURLWithPath: "/tmp/lithe-generic-java-lsp", isDirectory: true)
- let runtime = ProjectRuntimeService(
- runtimeLocator: RunTestRuntimeLocator(),
- store: RunTestKeyValueStore()
- )
- runtime.openProject(at: root)
- let process = RecordingRawProcessSession()
- let service = JavaLanguageService(
- runtimeService: runtime,
- process: process,
- archiveReader: EmptyArchiveEntryReader(),
- fileStorage: RunTestFileStorage(),
- javaMavenOperations: RunTestJavaMavenOperations()
- )
- let provider = JavaLanguageProviderRuntime(service: service)
- let manager = LanguageToolingSessionManager(runtimes: [provider])
-
- #expect(process.requests.isEmpty)
- let source = root.appendingPathComponent("src/Main.java")
- #expect(manager.supportsGenericEditing(for: source))
- _ = try manager.activateLanguageServer(
- for: source,
- rootURL: root
- )
- #expect(process.requests.count == 1)
- #expect(manager.activeLanguageServerIDs == ["java"])
-
- let initialize = try #require(Self.framedJSON(process.sentData.first))
- let initializeID = try #require(initialize["id"] as? Int)
- let initializeParameters = try #require(initialize["params"] as? [String: Any])
- let clientCapabilities = try #require(initializeParameters["capabilities"] as? [String: Any])
- let textDocumentCapabilities = try #require(clientCapabilities["textDocument"] as? [String: Any])
- let formattingCapability = try #require(textDocumentCapabilities["formatting"] as? [String: Any])
- #expect(formattingCapability["dynamicRegistration"] as? Bool == true)
- process.emitJSON([
- "jsonrpc": "2.0",
- "id": initializeID,
- "result": ["capabilities": [
- "definitionProvider": true,
- "renameProvider": true,
- "codeActionProvider": ["resolveProvider": true]
- ]]
- ])
- await Self.drainMainActorTasks()
- let javaFeatures = manager.features(for: source)
- #expect(javaFeatures.contains([.definition, .rename, .codeActions, .codeActionResolve]))
- #expect(!javaFeatures.contains(.formatting))
-
- process.emitJSON([
- "jsonrpc": "2.0",
- "id": 501,
- "method": "client/registerCapability",
- "params": ["registrations": [[
- "id": "java-formatting",
- "method": "textDocument/formatting",
- "registerOptions": [:]
- ]]]
- ])
- await Self.drainMainActorTasks()
- #expect(manager.features(for: source).contains(.formatting))
- #expect(Self.framedJSON(process.sentData.last)?["id"] as? Int == 501)
-
- process.emitJSON([
- "jsonrpc": "2.0",
- "id": 502,
- "method": "client/unregisterCapability",
- "params": ["unregistrations": [[
- "id": "java-formatting",
- "method": "textDocument/formatting"
- ]]]
- ])
- await Self.drainMainActorTasks()
- #expect(!manager.features(for: source).contains(.formatting))
- #expect(Self.framedJSON(process.sentData.last)?["id"] as? Int == 502)
-
- let countBeforeRejectedFormat = process.sentData.count
- #expect(throws: LanguageToolingSessionError.self) {
- try manager.format(fileURL: source, text: "class Main {}\n", rootURL: root) { _ in }
- }
- #expect(process.sentData.count == countBeforeRejectedFormat)
-
- let text = "class Main { void oldName() {} }\n"
- try manager.synchronizeLanguageServer(for: source, text: text, rootURL: root)
- #expect(Self.framedJSON(process.sentData.last)?["method"] as? String == "textDocument/didOpen")
-
- var renameResult: Result?
- try manager.rename(
- fileURL: source,
- text: text,
- position: LanguageServerPosition(line: 0, utf16Column: 20),
- newName: "newName",
- rootURL: root
- ) { renameResult = $0 }
- let renameRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(renameRequest["method"] as? String == "textDocument/rename")
- let renameID = try #require(renameRequest["id"] as? Int)
- process.emitJSON([
- "jsonrpc": "2.0",
- "id": renameID,
- "result": ["changes": [source.absoluteString: [[
- "range": [
- "start": ["line": 0, "character": 18],
- "end": ["line": 0, "character": 25]
- ],
- "newText": "newName"
- ]]]]
- ])
- await Self.drainMainActorTasks()
- #expect(try renameResult?.get().changes[source.standardizedFileURL]?.first?.newText == "newName")
-
- let unresolvedAction = LanguageServerCodeAction(
- title: "Add import",
- kind: "quickfix",
- isPreferred: true,
- edit: nil,
- command: nil,
- data: .object(["proposal": .integer(3)])
- )
- var resolvedActionResult: Result?
- try manager.resolveCodeAction(
- unresolvedAction,
- fileURL: source,
- text: text,
- rootURL: root
- ) { resolvedActionResult = $0 }
- let resolveRequest = try #require(Self.framedJSON(process.sentData.last))
- #expect(resolveRequest["method"] as? String == "codeAction/resolve")
- let resolveID = try #require(resolveRequest["id"] as? Int)
- process.emitJSON([
- "jsonrpc": "2.0", "id": resolveID,
- "result": [
- "title": "Add import",
- "kind": "quickfix",
- "edit": ["changes": [source.absoluteString: []]],
- "data": ["proposal": 3]
- ]
- ])
- await Self.drainMainActorTasks()
- #expect(try resolvedActionResult?.get().edit != nil)
-
- manager.stopAll()
- #expect(!process.isRunning)
- #expect(manager.activeLanguageServerIDs.isEmpty)
- }
-
@Test
func mavenDebugUsesSharedDebugLaunchPlan() throws {
let root = URL(fileURLWithPath: "/tmp/lithe-debug-service", isDirectory: true)
@@ -3069,6 +3146,83 @@ struct RunConfigurationIntegrationTests {
return try? JSONSerialization.jsonObject(with: body) as? [String: Any]
}
+ private func makeLanguageServerHarness(
+ initializeTimeoutNanoseconds: UInt64 = 10_000_000_000,
+ requestTimeoutNanoseconds: UInt64 = 30_000_000_000
+ ) -> LanguageServerReliabilityHarness {
+ let descriptor = LanguageProviderDescriptor(
+ id: "swift",
+ displayName: "Swift",
+ fileExtensions: ["swift"],
+ capabilities: [.languageServer, .formatting],
+ activationPolicy: .onDemand,
+ languageIdentifier: "swift"
+ )
+ let root = URL(fileURLWithPath: "/tmp/lithe-lsp-reliability", isDirectory: true)
+ let source = root.appendingPathComponent("App.swift")
+ let process = RecordingRawProcessSession()
+ let initializationRecorder = TestLspInitializationRecorder()
+ let session = StdioLanguageServerSession(
+ executableURL: URL(fileURLWithPath: "/usr/bin/sourcekit-lsp"),
+ arguments: [],
+ environment: [:],
+ initializeTimeoutNanoseconds: initializeTimeoutNanoseconds,
+ requestTimeoutNanoseconds: requestTimeoutNanoseconds,
+ process: process,
+ core: TestLspClientCore(
+ diagnosticURL: source,
+ initializationRecorder: initializationRecorder
+ )
+ )
+ let runtime = TestLanguageServerRuntime(descriptor: descriptor, session: session)
+ let manager = LanguageToolingSessionManager(
+ catalog: LanguageProviderCatalog(descriptors: [descriptor]),
+ runtimes: [runtime]
+ )
+ return LanguageServerReliabilityHarness(
+ root: root,
+ source: source,
+ process: process,
+ session: session,
+ manager: manager
+ )
+ }
+
+ private static func emitSuccessfulInitialize(on process: RecordingRawProcessSession) {
+ process.emitJSON([
+ "jsonrpc": "2.0",
+ "id": "1",
+ "result": [
+ "capabilities": [
+ "hoverProvider": true,
+ "completionProvider": [:]
+ ],
+ "serverInfo": [
+ "name": "sourcekit-lsp",
+ "version": "6.2"
+ ]
+ ]
+ ])
+ }
+
+ private static func emitPublishDiagnostics(on process: RecordingRawProcessSession) {
+ process.emitJSON([
+ "jsonrpc": "2.0",
+ "method": "textDocument/publishDiagnostics",
+ "params": [
+ "uri": "file:///ignored-by-test-core",
+ "diagnostics": []
+ ]
+ ])
+ }
+
+ private static func framedMessages(_ frames: [Data]) -> [String] {
+ frames.compactMap { data in
+ guard let separator = data.range(of: Data("\r\n\r\n".utf8)) else { return nil }
+ return String(decoding: data[separator.upperBound...], as: UTF8.self)
+ }
+ }
+
private static func debugRequest(named command: String, in frames: [Data]) -> [String: Any]? {
frames.lazy
.compactMap(framedJSON)
@@ -3081,6 +3235,27 @@ struct RunConfigurationIntegrationTests {
await Task.yield()
await Task.yield()
}
+
+ private static func waitForMainActorCondition(
+ attempts: Int = 100,
+ _ condition: () -> Bool
+ ) async -> Bool {
+ for _ in 0.. RustCoreBridge.LspClientResponsePayload? {
+ response(
+ messages: [#"{"jsonrpc":"2.0","id":"1","method":"initialize","params":{}}"#]
+ )
+ }
+
+ func lspClientInitialize(
+ rootURL: URL,
+ initializationOptions: ToolingJSONValue?
+ ) -> RustCoreBridge.LspClientResponsePayload? {
+ initializationRecorder?.options = initializationOptions
+ return lspClientInitialize(rootURL: rootURL)
+ }
+
+ func lspClientOpenDocument(
+ state _: ToolingJSONValue,
+ fileURL: URL,
+ languageID: String,
+ text: String
+ ) -> RustCoreBridge.LspClientResponsePayload? {
+ response(messages: [
+ #"{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"\#(fileURL.standardizedFileURL.absoluteString)","languageId":"\#(languageID)","version":1,"text":"\#(text)"}}}"#
+ ])
+ }
+
+ func lspClientChangeDocument(
+ state _: ToolingJSONValue,
+ fileURL _: URL,
+ text _: String
+ ) -> RustCoreBridge.LspClientResponsePayload? {
+ response()
+ }
+
+ func lspClientCloseDocument(
+ state: ToolingJSONValue,
+ fileURL: URL
+ ) -> RustCoreBridge.LspClientResponsePayload? {
+ response(
+ state: state,
+ messages: [
+ #"{"jsonrpc":"2.0","method":"textDocument/didClose","params":{"textDocument":{"uri":"\#(fileURL.standardizedFileURL.absoluteString)"}}}"#
+ ]
+ )
+ }
+
+ func lspClientShutdown(
+ state: ToolingJSONValue
+ ) -> RustCoreBridge.LspClientResponsePayload? {
+ response(
+ state: state,
+ messages: [#"{"jsonrpc":"2.0","id":"9","method":"shutdown"}"#]
+ )
+ }
+
+ func lspClientRequest(
+ state _: ToolingJSONValue,
+ fileURL _: URL,
+ method: String,
+ position _: LanguageServerPosition?,
+ newName _: String?,
+ range _: LanguageServerRange?,
+ diagnostics _: [LanguageServerDiagnostic],
+ completionItem _: LanguageServerCompletionItem?,
+ codeAction _: LanguageServerCodeAction?,
+ command _: LanguageServerCommand?
+ ) -> RustCoreBridge.LspClientResponsePayload? {
+ let id: String
+ switch method {
+ case "textDocument/rename":
+ id = "3"
+ case "textDocument/formatting":
+ id = "4"
+ case "textDocument/codeAction":
+ id = "5"
+ case "completionItem/resolve":
+ id = "6"
+ case "codeAction/resolve":
+ id = "7"
+ case "workspace/executeCommand":
+ id = "8"
+ default:
+ id = "2"
+ }
+ return response(messages: [
+ #"{"jsonrpc":"2.0","id":"\#(id)","method":"\#(method)","params":{}}"#
+ ])
+ }
+
+ func lspClientApplyServerMessage(
+ state _: ToolingJSONValue,
+ message: String
+ ) -> RustCoreBridge.LspClientResponsePayload? {
+ if let data = message.data(using: .utf8),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ object["id"] as? String == "1" {
+ let result = object["result"].flatMap(ToolingJSONValue.fromFoundation)
+ let error = object["error"].map { String(describing: $0) }
+ return initializeResponse(result: result, error: error)
+ }
+ if message.contains("publishDiagnostics") {
+ return response(events: [
+ RustCoreBridge.LspClientEventPayload(
+ kind: "diagnostics",
+ requestId: nil,
+ method: nil,
+ uri: diagnosticURL.standardizedFileURL.absoluteString,
+ diagnostics: [
+ RustCoreBridge.LspClientDiagnosticPayload(
+ range: RustCoreBridge.LspRangePayload(
+ start: RustCoreBridge.LspPositionPayload(line: 0, utf16Column: 7),
+ end: RustCoreBridge.LspPositionPayload(line: 0, utf16Column: 10)
+ ),
+ severity: 2,
+ message: "example warning",
+ source: "sourcekit-lsp",
+ code: nil
+ )
+ ],
+ result: nil,
+ error: nil
+ )
+ ])
+ }
+ if message.contains(#""id":"2""#) {
+ return response(events: [
+ RustCoreBridge.LspClientEventPayload(
+ kind: "response",
+ requestId: "2",
+ method: "textDocument/completion",
+ uri: nil,
+ diagnostics: nil,
+ result: .object([
+ "items": .array([
+ .object([
+ "label": .string("title"),
+ "insertText": .string("title"),
+ "kind": .integer(6),
+ "detail": .string("String"),
+ "additionalTextEdits": .array([]),
+ "data": .null
+ ])
+ ])
+ ]),
+ error: nil
+ )
+ ])
+ }
+ if message.contains(#""id":"3""#) {
+ return response(events: [
+ RustCoreBridge.LspClientEventPayload(
+ kind: "response",
+ requestId: "3",
+ method: "textDocument/rename",
+ uri: nil,
+ diagnostics: nil,
+ result: .object([
+ "changes": .object([
+ diagnosticURL.standardizedFileURL.path: .array([
+ .object([
+ "range": .object([
+ "start": .object(["line": .integer(0), "utf16Column": .integer(17)]),
+ "end": .object(["line": .integer(0), "utf16Column": .integer(22)])
+ ]),
+ "newText": .string("headline")
+ ])
+ ])
+ ])
+ ]),
+ error: nil
+ )
+ ])
+ }
+ if message.contains(#""id":"4""#) {
+ return response(events: [
+ RustCoreBridge.LspClientEventPayload(
+ kind: "response",
+ requestId: "4",
+ method: "textDocument/formatting",
+ uri: nil,
+ diagnostics: nil,
+ result: .object([
+ "edits": .array([
+ .object([
+ "range": .object([
+ "start": .object(["line": .integer(0), "utf16Column": .integer(10)]),
+ "end": .object(["line": .integer(0), "utf16Column": .integer(10)])
+ ]),
+ "newText": .string(" ")
+ ])
+ ])
+ ]),
+ error: nil
+ )
+ ])
+ }
+ if message.contains(#""id":"5""#) {
+ return response(events: [
+ RustCoreBridge.LspClientEventPayload(
+ kind: "response",
+ requestId: "5",
+ method: "textDocument/codeAction",
+ uri: nil,
+ diagnostics: nil,
+ result: .object([
+ "actions": .array([
+ .object([
+ "title": .string("Fix warning"),
+ "kind": .string("quickfix"),
+ "isPreferred": .bool(true),
+ "command": .object([
+ "title": .string("Apply fix"),
+ "command": .string("source.fix"),
+ "arguments": .array([
+ .object(["uri": .string(diagnosticURL.standardizedFileURL.absoluteString)])
+ ])
+ ]),
+ "data": .object(["token": .string("fix-1")])
+ ])
+ ])
+ ]),
+ error: nil
+ )
+ ])
+ }
+ if message.contains(#""id":"6""#) {
+ return response(events: [
+ RustCoreBridge.LspClientEventPayload(
+ kind: "response",
+ requestId: "6",
+ method: "completionItem/resolve",
+ uri: nil,
+ diagnostics: nil,
+ result: .object([
+ "item": .object([
+ "label": .string("title"),
+ "insertText": .string("title"),
+ "kind": .integer(6),
+ "detail": .string("String"),
+ "documentation": .string("Resolved docs"),
+ "additionalTextEdits": .array([]),
+ "data": .object(["id": .string("completion-1")])
+ ])
+ ]),
+ error: nil
+ )
+ ])
+ }
+ if message.contains(#""id":"7""#) {
+ return response(events: [
+ RustCoreBridge.LspClientEventPayload(
+ kind: "response",
+ requestId: "7",
+ method: "codeAction/resolve",
+ uri: nil,
+ diagnostics: nil,
+ result: .object([
+ "action": .object([
+ "title": .string("Fix warning"),
+ "kind": .string("quickfix"),
+ "isPreferred": .bool(false),
+ "edit": .object([
+ "changes": .object([
+ diagnosticURL.standardizedFileURL.path: .array([
+ .object([
+ "range": .object([
+ "start": .object(["line": .integer(0), "utf16Column": .integer(10)]),
+ "end": .object(["line": .integer(0), "utf16Column": .integer(10)])
+ ]),
+ "newText": .string(" ")
+ ])
+ ])
+ ])
+ ]),
+ "data": .object(["token": .string("fix-1")])
+ ])
+ ]),
+ error: nil
+ )
+ ])
+ }
+ if message.contains(#""id":"8""#) {
+ return response(events: [
+ RustCoreBridge.LspClientEventPayload(
+ kind: "response",
+ requestId: "8",
+ method: "workspace/executeCommand",
+ uri: nil,
+ diagnostics: nil,
+ result: .object(["ok": .bool(true)]),
+ error: nil
+ )
+ ])
+ }
+ if message.contains(#""id":"9""#) {
+ return response(
+ state: .object([:]),
+ messages: [#"{"jsonrpc":"2.0","method":"exit"}"#],
+ events: [
+ RustCoreBridge.LspClientEventPayload(
+ kind: "response",
+ requestId: "9",
+ method: "shutdown",
+ uri: nil,
+ diagnostics: nil,
+ result: .object(["ok": .bool(true)]),
+ error: nil
+ )
+ ]
+ )
+ }
+ return initializeResponse(
+ result: .object([
+ "capabilities": .object([:]),
+ "serverInfo": .object([
+ "name": .string("sourcekit-lsp"),
+ "version": .string("6.2")
+ ])
+ ]),
+ error: nil
+ )
+ }
+
+ private func initializeResponse(
+ result: ToolingJSONValue?,
+ error: String?
+ ) -> RustCoreBridge.LspClientResponsePayload {
+ response(
+ state: .object([
+ "serverCapabilities": .array([
+ .string("hover"),
+ .string("completion"),
+ .string("completionResolve"),
+ .string("rename"),
+ .string("formatting"),
+ .string("codeActions"),
+ .string("codeActionResolve"),
+ .string("executeCommand")
+ ])
+ ]),
+ messages: error == nil && result != nil
+ ? [#"{"jsonrpc":"2.0","method":"initialized","params":{}}"#]
+ : [],
+ events: [
+ RustCoreBridge.LspClientEventPayload(
+ kind: error == nil ? "response" : "error",
+ requestId: "1",
+ method: "initialize",
+ uri: nil,
+ diagnostics: nil,
+ result: result,
+ error: error
+ )
+ ]
+ )
+ }
+
+ func lspFrameMessage(_ message: String) -> RustCoreBridge.LspFramePayload? {
+ RustCoreBridge.LspFramePayload(
+ frame: "Content-Length: \(message.utf8.count)\r\n\r\n\(message)"
+ )
+ }
+
+ func lspParseServerMessages(
+ buffer: [UInt8],
+ chunk: [UInt8]
+ ) -> RustCoreBridge.LspParsedMessagesPayload? {
+ var data = Data(buffer + chunk)
+ var messages: [String] = []
+ while let headerEnd = data.range(of: Data("\r\n\r\n".utf8)) {
+ let headerData = data[..= bodyStart + contentLength else { break }
+ let body = data.subdata(in: bodyStart..<(bodyStart + contentLength))
+ data.removeSubrange(0..<(bodyStart + contentLength))
+ if let message = String(data: body, encoding: .utf8) {
+ messages.append(message)
+ }
+ }
+ return RustCoreBridge.LspParsedMessagesPayload(buffer: Array(data), messages: messages)
+ }
+
+ func lspSessionCreate(
+ rootURL: URL,
+ initializationOptions: ToolingJSONValue?
+ ) -> RustCoreBridge.LspSessionResponsePayload? {
+ initializationRecorder?.options = initializationOptions
+ initializationRecorder?.actions.append("create")
+ return lspClientInitialize(rootURL: rootURL).map(sessionResponse)
+ }
+
+ func lspSessionOpenDocument(
+ sessionID _: String,
+ fileURL: URL,
+ languageID: String,
+ text: String
+ ) -> RustCoreBridge.LspSessionResponsePayload? {
+ initializationRecorder?.actions.append("openDocument")
+ return lspClientOpenDocument(
+ state: .object([:]),
+ fileURL: fileURL,
+ languageID: languageID,
+ text: text
+ ).map(sessionResponse)
+ }
+
+ func lspSessionChangeDocument(
+ sessionID _: String,
+ fileURL: URL,
+ text: String
+ ) -> RustCoreBridge.LspSessionResponsePayload? {
+ initializationRecorder?.actions.append("changeDocument")
+ return lspClientChangeDocument(
+ state: .object([:]),
+ fileURL: fileURL,
+ text: text
+ ).map(sessionResponse)
+ }
+
+ func lspSessionCloseDocument(
+ sessionID _: String,
+ fileURL: URL
+ ) -> RustCoreBridge.LspSessionResponsePayload? {
+ initializationRecorder?.actions.append("closeDocument")
+ return lspClientCloseDocument(state: .object([:]), fileURL: fileURL)
+ .map(sessionResponse)
+ }
+
+ func lspSessionShutdown(
+ sessionID _: String
+ ) -> RustCoreBridge.LspSessionResponsePayload? {
+ initializationRecorder?.actions.append("shutdown")
+ return lspClientShutdown(state: .object([:])).map(sessionResponse)
+ }
+
+ func lspSessionRequest(
+ sessionID _: String,
+ fileURL: URL,
+ method: String,
+ position: LanguageServerPosition?,
+ newName: String?,
+ range: LanguageServerRange?,
+ diagnostics: [LanguageServerDiagnostic],
+ completionItem: LanguageServerCompletionItem?,
+ codeAction: LanguageServerCodeAction?,
+ command: LanguageServerCommand?
+ ) -> RustCoreBridge.LspSessionResponsePayload? {
+ initializationRecorder?.actions.append("request")
+ return lspClientRequest(
+ state: .object([:]),
+ fileURL: fileURL,
+ method: method,
+ position: position,
+ newName: newName,
+ range: range,
+ diagnostics: diagnostics,
+ completionItem: completionItem,
+ codeAction: codeAction,
+ command: command
+ ).map(sessionResponse)
+ }
+
+ func lspSessionApplyServerMessage(
+ sessionID _: String,
+ message: String
+ ) -> RustCoreBridge.LspSessionResponsePayload? {
+ initializationRecorder?.actions.append("applyServerMessage")
+ return lspClientApplyServerMessage(state: .object([:]), message: message)
+ .map(sessionResponse)
+ }
+
+ func lspSessionDestroy(sessionID _: String) {
+ initializationRecorder?.actions.append("destroy")
+ }
+
+ private func response(
+ state: ToolingJSONValue = .object([:]),
+ messages: [String] = [],
+ events: [RustCoreBridge.LspClientEventPayload] = []
+ ) -> RustCoreBridge.LspClientResponsePayload {
+ RustCoreBridge.LspClientResponsePayload(
+ state: state,
+ messages: messages,
+ events: events
+ )
+ }
+
+ private func sessionResponse(
+ _ response: RustCoreBridge.LspClientResponsePayload
+ ) -> RustCoreBridge.LspSessionResponsePayload {
+ let capabilities: [String]
+ if case .object(let state) = response.state,
+ case .array(let values)? = state["serverCapabilities"] {
+ capabilities = values.compactMap {
+ guard case .string(let value) = $0 else { return nil }
+ return value
+ }
+ } else {
+ capabilities = []
+ }
+ if !capabilities.isEmpty {
+ initializationRecorder?.serverCapabilities = capabilities
+ }
+ return RustCoreBridge.LspSessionResponsePayload(
+ sessionId: "test-session",
+ serverCapabilities: initializationRecorder?.serverCapabilities ?? capabilities,
+ messages: response.messages,
+ events: response.events
+ )
+ }
+}
+
+private final class TestLspInitializationRecorder: @unchecked Sendable {
+ var options: ToolingJSONValue?
+ var actions: [String] = []
+ var serverCapabilities: [String] = []
+}
+
private final class RecordingRawProcessSession: RawProcessSession, @unchecked Sendable {
var isRunning = false
var onOutput: (@Sendable (Data) -> Void)?
@@ -3290,14 +4004,31 @@ private final class RecordingRawProcessSession: RawProcessSession, @unchecked Se
var onStateChange: (@Sendable (ProcessLifecycleEvent) -> Void)?
private(set) var requests: [ProcessRequest] = []
private(set) var sentData: [Data] = []
+ var sendFailurePredicate: ((Data) -> Bool)?
func start(_ request: ProcessRequest) throws {
requests.append(request)
isRunning = true
}
- func send(_ input: Data) throws { sentData.append(input) }
+ func send(_ input: Data) throws {
+ if sendFailurePredicate?(input) == true {
+ throw RecordingRawProcessSessionError.sendFailed
+ }
+ sentData.append(input)
+ }
func stop() { isRunning = false }
+ func terminate(exitCode: Int32) {
+ isRunning = false
+ onStateChange?(ProcessLifecycleEvent(
+ operationID: requests.last?.operationID,
+ state: .finished,
+ exitCode: exitCode,
+ message: nil
+ ))
+ onTermination?(exitCode)
+ }
+
func emitJSON(_ object: [String: Any], splitAt: Int? = nil) {
let body = try! JSONSerialization.data(withJSONObject: object)
var framed = Data("Content-Length: \(body.count)\r\n\r\n".utf8)
@@ -3311,6 +4042,12 @@ private final class RecordingRawProcessSession: RawProcessSession, @unchecked Se
}
}
+private enum RecordingRawProcessSessionError: LocalizedError {
+ case sendFailed
+
+ var errorDescription: String? { "Injected language server transport write failure." }
+}
+
@MainActor
private final class RecordingDebugAdapterTransport: DebugAdapterTransport, DebugAdapterChildTransportProviding {
private(set) var isRunning = false
@@ -3338,10 +4075,6 @@ private final class RecordingDebugAdapterTransport: DebugAdapterTransport, Debug
}
}
-private struct EmptyArchiveEntryReader: ArchiveEntryReader {
- func read(entry: String, from archive: URL) -> String? { nil }
-}
-
private final class RecordingProcessFactory: @unchecked Sendable {
private(set) var processes: [RecordingStreamingProcess] = []
@@ -3414,7 +4147,6 @@ private struct RunTestRuntimeLocator: RuntimeLocator {
)
}
func systemJDBExecutable() -> URL? { URL(fileURLWithPath: "/toolchains/jdk/bin/jdb") }
- func javaLanguageServerExecutable() -> URL? { URL(fileURLWithPath: "/toolchains/jdtls") }
}
private struct MissingJavaRuntimeLocator: RuntimeLocator {
@@ -3429,7 +4161,6 @@ private struct MissingJavaRuntimeLocator: RuntimeLocator {
func mavenExecutable(forHomePath path: String) -> URL? { nil }
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil }
func systemJDBExecutable() -> URL? { nil }
- func javaLanguageServerExecutable() -> URL? { nil }
}
private struct XcrunOnlyRuntimeLocator: RuntimeLocator {
@@ -3442,46 +4173,6 @@ private struct XcrunOnlyRuntimeLocator: RuntimeLocator {
func mavenExecutable(forHomePath path: String) -> URL? { nil }
func mavenRuntime(at executableURL: URL) -> MavenRuntimeCandidate? { nil }
func systemJDBExecutable() -> URL? { nil }
- func javaLanguageServerExecutable() -> URL? { nil }
-}
-
-@MainActor
-private final class TestLanguageServerSession: LanguageServerSession {
- private(set) var isRunning = false
- private(set) var startCount = 0
- private(set) var stopCount = 0
-
- func start(rootURL: URL) throws {
- startCount += 1
- isRunning = true
- }
-
- func stop() {
- stopCount += 1
- isRunning = false
- }
-}
-
-@MainActor
-private final class TestLanguageProviderRuntime: LanguageProviderRuntime {
- let descriptor: LanguageProviderDescriptor
- private(set) var languageServers: [TestLanguageServerSession] = []
- private(set) var makeCount = 0
-
- var languageServer: TestLanguageServerSession { languageServers[0] }
-
- init(descriptor: LanguageProviderDescriptor) {
- self.descriptor = descriptor
- }
-
- func makeLanguageServerSession() -> (any LanguageServerSession)? {
- makeCount += 1
- let session = TestLanguageServerSession()
- languageServers.append(session)
- return session
- }
-
- func makeDebugAdapterSession() -> (any DebugAdapterSession)? { nil }
}
@MainActor
@@ -3531,7 +4222,7 @@ private final class TestDebugLanguageProviderRuntime: LanguageProviderRuntime {
self.descriptor = descriptor
supportsDebugAdapterSession = supportsDebugAdapter
}
- func makeLanguageServerSession() -> (any LanguageServerSession)? { nil }
+
func makeDebugAdapterSession() -> (any DebugAdapterSession)? {
let session = TestDebugAdapterSession()
debugAdapters.append(session)
@@ -3539,6 +4230,36 @@ private final class TestDebugLanguageProviderRuntime: LanguageProviderRuntime {
}
}
+@MainActor
+private final class TestLanguageServerRuntime: LanguageProviderRuntime {
+ let descriptor: LanguageProviderDescriptor
+ let supportsLanguageServerSession = true
+ private let session: any LanguageServerSession
+
+ init(descriptor: LanguageProviderDescriptor, session: any LanguageServerSession) {
+ self.descriptor = descriptor
+ self.session = session
+ }
+
+ func makeLanguageServerSession() -> (any LanguageServerSession)? { session }
+ func makeDebugAdapterSession() -> (any DebugAdapterSession)? { nil }
+}
+
+@MainActor
+private final class TestLanguageProviderRuntimeFactory: LanguageProviderRuntimeFactory {
+ private(set) var createdDescriptors: [LanguageProviderDescriptor] = []
+
+ func makeRuntime(
+ for descriptor: LanguageProviderDescriptor
+ ) -> (any LanguageProviderRuntime)? {
+ createdDescriptors.append(descriptor)
+ return TestDebugLanguageProviderRuntime(
+ descriptor: descriptor,
+ supportsDebugAdapter: true
+ )
+ }
+}
+
@MainActor
private final class TestDlvSocketConnection: DlvSocketConnection {
var onReady: (() -> Void)?
diff --git a/docs/architecture/language-tooling.md b/docs/architecture/language-tooling.md
new file mode 100644
index 000000000..f9d46d355
--- /dev/null
+++ b/docs/architecture/language-tooling.md
@@ -0,0 +1,189 @@
+# 语言工具与 LSP 架构
+
+本文说明 Lithe 当前的语言能力分层、LSP 兼容边界,以及接入新语言服务器时必须遵守的约束。公开的 Rust JSON 命令仍以
+[`rust-core-api.md`](../../shared/contracts/rust-core-api.md) 为准。
+
+## 设计目标
+
+语言能力不应等同于“已经启动一个 LSP 进程”。当前实现遵循以下规则:
+
+1. 编辑器只依赖统一的 `LanguageFeatureProvider`,不直接依赖具体语言服务器。
+2. 轻量本地能力无需外部进程,LSP 是按需启动的语义增强层。
+3. 可调用的 LSP 功能以服务器 `initialize` 响应和动态注册结果为准,不能根据语言名称硬编码。
+4. LSP 进程、stdio、JSON-RPC 状态机、deadline 和结果归一化属于 Rust Core;平台 adapter 只负责可执行文件与运行环境发现。
+5. 单个 provider 失败、缺失或返回空结果时,不应阻断仍可工作的本地能力。
+
+## 组件边界
+
+```mermaid
+flowchart LR
+ UI["Editor / feature model"] --> MANAGER["LanguageToolingSessionManager"]
+ MANAGER --> ROUTER["LanguageFeatureProvider routing"]
+ ROUTER --> BUILTIN["Builtin provider
keywords + current-file symbols"]
+ ROUTER --> LSPPROVIDER["LSP provider
server capabilities"]
+ LSPPROVIDER --> SESSION["Swift semantic facade
opaque operation IDs"]
+ SESSION --> CORE["Rust LSP runtime
process + state + deadlines + stdio"]
+ CORE --> SERVER["gopls / jdtls / rust-analyzer / ..."]
+```
+
+| 层 | 职责 | 不负责 |
+| --- | --- | --- |
+| `LanguageToolingSessionManager` | 文档同步、provider 选择、结果降级/合并、诊断和会话归属 | JSON-RPC 编解码、直接启动 `Process` |
+| `LanguageFeatureProvider` | 声明单项能力、优先级和统一结果类型 | 维护 UI 状态 |
+| `BuiltinLanguageFeatureProvider` | 当前文件标识符、轻量 hover/导航、语言关键字 | 类型推断、跨文件索引 |
+| `LanguageServerFeatureProvider` | 将已协商的服务器能力适配到统一 provider 接口 | 猜测服务器能力 |
+| `StdioLanguageServerSession` | 调用语义命令、投影 typed event,并以不透明 operation ID 交付 UI 回调 | LSP 请求 ID、frame、文档版本、协议超时或子进程 |
+| Rust Core | LSP 子进程与 stdio、session/document state、请求 ID、deadline、frame、UTF-16 位置、结果归一化、动态能力 | 可执行文件发现、UI provider 路由 |
+| macOS adapter | 工具发现、环境变量和用户可执行文件覆盖 | LSP 子进程、语言功能路由和协议语义 |
+
+Rust Core 的 LSP 实现统一收在 `rust/lithe-core/src/lsp/`,根模块只作为稳定 facade,command runtime 仍通过 `crate::lsp::*` 使用公开契约:
+
+```text
+lsp/
+├── interface/ # 通用 LSP engine、协议 reducer、transport 与稳定 DTO
+│ ├── types.rs
+│ ├── client.rs
+│ ├── transport.rs
+│ ├── host.rs
+│ └── engine.rs
+├── lightweight/ # 不启动语言服务器的编辑、snippet 和当前文件符号能力
+│ ├── edits.rs
+│ ├── snippets.rs
+│ └── symbols.rs
+└── languages/ # provider catalog 与语言/宿主模型 adapter
+ ├── catalog.rs
+ ├── jdt.rs
+ └── swift.rs
+```
+
+共享的 LSP position/range 与协议 DTO 只能定义在 `interface/types.rs`;对应用公开的 runtime command/event DTO 位于 `interface/engine.rs`。`lightweight` 可以依赖这些协议 DTO,但 `interface` 不依赖轻量实现。`languages/jdt.rs` 封装 JDTLS 启动参数、配置与虚拟源码语义,generic engine 不按 Java 硬编码 capability。provider catalog 位于 `languages`,因为它描述可动态加载的语言/provider 元数据,而不是 client 状态机的一部分。
+
+## Provider 路由
+
+当前优先级由高到低为 `languageServer (200)`、预留的 `projectSymbols (100)`、`builtin (0)`。每次请求先按文件和功能过滤 provider,再按优先级路由:
+
+- **Completion**:依次收集所有成功结果,保持高优先级顺序,并按 `label` 去重。因此 LSP 可提供精确候选,本地关键字和当前文件符号仍能补足结果。
+- **Hover**:返回第一个非空结果;LSP 无结果或失败时继续询问本地 provider。
+- **Definition/References/Implementation**:返回第一个非空位置列表,并在 LSP 不可用时降级到当前文件文本级导航。
+- **Rename/Formatting/Code Action/Resolve/Execute Command**:目前仍是 LSP-only;未运行或未声明相应能力时应返回明确的 capability 错误。
+
+provider 抛错不会让路由提前结束。这个策略用于隔离第三方语言服务器故障,但也意味着新增 provider 时必须给出稳定优先级,并避免返回伪造的“成功但无意义”结果。
+
+## 无进程能力
+
+Rust Core 的 `lsp.builtinCompletions`、`lsp.builtinHover` 和
+`lsp.builtinNavigation` 只读取当前文件文本。Swift 层另外为 Go、Swift、Rust、Python、JavaScript 和 TypeScript 提供关键字候选;即使 Rust Core 未链接,关键字补全仍可使用。
+
+这些结果是可用性降级,不是类型系统:
+
+- 不解析依赖,不启动构建工具,不访问网络;
+- 不保证跨文件定义、重载解析或类型正确性;
+- 位置统一使用零基行号和 UTF-16 列,确保可与 AppKit 和 LSP 结果合并。
+
+## Catalog 与工具发现
+
+内置 provider catalog 位于
+[`rust/lithe-core/resources/lsp/language-providers.json`](../../rust/lithe-core/resources/lsp/language-providers.json)。项目可以通过 `.lithe/lsp/language-providers.json` 按 `id` 覆盖内置字段、添加 provider,或使用 `disabled: true` 禁用 provider。配置格式由 [`docs/reference/language-providers.schema.json`](../reference/language-providers.schema.json) 定义:
+
+```json
+{
+ "$schema": "../../docs/reference/language-providers.schema.json",
+ "version": 2,
+ "providers": [
+ {
+ "id": "go",
+ "languageServerLaunch": {
+ "executableNames": ["gopls-custom", "gopls"],
+ "arguments": [],
+ "environment": {
+ "GOTOOLCHAIN": "auto"
+ },
+ "initializationOptions": {}
+ },
+ "languageServerInstallation": {
+ "homebrewFormula": "gopls",
+ "officialDownloadURL": "https://go.dev/gopls/"
+ }
+ },
+ {
+ "id": "templ",
+ "displayName": "Templ",
+ "fileExtensions": ["templ"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "templ",
+ "languageServerLaunch": {
+ "executableNames": ["templ"],
+ "arguments": ["lsp"]
+ }
+ }
+ ]
+}
+```
+
+`executableNames` 按顺序尝试,`environment` 覆盖 Lithe 进程环境中的同名键,`initializationOptions` 原样进入 LSP `initialize` 参数。可选的 `validationArguments` 会在候选进入 session 解析前直接执行,例如 Rust provider 使用 `["--version"]` 排除存在于 `PATH` 但缺少组件的 rustup proxy。探测结果按路径和参数缓存 30 秒,退出码非零或超时的候选不会被视为可用。catalog 更新后,session manager 会丢弃 descriptor 已变化的旧会话和 runtime,并由 runtime factory 根据新 descriptor 延迟创建 runtime;因此项目新增 provider 或覆盖启动命令不再受应用启动时的内置 runtime 列表限制。
+
+macOS discovery 的查找顺序包括项目 `.lithe` 工具目录、`LITHE__PATH`/`LITHE_TOOL__PATH`、`PATH` 和常见系统目录;`gopls` 等 Go 工具还会检查 `GOBIN`、`GOPATH/bin`、`~/go/bin` 和 `~/.go/bin`。discovery 只查找,不自动安装软件。
+
+LSP 控制中心标题栏的工具设置会在用户偏好中保存每个 provider 的可执行文件覆盖路径。session 创建时先验证并使用该路径,路径失效时继续使用 catalog 候选进行自动探测。Homebrew formula 和官方兜底地址都来自 `languageServerInstallation`,Swift 不维护 provider ID 映射。安装仍由平台层以参数数组直接执行 `brew install`,不经过 shell;没有 Homebrew/formula 时只打开对应项目的 HTTPS 官方发布或安装页面,避免用一套不安全的通用解压逻辑处理不同项目的签名和包结构。
+
+项目配置是可执行工具配置,只有打开受信任项目时才应启用。JSON 可以声明 executable name 和参数,但不能声明 shell、任意安装命令或关闭路径/URL 校验;进程创建、超时、可执行文件验证、Homebrew 调用方式和 HTTPS 限制仍属于平台安全边界。
+
+## LSP 会话与兼容性
+
+当前 transport 是 LSP 标准的 stdio `Content-Length` framing。一个 provider 在一个 workspace root 下复用一个 session;同一 provider 切换到另一个 root 时,manager 会停止旧 session 并创建新 session。
+
+生产路径由 Rust engine 持有长生命周期 `sessionID -> RuntimeSession` registry。每个 runtime 同时拥有子进程、stdio、frame buffer、文档版本、pending request/deadline、capability 和 diagnostics;Swift 只保存不透明 `sessionID` 与 application-level `operationID`。`syncDocument` 由 Rust 决定发送 version 1 的 `didOpen` 或递增版本的 `didChange`,`pollEvents` 只返回 typed state/feature/diagnostic/result/error 事件。协议 reducer/host 只作为 engine 内部实现与纯函数测试 seam,不属于应用公开命令面。
+
+启动顺序:
+
+1. Swift 完成可执行文件和环境发现,向 Rust 提交 typed `startServer`;
+2. Rust engine 创建 session、启动进程并安装 stdout/stderr reader,再发送 `initialize`;
+3. 收到响应后,Rust 保存服务器 capability,发送 `initialized` 和 provider adapter 通知;
+4. manager 发布实际 capability,随后通过 `didOpen`/全量 `didChange` 同步文档;
+5. Rust 以 LSP request ID 关联 deadline,并用不透明 operation ID 把 terminal result 投影给 Swift。
+
+服务端 capability 可以来自 initialize 响应,也可以通过 `client/registerCapability` 和
+`client/unregisterCapability` 动态变化。客户端处理有文档/version 归属的 diagnostics、workspace configuration/folders、work-done progress 和 apply-edit 协议;未知的服务端 request 返回 JSON-RPC `Method not found`,未知 notification 作为 typed log/event 保留。
+
+关闭文档时发送 `textDocument/didClose` 并清除该文档诊断。停止 session 时先请求 `shutdown`,收到响应后发送 `exit`;若服务器无响应,则由超时路径强制停止进程。不要直接以 `terminate()` 代替正常 LSP 关闭流程。
+
+### 当前限制
+
+- 只支持 stdio transport,尚无 socket/TCP 或服务器自定义握手 adapter。
+- session 当前以 provider ID 和单个 workspace root 为单位,尚无 multi-root session。
+- `workspace/applyEdit` 只提供协议确认和 normalized edit 数据,实际应用仍必须经过编辑器工作区安全校验。
+- initialize 可协商 snippet、resolve、inlay hint、folding range、code lens 和 workspace symbol;UI 只启用已完整投影且服务器实际声明的能力。
+- 文档同步当前发送全量文本,没有按服务器类型实现增量 diff。
+- catalog 描述的是“可尝试启动的工具”;最终功能必须以运行时服务器 capability 为准。
+- project config 是受信任的项目配置,只接受 schema 中的 typed 字段,不执行 shell 命令。
+
+## 接入新 LSP 的检查清单
+
+1. 在 catalog 中定义稳定 `id`、文件匹配规则、`languageId`、候选 executable 和参数;存在 shim/proxy 的工具应声明无副作用的 `validationArguments`。
+2. 需要安装入口时定义 `languageServerInstallation`;不要在 Swift UI 中增加 provider ID 分支。
+3. 确认服务器支持 stdio 和标准 `Content-Length` framing。
+4. 不在 UI 或 manager 中按语言写分支;服务器差异应进入 descriptor 或独立 adapter。
+5. 用 initialize 响应验证 capability,不把 catalog 的 `languageServer` 标记当成 feature 支持证明。
+6. 至少测试 initialize timeout/error、didOpen/change/close、请求 timeout/late response、shutdown/exit、强制停止和异常退出。
+7. 包含 malformed/partial/multiple frame、动态能力、stale diagnostics、UTF-16、带空格/非 ASCII 文件 URI,以及启动即输出的场景。
+
+## 真实 gopls 验证
+
+[`RealGoplsIntegrationTests.swift`](../../Tests/LitheTests/RealGoplsIntegrationTests.swift)
+会穿过 manager、Swift semantic facade 和拥有进程的真实 Rust Core。测试默认不启动外部工具,需要显式开启:
+
+```bash
+scripts/build-rust-core.sh --debug --target aarch64-apple-darwin
+
+LITHE_RUN_GOPLS_INTEGRATION=1 \
+LITHE_GOPLS_PATH="$HOME/.go/bin/gopls" \
+swift test --disable-sandbox --no-parallel \
+ --triple arm64-apple-macosx \
+ -Xswiftc -Xfrontend -Xswiftc -disable-round-trip-debug-types \
+ -Xlinker -force_load \
+ -Xlinker "$(pwd)/rust/target/macos/aarch64-apple-darwin/debug/liblithe_core.a" \
+ --filter RealGoplsIntegrationTests
+```
+
+`-force_load` 是必要条件:测试包同时包含 C bridge 的 weak fallback;普通链接可能在没有加载 Rust archive 的情况下仍然成功。Intel macOS 需要把 target/triple 和库路径替换为对应的 `x86_64` 产物。
diff --git a/docs/architecture/lsp-runtime-migration.md b/docs/architecture/lsp-runtime-migration.md
new file mode 100644
index 000000000..8d363961f
--- /dev/null
+++ b/docs/architecture/lsp-runtime-migration.md
@@ -0,0 +1,167 @@
+# Rust LSP Runtime Migration
+
+This document records the ownership audit that preceded the `feat/LSP`
+runtime migration. It is intentionally implementation-oriented: each phase
+names the state owner, the compatibility code that must disappear, and the
+evidence required before the phase is complete.
+
+The Windows application is not part of this iteration. The public Rust
+contract remains cross-platform, but adapting the Qt client is tracked by the
+Windows workstream.
+
+## Baseline ownership audit
+
+At commit `7fae1da`, Rust owned the protocol reducer while Swift still owned
+the live runtime:
+
+```text
+Swift LanguageToolingSessionManager
+ -> Swift StdioLanguageServerSession
+ - child process and stdio
+ - Content-Length read buffer
+ - initialized / stopping state
+ - opened-document set and pending documents
+ - request completion handlers and timeout tasks
+ - shutdown fallback
+ -> Rust LspHost handle
+ - JSON-RPC construction and parsing
+ - request IDs
+ - document versions
+ - negotiated capabilities
+ - diagnostics
+```
+
+That split left the following duplicated or competing state:
+
+| Concern | Swift baseline | Rust baseline |
+| --- | --- | --- |
+| Document lifecycle | `openedDocumentURIs`, `pendingDocuments` | `open_documents` |
+| Initialization | `isInitialized` and process callbacks | `initialized` |
+| Request lifecycle | `responseHandlers`, timeout tasks | `pending_requests` |
+| Transport | `readBuffer`, raw process send/receive | stateless frame/parser functions |
+| Diagnostics | provider projection plus legacy Java store | URI-indexed diagnostics |
+| Shutdown | timer and force terminate | shutdown JSON-RPC reducer |
+
+The old public command surface also exposed implementation details:
+`lsp.clientOpenDocument`, `lsp.clientChangeDocument`,
+`lsp.clientApplyServerMessage`, `lsp.sessionExecute`, `lsp.frameMessage`, and
+`lsp.parseServerMessages`.
+
+## Target ownership
+
+```text
+SwiftUI / application facade
+ -> semantic Rust commands
+ startServer / stopServer
+ syncDocument / closeDocument
+ completion / hover / navigation / rename / format / code actions
+ resolve / execute / cancel
+ pollEvents
+ -> Rust LSP engine
+ provider adapter and launch configuration
+ process + stdin/stdout/stderr
+ lifecycle state machine
+ framing + JSON-RPC
+ document and version store
+ pending request deadlines and terminal outcomes
+ negotiated capabilities
+ diagnostics by session/document/version
+ graceful shutdown, crash handling, and restart isolation
+```
+
+Swift may retain UI projections and application-level completion closures
+keyed by opaque operation IDs. It must not retain LSP request IDs, raw JSON,
+framing buffers, child-process handles, document-open truth, or protocol
+timeouts.
+
+## Migration phases
+
+### 1. State convergence
+
+- Add one Rust lifecycle enum covering process start through terminal states.
+- Replace split open/change calls with `syncDocument`; Rust decides whether to
+ emit `didOpen` version 1 or `didChange` with the next version.
+- Track request metadata and deadlines in Rust.
+- Store diagnostic version metadata and clear it on close, stop, crash,
+ restart, provider reconfiguration, and workspace replacement.
+
+Primary files:
+
+- `rust/lithe-core/src/lsp/interface/{engine,host,client,types}.rs`
+- `rust/lithe-core/src/lsp/tests.rs`
+
+### 2. Runtime and transport
+
+- Spawn and own the language-server child process in Rust using a
+ cross-platform process abstraction.
+- Move stdin/stdout/stderr, partial-frame buffering, and malformed-frame
+ failure handling into the Rust session.
+- Add an event queue drained through `lsp.pollEvents`.
+- Implement initialize, request, and shutdown deadlines; fail every pending
+ request exactly once on timeout, cancellation, crash, stop, or restart.
+
+Primary files:
+
+- `rust/lithe-core/src/lsp/interface/{engine,transport}.rs`
+- `rust/lithe-core/src/protocol/command.rs`
+- `rust/lithe-core/src/runtime/dispatcher.rs`
+
+### 3. Application facade
+
+- Reduce `StdioLanguageServerSession.swift` to semantic commands, event
+ polling, model conversion, and opaque operation completion delivery.
+- Stop constructing `RawProcessSession` for LSP. DAP keeps its independent
+ transport boundary.
+- Remove state-passing and raw-message APIs from `RustCoreBridge.swift`.
+- Keep `LanguageToolingSessionManager` as the UI-facing provider router and
+ read-only projection.
+
+Primary files:
+
+- `Sources/Lithe/Services/StdioLanguageServerSession.swift`
+- `Sources/Lithe/Services/StdioLanguageProviderRuntime.swift`
+- `Sources/Lithe/Services/LanguageToolingSessionManager.swift`
+- `Sources/Lithe/Core/RustCoreBridge.swift`
+- `Sources/Lithe/Core/Ports/LanguageTooling.swift`
+
+### 4. Provider convergence and legacy deletion
+
+- Keep Maven, Run, Debug, JDK discovery, and local lightweight parsing outside
+ the LSP runtime.
+- Put JDTLS launch arguments, Java configuration responses, and virtual source
+ semantics behind the Rust provider adapter.
+- Delete the empty Java diagnostics and implementation-marker compatibility
+ paths rather than retaining a second potential truth source.
+- Delete the legacy client/session/frame/parse command surface after the
+ production facade has migrated.
+
+Primary files:
+
+- `rust/lithe-core/src/lsp/languages/jdt.rs`
+- `Sources/Lithe/Application/JavaFeatureModel.swift`
+- `Sources/Lithe/Models/JavaDiagnosticModels.swift`
+- `Sources/Lithe/Views/CodeEditorView.swift`
+
+## Completion evidence
+
+The migration is complete only when tests demonstrate all of the following:
+
+1. A spawned process that never initializes cannot become ready.
+2. An initialize error cannot become ready.
+3. Two syncs emit open version 1 and change version 2.
+4. A crash fails pending operations with `serverExited`.
+5. A request deadline removes the pending request.
+6. A late response after timeout is ignored.
+7. Responses from an old session cannot affect a restarted session.
+8. Old-session and old-document-version diagnostics are ignored.
+9. Closing a document clears Rust document and diagnostic state.
+10. Shutdown sends exit after the response and force-terminates on timeout.
+11. Malformed `Content-Length` produces a transport failure.
+12. A partial stdout frame is retained and completed in Rust.
+13. Consecutive frames are parsed in order.
+14. Dynamic capability registration and unregistration update availability.
+15. Workspace replacement stops the old root and clears its state.
+
+Architecture searches must additionally show no production Swift ownership of
+LSP `Content-Length`, raw JSON-RPC request IDs, frame buffers, open-document
+sets, pending LSP requests, or language-server child processes.
diff --git a/docs/architecture/mac-service-boundaries.md b/docs/architecture/mac-service-boundaries.md
index c8587e79c..ef1216122 100644
--- a/docs/architecture/mac-service-boundaries.md
+++ b/docs/architecture/mac-service-boundaries.md
@@ -35,7 +35,7 @@ implementations.
| `Sources/Lithe/Views/` | SwiftUI/AppKit presentation, input, navigation destinations, and view-local rendering. |
| `Sources/Lithe/Models/` | UI-facing models and value types. `AppModel` is the observable aggregate, not the platform composition root. |
| `Sources/Lithe/Application/` | Workspace, Document, Git, Search, Java, Terminal, Project History, and UI Feature Models. These coordinate state and user actions. |
-| `Sources/Lithe/Services/` | Product workflow orchestration. Git, workspace, search, and history use Rust operations; Java LSP and Maven/Run/Debug process lifecycles remain Swift workflows behind ports. |
+| `Sources/Lithe/Services/` | Product workflow orchestration. Language feature routing plus Maven/Run/Debug lifecycles remain Swift workflows; the LSP service is a semantic facade over the Rust runtime. |
| `Sources/Lithe/Core/Ports/` | Platform-neutral interfaces for process, terminal, storage, runtime discovery, file operations, watchers, and native UI capabilities. |
| `Sources/Lithe/Core/Rust*` | Typed operations and model conversion for the shared Rust JSON contract. |
| `Sources/Lithe/Platform/MacOS/` | FSEvents, file operations, persistence, process sessions, PTY, runtime discovery, native UI, shortcuts, and updates. |
@@ -48,9 +48,11 @@ or concrete `Mac*` types. They must not construct `Process`, `Pipe`,
`FileManager`, `UserDefaults`, or `FileHandle` directly.
Services must receive those capabilities through ports. A Service may own a
-workflow state machine, such as the JDT LS protocol or Maven/Debug lifecycle,
-but it must not decide how the operating system starts, watches, stores, or
-terminates the underlying resource.
+workflow state machine, such as language-provider routing or Maven/Debug
+lifecycle, but it must not decide how the operating system starts, watches,
+stores, or terminates the underlying resource. The Rust LSP runtime is the
+sole owner of its child process, stdio, JSON-RPC state, document versions,
+request deadlines, diagnostics, and message normalization.
Views receive `AppModel` or a dedicated UI Feature Model. They must not receive
concrete workflow services, call the Rust C ABI directly, or construct platform
@@ -64,6 +66,9 @@ The Rust Core owns deterministic cross-platform behavior:
- Local History metadata and snapshot operations;
- Maven descriptor and diagnostic parsing;
- Java source structure, code vision, class-name, and run-configuration parsing;
+- lightweight language features and the complete LSP runtime: process,
+ stdio/framing, lifecycle, documents, deadlines, capabilities, diagnostics,
+ provider adapters, and normalized feature results;
- request envelopes, cancellation, deadlines, error codes, validation, and
stable JSON ordering.
@@ -71,9 +76,9 @@ macOS owns the platform side of these capabilities:
- workspace selection, FSEvents, atomic/native file operations, permissions,
persistence location, and Finder integration;
-- JDK/Maven/JDT LS discovery and process sessions;
+- language-server/JDK/Maven discovery and platform environment resolution;
- Java/Maven/Debug process transports, terminal PTY, shell, signals, and
- native handles;
+ native handles (LSP process transport belongs to Rust);
- native window, menu, clipboard, shortcut, installer, and update behavior.
## Verification
@@ -92,7 +97,8 @@ with `scripts/verify-shared-contracts.sh` and `scripts/verify-rust-core.sh`.
## Remaining migration work
The current boundary is usable and enforced, but it is not a claim that every
-workflow has moved into Rust. Java LSP protocol state, Maven execution, Java
-Run/Debug sessions, and terminal session state are still Swift application
-workflows using platform ports. Their data models and lifecycle events should
-be promoted into shared contracts before Windows implements equivalent UI.
+workflow has moved into Rust. Language provider routing remains an application
+workflow, while the LSP process lifecycle and protocol state are shared Rust
+contracts. Maven execution, Java Run/Debug sessions, and terminal session
+state still use Swift platform ports. See
+[`language-tooling.md`](language-tooling.md) for the language tooling split.
diff --git a/docs/architecture/repository-layout.md b/docs/architecture/repository-layout.md
index f58def826..48ee0310c 100644
--- a/docs/architecture/repository-layout.md
+++ b/docs/architecture/repository-layout.md
@@ -51,6 +51,26 @@ Both platforms consume `rust/lithe-core` through the same JSON envelope and
command names. Shared behavior belongs in `shared/contracts/` and should have
a fixture under `shared/fixtures/` before the second platform relies on it.
+## Rust Core packages
+
+`rust/lithe-core/src/lib.rs` is only the crate composition root and public API. Rust implementation files are grouped by stable ownership boundary instead of being added beside `lib.rs`:
+
+```text
+rust/lithe-core/src/
+├── protocol/ # command names, wire contracts, responses, errors, events, cancellation
+├── runtime/ # JSON dispatcher and C ABI exports
+├── project/ # files/search, local history, Markdown, Maven project inspection
+├── execution/ # run configuration, launch/toolchain models, project detectors
+├── languages/ # language-specific source inspection such as Java
+├── git/ # Git validation, parsing, state, and mutations
+├── lsp/ # generic LSP, lightweight fallback, provider/Swift adapters
+└── tests/ # command-level tests grouped by the same domains
+```
+
+The dependency direction is `protocol <- domain packages <- runtime/FFI`. A domain may use protocol contracts, but it must not depend on the runtime dispatcher. `execution/types.rs` is the shared type layer for configuration and detectors, so those modules do not import each other through the package facade. `lsp/mod.rs` and the other package `mod.rs` files are compatibility facades; new implementation logic belongs in an owned submodule rather than in the facade.
+
+Moving Rust files must not change JSON command strings, Serde field names, error codes, or the exported C symbols. Directory-sensitive fixtures and embedded resources must use `CARGO_MANIFEST_DIR` instead of paths derived from a module's current depth.
+
## Ownership rules
| Shared Rust Core | Platform-owned adapters |
@@ -59,13 +79,18 @@ a fixture under `shared/fixtures/` before the second platform relies on it.
| UTF-8 file command validation and results | Native file APIs, permissions, and persistence paths |
| Git models, validation, parsing, and mutations | Executable environment and credentials |
| History metadata and snapshot rules | History storage location and file movement |
-| Maven and Java source parsing | JDK/Maven/JDT LS discovery and child processes |
+| Language provider catalog, lightweight features, complete LSP runtime, Maven, and Java source parsing | Language-server/JDK/Maven discovery; Maven/Debug child processes |
| Error codes, cancellation, deadlines, and JSON envelope | PTY/ConPTY, signals, handles, and native UI |
The UI must depend on feature models and shared models, not on a concrete
adapter. Core and Services must remain free of AppKit, SwiftUI, Win32, Qt,
`Process`, and direct platform file APIs.
+Language tooling has an additional protocol/application split: Rust owns the
+complete LSP process/session runtime and normalized results, while platform
+services own discovery, provider routing, and UI projection. The complete rules are in
+[`language-tooling.md`](language-tooling.md).
+
## Repository hygiene
Do not commit generated outputs such as `.build/`, `.swiftpm/`, `dist/`,
diff --git a/docs/reference/language-providers.schema.json b/docs/reference/language-providers.schema.json
new file mode 100644
index 000000000..c73c4e3d8
--- /dev/null
+++ b/docs/reference/language-providers.schema.json
@@ -0,0 +1,108 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://lithe.dev/schemas/language-providers.schema.json",
+ "title": "Lithe language provider catalog",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["version", "providers"],
+ "properties": {
+ "$schema": { "type": "string" },
+ "version": { "type": "integer", "minimum": 1 },
+ "providers": {
+ "type": "array",
+ "items": { "$ref": "#/$defs/provider" }
+ }
+ },
+ "$defs": {
+ "provider": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$"
+ },
+ "displayName": { "type": "string", "minLength": 1 },
+ "fileExtensions": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ },
+ "fileNames": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ },
+ "fileNamePrefixes": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1 },
+ "uniqueItems": true
+ },
+ "capabilities": {
+ "type": "array",
+ "items": {
+ "enum": ["run", "languageServer", "debugAdapter", "formatting", "testing"]
+ },
+ "uniqueItems": true
+ },
+ "activationPolicy": { "enum": ["onDemand", "always"] },
+ "languageId": { "type": "string", "minLength": 1 },
+ "languageIdsByExtension": { "$ref": "#/$defs/stringMap" },
+ "languageIdsByFileName": { "$ref": "#/$defs/stringMap" },
+ "languageServerLaunch": { "$ref": "#/$defs/languageServerLaunch" },
+ "languageServerInstallation": { "$ref": "#/$defs/languageServerInstallation" },
+ "disabled": { "type": "boolean" }
+ }
+ },
+ "stringMap": {
+ "type": "object",
+ "additionalProperties": { "type": "string", "minLength": 1 }
+ },
+ "languageServerLaunch": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["executableNames"],
+ "properties": {
+ "executableNames": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]*$"
+ },
+ "uniqueItems": true
+ },
+ "arguments": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "validationArguments": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "environment": { "$ref": "#/$defs/stringMap" },
+ "initializationOptions": {}
+ }
+ },
+ "languageServerInstallation": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "homebrewFormula": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9@+._/-]*$"
+ },
+ "officialDownloadURL": {
+ "type": "string",
+ "format": "uri",
+ "pattern": "^https://"
+ }
+ },
+ "anyOf": [
+ { "required": ["homebrewFormula"] },
+ { "required": ["officialDownloadURL"] }
+ ]
+ }
+ }
+}
diff --git "a/docs/\351\234\200\346\261\202\345\274\200\345\217\221\344\271\246.md" "b/docs/\351\234\200\346\261\202\345\274\200\345\217\221\344\271\246.md"
index 7f705db08..344286b3d 100644
--- "a/docs/\351\234\200\346\261\202\345\274\200\345\217\221\344\271\246.md"
+++ "b/docs/\351\234\200\346\261\202\345\274\200\345\217\221\344\271\246.md"
@@ -101,16 +101,18 @@ Lithe 自动发现文件与 Git 状态变化
- 基础语法着色
- 路径面包屑
-编辑器用于阅读和少量修正,不以大量人工编码为主要目标。首期仅为 Java 接入方法定义跳转和引用查找,不包含自动补全或重构系统。
+编辑器用于阅读和少量修正,不以大量人工编码为主要目标。基础补全、hover 和当前文件导航由无进程 provider 提供;安装对应语言服务器后,再按服务端实际声明的 capability 增强语义能力。安全重构仍不属于基础能力。
-### 5.5 Java 代码导航
+### 5.5 语言能力与 Java 代码导航
-- 使用 Eclipse JDT Language Server
+- 语言功能统一通过 provider 路由,不让编辑器直接依赖具体 LSP
+- Go、Swift、Rust、Python、JavaScript 和 TypeScript 在未启动 LSP 时仍提供关键字补全
+- Java 使用 Eclipse JDT Language Server;Go、Rust 等语言可通过 catalog 接入标准 stdio LSP
- 光标处方法或符号跳转到定义
- 查找引用并在底部结果列表展示
- 点击结果打开文件并定位到精确行列
- 编辑器右键菜单与 Navigate 主菜单入口
-- 打开 Java 项目后后台启动语言服务器,关闭项目时停止
+- 语言服务器按需启动;关闭文档发送 `didClose`,停止会话执行 `shutdown`/`exit`
- 接收 JDT LS `textDocument/publishDiagnostics`,显示错误、警告、信息和提示级别
- Problems 工具窗口支持按诊断严重级别筛选,并保留文件、行列、source、code 和关联信息
- 对 JDT LS 标记为 `unnecessary` 或明确未使用的代码范围进行弱化显示,同时保留可点击诊断提示
@@ -204,8 +206,8 @@ Lithe 自动发现文件与 Git 状态变化
- Windows 当前处于独立实现阶段,不纳入当前 macOS 发布版的功能承诺和验收范围。
- AI、Codex 或其他模型调用
- Agent 会话和聊天界面
-- Java 以外语言的 LSP 和代码智能
-- 传统代码补全
+- 无 LSP 时的跨文件类型感知补全和精确语义导航
+- 非 stdio transport、自定义握手和 multi-root LSP session
- 自动导包、快速修复和安全重命名
- 完整代码格式化
- 测试识别、测试树和覆盖率
diff --git a/rust/lithe-core/include/lithe_core.h b/rust/lithe-core/include/lithe_core.h
index 9005aa77b..1444ee96c 100644
--- a/rust/lithe-core/include/lithe_core.h
+++ b/rust/lithe-core/include/lithe_core.h
@@ -9,6 +9,7 @@ extern "C" {
const char *lithe_core_version(void);
char *lithe_core_execute_json(const char *request);
+char *lithe_core_lsp_provider_catalog_json(const char *workspace_root);
int32_t lithe_core_cancel(const char *operation_id);
void lithe_core_free_string(char *value);
diff --git a/rust/lithe-core/resources/lsp/language-providers.json b/rust/lithe-core/resources/lsp/language-providers.json
new file mode 100644
index 000000000..113241ef6
--- /dev/null
+++ b/rust/lithe-core/resources/lsp/language-providers.json
@@ -0,0 +1,500 @@
+{
+ "version": 2,
+ "providers": [
+ {
+ "id": "java",
+ "displayName": "Java",
+ "fileExtensions": ["java"],
+ "capabilities": ["run", "languageServer", "formatting", "testing"],
+ "activationPolicy": "onDemand",
+ "languageId": "java",
+ "languageServerLaunch": {
+ "executableNames": ["jdtls"],
+ "arguments": []
+ },
+ "languageServerInstallation": {
+ "homebrewFormula": "jdtls",
+ "officialDownloadURL": "https://download.eclipse.org/jdtls/milestones/"
+ }
+ },
+ {
+ "id": "go",
+ "displayName": "Go",
+ "fileExtensions": ["go"],
+ "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"],
+ "activationPolicy": "onDemand",
+ "languageId": "go",
+ "languageServerLaunch": {
+ "executableNames": ["gopls"],
+ "arguments": []
+ },
+ "languageServerInstallation": {
+ "homebrewFormula": "gopls",
+ "officialDownloadURL": "https://go.dev/gopls/"
+ }
+ },
+ {
+ "id": "python",
+ "displayName": "Python",
+ "fileExtensions": ["py", "pyw"],
+ "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"],
+ "activationPolicy": "onDemand",
+ "languageId": "python",
+ "languageServerLaunch": {
+ "executableNames": ["basedpyright-langserver", "pyright-langserver"],
+ "arguments": ["--stdio"]
+ },
+ "languageServerInstallation": {
+ "homebrewFormula": "pyright",
+ "officialDownloadURL": "https://github.com/microsoft/pyright"
+ }
+ },
+ {
+ "id": "node",
+ "displayName": "Node.js",
+ "fileExtensions": ["js", "jsx", "ts", "tsx", "mjs", "cjs"],
+ "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"],
+ "activationPolicy": "onDemand",
+ "languageId": "javascript",
+ "languageServerLaunch": {
+ "executableNames": ["typescript-language-server"],
+ "arguments": ["--stdio"]
+ },
+ "languageServerInstallation": {
+ "homebrewFormula": "typescript-language-server",
+ "officialDownloadURL": "https://github.com/typescript-language-server/typescript-language-server"
+ },
+ "languageIdsByExtension": {
+ "jsx": "javascriptreact",
+ "ts": "typescript",
+ "tsx": "typescriptreact"
+ }
+ },
+ {
+ "id": "rust",
+ "displayName": "Rust",
+ "fileExtensions": ["rs"],
+ "capabilities": ["run", "languageServer", "debugAdapter", "formatting", "testing"],
+ "activationPolicy": "onDemand",
+ "languageId": "rust",
+ "languageServerLaunch": {
+ "executableNames": ["rust-analyzer"],
+ "arguments": [],
+ "validationArguments": ["--version"]
+ },
+ "languageServerInstallation": {
+ "homebrewFormula": "rust-analyzer",
+ "officialDownloadURL": "https://rust-analyzer.github.io/manual.html#installation"
+ }
+ },
+ {
+ "id": "clangd",
+ "displayName": "C/C++/Objective-C",
+ "fileExtensions": ["c", "h", "hh", "hpp", "hxx", "cpp", "cc", "cxx", "m", "mm"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "cpp",
+ "languageServerLaunch": {
+ "executableNames": ["clangd"],
+ "arguments": []
+ },
+ "languageServerInstallation": {
+ "homebrewFormula": "llvm",
+ "officialDownloadURL": "https://clangd.llvm.org/installation"
+ },
+ "languageIdsByExtension": {
+ "c": "c",
+ "h": "c",
+ "m": "objective-c",
+ "mm": "objective-cpp"
+ }
+ },
+ {
+ "id": "csharp",
+ "displayName": "C#",
+ "fileExtensions": ["cs", "csx"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "csharp"
+ },
+ {
+ "id": "fsharp",
+ "displayName": "F#",
+ "fileExtensions": ["fs", "fsi", "fsx"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "fsharp"
+ },
+ {
+ "id": "swift",
+ "displayName": "Swift",
+ "fileExtensions": ["swift"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "swift",
+ "languageServerLaunch": {
+ "executableNames": ["sourcekit-lsp"],
+ "arguments": []
+ },
+ "languageServerInstallation": {
+ "officialDownloadURL": "https://github.com/swiftlang/sourcekit-lsp"
+ }
+ },
+ {
+ "id": "kotlin",
+ "displayName": "Kotlin",
+ "fileExtensions": ["kt", "kts"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "kotlin",
+ "languageServerLaunch": {
+ "executableNames": ["kotlin-language-server"],
+ "arguments": []
+ },
+ "languageServerInstallation": {
+ "officialDownloadURL": "https://github.com/Kotlin/kotlin-lsp"
+ }
+ },
+ {
+ "id": "scala",
+ "displayName": "Scala",
+ "fileExtensions": ["scala", "sc"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "scala",
+ "languageServerLaunch": {
+ "executableNames": ["metals"],
+ "arguments": []
+ },
+ "languageServerInstallation": {
+ "homebrewFormula": "metals",
+ "officialDownloadURL": "https://scalameta.org/metals/docs/editors/overview.html"
+ }
+ },
+ {
+ "id": "groovy",
+ "displayName": "Groovy",
+ "fileExtensions": ["groovy"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "groovy"
+ },
+ {
+ "id": "ruby",
+ "displayName": "Ruby",
+ "fileExtensions": ["rb", "rake", "gemspec"],
+ "fileNames": ["Rakefile", "Gemfile"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "ruby",
+ "languageServerLaunch": {
+ "executableNames": ["ruby-lsp"],
+ "arguments": []
+ },
+ "languageServerInstallation": {
+ "officialDownloadURL": "https://shopify.github.io/ruby-lsp/"
+ }
+ },
+ {
+ "id": "php",
+ "displayName": "PHP",
+ "fileExtensions": ["php", "phtml"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "php",
+ "languageServerLaunch": {
+ "executableNames": ["intelephense", "phpactor"],
+ "arguments": []
+ },
+ "languageServerInstallation": {
+ "officialDownloadURL": "https://intelephense.com/"
+ }
+ },
+ {
+ "id": "dart",
+ "displayName": "Dart",
+ "fileExtensions": ["dart"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "dart"
+ },
+ {
+ "id": "lua",
+ "displayName": "Lua",
+ "fileExtensions": ["lua"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "lua"
+ },
+ {
+ "id": "shell",
+ "displayName": "Shell",
+ "fileExtensions": ["sh", "bash", "zsh", "fish", "ksh"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "shellscript",
+ "languageServerLaunch": {
+ "executableNames": ["bash-language-server"],
+ "arguments": ["start"]
+ },
+ "languageServerInstallation": {
+ "officialDownloadURL": "https://github.com/bash-lsp/bash-language-server"
+ }
+ },
+ {
+ "id": "powershell",
+ "displayName": "PowerShell",
+ "fileExtensions": ["ps1", "psm1", "psd1"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "powershell"
+ },
+ {
+ "id": "html",
+ "displayName": "HTML",
+ "fileExtensions": ["html", "htm", "xhtml"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "html"
+ },
+ {
+ "id": "css",
+ "displayName": "CSS",
+ "fileExtensions": ["css", "scss", "sass", "less"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "css",
+ "languageIdsByExtension": {
+ "less": "less",
+ "sass": "sass",
+ "scss": "scss"
+ }
+ },
+ {
+ "id": "vue",
+ "displayName": "Vue",
+ "fileExtensions": ["vue"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "vue"
+ },
+ {
+ "id": "svelte",
+ "displayName": "Svelte",
+ "fileExtensions": ["svelte"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "svelte"
+ },
+ {
+ "id": "astro",
+ "displayName": "Astro",
+ "fileExtensions": ["astro"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "astro"
+ },
+ {
+ "id": "json",
+ "displayName": "JSON",
+ "fileExtensions": ["json", "jsonc", "json5"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "json",
+ "languageIdsByExtension": {
+ "jsonc": "jsonc"
+ }
+ },
+ {
+ "id": "yaml",
+ "displayName": "YAML",
+ "fileExtensions": ["yml", "yaml"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "yaml",
+ "languageServerLaunch": {
+ "executableNames": ["yaml-language-server"],
+ "arguments": ["--stdio"]
+ },
+ "languageServerInstallation": {
+ "homebrewFormula": "yaml-language-server",
+ "officialDownloadURL": "https://github.com/redhat-developer/yaml-language-server"
+ }
+ },
+ {
+ "id": "xml",
+ "displayName": "XML",
+ "fileExtensions": ["xml", "xsd", "wsdl", "pom"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "xml"
+ },
+ {
+ "id": "markdown",
+ "displayName": "Markdown",
+ "fileExtensions": ["md", "markdown", "mdx"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "markdown"
+ },
+ {
+ "id": "sql",
+ "displayName": "SQL",
+ "fileExtensions": ["sql"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "sql"
+ },
+ {
+ "id": "terraform",
+ "displayName": "Terraform",
+ "fileExtensions": ["tf", "tfvars"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "terraform"
+ },
+ {
+ "id": "dockerfile",
+ "displayName": "Dockerfile",
+ "fileExtensions": ["dockerfile"],
+ "fileNames": ["Dockerfile"],
+ "fileNamePrefixes": ["Dockerfile."],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "dockerfile",
+ "languageIdsByFileName": {
+ "Dockerfile": "dockerfile"
+ }
+ },
+ {
+ "id": "cmake",
+ "displayName": "CMake",
+ "fileExtensions": ["cmake"],
+ "fileNames": ["CMakeLists.txt"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "cmake"
+ },
+ {
+ "id": "make",
+ "displayName": "Make",
+ "fileExtensions": ["mk"],
+ "fileNames": ["Makefile", "GNUmakefile"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "makefile"
+ },
+ {
+ "id": "toml",
+ "displayName": "TOML",
+ "fileExtensions": ["toml"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "toml"
+ },
+ {
+ "id": "graphql",
+ "displayName": "GraphQL",
+ "fileExtensions": ["graphql", "gql"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "graphql"
+ },
+ {
+ "id": "protobuf",
+ "displayName": "Protocol Buffers",
+ "fileExtensions": ["proto"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "proto"
+ },
+ {
+ "id": "prisma",
+ "displayName": "Prisma",
+ "fileExtensions": ["prisma"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "prisma"
+ },
+ {
+ "id": "elixir",
+ "displayName": "Elixir",
+ "fileExtensions": ["ex", "exs"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "elixir"
+ },
+ {
+ "id": "erlang",
+ "displayName": "Erlang",
+ "fileExtensions": ["erl", "hrl"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "erlang"
+ },
+ {
+ "id": "haskell",
+ "displayName": "Haskell",
+ "fileExtensions": ["hs", "lhs"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "haskell"
+ },
+ {
+ "id": "ocaml",
+ "displayName": "OCaml",
+ "fileExtensions": ["ml", "mli"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "ocaml"
+ },
+ {
+ "id": "clojure",
+ "displayName": "Clojure",
+ "fileExtensions": ["clj", "cljs", "cljc", "edn"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "clojure"
+ },
+ {
+ "id": "julia",
+ "displayName": "Julia",
+ "fileExtensions": ["jl"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "julia"
+ },
+ {
+ "id": "r",
+ "displayName": "R",
+ "fileExtensions": ["r"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "r"
+ },
+ {
+ "id": "perl",
+ "displayName": "Perl",
+ "fileExtensions": ["pl", "pm", "t"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "perl"
+ },
+ {
+ "id": "zig",
+ "displayName": "Zig",
+ "fileExtensions": ["zig"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "zig"
+ },
+ {
+ "id": "solidity",
+ "displayName": "Solidity",
+ "fileExtensions": ["sol"],
+ "capabilities": ["languageServer", "formatting"],
+ "activationPolicy": "onDemand",
+ "languageId": "solidity"
+ }
+ ]
+}
diff --git a/rust/lithe-core/src/run_configuration.rs b/rust/lithe-core/src/execution/configuration.rs
similarity index 97%
rename from rust/lithe-core/src/run_configuration.rs
rename to rust/lithe-core/src/execution/configuration.rs
index cd7a69a6c..2bcc95156 100644
--- a/rust/lithe-core/src/run_configuration.rs
+++ b/rust/lithe-core/src/execution/configuration.rs
@@ -1,5 +1,6 @@
-use crate::error::{invalid_relative_path, CoreError, ErrorCode};
-use crate::java::JavaRunConfigurationsRequest;
+use super::types::{Confidence, Execution};
+use crate::languages::JavaRunConfigurationsRequest;
+use crate::protocol::{invalid_relative_path, CoreError, ErrorCode};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
@@ -185,42 +186,6 @@ pub struct GeneratorMetadata {
pub inputs: BTreeMap,
}
-/// How a configuration behaves once started.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(rename_all = "lowercase")]
-pub enum Execution {
- /// User-launched program whose lifetime is not assumed to be long-running.
- Application,
- /// Long-running process: has stop semantics and may expose a port.
- Service,
- /// Runs to completion and exits.
- Task,
- /// Orchestrates other configurations by id.
- Group,
-}
-
-impl Default for Execution {
- fn default() -> Self {
- Self::Application
- }
-}
-
-/// How the configuration was discovered. Ranks dedupe winners: a native parse
-/// beats a manifest declaration, which beats an entry-point guess.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
-#[serde(rename_all = "lowercase")]
-pub enum Confidence {
- Heuristic,
- Declared,
- Native,
-}
-
-impl Default for Confidence {
- fn default() -> Self {
- Self::Declared
- }
-}
-
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DebugCapability {
@@ -383,7 +348,7 @@ pub fn generate(request: GenerateRequest) -> Result {
let has_maven_project = root.join("pom.xml").is_file() || root.join("mvnw").is_file();
let has_java_ecosystem = has_java_sources || has_maven_project;
let module_paths = inferred_maven_module_paths(&root, &request.paths, request.module_paths);
- let scanned = crate::java::run_configurations(JavaRunConfigurationsRequest {
+ let scanned = crate::languages::run_configurations(JavaRunConfigurationsRequest {
root: request.root.clone(),
paths: request.paths,
module_paths,
@@ -507,7 +472,7 @@ fn detected_configurations(
root: &Path,
claimed: &BTreeSet,
) -> Result, CoreError> {
- Ok(crate::detectors::detect_all(root)?
+ Ok(super::detectors::detect_all(root)?
.into_iter()
.map(|item| RunConfiguration {
id: item.id(),
@@ -686,9 +651,15 @@ pub fn update_options(request: UpdateOptionsRequest) -> Result
})?;
let provider = resolved["configurations"]
.as_array()
- .and_then(|items| items.iter().find(|value| value["id"] == request.configuration_id))
+ .and_then(|items| {
+ items
+ .iter()
+ .find(|value| value["id"] == request.configuration_id)
+ })
.and_then(|value| value["provider"].as_str())
- .ok_or_else(|| CoreError::new(ErrorCode::InvalidRequest, "Run configuration was not found"))?;
+ .ok_or_else(|| {
+ CoreError::new(ErrorCode::InvalidRequest, "Run configuration was not found")
+ })?;
let uses_maven_capability = matches!(
provider,
"java.current-file" | "java.main" | "spring-boot.maven" | "maven.module"
@@ -1836,10 +1807,9 @@ fn declared_go_version(root: &Path) -> Option {
}
fn declared_python_version(root: &Path) -> Option {
- let expression = regex::Regex::new(
- r#"(?m)^\s*(?:requires-python|python)\s*=\s*[\"']([^\"']+)[\"']"#,
- )
- .ok()?;
+ let expression =
+ regex::Regex::new(r#"(?m)^\s*(?:requires-python|python)\s*=\s*[\"']([^\"']+)[\"']"#)
+ .ok()?;
highest_version(
project_manifest_paths(root, &["pyproject.toml"])
.into_iter()
diff --git a/rust/lithe-core/src/detectors/cargo.rs b/rust/lithe-core/src/execution/detectors/cargo.rs
similarity index 100%
rename from rust/lithe-core/src/detectors/cargo.rs
rename to rust/lithe-core/src/execution/detectors/cargo.rs
diff --git a/rust/lithe-core/src/detectors/compose.rs b/rust/lithe-core/src/execution/detectors/compose.rs
similarity index 100%
rename from rust/lithe-core/src/detectors/compose.rs
rename to rust/lithe-core/src/execution/detectors/compose.rs
diff --git a/rust/lithe-core/src/detectors/go.rs b/rust/lithe-core/src/execution/detectors/go.rs
similarity index 97%
rename from rust/lithe-core/src/detectors/go.rs
rename to rust/lithe-core/src/execution/detectors/go.rs
index ee53c760f..ef4ea7c2d 100644
--- a/rust/lithe-core/src/detectors/go.rs
+++ b/rust/lithe-core/src/execution/detectors/go.rs
@@ -1,5 +1,5 @@
+use super::super::types::Confidence;
use super::{Detected, DirectoryContext};
-use crate::run_configuration::Confidence;
/// A Go module is runnable when a `package main` sits next to its `go.mod`, or
/// under the conventional `cmd/` layout. There is no declaration listing
diff --git a/rust/lithe-core/src/detectors/make.rs b/rust/lithe-core/src/execution/detectors/make.rs
similarity index 98%
rename from rust/lithe-core/src/detectors/make.rs
rename to rust/lithe-core/src/execution/detectors/make.rs
index 4854ecf3d..19e73ba4c 100644
--- a/rust/lithe-core/src/detectors/make.rs
+++ b/rust/lithe-core/src/execution/detectors/make.rs
@@ -1,5 +1,5 @@
+use super::super::types::Confidence;
use super::{Detected, DirectoryContext};
-use crate::run_configuration::Confidence;
const FILES: &[&str] = &["Makefile", "makefile", "GNUmakefile"];
diff --git a/rust/lithe-core/src/detectors/mod.rs b/rust/lithe-core/src/execution/detectors/mod.rs
similarity index 98%
rename from rust/lithe-core/src/detectors/mod.rs
rename to rust/lithe-core/src/execution/detectors/mod.rs
index 3253696ba..8fcffc333 100644
--- a/rust/lithe-core/src/detectors/mod.rs
+++ b/rust/lithe-core/src/execution/detectors/mod.rs
@@ -15,8 +15,8 @@ mod python;
mod scan;
mod shell;
-use crate::error::CoreError;
-use crate::run_configuration::{Confidence, Execution};
+use super::types::{Confidence, Execution};
+use crate::protocol::CoreError;
use std::collections::BTreeMap;
use std::path::Path;
diff --git a/rust/lithe-core/src/detectors/npm.rs b/rust/lithe-core/src/execution/detectors/npm.rs
similarity index 100%
rename from rust/lithe-core/src/detectors/npm.rs
rename to rust/lithe-core/src/execution/detectors/npm.rs
diff --git a/rust/lithe-core/src/detectors/procfile.rs b/rust/lithe-core/src/execution/detectors/procfile.rs
similarity index 97%
rename from rust/lithe-core/src/detectors/procfile.rs
rename to rust/lithe-core/src/execution/detectors/procfile.rs
index 195be8233..9b3dccffe 100644
--- a/rust/lithe-core/src/detectors/procfile.rs
+++ b/rust/lithe-core/src/execution/detectors/procfile.rs
@@ -1,5 +1,5 @@
+use super::super::types::Confidence;
use super::{Detected, DirectoryContext};
-use crate::run_configuration::Confidence;
const FILES: &[&str] = &["Procfile", "Procfile.dev", "Procfile.local"];
diff --git a/rust/lithe-core/src/detectors/python.rs b/rust/lithe-core/src/execution/detectors/python.rs
similarity index 98%
rename from rust/lithe-core/src/detectors/python.rs
rename to rust/lithe-core/src/execution/detectors/python.rs
index 274b62da6..1e83d1ba3 100644
--- a/rust/lithe-core/src/detectors/python.rs
+++ b/rust/lithe-core/src/execution/detectors/python.rs
@@ -1,5 +1,5 @@
+use super::super::types::Confidence;
use super::{Detected, DirectoryContext};
-use crate::run_configuration::Confidence;
pub fn detect(ctx: &DirectoryContext) -> Vec {
let mut detected = pyproject(ctx);
diff --git a/rust/lithe-core/src/detectors/scan.rs b/rust/lithe-core/src/execution/detectors/scan.rs
similarity index 99%
rename from rust/lithe-core/src/detectors/scan.rs
rename to rust/lithe-core/src/execution/detectors/scan.rs
index 2ea626bcf..6d1fdf5dc 100644
--- a/rust/lithe-core/src/detectors/scan.rs
+++ b/rust/lithe-core/src/execution/detectors/scan.rs
@@ -1,4 +1,4 @@
-use crate::error::{CoreError, ErrorCode};
+use crate::protocol::{CoreError, ErrorCode};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
diff --git a/rust/lithe-core/src/detectors/shell.rs b/rust/lithe-core/src/execution/detectors/shell.rs
similarity index 97%
rename from rust/lithe-core/src/detectors/shell.rs
rename to rust/lithe-core/src/execution/detectors/shell.rs
index 823f6c636..54b3a6669 100644
--- a/rust/lithe-core/src/detectors/shell.rs
+++ b/rust/lithe-core/src/execution/detectors/shell.rs
@@ -1,5 +1,5 @@
+use super::super::types::Confidence;
use super::{Detected, DirectoryContext};
-use crate::run_configuration::Confidence;
const JUSTFILES: &[&str] = &["justfile", "Justfile", ".justfile"];
diff --git a/rust/lithe-core/src/execution/mod.rs b/rust/lithe-core/src/execution/mod.rs
new file mode 100644
index 000000000..b67d3a901
--- /dev/null
+++ b/rust/lithe-core/src/execution/mod.rs
@@ -0,0 +1,7 @@
+//! Run configuration generation, resolution, and project detectors.
+
+mod configuration;
+mod detectors;
+mod types;
+
+pub(crate) use configuration::*;
diff --git a/rust/lithe-core/src/execution/types.rs b/rust/lithe-core/src/execution/types.rs
new file mode 100644
index 000000000..9a943b3f6
--- /dev/null
+++ b/rust/lithe-core/src/execution/types.rs
@@ -0,0 +1,32 @@
+use serde::{Deserialize, Serialize};
+
+/// How a configuration behaves once started.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Execution {
+ Application,
+ Service,
+ Task,
+ Group,
+}
+
+impl Default for Execution {
+ fn default() -> Self {
+ Self::Application
+ }
+}
+
+/// How the configuration was discovered. Higher values win deduplication.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Confidence {
+ Heuristic,
+ Declared,
+ Native,
+}
+
+impl Default for Confidence {
+ fn default() -> Self {
+ Self::Declared
+ }
+}
diff --git a/rust/lithe-core/src/git.rs b/rust/lithe-core/src/git/mod.rs
similarity index 99%
rename from rust/lithe-core/src/git.rs
rename to rust/lithe-core/src/git/mod.rs
index bd7e94f88..afbfe0302 100644
--- a/rust/lithe-core/src/git.rs
+++ b/rust/lithe-core/src/git/mod.rs
@@ -1,5 +1,5 @@
-use crate::error::{CoreError, ErrorCode};
-use crate::model::{
+use crate::protocol::{CoreError, ErrorCode};
+use crate::protocol::{
GitBlameLineResponse, GitBlameResponse, GitChange, GitCheckoutPreflightResponse,
GitCommitLookupResponse, GitCommitResponse, GitComparisonResponse, GitConflictMarkerResponse,
GitDiffHunkResponse, GitDiffResponse, GitDiffRowResponse, GitFileResponse, GitFilesResponse,
@@ -443,7 +443,7 @@ fn execute_git(
arguments: &[String],
input: Option,
) -> Result {
- crate::cancellation::check()?;
+ crate::protocol::cancellation::check()?;
let mut process = Command::new("git");
process.args(arguments).current_dir(root);
process.stdin(if input.is_some() {
@@ -494,7 +494,7 @@ fn execute_git(
})? {
break status;
}
- if let Err(error) = crate::cancellation::check() {
+ if let Err(error) = crate::protocol::cancellation::check() {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_reader.join();
@@ -2528,9 +2528,11 @@ mod tests {
#[test]
fn structured_diff_matches_shared_fixture() {
- let fixture: Value =
- serde_json::from_str(include_str!("../../../shared/fixtures/git/diff.json"))
- .expect("diff fixture should be valid JSON");
+ let fixture: Value = serde_json::from_str(include_str!(concat!(
+ env!("CARGO_MANIFEST_DIR"),
+ "/../../shared/fixtures/git/diff.json"
+ )))
+ .expect("diff fixture should be valid JSON");
let patch = fixture["patch"]
.as_str()
.expect("fixture patch should be text");
diff --git a/rust/lithe-core/src/java.rs b/rust/lithe-core/src/languages/java.rs
similarity index 98%
rename from rust/lithe-core/src/java.rs
rename to rust/lithe-core/src/languages/java.rs
index b554f3274..aa0eb769c 100644
--- a/rust/lithe-core/src/java.rs
+++ b/rust/lithe-core/src/languages/java.rs
@@ -1,5 +1,5 @@
-use crate::error::{CoreError, ErrorCode};
-use crate::model::{
+use crate::protocol::{CoreError, ErrorCode};
+use crate::protocol::{
JavaClassNameResponse, JavaCodeVisionHintResponse, JavaCodeVisionResponse,
JavaFoldRegionResponse, JavaImplementationMarkerResponse, JavaInlayHintResponse,
JavaMainClassResponse, JavaRunConfigurationResponse, JavaRunConfigurationsResponse,
@@ -183,7 +183,7 @@ pub fn class_name(request: JavaClassNameRequest) -> Result Result