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. + - - - - Star History Chart - + Star History Chart ## 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 开始。 + - - - - Star History 图表 - + Star History 图表 ## 联系我们 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, CoreError> { +) -> Result, CoreError> { let lines = request.source.split('\n').collect::>(); if let Some(member) = request.member_name { let method = @@ -199,7 +199,7 @@ pub fn source_definition( && !prefix.contains('#') && !prefix.ends_with('.') { - return Ok(Some(crate::model::JavaSourceDefinitionResponse { + return Ok(Some(crate::protocol::JavaSourceDefinitionResponse { line: line_number, utf16_column: utf16_column(line, found.start()), })); @@ -213,7 +213,7 @@ pub fn source_definition( })?; for (line_number, line) in lines.iter().enumerate() { if let Some(found) = field.find(line) { - return Ok(Some(crate::model::JavaSourceDefinitionResponse { + return Ok(Some(crate::protocol::JavaSourceDefinitionResponse { line: line_number, utf16_column: utf16_column(line, found.start()), })); @@ -235,7 +235,7 @@ pub fn source_definition( .find(&request.declaration_name) .map(|value| found.start() + value) .unwrap_or(found.start()); - return Ok(Some(crate::model::JavaSourceDefinitionResponse { + return Ok(Some(crate::protocol::JavaSourceDefinitionResponse { line: line_number, utf16_column: utf16_column(line, offset), })); diff --git a/rust/lithe-core/src/languages/mod.rs b/rust/lithe-core/src/languages/mod.rs new file mode 100644 index 000000000..e315d2c4c --- /dev/null +++ b/rust/lithe-core/src/languages/mod.rs @@ -0,0 +1,5 @@ +//! Language-specific project inspection that is independent from LSP transport. + +mod java; + +pub(crate) use java::*; diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index c83af0d54..0f956c232 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -1,23 +1,14 @@ -mod cancellation; -mod command; -mod detectors; -mod error; -mod event; -mod ffi; +mod execution; mod git; -mod history; -mod java; -mod markdown; -mod maven; -mod model; -mod run_configuration; +mod languages; +mod lsp; +mod project; +mod protocol; mod runtime; -mod workspace; -pub use command::{CoreCommand, CoreRequest}; -pub use error::{CoreError, ErrorCode}; -pub use event::CoreEvent; -pub use model::{CoreResponse, ResponseData}; +pub use protocol::{ + CoreCommand, CoreError, CoreEvent, CoreRequest, CoreResponse, ErrorCode, ResponseData, +}; /// Executes one versioned application command and returns a JSON response. pub fn execute_json(request: &str) -> String { @@ -25,3351 +16,4 @@ pub fn execute_json(request: &str) -> String { } #[cfg(test)] -mod tests { - use super::execute_json; - use serde_json::Value; - use std::fs; - use std::path::{Path, PathBuf}; - use std::process::Command; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn fixture() -> Value { - let fixture_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../shared/fixtures/search/basic.json"); - serde_json::from_str(&fs::read_to_string(fixture_path).expect("fixture should be readable")) - .expect("fixture should be valid JSON") - } - - fn temporary_root(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock should be valid") - .as_nanos(); - std::env::temp_dir().join(format!("lithe-core-{label}-{}-{nonce}", std::process::id())) - } - - #[test] - fn ping_exposes_protocol_version() { - let response: Value = serde_json::from_str(&execute_json( - r#"{"id":"test-1","command":"core.ping","payload":{}}"#, - )) - .expect("ping response should be JSON"); - - assert_eq!(response["ok"], true); - assert_eq!(response["data"]["protocolVersion"], 1); - assert_eq!(response["data"]["coreVersion"], "0.1.0"); - } - - #[test] - fn run_configuration_commands_generate_merge_and_plan() { - let root = temporary_root("run-config"); - fs::create_dir_all(root.join("src/main/java/com/example")) - .expect("source directory should be creatable"); - fs::write(root.join("src/main/java/com/example/App.java"), "package com.example; @SpringBootApplication class App { public static void main(String[] args) {} }").expect("source should be writable"); - fs::write(root.join("pom.xml"), "21").expect("pom should be writable"); - - let request = serde_json::json!({"id":"generate","command":"runConfig.generate","payload":{"root":root,"paths":["src/main/java/com/example/App.java"],"modulePaths":[]}}); - let generated: Value = serde_json::from_str(&execute_json(&request.to_string())) - .expect("generate response should be JSON"); - assert_eq!(generated["ok"], true); - assert_eq!(generated["data"]["generated"]["version"], 2); - assert!(generated["data"]["generated"]["configurations"] - .as_array() - .unwrap() - .iter() - .any(|v| v["id"] == "current-file")); - - let generated_doc = serde_json::to_string(&generated["data"]["generated"]).unwrap(); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write(root.join(".lithe/run/generated.json"), generated_doc).unwrap(); - fs::write(root.join(".lithe/run/configurations.json"), r#"{"version":1,"configurations":[{"id":"current-file","name":"My File","type":"java.current-file","workingDirectory":"backend","jvmArguments":["-Xmx2g"],"toolchains":{"maven":"custom-maven"}}]}"#).unwrap(); - fs::write(root.join(".lithe/run/local.json"), r#"{"version":1,"configurations":[{"id":"current-file","name":"Local File","type":"java.current-file","workingDirectory":".","programArguments":["--dev"],"toolchains":{"java":"custom-jdk"}}]}"#).unwrap(); - - let resolve: Value = serde_json::from_str(&execute_json(&serde_json::json!({"id":"resolve","command":"runConfig.resolve","payload":{"root":root}}).to_string())).unwrap(); - assert_eq!(resolve["ok"], true); - let current = resolve["data"]["configurations"] - .as_array() - .unwrap() - .iter() - .find(|v| v["id"] == "current-file") - .unwrap(); - assert_eq!(current["name"], "Local File"); - assert_eq!(current["toolchains"]["java"], "custom-jdk"); - assert_eq!(current["toolchains"]["maven"], "custom-maven"); - let plan: Value = serde_json::from_str(&execute_json(&serde_json::json!({"id":"plan","command":"runConfig.createLaunchPlan","payload":{"root":root,"configurationId":"current-file","currentFile":"src/main/java/com/example/App.java"}}).to_string())).unwrap(); - assert_eq!(plan["ok"], true); - assert_eq!(plan["data"]["executable"]["toolchain"], "custom-jdk"); - let debug_plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id":"debug-plan", - "command":"runConfig.createLaunchPlan", - "payload":{ - "root":root, - "configurationId":"spring:com.example.App", - "debugPort":5005 - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(debug_plan["ok"], true); - assert!(debug_plan["data"]["arguments"] - .as_array() - .unwrap() - .iter() - .filter_map(Value::as_str) - .any(|argument| argument.contains("address=127.0.0.1:5005"))); - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_generation_infers_maven_modules_from_nearest_pom() { - let root = temporary_root("run-config-inferred-modules"); - let backend = "backend-api/src/main/java/com/example/BackendApplication.java"; - let worker = "batch-worker/src/main/java/com/example/WorkerMain.java"; - fs::create_dir_all(root.join("backend-api/src/main/java/com/example")).unwrap(); - fs::create_dir_all(root.join("batch-worker/src/main/java/com/example")).unwrap(); - fs::write(root.join("pom.xml"), "").unwrap(); - fs::write(root.join("backend-api/pom.xml"), "").unwrap(); - fs::write(root.join("batch-worker/pom.xml"), "").unwrap(); - fs::write( - root.join(backend), - "package com.example; @SpringBootApplication class BackendApplication { public static void main(String[] args) {} }", - ) - .unwrap(); - fs::write( - root.join(worker), - "package com.example; class WorkerMain { public static void main(String[] args) {} }", - ) - .unwrap(); - - let response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-inferred-modules", - "command": "runConfig.generate", - "payload": { - "root": root, - "paths": [backend, worker], - "modulePaths": [] - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true); - let configurations = response["data"]["generated"]["configurations"] - .as_array() - .unwrap(); - assert!(configurations.iter().any(|value| { - value["id"] == "spring:com.example.BackendApplication" - && value["extensions"]["maven"]["module"] == "backend-api" - })); - assert!(configurations.iter().any(|value| { - value["id"] == "java-main:com.example.WorkerMain" - && value["extensions"]["maven"]["module"] == "batch-worker" - && value["execution"] == "application" - })); - assert!(!configurations - .iter() - .any(|value| value["provider"] == "maven.module")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn ordinary_java_main_uses_an_application_launch_plan() { - let root = temporary_root("run-config-java-main"); - let source = "batch-worker/src/main/java/com/example/WorkerMain.java"; - fs::create_dir_all(root.join("batch-worker/src/main/java/com/example")).unwrap(); - fs::write(root.join("pom.xml"), "").unwrap(); - fs::write(root.join("batch-worker/pom.xml"), "").unwrap(); - fs::write( - root.join(source), - "package com.example; class WorkerMain { public static void main(String[] args) {} }", - ) - .unwrap(); - - let generated_response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-java-main", - "command": "runConfig.generate", - "payload": {"root": root, "paths": [source], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - let generated = &generated_response["data"]["generated"]; - let java_main = generated["configurations"] - .as_array() - .unwrap() - .iter() - .find(|value| value["provider"] == "java.main") - .unwrap(); - assert_eq!(java_main["execution"], "application"); - assert_eq!(java_main["extensions"]["maven"]["module"], "batch-worker"); - - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - serde_json::to_string(generated).unwrap(), - ) - .unwrap(); - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "plan-java-main", - "command": "runConfig.createLaunchPlan", - "payload": { - "root": root, - "configurationId": "java-main:com.example.WorkerMain" - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!(plan["data"]["executable"]["toolchain"], "project-maven"); - assert!(plan["data"]["arguments"] - .as_array() - .unwrap() - .iter() - .any(|value| value == "-Dexec.mainClass=com.example.WorkerMain")); - assert_eq!( - plan["data"]["arguments"] - .as_array() - .unwrap() - .last() - .unwrap(), - "org.codehaus.mojo:exec-maven-plugin:3.5.0:java" - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_inspect_reports_malformed_and_unsupported_documents() { - let root = temporary_root("run-config-errors"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write(root.join(".lithe/run/generated.json"), "{").unwrap(); - - let inspect = |id: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": id, - "command": "runConfig.inspect", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap() - }; - let malformed = inspect("malformed"); - assert_eq!(malformed["ok"], false); - assert_eq!(malformed["error"]["code"], "parse_failed"); - - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":3,"configurations":[]}"#, - ) - .unwrap(); - let unsupported = inspect("unsupported"); - assert_eq!(unsupported["ok"], false); - assert_eq!(unsupported["error"]["code"], "not_supported"); - assert!(unsupported["error"]["details"] - .as_str() - .unwrap() - .contains("found 3")); - - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[]}"#, - ) - .unwrap(); - fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); - fs::write(root.join(".lithe/toolchains/local.json"), "{").unwrap(); - let malformed_toolchains = inspect("malformed-toolchains"); - assert_eq!(malformed_toolchains["ok"], false); - assert_eq!(malformed_toolchains["error"]["code"], "parse_failed"); - assert!(malformed_toolchains["error"]["message"] - .as_str() - .unwrap() - .contains(".lithe/toolchains/local.json")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_mutations_are_shared_and_validated() { - let root = temporary_root("run-config-mutations"); - fs::create_dir_all(root.join("src/main/java/com/example")).unwrap(); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join("src/main/java/com/example/App.java"), - "package com.example; class App { public static void main(String[] args) {} }", - ) - .unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file","toolchains":{"java":"project-jdk"}}]}"#, - ) - .unwrap(); - - let updated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "update-options", - "command": "runConfig.updateOptions", - "payload": { - "root": root, - "scope": "project", - "configurationId": "current-file", - "workingDirectory": ".", - "jvmArguments": "\"-Dlabel=hello world\" -Xmx2g", - "programArguments": "--dev", - "mavenProfiles": ["dev"] - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(updated["ok"], true); - let updated_document: Value = - serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); - assert_eq!( - updated_document["configurations"][0]["extensions"]["maven"]["jvmArguments"], - serde_json::json!(["-Dlabel=hello world", "-Xmx2g"]) - ); - fs::write( - root.join(".lithe/run/configurations.json"), - updated["data"]["document"].as_str().unwrap(), - ) - .unwrap(); - - let create = |name: &str, module: &str, main_class: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "create-user", - "command": "runConfig.createUserConfiguration", - "payload": { - "root": root, - "scope": "project", - "name": name, - "type": "springBoot", - "module": module, - "mainClass": main_class - } - }) - .to_string(), - )) - .unwrap() - }; - let first = create("Backend Dev", ".", "com.example.App"); - assert_eq!(first["data"]["id"], "user:backend-dev"); - fs::write( - root.join(".lithe/run/configurations.json"), - first["data"]["document"].as_str().unwrap(), - ) - .unwrap(); - let second = create("Backend Dev", ".", "com.example.App"); - assert_eq!(second["data"]["id"], "user:backend-dev-2"); - assert_eq!( - create("Outside", "../outside", "com.example.App")["ok"], - false - ); - assert_eq!(create("Missing", ".", "com.example.Missing")["ok"], false); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_generation_detects_declared_toolchain_versions() { - let root = temporary_root("run-config-toolchains"); - fs::create_dir_all(root.join(".mvn/wrapper")).unwrap(); - fs::write(root.join(".sdkmanrc"), "java=21.0.5-tem\n").unwrap(); - fs::write(root.join("mvnw"), "#!/bin/sh\n").unwrap(); - fs::write( - root.join(".mvn/wrapper/maven-wrapper.properties"), - "distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip\n", - ) - .unwrap(); - - let generated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-toolchains", - "command": "runConfig.generate", - "payload": {"root": root, "paths": [], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(generated["ok"], true); - assert_eq!( - generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"] - ["minimumVersion"], - "21" - ); - assert_eq!( - generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"] - ["preferredVendor"], - "temurin" - ); - assert_eq!( - generated["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["version"], - "3.9.9" - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_generation_detects_maven_compiler_target() { - let root = temporary_root("run-config-compiler-target"); - fs::create_dir_all(&root).unwrap(); - fs::write( - root.join("pom.xml"), - "17", - ) - .unwrap(); - let generated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-target", - "command": "runConfig.generate", - "payload": {"root": root, "paths": [], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - assert_eq!( - generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"] - ["minimumVersion"], - "17" - ); - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_inspection_summarizes_changed_inputs() { - let root = temporary_root("run-config-input-summary"); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write(root.join("src/App.java"), "class App {}").unwrap(); - let generated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-summary", - "command": "runConfig.generate", - "payload": {"root": root, "paths": ["src/App.java"], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - let generated_again: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-summary-again", - "command": "runConfig.generate", - "payload": {"root": root, "paths": ["src/App.java"], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(generated["data"], generated_again["data"]); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - serde_json::to_string(&generated["data"]["generated"]).unwrap(), - ) - .unwrap(); - fs::write(root.join("src/App.java"), "class App { int changed; }").unwrap(); - - let inspected: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "inspect-summary", - "command": "runConfig.inspect", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(inspected["ok"], true); - assert_eq!( - inspected["data"]["diagnostics"][0]["message"], - "Project inputs changed: 0 added, 0 removed, 1 modified" - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_resolve_matches_toolchains_and_rejects_unsafe_paths() { - let root = temporary_root("run-config-toolchain-resolution"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"}]}"#, - ) - .unwrap(); - fs::write( - root.join(".lithe/toolchains/requirements.json"), - r#"{"version":1,"toolchains":{"project-jdk":{"type":"java","minimumVersion":"21","preferredVendor":"temurin"}}}"#, - ) - .unwrap(); - - let resolve = |version: &str, vendor: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "resolve-toolchains", - "command": "runConfig.resolve", - "payload": { - "root": root, - "toolchainCandidates": [{ - "id": "project-jdk", - "type": "java", - "version": version, - "vendor": vendor - }] - } - }) - .to_string(), - )) - .unwrap() - }; - let mismatch = resolve("17.0.12", "Zulu"); - assert!(mismatch["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "toolchainVersionMismatch")); - assert!(mismatch["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "toolchainVendorMismatch")); - - let matching = resolve("21.0.5", "Eclipse Temurin"); - assert!(matching["data"]["diagnostics"] - .as_array() - .unwrap() - .is_empty()); - - fs::write( - root.join(".lithe/run/local.json"), - r#"{"version":1,"configurations":[{"id":"current-file","workingDirectory":"../outside"}]}"#, - ) - .unwrap(); - let unsafe_path = resolve("21.0.5", "Eclipse Temurin"); - assert_eq!(unsafe_path["ok"], false); - assert_eq!(unsafe_path["error"]["code"], "invalid_request"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn run_configuration_main_class_validation_uses_the_declared_package() { - let root = temporary_root("run-config-main-class-package"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::create_dir_all(root.join("src/main/java/other")).unwrap(); - fs::write( - root.join("src/main/java/other/App.java"), - "package other; class App {}", - ) - .unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"spring:com.example.App","name":"App","type":"spring-boot.maven","mainClass":"com.example.App"}]}"#, - ) - .unwrap(); - - let resolved: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "resolve-main-class", - "command": "runConfig.resolve", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(resolved["ok"], true); - assert!(resolved["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "missingMainClass")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn shared_run_configuration_fixtures_have_the_versioned_contract_shape() { - let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../shared/fixtures/run-configuration"); - let mut fixture_count = 0; - for entry in fs::read_dir(directory).unwrap() { - let path = entry.unwrap().path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - fixture_count += 1; - let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(value["version"], 1, "{}", path.display()); - assert!(value["expected"].is_object(), "{}", path.display()); - if let Some(generated) = value.get("generated") { - assert!(generated["version"].is_number(), "{}", path.display()); - assert!(generated["configurations"].is_array(), "{}", path.display()); - } - } - assert!(fixture_count >= 6); - } - - #[test] - fn run_configuration_resolve_diagnoses_orphans_and_deleted_modules() { - let root = temporary_root("run-config-diagnostics"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"},{"id":"module:deleted","name":"Deleted","type":"maven.module","module":"deleted"}]}"#, - ) - .unwrap(); - fs::write( - root.join(".lithe/run/local.json"), - r#"{"version":1,"configurations":[{"id":"module:old","jvmArguments":["-Xmx1g"]}]}"#, - ) - .unwrap(); - - let resolved: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "resolve-diagnostics", - "command": "runConfig.resolve", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(resolved["ok"], true); - assert!(resolved["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "orphanedOverride")); - assert!(resolved["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "missingModule")); - - fs::write( - root.join(".lithe/run/local.json"), - r#"{"version":1,"configurations":[{"id":"module:deleted","jvmArguments":["-Xmx1g"]}]}"#, - ) - .unwrap(); - let resolved: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "resolve-missing-module", - "command": "runConfig.resolve", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(resolved["ok"], true); - assert_eq!( - resolved["data"]["configurations"].as_array().unwrap().len(), - 1 - ); - assert!(resolved["data"]["diagnostics"] - .as_array() - .unwrap() - .iter() - .any(|value| value["code"] == "missingModule")); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn markdown_render_command_returns_sanitized_html() { - let request = serde_json::json!({ - "id": "markdown-1", - "command": "markdown.render", - "payload": { - "source": "# Preview\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n```plantuml\nAlice -> Bob\n```\n\n" - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Markdown request should encode"), - )) - .expect("Markdown response should be JSON"); - - assert_eq!(response["id"], "markdown-1"); - assert_eq!(response["ok"], true); - let html = response["data"]["html"] - .as_str() - .expect("Markdown response should contain HTML"); - assert!(html.contains(" Vec { - let request = serde_json::json!({ - "id": "mask", - "command": "workspace.search", - "payload": { - "root": root, - "query": "total", - "fileMask": mask - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("search request should encode"), - )) - .expect("search response should be JSON"); - assert_eq!(response["ok"], true); - response["data"]["matches"] - .as_array() - .expect("matches should be an array") - .iter() - .map(|value| value["path"].as_str().unwrap_or_default().to_string()) - .collect() - }; - - let unfiltered = search(""); - assert!(unfiltered.iter().any(|path| path.ends_with("Service.java"))); - assert!(unfiltered.iter().any(|path| path.ends_with("notes.txt"))); - - let java_only = search("*.java"); - assert!(java_only.iter().any(|path| path.ends_with("Service.java"))); - assert!(!java_only.iter().any(|path| path.ends_with("notes.txt"))); - - // 多个掩码取并集,且容忍逗号后的空格。 - let both = search("*.java, *.txt"); - assert!(both.iter().any(|path| path.ends_with("Service.java"))); - assert!(both.iter().any(|path| path.ends_with("notes.txt"))); - - fs::remove_dir_all(root).expect("temporary fixture should be removable"); - } - - #[test] - fn preserve_case_matches_original_occurrence_shape() { - let root = temporary_root("preserve-case"); - fs::create_dir_all(&root).expect("fixture directory should be creatable"); - let relative = "Sample.java"; - fs::write(root.join(relative), "fooBar FooBar FOOBAR fooBar();\n") - .expect("fixture should be writable"); - - let replace = |preserve_case: bool| -> String { - let request = serde_json::json!({ - "id": "preserve", - "command": "workspace.replacePreview", - "payload": { - "root": root, - "query": "fooBar", - "replacement": "bazQux", - "caseSensitive": false, - "preserveCase": preserve_case, - "paths": [relative] - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("replace request should encode"), - )) - .expect("replace response should be JSON"); - assert_eq!(response["ok"], true); - response["data"]["files"][0]["matches"][0]["after"] - .as_str() - .expect("after text should be a string") - .to_string() - }; - - assert_eq!(replace(false), "bazQux bazQux bazQux bazQux();"); - assert_eq!(replace(true), "bazQux BazQux BAZQUX bazQux();"); - - fs::remove_dir_all(root).expect("temporary fixture should be removable"); - } - - #[test] - fn local_history_records_deduplicates_lists_and_relocates() { - let root = temporary_root("history"); - fs::create_dir_all(&root).expect("history workspace should be creatable"); - let storage = root.join("history-storage"); - - let request = |command: &str, payload: Value| -> Value { - let request = serde_json::json!({ - "id": command, - "command": command, - "payload": payload - }); - serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("history request should encode"), - )) - .expect("history response should be JSON") - }; - - let record_payload = |content: &str| { - serde_json::json!({ - "workspaceRoot": root, - "storageRoot": storage, - "path": "src/Main.java", - "reason": "saved", - "content": content, - "hiddenDirectoryNames": [], - "hiddenFilePatterns": [] - }) - }; - let first = request("history.record", record_payload("one\n")); - assert_eq!(first["ok"], true); - assert!(first["data"]["id"].as_str().is_some()); - let duplicate = request("history.record", record_payload("one\n")); - assert_eq!(duplicate["ok"], true); - assert!(duplicate["data"].is_null()); - let mut invalid_reason = record_payload("invalid\n"); - invalid_reason["reason"] = serde_json::json!("not-a-history-reason"); - let invalid = request("history.record", invalid_reason); - assert_eq!(invalid["ok"], false); - assert_eq!(invalid["error"]["code"], "invalid_request"); - - let second = request("history.record", record_payload("two\n")); - assert_eq!(second["ok"], true); - let listed = request( - "history.entries", - serde_json::json!({ - "workspaceRoot": root, - "storageRoot": storage, - "path": "src/Main.java" - }), - ); - assert_eq!(listed["ok"], true); - assert_eq!(listed["data"]["entries"].as_array().unwrap().len(), 2); - let content_path = listed["data"]["entries"][0]["contentPath"] - .as_str() - .unwrap(); - let content = request( - "history.content", - serde_json::json!({ - "storageRoot": storage, - "contentPath": content_path - }), - ); - assert_eq!(content["data"]["text"], "two\n"); - - let relocated = request( - "history.relocate", - serde_json::json!({ - "storageRoot": storage, - "sourcePath": "src/Main.java", - "destinationPath": "src/Renamed.java" - }), - ); - assert_eq!(relocated["ok"], true); - let relocated_entries = request( - "history.entries", - serde_json::json!({ - "workspaceRoot": root, - "storageRoot": storage, - "path": "src/Renamed.java" - }), - ); - assert_eq!( - relocated_entries["data"]["entries"] - .as_array() - .unwrap() - .len(), - 2 - ); - - let traversal = request( - "history.content", - serde_json::json!({ - "storageRoot": storage, - "contentPath": "../outside.snapshot" - }), - ); - assert_eq!(traversal["ok"], false); - assert_eq!(traversal["error"]["code"], "invalid_request"); - fs::remove_dir_all(root).expect("history workspace should be removable"); - } - - #[test] - fn file_commands_round_trip_and_reject_traversal() { - let root = temporary_root("file"); - let outside = temporary_root("outside"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - fs::create_dir_all(&outside).expect("outside directory should be creatable"); - - let write = serde_json::json!({ - "id": "write", - "command": "file.write", - "payload": {"root": root, "path": "nested/example.txt", "text": "hello"} - }); - let write_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&write).expect("write request should encode"), - )) - .expect("write response should be JSON"); - assert_eq!(write_response["ok"], true); - - let read = serde_json::json!({ - "id": "read", - "command": "file.read", - "payload": {"root": root, "path": "nested/example.txt"} - }); - let read_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&read).expect("read request should encode"), - )) - .expect("read response should be JSON"); - assert_eq!(read_response["data"]["text"], "hello"); - - let traversal = serde_json::json!({ - "id": "traversal", - "command": "file.read", - "payload": {"root": root, "path": "../outside.txt"} - }); - let traversal_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&traversal).expect("traversal request should encode"), - )) - .expect("traversal response should be JSON"); - assert_eq!(traversal_response["ok"], false); - assert_eq!(traversal_response["error"]["code"], "invalid_request"); - - for path in [ - "..\\outside.txt", - "nested\\..\\outside.txt", - "C:\\outside.txt", - ] { - let request = serde_json::json!({ - "id": "windows-path", - "command": "file.read", - "payload": {"root": root, "path": path} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Windows path request should encode"), - )) - .expect("Windows path response should be JSON"); - assert_eq!(response["ok"], false, "path {path} should be rejected"); - assert_eq!(response["error"]["code"], "invalid_request"); - } - - #[cfg(unix)] - { - std::os::unix::fs::symlink(&outside, root.join("link")) - .expect("test symlink should be creatable"); - let symlink_write = serde_json::json!({ - "id": "symlink-write", - "command": "file.write", - "payload": {"root": root, "path": "link/escape.txt", "text": "outside"} - }); - let symlink_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&symlink_write).expect("symlink request should encode"), - )) - .expect("symlink response should be JSON"); - assert_eq!(symlink_response["ok"], false); - assert_eq!(symlink_response["error"]["code"], "permission_denied"); - assert!(!outside.join("escape.txt").exists()); - } - - fs::remove_dir_all(root).expect("temporary workspace should be removable"); - fs::remove_dir_all(outside).expect("outside fixture should be removable"); - } - - #[test] - fn git_status_returns_contract_shape() { - let root = temporary_root("git"); - fs::create_dir_all(&root).expect("temporary repository should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - fs::write(root.join("new.txt"), "new").expect("test file should be writable"); - - let request = serde_json::json!({ - "id": "git", - "command": "git.status", - "payload": {"root": root} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Git request should encode"), - )) - .expect("Git response should be JSON"); - assert_eq!(response["ok"], true); - assert_eq!(response["data"]["repositoryRoot"], "."); - assert_eq!(response["data"]["changes"][0]["path"], "new.txt"); - assert_eq!(response["data"]["changes"][0]["untracked"], true); - - fs::remove_dir_all(root).expect("temporary repository should be removable"); - } - - #[test] - fn git_command_returns_combined_output_and_exit_code() { - let root = temporary_root("git-command"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - - let request = serde_json::json!({ - "id": "git-command", - "command": "git.command", - "payload": { - "root": root, - "arguments": ["--version"] - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Git command request should encode"), - )) - .expect("Git command response should be JSON"); - assert_eq!(response["ok"], true); - assert_eq!(response["data"]["exitCode"], 0); - assert!(response["data"]["output"] - .as_str() - .expect("Git version output should be text") - .contains("git version")); - - fs::remove_dir_all(root).expect("temporary workspace should be removable"); - } - - #[test] - fn git_write_validates_and_executes_shared_mutations() { - let root = temporary_root("git-write"); - fs::create_dir_all(&root).expect("temporary repository should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); - - let request = |operation: &str, payload: Value| -> Value { - let request = serde_json::json!({ - "id": operation, - "command": "git.write", - "payload": { - "root": root, - "operation": operation, - "paths": [], - "reference": null, - "referenceKind": null, - "revision": null, - "name": null, - "message": null, - "remote": null, - "destination": null, - "mode": null, - "includeUntracked": false, - "checkout": false, - "amend": false - } - }); - let mut request = request; - if let Value::Object(overrides) = payload { - for (key, value) in overrides { - request["payload"][key.as_str()] = value; - } - } - serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("write request should encode"), - )) - .expect("write response should be JSON") - }; - - let stage = request("stage", serde_json::json!({"paths": ["example.txt"]})); - assert_eq!(stage["ok"], true); - let commit = request( - "commit", - serde_json::json!({"message": "initial", "amend": false}), - ); - assert_eq!(commit["ok"], true); - - fs::write(root.join("example.txt"), "staged change\n").expect("file should be writable"); - assert_eq!( - request("stage", serde_json::json!({"paths": ["example.txt"]}))["ok"], - true - ); - assert_eq!( - request("unstage", serde_json::json!({"paths": ["example.txt"]}))["ok"], - true - ); - assert_eq!( - String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), - " M example.txt\n" - ); - assert_eq!( - request("discard", serde_json::json!({"paths": ["example.txt"]}))["ok"], - true - ); - assert_eq!( - fs::read_to_string(root.join("example.txt")).expect("file should be readable"), - "initial\n" - ); - - // Conflict-dialog rollback must discard both sides of a file, including - // a staged edit followed by a working-tree edit. - fs::write(root.join("example.txt"), "staged\n").expect("file should be writable"); - assert!(run(&["add", "example.txt"]).status.success()); - fs::write(root.join("example.txt"), "working\n").expect("file should be writable"); - let discard_all = request("discardAll", serde_json::json!({"paths": ["example.txt"]})); - assert_eq!(discard_all["ok"], true, "{discard_all:?}"); - assert_eq!( - fs::read_to_string(root.join("example.txt")).expect("file should be readable"), - "initial\n" - ); - assert_eq!( - String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), - "" - ); - - fs::write(root.join("untracked.txt"), "discard me\n") - .expect("untracked file should be writable"); - assert_eq!( - request("discard", serde_json::json!({"paths": ["untracked.txt"]}))["ok"], - true - ); - assert!(!root.join("untracked.txt").exists()); - - let current = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) - .trim() - .to_string(); - let create = request( - "createBranch", - serde_json::json!({ - "reference": format!("refs/heads/{current}"), - "name": "feature/core", - "checkout": true - }), - ); - assert_eq!(create["ok"], true); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - "feature/core" - ); - - let checkout = request( - "checkout", - serde_json::json!({ - "reference": format!("refs/heads/{current}"), - "referenceKind": "local" - }), - ); - assert_eq!(checkout["ok"], true); - assert_eq!(checkout["data"]["exitCode"], 0, "{checkout:?}"); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - current - ); - - // Nested branch names go through the same short-name path. - assert!(run(&["branch", "feature/nested"]).status.success()); - let nested = request( - "checkout", - serde_json::json!({ - "reference": "refs/heads/feature/nested", - "referenceKind": "local" - }), - ); - assert_eq!(nested["ok"], true); - assert_eq!(nested["data"]["exitCode"], 0, "{nested:?}"); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - "feature/nested" - ); - assert!(run(&["switch", ¤t]).status.success()); - - fs::write(root.join("example.txt"), "working tree\n").expect("file should be writable"); - let stash = request( - "stashPush", - serde_json::json!({"message": "core write", "includeUntracked": false}), - ); - assert_eq!(stash["ok"], true); - let pop = request("stashPop", serde_json::json!({"reference": "stash@{0}"})); - assert_eq!(pop["ok"], true); - assert_eq!( - fs::read_to_string(root.join("example.txt")).expect("file should be readable"), - "working tree\n" - ); - - // Checkout conflict handling. `feature/core` and the current branch hold different - // content for conflict.txt, so a dirty working copy of it blocks a plain switch. - fs::write(root.join("conflict.txt"), "on main\n").expect("file should be writable"); - assert!(run(&["add", "conflict.txt"]).status.success()); - assert!(run(&["commit", "-qm", "main conflict"]).status.success()); - assert!(run(&["switch", "feature/core"]).status.success()); - fs::write(root.join("conflict.txt"), "on feature\n").expect("file should be writable"); - assert!(run(&["add", "conflict.txt"]).status.success()); - assert!(run(&["commit", "-qm", "feature conflict"]).status.success()); - assert!(run(&["switch", ¤t]).status.success()); - - let preflight = |reference: &str| -> Value { - let value = execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "preflight", - "command": "git.checkoutPreflight", - "payload": {"root": root, "reference": reference} - })) - .expect("request should encode"), - ); - serde_json::from_str(&value).expect("response should decode") - }; - - // Clean tree: nothing blocks the switch. - let clean = preflight("refs/heads/feature/core"); - assert_eq!(clean["ok"], true, "{clean:?}"); - assert_eq!( - clean["data"]["blockingPaths"], - serde_json::json!([]), - "{clean:?}" - ); - - // Dirty and divergent: preflight names the exact blocking file. - fs::write(root.join("conflict.txt"), "local edit\n").expect("file should be writable"); - let blocked = preflight("refs/heads/feature/core"); - assert_eq!(blocked["ok"], true); - assert_eq!( - blocked["data"]["blockingPaths"], - serde_json::json!(["conflict.txt"]), - "{blocked:?}" - ); - - // Untracked files that the target branch tracks also block a checkout, even - // though they never appear in `git diff HEAD`. - assert!(run(&["stash", "-u"]).status.success()); - fs::write(root.join("conflict.txt"), "untracked local\n").expect("file should be writable"); - let untracked_block = preflight("refs/heads/feature/core"); - assert_eq!( - untracked_block["data"]["blockingPaths"], - serde_json::json!(["conflict.txt"]), - "{untracked_block:?}" - ); - fs::remove_file(root.join("conflict.txt")).expect("file should be removable"); - assert!(run(&["stash", "pop"]).status.success()); - - // A plain checkout is refused rather than clobbering the edit. - let refused = request( - "checkout", - serde_json::json!({ - "reference": "refs/heads/feature/core", - "referenceKind": "local" - }), - ); - assert_ne!(refused["data"]["exitCode"], 0, "{refused:?}"); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - current - ); - - // Smart checkout stashes the edit, switches, and restores it. - assert!(run(&["switch", "-c", "feature/smart"]).status.success()); - let smart = request( - "checkout", - serde_json::json!({ - "reference": format!("refs/heads/{current}"), - "referenceKind": "local", - "autoStash": true - }), - ); - assert_eq!(smart["ok"], true); - assert_eq!(smart["data"]["exitCode"], 0, "{smart:?}"); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - current - ); - assert_eq!( - fs::read_to_string(root.join("conflict.txt")).expect("file should be readable"), - "local edit\n" - ); - assert!( - String::from_utf8_lossy(&run(&["stash", "list"]).stdout).is_empty(), - "smart checkout should consume its stash" - ); - - // Force checkout discards the local edit and lands on the target branch. - let forced = request( - "checkout", - serde_json::json!({ - "reference": "refs/heads/feature/core", - "referenceKind": "local", - "force": true - }), - ); - assert_eq!(forced["ok"], true); - assert_eq!(forced["data"]["exitCode"], 0, "{forced:?}"); - assert_eq!( - String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), - "feature/core" - ); - assert_eq!( - fs::read_to_string(root.join("conflict.txt")).expect("file should be readable"), - "on feature\n" - ); - assert!(run(&["switch", ¤t]).status.success()); - - let clone = root - .parent() - .expect("temporary root should have a parent") - .join(format!("lithe-core-clone-{}", std::process::id())); - let clone_result = request( - "clone", - serde_json::json!({ - "remote": root.to_string_lossy(), - "destination": clone.to_string_lossy() - }), - ); - assert_eq!(clone_result["ok"], true); - assert!(clone.join(".git").exists()); - fs::remove_dir_all(clone).expect("temporary clone should be removable"); - - let invalid = request( - "reset", - serde_json::json!({"revision": "HEAD", "mode": "--invalid"}), - ); - assert_eq!(invalid["ok"], false); - assert_eq!(invalid["error"]["code"], "invalid_request"); - - fs::remove_dir_all(root).expect("temporary repository should be removable"); - } - - #[test] - fn stash_restore_conflicts_return_structured_recovery_data() { - let root = temporary_root("git-stash-conflict"); - fs::create_dir_all(&root).expect("temporary repository should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q", "-b", "main"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); - assert!(run(&["add", "shared.txt"]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - - assert!(run(&["switch", "-qc", "feature"]).status.success()); - fs::write(root.join("shared.txt"), "feature\n").expect("file should be writable"); - assert!(run(&["commit", "-qam", "feature edit"]).status.success()); - assert!(run(&["switch", "-q", "main"]).status.success()); - - fs::write(root.join("shared.txt"), "local\n").expect("file should be writable"); - assert!(run(&["stash", "push", "-qm", "restore conflict"]) - .status - .success()); - let stash_reference = - String::from_utf8_lossy(&run(&["stash", "list", "--format=%gd"]).stdout) - .lines() - .next() - .expect("stash reference should exist") - .trim() - .to_string(); - assert!(run(&["switch", "-q", "feature"]).status.success()); - - let write = |operation: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": format!("stash-{operation}"), - "command": "git.write", - "payload": { - "root": root, - "operation": operation, - "reference": stash_reference - } - })) - .expect("stash request should encode"), - )) - .expect("stash response should be JSON") - }; - - let applied = write("stashApply"); - assert_eq!(applied["ok"], true, "{applied:?}"); - assert_eq!(applied["data"]["exitCode"], 1, "{applied:?}"); - assert_eq!( - applied["data"]["stashRestore"]["stashReference"], stash_reference, - "{applied:?}" - ); - assert_eq!( - applied["data"]["stashRestore"]["conflictedPaths"], - serde_json::json!(["shared.txt"]), - "{applied:?}" - ); - - // Clear the index conflict without dropping the saved entry, then verify - // `pop` reports the same structured recovery data. - assert!(run(&["reset", "--hard", "HEAD"]).status.success()); - let popped = write("stashPop"); - assert_eq!(popped["ok"], true, "{popped:?}"); - assert_eq!(popped["data"]["exitCode"], 1, "{popped:?}"); - assert_eq!( - popped["data"]["stashRestore"]["stashReference"], stash_reference, - "{popped:?}" - ); - assert_eq!( - popped["data"]["stashRestore"]["conflictedPaths"], - serde_json::json!(["shared.txt"]), - "{popped:?}" - ); - - assert!(run(&["reset", "--hard", "HEAD"]).status.success()); - assert!(run(&["stash", "drop", &stash_reference]).status.success()); - fs::remove_dir_all(root).expect("temporary repository should be removable"); - } - - #[test] - fn git_operation_state_reports_and_resolves_a_merge_conflict() { - let root = temporary_root("git-operation"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - - fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); - assert!(run(&["add", "shared.txt"]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - let current = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) - .trim() - .to_string(); - - let state = || -> Value { - let value = execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "operation-state", - "command": "git.operationState", - "payload": {"root": root} - })) - .expect("request should encode"), - ); - serde_json::from_str(&value).expect("response should decode") - }; - let write = |operation: &str| -> Value { - let value = execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "operation-write", - "command": "git.write", - "payload": {"root": root, "operation": operation} - })) - .expect("request should encode"), - ); - serde_json::from_str(&value).expect("response should decode") - }; - - // A settled repository reports no operation and no conflicts. - let idle = state(); - assert_eq!(idle["ok"], true, "{idle:?}"); - assert_eq!(idle["data"]["kind"], "", "{idle:?}"); - assert_eq!(idle["data"]["conflictedPaths"], serde_json::json!([])); - - // Continuing when nothing is in progress is rejected rather than run blindly. - let nothing = write("operationContinue"); - assert_eq!(nothing["ok"], false, "{nothing:?}"); - assert_eq!(nothing["error"]["code"], "invalid_request"); - - // Build two branches that edit the same line, so merging must conflict. - assert!(run(&["switch", "-qc", "feature/conflict"]).status.success()); - fs::write(root.join("shared.txt"), "from feature\n").expect("file should be writable"); - assert!(run(&["commit", "-qam", "feature edit"]).status.success()); - assert!(run(&["switch", "-q", ¤t]).status.success()); - fs::write(root.join("shared.txt"), "from main\n").expect("file should be writable"); - assert!(run(&["commit", "-qam", "main edit"]).status.success()); - - // Conflicting merges exit non-zero; the point is the state they leave behind. - assert!(!run(&["merge", "--no-edit", "feature/conflict"]) - .status - .success()); - - let conflicted = state(); - assert_eq!(conflicted["ok"], true, "{conflicted:?}"); - assert_eq!(conflicted["data"]["kind"], "merge", "{conflicted:?}"); - assert_eq!( - conflicted["data"]["conflictedPaths"], - serde_json::json!(["shared.txt"]), - "{conflicted:?}" - ); - - // Continuing with the conflict unresolved is refused, so the user cannot - // commit conflict markers by clicking through the banner. - let premature = write("operationContinue"); - assert_eq!(premature["ok"], false, "{premature:?}"); - assert_eq!(premature["error"]["code"], "invalid_request"); - - // A merge has no skip step. - let skip = write("operationSkip"); - assert_eq!(skip["ok"], false, "{skip:?}"); - - // Resolving the file and continuing completes the merge without opening an editor. - fs::write(root.join("shared.txt"), "resolved\n").expect("file should be writable"); - assert!(run(&["add", "shared.txt"]).status.success()); - let finished = write("operationContinue"); - assert_eq!(finished["ok"], true, "{finished:?}"); - assert_eq!(finished["data"]["exitCode"], 0, "{finished:?}"); - - let settled = state(); - assert_eq!(settled["data"]["kind"], "", "{settled:?}"); - assert_eq!(settled["data"]["conflictedPaths"], serde_json::json!([])); - - // Abort restores the pre-merge state of a fresh conflict. - fs::write(root.join("shared.txt"), "main again\n").expect("file should be writable"); - assert!(run(&["commit", "-qam", "main again"]).status.success()); - assert!(run(&["switch", "-q", "feature/conflict"]).status.success()); - fs::write(root.join("shared.txt"), "feature again\n").expect("file should be writable"); - assert!(run(&["commit", "-qam", "feature again"]).status.success()); - assert!(!run(&["merge", "--no-edit", ¤t]).status.success()); - assert_eq!(state()["data"]["kind"], "merge"); - - let aborted = write("operationAbort"); - assert_eq!(aborted["ok"], true, "{aborted:?}"); - assert_eq!(aborted["data"]["exitCode"], 0, "{aborted:?}"); - assert_eq!(state()["data"]["kind"], ""); - assert_eq!( - fs::read_to_string(root.join("shared.txt")).expect("file should be readable"), - "feature again\n" - ); - - fs::remove_dir_all(root).expect("temporary repository should be removable"); - } - - #[test] - fn git_diff_and_apply_round_trip_a_patch() { - let root = temporary_root("git-diff"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - fs::write(root.join("example.txt"), "before\n").expect("file should be writable"); - assert!(run(&["add", "example.txt"]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - fs::write(root.join("example.txt"), "after\n").expect("file should be writable"); - - let diff = serde_json::json!({ - "id": "diff", - "command": "git.diff", - "payload": { - "root": root, - "pathspecs": ["example.txt"], - "contextLines": 80 - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&diff).expect("diff request should encode"), - )) - .expect("diff response should be JSON"); - assert_eq!(response["ok"], true); - assert!(response["data"]["patch"] - .as_str() - .expect("diff output should be text") - .contains("+after")); - assert_eq!(response["data"]["hunks"].as_array().unwrap().len(), 1); - assert!(response["data"]["rows"] - .as_array() - .unwrap() - .iter() - .any(|row| row["kind"] == "changed" && row["right"] == "after")); - - let reference_diff = serde_json::json!({ - "id": "reference-diff", - "command": "git.diff", - "payload": { - "root": root, - "pathspecs": ["example.txt"], - "reference": "HEAD", - "contextLines": 80 - } - }); - let reference_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&reference_diff).expect("reference diff should encode"), - )) - .expect("reference diff response should be JSON"); - assert_eq!(reference_response["ok"], true); - assert!(reference_response["data"]["patch"] - .as_str() - .expect("reference diff patch should be text") - .contains("+after")); - - let apply = serde_json::json!({ - "id": "apply", - "command": "git.apply", - "payload": { - "root": root, - "patch": response["data"]["patch"], - "mode": "stage" - } - }); - let apply_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&apply).expect("apply request should encode"), - )) - .expect("apply response should be JSON"); - assert_eq!(apply_response["ok"], true); - assert_eq!(apply_response["data"]["exitCode"], 0); - - let status = run(&["status", "--porcelain"]).stdout; - assert_eq!(String::from_utf8_lossy(&status), "M example.txt\n"); - - // Shelve restores the index snapshot and the unstaged worktree delta - // separately. Verify that a file with both kinds of edits returns as MM - // and keeps the final worktree content. - assert!(run(&["reset", "--hard", "HEAD"]).status.success()); - fs::write(root.join("example.txt"), "staged\n").expect("file should be writable"); - assert!(run(&["add", "example.txt"]).status.success()); - let staged_diff = serde_json::json!({ - "id": "staged-diff", - "command": "git.diff", - "payload": { - "root": root, - "pathspecs": ["example.txt"], - "staged": true - } - }); - let staged_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&staged_diff).expect("staged diff request should encode"), - )) - .expect("staged diff response should be JSON"); - let staged_patch = staged_response["data"]["patch"] - .as_str() - .expect("staged patch should be text") - .to_string(); - - fs::write(root.join("example.txt"), "final\n").expect("file should be writable"); - let working_diff = serde_json::json!({ - "id": "working-diff", - "command": "git.diff", - "payload": { - "root": root, - "pathspecs": ["example.txt"] - } - }); - let working_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&working_diff).expect("working diff request should encode"), - )) - .expect("working diff response should be JSON"); - let working_patch = working_response["data"]["patch"] - .as_str() - .expect("working patch should be text") - .to_string(); - assert!(run(&["reset", "--hard", "HEAD"]).status.success()); - - for (id, patch, mode) in [ - ("restore-index", staged_patch.as_str(), "restoreIndex"), - ("restore-worktree", working_patch.as_str(), "worktree"), - ] { - let apply = serde_json::json!({ - "id": id, - "command": "git.apply", - "payload": {"root": root, "patch": patch, "mode": mode} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&apply).expect("restore apply request should encode"), - )) - .expect("restore apply response should be JSON"); - assert_eq!(response["ok"], true, "{response:?}"); - assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); - } - assert_eq!( - String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), - "MM example.txt\n" - ); - assert_eq!( - fs::read_to_string(root.join("example.txt")).expect("file should be readable"), - "final\n" - ); - - for (id, patch, mode) in [ - ( - "restore-index-check", - staged_patch.as_str(), - "restoreIndexCheck", - ), - ("worktree-check", working_patch.as_str(), "worktreeCheck"), - ] { - let check = serde_json::json!({ - "id": id, - "command": "git.apply", - "payload": {"root": root, "patch": patch, "mode": mode} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&check).expect("patch check request should encode"), - )) - .expect("patch check response should be JSON"); - assert_eq!(response["ok"], true, "{response:?}"); - assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); - } - - assert!(run(&["reset", "--hard", "HEAD"]).status.success()); - fs::write(root.join("new.txt"), "untracked\n").expect("file should be writable"); - let untracked_diff = serde_json::json!({ - "id": "untracked-diff", - "command": "git.diff", - "payload": { - "root": root, - "pathspecs": ["new.txt"], - "untracked": true - } - }); - let untracked_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&untracked_diff).expect("untracked diff request should encode"), - )) - .expect("untracked diff response should be JSON"); - let untracked_patch = untracked_response["data"]["patch"] - .as_str() - .expect("untracked patch should be text") - .to_string(); - fs::remove_file(root.join("new.txt")).expect("file should be removable"); - let untracked_apply = serde_json::json!({ - "id": "untracked-apply", - "command": "git.apply", - "payload": {"root": root, "patch": untracked_patch, "mode": "worktree"} - }); - let untracked_apply_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&untracked_apply) - .expect("untracked apply request should encode"), - )) - .expect("untracked apply response should be JSON"); - assert_eq!(untracked_apply_response["ok"], true); - assert_eq!(untracked_apply_response["data"]["exitCode"], 0); - assert_eq!( - fs::read_to_string(root.join("new.txt")).expect("file should be readable"), - "untracked\n" - ); - fs::remove_dir_all(root).expect("temporary workspace should be removable"); - } - - #[test] - fn git_history_returns_references_and_commit_graph_fields() { - let root = temporary_root("git-history"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - fs::write(root.join("example.txt"), "hello\n").expect("file should be writable"); - assert!(run(&["add", "example.txt"]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - - let commit_hash = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) - .trim() - .to_string(); - - let blame_request = serde_json::json!({ - "id": "blame", - "command": "git.blame", - "payload": {"root": root, "path": "example.txt"} - }); - let blame_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&blame_request).expect("blame request should encode"), - )) - .expect("blame response should be JSON"); - assert_eq!(blame_response["ok"], true); - assert_eq!(blame_response["data"]["lines"][0]["line"], 1); - assert_eq!( - blame_response["data"]["lines"][0]["commitHash"], - commit_hash - ); - - let commit_request = serde_json::json!({ - "id": "commit", - "command": "git.commit", - "payload": {"root": root, "commit": commit_hash} - }); - let commit_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&commit_request).expect("commit request should encode"), - )) - .expect("commit response should be JSON"); - assert_eq!(commit_response["ok"], true); - assert_eq!(commit_response["data"]["commit"]["hash"], commit_hash); - - let files_request = serde_json::json!({ - "id": "files", - "command": "git.commitFiles", - "payload": {"root": root, "commit": commit_hash} - }); - let files_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&files_request).expect("commit files request should encode"), - )) - .expect("commit files response should be JSON"); - assert_eq!(files_response["ok"], true); - assert_eq!(files_response["data"]["files"][0]["path"], "example.txt"); - - fs::write(root.join("example.txt"), "changed\n").expect("file should be writable"); - let comparison_request = serde_json::json!({ - "id": "comparison", - "command": "git.comparison", - "payload": {"root": root, "reference": "HEAD"} - }); - let comparison_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&comparison_request).expect("comparison request should encode"), - )) - .expect("comparison response should be JSON"); - assert_eq!(comparison_response["ok"], true); - assert_eq!( - comparison_response["data"]["files"][0]["path"], - "example.txt" - ); - - assert!(run(&["stash", "push", "-qm", "saved"]).status.success()); - let stashes_request = serde_json::json!({ - "id": "stashes", - "command": "git.stashes", - "payload": {"root": root} - }); - let stashes_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&stashes_request).expect("stashes request should encode"), - )) - .expect("stashes response should be JSON"); - assert_eq!(stashes_response["ok"], true); - assert_eq!(stashes_response["data"]["stashes"][0]["message"], "saved"); - - let request = serde_json::json!({ - "id": "history", - "command": "git.history", - "payload": {"root": root, "reference": "HEAD", "limit": 10} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("history request should encode"), - )) - .expect("history response should be JSON"); - assert_eq!(response["ok"], true); - assert_eq!(response["data"]["commits"][0]["subject"], "initial"); - assert!( - response["data"]["commits"][0]["hash"] - .as_str() - .expect("commit hash should be text") - .len() - >= 7 - ); - assert!(response["data"]["references"] - .as_array() - .expect("references should be an array") - .iter() - .any(|reference| reference["kind"] == "local")); - - fs::remove_dir_all(root).expect("temporary workspace should be removable"); - } - - #[test] - fn maven_scan_returns_recursive_shared_project_model() { - let root = temporary_root("maven"); - fs::create_dir_all(root.join("module-a/module-b")).expect("modules should be creatable"); - fs::write( - root.join("pom.xml"), - r#"com.exampledemo1pommodule-adevtrue"#, - ) - .expect("root pom should be writable"); - fs::write( - root.join("module-a/pom.xml"), - r#"onemodule-b"#, - ) - .expect("module pom should be writable"); - fs::write( - root.join("module-a/module-b/pom.xml"), - r#"two"#, - ) - .expect("nested pom should be writable"); - fs::write(root.join("mvnw.cmd"), "@echo off\n").expect("wrapper should be writable"); - - let request = serde_json::json!({ - "id": "maven", - "command": "maven.scan", - "payload": {"root": root} - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&request).expect("Maven request should encode"), - )) - .expect("Maven response should be JSON"); - assert_eq!(response["ok"], true); - assert_eq!(response["data"]["artifactId"], "demo"); - assert_eq!(response["data"]["packaging"], "pom"); - assert_eq!(response["data"]["profiles"][0]["id"], "dev"); - assert_eq!(response["data"]["hasWrapper"], true); - assert_eq!(response["data"]["modules"][0]["relativePath"], "module-a"); - assert_eq!( - response["data"]["modules"][0]["modules"][0]["relativePath"], - "module-a/module-b" - ); - let diagnostics = serde_json::json!({ - "id": "maven-diagnostics", - "command": "maven.diagnostics", - "payload": { - "root": root, - "output": "[ERROR] src/App.java:[12,4] cannot find symbol\n[ERROR] src/App.java:[12,4] cannot find symbol\n[WARNING] src/App.java:[4] unused import\n" - } - }); - let diagnostics_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&diagnostics).expect("diagnostics request should encode"), - )) - .expect("diagnostics response should be JSON"); - assert_eq!(diagnostics_response["ok"], true); - assert_eq!( - diagnostics_response["data"]["issues"] - .as_array() - .unwrap() - .len(), - 2 - ); - assert_eq!( - diagnostics_response["data"]["issues"][0]["severity"], - "error" - ); - fs::remove_dir_all(root).expect("Maven fixture should be removable"); - } - - #[test] - fn java_core_commands_return_shared_runtime_and_structure_data() { - let root = temporary_root("java"); - fs::create_dir_all(root.join("src/main/java/com/example")) - .expect("Java source should be creatable"); - fs::write( - root.join("src/main/java/com/example/App.java"), - "package com.example;\n@SpringBootApplication\nclass App {\n static void main(String[] args) {}\n}\n", - ) - .expect("Java source should be writable"); - let configurations = serde_json::json!({ - "id": "java-config", - "command": "java.runConfigurations", - "payload": { - "root": root, - "paths": ["src/main/java/com/example/App.java"], - "modulePaths": ["src"] - } - }); - let response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&configurations).expect("Java request should encode"), - )) - .expect("Java response should be JSON"); - assert_eq!(response["ok"], true); - assert_eq!( - response["data"]["mainClasses"][0]["qualifiedName"], - "com.example.App" - ); - assert_eq!(response["data"]["configurations"][0]["kind"], "springBoot"); - assert_eq!(response["data"]["configurations"][0]["modulePath"], "src"); - - let structure = serde_json::json!({ - "id": "java-structure", - "command": "java.structure", - "payload": { - "source": "import a.A;\nimport b.B;\ninterface Service {}\n" - } - }); - let structure_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&structure).expect("Java structure request should encode"), - )) - .expect("Java structure response should be JSON"); - assert_eq!(structure_response["ok"], true); - assert_eq!( - structure_response["data"]["foldRegions"][0]["kind"], - "imports" - ); - assert_eq!( - structure_response["data"]["implementationMarkers"][0]["direction"], - "down" - ); - let swift_structure = serde_json::json!({ - "id": "swift-structure", - "command": "java.structure", - "payload": { - "source": "struct Demo {\n func run() {\n if ready {\n work()\n }\n }\n}\n" - } - }); - let swift_structure_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&swift_structure) - .expect("Swift structure request should encode"), - )) - .expect("Swift structure response should be JSON"); - let swift_folds = swift_structure_response["data"]["foldRegions"] - .as_array() - .expect("Swift structure should return fold regions"); - assert!(swift_folds.iter().any(|fold| { - fold["startLine"] == 0 && fold["endLine"] == 6 && fold["kind"] == "type" - })); - assert!(swift_folds.iter().any(|fold| { - fold["startLine"] == 1 && fold["endLine"] == 5 && fold["kind"] == "method" - })); - let code_vision = serde_json::json!({ - "id": "java-vision", - "command": "java.codeVision", - "payload": { - "root": root, - "targetPath": "src/main/java/com/example/App.java", - "paths": ["src/main/java/com/example/App.java"] - } - }); - let vision_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&code_vision).expect("code vision request should encode"), - )) - .expect("code vision response should be JSON"); - assert_eq!(vision_response["ok"], true); - assert!(vision_response["data"]["hints"] - .as_array() - .unwrap() - .iter() - .any(|hint| hint["symbol"] == "App")); - let class_name = serde_json::json!({ - "id": "java-class", - "command": "java.className", - "payload": {"source": "package com.example;\nclass App {}", "simpleName": "App"} - }); - let class_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&class_name).expect("class name request should encode"), - )) - .expect("class name response should be JSON"); - assert_eq!(class_response["data"]["className"], "com.example.App"); - let definition = serde_json::json!({ - "id": "java-definition", - "command": "java.sourceDefinition", - "payload": { - "source": "class App {\n void run() {}\n}", - "declarationName": "App", - "memberName": "run" - } - }); - let definition_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&definition).expect("definition request should encode"), - )) - .expect("definition response should be JSON"); - assert_eq!(definition_response["data"]["line"], 1); - let server_port = serde_json::json!({ - "id": "java-port", - "command": "java.serverPort", - "payload": {"content": "server:\n port: 8080\n", "fileExtension": "yml"} - }); - let port_response: Value = serde_json::from_str(&execute_json( - &serde_json::to_string(&server_port).expect("server port request should encode"), - )) - .expect("server port response should be JSON"); - assert_eq!(port_response["data"]["port"], 8080); - fs::remove_dir_all(root).expect("Java fixture should be removable"); - } - - #[test] - fn git_conflict_markers_ignore_markdown_headings() { - let root = temporary_root("git-markers"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q", "-b", "main"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - - let markers = || -> Value { - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "conflict-markers", - "command": "git.conflictMarkers", - "payload": {"root": root} - })) - .expect("request should encode"), - )) - .expect("conflict marker response should be JSON") - }; - - // A Markdown setext heading underline looks exactly like the middle of a - // conflict block, so matching a bare `=======` would flag ordinary docs. - fs::write(root.join("doc.md"), "Title\n=======\n\nbody\n") - .expect("file should be writable"); - assert!(run(&["add", "doc.md"]).status.success()); - let clean = markers(); - assert_eq!(clean["ok"], true); - assert_eq!( - clean["data"]["paths"].as_array().unwrap().len(), - 0, - "a Markdown heading is not a conflict: {clean}" - ); - - // Only files carrying the opening or closing marker are real conflicts. - fs::write( - root.join("code.txt"), - "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\n", - ) - .expect("file should be writable"); - // The diff3 style adds a `|||||||` base section, which also counts. - fs::write( - root.join("diff3.txt"), - "x\n<<<<<<< HEAD\na\n||||||| base\nb\n=======\nc\n>>>>>>> other\n", - ) - .expect("file should be writable"); - assert!(run(&["add", "."]).status.success()); - - let found = markers(); - let paths = found["data"]["paths"].as_array().unwrap(); - assert_eq!(paths.len(), 2, "{found}"); - assert_eq!(paths[0], "code.txt"); - assert_eq!(paths[1], "diff3.txt"); - - fs::remove_dir_all(root).expect("Git fixture should be removable"); - } - - #[test] - fn git_integration_preflight_separates_merge_overlap_from_rebase_strictness() { - let root = temporary_root("git-integration"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q", "-b", "main"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - - fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); - fs::write(root.join("other.txt"), "untouched\n").expect("file should be writable"); - assert!(run(&["add", "."]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - - // A side branch that only ever touches shared.txt. - assert!(run(&["switch", "-qc", "feature"]).status.success()); - fs::write(root.join("shared.txt"), "incoming\n").expect("file should be writable"); - assert!(run(&["add", "shared.txt"]).status.success()); - assert!(run(&["commit", "-qm", "incoming"]).status.success()); - assert!(run(&["switch", "-q", "main"]).status.success()); - // Move main forward so the branches genuinely diverge. - fs::write(root.join("main.txt"), "main\n").expect("file should be writable"); - assert!(run(&["add", "main.txt"]).status.success()); - assert!(run(&["commit", "-qm", "main side"]).status.success()); - - let preflight = |operation: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "integration-preflight", - "command": "git.integrationPreflight", - "payload": { - "root": root, - "reference": "refs/heads/feature", - "operation": operation - } - })) - .expect("request should encode"), - )) - .expect("integration preflight response should be JSON") - }; - - // A clean tree blocks neither operation. - assert_eq!( - preflight("merge")["data"]["blockingPaths"] - .as_array() - .unwrap() - .len(), - 0 - ); - assert_eq!( - preflight("rebase")["data"]["blockingPaths"] - .as_array() - .unwrap() - .len(), - 0 - ); - - // Dirty a file the incoming branch never touches. Git lets a merge proceed - // here but still refuses a rebase, so the two must report differently. - fs::write(root.join("other.txt"), "local edit\n").expect("file should be writable"); - - let merge = preflight("merge"); - assert_eq!(merge["ok"], true); - assert_eq!( - merge["data"]["blockingPaths"].as_array().unwrap().len(), - 0, - "an unrelated edit should not block a merge: {merge}" - ); - assert_eq!(merge["data"]["blocksEntirely"], false); - - let rebase = preflight("rebase"); - assert_eq!(rebase["data"]["blockingPaths"][0], "other.txt"); - assert_eq!(rebase["data"]["blocksEntirely"], true); - - // Now dirty the file the merge would write; that one does block it. - fs::write(root.join("shared.txt"), "local edit\n").expect("file should be writable"); - let overlapping = preflight("merge"); - assert_eq!(overlapping["data"]["blockingPaths"][0], "shared.txt"); - assert_eq!( - overlapping["data"]["blockingPaths"] - .as_array() - .unwrap() - .len(), - 1, - "only the overlapping file blocks: {overlapping}" - ); - - // An unknown operation is rejected rather than guessed at. - let invalid = serde_json::from_str::(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "integration-preflight", - "command": "git.integrationPreflight", - "payload": { - "root": root, - "reference": "refs/heads/feature", - "operation": "graft" - } - })) - .expect("request should encode"), - )) - .expect("response should be JSON"); - assert_eq!(invalid["ok"], false); - - fs::remove_dir_all(root).expect("Git fixture should be removable"); - } - - #[test] - fn git_integration_preflight_scopes_cherry_pick_to_the_replayed_commit() { - let root = temporary_root("git-cherry-pick"); - fs::create_dir_all(&root).expect("temporary workspace should be creatable"); - let run = |arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(&root) - .output() - .expect("git should be available") - }; - assert!(run(&["init", "-q", "-b", "main"]).status.success()); - assert!(run(&["config", "core.autocrlf", "false"]).status.success()); - assert!(run(&["config", "user.email", "test@example.com"]) - .status - .success()); - assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); - - fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); - fs::write(root.join("other.txt"), "untouched\n").expect("file should be writable"); - assert!(run(&["add", "."]).status.success()); - assert!(run(&["commit", "-qm", "initial"]).status.success()); - - // A side branch of two commits. Only the second one touches shared.txt, so - // picking it must consider that file alone rather than the whole branch. - assert!(run(&["switch", "-qc", "feature"]).status.success()); - fs::write(root.join("early.txt"), "early\n").expect("file should be writable"); - assert!(run(&["add", "early.txt"]).status.success()); - assert!(run(&["commit", "-qm", "earlier work"]).status.success()); - fs::write(root.join("shared.txt"), "incoming\n").expect("file should be writable"); - assert!(run(&["add", "shared.txt"]).status.success()); - assert!(run(&["commit", "-qm", "touches shared"]).status.success()); - let pick = String::from_utf8(run(&["rev-parse", "HEAD"]).stdout) - .expect("a revision should be UTF-8"); - let pick = pick.trim().to_string(); - assert!(run(&["switch", "-q", "main"]).status.success()); - - let preflight = |operation: &str, reference: &str| -> Value { - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "integration-preflight", - "command": "git.integrationPreflight", - "payload": { - "root": root, - "reference": reference, - "operation": operation - } - })) - .expect("request should encode"), - )) - .expect("integration preflight response should be JSON") - }; - - // An edit to a file the picked commit never touches is not in its way, the - // same rule a merge follows and unlike a rebase. - fs::write(root.join("other.txt"), "local edit\n").expect("file should be writable"); - for operation in ["cherryPick", "revert"] { - let clear = preflight(operation, &pick); - assert_eq!(clear["ok"], true, "{operation} should succeed: {clear}"); - assert_eq!( - clear["data"]["blockingPaths"].as_array().unwrap().len(), - 0, - "an unrelated edit should not block {operation}: {clear}" - ); - assert_eq!(clear["data"]["blocksEntirely"], false); - } - - // Dirtying the file that commit rewrites does block it. - fs::write(root.join("shared.txt"), "local edit\n").expect("file should be writable"); - let blocked = preflight("cherryPick", &pick); - assert_eq!(blocked["data"]["blockingPaths"][0], "shared.txt"); - assert_eq!( - blocked["data"]["blockingPaths"].as_array().unwrap().len(), - 1, - "only the file the commit writes blocks it: {blocked}" - ); - - // The branch tip as a whole also adds early.txt, but picking the single - // commit must not inherit that; a merge of the same ref would report it. - let merge = preflight("merge", "refs/heads/feature"); - let merge_blocking = merge["data"]["blockingPaths"].as_array().unwrap(); - assert!( - merge_blocking.iter().any(|path| path == "shared.txt"), - "the merge shares the overlap: {merge}" - ); - - fs::remove_dir_all(root).expect("Git fixture should be removable"); - } - - #[test] - fn git_pull_preflight_reports_divergence_and_strategies_resolve_it() { - let root = temporary_root("git-pull"); - let upstream = root.join("upstream"); - let work = root.join("work"); - fs::create_dir_all(&upstream).expect("temporary workspace should be creatable"); - - let git = |directory: &std::path::Path, arguments: &[&str]| { - Command::new("git") - .args(arguments) - .current_dir(directory) - .output() - .expect("git should be available") - }; - let identify = |directory: &std::path::Path| { - assert!(git(directory, &["config", "core.autocrlf", "false"]) - .status - .success()); - assert!( - git(directory, &["config", "user.email", "test@example.com"]) - .status - .success() - ); - assert!(git(directory, &["config", "user.name", "Lithe Test"]) - .status - .success()); - }; - - assert!(git(&upstream, &["init", "-q", "-b", "main"]) - .status - .success()); - identify(&upstream); - fs::write(upstream.join("shared.txt"), "base\n").expect("file should be writable"); - assert!(git(&upstream, &["add", "shared.txt"]).status.success()); - assert!(git(&upstream, &["commit", "-qm", "initial"]) - .status - .success()); - - assert!(git( - &root, - &[ - "clone", - "-q", - "-c", - "core.autocrlf=false", - "upstream", - "work" - ] - ) - .status - .success()); - identify(&work); - - let preflight = || -> Value { - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "pull-preflight", - "command": "git.pullPreflight", - "payload": {"root": work} - })) - .expect("request should encode"), - )) - .expect("pull preflight response should be JSON") - }; - - // A fresh clone is level with its upstream, so nothing needs deciding. - let clean = preflight(); - assert_eq!(clean["ok"], true); - assert_eq!(clean["data"]["upstream"], "origin/main"); - assert_eq!(clean["data"]["diverged"], false); - assert_eq!(clean["data"]["ahead"], 0); - assert_eq!(clean["data"]["behind"], 0); - - // Commit on both sides so neither can fast-forward past the other. - fs::write(upstream.join("remote.txt"), "remote\n").expect("file should be writable"); - assert!(git(&upstream, &["add", "remote.txt"]).status.success()); - assert!(git(&upstream, &["commit", "-qm", "remote"]) - .status - .success()); - fs::write(work.join("local.txt"), "local\n").expect("file should be writable"); - assert!(git(&work, &["add", "local.txt"]).status.success()); - assert!(git(&work, &["commit", "-qm", "local"]).status.success()); - assert!(git(&work, &["fetch", "-q"]).status.success()); - - let diverged = preflight(); - assert_eq!(diverged["data"]["diverged"], true); - assert_eq!(diverged["data"]["ahead"], 1); - assert_eq!(diverged["data"]["behind"], 1); - - let pull = |mode: Option<&str>| -> Value { - let mut payload = serde_json::json!({"root": work, "operation": "pull"}); - if let Some(mode) = mode { - payload["mode"] = serde_json::json!(mode); - } - serde_json::from_str(&execute_json( - &serde_json::to_string(&serde_json::json!({ - "id": "pull", - "command": "git.write", - "payload": payload - })) - .expect("request should encode"), - )) - .expect("pull response should be JSON") - }; - - // The default refuses a divergent history rather than inventing a merge. - let refused = pull(None); - assert_ne!(refused["data"]["exitCode"], 0); - - // Rebase replays the local commit on top, leaving a linear history. - let rebased = pull(Some("rebase")); - assert_eq!(rebased["data"]["exitCode"], 0, "{rebased}"); - - let settled = preflight(); - assert_eq!(settled["data"]["diverged"], false); - assert_eq!(settled["data"]["behind"], 0); - assert_eq!(settled["data"]["ahead"], 1); - - // An unknown strategy is rejected before Git ever runs. - let invalid = pull(Some("squash")); - assert_eq!(invalid["ok"], false); - - fs::remove_dir_all(root).expect("Git fixture should be removable"); - } - - /// The v1 -> v2 rewrite must not change a single byte of the emitted command - /// line. Values are asserted literally rather than recomputed, so a future - /// refactor that silently drops an argument fails here instead of at runtime. - #[test] - fn migrated_v1_documents_produce_identical_launch_arguments() { - let root = temporary_root("run-config-migration"); - fs::create_dir_all(root.join("backend/src/main/java/com/example")).unwrap(); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write(root.join("pom.xml"), "").unwrap(); - fs::write(root.join("backend/pom.xml"), "").unwrap(); - fs::write( - root.join("backend/src/main/java/com/example/App.java"), - "package com.example; class App { public static void main(String[] args) {} }", - ) - .unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{ - "id":"spring:com.example.App", - "name":"App", - "type":"spring-boot.maven", - "module":"backend", - "workingDirectory":".", - "mainClass":"com.example.App", - "jvmArguments":["-Xmx2g"], - "programArguments":["--dev"], - "mavenProfiles":["local"], - "toolchains":{"java":"project-jdk","maven":"project-maven"} - }]}"#, - ) - .unwrap(); - - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "migrated-plan", - "command": "runConfig.createLaunchPlan", - "payload": { - "root": root, - "configurationId": "spring:com.example.App", - "debugPort": 5005 - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], true); - assert_eq!( - plan["data"]["arguments"], - serde_json::json!([ - "-B", - "-ntp", - "-pl", - "backend", - "-P", - "local", - "-Dspring-boot.run.main-class=com.example.App", - "-Dspring-boot.run.jvmArguments=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:5005 -Duser.language=en -Duser.country=US -Xmx2g", - "-Dspring-boot.run.arguments=--dev", - "spring-boot:run" - ]) - ); - assert_eq!(plan["data"]["workingDirectory"], "."); - assert_eq!(plan["data"]["executable"]["toolchain"], "project-maven"); - - fs::remove_dir_all(root).unwrap(); - } - - /// `project.json` and the toolchain files sit under `.lithe` and carry their - /// own `version: 1`, unrelated to the run-configuration schema. Migration - /// must not touch them, or resolve rejects a perfectly valid project. - #[test] - fn migration_leaves_sidecar_documents_at_their_own_version() { - let root = temporary_root("run-config-sidecar"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"}]}"#, - ) - .unwrap(); - fs::write( - root.join(".lithe/project.json"), - r#"{"version":1,"defaultRunConfiguration":"current-file"}"#, - ) - .unwrap(); - fs::write( - root.join(".lithe/toolchains/requirements.json"), - r#"{"version":1,"toolchains":{}}"#, - ) - .unwrap(); - - let resolved: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "sidecar", - "command": "runConfig.resolve", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(resolved["ok"], true, "{resolved}"); - assert_eq!(resolved["data"]["version"], 2); - assert_eq!(resolved["data"]["defaultRunConfiguration"], "current-file"); - - fs::remove_dir_all(root).unwrap(); - } - - /// A non-Java service must reach a launch plan without acquiring a Java - /// toolchain or a JAVA_HOME it has no use for. - #[test] - fn process_configurations_launch_without_java_assumptions() { - let root = temporary_root("run-config-process"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::create_dir_all(root.join("frontend")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":2,"configurations":[{ - "id":"npm:dev", - "name":"web dev", - "provider":"npm.script", - "execution":"service", - "confidence":"declared", - "command":"npm", - "args":["run","dev"], - "cwd":"frontend", - "env":{"PORT":"3000"}, - "toolchains":{} - }]}"#, - ) - .unwrap(); - - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "process-plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "npm:dev"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!(plan["data"]["executable"]["command"], "npm"); - assert!(plan["data"]["executable"]["toolchain"].is_null()); - assert_eq!(plan["data"]["arguments"], serde_json::json!(["run", "dev"])); - assert_eq!(plan["data"]["workingDirectory"], "frontend"); - assert_eq!(plan["data"]["env"]["PORT"], "3000"); - assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn toolchain_backed_process_uses_the_generic_runtime_binding() { - let root = temporary_root("run-config-go-toolchain"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":2,"configurations":[{ - "id":"go:api","name":"Go API","provider":"go.main", - "execution":"application","args":["run","./cmd/api"],"cwd":".", - "env":{"APP_ENV":"dev"},"toolchains":{"runtime":"project-go"} - }]}"#, - ) - .unwrap(); - - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "go-toolchain-plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "go:api"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!(plan["data"]["executable"]["toolchain"], "project-go"); - assert!(plan["data"]["executable"]["command"].is_null()); - assert_eq!(plan["data"]["arguments"], serde_json::json!(["run", "./cmd/api"])); - assert_eq!(plan["data"]["env"]["APP_ENV"], "dev"); - assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn pure_go_generation_does_not_require_java_or_add_java_current_file() { - let root = temporary_root("pure-go-no-jdk"); - fs::create_dir_all(&root).unwrap(); - fs::write(root.join("go.mod"), "module example.com/api\n\ngo 1.24\n").unwrap(); - fs::write(root.join("main.go"), "package main\nfunc main() {}\n").unwrap(); - - let generated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-pure-go", - "command": "runConfig.generate", - "payload": {"root": root, "paths": ["go.mod", "main.go"], "modulePaths": []} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(generated["ok"], true, "{generated}"); - let configurations = generated["data"]["generated"]["configurations"] - .as_array() - .unwrap(); - assert!(configurations.iter().any(|value| value["provider"] == "go.main")); - assert!(!configurations.iter().any(|value| value["id"] == "current-file")); - assert!(generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"] - .is_null()); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn multi_language_generation_declares_runtime_requirements_and_versions() { - let root = temporary_root("generic-toolchain-requirements"); - for directory in ["python", "web", "worker/src"] { - fs::create_dir_all(root.join(directory)).unwrap(); - } - fs::write(root.join("go.mod"), "module example.com/api\n\ngo 1.24\n").unwrap(); - fs::write(root.join("main.go"), "package main\nfunc main() {}\n").unwrap(); - fs::write( - root.join("python/pyproject.toml"), - "[project]\nname = \"api\"\nrequires-python = \">=3.12\"\n[project.scripts]\napi = \"api:main\"\n", - ) - .unwrap(); - fs::write( - root.join("web/package.json"), - r#"{"engines":{"node":">=22.4"},"scripts":{"dev":"vite"}}"#, - ) - .unwrap(); - fs::write( - root.join("worker/Cargo.toml"), - "[package]\nname = \"worker\"\nversion = \"0.1.0\"\nrust-version = \"1.82\"\n", - ) - .unwrap(); - fs::write(root.join("worker/src/main.rs"), "fn main() {}\n").unwrap(); - - let generated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-generic-requirements", - "command": "runConfig.generate", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(generated["ok"], true, "{generated}"); - let requirements = &generated["data"]["toolchainRequirements"]["toolchains"]; - for (id, kind, version) in [ - ("project-go", "go", "1.24"), - ("project-python", "python", "3.12"), - ("project-node", "node", "22.4"), - ("project-cargo", "rust", "1.82"), - ] { - assert_eq!(requirements[id]["type"], kind, "{requirements}"); - assert_eq!(requirements[id]["minimumVersion"], version, "{requirements}"); - } - assert!(requirements["project-jdk"].is_null(), "{requirements}"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn unknown_provider_without_an_executable_never_falls_into_maven() { - let root = temporary_root("run-config-unknown-provider"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":2,"configurations":[{ - "id":"zig:app","name":"Zig App","provider":"zig.main", - "args":[],"cwd":".","toolchains":{} - }]}"#, - ) - .unwrap(); - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "unknown-provider-plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "zig:app"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], false, "{plan}"); - assert_eq!(plan["error"]["code"], "invalid_request"); - assert!(plan["error"]["message"] - .as_str() - .unwrap_or("") - .contains("command or runtime toolchain")); - - fs::remove_dir_all(root).unwrap(); - } - - /// Generic editor options must patch the common process shape. Writing - /// them into extensions.maven makes the UI appear to save successfully - /// while Go/Python/Node launch plans continue using the old arguments. - #[test] - fn process_options_update_common_arguments_and_environment() { - let root = temporary_root("run-config-process-options"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::create_dir_all(root.join("backend")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":2,"configurations":[{ - "id":"python:api","name":"API","provider":"python.script", - "command":"python3","args":["app.py"],"cwd":".","toolchains":{} - }]}"#, - ) - .unwrap(); - - let updated: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "update-process-options", - "command": "runConfig.updateOptions", - "payload": { - "root": root, - "scope": "local", - "configurationId": "python:api", - "workingDirectory": "backend", - "arguments": "app.py --port 9000", - "environment": {"APP_ENV": "test"} - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(updated["ok"], true, "{updated}"); - let document: Value = serde_json::from_str( - updated["data"]["document"].as_str().expect("document string"), - ) - .unwrap(); - let patch = &document["configurations"][0]; - assert_eq!(patch["args"], serde_json::json!(["app.py", "--port", "9000"])); - assert_eq!(patch["env"]["APP_ENV"], "test"); - assert!(patch["extensions"]["maven"].is_null()); - - fs::write( - root.join(".lithe/run/local.json"), - updated["data"]["document"].as_str().unwrap(), - ) - .unwrap(); - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "updated-process-plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "python:api"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!(plan["data"]["arguments"], serde_json::json!(["app.py", "--port", "9000"])); - assert_eq!(plan["data"]["env"]["APP_ENV"], "test"); - - fs::remove_dir_all(root).unwrap(); - } - - /// An absolute or relative path would let a project manifest point the IDE - /// at an executable of its choosing. Commands resolve on PATH only. - #[test] - fn process_configurations_reject_path_qualified_commands() { - let root = temporary_root("run-config-process-path"); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - r#"{"version":2,"configurations":[{ - "id":"evil","name":"evil","provider":"shell.command", - "command":"../../../usr/bin/curl","args":[],"cwd":".","toolchains":{} - }]}"#, - ) - .unwrap(); - - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "evil-plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "evil"} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(plan["ok"], false); - assert_eq!(plan["error"]["code"], "invalid_request"); - - fs::remove_dir_all(root).unwrap(); - } - - /// Builds a project that mixes six ecosystems in one tree, including the - /// traps that break naive detectors: a lockfile that is not npm's, a - /// `node_modules` full of decoy manifests, and a Go command in a - /// subdirectory with no manifest of its own. - fn multi_language_project(root: &Path) { - for directory in ["frontend/node_modules/decoy", "api", "cmd/gateway"] { - fs::create_dir_all(root.join(directory)).unwrap(); - } - fs::write( - root.join("frontend/package.json"), - r#"{"name":"web","scripts":{"dev":"vite","build":"vite build"}}"#, - ) - .unwrap(); - fs::write(root.join("frontend/pnpm-lock.yaml"), "lockfileVersion: 9\n").unwrap(); - fs::write( - root.join("frontend/node_modules/decoy/package.json"), - r#"{"scripts":{"dev":"should-never-appear"}}"#, - ) - .unwrap(); - fs::write( - root.join("api/pyproject.toml"), - "[tool.poetry]\nname = \"api\"\n[tool.poetry.scripts]\napi-server = \"api.main:run\"\n", - ) - .unwrap(); - fs::write( - root.join("api/main.py"), - "from fastapi import FastAPI\napp = FastAPI()\n", - ) - .unwrap(); - fs::write(root.join("go.mod"), "module example.com/gw\ngo 1.22\n").unwrap(); - fs::write( - root.join("cmd/gateway/main.go"), - "package main\nfunc main() {}\n", - ) - .unwrap(); - fs::write( - root.join("docker-compose.yml"), - "services:\n db:\n image: postgres\n cache:\n image: redis\n", - ) - .unwrap(); - fs::write( - root.join("Procfile"), - "worker: python worker/run.py\nweb: gunicorn api.main:app\n", - ) - .unwrap(); - fs::write( - root.join("Makefile"), - "run:\n\techo run\nclean:\n\techo clean\n", - ) - .unwrap(); - } - - fn generated_configurations(root: &Path) -> Vec { - let response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate", - "command": "runConfig.generate", - "payload": {"root": root} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - response["data"]["generated"]["configurations"] - .as_array() - .cloned() - .unwrap() - } - - /// The headline behaviour: one project, six ecosystems, every service found - /// without the user configuring anything. - #[test] - fn detectors_find_services_across_unrelated_ecosystems() { - let root = temporary_root("detect-multi"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - - let ids = generated_configurations(&root) - .iter() - .map(|item| item["id"].as_str().unwrap().to_string()) - .collect::>(); - - for expected in [ - "npm.script:frontend/dev", - "python.script:api/api-server", - "python.uvicorn:api/main", - "go.command:cmd/gateway/gateway", - "compose.service:db", - "compose.stack:compose up", - "procfile.process:web", - "make.target:run", - ] { - assert!( - ids.contains(&expected.to_string()), - "missing {expected} in {ids:?}" - ); - } - - fs::remove_dir_all(root).unwrap(); - } - - /// A dependency tree contains thousands of manifests. Descending into it - /// would both bury the real services and make project open unusably slow. - #[test] - fn detectors_never_descend_into_dependency_directories() { - let root = temporary_root("detect-prune"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - - let sources = generated_configurations(&root) - .iter() - .filter_map(|item| item["source"].as_str().map(str::to_string)) - .collect::>(); - - assert!( - !sources.iter().any(|source| source.contains("node_modules")), - "{sources:?}" - ); - - fs::remove_dir_all(root).unwrap(); - } - - /// Running `npm run dev` in a pnpm workspace fails at spawn time with an - /// error that points nowhere useful, so the lockfile decides the command. - #[test] - fn npm_detector_uses_the_package_manager_the_lockfile_names() { - let root = temporary_root("detect-pnpm"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - - let dev = generated_configurations(&root) - .into_iter() - .find(|item| item["id"] == "npm.script:frontend/dev") - .unwrap(); - - assert_eq!(dev["command"], "pnpm"); - assert_eq!(dev["args"], serde_json::json!(["run", "dev"])); - assert_eq!(dev["cwd"], "frontend"); - assert_eq!(dev["execution"], "service"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn npm_detector_inherits_the_workspace_package_manager() { - let root = temporary_root("detect-pnpm-workspace"); - fs::create_dir_all(root.join("apps/web")).unwrap(); - fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").unwrap(); - fs::write( - root.join("apps/web/package.json"), - r#"{"scripts":{"dev":"vite"}}"#, - ) - .unwrap(); - - let dev = generated_configurations(&root) - .into_iter() - .find(|item| item["id"] == "npm.script:apps/web/dev") - .unwrap(); - assert_eq!(dev["command"], "pnpm"); - assert_eq!(dev["execution"], "service"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn detectors_preserve_application_service_and_task_semantics() { - let root = temporary_root("detect-execution-semantics"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - let configurations = generated_configurations(&root); - let execution = |id: &str| { - configurations - .iter() - .find(|item| item["id"] == id) - .and_then(|item| item["execution"].as_str()) - }; - - assert_eq!(execution("npm.script:frontend/dev"), Some("service")); - assert_eq!(execution("npm.script:frontend/build"), Some("task")); - assert_eq!( - execution("python.script:api/api-server"), - Some("application") - ); - assert_eq!( - execution("go.command:cmd/gateway/gateway"), - Some("application") - ); - assert_eq!(execution("compose.stack:compose up"), Some("service")); - - fs::remove_dir_all(root).unwrap(); - } - - /// Detected entries are process-based, so they must survive the same launch - /// path as any other configuration without acquiring Java assumptions. - #[test] - fn detected_services_produce_runnable_launch_plans() { - let root = temporary_root("detect-launch"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - let generated = serde_json::json!({ - "version": 2, - "configurations": generated_configurations(&root) - }); - fs::create_dir_all(root.join(".lithe/run")).unwrap(); - fs::write( - root.join(".lithe/run/generated.json"), - serde_json::to_string(&generated).unwrap(), - ) - .unwrap(); - - let plan: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "plan", - "command": "runConfig.createLaunchPlan", - "payload": {"root": root, "configurationId": "npm.script:frontend/dev"} - }) - .to_string(), - )) - .unwrap(); - - assert_eq!(plan["ok"], true, "{plan}"); - assert_eq!(plan["data"]["executable"]["command"], "pnpm"); - assert!(plan["data"]["executable"]["toolchain"].is_null()); - assert_eq!(plan["data"]["workingDirectory"], "frontend"); - assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); - - fs::remove_dir_all(root).unwrap(); - } - - /// Ids are the join key for the team and local override layers. A detector - /// that renamed a Java configuration would silently detach every override - /// written against it, with no error anywhere. - #[test] - fn detectors_never_claim_an_id_the_java_scan_already_produced() { - let root = temporary_root("detect-no-clobber"); - fs::create_dir_all(&root).unwrap(); - multi_language_project(&root); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write( - root.join("src/Main.java"), - "class Main { public static void main(String[] args) {} }", - ) - .unwrap(); - - let response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate-with-java", - "command": "runConfig.generate", - "payload": {"root": root, "paths": ["src/Main.java"]} - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - let ids = response["data"]["generated"]["configurations"] - .as_array() - .unwrap() - .iter() - .map(|item| item["id"].as_str().unwrap().to_string()) - .collect::>(); - let mut unique = ids.clone(); - unique.sort(); - unique.dedup(); - - assert_eq!(ids.len(), unique.len(), "duplicate ids in {ids:?}"); - assert!(ids.contains(&"current-file".to_string())); - - fs::remove_dir_all(root).unwrap(); - } - - /// A Java project that also ships a frontend must gain the frontend's - /// services without any Java configuration changing id. Ids are the join key - /// for the team and local layers, so a shifted id detaches every override - /// silently -- there is no error to notice. - #[test] - fn detectors_extend_a_java_project_without_disturbing_its_configurations() { - let root = temporary_root("detect-java-mixed"); - let module = root.join("backend-api/src/main/java/com/demo"); - fs::create_dir_all(&module).unwrap(); - fs::create_dir_all(root.join("frontend-web")).unwrap(); - fs::write( - root.join("pom.xml"), - "backend-api", - ) - .unwrap(); - fs::write(root.join("backend-api/pom.xml"), "").unwrap(); - fs::write( - module.join("BackendApplication.java"), - "package com.demo;\n@SpringBootApplication\npublic class BackendApplication { public static void main(String[] a) {} }\n", - ) - .unwrap(); - fs::write( - root.join("frontend-web/package.json"), - r#"{"name":"web","scripts":{"dev":"vite"}}"#, - ) - .unwrap(); - - let response: Value = serde_json::from_str(&execute_json( - &serde_json::json!({ - "id": "generate", - "command": "runConfig.generate", - "payload": { - "root": root, - "paths": ["backend-api/src/main/java/com/demo/BackendApplication.java"] - } - }) - .to_string(), - )) - .unwrap(); - assert_eq!(response["ok"], true, "{response}"); - let configurations = response["data"]["generated"]["configurations"] - .as_array() - .unwrap(); - let ids = configurations - .iter() - .map(|item| item["id"].as_str().unwrap().to_string()) - .collect::>(); - - assert!( - ids.contains(&"spring:com.demo.BackendApplication".to_string()), - "{ids:?}" - ); - assert!( - ids.contains(&"npm.script:frontend-web/dev".to_string()), - "{ids:?}" - ); - // The Java entries stay toolchain-backed; only the detected ones are - // process-based. A regression here would send npm through Maven. - let java = configurations - .iter() - .find(|item| item["id"] == "spring:com.demo.BackendApplication") - .unwrap(); - assert!(java["command"].is_null()); - assert_eq!(java["toolchains"]["maven"], "project-maven"); - - fs::remove_dir_all(root).unwrap(); - } -} +mod tests; diff --git a/rust/lithe-core/src/lsp/interface/client.rs b/rust/lithe-core/src/lsp/interface/client.rs new file mode 100644 index 000000000..d67963e1c --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/client.rs @@ -0,0 +1,1323 @@ +use super::types::*; +use crate::protocol::{CoreError, ErrorCode}; +use serde_json::{json, Value}; + +pub fn client_initialize(request: ClientInitializeRequest) -> Result { + validate_uri(&request.root_uri)?; + let workspace_name = workspace_name_from_uri(&request.root_uri); + let mut state = request.state; + let id = allocate_request(&mut state, "initialize"); + let message = json_rpc_request( + &id, + "initialize", + json!({ + "processId": request.process_id, + "clientInfo": { + "name": "Lithe", + "version": env!("CARGO_PKG_VERSION") + }, + "rootUri": &request.root_uri, + "workspaceFolders": [{ + "uri": &request.root_uri, + "name": workspace_name + }], + "capabilities": { + "textDocument": { + "synchronization": {}, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": true, + "documentationFormat": ["markdown", "plaintext"], + "resolveSupport": { + "properties": [ + "detail", + "documentation", + "textEdit", + "additionalTextEdits" + ] + } + } + }, + "hover": { + "dynamicRegistration": true, + "contentFormat": ["markdown", "plaintext"] + }, + "definition": { "dynamicRegistration": true }, + "declaration": { "dynamicRegistration": true }, + "typeDefinition": { "dynamicRegistration": true }, + "implementation": { "dynamicRegistration": true }, + "references": { "dynamicRegistration": true }, + "rename": { "dynamicRegistration": true }, + "formatting": { "dynamicRegistration": true }, + "inlayHint": { "dynamicRegistration": true }, + "foldingRange": { + "dynamicRegistration": true, + "lineFoldingOnly": false, + "foldingRangeKind": { + "valueSet": ["comment", "imports", "region"] + }, + "foldingRange": { + "collapsedText": true + } + }, + "codeLens": { "dynamicRegistration": true }, + "codeAction": { + "dynamicRegistration": true, + "resolveSupport": { + "properties": ["edit", "command"] + }, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": ["quickfix", "refactor", "source"] + } + } + }, + "publishDiagnostics": { + "versionSupport": true, + "relatedInformation": true, + "tagSupport": { + "valueSet": [1, 2] + } + } + }, + "workspace": { + "applyEdit": true, + "configuration": true, + "workspaceFolders": true, + "workspaceEdit": { + "documentChanges": true + }, + "executeCommand": { "dynamicRegistration": true }, + "symbol": { + "dynamicRegistration": true, + "symbolKind": { + "valueSet": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, + 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 + ] + } + } + }, + "window": { + "workDoneProgress": true + } + }, + "initializationOptions": request.initialization_options + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_open_document( + request: ClientOpenDocumentRequest, +) -> Result { + validate_uri(&request.uri)?; + let mut state = request.state; + let document = LspClientDocument { + uri: request.uri.clone(), + language_id: request.language_id, + version: 1, + text: request.text, + }; + let message = json_rpc_notification( + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": document.uri, + "languageId": document.language_id, + "version": document.version, + "text": document.text + } + }), + )?; + state.diagnostics.remove(&request.uri); + state.diagnostic_versions.remove(&request.uri); + state.open_documents.insert(request.uri, document); + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_change_document( + request: ClientChangeDocumentRequest, +) -> Result { + validate_uri(&request.uri)?; + let mut state = request.state; + let Some(document) = state.open_documents.get_mut(&request.uri) else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot change a document that is not open in the LSP client.", + )); + }; + document.version += 1; + document.text = request.text; + let message = json_rpc_notification( + "textDocument/didChange", + json!({ + "textDocument": { + "uri": document.uri, + "version": document.version + }, + "contentChanges": [{ + "text": document.text + }] + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_close_document( + request: ClientCloseDocumentRequest, +) -> Result { + validate_uri(&request.uri)?; + let mut state = request.state; + let Some(document) = state.open_documents.remove(&request.uri) else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot close a document that is not open in the LSP client.", + )); + }; + state.diagnostics.remove(&request.uri); + state.diagnostic_versions.remove(&request.uri); + let message = json_rpc_notification( + "textDocument/didClose", + json!({ + "textDocument": { + "uri": document.uri + } + }), + )?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_shutdown(request: ClientShutdownRequest) -> Result { + let mut state = request.state; + if state.shutdown_requested { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP client shutdown has already been requested.", + )); + } + let id = allocate_request(&mut state, "shutdown"); + state.shutdown_requested = true; + let message = json_rpc_message_without_params(Some(&id), "shutdown")?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub(crate) fn client_feature_request_canonical( + request: ClientFeatureRequest, +) -> Result { + validate_uri(&request.uri)?; + validate_lsp_method(&request.method)?; + let params = feature_request_params(&request)?; + let uri = request.uri.clone(); + let method = request.method.clone(); + let mut state = request.state; + if !state.open_documents.contains_key(&uri) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot request LSP features for a document that is not open.", + )); + } + let id = allocate_request(&mut state, &method); + let message = json_rpc_request(&id, &method, params)?; + Ok(client_response(state, vec![message], Vec::new())) +} + +pub fn client_apply_server_message( + request: ClientApplyServerMessageRequest, +) -> Result { + let mut state = request.state; + let message: Value = serde_json::from_str(&request.message).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP server JSON message") + .with_details(error.to_string()) + })?; + let mut responses = Vec::new(); + let mut events = Vec::new(); + + if let Some(method) = message.get("method").and_then(Value::as_str) { + let request_id = message.get("id"); + match method { + "textDocument/publishDiagnostics" => { + if let Some(params) = message.get("params") { + let uri = params + .get("uri") + .and_then(Value::as_str) + .unwrap_or_default(); + validate_uri(uri)?; + let Some(document) = state.open_documents.get(uri) else { + return Ok(client_response(state, responses, events)); + }; + let version = params.get("version").and_then(Value::as_i64); + if version.is_some_and(|version| version != document.version) { + return Ok(client_response(state, responses, events)); + } + let diagnostics = parse_diagnostics(params.get("diagnostics")); + state + .diagnostics + .insert(uri.to_string(), diagnostics.clone()); + if let Some(version) = version { + state.diagnostic_versions.insert(uri.to_string(), version); + } else { + state.diagnostic_versions.remove(uri); + } + events.push(LspClientEvent { + kind: "diagnostics".to_string(), + request_id: None, + method: None, + uri: Some(uri.to_string()), + version, + diagnostics: Some(diagnostics), + result: None, + error: None, + }); + } + } + "client/registerCapability" => { + apply_dynamic_registration(&mut state, &message); + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); + } + } + "client/unregisterCapability" => { + apply_dynamic_unregistration(&mut state, &message); + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); + } + } + "workspace/configuration" => { + if let Some(id) = request_id { + let item_count = message + .get("params") + .and_then(|params| params.get("items")) + .and_then(Value::as_array) + .map_or(0, Vec::len); + responses.push(json_rpc_result( + id, + Value::Array(vec![Value::Null; item_count]), + )?); + } + } + "workspace/workspaceFolders" | "window/workDoneProgress/create" => { + if let Some(id) = request_id { + responses.push(json_rpc_result(id, Value::Null)?); + } + } + _ => { + if let Some(id) = request_id { + responses.push(json_rpc_error(id, -32601, "Method not found")?); + } else { + events.push(LspClientEvent { + kind: "notification".to_string(), + request_id: None, + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result: message.get("params").cloned(), + error: None, + }); + } + } + } + } else if let Some(id) = lsp_message_id(&message) { + let pending = state.pending_requests.remove(&id); + if pending.as_deref() == Some("initialize") { + if let Some(result) = message + .get("result") + .and_then(Value::as_object) + .filter(|result| result.get("capabilities").is_some_and(Value::is_object)) + { + state.server_capabilities = feature_names_from_capabilities( + result.get("capabilities").unwrap_or(&Value::Null), + ); + state.initialized = true; + responses.push(json_rpc_notification("initialized", json!({}))?); + } + } + if pending.as_deref() == Some("shutdown") { + state.initialized = false; + state.shutdown_requested = false; + state.server_capabilities.clear(); + state.open_documents.clear(); + state.diagnostics.clear(); + state.diagnostic_versions.clear(); + responses.push(json_rpc_message_without_params(None, "exit")?); + } + let result = lsp_feature_result_for_method(pending.as_deref(), message.get("result")); + events.push(LspClientEvent { + kind: if message.get("error").is_some() { + "error".to_string() + } else { + "response".to_string() + }, + request_id: Some(id), + method: pending, + uri: None, + version: None, + diagnostics: None, + result, + error: message.get("error").map(|value| value.to_string()), + }); + } + + Ok(client_response(state, responses, events)) +} + +fn client_response( + state: LspClientState, + messages: Vec, + events: Vec, +) -> LspClientResponse { + LspClientResponse { + state, + messages, + events, + } +} + +fn allocate_request(state: &mut LspClientState, method: &str) -> String { + let id = state.next_request_id.to_string(); + state.next_request_id += 1; + state + .pending_requests + .insert(id.clone(), method.to_string()); + id +} + +fn json_rpc_request(id: &str, method: &str, params: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + })) +} + +fn json_rpc_notification(method: &str, params: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "method": method, + "params": params + })) +} + +fn json_rpc_message_without_params(id: Option<&str>, method: &str) -> Result { + let mut message = json!({ + "jsonrpc": "2.0", + "method": method + }); + if let Some(id) = id { + message["id"] = Value::String(id.to_string()); + } + encode_json_rpc(message) +} + +fn json_rpc_result(id: &Value, result: Value) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + })) +} + +fn json_rpc_error(id: &Value, code: i64, message: &str) -> Result { + encode_json_rpc(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + })) +} + +fn encode_json_rpc(value: Value) -> Result { + serde_json::to_string(&value).map_err(|error| { + CoreError::new(ErrorCode::Unknown, "Could not encode LSP JSON-RPC message") + .with_details(error.to_string()) + }) +} + +fn validate_uri(value: &str) -> Result<(), CoreError> { + if value.trim().is_empty() || value.contains('\0') { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP request requires a valid URI.", + )) + } else { + Ok(()) + } +} + +fn workspace_name_from_uri(uri: &str) -> String { + let decoded = file_path_from_uri(uri); + decoded + .trim_end_matches(['/', '\\']) + .rsplit(['/', '\\']) + .find(|component| !component.is_empty()) + .unwrap_or("workspace") + .to_string() +} + +fn validate_lsp_method(method: &str) -> Result<(), CoreError> { + match method { + "textDocument/completion" + | "textDocument/hover" + | "textDocument/definition" + | "textDocument/declaration" + | "textDocument/typeDefinition" + | "textDocument/implementation" + | "textDocument/references" + | "textDocument/rename" + | "textDocument/formatting" + | "textDocument/inlayHint" + | "textDocument/foldingRange" + | "textDocument/codeLens" + | "textDocument/codeAction" + | "completionItem/resolve" + | "codeAction/resolve" + | "workspace/executeCommand" => Ok(()), + _ => Err(CoreError::new( + ErrorCode::NotSupported, + "Unsupported LSP client request method.", + ) + .with_details(method.to_string())), + } +} + +fn feature_request_params(request: &ClientFeatureRequest) -> Result { + let text_document = json!({ "uri": request.uri }); + match request.method.as_str() { + "textDocument/completion" + | "textDocument/hover" + | "textDocument/definition" + | "textDocument/declaration" + | "textDocument/typeDefinition" + | "textDocument/implementation" => Ok(json!({ + "textDocument": text_document, + "position": lsp_position_json(required_position(request)?) + })), + "textDocument/references" => Ok(json!({ + "textDocument": text_document, + "position": lsp_position_json(required_position(request)?), + "context": { "includeDeclaration": true } + })), + "textDocument/rename" => Ok(json!({ + "textDocument": text_document, + "position": lsp_position_json(required_position(request)?), + "newName": request.new_name.clone().unwrap_or_default() + })), + "textDocument/formatting" => Ok(json!({ + "textDocument": text_document, + "options": { + "tabSize": 4, + "insertSpaces": true, + "trimTrailingWhitespace": true, + "insertFinalNewline": true, + "trimFinalNewlines": true + } + })), + "textDocument/inlayHint" => Ok(json!({ + "textDocument": text_document, + "range": lsp_range_json(required_range(request)?) + })), + "textDocument/foldingRange" | "textDocument/codeLens" => Ok(json!({ + "textDocument": text_document + })), + "textDocument/codeAction" => Ok(json!({ + "textDocument": text_document, + "range": lsp_range_json(required_range(request)?), + "context": { + "diagnostics": request + .diagnostics + .iter() + .map(lsp_diagnostic_json) + .collect::>() + } + })), + "completionItem/resolve" => request.completion_item.clone().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a completion item.", + ) + }), + "codeAction/resolve" => request.code_action.clone().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a code action.", + ) + }), + "workspace/executeCommand" => request.command.clone().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a command.", + ) + }), + _ => Ok(json!({ "textDocument": text_document })), + } +} + +fn required_position(request: &ClientFeatureRequest) -> Result { + request.position.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a text document position.", + ) + }) +} + +fn required_range(request: &ClientFeatureRequest) -> Result { + request.range.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This LSP request requires a text document range.", + ) + }) +} + +fn lsp_position_json(position: LspPosition) -> Value { + json!({ + "line": position.line, + "character": position.utf16_column + }) +} + +fn lsp_range_json(range: LspRange) -> Value { + json!({ + "start": lsp_position_json(range.start), + "end": lsp_position_json(range.end) + }) +} + +fn lsp_diagnostic_json(diagnostic: &LspClientDiagnostic) -> Value { + json!({ + "range": lsp_range_response_json(diagnostic.range), + "severity": diagnostic.severity, + "message": diagnostic.message, + "source": diagnostic.source, + "code": diagnostic.code, + "tags": diagnostic.tags, + "relatedInformation": diagnostic.related_information.iter().map(|information| { + json!({ + "location": { + "uri": information.location.uri, + "range": lsp_range_response_json(information.location.range) + }, + "message": information.message + }) + }).collect::>() + }) +} + +fn lsp_range_response_json(range: LspRangeResponse) -> Value { + json!({ + "start": { + "line": range.start.line, + "character": range.start.utf16_column + }, + "end": { + "line": range.end.line, + "character": range.end.utf16_column + } + }) +} + +fn lsp_message_id(message: &Value) -> Option { + message.get("id").and_then(|id| match id { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }) +} + +fn parse_diagnostics(value: Option<&Value>) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + Some(LspClientDiagnostic { + range: parse_lsp_range(item.get("range")?)?, + severity: item.get("severity").and_then(Value::as_i64), + message: item + .get("message") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + source: item + .get("source") + .and_then(Value::as_str) + .map(str::to_string), + code: item.get("code").and_then(|code| match code { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + }), + tags: item + .get("tags") + .and_then(Value::as_array) + .map(|tags| tags.iter().filter_map(Value::as_i64).collect()) + .unwrap_or_default(), + related_information: parse_diagnostic_related_information( + item.get("relatedInformation"), + ), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_diagnostic_related_information( + value: Option<&Value>, +) -> Vec { + value + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let location = item.get("location")?; + Some(LspClientDiagnosticRelatedInformation { + location: LspClientDiagnosticLocation { + uri: location.get("uri")?.as_str()?.to_string(), + range: parse_lsp_range(location.get("range")?)?, + }, + message: item.get("message")?.as_str()?.to_string(), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_lsp_range(value: &Value) -> Option { + Some(LspRangeResponse { + start: parse_lsp_position(value.get("start")?)?, + end: parse_lsp_position(value.get("end")?)?, + }) +} + +fn parse_lsp_position(value: &Value) -> Option { + Some(LspPositionResponse { + line: value.get("line")?.as_i64()?, + utf16_column: value.get("character")?.as_i64()?, + }) +} + +fn lsp_feature_result_for_method(method: Option<&str>, result: Option<&Value>) -> Option { + let result = result?; + match method { + Some("textDocument/completion") => Some(json!({ + "items": parse_completion_items(result) + })), + Some("completionItem/resolve") => parse_completion_item(result).map(|item| { + json!({ + "item": item + }) + }), + Some("textDocument/hover") => Some(json!({ + "hover": parse_hover(result) + })), + Some("textDocument/rename") => Some(json!({ + "changes": parse_workspace_edit(result) + })), + Some("textDocument/formatting") => Some(json!({ + "edits": result + .as_array() + .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) + .unwrap_or_default() + })), + Some("textDocument/inlayHint") => Some(json!({ + "hints": parse_inlay_hints(result) + })), + Some("textDocument/foldingRange") => Some(json!({ + "ranges": parse_folding_ranges(result) + })), + Some("textDocument/codeLens") => Some(json!({ + "lenses": parse_code_lenses(result) + })), + Some("textDocument/codeAction") => Some(json!({ + "actions": parse_code_actions(result) + })), + Some("codeAction/resolve") => parse_code_action(result).map(|action| { + json!({ + "action": action + }) + }), + Some("workspace/executeCommand") => Some(json!({ "ok": true })), + Some("textDocument/definition") + | Some("textDocument/declaration") + | Some("textDocument/typeDefinition") + | Some("textDocument/implementation") + | Some("textDocument/references") => Some(json!({ + "locations": parse_locations(result) + })), + _ => Some(result.clone()), + } +} + +fn parse_completion_items(result: &Value) -> Vec { + let values = result + .as_array() + .or_else(|| result.get("items").and_then(Value::as_array)); + let Some(values) = values else { + return Vec::new(); + }; + values + .iter() + .filter_map(|item| parse_completion_item(item)) + .collect() +} + +fn parse_completion_item(item: &Value) -> Option { + let label = item.get("label").and_then(Value::as_str)?; + let insert_text = item + .get("insertText") + .and_then(Value::as_str) + .or_else(|| { + item.get("textEdit") + .and_then(|edit| edit.get("newText")) + .and_then(Value::as_str) + }) + .unwrap_or(label); + Some(json!({ + "label": label, + "insertText": insert_text, + "kind": item.get("kind").and_then(Value::as_i64), + "detail": item.get("detail").and_then(Value::as_str), + "documentation": completion_documentation(item.get("documentation")), + "sortText": item.get("sortText").and_then(Value::as_str), + "filterText": item.get("filterText").and_then(Value::as_str), + "textEdit": item.get("textEdit").and_then(parse_lsp_text_edit_value), + "additionalTextEdits": item + .get("additionalTextEdits") + .and_then(Value::as_array) + .map(|edits| edits.iter().filter_map(parse_lsp_text_edit_value).collect::>()) + .unwrap_or_default(), + "data": item.get("data").cloned().unwrap_or(Value::Null) + })) +} + +fn completion_documentation(value: Option<&Value>) -> Option { + match value? { + Value::String(text) => Some(text.clone()), + Value::Object(object) => object + .get("value") + .and_then(Value::as_str) + .map(ToString::to_string), + _ => None, + } +} + +fn parse_hover(result: &Value) -> Option { + if result.is_null() { + return None; + } + let contents = hover_contents(result.get("contents").unwrap_or(result))?; + let range = result.get("range").and_then(parse_lsp_range_value); + Some(json!({ + "contents": contents.0, + "isMarkdown": contents.1, + "range": range + })) +} + +fn hover_contents(value: &Value) -> Option<(String, bool)> { + match value { + Value::String(text) => Some((text.clone(), false)), + Value::Object(object) => { + if let Some(value) = object.get("value").and_then(Value::as_str) { + let is_markdown = object + .get("kind") + .and_then(Value::as_str) + .map(|kind| kind == "markdown") + .unwrap_or(false); + Some((value.to_string(), is_markdown)) + } else if let Some(value) = object.get("language").and_then(Value::as_str) { + Some((value.to_string(), true)) + } else { + None + } + } + Value::Array(values) => { + let parts: Vec<_> = values + .iter() + .filter_map(hover_contents) + .map(|(text, _)| text) + .collect(); + if parts.is_empty() { + None + } else { + Some((parts.join("\n\n"), true)) + } + } + _ => None, + } +} + +fn parse_inlay_hints(result: &Value) -> Vec { + result + .as_array() + .map(|values| { + values + .iter() + .filter_map(|hint| { + Some(LspInlayHintResponse { + position: parse_lsp_position(hint.get("position")?)?, + label: parse_inlay_hint_label(hint.get("label")?)?, + kind: hint.get("kind").and_then(Value::as_i64), + tooltip: completion_documentation(hint.get("tooltip")), + padding_left: hint + .get("paddingLeft") + .and_then(Value::as_bool) + .unwrap_or(false), + padding_right: hint + .get("paddingRight") + .and_then(Value::as_bool) + .unwrap_or(false), + text_edits: hint + .get("textEdits") + .and_then(Value::as_array) + .map(|edits| { + edits.iter().filter_map(parse_lsp_text_edit_value).collect() + }) + .unwrap_or_default(), + data: hint.get("data").cloned(), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_inlay_hint_label(value: &Value) -> Option { + match value { + Value::String(label) => Some(label.clone()), + Value::Array(parts) => { + let label = parts + .iter() + .filter_map(|part| part.get("value").and_then(Value::as_str)) + .collect::(); + (!label.is_empty()).then_some(label) + } + _ => None, + } +} + +fn parse_folding_ranges(result: &Value) -> Vec { + result + .as_array() + .map(|values| { + values + .iter() + .filter_map(|range| { + Some(LspFoldingRangeResponse { + start_line: range.get("startLine")?.as_i64()?, + start_utf16_column: range.get("startCharacter").and_then(Value::as_i64), + end_line: range.get("endLine")?.as_i64()?, + end_utf16_column: range.get("endCharacter").and_then(Value::as_i64), + kind: range + .get("kind") + .and_then(Value::as_str) + .map(str::to_string), + collapsed_text: range + .get("collapsedText") + .and_then(Value::as_str) + .map(str::to_string), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_code_lenses(result: &Value) -> Vec { + result + .as_array() + .map(|values| { + values + .iter() + .filter_map(|lens| { + Some(LspCodeLensResponse { + range: parse_lsp_range(lens.get("range")?)?, + command: lens.get("command").and_then(parse_lsp_command), + data: lens.get("data").cloned(), + }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn parse_locations(result: &Value) -> Vec { + let values: Vec<&Value> = if let Some(array) = result.as_array() { + array.iter().collect() + } else if result.is_object() { + vec![result] + } else { + Vec::new() + }; + values + .into_iter() + .filter_map(|location| { + let uri = location + .get("uri") + .or_else(|| location.get("targetUri")) + .and_then(Value::as_str)?; + let range = location + .get("range") + .or_else(|| location.get("targetSelectionRange")) + .or_else(|| location.get("targetRange")) + .and_then(parse_lsp_range_value)?; + let file_path = file_path_for_uri(uri); + let is_read_only = file_path.is_none(); + Some(json!({ + "uri": uri, + "filePath": file_path, + "range": range, + "isReadOnly": is_read_only, + "displayPath": Value::Null + })) + }) + .collect() +} + +fn parse_code_actions(result: &Value) -> Vec { + let Some(values) = result.as_array() else { + return Vec::new(); + }; + values.iter().filter_map(parse_code_action).collect() +} + +fn parse_code_action(action: &Value) -> Option { + let title = action.get("title").and_then(Value::as_str)?; + let command = if action.get("command").and_then(Value::as_str).is_some() { + parse_lsp_command(action) + } else { + action.get("command").and_then(parse_lsp_command) + }; + Some(json!({ + "title": title, + "kind": action.get("kind").and_then(Value::as_str), + "isPreferred": action + .get("isPreferred") + .and_then(Value::as_bool) + .unwrap_or(false), + "edit": action.get("edit").map(|edit| json!({ + "changes": parse_workspace_edit(edit) + })), + "command": command, + "data": action.get("data").cloned().unwrap_or(Value::Null) + })) +} + +fn parse_lsp_command(value: &Value) -> Option { + Some(json!({ + "title": value.get("title").and_then(Value::as_str).unwrap_or_default(), + "command": value.get("command").and_then(Value::as_str)?, + "arguments": value + .get("arguments") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + })) +} + +fn parse_workspace_edit(result: &Value) -> serde_json::Map { + let mut changes = serde_json::Map::new(); + if let Some(entries) = result.get("changes").and_then(Value::as_object) { + for (uri, edits) in entries { + let parsed = edits + .as_array() + .map(|edits| { + edits + .iter() + .filter_map(parse_lsp_text_edit_value) + .collect::>() + }) + .unwrap_or_default(); + changes.insert(file_path_from_uri(uri), json!(parsed)); + } + } + if let Some(document_changes) = result.get("documentChanges").and_then(Value::as_array) { + for change in document_changes { + let Some(uri) = change + .get("textDocument") + .and_then(|document| document.get("uri")) + .and_then(Value::as_str) + else { + continue; + }; + let parsed = change + .get("edits") + .and_then(Value::as_array) + .map(|edits| { + edits + .iter() + .filter_map(parse_lsp_text_edit_value) + .collect::>() + }) + .unwrap_or_default(); + changes.insert(file_path_from_uri(uri), json!(parsed)); + } + } + changes +} + +fn parse_lsp_text_edit_value(value: &Value) -> Option { + Some(json!({ + "range": parse_lsp_range_value(value.get("range")?)?, + "newText": value.get("newText").and_then(Value::as_str).unwrap_or_default() + })) +} + +fn parse_lsp_range_value(value: &Value) -> Option { + Some(json!({ + "start": parse_lsp_position_value(value.get("start")?)?, + "end": parse_lsp_position_value(value.get("end")?)? + })) +} + +fn parse_lsp_position_value(value: &Value) -> Option { + Some(json!({ + "line": value.get("line").and_then(Value::as_i64).unwrap_or(0), + "utf16Column": value + .get("character") + .or_else(|| value.get("utf16Column")) + .and_then(Value::as_i64) + .unwrap_or(0) + })) +} + +pub(crate) fn file_path_from_uri(uri: &str) -> String { + file_path_for_uri(uri).unwrap_or_else(|| percent_decode(uri)) +} + +fn file_path_for_uri(uri: &str) -> Option { + let (scheme, remainder) = uri.split_once(':')?; + if !scheme.eq_ignore_ascii_case("file") { + return None; + } + let path = if remainder.starts_with("///") { + &remainder[2..] + } else { + remainder + }; + Some(percent_decode(path)) +} + +fn percent_decode(value: &str) -> String { + let mut decoded = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { + decoded.push((high << 4) | low); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8(decoded).unwrap_or_else(|_| value.to_string()) +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn feature_names_from_capabilities(capabilities: &Value) -> Vec { + let mut values = Vec::new(); + add_capability( + &mut values, + capabilities, + "definitionProvider", + "definition", + ); + add_capability( + &mut values, + capabilities, + "declarationProvider", + "declaration", + ); + add_capability( + &mut values, + capabilities, + "typeDefinitionProvider", + "typeDefinition", + ); + add_capability( + &mut values, + capabilities, + "implementationProvider", + "implementation", + ); + add_capability( + &mut values, + capabilities, + "referencesProvider", + "references", + ); + add_capability(&mut values, capabilities, "hoverProvider", "hover"); + add_capability( + &mut values, + capabilities, + "completionProvider", + "completion", + ); + add_capability(&mut values, capabilities, "renameProvider", "rename"); + add_capability( + &mut values, + capabilities, + "documentFormattingProvider", + "formatting", + ); + add_capability(&mut values, capabilities, "inlayHintProvider", "inlayHints"); + add_capability( + &mut values, + capabilities, + "foldingRangeProvider", + "foldingRanges", + ); + add_capability(&mut values, capabilities, "codeLensProvider", "codeLens"); + add_capability( + &mut values, + capabilities, + "workspaceSymbolProvider", + "workspaceSymbols", + ); + add_capability( + &mut values, + capabilities, + "codeActionProvider", + "codeActions", + ); + add_capability( + &mut values, + capabilities, + "executeCommandProvider", + "executeCommand", + ); + if capabilities + .get("completionProvider") + .and_then(|value| value.get("resolveProvider")) + .and_then(Value::as_bool) + == Some(true) + { + insert_unique(&mut values, "completionResolve"); + } + if capabilities + .get("codeActionProvider") + .and_then(|value| value.get("resolveProvider")) + .and_then(Value::as_bool) + == Some(true) + { + insert_unique(&mut values, "codeActionResolve"); + } + values +} + +fn add_capability(values: &mut Vec, capabilities: &Value, key: &str, feature: &str) { + match capabilities.get(key) { + Some(Value::Bool(true)) => insert_unique(values, feature), + Some(Value::Object(_)) => insert_unique(values, feature), + _ => {} + } +} + +fn apply_dynamic_registration(state: &mut LspClientState, message: &Value) { + let Some(registrations) = message + .get("params") + .and_then(|params| params.get("registrations")) + .and_then(Value::as_array) + else { + return; + }; + for registration in registrations { + if let Some(feature) = registration + .get("method") + .and_then(Value::as_str) + .and_then(feature_name_for_method) + { + insert_unique(&mut state.server_capabilities, feature); + } + if registration + .get("registerOptions") + .and_then(|options| options.get("resolveProvider")) + .and_then(Value::as_bool) + == Some(true) + { + if registration.get("method").and_then(Value::as_str) == Some("textDocument/completion") + { + insert_unique(&mut state.server_capabilities, "completionResolve"); + } + if registration.get("method").and_then(Value::as_str) == Some("textDocument/codeAction") + { + insert_unique(&mut state.server_capabilities, "codeActionResolve"); + } + } + } +} + +fn apply_dynamic_unregistration(state: &mut LspClientState, message: &Value) { + let Some(unregistrations) = message + .get("params") + .and_then(|params| { + params + .get("unregistrations") + .or_else(|| params.get("unregisterations")) + }) + .and_then(Value::as_array) + else { + return; + }; + for unregistration in unregistrations { + if let Some(feature) = unregistration + .get("method") + .and_then(Value::as_str) + .and_then(feature_name_for_method) + { + state + .server_capabilities + .retain(|existing| existing != feature); + } + } +} + +fn feature_name_for_method(method: &str) -> Option<&'static str> { + match method { + "textDocument/definition" => Some("definition"), + "textDocument/declaration" => Some("declaration"), + "textDocument/typeDefinition" => Some("typeDefinition"), + "textDocument/implementation" => Some("implementation"), + "textDocument/references" => Some("references"), + "textDocument/hover" => Some("hover"), + "textDocument/completion" => Some("completion"), + "textDocument/rename" => Some("rename"), + "textDocument/formatting" => Some("formatting"), + "textDocument/inlayHint" => Some("inlayHints"), + "textDocument/foldingRange" => Some("foldingRanges"), + "textDocument/codeLens" => Some("codeLens"), + "textDocument/codeAction" => Some("codeActions"), + "workspace/symbol" => Some("workspaceSymbols"), + "workspace/executeCommand" => Some("executeCommand"), + _ => None, + } +} + +fn insert_unique(values: &mut Vec, value: &str) { + if !values.iter().any(|existing| existing == value) { + values.push(value.to_string()); + } +} diff --git a/rust/lithe-core/src/lsp/interface/engine.rs b/rust/lithe-core/src/lsp/interface/engine.rs new file mode 100644 index 000000000..6b3374776 --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/engine.rs @@ -0,0 +1,1997 @@ +use super::{ + client_apply_server_message, client_change_document, client_close_document, + client_feature_request_canonical, client_initialize, client_open_document, client_shutdown, + frame_message, parse_server_messages, ClientApplyServerMessageRequest, + ClientChangeDocumentRequest, ClientCloseDocumentRequest, ClientFeatureRequest, + ClientInitializeRequest, ClientOpenDocumentRequest, ClientShutdownRequest, FrameMessageRequest, + LspClientDiagnostic, LspClientDocument, LspClientState, LspPosition, LspRange, + ParseServerMessagesRequest, +}; +use crate::lsp::languages::jdt::{ + adapt_start, initialized_notification, virtual_source_resolve_params, workspace_configuration, + JdtStartContext, ProviderLocation, WorkspaceConfigurationItem, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, VecDeque}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; + +const DEFAULT_INITIALIZE_TIMEOUT_MS: u64 = 10_000; +const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; +const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 2_000; +const MONITOR_INTERVAL_MS: u64 = 10; + +static ENGINE: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspLifecycleState { + Created, + ProcessStarting, + Initializing, + Ready, + Stopping, + Stopped, + Failed, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StartServerRequest { + pub provider_id: String, + pub executable_path: String, + #[serde(default)] + pub arguments: Vec, + #[serde(default)] + pub environment: BTreeMap, + pub root_uri: String, + pub working_directory: String, + #[serde(default)] + pub initialization_options: Option, + #[serde(default)] + pub runtime_executable_path: Option, + #[serde(default)] + pub cache_directory: Option, + #[serde(default = "default_initialize_timeout")] + pub initialize_timeout_milliseconds: u64, + #[serde(default = "default_request_timeout")] + pub request_timeout_milliseconds: u64, + #[serde(default = "default_shutdown_timeout")] + pub shutdown_timeout_milliseconds: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StartServerResponse { + pub session_id: String, + pub state: LspLifecycleState, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SessionRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SyncDocumentRequest { + pub session_id: String, + pub uri: String, + pub language_id: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CloseDocumentRequest { + pub session_id: String, + pub uri: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspSemanticOperation { + Completion, + Hover, + Definition, + Declaration, + TypeDefinition, + References, + Implementation, + Rename, + Formatting, + CodeActions, + ResolveCompletion, + ResolveCodeAction, + ExecuteCommand, + InlayHints, + FoldingRanges, + CodeLens, + VirtualDocument, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SemanticRequest { + pub session_id: String, + #[serde(default)] + pub operation_id: Option, + pub operation: LspSemanticOperation, + #[serde(default)] + pub uri: Option, + #[serde(default)] + pub virtual_uri: Option, + #[serde(default)] + pub position: Option, + #[serde(default)] + pub new_name: Option, + #[serde(default)] + pub range: Option, + #[serde(default)] + pub diagnostics: Vec, + #[serde(default)] + pub completion_item: Option, + #[serde(default)] + pub code_action: Option, + #[serde(default)] + pub command: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OperationResponse { + pub operation_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CancelOperationRequest { + pub session_id: String, + pub operation_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PollEventsResponse { + pub events: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspRuntimeEvent { + #[serde(rename = "type")] + pub kind: String, + pub sequence: u64, + pub provider_id: String, + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub operation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostics: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub capabilities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub server_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspServerInfo { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspRuntimeError { + pub code: String, + pub provider_id: String, + pub session_id: String, + pub stage: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub document_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub underlying_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub process_exit_code: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EngineSnapshot { + pub session_id: String, + pub provider_id: String, + pub state: LspLifecycleState, + pub initialized: bool, + pub open_documents: BTreeMap, + pub pending_operation_ids: Vec, + pub diagnostic_versions: BTreeMap, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum PendingKind { + Initialize, + Feature, + Shutdown, +} + +#[derive(Debug, Clone)] +struct PendingRequest { + kind: PendingKind, + operation_id: Option, + method: String, + document_uri: Option, + created_at: Instant, + deadline: Instant, +} + +struct SessionState { + lifecycle: LspLifecycleState, + client: LspClientState, + pending: BTreeMap, + request_by_operation: BTreeMap, + events: VecDeque, + next_sequence: u64, + initialize_deadline: Option, + shutdown_deadline: Option, + request_timeout: Duration, + shutdown_timeout: Duration, + terminal_event_emitted: bool, +} + +struct RuntimeSession { + id: String, + provider_id: String, + root_uri: String, + state: Mutex, + stdin: Mutex>, + child: Mutex, + active: AtomicBool, +} + +struct LspEngine { + next_session_id: AtomicU64, + next_operation_id: AtomicU64, + sessions: Mutex>>, +} + +fn default_initialize_timeout() -> u64 { + DEFAULT_INITIALIZE_TIMEOUT_MS +} + +fn default_request_timeout() -> u64 { + DEFAULT_REQUEST_TIMEOUT_MS +} + +fn default_shutdown_timeout() -> u64 { + DEFAULT_SHUTDOWN_TIMEOUT_MS +} + +pub fn start_server(request: StartServerRequest) -> Result { + engine().start_server(request) +} + +pub fn stop_server(request: SessionRequest) -> Result<(), CoreError> { + engine().session(&request.session_id)?.stop() +} + +pub fn sync_document(request: SyncDocumentRequest) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .sync_document(request) +} + +pub fn close_document(request: CloseDocumentRequest) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .close_document(&request.uri) +} + +pub fn semantic_request(request: SemanticRequest) -> Result { + let operation_id = request + .operation_id + .clone() + .unwrap_or_else(|| engine().next_operation_id()); + engine() + .session(&request.session_id)? + .request(request, operation_id.clone())?; + Ok(OperationResponse { operation_id }) +} + +pub fn cancel_operation(request: CancelOperationRequest) -> Result<(), CoreError> { + engine() + .session(&request.session_id)? + .cancel_operation(&request.operation_id) +} + +pub fn poll_events(request: SessionRequest) -> Result { + Ok(PollEventsResponse { + events: engine().session(&request.session_id)?.poll_events()?, + }) +} + +pub fn clear_diagnostics(request: SessionRequest) -> Result<(), CoreError> { + engine().session(&request.session_id)?.clear_diagnostics() +} + +pub fn snapshot(request: SessionRequest) -> Result { + engine().session(&request.session_id)?.snapshot() +} + +pub fn destroy_server(request: SessionRequest) -> Result<(), CoreError> { + engine().destroy(&request.session_id) +} + +fn engine() -> &'static LspEngine { + ENGINE.get_or_init(LspEngine::new) +} + +impl LspEngine { + fn new() -> Self { + Self { + next_session_id: AtomicU64::new(1), + next_operation_id: AtomicU64::new(1), + sessions: Mutex::new(BTreeMap::new()), + } + } + + fn next_operation_id(&self) -> String { + format!( + "lsp-operation-{}", + self.next_operation_id.fetch_add(1, Ordering::Relaxed) + ) + } + + fn start_server(&self, request: StartServerRequest) -> Result { + validate_start_request(&request)?; + let session_id = format!( + "lsp-session-{}", + self.next_session_id.fetch_add(1, Ordering::Relaxed) + ); + let workspace_root = PathBuf::from(&request.working_directory); + let data_root = request + .cache_directory + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("lithe-lsp")); + let selected_java_executable = request + .runtime_executable_path + .as_deref() + .map(PathBuf::from) + .or_else(|| java_executable_from_environment(&request.environment)); + let adaptation = adapt_start(&JdtStartContext { + provider_id: request.provider_id.clone(), + workspace_root: workspace_root.clone(), + data_root, + selected_java_executable, + arguments: request.arguments.clone(), + }); + if let Some(directory) = &adaptation.data_directory { + std::fs::create_dir_all(directory).map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not create the language-server state directory.", + ) + .with_details(error.to_string()) + })?; + } + + let mut command = Command::new(&request.executable_path); + command + .args(&adaptation.arguments) + .current_dir(&workspace_root) + .envs(&request.environment) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().map_err(|error| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Could not start the language-server process.", + ) + .with_details(error.to_string()) + })?; + let stdin = child.stdin.take().ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Language-server stdin was unavailable.", + ) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Language-server stdout was unavailable.", + ) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + CoreError::new( + ErrorCode::ProcessStartFailed, + "Language-server stderr was unavailable.", + ) + })?; + + let initialize_timeout = Duration::from_millis(request.initialize_timeout_milliseconds); + let request_timeout = Duration::from_millis(request.request_timeout_milliseconds); + let shutdown_timeout = Duration::from_millis(request.shutdown_timeout_milliseconds); + let initialize = client_initialize(ClientInitializeRequest { + state: LspClientState::default(), + root_uri: request.root_uri.clone(), + process_id: Some(std::process::id() as i64), + initialization_options: request.initialization_options, + })?; + let request_id = (initialize.state.next_request_id - 1).to_string(); + let now = Instant::now(); + let session = Arc::new(RuntimeSession { + id: session_id.clone(), + provider_id: request.provider_id, + root_uri: request.root_uri, + state: Mutex::new(SessionState { + lifecycle: LspLifecycleState::Created, + client: initialize.state, + pending: BTreeMap::from([( + request_id, + PendingRequest { + kind: PendingKind::Initialize, + operation_id: None, + method: "initialize".to_string(), + document_uri: None, + created_at: now, + deadline: now + initialize_timeout, + }, + )]), + request_by_operation: BTreeMap::new(), + events: VecDeque::new(), + next_sequence: 1, + initialize_deadline: Some(now + initialize_timeout), + shutdown_deadline: None, + request_timeout, + shutdown_timeout, + terminal_event_emitted: false, + }), + stdin: Mutex::new(Some(stdin)), + child: Mutex::new(child), + active: AtomicBool::new(true), + }); + session.transition(LspLifecycleState::Created, None)?; + session.transition(LspLifecycleState::ProcessStarting, None)?; + session.transition(LspLifecycleState::Initializing, None)?; + + self.lock_sessions()? + .insert(session_id.clone(), session.clone()); + session.spawn_readers(stdout, stderr); + session.spawn_monitor(); + if let Err(error) = session.send_messages(initialize.messages) { + session.fail( + "transportFailed", + "initialize", + "Could not write the initialize request.", + Some(core_error_detail(&error)), + None, + ); + session.kill_process(); + return Err(error); + } + + Ok(StartServerResponse { + session_id, + state: LspLifecycleState::Initializing, + }) + } + + fn session(&self, session_id: &str) -> Result, CoreError> { + self.lock_sessions()? + .get(session_id) + .cloned() + .ok_or_else(|| unknown_session(session_id)) + } + + fn destroy(&self, session_id: &str) -> Result<(), CoreError> { + let session = self.session(session_id)?; + let lifecycle = session.lock_state()?.lifecycle; + if !matches!( + lifecycle, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "A running language-server session cannot be destroyed.", + )); + } + self.lock_sessions()?.remove(session_id); + Ok(()) + } + + fn lock_sessions( + &self, + ) -> Result>>, CoreError> { + self.sessions.lock().map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server session registry lock was poisoned.", + ) + }) + } +} + +impl RuntimeSession { + fn sync_document(&self, request: SyncDocumentRequest) -> Result<(), CoreError> { + let uri = request.uri; + let mut messages = Vec::new(); + { + let mut state = self.lock_state()?; + ensure_not_terminal(state.lifecycle)?; + if state.lifecycle == LspLifecycleState::Ready { + let response = if state + .client + .open_documents + .get(&uri) + .is_some_and(|document| document.version > 0) + { + client_change_document(ClientChangeDocumentRequest { + state: state.client.clone(), + uri: uri.clone(), + text: request.text, + })? + } else { + client_open_document(ClientOpenDocumentRequest { + state: state.client.clone(), + uri: uri.clone(), + language_id: request.language_id, + text: request.text, + })? + }; + state.client = response.state; + messages = response.messages; + } else { + // Version zero means the semantic document exists in the Rust + // store but has not yet been opened on the server. The latest + // sync wins until initialize completes. + state.client.diagnostics.remove(&uri); + state.client.diagnostic_versions.remove(&uri); + state.client.open_documents.insert( + uri.clone(), + LspClientDocument { + uri, + language_id: request.language_id, + version: 0, + text: request.text, + }, + ); + } + } + self.send_messages_or_fail(messages, "documentSync") + } + + fn close_document(&self, uri: &str) -> Result<(), CoreError> { + let mut messages = Vec::new(); + let cleared; + { + let mut state = self.lock_state()?; + ensure_not_terminal(state.lifecycle)?; + let Some(document) = state.client.open_documents.get(uri).cloned() else { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Cannot close a document that is not owned by the language-server session.", + )); + }; + cleared = state.client.diagnostics.contains_key(uri); + if document.version == 0 { + state.client.open_documents.remove(uri); + state.client.diagnostics.remove(uri); + state.client.diagnostic_versions.remove(uri); + } else { + let response = client_close_document(ClientCloseDocumentRequest { + state: state.client.clone(), + uri: uri.to_string(), + })?; + state.client = response.state; + messages = response.messages; + } + if cleared { + push_diagnostics_event(self, &mut state, uri, None, Vec::new()); + } + } + self.send_messages_or_fail(messages, "documentClose") + } + + fn request(&self, request: SemanticRequest, operation_id: String) -> Result<(), CoreError> { + let (messages, request_id) = { + let mut state = self.lock_state()?; + if state.lifecycle != LspLifecycleState::Ready || !state.client.initialized { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server is not ready.", + )); + } + if state.request_by_operation.contains_key(&operation_id) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The language-server operation ID is already pending.", + )); + } + let uri = request.uri.clone(); + let method = semantic_method(request.operation); + let required_capability = semantic_capability(request.operation); + if let Some(capability) = required_capability { + if !state + .client + .server_capabilities + .iter() + .any(|candidate| candidate == capability) + { + return Err(CoreError::new( + ErrorCode::NotSupported, + "The language server did not advertise this capability.", + ) + .with_details(capability)); + } + } + + let response = if request.operation == LspSemanticOperation::VirtualDocument { + let virtual_uri = request.virtual_uri.as_deref().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Virtual-document resolution requires virtualUri.", + ) + })?; + let params = virtual_source_resolve_params(&self.provider_id, virtual_uri) + .ok_or_else(|| { + CoreError::new( + ErrorCode::NotSupported, + "The provider cannot resolve this virtual document URI.", + ) + })?; + allocate_raw_request( + state.client.clone(), + method, + json!({ + "command": params.command, + "arguments": params.arguments + }), + )? + } else { + let uri = uri.clone().ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "This language-server operation requires a document URI.", + ) + })?; + if !state + .client + .open_documents + .get(&uri) + .is_some_and(|document| document.version > 0) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "The document is not open in the language server.", + )); + } + client_feature_request_canonical(ClientFeatureRequest { + state: state.client.clone(), + uri, + method: method.to_string(), + position: request.position, + new_name: request.new_name, + range: request.range, + diagnostics: request.diagnostics, + completion_item: request.completion_item, + code_action: request.code_action, + command: request.command, + })? + }; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + let pending = PendingRequest { + kind: PendingKind::Feature, + operation_id: Some(operation_id.clone()), + method: method.to_string(), + document_uri: uri, + created_at: now, + deadline: now + state.request_timeout, + }; + state.client = response.state; + state.pending.insert(request_id.clone(), pending); + state + .request_by_operation + .insert(operation_id, request_id.clone()); + (response.messages, request_id) + }; + if let Err(error) = self.send_messages(messages) { + self.complete_request_with_error( + &request_id, + "transportFailed", + "request", + "Could not write the language-server request.", + Some(core_error_detail(&error)), + None, + ); + self.fail( + "transportFailed", + "request", + "Language-server stdin failed.", + Some(core_error_detail(&error)), + None, + ); + self.kill_process(); + return Err(error); + } + Ok(()) + } + + fn cancel_operation(&self, operation_id: &str) -> Result<(), CoreError> { + let request_id = { + let mut state = self.lock_state()?; + let request_id = state + .request_by_operation + .remove(operation_id) + .ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Unknown pending language-server operation.", + ) + })?; + let pending = state.pending.remove(&request_id).ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Unknown pending language-server request.", + ) + })?; + state.client.pending_requests.remove(&request_id); + let error = runtime_error( + self, + "requestCancelled", + "request", + Some(&pending.method), + pending.document_uri.as_deref(), + Some(&request_id), + "Language-server request was cancelled.", + None, + None, + ); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + None, + Some(error), + ); + request_id + }; + let cancellation = json!({ + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": { "id": request_id } + }) + .to_string(); + self.send_messages_or_fail(vec![cancellation], "requestCancel") + } + + fn stop(&self) -> Result<(), CoreError> { + let (messages, force_kill) = { + let mut state = self.lock_state()?; + if matches!( + state.lifecycle, + LspLifecycleState::Stopped | LspLifecycleState::Failed + ) { + return Ok(()); + } + if state.lifecycle == LspLifecycleState::Stopping { + return Ok(()); + } + fail_feature_requests( + self, + &mut state, + "requestCancelled", + "stop", + "Language-server session is stopping.", + None, + ); + clear_runtime_diagnostics(self, &mut state); + transition_locked(self, &mut state, LspLifecycleState::Stopping, None); + state.initialize_deadline = None; + state.shutdown_deadline = Some(Instant::now() + state.shutdown_timeout); + if state.client.initialized { + let response = client_shutdown(ClientShutdownRequest { + state: state.client.clone(), + })?; + let request_id = (response.state.next_request_id - 1).to_string(); + let now = Instant::now(); + let shutdown_timeout = state.shutdown_timeout; + state.pending.insert( + request_id, + PendingRequest { + kind: PendingKind::Shutdown, + operation_id: None, + method: "shutdown".to_string(), + document_uri: None, + created_at: now, + deadline: now + shutdown_timeout, + }, + ); + state.client = response.state; + (response.messages, false) + } else { + state.client.pending_requests.clear(); + state.pending.clear(); + (Vec::new(), true) + } + }; + self.send_messages_or_fail(messages, "shutdown")?; + if force_kill { + self.kill_process(); + } + Ok(()) + } + + fn clear_diagnostics(&self) -> Result<(), CoreError> { + let mut state = self.lock_state()?; + clear_runtime_diagnostics(self, &mut state); + Ok(()) + } + + fn poll_events(&self) -> Result, CoreError> { + let mut state = self.lock_state()?; + Ok(state.events.drain(..).collect()) + } + + fn snapshot(&self) -> Result { + let state = self.lock_state()?; + Ok(EngineSnapshot { + session_id: self.id.clone(), + provider_id: self.provider_id.clone(), + state: state.lifecycle, + initialized: state.client.initialized, + open_documents: state.client.open_documents.clone(), + pending_operation_ids: state.request_by_operation.keys().cloned().collect(), + diagnostic_versions: state.client.diagnostic_versions.clone(), + }) + } + + fn spawn_readers( + self: &Arc, + mut stdout: std::process::ChildStdout, + mut stderr: std::process::ChildStderr, + ) { + let output_session = self.clone(); + thread::spawn(move || { + let mut frame_buffer = Vec::new(); + let mut chunk = vec![0_u8; 8 * 1024]; + while output_session.active.load(Ordering::Acquire) { + match stdout.read(&mut chunk) { + Ok(0) => break, + Ok(count) => { + match parse_server_messages(ParseServerMessagesRequest { + buffer: std::mem::take(&mut frame_buffer), + chunk: chunk[..count].to_vec(), + }) { + Ok(parsed) => { + frame_buffer = parsed.buffer; + for message in parsed.messages { + if let Err(error) = + output_session.handle_server_message(message) + { + output_session.fail( + "invalidServerMessage", + "transport", + "Language server sent an invalid message.", + Some(core_error_detail(&error)), + None, + ); + output_session.kill_process(); + return; + } + } + } + Err(error) => { + output_session.fail( + "transportFailed", + "transport", + "Language-server stdout framing failed.", + Some(core_error_detail(&error)), + None, + ); + output_session.kill_process(); + return; + } + } + } + Err(error) => { + output_session.fail( + "transportFailed", + "transport", + "Could not read language-server stdout.", + Some(error.to_string()), + None, + ); + output_session.kill_process(); + return; + } + } + } + }); + + let error_session = self.clone(); + thread::spawn(move || { + let mut chunk = vec![0_u8; 4 * 1024]; + while error_session.active.load(Ordering::Acquire) { + match stderr.read(&mut chunk) { + Ok(0) => break, + Ok(count) => error_session.log( + "warning", + "Language-server stderr", + Some(String::from_utf8_lossy(&chunk[..count]).trim().to_string()), + ), + Err(error) => { + error_session.log( + "warning", + "Could not read language-server stderr", + Some(error.to_string()), + ); + break; + } + } + } + }); + } + + fn spawn_monitor(self: &Arc) { + let session = self.clone(); + thread::spawn(move || { + while session.active.load(Ordering::Acquire) { + let exit = session + .child + .lock() + .ok() + .and_then(|mut child| child.try_wait().ok().flatten()); + if let Some(status) = exit { + session.handle_process_exit(status.code()); + break; + } + session.expire_deadlines(); + thread::sleep(Duration::from_millis(MONITOR_INTERVAL_MS)); + } + }); + } + + fn handle_server_message(&self, message: String) -> Result<(), CoreError> { + let value: Value = serde_json::from_str(&message).map_err(|error| { + CoreError::new(ErrorCode::ParseFailed, "Invalid LSP server JSON message.") + .with_details(error.to_string()) + })?; + + if value.get("method").and_then(Value::as_str) == Some("workspace/configuration") { + if let Some(response) = self.provider_configuration_response(&value)? { + return self.send_messages_or_fail(vec![response], "serverRequest"); + } + } + + let response_id = if value.get("method").is_none() { + lsp_value_id(value.get("id")) + } else { + None + }; + let (known_pending, pending_before, old_capabilities) = { + let state = self.lock_state()?; + let pending = response_id + .as_ref() + .and_then(|id| state.pending.get(id).cloned()); + ( + response_id + .as_ref() + .is_none_or(|id| state.client.pending_requests.contains_key(id)), + pending, + state.client.server_capabilities.clone(), + ) + }; + // A response whose request has timed out, been cancelled, or belongs + // to an older session is intentionally ignored. + if response_id.is_some() && !known_pending { + self.log( + "info", + "Ignored a late language-server response", + response_id, + ); + return Ok(()); + } + + let reduced = { + let state = self.lock_state()?; + client_apply_server_message(ClientApplyServerMessageRequest { + state: state.client.clone(), + message, + })? + }; + let mut outbound = reduced.messages; + let mut flush_documents = false; + let mut fail_initialize: Option<(String, Option)> = None; + { + let mut state = self.lock_state()?; + state.client = reduced.state; + if let Some(request_id) = response_id.as_ref() { + state.client.pending_requests.remove(request_id); + if let Some(pending) = state.pending.remove(request_id) { + if let Some(operation_id) = &pending.operation_id { + state.request_by_operation.remove(operation_id); + } + } + } + + match pending_before.as_ref().map(|pending| pending.kind) { + Some(PendingKind::Initialize) => { + state.initialize_deadline = None; + let server_error = value.get("error").map(Value::to_string); + if server_error.is_some() || !state.client.initialized { + fail_initialize = Some(( + if server_error.is_some() { + "initializeFailed".to_string() + } else { + "invalidServerMessage".to_string() + }, + server_error, + )); + } else { + transition_locked(self, &mut state, LspLifecycleState::Ready, None); + let capabilities = state.client.server_capabilities.clone(); + push_features_event(self, &mut state, capabilities); + if let Some(info) = parse_server_info(&value) { + push_server_info_event(self, &mut state, info); + } + flush_documents = true; + } + } + Some(PendingKind::Feature) => { + if let Some(pending) = pending_before.as_ref() { + if let Some(operation_id) = &pending.operation_id { + let event = reduced + .events + .iter() + .find(|event| event.request_id.as_ref() == response_id.as_ref()); + let error = + event.and_then(|event| event.error.as_ref()).map(|detail| { + runtime_error( + self, + "serverError", + "request", + Some(&pending.method), + pending.document_uri.as_deref(), + response_id.as_deref(), + "Language server returned an error.", + Some(detail), + None, + ) + }); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + event.and_then(|event| event.result.clone()), + error, + ); + } + } + } + Some(PendingKind::Shutdown) => { + // The reducer emits `exit` only after the shutdown response. + state.shutdown_deadline = Some(Instant::now() + state.shutdown_timeout); + } + None => {} + } + + for event in reduced.events { + if event.kind == "diagnostics" { + if let Some(uri) = event.uri.as_deref() { + push_diagnostics_event( + self, + &mut state, + uri, + event.version, + event.diagnostics.unwrap_or_default(), + ); + } + } else if event.kind == "notification" { + push_log_event( + self, + &mut state, + "info", + event + .method + .as_deref() + .unwrap_or("Language-server notification"), + event.result.map(|value| value.to_string()), + ); + } + } + if state.client.server_capabilities != old_capabilities + && state.lifecycle == LspLifecycleState::Ready + { + let capabilities = state.client.server_capabilities.clone(); + push_features_event(self, &mut state, capabilities); + } + } + + if let Some((code, detail)) = fail_initialize { + self.fail( + &code, + "initialize", + "Language-server initialization failed.", + detail, + None, + ); + self.kill_process(); + return Ok(()); + } + if flush_documents { + if let Some(notification) = initialized_notification(&self.provider_id) { + outbound.push( + json!({ + "jsonrpc": "2.0", + "method": notification.method, + "params": notification.params + }) + .to_string(), + ); + } + outbound.extend(self.flush_queued_documents()?); + } + self.send_messages_or_fail(outbound, "serverResponse") + } + + fn provider_configuration_response( + &self, + message: &Value, + ) -> Result, CoreError> { + let Some(id) = message.get("id") else { + return Ok(None); + }; + let items: Vec = message + .get("params") + .and_then(|params| params.get("items")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|item| WorkspaceConfigurationItem { + scope_uri: item + .get("scopeUri") + .and_then(Value::as_str) + .map(ToString::to_string), + section: item + .get("section") + .and_then(Value::as_str) + .map(ToString::to_string), + }) + .collect(); + let Some(values) = workspace_configuration(&self.provider_id, &items) else { + return Ok(None); + }; + Ok(Some( + serde_json::to_string(&json!({ + "jsonrpc": "2.0", + "id": id, + "result": values + })) + .map_err(|error| { + CoreError::new( + ErrorCode::Unknown, + "Could not encode the provider configuration response.", + ) + .with_details(error.to_string()) + })?, + )) + } + + fn flush_queued_documents(&self) -> Result, CoreError> { + let mut state = self.lock_state()?; + let queued: Vec<_> = state + .client + .open_documents + .values() + .filter(|document| document.version == 0) + .cloned() + .collect(); + let mut messages = Vec::new(); + for document in queued { + let response = client_open_document(ClientOpenDocumentRequest { + state: state.client.clone(), + uri: document.uri, + language_id: document.language_id, + text: document.text, + })?; + state.client = response.state; + messages.extend(response.messages); + } + Ok(messages) + } + + fn expire_deadlines(&self) { + let now = Instant::now(); + let mut cancellations = Vec::new(); + let mut initialize_timeout = false; + let mut shutdown_timeout = false; + if let Ok(mut state) = self.lock_state() { + if state.lifecycle == LspLifecycleState::Initializing + && state + .initialize_deadline + .is_some_and(|deadline| now >= deadline) + { + state.initialize_deadline = None; + initialize_timeout = true; + } + + let expired: Vec<_> = state + .pending + .iter() + .filter(|(_, pending)| { + pending.kind == PendingKind::Feature && now >= pending.deadline + }) + .map(|(id, _)| id.clone()) + .collect(); + for request_id in expired { + let Some(pending) = state.pending.remove(&request_id) else { + continue; + }; + state.client.pending_requests.remove(&request_id); + if let Some(operation_id) = pending.operation_id.as_deref() { + state.request_by_operation.remove(operation_id); + let elapsed = now.saturating_duration_since(pending.created_at); + let error = runtime_error( + self, + "requestTimeout", + "request", + Some(&pending.method), + pending.document_uri.as_deref(), + Some(&request_id), + "Language-server request timed out.", + Some(&format!("elapsedMilliseconds={}", elapsed.as_millis())), + None, + ); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + None, + Some(error), + ); + } + cancellations.push(request_id); + } + if state.lifecycle == LspLifecycleState::Stopping + && state + .shutdown_deadline + .is_some_and(|deadline| now >= deadline) + { + state.shutdown_deadline = None; + shutdown_timeout = true; + push_log_event( + self, + &mut state, + "warning", + "Language-server shutdown timed out; forcing termination", + None, + ); + } + } + if !cancellations.is_empty() { + let messages = cancellations + .into_iter() + .map(|id| { + json!({ + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": { "id": id } + }) + .to_string() + }) + .collect(); + let _ = self.send_messages(messages); + } + if initialize_timeout { + self.fail( + "initializeTimeout", + "initialize", + "Language-server initialization timed out.", + None, + None, + ); + self.kill_process(); + } else if shutdown_timeout { + self.kill_process(); + } + } + + fn handle_process_exit(&self, exit_code: Option) { + self.active.store(false, Ordering::Release); + if let Ok(mut input) = self.stdin.lock() { + *input = None; + } + if let Ok(mut state) = self.lock_state() { + let was_stopping = state.lifecycle == LspLifecycleState::Stopping; + if !state.terminal_event_emitted { + let error = (!was_stopping).then(|| { + runtime_error( + self, + "serverExited", + "process", + None, + None, + None, + "Language-server process exited.", + None, + exit_code, + ) + }); + fail_feature_requests( + self, + &mut state, + "serverExited", + "process", + "Language-server process exited before the request completed.", + exit_code, + ); + clear_runtime_state(self, &mut state); + transition_locked( + self, + &mut state, + if was_stopping { + LspLifecycleState::Stopped + } else { + LspLifecycleState::Failed + }, + error, + ); + state.terminal_event_emitted = true; + } + } + } + + fn complete_request_with_error( + &self, + request_id: &str, + code: &str, + stage: &str, + message: &str, + underlying: Option, + exit_code: Option, + ) { + if let Ok(mut state) = self.lock_state() { + let Some(pending) = state.pending.remove(request_id) else { + return; + }; + state.client.pending_requests.remove(request_id); + if let Some(operation_id) = pending.operation_id.as_deref() { + state.request_by_operation.remove(operation_id); + let error = runtime_error( + self, + code, + stage, + Some(&pending.method), + pending.document_uri.as_deref(), + Some(request_id), + message, + underlying.as_deref(), + exit_code, + ); + push_request_event( + self, + &mut state, + operation_id, + &pending.method, + None, + Some(error), + ); + } + } + } + + fn fail( + &self, + code: &str, + stage: &str, + message: &str, + underlying: Option, + exit_code: Option, + ) { + if let Ok(mut state) = self.lock_state() { + if state.terminal_event_emitted { + return; + } + fail_feature_requests(self, &mut state, code, stage, message, exit_code); + clear_runtime_state(self, &mut state); + let error = runtime_error( + self, + code, + stage, + None, + None, + None, + message, + underlying.as_deref(), + exit_code, + ); + transition_locked(self, &mut state, LspLifecycleState::Failed, Some(error)); + state.terminal_event_emitted = true; + } + } + + fn transition( + &self, + lifecycle: LspLifecycleState, + error: Option, + ) -> Result<(), CoreError> { + let mut state = self.lock_state()?; + transition_locked(self, &mut state, lifecycle, error); + Ok(()) + } + + fn log(&self, level: &str, message: &str, detail: Option) { + if let Ok(mut state) = self.lock_state() { + push_log_event(self, &mut state, level, message, detail); + } + } + + fn send_messages_or_fail(&self, messages: Vec, stage: &str) -> Result<(), CoreError> { + if messages.is_empty() { + return Ok(()); + } + if let Err(error) = self.send_messages(messages) { + self.fail( + "transportFailed", + stage, + "Could not write to language-server stdin.", + Some(core_error_detail(&error)), + None, + ); + self.kill_process(); + return Err(error); + } + Ok(()) + } + + fn send_messages(&self, messages: Vec) -> Result<(), CoreError> { + if messages.is_empty() { + return Ok(()); + } + let mut input = self.stdin.lock().map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server stdin lock was poisoned.", + ) + })?; + let input = input.as_mut().ok_or_else(|| { + CoreError::new(ErrorCode::ProcessFailed, "Language-server stdin is closed.") + })?; + for message in messages { + let frame = frame_message(FrameMessageRequest { message })?.frame; + input.write_all(frame.as_bytes()).map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Could not write to language-server stdin.", + ) + .with_details(error.to_string()) + })?; + } + input.flush().map_err(|error| { + CoreError::new( + ErrorCode::ProcessFailed, + "Could not flush language-server stdin.", + ) + .with_details(error.to_string()) + }) + } + + fn kill_process(&self) { + if let Ok(mut child) = self.child.lock() { + let _ = child.kill(); + } + } + + fn lock_state(&self) -> Result, CoreError> { + self.state.lock().map_err(|_| { + CoreError::new( + ErrorCode::Unknown, + "Language-server session state lock was poisoned.", + ) + }) + } +} + +fn validate_start_request(request: &StartServerRequest) -> Result<(), CoreError> { + if request.provider_id.trim().is_empty() { + return Err(invalid_field("providerId")); + } + if request.executable_path.trim().is_empty() + || request.executable_path.contains('\0') + || request.working_directory.trim().is_empty() + || request.working_directory.contains('\0') + { + return Err(invalid_field("executablePath/workingDirectory")); + } + if !request.root_uri.contains("://") || request.root_uri.contains('\0') { + return Err(invalid_field("rootUri")); + } + Ok(()) +} + +fn invalid_field(field: &str) -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid language-server start request.", + ) + .with_details(field) +} + +fn core_error_detail(error: &CoreError) -> String { + match error.details.as_deref() { + Some(details) if !details.is_empty() => format!("{} ({details})", error.message), + _ => error.message.clone(), + } +} + +fn unknown_session(session_id: &str) -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Unknown language-server session.", + ) + .with_details(session_id) +} + +fn ensure_not_terminal(lifecycle: LspLifecycleState) -> Result<(), CoreError> { + if matches!( + lifecycle, + LspLifecycleState::Stopping | LspLifecycleState::Stopped | LspLifecycleState::Failed + ) { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language-server session is not accepting document changes.", + )) + } else { + Ok(()) + } +} + +fn java_executable_from_environment(environment: &BTreeMap) -> Option { + environment.get("JAVA_HOME").map(|home| { + let executable = if cfg!(windows) { "java.exe" } else { "java" }; + Path::new(home).join("bin").join(executable) + }) +} + +fn semantic_method(operation: LspSemanticOperation) -> &'static str { + match operation { + LspSemanticOperation::Completion => "textDocument/completion", + LspSemanticOperation::Hover => "textDocument/hover", + LspSemanticOperation::Definition => "textDocument/definition", + LspSemanticOperation::Declaration => "textDocument/declaration", + LspSemanticOperation::TypeDefinition => "textDocument/typeDefinition", + LspSemanticOperation::References => "textDocument/references", + LspSemanticOperation::Implementation => "textDocument/implementation", + LspSemanticOperation::Rename => "textDocument/rename", + LspSemanticOperation::Formatting => "textDocument/formatting", + LspSemanticOperation::CodeActions => "textDocument/codeAction", + LspSemanticOperation::ResolveCompletion => "completionItem/resolve", + LspSemanticOperation::ResolveCodeAction => "codeAction/resolve", + LspSemanticOperation::ExecuteCommand | LspSemanticOperation::VirtualDocument => { + "workspace/executeCommand" + } + LspSemanticOperation::InlayHints => "textDocument/inlayHint", + LspSemanticOperation::FoldingRanges => "textDocument/foldingRange", + LspSemanticOperation::CodeLens => "textDocument/codeLens", + } +} + +fn semantic_capability(operation: LspSemanticOperation) -> Option<&'static str> { + match operation { + LspSemanticOperation::Completion => Some("completion"), + LspSemanticOperation::Hover => Some("hover"), + LspSemanticOperation::Definition => Some("definition"), + LspSemanticOperation::Declaration => Some("declaration"), + LspSemanticOperation::TypeDefinition => Some("typeDefinition"), + LspSemanticOperation::References => Some("references"), + LspSemanticOperation::Implementation => Some("implementation"), + LspSemanticOperation::Rename => Some("rename"), + LspSemanticOperation::Formatting => Some("formatting"), + LspSemanticOperation::CodeActions => Some("codeActions"), + LspSemanticOperation::ResolveCompletion => Some("completionResolve"), + LspSemanticOperation::ResolveCodeAction => Some("codeActionResolve"), + LspSemanticOperation::ExecuteCommand | LspSemanticOperation::VirtualDocument => { + Some("executeCommand") + } + LspSemanticOperation::InlayHints => Some("inlayHints"), + LspSemanticOperation::FoldingRanges => Some("foldingRanges"), + LspSemanticOperation::CodeLens => Some("codeLens"), + } +} + +fn allocate_raw_request( + mut state: LspClientState, + method: &str, + params: Value, +) -> Result { + let id = state.next_request_id.to_string(); + state.next_request_id += 1; + state + .pending_requests + .insert(id.clone(), method.to_string()); + let message = serde_json::to_string(&json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + })) + .map_err(|error| { + CoreError::new( + ErrorCode::Unknown, + "Could not encode a language-server request.", + ) + .with_details(error.to_string()) + })?; + Ok(super::LspClientResponse { + state, + messages: vec![message], + events: Vec::new(), + }) +} + +fn lsp_value_id(value: Option<&Value>) -> Option { + match value? { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +fn parse_server_info(message: &Value) -> Option { + let info = message.get("result")?.get("serverInfo")?; + Some(LspServerInfo { + name: info.get("name")?.as_str()?.to_string(), + version: info + .get("version") + .and_then(Value::as_str) + .map(ToString::to_string), + }) +} + +fn transition_locked( + session: &RuntimeSession, + state: &mut SessionState, + lifecycle: LspLifecycleState, + error: Option, +) { + state.lifecycle = lifecycle; + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "stateChanged".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: Some(lifecycle), + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + }); +} + +fn push_request_event( + session: &RuntimeSession, + state: &mut SessionState, + operation_id: &str, + method: &str, + result: Option, + error: Option, +) { + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "requestCompleted".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: Some(operation_id.to_string()), + method: Some(method.to_string()), + uri: None, + version: None, + diagnostics: None, + result, + error, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + }); +} + +fn push_diagnostics_event( + session: &RuntimeSession, + state: &mut SessionState, + uri: &str, + version: Option, + diagnostics: Vec, +) { + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "diagnostics".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: Some(uri.to_string()), + version, + diagnostics: Some(diagnostics), + result: None, + error: None, + capabilities: None, + server_info: None, + level: None, + message: None, + detail: None, + }); +} + +fn push_features_event( + session: &RuntimeSession, + state: &mut SessionState, + capabilities: Vec, +) { + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "featuresChanged".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error: None, + capabilities: Some(capabilities), + server_info: None, + level: None, + message: None, + detail: None, + }); +} + +fn push_server_info_event(session: &RuntimeSession, state: &mut SessionState, info: LspServerInfo) { + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "serverInfoChanged".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error: None, + capabilities: None, + server_info: Some(info), + level: None, + message: None, + detail: None, + }); +} + +fn push_log_event( + session: &RuntimeSession, + state: &mut SessionState, + level: &str, + message: &str, + detail: Option, +) { + let sequence = take_sequence(state); + state.events.push_back(LspRuntimeEvent { + kind: "log".to_string(), + sequence, + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + state: None, + operation_id: None, + method: None, + uri: None, + version: None, + diagnostics: None, + result: None, + error: None, + capabilities: None, + server_info: None, + level: Some(level.to_string()), + message: Some(message.to_string()), + detail: detail.filter(|value| !value.is_empty()), + }); +} + +fn take_sequence(state: &mut SessionState) -> u64 { + let sequence = state.next_sequence; + state.next_sequence += 1; + sequence +} + +fn runtime_error( + session: &RuntimeSession, + code: &str, + stage: &str, + method: Option<&str>, + document_uri: Option<&str>, + request_id: Option<&str>, + message: &str, + underlying: Option<&str>, + process_exit_code: Option, +) -> LspRuntimeError { + LspRuntimeError { + code: code.to_string(), + provider_id: session.provider_id.clone(), + session_id: session.id.clone(), + stage: stage.to_string(), + method: method.map(ToString::to_string), + document_uri: document_uri.map(ToString::to_string), + request_id: request_id.map(ToString::to_string), + message: message.to_string(), + underlying_message: underlying.map(ToString::to_string), + process_exit_code, + } +} + +fn fail_feature_requests( + session: &RuntimeSession, + state: &mut SessionState, + code: &str, + stage: &str, + message: &str, + exit_code: Option, +) { + let pending: Vec<_> = state + .pending + .iter() + .filter(|(_, pending)| pending.kind == PendingKind::Feature) + .map(|(request_id, pending)| (request_id.clone(), pending.clone())) + .collect(); + for (request_id, pending) in pending { + state.pending.remove(&request_id); + state.client.pending_requests.remove(&request_id); + if let Some(operation_id) = pending.operation_id.as_deref() { + state.request_by_operation.remove(operation_id); + let error = runtime_error( + session, + code, + stage, + Some(&pending.method), + pending.document_uri.as_deref(), + Some(&request_id), + message, + None, + exit_code, + ); + push_request_event( + session, + state, + operation_id, + &pending.method, + None, + Some(error), + ); + } + } +} + +fn clear_runtime_diagnostics(session: &RuntimeSession, state: &mut SessionState) { + let diagnostics: Vec<_> = state.client.diagnostics.keys().cloned().collect(); + state.client.diagnostics.clear(); + state.client.diagnostic_versions.clear(); + for uri in diagnostics { + push_diagnostics_event(session, state, &uri, None, Vec::new()); + } +} + +fn clear_runtime_state(session: &RuntimeSession, state: &mut SessionState) { + clear_runtime_diagnostics(session, state); + state.client.initialized = false; + state.client.shutdown_requested = false; + state.client.server_capabilities.clear(); + state.client.open_documents.clear(); + state.client.pending_requests.clear(); + state.pending.clear(); + state.request_by_operation.clear(); + state.initialize_deadline = None; + state.shutdown_deadline = None; + push_features_event(session, state, Vec::new()); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semantic_operations_are_protocol_methods_but_never_expose_request_ids() { + assert_eq!( + semantic_method(LspSemanticOperation::Definition), + "textDocument/definition" + ); + assert_eq!( + semantic_capability(LspSemanticOperation::VirtualDocument), + Some("executeCommand") + ); + } + + #[test] + fn java_runtime_is_derived_from_the_start_environment() { + let environment = BTreeMap::from([("JAVA_HOME".to_string(), "/jdk".to_string())]); + let path = java_executable_from_environment(&environment).unwrap(); + assert!(path.ends_with(if cfg!(windows) { + "bin/java.exe" + } else { + "bin/java" + })); + } + + #[test] + fn start_contract_rejects_missing_runtime_identity() { + let request = StartServerRequest { + provider_id: String::new(), + executable_path: "/bin/server".to_string(), + arguments: Vec::new(), + environment: BTreeMap::new(), + root_uri: "file:///workspace".to_string(), + working_directory: "/workspace".to_string(), + initialization_options: None, + runtime_executable_path: None, + cache_directory: None, + initialize_timeout_milliseconds: 1, + request_timeout_milliseconds: 1, + shutdown_timeout_milliseconds: 1, + }; + assert!(validate_start_request(&request).is_err()); + } +} diff --git a/rust/lithe-core/src/lsp/interface/host.rs b/rust/lithe-core/src/lsp/interface/host.rs new file mode 100644 index 000000000..d1925e6bd --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/host.rs @@ -0,0 +1,386 @@ +use super::{ + client_apply_server_message, client_change_document, client_close_document, + client_feature_request_canonical, client_initialize, client_open_document, client_shutdown, + ClientApplyServerMessageRequest, ClientChangeDocumentRequest, ClientCloseDocumentRequest, + ClientFeatureRequest, ClientInitializeRequest, ClientOpenDocumentRequest, + ClientShutdownRequest, LspClientDiagnostic, LspClientEvent, LspClientState, LspPosition, + LspRange, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +static HOST: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LspSessionAction { + Create, + OpenDocument, + ChangeDocument, + CloseDocument, + Shutdown, + Request, + ApplyServerMessage, + Destroy, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LspSessionCommandRequest { + pub action: LspSessionAction, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub root_uri: Option, + #[serde(default)] + pub process_id: Option, + #[serde(default)] + pub initialization_options: Option, + #[serde(default)] + pub uri: Option, + #[serde(default)] + pub language_id: Option, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub method: Option, + #[serde(default)] + pub position: Option, + #[serde(default)] + pub new_name: Option, + #[serde(default)] + pub range: Option, + #[serde(default)] + pub diagnostics: Vec, + #[serde(default)] + pub completion_item: Option, + #[serde(default)] + pub code_action: Option, + #[serde(default)] + pub command: Option, + #[serde(default)] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspSessionResponse { + pub session_id: String, + pub server_capabilities: Vec, + pub messages: Vec, + pub events: Vec, +} + +pub fn execute(request: LspSessionCommandRequest) -> Result { + HOST.get_or_init(LspHost::new).execute(request) +} + +struct LspHost { + next_session_id: AtomicU64, + sessions: Mutex>>>, +} + +impl LspHost { + fn new() -> Self { + Self { + next_session_id: AtomicU64::new(1), + sessions: Mutex::new(BTreeMap::new()), + } + } + + fn execute(&self, request: LspSessionCommandRequest) -> Result { + if matches!(request.action, LspSessionAction::Create) { + return self.create(request); + } + let session_id = required(request.session_id.clone(), "sessionId")?; + if matches!(request.action, LspSessionAction::Destroy) { + let mut sessions = self.lock_sessions()?; + if sessions.remove(&session_id).is_none() { + return Err(missing_session(&session_id)); + } + return Ok(LspSessionResponse { + session_id, + server_capabilities: Vec::new(), + messages: Vec::new(), + events: Vec::new(), + }); + } + + let session = self.session(&session_id)?; + let mut session_state = Self::lock_session(&session)?; + let state = session_state.clone(); + let response = match request.action { + LspSessionAction::OpenDocument => client_open_document(ClientOpenDocumentRequest { + state, + uri: required(request.uri, "uri")?, + language_id: required(request.language_id, "languageId")?, + text: required(request.text, "text")?, + }), + LspSessionAction::ChangeDocument => { + client_change_document(ClientChangeDocumentRequest { + state, + uri: required(request.uri, "uri")?, + text: required(request.text, "text")?, + }) + } + LspSessionAction::CloseDocument => client_close_document(ClientCloseDocumentRequest { + state, + uri: required(request.uri, "uri")?, + }), + LspSessionAction::Shutdown => client_shutdown(ClientShutdownRequest { state }), + LspSessionAction::Request => client_feature_request_canonical(ClientFeatureRequest { + state, + uri: required(request.uri, "uri")?, + method: required(request.method, "method")?, + position: request.position, + new_name: request.new_name, + range: request.range, + diagnostics: request.diagnostics, + completion_item: request.completion_item, + code_action: request.code_action, + command: request.command, + }), + LspSessionAction::ApplyServerMessage => { + client_apply_server_message(ClientApplyServerMessageRequest { + state, + message: required(request.message, "message")?, + }) + } + LspSessionAction::Create | LspSessionAction::Destroy => unreachable!(), + }?; + let server_capabilities = response.state.server_capabilities.clone(); + *session_state = response.state; + Ok(LspSessionResponse { + session_id, + server_capabilities, + messages: response.messages, + events: response.events, + }) + } + + fn create(&self, request: LspSessionCommandRequest) -> Result { + let response = client_initialize(ClientInitializeRequest { + state: LspClientState::default(), + root_uri: required(request.root_uri, "rootUri")?, + process_id: request.process_id, + initialization_options: request.initialization_options, + })?; + let session_id = self + .next_session_id + .fetch_add(1, Ordering::Relaxed) + .to_string(); + let server_capabilities = response.state.server_capabilities.clone(); + self.lock_sessions()? + .insert(session_id.clone(), Arc::new(Mutex::new(response.state))); + Ok(LspSessionResponse { + session_id, + server_capabilities, + messages: response.messages, + events: response.events, + }) + } + + fn session(&self, session_id: &str) -> Result>, CoreError> { + self.lock_sessions()? + .get(session_id) + .cloned() + .ok_or_else(|| missing_session(session_id)) + } + + fn lock_sessions( + &self, + ) -> Result>>>, CoreError> { + self.sessions.lock().map_err(|_| { + CoreError::new(ErrorCode::Unknown, "LSP session registry lock is poisoned") + }) + } + + fn lock_session( + session: &Mutex, + ) -> Result, CoreError> { + session + .lock() + .map_err(|_| CoreError::new(ErrorCode::Unknown, "LSP session lock is poisoned")) + } +} + +fn required(value: Option, field: &str) -> Result { + value.ok_or_else(|| { + CoreError::new( + ErrorCode::InvalidRequest, + "Missing LSP session request field", + ) + .with_details(field) + }) +} + +fn missing_session(session_id: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, "Unknown LSP session handle").with_details(session_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn request(action: LspSessionAction) -> LspSessionCommandRequest { + serde_json::from_value(json!({ "action": action_name(action) })).unwrap() + } + + fn action_name(action: LspSessionAction) -> &'static str { + match action { + LspSessionAction::Create => "create", + LspSessionAction::OpenDocument => "openDocument", + LspSessionAction::ChangeDocument => "changeDocument", + LspSessionAction::CloseDocument => "closeDocument", + LspSessionAction::Shutdown => "shutdown", + LspSessionAction::Request => "request", + LspSessionAction::ApplyServerMessage => "applyServerMessage", + LspSessionAction::Destroy => "destroy", + } + } + + #[test] + fn host_owns_state_across_document_changes() { + let host = LspHost::new(); + let mut create = request(LspSessionAction::Create); + create.root_uri = Some("file:///tmp/project".to_string()); + let created = host.execute(create).unwrap(); + + let mut initialized = request(LspSessionAction::ApplyServerMessage); + initialized.session_id = Some(created.session_id.clone()); + initialized.message = Some( + json!({ + "jsonrpc": "2.0", + "id": "1", + "result": { "capabilities": { "completionProvider": {} } } + }) + .to_string(), + ); + let initialized = host.execute(initialized).unwrap(); + assert_eq!(initialized.server_capabilities, vec!["completion"]); + + let mut open = request(LspSessionAction::OpenDocument); + open.session_id = Some(created.session_id.clone()); + open.uri = Some("file:///tmp/project/main.go".to_string()); + open.language_id = Some("go".to_string()); + open.text = Some("package main".to_string()); + assert_eq!(host.execute(open).unwrap().messages.len(), 1); + + let mut change = request(LspSessionAction::ChangeDocument); + change.session_id = Some(created.session_id.clone()); + change.uri = Some("file:///tmp/project/main.go".to_string()); + change.text = Some("package main\nfunc main() {}".to_string()); + assert_eq!(host.execute(change).unwrap().messages.len(), 1); + + let mut destroy = request(LspSessionAction::Destroy); + destroy.session_id = Some(created.session_id.clone()); + host.execute(destroy).unwrap(); + + let mut stale = request(LspSessionAction::Shutdown); + stale.session_id = Some(created.session_id); + assert!(host.execute(stale).is_err()); + } + + #[test] + fn host_isolates_open_documents_between_sessions() { + let host = LspHost::new(); + let mut first_create = request(LspSessionAction::Create); + first_create.root_uri = Some("file:///tmp/first".to_string()); + let first = host.execute(first_create).unwrap(); + let mut second_create = request(LspSessionAction::Create); + second_create.root_uri = Some("file:///tmp/second".to_string()); + let second = host.execute(second_create).unwrap(); + + let mut open = request(LspSessionAction::OpenDocument); + open.session_id = Some(first.session_id); + open.uri = Some("file:///tmp/first/main.go".to_string()); + open.language_id = Some("go".to_string()); + open.text = Some("package main".to_string()); + host.execute(open).unwrap(); + + let mut change = request(LspSessionAction::ChangeDocument); + change.session_id = Some(second.session_id); + change.uri = Some("file:///tmp/first/main.go".to_string()); + change.text = Some("package changed".to_string()); + assert!(host.execute(change).is_err()); + } + + #[test] + fn host_rejects_diagnostics_from_destroyed_sessions_and_noncurrent_documents() { + let host = LspHost::new(); + let uri = "file:///tmp/project/main.rs"; + let mut first_create = request(LspSessionAction::Create); + first_create.root_uri = Some("file:///tmp/project".to_string()); + let first = host.execute(first_create).unwrap(); + + let mut first_open = request(LspSessionAction::OpenDocument); + first_open.session_id = Some(first.session_id.clone()); + first_open.uri = Some(uri.to_string()); + first_open.language_id = Some("rust".to_string()); + first_open.text = Some("fn first() {}".to_string()); + host.execute(first_open).unwrap(); + + let mut destroy = request(LspSessionAction::Destroy); + destroy.session_id = Some(first.session_id.clone()); + host.execute(destroy).unwrap(); + + let mut old_session_diagnostics = request(LspSessionAction::ApplyServerMessage); + old_session_diagnostics.session_id = Some(first.session_id); + old_session_diagnostics.message = Some( + json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 1, + "diagnostics": [] + } + }) + .to_string(), + ); + assert!(host.execute(old_session_diagnostics).is_err()); + + let mut second_create = request(LspSessionAction::Create); + second_create.root_uri = Some("file:///tmp/project".to_string()); + let second = host.execute(second_create).unwrap(); + let mut second_open = request(LspSessionAction::OpenDocument); + second_open.session_id = Some(second.session_id.clone()); + second_open.uri = Some(uri.to_string()); + second_open.language_id = Some("rust".to_string()); + second_open.text = Some("fn second() {}".to_string()); + host.execute(second_open).unwrap(); + + let mut stale_diagnostics = request(LspSessionAction::ApplyServerMessage); + stale_diagnostics.session_id = Some(second.session_id); + stale_diagnostics.message = Some( + json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 2, + "diagnostics": [] + } + }) + .to_string(), + ); + assert!(host.execute(stale_diagnostics).unwrap().events.is_empty()); + } + + #[test] + fn session_requests_reject_unknown_fields() { + let error = serde_json::from_value::(json!({ + "action": "create", + "rootUri": "file:///tmp/project", + "unexpected": true + })) + .unwrap_err(); + + assert!(error.to_string().contains("unknown field `unexpected`")); + } +} diff --git a/rust/lithe-core/src/lsp/interface/mod.rs b/rust/lithe-core/src/lsp/interface/mod.rs new file mode 100644 index 000000000..dbc34225c --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/mod.rs @@ -0,0 +1,15 @@ +//! Standard LSP contracts and the stateful client/session implementation. + +mod client; +mod engine; +mod host; +mod transport; +mod types; + +pub(crate) use client::*; +pub(crate) use engine::*; +pub(crate) use host::{ + execute as session_execute_canonical, LspSessionCommandRequest, LspSessionResponse, +}; +pub(crate) use transport::*; +pub(crate) use types::*; diff --git a/rust/lithe-core/src/lsp/interface/transport.rs b/rust/lithe-core/src/lsp/interface/transport.rs new file mode 100644 index 000000000..8d8b873f5 --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/transport.rs @@ -0,0 +1,143 @@ +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; + +const MAX_HEADER_BYTES: usize = 64 * 1024; +const MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrameMessageRequest { + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FrameMessageResponse { + pub frame: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParseServerMessagesRequest { + #[serde(default)] + pub buffer: Vec, + #[serde(default)] + pub chunk: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParseServerMessagesResponse { + pub buffer: Vec, + pub messages: Vec, +} +pub fn frame_message(request: FrameMessageRequest) -> Result { + if request.message.contains('\0') { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP message frame cannot contain NUL bytes.", + )); + } + Ok(FrameMessageResponse { + frame: format!( + "Content-Length: {}\r\n\r\n{}", + request.message.len(), + request.message + ), + }) +} + +pub fn parse_server_messages( + request: ParseServerMessagesRequest, +) -> Result { + let mut buffer = request.buffer; + buffer.extend(request.chunk); + let mut messages = Vec::new(); + + loop { + let Some(header_end) = find_header_end(&buffer) else { + if buffer.len() > MAX_HEADER_BYTES { + return Err(transport_error("LSP header exceeded the maximum size.")); + } + break; + }; + if header_end > MAX_HEADER_BYTES { + return Err(transport_error("LSP header exceeded the maximum size.")); + } + let header = String::from_utf8_lossy(&buffer[..header_end]); + let content_length = content_length_from_header(&header)?; + if content_length > MAX_MESSAGE_BYTES { + return Err(transport_error("LSP message exceeded the maximum size.")); + } + let body_start = header_end + 4; + let body_end = body_start + .checked_add(content_length) + .ok_or_else(|| transport_error("LSP Content-Length overflowed."))?; + if buffer.len() < body_end { + break; + } + let body = buffer[body_start..body_end].to_vec(); + buffer.drain(..body_end); + let message = String::from_utf8(body).map_err(|error| { + transport_error("LSP message body was not valid UTF-8.").with_details(error.to_string()) + })?; + messages.push(message); + } + + Ok(ParseServerMessagesResponse { buffer, messages }) +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn content_length_from_header(header: &str) -> Result { + let value = header.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.trim().eq_ignore_ascii_case("content-length") { + Some(value.trim()) + } else { + None + } + }); + let Some(value) = value else { + return Err(transport_error("LSP frame did not contain Content-Length.")); + }; + value.parse::().map_err(|error| { + transport_error("LSP Content-Length was not a valid non-negative integer.") + .with_details(error.to_string()) + }) +} + +fn transport_error(message: &str) -> CoreError { + CoreError::new(ErrorCode::ParseFailed, message) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn malformed_content_length_is_a_transport_error() { + let missing = parse_server_messages(ParseServerMessagesRequest { + buffer: Vec::new(), + chunk: b"Content-Type: application/json\r\n\r\n{}".to_vec(), + }); + assert!(missing.is_err()); + + let invalid = parse_server_messages(ParseServerMessagesRequest { + buffer: Vec::new(), + chunk: b"Content-Length: nope\r\n\r\n{}".to_vec(), + }); + assert!(invalid.is_err()); + } + + #[test] + fn invalid_utf8_body_is_a_transport_error() { + let result = parse_server_messages(ParseServerMessagesRequest { + buffer: Vec::new(), + chunk: b"Content-Length: 1\r\n\r\n\xff".to_vec(), + }); + assert!(result.is_err()); + } +} diff --git a/rust/lithe-core/src/lsp/interface/types.rs b/rust/lithe-core/src/lsp/interface/types.rs new file mode 100644 index 000000000..5ecc17c0e --- /dev/null +++ b/rust/lithe-core/src/lsp/interface/types.rs @@ -0,0 +1,251 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LspRange { + pub start: LspPosition, + pub end: LspPosition, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LspPosition { + pub line: i64, + pub utf16_column: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspTextEditResponse { + pub range: LspRangeResponse, + pub new_text: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspInlayHintResponse { + pub position: LspPositionResponse, + pub label: String, + pub kind: Option, + pub tooltip: Option, + pub padding_left: bool, + pub padding_right: bool, + pub text_edits: Vec, + pub data: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspFoldingRangeResponse { + pub start_line: i64, + pub start_utf16_column: Option, + pub end_line: i64, + pub end_utf16_column: Option, + pub kind: Option, + pub collapsed_text: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspCodeLensResponse { + pub range: LspRangeResponse, + pub command: Option, + pub data: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspRangeResponse { + pub start: LspPositionResponse, + pub end: LspPositionResponse, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspPositionResponse { + pub line: i64, + pub utf16_column: i64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientState { + #[serde(default = "default_next_request_id")] + pub next_request_id: u64, + #[serde(default)] + pub initialized: bool, + #[serde(default)] + pub shutdown_requested: bool, + #[serde(default)] + pub server_capabilities: Vec, + #[serde(default)] + pub open_documents: BTreeMap, + #[serde(default)] + pub pending_requests: BTreeMap, + #[serde(default)] + pub diagnostics: BTreeMap>, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub diagnostic_versions: BTreeMap, +} + +impl Default for LspClientState { + fn default() -> Self { + Self { + next_request_id: default_next_request_id(), + initialized: false, + shutdown_requested: false, + server_capabilities: Vec::new(), + open_documents: BTreeMap::new(), + pending_requests: BTreeMap::new(), + diagnostics: BTreeMap::new(), + diagnostic_versions: BTreeMap::new(), + } + } +} + +fn default_next_request_id() -> u64 { + 1 +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientDocument { + pub uri: String, + pub language_id: String, + pub version: i64, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientDiagnostic { + pub range: LspRangeResponse, + pub severity: Option, + pub message: String, + pub source: Option, + pub code: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub related_information: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientDiagnosticRelatedInformation { + pub location: LspClientDiagnosticLocation, + pub message: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientDiagnosticLocation { + pub uri: String, + pub range: LspRangeResponse, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientInitializeRequest { + #[serde(default)] + pub state: LspClientState, + pub root_uri: String, + #[serde(default)] + pub process_id: Option, + #[serde(default)] + pub initialization_options: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientOpenDocumentRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, + pub language_id: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientChangeDocumentRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, + pub text: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientCloseDocumentRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientShutdownRequest { + #[serde(default)] + pub state: LspClientState, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientFeatureRequest { + #[serde(default)] + pub state: LspClientState, + pub uri: String, + pub method: String, + #[serde(default)] + pub position: Option, + #[serde(default)] + pub new_name: Option, + #[serde(default)] + pub range: Option, + #[serde(default)] + pub diagnostics: Vec, + #[serde(default)] + pub completion_item: Option, + #[serde(default)] + pub code_action: Option, + #[serde(default)] + pub command: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientApplyServerMessageRequest { + #[serde(default)] + pub state: LspClientState, + pub message: String, +} +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientResponse { + pub state: LspClientState, + pub messages: Vec, + pub events: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspClientEvent { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub diagnostics: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} diff --git a/rust/lithe-core/src/lsp/languages/catalog.rs b/rust/lithe-core/src/lsp/languages/catalog.rs new file mode 100644 index 000000000..b2c2274bb --- /dev/null +++ b/rust/lithe-core/src/lsp/languages/catalog.rs @@ -0,0 +1,351 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +const BUILTIN_LANGUAGE_PROVIDERS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/resources/lsp/language-providers.json" +)); + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspProviderCatalog { + pub version: u32, + pub origin: LspProviderCatalogOrigin, + pub providers: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspProviderCatalogOrigin { + Builtin, + WorkspaceOverride, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspProviderConfigDiagnostic { + pub path: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LspProviderDescriptor { + pub id: String, + pub display_name: String, + pub file_extensions: Vec, + pub file_names: Vec, + pub file_name_prefixes: Vec, + pub capabilities: Vec, + pub activation_policy: LspActivationPolicy, + pub language_id: Option, + pub language_ids_by_extension: BTreeMap, + pub language_ids_by_file_name: BTreeMap, + pub language_server_launch: Option, + pub language_server_installation: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LspServerLaunchDescriptor { + pub executable_names: Vec, + #[serde(default)] + pub arguments: Vec, + #[serde(default)] + pub validation_arguments: Vec, + #[serde(default)] + pub environment: BTreeMap, + #[serde(default)] + pub initialization_options: Option, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LspServerInstallationDescriptor { + #[serde(default)] + pub homebrew_formula: Option, + #[serde(default, rename = "officialDownloadURL")] + pub official_download_url: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspProviderCapability { + Run, + LanguageServer, + DebugAdapter, + Formatting, + Testing, +} + +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LspActivationPolicy { + OnDemand, + Always, +} + +impl Default for LspActivationPolicy { + fn default() -> Self { + Self::OnDemand + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LspProviderConfigDocument { + #[serde(default, rename = "$schema")] + _schema: Option, + #[serde(default = "default_config_version")] + version: u32, + #[serde(default)] + providers: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LspProviderPatch { + id: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + file_extensions: Option>, + #[serde(default)] + file_names: Option>, + #[serde(default)] + file_name_prefixes: Option>, + #[serde(default)] + capabilities: Option>, + #[serde(default)] + activation_policy: Option, + #[serde(default)] + language_id: Option, + #[serde(default)] + language_ids_by_extension: Option>, + #[serde(default)] + language_ids_by_file_name: Option>, + #[serde(default)] + language_server_launch: Option, + #[serde(default)] + language_server_installation: Option, + #[serde(default)] + disabled: bool, +} +pub fn provider_catalog_json(workspace_root: Option<&Path>) -> String { + let catalog = provider_catalog(workspace_root); + serde_json::to_string(&catalog).unwrap_or_else(|_| { + concat!( + "{\"version\":1,\"origin\":\"builtin\",\"providers\":[],", + "\"diagnostics\":[{\"path\":\"builtin:lsp\",", + "\"message\":\"Could not serialize the language-provider catalog.\"}]}" + ) + .to_string() + }) +} + +pub fn provider_catalog(workspace_root: Option<&Path>) -> LspProviderCatalog { + let mut diagnostics = Vec::new(); + let mut origin = LspProviderCatalogOrigin::Builtin; + let mut document = match parse_document(BUILTIN_LANGUAGE_PROVIDERS, "builtin:lsp") { + Ok(document) => document, + Err(message) => { + diagnostics.push(LspProviderConfigDiagnostic { + path: "builtin:lsp".to_string(), + message, + }); + LspProviderConfigDocument { + _schema: None, + version: 1, + providers: Vec::new(), + } + } + }; + + if let Some(root) = workspace_root { + let path = project_config_path(root); + if path.is_file() { + match std::fs::read_to_string(&path) { + Ok(raw) => match parse_document(&raw, &path.display().to_string()) { + Ok(project_document) => { + document = merge_documents(document, project_document); + origin = LspProviderCatalogOrigin::WorkspaceOverride; + } + Err(message) => diagnostics.push(LspProviderConfigDiagnostic { + path: path.display().to_string(), + message, + }), + }, + Err(error) => diagnostics.push(LspProviderConfigDiagnostic { + path: path.display().to_string(), + message: error.to_string(), + }), + } + } + } + + let mut providers = Vec::new(); + for patch in document.providers { + if patch.disabled { + continue; + } + providers.push(LspProviderDescriptor::from_patch(patch)); + } + LspProviderCatalog { + version: document.version, + origin, + providers, + diagnostics, + } +} + +fn parse_document(raw: &str, source: &str) -> Result { + serde_json::from_str(raw).map_err(|error| format!("{source}: {error}")) +} + +fn merge_documents( + mut base: LspProviderConfigDocument, + project: LspProviderConfigDocument, +) -> LspProviderConfigDocument { + base.version = project.version.max(base.version); + for patch in project.providers { + if let Some(existing) = base + .providers + .iter_mut() + .find(|provider| provider.id == patch.id) + { + existing.apply(patch); + } else { + base.providers.push(patch); + } + } + base +} + +fn project_config_path(root: &Path) -> PathBuf { + root.join(".lithe") + .join("lsp") + .join("language-providers.json") +} + +fn default_config_version() -> u32 { + 1 +} + +impl LspProviderPatch { + fn apply(&mut self, patch: LspProviderPatch) { + if patch.display_name.is_some() { + self.display_name = patch.display_name; + } + if patch.file_extensions.is_some() { + self.file_extensions = patch.file_extensions; + } + if patch.file_names.is_some() { + self.file_names = patch.file_names; + } + if patch.file_name_prefixes.is_some() { + self.file_name_prefixes = patch.file_name_prefixes; + } + if patch.capabilities.is_some() { + self.capabilities = patch.capabilities; + } + if patch.activation_policy.is_some() { + self.activation_policy = patch.activation_policy; + } + if patch.language_id.is_some() { + self.language_id = patch.language_id; + } + if patch.language_ids_by_extension.is_some() { + self.language_ids_by_extension = patch.language_ids_by_extension; + } + if patch.language_ids_by_file_name.is_some() { + self.language_ids_by_file_name = patch.language_ids_by_file_name; + } + if patch.language_server_launch.is_some() { + self.language_server_launch = patch.language_server_launch; + } + if patch.language_server_installation.is_some() { + self.language_server_installation = patch.language_server_installation; + } + self.disabled = patch.disabled; + } +} + +impl LspProviderDescriptor { + fn from_patch(patch: LspProviderPatch) -> Self { + let id = normalized_id(&patch.id); + let display_name = patch + .display_name + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| id.clone()); + let capabilities = patch.capabilities.unwrap_or_else(|| { + vec![ + LspProviderCapability::LanguageServer, + LspProviderCapability::Formatting, + ] + }); + Self { + id: id.clone(), + display_name, + file_extensions: normalized_values(patch.file_extensions.unwrap_or_default(), true), + file_names: normalized_values(patch.file_names.unwrap_or_default(), false), + file_name_prefixes: normalized_values( + patch.file_name_prefixes.unwrap_or_default(), + false, + ), + capabilities, + activation_policy: patch.activation_policy.unwrap_or_default(), + language_id: patch.language_id.filter(|value| !value.trim().is_empty()), + language_ids_by_extension: normalized_map( + patch.language_ids_by_extension.unwrap_or_default(), + true, + ), + language_ids_by_file_name: normalized_map( + patch.language_ids_by_file_name.unwrap_or_default(), + false, + ), + language_server_launch: patch.language_server_launch, + language_server_installation: patch.language_server_installation, + } + } +} + +fn normalized_id(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn normalized_values(values: Vec, trim_dot: bool) -> Vec { + let mut result = Vec::new(); + for value in values { + let normalized = normalized_key(&value, trim_dot); + if !normalized.is_empty() && !result.contains(&normalized) { + result.push(normalized); + } + } + result +} + +fn normalized_map(values: BTreeMap, trim_dot: bool) -> BTreeMap { + values + .into_iter() + .filter_map(|(key, value)| { + let key = normalized_key(&key, trim_dot); + if key.is_empty() || value.trim().is_empty() { + None + } else { + Some((key, value)) + } + }) + .collect() +} + +fn normalized_key(value: &str, trim_dot: bool) -> String { + let mut value = value.trim().to_ascii_lowercase(); + if trim_dot { + value = value.trim_start_matches('.').to_string(); + } + value +} diff --git a/rust/lithe-core/src/lsp/languages/jdt.rs b/rust/lithe-core/src/lsp/languages/jdt.rs new file mode 100644 index 000000000..b4a895615 --- /dev/null +++ b/rust/lithe-core/src/lsp/languages/jdt.rs @@ -0,0 +1,524 @@ +//! JDT LS-specific policy kept outside the generic LSP client and transport. +//! +//! The adapter is deliberately pure: it describes launch arguments, provider +//! notifications, configuration responses, and virtual-source requests. The +//! process engine remains responsible for creating directories and performing +//! all I/O. + +#![allow(dead_code)] // This module is an engine adapter seam; integration is intentionally separate. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +const JAVA_PROVIDER_ID: &str = "java"; +const JDT_URI_SCHEME: &str = "jdt"; +const JDTLS_DATA_DIRECTORY: &str = "jdtls"; +const JAVA_DECOMPILE_COMMAND: &str = "java.decompile"; +const DID_CHANGE_CONFIGURATION_METHOD: &str = "workspace/didChangeConfiguration"; + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct JdtStartContext { + pub provider_id: String, + pub workspace_root: PathBuf, + /// Engine-owned cache/state root. The adapter only derives a child path. + pub data_root: PathBuf, + #[serde(default)] + pub selected_java_executable: Option, + #[serde(default)] + pub arguments: Vec, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct JdtStartAdaptation { + pub arguments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_directory: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WorkspaceConfigurationItem { + #[serde(default)] + pub scope_uri: Option, + #[serde(default)] + pub section: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProviderNotification { + pub method: String, + pub params: Value, +} + +#[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProviderLocation { + pub uri: String, + #[serde(default)] + pub is_read_only: bool, + #[serde(default)] + pub display_path: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ExecuteCommandParams { + pub command: String, + pub arguments: Vec, +} + +/// Applies the JDT LS-owned part of a provider start plan. +/// +/// `data_directory` is returned to the engine as a directory requirement; this +/// function never creates it. Arguments owned by this adapter are replaced so +/// repeated adaptation is deterministic and cannot leave two `-data` roots. +pub(crate) fn adapt_start(context: &JdtStartContext) -> JdtStartAdaptation { + if !is_java_provider(&context.provider_id) { + return JdtStartAdaptation { + arguments: context.arguments.clone(), + data_directory: None, + }; + } + + let data_directory = context + .data_root + .join(JDTLS_DATA_DIRECTORY) + .join(workspace_key(&context.workspace_root)); + let mut arguments = without_jdt_owned_arguments(&context.arguments); + if let Some(java_executable) = &context.selected_java_executable { + arguments.push("--java-executable".to_string()); + arguments.push(java_executable.to_string_lossy().into_owned()); + } + arguments.extend([ + "--jvm-arg=-Xms256m".to_string(), + "--jvm-arg=-Xmx1024m".to_string(), + "-data".to_string(), + data_directory.to_string_lossy().into_owned(), + ]); + + JdtStartAdaptation { + arguments, + data_directory: Some(data_directory), + } +} + +/// Returns JDT LS configuration values in the same order as the requested +/// `workspace/configuration` items. `None` delegates non-Java providers to the +/// generic engine behavior. +pub(crate) fn workspace_configuration( + provider_id: &str, + items: &[WorkspaceConfigurationItem], +) -> Option> { + if !is_java_provider(provider_id) { + return None; + } + Some( + items + .iter() + .map(|item| java_configuration_for_section(item.section.as_deref())) + .collect(), + ) +} + +/// Notification the engine sends after the generic `initialized` handshake. +pub(crate) fn initialized_notification(provider_id: &str) -> Option { + is_java_provider(provider_id).then(|| ProviderNotification { + method: DID_CHANGE_CONFIGURATION_METHOD.to_string(), + params: json!({ + "settings": java_settings() + }), + }) +} + +/// Marks JDT virtual locations as read-only and gives them a source-like path. +/// File locations and locations from other providers pass through unchanged. +pub(crate) fn normalize_location( + provider_id: &str, + mut location: ProviderLocation, +) -> ProviderLocation { + if is_java_provider(provider_id) && has_uri_scheme(&location.uri, JDT_URI_SCHEME) { + location.is_read_only = true; + location.display_path = jdt_display_path(&location.uri); + } + location +} + +/// Converts a JDT virtual URI into `workspace/executeCommand` parameters. The +/// generic engine owns request IDs and JSON-RPC framing. +pub(crate) fn virtual_source_resolve_params( + provider_id: &str, + uri: &str, +) -> Option { + if !is_java_provider(provider_id) || !has_uri_scheme(uri, JDT_URI_SCHEME) { + return None; + } + Some(ExecuteCommandParams { + command: JAVA_DECOMPILE_COMMAND.to_string(), + arguments: vec![json!(uri)], + }) +} + +fn is_java_provider(provider_id: &str) -> bool { + provider_id.trim().eq_ignore_ascii_case(JAVA_PROVIDER_ID) +} + +fn without_jdt_owned_arguments(arguments: &[String]) -> Vec { + let mut retained = Vec::with_capacity(arguments.len()); + let mut index = 0; + while index < arguments.len() { + let argument = &arguments[index]; + let owns_following_value = argument == "--java-executable" || argument == "-data"; + let owns_inline_value = argument.starts_with("--java-executable=") + || argument.starts_with("-data=") + || argument.starts_with("--jvm-arg=-Xms") + || argument.starts_with("--jvm-arg=-Xmx"); + if owns_following_value { + index += usize::from(index + 1 < arguments.len()) + 1; + } else { + if !owns_inline_value { + retained.push(argument.clone()); + } + index += 1; + } + } + retained +} + +fn java_settings() -> Value { + json!({ + "java": { + "inlayHints": { + "parameterNames": { + "enabled": "all" + } + } + } + }) +} + +fn java_configuration_for_section(section: Option<&str>) -> Value { + match section { + Some("java") => json!({ + "inlayHints": { + "parameterNames": { + "enabled": "all" + } + } + }), + Some("java.inlayHints") => json!({ + "parameterNames": { + "enabled": "all" + } + }), + Some("java.inlayHints.parameterNames") => json!({ "enabled": "all" }), + Some("java.inlayHints.parameterNames.enabled") => json!("all"), + _ => Value::Null, + } +} + +fn workspace_key(workspace_root: &Path) -> String { + let identity = normalized_workspace_identity(workspace_root); + let digest = Sha256::digest(identity.as_bytes()); + let mut key = String::with_capacity(digest.len() * 2); + const HEX: &[u8; 16] = b"0123456789abcdef"; + for byte in digest { + key.push(HEX[(byte >> 4) as usize] as char); + key.push(HEX[(byte & 0x0f) as usize] as char); + } + key +} + +fn normalized_workspace_identity(workspace_root: &Path) -> String { + let raw = workspace_root.to_string_lossy().replace('\\', "/"); + let bytes = raw.as_bytes(); + let has_drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + let is_unc = raw.starts_with("//"); + let (prefix, remainder, is_absolute, protected_components, fold_case) = if has_drive { + let drive = (bytes[0] as char).to_ascii_lowercase(); + let remainder = &raw[2..]; + ( + format!("{drive}:"), + remainder, + remainder.starts_with('/'), + 0, + true, + ) + } else if is_unc { + ("//".to_string(), raw.trim_start_matches('/'), true, 2, true) + } else if raw.starts_with('/') { + ("/".to_string(), raw.trim_start_matches('/'), true, 0, false) + } else { + (String::new(), raw.as_str(), false, 0, false) + }; + + let mut components: Vec = Vec::new(); + for component in remainder.split('/') { + match component { + "" | "." => {} + ".." => { + if components.len() > protected_components + && components.last().is_some_and(|value| value != "..") + { + components.pop(); + } else if !is_absolute { + components.push("..".to_string()); + } + } + value => components.push(if fold_case { + value.to_lowercase() + } else { + value.to_string() + }), + } + } + + let joined = components.join("/"); + match (prefix.as_str(), joined.is_empty(), is_absolute) { + ("", true, _) => ".".to_string(), + ("/", true, _) => "/".to_string(), + ("//", true, _) => "//".to_string(), + (prefix, true, _) => prefix.to_string(), + ("", false, _) => joined, + ("/", false, _) => format!("/{joined}"), + ("//", false, _) => format!("//{joined}"), + (prefix, false, true) => format!("{prefix}/{joined}"), + (prefix, false, false) => format!("{prefix}{joined}"), + } +} + +fn has_uri_scheme(uri: &str, expected: &str) -> bool { + uri.split_once(':') + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case(expected)) +} + +fn jdt_display_path(uri: &str) -> Option { + let (_, remainder) = uri.split_once(':')?; + let path = if let Some(with_authority) = remainder.strip_prefix("//") { + with_authority + .find('/') + .map(|index| &with_authority[index + 1..])? + } else { + remainder.trim_start_matches('/') + }; + let path = path.split_once(['?', '#']).map_or(path, |(value, _)| value); + let decoded = percent_decode(path); + let mut components: Vec<_> = decoded + .split('/') + .filter(|component| !component.is_empty()) + .map(str::to_string) + .collect(); + let last = components.last_mut()?; + if let Some(class_name) = last.strip_suffix(".class") { + *last = format!("{class_name}.java"); + } + Some(components.join("/")) +} + +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let (Some(high), Some(low)) = + (hex_value(bytes[index + 1]), hex_value(bytes[index + 2])) + { + decoded.push((high << 4) | low); + index += 3; + continue; + } + } + decoded.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&decoded).into_owned() +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn java_start_context() -> JdtStartContext { + JdtStartContext { + provider_id: "java".to_string(), + workspace_root: PathBuf::from("/workspace/project"), + data_root: PathBuf::from("/cache/Lithe"), + selected_java_executable: Some(PathBuf::from("/jdk/bin/java")), + arguments: vec!["--stdio".to_string()], + } + } + + #[test] + fn java_start_adds_runtime_memory_and_unique_data_arguments() { + let context = java_start_context(); + let adapted = adapt_start(&context); + let data_directory = adapted.data_directory.as_ref().unwrap(); + + assert_eq!( + data_directory.parent().unwrap(), + Path::new("/cache/Lithe/jdtls") + ); + assert_eq!( + data_directory.file_name().unwrap().to_string_lossy().len(), + 64 + ); + assert_eq!( + adapted.arguments, + vec![ + "--stdio", + "--java-executable", + "/jdk/bin/java", + "--jvm-arg=-Xms256m", + "--jvm-arg=-Xmx1024m", + "-data", + data_directory.to_string_lossy().as_ref() + ] + ); + } + + #[test] + fn java_start_replaces_adapter_owned_arguments_and_is_stable() { + let mut context = java_start_context(); + context.arguments = vec![ + "--java-executable".to_string(), + "/old/java".to_string(), + "--jvm-arg=-Xms2g".to_string(), + "--jvm-arg=-Xmx4g".to_string(), + "--jvm-arg=-Duser.language=en".to_string(), + "-data".to_string(), + "/old/data".to_string(), + ]; + let first = adapt_start(&context); + context.arguments = first.arguments.clone(); + let second = adapt_start(&context); + + assert_eq!(first, second); + assert!(first + .arguments + .contains(&"--jvm-arg=-Duser.language=en".to_string())); + assert!(!first.arguments.contains(&"/old/java".to_string())); + assert!(!first.arguments.contains(&"/old/data".to_string())); + } + + #[test] + fn workspace_identity_is_lexical_cross_platform_and_unique() { + assert_eq!( + workspace_key(Path::new(r"C:\Users\Ada\Project\.\src\..")), + workspace_key(Path::new("c:/users/ada/project")) + ); + assert_eq!( + workspace_key(Path::new("/workspace/project/./src/..")), + workspace_key(Path::new("/workspace/project")) + ); + assert_ne!( + workspace_key(Path::new("/workspace/project")), + workspace_key(Path::new("/workspace/other")) + ); + } + + #[test] + fn non_java_start_is_a_generic_noop() { + let mut context = java_start_context(); + context.provider_id = "rust".to_string(); + let adapted = adapt_start(&context); + + assert_eq!(adapted.arguments, context.arguments); + assert_eq!(adapted.data_directory, None); + assert!(workspace_configuration("rust", &[]).is_none()); + assert!(initialized_notification("rust").is_none()); + assert!(virtual_source_resolve_params("rust", "jdt://contents/A.class").is_none()); + let location = ProviderLocation { + uri: "jdt://contents/A.class".to_string(), + is_read_only: false, + display_path: None, + }; + assert_eq!(normalize_location("rust", location.clone()), location); + } + + #[test] + fn java_workspace_configuration_matches_each_section_shape() { + let items = [ + "java", + "java.inlayHints", + "java.inlayHints.parameterNames", + "java.inlayHints.parameterNames.enabled", + "java.unknown", + ] + .map(|section| WorkspaceConfigurationItem { + scope_uri: Some("file:///workspace/project".to_string()), + section: Some(section.to_string()), + }); + let values = workspace_configuration("java", &items).unwrap(); + + assert_eq!(values[0]["inlayHints"]["parameterNames"]["enabled"], "all"); + assert_eq!(values[1]["parameterNames"]["enabled"], "all"); + assert_eq!(values[2]["enabled"], "all"); + assert_eq!(values[3], "all"); + assert_eq!(values[4], Value::Null); + } + + #[test] + fn java_initialized_notification_publishes_inlay_settings() { + let notification = initialized_notification("JAVA").unwrap(); + + assert_eq!(notification.method, "workspace/didChangeConfiguration"); + assert_eq!( + notification.params["settings"]["java"]["inlayHints"]["parameterNames"]["enabled"], + "all" + ); + } + + #[test] + fn jdt_location_is_read_only_with_a_source_display_path() { + let location = normalize_location( + "java", + ProviderLocation { + uri: "jdt://contents/java.base/java/util/Map%24Entry.class?=demo".to_string(), + is_read_only: false, + display_path: None, + }, + ); + + assert!(location.is_read_only); + assert_eq!( + location.display_path.as_deref(), + Some("java.base/java/util/Map$Entry.java") + ); + + let unchanged = normalize_location( + "java", + ProviderLocation { + uri: "file:///workspace/Main.java".to_string(), + is_read_only: false, + display_path: Some("Main.java".to_string()), + }, + ); + assert!(!unchanged.is_read_only); + assert_eq!(unchanged.display_path.as_deref(), Some("Main.java")); + } + + #[test] + fn virtual_source_uses_java_decompile_execute_command_params() { + let uri = "jdt://contents/java.base/java/lang/String.class"; + let params = virtual_source_resolve_params("java", uri).unwrap(); + let encoded = serde_json::to_value(params).unwrap(); + + assert_eq!(encoded["command"], "java.decompile"); + assert_eq!(encoded["arguments"], json!([uri])); + assert!(virtual_source_resolve_params("java", "file:///tmp/String.java").is_none()); + } +} diff --git a/rust/lithe-core/src/lsp/languages/mod.rs b/rust/lithe-core/src/lsp/languages/mod.rs new file mode 100644 index 000000000..c77b55510 --- /dev/null +++ b/rust/lithe-core/src/lsp/languages/mod.rs @@ -0,0 +1,7 @@ +//! Dynamic provider metadata and host-model adapters for individual languages. + +mod catalog; +pub(crate) mod jdt; +pub(crate) mod swift; + +pub(crate) use catalog::*; diff --git a/rust/lithe-core/src/lsp/languages/swift.rs b/rust/lithe-core/src/lsp/languages/swift.rs new file mode 100644 index 000000000..29c139187 --- /dev/null +++ b/rust/lithe-core/src/lsp/languages/swift.rs @@ -0,0 +1,189 @@ +use crate::lsp::interface::{ClientFeatureRequest, LspSessionCommandRequest}; +use serde_json::{json, Value}; + +pub(crate) fn adapt_feature_request(mut request: ClientFeatureRequest) -> ClientFeatureRequest { + request.completion_item = request + .completion_item + .as_ref() + .map(swift_completion_item_to_lsp); + request.code_action = request.code_action.as_ref().map(swift_code_action_to_lsp); + request.command = request.command.as_ref().and_then(swift_command_to_lsp); + request +} + +pub(crate) fn adapt_session_request( + mut request: LspSessionCommandRequest, +) -> LspSessionCommandRequest { + request.completion_item = request + .completion_item + .as_ref() + .map(swift_completion_item_to_lsp); + request.code_action = request.code_action.as_ref().map(swift_code_action_to_lsp); + request.command = request.command.as_ref().and_then(swift_command_to_lsp); + request +} + +pub(crate) fn swift_completion_item_to_lsp(item: &Value) -> Value { + let mut object = serde_json::Map::new(); + copy_string_field(item, &mut object, "label"); + copy_string_field(item, &mut object, "detail"); + copy_string_field(item, &mut object, "documentation"); + copy_string_field(item, &mut object, "insertText"); + copy_string_field(item, &mut object, "sortText"); + copy_string_field(item, &mut object, "filterText"); + if let Some(kind) = item.get("kind").and_then(Value::as_i64) { + object.insert("kind".to_string(), json!(kind)); + } + if let Some(edit) = item.get("textEdit").and_then(swift_text_edit_to_lsp) { + object.insert("textEdit".to_string(), edit); + } + if let Some(edits) = item.get("additionalTextEdits").and_then(Value::as_array) { + object.insert( + "additionalTextEdits".to_string(), + json!(edits + .iter() + .filter_map(swift_text_edit_to_lsp) + .collect::>()), + ); + } + if let Some(data) = item.get("data") { + object.insert("data".to_string(), data.clone()); + } + Value::Object(object) +} + +pub(crate) fn swift_code_action_to_lsp(action: &Value) -> Value { + let mut object = serde_json::Map::new(); + copy_string_field(action, &mut object, "title"); + copy_string_field(action, &mut object, "kind"); + if let Some(is_preferred) = action.get("isPreferred").and_then(Value::as_bool) { + object.insert("isPreferred".to_string(), json!(is_preferred)); + } + if let Some(edit) = action.get("edit").and_then(swift_workspace_edit_to_lsp) { + object.insert("edit".to_string(), edit); + } + if let Some(command) = action.get("command").and_then(swift_command_to_lsp) { + object.insert("command".to_string(), command); + } + if let Some(data) = action.get("data") { + object.insert("data".to_string(), data.clone()); + } + Value::Object(object) +} + +fn swift_workspace_edit_to_lsp(value: &Value) -> Option { + let changes = value.get("changes")?.as_object()?; + let mut parsed_changes = serde_json::Map::new(); + for (path, edits) in changes { + let uri = if path.starts_with("file://") { + path.clone() + } else { + format!("file://{path}") + }; + parsed_changes.insert( + uri, + json!(edits + .as_array() + .map(|values| values + .iter() + .filter_map(swift_text_edit_to_lsp) + .collect::>()) + .unwrap_or_default()), + ); + } + Some(json!({ "changes": parsed_changes })) +} + +pub(crate) fn swift_command_to_lsp(value: &Value) -> Option { + Some(json!({ + "title": value.get("title").and_then(Value::as_str).unwrap_or_default(), + "command": value.get("command").and_then(Value::as_str)?, + "arguments": value + .get("arguments") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + })) +} + +fn copy_string_field(source: &Value, target: &mut serde_json::Map, field: &str) { + if let Some(value) = source.get(field).and_then(Value::as_str) { + target.insert(field.to_string(), json!(value)); + } +} + +fn swift_text_edit_to_lsp(value: &Value) -> Option { + Some(json!({ + "range": swift_range_to_lsp(value.get("range")?)?, + "newText": value.get("newText").and_then(Value::as_str).unwrap_or_default() + })) +} + +fn swift_range_to_lsp(value: &Value) -> Option { + Some(json!({ + "start": swift_position_to_lsp(value.get("start")?)?, + "end": swift_position_to_lsp(value.get("end")?)? + })) +} + +fn swift_position_to_lsp(value: &Value) -> Option { + Some(json!({ + "line": value.get("line").and_then(Value::as_i64).unwrap_or(0), + "character": value + .get("utf16Column") + .or_else(|| value.get("character")) + .and_then(Value::as_i64) + .unwrap_or(0) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completion_items_translate_swift_utf16_positions() { + let item = json!({ + "label": "print", + "data": { "token": 7 }, + "textEdit": { + "range": { + "start": { "line": 2, "utf16Column": 3 }, + "end": { "line": 2, "utf16Column": 5 } + }, + "newText": "print()" + } + }); + + let converted = swift_completion_item_to_lsp(&item); + assert_eq!(converted["textEdit"]["range"]["start"]["character"], 3); + assert_eq!(converted["textEdit"]["newText"], "print()"); + assert_eq!(converted["data"]["token"], 7); + } + + #[test] + fn code_actions_translate_workspace_paths_to_file_uris() { + let action = json!({ + "title": "Apply fix", + "edit": { + "changes": { + "/tmp/main.swift": [{ + "range": { + "start": { "line": 0, "utf16Column": 0 }, + "end": { "line": 0, "utf16Column": 1 } + }, + "newText": "x" + }] + } + } + }); + + let converted = swift_code_action_to_lsp(&action); + assert!(converted["edit"]["changes"]["file:///tmp/main.swift"].is_array()); + } + + #[test] + fn commands_require_a_command_identifier() { + assert!(swift_command_to_lsp(&json!({ "title": "Missing" })).is_none()); + } +} diff --git a/rust/lithe-core/src/lsp/lightweight/edits.rs b/rust/lithe-core/src/lsp/lightweight/edits.rs new file mode 100644 index 000000000..6dda78d63 --- /dev/null +++ b/rust/lithe-core/src/lsp/lightweight/edits.rs @@ -0,0 +1,157 @@ +use crate::lsp::interface::{LspPosition, LspPositionResponse, LspRange, LspRangeResponse}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyTextEditsRequest { + pub text: String, + #[serde(default)] + pub edits: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LspTextEdit { + pub range: LspRange, + pub new_text: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TextResponse { + pub text: String, +} + +pub fn apply_text_edits(request: ApplyTextEditsRequest) -> Result { + let mut replacements = Vec::new(); + for edit in request.edits { + let start = utf16_position_to_byte_offset(&request.text, edit.range.start)?; + let end = utf16_position_to_byte_offset(&request.text, edit.range.end)?; + if end < start { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned an invalid text range.", + ) + .with_details("invalidRange")); + } + replacements.push((start, end, edit.new_text)); + } + replacements.sort_by_key(|(start, _, _)| *start); + for pair in replacements.windows(2) { + if pair[0].1 > pair[1].0 { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned overlapping text edits.", + ) + .with_details("overlappingEdits")); + } + } + + let mut text = request.text; + for (start, end, replacement) in replacements.into_iter().rev() { + text.replace_range(start..end, &replacement); + } + Ok(TextResponse { text }) +} + +pub(super) fn utf16_position_to_byte_offset( + text: &str, + position: LspPosition, +) -> Result { + if position.line < 0 || position.utf16_column < 0 { + return Err(invalid_range_error()); + } + let line = usize::try_from(position.line).map_err(|_| invalid_range_error())?; + let column = usize::try_from(position.utf16_column).map_err(|_| invalid_range_error())?; + let Some((start, contents_end)) = line_bounds(text, line) else { + return Err(invalid_range_error()); + }; + Ok(byte_offset_for_utf16_column( + text, + start, + contents_end, + column, + )) +} + +fn invalid_range_error() -> CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + "Language server returned an invalid text range.", + ) + .with_details("invalidRange") +} + +fn line_bounds(text: &str, target_line: usize) -> Option<(usize, usize)> { + let bytes = text.as_bytes(); + let mut line = 0; + let mut start = 0; + for (index, byte) in bytes.iter().enumerate() { + if *byte == b'\n' { + if line == target_line { + let contents_end = if index > start && bytes[index - 1] == b'\r' { + index - 1 + } else { + index + }; + return Some((start, contents_end)); + } + line += 1; + start = index + 1; + } + } + if line == target_line { + Some((start, text.len())) + } else { + None + } +} + +fn byte_offset_for_utf16_column( + text: &str, + start: usize, + contents_end: usize, + column: usize, +) -> usize { + let mut units = 0; + for (relative, character) in text[start..contents_end].char_indices() { + let next_units = units + character.len_utf16(); + if next_units > column { + return start + relative; + } + units = next_units; + if units == column { + return start + relative + character.len_utf8(); + } + } + contents_end +} + +fn byte_offset_to_lsp_position(text: &str, offset: usize) -> LspPositionResponse { + let offset = offset.min(text.len()); + let mut line = 0_i64; + let mut column = 0_i64; + for (index, character) in text.char_indices() { + if index >= offset { + break; + } + if character == '\n' { + line += 1; + column = 0; + } else { + column += character.len_utf16() as i64; + } + } + LspPositionResponse { + line, + utf16_column: column, + } +} + +pub(super) fn range_for_offsets(text: &str, start: usize, end: usize) -> LspRangeResponse { + LspRangeResponse { + start: byte_offset_to_lsp_position(text, start), + end: byte_offset_to_lsp_position(text, end), + } +} diff --git a/rust/lithe-core/src/lsp/lightweight/mod.rs b/rust/lithe-core/src/lsp/lightweight/mod.rs new file mode 100644 index 000000000..9bf3c9232 --- /dev/null +++ b/rust/lithe-core/src/lsp/lightweight/mod.rs @@ -0,0 +1,9 @@ +//! In-process language features that remain available without an LSP server. + +mod edits; +mod snippets; +mod symbols; + +pub(crate) use edits::*; +pub(crate) use snippets::*; +pub(crate) use symbols::*; diff --git a/rust/lithe-core/src/lsp/lightweight/snippets.rs b/rust/lithe-core/src/lsp/lightweight/snippets.rs new file mode 100644 index 000000000..92f3c89d6 --- /dev/null +++ b/rust/lithe-core/src/lsp/lightweight/snippets.rs @@ -0,0 +1,78 @@ +use super::edits::TextResponse; +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlainSnippetRequest { + pub value: String, +} + +pub fn plain_snippet(request: PlainSnippetRequest) -> TextResponse { + TextResponse { + text: snippet_plain_text(&request.value), + } +} + +pub(crate) fn snippet_plain_text(value: &str) -> String { + let mut output = String::new(); + let mut chars = value.chars().peekable(); + while let Some(character) = chars.next() { + if character != '$' { + output.push(character); + continue; + } + match chars.peek().copied() { + Some('{') => { + chars.next(); + if !consume_digits(&mut chars) { + output.push_str("${"); + continue; + } + match chars.peek().copied() { + Some(':') => { + chars.next(); + output.push_str(&consume_until_placeholder_end(&mut chars)); + } + Some('}') => { + chars.next(); + } + _ => output.push('$'), + } + } + Some(next) if next.is_ascii_digit() => { + consume_digits(&mut chars); + } + _ => output.push('$'), + } + } + output +} + +fn consume_digits(chars: &mut std::iter::Peekable) -> bool +where + I: Iterator, +{ + let mut consumed = false; + while chars + .peek() + .is_some_and(|character| character.is_ascii_digit()) + { + chars.next(); + consumed = true; + } + consumed +} + +fn consume_until_placeholder_end(chars: &mut std::iter::Peekable) -> String +where + I: Iterator, +{ + let mut value = String::new(); + for character in chars.by_ref() { + if character == '}' { + break; + } + value.push(character); + } + value +} diff --git a/rust/lithe-core/src/lsp/lightweight/symbols.rs b/rust/lithe-core/src/lsp/lightweight/symbols.rs new file mode 100644 index 000000000..0d1c00c09 --- /dev/null +++ b/rust/lithe-core/src/lsp/lightweight/symbols.rs @@ -0,0 +1,365 @@ +use super::edits::{range_for_offsets, utf16_position_to_byte_offset}; +use crate::lsp::interface::{ + LspPosition, LspPositionResponse, LspRangeResponse, LspTextEditResponse, +}; +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinRequest { + pub file_path: String, + pub text: String, + pub position: LspPosition, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinNavigationRequest { + pub file_path: String, + pub text: String, + pub position: LspPosition, + pub method: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinCompletionResponse { + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinCompletionItem { + pub label: String, + pub insert_text: String, + pub kind: Option, + pub detail: Option, + pub text_edit: LspTextEditResponse, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinHoverResponse { + pub hover: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinHover { + pub contents: String, + pub is_markdown: bool, + pub range: LspRangeResponse, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinNavigationResponse { + pub locations: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltinLocation { + pub file_path: String, + pub range: LspRangeResponse, + pub is_read_only: bool, + pub display_path: Option, +} + +#[derive(Debug, Clone)] +struct IdentifierOccurrence { + value: String, + start: usize, + end: usize, + range: LspRangeResponse, +} + +pub fn builtin_completions( + request: BuiltinRequest, +) -> Result { + validate_file_path(&request.file_path)?; + let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; + let prefix = identifier_prefix_at(&request.text, cursor); + let start_column = request.position.utf16_column - prefix.encode_utf16().count() as i64; + let replacement_range = LspRangeResponse { + start: LspPositionResponse { + line: request.position.line, + utf16_column: start_column.max(0), + }, + end: LspPositionResponse { + line: request.position.line, + utf16_column: request.position.utf16_column, + }, + }; + + let mut seen = BTreeMap::::new(); + for occurrence in identifier_occurrences(&request.text) { + if occurrence.value == prefix { + continue; + } + if !prefix.is_empty() && !occurrence.value.starts_with(&prefix) { + continue; + } + let kind = builtin_completion_kind(&request.text, occurrence.start); + seen.entry(occurrence.value).or_insert(kind); + } + + let items = seen + .into_iter() + .take(80) + .map(|(label, kind)| BuiltinCompletionItem { + insert_text: label.clone(), + label, + kind: Some(kind), + detail: Some("Current file symbol".to_string()), + text_edit: LspTextEditResponse { + range: replacement_range, + new_text: String::new(), + }, + }) + .map(|mut item| { + item.text_edit.new_text = item.insert_text.clone(); + item + }) + .collect(); + Ok(BuiltinCompletionResponse { items }) +} + +pub fn builtin_hover(request: BuiltinRequest) -> Result { + validate_file_path(&request.file_path)?; + let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; + let Some(identifier) = identifier_at(&request.text, cursor) else { + return Ok(BuiltinHoverResponse { hover: None }); + }; + Ok(BuiltinHoverResponse { + hover: Some(BuiltinHover { + contents: format!("`{}`", identifier.value), + is_markdown: true, + range: identifier.range, + }), + }) +} + +pub fn builtin_navigation( + request: BuiltinNavigationRequest, +) -> Result { + validate_file_path(&request.file_path)?; + let cursor = utf16_position_to_byte_offset(&request.text, request.position)?; + let Some(identifier) = identifier_at(&request.text, cursor) else { + return Ok(BuiltinNavigationResponse { + locations: Vec::new(), + }); + }; + let mut occurrences: Vec<_> = identifier_occurrences(&request.text) + .into_iter() + .filter(|occurrence| occurrence.value == identifier.value) + .collect(); + + if request.method == "textDocument/definition" + || request.method == "textDocument/declaration" + || request.method == "textDocument/typeDefinition" + { + let declarations: Vec<_> = occurrences + .iter() + .filter(|occurrence| looks_like_declaration(&request.text, occurrence.start)) + .cloned() + .collect(); + if !declarations.is_empty() { + occurrences = declarations; + } + } else if request.method == "textDocument/implementation" { + occurrences.retain(|occurrence| occurrence.start != identifier.start); + } + + let locations = occurrences + .into_iter() + .take(200) + .map(|occurrence| BuiltinLocation { + file_path: request.file_path.clone(), + range: occurrence.range, + is_read_only: false, + display_path: None, + }) + .collect(); + Ok(BuiltinNavigationResponse { locations }) +} + +fn identifier_occurrences(text: &str) -> Vec { + let mut values = Vec::new(); + let mut current_start: Option = None; + for (index, character) in text.char_indices() { + if is_identifier_character(character) { + if current_start.is_none() { + current_start = Some(index); + } + } else if let Some(start) = current_start.take() { + push_identifier(text, start, index, &mut values); + } + } + if let Some(start) = current_start { + push_identifier(text, start, text.len(), &mut values); + } + values +} + +fn push_identifier(text: &str, start: usize, end: usize, values: &mut Vec) { + let value = &text[start..end]; + if value.chars().next().is_some_and(is_identifier_start) + && !is_language_keyword(value) + && value.len() <= 120 + { + values.push(IdentifierOccurrence { + value: value.to_string(), + start, + end, + range: range_for_offsets(text, start, end), + }); + } +} + +fn identifier_at(text: &str, cursor: usize) -> Option { + identifier_occurrences(text) + .into_iter() + .find(|occurrence| occurrence.start <= cursor && cursor <= occurrence.end) +} + +fn identifier_prefix_at(text: &str, cursor: usize) -> String { + let mut start = cursor.min(text.len()); + while start > 0 { + let Some((previous_index, previous)) = text[..start].char_indices().next_back() else { + break; + }; + if !is_identifier_character(previous) { + break; + } + start = previous_index; + } + text[start..cursor.min(text.len())].to_string() +} + +fn is_identifier_start(character: char) -> bool { + character == '_' || character.is_alphabetic() +} + +fn is_identifier_character(character: char) -> bool { + character == '_' || character.is_alphanumeric() +} + +fn is_language_keyword(value: &str) -> bool { + matches!( + value, + "as" | "async" + | "await" + | "break" + | "case" + | "catch" + | "class" + | "const" + | "continue" + | "def" + | "default" + | "defer" + | "do" + | "else" + | "enum" + | "export" + | "extends" + | "false" + | "final" + | "fn" + | "for" + | "func" + | "function" + | "if" + | "impl" + | "import" + | "in" + | "interface" + | "let" + | "match" + | "mod" + | "mut" + | "nil" + | "null" + | "package" + | "private" + | "protected" + | "public" + | "return" + | "self" + | "static" + | "struct" + | "switch" + | "this" + | "throw" + | "throws" + | "trait" + | "true" + | "try" + | "type" + | "var" + | "while" + ) +} + +fn builtin_completion_kind(text: &str, start: usize) -> i32 { + if looks_like_declaration_with_keywords( + text, + start, + &["class", "struct", "enum", "interface", "trait"], + ) { + 7 + } else if looks_like_declaration_with_keywords(text, start, &["func", "function", "def", "fn"]) + { + 3 + } else { + 6 + } +} + +fn looks_like_declaration(text: &str, start: usize) -> bool { + looks_like_declaration_with_keywords( + text, + start, + &[ + "class", + "struct", + "enum", + "interface", + "trait", + "func", + "function", + "def", + "fn", + "let", + "var", + "const", + "type", + ], + ) +} + +fn looks_like_declaration_with_keywords(text: &str, start: usize, keywords: &[&str]) -> bool { + let line_start = text[..start].rfind('\n').map_or(0, |index| index + 1); + let prefix = &text[line_start..start]; + let tokens: Vec<&str> = prefix + .split(|character: char| !is_identifier_character(character)) + .filter(|token| !token.is_empty()) + .collect(); + tokens + .last() + .is_some_and(|token| keywords.iter().any(|keyword| keyword == token)) +} + +fn validate_file_path(value: &str) -> Result<(), CoreError> { + if value.trim().is_empty() { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "LSP builtin request requires a file path.", + )) + } else { + Ok(()) + } +} diff --git a/rust/lithe-core/src/lsp/mod.rs b/rust/lithe-core/src/lsp/mod.rs new file mode 100644 index 000000000..ee7ad3a52 --- /dev/null +++ b/rust/lithe-core/src/lsp/mod.rs @@ -0,0 +1,24 @@ +//! Language tooling is split by responsibility while this facade keeps the Core command API stable. + +pub(crate) mod interface; +mod languages; +pub(crate) mod lightweight; + +pub(crate) use interface::*; +pub(crate) use languages::*; +pub(crate) use lightweight::*; + +pub(crate) fn client_feature_request( + request: ClientFeatureRequest, +) -> Result { + interface::client_feature_request_canonical(languages::swift::adapt_feature_request(request)) +} + +pub(crate) fn session_execute( + request: LspSessionCommandRequest, +) -> Result { + interface::session_execute_canonical(languages::swift::adapt_session_request(request)) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/lsp/tests.rs b/rust/lithe-core/src/lsp/tests.rs new file mode 100644 index 000000000..c62ad6e70 --- /dev/null +++ b/rust/lithe-core/src/lsp/tests.rs @@ -0,0 +1,1855 @@ +use super::*; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temporary_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + std::env::temp_dir().join(format!("lithe-lsp-{label}-{}-{nonce}", std::process::id())) +} + +#[test] +fn builtin_catalog_describes_market_lsp_providers() { + let catalog = provider_catalog(None); + let ids: Vec<_> = catalog + .providers + .iter() + .map(|provider| provider.id.as_str()) + .collect(); + assert!(ids.starts_with(&["java", "go", "python", "node", "rust"])); + assert!(ids.contains(&"swift")); + assert!(ids.contains(&"clangd")); + assert!(ids.contains(&"dockerfile")); + assert!(ids.contains(&"graphql")); + let clangd = catalog + .providers + .iter() + .find(|provider| provider.id == "clangd") + .expect("clangd provider should exist"); + assert_eq!( + clangd.language_ids_by_extension.get("m"), + Some(&"objective-c".to_string()) + ); + let swift = catalog + .providers + .iter() + .find(|provider| provider.id == "swift") + .expect("swift provider should exist"); + let swift_launch = swift + .language_server_launch + .as_ref() + .expect("swift launch descriptor should exist"); + assert_eq!( + swift_launch.executable_names, + vec!["sourcekit-lsp".to_string()] + ); + let go = catalog + .providers + .iter() + .find(|provider| provider.id == "go") + .expect("go provider should exist"); + let go_installation = go + .language_server_installation + .as_ref() + .expect("go installation descriptor should exist"); + assert_eq!(go_installation.homebrew_formula.as_deref(), Some("gopls")); + assert_eq!( + go_installation.official_download_url.as_deref(), + Some("https://go.dev/gopls/") + ); + let rust = catalog + .providers + .iter() + .find(|provider| provider.id == "rust") + .expect("rust provider should exist"); + assert_eq!( + rust.language_server_launch + .as_ref() + .expect("rust launch descriptor should exist") + .validation_arguments, + vec!["--version".to_string()] + ); +} + +#[test] +fn project_config_extends_and_overrides_builtin_catalog() { + let root = temporary_root("project-config"); + fs::create_dir_all(root.join(".lithe/lsp")).unwrap(); + fs::write( + root.join(".lithe/lsp/language-providers.json"), + r#"{ + "version": 1, + "providers": [ + { + "id": "roc", + "displayName": "Roc", + "fileExtensions": ["roc"], + "capabilities": ["languageServer", "formatting"], + "activationPolicy": "onDemand", + "languageId": "roc" + }, + { + "id": "swift", + "fileExtensions": ["swift", "swiftinterface"], + "languageServerLaunch": { + "executableNames": ["custom-sourcekit-lsp"], + "arguments": ["--stdio"], + "environment": { + "SOURCEKIT_TOOLCHAIN": "custom" + }, + "initializationOptions": { + "indexing": true + } + }, + "languageServerInstallation": { + "homebrewFormula": "custom-sourcekit-lsp", + "officialDownloadURL": "https://example.com/sourcekit-lsp" + } + }, + { + "id": "perl", + "disabled": true + } + ] + }"#, + ) + .unwrap(); + + let catalog = provider_catalog(Some(&root)); + assert_eq!(catalog.origin, LspProviderCatalogOrigin::WorkspaceOverride); + assert!(catalog + .providers + .iter() + .any(|provider| provider.id == "roc")); + let swift = catalog + .providers + .iter() + .find(|provider| provider.id == "swift") + .expect("swift provider should still exist"); + assert!(swift + .file_extensions + .contains(&"swiftinterface".to_string())); + let swift_launch = swift + .language_server_launch + .as_ref() + .expect("swift launch descriptor should be overridden"); + assert_eq!( + swift_launch.executable_names, + vec!["custom-sourcekit-lsp".to_string()] + ); + assert_eq!(swift_launch.arguments, vec!["--stdio".to_string()]); + assert_eq!( + swift_launch.environment.get("SOURCEKIT_TOOLCHAIN"), + Some(&"custom".to_string()) + ); + assert_eq!( + swift_launch.initialization_options, + Some(json!({ "indexing": true })) + ); + let swift_installation = swift + .language_server_installation + .as_ref() + .expect("swift installation descriptor should be overridden"); + assert_eq!( + swift_installation.homebrew_formula.as_deref(), + Some("custom-sourcekit-lsp") + ); + assert!(!catalog + .providers + .iter() + .any(|provider| provider.id == "perl")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn ffi_json_is_a_standalone_catalog_document() { + let raw = provider_catalog_json(None); + let value: Value = serde_json::from_str(&raw).expect("catalog should be JSON"); + assert_eq!(value["version"], 2); + assert_eq!(value["origin"], "builtin"); + assert!(value["providers"].as_array().unwrap().len() > 10); + assert!(value.get("ok").is_none()); + assert!(value.get("command").is_none()); +} + +#[test] +fn project_catalog_reports_unknown_configuration_fields() { + let root = temporary_root("project-config-unknown-field"); + fs::create_dir_all(root.join(".lithe/lsp")).unwrap(); + fs::write( + root.join(".lithe/lsp/language-providers.json"), + r#"{ + "version": 2, + "providers": [{ "id": "go", "languageServerLanch": {} }] + }"#, + ) + .unwrap(); + + let catalog = provider_catalog(Some(&root)); + assert_eq!(catalog.origin, LspProviderCatalogOrigin::Builtin); + assert_eq!(catalog.diagnostics.len(), 1); + assert!(catalog.diagnostics[0].message.contains("unknown field")); + assert!(catalog.providers.iter().any(|provider| provider.id == "go")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn malformed_project_catalog_is_rejected_with_a_visible_diagnostic() { + let root = temporary_root("project-config-malformed"); + let config_path = root.join(".lithe/lsp/language-providers.json"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, r#"{"version":2,"providers":["#).unwrap(); + + let catalog = provider_catalog(Some(&root)); + + assert_eq!(catalog.origin, LspProviderCatalogOrigin::Builtin); + assert!(catalog.providers.iter().any(|provider| provider.id == "go")); + assert_eq!(catalog.diagnostics.len(), 1); + assert_eq!( + catalog.diagnostics[0].path, + config_path.display().to_string() + ); + assert!(catalog.diagnostics[0].message.contains("EOF")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn text_edits_use_lsp_utf16_positions() { + let response = apply_text_edits(ApplyTextEditsRequest { + text: "one 😀\ntwo three\n".to_string(), + edits: vec![ + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 0, + utf16_column: 4, + }, + end: LspPosition { + line: 0, + utf16_column: 6, + }, + }, + new_text: "rocket".to_string(), + }, + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 1, + utf16_column: 4, + }, + end: LspPosition { + line: 1, + utf16_column: 9, + }, + }, + new_text: "four".to_string(), + }, + ], + }) + .unwrap(); + + assert_eq!(response.text, "one rocket\ntwo four\n"); +} + +#[test] +fn text_edits_reject_invalid_and_overlapping_ranges() { + let invalid = apply_text_edits(ApplyTextEditsRequest { + text: "one line".to_string(), + edits: vec![LspTextEdit { + range: LspRange { + start: LspPosition { + line: 9, + utf16_column: 0, + }, + end: LspPosition { + line: 9, + utf16_column: 1, + }, + }, + new_text: "x".to_string(), + }], + }) + .unwrap_err(); + assert_eq!(invalid.details.as_deref(), Some("invalidRange")); + + let overlapping = apply_text_edits(ApplyTextEditsRequest { + text: "one line".to_string(), + edits: vec![ + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 0, + utf16_column: 0, + }, + end: LspPosition { + line: 0, + utf16_column: 4, + }, + }, + new_text: "a".to_string(), + }, + LspTextEdit { + range: LspRange { + start: LspPosition { + line: 0, + utf16_column: 2, + }, + end: LspPosition { + line: 0, + utf16_column: 6, + }, + }, + new_text: "b".to_string(), + }, + ], + }) + .unwrap_err(); + assert_eq!(overlapping.details.as_deref(), Some("overlappingEdits")); +} + +#[test] +fn snippet_plain_text_removes_tab_stops_and_keeps_defaults() { + assert_eq!(snippet_plain_text("print(${1:value})$0"), "print(value)"); + assert_eq!(snippet_plain_text("${1:let} ${2:name} = $3"), "let name = "); +} + +#[test] +fn builtin_completion_returns_current_file_identifiers_for_prefix() { + let response = builtin_completions(BuiltinRequest { + file_path: "/tmp/main.swift".to_string(), + text: "struct RocketShip {}\nlet rocketSpeed = Roc\n".to_string(), + position: LspPosition { + line: 1, + utf16_column: 19, + }, + }) + .unwrap(); + + assert!(response.items.iter().any(|item| item.label == "RocketShip")); + let item = response + .items + .iter() + .find(|item| item.label == "RocketShip") + .unwrap(); + assert_eq!(item.text_edit.range.start.utf16_column, 18); + assert_eq!(item.text_edit.new_text, "RocketShip"); +} + +#[test] +fn builtin_hover_returns_current_identifier_range() { + let response = builtin_hover(BuiltinRequest { + file_path: "/tmp/main.rs".to_string(), + text: "fn launch() {}\n".to_string(), + position: LspPosition { + line: 0, + utf16_column: 4, + }, + }) + .unwrap(); + + let hover = response.hover.unwrap(); + assert_eq!(hover.contents, "`launch`"); + assert_eq!(hover.range.start.utf16_column, 3); + assert_eq!(hover.range.end.utf16_column, 9); +} + +#[test] +fn builtin_navigation_prefers_declarations_and_finds_references() { + let text = "let service = 1\nprint(service)\n"; + let definitions = builtin_navigation(BuiltinNavigationRequest { + file_path: "/tmp/main.swift".to_string(), + text: text.to_string(), + position: LspPosition { + line: 1, + utf16_column: 8, + }, + method: "textDocument/definition".to_string(), + }) + .unwrap(); + assert_eq!(definitions.locations.len(), 1); + assert_eq!(definitions.locations[0].range.start.line, 0); + assert_eq!(definitions.locations[0].range.start.utf16_column, 4); + + let references = builtin_navigation(BuiltinNavigationRequest { + file_path: "/tmp/main.swift".to_string(), + text: text.to_string(), + position: LspPosition { + line: 1, + utf16_column: 8, + }, + method: "textDocument/references".to_string(), + }) + .unwrap(); + assert_eq!(references.locations.len(), 2); +} + +#[test] +fn file_uri_paths_decode_spaces_and_utf8_characters() { + assert_eq!( + file_path_from_uri("file:///tmp/go%20project/%E4%B8%AD%E6%96%87/main.go"), + "/tmp/go project/中文/main.go" + ); + assert_eq!( + file_path_from_uri("file://server/share/Main.java"), + "//server/share/Main.java" + ); +} + +#[test] +fn client_core_initializes_and_applies_server_capabilities() { + let initialized = client_initialize(ClientInitializeRequest { + state: LspClientState::default(), + root_uri: "file:///tmp/project".to_string(), + process_id: Some(42), + initialization_options: Some(json!({ + "ui.semanticTokens": true + })), + }) + .unwrap(); + assert_eq!( + initialized.state.pending_requests.get("1").unwrap(), + "initialize" + ); + let initialize_message: Value = + serde_json::from_str(&initialized.messages[0]).expect("initialize JSON"); + assert_eq!(initialize_message["method"], "initialize"); + assert_eq!( + initialize_message["params"]["rootUri"], + "file:///tmp/project" + ); + assert_eq!(initialize_message["params"]["clientInfo"]["name"], "Lithe"); + assert_eq!( + initialize_message["params"]["clientInfo"]["version"], + env!("CARGO_PKG_VERSION") + ); + assert_eq!( + initialize_message["params"]["workspaceFolders"], + json!([{ + "uri": "file:///tmp/project", + "name": "project" + }]) + ); + let client_capabilities = &initialize_message["params"]["capabilities"]; + assert_eq!(client_capabilities["workspace"]["configuration"], true); + assert_eq!(client_capabilities["workspace"]["applyEdit"], true); + assert_eq!(client_capabilities["workspace"]["workspaceFolders"], true); + assert_eq!( + client_capabilities["workspace"]["symbol"]["dynamicRegistration"], + true + ); + assert_eq!( + client_capabilities["textDocument"]["completion"]["completionItem"]["snippetSupport"], + true + ); + assert_eq!( + client_capabilities["textDocument"]["completion"]["completionItem"]["resolveSupport"] + ["properties"], + json!(["detail", "documentation", "textEdit", "additionalTextEdits"]) + ); + assert_eq!( + initialize_message["params"]["initializationOptions"]["ui.semanticTokens"], + true + ); + assert_eq!( + client_capabilities["textDocument"]["inlayHint"]["dynamicRegistration"], + true + ); + assert_eq!( + client_capabilities["textDocument"]["foldingRange"]["dynamicRegistration"], + true + ); + assert_eq!( + client_capabilities["textDocument"]["codeLens"]["dynamicRegistration"], + true + ); + assert!(client_capabilities["textDocument"]["synchronization"] + .get("didSave") + .is_none()); + assert_eq!( + client_capabilities["textDocument"]["publishDiagnostics"]["relatedInformation"], + true + ); + assert_eq!( + client_capabilities["textDocument"]["publishDiagnostics"]["versionSupport"], + true + ); + assert_eq!( + client_capabilities["textDocument"]["publishDiagnostics"]["tagSupport"]["valueSet"], + json!([1, 2]) + ); + assert_eq!(client_capabilities["window"]["workDoneProgress"], true); + + let applied = client_apply_server_message(ClientApplyServerMessageRequest { + state: initialized.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "capabilities": { + "definitionProvider": true, + "hoverProvider": true, + "completionProvider": { "resolveProvider": true }, + "codeActionProvider": { "resolveProvider": true }, + "inlayHintProvider": true, + "foldingRangeProvider": {}, + "codeLensProvider": { "resolveProvider": false }, + "workspaceSymbolProvider": true + } + } + }"# + .to_string(), + }) + .unwrap(); + + assert!(applied.state.initialized); + assert!(applied.state.pending_requests.is_empty()); + assert!(applied + .state + .server_capabilities + .contains(&"definition".to_string())); + assert!(applied + .state + .server_capabilities + .contains(&"completionResolve".to_string())); + for feature in [ + "inlayHints", + "foldingRanges", + "codeLens", + "workspaceSymbols", + ] { + assert!(applied + .state + .server_capabilities + .contains(&feature.to_string())); + } + assert_eq!(applied.messages.len(), 1); + let initialized_notification: Value = + serde_json::from_str(&applied.messages[0]).expect("initialized JSON"); + assert_eq!(initialized_notification["method"], "initialized"); +} + +#[test] +fn client_core_does_not_initialize_without_valid_capabilities() { + for message in [ + r#"{"jsonrpc":"2.0","id":"1","result":null}"#, + r#"{"jsonrpc":"2.0","id":"1","result":{}}"#, + r#"{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"rejected"}}"#, + ] { + let initialized = client_initialize(ClientInitializeRequest { + state: LspClientState::default(), + root_uri: "file:///tmp/project".to_string(), + process_id: None, + initialization_options: None, + }) + .unwrap(); + let applied = client_apply_server_message(ClientApplyServerMessageRequest { + state: initialized.state, + message: message.to_string(), + }) + .unwrap(); + + assert!(!applied.state.initialized); + assert!(applied.messages.is_empty()); + assert!(applied.state.server_capabilities.is_empty()); + } +} + +#[test] +fn client_core_tracks_documents_and_feature_requests() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/main.rs".to_string(), + language_id: "rust".to_string(), + text: "fn main() {}\n".to_string(), + }) + .unwrap(); + assert_eq!( + opened + .state + .open_documents + .get("file:///tmp/project/main.rs") + .unwrap() + .version, + 1 + ); + let did_open: Value = serde_json::from_str(&opened.messages[0]).unwrap(); + assert_eq!(did_open["method"], "textDocument/didOpen"); + + let changed = client_change_document(ClientChangeDocumentRequest { + state: opened.state, + uri: "file:///tmp/project/main.rs".to_string(), + text: "fn main() { launch(); }\n".to_string(), + }) + .unwrap(); + assert_eq!( + changed + .state + .open_documents + .get("file:///tmp/project/main.rs") + .unwrap() + .version, + 2 + ); + let did_change: Value = serde_json::from_str(&changed.messages[0]).unwrap(); + assert_eq!(did_change["method"], "textDocument/didChange"); + + let requested = client_feature_request(ClientFeatureRequest { + state: changed.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/definition".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 12, + }), + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + assert_eq!( + requested.state.pending_requests.get("1").unwrap(), + "textDocument/definition" + ); + let request_message: Value = serde_json::from_str(&requested.messages[0]).unwrap(); + assert_eq!(request_message["method"], "textDocument/definition"); + assert_eq!(request_message["params"]["position"]["character"], 12); +} + +#[test] +fn client_core_closes_open_documents() { + let uri = "file:///tmp/project/main.go"; + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: uri.to_string(), + language_id: "go".to_string(), + text: "package main\n".to_string(), + }) + .unwrap(); + let diagnosed = client_apply_server_message(ClientApplyServerMessageRequest { + state: opened.state, + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 1, + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 7 } + }, + "message": "Example diagnostic" + }] + } + }) + .to_string(), + }) + .unwrap(); + assert!(diagnosed.state.diagnostics.contains_key(uri)); + assert_eq!(diagnosed.state.diagnostic_versions.get(uri), Some(&1)); + + let closed = client_close_document(ClientCloseDocumentRequest { + state: diagnosed.state, + uri: uri.to_string(), + }) + .unwrap(); + + assert!(!closed.state.open_documents.contains_key(uri)); + assert!(!closed.state.diagnostics.contains_key(uri)); + assert!(!closed.state.diagnostic_versions.contains_key(uri)); + assert_eq!(closed.messages.len(), 1); + let did_close: Value = serde_json::from_str(&closed.messages[0]).unwrap(); + assert_eq!( + did_close, + json!({ + "jsonrpc": "2.0", + "method": "textDocument/didClose", + "params": { + "textDocument": { + "uri": uri + } + } + }) + ); + + let error = client_close_document(ClientCloseDocumentRequest { + state: closed.state, + uri: uri.to_string(), + }) + .unwrap_err(); + assert_eq!(serde_json::to_value(error.code).unwrap(), "invalid_request"); +} + +#[test] +fn client_core_ignores_diagnostics_for_unopened_or_stale_document_versions() { + let uri = "file:///tmp/project/main.rs"; + let unopened = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 1, + "diagnostics": [] + } + }) + .to_string(), + }) + .unwrap(); + assert!(unopened.events.is_empty()); + assert!(!unopened.state.diagnostics.contains_key(uri)); + + let opened = client_open_document(ClientOpenDocumentRequest { + state: unopened.state, + uri: uri.to_string(), + language_id: "rust".to_string(), + text: "fn main() {}\n".to_string(), + }) + .unwrap(); + let changed = client_change_document(ClientChangeDocumentRequest { + state: opened.state, + uri: uri.to_string(), + text: "fn main() { launch(); }\n".to_string(), + }) + .unwrap(); + + for stale_version in [1, 3] { + let stale = client_apply_server_message(ClientApplyServerMessageRequest { + state: changed.state.clone(), + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": stale_version, + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 2 } + }, + "message": "Stale diagnostic" + }] + } + }) + .to_string(), + }) + .unwrap(); + assert!(stale.events.is_empty()); + assert!(!stale.state.diagnostics.contains_key(uri)); + } + + let current = client_apply_server_message(ClientApplyServerMessageRequest { + state: changed.state, + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 2, + "diagnostics": [{ + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 2 } + }, + "message": "Current diagnostic" + }] + } + }) + .to_string(), + }) + .unwrap(); + assert_eq!(current.events.len(), 1); + assert_eq!(current.events[0].version, Some(2)); + assert_eq!(current.state.diagnostic_versions.get(uri), Some(&2)); + + let stale_after_current = client_apply_server_message(ClientApplyServerMessageRequest { + state: current.state, + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "version": 1, + "diagnostics": [] + } + }) + .to_string(), + }) + .unwrap(); + assert!(stale_after_current.events.is_empty()); + assert_eq!( + stale_after_current.state.diagnostics[uri][0].message, + "Current diagnostic" + ); + assert_eq!( + stale_after_current.state.diagnostic_versions.get(uri), + Some(&2) + ); + + let unversioned = client_apply_server_message(ClientApplyServerMessageRequest { + state: stale_after_current.state, + message: json!({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "diagnostics": [] + } + }) + .to_string(), + }) + .unwrap(); + assert_eq!(unversioned.events.len(), 1); + assert_eq!(unversioned.events[0].version, None); + assert!(!unversioned.state.diagnostic_versions.contains_key(uri)); + assert!(serde_json::to_value(&unversioned.events[0]) + .unwrap() + .get("version") + .is_none()); +} + +#[test] +fn client_state_keeps_legacy_diagnostics_shape_compatible() { + let uri = "file:///tmp/project/main.rs"; + let state: LspClientState = serde_json::from_value(json!({ + "diagnostics": { + uri: [] + } + })) + .unwrap(); + + assert!(state.diagnostics.contains_key(uri)); + assert!(state.diagnostic_versions.is_empty()); + let serialized = serde_json::to_value(state).unwrap(); + assert!(serialized["diagnostics"][uri].is_array()); + assert!(serialized.get("diagnosticVersions").is_none()); +} + +#[test] +fn client_core_waits_for_shutdown_response_before_exiting() { + let mut state = LspClientState { + initialized: true, + ..LspClientState::default() + }; + state.server_capabilities.push("completion".to_string()); + state.open_documents.insert( + "file:///tmp/project/main.go".to_string(), + LspClientDocument { + uri: "file:///tmp/project/main.go".to_string(), + language_id: "go".to_string(), + version: 1, + text: "package main\n".to_string(), + }, + ); + + let shutting_down = client_shutdown(ClientShutdownRequest { state }).unwrap(); + assert!(shutting_down.state.shutdown_requested); + assert_eq!( + shutting_down.state.pending_requests.get("1"), + Some(&"shutdown".to_string()) + ); + let shutdown: Value = serde_json::from_str(&shutting_down.messages[0]).unwrap(); + assert_eq!( + shutdown, + json!({ + "jsonrpc": "2.0", + "id": "1", + "method": "shutdown" + }) + ); + + let exited = client_apply_server_message(ClientApplyServerMessageRequest { + state: shutting_down.state, + message: json!({ + "jsonrpc": "2.0", + "id": "1", + "result": null + }) + .to_string(), + }) + .unwrap(); + + assert!(!exited.state.initialized); + assert!(!exited.state.shutdown_requested); + assert!(exited.state.pending_requests.is_empty()); + assert!(exited.state.server_capabilities.is_empty()); + assert!(exited.state.open_documents.is_empty()); + assert_eq!(exited.messages.len(), 1); + let exit: Value = serde_json::from_str(&exited.messages[0]).unwrap(); + assert_eq!( + exit, + json!({ + "jsonrpc": "2.0", + "method": "exit" + }) + ); + assert_eq!(exited.events.len(), 1); + assert_eq!(exited.events[0].method.as_deref(), Some("shutdown")); +} + +#[test] +fn client_core_rejects_duplicate_shutdown_requests() { + let shutting_down = client_shutdown(ClientShutdownRequest { + state: LspClientState::default(), + }) + .unwrap(); + + let error = client_shutdown(ClientShutdownRequest { + state: shutting_down.state, + }) + .unwrap_err(); + assert_eq!(serde_json::to_value(error.code).unwrap(), "invalid_request"); +} + +#[test] +fn frame_message_uses_lsp_content_length_bytes() { + let message = r#"{"jsonrpc":"2.0","method":"window/logMessage","params":{"message":"你好"}}"#; + let framed = frame_message(FrameMessageRequest { + message: message.to_string(), + }) + .unwrap(); + assert!(framed + .frame + .starts_with(&format!("Content-Length: {}\r\n\r\n", message.len()))); + assert!(framed.frame.ends_with(message)); +} + +#[test] +fn parse_server_messages_returns_complete_messages_and_remaining_buffer() { + let first = r#"{"jsonrpc":"2.0","id":1,"result":null}"#; + let second = r#"{"jsonrpc":"2.0","method":"window/logMessage","params":{"message":"ok"}}"#; + let first_frame = frame_message(FrameMessageRequest { + message: first.to_string(), + }) + .unwrap() + .frame; + let second_frame = frame_message(FrameMessageRequest { + message: second.to_string(), + }) + .unwrap() + .frame; + let split_at = first_frame.len() - 3; + let partial = parse_server_messages(ParseServerMessagesRequest { + buffer: Vec::new(), + chunk: first_frame.as_bytes()[..split_at].to_vec(), + }) + .unwrap(); + assert!(partial.messages.is_empty()); + assert_eq!(partial.buffer, first_frame.as_bytes()[..split_at]); + + let mut next_chunk = first_frame.as_bytes()[split_at..].to_vec(); + next_chunk.extend(second_frame.as_bytes()); + let parsed = parse_server_messages(ParseServerMessagesRequest { + buffer: partial.buffer, + chunk: next_chunk, + }) + .unwrap(); + assert_eq!(parsed.messages, vec![first.to_string(), second.to_string()]); + assert!(parsed.buffer.is_empty()); +} + +#[test] +fn client_core_shapes_feature_responses_for_swift_models() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/main.rs".to_string(), + language_id: "rust".to_string(), + text: "fn main() { la }\n".to_string(), + }) + .unwrap(); + let requested = client_feature_request(ClientFeatureRequest { + state: opened.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/completion".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 14, + }), + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let completed = client_apply_server_message(ClientApplyServerMessageRequest { + state: requested.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "1", + "result": { + "items": [{ + "label": "launch", + "kind": 3, + "detail": "fn()", + "textEdit": { + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 14 } + }, + "newText": "launch" + } + }] + } + }"# + .to_string(), + }) + .unwrap(); + + let result = completed.events[0].result.as_ref().unwrap(); + assert_eq!(result["items"][0]["label"], "launch"); + assert_eq!( + result["items"][0]["textEdit"]["range"]["start"]["utf16Column"], + 12 + ); + + let rename = client_feature_request(ClientFeatureRequest { + state: completed.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/rename".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 12, + }), + new_name: Some("start".to_string()), + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let renamed = client_apply_server_message(ClientApplyServerMessageRequest { + state: rename.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "2", + "result": { + "changes": { + "file:///tmp/project/main.rs": [{ + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 18 } + }, + "newText": "start" + }] + } + } + }"# + .to_string(), + }) + .unwrap(); + let rename_result = renamed.events[0].result.as_ref().unwrap(); + assert_eq!( + rename_result["changes"]["/tmp/project/main.rs"][0]["newText"], + "start" + ); + + let formatting = client_feature_request(ClientFeatureRequest { + state: renamed.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/formatting".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let formatted = client_apply_server_message(ClientApplyServerMessageRequest { + state: formatting.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "3", + "result": [{ + "range": { + "start": { "line": 0, "character": 2 }, + "end": { "line": 0, "character": 2 } + }, + "newText": " " + }] + }"# + .to_string(), + }) + .unwrap(); + let format_result = formatted.events[0].result.as_ref().unwrap(); + assert_eq!( + format_result["edits"][0]["range"]["start"]["utf16Column"], + 2 + ); + + let code_actions = client_feature_request(ClientFeatureRequest { + state: formatted.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "textDocument/codeAction".to_string(), + position: None, + new_name: None, + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 0, + }, + end: LspPosition { + line: 0, + utf16_column: 0, + }, + }), + diagnostics: vec![LspClientDiagnostic { + range: LspRangeResponse { + start: LspPositionResponse { + line: 0, + utf16_column: 12, + }, + end: LspPositionResponse { + line: 0, + utf16_column: 18, + }, + }, + severity: Some(2), + message: "rename suggestion".to_string(), + source: Some("rust-analyzer".to_string()), + code: None, + tags: vec![1, 99], + related_information: vec![LspClientDiagnosticRelatedInformation { + location: LspClientDiagnosticLocation { + uri: "file:///tmp/project/lib.rs".to_string(), + range: LspRangeResponse { + start: LspPositionResponse { + line: 3, + utf16_column: 4, + }, + end: LspPositionResponse { + line: 3, + utf16_column: 8, + }, + }, + }, + message: "declared here".to_string(), + }], + }], + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let code_action_request: Value = serde_json::from_str(&code_actions.messages[0]).unwrap(); + assert_eq!(code_action_request["method"], "textDocument/codeAction"); + assert_eq!( + code_action_request["params"]["context"]["diagnostics"][0]["range"]["start"]["character"], + 12 + ); + assert_eq!( + code_action_request["params"]["context"]["diagnostics"][0]["tags"], + json!([1, 99]) + ); + assert_eq!( + code_action_request["params"]["context"]["diagnostics"][0]["relatedInformation"][0] + ["location"]["uri"], + "file:///tmp/project/lib.rs" + ); + assert_eq!( + code_action_request["params"]["context"]["diagnostics"][0]["relatedInformation"][0] + ["location"]["range"]["start"]["character"], + 4 + ); + let code_actioned = client_apply_server_message(ClientApplyServerMessageRequest { + state: code_actions.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "4", + "result": [{ + "title": "Apply rename", + "kind": "quickfix", + "isPreferred": true, + "edit": { + "changes": { + "file:///tmp/project/main.rs": [{ + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 18 } + }, + "newText": "start" + }] + } + }, + "command": { + "title": "Apply", + "command": "rust-analyzer.applySourceChange", + "arguments": [{ "label": "rename" }] + }, + "data": { "id": "action-1" } + }] + }"# + .to_string(), + }) + .unwrap(); + let action_result = code_actioned.events[0].result.as_ref().unwrap(); + assert_eq!(action_result["actions"][0]["title"], "Apply rename"); + assert_eq!( + action_result["actions"][0]["edit"]["changes"]["/tmp/project/main.rs"][0]["newText"], + "start" + ); + assert_eq!( + action_result["actions"][0]["command"]["command"], + "rust-analyzer.applySourceChange" + ); + + let code_action_resolve = client_feature_request(ClientFeatureRequest { + state: code_actioned.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "codeAction/resolve".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: Some(json!({ + "title": "Apply rename", + "kind": "quickfix", + "isPreferred": true, + "data": { "id": "action-1" } + })), + command: None, + }) + .unwrap(); + let code_action_resolve_request: Value = + serde_json::from_str(&code_action_resolve.messages[0]).unwrap(); + assert_eq!(code_action_resolve_request["method"], "codeAction/resolve"); + assert_eq!( + code_action_resolve_request["params"]["title"], + "Apply rename" + ); + let code_action_resolved = client_apply_server_message(ClientApplyServerMessageRequest { + state: code_action_resolve.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "5", + "result": { + "title": "Apply rename", + "kind": "quickfix", + "edit": { + "changes": { + "file:///tmp/project/main.rs": [{ + "range": { + "start": { "line": 0, "character": 12 }, + "end": { "line": 0, "character": 18 } + }, + "newText": "start" + }] + } + }, + "data": { "id": "action-1" } + } + }"# + .to_string(), + }) + .unwrap(); + let code_action_resolve_result = code_action_resolved.events[0].result.as_ref().unwrap(); + assert_eq!( + code_action_resolve_result["action"]["edit"]["changes"]["/tmp/project/main.rs"][0] + ["newText"], + "start" + ); + + let completion_resolve = client_feature_request(ClientFeatureRequest { + state: code_action_resolved.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "completionItem/resolve".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: Some(json!({ + "label": "launch", + "insertText": "launch", + "kind": 3, + "textEdit": { + "range": { + "start": { "line": 0, "utf16Column": 12 }, + "end": { "line": 0, "utf16Column": 14 } + }, + "newText": "launch" + }, + "data": { "id": "completion-1" } + })), + code_action: None, + command: None, + }) + .unwrap(); + let completion_resolve_request: Value = + serde_json::from_str(&completion_resolve.messages[0]).unwrap(); + assert_eq!( + completion_resolve_request["method"], + "completionItem/resolve" + ); + assert_eq!( + completion_resolve_request["params"]["textEdit"]["range"]["start"]["character"], + 12 + ); + let completion_resolved = client_apply_server_message(ClientApplyServerMessageRequest { + state: completion_resolve.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "6", + "result": { + "label": "launch", + "kind": 3, + "detail": "fn launch()", + "documentation": { "kind": "markdown", "value": "Launches the app." }, + "insertText": "launch", + "data": { "id": "completion-1" } + } + }"# + .to_string(), + }) + .unwrap(); + let completion_resolve_result = completion_resolved.events[0].result.as_ref().unwrap(); + assert_eq!( + completion_resolve_result["item"]["documentation"], + "Launches the app." + ); + + let execute_command = client_feature_request(ClientFeatureRequest { + state: completion_resolved.state, + uri: "file:///tmp/project/main.rs".to_string(), + method: "workspace/executeCommand".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: Some(json!({ + "title": "Apply", + "command": "rust-analyzer.applySourceChange", + "arguments": [{ "label": "rename" }] + })), + }) + .unwrap(); + let execute_request: Value = serde_json::from_str(&execute_command.messages[0]).unwrap(); + assert_eq!(execute_request["method"], "workspace/executeCommand"); + assert_eq!( + execute_request["params"]["command"], + "rust-analyzer.applySourceChange" + ); + let executed = client_apply_server_message(ClientApplyServerMessageRequest { + state: execute_command.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "7", + "result": null + }"# + .to_string(), + }) + .unwrap(); + assert_eq!(executed.events[0].result.as_ref().unwrap()["ok"], true); +} + +#[test] +fn client_core_requests_and_shapes_inlay_hints_folding_ranges_and_code_lenses() { + let uri = "file:///tmp/project/Main.java"; + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: uri.to_string(), + language_id: "java".to_string(), + text: "class Main {}\n".to_string(), + }) + .unwrap(); + + let inlay_request = client_feature_request(ClientFeatureRequest { + state: opened.state, + uri: uri.to_string(), + method: "textDocument/inlayHint".to_string(), + position: None, + new_name: None, + range: Some(LspRange { + start: LspPosition { + line: 0, + utf16_column: 0, + }, + end: LspPosition { + line: 20, + utf16_column: 0, + }, + }), + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let request_json: Value = serde_json::from_str(&inlay_request.messages[0]).unwrap(); + assert_eq!(request_json["method"], "textDocument/inlayHint"); + assert_eq!(request_json["params"]["range"]["end"]["line"], 20); + + let inlay_response = client_apply_server_message(ClientApplyServerMessageRequest { + state: inlay_request.state, + message: json!({ + "jsonrpc": "2.0", + "id": "1", + "result": [{ + "position": { "line": 4, "character": 12 }, + "label": [{ "value": "parameter" }, { "value": ":" }], + "kind": 2, + "tooltip": { "kind": "markdown", "value": "Parameter name" }, + "paddingLeft": true, + "paddingRight": false, + "textEdits": [{ + "range": { + "start": { "line": 4, "character": 12 }, + "end": { "line": 4, "character": 12 } + }, + "newText": "parameter: " + }], + "data": { "id": "hint-1" } + }] + }) + .to_string(), + }) + .unwrap(); + let hint = &inlay_response.events[0].result.as_ref().unwrap()["hints"][0]; + assert_eq!(hint["position"]["utf16Column"], 12); + assert_eq!(hint["label"], "parameter:"); + assert_eq!(hint["kind"], 2); + assert_eq!(hint["tooltip"], "Parameter name"); + assert_eq!(hint["paddingLeft"], true); + assert_eq!(hint["textEdits"][0]["range"]["start"]["utf16Column"], 12); + assert_eq!(hint["data"]["id"], "hint-1"); + + let folding_request = client_feature_request(ClientFeatureRequest { + state: inlay_response.state, + uri: uri.to_string(), + method: "textDocument/foldingRange".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let request_json: Value = serde_json::from_str(&folding_request.messages[0]).unwrap(); + assert_eq!(request_json["method"], "textDocument/foldingRange"); + assert_eq!(request_json["params"]["textDocument"]["uri"], uri); + let folding_response = client_apply_server_message(ClientApplyServerMessageRequest { + state: folding_request.state, + message: json!({ + "jsonrpc": "2.0", + "id": "2", + "result": [{ + "startLine": 2, + "startCharacter": 4, + "endLine": 8, + "endCharacter": 1, + "kind": "region", + "collapsedText": "methods" + }] + }) + .to_string(), + }) + .unwrap(); + let range = &folding_response.events[0].result.as_ref().unwrap()["ranges"][0]; + assert_eq!(range["startLine"], 2); + assert_eq!(range["startUtf16Column"], 4); + assert_eq!(range["endLine"], 8); + assert_eq!(range["endUtf16Column"], 1); + assert_eq!(range["kind"], "region"); + assert_eq!(range["collapsedText"], "methods"); + + let code_lens_request = client_feature_request(ClientFeatureRequest { + state: folding_response.state, + uri: uri.to_string(), + method: "textDocument/codeLens".to_string(), + position: None, + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let code_lens_response = client_apply_server_message(ClientApplyServerMessageRequest { + state: code_lens_request.state, + message: json!({ + "jsonrpc": "2.0", + "id": "3", + "result": [{ + "range": { + "start": { "line": 3, "character": 2 }, + "end": { "line": 3, "character": 7 } + }, + "command": { + "title": "Run test", + "command": "java.test.run", + "arguments": [{ "test": "MainTest" }] + }, + "data": { "id": "lens-1" } + }] + }) + .to_string(), + }) + .unwrap(); + let lens = &code_lens_response.events[0].result.as_ref().unwrap()["lenses"][0]; + assert_eq!(lens["range"]["start"]["utf16Column"], 2); + assert_eq!(lens["command"]["command"], "java.test.run"); + assert_eq!(lens["command"]["arguments"][0]["test"], "MainTest"); + assert_eq!(lens["data"]["id"], "lens-1"); +} + +#[test] +fn navigation_locations_preserve_uris_without_fabricating_virtual_file_paths() { + let uri = "file:///tmp/project/Main.java"; + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: uri.to_string(), + language_id: "java".to_string(), + text: "class Main {}\n".to_string(), + }) + .unwrap(); + let requested = client_feature_request(ClientFeatureRequest { + state: opened.state, + uri: uri.to_string(), + method: "textDocument/definition".to_string(), + position: Some(LspPosition { + line: 0, + utf16_column: 6, + }), + new_name: None, + range: None, + diagnostics: Vec::new(), + completion_item: None, + code_action: None, + command: None, + }) + .unwrap(); + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: requested.state, + message: json!({ + "jsonrpc": "2.0", + "id": "1", + "result": [{ + "uri": "file:///tmp/project/Main%20File.java", + "range": { + "start": { "line": 1, "character": 2 }, + "end": { "line": 1, "character": 6 } + } + }, { + "uri": "jdt://contents/java.base/java/lang/String.class", + "range": { + "start": { "line": 10, "character": 4 }, + "end": { "line": 10, "character": 10 } + } + }] + }) + .to_string(), + }) + .unwrap(); + let locations = &response.events[0].result.as_ref().unwrap()["locations"]; + + assert_eq!(locations[0]["uri"], "file:///tmp/project/Main%20File.java"); + assert_eq!(locations[0]["filePath"], "/tmp/project/Main File.java"); + assert_eq!(locations[0]["isReadOnly"], false); + assert_eq!( + locations[1]["uri"], + "jdt://contents/java.base/java/lang/String.class" + ); + assert!(locations[1]["filePath"].is_null()); + assert_eq!(locations[1]["isReadOnly"], true); + assert!(locations[1]["displayPath"].is_null()); +} + +#[test] +fn client_core_applies_diagnostics_and_dynamic_registrations() { + let opened = client_open_document(ClientOpenDocumentRequest { + state: LspClientState::default(), + uri: "file:///tmp/project/main.py".to_string(), + language_id: "python".to_string(), + text: "example = value\n".to_string(), + }) + .unwrap(); + let diagnostics = client_apply_server_message(ClientApplyServerMessageRequest { + state: opened.state, + message: r#"{ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": "file:///tmp/project/main.py", + "version": 1, + "diagnostics": [{ + "range": { + "start": { "line": 2, "character": 4 }, + "end": { "line": 2, "character": 9 } + }, + "source": "pyright", + "code": "reportGeneralTypeIssues", + "message": "Example diagnostic", + "tags": [1, 2, 99], + "relatedInformation": [{ + "location": { + "uri": "file:///tmp/project/types.py", + "range": { + "start": { "line": 8, "character": 2 }, + "end": { "line": 8, "character": 7 } + } + }, + "message": "Type declared here" + }] + }] + } + }"# + .to_string(), + }) + .unwrap(); + let stored = diagnostics + .state + .diagnostics + .get("file:///tmp/project/main.py") + .unwrap(); + assert_eq!(stored[0].message, "Example diagnostic"); + assert_eq!(stored[0].range.start.utf16_column, 4); + assert_eq!(stored[0].severity, None); + assert_eq!(stored[0].tags, vec![1, 2, 99]); + assert_eq!(stored[0].related_information.len(), 1); + assert_eq!( + stored[0].related_information[0].location.uri, + "file:///tmp/project/types.py" + ); + assert_eq!( + stored[0].related_information[0] + .location + .range + .start + .utf16_column, + 2 + ); + assert_eq!( + stored[0].related_information[0].message, + "Type declared here" + ); + assert_eq!(diagnostics.events[0].kind, "diagnostics"); + assert_eq!(diagnostics.events[0].version, Some(1)); + assert_eq!( + diagnostics + .state + .diagnostic_versions + .get("file:///tmp/project/main.py"), + Some(&1) + ); + let event_json = serde_json::to_value(&diagnostics.events[0]).unwrap(); + assert_eq!(event_json["version"], 1); + assert_eq!(event_json["diagnostics"][0]["tags"], json!([1, 2, 99])); + assert_eq!( + event_json["diagnostics"][0]["relatedInformation"][0]["location"]["range"]["start"] + ["utf16Column"], + 2 + ); + + let registered = client_apply_server_message(ClientApplyServerMessageRequest { + state: diagnostics.state, + message: r#"{ + "jsonrpc": "2.0", + "id": 77, + "method": "client/registerCapability", + "params": { + "registrations": [{ + "id": "formatting", + "method": "textDocument/formatting", + "registerOptions": {} + }, { + "id": "inlay-hints", + "method": "textDocument/inlayHint", + "registerOptions": {} + }, { + "id": "folding-ranges", + "method": "textDocument/foldingRange", + "registerOptions": {} + }, { + "id": "code-lens", + "method": "textDocument/codeLens", + "registerOptions": {} + }, { + "id": "workspace-symbols", + "method": "workspace/symbol", + "registerOptions": {} + }] + } + }"# + .to_string(), + }) + .unwrap(); + assert!(registered + .state + .server_capabilities + .contains(&"formatting".to_string())); + for feature in [ + "inlayHints", + "foldingRanges", + "codeLens", + "workspaceSymbols", + ] { + assert!(registered + .state + .server_capabilities + .contains(&feature.to_string())); + } + let response: Value = serde_json::from_str(®istered.messages[0]).unwrap(); + assert_eq!( + response, + json!({ "jsonrpc": "2.0", "id": 77, "result": null }) + ); + + let unregistered = client_apply_server_message(ClientApplyServerMessageRequest { + state: registered.state, + message: r#"{ + "jsonrpc": "2.0", + "id": "unregister-1", + "method": "client/unregisterCapability", + "params": { + "unregisterations": [{ + "id": "formatting", + "method": "textDocument/formatting" + }, { + "id": "inlay-hints", + "method": "textDocument/inlayHint" + }, { + "id": "folding-ranges", + "method": "textDocument/foldingRange" + }, { + "id": "code-lens", + "method": "textDocument/codeLens" + }, { + "id": "workspace-symbols", + "method": "workspace/symbol" + }] + } + }"# + .to_string(), + }) + .unwrap(); + assert!(!unregistered + .state + .server_capabilities + .contains(&"formatting".to_string())); + for feature in [ + "inlayHints", + "foldingRanges", + "codeLens", + "workspaceSymbols", + ] { + assert!(!unregistered + .state + .server_capabilities + .contains(&feature.to_string())); + } + let response: Value = serde_json::from_str(&unregistered.messages[0]).unwrap(); + assert_eq!( + response, + json!({ "jsonrpc": "2.0", "id": "unregister-1", "result": null }) + ); +} + +#[test] +fn client_core_answers_workspace_configuration_requests_by_item() { + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: r#"{ + "jsonrpc": "2.0", + "id": "configuration-1", + "method": "workspace/configuration", + "params": { + "items": [ + { "section": "gopls" }, + { "scopeUri": "file:///tmp/project", "section": "gopls.ui" } + ] + } + }"# + .to_string(), + }) + .unwrap(); + + assert!(response.events.is_empty()); + assert_eq!(response.messages.len(), 1); + let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); + assert_eq!( + message, + json!({ + "jsonrpc": "2.0", + "id": "configuration-1", + "result": [null, null] + }) + ); +} + +#[test] +fn client_core_answers_workspace_folder_and_progress_requests() { + for (method, id) in [ + ("workspace/workspaceFolders", json!(42)), + ("window/workDoneProgress/create", json!("progress-1")), + ] { + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": {} + }) + .to_string(), + }) + .unwrap(); + + assert!(response.events.is_empty()); + assert_eq!(response.messages.len(), 1); + let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); + assert_eq!( + message, + json!({ "jsonrpc": "2.0", "id": id, "result": null }) + ); + } +} + +#[test] +fn client_core_rejects_unknown_server_requests_with_method_not_found() { + let response = client_apply_server_message(ClientApplyServerMessageRequest { + state: LspClientState::default(), + message: r#"{ + "jsonrpc": "2.0", + "id": 91, + "method": "experimental/notSupported", + "params": { "value": true } + }"# + .to_string(), + }) + .unwrap(); + + assert!(response.events.is_empty()); + assert_eq!(response.messages.len(), 1); + let message: Value = serde_json::from_str(&response.messages[0]).unwrap(); + assert_eq!( + message, + json!({ + "jsonrpc": "2.0", + "id": 91, + "error": { + "code": -32601, + "message": "Method not found" + } + }) + ); +} diff --git a/rust/lithe-core/src/workspace.rs b/rust/lithe-core/src/project/files.rs similarity index 97% rename from rust/lithe-core/src/workspace.rs rename to rust/lithe-core/src/project/files.rs index 33b25a27c..1282365ac 100644 --- a/rust/lithe-core/src/workspace.rs +++ b/rust/lithe-core/src/project/files.rs @@ -1,5 +1,5 @@ -use crate::error::{invalid_relative_path, CoreError, ErrorCode}; -use crate::model::{ +use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; +use crate::protocol::{ FileReadResponse, FileWriteResponse, ReplacementPreviewResponse, SearchMatch, SearchResponse, WorkspaceNode, WorkspaceSnapshotResponse, }; @@ -146,7 +146,7 @@ pub fn search(request: SearchRequest) -> Result { let mut file_matches = 0; for path in &snapshot.files { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; if matches.len() >= limit || file_matches >= file_limit { break; } @@ -167,7 +167,7 @@ pub fn search(request: SearchRequest) -> Result { let mut content_matches = 0; for path in &snapshot.files { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; if matches.len() >= limit || content_matches >= content_limit { break; } @@ -190,7 +190,7 @@ pub fn search(request: SearchRequest) -> Result { Err(_) => continue, }; for (index, line) in text.split('\n').enumerate() { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; if matcher.matches(line) { matches.push(SearchMatch { kind: "content".to_string(), @@ -233,7 +233,7 @@ pub fn search_everywhere(request: SearchRequest) -> Result= symbol_limit && symbols.len() >= symbol_limit { break; } @@ -245,7 +245,7 @@ pub fn search_everywhere(request: SearchRequest) -> Result 0 { - matches.push(crate::model::ReplacementMatch { + matches.push(crate::protocol::ReplacementMatch { line: index + 1, before: line.to_string(), after, @@ -336,7 +336,7 @@ pub fn replace_preview( } } if !matches.is_empty() { - files.push(crate::model::ReplacementFile { + files.push(crate::protocol::ReplacementFile { path: relative, matches, replacement_text: replaced_lines.join("\n"), @@ -427,7 +427,7 @@ fn scan_node( rules: &VisibilityRules, files: &mut Vec, ) -> Result { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; let metadata = fs::symlink_metadata(path)?; let relative = relative_path(path, root); let name = path @@ -481,7 +481,7 @@ fn scan_node( let mut visible_children = Vec::new(); for (child_path, _) in children { - crate::cancellation::check()?; + crate::protocol::cancellation::check()?; if let Ok(child) = scan_node(&child_path, root, rules, files) { visible_children.push(child); } diff --git a/rust/lithe-core/src/history.rs b/rust/lithe-core/src/project/history.rs similarity index 99% rename from rust/lithe-core/src/history.rs rename to rust/lithe-core/src/project/history.rs index ad9404378..b2fa348e8 100644 --- a/rust/lithe-core/src/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -1,5 +1,5 @@ -use crate::error::{invalid_relative_path, CoreError, ErrorCode}; -use crate::model::{HistoryEntriesResponse, HistoryEntryResponse}; +use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; +use crate::protocol::{HistoryEntriesResponse, HistoryEntryResponse}; use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; diff --git a/rust/lithe-core/src/markdown.rs b/rust/lithe-core/src/project/markdown.rs similarity index 100% rename from rust/lithe-core/src/markdown.rs rename to rust/lithe-core/src/project/markdown.rs diff --git a/rust/lithe-core/src/maven.rs b/rust/lithe-core/src/project/maven.rs similarity index 99% rename from rust/lithe-core/src/maven.rs rename to rust/lithe-core/src/project/maven.rs index caf4a9f59..a87e6128d 100644 --- a/rust/lithe-core/src/maven.rs +++ b/rust/lithe-core/src/project/maven.rs @@ -1,5 +1,5 @@ -use crate::error::{CoreError, ErrorCode}; -use crate::model::{ +use crate::protocol::{CoreError, ErrorCode}; +use crate::protocol::{ MavenDiagnosticResponse, MavenDiagnosticsResponse, MavenModuleResponse, MavenProfileResponse, MavenScanResponse, }; diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs new file mode 100644 index 000000000..1063f6eca --- /dev/null +++ b/rust/lithe-core/src/project/mod.rs @@ -0,0 +1,11 @@ +//! Project files, search, local history, and document rendering services. + +mod files; +mod history; +mod markdown; +mod maven; + +pub(crate) use files::*; +pub(crate) use history::*; +pub(crate) use markdown::*; +pub(crate) use maven::*; diff --git a/rust/lithe-core/src/cancellation.rs b/rust/lithe-core/src/protocol/cancellation.rs similarity index 97% rename from rust/lithe-core/src/cancellation.rs rename to rust/lithe-core/src/protocol/cancellation.rs index 0e924e5b7..174fbd053 100644 --- a/rust/lithe-core/src/cancellation.rs +++ b/rust/lithe-core/src/protocol/cancellation.rs @@ -1,4 +1,4 @@ -use crate::error::{CoreError, ErrorCode}; +use crate::protocol::{CoreError, ErrorCode}; use std::cell::RefCell; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; @@ -95,7 +95,7 @@ fn registry() -> &'static Mutex>> { #[cfg(test)] mod tests { use super::{cancel, check, Scope}; - use crate::error::ErrorCode; + use crate::protocol::ErrorCode; use std::thread; use std::time::Duration; diff --git a/rust/lithe-core/src/command.rs b/rust/lithe-core/src/protocol/command.rs similarity index 54% rename from rust/lithe-core/src/command.rs rename to rust/lithe-core/src/protocol/command.rs index a6ee116c3..5dc03a9e5 100644 --- a/rust/lithe-core/src/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -31,6 +31,31 @@ pub enum CoreCommand { MavenScan, MavenDiagnostics, MarkdownRender, + LspApplyTextEdits, + LspPlainSnippet, + LspBuiltinCompletions, + LspBuiltinHover, + LspBuiltinNavigation, + LspStartServer, + LspStopServer, + LspSyncDocument, + LspCloseDocument, + LspRequest, + LspCancelOperation, + LspPollEvents, + LspClearDiagnostics, + LspSnapshot, + LspDestroyServer, + LspClientInitialize, + LspClientOpenDocument, + LspClientChangeDocument, + LspClientCloseDocument, + LspClientShutdown, + LspClientRequest, + LspClientApplyServerMessage, + LspSessionExecute, + LspFrameMessage, + LspParseServerMessages, JavaRunConfigurations, RunConfigInspect, RunConfigGenerate, @@ -78,6 +103,31 @@ impl CoreCommand { "maven.scan" => Some(Self::MavenScan), "maven.diagnostics" => Some(Self::MavenDiagnostics), "markdown.render" => Some(Self::MarkdownRender), + "lsp.applyTextEdits" => Some(Self::LspApplyTextEdits), + "lsp.plainSnippet" => Some(Self::LspPlainSnippet), + "lsp.builtinCompletions" => Some(Self::LspBuiltinCompletions), + "lsp.builtinHover" => Some(Self::LspBuiltinHover), + "lsp.builtinNavigation" => Some(Self::LspBuiltinNavigation), + "lsp.startServer" => Some(Self::LspStartServer), + "lsp.stopServer" => Some(Self::LspStopServer), + "lsp.syncDocument" => Some(Self::LspSyncDocument), + "lsp.closeDocument" => Some(Self::LspCloseDocument), + "lsp.request" => Some(Self::LspRequest), + "lsp.cancelOperation" => Some(Self::LspCancelOperation), + "lsp.pollEvents" => Some(Self::LspPollEvents), + "lsp.clearDiagnostics" => Some(Self::LspClearDiagnostics), + "lsp.snapshot" => Some(Self::LspSnapshot), + "lsp.destroyServer" => Some(Self::LspDestroyServer), + "lsp.clientInitialize" => Some(Self::LspClientInitialize), + "lsp.clientOpenDocument" => Some(Self::LspClientOpenDocument), + "lsp.clientChangeDocument" => Some(Self::LspClientChangeDocument), + "lsp.clientCloseDocument" => Some(Self::LspClientCloseDocument), + "lsp.clientShutdown" => Some(Self::LspClientShutdown), + "lsp.clientRequest" => Some(Self::LspClientRequest), + "lsp.clientApplyServerMessage" => Some(Self::LspClientApplyServerMessage), + "lsp.sessionExecute" => Some(Self::LspSessionExecute), + "lsp.frameMessage" => Some(Self::LspFrameMessage), + "lsp.parseServerMessages" => Some(Self::LspParseServerMessages), "java.runConfigurations" => Some(Self::JavaRunConfigurations), "runConfig.inspect" => Some(Self::RunConfigInspect), "runConfig.generate" => Some(Self::RunConfigGenerate), @@ -110,3 +160,42 @@ impl CoreCommand { } } } + +#[cfg(test)] +mod tests { + use super::CoreCommand; + + #[test] + fn parses_lsp_document_and_session_close_commands() { + assert!(matches!( + CoreCommand::parse("lsp.clientCloseDocument"), + Some(CoreCommand::LspClientCloseDocument) + )); + assert!(matches!( + CoreCommand::parse("lsp.clientShutdown"), + Some(CoreCommand::LspClientShutdown) + )); + assert!(matches!( + CoreCommand::parse("lsp.sessionExecute"), + Some(CoreCommand::LspSessionExecute) + )); + } + + #[test] + fn parses_semantic_lsp_runtime_commands() { + for command in [ + "lsp.startServer", + "lsp.stopServer", + "lsp.syncDocument", + "lsp.closeDocument", + "lsp.request", + "lsp.cancelOperation", + "lsp.pollEvents", + "lsp.clearDiagnostics", + "lsp.snapshot", + "lsp.destroyServer", + ] { + assert!(CoreCommand::parse(command).is_some(), "missing {command}"); + } + } +} diff --git a/rust/lithe-core/src/model.rs b/rust/lithe-core/src/protocol/contracts.rs similarity index 99% rename from rust/lithe-core/src/model.rs rename to rust/lithe-core/src/protocol/contracts.rs index 39b567f6f..ade97f94f 100644 --- a/rust/lithe-core/src/model.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -1,4 +1,4 @@ -use crate::error::CoreError; +use crate::protocol::CoreError; use serde::Serialize; use serde_json::Value; diff --git a/rust/lithe-core/src/error.rs b/rust/lithe-core/src/protocol/error.rs similarity index 100% rename from rust/lithe-core/src/error.rs rename to rust/lithe-core/src/protocol/error.rs diff --git a/rust/lithe-core/src/event.rs b/rust/lithe-core/src/protocol/event.rs similarity index 74% rename from rust/lithe-core/src/event.rs rename to rust/lithe-core/src/protocol/event.rs index 959f1fcad..3a8ca6e6c 100644 --- a/rust/lithe-core/src/event.rs +++ b/rust/lithe-core/src/protocol/event.rs @@ -1,5 +1,5 @@ -use crate::error::CoreError; -use crate::model::{GitStatusResponse, SearchResponse, WorkspaceSnapshotResponse}; +use crate::protocol::CoreError; +use crate::protocol::{GitStatusResponse, SearchResponse, WorkspaceSnapshotResponse}; use serde::Serialize; #[derive(Debug, Clone, Serialize)] diff --git a/rust/lithe-core/src/protocol/mod.rs b/rust/lithe-core/src/protocol/mod.rs new file mode 100644 index 000000000..d060af011 --- /dev/null +++ b/rust/lithe-core/src/protocol/mod.rs @@ -0,0 +1,12 @@ +//! Stable command, response, error, event, and cancellation contracts. + +pub(crate) mod cancellation; +mod command; +mod contracts; +mod error; +mod event; + +pub use command::*; +pub use contracts::*; +pub use error::*; +pub use event::*; diff --git a/rust/lithe-core/src/runtime.rs b/rust/lithe-core/src/runtime/dispatcher.rs similarity index 51% rename from rust/lithe-core/src/runtime.rs rename to rust/lithe-core/src/runtime/dispatcher.rs index 44a084981..124488f2b 100644 --- a/rust/lithe-core/src/runtime.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1,25 +1,24 @@ -use crate::command::{CoreCommand, CoreRequest}; -use crate::error::{CoreError, ErrorCode}; use crate::git::{ self, GitApplyRequest, GitBlameRequest, GitCheckoutPreflightRequest, GitCommandRequest, GitCommitFilesRequest, GitCommitRequest, GitComparisonRequest, GitConflictMarkerRequest, GitDiffRequest, GitHistoryRequest, GitIntegrationPreflightRequest, GitOperationStateRequest, GitPullPreflightRequest, GitStashesRequest, GitStatusRequest, GitWriteRequest, }; -use crate::history::{ - HistoryContentRequest, HistoryEntriesRequest, HistoryRecordRequest, HistoryRelocateRequest, -}; -use crate::java::{ +use crate::languages::{ JavaClassNameRequest, JavaCodeVisionRequest, JavaRunConfigurationsRequest, JavaServerPortRequest, JavaSourceDefinitionRequest, JavaStructureRequest, }; -use crate::markdown::MarkdownRenderRequest; -use crate::maven::{MavenDiagnosticsRequest, MavenScanRequest}; -use crate::model::CoreResponse; -use crate::workspace::{ +use crate::project::{ self, FileReadRequest, FileWriteRequest, ReplacementPreviewRequest, SearchRequest, WorkspaceSnapshotRequest, }; +use crate::project::{ + HistoryContentRequest, HistoryEntriesRequest, HistoryRecordRequest, HistoryRelocateRequest, +}; +use crate::project::{MarkdownRenderRequest, MavenDiagnosticsRequest, MavenScanRequest}; +use crate::protocol::CoreResponse; +use crate::protocol::{CoreCommand, CoreRequest}; +use crate::protocol::{CoreError, ErrorCode}; use serde_json::json; pub fn execute_json(request: &str) -> String { @@ -48,8 +47,8 @@ fn execute(request: &str) -> CoreResponse { let response_id = id.clone(); let operation_id = parsed.operation_id.clone().or_else(|| id.clone()); let _cancellation_scope = - crate::cancellation::Scope::begin(operation_id, parsed.timeout_milliseconds); - if let Err(error) = crate::cancellation::check() { + crate::protocol::cancellation::Scope::begin(operation_id, parsed.timeout_milliseconds); + if let Err(error) = crate::protocol::cancellation::check() { return CoreResponse::failure(id, error); } let Some(command) = CoreCommand::parse(&parsed.command) else { @@ -77,7 +76,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(workspace::snapshot) + .and_then(project::snapshot) { Ok(data) => CoreResponse::success( id, @@ -92,7 +91,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid search request") .with_details(error.to_string()) }) - .and_then(workspace::search) + .and_then(project::search) { Ok(data) => CoreResponse::success( id, @@ -110,7 +109,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(workspace::search_everywhere) + .and_then(project::search_everywhere) { Ok(data) => CoreResponse::success( id, @@ -128,7 +127,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(workspace::replace_preview) + .and_then(project::replace_preview) { Ok(data) => CoreResponse::success( id, @@ -142,7 +141,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid file read request") .with_details(error.to_string()) }) - .and_then(workspace::read_file) + .and_then(project::read_file) { Ok(data) => CoreResponse::success( id, @@ -155,7 +154,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid file write request") .with_details(error.to_string()) }) - .and_then(workspace::write_file) + .and_then(project::write_file) { Ok(data) => CoreResponse::success( id, @@ -169,7 +168,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid history record request") .with_details(error.to_string()) }) - .and_then(crate::history::record) + .and_then(crate::project::record) { Ok(data) => CoreResponse::success( id, @@ -184,7 +183,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid history entries request") .with_details(error.to_string()) }) - .and_then(crate::history::entries) + .and_then(crate::project::entries) { Ok(data) => CoreResponse::success( id, @@ -199,7 +198,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid history content request") .with_details(error.to_string()) }) - .and_then(crate::history::content) + .and_then(crate::project::content) { Ok(data) => CoreResponse::success(id, serde_json::json!({"text": data})), Err(error) => CoreResponse::failure(id, error), @@ -214,7 +213,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::history::relocate) + .and_then(crate::project::relocate) { Ok(()) => CoreResponse::success(id, serde_json::json!({"relocated": true})), Err(error) => CoreResponse::failure(id, error), @@ -225,7 +224,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid Maven scan request") .with_details(error.to_string()) }) - .and_then(crate::maven::scan) + .and_then(crate::project::scan) { Ok(data) => CoreResponse::success( id, @@ -242,7 +241,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::maven::diagnostics) + .and_then(crate::project::diagnostics) { Ok(data) => CoreResponse::success( id, @@ -258,65 +257,471 @@ fn execute(request: &str) -> CoreResponse { }) { Ok(request) => CoreResponse::success( id, - serde_json::to_value(crate::markdown::render(request)) + serde_json::to_value(crate::project::render(request)) .expect("Markdown render response should encode"), ), Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::JavaRunConfigurations => { - match serde_json::from_value::(parsed.payload) + CoreCommand::LspApplyTextEdits => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP text edit request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::apply_text_edits) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP text edit response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspPlainSnippet => { + match serde_json::from_value::(parsed.payload).map_err( + |error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP snippet request") + .with_details(error.to_string()) + }, + ) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(crate::lsp::plain_snippet(request)) + .expect("LSP snippet response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspBuiltinCompletions => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP completion request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::builtin_completions) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP completion response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspBuiltinHover => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP hover request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::builtin_hover) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP hover response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspBuiltinNavigation => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP navigation request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::builtin_navigation) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP navigation response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspStartServer => { + match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, - "Invalid Java run configuration request", + "Invalid LSP start-server request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::start_server) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP start-server response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspStopServer => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP stop-server request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::stop_server) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP stop-server response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspSyncDocument => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP sync-document request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::sync_document) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP sync-document response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspCloseDocument => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP close-document request", ) .with_details(error.to_string()) }) - .and_then(crate::java::run_configurations) + .and_then(crate::lsp::close_document) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP close-document response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid semantic LSP request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::semantic_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Semantic LSP response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspCancelOperation => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP cancellation request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::cancel_operation) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP cancellation response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspPollEvents => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP poll-events request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::poll_events) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP poll-events response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClearDiagnostics => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP clear-diagnostics request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::clear_diagnostics) { Ok(data) => CoreResponse::success( id, serde_json::to_value(data) - .expect("Java run configuration response should encode"), + .expect("LSP clear-diagnostics response should encode"), ), Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::RunConfigInspect => { - match serde_json::from_value::(parsed.payload) + CoreCommand::LspSnapshot => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP snapshot request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::snapshot) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP snapshot response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspDestroyServer => { + match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, - "Invalid run configuration inspect request", + "Invalid LSP destroy-server request", ) .with_details(error.to_string()) }) - .and_then(crate::run_configuration::inspect) + .and_then(crate::lsp::destroy_server) { - Ok(data) => CoreResponse::success(id, data), + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP destroy-server response should encode"), + ), Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::RunConfigGenerate => { - match serde_json::from_value::( + CoreCommand::LspClientInitialize => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP initialize request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_initialize) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientOpenDocument => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP open document request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_open_document) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientChangeDocument => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP change document request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_change_document) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientCloseDocument => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid LSP close document request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_close_document) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientShutdown => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP shutdown request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_shutdown) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientRequest => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP feature request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::client_feature_request) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspClientApplyServerMessage => { + match serde_json::from_value::( parsed.payload, ) .map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, - "Invalid run configuration generate request", + "Invalid LSP server message request", ) .with_details(error.to_string()) }) - .and_then(crate::run_configuration::generate) + .and_then(crate::lsp::client_apply_server_message) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP client response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspSessionExecute => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP session request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::session_execute) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP session response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspFrameMessage => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP frame request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::frame_message) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP frame response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::LspParseServerMessages => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid LSP parser request") + .with_details(error.to_string()) + }) + .and_then(crate::lsp::parse_server_messages) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("LSP parser response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::JavaRunConfigurations => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Java run configuration request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::languages::run_configurations) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data) + .expect("Java run configuration response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::RunConfigInspect => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid run configuration inspect request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::inspect) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::RunConfigGenerate => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid run configuration generate request", + ) + .with_details(error.to_string()) + }) + .and_then(crate::execution::generate) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), } } CoreCommand::RunConfigResolve => { - match serde_json::from_value::(parsed.payload) + match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new( ErrorCode::InvalidRequest, @@ -324,49 +729,48 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::run_configuration::resolve) + .and_then(crate::execution::resolve) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), } } CoreCommand::RunConfigUpdateOptions => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid run options request") - .with_details(error.to_string()) - }) - .and_then(crate::run_configuration::update_options) + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid run options request") + .with_details(error.to_string()) + }) + .and_then(crate::execution::update_options) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), } } CoreCommand::RunConfigCreateUserConfiguration => { - match serde_json::from_value::< - crate::run_configuration::CreateUserConfigurationRequest, - >(parsed.payload) + match serde_json::from_value::( + parsed.payload, + ) .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid user configuration request") - .with_details(error.to_string()) + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid user configuration request", + ) + .with_details(error.to_string()) }) - .and_then(crate::run_configuration::create_user_configuration) + .and_then(crate::execution::create_user_configuration) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), } } CoreCommand::RunConfigCreateLaunchPlan => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid launch plan request") - .with_details(error.to_string()) - }) - .and_then(crate::run_configuration::create_launch_plan) + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid launch plan request") + .with_details(error.to_string()) + }) + .and_then(crate::execution::create_launch_plan) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), @@ -381,7 +785,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::java::code_vision) + .and_then(crate::languages::code_vision) { Ok(data) => CoreResponse::success( id, @@ -396,7 +800,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid Java class name request") .with_details(error.to_string()) }) - .and_then(crate::java::class_name) + .and_then(crate::languages::class_name) { Ok(data) => CoreResponse::success( id, @@ -414,7 +818,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::java::source_definition) + .and_then(crate::languages::source_definition) { Ok(data) => CoreResponse::success( id, @@ -432,7 +836,7 @@ fn execute(request: &str) -> CoreResponse { ) .with_details(error.to_string()) }) - .and_then(crate::java::server_port) + .and_then(crate::languages::server_port) { Ok(data) => CoreResponse::success( id, @@ -447,7 +851,7 @@ fn execute(request: &str) -> CoreResponse { CoreError::new(ErrorCode::InvalidRequest, "Invalid Java structure request") .with_details(error.to_string()) }) - .and_then(crate::java::structure) + .and_then(crate::languages::structure) { Ok(data) => CoreResponse::success( id, @@ -708,7 +1112,7 @@ fn execute(request: &str) -> CoreResponse { }, }; if response.is_success() { - match crate::cancellation::check() { + match crate::protocol::cancellation::check() { Ok(()) => response, Err(error) => CoreResponse::failure(response_id, error), } @@ -716,3 +1120,156 @@ fn execute(request: &str) -> CoreResponse { response } } + +#[cfg(test)] +mod tests { + use super::execute_json; + use serde_json::{json, Value}; + + #[test] + fn routes_lsp_close_document_and_shutdown_commands() { + let uri = "file:///tmp/project/main.go"; + let close_response: Value = serde_json::from_str(&execute_json( + &json!({ + "id": "close-1", + "command": "lsp.clientCloseDocument", + "payload": { + "state": { + "openDocuments": { + uri: { + "uri": uri, + "languageId": "go", + "version": 1, + "text": "package main\n" + } + } + }, + "uri": uri + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(close_response["ok"], true); + assert!(close_response["data"]["state"]["openDocuments"] + .as_object() + .unwrap() + .is_empty()); + let did_close: Value = + serde_json::from_str(close_response["data"]["messages"][0].as_str().unwrap()).unwrap(); + assert_eq!(did_close["method"], "textDocument/didClose"); + + let shutdown_response: Value = serde_json::from_str(&execute_json( + &json!({ + "id": "shutdown-1", + "command": "lsp.clientShutdown", + "payload": { + "state": { + "initialized": true + } + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(shutdown_response["ok"], true); + assert_eq!( + shutdown_response["data"]["state"]["shutdownRequested"], + true + ); + let shutdown: Value = + serde_json::from_str(shutdown_response["data"]["messages"][0].as_str().unwrap()) + .unwrap(); + assert_eq!(shutdown["method"], "shutdown"); + assert_eq!(shutdown["id"], "1"); + assert!(shutdown.get("params").is_none()); + } + + #[test] + fn routes_semantic_lsp_runtime_commands() { + let unknown_session = "missing-runtime-session"; + let requests = [ + json!({ + "command": "lsp.stopServer", + "payload": { "sessionId": unknown_session } + }), + json!({ + "command": "lsp.syncDocument", + "payload": { + "sessionId": unknown_session, + "uri": "file:///tmp/main.go", + "languageId": "go", + "text": "package main\n" + } + }), + json!({ + "command": "lsp.closeDocument", + "payload": { + "sessionId": unknown_session, + "uri": "file:///tmp/main.go" + } + }), + json!({ + "command": "lsp.request", + "payload": { + "sessionId": unknown_session, + "operationId": "completion-1", + "operation": "completion", + "uri": "file:///tmp/main.go", + "position": { "line": 0, "utf16Column": 0 } + } + }), + json!({ + "command": "lsp.cancelOperation", + "payload": { + "sessionId": unknown_session, + "operationId": "completion-1" + } + }), + json!({ + "command": "lsp.pollEvents", + "payload": { "sessionId": unknown_session } + }), + json!({ + "command": "lsp.clearDiagnostics", + "payload": { "sessionId": unknown_session } + }), + json!({ + "command": "lsp.snapshot", + "payload": { "sessionId": unknown_session } + }), + json!({ + "command": "lsp.destroyServer", + "payload": { "sessionId": unknown_session } + }), + ]; + + for request in requests { + let response: Value = + serde_json::from_str(&execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], false, "request should reach the runtime"); + assert_eq!(response["error"]["code"], "invalid_request"); + assert_eq!(response["error"]["details"], unknown_session); + } + + let invalid_start: Value = serde_json::from_str(&execute_json( + &json!({ + "command": "lsp.startServer", + "payload": { + "providerId": "go", + "executablePath": "", + "rootUri": "file:///tmp/project", + "workingDirectory": "/tmp/project" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(invalid_start["ok"], false); + assert_eq!(invalid_start["error"]["code"], "invalid_request"); + assert_eq!( + invalid_start["error"]["details"], + "executablePath/workingDirectory" + ); + } +} diff --git a/rust/lithe-core/src/ffi.rs b/rust/lithe-core/src/runtime/ffi.rs similarity index 69% rename from rust/lithe-core/src/ffi.rs rename to rust/lithe-core/src/runtime/ffi.rs index 04662dc27..2f17e3bfb 100644 --- a/rust/lithe-core/src/ffi.rs +++ b/rust/lithe-core/src/runtime/ffi.rs @@ -1,5 +1,6 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; +use std::path::PathBuf; #[no_mangle] pub extern "C" fn lithe_core_version() -> *const c_char { @@ -18,6 +19,23 @@ pub unsafe extern "C" fn lithe_core_execute_json(request: *const c_char) -> *mut response_pointer(&crate::execute_json(&request)) } +#[no_mangle] +pub unsafe extern "C" fn lithe_core_lsp_provider_catalog_json( + workspace_root: *const c_char, +) -> *mut c_char { + let root = if workspace_root.is_null() { + None + } else { + let value = CStr::from_ptr(workspace_root).to_string_lossy(); + if value.trim().is_empty() { + None + } else { + Some(PathBuf::from(value.as_ref())) + } + }; + response_pointer(&crate::lsp::provider_catalog_json(root.as_deref())) +} + /// Requests cooperative cancellation of an in-flight operation. The call is /// thread-safe and returns 1 when an active operation was found. #[no_mangle] @@ -26,7 +44,7 @@ pub unsafe extern "C" fn lithe_core_cancel(operation_id: *const c_char) -> i32 { return 0; } let operation_id = CStr::from_ptr(operation_id).to_string_lossy(); - crate::cancellation::cancel(&operation_id) as i32 + crate::protocol::cancellation::cancel(&operation_id) as i32 } #[no_mangle] diff --git a/rust/lithe-core/src/runtime/mod.rs b/rust/lithe-core/src/runtime/mod.rs new file mode 100644 index 000000000..a15e9e757 --- /dev/null +++ b/rust/lithe-core/src/runtime/mod.rs @@ -0,0 +1,6 @@ +//! JSON command dispatch and the exported C ABI. + +mod dispatcher; +mod ffi; + +pub(crate) use dispatcher::execute_json; diff --git a/rust/lithe-core/src/tests/detectors.rs b/rust/lithe-core/src/tests/detectors.rs new file mode 100644 index 000000000..c0fd80c11 --- /dev/null +++ b/rust/lithe-core/src/tests/detectors.rs @@ -0,0 +1,343 @@ +use super::support::temporary_root; +use crate::execute_json; +use serde_json::Value; +use std::fs; +use std::path::Path; + +/// Builds a project that mixes six ecosystems in one tree, including the +/// traps that break naive detectors: a lockfile that is not npm's, a +/// `node_modules` full of decoy manifests, and a Go command in a +/// subdirectory with no manifest of its own. +fn multi_language_project(root: &Path) { + for directory in ["frontend/node_modules/decoy", "api", "cmd/gateway"] { + fs::create_dir_all(root.join(directory)).unwrap(); + } + fs::write( + root.join("frontend/package.json"), + r#"{"name":"web","scripts":{"dev":"vite","build":"vite build"}}"#, + ) + .unwrap(); + fs::write(root.join("frontend/pnpm-lock.yaml"), "lockfileVersion: 9\n").unwrap(); + fs::write( + root.join("frontend/node_modules/decoy/package.json"), + r#"{"scripts":{"dev":"should-never-appear"}}"#, + ) + .unwrap(); + fs::write( + root.join("api/pyproject.toml"), + "[tool.poetry]\nname = \"api\"\n[tool.poetry.scripts]\napi-server = \"api.main:run\"\n", + ) + .unwrap(); + fs::write( + root.join("api/main.py"), + "from fastapi import FastAPI\napp = FastAPI()\n", + ) + .unwrap(); + fs::write(root.join("go.mod"), "module example.com/gw\ngo 1.22\n").unwrap(); + fs::write( + root.join("cmd/gateway/main.go"), + "package main\nfunc main() {}\n", + ) + .unwrap(); + fs::write( + root.join("docker-compose.yml"), + "services:\n db:\n image: postgres\n cache:\n image: redis\n", + ) + .unwrap(); + fs::write( + root.join("Procfile"), + "worker: python worker/run.py\nweb: gunicorn api.main:app\n", + ) + .unwrap(); + fs::write( + root.join("Makefile"), + "run:\n\techo run\nclean:\n\techo clean\n", + ) + .unwrap(); +} + +fn generated_configurations(root: &Path) -> Vec { + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate", + "command": "runConfig.generate", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["data"]["generated"]["configurations"] + .as_array() + .cloned() + .unwrap() +} + +/// The headline behaviour: one project, six ecosystems, every service found +/// without the user configuring anything. +#[test] +fn detectors_find_services_across_unrelated_ecosystems() { + let root = temporary_root("detect-multi"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + + let ids = generated_configurations(&root) + .iter() + .map(|item| item["id"].as_str().unwrap().to_string()) + .collect::>(); + + for expected in [ + "npm.script:frontend/dev", + "python.script:api/api-server", + "python.uvicorn:api/main", + "go.command:cmd/gateway/gateway", + "compose.service:db", + "compose.stack:compose up", + "procfile.process:web", + "make.target:run", + ] { + assert!( + ids.contains(&expected.to_string()), + "missing {expected} in {ids:?}" + ); + } + + fs::remove_dir_all(root).unwrap(); +} + +/// A dependency tree contains thousands of manifests. Descending into it +/// would both bury the real services and make project open unusably slow. +#[test] +fn detectors_never_descend_into_dependency_directories() { + let root = temporary_root("detect-prune"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + + let sources = generated_configurations(&root) + .iter() + .filter_map(|item| item["source"].as_str().map(str::to_string)) + .collect::>(); + + assert!( + !sources.iter().any(|source| source.contains("node_modules")), + "{sources:?}" + ); + + fs::remove_dir_all(root).unwrap(); +} + +/// Running `npm run dev` in a pnpm workspace fails at spawn time with an +/// error that points nowhere useful, so the lockfile decides the command. +#[test] +fn npm_detector_uses_the_package_manager_the_lockfile_names() { + let root = temporary_root("detect-pnpm"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + + let dev = generated_configurations(&root) + .into_iter() + .find(|item| item["id"] == "npm.script:frontend/dev") + .unwrap(); + + assert_eq!(dev["command"], "pnpm"); + assert_eq!(dev["args"], serde_json::json!(["run", "dev"])); + assert_eq!(dev["cwd"], "frontend"); + assert_eq!(dev["execution"], "service"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn npm_detector_inherits_the_workspace_package_manager() { + let root = temporary_root("detect-pnpm-workspace"); + fs::create_dir_all(root.join("apps/web")).unwrap(); + fs::write(root.join("pnpm-lock.yaml"), "lockfileVersion: 9\n").unwrap(); + fs::write( + root.join("apps/web/package.json"), + r#"{"scripts":{"dev":"vite"}}"#, + ) + .unwrap(); + + let dev = generated_configurations(&root) + .into_iter() + .find(|item| item["id"] == "npm.script:apps/web/dev") + .unwrap(); + assert_eq!(dev["command"], "pnpm"); + assert_eq!(dev["execution"], "service"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn detectors_preserve_application_service_and_task_semantics() { + let root = temporary_root("detect-execution-semantics"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + let configurations = generated_configurations(&root); + let execution = |id: &str| { + configurations + .iter() + .find(|item| item["id"] == id) + .and_then(|item| item["execution"].as_str()) + }; + + assert_eq!(execution("npm.script:frontend/dev"), Some("service")); + assert_eq!(execution("npm.script:frontend/build"), Some("task")); + assert_eq!( + execution("python.script:api/api-server"), + Some("application") + ); + assert_eq!( + execution("go.command:cmd/gateway/gateway"), + Some("application") + ); + assert_eq!(execution("compose.stack:compose up"), Some("service")); + + fs::remove_dir_all(root).unwrap(); +} + +/// Detected entries are process-based, so they must survive the same launch +/// path as any other configuration without acquiring Java assumptions. +#[test] +fn detected_services_produce_runnable_launch_plans() { + let root = temporary_root("detect-launch"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + let generated = serde_json::json!({ + "version": 2, + "configurations": generated_configurations(&root) + }); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&generated).unwrap(), + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "npm.script:frontend/dev"} + }) + .to_string(), + )) + .unwrap(); + + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["command"], "pnpm"); + assert!(plan["data"]["executable"]["toolchain"].is_null()); + assert_eq!(plan["data"]["workingDirectory"], "frontend"); + assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); + + fs::remove_dir_all(root).unwrap(); +} + +/// Ids are the join key for the team and local override layers. A detector +/// that renamed a Java configuration would silently detach every override +/// written against it, with no error anywhere. +#[test] +fn detectors_never_claim_an_id_the_java_scan_already_produced() { + let root = temporary_root("detect-no-clobber"); + fs::create_dir_all(&root).unwrap(); + multi_language_project(&root); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("src/Main.java"), + "class Main { public static void main(String[] args) {} }", + ) + .unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-with-java", + "command": "runConfig.generate", + "payload": {"root": root, "paths": ["src/Main.java"]} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + let ids = response["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .map(|item| item["id"].as_str().unwrap().to_string()) + .collect::>(); + let mut unique = ids.clone(); + unique.sort(); + unique.dedup(); + + assert_eq!(ids.len(), unique.len(), "duplicate ids in {ids:?}"); + assert!(ids.contains(&"current-file".to_string())); + + fs::remove_dir_all(root).unwrap(); +} + +/// A Java project that also ships a frontend must gain the frontend's +/// services without any Java configuration changing id. Ids are the join key +/// for the team and local layers, so a shifted id detaches every override +/// silently -- there is no error to notice. +#[test] +fn detectors_extend_a_java_project_without_disturbing_its_configurations() { + let root = temporary_root("detect-java-mixed"); + let module = root.join("backend-api/src/main/java/com/demo"); + fs::create_dir_all(&module).unwrap(); + fs::create_dir_all(root.join("frontend-web")).unwrap(); + fs::write( + root.join("pom.xml"), + "backend-api", + ) + .unwrap(); + fs::write(root.join("backend-api/pom.xml"), "").unwrap(); + fs::write( + module.join("BackendApplication.java"), + "package com.demo;\n@SpringBootApplication\npublic class BackendApplication { public static void main(String[] a) {} }\n", + ) + .unwrap(); + fs::write( + root.join("frontend-web/package.json"), + r#"{"name":"web","scripts":{"dev":"vite"}}"#, + ) + .unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate", + "command": "runConfig.generate", + "payload": { + "root": root, + "paths": ["backend-api/src/main/java/com/demo/BackendApplication.java"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + let configurations = response["data"]["generated"]["configurations"] + .as_array() + .unwrap(); + let ids = configurations + .iter() + .map(|item| item["id"].as_str().unwrap().to_string()) + .collect::>(); + + assert!( + ids.contains(&"spring:com.demo.BackendApplication".to_string()), + "{ids:?}" + ); + assert!( + ids.contains(&"npm.script:frontend-web/dev".to_string()), + "{ids:?}" + ); + // The Java entries stay toolchain-backed; only the detected ones are + // process-based. A regression here would send npm through Maven. + let java = configurations + .iter() + .find(|item| item["id"] == "spring:com.demo.BackendApplication") + .unwrap(); + assert!(java["command"].is_null()); + assert_eq!(java["toolchains"]["maven"], "project-maven"); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs new file mode 100644 index 000000000..e654b8122 --- /dev/null +++ b/rust/lithe-core/src/tests/git.rs @@ -0,0 +1,1309 @@ +use super::support::temporary_root; +use crate::execute_json; +use serde_json::Value; +use std::fs; +use std::process::Command; + +#[test] +fn git_status_returns_contract_shape() { + let root = temporary_root("git"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + fs::write(root.join("new.txt"), "new").expect("test file should be writable"); + + let request = serde_json::json!({ + "id": "git", + "command": "git.status", + "payload": {"root": root} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Git request should encode"), + )) + .expect("Git response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["repositoryRoot"], "."); + assert_eq!(response["data"]["changes"][0]["path"], "new.txt"); + assert_eq!(response["data"]["changes"][0]["untracked"], true); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_command_returns_combined_output_and_exit_code() { + let root = temporary_root("git-command"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + + let request = serde_json::json!({ + "id": "git-command", + "command": "git.command", + "payload": { + "root": root, + "arguments": ["--version"] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Git command request should encode"), + )) + .expect("Git command response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["exitCode"], 0); + assert!(response["data"]["output"] + .as_str() + .expect("Git version output should be text") + .contains("git version")); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_write_validates_and_executes_shared_mutations() { + let root = temporary_root("git-write"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "initial\n").expect("file should be writable"); + + let request = |operation: &str, payload: Value| -> Value { + let request = serde_json::json!({ + "id": operation, + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "paths": [], + "reference": null, + "referenceKind": null, + "revision": null, + "name": null, + "message": null, + "remote": null, + "destination": null, + "mode": null, + "includeUntracked": false, + "checkout": false, + "amend": false + } + }); + let mut request = request; + if let Value::Object(overrides) = payload { + for (key, value) in overrides { + request["payload"][key.as_str()] = value; + } + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("write request should encode"), + )) + .expect("write response should be JSON") + }; + + let stage = request("stage", serde_json::json!({"paths": ["example.txt"]})); + assert_eq!(stage["ok"], true); + let commit = request( + "commit", + serde_json::json!({"message": "initial", "amend": false}), + ); + assert_eq!(commit["ok"], true); + + fs::write(root.join("example.txt"), "staged change\n").expect("file should be writable"); + assert_eq!( + request("stage", serde_json::json!({"paths": ["example.txt"]}))["ok"], + true + ); + assert_eq!( + request("unstage", serde_json::json!({"paths": ["example.txt"]}))["ok"], + true + ); + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + " M example.txt\n" + ); + assert_eq!( + request("discard", serde_json::json!({"paths": ["example.txt"]}))["ok"], + true + ); + assert_eq!( + fs::read_to_string(root.join("example.txt")).expect("file should be readable"), + "initial\n" + ); + + // Conflict-dialog rollback must discard both sides of a file, including + // a staged edit followed by a working-tree edit. + fs::write(root.join("example.txt"), "staged\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + fs::write(root.join("example.txt"), "working\n").expect("file should be writable"); + let discard_all = request("discardAll", serde_json::json!({"paths": ["example.txt"]})); + assert_eq!(discard_all["ok"], true, "{discard_all:?}"); + assert_eq!( + fs::read_to_string(root.join("example.txt")).expect("file should be readable"), + "initial\n" + ); + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + "" + ); + + fs::write(root.join("untracked.txt"), "discard me\n") + .expect("untracked file should be writable"); + assert_eq!( + request("discard", serde_json::json!({"paths": ["untracked.txt"]}))["ok"], + true + ); + assert!(!root.join("untracked.txt").exists()); + + let current = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) + .trim() + .to_string(); + let create = request( + "createBranch", + serde_json::json!({ + "reference": format!("refs/heads/{current}"), + "name": "feature/core", + "checkout": true + }), + ); + assert_eq!(create["ok"], true); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + "feature/core" + ); + + let checkout = request( + "checkout", + serde_json::json!({ + "reference": format!("refs/heads/{current}"), + "referenceKind": "local" + }), + ); + assert_eq!(checkout["ok"], true); + assert_eq!(checkout["data"]["exitCode"], 0, "{checkout:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + current + ); + + // Nested branch names go through the same short-name path. + assert!(run(&["branch", "feature/nested"]).status.success()); + let nested = request( + "checkout", + serde_json::json!({ + "reference": "refs/heads/feature/nested", + "referenceKind": "local" + }), + ); + assert_eq!(nested["ok"], true); + assert_eq!(nested["data"]["exitCode"], 0, "{nested:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + "feature/nested" + ); + assert!(run(&["switch", ¤t]).status.success()); + + fs::write(root.join("example.txt"), "working tree\n").expect("file should be writable"); + let stash = request( + "stashPush", + serde_json::json!({"message": "core write", "includeUntracked": false}), + ); + assert_eq!(stash["ok"], true); + let pop = request("stashPop", serde_json::json!({"reference": "stash@{0}"})); + assert_eq!(pop["ok"], true); + assert_eq!( + fs::read_to_string(root.join("example.txt")).expect("file should be readable"), + "working tree\n" + ); + + // Checkout conflict handling. `feature/core` and the current branch hold different + // content for conflict.txt, so a dirty working copy of it blocks a plain switch. + fs::write(root.join("conflict.txt"), "on main\n").expect("file should be writable"); + assert!(run(&["add", "conflict.txt"]).status.success()); + assert!(run(&["commit", "-qm", "main conflict"]).status.success()); + assert!(run(&["switch", "feature/core"]).status.success()); + fs::write(root.join("conflict.txt"), "on feature\n").expect("file should be writable"); + assert!(run(&["add", "conflict.txt"]).status.success()); + assert!(run(&["commit", "-qm", "feature conflict"]).status.success()); + assert!(run(&["switch", ¤t]).status.success()); + + let preflight = |reference: &str| -> Value { + let value = execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "preflight", + "command": "git.checkoutPreflight", + "payload": {"root": root, "reference": reference} + })) + .expect("request should encode"), + ); + serde_json::from_str(&value).expect("response should decode") + }; + + // Clean tree: nothing blocks the switch. + let clean = preflight("refs/heads/feature/core"); + assert_eq!(clean["ok"], true, "{clean:?}"); + assert_eq!( + clean["data"]["blockingPaths"], + serde_json::json!([]), + "{clean:?}" + ); + + // Dirty and divergent: preflight names the exact blocking file. + fs::write(root.join("conflict.txt"), "local edit\n").expect("file should be writable"); + let blocked = preflight("refs/heads/feature/core"); + assert_eq!(blocked["ok"], true); + assert_eq!( + blocked["data"]["blockingPaths"], + serde_json::json!(["conflict.txt"]), + "{blocked:?}" + ); + + // Untracked files that the target branch tracks also block a checkout, even + // though they never appear in `git diff HEAD`. + assert!(run(&["stash", "-u"]).status.success()); + fs::write(root.join("conflict.txt"), "untracked local\n").expect("file should be writable"); + let untracked_block = preflight("refs/heads/feature/core"); + assert_eq!( + untracked_block["data"]["blockingPaths"], + serde_json::json!(["conflict.txt"]), + "{untracked_block:?}" + ); + fs::remove_file(root.join("conflict.txt")).expect("file should be removable"); + assert!(run(&["stash", "pop"]).status.success()); + + // A plain checkout is refused rather than clobbering the edit. + let refused = request( + "checkout", + serde_json::json!({ + "reference": "refs/heads/feature/core", + "referenceKind": "local" + }), + ); + assert_ne!(refused["data"]["exitCode"], 0, "{refused:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + current + ); + + // Smart checkout stashes the edit, switches, and restores it. + assert!(run(&["switch", "-c", "feature/smart"]).status.success()); + let smart = request( + "checkout", + serde_json::json!({ + "reference": format!("refs/heads/{current}"), + "referenceKind": "local", + "autoStash": true + }), + ); + assert_eq!(smart["ok"], true); + assert_eq!(smart["data"]["exitCode"], 0, "{smart:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + current + ); + assert_eq!( + fs::read_to_string(root.join("conflict.txt")).expect("file should be readable"), + "local edit\n" + ); + assert!( + String::from_utf8_lossy(&run(&["stash", "list"]).stdout).is_empty(), + "smart checkout should consume its stash" + ); + + // Force checkout discards the local edit and lands on the target branch. + let forced = request( + "checkout", + serde_json::json!({ + "reference": "refs/heads/feature/core", + "referenceKind": "local", + "force": true + }), + ); + assert_eq!(forced["ok"], true); + assert_eq!(forced["data"]["exitCode"], 0, "{forced:?}"); + assert_eq!( + String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout).trim(), + "feature/core" + ); + assert_eq!( + fs::read_to_string(root.join("conflict.txt")).expect("file should be readable"), + "on feature\n" + ); + assert!(run(&["switch", ¤t]).status.success()); + + let clone = root + .parent() + .expect("temporary root should have a parent") + .join(format!("lithe-core-clone-{}", std::process::id())); + let clone_result = request( + "clone", + serde_json::json!({ + "remote": root.to_string_lossy(), + "destination": clone.to_string_lossy() + }), + ); + assert_eq!(clone_result["ok"], true); + assert!(clone.join(".git").exists()); + fs::remove_dir_all(clone).expect("temporary clone should be removable"); + + let invalid = request( + "reset", + serde_json::json!({"revision": "HEAD", "mode": "--invalid"}), + ); + assert_eq!(invalid["ok"], false); + assert_eq!(invalid["error"]["code"], "invalid_request"); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn stash_restore_conflicts_return_structured_recovery_data() { + let root = temporary_root("git-stash-conflict"); + fs::create_dir_all(&root).expect("temporary repository should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); + assert!(run(&["add", "shared.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + assert!(run(&["switch", "-qc", "feature"]).status.success()); + fs::write(root.join("shared.txt"), "feature\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "feature edit"]).status.success()); + assert!(run(&["switch", "-q", "main"]).status.success()); + + fs::write(root.join("shared.txt"), "local\n").expect("file should be writable"); + assert!(run(&["stash", "push", "-qm", "restore conflict"]) + .status + .success()); + let stash_reference = String::from_utf8_lossy(&run(&["stash", "list", "--format=%gd"]).stdout) + .lines() + .next() + .expect("stash reference should exist") + .trim() + .to_string(); + assert!(run(&["switch", "-q", "feature"]).status.success()); + + let write = |operation: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": format!("stash-{operation}"), + "command": "git.write", + "payload": { + "root": root, + "operation": operation, + "reference": stash_reference + } + })) + .expect("stash request should encode"), + )) + .expect("stash response should be JSON") + }; + + let applied = write("stashApply"); + assert_eq!(applied["ok"], true, "{applied:?}"); + assert_eq!(applied["data"]["exitCode"], 1, "{applied:?}"); + assert_eq!( + applied["data"]["stashRestore"]["stashReference"], stash_reference, + "{applied:?}" + ); + assert_eq!( + applied["data"]["stashRestore"]["conflictedPaths"], + serde_json::json!(["shared.txt"]), + "{applied:?}" + ); + + // Clear the index conflict without dropping the saved entry, then verify + // `pop` reports the same structured recovery data. + assert!(run(&["reset", "--hard", "HEAD"]).status.success()); + let popped = write("stashPop"); + assert_eq!(popped["ok"], true, "{popped:?}"); + assert_eq!(popped["data"]["exitCode"], 1, "{popped:?}"); + assert_eq!( + popped["data"]["stashRestore"]["stashReference"], stash_reference, + "{popped:?}" + ); + assert_eq!( + popped["data"]["stashRestore"]["conflictedPaths"], + serde_json::json!(["shared.txt"]), + "{popped:?}" + ); + + assert!(run(&["reset", "--hard", "HEAD"]).status.success()); + assert!(run(&["stash", "drop", &stash_reference]).status.success()); + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_operation_state_reports_and_resolves_a_merge_conflict() { + let root = temporary_root("git-operation"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + + fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); + assert!(run(&["add", "shared.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + let current = String::from_utf8_lossy(&run(&["branch", "--show-current"]).stdout) + .trim() + .to_string(); + + let state = || -> Value { + let value = execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "operation-state", + "command": "git.operationState", + "payload": {"root": root} + })) + .expect("request should encode"), + ); + serde_json::from_str(&value).expect("response should decode") + }; + let write = |operation: &str| -> Value { + let value = execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "operation-write", + "command": "git.write", + "payload": {"root": root, "operation": operation} + })) + .expect("request should encode"), + ); + serde_json::from_str(&value).expect("response should decode") + }; + + // A settled repository reports no operation and no conflicts. + let idle = state(); + assert_eq!(idle["ok"], true, "{idle:?}"); + assert_eq!(idle["data"]["kind"], "", "{idle:?}"); + assert_eq!(idle["data"]["conflictedPaths"], serde_json::json!([])); + + // Continuing when nothing is in progress is rejected rather than run blindly. + let nothing = write("operationContinue"); + assert_eq!(nothing["ok"], false, "{nothing:?}"); + assert_eq!(nothing["error"]["code"], "invalid_request"); + + // Build two branches that edit the same line, so merging must conflict. + assert!(run(&["switch", "-qc", "feature/conflict"]).status.success()); + fs::write(root.join("shared.txt"), "from feature\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "feature edit"]).status.success()); + assert!(run(&["switch", "-q", ¤t]).status.success()); + fs::write(root.join("shared.txt"), "from main\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "main edit"]).status.success()); + + // Conflicting merges exit non-zero; the point is the state they leave behind. + assert!(!run(&["merge", "--no-edit", "feature/conflict"]) + .status + .success()); + + let conflicted = state(); + assert_eq!(conflicted["ok"], true, "{conflicted:?}"); + assert_eq!(conflicted["data"]["kind"], "merge", "{conflicted:?}"); + assert_eq!( + conflicted["data"]["conflictedPaths"], + serde_json::json!(["shared.txt"]), + "{conflicted:?}" + ); + + // Continuing with the conflict unresolved is refused, so the user cannot + // commit conflict markers by clicking through the banner. + let premature = write("operationContinue"); + assert_eq!(premature["ok"], false, "{premature:?}"); + assert_eq!(premature["error"]["code"], "invalid_request"); + + // A merge has no skip step. + let skip = write("operationSkip"); + assert_eq!(skip["ok"], false, "{skip:?}"); + + // Resolving the file and continuing completes the merge without opening an editor. + fs::write(root.join("shared.txt"), "resolved\n").expect("file should be writable"); + assert!(run(&["add", "shared.txt"]).status.success()); + let finished = write("operationContinue"); + assert_eq!(finished["ok"], true, "{finished:?}"); + assert_eq!(finished["data"]["exitCode"], 0, "{finished:?}"); + + let settled = state(); + assert_eq!(settled["data"]["kind"], "", "{settled:?}"); + assert_eq!(settled["data"]["conflictedPaths"], serde_json::json!([])); + + // Abort restores the pre-merge state of a fresh conflict. + fs::write(root.join("shared.txt"), "main again\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "main again"]).status.success()); + assert!(run(&["switch", "-q", "feature/conflict"]).status.success()); + fs::write(root.join("shared.txt"), "feature again\n").expect("file should be writable"); + assert!(run(&["commit", "-qam", "feature again"]).status.success()); + assert!(!run(&["merge", "--no-edit", ¤t]).status.success()); + assert_eq!(state()["data"]["kind"], "merge"); + + let aborted = write("operationAbort"); + assert_eq!(aborted["ok"], true, "{aborted:?}"); + assert_eq!(aborted["data"]["exitCode"], 0, "{aborted:?}"); + assert_eq!(state()["data"]["kind"], ""); + assert_eq!( + fs::read_to_string(root.join("shared.txt")).expect("file should be readable"), + "feature again\n" + ); + + fs::remove_dir_all(root).expect("temporary repository should be removable"); +} + +#[test] +fn git_diff_and_apply_round_trip_a_patch() { + let root = temporary_root("git-diff"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "before\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + fs::write(root.join("example.txt"), "after\n").expect("file should be writable"); + + let diff = serde_json::json!({ + "id": "diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["example.txt"], + "contextLines": 80 + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&diff).expect("diff request should encode"), + )) + .expect("diff response should be JSON"); + assert_eq!(response["ok"], true); + assert!(response["data"]["patch"] + .as_str() + .expect("diff output should be text") + .contains("+after")); + assert_eq!(response["data"]["hunks"].as_array().unwrap().len(), 1); + assert!(response["data"]["rows"] + .as_array() + .unwrap() + .iter() + .any(|row| row["kind"] == "changed" && row["right"] == "after")); + + let reference_diff = serde_json::json!({ + "id": "reference-diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["example.txt"], + "reference": "HEAD", + "contextLines": 80 + } + }); + let reference_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&reference_diff).expect("reference diff should encode"), + )) + .expect("reference diff response should be JSON"); + assert_eq!(reference_response["ok"], true); + assert!(reference_response["data"]["patch"] + .as_str() + .expect("reference diff patch should be text") + .contains("+after")); + + let apply = serde_json::json!({ + "id": "apply", + "command": "git.apply", + "payload": { + "root": root, + "patch": response["data"]["patch"], + "mode": "stage" + } + }); + let apply_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&apply).expect("apply request should encode"), + )) + .expect("apply response should be JSON"); + assert_eq!(apply_response["ok"], true); + assert_eq!(apply_response["data"]["exitCode"], 0); + + let status = run(&["status", "--porcelain"]).stdout; + assert_eq!(String::from_utf8_lossy(&status), "M example.txt\n"); + + // Shelve restores the index snapshot and the unstaged worktree delta + // separately. Verify that a file with both kinds of edits returns as MM + // and keeps the final worktree content. + assert!(run(&["reset", "--hard", "HEAD"]).status.success()); + fs::write(root.join("example.txt"), "staged\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + let staged_diff = serde_json::json!({ + "id": "staged-diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["example.txt"], + "staged": true + } + }); + let staged_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&staged_diff).expect("staged diff request should encode"), + )) + .expect("staged diff response should be JSON"); + let staged_patch = staged_response["data"]["patch"] + .as_str() + .expect("staged patch should be text") + .to_string(); + + fs::write(root.join("example.txt"), "final\n").expect("file should be writable"); + let working_diff = serde_json::json!({ + "id": "working-diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["example.txt"] + } + }); + let working_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&working_diff).expect("working diff request should encode"), + )) + .expect("working diff response should be JSON"); + let working_patch = working_response["data"]["patch"] + .as_str() + .expect("working patch should be text") + .to_string(); + assert!(run(&["reset", "--hard", "HEAD"]).status.success()); + + for (id, patch, mode) in [ + ("restore-index", staged_patch.as_str(), "restoreIndex"), + ("restore-worktree", working_patch.as_str(), "worktree"), + ] { + let apply = serde_json::json!({ + "id": id, + "command": "git.apply", + "payload": {"root": root, "patch": patch, "mode": mode} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&apply).expect("restore apply request should encode"), + )) + .expect("restore apply response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + } + assert_eq!( + String::from_utf8_lossy(&run(&["status", "--porcelain"]).stdout), + "MM example.txt\n" + ); + assert_eq!( + fs::read_to_string(root.join("example.txt")).expect("file should be readable"), + "final\n" + ); + + for (id, patch, mode) in [ + ( + "restore-index-check", + staged_patch.as_str(), + "restoreIndexCheck", + ), + ("worktree-check", working_patch.as_str(), "worktreeCheck"), + ] { + let check = serde_json::json!({ + "id": id, + "command": "git.apply", + "payload": {"root": root, "patch": patch, "mode": mode} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&check).expect("patch check request should encode"), + )) + .expect("patch check response should be JSON"); + assert_eq!(response["ok"], true, "{response:?}"); + assert_eq!(response["data"]["exitCode"], 0, "{response:?}"); + } + + assert!(run(&["reset", "--hard", "HEAD"]).status.success()); + fs::write(root.join("new.txt"), "untracked\n").expect("file should be writable"); + let untracked_diff = serde_json::json!({ + "id": "untracked-diff", + "command": "git.diff", + "payload": { + "root": root, + "pathspecs": ["new.txt"], + "untracked": true + } + }); + let untracked_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&untracked_diff).expect("untracked diff request should encode"), + )) + .expect("untracked diff response should be JSON"); + let untracked_patch = untracked_response["data"]["patch"] + .as_str() + .expect("untracked patch should be text") + .to_string(); + fs::remove_file(root.join("new.txt")).expect("file should be removable"); + let untracked_apply = serde_json::json!({ + "id": "untracked-apply", + "command": "git.apply", + "payload": {"root": root, "patch": untracked_patch, "mode": "worktree"} + }); + let untracked_apply_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&untracked_apply).expect("untracked apply request should encode"), + )) + .expect("untracked apply response should be JSON"); + assert_eq!(untracked_apply_response["ok"], true); + assert_eq!(untracked_apply_response["data"]["exitCode"], 0); + assert_eq!( + fs::read_to_string(root.join("new.txt")).expect("file should be readable"), + "untracked\n" + ); + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} + +#[test] +fn git_history_returns_references_and_commit_graph_fields() { + let root = temporary_root("git-history"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + fs::write(root.join("example.txt"), "hello\n").expect("file should be writable"); + assert!(run(&["add", "example.txt"]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + let commit_hash = String::from_utf8_lossy(&run(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + + let blame_request = serde_json::json!({ + "id": "blame", + "command": "git.blame", + "payload": {"root": root, "path": "example.txt"} + }); + let blame_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&blame_request).expect("blame request should encode"), + )) + .expect("blame response should be JSON"); + assert_eq!(blame_response["ok"], true); + assert_eq!(blame_response["data"]["lines"][0]["line"], 1); + assert_eq!( + blame_response["data"]["lines"][0]["commitHash"], + commit_hash + ); + + let commit_request = serde_json::json!({ + "id": "commit", + "command": "git.commit", + "payload": {"root": root, "commit": commit_hash} + }); + let commit_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&commit_request).expect("commit request should encode"), + )) + .expect("commit response should be JSON"); + assert_eq!(commit_response["ok"], true); + assert_eq!(commit_response["data"]["commit"]["hash"], commit_hash); + + let files_request = serde_json::json!({ + "id": "files", + "command": "git.commitFiles", + "payload": {"root": root, "commit": commit_hash} + }); + let files_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&files_request).expect("commit files request should encode"), + )) + .expect("commit files response should be JSON"); + assert_eq!(files_response["ok"], true); + assert_eq!(files_response["data"]["files"][0]["path"], "example.txt"); + + fs::write(root.join("example.txt"), "changed\n").expect("file should be writable"); + let comparison_request = serde_json::json!({ + "id": "comparison", + "command": "git.comparison", + "payload": {"root": root, "reference": "HEAD"} + }); + let comparison_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&comparison_request).expect("comparison request should encode"), + )) + .expect("comparison response should be JSON"); + assert_eq!(comparison_response["ok"], true); + assert_eq!( + comparison_response["data"]["files"][0]["path"], + "example.txt" + ); + + assert!(run(&["stash", "push", "-qm", "saved"]).status.success()); + let stashes_request = serde_json::json!({ + "id": "stashes", + "command": "git.stashes", + "payload": {"root": root} + }); + let stashes_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&stashes_request).expect("stashes request should encode"), + )) + .expect("stashes response should be JSON"); + assert_eq!(stashes_response["ok"], true); + assert_eq!(stashes_response["data"]["stashes"][0]["message"], "saved"); + + let request = serde_json::json!({ + "id": "history", + "command": "git.history", + "payload": {"root": root, "reference": "HEAD", "limit": 10} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("history request should encode"), + )) + .expect("history response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["commits"][0]["subject"], "initial"); + assert!( + response["data"]["commits"][0]["hash"] + .as_str() + .expect("commit hash should be text") + .len() + >= 7 + ); + assert!(response["data"]["references"] + .as_array() + .expect("references should be an array") + .iter() + .any(|reference| reference["kind"] == "local")); + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); +} +#[test] +fn git_conflict_markers_ignore_markdown_headings() { + let root = temporary_root("git-markers"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + + let markers = || -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "conflict-markers", + "command": "git.conflictMarkers", + "payload": {"root": root} + })) + .expect("request should encode"), + )) + .expect("conflict marker response should be JSON") + }; + + // A Markdown setext heading underline looks exactly like the middle of a + // conflict block, so matching a bare `=======` would flag ordinary docs. + fs::write(root.join("doc.md"), "Title\n=======\n\nbody\n").expect("file should be writable"); + assert!(run(&["add", "doc.md"]).status.success()); + let clean = markers(); + assert_eq!(clean["ok"], true); + assert_eq!( + clean["data"]["paths"].as_array().unwrap().len(), + 0, + "a Markdown heading is not a conflict: {clean}" + ); + + // Only files carrying the opening or closing marker are real conflicts. + fs::write( + root.join("code.txt"), + "a\n<<<<<<< HEAD\nmine\n=======\ntheirs\n>>>>>>> feature\n", + ) + .expect("file should be writable"); + // The diff3 style adds a `|||||||` base section, which also counts. + fs::write( + root.join("diff3.txt"), + "x\n<<<<<<< HEAD\na\n||||||| base\nb\n=======\nc\n>>>>>>> other\n", + ) + .expect("file should be writable"); + assert!(run(&["add", "."]).status.success()); + + let found = markers(); + let paths = found["data"]["paths"].as_array().unwrap(); + assert_eq!(paths.len(), 2, "{found}"); + assert_eq!(paths[0], "code.txt"); + assert_eq!(paths[1], "diff3.txt"); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} + +#[test] +fn git_integration_preflight_separates_merge_overlap_from_rebase_strictness() { + let root = temporary_root("git-integration"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + + fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); + fs::write(root.join("other.txt"), "untouched\n").expect("file should be writable"); + assert!(run(&["add", "."]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + // A side branch that only ever touches shared.txt. + assert!(run(&["switch", "-qc", "feature"]).status.success()); + fs::write(root.join("shared.txt"), "incoming\n").expect("file should be writable"); + assert!(run(&["add", "shared.txt"]).status.success()); + assert!(run(&["commit", "-qm", "incoming"]).status.success()); + assert!(run(&["switch", "-q", "main"]).status.success()); + // Move main forward so the branches genuinely diverge. + fs::write(root.join("main.txt"), "main\n").expect("file should be writable"); + assert!(run(&["add", "main.txt"]).status.success()); + assert!(run(&["commit", "-qm", "main side"]).status.success()); + + let preflight = |operation: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "integration-preflight", + "command": "git.integrationPreflight", + "payload": { + "root": root, + "reference": "refs/heads/feature", + "operation": operation + } + })) + .expect("request should encode"), + )) + .expect("integration preflight response should be JSON") + }; + + // A clean tree blocks neither operation. + assert_eq!( + preflight("merge")["data"]["blockingPaths"] + .as_array() + .unwrap() + .len(), + 0 + ); + assert_eq!( + preflight("rebase")["data"]["blockingPaths"] + .as_array() + .unwrap() + .len(), + 0 + ); + + // Dirty a file the incoming branch never touches. Git lets a merge proceed + // here but still refuses a rebase, so the two must report differently. + fs::write(root.join("other.txt"), "local edit\n").expect("file should be writable"); + + let merge = preflight("merge"); + assert_eq!(merge["ok"], true); + assert_eq!( + merge["data"]["blockingPaths"].as_array().unwrap().len(), + 0, + "an unrelated edit should not block a merge: {merge}" + ); + assert_eq!(merge["data"]["blocksEntirely"], false); + + let rebase = preflight("rebase"); + assert_eq!(rebase["data"]["blockingPaths"][0], "other.txt"); + assert_eq!(rebase["data"]["blocksEntirely"], true); + + // Now dirty the file the merge would write; that one does block it. + fs::write(root.join("shared.txt"), "local edit\n").expect("file should be writable"); + let overlapping = preflight("merge"); + assert_eq!(overlapping["data"]["blockingPaths"][0], "shared.txt"); + assert_eq!( + overlapping["data"]["blockingPaths"] + .as_array() + .unwrap() + .len(), + 1, + "only the overlapping file blocks: {overlapping}" + ); + + // An unknown operation is rejected rather than guessed at. + let invalid = serde_json::from_str::(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "integration-preflight", + "command": "git.integrationPreflight", + "payload": { + "root": root, + "reference": "refs/heads/feature", + "operation": "graft" + } + })) + .expect("request should encode"), + )) + .expect("response should be JSON"); + assert_eq!(invalid["ok"], false); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} + +#[test] +fn git_integration_preflight_scopes_cherry_pick_to_the_replayed_commit() { + let root = temporary_root("git-cherry-pick"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + let run = |arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(&root) + .output() + .expect("git should be available") + }; + assert!(run(&["init", "-q", "-b", "main"]).status.success()); + assert!(run(&["config", "core.autocrlf", "false"]).status.success()); + assert!(run(&["config", "user.email", "test@example.com"]) + .status + .success()); + assert!(run(&["config", "user.name", "Lithe Test"]).status.success()); + + fs::write(root.join("shared.txt"), "base\n").expect("file should be writable"); + fs::write(root.join("other.txt"), "untouched\n").expect("file should be writable"); + assert!(run(&["add", "."]).status.success()); + assert!(run(&["commit", "-qm", "initial"]).status.success()); + + // A side branch of two commits. Only the second one touches shared.txt, so + // picking it must consider that file alone rather than the whole branch. + assert!(run(&["switch", "-qc", "feature"]).status.success()); + fs::write(root.join("early.txt"), "early\n").expect("file should be writable"); + assert!(run(&["add", "early.txt"]).status.success()); + assert!(run(&["commit", "-qm", "earlier work"]).status.success()); + fs::write(root.join("shared.txt"), "incoming\n").expect("file should be writable"); + assert!(run(&["add", "shared.txt"]).status.success()); + assert!(run(&["commit", "-qm", "touches shared"]).status.success()); + let pick = + String::from_utf8(run(&["rev-parse", "HEAD"]).stdout).expect("a revision should be UTF-8"); + let pick = pick.trim().to_string(); + assert!(run(&["switch", "-q", "main"]).status.success()); + + let preflight = |operation: &str, reference: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "integration-preflight", + "command": "git.integrationPreflight", + "payload": { + "root": root, + "reference": reference, + "operation": operation + } + })) + .expect("request should encode"), + )) + .expect("integration preflight response should be JSON") + }; + + // An edit to a file the picked commit never touches is not in its way, the + // same rule a merge follows and unlike a rebase. + fs::write(root.join("other.txt"), "local edit\n").expect("file should be writable"); + for operation in ["cherryPick", "revert"] { + let clear = preflight(operation, &pick); + assert_eq!(clear["ok"], true, "{operation} should succeed: {clear}"); + assert_eq!( + clear["data"]["blockingPaths"].as_array().unwrap().len(), + 0, + "an unrelated edit should not block {operation}: {clear}" + ); + assert_eq!(clear["data"]["blocksEntirely"], false); + } + + // Dirtying the file that commit rewrites does block it. + fs::write(root.join("shared.txt"), "local edit\n").expect("file should be writable"); + let blocked = preflight("cherryPick", &pick); + assert_eq!(blocked["data"]["blockingPaths"][0], "shared.txt"); + assert_eq!( + blocked["data"]["blockingPaths"].as_array().unwrap().len(), + 1, + "only the file the commit writes blocks it: {blocked}" + ); + + // The branch tip as a whole also adds early.txt, but picking the single + // commit must not inherit that; a merge of the same ref would report it. + let merge = preflight("merge", "refs/heads/feature"); + let merge_blocking = merge["data"]["blockingPaths"].as_array().unwrap(); + assert!( + merge_blocking.iter().any(|path| path == "shared.txt"), + "the merge shares the overlap: {merge}" + ); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} + +#[test] +fn git_pull_preflight_reports_divergence_and_strategies_resolve_it() { + let root = temporary_root("git-pull"); + let upstream = root.join("upstream"); + let work = root.join("work"); + fs::create_dir_all(&upstream).expect("temporary workspace should be creatable"); + + let git = |directory: &std::path::Path, arguments: &[&str]| { + Command::new("git") + .args(arguments) + .current_dir(directory) + .output() + .expect("git should be available") + }; + let identify = |directory: &std::path::Path| { + assert!(git(directory, &["config", "core.autocrlf", "false"]) + .status + .success()); + assert!( + git(directory, &["config", "user.email", "test@example.com"]) + .status + .success() + ); + assert!(git(directory, &["config", "user.name", "Lithe Test"]) + .status + .success()); + }; + + assert!(git(&upstream, &["init", "-q", "-b", "main"]) + .status + .success()); + identify(&upstream); + fs::write(upstream.join("shared.txt"), "base\n").expect("file should be writable"); + assert!(git(&upstream, &["add", "shared.txt"]).status.success()); + assert!(git(&upstream, &["commit", "-qm", "initial"]) + .status + .success()); + + assert!(git( + &root, + &[ + "clone", + "-q", + "-c", + "core.autocrlf=false", + "upstream", + "work" + ] + ) + .status + .success()); + identify(&work); + + let preflight = || -> Value { + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "pull-preflight", + "command": "git.pullPreflight", + "payload": {"root": work} + })) + .expect("request should encode"), + )) + .expect("pull preflight response should be JSON") + }; + + // A fresh clone is level with its upstream, so nothing needs deciding. + let clean = preflight(); + assert_eq!(clean["ok"], true); + assert_eq!(clean["data"]["upstream"], "origin/main"); + assert_eq!(clean["data"]["diverged"], false); + assert_eq!(clean["data"]["ahead"], 0); + assert_eq!(clean["data"]["behind"], 0); + + // Commit on both sides so neither can fast-forward past the other. + fs::write(upstream.join("remote.txt"), "remote\n").expect("file should be writable"); + assert!(git(&upstream, &["add", "remote.txt"]).status.success()); + assert!(git(&upstream, &["commit", "-qm", "remote"]) + .status + .success()); + fs::write(work.join("local.txt"), "local\n").expect("file should be writable"); + assert!(git(&work, &["add", "local.txt"]).status.success()); + assert!(git(&work, &["commit", "-qm", "local"]).status.success()); + assert!(git(&work, &["fetch", "-q"]).status.success()); + + let diverged = preflight(); + assert_eq!(diverged["data"]["diverged"], true); + assert_eq!(diverged["data"]["ahead"], 1); + assert_eq!(diverged["data"]["behind"], 1); + + let pull = |mode: Option<&str>| -> Value { + let mut payload = serde_json::json!({"root": work, "operation": "pull"}); + if let Some(mode) = mode { + payload["mode"] = serde_json::json!(mode); + } + serde_json::from_str(&execute_json( + &serde_json::to_string(&serde_json::json!({ + "id": "pull", + "command": "git.write", + "payload": payload + })) + .expect("request should encode"), + )) + .expect("pull response should be JSON") + }; + + // The default refuses a divergent history rather than inventing a merge. + let refused = pull(None); + assert_ne!(refused["data"]["exitCode"], 0); + + // Rebase replays the local commit on top, leaving a linear history. + let rebased = pull(Some("rebase")); + assert_eq!(rebased["data"]["exitCode"], 0, "{rebased}"); + + let settled = preflight(); + assert_eq!(settled["data"]["diverged"], false); + assert_eq!(settled["data"]["behind"], 0); + assert_eq!(settled["data"]["ahead"], 1); + + // An unknown strategy is rejected before Git ever runs. + let invalid = pull(Some("squash")); + assert_eq!(invalid["ok"], false); + + fs::remove_dir_all(root).expect("Git fixture should be removable"); +} diff --git a/rust/lithe-core/src/tests/languages.rs b/rust/lithe-core/src/tests/languages.rs new file mode 100644 index 000000000..94db4b73d --- /dev/null +++ b/rust/lithe-core/src/tests/languages.rs @@ -0,0 +1,198 @@ +use super::support::temporary_root; +use crate::execute_json; +use serde_json::Value; +use std::fs; + +#[test] +fn maven_scan_returns_recursive_shared_project_model() { + let root = temporary_root("maven"); + fs::create_dir_all(root.join("module-a/module-b")).expect("modules should be creatable"); + fs::write( + root.join("pom.xml"), + r#"com.exampledemo1pommodule-adevtrue"#, + ) + .expect("root pom should be writable"); + fs::write( + root.join("module-a/pom.xml"), + r#"onemodule-b"#, + ) + .expect("module pom should be writable"); + fs::write( + root.join("module-a/module-b/pom.xml"), + r#"two"#, + ) + .expect("nested pom should be writable"); + fs::write(root.join("mvnw.cmd"), "@echo off\n").expect("wrapper should be writable"); + + let request = serde_json::json!({ + "id": "maven", + "command": "maven.scan", + "payload": {"root": root} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Maven request should encode"), + )) + .expect("Maven response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["artifactId"], "demo"); + assert_eq!(response["data"]["packaging"], "pom"); + assert_eq!(response["data"]["profiles"][0]["id"], "dev"); + assert_eq!(response["data"]["hasWrapper"], true); + assert_eq!(response["data"]["modules"][0]["relativePath"], "module-a"); + assert_eq!( + response["data"]["modules"][0]["modules"][0]["relativePath"], + "module-a/module-b" + ); + let diagnostics = serde_json::json!({ + "id": "maven-diagnostics", + "command": "maven.diagnostics", + "payload": { + "root": root, + "output": "[ERROR] src/App.java:[12,4] cannot find symbol\n[ERROR] src/App.java:[12,4] cannot find symbol\n[WARNING] src/App.java:[4] unused import\n" + } + }); + let diagnostics_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&diagnostics).expect("diagnostics request should encode"), + )) + .expect("diagnostics response should be JSON"); + assert_eq!(diagnostics_response["ok"], true); + assert_eq!( + diagnostics_response["data"]["issues"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!( + diagnostics_response["data"]["issues"][0]["severity"], + "error" + ); + fs::remove_dir_all(root).expect("Maven fixture should be removable"); +} + +#[test] +fn java_core_commands_return_shared_runtime_and_structure_data() { + let root = temporary_root("java"); + fs::create_dir_all(root.join("src/main/java/com/example")) + .expect("Java source should be creatable"); + fs::write( + root.join("src/main/java/com/example/App.java"), + "package com.example;\n@SpringBootApplication\nclass App {\n static void main(String[] args) {}\n}\n", + ) + .expect("Java source should be writable"); + let configurations = serde_json::json!({ + "id": "java-config", + "command": "java.runConfigurations", + "payload": { + "root": root, + "paths": ["src/main/java/com/example/App.java"], + "modulePaths": ["src"] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&configurations).expect("Java request should encode"), + )) + .expect("Java response should be JSON"); + assert_eq!(response["ok"], true); + assert_eq!( + response["data"]["mainClasses"][0]["qualifiedName"], + "com.example.App" + ); + assert_eq!(response["data"]["configurations"][0]["kind"], "springBoot"); + assert_eq!(response["data"]["configurations"][0]["modulePath"], "src"); + + let structure = serde_json::json!({ + "id": "java-structure", + "command": "java.structure", + "payload": { + "source": "import a.A;\nimport b.B;\ninterface Service {}\n" + } + }); + let structure_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&structure).expect("Java structure request should encode"), + )) + .expect("Java structure response should be JSON"); + assert_eq!(structure_response["ok"], true); + assert_eq!( + structure_response["data"]["foldRegions"][0]["kind"], + "imports" + ); + assert_eq!( + structure_response["data"]["implementationMarkers"][0]["direction"], + "down" + ); + let swift_structure = serde_json::json!({ + "id": "swift-structure", + "command": "java.structure", + "payload": { + "source": "struct Demo {\n func run() {\n if ready {\n work()\n }\n }\n}\n" + } + }); + let swift_structure_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&swift_structure).expect("Swift structure request should encode"), + )) + .expect("Swift structure response should be JSON"); + let swift_folds = swift_structure_response["data"]["foldRegions"] + .as_array() + .expect("Swift structure should return fold regions"); + assert!(swift_folds + .iter() + .any(|fold| { fold["startLine"] == 0 && fold["endLine"] == 6 && fold["kind"] == "type" })); + assert!(swift_folds.iter().any(|fold| { + fold["startLine"] == 1 && fold["endLine"] == 5 && fold["kind"] == "method" + })); + let code_vision = serde_json::json!({ + "id": "java-vision", + "command": "java.codeVision", + "payload": { + "root": root, + "targetPath": "src/main/java/com/example/App.java", + "paths": ["src/main/java/com/example/App.java"] + } + }); + let vision_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&code_vision).expect("code vision request should encode"), + )) + .expect("code vision response should be JSON"); + assert_eq!(vision_response["ok"], true); + assert!(vision_response["data"]["hints"] + .as_array() + .unwrap() + .iter() + .any(|hint| hint["symbol"] == "App")); + let class_name = serde_json::json!({ + "id": "java-class", + "command": "java.className", + "payload": {"source": "package com.example;\nclass App {}", "simpleName": "App"} + }); + let class_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&class_name).expect("class name request should encode"), + )) + .expect("class name response should be JSON"); + assert_eq!(class_response["data"]["className"], "com.example.App"); + let definition = serde_json::json!({ + "id": "java-definition", + "command": "java.sourceDefinition", + "payload": { + "source": "class App {\n void run() {}\n}", + "declarationName": "App", + "memberName": "run" + } + }); + let definition_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&definition).expect("definition request should encode"), + )) + .expect("definition response should be JSON"); + assert_eq!(definition_response["data"]["line"], 1); + let server_port = serde_json::json!({ + "id": "java-port", + "command": "java.serverPort", + "payload": {"content": "server:\n port: 8080\n", "fileExtension": "yml"} + }); + let port_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&server_port).expect("server port request should encode"), + )) + .expect("server port response should be JSON"); + assert_eq!(port_response["data"]["port"], 8080); + fs::remove_dir_all(root).expect("Java fixture should be removable"); +} diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs new file mode 100644 index 000000000..67722294b --- /dev/null +++ b/rust/lithe-core/src/tests/mod.rs @@ -0,0 +1,7 @@ +mod detectors; +mod git; +mod languages; +mod project; +mod protocol; +mod run_configuration; +mod support; diff --git a/rust/lithe-core/src/tests/project.rs b/rust/lithe-core/src/tests/project.rs new file mode 100644 index 000000000..d01ed5b7e --- /dev/null +++ b/rust/lithe-core/src/tests/project.rs @@ -0,0 +1,468 @@ +use super::support::{fixture, temporary_root}; +use crate::execute_json; +use serde_json::Value; +use std::fs; + +#[test] +fn markdown_render_command_returns_sanitized_html() { + let request = serde_json::json!({ + "id": "markdown-1", + "command": "markdown.render", + "payload": { + "source": "# Preview\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n```plantuml\nAlice -> Bob\n```\n\n" + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Markdown request should encode"), + )) + .expect("Markdown response should be JSON"); + + assert_eq!(response["id"], "markdown-1"); + assert_eq!(response["ok"], true); + let html = response["data"]["html"] + .as_str() + .expect("Markdown response should contain HTML"); + assert!(html.contains(" Vec { + let request = serde_json::json!({ + "id": "mask", + "command": "workspace.search", + "payload": { + "root": root, + "query": "total", + "fileMask": mask + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("search request should encode"), + )) + .expect("search response should be JSON"); + assert_eq!(response["ok"], true); + response["data"]["matches"] + .as_array() + .expect("matches should be an array") + .iter() + .map(|value| value["path"].as_str().unwrap_or_default().to_string()) + .collect() + }; + + let unfiltered = search(""); + assert!(unfiltered.iter().any(|path| path.ends_with("Service.java"))); + assert!(unfiltered.iter().any(|path| path.ends_with("notes.txt"))); + + let java_only = search("*.java"); + assert!(java_only.iter().any(|path| path.ends_with("Service.java"))); + assert!(!java_only.iter().any(|path| path.ends_with("notes.txt"))); + + // 多个掩码取并集,且容忍逗号后的空格。 + let both = search("*.java, *.txt"); + assert!(both.iter().any(|path| path.ends_with("Service.java"))); + assert!(both.iter().any(|path| path.ends_with("notes.txt"))); + + fs::remove_dir_all(root).expect("temporary fixture should be removable"); +} + +#[test] +fn preserve_case_matches_original_occurrence_shape() { + let root = temporary_root("preserve-case"); + fs::create_dir_all(&root).expect("fixture directory should be creatable"); + let relative = "Sample.java"; + fs::write(root.join(relative), "fooBar FooBar FOOBAR fooBar();\n") + .expect("fixture should be writable"); + + let replace = |preserve_case: bool| -> String { + let request = serde_json::json!({ + "id": "preserve", + "command": "workspace.replacePreview", + "payload": { + "root": root, + "query": "fooBar", + "replacement": "bazQux", + "caseSensitive": false, + "preserveCase": preserve_case, + "paths": [relative] + } + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("replace request should encode"), + )) + .expect("replace response should be JSON"); + assert_eq!(response["ok"], true); + response["data"]["files"][0]["matches"][0]["after"] + .as_str() + .expect("after text should be a string") + .to_string() + }; + + assert_eq!(replace(false), "bazQux bazQux bazQux bazQux();"); + assert_eq!(replace(true), "bazQux BazQux BAZQUX bazQux();"); + + fs::remove_dir_all(root).expect("temporary fixture should be removable"); +} + +#[test] +fn local_history_records_deduplicates_lists_and_relocates() { + let root = temporary_root("history"); + fs::create_dir_all(&root).expect("history workspace should be creatable"); + let storage = root.join("history-storage"); + + let request = |command: &str, payload: Value| -> Value { + let request = serde_json::json!({ + "id": command, + "command": command, + "payload": payload + }); + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("history request should encode"), + )) + .expect("history response should be JSON") + }; + + let record_payload = |content: &str| { + serde_json::json!({ + "workspaceRoot": root, + "storageRoot": storage, + "path": "src/Main.java", + "reason": "saved", + "content": content, + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [] + }) + }; + let first = request("history.record", record_payload("one\n")); + assert_eq!(first["ok"], true); + assert!(first["data"]["id"].as_str().is_some()); + let duplicate = request("history.record", record_payload("one\n")); + assert_eq!(duplicate["ok"], true); + assert!(duplicate["data"].is_null()); + let mut invalid_reason = record_payload("invalid\n"); + invalid_reason["reason"] = serde_json::json!("not-a-history-reason"); + let invalid = request("history.record", invalid_reason); + assert_eq!(invalid["ok"], false); + assert_eq!(invalid["error"]["code"], "invalid_request"); + + let second = request("history.record", record_payload("two\n")); + assert_eq!(second["ok"], true); + let listed = request( + "history.entries", + serde_json::json!({ + "workspaceRoot": root, + "storageRoot": storage, + "path": "src/Main.java" + }), + ); + assert_eq!(listed["ok"], true); + assert_eq!(listed["data"]["entries"].as_array().unwrap().len(), 2); + let content_path = listed["data"]["entries"][0]["contentPath"] + .as_str() + .unwrap(); + let content = request( + "history.content", + serde_json::json!({ + "storageRoot": storage, + "contentPath": content_path + }), + ); + assert_eq!(content["data"]["text"], "two\n"); + + let relocated = request( + "history.relocate", + serde_json::json!({ + "storageRoot": storage, + "sourcePath": "src/Main.java", + "destinationPath": "src/Renamed.java" + }), + ); + assert_eq!(relocated["ok"], true); + let relocated_entries = request( + "history.entries", + serde_json::json!({ + "workspaceRoot": root, + "storageRoot": storage, + "path": "src/Renamed.java" + }), + ); + assert_eq!( + relocated_entries["data"]["entries"] + .as_array() + .unwrap() + .len(), + 2 + ); + + let traversal = request( + "history.content", + serde_json::json!({ + "storageRoot": storage, + "contentPath": "../outside.snapshot" + }), + ); + assert_eq!(traversal["ok"], false); + assert_eq!(traversal["error"]["code"], "invalid_request"); + fs::remove_dir_all(root).expect("history workspace should be removable"); +} + +#[test] +fn file_commands_round_trip_and_reject_traversal() { + let root = temporary_root("file"); + let outside = temporary_root("outside"); + fs::create_dir_all(&root).expect("temporary workspace should be creatable"); + fs::create_dir_all(&outside).expect("outside directory should be creatable"); + + let write = serde_json::json!({ + "id": "write", + "command": "file.write", + "payload": {"root": root, "path": "nested/example.txt", "text": "hello"} + }); + let write_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&write).expect("write request should encode"), + )) + .expect("write response should be JSON"); + assert_eq!(write_response["ok"], true); + + let read = serde_json::json!({ + "id": "read", + "command": "file.read", + "payload": {"root": root, "path": "nested/example.txt"} + }); + let read_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&read).expect("read request should encode"), + )) + .expect("read response should be JSON"); + assert_eq!(read_response["data"]["text"], "hello"); + + let traversal = serde_json::json!({ + "id": "traversal", + "command": "file.read", + "payload": {"root": root, "path": "../outside.txt"} + }); + let traversal_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&traversal).expect("traversal request should encode"), + )) + .expect("traversal response should be JSON"); + assert_eq!(traversal_response["ok"], false); + assert_eq!(traversal_response["error"]["code"], "invalid_request"); + + for path in [ + "..\\outside.txt", + "nested\\..\\outside.txt", + "C:\\outside.txt", + ] { + let request = serde_json::json!({ + "id": "windows-path", + "command": "file.read", + "payload": {"root": root, "path": path} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("Windows path request should encode"), + )) + .expect("Windows path response should be JSON"); + assert_eq!(response["ok"], false, "path {path} should be rejected"); + assert_eq!(response["error"]["code"], "invalid_request"); + } + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&outside, root.join("link")) + .expect("test symlink should be creatable"); + let symlink_write = serde_json::json!({ + "id": "symlink-write", + "command": "file.write", + "payload": {"root": root, "path": "link/escape.txt", "text": "outside"} + }); + let symlink_response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&symlink_write).expect("symlink request should encode"), + )) + .expect("symlink response should be JSON"); + assert_eq!(symlink_response["ok"], false); + assert_eq!(symlink_response["error"]["code"], "permission_denied"); + assert!(!outside.join("escape.txt").exists()); + } + + fs::remove_dir_all(root).expect("temporary workspace should be removable"); + fs::remove_dir_all(outside).expect("outside fixture should be removable"); +} diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs new file mode 100644 index 000000000..fcd09f393 --- /dev/null +++ b/rust/lithe-core/src/tests/protocol.rs @@ -0,0 +1,14 @@ +use crate::execute_json; +use serde_json::Value; + +#[test] +fn ping_exposes_protocol_version() { + let response: Value = serde_json::from_str(&execute_json( + r#"{"id":"test-1","command":"core.ping","payload":{}}"#, + )) + .expect("ping response should be JSON"); + + assert_eq!(response["ok"], true); + assert_eq!(response["data"]["protocolVersion"], 1); + assert_eq!(response["data"]["coreVersion"], "0.1.0"); +} diff --git a/rust/lithe-core/src/tests/run_configuration.rs b/rust/lithe-core/src/tests/run_configuration.rs new file mode 100644 index 000000000..fde8fe2e2 --- /dev/null +++ b/rust/lithe-core/src/tests/run_configuration.rs @@ -0,0 +1,1031 @@ +use super::support::temporary_root; +use crate::execute_json; +use serde_json::Value; +use std::fs; +use std::path::PathBuf; + +#[test] +fn run_configuration_commands_generate_merge_and_plan() { + let root = temporary_root("run-config"); + fs::create_dir_all(root.join("src/main/java/com/example")) + .expect("source directory should be creatable"); + fs::write(root.join("src/main/java/com/example/App.java"), "package com.example; @SpringBootApplication class App { public static void main(String[] args) {} }").expect("source should be writable"); + fs::write(root.join("pom.xml"), "21").expect("pom should be writable"); + + let request = serde_json::json!({"id":"generate","command":"runConfig.generate","payload":{"root":root,"paths":["src/main/java/com/example/App.java"],"modulePaths":[]}}); + let generated: Value = serde_json::from_str(&execute_json(&request.to_string())) + .expect("generate response should be JSON"); + assert_eq!(generated["ok"], true); + assert_eq!(generated["data"]["generated"]["version"], 2); + assert!(generated["data"]["generated"]["configurations"] + .as_array() + .unwrap() + .iter() + .any(|v| v["id"] == "current-file")); + + let generated_doc = serde_json::to_string(&generated["data"]["generated"]).unwrap(); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write(root.join(".lithe/run/generated.json"), generated_doc).unwrap(); + fs::write(root.join(".lithe/run/configurations.json"), r#"{"version":1,"configurations":[{"id":"current-file","name":"My File","type":"java.current-file","workingDirectory":"backend","jvmArguments":["-Xmx2g"],"toolchains":{"maven":"custom-maven"}}]}"#).unwrap(); + fs::write(root.join(".lithe/run/local.json"), r#"{"version":1,"configurations":[{"id":"current-file","name":"Local File","type":"java.current-file","workingDirectory":".","programArguments":["--dev"],"toolchains":{"java":"custom-jdk"}}]}"#).unwrap(); + + let resolve: Value = serde_json::from_str(&execute_json( + &serde_json::json!({"id":"resolve","command":"runConfig.resolve","payload":{"root":root}}) + .to_string(), + )) + .unwrap(); + assert_eq!(resolve["ok"], true); + let current = resolve["data"]["configurations"] + .as_array() + .unwrap() + .iter() + .find(|v| v["id"] == "current-file") + .unwrap(); + assert_eq!(current["name"], "Local File"); + assert_eq!(current["toolchains"]["java"], "custom-jdk"); + assert_eq!(current["toolchains"]["maven"], "custom-maven"); + let plan: Value = serde_json::from_str(&execute_json(&serde_json::json!({"id":"plan","command":"runConfig.createLaunchPlan","payload":{"root":root,"configurationId":"current-file","currentFile":"src/main/java/com/example/App.java"}}).to_string())).unwrap(); + assert_eq!(plan["ok"], true); + assert_eq!(plan["data"]["executable"]["toolchain"], "custom-jdk"); + let debug_plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id":"debug-plan", + "command":"runConfig.createLaunchPlan", + "payload":{ + "root":root, + "configurationId":"spring:com.example.App", + "debugPort":5005 + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(debug_plan["ok"], true); + assert!(debug_plan["data"]["arguments"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .any(|argument| argument.contains("address=127.0.0.1:5005"))); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_generation_infers_maven_modules_from_nearest_pom() { + let root = temporary_root("run-config-inferred-modules"); + let backend = "backend-api/src/main/java/com/example/BackendApplication.java"; + let worker = "batch-worker/src/main/java/com/example/WorkerMain.java"; + fs::create_dir_all(root.join("backend-api/src/main/java/com/example")).unwrap(); + fs::create_dir_all(root.join("batch-worker/src/main/java/com/example")).unwrap(); + fs::write(root.join("pom.xml"), "").unwrap(); + fs::write(root.join("backend-api/pom.xml"), "").unwrap(); + fs::write(root.join("batch-worker/pom.xml"), "").unwrap(); + fs::write( + root.join(backend), + "package com.example; @SpringBootApplication class BackendApplication { public static void main(String[] args) {} }", + ) + .unwrap(); + fs::write( + root.join(worker), + "package com.example; class WorkerMain { public static void main(String[] args) {} }", + ) + .unwrap(); + + let response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-inferred-modules", + "command": "runConfig.generate", + "payload": { + "root": root, + "paths": [backend, worker], + "modulePaths": [] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true); + let configurations = response["data"]["generated"]["configurations"] + .as_array() + .unwrap(); + assert!(configurations.iter().any(|value| { + value["id"] == "spring:com.example.BackendApplication" + && value["extensions"]["maven"]["module"] == "backend-api" + })); + assert!(configurations.iter().any(|value| { + value["id"] == "java-main:com.example.WorkerMain" + && value["extensions"]["maven"]["module"] == "batch-worker" + && value["execution"] == "application" + })); + assert!(!configurations + .iter() + .any(|value| value["provider"] == "maven.module")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn ordinary_java_main_uses_an_application_launch_plan() { + let root = temporary_root("run-config-java-main"); + let source = "batch-worker/src/main/java/com/example/WorkerMain.java"; + fs::create_dir_all(root.join("batch-worker/src/main/java/com/example")).unwrap(); + fs::write(root.join("pom.xml"), "").unwrap(); + fs::write(root.join("batch-worker/pom.xml"), "").unwrap(); + fs::write( + root.join(source), + "package com.example; class WorkerMain { public static void main(String[] args) {} }", + ) + .unwrap(); + + let generated_response: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-java-main", + "command": "runConfig.generate", + "payload": {"root": root, "paths": [source], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + let generated = &generated_response["data"]["generated"]; + let java_main = generated["configurations"] + .as_array() + .unwrap() + .iter() + .find(|value| value["provider"] == "java.main") + .unwrap(); + assert_eq!(java_main["execution"], "application"); + assert_eq!(java_main["extensions"]["maven"]["module"], "batch-worker"); + + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(generated).unwrap(), + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "plan-java-main", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": "java-main:com.example.WorkerMain" + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["toolchain"], "project-maven"); + assert!(plan["data"]["arguments"] + .as_array() + .unwrap() + .iter() + .any(|value| value == "-Dexec.mainClass=com.example.WorkerMain")); + assert_eq!( + plan["data"]["arguments"] + .as_array() + .unwrap() + .last() + .unwrap(), + "org.codehaus.mojo:exec-maven-plugin:3.5.0:java" + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_inspect_reports_malformed_and_unsupported_documents() { + let root = temporary_root("run-config-errors"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write(root.join(".lithe/run/generated.json"), "{").unwrap(); + + let inspect = |id: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": id, + "command": "runConfig.inspect", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap() + }; + let malformed = inspect("malformed"); + assert_eq!(malformed["ok"], false); + assert_eq!(malformed["error"]["code"], "parse_failed"); + + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":3,"configurations":[]}"#, + ) + .unwrap(); + let unsupported = inspect("unsupported"); + assert_eq!(unsupported["ok"], false); + assert_eq!(unsupported["error"]["code"], "not_supported"); + assert!(unsupported["error"]["details"] + .as_str() + .unwrap() + .contains("found 3")); + + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[]}"#, + ) + .unwrap(); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::write(root.join(".lithe/toolchains/local.json"), "{").unwrap(); + let malformed_toolchains = inspect("malformed-toolchains"); + assert_eq!(malformed_toolchains["ok"], false); + assert_eq!(malformed_toolchains["error"]["code"], "parse_failed"); + assert!(malformed_toolchains["error"]["message"] + .as_str() + .unwrap() + .contains(".lithe/toolchains/local.json")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_mutations_are_shared_and_validated() { + let root = temporary_root("run-config-mutations"); + fs::create_dir_all(root.join("src/main/java/com/example")).unwrap(); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join("src/main/java/com/example/App.java"), + "package com.example; class App { public static void main(String[] args) {} }", + ) + .unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file","toolchains":{"java":"project-jdk"}}]}"#, + ) + .unwrap(); + + let updated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "update-options", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "project", + "configurationId": "current-file", + "workingDirectory": ".", + "jvmArguments": "\"-Dlabel=hello world\" -Xmx2g", + "programArguments": "--dev", + "mavenProfiles": ["dev"] + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(updated["ok"], true); + let updated_document: Value = + serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap(); + assert_eq!( + updated_document["configurations"][0]["extensions"]["maven"]["jvmArguments"], + serde_json::json!(["-Dlabel=hello world", "-Xmx2g"]) + ); + fs::write( + root.join(".lithe/run/configurations.json"), + updated["data"]["document"].as_str().unwrap(), + ) + .unwrap(); + + let create = |name: &str, module: &str, main_class: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "create-user", + "command": "runConfig.createUserConfiguration", + "payload": { + "root": root, + "scope": "project", + "name": name, + "type": "springBoot", + "module": module, + "mainClass": main_class + } + }) + .to_string(), + )) + .unwrap() + }; + let first = create("Backend Dev", ".", "com.example.App"); + assert_eq!(first["data"]["id"], "user:backend-dev"); + fs::write( + root.join(".lithe/run/configurations.json"), + first["data"]["document"].as_str().unwrap(), + ) + .unwrap(); + let second = create("Backend Dev", ".", "com.example.App"); + assert_eq!(second["data"]["id"], "user:backend-dev-2"); + assert_eq!( + create("Outside", "../outside", "com.example.App")["ok"], + false + ); + assert_eq!(create("Missing", ".", "com.example.Missing")["ok"], false); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_generation_detects_declared_toolchain_versions() { + let root = temporary_root("run-config-toolchains"); + fs::create_dir_all(root.join(".mvn/wrapper")).unwrap(); + fs::write(root.join(".sdkmanrc"), "java=21.0.5-tem\n").unwrap(); + fs::write(root.join("mvnw"), "#!/bin/sh\n").unwrap(); + fs::write( + root.join(".mvn/wrapper/maven-wrapper.properties"), + "distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip\n", + ) + .unwrap(); + + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-toolchains", + "command": "runConfig.generate", + "payload": {"root": root, "paths": [], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(generated["ok"], true); + assert_eq!( + generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["minimumVersion"], + "21" + ); + assert_eq!( + generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["preferredVendor"], + "temurin" + ); + assert_eq!( + generated["data"]["toolchainRequirements"]["toolchains"]["project-maven"]["version"], + "3.9.9" + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_generation_detects_maven_compiler_target() { + let root = temporary_root("run-config-compiler-target"); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("pom.xml"), + "17", + ) + .unwrap(); + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-target", + "command": "runConfig.generate", + "payload": {"root": root, "paths": [], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + assert_eq!( + generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"]["minimumVersion"], + "17" + ); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_inspection_summarizes_changed_inputs() { + let root = temporary_root("run-config-input-summary"); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write(root.join("src/App.java"), "class App {}").unwrap(); + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-summary", + "command": "runConfig.generate", + "payload": {"root": root, "paths": ["src/App.java"], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + let generated_again: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-summary-again", + "command": "runConfig.generate", + "payload": {"root": root, "paths": ["src/App.java"], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(generated["data"], generated_again["data"]); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + serde_json::to_string(&generated["data"]["generated"]).unwrap(), + ) + .unwrap(); + fs::write(root.join("src/App.java"), "class App { int changed; }").unwrap(); + + let inspected: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "inspect-summary", + "command": "runConfig.inspect", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(inspected["ok"], true); + assert_eq!( + inspected["data"]["diagnostics"][0]["message"], + "Project inputs changed: 0 added, 0 removed, 1 modified" + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_resolve_matches_toolchains_and_rejects_unsafe_paths() { + let root = temporary_root("run-config-toolchain-resolution"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"}]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/toolchains/requirements.json"), + r#"{"version":1,"toolchains":{"project-jdk":{"type":"java","minimumVersion":"21","preferredVendor":"temurin"}}}"#, + ) + .unwrap(); + + let resolve = |version: &str, vendor: &str| -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-toolchains", + "command": "runConfig.resolve", + "payload": { + "root": root, + "toolchainCandidates": [{ + "id": "project-jdk", + "type": "java", + "version": version, + "vendor": vendor + }] + } + }) + .to_string(), + )) + .unwrap() + }; + let mismatch = resolve("17.0.12", "Zulu"); + assert!(mismatch["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "toolchainVersionMismatch")); + assert!(mismatch["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "toolchainVendorMismatch")); + + let matching = resolve("21.0.5", "Eclipse Temurin"); + assert!(matching["data"]["diagnostics"] + .as_array() + .unwrap() + .is_empty()); + + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":1,"configurations":[{"id":"current-file","workingDirectory":"../outside"}]}"#, + ) + .unwrap(); + let unsafe_path = resolve("21.0.5", "Eclipse Temurin"); + assert_eq!(unsafe_path["ok"], false); + assert_eq!(unsafe_path["error"]["code"], "invalid_request"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn run_configuration_main_class_validation_uses_the_declared_package() { + let root = temporary_root("run-config-main-class-package"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join("src/main/java/other")).unwrap(); + fs::write( + root.join("src/main/java/other/App.java"), + "package other; class App {}", + ) + .unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{"id":"spring:com.example.App","name":"App","type":"spring-boot.maven","mainClass":"com.example.App"}]}"#, + ) + .unwrap(); + + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-main-class", + "command": "runConfig.resolve", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true); + assert!(resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "missingMainClass")); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn shared_run_configuration_fixtures_have_the_versioned_contract_shape() { + let directory = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../shared/fixtures/run-configuration"); + let mut fixture_count = 0; + for entry in fs::read_dir(directory).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + fixture_count += 1; + let value: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(value["version"], 1, "{}", path.display()); + assert!(value["expected"].is_object(), "{}", path.display()); + if let Some(generated) = value.get("generated") { + assert!(generated["version"].is_number(), "{}", path.display()); + assert!(generated["configurations"].is_array(), "{}", path.display()); + } + } + assert!(fixture_count >= 6); +} + +#[test] +fn run_configuration_resolve_diagnoses_orphans_and_deleted_modules() { + let root = temporary_root("run-config-diagnostics"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"},{"id":"module:deleted","name":"Deleted","type":"maven.module","module":"deleted"}]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":1,"configurations":[{"id":"module:old","jvmArguments":["-Xmx1g"]}]}"#, + ) + .unwrap(); + + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-diagnostics", + "command": "runConfig.resolve", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true); + assert!(resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "orphanedOverride")); + assert!(resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "missingModule")); + + fs::write( + root.join(".lithe/run/local.json"), + r#"{"version":1,"configurations":[{"id":"module:deleted","jvmArguments":["-Xmx1g"]}]}"#, + ) + .unwrap(); + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "resolve-missing-module", + "command": "runConfig.resolve", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true); + assert_eq!( + resolved["data"]["configurations"].as_array().unwrap().len(), + 1 + ); + assert!(resolved["data"]["diagnostics"] + .as_array() + .unwrap() + .iter() + .any(|value| value["code"] == "missingModule")); + + fs::remove_dir_all(root).unwrap(); +} + +/// The v1 -> v2 rewrite must not change a single byte of the emitted command +/// line. Values are asserted literally rather than recomputed, so a future +/// refactor that silently drops an argument fails here instead of at runtime. +#[test] +fn migrated_v1_documents_produce_identical_launch_arguments() { + let root = temporary_root("run-config-migration"); + fs::create_dir_all(root.join("backend/src/main/java/com/example")).unwrap(); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write(root.join("pom.xml"), "").unwrap(); + fs::write(root.join("backend/pom.xml"), "").unwrap(); + fs::write( + root.join("backend/src/main/java/com/example/App.java"), + "package com.example; class App { public static void main(String[] args) {} }", + ) + .unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{ + "id":"spring:com.example.App", + "name":"App", + "type":"spring-boot.maven", + "module":"backend", + "workingDirectory":".", + "mainClass":"com.example.App", + "jvmArguments":["-Xmx2g"], + "programArguments":["--dev"], + "mavenProfiles":["local"], + "toolchains":{"java":"project-jdk","maven":"project-maven"} + }]}"#, + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "migrated-plan", + "command": "runConfig.createLaunchPlan", + "payload": { + "root": root, + "configurationId": "spring:com.example.App", + "debugPort": 5005 + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true); + assert_eq!( + plan["data"]["arguments"], + serde_json::json!([ + "-B", + "-ntp", + "-pl", + "backend", + "-P", + "local", + "-Dspring-boot.run.main-class=com.example.App", + "-Dspring-boot.run.jvmArguments=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:5005 -Duser.language=en -Duser.country=US -Xmx2g", + "-Dspring-boot.run.arguments=--dev", + "spring-boot:run" + ]) + ); + assert_eq!(plan["data"]["workingDirectory"], "."); + assert_eq!(plan["data"]["executable"]["toolchain"], "project-maven"); + + fs::remove_dir_all(root).unwrap(); +} + +/// `project.json` and the toolchain files sit under `.lithe` and carry their +/// own `version: 1`, unrelated to the run-configuration schema. Migration +/// must not touch them, or resolve rejects a perfectly valid project. +#[test] +fn migration_leaves_sidecar_documents_at_their_own_version() { + let root = temporary_root("run-config-sidecar"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join(".lithe/toolchains")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":1,"configurations":[{"id":"current-file","name":"Current File","type":"java.current-file"}]}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/project.json"), + r#"{"version":1,"defaultRunConfiguration":"current-file"}"#, + ) + .unwrap(); + fs::write( + root.join(".lithe/toolchains/requirements.json"), + r#"{"version":1,"toolchains":{}}"#, + ) + .unwrap(); + + let resolved: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "sidecar", + "command": "runConfig.resolve", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(resolved["ok"], true, "{resolved}"); + assert_eq!(resolved["data"]["version"], 2); + assert_eq!(resolved["data"]["defaultRunConfiguration"], "current-file"); + + fs::remove_dir_all(root).unwrap(); +} + +/// A non-Java service must reach a launch plan without acquiring a Java +/// toolchain or a JAVA_HOME it has no use for. +#[test] +fn process_configurations_launch_without_java_assumptions() { + let root = temporary_root("run-config-process"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join("frontend")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{ + "id":"npm:dev", + "name":"web dev", + "provider":"npm.script", + "execution":"service", + "confidence":"declared", + "command":"npm", + "args":["run","dev"], + "cwd":"frontend", + "env":{"PORT":"3000"}, + "toolchains":{} + }]}"#, + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "process-plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "npm:dev"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["command"], "npm"); + assert!(plan["data"]["executable"]["toolchain"].is_null()); + assert_eq!(plan["data"]["arguments"], serde_json::json!(["run", "dev"])); + assert_eq!(plan["data"]["workingDirectory"], "frontend"); + assert_eq!(plan["data"]["env"]["PORT"], "3000"); + assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn toolchain_backed_process_uses_the_generic_runtime_binding() { + let root = temporary_root("run-config-go-toolchain"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{ + "id":"go:api","name":"Go API","provider":"go.main", + "execution":"application","args":["run","./cmd/api"],"cwd":".", + "env":{"APP_ENV":"dev"},"toolchains":{"runtime":"project-go"} + }]}"#, + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "go-toolchain-plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "go:api"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!(plan["data"]["executable"]["toolchain"], "project-go"); + assert!(plan["data"]["executable"]["command"].is_null()); + assert_eq!( + plan["data"]["arguments"], + serde_json::json!(["run", "./cmd/api"]) + ); + assert_eq!(plan["data"]["env"]["APP_ENV"], "dev"); + assert!(plan["data"]["environment"]["JAVA_HOME"].is_null()); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn pure_go_generation_does_not_require_java_or_add_java_current_file() { + let root = temporary_root("pure-go-no-jdk"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("go.mod"), "module example.com/api\n\ngo 1.24\n").unwrap(); + fs::write(root.join("main.go"), "package main\nfunc main() {}\n").unwrap(); + + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-pure-go", + "command": "runConfig.generate", + "payload": {"root": root, "paths": ["go.mod", "main.go"], "modulePaths": []} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(generated["ok"], true, "{generated}"); + let configurations = generated["data"]["generated"]["configurations"] + .as_array() + .unwrap(); + assert!(configurations + .iter() + .any(|value| value["provider"] == "go.main")); + assert!(!configurations + .iter() + .any(|value| value["id"] == "current-file")); + assert!(generated["data"]["toolchainRequirements"]["toolchains"]["project-jdk"].is_null()); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn multi_language_generation_declares_runtime_requirements_and_versions() { + let root = temporary_root("generic-toolchain-requirements"); + for directory in ["python", "web", "worker/src"] { + fs::create_dir_all(root.join(directory)).unwrap(); + } + fs::write(root.join("go.mod"), "module example.com/api\n\ngo 1.24\n").unwrap(); + fs::write(root.join("main.go"), "package main\nfunc main() {}\n").unwrap(); + fs::write( + root.join("python/pyproject.toml"), + "[project]\nname = \"api\"\nrequires-python = \">=3.12\"\n[project.scripts]\napi = \"api:main\"\n", + ) + .unwrap(); + fs::write( + root.join("web/package.json"), + r#"{"engines":{"node":">=22.4"},"scripts":{"dev":"vite"}}"#, + ) + .unwrap(); + fs::write( + root.join("worker/Cargo.toml"), + "[package]\nname = \"worker\"\nversion = \"0.1.0\"\nrust-version = \"1.82\"\n", + ) + .unwrap(); + fs::write(root.join("worker/src/main.rs"), "fn main() {}\n").unwrap(); + + let generated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "generate-generic-requirements", + "command": "runConfig.generate", + "payload": {"root": root} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(generated["ok"], true, "{generated}"); + let requirements = &generated["data"]["toolchainRequirements"]["toolchains"]; + for (id, kind, version) in [ + ("project-go", "go", "1.24"), + ("project-python", "python", "3.12"), + ("project-node", "node", "22.4"), + ("project-cargo", "rust", "1.82"), + ] { + assert_eq!(requirements[id]["type"], kind, "{requirements}"); + assert_eq!( + requirements[id]["minimumVersion"], version, + "{requirements}" + ); + } + assert!(requirements["project-jdk"].is_null(), "{requirements}"); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn unknown_provider_without_an_executable_never_falls_into_maven() { + let root = temporary_root("run-config-unknown-provider"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{ + "id":"zig:app","name":"Zig App","provider":"zig.main", + "args":[],"cwd":".","toolchains":{} + }]}"#, + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "unknown-provider-plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "zig:app"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], false, "{plan}"); + assert_eq!(plan["error"]["code"], "invalid_request"); + assert!(plan["error"]["message"] + .as_str() + .unwrap_or("") + .contains("command or runtime toolchain")); + + fs::remove_dir_all(root).unwrap(); +} + +/// Generic editor options must patch the common process shape. Writing +/// them into extensions.maven makes the UI appear to save successfully +/// while Go/Python/Node launch plans continue using the old arguments. +#[test] +fn process_options_update_common_arguments_and_environment() { + let root = temporary_root("run-config-process-options"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::create_dir_all(root.join("backend")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{ + "id":"python:api","name":"API","provider":"python.script", + "command":"python3","args":["app.py"],"cwd":".","toolchains":{} + }]}"#, + ) + .unwrap(); + + let updated: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "update-process-options", + "command": "runConfig.updateOptions", + "payload": { + "root": root, + "scope": "local", + "configurationId": "python:api", + "workingDirectory": "backend", + "arguments": "app.py --port 9000", + "environment": {"APP_ENV": "test"} + } + }) + .to_string(), + )) + .unwrap(); + assert_eq!(updated["ok"], true, "{updated}"); + let document: Value = serde_json::from_str( + updated["data"]["document"] + .as_str() + .expect("document string"), + ) + .unwrap(); + let patch = &document["configurations"][0]; + assert_eq!( + patch["args"], + serde_json::json!(["app.py", "--port", "9000"]) + ); + assert_eq!(patch["env"]["APP_ENV"], "test"); + assert!(patch["extensions"]["maven"].is_null()); + + fs::write( + root.join(".lithe/run/local.json"), + updated["data"]["document"].as_str().unwrap(), + ) + .unwrap(); + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "updated-process-plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "python:api"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], true, "{plan}"); + assert_eq!( + plan["data"]["arguments"], + serde_json::json!(["app.py", "--port", "9000"]) + ); + assert_eq!(plan["data"]["env"]["APP_ENV"], "test"); + + fs::remove_dir_all(root).unwrap(); +} + +/// An absolute or relative path would let a project manifest point the IDE +/// at an executable of its choosing. Commands resolve on PATH only. +#[test] +fn process_configurations_reject_path_qualified_commands() { + let root = temporary_root("run-config-process-path"); + fs::create_dir_all(root.join(".lithe/run")).unwrap(); + fs::write( + root.join(".lithe/run/generated.json"), + r#"{"version":2,"configurations":[{ + "id":"evil","name":"evil","provider":"shell.command", + "command":"../../../usr/bin/curl","args":[],"cwd":".","toolchains":{} + }]}"#, + ) + .unwrap(); + + let plan: Value = serde_json::from_str(&execute_json( + &serde_json::json!({ + "id": "evil-plan", + "command": "runConfig.createLaunchPlan", + "payload": {"root": root, "configurationId": "evil"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(plan["ok"], false); + assert_eq!(plan["error"]["code"], "invalid_request"); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/rust/lithe-core/src/tests/support.rs b/rust/lithe-core/src/tests/support.rs new file mode 100644 index 000000000..11b191798 --- /dev/null +++ b/rust/lithe-core/src/tests/support.rs @@ -0,0 +1,19 @@ +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) fn fixture() -> Value { + let fixture_path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../shared/fixtures/search/basic.json"); + serde_json::from_str(&fs::read_to_string(fixture_path).expect("fixture should be readable")) + .expect("fixture should be valid JSON") +} + +pub(super) fn temporary_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be valid") + .as_nanos(); + std::env::temp_dir().join(format!("lithe-core-{label}-{}-{nonce}", std::process::id())) +} diff --git a/scripts/RustCoreBridgeVerification.swift b/scripts/RustCoreBridgeVerification.swift index bf305009a..395a8f82b 100644 --- a/scripts/RustCoreBridgeVerification.swift +++ b/scripts/RustCoreBridgeVerification.swift @@ -3,6 +3,9 @@ import Foundation @_silgen_name("lithe_bridge_execute_json") private func executeJSON(_ request: UnsafePointer) -> UnsafeMutablePointer? +@_silgen_name("lithe_bridge_lsp_provider_catalog_json") +private func lspProviderCatalogJSON(_ workspaceRoot: UnsafePointer?) -> UnsafeMutablePointer? + @_silgen_name("lithe_bridge_free_string") private func freeJSON(_ value: UnsafeMutablePointer) @@ -25,3 +28,222 @@ guard let data = response.data(using: .utf8), } print("Rust Core bridge response passed: \(response)") + +guard let catalogPointer = lspProviderCatalogJSON(nil) else { + fputs("Rust Core LSP provider bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(catalogPointer) } + +let catalogResponse = String(cString: catalogPointer) +guard let catalogData = catalogResponse.data(using: .utf8), + let catalog = try? JSONSerialization.jsonObject(with: catalogData) as? [String: Any], + let providers = catalog["providers"] as? [[String: Any]], + let swiftProvider = providers.first(where: { $0["id"] as? String == "swift" }), + let swiftLaunch = swiftProvider["languageServerLaunch"] as? [String: Any], + let swiftExecutables = swiftLaunch["executableNames"] as? [String], + swiftExecutables.contains("sourcekit-lsp") else { + fputs("Unexpected Rust Core LSP provider catalog: \(catalogResponse)\n", stderr) + exit(1) +} + +print("Rust Core LSP provider catalog passed: \(providers.count) providers") + +let editRequest = """ +{"id":"lsp-edit-test","command":"lsp.applyTextEdits","payload":{"text":"one 😀\\ntwo three\\n","edits":[{"range":{"start":{"line":0,"utf16Column":4},"end":{"line":0,"utf16Column":6}},"newText":"rocket"},{"range":{"start":{"line":1,"utf16Column":4},"end":{"line":1,"utf16Column":9}},"newText":"four"}]}} +""" +guard let editPointer = editRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP text edit bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(editPointer) } + +let editResponse = String(cString: editPointer) +guard let editData = editResponse.data(using: .utf8), + let editEnvelope = try? JSONSerialization.jsonObject(with: editData) as? [String: Any], + let editPayload = editEnvelope["data"] as? [String: Any], + editPayload["text"] as? String == "one rocket\ntwo four\n" else { + fputs("Unexpected Rust Core LSP text edit response: \(editResponse)\n", stderr) + exit(1) +} + +let snippetRequest = """ +{"id":"lsp-snippet-test","command":"lsp.plainSnippet","payload":{"value":"print(${1:value})$0"}} +""" +guard let snippetPointer = snippetRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP snippet bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(snippetPointer) } + +let snippetResponse = String(cString: snippetPointer) +guard let snippetData = snippetResponse.data(using: .utf8), + let snippetEnvelope = try? JSONSerialization.jsonObject(with: snippetData) as? [String: Any], + let snippetPayload = snippetEnvelope["data"] as? [String: Any], + snippetPayload["text"] as? String == "print(value)" else { + fputs("Unexpected Rust Core LSP snippet response: \(snippetResponse)\n", stderr) + exit(1) +} + +print("Rust Core LSP text edit and snippet bridge passed") + +let builtinCompletionRequest = """ +{"id":"lsp-builtin-completion-test","command":"lsp.builtinCompletions","payload":{"filePath":"/tmp/main.swift","text":"struct RocketShip {}\\nlet value = Roc\\n","position":{"line":1,"utf16Column":15}}} +""" +guard let builtinCompletionPointer = builtinCompletionRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core builtin completion bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(builtinCompletionPointer) } + +let builtinCompletionResponse = String(cString: builtinCompletionPointer) +guard let builtinCompletionData = builtinCompletionResponse.data(using: .utf8), + let builtinCompletionEnvelope = try? JSONSerialization.jsonObject(with: builtinCompletionData) as? [String: Any], + let builtinCompletionPayload = builtinCompletionEnvelope["data"] as? [String: Any], + let builtinCompletionItems = builtinCompletionPayload["items"] as? [[String: Any]], + builtinCompletionItems.contains(where: { $0["label"] as? String == "RocketShip" }) else { + fputs("Unexpected Rust Core builtin completion response: \(builtinCompletionResponse)\n", stderr) + exit(1) +} + +let builtinNavigationRequest = """ +{"id":"lsp-builtin-navigation-test","command":"lsp.builtinNavigation","payload":{"filePath":"/tmp/main.swift","text":"let service = 1\\nprint(service)\\n","position":{"line":1,"utf16Column":8},"method":"textDocument/definition"}} +""" +guard let builtinNavigationPointer = builtinNavigationRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core builtin navigation bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(builtinNavigationPointer) } + +let builtinNavigationResponse = String(cString: builtinNavigationPointer) +guard let builtinNavigationData = builtinNavigationResponse.data(using: .utf8), + let builtinNavigationEnvelope = try? JSONSerialization.jsonObject(with: builtinNavigationData) as? [String: Any], + let builtinNavigationPayload = builtinNavigationEnvelope["data"] as? [String: Any], + let builtinNavigationLocations = builtinNavigationPayload["locations"] as? [[String: Any]], + builtinNavigationLocations.count == 1 else { + fputs("Unexpected Rust Core builtin navigation response: \(builtinNavigationResponse)\n", stderr) + exit(1) +} + +print("Rust Core builtin LSP bridge passed") + +let clientInitializeRequest = """ +{"id":"lsp-client-init-test","command":"lsp.clientInitialize","payload":{"state":{},"rootUri":"file:///tmp/project","processId":42}} +""" +guard let clientInitializePointer = clientInitializeRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP client initialize bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(clientInitializePointer) } + +let clientInitializeResponse = String(cString: clientInitializePointer) +guard let clientInitializeData = clientInitializeResponse.data(using: .utf8), + let clientInitializeEnvelope = try? JSONSerialization.jsonObject(with: clientInitializeData) as? [String: Any], + let clientInitializePayload = clientInitializeEnvelope["data"] as? [String: Any], + let clientMessages = clientInitializePayload["messages"] as? [String], + let initializeMessageData = clientMessages.first?.data(using: .utf8), + let initializeMessage = try? JSONSerialization.jsonObject(with: initializeMessageData) as? [String: Any], + initializeMessage["method"] as? String == "initialize", + let clientState = clientInitializePayload["state"] as? [String: Any] else { + fputs("Unexpected Rust Core LSP client initialize response: \(clientInitializeResponse)\n", stderr) + exit(1) +} + +let stateData = try JSONSerialization.data(withJSONObject: clientState) +let serverMessage = #"{"jsonrpc":"2.0","id":"1","result":{"capabilities":{"definitionProvider":true,"completionProvider":{"resolveProvider":true}}}}"# +let stateObject = try JSONSerialization.jsonObject(with: stateData) +let clientApplyRequestData = try JSONSerialization.data(withJSONObject: [ + "id": "lsp-client-apply-test", + "command": "lsp.clientApplyServerMessage", + "payload": [ + "state": stateObject, + "message": serverMessage + ] +]) +let clientApplyRequest = String(data: clientApplyRequestData, encoding: .utf8)! +guard let clientApplyPointer = clientApplyRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP client apply bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(clientApplyPointer) } + +let clientApplyResponse = String(cString: clientApplyPointer) +guard let clientApplyData = clientApplyResponse.data(using: .utf8), + let clientApplyEnvelope = try? JSONSerialization.jsonObject(with: clientApplyData) as? [String: Any], + let clientApplyPayload = clientApplyEnvelope["data"] as? [String: Any], + let appliedState = clientApplyPayload["state"] as? [String: Any], + appliedState["initialized"] as? Bool == true, + let serverCapabilities = appliedState["serverCapabilities"] as? [String], + serverCapabilities.contains("definition"), + serverCapabilities.contains("completionResolve") else { + fputs("Unexpected Rust Core LSP client apply response: \(clientApplyResponse)\n", stderr) + exit(1) +} + +print("Rust Core LSP client bridge passed") + +let sessionCreateRequest = """ +{"id":"lsp-session-create-test","command":"lsp.sessionExecute","payload":{"action":"create","rootUri":"file:///tmp/project","processId":42}} +""" +guard let sessionCreatePointer = sessionCreateRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP session create bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(sessionCreatePointer) } + +let sessionCreateResponse = String(cString: sessionCreatePointer) +guard let sessionCreateData = sessionCreateResponse.data(using: .utf8), + let sessionCreateEnvelope = try? JSONSerialization.jsonObject(with: sessionCreateData) as? [String: Any], + let sessionCreatePayload = sessionCreateEnvelope["data"] as? [String: Any], + let sessionID = sessionCreatePayload["sessionId"] as? String, + sessionCreatePayload["state"] == nil else { + fputs("Unexpected Rust Core LSP session create response: \(sessionCreateResponse)\n", stderr) + exit(1) +} + +let sessionApplyRequestData = try JSONSerialization.data(withJSONObject: [ + "id": "lsp-session-apply-test", + "command": "lsp.sessionExecute", + "payload": [ + "action": "applyServerMessage", + "sessionId": sessionID, + "message": serverMessage + ] +]) +let sessionApplyRequest = String(data: sessionApplyRequestData, encoding: .utf8)! +guard let sessionApplyPointer = sessionApplyRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP session apply bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(sessionApplyPointer) } + +let sessionApplyResponse = String(cString: sessionApplyPointer) +guard let sessionApplyData = sessionApplyResponse.data(using: .utf8), + let sessionApplyEnvelope = try? JSONSerialization.jsonObject(with: sessionApplyData) as? [String: Any], + let sessionApplyPayload = sessionApplyEnvelope["data"] as? [String: Any], + sessionApplyPayload["state"] == nil, + let sessionCapabilities = sessionApplyPayload["serverCapabilities"] as? [String], + sessionCapabilities.contains("definition"), + sessionCapabilities.contains("completionResolve") else { + fputs("Unexpected Rust Core LSP session apply response: \(sessionApplyResponse)\n", stderr) + exit(1) +} + +let sessionDestroyRequest = """ +{"id":"lsp-session-destroy-test","command":"lsp.sessionExecute","payload":{"action":"destroy","sessionId":"\(sessionID)"}} +""" +guard let sessionDestroyPointer = sessionDestroyRequest.withCString({ executeJSON($0) }) else { + fputs("Rust Core LSP session destroy bridge returned no response\n", stderr) + exit(1) +} +defer { freeJSON(sessionDestroyPointer) } + +let sessionDestroyResponse = String(cString: sessionDestroyPointer) +guard let sessionDestroyData = sessionDestroyResponse.data(using: .utf8), + let sessionDestroyEnvelope = try? JSONSerialization.jsonObject(with: sessionDestroyData) as? [String: Any], + sessionDestroyEnvelope["ok"] as? Bool == true else { + fputs("Unexpected Rust Core LSP session destroy response: \(sessionDestroyResponse)\n", stderr) + exit(1) +} + +print("Rust Core LSP session handle bridge passed") diff --git a/scripts/update-repo-charts.py b/scripts/update-repo-charts.py index 28d41d0bd..41a3292de 100644 --- a/scripts/update-repo-charts.py +++ b/scripts/update-repo-charts.py @@ -4,12 +4,14 @@ from __future__ import annotations import argparse +import base64 import html import json import math from collections import Counter -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from pathlib import Path +from urllib import request from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit @@ -17,8 +19,10 @@ CHART_HEIGHT = 680 PLOT_LEFT = 115 PLOT_RIGHT = 1135 -PLOT_TOP = 92 -PLOT_BOTTOM = 545 +PLOT_TOP = 112 +PLOT_BOTTOM = 535 +CHART_TIMEZONE = timezone(timedelta(hours=8)) +STAR_HISTORY_START_DATE = date(2026, 8, 2) def parse_args() -> argparse.Namespace: @@ -51,7 +55,10 @@ def parse_starred_date(entry: dict) -> date | None: return None try: - return datetime.fromisoformat(raw_value.replace("Z", "+00:00")).date() + starred_at = datetime.fromisoformat(raw_value.replace("Z", "+00:00")) + if starred_at.tzinfo is None: + starred_at = starred_at.replace(tzinfo=timezone.utc) + return starred_at.astimezone(CHART_TIMEZONE).date() except ValueError: return None @@ -96,6 +103,73 @@ def svg_text(x: float, y: float, value: str, **attributes: str) -> str: return f'{html.escape(value)}' +def avatar_data_uri(url: str) -> str | None: + parts = urlsplit(url) + if parts.scheme != "https": + return None + + try: + avatar_request = request.Request( + add_query_parameter(url, "s", "136"), + headers={"User-Agent": "Lithe-IDEA chart generator"}, + ) + with request.urlopen(avatar_request, timeout=10) as response: + content_type = response.headers.get_content_type() + if not content_type.startswith("image/"): + return None + encoded = base64.b64encode(response.read()).decode("ascii") + except (OSError, ValueError): + return None + + return f"data:{content_type};base64,{encoded}" + + +def monotone_curve_path(coordinates: list[tuple[float, float]]) -> tuple[str, list[str]]: + if len(coordinates) == 1: + x, y = coordinates[0] + path = f"M {x:.2f},{y:.2f}" + return path, [] + + intervals = [coordinates[index + 1][0] - coordinates[index][0] for index in range(len(coordinates) - 1)] + slopes = [ + (coordinates[index + 1][1] - coordinates[index][1]) / intervals[index] + for index in range(len(intervals)) + ] + tangents = [slopes[0]] + for index in range(1, len(coordinates) - 1): + previous_slope = slopes[index - 1] + next_slope = slopes[index] + if previous_slope * next_slope <= 0: + tangents.append(0.0) + continue + previous_interval = intervals[index - 1] + next_interval = intervals[index] + previous_weight = 2 * next_interval + previous_interval + next_weight = next_interval + 2 * previous_interval + tangents.append( + (previous_weight + next_weight) + / (previous_weight / previous_slope + next_weight / next_slope) + ) + tangents.append(slopes[-1]) + + commands: list[str] = [] + for index, interval in enumerate(intervals): + x1, y1 = coordinates[index] + x2, y2 = coordinates[index + 1] + commands.append( + " ".join( + [ + "C", + f"{x1 + interval / 3:.2f},{y1 + tangents[index] * interval / 3:.2f}", + f"{x2 - interval / 3:.2f},{y2 - tangents[index + 1] * interval / 3:.2f}", + f"{x2:.2f},{y2:.2f}", + ] + ) + ) + x, y = coordinates[0] + return " ".join([f"M {x:.2f},{y:.2f}", *commands]), commands + + def star_history_svg(repo: str, entries: list[dict], dark: bool) -> str: counts = Counter( starred_date @@ -103,24 +177,20 @@ def star_history_svg(repo: str, entries: list[dict], dark: bool) -> str: if (starred_date := parse_starred_date(entry)) is not None ) - today = date.today() - if counts: - first_day = min(counts) - last_day = max(today, max(counts)) - else: - first_day = today - timedelta(days=30) - last_day = today + today = datetime.now(CHART_TIMEZONE).date() + first_day = STAR_HISTORY_START_DATE + last_day = max(first_day, today) span = max((last_day - first_day).days, 1) - cumulative = 0 + cumulative = sum(count for starred_date, count in counts.items() if starred_date < first_day) points: list[tuple[date, int]] = [] current_day = first_day while current_day <= last_day: - cumulative += counts[current_day] points.append((current_day, cumulative)) + cumulative += counts[current_day] current_day += timedelta(days=1) - maximum = nice_maximum(max(cumulative, 1)) + maximum = nice_maximum(max((value for _, value in points), default=1)) plot_width = PLOT_RIGHT - PLOT_LEFT plot_height = PLOT_BOTTOM - PLOT_TOP @@ -129,21 +199,32 @@ def point_for(index: int, value: int) -> tuple[float, float]: y = PLOT_BOTTOM - (value / maximum) * plot_height return x, y - path = " ".join( - ("M" if index == 0 else "L") - + f" {point_for(index, value)[0]:.2f},{point_for(index, value)[1]:.2f}" - for index, (_, value) in enumerate(points) + coordinates = [point_for(index, value) for index, (_, value) in enumerate(points)] + path, curve_commands = monotone_curve_path(coordinates) + area_path = " ".join( + [ + f"M {coordinates[0][0]:.2f},{PLOT_BOTTOM:.2f}", + f"L {coordinates[0][0]:.2f},{coordinates[0][1]:.2f}", + *curve_commands, + f"L {coordinates[-1][0]:.2f},{PLOT_BOTTOM:.2f}", + "Z", + ] ) background = "#ffffff" if not dark else "#111827" foreground = "#111111" if not dark else "#f3f4f6" muted = "#4b5563" if not dark else "#d1d5db" - grid = "#d1d5db" if not dark else "#374151" + grid = "#e5e7eb" if not dark else "#374151" accent = "#e34b2d" tick_count = 4 + latest_x, latest_y = coordinates[-1] + latest_label = f"{format_number(points[-1][1])} stars" + latest_label_x = latest_x - 18 + latest_label_y = max(PLOT_TOP + 28, latest_y - 18) elements = [ f'', - svg_text(600, 54, "Star History", text_anchor="middle", font_size="34", font_weight="700", fill=foreground), + svg_text(600, 46, "Star History", text_anchor="middle", font_size="34", font_weight="700", fill=foreground), + svg_text(600, 78, "Cumulative GitHub Stars at 00:00 Beijing time", text_anchor="middle", font_size="17", fill=muted), f'', f'', svg_text(54, 325, "GitHub Stars", text_anchor="middle", transform="rotate(-90 54 325)", font_size="22", fill=foreground), @@ -154,9 +235,9 @@ def point_for(index: int, value: int) -> tuple[float, float]: value = int(maximum * tick / tick_count) y = PLOT_BOTTOM - (tick / tick_count) * plot_height elements.append( - f'' + f'' ) - elements.append(svg_text(PLOT_LEFT - 18, y + 7, format_number(value), text_anchor="end", font_size="18", fill=foreground)) + elements.append(svg_text(PLOT_LEFT - 18, y + 6, format_number(value), text_anchor="end", font_size="17", fill=muted)) label_indices = sorted({0, len(points) // 4, len(points) // 2, (len(points) * 3) // 4, len(points) - 1}) for index in label_indices: @@ -165,17 +246,25 @@ def point_for(index: int, value: int) -> tuple[float, float]: elements.extend( [ + f'', f'', - f'', - f'', - svg_text(PLOT_LEFT + 68, PLOT_TOP + 55, repo, font_size="20", fill=foreground), + *[ + f'' + for x, y in coordinates + ], + f'', + f'', + svg_text(latest_label_x - 38, latest_label_y - 9, latest_label, text_anchor="middle", font_size="15", font_weight="700", fill=foreground), + f'', + f'', + svg_text(PLOT_LEFT + 64, PLOT_TOP + 53, repo, font_size="19", font_weight="600", fill=foreground), ] ) return "\n".join( [ '', - f'', + f'', f'', *elements, "", @@ -197,6 +286,7 @@ def contributor_svg(repo: str, entries: list[dict]) -> str: rows = max(1, math.ceil(len(contributors) / columns)) width = margin * 2 + columns * avatar_size + (columns - 1) * gap height = margin * 2 + rows * avatar_size + (rows - 1) * gap + avatar_cache: dict[str, str | None] = {} elements = [ f'', f'Contributors to {html.escape(repo, quote=True)}', @@ -213,9 +303,14 @@ def contributor_svg(repo: str, entries: list[dict]) -> str: label = f"{login} ({contributions} contributions)" elements.append(f'') elements.append(f'') + avatar_href = None if isinstance(avatar_url, str) and avatar_url: + if avatar_url not in avatar_cache: + avatar_cache[avatar_url] = avatar_data_uri(avatar_url) + avatar_href = avatar_cache[avatar_url] + if avatar_href: elements.append( - f'' + f'' ) else: elements.append(f'') diff --git a/scripts/verify-rust-core-layout.sh b/scripts/verify-rust-core-layout.sh new file mode 100755 index 000000000..2fa7f05d0 --- /dev/null +++ b/scripts/verify-rust-core-layout.sh @@ -0,0 +1,27 @@ +#!/bin/zsh +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +cd "$ROOT_DIR" + +unexpected="$(find rust/lithe-core/src -maxdepth 1 -type f -name '*.rs' ! -name 'lib.rs' -print)" +if [[ -n "$unexpected" ]]; then + print -u2 -- "Rust Core implementation files must live in an owned package:" + print -u2 -- "$unexpected" + exit 1 +fi + +for package in protocol runtime project execution languages git lsp tests; do + if [[ ! -f "rust/lithe-core/src/$package/mod.rs" ]]; then + print -u2 -- "Rust Core package is missing its facade: $package" + exit 1 + fi +done + +legacy_pattern='crate::(error|model|command|cancellation|workspace|history|markdown|run_configuration|detectors|java|maven)\b' +if rg -n "$legacy_pattern" rust/lithe-core/src -g '*.rs'; then + print -u2 -- "Rust Core contains imports that bypass the package layout" + exit 1 +fi + +print "Rust Core package layout verification passed" diff --git a/scripts/verify-rust-core.sh b/scripts/verify-rust-core.sh index b6a7ac9f6..e59f52e1d 100755 --- a/scripts/verify-rust-core.sh +++ b/scripts/verify-rust-core.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" +scripts/verify-rust-core-layout.sh cargo fmt --manifest-path rust/Cargo.toml -- --check cargo test --manifest-path rust/Cargo.toml @@ -38,5 +39,9 @@ if ! nm -gU "$BINARY" | grep -F "_lithe_core_execute_json" > /dev/null; then print -u2 -- "Rust Core symbols are missing from the macOS binary" exit 1 fi +if ! nm -gU "$BINARY" | grep -F "_lithe_core_lsp_provider_catalog_json" > /dev/null; then + print -u2 -- "Rust Core LSP provider catalog symbol is missing from the macOS binary" + exit 1 +fi print "Rust Core verification passed: Rust tests, Swift bridge build, and linked symbols" diff --git a/scripts/verify-service-boundaries.sh b/scripts/verify-service-boundaries.sh index 182738c41..ce54dcf03 100755 --- a/scripts/verify-service-boundaries.sh +++ b/scripts/verify-service-boundaries.sh @@ -6,7 +6,7 @@ cd "$ROOT_DIR" core_pattern='import (SwiftUI|AppKit|CoreServices)|\b(FileManager|UserDefaults|NSWorkspace|NSApp)\b|(^|[^A-Za-z])Process\(|(^|[^A-Za-z])Pipe\(|FileHandle' service_pattern='import (SwiftUI|AppKit)|\b(FileManager|UserDefaults|NSWorkspace|NSApp)\b|(^|[^A-Za-z])Process\(|(^|[^A-Za-z])Pipe\(|FileHandle|String\(contentsOf:|Data\(contentsOf:|write\(to:.*encoding:|\bMac[A-Z][A-Za-z]+\b|/opt/homebrew|/usr/local|/usr/bin' -ui_service_pattern='\b(MavenService|JavaRunService|JavaDebugService|ProjectRuntimeService|JavaLanguageService|JavaImplementationMarkerService|GitService|WorkspaceSearchIndex)\b' +ui_service_pattern='\b(MavenService|JavaRunService|JavaDebugService|ProjectRuntimeService|GitService|WorkspaceSearchIndex)\b' composition_pattern='\bMac[A-Z][A-Za-z]+\b' application_ui_pattern='import AppKit|\b(NSOpenPanel|NSWorkspace|NSPasteboard|NSEvent)\b' appmodel_business_pattern='Task\.detached|LocalHistoryService|WorkspaceTextFilePolicy|DirectoryChangeSource|fileOperations\.(fileExists|isDirectory|createFile|createDirectory|copyItem|moveItem|removeItem|trashItem|writeText)|mavenFeature\.loadProject|runFeature\.loadProject|configuration\.kind|debugFeature\.(startMaven|toggleBreakpoint|attachRemote)' diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 2888e2400..83e58bfa3 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -10,8 +10,10 @@ verification scripts are the executable source of boundary checks. - All payloads are UTF-8 JSON when exchanged across a process or language boundary. - Workspace paths are relative to the opened workspace and use `/` separators. -- Absolute paths may appear only in platform-owned diagnostics and are never used as identifiers. -- Line numbers are one-based. Missing locations are `null`. +- Absolute paths may appear at native editor/process boundaries and as LSP + `file://` URIs, but are not persisted as cross-platform identifiers. +- Product-facing line numbers are one-based. Editor/LSP positions explicitly use + zero-based lines and UTF-16 columns. Missing locations are `null`. - Lists have deterministic ordering so contract fixtures can be compared directly. - Every asynchronous operation exposes `idle`, `loading`, `ready`, and `failed` outcomes. - Failures contain a stable `code` and user-facing `message`; platform details belong in `details`. @@ -25,7 +27,8 @@ verification scripts are the executable source of boundary checks. | Search | query matching, deterministic result ordering, symbols, and replacement preview | workspace lifecycle and optional index persistence | | Git | changes, commits, branches, diffs, history, validation, and mutation results | Git executable discovery, credentials, process environment | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | -| Java/Maven | Maven project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, and run-configuration detection | JDK/Maven discovery, JDT LS, Java/Maven child processes, sockets, JDB/LSP transport | +| Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | +| Java/Maven | Maven project structure, modules and profiles; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, and JDTLS adapter policy | JDK/Maven discovery, Java/Maven child processes, sockets, and JDB transport | | Run/Debug | versioned configuration documents, three-layer resolution, diagnostics, and platform-neutral launch plans | project file persistence, child processes, sockets, and JDB transport | | Terminal | input bytes, output bytes, lifecycle | PTY/ConPTY, shell and environment | | Local History | revision metadata, text content, restore result | persistence location and file operations | @@ -37,6 +40,14 @@ ignore stale termination events after a restart. `stop()` is the cancellation operation and must terminate the platform process without changing feature state owned by another operation. +Language feature clients route through a provider interface rather than +depending directly on an LSP session. Process-free providers remain available +when an executable is missing. LSP-backed features are enabled only after the +server advertises them during initialize or dynamic registration. The shared +core owns JSON-RPC state and normalized results; platform adapters own stdio and +process lifecycle. Detailed invariants are documented in +[`language-tooling.md`](../../docs/architecture/language-tooling.md). + ## Error Codes Use stable categories rather than platform error strings: diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 2f3069e15..501868fa1 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -6,6 +6,7 @@ Qt/C++. Both bindings call the same C ABI: ```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); ``` @@ -69,6 +70,21 @@ stable error code and a user-facing message: | `history.relocate` | Move a file's history records after a rename | | `maven.scan` | Parse a Maven project descriptor and recursively return modules/profiles | | `maven.diagnostics` | Parse stable Maven compiler diagnostics from build output | +| `lsp.applyTextEdits` | Apply LSP UTF-16 text edits with range validation | +| `lsp.plainSnippet` | Convert LSP snippet insert text into plain editor text | +| `lsp.builtinCompletions` | Return lightweight current-file identifier completions | +| `lsp.builtinHover` | Return lightweight current-symbol hover text | +| `lsp.builtinNavigation` | Return lightweight current-file definition/reference locations | +| `lsp.startServer` | Start one Rust-owned process/session and begin initialization | +| `lsp.stopServer` | Gracefully shut down a session, with a bounded force-stop fallback | +| `lsp.syncDocument` | Open or full-text change a Rust-owned document with monotonic versions | +| `lsp.closeDocument` | Close a document and clear its diagnostics | +| `lsp.request` | Submit a typed semantic request and return an opaque operation ID | +| `lsp.cancelOperation` | Cancel one pending semantic operation | +| `lsp.pollEvents` | Drain ordered typed lifecycle/feature/diagnostic/result/log events | +| `lsp.clearDiagnostics` | Clear every diagnostic owned by a session | +| `lsp.snapshot` | Return a diagnostic runtime snapshot for testing and control surfaces | +| `lsp.destroyServer` | Remove a terminal session handle from the registry | | `java.runConfigurations` | Scan Java sources for main classes and return Maven/Spring run configurations | | `java.codeVision` | Return Java declaration usage counts for editor code vision | | `java.className` | Resolve a Java source package and simple name into a runtime class name | @@ -170,6 +186,68 @@ where each file contains replacement matches and the complete `replacementText` to write. The command never writes files; callers can record history before using `file.write` for the selected files. +`lsp.applyTextEdits` accepts `{ "text": string, "edits": [] }`, where each edit +has an LSP range with zero-based `line` and UTF-16 `utf16Column` fields plus +`newText`. Ranges are validated and overlapping edits return +`invalid_request` with details `overlappingEdits`; invalid positions return +details `invalidRange`. Successful responses return `{ "text": string }`. + +`lsp.plainSnippet` accepts `{ "value": string }` and returns `{ "text": string }` +after removing LSP tab stops and replacing simple placeholder defaults such as +`${1:name}` with `name`. + +`lsp.builtinCompletions`, `lsp.builtinHover`, and `lsp.builtinNavigation` are +the no-process lightweight language path. They accept current-file text, an +absolute `filePath`, and a zero-based LSP position. Completion returns +current-file identifiers with text edits for the active prefix. Hover returns +the current identifier as markdown. Navigation returns current-file locations; +definition prefers declaration-looking occurrences, while references returns +all matching identifier occurrences. These commands are deliberately +text-level fallbacks; precise type-aware behavior belongs to a started language +server. + +The LSP provider catalog is returned by `lithe_core_lsp_provider_catalog_json`. +Each provider descriptor may include `languageServerLaunch` with ordered +`executableNames` and `arguments`; Swift adapters use this metadata when they +need to discover a real language-server executable; the selected launch plan is +then submitted to the Rust-owned runtime. Built-in descriptors are merged by provider ID with the optional +`.lithe/lsp/language-providers.json` workspace document. See +[`language-tooling.md`](../../docs/architecture/language-tooling.md) for routing, +discovery, lifecycle, and compatibility rules. + +The `lsp.*Server`, `lsp.*Document`, `lsp.request`, and `lsp.pollEvents` +commands are the semantic LSP runtime boundary. `lsp.startServer` accepts the +provider ID, selected executable/arguments/environment, root URI, working +directory, initialization options, optional runtime executable and cache +directory, plus initialize/request/shutdown deadlines. Rust owns the returned +session's child process, stdin/stdout/stderr, framing buffer, JSON-RPC request +IDs, document versions, pending deadlines, capabilities, diagnostics, and +graceful/forced termination. + +`lsp.syncDocument` accepts `{ sessionId, uri, languageId, text }`; the first +sync emits `didOpen` at version 1 and later syncs emit full-text `didChange` +with increasing versions. `lsp.request` accepts a semantic `operation` plus +the operation-specific URI, position, range, diagnostics, item, action, or +command fields, and returns `{ operationId }`. Supported operations include +completion, hover, definition/declaration/type-definition, references, +implementation, rename, formatting, code actions and resolve, execute command, +inlay hints, folding ranges, code lens, and provider virtual documents. + +`lsp.pollEvents` drains events ordered by per-session `sequence`. Event types +include `stateChanged`, `featuresChanged`, `diagnostics`, +`requestCompleted`, `serverInfoChanged`, and `log`. Every request completes at +most once with either `result` or a structured runtime error containing +provider/session, stage, optional method/document/request, stable code, and +optional process-exit detail. Late responses after cancellation or deadline +are ignored. Diagnostics are accepted only for documents open in the current +session, and versioned diagnostics must match the current document version. + +The client reducer, raw JSON-RPC message, frame, and parser functions are +internal Rust implementation seams; they are not public application commands. +Completion, hover, navigation, edit, hint, folding, and code-lens responses are +normalized by Rust before they cross the application boundary. Unknown server +requests receive JSON-RPC `Method not found` instead of being silently ignored. + The `history.*` commands accept an adapter-selected `storageRoot`; history metadata never stores an absolute workspace or storage path. `history.record` accepts `workspaceRoot`, a relative `path`, a `reason`, and optional UTF-8