diff --git a/Package.swift b/Package.swift index 5b8f6f96..07a4e02c 100644 --- a/Package.swift +++ b/Package.swift @@ -8,12 +8,80 @@ let package = Package( .macOS(.v13) ], products: [ - .executable(name: "Lithe", targets: ["Lithe"]) + .executable(name: "Lithe", targets: ["Lithe"]), + .library(name: "LitheModuleAPI", targets: ["LitheModuleAPI"]), + .library(name: "LitheApplicationKernel", targets: ["LitheApplicationKernel"]), + .library(name: "LitheCoreContracts", targets: ["LitheCoreContracts"]), + .library(name: "LitheGitModule", targets: ["LitheGitModule"]), + .library(name: "LitheSearchModule", targets: ["LitheSearchModule"]), + .library(name: "LitheLocalHistoryModule", targets: ["LitheLocalHistoryModule"]), + .library(name: "LitheTerminalModule", targets: ["LitheTerminalModule"]), + .library(name: "LitheDatabaseModule", targets: ["LitheDatabaseModule"]), + .library(name: "LitheAIAssistanceModule", targets: ["LitheAIAssistanceModule"]), + .library(name: "LitheExecutionModule", targets: ["LitheExecutionModule"]), + .library(name: "LitheDebugModule", targets: ["LitheDebugModule"]), + .library(name: "LitheLanguageIntelligenceModule", targets: ["LitheLanguageIntelligenceModule"]), + .library(name: "LitheWorkspaceModule", targets: ["LitheWorkspaceModule"]), + .library(name: "LitheGoSupportModule", targets: ["LitheGoSupportModule"]), + .executable(name: "LitheCoreVerifier", targets: ["LitheCoreVerifier"]), + .executable(name: "LitheGitGraphVerifier", targets: ["LitheGitGraphVerifier"]), + .executable(name: "LitheOfficialPluginVerifier", targets: ["LitheOfficialPluginVerifier"]) ], dependencies: [ .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", exact: "1.15.0") ], targets: [ + .target( + name: "LitheModuleAPI", + path: "Sources/LitheModuleAPI", + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), + .target( + name: "LitheApplicationKernel", + dependencies: ["LitheModuleAPI"], + path: "Sources/LitheApplicationKernel", + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), + .target( + name: "LitheCoreContracts", + dependencies: ["LitheModuleAPI"], + path: "Sources/LitheCoreContracts", + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), + .target( + name: "LitheGitModule", + dependencies: ["LitheModuleAPI", "LitheCoreContracts"], + path: "Sources/LitheGitModule", + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), + .target( + name: "LitheSearchModule", + dependencies: ["LitheModuleAPI", "LitheCoreContracts"], + path: "Sources/LitheSearchModule", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .target( + name: "LitheLocalHistoryModule", + dependencies: ["LitheModuleAPI", "LitheCoreContracts"], + path: "Sources/LitheLocalHistoryModule", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .target(name: "LitheTerminalModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Sources/LitheTerminalModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target(name: "LitheDatabaseModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Sources/LitheDatabaseModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target(name: "LitheAIAssistanceModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Sources/LitheAIAssistanceModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target(name: "LitheExecutionModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Sources/LitheExecutionModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target(name: "LitheDebugModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Sources/LitheDebugModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target(name: "LitheLanguageIntelligenceModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Sources/LitheLanguageIntelligenceModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target(name: "LitheWorkspaceModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Sources/LitheWorkspaceModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target(name: "LitheGoSupportModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "Sources/LitheGoSupportModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target( name: "LitheRustCore", path: "Sources/LitheRustCore", @@ -22,6 +90,19 @@ let package = Package( .executableTarget( name: "Lithe", dependencies: [ + "LitheModuleAPI", + "LitheApplicationKernel", + "LitheCoreContracts", + "LitheGitModule", + "LitheSearchModule", + "LitheLocalHistoryModule", + "LitheTerminalModule", + "LitheDatabaseModule", + "LitheAIAssistanceModule", + "LitheExecutionModule", + "LitheDebugModule", + "LitheLanguageIntelligenceModule", + "LitheWorkspaceModule", "LitheRustCore", .product(name: "SwiftTerm", package: "SwiftTerm") ], @@ -35,11 +116,107 @@ let package = Package( ), .testTarget( name: "LitheTests", - dependencies: ["Lithe"], + dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule"], path: "Tests/LitheTests", swiftSettings: [ .swiftLanguageMode(.v6) ] + ), + .testTarget( + name: "LitheApplicationKernelTests", + dependencies: ["LitheModuleAPI", "LitheApplicationKernel"], + path: "Tests/LitheApplicationKernelTests", + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), + .testTarget( + name: "LitheTerminalModuleTests", + dependencies: ["LitheTerminalModule"], + path: "Tests/LitheTerminalModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "LitheAIAssistanceModuleTests", + dependencies: ["LitheAIAssistanceModule", "LitheApplicationKernel", "LitheCoreContracts"], + path: "Tests/LitheAIAssistanceModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "LitheSearchModuleTests", + dependencies: ["LitheSearchModule", "LitheApplicationKernel"], + path: "Tests/LitheSearchModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "LitheLocalHistoryModuleTests", + dependencies: ["LitheLocalHistoryModule", "LitheApplicationKernel"], + path: "Tests/LitheLocalHistoryModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "LitheGitModuleTests", + dependencies: ["LitheGitModule", "LitheApplicationKernel"], + path: "Tests/LitheGitModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "LitheDatabaseModuleTests", + dependencies: ["LitheDatabaseModule", "LitheApplicationKernel"], + path: "Tests/LitheDatabaseModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "LitheLanguageIntelligenceModuleTests", + dependencies: ["LitheLanguageIntelligenceModule", "LitheApplicationKernel", "LitheCoreContracts"], + path: "Tests/LitheLanguageIntelligenceModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "LitheDebugModuleTests", + dependencies: ["LitheDebugModule", "LitheApplicationKernel"], + path: "Tests/LitheDebugModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "LitheExecutionModuleTests", + dependencies: ["LitheExecutionModule", "LitheApplicationKernel", "LitheCoreContracts"], + path: "Tests/LitheExecutionModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "LitheWorkspaceModuleTests", + dependencies: ["LitheWorkspaceModule", "LitheApplicationKernel"], + path: "Tests/LitheWorkspaceModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "LitheGoSupportModuleTests", + dependencies: [ + "LitheGoSupportModule", + "LitheApplicationKernel", + "LitheLanguageIntelligenceModule" + ], + path: "Tests/LitheGoSupportModuleTests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .executableTarget( + name: "LitheCoreVerifier", + dependencies: ["LitheCoreContracts", "LitheGitModule", "LitheSearchModule"], + path: "Tests/LitheCoreVerifier", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .executableTarget( + name: "LitheGitGraphVerifier", + dependencies: ["LitheGitModule"], + path: "Tests/LitheGitGraphVerifier", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .executableTarget( + name: "LitheOfficialPluginVerifier", + dependencies: ["LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts"], + path: "Tests/LitheOfficialPluginVerifier", + swiftSettings: [.swiftLanguageMode(.v6)] ) ] ) diff --git a/Plugins/Official/GoSupport/Info.plist b/Plugins/Official/GoSupport/Info.plist new file mode 100644 index 00000000..3a566988 --- /dev/null +++ b/Plugins/Official/GoSupport/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + LitheGoSupportPlugin + CFBundleIdentifier + dev.lithe.plugin.go-support.bundle + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Go Support + CFBundlePackageType + BNDL + CFBundleShortVersionString + 0.3.0 + CFBundleVersion + 1 + NSPrincipalClass + LitheGoSupportPluginEntrypoint + + diff --git a/Plugins/Official/GoSupport/plugin.json b/Plugins/Official/GoSupport/plugin.json new file mode 100644 index 00000000..e52a87de --- /dev/null +++ b/Plugins/Official/GoSupport/plugin.json @@ -0,0 +1,71 @@ +{ + "schemaVersion": 1, + "id": "dev.lithe.plugin.go-support", + "displayName": "Go Support", + "version": "0.3.0", + "apiVersion": 1, + "hostCompatibility": { + "minimum": "0.3.0", + "maximumExclusive": "0.4.0" + }, + "vendor": { + "id": "dev.lithe", + "displayName": "Lithe", + "signatureRequirement": "sameTeamAsHost" + }, + "entrypoint": { + "kind": "nativeBundle", + "bundleIdentifier": "dev.lithe.plugin.go-support.bundle", + "principalClass": "LitheGoSupportPluginEntrypoint", + "bundlePath": "GoSupport.bundle" + }, + "modules": [ + { + "id": "dev.lithe.language.go.execution", + "displayName": "Go Execution", + "scope": "workspace", + "defaultState": "enabled", + "activationPolicy": "onDemand", + "sleepPolicy": { + "kind": "whenIdle", + "afterSeconds": 600 + }, + "moduleDependencies": ["dev.lithe.workspace"], + "capabilityDependencies": [], + "providedCapabilities": [ + "dev.lithe.capability.language.go.execution", + "dev.lithe.capability.language.go.testing" + ], + "contributions": [], + "required": false + }, + { + "id": "dev.lithe.language.go.language-server", + "displayName": "Go Language Server", + "scope": "workspace", + "defaultState": "enabled", + "activationPolicy": "onDemand", + "sleepPolicy": { + "kind": "whenIdle", + "afterSeconds": 600 + }, + "moduleDependencies": ["dev.lithe.workspace"], + "capabilityDependencies": [], + "providedCapabilities": ["dev.lithe.capability.language.go.language-server"], + "contributions": [], + "required": false + } + ], + "languageSupports": [ + { + "id": "go", + "displayName": "Go", + "fileExtensions": ["go"], + "fileNames": [], + "projectFileNames": ["go.mod", "go.work"], + "languageServerModuleID": "dev.lithe.language.go.language-server", + "executionModuleID": "dev.lithe.language.go.execution", + "testingModuleID": "dev.lithe.language.go.execution" + } + ] +} diff --git a/README.md b/README.md index bda5de22..0b9a733a 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@

Lithe

-

A cross-platform IDE for AI-assisted development

-

Familiar development workflows · multi-language project support · a focused resource footprint

+

A lightweight IDE for the AI era

+

Start tools on demand · keep the workspace responsive · stay focused on the code

AI writes the code. Lithe helps you understand it, run it, and review it.

@@ -45,11 +45,11 @@ ## About Lithe -Lithe is a high-performance, general-purpose IDE for AI-assisted development. It brings project browsing, editing, search, code navigation, Git, run, and debug workflows together for multi-language and multi-type projects, while starting language servers, terminals, build tools, and debug processes only when needed. +Lithe is a lightweight, general-purpose IDE built for the AI era. It brings project browsing, editing, search, code navigation, Git, run, and debug workflows together for multi-language and multi-type projects, while starting language servers, terminals, build tools, 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. -> **A high-performance general-purpose IDE for modern development.** +> **A lightweight IDE that starts what you need, when you need it.** ## Core features diff --git a/README.zh-CN.md b/README.zh-CN.md index 9d095e05..a47810b1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -3,8 +3,8 @@

Lithe

-

一款面向 AI 辅助开发的跨平台 IDE

-

熟悉的开发工作流 · 支持多语言与多类型项目 · 更专注的资源占用

+

一款面向 AI 时代的轻量 IDE

+

工具按需启动 · 工作区保持流畅 · 让注意力回到代码

AI 负责编写代码,Lithe 负责帮你看懂、跑通并审查修改。

@@ -45,11 +45,11 @@ ## 项目简介 -Lithe 是一款面向 AI 辅助开发、追求极致性能的通用型 IDE。它面向多语言和多类型项目,整合项目浏览、编辑、搜索、代码导航、Git、运行和调试工作流,并让语言服务器、终端、构建工具和调试进程只在需要时启动。 +Lithe 是一款面向 AI 时代打造的轻量通用型 IDE。它面向多语言和多类型项目,整合项目浏览、编辑、搜索、代码导航、Git、运行和调试工作流,并让语言服务器、终端、构建工具和调试进程只在需要时启动。 当外部 AI 工具修改项目后,你可以用 Lithe 定位受影响的代码、运行项目、审查 Diff,并决定暂存、撤销或提交哪些修改。 -> **一款面向现代开发的极致性能通用型 IDE。** +> **需要什么就启动什么,让 IDE 始终保持轻量。** ## 核心功能 diff --git a/Sources/Lithe/Application/AppServices.swift b/Sources/Lithe/Application/Composition/AppServices.swift similarity index 61% rename from Sources/Lithe/Application/AppServices.swift rename to Sources/Lithe/Application/Composition/AppServices.swift index 1dcd533f..837550d3 100644 --- a/Sources/Lithe/Application/AppServices.swift +++ b/Sources/Lithe/Application/Composition/AppServices.swift @@ -1,34 +1,25 @@ import Foundation - -protocol DirectoryWatcherFactory { - func make( - configuration: DirectoryWatchConfiguration, - visibilityRules: FileVisibilityRules, - onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void - ) -> any DirectoryChangeSource -} +import LitheApplicationKernel +import LitheCoreContracts /// Platform-neutral service graph consumed by application orchestration. /// Platform composition roots construct this graph with their own adapters. @MainActor final class AppServices { + let moduleRuntime: ModuleRuntime + let pluginManager: any PluginManaging + let pluginCatalog: ValidatedPluginCatalog /// Unified language-pack composition. The derived catalog and focused /// 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 - let localHistoryOperations: any LocalHistoryOperations let javaMavenOperations: any JavaMavenOperations let markdownRenderer: any MarkdownRendering let markdownImageImporter: any MarkdownImageImporting @@ -38,14 +29,7 @@ final class AppServices { /// Empty by default; binary support exists only after an explicit registration. let binaryFileViewerRegistry: BinaryFileViewerRegistry let projectRuntimeService: ProjectRuntimeService - let mavenService: MavenService - let runService: RunService - let javaDebugService: JavaDebugService - let gitService: GitService - let databaseOperations: any DatabaseOperations - let databaseRecoveryStore: any DatabaseRecoveryStoring - let shelveService: ShelveService - let commitMessageGenerator: CommitMessageGenerationService + let gitWatchContextProvider: any GitWatchContextProviding let secureStore: any SecureStore let databaseSecureStore: any SecureStore let credentialResolver: any AIProviderCredentialResolver @@ -53,23 +37,18 @@ final class AppServices { let recentProjectsStore: RecentProjectsStore let workspaceSessionStore: WorkspaceSessionStore let workbenchLayoutStore: WorkbenchLayoutStore - let terminalFactory: () -> any TerminalTransport - let shellDiscovery: () -> [String] let directoryWatcherFactory: any DirectoryWatcherFactory let platformUI: any PlatformUI let shortcutDetectorFactory: any ShortcutDetectorFactory init( + moduleRuntime: ModuleRuntime, + pluginManager: any PluginManaging, + pluginCatalog: ValidatedPluginCatalog, 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, - localHistoryOperations: any LocalHistoryOperations, javaMavenOperations: any JavaMavenOperations, markdownRenderer: any MarkdownRendering, markdownImageImporter: any MarkdownImageImporting, @@ -78,14 +57,7 @@ final class AppServices { fileOperations: any WorkspaceFileOperations, binaryFileViewerRegistry: BinaryFileViewerRegistry, projectRuntimeService: ProjectRuntimeService, - mavenService: MavenService, - runService: RunService, - javaDebugService: JavaDebugService, - gitService: GitService, - databaseOperations: any DatabaseOperations, - databaseRecoveryStore: any DatabaseRecoveryStoring, - shelveService: ShelveService, - commitMessageGenerator: CommitMessageGenerationService, + gitWatchContextProvider: any GitWatchContextProviding, secureStore: any SecureStore, databaseSecureStore: any SecureStore, credentialResolver: any AIProviderCredentialResolver, @@ -93,32 +65,22 @@ final class AppServices { recentProjectsStore: RecentProjectsStore, workspaceSessionStore: WorkspaceSessionStore, workbenchLayoutStore: WorkbenchLayoutStore, - terminalFactory: @escaping () -> any TerminalTransport, - shellDiscovery: @escaping () -> [String], directoryWatcherFactory: any DirectoryWatcherFactory, platformUI: any PlatformUI, shortcutDetectorFactory: any ShortcutDetectorFactory ) { + self.moduleRuntime = moduleRuntime + self.pluginManager = pluginManager + self.pluginCatalog = pluginCatalog self.languageProviderCatalogSource = languageProviderCatalogSource let resolvedCatalogSnapshot = languageProviderCatalogSnapshot ?? languageProviderCatalogSource.load(workspaceURL: nil) self.languageProviderCatalogSnapshot = resolvedCatalogSnapshot let resolvedCatalog = resolvedCatalogSnapshot.catalog - let resolvedLanguagePacks = languagePacks ?? LanguagePackRegistry.standard( - catalog: resolvedCatalog - ) - self.languagePacks = resolvedLanguagePacks - self.languageProviderCatalog = resolvedLanguagePacks.catalog - self.runToolchainRegistry = runToolchainRegistry ?? resolvedLanguagePacks.toolchainRegistry - self.languageToolingSessions = languageToolingSessions ?? LanguageToolingSessionManager( - registry: resolvedLanguagePacks - ) - self.languageServerTools = languageServerTools + self.languageProviderCatalog = resolvedCatalog self.debugLaunchConfigurationResolver = debugLaunchConfigurationResolver ?? DebugLaunchConfigurationResolver(fileStorage: fileStorage) - self.languageTestService = languageTestService self.workspaceOperations = workspaceOperations - self.localHistoryOperations = localHistoryOperations self.javaMavenOperations = javaMavenOperations self.markdownRenderer = markdownRenderer self.markdownImageImporter = markdownImageImporter @@ -127,14 +89,7 @@ final class AppServices { self.fileOperations = fileOperations self.binaryFileViewerRegistry = binaryFileViewerRegistry self.projectRuntimeService = projectRuntimeService - self.mavenService = mavenService - self.runService = runService - self.javaDebugService = javaDebugService - self.gitService = gitService - self.databaseOperations = databaseOperations - self.databaseRecoveryStore = databaseRecoveryStore - self.shelveService = shelveService - self.commitMessageGenerator = commitMessageGenerator + self.gitWatchContextProvider = gitWatchContextProvider self.secureStore = secureStore self.databaseSecureStore = databaseSecureStore self.credentialResolver = credentialResolver @@ -142,8 +97,6 @@ final class AppServices { self.recentProjectsStore = recentProjectsStore self.workspaceSessionStore = workspaceSessionStore self.workbenchLayoutStore = workbenchLayoutStore - self.terminalFactory = terminalFactory - self.shellDiscovery = shellDiscovery self.directoryWatcherFactory = directoryWatcherFactory self.platformUI = platformUI self.shortcutDetectorFactory = shortcutDetectorFactory diff --git a/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift b/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift new file mode 100644 index 00000000..444708cf --- /dev/null +++ b/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift @@ -0,0 +1,54 @@ +import Combine +import Foundation +import LitheDebugModule +import LitheModuleAPI + +@MainActor +final class DebugFeatureGraph: NSObject, DebugServiceGraph { + let java: JavaDebugService + let adapterSessions: DebugAdapterSessionManager + let javaFeature: JavaDebugFeatureModel + let genericFeature: GenericDebugFeatureModel + private var activityObservers: Set = [] + private var javaLease: ModuleLease? + private var adapterLease: ModuleLease? + + init(java: JavaDebugService, adapterSessions: DebugAdapterSessionManager) { + self.java = java; self.adapterSessions = adapterSessions + javaFeature = JavaDebugFeatureModel(service: java) + genericFeature = GenericDebugFeatureModel(sessions: adapterSessions) + } + + var isActive: Bool { java.state != .idle || !adapterSessions.activeAdapterIDs.isEmpty } + var javaFeatureTarget: any JavaDebugFeatureTarget { javaFeature } + var genericFeatureTarget: any GenericDebugFeatureTarget { genericFeature } + var hasActiveDebugWork: Bool { isActive } + func activate(context: ModuleContext) { + configureModuleLeases { reason in context.leases.acquireLease(reason: reason) } + } + func prepareForSleep() async throws { + guard !isActive else { throw FeatureModuleSleepError.activeWork("A debug session is still active.") } + } + + func configureModuleLeases(acquire: @escaping @MainActor (String) -> ModuleLease) { + java.$state.map { $0 != .idle }.removeDuplicates().sink { [weak self] active in + guard let self else { return } + if active, javaLease == nil { javaLease = acquire("Java debug session is active") } + if !active { javaLease?.release(); javaLease = nil } + }.store(in: &activityObservers) + genericFeature.$state.map { ![.idle, .terminated, .failed].contains($0) } + .removeDuplicates().sink { [weak self] active in + guard let self else { return } + if active, adapterLease == nil { adapterLease = acquire("Debug adapter session is active") } + if !active { adapterLease?.release(); adapterLease = nil } + }.store(in: &activityObservers) + } + + func stop() { + java.stop(); adapterSessions.stopAll() + javaLease?.release(); javaLease = nil + adapterLease?.release(); adapterLease = nil + activityObservers.removeAll() + } + +} diff --git a/Sources/Lithe/Application/Composition/WorkspaceModuleResourceOwner.swift b/Sources/Lithe/Application/Composition/WorkspaceModuleResourceOwner.swift new file mode 100644 index 00000000..cd2e7305 --- /dev/null +++ b/Sources/Lithe/Application/Composition/WorkspaceModuleResourceOwner.swift @@ -0,0 +1,23 @@ +import Foundation +import LitheModuleAPI +import LitheWorkspaceModule + +/// Bridges the required Workspace module's resource scope to the UI-facing +/// workspace projection without making the module target depend on app types. +@MainActor +final class WorkspaceModuleResourceOwner: NSObject, WorkspaceResourceGraph { + private(set) var feature: WorkspaceFeatureModel? + + func attach(workspaceProjection: WorkspaceFeatureModel) { + feature = workspaceProjection + } + + var hasActiveResources: Bool { + feature?.hasActiveModuleResources ?? false + } + + func stop() async { + feature?.prepareForModuleRelease() + feature = nil + } +} diff --git a/Sources/Lithe/Application/DocumentFeatureModel.swift b/Sources/Lithe/Application/Features/DocumentFeatureModel.swift similarity index 100% rename from Sources/Lithe/Application/DocumentFeatureModel.swift rename to Sources/Lithe/Application/Features/DocumentFeatureModel.swift diff --git a/Sources/Lithe/Application/Features/ExecutionFeatureAliases.swift b/Sources/Lithe/Application/Features/ExecutionFeatureAliases.swift new file mode 100644 index 00000000..57a3c5d6 --- /dev/null +++ b/Sources/Lithe/Application/Features/ExecutionFeatureAliases.swift @@ -0,0 +1,7 @@ +import LitheExecutionModule + +typealias MavenFeatureModel = LitheExecutionModule.MavenFeatureModel +typealias RunFeatureModel = LitheExecutionModule.RunFeatureModel +typealias ProjectDevelopmentFeatureModel = LitheExecutionModule.ProjectDevelopmentFeatureModel +typealias RunConfigurationGenerationIntent = LitheExecutionModule.RunConfigurationGenerationIntent +typealias JavaRunFeatureModel = LitheExecutionModule.RunFeatureModel diff --git a/Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift b/Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift new file mode 100644 index 00000000..cf887aff --- /dev/null +++ b/Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift @@ -0,0 +1,105 @@ +import Combine +import Foundation +import LitheDebugModule + +/// UI-facing projection for Java debugger state and commands. +@MainActor +final class JavaDebugFeatureModel: ObservableObject, JavaDebugFeatureTarget { + private let service: JavaDebugService + private var observation: AnyCancellable? + + @Published var targetKind: JavaDebugTargetKind { + didSet { + guard targetKind != service.targetKind else { return } + service.targetKind = targetKind + } + } + + @Published var remoteHost: String { + didSet { + guard remoteHost != service.remoteHost else { return } + service.remoteHost = remoteHost + } + } + + @Published var remotePort: String { + didSet { + guard remotePort != service.remotePort else { return } + service.remotePort = remotePort + } + } + + @Published var remoteJavaHomePath: String { + didSet { + guard remoteJavaHomePath != service.remoteJavaHomePath else { return } + service.remoteJavaHomePath = remoteJavaHomePath + } + } + + init(service: JavaDebugService) { + self.service = service + _targetKind = Published(initialValue: service.targetKind) + _remoteHost = Published(initialValue: service.remoteHost) + _remotePort = Published(initialValue: service.remotePort) + _remoteJavaHomePath = Published(initialValue: service.remoteJavaHomePath) + observation = service.objectWillChange.sink { [weak self] _ in + guard let self else { return } + if self.targetKind != self.service.targetKind { self.targetKind = self.service.targetKind } + if self.remoteHost != self.service.remoteHost { self.remoteHost = self.service.remoteHost } + if self.remotePort != self.service.remotePort { self.remotePort = self.service.remotePort } + if self.remoteJavaHomePath != self.service.remoteJavaHomePath { + self.remoteJavaHomePath = self.service.remoteJavaHomePath + } + self.objectWillChange.send() + } + } + + var state: JavaDebugSessionState { service.state } + var output: String { service.output } + var inspectionTitle: String? { service.inspectionTitle } + var inspectionOutput: String { service.inspectionOutput } + var variables: [JavaDebugVariable] { service.variables } + var threads: [JavaDebugThread] { service.threads } + var callStack: [JavaDebugStackFrame] { service.callStack } + var expandingVariableID: String? { service.expandingVariableID } + var exceptionMessage: String? { service.exceptionMessage } + var port: Int? { service.port } + var breakpoints: [JavaDebugBreakpoint] { service.breakpoints } + var runningTargetTitle: String? { service.runningTargetTitle } + var isSessionActive: Bool { service.isSessionActive } + var canControl: Bool { service.canControl } + + func pause() { service.pause() } + func continueExecution() { service.continueExecution() } + func stepInto() { service.stepInto() } + func stepOver() { service.stepOver() } + func stepOut() { service.stepOut() } + func inspectThreads() { service.inspectThreads() } + func inspectStack() { service.inspectStack() } + func inspectVariables() { service.inspectVariables() } + func evaluate(_ expression: String) { service.evaluate(expression) } + func toggleVariable(_ variable: JavaDebugVariable) { service.toggleVariable(variable) } + func clearOutput() { service.clearOutput() } + + func reset() { service.reset() } + func start( + fileURL: URL, + sourceText: String, + projectURL: URL?, + options: RunOptions + ) { service.start(fileURL: fileURL, sourceText: sourceText, projectURL: projectURL, options: options) } + func startMaven( + configuration: RunConfiguration, + project: MavenProject, + projectURL: URL, + options: RunOptions + ) { service.startMaven(configuration: configuration, project: project, projectURL: projectURL, options: options) } + func attachRemote() { service.attachRemote() } + func toggleBreakpoint(fileURL: URL, line: Int, className: String) { + service.toggleBreakpoint(fileURL: fileURL, line: line, className: className) + } + func className(for fileURL: URL, sourceText: String) -> String { + service.className(for: fileURL, sourceText: sourceText) + } + func stop() { service.stop() } +} diff --git a/Sources/Lithe/Application/JavaFeatureModel.swift b/Sources/Lithe/Application/Features/JavaFeatureModel.swift similarity index 98% rename from Sources/Lithe/Application/JavaFeatureModel.swift rename to Sources/Lithe/Application/Features/JavaFeatureModel.swift index 0bab1558..7ace5cb7 100644 --- a/Sources/Lithe/Application/JavaFeatureModel.swift +++ b/Sources/Lithe/Application/Features/JavaFeatureModel.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import LitheGitModule /// Owns Java-only code vision, fallback inlay hints, Maven integration, and /// legacy Java debug behavior. Java LSP navigation and editing are delegated @@ -40,8 +41,8 @@ final class JavaFeatureModel: ObservableObject { } func configureRuntime( - mavenFeature: MavenFeatureModel, - debugFeature: JavaDebugFeatureModel + mavenFeature: MavenFeatureModel?, + debugFeature: JavaDebugFeatureModel? ) { self.mavenFeature = mavenFeature self.debugFeature = debugFeature diff --git a/Sources/Lithe/Application/LSPControlCenterPresentation.swift b/Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift similarity index 100% rename from Sources/Lithe/Application/LSPControlCenterPresentation.swift rename to Sources/Lithe/Application/Features/LSPControlCenterPresentation.swift diff --git a/Sources/Lithe/Application/LanguageToolingFeatureModel.swift b/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift similarity index 85% rename from Sources/Lithe/Application/LanguageToolingFeatureModel.swift rename to Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift index 9377ed31..fb337a93 100644 --- a/Sources/Lithe/Application/LanguageToolingFeatureModel.swift +++ b/Sources/Lithe/Application/Features/LanguageToolingFeatureModel.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import LitheLanguageIntelligenceModule /// Owns language-provider selection and workspace-scoped language-server UI state. /// Protocol/session ownership remains in LanguageToolingSessionManager; this model @@ -12,7 +13,7 @@ final class LanguageToolingFeatureModel: ObservableObject { private(set) var startupFailures: [String: String] = [:] private let catalogSource: any LanguageProviderCatalogSource - private let sessions: LanguageToolingSessionManager + private var sessionsProvider: @MainActor () -> LanguageToolingSessionManager? private let runtimeFeature: RuntimeSettingsFeatureModel private let settings: AppSettings private let projectRuntimeService: ProjectRuntimeService @@ -24,7 +25,7 @@ final class LanguageToolingFeatureModel: ObservableObject { init( catalogSource: any LanguageProviderCatalogSource, catalogSnapshot: LanguageProviderCatalogSnapshot, - sessions: LanguageToolingSessionManager, + sessionsProvider: @escaping @MainActor () -> LanguageToolingSessionManager?, runtimeFeature: RuntimeSettingsFeatureModel, settings: AppSettings, projectRuntimeService: ProjectRuntimeService @@ -32,7 +33,7 @@ final class LanguageToolingFeatureModel: ObservableObject { self.catalogSource = catalogSource self.catalogSnapshot = catalogSnapshot catalog = catalogSnapshot.catalog - self.sessions = sessions + self.sessionsProvider = sessionsProvider self.runtimeFeature = runtimeFeature self.settings = settings self.projectRuntimeService = projectRuntimeService @@ -50,6 +51,12 @@ final class LanguageToolingFeatureModel: ObservableObject { self.notify = notify } + func configureSessions( + provider: @escaping @MainActor () -> LanguageToolingSessionManager? + ) { + sessionsProvider = provider + } + func resetWorkspaceState() { disabledProviderIDs.removeAll() startupFailures.removeAll() @@ -59,7 +66,7 @@ final class LanguageToolingFeatureModel: ObservableObject { let snapshot = catalogSource.load(workspaceURL: workspaceURL) catalogSnapshot = snapshot catalog = snapshot.catalog - sessions.updateCatalog(snapshot.catalog) + sessionsProvider()?.updateCatalog(snapshot.catalog) } func isDisabled(_ providerID: String) -> Bool { @@ -72,21 +79,21 @@ final class LanguageToolingFeatureModel: ObservableObject { synchronizeOpenDocuments(providerID: providerID) } else { disabledProviderIDs.insert(providerID) - sessions.recordLanguageServerLog( + sessionsProvider()?.recordLanguageServerLog( providerID: providerID, level: .warning, message: "Language server disabled in this workspace", detail: "Manual stop" ) - sessions.stopLanguageServer(providerID: providerID) + sessionsProvider()?.stopLanguageServer(providerID: providerID) } } func toolConfigurationDidChange(providerID: String) { disabledProviderIDs.remove(providerID) startupFailures[providerID] = nil - sessions.stopLanguageServer(providerID: providerID) - sessions.recordLanguageServerLog( + sessionsProvider()?.stopLanguageServer(providerID: providerID) + sessionsProvider()?.recordLanguageServerLog( providerID: providerID, level: .info, message: "Language server tool configuration changed", @@ -108,7 +115,7 @@ final class LanguageToolingFeatureModel: ObservableObject { let message = error.localizedDescription guard startupFailures[providerID] != message else { return } startupFailures[providerID] = message - sessions.recordLanguageServerLog( + sessionsProvider()?.recordLanguageServerLog( providerID: providerID, level: .error, message: "Language server activation failed", diff --git a/Sources/Lithe/Application/Features/PluginManagement.swift b/Sources/Lithe/Application/Features/PluginManagement.swift new file mode 100644 index 00000000..30194467 --- /dev/null +++ b/Sources/Lithe/Application/Features/PluginManagement.swift @@ -0,0 +1,36 @@ +import Foundation +import LitheModuleAPI + +struct PluginManagementIssue: Equatable, Sendable, Identifiable { + let pluginID: PluginID? + let message: String + + var id: String { "\(pluginID?.rawValue ?? "host"):\(message)" } +} + +struct PluginManagementSnapshot: Equatable, Sendable, Identifiable { + let manifest: PluginManifest + let origin: PluginInstallationOrigin + let installationStatus: PluginInstallationStatus + let isEnabled: Bool + let isRequired: Bool + let isRunning: Bool + let isQuarantined: Bool + let isSuppressedBySafeMode: Bool + let requiresRestart: Bool + let canRollback: Bool + let statusMessage: String + + var id: PluginID { manifest.id } +} + +@MainActor +protocol PluginManaging: AnyObject { + var snapshots: [PluginManagementSnapshot] { get } + var issues: [PluginManagementIssue] { get } + + func setEnabled(_ enabled: Bool, for pluginID: PluginID) async throws + func installPackage(at packageURL: URL) throws + func rollback(_ pluginID: PluginID) throws + func uninstall(_ pluginID: PluginID) async throws +} diff --git a/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift b/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift new file mode 100644 index 00000000..bf081aec --- /dev/null +++ b/Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift @@ -0,0 +1,46 @@ +import Combine +import Foundation + +/// UI-facing projection for project runtime settings and discovery. +@MainActor +final class RuntimeSettingsFeatureModel: ObservableObject { + private let service: ProjectRuntimeService + private var observation: AnyCancellable? + + @Published private(set) var javaRuntimes: [JavaRuntimeCandidate] + @Published private(set) var mavenRuntimes: [MavenRuntimeCandidate] + @Published private(set) var javaEnvironmentReport: JavaEnvironmentReport? + @Published private(set) var isDiscovering: Bool + + init(service: ProjectRuntimeService) { + self.service = service + _javaRuntimes = Published(initialValue: service.javaRuntimes) + _mavenRuntimes = Published(initialValue: service.mavenRuntimes) + _javaEnvironmentReport = Published(initialValue: service.javaEnvironmentReport) + _isDiscovering = Published(initialValue: service.isDiscovering) + observation = service.objectWillChange.sink { [weak self] _ in + guard let self else { return } + self.javaRuntimes = self.service.javaRuntimes + self.mavenRuntimes = self.service.mavenRuntimes + self.javaEnvironmentReport = self.service.javaEnvironmentReport + self.isDiscovering = self.service.isDiscovering + } + } + + func openProject(at url: URL) { service.openProject(at: url) } + func closeProject() { service.closeProject() } + func refreshAvailableRuntimes() async { await service.refreshAvailableRuntimes() } + func activeJavaRuntime() -> JavaRuntimeCandidate? { service.activeJavaRuntime() } + func activeMavenRuntime(for project: MavenProject) -> MavenRuntimeCandidate? { + service.activeMavenRuntime(for: project) + } + 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/Application/Features/WorkspaceFeatureModel.swift b/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift new file mode 100644 index 00000000..04156302 --- /dev/null +++ b/Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift @@ -0,0 +1,72 @@ +import Foundation +import LitheCoreContracts +import LitheWorkspaceModule + +typealias WorkspaceRebuildResult = LitheWorkspaceModule.WorkspaceRebuildResult +typealias WorkspaceFeatureModel = LitheWorkspaceModule.WorkspaceFeatureModel + +@MainActor +extension LitheWorkspaceModule.WorkspaceFeatureModel { + convenience init( + operations: any WorkspaceOperations, + fileOperations: any WorkspaceFileOperations, + fileStorage: any FileStorage, + gitWatchContextProvider: any GitWatchContextProviding, + directoryWatcherFactory: any DirectoryWatcherFactory, + workspaceSessionStore: any WorkspaceSessionStoring + ) { + _ = fileStorage + self.init( + operations: operations, + fileOperations: fileOperations, + gitWatchContextProvider: gitWatchContextProvider, + directoryWatcherFactory: directoryWatcherFactory, + workspaceSessionStore: workspaceSessionStore + ) + } + + func configure( + documentsProvider: @escaping @MainActor @Sendable () -> [EditorDocument], + activeDocumentProvider: @escaping @MainActor @Sendable () -> EditorDocument?, + selectedSidebarProvider: @escaping @MainActor @Sendable () -> String, + setSelectedSidebar: @escaping @MainActor @Sendable (String) -> Void, + restoreSession: @escaping @MainActor @Sendable (WorkspaceSession, [URL]) async -> Void, + openFile: @escaping @MainActor @Sendable (URL) -> Void, + notify: @escaping @MainActor @Sendable (String) -> Void, + recordHistory: @escaping @MainActor @Sendable (URL, LocalHistoryReason) async -> Void, + relocateHistory: @escaping @MainActor @Sendable (URL, URL) async -> Void, + relocateOpenDocuments: @escaping @MainActor @Sendable (URL, URL) -> Void, + closeDocuments: @escaping @MainActor @Sendable (URL) -> Void, + processExternalChanges: @escaping @MainActor @Sendable ([URL]) -> Bool, + reloadProjectServices: @escaping @MainActor @Sendable () async -> Void, + refreshGit: @escaping @MainActor @Sendable () async -> Void, + updateHistoryVisibilityRules: @escaping @MainActor @Sendable (FileVisibilityRules) async -> Void, + onSnapshotLoaded: @escaping @MainActor @Sendable (WorkspaceSnapshot, Bool) async -> Void + ) { + configureProjection( + documentsProvider: { + documentsProvider().map { WorkspaceDocumentState(url: $0.url, isDirty: $0.isDirty) } + }, + activeDocumentProvider: { + activeDocumentProvider().map { WorkspaceDocumentState(url: $0.url, isDirty: $0.isDirty) } + }, + selectedSidebarProvider: selectedSidebarProvider, + setSelectedSidebar: setSelectedSidebar, + restoreSession: restoreSession, + openFile: openFile, + notify: notify, + recordHistory: recordHistory, + relocateHistory: relocateHistory, + relocateOpenDocuments: relocateOpenDocuments, + closeDocuments: closeDocuments, + processExternalChanges: processExternalChanges, + reloadProjectServices: reloadProjectServices, + refreshGit: refreshGit, + updateHistoryVisibilityRules: updateHistoryVisibilityRules, + onSnapshotLoaded: onSnapshotLoaded, + warmSearchIndex: { _, _ in }, + updateSearchIndex: { _, _, _ in }, + invalidateSearchIndex: { _, _ in } + ) + } +} diff --git a/Sources/Lithe/Application/Lifecycle/FeatureModuleSleepError.swift b/Sources/Lithe/Application/Lifecycle/FeatureModuleSleepError.swift new file mode 100644 index 00000000..d48e80ba --- /dev/null +++ b/Sources/Lithe/Application/Lifecycle/FeatureModuleSleepError.swift @@ -0,0 +1,11 @@ +import Foundation + +enum FeatureModuleSleepError: LocalizedError { + case activeWork(String) + + var errorDescription: String? { + switch self { + case .activeWork(let message): message + } + } +} diff --git a/Sources/Lithe/Application/UIFeatureModels.swift b/Sources/Lithe/Application/UIFeatureModels.swift deleted file mode 100644 index 5ebf3e05..00000000 --- a/Sources/Lithe/Application/UIFeatureModels.swift +++ /dev/null @@ -1,358 +0,0 @@ -import Combine -import Foundation - -/// UI-facing projection for Maven state and commands. -/// The view layer does not depend on MavenService or its process adapter. -@MainActor -final class MavenFeatureModel: ObservableObject { - private let service: MavenService - private var observation: AnyCancellable? - - init(service: MavenService) { - self.service = service - observation = service.objectWillChange.sink { [weak self] _ in - self?.objectWillChange.send() - } - } - - var project: MavenProject? { service.project } - var isLoadingProject: Bool { service.isLoadingProject } - var isRunning: Bool { service.isRunning } - var runningTitle: String? { service.runningTitle } - var output: String { service.output } - var issues: [MavenBuildIssue] { service.issues } - var lastExitCode: Int32? { service.lastExitCode } - - func loadProject(at workspaceURL: URL, files: [URL]) async { - await service.loadProject(at: workspaceURL, files: files) - } - - func run(phase: MavenLifecyclePhase, module: MavenModule?, profiles: Set) { - service.run(phase: phase, module: module, profiles: profiles) - } - - func reset() { service.reset() } - - func stop() { - service.stop() - } - - func clearOutput() { - service.clearOutput() - } - -} - -/// UI-facing projection for language-neutral run configurations and process sessions. -enum RunConfigurationGenerationIntent: Sendable { - case identifyOnly - case run - case debug -} - -@MainActor -final class RunFeatureModel: ObservableObject { - private let service: RunService - private var observation: AnyCancellable? - @Published var isGenerationConfirmationPresented = false - private(set) var generationIntent: RunConfigurationGenerationIntent = .identifyOnly - - init(service: RunService) { - self.service = service - observation = service.objectWillChange.sink { [weak self] _ in - self?.objectWillChange.send() - } - } - - var selectedConfigurationID: String { - get { service.selectedConfigurationID } - set { service.selectedConfigurationID = newValue } - } - - var configurations: [RunConfiguration] { service.configurations } - var selectedConfiguration: RunConfiguration? { service.selectedConfiguration } - var isLoadingProject: Bool { service.isLoadingProject } - var isRunning: Bool { service.isRunning } - var runningTitle: String? { service.runningTitle } - var output: String { service.output } - var lastExitCode: Int32? { service.lastExitCode } - var mavenProfiles: [MavenProfile] { service.mavenProfiles } - var moduleSessions: [RunSession] { service.moduleSessions } - var portConflicts: [RunPortConflict] { service.portConflicts } - var configurationStatus: ProjectRunConfigurationStatus { service.configurationStatus } - var configurationDiagnostics: [RunConfigurationDiagnostic] { service.configurationDiagnostics } - var generationState: RunConfigurationGenerationState { service.generationState } - var recoveryAction: RunConfigurationRecoveryAction { service.recoveryAction } - var recoveryPath: String? { service.recoveryPath } - var configurationSaveError: String? { service.configurationSaveError } - var blockingToolchainDiagnostic: RunConfigurationDiagnostic? { - service.configurationDiagnostics.first { - $0.code == "missingToolchain" || $0.code == "toolchainVersionMismatch" - } - } - var sourceSearchRoots: [URL] { service.sourceSearchRoots } - - func options(for configuration: RunConfiguration) -> RunOptions { - service.options(for: configuration) - } - - func source(for configuration: RunConfiguration) -> RunConfigurationSource { - service.source(for: configuration) - } - - func serviceURL(for configuration: RunConfiguration) -> URL? { - service.serviceURL(for: configuration) - } - - @discardableResult - func updateOptions( - _ options: RunOptions, - for configuration: RunConfiguration, - scope: RunConfigurationSaveScope = .local - ) -> Bool { - service.updateOptions(options, for: configuration, scope: scope) - } - - func resetOptions(for configuration: RunConfiguration) { - service.resetOptions(for: configuration) - } - - @discardableResult - func createConfiguration(_ draft: RunConfigurationDraft) -> Bool { - service.createConfiguration(draft) - } - - func runAllServices() { - service.runAllServices() - } - - func stopAllServices() { - service.stopAllServices() - } - - func startConfiguration(_ configuration: RunConfiguration) { - service.startConfiguration(configuration) - } - - func stopModule(_ session: RunSession) { - service.stopModule(session) - } - - func restartModule(_ session: RunSession) { - service.restartModule(session) - } - - func clearModuleOutput(_ session: RunSession) { - service.clearModuleOutput(session) - } - - func clearOutput() { - service.clearOutput() - } - - func loadProject( - at workspaceURL: URL, - files: [URL], - mavenProject: MavenProject? - ) async { - await service.loadProject(at: workspaceURL, files: files, mavenProject: mavenProject) - } - - func generateRunConfigurations() async { - isGenerationConfirmationPresented = false - await service.generateRunConfigurations() - } - - func requestRunConfigurationGeneration(intent: RunConfigurationGenerationIntent = .identifyOnly) { - guard recoveryAction != .upgradeApplication else { return } - generationIntent = intent - isGenerationConfirmationPresented = true - } - - func select(_ configuration: RunConfiguration) { service.select(configuration) } - func runSelected(currentFileURL: URL?) { service.runSelected(currentFileURL: currentFileURL) } - func restart() { service.restart() } - func stop() { service.stop() } - func reset() { service.reset() } -} - -/// Coordinates project-scoped build and run loading without making AppModel -/// own build-system sequencing. Language-specific project loaders can later be -/// added here without changing the workspace/UI composition boundary. -@MainActor -final class ProjectDevelopmentFeatureModel { - private let mavenFeature: MavenFeatureModel - private let runFeature: RunFeatureModel - - init(mavenFeature: MavenFeatureModel, runFeature: RunFeatureModel) { - self.mavenFeature = mavenFeature - self.runFeature = runFeature - } - - func loadProject(at workspaceURL: URL, files: [URL]) async { - // Maven is one build-system Provider, not a workspace prerequisite. - // Avoid scanning every project as Maven; non-Maven ecosystems should - // reach the generic run pipeline without paying for Java discovery. - let hasMavenDescriptor = files.contains { file in - file.lastPathComponent.lowercased() == "pom.xml" - } - if hasMavenDescriptor { - await mavenFeature.loadProject(at: workspaceURL, files: files) - } else { - mavenFeature.reset() - } - await runFeature.loadProject( - at: workspaceURL, - files: files, - mavenProject: mavenFeature.project - ) - } -} - -typealias JavaRunFeatureModel = RunFeatureModel - -/// UI-facing projection for Java debugger state and commands. -@MainActor -final class JavaDebugFeatureModel: ObservableObject { - private let service: JavaDebugService - private var observation: AnyCancellable? - - @Published var targetKind: JavaDebugTargetKind { - didSet { - guard targetKind != service.targetKind else { return } - service.targetKind = targetKind - } - } - - @Published var remoteHost: String { - didSet { - guard remoteHost != service.remoteHost else { return } - service.remoteHost = remoteHost - } - } - - @Published var remotePort: String { - didSet { - guard remotePort != service.remotePort else { return } - service.remotePort = remotePort - } - } - - @Published var remoteJavaHomePath: String { - didSet { - guard remoteJavaHomePath != service.remoteJavaHomePath else { return } - service.remoteJavaHomePath = remoteJavaHomePath - } - } - - init(service: JavaDebugService) { - self.service = service - _targetKind = Published(initialValue: service.targetKind) - _remoteHost = Published(initialValue: service.remoteHost) - _remotePort = Published(initialValue: service.remotePort) - _remoteJavaHomePath = Published(initialValue: service.remoteJavaHomePath) - observation = service.objectWillChange.sink { [weak self] _ in - guard let self else { return } - if self.targetKind != self.service.targetKind { self.targetKind = self.service.targetKind } - if self.remoteHost != self.service.remoteHost { self.remoteHost = self.service.remoteHost } - if self.remotePort != self.service.remotePort { self.remotePort = self.service.remotePort } - if self.remoteJavaHomePath != self.service.remoteJavaHomePath { - self.remoteJavaHomePath = self.service.remoteJavaHomePath - } - self.objectWillChange.send() - } - } - - var state: JavaDebugSessionState { service.state } - var output: String { service.output } - var inspectionTitle: String? { service.inspectionTitle } - var inspectionOutput: String { service.inspectionOutput } - var variables: [JavaDebugVariable] { service.variables } - var threads: [JavaDebugThread] { service.threads } - var callStack: [JavaDebugStackFrame] { service.callStack } - var expandingVariableID: String? { service.expandingVariableID } - var exceptionMessage: String? { service.exceptionMessage } - var port: Int? { service.port } - var breakpoints: [JavaDebugBreakpoint] { service.breakpoints } - var runningTargetTitle: String? { service.runningTargetTitle } - var isSessionActive: Bool { service.isSessionActive } - var canControl: Bool { service.canControl } - - func pause() { service.pause() } - func continueExecution() { service.continueExecution() } - func stepInto() { service.stepInto() } - func stepOver() { service.stepOver() } - func stepOut() { service.stepOut() } - func inspectThreads() { service.inspectThreads() } - func inspectStack() { service.inspectStack() } - func inspectVariables() { service.inspectVariables() } - func evaluate(_ expression: String) { service.evaluate(expression) } - func toggleVariable(_ variable: JavaDebugVariable) { service.toggleVariable(variable) } - func clearOutput() { service.clearOutput() } - - func reset() { service.reset() } - func start( - fileURL: URL, - sourceText: String, - projectURL: URL?, - options: RunOptions - ) { service.start(fileURL: fileURL, sourceText: sourceText, projectURL: projectURL, options: options) } - func startMaven( - configuration: RunConfiguration, - project: MavenProject, - projectURL: URL, - options: RunOptions - ) { service.startMaven(configuration: configuration, project: project, projectURL: projectURL, options: options) } - func attachRemote() { service.attachRemote() } - func toggleBreakpoint(fileURL: URL, line: Int, className: String) { - service.toggleBreakpoint(fileURL: fileURL, line: line, className: className) - } - func className(for fileURL: URL, sourceText: String) -> String { - service.className(for: fileURL, sourceText: sourceText) - } - func stop() { service.stop() } -} - -/// UI-facing projection for project runtime settings and discovery. -@MainActor -final class RuntimeSettingsFeatureModel: ObservableObject { - private let service: ProjectRuntimeService - private var observation: AnyCancellable? - - @Published private(set) var javaRuntimes: [JavaRuntimeCandidate] - @Published private(set) var mavenRuntimes: [MavenRuntimeCandidate] - @Published private(set) var javaEnvironmentReport: JavaEnvironmentReport? - @Published private(set) var isDiscovering: Bool - - init(service: ProjectRuntimeService) { - self.service = service - _javaRuntimes = Published(initialValue: service.javaRuntimes) - _mavenRuntimes = Published(initialValue: service.mavenRuntimes) - _javaEnvironmentReport = Published(initialValue: service.javaEnvironmentReport) - _isDiscovering = Published(initialValue: service.isDiscovering) - observation = service.objectWillChange.sink { [weak self] _ in - guard let self else { return } - self.javaRuntimes = self.service.javaRuntimes - self.mavenRuntimes = self.service.mavenRuntimes - self.javaEnvironmentReport = self.service.javaEnvironmentReport - self.isDiscovering = self.service.isDiscovering - } - } - - func openProject(at url: URL) { service.openProject(at: url) } - func closeProject() { service.closeProject() } - func refreshAvailableRuntimes() async { await service.refreshAvailableRuntimes() } - func activeJavaRuntime() -> JavaRuntimeCandidate? { service.activeJavaRuntime() } - func activeMavenRuntime(for project: MavenProject) -> MavenRuntimeCandidate? { - service.activeMavenRuntime(for: project) - } - 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/Language/PluginLanguageProviderCatalogSource.swift b/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift new file mode 100644 index 00000000..d7926561 --- /dev/null +++ b/Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift @@ -0,0 +1,78 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// Overlays installed language-package metadata onto the shared Rust catalog. +/// Recognition stays inert: reading a manifest never loads plugin code or +/// starts a toolchain process. +struct PluginLanguageProviderCatalogSource: LanguageProviderCatalogSource { + private let base: any LanguageProviderCatalogSource + private let languageSupports: [LanguageSupportDeclaration] + + init( + base: any LanguageProviderCatalogSource, + languageSupports: [LanguageSupportDeclaration] + ) { + self.base = base + self.languageSupports = languageSupports.sorted { $0.id < $1.id } + } + + func load(workspaceURL: URL? = nil) -> LanguageProviderCatalogSnapshot { + let snapshot = base.load(workspaceURL: workspaceURL) + return LanguageProviderCatalogSnapshot( + catalog: snapshot.catalog.applying(languageSupports: languageSupports), + schemaVersion: snapshot.schemaVersion, + origin: snapshot.origin, + issues: snapshot.issues + ) + } +} + +extension LanguageProviderCatalog { + func applying( + languageSupports: [LanguageSupportDeclaration] + ) -> LanguageProviderCatalog { + var merged = descriptors + var indicesByID = Dictionary( + uniqueKeysWithValues: merged.enumerated().map { ($0.element.id, $0.offset) } + ) + + for support in languageSupports.sorted(by: { $0.id < $1.id }) { + let existingIndex = indicesByID[support.id] + let existing = existingIndex.map { merged[$0] } + var capabilities = existing?.capabilities ?? [] + + // Process-backed capabilities declared by a language package are + // authoritative. This prevents a shared fallback catalog from + // silently running tools after the package module is disabled. + capabilities.subtract([.run, .languageServer, .debugAdapter, .testing]) + if support.languageServerModuleID != nil { capabilities.insert(.languageServer) } + if support.executionModuleID != nil { capabilities.insert(.run) } + if support.testingModuleID != nil { capabilities.insert(.testing) } + if support.debugModuleID != nil { capabilities.insert(.debugAdapter) } + + let descriptor = LanguageProviderDescriptor( + id: support.id, + displayName: support.displayName, + fileExtensions: Set(support.fileExtensions).union(existing?.fileExtensions ?? []), + fileNames: Set(support.fileNames).union(existing?.fileNames ?? []), + fileNamePrefixes: existing?.fileNamePrefixes ?? [], + capabilities: capabilities, + activationPolicy: existing?.activationPolicy ?? .onDemand, + languageIdentifier: existing?.languageIdentifier ?? support.id, + languageIdentifiersByExtension: existing?.languageIdentifiersByExtension ?? [:], + languageIdentifiersByFileName: existing?.languageIdentifiersByFileName ?? [:], + languageServerLaunch: nil, + languageServerInstallation: existing?.languageServerInstallation + ) + + if let existingIndex { + merged[existingIndex] = descriptor + } else { + indicesByID[support.id] = merged.endIndex + merged.append(descriptor) + } + } + return LanguageProviderCatalog(descriptors: merged) + } +} diff --git a/Sources/Lithe/Core/Ports/DirectoryChangeSource.swift b/Sources/Lithe/Core/Ports/DirectoryChangeSource.swift index e6b59baa..1a930d10 100644 --- a/Sources/Lithe/Core/Ports/DirectoryChangeSource.swift +++ b/Sources/Lithe/Core/Ports/DirectoryChangeSource.swift @@ -1,100 +1,7 @@ -import Foundation +import LitheCoreContracts - -struct DirectoryWatchConfiguration: Equatable, Sendable { - let workspaceRoot: URL - let repositoryRoot: URL? - let gitDirectory: URL? - let gitCommonDirectory: URL? - - init(workspaceRoot: URL, gitContext: GitWatchContext?) { - self.workspaceRoot = Self.normalize(workspaceRoot) - repositoryRoot = gitContext.map { Self.normalize($0.repositoryRoot) } - gitDirectory = gitContext.map { Self.normalize($0.gitDirectory) } - gitCommonDirectory = gitContext.map { Self.normalize($0.gitCommonDirectory) } - } - - var physicalRoots: [URL] { - let logicalRoots = [workspaceRoot, repositoryRoot, gitDirectory, gitCommonDirectory] - .compactMap { $0 } - var seen = Set() - let uniqueRoots = logicalRoots - .filter { seen.insert($0.path).inserted } - .sorted { - if $0.path.count == $1.path.count { return $0.path < $1.path } - return $0.path.count < $1.path.count - } - return uniqueRoots.filter { candidate in - !uniqueRoots.contains { root in - root.path != candidate.path && Self.contains(root, candidate) - } - } - } - - func containsWorkspacePath(_ url: URL) -> Bool { - Self.contains(workspaceRoot, Self.normalize(url)) - } - - func containsRepositoryPath(_ url: URL) -> Bool { - guard let repositoryRoot else { return false } - return Self.contains(repositoryRoot, Self.normalize(url)) - } - - func containsGitMetadataPath(_ url: URL) -> Bool { - let normalized = Self.normalize(url) - return [gitDirectory, gitCommonDirectory] - .compactMap { $0 } - .contains { Self.contains($0, normalized) } - } - - func isGitContextPointer(_ url: URL) -> Bool { - let normalized = Self.normalize(url) - let candidates = [workspaceRoot, repositoryRoot] - .compactMap { $0 } - .map { $0.appendingPathComponent(".git").standardizedFileURL.path } - return candidates.contains(normalized.path) - } - - func isLogicalRoot(_ url: URL) -> Bool { - let path = Self.normalize(url).path - return [workspaceRoot, repositoryRoot, gitDirectory, gitCommonDirectory] - .compactMap { $0 } - .contains { $0.path == path } - } - - private static func normalize(_ url: URL) -> URL { - url.standardizedFileURL.resolvingSymlinksInPath() - } - - private static func contains(_ parent: URL, _ child: URL) -> Bool { - child.path == parent.path || child.path.hasPrefix(parent.path + "/") - } -} - -struct DirectoryChangeBatch: Equatable, Sendable { - var workspacePaths: [String] - var gitStateMayHaveChanged: Bool - var requiresFullRescan: Bool - var watchRootsChanged: Bool - - init( - workspacePaths: [String] = [], - gitStateMayHaveChanged: Bool = false, - requiresFullRescan: Bool = false, - watchRootsChanged: Bool = false - ) { - self.workspacePaths = workspacePaths - self.gitStateMayHaveChanged = gitStateMayHaveChanged - self.requiresFullRescan = requiresFullRescan - self.watchRootsChanged = watchRootsChanged - } - - var isEmpty: Bool { - workspacePaths.isEmpty && !gitStateMayHaveChanged && !requiresFullRescan && !watchRootsChanged - } -} - -protocol DirectoryChangeSource: AnyObject { - func start() - func stop() -} +typealias DirectoryWatchConfiguration = LitheCoreContracts.DirectoryWatchConfiguration +typealias DirectoryChangeBatch = LitheCoreContracts.DirectoryChangeBatch +typealias DirectoryChangeSource = LitheCoreContracts.DirectoryChangeSource +typealias DirectoryWatcherFactory = LitheCoreContracts.DirectoryWatcherFactory +typealias GitWatchContextProviding = LitheCoreContracts.GitWatchContextProviding diff --git a/Sources/Lithe/Core/Ports/LanguagePacks.swift b/Sources/Lithe/Core/Ports/LanguagePacks.swift index 7bf6815e..950f94ef 100644 --- a/Sources/Lithe/Core/Ports/LanguagePacks.swift +++ b/Sources/Lithe/Core/Ports/LanguagePacks.swift @@ -1,4 +1,5 @@ import Foundation +import LitheExecutionModule struct StdioDebugAdapterLaunch: Sendable, Equatable { struct Fallback: Sendable, Equatable { diff --git a/Sources/Lithe/Core/Ports/LanguageRunProviders.swift b/Sources/Lithe/Core/Ports/LanguageRunProviders.swift index 5d0870b8..2e935671 100644 --- a/Sources/Lithe/Core/Ports/LanguageRunProviders.swift +++ b/Sources/Lithe/Core/Ports/LanguageRunProviders.swift @@ -1,181 +1,8 @@ -import Foundation - -struct LanguageRunContext: Equatable, Sendable { - let workspaceURL: URL - let fileURL: URL - - init(workspaceURL: URL, fileURL: URL) { - self.workspaceURL = workspaceURL.standardizedFileURL - self.fileURL = fileURL.standardizedFileURL - } - - var relativeFilePath: String? { - let root = workspaceURL.path - let file = fileURL.path - guard file == root || file.hasPrefix(root + "/") else { return nil } - guard file != root else { return "" } - return String(file.dropFirst(root.count + 1)) - } -} - -enum RunArgumentParser { - static func parse(_ input: String) -> [String] { - var result: [String] = [] - var current = "" - var quote: Character? - var escaped = false - - for character in input { - if escaped { - current.append(character) - escaped = false - continue - } - if character == "\\" && quote != "'" { - escaped = true - continue - } - if character == "'" || character == "\"" { - if quote == character { - quote = nil - } else if quote == nil { - quote = character - } else { - current.append(character) - } - continue - } - if character.isWhitespace && quote == nil { - if !current.isEmpty { - result.append(current) - current = "" - } - } else { - current.append(character) - } - } - if escaped { current.append("\\") } - if !current.isEmpty { result.append(current) } - return result - } -} - -enum LanguageRunPlanError: LocalizedError, Equatable, Sendable { - case noProvider(fileExtension: String) - case fileOutsideWorkspace(URL) - case unsupportedCurrentFile(String) - - var errorDescription: String? { - switch self { - case .noProvider(let fileExtension): - return "No language run provider handles .\(fileExtension) files." - case .fileOutsideWorkspace(let url): - return "The current file is outside the workspace: \(url.path)" - case .unsupportedCurrentFile(let provider): - return "\(provider) does not support running the current file directly. Use a project run configuration." - } - } -} - -/// Language-specific translation for the language-neutral Current File entry. -/// The provider creates only a launch plan; executable lookup and process -/// lifecycle remain in the shared RunService and injected platform adapters. -protocol LanguageRunProvider: Sendable { - var descriptor: LanguageProviderDescriptor { get } - func launchPlan( - context: LanguageRunContext, - options: RunOptions - ) throws -> SharedLaunchPlan -} - -struct StandardLanguageRunProvider: LanguageRunProvider { - let descriptor: LanguageProviderDescriptor - - func launchPlan( - context: LanguageRunContext, - options: RunOptions - ) throws -> SharedLaunchPlan { - guard let relative = context.relativeFilePath else { - throw LanguageRunPlanError.fileOutsideWorkspace(context.fileURL) - } - guard !relative.isEmpty else { - throw LanguageRunPlanError.unsupportedCurrentFile(descriptor.displayName) - } - - switch descriptor.id { - case "go": - return SharedLaunchPlan( - executable: .toolchain("project-go"), - arguments: ["run", relative] + RunArgumentParser.parse(options.arguments), - workingDirectory: ".", - environment: options.environment - ) - case "python": - return SharedLaunchPlan( - executable: .toolchain("project-python"), - arguments: [relative] + RunArgumentParser.parse(options.arguments), - workingDirectory: ".", - environment: options.environment - ) - case "node": - let extensionName = context.fileURL.pathExtension.lowercased() - if extensionName == "ts" || extensionName == "tsx" { - return SharedLaunchPlan( - executable: .toolchain("project-tsx"), - arguments: [relative] + RunArgumentParser.parse(options.arguments), - workingDirectory: ".", - environment: options.environment - ) - } - return SharedLaunchPlan( - executable: .toolchain("project-node"), - arguments: [relative] + RunArgumentParser.parse(options.arguments), - workingDirectory: ".", - environment: options.environment - ) - case "rust": - throw LanguageRunPlanError.unsupportedCurrentFile(descriptor.displayName) - default: - throw LanguageRunPlanError.unsupportedCurrentFile(descriptor.displayName) - } - } -} - -struct LanguageRunProviderRegistry: Sendable { - private let providersByID: [String: any LanguageRunProvider] - private let descriptors: [LanguageProviderDescriptor] - - init(providers: [any LanguageRunProvider]) { - providersByID = Dictionary(uniqueKeysWithValues: providers.map { ($0.descriptor.id, $0) }) - descriptors = providers.map(\.descriptor) - } - - static func standard(catalog: LanguageProviderCatalog = .standard) -> Self { - Self(providers: catalog.descriptors - .filter { $0.capabilities.contains(.run) && $0.id != "java" } - .map(StandardLanguageRunProvider.init)) - } - - func provider(for fileURL: URL) -> (any LanguageRunProvider)? { - guard let descriptor = descriptors.first(where: { $0.handles(fileURL: fileURL) }) else { return nil } - return providersByID[descriptor.id] - } - - func provider(id: String) -> (any LanguageRunProvider)? { - providersByID[id] - } - - func launchPlan( - for fileURL: URL, - workspaceURL: URL, - options: RunOptions = RunOptions() - ) throws -> SharedLaunchPlan { - guard let provider = provider(for: fileURL) else { - throw LanguageRunPlanError.noProvider(fileExtension: fileURL.pathExtension.lowercased()) - } - return try provider.launchPlan( - context: LanguageRunContext(workspaceURL: workspaceURL, fileURL: fileURL), - options: options - ) - } -} +import LitheCoreContracts + +typealias LanguageRunContext = LitheCoreContracts.LanguageRunContext +typealias RunArgumentParser = LitheCoreContracts.RunArgumentParser +typealias LanguageRunPlanError = LitheCoreContracts.LanguageRunPlanError +typealias LanguageRunProvider = LitheCoreContracts.LanguageRunProvider +typealias StandardLanguageRunProvider = LitheCoreContracts.StandardLanguageRunProvider +typealias LanguageRunProviderRegistry = LitheCoreContracts.LanguageRunProviderRegistry diff --git a/Sources/Lithe/Core/Ports/LanguageTesting.swift b/Sources/Lithe/Core/Ports/LanguageTesting.swift new file mode 100644 index 00000000..e38b82eb --- /dev/null +++ b/Sources/Lithe/Core/Ports/LanguageTesting.swift @@ -0,0 +1,9 @@ +import Foundation +import LitheCoreContracts + +typealias LanguageTestItemKind = LitheCoreContracts.LanguageTestItemKind +typealias LanguageTestItem = LitheCoreContracts.LanguageTestItem +typealias LanguageTestScope = LitheCoreContracts.LanguageTestScope +typealias LanguageTestContext = LitheCoreContracts.LanguageTestContext +typealias LanguageTestPlan = LitheCoreContracts.LanguageTestPlan +typealias LanguageTestProvider = LitheCoreContracts.LanguageTestProvider diff --git a/Sources/Lithe/Core/Ports/LanguageTooling.swift b/Sources/Lithe/Core/Ports/LanguageTooling.swift index 9c40b58c..c3b8e777 100644 --- a/Sources/Lithe/Core/Ports/LanguageTooling.swift +++ b/Sources/Lithe/Core/Ports/LanguageTooling.swift @@ -1,794 +1 @@ -import Foundation - -struct LanguageToolingCapability: OptionSet, Hashable, Sendable { - let rawValue: Int - - static let run = Self(rawValue: 1 << 0) - static let languageServer = Self(rawValue: 1 << 1) - 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 { - let rawValue: Int - - static let definition = Self(rawValue: 1 << 0) - static let references = Self(rawValue: 1 << 1) - static let implementation = Self(rawValue: 1 << 2) - static let hover = Self(rawValue: 1 << 3) - static let completion = Self(rawValue: 1 << 4) - static let rename = Self(rawValue: 1 << 5) - static let formatting = Self(rawValue: 1 << 6) - static let codeActions = Self(rawValue: 1 << 7) - static let completionResolve = Self(rawValue: 1 << 8) - static let codeActionResolve = Self(rawValue: 1 << 9) - static let executeCommand = Self(rawValue: 1 << 10) - - static let standardEditing: Self = [ - .definition, .references, .implementation, .hover, .completion, - .rename, .formatting, .codeActions, .completionResolve, - .codeActionResolve, .executeCommand - ] -} - -enum ToolingActivationPolicy: String, Codable, Hashable, Sendable { - 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 { - 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 { - let extensionName = fileURL.pathExtension.lowercased() - let fileName = fileURL.lastPathComponent.lowercased() - return languageIdentifiersByFileName[fileName] - ?? languageIdentifiersByExtension[extensionName] - ?? languageIdentifier - ?? id - } -} - -struct LanguageProviderCatalog: Sendable { - let descriptors: [LanguageProviderDescriptor] - - /// 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"], - capabilities: [.run, .languageServer, .formatting, .testing], - activationPolicy: .onDemand - ), - LanguageProviderDescriptor( - id: "go", displayName: "Go", fileExtensions: ["go"], - capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing], - activationPolicy: .onDemand - ), - LanguageProviderDescriptor( - id: "python", displayName: "Python", fileExtensions: ["py", "pyw"], - capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing], - activationPolicy: .onDemand - ), - LanguageProviderDescriptor( - id: "node", displayName: "Node.js", fileExtensions: ["js", "jsx", "ts", "tsx", "mjs", "cjs"], - capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing], - 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? { - descriptors.first { $0.handles(fileURL: fileURL) } - } -} - -struct LanguageServerPosition: Equatable, Sendable { - let line: Int - let utf16Column: Int -} - -struct LanguageServerRange: Equatable, Sendable { - let start: LanguageServerPosition - 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 { - let url: URL - let range: LanguageServerRange - let isReadOnly: Bool - let displayPath: String? - - init( - url: URL, - range: LanguageServerRange, - isReadOnly: Bool = false, - displayPath: String? = nil - ) { - self.url = url - self.range = range - self.isReadOnly = isReadOnly - self.displayPath = displayPath - } -} - -struct LanguageServerHover: Equatable, Sendable { - let contents: String - let isMarkdown: Bool - let range: LanguageServerRange? -} - -struct LanguageServerCompletionItem: Identifiable, Equatable, Sendable { - let label: String - let detail: String? - let documentation: String? - let insertText: String - let sortText: String? - let filterText: String? - let kind: Int? - let textEdit: LanguageServerTextEdit? - let additionalTextEdits: [LanguageServerTextEdit] - let data: ToolingJSONValue? - - var id: String { - [label, detail ?? "", insertText, sortText ?? ""].joined(separator: "\u{1F}") - } -} - -struct LanguageServerCommand: Equatable, Sendable { - let title: String - let command: String - let arguments: [ToolingJSONValue] -} - -enum LanguageServerLogLevel: String, Sendable { - case info - case warning - case error -} - -/// What the editor wants from a language server, named by intent rather than by -/// the LSP method that satisfies it. The core maps these to methods and owns the -/// request IDs, so the UI never names a protocol method or reads a raw response. -enum LanguageServerOperation: String, Equatable, Sendable { - case completion - case hover - case definition - case declaration - case typeDefinition - case references - case implementation - case rename - case formatting - case codeActions - case resolveCompletion - case resolveCodeAction - case executeCommand - case inlayHints - case foldingRanges - case codeLens - /// Resolving a server-owned source that has no file on disk, such as a - /// decompiled class behind a `jdt://` URI. - case virtualDocument -} - -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 -} - -struct LanguageServerWorkspaceEdit: Equatable, Sendable { - let changes: [URL: [LanguageServerTextEdit]] - - init(changes: [URL: [LanguageServerTextEdit]] = [:]) { - self.changes = changes - } -} - -struct LanguageServerCodeAction: Identifiable, Equatable, Sendable { - let title: String - let kind: String? - let isPreferred: Bool - let edit: LanguageServerWorkspaceEdit? - let command: LanguageServerCommand? - let data: ToolingJSONValue? - - var id: String { [title, kind ?? ""].joined(separator: "\u{1F}") } -} - -enum LanguageTestItemKind: String, Equatable, Sendable { - case workspace - case file - case testCase -} - -struct LanguageTestItem: Identifiable, Equatable, Sendable { - let id: String - let providerID: String - let label: String - let kind: LanguageTestItemKind - let fileURL: URL? -} - -enum LanguageTestScope: Equatable, Sendable { - case workspace - case file(URL) - case testCase(identifier: String, fileURL: URL?) -} - -struct LanguageTestContext: Equatable, Sendable { - let workspaceURL: URL - let projectFiles: [URL] - - init(workspaceURL: URL, projectFiles: [URL] = []) { - self.workspaceURL = workspaceURL.standardizedFileURL - self.projectFiles = projectFiles.map(\.standardizedFileURL) - } - - var projectFileNames: Set { - Set(projectFiles.map { $0.lastPathComponent.lowercased() }) - } -} - -struct LanguageTestPlan: Sendable { - let providerID: String - let label: String - let frameworkID: String? - let launchPlan: SharedLaunchPlan - - init( - providerID: String, - label: String, - frameworkID: String? = nil, - launchPlan: SharedLaunchPlan - ) { - self.providerID = providerID - self.label = label - self.frameworkID = frameworkID - self.launchPlan = launchPlan - } -} - -protocol LanguageTestProvider: Sendable { - var descriptor: LanguageProviderDescriptor { get } - func discoverTests(workspaceURL: URL, files: [URL]) -> [LanguageTestItem] - func discoverTests(context: LanguageTestContext) -> [LanguageTestItem] - func testPlan(scope: LanguageTestScope, context: LanguageTestContext) throws -> LanguageTestPlan -} - -extension LanguageTestProvider { - func discoverTests(context: LanguageTestContext) -> [LanguageTestItem] { - discoverTests(workspaceURL: context.workspaceURL, files: context.projectFiles) - } - - func testPlan(scope: LanguageTestScope, workspaceURL: URL) throws -> LanguageTestPlan { - try testPlan( - scope: scope, - context: LanguageTestContext(workspaceURL: workspaceURL) - ) - } -} - -@MainActor -protocol LanguageServerSession: AnyObject { - var isRunning: Bool { get } - var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { get set } - 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<[LanguageServerCompletionItem], Error>) -> Void - ) throws - func hover( - fileURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result) -> Void - ) throws - func navigate( - method: String, - fileURL: URL, - position: LanguageServerPosition, - completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void - ) throws - func rename( - fileURL: URL, - position: LanguageServerPosition, - newName: String, - completion: @escaping (Result) -> Void - ) throws - func format( - fileURL: URL, - completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void - ) throws - func codeActions( - fileURL: URL, - range: LanguageServerRange, - diagnostics: [LanguageServerDiagnostic], - completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> 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 resolveVirtualDocument( - uri: String, - 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 -protocol DebugAdapterSession: AnyObject { - var isRunning: Bool { get } - var state: DebugAdapterState { get } - func start(rootURL: URL) throws - func stop() -} - -@MainActor -protocol DebugAdapterTransport: AnyObject { - var isRunning: Bool { get } - var onData: ((Data) -> Void)? { get set } - var onErrorOutput: ((Data) -> Void)? { get set } - var onTermination: ((Int) -> Void)? { get set } - func start(rootURL: URL) throws - func send(_ data: Data) throws - func stop() -} - -@MainActor -protocol DebugAdapterChildTransportProviding: AnyObject { - func makeChildTransport() -> (any DebugAdapterTransport)? -} - -extension DebugAdapterSession { - var state: DebugAdapterState { isRunning ? .running : .idle } -} - -enum ToolingJSONValue: Codable, Equatable, Hashable, Sendable { - case string(String) - case integer(Int) - case number(Double) - case bool(Bool) - case object([String: ToolingJSONValue]) - case array([ToolingJSONValue]) - case null - - var foundationObject: Any { - switch self { - case .string(let value): value - case .integer(let value): value - case .number(let value): value - case .bool(let value): value - case .object(let value): value.mapValues(\.foundationObject) - case .array(let value): value.map(\.foundationObject) - case .null: NSNull() - } - } - - static func fromFoundation(_ value: Any) -> ToolingJSONValue? { - if value is NSNull { return .null } - if let value = value as? String { return .string(value) } - if let number = value as? NSNumber { - if CFGetTypeID(number) == CFBooleanGetTypeID() { return .bool(number.boolValue) } - let double = number.doubleValue - if double.rounded() == double, double >= Double(Int.min), double <= Double(Int.max) { - return .integer(number.intValue) - } - return .number(double) - } - if let values = value as? [Any] { return .array(values.compactMap(fromFoundation)) } - if let object = value as? [String: Any] { - return .object(object.compactMapValues(fromFoundation)) - } - 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 { - case idle - case initializing - case ready - case launching - case running - case paused - case terminated - case failed -} - -enum DebugRequestKind: String, Equatable, Sendable { - case launch - case attach -} - -struct DebugLaunchConfiguration: Equatable, Sendable { - let name: String - let request: DebugRequestKind - let arguments: [String: ToolingJSONValue] -} - -struct DebugSourceBreakpoint: Hashable, Sendable { - let line: Int - let column: Int? - let condition: String? - - init(line: Int, column: Int? = nil, condition: String? = nil) { - self.line = line - self.column = column - self.condition = condition - } -} - -struct DebugBreakpoint: Identifiable, Equatable, Sendable { - let id: Int - let verified: Bool - let message: String? - let sourceURL: URL? - let line: Int? - let column: Int? -} - -struct DebugThread: Identifiable, Equatable, Sendable { - let id: Int - let name: String -} - -struct DebugStackFrame: Identifiable, Equatable, Sendable { - let id: Int - let name: String - let sourceURL: URL? - let line: Int - let column: Int -} - -struct DebugScope: Identifiable, Equatable, Sendable { - let id: Int - let name: String - let variablesReference: Int - let expensive: Bool -} - -struct DebugVariable: Identifiable, Equatable, Sendable { - let id: String - let name: String - let value: String - let type: String? - let evaluateName: String? - let variablesReference: Int - - var isExpandable: Bool { variablesReference > 0 } -} - -enum DebugAdapterEvent: Equatable, Sendable { - case initialized - case output(category: String?, output: String) - case stopped(reason: String, threadID: Int?, description: String?) - case continued(threadID: Int?) - case terminated(exitCode: Int?) - case breakpoint(DebugBreakpoint) -} - -enum DebugExecutionCommand: String, Equatable, Sendable { - case continueExecution = "continue" - case pause - case next - case stepIn - case stepOut -} - -@MainActor -protocol DebugAdapterControllingSession: DebugAdapterSession { - var onStateChange: ((DebugAdapterState) -> Void)? { get set } - var onEvent: ((DebugAdapterEvent) -> Void)? { get set } - func launch(_ configuration: DebugLaunchConfiguration) throws - func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) - func execute(_ command: DebugExecutionCommand, threadID: Int?) - func requestThreads(_ completion: @escaping (Result<[DebugThread], Error>) -> Void) - func requestStackTrace( - threadID: Int, - completion: @escaping (Result<[DebugStackFrame], Error>) -> Void - ) - func requestScopes( - frameID: Int, - completion: @escaping (Result<[DebugScope], Error>) -> Void - ) - func requestVariables( - reference: Int, - completion: @escaping (Result<[DebugVariable], Error>) -> Void - ) - func evaluate( - _ expression: String, - frameID: Int?, - completion: @escaping (Result) -> Void - ) -} - -@MainActor -protocol LanguageProviderRuntime: AnyObject { - var descriptor: LanguageProviderDescriptor { get } - var supportsLanguageServerSession: Bool { get } - var supportsDebugAdapterSession: Bool { get } - var unavailableToolingMessage: String? { 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 supportsLanguageServerSession: Bool { false } - var supportsDebugAdapterSession: Bool { false } - var unavailableToolingMessage: String? { nil } - func makeLanguageServerSession() -> (any LanguageServerSession)? { nil } - func makeDebugAdapterSession(rootURL: URL) -> (any DebugAdapterSession)? { - makeDebugAdapterSession() - } -} +@_exported import LitheCoreContracts diff --git a/Sources/Lithe/Core/Ports/ProcessRunner.swift b/Sources/Lithe/Core/Ports/ProcessRunner.swift index 2fb41eb5..321fb7b3 100644 --- a/Sources/Lithe/Core/Ports/ProcessRunner.swift +++ b/Sources/Lithe/Core/Ports/ProcessRunner.swift @@ -1,50 +1,10 @@ import Foundation +import LitheCoreContracts +import LitheGitModule -struct ProcessRequest: Sendable { - let operationID: String? - let executablePath: String - let arguments: [String] - let workingDirectory: String? - let environment: [String: String]? - let standardInput: Data? - let keepsStandardInputOpen: Bool - let timeoutMilliseconds: Int? - - init( - operationID: String? = nil, - executablePath: String, - arguments: [String] = [], - workingDirectory: String? = nil, - environment: [String: String]? = nil, - standardInput: Data? = nil, - keepsStandardInputOpen: Bool = false, - timeoutMilliseconds: Int? = nil - ) { - self.operationID = operationID - self.executablePath = executablePath - self.arguments = arguments - self.workingDirectory = workingDirectory - self.environment = environment - self.standardInput = standardInput - self.keepsStandardInputOpen = keepsStandardInputOpen - self.timeoutMilliseconds = timeoutMilliseconds - } -} - -enum ProcessLifecycleState: String, Sendable { - case starting - case running - case stopping - case finished - case failed -} - -struct ProcessLifecycleEvent: Sendable { - let operationID: String? - let state: ProcessLifecycleState - let exitCode: Int32? - let message: String? -} +typealias ProcessRequest = LitheCoreContracts.ProcessRequest +typealias ProcessLifecycleState = LitheCoreContracts.ProcessLifecycleState +typealias ProcessLifecycleEvent = LitheCoreContracts.ProcessLifecycleEvent struct ProcessResult: Sendable { let output: String diff --git a/Sources/Lithe/Core/Ports/RunConfigurationOperations.swift b/Sources/Lithe/Core/Ports/RunConfigurationOperations.swift index 01903f1d..eb6f3fe7 100644 --- a/Sources/Lithe/Core/Ports/RunConfigurationOperations.swift +++ b/Sources/Lithe/Core/Ports/RunConfigurationOperations.swift @@ -1,158 +1,20 @@ -import Foundation - -enum ProjectRunConfigurationStatus: Equatable, Sendable { - case missing - case ready - case invalid(String) -} - -enum RunConfigurationRecoveryAction: Equatable, Sendable { - case none - case regenerate - case editConfiguration - case fixPermissions - case upgradeApplication -} - -struct RunConfigurationDiagnostic: Equatable, Identifiable, Sendable { - let configurationID: String? - let code: String - let message: String - - var id: String { [configurationID, code, message].compactMap { $0 }.joined(separator: ":") } -} - -struct ProjectRunConfigurationInspection: Equatable, Sendable { - let status: ProjectRunConfigurationStatus - let diagnostics: [RunConfigurationDiagnostic] - var recoveryAction: RunConfigurationRecoveryAction = .none - var recoveryPath: String? = nil -} - -enum RunConfigurationGenerationState: Equatable, Sendable { - case idle - case succeeded(entryCount: Int) - case noEntries - case failed(String) -} - -enum RunConfigurationSaveScope: String, CaseIterable, Identifiable, Sendable { - case local - case project - - var id: String { rawValue } -} - -enum RunConfigurationSource: String, Sendable { - case generated - case project - case local -} - -struct EffectiveRunConfiguration: Sendable { - let configuration: RunConfiguration - let options: RunOptions - var source: RunConfigurationSource = .generated -} - -struct RunConfigurationResolution: Sendable { - let configurations: [EffectiveRunConfiguration] - let diagnostics: [RunConfigurationDiagnostic] - let defaultConfigurationID: String? -} - -struct RunConfigurationOperationFailure: LocalizedError, Sendable { - let message: String - - var errorDescription: String? { message } -} - -struct SharedLaunchPlan: Sendable { - /// Exactly one of `toolchainID` / `command` is set. A toolchain is resolved - /// through the IDE's registry; a command is resolved on PATH. - enum Executable: Sendable { - case toolchain(String) - case command(String) - } - - let executable: Executable - let arguments: [String] - let workingDirectory: String - var environment: [String: String] = [:] - - var toolchainID: String? { - if case .toolchain(let value) = executable { return value } - return nil - } -} - -struct RunConfigurationGenerationResult: Sendable { - let entryCount: Int -} - -struct RunConfigurationDraft: Sendable { - let name: String - let kind: RunConfigurationKind - let modulePath: String - let mainClass: String - let scope: RunConfigurationSaveScope -} - -struct RunConfigurationDocumentMutation: Sendable { - let configurationID: String? - let document: Data -} - -protocol RunConfigurationDocumentMutating: Sendable { - func updateOptionsDocument( - at projectURL: URL, - configurationID: String, - scope: RunConfigurationSaveScope, - options: RunOptions - ) throws -> RunConfigurationDocumentMutation - func createConfigurationDocument( - at projectURL: URL, - draft: RunConfigurationDraft - ) throws -> RunConfigurationDocumentMutation -} - -struct ProjectToolchainSelection: Equatable, Sendable { - var javaHomePath = "" - var mavenExecutablePath = "" - var mavenJavaHomePath = "" -} - -struct ProjectToolchainCandidate: Codable, Equatable, Sendable { - let id: String - let type: String - let version: String - let vendor: String -} - -protocol RunConfigurationOperations: Sendable { - func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection - func generate( - at projectURL: URL, - files: [URL], - modulePaths: [String] - ) throws -> RunConfigurationGenerationResult - func resolve( - at projectURL: URL, - toolchainCandidates: [ProjectToolchainCandidate] - ) throws -> RunConfigurationResolution - func launchPlan( - at projectURL: URL, - configurationID: String, - currentFile: String?, - classPath: String?, - debugPort: Int? - ) throws -> SharedLaunchPlan - func saveOptions( - _ options: RunOptions, - configurationID: String, - scope: RunConfigurationSaveScope, - at projectURL: URL - ) throws - func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String - func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws -} +import LitheCoreContracts + +typealias ProjectRunConfigurationStatus = LitheCoreContracts.ProjectRunConfigurationStatus +typealias RunConfigurationRecoveryAction = LitheCoreContracts.RunConfigurationRecoveryAction +typealias RunConfigurationDiagnostic = LitheCoreContracts.RunConfigurationDiagnostic +typealias ProjectRunConfigurationInspection = LitheCoreContracts.ProjectRunConfigurationInspection +typealias RunConfigurationGenerationState = LitheCoreContracts.RunConfigurationGenerationState +typealias RunConfigurationSaveScope = LitheCoreContracts.RunConfigurationSaveScope +typealias RunConfigurationSource = LitheCoreContracts.RunConfigurationSource +typealias EffectiveRunConfiguration = LitheCoreContracts.EffectiveRunConfiguration +typealias RunConfigurationResolution = LitheCoreContracts.RunConfigurationResolution +typealias RunConfigurationOperationFailure = LitheCoreContracts.RunConfigurationOperationFailure +typealias SharedLaunchPlan = LitheCoreContracts.SharedLaunchPlan +typealias RunConfigurationGenerationResult = LitheCoreContracts.RunConfigurationGenerationResult +typealias RunConfigurationDraft = LitheCoreContracts.RunConfigurationDraft +typealias RunConfigurationDocumentMutation = LitheCoreContracts.RunConfigurationDocumentMutation +typealias RunConfigurationDocumentMutating = LitheCoreContracts.RunConfigurationDocumentMutating +typealias ProjectToolchainSelection = LitheCoreContracts.ProjectToolchainSelection +typealias ProjectToolchainCandidate = LitheCoreContracts.ProjectToolchainCandidate +typealias RunConfigurationOperations = LitheCoreContracts.RunConfigurationOperations diff --git a/Sources/Lithe/Core/Ports/RunExecutableResolving.swift b/Sources/Lithe/Core/Ports/RunExecutableResolving.swift index c521e796..1451d73b 100644 --- a/Sources/Lithe/Core/Ports/RunExecutableResolving.swift +++ b/Sources/Lithe/Core/Ports/RunExecutableResolving.swift @@ -1,9 +1,7 @@ import Foundation +import LitheCoreContracts -struct ResolvedRunExecutable: Sendable { - let executableURL: URL - let environment: [String: String] -} +typealias ResolvedRunExecutable = LitheCoreContracts.ResolvedRunExecutable struct RunExecutableResolutionError: LocalizedError, Equatable, Sendable { let message: String @@ -23,18 +21,4 @@ protocol RunToolchainMetadataResolving: Sendable { /// Application boundary for resolving a launch plan. Toolchain ids are an /// open registry, but every id must have an explicit resolver; an unknown id /// must never silently acquire Maven semantics. -@MainActor -protocol RunExecutableResolving: AnyObject { - func resolve( - _ plan: SharedLaunchPlan, - projectURL: URL, - options: RunOptions - ) throws -> ResolvedRunExecutable - func refreshCandidates(projectURL: URL) async - func candidates(projectURL: URL) -> [ProjectToolchainCandidate] -} - -extension RunExecutableResolving { - func refreshCandidates(projectURL: URL) async {} - func candidates(projectURL: URL) -> [ProjectToolchainCandidate] { [] } -} +typealias RunExecutableResolving = LitheCoreContracts.RunExecutableResolving diff --git a/Sources/Lithe/Core/Ports/RuntimeLocator.swift b/Sources/Lithe/Core/Ports/RuntimeLocator.swift index 76649973..8c432c1d 100644 --- a/Sources/Lithe/Core/Ports/RuntimeLocator.swift +++ b/Sources/Lithe/Core/Ports/RuntimeLocator.swift @@ -1,51 +1,9 @@ import Foundation +import LitheCoreContracts /// Where a tool candidate came from. The value is intentionally platform /// 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 - case path - case homebrew - case xcode - case system - case custom - - var displayName: String { - switch self { - case .project: "Project" - case .environment: "Environment" - case .path: "PATH" - case .homebrew: "Homebrew" - case .xcode: "Xcode Command Line Tools" - case .system: "System" - case .custom: "Custom" - } - } -} - -struct RuntimeToolCandidate: Identifiable, Equatable, Sendable { - let command: String - let executableURL: URL - let source: RuntimeToolSource - let detail: String? - - var id: String { command + "\u{1F}" + executableURL.standardizedFileURL.path } - - init( - command: String, - executableURL: URL, - source: RuntimeToolSource, - detail: String? = nil - ) { - self.command = command - self.executableURL = executableURL.standardizedFileURL - self.source = source - self.detail = detail - } -} - struct RuntimeToolGuidance: Equatable, Sendable { let command: String let displayName: String diff --git a/Sources/Lithe/Core/Ports/SecureStore.swift b/Sources/Lithe/Core/Ports/SecureStore.swift index 9ed561f3..1e97ac0f 100644 --- a/Sources/Lithe/Core/Ports/SecureStore.swift +++ b/Sources/Lithe/Core/Ports/SecureStore.swift @@ -5,48 +5,3 @@ protocol SecureStore: Sendable { func write(_ value: String, key: String) throws func delete(key: String) throws } - -protocol AIProviderCredentialResolver: Sendable { - func readAPIKey(for provider: AIProviderProfile) -> String? -} - -protocol AIHTTPTransport: Sendable { - func send(_ request: AIHTTPRequest) async throws -> AIHTTPResponse -} - -struct AIHTTPRequest: Sendable { - let url: URL - let headers: [String: String] - let body: Data - let timeout: TimeInterval - let allowsInsecureHTTP: Bool - - init( - url: URL, - headers: [String: String], - body: Data, - timeout: TimeInterval, - allowsInsecureHTTP: Bool = false - ) { - self.url = url - self.headers = headers - self.body = body - self.timeout = timeout - self.allowsInsecureHTTP = allowsInsecureHTTP - } -} - -struct AIHTTPResponse: Sendable { - let statusCode: Int - let body: Data -} - -protocol AIConfigurationSource: Sendable { - func load() -> AIConfigurationSnapshot? -} - -protocol CodexConfigurationSource: AIConfigurationSource { -} - -protocol ClaudeConfigurationSource: AIConfigurationSource { -} diff --git a/Sources/Lithe/Core/Ports/StreamingProcess.swift b/Sources/Lithe/Core/Ports/StreamingProcess.swift index 726b4702..07cca78f 100644 --- a/Sources/Lithe/Core/Ports/StreamingProcess.swift +++ b/Sources/Lithe/Core/Ports/StreamingProcess.swift @@ -1,12 +1,4 @@ import Foundation +import LitheCoreContracts -protocol StreamingProcess: AnyObject, Sendable { - var isRunning: Bool { get } - var onOutput: (@Sendable (String) -> Void)? { get set } - var onTermination: (@Sendable (Int32) -> Void)? { get set } - var onStateChange: (@Sendable (ProcessLifecycleEvent) -> Void)? { get set } - - func start(_ request: ProcessRequest) throws - func send(_ input: Data) throws - func stop() -} +typealias StreamingProcess = LitheCoreContracts.StreamingProcess diff --git a/Sources/Lithe/Core/Ports/WorkspaceFileOperations.swift b/Sources/Lithe/Core/Ports/WorkspaceFileOperations.swift index 82132ef7..f6c51a7a 100644 --- a/Sources/Lithe/Core/Ports/WorkspaceFileOperations.swift +++ b/Sources/Lithe/Core/Ports/WorkspaceFileOperations.swift @@ -1,14 +1,3 @@ -import Foundation +import LitheCoreContracts -protocol WorkspaceFileOperations: Sendable { - func fileExists(at url: URL) -> Bool - func isDirectory(at url: URL) -> Bool - func createFile(at url: URL) throws - func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws - func copyItem(at sourceURL: URL, to destinationURL: URL) throws - func moveItem(at sourceURL: URL, to destinationURL: URL) throws - func removeItem(at url: URL) throws - func trashItem(at url: URL) throws - func writeText(_ text: String, to url: URL) throws - func readText(from url: URL) throws -> String -} +typealias WorkspaceFileOperations = LitheCoreContracts.WorkspaceFileOperations diff --git a/Sources/Lithe/Core/RustCoreBridge.swift b/Sources/Lithe/Core/Rust/RustCoreBridge.swift similarity index 99% rename from Sources/Lithe/Core/RustCoreBridge.swift rename to Sources/Lithe/Core/Rust/RustCoreBridge.swift index f7ae19f9..6ddc8a2a 100644 --- a/Sources/Lithe/Core/RustCoreBridge.swift +++ b/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1,4 +1,7 @@ import Foundation +import LitheCoreContracts +import LitheGitModule +import LitheSearchModule import LitheRustCore /// Language-neutral application boundary backed by the Rust Core. @@ -2296,7 +2299,7 @@ struct RustCoreBridge: Sendable { return response?.text } - func builtinLanguageCompletions( + package func builtinLanguageCompletions( fileURL: URL, text: String, position: LanguageServerPosition @@ -2312,7 +2315,7 @@ struct RustCoreBridge: Sendable { return response?.makeModels() } - func builtinLanguageHover( + package func builtinLanguageHover( fileURL: URL, text: String, position: LanguageServerPosition @@ -2328,7 +2331,7 @@ struct RustCoreBridge: Sendable { return response?.hover?.makeModel() } - func builtinLanguageNavigation( + package func builtinLanguageNavigation( method: String, fileURL: URL, text: String, diff --git a/Sources/Lithe/Core/RustGitOperations.swift b/Sources/Lithe/Core/Rust/RustGitOperations.swift similarity index 83% rename from Sources/Lithe/Core/RustGitOperations.swift rename to Sources/Lithe/Core/Rust/RustGitOperations.swift index e46d0bec..194fb202 100644 --- a/Sources/Lithe/Core/RustGitOperations.swift +++ b/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -1,15 +1,16 @@ import Foundation +import LitheGitModule /// Typed Git operations exposed by Rust Core. /// /// This is the migration seam for GitService. The Swift service can continue /// to translate Rust payloads into SwiftUI-facing models while Git execution /// and patch application remain shared and platform-neutral. -struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { +struct RustGitOperations: GitOperations, Sendable { let core: RustCoreBridge - private func makeProcessResult(_ response: RustCoreBridge.GitCommandPayload) -> ProcessResult { - ProcessResult( + private func makeProcessResult(_ response: RustCoreBridge.GitCommandPayload) -> GitProcessResult { + GitProcessResult( output: response.output, exitCode: response.exitCode, stashRestoreConflict: response.stashRestore.map { @@ -25,7 +26,7 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { arguments: [String], workingDirectory: String, input: String? - ) -> ProcessResult { + ) -> GitProcessResult { switch core.gitCommandResult( at: URL(fileURLWithPath: workingDirectory), arguments: arguments, @@ -34,7 +35,7 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { case .success(let response): return makeProcessResult(response) case .failure(let error): - return ProcessResult(output: error.userMessage, exitCode: 1) + return GitProcessResult(output: error.userMessage, exitCode: 1) } } @@ -55,7 +56,7 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { amend: Bool = false, force: Bool = false, autoStash: Bool = false - ) -> ProcessResult? { + ) -> GitProcessResult? { switch core.gitWriteResult( at: rootURL, operation: operation, @@ -77,43 +78,43 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { case .success(let response): return makeProcessResult(response) case .failure(let error): - return ProcessResult(output: error.userMessage, exitCode: 1) + return GitProcessResult(output: error.userMessage, exitCode: 1) } } - func stage(_ change: GitChange) -> ProcessResult? { + func stage(_ change: GitChange) -> GitProcessResult? { write(at: change.repositoryRoot, operation: "stage", paths: change.pathspecs) } - func unstage(_ change: GitChange) -> ProcessResult? { + func unstage(_ change: GitChange) -> GitProcessResult? { write(at: change.repositoryRoot, operation: "unstage", paths: change.pathspecs) } - func discard(_ change: GitChange) -> ProcessResult? { + func discard(_ change: GitChange) -> GitProcessResult? { return write(at: change.repositoryRoot, operation: "discard", paths: change.pathspecs) } - func discardAll(_ change: GitChange) -> ProcessResult? { + func discardAll(_ change: GitChange) -> GitProcessResult? { write(at: change.repositoryRoot, operation: "discardAll", paths: change.pathspecs) } - func commit(at rootURL: URL, message: String, amend: Bool) -> ProcessResult? { + func commit(at rootURL: URL, message: String, amend: Bool) -> GitProcessResult? { write(at: rootURL, operation: "commit", message: message, amend: amend) } - func cherryPick(_ hash: String, at rootURL: URL) -> ProcessResult? { + func cherryPick(_ hash: String, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "cherryPick", revision: hash) } - func revert(_ hash: String, at rootURL: URL) -> ProcessResult? { + func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "revert", revision: hash) } - func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> ProcessResult? { + func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "reset", revision: hash, mode: mode) } - func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> ProcessResult? { + func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? { write( at: rootURL, operation: "createBranch", @@ -123,23 +124,23 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { ) } - func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> ProcessResult? { + func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "renameBranch", reference: reference.fullName, name: name) } - func deleteBranch(_ reference: GitReference, at rootURL: URL) -> ProcessResult? { + func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "deleteBranch", reference: reference.fullName) } - func mergeBranch(_ reference: GitReference, at rootURL: URL) -> ProcessResult? { + func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "merge", reference: reference.fullName) } - func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> ProcessResult? { + func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "rebase", reference: reference.fullName) } - func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy = .ffOnly) -> ProcessResult? { + func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy = .ffOnly) -> GitProcessResult? { write(at: rootURL, operation: "pull", mode: strategy.rawValue) } @@ -177,7 +178,7 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { ) } - func fetch(at rootURL: URL) -> ProcessResult? { + func fetch(at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "fetch") } @@ -186,7 +187,7 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { at rootURL: URL, force: Bool = false, autoStash: Bool = false - ) -> ProcessResult? { + ) -> GitProcessResult? { write( at: rootURL, operation: "checkout", @@ -216,27 +217,27 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { ) } - func continueOperation(at rootURL: URL) -> ProcessResult? { + func continueOperation(at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "operationContinue") } - func abortOperation(at rootURL: URL) -> ProcessResult? { + func abortOperation(at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "operationAbort") } - func skipOperationStep(at rootURL: URL) -> ProcessResult? { + func skipOperationStep(at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "operationSkip") } - func checkoutRevision(_ revision: String, at rootURL: URL) -> ProcessResult? { + func checkoutRevision(_ revision: String, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "checkoutRevision", revision: revision) } - func push(_ reference: GitReference, at rootURL: URL) -> ProcessResult? { + func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "push", reference: reference.fullName) } - func cloneRepository(from remote: String, to destination: URL) -> ProcessResult? { + func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? { write( at: destination.deletingLastPathComponent(), operation: "clone", @@ -245,7 +246,7 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { ) } - func stash(message: String, includeUntracked: Bool, at rootURL: URL) -> ProcessResult? { + func stash(message: String, includeUntracked: Bool, at rootURL: URL) -> GitProcessResult? { write( at: rootURL, operation: "stashPush", @@ -254,19 +255,19 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { ) } - func applyStash(_ stash: GitStash, at rootURL: URL) -> ProcessResult? { + func applyStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "stashApply", reference: stash.reference) } - func popStash(_ stash: GitStash, at rootURL: URL) -> ProcessResult? { + func popStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "stashPop", reference: stash.reference) } - func dropStash(_ stash: GitStash, at rootURL: URL) -> ProcessResult? { + func dropStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "stashDrop", reference: stash.reference) } - func stageAll(at rootURL: URL) -> ProcessResult? { + func stageAll(at rootURL: URL) -> GitProcessResult? { write(at: rootURL, operation: "stageAll") } @@ -346,12 +347,12 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { _ patch: String, at rootURL: URL, mode: String - ) -> ProcessResult? { + ) -> GitProcessResult? { switch core.gitApplyResult(at: rootURL, patch: patch, mode: mode) { case .success(let response): - return ProcessResult(output: response.output, exitCode: response.exitCode) + return GitProcessResult(output: response.output, exitCode: response.exitCode) case .failure(let error): - return ProcessResult(output: error.userMessage, exitCode: 1) + return GitProcessResult(output: error.userMessage, exitCode: 1) } } @@ -407,3 +408,14 @@ struct RustGitOperations: GitOperations, GitCommandRunner, Sendable { core.gitBlame(at: rootURL, relativePath: relativePath)?.makeModels() } } + +/// Workspace Foundation only needs repository metadata paths for its watcher. +/// This narrow provider avoids constructing the complete Git workflow while +/// the on-demand Git module is inactive. +struct RustGitWatchContextProvider: GitWatchContextProviding, Sendable { + let core: RustCoreBridge + + func watchContext(for workspace: URL) async -> GitWatchContext? { + core.gitWatchContext(at: workspace)?.makeContext() + } +} diff --git a/Sources/Lithe/Core/RustJavaMavenOperations.swift b/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift similarity index 98% rename from Sources/Lithe/Core/RustJavaMavenOperations.swift rename to Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift index b5e8796c..db1ead76 100644 --- a/Sources/Lithe/Core/RustJavaMavenOperations.swift +++ b/Sources/Lithe/Core/Rust/RustJavaMavenOperations.swift @@ -1,6 +1,6 @@ import Foundation -protocol JavaMavenOperations: Sendable { +protocol JavaMavenOperations: MavenProjectOperations, RunServerPortParsing, Sendable { func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] func codeVision( diff --git a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift b/Sources/Lithe/Core/Rust/RustLanguageProviderCatalogSource.swift similarity index 99% rename from Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift rename to Sources/Lithe/Core/Rust/RustLanguageProviderCatalogSource.swift index ce0d9a4b..6e45a0c2 100644 --- a/Sources/Lithe/Core/RustLanguageProviderCatalogSource.swift +++ b/Sources/Lithe/Core/Rust/RustLanguageProviderCatalogSource.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts import LitheRustCore enum LanguageProviderCatalogOrigin: Equatable, Sendable { diff --git a/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift b/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift new file mode 100644 index 00000000..9af276b7 --- /dev/null +++ b/Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift @@ -0,0 +1,149 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +extension RustCoreBridge: LanguageServerRuntimeCore { + func startLanguageServer( + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + workingDirectoryURL: URL, + initializationOptions: ToolingJSONValue?, + runtimeExecutableURL: URL?, + cacheDirectoryURL: URL?, + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result { + lspStartServer( + providerID: providerID, + executableURL: executableURL, + arguments: arguments, + environment: environment, + rootURL: rootURL, + workingDirectoryURL: workingDirectoryURL, + initializationOptions: initializationOptions, + runtimeExecutableURL: runtimeExecutableURL, + cacheDirectoryURL: cacheDirectoryURL, + initializeTimeout: initializeTimeout, + requestTimeout: requestTimeout, + shutdownTimeout: shutdownTimeout + ).map { + LanguageServerRuntimeStart( + sessionID: $0.sessionId, + state: $0.state, + processID: $0.processId + ) + }.mapError(Self.runtimeFailure) + } + + func stopLanguageServer(sessionID: String) { + lspStopServer(sessionID: sessionID) + } + + func syncLanguageServerDocument( + sessionID: String, + fileURL: URL, + languageID: String, + text: String + ) -> Result { + lspSyncDocument( + sessionID: sessionID, + fileURL: fileURL, + languageID: languageID, + text: text + ).mapError(Self.runtimeFailure) + } + + func closeLanguageServerDocument(sessionID: String, fileURL: URL) { + lspCloseDocument(sessionID: sessionID, fileURL: fileURL) + } + + func requestLanguageServerOperation( + sessionID: String, + operation: LanguageServerOperation, + fileURL: URL?, + virtualURI: String?, + position: LanguageServerPosition?, + newName: String?, + range: LanguageServerRange?, + diagnostics: [LanguageServerDiagnostic], + completionItem: LanguageServerCompletionItem?, + codeAction: LanguageServerCodeAction?, + command: LanguageServerCommand? + ) -> Result { + lspRequest( + sessionID: sessionID, + operation: operation, + fileURL: fileURL, + virtualURI: virtualURI, + position: position, + newName: newName, + range: range, + diagnostics: diagnostics, + completionItem: completionItem, + codeAction: codeAction, + command: command + ).map { LanguageServerRuntimeOperation(operationID: $0.operationId) } + .mapError(Self.runtimeFailure) + } + + func cancelLanguageServerOperation(sessionID: String, operationID: String) { + lspCancelOperation(sessionID: sessionID, operationID: operationID) + } + + func pollLanguageServerEvents(sessionID: String) -> [LanguageServerRuntimeEvent] { + lspPollEvents(sessionID: sessionID).map { event in + LanguageServerRuntimeEvent( + type: event.type, + state: event.state, + operationID: event.operationId, + uri: event.uri, + diagnostics: event.diagnostics?.map { $0.makeModel() }, + result: event.result, + error: event.error.map { + LanguageServerRuntimeError( + message: $0.message, + underlyingMessage: $0.underlyingMessage, + processExitCode: $0.processExitCode + ) + }, + capabilities: event.capabilities, + serverInfo: event.serverInfo.map { + LanguageServerInfo(name: $0.name, version: $0.version) + }, + level: event.level, + message: event.message, + detail: event.detail + ) + } + } + + func destroyLanguageServer(sessionID: String) { + lspDestroyServer(sessionID: sessionID) + } + + private static func runtimeFailure(_ error: CoreCallError) -> LanguageServerRuntimeFailure { + LanguageServerRuntimeFailure( + code: error.code, + message: error.message, + details: error.details + ) + } +} + +extension RustCoreBridge: BuiltinLanguageFeatureCore { + package var isBuiltinLanguageFeatureAvailable: Bool { isAvailable } +} + +extension ManagedProcessRegistry: LanguageServerProcessRegistry { + func registerLanguageServerProcess(pid: Int32, moduleID: ModuleID) { + register(pid: pid, category: .languageServer, moduleID: moduleID) + } + + func unregisterLanguageServerProcess(pid: Int32, moduleID: ModuleID) { + unregister(pid: pid, category: .languageServer, moduleID: moduleID) + } +} diff --git a/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift b/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift new file mode 100644 index 00000000..e45669d7 --- /dev/null +++ b/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift @@ -0,0 +1,30 @@ +import Foundation +import LitheLocalHistoryModule + +struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable { + let core: RustCoreBridge + + func record(at workspaceURL: URL, storageURL: URL, relativePath: String, reason: LocalHistoryReason, content: String?, pruneExpired: Bool, visibilityRules: LocalHistoryVisibilityRules) -> LocalHistoryEntryPayload? { + core.historyRecord( + at: workspaceURL, storageURL: storageURL, relativePath: relativePath, + reason: reason.rawValue, content: content, pruneExpired: pruneExpired, + hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames, + hiddenFilePatterns: visibilityRules.hiddenFilePatterns + ).map(Self.makePayload) + } + + func entries(at workspaceURL: URL, storageURL: URL, relativePath: String?, visibilityRules: LocalHistoryVisibilityRules) -> [LocalHistoryEntryPayload]? { + core.historyEntries( + at: workspaceURL, storageURL: storageURL, relativePath: relativePath, + hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames, + hiddenFilePatterns: visibilityRules.hiddenFilePatterns + )?.entries.map(Self.makePayload) + } + + func content(at storageURL: URL, contentPath: String) -> String? { core.historyContent(storageURL: storageURL, contentPath: contentPath)?.text } + func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { core.historyRelocate(storageURL: storageURL, sourcePath: sourcePath, destinationPath: destinationPath) } + + private static func makePayload(_ value: RustCoreBridge.HistoryEntryPayload) -> LocalHistoryEntryPayload { + LocalHistoryEntryPayload(id: value.id, timestamp: value.timestamp, relativePath: value.relativePath, reason: value.reason, contentPath: value.contentPath, byteCount: value.byteCount) + } +} diff --git a/Sources/Lithe/Core/RustMarkdownRendering.swift b/Sources/Lithe/Core/Rust/RustMarkdownRendering.swift similarity index 100% rename from Sources/Lithe/Core/RustMarkdownRendering.swift rename to Sources/Lithe/Core/Rust/RustMarkdownRendering.swift diff --git a/Sources/Lithe/Core/RustWorkspaceOperations.swift b/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift similarity index 76% rename from Sources/Lithe/Core/RustWorkspaceOperations.swift rename to Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift index 820fc808..43efa8f3 100644 --- a/Sources/Lithe/Core/RustWorkspaceOperations.swift +++ b/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift @@ -1,58 +1,8 @@ import Foundation +import LitheCoreContracts +import LitheSearchModule -protocol WorkspaceOperations: Sendable { - func snapshot( - at rootURL: URL, - visibilityRules: FileVisibilityRules - ) -> WorkspaceSnapshot? - - func search( - at rootURL: URL, - query: String, - options: ProjectSearchOptions, - visibilityRules: FileVisibilityRules - ) -> [FileSearchResult]? - - func searchEverywhere( - at rootURL: URL, - query: String, - options: ProjectSearchOptions, - visibilityRules: FileVisibilityRules - ) -> SearchEverywhereResults? - - func previewReplacement( - at rootURL: URL, - query: String, - replacement: String, - options: ProjectSearchOptions, - paths: [String], - textOverrides: [String: String], - visibilityRules: FileVisibilityRules - ) -> [ProjectReplacementFile]? - - func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) - func updateSearchIndex( - at rootURL: URL, - changedPaths: [String], - visibilityRules: FileVisibilityRules - ) - func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) - - func readFile(at rootURL: URL, relativePath: String) -> String? - func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool -} - -extension WorkspaceOperations { - func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} - - func updateSearchIndex( - at rootURL: URL, - changedPaths: [String], - visibilityRules: FileVisibilityRules - ) {} - - func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} -} +typealias WorkspaceOperations = LitheCoreContracts.WorkspaceOperations struct RustWorkspaceOperations: WorkspaceOperations, Sendable { let core: RustCoreBridge @@ -176,3 +126,59 @@ struct RustWorkspaceOperations: WorkspaceOperations, Sendable { core.writeFile(text, at: rootURL, relativePath: relativePath) != nil } } + +extension RustWorkspaceOperations: SearchOperations { + func warmSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) { + warmSearchIndex(at: rootURL, visibilityRules: FileVisibilityRules(searchRules: visibilityRules)) + } + + func updateSearchIndex( + at rootURL: URL, + changedPaths: [String], + visibilityRules: SearchVisibilityRules + ) { + updateSearchIndex( + at: rootURL, + changedPaths: changedPaths, + visibilityRules: FileVisibilityRules(searchRules: visibilityRules) + ) + } + + func invalidateSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) { + invalidateSearchIndex(at: rootURL, visibilityRules: FileVisibilityRules(searchRules: visibilityRules)) + } + + func search( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules + ) -> [FileSearchResult]? { + search(at: rootURL, query: query, options: options, visibilityRules: FileVisibilityRules(searchRules: visibilityRules)) + } + + func searchEverywhere( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules + ) -> SearchEverywhereResults? { + searchEverywhere(at: rootURL, query: query, options: options, visibilityRules: FileVisibilityRules(searchRules: visibilityRules)) + } + + func previewReplacement( + at rootURL: URL, + query: String, + replacement: String, + options: ProjectSearchOptions, + paths: [String], + textOverrides: [String: String], + visibilityRules: SearchVisibilityRules + ) -> [ProjectReplacementFile]? { + previewReplacement( + at: rootURL, query: query, replacement: replacement, options: options, + paths: paths, textOverrides: textOverrides, + visibilityRules: FileVisibilityRules(searchRules: visibilityRules) + ) + } +} diff --git a/Sources/Lithe/Core/RustLocalHistoryOperations.swift b/Sources/Lithe/Core/RustLocalHistoryOperations.swift deleted file mode 100644 index 83d7f27a..00000000 --- a/Sources/Lithe/Core/RustLocalHistoryOperations.swift +++ /dev/null @@ -1,83 +0,0 @@ -import Foundation - -protocol LocalHistoryOperations: Sendable { - func record( - at workspaceURL: URL, - storageURL: URL, - relativePath: String, - reason: LocalHistoryReason, - content: String?, - pruneExpired: Bool, - visibilityRules: FileVisibilityRules - ) -> RustCoreBridge.HistoryEntryPayload? - - func entries( - at workspaceURL: URL, - storageURL: URL, - relativePath: String?, - visibilityRules: FileVisibilityRules - ) -> [RustCoreBridge.HistoryEntryPayload]? - - func content( - at storageURL: URL, - contentPath: String - ) -> String? - - func relocate( - at storageURL: URL, - sourcePath: String, - destinationPath: String - ) -> Bool -} - -struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable { - let core: RustCoreBridge - - func record( - at workspaceURL: URL, - storageURL: URL, - relativePath: String, - reason: LocalHistoryReason, - content: String?, - pruneExpired: Bool, - visibilityRules: FileVisibilityRules - ) -> RustCoreBridge.HistoryEntryPayload? { - core.historyRecord( - at: workspaceURL, - storageURL: storageURL, - relativePath: relativePath, - reason: reason.rawValue, - content: content, - pruneExpired: pruneExpired, - hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames, - hiddenFilePatterns: visibilityRules.hiddenFilePatterns - ) - } - - func entries( - at workspaceURL: URL, - storageURL: URL, - relativePath: String?, - visibilityRules: FileVisibilityRules - ) -> [RustCoreBridge.HistoryEntryPayload]? { - core.historyEntries( - at: workspaceURL, - storageURL: storageURL, - relativePath: relativePath, - hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames, - hiddenFilePatterns: visibilityRules.hiddenFilePatterns - )?.entries - } - - func content(at storageURL: URL, contentPath: String) -> String? { - core.historyContent(storageURL: storageURL, contentPath: contentPath)?.text - } - - func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { - core.historyRelocate( - storageURL: storageURL, - sourcePath: sourcePath, - destinationPath: destinationPath - ) - } -} diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index 288d8e6d..22451ecf 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -6,6 +6,7 @@ private let litheProcessLaunchDate = Date() @MainActor final class LitheAppDelegate: NSObject, NSApplicationDelegate { weak var projectSessions: ProjectSessionManager? + var recordCleanPluginShutdown: (() -> Void)? func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true @@ -18,6 +19,7 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { projectSessions?.stopAllSessions() + recordCleanPluginShutdown?() } func applicationDidBecomeActive(_ notification: Notification) { @@ -59,6 +61,9 @@ struct LitheApp: App { let store = MacUserDefaultsStore() let settings = AppSettings(store: store) let processRegistry = ManagedProcessRegistry() + let moduleStore = MacModuleConfigurationStore(store: store) + let pluginRuntimeRecovery = MacPluginRuntimeRecoveryCoordinator() + pluginRuntimeRecovery.recoverPreviousSession(using: moduleStore) _settings = StateObject(wrappedValue: settings) let projectSessions = ProjectSessionManager( settings: settings, @@ -68,7 +73,12 @@ struct LitheApp: App { services: MacServiceContainer( store: store, settings: settings, - processRegistry: processRegistry + processRegistry: processRegistry, + moduleLaunchMode: CommandLine.arguments.contains("--safe-mode") + ? .safeMode + : .normal, + moduleStore: moduleStore, + pluginRuntimeRecovery: pluginRuntimeRecovery ).services ) }, @@ -89,6 +99,9 @@ struct LitheApp: App { memorySampler: MacProcessMemorySampler() )) appDelegate.projectSessions = projectSessions + appDelegate.recordCleanPluginShutdown = { + pluginRuntimeRecovery.recordCleanShutdown(using: moduleStore) + } } private var model: AppModel { projectSessions.activeModel } diff --git a/Sources/Lithe/Models/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel+FeatureState.swift deleted file mode 100644 index 01f7237f..00000000 --- a/Sources/Lithe/Models/AppModel+FeatureState.swift +++ /dev/null @@ -1,183 +0,0 @@ -import Foundation - -extension AppModel { - var rootNode: FileNode? { workspaceFeature.rootNode } - var projectFiles: [URL] { workspaceFeature.projectFiles } - var javaEnvironmentReport: JavaEnvironmentReport? { - runtimeFeature.javaEnvironmentReport - } - - var shouldShowJavaEnvironmentBanner: Bool { - guard javaEnvironmentReport?.status.requiresAttention == true else { return false } - return projectFiles.contains { $0.pathExtension.lowercased() == "java" } - || hasMavenProject - || activeDocument?.url.pathExtension.lowercased() == "java" - } - - /// Maven is an optional build-system feature. Keeping this capability in - /// the generic workspace projection lets the UI hide the Java-only tool - /// window for Go, Python, Node, Rust, Gradle-only, and plain projects. - var hasMavenProject: Bool { - projectFiles.contains { $0.lastPathComponent.lowercased() == "pom.xml" } - } - - var openDocuments: [EditorDocument] { documentFeature.openDocuments } - var activeDocumentID: UUID? { - get { documentFeature.activeDocumentID } - set { - let previousDocumentID = documentFeature.activeDocumentID - documentFeature.activeDocumentID = newValue - guard previousDocumentID != newValue else { return } - activateCurrentDocumentLanguageServerIfAvailable() - } - } - - func moveOpenDocument(_ documentID: UUID, before targetDocumentID: UUID) { - documentFeature.moveDocument(documentID, before: targetDocumentID) - } - - func moveOpenDocument(_ documentID: UUID, after targetDocumentID: UUID) { - documentFeature.moveDocument(documentID, after: targetDocumentID) - } - var pendingCloseDocument: EditorDocument? { documentFeature.pendingCloseDocument } - var isPendingProjectClose: Bool { documentFeature.isPendingProjectClose } - - var gitChanges: [GitChange] { gitFeature.gitChanges } - var gitStashes: [GitStash] { gitFeature.gitStashes } - var gitShelves: [GitShelfEntry] { gitFeature.gitShelves } - var gitSaveChangesPolicy: GitSaveChangesPolicy { settings.gitSaveChangesPolicy } - var isPerformingStashOperation: Bool { gitFeature.isPerformingStashOperation } - var isPerformingShelfOperation: Bool { gitFeature.isPerformingShelfOperation } - var gitOperationState: GitOperationState? { gitFeature.gitOperationState } - var isResolvingGitOperation: Bool { gitFeature.isResolvingGitOperation } - var gitRepositoryRoot: URL? { gitFeature.gitRepositoryRoot } - var currentBranch: String { gitFeature.currentBranch } - var selectedChange: GitChange? { - get { gitFeature.selectedChange } - set { gitFeature.selectedChange = newValue } - } - var diffRows: [DiffRow] { gitFeature.diffRows } - var diffHunks: [DiffHunk] { gitFeature.diffHunks } - var gitDiffWhitespaceMode: GitDiffWhitespaceMode { - get { gitFeature.gitDiffWhitespaceMode } - set { gitFeature.gitDiffWhitespaceMode = newValue } - } - var isLoadingDiff: Bool { gitFeature.isLoadingDiff } - var isRefreshingGit: Bool { gitFeature.isRefreshingGit } - var pendingDiscardChange: GitChange? { - get { gitFeature.pendingDiscardChange } - set { gitFeature.pendingDiscardChange = newValue } - } - var pendingDiscardHunk: DiffHunkRequest? { - get { gitFeature.pendingDiscardHunk } - set { gitFeature.pendingDiscardHunk = newValue } - } - var pendingCheckoutConflict: GitCheckoutConflictRequest? { - get { gitFeature.pendingCheckoutConflict } - set { gitFeature.pendingCheckoutConflict = newValue } - } - - var pendingPullStrategy: GitPullStrategyRequest? { - get { gitFeature.pendingPullStrategy } - set { gitFeature.pendingPullStrategy = newValue } - } - - var pendingIntegrationConflict: GitIntegrationConflictRequest? { - get { gitFeature.pendingIntegrationConflict } - set { gitFeature.pendingIntegrationConflict = newValue } - } - var pendingConflictRollback: GitConflictRollbackRequest? { - get { gitFeature.pendingConflictRollback } - set { gitFeature.pendingConflictRollback = newValue } - } - var pendingStashRestoreConflict: GitStashRestoreConflictRequest? { - gitFeature.pendingStashRestoreConflict - } - var isStashRestoreConflictNoticeVisible: Bool { - gitFeature.isStashRestoreConflictNoticeVisible - } - var gitConflictFilterPaths: Set { - gitFeature.gitConflictFilterPaths - } - var requestedStashReference: String? { - gitFeature.requestedStashReference - } - var isCommitting: Bool { gitFeature.isCommitting } - var gitBlameLines: [URL: [GitBlameLine]] { gitFeature.gitBlameLines } - var gitReferences: [GitReference] { gitFeature.gitReferences } - var gitCommits: [GitCommit] { gitFeature.gitCommits } - var selectedGitReference: GitReference? { - get { gitFeature.selectedGitReference } - set { gitFeature.selectedGitReference = newValue } - } - var selectedGitCommit: GitCommit? { - get { gitFeature.selectedGitCommit } - set { gitFeature.selectedGitCommit = newValue } - } - var selectedGitCommitFiles: [GitCommitFile] { gitFeature.selectedGitCommitFiles } - var selectedGitCommitFile: GitCommitFile? { - get { gitFeature.selectedGitCommitFile } - set { gitFeature.selectedGitCommitFile = newValue } - } - var selectedGitCommitDiffContext: GitCommitDiffContext? { - get { gitFeature.selectedGitCommitDiffContext } - set { gitFeature.selectedGitCommitDiffContext = newValue } - } - var isLoadingGitHistory: Bool { gitFeature.isLoadingGitHistory } - var isLoadingMoreGitHistory: Bool { gitFeature.isLoadingMoreGitHistory } - var canLoadMoreGitHistory: Bool { gitFeature.canLoadMoreGitHistory } - var branchComparison: GitBranchComparison? { gitFeature.branchComparison } - var selectedBranchComparisonFile: GitBranchComparisonFile? { - get { gitFeature.selectedBranchComparisonFile } - set { gitFeature.selectedBranchComparisonFile = newValue } - } - var branchComparisonRows: [DiffRow] { gitFeature.branchComparisonRows } - var isLoadingBranchComparison: Bool { gitFeature.isLoadingBranchComparison } - var isPerformingBranchOperation: Bool { gitFeature.isPerformingBranchOperation } - var isCloningRepository: Bool { gitFeature.isCloningRepository } - var languageNavigationResults: [LanguageNavigationLocation] { - languageNavigationLocations - } - var languageNavigationKind: LanguageNavigationResultKind { - languageNavigationResultKind - } - var isLoadingNavigation: Bool { - isLoadingLanguageNavigation - } - var isLoadingWorkspace: Bool { workspaceFeature.isLoadingWorkspace } - var isRefreshingWorkspace: Bool { workspaceFeature.isRefreshingWorkspace } - var workspaceLoadErrorMessage: String? { workspaceFeature.loadErrorMessage } - var searchResults: [FileSearchResult] { searchFeature.searchResults } - var isSearching: Bool { searchFeature.isSearching } - var searchEverywhereResults: SearchEverywhereResults { searchFeature.searchEverywhereResults } - var isSearchingEverywhere: Bool { searchFeature.isSearchingEverywhere } - var projectReplacementFiles: [ProjectReplacementFile] { searchFeature.projectReplacementFiles } - var isLoadingProjectReplacement: Bool { searchFeature.isLoadingProjectReplacement } - - var localHistoryRequest: LocalHistoryRequest? { - get { projectHistoryFeature.localHistoryRequest } - set { projectHistoryFeature.localHistoryRequest = newValue } - } - var localHistoryEntries: [LocalHistoryEntry] { projectHistoryFeature.localHistoryEntries } - var selectedLocalHistoryEntry: LocalHistoryEntry? { - get { projectHistoryFeature.selectedLocalHistoryEntry } - set { projectHistoryFeature.selectedLocalHistoryEntry = newValue } - } - var localHistoryDiffRows: [DiffRow] { projectHistoryFeature.localHistoryDiffRows } - var isLoadingLocalHistory: Bool { projectHistoryFeature.isLoadingLocalHistory } - var projectLocalHistoryRequest: ProjectLocalHistoryRequest? { - get { projectHistoryFeature.projectLocalHistoryRequest } - set { projectHistoryFeature.projectLocalHistoryRequest = newValue } - } - var projectLocalHistoryEntries: [LocalHistoryEntry] { - projectHistoryFeature.projectLocalHistoryEntries - } - var selectedProjectLocalHistoryEntry: LocalHistoryEntry? { - get { projectHistoryFeature.selectedProjectLocalHistoryEntry } - set { projectHistoryFeature.selectedProjectLocalHistoryEntry = newValue } - } - var projectLocalHistoryDiffRows: [DiffRow] { projectHistoryFeature.projectLocalHistoryDiffRows } - var isLoadingProjectLocalHistory: Bool { - projectHistoryFeature.isLoadingProjectLocalHistory - } -} diff --git a/Sources/Lithe/Models/AppModel+AIConfiguration.swift b/Sources/Lithe/Models/AppModel/AppModel+AIConfiguration.swift similarity index 89% rename from Sources/Lithe/Models/AppModel+AIConfiguration.swift rename to Sources/Lithe/Models/AppModel/AppModel+AIConfiguration.swift index 919dbdae..0a0a7d3e 100644 --- a/Sources/Lithe/Models/AppModel+AIConfiguration.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+AIConfiguration.swift @@ -1,4 +1,6 @@ import Foundation +import LitheCoreContracts +import LitheModuleAPI extension AppModel { @discardableResult @@ -26,6 +28,7 @@ extension AppModel { try? services.secureStore.delete(key: provider.apiKeyIdentifier) detectedAIConfigurations.removeAll { $0.source == configuration.source } detectedAIConfigurations.append(configuration) + enableAIAssistanceModule() showNotification("\(configuration.source.title) configuration imported") return true } @@ -71,7 +74,10 @@ extension AppModel { do { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { try services.secureStore.delete(key: provider.apiKeyIdentifier) } - else { try services.secureStore.write(trimmed, key: provider.apiKeyIdentifier) } + else { + try services.secureStore.write(trimmed, key: provider.apiKeyIdentifier) + enableAIAssistanceModule() + } showNotification("API key saved locally") } catch { showNotification(error.localizedDescription) } } @@ -102,4 +108,15 @@ extension AppModel { } try? services.secureStore.delete(key: "lithe.\(configuration.source.rawValue).imported.apiKey") } + + private func enableAIAssistanceModule() { + Task { @MainActor in + do { + try await services.moduleRuntime.setEnabled(true, for: .aiAssistance) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + } + } + } } diff --git a/Sources/Lithe/Models/AppModel+Development.swift b/Sources/Lithe/Models/AppModel/AppModel+Development.swift similarity index 73% rename from Sources/Lithe/Models/AppModel+Development.swift rename to Sources/Lithe/Models/AppModel/AppModel+Development.swift index f0a880f0..f8986a6d 100644 --- a/Sources/Lithe/Models/AppModel+Development.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -1,10 +1,20 @@ import Foundation +import LitheCoreContracts +import LitheExecutionModule +import LitheModuleAPI @MainActor extension AppModel { func toggleRun() { isRunVisible.toggle() guard isRunVisible else { return } + Task { [weak self] in + guard let self else { return } + guard await activateExecutionModule() != nil else { return } + if let workspaceURL { + await loadProjectServices(at: workspaceURL, files: projectFiles) + } + } isTestsVisible = false isGitLogVisible = false isTerminalVisible = false @@ -22,6 +32,11 @@ extension AppModel { } isMavenVisible.toggle() guard isMavenVisible else { return } + Task { [weak self] in + guard let self, await activateExecutionModule() != nil, + let workspaceURL else { return } + await loadProjectServices(at: workspaceURL, files: projectFiles) + } isTestsVisible = false isGitLogVisible = false isTerminalVisible = false @@ -32,7 +47,8 @@ extension AppModel { guard let workspaceURL else { return } Task { [weak self] in guard let self else { return } - if self.mavenFeature.project == nil { + let capability = await self.activateExecutionModule() + if capability?.mavenFeature.project == nil { await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) } } @@ -50,11 +66,14 @@ extension AppModel { isProblemsVisible = false isRunVisible = false isDebugVisible = false - mavenFeature.run(phase: phase, module: module, profiles: profiles) + Task { [weak self] in + guard let feature = await self?.activateExecutionModule()?.mavenFeature else { return } + feature.run(phase: phase, module: module, profiles: profiles) + } } func stopMaven() { - mavenFeature.stop() + mavenFeatureIfActive?.stop() } func openMavenIssue(_ issue: MavenBuildIssue) { @@ -102,7 +121,7 @@ extension AppModel { } func selectRunConfiguration(_ configuration: RunConfiguration) { - runFeature.select(configuration) + runFeatureIfActive?.select(configuration) } func openRunConfiguration(relativePath: String?) { @@ -113,11 +132,23 @@ extension AppModel { } func runSelectedConfiguration() { + Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() } + } + + private func runSelectedConfigurationAfterActivation() async { + guard let runFeature = await activateExecutionModule()?.runFeature else { return } guard runFeature.configurationStatus == .ready else { runFeature.requestRunConfigurationGeneration(intent: .run) return } guard let configuration = runFeature.selectedConfiguration else { return } + if !(await activateLanguageRunExtensionIfNeeded( + for: configuration, + currentFileURL: activeDocument?.url, + runFeature: runFeature + )) { + return + } if configuration.usesCurrentEditorFile, let activeDocument, activeDocument.isDirty { @@ -141,16 +172,124 @@ extension AppModel { func restartSelectedRun() { isRunVisible = true - runFeature.restart() + Task { [weak self] in + guard let self, + let runFeature = await activateExecutionModule()?.runFeature else { return } + guard let configuration = runFeature.lastConfiguration else { return } + if !(await activateLanguageRunExtensionIfNeeded( + for: configuration, + currentFileURL: runFeature.lastRunFileURL, + runFeature: runFeature + )) { + return + } + runFeature.restart() + } + } + + func startRunConfiguration(_ configuration: RunConfiguration) { + Task { [weak self] in + guard let self, + let runFeature = await activateExecutionModule()?.runFeature, + await activateLanguageRunExtensionIfNeeded( + for: configuration, + currentFileURL: activeDocument?.url, + runFeature: runFeature + ) else { return } + runFeature.startConfiguration(configuration) + } + } + + func runAllServiceConfigurations() { + Task { [weak self] in + guard let self, + let runFeature = await activateExecutionModule()?.runFeature else { return } + for configuration in runFeature.configurations where configuration.execution == .service { + guard await activateLanguageRunExtensionIfNeeded( + for: configuration, + currentFileURL: nil, + runFeature: runFeature + ) else { return } + } + runFeature.runAllServices() + } } func stopSelectedRun() { - runFeature.stop() + runFeatureIfActive?.stop() + } + + private func activateLanguageRunExtensionIfNeeded( + for fileURL: URL, + runFeature: RunFeatureModel + ) async -> Bool { + guard let ownership = services.pluginCatalog.languageSupport(for: fileURL) else { + return true + } + return await activateLanguageRunExtension( + ownership.declaration, + runFeature: runFeature + ) + } + + private func activateLanguageRunExtensionIfNeeded( + for configuration: RunConfiguration, + currentFileURL: URL?, + runFeature: RunFeatureModel + ) async -> Bool { + if configuration.usesCurrentEditorFile { + guard let currentFileURL else { return true } + return await activateLanguageRunExtensionIfNeeded( + for: currentFileURL, + runFeature: runFeature + ) + } + guard let ownership = services.pluginCatalog.languageSupports[ + configuration.kind.providerID + ] else { + return true + } + return await activateLanguageRunExtension( + ownership.declaration, + runFeature: runFeature + ) + } + + private func activateLanguageRunExtension( + _ support: LanguageSupportDeclaration, + runFeature: RunFeatureModel + ) async -> Bool { + guard support.executionModuleID != nil else { + showNotification("\(support.displayName) does not provide project execution") + return false + } + do { + let value = try await services.moduleRuntime.activateCapability( + .languageExecutionExtension(support.id) + ) + guard let provider = value as? any LanguageRunExtensionProviding, + runFeature.registerLanguageRunExtension(provider, support: support) else { + showNotification("\(support.displayName) returned an invalid execution provider") + return false + } + return true + } catch { + showNotification(error.localizedDescription) + return false + } } func toggleDebug() { isDebugVisible.toggle() guard isDebugVisible else { return } + Task { [weak self] in + guard let self else { return } + guard await activateExecutionModule() != nil else { return } + if let workspaceURL { + await loadProjectServices(at: workspaceURL, files: projectFiles) + } + _ = await activateDebugModule() + } isTestsVisible = false isGitLogVisible = false isTerminalVisible = false @@ -161,8 +300,21 @@ extension AppModel { } func startDebugging() { + Task { [weak self] in await self?.startDebuggingAfterActivation() } + } + + private func startDebuggingAfterActivation() async { + guard let execution = await activateExecutionModule(), + let debug = await activateDebugModule() else { return } + let runFeature = execution.runFeature + let debugFeature = debug.javaFeature + javaFeature.configureRuntime( + mavenFeature: execution.mavenFeature, + debugFeature: debugFeature + ) if let document = activeDocument, - languageToolingSessions.supportsGenericDebugging(for: document.url) { + languageProviderCatalog.provider(for: document.url)? + .capabilities.contains(.debugAdapter) == true { startGenericDebugging(document) return } @@ -211,6 +363,7 @@ extension AppModel { func toggleTests() { isTestsVisible.toggle() guard isTestsVisible else { return } + Task { [weak self] in _ = await self?.activateExecutionModule() } isGitLogVisible = false isTerminalVisible = false isReferencesVisible = false @@ -218,14 +371,29 @@ extension AppModel { isMavenVisible = false isRunVisible = false isDebugVisible = false - if let workspaceURL { - languageTestService.discover(workspaceURL: workspaceURL, files: projectFiles) + guard let workspaceURL else { return } + Task { [weak self] in + guard let self, + let execution = await activateExecutionModule(), + await activateLanguageTestExtensionsIfNeeded( + for: projectFiles, + testService: execution.tests + ) else { return } + execution.tests.discover(workspaceURL: workspaceURL, files: projectFiles) } } func refreshTests() { guard let workspaceURL else { return } - languageTestService.discover(workspaceURL: workspaceURL, files: projectFiles) + Task { [weak self] in + guard let self, + let execution = await activateExecutionModule(), + await activateLanguageTestExtensionsIfNeeded( + for: projectFiles, + testService: execution.tests + ) else { return } + execution.tests.discover(workspaceURL: workspaceURL, files: projectFiles) + } } func runTest(providerID: String, scope: LanguageTestScope) { @@ -238,23 +406,73 @@ extension AppModel { isMavenVisible = false isRunVisible = false isDebugVisible = false - _ = languageTestService.run( - providerID: providerID, - scope: scope, - workspaceURL: workspaceURL, - projectFiles: projectFiles + Task { [weak self] in + guard let self, let execution = await activateExecutionModule() else { return } + if let ownership = services.pluginCatalog.languageSupports[providerID], + !(await activateLanguageTestExtension( + ownership.declaration, + testService: execution.tests + )) { + return + } + _ = execution.tests.run( + providerID: providerID, + scope: scope, + workspaceURL: workspaceURL, + projectFiles: projectFiles + ) + } + } + + private func activateLanguageTestExtensionsIfNeeded( + for files: [URL], + testService: LanguageTestService + ) async -> Bool { + let supports = services.pluginCatalog.languageSupports( + recognizingProjectFileNames: files.map(\.lastPathComponent) ) + for ownership in supports where ownership.declaration.testingModuleID != nil { + guard await activateLanguageTestExtension( + ownership.declaration, + testService: testService + ) else { return false } + } + return true + } + + private func activateLanguageTestExtension( + _ support: LanguageSupportDeclaration, + testService: LanguageTestService + ) async -> Bool { + guard support.testingModuleID != nil else { + showNotification("\(support.displayName) does not provide test execution") + return false + } + do { + let value = try await services.moduleRuntime.activateCapability( + .languageTestingExtension(support.id) + ) + guard let provider = value as? any LanguageTestExtensionProviding, + testService.registerLanguageTestExtension(provider, support: support) else { + showNotification("\(support.displayName) returned an invalid test provider") + return false + } + return true + } catch { + showNotification(error.localizedDescription) + return false + } } func stopTests() { - languageTestService.stop() + languageTestServiceIfActive?.stop() } func stopDebugging() { - if genericDebugFeature.providerID != nil { - genericDebugFeature.stop() + if genericDebugFeatureIfActive?.providerID != nil { + genericDebugFeatureIfActive?.stop() } else { - debugFeature.stop() + debugFeatureIfActive?.stop() } } @@ -269,8 +487,12 @@ extension AppModel { } func toggleDebugBreakpoint(fileURL: URL, line: Int) { - if languageToolingSessions.supportsGenericDebugging(for: fileURL) { - genericDebugFeature.toggleBreakpoint(fileURL: fileURL, line: line) + if languageProviderCatalog.provider(for: fileURL)? + .capabilities.contains(.debugAdapter) == true { + Task { [weak self] in + guard let feature = await self?.activateDebugModule()?.genericFeature else { return } + feature.toggleBreakpoint(fileURL: fileURL, line: line) + } } else if javaFeature.supportsLegacyDebugging(fileURL: fileURL) { javaFeature.toggleDebugBreakpoint(at: fileURL, line: line, documents: openDocuments) } else { @@ -279,7 +501,7 @@ extension AppModel { } var prefersGenericDebugUI: Bool { - if genericDebugFeature.providerID != nil { return true } + if genericDebugFeatureIfActive?.providerID != nil { return true } guard let document = activeDocument else { return false } // Never show the Java/JDB panel for another language. A configured // Provider may still be unavailable locally; the generic panel can @@ -289,12 +511,18 @@ extension AppModel { return true } return descriptor.id != "java" - || languageToolingSessions.supportsGenericDebugging(for: document.url) + || descriptor.capabilities.contains(.debugAdapter) } private func startGenericDebugging(_ document: EditorDocument) { + Task { [weak self] in await self?.startGenericDebuggingAfterActivation(document) } + } + + private func startGenericDebuggingAfterActivation(_ document: EditorDocument) async { guard let workspaceURL, - let provider = languageProviderCatalog.provider(for: document.url) else { + let provider = languageProviderCatalog.provider(for: document.url), + let runFeature = await activateExecutionModule()?.runFeature, + let genericDebugFeature = await activateDebugModule()?.genericFeature else { showNotification("No language provider is available for this file") return } @@ -377,7 +605,7 @@ extension AppModel { line: max(0, line), utf16Column: max(0, utf16Column) ) - if languageToolingSessions.features(for: normalizedURL).contains(.definition) { + if (languageToolingSessionsIfActive?.features(for: normalizedURL).contains(.definition) == true) { performGenericNavigation( method: "textDocument/definition", kind: .definitions, @@ -424,7 +652,7 @@ extension AppModel { } isLoadingLanguageNavigation = true do { - try languageToolingSessions.resolveVirtualDocument( + try languageToolingSessionsIfActive?.resolveVirtualDocument( providerID: providerID, uri: location.url ) { [weak self] result in @@ -482,13 +710,13 @@ extension AppModel { completion: @escaping (LanguageServerHover?) -> Void ) { guard let document = activeDocument, - languageToolingSessions.features(for: document.url).contains(.hover), + (languageToolingSessionsIfActive?.features(for: document.url).contains(.hover) == true), let workspaceURL else { completion(nil) return } do { - try languageToolingSessions.hover( + try languageToolingSessionsIfActive?.hover( fileURL: document.url, text: document.text, position: LanguageServerPosition( @@ -516,13 +744,13 @@ extension AppModel { completion: @escaping ([LanguageServerCompletionItem]) -> Void ) { guard let document = activeDocument, - languageToolingSessions.features(for: document.url).contains(.completion), + (languageToolingSessionsIfActive?.features(for: document.url).contains(.completion) == true), let workspaceURL else { completion([]) return } do { - try languageToolingSessions.completions( + try languageToolingSessionsIfActive?.completions( fileURL: document.url, text: document.text, position: LanguageServerPosition( @@ -550,10 +778,10 @@ extension AppModel { newName: String ) { guard let document = activeDocument, - languageToolingSessions.features(for: document.url).contains(.rename), + (languageToolingSessionsIfActive?.features(for: document.url).contains(.rename) == true), let workspaceURL else { return } do { - try languageToolingSessions.rename( + try languageToolingSessionsIfActive?.rename( fileURL: document.url, text: document.text, position: LanguageServerPosition(line: max(0, line), utf16Column: max(0, utf16Column)), @@ -570,10 +798,10 @@ extension AppModel { func requestLanguageFormatting() { guard let document = activeDocument, - languageToolingSessions.features(for: document.url).contains(.formatting), + (languageToolingSessionsIfActive?.features(for: document.url).contains(.formatting) == true), let workspaceURL else { return } do { - try languageToolingSessions.format( + try languageToolingSessionsIfActive?.format( fileURL: document.url, text: document.text, rootURL: workspaceURL @@ -596,12 +824,12 @@ extension AppModel { completion: @escaping ([LanguageServerCodeAction]) -> Void ) { guard let document = activeDocument, - languageToolingSessions.features(for: document.url).contains(.codeActions), + (languageToolingSessionsIfActive?.features(for: document.url).contains(.codeActions) == true), let workspaceURL else { completion([]); return } let position = LanguageServerPosition(line: max(0, line), utf16Column: max(0, utf16Column)) let range = LanguageServerRange(start: position, end: position) do { - try languageToolingSessions.codeActions( + try languageToolingSessionsIfActive?.codeActions( fileURL: document.url, text: document.text, range: range, @@ -619,12 +847,12 @@ extension AppModel { func applyLanguageCodeAction(_ action: LanguageServerCodeAction) { guard let document = activeDocument, let workspaceURL else { return } guard action.data != nil, - languageToolingSessions.features(for: document.url).contains(.codeActionResolve) else { + (languageToolingSessionsIfActive?.features(for: document.url).contains(.codeActionResolve) == true) else { performLanguageCodeAction(action, documentURL: document.url, rootURL: workspaceURL) return } do { - try languageToolingSessions.resolveCodeAction( + try languageToolingSessionsIfActive?.resolveCodeAction( action, fileURL: document.url, text: document.text, @@ -653,7 +881,7 @@ extension AppModel { $0.url.standardizedFileURL == documentURL.standardizedFileURL }) else { return } do { - try languageToolingSessions.execute( + try languageToolingSessionsIfActive?.execute( command, fileURL: document.url, text: document.text, @@ -670,12 +898,12 @@ extension AppModel { ) { guard let document = activeDocument, let workspaceURL else { return } guard item.data != nil, - languageToolingSessions.features(for: document.url).contains(.completionResolve) else { + (languageToolingSessionsIfActive?.features(for: document.url).contains(.completionResolve) == true) else { performLanguageCompletion(item, fallbackRange: fallbackRange, documentURL: document.url) return } do { - try languageToolingSessions.resolveCompletion( + try languageToolingSessionsIfActive?.resolveCompletion( item, fileURL: document.url, text: document.text, @@ -777,7 +1005,7 @@ extension AppModel { func supportsLanguageServerFeature(_ feature: LanguageServerFeatureSet) -> Bool { guard let document = activeDocument else { return false } - return languageToolingSessions.features(for: document.url).contains(feature) + return (languageToolingSessionsIfActive?.features(for: document.url).contains(feature) == true) } private func performGenericNavigation( @@ -799,7 +1027,7 @@ extension AppModel { languageNavigationProviderID = provider.id languageNavigationResultKind = kind do { - try languageToolingSessions.navigate( + try languageToolingSessionsIfActive?.navigate( method: method, fileURL: document.url, text: document.text, @@ -820,7 +1048,7 @@ extension AppModel { kind == .definitions, values.count == 1, values[0].url.standardizedFileURL == document.url.standardizedFileURL, - self.languageToolingSessions.features(for: document.url).contains(.implementation) { + self.languageToolingSessionsIfActive?.features(for: document.url).contains(.implementation) == true { self.requestGenericImplementationFallback( document: document, caret: caret, @@ -853,7 +1081,7 @@ extension AppModel { ) { isLoadingLanguageNavigation = true do { - try languageToolingSessions.navigate( + try languageToolingSessionsIfActive?.navigate( method: "textDocument/implementation", fileURL: document.url, text: document.text, diff --git a/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift b/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift new file mode 100644 index 00000000..61cf7624 --- /dev/null +++ b/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift @@ -0,0 +1,42 @@ +import Foundation + +extension AppModel { + func refreshCodeVision(for fileURL: URL) async { + let normalizedURL = fileURL.standardizedFileURL + guard normalizedURL.pathExtension.lowercased() == "java", + let document = openDocuments.first(where: { $0.url.standardizedFileURL == normalizedURL }), + !document.isReadOnly, + let workspaceRoot = workspaceURL else { return } + await javaFeature.refreshCodeVision( + for: document, + projectFiles: projectFiles, + workspaceRoot: workspaceRoot + ) + } + + func refreshJavaInlayHints(for document: EditorDocument) { + javaFeature.refreshInlayHints( + for: document, + projectFiles: projectFiles, + workspaceRoot: workspaceURL + ) + } + + func showBlame(for fileURL: URL) { + let normalizedURL = fileURL.standardizedFileURL + blameVisibleURL = blameVisibleURL == normalizedURL ? nil : normalizedURL + } + + func hideBlame() { + blameVisibleURL = nil + } + + func findUsages(for hint: JavaCodeVisionHint, in fileURL: URL) { + editorCaret = EditorCaret( + url: fileURL.standardizedFileURL, + line: hint.line, + utf16Column: hint.utf16Column + ) + findReferences() + } +} diff --git a/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift b/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift new file mode 100644 index 00000000..9c95a36d --- /dev/null +++ b/Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift @@ -0,0 +1,80 @@ +import Combine +import Foundation +import LitheDebugModule +import LitheExecutionModule + +@MainActor +extension AppModel { + struct DebugFeatureAccess { + let javaFeature: JavaDebugFeatureModel + let genericFeature: GenericDebugFeatureModel + } + struct ExecutionFeatureAccess { + let mavenFeature: MavenFeatureModel + let runFeature: RunFeatureModel + let tests: LanguageTestService + let projectDevelopment: ProjectDevelopmentFeatureModel + } + + var mavenFeatureIfActive: MavenFeatureModel? { executionCapability?.mavenFeature } + var runFeatureIfActive: RunFeatureModel? { executionCapability?.runFeature } + var debugFeatureIfActive: JavaDebugFeatureModel? { + debugCapability?.javaFeature as? JavaDebugFeatureModel + } + var genericDebugFeatureIfActive: GenericDebugFeatureModel? { + debugCapability?.genericFeature as? GenericDebugFeatureModel + } + + func activateExecutionModule() async -> ExecutionFeatureAccess? { + if let mavenFeature = mavenFeatureIfActive, + let runFeature = runFeatureIfActive, + let tests = languageTestServiceIfActive, + let projectDevelopment = executionCapability?.projectDevelopment { + return ExecutionFeatureAccess(mavenFeature: mavenFeature, runFeature: runFeature, tests: tests, projectDevelopment: projectDevelopment) + } + do { + let value = try await services.moduleRuntime.activateCapability(.executionWorkspace) + guard let capability = value as? LitheExecutionModule.ExecutionModuleCapability else { return nil } + let mavenFeature = capability.mavenFeature + let runFeature = capability.runFeature + let tests = capability.testService + let projectDevelopment = capability.projectDevelopment + cacheModuleCapability(capability, id: .executionWorkspace, moduleID: .execution) + observeModuleFeature(.execution, observation: runFeature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + }) + observeModuleFeature(.execution, observation: tests.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + }) + return ExecutionFeatureAccess(mavenFeature: mavenFeature, runFeature: runFeature, tests: tests, projectDevelopment: projectDevelopment) + } catch { + showNotification(error.localizedDescription) + return nil + } + } + + func activateDebugModule() async -> DebugFeatureAccess? { + if let javaFeature = debugFeatureIfActive, + let genericFeature = genericDebugFeatureIfActive { + return DebugFeatureAccess(javaFeature: javaFeature, genericFeature: genericFeature) + } + do { + let value = try await services.moduleRuntime.activateCapability(.debugWorkspace) + guard let capability = value as? LitheDebugModule.DebugModuleCapability, + let javaFeature = capability.javaFeature as? JavaDebugFeatureModel, + let genericFeature = capability.genericFeature as? GenericDebugFeatureModel else { return nil } + cacheModuleCapability(capability, id: .debugWorkspace, moduleID: .debug) + self.javaFeature.configureRuntime( + mavenFeature: mavenFeatureIfActive, + debugFeature: javaFeature + ) + observeModuleFeature(.debug, observation: javaFeature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + }) + return DebugFeatureAccess(javaFeature: javaFeature, genericFeature: genericFeature) + } catch { + showNotification(error.localizedDescription) + return nil + } + } +} diff --git a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift new file mode 100644 index 00000000..cc52b45a --- /dev/null +++ b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -0,0 +1,195 @@ +import Foundation +import LitheGitModule +import LitheLocalHistoryModule +import LitheSearchModule + +extension AppModel { + var rootNode: FileNode? { workspaceFeature.rootNode } + var projectFiles: [URL] { workspaceFeature.projectFiles } + var javaEnvironmentReport: JavaEnvironmentReport? { + runtimeFeature.javaEnvironmentReport + } + + var shouldShowJavaEnvironmentBanner: Bool { + guard javaEnvironmentReport?.status.requiresAttention == true else { return false } + return projectFiles.contains { $0.pathExtension.lowercased() == "java" } + || hasMavenProject + || activeDocument?.url.pathExtension.lowercased() == "java" + } + + /// Maven is an optional build-system feature. Keeping this capability in + /// the generic workspace projection lets the UI hide the Java-only tool + /// window for Go, Python, Node, Rust, Gradle-only, and plain projects. + var hasMavenProject: Bool { + projectFiles.contains { $0.lastPathComponent.lowercased() == "pom.xml" } + } + + var openDocuments: [EditorDocument] { documentFeature.openDocuments } + var activeDocumentID: UUID? { + get { documentFeature.activeDocumentID } + set { + let previousDocumentID = documentFeature.activeDocumentID + documentFeature.activeDocumentID = newValue + guard previousDocumentID != newValue else { return } + activateCurrentDocumentLanguageServerIfAvailable() + } + } + + func moveOpenDocument(_ documentID: UUID, before targetDocumentID: UUID) { + documentFeature.moveDocument(documentID, before: targetDocumentID) + } + + func moveOpenDocument(_ documentID: UUID, after targetDocumentID: UUID) { + documentFeature.moveDocument(documentID, after: targetDocumentID) + } + var pendingCloseDocument: EditorDocument? { documentFeature.pendingCloseDocument } + var isPendingProjectClose: Bool { documentFeature.isPendingProjectClose } + + var gitChanges: [GitChange] { gitFeatureIfActive?.gitChanges ?? [] } + var gitStashes: [GitStash] { gitFeatureIfActive?.gitStashes ?? [] } + var gitShelves: [GitShelfEntry] { gitFeatureIfActive?.gitShelves ?? [] } + var gitSaveChangesPolicy: GitSaveChangesPolicy { settings.gitSaveChangesPolicy } + var isPerformingStashOperation: Bool { gitFeatureIfActive?.isPerformingStashOperation ?? false } + var isPerformingShelfOperation: Bool { gitFeatureIfActive?.isPerformingShelfOperation ?? false } + var gitOperationState: GitOperationState? { gitFeatureIfActive?.gitOperationState } + var isResolvingGitOperation: Bool { gitFeatureIfActive?.isResolvingGitOperation ?? false } + var gitRepositoryRoot: URL? { gitFeatureIfActive?.gitRepositoryRoot } + var currentBranch: String { gitFeatureIfActive?.currentBranch ?? "No Git" } + var selectedChange: GitChange? { + get { gitFeatureIfActive?.selectedChange } + set { gitFeatureIfActive?.selectedChange = newValue } + } + var diffRows: [DiffRow] { gitFeatureIfActive?.diffRows ?? [] } + var diffHunks: [DiffHunk] { gitFeatureIfActive?.diffHunks ?? [] } + var gitDiffWhitespaceMode: GitDiffWhitespaceMode { + get { gitFeatureIfActive?.gitDiffWhitespaceMode ?? .doNotIgnore } + set { gitFeatureIfActive?.gitDiffWhitespaceMode = newValue } + } + var isLoadingDiff: Bool { gitFeatureIfActive?.isLoadingDiff ?? false } + var isRefreshingGit: Bool { gitFeatureIfActive?.isRefreshingGit ?? false } + var pendingDiscardChange: GitChange? { + get { gitFeatureIfActive?.pendingDiscardChange } + set { gitFeatureIfActive?.pendingDiscardChange = newValue } + } + var pendingDiscardHunk: DiffHunkRequest? { + get { gitFeatureIfActive?.pendingDiscardHunk } + set { gitFeatureIfActive?.pendingDiscardHunk = newValue } + } + var pendingCheckoutConflict: GitCheckoutConflictRequest? { + get { gitFeatureIfActive?.pendingCheckoutConflict } + set { gitFeatureIfActive?.pendingCheckoutConflict = newValue } + } + + var pendingPullStrategy: GitPullStrategyRequest? { + get { gitFeatureIfActive?.pendingPullStrategy } + set { gitFeatureIfActive?.pendingPullStrategy = newValue } + } + + var pendingIntegrationConflict: GitIntegrationConflictRequest? { + get { gitFeatureIfActive?.pendingIntegrationConflict } + set { gitFeatureIfActive?.pendingIntegrationConflict = newValue } + } + var pendingConflictRollback: GitConflictRollbackRequest? { + get { gitFeatureIfActive?.pendingConflictRollback } + set { gitFeatureIfActive?.pendingConflictRollback = newValue } + } + var pendingStashRestoreConflict: GitStashRestoreConflictRequest? { + gitFeatureIfActive?.pendingStashRestoreConflict + } + var isStashRestoreConflictNoticeVisible: Bool { + gitFeatureIfActive?.isStashRestoreConflictNoticeVisible ?? false + } + var gitConflictFilterPaths: Set { + gitFeatureIfActive?.gitConflictFilterPaths ?? [] + } + var requestedStashReference: String? { + gitFeatureIfActive?.requestedStashReference + } + var isCommitting: Bool { gitFeatureIfActive?.isCommitting ?? false } + var gitBlameLines: [URL: [GitBlameLine]] { gitFeatureIfActive?.gitBlameLines ?? [:] } + var gitReferences: [GitReference] { gitFeatureIfActive?.gitReferences ?? [] } + var gitCommits: [GitCommit] { gitFeatureIfActive?.gitCommits ?? [] } + var selectedGitReference: GitReference? { + get { gitFeatureIfActive?.selectedGitReference } + set { gitFeatureIfActive?.selectedGitReference = newValue } + } + var selectedGitCommit: GitCommit? { + get { gitFeatureIfActive?.selectedGitCommit } + set { gitFeatureIfActive?.selectedGitCommit = newValue } + } + var selectedGitCommitFiles: [GitCommitFile] { gitFeatureIfActive?.selectedGitCommitFiles ?? [] } + var selectedGitCommitFile: GitCommitFile? { + get { gitFeatureIfActive?.selectedGitCommitFile } + set { gitFeatureIfActive?.selectedGitCommitFile = newValue } + } + var selectedGitCommitDiffContext: GitCommitDiffContext? { + get { gitFeatureIfActive?.selectedGitCommitDiffContext } + set { gitFeatureIfActive?.selectedGitCommitDiffContext = newValue } + } + var isLoadingGitHistory: Bool { gitFeatureIfActive?.isLoadingGitHistory ?? false } + var isLoadingMoreGitHistory: Bool { gitFeatureIfActive?.isLoadingMoreGitHistory ?? false } + var canLoadMoreGitHistory: Bool { gitFeatureIfActive?.canLoadMoreGitHistory ?? false } + var branchComparison: GitBranchComparison? { gitFeatureIfActive?.branchComparison } + var selectedBranchComparisonFile: GitBranchComparisonFile? { + get { gitFeatureIfActive?.selectedBranchComparisonFile } + set { gitFeatureIfActive?.selectedBranchComparisonFile = newValue } + } + var branchComparisonRows: [DiffRow] { gitFeatureIfActive?.branchComparisonRows ?? [] } + var isLoadingBranchComparison: Bool { gitFeatureIfActive?.isLoadingBranchComparison ?? false } + var isPerformingBranchOperation: Bool { gitFeatureIfActive?.isPerformingBranchOperation ?? false } + var isCloningRepository: Bool { gitFeatureIfActive?.isCloningRepository ?? false } + var languageNavigationResults: [LanguageNavigationLocation] { + languageNavigationLocations + } + var languageNavigationKind: LanguageNavigationResultKind { + languageNavigationResultKind + } + var isLoadingNavigation: Bool { + isLoadingLanguageNavigation + } + var isLoadingWorkspace: Bool { workspaceFeature.isLoadingWorkspace } + var isRefreshingWorkspace: Bool { workspaceFeature.isRefreshingWorkspace } + var workspaceLoadErrorMessage: String? { workspaceFeature.loadErrorMessage } + var searchResults: [FileSearchResult] { searchFeatureIfActive?.searchResults ?? [] } + var isSearching: Bool { searchFeatureIfActive?.isSearching ?? false } + var searchEverywhereResults: SearchEverywhereResults { + searchFeatureIfActive?.searchEverywhereResults ?? SearchEverywhereResults() + } + var searchEverywhereActionMatches: [LitheAction] { + LitheActionRegistry.actions(for: self).filter { $0.matches(searchEverywhereQuery) } + } + var isSearchingEverywhere: Bool { searchFeatureIfActive?.isSearchingEverywhere ?? false } + var projectReplacementFiles: [ProjectReplacementFile] { + searchFeatureIfActive?.projectReplacementFiles ?? [] + } + var isLoadingProjectReplacement: Bool { + searchFeatureIfActive?.isLoadingProjectReplacement ?? false + } + + var localHistoryRequest: LocalHistoryRequest? { + get { projectHistoryFeatureIfActive?.localHistoryRequest } + set { projectHistoryFeatureIfActive?.localHistoryRequest = newValue } + } + var localHistoryEntries: [LocalHistoryEntry] { projectHistoryFeatureIfActive?.localHistoryEntries ?? [] } + var selectedLocalHistoryEntry: LocalHistoryEntry? { + get { projectHistoryFeatureIfActive?.selectedLocalHistoryEntry } + set { projectHistoryFeatureIfActive?.selectedLocalHistoryEntry = newValue } + } + var localHistoryDiffRows: [DiffRow] { (projectHistoryFeatureIfActive?.localHistoryDiffRows ?? []).map(DiffRow.init) } + var isLoadingLocalHistory: Bool { projectHistoryFeatureIfActive?.isLoadingLocalHistory ?? false } + var projectLocalHistoryRequest: ProjectLocalHistoryRequest? { + get { projectHistoryFeatureIfActive?.projectLocalHistoryRequest } + set { projectHistoryFeatureIfActive?.projectLocalHistoryRequest = newValue } + } + var projectLocalHistoryEntries: [LocalHistoryEntry] { + projectHistoryFeatureIfActive?.projectLocalHistoryEntries ?? [] + } + var selectedProjectLocalHistoryEntry: LocalHistoryEntry? { + get { projectHistoryFeatureIfActive?.selectedProjectLocalHistoryEntry } + set { projectHistoryFeatureIfActive?.selectedProjectLocalHistoryEntry = newValue } + } + var projectLocalHistoryDiffRows: [DiffRow] { (projectHistoryFeatureIfActive?.projectLocalHistoryDiffRows ?? []).map(DiffRow.init) } + var isLoadingProjectLocalHistory: Bool { + projectHistoryFeatureIfActive?.isLoadingProjectLocalHistory ?? false + } +} diff --git a/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift b/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift new file mode 100644 index 00000000..891e21dc --- /dev/null +++ b/Sources/Lithe/Models/AppModel/AppModel+GitModule.swift @@ -0,0 +1,43 @@ +import Combine +import Foundation +import LitheGitModule + +@MainActor +extension AppModel { + var gitFeatureIfActive: GitFeatureModel? { + gitCapability?.feature + } + + func activateGitModule() async -> GitFeatureModel? { + if let feature = gitFeatureIfActive { return feature } + do { + let value = try await services.moduleRuntime.activateCapability(.gitWorkspace) + guard let capability = value as? LitheGitModule.GitModuleCapability else { return nil } + let feature = capability.feature + feature.configure( + workspaceURLProvider: { [weak self] in self?.workspaceURL }, + isGitLogVisibleProvider: { [weak self] in self?.isGitLogVisible ?? false }, + notify: { [weak self] message in self?.showNotification(message) }, + onStateRefreshed: { [weak self] in + guard let self, let document = self.activeDocument else { return } + await self.refreshCodeVision(for: document.url) + }, + saveChangesPolicy: { [weak self] in self?.settings.gitSaveChangesPolicy ?? .stash }, + onGitOperationBegan: { [weak self] in + self?.workspaceFeature.beginGitOperationFreeze() + }, + onGitOperationEnded: { [weak self] in + await self?.workspaceFeature.endGitOperationFreeze() + } + ) + cacheModuleCapability(capability, id: .gitWorkspace, moduleID: .git) + observeModuleFeature(.git, observation: feature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + }) + return feature + } catch { + showNotification(error.localizedDescription) + return nil + } + } +} diff --git a/Sources/Lithe/Models/AppModel+GitOperations.swift b/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift similarity index 52% rename from Sources/Lithe/Models/AppModel+GitOperations.swift rename to Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift index 5808ee62..aa74a9fa 100644 --- a/Sources/Lithe/Models/AppModel+GitOperations.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+GitOperations.swift @@ -1,75 +1,89 @@ import Foundation +import LitheGitModule extension AppModel { func stashWorkingTree(message: String, includeUntracked: Bool) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.stashWorkingTree(message: message, includeUntracked: includeUntracked) } func shelveWorkingTree(message: String) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.shelveWorkingTree(message: message) } func applyStash(_ stash: GitStash, pop: Bool = false) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.applyStash(stash, pop: pop) } func requestConflictRollback(path: String, resume: GitConflictResume) { - gitFeature.requestConflictRollback(path: path, resume: resume) + gitFeatureIfActive?.requestConflictRollback(path: path, resume: resume) } func confirmConflictRollback(_ request: GitConflictRollbackRequest) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.confirmConflictRollback(request) } func cancelConflictRollback() { - gitFeature.cancelConflictRollback() + gitFeatureIfActive?.cancelConflictRollback() } func showGitConflictDiff(path: String) { selectedSidebar = .changes - gitFeature.clearGitConflictFilter() - Task { await gitFeature.selectConflictPath(path) } + gitFeatureIfActive?.clearGitConflictFilter() + Task { [weak self] in + guard let gitFeature = await self?.activateGitModule() else { return } + await gitFeature.selectConflictPath(path) + } } func showGitConflictFiles(_ paths: [String]) { selectedSidebar = .changes - gitFeature.setGitConflictFilter(paths) + gitFeatureIfActive?.setGitConflictFilter(paths) if let first = paths.first { - Task { await gitFeature.selectConflictPath(first) } + Task { [weak self] in + guard let gitFeature = await self?.activateGitModule() else { return } + await gitFeature.selectConflictPath(first) + } } } func clearGitConflictFilter() { - gitFeature.clearGitConflictFilter() + gitFeatureIfActive?.clearGitConflictFilter() } func showStashRestoreConflictFiles() { selectedSidebar = .changes - gitFeature.showStashRestoreConflictFiles() + gitFeatureIfActive?.showStashRestoreConflictFiles() } func showStashRestoreConflictStash() { selectedSidebar = .changes - gitFeature.showStashRestoreConflictStash() + gitFeatureIfActive?.showStashRestoreConflictStash() } func dismissStashRestoreConflictNotice() { - gitFeature.dismissStashRestoreConflictNotice() + gitFeatureIfActive?.dismissStashRestoreConflictNotice() } func showStashRestoreConflictNotice() { - gitFeature.showStashRestoreConflictNotice() + gitFeatureIfActive?.showStashRestoreConflictNotice() } func dropStash(_ stash: GitStash) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.dropStash(stash) } func applyShelf(_ shelf: GitShelfEntry) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.applyShelf(shelf) } func dropShelf(_ shelf: GitShelfEntry) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.dropShelf(shelf) } } diff --git a/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift b/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift new file mode 100644 index 00000000..79e1b6a7 --- /dev/null +++ b/Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift @@ -0,0 +1,48 @@ +import Combine +import Foundation +import LitheLocalHistoryModule + +@MainActor +extension AppModel { + var projectHistoryFeatureIfActive: ProjectHistoryFeatureModel? { + historyCapability?.feature + } + + func activateHistoryModule() async -> ProjectHistoryFeatureModel? { + if let feature = projectHistoryFeatureIfActive { return feature } + do { + let value = try await services.moduleRuntime.activateCapability(.historyWorkspace) + guard let capability = value as? LitheLocalHistoryModule.HistoryModuleCapability else { return nil } + cacheModuleCapability(capability, id: .historyWorkspace, moduleID: .localHistory) + let feature = capability.feature + feature.configure( + workspaceURLProvider: { [weak self] in self?.workspaceURL }, + projectFilesProvider: { [weak self] in self?.projectFiles ?? [] }, + documentsProvider: { [weak self] in + self?.openDocuments.map { + LocalHistoryDocumentSnapshot(id: $0.id, url: $0.url, text: $0.text) + } ?? [] + } + ) + if let workspaceURL { + feature.openWorkspace( + at: workspaceURL, + visibilityRules: settings.fileVisibilityRules.localHistoryRules + ) + } + observeModuleFeature(.localHistory, observation: feature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + }) + return feature + } catch { + return nil + } + } + + func withHistoryModule(_ action: @escaping @MainActor (ProjectHistoryFeatureModel) async -> Void) { + Task { @MainActor [weak self] in + guard let self, let feature = await self.activateHistoryModule() else { return } + await action(feature) + } + } +} diff --git a/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift b/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift new file mode 100644 index 00000000..e97fda53 --- /dev/null +++ b/Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift @@ -0,0 +1,56 @@ +import Foundation +import LitheModuleAPI + +extension AppModel { + var pluginSnapshots: [PluginManagementSnapshot] { + services.pluginManager.snapshots + } + + var pluginManagementIssues: [PluginManagementIssue] { + services.pluginManager.issues + } + + func setPluginEnabled(_ enabled: Bool, pluginID: PluginID) { + Task { @MainActor in + do { + try await services.pluginManager.setEnabled(enabled, for: pluginID) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + } + } + } + + func installPluginPackage() { + guard let packageURL = platformUI.chooseDirectory( + title: "Install Plugin Package", + prompt: "Install" + ) else { return } + do { + try services.pluginManager.installPackage(at: packageURL) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + } + } + + func rollbackPlugin(_ pluginID: PluginID) { + do { + try services.pluginManager.rollback(pluginID) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + } + } + + func uninstallPlugin(_ pluginID: PluginID) { + Task { @MainActor in + do { + try await services.pluginManager.uninstall(pluginID) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + } + } + } +} diff --git a/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift b/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift new file mode 100644 index 00000000..4465f82f --- /dev/null +++ b/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift @@ -0,0 +1,148 @@ +import Combine +import Foundation +import LitheSearchModule + +@MainActor +extension AppModel { + var searchFeatureIfActive: SearchFeatureModel? { searchCapability?.feature } + + func activateSearchModule() async -> SearchFeatureModel? { + if let feature = searchFeatureIfActive { return feature } + do { + let value = try await services.moduleRuntime.activateCapability(.searchWorkspace) + guard let capability = value as? LitheSearchModule.SearchModuleCapability else { return nil } + cacheModuleCapability(capability, id: .searchWorkspace, moduleID: .search) + observeModuleFeature(.search, observation: capability.feature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + }) + if let workspaceURL { + capability.feature.warmIndex( + at: workspaceURL, + visibilityRules: settings.fileVisibilityRules.searchRules + ) + } + return capability.feature + } catch { + showNotification(error.localizedDescription) + return nil + } + } + + func searchProject(options: ProjectSearchOptions = .default) async { + guard let workspaceURL, let searchFeature = await activateSearchModule() else { return } + let query = searchQuery + await searchFeature.searchProject( + at: workspaceURL, query: query, options: options, + visibilityRules: settings.fileVisibilityRules.searchRules, + isCurrent: { [weak self] in self?.workspaceURL == workspaceURL && self?.searchQuery == query } + ) + try? services.moduleRuntime.markIdle(.search) + } + + func toggleSearchEverywhere() { + guard workspaceURL != nil, !isSearchEverywhereVisible else { return } + isSearchEverywhereVisible = true + } + + func dismissSearchEverywhere() { + isSearchEverywhereVisible = false + searchEverywhereQuery = "" + searchFeatureIfActive?.clearSearchEverywhere() + } + + func searchEverywhere(options: ProjectSearchOptions = .default) async { + guard let searchFeature = await activateSearchModule() else { return } + guard let workspaceURL else { searchFeature.clearSearchEverywhere(); return } + let query = searchEverywhereQuery + await searchFeature.searchEverywhere( + at: workspaceURL, query: query, options: options, + visibilityRules: settings.fileVisibilityRules.searchRules, + isCurrent: { [weak self] in self?.workspaceURL == workspaceURL && self?.searchEverywhereQuery == query } + ) + try? services.moduleRuntime.markIdle(.search) + } + + func openProjectSearch() { + guard workspaceURL != nil else { return } + if !editorSelectedText.isEmpty { searchQuery = editorSelectedText } + selectedSidebar = .search + searchSidebarFocusRequest += 1 + } + + func clearProjectReplacementPreview() { + searchFeatureIfActive?.clearProjectReplacementPreview() + selectedProjectReplacementPaths = [] + } + + func openProjectReplace(inheriting options: ProjectSearchOptions? = nil) { + guard workspaceURL != nil else { return } + if !editorSelectedText.isEmpty { searchQuery = editorSelectedText } + projectReplaceQuery = searchQuery + projectReplaceText = "" + if let options { projectReplaceOptions = options } + clearProjectReplacementPreview() + isProjectReplaceVisible = true + } + + func previewProjectReplacement() async { + guard let rootURL = workspaceURL, let searchFeature = await activateSearchModule() else { return } + let query = projectReplaceQuery + let overrides = openDocumentTextOverrides(rootURL: rootURL) + await searchFeature.previewProjectReplacement( + at: rootURL, query: query, replacement: projectReplaceText, + paths: projectFiles.compactMap { workspaceRelativePath(for: $0, root: rootURL) }, + textOverrides: overrides, options: projectReplaceOptions, + visibilityRules: settings.fileVisibilityRules.searchRules, + isCurrent: { [weak self] in self?.workspaceURL == rootURL && self?.projectReplaceQuery == query } + ) + try? services.moduleRuntime.markIdle(.search) + guard projectReplaceQuery == query else { return } + selectedProjectReplacementPaths = Set(projectReplacementFiles.map(\.relativePath)) + } + + func applyProjectReplacement() async { + guard let rootURL = workspaceURL, + !projectReplaceQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let searchFeature = await activateSearchModule() else { return } + let result = await searchFeature.applyProjectReplacement( + at: rootURL, selectedPaths: selectedProjectReplacementPaths, + textOverrides: openDocumentTextOverrides(rootURL: rootURL), + recordHistory: { [weak self] text, fileURL in + guard let feature = await self?.activateHistoryModule() else { return } + await feature.recordHistorySnapshot(text: text, for: fileURL, reason: .beforeBatchReplace) + }, + saveTextOverride: { [weak self] url, text in + guard let self, + let document = self.openDocuments.first(where: { $0.url.standardizedFileURL == url.standardizedFileURL }) else { return false } + let previousText = document.text + document.text = text + do { try self.saveDocument(document); return true } + catch { document.text = previousText; throw error } + } + ) + try? services.moduleRuntime.markIdle(.search) + isProjectReplaceVisible = false + searchFeature.clearProjectReplacementPreview() + selectedProjectReplacementPaths = [] + await refreshWorkspace() + if !result.failedFiles.isEmpty { showNotification("Could not replace in \(result.failedFiles.count) file(s)") } + else if result.changedFiles > 0 { showNotification("Replaced text in \(result.changedFiles) file(s)") } + } + + func openSearchEverywhereResult(_ result: FileSearchResult) { dismissSearchEverywhere(); openSearchResult(result) } + func performSearchEverywhereAction(_ action: LitheAction) { dismissSearchEverywhere(); action.perform() } + + func openSearchResult(_ result: FileSearchResult) { + openFile(result.url) + if let line = result.line { + editorNavigationTarget = EditorNavigationTarget(url: result.url, line: line - 1, utf16Column: 0) + } + } + + private func openDocumentTextOverrides(rootURL: URL) -> [String: String] { + Dictionary(uniqueKeysWithValues: openDocuments.compactMap { document in + guard let path = workspaceRelativePath(for: document.url, root: rootURL) else { return nil } + return (path, document.text) + }) + } +} diff --git a/Sources/Lithe/Models/AppModel+Terminal.swift b/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift similarity index 65% rename from Sources/Lithe/Models/AppModel+Terminal.swift rename to Sources/Lithe/Models/AppModel/AppModel+Terminal.swift index 8bce8f4e..fdbb514a 100644 --- a/Sources/Lithe/Models/AppModel+Terminal.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift @@ -1,4 +1,5 @@ import Foundation +import LitheTerminalModule extension AppModel { func toggleTerminal() { @@ -11,18 +12,30 @@ extension AppModel { isMavenVisible = false isRunVisible = false isDebugVisible = false - if activeTerminalSession == nil { createTerminalSession() } + if terminalCapability == nil || activeTerminalSession == nil { + Task { @MainActor [weak self] in + guard let self, await self.activateTerminalModule() else { return } + _ = self.createTerminalSession() + } + } } - var terminalSessions: [TerminalSession] { terminalFeature.terminalSessions } - var activeTerminalSessionID: UUID? { terminalFeature.activeTerminalSessionID } - var activeTerminalSession: TerminalSession? { terminalFeature.activeTerminalSession } - func terminalTitle(for session: TerminalSession) -> String { terminalFeature.terminalTitle(for: session) } + var terminalSessions: [TerminalSession] { terminalFeature?.terminalSessions ?? [] } + var activeTerminalSessionID: UUID? { terminalFeature?.activeTerminalSessionID } + var activeTerminalSession: TerminalSession? { terminalFeature?.activeTerminalSession } + func terminalTitle(for session: TerminalSession) -> String { terminalFeature?.terminalTitle(for: session) ?? "Local" } @discardableResult func createTerminalSession(shellPath: String? = nil) -> TerminalSession? { guard let workspaceURL else { return nil } - let session = terminalFeature.createSession(in: workspaceURL, shellPath: shellPath ?? settings.terminalShellPath) + guard let feature = terminalFeature else { + Task { @MainActor [weak self] in + guard let self, await self.activateTerminalModule() else { return } + _ = self.createTerminalSession(shellPath: shellPath) + } + return nil + } + let session = feature.createSession(in: workspaceURL, shellPath: shellPath ?? settings.terminalShellPath) configureTerminalSession(session) isTerminalVisible = true isTestsVisible = false @@ -68,21 +81,24 @@ extension AppModel { } func selectTerminalSession(_ session: TerminalSession) { - guard terminalFeature.selectSession(session) else { return } + guard terminalFeature?.selectSession(session) == true else { return } isTerminalVisible = true } func closeTerminalSession(_ session: TerminalSession) { guard terminalSessions.contains(where: { $0.id == session.id }) else { return } - terminalFeature.closeSession(session) - if terminalSessions.isEmpty { isTerminalVisible = false } + terminalFeature?.closeSession(session) + if terminalSessions.isEmpty { + isTerminalVisible = false + try? services.moduleRuntime.markIdle(.terminal) + } } - func restartActiveTerminal() { terminalFeature.restartActiveSession() } - func restartActiveTerminal(using shellPath: String) { terminalFeature.restartActiveSession(using: shellPath) } - func stopTerminalSessions() { terminalFeature.stopAllSessions() } + func restartActiveTerminal() { terminalFeature?.restartActiveSession() } + func restartActiveTerminal(using shellPath: String) { terminalFeature?.restartActiveSession(using: shellPath) } + func stopTerminalSessions() { terminalFeature?.stopAllSessions() } var activeTerminalShellPath: String { - settings.terminalShellPath ?? terminalFeature.availableShells.first ?? "/bin/zsh" + settings.terminalShellPath ?? terminalFeature?.availableShells.first ?? "/bin/zsh" } } diff --git a/Sources/Lithe/Models/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift similarity index 67% rename from Sources/Lithe/Models/AppModel.swift rename to Sources/Lithe/Models/AppModel/AppModel.swift index 0e7bb258..3e3aed8a 100644 --- a/Sources/Lithe/Models/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1,5 +1,16 @@ import Combine import Foundation +import LitheGitModule +import LitheDatabaseModule +import LitheDebugModule +import LitheExecutionModule +import LitheLocalHistoryModule +import LitheLanguageIntelligenceModule +import LitheModuleAPI +import LitheSearchModule +import LitheTerminalModule +import LitheWorkspaceModule +import LitheCoreContracts enum SettingsCategory: String, CaseIterable, Identifiable { case general = "General" @@ -7,6 +18,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case terminal = "Terminal" case lsp = "LSP" case ai = "AI & Commit" + case plugins = "Plugins" case updates = "Updates" var id: String { rawValue } @@ -18,6 +30,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case .terminal: "terminal" case .lsp: "server.rack" case .ai: "wand.and.stars" + case .plugins: "puzzlepiece.extension" case .updates: "arrow.down.circle" } } @@ -27,7 +40,12 @@ enum SettingsCategory: String, CaseIterable, Identifiable { final class AppModel: ObservableObject, Identifiable { let id = UUID() @Published private(set) var workspaceURL: URL? - @Published var selectedSidebar: SidebarDestination = .project + @Published var selectedSidebar: SidebarDestination = .project { + didSet { + guard selectedSidebar == .changes, oldValue != .changes else { return } + Task { [weak self] in await self?.refreshGit() } + } + } @Published var isRunVisible = false @Published var isTestsVisible = false @Published var isSettingsPresented = false @@ -46,7 +64,7 @@ final class AppModel: ObservableObject, Identifiable { /// 编辑器当前选中的单行文本,供 Find/Replace in Files 预填查询词。 @Published var editorSelectedText = "" /// 递增令牌:搜索侧栏观察它来把焦点移回输入框。 - @Published private(set) var searchSidebarFocusRequest = 0 + @Published var searchSidebarFocusRequest = 0 @Published var isFindBarVisible = false @Published var findBarQuery = "" @Published private(set) var findMatchCount = 0 @@ -102,37 +120,110 @@ final class AppModel: ObservableObject, Identifiable { let settings: AppSettings let runtimeFeature: RuntimeSettingsFeatureModel let languageToolingFeature: LanguageToolingFeatureModel - let mavenFeature: MavenFeatureModel - let runFeature: RunFeatureModel - let projectDevelopmentFeature: ProjectDevelopmentFeatureModel - let debugFeature: JavaDebugFeatureModel - let genericDebugFeature: GenericDebugFeatureModel let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver let workspaceFeature: WorkspaceFeatureModel - let searchFeature: SearchFeatureModel - let terminalFeature: TerminalFeatureModel - let projectHistoryFeature: ProjectHistoryFeatureModel - let gitFeature: GitFeatureModel + private struct CachedModuleCapability { + let moduleID: ModuleID + let value: AnyObject + } + private var moduleCapabilities: [ModuleCapabilityID: CachedModuleCapability] = [:] + private var moduleFeatureObservations: [ModuleID: [AnyCancellable]] = [:] + var languageCapability: LitheLanguageIntelligenceModule.LanguageIntelligenceCapability? { + cachedModuleCapability(.languageIntelligence) + } + var executionCapability: LitheExecutionModule.ExecutionModuleCapability? { + cachedModuleCapability(.executionWorkspace) + } + var debugCapability: LitheDebugModule.DebugModuleCapability? { + cachedModuleCapability(.debugWorkspace) + } + var searchCapability: LitheSearchModule.SearchModuleCapability? { + cachedModuleCapability(.searchWorkspace) + } + var terminalCapability: LitheTerminalModule.TerminalModuleCapability? { + cachedModuleCapability(.terminalWorkspace) + } + var terminalFeature: TerminalFeatureModel? { terminalCapability?.feature } + var availableTerminalShells: [String] { terminalFeature?.availableShells ?? [] } + + @MainActor + func activateTerminalModule() async -> Bool { + guard terminalCapability == nil else { return true } + do { + let value = try await services.moduleRuntime.activateCapability(.terminalWorkspace) + guard let capability = value as? LitheTerminalModule.TerminalModuleCapability else { return false } + let feature = capability.feature + cacheModuleCapability(capability, id: .terminalWorkspace, moduleID: .terminal) + observeModuleFeature(.terminal, observation: feature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + }) + return true + } catch { + return false + } + } + var historyCapability: LitheLocalHistoryModule.HistoryModuleCapability? { + cachedModuleCapability(.historyWorkspace) + } + var gitCapability: LitheGitModule.GitModuleCapability? { + cachedModuleCapability(.gitWorkspace) + } let documentFeature: DocumentFeatureModel let javaFeature: JavaFeatureModel - let databaseFeature: DatabaseFeatureModel + private var activeDatabaseFeature: DatabaseFeatureModel? { + let capability: LitheDatabaseModule.DatabaseModuleCapability? = cachedModuleCapability(.databaseWorkspace) + return capability?.feature + } + var databaseFeature: DatabaseFeatureModel { + guard let activeDatabaseFeature else { + preconditionFailure("Database UI accessed before the Database module was activated.") + } + return activeDatabaseFeature + } + var isDatabaseModuleActive: Bool { activeDatabaseFeature != nil } + var moduleSnapshots: [ModuleSnapshot] { services.moduleRuntime.snapshots() } + var availableSidebarDestinations: [SidebarDestination] { + SidebarDestination.allCases.filter { destination in + let moduleID: ModuleID? + switch destination { + case .project: moduleID = nil + case .changes: moduleID = .git + case .search: moduleID = .search + case .database: moduleID = .database + } + guard let moduleID else { return true } + return moduleSnapshots.first(where: { $0.manifest.id == moduleID })?.state != .disabled + } + } + var activeModuleContributions: [ModuleContribution] { + services.moduleRuntime.availableContributions().values.flatMap { $0 }.sorted { + ($0.placement.rawValue, $0.order, $0.id) + < ($1.placement.rawValue, $1.order, $1.id) + } + } + var activityBarContributions: [ModuleContribution] { + activeModuleContributions.filter { $0.placement == .activityBar } + } var workspaceFileOperations: any WorkspaceFileOperations { services.fileOperations } func fileExists(at url: URL) -> Bool { services.fileStorage.fileExists(at: url) } - var languageToolingSessions: LanguageToolingSessionManager { services.languageToolingSessions } - var languageServerTools: LanguageServerToolService { services.languageServerTools } - var languageTestService: LanguageTestService { services.languageTestService } + var languageToolingSessionsIfActive: LanguageToolingSessionManager? { + languageCapability?.sessions + } + var languageServerToolsIfActive: LanguageServerToolService? { + languageCapability?.tools + } + var languageTestServiceIfActive: LanguageTestService? { + executionCapability?.testService as? LanguageTestService + } var languageDiagnostics: [URL: [LanguageServerDiagnostic]] { - languageToolingSessions.diagnostics + languageToolingSessionsIfActive?.diagnostics ?? [:] } var editorDiagnostics: [URL: [EditorDiagnostic]] { EditorDiagnostic.fromLanguageServerDiagnostics(languageDiagnostics) } private var workspaceFeatureObservation: AnyCancellable? private var runtimeFeatureObservation: AnyCancellable? - private var searchFeatureObservation: AnyCancellable? - private var terminalFeatureObservation: AnyCancellable? - private var projectHistoryFeatureObservation: AnyCancellable? - private var databaseFeatureObservation: AnyCancellable? + private var moduleRuntimeObservationID: UUID? var detectedCodexConfiguration: CodexConfigurationSnapshot? { detectedAIConfigurations.first { $0.source == .codex } @@ -214,16 +305,55 @@ final class AppModel: ObservableObject, Identifiable { languageToolingFeature.setEnabled(false, providerID: providerID) } - private var gitFeatureObservation: AnyCancellable? private var documentFeatureObservation: AnyCancellable? private var javaFeatureObservation: AnyCancellable? private var isObjectWillChangeRelayScheduled = false private var languageToolingObservation: AnyCancellable? - private var languageTestObservation: AnyCancellable? private var recentProjectsStore: RecentProjectsStore { services.recentProjectsStore } private var workbenchLayoutStore: WorkbenchLayoutStore { services.workbenchLayoutStore } - private func scheduleObjectWillChangeRelay() { + func cachedModuleCapability( + _ id: ModuleCapabilityID, + as type: Capability.Type = Capability.self + ) -> Capability? { + moduleCapabilities[id]?.value as? Capability + } + + func cacheModuleCapability( + _ capability: AnyObject, + id: ModuleCapabilityID, + moduleID: ModuleID + ) { + moduleCapabilities[id] = CachedModuleCapability(moduleID: moduleID, value: capability) + } + + func clearModuleBindings(for moduleID: ModuleID) { + moduleFeatureObservations[moduleID] = nil + moduleCapabilities = moduleCapabilities.filter { $0.value.moduleID != moduleID } + for ownership in services.pluginCatalog.languageSupports.values { + let support = ownership.declaration + if support.languageServerModuleID == moduleID { + languageToolingSessionsIfActive?.unregisterLanguageServerExtension( + languageID: support.id + ) + } + if support.executionModuleID == moduleID { + runFeatureIfActive?.unregisterLanguageRunExtension(languageID: support.id) + } + if support.testingModuleID == moduleID { + languageTestServiceIfActive?.unregisterLanguageTestExtension(languageID: support.id) + } + } + } + + func observeModuleFeature( + _ moduleID: ModuleID, + observation: AnyCancellable + ) { + moduleFeatureObservations[moduleID, default: []].append(observation) + } + + func scheduleObjectWillChangeRelay() { guard !isObjectWillChangeRelayScheduled else { return } isObjectWillChangeRelayScheduled = true Task { @MainActor [weak self] in @@ -240,44 +370,25 @@ final class AppModel: ObservableObject, Identifiable { workspaceFeature = WorkspaceFeatureModel( operations: services.workspaceOperations, fileOperations: services.fileOperations, - fileStorage: services.fileStorage, - gitWatchContextProvider: services.gitService, + gitWatchContextProvider: services.gitWatchContextProvider, directoryWatcherFactory: services.directoryWatcherFactory, workspaceSessionStore: services.workspaceSessionStore ) - searchFeature = SearchFeatureModel(operations: services.workspaceOperations) + Task { @MainActor [workspaceFeature, moduleRuntime = services.moduleRuntime] in + guard let capability = try? await moduleRuntime.activateCapability(.workspaceFoundation), + let capability = capability as? LitheWorkspaceModule.WorkspaceFoundationCapability else { return } + capability.attach(workspaceProjection: workspaceFeature) + } runtimeFeature = RuntimeSettingsFeatureModel(service: services.projectRuntimeService) languageToolingFeature = LanguageToolingFeatureModel( catalogSource: services.languageProviderCatalogSource, catalogSnapshot: services.languageProviderCatalogSnapshot, - sessions: services.languageToolingSessions, + sessionsProvider: { nil }, runtimeFeature: runtimeFeature, settings: settings, projectRuntimeService: services.projectRuntimeService ) - mavenFeature = MavenFeatureModel(service: services.mavenService) - runFeature = RunFeatureModel(service: services.runService) - projectDevelopmentFeature = ProjectDevelopmentFeatureModel( - mavenFeature: mavenFeature, - runFeature: runFeature - ) - debugFeature = JavaDebugFeatureModel(service: services.javaDebugService) - genericDebugFeature = GenericDebugFeatureModel(sessions: services.languageToolingSessions) debugLaunchConfigurationResolver = services.debugLaunchConfigurationResolver - terminalFeature = TerminalFeatureModel( - terminalFactory: services.terminalFactory, - shellDiscovery: services.shellDiscovery - ) - projectHistoryFeature = ProjectHistoryFeatureModel( - workspaceOperations: services.workspaceOperations, - fileOperations: services.fileOperations, - fileStorage: services.fileStorage, - localHistoryOperations: services.localHistoryOperations - ) - gitFeature = GitFeatureModel( - service: services.gitService, - shelveService: services.shelveService - ) documentFeature = DocumentFeatureModel( operations: services.workspaceOperations, fileOperations: services.fileOperations, @@ -288,19 +399,20 @@ final class AppModel: ObservableObject, Identifiable { operations: services.javaMavenOperations, workspaceOperations: services.workspaceOperations ) - databaseFeature = DatabaseFeatureModel( - operations: services.databaseOperations, - connectionStore: DatabaseConnectionStore(store: services.store, secureStore: services.databaseSecureStore), - recoveryStore: services.databaseRecoveryStore, - fileStorage: services.fileStorage - ) - javaFeature.configureRuntime( - mavenFeature: mavenFeature, - debugFeature: debugFeature - ) recentProjects = services.recentProjectsStore.load() - databaseFeatureObservation = databaseFeature.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() + languageToolingFeature.configureSessions { [weak self] in + self?.languageToolingSessionsIfActive + } + moduleRuntimeObservationID = services.moduleRuntime.observeEvents { [weak self] event in + guard let self else { return } + if event.name == "module.sleeping" || event.name == "module.shutdown" { + clearModuleBindings(for: event.source) + } + if event.name == ModuleEvent.stateChangedName + || event.name == "module.sleeping" + || event.name == "module.shutdown" { + scheduleObjectWillChangeRelay() + } } workspaceFeatureObservation = workspaceFeature.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChangeRelay() @@ -308,26 +420,13 @@ final class AppModel: ObservableObject, Identifiable { runtimeFeatureObservation = runtimeFeature.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChangeRelay() } - searchFeatureObservation = searchFeature.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() - } - terminalFeatureObservation = terminalFeature.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() - } - languageToolingObservation = services.languageToolingSessions.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() - } - languageTestObservation = services.languageTestService.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() - } - projectHistoryFeature.configure( - workspaceURLProvider: { [weak self] in self?.workspaceURL }, - projectFilesProvider: { [weak self] in self?.projectFiles ?? [] }, - documentsProvider: { [weak self] in self?.openDocuments ?? [] } - ) - workspaceFeature.configure( - documentsProvider: { [weak self] in self?.openDocuments ?? [] }, - activeDocumentProvider: { [weak self] in self?.activeDocument }, + workspaceFeature.configureProjection( + documentsProvider: { [weak self] in + self?.openDocuments.map { WorkspaceDocumentState(url: $0.url, isDirty: $0.isDirty) } ?? [] + }, + activeDocumentProvider: { [weak self] in + self?.activeDocument.map { WorkspaceDocumentState(url: $0.url, isDirty: $0.isDirty) } + }, selectedSidebarProvider: { [weak self] in self?.selectedSidebar.rawValue ?? SidebarDestination.project.rawValue }, setSelectedSidebar: { [weak self] rawValue in self?.selectedSidebar = SidebarDestination(rawValue: rawValue) ?? .project @@ -362,10 +461,12 @@ final class AppModel: ObservableObject, Identifiable { openFile: { [weak self] url in self?.openFile(url) }, notify: { [weak self] message in self?.showNotification(message) }, recordHistory: { [weak self] url, reason in - await self?.projectHistoryFeature.recordHistory(containedIn: url, reason: reason) + guard let feature = await self?.activateHistoryModule() else { return } + await feature.recordHistory(containedIn: url, reason: reason) }, relocateHistory: { [weak self] source, destination in - await self?.projectHistoryFeature.relocateHistory(from: source, to: destination) + guard let feature = await self?.activateHistoryModule() else { return } + await feature.relocateHistory(from: source, to: destination) }, relocateOpenDocuments: { [weak self] source, destination in self?.documentFeature.relocateOpenDocuments(from: source, to: destination) @@ -376,24 +477,41 @@ final class AppModel: ObservableObject, Identifiable { processExternalChanges: { [weak self] paths in guard let self else { return false } let conflict = self.documentFeature.processExternalChanges(paths) - self.projectHistoryFeature.recordExternalChanges(paths) + self.withHistoryModule { $0.recordExternalChanges(paths) } return conflict }, reloadProjectServices: { [weak self] in guard let self, let workspaceURL = self.workspaceURL else { return } await self.loadProjectServices(at: workspaceURL, files: self.projectFiles) }, - refreshGit: { [weak self] in await self?.refreshGit() }, + refreshGit: { [weak self] in + guard let feature = self?.gitFeatureIfActive else { return } + await feature.refreshGit() + }, updateHistoryVisibilityRules: { [weak self] rules in - await self?.projectHistoryFeature.updateVisibilityRules(rules) + guard let feature = await self?.activateHistoryModule() else { return } + await feature.updateVisibilityRules(rules.localHistoryRules) }, onSnapshotLoaded: { [weak self] snapshot, isInitialLoad in guard let self, let workspaceURL = self.workspaceURL else { return } // WorkspaceFeatureModel requests the single Git refresh after this callback. await self.loadProjectServices(at: workspaceURL, files: snapshot.files) if isInitialLoad { - self.projectHistoryFeature.seed(files: snapshot.files) + self.projectHistoryFeatureIfActive?.seed(files: snapshot.files) } + }, + warmSearchIndex: { [weak self] workspaceURL, rules in + self?.searchFeatureIfActive?.warmIndex(at: workspaceURL, visibilityRules: rules.searchRules) + }, + updateSearchIndex: { [weak self] workspaceURL, paths, rules in + await self?.searchFeatureIfActive?.updateIndex( + at: workspaceURL, + changedPaths: paths, + visibilityRules: rules.searchRules + ) + }, + invalidateSearchIndex: { [weak self] workspaceURL, rules in + self?.searchFeatureIfActive?.invalidateIndex(at: workspaceURL, visibilityRules: rules.searchRules) } ) languageToolingFeature.configure( @@ -404,28 +522,6 @@ final class AppModel: ObservableObject, Identifiable { }, notify: { [weak self] message in self?.showNotification(message) } ) - projectHistoryFeatureObservation = projectHistoryFeature.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() - } - gitFeature.configure( - workspaceURLProvider: { [weak self] in self?.workspaceURL }, - isGitLogVisibleProvider: { [weak self] in self?.isGitLogVisible ?? false }, - notify: { [weak self] message in self?.showNotification(message) }, - onStateRefreshed: { [weak self] in - guard let self, let document = self.activeDocument else { return } - await self.refreshCodeVision(for: document.url) - }, - saveChangesPolicy: { [weak self] in self?.settings.gitSaveChangesPolicy ?? .stash }, - onGitOperationBegan: { [weak self] in - self?.workspaceFeature.beginGitOperationFreeze() - }, - onGitOperationEnded: { [weak self] in - await self?.workspaceFeature.endGitOperationFreeze() - } - ) - gitFeatureObservation = gitFeature.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() - } documentFeature.configure( workspaceURLProvider: { [weak self] in self?.workspaceURL }, autoSaveEnabledProvider: { [weak self] in self?.settings.autoSave ?? false }, @@ -455,7 +551,7 @@ final class AppModel: ObservableObject, Identifiable { self?.recordDiscardedEditorText(document) }, onRecordExternalChanges: { [weak self] paths in - self?.projectHistoryFeature.recordExternalChanges(paths) + self?.withHistoryModule { $0.recordExternalChanges(paths) } }, onDocumentCollectionChanged: { [weak self] in self?.workspaceFeature.scheduleWorkspaceSessionPersistence() @@ -473,7 +569,8 @@ final class AppModel: ObservableObject, Identifiable { notify: { [weak self] message in self?.showNotification(message) }, loadBlame: { [weak self] fileURL in guard let self else { return [] } - return await self.gitFeature.loadBlame(for: fileURL) + guard let feature = await self.activateGitModule() else { return [] } + return await feature.loadBlame(for: fileURL) } ) javaFeatureObservation = javaFeature.objectWillChange.sink { [weak self] _ in @@ -502,7 +599,7 @@ final class AppModel: ObservableObject, Identifiable { let provider = settings.importAIConfiguration(configuration) try? services.secureStore.delete(key: provider.apiKeyIdentifier) } - languageServerTools.onCandidatesChanged = { [weak self] providerID in + languageServerToolsIfActive?.onCandidatesChanged = { [weak self] providerID in guard let self, self.languageToolingFeature.shouldRetryCandidate(providerID: providerID), let document = self.activeDocument, @@ -517,6 +614,36 @@ final class AppModel: ObservableObject, Identifiable { doubleShiftDetector?.start() } + func activateDatabaseModule() async { + do { + let value = try await services.moduleRuntime.activateCapability(.databaseWorkspace) + guard let capability = value as? LitheDatabaseModule.DatabaseModuleCapability else { + throw ModuleRuntimeError.missingCapabilityDependency( + module: .database, + capability: .databaseWorkspace + ) + } + let feature = capability.feature + cacheModuleCapability(capability, id: .databaseWorkspace, moduleID: .database) + observeModuleFeature(.database, observation: feature.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + }) + selectedSidebar = .database + } catch { + showNotification(error.localizedDescription) + } + } + + func sleepDatabaseModule() async { + do { + try await services.moduleRuntime.sleep(.database) + clearModuleBindings(for: .database) + if selectedSidebar == .database { selectedSidebar = .project } + } catch { + showNotification(error.localizedDescription) + } + } + deinit { doubleShiftDetector?.stop() } @@ -542,8 +669,11 @@ final class AppModel: ObservableObject, Identifiable { func shutdownProjectSession() { doubleShiftDetector?.stop() - languageToolingSessions.stopAll() - languageTestService.stop() + Task { [weak self] in + await self?.services.moduleRuntime.shutdownAll() + } + languageToolingSessionsIfActive?.stopAll() + languageTestServiceIfActive?.stop() stopTerminalSessions() stopAccessingWorkspace() if let fileVisibilityRulesObserverID { @@ -553,9 +683,9 @@ final class AppModel: ObservableObject, Identifiable { } private func reloadJavaRuntimeServices() { - debugFeature.stop() - mavenFeature.stop() - languageToolingSessions.stopLanguageServer(providerID: "java") + debugFeatureIfActive?.stop() + mavenFeatureIfActive?.stop() + languageToolingSessionsIfActive?.stopLanguageServer(providerID: "java") javaFeature.stop() if let workspaceURL { if let document = activeDocument, @@ -572,8 +702,9 @@ final class AppModel: ObservableObject, Identifiable { /// Loads build-system and run state at the workspace boundary. The generic /// run lifecycle is intentionally not owned by JavaFeatureModel. func loadProjectServices(at workspaceURL: URL, files: [URL]) async { - languageTestService.discover(workspaceURL: workspaceURL, files: files) - await projectDevelopmentFeature.loadProject(at: workspaceURL, files: files) + guard let execution = await activateExecutionModule() else { return } + execution.tests.discover(workspaceURL: workspaceURL, files: files) + await execution.projectDevelopment.loadProject(at: workspaceURL, files: files) } var projectName: String { @@ -590,7 +721,7 @@ final class AppModel: ObservableObject, Identifiable { let status = LSPControlCenterPresenter.serverStatus( isDisabled: languageToolingFeature.isDisabled(descriptor.id), - sessionState: languageToolingSessions.languageServerStates[descriptor.id] + sessionState: languageToolingSessionsIfActive?.languageServerStates[descriptor.id] ) switch status { case .starting: @@ -625,7 +756,7 @@ final class AppModel: ObservableObject, Identifiable { } func restartLanguageServers() { - languageToolingSessions.stopAllLanguageServers() + languageToolingSessionsIfActive?.stopAllLanguageServers() languageToolingFeature.resetWorkspaceState() let didStart = activateCurrentDocumentLanguageServerIfAvailable() showNotification( @@ -636,7 +767,7 @@ final class AppModel: ObservableObject, Identifiable { } func clearLanguageServerDiagnostics() { - languageToolingSessions.clearDiagnostics() + languageToolingSessionsIfActive?.clearDiagnostics() showNotification(settings.language == .simplifiedChinese ? "语言服务器诊断已清空" : "Language server diagnostics cleared") } @@ -690,6 +821,7 @@ final class AppModel: ObservableObject, Identifiable { } func cloneRepository(remote: String, destination: URL) async -> String? { + guard let gitFeature = await activateGitModule() else { return "Git module is disabled" } let result = await gitFeature.cloneRepository( remote: remote, destination: destination, @@ -716,6 +848,13 @@ final class AppModel: ObservableObject, Identifiable { func openProjectDirectly(_ url: URL) { let normalizedURL = url.standardizedFileURL + Task { [weak self] in + guard let self else { return } + await self.services.moduleRuntime.shutdownAll() + await MainActor.run { + self.clearModuleBindings(for: .database) + } + } if let previousWorkspaceURL = workspaceURL { workspaceFeature.persistWorkspaceSession(for: previousWorkspaceURL) } @@ -723,20 +862,20 @@ final class AppModel: ObservableObject, Identifiable { // 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() + languageToolingSessionsIfActive?.stopAll() reloadLanguageProviderCatalog(for: normalizedURL) stopTerminalSessions() - languageTestService.reset() + languageTestServiceIfActive?.reset() languageToolingFeature.resetWorkspaceState() runtimeFeature.openProject(at: normalizedURL) - mavenFeature.reset() - runFeature.reset() - debugFeature.reset() - genericDebugFeature.reset() + mavenFeatureIfActive?.reset() + runFeatureIfActive?.reset() + debugFeatureIfActive?.reset() + genericDebugFeatureIfActive?.reset() clearLanguageNavigationProjection() javaFeature.stop() workspaceFeature.reset() - searchFeature.reset() + searchFeatureIfActive?.reset() isTerminalVisible = false isReferencesVisible = false isProblemsVisible = false @@ -747,13 +886,12 @@ final class AppModel: ObservableObject, Identifiable { editorCaret = nil editorNavigationTarget = nil blameVisibleURL = nil - gitFeature.reset() + gitFeatureIfActive?.reset() documentFeature.reset() gitLogSearchQuery = "" - projectHistoryFeature.reset() + projectHistoryFeatureIfActive?.reset() workspaceURL = normalizedURL let visibilityRules = settings.fileVisibilityRules - projectHistoryFeature.openWorkspace(at: normalizedURL, visibilityRules: visibilityRules) workspaceFeature.beginWorkspace(at: normalizedURL, visibilityRules: visibilityRules) selectedSidebar = .project projectItemEditRequest = nil @@ -782,6 +920,13 @@ final class AppModel: ObservableObject, Identifiable { } private func performCloseProject() { + Task { [weak self] in + guard let self else { return } + await self.services.moduleRuntime.shutdownAll() + await MainActor.run { + self.clearModuleBindings(for: .database) + } + } if let workspaceURL { workspaceFeature.persistWorkspaceSession(for: workspaceURL) } @@ -791,7 +936,7 @@ final class AppModel: ObservableObject, Identifiable { selectedSidebar = .project workspaceFeature.reset() documentFeature.reset() - searchFeature.reset() + searchFeatureIfActive?.reset() searchQuery = "" isSearchEverywhereVisible = false searchEverywhereQuery = "" @@ -803,9 +948,9 @@ final class AppModel: ObservableObject, Identifiable { findBarQuery = "" findMatchCount = 0 currentFindMatchIndex = 0 - projectHistoryFeature.reset() + projectHistoryFeatureIfActive?.reset() workspaceFeature.reset() - gitFeature.reset() + gitFeatureIfActive?.reset() isGitLogVisible = false isTerminalVisible = false isReferencesVisible = false @@ -815,13 +960,13 @@ final class AppModel: ObservableObject, Identifiable { isTestsVisible = false isDebugVisible = false stopTerminalSessions() - languageToolingSessions.stopAll() - languageTestService.reset() + languageToolingSessionsIfActive?.stopAll() + languageTestServiceIfActive?.reset() runtimeFeature.closeProject() - mavenFeature.reset() - runFeature.reset() - debugFeature.reset() - genericDebugFeature.reset() + mavenFeatureIfActive?.reset() + runFeatureIfActive?.reset() + debugFeatureIfActive?.reset() + genericDebugFeatureIfActive?.reset() javaFeature.stop() editorCaret = nil editorNavigationTarget = nil @@ -870,7 +1015,7 @@ final class AppModel: ObservableObject, Identifiable { } func javaIconKind(for url: URL) async -> LitheIconKind? { - await workspaceFeature.javaIconKind(for: url) + await JavaFileIconResolver.resolve(for: url, storage: services.fileStorage) } func refreshWorkspace() async { @@ -925,55 +1070,61 @@ final class AppModel: ObservableObject, Identifiable { } func showLocalHistory(for fileURL: URL) { - projectHistoryFeature.showLocalHistory(for: fileURL) + withHistoryModule { $0.showLocalHistory(for: fileURL) } } func showProjectLocalHistory() { - projectHistoryFeature.showProjectLocalHistory() + withHistoryModule { $0.showProjectLocalHistory() } } func selectLocalHistoryEntry(_ entry: LocalHistoryEntry) { - projectHistoryFeature.selectLocalHistoryEntry(entry) + projectHistoryFeatureIfActive?.selectLocalHistoryEntry(entry) } func selectProjectLocalHistoryEntry(_ entry: LocalHistoryEntry) { - projectHistoryFeature.selectProjectLocalHistoryEntry(entry) + projectHistoryFeatureIfActive?.selectProjectLocalHistoryEntry(entry) } func refreshLocalHistory() async { - await projectHistoryFeature.refreshLocalHistory() + guard let feature = await activateHistoryModule() else { return } + await feature.refreshLocalHistory() } func refreshProjectLocalHistory() async { - await projectHistoryFeature.refreshProjectLocalHistory() + guard let feature = await activateHistoryModule() else { return } + await feature.refreshProjectLocalHistory() } func restoreSelectedLocalHistoryEntry() async { - guard let restoration = await projectHistoryFeature.restoreSelectedLocalHistoryEntry() else { + guard let feature = await activateHistoryModule(), + let restoration = await feature.restoreSelectedLocalHistoryEntry() else { showNotification("Could not restore local history") return } if let documentID = restoration.documentID { + try? openDocuments.first(where: { $0.id == documentID })?.reloadFromDisk() activeDocumentID = documentID } else { openFile(restoration.url) } showNotification("Restored \(restoration.url.lastPathComponent)") await refreshWorkspace() - await projectHistoryFeature.refreshLocalHistory() + await feature.refreshLocalHistory() } func restoreSelectedProjectLocalHistoryEntry() async { - guard let restoration = await projectHistoryFeature.restoreSelectedProjectLocalHistoryEntry() else { + guard let feature = await activateHistoryModule(), + let restoration = await feature.restoreSelectedProjectLocalHistoryEntry() else { showNotification("Could not restore project history") return } if let documentID = restoration.documentID { + try? openDocuments.first(where: { $0.id == documentID })?.reloadFromDisk() activeDocumentID = documentID } showNotification("Restored \(restoration.url.lastPathComponent)") await refreshWorkspace() - await projectHistoryFeature.refreshProjectLocalHistory() + await feature.refreshProjectLocalHistory() } func requestCloseDocument(_ document: EditorDocument) { @@ -1014,7 +1165,7 @@ final class AppModel: ObservableObject, Identifiable { try documentFeature.save(document) } - private func workspaceRelativePath(for url: URL, root: URL) -> String? { + func workspaceRelativePath(for url: URL, root: URL) -> String? { let normalizedRoot = root.standardizedFileURL.path let normalizedPath = url.standardizedFileURL.path guard normalizedPath.hasPrefix(normalizedRoot + "/") else { return nil } @@ -1037,7 +1188,7 @@ final class AppModel: ObservableObject, Identifiable { } private func handleDocumentClosed(_ document: EditorDocument) { - languageToolingSessions.closeDocument(document.url) + languageToolingSessionsIfActive?.closeDocument(document.url) if javaFeature.handles(fileURL: document.url) { javaFeature.close(document) } @@ -1053,8 +1204,72 @@ final class AppModel: ObservableObject, Identifiable { private func activateLanguageServerIfAvailable(for document: EditorDocument) -> Bool { guard let workspaceURL, let descriptor = languageProviderCatalog.provider(for: document.url) else { return false } + if let ownership = services.pluginCatalog.languageSupport(for: document.url), + ownership.declaration.languageServerModuleID != nil { + let support = ownership.declaration + let capabilityID = ModuleCapabilityID.languageServerExtension(support.id) + if services.moduleRuntime.capability(capabilityID) == nil { + Task { [weak self, weak document] in + guard let self, let document else { return } + do { + _ = try await self.services.moduleRuntime.activateCapability(capabilityID) + _ = self.activateLanguageServerIfAvailable(for: document) + } catch { + self.languageToolingFeature.markActivationFailed( + providerID: descriptor.id, + descriptor: descriptor, + error: error + ) + } + } + return false + } + if let provider = services.moduleRuntime.capability(capabilityID) + as? any LanguageServerExtensionProviding, + let sessions = languageToolingSessionsIfActive, + !sessions.registerLanguageServerExtension(provider, support: support) { + languageToolingFeature.markActivationFailed( + providerID: descriptor.id, + descriptor: descriptor, + error: LanguageExtensionRegistrationError.invalidLanguageServerProvider( + support.displayName + ) + ) + return false + } + } + if let snapshot = try? services.moduleRuntime.snapshot(for: .languageIntelligence), + snapshot.state != .active, + snapshot.state != .idle { + Task { [weak self] in + guard let self else { return } + do { + let value = try await self.services.moduleRuntime.activateCapability(.languageIntelligence) + guard let capability = value as? LitheLanguageIntelligenceModule.LanguageIntelligenceCapability else { return } + self.cacheModuleCapability(capability, id: .languageIntelligence, moduleID: .languageIntelligence) + self.observeModuleFeature(.languageIntelligence, observation: capability.sessions.objectWillChange.sink { [weak self] _ in + self?.scheduleObjectWillChangeRelay() + }) + capability.tools.onCandidatesChanged = { [weak self] providerID in + guard let self, + self.languageToolingFeature.shouldRetryCandidate(providerID: providerID), + let document = self.activeDocument, + self.languageProviderCatalog.provider(for: document.url)?.id == providerID else { return } + _ = self.activateLanguageServerIfAvailable(for: document) + } + _ = self.activateLanguageServerIfAvailable(for: document) + } catch { + self.languageToolingFeature.markActivationFailed( + providerID: descriptor.id, + descriptor: descriptor, + error: error + ) + } + } + return false + } guard !languageToolingFeature.isDisabled(descriptor.id) else { - languageToolingSessions.recordLanguageServerLog( + languageToolingSessionsIfActive?.recordLanguageServerLog( providerID: descriptor.id, level: .info, message: "Language server activation skipped", @@ -1063,12 +1278,20 @@ final class AppModel: ObservableObject, Identifiable { return false } do { + guard let languageToolingSessions = languageToolingSessionsIfActive else { return false } try languageToolingSessions.synchronizeLanguageServer( for: document.url, text: document.text, rootURL: workspaceURL ) languageToolingFeature.markActivationSucceeded(providerID: descriptor.id) + if let moduleID = services.pluginCatalog.languageSupport(for: document.url)? + .declaration.languageServerModuleID { + // A successful sync is the plugin LSP's latest activity. The + // idle policy can stop it after the user leaves the document + // untouched, while subsequent edits refresh this timestamp. + try? services.moduleRuntime.markIdle(moduleID) + } return languageToolingSessions.activeLanguageServerIDs.contains(descriptor.id) } catch { languageToolingFeature.markActivationFailed(providerID: descriptor.id, descriptor: descriptor, error: error) @@ -1076,163 +1299,6 @@ final class AppModel: ObservableObject, Identifiable { } } - func searchProject(options: ProjectSearchOptions = .default) async { - guard let workspaceURL else { return } - let query = searchQuery - await searchFeature.searchProject( - at: workspaceURL, - query: query, - options: options, - visibilityRules: settings.fileVisibilityRules, - isCurrent: { [weak self] in - self?.workspaceURL == workspaceURL && self?.searchQuery == query - } - ) - } - - func toggleSearchEverywhere() { - guard workspaceURL != nil else { return } - // 弹窗已打开时忽略再次双击 Shift:避免输入大写字母等场景误触关闭。 - guard !isSearchEverywhereVisible else { return } - isSearchEverywhereVisible = true - } - - func dismissSearchEverywhere() { - isSearchEverywhereVisible = false - searchEverywhereQuery = "" - searchFeature.clearSearchEverywhere() - } - - func searchEverywhere(options: ProjectSearchOptions = .default) async { - guard let workspaceURL else { - searchFeature.clearSearchEverywhere() - return - } - let query = searchEverywhereQuery - let actionMatches = LitheActionRegistry.actions(for: self).filter { $0.matches(query) } - await searchFeature.searchEverywhere( - at: workspaceURL, - query: query, - options: options, - visibilityRules: settings.fileVisibilityRules, - actionMatches: actionMatches, - isCurrent: { [weak self] in - self?.workspaceURL == workspaceURL && self?.searchEverywhereQuery == query - } - ) - } - - /// Find in Files:切到搜索侧栏,预填当前选区并把焦点交给输入框。 - func openProjectSearch() { - guard workspaceURL != nil else { return } - if !editorSelectedText.isEmpty { - searchQuery = editorSelectedText - } - selectedSidebar = .search - searchSidebarFocusRequest += 1 - } - - func clearProjectReplacementPreview() { - searchFeature.clearProjectReplacementPreview() - selectedProjectReplacementPaths = [] - } - - /// 打开 Replace in Project。传入侧栏当前选项可让查询条件延续,避免重填。 - func openProjectReplace(inheriting options: ProjectSearchOptions? = nil) { - guard workspaceURL != nil else { return } - if !editorSelectedText.isEmpty { - searchQuery = editorSelectedText - } - projectReplaceQuery = searchQuery - projectReplaceText = "" - if let options { - projectReplaceOptions = options - } - searchFeature.clearProjectReplacementPreview() - selectedProjectReplacementPaths = [] - isProjectReplaceVisible = true - } - - func previewProjectReplacement() async { - guard let rootURL = workspaceURL else { return } - let query = projectReplaceQuery - let rules = settings.fileVisibilityRules - let replacement = projectReplaceText - let paths = projectFiles.compactMap { workspaceRelativePath(for: $0, root: rootURL) } - let overrides: [String: String] = Dictionary(uniqueKeysWithValues: openDocuments.compactMap { document in - guard let path = workspaceRelativePath(for: document.url, root: rootURL) else { return nil } - return (path, document.text) - }) - await searchFeature.previewProjectReplacement( - at: rootURL, - query: query, - replacement: replacement, - paths: paths, - textOverrides: overrides, - options: projectReplaceOptions, - visibilityRules: rules, - isCurrent: { [weak self] in - self?.workspaceURL == rootURL && self?.projectReplaceQuery == query - } - ) - guard projectReplaceQuery == query else { return } - selectedProjectReplacementPaths = Set(projectReplacementFiles.map(\.relativePath)) - } - - func applyProjectReplacement() async { - guard self.workspaceURL != nil, - !projectReplaceQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - - let selectedPaths = selectedProjectReplacementPaths - guard let rootURL = workspaceURL else { return } - let result = await searchFeature.applyProjectReplacement( - at: rootURL, - selectedPaths: selectedPaths, - documents: openDocuments, - recordHistory: { [weak self] text, fileURL in - await self?.projectHistoryFeature.recordHistorySnapshot( - text: text, - for: fileURL, - reason: .beforeBatchReplace - ) - }, - saveDocument: { [weak self] document in - try self?.saveDocument(document) - } - ) - isProjectReplaceVisible = false - searchFeature.clearProjectReplacementPreview() - selectedProjectReplacementPaths = [] - await refreshWorkspace() - if !result.failedFiles.isEmpty { - showNotification("Could not replace in \(result.failedFiles.count) file(s)") - } else if result.changedFiles > 0 { - showNotification("Replaced text in \(result.changedFiles) file(s)") - } - - } - - func openSearchEverywhereResult(_ result: FileSearchResult) { - dismissSearchEverywhere() - openSearchResult(result) - } - - func performSearchEverywhereAction(_ action: LitheAction) { - dismissSearchEverywhere() - action.perform() - } - - func openSearchResult(_ result: FileSearchResult) { - openFile(result.url) - if let line = result.line { - editorNavigationTarget = EditorNavigationTarget( - url: result.url, - line: line - 1, - utf16Column: 0 - ) - } - } - func showFindBar() { guard activeDocument != nil else { return } isFindBarVisible = true @@ -1278,62 +1344,74 @@ final class AppModel: ObservableObject, Identifiable { func selectChange(_ change: GitChange) { activeDocumentID = nil - Task { await gitFeature.selectChange(change) } + Task { [weak self] in + guard let gitFeature = await self?.activateGitModule() else { return } + await gitFeature.selectChange(change) + } } func reloadSelectedChangeDiff(whitespace: GitDiffWhitespaceMode) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.reloadSelectedChangeDiff(whitespace: whitespace) } func refreshGit() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.refreshGit() } func stageSelectedChange() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.stageSelectedChange() } func unstageSelectedChange() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.unstageSelectedChange() } func stageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.stageDiffHunk(hunk, in: change) } func unstageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.unstageDiffHunk(hunk, in: change) } func requestDiscardHunk(_ hunk: DiffHunk, in change: GitChange) { - gitFeature.requestDiscardHunk(hunk, in: change) + gitFeatureIfActive?.requestDiscardHunk(hunk, in: change) } func confirmDiscardHunk() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.confirmDiscardHunk() } func cancelDiscardHunk() { - gitFeature.cancelDiscardHunk() + gitFeatureIfActive?.cancelDiscardHunk() } func requestDiscardSelectedChange() { - gitFeature.requestDiscardSelectedChange() + gitFeatureIfActive?.requestDiscardSelectedChange() } func requestDiscardChange(_ change: GitChange) { - gitFeature.requestDiscardChange(change) + gitFeatureIfActive?.requestDiscardChange(change) } func confirmDiscardChange() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.confirmDiscardChange() } func cancelDiscardChange() { - gitFeature.cancelDiscardChange() + gitFeatureIfActive?.cancelDiscardChange() } func commitStagedChanges() async { + guard let gitFeature = await activateGitModule() else { return } if await gitFeature.commitStagedChanges(message: commitMessage, amend: amendCommit) { commitMessage = "" amendCommit = false @@ -1341,6 +1419,7 @@ final class AppModel: ObservableObject, Identifiable { } func commitAndPushStagedChanges() async { + guard let gitFeature = await activateGitModule() else { return } if await gitFeature.commitAndPushStagedChanges(message: commitMessage, amend: amendCommit) { commitMessage = "" amendCommit = false @@ -1349,6 +1428,7 @@ final class AppModel: ObservableObject, Identifiable { func generateCommitMessage() async { guard !isGeneratingCommitMessage else { return } + guard let gitFeature = await activateGitModule() else { return } let stagedChanges = gitFeature.gitChanges.filter(\.isStaged) guard !stagedChanges.isEmpty else { showNotification("Stage at least one file first") @@ -1365,7 +1445,15 @@ final class AppModel: ObservableObject, Identifiable { guard let input = await gitFeature.stagedCommitMessageInput() else { throw CommitMessageGenerationError.emptyDiff } - let generated = try await services.commitMessageGenerator.generate( + let value = try await services.moduleRuntime.activateCapability(.aiCommitMessage) + guard let capability = value as? any AICommitMessageGenerating else { + throw ModuleRuntimeError.missingCapabilityDependency( + module: .aiAssistance, + capability: .aiCommitMessage + ) + } + defer { try? services.moduleRuntime.markIdle(.aiAssistance) } + let generated = try await capability.generateCommitMessage( input: input, settings: settings.commitMessageAI ) @@ -1400,10 +1488,12 @@ final class AppModel: ObservableObject, Identifiable { } func toggleStaging(_ change: GitChange) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.toggleStaging(change) } func stageAllChanges() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.stageAllChanges() } @@ -1428,71 +1518,41 @@ final class AppModel: ObservableObject, Identifiable { } func selectGitReference(_ reference: GitReference?) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.selectGitReference(reference) } func refreshGitHistory() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.refreshGitHistory() } func loadMoreGitHistory() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.loadMoreGitHistory() } func selectGitCommit(_ commit: GitCommit) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.selectGitCommit(commit) } func showGitCommitDiff(for file: GitCommitFile) { activeDocumentID = nil - Task { await gitFeature.showGitCommitDiff(for: file) } + Task { [weak self] in + guard let gitFeature = await self?.activateGitModule() else { return } + await gitFeature.showGitCommitDiff(for: file) + } } func closeGitCommitDiff() { - gitFeature.closeGitCommitDiff() - } - - func refreshCodeVision(for fileURL: URL) async { - let normalizedURL = fileURL.standardizedFileURL - guard normalizedURL.pathExtension.lowercased() == "java", - let document = openDocuments.first(where: { $0.url.standardizedFileURL == normalizedURL }), - !document.isReadOnly, - let workspaceRoot = workspaceURL else { return } - await javaFeature.refreshCodeVision( - for: document, - projectFiles: projectFiles, - workspaceRoot: workspaceRoot - ) - } - - func refreshJavaInlayHints(for document: EditorDocument) { - javaFeature.refreshInlayHints( - for: document, - projectFiles: projectFiles, - workspaceRoot: workspaceURL - ) - } - - func showBlame(for fileURL: URL) { - let normalizedURL = fileURL.standardizedFileURL - blameVisibleURL = blameVisibleURL == normalizedURL ? nil : normalizedURL - } - - func hideBlame() { - blameVisibleURL = nil - } - - func findUsages(for hint: JavaCodeVisionHint, in fileURL: URL) { - editorCaret = EditorCaret( - url: fileURL.standardizedFileURL, - line: hint.line, - utf16Column: hint.utf16Column - ) - findReferences() + gitFeatureIfActive?.closeGitCommitDiff() } func showGitCommit(_ hash: String) async { - guard gitRepositoryRoot != nil, !hash.allSatisfy({ $0 == "0" }) else { return } + guard let gitFeature = await activateGitModule(), + gitFeature.gitRepositoryRoot != nil, + !hash.allSatisfy({ $0 == "0" }) else { return } isTerminalVisible = false isReferencesVisible = false isProblemsVisible = false @@ -1506,15 +1566,17 @@ final class AppModel: ObservableObject, Identifiable { func showComparisonWithWorkingTree(for reference: GitReference) async { activeDocumentID = nil + guard let gitFeature = await activateGitModule() else { return } await gitFeature.showComparisonWithWorkingTree(for: reference) } func selectBranchComparisonFile(_ file: GitBranchComparisonFile) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.selectBranchComparisonFile(file) } func closeBranchComparison() { - gitFeature.closeBranchComparison() + gitFeatureIfActive?.closeBranchComparison() } func createBranch( @@ -1522,62 +1584,75 @@ final class AppModel: ObservableObject, Identifiable { from reference: GitReference, checkout: Bool ) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.createBranch(named: rawName, from: reference, checkout: checkout) } func renameBranch(_ reference: GitReference, to rawName: String) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.renameBranch(reference, to: rawName) } func deleteBranch(_ reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.deleteBranch(reference) } func mergeBranch(_ reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.mergeBranch(reference) } func continueGitOperation() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.continueGitOperation() } func resolvePullStrategy(_ strategy: GitPullStrategy) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.resolvePullStrategy(strategy) } func cancelPullStrategy() { - gitFeature.cancelPullStrategy() + gitFeatureIfActive?.cancelPullStrategy() } func resolveIntegrationConflict(_ request: GitIntegrationConflictRequest) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.resolveIntegrationConflict(request) } func cancelIntegrationConflict() { - gitFeature.cancelIntegrationConflict() + gitFeatureIfActive?.cancelIntegrationConflict() } func abortGitOperation() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.abortGitOperation() } func skipGitOperationStep() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.skipGitOperationStep() } func rebaseCurrentBranch(onto reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.rebaseCurrentBranch(onto: reference) } func updateCurrentBranch(_ reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.updateCurrentBranch(reference) } func fetchGit() async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.fetchGit() } func checkoutReference(_ reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.checkoutReference(reference) } @@ -1585,26 +1660,32 @@ final class AppModel: ObservableObject, Identifiable { _ request: GitCheckoutConflictRequest, strategy: GitCheckoutConflictStrategy ) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.resolveCheckoutConflict(request, strategy: strategy) } func checkoutRevision(_ rawRevision: String) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.checkoutRevision(rawRevision) } func cherryPick(_ commit: GitCommit) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.cherryPick(commit) } func revert(_ commit: GitCommit) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.revert(commit) } func resetCurrentBranch(to commit: GitCommit) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.resetCurrentBranch(to: commit) } func pushBranch(_ reference: GitReference) async { + guard let gitFeature = await activateGitModule() else { return } await gitFeature.pushBranch(reference) } @@ -1632,75 +1713,12 @@ final class AppModel: ObservableObject, Identifiable { } func recordSave(_ document: EditorDocument, previousText: String) { - projectHistoryFeature.recordSave(document, previousText: previousText) + let snapshot = LocalHistoryDocumentSnapshot(id: document.id, url: document.url, text: document.text) + withHistoryModule { $0.recordSave(snapshot, previousText: previousText) } } private func recordDiscardedEditorText(_ document: EditorDocument) { - projectHistoryFeature.recordDiscardedEditorText(document) + let snapshot = LocalHistoryDocumentSnapshot(id: document.id, url: document.url, text: document.text) + withHistoryModule { $0.recordDiscardedEditorText(snapshot) } } } - -enum SidebarDestination: String, CaseIterable, Identifiable { - case project - case changes - case search - case database - - var id: String { rawValue } - - var title: String { - switch self { - case .project: "Project" - case .changes: "Changes" - case .search: "Search" - case .database: "Database" - } - } - - var systemImage: String { - switch self { - case .project: "folder" - case .changes: "slider.horizontal.3" - case .search: "magnifyingglass" - case .database: "cylinder.split.1x2" - } - } - - var ideaAssetPath: String { - switch self { - case .project: "toolwindows/toolWindowProject.svg" - case .changes: "toolwindows/toolWindowCommit.svg" - case .search: "toolwindows/toolWindowFind.svg" - case .database: "toolwindows/toolWindowDatabase.svg" - } - } -} - -enum ProjectItemEditKind: Sendable { - case createFile - case createDirectory - case rename -} - -struct ProjectItemEditRequest: Identifiable, Sendable { - let id = UUID() - let kind: ProjectItemEditKind - let targetURL: URL -} - -struct ProjectItemDeletionRequest: Identifiable, Sendable { - let id = UUID() - let url: URL - let isDirectory: Bool -} - -enum FindNotificationKeys { - static let query = "query" - static let direction = "direction" -} - -extension Notification.Name { - static let litheFindQueryChanged = Notification.Name("litheFindQueryChanged") - static let litheFindNavigate = Notification.Name("litheFindNavigate") - static let litheFindDismiss = Notification.Name("litheFindDismiss") -} diff --git a/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift new file mode 100644 index 00000000..3c565882 --- /dev/null +++ b/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -0,0 +1,50 @@ +import Foundation +import LitheCoreContracts + +enum SidebarDestination: String, CaseIterable, Identifiable { + case project + case changes + case search + case database + + var id: String { rawValue } + var title: String { + switch self { + case .project: "Project" + case .changes: "Changes" + case .search: "Search" + case .database: "Database" + } + } + var systemImage: String { + switch self { + case .project: "folder" + case .changes: "slider.horizontal.3" + case .search: "magnifyingglass" + case .database: "cylinder.split.1x2" + } + } + var ideaAssetPath: String { + switch self { + case .project: "toolwindows/toolWindowProject.svg" + case .changes: "toolwindows/toolWindowCommit.svg" + case .search: "toolwindows/toolWindowFind.svg" + case .database: "toolwindows/toolWindowDatabase.svg" + } + } +} + +typealias ProjectItemEditKind = LitheCoreContracts.ProjectItemEditKind +typealias ProjectItemEditRequest = LitheCoreContracts.ProjectItemEditRequest +typealias ProjectItemDeletionRequest = LitheCoreContracts.ProjectItemDeletionRequest + +enum FindNotificationKeys { + static let query = "query" + static let direction = "direction" +} + +extension Notification.Name { + static let litheFindQueryChanged = Notification.Name("litheFindQueryChanged") + static let litheFindNavigate = Notification.Name("litheFindNavigate") + static let litheFindDismiss = Notification.Name("litheFindDismiss") +} diff --git a/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift b/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift new file mode 100644 index 00000000..1b32deff --- /dev/null +++ b/Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift @@ -0,0 +1,28 @@ +import LitheCoreContracts +import LitheLocalHistoryModule +import LitheSearchModule + +typealias FileVisibilityRules = LitheCoreContracts.FileVisibilityRules + +extension FileVisibilityRules { + init(searchRules: SearchVisibilityRules) { + self.init( + hiddenDirectoryNames: searchRules.hiddenDirectoryNames, + hiddenFilePatterns: searchRules.hiddenFilePatterns + ) + } + + var searchRules: SearchVisibilityRules { + SearchVisibilityRules( + hiddenDirectoryNames: hiddenDirectoryNames, + hiddenFilePatterns: hiddenFilePatterns + ) + } + + var localHistoryRules: LocalHistoryVisibilityRules { + LocalHistoryVisibilityRules( + hiddenDirectoryNames: hiddenDirectoryNames, + hiddenFilePatterns: hiddenFilePatterns + ) + } +} diff --git a/Sources/Lithe/Models/Bridges/GitModuleBridges.swift b/Sources/Lithe/Models/Bridges/GitModuleBridges.swift new file mode 100644 index 00000000..34f711cb --- /dev/null +++ b/Sources/Lithe/Models/Bridges/GitModuleBridges.swift @@ -0,0 +1,20 @@ +import Foundation +import LitheGitModule +import LitheLocalHistoryModule + +extension DiffRow { + init(_ row: LocalHistoryDiffRow) { + self.init( + oldLine: row.oldLine, newLine: row.newLine, left: row.left, right: row.rightText, + kind: { + switch row.kind { + case .context: .context + case .changed: .changed + case .addition: .addition + case .removal: .removal + } + }(), + sequence: row.sequence + ) + } +} diff --git a/Sources/Lithe/Models/DiffCollapse.swift b/Sources/Lithe/Models/Diff/DiffCollapse.swift similarity index 99% rename from Sources/Lithe/Models/DiffCollapse.swift rename to Sources/Lithe/Models/Diff/DiffCollapse.swift index b8c17229..aa02f0c1 100644 --- a/Sources/Lithe/Models/DiffCollapse.swift +++ b/Sources/Lithe/Models/Diff/DiffCollapse.swift @@ -1,4 +1,5 @@ import Foundation +import LitheGitModule /// A run of unchanged rows folded into a single clickable band. struct DiffCollapsedRegion: Identifiable, Hashable { diff --git a/Sources/Lithe/Models/DiffPairing.swift b/Sources/Lithe/Models/Diff/DiffPairing.swift similarity index 100% rename from Sources/Lithe/Models/DiffPairing.swift rename to Sources/Lithe/Models/Diff/DiffPairing.swift diff --git a/Sources/Lithe/Models/DiffSplitLayout.swift b/Sources/Lithe/Models/Diff/DiffSplitLayout.swift similarity index 99% rename from Sources/Lithe/Models/DiffSplitLayout.swift rename to Sources/Lithe/Models/Diff/DiffSplitLayout.swift index 551e2583..b33bedcc 100644 --- a/Sources/Lithe/Models/DiffSplitLayout.swift +++ b/Sources/Lithe/Models/Diff/DiffSplitLayout.swift @@ -1,4 +1,5 @@ import CoreGraphics +import LitheGitModule /// Lays the old and new sides out as independent vertical streams. /// diff --git a/Sources/Lithe/Models/BinaryFileViewerRegistry.swift b/Sources/Lithe/Models/Editor/BinaryFileViewerRegistry.swift similarity index 100% rename from Sources/Lithe/Models/BinaryFileViewerRegistry.swift rename to Sources/Lithe/Models/Editor/BinaryFileViewerRegistry.swift diff --git a/Sources/Lithe/Models/EditorDocument.swift b/Sources/Lithe/Models/Editor/EditorDocument.swift similarity index 100% rename from Sources/Lithe/Models/EditorDocument.swift rename to Sources/Lithe/Models/Editor/EditorDocument.swift diff --git a/Sources/Lithe/Models/MarkdownImageInsertion.swift b/Sources/Lithe/Models/Editor/MarkdownImageInsertion.swift similarity index 100% rename from Sources/Lithe/Models/MarkdownImageInsertion.swift rename to Sources/Lithe/Models/Editor/MarkdownImageInsertion.swift diff --git a/Sources/Lithe/Models/MarkdownScrollPosition.swift b/Sources/Lithe/Models/Editor/MarkdownScrollPosition.swift similarity index 100% rename from Sources/Lithe/Models/MarkdownScrollPosition.swift rename to Sources/Lithe/Models/Editor/MarkdownScrollPosition.swift diff --git a/Sources/Lithe/Models/FileNode.swift b/Sources/Lithe/Models/FileNode.swift deleted file mode 100644 index 6bb5eafa..00000000 --- a/Sources/Lithe/Models/FileNode.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Foundation - -struct FileNode: Identifiable, Hashable, Sendable { - let url: URL - let isDirectory: Bool - let children: [FileNode]? - /// 被压缩的中间包所对应的目录(不含本节点自身)。展开/折叠时需要 - /// 一并处理,否则父目录的展开状态会和显示的行对不上。 - let collapsedAncestorPaths: [String] - /// 该目录是否位于源码根之下,决定用包图标还是普通文件夹图标。 - let isInsideSourceRoot: Bool - - init( - url: URL, - isDirectory: Bool, - children: [FileNode]?, - collapsedAncestorPaths: [String] = [], - isInsideSourceRoot: Bool = false - ) { - self.url = url - self.isDirectory = isDirectory - self.children = children - self.collapsedAncestorPaths = collapsedAncestorPaths - self.isInsideSourceRoot = isInsideSourceRoot - } - - var id: String { url.path } - - /// 压缩中间包后显示的名字,例如 com.alibaba.nacos.ai。 - var name: String { - guard !collapsedAncestorPaths.isEmpty else { return url.lastPathComponent } - let names = collapsedAncestorPaths.map { ($0 as NSString).lastPathComponent } - return (names + [url.lastPathComponent]).joined(separator: ".") - } - - var iconKind: LitheIconKind { - LitheIcons.kind(for: url, isDirectory: isDirectory, isInsideSourceRoot: isInsideSourceRoot) - } -} - -struct WorkspaceSnapshot: Sendable { - let root: FileNode - let files: [URL] -} - -struct FileSearchResult: Identifiable, Hashable, Sendable { - let kind: SearchResultKind - let url: URL - let line: Int? - let preview: String - let symbolName: String? - - init( - url: URL, - line: Int?, - preview: String, - kind: SearchResultKind = .content, - symbolName: String? = nil - ) { - self.kind = kind - self.url = url - self.line = line - self.preview = preview - self.symbolName = symbolName - } - - var id: String { "\(kind.rawValue):\(url.path):\(line ?? 0):\(preview)" } -} - -enum SearchResultKind: String, Codable, Hashable, Sendable { - case file - case content - case type - case symbol - - var title: String { - switch self { - case .file: "Files" - case .content: "Matches" - case .type: "Classes" - case .symbol: "Symbols" - } - } -} - -struct SearchSymbol: Codable, Hashable, Sendable { - let name: String - let kind: SearchResultKind - let line: Int - let signature: String -} - -struct SearchEverywhereResults: @unchecked Sendable { - /// 后端一次最多返回这么多命中;命中数顶到上限时 UI 提示还有更多。 - static let matchLimit = 200 - - let fileMatches: [FileSearchResult] - let classMatches: [FileSearchResult] - let symbolMatches: [FileSearchResult] - let contentMatches: [FileSearchResult] - let actionMatches: [LitheAction] - - init( - fileMatches: [FileSearchResult] = [], - classMatches: [FileSearchResult] = [], - symbolMatches: [FileSearchResult] = [], - contentMatches: [FileSearchResult] = [], - actionMatches: [LitheAction] = [] - ) { - self.fileMatches = fileMatches - self.classMatches = classMatches - self.symbolMatches = symbolMatches - self.contentMatches = contentMatches - self.actionMatches = actionMatches - } - - var allMatches: [FileSearchResult] { - fileMatches + classMatches + symbolMatches + contentMatches - } - - var totalCount: Int { allMatches.count + actionMatches.count } -} diff --git a/Sources/Lithe/Models/GitGraphModels.swift b/Sources/Lithe/Models/GitGraphModels.swift deleted file mode 100644 index 2986d6ab..00000000 --- a/Sources/Lithe/Models/GitGraphModels.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation - -enum GitGraphReferenceKind: String, Hashable, Sendable { - case head - case branch - case remote - case tag -} - -struct GitGraphLabel: Identifiable, Hashable, Sendable { - let title: String - let kind: GitGraphReferenceKind - - var id: String { "\(kind.rawValue):\(title)" } -} - -struct GitGraphEdge: Identifiable, Hashable, Sendable { - let id: String - let parentHash: String - let targetLane: Int? - let colorIndex: Int - let isMissing: Bool -} - -struct GitGraphRow: Identifiable, Hashable, Sendable { - let commit: GitCommit - let lane: Int - let laneCount: Int - /// One entry per lane slot, ordered by lane index. `nil` marks a slot that no - /// branch occupies at this row, so lane indices stay stable between rows. - let incomingLaneColors: [Int?] - let parentEdges: [GitGraphEdge] - let labels: [GitGraphLabel] - - var id: String { commit.id } - var isMerge: Bool { commit.parentHashes.count > 1 } - var isRoot: Bool { commit.parentHashes.isEmpty } -} - -struct GitGraphLayout: Sendable { - let rows: [GitGraphRow] - let laneCount: Int - let hasMissingParents: Bool -} diff --git a/Sources/Lithe/Models/JavaDebugModels.swift b/Sources/Lithe/Models/Java/JavaDebugModels.swift similarity index 100% rename from Sources/Lithe/Models/JavaDebugModels.swift rename to Sources/Lithe/Models/Java/JavaDebugModels.swift diff --git a/Sources/Lithe/Models/JavaDiagnosticModels.swift b/Sources/Lithe/Models/Java/JavaDiagnosticModels.swift similarity index 100% rename from Sources/Lithe/Models/JavaDiagnosticModels.swift rename to Sources/Lithe/Models/Java/JavaDiagnosticModels.swift diff --git a/Sources/Lithe/Models/JavaNavigationModels.swift b/Sources/Lithe/Models/Java/JavaNavigationModels.swift similarity index 100% rename from Sources/Lithe/Models/JavaNavigationModels.swift rename to Sources/Lithe/Models/Java/JavaNavigationModels.swift diff --git a/Sources/Lithe/Models/Java/JavaRunModels.swift b/Sources/Lithe/Models/Java/JavaRunModels.swift new file mode 100644 index 00000000..f63d2dcf --- /dev/null +++ b/Sources/Lithe/Models/Java/JavaRunModels.swift @@ -0,0 +1,15 @@ +import LitheCoreContracts + +typealias RunOptions = LitheCoreContracts.RunOptions +typealias JavaRunOptions = LitheCoreContracts.RunOptions +typealias RunSession = LitheCoreContracts.RunSession +typealias RunPortConflict = LitheCoreContracts.RunPortConflict +typealias RunConfigurationCapabilities = LitheCoreContracts.RunConfigurationCapabilities +typealias MavenFrameworkKind = LitheCoreContracts.MavenFrameworkKind +typealias RunConfigurationKind = LitheCoreContracts.RunConfigurationKind +typealias RunConfigurationExecution = LitheCoreContracts.RunConfigurationExecution +typealias RunConfiguration = LitheCoreContracts.RunConfiguration +typealias JavaRunSession = LitheCoreContracts.RunSession +typealias JavaRunPortConflict = LitheCoreContracts.RunPortConflict +typealias JavaRunConfigurationKind = LitheCoreContracts.RunConfigurationKind +typealias JavaRunConfiguration = LitheCoreContracts.RunConfiguration diff --git a/Sources/Lithe/Models/Java/MavenModels.swift b/Sources/Lithe/Models/Java/MavenModels.swift new file mode 100644 index 00000000..c2fb3f78 --- /dev/null +++ b/Sources/Lithe/Models/Java/MavenModels.swift @@ -0,0 +1,9 @@ +import Foundation +import LitheCoreContracts + +typealias MavenProject = LitheCoreContracts.MavenProject +typealias MavenModule = LitheCoreContracts.MavenModule +typealias MavenProfile = LitheCoreContracts.MavenProfile +typealias MavenLifecyclePhase = LitheCoreContracts.MavenLifecyclePhase +typealias MavenIssueSeverity = LitheCoreContracts.MavenIssueSeverity +typealias MavenBuildIssue = LitheCoreContracts.MavenBuildIssue diff --git a/Sources/Lithe/Models/JavaRunModels.swift b/Sources/Lithe/Models/JavaRunModels.swift deleted file mode 100644 index ad7b52a9..00000000 --- a/Sources/Lithe/Models/JavaRunModels.swift +++ /dev/null @@ -1,419 +0,0 @@ -import Foundation - -/// Language-neutral options owned by the run subsystem. -/// -/// Java and Maven settings remain available as provider capabilities, but the -/// common argument/environment fields are usable by every process provider. -struct RunOptions: Codable, Hashable, Sendable { - struct JavaCapability: Codable, Hashable, Sendable { - var homePath = "" - var mavenExecutablePath = "" - var mavenJavaHomePath = "" - var vmArguments = "" - var activeMavenProfiles: Set = [] - - private enum CodingKeys: String, CodingKey { - case homePath, mavenExecutablePath, mavenJavaHomePath, vmArguments, activeMavenProfiles - } - - init( - homePath: String = "", - mavenExecutablePath: String = "", - mavenJavaHomePath: String = "", - vmArguments: String = "", - activeMavenProfiles: Set = [] - ) { - self.homePath = homePath - self.mavenExecutablePath = mavenExecutablePath - self.mavenJavaHomePath = mavenJavaHomePath - self.vmArguments = vmArguments - self.activeMavenProfiles = activeMavenProfiles - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - homePath = try container.decodeIfPresent(String.self, forKey: .homePath) ?? "" - mavenExecutablePath = try container.decodeIfPresent(String.self, forKey: .mavenExecutablePath) ?? "" - mavenJavaHomePath = try container.decodeIfPresent(String.self, forKey: .mavenJavaHomePath) ?? "" - vmArguments = try container.decodeIfPresent(String.self, forKey: .vmArguments) ?? "" - activeMavenProfiles = try container.decodeIfPresent(Set.self, forKey: .activeMavenProfiles) ?? [] - } - } - - var workingDirectoryPath = "" - var arguments = "" - var environment: [String: String] = [:] - var java = JavaCapability() - - init( - javaHomePath: String = "", - workingDirectoryPath: String = "", - vmArguments: String = "", - programArguments: String = "", - activeProfiles: Set = [], - mavenExecutablePath: String = "", - mavenJavaHomePath: String = "", - environment: [String: String] = [:] - ) { - self.workingDirectoryPath = workingDirectoryPath - arguments = programArguments - self.environment = environment - java = JavaCapability( - homePath: javaHomePath, - mavenExecutablePath: mavenExecutablePath, - mavenJavaHomePath: mavenJavaHomePath, - vmArguments: vmArguments, - activeMavenProfiles: activeProfiles - ) - } - - // Compatibility accessors keep Java debug and the one-release preference - // migration readable while new code uses the generic fields above. - var javaHomePath: String { - get { java.homePath } - set { java.homePath = newValue } - } - - var vmArguments: String { - get { java.vmArguments } - set { java.vmArguments = newValue } - } - - var mavenExecutablePath: String { - get { java.mavenExecutablePath } - set { java.mavenExecutablePath = newValue } - } - - var mavenJavaHomePath: String { - get { java.mavenJavaHomePath } - set { java.mavenJavaHomePath = newValue } - } - - var programArguments: String { - get { arguments } - set { arguments = newValue } - } - - var activeProfiles: Set { - get { java.activeMavenProfiles } - set { java.activeMavenProfiles = newValue } - } - - private enum CodingKeys: String, CodingKey { - case workingDirectoryPath - case arguments - case environment - case java - // Legacy UserDefaults keys used by JavaRunOptions. - case javaHomePath - case vmArguments - case programArguments - case activeProfiles - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - workingDirectoryPath = try container.decodeIfPresent(String.self, forKey: .workingDirectoryPath) ?? "" - arguments = try container.decodeIfPresent(String.self, forKey: .arguments) - ?? container.decodeIfPresent(String.self, forKey: .programArguments) - ?? "" - environment = try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:] - java = try container.decodeIfPresent(JavaCapability.self, forKey: .java) ?? JavaCapability( - homePath: try container.decodeIfPresent(String.self, forKey: .javaHomePath) ?? "", - vmArguments: try container.decodeIfPresent(String.self, forKey: .vmArguments) ?? "", - activeMavenProfiles: try container.decodeIfPresent(Set.self, forKey: .activeProfiles) ?? [] - ) - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(workingDirectoryPath, forKey: .workingDirectoryPath) - try container.encode(arguments, forKey: .arguments) - try container.encode(environment, forKey: .environment) - try container.encode(java, forKey: .java) - } -} - -/// Source compatibility for extensions and persisted data written before the -/// generic run-core migration. New run code should use `RunOptions`. -typealias JavaRunOptions = RunOptions - -struct RunSession: Identifiable, Hashable, Sendable { - let id: String - let configurationID: String - let title: String - var output: String - var isRunning: Bool - var exitCode: Int32? -} - -struct RunPortConflict: Identifiable, Hashable, Sendable { - let port: Int - let configurationNames: [String] - - var id: String { String(port) } - - var title: String { - "Port (port) is used by " + configurationNames.joined(separator: ", ") - } -} - -struct RunConfigurationCapabilities: OptionSet, Hashable, Sendable { - let rawValue: Int - - static let workingDirectory = Self(rawValue: 1 << 0) - static let arguments = Self(rawValue: 1 << 1) - static let environment = Self(rawValue: 1 << 2) - static let javaRuntime = Self(rawValue: 1 << 3) - static let javaVMArguments = Self(rawValue: 1 << 4) - static let mavenProfiles = Self(rawValue: 1 << 5) - static let jdwpDebug = Self(rawValue: 1 << 6) - - static let process: Self = [.workingDirectory, .arguments, .environment] -} - -/// A JVM framework launched by a Maven goal rather than by spawning a process. -/// -/// These share Spring Boot's capabilities exactly -- the core assembles the goal -/// and the property names its arguments travel under -- so they are one case -/// carrying the framework rather than three parallel cases. -enum MavenFrameworkKind: String, Hashable, Sendable, CaseIterable { - case springBoot - case quarkus - case micronaut - - /// The core provider this framework is reported as. - var provider: String { - switch self { - case .springBoot: "spring-boot.maven" - case .quarkus: "quarkus.maven" - case .micronaut: "micronaut.maven" - } - } - - var title: String { - switch self { - case .springBoot: "Spring Boot" - case .quarkus: "Quarkus" - case .micronaut: "Micronaut" - } - } - - /// Only Spring Boot's goal accepts a main class; Quarkus and Micronaut - /// resolve it from the build, so naming one would be ignored. - var namesMainClass: Bool { self == .springBoot } -} - -enum RunConfigurationKind: Hashable, Identifiable, Sendable { - case currentFile - case javaMain - case mavenModule - /// A JVM framework whose service is started by a Maven goal. - case mavenFramework(MavenFrameworkKind) - /// Any provider this build has no first-class handling for. Carrying the - /// raw provider keeps unknown ecosystems visible and runnable instead of - /// silently dropping them at the decode boundary. - case process(provider: String) - - static let springBoot: Self = .mavenFramework(.springBoot) - - init?(rawValue: String) { - switch rawValue { - case "currentFile": self = .currentFile - case "springBoot": self = .springBoot - case "javaMain": self = .javaMain - case "mavenModule": self = .mavenModule - case "quarkus": self = .mavenFramework(.quarkus) - case "micronaut": self = .mavenFramework(.micronaut) - default: return nil - } - } - - var id: String { - switch self { - case .currentFile: "currentFile" - case .javaMain: "javaMain" - case .mavenModule: "mavenModule" - case .mavenFramework(let framework): framework.rawValue - case .process(let provider): provider - } - } - - var providerID: String { - switch self { - case .currentFile, .javaMain: "java" - case .mavenModule, .mavenFramework: "maven" - case .process(let provider): provider.split(separator: ".").first.map(String.init) ?? provider - } - } - - /// The framework whose Maven goal starts this configuration, if any. - var mavenFramework: MavenFrameworkKind? { - if case .mavenFramework(let framework) = self { return framework } - return nil - } - - /// True for the Maven-backed kinds that support JDWP debugging and Maven - /// profiles. Callers should branch on this rather than enumerating cases. - var isMavenBacked: Bool { - self == .mavenModule || mavenFramework != nil - } - - var capabilities: RunConfigurationCapabilities { - switch self { - case .currentFile, .javaMain: - return [.workingDirectory, .arguments, .environment, .javaRuntime, .javaVMArguments, .jdwpDebug] - case .mavenModule, .mavenFramework: - return [.workingDirectory, .arguments, .environment, .javaRuntime, .javaVMArguments, .mavenProfiles, .jdwpDebug] - case .process: - return .process - } - } - - var title: String { - switch self { - case .currentFile: "Current File" - case .javaMain: "Java Application" - case .mavenModule: "Maven Module" - case .mavenFramework(let framework): framework.title - case .process(let provider): Self.displayTitle(for: provider) - } - } - - var systemImage: String { - switch self { - case .currentFile: "doc.text" - case .javaMain: "cup.and.heat.waves" - case .mavenModule: "shippingbox" - // All three are long-running JVM services started the same way, so they - // share one symbol rather than implying a difference that is not there. - case .mavenFramework: "leaf" - case .process(let provider): Self.symbol(for: provider) - } - } - - /// Providers are `namespace.name`. Falling back to a title-cased namespace - /// means an ecosystem this build has never heard of still reads as a label - /// rather than as a raw identifier. - private static func displayTitle(for provider: String) -> String { - let namespace = provider.split(separator: ".").first.map(String.init) ?? provider - switch namespace { - case "npm": return "Node" - case "compose": return "Docker Compose" - case "python": return "Python" - case "go": return "Go" - case "cargo": return "Rust" - case "make": return "Make" - case "just": return "Just" - case "procfile": return "Procfile" - default: return namespace.capitalized - } - } - - private static func symbol(for provider: String) -> String { - switch provider.split(separator: ".").first.map(String.init) { - case "compose": return "square.stack.3d.up" - case "npm", "python", "go", "cargo": return "chevron.left.forwardslash.chevron.right" - default: return "terminal" - } - } -} - -enum RunConfigurationExecution: String, CaseIterable, Hashable, Sendable { - case application - case service - case task - case group - - static let displayOrder: [Self] = [.service, .application, .task, .group] - - var sectionTitle: String { - switch self { - case .application: "Applications" - case .service: "Services" - case .task: "Tasks" - case .group: "Groups" - } - } -} - -struct RunConfiguration: Identifiable, Hashable, Sendable { - static let currentFileID = "current-file" - - let id: String - let name: String - let kind: RunConfigurationKind - let execution: RunConfigurationExecution - let modulePath: String? - let mainClass: String? - - var usesCurrentEditorFile: Bool { kind == .currentFile } - - init( - id: String, - name: String, - kind: RunConfigurationKind, - execution: RunConfigurationExecution? = nil, - modulePath: String?, - mainClass: String? - ) { - self.id = id - self.name = name - self.kind = kind - self.execution = execution ?? Self.defaultExecution(for: kind) - self.modulePath = modulePath - self.mainClass = mainClass - } - - var systemImage: String { kind.systemImage } - - /// Current File is a language-neutral entry. Java keeps its legacy JDK - /// capability, while other Providers expose only the shared process - /// fields in the configuration editor. - func effectiveCapabilities( - for currentFileURL: URL?, - catalog: LanguageProviderCatalog = .standard - ) -> RunConfigurationCapabilities { - guard kind == .currentFile else { return kind.capabilities } - guard let currentFileURL, - let descriptor = catalog.provider(for: currentFileURL) else { - // An unknown extension is still a language-neutral Current File - // entry. Showing JDK/Maven controls here would make an unsupported - // language look like a Java project and leak provider assumptions - // into the shared editor. - return .process - } - guard descriptor.id == "java" else { - return .process - } - return kind.capabilities - } - - static var currentFile: RunConfiguration { - RunConfiguration( - id: currentFileID, - name: "Current File", - kind: .currentFile, - execution: .application, - modulePath: nil, - mainClass: nil - ) - } - - private static func defaultExecution( - for kind: RunConfigurationKind - ) -> RunConfigurationExecution { - switch kind { - case .mavenFramework: .service - case .currentFile, .javaMain, .process: .application - case .mavenModule: .task - } - } -} - -// Temporary source compatibility at the Java-debug boundary. These aliases do -// not own behavior; the canonical models above are language neutral. -typealias JavaRunSession = RunSession -typealias JavaRunPortConflict = RunPortConflict -typealias JavaRunConfigurationKind = RunConfigurationKind -typealias JavaRunConfiguration = RunConfiguration diff --git a/Sources/Lithe/Models/MavenModels.swift b/Sources/Lithe/Models/MavenModels.swift deleted file mode 100644 index 5e89379f..00000000 --- a/Sources/Lithe/Models/MavenModels.swift +++ /dev/null @@ -1,114 +0,0 @@ -import Foundation - -struct MavenProject: Identifiable, Hashable, Sendable { - let rootURL: URL - let pomURL: URL - let groupID: String? - let artifactID: String - let version: String? - let packaging: String - let modules: [MavenModule] - let profiles: [MavenProfile] - let hasWrapper: Bool - - var id: String { rootURL.path } - var displayName: String { artifactID.isEmpty ? rootURL.lastPathComponent : artifactID } - var isMultiModule: Bool { !modules.isEmpty } - var allModules: [MavenModule] { - modules + modules.flatMap { $0.allModules } - } -} - -struct MavenModule: Identifiable, Hashable, Sendable { - let relativePath: String - let url: URL - let groupID: String? - let artifactID: String - let version: String? - let packaging: String - let modules: [MavenModule] - - var id: String { relativePath } - var displayName: String { artifactID.isEmpty ? relativePath : artifactID } - var allModules: [MavenModule] { - modules + modules.flatMap { $0.allModules } - } -} - -struct MavenProfile: Identifiable, Hashable, Sendable { - let id: String - let isActiveByDefault: Bool -} - -enum MavenLifecyclePhase: String, CaseIterable, Identifiable, Sendable { - case clean - case validate - case compile - case test - case packagePhase = "package" - case verify - case install - case site - case deploy - - var id: String { rawValue } - - var title: String { - switch self { - case .clean: "clean" - case .validate: "validate" - case .compile: "compile" - case .test: "test" - case .packagePhase: "package" - case .verify: "verify" - case .install: "install" - case .site: "site" - case .deploy: "deploy" - } - } - - var systemImage: String { - switch self { - case .clean: "trash" - case .validate: "checkmark.seal" - case .compile: "hammer" - case .test: "checkmark.circle" - case .packagePhase: "shippingbox" - case .verify: "checkmark.shield" - case .install: "arrow.down.to.line" - case .site: "globe" - case .deploy: "arrow.up.to.line" - } - } -} - -enum MavenIssueSeverity: String, Sendable { - case error - case warning - case info - - var systemImage: String { - switch self { - case .error: "xmark.octagon.fill" - case .warning: "exclamationmark.triangle.fill" - case .info: "info.circle.fill" - } - } -} - -struct MavenBuildIssue: Identifiable, Hashable, Sendable { - let id: String - let fileURL: URL? - let line: Int? - let column: Int? - let severity: MavenIssueSeverity - let message: String - - var locationTitle: String { - guard let fileURL else { return "Build output" } - let location = [line, column].compactMap { value in - value.map(String.init) - }.joined(separator: ":") - return location.isEmpty ? fileURL.lastPathComponent : fileURL.lastPathComponent + ":" + location - } -} diff --git a/Sources/Lithe/Models/ProjectReplacementModels.swift b/Sources/Lithe/Models/ProjectReplacementModels.swift deleted file mode 100644 index 551478cc..00000000 --- a/Sources/Lithe/Models/ProjectReplacementModels.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -struct ProjectReplacementMatch: Identifiable, Hashable, Sendable { - let line: Int - let before: String - let after: String - let occurrenceCount: Int - - var id: String { "\(line):\(before):\(after)" } -} - -struct ProjectReplacementFile: Identifiable, Hashable, Sendable { - let url: URL - let relativePath: String - let matches: [ProjectReplacementMatch] - let replacementText: String? - - init( - url: URL, - relativePath: String, - matches: [ProjectReplacementMatch], - replacementText: String? = nil - ) { - self.url = url - self.relativePath = relativePath - self.matches = matches - self.replacementText = replacementText - } - - var id: String { url.path } - var matchCount: Int { matches.reduce(0) { $0 + $1.occurrenceCount } } -} diff --git a/Sources/Lithe/Models/ProjectRuntimeModels.swift b/Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift similarity index 100% rename from Sources/Lithe/Models/ProjectRuntimeModels.swift rename to Sources/Lithe/Models/Runtime/ProjectRuntimeModels.swift diff --git a/Sources/Lithe/Models/SearchRelevance.swift b/Sources/Lithe/Models/Search/SearchRelevance.swift similarity index 99% rename from Sources/Lithe/Models/SearchRelevance.swift rename to Sources/Lithe/Models/Search/SearchRelevance.swift index 26943dca..7ce5f9e8 100644 --- a/Sources/Lithe/Models/SearchRelevance.swift +++ b/Sources/Lithe/Models/Search/SearchRelevance.swift @@ -1,4 +1,5 @@ import Foundation +import LitheSearchModule /// Search Everywhere 的 All 页把文件、类、符号混在一张列表里,需要一个共同的 /// 相关度标准来排序,否则只能按 kind 分段展示(IDEA 是混排的)。 diff --git a/Sources/Lithe/Models/AppSettings.swift b/Sources/Lithe/Models/Settings/AppSettings.swift similarity index 99% rename from Sources/Lithe/Models/AppSettings.swift rename to Sources/Lithe/Models/Settings/AppSettings.swift index 9ffa20b4..d9df2616 100644 --- a/Sources/Lithe/Models/AppSettings.swift +++ b/Sources/Lithe/Models/Settings/AppSettings.swift @@ -1,4 +1,6 @@ import Foundation +import LitheCoreContracts +import LitheGitModule @MainActor final class AppSettings: ObservableObject { diff --git a/Sources/Lithe/Models/Workspace/FileNode.swift b/Sources/Lithe/Models/Workspace/FileNode.swift new file mode 100644 index 00000000..9b75f005 --- /dev/null +++ b/Sources/Lithe/Models/Workspace/FileNode.swift @@ -0,0 +1,10 @@ +import LitheCoreContracts + +typealias FileNode = LitheCoreContracts.FileNode +typealias WorkspaceSnapshot = LitheCoreContracts.WorkspaceSnapshot + +extension FileNode { + var iconKind: LitheIconKind { + LitheIcons.kind(for: url, isDirectory: isDirectory, isInsideSourceRoot: isInsideSourceRoot) + } +} diff --git a/Sources/Lithe/Models/ProjectSessionManager.swift b/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift similarity index 100% rename from Sources/Lithe/Models/ProjectSessionManager.swift rename to Sources/Lithe/Models/Workspace/ProjectSessionManager.swift diff --git a/Sources/Lithe/Models/RecentProject.swift b/Sources/Lithe/Models/Workspace/RecentProject.swift similarity index 100% rename from Sources/Lithe/Models/RecentProject.swift rename to Sources/Lithe/Models/Workspace/RecentProject.swift diff --git a/Sources/Lithe/Models/WorkspaceTextFilePolicy.swift b/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift similarity index 100% rename from Sources/Lithe/Models/WorkspaceTextFilePolicy.swift rename to Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift diff --git a/Sources/Lithe/Platform/MacOS/AI/MacAIProviderCredentialResolver.swift b/Sources/Lithe/Platform/MacOS/AI/MacAIProviderCredentialResolver.swift index ce424efb..c251bd8b 100644 --- a/Sources/Lithe/Platform/MacOS/AI/MacAIProviderCredentialResolver.swift +++ b/Sources/Lithe/Platform/MacOS/AI/MacAIProviderCredentialResolver.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts final class MacAIProviderCredentialResolver: AIProviderCredentialResolver, @unchecked Sendable { private let localStore: any SecureStore diff --git a/Sources/Lithe/Platform/MacOS/AI/MacClaudeConfigurationSource.swift b/Sources/Lithe/Platform/MacOS/AI/MacClaudeConfigurationSource.swift index 3b24f2b7..bec406b1 100644 --- a/Sources/Lithe/Platform/MacOS/AI/MacClaudeConfigurationSource.swift +++ b/Sources/Lithe/Platform/MacOS/AI/MacClaudeConfigurationSource.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts final class MacClaudeConfigurationSource: ClaudeConfigurationSource, @unchecked Sendable { private let fileManager: FileManager diff --git a/Sources/Lithe/Platform/MacOS/AI/MacCodexConfigurationSource.swift b/Sources/Lithe/Platform/MacOS/AI/MacCodexConfigurationSource.swift index 2d154e6d..0aff83f8 100644 --- a/Sources/Lithe/Platform/MacOS/AI/MacCodexConfigurationSource.swift +++ b/Sources/Lithe/Platform/MacOS/AI/MacCodexConfigurationSource.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts final class MacCodexConfigurationSource: CodexConfigurationSource, @unchecked Sendable { private let fileManager: FileManager diff --git a/Sources/Lithe/Platform/MacOS/AI/MacURLSessionTransport.swift b/Sources/Lithe/Platform/MacOS/AI/MacURLSessionTransport.swift index c0f0c8a1..0c9850f1 100644 --- a/Sources/Lithe/Platform/MacOS/AI/MacURLSessionTransport.swift +++ b/Sources/Lithe/Platform/MacOS/AI/MacURLSessionTransport.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts import Network struct MacURLSessionTransport: AIHTTPTransport { diff --git a/Sources/Lithe/Platform/MacOS/Debug/MacProcessDebugAdapterTransport.swift b/Sources/Lithe/Platform/MacOS/Debug/MacProcessDebugAdapterTransport.swift new file mode 100644 index 00000000..849bae1e --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Debug/MacProcessDebugAdapterTransport.swift @@ -0,0 +1,57 @@ +import Foundation +import LitheCoreContracts + +/// Adapts a macOS child process to the platform-neutral DAP transport contract. +@MainActor +final class MacProcessDebugAdapterTransport: DebugAdapterTransport { + private let executableURL: URL + private let arguments: [String] + private let environment: [String: String] + private let process: any RawProcessSession + + var onData: ((Data) -> Void)? + var onErrorOutput: ((Data) -> Void)? + var onTermination: ((Int) -> Void)? + + 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?.onData?(data) } + } + process.onError = { [weak self] data in + Task { @MainActor [weak self] in self?.onErrorOutput?(data) } + } + process.onTermination = { [weak self] exitCode in + Task { @MainActor [weak self] in self?.onTermination?(Int(exitCode)) } + } + } + + var isRunning: Bool { process.isRunning } + + func start(rootURL: URL) throws { + try process.start(ProcessRequest( + operationID: UUID().uuidString, + executablePath: executableURL.path, + arguments: arguments, + workingDirectory: rootURL.standardizedFileURL.path, + environment: environment, + keepsStandardInputOpen: true + )) + } + + func send(_ data: Data) throws { + try process.send(data) + } + + func stop() { + process.stop() + } +} diff --git a/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift b/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift index 8508aad5..52fe1967 100644 --- a/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift +++ b/Sources/Lithe/Platform/MacOS/Debug/MacServerDebugAdapterTransport.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts import Network struct ServerDebugAdapterProcessLaunch { diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 17bb8d4b..56057cfc 100644 --- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1,4 +1,17 @@ import Foundation +import LitheAIAssistanceModule +import LitheApplicationKernel +import LitheCoreContracts +import LitheDatabaseModule +import LitheDebugModule +import LitheExecutionModule +import LitheGitModule +import LitheLocalHistoryModule +import LitheLanguageIntelligenceModule +import LitheModuleAPI +import LitheSearchModule +import LitheTerminalModule +import LitheWorkspaceModule private struct MacDirectoryWatcherFactory: DirectoryWatcherFactory { func make( @@ -23,195 +36,354 @@ private struct MacDirectoryWatcherFactory: DirectoryWatcherFactory { final class MacServiceContainer { let services: AppServices let runConfigurationStore: MacRunConfigurationStore + let moduleLifecycleCoordinator: ModuleLifecycleCoordinator init( store: any KeyValueStore, settings: AppSettings, - processRegistry: ManagedProcessRegistry = ManagedProcessRegistry() + processRegistry: ManagedProcessRegistry = ManagedProcessRegistry(), + moduleLaunchMode: ModuleLaunchMode = .normal, + moduleStore providedModuleStore: MacModuleConfigurationStore? = nil, + pluginRuntimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil ) { let rustCore = RustCoreBridge() let javaMavenOperations = RustJavaMavenOperations(core: rustCore) let fileStorage = MacFileStorage() - runConfigurationStore = MacRunConfigurationStore( + let runConfigurationStore = MacRunConfigurationStore( core: rustCore, storage: fileStorage, preferences: store ) + self.runConfigurationStore = runConfigurationStore let fileOperations = MacWorkspaceFileOperations() let processRunner = MacProcessRunner() + let secureStore = MacLocalSecretStore() + let databaseSecureStore = MacKeychainSecureStore( + service: "app.lithe.desktop.database", + legacyStore: secureStore + ) + let codexConfigurationSource = MacCodexConfigurationSource() + let claudeConfigurationSource = MacClaudeConfigurationSource() + let aiConfigurationSources: [any AIConfigurationSource] = [ + codexConfigurationSource, + claudeConfigurationSource + ] + let credentialResolver = MacAIProviderCredentialResolver( + localStore: secureStore, + configurationSources: aiConfigurationSources + ) + let pluginHostServices = MacPluginHostServiceRegistry() + pluginHostServices.register( + MacLanguageExecutionHost(processRegistry: processRegistry), + for: .languageExecution + ) let databaseSidecarURL = MacDatabaseSidecarLocator(fileStorage: fileStorage).executableURL() - let databaseOperations = DatabaseSidecarService(processRunner: processRunner, executableURL: databaseSidecarURL) - let databaseRecoveryStore = MacDatabaseRecoveryStore(fileStorage: fileStorage) + let moduleStore = providedModuleStore ?? MacModuleConfigurationStore(store: store) + let moduleRuntime = ModuleRuntime( + configurationStore: moduleStore, + recoveryStore: moduleStore, + launchMode: moduleLaunchMode + ) + moduleLifecycleCoordinator = ModuleLifecycleCoordinator(runtime: moduleRuntime) + let pluginPackageStore = MacPluginPackageStore(fileStorage: fileStorage) + let pluginStartup = MacPluginStartupLoader( + packageStore: pluginPackageStore, + nativeLoader: MacNativePluginLoader( + hostContext: PluginHostContext(resolver: pluginHostServices) + ), + runtimeRecovery: pluginRuntimeRecovery + ).load(policy: MacPluginLoadPolicy( + configurationStore: moduleStore, + recoveryStore: moduleStore, + launchMode: moduleLaunchMode + )) + let moduleRegistry = ModuleRegistry( + runtime: moduleRuntime, + pluginManifests: BuiltInPluginCatalog.manifests + pluginStartup.activeNativeManifests + ) + do { + try moduleRegistry.register(ModuleFactory(manifest: WorkspaceFoundationModule.moduleManifest) { + WorkspaceFoundationModule(makeGraph: { + let owner = WorkspaceModuleResourceOwner() + return owner + }) + }) + try moduleRegistry.register(ModuleFactory( + manifest: AIAssistanceModule.moduleManifest, + contributions: AIAssistanceModule.moduleContributions + ) { + AIAssistanceModule( + transportFactory: { MacURLSessionTransport() }, + credentialResolver: credentialResolver + ) + }) + try moduleRegistry.register(ModuleFactory(manifest: DatabaseModule.moduleManifest, contributions: DatabaseModule.moduleContributions) { + DatabaseModule( + processRunner: processRunner, + executableURL: databaseSidecarURL, + preferenceStore: MacDatabasePreferenceStore(store: store), + secureStore: MacKeychainSecureStore( + service: "app.lithe.desktop.database", + legacyStore: MacLocalSecretStore() + ), + recoveryStore: MacDatabaseRecoveryStore(fileStorage: fileStorage), + fileStorage: fileStorage + ) + }) + try moduleRegistry.register(ModuleFactory(manifest: TerminalModule.moduleManifest, contributions: TerminalModule.moduleContributions) { + TerminalModule( + terminalFactory: { MacTerminalTransport() }, + shellDiscovery: { MacTerminalTransport.availableShells() } + ) + }) + } catch { + preconditionFailure("Invalid built-in module graph: \(error.localizedDescription)") + } let runtimeService = ProjectRuntimeService( runtimeLocator: MacRuntimeLocator(), store: store, toolDiscovery: MacRuntimeToolDiscovery() ) - let languageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore) + let rustLanguageProviderCatalogSource = RustLanguageProviderCatalogSource(core: rustCore) + // Installation owns the process-backed language boundary even when a + // package is disabled, quarantined, or failed to load. Only the active + // manifests below contribute factories; keeping static ownership here + // prevents the host from silently restoring its legacy process path. + let installedPluginManifests = pluginStartup.installedManifests + let installedLanguageSupports = pluginStartup.installedLanguageSupports + let languageProviderCatalogSource = PluginLanguageProviderCatalogSource( + base: rustLanguageProviderCatalogSource, + languageSupports: installedLanguageSupports + ) let languageProviderCatalogSnapshot = languageProviderCatalogSource.load() let languageProviderCatalog = languageProviderCatalogSnapshot.catalog - let languageServerTools = LanguageServerToolService( - runtimeService: runtimeService, - processRunner: processRunner, - store: store - ) + let pluginLanguageIDs = Set(installedLanguageSupports.map(\.id)) // 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 + catalog: languageProviderCatalog, + extensionRequiredProviderIDs: pluginLanguageIDs ) - Task { - for descriptor in languageProviderCatalog.descriptors where - descriptor.capabilities.contains(.languageServer) { - await languageServerTools.refreshCandidates(for: descriptor) - } - } - 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() - ) - ) - }, - "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() - ) - ) - } - ] let debugLaunches = Dictionary( uniqueKeysWithValues: languagePackDefinitions.packs.compactMap { pack in pack.debugAdapterLaunch.map { (pack.descriptor.id, $0) } } ) - let languageToolingRuntimeFactory = StdioLanguageProviderRuntimeFactory( - runtimeService: runtimeService, - processFactory: { MacRawProcessSession() }, - languageServerCore: rustCore, - languageServerExecutableResolver: { descriptor in - languageServerTools.executableURL(for: descriptor) - }, - // JDT LS runs on a JDK the Rust runtime cannot discover for itself. - languageServerRuntimeResolver: { descriptor in - descriptor.id == "java" - ? runtimeService.configuredJavaExecutableURL( - overridePath: settings.javaLanguageServerJDKPath - ) - : nil - }, - languageServerCacheDirectory: fileStorage - .cacheDirectory() - .appendingPathComponent("Lithe/language-servers", isDirectory: true), - processRegistry: processRegistry, - 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 languagePackRegistry = languagePackDefinitions let runToolchainRegistry = languagePackRegistry.toolchainRegistry - let languageToolingSessions = LanguageToolingSessionManager( - catalog: languagePackRegistry.catalog, - runtimes: languagePackRegistry.toolingRuntimes, - runtimeFactory: languageToolingRuntimeFactory, - core: rustCore - ) - let testExecutableResolver = RunExecutableResolver( - runtimeService: runtimeService, - toolchainRegistry: runToolchainRegistry, - metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner) - ) - let languageTestService = LanguageTestService( - registry: languagePackRegistry, - executableResolver: testExecutableResolver, - processFactory: { MacStreamingProcess(processRegistry: processRegistry) } - ) - - let mavenService = MavenService( - runtimeService: runtimeService, - process: MacStreamingProcess(processRegistry: processRegistry), - javaMavenOperations: javaMavenOperations - ) - let runService = RunService( - runtimeService: runtimeService, - process: MacStreamingProcess(processRegistry: processRegistry), - processFactory: { MacStreamingProcess(processRegistry: processRegistry) }, - fileStorage: fileStorage, - preferences: store, - javaMavenOperations: javaMavenOperations, - runConfigurationOperations: runConfigurationStore, - executableResolver: RunExecutableResolver( - runtimeService: runtimeService, - toolchainRegistry: runToolchainRegistry, - metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner) - ), - languagePackRegistry: languagePackRegistry - ) - let javaDebugService = JavaDebugService( - runtimeService: runtimeService, - processFactory: { MacStreamingProcess(processRegistry: processRegistry) }, - fileStorage: fileStorage, - javaMavenOperations: javaMavenOperations, - runConfigurationOperations: runConfigurationStore - ) + do { + try moduleRegistry.register(ModuleFactory(manifest: LanguageIntelligenceModule.moduleManifest, contributions: LanguageIntelligenceModule.moduleContributions) { + LanguageIntelligenceModule(makeGraph: { + let tools = LanguageServerToolService( + runtimeService: runtimeService, + commandRunner: processRunner, + settingsStore: MacLanguageToolSettingsStore(store: store) + ) + let runtimeFactory = StdioLanguageProviderRuntimeFactory( + runtimeService: runtimeService, + languageServerCore: rustCore, + languageServerExecutableResolver: { tools.executableURL(for: $0) }, + languageServerRuntimeResolver: { descriptor in + descriptor.id == "java" + ? runtimeService.configuredJavaExecutableURL( + overridePath: settings.javaLanguageServerJDKPath + ) + : nil + }, + languageServerCacheDirectory: fileStorage.cacheDirectory() + .appendingPathComponent("Lithe/language-servers", isDirectory: true), + processRegistry: processRegistry + ) + let runtimes = languagePackDefinitions.packs + .filter { !pluginLanguageIDs.contains($0.descriptor.id) } + .compactMap { + runtimeFactory.makeRuntime(for: $0.descriptor) + } + let registry = LanguagePackRegistry.standard( + catalog: languageProviderCatalog, + runtimes: runtimes, + extensionRequiredProviderIDs: pluginLanguageIDs + ) + let sessions = LanguageToolingSessionManager( + catalog: registry.catalog, + runtimes: registry.toolingRuntimes, + runtimeFactory: runtimeFactory, + builtinCore: rustCore, + extensionRequiredProviderIDs: pluginLanguageIDs + ) + let graph = LanguageIntelligenceFeatureGraph( + sessions: sessions, + tools: tools + ) + return graph + }) + }) + } catch { + preconditionFailure("Invalid language module graph: \(error.localizedDescription)") + } + do { + try moduleRegistry.register(ModuleFactory(manifest: ExecutionModule.moduleManifest, contributions: ExecutionModule.moduleContributions) { + ExecutionModule(makeGraph: { + let executableResolver = RunExecutableResolver( + runtimeService: runtimeService, + toolchainRegistry: runToolchainRegistry, + metadataResolver: ProcessRunToolchainMetadataResolver(processRunner: processRunner) + ) + let graph = ExecutionFeatureGraph( + maven: MavenService( + runtimeService: runtimeService, + process: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution), + mavenOperations: javaMavenOperations + ), + run: RunService( + runtime: runtimeService, + process: MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution), + processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution) }, + fileAccess: MacRunFileAccess(storage: fileStorage), + preferences: MacRunPreferenceStore(store: store), + serverPortParser: javaMavenOperations, + runConfigurationOperations: runConfigurationStore, + executableResolver: executableResolver, + languageProviderCatalog: languagePackRegistry.catalog, + languageRunProviders: languagePackRegistry.runProviders, + extensionRequiredLanguageIDs: pluginLanguageIDs + ), + tests: LanguageTestService( + catalog: languagePackRegistry.catalog, + registry: languagePackRegistry.testProviders, + executableResolver: executableResolver, + processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .execution) }, + extensionRequiredLanguageIDs: pluginLanguageIDs + ) + ) + return graph + }) + }) + try moduleRegistry.register(ModuleFactory(manifest: DebugModule.moduleManifest, contributions: DebugModule.moduleContributions) { + DebugModule(makeGraph: { + let debugFactories: [String: () -> (any DebugAdapterSession)?] = [ + "go": { + guard let executable = runtimeService.executableOnPath("dlv") else { return nil } + return DebugAdapterProtocolSession( + adapterID: "go", + transport: MacDlvDebugAdapterTransport( + executableURL: executable, + environment: runtimeService.processEnvironment(), + process: MacRawProcessSession() + ) + ) + }, + "node": { + guard let executable = runtimeService.executableOnPath("node") else { return nil } + let environment = runtimeService.processEnvironment() + return DebugAdapterProtocolSession( + adapterID: "pwa-node", + transport: MacNodeDebugAdapterTransport( + nodeExecutableURL: executable, + locator: MacJavaScriptDebugAdapterLocator( + environment: environment, + executableOnPath: { runtimeService.executableOnPath($0) } + ), + process: MacRawProcessSession() + ) + ) + } + ] + let debugRuntimeFactory = DebugAdapterRuntimeFactory( + runtimeService: runtimeService, + transportFactory: { executableURL, arguments, environment in + MacProcessDebugAdapterTransport( + executableURL: executableURL, + arguments: arguments, + environment: environment, + process: MacRawProcessSession() + ) + }, + launches: debugLaunches, + sessionFactories: debugFactories + ) + let adapterSessions = DebugAdapterSessionManager( + providers: languageProviderCatalog.debugProviders, + makeSession: { descriptor, rootURL in + debugRuntimeFactory.makeSession( + for: descriptor, + rootURL: rootURL + ) + } + ) + let graph = DebugFeatureGraph( + java: JavaDebugService( + runtimeService: runtimeService, + processFactory: { MacStreamingProcess(processRegistry: processRegistry, moduleID: .debug) }, + fileStorage: fileStorage, + javaMavenOperations: javaMavenOperations, + runConfigurationOperations: runConfigurationStore + ), + adapterSessions: adapterSessions + ) + return graph + }) + }) + } catch { + preconditionFailure("Invalid execution/debug module graph: \(error.localizedDescription)") + } let gitOperations = RustGitOperations(core: rustCore) let workspaceOperations = RustWorkspaceOperations(core: rustCore) let localHistoryOperations = RustLocalHistoryOperations(core: rustCore) let markdownRenderer = RustMarkdownRendering(core: rustCore) let markdownImageImporter = MarkdownImageImportService(storage: fileStorage) - let gitService = GitService(operations: gitOperations) - let shelveService = ShelveService(storage: fileStorage) - let secureStore = MacLocalSecretStore() - let databaseSecureStore = MacKeychainSecureStore( - service: "app.lithe.desktop.database", - legacyStore: secureStore - ) - let codexConfigurationSource = MacCodexConfigurationSource() - let claudeConfigurationSource = MacClaudeConfigurationSource() - let aiConfigurationSources: [any AIConfigurationSource] = [ - codexConfigurationSource, - claudeConfigurationSource - ] - let credentialResolver = MacAIProviderCredentialResolver( - localStore: secureStore, - configurationSources: aiConfigurationSources - ) - let commitMessageGenerator = CommitMessageGenerationService( - transport: MacURLSessionTransport(), - credentialResolver: credentialResolver - ) + do { + try moduleRegistry.register(ModuleFactory(manifest: GitModule.moduleManifest, contributions: GitModule.moduleContributions) { + GitModule( + operations: gitOperations, + shelfStorage: MacGitShelfStorage(storage: fileStorage) + ) + }) + try moduleRegistry.register(ModuleFactory(manifest: SearchModule.moduleManifest, contributions: SearchModule.moduleContributions) { + SearchModule(operations: workspaceOperations) + }) + try moduleRegistry.register(ModuleFactory(manifest: HistoryModule.moduleManifest, contributions: HistoryModule.moduleContributions) { + HistoryModule( + workspaceAccess: MacLocalHistoryWorkspaceAccess(workspaceOperations: workspaceOperations, fileOperations: fileOperations), + storage: MacLocalHistoryStorage(storage: fileStorage), + operations: localHistoryOperations + ) + }) + for pluginID in pluginStartup.factoriesByPlugin.keys.sorted() { + for factory in pluginStartup.factoriesByPlugin[pluginID] ?? [] { + try moduleRegistry.register(factory) + } + } + try moduleRegistry.validate() + } catch { + preconditionFailure("Invalid workspace module graph: \(error.localizedDescription)") + } // Keep binary formats default-denied. Future format support must be // registered explicitly at this composition boundary. let binaryFileViewerRegistry = BinaryFileViewerRegistry() + let pluginManager = MacPluginManager( + packageStore: pluginPackageStore, + moduleRuntime: moduleRuntime, + configurationStore: moduleStore, + launchMode: moduleLaunchMode, + startup: pluginStartup + ) + let pluginCatalog: ValidatedPluginCatalog + do { + pluginCatalog = try ValidatedPluginCatalog( + manifests: BuiltInPluginCatalog.manifests + installedPluginManifests, + hostVersion: BuiltInPluginCatalog.hostVersion + ) + } catch { + preconditionFailure("Invalid installed plugin catalog: \(error.localizedDescription)") + } services = AppServices( + moduleRuntime: moduleRuntime, + pluginManager: pluginManager, + pluginCatalog: pluginCatalog, languageProviderCatalogSource: languageProviderCatalogSource, languageProviderCatalogSnapshot: languageProviderCatalogSnapshot, - languagePacks: languagePackRegistry, - runToolchainRegistry: runToolchainRegistry, - languageToolingSessions: languageToolingSessions, - languageServerTools: languageServerTools, - languageTestService: languageTestService, workspaceOperations: workspaceOperations, - localHistoryOperations: localHistoryOperations, javaMavenOperations: javaMavenOperations, markdownRenderer: markdownRenderer, markdownImageImporter: markdownImageImporter, @@ -220,14 +392,7 @@ final class MacServiceContainer { fileOperations: fileOperations, binaryFileViewerRegistry: binaryFileViewerRegistry, projectRuntimeService: runtimeService, - mavenService: mavenService, - runService: runService, - javaDebugService: javaDebugService, - gitService: gitService, - databaseOperations: databaseOperations, - databaseRecoveryStore: databaseRecoveryStore, - shelveService: shelveService, - commitMessageGenerator: commitMessageGenerator, + gitWatchContextProvider: RustGitWatchContextProvider(core: rustCore), secureStore: secureStore, databaseSecureStore: databaseSecureStore, credentialResolver: credentialResolver, @@ -235,11 +400,11 @@ final class MacServiceContainer { recentProjectsStore: RecentProjectsStore(store: store), workspaceSessionStore: WorkspaceSessionStore(store: store), workbenchLayoutStore: WorkbenchLayoutStore(store: store), - terminalFactory: { MacTerminalTransport() }, - shellDiscovery: { MacTerminalTransport.availableShells() }, directoryWatcherFactory: MacDirectoryWatcherFactory(), platformUI: MacPlatformUI(), shortcutDetectorFactory: MacShortcutDetectorFactory() ) + moduleLifecycleCoordinator.start() + Task { try? await moduleRegistry.startEagerModules() } } } diff --git a/Sources/Lithe/Platform/MacOS/Persistence/MacDatabaseRecoveryStore.swift b/Sources/Lithe/Platform/MacOS/Persistence/MacDatabaseRecoveryStore.swift index c5474ef2..387e4ba2 100644 --- a/Sources/Lithe/Platform/MacOS/Persistence/MacDatabaseRecoveryStore.swift +++ b/Sources/Lithe/Platform/MacOS/Persistence/MacDatabaseRecoveryStore.swift @@ -1,6 +1,7 @@ import Compression import CryptoKit import Foundation +import LitheDatabaseModule final class MacDatabaseRecoveryStore: DatabaseRecoveryStoring, @unchecked Sendable { private static let executionLogLock = NSRecursiveLock() diff --git a/Sources/Lithe/Platform/MacOS/Persistence/MacLanguageToolSettingsStore.swift b/Sources/Lithe/Platform/MacOS/Persistence/MacLanguageToolSettingsStore.swift new file mode 100644 index 00000000..b67b086f --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Persistence/MacLanguageToolSettingsStore.swift @@ -0,0 +1,24 @@ +import Foundation +import LitheCoreContracts + +final class MacLanguageToolSettingsStore: LanguageToolSettingsStoring { + private static let key = "lithe.language-server-tools.executable-paths" + private let store: any KeyValueStore + + init(store: any KeyValueStore) { + self.store = store + } + + func loadLanguageToolExecutablePaths() -> [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 saveLanguageToolExecutablePaths(_ paths: [String: String]) { + guard let data = try? JSONEncoder().encode(paths) else { return } + store.set(data, forKey: Self.key) + } +} diff --git a/Sources/Lithe/Platform/MacOS/Plugins/MacLanguageExecutionHost.swift b/Sources/Lithe/Platform/MacOS/Plugins/MacLanguageExecutionHost.swift new file mode 100644 index 00000000..455c4804 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Plugins/MacLanguageExecutionHost.swift @@ -0,0 +1,81 @@ +import LitheCoreContracts +import LitheModuleAPI + +@MainActor +final class MacLanguageExecutionHost: LanguageExecutionHostProviding { + private let processRegistry: ManagedProcessRegistry + + init(processRegistry: ManagedProcessRegistry) { + self.processRegistry = processRegistry + } + + func makeSession(ownerModuleID: ModuleID) -> any LanguageExecutionSession { + MacLanguageExecutionSession(process: MacStreamingProcess( + processRegistry: processRegistry, + moduleID: ownerModuleID + )) + } +} + +@MainActor +private final class MacLanguageExecutionSession: LanguageExecutionSession { + var isRunning: Bool { process.isRunning } + + var onOutput: (@Sendable (String) -> Void)? { + didSet { process.onOutput = onOutput } + } + var onTermination: (@Sendable (Int32) -> Void)? { + didSet { process.onTermination = onTermination } + } + var onStateChange: (@Sendable (LanguageExecutionLifecycleEvent) -> Void)? { + didSet { installStateForwarding() } + } + + private let process: MacStreamingProcess + + init(process: MacStreamingProcess) { + self.process = process + } + + func start(_ request: LanguageExecutionProcessRequest) throws { + try process.start(ProcessRequest( + operationID: request.operationID, + executablePath: request.executablePath, + arguments: request.arguments, + workingDirectory: request.workingDirectory, + environment: request.environment + )) + } + + func stop() { + process.stop() + } + + func stopAndWait() async -> Bool { + await process.stopAndWait() + } + + private func installStateForwarding() { + let callback = onStateChange + process.onStateChange = { event in + callback?(LanguageExecutionLifecycleEvent( + operationID: event.operationID, + state: Self.state(event.state), + exitCode: event.exitCode, + message: event.message + )) + } + } + + private nonisolated static func state( + _ state: ProcessLifecycleState + ) -> LanguageExecutionLifecycleState { + switch state { + case .starting: .starting + case .running: .running + case .stopping: .stopping + case .finished: .finished + case .failed: .failed + } + } +} diff --git a/Sources/Lithe/Platform/MacOS/Plugins/MacNativePluginLoader.swift b/Sources/Lithe/Platform/MacOS/Plugins/MacNativePluginLoader.swift new file mode 100644 index 00000000..5f171222 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Plugins/MacNativePluginLoader.swift @@ -0,0 +1,120 @@ +import Foundation +import LitheModuleAPI + +protocol PluginPrincipalClassLoading { + func principalClass(at bundleURL: URL) throws -> AnyClass +} + +enum NativePluginLoaderError: Error, Equatable, LocalizedError { + case invalidBundlePath(PluginID) + case bundleCouldNotLoad(PluginID) + case invalidPrincipalClass(PluginID) + case factoryCatalogMismatch(PluginID) + + var errorDescription: String? { + switch self { + case .invalidBundlePath(let id): "Plugin \(id) has an invalid bundle path." + case .bundleCouldNotLoad(let id): "Plugin \(id) bundle could not be loaded." + case .invalidPrincipalClass(let id): "Plugin \(id) does not expose a valid Lithe entrypoint." + case .factoryCatalogMismatch(let id): "Plugin \(id) factories differ from its static manifest." + } + } +} + +struct MacPluginLoadPolicy { + let configurationStore: (any ModuleConfigurationStore)? + let recoveryStore: (any ModuleRecoveryStore)? + let launchMode: ModuleLaunchMode + + func shouldLoad(_ plugin: PluginManifest) -> Bool { + if plugin.modules.contains(where: { $0.manifest.isRequired }) { + return true + } + guard launchMode == .normal else { return false } + return plugin.modules.contains { declaration in + let manifest = declaration.manifest + let enabled = configurationStore?.enabledState(for: manifest.id) + ?? (manifest.defaultState == .enabled) + return enabled && !(recoveryStore?.isQuarantined(manifest.id) ?? false) + } + } +} + +@MainActor +final class MacNativePluginLoader { + private let codeLoader: any PluginPrincipalClassLoading + private let hostContext: PluginHostContext + + init(codeLoader: any PluginPrincipalClassLoading = MacBundlePrincipalClassLoader()) { + self.codeLoader = codeLoader + hostContext = .empty + } + + init( + codeLoader: any PluginPrincipalClassLoading = MacBundlePrincipalClassLoader(), + hostContext: PluginHostContext + ) { + self.codeLoader = codeLoader + self.hostContext = hostContext + } + + func loadFactories( + from installedPlugins: [InstalledPluginPackage], + policy: MacPluginLoadPolicy + ) throws -> [PluginID: [ModuleFactory]] { + var result: [PluginID: [ModuleFactory]] = [:] + for installed in installedPlugins.sorted(by: { $0.manifest.id < $1.manifest.id }) { + let manifest = installed.manifest + guard policy.shouldLoad(manifest) else { continue } + guard manifest.entrypoint.kind == .nativeBundle, + let bundlePath = manifest.entrypoint.bundlePath, + Self.isSafeRelativePath(bundlePath) else { + throw NativePluginLoaderError.invalidBundlePath(manifest.id) + } + let bundleURL = installed.packageURL + .appendingPathComponent(bundlePath) + .standardizedFileURL + guard bundleURL.path.hasPrefix(installed.packageURL.standardizedFileURL.path + "/") else { + throw NativePluginLoaderError.invalidBundlePath(manifest.id) + } + let principalClass: AnyClass = try codeLoader.principalClass(at: bundleURL) + guard let entrypointType = principalClass as? LithePluginEntrypoint.Type else { + throw NativePluginLoaderError.invalidPrincipalClass(manifest.id) + } + let factories = try entrypointType.init().moduleFactories(context: hostContext).sorted { + $0.manifest.id < $1.manifest.id + } + let declarations = manifest.modules.sorted { $0.manifest.id < $1.manifest.id } + guard factories.count == declarations.count, + zip(factories, declarations).allSatisfy({ factory, declaration in + factory.manifest == declaration.manifest + && factory.contributions == declaration.contributions + }) else { + throw NativePluginLoaderError.factoryCatalogMismatch(manifest.id) + } + result[manifest.id] = factories + } + return result + } + + private static func isSafeRelativePath(_ path: String) -> Bool { + !path.isEmpty + && !path.hasPrefix("/") + && !path.split(separator: "/", omittingEmptySubsequences: false).contains("..") + } +} + +struct MacBundlePrincipalClassLoader: PluginPrincipalClassLoading { + func principalClass(at bundleURL: URL) throws -> AnyClass { + guard let bundle = Bundle(url: bundleURL) else { + throw NativePluginLoaderError.bundleCouldNotLoad(PluginID(bundleURL.lastPathComponent)) + } + try bundle.loadAndReturnError() + guard let principalClass = bundle.principalClass else { + throw NativePluginLoaderError.bundleCouldNotLoad( + PluginID(bundle.bundleIdentifier ?? bundleURL.lastPathComponent) + ) + } + return principalClass + } +} diff --git a/Sources/Lithe/Platform/MacOS/Plugins/MacPluginHostContext.swift b/Sources/Lithe/Platform/MacOS/Plugins/MacPluginHostContext.swift new file mode 100644 index 00000000..edb96255 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Plugins/MacPluginHostContext.swift @@ -0,0 +1,15 @@ +import LitheModuleAPI + +@MainActor +final class MacPluginHostServiceRegistry: PluginHostServiceResolving { + private var services: [PluginHostServiceID: AnyObject] = [:] + + func register(_ service: AnyObject, for id: PluginHostServiceID) { + precondition(services[id] == nil, "Plugin host service \(id) is already registered.") + services[id] = service + } + + func service(_ id: PluginHostServiceID) -> AnyObject? { + services[id] + } +} diff --git a/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift b/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift new file mode 100644 index 00000000..357d1723 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift @@ -0,0 +1,216 @@ +import Foundation +import LitheApplicationKernel +import LitheModuleAPI + +@MainActor +final class MacPluginManager: PluginManaging { + private let packageStore: MacPluginPackageStore + private let moduleRuntime: ModuleRuntime + private let configurationStore: MacModuleConfigurationStore + private let launchMode: ModuleLaunchMode + private let activeNativePluginIDs: Set + private var installedPlugins: [PluginID: InstalledPluginPackage] + private var restartRequiredPluginIDs: Set = [] + private(set) var issues: [PluginManagementIssue] + + init( + packageStore: MacPluginPackageStore, + moduleRuntime: ModuleRuntime, + configurationStore: MacModuleConfigurationStore, + launchMode: ModuleLaunchMode, + startup: MacPluginStartupResult + ) { + self.packageStore = packageStore + self.moduleRuntime = moduleRuntime + self.configurationStore = configurationStore + self.launchMode = launchMode + activeNativePluginIDs = Set(startup.activeNativeManifests.map(\.id)) + installedPlugins = Dictionary( + uniqueKeysWithValues: startup.installedPlugins.map { ($0.manifest.id, $0) } + ) + issues = startup.issues.map { + PluginManagementIssue(pluginID: $0.pluginID, message: $0.message) + } + } + + var snapshots: [PluginManagementSnapshot] { + let runtimeSnapshots = Dictionary( + uniqueKeysWithValues: moduleRuntime.snapshots().map { ($0.manifest.id, $0) } + ) + let native = installedPlugins.values + .map { + snapshot( + manifest: $0.manifest, + origin: $0.installation.origin, + installationStatus: $0.installation.status, + previousVersion: $0.installation.previousVersion, + runtimeSnapshots: runtimeSnapshots + ) + } + return native.sorted { $0.manifest.displayName < $1.manifest.displayName } + } + + func setEnabled(_ enabled: Bool, for pluginID: PluginID) async throws { + guard let manifest = manifest(for: pluginID) else { + throw PluginManagerError.unknownPlugin(pluginID) + } + guard enabled || !manifest.modules.contains(where: { $0.manifest.isRequired }) else { + throw PluginManagerError.requiredPluginCannotBeDisabled(pluginID) + } + + let registeredIDs = Set(moduleRuntime.snapshots().map(\.manifest.id)) + let declarations: [PluginModuleDeclaration] = enabled + ? manifest.modules + : Array(manifest.modules.reversed()) + for declaration in declarations { + let moduleID = declaration.manifest.id + if registeredIDs.contains(moduleID) { + try await moduleRuntime.setEnabled(enabled, for: moduleID) + } else { + configurationStore.setEnabledState(enabled, for: moduleID) + if enabled { + configurationStore.setQuarantined(false, for: moduleID) + } + } + } + + if manifest.entrypoint.kind == .nativeBundle, + enabled != activeNativePluginIDs.contains(pluginID) { + restartRequiredPluginIDs.insert(pluginID) + } else if manifest.entrypoint.kind == .nativeBundle, !enabled { + // The module graph is stopped immediately, but Swift Bundle code + // remains mapped until this process exits. + restartRequiredPluginIDs.insert(pluginID) + } + } + + func installPackage(at packageURL: URL) throws { + let installed = try packageStore.installPackage( + from: packageURL, + deferActivationUntilRestart: true + ) + restartRequiredPluginIDs.insert(installed.manifest.id) + try refreshInstalledPlugins() + } + + func rollback(_ pluginID: PluginID) throws { + _ = try packageStore.rollback(pluginID, deferActivationUntilRestart: true) + restartRequiredPluginIDs.insert(pluginID) + try refreshInstalledPlugins() + } + + func uninstall(_ pluginID: PluginID) async throws { + guard let installed = installedPlugins[pluginID] else { + guard issues.contains(where: { $0.pluginID == pluginID }) else { + throw PluginManagerError.unknownPlugin(pluginID) + } + try packageStore.stageInvalidPackageUninstall(pluginID) + restartRequiredPluginIDs.insert(pluginID) + issues.removeAll { $0.pluginID == pluginID } + issues.append(PluginManagementIssue( + pluginID: pluginID, + message: "Will be uninstalled after restart" + )) + return + } + if activeNativePluginIDs.contains(pluginID) { + try await setEnabled(false, for: pluginID) + } + guard !installed.manifest.modules.contains(where: { $0.manifest.isRequired }) else { + throw PluginManagerError.requiredPluginCannotBeUninstalled(pluginID) + } + try packageStore.stageUninstall(pluginID) + restartRequiredPluginIDs.insert(pluginID) + try refreshInstalledPlugins() + } + + private func manifest(for pluginID: PluginID) -> PluginManifest? { + installedPlugins[pluginID]?.manifest + } + + private func refreshInstalledPlugins() throws { + let scan = try packageStore.scanInstalledPlugins() + installedPlugins = Dictionary( + uniqueKeysWithValues: scan.packages.map { ($0.manifest.id, $0) } + ) + issues = issues.filter { issue in + guard let pluginID = issue.pluginID else { return true } + return installedPlugins[pluginID] == nil + } + scan.issues.map { + PluginManagementIssue(pluginID: $0.pluginID, message: $0.message) + } + } + + private func snapshot( + manifest: PluginManifest, + origin: PluginInstallationOrigin, + installationStatus: PluginInstallationStatus, + previousVersion: PluginVersion?, + runtimeSnapshots: [ModuleID: ModuleSnapshot] + ) -> PluginManagementSnapshot { + let moduleSnapshots = manifest.modules.compactMap { runtimeSnapshots[$0.manifest.id] } + let isConfiguredEnabled = manifest.modules.contains { declaration in + if let runtime = runtimeSnapshots[declaration.manifest.id] { + return runtime.state != .disabled + } + return configurationStore.enabledState(for: declaration.manifest.id) + ?? (declaration.manifest.defaultState == .enabled) + } + let isQuarantined = manifest.modules.contains { declaration in + runtimeSnapshots[declaration.manifest.id]?.isQuarantined + ?? configurationStore.isQuarantined(declaration.manifest.id) + } + let isEnabled = isConfiguredEnabled && !isQuarantined + let isSuppressedBySafeMode = launchMode == .safeMode + && !manifest.modules.contains(where: { $0.manifest.isRequired }) + let isRunning = moduleSnapshots.contains { $0.isInstantiated } + let requiresRestart = restartRequiredPluginIDs.contains(manifest.id) + || installationStatus != .installed + let matchingIssue = issues.first { $0.pluginID == manifest.id } + let statusMessage: String + if let matchingIssue { + statusMessage = matchingIssue.message + } else if installationStatus == .uninstallPending { + statusMessage = "Will be uninstalled after restart" + } else if requiresRestart { + statusMessage = "Restart required" + } else if isQuarantined { + statusMessage = "Disabled after the previous plugin session ended unexpectedly" + } else if isSuppressedBySafeMode { + statusMessage = "Disabled in Safe Mode" + } else if isRunning { + statusMessage = "Running" + } else if isEnabled { + statusMessage = "Enabled" + } else { + statusMessage = "Disabled" + } + return PluginManagementSnapshot( + manifest: manifest, + origin: origin, + installationStatus: installationStatus, + isEnabled: isEnabled, + isRequired: manifest.modules.contains(where: { $0.manifest.isRequired }), + isRunning: isRunning, + isQuarantined: isQuarantined, + isSuppressedBySafeMode: isSuppressedBySafeMode, + requiresRestart: requiresRestart, + canRollback: previousVersion != nil, + statusMessage: statusMessage + ) + } +} + +enum PluginManagerError: Error, Equatable, LocalizedError { + case unknownPlugin(PluginID) + case requiredPluginCannotBeDisabled(PluginID) + case requiredPluginCannotBeUninstalled(PluginID) + + var errorDescription: String? { + switch self { + case .unknownPlugin(let id): "Plugin \(id) is not installed." + case .requiredPluginCannotBeDisabled(let id): "Required plugin \(id) cannot be disabled." + case .requiredPluginCannotBeUninstalled(let id): "Required plugin \(id) cannot be uninstalled." + } + } +} diff --git a/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift b/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift new file mode 100644 index 00000000..64635d7d --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift @@ -0,0 +1,505 @@ +import Foundation +import LitheApplicationKernel +import LitheModuleAPI +import Security + +protocol PluginPackageSignatureVerifying { + func verify(packageAt packageURL: URL, manifest: PluginManifest) throws +} + +enum PluginPackageStoreError: Error, Equatable, LocalizedError { + case invalidPackageDirectory + case unsafeIdentifier(String) + case manifestDoesNotMatchInstallation + case versionAlreadyInstalled(PluginVersion) + case missingInstallation(PluginID) + case rollbackUnavailable(PluginID) + case requiredPluginCannotBeUninstalled(PluginID) + case unsupportedEntrypoint(PluginID) + case invalidBundlePath(PluginID) + case unsignedCode(URL) + case invalidCodeSignature(URL) + case signingTeamMismatch + case invalidInstalledPlugin(PluginID?, String) + + var errorDescription: String? { + switch self { + case .invalidPackageDirectory: "The plugin package directory is invalid." + case .unsafeIdentifier(let value): "Plugin package identifier is unsafe: \(value)." + case .manifestDoesNotMatchInstallation: "Plugin manifest does not match its installation record." + case .versionAlreadyInstalled(let version): "Plugin version \(version) is already installed." + case .missingInstallation(let id): "Plugin \(id) is not installed." + case .rollbackUnavailable(let id): "Plugin \(id) has no previous version to restore." + case .requiredPluginCannotBeUninstalled(let id): "Required plugin \(id) cannot be uninstalled." + case .unsupportedEntrypoint(let id): "Plugin \(id) is not an installable native bundle." + case .invalidBundlePath(let id): "Plugin \(id) has an invalid bundle path." + case .unsignedCode(let url): "Plugin code is not signed: \(url.lastPathComponent)." + case .invalidCodeSignature(let url): "Plugin signature is invalid: \(url.lastPathComponent)." + case .signingTeamMismatch: "Plugin and host application signing teams do not match." + case .invalidInstalledPlugin(let id, let message): + if let id { + "Installed plugin \(id) is invalid: \(message)" + } else { + "An installed plugin is invalid: \(message)" + } + } + } +} + +struct InstalledPluginPackage: Equatable { + let manifest: PluginManifest + let installation: PluginInstallationRecord + let packageURL: URL +} + +struct PluginPackageScanIssue: Equatable { + let pluginID: PluginID? + let message: String +} + +struct PluginPackageScanResult: Equatable { + let packages: [InstalledPluginPackage] + let issues: [PluginPackageScanIssue] +} + +final class MacPluginPackageStore { + private let rootURL: URL + private let bundledRootURL: URL? + private let hostVersion: PluginVersion + private let verifier: any PluginPackageSignatureVerifying + private let fileManager: FileManager + private let encoder: JSONEncoder + private let decoder = JSONDecoder() + + init( + rootURL: URL, + bundledRootURL: URL? = nil, + hostVersion: PluginVersion = BuiltInPluginCatalog.hostVersion, + verifier: any PluginPackageSignatureVerifying = MacOfficialPluginSignatureVerifier(), + fileManager: FileManager = .default + ) { + self.rootURL = rootURL.standardizedFileURL + self.bundledRootURL = bundledRootURL?.standardizedFileURL + self.hostVersion = hostVersion + self.verifier = verifier + self.fileManager = fileManager + encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + } + + convenience init(fileStorage: any FileStorage) { + self.init( + rootURL: fileStorage.applicationSupportDirectory() + .appendingPathComponent("Lithe/Plugins", isDirectory: true), + bundledRootURL: Bundle.main.resourceURL? + .appendingPathComponent("OfficialPlugins", isDirectory: true) + ) + } + + /// Completes operations that were deferred because the previous process + /// could still have the plugin bundle mapped in memory. + func prepareForLaunch() throws { + guard fileManager.fileExists(atPath: rootURL.path) else { return } + let pluginDirectories = try fileManager.contentsOfDirectory( + at: rootURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ).filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + + for pluginDirectory in pluginDirectories.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + guard let record = try? installationRecord(at: pluginDirectory) else { continue } + switch record.status { + case .installed: + continue + case .updateStaged: + try write(PluginInstallationRecord( + pluginID: record.pluginID, + activeVersion: record.activeVersion, + previousVersion: record.previousVersion, + origin: record.origin, + status: .installed + ), to: pluginDirectory.appendingPathComponent("installation.json")) + case .uninstallPending: + try fileManager.removeItem(at: pluginDirectory) + } + } + } + + func installedPlugins() throws -> [InstalledPluginPackage] { + let result = try scanInstalledPlugins() + if let issue = result.issues.first { + throw PluginPackageStoreError.invalidInstalledPlugin(issue.pluginID, issue.message) + } + return result.packages + } + + /// Reads and verifies every package independently so one damaged optional + /// plugin cannot prevent the host from starting or managing the others. + func scanInstalledPlugins() throws -> PluginPackageScanResult { + var installed: [InstalledPluginPackage] = [] + var issues: [PluginPackageScanIssue] = [] + if let bundledRootURL, fileManager.fileExists(atPath: bundledRootURL.path) { + let bundled = scanBundledPlugins(at: bundledRootURL) + installed = bundled.packages + issues = bundled.issues + } + guard fileManager.fileExists(atPath: rootURL.path) else { + return PluginPackageScanResult(packages: installed, issues: issues) + } + let pluginDirectories = try pluginDirectories(at: rootURL) + + for pluginDirectory in pluginDirectories.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + var issuePluginID: PluginID? + do { + let record = try decode( + PluginInstallationRecord.self, + at: pluginDirectory.appendingPathComponent("installation.json") + ) + issuePluginID = record.pluginID + try validatePathComponent(record.pluginID.rawValue) + guard pluginDirectory.lastPathComponent == record.pluginID.rawValue else { + throw PluginPackageStoreError.manifestDoesNotMatchInstallation + } + let packageURL = versionDirectory( + pluginDirectory: pluginDirectory, + version: record.activeVersion + ) + let manifest = try loadManifest(at: packageURL) + guard manifest.id == record.pluginID, + manifest.version == record.activeVersion else { + throw PluginPackageStoreError.manifestDoesNotMatchInstallation + } + _ = try ValidatedPluginCatalog(manifests: [manifest], hostVersion: hostVersion) + try verifier.verify(packageAt: packageURL, manifest: manifest) + let candidate = InstalledPluginPackage( + manifest: manifest, + installation: record, + packageURL: packageURL + ) + let packagesWithoutBundledVersion = installed.filter { + $0.manifest.id != candidate.manifest.id + } + _ = try ValidatedPluginCatalog( + manifests: packagesWithoutBundledVersion.map(\.manifest) + [manifest], + hostVersion: hostVersion + ) + installed = packagesWithoutBundledVersion + [candidate] + } catch { + issues.append(PluginPackageScanIssue( + pluginID: issuePluginID, + message: error.localizedDescription + )) + } + } + return PluginPackageScanResult( + packages: installed.sorted { $0.manifest.id < $1.manifest.id }, + issues: issues + ) + } + + private func scanBundledPlugins(at bundledRootURL: URL) -> PluginPackageScanResult { + var installed: [InstalledPluginPackage] = [] + var issues: [PluginPackageScanIssue] = [] + let directories: [URL] + do { + directories = try pluginDirectories(at: bundledRootURL) + } catch { + return PluginPackageScanResult( + packages: [], + issues: [PluginPackageScanIssue(pluginID: nil, message: error.localizedDescription)] + ) + } + + for packageURL in directories.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + var issuePluginID: PluginID? + do { + let manifest = try loadManifest(at: packageURL) + issuePluginID = manifest.id + try validatePathComponent(manifest.id.rawValue) + guard packageURL.lastPathComponent == manifest.id.rawValue else { + throw PluginPackageStoreError.manifestDoesNotMatchInstallation + } + _ = try ValidatedPluginCatalog( + manifests: installed.map(\.manifest) + [manifest], + hostVersion: hostVersion + ) + try verifier.verify(packageAt: packageURL, manifest: manifest) + installed.append(InstalledPluginPackage( + manifest: manifest, + installation: PluginInstallationRecord( + pluginID: manifest.id, + activeVersion: manifest.version, + origin: .bundled + ), + packageURL: packageURL + )) + } catch { + issues.append(PluginPackageScanIssue( + pluginID: issuePluginID, + message: error.localizedDescription + )) + } + } + return PluginPackageScanResult(packages: installed, issues: issues) + } + + private func pluginDirectories(at directory: URL) throws -> [URL] { + try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ).filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + } + + @discardableResult + func installPackage( + from sourceURL: URL, + deferActivationUntilRestart: Bool = false + ) throws -> InstalledPluginPackage { + let sourceManifest = try loadManifest(at: sourceURL) + _ = try ValidatedPluginCatalog(manifests: [sourceManifest], hostVersion: hostVersion) + try validatePathComponent(sourceManifest.id.rawValue) + + let stagingRoot = rootURL.appendingPathComponent(".staging", isDirectory: true) + try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true) + let stagedURL = stagingRoot.appendingPathComponent(UUID().uuidString, isDirectory: true) + try fileManager.copyItem(at: sourceURL, to: stagedURL) + var shouldRemoveStaging = true + defer { if shouldRemoveStaging { try? fileManager.removeItem(at: stagedURL) } } + + let manifest = try loadManifest(at: stagedURL) + guard manifest == sourceManifest else { + throw PluginPackageStoreError.manifestDoesNotMatchInstallation + } + try verifier.verify(packageAt: stagedURL, manifest: manifest) + + let pluginDirectory = rootURL.appendingPathComponent(manifest.id.rawValue, isDirectory: true) + let versionsDirectory = pluginDirectory.appendingPathComponent("versions", isDirectory: true) + try fileManager.createDirectory(at: versionsDirectory, withIntermediateDirectories: true) + let destination = versionDirectory( + pluginDirectory: pluginDirectory, + version: manifest.version + ) + guard !fileManager.fileExists(atPath: destination.path) else { + throw PluginPackageStoreError.versionAlreadyInstalled(manifest.version) + } + + let existingRecord = try? installationRecord(at: pluginDirectory) + try fileManager.moveItem(at: stagedURL, to: destination) + shouldRemoveStaging = false + do { + let record = PluginInstallationRecord( + pluginID: manifest.id, + activeVersion: manifest.version, + previousVersion: existingRecord?.activeVersion, + origin: .marketplace, + status: deferActivationUntilRestart ? .updateStaged : .installed + ) + try write(record, to: pluginDirectory.appendingPathComponent("installation.json")) + return InstalledPluginPackage( + manifest: manifest, + installation: record, + packageURL: destination + ) + } catch { + try? fileManager.removeItem(at: destination) + throw error + } + } + + @discardableResult + func rollback( + _ pluginID: PluginID, + deferActivationUntilRestart: Bool = false + ) throws -> InstalledPluginPackage { + try validatePathComponent(pluginID.rawValue) + let pluginDirectory = rootURL.appendingPathComponent(pluginID.rawValue, isDirectory: true) + let record = try installationRecord(at: pluginDirectory) + guard let previousVersion = record.previousVersion else { + throw PluginPackageStoreError.rollbackUnavailable(pluginID) + } + let previousPackageURL = versionDirectory( + pluginDirectory: pluginDirectory, + version: previousVersion + ) + let manifest = try loadManifest(at: previousPackageURL) + guard manifest.id == pluginID, manifest.version == previousVersion else { + throw PluginPackageStoreError.manifestDoesNotMatchInstallation + } + let restored = PluginInstallationRecord( + pluginID: pluginID, + activeVersion: previousVersion, + previousVersion: record.activeVersion, + origin: record.origin, + status: deferActivationUntilRestart ? .updateStaged : .installed + ) + try write(restored, to: pluginDirectory.appendingPathComponent("installation.json")) + return InstalledPluginPackage( + manifest: manifest, + installation: restored, + packageURL: previousPackageURL + ) + } + + func uninstall(_ pluginID: PluginID) throws { + try validatePathComponent(pluginID.rawValue) + let pluginDirectory = rootURL.appendingPathComponent(pluginID.rawValue, isDirectory: true) + guard fileManager.fileExists(atPath: pluginDirectory.path) else { + throw PluginPackageStoreError.missingInstallation(pluginID) + } + let record = try installationRecord(at: pluginDirectory) + let manifest = try loadManifest(at: versionDirectory( + pluginDirectory: pluginDirectory, + version: record.activeVersion + )) + guard !manifest.modules.contains(where: { $0.manifest.isRequired }) else { + throw PluginPackageStoreError.requiredPluginCannotBeUninstalled(pluginID) + } + try fileManager.removeItem(at: pluginDirectory) + } + + func stageUninstall(_ pluginID: PluginID) throws { + try validatePathComponent(pluginID.rawValue) + let pluginDirectory = rootURL.appendingPathComponent(pluginID.rawValue, isDirectory: true) + let record = try installationRecord(at: pluginDirectory) + let manifest = try loadManifest(at: versionDirectory( + pluginDirectory: pluginDirectory, + version: record.activeVersion + )) + guard !manifest.modules.contains(where: { $0.manifest.isRequired }) else { + throw PluginPackageStoreError.requiredPluginCannotBeUninstalled(pluginID) + } + try write(PluginInstallationRecord( + pluginID: record.pluginID, + activeVersion: record.activeVersion, + previousVersion: record.previousVersion, + origin: record.origin, + status: .uninstallPending + ), to: pluginDirectory.appendingPathComponent("installation.json")) + } + + /// Recovery path for an unreadable active package. The installation + /// record is deliberately sufficient to schedule removal without opening + /// the plugin manifest or loading any plugin code. + func stageInvalidPackageUninstall(_ pluginID: PluginID) throws { + try validatePathComponent(pluginID.rawValue) + let pluginDirectory = rootURL.appendingPathComponent(pluginID.rawValue, isDirectory: true) + let record = try installationRecord(at: pluginDirectory) + try write(PluginInstallationRecord( + pluginID: record.pluginID, + activeVersion: record.activeVersion, + previousVersion: record.previousVersion, + origin: record.origin, + status: .uninstallPending + ), to: pluginDirectory.appendingPathComponent("installation.json")) + } + + private func loadManifest(at packageURL: URL) throws -> PluginManifest { + let values = try packageURL.resourceValues(forKeys: [.isDirectoryKey]) + guard values.isDirectory == true else { + throw PluginPackageStoreError.invalidPackageDirectory + } + return try decode( + PluginManifest.self, + at: packageURL.appendingPathComponent("plugin.json") + ) + } + + private func installationRecord(at pluginDirectory: URL) throws -> PluginInstallationRecord { + let url = pluginDirectory.appendingPathComponent("installation.json") + guard fileManager.fileExists(atPath: url.path) else { + throw PluginPackageStoreError.missingInstallation( + PluginID(pluginDirectory.lastPathComponent) + ) + } + return try decode(PluginInstallationRecord.self, at: url) + } + + private func versionDirectory(pluginDirectory: URL, version: PluginVersion) -> URL { + pluginDirectory + .appendingPathComponent("versions", isDirectory: true) + .appendingPathComponent(version.description, isDirectory: true) + } + + private func decode(_ type: Value.Type, at url: URL) throws -> Value { + try decoder.decode(type, from: Data(contentsOf: url, options: .mappedIfSafe)) + } + + private func write(_ value: Value, to url: URL) throws { + let data = try encoder.encode(value) + try data.write(to: url, options: .atomic) + try fileManager.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + } + + private func validatePathComponent(_ value: String) throws { + let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789.-") + guard !value.isEmpty, + value != ".", + value != "..", + value.unicodeScalars.allSatisfy(allowed.contains) else { + throw PluginPackageStoreError.unsafeIdentifier(value) + } + } +} + +struct MacOfficialPluginSignatureVerifier: PluginPackageSignatureVerifying { + func verify(packageAt packageURL: URL, manifest: PluginManifest) throws { + guard manifest.entrypoint.kind == .nativeBundle else { + throw PluginPackageStoreError.unsupportedEntrypoint(manifest.id) + } + guard let relativePath = manifest.entrypoint.bundlePath, + !relativePath.hasPrefix("/"), + !relativePath.split(separator: "/", omittingEmptySubsequences: false).contains("..") else { + throw PluginPackageStoreError.invalidBundlePath(manifest.id) + } + let pluginBundleURL = packageURL.appendingPathComponent(relativePath).standardizedFileURL + guard pluginBundleURL.path.hasPrefix(packageURL.standardizedFileURL.path + "/") else { + throw PluginPackageStoreError.invalidBundlePath(manifest.id) + } + let pluginCode = try staticCode(at: pluginBundleURL) + let hostCode = try staticCode(at: Bundle.main.bundleURL) + let validationFlags = SecCSFlags( + rawValue: UInt32(kSecCSCheckAllArchitectures | kSecCSStrictValidate) + ) + guard SecStaticCodeCheckValidity(pluginCode, validationFlags, nil) == errSecSuccess else { + throw PluginPackageStoreError.invalidCodeSignature(pluginBundleURL) + } + let pluginTeam = try teamIdentifier(for: pluginCode) + let hostTeam = try teamIdentifier(for: hostCode) + if let pluginTeam, let hostTeam { + guard pluginTeam == hostTeam else { + throw PluginPackageStoreError.signingTeamMismatch + } + return + } + let hostBundlePath = Bundle.main.bundleURL.standardizedFileURL.path + "/" + guard pluginTeam == nil, + hostTeam == nil, + pluginBundleURL.path.hasPrefix(hostBundlePath) else { + throw PluginPackageStoreError.signingTeamMismatch + } + } + + private func staticCode(at url: URL) throws -> SecStaticCode { + var code: SecStaticCode? + guard SecStaticCodeCreateWithPath(url as CFURL, [], &code) == errSecSuccess, + let code else { + throw PluginPackageStoreError.unsignedCode(url) + } + return code + } + + private func teamIdentifier(for code: SecStaticCode) throws -> String? { + var information: CFDictionary? + guard SecCodeCopySigningInformation(code, [], &information) == errSecSuccess, + let values = information as? [CFString: Any] else { + throw PluginPackageStoreError.signingTeamMismatch + } + guard let teamID = values[kSecCodeInfoTeamIdentifier] as? String, + !teamID.isEmpty else { return nil } + return teamID + } +} diff --git a/Sources/Lithe/Platform/MacOS/Plugins/MacPluginRuntimeRecoveryCoordinator.swift b/Sources/Lithe/Platform/MacOS/Plugins/MacPluginRuntimeRecoveryCoordinator.swift new file mode 100644 index 00000000..64653963 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Plugins/MacPluginRuntimeRecoveryCoordinator.swift @@ -0,0 +1,47 @@ +import Foundation +import LitheModuleAPI + +/// Keeps native plugin code marked for the full process lifetime. If the mark +/// survives, the next launch quarantines those modules before loading a Bundle. +@MainActor +final class MacPluginRuntimeRecoveryCoordinator { + private var didRecoverPreviousSession = false + private var loadedModuleIDs: Set = [] + + func recoverPreviousSession(using store: any ModuleRecoveryStore) { + guard !didRecoverPreviousSession else { return } + let interruptedModuleIDs = store.pendingPluginLoadModules() + for moduleID in interruptedModuleIDs { + store.setQuarantined(true, for: moduleID) + } + store.setPendingPluginLoadModules([]) + didRecoverPreviousSession = true + } + + func prepareToLoad( + _ moduleIDs: [ModuleID], + using store: any ModuleRecoveryStore + ) { + recoverPreviousSession(using: store) + store.setPendingPluginLoadModules( + loadedModuleIDs.union(moduleIDs).sorted() + ) + } + + func recordSuccessfulLoad( + _ moduleIDs: [ModuleID], + using store: any ModuleRecoveryStore + ) { + loadedModuleIDs.formUnion(moduleIDs) + store.setPendingPluginLoadModules(loadedModuleIDs.sorted()) + } + + func recordFailedLoad(using store: any ModuleRecoveryStore) { + store.setPendingPluginLoadModules(loadedModuleIDs.sorted()) + } + + func recordCleanShutdown(using store: any ModuleRecoveryStore) { + loadedModuleIDs.removeAll() + store.setPendingPluginLoadModules([]) + } +} diff --git a/Sources/Lithe/Platform/MacOS/Plugins/MacPluginStartupLoader.swift b/Sources/Lithe/Platform/MacOS/Plugins/MacPluginStartupLoader.swift new file mode 100644 index 00000000..8b6938e8 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Plugins/MacPluginStartupLoader.swift @@ -0,0 +1,200 @@ +import Foundation +import LitheApplicationKernel +import LitheModuleAPI + +struct PluginStartupIssue: Equatable { + let pluginID: PluginID? + let message: String +} + +@MainActor +struct MacPluginStartupResult { + let installedPlugins: [InstalledPluginPackage] + let activeNativeManifests: [PluginManifest] + let factoriesByPlugin: [PluginID: [ModuleFactory]] + let issues: [PluginStartupIssue] + + var installedManifests: [PluginManifest] { + installedPlugins.map(\.manifest).sorted { $0.id < $1.id } + } + + var installedLanguageSupports: [LanguageSupportDeclaration] { + installedManifests + .flatMap { $0.languageSupports ?? [] } + .sorted { $0.id < $1.id } + } +} + +/// Establishes the native-code loading boundary for optional plugins. Static +/// metadata and signatures are checked before a bundle or principal class is +/// touched. Failures remain local to plugin startup and never replace the +/// required built-in catalog. +@MainActor +final class MacPluginStartupLoader { + private let packageStore: MacPluginPackageStore + private let nativeLoader: MacNativePluginLoader + private let hostVersion: PluginVersion + private let runtimeRecovery: MacPluginRuntimeRecoveryCoordinator? + + init( + packageStore: MacPluginPackageStore, + nativeLoader: MacNativePluginLoader? = nil, + hostVersion: PluginVersion = BuiltInPluginCatalog.hostVersion, + runtimeRecovery: MacPluginRuntimeRecoveryCoordinator? = nil + ) { + self.packageStore = packageStore + self.nativeLoader = nativeLoader ?? MacNativePluginLoader() + self.hostVersion = hostVersion + self.runtimeRecovery = runtimeRecovery + } + + func load(policy: MacPluginLoadPolicy) -> MacPluginStartupResult { + if let recoveryStore = policy.recoveryStore, let runtimeRecovery { + runtimeRecovery.recoverPreviousSession(using: recoveryStore) + } else { + recoverInterruptedPluginLoad(using: policy.recoveryStore) + } + let scan: PluginPackageScanResult + do { + try packageStore.prepareForLaunch() + scan = try packageStore.scanInstalledPlugins() + } catch { + return MacPluginStartupResult( + installedPlugins: [], + activeNativeManifests: [], + factoriesByPlugin: [:], + issues: [PluginStartupIssue(pluginID: nil, message: error.localizedDescription)] + ) + } + + var candidatePackages: [InstalledPluginPackage] = [] + var candidateManifests: [PluginManifest] = [] + var factoriesByPlugin: [PluginID: [ModuleFactory]] = [:] + var issues = scan.issues.map { + PluginStartupIssue(pluginID: $0.pluginID, message: $0.message) + } + + for installed in scan.packages.sorted(by: { $0.manifest.id < $1.manifest.id }) { + let manifest = installed.manifest + guard policy.shouldLoad(manifest) else { continue } + + do { + // Include all accepted candidates in the static catalog check so + // plugin and module ownership collisions fail before code load. + _ = try ValidatedPluginCatalog( + manifests: BuiltInPluginCatalog.manifests + candidateManifests + [manifest], + hostVersion: hostVersion + ) + candidatePackages.append(installed) + candidateManifests.append(manifest) + } catch { + issues.append(PluginStartupIssue( + pluginID: manifest.id, + message: error.localizedDescription + )) + } + } + + do { + try validateStaticGraph( + manifests: BuiltInPluginCatalog.manifests + candidateManifests + ) + } catch { + issues.append(PluginStartupIssue(pluginID: nil, message: error.localizedDescription)) + candidatePackages.removeAll() + candidateManifests.removeAll() + } + + var activeNativeManifests: [PluginManifest] = [] + for installed in candidatePackages { + let moduleIDs = installed.manifest.modules.map(\.manifest.id).sorted() + if let recoveryStore = policy.recoveryStore, let runtimeRecovery { + runtimeRecovery.prepareToLoad(moduleIDs, using: recoveryStore) + } else { + policy.recoveryStore?.setPendingPluginLoadModules(moduleIDs) + } + do { + let loaded = try nativeLoader.loadFactories(from: [installed], policy: policy) + guard let factories = loaded[installed.manifest.id] else { + clearPendingLoad(using: policy.recoveryStore) + continue + } + activeNativeManifests.append(installed.manifest) + factoriesByPlugin[installed.manifest.id] = factories + if let recoveryStore = policy.recoveryStore, let runtimeRecovery { + runtimeRecovery.recordSuccessfulLoad(moduleIDs, using: recoveryStore) + } else { + policy.recoveryStore?.setPendingPluginLoadModules([]) + } + } catch { + for moduleID in moduleIDs { + policy.recoveryStore?.setQuarantined(true, for: moduleID) + } + clearPendingLoad(using: policy.recoveryStore) + issues.append(PluginStartupIssue( + pluginID: installed.manifest.id, + message: error.localizedDescription + )) + } + } + + do { + try validateStaticGraph( + manifests: BuiltInPluginCatalog.manifests + activeNativeManifests + ) + } catch { + issues.append(PluginStartupIssue(pluginID: nil, message: error.localizedDescription)) + activeNativeManifests.removeAll() + factoriesByPlugin.removeAll() + } + + return MacPluginStartupResult( + installedPlugins: scan.packages, + activeNativeManifests: activeNativeManifests.sorted { $0.id < $1.id }, + factoriesByPlugin: factoriesByPlugin, + issues: issues + ) + } + + private func clearPendingLoad(using recoveryStore: (any ModuleRecoveryStore)?) { + guard let recoveryStore else { return } + if let runtimeRecovery { + runtimeRecovery.recordFailedLoad(using: recoveryStore) + } else { + recoveryStore.setPendingPluginLoadModules([]) + } + } + + private func recoverInterruptedPluginLoad(using recoveryStore: (any ModuleRecoveryStore)?) { + guard let recoveryStore else { return } + let pending = recoveryStore.pendingPluginLoadModules() + for moduleID in pending { + recoveryStore.setQuarantined(true, for: moduleID) + } + if !pending.isEmpty { + recoveryStore.setPendingPluginLoadModules([]) + } + } + + private func validateStaticGraph(manifests: [PluginManifest]) throws { + let runtime = ModuleRuntime() + let registry = ModuleRegistry( + runtime: runtime, + pluginManifests: manifests, + hostVersion: hostVersion + ) + for declaration in manifests.flatMap(\.modules) { + try registry.register(ModuleFactory( + manifest: declaration.manifest, + contributions: declaration.contributions + ) { + throw StaticPluginGraphValidationError.factoryMustNotBeInvoked + }) + } + try registry.validate() + } +} + +private enum StaticPluginGraphValidationError: Error { + case factoryMustNotBeInvoked +} diff --git a/Sources/Lithe/Platform/MacOS/Process/MacProcessRunner.swift b/Sources/Lithe/Platform/MacOS/Process/MacProcessRunner.swift index f4953e40..9407394e 100644 --- a/Sources/Lithe/Platform/MacOS/Process/MacProcessRunner.swift +++ b/Sources/Lithe/Platform/MacOS/Process/MacProcessRunner.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts final class MacProcessRunner: ProcessRunner, @unchecked Sendable { func run(_ request: ProcessRequest) -> ProcessResult { @@ -62,3 +63,22 @@ final class MacProcessRunner: ProcessRunner, @unchecked Sendable { } } } + +extension MacProcessRunner: LanguageToolCommandRunning { + func runLanguageToolCommand( + operationID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + timeoutMilliseconds: Int + ) -> LanguageToolCommandResult { + let result = run(ProcessRequest( + operationID: operationID, + executablePath: executableURL.path, + arguments: arguments, + environment: environment, + timeoutMilliseconds: timeoutMilliseconds + )) + return LanguageToolCommandResult(output: result.output, exitCode: result.exitCode) + } +} diff --git a/Sources/Lithe/Platform/MacOS/Process/MacStreamingProcess.swift b/Sources/Lithe/Platform/MacOS/Process/MacStreamingProcess.swift index 77e902db..28f23df8 100644 --- a/Sources/Lithe/Platform/MacOS/Process/MacStreamingProcess.swift +++ b/Sources/Lithe/Platform/MacOS/Process/MacStreamingProcess.swift @@ -1,4 +1,6 @@ +import Darwin import Foundation +import LitheModuleAPI final class MacStreamingProcess: StreamingProcess, @unchecked Sendable { var isRunning: Bool { process?.isRunning == true } @@ -13,14 +15,17 @@ final class MacStreamingProcess: StreamingProcess, @unchecked Sendable { private var activeOperationID: String? private let processRegistry: ManagedProcessRegistry? private let category: ManagedProcessCategory + private let moduleID: ModuleID? private var registeredPID: Int32? init( processRegistry: ManagedProcessRegistry? = nil, - category: ManagedProcessCategory = .service + category: ManagedProcessCategory = .service, + moduleID: ModuleID? = nil ) { self.processRegistry = processRegistry self.category = category + self.moduleID = moduleID } func start(_ request: ProcessRequest) throws { @@ -89,7 +94,7 @@ final class MacStreamingProcess: StreamingProcess, @unchecked Sendable { } self.process = process registeredPID = process.processIdentifier - processRegistry?.register(pid: process.processIdentifier, category: category) + processRegistry?.register(pid: process.processIdentifier, category: category, moduleID: moduleID) self.inputPipe = inputPipe self.outputPipe = outputPipe if let input = request.standardInput, let inputPipe { @@ -133,9 +138,32 @@ final class MacStreamingProcess: StreamingProcess, @unchecked Sendable { activeOperationID = nil } + func stopAndWait() async -> Bool { + guard let runningProcess = process else { + stop() + return true + } + let processID = runningProcess.processIdentifier + stop() + + let clock = ContinuousClock() + var deadline = clock.now.advanced(by: .seconds(1)) + while runningProcess.isRunning, clock.now < deadline { + try? await Task.sleep(for: .milliseconds(20)) + } + if runningProcess.isRunning { + _ = Darwin.kill(processID, SIGKILL) + deadline = clock.now.advanced(by: .seconds(1)) + while runningProcess.isRunning, clock.now < deadline { + try? await Task.sleep(for: .milliseconds(20)) + } + } + return !runningProcess.isRunning + } + private func unregisterProcess() { guard let registeredPID else { return } - processRegistry?.unregister(pid: registeredPID, category: category) + processRegistry?.unregister(pid: registeredPID, category: category, moduleID: moduleID) self.registeredPID = nil } diff --git a/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunServiceAdapters.swift b/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunServiceAdapters.swift new file mode 100644 index 00000000..9b4e5fab --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunServiceAdapters.swift @@ -0,0 +1,28 @@ +import Foundation +import LitheCoreContracts + +struct MacRunFileAccess: RunFileAccess { + let storage: any FileStorage + + func isDirectory(at url: URL) -> Bool { + storage.metadata(for: url)?.isDirectory == true + } + + func readData(from url: URL) throws -> Data { + try storage.readData(from: url, options: []) + } +} + +@MainActor +final class MacRunPreferenceStore: RunPreferenceStore { + private let store: any KeyValueStore + + init(store: any KeyValueStore) { + self.store = store + } + + func data(forKey key: String) -> Data? { store.data(forKey: key) } + func string(forKey key: String) -> String? { store.string(forKey: key) } + func setData(_ data: Data, forKey key: String) { store.set(data, forKey: key) } + func setString(_ value: String, forKey key: String) { store.set(value, forKey: key) } +} diff --git a/Sources/Lithe/Platform/MacOS/RunConfiguration/RunServiceCompatibility.swift b/Sources/Lithe/Platform/MacOS/RunConfiguration/RunServiceCompatibility.swift new file mode 100644 index 00000000..b60b1e09 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/RunConfiguration/RunServiceCompatibility.swift @@ -0,0 +1,36 @@ +import Foundation +import LitheCoreContracts +import LitheExecutionModule + +@MainActor +extension LitheExecutionModule.RunService { + convenience init( + runtimeService: ProjectRuntimeService, + process: any StreamingProcess, + processFactory: @escaping () -> any StreamingProcess, + fileStorage: any FileStorage, + preferences: any KeyValueStore, + javaMavenOperations: any JavaMavenOperations, + runConfigurationOperations: any RunConfigurationOperations, + executableResolver: (any RunExecutableResolving)? = nil, + languageProviderCatalog: LanguageProviderCatalog = .standard, + languageRunProviders: LanguageRunProviderRegistry? = nil, + languagePackRegistry: LanguagePackRegistry? = nil + ) { + let catalog = languagePackRegistry?.catalog ?? languageProviderCatalog + self.init( + runtime: runtimeService, + process: process, + processFactory: processFactory, + fileAccess: MacRunFileAccess(storage: fileStorage), + preferences: MacRunPreferenceStore(store: preferences), + serverPortParser: javaMavenOperations, + runConfigurationOperations: runConfigurationOperations, + executableResolver: executableResolver ?? RunExecutableResolver(runtimeService: runtimeService), + languageProviderCatalog: catalog, + languageRunProviders: languagePackRegistry?.runProviders + ?? languageRunProviders + ?? .standard(catalog: catalog) + ) + } +} diff --git a/Sources/Lithe/Platform/MacOS/Storage/MacDatabaseAdapters.swift b/Sources/Lithe/Platform/MacOS/Storage/MacDatabaseAdapters.swift new file mode 100644 index 00000000..2b07527e --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Storage/MacDatabaseAdapters.swift @@ -0,0 +1,26 @@ +import Foundation +import LitheDatabaseModule + +extension MacProcessRunner: DatabaseProcessRunning { + func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { + let result = run(ProcessRequest( + executablePath: request.executablePath, + environment: request.environment, + standardInput: request.standardInput, + timeoutMilliseconds: request.timeoutMilliseconds + )) + return DatabaseProcessResult(output: result.output, exitCode: result.exitCode) + } +} + +extension MacKeychainSecureStore: DatabaseSecureStore {} + +struct MacDatabasePreferenceStore: DatabasePreferenceStore, @unchecked Sendable { + let store: any KeyValueStore + func data(forKey key: String) -> Data? { store.data(forKey: key) } + func set(_ value: Any?, forKey key: String) { store.set(value, forKey: key) } +} + +extension MacFileStorage: DatabaseFileStorage { + func readData(from url: URL) throws -> Data { try readData(from: url, options: []) } +} diff --git a/Sources/Lithe/Platform/MacOS/Storage/MacGitShelfStorage.swift b/Sources/Lithe/Platform/MacOS/Storage/MacGitShelfStorage.swift new file mode 100644 index 00000000..ee19b997 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Storage/MacGitShelfStorage.swift @@ -0,0 +1,13 @@ +import Foundation +import LitheGitModule + +struct MacGitShelfStorage: GitShelfStorage { + let storage: any FileStorage + func applicationSupportDirectory() -> URL { storage.applicationSupportDirectory() } + func fileExists(at url: URL) -> Bool { storage.fileExists(at: url) } + func listDirectory(at url: URL) -> [URL] { storage.listDirectory(at: url) } + func readData(from url: URL) throws -> Data { try storage.readData(from: url, options: []) } + func writeData(_ data: Data, to url: URL) throws { try storage.writeData(data, to: url, options: []) } + func createDirectory(at url: URL) throws { try storage.createDirectory(at: url, withIntermediateDirectories: true) } + func removeItem(at url: URL) throws { try storage.removeItem(at: url) } +} diff --git a/Sources/Lithe/Platform/MacOS/Storage/MacLocalHistoryAdapters.swift b/Sources/Lithe/Platform/MacOS/Storage/MacLocalHistoryAdapters.swift new file mode 100644 index 00000000..d0c12441 --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Storage/MacLocalHistoryAdapters.swift @@ -0,0 +1,15 @@ +import Foundation +import LitheLocalHistoryModule + +struct MacLocalHistoryStorage: LocalHistoryStorage { + let storage: any FileStorage + func applicationSupportDirectory() -> URL { storage.applicationSupportDirectory() } +} + +struct MacLocalHistoryWorkspaceAccess: LocalHistoryWorkspaceAccess { + let workspaceOperations: any WorkspaceOperations + let fileOperations: any WorkspaceFileOperations + func fileExists(at url: URL) -> Bool { fileOperations.fileExists(at: url) } + func readFile(at workspaceURL: URL, relativePath: String) -> String? { workspaceOperations.readFile(at: workspaceURL, relativePath: relativePath) } + func writeFile(_ text: String, at workspaceURL: URL, relativePath: String) -> Bool { workspaceOperations.writeFile(text, at: workspaceURL, relativePath: relativePath) } +} diff --git a/Sources/Lithe/Platform/MacOS/Storage/MacModuleConfigurationStore.swift b/Sources/Lithe/Platform/MacOS/Storage/MacModuleConfigurationStore.swift new file mode 100644 index 00000000..423948ad --- /dev/null +++ b/Sources/Lithe/Platform/MacOS/Storage/MacModuleConfigurationStore.swift @@ -0,0 +1,89 @@ +import Foundation +import LitheModuleAPI + +final class MacModuleConfigurationStore: ModuleConfigurationStore, ModuleRecoveryStore, @unchecked Sendable { + private let store: any KeyValueStore + private let lock = NSLock() + + init(store: any KeyValueStore) { + self.store = store + } + + func enabledState(for moduleID: ModuleID) -> Bool? { + lock.lock(); defer { lock.unlock() } + return store.object(forKey: key(for: moduleID)) as? Bool + } + + func setEnabledState(_ enabled: Bool, for moduleID: ModuleID) { + lock.lock(); defer { lock.unlock() } + store.set(enabled, forKey: key(for: moduleID)) + } + + func pendingActivation() -> ModuleID? { + lock.lock(); defer { lock.unlock() } + return pendingActivationsLocked().first + } + + func setPendingActivation(_ moduleID: ModuleID?) { + lock.lock(); defer { lock.unlock() } + setPendingActivationsLocked(moduleID.map { [$0] } ?? []) + } + + func pendingActivations() -> [ModuleID] { + lock.lock(); defer { lock.unlock() } + return pendingActivationsLocked() + } + + func setPendingActivations(_ moduleIDs: [ModuleID]) { + lock.lock(); defer { lock.unlock() } + setPendingActivationsLocked(moduleIDs) + } + + func isQuarantined(_ moduleID: ModuleID) -> Bool { + lock.lock(); defer { lock.unlock() } + return store.object(forKey: quarantineKey(for: moduleID)) as? Bool ?? false + } + + func setQuarantined(_ quarantined: Bool, for moduleID: ModuleID) { + lock.lock(); defer { lock.unlock() } + store.set(quarantined ? true : nil, forKey: quarantineKey(for: moduleID)) + } + + func pendingPluginLoadModules() -> [ModuleID] { + lock.lock(); defer { lock.unlock() } + let values = store.object(forKey: Self.pendingPluginLoadKey) as? [String] ?? [] + return values.map { ModuleID($0) }.sorted() + } + + func setPendingPluginLoadModules(_ moduleIDs: [ModuleID]) { + lock.lock(); defer { lock.unlock() } + let values = moduleIDs.map(\.rawValue).sorted() + store.set(values.isEmpty ? nil : values, forKey: Self.pendingPluginLoadKey) + } + + private func key(for moduleID: ModuleID) -> String { + "lithe.modules.\(moduleID.rawValue).enabled" + } + + private func quarantineKey(for moduleID: ModuleID) -> String { + "lithe.modules.\(moduleID.rawValue).quarantined" + } + + private func pendingActivationsLocked() -> [ModuleID] { + var values = store.object(forKey: Self.pendingActivationsKey) as? [String] ?? [] + if let legacy = store.string(forKey: Self.pendingActivationKey), !legacy.isEmpty { + values.append(legacy) + } + return Set(values.map { ModuleID($0) }).sorted() + } + + private func setPendingActivationsLocked(_ moduleIDs: [ModuleID]) { + let values = Set(moduleIDs).sorted().map(\.rawValue) + store.set(values.isEmpty ? nil : values, forKey: Self.pendingActivationsKey) + store.set(nil, forKey: Self.pendingActivationKey) + } + + private static let pendingActivationKey = "lithe.modules.pending-activation" + private static let pendingActivationsKey = "lithe.modules.pending-activations" + private static let pendingPluginLoadKey = "lithe.plugins.pending-code-load" +} diff --git a/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift b/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift index 1f05f57e..6edb4ebc 100644 --- a/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift +++ b/Sources/Lithe/Platform/MacOS/Terminal/MacTerminalTransport.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import SwiftTerm +import LitheTerminalModule /// SwiftTerm's default link handler opens URLs in the system. Lithe needs the /// link event so workspace-relative paths can open in its own editor instead. diff --git a/Sources/Lithe/Services/Debug/DebugAdapterRuntimeFactory.swift b/Sources/Lithe/Services/Debug/DebugAdapterRuntimeFactory.swift new file mode 100644 index 00000000..179ca415 --- /dev/null +++ b/Sources/Lithe/Services/Debug/DebugAdapterRuntimeFactory.swift @@ -0,0 +1,55 @@ +import Foundation +import LitheCoreContracts +import LitheDebugModule + +/// Creates DAP sessions for the Debug module without constructing or retaining +/// a language-server runtime. Provider descriptors remain shared catalog data; +/// the adapter process and session are owned exclusively by Debug. +@MainActor +final class DebugAdapterRuntimeFactory { + private let runtimeService: ProjectRuntimeService + private let transportFactory: (URL, [String], [String: String]) -> any DebugAdapterTransport + private let launches: [String: StdioDebugAdapterLaunch] + private let sessionFactories: [String: () -> (any DebugAdapterSession)?] + + init( + runtimeService: ProjectRuntimeService, + transportFactory: @escaping (URL, [String], [String: String]) -> any DebugAdapterTransport, + launches: [String: StdioDebugAdapterLaunch], + sessionFactories: [String: () -> (any DebugAdapterSession)?] = [:] + ) { + self.runtimeService = runtimeService + self.transportFactory = transportFactory + self.launches = launches + self.sessionFactories = sessionFactories + } + + func makeSession( + for descriptor: DebugProviderDescriptor, + rootURL _: URL + ) -> (any DebugAdapterSession)? { + if let sessionFactory = sessionFactories[descriptor.id] { + return sessionFactory() + } + guard let launch = launches[descriptor.id] else { return nil } + + let direct = launch.executableNames.lazy.compactMap { name in + self.runtimeService.executableOnPath(name).map { ($0, launch.arguments) } + }.first + let fallback = launch.fallbacks.lazy.compactMap { fallback in + self.runtimeService.executableOnPath(fallback.executableName).map { + ($0, fallback.argumentPrefix + launch.arguments) + } + }.first + guard let (executableURL, arguments) = direct ?? fallback else { return nil } + + return DebugAdapterProtocolSession( + adapterID: launch.adapterID, + transport: transportFactory( + executableURL, + arguments, + runtimeService.processEnvironment() + ) + ) + } +} diff --git a/Sources/Lithe/Services/DebugLaunchConfigurationResolver.swift b/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift similarity index 99% rename from Sources/Lithe/Services/DebugLaunchConfigurationResolver.swift rename to Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift index 3bebd43e..dc11ed28 100644 --- a/Sources/Lithe/Services/DebugLaunchConfigurationResolver.swift +++ b/Sources/Lithe/Services/Debug/DebugLaunchConfigurationResolver.swift @@ -1,4 +1,5 @@ import Foundation +import LitheCoreContracts enum DebugLaunchConfigurationResolutionError: LocalizedError, Equatable { case unsupportedProvider(String) diff --git a/Sources/Lithe/Services/JavaCodeVisionService.swift b/Sources/Lithe/Services/Java/JavaCodeVisionService.swift similarity index 98% rename from Sources/Lithe/Services/JavaCodeVisionService.swift rename to Sources/Lithe/Services/Java/JavaCodeVisionService.swift index 797dc55c..65e1d7a1 100644 --- a/Sources/Lithe/Services/JavaCodeVisionService.swift +++ b/Sources/Lithe/Services/Java/JavaCodeVisionService.swift @@ -1,4 +1,5 @@ import Foundation +import LitheGitModule enum JavaCodeVisionService { static func hints( diff --git a/Sources/Lithe/Services/JavaDebugService.swift b/Sources/Lithe/Services/Java/JavaDebugService.swift similarity index 100% rename from Sources/Lithe/Services/JavaDebugService.swift rename to Sources/Lithe/Services/Java/JavaDebugService.swift diff --git a/Sources/Lithe/Services/Java/JavaRunService.swift b/Sources/Lithe/Services/Java/JavaRunService.swift new file mode 100644 index 00000000..ac18f718 --- /dev/null +++ b/Sources/Lithe/Services/Java/JavaRunService.swift @@ -0,0 +1,4 @@ +import LitheExecutionModule + +typealias RunService = LitheExecutionModule.RunService +typealias JavaRunService = LitheExecutionModule.RunService diff --git a/Sources/Lithe/Services/ProjectRuntimeService.swift b/Sources/Lithe/Services/Java/ProjectRuntimeService.swift similarity index 94% rename from Sources/Lithe/Services/ProjectRuntimeService.swift rename to Sources/Lithe/Services/Java/ProjectRuntimeService.swift index bf9e7c48..81da7e31 100644 --- a/Sources/Lithe/Services/ProjectRuntimeService.swift +++ b/Sources/Lithe/Services/Java/ProjectRuntimeService.swift @@ -1,10 +1,33 @@ import Foundation +import LitheCoreContracts enum ProjectRuntimeProcessKind: Sendable { case java case maven } +extension ProjectRuntimeService: RunRuntimePort {} + +extension ProjectRuntimeService: LanguageToolRuntimePort { + package func languageToolProcessEnvironment() -> [String: String] { + processEnvironment() + } + + package func missingLanguageToolMessage(_ name: String) -> String { + missingToolMessage(name) + } +} + +extension ProjectRuntimeService: MavenRuntimePort { + package func mavenExecutable(for project: MavenProject) -> URL? { + mavenExecutable(for: project, overridePath: nil) + } + + package func mavenProcessEnvironment() -> [String: String] { + environment(for: .maven) + } +} + @MainActor final class ProjectRuntimeService: ObservableObject { @Published private(set) var projectURL: URL? @@ -279,7 +302,7 @@ final class ProjectRuntimeService: ObservableObject { return runtimeLocator.isExecutable(at: url) ? url : nil } - func mavenExecutable(for project: MavenProject, overridePath: String? = nil) -> URL? { + package func mavenExecutable(for project: MavenProject, overridePath: String? = nil) -> URL? { mavenExecutable(at: project.rootURL, overridePath: overridePath) } diff --git a/Sources/Lithe/Services/RunExecutableResolver.swift b/Sources/Lithe/Services/Java/RunExecutableResolver.swift similarity index 100% rename from Sources/Lithe/Services/RunExecutableResolver.swift rename to Sources/Lithe/Services/Java/RunExecutableResolver.swift diff --git a/Sources/Lithe/Services/RunToolchainMetadataResolver.swift b/Sources/Lithe/Services/Java/RunToolchainMetadataResolver.swift similarity index 100% rename from Sources/Lithe/Services/RunToolchainMetadataResolver.swift rename to Sources/Lithe/Services/Java/RunToolchainMetadataResolver.swift diff --git a/Sources/Lithe/Services/LanguagePackRegistry.swift b/Sources/Lithe/Services/Language/LanguagePackRegistry.swift similarity index 88% rename from Sources/Lithe/Services/LanguagePackRegistry.swift rename to Sources/Lithe/Services/Language/LanguagePackRegistry.swift index f6c23816..68981229 100644 --- a/Sources/Lithe/Services/LanguagePackRegistry.swift +++ b/Sources/Lithe/Services/Language/LanguagePackRegistry.swift @@ -1,4 +1,5 @@ import Foundation +import LitheExecutionModule /// The single registration surface for language capabilities. /// @@ -49,7 +50,8 @@ final class LanguagePackRegistry { /// platform composition root; constructing this value itself is inert. static func standard( catalog: LanguageProviderCatalog = .standard, - runtimes: [any LanguageProviderRuntime] = [] + runtimes: [any LanguageProviderRuntime] = [], + extensionRequiredProviderIDs: Set = [] ) -> Self { let runtimeByID = Dictionary(uniqueKeysWithValues: runtimes.map { ($0.descriptor.id, $0) @@ -57,12 +59,14 @@ final class LanguagePackRegistry { let standardToolchains = RunToolchainRegistry.standardProviders() let packs = catalog.descriptors.map { descriptor in - let runProvider: (any LanguageRunProvider)? = descriptor.id == "java" + let isExtensionOwned = extensionRequiredProviderIDs.contains(descriptor.id) + let runProvider: (any LanguageRunProvider)? = descriptor.id == "java" || isExtensionOwned ? nil : descriptor.capabilities.contains(.run) ? StandardLanguageRunProvider(descriptor: descriptor) : nil - let testProviders: [any LanguageTestProvider] = descriptor.capabilities.contains(.testing) + let testProviders: [any LanguageTestProvider] = !isExtensionOwned + && descriptor.capabilities.contains(.testing) ? [StandardLanguageTestProvider(descriptor: descriptor)] : [] return LanguagePack( @@ -71,7 +75,9 @@ final class LanguagePackRegistry { toolchainProviders: standardToolchains.filter { $0.languageProviderID == descriptor.id }, - debugAdapterLaunch: Self.standardDebugAdapterDefinition(for: descriptor.id), + debugAdapterLaunch: isExtensionOwned + ? nil + : Self.standardDebugAdapterDefinition(for: descriptor.id), toolingRuntime: runtimeByID[descriptor.id], testProviders: testProviders ) diff --git a/Sources/Lithe/Services/LanguageServerTextEditApplicator.swift b/Sources/Lithe/Services/Language/LanguageServerTextEditApplicator.swift similarity index 100% rename from Sources/Lithe/Services/LanguageServerTextEditApplicator.swift rename to Sources/Lithe/Services/Language/LanguageServerTextEditApplicator.swift diff --git a/Sources/Lithe/Services/LanguageTestService.swift b/Sources/Lithe/Services/LanguageTestService.swift deleted file mode 100644 index 6b97bca0..00000000 --- a/Sources/Lithe/Services/LanguageTestService.swift +++ /dev/null @@ -1,187 +0,0 @@ -import Foundation - -enum LanguageTestRunState: Equatable, Sendable { - case idle - case running - case passed - case failed(exitCode: Int32) - case cancelled -} - -@MainActor -final class LanguageTestService: ObservableObject { - @Published private(set) var itemsByProviderID: [String: [LanguageTestItem]] = [:] - @Published private(set) var state: LanguageTestRunState = .idle - @Published private(set) var activePlan: LanguageTestPlan? - @Published private(set) var output = "" - @Published private(set) var errorMessage: String? - - private let catalog: LanguageProviderCatalog - private let registry: LanguageTestProviderRegistry - private let executableResolver: any RunExecutableResolving - private let processFactory: () -> any StreamingProcess - private var process: (any StreamingProcess)? - private var activeOperationID: String? - private let maximumOutputCharacters = 400_000 - - init( - catalog: LanguageProviderCatalog = .standard, - registry: LanguageTestProviderRegistry? = nil, - executableResolver: any RunExecutableResolving, - processFactory: @escaping () -> any StreamingProcess - ) { - self.catalog = catalog - self.registry = registry ?? .standard(catalog: catalog) - self.executableResolver = executableResolver - self.processFactory = processFactory - } - - convenience init( - registry: LanguagePackRegistry, - executableResolver: any RunExecutableResolving, - processFactory: @escaping () -> any StreamingProcess - ) { - self.init( - catalog: registry.catalog, - registry: registry.testProviders, - executableResolver: executableResolver, - processFactory: processFactory - ) - } - - var isRunning: Bool { state == .running } - - func discover(workspaceURL: URL, files: [URL]) { - var discovered: [String: [LanguageTestItem]] = [:] - let context = LanguageTestContext( - workspaceURL: workspaceURL, - projectFiles: files - ) - for descriptor in catalog.descriptors where descriptor.capabilities.contains(.testing) { - guard let provider = registry.provider(id: descriptor.id) else { continue } - let items = provider.discoverTests(context: context) - if !items.isEmpty { discovered[descriptor.id] = items } - } - itemsByProviderID = discovered - } - - @discardableResult - func run( - providerID: String, - scope: LanguageTestScope, - workspaceURL: URL, - projectFiles: [URL] = [], - options: RunOptions = RunOptions() - ) -> Bool { - stop(markCancelled: false) - output = "" - errorMessage = nil - let root = workspaceURL.standardizedFileURL - do { - guard let provider = registry.provider(id: providerID) else { - throw LanguageTestPlanError.unsupportedProvider(providerID) - } - let plan = try provider.testPlan( - scope: scope, - context: LanguageTestContext( - workspaceURL: root, - projectFiles: projectFiles - ) - ) - let resolved = try executableResolver.resolve( - plan.launchPlan, - projectURL: root, - options: options - ) - let workingDirectory = try resolvedWorkingDirectory( - plan.launchPlan.workingDirectory, - workspaceURL: root - ) - let operationID = UUID().uuidString - let process = processFactory() - process.onOutput = { [weak self] chunk in - Task { @MainActor [weak self] in - guard self?.activeOperationID == operationID else { return } - self?.append(chunk) - } - } - process.onTermination = { [weak self] exitCode in - Task { @MainActor [weak self] in - guard let self, self.activeOperationID == operationID else { return } - self.state = exitCode == 0 ? .passed : .failed(exitCode: exitCode) - self.activeOperationID = nil - self.process = nil - } - } - self.process = process - activeOperationID = operationID - activePlan = plan - state = .running - append("$ \(resolved.executableURL.lastPathComponent) \(plan.launchPlan.arguments.joined(separator: " "))\n\n") - try process.start(ProcessRequest( - operationID: operationID, - executablePath: resolved.executableURL.path, - arguments: plan.launchPlan.arguments, - workingDirectory: workingDirectory.path, - environment: resolved.environment - )) - return true - } catch { - process?.stop() - process = nil - activeOperationID = nil - activePlan = nil - state = .failed(exitCode: -1) - errorMessage = error.localizedDescription - append(error.localizedDescription + "\n") - return false - } - } - - func stop() { stop(markCancelled: true) } - - func reset() { - stop(markCancelled: false) - itemsByProviderID = [:] - activePlan = nil - output = "" - errorMessage = nil - state = .idle - } - - func clearOutput() { output = "" } - - private func stop(markCancelled: Bool) { - let wasRunning = state == .running - activeOperationID = nil - process?.stop() - process = nil - if wasRunning && markCancelled { state = .cancelled } - else if !markCancelled { state = .idle } - } - - private func resolvedWorkingDirectory( - _ value: String, - workspaceURL: URL - ) throws -> URL { - let candidate: URL - if value.isEmpty || value == "." { - candidate = workspaceURL - } else if value.hasPrefix("/") { - candidate = URL(fileURLWithPath: value, isDirectory: true).standardizedFileURL - } else { - candidate = workspaceURL.appendingPathComponent(value, isDirectory: true).standardizedFileURL - } - guard candidate.path == workspaceURL.path || candidate.path.hasPrefix(workspaceURL.path + "/") else { - throw LanguageTestPlanError.fileOutsideWorkspace(candidate) - } - return candidate - } - - private func append(_ text: String) { - output += text - if output.count > maximumOutputCharacters { - output.removeFirst(output.count - maximumOutputCharacters) - } - } -} diff --git a/Sources/Lithe/Services/MarkdownImageImportService.swift b/Sources/Lithe/Services/Markdown/MarkdownImageImportService.swift similarity index 100% rename from Sources/Lithe/Services/MarkdownImageImportService.swift rename to Sources/Lithe/Services/Markdown/MarkdownImageImportService.swift diff --git a/Sources/Lithe/Services/MemoryUsageMonitor.swift b/Sources/Lithe/Services/Monitoring/MemoryUsageMonitor.swift similarity index 91% rename from Sources/Lithe/Services/MemoryUsageMonitor.swift rename to Sources/Lithe/Services/Monitoring/MemoryUsageMonitor.swift index 606b4f4e..9b7ebd32 100644 --- a/Sources/Lithe/Services/MemoryUsageMonitor.swift +++ b/Sources/Lithe/Services/Monitoring/MemoryUsageMonitor.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import LitheModuleAPI enum ManagedProcessCategory: String, Sendable { case languageServer @@ -9,22 +10,46 @@ enum ManagedProcessCategory: String, Sendable { final class ManagedProcessRegistry: @unchecked Sendable { private let lock = NSLock() private var entries: [ManagedProcessCategory: Set] = [:] + private var moduleEntries: [ModuleID: Set] = [:] func register(pid: Int32, category: ManagedProcessCategory) { + register(pid: pid, category: category, moduleID: nil) + } + + func register(pid: Int32, category: ManagedProcessCategory, moduleID: ModuleID?) { guard pid > 0 else { return } lock.lock(); defer { lock.unlock() } entries[category, default: []].insert(pid) + if let moduleID { moduleEntries[moduleID, default: []].insert(pid) } } func unregister(pid: Int32, category: ManagedProcessCategory) { + unregister(pid: pid, category: category, moduleID: nil) + } + + func unregister(pid: Int32, category: ManagedProcessCategory, moduleID: ModuleID?) { lock.lock(); defer { lock.unlock() } entries[category]?.remove(pid) + if let moduleID { + moduleEntries[moduleID]?.remove(pid) + } else { + for id in moduleEntries.keys { moduleEntries[id]?.remove(pid) } + } } func processIDs(for category: ManagedProcessCategory) -> Set { lock.lock(); defer { lock.unlock() } return entries[category] ?? [] } + + func processIDs(for moduleID: ModuleID) -> Set { + lock.lock(); defer { lock.unlock() } + return moduleEntries[moduleID] ?? [] + } + + func processCount(for moduleID: ModuleID) -> Int { + processIDs(for: moduleID).count + } } protocol ManagedProcessMemorySampling: Sendable { diff --git a/Sources/Lithe/Services/Runtime/OutputTimestamper.swift b/Sources/Lithe/Services/Runtime/OutputTimestamper.swift new file mode 100644 index 00000000..dfabfcc1 --- /dev/null +++ b/Sources/Lithe/Services/Runtime/OutputTimestamper.swift @@ -0,0 +1,3 @@ +import LitheCoreContracts + +typealias OutputTimestamper = LitheCoreContracts.OutputTimestamper diff --git a/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift b/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift deleted file mode 100644 index 482aff53..00000000 --- a/Sources/Lithe/Services/StdioLanguageProviderRuntime.swift +++ /dev/null @@ -1,217 +0,0 @@ -import Foundation - -@MainActor -final class StdioLanguageProviderRuntime: LanguageProviderRuntime { - let descriptor: LanguageProviderDescriptor - private let runtimeService: ProjectRuntimeService - /// Kept for the debug adapter only: LSP transport lives in the Rust runtime. - private let processFactory: () -> any RawProcessSession - private let languageServerLaunch: LanguageServerLaunchDescriptor? - private let languageServerCore: any LanguageServerRuntimeCore - private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? - private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? - private let languageServerCacheDirectory: URL? - private let processRegistry: ManagedProcessRegistry? - private let debugLaunch: StdioDebugAdapterLaunch? - private let debugSessionFactory: (() -> (any DebugAdapterSession)?)? - - var supportsLanguageServerSession: Bool { - languageServerLaunch != nil - } - - var supportsDebugAdapterSession: Bool { - debugLaunch != nil || debugSessionFactory != nil - } - - var unavailableToolingMessage: String? { - guard let command = languageServerLaunch?.executableNames.first - ?? debugLaunch?.executableNames.first else { return nil } - return runtimeService.missingToolMessage(command) - } - - init( - descriptor: LanguageProviderDescriptor, - runtimeService: ProjectRuntimeService, - processFactory: @escaping () -> any RawProcessSession, - languageServerLaunch: LanguageServerLaunchDescriptor? = nil, - languageServerCore: any LanguageServerRuntimeCore = RustCoreBridge(), - languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerCacheDirectory: URL? = nil, - processRegistry: ManagedProcessRegistry? = nil, - debugLaunch: StdioDebugAdapterLaunch? = nil, - debugSessionFactory: (() -> (any DebugAdapterSession)?)? = nil - ) { - self.descriptor = descriptor - self.runtimeService = runtimeService - self.processFactory = processFactory - self.languageServerLaunch = languageServerLaunch - self.languageServerCore = languageServerCore - self.languageServerExecutableResolver = languageServerExecutableResolver - self.languageServerRuntimeResolver = languageServerRuntimeResolver - self.languageServerCacheDirectory = languageServerCacheDirectory - self.processRegistry = processRegistry - self.debugLaunch = debugLaunch - self.debugSessionFactory = debugSessionFactory - } - - func makeLanguageServerSession() -> (any LanguageServerSession)? { - guard let languageServerLaunch else { return nil } - let executableURL = if let languageServerExecutableResolver { - languageServerExecutableResolver(descriptor) - } else { - languageServerLaunch.executableNames.lazy.compactMap({ - self.runtimeService.executableOnPath($0) - }).first - } - guard let executableURL else { return nil } - var environment = runtimeService.processEnvironment() - environment.merge(languageServerLaunch.environment) { _, configured in configured } - return StdioLanguageServerSession( - providerID: descriptor.id, - executableURL: executableURL, - arguments: languageServerLaunch.arguments, - environment: environment, - initializationOptions: languageServerLaunch.initializationOptions, - runtimeExecutableURL: languageServerRuntimeResolver?(descriptor), - cacheDirectoryURL: languageServerCacheDirectory, - core: languageServerCore, - processRegistry: processRegistry - ) - } - - func makeDebugAdapterSession() -> (any DebugAdapterSession)? { - if let debugSessionFactory { return debugSessionFactory() } - guard let debugLaunch else { return nil } - let direct = debugLaunch.executableNames.lazy.compactMap({ name in - self.runtimeService.executableOnPath(name).map { ($0, debugLaunch.arguments) } - }).first - let fallback = debugLaunch.fallbacks.lazy.compactMap { fallback in - self.runtimeService.executableOnPath(fallback.executableName).map { - ($0, fallback.argumentPrefix + debugLaunch.arguments) - } - }.first - guard let (executableURL, arguments) = direct ?? fallback else { return nil } - return DebugAdapterProtocolSession( - adapterID: debugLaunch.adapterID, - executableURL: executableURL, - arguments: arguments, - environment: runtimeService.processEnvironment(), - process: processFactory() - ) - } - - static func standard( - packs: [LanguagePack], - runtimeService: ProjectRuntimeService, - processFactory: @escaping () -> any RawProcessSession, - languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerCacheDirectory: URL? = nil, - debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:] - ) -> [any LanguageProviderRuntime] { - packs.compactMap { pack in - 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, - languageServerLaunch: pack.descriptor.languageServerLaunch, - languageServerExecutableResolver: languageServerExecutableResolver, - languageServerRuntimeResolver: languageServerRuntimeResolver, - languageServerCacheDirectory: languageServerCacheDirectory, - debugLaunch: pack.debugAdapterLaunch, - debugSessionFactory: debugSessionFactories[pack.descriptor.id] - ) - } - } - - static func standard( - catalog: LanguageProviderCatalog, - runtimeService: ProjectRuntimeService, - processFactory: @escaping () -> any RawProcessSession, - languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerCacheDirectory: URL? = nil, - debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:] - ) -> [any LanguageProviderRuntime] { - standard( - packs: LanguagePackRegistry.standard(catalog: catalog).packs, - runtimeService: runtimeService, - processFactory: processFactory, - languageServerExecutableResolver: languageServerExecutableResolver, - languageServerRuntimeResolver: languageServerRuntimeResolver, - languageServerCacheDirectory: languageServerCacheDirectory, - debugSessionFactories: debugSessionFactories - ) - } -} - -@MainActor -final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntimeFactory { - private let runtimeService: ProjectRuntimeService - private let processFactory: () -> any RawProcessSession - private let languageServerCore: any LanguageServerRuntimeCore - private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? - private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? - private let languageServerCacheDirectory: URL? - private let processRegistry: ManagedProcessRegistry? - private let debugLaunches: [String: StdioDebugAdapterLaunch] - private let debugSessionFactories: [String: () -> (any DebugAdapterSession)?] - - init( - runtimeService: ProjectRuntimeService, - processFactory: @escaping () -> any RawProcessSession, - languageServerCore: any LanguageServerRuntimeCore = RustCoreBridge(), - languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, - languageServerCacheDirectory: URL? = nil, - processRegistry: ManagedProcessRegistry? = nil, - debugLaunches: [String: StdioDebugAdapterLaunch] = [:], - debugSessionFactories: [String: () -> (any DebugAdapterSession)?] = [:] - ) { - self.runtimeService = runtimeService - self.processFactory = processFactory - self.languageServerCore = languageServerCore - self.languageServerExecutableResolver = languageServerExecutableResolver - self.languageServerRuntimeResolver = languageServerRuntimeResolver - self.languageServerCacheDirectory = languageServerCacheDirectory - self.processRegistry = processRegistry - 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, - languageServerRuntimeResolver: languageServerRuntimeResolver, - languageServerCacheDirectory: languageServerCacheDirectory, - processRegistry: processRegistry, - debugLaunch: debugLaunch, - debugSessionFactory: debugSessionFactory - ) - } -} diff --git a/Sources/Lithe/Services/TerminalLinkResolver.swift b/Sources/Lithe/Services/TerminalLinkResolver.swift deleted file mode 100644 index 984c20e5..00000000 --- a/Sources/Lithe/Services/TerminalLinkResolver.swift +++ /dev/null @@ -1,79 +0,0 @@ -import Foundation - -struct TerminalLinkLocation: Equatable { - let url: URL - let line: Int? - let column: Int? -} - -enum TerminalLinkTarget: Equatable { - case file(TerminalLinkLocation) - case external(URL) -} - -enum TerminalLinkResolver { - /// Resolves SwiftTerm's implicit links without interpreting terminal output - /// as commands. Trailing line and column numbers are treated as editor - /// coordinates when present, for example `Sources/App.swift:42:7`. - static func resolve( - _ rawLink: String, - relativeTo directory: URL, - fileExists: (URL) -> Bool - ) -> TerminalLinkTarget? { - let rawLink = rawLink.trimmingCharacters(in: .whitespacesAndNewlines) - guard !rawLink.isEmpty else { return nil } - - // Do not interpret a URL port or numeric path segment as an editor line. - if let externalURL = URL(string: rawLink), - let scheme = externalURL.scheme, - !scheme.isEmpty, - !externalURL.isFileURL { - return .external(externalURL) - } - - let (link, line, column) = splitLocationSuffix(rawLink) - guard !link.isEmpty else { return nil } - - let path: String - if let fileURL = URL(string: link), fileURL.isFileURL { - path = fileURL.path - } else { - path = (link as NSString).expandingTildeInPath - } - - let fileURL: URL - if path.hasPrefix("/") { - fileURL = URL(fileURLWithPath: path).standardizedFileURL - } else { - fileURL = directory.appendingPathComponent(path).standardizedFileURL - } - guard fileExists(fileURL) else { return nil } - return .file( - TerminalLinkLocation( - url: fileURL, - line: line, - column: column - ) - ) - } - - private static func splitLocationSuffix(_ value: String) -> (String, Int?, Int?) { - var components = value.split(separator: ":", omittingEmptySubsequences: false).map(String.init) - var line: Int? - var column: Int? - - if components.count >= 3, - let maybeColumn = Int(components[components.count - 1]), - let maybeLine = Int(components[components.count - 2]) { - column = maybeColumn - line = maybeLine - components.removeLast(2) - } else if components.count >= 2, - let maybeLine = Int(components[components.count - 1]) { - line = maybeLine - components.removeLast() - } - - return (components.joined(separator: ":"), line, column) - } -} diff --git a/Sources/Lithe/Services/TerminalSession.swift b/Sources/Lithe/Services/TerminalSession.swift deleted file mode 100644 index 9e8cfeed..00000000 --- a/Sources/Lithe/Services/TerminalSession.swift +++ /dev/null @@ -1,162 +0,0 @@ -import Foundation - -@MainActor -final class TerminalSession: ObservableObject, Identifiable { - let id = UUID() - - @Published private(set) var isRunning = false - @Published private(set) var isReady = false - @Published private(set) var shellName = "Shell" - @Published private(set) var processTitle: String? - @Published private(set) var currentDirectory: URL? - @Published private(set) var lastExitCode: Int32? - @Published private(set) var startedAt: Date? - @Published private(set) var endedAt: Date? - - /// Receives links recognized by the terminal surface. AppModel supplies the - /// editor-aware handler when the session is created. - var onLink: ((String, [String: String]) -> Void)? - - private let transport: any TerminalTransport - private var workspaceURL: URL? - private var selectedShellPath: String? - init(transport: any TerminalTransport) { - self.transport = transport - transport.onTermination = { [weak self] exitCode in - guard let self else { return } - isRunning = false - isReady = false - lastExitCode = exitCode - endedAt = Date() - } - transport.onTitle = { [weak self] title in - let normalized = title.trimmingCharacters(in: .whitespacesAndNewlines) - self?.processTitle = normalized.isEmpty ? nil : normalized - } - transport.onDirectoryUpdate = { [weak self] directory in - self?.updateCurrentDirectory(directory) - } - transport.onLink = { [weak self] link, params in - self?.onLink?(link, params) - } - } - - var nativeView: AnyObject { transport.nativeView } - - var displayTitle: String { - if let processTitle, !processTitle.isEmpty { - return processTitle - } - return shellName - } - - var displayDirectory: String? { - currentDirectory?.lastPathComponent.nonEmpty - } - - func elapsedDescription(at date: Date = Date()) -> String? { - guard let startedAt else { return nil } - let end = endedAt ?? date - let elapsed = max(0, end.timeIntervalSince(startedAt)) - let totalSeconds = Int(elapsed.rounded(.down)) - let hours = totalSeconds / 3_600 - let minutes = (totalSeconds % 3_600) / 60 - let seconds = totalSeconds % 60 - if hours > 0 { - return String(format: "%d:%02d:%02d", hours, minutes, seconds) - } - return String(format: "%02d:%02d", minutes, seconds) - } - - func start(in workspaceURL: URL, shellPath: String? = nil) { - stop() - self.workspaceURL = workspaceURL - currentDirectory = workspaceURL.standardizedFileURL - processTitle = nil - lastExitCode = nil - startedAt = Date() - endedAt = nil - - let shell = shellPath ?? selectedShellPath ?? transport.defaultShellPath() - selectedShellPath = shell - shellName = URL(fileURLWithPath: shell).lastPathComponent - - var environment = transport.defaultEnvironment() - environment["TERM"] = "xterm-256color" - environment["COLORTERM"] = "truecolor" - environment["TERM_PROGRAM"] = "Lithe" - - do { - try transport.start( - workingDirectory: workspaceURL.path, - shellPath: shell, - environment: environment - ) - isRunning = transport.isRunning - isReady = isRunning - } catch { - isRunning = false - isReady = false - startedAt = nil - endedAt = Date() - } - } - - func restart() { - guard let workspaceURL else { return } - start(in: workspaceURL, shellPath: selectedShellPath) - } - - func restart(using shellPath: String) { - guard let workspaceURL else { return } - start(in: workspaceURL, shellPath: shellPath) - } - - func send(_ command: String) { - sendInput(command + "\n") - } - - func sendInput(_ input: String) { - guard isRunning, isReady else { return } - guard let data = input.data(using: .utf8) else { return } - try? transport.send(data) - } - - func interrupt() { - guard isRunning else { return } - try? transport.interrupt() - } - - func clear() { - transport.clear() - } - - func focus() { - transport.focus() - } - - func stop() { - transport.stop() - isRunning = false - isReady = false - if startedAt != nil { - endedAt = Date() - } - } - - private func updateCurrentDirectory(_ rawValue: String?) { - guard let rawValue, !rawValue.isEmpty else { return } - if let url = URL(string: rawValue), url.isFileURL { - currentDirectory = url.standardizedFileURL - } else if rawValue.hasPrefix("/") { - currentDirectory = URL(fileURLWithPath: rawValue).standardizedFileURL - } - } - -} - -private extension String { - var nonEmpty: String? { - isEmpty ? nil : self - } -} diff --git a/Sources/Lithe/Services/WorkbenchLayoutStore.swift b/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift similarity index 100% rename from Sources/Lithe/Services/WorkbenchLayoutStore.swift rename to Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift diff --git a/Sources/Lithe/Services/RecentProjectsStore.swift b/Sources/Lithe/Services/Workspace/RecentProjectsStore.swift similarity index 100% rename from Sources/Lithe/Services/RecentProjectsStore.swift rename to Sources/Lithe/Services/Workspace/RecentProjectsStore.swift diff --git a/Sources/Lithe/Services/WorkspaceSessionStore.swift b/Sources/Lithe/Services/Workspace/WorkspaceSessionStore.swift similarity index 82% rename from Sources/Lithe/Services/WorkspaceSessionStore.swift rename to Sources/Lithe/Services/Workspace/WorkspaceSessionStore.swift index 1f44fd81..e28450f2 100644 --- a/Sources/Lithe/Services/WorkspaceSessionStore.swift +++ b/Sources/Lithe/Services/Workspace/WorkspaceSessionStore.swift @@ -1,12 +1,10 @@ import Foundation +import LitheCoreContracts -struct WorkspaceSession: Codable, Sendable { - let openPaths: [String] - let activePath: String? - let selectedSidebar: String -} +typealias WorkspaceSession = LitheCoreContracts.WorkspaceSession -struct WorkspaceSessionStore { +@MainActor +final class WorkspaceSessionStore: WorkspaceSessionStoring { private static let keyPrefix = "lithe.workspace-session." private let store: any KeyValueStore diff --git a/Sources/Lithe/Theme/DatabaseBrandIcon.swift b/Sources/Lithe/Theme/DatabaseBrandIcon.swift index 094e5e2d..a84ff15b 100644 --- a/Sources/Lithe/Theme/DatabaseBrandIcon.swift +++ b/Sources/Lithe/Theme/DatabaseBrandIcon.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import LitheDatabaseModule extension DatabaseKind { var brandIconFilename: String { diff --git a/Sources/Lithe/Theme/JavaFileIconResolver.swift b/Sources/Lithe/Theme/JavaFileIconResolver.swift new file mode 100644 index 00000000..5b91ef97 --- /dev/null +++ b/Sources/Lithe/Theme/JavaFileIconResolver.swift @@ -0,0 +1,12 @@ +import Foundation + +enum JavaFileIconResolver { + static func resolve(for url: URL, storage: any FileStorage) async -> LitheIconKind? { + guard url.pathExtension.lowercased() == "java" else { return nil } + let data = await Task.detached(priority: .utility) { + try? storage.readPrefix(from: url, byteCount: 4 * 1024) + }.value + guard let data, let prefix = String(data: data, encoding: .utf8) else { return nil } + return LitheIcons.javaSymbolKind(fromSourcePrefix: prefix) + } +} diff --git a/Sources/Lithe/Views/RootView.swift b/Sources/Lithe/Views/App/RootView.swift similarity index 99% rename from Sources/Lithe/Views/RootView.swift rename to Sources/Lithe/Views/App/RootView.swift index 68d35b7d..84904a3f 100644 --- a/Sources/Lithe/Views/RootView.swift +++ b/Sources/Lithe/Views/App/RootView.swift @@ -119,7 +119,6 @@ struct RootView: View { WelcomeView() } else { WorkbenchView() - .environmentObject(session.runFeature) .ignoresSafeArea(.container, edges: .top) } } diff --git a/Sources/Lithe/Views/SettingsView.swift b/Sources/Lithe/Views/App/SettingsView.swift similarity index 91% rename from Sources/Lithe/Views/SettingsView.swift rename to Sources/Lithe/Views/App/SettingsView.swift index a5bab4c3..ea7976c8 100644 --- a/Sources/Lithe/Views/SettingsView.swift +++ b/Sources/Lithe/Views/App/SettingsView.swift @@ -1,5 +1,8 @@ import AppKit import SwiftUI +import LitheCoreContracts +import LitheGitModule +import LitheModuleAPI struct SettingsView: View { @Environment(\.dismiss) private var dismiss @@ -117,6 +120,7 @@ struct SettingsView: View { case .terminal: terminalSettings case .lsp: EmptyView() case .ai: aiSettings + case .plugins: pluginSettings case .updates: updatesSettings } } @@ -260,6 +264,84 @@ struct SettingsView: View { } } + private var pluginSettings: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Spacer() + Button { + model.installPluginPackage() + } label: { + Label("Install", systemImage: "plus") + } + .buttonStyle(.bordered) + } + + ForEach(model.pluginManagementIssues) { issue in + HStack(spacing: 10) { + Label(issue.message, systemImage: "exclamationmark.triangle") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.warning) + .fixedSize(horizontal: false, vertical: true) + Spacer() + if let pluginID = issue.pluginID { + Button("Roll Back") { + model.rollbackPlugin(pluginID) + } + Button("Uninstall", role: .destructive) { + model.uninstallPlugin(pluginID) + } + } + } + } + + ForEach(model.pluginSnapshots) { plugin in + HStack(spacing: 12) { + Image(systemName: "puzzlepiece.extension") + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 20) + VStack(alignment: .leading, spacing: 3) { + Text(plugin.manifest.displayName) + .font(.system(size: 13, weight: .medium)) + Text(verbatim: "\(plugin.manifest.vendor.displayName) · \(plugin.manifest.version)") + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + Text(plugin.statusMessage) + .font(LitheTheme.smallFont) + .foregroundStyle(plugin.requiresRestart ? LitheTheme.warning : LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + if plugin.origin == .marketplace { + Menu { + Button("Roll Back") { + model.rollbackPlugin(plugin.id) + } + .disabled(!plugin.canRollback) + Divider() + Button("Uninstall", role: .destructive) { + model.uninstallPlugin(plugin.id) + } + .disabled(plugin.isRequired || plugin.installationStatus == .uninstallPending) + } label: { + Image(systemName: "ellipsis") + } + .menuStyle(.borderlessButton) + .frame(width: 28) + .help("Plugin actions") + } + Toggle("Enabled", isOn: Binding( + get: { plugin.isEnabled }, + set: { model.setPluginEnabled($0, pluginID: plugin.id) } + )) + .labelsHidden() + .disabled(plugin.isRequired || plugin.installationStatus == .uninstallPending) + } + .padding(.vertical, 6) + Divider() + } + } + } + private var editorSettings: some View { VStack(alignment: .leading, spacing: 18) { group("Display") { diff --git a/Sources/Lithe/Views/WelcomeView.swift b/Sources/Lithe/Views/App/WelcomeView.swift similarity index 100% rename from Sources/Lithe/Views/WelcomeView.swift rename to Sources/Lithe/Views/App/WelcomeView.swift diff --git a/Sources/Lithe/Views/DatabaseLocalization.swift b/Sources/Lithe/Views/Database/DatabaseLocalization.swift similarity index 99% rename from Sources/Lithe/Views/DatabaseLocalization.swift rename to Sources/Lithe/Views/Database/DatabaseLocalization.swift index 494c1098..abf4fc92 100644 --- a/Sources/Lithe/Views/DatabaseLocalization.swift +++ b/Sources/Lithe/Views/Database/DatabaseLocalization.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheDatabaseModule /// Database operations keep some user-facing status values as strings so they /// can be persisted in the audit log. Resolve those strings at the view edge: diff --git a/Sources/Lithe/Views/DatabaseSQLWorkspaceView.swift b/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift similarity index 99% rename from Sources/Lithe/Views/DatabaseSQLWorkspaceView.swift rename to Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift index 090ffe32..323b7e77 100644 --- a/Sources/Lithe/Views/DatabaseSQLWorkspaceView.swift +++ b/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import LitheDatabaseModule struct DatabaseWorkspaceView: View { @EnvironmentObject private var model: AppModel @@ -380,13 +381,7 @@ private struct DatabaseDashboardView: View { } } -enum DatabaseWorkspaceSection: String, CaseIterable, Identifiable, Sendable { - case data - case sql - case structure - case history - - var id: String { rawValue } +extension DatabaseWorkspaceSection { var titleKey: LocalizedStringKey { switch self { case .data: "Data" diff --git a/Sources/Lithe/Views/DatabaseSchemaDiffView.swift b/Sources/Lithe/Views/Database/DatabaseSchemaDiffView.swift similarity index 99% rename from Sources/Lithe/Views/DatabaseSchemaDiffView.swift rename to Sources/Lithe/Views/Database/DatabaseSchemaDiffView.swift index d9501a5a..0dad0fd2 100644 --- a/Sources/Lithe/Views/DatabaseSchemaDiffView.swift +++ b/Sources/Lithe/Views/Database/DatabaseSchemaDiffView.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import LitheDatabaseModule struct DatabaseSchemaDiffView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/DatabaseSidebarView.swift b/Sources/Lithe/Views/Database/DatabaseSidebarView.swift similarity index 99% rename from Sources/Lithe/Views/DatabaseSidebarView.swift rename to Sources/Lithe/Views/Database/DatabaseSidebarView.swift index d792d41f..8c5b911b 100644 --- a/Sources/Lithe/Views/DatabaseSidebarView.swift +++ b/Sources/Lithe/Views/Database/DatabaseSidebarView.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import UniformTypeIdentifiers +import LitheDatabaseModule private struct DatabaseTableContextAction { enum Kind { case clear, drop } diff --git a/Sources/Lithe/Views/DatabaseSpecializedWorkspaceViews.swift b/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift similarity index 99% rename from Sources/Lithe/Views/DatabaseSpecializedWorkspaceViews.swift rename to Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift index a49182e0..61a493e8 100644 --- a/Sources/Lithe/Views/DatabaseSpecializedWorkspaceViews.swift +++ b/Sources/Lithe/Views/Database/DatabaseSpecializedWorkspaceViews.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheDatabaseModule struct RedisWorkspaceView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/DatabaseTableView.swift b/Sources/Lithe/Views/Database/DatabaseTableView.swift similarity index 99% rename from Sources/Lithe/Views/DatabaseTableView.swift rename to Sources/Lithe/Views/Database/DatabaseTableView.swift index c7815198..1f032281 100644 --- a/Sources/Lithe/Views/DatabaseTableView.swift +++ b/Sources/Lithe/Views/Database/DatabaseTableView.swift @@ -1,6 +1,7 @@ import AppKit import SwiftUI import UniformTypeIdentifiers +import LitheDatabaseModule struct DatabaseTableView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/GenericDebugView.swift b/Sources/Lithe/Views/Debug/GenericDebugView.swift similarity index 99% rename from Sources/Lithe/Views/GenericDebugView.swift rename to Sources/Lithe/Views/Debug/GenericDebugView.swift index 9d611a1f..aeb1931b 100644 --- a/Sources/Lithe/Views/GenericDebugView.swift +++ b/Sources/Lithe/Views/Debug/GenericDebugView.swift @@ -1,4 +1,6 @@ import SwiftUI +import LitheCoreContracts +import LitheDebugModule struct GenericDebugView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/JavaDebugView.swift b/Sources/Lithe/Views/Debug/JavaDebugView.swift similarity index 100% rename from Sources/Lithe/Views/JavaDebugView.swift rename to Sources/Lithe/Views/Debug/JavaDebugView.swift diff --git a/Sources/Lithe/Views/DiffCollapsedBandView.swift b/Sources/Lithe/Views/Diff/DiffCollapsedBandView.swift similarity index 100% rename from Sources/Lithe/Views/DiffCollapsedBandView.swift rename to Sources/Lithe/Views/Diff/DiffCollapsedBandView.swift diff --git a/Sources/Lithe/Views/DiffHorizontalScrollSupport.swift b/Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift similarity index 100% rename from Sources/Lithe/Views/DiffHorizontalScrollSupport.swift rename to Sources/Lithe/Views/Diff/DiffHorizontalScrollSupport.swift diff --git a/Sources/Lithe/Views/DiffMapView.swift b/Sources/Lithe/Views/Diff/DiffMapView.swift similarity index 99% rename from Sources/Lithe/Views/DiffMapView.swift rename to Sources/Lithe/Views/Diff/DiffMapView.swift index fbc81226..ec3b8a7e 100644 --- a/Sources/Lithe/Views/DiffMapView.swift +++ b/Sources/Lithe/Views/Diff/DiffMapView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheGitModule /// Whole-file change overview drawn beside the scrollbar, like IDEA's diff map. /// diff --git a/Sources/Lithe/Views/DiffPaneView.swift b/Sources/Lithe/Views/Diff/DiffPaneView.swift similarity index 99% rename from Sources/Lithe/Views/DiffPaneView.swift rename to Sources/Lithe/Views/Diff/DiffPaneView.swift index 69b00ae0..abb2ca40 100644 --- a/Sources/Lithe/Views/DiffPaneView.swift +++ b/Sources/Lithe/Views/Diff/DiffPaneView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheGitModule /// Shared side-by-side diff surface. /// diff --git a/Sources/Lithe/Views/DiffReviewView.swift b/Sources/Lithe/Views/Diff/DiffReviewView.swift similarity index 99% rename from Sources/Lithe/Views/DiffReviewView.swift rename to Sources/Lithe/Views/Diff/DiffReviewView.swift index 5404d55d..a050bc4e 100644 --- a/Sources/Lithe/Views/DiffReviewView.swift +++ b/Sources/Lithe/Views/Diff/DiffReviewView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheGitModule struct DiffReviewView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/DiffSplitPaneView.swift b/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift similarity index 99% rename from Sources/Lithe/Views/DiffSplitPaneView.swift rename to Sources/Lithe/Views/Diff/DiffSplitPaneView.swift index 92de1026..383dfa32 100644 --- a/Sources/Lithe/Views/DiffSplitPaneView.swift +++ b/Sources/Lithe/Views/Diff/DiffSplitPaneView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheGitModule /// IDEA-style side-by-side diff whose two code panes advance independently. /// One-sided changes therefore never manufacture blank source rows; their diff --git a/Sources/Lithe/Views/CodeEditorView.swift b/Sources/Lithe/Views/Editor/CodeEditorView.swift similarity index 99% rename from Sources/Lithe/Views/CodeEditorView.swift rename to Sources/Lithe/Views/Editor/CodeEditorView.swift index 8564442d..8d235307 100644 --- a/Sources/Lithe/Views/CodeEditorView.swift +++ b/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import LitheGitModule fileprivate struct CodeEditorPalette { let isDark: Bool @@ -56,7 +57,7 @@ struct CodeEditorView: NSViewRepresentable { @EnvironmentObject private var model: AppModel @EnvironmentObject private var settings: AppSettings @ObservedObject var document: EditorDocument - @ObservedObject var debugService: JavaDebugFeatureModel + var debugService: JavaDebugFeatureModel? var shouldFocus = true var markdownScrollPosition: Binding? = nil @@ -127,7 +128,7 @@ struct CodeEditorView: NSViewRepresentable { textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.isContinuousSpellCheckingEnabled = false - textView.languageServerFeatures = model.languageToolingSessions.features(for: document.url) + textView.languageServerFeatures = model.languageToolingSessionsIfActive?.features(for: document.url) ?? [] textView.isLanguageNavigationEnabled = !textView.languageServerFeatures.intersection([ .definition, .references, .implementation ]).isEmpty @@ -225,7 +226,7 @@ struct CodeEditorView: NSViewRepresentable { textView.font = .monospacedSystemFont(ofSize: settings.editorFontSize, weight: .regular) if let codeTextView = textView as? CodeTextView { codeTextView.indentationWidth = settings.tabWidth - codeTextView.languageServerFeatures = model.languageToolingSessions.features(for: document.url) + codeTextView.languageServerFeatures = model.languageToolingSessionsIfActive?.features(for: document.url) ?? [] codeTextView.isLanguageNavigationEnabled = !codeTextView.languageServerFeatures.intersection([ .definition, .references, .implementation ]).isEmpty @@ -290,7 +291,7 @@ struct CodeEditorView: NSViewRepresentable { init( document: EditorDocument, model: AppModel, - debugService: JavaDebugFeatureModel, + debugService: JavaDebugFeatureModel?, markdownScrollPosition: Binding? ) { self.document = document @@ -577,7 +578,7 @@ struct CodeEditorView: NSViewRepresentable { let javaBreakpointLines = debugService?.breakpoints.filter { $0.fileURL.standardizedFileURL == url }.map(\.line) ?? [] - let genericBreakpointLines = model.genericDebugFeature.breakpoints.filter { + let genericBreakpointLines = (model.genericDebugFeatureIfActive?.breakpoints ?? []).filter { $0.fileURL.standardizedFileURL == url }.map(\.line) let debugBreakpointLines = Set(javaBreakpointLines + genericBreakpointLines) diff --git a/Sources/Lithe/Views/EditorAreaView.swift b/Sources/Lithe/Views/Editor/EditorAreaView.swift similarity index 99% rename from Sources/Lithe/Views/EditorAreaView.swift rename to Sources/Lithe/Views/Editor/EditorAreaView.swift index 15e4fcc2..6c7f3576 100644 --- a/Sources/Lithe/Views/EditorAreaView.swift +++ b/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheGitModule private enum MarkdownViewMode: String, CaseIterable, Identifiable, Equatable { case editor @@ -510,7 +511,7 @@ struct EditorAreaView: View { if let document { CodeEditorView( document: document, - debugService: model.debugFeature, + debugService: model.debugFeatureIfActive, shouldFocus: !showsHeader && document.id == model.activeDocumentID ) .id(document.id) @@ -646,7 +647,7 @@ struct EditorAreaView: View { ) -> some View { CodeEditorView( document: document, - debugService: model.debugFeature, + debugService: model.debugFeatureIfActive, shouldFocus: true, markdownScrollPosition: markdownScrollPosition ) diff --git a/Sources/Lithe/Views/EditorTabFlowLayout.swift b/Sources/Lithe/Views/Editor/EditorTabFlowLayout.swift similarity index 100% rename from Sources/Lithe/Views/EditorTabFlowLayout.swift rename to Sources/Lithe/Views/Editor/EditorTabFlowLayout.swift diff --git a/Sources/Lithe/Views/FindBarView.swift b/Sources/Lithe/Views/Editor/FindBarView.swift similarity index 100% rename from Sources/Lithe/Views/FindBarView.swift rename to Sources/Lithe/Views/Editor/FindBarView.swift diff --git a/Sources/Lithe/Views/MarkdownPreviewView.swift b/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift similarity index 100% rename from Sources/Lithe/Views/MarkdownPreviewView.swift rename to Sources/Lithe/Views/Editor/MarkdownPreviewView.swift diff --git a/Sources/Lithe/Views/BranchComparisonView.swift b/Sources/Lithe/Views/Git/BranchComparisonView.swift similarity index 99% rename from Sources/Lithe/Views/BranchComparisonView.swift rename to Sources/Lithe/Views/Git/BranchComparisonView.swift index a830aeba..58dcff6a 100644 --- a/Sources/Lithe/Views/BranchComparisonView.swift +++ b/Sources/Lithe/Views/Git/BranchComparisonView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheGitModule struct BranchComparisonView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/BranchSwitcherPopover.swift b/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift similarity index 99% rename from Sources/Lithe/Views/BranchSwitcherPopover.swift rename to Sources/Lithe/Views/Git/BranchSwitcherPopover.swift index f98f3dd9..38cc972a 100644 --- a/Sources/Lithe/Views/BranchSwitcherPopover.swift +++ b/Sources/Lithe/Views/Git/BranchSwitcherPopover.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheGitModule struct BranchSwitcherPopover: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/ChangesSidebarView.swift b/Sources/Lithe/Views/Git/ChangesSidebarView.swift similarity index 99% rename from Sources/Lithe/Views/ChangesSidebarView.swift rename to Sources/Lithe/Views/Git/ChangesSidebarView.swift index 288cf92c..5b9d8cb8 100644 --- a/Sources/Lithe/Views/ChangesSidebarView.swift +++ b/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheGitModule struct ChangesSidebarView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/GitCommitDiffReviewView.swift b/Sources/Lithe/Views/Git/GitCommitDiffReviewView.swift similarity index 99% rename from Sources/Lithe/Views/GitCommitDiffReviewView.swift rename to Sources/Lithe/Views/Git/GitCommitDiffReviewView.swift index 1dba57b2..b2bc64ab 100644 --- a/Sources/Lithe/Views/GitCommitDiffReviewView.swift +++ b/Sources/Lithe/Views/Git/GitCommitDiffReviewView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheGitModule /// Read-only commit diff opened from the changed-files pane of Git Log. /// Working-tree diffs keep using DiffReviewView because they expose stage and diff --git a/Sources/Lithe/Views/GitGraphView.swift b/Sources/Lithe/Views/Git/GitGraphView.swift similarity index 99% rename from Sources/Lithe/Views/GitGraphView.swift rename to Sources/Lithe/Views/Git/GitGraphView.swift index fe981bbd..5551e535 100644 --- a/Sources/Lithe/Views/GitGraphView.swift +++ b/Sources/Lithe/Views/Git/GitGraphView.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import LitheGitModule /// Commit-row callbacks are grouped so that a row receives one stable value /// instead of four freshly allocated closures per redraw. Rows are compared by diff --git a/Sources/Lithe/Views/GitLogView.swift b/Sources/Lithe/Views/Git/GitLogView.swift similarity index 99% rename from Sources/Lithe/Views/GitLogView.swift rename to Sources/Lithe/Views/Git/GitLogView.swift index d259bf1a..a79de4b7 100644 --- a/Sources/Lithe/Views/GitLogView.swift +++ b/Sources/Lithe/Views/Git/GitLogView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheGitModule struct GitLogView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/LocalHistoryView.swift b/Sources/Lithe/Views/History/LocalHistoryView.swift similarity index 99% rename from Sources/Lithe/Views/LocalHistoryView.swift rename to Sources/Lithe/Views/History/LocalHistoryView.swift index f1b269fe..b7195b40 100644 --- a/Sources/Lithe/Views/LocalHistoryView.swift +++ b/Sources/Lithe/Views/History/LocalHistoryView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheLocalHistoryModule struct LocalHistoryView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/ProjectLocalHistoryView.swift b/Sources/Lithe/Views/History/ProjectLocalHistoryView.swift similarity index 99% rename from Sources/Lithe/Views/ProjectLocalHistoryView.swift rename to Sources/Lithe/Views/History/ProjectLocalHistoryView.swift index 3731a11d..372d5494 100644 --- a/Sources/Lithe/Views/ProjectLocalHistoryView.swift +++ b/Sources/Lithe/Views/History/ProjectLocalHistoryView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheLocalHistoryModule struct ProjectLocalHistoryView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/JavaProblemsView.swift b/Sources/Lithe/Views/Language/JavaProblemsView.swift similarity index 100% rename from Sources/Lithe/Views/JavaProblemsView.swift rename to Sources/Lithe/Views/Language/JavaProblemsView.swift diff --git a/Sources/Lithe/Views/JavaReferencesView.swift b/Sources/Lithe/Views/Language/JavaReferencesView.swift similarity index 100% rename from Sources/Lithe/Views/JavaReferencesView.swift rename to Sources/Lithe/Views/Language/JavaReferencesView.swift diff --git a/Sources/Lithe/Views/LSPControlCenterView.swift b/Sources/Lithe/Views/Language/LSPControlCenterView.swift similarity index 99% rename from Sources/Lithe/Views/LSPControlCenterView.swift rename to Sources/Lithe/Views/Language/LSPControlCenterView.swift index 4068c64d..f42c9dcb 100644 --- a/Sources/Lithe/Views/LSPControlCenterView.swift +++ b/Sources/Lithe/Views/Language/LSPControlCenterView.swift @@ -244,7 +244,7 @@ struct LSPControlCenterView: View { private func serverStatus(for descriptor: LanguageProviderDescriptor) -> LSPServerStatus { LSPControlCenterPresenter.serverStatus( isDisabled: model.isLanguageServerDisabledInCurrentWorkspace(providerID: descriptor.id), - sessionState: model.languageToolingSessions.languageServerStates[descriptor.id] + sessionState: model.languageToolingSessionsIfActive?.languageServerStates[descriptor.id] ) } diff --git a/Sources/Lithe/Views/LanguageServerSetupView.swift b/Sources/Lithe/Views/Language/LanguageServerSetupView.swift similarity index 99% rename from Sources/Lithe/Views/LanguageServerSetupView.swift rename to Sources/Lithe/Views/Language/LanguageServerSetupView.swift index 9b937228..32d32a9c 100644 --- a/Sources/Lithe/Views/LanguageServerSetupView.swift +++ b/Sources/Lithe/Views/Language/LanguageServerSetupView.swift @@ -1,3 +1,5 @@ +import LitheCoreContracts +import LitheLanguageIntelligenceModule import SwiftUI struct LanguageServerSetupView: View { diff --git a/Sources/Lithe/Views/LanguageTestsView.swift b/Sources/Lithe/Views/Language/LanguageTestsView.swift similarity index 99% rename from Sources/Lithe/Views/LanguageTestsView.swift rename to Sources/Lithe/Views/Language/LanguageTestsView.swift index b2fbd44b..6cca3c68 100644 --- a/Sources/Lithe/Views/LanguageTestsView.swift +++ b/Sources/Lithe/Views/Language/LanguageTestsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheExecutionModule /// Language-neutral test tool window. Discovery is metadata-only; a process is /// created only after the user chooses a workspace or file item and presses Run. diff --git a/Sources/Lithe/Views/JavaRunConfigurationEditorView.swift b/Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift similarity index 100% rename from Sources/Lithe/Views/JavaRunConfigurationEditorView.swift rename to Sources/Lithe/Views/Run/JavaRunConfigurationEditorView.swift diff --git a/Sources/Lithe/Views/MavenView.swift b/Sources/Lithe/Views/Run/MavenView.swift similarity index 100% rename from Sources/Lithe/Views/MavenView.swift rename to Sources/Lithe/Views/Run/MavenView.swift diff --git a/Sources/Lithe/Views/RunConfigurationIcon.swift b/Sources/Lithe/Views/Run/RunConfigurationIcon.swift similarity index 100% rename from Sources/Lithe/Views/RunConfigurationIcon.swift rename to Sources/Lithe/Views/Run/RunConfigurationIcon.swift diff --git a/Sources/Lithe/Views/RunView.swift b/Sources/Lithe/Views/Run/RunView.swift similarity index 99% rename from Sources/Lithe/Views/RunView.swift rename to Sources/Lithe/Views/Run/RunView.swift index 93aa8396..1d53047d 100644 --- a/Sources/Lithe/Views/RunView.swift +++ b/Sources/Lithe/Views/Run/RunView.swift @@ -271,7 +271,7 @@ struct RunView: View { if hasServiceConfigurations { Button { - feature.runAllServices() + model.runAllServiceConfigurations() selectedSessionID = feature.moduleSessions.first?.id } label: { Image(systemName: "square.stack.3d.up.fill") @@ -294,7 +294,7 @@ struct RunView: View { if let session = selectedModuleSession, session.isRunning { feature.stopModule(session) } else if let configuration = selectedRunnableConfiguration { - feature.startConfiguration(configuration) + model.startRunConfiguration(configuration) } else if feature.isRunning { model.stopSelectedRun() } else { @@ -310,7 +310,7 @@ struct RunView: View { Button { if let configuration = selectedRunnableConfiguration { - feature.startConfiguration(configuration) + model.startRunConfiguration(configuration) } else { model.restartSelectedRun() } @@ -588,7 +588,7 @@ struct RunView: View { if let session, session.isRunning { feature.stopModule(session) } else { - feature.startConfiguration(configuration) + model.startRunConfiguration(configuration) selectedSessionID = configuration.id } } diff --git a/Sources/Lithe/Views/ProjectReplaceView.swift b/Sources/Lithe/Views/Search/ProjectReplaceView.swift similarity index 99% rename from Sources/Lithe/Views/ProjectReplaceView.swift rename to Sources/Lithe/Views/Search/ProjectReplaceView.swift index f9eaac61..8acfd238 100644 --- a/Sources/Lithe/Views/ProjectReplaceView.swift +++ b/Sources/Lithe/Views/Search/ProjectReplaceView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheSearchModule struct ProjectReplaceView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/SearchEverywhereView.swift b/Sources/Lithe/Views/Search/SearchEverywhereView.swift similarity index 98% rename from Sources/Lithe/Views/SearchEverywhereView.swift rename to Sources/Lithe/Views/Search/SearchEverywhereView.swift index c97613c3..4aa3d261 100644 --- a/Sources/Lithe/Views/SearchEverywhereView.swift +++ b/Sources/Lithe/Views/Search/SearchEverywhereView.swift @@ -1,4 +1,5 @@ import AppKit +import LitheSearchModule import SwiftUI enum SearchEverywhereScope: String, CaseIterable, Identifiable { @@ -36,7 +37,7 @@ struct SearchEverywhereView: View { + model.searchEverywhereResults.classMatches + model.searchEverywhereResults.symbolMatches return rankedResults(nameMatches) - + model.searchEverywhereResults.actionMatches.map(SearchItem.action) + + model.searchEverywhereActionMatches.map(SearchItem.action) case .classes: return results(in: model.searchEverywhereResults.classMatches) case .files: @@ -46,7 +47,7 @@ struct SearchEverywhereView: View { case .text: return results(in: model.searchEverywhereResults.contentMatches) case .actions: - return model.searchEverywhereResults.actionMatches.map(SearchItem.action) + return model.searchEverywhereActionMatches.map(SearchItem.action) } } @@ -344,7 +345,7 @@ struct SearchEverywhereView: View { /// 结果归属的 Maven 模块 artifactID;非 Maven 项目或匹配不到时回退到顶层目录名。 private func moduleLabel(for url: URL) -> String { let path = url.standardizedFileURL.path - if let project = model.mavenFeature.project { + if let project = model.mavenFeatureIfActive?.project { // 多个模块可能嵌套,取路径最长(最深)的那个才是直接归属。 let owning = project.allModules .filter { path.hasPrefix($0.url.standardizedFileURL.path + "/") } diff --git a/Sources/Lithe/Views/SearchSidebarView.swift b/Sources/Lithe/Views/Search/SearchSidebarView.swift similarity index 99% rename from Sources/Lithe/Views/SearchSidebarView.swift rename to Sources/Lithe/Views/Search/SearchSidebarView.swift index 0690744c..f415fd0d 100644 --- a/Sources/Lithe/Views/SearchSidebarView.swift +++ b/Sources/Lithe/Views/Search/SearchSidebarView.swift @@ -1,4 +1,5 @@ import SwiftUI +import LitheSearchModule struct SearchSidebarView: View { @EnvironmentObject private var model: AppModel diff --git a/Sources/Lithe/Views/TerminalView.swift b/Sources/Lithe/Views/Terminal/TerminalView.swift similarity index 98% rename from Sources/Lithe/Views/TerminalView.swift rename to Sources/Lithe/Views/Terminal/TerminalView.swift index 0d1078da..60da3038 100644 --- a/Sources/Lithe/Views/TerminalView.swift +++ b/Sources/Lithe/Views/Terminal/TerminalView.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import LitheTerminalModule struct TerminalView: View { @EnvironmentObject private var model: AppModel @@ -48,7 +49,7 @@ struct TerminalView: View { .help("New terminal session") Menu { - ForEach(model.terminalFeature.availableShells, id: \.self) { shell in + ForEach(model.availableTerminalShells, id: \.self) { shell in Button("New \(shellLabel(for: shell))") { model.createTerminalSession(shellPath: shell) requestInputFocus() diff --git a/Sources/Lithe/Views/OutputTextView.swift b/Sources/Lithe/Views/Workbench/OutputTextView.swift similarity index 100% rename from Sources/Lithe/Views/OutputTextView.swift rename to Sources/Lithe/Views/Workbench/OutputTextView.swift diff --git a/Sources/Lithe/Views/SplitHandleView.swift b/Sources/Lithe/Views/Workbench/SplitHandleView.swift similarity index 100% rename from Sources/Lithe/Views/SplitHandleView.swift rename to Sources/Lithe/Views/Workbench/SplitHandleView.swift diff --git a/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift new file mode 100644 index 00000000..369921b4 --- /dev/null +++ b/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -0,0 +1,152 @@ +import SwiftUI +import LitheDebugModule +import LitheExecutionModule +import LitheGitModule +import LitheLanguageIntelligenceModule +import LitheTerminalModule + +@MainActor +enum WorkbenchModuleUIComposition { + static let builtIn: WorkbenchModuleUIRegistry = { + do { + return try WorkbenchModuleUIRegistry(registrations: [ + terminalRegistration, + gitRegistration, + languageRegistration, + executionRegistration, + debugRegistration + ]) + } catch { + preconditionFailure("Invalid built-in module UI registration: \(error)") + } + }() + + private static let terminalRegistration = WorkbenchModuleUIRegistry.Registration( + contributions: TerminalModule.moduleContributions, + actions: [ + .init(id: "terminal.toggle", perform: { $0.toggleTerminal() }) + ], + renderers: [ + .init( + id: "terminal.sessions", + ideaAssetPath: nil, + isVisible: { _ in true }, + isSelected: { $0.isTerminalVisible }, + content: { model in + guard let session = model.activeTerminalSession else { + return AnyView(WorkbenchModuleUIRegistry.moduleLoadingView) + } + return AnyView(TerminalView(session: session).id(session.id)) + } + ) + ] + ) + + private static let gitRegistration = WorkbenchModuleUIRegistry.Registration( + contributions: GitModule.moduleContributions, + actions: [ + .init(id: "git.log.toggle", perform: { model in + if !model.isGitLogVisible { model.selectedSidebar = .changes } + Task { await model.toggleGitLog() } + }) + ], + renderers: [ + .init( + id: "git.log", + ideaAssetPath: "toolwindows/toolWindowVcs.svg", + isVisible: { _ in true }, + isSelected: { $0.isGitLogVisible }, + content: { _ in AnyView(GitLogView()) } + ) + ] + ) + + private static let languageRegistration = WorkbenchModuleUIRegistry.Registration( + contributions: LanguageIntelligenceModule.moduleContributions, + actions: [ + .init(id: "language.problems.toggle", perform: { $0.toggleProblems() }) + ], + renderers: [ + .init( + id: "language.problems", + ideaAssetPath: "toolwindows/toolWindowProblems.svg", + isVisible: { _ in true }, + isSelected: { $0.isProblemsVisible }, + content: { _ in AnyView(ProblemsView()) } + ) + ] + ) + + private static let executionRegistration = WorkbenchModuleUIRegistry.Registration( + contributions: ExecutionModule.moduleContributions, + actions: [ + .init(id: "execution.maven.toggle", perform: { $0.toggleMaven() }), + .init(id: "execution.run.toggle", perform: { $0.toggleRun() }), + .init(id: "execution.tests.toggle", perform: { $0.toggleTests() }) + ], + renderers: [ + .init( + id: "execution.maven", + ideaAssetPath: "maven/toolWindowMaven.svg", + isVisible: { $0.hasMavenProject }, + isSelected: { $0.isMavenVisible }, + content: { model in + guard let feature = model.mavenFeatureIfActive else { + return AnyView(WorkbenchModuleUIRegistry.moduleLoadingView) + } + return AnyView(MavenView(feature: feature)) + } + ), + .init( + id: "execution.run", + ideaAssetPath: "toolwindows/toolWindowRun.svg", + isVisible: { _ in true }, + isSelected: { $0.isRunVisible }, + content: { model in + guard let feature = model.runFeatureIfActive else { + return AnyView(WorkbenchModuleUIRegistry.moduleLoadingView) + } + return AnyView(RunView(feature: feature)) + } + ), + .init( + id: "execution.tests", + ideaAssetPath: nil, + isVisible: { _ in true }, + isSelected: { $0.isTestsVisible }, + content: { model in + guard let service = model.languageTestServiceIfActive else { + return AnyView(WorkbenchModuleUIRegistry.moduleLoadingView) + } + return AnyView(LanguageTestsView(service: service)) + } + ) + ] + ) + + private static let debugRegistration = WorkbenchModuleUIRegistry.Registration( + contributions: DebugModule.moduleContributions, + actions: [ + .init(id: "debug.toggle", perform: { $0.toggleDebug() }) + ], + renderers: [ + .init( + id: "debug.session", + ideaAssetPath: "toolwindows/toolWindowDebugger.svg", + isVisible: { _ in true }, + isSelected: { $0.isDebugVisible }, + content: { model in + if model.prefersGenericDebugUI, + let feature = model.genericDebugFeatureIfActive { + return AnyView(GenericDebugView(feature: feature)) + } + guard let feature = model.debugFeatureIfActive, + let runFeature = model.runFeatureIfActive else { + return AnyView(WorkbenchModuleUIRegistry.moduleLoadingView) + } + return AnyView(JavaDebugView(feature: feature, runFeature: runFeature)) + } + ) + ] + ) +} diff --git a/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift b/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift new file mode 100644 index 00000000..e750e291 --- /dev/null +++ b/Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift @@ -0,0 +1,121 @@ +import Foundation +import LitheModuleAPI +import SwiftUI + +enum WorkbenchModuleUIRegistryError: Error, Equatable { + case duplicateActionID(String) + case duplicateRendererID(String) + case missingAction(contributionID: String, actionID: String) + case missingRenderer(contributionID: String, rendererID: String) +} + +/// Host-side adapters for module-declared action and renderer identifiers. +/// This type owns only registration and lookup. Concrete built-in adapters are +/// assembled by the application composition root. +@MainActor +struct WorkbenchModuleUIRegistry { + struct Action { + let id: String + let perform: @MainActor (AppModel) -> Void + } + + struct Renderer { + let id: String + let ideaAssetPath: String? + let isVisible: @MainActor (AppModel) -> Bool + let isSelected: @MainActor (AppModel) -> Bool + let content: @MainActor (AppModel) -> AnyView + } + + struct Registration { + let contributions: [ModuleContribution] + let actions: [Action] + let renderers: [Renderer] + + init( + contributions: [ModuleContribution] = [], + actions: [Action] = [], + renderers: [Renderer] = [] + ) { + self.contributions = contributions + self.actions = actions + self.renderers = renderers + } + } + + private let actions: [String: @MainActor (AppModel) -> Void] + private let renderers: [String: Renderer] + + init(registrations: [Registration]) throws { + var actions: [String: @MainActor (AppModel) -> Void] = [:] + var renderers: [String: Renderer] = [:] + + for registration in registrations { + for action in registration.actions { + guard actions[action.id] == nil else { + throw WorkbenchModuleUIRegistryError.duplicateActionID(action.id) + } + actions[action.id] = action.perform + } + for renderer in registration.renderers { + guard renderers[renderer.id] == nil else { + throw WorkbenchModuleUIRegistryError.duplicateRendererID(renderer.id) + } + renderers[renderer.id] = renderer + } + } + + self.actions = actions + self.renderers = renderers + try validate(contributions: registrations.flatMap(\.contributions)) + } + + func validate(contributions: [ModuleContribution]) throws { + for contribution in contributions { + if let actionID = contribution.actionID, actions[actionID] == nil { + throw WorkbenchModuleUIRegistryError.missingAction( + contributionID: contribution.id, + actionID: actionID + ) + } + if let rendererID = contribution.rendererID, renderers[rendererID] == nil { + throw WorkbenchModuleUIRegistryError.missingRenderer( + contributionID: contribution.id, + rendererID: rendererID + ) + } + } + } + + func renderer(for contribution: ModuleContribution) -> Renderer? { + guard let rendererID = contribution.rendererID else { return nil } + return renderers[rendererID] + } + + func perform(_ contribution: ModuleContribution, model: AppModel) { + guard let actionID = contribution.actionID else { return } + actions[actionID]?(model) + } + + func selectedToolContent( + from contributions: [ModuleContribution], + model: AppModel + ) -> AnyView { + for contribution in contributions { + guard let renderer = renderer(for: contribution), renderer.isSelected(model) else { continue } + return renderer.content(model) + } + return AnyView(Self.moduleLoadingView) + } + + static var moduleLoadingView: some View { + VStack(spacing: 8) { + ProgressView() + Text("Starting module...") + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(LitheTheme.editor) + } +} diff --git a/Sources/Lithe/Views/WorkbenchView.swift b/Sources/Lithe/Views/Workbench/WorkbenchView.swift similarity index 90% rename from Sources/Lithe/Views/WorkbenchView.swift rename to Sources/Lithe/Views/Workbench/WorkbenchView.swift index 473bd922..832c82c0 100644 --- a/Sources/Lithe/Views/WorkbenchView.swift +++ b/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import LitheGitModule private enum ActivityBarMetrics { static let width: CGFloat = 38 @@ -10,11 +11,11 @@ private enum ActivityBarMetrics { } struct WorkbenchView: View { + private let moduleUIRegistry = WorkbenchModuleUIComposition.builtIn @EnvironmentObject private var model: AppModel @EnvironmentObject private var projectSessions: ProjectSessionManager @EnvironmentObject private var settings: AppSettings @EnvironmentObject private var memoryUsageMonitor: MemoryUsageMonitor - @EnvironmentObject private var runFeature: RunFeatureModel @State private var sidebarWidth: CGFloat = 320 @State private var sidebarDragStart: CGFloat = 320 @State private var topPaneHeight: CGFloat? @@ -62,16 +63,21 @@ struct WorkbenchView: View { } } .sheet(isPresented: $isNewRunConfigurationPresented) { - NewRunConfigurationView(feature: runFeature) { - isNewRunConfigurationPresented = false + if let runFeature = model.runFeatureIfActive { + NewRunConfigurationView(feature: runFeature) { + isNewRunConfigurationPresented = false + } } } .confirmationDialog( runConfigurationSetupTitle, - isPresented: $runFeature.isGenerationConfirmationPresented, + isPresented: Binding( + get: { model.runFeatureIfActive?.isGenerationConfirmationPresented ?? false }, + set: { model.runFeatureIfActive?.isGenerationConfirmationPresented = $0 } + ), titleVisibility: .visible ) { - Button(runFeature.configurationStatus == .ready ? "Rescan" : "Identify and Generate") { + Button(model.runFeatureIfActive?.configurationStatus == .ready ? "Rescan" : "Identify and Generate") { continueAfterRunConfigurationGeneration() } Button("Cancel", role: .cancel) {} @@ -514,9 +520,13 @@ struct WorkbenchView: View { GeometryReader { geometry in VStack(spacing: 0) { VStack(spacing: ActivityBarMetrics.spacing) { - ForEach(SidebarDestination.allCases) { destination in + ForEach(model.availableSidebarDestinations) { destination in Button { - model.selectedSidebar = destination + if destination == .database { + Task { await model.activateDatabaseModule() } + } else { + model.selectedSidebar = destination + } } label: { LitheIDEAIcon( resourcePath: destination.ideaAssetPath, @@ -545,72 +555,20 @@ struct WorkbenchView: View { ScrollView(.vertical, showsIndicators: false) { VStack(spacing: ActivityBarMetrics.spacing) { - activityToolButton( - systemImage: "terminal", - help: "Terminal", - isSelected: model.isTerminalVisible - ) { - model.toggleTerminal() - } - - activityToolButton( - systemImage: "point.3.connected.trianglepath.dotted", - ideaAssetPath: "toolwindows/toolWindowVcs.svg", - help: "Git", - isSelected: model.isGitLogVisible - ) { - if !model.isGitLogVisible { - model.selectedSidebar = .changes - } - Task { await model.toggleGitLog() } - } - - activityToolButton( - systemImage: "exclamationmark.triangle", - ideaAssetPath: "toolwindows/toolWindowProblems.svg", - help: "Problems", - isSelected: model.isProblemsVisible - ) { - model.toggleProblems() - } - - if model.hasMavenProject { - activityToolButton( - systemImage: "shippingbox", - ideaAssetPath: "maven/toolWindowMaven.svg", - help: "Maven", - isSelected: model.isMavenVisible - ) { - model.toggleMaven() + ForEach(model.activityBarContributions) { contribution in + if let renderer = moduleUIRegistry.renderer(for: contribution), + renderer.isVisible(model) { + activityToolButton( + systemImage: contribution.icon ?? "square.grid.2x2", + ideaAssetPath: renderer.ideaAssetPath, + help: contribution.title, + isSelected: renderer.isSelected(model) + ) { + moduleUIRegistry.perform(contribution, model: model) + } } } - activityToolButton( - systemImage: "play.rectangle", - ideaAssetPath: "toolwindows/toolWindowRun.svg", - help: "Services", - isSelected: model.isRunVisible - ) { - model.toggleRun() - } - - activityToolButton( - systemImage: "checkmark.seal", - help: "Tests", - isSelected: model.isTestsVisible - ) { - model.toggleTests() - } - - activityToolButton( - systemImage: "ladybug", - ideaAssetPath: "toolwindows/toolWindowDebugger.svg", - help: "Debug", - isSelected: model.isDebugVisible - ) { - model.toggleDebug() - } - activityToolButton( systemImage: "gearshape", ideaAssetPath: "general/gear.svg", @@ -633,17 +591,20 @@ struct WorkbenchView: View { private var runControls: some View { HStack(spacing: 3) { Button { - if runFeature.configurationStatus == .ready { + if model.runFeatureIfActive?.configurationStatus == .ready { isRunConfigurationPickerPresented.toggle() } else { - runFeature.requestRunConfigurationGeneration() + Task { + let feature = await model.activateExecutionModule()?.runFeature + feature?.requestRunConfigurationGeneration() + } } } label: { HStack(spacing: 5) { - Image(systemName: runFeature.selectedConfiguration?.systemImage ?? "play.fill") + Image(systemName: model.runFeatureIfActive?.selectedConfiguration?.systemImage ?? "play.fill") .font(.system(size: 13)) .frame(width: 17) - Text(LocalizedStringKey(runFeature.selectedConfiguration?.name ?? "Current File")) + Text(LocalizedStringKey(model.runFeatureIfActive?.selectedConfiguration?.name ?? "Current File")) .lineLimit(1) Spacer(minLength: 5) Image(systemName: "chevron.down") @@ -663,13 +624,13 @@ struct WorkbenchView: View { .buttonStyle(.plain) .lithePointer() .help("Select run configuration") - .disabled(runFeature.isLoadingProject) + .disabled(model.runFeatureIfActive?.isLoadingProject ?? false) .popover(isPresented: $isRunConfigurationPickerPresented, arrowEdge: .top) { RunConfigurationPickerPopover( - configurations: runFeature.configurations, + configurations: model.runFeatureIfActive?.configurations ?? [], selectedConfigurationID: Binding( - get: { runFeature.selectedConfigurationID }, - set: { runFeature.selectedConfigurationID = $0 } + get: { model.runFeatureIfActive?.selectedConfigurationID ?? "" }, + set: { model.runFeatureIfActive?.selectedConfigurationID = $0 } ), isPresented: $isRunConfigurationPickerPresented, onCreate: { @@ -680,13 +641,13 @@ struct WorkbenchView: View { } Button { - if runFeature.isRunning { + if model.runFeatureIfActive?.isRunning == true { model.stopSelectedRun() } else { model.runSelectedConfiguration() } } label: { - if runFeature.isRunning { + if model.runFeatureIfActive?.isRunning == true { Image(systemName: "stop.fill") .foregroundStyle(LitheTheme.warning) } else { @@ -695,14 +656,14 @@ struct WorkbenchView: View { } .litheIconButton() .help(LocalizedStringKey( - runFeature.isRunning ? "Stop current run" : "Run selected configuration" + model.runFeatureIfActive?.isRunning == true ? "Stop current run" : "Run selected configuration" )) - .disabled(runFeature.isLoadingProject) + .disabled(model.runFeatureIfActive?.isLoadingProject ?? false) } } private var runConfigurationSetupTitle: String { - switch runFeature.configurationStatus { + switch model.runFeatureIfActive?.configurationStatus ?? .missing { case .missing: String(localized: "Project run configuration not found") case .invalid: @@ -715,12 +676,13 @@ struct WorkbenchView: View { /// The dialog doubles as first-time setup and as an explicit rescan. Only /// the first case can claim Run is unavailable until it completes. private var runConfigurationSetupMessage: String { - runFeature.configurationStatus == .ready + model.runFeatureIfActive?.configurationStatus == .ready ? String(localized: "Lithe will look for services again and refresh .lithe/run/generated.json. Project and local overrides will not be changed.") : String(localized: "Lithe needs to identify the project and generate .lithe/run/generated.json before Run and Debug are available. Project and local overrides will not be changed.") } private func continueAfterRunConfigurationGeneration() { + guard let runFeature = model.runFeatureIfActive else { return } let intent = runFeature.generationIntent Task { await runFeature.generateRunConfigurations() @@ -847,30 +809,13 @@ struct WorkbenchView: View { .padding(.horizontal, 6) Group { - if model.isTerminalVisible, let session = model.activeTerminalSession { - TerminalView(session: session) - .id(session.id) - } else if model.isReferencesVisible { + if model.isReferencesVisible { LanguageReferencesView() - } else if model.isProblemsVisible { - ProblemsView() - } else if model.isDebugVisible { - if model.prefersGenericDebugUI { - GenericDebugView(feature: model.genericDebugFeature) - } else { - JavaDebugView( - feature: model.debugFeature, - runFeature: runFeature - ) - } - } else if model.isRunVisible { - RunView(feature: runFeature) - } else if model.isTestsVisible { - LanguageTestsView(service: model.languageTestService) - } else if model.isMavenVisible { - MavenView(feature: model.mavenFeature) } else { - GitLogView() + moduleUIRegistry.selectedToolContent( + from: model.activityBarContributions, + model: model + ) } } .clipShape(RoundedRectangle(cornerRadius: 10)) @@ -894,7 +839,13 @@ struct WorkbenchView: View { case .search: SearchSidebarView() case .database: - DatabaseSidebarView() + if model.isDatabaseModuleActive { + DatabaseSidebarView() + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { await model.activateDatabaseModule() } + } } } .background(LitheTheme.sidebar) diff --git a/Sources/Lithe/Views/CloneRepositoryView.swift b/Sources/Lithe/Views/Workspace/CloneRepositoryView.swift similarity index 100% rename from Sources/Lithe/Views/CloneRepositoryView.swift rename to Sources/Lithe/Views/Workspace/CloneRepositoryView.swift diff --git a/Sources/Lithe/Views/OpenProjectLocationDialog.swift b/Sources/Lithe/Views/Workspace/OpenProjectLocationDialog.swift similarity index 100% rename from Sources/Lithe/Views/OpenProjectLocationDialog.swift rename to Sources/Lithe/Views/Workspace/OpenProjectLocationDialog.swift diff --git a/Sources/Lithe/Views/ProjectSidebarView.swift b/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift similarity index 100% rename from Sources/Lithe/Views/ProjectSidebarView.swift rename to Sources/Lithe/Views/Workspace/ProjectSidebarView.swift diff --git a/Sources/Lithe/Views/ProjectSwitcherPopover.swift b/Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift similarity index 100% rename from Sources/Lithe/Views/ProjectSwitcherPopover.swift rename to Sources/Lithe/Views/Workspace/ProjectSwitcherPopover.swift diff --git a/Sources/LitheAIAssistanceModule/Module/AIAssistanceModule.swift b/Sources/LitheAIAssistanceModule/Module/AIAssistanceModule.swift new file mode 100644 index 00000000..910d173d --- /dev/null +++ b/Sources/LitheAIAssistanceModule/Module/AIAssistanceModule.swift @@ -0,0 +1,86 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +@MainActor +public final class AIAssistanceCapability: NSObject, AICommitMessageGenerating { + private let service: CommitMessageGenerationService + private weak var resources: (any ModuleResourceManaging)? + private weak var leases: (any ModuleLeaseManaging)? + + init(service: CommitMessageGenerationService, resources: any ModuleResourceManaging, leases: any ModuleLeaseManaging) { + self.service = service + self.resources = resources + self.leases = leases + } + + public func generateCommitMessage(input: CommitMessageInput, settings: CommitMessageAISettings) async throws -> String { + guard let resources, let leases else { throw CancellationError() } + let task = Task { try await service.generate(input: input, settings: settings) } + let resource = AIRequestResource(task: task) + let resourceID = resources.register(resource) + let lease = leases.acquireLease(reason: "Generating an AI commit message") + defer { + lease.release() + resource.markCompleted() + resources.unregisterResource(id: resourceID) + } + return try await task.value + } +} + +@MainActor +public final class AIAssistanceModule: LitheModule { + public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .aiAssistance) + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .aiAssistance)! + + public let manifest = moduleManifest + private let transportFactory: @MainActor () -> any AIHTTPTransport + private let credentialResolver: any AIProviderCredentialResolver + private var capability: AIAssistanceCapability? + + public init( + transportFactory: @escaping @MainActor () -> any AIHTTPTransport, + credentialResolver: any AIProviderCredentialResolver + ) { + self.transportFactory = transportFactory + self.credentialResolver = credentialResolver + } + + public func activate(context: ModuleContext) async throws { + guard capability == nil else { return } + capability = AIAssistanceCapability( + service: CommitMessageGenerationService(transport: transportFactory(), credentialResolver: credentialResolver), + resources: context.resources, + leases: context.leases + ) + } + + public func prepareForSleep() async throws {} + public func sleep() async { capability = nil } + public func shutdown() async { capability = nil } + + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.aiCommitMessage: capability] + } + + public func contributions() -> [ModuleContribution] { + Self.moduleContributions + } +} + +@MainActor +private final class AIRequestResource: ModuleResource { + let task: Task + private var isActive = true + init(task: Task) { self.task = task } + var moduleResourceKind: String { "ai-http-request" } + var isModuleResourceActive: Bool { isActive } + func markCompleted() { isActive = false } + func stopModuleResource() async { + task.cancel() + _ = await task.result + isActive = false + } +} diff --git a/Sources/Lithe/Services/CommitMessageGenerationService.swift b/Sources/LitheAIAssistanceModule/Services/CommitMessageGenerationService.swift similarity index 92% rename from Sources/Lithe/Services/CommitMessageGenerationService.swift rename to Sources/LitheAIAssistanceModule/Services/CommitMessageGenerationService.swift index 3fc8ca94..8ee6e27a 100644 --- a/Sources/Lithe/Services/CommitMessageGenerationService.swift +++ b/Sources/LitheAIAssistanceModule/Services/CommitMessageGenerationService.swift @@ -1,10 +1,11 @@ import Foundation +import LitheCoreContracts -struct CommitMessageGenerationService: Sendable { +public struct CommitMessageGenerationService: Sendable { private let transport: any AIHTTPTransport private let credentialResolver: any AIProviderCredentialResolver - init( + public init( transport: any AIHTTPTransport, credentialResolver: any AIProviderCredentialResolver ) { @@ -12,7 +13,7 @@ struct CommitMessageGenerationService: Sendable { self.credentialResolver = credentialResolver } - func generate( + public func generate( input: CommitMessageInput, settings: CommitMessageAISettings ) async throws -> String { @@ -352,41 +353,6 @@ struct CommitMessageGenerationService: Sendable { } } -enum CommitMessageGenerationError: LocalizedError, Sendable { - case noProviderConfigured - case invalidProvider - case insecureEndpoint - case missingAPIKey - case emptyDiff - case sensitiveFileExcluded - case httpFailure(statusCode: Int) - case invalidResponse - case emptyResponse - - var errorDescription: String? { - switch self { - case .noProviderConfigured: - return "Configure an AI provider in Settings first." - case .invalidProvider: - return "The selected AI provider has an invalid API URL or model." - case .insecureEndpoint: - return String(localized: "HTTP is disabled for this provider. Enable the insecure HTTP option or use HTTPS.") - case .missingAPIKey: - return "The selected AI provider has no API key." - case .emptyDiff: - return String(localized: "The staged changes have no textual diff to summarize.") - case .sensitiveFileExcluded: - return "Sensitive files are not sent to an AI provider." - case .httpFailure(let statusCode): - return "The AI provider returned HTTP \(statusCode)." - case .invalidResponse: - return "The AI provider returned an unexpected response." - case .emptyResponse: - return "The AI provider returned an empty commit message." - } - } -} - private struct ResponsesRequest: Encodable { struct InputMessage: Encodable { let role: String diff --git a/Sources/LitheApplicationKernel/Lifecycle/ModuleLifecycleCoordinator.swift b/Sources/LitheApplicationKernel/Lifecycle/ModuleLifecycleCoordinator.swift new file mode 100644 index 00000000..d1145375 --- /dev/null +++ b/Sources/LitheApplicationKernel/Lifecycle/ModuleLifecycleCoordinator.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Drives idle evaluation without making the application shell own a timer. +/// The coordinator itself is application-scoped and stops deterministically +/// during project/session shutdown. +@MainActor +public final class ModuleLifecycleCoordinator { + private let runtime: ModuleRuntime + private let evaluationInterval: Duration + private var task: Task? + + public init(runtime: ModuleRuntime, evaluationInterval: Duration = .seconds(30)) { + self.runtime = runtime + self.evaluationInterval = evaluationInterval + } + + public func start() { + guard task == nil else { return } + task = Task { @MainActor [weak self] in + while let self, !Task.isCancelled { + try? await Task.sleep(for: self.evaluationInterval) + guard !Task.isCancelled else { return } + await self.runtime.evaluateIdleModules() + } + } + } + + public func stop() { + task?.cancel() + task = nil + } + + deinit { task?.cancel() } +} diff --git a/Sources/LitheApplicationKernel/Lifecycle/ModuleResourceScope.swift b/Sources/LitheApplicationKernel/Lifecycle/ModuleResourceScope.swift new file mode 100644 index 00000000..81fb0702 --- /dev/null +++ b/Sources/LitheApplicationKernel/Lifecycle/ModuleResourceScope.swift @@ -0,0 +1,97 @@ +import Foundation +import LitheModuleAPI + +@MainActor +public final class ModuleResourceScope: ModuleResourceManaging, ModuleLeaseManaging { + private struct RegisteredResource { + let value: any ModuleResource + let kind: String + } + + public let moduleID: ModuleID + private var resources: [UUID: RegisteredResource] = [:] + private var leases: [UUID: String] = [:] + public private(set) var lastActivityAt: Date? + + public init(moduleID: ModuleID) { + self.moduleID = moduleID + } + + @discardableResult + public func register(_ resource: any ModuleResource) -> UUID { + let id = UUID() + resources[id] = RegisteredResource(value: resource, kind: resource.moduleResourceKind) + touch() + return id + } + + public func unregisterResource(id: UUID) { + guard let resource = resources[id] else { return } + guard !resource.value.isModuleResourceActive else { + touch() + return + } + resources.removeValue(forKey: id) + touch() + } + + public func resourceSnapshots() -> [ModuleResourceSnapshot] { + return resources.map { id, resource in + ModuleResourceSnapshot( + id: id, + kind: resource.kind, + isActive: resource.value.isModuleResourceActive + ) + }.sorted { lhs, rhs in + if lhs.kind == rhs.kind { return lhs.id.uuidString < rhs.id.uuidString } + return lhs.kind < rhs.kind + } + } + + public func acquireLease(reason: String) -> ModuleLease { + let leaseID = UUID() + leases[leaseID] = reason + touch() + return ModuleLease(id: leaseID, reason: reason) { [weak self] id in + self?.releaseLease(id: id) + } + } + + public var activeLeaseReasons: [String] { + leases.values.sorted() + } + + public var activity: ModuleActivity { + ModuleActivity( + activeLeaseCount: leases.count, + activeResourceCount: resourceSnapshots().filter(\.isActive).count, + lastActivityAt: lastActivityAt + ) + } + + public func recordActivity(at date: Date = Date()) { + lastActivityAt = date + } + + public func stopAllResources() async { + let activeResources = resources.values.map(\.value).filter(\.isModuleResourceActive) + for resource in activeResources { + await resource.stopModuleResource() + } + touch() + } + + public func releaseStoppedResources() { + resources = resources.filter { $0.value.value.isModuleResourceActive } + touch() + } + + private func releaseLease(id: UUID) { + leases.removeValue(forKey: id) + touch() + } + + private func touch() { + recordActivity() + } +} diff --git a/Sources/LitheApplicationKernel/Lifecycle/ModuleRuntime.swift b/Sources/LitheApplicationKernel/Lifecycle/ModuleRuntime.swift new file mode 100644 index 00000000..3822c1d3 --- /dev/null +++ b/Sources/LitheApplicationKernel/Lifecycle/ModuleRuntime.swift @@ -0,0 +1,545 @@ +import Foundation +import LitheModuleAPI + +@MainActor +public final class ModuleRuntime: ModuleCapabilityResolver, ModuleEventPublishing, ModuleContributionPublishing { + private struct Entry { + let factory: ModuleFactory + let resources: ModuleResourceScope + var isEnabled: Bool + var isQuarantined: Bool + let isSuppressedBySafeMode: Bool + var state: ModuleState + var instance: (any LitheModule)? + } + + private var entries: [ModuleID: Entry] = [:] + private var capabilities: [ModuleCapabilityID: (provider: ModuleID, value: AnyObject)] = [:] + private var eventObservers: [UUID: @MainActor (ModuleEvent) -> Void] = [:] + private var moduleContributions: [ModuleID: [ModuleContribution]] = [:] + private let workspaceURL: URL? + private let configurationStore: (any ModuleConfigurationStore)? + private let recoveryStore: (any ModuleRecoveryStore)? + private let launchMode: ModuleLaunchMode + + public init( + workspaceURL: URL? = nil, + configurationStore: (any ModuleConfigurationStore)? = nil, + recoveryStore: (any ModuleRecoveryStore)? = nil, + launchMode: ModuleLaunchMode = .normal + ) { + self.workspaceURL = workspaceURL + self.configurationStore = configurationStore + self.recoveryStore = recoveryStore + self.launchMode = launchMode + let pendingActivations = recoveryStore?.pendingActivations() ?? [] + for pending in pendingActivations { + recoveryStore?.setQuarantined(true, for: pending) + } + if !pendingActivations.isEmpty { + recoveryStore?.setPendingActivations([]) + } + } + + public func register(_ factory: ModuleFactory, enabled: Bool? = nil) throws { + let id = factory.manifest.id + guard entries[id] == nil else { throw ModuleRuntimeError.duplicateModule(id) } + let configuredEnabled = enabled + ?? configurationStore?.enabledState(for: id) + ?? (factory.manifest.defaultState == .enabled) + let isQuarantined = !factory.manifest.isRequired + && (recoveryStore?.isQuarantined(id) ?? false) + if factory.manifest.isRequired, recoveryStore?.isQuarantined(id) == true { + recoveryStore?.setQuarantined(false, for: id) + } + let isSuppressedBySafeMode = launchMode == .safeMode && !factory.manifest.isRequired + let isEnabled = configuredEnabled && !isQuarantined && !isSuppressedBySafeMode + entries[id] = Entry( + factory: factory, + resources: ModuleResourceScope(moduleID: id), + isEnabled: isEnabled, + isQuarantined: isQuarantined, + isSuppressedBySafeMode: isSuppressedBySafeMode, + state: isEnabled ? .inactive : .disabled, + instance: nil + ) + publishStateChanged(id) + } + + public func validateGraph() throws { + let capabilityProviders = Dictionary(grouping: entries.values.flatMap { entry in + entry.factory.manifest.providedCapabilities.map { ($0, entry.factory.manifest.id) } + }, by: \.0) + + for (capability, values) in capabilityProviders where values.count > 1 { + throw ModuleRuntimeError.capabilityCollision( + capability: capability, + providers: values.map(\.1).sorted() + ) + } + + for entry in entries.values { + for dependency in entry.factory.manifest.dependencies { + switch dependency { + case .module(let dependencyID): + guard entries[dependencyID] != nil else { + throw ModuleRuntimeError.missingModuleDependency( + module: entry.factory.manifest.id, + dependency: dependencyID + ) + } + case .capability(let capability): + guard capabilityProviders[capability] != nil else { + throw ModuleRuntimeError.missingCapabilityDependency( + module: entry.factory.manifest.id, + capability: capability + ) + } + } + } + } + + var visited: Set = [] + var visiting: [ModuleID] = [] + for id in entries.keys.sorted() { + try visit(id, visited: &visited, visiting: &visiting) + } + } + + public func startEagerModules() async throws { + try validateGraph() + let eagerModuleIDs = entries.values + .filter { $0.isEnabled && $0.factory.manifest.activationPolicy == .eager } + .map { $0.factory.manifest.id } + .sorted() + for id in eagerModuleIDs { + try await activate(id) + } + } + + @discardableResult + public func activate(_ id: ModuleID) async throws -> any LitheModule { + guard var entry = entries[id] else { throw ModuleRuntimeError.unknownModule(id) } + guard !entry.isQuarantined else { throw ModuleRuntimeError.moduleQuarantined(id) } + guard !entry.isSuppressedBySafeMode else { + throw ModuleRuntimeError.optionalModuleUnavailableInSafeMode(id) + } + guard entry.isEnabled else { throw ModuleRuntimeError.moduleDisabled(id) } + if let instance = entry.instance, entry.state == .active || entry.state == .idle { + return instance + } + + for dependency in entry.factory.manifest.dependencies.sorted(by: dependencyOrder) { + switch dependency { + case .module(let dependencyID): + _ = try await activate(dependencyID) + case .capability(let capabilityID): + guard let provider = providerID(for: capabilityID) else { + throw ModuleRuntimeError.missingCapabilityDependency(module: id, capability: capabilityID) + } + _ = try await activate(provider) + } + } + + if !entry.factory.manifest.isRequired { + addPendingActivation(id) + } + entry.state = .activating + entries[id] = entry + publishStateChanged(id) + var activatingInstance: (any LitheModule)? + do { + let instance = try entry.instance ?? entry.factory.makeModule() + activatingInstance = instance + let context = ModuleContext( + moduleID: id, + workspaceURL: workspaceURL, + capabilities: self, + events: self, + resources: entry.resources, + leases: entry.resources, + contributions: self + ) + try await instance.activate(context: context) + entry.resources.recordActivity() + entry.instance = instance + entry.state = .active + entries[id] = entry + try publishCapabilities(of: instance, manifest: entry.factory.manifest) + let instanceContributions = instance.contributions().sorted(by: contributionOrder) + guard instanceContributions == entry.factory.contributions else { + throw ModuleRuntimeError.contributionCatalogMismatch(id) + } + for contribution in entry.factory.contributions { + register(contribution, for: id) + } + publish(ModuleEvent(source: id, name: "module.activated")) + publishStateChanged(id) + removePendingActivation(id) + return instance + } catch { + removePendingActivation(id) + if let activatingInstance { + await activatingInstance.shutdown() + } + await entry.resources.stopAllResources() + let activeKinds = entry.resources.resourceSnapshots().filter(\.isActive).map(\.kind) + entry.resources.releaseStoppedResources() + removeCapabilities(providedBy: id) + removeContributions(for: id) + entry.instance = nil + if !activeKinds.isEmpty { + entry.state = .failed(message: "Resources remain active after failed activation") + entries[id] = entry + publishStateChanged(id) + throw ModuleRuntimeError.activeResourcesRemain(module: id, kinds: activeKinds) + } + entry.state = .failed(message: error.localizedDescription) + entries[id] = entry + publishStateChanged(id) + throw error + } + } + + public func setEnabled(_ enabled: Bool, for id: ModuleID) async throws { + guard var entry = entries[id] else { throw ModuleRuntimeError.unknownModule(id) } + if enabled, entry.isSuppressedBySafeMode { + throw ModuleRuntimeError.optionalModuleUnavailableInSafeMode(id) + } + if enabled, entry.isQuarantined { + entry.isQuarantined = false + recoveryStore?.setQuarantined(false, for: id) + } + guard entry.isEnabled != enabled else { + entries[id] = entry + return + } + if enabled { + entry.isEnabled = true + entry.state = .inactive + entries[id] = entry + if entry.factory.manifest.activationPolicy == .eager { + _ = try await activate(id) + } + } else { + guard !entry.factory.manifest.isRequired else { + throw ModuleRuntimeError.requiredModuleCannotBeDisabled(id) + } + let dependents = enabledDependents(of: id) + guard dependents.isEmpty else { + throw ModuleRuntimeError.enabledDependentsPreventDisable(module: id, dependents: dependents) + } + entries[id] = entry + try await shutdown(id) + guard var stopped = entries[id] else { return } + stopped.isEnabled = false + stopped.state = .disabled + entries[id] = stopped + } + configurationStore?.setEnabledState(enabled, for: id) + publishStateChanged(id) + } + + private func enabledDependents(of moduleID: ModuleID) -> [ModuleID] { + let provided = entries[moduleID]?.factory.manifest.providedCapabilities ?? [] + return entries.values.compactMap { candidate in + guard candidate.isEnabled, candidate.factory.manifest.id != moduleID else { return nil } + let depends = candidate.factory.manifest.dependencies.contains { dependency in + switch dependency { + case .module(let id): id == moduleID + case .capability(let capability): provided.contains(capability) + } + } + return depends ? candidate.factory.manifest.id : nil + }.sorted() + } + + private func instantiatedDependents(of moduleID: ModuleID) -> [ModuleID] { + let provided = entries[moduleID]?.factory.manifest.providedCapabilities ?? [] + return entries.values.compactMap { candidate in + guard candidate.instance != nil, candidate.factory.manifest.id != moduleID else { return nil } + let depends = candidate.factory.manifest.dependencies.contains { dependency in + switch dependency { + case .module(let id): id == moduleID + case .capability(let capability): provided.contains(capability) + } + } + return depends ? candidate.factory.manifest.id : nil + }.sorted() + } + + public func markIdle(_ id: ModuleID) throws { + guard var entry = entries[id] else { throw ModuleRuntimeError.unknownModule(id) } + guard entry.instance != nil else { return } + entry.state = .idle + entry.resources.recordActivity() + entries[id] = entry + publishStateChanged(id) + } + + public func sleep(_ id: ModuleID) async throws { + guard var entry = entries[id] else { throw ModuleRuntimeError.unknownModule(id) } + let dependents = instantiatedDependents(of: id) + guard dependents.isEmpty else { + let reason = "Active dependents: \(dependents.map(\.rawValue).joined(separator: ", "))" + entry.state = .sleepBlocked(reason: reason) + entries[id] = entry + publishStateChanged(id) + throw ModuleRuntimeError.activeDependentsPreventSleep(module: id, dependents: dependents) + } + guard let instance = entry.instance else { + if entry.isEnabled { entry.state = .sleeping } + entries[id] = entry + publishStateChanged(id) + return + } + let reasons = entry.resources.activeLeaseReasons + guard reasons.isEmpty else { + entry.state = .sleepBlocked(reason: reasons.joined(separator: ", ")) + entries[id] = entry + publishStateChanged(id) + throw ModuleRuntimeError.activeLeasesPreventSleep(module: id, reasons: reasons) + } + + entry.state = .preparingToSleep + entries[id] = entry + publishStateChanged(id) + do { + try await instance.prepareForSleep() + await instance.sleep() + await entry.resources.stopAllResources() + let activeKinds = entry.resources.resourceSnapshots().filter(\.isActive).map(\.kind) + guard activeKinds.isEmpty else { + entry.state = .sleepBlocked(reason: "Resources remain active") + entries[id] = entry + publishStateChanged(id) + throw ModuleRuntimeError.activeResourcesRemain(module: id, kinds: activeKinds) + } + entry.resources.releaseStoppedResources() + removeCapabilities(providedBy: id) + removeContributions(for: id) + entry.instance = nil + entry.state = .sleeping + entries[id] = entry + publish(ModuleEvent(source: id, name: "module.sleeping")) + publishStateChanged(id) + } catch { + if case ModuleRuntimeError.activeResourcesRemain = error { throw error } + entry.state = .sleepBlocked(reason: error.localizedDescription) + entries[id] = entry + publishStateChanged(id) + throw error + } + } + + public func shutdown(_ id: ModuleID) async throws { + guard var entry = entries[id] else { throw ModuleRuntimeError.unknownModule(id) } + if let instance = entry.instance { + await instance.shutdown() + } + await entry.resources.stopAllResources() + let activeKinds = entry.resources.resourceSnapshots().filter(\.isActive).map(\.kind) + guard activeKinds.isEmpty else { + entry.state = .failed(message: "Resources remain active after shutdown") + entries[id] = entry + publishStateChanged(id) + throw ModuleRuntimeError.activeResourcesRemain(module: id, kinds: activeKinds) + } + entry.resources.releaseStoppedResources() + removeCapabilities(providedBy: id) + removeContributions(for: id) + entry.instance = nil + entry.state = entry.isEnabled ? .inactive : .disabled + entries[id] = entry + publish(ModuleEvent(source: id, name: "module.shutdown")) + publishStateChanged(id) + } + + public func shutdownAll() async { + for id in entries.keys.sorted().reversed() { + try? await shutdown(id) + } + } + + public func evaluateIdleModules(now: Date = Date()) async { + for id in entries.keys.sorted() { + guard let entry = entries[id], + entry.state == .idle, + let interval = entry.factory.manifest.sleepPolicy.idleInterval, + entry.resources.activeLeaseReasons.isEmpty, + let lastActivity = entry.resources.lastActivityAt, + now.timeIntervalSince(lastActivity) >= interval else { continue } + try? await sleep(id) + } + } + + public func snapshot(for id: ModuleID) throws -> ModuleSnapshot { + guard let entry = entries[id] else { throw ModuleRuntimeError.unknownModule(id) } + return ModuleSnapshot( + manifest: entry.factory.manifest, + state: entry.state, + activity: entry.resources.activity, + isInstantiated: entry.instance != nil, + resources: entry.resources.resourceSnapshots(), + activeLeaseReasons: entry.resources.activeLeaseReasons, + isQuarantined: entry.isQuarantined, + isSuppressedBySafeMode: entry.isSuppressedBySafeMode + ) + } + + public func snapshots() -> [ModuleSnapshot] { + entries.keys.sorted().compactMap { try? snapshot(for: $0) } + } + + public func capability(_ id: ModuleCapabilityID) -> AnyObject? { + capabilities[id]?.value + } + + public func activateCapability(_ id: ModuleCapabilityID) async throws -> AnyObject { + guard let provider = providerID(for: id) else { + throw ModuleRuntimeError.missingCapabilityDependency( + module: ModuleID("dev.lithe.capability-client"), + capability: id + ) + } + _ = try await activate(provider) + guard let value = capability(id) else { + throw ModuleRuntimeError.missingCapabilityDependency(module: provider, capability: id) + } + return value + } + + @discardableResult + public func observeEvents(_ observer: @escaping @MainActor (ModuleEvent) -> Void) -> UUID { + let id = UUID() + eventObservers[id] = observer + return id + } + + public func removeEventObserver(_ id: UUID) { + eventObservers.removeValue(forKey: id) + } + + public func publish(_ event: ModuleEvent) { + applyActivityEvent(event) + for observer in eventObservers.values { observer(event) } + } + + private func applyActivityEvent(_ event: ModuleEvent) { + guard event.name == ModuleEvent.activityStartedName + || event.name == ModuleEvent.activityEndedName, + var entry = entries[event.source], + entry.instance != nil else { return } + entry.resources.recordActivity() + entry.state = event.name == ModuleEvent.activityStartedName ? .active : .idle + entries[event.source] = entry + publishStateChanged(event.source) + } + + public func register(_ contribution: ModuleContribution, for moduleID: ModuleID) { + moduleContributions[moduleID, default: []].append(contribution) + moduleContributions[moduleID]?.sort { $0.id < $1.id } + } + + public func removeContributions(for moduleID: ModuleID) { + moduleContributions.removeValue(forKey: moduleID) + } + + public func contributions() -> [ModuleID: [ModuleContribution]] { moduleContributions } + + /// Declarative UI metadata for enabled modules. Reading this catalog never + /// invokes a module factory, so an inactive module can expose the action + /// that activates it without constructing any service or resource. + public func availableContributions() -> [ModuleID: [ModuleContribution]] { + Dictionary(uniqueKeysWithValues: entries.values.compactMap { entry in + guard entry.isEnabled, !entry.factory.contributions.isEmpty else { return nil } + return (entry.factory.manifest.id, entry.factory.contributions) + }) + } + + private func providerID(for capability: ModuleCapabilityID) -> ModuleID? { + entries.values.first { $0.factory.manifest.providedCapabilities.contains(capability) }? + .factory.manifest.id + } + + private func publishCapabilities(of module: any LitheModule, manifest: ModuleManifest) throws { + let values = module.exportedCapabilities() + if let missing = manifest.providedCapabilities.subtracting(values.keys).sorted().first { + throw ModuleRuntimeError.missingExportedCapability( + module: manifest.id, + capability: missing + ) + } + if let undeclared = Set(values.keys).subtracting(manifest.providedCapabilities).sorted().first { + throw ModuleRuntimeError.undeclaredExportedCapability( + module: manifest.id, + capability: undeclared + ) + } + for capabilityID in manifest.providedCapabilities { + if let existing = capabilities[capabilityID], existing.provider != manifest.id { + throw ModuleRuntimeError.capabilityCollision( + capability: capabilityID, + providers: [existing.provider, manifest.id].sorted() + ) + } + } + for capabilityID in manifest.providedCapabilities { + capabilities[capabilityID] = (manifest.id, values[capabilityID]!) + } + } + + private func removeCapabilities(providedBy id: ModuleID) { + capabilities = capabilities.filter { $0.value.provider != id } + } + + private func visit( + _ id: ModuleID, + visited: inout Set, + visiting: inout [ModuleID] + ) throws { + if visited.contains(id) { return } + if let cycleStart = visiting.firstIndex(of: id) { + throw ModuleRuntimeError.dependencyCycle(Array(visiting[cycleStart...]) + [id]) + } + visiting.append(id) + let dependencies = entries[id]?.factory.manifest.dependencies.compactMap { dependency -> ModuleID? in + switch dependency { + case .module(let dependencyID): dependencyID + case .capability(let capability): providerID(for: capability) + } + }.sorted() ?? [] + for dependency in dependencies { + try visit(dependency, visited: &visited, visiting: &visiting) + } + _ = visiting.popLast() + visited.insert(id) + } + + private func dependencyOrder(_ lhs: ModuleDependency, _ rhs: ModuleDependency) -> Bool { + String(describing: lhs) < String(describing: rhs) + } + + private func publishStateChanged(_ id: ModuleID) { + publish(ModuleEvent(source: id, name: ModuleEvent.stateChangedName)) + } + + private func addPendingActivation(_ id: ModuleID) { + guard let recoveryStore else { return } + var pending = Set(recoveryStore.pendingActivations()) + pending.insert(id) + recoveryStore.setPendingActivations(pending.sorted()) + } + + private func removePendingActivation(_ id: ModuleID) { + guard let recoveryStore else { return } + var pending = Set(recoveryStore.pendingActivations()) + pending.remove(id) + recoveryStore.setPendingActivations(pending.sorted()) + } + + private func contributionOrder(_ lhs: ModuleContribution, _ rhs: ModuleContribution) -> Bool { + (lhs.placement.rawValue, lhs.order, lhs.id) + < (rhs.placement.rawValue, rhs.order, rhs.id) + } +} diff --git a/Sources/LitheApplicationKernel/Plugins/PluginManifestValidator.swift b/Sources/LitheApplicationKernel/Plugins/PluginManifestValidator.swift new file mode 100644 index 00000000..f9817750 --- /dev/null +++ b/Sources/LitheApplicationKernel/Plugins/PluginManifestValidator.swift @@ -0,0 +1,187 @@ +import Foundation +import LitheModuleAPI + +public enum PluginCatalogError: Error, Equatable, LocalizedError, Sendable { + case duplicatePlugin(PluginID) + case duplicateModule(module: ModuleID, plugins: [PluginID]) + case duplicateLanguageSupport(languageID: String, plugins: [PluginID]) + case unsupportedSchema(plugin: PluginID, version: Int) + case unsupportedAPI(plugin: PluginID, version: Int) + case incompatibleHost(plugin: PluginID, hostVersion: PluginVersion) + case emptyPlugin(PluginID) + case invalidEntrypoint(PluginID) + case invalidLanguageSupport(plugin: PluginID, languageID: String) + case missingRequiredModule(ModuleID) + case missingModuleFactory(plugin: PluginID, module: ModuleID) + case factoryWithoutInstalledPlugin(ModuleID) + case moduleFactoryMismatch(plugin: PluginID, module: ModuleID) + + public var errorDescription: String? { + switch self { + case .duplicatePlugin(let id): "Plugin \(id) is declared more than once." + case .duplicateModule(let module, let plugins): + "Module \(module) is declared by multiple plugins: \(plugins.map(\.rawValue).joined(separator: ", "))." + case .duplicateLanguageSupport(let languageID, let plugins): + "Language support \(languageID) is declared by multiple plugins: \(plugins.map(\.rawValue).joined(separator: ", "))." + case .unsupportedSchema(let plugin, let version): + "Plugin \(plugin) uses unsupported manifest schema \(version)." + case .unsupportedAPI(let plugin, let version): + "Plugin \(plugin) requires unsupported Plugin API \(version)." + case .incompatibleHost(let plugin, let hostVersion): + "Plugin \(plugin) is not compatible with Lithe \(hostVersion)." + case .emptyPlugin(let plugin): "Plugin \(plugin) declares no modules." + case .invalidEntrypoint(let plugin): "Plugin \(plugin) has invalid entrypoint metadata." + case .invalidLanguageSupport(let plugin, let languageID): + "Plugin \(plugin) has an invalid language support declaration for \(languageID)." + case .missingRequiredModule(let module): "Required module \(module) is not installed." + case .missingModuleFactory(let plugin, let module): + "Installed plugin \(plugin) did not register module \(module)." + case .factoryWithoutInstalledPlugin(let module): + "Module \(module) registered code without an installed static plugin manifest." + case .moduleFactoryMismatch(let plugin, let module): + "Module \(module) factory differs from plugin \(plugin)'s static manifest." + } + } +} + +public struct PluginModuleOwnership: Equatable, Sendable { + public let pluginID: PluginID + public let declaration: PluginModuleDeclaration +} + +public struct PluginLanguageSupportOwnership: Equatable, Sendable { + public let pluginID: PluginID + public let declaration: LanguageSupportDeclaration +} + +public struct ValidatedPluginCatalog: Sendable { + public let manifests: [PluginManifest] + public let modules: [ModuleID: PluginModuleOwnership] + public let languageSupports: [String: PluginLanguageSupportOwnership] + + public init( + manifests: [PluginManifest], + hostVersion: PluginVersion, + supportedAPIVersion: Int = PluginManifest.currentAPIVersion + ) throws { + var pluginIDs: Set = [] + var modules: [ModuleID: PluginModuleOwnership] = [:] + var languageSupports: [String: PluginLanguageSupportOwnership] = [:] + for plugin in manifests.sorted(by: { $0.id < $1.id }) { + guard pluginIDs.insert(plugin.id).inserted else { + throw PluginCatalogError.duplicatePlugin(plugin.id) + } + guard plugin.schemaVersion == PluginManifest.currentSchemaVersion else { + throw PluginCatalogError.unsupportedSchema( + plugin: plugin.id, + version: plugin.schemaVersion + ) + } + guard plugin.apiVersion == supportedAPIVersion else { + throw PluginCatalogError.unsupportedAPI(plugin: plugin.id, version: plugin.apiVersion) + } + guard plugin.hostCompatibility.contains(hostVersion) else { + throw PluginCatalogError.incompatibleHost( + plugin: plugin.id, + hostVersion: hostVersion + ) + } + guard !plugin.modules.isEmpty else { + throw PluginCatalogError.emptyPlugin(plugin.id) + } + switch plugin.entrypoint.kind { + case .builtIn: + guard plugin.entrypoint.targetName?.isEmpty == false, + plugin.entrypoint.bundleIdentifier == nil, + plugin.entrypoint.principalClass == nil, + plugin.entrypoint.bundlePath == nil else { + throw PluginCatalogError.invalidEntrypoint(plugin.id) + } + case .nativeBundle: + guard plugin.entrypoint.targetName == nil, + plugin.entrypoint.bundleIdentifier?.isEmpty == false, + plugin.entrypoint.principalClass?.isEmpty == false, + Self.isSafeRelativePath(plugin.entrypoint.bundlePath) else { + throw PluginCatalogError.invalidEntrypoint(plugin.id) + } + } + for declaration in plugin.modules { + let moduleID = declaration.manifest.id + if let existing = modules[moduleID] { + throw PluginCatalogError.duplicateModule( + module: moduleID, + plugins: [existing.pluginID, plugin.id].sorted() + ) + } + modules[moduleID] = PluginModuleOwnership( + pluginID: plugin.id, + declaration: declaration + ) + } + try Self.validateLanguageSupports(in: plugin) + for support in plugin.languageSupports ?? [] { + if let existing = languageSupports[support.id] { + throw PluginCatalogError.duplicateLanguageSupport( + languageID: support.id, + plugins: [existing.pluginID, plugin.id].sorted() + ) + } + languageSupports[support.id] = PluginLanguageSupportOwnership( + pluginID: plugin.id, + declaration: support + ) + } + } + self.manifests = manifests.sorted { $0.id < $1.id } + self.modules = modules + self.languageSupports = languageSupports + } + + public func languageSupport(for fileURL: URL) -> PluginLanguageSupportOwnership? { + languageSupports.values + .filter { $0.declaration.handles(fileURL: fileURL) } + .sorted { $0.declaration.id < $1.declaration.id } + .first + } + + public func languageSupports( + recognizingProjectFileNames fileNames: some Sequence + ) -> [PluginLanguageSupportOwnership] { + languageSupports.values + .filter { $0.declaration.recognizesProject(fileNames: fileNames) } + .sorted { $0.declaration.id < $1.declaration.id } + } + + private static func isSafeRelativePath(_ path: String?) -> Bool { + guard let path, !path.isEmpty, !path.hasPrefix("/") else { return false } + return !path.split(separator: "/", omittingEmptySubsequences: false).contains("..") + } + + private static func validateLanguageSupports(in plugin: PluginManifest) throws { + let declaredModuleIDs = Set(plugin.modules.map(\.manifest.id)) + var languageIDs: Set = [] + for support in plugin.languageSupports ?? [] { + let moduleIDs = support.moduleIDs + let hasRecognitionMetadata = !support.fileExtensions.isEmpty + || !support.fileNames.isEmpty + || !support.projectFileNames.isEmpty + let normalizedID = support.id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let hasInvalidName = support.id != normalizedID + || normalizedID.isEmpty + || support.displayName.isEmpty + || support.fileExtensions.contains(where: { $0.contains("/") || $0.hasPrefix(".") }) + || support.fileNames.contains(where: { $0.contains("/") }) + || support.projectFileNames.contains(where: { $0.contains("/") }) + guard languageIDs.insert(support.id).inserted, + hasRecognitionMetadata, + !hasInvalidName, + !moduleIDs.isEmpty, + moduleIDs.allSatisfy(declaredModuleIDs.contains) else { + throw PluginCatalogError.invalidLanguageSupport( + plugin: plugin.id, + languageID: support.id + ) + } + } + } +} diff --git a/Sources/LitheApplicationKernel/Registry/ModuleRegistry.swift b/Sources/LitheApplicationKernel/Registry/ModuleRegistry.swift new file mode 100644 index 00000000..7dbc18ce --- /dev/null +++ b/Sources/LitheApplicationKernel/Registry/ModuleRegistry.swift @@ -0,0 +1,70 @@ +import Foundation +import LitheModuleAPI + +/// Declarative registration surface for the application composition root. +/// A feature module contributes one factory; lifecycle and graph validation +/// remain centralized in ModuleRuntime. +@MainActor +public final class ModuleRegistry { + private let runtime: ModuleRuntime + private let pluginManifests: [PluginManifest] + private let hostVersion: PluginVersion + private var factories: [ModuleID: ModuleFactory] = [:] + + public init( + runtime: ModuleRuntime, + pluginManifests: [PluginManifest] = BuiltInPluginCatalog.manifests, + hostVersion: PluginVersion = BuiltInPluginCatalog.hostVersion + ) { + self.runtime = runtime + self.pluginManifests = pluginManifests + self.hostVersion = hostVersion + } + + public func register(_ factory: ModuleFactory) throws { + guard factories[factory.manifest.id] == nil else { + throw ModuleRuntimeError.duplicateModule(factory.manifest.id) + } + factories[factory.manifest.id] = factory + try runtime.register(factory) + } + + public func validate() throws { + let catalog = try ValidatedPluginCatalog( + manifests: pluginManifests, + hostVersion: hostVersion + ) + for required in BuiltInModuleCatalog.manifests.filter(\.isRequired) { + guard catalog.modules[required.id] != nil, factories[required.id] != nil else { + throw PluginCatalogError.missingRequiredModule(required.id) + } + } + for (moduleID, factory) in factories { + guard let ownership = catalog.modules[moduleID] else { + throw PluginCatalogError.factoryWithoutInstalledPlugin(moduleID) + } + guard ownership.declaration.manifest == factory.manifest, + ownership.declaration.contributions == factory.contributions else { + throw PluginCatalogError.moduleFactoryMismatch( + plugin: ownership.pluginID, + module: moduleID + ) + } + } + for (moduleID, ownership) in catalog.modules where factories[moduleID] == nil { + throw PluginCatalogError.missingModuleFactory( + plugin: ownership.pluginID, + module: moduleID + ) + } + try runtime.validateGraph() + } + + public func startEagerModules() async throws { + try await runtime.startEagerModules() + } + + public var registeredModuleIDs: [ModuleID] { + factories.keys.sorted() + } +} diff --git a/Sources/LitheCoreContracts/AI/AIAssistancePorts.swift b/Sources/LitheCoreContracts/AI/AIAssistancePorts.swift new file mode 100644 index 00000000..710a1c65 --- /dev/null +++ b/Sources/LitheCoreContracts/AI/AIAssistancePorts.swift @@ -0,0 +1,91 @@ +import Foundation + +@MainActor +public protocol AICommitMessageGenerating: AnyObject { + func generateCommitMessage( + input: CommitMessageInput, + settings: CommitMessageAISettings + ) async throws -> String +} + +public protocol AIProviderCredentialResolver: Sendable { + func readAPIKey(for provider: AIProviderProfile) -> String? +} + +public protocol AIHTTPTransport: Sendable { + func send(_ request: AIHTTPRequest) async throws -> AIHTTPResponse +} + +public struct AIHTTPRequest: Sendable { + public let url: URL + public let headers: [String: String] + public let body: Data + public let timeout: TimeInterval + public let allowsInsecureHTTP: Bool + + public init( + url: URL, + headers: [String: String], + body: Data, + timeout: TimeInterval, + allowsInsecureHTTP: Bool = false + ) { + self.url = url + self.headers = headers + self.body = body + self.timeout = timeout + self.allowsInsecureHTTP = allowsInsecureHTTP + } +} + +public struct AIHTTPResponse: Sendable { + public let statusCode: Int + public let body: Data + + public init(statusCode: Int, body: Data) { + self.statusCode = statusCode + self.body = body + } +} + +public protocol AIConfigurationSource: Sendable { + func load() -> AIConfigurationSnapshot? +} + +public protocol CodexConfigurationSource: AIConfigurationSource {} +public protocol ClaudeConfigurationSource: AIConfigurationSource {} + +public enum CommitMessageGenerationError: LocalizedError, Sendable { + case noProviderConfigured + case invalidProvider + case insecureEndpoint + case missingAPIKey + case emptyDiff + case sensitiveFileExcluded + case httpFailure(statusCode: Int) + case invalidResponse + case emptyResponse + + public var errorDescription: String? { + switch self { + case .noProviderConfigured: + return "Configure an AI provider in Settings first." + case .invalidProvider: + return "The selected AI provider has an invalid API URL or model." + case .insecureEndpoint: + return String(localized: "HTTP is disabled for this provider. Enable the insecure HTTP option or use HTTPS.") + case .missingAPIKey: + return "The selected AI provider has no API key." + case .emptyDiff: + return String(localized: "The staged changes have no textual diff to summarize.") + case .sensitiveFileExcluded: + return "Sensitive files are not sent to an AI provider." + case .httpFailure(let statusCode): + return "The AI provider returned HTTP \(statusCode)." + case .invalidResponse: + return "The AI provider returned an unexpected response." + case .emptyResponse: + return "The AI provider returned an empty commit message." + } + } +} diff --git a/Sources/LitheCoreContracts/AI/CommitMessageInput.swift b/Sources/LitheCoreContracts/AI/CommitMessageInput.swift new file mode 100644 index 00000000..75faf761 --- /dev/null +++ b/Sources/LitheCoreContracts/AI/CommitMessageInput.swift @@ -0,0 +1,36 @@ +import Foundation + +public enum CommitMessageChangeKind: String, Sendable { + case added, modified, deleted, renamed, copied, unmerged, untracked + public var title: String { + switch self { + case .added: "Added" + case .modified: "Modified" + case .deleted: "Deleted" + case .renamed: "Renamed" + case .copied: "Copied" + case .unmerged: "Unmerged" + case .untracked: "Untracked" + } + } +} + +public struct CommitMessageFileInput: Sendable { + public let path: String + public let changeKind: CommitMessageChangeKind + public let diff: String + public init(path: String, changeKind: CommitMessageChangeKind, diff: String) { + self.path = path; self.changeKind = changeKind; self.diff = diff + } +} + +public struct CommitMessageInput: Sendable { + public let files: [CommitMessageFileInput] + public init(files: [CommitMessageFileInput]) { self.files = files } + public init(path: String, changeKind: CommitMessageChangeKind, diff: String) { + files = [CommitMessageFileInput(path: path, changeKind: changeKind, diff: diff)] + } + public var path: String { files.count == 1 ? (files.first?.path ?? "") : "\(files.count) files" } + public var changeKind: CommitMessageChangeKind { files.count == 1 ? (files.first?.changeKind ?? .modified) : .modified } + public var diff: String { files.map(\.diff).joined(separator: "\n\n") } +} diff --git a/Sources/Lithe/Models/CommitMessageModels.swift b/Sources/LitheCoreContracts/AI/CommitMessageModels.swift similarity index 70% rename from Sources/Lithe/Models/CommitMessageModels.swift rename to Sources/LitheCoreContracts/AI/CommitMessageModels.swift index 6f0b9e4e..2643012a 100644 --- a/Sources/Lithe/Models/CommitMessageModels.swift +++ b/Sources/LitheCoreContracts/AI/CommitMessageModels.swift @@ -1,13 +1,13 @@ import Foundation -enum CommitMessageAPIProtocol: String, CaseIterable, Codable, Identifiable, Sendable { +public enum CommitMessageAPIProtocol: String, CaseIterable, Codable, Identifiable, Sendable { case responses case chatCompletions case anthropicMessages - var id: String { rawValue } + public var id: String { rawValue } - var title: String { + public var title: String { switch self { case .responses: return "Responses API" @@ -18,7 +18,7 @@ enum CommitMessageAPIProtocol: String, CaseIterable, Codable, Identifiable, Send } } - var endpointSuffix: String { + public var endpointSuffix: String { switch self { case .responses: return "responses" @@ -30,12 +30,12 @@ enum CommitMessageAPIProtocol: String, CaseIterable, Codable, Identifiable, Send } } -enum AIProviderAuthentication: String, Codable, Sendable { +public enum AIProviderAuthentication: String, Codable, Sendable { case bearer case apiKey } -enum CommitMessageReasoningEffort: String, CaseIterable, Codable, Identifiable, Sendable { +public enum CommitMessageReasoningEffort: String, CaseIterable, Codable, Identifiable, Sendable { case none case low case medium @@ -43,9 +43,9 @@ enum CommitMessageReasoningEffort: String, CaseIterable, Codable, Identifiable, case xhigh case max - var id: String { rawValue } + public var id: String { rawValue } - var title: String { + public var title: String { switch self { case .none: return "None (fastest)" @@ -63,13 +63,13 @@ enum CommitMessageReasoningEffort: String, CaseIterable, Codable, Identifiable, } } -enum CommitMessageLanguage: String, CaseIterable, Codable, Identifiable, Sendable { +public enum CommitMessageLanguage: String, CaseIterable, Codable, Identifiable, Sendable { case english case simplifiedChinese - var id: String { rawValue } + public var id: String { rawValue } - var title: String { + public var title: String { switch self { case .english: return "English" @@ -79,7 +79,7 @@ enum CommitMessageLanguage: String, CaseIterable, Codable, Identifiable, Sendabl } } -enum CommitMessageFormat: String, CaseIterable, Codable, Hashable, Identifiable, Sendable { +public enum CommitMessageFormat: String, CaseIterable, Codable, Hashable, Identifiable, Sendable { case conventional case concise case imperative @@ -87,15 +87,15 @@ enum CommitMessageFormat: String, CaseIterable, Codable, Hashable, Identifiable, case releaseNote case custom - static let builtInCases: [Self] = [.conventional, .concise, .descriptive] + public static let builtInCases: [Self] = [.conventional, .concise, .descriptive] - static var allCases: [Self] { + public static var allCases: [Self] { builtInCases + [.custom] } - var id: String { rawValue } + public var id: String { rawValue } - var icon: String { + public var icon: String { switch self { case .conventional: return "number" @@ -112,7 +112,7 @@ enum CommitMessageFormat: String, CaseIterable, Codable, Hashable, Identifiable, } } - var title: String { + public var title: String { switch self { case .conventional: return "Conventional Commits" @@ -129,7 +129,7 @@ enum CommitMessageFormat: String, CaseIterable, Codable, Hashable, Identifiable, } } - var description: String { + public var description: String { switch self { case .conventional: return "Structured type(scope): subject format" @@ -146,7 +146,7 @@ enum CommitMessageFormat: String, CaseIterable, Codable, Hashable, Identifiable, } } - var example: String { + public var example: String { switch self { case .conventional: return "feat(editor): add memory usage indicator" @@ -164,12 +164,12 @@ enum CommitMessageFormat: String, CaseIterable, Codable, Hashable, Identifiable, } } -enum AIProviderCredentialSource: String, Codable, Sendable { +public enum AIProviderCredentialSource: String, Codable, Sendable { case local case codex case claude - var configurationSource: AIConfigurationSourceKind? { + public var configurationSource: AIConfigurationSourceKind? { switch self { case .local: return nil @@ -181,13 +181,13 @@ enum AIProviderCredentialSource: String, Codable, Sendable { } } -enum AIConfigurationSourceKind: String, CaseIterable, Identifiable, Sendable { +public enum AIConfigurationSourceKind: String, CaseIterable, Identifiable, Sendable { case codex case claude - var id: String { rawValue } + public var id: String { rawValue } - var title: String { + public var title: String { switch self { case .codex: return "Codex" @@ -196,7 +196,7 @@ enum AIConfigurationSourceKind: String, CaseIterable, Identifiable, Sendable { } } - var credentialSource: AIProviderCredentialSource { + public var credentialSource: AIProviderCredentialSource { switch self { case .codex: return .codex @@ -205,46 +205,46 @@ enum AIConfigurationSourceKind: String, CaseIterable, Identifiable, Sendable { } } - var detectedTitle: String { + public var detectedTitle: String { "\(title) configuration detected" } - var apiKeyAvailableTitle: String { + public var apiKeyAvailableTitle: String { "API key available in \(title) configuration" } - var noAPIKeyTitle: String { + public var noAPIKeyTitle: String { "No API key found in \(title) configuration" } - var credentialAvailableTitle: String { + public var credentialAvailableTitle: String { "Credential available in \(title) configuration" } - var noCredentialTitle: String { + public var noCredentialTitle: String { "No credential found in \(title) configuration" } - var importTitle: String { + public var importTitle: String { "Import from \(title)" } - var settingsDescription: String { + public var settingsDescription: String { "\(title) settings and credentials are read directly from its local configuration files." } } -struct AIProviderProfile: Codable, Equatable, Identifiable, Sendable { - let id: UUID - var name: String - var endpoint: String - var model: String - var apiProtocol: CommitMessageAPIProtocol - var authentication: AIProviderAuthentication - var allowsInsecureHTTP: Bool - var apiKeyIdentifier: String - var requiresAPIKey: Bool - var credentialSource: AIProviderCredentialSource +public struct AIProviderProfile: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public var name: String + public var endpoint: String + public var model: String + public var apiProtocol: CommitMessageAPIProtocol + public var authentication: AIProviderAuthentication + public var allowsInsecureHTTP: Bool + public var apiKeyIdentifier: String + public var requiresAPIKey: Bool + public var credentialSource: AIProviderCredentialSource private enum CodingKeys: String, CodingKey { case id @@ -259,7 +259,7 @@ struct AIProviderProfile: Codable, Equatable, Identifiable, Sendable { case credentialSource } - init( + public init( id: UUID = UUID(), name: String, endpoint: String, @@ -284,7 +284,7 @@ struct AIProviderProfile: Codable, Equatable, Identifiable, Sendable { self.credentialSource = credentialSource } - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) name = try container.decode(String.self, forKey: .name) @@ -307,7 +307,7 @@ struct AIProviderProfile: Codable, Equatable, Identifiable, Sendable { ) ?? .local } - func encode(to encoder: Encoder) throws { + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(name, forKey: .name) @@ -321,13 +321,13 @@ struct AIProviderProfile: Codable, Equatable, Identifiable, Sendable { try container.encode(credentialSource, forKey: .credentialSource) } - var endpointURL: URL? { + public var endpointURL: URL? { let value = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { return nil } return URL(string: value) } - var isValid: Bool { + public var isValid: Bool { guard let url = endpointURL, let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), @@ -338,24 +338,24 @@ struct AIProviderProfile: Codable, Equatable, Identifiable, Sendable { return true } - var usesInsecureHTTP: Bool { + public var usesInsecureHTTP: Bool { endpointURL?.scheme?.lowercased() == "http" } } -struct CommitMessageAISettings: Codable, Equatable, Sendable { - var providers: [AIProviderProfile] - var activeProviderID: UUID? - var reasoningEffort: CommitMessageReasoningEffort - var language: CommitMessageLanguage - var format: CommitMessageFormat - var customInstructions: String - var includeBody: Bool - var subjectMaximumLength: Int - var maximumDiffCharacters: Int - var codexImportCompleted: Bool - - static var `default`: Self { +public struct CommitMessageAISettings: Codable, Equatable, Sendable { + public var providers: [AIProviderProfile] + public var activeProviderID: UUID? + public var reasoningEffort: CommitMessageReasoningEffort + public var language: CommitMessageLanguage + public var format: CommitMessageFormat + public var customInstructions: String + public var includeBody: Bool + public var subjectMaximumLength: Int + public var maximumDiffCharacters: Int + public var codexImportCompleted: Bool + + public static var `default`: Self { Self( providers: [], activeProviderID: nil, @@ -370,16 +370,16 @@ struct CommitMessageAISettings: Codable, Equatable, Sendable { ) } - var activeProvider: AIProviderProfile? { + public var activeProvider: AIProviderProfile? { guard let activeProviderID else { return nil } return providers.first { $0.id == activeProviderID } } - mutating func selectProvider(_ id: UUID?) { + public mutating func selectProvider(_ id: UUID?) { activeProviderID = id } - mutating func updateActiveProvider(_ update: (inout AIProviderProfile) -> Void) { + public mutating func updateActiveProvider(_ update: (inout AIProviderProfile) -> Void) { guard let activeProviderID, let index = providers.firstIndex(where: { $0.id == activeProviderID }) else { return @@ -387,7 +387,7 @@ struct CommitMessageAISettings: Codable, Equatable, Sendable { update(&providers[index]) } - mutating func addProvider() -> AIProviderProfile { + public mutating func addProvider() -> AIProviderProfile { let provider = AIProviderProfile( name: "Custom Provider", endpoint: "", @@ -400,57 +400,25 @@ struct CommitMessageAISettings: Codable, Equatable, Sendable { return provider } - mutating func removeActiveProvider() { + public mutating func removeActiveProvider() { guard let activeProviderID else { return } providers.removeAll { $0.id == activeProviderID } self.activeProviderID = providers.first?.id } } -struct CommitMessageFileInput: Sendable { - let path: String - let changeKind: GitChangeKind - let diff: String -} - -struct CommitMessageInput: Sendable { - let files: [CommitMessageFileInput] - - init(files: [CommitMessageFileInput]) { - self.files = files - } - - init(path: String, changeKind: GitChangeKind, diff: String) { - files = [CommitMessageFileInput(path: path, changeKind: changeKind, diff: diff)] - } - - // These accessors keep single-file callers source-compatible while the - // generation pipeline can now represent one complete staged change set. - var path: String { - files.count == 1 ? (files.first?.path ?? "") : "(files.count) files" - } - - var changeKind: GitChangeKind { - files.count == 1 ? (files.first?.changeKind ?? .modified) : .modified - } - - var diff: String { - files.map(\.diff).joined(separator: "\n\n") - } -} - -struct AIConfigurationSnapshot: Identifiable, Sendable { - let source: AIConfigurationSourceKind - let providerName: String - let endpoint: String - let model: String - let apiProtocol: CommitMessageAPIProtocol - let authentication: AIProviderAuthentication - let reasoningEffort: CommitMessageReasoningEffort? - let requiresAPIKey: Bool - let apiKey: String? - - init( +public struct AIConfigurationSnapshot: Identifiable, Sendable { + public let source: AIConfigurationSourceKind + public let providerName: String + public let endpoint: String + public let model: String + public let apiProtocol: CommitMessageAPIProtocol + public let authentication: AIProviderAuthentication + public let reasoningEffort: CommitMessageReasoningEffort? + public let requiresAPIKey: Bool + public let apiKey: String? + + public init( source: AIConfigurationSourceKind = .codex, providerName: String, endpoint: String, @@ -472,13 +440,13 @@ struct AIConfigurationSnapshot: Identifiable, Sendable { self.apiKey = apiKey } - var id: String { source.rawValue } + public var id: String { source.rawValue } - var hasAPIKey: Bool { + public var hasAPIKey: Bool { !(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) } - var hasCredential: Bool { hasAPIKey } + public var hasCredential: Bool { hasAPIKey } } -typealias CodexConfigurationSnapshot = AIConfigurationSnapshot +public typealias CodexConfigurationSnapshot = AIConfigurationSnapshot diff --git a/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift b/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift new file mode 100644 index 00000000..0a9ebd43 --- /dev/null +++ b/Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift @@ -0,0 +1,169 @@ +import Foundation + +@MainActor +public protocol DebugAdapterSession: AnyObject { + var isRunning: Bool { get } + var state: DebugAdapterState { get } + func start(rootURL: URL) throws + func stop() +} + +@MainActor +public protocol DebugAdapterTransport: AnyObject { + var isRunning: Bool { get } + var onData: ((Data) -> Void)? { get set } + var onErrorOutput: ((Data) -> Void)? { get set } + var onTermination: ((Int) -> Void)? { get set } + func start(rootURL: URL) throws + func send(_ data: Data) throws + func stop() +} + +@MainActor +public protocol DebugAdapterChildTransportProviding: AnyObject { + func makeChildTransport() -> (any DebugAdapterTransport)? +} + +public extension DebugAdapterSession { + var state: DebugAdapterState { isRunning ? .running : .idle } +} + +public enum DebugAdapterState: String, Equatable, Sendable { + case idle, initializing, ready, launching, running, paused, terminated, failed +} + +public enum DebugRequestKind: String, Equatable, Sendable { + case launch, attach +} + +public struct DebugLaunchConfiguration: Equatable, Sendable { + public let name: String + public let request: DebugRequestKind + public let arguments: [String: ToolingJSONValue] + + public init(name: String, request: DebugRequestKind, arguments: [String: ToolingJSONValue]) { + self.name = name + self.request = request + self.arguments = arguments + } +} + +public struct DebugSourceBreakpoint: Hashable, Sendable { + public let line: Int + public let column: Int? + public let condition: String? + + public init(line: Int, column: Int? = nil, condition: String? = nil) { + self.line = line + self.column = column + self.condition = condition + } +} + +public struct DebugBreakpoint: Identifiable, Equatable, Sendable { + public let id: Int + public let verified: Bool + public let message: String? + public let sourceURL: URL? + public let line: Int? + public let column: Int? + + public init(id: Int, verified: Bool, message: String?, sourceURL: URL?, line: Int?, column: Int?) { + self.id = id + self.verified = verified + self.message = message + self.sourceURL = sourceURL + self.line = line + self.column = column + } +} + +public struct DebugThread: Identifiable, Equatable, Sendable { + public let id: Int + public let name: String + public init(id: Int, name: String) { self.id = id; self.name = name } +} + +public struct DebugStackFrame: Identifiable, Equatable, Sendable { + public let id: Int + public let name: String + public let sourceURL: URL? + public let line: Int + public let column: Int + + public init(id: Int, name: String, sourceURL: URL?, line: Int, column: Int) { + self.id = id + self.name = name + self.sourceURL = sourceURL + self.line = line + self.column = column + } +} + +public struct DebugScope: Identifiable, Equatable, Sendable { + public let id: Int + public let name: String + public let variablesReference: Int + public let expensive: Bool + + public init(id: Int, name: String, variablesReference: Int, expensive: Bool) { + self.id = id + self.name = name + self.variablesReference = variablesReference + self.expensive = expensive + } +} + +public struct DebugVariable: Identifiable, Equatable, Sendable { + public let id: String + public let name: String + public let value: String + public let type: String? + public let evaluateName: String? + public let variablesReference: Int + public var isExpandable: Bool { variablesReference > 0 } + + public init( + id: String, + name: String, + value: String, + type: String?, + evaluateName: String?, + variablesReference: Int + ) { + self.id = id + self.name = name + self.value = value + self.type = type + self.evaluateName = evaluateName + self.variablesReference = variablesReference + } +} + +public enum DebugAdapterEvent: Equatable, Sendable { + case initialized + case output(category: String?, output: String) + case stopped(reason: String, threadID: Int?, description: String?) + case continued(threadID: Int?) + case terminated(exitCode: Int?) + case breakpoint(DebugBreakpoint) +} + +public enum DebugExecutionCommand: String, Equatable, Sendable { + case continueExecution = "continue" + case pause, next, stepIn, stepOut +} + +@MainActor +public protocol DebugAdapterControllingSession: DebugAdapterSession { + var onStateChange: ((DebugAdapterState) -> Void)? { get set } + var onEvent: ((DebugAdapterEvent) -> Void)? { get set } + func launch(_ configuration: DebugLaunchConfiguration) throws + func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) + func execute(_ command: DebugExecutionCommand, threadID: Int?) + func requestThreads(_ completion: @escaping (Result<[DebugThread], Error>) -> Void) + func requestStackTrace(threadID: Int, completion: @escaping (Result<[DebugStackFrame], Error>) -> Void) + func requestScopes(frameID: Int, completion: @escaping (Result<[DebugScope], Error>) -> Void) + func requestVariables(reference: Int, completion: @escaping (Result<[DebugVariable], Error>) -> Void) + func evaluate(_ expression: String, frameID: Int?, completion: @escaping (Result) -> Void) +} diff --git a/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift b/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift new file mode 100644 index 00000000..5fd39405 --- /dev/null +++ b/Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Debug-owned projection of language catalog metadata. DAP orchestration +/// needs file matching and a stable adapter ID, not the Language module's +/// provider runtime or LSP capability graph. +public struct DebugProviderDescriptor: Identifiable, Hashable, Sendable { + public let id: String + public let displayName: String + public let fileExtensions: Set + public let fileNames: Set + public let fileNamePrefixes: Set + + public init( + id: String, + displayName: String, + fileExtensions: Set, + fileNames: Set = [], + fileNamePrefixes: Set = [] + ) { + self.id = id + self.displayName = displayName + self.fileExtensions = fileExtensions + self.fileNames = fileNames + self.fileNamePrefixes = fileNamePrefixes + } + + public func matches(_ fileURL: URL) -> Bool { + let name = fileURL.lastPathComponent.lowercased() + if fileNames.contains(name) { return true } + if fileNamePrefixes.contains(where: name.hasPrefix) { return true } + return fileExtensions.contains(fileURL.pathExtension.lowercased()) + } +} + +public enum DebugProviderError: LocalizedError, Equatable, Sendable { + case noProvider(fileExtension: String) + case adapterUnavailable(String) + case capabilityUnavailable(provider: String, capability: String) + + public var errorDescription: String? { + switch self { + case .noProvider(let fileExtension): + "No debug provider handles .\(fileExtension) files." + case .adapterUnavailable(let provider): + "\(provider) debug adapter is unavailable." + case .capabilityUnavailable(let provider, let capability): + "The \(provider) debug provider does not support \(capability)." + } + } +} diff --git a/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift b/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift new file mode 100644 index 00000000..b0efa306 --- /dev/null +++ b/Sources/LitheCoreContracts/Execution/ExecutionContracts.swift @@ -0,0 +1,282 @@ +import Foundation + +package struct RunOptions: Codable, Hashable, Sendable { + package struct JavaCapability: Codable, Hashable, Sendable { + package var homePath = "" + package var mavenExecutablePath = "" + package var mavenJavaHomePath = "" + package var vmArguments = "" + package var activeMavenProfiles: Set = [] + + private enum CodingKeys: String, CodingKey { + case homePath, mavenExecutablePath, mavenJavaHomePath, vmArguments, activeMavenProfiles + } + + package init( + homePath: String = "", + mavenExecutablePath: String = "", + mavenJavaHomePath: String = "", + vmArguments: String = "", + activeMavenProfiles: Set = [] + ) { + self.homePath = homePath + self.mavenExecutablePath = mavenExecutablePath + self.mavenJavaHomePath = mavenJavaHomePath + self.vmArguments = vmArguments + self.activeMavenProfiles = activeMavenProfiles + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + homePath = try container.decodeIfPresent(String.self, forKey: .homePath) ?? "" + mavenExecutablePath = try container.decodeIfPresent(String.self, forKey: .mavenExecutablePath) ?? "" + mavenJavaHomePath = try container.decodeIfPresent(String.self, forKey: .mavenJavaHomePath) ?? "" + vmArguments = try container.decodeIfPresent(String.self, forKey: .vmArguments) ?? "" + activeMavenProfiles = try container.decodeIfPresent(Set.self, forKey: .activeMavenProfiles) ?? [] + } + } + + package var workingDirectoryPath = "" + package var arguments = "" + package var environment: [String: String] = [:] + package var java = JavaCapability() + + package init( + javaHomePath: String = "", + workingDirectoryPath: String = "", + vmArguments: String = "", + programArguments: String = "", + activeProfiles: Set = [], + mavenExecutablePath: String = "", + mavenJavaHomePath: String = "", + environment: [String: String] = [:] + ) { + self.workingDirectoryPath = workingDirectoryPath + arguments = programArguments + self.environment = environment + java = JavaCapability( + homePath: javaHomePath, + mavenExecutablePath: mavenExecutablePath, + mavenJavaHomePath: mavenJavaHomePath, + vmArguments: vmArguments, + activeMavenProfiles: activeProfiles + ) + } + + package var javaHomePath: String { + get { java.homePath } + set { java.homePath = newValue } + } + package var vmArguments: String { + get { java.vmArguments } + set { java.vmArguments = newValue } + } + package var mavenExecutablePath: String { + get { java.mavenExecutablePath } + set { java.mavenExecutablePath = newValue } + } + package var mavenJavaHomePath: String { + get { java.mavenJavaHomePath } + set { java.mavenJavaHomePath = newValue } + } + package var programArguments: String { + get { arguments } + set { arguments = newValue } + } + package var activeProfiles: Set { + get { java.activeMavenProfiles } + set { java.activeMavenProfiles = newValue } + } + + private enum CodingKeys: String, CodingKey { + case workingDirectoryPath, arguments, environment, java + case javaHomePath, vmArguments, programArguments, activeProfiles + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + workingDirectoryPath = try container.decodeIfPresent(String.self, forKey: .workingDirectoryPath) ?? "" + arguments = try container.decodeIfPresent(String.self, forKey: .arguments) + ?? container.decodeIfPresent(String.self, forKey: .programArguments) + ?? "" + environment = try container.decodeIfPresent([String: String].self, forKey: .environment) ?? [:] + java = try container.decodeIfPresent(JavaCapability.self, forKey: .java) ?? JavaCapability( + homePath: try container.decodeIfPresent(String.self, forKey: .javaHomePath) ?? "", + vmArguments: try container.decodeIfPresent(String.self, forKey: .vmArguments) ?? "", + activeMavenProfiles: try container.decodeIfPresent(Set.self, forKey: .activeProfiles) ?? [] + ) + } + + package func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(workingDirectoryPath, forKey: .workingDirectoryPath) + try container.encode(arguments, forKey: .arguments) + try container.encode(environment, forKey: .environment) + try container.encode(java, forKey: .java) + } +} + +package struct SharedLaunchPlan: Sendable { + package enum Executable: Sendable { + case toolchain(String) + case command(String) + } + + package let executable: Executable + package let arguments: [String] + package let workingDirectory: String + package var environment: [String: String] + + package init( + executable: Executable, + arguments: [String], + workingDirectory: String, + environment: [String: String] = [:] + ) { + self.executable = executable + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environment = environment + } + + package var toolchainID: String? { + if case .toolchain(let value) = executable { return value } + return nil + } +} + +package struct ProjectToolchainCandidate: Codable, Equatable, Sendable { + package let id: String + package let type: String + package let version: String + package let vendor: String + + package init(id: String, type: String, version: String, vendor: String) { + self.id = id + self.type = type + self.version = version + self.vendor = vendor + } +} + +package struct ResolvedRunExecutable: Sendable { + package let executableURL: URL + package let environment: [String: String] + + package init(executableURL: URL, environment: [String: String]) { + self.executableURL = executableURL + self.environment = environment + } +} + +@MainActor +package protocol RunExecutableResolving: AnyObject { + func resolve(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) throws -> ResolvedRunExecutable + func refreshCandidates(projectURL: URL) async + func candidates(projectURL: URL) -> [ProjectToolchainCandidate] +} + +package extension RunExecutableResolving { + func refreshCandidates(projectURL _: URL) async {} + func candidates(projectURL _: URL) -> [ProjectToolchainCandidate] { [] } +} + +@MainActor +package protocol RunRuntimePort: AnyObject { + func setActiveServiceJavaHomePath(_ path: String) + func javaHomeURL(overridePath: String?) -> URL? + func mavenJavaHomeURL(overridePath: String?) -> URL? + func runConfigurationToolchainCandidates( + for project: MavenProject?, + projectRoot: URL?, + javaHomeOverride: String?, + mavenExecutableOverride: String? + ) -> [ProjectToolchainCandidate] +} + +package protocol RunFileAccess: Sendable { + func isDirectory(at url: URL) -> Bool + func readData(from url: URL) throws -> Data +} + +@MainActor +package protocol RunPreferenceStore: AnyObject { + func data(forKey key: String) -> Data? + func string(forKey key: String) -> String? + func setData(_ data: Data, forKey key: String) + func setString(_ value: String, forKey key: String) +} + +package protocol RunServerPortParsing: Sendable { + func serverPort(content: String, fileExtension: String) -> Int? +} + +package enum LanguageTestItemKind: String, Equatable, Sendable { + case workspace, file, testCase +} + +package struct LanguageTestItem: Identifiable, Equatable, Sendable { + package let id: String + package let providerID: String + package let label: String + package let kind: LanguageTestItemKind + package let fileURL: URL? + + package init(id: String, providerID: String, label: String, kind: LanguageTestItemKind, fileURL: URL?) { + self.id = id + self.providerID = providerID + self.label = label + self.kind = kind + self.fileURL = fileURL + } +} + +package enum LanguageTestScope: Equatable, Sendable { + case workspace + case file(URL) + case testCase(identifier: String, fileURL: URL?) +} + +package struct LanguageTestContext: Equatable, Sendable { + package let workspaceURL: URL + package let projectFiles: [URL] + + package init(workspaceURL: URL, projectFiles: [URL] = []) { + self.workspaceURL = workspaceURL.standardizedFileURL + self.projectFiles = projectFiles.map(\.standardizedFileURL) + } + + package var projectFileNames: Set { + Set(projectFiles.map { $0.lastPathComponent.lowercased() }) + } +} + +package struct LanguageTestPlan: Sendable { + package let providerID: String + package let label: String + package let frameworkID: String? + package let launchPlan: SharedLaunchPlan + + package init(providerID: String, label: String, frameworkID: String? = nil, launchPlan: SharedLaunchPlan) { + self.providerID = providerID + self.label = label + self.frameworkID = frameworkID + self.launchPlan = launchPlan + } +} + +package protocol LanguageTestProvider: Sendable { + var descriptor: LanguageProviderDescriptor { get } + func discoverTests(workspaceURL: URL, files: [URL]) -> [LanguageTestItem] + func discoverTests(context: LanguageTestContext) -> [LanguageTestItem] + func testPlan(scope: LanguageTestScope, context: LanguageTestContext) throws -> LanguageTestPlan +} + +package extension LanguageTestProvider { + func discoverTests(context: LanguageTestContext) -> [LanguageTestItem] { + discoverTests(workspaceURL: context.workspaceURL, files: context.projectFiles) + } + func testPlan(scope: LanguageTestScope, workspaceURL: URL) throws -> LanguageTestPlan { + try testPlan(scope: scope, context: LanguageTestContext(workspaceURL: workspaceURL)) + } +} diff --git a/Sources/LitheCoreContracts/Execution/LanguageRunContracts.swift b/Sources/LitheCoreContracts/Execution/LanguageRunContracts.swift new file mode 100644 index 00000000..b3c63a1f --- /dev/null +++ b/Sources/LitheCoreContracts/Execution/LanguageRunContracts.swift @@ -0,0 +1,182 @@ +import Foundation + +package struct LanguageRunContext: Equatable, Sendable { + package let workspaceURL: URL + package let fileURL: URL + + package init(workspaceURL: URL, fileURL: URL) { + self.workspaceURL = workspaceURL.standardizedFileURL + self.fileURL = fileURL.standardizedFileURL + } + + package var relativeFilePath: String? { + let root = workspaceURL.path + let file = fileURL.path + guard file == root || file.hasPrefix(root + "/") else { return nil } + guard file != root else { return "" } + return String(file.dropFirst(root.count + 1)) + } +} + +package enum RunArgumentParser { + package static func parse(_ input: String) -> [String] { + var result: [String] = [] + var current = "" + var quote: Character? + var escaped = false + + for character in input { + if escaped { + current.append(character) + escaped = false + continue + } + if character == "\\" && quote != "'" { + escaped = true + continue + } + if character == "'" || character == "\"" { + if quote == character { + quote = nil + } else if quote == nil { + quote = character + } else { + current.append(character) + } + continue + } + if character.isWhitespace && quote == nil { + if !current.isEmpty { + result.append(current) + current = "" + } + } else { + current.append(character) + } + } + if escaped { current.append("\\") } + if !current.isEmpty { result.append(current) } + return result + } +} + +package enum LanguageRunPlanError: LocalizedError, Equatable, Sendable { + case noProvider(fileExtension: String) + case fileOutsideWorkspace(URL) + case unsupportedCurrentFile(String) + + package var errorDescription: String? { + switch self { + case .noProvider(let fileExtension): + return "No language run provider handles .\(fileExtension) files." + case .fileOutsideWorkspace(let url): + return "The current file is outside the workspace: \(url.path)" + case .unsupportedCurrentFile(let provider): + return "\(provider) does not support running the current file directly. Use a project run configuration." + } + } +} + +/// Language-specific translation for the language-neutral Current File entry. +/// The provider creates only a launch plan; executable lookup and process +/// lifecycle remain in the shared RunService and injected platform adapters. +package protocol LanguageRunProvider: Sendable { + var descriptor: LanguageProviderDescriptor { get } + func launchPlan( + context: LanguageRunContext, + options: RunOptions + ) throws -> SharedLaunchPlan +} + +package struct StandardLanguageRunProvider: LanguageRunProvider { + package let descriptor: LanguageProviderDescriptor + + package init(descriptor: LanguageProviderDescriptor) { + self.descriptor = descriptor + } + + package func launchPlan( + context: LanguageRunContext, + options: RunOptions + ) throws -> SharedLaunchPlan { + guard let relative = context.relativeFilePath else { + throw LanguageRunPlanError.fileOutsideWorkspace(context.fileURL) + } + guard !relative.isEmpty else { + throw LanguageRunPlanError.unsupportedCurrentFile(descriptor.displayName) + } + + switch descriptor.id { + case "python": + return SharedLaunchPlan( + executable: .toolchain("project-python"), + arguments: [relative] + RunArgumentParser.parse(options.arguments), + workingDirectory: ".", + environment: options.environment + ) + case "node": + let extensionName = context.fileURL.pathExtension.lowercased() + if extensionName == "ts" || extensionName == "tsx" { + return SharedLaunchPlan( + executable: .toolchain("project-tsx"), + arguments: [relative] + RunArgumentParser.parse(options.arguments), + workingDirectory: ".", + environment: options.environment + ) + } + return SharedLaunchPlan( + executable: .toolchain("project-node"), + arguments: [relative] + RunArgumentParser.parse(options.arguments), + workingDirectory: ".", + environment: options.environment + ) + case "rust": + throw LanguageRunPlanError.unsupportedCurrentFile(descriptor.displayName) + default: + throw LanguageRunPlanError.unsupportedCurrentFile(descriptor.displayName) + } + } +} + +package struct LanguageRunProviderRegistry: Sendable { + private let providersByID: [String: any LanguageRunProvider] + private let descriptors: [LanguageProviderDescriptor] + + package init(providers: [any LanguageRunProvider]) { + providersByID = Dictionary(uniqueKeysWithValues: providers.map { ($0.descriptor.id, $0) }) + descriptors = providers.map(\.descriptor) + } + + package static func standard(catalog: LanguageProviderCatalog = .compatibilityFallback) -> Self { + Self(providers: catalog.descriptors + .filter { + $0.capabilities.contains(.run) + && $0.id != "java" + && $0.id != "go" + } + .map(StandardLanguageRunProvider.init)) + } + + package func provider(for fileURL: URL) -> (any LanguageRunProvider)? { + guard let descriptor = descriptors.first(where: { $0.handles(fileURL: fileURL) }) else { return nil } + return providersByID[descriptor.id] + } + + package func provider(id: String) -> (any LanguageRunProvider)? { + providersByID[id] + } + + package func launchPlan( + for fileURL: URL, + workspaceURL: URL, + options: RunOptions = RunOptions() + ) throws -> SharedLaunchPlan { + guard let provider = provider(for: fileURL) else { + throw LanguageRunPlanError.noProvider(fileExtension: fileURL.pathExtension.lowercased()) + } + return try provider.launchPlan( + context: LanguageRunContext(workspaceURL: workspaceURL, fileURL: fileURL), + options: options + ) + } +} diff --git a/Sources/LitheCoreContracts/Execution/MavenContracts.swift b/Sources/LitheCoreContracts/Execution/MavenContracts.swift new file mode 100644 index 00000000..b5f0e293 --- /dev/null +++ b/Sources/LitheCoreContracts/Execution/MavenContracts.swift @@ -0,0 +1,151 @@ +import Foundation + +package struct MavenProject: Identifiable, Hashable, Sendable { + package let rootURL: URL + package let pomURL: URL + package let groupID: String? + package let artifactID: String + package let version: String? + package let packaging: String + package let modules: [MavenModule] + package let profiles: [MavenProfile] + package let hasWrapper: Bool + + package init( + rootURL: URL, + pomURL: URL, + groupID: String?, + artifactID: String, + version: String?, + packaging: String, + modules: [MavenModule], + profiles: [MavenProfile], + hasWrapper: Bool + ) { + self.rootURL = rootURL + self.pomURL = pomURL + self.groupID = groupID + self.artifactID = artifactID + self.version = version + self.packaging = packaging + self.modules = modules + self.profiles = profiles + self.hasWrapper = hasWrapper + } + + package var id: String { rootURL.path } + package var displayName: String { artifactID.isEmpty ? rootURL.lastPathComponent : artifactID } + package var isMultiModule: Bool { !modules.isEmpty } + package var allModules: [MavenModule] { modules + modules.flatMap { $0.allModules } } +} + +package struct MavenModule: Identifiable, Hashable, Sendable { + package let relativePath: String + package let url: URL + package let groupID: String? + package let artifactID: String + package let version: String? + package let packaging: String + package let modules: [MavenModule] + + package init( + relativePath: String, + url: URL, + groupID: String?, + artifactID: String, + version: String?, + packaging: String, + modules: [MavenModule] + ) { + self.relativePath = relativePath + self.url = url + self.groupID = groupID + self.artifactID = artifactID + self.version = version + self.packaging = packaging + self.modules = modules + } + + package var id: String { relativePath } + package var displayName: String { artifactID.isEmpty ? relativePath : artifactID } + package var allModules: [MavenModule] { modules + modules.flatMap { $0.allModules } } +} + +package struct MavenProfile: Identifiable, Hashable, Sendable { + package let id: String + package let isActiveByDefault: Bool + + package init(id: String, isActiveByDefault: Bool) { + self.id = id + self.isActiveByDefault = isActiveByDefault + } +} + +package enum MavenLifecyclePhase: String, CaseIterable, Identifiable, Sendable { + case clean, validate, compile, test + case packagePhase = "package" + case verify, install, site, deploy + + package var id: String { rawValue } + package var title: String { rawValue } + package var systemImage: String { + switch self { + case .clean: "trash" + case .validate: "checkmark.seal" + case .compile: "hammer" + case .test: "checkmark.circle" + case .packagePhase: "shippingbox" + case .verify: "checkmark.shield" + case .install: "arrow.down.to.line" + case .site: "globe" + case .deploy: "arrow.up.to.line" + } + } +} + +package enum MavenIssueSeverity: String, Sendable { + case error, warning, info + + package var systemImage: String { + switch self { + case .error: "xmark.octagon.fill" + case .warning: "exclamationmark.triangle.fill" + case .info: "info.circle.fill" + } + } +} + +package struct MavenBuildIssue: Identifiable, Hashable, Sendable { + package let id: String + package let fileURL: URL? + package let line: Int? + package let column: Int? + package let severity: MavenIssueSeverity + package let message: String + + package init(id: String, fileURL: URL?, line: Int?, column: Int?, severity: MavenIssueSeverity, message: String) { + self.id = id + self.fileURL = fileURL + self.line = line + self.column = column + self.severity = severity + self.message = message + } + + package var locationTitle: String { + guard let fileURL else { return "Build output" } + let location = [line, column].compactMap { $0.map(String.init) }.joined(separator: ":") + return location.isEmpty ? fileURL.lastPathComponent : fileURL.lastPathComponent + ":" + location + } +} + +package protocol MavenProjectOperations: Sendable { + func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? + func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] +} + +@MainActor +package protocol MavenRuntimePort: AnyObject { + func mavenExecutable(for project: MavenProject) -> URL? + func mavenProcessEnvironment() -> [String: String] +} diff --git a/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift b/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift new file mode 100644 index 00000000..bcaeb1f0 --- /dev/null +++ b/Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift @@ -0,0 +1,192 @@ +import Foundation + +package enum ProjectRunConfigurationStatus: Equatable, Sendable { + case missing + case ready + case invalid(String) +} + +package enum RunConfigurationRecoveryAction: Equatable, Sendable { + case none + case regenerate + case editConfiguration + case fixPermissions + case upgradeApplication +} + +package struct RunConfigurationDiagnostic: Equatable, Identifiable, Sendable { + package let configurationID: String? + package let code: String + package let message: String + + package init(configurationID: String?, code: String, message: String) { + self.configurationID = configurationID + self.code = code + self.message = message + } + + package var id: String { [configurationID, code, message].compactMap { $0 }.joined(separator: ":") } +} + +package struct ProjectRunConfigurationInspection: Equatable, Sendable { + package let status: ProjectRunConfigurationStatus + package let diagnostics: [RunConfigurationDiagnostic] + package var recoveryAction: RunConfigurationRecoveryAction = .none + package var recoveryPath: String? = nil + + package init( + status: ProjectRunConfigurationStatus, + diagnostics: [RunConfigurationDiagnostic], + recoveryAction: RunConfigurationRecoveryAction = .none, + recoveryPath: String? = nil + ) { + self.status = status + self.diagnostics = diagnostics + self.recoveryAction = recoveryAction + self.recoveryPath = recoveryPath + } +} + +package enum RunConfigurationGenerationState: Equatable, Sendable { + case idle + case succeeded(entryCount: Int) + case noEntries + case failed(String) +} + +package enum RunConfigurationSaveScope: String, CaseIterable, Identifiable, Sendable { + case local + case project + + package var id: String { rawValue } +} + +package enum RunConfigurationSource: String, Sendable { + case generated + case project + case local +} + +package struct EffectiveRunConfiguration: Sendable { + package let configuration: RunConfiguration + package let options: RunOptions + package var source: RunConfigurationSource = .generated + + package init( + configuration: RunConfiguration, + options: RunOptions, + source: RunConfigurationSource = .generated + ) { + self.configuration = configuration + self.options = options + self.source = source + } +} + +package struct RunConfigurationResolution: Sendable { + package let configurations: [EffectiveRunConfiguration] + package let diagnostics: [RunConfigurationDiagnostic] + package let defaultConfigurationID: String? + + package init( + configurations: [EffectiveRunConfiguration], + diagnostics: [RunConfigurationDiagnostic], + defaultConfigurationID: String? + ) { + self.configurations = configurations + self.diagnostics = diagnostics + self.defaultConfigurationID = defaultConfigurationID + } +} + +package struct RunConfigurationOperationFailure: LocalizedError, Sendable { + package let message: String + + package init(message: String) { self.message = message } + + package var errorDescription: String? { message } +} + +package struct RunConfigurationGenerationResult: Sendable { + package let entryCount: Int + package init(entryCount: Int) { self.entryCount = entryCount } +} + +package struct RunConfigurationDraft: Sendable { + package let name: String + package let kind: RunConfigurationKind + package let modulePath: String + package let mainClass: String + package let scope: RunConfigurationSaveScope + + package init( + name: String, + kind: RunConfigurationKind, + modulePath: String, + mainClass: String, + scope: RunConfigurationSaveScope + ) { + self.name = name + self.kind = kind + self.modulePath = modulePath + self.mainClass = mainClass + self.scope = scope + } +} + +package struct RunConfigurationDocumentMutation: Sendable { + package let configurationID: String? + package let document: Data + + package init(configurationID: String?, document: Data) { + self.configurationID = configurationID + self.document = document + } +} + +package protocol RunConfigurationDocumentMutating: Sendable { + func updateOptionsDocument( + at projectURL: URL, + configurationID: String, + scope: RunConfigurationSaveScope, + options: RunOptions + ) throws -> RunConfigurationDocumentMutation + func createConfigurationDocument( + at projectURL: URL, + draft: RunConfigurationDraft + ) throws -> RunConfigurationDocumentMutation +} + +package struct ProjectToolchainSelection: Equatable, Sendable { + package var javaHomePath = "" + package var mavenExecutablePath = "" + package var mavenJavaHomePath = "" +} + +package protocol RunConfigurationOperations: Sendable { + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection + func generate( + at projectURL: URL, + files: [URL], + modulePaths: [String] + ) throws -> RunConfigurationGenerationResult + func resolve( + at projectURL: URL, + toolchainCandidates: [ProjectToolchainCandidate] + ) throws -> RunConfigurationResolution + func launchPlan( + at projectURL: URL, + configurationID: String, + currentFile: String?, + classPath: String?, + debugPort: Int? + ) throws -> SharedLaunchPlan + func saveOptions( + _ options: RunOptions, + configurationID: String, + scope: RunConfigurationSaveScope, + at projectURL: URL + ) throws + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws +} diff --git a/Sources/LitheCoreContracts/Execution/RunModels.swift b/Sources/LitheCoreContracts/Execution/RunModels.swift new file mode 100644 index 00000000..0d764f3b --- /dev/null +++ b/Sources/LitheCoreContracts/Execution/RunModels.swift @@ -0,0 +1,303 @@ +import Foundation + +package struct RunSession: Identifiable, Hashable, Sendable { + package let id: String + package let configurationID: String + package let title: String + package var output: String + package var isRunning: Bool + package var exitCode: Int32? + + package init( + id: String, + configurationID: String, + title: String, + output: String, + isRunning: Bool, + exitCode: Int32? = nil + ) { + self.id = id + self.configurationID = configurationID + self.title = title + self.output = output + self.isRunning = isRunning + self.exitCode = exitCode + } +} + +package struct RunPortConflict: Identifiable, Hashable, Sendable { + package let port: Int + package let configurationNames: [String] + + package init(port: Int, configurationNames: [String]) { + self.port = port + self.configurationNames = configurationNames + } + + package var id: String { String(port) } + + package var title: String { + "Port (port) is used by " + configurationNames.joined(separator: ", ") + } +} + +package struct RunConfigurationCapabilities: OptionSet, Hashable, Sendable { + package let rawValue: Int + package init(rawValue: Int) { self.rawValue = rawValue } + + package static let workingDirectory = Self(rawValue: 1 << 0) + package static let arguments = Self(rawValue: 1 << 1) + package static let environment = Self(rawValue: 1 << 2) + package static let javaRuntime = Self(rawValue: 1 << 3) + package static let javaVMArguments = Self(rawValue: 1 << 4) + package static let mavenProfiles = Self(rawValue: 1 << 5) + package static let jdwpDebug = Self(rawValue: 1 << 6) + + package static let process: Self = [.workingDirectory, .arguments, .environment] +} + +/// A JVM framework launched by a Maven goal rather than by spawning a process. +/// +/// These share Spring Boot's capabilities exactly -- the core assembles the goal +/// and the property names its arguments travel under -- so they are one case +/// carrying the framework rather than three parallel cases. +package enum MavenFrameworkKind: String, Hashable, Sendable, CaseIterable { + case springBoot + case quarkus + case micronaut + + /// The core provider this framework is reported as. + package var provider: String { + switch self { + case .springBoot: "spring-boot.maven" + case .quarkus: "quarkus.maven" + case .micronaut: "micronaut.maven" + } + } + + package var title: String { + switch self { + case .springBoot: "Spring Boot" + case .quarkus: "Quarkus" + case .micronaut: "Micronaut" + } + } + + /// Only Spring Boot's goal accepts a main class; Quarkus and Micronaut + /// resolve it from the build, so naming one would be ignored. + package var namesMainClass: Bool { self == .springBoot } +} + +package enum RunConfigurationKind: Hashable, Identifiable, Sendable { + case currentFile + case javaMain + case mavenModule + /// A JVM framework whose service is started by a Maven goal. + case mavenFramework(MavenFrameworkKind) + /// Any provider this build has no first-class handling for. Carrying the + /// raw provider keeps unknown ecosystems visible and runnable instead of + /// silently dropping them at the decode boundary. + case process(provider: String) + + package static let springBoot: Self = .mavenFramework(.springBoot) + + package init?(rawValue: String) { + switch rawValue { + case "currentFile": self = .currentFile + case "springBoot": self = .springBoot + case "javaMain": self = .javaMain + case "mavenModule": self = .mavenModule + case "quarkus": self = .mavenFramework(.quarkus) + case "micronaut": self = .mavenFramework(.micronaut) + default: return nil + } + } + + package var id: String { + switch self { + case .currentFile: "currentFile" + case .javaMain: "javaMain" + case .mavenModule: "mavenModule" + case .mavenFramework(let framework): framework.rawValue + case .process(let provider): provider + } + } + + package var providerID: String { + switch self { + case .currentFile, .javaMain: "java" + case .mavenModule, .mavenFramework: "maven" + case .process(let provider): provider.split(separator: ".").first.map(String.init) ?? provider + } + } + + /// The framework whose Maven goal starts this configuration, if any. + package var mavenFramework: MavenFrameworkKind? { + if case .mavenFramework(let framework) = self { return framework } + return nil + } + + /// True for the Maven-backed kinds that support JDWP debugging and Maven + /// profiles. Callers should branch on this rather than enumerating cases. + package var isMavenBacked: Bool { + self == .mavenModule || mavenFramework != nil + } + + package var capabilities: RunConfigurationCapabilities { + switch self { + case .currentFile, .javaMain: + return [.workingDirectory, .arguments, .environment, .javaRuntime, .javaVMArguments, .jdwpDebug] + case .mavenModule, .mavenFramework: + return [.workingDirectory, .arguments, .environment, .javaRuntime, .javaVMArguments, .mavenProfiles, .jdwpDebug] + case .process: + return .process + } + } + + package var title: String { + switch self { + case .currentFile: "Current File" + case .javaMain: "Java Application" + case .mavenModule: "Maven Module" + case .mavenFramework(let framework): framework.title + case .process(let provider): Self.displayTitle(for: provider) + } + } + + package var systemImage: String { + switch self { + case .currentFile: "doc.text" + case .javaMain: "cup.and.heat.waves" + case .mavenModule: "shippingbox" + // All three are long-running JVM services started the same way, so they + // share one symbol rather than implying a difference that is not there. + case .mavenFramework: "leaf" + case .process(let provider): Self.symbol(for: provider) + } + } + + /// Providers are `namespace.name`. Falling back to a title-cased namespace + /// means an ecosystem this build has never heard of still reads as a label + /// rather than as a raw identifier. + private static func displayTitle(for provider: String) -> String { + let namespace = provider.split(separator: ".").first.map(String.init) ?? provider + switch namespace { + case "npm": return "Node" + case "compose": return "Docker Compose" + case "python": return "Python" + case "go": return "Go" + case "cargo": return "Rust" + case "make": return "Make" + case "just": return "Just" + case "procfile": return "Procfile" + default: return namespace.capitalized + } + } + + private static func symbol(for provider: String) -> String { + switch provider.split(separator: ".").first.map(String.init) { + case "compose": return "square.stack.3d.up" + case "npm", "python", "go", "cargo": return "chevron.left.forwardslash.chevron.right" + default: return "terminal" + } + } +} + +package enum RunConfigurationExecution: String, CaseIterable, Hashable, Sendable { + case application + case service + case task + case group + + package static let displayOrder: [Self] = [.service, .application, .task, .group] + + package var sectionTitle: String { + switch self { + case .application: "Applications" + case .service: "Services" + case .task: "Tasks" + case .group: "Groups" + } + } +} + +package struct RunConfiguration: Identifiable, Hashable, Sendable { + package static let currentFileID = "current-file" + + package let id: String + package let name: String + package let kind: RunConfigurationKind + package let execution: RunConfigurationExecution + package let modulePath: String? + package let mainClass: String? + + package var usesCurrentEditorFile: Bool { kind == .currentFile } + + package init( + id: String, + name: String, + kind: RunConfigurationKind, + execution: RunConfigurationExecution? = nil, + modulePath: String?, + mainClass: String? + ) { + self.id = id + self.name = name + self.kind = kind + self.execution = execution ?? Self.defaultExecution(for: kind) + self.modulePath = modulePath + self.mainClass = mainClass + } + + package var systemImage: String { kind.systemImage } + + /// Current File is a language-neutral entry. Java keeps its legacy JDK + /// capability, while other Providers expose only the shared process + /// fields in the configuration editor. + package func effectiveCapabilities( + for currentFileURL: URL?, + catalog: LanguageProviderCatalog = .compatibilityFallback + ) -> RunConfigurationCapabilities { + guard kind == .currentFile else { return kind.capabilities } + guard let currentFileURL, + let descriptor = catalog.provider(for: currentFileURL) else { + // An unknown extension is still a language-neutral Current File + // entry. Showing JDK/Maven controls here would make an unsupported + // language look like a Java project and leak provider assumptions + // into the shared editor. + return .process + } + guard descriptor.id == "java" else { + return .process + } + return kind.capabilities + } + + package static var currentFile: RunConfiguration { + RunConfiguration( + id: currentFileID, + name: "Current File", + kind: .currentFile, + execution: .application, + modulePath: nil, + mainClass: nil + ) + } + + private static func defaultExecution( + for kind: RunConfigurationKind + ) -> RunConfigurationExecution { + switch kind { + case .mavenFramework: .service + case .currentFile, .javaMain, .process: .application + case .mavenModule: .task + } + } +} + +// Temporary source compatibility at the Java-debug boundary. These aliases do +// not own behavior; the canonical models above are language neutral. +package typealias JavaRunSession = RunSession +package typealias JavaRunPortConflict = RunPortConflict +package typealias JavaRunConfigurationKind = RunConfigurationKind +package typealias JavaRunConfiguration = RunConfiguration diff --git a/Sources/LitheCoreContracts/Execution/StreamingProcessContracts.swift b/Sources/LitheCoreContracts/Execution/StreamingProcessContracts.swift new file mode 100644 index 00000000..df597f9e --- /dev/null +++ b/Sources/LitheCoreContracts/Execution/StreamingProcessContracts.swift @@ -0,0 +1,70 @@ +import Foundation + +package struct ProcessRequest: Sendable { + package let operationID: String? + package let executablePath: String + package let arguments: [String] + package let workingDirectory: String? + package let environment: [String: String]? + package let standardInput: Data? + package let keepsStandardInputOpen: Bool + package let timeoutMilliseconds: Int? + + package init( + operationID: String? = nil, + executablePath: String, + arguments: [String] = [], + workingDirectory: String? = nil, + environment: [String: String]? = nil, + standardInput: Data? = nil, + keepsStandardInputOpen: Bool = false, + timeoutMilliseconds: Int? = nil + ) { + self.operationID = operationID + self.executablePath = executablePath + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environment = environment + self.standardInput = standardInput + self.keepsStandardInputOpen = keepsStandardInputOpen + self.timeoutMilliseconds = timeoutMilliseconds + } +} + +package enum ProcessLifecycleState: String, Sendable { + case starting + case running + case stopping + case finished + case failed +} + +package struct ProcessLifecycleEvent: Sendable { + package let operationID: String? + package let state: ProcessLifecycleState + package let exitCode: Int32? + package let message: String? + + package init( + operationID: String?, + state: ProcessLifecycleState, + exitCode: Int32?, + message: String? + ) { + self.operationID = operationID + self.state = state + self.exitCode = exitCode + self.message = message + } +} + +package protocol StreamingProcess: AnyObject, Sendable { + var isRunning: Bool { get } + var onOutput: (@Sendable (String) -> Void)? { get set } + var onTermination: (@Sendable (Int32) -> Void)? { get set } + var onStateChange: (@Sendable (ProcessLifecycleEvent) -> Void)? { get set } + + func start(_ request: ProcessRequest) throws + func send(_ input: Data) throws + func stop() +} diff --git a/Sources/LitheCoreContracts/Language/BuiltinLanguageFeatureCore.swift b/Sources/LitheCoreContracts/Language/BuiltinLanguageFeatureCore.swift new file mode 100644 index 00000000..05b5e785 --- /dev/null +++ b/Sources/LitheCoreContracts/Language/BuiltinLanguageFeatureCore.swift @@ -0,0 +1,21 @@ +import Foundation + +package protocol BuiltinLanguageFeatureCore: Sendable { + var isBuiltinLanguageFeatureAvailable: Bool { get } + func builtinLanguageCompletions( + fileURL: URL, + text: String, + position: LanguageServerPosition + ) -> [LanguageServerCompletionItem]? + func builtinLanguageHover( + fileURL: URL, + text: String, + position: LanguageServerPosition + ) -> LanguageServerHover? + func builtinLanguageNavigation( + method: String, + fileURL: URL, + text: String, + position: LanguageServerPosition + ) -> [LanguageServerLocation]? +} diff --git a/Sources/LitheCoreContracts/Language/LanguageExtensionContracts.swift b/Sources/LitheCoreContracts/Language/LanguageExtensionContracts.swift new file mode 100644 index 00000000..053d0ca0 --- /dev/null +++ b/Sources/LitheCoreContracts/Language/LanguageExtensionContracts.swift @@ -0,0 +1,316 @@ +import Foundation +import LitheModuleAPI + +public extension PluginHostServiceID { + static let languageExecution = PluginHostServiceID("dev.lithe.host.language-execution.v1") +} + +public enum LanguageExecutionLifecycleState: String, Sendable { + case starting + case running + case stopping + case finished + case failed +} + +public struct LanguageExecutionLifecycleEvent: Sendable { + public let operationID: String? + public let state: LanguageExecutionLifecycleState + public let exitCode: Int32? + public let message: String? + + public init( + operationID: String?, + state: LanguageExecutionLifecycleState, + exitCode: Int32? = nil, + message: String? = nil + ) { + self.operationID = operationID + self.state = state + self.exitCode = exitCode + self.message = message + } +} + +public struct LanguageExecutionProcessRequest: Sendable { + public let operationID: String? + public let executablePath: String + public let arguments: [String] + public let workingDirectory: String? + public let environment: [String: String]? + + public init( + operationID: String? = nil, + executablePath: String, + arguments: [String] = [], + workingDirectory: String? = nil, + environment: [String: String]? = nil + ) { + self.operationID = operationID + self.executablePath = executablePath + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environment = environment + } +} + +@MainActor +public protocol LanguageExecutionSession: AnyObject { + var isRunning: Bool { get } + var onOutput: (@Sendable (String) -> Void)? { get set } + var onTermination: (@Sendable (Int32) -> Void)? { get set } + var onStateChange: (@Sendable (LanguageExecutionLifecycleEvent) -> Void)? { get set } + + func start(_ request: LanguageExecutionProcessRequest) throws + func stop() + func stopAndWait() async -> Bool +} + +public extension LanguageExecutionSession { + func stopAndWait() async -> Bool { + stop() + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while isRunning, clock.now < deadline { + try? await Task.sleep(for: .milliseconds(25)) + } + return !isRunning + } +} + +@MainActor +public protocol LanguageExecutionHostProviding: AnyObject { + func makeSession(ownerModuleID: ModuleID) -> any LanguageExecutionSession +} + +public struct LanguageServerExtensionConfiguration: Equatable, Sendable { + public let languageID: String + public let displayName: String + public let executableNames: [String] + public let arguments: [String] + public let validationArguments: [String] + public let environment: [String: String] + public let languageIdentifier: String + + public init( + languageID: String, + displayName: String, + executableNames: [String], + arguments: [String] = [], + validationArguments: [String] = [], + environment: [String: String] = [:], + languageIdentifier: String + ) { + self.languageID = languageID + self.displayName = displayName + self.executableNames = executableNames + self.arguments = arguments + self.validationArguments = validationArguments + self.environment = environment + self.languageIdentifier = languageIdentifier + } +} + +@MainActor +public protocol LanguageServerExtensionProviding: AnyObject { + var configuration: LanguageServerExtensionConfiguration { get } + var lifecycle: any LanguageServerExtensionLifecycle { get } +} + +@MainActor +public protocol LanguageServerExtensionLifecycle: AnyObject { + var isRunning: Bool { get } + func attach( + isRunning: @escaping @MainActor () -> Bool, + stop: @escaping @MainActor () -> Void + ) + func stop() + func waitUntilStopped() async +} + +public extension LanguageServerExtensionLifecycle { + func waitUntilStopped() async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(5)) + while isRunning, clock.now < deadline { + try? await Task.sleep(for: .milliseconds(25)) + } + } +} + +public struct LanguageRunExtensionRequest: Equatable, Sendable { + public let relativeFilePath: String + public let arguments: [String] + public let environment: [String: String] + + public init( + relativeFilePath: String, + arguments: [String] = [], + environment: [String: String] = [:] + ) { + self.relativeFilePath = relativeFilePath + self.arguments = arguments + self.environment = environment + } +} + +public enum LanguageRunExtensionExecutable: Equatable, Sendable { + case toolchain(String) + case command(String) +} + +public struct LanguageRunExtensionPlan: Equatable, Sendable { + public let executable: LanguageRunExtensionExecutable + public let arguments: [String] + public let workingDirectory: String + public let environment: [String: String] + + public init( + executable: LanguageRunExtensionExecutable, + arguments: [String], + workingDirectory: String = ".", + environment: [String: String] = [:] + ) { + self.executable = executable + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environment = environment + } +} + +@MainActor +public protocol LanguageRunExtensionProviding: AnyObject { + var languageID: String { get } + func makeExecutionSession() -> any LanguageExecutionSession + func launchPlan(for request: LanguageRunExtensionRequest) throws -> LanguageRunExtensionPlan +} + +public enum LanguageTestExtensionItemKind: String, Equatable, Sendable { + case workspace + case file + case testCase +} + +public struct LanguageTestExtensionItem: Equatable, Sendable { + public let id: String + public let label: String + public let kind: LanguageTestExtensionItemKind + public let relativeFilePath: String? + + public init( + id: String, + label: String, + kind: LanguageTestExtensionItemKind, + relativeFilePath: String? = nil + ) { + self.id = id + self.label = label + self.kind = kind + self.relativeFilePath = relativeFilePath + } +} + +public struct LanguageTestExtensionDiscoveryRequest: Equatable, Sendable { + public let relativeProjectFilePaths: [String] + + public init(relativeProjectFilePaths: [String]) { + self.relativeProjectFilePaths = relativeProjectFilePaths + } +} + +public enum LanguageTestExtensionScope: Equatable, Sendable { + case workspace + case file(relativePath: String) + case testCase(identifier: String, relativeFilePath: String?) +} + +public struct LanguageTestExtensionRequest: Equatable, Sendable { + public let scope: LanguageTestExtensionScope + public let relativeProjectFilePaths: [String] + + public init( + scope: LanguageTestExtensionScope, + relativeProjectFilePaths: [String] + ) { + self.scope = scope + self.relativeProjectFilePaths = relativeProjectFilePaths + } +} + +public struct LanguageTestExtensionPlan: Equatable, Sendable { + public let label: String + public let frameworkID: String? + public let launchPlan: LanguageRunExtensionPlan + + public init( + label: String, + frameworkID: String? = nil, + launchPlan: LanguageRunExtensionPlan + ) { + self.label = label + self.frameworkID = frameworkID + self.launchPlan = launchPlan + } +} + +@MainActor +public protocol LanguageTestExtensionProviding: AnyObject { + var languageID: String { get } + func makeTestExecutionSession() -> any LanguageExecutionSession + func discoverTests( + for request: LanguageTestExtensionDiscoveryRequest + ) throws -> [LanguageTestExtensionItem] + func testPlan( + for request: LanguageTestExtensionRequest + ) throws -> LanguageTestExtensionPlan +} + +public enum LanguageExtensionHostError: Error, Equatable, LocalizedError, Sendable { + case missingExecutionHost(languageID: String) + + public var errorDescription: String? { + switch self { + case .missingExecutionHost(let languageID): + "The host cannot provide an execution session for \(languageID)." + } + } +} + +public enum LanguageExtensionRegistrationError: Error, Equatable, LocalizedError, Sendable { + case invalidLanguageServerProvider(String) + + public var errorDescription: String? { + switch self { + case .invalidLanguageServerProvider(let displayName): + "\(displayName) returned an invalid language-server provider." + } + } +} + +public enum LanguageRunExtensionError: Error, Equatable, LocalizedError, Sendable { + case invalidRelativePath + + public var errorDescription: String? { + switch self { + case .invalidRelativePath: + "The selected file must be inside the current workspace." + } + } +} + +public enum LanguageTestExtensionError: Error, Equatable, LocalizedError, Sendable { + case invalidRelativePath + case invalidTestIdentifier + case unsupportedProject(languageID: String) + + public var errorDescription: String? { + switch self { + case .invalidRelativePath: + "A test path must stay inside the current workspace." + case .invalidTestIdentifier: + "The selected test identifier is invalid." + case .unsupportedProject(let languageID): + "The workspace is not a supported \(languageID) test project." + } + } +} diff --git a/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift b/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift new file mode 100644 index 00000000..7b38d3b9 --- /dev/null +++ b/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift @@ -0,0 +1,142 @@ +import Foundation +import LitheModuleAPI + +package struct LanguageServerRuntimeFailure: Error, Equatable, Sendable { + package let code: String + package let message: String + package let details: String? + + package init(code: String, message: String, details: String? = nil) { + self.code = code + self.message = message + self.details = details + } + + package var userMessage: String { + guard let details, !details.isEmpty else { return message } + return message + ": " + details + } +} + +package struct LanguageServerRuntimeStart: Equatable, Sendable { + package let sessionID: String + package let state: String + package let processID: Int32? + + package init(sessionID: String, state: String, processID: Int32?) { + self.sessionID = sessionID + self.state = state + self.processID = processID + } +} + +package struct LanguageServerRuntimeOperation: Equatable, Sendable { + package let operationID: String + + package init(operationID: String) { + self.operationID = operationID + } +} + +package struct LanguageServerRuntimeError: Equatable, Sendable { + package let message: String + package let underlyingMessage: String? + package let processExitCode: Int? + + package init(message: String, underlyingMessage: String?, processExitCode: Int?) { + self.message = message + self.underlyingMessage = underlyingMessage + self.processExitCode = processExitCode + } +} + +package struct LanguageServerRuntimeEvent: Equatable, Sendable { + package let type: String + package let state: String? + package let operationID: String? + package let uri: String? + package let diagnostics: [LanguageServerDiagnostic]? + package let result: ToolingJSONValue? + package let error: LanguageServerRuntimeError? + package let capabilities: [String]? + package let serverInfo: LanguageServerInfo? + package let level: String? + package let message: String? + package let detail: String? + + package init( + type: String, + state: String? = nil, + operationID: String? = nil, + uri: String? = nil, + diagnostics: [LanguageServerDiagnostic]? = nil, + result: ToolingJSONValue? = nil, + error: LanguageServerRuntimeError? = nil, + capabilities: [String]? = nil, + serverInfo: LanguageServerInfo? = nil, + level: String? = nil, + message: String? = nil, + detail: String? = nil + ) { + self.type = type + self.state = state + self.operationID = operationID + self.uri = uri + self.diagnostics = diagnostics + self.result = result + self.error = error + self.capabilities = capabilities + self.serverInfo = serverInfo + self.level = level + self.message = message + self.detail = detail + } +} + +package protocol LanguageServerRuntimeCore: Sendable { + func startLanguageServer( + providerID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + rootURL: URL, + workingDirectoryURL: URL, + initializationOptions: ToolingJSONValue?, + runtimeExecutableURL: URL?, + cacheDirectoryURL: URL?, + initializeTimeout: TimeInterval, + requestTimeout: TimeInterval, + shutdownTimeout: TimeInterval + ) -> Result + + func stopLanguageServer(sessionID: String) + func syncLanguageServerDocument( + sessionID: String, + fileURL: URL, + languageID: String, + text: String + ) -> Result + func closeLanguageServerDocument(sessionID: String, fileURL: URL) + func requestLanguageServerOperation( + sessionID: String, + operation: LanguageServerOperation, + fileURL: URL?, + virtualURI: String?, + position: LanguageServerPosition?, + newName: String?, + range: LanguageServerRange?, + diagnostics: [LanguageServerDiagnostic], + completionItem: LanguageServerCompletionItem?, + codeAction: LanguageServerCodeAction?, + command: LanguageServerCommand? + ) -> Result + func cancelLanguageServerOperation(sessionID: String, operationID: String) + func pollLanguageServerEvents(sessionID: String) -> [LanguageServerRuntimeEvent] + func destroyLanguageServer(sessionID: String) +} + +@MainActor +package protocol LanguageServerProcessRegistry: AnyObject { + func registerLanguageServerProcess(pid: Int32, moduleID: ModuleID) + func unregisterLanguageServerProcess(pid: Int32, moduleID: ModuleID) +} diff --git a/Sources/LitheCoreContracts/Language/LanguageToolRuntimePort.swift b/Sources/LitheCoreContracts/Language/LanguageToolRuntimePort.swift new file mode 100644 index 00000000..e8899a8c --- /dev/null +++ b/Sources/LitheCoreContracts/Language/LanguageToolRuntimePort.swift @@ -0,0 +1,10 @@ +import Foundation + +@MainActor +package protocol LanguageToolRuntimePort: AnyObject { + func executableOnPath(_ name: String) -> URL? + func executableURL(at path: String) -> URL? + func executableCandidates(_ command: String) -> [RuntimeToolCandidate] + func languageToolProcessEnvironment() -> [String: String] + func missingLanguageToolMessage(_ name: String) -> String +} diff --git a/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift b/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift new file mode 100644 index 00000000..094dd47c --- /dev/null +++ b/Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift @@ -0,0 +1,73 @@ +import Foundation + +package enum RuntimeToolSource: String, Codable, Hashable, Sendable { + case project + case environment + case path + case homebrew + case xcode + case system + case custom + + package var displayName: String { + switch self { + case .project: "Project" + case .environment: "Environment" + case .path: "PATH" + case .homebrew: "Homebrew" + case .xcode: "Xcode Command Line Tools" + case .system: "System" + case .custom: "Custom" + } + } +} + +package struct RuntimeToolCandidate: Identifiable, Equatable, Sendable { + package let command: String + package let executableURL: URL + package let source: RuntimeToolSource + package let detail: String? + + package var id: String { + command + "\u{1F}" + executableURL.standardizedFileURL.path + } + + package init( + command: String, + executableURL: URL, + source: RuntimeToolSource, + detail: String? = nil + ) { + self.command = command + self.executableURL = executableURL.standardizedFileURL + self.source = source + self.detail = detail + } +} + +package struct LanguageToolCommandResult: Equatable, Sendable { + package let output: String + package let exitCode: Int32 + + package init(output: String, exitCode: Int32) { + self.output = output + self.exitCode = exitCode + } + + package var succeeded: Bool { exitCode == 0 } +} + +package protocol LanguageToolCommandRunning: Sendable { + func runLanguageToolCommand( + operationID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + timeoutMilliseconds: Int + ) -> LanguageToolCommandResult +} + +package protocol LanguageToolSettingsStoring: AnyObject { + func loadLanguageToolExecutablePaths() -> [String: String] + func saveLanguageToolExecutablePaths(_ paths: [String: String]) +} diff --git a/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift b/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift new file mode 100644 index 00000000..2f83ca26 --- /dev/null +++ b/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift @@ -0,0 +1,609 @@ +import Foundation +import LitheModuleAPI + +package struct LanguageToolingCapability: OptionSet, Hashable, Sendable { + package let rawValue: Int + package init(rawValue: Int) { self.rawValue = rawValue } + + package static let run = Self(rawValue: 1 << 0) + package static let languageServer = Self(rawValue: 1 << 1) + package static let debugAdapter = Self(rawValue: 1 << 2) + package static let formatting = Self(rawValue: 1 << 3) + package static let testing = Self(rawValue: 1 << 4) + + package 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 + } + } + + package static func names(_ names: [String]) -> Self { + names.reduce(into: Self()) { capabilities, name in + if let capability = Self.named(name) { + capabilities.insert(capability) + } + } + } +} + +package struct LanguageServerFeatureSet: OptionSet, Hashable, Sendable { + package let rawValue: Int + package init(rawValue: Int) { self.rawValue = rawValue } + + package static let definition = Self(rawValue: 1 << 0) + package static let references = Self(rawValue: 1 << 1) + package static let implementation = Self(rawValue: 1 << 2) + package static let hover = Self(rawValue: 1 << 3) + package static let completion = Self(rawValue: 1 << 4) + package static let rename = Self(rawValue: 1 << 5) + package static let formatting = Self(rawValue: 1 << 6) + package static let codeActions = Self(rawValue: 1 << 7) + package static let completionResolve = Self(rawValue: 1 << 8) + package static let codeActionResolve = Self(rawValue: 1 << 9) + package static let executeCommand = Self(rawValue: 1 << 10) + + package static let standardEditing: Self = [ + .definition, .references, .implementation, .hover, .completion, + .rename, .formatting, .codeActions, .completionResolve, + .codeActionResolve, .executeCommand + ] +} + +package enum ToolingActivationPolicy: String, Codable, Hashable, Sendable { + case onDemand + case always +} + +package struct LanguageServerLaunchDescriptor: Hashable, Sendable { + package let executableNames: [String] + package let arguments: [String] + package let validationArguments: [String] + package let environment: [String: String] + package let initializationOptions: ToolingJSONValue? + + package 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 + } +} + +package struct LanguageServerInstallationDescriptor: Hashable, Sendable { + package let homebrewFormula: String? + package let officialDownloadURL: URL? + + package init(homebrewFormula: String?, officialDownloadURL: URL?) { + self.homebrewFormula = homebrewFormula + self.officialDownloadURL = officialDownloadURL + } +} + +package struct LanguageProviderDescriptor: Identifiable, Hashable, Sendable { + package let id: String + package let displayName: String + package let fileExtensions: Set + package let fileNames: Set + package let fileNamePrefixes: Set + package let capabilities: LanguageToolingCapability + package let activationPolicy: ToolingActivationPolicy + package let languageIdentifier: String? + package let languageIdentifiersByExtension: [String: String] + package let languageIdentifiersByFileName: [String: String] + package let languageServerLaunch: LanguageServerLaunchDescriptor? + package let languageServerInstallation: LanguageServerInstallationDescriptor? + + package 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 + } + + package func handles(fileURL: URL) -> Bool { + let fileName = fileURL.lastPathComponent.lowercased() + return fileExtensions.contains(fileURL.pathExtension.lowercased()) + || fileNames.contains(fileName) + || fileNamePrefixes.contains { fileName.hasPrefix($0) } + } + + package func languageIdentifier(for fileURL: URL) -> String { + let extensionName = fileURL.pathExtension.lowercased() + let fileName = fileURL.lastPathComponent.lowercased() + return languageIdentifiersByFileName[fileName] + ?? languageIdentifiersByExtension[extensionName] + ?? languageIdentifier + ?? id + } +} + +package struct LanguageProviderCatalog: Sendable { + package let descriptors: [LanguageProviderDescriptor] + + package init(descriptors: [LanguageProviderDescriptor]) { + self.descriptors = 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. + package static let compatibilityFallback = LanguageProviderCatalog(descriptors: [ + LanguageProviderDescriptor( + id: "java", displayName: "Java", fileExtensions: ["java"], + capabilities: [.run, .languageServer, .formatting, .testing], + activationPolicy: .onDemand + ), + LanguageProviderDescriptor( + id: "go", displayName: "Go", fileExtensions: ["go"], + capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing], + activationPolicy: .onDemand + ), + LanguageProviderDescriptor( + id: "python", displayName: "Python", fileExtensions: ["py", "pyw"], + capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing], + activationPolicy: .onDemand + ), + LanguageProviderDescriptor( + id: "node", displayName: "Node.js", fileExtensions: ["js", "jsx", "ts", "tsx", "mjs", "cjs"], + capabilities: [.run, .languageServer, .debugAdapter, .formatting, .testing], + 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 + ), + ]) + + package func provider(for fileURL: URL) -> LanguageProviderDescriptor? { + descriptors.first { $0.handles(fileURL: fileURL) } + } +} + +package extension LanguageProviderCatalog { + var debugProviders: [DebugProviderDescriptor] { + descriptors.compactMap { descriptor in + guard descriptor.capabilities.contains(.debugAdapter) else { return nil } + return DebugProviderDescriptor( + id: descriptor.id, + displayName: descriptor.displayName, + fileExtensions: descriptor.fileExtensions, + fileNames: descriptor.fileNames, + fileNamePrefixes: descriptor.fileNamePrefixes + ) + } + } +} + +package struct LanguageServerPosition: Equatable, Sendable { + package let line: Int + package let utf16Column: Int + + package init(line: Int, utf16Column: Int) { + self.line = line + self.utf16Column = utf16Column + } +} + +package struct LanguageServerRange: Equatable, Sendable { + package let start: LanguageServerPosition + package let end: LanguageServerPosition + + package init(start: LanguageServerPosition, end: LanguageServerPosition) { + self.start = start + self.end = end + } +} + +package struct LanguageServerDiagnosticRelatedInformation: Equatable, Sendable { + package let fileURL: URL + package let range: LanguageServerRange + package let message: String + + package init(fileURL: URL, range: LanguageServerRange, message: String) { + self.fileURL = fileURL + self.range = range + self.message = message + } +} + +package struct LanguageServerDiagnostic: Equatable, Sendable { + package let range: LanguageServerRange + package let severity: Int? + package let message: String + package let source: String? + package let code: String? + package let tags: [Int] + package let relatedInformation: [LanguageServerDiagnosticRelatedInformation] + + package 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 + } +} + +package struct LanguageServerLocation: Equatable, Sendable { + package let url: URL + package let range: LanguageServerRange + package let isReadOnly: Bool + package let displayPath: String? + + package init( + url: URL, + range: LanguageServerRange, + isReadOnly: Bool = false, + displayPath: String? = nil + ) { + self.url = url + self.range = range + self.isReadOnly = isReadOnly + self.displayPath = displayPath + } +} + +package struct LanguageServerHover: Equatable, Sendable { + package let contents: String + package let isMarkdown: Bool + package let range: LanguageServerRange? + + package init(contents: String, isMarkdown: Bool, range: LanguageServerRange?) { + self.contents = contents + self.isMarkdown = isMarkdown + self.range = range + } +} + +package struct LanguageServerCompletionItem: Identifiable, Equatable, Sendable { + package let label: String + package let detail: String? + package let documentation: String? + package let insertText: String + package let sortText: String? + package let filterText: String? + package let kind: Int? + package let textEdit: LanguageServerTextEdit? + package let additionalTextEdits: [LanguageServerTextEdit] + package let data: ToolingJSONValue? + + package init( + label: String, + detail: String?, + documentation: String?, + insertText: String, + sortText: String?, + filterText: String?, + kind: Int?, + textEdit: LanguageServerTextEdit?, + additionalTextEdits: [LanguageServerTextEdit], + data: ToolingJSONValue? + ) { + self.label = label + self.detail = detail + self.documentation = documentation + self.insertText = insertText + self.sortText = sortText + self.filterText = filterText + self.kind = kind + self.textEdit = textEdit + self.additionalTextEdits = additionalTextEdits + self.data = data + } + + package var id: String { + [label, detail ?? "", insertText, sortText ?? ""].joined(separator: "\u{1F}") + } +} + +package struct LanguageServerCommand: Equatable, Sendable { + package let title: String + package let command: String + package let arguments: [ToolingJSONValue] + + package init(title: String, command: String, arguments: [ToolingJSONValue]) { + self.title = title + self.command = command + self.arguments = arguments + } +} + +package enum LanguageServerLogLevel: String, Sendable { + case info + case warning + case error +} + +/// What the editor wants from a language server, named by intent rather than by +/// the LSP method that satisfies it. The core maps these to methods and owns the +/// request IDs, so the UI never names a protocol method or reads a raw response. +package enum LanguageServerOperation: String, Equatable, Sendable { + case completion + case hover + case definition + case declaration + case typeDefinition + case references + case implementation + case rename + case formatting + case codeActions + case resolveCompletion + case resolveCodeAction + case executeCommand + case inlayHints + case foldingRanges + case codeLens + /// Resolving a server-owned source that has no file on disk, such as a + /// decompiled class behind a `jdt://` URI. + case virtualDocument +} + +package enum LanguageServerSessionState: Equatable, Sendable { + case startingProcess + case initializing + case ready + case stopping + case stopped + case failed(exitCode: Int32?, message: String?) +} + +package struct LanguageServerInfo: Equatable, Sendable { + package let name: String + package let version: String? + + package init(name: String, version: String?) { + self.name = name + self.version = version + } +} + +package struct LanguageServerLogEntry: Identifiable, Equatable, Sendable { + package let id: UUID + package let timestamp: Date + package let providerID: String + package let level: LanguageServerLogLevel + package let message: String + package let detail: String? + + package 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 + } +} + +package struct LanguageServerTextEdit: Equatable, Sendable { + package let range: LanguageServerRange + package let newText: String + + package init(range: LanguageServerRange, newText: String) { + self.range = range + self.newText = newText + } +} + +package struct LanguageServerWorkspaceEdit: Equatable, Sendable { + package let changes: [URL: [LanguageServerTextEdit]] + + package init(changes: [URL: [LanguageServerTextEdit]] = [:]) { + self.changes = changes + } +} + +package struct LanguageServerCodeAction: Identifiable, Equatable, Sendable { + package let title: String + package let kind: String? + package let isPreferred: Bool + package let edit: LanguageServerWorkspaceEdit? + package let command: LanguageServerCommand? + package let data: ToolingJSONValue? + + package init( + title: String, + kind: String?, + isPreferred: Bool, + edit: LanguageServerWorkspaceEdit?, + command: LanguageServerCommand?, + data: ToolingJSONValue? + ) { + self.title = title + self.kind = kind + self.isPreferred = isPreferred + self.edit = edit + self.command = command + self.data = data + } + + package var id: String { [title, kind ?? ""].joined(separator: "\u{1F}") } +} + +@MainActor +package protocol LanguageServerSession: AnyObject { + var isRunning: Bool { get } + var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? { get set } + 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<[LanguageServerCompletionItem], Error>) -> Void + ) throws + func hover( + fileURL: URL, + position: LanguageServerPosition, + completion: @escaping (Result) -> Void + ) throws + func navigate( + method: String, + fileURL: URL, + position: LanguageServerPosition, + completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void + ) throws + func rename( + fileURL: URL, + position: LanguageServerPosition, + newName: String, + completion: @escaping (Result) -> Void + ) throws + func format( + fileURL: URL, + completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void + ) throws + func codeActions( + fileURL: URL, + range: LanguageServerRange, + diagnostics: [LanguageServerDiagnostic], + completion: @escaping (Result<[LanguageServerCodeAction], Error>) -> 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 resolveVirtualDocument( + uri: String, + completion: @escaping (Result) -> Void + ) throws + func stop() +} + +package 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 +package protocol LanguageProviderRuntime: AnyObject { + var descriptor: LanguageProviderDescriptor { get } + var supportsLanguageServerSession: Bool { get } + var unavailableToolingMessage: String? { get } + func makeLanguageServerSession() -> (any LanguageServerSession)? +} + +@MainActor +package protocol LanguageProviderRuntimeFactory: AnyObject { + func makeRuntime(for descriptor: LanguageProviderDescriptor) -> (any LanguageProviderRuntime)? + func makeRuntime( + for descriptor: LanguageProviderDescriptor, + languageServerLaunch: LanguageServerLaunchDescriptor, + ownerModuleID: ModuleID + ) -> (any LanguageProviderRuntime)? +} + +package extension LanguageProviderRuntimeFactory { + func makeRuntime( + for descriptor: LanguageProviderDescriptor, + languageServerLaunch: LanguageServerLaunchDescriptor, + ownerModuleID: ModuleID + ) -> (any LanguageProviderRuntime)? { + makeRuntime(for: descriptor) + } +} + +package extension LanguageProviderRuntime { + var supportsLanguageServerSession: Bool { false } + var unavailableToolingMessage: String? { nil } + func makeLanguageServerSession() -> (any LanguageServerSession)? { nil } +} diff --git a/Sources/LitheCoreContracts/Language/ToolingJSONValue.swift b/Sources/LitheCoreContracts/Language/ToolingJSONValue.swift new file mode 100644 index 00000000..e49535e7 --- /dev/null +++ b/Sources/LitheCoreContracts/Language/ToolingJSONValue.swift @@ -0,0 +1,73 @@ +import Foundation + +public enum ToolingJSONValue: Codable, Equatable, Hashable, Sendable { + case string(String) + case integer(Int) + case number(Double) + case bool(Bool) + case object([String: ToolingJSONValue]) + case array([ToolingJSONValue]) + case null + + public var foundationObject: Any { + switch self { + case .string(let value): value + case .integer(let value): value + case .number(let value): value + case .bool(let value): value + case .object(let value): value.mapValues(\.foundationObject) + case .array(let value): value.map(\.foundationObject) + case .null: NSNull() + } + } + + public static func fromFoundation(_ value: Any) -> ToolingJSONValue? { + if value is NSNull { return .null } + if let value = value as? String { return .string(value) } + if let number = value as? NSNumber { + if CFGetTypeID(number) == CFBooleanGetTypeID() { return .bool(number.boolValue) } + let double = number.doubleValue + if double.rounded() == double, double >= Double(Int.min), double <= Double(Int.max) { + return .integer(number.intValue) + } + return .number(double) + } + if let values = value as? [Any] { return .array(values.compactMap(fromFoundation)) } + if let object = value as? [String: Any] { + return .object(object.compactMapValues(fromFoundation)) + } + return nil + } + + public 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)) + } + } + + public 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() + } + } +} diff --git a/Sources/LitheCoreContracts/Modules/FeatureModuleHandle.swift b/Sources/LitheCoreContracts/Modules/FeatureModuleHandle.swift new file mode 100644 index 00000000..7f58e5a9 --- /dev/null +++ b/Sources/LitheCoreContracts/Modules/FeatureModuleHandle.swift @@ -0,0 +1,133 @@ +import Foundation +import LitheModuleAPI + +/// Type-erased ownership boundary between a feature target and the app-specific +/// workflow object it hosts. The module target owns lifecycle and capability +/// publication; the composition root supplies the platform-independent feature +/// object and its narrowly scoped lifecycle callbacks. +@MainActor +public final class FeatureModuleHandle: @unchecked Sendable { + public let value: AnyObject + private let configureAction: @MainActor (ModuleContext) -> Void + private let prepareAction: @MainActor () async throws -> Void + private let stopAction: @MainActor () async -> Void + private let resourceKind: String? + private let resourceActive: @MainActor () -> Bool + private var resourceID: UUID? + private weak var resourceManager: (any ModuleResourceManaging)? + + public init( + value: AnyObject, + configure: @escaping @MainActor (ModuleContext) -> Void = { _ in }, + prepareForSleep: @escaping @MainActor () async throws -> Void = {}, + resourceKind: String? = nil, + isResourceActive: @escaping @MainActor () -> Bool = { false }, + stop: @escaping @MainActor () async -> Void + ) { + self.value = value + configureAction = configure + prepareAction = prepareForSleep + self.resourceKind = resourceKind + resourceActive = isResourceActive + stopAction = stop + } + + public func configure(context: ModuleContext) { + configureAction(context) + if let resourceKind { + resourceManager = context.resources + resourceID = context.resources.register( + HostedFeatureResource(kind: resourceKind, isActive: resourceActive, stop: stopAction) + ) + } + } + + public func prepareForSleep() async throws { + try await prepareAction() + } + + public func stop() async { + await stopAction() + if let resourceID { + resourceManager?.unregisterResource(id: resourceID) + } + resourceID = nil + resourceManager = nil + } +} + +@MainActor +private final class HostedFeatureResource: ModuleResource { + let moduleResourceKind: String + private let activeAction: @MainActor () -> Bool + private let stopAction: @MainActor () async -> Void + + init( + kind: String, + isActive: @escaping @MainActor () -> Bool, + stop: @escaping @MainActor () async -> Void + ) { + moduleResourceKind = kind + activeAction = isActive + stopAction = stop + } + + var isModuleResourceActive: Bool { activeAction() } + func stopModuleResource() async { await stopAction() } +} + +/// Reusable lifecycle implementation for modules whose concrete feature graph +/// is supplied by the platform composition root through a `FeatureModuleHandle`. +/// Each feature target still declares its own manifest and capability type. +@MainActor +open class HostedFeatureModule: LitheModule { + public let manifest: ModuleManifest + private let capabilityID: ModuleCapabilityID + private let makeHandle: @MainActor () -> FeatureModuleHandle + private let makeCapability: @MainActor (FeatureModuleHandle) -> AnyObject + private var handle: FeatureModuleHandle? + private var capability: AnyObject? + + public init( + manifest: ModuleManifest, + capabilityID: ModuleCapabilityID, + makeHandle: @escaping @MainActor () -> FeatureModuleHandle, + makeCapability: @escaping @MainActor (FeatureModuleHandle) -> AnyObject + ) { + self.manifest = manifest + self.capabilityID = capabilityID + self.makeHandle = makeHandle + self.makeCapability = makeCapability + } + + open func activate(context: ModuleContext) async throws { + guard handle == nil else { return } + let value = makeHandle() + value.configure(context: context) + handle = value + capability = makeCapability(value) + } + + open func prepareForSleep() async throws { + try await handle?.prepareForSleep() + } + + open func sleep() async { + await handle?.stop() + capability = nil + handle = nil + } + + open func shutdown() async { + await handle?.stop() + capability = nil + handle = nil + } + + open func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [capabilityID: capability] + } + + open func contributions() -> [ModuleContribution] { [] } +} diff --git a/Sources/Lithe/Services/OutputTimestamper.swift b/Sources/LitheCoreContracts/Output/OutputTimestamper.swift similarity index 90% rename from Sources/Lithe/Services/OutputTimestamper.swift rename to Sources/LitheCoreContracts/Output/OutputTimestamper.swift index c09bee12..0ed46306 100644 --- a/Sources/Lithe/Services/OutputTimestamper.swift +++ b/Sources/LitheCoreContracts/Output/OutputTimestamper.swift @@ -6,7 +6,7 @@ import Foundation /// all, which makes "when did this stall" unanswerable from the log alone. /// Spring Boot already prints its own timestamp, so those lines are left /// untouched rather than carrying two clocks. -enum OutputTimestamper { +package enum OutputTimestamper { private static let formatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -23,7 +23,7 @@ enum OutputTimestamper { /// - Parameter continuingLine: true when the previous chunk ended mid-line, /// so this chunk's first line is a continuation and must not be stamped. - static func stamped(_ value: String, continuingLine: Bool, now: Date = Date()) -> String { + package static func stamped(_ value: String, continuingLine: Bool, now: Date = Date()) -> String { guard !value.isEmpty else { return value } let stamp = formatter.string(from: now) + " " var result = "" @@ -42,14 +42,14 @@ enum OutputTimestamper { return result } - static func hasLeadingTime(_ line: String) -> Bool { + package static func hasLeadingTime(_ line: String) -> Bool { leadingTimeLength(of: line) != nil } /// Length of the clock at the start of the line, in characters, or nil when /// the line does not begin with one. Callers use it to style the stamp /// separately from the message. - static func leadingTimeLength(of line: String) -> Int? { + package static func leadingTimeLength(of line: String) -> Int? { let range = NSRange(line.startIndex..() + let uniqueRoots = logicalRoots + .filter { seen.insert($0.path).inserted } + .sorted { + if $0.path.count == $1.path.count { return $0.path < $1.path } + return $0.path.count < $1.path.count + } + return uniqueRoots.filter { candidate in + !uniqueRoots.contains { root in + root.path != candidate.path && Self.contains(root, candidate) + } + } + } + + package func containsWorkspacePath(_ url: URL) -> Bool { + Self.contains(workspaceRoot, Self.normalize(url)) + } + + package func containsRepositoryPath(_ url: URL) -> Bool { + guard let repositoryRoot else { return false } + return Self.contains(repositoryRoot, Self.normalize(url)) + } + + package func containsGitMetadataPath(_ url: URL) -> Bool { + let normalized = Self.normalize(url) + return [gitDirectory, gitCommonDirectory] + .compactMap { $0 } + .contains { Self.contains($0, normalized) } + } + + package func isGitContextPointer(_ url: URL) -> Bool { + let normalized = Self.normalize(url) + let candidates = [workspaceRoot, repositoryRoot] + .compactMap { $0 } + .map { $0.appendingPathComponent(".git").standardizedFileURL.path } + return candidates.contains(normalized.path) + } + + package func isLogicalRoot(_ url: URL) -> Bool { + let path = Self.normalize(url).path + return [workspaceRoot, repositoryRoot, gitDirectory, gitCommonDirectory] + .compactMap { $0 } + .contains { $0.path == path } + } + + private static func normalize(_ url: URL) -> URL { + url.standardizedFileURL.resolvingSymlinksInPath() + } + + private static func contains(_ parent: URL, _ child: URL) -> Bool { + child.path == parent.path || child.path.hasPrefix(parent.path + "/") + } +} + +package struct DirectoryChangeBatch: Equatable, Sendable { + package var workspacePaths: [String] + package var gitStateMayHaveChanged: Bool + package var requiresFullRescan: Bool + package var watchRootsChanged: Bool + + package init( + workspacePaths: [String] = [], + gitStateMayHaveChanged: Bool = false, + requiresFullRescan: Bool = false, + watchRootsChanged: Bool = false + ) { + self.workspacePaths = workspacePaths + self.gitStateMayHaveChanged = gitStateMayHaveChanged + self.requiresFullRescan = requiresFullRescan + self.watchRootsChanged = watchRootsChanged + } + + package var isEmpty: Bool { + workspacePaths.isEmpty && !gitStateMayHaveChanged && !requiresFullRescan && !watchRootsChanged + } +} + +package protocol DirectoryChangeSource: AnyObject, Sendable { + func start() + func stop() +} diff --git a/Sources/Lithe/Models/FileVisibilityRules.swift b/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift similarity index 88% rename from Sources/Lithe/Models/FileVisibilityRules.swift rename to Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift index 76f4f775..153c9016 100644 --- a/Sources/Lithe/Models/FileVisibilityRules.swift +++ b/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift @@ -1,24 +1,24 @@ import Foundation -struct FileVisibilityRules: Hashable, Sendable { - static let builtInHiddenDirectories = [ +package struct FileVisibilityRules: Hashable, Sendable { + package static let builtInHiddenDirectories = [ ".git", ".worktree", ".worktrees", ".build", ".swiftpm", "node_modules", "target", "build", "DerivedData", ".gradle", ".next", "dist", "coverage", "design-qa-artifacts" ] - static let builtInHiddenFilePatterns = [ + package static let builtInHiddenFilePatterns = [ ".DS_Store", ".lithe/run/local.json", ] - var hiddenDirectoryNames: [String] - var hiddenFilePatterns: [String] + package var hiddenDirectoryNames: [String] + package var hiddenFilePatterns: [String] - static let `default` = FileVisibilityRules( + package static let `default` = FileVisibilityRules( hiddenDirectoryNames: builtInHiddenDirectories, hiddenFilePatterns: builtInHiddenFilePatterns ) - init(hiddenDirectoryNames: [String], hiddenFilePatterns: [String]) { + package init(hiddenDirectoryNames: [String], hiddenFilePatterns: [String]) { self.hiddenDirectoryNames = Self.normalizedEntries( Self.builtInHiddenDirectories + hiddenDirectoryNames ) @@ -27,7 +27,7 @@ struct FileVisibilityRules: Hashable, Sendable { ) } - func isHidden( + package func isHidden( _ url: URL, relativeTo rootURL: URL, isDirectory: Bool? = nil @@ -60,11 +60,11 @@ struct FileVisibilityRules: Hashable, Sendable { } } - func isHiddenPath(_ url: URL, relativeTo rootURL: URL) -> Bool { + package func isHiddenPath(_ url: URL, relativeTo rootURL: URL) -> Bool { isHidden(url, relativeTo: rootURL, isDirectory: nil) } - func isHiddenDirectoryName(_ name: String) -> Bool { + package func isHiddenDirectoryName(_ name: String) -> Bool { hiddenDirectoryNames.contains { $0.caseInsensitiveCompare(name) == .orderedSame } } diff --git a/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift b/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift new file mode 100644 index 00000000..4627acf1 --- /dev/null +++ b/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift @@ -0,0 +1,122 @@ +import Foundation + +package struct WorkspaceDocumentState: Sendable { + package let url: URL + package let isDirty: Bool + + package init(url: URL, isDirty: Bool) { + self.url = url + self.isDirty = isDirty + } +} + +package struct WorkspaceSession: Codable, Sendable { + package let openPaths: [String] + package let activePath: String? + package let selectedSidebar: String + + package init(openPaths: [String], activePath: String?, selectedSidebar: String) { + self.openPaths = openPaths + self.activePath = activePath + self.selectedSidebar = selectedSidebar + } +} + +@MainActor +package protocol WorkspaceSessionStoring: AnyObject { + func load(for workspaceURL: URL) -> WorkspaceSession? + func save(_ session: WorkspaceSession, for workspaceURL: URL) +} + +package protocol WorkspaceOperations: Sendable { + func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? + func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) + func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: FileVisibilityRules) + func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) + func readFile(at rootURL: URL, relativePath: String) -> String? + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool +} + +package extension WorkspaceOperations { + func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} + func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: FileVisibilityRules) {} + func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} +} + +package protocol DirectoryWatcherFactory { + func make( + configuration: DirectoryWatchConfiguration, + visibilityRules: FileVisibilityRules, + onChange: @escaping @Sendable (DirectoryChangeBatch) -> Void + ) -> any DirectoryChangeSource +} + +package protocol GitWatchContextProviding: Sendable { + func watchContext(for workspace: URL) async -> GitWatchContext? +} + +package enum ProjectItemEditKind: Sendable { + case createFile + case createDirectory + case rename +} + +package struct ProjectItemEditRequest: Identifiable, Sendable { + package let id: UUID + package let kind: ProjectItemEditKind + package let targetURL: URL + + package init(id: UUID = UUID(), kind: ProjectItemEditKind, targetURL: URL) { + self.id = id + self.kind = kind + self.targetURL = targetURL + } +} + +package struct ProjectItemDeletionRequest: Identifiable, Sendable { + package let id: UUID + package let url: URL + package let isDirectory: Bool + + package init(id: UUID = UUID(), url: URL, isDirectory: Bool) { + self.id = id + self.url = url + self.isDirectory = isDirectory + } +} + +package struct GitWatchContext: Equatable, Sendable { + package let repositoryRoot: URL + package let gitDirectory: URL + package let gitCommonDirectory: URL + + package init(repositoryRoot: URL, gitDirectory: URL, gitCommonDirectory: URL) { + self.repositoryRoot = repositoryRoot + self.gitDirectory = gitDirectory + self.gitCommonDirectory = gitCommonDirectory + } +} + +public enum LocalHistoryReason: String, Codable, Sendable { + case projectBaseline + case saved + case externalChange + case beforeRename + case beforeDelete + case beforeBatchReplace + case unsavedDiscard + case restored + + public var title: String { + switch self { + case .projectBaseline: "Project opened" + case .saved: "File saved" + case .externalChange: "External change" + case .beforeRename: "Before rename" + case .beforeDelete: "Before deletion" + case .beforeBatchReplace: "Before project replacement" + case .unsavedDiscard: "Discarded editor changes" + case .restored: "Before restore" + } + } +} diff --git a/Sources/LitheCoreContracts/Workspace/WorkspaceFileOperations.swift b/Sources/LitheCoreContracts/Workspace/WorkspaceFileOperations.swift new file mode 100644 index 00000000..006c7deb --- /dev/null +++ b/Sources/LitheCoreContracts/Workspace/WorkspaceFileOperations.swift @@ -0,0 +1,14 @@ +import Foundation + +package protocol WorkspaceFileOperations: Sendable { + func fileExists(at url: URL) -> Bool + func isDirectory(at url: URL) -> Bool + func createFile(at url: URL) throws + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws + func copyItem(at sourceURL: URL, to destinationURL: URL) throws + func moveItem(at sourceURL: URL, to destinationURL: URL) throws + func removeItem(at url: URL) throws + func trashItem(at url: URL) throws + func writeText(_ text: String, to url: URL) throws + func readText(from url: URL) throws -> String +} diff --git a/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift b/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift new file mode 100644 index 00000000..542bf7d4 --- /dev/null +++ b/Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift @@ -0,0 +1,45 @@ +import Foundation + +package struct FileNode: Identifiable, Hashable, Sendable { + package let url: URL + package let isDirectory: Bool + package let children: [FileNode]? + /// 被压缩的中间包所对应的目录(不含本节点自身)。展开/折叠时需要 + /// 一并处理,否则父目录的展开状态会和显示的行对不上。 + package let collapsedAncestorPaths: [String] + /// 该目录是否位于源码根之下,决定用包图标还是普通文件夹图标。 + package let isInsideSourceRoot: Bool + + package init( + url: URL, + isDirectory: Bool, + children: [FileNode]?, + collapsedAncestorPaths: [String] = [], + isInsideSourceRoot: Bool = false + ) { + self.url = url + self.isDirectory = isDirectory + self.children = children + self.collapsedAncestorPaths = collapsedAncestorPaths + self.isInsideSourceRoot = isInsideSourceRoot + } + + package var id: String { url.path } + + /// 压缩中间包后显示的名字,例如 com.alibaba.nacos.ai。 + package var name: String { + guard !collapsedAncestorPaths.isEmpty else { return url.lastPathComponent } + let names = collapsedAncestorPaths.map { ($0 as NSString).lastPathComponent } + return (names + [url.lastPathComponent]).joined(separator: ".") + } + +} + +package struct WorkspaceSnapshot: Sendable { + package let root: FileNode + package let files: [URL] + package init(root: FileNode, files: [URL]) { + self.root = root + self.files = files + } +} diff --git a/Sources/Lithe/Application/DatabaseFeatureModel.swift b/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift similarity index 90% rename from Sources/Lithe/Application/DatabaseFeatureModel.swift rename to Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift index 31471778..e38c8185 100644 --- a/Sources/Lithe/Application/DatabaseFeatureModel.swift +++ b/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift @@ -2,21 +2,21 @@ import Combine import Foundation private struct DatabaseSQLBatchError: LocalizedError { - let statementIndex: Int - let message: String + package let statementIndex: Int + package let message: String - var errorDescription: String? { + package var errorDescription: String? { "Statement \(statementIndex) failed: \(message) Batch stopped; earlier statements may have been applied." } } -enum DatabaseConnectionStatus: Equatable, Sendable { +package enum DatabaseConnectionStatus: Equatable, Sendable { case idle case connecting case connected case failed - var title: String { + package var title: String { switch self { case .idle: "Not connected" case .connecting: "Connecting" @@ -27,54 +27,55 @@ enum DatabaseConnectionStatus: Equatable, Sendable { } @MainActor -final class DatabaseFeatureModel: ObservableObject { - @Published private(set) var profiles: [DatabaseProfile] - @Published private(set) var folders: [DatabaseConnectionFolder] - @Published var selectedProfileID: UUID? - @Published private(set) var tables: [String] = [] - @Published private(set) var databaseOptions: [String] = [] - @Published var selectedTable: String? - @Published private(set) var openTableTabs: [String] = [] - @Published private(set) var columns: [String] = [] - @Published private(set) var columnTypes: [String: String] = [:] - @Published private(set) var rows: [DatabaseRow] = [] - @Published private(set) var totalRows: Int64 = 0 - @Published private(set) var currentOffset = 0 - let pageSize = 200 - @Published private(set) var primaryKeyColumns: [String] = [] - @Published private(set) var indexes: [DatabaseRow] = [] - @Published private(set) var foreignKeys: [DatabaseRow] = [] - @Published private(set) var objects: [DatabaseObjectKind: [DatabaseRow]] = [:] - @Published private(set) var lastExplainResult: DatabaseQueryResult? - @Published private(set) var lastDiagnostics: DatabaseQueryResult? - @Published private(set) var recoveryPoints: [DatabaseRecoveryPoint] - @Published private(set) var auditEntries: [DatabaseAuditEntry] - @Published private(set) var executionEvents: [DatabaseExecutionEvent] - @Published private(set) var backupSchedules: [DatabaseBackupSchedule] - @Published private(set) var sqlTabs: [DatabaseSQLTab] - @Published var selectedSQLTabID: UUID? - @Published var workspaceSection: DatabaseWorkspaceSection = .data - @Published private(set) var sqlHistory: [DatabaseSQLHistoryEntry] - @Published private(set) var connectionStatuses: [UUID: DatabaseConnectionStatus] = [:] - @Published private(set) var isLoading = false - @Published private(set) var backupProgress: Double? - @Published var errorMessage: String? - @Published private(set) var redisKeys: [RedisKeySummary] = [] - @Published private(set) var redisNextCursor = "0" - @Published private(set) var redisSelectedKey: RedisKeyDetail? - @Published var redisIncludeSize = true - @Published private(set) var nacosConfigs: [NacosConfigSummary] = [] - @Published private(set) var nacosConfigTotalCount = 0 - @Published var nacosSelectedConfig: NacosConfigDetail? - @Published private(set) var nacosServices: [NacosServiceSummary] = [] - @Published private(set) var nacosServiceTotalCount = 0 - @Published private(set) var nacosInstances: [NacosInstanceSummary] = [] +package final class DatabaseFeatureModel: ObservableObject { + @Published package private(set) var profiles: [DatabaseProfile] + @Published package private(set) var folders: [DatabaseConnectionFolder] + @Published package var selectedProfileID: UUID? + @Published package private(set) var tables: [String] = [] + @Published package private(set) var databaseOptions: [String] = [] + @Published package var selectedTable: String? + @Published package private(set) var openTableTabs: [String] = [] + @Published package private(set) var columns: [String] = [] + @Published package private(set) var columnTypes: [String: String] = [:] + @Published package private(set) var rows: [DatabaseRow] = [] + @Published package private(set) var totalRows: Int64 = 0 + @Published package private(set) var currentOffset = 0 + package let pageSize = 200 + @Published package private(set) var primaryKeyColumns: [String] = [] + @Published package private(set) var indexes: [DatabaseRow] = [] + @Published package private(set) var foreignKeys: [DatabaseRow] = [] + @Published package private(set) var objects: [DatabaseObjectKind: [DatabaseRow]] = [:] + @Published package private(set) var lastExplainResult: DatabaseQueryResult? + @Published package private(set) var lastDiagnostics: DatabaseQueryResult? + @Published package private(set) var recoveryPoints: [DatabaseRecoveryPoint] + @Published package private(set) var auditEntries: [DatabaseAuditEntry] + @Published package private(set) var executionEvents: [DatabaseExecutionEvent] + @Published package private(set) var backupSchedules: [DatabaseBackupSchedule] + @Published package private(set) var sqlTabs: [DatabaseSQLTab] + @Published package var selectedSQLTabID: UUID? + @Published package var workspaceSection: DatabaseWorkspaceSection = .data + @Published package private(set) var sqlHistory: [DatabaseSQLHistoryEntry] + @Published package private(set) var connectionStatuses: [UUID: DatabaseConnectionStatus] = [:] + @Published package private(set) var isLoading = false + @Published package private(set) var backupProgress: Double? + @Published package var errorMessage: String? + @Published package private(set) var redisKeys: [RedisKeySummary] = [] + @Published package private(set) var redisNextCursor = "0" + @Published package private(set) var redisSelectedKey: RedisKeyDetail? + @Published package var redisIncludeSize = true + @Published package private(set) var nacosConfigs: [NacosConfigSummary] = [] + @Published package private(set) var nacosConfigTotalCount = 0 + @Published package var nacosSelectedConfig: NacosConfigDetail? + @Published package private(set) var nacosServices: [NacosServiceSummary] = [] + @Published package private(set) var nacosServiceTotalCount = 0 + @Published package private(set) var nacosInstances: [NacosInstanceSummary] = [] private let operations: any DatabaseOperations private let connectionStore: DatabaseConnectionStore private let recoveryStore: any DatabaseRecoveryStoring - private let fileStorage: any FileStorage + private let fileStorage: any DatabaseFileStorage private var backupTimer: Timer? + private var scheduledBackupTasks: [UUID: Task] = [:] private var profileGeneration: UInt64 = 0 private var tableListRequestID: UUID? private var databaseListRequestID: UUID? @@ -94,11 +95,11 @@ final class DatabaseFeatureModel: ObservableObject { // accidentally persist the display placeholder. private var sourceRows: [DatabaseRow] = [] - init( + package init( operations: any DatabaseOperations, connectionStore: DatabaseConnectionStore, recoveryStore: any DatabaseRecoveryStoring = UnavailableDatabaseRecoveryStore(), - fileStorage: any FileStorage = UnavailableFileStorage() + fileStorage: any DatabaseFileStorage = UnavailableDatabaseFileStorage() ) { self.operations = operations self.connectionStore = connectionStore @@ -118,17 +119,39 @@ final class DatabaseFeatureModel: ObservableObject { refreshBackupTimer() } - deinit { backupTimer?.invalidate() } + package var hasActiveModuleWork: Bool { + isLoading + || sqlTabs.contains(where: { $0.isRunning }) + || backupProgress != nil + || !scheduledBackupTasks.isEmpty + } + + package func prepareForModuleRelease() { + backupTimer?.invalidate() + backupTimer = nil + profileGeneration &+= 1 + for task in scheduledBackupTasks.values { task.cancel() } + scheduledBackupTasks.removeAll() + tableListRequestID = nil + databaseListRequestID = nil + tableRequestID = nil + redisScanRequestID = nil + redisDetailRequestID = nil + nacosConfigListRequestID = nil + nacosConfigDetailRequestID = nil + nacosServiceListRequestID = nil + nacosInstanceRequestID = nil + } - func add(_ profile: DatabaseProfile, password: String) async -> Bool { + package func add(_ profile: DatabaseProfile, password: String) async -> Bool { await save(profile, password: password) } - func update(_ profile: DatabaseProfile, password: String?) async -> Bool { + package func update(_ profile: DatabaseProfile, password: String?) async -> Bool { await save(profile, password: password) } - func hasSavedPassword(for profile: DatabaseProfile) -> Bool { + package func hasSavedPassword(for profile: DatabaseProfile) -> Bool { connectionStore.hasPassword(for: profile.id) } @@ -178,7 +201,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func remove(_ profile: DatabaseProfile) { + package func remove(_ profile: DatabaseProfile) { do { let updated = profiles.filter { $0.id != profile.id } try connectionStore.save(updated); try connectionStore.deletePassword(for: profile.id); try connectionStore.deleteSQLHistory(for: profile.id); try connectionStore.deleteBackupSchedule(for: profile.id); try recoveryStore.deleteExecutionEvents(for: profile.id) @@ -194,7 +217,7 @@ final class DatabaseFeatureModel: ObservableObject { } catch { errorMessage = error.localizedDescription } } - func createFolder(name: String, parentID: UUID? = nil) -> Bool { + package func createFolder(name: String, parentID: UUID? = nil) -> Bool { let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty else { errorMessage = "Folder name is required." @@ -217,7 +240,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func renameFolder(_ folder: DatabaseConnectionFolder, to name: String) -> Bool { + package func renameFolder(_ folder: DatabaseConnectionFolder, to name: String) -> Bool { let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty else { errorMessage = "Folder name is required." @@ -242,7 +265,7 @@ final class DatabaseFeatureModel: ObservableObject { /// Removing a folder never removes a saved connection. Its connections are /// moved to the root so credentials and history remain intact. - func removeFolder(_ folder: DatabaseConnectionFolder) { + package func removeFolder(_ folder: DatabaseConnectionFolder) { do { let updatedProfiles = profiles.map { profile -> DatabaseProfile in guard profile.folderID == folder.id else { return profile } @@ -265,7 +288,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func move(_ profile: DatabaseProfile, toFolder folderID: UUID?) { + package func move(_ profile: DatabaseProfile, toFolder folderID: UUID?) { guard folderID == nil || folders.contains(where: { $0.id == folderID }) else { errorMessage = "The selected connection folder no longer exists." return @@ -283,7 +306,7 @@ final class DatabaseFeatureModel: ObservableObject { } @discardableResult - func importDBXConnections(plan: DatabaseDBXImportPlan, selectedIDs: Set) -> Int { + package func importDBXConnections(plan: DatabaseDBXImportPlan, selectedIDs: Set) -> Int { errorMessage = nil var updatedFolders = folders var folderIDMap: [UUID: UUID] = [:] @@ -334,14 +357,14 @@ final class DatabaseFeatureModel: ObservableObject { } } - func disconnect(_ profile: DatabaseProfile) { + package func disconnect(_ profile: DatabaseProfile) { setConnectionStatus(.idle, for: profile.id) if selectedProfileID == profile.id { clearProfileScopedState() } } - func duplicate(_ profile: DatabaseProfile) -> DatabaseProfile? { + package func duplicate(_ profile: DatabaseProfile) -> DatabaseProfile? { var copy = profile copy = DatabaseProfile( name: "\(profile.name) Copy", kind: profile.kind, host: profile.host, port: profile.port, @@ -369,7 +392,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func select(_ profile: DatabaseProfile) async { + package func select(_ profile: DatabaseProfile) async { activateProfile(profile) if profile.kind.supportsDataGrid { await refreshTables() @@ -379,7 +402,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func refreshTables() async { + package func refreshTables() async { guard let profile = selectedProfile else { tables = [] databaseOptions = [] @@ -445,7 +468,7 @@ final class DatabaseFeatureModel: ObservableObject { if isCurrent(profileID: profile.id, generation: generation), tableListRequestID == requestID, databaseListRequestID == databaseRequestID { isLoading = false } } - func refreshDatabases() async { + package func refreshDatabases() async { guard let profile = selectedProfile, profile.kind == .mysql || profile.kind == .mariadb else { return } let generation = profileGeneration let requestID = UUID() @@ -472,7 +495,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func selectDatabase(_ database: String, for profile: DatabaseProfile) async { + package func selectDatabase(_ database: String, for profile: DatabaseProfile) async { guard selectedProfileID == profile.id, (profile.kind == .mysql || profile.kind == .mariadb), databaseOptions.contains(database) else { return } @@ -518,7 +541,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func loadSchemaSnapshot(profileID: UUID) async -> DatabaseSchemaSnapshot? { + package func loadSchemaSnapshot(profileID: UUID) async -> DatabaseSchemaSnapshot? { guard let profile = profiles.first(where: { $0.id == profileID }) else { errorMessage = "The selected database connection no longer exists." return nil @@ -592,7 +615,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func applySchemaMigration(_ diff: DatabaseSchemaDiffResult, targetProfileID: UUID, confirmed: Bool = false) async -> Bool { + package func applySchemaMigration(_ diff: DatabaseSchemaDiffResult, targetProfileID: UUID, confirmed: Bool = false) async -> Bool { guard let profile = profiles.first(where: { $0.id == targetProfileID }) else { errorMessage = "The target database connection no longer exists." return false @@ -648,7 +671,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func applySchemaChange(_ change: DatabaseSchemaChange, confirmed: Bool = false) async -> Bool { + package func applySchemaChange(_ change: DatabaseSchemaChange, confirmed: Bool = false) async -> Bool { guard let profile = selectedProfile else { errorMessage = "Select a database connection first."; return false } let generation = profileGeneration isLoading = true; errorMessage = nil @@ -674,7 +697,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func explainSQL(_ sql: String, format: String = "json") async -> DatabaseQueryResult? { + package func explainSQL(_ sql: String, format: String = "json") async -> DatabaseQueryResult? { guard let profile = selectedProfile else { errorMessage = "Select a database connection first."; return nil } let generation = profileGeneration do { @@ -694,7 +717,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func loadDiagnostics(_ request: DatabaseDiagnosticsRequest) async -> DatabaseQueryResult? { + package func loadDiagnostics(_ request: DatabaseDiagnosticsRequest) async -> DatabaseQueryResult? { guard let profile = selectedProfile else { errorMessage = "Select a database connection first."; return nil } let generation = profileGeneration do { @@ -714,7 +737,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func openTable(_ table: String) async { + package func openTable(_ table: String) async { guard let profile = selectedProfile else { return } let generation = profileGeneration let requestID = UUID() @@ -759,7 +782,7 @@ final class DatabaseFeatureModel: ObservableObject { } @discardableResult - func closeTableTab(_ table: String) -> String? { + package func closeTableTab(_ table: String) -> String? { openTableTabs.removeAll { $0 == table } guard selectedTable == table else { return selectedTable } selectedTable = openTableTabs.last @@ -770,7 +793,7 @@ final class DatabaseFeatureModel: ObservableObject { return selectedTable } - func loadPage(filters: [DatabaseFilter], sort: [DatabaseSort], offset: Int) async { + package func loadPage(filters: [DatabaseFilter], sort: [DatabaseSort], offset: Int) async { guard let profile = selectedProfile, let table = selectedTable else { return } let generation = profileGeneration let requestID = UUID() @@ -794,7 +817,7 @@ final class DatabaseFeatureModel: ObservableObject { if isCurrent(profileID: profile.id, generation: generation), tableRequestID == requestID { isLoading = false } } - func apply(drafts: [DatabaseCellDraft], insertedRows: [DatabaseRow], deletedIndexes: Set, confirmed: Bool = false) async -> Bool { + package func apply(drafts: [DatabaseCellDraft], insertedRows: [DatabaseRow], deletedIndexes: Set, confirmed: Bool = false) async -> Bool { guard let profile = selectedProfile, let table = selectedTable else { return false } let generation = profileGeneration var changesByRow: [Int: DatabaseRow] = [:] @@ -829,7 +852,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func exportData(format: DatabaseTransferFormat) async -> Data? { + package func exportData(format: DatabaseTransferFormat) async -> Data? { guard let profile = selectedProfile else { return nil } let connection = connection(profile) let table = selectedTable @@ -856,7 +879,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func exportDataFile(format: DatabaseTransferFormat) async -> URL? { + package func exportDataFile(format: DatabaseTransferFormat) async -> URL? { guard format == .sql, let profile = selectedProfile else { return nil } let generation = profileGeneration let outputURL = fileStorage.temporaryDirectory().appendingPathComponent("lithe-database-\(UUID().uuidString).sql") @@ -882,21 +905,21 @@ final class DatabaseFeatureModel: ObservableObject { } } - func prepareImportFile(from url: URL) throws -> URL { + package func prepareImportFile(from url: URL) throws -> URL { let destination = fileStorage.temporaryDirectory().appendingPathComponent("lithe-import-\(UUID().uuidString).sql") try fileStorage.copyItem(at: url, to: destination) return destination } - func readImportData(from url: URL) throws -> Data { - try fileStorage.readData(from: url, options: []) + package func readImportData(from url: URL) throws -> Data { + try fileStorage.readData(from: url) } - func removeTemporaryFile(_ url: URL) { + package func removeTemporaryFile(_ url: URL) { try? fileStorage.removeItem(at: url) } - func importData(_ data: Data, format: DatabaseTransferFormat, confirmed: Bool = false) async -> Bool { + package func importData(_ data: Data, format: DatabaseTransferFormat, confirmed: Bool = false) async -> Bool { guard let profile = selectedProfile else { return false } let generation = profileGeneration let table = selectedTable @@ -931,7 +954,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func importDataFile(_ fileURL: URL, format: DatabaseTransferFormat, confirmed: Bool = false) async -> Bool { + package func importDataFile(_ fileURL: URL, format: DatabaseTransferFormat, confirmed: Bool = false) async -> Bool { guard format == .sql, let profile = selectedProfile else { return false } let generation = profileGeneration let table = selectedTable @@ -958,9 +981,9 @@ final class DatabaseFeatureModel: ObservableObject { } } - var selectedSQLTab: DatabaseSQLTab? { sqlTabs.first { $0.id == selectedSQLTabID } } + package var selectedSQLTab: DatabaseSQLTab? { sqlTabs.first { $0.id == selectedSQLTabID } } - var sqlCompletionItems: [String] { + package var sqlCompletionItems: [String] { let keywords = [ "SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "JOIN", "LEFT JOIN", "ORDER BY", "GROUP BY", "LIMIT", "EXPLAIN", "SHOW", "DESCRIBE", "BEGIN", "COMMIT", "ROLLBACK" @@ -971,13 +994,13 @@ final class DatabaseFeatureModel: ObservableObject { } } - func addSQLTab(sql: String = "") { + package func addSQLTab(sql: String = "") { let tab = DatabaseSQLTab(title: "Query \(sqlTabs.count + 1)", sql: sql) sqlTabs.append(tab) selectedSQLTabID = tab.id } - func closeSQLTab(_ id: UUID) { + package func closeSQLTab(_ id: UUID) { guard sqlTabs.count > 1 else { updateSQLTab(id) { tab in tab.sql = ""; tab.result = nil; tab.resultColumns = []; tab.rowsAffected = nil; tab.execution = nil; tab.errorMessage = nil @@ -989,19 +1012,19 @@ final class DatabaseFeatureModel: ObservableObject { if selectedSQLTabID == id { selectedSQLTabID = sqlTabs[max(0, index - 1)].id } } - func updateSQL(_ sql: String, in tabID: UUID) { + package func updateSQL(_ sql: String, in tabID: UUID) { updateSQLTab(tabID) { tab in tab.sql = sql tab.errorMessage = nil } } - func formatSQL(in tabID: UUID) { + package func formatSQL(in tabID: UUID) { guard let tab = sqlTabs.first(where: { $0.id == tabID }) else { return } updateSQL(DatabaseSQLFormatter.format(tab.sql), in: tabID) } - func analysis(forSQLTab tabID: UUID, scope: DatabaseSQLExecutionScope = .all) -> DatabaseSQLAnalysis { + package func analysis(forSQLTab tabID: UUID, scope: DatabaseSQLExecutionScope = .all) -> DatabaseSQLAnalysis { DatabaseSQLAnalyzer.analyze(sql(for: tabID, scope: scope)) } @@ -1014,7 +1037,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func restoreSQLHistory(_ entry: DatabaseSQLHistoryEntry) { + package func restoreSQLHistory(_ entry: DatabaseSQLHistoryEntry) { if let selected = selectedSQLTab, selected.sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { updateSQL(entry.sql, in: selected.id) } else { @@ -1022,7 +1045,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func runSQL(in tabID: UUID, scope: DatabaseSQLExecutionScope = .all, confirmedRisk: Bool = false) async { + package func runSQL(in tabID: UUID, scope: DatabaseSQLExecutionScope = .all, confirmedRisk: Bool = false) async { guard let profile = selectedProfile, sqlTabs.contains(where: { $0.id == tabID }) else { errorMessage = "Select a database connection first." return @@ -1119,7 +1142,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func rollback(to point: DatabaseRecoveryPoint) async -> Bool { + package func rollback(to point: DatabaseRecoveryPoint) async -> Bool { guard let profile = selectedProfile, profile.id == point.profileID else { errorMessage = "Select the connection that owns this recovery point."; return false } let generation = profileGeneration isLoading = true; errorMessage = nil @@ -1147,7 +1170,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func configureBackupSchedule(profileID: UUID, isEnabled: Bool, intervalHours: Int, retentionCount: Int) { + package func configureBackupSchedule(profileID: UUID, isEnabled: Bool, intervalHours: Int, retentionCount: Int) { let nextRun = Date().addingTimeInterval(TimeInterval(max(1, intervalHours)) * 3_600) let schedule = DatabaseBackupSchedule(profileID: profileID, isEnabled: isEnabled, intervalHours: intervalHours, retentionCount: retentionCount, nextRunAt: nextRun) backupSchedules.removeAll { $0.profileID == profileID } @@ -1156,15 +1179,18 @@ final class DatabaseFeatureModel: ObservableObject { refreshBackupTimer() } - func createBackup(profileID: UUID, reason: String = "Manual backup") async -> Bool { + package func createBackup(profileID: UUID, reason: String = "Manual backup") async -> Bool { + guard !Task.isCancelled else { return false } guard let profile = profiles.first(where: { $0.id == profileID }) else { errorMessage = "The backup connection no longer exists."; return false } let generation = profileGeneration do { let point = try await createRecoveryPoint(profile: profile, reason: reason) + guard !Task.isCancelled, profileGeneration == generation else { return false } pruneRecoveryPoints(for: profile.id) appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "backup", summary: reason, createdAt: Date(), recoveryPointID: point.id, rowsAffected: nil, succeeded: true, errorMessage: nil)) return true } catch { + guard !Task.isCancelled, profileGeneration == generation else { return false } let sanitizedError = executionError(error) appendAudit(DatabaseAuditEntry(id: UUID(), profileID: profile.id, action: "backup", summary: "Backup failed", createdAt: Date(), recoveryPointID: nil, rowsAffected: nil, succeeded: false, errorMessage: sanitizedError)) if isCurrent(profileID: profileID, generation: generation) { @@ -1174,13 +1200,17 @@ final class DatabaseFeatureModel: ObservableObject { } } - private func runScheduledBackups() { - let now = Date() + package func runScheduledBackups(now: Date = Date()) { for schedule in backupSchedules where schedule.isEnabled && schedule.nextRunAt <= now { - Task { [weak self] in + guard scheduledBackupTasks[schedule.profileID] == nil else { continue } + let generation = profileGeneration + let profileID = schedule.profileID + scheduledBackupTasks[profileID] = Task { [weak self] in guard let self else { return } - let succeeded = await self.createBackup(profileID: schedule.profileID, reason: "Scheduled backup") - self.advanceBackupSchedule(for: schedule.profileID, succeeded: succeeded) + let succeeded = await self.createBackup(profileID: profileID, reason: "Scheduled backup") + guard !Task.isCancelled, self.profileGeneration == generation else { return } + self.advanceBackupSchedule(for: profileID, succeeded: succeeded) + self.scheduledBackupTasks[profileID] = nil } } } @@ -1218,7 +1248,7 @@ final class DatabaseFeatureModel: ObservableObject { // MARK: - Redis workspace - func loadRedisKeys(pattern: String, reset: Bool = true) async { + package func loadRedisKeys(pattern: String, reset: Bool = true) async { guard let profile = selectedProfile, profile.kind == .redis else { return } let generation = profileGeneration redisScanPattern = pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "*" : pattern @@ -1265,7 +1295,7 @@ final class DatabaseFeatureModel: ObservableObject { if isCurrent(profileID: profile.id, generation: generation), redisScanRequestID == requestID { isLoading = false } } - func loadRedisKey(_ key: String) async { + package func loadRedisKey(_ key: String) async { guard let profile = selectedProfile, profile.kind == .redis else { return } let generation = profileGeneration let requestID = UUID() @@ -1301,7 +1331,7 @@ final class DatabaseFeatureModel: ObservableObject { if isCurrent(profileID: profile.id, generation: generation), redisDetailRequestID == requestID { isLoading = false } } - func saveRedisString(key: String, value: String, ttl: Int64?, confirmed: Bool) async -> Bool { + package func saveRedisString(key: String, value: String, ttl: Int64?, confirmed: Bool) async -> Bool { await performRedisWrite(summary: "Updated Redis string \(key)", confirmed: confirmed) { connection, operations in try operations.redisSetString(connection: connection, key: key, value: value, ttl: ttl, confirmed: confirmed, allowWrite: false) } afterSuccess: { @@ -1309,7 +1339,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func replaceRedisHash(key: String, entries: [RedisHashEntry], confirmed: Bool) async -> Bool { + package func replaceRedisHash(key: String, entries: [RedisHashEntry], confirmed: Bool) async -> Bool { await performRedisWrite(summary: "Updated Redis hash \(key)", confirmed: confirmed) { connection, operations in try operations.redisReplaceHash(connection: connection, key: key, entries: entries, confirmed: confirmed, allowWrite: false) } afterSuccess: { @@ -1317,7 +1347,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func setRedisTTL(key: String, ttl: Int64, confirmed: Bool) async -> Bool { + package func setRedisTTL(key: String, ttl: Int64, confirmed: Bool) async -> Bool { await performRedisWrite(summary: "Changed Redis TTL for \(key)", confirmed: confirmed) { connection, operations in try operations.redisSetTTL(connection: connection, key: key, ttl: ttl, confirmed: confirmed, allowWrite: false) } afterSuccess: { @@ -1325,7 +1355,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func renameRedisKey(key: String, newKey: String, confirmed: Bool) async -> Bool { + package func renameRedisKey(key: String, newKey: String, confirmed: Bool) async -> Bool { await performRedisWrite(summary: "Renamed Redis key \(key)", confirmed: confirmed) { connection, operations in try operations.redisRenameKey(connection: connection, key: key, newKey: newKey, confirmed: confirmed, allowWrite: false) } afterSuccess: { @@ -1333,7 +1363,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func deleteRedisKey(key: String, confirmed: Bool) async -> Bool { + package func deleteRedisKey(key: String, confirmed: Bool) async -> Bool { await performRedisWrite(summary: "Deleted Redis key \(key)", confirmed: confirmed) { connection, operations in try operations.redisDeleteKey(connection: connection, key: key, confirmed: confirmed, allowWrite: false) } afterSuccess: { @@ -1341,7 +1371,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func flushRedisDatabase(confirmed: Bool) async -> Bool { + package func flushRedisDatabase(confirmed: Bool) async -> Bool { await performRedisWrite(summary: "Cleared Redis database", confirmed: confirmed) { connection, operations in try operations.redisFlushDatabase(connection: connection, confirmed: confirmed, allowWrite: false) } afterSuccess: { @@ -1400,7 +1430,7 @@ final class DatabaseFeatureModel: ObservableObject { // MARK: - Nacos workspace - func loadNacosConfigs(dataId: String, group: String, page: Int = 1) async { + package func loadNacosConfigs(dataId: String, group: String, page: Int = 1) async { guard let profile = selectedProfile, profile.kind == .nacos else { return } let generation = profileGeneration let selectedConfigToRefresh = page == 1 ? nacosSelectedConfig.map { ($0.dataId, $0.group) } : nil @@ -1446,7 +1476,7 @@ final class DatabaseFeatureModel: ObservableObject { if isCurrent(profileID: profile.id, generation: generation), nacosConfigListRequestID == requestID { isLoading = false } } - func loadNacosConfig(dataId: String, group: String) async { + package func loadNacosConfig(dataId: String, group: String) async { guard let profile = selectedProfile, profile.kind == .nacos else { return } let generation = profileGeneration let requestID = UUID() @@ -1476,7 +1506,7 @@ final class DatabaseFeatureModel: ObservableObject { if isCurrent(profileID: profile.id, generation: generation), nacosConfigDetailRequestID == requestID { isLoading = false } } - func publishNacosConfig(dataId: String, group: String, content: String, type: String?, confirmed: Bool) async -> Bool { + package func publishNacosConfig(dataId: String, group: String, content: String, type: String?, confirmed: Bool) async -> Bool { guard let profile = selectedProfile, profile.kind == .nacos else { return false } let summary = "Published Nacos config \(group)/\(dataId)" return await performNacosWrite(profile: profile, summary: summary) { connection, operations in @@ -1487,7 +1517,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func deleteNacosConfig(dataId: String, group: String, confirmed: Bool) async -> Bool { + package func deleteNacosConfig(dataId: String, group: String, confirmed: Bool) async -> Bool { guard let profile = selectedProfile, profile.kind == .nacos else { return false } let summary = "Deleted Nacos config \(group)/\(dataId)" return await performNacosWrite(profile: profile, summary: summary) { connection, operations in @@ -1498,7 +1528,7 @@ final class DatabaseFeatureModel: ObservableObject { } } - func loadNacosServices(serviceName: String, group: String, page: Int = 1) async { + package func loadNacosServices(serviceName: String, group: String, page: Int = 1) async { guard let profile = selectedProfile, profile.kind == .nacos else { return } let generation = profileGeneration let requestID = UUID() @@ -1544,7 +1574,7 @@ final class DatabaseFeatureModel: ObservableObject { if isCurrent(profileID: profile.id, generation: generation), nacosServiceListRequestID == requestID { isLoading = false } } - func loadNacosInstances(serviceName: String, group: String) async { + package func loadNacosInstances(serviceName: String, group: String) async { guard let profile = selectedProfile, profile.kind == .nacos else { return } let generation = profileGeneration nacosSelectedServiceName = serviceName @@ -1879,13 +1909,13 @@ final class DatabaseFeatureModel: ObservableObject { return value } - var selectedProfile: DatabaseProfile? { profiles.first { $0.id == selectedProfileID } } + package var selectedProfile: DatabaseProfile? { profiles.first { $0.id == selectedProfileID } } - func connectionStatus(for profile: DatabaseProfile) -> DatabaseConnectionStatus { + package func connectionStatus(for profile: DatabaseProfile) -> DatabaseConnectionStatus { connectionStatuses[profile.id] ?? .idle } - var connectedProfileCount: Int { + package var connectedProfileCount: Int { connectionStatuses.values.reduce(into: 0) { count, status in if status == .connected { count += 1 } } @@ -1924,11 +1954,12 @@ private extension Dictionary where Key == String, Value == DatabaseValue { } } -struct DatabaseCellDraft: Sendable { - let rowIndex: Int - let column: String - let value: DatabaseValue +package struct DatabaseCellDraft: Sendable { + package let rowIndex: Int + package let column: String + package let value: DatabaseValue + package init(rowIndex: Int, column: String, value: DatabaseValue) { self.rowIndex = rowIndex; self.column = column; self.value = value } } -enum DatabaseTransferFormat: String, Sendable { case csv, json, sql } +package enum DatabaseTransferFormat: String, Sendable { case csv, json, sql } private enum DatabaseTransferError: LocalizedError { case tableRequired; var errorDescription: String? { "Select a table first." } } diff --git a/Sources/Lithe/Application/DatabaseSQLSupport.swift b/Sources/LitheDatabaseModule/Models/DatabaseSQLSupport.swift similarity index 86% rename from Sources/Lithe/Application/DatabaseSQLSupport.swift rename to Sources/LitheDatabaseModule/Models/DatabaseSQLSupport.swift index 43ea03b0..d4333b15 100644 --- a/Sources/Lithe/Application/DatabaseSQLSupport.swift +++ b/Sources/LitheDatabaseModule/Models/DatabaseSQLSupport.swift @@ -1,7 +1,16 @@ import Foundation -enum DatabaseSensitiveFieldMasker { - static func mask(rows: [DatabaseRow], enabled: Bool, patterns: [String]) -> [DatabaseRow] { +package enum DatabaseWorkspaceSection: String, CaseIterable, Identifiable, Sendable { + case data + case sql + case structure + case history + + package var id: String { rawValue } +} + +package enum DatabaseSensitiveFieldMasker { + package static func mask(rows: [DatabaseRow], enabled: Bool, patterns: [String]) -> [DatabaseRow] { guard enabled else { return rows } let normalizedPatterns = patterns .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } @@ -21,7 +30,7 @@ enum DatabaseSensitiveFieldMasker { } } -enum DatabaseSQLStatementKind: String, Codable, Equatable, Sendable { +package enum DatabaseSQLStatementKind: String, Codable, Equatable, Sendable { case query case mutation case definition @@ -29,21 +38,21 @@ enum DatabaseSQLStatementKind: String, Codable, Equatable, Sendable { case batch case unknown - var usesQueryEndpoint: Bool { + package var usesQueryEndpoint: Bool { self == .query } } -struct DatabaseSQLAnalysis: Equatable, Sendable { - let kind: DatabaseSQLStatementKind - let statementCount: Int - let requiresConfirmation: Bool - let warning: String? - let statements: [String] +package struct DatabaseSQLAnalysis: Equatable, Sendable { + package let kind: DatabaseSQLStatementKind + package let statementCount: Int + package let requiresConfirmation: Bool + package let warning: String? + package let statements: [String] - var canExecute: Bool { !statements.isEmpty || statementCount == 1 } + package var canExecute: Bool { !statements.isEmpty || statementCount == 1 } - init( + package init( kind: DatabaseSQLStatementKind, statementCount: Int, requiresConfirmation: Bool, @@ -58,23 +67,23 @@ struct DatabaseSQLAnalysis: Equatable, Sendable { } } -enum DatabaseSQLExecutionScope: Equatable, Sendable { +package enum DatabaseSQLExecutionScope: Equatable, Sendable { case all case selection(String) } -struct DatabaseSQLTab: Identifiable, Equatable, Sendable { - let id: UUID - var title: String - var sql: String - var result: DatabaseQueryResult? - var resultColumns: [String] - var rowsAffected: UInt64? - var execution: DatabaseSQLExecution? - var errorMessage: String? - var isRunning: Bool +package struct DatabaseSQLTab: Identifiable, Equatable, Sendable { + package let id: UUID + package var title: String + package var sql: String + package var result: DatabaseQueryResult? + package var resultColumns: [String] + package var rowsAffected: UInt64? + package var execution: DatabaseSQLExecution? + package var errorMessage: String? + package var isRunning: Bool - init(id: UUID = UUID(), title: String, sql: String = "") { + package init(id: UUID = UUID(), title: String, sql: String = "") { self.id = id self.title = title self.sql = sql @@ -87,28 +96,28 @@ struct DatabaseSQLTab: Identifiable, Equatable, Sendable { } } -struct DatabaseSQLExecution: Equatable, Sendable { - let startedAt: Date - let durationMilliseconds: Int - let rowsReturned: Int? - let rowsAffected: UInt64? - let truncated: Bool +package struct DatabaseSQLExecution: Equatable, Sendable { + package let startedAt: Date + package let durationMilliseconds: Int + package let rowsReturned: Int? + package let rowsAffected: UInt64? + package let truncated: Bool } /// Kept separately from a connection profile so neither passwords nor result /// data ever enter preferences. The SQL text is intentionally retained for a /// familiar query-history workflow. -struct DatabaseSQLHistoryEntry: Codable, Equatable, Identifiable, Sendable { - let id: UUID - let profileID: UUID - let sql: String - let kind: DatabaseSQLStatementKind - let executedAt: Date - let durationMilliseconds: Int - let rowsReturned: Int? - let rowsAffected: UInt64? +package struct DatabaseSQLHistoryEntry: Codable, Equatable, Identifiable, Sendable { + package let id: UUID + package let profileID: UUID + package let sql: String + package let kind: DatabaseSQLStatementKind + package let executedAt: Date + package let durationMilliseconds: Int + package let rowsReturned: Int? + package let rowsAffected: UInt64? - init( + package init( id: UUID = UUID(), profileID: UUID, sql: String, @@ -129,8 +138,8 @@ struct DatabaseSQLHistoryEntry: Codable, Equatable, Identifiable, Sendable { } } -enum DatabaseSQLAnalyzer { - static func analyze(_ sql: String) -> DatabaseSQLAnalysis { +package enum DatabaseSQLAnalyzer { + package static func analyze(_ sql: String) -> DatabaseSQLAnalysis { let statements = DatabaseSQLLexing.statements(in: sql) guard !statements.isEmpty else { return DatabaseSQLAnalysis( @@ -195,8 +204,8 @@ enum DatabaseSQLAnalyzer { } } -enum DatabaseSQLFormatter { - static func format(_ sql: String) -> String { +package enum DatabaseSQLFormatter { + package static func format(_ sql: String) -> String { let keywords = DatabaseSQLLexing.formatKeywords let source = Array(sql.trimmingCharacters(in: .whitespacesAndNewlines)) var output = "" @@ -304,7 +313,7 @@ enum DatabaseSQLFormatter { } private enum DatabaseSQLLexing { - static let formatKeywords: Set = [ + package static let formatKeywords: Set = [ "SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES", "UPDATE", "DELETE", "MERGE", "REPLACE", "CREATE", "ALTER", "DROP", "TRUNCATE", "TABLE", "VIEW", "INDEX", "DATABASE", "SCHEMA", "TRIGGER", "PROCEDURE", "FUNCTION", "BEGIN", "COMMIT", "ROLLBACK", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", @@ -314,11 +323,11 @@ private enum DatabaseSQLLexing { "WHEN", "THEN", "ELSE", "END", "ASC", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "DEFAULT" ] - static func keywords(in sql: String) -> [String] { + package static func keywords(in sql: String) -> [String] { sanitized(sql).split { !$0.isLetter && $0 != "_" }.map { String($0).uppercased() } } - static func statements(in sql: String) -> [String] { + package static func statements(in sql: String) -> [String] { let characters = Array(sql) var statements: [String] = [] var current = "" diff --git a/Sources/Lithe/Application/DatabaseSchemaDiff.swift b/Sources/LitheDatabaseModule/Models/DatabaseSchemaDiff.swift similarity index 88% rename from Sources/Lithe/Application/DatabaseSchemaDiff.swift rename to Sources/LitheDatabaseModule/Models/DatabaseSchemaDiff.swift index 17cca047..e9bd80b2 100644 --- a/Sources/Lithe/Application/DatabaseSchemaDiff.swift +++ b/Sources/LitheDatabaseModule/Models/DatabaseSchemaDiff.swift @@ -1,47 +1,47 @@ import Foundation -struct DatabaseSchemaColumnSnapshot: Codable, Equatable, Sendable { - let name: String - let dataType: String - let isNullable: Bool - let defaultValue: String? - let isPrimaryKey: Bool +package struct DatabaseSchemaColumnSnapshot: Codable, Equatable, Sendable { + package let name: String + package let dataType: String + package let isNullable: Bool + package let defaultValue: String? + package let isPrimaryKey: Bool } -struct DatabaseSchemaIndexSnapshot: Codable, Equatable, Sendable { - let name: String - let definition: String +package struct DatabaseSchemaIndexSnapshot: Codable, Equatable, Sendable { + package let name: String + package let definition: String } -struct DatabaseSchemaForeignKeySnapshot: Codable, Equatable, Sendable { - let name: String - let column: String - let referencedTable: String - let referencedColumn: String +package struct DatabaseSchemaForeignKeySnapshot: Codable, Equatable, Sendable { + package let name: String + package let column: String + package let referencedTable: String + package let referencedColumn: String } -struct DatabaseSchemaTableSnapshot: Codable, Equatable, Sendable { - let name: String - let columns: [DatabaseSchemaColumnSnapshot] - let indexes: [DatabaseSchemaIndexSnapshot] - let foreignKeys: [DatabaseSchemaForeignKeySnapshot] +package struct DatabaseSchemaTableSnapshot: Codable, Equatable, Sendable { + package let name: String + package let columns: [DatabaseSchemaColumnSnapshot] + package let indexes: [DatabaseSchemaIndexSnapshot] + package let foreignKeys: [DatabaseSchemaForeignKeySnapshot] } -struct DatabaseSchemaSnapshot: Codable, Equatable, Sendable { - let profileID: UUID - let profileName: String - let kind: DatabaseKind - let schema: String - let tables: [DatabaseSchemaTableSnapshot] +package struct DatabaseSchemaSnapshot: Codable, Equatable, Sendable { + package let profileID: UUID + package let profileName: String + package let kind: DatabaseKind + package let schema: String + package let tables: [DatabaseSchemaTableSnapshot] } private struct DatabaseForeignKeyGroup { - let name: String - let referencedTable: String - let columns: [DatabaseSchemaForeignKeySnapshot] + package let name: String + package let referencedTable: String + package let columns: [DatabaseSchemaForeignKeySnapshot] } -enum DatabaseSchemaDiffKind: String, Codable, CaseIterable, Sendable { +package enum DatabaseSchemaDiffKind: String, Codable, CaseIterable, Sendable { case addTable case dropTable case addColumn @@ -52,14 +52,14 @@ enum DatabaseSchemaDiffKind: String, Codable, CaseIterable, Sendable { case addForeignKey case dropForeignKey - var isDestructive: Bool { + package var isDestructive: Bool { switch self { case .dropTable, .dropColumn, .dropIndex, .dropForeignKey: true default: false } } - var title: String { + package var title: String { switch self { case .addTable: "Add table" case .dropTable: "Drop table" @@ -74,27 +74,27 @@ enum DatabaseSchemaDiffKind: String, Codable, CaseIterable, Sendable { } } -struct DatabaseSchemaDiffItem: Codable, Equatable, Identifiable, Sendable { - let id: String - let kind: DatabaseSchemaDiffKind - let table: String - let detail: String - let sql: String +package struct DatabaseSchemaDiffItem: Codable, Equatable, Identifiable, Sendable { + package let id: String + package let kind: DatabaseSchemaDiffKind + package let table: String + package let detail: String + package let sql: String - var isDestructive: Bool { kind.isDestructive } + package var isDestructive: Bool { kind.isDestructive } } -struct DatabaseSchemaDiffResult: Codable, Equatable, Sendable { - let source: DatabaseSchemaSnapshot - let target: DatabaseSchemaSnapshot - let items: [DatabaseSchemaDiffItem] +package struct DatabaseSchemaDiffResult: Codable, Equatable, Sendable { + package let source: DatabaseSchemaSnapshot + package let target: DatabaseSchemaSnapshot + package let items: [DatabaseSchemaDiffItem] - var requiresConfirmation: Bool { items.contains(where: { $0.isDestructive }) } - var migrationSQL: String { items.map(\.sql).joined(separator: "\n") } + package var requiresConfirmation: Bool { items.contains(where: { $0.isDestructive }) } + package var migrationSQL: String { items.map(\.sql).joined(separator: "\n") } } -enum DatabaseSchemaDiffEngine { - static func statements(in sql: String) -> [String] { +package enum DatabaseSchemaDiffEngine { + package static func statements(in sql: String) -> [String] { var result: [String] = [] var statement = "" var quote: Character? @@ -124,7 +124,7 @@ enum DatabaseSchemaDiffEngine { return result } - static func compare(source: DatabaseSchemaSnapshot, target: DatabaseSchemaSnapshot) -> DatabaseSchemaDiffResult { + package static func compare(source: DatabaseSchemaSnapshot, target: DatabaseSchemaSnapshot) -> DatabaseSchemaDiffResult { var items: [DatabaseSchemaDiffItem] = [] let sourceTables = Dictionary(uniqueKeysWithValues: source.tables.map { ($0.name, $0) }) let targetTables = Dictionary(uniqueKeysWithValues: target.tables.map { ($0.name, $0) }) diff --git a/Sources/LitheDatabaseModule/Module/DatabaseModule.swift b/Sources/LitheDatabaseModule/Module/DatabaseModule.swift new file mode 100644 index 00000000..4b6e4ccd --- /dev/null +++ b/Sources/LitheDatabaseModule/Module/DatabaseModule.swift @@ -0,0 +1,86 @@ +import Foundation +import LitheModuleAPI + +@MainActor +public final class DatabaseModuleCapability: NSObject { + package let feature: DatabaseFeatureModel + package init(feature: DatabaseFeatureModel) { self.feature = feature } +} + +@MainActor +public final class DatabaseModule: LitheModule { + public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .database) + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .database)! + + public let manifest = moduleManifest + private let processRunner: any DatabaseProcessRunning + private let executableURL: URL? + private let preferenceStore: any DatabasePreferenceStore + private let secureStore: any DatabaseSecureStore + private let recoveryStore: any DatabaseRecoveryStoring + private let fileStorage: any DatabaseFileStorage + private var capability: DatabaseModuleCapability? + + package init( + processRunner: any DatabaseProcessRunning, + executableURL: URL?, + preferenceStore: any DatabasePreferenceStore, + secureStore: any DatabaseSecureStore, + recoveryStore: any DatabaseRecoveryStoring, + fileStorage: any DatabaseFileStorage + ) { + self.processRunner = processRunner + self.executableURL = executableURL + self.preferenceStore = preferenceStore + self.secureStore = secureStore + self.recoveryStore = recoveryStore + self.fileStorage = fileStorage + } + + public func activate(context: ModuleContext) async throws { + guard capability == nil else { return } + let feature = DatabaseFeatureModel( + operations: DatabaseSidecarService(processRunner: processRunner, executableURL: executableURL), + connectionStore: DatabaseConnectionStore(store: preferenceStore, secureStore: secureStore), + recoveryStore: recoveryStore, + fileStorage: fileStorage + ) + context.resources.register(DatabaseFeatureResource(feature: feature)) + capability = DatabaseModuleCapability(feature: feature) + } + + public func prepareForSleep() async throws { + guard capability?.feature.hasActiveModuleWork != true else { throw DatabaseModuleSleepError.activeWork } + } + + public func sleep() async { releaseFeature() } + public func shutdown() async { releaseFeature() } + + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.databaseWorkspace: capability] + } + + public func contributions() -> [ModuleContribution] { + Self.moduleContributions + } + + private func releaseFeature() { + capability?.feature.prepareForModuleRelease() + capability = nil + } +} + +public enum DatabaseModuleSleepError: LocalizedError, Sendable { + case activeWork + public var errorDescription: String? { "Database operations or scheduled backup work are still active." } +} + +@MainActor +private final class DatabaseFeatureResource: ModuleResource { + let feature: DatabaseFeatureModel + init(feature: DatabaseFeatureModel) { self.feature = feature } + var moduleResourceKind: String { "database-timer-and-operations" } + var isModuleResourceActive: Bool { feature.hasActiveModuleWork } + func stopModuleResource() async { feature.prepareForModuleRelease() } +} diff --git a/Sources/LitheDatabaseModule/Ports/DatabasePorts.swift b/Sources/LitheDatabaseModule/Ports/DatabasePorts.swift new file mode 100644 index 00000000..07667b62 --- /dev/null +++ b/Sources/LitheDatabaseModule/Ports/DatabasePorts.swift @@ -0,0 +1,58 @@ +import Foundation + +package struct DatabaseProcessRequest: Sendable { + package let executablePath: String + package let environment: [String: String]? + package let standardInput: Data? + package let timeoutMilliseconds: Int? + + package init(executablePath: String, environment: [String: String]?, standardInput: Data?, timeoutMilliseconds: Int?) { + self.executablePath = executablePath + self.environment = environment + self.standardInput = standardInput + self.timeoutMilliseconds = timeoutMilliseconds + } +} + +package struct DatabaseProcessResult: Sendable { + package let output: String + package let exitCode: Int32 + package var succeeded: Bool { exitCode == 0 } + + package init(output: String, exitCode: Int32) { + self.output = output + self.exitCode = exitCode + } +} + +package protocol DatabaseProcessRunning: Sendable { + func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult +} + +package protocol DatabasePreferenceStore: Sendable { + func data(forKey key: String) -> Data? + func set(_ value: Any?, forKey key: String) +} + +package protocol DatabaseSecureStore: Sendable { + func read(key: String) -> String? + func write(_ value: String, key: String) throws + func delete(key: String) throws +} + +package protocol DatabaseFileStorage: Sendable { + func temporaryDirectory() -> URL + func fileExists(at url: URL) -> Bool + func readData(from url: URL) throws -> Data + func copyItem(at sourceURL: URL, to destinationURL: URL) throws + func removeItem(at url: URL) throws +} + +package struct UnavailableDatabaseFileStorage: DatabaseFileStorage { + private let root = URL(fileURLWithPath: "/unavailable") + package func temporaryDirectory() -> URL { root } + package func fileExists(at url: URL) -> Bool { false } + package func readData(from url: URL) throws -> Data { throw CocoaError(.fileReadNoSuchFile) } + package func copyItem(at sourceURL: URL, to destinationURL: URL) throws { throw CocoaError(.fileNoSuchFile) } + package func removeItem(at url: URL) throws { throw CocoaError(.fileNoSuchFile) } +} diff --git a/Sources/Lithe/Core/Ports/DatabaseRecovery.swift b/Sources/LitheDatabaseModule/Ports/DatabaseRecovery.swift similarity index 53% rename from Sources/Lithe/Core/Ports/DatabaseRecovery.swift rename to Sources/LitheDatabaseModule/Ports/DatabaseRecovery.swift index 2a002b4f..c9f06e2f 100644 --- a/Sources/Lithe/Core/Ports/DatabaseRecovery.swift +++ b/Sources/LitheDatabaseModule/Ports/DatabaseRecovery.swift @@ -1,15 +1,15 @@ import Foundation -struct DatabaseBackupSchedule: Codable, Equatable, Identifiable, Sendable { - let profileID: UUID - var isEnabled: Bool - var intervalHours: Int - var retentionCount: Int - var nextRunAt: Date +package struct DatabaseBackupSchedule: Codable, Equatable, Identifiable, Sendable { + package let profileID: UUID + package var isEnabled: Bool + package var intervalHours: Int + package var retentionCount: Int + package var nextRunAt: Date - var id: UUID { profileID } + package var id: UUID { profileID } - init(profileID: UUID, isEnabled: Bool = true, intervalHours: Int = 24, retentionCount: Int = 14, nextRunAt: Date = Date()) { + package init(profileID: UUID, isEnabled: Bool = true, intervalHours: Int = 24, retentionCount: Int = 14, nextRunAt: Date = Date()) { self.profileID = profileID self.isEnabled = isEnabled self.intervalHours = max(1, intervalHours) @@ -18,20 +18,20 @@ struct DatabaseBackupSchedule: Codable, Equatable, Identifiable, Sendable { } } -struct DatabaseRecoveryPoint: Codable, Equatable, Identifiable, Sendable { - let id: UUID - let profileID: UUID - let reason: String - let createdAt: Date - let byteCount: Int - let fileName: String - let originalByteCount: Int - let isCompressed: Bool - let sha256: String +package struct DatabaseRecoveryPoint: Codable, Equatable, Identifiable, Sendable { + package let id: UUID + package let profileID: UUID + package let reason: String + package let createdAt: Date + package let byteCount: Int + package let fileName: String + package let originalByteCount: Int + package let isCompressed: Bool + package let sha256: String private enum CodingKeys: String, CodingKey { case id, profileID, reason, createdAt, byteCount, fileName, originalByteCount, isCompressed, sha256 } - init(id: UUID, profileID: UUID, reason: String, createdAt: Date, byteCount: Int, fileName: String, originalByteCount: Int, isCompressed: Bool, sha256: String = "") { + package init(id: UUID, profileID: UUID, reason: String, createdAt: Date, byteCount: Int, fileName: String, originalByteCount: Int, isCompressed: Bool, sha256: String = "") { self.id = id self.profileID = profileID self.reason = reason @@ -43,7 +43,7 @@ struct DatabaseRecoveryPoint: Codable, Equatable, Identifiable, Sendable { self.sha256 = sha256 } - init(from decoder: Decoder) throws { + package init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) profileID = try container.decode(UUID.self, forKey: .profileID) @@ -57,45 +57,45 @@ struct DatabaseRecoveryPoint: Codable, Equatable, Identifiable, Sendable { } } -struct DatabaseAuditEntry: Codable, Equatable, Identifiable, Sendable { - let id: UUID - let profileID: UUID - let action: String - let summary: String - let createdAt: Date - let recoveryPointID: UUID? - let rowsAffected: UInt64? - let succeeded: Bool - let errorMessage: String? +package struct DatabaseAuditEntry: Codable, Equatable, Identifiable, Sendable { + package let id: UUID + package let profileID: UUID + package let action: String + package let summary: String + package let createdAt: Date + package let recoveryPointID: UUID? + package let rowsAffected: UInt64? + package let succeeded: Bool + package let errorMessage: String? } -enum DatabaseExecutionSource: String, Codable, Equatable, Sendable { +package enum DatabaseExecutionSource: String, Codable, Equatable, Sendable { case sql case redis case nacos } -enum DatabaseExecutionStatus: String, Codable, Equatable, Sendable { +package enum DatabaseExecutionStatus: String, Codable, Equatable, Sendable { case succeeded case failed case cancelled } -struct DatabaseExecutionEvent: Codable, Equatable, Identifiable, Sendable { - let id: UUID - let profileID: UUID - let profileName: String - let source: DatabaseExecutionSource - let operation: String - let startedAt: Date - let durationMilliseconds: Int - let status: DatabaseExecutionStatus - let rowsReturned: Int? - let rowsAffected: UInt64? - let errorMessage: String? +package struct DatabaseExecutionEvent: Codable, Equatable, Identifiable, Sendable { + package let id: UUID + package let profileID: UUID + package let profileName: String + package let source: DatabaseExecutionSource + package let operation: String + package let startedAt: Date + package let durationMilliseconds: Int + package let status: DatabaseExecutionStatus + package let rowsReturned: Int? + package let rowsAffected: UInt64? + package let errorMessage: String? } -protocol DatabaseRecoveryStoring: AnyObject, Sendable { +package protocol DatabaseRecoveryStoring: AnyObject, Sendable { func createRecoveryPoint(profileID: UUID, reason: String, data: Data) throws -> DatabaseRecoveryPoint func createRecoveryPoint(profileID: UUID, reason: String, fileURL: URL, expectedSHA256: String, progress: ((Double) -> Void)?) throws -> DatabaseRecoveryPoint func recoveryPoints(for profileID: UUID?) -> [DatabaseRecoveryPoint] @@ -111,20 +111,20 @@ protocol DatabaseRecoveryStoring: AnyObject, Sendable { final class UnavailableDatabaseRecoveryStore: DatabaseRecoveryStoring, @unchecked Sendable { private let error = CocoaError(.featureUnsupported) - func createRecoveryPoint(profileID: UUID, reason: String, data: Data) throws -> DatabaseRecoveryPoint { throw error } - func createRecoveryPoint(profileID: UUID, reason: String, fileURL: URL, expectedSHA256: String, progress: ((Double) -> Void)?) throws -> DatabaseRecoveryPoint { throw error } - func recoveryPoints(for profileID: UUID?) -> [DatabaseRecoveryPoint] { [] } - func data(for point: DatabaseRecoveryPoint) throws -> Data { throw error } - func fileURL(for point: DatabaseRecoveryPoint) throws -> URL { throw error } - func delete(_ point: DatabaseRecoveryPoint) throws { throw error } - func appendAudit(_ entry: DatabaseAuditEntry, maximumEntries: Int) throws { throw error } - func auditEntries(for profileID: UUID?) -> [DatabaseAuditEntry] { [] } - func appendExecutionEvent(_ event: DatabaseExecutionEvent, maximumEntries: Int) throws { throw error } - func executionEvents(for profileID: UUID?) -> [DatabaseExecutionEvent] { [] } - func deleteExecutionEvents(for profileID: UUID) throws { throw error } + package func createRecoveryPoint(profileID: UUID, reason: String, data: Data) throws -> DatabaseRecoveryPoint { throw error } + package func createRecoveryPoint(profileID: UUID, reason: String, fileURL: URL, expectedSHA256: String, progress: ((Double) -> Void)?) throws -> DatabaseRecoveryPoint { throw error } + package func recoveryPoints(for profileID: UUID?) -> [DatabaseRecoveryPoint] { [] } + package func data(for point: DatabaseRecoveryPoint) throws -> Data { throw error } + package func fileURL(for point: DatabaseRecoveryPoint) throws -> URL { throw error } + package func delete(_ point: DatabaseRecoveryPoint) throws { throw error } + package func appendAudit(_ entry: DatabaseAuditEntry, maximumEntries: Int) throws { throw error } + package func auditEntries(for profileID: UUID?) -> [DatabaseAuditEntry] { [] } + package func appendExecutionEvent(_ event: DatabaseExecutionEvent, maximumEntries: Int) throws { throw error } + package func executionEvents(for profileID: UUID?) -> [DatabaseExecutionEvent] { [] } + package func deleteExecutionEvents(for profileID: UUID) throws { throw error } } -extension DatabaseRecoveryStoring { +package extension DatabaseRecoveryStoring { func createRecoveryPoint(profileID: UUID, reason: String, fileURL: URL, expectedSHA256: String = "", progress: ((Double) -> Void)? = nil) throws -> DatabaseRecoveryPoint { try createRecoveryPoint(profileID: profileID, reason: reason, fileURL: fileURL, expectedSHA256: expectedSHA256, progress: progress) } diff --git a/Sources/Lithe/Services/DatabaseConnectionStore.swift b/Sources/LitheDatabaseModule/Services/DatabaseConnectionStore.swift similarity index 63% rename from Sources/Lithe/Services/DatabaseConnectionStore.swift rename to Sources/LitheDatabaseModule/Services/DatabaseConnectionStore.swift index 3293df21..870bbedd 100644 --- a/Sources/Lithe/Services/DatabaseConnectionStore.swift +++ b/Sources/LitheDatabaseModule/Services/DatabaseConnectionStore.swift @@ -1,32 +1,32 @@ import Foundation -struct DatabaseProfile: Codable, Equatable, Identifiable, Sendable { - let id: UUID - var name: String - var kind: DatabaseKind - var host: String - var port: UInt16 - var username: String - var database: String - var path: String - var ssl: Bool +package struct DatabaseProfile: Codable, Equatable, Identifiable, Sendable { + package let id: UUID + package var name: String + package var kind: DatabaseKind + package var host: String + package var port: UInt16 + package var username: String + package var database: String + package var path: String + package var ssl: Bool /// Legacy display grouping. New profiles use folderID; retain this field so /// older saved profiles can be migrated without losing the user's grouping. - var group: String - var folderID: UUID? - var colorHex: String - var readOnly: Bool - var productionProtection: Bool - var maskSensitiveFields: Bool - var sensitiveColumnPatterns: [String] - var caCertificatePath: String - var serverName: String - var sshHost: String - var sshPort: UInt16 - var sshUsername: String - var sshKeyPath: String - var sshLocalPort: UInt16 - var proxyURL: String + package var group: String + package var folderID: UUID? + package var colorHex: String + package var readOnly: Bool + package var productionProtection: Bool + package var maskSensitiveFields: Bool + package var sensitiveColumnPatterns: [String] + package var caCertificatePath: String + package var serverName: String + package var sshHost: String + package var sshPort: UInt16 + package var sshUsername: String + package var sshKeyPath: String + package var sshLocalPort: UInt16 + package var proxyURL: String private enum CodingKeys: String, CodingKey { case id, name, kind, host, port, username, database, path, ssl, group, folderID, colorHex @@ -34,14 +34,14 @@ struct DatabaseProfile: Codable, Equatable, Identifiable, Sendable { case caCertificatePath, serverName, sshHost, sshPort, sshUsername, sshKeyPath, sshLocalPort, proxyURL } - init(id: UUID = UUID(), name: String, kind: DatabaseKind, host: String = "127.0.0.1", port: UInt16 = 0, username: String = "", database: String = "", path: String = "", ssl: Bool = false, group: String = "", folderID: UUID? = nil, colorHex: String = "", readOnly: Bool = false, productionProtection: Bool = false, maskSensitiveFields: Bool = false, sensitiveColumnPatterns: [String] = ["password", "secret", "token", "api_key"], caCertificatePath: String = "", serverName: String = "", sshHost: String = "", sshPort: UInt16 = 0, sshUsername: String = "", sshKeyPath: String = "", sshLocalPort: UInt16 = 0, proxyURL: String = "") { + package init(id: UUID = UUID(), name: String, kind: DatabaseKind, host: String = "127.0.0.1", port: UInt16 = 0, username: String = "", database: String = "", path: String = "", ssl: Bool = false, group: String = "", folderID: UUID? = nil, colorHex: String = "", readOnly: Bool = false, productionProtection: Bool = false, maskSensitiveFields: Bool = false, sensitiveColumnPatterns: [String] = ["password", "secret", "token", "api_key"], caCertificatePath: String = "", serverName: String = "", sshHost: String = "", sshPort: UInt16 = 0, sshUsername: String = "", sshKeyPath: String = "", sshLocalPort: UInt16 = 0, proxyURL: String = "") { self.id = id; self.name = name; self.kind = kind; self.host = host; self.port = port self.username = username; self.database = database; self.path = path; self.ssl = ssl self.group = group; self.folderID = folderID; self.colorHex = colorHex; self.readOnly = readOnly; self.productionProtection = productionProtection; self.maskSensitiveFields = maskSensitiveFields; self.sensitiveColumnPatterns = sensitiveColumnPatterns self.caCertificatePath = caCertificatePath; self.serverName = serverName; self.sshHost = sshHost; self.sshPort = sshPort; self.sshUsername = sshUsername; self.sshKeyPath = sshKeyPath; self.sshLocalPort = sshLocalPort; self.proxyURL = proxyURL } - init(from decoder: Decoder) throws { + package init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(UUID.self, forKey: .id) name = try container.decode(String.self, forKey: .name) @@ -70,80 +70,80 @@ struct DatabaseProfile: Codable, Equatable, Identifiable, Sendable { } } -struct DatabaseConnectionFolder: Codable, Equatable, Identifiable, Sendable { - let id: UUID - var name: String - var parentID: UUID? +package struct DatabaseConnectionFolder: Codable, Equatable, Identifiable, Sendable { + package let id: UUID + package var name: String + package var parentID: UUID? - init(id: UUID = UUID(), name: String, parentID: UUID? = nil) { + package init(id: UUID = UUID(), name: String, parentID: UUID? = nil) { self.id = id; self.name = name; self.parentID = parentID } } -final class DatabaseConnectionStore: @unchecked Sendable { +package final class DatabaseConnectionStore: @unchecked Sendable { private static let profilesKey = "database.profiles.v1" private static let foldersKey = "database.connection-folders.v1" private static let sqlHistoryKey = "database.sql-history.v1" private static let backupSchedulesKey = "database.backup-schedules.v1" private static let maximumHistoryEntries = 100 - private let store: any KeyValueStore - private let secureStore: any SecureStore + private let store: any DatabasePreferenceStore + private let secureStore: any DatabaseSecureStore - init(store: any KeyValueStore, secureStore: any SecureStore) { + package init(store: any DatabasePreferenceStore, secureStore: any DatabaseSecureStore) { self.store = store self.secureStore = secureStore } - func load() -> [DatabaseProfile] { + package func load() -> [DatabaseProfile] { guard let data = store.data(forKey: Self.profilesKey) else { return [] } return (try? JSONDecoder().decode([DatabaseProfile].self, from: data)) ?? [] } - func save(_ profiles: [DatabaseProfile]) throws { + package func save(_ profiles: [DatabaseProfile]) throws { store.set(try JSONEncoder().encode(profiles), forKey: Self.profilesKey) } - func loadFolders() -> [DatabaseConnectionFolder] { + package func loadFolders() -> [DatabaseConnectionFolder] { guard let data = store.data(forKey: Self.foldersKey) else { return [] } return (try? JSONDecoder().decode([DatabaseConnectionFolder].self, from: data)) ?? [] } - func saveFolders(_ folders: [DatabaseConnectionFolder]) throws { + package func saveFolders(_ folders: [DatabaseConnectionFolder]) throws { store.set(try JSONEncoder().encode(folders), forKey: Self.foldersKey) } - func loadSQLHistory() -> [DatabaseSQLHistoryEntry] { + package func loadSQLHistory() -> [DatabaseSQLHistoryEntry] { guard let data = store.data(forKey: Self.sqlHistoryKey) else { return [] } return (try? JSONDecoder().decode([DatabaseSQLHistoryEntry].self, from: data)) ?? [] } - func appendSQLHistory(_ entry: DatabaseSQLHistoryEntry) throws { + package func appendSQLHistory(_ entry: DatabaseSQLHistoryEntry) throws { var entries = loadSQLHistory().filter { $0.id != entry.id } entries.insert(entry, at: 0) store.set(try JSONEncoder().encode(Array(entries.prefix(Self.maximumHistoryEntries))), forKey: Self.sqlHistoryKey) } - func deleteSQLHistory(for profileID: UUID) throws { + package func deleteSQLHistory(for profileID: UUID) throws { let remaining = loadSQLHistory().filter { $0.profileID != profileID } store.set(try JSONEncoder().encode(remaining), forKey: Self.sqlHistoryKey) } - func loadBackupSchedules() -> [DatabaseBackupSchedule] { + package func loadBackupSchedules() -> [DatabaseBackupSchedule] { guard let data = store.data(forKey: Self.backupSchedulesKey) else { return [] } return (try? JSONDecoder().decode([DatabaseBackupSchedule].self, from: data)) ?? [] } - func saveBackupSchedules(_ schedules: [DatabaseBackupSchedule]) throws { + package func saveBackupSchedules(_ schedules: [DatabaseBackupSchedule]) throws { store.set(try JSONEncoder().encode(schedules), forKey: Self.backupSchedulesKey) } - func deleteBackupSchedule(for profileID: UUID) throws { + package func deleteBackupSchedule(for profileID: UUID) throws { try saveBackupSchedules(loadBackupSchedules().filter { $0.profileID != profileID }) } - func password(for id: UUID) -> String { secureStore.read(key: passwordKey(id)) ?? "" } - func hasPassword(for id: UUID) -> Bool { secureStore.read(key: passwordKey(id)) != nil } - func savePassword(_ password: String, for id: UUID) throws { try secureStore.write(password, key: passwordKey(id)) } - func deletePassword(for id: UUID) throws { try secureStore.delete(key: passwordKey(id)) } + package func password(for id: UUID) -> String { secureStore.read(key: passwordKey(id)) ?? "" } + package func hasPassword(for id: UUID) -> Bool { secureStore.read(key: passwordKey(id)) != nil } + package func savePassword(_ password: String, for id: UUID) throws { try secureStore.write(password, key: passwordKey(id)) } + package func deletePassword(for id: UUID) throws { try secureStore.delete(key: passwordKey(id)) } private func passwordKey(_ id: UUID) -> String { "database.connection.\(id.uuidString).password" } } diff --git a/Sources/Lithe/Services/DatabaseDBXImportService.swift b/Sources/LitheDatabaseModule/Services/DatabaseDBXImportService.swift similarity index 80% rename from Sources/Lithe/Services/DatabaseDBXImportService.swift rename to Sources/LitheDatabaseModule/Services/DatabaseDBXImportService.swift index 99edfa14..9dba1619 100644 --- a/Sources/Lithe/Services/DatabaseDBXImportService.swift +++ b/Sources/LitheDatabaseModule/Services/DatabaseDBXImportService.swift @@ -1,40 +1,40 @@ import CryptoKit import Foundation -struct DatabaseDBXImportFolder: Equatable, Identifiable, Sendable { - let id: UUID - let name: String - let parentID: UUID? +package struct DatabaseDBXImportFolder: Equatable, Identifiable, Sendable { + package let id: UUID + package let name: String + package let parentID: UUID? } -struct DatabaseDBXImportCandidate: Equatable, Identifiable, Sendable { - let sourceID: String - var profile: DatabaseProfile - let password: String - let warnings: [String] - let isDuplicate: Bool +package struct DatabaseDBXImportCandidate: Equatable, Identifiable, Sendable { + package let sourceID: String + package var profile: DatabaseProfile + package let password: String + package let warnings: [String] + package let isDuplicate: Bool - var id: UUID { profile.id } + package var id: UUID { profile.id } } -struct DatabaseDBXImportPlan: Equatable, Sendable { - let candidates: [DatabaseDBXImportCandidate] - let folders: [DatabaseDBXImportFolder] - let unsupportedTypes: [String: Int] - let wasEncrypted: Bool +package struct DatabaseDBXImportPlan: Equatable, Sendable { + package let candidates: [DatabaseDBXImportCandidate] + package let folders: [DatabaseDBXImportFolder] + package let unsupportedTypes: [String: Int] + package let wasEncrypted: Bool - var importableCount: Int { candidates.count { !$0.isDuplicate } } - var duplicateCount: Int { candidates.count { $0.isDuplicate } } - var unsupportedCount: Int { unsupportedTypes.values.reduce(0, +) } + package var importableCount: Int { candidates.count { !$0.isDuplicate } } + package var duplicateCount: Int { candidates.count { $0.isDuplicate } } + package var unsupportedCount: Int { unsupportedTypes.values.reduce(0, +) } } -enum DatabaseDBXImportError: LocalizedError, Equatable { +package enum DatabaseDBXImportError: LocalizedError, Equatable { case invalidFile case passphraseRequired case wrongPassphrase case unsupportedEncryptedFormat - var errorDescription: String? { + package var errorDescription: String? { switch self { case .invalidFile: "The selected file is not a valid DBX connection export." case .passphraseRequired: "Enter the DBX export password to read this file." @@ -44,13 +44,14 @@ enum DatabaseDBXImportError: LocalizedError, Equatable { } } -struct DatabaseDBXImportService: Sendable { - func isEncrypted(_ data: Data) -> Bool { +package struct DatabaseDBXImportService: Sendable { + package init() {} + package func isEncrypted(_ data: Data) -> Bool { guard let envelope = try? JSONDecoder().decode(DBXEncryptedEnvelope.self, from: data) else { return false } return envelope.format == "dbx-encrypted" } - func parse( + package func parse( data: Data, passphrase: String?, existingProfiles: [DatabaseProfile] @@ -248,38 +249,38 @@ struct DatabaseDBXImportService: Sendable { } private struct DBXEncryptedEnvelope: Decodable { - let format: String - let version: Int - let salt: String - let iv: String - let data: String + package let format: String + package let version: Int + package let salt: String + package let iv: String + package let data: String } private struct DBXExport: Decodable { - let connections: [DBXConnection] - let layout: DBXLayout? + package let connections: [DBXConnection] + package let layout: DBXLayout? } private struct DBXConnection: Decodable { - let id: String - let name: String - let dbType: String - let host: String - let port: Int - let username: String - let password: String - let database: String? - let color: String? - let readOnly: Bool? - let isProduction: Bool? - let ssl: Bool? - let caCertPath: String? - let clientCertPath: String? - let clientKeyPath: String? - let connectionString: String? - let urlParams: String? - let transportLayers: [DBXTransportLayer]? - let redisConnectionMode: String? + package let id: String + package let name: String + package let dbType: String + package let host: String + package let port: Int + package let username: String + package let password: String + package let database: String? + package let color: String? + package let readOnly: Bool? + package let isProduction: Bool? + package let ssl: Bool? + package let caCertPath: String? + package let clientCertPath: String? + package let clientKeyPath: String? + package let connectionString: String? + package let urlParams: String? + package let transportLayers: [DBXTransportLayer]? + package let redisConnectionMode: String? private enum CodingKeys: String, CodingKey { case id, name, host, port, username, password, database, color, ssl @@ -297,14 +298,14 @@ private struct DBXConnection: Decodable { } private struct DBXTransportLayer: Decodable { - let type: String - let enabled: Bool? - let host: String - let port: Int - let user: String? - let password: String? - let keyPath: String? - let proxyType: String? + package let type: String + package let enabled: Bool? + package let host: String + package let port: Int + package let user: String? + package let password: String? + package let keyPath: String? + package let proxyType: String? private enum CodingKeys: String, CodingKey { case type, enabled, host, port, user, password @@ -312,7 +313,7 @@ private struct DBXTransportLayer: Decodable { case proxyType = "proxy_type" } - init(from decoder: Decoder) throws { + package init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) type = try container.decodeIfPresent(String.self, forKey: .type) ?? "" enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) @@ -326,25 +327,25 @@ private struct DBXTransportLayer: Decodable { } private struct DBXLayout: Decodable { - let groups: [DBXGroup] - let order: [DBXOrderEntry] + package let groups: [DBXGroup] + package let order: [DBXOrderEntry] } private struct DBXGroup: Decodable { - let id: String - let name: String + package let id: String + package let name: String } private struct DBXOrderEntry: Decodable { - let type: String - let id: String - let children: [DBXOrderEntry]? - let connectionIds: [String]? + package let type: String + package let id: String + package let children: [DBXOrderEntry]? + package let connectionIds: [String]? } private struct DBXResolvedLayout { - let folders: [DatabaseDBXImportFolder] - let connectionFolderIDs: [String: UUID] + package let folders: [DatabaseDBXImportFolder] + package let connectionFolderIDs: [String: UUID] } private extension String { diff --git a/Sources/Lithe/Services/DatabaseSidecarService.swift b/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift similarity index 63% rename from Sources/Lithe/Services/DatabaseSidecarService.swift rename to Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift index 62f57ae2..9795cde8 100644 --- a/Sources/Lithe/Services/DatabaseSidecarService.swift +++ b/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift @@ -1,6 +1,6 @@ import Foundation -enum DatabaseKind: String, Codable, CaseIterable, Sendable { +package enum DatabaseKind: String, Codable, CaseIterable, Sendable { case mysql case mariadb case postgresql @@ -10,35 +10,35 @@ enum DatabaseKind: String, Codable, CaseIterable, Sendable { case redis case nacos - var isSQLDatabase: Bool { + package var isSQLDatabase: Bool { switch self { case .mysql, .mariadb, .postgresql, .sqlite, .sqlserver: true case .mongodb, .redis, .nacos: false } } - var supportsDataGrid: Bool { isSQLDatabase || self == .mongodb } -} - -struct DatabaseConnection: Codable, Equatable, Sendable { - let kind: DatabaseKind - var host = "" - var port: UInt16 = 0 - var username = "" - var password = "" - var database = "" - var path = "" - var ssl = false - var caCertificatePath = "" - var serverName = "" - var sshHost = "" - var sshPort: UInt16 = 0 - var sshUsername = "" - var sshKeyPath = "" - var sshLocalPort: UInt16 = 0 - var proxyURL = "" - var readOnly = false - var productionProtection = false + package var supportsDataGrid: Bool { isSQLDatabase || self == .mongodb } +} + +package struct DatabaseConnection: Codable, Equatable, Sendable { + package let kind: DatabaseKind + package var host = "" + package var port: UInt16 = 0 + package var username = "" + package var password = "" + package var database = "" + package var path = "" + package var ssl = false + package var caCertificatePath = "" + package var serverName = "" + package var sshHost = "" + package var sshPort: UInt16 = 0 + package var sshUsername = "" + package var sshKeyPath = "" + package var sshLocalPort: UInt16 = 0 + package var proxyURL = "" + package var readOnly = false + package var productionProtection = false private enum CodingKeys: String, CodingKey { case kind, host, port, username, password, database, path, ssl @@ -46,7 +46,7 @@ struct DatabaseConnection: Codable, Equatable, Sendable { case readOnly, productionProtection } - init(from decoder: Decoder) throws { + package init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) kind = try container.decode(DatabaseKind.self, forKey: .kind) host = try container.decodeIfPresent(String.self, forKey: .host) ?? "" @@ -68,7 +68,7 @@ struct DatabaseConnection: Codable, Equatable, Sendable { productionProtection = try container.decodeIfPresent(Bool.self, forKey: .productionProtection) ?? false } - init( + package init( kind: DatabaseKind, host: String = "", port: UInt16 = 0, @@ -109,100 +109,101 @@ struct DatabaseConnection: Codable, Equatable, Sendable { } } -struct DatabaseCapabilities: Codable, Equatable, Sendable { - let protocolVersion: Int - let databaseTypes: [String] - let features: [String] +package struct DatabaseCapabilities: Codable, Equatable, Sendable { + package let protocolVersion: Int + package let databaseTypes: [String] + package let features: [String] } -struct DatabaseSQLFileExportResult: Codable, Equatable, Sendable { - let path: String - let byteCount: Int - let sha256: String +package struct DatabaseSQLFileExportResult: Codable, Equatable, Sendable { + package let path: String + package let byteCount: Int + package let sha256: String } /// Redis and Nacos are deliberately modeled as specialised workspaces rather /// than as SQL tables. Keeping their protocol types separate prevents callers /// from accidentally issuing SQL-style operations to a non-SQL service. -struct RedisKeySummary: Codable, Equatable, Identifiable, Sendable { - let key: String - let type: String - let ttl: Int64 - let size: Int64 +package struct RedisKeySummary: Codable, Equatable, Identifiable, Sendable { + package let key: String + package let type: String + package let ttl: Int64 + package let size: Int64 - var id: String { key } + package var id: String { key } } -struct RedisScanResult: Codable, Equatable, Sendable { - let keys: [RedisKeySummary] - let nextCursor: String +package struct RedisScanResult: Codable, Equatable, Sendable { + package let keys: [RedisKeySummary] + package let nextCursor: String } -struct RedisHashEntry: Codable, Equatable, Identifiable, Sendable { - let field: String - let value: String +package struct RedisHashEntry: Codable, Equatable, Identifiable, Sendable { + package let field: String + package let value: String - var id: String { field } + package var id: String { field } + package init(field: String, value: String) { self.field = field; self.value = value } } -struct RedisKeyDetail: Codable, Equatable, Sendable { - let key: String - let type: String - let ttl: Int64 - let size: Int64 - let stringValue: String? - let hashEntries: [RedisHashEntry] +package struct RedisKeyDetail: Codable, Equatable, Sendable { + package let key: String + package let type: String + package let ttl: Int64 + package let size: Int64 + package let stringValue: String? + package let hashEntries: [RedisHashEntry] } -struct NacosConfigSummary: Codable, Equatable, Identifiable, Sendable { - let dataId: String - let group: String - let namespace: String - let type: String? - let md5: String? +package struct NacosConfigSummary: Codable, Equatable, Identifiable, Sendable { + package let dataId: String + package let group: String + package let namespace: String + package let type: String? + package let md5: String? - var id: String { "\(namespace)|\(group)|\(dataId)" } + package var id: String { "\(namespace)|\(group)|\(dataId)" } } -struct NacosConfigList: Codable, Equatable, Sendable { - let items: [NacosConfigSummary] - let totalCount: Int +package struct NacosConfigList: Codable, Equatable, Sendable { + package let items: [NacosConfigSummary] + package let totalCount: Int } -struct NacosConfigDetail: Codable, Equatable, Sendable { - let dataId: String - let group: String - let namespace: String - let content: String - let type: String? - let md5: String? +package struct NacosConfigDetail: Codable, Equatable, Sendable { + package let dataId: String + package let group: String + package let namespace: String + package let content: String + package let type: String? + package let md5: String? } -struct NacosServiceSummary: Codable, Equatable, Identifiable, Sendable { - let name: String - let group: String - let clusterCount: Int +package struct NacosServiceSummary: Codable, Equatable, Identifiable, Sendable { + package let name: String + package let group: String + package let clusterCount: Int - var id: String { "\(group)|\(name)" } + package var id: String { "\(group)|\(name)" } } -struct NacosServiceList: Codable, Equatable, Sendable { - let items: [NacosServiceSummary] - let totalCount: Int +package struct NacosServiceList: Codable, Equatable, Sendable { + package let items: [NacosServiceSummary] + package let totalCount: Int } -struct NacosInstanceSummary: Codable, Equatable, Identifiable, Sendable { - let ip: String - let port: Int - let healthy: Bool - let enabled: Bool - let ephemeral: Bool - let clusterName: String? +package struct NacosInstanceSummary: Codable, Equatable, Identifiable, Sendable { + package let ip: String + package let port: Int + package let healthy: Bool + package let enabled: Bool + package let ephemeral: Bool + package let clusterName: String? - var id: String { "\(ip):\(port):\(clusterName ?? "")" } + package var id: String { "\(ip):\(port):\(clusterName ?? "")" } } -enum DatabaseValue: Codable, Equatable, Sendable { +package enum DatabaseValue: Codable, Equatable, Sendable { case null case bool(Bool) case integer(Int64) @@ -213,7 +214,7 @@ enum DatabaseValue: Codable, Equatable, Sendable { case object([String: DatabaseValue]) case array([DatabaseValue]) - init(from decoder: Decoder) throws { + package 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) } @@ -231,7 +232,7 @@ enum DatabaseValue: Codable, Equatable, Sendable { else { self = .array(try container.decode([DatabaseValue].self)) } } - func encode(to encoder: Encoder) throws { + package func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { case .null: try container.encodeNil() @@ -249,7 +250,7 @@ enum DatabaseValue: Codable, Equatable, Sendable { /// A stable value representation for grids, details panels, and metadata. /// Keeping this on the protocol value prevents an empty string from being /// rendered like a missing value in one of the database workspaces. - var displayText: String { + package var displayText: String { switch self { case .null: "NULL" case let .bool(value): value ? "true" : "false" @@ -269,15 +270,15 @@ enum DatabaseValue: Codable, Equatable, Sendable { } } -typealias DatabaseRow = [String: DatabaseValue] +package typealias DatabaseRow = [String: DatabaseValue] -struct DatabaseQueryResult: Codable, Equatable, Sendable { - let rows: [DatabaseRow] - let columns: [String]? - let truncated: Bool - var totalRows: Int64? +package struct DatabaseQueryResult: Codable, Equatable, Sendable { + package let rows: [DatabaseRow] + package let columns: [String]? + package let truncated: Bool + package var totalRows: Int64? - init(rows: [DatabaseRow], columns: [String]? = nil, truncated: Bool, totalRows: Int64? = nil) { + package init(rows: [DatabaseRow], columns: [String]? = nil, truncated: Bool, totalRows: Int64? = nil) { self.rows = rows self.columns = columns self.truncated = truncated @@ -286,7 +287,7 @@ struct DatabaseQueryResult: Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case rows, columns, truncated, totalRows } - init(from decoder: Decoder) throws { + package init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) rows = try container.decode([DatabaseRow].self, forKey: .rows) columns = try container.decodeIfPresent([String].self, forKey: .columns) @@ -295,39 +296,46 @@ struct DatabaseQueryResult: Codable, Equatable, Sendable { } } -struct DatabaseExecuteResult: Codable, Equatable, Sendable { - let rowsAffected: UInt64 +package struct DatabaseExecuteResult: Codable, Equatable, Sendable { + package let rowsAffected: UInt64 } -enum DatabaseMutationAction: String, Codable, Sendable { case insert, update, delete } +package enum DatabaseMutationAction: String, Codable, Sendable { case insert, update, delete } -struct DatabaseMutation: Codable, Equatable, Sendable { - let action: DatabaseMutationAction - let table: String - var values: DatabaseRow = [:] - var key: DatabaseRow = [:] +package struct DatabaseMutation: Codable, Equatable, Sendable { + package let action: DatabaseMutationAction + package let table: String + package var values: DatabaseRow = [:] + package var key: DatabaseRow = [:] } -enum DatabaseFilterOperator: String, Codable, CaseIterable, Sendable { case equals, notEquals, greaterThan, lessThan, contains, startsWith, isNull, isNotNull } -enum DatabaseFilterJoin: String, Codable, CaseIterable, Sendable { case and, or } -struct DatabaseFilter: Codable, Equatable, Sendable { - let column: String - let `operator`: DatabaseFilterOperator - var value: DatabaseValue = .null - var join: DatabaseFilterJoin = .and +package enum DatabaseFilterOperator: String, Codable, CaseIterable, Sendable { case equals, notEquals, greaterThan, lessThan, contains, startsWith, isNull, isNotNull } +package enum DatabaseFilterJoin: String, Codable, CaseIterable, Sendable { case and, or } +package struct DatabaseFilter: Codable, Equatable, Sendable { + package let column: String + package let `operator`: DatabaseFilterOperator + package var value: DatabaseValue = .null + package var join: DatabaseFilterJoin = .and + package init(column: String, operator: DatabaseFilterOperator = .equals, value: DatabaseValue = .null, join: DatabaseFilterJoin = .and) { + self.column = column; self.operator = `operator`; self.value = value; self.join = join + } +} +package struct DatabaseSort: Codable, Equatable, Sendable { + package let column: String + package var descending = false + package init(column: String, descending: Bool = false) { self.column = column; self.descending = descending } } -struct DatabaseSort: Codable, Equatable, Sendable { let column: String; var descending = false } -struct DatabaseSQLExportOptions: Codable, Equatable, Sendable { - var schema = "" - var selectedTables: [String] = [] - var includeStructure = true - var includeData = true +package struct DatabaseSQLExportOptions: Codable, Equatable, Sendable { + package var schema = "" + package var selectedTables: [String] = [] + package var includeStructure = true + package var includeData = true // A SQL backup must be complete. Zero is the sidecar protocol's explicit // unbounded sentinel; the sidecar streams rows directly to the output. - var limit = 0 + package var limit = 0 } -enum DatabaseObjectKind: String, Codable, CaseIterable, Sendable { +package enum DatabaseObjectKind: String, Codable, CaseIterable, Sendable { case tables case views case routines @@ -335,34 +343,38 @@ enum DatabaseObjectKind: String, Codable, CaseIterable, Sendable { case sequences } -struct DatabaseSchemaChange: Codable, Equatable, Sendable { - var operation: String - var table = "" - var name = "" - var oldName = "" - var dataType = "" - var nullable = true - var defaultValue = "" - var indexName = "" - var indexColumns: [String] = [] - var constraintName = "" - var referencedTable = "" - var referencedColumns: [String] = [] - var sql = "" +package struct DatabaseSchemaChange: Codable, Equatable, Sendable { + package var operation: String + package var table = "" + package var name = "" + package var oldName = "" + package var dataType = "" + package var nullable = true + package var defaultValue = "" + package var indexName = "" + package var indexColumns: [String] = [] + package var constraintName = "" + package var referencedTable = "" + package var referencedColumns: [String] = [] + package var sql = "" + package init(operation: String, table: String = "", name: String = "", oldName: String = "", dataType: String = "", nullable: Bool = true, defaultValue: String = "", indexName: String = "", indexColumns: [String] = [], constraintName: String = "", referencedTable: String = "", referencedColumns: [String] = [], sql: String = "") { + self.operation = operation; self.table = table; self.name = name; self.oldName = oldName; self.dataType = dataType; self.nullable = nullable; self.defaultValue = defaultValue; self.indexName = indexName; self.indexColumns = indexColumns; self.constraintName = constraintName; self.referencedTable = referencedTable; self.referencedColumns = referencedColumns; self.sql = sql + } } -struct DatabaseTransactionStatement: Codable, Equatable, Sendable { - let sql: String - var values: [DatabaseValue] = [] +package struct DatabaseTransactionStatement: Codable, Equatable, Sendable { + package let sql: String + package var values: [DatabaseValue] = [] } -struct DatabaseDiagnosticsRequest: Codable, Equatable, Sendable { - var kind = "tableSize" - var schema = "" - var table = "" +package struct DatabaseDiagnosticsRequest: Codable, Equatable, Sendable { + package var kind = "tableSize" + package var schema = "" + package var table = "" + package init(kind: String = "tableSize", schema: String = "", table: String = "") { self.kind = kind; self.schema = schema; self.table = table } } -protocol DatabaseOperations: Sendable { +package protocol DatabaseOperations: Sendable { func capabilities() throws -> DatabaseCapabilities func testConnection(_ connection: DatabaseConnection) throws func listDatabases(connection: DatabaseConnection) throws -> [String] @@ -407,7 +419,7 @@ protocol DatabaseOperations: Sendable { func nacosListInstances(connection: DatabaseConnection, serviceName: String, group: String) throws -> [NacosInstanceSummary] } -extension DatabaseOperations { +package extension DatabaseOperations { func listDatabases(connection: DatabaseConnection) throws -> [String] { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Database selection is not available for this database service.") } func redisScan(connection: DatabaseConnection, cursor: String, pattern: String, count: Int) throws -> RedisScanResult { try redisScan(connection: connection, cursor: cursor, pattern: pattern, count: count, includeSize: true) @@ -428,13 +440,13 @@ extension DatabaseOperations { func nacosListInstances(connection: DatabaseConnection, serviceName: String, group: String) throws -> [NacosInstanceSummary] { throw DatabaseSidecarError.requestFailed(code: "unsupported_operation", message: "Nacos is not available in this database service.") } } -enum DatabaseSidecarError: LocalizedError, Equatable { +package enum DatabaseSidecarError: LocalizedError, Equatable { case executableNotFound case processFailed(exitCode: Int32, output: String) case invalidResponse(String) case requestFailed(code: String, message: String) - var errorDescription: String? { + package var errorDescription: String? { switch self { case .executableNotFound: return "The Lithe database helper is not installed." case let .processFailed(exitCode, output): return "The database helper exited with code \(exitCode): \(Self.bounded(output))" @@ -453,105 +465,105 @@ enum DatabaseSidecarError: LocalizedError, Equatable { /// Executes the independently packaged database core only on demand. Connection /// secrets are sent over stdin and are never included in arguments or logs. -final class DatabaseSidecarService: DatabaseOperations, @unchecked Sendable { - private let processRunner: any ProcessRunner +package final class DatabaseSidecarService: DatabaseOperations, @unchecked Sendable { + private let processRunner: any DatabaseProcessRunning private let executableURL: URL? private let environment: [String: String]? - init(processRunner: any ProcessRunner, executableURL: URL?, environment: [String: String]? = nil) { + package init(processRunner: any DatabaseProcessRunning, executableURL: URL?, environment: [String: String]? = nil) { self.processRunner = processRunner self.executableURL = executableURL self.environment = environment } - func capabilities() throws -> DatabaseCapabilities { + package func capabilities() throws -> DatabaseCapabilities { try request(method: "capabilities", params: EmptyParams()) } - func testConnection(_ connection: DatabaseConnection) throws { + package func testConnection(_ connection: DatabaseConnection) throws { let _: ConnectedResult = try request(method: "testConnection", params: ConnectionParams(connection: connection)) } - func listDatabases(connection: DatabaseConnection) throws -> [String] { + package func listDatabases(connection: DatabaseConnection) throws -> [String] { try request(method: "listDatabases", params: ConnectionParams(connection: connection)) } - func listTables(connection: DatabaseConnection, schema: String = "") throws -> [DatabaseRow] { + package func listTables(connection: DatabaseConnection, schema: String = "") throws -> [DatabaseRow] { let result: DatabaseRowsResult = try request(method: "listTables", params: TableParams(connection: connection, schema: schema)) return result.rows } - func describeTable(connection: DatabaseConnection, schema: String = "", table: String) throws -> [DatabaseRow] { + package func describeTable(connection: DatabaseConnection, schema: String = "", table: String) throws -> [DatabaseRow] { let result: DatabaseRowsResult = try request(method: "describeTable", params: TableParams(connection: connection, schema: schema, table: table)) return result.rows } - func listIndexes(connection: DatabaseConnection, schema: String = "", table: String) throws -> [DatabaseRow] { + package func listIndexes(connection: DatabaseConnection, schema: String = "", table: String) throws -> [DatabaseRow] { let result: DatabaseRowsResult = try request(method: "listIndexes", params: TableParams(connection: connection, schema: schema, table: table)) return result.rows } - func listForeignKeys(connection: DatabaseConnection, schema: String = "", table: String) throws -> [DatabaseRow] { + package func listForeignKeys(connection: DatabaseConnection, schema: String = "", table: String) throws -> [DatabaseRow] { let result: DatabaseRowsResult = try request(method: "listForeignKeys", params: TableParams(connection: connection, schema: schema, table: table)) return result.rows } - func listObjects(connection: DatabaseConnection, schema: String = "", kind: DatabaseObjectKind) throws -> [DatabaseRow] { + package func listObjects(connection: DatabaseConnection, schema: String = "", kind: DatabaseObjectKind) throws -> [DatabaseRow] { let result: DatabaseRowsResult = try request(method: "listObjects", params: ObjectParams(connection: connection, schema: schema, objectKind: kind.rawValue)) return result.rows } - func pageTable(connection: DatabaseConnection, schema: String = "", table: String, limit: Int = 200, offset: Int = 0, filters: [DatabaseFilter] = [], sort: [DatabaseSort] = []) throws -> DatabaseQueryResult { + package func pageTable(connection: DatabaseConnection, schema: String = "", table: String, limit: Int = 200, offset: Int = 0, filters: [DatabaseFilter] = [], sort: [DatabaseSort] = []) throws -> DatabaseQueryResult { try request(method: "pageTable", params: TableParams(connection: connection, schema: schema, table: table, limit: limit, offset: offset, filters: filters, sort: sort)) } - func query(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], limit: Int = 200) throws -> DatabaseQueryResult { + package func query(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], limit: Int = 200) throws -> DatabaseQueryResult { try request(method: "query", params: QueryParams(connection: connection, sql: sql, values: values, limit: limit)) } - func execute(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { + package func execute(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { try request(method: "execute", params: QueryParams(connection: connection, sql: sql, values: values, confirmed: confirmed, allowWrite: allowWrite)) } - func applyChanges(connection: DatabaseConnection, schema: String = "", mutations: [DatabaseMutation], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { + package func applyChanges(connection: DatabaseConnection, schema: String = "", mutations: [DatabaseMutation], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { try request(method: "applyChanges", params: MutationParams(connection: connection, schema: schema, mutations: mutations, confirmed: confirmed, allowWrite: allowWrite)) } - func applySchemaChange(connection: DatabaseConnection, schema: String = "", change: DatabaseSchemaChange, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { + package func applySchemaChange(connection: DatabaseConnection, schema: String = "", change: DatabaseSchemaChange, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { try request(method: "schemaChange", params: SchemaChangeParams(connection: connection, schema: schema, change: change, confirmed: confirmed, allowWrite: allowWrite)) } - func explain(connection: DatabaseConnection, sql: String, format: String = "json") throws -> DatabaseQueryResult { + package func explain(connection: DatabaseConnection, sql: String, format: String = "json") throws -> DatabaseQueryResult { let result: ExplainResult = try request(method: "explain", params: ExplainParams(connection: connection, sql: sql, explainFormat: format)) return DatabaseQueryResult(rows: result.rows, truncated: result.truncated, totalRows: nil) } - func diagnostics(connection: DatabaseConnection, request: DatabaseDiagnosticsRequest) throws -> DatabaseQueryResult { + package func diagnostics(connection: DatabaseConnection, request: DatabaseDiagnosticsRequest) throws -> DatabaseQueryResult { let result: DiagnosticsResult = try self.request(method: "diagnostics", params: DiagnosticsParams(connection: connection, request: request)) return DatabaseQueryResult(rows: result.rows, truncated: result.truncated, totalRows: nil) } - func transaction(connection: DatabaseConnection, statements: [DatabaseTransactionStatement], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { + package func transaction(connection: DatabaseConnection, statements: [DatabaseTransactionStatement], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { try request(method: "transaction", params: TransactionParams(connection: connection, statements: statements, confirmed: confirmed, allowWrite: allowWrite)) } - func exportCSV(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], limit: Int = 10_000) throws -> Data { + package func exportCSV(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], limit: Int = 10_000) throws -> Data { try export(method: "exportCsv", connection: connection, sql: sql, values: values, limit: limit) } - func exportJSON(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], limit: Int = 10_000) throws -> Data { + package func exportJSON(connection: DatabaseConnection, sql: String, values: [DatabaseValue] = [], limit: Int = 10_000) throws -> Data { try export(method: "exportJson", connection: connection, sql: sql, values: values, limit: limit) } - func importCSV(connection: DatabaseConnection, schema: String = "", table: String, data: Data) throws -> DatabaseExecuteResult { + package func importCSV(connection: DatabaseConnection, schema: String = "", table: String, data: Data) throws -> DatabaseExecuteResult { try request(method: "importCsv", params: ImportParams(connection: connection, schema: schema, table: table, data: data.base64EncodedString())) } - func importJSON(connection: DatabaseConnection, schema: String = "", table: String, data: Data) throws -> DatabaseExecuteResult { + package func importJSON(connection: DatabaseConnection, schema: String = "", table: String, data: Data) throws -> DatabaseExecuteResult { try request(method: "importJson", params: ImportParams(connection: connection, schema: schema, table: table, data: data.base64EncodedString())) } - func exportSQL(connection: DatabaseConnection, options: DatabaseSQLExportOptions = DatabaseSQLExportOptions()) throws -> Data { + package func exportSQL(connection: DatabaseConnection, options: DatabaseSQLExportOptions = DatabaseSQLExportOptions()) throws -> Data { let result: ExportResult = try request(method: "exportSql", params: SQLExportParams( connection: connection, schema: options.schema, selectedTables: options.selectedTables, includeStructure: options.includeStructure, includeData: options.includeData, limit: options.limit @@ -562,7 +574,7 @@ final class DatabaseSidecarService: DatabaseOperations, @unchecked Sendable { return data } - func exportSQLToFile(connection: DatabaseConnection, options: DatabaseSQLExportOptions = DatabaseSQLExportOptions(), outputURL: URL) throws -> DatabaseSQLFileExportResult { + package func exportSQLToFile(connection: DatabaseConnection, options: DatabaseSQLExportOptions = DatabaseSQLExportOptions(), outputURL: URL) throws -> DatabaseSQLFileExportResult { try request(method: "exportSqlToFile", params: SQLFileExportParams( connection: connection, schema: options.schema, selectedTables: options.selectedTables, includeStructure: options.includeStructure, includeData: options.includeData, @@ -570,75 +582,75 @@ final class DatabaseSidecarService: DatabaseOperations, @unchecked Sendable { ), timeoutMilliseconds: 120_000) } - func importSQL(connection: DatabaseConnection, data: Data, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { + package func importSQL(connection: DatabaseConnection, data: Data, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { try request(method: "importSql", params: SQLImportParams(connection: connection, data: data.base64EncodedString(), confirmed: confirmed, allowWrite: allowWrite), timeoutMilliseconds: 120_000) } - func importSQLFile(connection: DatabaseConnection, fileURL: URL, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { + package func importSQLFile(connection: DatabaseConnection, fileURL: URL, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { try request(method: "importSqlFile", params: SQLFileImportParams(connection: connection, outputPath: fileURL.path, confirmed: confirmed, allowWrite: allowWrite), timeoutMilliseconds: 120_000) } - func restoreSQL(connection: DatabaseConnection, data: Data, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { + package func restoreSQL(connection: DatabaseConnection, data: Data, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { try request(method: "restoreSql", params: SQLImportParams(connection: connection, data: data.base64EncodedString(), confirmed: confirmed, allowWrite: allowWrite), timeoutMilliseconds: 120_000) } - func restoreSQLFile(connection: DatabaseConnection, fileURL: URL, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { + package func restoreSQLFile(connection: DatabaseConnection, fileURL: URL, confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { try request(method: "restoreSqlFile", params: SQLFileImportParams(connection: connection, outputPath: fileURL.path, confirmed: confirmed, allowWrite: allowWrite), timeoutMilliseconds: 120_000) } - func redisScan(connection: DatabaseConnection, cursor: String = "0", pattern: String = "*", count: Int = 100, includeSize: Bool = true) throws -> RedisScanResult { + package func redisScan(connection: DatabaseConnection, cursor: String = "0", pattern: String = "*", count: Int = 100, includeSize: Bool = true) throws -> RedisScanResult { try request(method: "redisScan", params: RedisScanParams(connection: connection, cursor: cursor, pattern: pattern, count: count, includeSize: includeSize)) } - func redisGetKey(connection: DatabaseConnection, key: String) throws -> RedisKeyDetail { + package func redisGetKey(connection: DatabaseConnection, key: String) throws -> RedisKeyDetail { try request(method: "redisGetKey", params: RedisKeyParams(connection: connection, key: key)) } - func redisSetString(connection: DatabaseConnection, key: String, value: String, ttl: Int64? = nil, confirmed: Bool = false, allowWrite: Bool = false) throws { + package func redisSetString(connection: DatabaseConnection, key: String, value: String, ttl: Int64? = nil, confirmed: Bool = false, allowWrite: Bool = false) throws { let _: EmptyResult = try request(method: "redisSetString", params: RedisWriteParams(connection: connection, key: key, value: value, ttl: ttl, confirmed: confirmed, allowWrite: allowWrite)) } - func redisReplaceHash(connection: DatabaseConnection, key: String, entries: [RedisHashEntry], confirmed: Bool = false, allowWrite: Bool = false) throws { + package func redisReplaceHash(connection: DatabaseConnection, key: String, entries: [RedisHashEntry], confirmed: Bool = false, allowWrite: Bool = false) throws { let _: EmptyResult = try request(method: "redisReplaceHash", params: RedisWriteParams(connection: connection, key: key, entries: entries, confirmed: confirmed, allowWrite: allowWrite)) } - func redisDeleteKey(connection: DatabaseConnection, key: String, confirmed: Bool = false, allowWrite: Bool = false) throws { + package func redisDeleteKey(connection: DatabaseConnection, key: String, confirmed: Bool = false, allowWrite: Bool = false) throws { let _: EmptyResult = try request(method: "redisDeleteKey", params: RedisWriteParams(connection: connection, key: key, confirmed: confirmed, allowWrite: allowWrite)) } - func redisRenameKey(connection: DatabaseConnection, key: String, newKey: String, confirmed: Bool = false, allowWrite: Bool = false) throws { + package func redisRenameKey(connection: DatabaseConnection, key: String, newKey: String, confirmed: Bool = false, allowWrite: Bool = false) throws { let _: EmptyResult = try request(method: "redisRenameKey", params: RedisWriteParams(connection: connection, key: key, newKey: newKey, confirmed: confirmed, allowWrite: allowWrite)) } - func redisSetTTL(connection: DatabaseConnection, key: String, ttl: Int64, confirmed: Bool = false, allowWrite: Bool = false) throws { + package func redisSetTTL(connection: DatabaseConnection, key: String, ttl: Int64, confirmed: Bool = false, allowWrite: Bool = false) throws { let _: EmptyResult = try request(method: "redisSetTTL", params: RedisWriteParams(connection: connection, key: key, ttl: ttl, confirmed: confirmed, allowWrite: allowWrite)) } - func redisFlushDatabase(connection: DatabaseConnection, confirmed: Bool = false, allowWrite: Bool = false) throws { + package func redisFlushDatabase(connection: DatabaseConnection, confirmed: Bool = false, allowWrite: Bool = false) throws { let _: EmptyResult = try request(method: "redisFlushDatabase", params: RedisWriteParams(connection: connection, key: "", confirmed: confirmed, allowWrite: allowWrite)) } - func nacosListConfigs(connection: DatabaseConnection, dataId: String = "", group: String = "", page: Int = 1, pageSize: Int = 100) throws -> NacosConfigList { + package func nacosListConfigs(connection: DatabaseConnection, dataId: String = "", group: String = "", page: Int = 1, pageSize: Int = 100) throws -> NacosConfigList { try request(method: "nacosListConfigs", params: NacosParams(connection: connection, dataId: dataId, group: group, page: page, pageSize: pageSize)) } - func nacosGetConfig(connection: DatabaseConnection, dataId: String, group: String) throws -> NacosConfigDetail { + package func nacosGetConfig(connection: DatabaseConnection, dataId: String, group: String) throws -> NacosConfigDetail { try request(method: "nacosGetConfig", params: NacosParams(connection: connection, dataId: dataId, group: group)) } - func nacosPublishConfig(connection: DatabaseConnection, dataId: String, group: String, content: String, type: String? = nil, confirmed: Bool = false, allowWrite: Bool = false) throws { + package func nacosPublishConfig(connection: DatabaseConnection, dataId: String, group: String, content: String, type: String? = nil, confirmed: Bool = false, allowWrite: Bool = false) throws { let _: EmptyResult = try request(method: "nacosPublishConfig", params: NacosParams(connection: connection, dataId: dataId, group: group, content: content, type: type ?? "", confirmed: confirmed, allowWrite: allowWrite)) } - func nacosDeleteConfig(connection: DatabaseConnection, dataId: String, group: String, confirmed: Bool = false, allowWrite: Bool = false) throws { + package func nacosDeleteConfig(connection: DatabaseConnection, dataId: String, group: String, confirmed: Bool = false, allowWrite: Bool = false) throws { let _: EmptyResult = try request(method: "nacosDeleteConfig", params: NacosParams(connection: connection, dataId: dataId, group: group, confirmed: confirmed, allowWrite: allowWrite)) } - func nacosListServices(connection: DatabaseConnection, serviceName: String = "", group: String = "", page: Int = 1, pageSize: Int = 100) throws -> NacosServiceList { + package func nacosListServices(connection: DatabaseConnection, serviceName: String = "", group: String = "", page: Int = 1, pageSize: Int = 100) throws -> NacosServiceList { try request(method: "nacosListServices", params: NacosParams(connection: connection, group: group, serviceName: serviceName, page: page, pageSize: pageSize)) } - func nacosListInstances(connection: DatabaseConnection, serviceName: String, group: String = "") throws -> [NacosInstanceSummary] { + package func nacosListInstances(connection: DatabaseConnection, serviceName: String, group: String = "") throws -> [NacosInstanceSummary] { try request(method: "nacosListInstances", params: NacosParams(connection: connection, group: group, serviceName: serviceName)) } @@ -658,7 +670,7 @@ final class DatabaseSidecarService: DatabaseOperations, @unchecked Sendable { do { input = try JSONEncoder().encode(body) } catch { throw DatabaseSidecarError.invalidResponse(error.localizedDescription) } - let process = processRunner.run(ProcessRequest( + let process = processRunner.runDatabaseProcess(DatabaseProcessRequest( executablePath: executableURL.path, environment: environment, standardInput: input, @@ -684,9 +696,9 @@ private struct ConnectionParams: Codable { let connection: DatabaseConnection } private struct ConnectedResult: Codable { let connected: Bool } private struct EmptyResult: Codable {} private struct DatabaseRowsResult: Decodable { - let rows: [DatabaseRow] + package let rows: [DatabaseRow] - init(from decoder: Decoder) throws { + package init(from decoder: Decoder) throws { if let rows = try? decoder.singleValueContainer().decode([DatabaseRow].self) { self.rows = rows return @@ -701,54 +713,54 @@ private struct ExportResult: Codable { let encoding: String; let data: String } private struct ExplainResult: Codable { let format: String; let rows: [DatabaseRow]; let truncated: Bool } private struct DiagnosticsResult: Codable { let rows: [DatabaseRow]; let truncated: Bool } private struct TableParams: Codable { - let connection: DatabaseConnection - var schema = "" - var table = "" - var limit = 200 - var offset = 0 - var filters: [DatabaseFilter] = [] - var sort: [DatabaseSort] = [] + package let connection: DatabaseConnection + package var schema = "" + package var table = "" + package var limit = 200 + package var offset = 0 + package var filters: [DatabaseFilter] = [] + package var sort: [DatabaseSort] = [] } private struct ObjectParams: Codable { - let connection: DatabaseConnection - var schema = "" - var objectKind = "" + package let connection: DatabaseConnection + package var schema = "" + package var objectKind = "" } private struct QueryParams: Codable { - let connection: DatabaseConnection - let sql: String - var values: [DatabaseValue] = [] - var limit = 200 - var confirmed = false - var allowWrite = false + package let connection: DatabaseConnection + package let sql: String + package var values: [DatabaseValue] = [] + package var limit = 200 + package var confirmed = false + package var allowWrite = false } private struct MutationParams: Codable { - let connection: DatabaseConnection - var schema = "" - let mutations: [DatabaseMutation] - var confirmed = false - var allowWrite = false + package let connection: DatabaseConnection + package var schema = "" + package let mutations: [DatabaseMutation] + package var confirmed = false + package var allowWrite = false } private struct SchemaChangeParams: Codable { - let connection: DatabaseConnection - var schema = "" - var operation: String - var table = "" - var name = "" - var oldName = "" - var dataType = "" - var nullable = true - var defaultValue = "" - var indexName = "" - var indexColumns: [String] = [] - var constraintName = "" - var referencedTable = "" - var referencedColumns: [String] = [] - var sql = "" - var confirmed = false - var allowWrite = false - - init(connection: DatabaseConnection, schema: String, change: DatabaseSchemaChange, confirmed: Bool, allowWrite: Bool) { + package let connection: DatabaseConnection + package var schema = "" + package var operation: String + package var table = "" + package var name = "" + package var oldName = "" + package var dataType = "" + package var nullable = true + package var defaultValue = "" + package var indexName = "" + package var indexColumns: [String] = [] + package var constraintName = "" + package var referencedTable = "" + package var referencedColumns: [String] = [] + package var sql = "" + package var confirmed = false + package var allowWrite = false + + package init(connection: DatabaseConnection, schema: String, change: DatabaseSchemaChange, confirmed: Bool, allowWrite: Bool) { self.connection = connection self.schema = schema operation = change.operation @@ -769,17 +781,17 @@ private struct SchemaChangeParams: Codable { } } private struct ExplainParams: Codable { - let connection: DatabaseConnection - let sql: String - var explainFormat = "json" + package let connection: DatabaseConnection + package let sql: String + package var explainFormat = "json" } private struct DiagnosticsParams: Codable { - let connection: DatabaseConnection - var schema = "" - var table = "" - var diagnosticKind = "tableSize" + package let connection: DatabaseConnection + package var schema = "" + package var table = "" + package var diagnosticKind = "tableSize" - init(connection: DatabaseConnection, request: DatabaseDiagnosticsRequest) { + package init(connection: DatabaseConnection, request: DatabaseDiagnosticsRequest) { self.connection = connection schema = request.schema table = request.table @@ -787,65 +799,65 @@ private struct DiagnosticsParams: Codable { } } private struct TransactionParams: Codable { - let connection: DatabaseConnection - let statements: [DatabaseTransactionStatement] - var confirmed = false - var allowWrite = false + package let connection: DatabaseConnection + package let statements: [DatabaseTransactionStatement] + package var confirmed = false + package var allowWrite = false } private struct ImportParams: Codable { - let connection: DatabaseConnection - var schema = "" - let table: String - let data: String + package let connection: DatabaseConnection + package var schema = "" + package let table: String + package let data: String } private struct SQLExportParams: Codable { - let connection: DatabaseConnection - var schema = "" - var selectedTables: [String] = [] - var includeStructure = true - var includeData = true - var limit = 0 + package let connection: DatabaseConnection + package var schema = "" + package var selectedTables: [String] = [] + package var includeStructure = true + package var includeData = true + package var limit = 0 } private struct SQLFileExportParams: Codable { - let connection: DatabaseConnection - var schema = "" - var selectedTables: [String] = [] - var includeStructure = true - var includeData = true - var limit = 0 - let outputPath: String + package let connection: DatabaseConnection + package var schema = "" + package var selectedTables: [String] = [] + package var includeStructure = true + package var includeData = true + package var limit = 0 + package let outputPath: String } private struct SQLImportParams: Codable { let connection: DatabaseConnection; let data: String; var confirmed = false; var allowWrite = false } private struct SQLFileImportParams: Codable { let connection: DatabaseConnection; let outputPath: String; var confirmed = false; var allowWrite = false } private struct RedisScanParams: Codable { - let connection: DatabaseConnection - var cursor = "0" - var pattern = "*" - var count = 100 - var includeSize = true + package let connection: DatabaseConnection + package var cursor = "0" + package var pattern = "*" + package var count = 100 + package var includeSize = true } private struct RedisKeyParams: Codable { let connection: DatabaseConnection; let key: String } private struct RedisWriteParams: Codable { - let connection: DatabaseConnection - let key: String - var newKey = "" - var value = "" - var entries: [RedisHashEntry] = [] - var ttl: Int64? - var confirmed = false - var allowWrite = false + package let connection: DatabaseConnection + package let key: String + package var newKey = "" + package var value = "" + package var entries: [RedisHashEntry] = [] + package var ttl: Int64? + package var confirmed = false + package var allowWrite = false } private struct NacosParams: Codable { - let connection: DatabaseConnection - var dataId = "" - var group = "" - var content = "" - var type = "" - var serviceName = "" - var page = 1 - var pageSize = 100 - var confirmed = false - var allowWrite = false + package let connection: DatabaseConnection + package var dataId = "" + package var group = "" + package var content = "" + package var type = "" + package var serviceName = "" + package var page = 1 + package var pageSize = 100 + package var confirmed = false + package var allowWrite = false } private struct RequestEnvelope: Encodable { let id: String; let method: String; let params: Params } private struct ResponseEnvelope: Decodable { let id: String; let ok: Bool; let result: Result?; let error: ResponseError? } diff --git a/Sources/Lithe/Application/GenericDebugFeatureModel.swift b/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift similarity index 75% rename from Sources/Lithe/Application/GenericDebugFeatureModel.swift rename to Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index 71ca5263..b75ec936 100644 --- a/Sources/Lithe/Application/GenericDebugFeatureModel.swift +++ b/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -1,54 +1,55 @@ import Foundation +import LitheCoreContracts -struct GenericDebugBreakpoint: Identifiable, Equatable, Sendable { - let fileURL: URL - let line: Int - var verified: Bool - var message: String? +public struct GenericDebugBreakpoint: Identifiable, Equatable, Sendable { + public let fileURL: URL + public let line: Int + public var verified: Bool + public var message: String? - var id: String { fileURL.standardizedFileURL.path + ":" + String(line) } - var title: String { fileURL.lastPathComponent + ":" + String(line) } + public var id: String { fileURL.standardizedFileURL.path + ":" + String(line) } + public var title: String { fileURL.lastPathComponent + ":" + String(line) } } @MainActor -final class GenericDebugFeatureModel: ObservableObject { - @Published private(set) var providerID: String? - @Published private(set) var targetTitle: String? - @Published private(set) var state: DebugAdapterState = .idle - @Published private(set) var output = "" - @Published private(set) var errorMessage: String? - @Published private(set) var stoppedReason: String? - @Published private(set) var breakpoints: [GenericDebugBreakpoint] = [] - @Published private(set) var threads: [DebugThread] = [] - @Published private(set) var stackFrames: [DebugStackFrame] = [] - @Published private(set) var scopes: [DebugScope] = [] - @Published private(set) var variables: [DebugVariable] = [] - @Published private(set) var selectedThreadID: Int? - @Published private(set) var selectedFrameID: Int? +public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatureTarget { + @Published public private(set) var providerID: String? + @Published public private(set) var targetTitle: String? + @Published public private(set) var state: DebugAdapterState = .idle + @Published public private(set) var output = "" + @Published public private(set) var errorMessage: String? + @Published public private(set) var stoppedReason: String? + @Published public private(set) var breakpoints: [GenericDebugBreakpoint] = [] + @Published public private(set) var threads: [DebugThread] = [] + @Published public private(set) var stackFrames: [DebugStackFrame] = [] + @Published public private(set) var scopes: [DebugScope] = [] + @Published public private(set) var variables: [DebugVariable] = [] + @Published public private(set) var selectedThreadID: Int? + @Published public private(set) var selectedFrameID: Int? - private let sessions: LanguageToolingSessionManager + private let sessions: DebugAdapterSessionManager private var requestedLinesByFile: [URL: Set] = [:] private let maximumOutputCharacters = 400_000 - init(sessions: LanguageToolingSessionManager) { + public init(sessions: DebugAdapterSessionManager) { self.sessions = sessions - sessions.onDebugStateChange = { [weak self] providerID, state in + sessions.onStateChange = { [weak self] providerID, state in guard self?.providerID == providerID else { return } self?.state = state } - sessions.onDebugEvent = { [weak self] providerID, event in + sessions.onEvent = { [weak self] providerID, event in guard self?.providerID == providerID else { return } self?.consume(event) } } - var isSessionActive: Bool { + public var isSessionActive: Bool { ![.idle, .terminated, .failed].contains(state) } - var canControl: Bool { state == .running || state == .paused } + public var canControl: Bool { state == .running || state == .paused } - func start( + public func start( fileURL: URL, rootURL: URL, configuration: DebugLaunchConfiguration @@ -67,12 +68,12 @@ final class GenericDebugFeatureModel: ObservableObject { selectedFrameID = nil do { if let lines = requestedLinesByFile[fileURL.standardizedFileURL] { - try sessions.setDebugBreakpoints( + try sessions.setBreakpoints( lines.sorted().map { DebugSourceBreakpoint(line: $0) }, in: fileURL ) } - let session = try sessions.launchDebugAdapter( + let session = try sessions.launch( for: fileURL, rootURL: rootURL, configuration: configuration @@ -87,9 +88,9 @@ final class GenericDebugFeatureModel: ObservableObject { } } - func stop() { + public func stop() { if let providerID { - sessions.stopDebugAdapter(providerID: providerID) + sessions.stop(providerID: providerID) } state = .idle stoppedReason = nil @@ -101,7 +102,7 @@ final class GenericDebugFeatureModel: ObservableObject { variables = [] } - func reset() { + public func reset() { stop() providerID = nil targetTitle = nil @@ -111,7 +112,7 @@ final class GenericDebugFeatureModel: ObservableObject { requestedLinesByFile = [:] } - func toggleBreakpoint(fileURL: URL, line: Int) { + public func toggleBreakpoint(fileURL: URL, line: Int) { guard line > 0 else { return } let normalizedURL = fileURL.standardizedFileURL var lines = requestedLinesByFile[normalizedURL] ?? [] @@ -122,19 +123,19 @@ final class GenericDebugFeatureModel: ObservableObject { } requestedLinesByFile[normalizedURL] = lines reconcileBreakpoints() - try? sessions.setDebugBreakpoints( + try? sessions.setBreakpoints( lines.sorted().map { DebugSourceBreakpoint(line: $0) }, in: normalizedURL ) } - func execute(_ command: DebugExecutionCommand) { + public func execute(_ command: DebugExecutionCommand) { guard let providerID, - let session = sessions.debugSession(providerID: providerID) else { return } + let session = sessions.session(providerID: providerID) else { return } session.execute(command, threadID: selectedThreadID) } - func inspectThreads() { + public func inspectThreads() { guard let session = activeSession else { return } session.requestThreads { [weak self] result in switch result { @@ -146,7 +147,7 @@ final class GenericDebugFeatureModel: ObservableObject { } } - func selectThread(_ thread: DebugThread) { + public func selectThread(_ thread: DebugThread) { selectedThreadID = thread.id guard let session = activeSession else { return } session.requestStackTrace(threadID: thread.id) { [weak self] result in @@ -160,7 +161,7 @@ final class GenericDebugFeatureModel: ObservableObject { } } - func selectFrame(_ frame: DebugStackFrame) { + public func selectFrame(_ frame: DebugStackFrame) { selectedFrameID = frame.id guard let session = activeSession else { return } session.requestScopes(frameID: frame.id) { [weak self] result in @@ -177,7 +178,7 @@ final class GenericDebugFeatureModel: ObservableObject { } } - func loadVariables(reference: Int) { + public func loadVariables(reference: Int) { guard let session = activeSession else { return } session.requestVariables(reference: reference) { [weak self] result in switch result { @@ -187,7 +188,7 @@ final class GenericDebugFeatureModel: ObservableObject { } } - func evaluate(_ expression: String) { + public func evaluate(_ expression: String) { let value = expression.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty, let session = activeSession else { return } session.evaluate(value, frameID: selectedFrameID) { [weak self] result in @@ -199,11 +200,11 @@ final class GenericDebugFeatureModel: ObservableObject { } } - func clearOutput() { output = "" } + public func clearOutput() { output = "" } private var activeSession: (any DebugAdapterControllingSession)? { guard let providerID else { return nil } - return sessions.debugSession(providerID: providerID) + return sessions.session(providerID: providerID) } private func sessionsProviderID(for fileURL: URL) -> String? { diff --git a/Sources/LitheDebugModule/Module/DebugModule.swift b/Sources/LitheDebugModule/Module/DebugModule.swift new file mode 100644 index 00000000..8e1bb492 --- /dev/null +++ b/Sources/LitheDebugModule/Module/DebugModule.swift @@ -0,0 +1,78 @@ +import Foundation +import LitheModuleAPI + +@MainActor +public protocol JavaDebugFeatureTarget: AnyObject {} + +@MainActor +public protocol GenericDebugFeatureTarget: AnyObject {} + +@MainActor +public protocol DebugServiceGraph: AnyObject { + var javaFeatureTarget: any JavaDebugFeatureTarget { get } + var genericFeatureTarget: any GenericDebugFeatureTarget { get } + var hasActiveDebugWork: Bool { get } + func activate(context: ModuleContext) + func prepareForSleep() async throws + func stop() async +} + +@MainActor +public final class DebugModuleCapability: NSObject { + public let javaFeature: any JavaDebugFeatureTarget + public let genericFeature: any GenericDebugFeatureTarget + + fileprivate init(graph: any DebugServiceGraph) { + javaFeature = graph.javaFeatureTarget + genericFeature = graph.genericFeatureTarget + } +} + +@MainActor +public final class DebugModule: LitheModule { + public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .debug) + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .debug)! + public let manifest = moduleManifest + private let makeGraph: @MainActor () -> any DebugServiceGraph + private var graph: (any DebugServiceGraph)? + private var capability: DebugModuleCapability? + + public init(makeGraph: @escaping @MainActor () -> any DebugServiceGraph) { + self.makeGraph = makeGraph + } + + public func activate(context: ModuleContext) async throws { + guard graph == nil else { return } + let graph = makeGraph() + graph.activate(context: context) + context.resources.register(DebugGraphResource(graph: graph)) + self.graph = graph + capability = DebugModuleCapability(graph: graph) + } + + public func prepareForSleep() async throws { try await graph?.prepareForSleep() } + public func sleep() async { await releaseGraph() } + public func shutdown() async { await releaseGraph() } + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.debugWorkspace: capability] + } + public func contributions() -> [ModuleContribution] { + Self.moduleContributions + } + + private func releaseGraph() async { + await graph?.stop() + capability = nil + graph = nil + } +} + +@MainActor +private final class DebugGraphResource: ModuleResource { + let moduleResourceKind = "debug-sessions" + private let graph: any DebugServiceGraph + init(graph: any DebugServiceGraph) { self.graph = graph } + var isModuleResourceActive: Bool { graph.hasActiveDebugWork } + func stopModuleResource() async { await graph.stop() } +} diff --git a/Sources/Lithe/Services/StdioDebugAdapterSession.swift b/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift similarity index 88% rename from Sources/Lithe/Services/StdioDebugAdapterSession.swift rename to Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift index 592ff29c..5fc30445 100644 --- a/Sources/Lithe/Services/StdioDebugAdapterSession.swift +++ b/Sources/LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift @@ -1,12 +1,13 @@ import Foundation +import LitheCoreContracts -enum DebugAdapterProtocolError: LocalizedError { +public enum DebugAdapterProtocolError: LocalizedError { case notReady case stopped case invalidResponse(String) case requestFailed(command: String, message: String) - var errorDescription: String? { + public var errorDescription: String? { switch self { case .notReady: "The Debug Adapter is not ready." @@ -20,59 +21,11 @@ enum DebugAdapterProtocolError: LocalizedError { } } -@MainActor -final class ProcessDebugAdapterTransport: DebugAdapterTransport { - private let executableURL: URL - private let arguments: [String] - private let environment: [String: String] - private let process: any RawProcessSession - var onData: ((Data) -> Void)? - var onErrorOutput: ((Data) -> Void)? - var onTermination: ((Int) -> Void)? - - 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?.onData?(data) } - } - process.onError = { [weak self] data in - Task { @MainActor [weak self] in self?.onErrorOutput?(data) } - } - process.onTermination = { [weak self] exitCode in - Task { @MainActor [weak self] in self?.onTermination?(Int(exitCode)) } - } - } - - var isRunning: Bool { process.isRunning } - - func start(rootURL: URL) throws { - try process.start(ProcessRequest( - operationID: UUID().uuidString, - executablePath: executableURL.path, - arguments: arguments, - workingDirectory: rootURL.standardizedFileURL.path, - environment: environment, - keepsStandardInputOpen: true - )) - } - - func send(_ data: Data) throws { try process.send(data) } - func stop() { process.stop() } -} - /// Generic Debug Adapter Protocol client. Transport details (stdio, TCP, or a /// future platform channel) stay behind `DebugAdapterTransport`; sequencing, /// breakpoints and inspection are shared by every language. @MainActor -final class DebugAdapterProtocolSession: DebugAdapterControllingSession { +public final class DebugAdapterProtocolSession: DebugAdapterControllingSession { private typealias ResponseHandler = (Result<[String: Any], Error>) -> Void private let adapterID: String @@ -88,34 +41,16 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { private var childSessions: [DebugAdapterProtocolSession] = [] private weak var activeChildSession: DebugAdapterProtocolSession? - private(set) var state: DebugAdapterState = .idle { + public private(set) var state: DebugAdapterState = .idle { didSet { guard state != oldValue else { return } onStateChange?(state) } } - var onStateChange: ((DebugAdapterState) -> Void)? - var onEvent: ((DebugAdapterEvent) -> Void)? + public var onStateChange: ((DebugAdapterState) -> Void)? + public var onEvent: ((DebugAdapterEvent) -> Void)? - convenience init( - adapterID: String, - executableURL: URL, - arguments: [String], - environment: [String: String], - process: any RawProcessSession - ) { - self.init( - adapterID: adapterID, - transport: ProcessDebugAdapterTransport( - executableURL: executableURL, - arguments: arguments, - environment: environment, - process: process - ) - ) - } - - init(adapterID: String, transport: any DebugAdapterTransport) { + public init(adapterID: String, transport: any DebugAdapterTransport) { self.adapterID = adapterID self.transport = transport transport.onData = { [weak self] data in self?.receive(data) } @@ -128,9 +63,9 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } - var isRunning: Bool { transport.isRunning } + public var isRunning: Bool { transport.isRunning } - func start(rootURL: URL) throws { + public func start(rootURL: URL) throws { if transport.isRunning { return } resetProtocolState() self.rootURL = rootURL.standardizedFileURL @@ -172,7 +107,7 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } - func launch(_ configuration: DebugLaunchConfiguration) throws { + public func launch(_ configuration: DebugLaunchConfiguration) throws { guard transport.isRunning else { throw DebugAdapterProtocolError.notReady } @@ -202,7 +137,7 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } - func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) { + public func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) { let normalizedURL = fileURL.standardizedFileURL breakpointsBySource[normalizedURL] = breakpoints.sorted { $0.line < $1.line } childSessions.forEach { $0.setBreakpoints(breakpoints, in: normalizedURL) } @@ -210,7 +145,7 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { sendBreakpoints(for: normalizedURL) } - func execute(_ command: DebugExecutionCommand, threadID: Int?) { + public func execute(_ command: DebugExecutionCommand, threadID: Int?) { if let activeChildSession { activeChildSession.execute(command, threadID: threadID) return @@ -228,7 +163,7 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } - func requestThreads(_ completion: @escaping (Result<[DebugThread], Error>) -> Void) { + public func requestThreads(_ completion: @escaping (Result<[DebugThread], Error>) -> Void) { if let activeChildSession { activeChildSession.requestThreads(completion) return @@ -243,7 +178,7 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } - func requestStackTrace( + public func requestStackTrace( threadID: Int, completion: @escaping (Result<[DebugStackFrame], Error>) -> Void ) { @@ -261,7 +196,7 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } - func requestScopes( + public func requestScopes( frameID: Int, completion: @escaping (Result<[DebugScope], Error>) -> Void ) { @@ -279,7 +214,7 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } - func requestVariables( + public func requestVariables( reference: Int, completion: @escaping (Result<[DebugVariable], Error>) -> Void ) { @@ -299,7 +234,7 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } - func evaluate( + public func evaluate( _ expression: String, frameID: Int?, completion: @escaping (Result) -> Void @@ -328,7 +263,7 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { } } - func stop() { + public func stop() { let children = childSessions childSessions = [] activeChildSession = nil @@ -693,6 +628,3 @@ final class DebugAdapterProtocolSession: DebugAdapterControllingSession { return URL(fileURLWithPath: path).standardizedFileURL } } - -/// Source compatibility for callers created before TCP adapters were added. -typealias StdioDebugAdapterSession = DebugAdapterProtocolSession diff --git a/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift b/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift new file mode 100644 index 00000000..09eb2421 --- /dev/null +++ b/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift @@ -0,0 +1,154 @@ +import Foundation +import LitheCoreContracts + +/// Owns every DAP session, breakpoint projection, and debug callback. +/// +/// This is deliberately separate from `LanguageToolingSessionManager`: an LSP +/// module can now sleep without stopping a debugger, and the Debug module can +/// release every adapter without retaining a language-server service graph. +@MainActor +public final class DebugAdapterSessionManager: ObservableObject { + @Published public private(set) var states: [String: DebugAdapterState] = [:] + @Published public private(set) var lastEvents: [String: DebugAdapterEvent] = [:] + @Published public private(set) var verifiedBreakpoints: [String: [DebugBreakpoint]] = [:] + + public var onStateChange: ((String, DebugAdapterState) -> Void)? + public var onEvent: ((String, DebugAdapterEvent) -> Void)? + + private let providers: [DebugProviderDescriptor] + private let makeSession: @MainActor ( + DebugProviderDescriptor, + URL + ) -> (any DebugAdapterSession)? + private var sessions: [String: any DebugAdapterSession] = [:] + private var roots: [String: URL] = [:] + private var requestedBreakpoints: [String: [URL: [DebugSourceBreakpoint]]] = [:] + + public init( + providers: [DebugProviderDescriptor], + makeSession: @escaping @MainActor ( + DebugProviderDescriptor, + URL + ) -> (any DebugAdapterSession)? + ) { + self.providers = providers + self.makeSession = makeSession + } + + public var activeAdapterIDs: Set { Set(sessions.keys) } + + public func provider(for fileURL: URL) -> DebugProviderDescriptor? { + providers.first { $0.matches(fileURL) } + } + + @discardableResult + public func activate(for fileURL: URL, rootURL: URL) throws -> any DebugAdapterSession { + guard let descriptor = provider(for: fileURL) else { + throw DebugProviderError.noProvider( + fileExtension: fileURL.pathExtension.lowercased() + ) + } + + let normalizedRoot = rootURL.standardizedFileURL + if let active = sessions[descriptor.id] { + if active.isRunning, roots[descriptor.id] == normalizedRoot { + return active + } + active.stop() + sessions[descriptor.id] = nil + roots[descriptor.id] = nil + } + + guard let session = makeSession(descriptor, normalizedRoot) else { + throw DebugProviderError.adapterUnavailable(descriptor.displayName) + } + configureCallbacks(session, providerID: descriptor.id) + try session.start(rootURL: normalizedRoot) + sessions[descriptor.id] = session + roots[descriptor.id] = normalizedRoot + states[descriptor.id] = session.state + if let controlling = session as? any DebugAdapterControllingSession { + for (source, breakpoints) in requestedBreakpoints[descriptor.id] ?? [:] { + controlling.setBreakpoints(breakpoints, in: source) + } + } + return session + } + + @discardableResult + public func launch( + for fileURL: URL, + rootURL: URL, + configuration: DebugLaunchConfiguration + ) throws -> any DebugAdapterControllingSession { + let session = try activate(for: fileURL, rootURL: rootURL) + guard let controlling = session as? any DebugAdapterControllingSession else { + throw DebugProviderError.capabilityUnavailable( + provider: provider(for: fileURL)?.displayName ?? fileURL.pathExtension, + capability: "DAP launch control" + ) + } + try controlling.launch(configuration) + return controlling + } + + public func setBreakpoints(_ breakpoints: [DebugSourceBreakpoint], in fileURL: URL) throws { + guard let descriptor = provider(for: fileURL) else { + throw DebugProviderError.noProvider( + fileExtension: fileURL.pathExtension.lowercased() + ) + } + var values = requestedBreakpoints[descriptor.id] ?? [:] + values[fileURL.standardizedFileURL] = breakpoints + requestedBreakpoints[descriptor.id] = values + session(providerID: descriptor.id)?.setBreakpoints(breakpoints, in: fileURL) + } + + public func session(providerID: String) -> (any DebugAdapterControllingSession)? { + sessions[providerID] as? any DebugAdapterControllingSession + } + + public func stop(providerID: String) { + sessions.removeValue(forKey: providerID)?.stop() + roots[providerID] = nil + states[providerID] = .idle + } + + public func stopAll() { + for session in sessions.values { session.stop() } + sessions.removeAll() + roots.removeAll() + states.removeAll() + lastEvents.removeAll() + verifiedBreakpoints.removeAll() + requestedBreakpoints.removeAll() + } + + private func configureCallbacks( + _ session: any DebugAdapterSession, + providerID: String + ) { + guard let controlling = session as? any DebugAdapterControllingSession else { return } + controlling.onStateChange = { [weak self] state in + self?.states[providerID] = state + self?.onStateChange?(providerID, state) + } + controlling.onEvent = { [weak self] event in + guard let self else { return } + lastEvents[providerID] = event + onEvent?(providerID, event) + if case .breakpoint(let breakpoint) = event { + var values = verifiedBreakpoints[providerID] ?? [] + if let index = values.firstIndex(where: { $0.id == breakpoint.id }) { + values[index] = breakpoint + } else { + values.append(breakpoint) + } + verifiedBreakpoints[providerID] = values.sorted { + ($0.sourceURL?.path ?? "", $0.line ?? 0) + < ($1.sourceURL?.path ?? "", $1.line ?? 0) + } + } + } + } +} diff --git a/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift b/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift new file mode 100644 index 00000000..a86d291f --- /dev/null +++ b/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift @@ -0,0 +1,227 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +/// UI-facing projection for Maven state and commands. +/// The view layer does not depend on MavenService or its process adapter. +@MainActor +package final class MavenFeatureModel: ObservableObject { + private let service: MavenService + private var observation: AnyCancellable? + + package init(service: MavenService) { + self.service = service + observation = service.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + } + + package var project: MavenProject? { service.project } + package var isLoadingProject: Bool { service.isLoadingProject } + package var isRunning: Bool { service.isRunning } + package var runningTitle: String? { service.runningTitle } + package var output: String { service.output } + package var issues: [MavenBuildIssue] { service.issues } + package var lastExitCode: Int32? { service.lastExitCode } + + package func loadProject(at workspaceURL: URL, files: [URL]) async { + await service.loadProject(at: workspaceURL, files: files) + } + + package func run(phase: MavenLifecyclePhase, module: MavenModule?, profiles: Set) { + service.run(phase: phase, module: module, profiles: profiles) + } + + package func reset() { service.reset() } + + package func stop() { + service.stop() + } + + package func clearOutput() { + service.clearOutput() + } + +} + +/// UI-facing projection for language-neutral run configurations and process sessions. +package enum RunConfigurationGenerationIntent: Sendable { + case identifyOnly + case run + case debug +} + +@MainActor +package final class RunFeatureModel: ObservableObject { + private let service: RunService + private var observation: AnyCancellable? + @Published package var isGenerationConfirmationPresented = false + package private(set) var generationIntent: RunConfigurationGenerationIntent = .identifyOnly + + package init(service: RunService) { + self.service = service + observation = service.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + } + + package var selectedConfigurationID: String { + get { service.selectedConfigurationID } + set { service.selectedConfigurationID = newValue } + } + + package var configurations: [RunConfiguration] { service.configurations } + package var selectedConfiguration: RunConfiguration? { service.selectedConfiguration } + package var lastRunFileURL: URL? { service.lastRunFileURL } + package var lastConfiguration: RunConfiguration? { service.lastConfiguration } + package var isLoadingProject: Bool { service.isLoadingProject } + package var isRunning: Bool { service.isRunning } + package var runningTitle: String? { service.runningTitle } + package var output: String { service.output } + package var lastExitCode: Int32? { service.lastExitCode } + package var mavenProfiles: [MavenProfile] { service.mavenProfiles } + package var moduleSessions: [RunSession] { service.moduleSessions } + package var portConflicts: [RunPortConflict] { service.portConflicts } + package var configurationStatus: ProjectRunConfigurationStatus { service.configurationStatus } + package var configurationDiagnostics: [RunConfigurationDiagnostic] { service.configurationDiagnostics } + package var generationState: RunConfigurationGenerationState { service.generationState } + package var recoveryAction: RunConfigurationRecoveryAction { service.recoveryAction } + package var recoveryPath: String? { service.recoveryPath } + package var configurationSaveError: String? { service.configurationSaveError } + package var blockingToolchainDiagnostic: RunConfigurationDiagnostic? { + service.configurationDiagnostics.first { + $0.code == "missingToolchain" || $0.code == "toolchainVersionMismatch" + } + } + package var sourceSearchRoots: [URL] { service.sourceSearchRoots } + + package func options(for configuration: RunConfiguration) -> RunOptions { + service.options(for: configuration) + } + + package func source(for configuration: RunConfiguration) -> RunConfigurationSource { + service.source(for: configuration) + } + + package func serviceURL(for configuration: RunConfiguration) -> URL? { + service.serviceURL(for: configuration) + } + + @discardableResult + package func updateOptions( + _ options: RunOptions, + for configuration: RunConfiguration, + scope: RunConfigurationSaveScope = .local + ) -> Bool { + service.updateOptions(options, for: configuration, scope: scope) + } + + package func resetOptions(for configuration: RunConfiguration) { + service.resetOptions(for: configuration) + } + + @discardableResult + package func createConfiguration(_ draft: RunConfigurationDraft) -> Bool { + service.createConfiguration(draft) + } + + package func runAllServices() { + service.runAllServices() + } + + package func stopAllServices() { + service.stopAllServices() + } + + package func startConfiguration(_ configuration: RunConfiguration) { + service.startConfiguration(configuration) + } + + package func stopModule(_ session: RunSession) { + service.stopModule(session) + } + + package func restartModule(_ session: RunSession) { + service.restartModule(session) + } + + package func clearModuleOutput(_ session: RunSession) { + service.clearModuleOutput(session) + } + + package func clearOutput() { + service.clearOutput() + } + + package func loadProject( + at workspaceURL: URL, + files: [URL], + mavenProject: MavenProject? + ) async { + await service.loadProject(at: workspaceURL, files: files, mavenProject: mavenProject) + } + + package func generateRunConfigurations() async { + isGenerationConfirmationPresented = false + await service.generateRunConfigurations() + } + + package func requestRunConfigurationGeneration(intent: RunConfigurationGenerationIntent = .identifyOnly) { + guard recoveryAction != .upgradeApplication else { return } + generationIntent = intent + isGenerationConfirmationPresented = true + } + + package func select(_ configuration: RunConfiguration) { service.select(configuration) } + @discardableResult + package func registerLanguageRunExtension( + _ provider: any LanguageRunExtensionProviding, + support: LanguageSupportDeclaration + ) -> Bool { + service.registerLanguageRunExtension(provider, support: support) + } + + package func unregisterLanguageRunExtension(languageID: String) { + service.unregisterLanguageRunExtension(languageID: languageID) + } + package func runSelected(currentFileURL: URL?) { service.runSelected(currentFileURL: currentFileURL) } + package func restart() { service.restart() } + package func stop() { service.stop() } + package func reset() { service.reset() } +} + +/// Coordinates project-scoped build and run loading without making AppModel +/// own build-system sequencing. Language-specific project loaders can later be +/// added here without changing the workspace/UI composition boundary. +@MainActor +package final class ProjectDevelopmentFeatureModel { + private let mavenFeature: MavenFeatureModel + private let runFeature: RunFeatureModel + + package init(mavenFeature: MavenFeatureModel, runFeature: RunFeatureModel) { + self.mavenFeature = mavenFeature + self.runFeature = runFeature + } + + package func loadProject(at workspaceURL: URL, files: [URL]) async { + // Maven is one build-system Provider, not a workspace prerequisite. + // Avoid scanning every project as Maven; non-Maven ecosystems should + // reach the generic run pipeline without paying for Java discovery. + let hasMavenDescriptor = files.contains { file in + file.lastPathComponent.lowercased() == "pom.xml" + } + if hasMavenDescriptor { + await mavenFeature.loadProject(at: workspaceURL, files: files) + } else { + mavenFeature.reset() + } + await runFeature.loadProject( + at: workspaceURL, + files: files, + mavenProject: mavenFeature.project + ) + } +} + +package typealias JavaRunFeatureModel = RunFeatureModel diff --git a/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift b/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift new file mode 100644 index 00000000..82bb9e1c --- /dev/null +++ b/Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift @@ -0,0 +1,70 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +@MainActor +package final class ExecutionFeatureGraph: NSObject, ExecutionServiceGraph { + package let maven: MavenService + package let run: RunService + package let tests: LanguageTestService + package let mavenFeature: MavenFeatureModel + package let runFeature: RunFeatureModel + package let projectDevelopment: ProjectDevelopmentFeatureModel + private var activityObservers: Set = [] + private var mavenLease: ModuleLease? + private var runLease: ModuleLease? + private var testLease: ModuleLease? + + package init(maven: MavenService, run: RunService, tests: LanguageTestService) { + self.maven = maven; self.run = run; self.tests = tests + mavenFeature = MavenFeatureModel(service: maven) + runFeature = RunFeatureModel(service: run) + projectDevelopment = ProjectDevelopmentFeatureModel(mavenFeature: mavenFeature, runFeature: runFeature) + } + + package var isActive: Bool { maven.isRunning || run.isRunning || tests.isRunning } + package var hasActiveExecutionWork: Bool { isActive } + package func activate(context: ModuleContext) { + configureModuleLeases { reason in context.leases.acquireLease(reason: reason) } + } + package func prepareForSleep() async throws { + guard !isActive else { throw ExecutionModuleSleepError.activeWork } + } + + package func configureModuleLeases(acquire: @escaping @MainActor (String) -> ModuleLease) { + maven.$isRunning.removeDuplicates().sink { [weak self] active in + guard let self else { return } + if active, mavenLease == nil { mavenLease = acquire("Maven build is running") } + if !active { mavenLease?.release(); mavenLease = nil } + }.store(in: &activityObservers) + run.$isRunning.removeDuplicates().sink { [weak self] active in + guard let self else { return } + if active, runLease == nil { runLease = acquire("Run configuration is running") } + if !active { runLease?.release(); runLease = nil } + }.store(in: &activityObservers) + tests.$state.map { $0 == .running }.removeDuplicates().sink { [weak self] active in + guard let self else { return } + if active, testLease == nil { testLease = acquire("Test run is active") } + if !active { testLease?.release(); testLease = nil } + }.store(in: &activityObservers) + } + + package func stop() { + maven.stop(); run.stop(); tests.stop() + releaseLeases() + activityObservers.removeAll() + } + + private func releaseLeases() { + mavenLease?.release(); mavenLease = nil + runLease?.release(); runLease = nil + testLease?.release(); testLease = nil + } +} + + +private struct ExecutionModuleSleepError: LocalizedError { + let errorDescription: String? = "Build, run, or test work is still active." + static let activeWork = Self() +} diff --git a/Sources/LitheExecutionModule/Module/ExecutionModule.swift b/Sources/LitheExecutionModule/Module/ExecutionModule.swift new file mode 100644 index 00000000..ea879ad1 --- /dev/null +++ b/Sources/LitheExecutionModule/Module/ExecutionModule.swift @@ -0,0 +1,76 @@ +import Foundation +import LitheModuleAPI + +@MainActor +package protocol ExecutionServiceGraph: AnyObject { + var mavenFeature: MavenFeatureModel { get } + var runFeature: RunFeatureModel { get } + var tests: LanguageTestService { get } + var projectDevelopment: ProjectDevelopmentFeatureModel { get } + var hasActiveExecutionWork: Bool { get } + func activate(context: ModuleContext) + func prepareForSleep() async throws + func stop() async +} + +@MainActor +public final class ExecutionModuleCapability: NSObject { + package let mavenFeature: MavenFeatureModel + package let runFeature: RunFeatureModel + package let testService: LanguageTestService + package let projectDevelopment: ProjectDevelopmentFeatureModel + fileprivate init(graph: any ExecutionServiceGraph) { + mavenFeature = graph.mavenFeature + runFeature = graph.runFeature + testService = graph.tests + projectDevelopment = graph.projectDevelopment + } +} + +@MainActor +public final class ExecutionModule: LitheModule { + public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .execution) + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .execution)! + public let manifest = moduleManifest + private let makeGraph: @MainActor () -> any ExecutionServiceGraph + private var graph: (any ExecutionServiceGraph)? + private var capability: ExecutionModuleCapability? + + package init(makeGraph: @escaping @MainActor () -> any ExecutionServiceGraph) { + self.makeGraph = makeGraph + } + + public func activate(context: ModuleContext) async throws { + guard graph == nil else { return } + let graph = makeGraph() + graph.activate(context: context) + context.resources.register(ExecutionGraphResource(graph: graph)) + self.graph = graph + capability = ExecutionModuleCapability(graph: graph) + } + public func prepareForSleep() async throws { try await graph?.prepareForSleep() } + public func sleep() async { await releaseGraph() } + public func shutdown() async { await releaseGraph() } + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.executionWorkspace: capability] + } + public func contributions() -> [ModuleContribution] { + Self.moduleContributions + } + + private func releaseGraph() async { + await graph?.stop() + capability = nil + graph = nil + } +} + +@MainActor +private final class ExecutionGraphResource: ModuleResource { + let moduleResourceKind = "execution-processes" + private let graph: any ExecutionServiceGraph + init(graph: any ExecutionServiceGraph) { self.graph = graph } + var isModuleResourceActive: Bool { graph.hasActiveExecutionWork } + func stopModuleResource() async { await graph.stop() } +} diff --git a/Sources/LitheExecutionModule/Services/LanguageTestService.swift b/Sources/LitheExecutionModule/Services/LanguageTestService.swift new file mode 100644 index 00000000..724ff4e1 --- /dev/null +++ b/Sources/LitheExecutionModule/Services/LanguageTestService.swift @@ -0,0 +1,393 @@ +import Combine +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +package enum LanguageTestRunState: Equatable, Sendable { + case idle + case running + case passed + case failed(exitCode: Int32) + case cancelled +} + +@MainActor +package final class LanguageTestService: ObservableObject { + @Published package private(set) var itemsByProviderID: [String: [LanguageTestItem]] = [:] + @Published package private(set) var state: LanguageTestRunState = .idle + @Published package private(set) var activePlan: LanguageTestPlan? + @Published package private(set) var output = "" + @Published package private(set) var errorMessage: String? + + private let catalog: LanguageProviderCatalog + private let registry: LanguageTestProviderRegistry + private let executableResolver: any RunExecutableResolving + private let processFactory: () -> any StreamingProcess + private let extensionRequiredLanguageIDs: Set + private var process: (any StreamingProcess)? + private var extensionSession: (any LanguageExecutionSession)? + private var languageTestExtensions: [String: RegisteredLanguageTestExtension] = [:] + private var activeOperationID: String? + private let maximumOutputCharacters = 400_000 + + package init( + catalog: LanguageProviderCatalog = .compatibilityFallback, + registry: LanguageTestProviderRegistry? = nil, + executableResolver: any RunExecutableResolving, + processFactory: @escaping () -> any StreamingProcess, + extensionRequiredLanguageIDs: Set = [] + ) { + self.catalog = catalog + self.registry = registry ?? .standard(catalog: catalog) + self.executableResolver = executableResolver + self.processFactory = processFactory + self.extensionRequiredLanguageIDs = extensionRequiredLanguageIDs + } + + package var isRunning: Bool { state == .running } + + @discardableResult + package func registerLanguageTestExtension( + _ provider: any LanguageTestExtensionProviding, + support: LanguageSupportDeclaration + ) -> Bool { + guard provider.languageID == support.id, + support.testingModuleID != nil else { return false } + languageTestExtensions[support.id] = RegisteredLanguageTestExtension( + support: support, + provider: provider + ) + return true + } + + package func unregisterLanguageTestExtension(languageID: String) { + if activePlan?.providerID == languageID { stop() } + languageTestExtensions[languageID] = nil + itemsByProviderID[languageID] = nil + } + + package func discover(workspaceURL: URL, files: [URL]) { + var discovered: [String: [LanguageTestItem]] = [:] + let context = LanguageTestContext( + workspaceURL: workspaceURL, + projectFiles: files + ) + for descriptor in catalog.descriptors where descriptor.capabilities.contains(.testing) { + let items: [LanguageTestItem] + let extensionProvider = languageTestExtensions[descriptor.id]?.provider + if extensionRequiredLanguageIDs.contains(descriptor.id), extensionProvider == nil { + continue + } + if let provider = extensionProvider { + do { + items = try provider.discoverTests(for: LanguageTestExtensionDiscoveryRequest( + relativeProjectFilePaths: relativeProjectPaths( + context.projectFiles, + workspaceURL: context.workspaceURL + ) + )).compactMap { + testItem(from: $0, providerID: descriptor.id, workspaceURL: context.workspaceURL) + } + } catch { + errorMessage = error.localizedDescription + continue + } + } else { + guard let provider = registry.provider(id: descriptor.id) else { continue } + items = provider.discoverTests(context: context) + } + if !items.isEmpty { discovered[descriptor.id] = items } + } + itemsByProviderID = discovered + } + + @discardableResult + package func run( + providerID: String, + scope: LanguageTestScope, + workspaceURL: URL, + projectFiles: [URL] = [], + options: RunOptions = RunOptions() + ) -> Bool { + stop(markCancelled: false) + output = "" + errorMessage = nil + let root = workspaceURL.standardizedFileURL + do { + let plan: LanguageTestPlan + let extensionProvider = languageTestExtensions[providerID]?.provider + if extensionRequiredLanguageIDs.contains(providerID), extensionProvider == nil { + throw LanguageTestPlanError.extensionNotActive(providerID) + } + if let extensionProvider { + let extensionPlan = try extensionProvider.testPlan(for: LanguageTestExtensionRequest( + scope: try extensionScope(scope, workspaceURL: root), + relativeProjectFilePaths: relativeProjectPaths( + projectFiles, + workspaceURL: root + ) + )) + plan = LanguageTestPlan( + providerID: providerID, + label: extensionPlan.label, + frameworkID: extensionPlan.frameworkID, + launchPlan: Self.sharedLaunchPlan(from: extensionPlan.launchPlan) + ) + } else { + guard let provider = registry.provider(id: providerID) else { + throw LanguageTestPlanError.unsupportedProvider(providerID) + } + plan = try provider.testPlan( + scope: scope, + context: LanguageTestContext( + workspaceURL: root, + projectFiles: projectFiles + ) + ) + } + let resolved = try executableResolver.resolve( + plan.launchPlan, + projectURL: root, + options: options + ) + let workingDirectory = try resolvedWorkingDirectory( + plan.launchPlan.workingDirectory, + workspaceURL: root + ) + let operationID = UUID().uuidString + activeOperationID = operationID + activePlan = plan + state = .running + append("$ \(resolved.executableURL.lastPathComponent) \(plan.launchPlan.arguments.joined(separator: " "))\n\n") + if let extensionProvider { + let session = extensionProvider.makeTestExecutionSession() + configureExtensionSession(session, operationID: operationID) + extensionSession = session + try session.start(LanguageExecutionProcessRequest( + operationID: operationID, + executablePath: resolved.executableURL.path, + arguments: plan.launchPlan.arguments, + workingDirectory: workingDirectory.path, + environment: resolved.environment + )) + } else { + let process = processFactory() + configureProcess(process, operationID: operationID) + self.process = process + try process.start(ProcessRequest( + operationID: operationID, + executablePath: resolved.executableURL.path, + arguments: plan.launchPlan.arguments, + workingDirectory: workingDirectory.path, + environment: resolved.environment + )) + } + return true + } catch { + process?.stop() + process = nil + extensionSession?.stop() + extensionSession = nil + activeOperationID = nil + activePlan = nil + state = .failed(exitCode: -1) + errorMessage = error.localizedDescription + append(error.localizedDescription + "\n") + return false + } + } + + package func stop() { stop(markCancelled: true) } + + package func reset() { + stop(markCancelled: false) + itemsByProviderID = [:] + activePlan = nil + output = "" + errorMessage = nil + state = .idle + } + + package func clearOutput() { output = "" } + + private func stop(markCancelled: Bool) { + let wasRunning = state == .running + activeOperationID = nil + process?.stop() + process = nil + extensionSession?.stop() + extensionSession = nil + if wasRunning && markCancelled { state = .cancelled } + else if !markCancelled { state = .idle } + } + + private func resolvedWorkingDirectory( + _ value: String, + workspaceURL: URL + ) throws -> URL { + let candidate: URL + if value.isEmpty || value == "." { + candidate = workspaceURL + } else if value.hasPrefix("/") { + candidate = URL(fileURLWithPath: value, isDirectory: true).standardizedFileURL + } else { + candidate = workspaceURL.appendingPathComponent(value, isDirectory: true).standardizedFileURL + } + guard candidate.path == workspaceURL.path || candidate.path.hasPrefix(workspaceURL.path + "/") else { + throw LanguageTestPlanError.fileOutsideWorkspace(candidate) + } + return candidate + } + + private func append(_ text: String) { + output += text + if output.count > maximumOutputCharacters { + output.removeFirst(output.count - maximumOutputCharacters) + } + } + + private func configureProcess(_ process: any StreamingProcess, operationID: String) { + process.onOutput = { [weak self] chunk in + Task { @MainActor [weak self] in + guard self?.activeOperationID == operationID else { return } + self?.append(chunk) + } + } + process.onTermination = { [weak self] exitCode in + Task { @MainActor [weak self] in + self?.finish(operationID: operationID, exitCode: exitCode) + } + } + } + + private func configureExtensionSession( + _ session: any LanguageExecutionSession, + operationID: String + ) { + session.onOutput = { [weak self] chunk in + Task { @MainActor [weak self] in + guard self?.activeOperationID == operationID else { return } + self?.append(chunk) + } + } + session.onTermination = { [weak self] exitCode in + Task { @MainActor [weak self] in + self?.finish(operationID: operationID, exitCode: exitCode) + } + } + session.onStateChange = { [weak self] event in + guard event.operationID == operationID, + event.state == .failed else { return } + Task { @MainActor [weak self] in + guard let self, self.activeOperationID == operationID else { return } + if let message = event.message, !message.isEmpty { + self.errorMessage = message + self.append(message + "\n") + } + self.finish(operationID: operationID, exitCode: event.exitCode ?? 1) + } + } + } + + private func finish(operationID: String, exitCode: Int32) { + guard activeOperationID == operationID else { return } + state = exitCode == 0 ? .passed : .failed(exitCode: exitCode) + activeOperationID = nil + process = nil + extensionSession = nil + } + + private func relativeProjectPaths(_ files: [URL], workspaceURL: URL) -> [String] { + files.compactMap { relativePath($0, workspaceURL: workspaceURL) }.sorted() + } + + private func relativePath(_ fileURL: URL, workspaceURL: URL) -> String? { + let filePath = fileURL.standardizedFileURL.path + let rootPath = workspaceURL.standardizedFileURL.path + guard filePath.hasPrefix(rootPath + "/") else { return nil } + return String(filePath.dropFirst(rootPath.count + 1)) + } + + private func testItem( + from item: LanguageTestExtensionItem, + providerID: String, + workspaceURL: URL + ) -> LanguageTestItem? { + let kind: LanguageTestItemKind + switch item.kind { + case .workspace: kind = .workspace + case .file: kind = .file + case .testCase: kind = .testCase + } + let fileURL: URL? + if let path = item.relativeFilePath { + guard !path.hasPrefix("/"), !path.split(separator: "/").contains("..") else { return nil } + fileURL = workspaceURL.appendingPathComponent(path).standardizedFileURL + } else { + fileURL = nil + } + return LanguageTestItem( + id: item.id, + providerID: providerID, + label: item.label, + kind: kind, + fileURL: fileURL + ) + } + + private func extensionScope( + _ scope: LanguageTestScope, + workspaceURL: URL + ) throws -> LanguageTestExtensionScope { + switch scope { + case .workspace: + return .workspace + case .file(let fileURL): + guard let path = relativePath(fileURL, workspaceURL: workspaceURL) else { + throw LanguageTestPlanError.fileOutsideWorkspace(fileURL) + } + return .file(relativePath: path) + case .testCase(let identifier, let fileURL): + let path: String? + if let fileURL { + guard let relative = relativePath(fileURL, workspaceURL: workspaceURL) else { + throw LanguageTestPlanError.fileOutsideWorkspace(fileURL) + } + path = relative + } else { + path = nil + } + return .testCase(identifier: identifier, relativeFilePath: path) + } + } + + private static func sharedLaunchPlan( + from plan: LanguageRunExtensionPlan + ) -> SharedLaunchPlan { + let executable: SharedLaunchPlan.Executable + switch plan.executable { + case .toolchain(let id): executable = .toolchain(id) + case .command(let command): executable = .command(command) + } + return SharedLaunchPlan( + executable: executable, + arguments: plan.arguments, + workingDirectory: plan.workingDirectory, + environment: plan.environment + ) + } +} + +@MainActor +private final class RegisteredLanguageTestExtension { + let support: LanguageSupportDeclaration + weak var provider: (any LanguageTestExtensionProviding)? + + init( + support: LanguageSupportDeclaration, + provider: any LanguageTestExtensionProviding + ) { + self.support = support + self.provider = provider + } +} diff --git a/Sources/Lithe/Services/MavenService.swift b/Sources/LitheExecutionModule/Services/MavenService.swift similarity index 79% rename from Sources/Lithe/Services/MavenService.swift rename to Sources/LitheExecutionModule/Services/MavenService.swift index b223df48..136d8151 100644 --- a/Sources/Lithe/Services/MavenService.swift +++ b/Sources/LitheExecutionModule/Services/MavenService.swift @@ -1,30 +1,32 @@ +import Combine import Foundation +import LitheCoreContracts @MainActor -final class MavenService: ObservableObject { - @Published private(set) var project: MavenProject? - @Published private(set) var isLoadingProject = false - @Published private(set) var isRunning = false - @Published private(set) var runningTitle: String? - @Published private(set) var output = "" - @Published private(set) var issues: [MavenBuildIssue] = [] - @Published private(set) var lastExitCode: Int32? +package final class MavenService: ObservableObject { + @Published package private(set) var project: MavenProject? + @Published package private(set) var isLoadingProject = false + @Published package private(set) var isRunning = false + @Published package private(set) var runningTitle: String? + @Published package private(set) var output = "" + @Published package private(set) var issues: [MavenBuildIssue] = [] + @Published package private(set) var lastExitCode: Int32? private let process: any StreamingProcess - private let javaMavenOperations: any JavaMavenOperations + private let mavenOperations: any MavenProjectOperations private var projectLoadID = UUID() private let maximumOutputCharacters = 500_000 - private let runtimeService: ProjectRuntimeService + private let runtimeService: any MavenRuntimePort private var activeOperationID: String? - init( - runtimeService: ProjectRuntimeService, + package init( + runtimeService: any MavenRuntimePort, process: any StreamingProcess, - javaMavenOperations: any JavaMavenOperations + mavenOperations: any MavenProjectOperations ) { self.runtimeService = runtimeService self.process = process - self.javaMavenOperations = javaMavenOperations + self.mavenOperations = mavenOperations process.onOutput = { [weak self] chunk in Task { @MainActor [weak self] in self?.append(chunk) @@ -42,21 +44,21 @@ final class MavenService: ObservableObject { } } - func loadProject(at workspaceURL: URL, files: [URL]) async { + package func loadProject(at workspaceURL: URL, files: [URL]) async { let loadID = UUID() projectLoadID = loadID isLoadingProject = true let rootURL = workspaceURL.standardizedFileURL - let javaMavenOperations = javaMavenOperations + let mavenOperations = mavenOperations let scannedProject = await Task.detached(priority: .utility) { - javaMavenOperations.scanMavenProject(at: rootURL, files: files) + mavenOperations.scanMavenProject(at: rootURL, files: files) }.value guard !Task.isCancelled, projectLoadID == loadID else { return } project = scannedProject isLoadingProject = false } - func run( + package func run( phase: MavenLifecyclePhase, module: MavenModule?, profiles: Set @@ -72,14 +74,14 @@ final class MavenService: ObservableObject { startProcess(arguments: arguments, title: taskTitle(phase: phase, module: module)) } - func stop() { + package func stop() { process.stop() isRunning = false runningTitle = nil activeOperationID = nil } - func reset() { + package func reset() { stop() projectLoadID = UUID() project = nil @@ -89,7 +91,7 @@ final class MavenService: ObservableObject { lastExitCode = nil } - func clearOutput() { + package func clearOutput() { output = "" issues = [] lastExitCode = nil @@ -130,7 +132,7 @@ final class MavenService: ObservableObject { executablePath: executable.path, arguments: arguments, workingDirectory: project.rootURL.path, - environment: runtimeService.environment(for: .maven) + environment: runtimeService.mavenProcessEnvironment() )) } catch { append("Unable to start Maven: " + error.localizedDescription + "\n") @@ -153,7 +155,7 @@ final class MavenService: ObservableObject { isRunning = false runningTitle = nil lastExitCode = exitCode - issues = javaMavenOperations.mavenDiagnostics(output: output, projectRoot: project.rootURL) + issues = mavenOperations.mavenDiagnostics(output: output, projectRoot: project.rootURL) activeOperationID = nil } diff --git a/Sources/Lithe/Services/JavaRunService.swift b/Sources/LitheExecutionModule/Services/RunService.swift similarity index 70% rename from Sources/Lithe/Services/JavaRunService.swift rename to Sources/LitheExecutionModule/Services/RunService.swift index 125285e8..e39407bf 100644 --- a/Sources/Lithe/Services/JavaRunService.swift +++ b/Sources/LitheExecutionModule/Services/RunService.swift @@ -1,40 +1,46 @@ +import Combine import Foundation +import LitheCoreContracts +import LitheModuleAPI @MainActor -final class RunService: ObservableObject { - @Published private(set) var configurations: [RunConfiguration] = [.currentFile] - @Published var selectedConfigurationID = RunConfiguration.currentFileID { +package final class RunService: ObservableObject { + @Published package private(set) var configurations: [RunConfiguration] = [.currentFile] + @Published package var selectedConfigurationID = RunConfiguration.currentFileID { didSet { guard let projectURL else { return } selectedConfigurationIDsByProject[projectURL.path] = selectedConfigurationID - preferences.set(selectedConfigurationID, forKey: selectionPreferenceKey(for: projectURL)) - } - } - @Published private(set) var isLoadingProject = false - @Published private(set) var isRunning = false - @Published private(set) var runningTitle: String? - @Published private(set) var output = "" - @Published private(set) var lastExitCode: Int32? - @Published private(set) var optionsByConfigurationID: [String: RunOptions] = [:] - @Published private(set) var effectiveSourcesByConfigurationID: [String: RunConfigurationSource] = [:] - @Published private(set) var mavenProfiles: [MavenProfile] = [] - @Published private(set) var moduleSessions: [RunSession] = [] - @Published private(set) var portConflicts: [RunPortConflict] = [] - @Published private(set) var configurationStatus: ProjectRunConfigurationStatus = .missing - @Published private(set) var configurationDiagnostics: [RunConfigurationDiagnostic] = [] - @Published private(set) var generationState: RunConfigurationGenerationState = .idle - @Published private(set) var recoveryAction: RunConfigurationRecoveryAction = .regenerate - @Published private(set) var recoveryPath: String? - @Published private(set) var configurationSaveError: String? + preferences.setString(selectedConfigurationID, forKey: selectionPreferenceKey(for: projectURL)) + } + } + @Published package private(set) var isLoadingProject = false + @Published package private(set) var isRunning = false + @Published package private(set) var runningTitle: String? + @Published package private(set) var output = "" + @Published package private(set) var lastExitCode: Int32? + @Published package private(set) var optionsByConfigurationID: [String: RunOptions] = [:] + @Published package private(set) var effectiveSourcesByConfigurationID: [String: RunConfigurationSource] = [:] + @Published package private(set) var mavenProfiles: [MavenProfile] = [] + @Published package private(set) var moduleSessions: [RunSession] = [] + @Published package private(set) var portConflicts: [RunPortConflict] = [] + @Published package private(set) var configurationStatus: ProjectRunConfigurationStatus = .missing + @Published package private(set) var configurationDiagnostics: [RunConfigurationDiagnostic] = [] + @Published package private(set) var generationState: RunConfigurationGenerationState = .idle + @Published package private(set) var recoveryAction: RunConfigurationRecoveryAction = .regenerate + @Published package private(set) var recoveryPath: String? + @Published package private(set) var configurationSaveError: String? private let process: any StreamingProcess private let processFactory: () -> any StreamingProcess - private let fileStorage: any FileStorage - private let preferences: any KeyValueStore - private let javaMavenOperations: any JavaMavenOperations + private let fileAccess: any RunFileAccess + private let preferences: any RunPreferenceStore + private let serverPortParser: any RunServerPortParsing private let runConfigurationOperations: any RunConfigurationOperations private let languageProviderCatalog: LanguageProviderCatalog private let languageRunProviders: LanguageRunProviderRegistry + private let extensionRequiredLanguageIDs: Set + private var languageRunExtensions: [String: RegisteredLanguageRunExtension] = [:] + private var activeLanguageExecutionSession: (any LanguageExecutionSession)? private var projectURL: URL? private var projectFiles: [URL] = [] private var mavenProject: MavenProject? @@ -43,37 +49,37 @@ final class RunService: ObservableObject { private var lastRunConfiguration: RunConfiguration? private var lastCurrentFileURL: URL? private var moduleProcesses: [String: any StreamingProcess] = [:] + private var moduleLanguageExecutionSessions: [String: any LanguageExecutionSession] = [:] private var activeOperationID: String? private var moduleOperationIDs: [String: String] = [:] private let maximumOutputCharacters = 500_000 - private let runtimeService: ProjectRuntimeService + private let runtime: any RunRuntimePort private let executableResolver: any RunExecutableResolving - init( - runtimeService: ProjectRuntimeService, + package init( + runtime: any RunRuntimePort, process: any StreamingProcess, processFactory: @escaping () -> any StreamingProcess, - fileStorage: any FileStorage, - preferences: any KeyValueStore, - javaMavenOperations: any JavaMavenOperations, + fileAccess: any RunFileAccess, + preferences: any RunPreferenceStore, + serverPortParser: any RunServerPortParsing, runConfigurationOperations: any RunConfigurationOperations, - executableResolver: (any RunExecutableResolving)? = nil, - languageProviderCatalog: LanguageProviderCatalog = .standard, - languageRunProviders: LanguageRunProviderRegistry? = nil, - languagePackRegistry: LanguagePackRegistry? = nil + executableResolver: any RunExecutableResolving, + languageProviderCatalog: LanguageProviderCatalog, + languageRunProviders: LanguageRunProviderRegistry, + extensionRequiredLanguageIDs: Set = [] ) { - self.runtimeService = runtimeService + self.runtime = runtime self.process = process self.processFactory = processFactory - self.fileStorage = fileStorage + self.fileAccess = fileAccess self.preferences = preferences - self.javaMavenOperations = javaMavenOperations + self.serverPortParser = serverPortParser self.runConfigurationOperations = runConfigurationOperations - self.languageProviderCatalog = languagePackRegistry?.catalog ?? languageProviderCatalog - self.languageRunProviders = languagePackRegistry?.runProviders - ?? languageRunProviders - ?? .standard(catalog: languageProviderCatalog) - self.executableResolver = executableResolver ?? RunExecutableResolver(runtimeService: runtimeService) + self.languageProviderCatalog = languageProviderCatalog + self.languageRunProviders = languageRunProviders + self.extensionRequiredLanguageIDs = extensionRequiredLanguageIDs + self.executableResolver = executableResolver process.onOutput = { [weak self] chunk in Task { @MainActor [weak self] in self?.append(chunk) @@ -91,12 +97,33 @@ final class RunService: ObservableObject { } } - var selectedConfiguration: RunConfiguration? { + package var selectedConfiguration: RunConfiguration? { configurations.first { $0.id == selectedConfigurationID } } + package var lastRunFileURL: URL? { lastCurrentFileURL } + package var lastConfiguration: RunConfiguration? { lastRunConfiguration } + + @discardableResult + package func registerLanguageRunExtension( + _ provider: any LanguageRunExtensionProviding, + support: LanguageSupportDeclaration + ) -> Bool { + guard provider.languageID == support.id, + support.executionModuleID != nil else { return false } + languageRunExtensions[support.id] = RegisteredLanguageRunExtension( + support: support, + provider: provider + ) + return true + } + + package func unregisterLanguageRunExtension(languageID: String) { + languageRunExtensions[languageID] = nil + } + /// 供输出文本定位源码使用:项目根 + 各 Maven 模块根。 - var sourceSearchRoots: [URL] { + package var sourceSearchRoots: [URL] { var roots = projectURL.map { [$0] } ?? [] if let mavenProject { roots.append(contentsOf: mavenProject.allModules.map(\.url)) @@ -104,7 +131,7 @@ final class RunService: ObservableObject { return roots } - func loadProject( + package func loadProject( at projectURL: URL, files: [URL], mavenProject: MavenProject? @@ -167,7 +194,7 @@ final class RunService: ObservableObject { } } - func generateRunConfigurations() async { + package func generateRunConfigurations() async { guard let projectURL else { return } let loadID = projectLoadID isLoadingProject = true @@ -237,10 +264,10 @@ final class RunService: ObservableObject { } } - func select(_ configuration: RunConfiguration) { + package func select(_ configuration: RunConfiguration) { selectedConfigurationID = configuration.id if configuration.kind.capabilities.contains(.javaRuntime) { - runtimeService.setActiveServiceJavaHomePath(options(for: configuration).javaHomePath) + runtime.setActiveServiceJavaHomePath(options(for: configuration).javaHomePath) } } @@ -249,15 +276,15 @@ final class RunService: ObservableObject { + projectURL.standardizedFileURL.path.replacingOccurrences(of: "/", with: "_") } - func options(for configuration: RunConfiguration) -> RunOptions { + package func options(for configuration: RunConfiguration) -> RunOptions { optionsByConfigurationID[configuration.id] ?? RunOptions() } - func source(for configuration: RunConfiguration) -> RunConfigurationSource { + package func source(for configuration: RunConfiguration) -> RunConfigurationSource { effectiveSourcesByConfigurationID[configuration.id] ?? .generated } - func serviceURL(for configuration: RunConfiguration) -> URL? { + package func serviceURL(for configuration: RunConfiguration) -> URL? { guard configuration.execution == .service, let port = configuredPort(for: configuration), (1...65_535).contains(port) else { @@ -267,7 +294,7 @@ final class RunService: ObservableObject { } @discardableResult - func updateOptions( + package func updateOptions( _ options: RunOptions, for configuration: RunConfiguration, scope: RunConfigurationSaveScope = .local @@ -294,7 +321,7 @@ final class RunService: ObservableObject { } optionsByConfigurationID[configuration.id] = options if configuration.kind.capabilities.contains(.javaRuntime) { - runtimeService.setActiveServiceJavaHomePath(options.javaHomePath) + runtime.setActiveServiceJavaHomePath(options.javaHomePath) } effectiveSourcesByConfigurationID[configuration.id] = scope == .local ? .local : .project if let projectURL, @@ -313,13 +340,13 @@ final class RunService: ObservableObject { return true } - func resetOptions(for configuration: RunConfiguration) { + package func resetOptions(for configuration: RunConfiguration) { let options = RunOptions() updateOptions(options, for: configuration) } @discardableResult - func createConfiguration(_ draft: RunConfigurationDraft) -> Bool { + package func createConfiguration(_ draft: RunConfigurationDraft) -> Bool { configurationSaveError = nil guard configurationStatus == .ready, let projectURL else { configurationSaveError = "Identify the project before creating a run configuration." @@ -349,17 +376,17 @@ final class RunService: ObservableObject { } } - func runSelected(currentFileURL: URL?) { + package func runSelected(currentFileURL: URL?) { guard let configuration = selectedConfiguration else { return } run(configuration: configuration, currentFileURL: currentFileURL) } - func restart() { + package func restart() { guard let lastRunConfiguration else { return } run(configuration: lastRunConfiguration, currentFileURL: lastCurrentFileURL) } - func run(configuration: RunConfiguration, currentFileURL: URL?) { + package func run(configuration: RunConfiguration, currentFileURL: URL?) { stop() output = "" lastExitCode = nil @@ -370,7 +397,7 @@ final class RunService: ObservableObject { && isGenericCurrentFile(currentFileURL) if !usesGenericCurrentFile { let configuredJavaHome = options.javaHomePath.trimmingCharacters(in: .whitespacesAndNewlines) - if !configuredJavaHome.isEmpty && runtimeService.javaHomeURL(overridePath: configuredJavaHome) == nil { + if !configuredJavaHome.isEmpty && runtime.javaHomeURL(overridePath: configuredJavaHome) == nil { fail("JDK Home does not point to a directory: " + configuredJavaHome) return } @@ -390,14 +417,38 @@ final class RunService: ObservableObject { } let currentFile = currentFileURL.flatMap { relativePath(for: $0, root: projectURL) } let planClassPath = currentFileURL.flatMap(classPath(for:)) + let requiredExtensionLanguageID = configuration.kind == .currentFile + ? currentFileURL.flatMap { languageProviderCatalog.provider(for: $0)?.id } + : configuration.kind.providerID + if let requiredExtensionLanguageID, + extensionRequiredLanguageIDs.contains(requiredExtensionLanguageID), + languageRunExtension(providerID: requiredExtensionLanguageID) == nil { + fail("\(requiredExtensionLanguageID) execution extension is not active.") + return + } let plan: SharedLaunchPlan + var extensionSession: (any LanguageExecutionSession)? do { if usesGenericCurrentFile, let currentFileURL { - plan = try languageRunProviders.launchPlan( - for: currentFileURL, - workspaceURL: projectURL, - options: options - ) + if let provider = languageRunExtension(for: currentFileURL) { + guard let relativeFilePath = relativePath(for: currentFileURL, root: projectURL) else { + throw LanguageRunPlanError.fileOutsideWorkspace(currentFileURL) + } + plan = Self.sharedLaunchPlan(from: try provider.launchPlan( + for: LanguageRunExtensionRequest( + relativeFilePath: relativeFilePath, + arguments: RunArgumentParser.parse(options.arguments), + environment: options.environment + ) + )) + extensionSession = provider.makeExecutionSession() + } else { + plan = try languageRunProviders.launchPlan( + for: currentFileURL, + workspaceURL: projectURL, + options: options + ) + } } else { plan = try runConfigurationOperations.launchPlan( at: projectURL, @@ -406,6 +457,9 @@ final class RunService: ObservableObject { classPath: planClassPath, debugPort: nil ) + extensionSession = languageRunExtension( + providerID: configuration.kind.providerID + )?.makeExecutionSession() } } catch { fail(error.localizedDescription) @@ -428,19 +482,32 @@ final class RunService: ObservableObject { let operationID = UUID().uuidString activeOperationID = operationID do { - try process.start(ProcessRequest( - operationID: operationID, - executablePath: resolved.executableURL.path, - arguments: arguments, - workingDirectory: workingDirectory.path, - environment: resolved.environment - )) + if let extensionSession { + activeLanguageExecutionSession = extensionSession + configureLanguageExecutionSession(extensionSession) + try extensionSession.start(LanguageExecutionProcessRequest( + operationID: operationID, + executablePath: resolved.executableURL.path, + arguments: arguments, + workingDirectory: workingDirectory.path, + environment: resolved.environment + )) + } else { + try process.start(ProcessRequest( + operationID: operationID, + executablePath: resolved.executableURL.path, + arguments: arguments, + workingDirectory: workingDirectory.path, + environment: resolved.environment + )) + } } catch { + activeLanguageExecutionSession = nil fail("Unable to start " + configuration.name + ": " + error.localizedDescription) } } - func runAllServices() { + package func runAllServices() { let serviceConfigurations = configurations.filter { $0.execution == .service } guard !serviceConfigurations.isEmpty else { fail(String(localized: "No runnable services were detected in this project.")) @@ -453,48 +520,51 @@ final class RunService: ObservableObject { } } - func startConfiguration(_ configuration: RunConfiguration) { + package func startConfiguration(_ configuration: RunConfiguration) { guard configuration.kind != .currentFile else { return } stopModule(sessionID: configuration.id) startModuleSession(configuration) } - func stopModule(_ session: RunSession) { + package func stopModule(_ session: RunSession) { stopModule(sessionID: session.id) } - func restartModule(_ session: RunSession) { + package func restartModule(_ session: RunSession) { guard let configuration = configurations.first(where: { $0.id == session.configurationID }) else { return } stopModule(sessionID: session.id) moduleSessions.removeAll { $0.id == session.id } startModuleSession(configuration) } - func stopAllServices() { - for sessionID in Array(moduleProcesses.keys) { + package func stopAllServices() { + let sessionIDs = Set(moduleProcesses.keys).union(moduleLanguageExecutionSessions.keys) + for sessionID in sessionIDs { stopModule(sessionID: sessionID) } } - func clearModuleOutput() { + package func clearModuleOutput() { for index in moduleSessions.indices { moduleSessions[index].output = "" } } - func clearModuleOutput(_ session: RunSession) { + package func clearModuleOutput(_ session: RunSession) { guard let index = moduleSessions.firstIndex(where: { $0.id == session.id }) else { return } moduleSessions[index].output = "" } - func stop() { + package func stop() { + activeLanguageExecutionSession?.stop() + activeLanguageExecutionSession = nil process.stop() isRunning = false runningTitle = nil activeOperationID = nil } - func reset() { + package func reset() { stop() stopAllServices() projectLoadID = UUID() @@ -522,7 +592,7 @@ final class RunService: ObservableObject { lastCurrentFileURL = nil } - func clearOutput() { + package func clearOutput() { output = "" lastExitCode = nil } @@ -551,7 +621,7 @@ final class RunService: ObservableObject { mavenProject: MavenProject?, options: RunOptions? = nil ) -> [ProjectToolchainCandidate] { - let runtimeCandidates = runtimeService.runConfigurationToolchainCandidates( + let runtimeCandidates = runtime.runConfigurationToolchainCandidates( for: mavenProject, projectRoot: projectURL, javaHomeOverride: options?.javaHomePath, @@ -619,7 +689,7 @@ final class RunService: ObservableObject { item.configuration.id == preferredConfigurationID && item.configuration.kind.capabilities.contains(.javaRuntime) } ?? resolved.first { $0.configuration.kind.capabilities.contains(.javaRuntime) } - runtimeService.setActiveServiceJavaHomePath(preferredJava?.options.javaHomePath ?? "") + runtime.setActiveServiceJavaHomePath(preferredJava?.options.javaHomePath ?? "") effectiveSourcesByConfigurationID = Dictionary(uniqueKeysWithValues: resolved.map { ($0.configuration.id, $0.source) }) @@ -643,6 +713,7 @@ final class RunService: ObservableObject { } private func finishProcess(exitCode: Int32) { + activeLanguageExecutionSession = nil isRunning = false runningTitle = nil lastExitCode = exitCode @@ -666,6 +737,69 @@ final class RunService: ObservableObject { } } + private func languageRunExtension( + for fileURL: URL + ) -> (any LanguageRunExtensionProviding)? { + languageRunExtensions.values + .filter { $0.support.handles(fileURL: fileURL) } + .sorted { $0.support.id < $1.support.id } + .compactMap(\.provider) + .first + } + + private func languageRunExtension( + providerID: String + ) -> (any LanguageRunExtensionProviding)? { + languageRunExtensions[providerID]?.provider + } + + private func configureLanguageExecutionSession(_ session: any LanguageExecutionSession) { + session.onOutput = { [weak self] chunk in + Task { @MainActor [weak self] in self?.append(chunk) } + } + session.onTermination = { [weak self] exitCode in + Task { @MainActor [weak self] in self?.finishProcess(exitCode: exitCode) } + } + session.onStateChange = { [weak self] event in + Task { @MainActor [weak self] in + self?.consumeLifecycle(ProcessLifecycleEvent( + operationID: event.operationID, + state: Self.processState(event.state), + exitCode: event.exitCode, + message: event.message + )) + } + } + } + + private static func sharedLaunchPlan( + from plan: LanguageRunExtensionPlan + ) -> SharedLaunchPlan { + let executable: SharedLaunchPlan.Executable + switch plan.executable { + case .toolchain(let id): executable = .toolchain(id) + case .command(let command): executable = .command(command) + } + return SharedLaunchPlan( + executable: executable, + arguments: plan.arguments, + workingDirectory: plan.workingDirectory, + environment: plan.environment + ) + } + + private static func processState( + _ state: LanguageExecutionLifecycleState + ) -> ProcessLifecycleState { + switch state { + case .starting: .starting + case .running: .running + case .stopping: .stopping + case .finished: .finished + case .failed: .failed + } + } + private func append(_ value: String) { let continuing = !(output.isEmpty || output.hasSuffix("\n")) output.append( @@ -696,7 +830,7 @@ final class RunService: ObservableObject { for root in candidateRoots { let classesURL = root.appendingPathComponent("target/classes", isDirectory: true) guard seenPaths.insert(classesURL.standardizedFileURL.path).inserted else { continue } - guard fileStorage.metadata(for: classesURL)?.isDirectory == true else { continue } + guard fileAccess.isDirectory(at: classesURL) else { continue } return classesURL.standardizedFileURL.path } return nil @@ -706,11 +840,23 @@ final class RunService: ObservableObject { guard configurationStatus == .ready, let projectURL else { return } moduleSessions.removeAll { $0.id == configuration.id } + if extensionRequiredLanguageIDs.contains(configuration.kind.providerID), + languageRunExtension(providerID: configuration.kind.providerID) == nil { + moduleSessions.append(RunSession( + id: configuration.id, + configurationID: configuration.id, + title: configuration.name, + output: "\(configuration.kind.providerID) execution extension is not active.\n", + isRunning: false, + exitCode: 1 + )) + return + } let options = self.options(for: configuration) let configuredJavaHome = (options.mavenJavaHomePath.isEmpty ? options.javaHomePath : options.mavenJavaHomePath).trimmingCharacters(in: .whitespacesAndNewlines) - if !configuredJavaHome.isEmpty && runtimeService.mavenJavaHomeURL(overridePath: configuredJavaHome) == nil { + if !configuredJavaHome.isEmpty && runtime.mavenJavaHomeURL(overridePath: configuredJavaHome) == nil { moduleSessions.append(RunSession( id: configuration.id, configurationID: configuration.id, @@ -772,36 +918,38 @@ final class RunService: ObservableObject { ) moduleSessions.append(session) - let process = processFactory() - process.onOutput = { [weak self] chunk in - Task { @MainActor [weak self] in - self?.appendModuleOutput(chunk, sessionID: configuration.id) - } - } - process.onTermination = { [weak self] exitCode in - Task { @MainActor [weak self] in - self?.finishModule(sessionID: configuration.id, exitCode: exitCode) - } - } let operationID = UUID().uuidString - process.onStateChange = { [weak self] event in - Task { @MainActor [weak self] in - self?.consumeModuleLifecycle(event, sessionID: configuration.id) - } - } - - moduleProcesses[configuration.id] = process moduleOperationIDs[configuration.id] = operationID do { - try process.start(ProcessRequest( - operationID: operationID, - executablePath: resolved.executableURL.path, - arguments: arguments, - workingDirectory: workingDirectory.path, - environment: resolved.environment - )) + if let provider = languageRunExtension(providerID: configuration.kind.providerID) { + let extensionSession = provider.makeExecutionSession() + configureModuleLanguageExecutionSession( + extensionSession, + sessionID: configuration.id + ) + moduleLanguageExecutionSessions[configuration.id] = extensionSession + try extensionSession.start(LanguageExecutionProcessRequest( + operationID: operationID, + executablePath: resolved.executableURL.path, + arguments: arguments, + workingDirectory: workingDirectory.path, + environment: resolved.environment + )) + } else { + let process = processFactory() + configureModuleProcess(process, sessionID: configuration.id) + moduleProcesses[configuration.id] = process + try process.start(ProcessRequest( + operationID: operationID, + executablePath: resolved.executableURL.path, + arguments: arguments, + workingDirectory: workingDirectory.path, + environment: resolved.environment + )) + } } catch { moduleProcesses[configuration.id] = nil + moduleLanguageExecutionSessions[configuration.id] = nil moduleOperationIDs[configuration.id] = nil if let index = moduleSessions.firstIndex(where: { $0.id == configuration.id }) { moduleSessions[index].isRunning = false @@ -817,6 +965,8 @@ final class RunService: ObservableObject { private func stopModule(sessionID: String) { moduleProcesses[sessionID]?.stop() moduleProcesses[sessionID] = nil + moduleLanguageExecutionSessions[sessionID]?.stop() + moduleLanguageExecutionSessions[sessionID] = nil moduleOperationIDs[sessionID] = nil if let index = moduleSessions.firstIndex(where: { $0.id == sessionID }) { moduleSessions[index].isRunning = false @@ -824,12 +974,14 @@ final class RunService: ObservableObject { } private func finishModule(sessionID: String, exitCode: Int32) { - guard moduleProcesses[sessionID] != nil else { return } + guard moduleProcesses[sessionID] != nil + || moduleLanguageExecutionSessions[sessionID] != nil else { return } if let index = moduleSessions.firstIndex(where: { $0.id == sessionID }) { moduleSessions[index].isRunning = false moduleSessions[index].exitCode = exitCode } moduleProcesses[sessionID] = nil + moduleLanguageExecutionSessions[sessionID] = nil moduleOperationIDs[sessionID] = nil } @@ -856,13 +1008,64 @@ final class RunService: ObservableObject { } private func reconcileModuleSessions(validConfigurationIDs: Set) { - let staleSessionIDs = moduleProcesses.keys.filter { !validConfigurationIDs.contains($0) } + let activeSessionIDs = Set(moduleProcesses.keys).union(moduleLanguageExecutionSessions.keys) + let staleSessionIDs = activeSessionIDs.filter { !validConfigurationIDs.contains($0) } for sessionID in staleSessionIDs { stopModule(sessionID: sessionID) } moduleSessions.removeAll { !validConfigurationIDs.contains($0.configurationID) } } + private func configureModuleProcess( + _ process: any StreamingProcess, + sessionID: String + ) { + process.onOutput = { [weak self] chunk in + Task { @MainActor [weak self] in + self?.appendModuleOutput(chunk, sessionID: sessionID) + } + } + process.onTermination = { [weak self] exitCode in + Task { @MainActor [weak self] in + self?.finishModule(sessionID: sessionID, exitCode: exitCode) + } + } + process.onStateChange = { [weak self] event in + Task { @MainActor [weak self] in + self?.consumeModuleLifecycle(event, sessionID: sessionID) + } + } + } + + private func configureModuleLanguageExecutionSession( + _ session: any LanguageExecutionSession, + sessionID: String + ) { + session.onOutput = { [weak self] chunk in + Task { @MainActor [weak self] in + self?.appendModuleOutput(chunk, sessionID: sessionID) + } + } + session.onTermination = { [weak self] exitCode in + Task { @MainActor [weak self] in + self?.finishModule(sessionID: sessionID, exitCode: exitCode) + } + } + session.onStateChange = { [weak self] event in + Task { @MainActor [weak self] in + self?.consumeModuleLifecycle( + ProcessLifecycleEvent( + operationID: event.operationID, + state: Self.processState(event.state), + exitCode: event.exitCode, + message: event.message + ), + sessionID: sessionID + ) + } + } + } + private func appendModuleOutput(_ value: String, sessionID: String) { guard let index = moduleSessions.firstIndex(where: { $0.id == sessionID }) else { return } let existing = moduleSessions[index].output @@ -924,9 +1127,9 @@ final class RunService: ObservableObject { (name.hasSuffix(".properties") || name.hasSuffix(".yml") || name.hasSuffix(".yaml")))) } for fileURL in resourceFiles { - guard let data = try? fileStorage.readData(from: fileURL, options: []), + guard let data = try? fileAccess.readData(from: fileURL), let contents = String(data: data, encoding: .utf8), - let port = javaMavenOperations.serverPort( + let port = serverPortParser.serverPort( content: contents, fileExtension: fileURL.pathExtension.lowercased() ) else { @@ -971,7 +1174,7 @@ final class RunService: ObservableObject { ? URL(fileURLWithPath: trimmed) : URL(fileURLWithPath: trimmed, relativeTo: projectURL ?? fallback) let standardized = url.standardizedFileURL - guard fileStorage.metadata(for: standardized)?.isDirectory == true else { return fallback } + guard fileAccess.isDirectory(at: standardized) else { return fallback } return standardized } @@ -993,10 +1196,24 @@ final class RunService: ObservableObject { private func persist(_ options: RunOptions, for configurationID: String) { guard let key = optionsKey(for: configurationID), let data = try? JSONEncoder().encode(options) else { return } - preferences.set(data, forKey: key) + preferences.setData(data, forKey: key) + } +} + +@MainActor +private final class RegisteredLanguageRunExtension { + let support: LanguageSupportDeclaration + weak var provider: (any LanguageRunExtensionProviding)? + + init( + support: LanguageSupportDeclaration, + provider: any LanguageRunExtensionProviding + ) { + self.support = support + self.provider = provider } } /// Compatibility name retained while Java debug remains a provider-specific /// consumer of the generic run service. -typealias JavaRunService = RunService +package typealias JavaRunService = RunService diff --git a/Sources/Lithe/Services/StandardLanguageTestProvider.swift b/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift similarity index 91% rename from Sources/Lithe/Services/StandardLanguageTestProvider.swift rename to Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift index b1afb0f7..9eb6ae4d 100644 --- a/Sources/Lithe/Services/StandardLanguageTestProvider.swift +++ b/Sources/LitheExecutionModule/Services/StandardLanguageTestProvider.swift @@ -1,20 +1,24 @@ import Foundation +import LitheCoreContracts -enum LanguageTestPlanError: LocalizedError, Equatable, Sendable { +package enum LanguageTestPlanError: LocalizedError, Equatable, Sendable { case unsupportedProvider(String) + case extensionNotActive(String) case fileOutsideWorkspace(URL) - var errorDescription: String? { + package var errorDescription: String? { switch self { case .unsupportedProvider(let provider): return "No test runner is configured for \(provider)." + case .extensionNotActive(let provider): + return "\(provider) testing extension is not active." case .fileOutsideWorkspace(let url): return "The test file is outside the current workspace: \(url.path)" } } } -struct StandardLanguageTestProvider: LanguageTestProvider { +package struct StandardLanguageTestProvider: LanguageTestProvider { private enum StandardTestFramework: String { case maven case gradle @@ -27,9 +31,13 @@ struct StandardLanguageTestProvider: LanguageTestProvider { case cargo } - let descriptor: LanguageProviderDescriptor + package let descriptor: LanguageProviderDescriptor - func discoverTests(workspaceURL: URL, files: [URL]) -> [LanguageTestItem] { + package init(descriptor: LanguageProviderDescriptor) { + self.descriptor = descriptor + } + + package func discoverTests(workspaceURL: URL, files: [URL]) -> [LanguageTestItem] { makeTestItems(workspaceURL: workspaceURL, files: files) } @@ -37,7 +45,7 @@ struct StandardLanguageTestProvider: LanguageTestProvider { /// directory is not misidentified as Maven/npm/Cargo merely because it /// contains a file whose name looks like a test. The legacy overload above /// remains permissive for callers that only have a file list. - func discoverTests(context: LanguageTestContext) -> [LanguageTestItem] { + package func discoverTests(context: LanguageTestContext) -> [LanguageTestItem] { guard framework(for: descriptor.id, context: context) != nil else { return [] } return makeTestItems( workspaceURL: context.workspaceURL, @@ -70,7 +78,7 @@ struct StandardLanguageTestProvider: LanguageTestProvider { return [workspace] + discovered } - func testPlan( + package func testPlan( scope: LanguageTestScope, context: LanguageTestContext ) throws -> LanguageTestPlan { @@ -323,23 +331,23 @@ struct StandardLanguageTestProvider: LanguageTestProvider { } } -struct LanguageTestProviderRegistry { +package struct LanguageTestProviderRegistry { private let providersByID: [String: any LanguageTestProvider] - init(providers: [any LanguageTestProvider]) { + package init(providers: [any LanguageTestProvider]) { providersByID = Dictionary(uniqueKeysWithValues: providers.map { ($0.descriptor.id, $0) }) } - static func standard(catalog: LanguageProviderCatalog = .standard) -> Self { + package static func standard(catalog: LanguageProviderCatalog = .compatibilityFallback) -> Self { Self(providers: catalog.descriptors .filter { $0.capabilities.contains(.testing) } .map(StandardLanguageTestProvider.init)) } - func provider(for fileURL: URL, catalog: LanguageProviderCatalog = .standard) -> (any LanguageTestProvider)? { + package func provider(for fileURL: URL, catalog: LanguageProviderCatalog = .compatibilityFallback) -> (any LanguageTestProvider)? { guard let descriptor = catalog.provider(for: fileURL) else { return nil } return providersByID[descriptor.id] } - func provider(id: String) -> (any LanguageTestProvider)? { providersByID[id] } + package func provider(id: String) -> (any LanguageTestProvider)? { providersByID[id] } } diff --git a/Sources/Lithe/Application/GitFeatureModel.swift b/Sources/LitheGitModule/Application/GitFeatureModel.swift similarity index 87% rename from Sources/Lithe/Application/GitFeatureModel.swift rename to Sources/LitheGitModule/Application/GitFeatureModel.swift index 6c4c8d9a..2a35f6cc 100644 --- a/Sources/Lithe/Application/GitFeatureModel.swift +++ b/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -1,55 +1,57 @@ import Combine import Foundation +import LitheCoreContracts +import LitheModuleAPI /// Owns Git state and Git workflows while keeping the UI-specific panel state /// in AppModel. Git command construction and parsing remain in GitService/Core. @MainActor -final class GitFeatureModel: ObservableObject { - @Published private(set) var gitChanges: [GitChange] = [] - @Published private(set) var gitStashes: [GitStash] = [] - @Published private(set) var gitShelves: [GitShelfEntry] = [] - @Published private(set) var isPerformingStashOperation = false - @Published private(set) var isPerformingShelfOperation = false - @Published private(set) var gitRepositoryRoot: URL? - @Published private(set) var currentBranch = "No Git" - @Published var selectedChange: GitChange? - @Published private(set) var selectedDiffPatch = "" - @Published private(set) var diffRows: [DiffRow] = [] - @Published private(set) var diffHunks: [DiffHunk] = [] - @Published var gitDiffWhitespaceMode = GitDiffWhitespaceMode.doNotIgnore - @Published private(set) var isLoadingDiff = false - @Published private(set) var isRefreshingGit = false - @Published var pendingDiscardChange: GitChange? - @Published var pendingDiscardHunk: DiffHunkRequest? - @Published var pendingCheckoutConflict: GitCheckoutConflictRequest? - @Published var pendingPullStrategy: GitPullStrategyRequest? - @Published var pendingIntegrationConflict: GitIntegrationConflictRequest? - @Published var pendingConflictRollback: GitConflictRollbackRequest? - @Published private(set) var pendingStashRestoreConflict: GitStashRestoreConflictRequest? - @Published private(set) var isStashRestoreConflictNoticeVisible = false - @Published private(set) var gitConflictFilterPaths: Set = [] - @Published private(set) var requestedStashReference: String? +package final class GitFeatureModel: ObservableObject { + @Published package private(set) var gitChanges: [GitChange] = [] + @Published package private(set) var gitStashes: [GitStash] = [] + @Published package private(set) var gitShelves: [GitShelfEntry] = [] + @Published package private(set) var isPerformingStashOperation = false + @Published package private(set) var isPerformingShelfOperation = false + @Published package private(set) var gitRepositoryRoot: URL? + @Published package private(set) var currentBranch = "No Git" + @Published package var selectedChange: GitChange? + @Published package private(set) var selectedDiffPatch = "" + @Published package private(set) var diffRows: [DiffRow] = [] + @Published package private(set) var diffHunks: [DiffHunk] = [] + @Published package var gitDiffWhitespaceMode = GitDiffWhitespaceMode.doNotIgnore + @Published package private(set) var isLoadingDiff = false + @Published package private(set) var isRefreshingGit = false + @Published package var pendingDiscardChange: GitChange? + @Published package var pendingDiscardHunk: DiffHunkRequest? + @Published package var pendingCheckoutConflict: GitCheckoutConflictRequest? + @Published package var pendingPullStrategy: GitPullStrategyRequest? + @Published package var pendingIntegrationConflict: GitIntegrationConflictRequest? + @Published package var pendingConflictRollback: GitConflictRollbackRequest? + @Published package private(set) var pendingStashRestoreConflict: GitStashRestoreConflictRequest? + @Published package private(set) var isStashRestoreConflictNoticeVisible = false + @Published package private(set) var gitConflictFilterPaths: Set = [] + @Published package private(set) var requestedStashReference: String? /// Set whenever Git is mid-merge, mid-rebase, mid-cherry-pick, or mid-revert. - @Published var gitOperationState: GitOperationState? - @Published var isResolvingGitOperation = false - @Published private(set) var isCommitting = false - @Published private(set) var gitBlameLines: [URL: [GitBlameLine]] = [:] - @Published private(set) var gitReferences: [GitReference] = [] - @Published private(set) var gitCommits: [GitCommit] = [] - @Published var selectedGitReference: GitReference? - @Published var selectedGitCommit: GitCommit? - @Published private(set) var selectedGitCommitFiles: [GitCommitFile] = [] - @Published var selectedGitCommitFile: GitCommitFile? - @Published var selectedGitCommitDiffContext: GitCommitDiffContext? - @Published private(set) var isLoadingGitHistory = false - @Published private(set) var isLoadingMoreGitHistory = false - @Published private(set) var canLoadMoreGitHistory = false - @Published private(set) var branchComparison: GitBranchComparison? - @Published var selectedBranchComparisonFile: GitBranchComparisonFile? - @Published private(set) var branchComparisonRows: [DiffRow] = [] - @Published private(set) var isLoadingBranchComparison = false - @Published private(set) var isPerformingBranchOperation = false - @Published private(set) var isCloningRepository = false + @Published package var gitOperationState: GitOperationState? + @Published package var isResolvingGitOperation = false + @Published package private(set) var isCommitting = false + @Published package private(set) var gitBlameLines: [URL: [GitBlameLine]] = [:] + @Published package private(set) var gitReferences: [GitReference] = [] + @Published package private(set) var gitCommits: [GitCommit] = [] + @Published package var selectedGitReference: GitReference? + @Published package var selectedGitCommit: GitCommit? + @Published package private(set) var selectedGitCommitFiles: [GitCommitFile] = [] + @Published package var selectedGitCommitFile: GitCommitFile? + @Published package var selectedGitCommitDiffContext: GitCommitDiffContext? + @Published package private(set) var isLoadingGitHistory = false + @Published package private(set) var isLoadingMoreGitHistory = false + @Published package private(set) var canLoadMoreGitHistory = false + @Published package private(set) var branchComparison: GitBranchComparison? + @Published package var selectedBranchComparisonFile: GitBranchComparisonFile? + @Published package private(set) var branchComparisonRows: [DiffRow] = [] + @Published package private(set) var isLoadingBranchComparison = false + @Published package private(set) var isPerformingBranchOperation = false + @Published package private(set) var isCloningRepository = false private let service: GitService private let shelveService: ShelveService? @@ -64,12 +66,13 @@ final class GitFeatureModel: ObservableObject { private var saveChangesPolicy: (@MainActor () -> GitSaveChangesPolicy)? private var onGitOperationBegan: (@MainActor () -> Void)? private var onGitOperationEnded: (@MainActor () async -> Void)? + private var acquireModuleLease: (@MainActor (String) -> ModuleLease)? private var gitHistoryLimit = 300 private var deferredSavedChanges: GitDeferredSavedChanges? private var refreshRequestedWhileRunning = false - init( + package init( service: GitService, shelveService: ShelveService? = nil, snapshotProvider: (@Sendable (URL) async -> GitSnapshot?)? = nil, @@ -87,7 +90,7 @@ final class GitFeatureModel: ObservableObject { } } - func configure( + package func configure( workspaceURLProvider: @escaping @MainActor () -> URL?, isGitLogVisibleProvider: @escaping @MainActor () -> Bool, notify: @escaping @MainActor (String) -> Void, @@ -105,11 +108,31 @@ final class GitFeatureModel: ObservableObject { self.onGitOperationEnded = onGitOperationEnded } - var currentGitReference: GitReference? { + package func configureModuleLeases( + acquire: @escaping @MainActor (String) -> ModuleLease + ) { + acquireModuleLease = acquire + } + + package var currentGitReference: GitReference? { gitReferences.first(where: \.isCurrent) } - func reset() { + package var hasActiveModuleWork: Bool { + isPerformingStashOperation + || isPerformingShelfOperation + || isLoadingDiff + || isRefreshingGit + || isCommitting + || isLoadingGitHistory + || isLoadingMoreGitHistory + || isLoadingBranchComparison + || isPerformingBranchOperation + || isCloningRepository + || isResolvingGitOperation + } + + package func reset() { gitChanges = [] gitStashes = [] gitShelves = [] @@ -158,7 +181,7 @@ final class GitFeatureModel: ObservableObject { isResolvingGitOperation = false } - func refreshGit() async { + package func refreshGit() async { guard let workspaceURLProvider else { return } if isRefreshingGit { refreshRequestedWhileRunning = true @@ -264,7 +287,7 @@ final class GitFeatureModel: ObservableObject { } } - func selectChange(_ change: GitChange) async { + package func selectChange(_ change: GitChange) async { closeBranchComparison() selectedGitCommitDiffContext = nil selectedChange = change @@ -283,7 +306,7 @@ final class GitFeatureModel: ObservableObject { isLoadingDiff = false } - func selectConflictPath(_ path: String) async { + package func selectConflictPath(_ path: String) async { guard let change = gitChanges.first(where: { $0.path == path }) else { return } await selectChange(change) } @@ -294,25 +317,27 @@ final class GitFeatureModel: ObservableObject { } private func withGitOperation(_ operation: () async -> T) async -> T { + let lease = acquireModuleLease?("Git operation in progress") + defer { lease?.release() } onGitOperationBegan?() let result = await operation() await onGitOperationEnded?() return result } - func setGitConflictFilter(_ paths: [String]) { + package func setGitConflictFilter(_ paths: [String]) { gitConflictFilterPaths = Set(paths) } - func clearGitConflictFilter() { + package func clearGitConflictFilter() { gitConflictFilterPaths = [] } - func requestStashSelection(_ reference: String) { + package func requestStashSelection(_ reference: String) { requestedStashReference = reference } - func reloadSelectedChangeDiff(whitespace: GitDiffWhitespaceMode) async { + package func reloadSelectedChangeDiff(whitespace: GitDiffWhitespaceMode) async { gitDiffWhitespaceMode = whitespace guard let selectedChange else { return } isLoadingDiff = true @@ -324,20 +349,20 @@ final class GitFeatureModel: ObservableObject { isLoadingDiff = false } - func commitMessageInput(for change: GitChange) async -> CommitMessageInput { + package func commitMessageInput(for change: GitChange) async -> CommitMessageInput { let patch: String if selectedChange?.id == change.id, !selectedDiffPatch.isEmpty { patch = selectedDiffPatch } else { patch = await service.diffPatch(for: change, whitespace: gitDiffWhitespaceMode) } - return CommitMessageInput(path: change.path, changeKind: change.kind, diff: patch) + return CommitMessageInput(path: change.path, changeKind: change.kind.commitMessageKind, diff: patch) } /// Builds the input for the commit editor from the index snapshot. This /// deliberately bypasses the selected file's working-tree diff so a file /// with both staged and unstaged edits is represented correctly. - func stagedCommitMessageInput() async -> CommitMessageInput? { + package func stagedCommitMessageInput() async -> CommitMessageInput? { let stagedChanges = gitChanges.filter(\.isStaged) guard !stagedChanges.isEmpty else { return nil } @@ -354,7 +379,7 @@ final class GitFeatureModel: ObservableObject { files.append( CommitMessageFileInput( path: change.path, - changeKind: change.kind, + changeKind: change.kind.commitMessageKind, diff: patch ) ) @@ -364,37 +389,37 @@ final class GitFeatureModel: ObservableObject { return CommitMessageInput(files: files) } - func stageSelectedChange() async { + package func stageSelectedChange() async { guard let selectedChange else { return } let result = await withGitOperation { await service.stage(selectedChange) } showResult(result, success: "Staged \(selectedChange.path)") await refreshGit() } - func unstageSelectedChange() async { + package func unstageSelectedChange() async { guard let selectedChange else { return } let result = await withGitOperation { await service.unstage(selectedChange) } showResult(result, success: "Unstaged \(selectedChange.path)") await refreshGit() } - func stageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { + package func stageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { let result = await withGitOperation { await service.stage(hunk: hunk, of: change) } showResult(result, success: "Staged a change block in \(change.path)") await refreshGit() } - func unstageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { + package func unstageDiffHunk(_ hunk: DiffHunk, in change: GitChange) async { let result = await withGitOperation { await service.unstage(hunk: hunk, of: change) } showResult(result, success: "Unstaged a change block in \(change.path)") await refreshGit() } - func requestDiscardHunk(_ hunk: DiffHunk, in change: GitChange) { + package func requestDiscardHunk(_ hunk: DiffHunk, in change: GitChange) { pendingDiscardHunk = DiffHunkRequest(change: change, hunk: hunk) } - func confirmDiscardHunk() async { + package func confirmDiscardHunk() async { guard let request = pendingDiscardHunk else { return } pendingDiscardHunk = nil let result = await withGitOperation { @@ -404,11 +429,11 @@ final class GitFeatureModel: ObservableObject { await refreshGit() } - func cancelDiscardHunk() { + package func cancelDiscardHunk() { pendingDiscardHunk = nil } - func requestDiscardSelectedChange() { + package func requestDiscardSelectedChange() { requestDiscardChange(selectedChange) } @@ -417,11 +442,11 @@ final class GitFeatureModel: ObservableObject { /// Context-menu actions can be invoked before the row has finished /// becoming the selected change, so they must not rely on /// `selectedChange` being up to date. - func requestDiscardChange(_ change: GitChange?) { + package func requestDiscardChange(_ change: GitChange?) { pendingDiscardChange = change } - func confirmDiscardChange() async { + package func confirmDiscardChange() async { guard let change = pendingDiscardChange else { return } pendingDiscardChange = nil let result = await withGitOperation { await service.discard(change) } @@ -429,11 +454,11 @@ final class GitFeatureModel: ObservableObject { await refreshGit() } - func cancelDiscardChange() { + package func cancelDiscardChange() { pendingDiscardChange = nil } - func requestConflictRollback(path: String, resume: GitConflictResume) { + package func requestConflictRollback(path: String, resume: GitConflictResume) { guard gitChanges.contains(where: { $0.path == path }) else { notify?("The conflict file is no longer in the working tree") return @@ -441,7 +466,7 @@ final class GitFeatureModel: ObservableObject { pendingConflictRollback = GitConflictRollbackRequest(path: path, resume: resume) } - func cancelConflictRollback() { + package func cancelConflictRollback() { pendingConflictRollback = nil } @@ -451,7 +476,7 @@ final class GitFeatureModel: ObservableObject { /// `pendingConflictRollback` before an action's `Task` starts. The explicit /// request keeps the destructive operation and its retry target alive across /// that dismissal. - func confirmConflictRollback(_ request: GitConflictRollbackRequest) async { + package func confirmConflictRollback(_ request: GitConflictRollbackRequest) async { if pendingConflictRollback?.id == request.id { pendingConflictRollback = nil } @@ -517,7 +542,7 @@ final class GitFeatureModel: ObservableObject { return true } - func commitStagedChanges(message rawMessage: String, amend: Bool) async -> Bool { + package func commitStagedChanges(message rawMessage: String, amend: Bool) async -> Bool { guard let gitRepositoryRoot else { return false } let message = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines) guard !message.isEmpty else { @@ -542,7 +567,7 @@ final class GitFeatureModel: ObservableObject { } @discardableResult - func commitAndPushStagedChanges(message rawMessage: String, amend: Bool) async -> Bool { + package func commitAndPushStagedChanges(message rawMessage: String, amend: Bool) async -> Bool { guard let gitRepositoryRoot else { return false } let message = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines) guard !message.isEmpty else { @@ -591,7 +616,7 @@ final class GitFeatureModel: ObservableObject { return true } - func toggleStaging(_ change: GitChange) async { + package func toggleStaging(_ change: GitChange) async { selectedChange = change let result = await withGitOperation { change.isStaged @@ -603,14 +628,14 @@ final class GitFeatureModel: ObservableObject { await refreshGit() } - func stageAllChanges() async { + package func stageAllChanges() async { guard let gitRepositoryRoot else { return } let result = await withGitOperation { await service.stageAll(at: gitRepositoryRoot) } showResult(result, success: "Staged all changes") await refreshGit() } - func stashWorkingTree(message: String, includeUntracked: Bool) async { + package func stashWorkingTree(message: String, includeUntracked: Bool) async { guard let gitRepositoryRoot else { return } isPerformingStashOperation = true let result = await withGitOperation { @@ -631,7 +656,7 @@ final class GitFeatureModel: ObservableObject { /// Saves the current worktree in Lithe's patch store and clears the Git /// worktree. This is the manual counterpart to the automatic Shelve policy. - func shelveWorkingTree(message: String) async { + package func shelveWorkingTree(message: String) async { guard let gitRepositoryRoot, shelveService != nil else { notify?("Shelve storage is unavailable") return @@ -649,7 +674,7 @@ final class GitFeatureModel: ObservableObject { } } - func applyShelf(_ shelf: GitShelfEntry) async { + package func applyShelf(_ shelf: GitShelfEntry) async { guard let gitRepositoryRoot else { return } isPerformingShelfOperation = true let restored = await withGitOperation { @@ -661,7 +686,7 @@ final class GitFeatureModel: ObservableObject { } } - func dropShelf(_ shelf: GitShelfEntry) async { + package func dropShelf(_ shelf: GitShelfEntry) async { guard let gitRepositoryRoot, let shelveService else { return } isPerformingShelfOperation = true let deleted = await withGitOperation { @@ -784,7 +809,7 @@ final class GitFeatureModel: ObservableObject { return true } - func applyStash(_ stash: GitStash, pop: Bool = false) async { + package func applyStash(_ stash: GitStash, pop: Bool = false) async { guard let gitRepositoryRoot else { return } isPerformingStashOperation = true let result = await withGitOperation { @@ -809,7 +834,7 @@ final class GitFeatureModel: ObservableObject { } } - func dropStash(_ stash: GitStash) async { + package func dropStash(_ stash: GitStash) async { guard let gitRepositoryRoot else { return } isPerformingStashOperation = true let result = await withGitOperation { await service.dropStash(stash, at: gitRepositoryRoot) } @@ -835,33 +860,33 @@ final class GitFeatureModel: ObservableObject { isStashRestoreConflictNoticeVisible = true } - func dismissStashRestoreConflictNotice() { + package func dismissStashRestoreConflictNotice() { isStashRestoreConflictNoticeVisible = false } - func showStashRestoreConflictNotice() { + package func showStashRestoreConflictNotice() { guard pendingStashRestoreConflict != nil else { return } isStashRestoreConflictNoticeVisible = true } - func showStashRestoreConflictFiles() { + package func showStashRestoreConflictFiles() { guard let conflict = pendingStashRestoreConflict else { return } setGitConflictFilter(conflict.conflictedPaths) } - func showStashRestoreConflictStash() { + package func showStashRestoreConflictStash() { guard let conflict = pendingStashRestoreConflict else { return } requestStashSelection(conflict.stashReference) } - func selectGitReference(_ reference: GitReference?) async { + package func selectGitReference(_ reference: GitReference?) async { selectedGitReference = reference gitHistoryLimit = 300 canLoadMoreGitHistory = false await refreshGitHistory() } - func refreshGitHistory() async { + package func refreshGitHistory() async { guard let gitRepositoryRoot, !isLoadingGitHistory else { return } isLoadingGitHistory = true let previousCommitHash = selectedGitCommit?.hash @@ -891,7 +916,7 @@ final class GitFeatureModel: ObservableObject { } } - func loadMoreGitHistory() async { + package func loadMoreGitHistory() async { guard canLoadMoreGitHistory, !isLoadingGitHistory else { return } isLoadingMoreGitHistory = true defer { isLoadingMoreGitHistory = false } @@ -899,7 +924,7 @@ final class GitFeatureModel: ObservableObject { await refreshGitHistory() } - func selectGitCommit(_ commit: GitCommit) async { + package func selectGitCommit(_ commit: GitCommit) async { guard let gitRepositoryRoot else { return } selectedGitCommit = commit selectedGitCommitFile = nil @@ -910,7 +935,7 @@ final class GitFeatureModel: ObservableObject { selectedGitCommitFile = files.first } - func showGitCommitDiff(for file: GitCommitFile) async { + package func showGitCommitDiff(for file: GitCommitFile) async { guard let gitRepositoryRoot, let commit = selectedGitCommit else { return } let context = GitCommitDiffContext( repositoryRoot: gitRepositoryRoot, @@ -937,7 +962,7 @@ final class GitFeatureModel: ObservableObject { isLoadingDiff = false } - func closeGitCommitDiff() { + package func closeGitCommitDiff() { selectedGitCommitDiffContext = nil selectedGitCommitFile = nil selectedDiffPatch = "" @@ -946,7 +971,7 @@ final class GitFeatureModel: ObservableObject { isLoadingDiff = false } - func loadBlame(for fileURL: URL) async -> [GitBlameLine] { + package func loadBlame(for fileURL: URL) async -> [GitBlameLine] { guard let gitRepositoryRoot else { return [] } let normalizedURL = fileURL.standardizedFileURL let blame = await service.blame(fileURL: normalizedURL, at: gitRepositoryRoot) @@ -954,7 +979,7 @@ final class GitFeatureModel: ObservableObject { return blame } - func showGitCommit(_ hash: String) async { + package func showGitCommit(_ hash: String) async { guard gitRepositoryRoot != nil, !hash.allSatisfy({ $0 == "0" }) else { return } if gitCommits.isEmpty { await refreshGitHistory() @@ -971,7 +996,7 @@ final class GitFeatureModel: ObservableObject { await selectGitCommit(loaded) } - func showComparisonWithWorkingTree(for reference: GitReference) async { + package func showComparisonWithWorkingTree(for reference: GitReference) async { guard let gitRepositoryRoot else { return } selectedGitCommitDiffContext = nil selectedChange = nil @@ -995,7 +1020,7 @@ final class GitFeatureModel: ObservableObject { isLoadingBranchComparison = false } - func selectBranchComparisonFile(_ file: GitBranchComparisonFile) async { + package func selectBranchComparisonFile(_ file: GitBranchComparisonFile) async { guard let gitRepositoryRoot, let comparison = branchComparison else { return } selectedBranchComparisonFile = file branchComparisonRows = [] @@ -1011,14 +1036,14 @@ final class GitFeatureModel: ObservableObject { isLoadingBranchComparison = false } - func closeBranchComparison() { + package func closeBranchComparison() { branchComparison = nil selectedBranchComparisonFile = nil branchComparisonRows = [] isLoadingBranchComparison = false } - func createBranch(named rawName: String, from reference: GitReference, checkout: Bool) async { + package func createBranch(named rawName: String, from reference: GitReference, checkout: Bool) async { guard let gitRepositoryRoot else { return } let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { @@ -1044,7 +1069,7 @@ final class GitFeatureModel: ObservableObject { } } - func renameBranch(_ reference: GitReference, to rawName: String) async { + package func renameBranch(_ reference: GitReference, to rawName: String) async { guard let gitRepositoryRoot else { return } let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { @@ -1066,7 +1091,7 @@ final class GitFeatureModel: ObservableObject { } } - func deleteBranch(_ reference: GitReference) async { + package func deleteBranch(_ reference: GitReference) async { guard let gitRepositoryRoot else { return } isPerformingBranchOperation = true let result = await withGitOperation { await service.deleteBranch(reference, at: gitRepositoryRoot) } @@ -1078,17 +1103,17 @@ final class GitFeatureModel: ObservableObject { /// Records the merge or rebase commit Git is waiting on once its conflicts are /// resolved. Rust refuses while any file is still conflicted, so the failure /// message names what is left. - func continueGitOperation() async { + package func continueGitOperation() async { await resolveGitOperation { await service.continueOperation(at: $0) } } /// Throws away the in-progress operation and restores the pre-operation state. - func abortGitOperation() async { + package func abortGitOperation() async { await resolveGitOperation { await service.abortOperation(at: $0) } } /// Drops the commit currently being replayed. Rebase only. - func skipGitOperationStep() async { + package func skipGitOperationStep() async { await resolveGitOperation { await service.skipOperationStep(at: $0) } } @@ -1157,11 +1182,11 @@ final class GitFeatureModel: ObservableObject { } } - func mergeBranch(_ reference: GitReference) async { + package func mergeBranch(_ reference: GitReference) async { await startIntegration(.reference(reference), operation: .merge) } - func rebaseCurrentBranch(onto reference: GitReference) async { + package func rebaseCurrentBranch(onto reference: GitReference) async { await startIntegration(.reference(reference), operation: .rebase) } @@ -1194,7 +1219,7 @@ final class GitFeatureModel: ObservableObject { /// The stash is left alone when the operation stops on a conflict: popping into /// a half-finished merge would tangle the user's own edits with the conflict /// markers they still have to resolve. - func resolveIntegrationConflict(_ request: GitIntegrationConflictRequest) async { + package func resolveIntegrationConflict(_ request: GitIntegrationConflictRequest) async { pendingIntegrationConflict = nil guard let gitRepositoryRoot else { return } await withGitOperation { @@ -1288,7 +1313,7 @@ final class GitFeatureModel: ObservableObject { } } - func cancelIntegrationConflict() { + package func cancelIntegrationConflict() { pendingIntegrationConflict = nil } @@ -1358,7 +1383,7 @@ final class GitFeatureModel: ObservableObject { } } - func updateCurrentBranch(_ reference: GitReference) async { + package func updateCurrentBranch(_ reference: GitReference) async { guard let gitRepositoryRoot, reference.isCurrent else { notify?("Only the current branch can be updated") return @@ -1407,7 +1432,7 @@ final class GitFeatureModel: ObservableObject { } /// Runs the pull the user chose from the divergence dialog. - func resolvePullStrategy(_ strategy: GitPullStrategy) async { + package func resolvePullStrategy(_ strategy: GitPullStrategy) async { pendingPullStrategy = nil guard let gitRepositoryRoot else { return } isPerformingBranchOperation = true @@ -1419,11 +1444,11 @@ final class GitFeatureModel: ObservableObject { await reportBranchOperation(result, success: verb) } - func cancelPullStrategy() { + package func cancelPullStrategy() { pendingPullStrategy = nil } - func fetchGit() async { + package func fetchGit() async { guard let gitRepositoryRoot else { return } isPerformingBranchOperation = true let result = await withGitOperation { await service.fetch(at: gitRepositoryRoot) } @@ -1432,7 +1457,7 @@ final class GitFeatureModel: ObservableObject { await refreshGit() } - func checkoutReference(_ reference: GitReference) async { + package func checkoutReference(_ reference: GitReference) async { guard let gitRepositoryRoot else { return } guard !reference.isCurrent else { notify?("Already on \(reference.shortName)") @@ -1455,7 +1480,7 @@ final class GitFeatureModel: ObservableObject { } /// Resolves a blocked checkout with the strategy the user picked in the conflict dialog. - func resolveCheckoutConflict( + package func resolveCheckoutConflict( _ request: GitCheckoutConflictRequest, strategy: GitCheckoutConflictStrategy ) async { @@ -1560,7 +1585,7 @@ final class GitFeatureModel: ObservableObject { } } - func checkoutRevision(_ rawRevision: String) async { + package func checkoutRevision(_ rawRevision: String) async { guard let gitRepositoryRoot else { return } isPerformingBranchOperation = true let result = await withGitOperation { @@ -1577,15 +1602,15 @@ final class GitFeatureModel: ObservableObject { } } - func cherryPick(_ commit: GitCommit) async { + package func cherryPick(_ commit: GitCommit) async { await startIntegration(.commit(commit), operation: .cherryPick) } - func revert(_ commit: GitCommit) async { + package func revert(_ commit: GitCommit) async { await startIntegration(.commit(commit), operation: .revert) } - func resetCurrentBranch(to commit: GitCommit) async { + package func resetCurrentBranch(to commit: GitCommit) async { guard let gitRepositoryRoot else { return } isPerformingBranchOperation = true let result = await withGitOperation { @@ -1600,7 +1625,7 @@ final class GitFeatureModel: ObservableObject { await refreshGit() } - func pushBranch(_ reference: GitReference) async { + package func pushBranch(_ reference: GitReference) async { guard let gitRepositoryRoot else { return } isPerformingBranchOperation = true let result = await withGitOperation { await service.push(reference, at: gitRepositoryRoot) } @@ -1610,7 +1635,7 @@ final class GitFeatureModel: ObservableObject { } @discardableResult - func cloneRepository( + package func cloneRepository( remote rawRemote: String, destination: URL, destinationExists: (URL) -> Bool diff --git a/Sources/LitheGitModule/Models/GitGraphModels.swift b/Sources/LitheGitModule/Models/GitGraphModels.swift new file mode 100644 index 00000000..ea59c12c --- /dev/null +++ b/Sources/LitheGitModule/Models/GitGraphModels.swift @@ -0,0 +1,50 @@ +import Foundation + +package enum GitGraphReferenceKind: String, Hashable, Sendable { + case head + case branch + case remote + case tag +} + +package struct GitGraphLabel: Identifiable, Hashable, Sendable { + package let title: String + package let kind: GitGraphReferenceKind + + package var id: String { "\(kind.rawValue):\(title)" } +} + +package struct GitGraphEdge: Identifiable, Hashable, Sendable { + package let id: String + package let parentHash: String + package let targetLane: Int? + package let colorIndex: Int + package let isMissing: Bool +} + +package struct GitGraphRow: Identifiable, Hashable, Sendable { + package let commit: GitCommit + package let lane: Int + package let laneCount: Int + /// One entry per lane slot, ordered by lane index. `nil` marks a slot that no + /// branch occupies at this row, so lane indices stay stable between rows. + package let incomingLaneColors: [Int?] + package let parentEdges: [GitGraphEdge] + package let labels: [GitGraphLabel] + + package var id: String { commit.id } + package var isMerge: Bool { commit.parentHashes.count > 1 } + package var isRoot: Bool { commit.parentHashes.isEmpty } +} + +package struct GitGraphLayout: Sendable { + package let rows: [GitGraphRow] + package let laneCount: Int + package let hasMissingParents: Bool + + package init(rows: [GitGraphRow], laneCount: Int, hasMissingParents: Bool) { + self.rows = rows + self.laneCount = laneCount + self.hasMissingParents = hasMissingParents + } +} diff --git a/Sources/Lithe/Models/GitModels.swift b/Sources/LitheGitModule/Models/GitModels.swift similarity index 58% rename from Sources/Lithe/Models/GitModels.swift rename to Sources/LitheGitModule/Models/GitModels.swift index 454cb7e4..8d0e142b 100644 --- a/Sources/Lithe/Models/GitModels.swift +++ b/Sources/LitheGitModule/Models/GitModels.swift @@ -1,83 +1,90 @@ import Foundation +import LitheCoreContracts -struct GitWatchContext: Equatable, Sendable { - let repositoryRoot: URL - let gitDirectory: URL - let gitCommonDirectory: URL -} +package typealias GitWatchContext = LitheCoreContracts.GitWatchContext -struct GitSnapshot: Sendable { - let repositoryRoot: URL - let branch: String - let changes: [GitChange] +package struct GitSnapshot: Sendable { + package let repositoryRoot: URL + package let branch: String + package let changes: [GitChange] + package init(repositoryRoot: URL, branch: String, changes: [GitChange]) { self.repositoryRoot = repositoryRoot; self.branch = branch; self.changes = changes } } -enum GitReferenceKind: String, Sendable { +package enum GitReferenceKind: String, Sendable { case local case remote case tag } -struct GitReference: Identifiable, Hashable, Sendable { - let fullName: String - let shortName: String - let kind: GitReferenceKind - let isCurrent: Bool - let upstreamShortName: String? +package struct GitReference: Identifiable, Hashable, Sendable { + package let fullName: String + package let shortName: String + package let kind: GitReferenceKind + package let isCurrent: Bool + package let upstreamShortName: String? + package init(fullName: String, shortName: String, kind: GitReferenceKind, isCurrent: Bool, upstreamShortName: String?) { self.fullName = fullName; self.shortName = shortName; self.kind = kind; self.isCurrent = isCurrent; self.upstreamShortName = upstreamShortName } - var id: String { fullName } + package var id: String { fullName } } -struct GitStash: Identifiable, Hashable, Sendable { - let reference: String - let message: String - let branch: String? - let date: String +package struct GitStash: Identifiable, Hashable, Sendable { + package let reference: String + package let message: String + package let branch: String? + package let date: String + package init(reference: String, message: String, branch: String?, date: String) { self.reference = reference; self.message = message; self.branch = branch; self.date = date } - var id: String { reference } + package var id: String { reference } } /// Structured information returned when `git stash pop` keeps the entry because /// restoring it created unresolved conflicts. The stash is intentionally not /// dropped so the user can finish recovery without losing the original patch. -struct GitStashRestoreConflict: Hashable, Sendable { - let stashReference: String - let conflictedPaths: [String] +public struct GitStashRestoreConflict: Hashable, Sendable { + public let stashReference: String + public let conflictedPaths: [String] + + public init(stashReference: String, conflictedPaths: [String]) { + self.stashReference = stashReference + self.conflictedPaths = conflictedPaths + } } -struct GitCommit: Identifiable, Hashable, Sendable { - let hash: String - let shortHash: String - let parentHashes: [String] - let authorName: String - let authorEmail: String - let date: String - let subject: String - let decorations: String +package struct GitCommit: Identifiable, Hashable, Sendable { + package let hash: String + package let shortHash: String + package let parentHashes: [String] + package let authorName: String + package let authorEmail: String + package let date: String + package let subject: String + package let decorations: String + package init(hash: String, shortHash: String, parentHashes: [String], authorName: String, authorEmail: String, date: String, subject: String, decorations: String) { self.hash = hash; self.shortHash = shortHash; self.parentHashes = parentHashes; self.authorName = authorName; self.authorEmail = authorEmail; self.date = date; self.subject = subject; self.decorations = decorations } - var id: String { hash } + package var id: String { hash } } -struct GitCommitFile: Identifiable, Hashable, Sendable { - let status: String - let path: String +package struct GitCommitFile: Identifiable, Hashable, Sendable { + package let status: String + package let path: String + package init(status: String, path: String) { self.status = status; self.path = path } - var id: String { "\(status):\(path)" } + package var id: String { "\(status):\(path)" } } -struct GitCommitFileTreeNode: Identifiable, Sendable { - let path: String - let name: String - let directories: [GitCommitFileTreeNode] - let files: [GitCommitFile] +package struct GitCommitFileTreeNode: Identifiable, Sendable { + package let path: String + package let name: String + package let directories: [GitCommitFileTreeNode] + package let files: [GitCommitFile] - var id: String { path.isEmpty ? "." : path } + package var id: String { path.isEmpty ? "." : path } - var fileCount: Int { + package var fileCount: Int { files.count + directories.reduce(0) { $0 + $1.fileCount } } - static func build(from files: [GitCommitFile], rootName: String) -> GitCommitFileTreeNode { + package static func build(from files: [GitCommitFile], rootName: String) -> GitCommitFileTreeNode { let root = MutableGitCommitFileTreeNode(name: rootName, path: "") for file in files { @@ -136,28 +143,28 @@ struct GitCommitFileTreeNode: Identifiable, Sendable { } private final class MutableGitCommitFileTreeNode { - let path: String - let name: String - var directories: [String: MutableGitCommitFileTreeNode] = [:] - var files: [GitCommitFile] = [] + package let path: String + package let name: String + package var directories: [String: MutableGitCommitFileTreeNode] = [:] + package var files: [GitCommitFile] = [] - init(name: String, path: String) { + package init(name: String, path: String) { self.name = name self.path = path } } /// Read-only diff context for a file changed by a historical commit. -struct GitCommitDiffContext: Identifiable, Hashable, Sendable { - let repositoryRoot: URL - let commit: GitCommit - let file: GitCommitFile +package struct GitCommitDiffContext: Identifiable, Hashable, Sendable { + package let repositoryRoot: URL + package let commit: GitCommit + package let file: GitCommitFile - var id: String { "\(commit.hash):\(file.id)" } - var path: String { file.path } - var url: URL { repositoryRoot.appendingPathComponent(file.path) } + package var id: String { "\(commit.hash):\(file.id)" } + package var path: String { file.path } + package var url: URL { repositoryRoot.appendingPathComponent(file.path) } - var kind: GitChangeKind { + package var kind: GitChangeKind { if file.status.hasPrefix("A") { return .added } if file.status.hasPrefix("D") { return .deleted } if file.status.hasPrefix("R") { return .moved } @@ -166,58 +173,63 @@ struct GitCommitDiffContext: Identifiable, Hashable, Sendable { } } -struct GitBlameLine: Identifiable, Hashable, Sendable { - let line: Int - let commitHash: String - let authorName: String - let date: String +package struct GitBlameLine: Identifiable, Hashable, Sendable { + package let line: Int + package let commitHash: String + package let authorName: String + package let date: String + package init(line: Int, commitHash: String, authorName: String, date: String) { self.line = line; self.commitHash = commitHash; self.authorName = authorName; self.date = date } - var id: Int { line } + package var id: Int { line } } -struct GitBranchComparisonFile: Identifiable, Hashable, Sendable { - let status: String - let path: String +package struct GitBranchComparisonFile: Identifiable, Hashable, Sendable { + package let status: String + package let path: String + package init(status: String, path: String) { self.status = status; self.path = path } - var id: String { "\(status):\(path)" } + package var id: String { "\(status):\(path)" } } -struct GitBranchComparison: Identifiable, Sendable { - let reference: GitReference - let files: [GitBranchComparisonFile] +package struct GitBranchComparison: Identifiable, Sendable { + package let reference: GitReference + package let files: [GitBranchComparisonFile] + package init(reference: GitReference, files: [GitBranchComparisonFile]) { self.reference = reference; self.files = files } - var id: String { reference.id } + package var id: String { reference.id } } -struct GitHistorySnapshot: Sendable { - let references: [GitReference] - let commits: [GitCommit] - let hasMore: Bool +package struct GitHistorySnapshot: Sendable { + package let references: [GitReference] + package let commits: [GitCommit] + package let hasMore: Bool + package init(references: [GitReference], commits: [GitCommit], hasMore: Bool) { self.references = references; self.commits = commits; self.hasMore = hasMore } } -struct GitChange: Identifiable, Hashable, Sendable { - let repositoryRoot: URL - let path: String - let originalPath: String? - let indexStatus: Character - let workTreeStatus: Character +package struct GitChange: Identifiable, Hashable, Sendable { + package let repositoryRoot: URL + package let path: String + package let originalPath: String? + package let indexStatus: Character + package let workTreeStatus: Character + package init(repositoryRoot: URL, path: String, originalPath: String?, indexStatus: Character, workTreeStatus: Character) { self.repositoryRoot = repositoryRoot; self.path = path; self.originalPath = originalPath; self.indexStatus = indexStatus; self.workTreeStatus = workTreeStatus } - var id: String { "\(originalPath ?? "")->\(path)" } - var url: URL { repositoryRoot.appendingPathComponent(path) } - var isStaged: Bool { indexStatus != " " && indexStatus != "?" } - var hasWorkingTreeChange: Bool { workTreeStatus != " " } - var isUntracked: Bool { indexStatus == "?" && workTreeStatus == "?" } + package var id: String { "\(originalPath ?? "")->\(path)" } + package var url: URL { repositoryRoot.appendingPathComponent(path) } + package var isStaged: Bool { indexStatus != " " && indexStatus != "?" } + package var hasWorkingTreeChange: Bool { workTreeStatus != " " } + package var isUntracked: Bool { indexStatus == "?" && workTreeStatus == "?" } /// True while a merge, rebase, cherry-pick, or revert has left this file /// unmerged. Git marks these with a `U` on either side, plus the `AA` and `DD` /// pairs for both-added and both-deleted. - var isConflicted: Bool { + package var isConflicted: Bool { if indexStatus == "U" || workTreeStatus == "U" { return true } return (indexStatus == "A" && workTreeStatus == "A") || (indexStatus == "D" && workTreeStatus == "D") } - var kind: GitChangeKind { + package var kind: GitChangeKind { // Checked first: an unmerged pair such as `AA` or `UD` would otherwise // match the plain added/deleted cases below and read as an ordinary edit. if isConflicted { return .conflicted } @@ -228,12 +240,12 @@ struct GitChange: Identifiable, Hashable, Sendable { return .modified } - var pathspecs: [String] { + package var pathspecs: [String] { if let originalPath, originalPath != path { return [originalPath, path] } return [path] } - var displayStatus: String { + package var displayStatus: String { if isConflicted { return "!" } if isUntracked { return "A" } if workTreeStatus != " " { return String(workTreeStatus) } @@ -241,7 +253,7 @@ struct GitChange: Identifiable, Hashable, Sendable { } } -enum GitChangeKind: String, Sendable { +package enum GitChangeKind: String, Sendable { case added case modified case deleted @@ -249,7 +261,7 @@ enum GitChangeKind: String, Sendable { case copied case conflicted - var title: String { + package var title: String { switch self { case .added: "Added" case .modified: "Modified" @@ -260,7 +272,7 @@ enum GitChangeKind: String, Sendable { } } - var symbol: String { + package var symbol: String { switch self { case .added: "plus" case .modified: "pencil" @@ -272,13 +284,27 @@ enum GitChangeKind: String, Sendable { } } -enum GitDiffWhitespaceMode: String, CaseIterable, Identifiable, Equatable, Sendable { +package extension GitChangeKind { + var commitMessageKind: CommitMessageChangeKind { + switch self { + case .added: .added + case .modified: .modified + case .deleted: .deleted + case .moved: .renamed + case .copied: .copied + case .conflicted: .unmerged + } + } +} + + +package enum GitDiffWhitespaceMode: String, CaseIterable, Identifiable, Equatable, Sendable { case doNotIgnore case ignoreAllWhitespace - var id: String { rawValue } + package var id: String { rawValue } - var title: String { + package var title: String { switch self { case .doNotIgnore: return "Do not ignore" @@ -288,7 +314,7 @@ enum GitDiffWhitespaceMode: String, CaseIterable, Identifiable, Equatable, Senda } } -enum DiffRowKind: Sendable, Equatable { +package enum DiffRowKind: Sendable, Equatable { case context case changed case addition @@ -296,26 +322,26 @@ enum DiffRowKind: Sendable, Equatable { case information } -struct DiffRow: Identifiable, Sendable { +package struct DiffRow: Identifiable, Sendable { /// Derived from the row's hunk and line numbers rather than a fresh UUID so /// that re-parsing the same diff keeps scroll position and difference /// selection stable across refreshes. - let id: DiffRowID - let oldLine: Int? - let newLine: Int? + package let id: DiffRowID + package let oldLine: Int? + package let newLine: Int? /// Text of the left (old) side. For `context` and `information` rows this is /// the text of both sides; see `rightText`. - let left: String? + package let left: String? /// Text of the right (new) side, stored only when it differs from `left`. /// Prefer `rightText`, which folds in the shared-text cases. - let storedRight: String? - let kind: DiffRowKind - let hunkID: String? + package let storedRight: String? + package let kind: DiffRowKind + package let hunkID: String? /// Right-side text with the shared-text fallback applied. `context` and /// `information` rows hold identical text on both sides, so the parser only /// keeps one copy. - var rightText: String? { + package var rightText: String? { switch kind { case .context, .information: return storedRight ?? left @@ -324,7 +350,7 @@ struct DiffRow: Identifiable, Sendable { } } - init( + package init( oldLine: Int?, newLine: Int?, left: String?, @@ -349,80 +375,83 @@ struct DiffRow: Identifiable, Sendable { } } + /// Stable, value-derived row identity. `sequence` disambiguates rows that share /// a hunk and line numbers, such as consecutive one-sided rows. -struct DiffRowID: Hashable, Sendable { - let hunkID: String? - let oldLine: Int? - let newLine: Int? - let sequence: Int +package struct DiffRowID: Hashable, Sendable { + package let hunkID: String? + package let oldLine: Int? + package let newLine: Int? + package let sequence: Int } -struct DiffHunk: Identifiable, Sendable { - let id: String - let header: String - let patch: String +package struct DiffHunk: Identifiable, Sendable { + package let id: String + package let header: String + package let patch: String + package init(id: String, header: String, patch: String) { self.id = id; self.header = header; self.patch = patch } } -struct DiffDocument: Sendable { - let patch: String - let rows: [DiffRow] - let hunks: [DiffHunk] +package struct DiffDocument: Sendable { + package let patch: String + package let rows: [DiffRow] + package let hunks: [DiffHunk] - init(patch: String = "", rows: [DiffRow], hunks: [DiffHunk]) { + package init(patch: String = "", rows: [DiffRow], hunks: [DiffHunk]) { self.patch = patch self.rows = rows self.hunks = hunks } } -struct DiffHunkRequest: Identifiable { - let id = UUID() - let change: GitChange - let hunk: DiffHunk +package struct DiffHunkRequest: Identifiable { + package let id = UUID() + package let change: GitChange + package let hunk: DiffHunk } /// A checkout that local changes would overwrite, awaiting the user's resolution choice. -struct GitCheckoutConflictRequest: Identifiable { - let id = UUID() - let reference: GitReference - let blockingPaths: [String] +package struct GitCheckoutConflictRequest: Identifiable { + package let id = UUID() + package let reference: GitReference + package let blockingPaths: [String] } /// The destructive rollback requested from a conflict dialog. The original /// operation is retained so a successful rollback can re-run its preflight and /// continue automatically when no blocking paths remain. -enum GitConflictResume: Sendable { +package enum GitConflictResume: Sendable { case checkout(GitReference) case integration(target: GitIntegrationTarget, operation: GitIntegrationOperation) } -struct GitConflictRollbackRequest: Identifiable, Sendable { - let id = UUID() - let path: String - let resume: GitConflictResume +package struct GitConflictRollbackRequest: Identifiable, Sendable { + package let id = UUID() + package let path: String + package let resume: GitConflictResume } /// What stands in the way of starting a merge or rebase. -struct GitIntegrationPreflightState: Sendable { - let blockingPaths: [String] +package struct GitIntegrationPreflightState: Sendable { + package let blockingPaths: [String] /// True for a rebase, which refuses on any uncommitted change rather than /// only those overlapping the incoming commits. - let blocksEntirely: Bool + package let blocksEntirely: Bool + package init(blockingPaths: [String], blocksEntirely: Bool) { self.blockingPaths = blockingPaths; self.blocksEntirely = blocksEntirely } - var isClear: Bool { blockingPaths.isEmpty } + package var isClear: Bool { blockingPaths.isEmpty } } /// What an integration replays: a whole branch, or a single commit. /// /// Merge and rebase name a branch while cherry-pick and revert name one commit, /// but the preflight only needs a revision to resolve, so they share this. -enum GitIntegrationTarget: Sendable { +package enum GitIntegrationTarget: Sendable { case reference(GitReference) case commit(GitCommit) /// The revision handed to Git. - var revision: String { + package var revision: String { switch self { case .reference(let reference): reference.fullName case .commit(let commit): commit.hash @@ -430,7 +459,7 @@ enum GitIntegrationTarget: Sendable { } /// The revision as the user knows it, for messages. - var displayName: String { + package var displayName: String { switch self { case .reference(let reference): reference.shortName case .commit(let commit): commit.shortHash @@ -439,60 +468,60 @@ enum GitIntegrationTarget: Sendable { } /// An integration blocked by uncommitted changes, awaiting the user's choice. -struct GitIntegrationConflictRequest: Identifiable { - let id = UUID() - let target: GitIntegrationTarget - let operation: GitIntegrationOperation - let blockingPaths: [String] - let blocksEntirely: Bool +package struct GitIntegrationConflictRequest: Identifiable { + package let id = UUID() + package let target: GitIntegrationTarget + package let operation: GitIntegrationOperation + package let blockingPaths: [String] + package let blocksEntirely: Bool } /// A stash created by Lithe could not be restored cleanly. The entry is kept so /// the user can resolve the working tree and drop it explicitly afterwards. -struct GitStashRestoreConflictRequest: Identifiable, Sendable { - let id = UUID() - let stashReference: String - let conflictedPaths: [String] - let operationTitle: String +package struct GitStashRestoreConflictRequest: Identifiable, Sendable { + package let id = UUID() + package let stashReference: String + package let conflictedPaths: [String] + package let operationTitle: String - var hasConflictPaths: Bool { !conflictedPaths.isEmpty } + package var hasConflictPaths: Bool { !conflictedPaths.isEmpty } } -struct GitDeferredSavedChanges: Sendable { - let stashReference: String? - let shelfID: UUID? - let operationTitle: String +package struct GitDeferredSavedChanges: Sendable { + package let stashReference: String? + package let shelfID: UUID? + package let operationTitle: String - init(stashReference: String, operationTitle: String) { + package init(stashReference: String, operationTitle: String) { self.stashReference = stashReference shelfID = nil self.operationTitle = operationTitle } - init(shelfID: UUID, operationTitle: String) { + package init(shelfID: UUID, operationTitle: String) { stashReference = nil self.shelfID = shelfID self.operationTitle = operationTitle } } -struct GitShelfEntry: Identifiable, Hashable, Sendable { - let id: UUID - let message: String - let createdAt: Date - let paths: [String] - let stagedPatch: String - let workingPatch: String +package struct GitShelfEntry: Identifiable, Hashable, Sendable { + package let id: UUID + package let message: String + package let createdAt: Date + package let paths: [String] + package let stagedPatch: String + package let workingPatch: String } /// The branch-integration operations that share a preflight. -enum GitIntegrationOperation: String, Sendable { +package enum GitIntegrationOperation: String, Sendable { case merge case rebase case cherryPick case revert - var title: String { + package var title: String { switch self { case .merge: "Merge" case .rebase: "Rebase" @@ -503,28 +532,29 @@ enum GitIntegrationOperation: String, Sendable { } /// Whether a pull can fast-forward, and how far the two sides have drifted. -struct GitPullPreflightState: Sendable { - let upstream: String? - let ahead: Int - let behind: Int - let diverged: Bool - let hasLocalChanges: Bool +package struct GitPullPreflightState: Sendable { + package let upstream: String? + package let ahead: Int + package let behind: Int + package let diverged: Bool + package let hasLocalChanges: Bool + package init(upstream: String?, ahead: Int, behind: Int, diverged: Bool, hasLocalChanges: Bool) { self.upstream = upstream; self.ahead = ahead; self.behind = behind; self.diverged = diverged; self.hasLocalChanges = hasLocalChanges } /// Nothing to pull, so the network call can be skipped entirely. - var isUpToDate: Bool { behind == 0 && !diverged } + package var isUpToDate: Bool { behind == 0 && !diverged } } /// A pull that cannot fast-forward, awaiting the user's choice of strategy. -struct GitPullStrategyRequest: Identifiable { - let id = UUID() - let upstream: String - let ahead: Int - let behind: Int - let hasLocalChanges: Bool +package struct GitPullStrategyRequest: Identifiable { + package let id = UUID() + package let upstream: String + package let ahead: Int + package let behind: Int + package let hasLocalChanges: Bool } /// How to reconcile a divergent history when pulling. -enum GitPullStrategy: String, Sendable { +package enum GitPullStrategy: String, Sendable { /// Refuse unless the pull can fast-forward. The safe default. case ffOnly /// Join the two histories with a merge commit. @@ -533,13 +563,13 @@ enum GitPullStrategy: String, Sendable { case rebase } -enum GitOperationKind: String, Equatable, Sendable { +package enum GitOperationKind: String, Equatable, Sendable { case merge case rebase case cherryPick case revert - var title: String { + package var title: String { switch self { case .merge: "Merging" case .rebase: "Rebasing" @@ -550,7 +580,7 @@ enum GitOperationKind: String, Equatable, Sendable { /// Whole literal keys rather than interpolating `title`, so translators get a /// complete sentence per operation instead of a fragment. - var inProgressTitle: String { + package var inProgressTitle: String { switch self { case .merge: "Merge in progress" case .rebase: "Rebase in progress" @@ -559,7 +589,7 @@ enum GitOperationKind: String, Equatable, Sendable { } } - var continueTitle: String { + package var continueTitle: String { switch self { case .merge: "Continue Merge" case .rebase: "Continue Rebase" @@ -569,49 +599,50 @@ enum GitOperationKind: String, Equatable, Sendable { } /// Only a rebase replays a sequence of commits, so it alone can skip one. - var canSkip: Bool { self == .rebase } + package var canSkip: Bool { self == .rebase } } /// A merge, rebase, cherry-pick, or revert that Git left half-finished, usually /// because it hit conflicts. Absent when the repository is in its normal state. -struct GitOperationState: Equatable, Sendable { - let kind: GitOperationKind - let reference: String? - let step: Int? - let total: Int? - let conflictedPaths: [String] +package struct GitOperationState: Equatable, Sendable { + package let kind: GitOperationKind + package let reference: String? + package let step: Int? + package let total: Int? + package let conflictedPaths: [String] + package init(kind: GitOperationKind, reference: String?, step: Int?, total: Int?, conflictedPaths: [String]) { self.kind = kind; self.reference = reference; self.step = step; self.total = total; self.conflictedPaths = conflictedPaths } - var hasConflicts: Bool { !conflictedPaths.isEmpty } + package var hasConflicts: Bool { !conflictedPaths.isEmpty } /// Rebase progress as `3/7`, nil for operations that replay a single commit. - var progress: String? { + package var progress: String? { guard let step, let total, total > 0 else { return nil } return "\(step)/\(total)" } } /// How to resolve a checkout blocked by local changes. -enum GitCheckoutConflictStrategy: Sendable { +package enum GitCheckoutConflictStrategy: Sendable { /// Stash the local changes, switch, then restore them. case smart /// Switch and discard the local changes. case force } -enum GitSaveChangesPolicy: String, CaseIterable, Identifiable, Sendable { +package enum GitSaveChangesPolicy: String, CaseIterable, Identifiable, Sendable { case stash case shelve - var id: String { rawValue } + package var id: String { rawValue } - var title: String { + package var title: String { switch self { case .stash: "Git stash" case .shelve: "Lithe Shelve" } } - var description: String { + package var description: String { switch self { case .stash: "Store temporary changes in Git's stash list." case .shelve: "Store patches in Lithe without adding objects to Git." @@ -619,17 +650,17 @@ enum GitSaveChangesPolicy: String, CaseIterable, Identifiable, Sendable { } } -enum DiffParser { +package enum DiffParser { private struct Entry { let number: Int let text: String } - static func parse(_ patch: String) -> [DiffRow] { + package static func parse(_ patch: String) -> [DiffRow] { parseDocument(patch).rows } - static func parseDocument(_ patch: String) -> DiffDocument { + package static func parseDocument(_ patch: String) -> DiffDocument { var rows: [DiffRow] = [] var oldLine = 0 var newLine = 0 diff --git a/Sources/LitheGitModule/Module/GitModule.swift b/Sources/LitheGitModule/Module/GitModule.swift new file mode 100644 index 00000000..64918f66 --- /dev/null +++ b/Sources/LitheGitModule/Module/GitModule.swift @@ -0,0 +1,82 @@ +import Foundation +import LitheModuleAPI + +@MainActor +public final class GitModuleCapability: NSObject { + package let feature: GitFeatureModel + + package init(feature: GitFeatureModel) { + self.feature = feature + } +} + +@MainActor +public final class GitModule: LitheModule { + public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .git) + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .git)! + + public let manifest = moduleManifest + private let operations: any GitOperations + private let shelfStorage: any GitShelfStorage + private var capability: GitModuleCapability? + + package init(operations: any GitOperations, shelfStorage: any GitShelfStorage) { + self.operations = operations + self.shelfStorage = shelfStorage + } + + public func activate(context: ModuleContext) async throws { + guard capability == nil else { return } + let feature = GitFeatureModel( + service: GitService(operations: operations), + shelveService: ShelveService(storage: shelfStorage) + ) + feature.configureModuleLeases { reason in + context.leases.acquireLease(reason: reason) + } + context.resources.register(GitFeatureResource(feature: feature)) + capability = GitModuleCapability(feature: feature) + } + + public func prepareForSleep() async throws { + guard capability?.feature.hasActiveModuleWork != true else { + throw GitModuleSleepError.activeWork + } + } + + public func sleep() async { releaseFeature() } + public func shutdown() async { releaseFeature() } + + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.gitWorkspace: capability] + } + + public func contributions() -> [ModuleContribution] { + Self.moduleContributions + } + + private func releaseFeature() { + capability?.feature.reset() + capability = nil + } +} + +public enum GitModuleSleepError: LocalizedError, Sendable { + case activeWork + + public var errorDescription: String? { "Git work is still active." } +} + +@MainActor +private final class GitFeatureResource: ModuleResource { + let feature: GitFeatureModel + + init(feature: GitFeatureModel) { + self.feature = feature + } + + var moduleResourceKind: String { "git-feature-work" } + var isModuleResourceActive: Bool { feature.hasActiveModuleWork } + func stopModuleResource() async { feature.reset() } +} diff --git a/Sources/LitheGitModule/Ports/GitPorts.swift b/Sources/LitheGitModule/Ports/GitPorts.swift new file mode 100644 index 00000000..f7596deb --- /dev/null +++ b/Sources/LitheGitModule/Ports/GitPorts.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct GitProcessResult: Sendable { + public let output: String + public let exitCode: Int32 + public let stashRestoreConflict: GitStashRestoreConflict? + public init(output: String, exitCode: Int32, stashRestoreConflict: GitStashRestoreConflict? = nil) { + self.output = output + self.exitCode = exitCode + self.stashRestoreConflict = stashRestoreConflict + } +} + +public protocol GitShelfStorage: Sendable { + func applicationSupportDirectory() -> URL + func fileExists(at url: URL) -> Bool + func listDirectory(at url: URL) -> [URL] + func readData(from url: URL) throws -> Data + func writeData(_ data: Data, to url: URL) throws + func createDirectory(at url: URL) throws + func removeItem(at url: URL) throws +} diff --git a/Sources/Lithe/Services/GitGraphLayoutService.swift b/Sources/LitheGitModule/Services/GitGraphLayoutService.swift similarity index 98% rename from Sources/Lithe/Services/GitGraphLayoutService.swift rename to Sources/LitheGitModule/Services/GitGraphLayoutService.swift index a0a859c9..35f07023 100644 --- a/Sources/Lithe/Services/GitGraphLayoutService.swift +++ b/Sources/LitheGitModule/Services/GitGraphLayoutService.swift @@ -1,6 +1,6 @@ import Foundation -enum GitGraphLayoutService { +package enum GitGraphLayoutService { private struct Lane: Hashable { let hash: String let colorIndex: Int @@ -13,7 +13,7 @@ enum GitGraphLayoutService { /// reads as a broken branch line. Inserting or removing slots positionally /// renumbers every later lane, so a slot is only ever cleared in place and /// reused once free. - static func layout(commits: [GitCommit]) -> GitGraphLayout { + package static func layout(commits: [GitCommit]) -> GitGraphLayout { guard !commits.isEmpty else { return GitGraphLayout(rows: [], laneCount: 0, hasMissingParents: false) } diff --git a/Sources/Lithe/Services/GitService.swift b/Sources/LitheGitModule/Services/GitService.swift similarity index 89% rename from Sources/Lithe/Services/GitService.swift rename to Sources/LitheGitModule/Services/GitService.swift index 166d778b..43f3e272 100644 --- a/Sources/Lithe/Services/GitService.swift +++ b/Sources/LitheGitModule/Services/GitService.swift @@ -1,6 +1,7 @@ import Foundation +import LitheCoreContracts -protocol GitOperations: Sendable { +package protocol GitOperations: Sendable { func snapshot(at rootURL: URL) -> GitSnapshot? func watchContext(at rootURL: URL) -> GitWatchContext? @@ -39,7 +40,7 @@ protocol GitOperations: Sendable { _ patch: String, at rootURL: URL, mode: String - ) -> ProcessResult? + ) -> GitProcessResult? func history( at rootURL: URL, @@ -53,20 +54,20 @@ protocol GitOperations: Sendable { func stashes(at rootURL: URL) -> [GitStash]? func blame(at rootURL: URL, relativePath: String) -> [GitBlameLine]? - func stage(_ change: GitChange) -> ProcessResult? - func unstage(_ change: GitChange) -> ProcessResult? - func discard(_ change: GitChange) -> ProcessResult? - func discardAll(_ change: GitChange) -> ProcessResult? - func commit(at rootURL: URL, message: String, amend: Bool) -> ProcessResult? - func cherryPick(_ hash: String, at rootURL: URL) -> ProcessResult? - func revert(_ hash: String, at rootURL: URL) -> ProcessResult? - func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> ProcessResult? - func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> ProcessResult? - func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> ProcessResult? - func deleteBranch(_ reference: GitReference, at rootURL: URL) -> ProcessResult? - func mergeBranch(_ reference: GitReference, at rootURL: URL) -> ProcessResult? - func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> ProcessResult? - func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> ProcessResult? + func stage(_ change: GitChange) -> GitProcessResult? + func unstage(_ change: GitChange) -> GitProcessResult? + func discard(_ change: GitChange) -> GitProcessResult? + func discardAll(_ change: GitChange) -> GitProcessResult? + func commit(at rootURL: URL, message: String, amend: Bool) -> GitProcessResult? + func cherryPick(_ hash: String, at rootURL: URL) -> GitProcessResult? + func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? + func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? + func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? + func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? + func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? + func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? + func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? + func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult? func pullPreflight(at rootURL: URL) -> GitPullPreflightState? func conflictMarkerPaths(at rootURL: URL) -> [String] func integrationPreflight( @@ -74,47 +75,45 @@ protocol GitOperations: Sendable { operation: GitIntegrationOperation, at rootURL: URL ) -> GitIntegrationPreflightState? - func fetch(at rootURL: URL) -> ProcessResult? + func fetch(at rootURL: URL) -> GitProcessResult? func checkout( _ reference: GitReference, at rootURL: URL, force: Bool, autoStash: Bool - ) -> ProcessResult? + ) -> GitProcessResult? func checkoutBlockingPaths(for reference: GitReference, at rootURL: URL) -> [String] func operationState(at rootURL: URL) -> GitOperationState? - func continueOperation(at rootURL: URL) -> ProcessResult? - func abortOperation(at rootURL: URL) -> ProcessResult? - func skipOperationStep(at rootURL: URL) -> ProcessResult? - func checkoutRevision(_ revision: String, at rootURL: URL) -> ProcessResult? - func push(_ reference: GitReference, at rootURL: URL) -> ProcessResult? - func cloneRepository(from remote: String, to destination: URL) -> ProcessResult? - func stash(message: String, includeUntracked: Bool, at rootURL: URL) -> ProcessResult? - func applyStash(_ stash: GitStash, at rootURL: URL) -> ProcessResult? - func popStash(_ stash: GitStash, at rootURL: URL) -> ProcessResult? - func dropStash(_ stash: GitStash, at rootURL: URL) -> ProcessResult? - func stageAll(at rootURL: URL) -> ProcessResult? + func continueOperation(at rootURL: URL) -> GitProcessResult? + func abortOperation(at rootURL: URL) -> GitProcessResult? + func skipOperationStep(at rootURL: URL) -> GitProcessResult? + func checkoutRevision(_ revision: String, at rootURL: URL) -> GitProcessResult? + func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? + func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? + func stash(message: String, includeUntracked: Bool, at rootURL: URL) -> GitProcessResult? + func applyStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? + func popStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? + func dropStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? + func stageAll(at rootURL: URL) -> GitProcessResult? } -protocol GitWatchContextProviding: Sendable { - func watchContext(for workspace: URL) async -> GitWatchContext? -} +package typealias GitWatchContextProviding = LitheCoreContracts.GitWatchContextProviding /// UI-facing Git service. Git command construction, validation, parsing, and /// process execution live behind the shared Rust operations port. -struct GitService: GitWatchContextProviding, Sendable { +package struct GitService: Sendable { private let operations: any GitOperations - init(operations: any GitOperations) { + package init(operations: any GitOperations) { self.operations = operations } - struct CommandResult: Sendable { - let output: String - let exitCode: Int32 - let stashRestoreConflict: GitStashRestoreConflict? + package struct CommandResult: Sendable { + package let output: String + package let exitCode: Int32 + package let stashRestoreConflict: GitStashRestoreConflict? - init( + package init( output: String, exitCode: Int32, stashRestoreConflict: GitStashRestoreConflict? = nil @@ -124,18 +123,13 @@ struct GitService: GitWatchContextProviding, Sendable { self.stashRestoreConflict = stashRestoreConflict } - var succeeded: Bool { exitCode == 0 } + package var succeeded: Bool { exitCode == 0 } } func snapshot(for workspace: URL) async -> GitSnapshot? { await read(priority: .utility) { $0.snapshot(at: workspace) } } - func watchContext(for workspace: URL) async -> GitWatchContext? { - await read(priority: .utility) { $0.watchContext(at: workspace) } - } - - func diff(for change: GitChange) async -> [DiffRow] { (await diffDocument(for: change)).rows } @@ -482,7 +476,7 @@ struct GitService: GitWatchContextProviding, Sendable { } private func command( - _ operation: @escaping @Sendable (any GitOperations) -> ProcessResult? + _ operation: @escaping @Sendable (any GitOperations) -> GitProcessResult? ) async -> CommandResult { let operations = self.operations return await Task.detached(priority: .userInitiated) { diff --git a/Sources/Lithe/Services/ShelveService.swift b/Sources/LitheGitModule/Services/ShelveService.swift similarity index 92% rename from Sources/Lithe/Services/ShelveService.swift rename to Sources/LitheGitModule/Services/ShelveService.swift index 7932b17e..8c7ca9f2 100644 --- a/Sources/Lithe/Services/ShelveService.swift +++ b/Sources/LitheGitModule/Services/ShelveService.swift @@ -6,11 +6,11 @@ import Foundation /// a stable hash of the repository root. Patch text stays portable and the /// metadata is explicit so a future format migration can reject or upgrade old /// entries instead of guessing. -struct ShelveService: Sendable { +package struct ShelveService: Sendable { private static let formatVersion = 1 - private let storage: any FileStorage + private let storage: any GitShelfStorage - init(storage: any FileStorage) { + package init(storage: any GitShelfStorage) { self.storage = storage } @@ -102,14 +102,14 @@ struct ShelveService: Sendable { } private static func readEntries( - storage: any FileStorage, + storage: any GitShelfStorage, repositoryRootPath: String ) -> [GitShelfEntry] { let directory = directoryURL(storage: storage, repositoryRootPath: repositoryRootPath) return storage.listDirectory(at: directory) .filter { $0.pathExtension == "json" } .compactMap { url in - guard let data = try? storage.readData(from: url, options: []) else { return nil } + guard let data = try? storage.readData(from: url) else { return nil } return (try? JSONDecoder().decode(DiskEntry.self, from: data))?.model } .sorted { $0.createdAt > $1.createdAt } @@ -117,15 +117,15 @@ struct ShelveService: Sendable { private static func write( _ entry: GitShelfEntry, - storage: any FileStorage, + storage: any GitShelfStorage, repositoryRootPath: String ) -> Bool { let directory = directoryURL(storage: storage, repositoryRootPath: repositoryRootPath) let url = fileURL(for: entry.id, storage: storage, repositoryRootPath: repositoryRootPath) do { - try storage.createDirectory(at: directory, withIntermediateDirectories: true) + try storage.createDirectory(at: directory) let data = try JSONEncoder().encode(DiskEntry(from: entry)) - try storage.writeData(data, to: url, options: []) + try storage.writeData(data, to: url) return true } catch { return false @@ -133,7 +133,7 @@ struct ShelveService: Sendable { } private static func directoryURL( - storage: any FileStorage, + storage: any GitShelfStorage, repositoryRootPath: String ) -> URL { storage.applicationSupportDirectory() @@ -144,7 +144,7 @@ struct ShelveService: Sendable { private static func fileURL( for id: UUID, - storage: any FileStorage, + storage: any GitShelfStorage, repositoryRootPath: String ) -> URL { directoryURL(storage: storage, repositoryRootPath: repositoryRootPath) diff --git a/Sources/LitheGoSupportModule/Capabilities/GoExecutionCapability.swift b/Sources/LitheGoSupportModule/Capabilities/GoExecutionCapability.swift new file mode 100644 index 00000000..ee959469 --- /dev/null +++ b/Sources/LitheGoSupportModule/Capabilities/GoExecutionCapability.swift @@ -0,0 +1,140 @@ +import Foundation +import LitheCoreContracts + +@MainActor +public final class GoExecutionCapability: NSObject, + LanguageRunExtensionProviding, + LanguageTestExtensionProviding { + public let languageID = goLanguageID + private let sessionFactory: @MainActor () -> any LanguageExecutionSession + + public init(executionSession: any LanguageExecutionSession) { + sessionFactory = { executionSession } + } + + init(sessionFactory: @escaping @MainActor () -> any LanguageExecutionSession) { + self.sessionFactory = sessionFactory + } + + public func makeExecutionSession() -> any LanguageExecutionSession { + sessionFactory() + } + + public func makeTestExecutionSession() -> any LanguageExecutionSession { + sessionFactory() + } + + public func launchPlan( + for request: LanguageRunExtensionRequest + ) throws -> LanguageRunExtensionPlan { + let path = request.relativeFilePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty, + !path.hasPrefix("/"), + !path.split(separator: "/").contains("..") else { + throw LanguageRunExtensionError.invalidRelativePath + } + return LanguageRunExtensionPlan( + executable: .toolchain("project-go"), + arguments: ["run", path] + request.arguments, + environment: request.environment + ) + } + + public func discoverTests( + for request: LanguageTestExtensionDiscoveryRequest + ) throws -> [LanguageTestExtensionItem] { + let paths = try request.relativeProjectFilePaths.map(Self.checkedRelativePath) + guard Self.isGoProject(paths) else { return [] } + let files = paths + .filter { $0.lowercased().hasSuffix("_test.go") } + .sorted() + .map { path in + LanguageTestExtensionItem( + id: "go:file:\(path)", + label: path, + kind: .file, + relativeFilePath: path + ) + } + return [LanguageTestExtensionItem( + id: "go:workspace", + label: "All Go Tests", + kind: .workspace + )] + files + } + + public func testPlan( + for request: LanguageTestExtensionRequest + ) throws -> LanguageTestExtensionPlan { + let projectPaths = try request.relativeProjectFilePaths.map(Self.checkedRelativePath) + guard Self.isGoProject(projectPaths) else { + throw LanguageTestExtensionError.unsupportedProject(languageID: languageID) + } + let arguments: [String] + let label: String + switch request.scope { + case .workspace: + arguments = ["test", "./..."] + label = "All Go Tests" + case .file(let relativePath): + let path = try Self.checkedRelativePath(relativePath) + arguments = ["test", Self.packageArgument(for: path)] + label = path.split(separator: "/").last.map(String.init) ?? path + case .testCase(let identifier, let relativeFilePath): + let name = identifier.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !name.contains("\n"), !name.contains("\r") else { + throw LanguageTestExtensionError.invalidTestIdentifier + } + let package: String + if let relativeFilePath { + package = Self.packageArgument( + for: try Self.checkedRelativePath(relativeFilePath) + ) + } else { + package = "./..." + } + arguments = [ + "test", + package, + "-run", + "^\(Self.goRegularExpressionLiteral(name))$" + ] + label = name + } + return LanguageTestExtensionPlan( + label: label, + frameworkID: "go", + launchPlan: LanguageRunExtensionPlan( + executable: .toolchain("project-go"), + arguments: arguments + ) + ) + } + + private static func checkedRelativePath(_ value: String) throws -> String { + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty, + !path.hasPrefix("/"), + !path.split(separator: "/").contains("..") else { + throw LanguageTestExtensionError.invalidRelativePath + } + return path + } + + private static func isGoProject(_ paths: [String]) -> Bool { + paths.contains { path in + let name = path.split(separator: "/").last.map(String.init)?.lowercased() + return name == "go.mod" || name == "go.work" + } + } + + private static func packageArgument(for relativeFilePath: String) -> String { + let components = relativeFilePath.split(separator: "/").dropLast() + return components.isEmpty ? "./..." : "./" + components.joined(separator: "/") + } + + private static func goRegularExpressionLiteral(_ value: String) -> String { + NSRegularExpression.escapedPattern(for: value) + .replacingOccurrences(of: "\\/", with: "/") + } +} diff --git a/Sources/LitheGoSupportModule/Capabilities/GoLanguageServerCapability.swift b/Sources/LitheGoSupportModule/Capabilities/GoLanguageServerCapability.swift new file mode 100644 index 00000000..10bfaf50 --- /dev/null +++ b/Sources/LitheGoSupportModule/Capabilities/GoLanguageServerCapability.swift @@ -0,0 +1,38 @@ +import Foundation +import LitheCoreContracts + +@MainActor +public final class GoLanguageServerCapability: NSObject, LanguageServerExtensionProviding { + public let configuration = LanguageServerExtensionConfiguration( + languageID: goLanguageID, + displayName: "Go", + executableNames: ["gopls"], + validationArguments: ["version"], + languageIdentifier: "go" + ) + public let lifecycle: any LanguageServerExtensionLifecycle + + init(lifecycle: any LanguageServerExtensionLifecycle) { + self.lifecycle = lifecycle + } +} + +@MainActor +final class GoLanguageServerLifecycle: LanguageServerExtensionLifecycle { + private var running: @MainActor () -> Bool = { false } + private var stopAction: @MainActor () -> Void = {} + + var isRunning: Bool { running() } + + func attach( + isRunning: @escaping @MainActor () -> Bool, + stop: @escaping @MainActor () -> Void + ) { + running = isRunning + stopAction = stop + } + + func stop() { + stopAction() + } +} diff --git a/Sources/LitheGoSupportModule/Module/GoExecutionModule.swift b/Sources/LitheGoSupportModule/Module/GoExecutionModule.swift new file mode 100644 index 00000000..d12eda02 --- /dev/null +++ b/Sources/LitheGoSupportModule/Module/GoExecutionModule.swift @@ -0,0 +1,176 @@ +import LitheCoreContracts +import LitheModuleAPI + +@MainActor +public final class GoExecutionModule: LitheModule { + public static let moduleManifest = OfficialPluginCatalog.moduleManifest( + for: .languageExecutionExtension(goLanguageID) + )! + + public let manifest = moduleManifest + private let executionHost: (any LanguageExecutionHostProviding)? + private var capability: GoExecutionCapability? + + public init(executionHost: (any LanguageExecutionHostProviding)? = nil) { + self.executionHost = executionHost + } + + public func activate(context: ModuleContext) async throws { + guard let executionHost else { + throw LanguageExtensionHostError.missingExecutionHost(languageID: goLanguageID) + } + let sessions = GoExecutionResource( + executionHost: executionHost, + ownerModuleID: manifest.id, + leases: context.leases, + events: context.events + ) + context.resources.register(sessions) + capability = GoExecutionCapability(sessionFactory: { sessions.makeSession() }) + } + + public func prepareForSleep() async throws {} + public func sleep() async { releaseCapability() } + public func shutdown() async { releaseCapability() } + + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + capability.map { + [ + .languageExecutionExtension(goLanguageID): $0, + .languageTestingExtension(goLanguageID): $0 + ] + } ?? [:] + } + + private func releaseCapability() { + capability = nil + } +} + +@MainActor +private final class GoExecutionResource: ModuleResource { + let moduleResourceKind = "language-execution-process" + private let executionHost: any LanguageExecutionHostProviding + private let ownerModuleID: ModuleID + private let leases: any ModuleLeaseManaging + private let events: any ModuleEventPublishing + private var sessions: [any LanguageExecutionSession] = [] + private var failedStopCount = 0 + + init( + executionHost: any LanguageExecutionHostProviding, + ownerModuleID: ModuleID, + leases: any ModuleLeaseManaging, + events: any ModuleEventPublishing + ) { + self.executionHost = executionHost + self.ownerModuleID = ownerModuleID + self.leases = leases + self.events = events + } + + var isModuleResourceActive: Bool { + failedStopCount > 0 || sessions.contains(where: \.isRunning) + } + + func makeSession() -> any LanguageExecutionSession { + let session = GoOwnedExecutionSession( + underlying: executionHost.makeSession(ownerModuleID: ownerModuleID), + ownerModuleID: ownerModuleID, + leases: leases, + events: events + ) + sessions.append(session) + return session + } + + func stopModuleResource() async { + failedStopCount = 0 + for session in sessions where session.isRunning { + if !(await session.stopAndWait()) { + failedStopCount += 1 + } + } + if failedStopCount == 0 { + sessions.removeAll() + } + } +} + +@MainActor +private final class GoOwnedExecutionSession: LanguageExecutionSession { + var isRunning: Bool { underlying.isRunning } + var onOutput: (@Sendable (String) -> Void)? + var onTermination: (@Sendable (Int32) -> Void)? + var onStateChange: (@Sendable (LanguageExecutionLifecycleEvent) -> Void)? + + private let underlying: any LanguageExecutionSession + private let ownerModuleID: ModuleID + private let leases: any ModuleLeaseManaging + private let events: any ModuleEventPublishing + private var activityLease: ModuleLease? + + init( + underlying: any LanguageExecutionSession, + ownerModuleID: ModuleID, + leases: any ModuleLeaseManaging, + events: any ModuleEventPublishing + ) { + self.underlying = underlying + self.ownerModuleID = ownerModuleID + self.leases = leases + self.events = events + } + + func start(_ request: LanguageExecutionProcessRequest) throws { + beginActivity(operationID: request.operationID) + installCallbacks() + do { + try underlying.start(request) + } catch { + endActivity() + throw error + } + } + + func stop() { + underlying.stop() + endActivity() + } + + func stopAndWait() async -> Bool { + let stopped = await underlying.stopAndWait() + if stopped { endActivity() } + return stopped + } + + private func installCallbacks() { + let output = onOutput + underlying.onOutput = { chunk in output?(chunk) } + + let termination = onTermination + underlying.onTermination = { [weak self] exitCode in + Task { @MainActor in + self?.endActivity() + termination?(exitCode) + } + } + + let stateChange = onStateChange + underlying.onStateChange = { event in stateChange?(event) } + } + + private func beginActivity(operationID: String?) { + guard activityLease == nil else { return } + let detail = operationID.map { "Language execution \($0)" } ?? "Language execution" + activityLease = leases.acquireLease(reason: detail) + events.publish(ModuleEvent(source: ownerModuleID, name: ModuleEvent.activityStartedName)) + } + + private func endActivity() { + guard let activityLease else { return } + activityLease.release() + self.activityLease = nil + events.publish(ModuleEvent(source: ownerModuleID, name: ModuleEvent.activityEndedName)) + } +} diff --git a/Sources/LitheGoSupportModule/Module/GoLanguageServerModule.swift b/Sources/LitheGoSupportModule/Module/GoLanguageServerModule.swift new file mode 100644 index 00000000..5bc55541 --- /dev/null +++ b/Sources/LitheGoSupportModule/Module/GoLanguageServerModule.swift @@ -0,0 +1,50 @@ +import LitheCoreContracts +import LitheModuleAPI + +@MainActor +public final class GoLanguageServerModule: LitheModule { + public static let moduleManifest = OfficialPluginCatalog.moduleManifest( + for: .languageServerExtension(goLanguageID) + )! + + public let manifest = moduleManifest + private var capability: GoLanguageServerCapability? + + public init() {} + + public func activate(context: ModuleContext) async throws { + let lifecycle = GoLanguageServerLifecycle() + context.resources.register(GoLanguageServerResource(lifecycle: lifecycle)) + capability = GoLanguageServerCapability(lifecycle: lifecycle) + } + + public func prepareForSleep() async throws {} + public func sleep() async { releaseCapability() } + public func shutdown() async { releaseCapability() } + + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + capability.map { [.languageServerExtension(goLanguageID): $0] } ?? [:] + } + + private func releaseCapability() { + capability?.lifecycle.stop() + capability = nil + } +} + +@MainActor +private final class GoLanguageServerResource: ModuleResource { + let moduleResourceKind = "language-server-session" + private let lifecycle: any LanguageServerExtensionLifecycle + + init(lifecycle: any LanguageServerExtensionLifecycle) { + self.lifecycle = lifecycle + } + + var isModuleResourceActive: Bool { lifecycle.isRunning } + + func stopModuleResource() async { + lifecycle.stop() + await lifecycle.waitUntilStopped() + } +} diff --git a/Sources/LitheGoSupportModule/Plugin/GoSupportPluginEntrypoint.swift b/Sources/LitheGoSupportModule/Plugin/GoSupportPluginEntrypoint.swift new file mode 100644 index 00000000..588a272d --- /dev/null +++ b/Sources/LitheGoSupportModule/Plugin/GoSupportPluginEntrypoint.swift @@ -0,0 +1,21 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +@MainActor +@objc(LitheGoSupportPluginEntrypoint) +public final class GoSupportPluginEntrypoint: NSObject, LithePluginEntrypoint { + public override init() { super.init() } + + public func moduleFactories(context: PluginHostContext) throws -> [ModuleFactory] { + let executionHost = context.service(.languageExecution) as? any LanguageExecutionHostProviding + return [ + ModuleFactory(manifest: GoExecutionModule.moduleManifest) { + GoExecutionModule(executionHost: executionHost) + }, + ModuleFactory(manifest: GoLanguageServerModule.moduleManifest) { + GoLanguageServerModule() + } + ] + } +} diff --git a/Sources/LitheGoSupportModule/Support/GoSupportIdentifiers.swift b/Sources/LitheGoSupportModule/Support/GoSupportIdentifiers.swift new file mode 100644 index 00000000..dc70ec89 --- /dev/null +++ b/Sources/LitheGoSupportModule/Support/GoSupportIdentifiers.swift @@ -0,0 +1 @@ +let goLanguageID = "go" diff --git a/Sources/LitheLanguageIntelligenceModule/Module/LanguageIntelligenceFeatureGraph.swift b/Sources/LitheLanguageIntelligenceModule/Module/LanguageIntelligenceFeatureGraph.swift new file mode 100644 index 00000000..a6c12ef2 --- /dev/null +++ b/Sources/LitheLanguageIntelligenceModule/Module/LanguageIntelligenceFeatureGraph.swift @@ -0,0 +1,46 @@ +import Foundation +import LitheModuleAPI + +@MainActor +package final class LanguageIntelligenceFeatureGraph: NSObject, LanguageIntelligenceServiceGraph { + package let sessions: LanguageToolingSessionManager + package let tools: LanguageServerToolService + + package init(sessions: LanguageToolingSessionManager, tools: LanguageServerToolService) { + self.sessions = sessions + self.tools = tools + } + + package var isActive: Bool { !sessions.activeLanguageServerIDs.isEmpty } + package var hasActiveLanguageServers: Bool { isActive } + + package func activate(context: ModuleContext) { + for descriptor in sessions.catalogSnapshot.descriptors + where descriptor.capabilities.contains(.languageServer) { + Task { await tools.refreshCandidates(for: descriptor) } + } + } + + package func prepareForSleep() async throws { + guard !isActive else { + throw LanguageIntelligenceSleepError.activeServers( + "Language servers are still active and cannot be put to sleep." + ) + } + } + + package func stop() async { + sessions.stopAll() + sessions.clearDiagnostics() + } +} + +private enum LanguageIntelligenceSleepError: LocalizedError { + case activeServers(String) + + var errorDescription: String? { + switch self { + case .activeServers(let message): message + } + } +} diff --git a/Sources/LitheLanguageIntelligenceModule/Module/LanguageIntelligenceModule.swift b/Sources/LitheLanguageIntelligenceModule/Module/LanguageIntelligenceModule.swift new file mode 100644 index 00000000..3a88a2a9 --- /dev/null +++ b/Sources/LitheLanguageIntelligenceModule/Module/LanguageIntelligenceModule.swift @@ -0,0 +1,104 @@ +import Foundation +import LitheModuleAPI + +/// The temporary host-facing seam used while the concrete language services +/// are being moved into this target. Unlike `FeatureModuleHandle`, this seam is +/// language-specific and cannot host an arbitrary application object. +/// +/// The module is the sole strong owner of the graph. Implementations must stop +/// every language-server session and polling task before `stop()` returns. +@MainActor +package protocol LanguageIntelligenceServiceGraph: AnyObject { + var sessions: LanguageToolingSessionManager { get } + var tools: LanguageServerToolService { get } + var hasActiveLanguageServers: Bool { get } + + func activate(context: ModuleContext) + func prepareForSleep() async throws + func stop() async +} + +@MainActor +public final class LanguageIntelligenceCapability: NSObject { + package let sessions: LanguageToolingSessionManager + package let tools: LanguageServerToolService + + fileprivate init(graph: any LanguageIntelligenceServiceGraph) { + sessions = graph.sessions + tools = graph.tools + } +} + +@MainActor +public final class LanguageIntelligenceModule: LitheModule { + public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .languageIntelligence) + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .languageIntelligence)! + + public let manifest = moduleManifest + + private let makeGraph: @MainActor () -> any LanguageIntelligenceServiceGraph + private var graph: (any LanguageIntelligenceServiceGraph)? + private var capability: LanguageIntelligenceCapability? + + package init( + makeGraph: @escaping @MainActor () -> any LanguageIntelligenceServiceGraph + ) { + self.makeGraph = makeGraph + } + + public func activate(context: ModuleContext) async throws { + guard graph == nil else { return } + + let graph = makeGraph() + graph.activate(context: context) + let resource = LanguageIntelligenceGraphResource(graph: graph) + context.resources.register(resource) + self.graph = graph + capability = LanguageIntelligenceCapability(graph: graph) + } + + public func prepareForSleep() async throws { + try await graph?.prepareForSleep() + } + + public func sleep() async { + await releaseGraph() + } + + public func shutdown() async { + await releaseGraph() + } + + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.languageIntelligence: capability] + } + + public func contributions() -> [ModuleContribution] { + Self.moduleContributions + } + + private func releaseGraph() async { + await graph?.stop() + capability = nil + graph = nil + } +} + +@MainActor +private final class LanguageIntelligenceGraphResource: ModuleResource { + let moduleResourceKind = "language-intelligence-sessions" + private let graph: any LanguageIntelligenceServiceGraph + + init(graph: any LanguageIntelligenceServiceGraph) { + self.graph = graph + } + + var isModuleResourceActive: Bool { + graph.hasActiveLanguageServers + } + + func stopModuleResource() async { + await graph.stop() + } +} diff --git a/Sources/Lithe/Services/LanguageFeatureProvider.swift b/Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift similarity index 81% rename from Sources/Lithe/Services/LanguageFeatureProvider.swift rename to Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift index 7eb532d2..47f7f0b6 100644 --- a/Sources/Lithe/Services/LanguageFeatureProvider.swift +++ b/Sources/LitheLanguageIntelligenceModule/Providers/LanguageFeatureProvider.swift @@ -1,35 +1,59 @@ import Foundation +import LitheCoreContracts + +struct UnavailableBuiltinLanguageFeatureCore: BuiltinLanguageFeatureCore { + var isBuiltinLanguageFeatureAvailable: Bool { false } + + func builtinLanguageCompletions( + fileURL _: URL, + text _: String, + position _: LanguageServerPosition + ) -> [LanguageServerCompletionItem]? { nil } + + func builtinLanguageHover( + fileURL _: URL, + text _: String, + position _: LanguageServerPosition + ) -> LanguageServerHover? { nil } + + func builtinLanguageNavigation( + method _: String, + fileURL _: URL, + text _: String, + position _: LanguageServerPosition + ) -> [LanguageServerLocation]? { nil } +} -enum LanguageFeature: Hashable, Sendable { +package enum LanguageFeature: Hashable, Sendable { case completion case hover case navigation(method: String) } -struct LanguageFeatureProviderPriority: RawRepresentable, Comparable, Hashable, Sendable { - let rawValue: Int +package struct LanguageFeatureProviderPriority: RawRepresentable, Comparable, Hashable, Sendable { + package let rawValue: Int - init(rawValue: Int) { + package init(rawValue: Int) { self.rawValue = rawValue } - static let builtin = Self(rawValue: 0) - static let projectSymbols = Self(rawValue: 100) - static let languageServer = Self(rawValue: 200) + package static let builtin = Self(rawValue: 0) + package static let projectSymbols = Self(rawValue: 100) + package static let languageServer = Self(rawValue: 200) - static func < (lhs: Self, rhs: Self) -> Bool { + package 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? +package struct LanguageFeatureRequestContext: Sendable { + package let fileURL: URL + package let text: String + package let position: LanguageServerPosition + package let languageID: String? + package let workspaceURL: URL? - init( + package init( fileURL: URL, text: String, position: LanguageServerPosition, @@ -45,7 +69,7 @@ struct LanguageFeatureRequestContext: Sendable { } @MainActor -protocol LanguageFeatureProvider: AnyObject { +package protocol LanguageFeatureProvider: AnyObject { var id: String { get } var priority: LanguageFeatureProviderPriority { get } @@ -66,30 +90,34 @@ protocol LanguageFeatureProvider: AnyObject { } @MainActor -final class BuiltinLanguageFeatureProvider: LanguageFeatureProvider { - let id = "builtin" - let priority: LanguageFeatureProviderPriority = .builtin +package final class BuiltinLanguageFeatureProvider: LanguageFeatureProvider { + package let id = "builtin" + package let priority: LanguageFeatureProviderPriority = .builtin - private let core: RustCoreBridge + private let core: any BuiltinLanguageFeatureCore - init(core: RustCoreBridge = RustCoreBridge()) { + package init(core: any BuiltinLanguageFeatureCore) { self.core = core } - func supports(_ feature: LanguageFeature, in context: LanguageFeatureRequestContext) -> Bool { + package convenience init() { + self.init(core: UnavailableBuiltinLanguageFeatureCore()) + } + + package func supports(_ feature: LanguageFeature, in context: LanguageFeatureRequestContext) -> Bool { switch feature { case .completion: - return core.isAvailable || Self.keywordLanguage(for: context) != nil + return core.isBuiltinLanguageFeatureAvailable || Self.keywordLanguage(for: context) != nil case .hover, .navigation: - return core.isAvailable + return core.isBuiltinLanguageFeatureAvailable } } - func completions( + package func completions( in context: LanguageFeatureRequestContext, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void ) throws { - let symbols = core.isAvailable + let symbols = core.isBuiltinLanguageFeatureAvailable ? core.builtinLanguageCompletions( fileURL: context.fileURL, text: context.text, @@ -103,12 +131,12 @@ final class BuiltinLanguageFeatureProvider: LanguageFeatureProvider { completion(.success(merged)) } - func hover( + package func hover( in context: LanguageFeatureRequestContext, completion: @escaping (Result) -> Void ) throws { completion(.success( - core.isAvailable + core.isBuiltinLanguageFeatureAvailable ? core.builtinLanguageHover( fileURL: context.fileURL, text: context.text, @@ -118,13 +146,13 @@ final class BuiltinLanguageFeatureProvider: LanguageFeatureProvider { )) } - func navigate( + package func navigate( method: String, in context: LanguageFeatureRequestContext, completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void ) throws { completion(.success( - core.isAvailable + core.isBuiltinLanguageFeatureAvailable ? core.builtinLanguageNavigation( method: method, fileURL: context.fileURL, @@ -137,14 +165,14 @@ final class BuiltinLanguageFeatureProvider: LanguageFeatureProvider { } @MainActor -final class LanguageServerFeatureProvider: LanguageFeatureProvider { - let id: String - let priority: LanguageFeatureProviderPriority = .languageServer +package final class LanguageServerFeatureProvider: LanguageFeatureProvider { + package let id: String + package let priority: LanguageFeatureProviderPriority = .languageServer private let session: any LanguageServerSession private(set) var features: LanguageServerFeatureSet - init( + package init( providerID: String, session: any LanguageServerSession, features: LanguageServerFeatureSet = [] @@ -154,11 +182,11 @@ final class LanguageServerFeatureProvider: LanguageFeatureProvider { self.features = features } - func updateFeatures(_ features: LanguageServerFeatureSet) { + package func updateFeatures(_ features: LanguageServerFeatureSet) { self.features = features } - func supports(_ feature: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool { + package func supports(_ feature: LanguageFeature, in _: LanguageFeatureRequestContext) -> Bool { guard session.isRunning else { return false } switch feature { case .completion: @@ -170,7 +198,7 @@ final class LanguageServerFeatureProvider: LanguageFeatureProvider { } } - func completions( + package func completions( in context: LanguageFeatureRequestContext, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void ) throws { @@ -181,7 +209,7 @@ final class LanguageServerFeatureProvider: LanguageFeatureProvider { ) } - func hover( + package func hover( in context: LanguageFeatureRequestContext, completion: @escaping (Result) -> Void ) throws { @@ -192,7 +220,7 @@ final class LanguageServerFeatureProvider: LanguageFeatureProvider { ) } - func navigate( + package func navigate( method: String, in context: LanguageFeatureRequestContext, completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void diff --git a/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift b/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift new file mode 100644 index 00000000..92c0d6c4 --- /dev/null +++ b/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift @@ -0,0 +1,141 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +@MainActor +package final class StdioLanguageProviderRuntime: LanguageProviderRuntime { + package let descriptor: LanguageProviderDescriptor + private let runtimeService: any LanguageToolRuntimePort + private let languageServerLaunch: LanguageServerLaunchDescriptor? + private let languageServerCore: any LanguageServerRuntimeCore + private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? + private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? + private let languageServerCacheDirectory: URL? + private weak var processRegistry: (any LanguageServerProcessRegistry)? + private let moduleID: ModuleID + + package var supportsLanguageServerSession: Bool { + languageServerLaunch != nil + } + + package var unavailableToolingMessage: String? { + guard let command = languageServerLaunch?.executableNames.first else { return nil } + return runtimeService.missingLanguageToolMessage(command) + } + + package init( + descriptor: LanguageProviderDescriptor, + runtimeService: any LanguageToolRuntimePort, + languageServerLaunch: LanguageServerLaunchDescriptor? = nil, + languageServerCore: any LanguageServerRuntimeCore, + languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, + languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, + languageServerCacheDirectory: URL? = nil, + processRegistry: (any LanguageServerProcessRegistry)? = nil, + moduleID: ModuleID = .languageIntelligence + ) { + self.descriptor = descriptor + self.runtimeService = runtimeService + self.languageServerLaunch = languageServerLaunch + self.languageServerCore = languageServerCore + self.languageServerExecutableResolver = languageServerExecutableResolver + self.languageServerRuntimeResolver = languageServerRuntimeResolver + self.languageServerCacheDirectory = languageServerCacheDirectory + self.processRegistry = processRegistry + self.moduleID = moduleID + } + + package func makeLanguageServerSession() -> (any LanguageServerSession)? { + guard let languageServerLaunch else { return nil } + let executableURL = if let languageServerExecutableResolver { + languageServerExecutableResolver(descriptor) + } else { + languageServerLaunch.executableNames.lazy.compactMap({ + self.runtimeService.executableOnPath($0) + }).first + } + guard let executableURL else { return nil } + var environment = runtimeService.languageToolProcessEnvironment() + environment.merge(languageServerLaunch.environment) { _, configured in configured } + return LanguageServerRuntimeSession( + providerID: descriptor.id, + executableURL: executableURL, + arguments: languageServerLaunch.arguments, + environment: environment, + initializationOptions: languageServerLaunch.initializationOptions, + runtimeExecutableURL: languageServerRuntimeResolver?(descriptor), + cacheDirectoryURL: languageServerCacheDirectory, + core: languageServerCore, + processRegistry: processRegistry, + moduleID: moduleID + ) + } + +} + +@MainActor +package final class StdioLanguageProviderRuntimeFactory: LanguageProviderRuntimeFactory { + private let runtimeService: any LanguageToolRuntimePort + private let languageServerCore: any LanguageServerRuntimeCore + private let languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? + private let languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? + private let languageServerCacheDirectory: URL? + private weak var processRegistry: (any LanguageServerProcessRegistry)? + private let moduleID: ModuleID + + package init( + runtimeService: any LanguageToolRuntimePort, + languageServerCore: any LanguageServerRuntimeCore, + languageServerExecutableResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, + languageServerRuntimeResolver: ((LanguageProviderDescriptor) -> URL?)? = nil, + languageServerCacheDirectory: URL? = nil, + processRegistry: (any LanguageServerProcessRegistry)? = nil, + moduleID: ModuleID = .languageIntelligence + ) { + self.runtimeService = runtimeService + self.languageServerCore = languageServerCore + self.languageServerExecutableResolver = languageServerExecutableResolver + self.languageServerRuntimeResolver = languageServerRuntimeResolver + self.languageServerCacheDirectory = languageServerCacheDirectory + self.processRegistry = processRegistry + self.moduleID = moduleID + } + + package func makeRuntime( + for descriptor: LanguageProviderDescriptor + ) -> (any LanguageProviderRuntime)? { + let languageServerLaunch = descriptor.capabilities.contains(.languageServer) + ? descriptor.languageServerLaunch + : nil + guard languageServerLaunch != nil else { return nil } + return StdioLanguageProviderRuntime( + descriptor: descriptor, + runtimeService: runtimeService, + languageServerLaunch: languageServerLaunch, + languageServerCore: languageServerCore, + languageServerExecutableResolver: languageServerExecutableResolver, + languageServerRuntimeResolver: languageServerRuntimeResolver, + languageServerCacheDirectory: languageServerCacheDirectory, + processRegistry: processRegistry, + moduleID: moduleID + ) + } + + package func makeRuntime( + for descriptor: LanguageProviderDescriptor, + languageServerLaunch: LanguageServerLaunchDescriptor, + ownerModuleID: ModuleID + ) -> (any LanguageProviderRuntime)? { + StdioLanguageProviderRuntime( + descriptor: descriptor, + runtimeService: runtimeService, + languageServerLaunch: languageServerLaunch, + languageServerCore: languageServerCore, + languageServerExecutableResolver: languageServerExecutableResolver, + languageServerRuntimeResolver: languageServerRuntimeResolver, + languageServerCacheDirectory: languageServerCacheDirectory, + processRegistry: processRegistry, + moduleID: ownerModuleID + ) + } +} diff --git a/Sources/Lithe/Services/StdioLanguageServerSession.swift b/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift similarity index 65% rename from Sources/Lithe/Services/StdioLanguageServerSession.swift rename to Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift index cd2a9417..6d209f62 100644 --- a/Sources/Lithe/Services/StdioLanguageServerSession.swift +++ b/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift @@ -1,53 +1,6 @@ import Foundation - -/// The semantic language-server surface the application depends on. -/// -/// Rust owns the child process, wire framing, protocol correlation, document -/// versions, and deadlines. This protocol -/// exposes only opaque session and operation IDs, so the facade below can be -/// driven by a test double without a real server behind it. -protocol LanguageServerRuntimeCore: Sendable { - func lspStartServer( - providerID: String, - executableURL: URL, - arguments: [String], - environment: [String: String], - rootURL: URL, - workingDirectoryURL: URL, - initializationOptions: ToolingJSONValue?, - runtimeExecutableURL: URL?, - cacheDirectoryURL: URL?, - initializeTimeout: TimeInterval, - requestTimeout: TimeInterval, - shutdownTimeout: TimeInterval - ) -> Result - func lspStopServer(sessionID: String) - func lspSyncDocument( - sessionID: String, - fileURL: URL, - languageID: String, - text: String - ) -> Result - func lspCloseDocument(sessionID: String, fileURL: URL) - func lspRequest( - sessionID: String, - operation: LanguageServerOperation, - fileURL: URL?, - virtualURI: String?, - position: LanguageServerPosition?, - newName: String?, - range: LanguageServerRange?, - diagnostics: [LanguageServerDiagnostic], - completionItem: LanguageServerCompletionItem?, - codeAction: LanguageServerCodeAction?, - command: LanguageServerCommand? - ) -> Result - func lspCancelOperation(sessionID: String, operationID: String) - func lspPollEvents(sessionID: String) -> [RustCoreBridge.LspRuntimeEventPayload] - func lspDestroyServer(sessionID: String) -} - -extension RustCoreBridge: LanguageServerRuntimeCore {} +import LitheCoreContracts +import LitheModuleAPI /// A language-server session projected from the Rust runtime. /// @@ -57,7 +10,7 @@ extension RustCoreBridge: LanguageServerRuntimeCore {} /// is the opaque session ID, the last lifecycle state it observed, and the /// closures waiting on opaque operation IDs. @MainActor -final class StdioLanguageServerSession: LanguageServerSession { +package final class LanguageServerRuntimeSession: LanguageServerSession { /// How often the event queue is drained. Waiting on a completion is worth a /// tighter loop than sitting idle with nothing outstanding. private static let activePollNanoseconds: UInt64 = 10_000_000 @@ -74,7 +27,8 @@ final class StdioLanguageServerSession: LanguageServerSession { private let requestTimeout: TimeInterval private let shutdownTimeout: TimeInterval private let core: any LanguageServerRuntimeCore - private let processRegistry: ManagedProcessRegistry? + private weak var processRegistry: (any LanguageServerProcessRegistry)? + private let moduleID: ModuleID private var sessionID: String? private var pendingOperations: [String: PendingOperation] = [:] @@ -82,15 +36,15 @@ final class StdioLanguageServerSession: LanguageServerSession { private var state: LanguageServerSessionState = .stopped private var processID: Int32? - 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)? + package var onDiagnostics: ((URL, [LanguageServerDiagnostic]) -> Void)? + package var onLog: ((LanguageServerLogLevel, String, String?) -> Void)? + package var onStateChange: ((LanguageServerSessionState) -> Void)? + package private(set) var features: LanguageServerFeatureSet = [] + package var onFeaturesChange: ((LanguageServerFeatureSet) -> Void)? + package private(set) var serverInfo: LanguageServerInfo? + package var onServerInfoChange: ((LanguageServerInfo?) -> Void)? - init( + package init( providerID: String, executableURL: URL, arguments: [String], @@ -101,8 +55,9 @@ final class StdioLanguageServerSession: LanguageServerSession { initializeTimeout: TimeInterval = 60, requestTimeout: TimeInterval = 30, shutdownTimeout: TimeInterval = 2, - core: any LanguageServerRuntimeCore = RustCoreBridge(), - processRegistry: ManagedProcessRegistry? = nil + core: any LanguageServerRuntimeCore, + processRegistry: (any LanguageServerProcessRegistry)? = nil, + moduleID: ModuleID = .languageIntelligence ) { self.providerID = providerID self.executableURL = executableURL @@ -116,11 +71,12 @@ final class StdioLanguageServerSession: LanguageServerSession { self.shutdownTimeout = shutdownTimeout self.core = core self.processRegistry = processRegistry + self.moduleID = moduleID } /// Derived from the last lifecycle state Rust published: there is no local /// process handle to ask. - var isRunning: Bool { + package var isRunning: Bool { guard sessionID != nil else { return false } switch state { case .stopped, .failed: @@ -130,7 +86,7 @@ final class StdioLanguageServerSession: LanguageServerSession { } } - func start(rootURL: URL) throws { + package func start(rootURL: URL) throws { guard sessionID == nil else { return } let normalizedRoot = rootURL.standardizedFileURL transition(to: .startingProcess) @@ -139,7 +95,7 @@ final class StdioLanguageServerSession: LanguageServerSession { "Starting language server", ([executableURL.path] + arguments).joined(separator: " ") ) - switch core.lspStartServer( + switch core.startLanguageServer( providerID: providerID, executableURL: executableURL, arguments: arguments, @@ -154,15 +110,15 @@ final class StdioLanguageServerSession: LanguageServerSession { shutdownTimeout: shutdownTimeout ) { case .success(let payload): - sessionID = payload.sessionId - processID = payload.processId + sessionID = payload.sessionID + processID = payload.processID if let processID { - processRegistry?.register(pid: processID, category: .languageServer) + processRegistry?.registerLanguageServerProcess(pid: processID, moduleID: moduleID) } transition(to: Self.sessionState(payload.state) ?? .initializing) startPolling() case .failure(let error): - let failure = StdioLanguageServerSessionError.startFailed(error.userMessage) + let failure = LanguageServerRuntimeSessionError.startFailed(error.userMessage) let message = failure.localizedDescription transition(to: .failed(exitCode: nil, message: message)) onLog?(.error, "Language server failed to start", message) @@ -170,68 +126,68 @@ final class StdioLanguageServerSession: LanguageServerSession { } } - func synchronize(fileURL: URL, text: String, languageID: String) throws { - guard let sessionID else { throw StdioLanguageServerSessionError.notReady } + package func synchronize(fileURL: URL, text: String, languageID: String) throws { + guard let sessionID else { throw LanguageServerRuntimeSessionError.notReady } // Documents synced before initialize completes are held by the runtime and // opened once the server is ready, so there is nothing to queue here. - if case .failure(let error) = core.lspSyncDocument( + if case .failure(let error) = core.syncLanguageServerDocument( sessionID: sessionID, fileURL: fileURL.standardizedFileURL, languageID: languageID, text: text ) { - throw StdioLanguageServerSessionError.documentSyncFailed(error.userMessage) + throw LanguageServerRuntimeSessionError.documentSyncFailed(error.userMessage) } } - func closeDocument(_ fileURL: URL) { + package func closeDocument(_ fileURL: URL) { guard let sessionID else { return } // The runtime owns which documents are open, so closing one it does not // know about is simply not its business. - core.lspCloseDocument(sessionID: sessionID, fileURL: fileURL.standardizedFileURL) + core.closeLanguageServerDocument(sessionID: sessionID, fileURL: fileURL.standardizedFileURL) } - func completions( + package func completions( fileURL: URL, position: LanguageServerPosition, completion: @escaping (Result<[LanguageServerCompletionItem], Error>) -> Void ) throws { try request(.completion, fileURL: fileURL, position: position) { result in completion(result.flatMap { - Self.decodeEventResult($0, as: RustCoreBridge.BuiltinCompletionPayload.self) + Self.decodeEventResult($0, as: CompletionPayload.self) }.map { $0.makeModels() }) } } - func hover( + package func hover( fileURL: URL, position: LanguageServerPosition, completion: @escaping (Result) -> Void ) throws { try request(.hover, fileURL: fileURL, position: position) { result in completion(result.flatMap { - Self.decodeEventResult($0, as: RustCoreBridge.BuiltinHoverPayload.self) + Self.decodeEventResult($0, as: HoverPayload.self) }.map { $0.hover?.makeModel() }) } } - func navigate( + package func navigate( method: String, fileURL: URL, position: LanguageServerPosition, completion: @escaping (Result<[LanguageServerLocation], Error>) -> Void ) throws { guard let operation = Self.navigationOperation(for: method) else { - throw StdioLanguageServerSessionError.unsupportedNavigation(method) + throw LanguageServerRuntimeSessionError.unsupportedNavigation(method) } try request(operation, fileURL: fileURL, position: position) { result in completion(result.flatMap { - Self.decodeEventResult($0, as: RustCoreBridge.BuiltinNavigationPayload.self) + Self.decodeEventResult($0, as: NavigationPayload.self) }.map { $0.makeModels() }) } } - func rename( + package func rename( fileURL: URL, position: LanguageServerPosition, newName: String, @@ -239,23 +195,23 @@ final class StdioLanguageServerSession: LanguageServerSession { ) throws { try request(.rename, fileURL: fileURL, position: position, newName: newName) { result in completion(result.flatMap { - Self.decodeEventResult($0, as: RustCoreBridge.LspWorkspaceEditPayload.self) + Self.decodeEventResult($0, as: WorkspaceEditPayload.self) }.map { $0.makeModel() }) } } - func format( + package func format( fileURL: URL, completion: @escaping (Result<[LanguageServerTextEdit], Error>) -> Void ) throws { try request(.formatting, fileURL: fileURL) { result in completion(result.flatMap { - Self.decodeEventResult($0, as: RustCoreBridge.LspFormattingPayload.self) + Self.decodeEventResult($0, as: FormattingPayload.self) }.map { $0.makeModels() }) } } - func codeActions( + package func codeActions( fileURL: URL, range: LanguageServerRange, diagnostics: [LanguageServerDiagnostic], @@ -268,36 +224,36 @@ final class StdioLanguageServerSession: LanguageServerSession { diagnostics: diagnostics ) { result in completion(result.flatMap { - Self.decodeEventResult($0, as: RustCoreBridge.LspCodeActionsPayload.self) + Self.decodeEventResult($0, as: CodeActionsPayload.self) }.map { $0.makeModels() }) } } - func resolveCompletion( + package func resolveCompletion( _ item: LanguageServerCompletionItem, fileURL: URL, completion: @escaping (Result) -> Void ) throws { try request(.resolveCompletion, fileURL: fileURL, completionItem: item) { result in completion(result.flatMap { - Self.decodeEventResult($0, as: RustCoreBridge.LspCompletionResolvePayload.self) + Self.decodeEventResult($0, as: CompletionResolvePayload.self) }.map { $0.makeModel() }) } } - func resolveCodeAction( + package func resolveCodeAction( _ action: LanguageServerCodeAction, fileURL: URL, completion: @escaping (Result) -> Void ) throws { try request(.resolveCodeAction, fileURL: fileURL, codeAction: action) { result in completion(result.flatMap { - Self.decodeEventResult($0, as: RustCoreBridge.LspCodeActionResolvePayload.self) + Self.decodeEventResult($0, as: CodeActionResolvePayload.self) }.map { $0.makeModel() }) } } - func execute( + package func execute( _ command: LanguageServerCommand, fileURL: URL, completion: @escaping (Result) -> Void @@ -310,27 +266,27 @@ final class StdioLanguageServerSession: LanguageServerSession { } } - func resolveVirtualDocument( + package func resolveVirtualDocument( uri: String, completion: @escaping (Result) -> Void ) throws { try request(.virtualDocument, fileURL: nil, virtualURI: uri) { result in completion(result.flatMap { - Self.decodeEventResult($0, as: RustCoreBridge.LspVirtualDocumentPayload.self) + Self.decodeEventResult($0, as: VirtualDocumentPayload.self) }.map(\.text)) } } - func stop() { + package func stop() { guard let sessionID else { - failPendingOperations(with: StdioLanguageServerSessionError.sessionStopped) + failPendingOperations(with: LanguageServerRuntimeSessionError.sessionStopped) transition(to: .stopped) return } // The runtime sends the shutdown, force-terminates on its own deadline, // and publishes the terminal transition. The poll loop releases the // session when that arrives, so nothing here waits on the server. - core.lspStopServer(sessionID: sessionID) + core.stopLanguageServer(sessionID: sessionID) if isRunning { transition(to: .stopping) } } @@ -347,12 +303,12 @@ final class StdioLanguageServerSession: LanguageServerSession { completionItem: LanguageServerCompletionItem? = nil, codeAction: LanguageServerCodeAction? = nil, command: LanguageServerCommand? = nil, - completion: @escaping (Result) -> Void + completion: @escaping (Result) -> Void ) throws { guard let sessionID, state == .ready else { - throw StdioLanguageServerSessionError.notReady + throw LanguageServerRuntimeSessionError.notReady } - switch core.lspRequest( + switch core.requestLanguageServerOperation( sessionID: sessionID, operation: operation, fileURL: fileURL?.standardizedFileURL, @@ -366,9 +322,9 @@ final class StdioLanguageServerSession: LanguageServerSession { command: command ) { case .success(let payload): - pendingOperations[payload.operationId] = PendingOperation(completion: completion) + pendingOperations[payload.operationID] = PendingOperation(completion: completion) case .failure(let error): - throw StdioLanguageServerSessionError.requestRejected(error.userMessage) + throw LanguageServerRuntimeSessionError.requestRejected(error.userMessage) } } @@ -382,7 +338,7 @@ final class StdioLanguageServerSession: LanguageServerSession { pollTask = Task { @MainActor [self] in while !Task.isCancelled { guard let sessionID else { return } - let events = core.lspPollEvents(sessionID: sessionID) + let events = core.pollLanguageServerEvents(sessionID: sessionID) var reachedTerminalState = false for event in events where handle(event) { reachedTerminalState = true @@ -404,18 +360,18 @@ final class StdioLanguageServerSession: LanguageServerSession { } /// Applies one runtime event and reports whether it ended the session. - private func handle(_ event: RustCoreBridge.LspRuntimeEventPayload) -> Bool { + private func handle(_ event: LanguageServerRuntimeEvent) -> Bool { switch event.type { case "stateChanged": return handleStateChange(event) case "requestCompleted": - guard let operationID = event.operationId, + guard let operationID = event.operationID, let pending = pendingOperations.removeValue(forKey: operationID) else { return false } if let error = event.error { pending.completion(.failure( - StdioLanguageServerSessionError.serverError(Self.message(for: error)) + LanguageServerRuntimeSessionError.serverError(Self.message(for: error)) )) } else { pending.completion(.success(event)) @@ -425,7 +381,7 @@ final class StdioLanguageServerSession: LanguageServerSession { guard let uri = event.uri, let url = URL(string: uri) else { return false } onDiagnostics?( url.standardizedFileURL, - (event.diagnostics ?? []).map { $0.makeModel() } + event.diagnostics ?? [] ) return false case "featuresChanged": @@ -448,7 +404,7 @@ final class StdioLanguageServerSession: LanguageServerSession { } } - private func handleStateChange(_ event: RustCoreBridge.LspRuntimeEventPayload) -> Bool { + private func handleStateChange(_ event: LanguageServerRuntimeEvent) -> Bool { guard let updated = event.state.flatMap(Self.sessionState) else { return false } switch updated { case .failed: @@ -475,13 +431,13 @@ final class StdioLanguageServerSession: LanguageServerSession { /// Hands the session back to the runtime once it has reached a terminal state. private func releaseSession() { pollTask = nil - failPendingOperations(with: StdioLanguageServerSessionError.sessionStopped) + failPendingOperations(with: LanguageServerRuntimeSessionError.sessionStopped) if let sessionID { - core.lspDestroyServer(sessionID: sessionID) + core.destroyLanguageServer(sessionID: sessionID) } sessionID = nil if let processID { - processRegistry?.unregister(pid: processID, category: .languageServer) + processRegistry?.unregisterLanguageServerProcess(pid: processID, moduleID: moduleID) self.processID = nil } if !features.isEmpty { @@ -543,7 +499,7 @@ final class StdioLanguageServerSession: LanguageServerSession { } private static func failureState( - from event: RustCoreBridge.LspRuntimeEventPayload + from event: LanguageServerRuntimeEvent ) -> LanguageServerSessionState { guard let error = event.error else { return .failed(exitCode: nil, message: event.message) @@ -554,7 +510,7 @@ final class StdioLanguageServerSession: LanguageServerSession { ) } - private static func message(for error: RustCoreBridge.LspRuntimeErrorPayload) -> String { + private static func message(for error: LanguageServerRuntimeError) -> String { var message = error.message if let underlying = error.underlyingMessage, !underlying.isEmpty { message += ": \(underlying)" @@ -574,11 +530,11 @@ final class StdioLanguageServerSession: LanguageServerSession { } private static func decodeEventResult( - _ event: RustCoreBridge.LspRuntimeEventPayload, + _ event: LanguageServerRuntimeEvent, as _: Payload.Type ) -> Result { guard let result = event.result else { - return .failure(StdioLanguageServerSessionError.missingResult) + return .failure(LanguageServerRuntimeSessionError.missingResult) } do { let data = try JSONSerialization.data(withJSONObject: result.foundationObject) @@ -589,10 +545,10 @@ final class StdioLanguageServerSession: LanguageServerSession { } private struct PendingOperation { - let completion: (Result) -> Void + let completion: (Result) -> Void } - private enum StdioLanguageServerSessionError: LocalizedError { + private enum LanguageServerRuntimeSessionError: LocalizedError { case notReady case startFailed(String) case documentSyncFailed(String) @@ -624,3 +580,182 @@ final class StdioLanguageServerSession: LanguageServerSession { } } } + +package typealias StdioLanguageServerSession = LanguageServerRuntimeSession + +private struct PositionPayload: Decodable { + let line: Int + let utf16Column: Int + + func makeModel() -> LanguageServerPosition { + LanguageServerPosition(line: line, utf16Column: utf16Column) + } +} + +private struct RangePayload: Decodable { + let start: PositionPayload + let end: PositionPayload + + func makeModel() -> LanguageServerRange { + LanguageServerRange(start: start.makeModel(), end: end.makeModel()) + } +} + +private struct TextEditPayload: Decodable { + let range: RangePayload + let newText: String + + func makeModel() -> LanguageServerTextEdit { + LanguageServerTextEdit(range: range.makeModel(), newText: newText) + } +} + +private struct CompletionItemPayload: Decodable { + let label: String + let insertText: String + let kind: Int? + let detail: String? + let documentation: String? + let sortText: String? + let filterText: String? + let textEdit: TextEditPayload? + let additionalTextEdits: [TextEditPayload]? + 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 + ) + } +} + +private struct CompletionPayload: Decodable { + let items: [CompletionItemPayload] + func makeModels() -> [LanguageServerCompletionItem] { items.map { $0.makeModel() } } +} + +private struct CompletionResolvePayload: Decodable { + let item: CompletionItemPayload + func makeModel() -> LanguageServerCompletionItem { item.makeModel() } +} + +private struct HoverPayload: Decodable { + struct Hover: Decodable { + let contents: String + let isMarkdown: Bool + let range: RangePayload? + + func makeModel() -> LanguageServerHover { + LanguageServerHover( + contents: contents, + isMarkdown: isMarkdown, + range: range?.makeModel() + ) + } + } + + let hover: Hover? +} + +private struct NavigationPayload: Decodable { + struct Location: Decodable { + let uri: String? + let filePath: String? + let range: RangePayload + let isReadOnly: Bool + let displayPath: String? + + func makeModel() -> LanguageServerLocation? { + let url: URL + if let filePath { + url = URL(fileURLWithPath: filePath) + } else if let uri, let virtualURL = URL(string: uri) { + url = virtualURL + } else { + return nil + } + return LanguageServerLocation( + url: url, + range: range.makeModel(), + isReadOnly: isReadOnly, + displayPath: displayPath + ) + } + } + + let locations: [Location] + func makeModels() -> [LanguageServerLocation] { locations.compactMap { $0.makeModel() } } +} + +private struct VirtualDocumentPayload: Decodable { + let text: String +} + +private struct WorkspaceEditPayload: Decodable { + let changes: [String: [TextEditPayload]] + + func makeModel() -> LanguageServerWorkspaceEdit { + LanguageServerWorkspaceEdit(changes: Dictionary( + uniqueKeysWithValues: changes.map { path, edits in + ( + URL(fileURLWithPath: path).standardizedFileURL, + edits.map { $0.makeModel() } + ) + } + )) + } +} + +private struct FormattingPayload: Decodable { + let edits: [TextEditPayload] + func makeModels() -> [LanguageServerTextEdit] { edits.map { $0.makeModel() } } +} + +private struct CommandPayload: Decodable { + let title: String + let command: String + let arguments: [ToolingJSONValue]? + + func makeModel() -> LanguageServerCommand { + LanguageServerCommand(title: title, command: command, arguments: arguments ?? []) + } +} + +private struct CodeActionPayload: Decodable { + let title: String + let kind: String? + let isPreferred: Bool + let edit: WorkspaceEditPayload? + let command: CommandPayload? + let data: ToolingJSONValue? + + func makeModel() -> LanguageServerCodeAction { + LanguageServerCodeAction( + title: title, + kind: kind, + isPreferred: isPreferred, + edit: edit?.makeModel(), + command: command?.makeModel(), + data: data + ) + } +} + +private struct CodeActionsPayload: Decodable { + let actions: [CodeActionPayload] + func makeModels() -> [LanguageServerCodeAction] { actions.map { $0.makeModel() } } +} + +private struct CodeActionResolvePayload: Decodable { + let action: CodeActionPayload + func makeModel() -> LanguageServerCodeAction { action.makeModel() } +} diff --git a/Sources/Lithe/Services/LanguageServerToolService.swift b/Sources/LitheLanguageIntelligenceModule/Services/LanguageServerToolService.swift similarity index 73% rename from Sources/Lithe/Services/LanguageServerToolService.swift rename to Sources/LitheLanguageIntelligenceModule/Services/LanguageServerToolService.swift index 7b9d6edd..9f040ead 100644 --- a/Sources/Lithe/Services/LanguageServerToolService.swift +++ b/Sources/LitheLanguageIntelligenceModule/Services/LanguageServerToolService.swift @@ -1,10 +1,12 @@ +import Combine import Foundation +import LitheCoreContracts -struct LanguageServerInstallPlan: Equatable, Sendable { - let homebrewFormula: String? - let officialDownloadURL: URL? +package struct LanguageServerInstallPlan: Equatable, Sendable { + package let homebrewFormula: String? + package let officialDownloadURL: URL? - static func plan(for descriptor: LanguageProviderDescriptor) -> Self { + package static func plan(for descriptor: LanguageProviderDescriptor) -> Self { let installation = descriptor.languageServerInstallation return Self( homebrewFormula: installation?.homebrewFormula.flatMap { @@ -30,27 +32,27 @@ struct LanguageServerInstallPlan: Equatable, Sendable { } } -enum LanguageServerInstallationState: Equatable, Sendable { +package enum LanguageServerInstallationState: Equatable, Sendable { case idle case installing case installed(String) case failed(String) } -enum LanguageServerExecutableVerificationState: Equatable, Sendable { +package enum LanguageServerExecutableVerificationState: Equatable, Sendable { case unavailable case foundUnverified case executableVerified } -enum LanguageServerToolConfigurationError: LocalizedError, Equatable { +package enum LanguageServerToolConfigurationError: LocalizedError, Equatable { case executableRequired case executableInvalid(String) case executableValidationFailed(path: String, message: String) case homebrewUnavailable case homebrewUnsupported(String) - var errorDescription: String? { + package var errorDescription: String? { switch self { case .executableRequired: "Choose a language-server executable." @@ -67,45 +69,45 @@ enum LanguageServerToolConfigurationError: LocalizedError, Equatable { } @MainActor -final class LanguageServerToolService: ObservableObject { - @Published private(set) var customExecutablePaths: [String: String] - @Published private(set) var installationStates: [String: LanguageServerInstallationState] = [:] +package final class LanguageServerToolService: ObservableObject { + @Published package private(set) var customExecutablePaths: [String: String] + @Published package private(set) var installationStates: [String: LanguageServerInstallationState] = [:] @Published private var validatedCandidates: [String: [RuntimeToolCandidate]] = [:] - var onCandidatesChanged: ((String) -> Void)? + package var onCandidatesChanged: ((String) -> Void)? - private let runtimeService: ProjectRuntimeService - private let processRunner: any ProcessRunner - private let settingsStore: LanguageServerToolSettingsStore + private let runtimeService: any LanguageToolRuntimePort + private let commandRunner: any LanguageToolCommandRunning + private let settingsStore: any LanguageToolSettingsStoring private var validationCache: [ExecutableValidationKey: ExecutableValidationResult] = [:] - init( - runtimeService: ProjectRuntimeService, - processRunner: any ProcessRunner, - store: any KeyValueStore + package init( + runtimeService: any LanguageToolRuntimePort, + commandRunner: any LanguageToolCommandRunning, + settingsStore: any LanguageToolSettingsStoring ) { self.runtimeService = runtimeService - self.processRunner = processRunner - settingsStore = LanguageServerToolSettingsStore(store: store) - customExecutablePaths = settingsStore.load() + self.commandRunner = commandRunner + self.settingsStore = settingsStore + customExecutablePaths = settingsStore.loadLanguageToolExecutablePaths() } - func installPlan(for descriptor: LanguageProviderDescriptor) -> LanguageServerInstallPlan { + package func installPlan(for descriptor: LanguageProviderDescriptor) -> LanguageServerInstallPlan { LanguageServerInstallPlan.plan(for: descriptor) } - func customExecutablePath(for providerID: String) -> String? { + package func customExecutablePath(for providerID: String) -> String? { customExecutablePaths[providerID] } - func installationState(for providerID: String) -> LanguageServerInstallationState { + package func installationState(for providerID: String) -> LanguageServerInstallationState { installationStates[providerID] ?? .idle } - func isHomebrewAvailable() -> Bool { + package func isHomebrewAvailable() -> Bool { runtimeService.executableOnPath("brew") != nil } - func candidates(for descriptor: LanguageProviderDescriptor) -> [RuntimeToolCandidate] { + package func candidates(for descriptor: LanguageProviderDescriptor) -> [RuntimeToolCandidate] { if let cached = validatedCandidates[descriptor.id] { return cached } @@ -116,7 +118,7 @@ final class LanguageServerToolService: ObservableObject { } @discardableResult - func refreshCandidates( + package func refreshCandidates( for descriptor: LanguageProviderDescriptor ) async -> [RuntimeToolCandidate] { let discovered = discoveredCandidates(for: descriptor) @@ -164,11 +166,11 @@ final class LanguageServerToolService: ObservableObject { return result } - func executableURL(for descriptor: LanguageProviderDescriptor) -> URL? { + package func executableURL(for descriptor: LanguageProviderDescriptor) -> URL? { candidates(for: descriptor).first?.executableURL } - func executableVerificationState( + package func executableVerificationState( for descriptor: LanguageProviderDescriptor ) -> LanguageServerExecutableVerificationState { guard let candidate = candidates(for: descriptor).first else { @@ -183,7 +185,7 @@ final class LanguageServerToolService: ObservableObject { return validationCache[key]?.didExecute == true ? .executableVerified : .unavailable } - func setCustomExecutablePath( + package func setCustomExecutablePath( _ path: String, for descriptor: LanguageProviderDescriptor ) async throws { @@ -212,17 +214,17 @@ final class LanguageServerToolService: ObservableObject { ) } customExecutablePaths[descriptor.id] = executableURL.path - settingsStore.save(customExecutablePaths) + settingsStore.saveLanguageToolExecutablePaths(customExecutablePaths) await refreshCandidates(for: descriptor) } - func clearCustomExecutablePath(for providerID: String) { + package func clearCustomExecutablePath(for providerID: String) { customExecutablePaths[providerID] = nil validatedCandidates[providerID] = nil - settingsStore.save(customExecutablePaths) + settingsStore.saveLanguageToolExecutablePaths(customExecutablePaths) } - func installWithHomebrew(_ descriptor: LanguageProviderDescriptor) async { + package func installWithHomebrew(_ descriptor: LanguageProviderDescriptor) async { let plan = installPlan(for: descriptor) guard let formula = plan.homebrewFormula else { installationStates[descriptor.id] = .failed( @@ -239,16 +241,17 @@ final class LanguageServerToolService: ObservableObject { } 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 runner = commandRunner + let operationID = "lsp-install-\(descriptor.id)-\(UUID().uuidString)" + let environment = runtimeService.languageToolProcessEnvironment() let result = await Task.detached(priority: .userInitiated) { - runner.run(request) + runner.runLanguageToolCommand( + operationID: operationID, + executableURL: brewURL, + arguments: ["install", formula], + environment: environment, + timeoutMilliseconds: 10 * 60 * 1_000 + ) }.value let output = result.output.trimmingCharacters(in: .whitespacesAndNewlines) @@ -280,16 +283,18 @@ final class LanguageServerToolService: ObservableObject { Date().timeIntervalSince(cached.checkedAt) < 30 { return cached } - let request = ProcessRequest( - operationID: "lsp-validate-\(descriptor.id)-\(UUID().uuidString)", - executablePath: key.executablePath, - arguments: arguments, - environment: runtimeService.processEnvironment(), - timeoutMilliseconds: 5_000 - ) - let runner = processRunner + let operationID = "lsp-validate-\(descriptor.id)-\(UUID().uuidString)" + let runner = commandRunner + let executableURL = candidate.executableURL + let environment = runtimeService.languageToolProcessEnvironment() let result = await Task.detached(priority: .userInitiated) { - runner.run(request) + runner.runLanguageToolCommand( + operationID: operationID, + executableURL: executableURL, + arguments: arguments, + environment: environment, + timeoutMilliseconds: 5_000 + ) }.value let output = result.output.trimmingCharacters(in: .whitespacesAndNewlines) let validation = ExecutableValidationResult( @@ -321,25 +326,3 @@ private struct ExecutableValidationResult { 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/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift similarity index 78% rename from Sources/Lithe/Services/LanguageToolingSessionManager.swift rename to Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift index b13e6ad4..65c37f54 100644 --- a/Sources/Lithe/Services/LanguageToolingSessionManager.swift +++ b/Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift @@ -1,12 +1,15 @@ +import Combine import Foundation +import LitheCoreContracts +import LitheModuleAPI -enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { +package enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { case noProvider(fileExtension: String) case providerNotInstalled(String) case toolingUnavailable(String) case capabilityUnavailable(provider: String, capability: String) - var errorDescription: String? { + package var errorDescription: String? { switch self { case .noProvider(let fileExtension): return "No language provider handles .\(fileExtension) files." @@ -23,21 +26,21 @@ enum LanguageToolingSessionError: LocalizedError, Equatable, Sendable { /// 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)? - +package final class LanguageToolingSessionManager: ObservableObject { + @Published package private(set) var diagnostics: [URL: [LanguageServerDiagnostic]] = [:] + @Published package private(set) var languageServerFeatures: [String: LanguageServerFeatureSet] = [:] + @Published package private(set) var languageServerLogs: [LanguageServerLogEntry] = [] + @Published package private(set) var languageServerStates: [String: LanguageServerSessionState] = [:] + @Published package private(set) var languageServerInfos: [String: LanguageServerInfo] = [:] private var catalog: LanguageProviderCatalog + private let extensionRequiredProviderIDs: Set + + package var catalogSnapshot: LanguageProviderCatalog { catalog } private var runtimesByID: [String: any LanguageProviderRuntime] + private var extensionRuntimeIDs: Set = [] + private var extensionProviderIdentities: [String: ObjectIdentifier] = [:] + private var extensionLanguageIdentifiers: [String: String] = [:] + private var extensionLifecycles: [String: WeakLanguageServerExtensionLifecycle] = [:] private let runtimeFactory: (any LanguageProviderRuntimeFactory)? private var languageServers: [String: any LanguageServerSession] = [:] private var languageServerRoots: [String: URL] = [:] @@ -45,39 +48,32 @@ final class LanguageToolingSessionManager: ObservableObject { 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, + package init( + catalog: LanguageProviderCatalog = .compatibilityFallback, runtimes: [any LanguageProviderRuntime] = [], runtimeFactory: (any LanguageProviderRuntimeFactory)? = nil, - core: RustCoreBridge = RustCoreBridge(), - languageFeatureProviders: [any LanguageFeatureProvider] = [] + builtinCore: (any BuiltinLanguageFeatureCore)? = nil, + languageFeatureProviders: [any LanguageFeatureProvider] = [], + extensionRequiredProviderIDs: Set = [] ) { self.catalog = catalog self.runtimeFactory = runtimeFactory + self.extensionRequiredProviderIDs = extensionRequiredProviderIDs self.languageFeatureProviders = languageFeatureProviders + [ - BuiltinLanguageFeatureProvider(core: core) + BuiltinLanguageFeatureProvider(core: builtinCore ?? UnavailableBuiltinLanguageFeatureCore()) ] runtimesByID = Dictionary(uniqueKeysWithValues: runtimes.map { ($0.descriptor.id, $0) }) } - convenience init(registry: LanguagePackRegistry) { - self.init(catalog: registry.catalog, runtimes: registry.toolingRuntimes) - } - - var activeLanguageServerIDs: Set { + package 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) { + package func updateCatalog(_ catalog: LanguageProviderCatalog) { let previousDescriptors = Dictionary( uniqueKeysWithValues: self.catalog.descriptors.map { ($0.id, $0) } ) @@ -100,31 +96,88 @@ final class LanguageToolingSessionManager: ObservableObject { 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 { + if runtimeFactory != nil, !extensionRuntimeIDs.contains(providerID) { runtimesByID[providerID] = nil } } } - func provider(for fileURL: URL) -> LanguageProviderDescriptor? { - catalog.provider(for: fileURL) + @discardableResult + package func registerLanguageServerExtension( + _ provider: any LanguageServerExtensionProviding, + support: LanguageSupportDeclaration + ) -> Bool { + let configuration = provider.configuration + guard configuration.languageID == support.id, + let ownerModuleID = support.languageServerModuleID, + !configuration.executableNames.isEmpty, + let runtimeFactory else { return false } + let providerIdentity = ObjectIdentifier(provider) + if extensionProviderIdentities[support.id] == providerIdentity, + runtimesByID[support.id] != nil { + return true + } + + let base = catalog.descriptors.first { $0.id == support.id } + let launch = LanguageServerLaunchDescriptor( + executableNames: configuration.executableNames, + arguments: configuration.arguments, + validationArguments: configuration.validationArguments, + environment: configuration.environment + ) + let descriptor = LanguageProviderDescriptor( + id: support.id, + displayName: configuration.displayName, + fileExtensions: Set(support.fileExtensions).union(base?.fileExtensions ?? []), + fileNames: Set(support.fileNames).union(base?.fileNames ?? []), + fileNamePrefixes: base?.fileNamePrefixes ?? [], + capabilities: (base?.capabilities ?? []).union(.languageServer), + activationPolicy: base?.activationPolicy ?? .onDemand, + languageIdentifier: configuration.languageIdentifier, + languageIdentifiersByExtension: base?.languageIdentifiersByExtension ?? [:], + languageIdentifiersByFileName: base?.languageIdentifiersByFileName ?? [:], + languageServerLaunch: launch, + languageServerInstallation: base?.languageServerInstallation + ) + guard let runtime = runtimeFactory.makeRuntime( + for: descriptor, + languageServerLaunch: launch, + ownerModuleID: ownerModuleID + ) else { return false } + + stopLanguageServer(providerID: support.id) + runtimesByID[support.id] = runtime + extensionRuntimeIDs.insert(support.id) + extensionProviderIdentities[support.id] = providerIdentity + extensionLanguageIdentifiers[support.id] = configuration.languageIdentifier + extensionLifecycles[support.id] = WeakLanguageServerExtensionLifecycle( + provider.lifecycle + ) + return true } - func supportsGenericEditing(for fileURL: URL) -> Bool { - return !features(for: fileURL).isEmpty + package func unregisterLanguageServerExtension(languageID: String) { + guard extensionRuntimeIDs.contains(languageID) else { return } + stopLanguageServer(providerID: languageID) + runtimesByID[languageID] = nil + extensionRuntimeIDs.remove(languageID) + extensionProviderIdentities[languageID] = nil + extensionLanguageIdentifiers[languageID] = nil + extensionLifecycles[languageID] = nil } - func supportsGenericDebugging(for fileURL: URL) -> Bool { - guard let descriptor = catalog.provider(for: fileURL), - descriptor.capabilities.contains(.debugAdapter) else { return false } - return runtime(for: descriptor)?.supportsDebugAdapterSession == true + package func provider(for fileURL: URL) -> LanguageProviderDescriptor? { + catalog.provider(for: fileURL) } - func features(for fileURL: URL) -> LanguageServerFeatureSet { + package func supportsGenericEditing(for fileURL: URL) -> Bool { + return !features(for: fileURL).isEmpty + } + + package func features(for fileURL: URL) -> LanguageServerFeatureSet { let context = featureContext( fileURL: fileURL, text: "", @@ -150,7 +203,7 @@ final class LanguageToolingSessionManager: ObservableObject { return result } - func synchronizeLanguageServer( + package func synchronizeLanguageServer( for fileURL: URL, text: String, rootURL: URL @@ -210,6 +263,12 @@ final class LanguageToolingSessionManager: ObservableObject { providerID: descriptor.id, sessionIdentity: sessionIdentity ) + extensionLifecycles[descriptor.id]?.value?.attach( + isRunning: { [weak created] in created?.isRunning ?? false }, + stop: { [weak self] in + self?.stopLanguageServer(providerID: descriptor.id) + } + ) do { try created.start(rootURL: normalizedRoot) } catch { @@ -243,35 +302,36 @@ final class LanguageToolingSessionManager: ObservableObject { try session.synchronize( fileURL: fileURL, text: text, - languageID: descriptor.languageIdentifier(for: fileURL) + languageID: extensionLanguageIdentifiers[descriptor.id] + ?? descriptor.languageIdentifier(for: fileURL) ) } - func closeDocument(_ fileURL: URL) { + package func closeDocument(_ fileURL: URL) { let standardizedURL = fileURL.standardizedFileURL clearDiagnostics(for: standardizedURL) languageServerSession(for: standardizedURL)?.closeDocument(standardizedURL) } - func clearDiagnostics() { + package func clearDiagnostics() { diagnosticsByProviderID = [:] diagnostics = [:] } - func diagnostics(for providerID: String) -> [URL: [LanguageServerDiagnostic]] { + package func diagnostics(for providerID: String) -> [URL: [LanguageServerDiagnostic]] { diagnosticsByProviderID[providerID] ?? [:] } - func clearDiagnostics(providerID: String) { + package func clearDiagnostics(providerID: String) { guard diagnosticsByProviderID.removeValue(forKey: providerID) != nil else { return } rebuildDiagnostics() } - func clearLanguageServerLogs() { + package func clearLanguageServerLogs() { languageServerLogs = [] } - func recordLanguageServerLog( + package func recordLanguageServerLog( providerID: String, level: LanguageServerLogLevel, message: String, @@ -288,7 +348,7 @@ final class LanguageToolingSessionManager: ObservableObject { } } - func stopLanguageServer(providerID: String) { + package func stopLanguageServer(providerID: String) { if languageServers[providerID] != nil { recordLanguageServerLog( providerID: providerID, @@ -307,7 +367,7 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerStates[providerID] = .stopped } - func stopAllLanguageServers() { + package func stopAllLanguageServers() { for providerID in languageServers.keys { recordLanguageServerLog( providerID: providerID, @@ -328,7 +388,7 @@ final class LanguageToolingSessionManager: ObservableObject { for session in sessions { session.stop() } } - func navigate( + package func navigate( method: String, fileURL: URL, text: String, @@ -358,7 +418,7 @@ final class LanguageToolingSessionManager: ObservableObject { ) } - func hover( + package func hover( fileURL: URL, text: String, position: LanguageServerPosition, @@ -383,7 +443,7 @@ final class LanguageToolingSessionManager: ObservableObject { ) } - func completions( + package func completions( fileURL: URL, text: String, position: LanguageServerPosition, @@ -410,7 +470,7 @@ final class LanguageToolingSessionManager: ObservableObject { ) } - func rename( + package func rename( fileURL: URL, text _: String, position: LanguageServerPosition, @@ -430,7 +490,7 @@ final class LanguageToolingSessionManager: ObservableObject { throw unavailableLanguageServerError(for: fileURL) } - func format( + package func format( fileURL: URL, text _: String, rootURL _: URL, @@ -450,7 +510,7 @@ final class LanguageToolingSessionManager: ObservableObject { throw unavailableLanguageServerError(for: fileURL) } - func codeActions( + package func codeActions( fileURL: URL, text _: String, range: LanguageServerRange, @@ -470,7 +530,7 @@ final class LanguageToolingSessionManager: ObservableObject { throw unavailableLanguageServerError(for: fileURL) } - func execute( + package func execute( _ command: LanguageServerCommand, fileURL: URL, text _: String, @@ -490,7 +550,7 @@ final class LanguageToolingSessionManager: ObservableObject { throw unavailableLanguageServerError(for: fileURL) } - func resolveVirtualDocument( + package func resolveVirtualDocument( providerID: String, uri: URL, completion: @escaping (Result) -> Void @@ -510,7 +570,7 @@ final class LanguageToolingSessionManager: ObservableObject { try session.resolveVirtualDocument(uri: uri.absoluteString, completion: completion) } - func resolveCompletion( + package func resolveCompletion( _ item: LanguageServerCompletionItem, fileURL: URL, text _: String, @@ -530,7 +590,7 @@ final class LanguageToolingSessionManager: ObservableObject { throw unavailableLanguageServerError(for: fileURL) } - func resolveCodeAction( + package func resolveCodeAction( _ action: LanguageServerCodeAction, fileURL: URL, text _: String, @@ -550,50 +610,15 @@ final class LanguageToolingSessionManager: ObservableObject { throw unavailableLanguageServerError(for: fileURL) } - @discardableResult - func activateDebugAdapter(for fileURL: URL, rootURL: URL) throws -> any DebugAdapterSession { - guard let descriptor = catalog.provider(for: fileURL) else { - throw LanguageToolingSessionError.noProvider(fileExtension: fileURL.pathExtension.lowercased()) - } - guard descriptor.capabilities.contains(.debugAdapter) else { - throw LanguageToolingSessionError.capabilityUnavailable( - provider: descriptor.displayName, - capability: "debug adapter" - ) - } - let normalizedRoot = rootURL.standardizedFileURL - if let active = debugAdapters[descriptor.id] { - if active.isRunning, debugAdapterRoots[descriptor.id] == normalizedRoot { - return active - } - active.stop() - debugAdapters[descriptor.id] = nil - debugAdapterRoots[descriptor.id] = nil - } - guard let runtime = runtime(for: descriptor) else { - throw LanguageToolingSessionError.providerNotInstalled(descriptor.displayName) - } - guard let session = runtime.makeDebugAdapterSession(rootURL: normalizedRoot) else { - throw LanguageToolingSessionError.toolingUnavailable( - runtime.unavailableToolingMessage ?? descriptor.displayName - ) - } - configureDebugCallbacks(session, providerID: descriptor.id) - try session.start(rootURL: normalizedRoot) - debugAdapters[descriptor.id] = session - debugAdapterRoots[descriptor.id] = normalizedRoot - debugStates[descriptor.id] = session.state - if let controlling = session as? any DebugAdapterControllingSession { - for (source, breakpoints) in requestedBreakpoints[descriptor.id] ?? [:] { - controlling.setBreakpoints(breakpoints, in: source) - } - } - return session - } - private func runtime( for descriptor: LanguageProviderDescriptor ) -> (any LanguageProviderRuntime)? { + if extensionRuntimeIDs.contains(descriptor.id) { + return runtimesByID[descriptor.id] + } + if extensionRequiredProviderIDs.contains(descriptor.id) { + return nil + } if let existing = runtimesByID[descriptor.id], existing.descriptor == descriptor { return existing @@ -606,15 +631,8 @@ final class LanguageToolingSessionManager: ObservableObject { return runtime } - func stopDebugAdapter(providerID: String) { - debugAdapters.removeValue(forKey: providerID)?.stop() - debugAdapterRoots[providerID] = nil - debugStates[providerID] = .idle - } - - func stopAll() { + package func stopAll() { let languageServerSessions = Array(languageServers.values) - for session in debugAdapters.values { session.stop() } clearDiagnostics() languageServerFeatures = [:] languageServerInfos = [:] @@ -624,56 +642,6 @@ final class LanguageToolingSessionManager: ObservableObject { languageServerFeatureProviders.removeAll() languageServerStates = [:] for session in languageServerSessions { session.stop() } - debugAdapters.removeAll() - debugAdapterRoots.removeAll() - debugStates = [:] - lastDebugEvents = [:] - verifiedBreakpoints = [:] - requestedBreakpoints = [:] - } - - @discardableResult - func launchDebugAdapter( - for fileURL: URL, - rootURL: URL, - configuration: DebugLaunchConfiguration - ) throws -> any DebugAdapterControllingSession { - let session = try activateDebugAdapter(for: fileURL, rootURL: rootURL) - guard let controlling = session as? any DebugAdapterControllingSession else { - let descriptor = catalog.provider(for: fileURL) - throw LanguageToolingSessionError.capabilityUnavailable( - provider: descriptor?.displayName ?? fileURL.pathExtension, - capability: "DAP launch control" - ) - } - try controlling.launch(configuration) - return controlling - } - - func setDebugBreakpoints( - _ breakpoints: [DebugSourceBreakpoint], - in fileURL: URL - ) throws { - guard let descriptor = catalog.provider(for: fileURL) else { - throw LanguageToolingSessionError.noProvider( - fileExtension: fileURL.pathExtension.lowercased() - ) - } - guard descriptor.capabilities.contains(.debugAdapter) else { - throw LanguageToolingSessionError.capabilityUnavailable( - provider: descriptor.displayName, - capability: "debug adapter breakpoints" - ) - } - var providerBreakpoints = requestedBreakpoints[descriptor.id] ?? [:] - providerBreakpoints[fileURL.standardizedFileURL] = breakpoints - requestedBreakpoints[descriptor.id] = providerBreakpoints - (debugAdapters[descriptor.id] as? any DebugAdapterControllingSession)? - .setBreakpoints(breakpoints, in: fileURL) - } - - func debugSession(providerID: String) -> (any DebugAdapterControllingSession)? { - debugAdapters[providerID] as? any DebugAdapterControllingSession } private func unavailableLanguageServerError(for fileURL: URL) -> LanguageToolingSessionError { @@ -972,30 +940,13 @@ final class LanguageToolingSessionManager: ObservableObject { diagnostics = flattened } - private func configureDebugCallbacks( - _ session: any DebugAdapterSession, - providerID: String - ) { - guard let controlling = session as? any DebugAdapterControllingSession else { return } - controlling.onStateChange = { [weak self] state in - self?.debugStates[providerID] = state - self?.onDebugStateChange?(providerID, state) - } - controlling.onEvent = { [weak self] event in - guard let self else { return } - self.lastDebugEvents[providerID] = event - self.onDebugEvent?(providerID, event) - if case .breakpoint(let breakpoint) = event { - var values = self.verifiedBreakpoints[providerID] ?? [] - if let index = values.firstIndex(where: { $0.id == breakpoint.id }) { - values[index] = breakpoint - } else { - values.append(breakpoint) - } - self.verifiedBreakpoints[providerID] = values.sorted { - ($0.sourceURL?.path ?? "", $0.line ?? 0) < ($1.sourceURL?.path ?? "", $1.line ?? 0) - } - } - } +} + +@MainActor +private final class WeakLanguageServerExtensionLifecycle { + weak var value: (any LanguageServerExtensionLifecycle)? + + init(_ value: any LanguageServerExtensionLifecycle) { + self.value = value } } diff --git a/Sources/Lithe/Application/ProjectHistoryFeatureModel.swift b/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift similarity index 72% rename from Sources/Lithe/Application/ProjectHistoryFeatureModel.swift rename to Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift index b4b1770b..d04bf08d 100644 --- a/Sources/Lithe/Application/ProjectHistoryFeatureModel.swift +++ b/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift @@ -4,74 +4,84 @@ import Foundation /// Owns local-history state and the shared restore/diff workflow. /// Platform composition only supplies storage and workspace operation ports. @MainActor -final class ProjectHistoryFeatureModel: ObservableObject { - struct Restoration: Sendable { - let url: URL - let documentID: UUID? +public final class ProjectHistoryFeatureModel: ObservableObject { + public struct Restoration: Sendable { + public let url: URL + public let documentID: UUID? } - @Published var localHistoryRequest: LocalHistoryRequest? - @Published private(set) var localHistoryEntries: [LocalHistoryEntry] = [] - @Published var selectedLocalHistoryEntry: LocalHistoryEntry? - @Published private(set) var localHistoryDiffRows: [DiffRow] = [] - @Published private(set) var isLoadingLocalHistory = false - - @Published var projectLocalHistoryRequest: ProjectLocalHistoryRequest? - @Published private(set) var projectLocalHistoryEntries: [LocalHistoryEntry] = [] - @Published var selectedProjectLocalHistoryEntry: LocalHistoryEntry? - @Published private(set) var projectLocalHistoryDiffRows: [DiffRow] = [] - @Published private(set) var isLoadingProjectLocalHistory = false - - private let workspaceOperations: any WorkspaceOperations - private let fileOperations: any WorkspaceFileOperations - private let fileStorage: any FileStorage + @Published public var localHistoryRequest: LocalHistoryRequest? + @Published public private(set) var localHistoryEntries: [LocalHistoryEntry] = [] + @Published public var selectedLocalHistoryEntry: LocalHistoryEntry? + @Published public private(set) var localHistoryDiffRows: [LocalHistoryDiffRow] = [] + @Published public private(set) var isLoadingLocalHistory = false + + @Published public var projectLocalHistoryRequest: ProjectLocalHistoryRequest? + @Published public private(set) var projectLocalHistoryEntries: [LocalHistoryEntry] = [] + @Published public var selectedProjectLocalHistoryEntry: LocalHistoryEntry? + @Published public private(set) var projectLocalHistoryDiffRows: [LocalHistoryDiffRow] = [] + @Published public private(set) var isLoadingProjectLocalHistory = false + + private let workspaceAccess: any LocalHistoryWorkspaceAccess + private let storage: any LocalHistoryStorage private let localHistoryOperations: any LocalHistoryOperations private var localHistoryService: LocalHistoryService? private var seedTask: Task? + private var localHistoryTask: Task? + private var projectHistoryTask: Task? + private var backgroundTasks: [UUID: Task] = [:] private var workspaceURLProvider: () -> URL? private var projectFilesProvider: () -> [URL] - private var documentsProvider: () -> [EditorDocument] + private var documentsProvider: () -> [LocalHistoryDocumentSnapshot] + + public var hasActiveModuleWork: Bool { + isLoadingLocalHistory || isLoadingProjectLocalHistory || seedTask != nil || !backgroundTasks.isEmpty + } - init( - workspaceOperations: any WorkspaceOperations, - fileOperations: any WorkspaceFileOperations, - fileStorage: any FileStorage, + public init( + workspaceAccess: any LocalHistoryWorkspaceAccess, + storage: any LocalHistoryStorage, localHistoryOperations: any LocalHistoryOperations, workspaceURLProvider: @escaping () -> URL? = { nil }, projectFilesProvider: @escaping () -> [URL] = { [] }, - documentsProvider: @escaping () -> [EditorDocument] = { [] } + documentsProvider: @escaping () -> [LocalHistoryDocumentSnapshot] = { [] } ) { - self.workspaceOperations = workspaceOperations - self.fileOperations = fileOperations - self.fileStorage = fileStorage + self.workspaceAccess = workspaceAccess + self.storage = storage self.localHistoryOperations = localHistoryOperations self.workspaceURLProvider = workspaceURLProvider self.projectFilesProvider = projectFilesProvider self.documentsProvider = documentsProvider } - func configure( + public func configure( workspaceURLProvider: @escaping () -> URL?, projectFilesProvider: @escaping () -> [URL], - documentsProvider: @escaping () -> [EditorDocument] + documentsProvider: @escaping () -> [LocalHistoryDocumentSnapshot] ) { self.workspaceURLProvider = workspaceURLProvider self.projectFilesProvider = projectFilesProvider self.documentsProvider = documentsProvider } - func openWorkspace(at workspaceURL: URL, visibilityRules: FileVisibilityRules) { + public func openWorkspace(at workspaceURL: URL, visibilityRules: LocalHistoryVisibilityRules) { localHistoryService = LocalHistoryService( workspaceURL: workspaceURL, visibilityRules: visibilityRules, - storage: fileStorage, + storage: storage, operations: localHistoryOperations ) } - func reset() { + public func reset() { seedTask?.cancel() seedTask = nil + localHistoryTask?.cancel() + localHistoryTask = nil + projectHistoryTask?.cancel() + projectHistoryTask = nil + backgroundTasks.values.forEach { $0.cancel() } + backgroundTasks.removeAll() localHistoryService = nil localHistoryRequest = nil localHistoryEntries = [] @@ -85,38 +95,40 @@ final class ProjectHistoryFeatureModel: ObservableObject { isLoadingProjectLocalHistory = false } - func updateVisibilityRules(_ rules: FileVisibilityRules) async { + public func updateVisibilityRules(_ rules: LocalHistoryVisibilityRules) async { await localHistoryService?.updateVisibilityRules(rules) } - func seed(files: [URL]) { + public func seed(files: [URL]) { seedTask?.cancel() guard let localHistoryService else { return } - seedTask = Task(priority: .utility) { + seedTask = Task { [weak self] in await localHistoryService.seed(files: files) + guard !Task.isCancelled else { return } + self?.seedTask = nil } } - func recordSave(_ document: EditorDocument, previousText: String) { + public func recordSave(_ document: LocalHistoryDocumentSnapshot, previousText: String) { guard let localHistoryService else { return } let currentText = document.text let url = document.url - Task(priority: .utility) { + startBackgroundTask { _ = try? await localHistoryService.record(text: previousText, for: url, reason: .saved) _ = try? await localHistoryService.record(text: currentText, for: url, reason: .saved) } } - func recordDiscardedEditorText(_ document: EditorDocument) { + public func recordDiscardedEditorText(_ document: LocalHistoryDocumentSnapshot) { guard let localHistoryService else { return } let text = document.text let url = document.url - Task(priority: .utility) { + startBackgroundTask { _ = try? await localHistoryService.record(text: text, for: url, reason: .unsavedDiscard) } } - func recordHistorySnapshot( + public func recordHistorySnapshot( text: String, for fileURL: URL, reason: LocalHistoryReason @@ -124,7 +136,7 @@ final class ProjectHistoryFeatureModel: ObservableObject { _ = try? await localHistoryService?.record(text: text, for: fileURL, reason: reason) } - func recordHistory(containedIn url: URL, reason: LocalHistoryReason) async { + public func recordHistory(containedIn url: URL, reason: LocalHistoryReason) async { guard let localHistoryService else { return } let files: [URL] if projectFilesProvider().contains(where: { $0.standardizedFileURL == url.standardizedFileURL }) { @@ -137,55 +149,69 @@ final class ProjectHistoryFeatureModel: ObservableObject { } } - func relocateHistory(from sourceURL: URL, to destinationURL: URL) async { + public func relocateHistory(from sourceURL: URL, to destinationURL: URL) async { try? await localHistoryService?.relocateHistory(from: sourceURL, to: destinationURL) } - func recordExternalChanges(_ paths: [URL]) { + public func recordExternalChanges(_ paths: [URL]) { guard let localHistoryService else { return } - let changedFiles = paths.filter { fileOperations.fileExists(at: $0) } - Task(priority: .utility) { + let changedFiles = paths.filter { workspaceAccess.fileExists(at: $0) } + startBackgroundTask { for fileURL in changedFiles { _ = try? await localHistoryService.recordFile(at: fileURL, reason: .externalChange) } } } - func showLocalHistory(for fileURL: URL) { + private func startBackgroundTask( + operation: @escaping @MainActor @Sendable () async -> Void + ) { + let id = UUID() + backgroundTasks[id] = Task(priority: .utility) { [weak self] in + await operation() + self?.backgroundTasks[id] = nil + } + } + + public func showLocalHistory(for fileURL: URL) { guard isWorkspaceURL(fileURL) else { return } localHistoryRequest = LocalHistoryRequest(fileURL: fileURL.standardizedFileURL) localHistoryEntries = [] selectedLocalHistoryEntry = nil localHistoryDiffRows = [] isLoadingLocalHistory = true - Task { await reloadLocalHistory() } + localHistoryTask?.cancel() + localHistoryTask = Task { [weak self] in await self?.reloadLocalHistory() } } - func showProjectLocalHistory() { + public func showProjectLocalHistory() { guard workspaceURLProvider() != nil else { return } projectLocalHistoryRequest = ProjectLocalHistoryRequest() projectLocalHistoryEntries = [] selectedProjectLocalHistoryEntry = nil projectLocalHistoryDiffRows = [] isLoadingProjectLocalHistory = true - Task { await reloadProjectLocalHistory() } + projectHistoryTask?.cancel() + projectHistoryTask = Task { [weak self] in await self?.reloadProjectLocalHistory() } } - func selectLocalHistoryEntry(_ entry: LocalHistoryEntry) { + public func selectLocalHistoryEntry(_ entry: LocalHistoryEntry) { selectedLocalHistoryEntry = entry localHistoryDiffRows = [] isLoadingLocalHistory = true - Task { await loadLocalHistoryDiff(for: entry) } + localHistoryTask?.cancel() + localHistoryTask = Task { [weak self] in await self?.loadLocalHistoryDiff(for: entry) } } - func selectProjectLocalHistoryEntry(_ entry: LocalHistoryEntry) { + public func selectProjectLocalHistoryEntry(_ entry: LocalHistoryEntry) { selectedProjectLocalHistoryEntry = entry projectLocalHistoryDiffRows = [] isLoadingProjectLocalHistory = true - Task { await loadProjectLocalHistoryDiff(for: entry) } + projectHistoryTask?.cancel() + projectHistoryTask = Task { [weak self] in await self?.loadProjectLocalHistoryDiff(for: entry) } } - func refreshLocalHistory() async { + public func refreshLocalHistory() async { isLoadingLocalHistory = true await reloadLocalHistory() if let selectedLocalHistoryEntry { @@ -193,7 +219,7 @@ final class ProjectHistoryFeatureModel: ObservableObject { } } - func refreshProjectLocalHistory() async { + public func refreshProjectLocalHistory() async { isLoadingProjectLocalHistory = true await reloadProjectLocalHistory() if let selectedProjectLocalHistoryEntry { @@ -201,7 +227,7 @@ final class ProjectHistoryFeatureModel: ObservableObject { } } - func restoreSelectedLocalHistoryEntry() async -> Restoration? { + public func restoreSelectedLocalHistoryEntry() async -> Restoration? { guard let request = localHistoryRequest, let entry = selectedLocalHistoryEntry, let localHistoryService, @@ -219,19 +245,18 @@ final class ProjectHistoryFeatureModel: ObservableObject { } else { _ = try? await localHistoryService.recordFile(at: request.fileURL, reason: .restored) } - guard workspaceOperations.writeFile( + guard workspaceAccess.writeFile( restoredText, at: workspaceURL, relativePath: relativePath ) else { return nil } - try document?.reloadFromDisk() return Restoration(url: request.fileURL, documentID: document?.id) } catch { return nil } } - func restoreSelectedProjectLocalHistoryEntry() async -> Restoration? { + public func restoreSelectedProjectLocalHistoryEntry() async -> Restoration? { guard let entry = selectedProjectLocalHistoryEntry, let workspaceURL = workspaceURLProvider(), let localHistoryService else { return nil } @@ -249,15 +274,14 @@ final class ProjectHistoryFeatureModel: ObservableObject { for: targetURL, reason: .restored ) - } else if fileOperations.fileExists(at: targetURL) { + } else if workspaceAccess.fileExists(at: targetURL) { _ = try? await localHistoryService.recordFile(at: targetURL, reason: .restored) } - guard workspaceOperations.writeFile( + guard workspaceAccess.writeFile( restoredText, at: workspaceURL, relativePath: relativePath ) else { return nil } - try document?.reloadFromDisk() return Restoration(url: targetURL, documentID: document?.id) } catch { return nil @@ -351,7 +375,7 @@ final class ProjectHistoryFeatureModel: ObservableObject { } guard let workspaceURL = workspaceURLProvider(), let relativePath = workspaceRelativePath(for: url, root: workspaceURL), - let text = workspaceOperations.readFile(at: workspaceURL, relativePath: relativePath) else { + let text = workspaceAccess.readFile(at: workspaceURL, relativePath: relativePath) else { throw NSError(domain: "LitheWorkspace", code: 4) } return text diff --git a/Sources/LitheLocalHistoryModule/Models/LocalHistoryDiffModels.swift b/Sources/LitheLocalHistoryModule/Models/LocalHistoryDiffModels.swift new file mode 100644 index 00000000..299c3a74 --- /dev/null +++ b/Sources/LitheLocalHistoryModule/Models/LocalHistoryDiffModels.swift @@ -0,0 +1,82 @@ +import Foundation + +public enum LocalHistoryDiffRowKind: Sendable, Equatable { + case context + case changed + case addition + case removal +} + +public struct LocalHistoryDiffRow: Identifiable, Sendable { + public let id: String + public let oldLine: Int? + public let newLine: Int? + public let left: String? + public let rightText: String? + public let kind: LocalHistoryDiffRowKind + public let sequence: Int + + public init(oldLine: Int?, newLine: Int?, left: String?, right: String?, kind: LocalHistoryDiffRowKind, sequence: Int) { + self.id = "\(oldLine ?? 0):\(newLine ?? 0):\(sequence)" + self.oldLine = oldLine + self.newLine = newLine + self.left = left + self.rightText = kind == .context ? (right ?? left) : right + self.kind = kind + self.sequence = sequence + } +} + +enum LocalHistoryDiffPairing { + static let maximumAlignmentCells = 4_096 + static let minimumPairSimilarity = 0.5 + + static func similarity(_ left: String, _ right: String) -> Double { + let left = left.trimmingCharacters(in: .whitespaces) + let right = right.trimmingCharacters(in: .whitespaces) + if left == right { return 1 } + if left.isEmpty || right.isEmpty { return 0 } + func bigrams(_ text: String) -> [String] { + let characters = Array(text) + guard characters.count >= 2 else { return [String(repeating: String(characters[0]), count: 2)] } + return (0..<(characters.count - 1)).map { String(characters[$0...($0 + 1)]) } + } + let leftBigrams = bigrams(left) + var rightBigrams = bigrams(right) + var shared = 0 + for bigram in leftBigrams { + if let index = rightBigrams.firstIndex(of: bigram) { + rightBigrams.remove(at: index) + shared += 1 + } + } + return Double(2 * shared) / Double(leftBigrams.count + bigrams(right).count) + } + + static func pairs(removed: [String], added: [String]) -> [(Int?, Int?)] { + let rows = removed.count, columns = added.count + if rows == 1, columns == 1 { return [(0, 0)] } + if rows == 0 || columns == 0 || rows * columns > maximumAlignmentCells { + return (0..= minimumPairSimilarity ? value + score[i + 1][j + 1] : -Double.infinity + score[i][j] = max(paired, score[i + 1][j], score[i][j + 1]) + } + } + var result: [(Int?, Int?)] = [], i = 0, j = 0 + while i < rows, j < columns { + let value = similarity(removed[i], added[j]) + let paired = value >= minimumPairSimilarity ? value + score[i + 1][j + 1] : -Double.infinity + if paired >= score[i + 1][j], paired >= score[i][j + 1] { result.append((i, j)); i += 1; j += 1 } + else if score[i + 1][j] >= score[i][j + 1] { result.append((i, nil)); i += 1 } + else { result.append((nil, j)); j += 1 } + } + while i < rows { result.append((i, nil)); i += 1 } + while j < columns { result.append((nil, j)); j += 1 } + return result + } +} diff --git a/Sources/Lithe/Models/LocalHistoryModels.swift b/Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift similarity index 70% rename from Sources/Lithe/Models/LocalHistoryModels.swift rename to Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift index 53ec7484..f60018fc 100644 --- a/Sources/Lithe/Models/LocalHistoryModels.swift +++ b/Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift @@ -1,49 +1,39 @@ import Foundation +import LitheCoreContracts -struct LocalHistoryEntry: Identifiable, Codable, Hashable, Sendable { - let id: UUID - let timestamp: Date - let relativePath: String - let reason: LocalHistoryReason - let contentURL: URL - let byteCount: Int -} - -enum LocalHistoryReason: String, Codable, Sendable { - case projectBaseline - case saved - case externalChange - case beforeRename - case beforeDelete - case beforeBatchReplace - case unsavedDiscard - case restored +public struct LocalHistoryEntry: Identifiable, Codable, Hashable, Sendable { + public let id: UUID + public let timestamp: Date + public let relativePath: String + public let reason: LocalHistoryReason + public let contentURL: URL + public let byteCount: Int - var title: String { - switch self { - case .projectBaseline: "Project opened" - case .saved: "File saved" - case .externalChange: "External change" - case .beforeRename: "Before rename" - case .beforeDelete: "Before deletion" - case .beforeBatchReplace: "Before project replacement" - case .unsavedDiscard: "Discarded editor changes" - case .restored: "Before restore" - } + public init(id: UUID, timestamp: Date, relativePath: String, reason: LocalHistoryReason, contentURL: URL, byteCount: Int) { + self.id = id + self.timestamp = timestamp + self.relativePath = relativePath + self.reason = reason + self.contentURL = contentURL + self.byteCount = byteCount } } -struct LocalHistoryRequest: Identifiable { - let id = UUID() - let fileURL: URL +public typealias LocalHistoryReason = LitheCoreContracts.LocalHistoryReason + +public struct LocalHistoryRequest: Identifiable { + public let id = UUID() + public let fileURL: URL + public init(fileURL: URL) { self.fileURL = fileURL } } -struct ProjectLocalHistoryRequest: Identifiable { - let id = UUID() +public struct ProjectLocalHistoryRequest: Identifiable { + public let id = UUID() + public init() {} } -enum LocalHistoryDiffBuilder { - static func rows(old oldText: String, current currentText: String) -> [DiffRow] { +public enum LocalHistoryDiffBuilder { + public static func rows(old oldText: String, current currentText: String) -> [LocalHistoryDiffRow] { let oldLines = lines(in: oldText) let currentLines = lines(in: currentText) let difference = currentLines.difference(from: oldLines) @@ -56,7 +46,7 @@ enum LocalHistoryDiffBuilder { } } - var rows: [DiffRow] = [] + var rows: [LocalHistoryDiffRow] = [] var oldIndex = 0 var currentIndex = 0 while oldIndex < oldLines.count || currentIndex < currentLines.count { @@ -64,7 +54,7 @@ enum LocalHistoryDiffBuilder { let currentIsInserted = currentIndex < currentLines.count && insertions.contains(currentIndex) if !oldIsRemoved, !currentIsInserted, oldIndex < oldLines.count, currentIndex < currentLines.count { - rows.append(DiffRow( + rows.append(LocalHistoryDiffRow( oldLine: oldIndex + 1, newLine: currentIndex + 1, left: oldLines[oldIndex], @@ -97,14 +87,14 @@ enum LocalHistoryDiffBuilder { } // Pair by similarity so an unrelated delete and insert do not render // as one bogus modification. Shared with the Rust diff path. - let pairs = DiffPairing.pairs( + let pairs = LocalHistoryDiffPairing.pairs( removed: removed.map(\.1), added: inserted.map(\.1) ) for (leftIndex, rightIndex) in pairs { let left = leftIndex.map { removed[$0] } let right = rightIndex.map { inserted[$0] } - rows.append(DiffRow( + rows.append(LocalHistoryDiffRow( oldLine: left?.0, newLine: right?.0, left: left?.1, diff --git a/Sources/LitheLocalHistoryModule/Module/LocalHistoryModule.swift b/Sources/LitheLocalHistoryModule/Module/LocalHistoryModule.swift new file mode 100644 index 00000000..b9c2b711 --- /dev/null +++ b/Sources/LitheLocalHistoryModule/Module/LocalHistoryModule.swift @@ -0,0 +1,65 @@ +import Foundation +import LitheModuleAPI + +@MainActor +public final class HistoryModuleCapability: NSObject { + public let feature: ProjectHistoryFeatureModel + public init(feature: ProjectHistoryFeatureModel) { self.feature = feature } +} + +@MainActor +public final class HistoryModule: LitheModule { + public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .localHistory) + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .localHistory)! + + public let manifest = moduleManifest + private let workspaceAccess: any LocalHistoryWorkspaceAccess + private let storage: any LocalHistoryStorage + private let operations: any LocalHistoryOperations + private var capability: HistoryModuleCapability? + + public init(workspaceAccess: any LocalHistoryWorkspaceAccess, storage: any LocalHistoryStorage, operations: any LocalHistoryOperations) { + self.workspaceAccess = workspaceAccess + self.storage = storage + self.operations = operations + } + + public func activate(context: ModuleContext) async throws { + guard capability == nil else { return } + let feature = ProjectHistoryFeatureModel(workspaceAccess: workspaceAccess, storage: storage, localHistoryOperations: operations) + context.resources.register(HistoryTaskResource(feature: feature)) + capability = HistoryModuleCapability(feature: feature) + } + + public func prepareForSleep() async throws { + guard capability?.feature.hasActiveModuleWork != true else { throw HistoryModuleSleepError.activeWork } + } + public func sleep() async { releaseFeature() } + public func shutdown() async { releaseFeature() } + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.historyWorkspace: capability] + } + public func contributions() -> [ModuleContribution] { + Self.moduleContributions + } + + private func releaseFeature() { + capability?.feature.reset() + capability = nil + } +} + +public enum HistoryModuleSleepError: LocalizedError, Sendable { + case activeWork + public var errorDescription: String? { "Local history work is still active." } +} + +@MainActor +private final class HistoryTaskResource: ModuleResource { + let feature: ProjectHistoryFeatureModel + init(feature: ProjectHistoryFeatureModel) { self.feature = feature } + var moduleResourceKind: String { "local-history-tasks" } + var isModuleResourceActive: Bool { feature.hasActiveModuleWork } + func stopModuleResource() async { feature.reset() } +} diff --git a/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift b/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift new file mode 100644 index 00000000..20193de5 --- /dev/null +++ b/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct LocalHistoryVisibilityRules: Hashable, Sendable { + public let hiddenDirectoryNames: [String] + public let hiddenFilePatterns: [String] + public init(hiddenDirectoryNames: [String], hiddenFilePatterns: [String]) { + self.hiddenDirectoryNames = hiddenDirectoryNames + self.hiddenFilePatterns = hiddenFilePatterns + } +} + +public struct LocalHistoryEntryPayload: Sendable { + public let id: String + public let timestamp: Int64 + public let relativePath: String + public let reason: String + public let contentPath: String + public let byteCount: Int + + public init(id: String, timestamp: Int64, relativePath: String, reason: String, contentPath: String, byteCount: Int) { + self.id = id + self.timestamp = timestamp + self.relativePath = relativePath + self.reason = reason + self.contentPath = contentPath + self.byteCount = byteCount + } +} + +public protocol LocalHistoryOperations: Sendable { + func record(at workspaceURL: URL, storageURL: URL, relativePath: String, reason: LocalHistoryReason, content: String?, pruneExpired: Bool, visibilityRules: LocalHistoryVisibilityRules) -> LocalHistoryEntryPayload? + func entries(at workspaceURL: URL, storageURL: URL, relativePath: String?, visibilityRules: LocalHistoryVisibilityRules) -> [LocalHistoryEntryPayload]? + func content(at storageURL: URL, contentPath: String) -> String? + func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool +} + +public protocol LocalHistoryWorkspaceAccess: Sendable { + func fileExists(at url: URL) -> Bool + func readFile(at workspaceURL: URL, relativePath: String) -> String? + func writeFile(_ text: String, at workspaceURL: URL, relativePath: String) -> Bool +} + +public protocol LocalHistoryStorage: Sendable { + func applicationSupportDirectory() -> URL +} + +public struct LocalHistoryDocumentSnapshot: Sendable { + public let id: UUID + public let url: URL + public let text: String + public init(id: UUID, url: URL, text: String) { self.id = id; self.url = url; self.text = text } +} diff --git a/Sources/Lithe/Services/LocalHistoryService.swift b/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift similarity index 94% rename from Sources/Lithe/Services/LocalHistoryService.swift rename to Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift index 1e138a2f..80f0e054 100644 --- a/Sources/Lithe/Services/LocalHistoryService.swift +++ b/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift @@ -2,14 +2,14 @@ import Foundation actor LocalHistoryService { private let workspaceURL: URL - private var visibilityRules: FileVisibilityRules + private var visibilityRules: LocalHistoryVisibilityRules private let storageURL: URL private let operations: any LocalHistoryOperations init( workspaceURL: URL, - visibilityRules: FileVisibilityRules = .default, - storage: any FileStorage, + visibilityRules: LocalHistoryVisibilityRules, + storage: any LocalHistoryStorage, operations: any LocalHistoryOperations ) { self.workspaceURL = workspaceURL.standardizedFileURL @@ -22,7 +22,7 @@ actor LocalHistoryService { .appendingPathComponent(Self.stableIdentifier(for: workspaceURL.path), isDirectory: true) } - func updateVisibilityRules(_ rules: FileVisibilityRules) { + func updateVisibilityRules(_ rules: LocalHistoryVisibilityRules) { visibilityRules = rules } @@ -112,7 +112,7 @@ actor LocalHistoryService { return makeEntry(value) } - private func makeEntry(_ value: RustCoreBridge.HistoryEntryPayload) -> LocalHistoryEntry? { + private func makeEntry(_ value: LocalHistoryEntryPayload) -> LocalHistoryEntry? { guard let id = UUID(uuidString: value.id) else { return nil } return LocalHistoryEntry( id: id, diff --git a/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift new file mode 100644 index 00000000..85ccae3a --- /dev/null +++ b/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -0,0 +1,257 @@ +import Foundation + +/// Stable, platform-neutral declarations shared by macOS and Windows. +/// Platform composition roots provide factories; they must not redefine IDs, +/// scope, or lifecycle defaults. +public enum BuiltInModuleCatalog { + public static let manifests: [ModuleManifest] = [ + ModuleManifest( + id: .aiAssistance, + displayName: "AI Assistance", + scope: .application, + defaultState: .disabled, + activationPolicy: .onDemand, + sleepPolicy: .whenIdle(afterSeconds: 5 * 60), + providedCapabilities: [.aiCommitMessage] + ), + ModuleManifest( + id: .database, + displayName: "Database", + scope: .workspace, + defaultState: .disabled, + activationPolicy: .onDemand, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [.databaseWorkspace] + ), + ModuleManifest( + id: .debug, + displayName: "Debug", + scope: .workspace, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [ + .module(.workspace), + .module(.languageIntelligence), + .module(.execution) + ], + providedCapabilities: [.debugWorkspace] + ), + ModuleManifest( + id: .execution, + displayName: "Build / Run / Test", + scope: .workspace, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [.executionWorkspace] + ), + ModuleManifest( + id: .git, + displayName: "Git Review", + scope: .workspace, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [.gitWorkspace] + ), + ModuleManifest( + id: .languageIntelligence, + displayName: "Language Intelligence", + scope: .workspace, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [.languageIntelligence] + ), + ModuleManifest( + id: .localHistory, + displayName: "Local History", + scope: .workspace, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [.historyWorkspace] + ), + ModuleManifest( + id: .search, + displayName: "Search & Index", + scope: .workspace, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [.searchWorkspace] + ), + ModuleManifest( + id: .terminal, + displayName: "Terminal", + scope: .workspace, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [.terminalWorkspace] + ), + ModuleManifest( + id: .workspace, + displayName: "Workspace Foundation", + scope: .workspace, + activationPolicy: .eager, + sleepPolicy: .never, + providedCapabilities: [.workspaceFoundation], + isRequired: true + ) + ].sorted { $0.id < $1.id } + + public static var ids: [ModuleID] { manifests.map(\.id) } + + public static let contributions: [ModuleID: [ModuleContribution]] = [ + .aiAssistance: [ + ModuleContribution(id: "ai.commit-message", kind: .command, title: "Generate Commit Message", icon: "wand.and.stars"), + ModuleContribution(id: "ai.settings", kind: .settings, title: "AI Assistance", icon: "wand.and.stars") + ], + .database: [ + ModuleContribution(id: "database.workspace", kind: .toolWindow, title: "Database", icon: "cylinder") + ], + .debug: [ + ModuleContribution(id: "debug.session", kind: .toolWindow, title: "Debug", icon: "ladybug", order: 700, actionID: "debug.toggle", rendererID: "debug.session") + ], + .execution: [ + ModuleContribution(id: "execution.maven", kind: .toolWindow, title: "Maven", icon: "shippingbox", order: 400, actionID: "execution.maven.toggle", rendererID: "execution.maven", visibility: ["projectKind": "maven"]), + ModuleContribution(id: "execution.run", kind: .toolWindow, title: "Run", icon: "play.rectangle", order: 500, actionID: "execution.run.toggle", rendererID: "execution.run"), + ModuleContribution(id: "execution.tests", kind: .toolWindow, title: "Tests", icon: "checkmark.seal", order: 600, actionID: "execution.tests.toggle", rendererID: "execution.tests") + ], + .git: [ + ModuleContribution(id: "git.changes", kind: .toolWindow, title: "Changes", icon: "arrow.triangle.branch"), + ModuleContribution(id: "git.log", kind: .toolWindow, title: "Git Log", icon: "point.3.connected.trianglepath.dotted", order: 200, actionID: "git.log.toggle", rendererID: "git.log") + ], + .languageIntelligence: [ + ModuleContribution(id: "language.problems", kind: .toolWindow, title: "Problems", icon: "exclamationmark.triangle", order: 300, actionID: "language.problems.toggle", rendererID: "language.problems"), + ModuleContribution(id: "language.settings", kind: .settings, title: "Language Servers", icon: "server.rack") + ], + .localHistory: [ + ModuleContribution(id: "history.local", kind: .toolWindow, title: "Local History", icon: "clock.arrow.circlepath") + ], + .search: [ + ModuleContribution(id: "search.workspace", kind: .toolWindow, title: "Search", icon: "magnifyingglass") + ], + .terminal: [ + ModuleContribution(id: "terminal.sessions", kind: .toolWindow, title: "Terminal", icon: "terminal", order: 100, actionID: "terminal.toggle", rendererID: "terminal.sessions") + ], + .workspace: [] + ] + + public static func manifest(for id: ModuleID) -> ModuleManifest? { + manifests.first { $0.id == id } + } + + public static func contributions(for id: ModuleID) -> [ModuleContribution] { + contributions[id] ?? [] + } +} + +public enum BuiltInPluginCatalog { + public static let hostVersion = PluginVersion(major: 0, minor: 3, patch: 0) + public static let vendor = PluginVendor( + id: "dev.lithe", + displayName: "Lithe", + signatureRequirement: .sameTeamAsHost + ) + + public static let manifests: [PluginManifest] = BuiltInModuleCatalog.manifests.map { manifest in + let suffix = manifest.id.rawValue.replacingOccurrences(of: "dev.lithe.", with: "") + return PluginManifest( + id: PluginID("dev.lithe.plugin.\(suffix)"), + displayName: manifest.displayName, + version: hostVersion, + hostCompatibility: PluginHostCompatibility( + minimum: hostVersion, + maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) + ), + vendor: vendor, + entrypoint: .builtIn(targetName: targetName(for: manifest.id)), + modules: [PluginModuleDeclaration( + manifest: manifest, + contributions: BuiltInModuleCatalog.contributions(for: manifest.id) + )] + ) + }.sorted { $0.id < $1.id } + + public static func manifest(forModule id: ModuleID) -> PluginManifest? { + manifests.first { plugin in plugin.modules.contains { $0.manifest.id == id } } + } + + private static func targetName(for id: ModuleID) -> String { + switch id { + case .aiAssistance: "LitheAIAssistanceModule" + case .database: "LitheDatabaseModule" + case .debug: "LitheDebugModule" + case .execution: "LitheExecutionModule" + case .git: "LitheGitModule" + case .languageIntelligence: "LitheLanguageIntelligenceModule" + case .localHistory: "LitheLocalHistoryModule" + case .search: "LitheSearchModule" + case .terminal: "LitheTerminalModule" + case .workspace: "LitheWorkspaceModule" + default: preconditionFailure("Unknown built-in module \(id)") + } + } +} + +/// Optional official packages distributed through the same native plugin path +/// as marketplace updates. These manifests are not part of the host's static +/// module graph and become available only when their signed package exists. +public enum OfficialPluginCatalog { + private static let goLanguageID = "go" + + public static let manifests: [PluginManifest] = [ + PluginManifest( + id: PluginID("dev.lithe.plugin.go-support"), + displayName: "Go Support", + version: BuiltInPluginCatalog.hostVersion, + hostCompatibility: PluginHostCompatibility( + minimum: BuiltInPluginCatalog.hostVersion, + maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) + ), + vendor: BuiltInPluginCatalog.vendor, + entrypoint: PluginEntrypoint( + kind: .nativeBundle, + bundleIdentifier: "dev.lithe.plugin.go-support.bundle", + principalClass: "LitheGoSupportPluginEntrypoint", + bundlePath: "GoSupport.bundle" + ), + modules: [ + PluginModuleDeclaration(manifest: ModuleManifest( + id: .languageExecutionExtension(goLanguageID), + displayName: "Go Execution", + scope: .workspace, + activationPolicy: .onDemand, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [ + .languageExecutionExtension(goLanguageID), + .languageTestingExtension(goLanguageID) + ] + )), + PluginModuleDeclaration(manifest: ModuleManifest( + id: .languageServerExtension(goLanguageID), + displayName: "Go Language Server", + scope: .workspace, + activationPolicy: .onDemand, + sleepPolicy: .whenIdle(afterSeconds: 10 * 60), + dependencies: [.module(.workspace)], + providedCapabilities: [.languageServerExtension(goLanguageID)] + )) + ], + languageSupports: [LanguageSupportDeclaration( + id: goLanguageID, + displayName: "Go", + fileExtensions: ["go"], + projectFileNames: ["go.mod", "go.work"], + languageServerModuleID: .languageServerExtension(goLanguageID), + executionModuleID: .languageExecutionExtension(goLanguageID), + testingModuleID: .languageExecutionExtension(goLanguageID) + )] + ) + ] + + public static func manifest(forModule id: ModuleID) -> PluginManifest? { + manifests.first { plugin in plugin.modules.contains { $0.manifest.id == id } } + } + + public static func moduleManifest(for id: ModuleID) -> ModuleManifest? { + manifest(forModule: id)?.modules.first { $0.manifest.id == id }?.manifest + } +} diff --git a/Sources/LitheModuleAPI/Lifecycle/ModuleContracts.swift b/Sources/LitheModuleAPI/Lifecycle/ModuleContracts.swift new file mode 100644 index 00000000..f125e272 --- /dev/null +++ b/Sources/LitheModuleAPI/Lifecycle/ModuleContracts.swift @@ -0,0 +1,314 @@ +import Foundation + +@MainActor +public protocol ModuleCapabilityResolver: AnyObject { + func capability(_ id: ModuleCapabilityID) -> AnyObject? +} + +@MainActor +public protocol ModuleEventPublishing: AnyObject { + func publish(_ event: ModuleEvent) +} + +@MainActor +public protocol ModuleContributionPublishing: AnyObject { + func register(_ contribution: ModuleContribution, for moduleID: ModuleID) + func removeContributions(for moduleID: ModuleID) + func contributions() -> [ModuleID: [ModuleContribution]] +} + +public protocol ModuleConfigurationStore: Sendable { + func enabledState(for moduleID: ModuleID) -> Bool? + func setEnabledState(_ enabled: Bool, for moduleID: ModuleID) +} + +/// Durable recovery metadata that is readable without constructing a module. +/// A pending activation left behind by a terminated process is quarantined on +/// the next launch, allowing the app shell to start without loading its code. +public protocol ModuleRecoveryStore: Sendable { + func pendingActivation() -> ModuleID? + func setPendingActivation(_ moduleID: ModuleID?) + func pendingActivations() -> [ModuleID] + func setPendingActivations(_ moduleIDs: [ModuleID]) + func isQuarantined(_ moduleID: ModuleID) -> Bool + func setQuarantined(_ quarantined: Bool, for moduleID: ModuleID) + func pendingPluginLoadModules() -> [ModuleID] + func setPendingPluginLoadModules(_ moduleIDs: [ModuleID]) +} + +public extension ModuleRecoveryStore { + func pendingActivations() -> [ModuleID] { + pendingActivation().map { [$0] } ?? [] + } + func setPendingActivations(_ moduleIDs: [ModuleID]) { + setPendingActivation(moduleIDs.sorted().first) + } + func pendingPluginLoadModules() -> [ModuleID] { [] } + func setPendingPluginLoadModules(_ moduleIDs: [ModuleID]) {} +} + +public struct ModuleEvent: Equatable, Sendable { + public let source: ModuleID + public let name: String + public let attributes: [String: String] + + public init(source: ModuleID, name: String, attributes: [String: String] = [:]) { + self.source = source + self.name = name + self.attributes = attributes + } +} + +/// Swift entrypoint for same-team official plugins built against the matching +/// Plugin API and host compatibility range. Static manifest validation and +/// enablement checks must happen before the bundle containing this type loads. +@MainActor +public protocol LithePluginEntrypoint: AnyObject { + init() + func moduleFactories(context: PluginHostContext) throws -> [ModuleFactory] +} + +public struct PluginHostServiceID: RawRepresentable, Hashable, Sendable, Comparable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + precondition(!rawValue.isEmpty, "A plugin host service ID must not be empty.") + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.init(rawValue: rawValue) + } + + public var description: String { rawValue } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +@MainActor +public protocol PluginHostServiceResolving: AnyObject { + func service(_ id: PluginHostServiceID) -> AnyObject? +} + +/// Read-only services supplied by the host while a plugin creates its lazy +/// module factories. Plugins request narrow, versioned service protocols by +/// ID instead of importing platform adapters or the application executable. +@MainActor +public struct PluginHostContext { + private let resolver: any PluginHostServiceResolving + + public init(resolver: any PluginHostServiceResolving) { + self.resolver = resolver + } + + public func service(_ id: PluginHostServiceID) -> AnyObject? { + resolver.service(id) + } + + public static var empty: PluginHostContext { + PluginHostContext(resolver: EmptyPluginHostServiceResolver.shared) + } +} + +@MainActor +private final class EmptyPluginHostServiceResolver: PluginHostServiceResolving { + static let shared = EmptyPluginHostServiceResolver() + func service(_ id: PluginHostServiceID) -> AnyObject? { nil } +} + +public extension ModuleEvent { + static let stateChangedName = "module.state-changed" + static let activityStartedName = "module.activity-started" + static let activityEndedName = "module.activity-ended" +} + +@MainActor +public protocol ModuleResource: AnyObject { + var moduleResourceKind: String { get } + var isModuleResourceActive: Bool { get } + func stopModuleResource() async +} + +public struct ModuleResourceSnapshot: Equatable, Sendable { + public let id: UUID + public let kind: String + public let isActive: Bool + + public init(id: UUID, kind: String, isActive: Bool) { + self.id = id + self.kind = kind + self.isActive = isActive + } +} + +@MainActor +public protocol ModuleResourceManaging: AnyObject { + @discardableResult + func register(_ resource: any ModuleResource) -> UUID + func unregisterResource(id: UUID) + func resourceSnapshots() -> [ModuleResourceSnapshot] +} + +@MainActor +public protocol ModuleLeaseManaging: AnyObject { + func acquireLease(reason: String) -> ModuleLease +} + +@MainActor +public final class ModuleLease { + public let id: UUID + public let reason: String + private let releaseAction: @MainActor (UUID) -> Void + private var isReleased = false + + public init( + id: UUID = UUID(), + reason: String, + releaseAction: @escaping @MainActor (UUID) -> Void + ) { + self.id = id + self.reason = reason + self.releaseAction = releaseAction + } + + public func release() { + guard !isReleased else { return } + isReleased = true + releaseAction(id) + } + + deinit { + if !isReleased { + let id = id + let releaseAction = releaseAction + Task { @MainActor in releaseAction(id) } + } + } +} + +@MainActor +public struct ModuleContext { + public let moduleID: ModuleID + public let workspaceURL: URL? + public let capabilities: any ModuleCapabilityResolver + public let events: any ModuleEventPublishing + public let resources: any ModuleResourceManaging + public let leases: any ModuleLeaseManaging + public let contributions: any ModuleContributionPublishing + + public init( + moduleID: ModuleID, + workspaceURL: URL?, + capabilities: any ModuleCapabilityResolver, + events: any ModuleEventPublishing, + resources: any ModuleResourceManaging, + leases: any ModuleLeaseManaging + , contributions: any ModuleContributionPublishing + ) { + self.moduleID = moduleID + self.workspaceURL = workspaceURL + self.capabilities = capabilities + self.events = events + self.resources = resources + self.leases = leases + self.contributions = contributions + } +} + +@MainActor +public protocol LitheModule: AnyObject { + var manifest: ModuleManifest { get } + func activate(context: ModuleContext) async throws + func prepareForSleep() async throws + func sleep() async + func shutdown() async + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] + func contributions() -> [ModuleContribution] +} + +public extension LitheModule { + func contributions() -> [ModuleContribution] { [] } +} + +@MainActor +public struct ModuleFactory { + public let manifest: ModuleManifest + public let contributions: [ModuleContribution] + private let makeAction: @MainActor () throws -> any LitheModule + + public init( + manifest: ModuleManifest, + contributions: [ModuleContribution] = [], + make: @escaping @MainActor () throws -> any LitheModule + ) { + self.manifest = manifest + self.contributions = contributions.sorted { + ($0.placement.rawValue, $0.order, $0.id) + < ($1.placement.rawValue, $1.order, $1.id) + } + self.makeAction = make + } + + public func makeModule() throws -> any LitheModule { + try makeAction() + } +} + +public enum ModuleRuntimeError: Error, Equatable, LocalizedError, Sendable { + case duplicateModule(ModuleID) + case unknownModule(ModuleID) + case dependencyCycle([ModuleID]) + case missingModuleDependency(module: ModuleID, dependency: ModuleID) + case missingCapabilityDependency(module: ModuleID, capability: ModuleCapabilityID) + case capabilityCollision(capability: ModuleCapabilityID, providers: [ModuleID]) + case missingExportedCapability(module: ModuleID, capability: ModuleCapabilityID) + case undeclaredExportedCapability(module: ModuleID, capability: ModuleCapabilityID) + case contributionCatalogMismatch(ModuleID) + case builtInManifestMismatch(ModuleID) + case moduleDisabled(ModuleID) + case moduleQuarantined(ModuleID) + case optionalModuleUnavailableInSafeMode(ModuleID) + case requiredModuleCannotBeDisabled(ModuleID) + case enabledDependentsPreventDisable(module: ModuleID, dependents: [ModuleID]) + case activeDependentsPreventSleep(module: ModuleID, dependents: [ModuleID]) + case activeLeasesPreventSleep(module: ModuleID, reasons: [String]) + case activeResourcesRemain(module: ModuleID, kinds: [String]) + + public var errorDescription: String? { + switch self { + case .duplicateModule(let id): "Module \(id) is already registered." + case .unknownModule(let id): "Module \(id) is not registered." + case .dependencyCycle(let ids): "Module dependency cycle: \(ids.map(\.rawValue).joined(separator: " -> "))." + case .missingModuleDependency(let module, let dependency): + "Module \(module) requires missing module \(dependency)." + case .missingCapabilityDependency(let module, let capability): + "Module \(module) requires missing capability \(capability)." + case .capabilityCollision(let capability, let providers): + "Capability \(capability) has multiple providers: \(providers.map(\.rawValue).joined(separator: ", "))." + case .missingExportedCapability(let module, let capability): + "Module \(module) did not export declared capability \(capability)." + case .undeclaredExportedCapability(let module, let capability): + "Module \(module) exported undeclared capability \(capability)." + case .contributionCatalogMismatch(let module): + "Module \(module) instance contributions differ from its static factory catalog." + case .builtInManifestMismatch(let module): + "Built-in module \(module) does not match the shared manifest catalog." + case .moduleDisabled(let id): "Module \(id) is disabled." + case .moduleQuarantined(let id): + "Module \(id) was disabled because its previous activation did not complete. Re-enable it to try again." + case .optionalModuleUnavailableInSafeMode(let id): + "Module \(id) is unavailable while Lithe is running in Safe Mode." + case .requiredModuleCannotBeDisabled(let id): "Required module \(id) cannot be disabled." + case .enabledDependentsPreventDisable(let module, let dependents): + "Module \(module) is required by enabled modules: \(dependents.map(\.rawValue).joined(separator: ", "))." + case .activeDependentsPreventSleep(let module, let dependents): + "Module \(module) cannot sleep while dependent modules are active: \(dependents.map(\.rawValue).joined(separator: ", "))." + case .activeLeasesPreventSleep(let module, let reasons): + "Module \(module) cannot sleep while active leases remain: \(reasons.joined(separator: ", "))." + case .activeResourcesRemain(let module, let kinds): + "Module \(module) still owns active resources after stopping: \(kinds.joined(separator: ", "))." + } + } +} diff --git a/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift b/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift new file mode 100644 index 00000000..f37c9ac0 --- /dev/null +++ b/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift @@ -0,0 +1,304 @@ +import Foundation + +public struct ModuleID: RawRepresentable, Hashable, Codable, Sendable, Comparable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + precondition(!rawValue.isEmpty, "A module ID must not be empty.") + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.init(rawValue: rawValue) + } + + public var description: String { rawValue } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +public struct ModuleCapabilityID: RawRepresentable, Hashable, Codable, Sendable, Comparable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + precondition(!rawValue.isEmpty, "A capability ID must not be empty.") + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.init(rawValue: rawValue) + } + + public var description: String { rawValue } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +public enum ModuleScope: String, Codable, Sendable { + case application + case workspace +} + +public enum ModuleDefaultState: String, Codable, Sendable { + case enabled + case disabled +} + +public enum ModuleActivationPolicy: String, Codable, Sendable { + case eager + case onDemand + case manual +} + +public enum ModuleLaunchMode: Sendable { + case normal + case safeMode +} + +public enum ModuleSleepPolicy: Equatable, Codable, Sendable { + case never + case whenIdle(afterSeconds: TimeInterval) + + public var idleInterval: TimeInterval? { + switch self { + case .never: nil + case .whenIdle(let interval): interval + } + } +} + +public enum ModuleDependency: Hashable, Codable, Sendable { + case module(ModuleID) + case capability(ModuleCapabilityID) +} + +public struct ModuleManifest: Equatable, Codable, Sendable { + public let id: ModuleID + public let displayName: String + public let scope: ModuleScope + public let defaultState: ModuleDefaultState + public let activationPolicy: ModuleActivationPolicy + public let sleepPolicy: ModuleSleepPolicy + public let dependencies: Set + public let providedCapabilities: Set + public let isRequired: Bool + + public init( + id: ModuleID, + displayName: String, + scope: ModuleScope, + defaultState: ModuleDefaultState = .enabled, + activationPolicy: ModuleActivationPolicy = .onDemand, + sleepPolicy: ModuleSleepPolicy = .never, + dependencies: Set = [], + providedCapabilities: Set = [], + isRequired: Bool = false + ) { + self.id = id + self.displayName = displayName + self.scope = scope + self.defaultState = defaultState + self.activationPolicy = activationPolicy + self.sleepPolicy = sleepPolicy + self.dependencies = dependencies + self.providedCapabilities = providedCapabilities + self.isRequired = isRequired + } +} + +public enum ModuleState: Equatable, Sendable { + case disabled + case inactive + case activating + case active + case idle + case preparingToSleep + case sleeping + case sleepBlocked(reason: String) + case failed(message: String) +} + +public struct ModuleActivity: Equatable, Sendable { + public let activeLeaseCount: Int + public let activeResourceCount: Int + public let lastActivityAt: Date? + + public init( + activeLeaseCount: Int, + activeResourceCount: Int, + lastActivityAt: Date? + ) { + self.activeLeaseCount = activeLeaseCount + self.activeResourceCount = activeResourceCount + self.lastActivityAt = lastActivityAt + } + + public var isIdle: Bool { + activeLeaseCount == 0 + } +} + +public struct ModuleSnapshot: Equatable, Sendable { + public let manifest: ModuleManifest + public let state: ModuleState + public let activity: ModuleActivity + public let isInstantiated: Bool + public let resources: [ModuleResourceSnapshot] + public let activeLeaseReasons: [String] + public let isQuarantined: Bool + public let isSuppressedBySafeMode: Bool + + public init( + manifest: ModuleManifest, + state: ModuleState, + activity: ModuleActivity, + isInstantiated: Bool, + resources: [ModuleResourceSnapshot] = [], + activeLeaseReasons: [String] = [], + isQuarantined: Bool = false, + isSuppressedBySafeMode: Bool = false + ) { + self.manifest = manifest + self.state = state + self.activity = activity + self.isInstantiated = isInstantiated + self.resources = resources + self.activeLeaseReasons = activeLeaseReasons + self.isQuarantined = isQuarantined + self.isSuppressedBySafeMode = isSuppressedBySafeMode + } +} + +public enum ModuleContributionKind: String, Codable, Sendable { + case command + case toolWindow + case settings + case status +} + +public enum ModuleContributionPlacement: String, Codable, Sendable { + case activityBar + case toolWindow + case commandPalette + case settings + case statusBar +} + +public struct ModuleContribution: Identifiable, Equatable, Codable, Sendable { + public let id: String + public let kind: ModuleContributionKind + public let title: String + public let icon: String? + public let placement: ModuleContributionPlacement + public let order: Int + public let actionID: String? + public let rendererID: String? + public let visibility: [String: String] + + public init( + id: String, + kind: ModuleContributionKind, + title: String, + icon: String? = nil, + placement: ModuleContributionPlacement? = nil, + order: Int = 0, + actionID: String? = nil, + rendererID: String? = nil, + visibility: [String: String] = [:] + ) { + self.id = id + self.kind = kind + self.title = title + self.icon = icon + self.placement = placement ?? Self.defaultPlacement(for: kind) + self.order = order + self.actionID = actionID + self.rendererID = rendererID + self.visibility = visibility + } + + private enum CodingKeys: String, CodingKey { + case id, kind, title, icon, placement, order, actionID, rendererID, visibility + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(ModuleContributionKind.self, forKey: .kind) + self.id = try container.decode(String.self, forKey: .id) + self.kind = kind + title = try container.decode(String.self, forKey: .title) + icon = try container.decodeIfPresent(String.self, forKey: .icon) + placement = try container.decodeIfPresent( + ModuleContributionPlacement.self, + forKey: .placement + ) ?? Self.defaultPlacement(for: kind) + order = try container.decodeIfPresent(Int.self, forKey: .order) ?? 0 + actionID = try container.decodeIfPresent(String.self, forKey: .actionID) + rendererID = try container.decodeIfPresent(String.self, forKey: .rendererID) + visibility = try container.decodeIfPresent( + [String: String].self, + forKey: .visibility + ) ?? [:] + } + + private static func defaultPlacement( + for kind: ModuleContributionKind + ) -> ModuleContributionPlacement { + switch kind { + case .command: .commandPalette + case .toolWindow: .activityBar + case .settings: .settings + case .status: .statusBar + } + } +} + +public extension ModuleID { + static let workspace = ModuleID("dev.lithe.workspace") + static let git = ModuleID("dev.lithe.git") + static let search = ModuleID("dev.lithe.search") + static let localHistory = ModuleID("dev.lithe.local-history") + static let languageIntelligence = ModuleID("dev.lithe.language-intelligence") + static let execution = ModuleID("dev.lithe.execution") + static let debug = ModuleID("dev.lithe.debug") + static let terminal = ModuleID("dev.lithe.terminal") + static let database = ModuleID("dev.lithe.database") + static let aiAssistance = ModuleID("dev.lithe.ai-assistance") + + static func languageServerExtension(_ languageID: String) -> ModuleID { + ModuleID("dev.lithe.language.\(languageID).language-server") + } + + static func languageExecutionExtension(_ languageID: String) -> ModuleID { + ModuleID("dev.lithe.language.\(languageID).execution") + } +} + +public extension ModuleCapabilityID { + static let workspaceFoundation = ModuleCapabilityID("dev.lithe.capability.workspace-foundation") + static let gitWorkspace = ModuleCapabilityID("dev.lithe.capability.git-workspace") + static let searchWorkspace = ModuleCapabilityID("dev.lithe.capability.search-workspace") + static let historyWorkspace = ModuleCapabilityID("dev.lithe.capability.history-workspace") + static let languageIntelligence = ModuleCapabilityID("dev.lithe.capability.language-intelligence") + static let executionWorkspace = ModuleCapabilityID("dev.lithe.capability.execution-workspace") + static let debugWorkspace = ModuleCapabilityID("dev.lithe.capability.debug-workspace") + static let terminalWorkspace = ModuleCapabilityID("dev.lithe.capability.terminal-workspace") + static let databaseWorkspace = ModuleCapabilityID("dev.lithe.capability.database-workspace") + static let aiCommitMessage = ModuleCapabilityID("dev.lithe.capability.ai-commit-message") + + static func languageServerExtension(_ languageID: String) -> ModuleCapabilityID { + ModuleCapabilityID("dev.lithe.capability.language.\(languageID).language-server") + } + + static func languageExecutionExtension(_ languageID: String) -> ModuleCapabilityID { + ModuleCapabilityID("dev.lithe.capability.language.\(languageID).execution") + } + + static func languageTestingExtension(_ languageID: String) -> ModuleCapabilityID { + ModuleCapabilityID("dev.lithe.capability.language.\(languageID).testing") + } +} diff --git a/Sources/LitheModuleAPI/Plugins/PluginTypes.swift b/Sources/LitheModuleAPI/Plugins/PluginTypes.swift new file mode 100644 index 00000000..8cdd9084 --- /dev/null +++ b/Sources/LitheModuleAPI/Plugins/PluginTypes.swift @@ -0,0 +1,416 @@ +import Foundation + +public struct PluginID: RawRepresentable, Hashable, Codable, Sendable, Comparable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + precondition(!rawValue.isEmpty, "A plugin ID must not be empty.") + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.init(rawValue: rawValue) + } + + public var description: String { rawValue } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + guard !rawValue.isEmpty else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "A plugin ID must not be empty." + ) + } + self.rawValue = rawValue + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +public struct PluginVersion: Hashable, Sendable, Comparable, Codable, CustomStringConvertible { + public let major: Int + public let minor: Int + public let patch: Int + + public init(major: Int, minor: Int, patch: Int) { + precondition(major >= 0 && minor >= 0 && patch >= 0, "Version components must not be negative.") + self.major = major + self.minor = minor + self.patch = patch + } + + public init?(_ value: String) { + let components = value.split(separator: ".", omittingEmptySubsequences: false) + guard components.count == 3, + let major = Int(components[0]), + let minor = Int(components[1]), + let patch = Int(components[2]), + major >= 0, minor >= 0, patch >= 0 else { return nil } + self.init(major: major, minor: minor, patch: patch) + } + + public var description: String { "\(major).\(minor).\(patch)" } + + public static func < (lhs: Self, rhs: Self) -> Bool { + (lhs.major, lhs.minor, lhs.patch) < (rhs.major, rhs.minor, rhs.patch) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + guard let version = PluginVersion(value) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected a semantic version with major.minor.patch components." + ) + } + self = version + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) + } +} + +public struct PluginHostCompatibility: Equatable, Codable, Sendable { + public let minimum: PluginVersion + public let maximumExclusive: PluginVersion? + + public init(minimum: PluginVersion, maximumExclusive: PluginVersion? = nil) { + self.minimum = minimum + self.maximumExclusive = maximumExclusive + } + + public func contains(_ hostVersion: PluginVersion) -> Bool { + guard hostVersion >= minimum else { return false } + return maximumExclusive.map { hostVersion < $0 } ?? true + } +} + +public enum PluginSignatureRequirement: String, Codable, Sendable { + case sameTeamAsHost +} + +public struct PluginVendor: Equatable, Codable, Sendable { + public let id: String + public let displayName: String + public let signatureRequirement: PluginSignatureRequirement + + public init( + id: String, + displayName: String, + signatureRequirement: PluginSignatureRequirement + ) { + self.id = id + self.displayName = displayName + self.signatureRequirement = signatureRequirement + } +} + +public enum PluginEntrypointKind: String, Codable, Sendable { + case builtIn + case nativeBundle +} + +public struct PluginEntrypoint: Equatable, Codable, Sendable { + public let kind: PluginEntrypointKind + public let targetName: String? + public let bundleIdentifier: String? + public let principalClass: String? + public let bundlePath: String? + + public init( + kind: PluginEntrypointKind, + targetName: String? = nil, + bundleIdentifier: String? = nil, + principalClass: String? = nil, + bundlePath: String? = nil + ) { + self.kind = kind + self.targetName = targetName + self.bundleIdentifier = bundleIdentifier + self.principalClass = principalClass + self.bundlePath = bundlePath + } + + public static func builtIn(targetName: String) -> Self { + Self(kind: .builtIn, targetName: targetName) + } +} + +public struct PluginModuleDeclaration: Equatable, Codable, Sendable { + public let manifest: ModuleManifest + public let contributions: [ModuleContribution] + + public init(manifest: ModuleManifest, contributions: [ModuleContribution] = []) { + self.manifest = manifest + self.contributions = contributions.sorted { + ($0.placement.rawValue, $0.order, $0.id) + < ($1.placement.rawValue, $1.order, $1.id) + } + } + + private enum CodingKeys: String, CodingKey { + case id + case displayName + case scope + case defaultState + case activationPolicy + case sleepPolicy + case moduleDependencies + case capabilityDependencies + case providedCapabilities + case contributions + case required + } + + private struct SleepPolicyValue: Codable { + let kind: String + let afterSeconds: TimeInterval? + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let id = ModuleID(try container.decode(String.self, forKey: .id)) + let moduleDependencies = try container.decodeIfPresent( + [String].self, + forKey: .moduleDependencies + ) ?? [] + let capabilityDependencies = try container.decodeIfPresent( + [String].self, + forKey: .capabilityDependencies + ) ?? [] + let providedCapabilities = try container.decode( + [String].self, + forKey: .providedCapabilities + ) + let sleepValue = try container.decode(SleepPolicyValue.self, forKey: .sleepPolicy) + let sleepPolicy: ModuleSleepPolicy + switch sleepValue.kind { + case "never": + guard sleepValue.afterSeconds == nil else { + throw DecodingError.dataCorruptedError( + forKey: .sleepPolicy, + in: container, + debugDescription: "A never sleep policy must not include afterSeconds." + ) + } + sleepPolicy = .never + case "whenIdle": + guard let interval = sleepValue.afterSeconds, interval > 0 else { + throw DecodingError.dataCorruptedError( + forKey: .sleepPolicy, + in: container, + debugDescription: "A whenIdle sleep policy requires a positive afterSeconds value." + ) + } + sleepPolicy = .whenIdle(afterSeconds: interval) + default: + throw DecodingError.dataCorruptedError( + forKey: .sleepPolicy, + in: container, + debugDescription: "Unsupported module sleep policy." + ) + } + manifest = ModuleManifest( + id: id, + displayName: try container.decode(String.self, forKey: .displayName), + scope: try container.decode(ModuleScope.self, forKey: .scope), + defaultState: try container.decode(ModuleDefaultState.self, forKey: .defaultState), + activationPolicy: try container.decode(ModuleActivationPolicy.self, forKey: .activationPolicy), + sleepPolicy: sleepPolicy, + dependencies: Set(moduleDependencies.map { .module(ModuleID($0)) }) + .union(capabilityDependencies.map { .capability(ModuleCapabilityID($0)) }), + providedCapabilities: Set(providedCapabilities.map { ModuleCapabilityID($0) }), + isRequired: try container.decode(Bool.self, forKey: .required) + ) + contributions = try container.decodeIfPresent( + [ModuleContribution].self, + forKey: .contributions + )?.sorted { + ($0.placement.rawValue, $0.order, $0.id) + < ($1.placement.rawValue, $1.order, $1.id) + } ?? [] + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(manifest.id.rawValue, forKey: .id) + try container.encode(manifest.displayName, forKey: .displayName) + try container.encode(manifest.scope, forKey: .scope) + try container.encode(manifest.defaultState, forKey: .defaultState) + try container.encode(manifest.activationPolicy, forKey: .activationPolicy) + let sleepValue: SleepPolicyValue + switch manifest.sleepPolicy { + case .never: + sleepValue = SleepPolicyValue(kind: "never", afterSeconds: nil) + case .whenIdle(let interval): + sleepValue = SleepPolicyValue(kind: "whenIdle", afterSeconds: interval) + } + try container.encode(sleepValue, forKey: .sleepPolicy) + let moduleDependencies = manifest.dependencies.compactMap { dependency -> String? in + guard case .module(let id) = dependency else { return nil } + return id.rawValue + }.sorted() + let capabilityDependencies = manifest.dependencies.compactMap { dependency -> String? in + guard case .capability(let id) = dependency else { return nil } + return id.rawValue + }.sorted() + try container.encode(moduleDependencies, forKey: .moduleDependencies) + try container.encode(capabilityDependencies, forKey: .capabilityDependencies) + try container.encode( + manifest.providedCapabilities.map(\.rawValue).sorted(), + forKey: .providedCapabilities + ) + try container.encode(contributions, forKey: .contributions) + try container.encode(manifest.isRequired, forKey: .required) + } +} + +/// Inert metadata used to recognize a language project and route host UI to +/// independently activated modules without loading the plugin Bundle. +public struct LanguageSupportDeclaration: Equatable, Codable, Sendable { + public let id: String + public let displayName: String + public let fileExtensions: [String] + public let fileNames: [String] + public let projectFileNames: [String] + public let languageServerModuleID: ModuleID? + public let executionModuleID: ModuleID? + public let testingModuleID: ModuleID? + public let debugModuleID: ModuleID? + + public init( + id: String, + displayName: String, + fileExtensions: [String] = [], + fileNames: [String] = [], + projectFileNames: [String] = [], + languageServerModuleID: ModuleID? = nil, + executionModuleID: ModuleID? = nil, + testingModuleID: ModuleID? = nil, + debugModuleID: ModuleID? = nil + ) { + self.id = id + self.displayName = displayName + self.fileExtensions = Self.normalized(fileExtensions, removingLeadingDot: true) + self.fileNames = Self.normalized(fileNames) + self.projectFileNames = Self.normalized(projectFileNames) + self.languageServerModuleID = languageServerModuleID + self.executionModuleID = executionModuleID + self.testingModuleID = testingModuleID + self.debugModuleID = debugModuleID + } + + public var moduleIDs: [ModuleID] { + [languageServerModuleID, executionModuleID, testingModuleID, debugModuleID].compactMap { $0 } + } + + public func handles(fileURL: URL) -> Bool { + let fileName = fileURL.lastPathComponent.lowercased() + return fileExtensions.contains(fileURL.pathExtension.lowercased()) + || fileNames.contains(fileName) + } + + public func recognizesProject(fileNames: some Sequence) -> Bool { + let candidates = Set(fileNames.map { $0.lowercased() }) + return projectFileNames.contains { candidates.contains($0) } + } + + private static func normalized( + _ values: [String], + removingLeadingDot: Bool = false + ) -> [String] { + Set(values.compactMap { value -> String? in + var normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if removingLeadingDot, normalized.hasPrefix(".") { + normalized.removeFirst() + } + return normalized.isEmpty ? nil : normalized + }).sorted() + } +} + +public struct PluginManifest: Equatable, Codable, Sendable { + public static let currentSchemaVersion = 1 + public static let currentAPIVersion = 1 + + public let schemaVersion: Int + public let id: PluginID + public let displayName: String + public let version: PluginVersion + public let apiVersion: Int + public let hostCompatibility: PluginHostCompatibility + public let vendor: PluginVendor + public let entrypoint: PluginEntrypoint + public let modules: [PluginModuleDeclaration] + public let languageSupports: [LanguageSupportDeclaration]? + + public init( + schemaVersion: Int = currentSchemaVersion, + id: PluginID, + displayName: String, + version: PluginVersion, + apiVersion: Int = currentAPIVersion, + hostCompatibility: PluginHostCompatibility, + vendor: PluginVendor, + entrypoint: PluginEntrypoint, + modules: [PluginModuleDeclaration], + languageSupports: [LanguageSupportDeclaration] = [] + ) { + self.schemaVersion = schemaVersion + self.id = id + self.displayName = displayName + self.version = version + self.apiVersion = apiVersion + self.hostCompatibility = hostCompatibility + self.vendor = vendor + self.entrypoint = entrypoint + self.modules = modules.sorted { $0.manifest.id < $1.manifest.id } + let sortedLanguageSupports = languageSupports.sorted { $0.id < $1.id } + self.languageSupports = sortedLanguageSupports.isEmpty ? nil : sortedLanguageSupports + } +} + +public enum PluginInstallationOrigin: String, Codable, Sendable { + case bundled + case marketplace +} + +public enum PluginInstallationStatus: String, Codable, Sendable { + case installed + case updateStaged + case uninstallPending +} + +public struct PluginInstallationRecord: Equatable, Codable, Sendable { + public let pluginID: PluginID + public let activeVersion: PluginVersion + public let previousVersion: PluginVersion? + public let origin: PluginInstallationOrigin + public let status: PluginInstallationStatus + + public init( + pluginID: PluginID, + activeVersion: PluginVersion, + previousVersion: PluginVersion? = nil, + origin: PluginInstallationOrigin, + status: PluginInstallationStatus = .installed + ) { + self.pluginID = pluginID + self.activeVersion = activeVersion + self.previousVersion = previousVersion + self.origin = origin + self.status = status + } +} diff --git a/Sources/Lithe/Application/SearchFeatureModel.swift b/Sources/LitheSearchModule/Application/SearchFeatureModel.swift similarity index 59% rename from Sources/Lithe/Application/SearchFeatureModel.swift rename to Sources/LitheSearchModule/Application/SearchFeatureModel.swift index a5844309..6bcd8db0 100644 --- a/Sources/Lithe/Application/SearchFeatureModel.swift +++ b/Sources/LitheSearchModule/Application/SearchFeatureModel.swift @@ -1,32 +1,41 @@ import Combine import Foundation -struct ProjectReplacementApplyResult: Sendable { - let changedFiles: Int - let failedFiles: [String] +public struct ProjectReplacementApplyResult: Sendable { + public let changedFiles: Int + public let failedFiles: [String] } /// Owns search result state and delegates matching/replacement preview semantics /// to the shared workspace operations port. @MainActor -final class SearchFeatureModel: ObservableObject { - @Published private(set) var searchResults: [FileSearchResult] = [] - @Published private(set) var isSearching = false - @Published private(set) var searchEverywhereResults = SearchEverywhereResults( +public final class SearchFeatureModel: ObservableObject { + @Published public private(set) var searchResults: [FileSearchResult] = [] + @Published public private(set) var isSearching = false + @Published public private(set) var searchEverywhereResults = SearchEverywhereResults( fileMatches: [], contentMatches: [] ) - @Published private(set) var isSearchingEverywhere = false - @Published private(set) var projectReplacementFiles: [ProjectReplacementFile] = [] - @Published private(set) var isLoadingProjectReplacement = false + @Published public private(set) var isSearchingEverywhere = false + @Published public private(set) var projectReplacementFiles: [ProjectReplacementFile] = [] + @Published public private(set) var isLoadingProjectReplacement = false - private let operations: any WorkspaceOperations + private let operations: any SearchOperations + private var indexTask: Task? + private var indexTaskGeneration = 0 - init(operations: any WorkspaceOperations) { + public var hasActiveModuleWork: Bool { + isSearching || isSearchingEverywhere || isLoadingProjectReplacement || indexTask != nil + } + + public init(operations: any SearchOperations) { self.operations = operations } - func reset() { + public func reset() { + indexTaskGeneration += 1 + indexTask?.cancel() + indexTask = nil searchResults = [] isSearching = false searchEverywhereResults = SearchEverywhereResults(fileMatches: [], contentMatches: []) @@ -35,16 +44,68 @@ final class SearchFeatureModel: ObservableObject { isLoadingProjectReplacement = false } - func clearProjectSearch() { + public func warmIndex(at workspaceURL: URL, visibilityRules: SearchVisibilityRules) { + replaceIndexTask { operations in + operations.warmSearchIndex(at: workspaceURL, visibilityRules: visibilityRules) + } + } + + public func invalidateIndex(at workspaceURL: URL, visibilityRules: SearchVisibilityRules) { + replaceIndexTask { operations in + operations.invalidateSearchIndex(at: workspaceURL, visibilityRules: visibilityRules) + } + } + + public func updateIndex( + at workspaceURL: URL, + changedPaths: [String], + visibilityRules: SearchVisibilityRules + ) async { + guard !changedPaths.isEmpty else { return } + replaceIndexTask { operations in + operations.updateSearchIndex( + at: workspaceURL, + changedPaths: changedPaths, + visibilityRules: visibilityRules + ) + } + await indexTask?.value + } + + private func replaceIndexTask( + operation: @escaping @Sendable (any SearchOperations) -> Void + ) { + let previousTask = indexTask + previousTask?.cancel() + let operations = self.operations + indexTaskGeneration += 1 + let generation = indexTaskGeneration + let worker = Task.detached(priority: .utility) { + await previousTask?.value + guard !Task.isCancelled else { return } + operation(operations) + } + indexTask = Task { [weak self] in + await withTaskCancellationHandler { + await worker.value + } onCancel: { + worker.cancel() + } + guard let self, self.indexTaskGeneration == generation else { return } + self.indexTask = nil + } + } + + public func clearProjectSearch() { searchResults = [] isSearching = false } - func searchProject( + public func searchProject( at workspaceURL: URL, query: String, options: ProjectSearchOptions, - visibilityRules: FileVisibilityRules, + visibilityRules: SearchVisibilityRules, isCurrent: @escaping @MainActor () -> Bool ) async { guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -71,17 +132,16 @@ final class SearchFeatureModel: ObservableObject { isSearching = false } - func clearSearchEverywhere() { + public func clearSearchEverywhere() { searchEverywhereResults = SearchEverywhereResults(fileMatches: [], contentMatches: []) isSearchingEverywhere = false } - func searchEverywhere( + public func searchEverywhere( at workspaceURL: URL, query: String, options: ProjectSearchOptions, - visibilityRules: FileVisibilityRules, - actionMatches: [LitheAction], + visibilityRules: SearchVisibilityRules, isCurrent: @escaping @MainActor () -> Bool ) async { guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -108,27 +168,26 @@ final class SearchFeatureModel: ObservableObject { fileMatches: indexedResults.fileMatches, classMatches: indexedResults.classMatches, symbolMatches: indexedResults.symbolMatches, - contentMatches: indexedResults.contentMatches, - actionMatches: actionMatches + contentMatches: indexedResults.contentMatches ) isSearchingEverywhere = false } - func clearProjectReplacementPreview() { + public func clearProjectReplacementPreview() { projectReplacementFiles = [] isLoadingProjectReplacement = false } - func setProjectReplacementLoading(_ loading: Bool) { + public func setProjectReplacementLoading(_ loading: Bool) { isLoadingProjectReplacement = loading } - func applyProjectReplacement( + public func applyProjectReplacement( at workspaceURL: URL, selectedPaths: Set, - documents: [EditorDocument], + textOverrides: [String: String], recordHistory: @escaping @MainActor (String, URL) async -> Void, - saveDocument: @escaping @MainActor (EditorDocument) throws -> Void + saveTextOverride: @escaping @MainActor (URL, String) throws -> Bool ) async -> ProjectReplacementApplyResult { let targets = projectReplacementFiles.filter { selectedPaths.contains($0.relativePath) } guard !targets.isEmpty else { @@ -139,8 +198,7 @@ final class SearchFeatureModel: ObservableObject { var changedFiles = 0 var failedFiles: [String] = [] for target in targets { - let document = documents.first { $0.url.standardizedFileURL == target.url.standardizedFileURL } - let currentText = document?.text ?? operations.readFile( + let currentText = textOverrides[target.relativePath] ?? operations.readFile( at: workspaceURL, relativePath: target.relativePath ) @@ -152,10 +210,8 @@ final class SearchFeatureModel: ObservableObject { await recordHistory(currentText, target.url) do { - if let document { - document.text = replacedText - try saveDocument(document) - } else if !operations.writeFile( + let savedOverride = try saveTextOverride(target.url, replacedText) + if !savedOverride && !operations.writeFile( replacedText, at: workspaceURL, relativePath: target.relativePath @@ -164,9 +220,6 @@ final class SearchFeatureModel: ObservableObject { } changedFiles += 1 } catch { - if let document { - document.text = currentText - } failedFiles.append(target.relativePath) } } @@ -177,14 +230,14 @@ final class SearchFeatureModel: ObservableObject { ) } - func previewProjectReplacement( + public func previewProjectReplacement( at workspaceURL: URL, query: String, replacement: String, paths: [String], textOverrides: [String: String], options: ProjectSearchOptions = .default, - visibilityRules: FileVisibilityRules, + visibilityRules: SearchVisibilityRules, isCurrent: @escaping @MainActor () -> Bool ) async { guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { diff --git a/Sources/LitheSearchModule/Models/ProjectReplacementModels.swift b/Sources/LitheSearchModule/Models/ProjectReplacementModels.swift new file mode 100644 index 00000000..85bb9b73 --- /dev/null +++ b/Sources/LitheSearchModule/Models/ProjectReplacementModels.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct ProjectReplacementMatch: Identifiable, Hashable, Sendable { + public let line: Int + public let before: String + public let after: String + public let occurrenceCount: Int + + public init(line: Int, before: String, after: String, occurrenceCount: Int) { + self.line = line + self.before = before + self.after = after + self.occurrenceCount = occurrenceCount + } + + public var id: String { "\(line):\(before):\(after)" } +} + +public struct ProjectReplacementFile: Identifiable, Hashable, Sendable { + public let url: URL + public let relativePath: String + public let matches: [ProjectReplacementMatch] + public let replacementText: String? + + public init( + url: URL, + relativePath: String, + matches: [ProjectReplacementMatch], + replacementText: String? = nil + ) { + self.url = url + self.relativePath = relativePath + self.matches = matches + self.replacementText = replacementText + } + + public var id: String { url.path } + public var matchCount: Int { matches.reduce(0) { $0 + $1.occurrenceCount } } +} diff --git a/Sources/Lithe/Models/SearchModels.swift b/Sources/LitheSearchModule/Models/SearchModels.swift similarity index 63% rename from Sources/Lithe/Models/SearchModels.swift rename to Sources/LitheSearchModule/Models/SearchModels.swift index 08f5f4cf..f605cd2e 100644 --- a/Sources/Lithe/Models/SearchModels.swift +++ b/Sources/LitheSearchModule/Models/SearchModels.swift @@ -3,25 +3,39 @@ import Foundation /// Shared search behavior for the project search sidebar and Search Everywhere. /// Keeping the matcher here makes both surfaces agree on case, word and regex /// semantics instead of silently returning different results. -struct ProjectSearchOptions: Hashable, Sendable { - var caseSensitive = false - var wholeWords = false - var regularExpression = false +public struct ProjectSearchOptions: Hashable, Sendable { + public var caseSensitive = false + public var wholeWords = false + public var regularExpression = false /// 替换时让结果沿用命中处的大小写形态(fooBar/FooBar/FOOBAR)。 - var preserveCase = false + public var preserveCase = false /// 逗号分隔的 glob 掩码,如 `*.java, *.kt`;为空表示不过滤。 - var fileMask = "" + public var fileMask = "" - static let `default` = ProjectSearchOptions() + public static let `default` = ProjectSearchOptions() - var cacheKey: String { + public init( + caseSensitive: Bool = false, + wholeWords: Bool = false, + regularExpression: Bool = false, + preserveCase: Bool = false, + fileMask: String = "" + ) { + self.caseSensitive = caseSensitive + self.wholeWords = wholeWords + self.regularExpression = regularExpression + self.preserveCase = preserveCase + self.fileMask = fileMask + } + + public var cacheKey: String { let flags = [caseSensitive, wholeWords, regularExpression, preserveCase] .map { $0 ? "1" : "0" } .joined() return "\(flags)|\(fileMask)" } - func matches(_ text: String, query: String) -> Bool { + public func matches(_ text: String, query: String) -> Bool { guard !query.isEmpty else { return true } if regularExpression || wholeWords { diff --git a/Sources/LitheSearchModule/Models/SearchResults.swift b/Sources/LitheSearchModule/Models/SearchResults.swift new file mode 100644 index 00000000..a10f407c --- /dev/null +++ b/Sources/LitheSearchModule/Models/SearchResults.swift @@ -0,0 +1,81 @@ +import Foundation + +public struct FileSearchResult: Identifiable, Hashable, Sendable { + public let kind: SearchResultKind + public let url: URL + public let line: Int? + public let preview: String + public let symbolName: String? + + public init( + url: URL, + line: Int?, + preview: String, + kind: SearchResultKind = .content, + symbolName: String? = nil + ) { + self.kind = kind + self.url = url + self.line = line + self.preview = preview + self.symbolName = symbolName + } + + public var id: String { "\(kind.rawValue):\(url.path):\(line ?? 0):\(preview)" } +} + +public enum SearchResultKind: String, Codable, Hashable, Sendable { + case file + case content + case type + case symbol + + public var title: String { + switch self { + case .file: "Files" + case .content: "Matches" + case .type: "Classes" + case .symbol: "Symbols" + } + } +} + +public struct SearchSymbol: Codable, Hashable, Sendable { + public let name: String + public let kind: SearchResultKind + public let line: Int + public let signature: String + + public init(name: String, kind: SearchResultKind, line: Int, signature: String) { + self.name = name + self.kind = kind + self.line = line + self.signature = signature + } +} + +public struct SearchEverywhereResults: Sendable { + public static let matchLimit = 200 + public let fileMatches: [FileSearchResult] + public let classMatches: [FileSearchResult] + public let symbolMatches: [FileSearchResult] + public let contentMatches: [FileSearchResult] + + public init( + fileMatches: [FileSearchResult] = [], + classMatches: [FileSearchResult] = [], + symbolMatches: [FileSearchResult] = [], + contentMatches: [FileSearchResult] = [] + ) { + self.fileMatches = fileMatches + self.classMatches = classMatches + self.symbolMatches = symbolMatches + self.contentMatches = contentMatches + } + + public var allMatches: [FileSearchResult] { + fileMatches + classMatches + symbolMatches + contentMatches + } + + public var totalCount: Int { allMatches.count } +} diff --git a/Sources/LitheSearchModule/Module/SearchModule.swift b/Sources/LitheSearchModule/Module/SearchModule.swift new file mode 100644 index 00000000..cf273a2f --- /dev/null +++ b/Sources/LitheSearchModule/Module/SearchModule.swift @@ -0,0 +1,66 @@ +import Foundation +import LitheModuleAPI + +@MainActor +public final class SearchModuleCapability: NSObject { + public let feature: SearchFeatureModel + public init(feature: SearchFeatureModel) { self.feature = feature } +} + +@MainActor +public final class SearchModule: LitheModule { + public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .search) + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .search)! + + public let manifest = moduleManifest + private let operations: any SearchOperations + private var capability: SearchModuleCapability? + + public init(operations: any SearchOperations) { + self.operations = operations + } + + public func activate(context: ModuleContext) async throws { + guard capability == nil else { return } + let feature = SearchFeatureModel(operations: operations) + context.resources.register(SearchTaskResource(feature: feature)) + capability = SearchModuleCapability(feature: feature) + } + + public func prepareForSleep() async throws { + guard capability?.feature.hasActiveModuleWork != true else { + throw SearchModuleSleepError.activeSearch + } + } + + public func sleep() async { releaseFeature() } + public func shutdown() async { releaseFeature() } + + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.searchWorkspace: capability] + } + + public func contributions() -> [ModuleContribution] { + Self.moduleContributions + } + + private func releaseFeature() { + capability?.feature.reset() + capability = nil + } +} + +public enum SearchModuleSleepError: LocalizedError, Sendable { + case activeSearch + public var errorDescription: String? { "Search or replacement work is still active." } +} + +@MainActor +private final class SearchTaskResource: ModuleResource { + let feature: SearchFeatureModel + init(feature: SearchFeatureModel) { self.feature = feature } + var moduleResourceKind: String { "search-tasks" } + var isModuleResourceActive: Bool { feature.hasActiveModuleWork } + func stopModuleResource() async { feature.reset() } +} diff --git a/Sources/LitheSearchModule/Ports/SearchOperations.swift b/Sources/LitheSearchModule/Ports/SearchOperations.swift new file mode 100644 index 00000000..52abbf60 --- /dev/null +++ b/Sources/LitheSearchModule/Ports/SearchOperations.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct SearchVisibilityRules: Hashable, Sendable { + public let hiddenDirectoryNames: [String] + public let hiddenFilePatterns: [String] + + public init(hiddenDirectoryNames: [String], hiddenFilePatterns: [String]) { + self.hiddenDirectoryNames = hiddenDirectoryNames + self.hiddenFilePatterns = hiddenFilePatterns + } +} + +public protocol SearchOperations: Sendable { + func warmSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) + func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: SearchVisibilityRules) + func invalidateSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) + func search( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules + ) -> [FileSearchResult]? + + func searchEverywhere( + at rootURL: URL, + query: String, + options: ProjectSearchOptions, + visibilityRules: SearchVisibilityRules + ) -> SearchEverywhereResults? + + func previewReplacement( + at rootURL: URL, + query: String, + replacement: String, + options: ProjectSearchOptions, + paths: [String], + textOverrides: [String: String], + visibilityRules: SearchVisibilityRules + ) -> [ProjectReplacementFile]? + + func readFile(at rootURL: URL, relativePath: String) -> String? + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool +} + +public extension SearchOperations { + func warmSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) {} + func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: SearchVisibilityRules) {} + func invalidateSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) {} +} diff --git a/Sources/Lithe/Application/TerminalFeatureModel.swift b/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift similarity index 58% rename from Sources/Lithe/Application/TerminalFeatureModel.swift rename to Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift index a8ffb5b4..d1ba8ef6 100644 --- a/Sources/Lithe/Application/TerminalFeatureModel.swift +++ b/Sources/LitheTerminalModule/Application/TerminalFeatureModel.swift @@ -1,17 +1,16 @@ import Combine import Foundation -/// Owns terminal session state while leaving the actual PTY/ConPTY transport -/// to the platform composition root. +/// Owns terminal sessions while the platform adapter owns the PTY and surface. @MainActor -final class TerminalFeatureModel: ObservableObject { - @Published private(set) var terminalSessions: [TerminalSession] = [] - @Published private(set) var activeTerminalSessionID: UUID? +public final class TerminalFeatureModel: ObservableObject { + @Published public private(set) var terminalSessions: [TerminalSession] = [] + @Published public private(set) var activeTerminalSessionID: UUID? private let terminalFactory: () -> any TerminalTransport private let shellDiscovery: () -> [String] - init( + public init( terminalFactory: @escaping () -> any TerminalTransport, shellDiscovery: @escaping () -> [String] = { [] } ) { @@ -19,25 +18,21 @@ final class TerminalFeatureModel: ObservableObject { self.shellDiscovery = shellDiscovery } - var availableShells: [String] { shellDiscovery() } + public var availableShells: [String] { shellDiscovery() } - var activeTerminalSession: TerminalSession? { + public var activeTerminalSession: TerminalSession? { guard let activeTerminalSessionID else { return terminalSessions.first } return terminalSessions.first { $0.id == activeTerminalSessionID } } - func terminalTitle(for session: TerminalSession) -> String { - if let processTitle = session.processTitle, !processTitle.isEmpty { - return processTitle - } - guard let index = terminalSessions.firstIndex(where: { $0.id == session.id }) else { - return "Local" - } + public func terminalTitle(for session: TerminalSession) -> String { + if let processTitle = session.processTitle, !processTitle.isEmpty { return processTitle } + guard let index = terminalSessions.firstIndex(where: { $0.id == session.id }) else { return "Local" } return index == 0 ? "Local" : "Local (\(index + 1))" } @discardableResult - func createSession(in workspaceURL: URL, shellPath: String? = nil) -> TerminalSession { + public func createSession(in workspaceURL: URL, shellPath: String? = nil) -> TerminalSession { let session = TerminalSession(transport: terminalFactory()) session.start(in: workspaceURL, shellPath: shellPath) terminalSessions.append(session) @@ -46,38 +41,27 @@ final class TerminalFeatureModel: ObservableObject { } @discardableResult - func selectSession(_ session: TerminalSession) -> Bool { + public func selectSession(_ session: TerminalSession) -> Bool { guard terminalSessions.contains(where: { $0.id == session.id }) else { return false } activeTerminalSessionID = session.id return true } - func closeSession(_ session: TerminalSession) { + public func closeSession(_ session: TerminalSession) { guard let index = terminalSessions.firstIndex(where: { $0.id == session.id }) else { return } let wasActive = activeTerminalSessionID == session.id let replacement = terminalSessions.dropFirst(index + 1).first ?? (index > 0 ? terminalSessions[index - 1] : nil) - session.stop() terminalSessions.remove(at: index) - - if wasActive { - activeTerminalSessionID = replacement?.id - } - if terminalSessions.isEmpty { - activeTerminalSessionID = nil - } + if wasActive { activeTerminalSessionID = replacement?.id } + if terminalSessions.isEmpty { activeTerminalSessionID = nil } } - func restartActiveSession() { - activeTerminalSession?.restart() - } - - func restartActiveSession(using shellPath: String) { - activeTerminalSession?.restart(using: shellPath) - } + public func restartActiveSession() { activeTerminalSession?.restart() } + public func restartActiveSession(using shellPath: String) { activeTerminalSession?.restart(using: shellPath) } - func stopAllSessions() { + public func stopAllSessions() { terminalSessions.forEach { $0.stop() } terminalSessions.removeAll() activeTerminalSessionID = nil diff --git a/Sources/LitheTerminalModule/Module/TerminalModule.swift b/Sources/LitheTerminalModule/Module/TerminalModule.swift new file mode 100644 index 00000000..cbad7bd3 --- /dev/null +++ b/Sources/LitheTerminalModule/Module/TerminalModule.swift @@ -0,0 +1,75 @@ +import Foundation +import LitheModuleAPI + +@MainActor +public final class TerminalModuleCapability: NSObject { + public let feature: TerminalFeatureModel + public init(feature: TerminalFeatureModel) { self.feature = feature } +} + +@MainActor +public final class TerminalModule: LitheModule { + public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .terminal) + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .terminal)! + + public let manifest = moduleManifest + private let terminalFactory: @MainActor () -> any TerminalTransport + private let shellDiscovery: @MainActor () -> [String] + private var capability: TerminalModuleCapability? + + public init( + terminalFactory: @escaping @MainActor () -> any TerminalTransport, + shellDiscovery: @escaping @MainActor () -> [String] = { [] } + ) { + self.terminalFactory = terminalFactory + self.shellDiscovery = shellDiscovery + } + + public func activate(context: ModuleContext) async throws { + guard capability == nil else { return } + let feature = TerminalFeatureModel( + terminalFactory: terminalFactory, + shellDiscovery: shellDiscovery + ) + let resource = TerminalSessionResource(feature: feature) + context.resources.register(resource) + capability = TerminalModuleCapability(feature: feature) + } + + public func prepareForSleep() async throws { + guard capability?.feature.terminalSessions.allSatisfy({ !$0.isRunning }) != false else { + throw TerminalModuleSleepError.runningSession + } + } + + public func sleep() async { releaseFeature() } + public func shutdown() async { releaseFeature() } + + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.terminalWorkspace: capability] + } + + public func contributions() -> [ModuleContribution] { + Self.moduleContributions + } + + private func releaseFeature() { + capability?.feature.stopAllSessions() + capability = nil + } +} + +public enum TerminalModuleSleepError: LocalizedError, Sendable { + case runningSession + public var errorDescription: String? { "A terminal session is still running." } +} + +@MainActor +private final class TerminalSessionResource: ModuleResource { + let feature: TerminalFeatureModel + init(feature: TerminalFeatureModel) { self.feature = feature } + var moduleResourceKind: String { "terminal-sessions" } + var isModuleResourceActive: Bool { feature.terminalSessions.contains(where: \.isRunning) } + func stopModuleResource() async { feature.stopAllSessions() } +} diff --git a/Sources/Lithe/Core/Ports/TerminalTransport.swift b/Sources/LitheTerminalModule/Ports/TerminalTransport.swift similarity index 56% rename from Sources/Lithe/Core/Ports/TerminalTransport.swift rename to Sources/LitheTerminalModule/Ports/TerminalTransport.swift index 65ca2cfd..662776f9 100644 --- a/Sources/Lithe/Core/Ports/TerminalTransport.swift +++ b/Sources/LitheTerminalModule/Ports/TerminalTransport.swift @@ -1,12 +1,8 @@ import Foundation -/// Platform terminal runtime used by the terminal tool window. -/// -/// The runtime owns both the PTY process and the native terminal surface. Keeping -/// those objects together preserves the terminal screen while SwiftUI switches -/// between tool windows or terminal tabs. +/// Platform terminal runtime injected by the native composition root. @MainActor -protocol TerminalTransport: AnyObject { +public protocol TerminalTransport: AnyObject { var isRunning: Bool { get } var shellName: String { get } var nativeView: AnyObject { get } @@ -17,11 +13,7 @@ protocol TerminalTransport: AnyObject { func defaultShellPath() -> String func defaultEnvironment() -> [String: String] - func start( - workingDirectory: String, - shellPath: String, - environment: [String: String] - ) throws + func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws func send(_ input: Data) throws func interrupt() throws func focus() diff --git a/Sources/LitheTerminalModule/Runtime/TerminalSession.swift b/Sources/LitheTerminalModule/Runtime/TerminalSession.swift new file mode 100644 index 00000000..d0f486d4 --- /dev/null +++ b/Sources/LitheTerminalModule/Runtime/TerminalSession.swift @@ -0,0 +1,90 @@ +import Combine +import Foundation + +@MainActor +public final class TerminalSession: ObservableObject, Identifiable { + public let id = UUID() + @Published public private(set) var isRunning = false + @Published public private(set) var isReady = false + @Published public private(set) var shellName = "Shell" + @Published public private(set) var processTitle: String? + @Published public private(set) var currentDirectory: URL? + @Published public private(set) var lastExitCode: Int32? + @Published public private(set) var startedAt: Date? + @Published public private(set) var endedAt: Date? + public var onLink: ((String, [String: String]) -> Void)? + + private let transport: any TerminalTransport + private var workspaceURL: URL? + private var selectedShellPath: String? + + public init(transport: any TerminalTransport) { + self.transport = transport + transport.onTermination = { [weak self] exitCode in + guard let self else { return } + isRunning = false; isReady = false; lastExitCode = exitCode; endedAt = Date() + } + transport.onTitle = { [weak self] title in + let value = title.trimmingCharacters(in: .whitespacesAndNewlines) + self?.processTitle = value.isEmpty ? nil : value + } + transport.onDirectoryUpdate = { [weak self] in self?.updateCurrentDirectory($0) } + transport.onLink = { [weak self] link, params in self?.onLink?(link, params) } + } + + public var nativeView: AnyObject { transport.nativeView } + public var displayTitle: String { processTitle?.isEmpty == false ? processTitle! : shellName } + public var displayDirectory: String? { currentDirectory?.lastPathComponent.nonEmpty } + + public func elapsedDescription(at date: Date = Date()) -> String? { + guard let startedAt else { return nil } + let seconds = Int(max(0, (endedAt ?? date).timeIntervalSince(startedAt)).rounded(.down)) + let hours = seconds / 3_600 + return hours > 0 + ? String(format: "%d:%02d:%02d", hours, (seconds % 3_600) / 60, seconds % 60) + : String(format: "%02d:%02d", seconds / 60, seconds % 60) + } + + public func start(in workspaceURL: URL, shellPath: String? = nil) { + stop() + self.workspaceURL = workspaceURL + currentDirectory = workspaceURL.standardizedFileURL + processTitle = nil; lastExitCode = nil; startedAt = Date(); endedAt = nil + let shell = shellPath ?? selectedShellPath ?? transport.defaultShellPath() + selectedShellPath = shell + shellName = URL(fileURLWithPath: shell).lastPathComponent + var environment = transport.defaultEnvironment() + environment["TERM"] = "xterm-256color" + environment["COLORTERM"] = "truecolor" + environment["TERM_PROGRAM"] = "Lithe" + do { + try transport.start(workingDirectory: workspaceURL.path, shellPath: shell, environment: environment) + isRunning = transport.isRunning; isReady = isRunning + } catch { + isRunning = false; isReady = false; startedAt = nil; endedAt = Date() + } + } + + public func restart() { if let workspaceURL { start(in: workspaceURL, shellPath: selectedShellPath) } } + public func restart(using shellPath: String) { if let workspaceURL { start(in: workspaceURL, shellPath: shellPath) } } + public func send(_ command: String) { sendInput(command + "\n") } + public func sendInput(_ input: String) { + guard isRunning, isReady, let data = input.data(using: .utf8) else { return } + try? transport.send(data) + } + public func interrupt() { if isRunning { try? transport.interrupt() } } + public func clear() { transport.clear() } + public func focus() { transport.focus() } + public func stop() { + transport.stop(); isRunning = false; isReady = false + if startedAt != nil { endedAt = Date() } + } + + private func updateCurrentDirectory(_ rawValue: String?) { + guard let rawValue, !rawValue.isEmpty else { return } + if let url = URL(string: rawValue), url.isFileURL { currentDirectory = url.standardizedFileURL } + else if rawValue.hasPrefix("/") { currentDirectory = URL(fileURLWithPath: rawValue).standardizedFileURL } + } +} + +private extension String { var nonEmpty: String? { isEmpty ? nil : self } } diff --git a/Sources/LitheTerminalModule/Services/TerminalLinkResolver.swift b/Sources/LitheTerminalModule/Services/TerminalLinkResolver.swift new file mode 100644 index 00000000..1c509e55 --- /dev/null +++ b/Sources/LitheTerminalModule/Services/TerminalLinkResolver.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct TerminalLinkLocation: Equatable, Sendable { + public let url: URL + public let line: Int? + public let column: Int? + public init(url: URL, line: Int?, column: Int?) { self.url = url; self.line = line; self.column = column } +} + +public enum TerminalLinkTarget: Equatable, Sendable { case file(TerminalLinkLocation); case external(URL) } + +public enum TerminalLinkResolver { + public static func resolve( + _ rawLink: String, + relativeTo directory: URL, + fileExists: (URL) -> Bool + ) -> TerminalLinkTarget? { + let rawLink = rawLink.trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawLink.isEmpty else { return nil } + if let url = URL(string: rawLink), let scheme = url.scheme, !scheme.isEmpty, !url.isFileURL { + return .external(url) + } + let (link, line, column) = splitLocationSuffix(rawLink) + guard !link.isEmpty else { return nil } + let path = URL(string: link)?.isFileURL == true + ? URL(string: link)!.path + : (link as NSString).expandingTildeInPath + let url = path.hasPrefix("/") + ? URL(fileURLWithPath: path).standardizedFileURL + : directory.appendingPathComponent(path).standardizedFileURL + guard fileExists(url) else { return nil } + return .file(TerminalLinkLocation(url: url, line: line, column: column)) + } + + private static func splitLocationSuffix(_ value: String) -> (String, Int?, Int?) { + var components = value.split(separator: ":", omittingEmptySubsequences: false).map(String.init) + var line: Int?; var column: Int? + if components.count >= 3, let c = Int(components.last!), let l = Int(components[components.count - 2]) { + column = c; line = l; components.removeLast(2) + } else if components.count >= 2, let l = Int(components.last!) { + line = l; components.removeLast() + } + return (components.joined(separator: ":"), line, column) + } +} diff --git a/Sources/Lithe/Application/WorkspaceFeatureModel.swift b/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift similarity index 86% rename from Sources/Lithe/Application/WorkspaceFeatureModel.swift rename to Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index c755c234..fe1f17b9 100644 --- a/Sources/Lithe/Application/WorkspaceFeatureModel.swift +++ b/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -1,7 +1,8 @@ import Combine import Foundation +import LitheCoreContracts -enum WorkspaceRebuildResult: Sendable { +package enum WorkspaceRebuildResult: Sendable { case loaded(WorkspaceSnapshot) case unavailable case stale @@ -9,23 +10,22 @@ enum WorkspaceRebuildResult: Sendable { /// Owns the workspace snapshot and delegates scanning and text reads to Core. @MainActor -final class WorkspaceFeatureModel: ObservableObject { - @Published private(set) var rootNode: FileNode? - @Published private(set) var projectFiles: [URL] = [] - @Published private(set) var isLoadingWorkspace = false - @Published private(set) var isRefreshingWorkspace = false - @Published private(set) var loadErrorMessage: String? - @Published var projectItemEditRequest: ProjectItemEditRequest? - @Published var pendingProjectItemDeletion: ProjectItemDeletionRequest? - @Published private(set) var isPerformingProjectItemOperation = false - private(set) var gitOperationFreezeDepth = 0 +package final class WorkspaceFeatureModel: ObservableObject { + @Published package private(set) var rootNode: FileNode? + @Published package private(set) var projectFiles: [URL] = [] + @Published package private(set) var isLoadingWorkspace = false + @Published package private(set) var isRefreshingWorkspace = false + @Published package private(set) var loadErrorMessage: String? + @Published package var projectItemEditRequest: ProjectItemEditRequest? + @Published package var pendingProjectItemDeletion: ProjectItemDeletionRequest? + @Published package private(set) var isPerformingProjectItemOperation = false + package private(set) var gitOperationFreezeDepth = 0 private let operations: any WorkspaceOperations private let fileOperations: any WorkspaceFileOperations - private let fileStorage: any FileStorage private let gitWatchContextProvider: any GitWatchContextProviding private let directoryWatcherFactory: any DirectoryWatcherFactory - private let workspaceSessionStore: WorkspaceSessionStore + private let workspaceSessionStore: any WorkspaceSessionStoring private var workspaceURL: URL? private var visibilityRules = FileVisibilityRules.default private var watchConfiguration: DirectoryWatchConfiguration? @@ -34,7 +34,6 @@ final class WorkspaceFeatureModel: ObservableObject { private var gitRefreshTask: Task? private var recoveryTask: Task? private var visibilityRulesRefreshTask: Task? - private var searchIndexTask: Task? private var pendingExternalPaths: Set = [] private var pendingGitRefresh = false private var pendingFullRescan = false @@ -45,8 +44,8 @@ final class WorkspaceFeatureModel: ObservableObject { private var workspaceSessionPersistenceTask: Task? private var hasRestoredWorkspaceSession = false - private var documentsProvider: (@MainActor () -> [EditorDocument])? - private var activeDocumentProvider: (@MainActor () -> EditorDocument?)? + private var documentsProvider: (@MainActor () -> [WorkspaceDocumentState])? + private var activeDocumentProvider: (@MainActor () -> WorkspaceDocumentState?)? private var selectedSidebarProvider: (@MainActor () -> String)? private var setSelectedSidebar: (@MainActor (String) -> Void)? private var restoreSession: (@MainActor (WorkspaceSession, [URL]) async -> Void)? @@ -61,26 +60,27 @@ final class WorkspaceFeatureModel: ObservableObject { private var refreshGit: (@MainActor () async -> Void)? private var updateHistoryVisibilityRules: (@MainActor (FileVisibilityRules) async -> Void)? private var onSnapshotLoaded: (@MainActor (WorkspaceSnapshot, Bool) async -> Void)? + private var warmSearchIndex: (@MainActor (URL, FileVisibilityRules) -> Void)? + private var updateSearchIndex: (@MainActor (URL, [String], FileVisibilityRules) async -> Void)? + private var invalidateSearchIndex: (@MainActor (URL, FileVisibilityRules) -> Void)? - init( + package init( operations: any WorkspaceOperations, fileOperations: any WorkspaceFileOperations, - fileStorage: any FileStorage, gitWatchContextProvider: any GitWatchContextProviding, directoryWatcherFactory: any DirectoryWatcherFactory, - workspaceSessionStore: WorkspaceSessionStore + workspaceSessionStore: any WorkspaceSessionStoring ) { self.operations = operations self.fileOperations = fileOperations - self.fileStorage = fileStorage self.gitWatchContextProvider = gitWatchContextProvider self.directoryWatcherFactory = directoryWatcherFactory self.workspaceSessionStore = workspaceSessionStore } - func configure( - documentsProvider: @escaping @MainActor () -> [EditorDocument], - activeDocumentProvider: @escaping @MainActor () -> EditorDocument?, + package func configureProjection( + documentsProvider: @escaping @MainActor () -> [WorkspaceDocumentState], + activeDocumentProvider: @escaping @MainActor () -> WorkspaceDocumentState?, selectedSidebarProvider: @escaping @MainActor () -> String, setSelectedSidebar: @escaping @MainActor (String) -> Void, restoreSession: @escaping @MainActor (WorkspaceSession, [URL]) async -> Void, @@ -94,7 +94,10 @@ final class WorkspaceFeatureModel: ObservableObject { reloadProjectServices: @escaping @MainActor () async -> Void, refreshGit: @escaping @MainActor () async -> Void, updateHistoryVisibilityRules: @escaping @MainActor (FileVisibilityRules) async -> Void, - onSnapshotLoaded: @escaping @MainActor (WorkspaceSnapshot, Bool) async -> Void + onSnapshotLoaded: @escaping @MainActor (WorkspaceSnapshot, Bool) async -> Void, + warmSearchIndex: @escaping @MainActor (URL, FileVisibilityRules) -> Void, + updateSearchIndex: @escaping @MainActor (URL, [String], FileVisibilityRules) async -> Void, + invalidateSearchIndex: @escaping @MainActor (URL, FileVisibilityRules) -> Void ) { self.documentsProvider = documentsProvider self.activeDocumentProvider = activeDocumentProvider @@ -112,13 +115,49 @@ final class WorkspaceFeatureModel: ObservableObject { self.refreshGit = refreshGit self.updateHistoryVisibilityRules = updateHistoryVisibilityRules self.onSnapshotLoaded = onSnapshotLoaded + self.warmSearchIndex = warmSearchIndex + self.updateSearchIndex = updateSearchIndex + self.invalidateSearchIndex = invalidateSearchIndex } - var hasSnapshot: Bool { + package var hasSnapshot: Bool { rootNode != nil || !projectFiles.isEmpty } - func reset() { + package var hasActiveModuleResources: Bool { + directoryWatcher != nil + || refreshTask != nil + || gitRefreshTask != nil + || recoveryTask != nil + || visibilityRulesRefreshTask != nil + || workspaceSessionPersistenceTask != nil + } + + package func prepareForModuleRelease() { + directoryWatcher?.stop() + directoryWatcher = nil + watchConfiguration = nil + refreshTask?.cancel() + refreshTask = nil + gitRefreshTask?.cancel() + gitRefreshTask = nil + recoveryTask?.cancel() + recoveryTask = nil + visibilityRulesRefreshTask?.cancel() + visibilityRulesRefreshTask = nil + workspaceSessionPersistenceTask?.cancel() + workspaceSessionPersistenceTask = nil + pendingExternalPaths.removeAll() + pendingGitRefresh = false + pendingFullRescan = false + pendingWatchRootsChanged = false + isGitRefreshRunning = false + externalRefreshGeneration += 1 + gitRefreshGeneration += 1 + gitOperationFreezeDepth = 0 + } + + package func reset() { if let workspaceURL { scheduleSearchIndexInvalidation(at: workspaceURL, rules: visibilityRules) } @@ -157,10 +196,9 @@ final class WorkspaceFeatureModel: ObservableObject { recoveryTask?.cancel() visibilityRulesRefreshTask?.cancel() workspaceSessionPersistenceTask?.cancel() - searchIndexTask?.cancel() } - func beginWorkspace(at url: URL, visibilityRules: FileVisibilityRules) { + package func beginWorkspace(at url: URL, visibilityRules: FileVisibilityRules) { workspaceURL = url.standardizedFileURL self.visibilityRules = visibilityRules hasRestoredWorkspaceSession = false @@ -179,7 +217,7 @@ final class WorkspaceFeatureModel: ObservableObject { /// Temporarily prevents FSEvents callbacks from making the workspace observe /// Git's intermediate index/worktree states. Nested calls are supported so a /// high-level workflow can contain several Git commands safely. - func beginGitOperationFreeze() { + package func beginGitOperationFreeze() { gitOperationFreezeDepth += 1 refreshTask?.cancel() refreshTask = nil @@ -192,7 +230,7 @@ final class WorkspaceFeatureModel: ObservableObject { } /// Flushes accumulated workspace and Git events after the outermost Git operation. - func endGitOperationFreeze() async { + package func endGitOperationFreeze() async { guard gitOperationFreezeDepth > 0 else { return } gitOperationFreezeDepth -= 1 guard gitOperationFreezeDepth == 0, let workspaceURL else { return } @@ -213,7 +251,7 @@ final class WorkspaceFeatureModel: ObservableObject { } } - func rebuild( + package func rebuild( at workspaceURL: URL, rules: FileVisibilityRules, isCurrent: @escaping @MainActor () -> Bool @@ -278,7 +316,7 @@ final class WorkspaceFeatureModel: ObservableObject { return .loaded(snapshot) } - func refreshCurrent() async { + package func refreshCurrent() async { guard let workspaceURL, !isLoadingWorkspace, !isRefreshingWorkspace else { return } refreshTask?.cancel() pendingExternalPaths.removeAll() @@ -290,17 +328,7 @@ final class WorkspaceFeatureModel: ObservableObject { ) } - func javaIconKind(for url: URL) async -> LitheIconKind? { - guard url.pathExtension.lowercased() == "java" else { return nil } - let storage = fileStorage - let data = await Task.detached(priority: .utility) { - try? storage.readPrefix(from: url, byteCount: 4 * 1024) - }.value - guard let data, let prefix = String(data: data, encoding: .utf8) else { return nil } - return LitheIcons.javaSymbolKind(fromSourcePrefix: prefix) - } - - func startWatchingCurrent() { + package func startWatchingCurrent() { guard let workspaceURL else { return } startWatching( watchConfiguration ?? DirectoryWatchConfiguration(workspaceRoot: workspaceURL, gitContext: nil), @@ -308,21 +336,21 @@ final class WorkspaceFeatureModel: ObservableObject { ) } - func resumeObservationAfterActivation() async { + package func resumeObservationAfterActivation() async { guard workspaceURL != nil else { return } await updateWatchConfiguration(forceRebuild: true) await requestGitRefreshNow() } - func contains(_ url: URL) -> Bool { + package func contains(_ url: URL) -> Bool { isWorkspaceURL(url) } - func fileExists(at url: URL) -> Bool { + package func fileExists(at url: URL) -> Bool { fileOperations.fileExists(at: url) } - func updateVisibilityRules(_ rules: FileVisibilityRules) { + package func updateVisibilityRules(_ rules: FileVisibilityRules) { visibilityRulesRefreshTask?.cancel() refreshTask?.cancel() guard let workspaceURL else { return } @@ -342,7 +370,7 @@ final class WorkspaceFeatureModel: ObservableObject { } } - func persistWorkspaceSession(for explicitWorkspaceURL: URL? = nil) { + package func persistWorkspaceSession(for explicitWorkspaceURL: URL? = nil) { guard let targetURL = explicitWorkspaceURL ?? workspaceURL, let documentsProvider, let activeDocumentProvider, @@ -361,7 +389,7 @@ final class WorkspaceFeatureModel: ObservableObject { ) } - func scheduleWorkspaceSessionPersistence() { + package func scheduleWorkspaceSessionPersistence() { workspaceSessionPersistenceTask?.cancel() workspaceSessionPersistenceTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .milliseconds(150)) @@ -370,28 +398,28 @@ final class WorkspaceFeatureModel: ObservableObject { } } - func requestCreateFile(in directory: URL) { + package func requestCreateFile(in directory: URL) { guard !isPerformingProjectItemOperation, isWorkspaceURL(directory) else { return } projectItemEditRequest = ProjectItemEditRequest(kind: .createFile, targetURL: directory) } - func requestCreateDirectory(in directory: URL) { + package func requestCreateDirectory(in directory: URL) { guard !isPerformingProjectItemOperation, isWorkspaceURL(directory) else { return } projectItemEditRequest = ProjectItemEditRequest(kind: .createDirectory, targetURL: directory) } - func requestRenameProjectItem(at url: URL) { + package func requestRenameProjectItem(at url: URL) { guard !isPerformingProjectItemOperation, isWorkspaceURL(url), url.standardizedFileURL != workspaceURL?.standardizedFileURL else { return } projectItemEditRequest = ProjectItemEditRequest(kind: .rename, targetURL: url) } - func cancelProjectItemEdit() { + package func cancelProjectItemEdit() { projectItemEditRequest = nil } - func performProjectItemEdit(named rawName: String) async { + package func performProjectItemEdit(named rawName: String) async { guard let request = projectItemEditRequest else { return } let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines) guard isValidProjectItemName(name) else { @@ -461,7 +489,7 @@ final class WorkspaceFeatureModel: ObservableObject { if request.kind == .createFile { openFile?(destination) } } - func duplicateProjectItem(at sourceURL: URL) async { + package func duplicateProjectItem(at sourceURL: URL) async { guard !isPerformingProjectItemOperation, isWorkspaceURL(sourceURL), sourceURL.standardizedFileURL != workspaceURL?.standardizedFileURL else { return } @@ -485,7 +513,7 @@ final class WorkspaceFeatureModel: ObservableObject { } } - func requestDeleteProjectItem(at url: URL, isDirectory: Bool) { + package func requestDeleteProjectItem(at url: URL, isDirectory: Bool) { guard !isPerformingProjectItemOperation, isWorkspaceURL(url), url.standardizedFileURL != workspaceURL?.standardizedFileURL else { return } @@ -496,11 +524,11 @@ final class WorkspaceFeatureModel: ObservableObject { pendingProjectItemDeletion = ProjectItemDeletionRequest(url: url, isDirectory: isDirectory) } - func cancelProjectItemDeletion() { + package func cancelProjectItemDeletion() { pendingProjectItemDeletion = nil } - func confirmProjectItemDeletion() async { + package func confirmProjectItemDeletion() async { guard let request = pendingProjectItemDeletion else { return } pendingProjectItemDeletion = nil isPerformingProjectItemOperation = true @@ -524,7 +552,7 @@ final class WorkspaceFeatureModel: ObservableObject { await refreshCurrent() } - func readFile(at workspaceURL: URL, relativePath: String) async -> String? { + package func readFile(at workspaceURL: URL, relativePath: String) async -> String? { let operations = self.operations return await Task.detached(priority: .userInitiated) { operations.readFile(at: workspaceURL, relativePath: relativePath) @@ -737,24 +765,11 @@ final class WorkspaceFeatureModel: ObservableObject { } private func scheduleSearchIndexWarm(at workspaceURL: URL, rules: FileVisibilityRules) { - let previousTask = searchIndexTask - previousTask?.cancel() - let operations = self.operations - searchIndexTask = Task.detached(priority: .utility) { - await previousTask?.value - guard !Task.isCancelled else { return } - operations.warmSearchIndex(at: workspaceURL, visibilityRules: rules) - } + warmSearchIndex?(workspaceURL, rules) } private func scheduleSearchIndexInvalidation(at workspaceURL: URL, rules: FileVisibilityRules) { - let previousTask = searchIndexTask - previousTask?.cancel() - let operations = self.operations - searchIndexTask = Task.detached(priority: .utility) { - await previousTask?.value - operations.invalidateSearchIndex(at: workspaceURL, visibilityRules: rules) - } + invalidateSearchIndex?(workspaceURL, rules) } private func updateSearchIndex( @@ -763,20 +778,7 @@ final class WorkspaceFeatureModel: ObservableObject { rules: FileVisibilityRules ) async { guard !changedPaths.isEmpty else { return } - let previousTask = searchIndexTask - previousTask?.cancel() - let operations = self.operations - let task = Task.detached(priority: .utility) { - await previousTask?.value - guard !Task.isCancelled else { return } - operations.updateSearchIndex( - at: workspaceURL, - changedPaths: changedPaths, - visibilityRules: rules - ) - } - searchIndexTask = task - await task.value + await updateSearchIndex?(workspaceURL, changedPaths, rules) } private func isWorkspaceURL(_ url: URL) -> Bool { diff --git a/Sources/LitheWorkspaceModule/Module/WorkspaceModule.swift b/Sources/LitheWorkspaceModule/Module/WorkspaceModule.swift new file mode 100644 index 00000000..c1e71bb5 --- /dev/null +++ b/Sources/LitheWorkspaceModule/Module/WorkspaceModule.swift @@ -0,0 +1,58 @@ +import Foundation +import LitheModuleAPI + +@MainActor +package protocol WorkspaceResourceGraph: AnyObject { + var hasActiveResources: Bool { get } + var feature: WorkspaceFeatureModel? { get } + func attach(workspaceProjection: WorkspaceFeatureModel) + func stop() async +} + +@MainActor +public final class WorkspaceFoundationCapability: NSObject { + private let graph: any WorkspaceResourceGraph + fileprivate init(graph: any WorkspaceResourceGraph) { self.graph = graph } + package var feature: WorkspaceFeatureModel? { graph.feature } + package func attach(workspaceProjection: WorkspaceFeatureModel) { + graph.attach(workspaceProjection: workspaceProjection) + } +} + +@MainActor +public final class WorkspaceFoundationModule: LitheModule { + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .workspace)! + public let manifest = moduleManifest + private let makeGraph: @MainActor () -> any WorkspaceResourceGraph + private var graph: (any WorkspaceResourceGraph)? + private var capability: WorkspaceFoundationCapability? + + package init(makeGraph: @escaping @MainActor () -> any WorkspaceResourceGraph) { self.makeGraph = makeGraph } + public func activate(context: ModuleContext) async throws { + guard graph == nil else { return } + let graph = makeGraph() + context.resources.register(WorkspaceGraphResource(graph: graph)) + self.graph = graph + capability = WorkspaceFoundationCapability(graph: graph) + } + public func prepareForSleep() async throws {} + public func sleep() async { await releaseGraph() } + public func shutdown() async { await releaseGraph() } + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.workspaceFoundation: capability] + } + private func releaseGraph() async { + await graph?.stop() + capability = nil + graph = nil + } +} + +@MainActor private final class WorkspaceGraphResource: ModuleResource { + let moduleResourceKind = "workspace-watchers-and-tasks" + private let graph: any WorkspaceResourceGraph + init(graph: any WorkspaceResourceGraph) { self.graph = graph } + var isModuleResourceActive: Bool { graph.hasActiveResources } + func stopModuleResource() async { await graph.stop() } +} diff --git a/Tests/LitheAIAssistanceModuleTests/AIAssistanceModuleTests.swift b/Tests/LitheAIAssistanceModuleTests/AIAssistanceModuleTests.swift new file mode 100644 index 00000000..4a282ec4 --- /dev/null +++ b/Tests/LitheAIAssistanceModuleTests/AIAssistanceModuleTests.swift @@ -0,0 +1,85 @@ +import Foundation +import LitheAIAssistanceModule +import LitheApplicationKernel +import LitheCoreContracts +import LitheModuleAPI +import Testing + +@MainActor +struct AIAssistanceModuleTests { + @Test + func disabledModuleDoesNotConstructFactoryOrTransport() async throws { + let recorder = FactoryRecorder() + let runtime = ModuleRuntime() + try runtime.register(ModuleFactory(manifest: AIAssistanceModule.moduleManifest, contributions: AIAssistanceModule.moduleContributions) { + recorder.moduleFactoryCalls += 1 + return AIAssistanceModule( + transportFactory: { + recorder.transportFactoryCalls += 1 + return TestTransport() + }, + credentialResolver: TestCredentialResolver() + ) + }) + + await #expect(throws: ModuleRuntimeError.moduleDisabled(.aiAssistance)) { + _ = try await runtime.activateCapability(.aiCommitMessage) + } + #expect(recorder.moduleFactoryCalls == 0) + #expect(recorder.transportFactoryCalls == 0) + #expect(try !runtime.snapshot(for: .aiAssistance).isInstantiated) + } + + @Test + func sleepReleasesCapabilityAndWakeReconstructsServiceGraph() async throws { + let recorder = FactoryRecorder() + let runtime = ModuleRuntime() + try runtime.register(ModuleFactory(manifest: AIAssistanceModule.moduleManifest, contributions: AIAssistanceModule.moduleContributions) { + recorder.moduleFactoryCalls += 1 + return AIAssistanceModule( + transportFactory: { + recorder.transportFactoryCalls += 1 + return TestTransport() + }, + credentialResolver: TestCredentialResolver() + ) + }) + try await runtime.setEnabled(true, for: .aiAssistance) + + var first: AIAssistanceCapability? = try #require( + try await runtime.activateCapability(.aiCommitMessage) as? AIAssistanceCapability + ) + weak var releasedCapability = first + #expect(recorder.moduleFactoryCalls == 1) + #expect(recorder.transportFactoryCalls == 1) + first = nil + + try await runtime.sleep(.aiAssistance) + #expect(releasedCapability == nil) + #expect(runtime.capability(.aiCommitMessage) == nil) + #expect(try runtime.snapshot(for: .aiAssistance).activity.activeResourceCount == 0) + + let second = try #require( + try await runtime.activateCapability(.aiCommitMessage) as? AIAssistanceCapability + ) + #expect(second !== releasedCapability) + #expect(recorder.moduleFactoryCalls == 2) + #expect(recorder.transportFactoryCalls == 2) + } +} + +@MainActor +private final class FactoryRecorder { + var moduleFactoryCalls = 0 + var transportFactoryCalls = 0 +} + +private struct TestCredentialResolver: AIProviderCredentialResolver { + func readAPIKey(for provider: AIProviderProfile) -> String? { nil } +} + +private struct TestTransport: AIHTTPTransport { + func send(_ request: AIHTTPRequest) async throws -> AIHTTPResponse { + AIHTTPResponse(statusCode: 200, body: Data(#"{"output_text":"test"}"#.utf8)) + } +} diff --git a/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift b/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift new file mode 100644 index 00000000..5ee42fbf --- /dev/null +++ b/Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift @@ -0,0 +1,960 @@ +import Foundation +import LitheApplicationKernel +import LitheModuleAPI +import Testing + +@MainActor +struct ModuleRuntimeTests { + @Test + func disabledModuleDoesNotInvokeFactory() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + try runtime.register( + testFactory( + id: .database, + defaultState: .disabled, + recorder: recorder + ) + ) + + await #expect(throws: ModuleRuntimeError.moduleDisabled(.database)) { + _ = try await runtime.activate(.database) + } + #expect(recorder.factoryCalls == []) + #expect(try runtime.snapshot(for: .database).state == .disabled) + #expect(try !runtime.snapshot(for: .database).isInstantiated) + } + + @Test + func interruptedActivationIsQuarantinedWithoutInvokingFactory() async throws { + let recorder = ModuleTestRecorder() + let recoveryStore = TestModuleRecoveryStore(pendingActivation: .search) + let runtime = ModuleRuntime(recoveryStore: recoveryStore) + try runtime.register(testFactory(id: .search, recorder: recorder)) + + await #expect(throws: ModuleRuntimeError.moduleQuarantined(.search)) { + _ = try await runtime.activate(.search) + } + + let snapshot = try runtime.snapshot(for: .search) + #expect(snapshot.state == .disabled) + #expect(snapshot.isQuarantined) + #expect(!snapshot.isInstantiated) + #expect(recorder.factoryCalls.isEmpty) + #expect(recoveryStore.pendingActivation() == nil) + } + + @Test + func everyInterruptedConcurrentActivationIsQuarantined() async throws { + let recorder = ModuleTestRecorder() + let recoveryStore = TestModuleRecoveryStore( + pendingActivations: [.search, .localHistory] + ) + let runtime = ModuleRuntime(recoveryStore: recoveryStore) + try runtime.register(testFactory(id: .search, recorder: recorder)) + try runtime.register(testFactory(id: .localHistory, recorder: recorder)) + + await #expect(throws: ModuleRuntimeError.moduleQuarantined(.search)) { + _ = try await runtime.activate(.search) + } + await #expect(throws: ModuleRuntimeError.moduleQuarantined(.localHistory)) { + _ = try await runtime.activate(.localHistory) + } + + #expect(recoveryStore.pendingActivations().isEmpty) + #expect(recoveryStore.isQuarantined(.search)) + #expect(recoveryStore.isQuarantined(.localHistory)) + #expect(recorder.factoryCalls.isEmpty) + } + + @Test + func quarantinedModuleCanBeExplicitlyReEnabled() async throws { + let recorder = ModuleTestRecorder() + let recoveryStore = TestModuleRecoveryStore(pendingActivation: .search) + let runtime = ModuleRuntime(recoveryStore: recoveryStore) + try runtime.register(testFactory(id: .search, recorder: recorder)) + + try await runtime.setEnabled(true, for: .search) + _ = try await runtime.activate(.search) + + let snapshot = try runtime.snapshot(for: .search) + #expect(snapshot.state == .active) + #expect(!snapshot.isQuarantined) + #expect(recorder.factoryCalls == [.search]) + #expect(!recoveryStore.isQuarantined(.search)) + #expect(recoveryStore.pendingActivation() == nil) + } + + @Test + func safeModeStartsRequiredModuleWithoutInvokingOptionalFactory() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime(launchMode: .safeMode) + let requiredManifest = ModuleManifest( + id: .workspace, + displayName: "Workspace", + scope: .workspace, + activationPolicy: .eager, + isRequired: true + ) + try runtime.register(ModuleFactory(manifest: requiredManifest) { + recorder.factoryCalls.append(.workspace) + return TestModule(manifest: requiredManifest, recorder: recorder) + }) + try runtime.register(testFactory(id: .search, recorder: recorder)) + + try await runtime.startEagerModules() + await #expect(throws: ModuleRuntimeError.optionalModuleUnavailableInSafeMode(.search)) { + _ = try await runtime.activate(.search) + } + + #expect(recorder.factoryCalls == [.workspace]) + #expect(try runtime.snapshot(for: .workspace).state == .active) + #expect(try runtime.snapshot(for: .search).isSuppressedBySafeMode) + #expect(try !runtime.snapshot(for: .search).isInstantiated) + } + + @Test + func optionalActivationMarkerWrapsFactoryAndClearsAfterSuccess() async throws { + let recorder = ModuleTestRecorder() + let recoveryStore = TestModuleRecoveryStore() + let runtime = ModuleRuntime(recoveryStore: recoveryStore) + let manifest = ModuleManifest(id: .search, displayName: "Search", scope: .workspace) + try runtime.register(ModuleFactory(manifest: manifest) { + #expect(recoveryStore.pendingActivation() == .search) + recorder.factoryCalls.append(.search) + return TestModule(manifest: manifest, recorder: recorder) + }) + + _ = try await runtime.activate(.search) + + #expect(recoveryStore.pendingActivation() == nil) + } + + @Test + func dependenciesActivateBeforeDependentModule() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + try runtime.register(testFactory(id: .workspace, recorder: recorder)) + try runtime.register(testFactory( + id: .search, + dependencies: [.module(.workspace)], + recorder: recorder + )) + + _ = try await runtime.activate(.search) + + #expect(recorder.activationOrder == [.workspace, .search]) + #expect(try runtime.snapshot(for: .workspace).state == .active) + #expect(try runtime.snapshot(for: .search).state == .active) + } + + @Test + func capabilityDependencyActivatesItsProvider() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let capability = ModuleCapabilityID("test.workspace") + try runtime.register(testFactory( + id: .workspace, + capabilities: [capability], + recorder: recorder + )) + try runtime.register(testFactory( + id: .git, + dependencies: [.capability(capability)], + recorder: recorder + )) + + _ = try await runtime.activate(.git) + + #expect(recorder.activationOrder == [.workspace, .git]) + #expect(runtime.capability(capability) != nil) + } + + @Test + func activeLeasePreventsSleepWithObservableReason() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + try runtime.register(testFactory(id: .terminal, recorder: recorder)) + let module = try #require(try await runtime.activate(.terminal) as? TestModule) + let lease = try #require(module.lease) + + await #expect(throws: ModuleRuntimeError.activeLeasesPreventSleep( + module: .terminal, + reasons: ["foreground command"] + )) { + try await runtime.sleep(.terminal) + } + #expect(try runtime.snapshot(for: .terminal).state == .sleepBlocked(reason: "foreground command")) + + lease.release() + try await runtime.sleep(.terminal) + #expect(try runtime.snapshot(for: .terminal).state == .sleeping) + } + + @Test + func activeDependentPreventsProviderSleepUntilDependentStops() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + try runtime.register(testFactory(id: .languageIntelligence, recorder: recorder)) + try runtime.register(testFactory( + id: .debug, + dependencies: [.module(.languageIntelligence)], + recorder: recorder + )) + _ = try await runtime.activate(.debug) + + await #expect(throws: ModuleRuntimeError.activeDependentsPreventSleep( + module: .languageIntelligence, + dependents: [.debug] + )) { + try await runtime.sleep(.languageIntelligence) + } + #expect(try runtime.snapshot(for: .languageIntelligence).state == .sleepBlocked( + reason: "Active dependents: dev.lithe.debug" + )) + + try await runtime.sleep(.debug) + try await runtime.sleep(.languageIntelligence) + #expect(try runtime.snapshot(for: .languageIntelligence).state == .sleeping) + } + + @Test + func sleepStopsResourcesReleasesInstanceAndWakeReconstructsIt() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + try runtime.register(testFactory(id: .database, recorder: recorder)) + + var first: TestModule? = try #require(try await runtime.activate(.database) as? TestModule) + weak var weakFirst = first + #expect(first?.resource?.isModuleResourceActive == true) + first = nil + try await runtime.sleep(.database) + + #expect(recorder.resourceStops == [.database]) + #expect(try runtime.snapshot(for: .database).state == .sleeping) + #expect(try !runtime.snapshot(for: .database).isInstantiated) + #expect(weakFirst == nil) + + let second = try #require(try await runtime.activate(.database) as? TestModule) + #expect(recorder.factoryCalls == [.database, .database]) + #expect(second !== weakFirst) + #expect(try runtime.snapshot(for: .database).state == .active) + } + + @Test + func activeResourceCannotUnregisterBeforeItStops() async { + let recorder = ModuleTestRecorder() + let scope = ModuleResourceScope(moduleID: .database) + let resource = TestResource(moduleID: .database, recorder: recorder) + let resourceID = scope.register(resource) + + scope.unregisterResource(id: resourceID) + #expect(scope.resourceSnapshots().count == 1) + #expect(scope.activity.activeResourceCount == 1) + + await scope.stopAllResources() + scope.unregisterResource(id: resourceID) + #expect(scope.resourceSnapshots().isEmpty) + #expect(scope.activity.activeResourceCount == 0) + } + + @Test + func shutdownAllStopsEveryInstantiatedModule() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + try runtime.register(testFactory(id: .terminal, recorder: recorder)) + try runtime.register(testFactory(id: .database, recorder: recorder)) + _ = try await runtime.activate(.terminal) + _ = try await runtime.activate(.database) + + await runtime.shutdownAll() + + #expect(Set(recorder.shutdowns) == Set([.terminal, .database])) + #expect(runtime.snapshots().allSatisfy { !$0.isInstantiated }) + #expect(runtime.snapshots().allSatisfy { $0.activity.activeResourceCount == 0 }) + } + + @Test + func duplicateCapabilityProvidersFailGraphValidation() throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let capability = ModuleCapabilityID("test.duplicate") + try runtime.register(testFactory(id: .git, capabilities: [capability], recorder: recorder)) + try runtime.register(testFactory(id: .search, capabilities: [capability], recorder: recorder)) + + #expect(throws: ModuleRuntimeError.capabilityCollision( + capability: capability, + providers: [.git, .search] + )) { + try runtime.validateGraph() + } + } + + @Test + func builtInRegistryAcceptsTheCanonicalManifestCatalog() throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let registry = ModuleRegistry(runtime: runtime) + + let manifests = BuiltInPluginCatalog.manifests + .flatMap(\.modules) + .map(\.manifest) + for manifest in manifests { + try registry.register(ModuleFactory( + manifest: manifest, + contributions: BuiltInModuleCatalog.contributions(for: manifest.id) + ) { + TestModule(manifest: manifest, recorder: recorder) + }) + } + + try registry.validate() + #expect(registry.registeredModuleIDs == manifests.map(\.id).sorted()) + } + + @Test + func builtInRegistryRejectsManifestDrift() throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let registry = ModuleRegistry(runtime: runtime) + + let manifests = BuiltInPluginCatalog.manifests + .flatMap(\.modules) + .map(\.manifest) + for manifest in manifests { + let registeredManifest: ModuleManifest + if manifest.id == .database { + registeredManifest = ModuleManifest( + id: manifest.id, + displayName: manifest.displayName, + scope: manifest.scope, + defaultState: .enabled, + activationPolicy: manifest.activationPolicy, + sleepPolicy: manifest.sleepPolicy, + dependencies: manifest.dependencies, + providedCapabilities: manifest.providedCapabilities, + isRequired: manifest.isRequired + ) + } else { + registeredManifest = manifest + } + try registry.register(ModuleFactory( + manifest: registeredManifest, + contributions: BuiltInModuleCatalog.contributions(for: manifest.id) + ) { + TestModule(manifest: registeredManifest, recorder: recorder) + }) + } + + #expect(throws: PluginCatalogError.moduleFactoryMismatch( + plugin: BuiltInPluginCatalog.manifest(forModule: .database)!.id, + module: .database + )) { + try registry.validate() + } + } + + @Test + func registryAllowsAnUninstalledOptionalOfficialPlugin() throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let workspacePlugin = try #require(BuiltInPluginCatalog.manifest(forModule: .workspace)) + let registry = ModuleRegistry(runtime: runtime, pluginManifests: [workspacePlugin]) + let workspace = try #require(BuiltInModuleCatalog.manifest(for: .workspace)) + try registry.register(ModuleFactory(manifest: workspace) { + TestModule(manifest: workspace, recorder: recorder) + }) + + try registry.validate() + + #expect(registry.registeredModuleIDs == [.workspace]) + #expect(recorder.factoryCalls.isEmpty) + } + + @Test + func staticPluginManifestsRoundTripWithoutInvokingFactories() throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(BuiltInPluginCatalog.manifests) + let decoded = try JSONDecoder().decode([PluginManifest].self, from: data) + + let catalog = try ValidatedPluginCatalog( + manifests: decoded, + hostVersion: BuiltInPluginCatalog.hostVersion + ) + + #expect(decoded == BuiltInPluginCatalog.manifests) + #expect(catalog.modules.keys.sorted() == BuiltInModuleCatalog.ids) + } + + @Test + func aiAssistanceIsBuiltInRatherThanAnOfficialDownload() { + #expect(BuiltInPluginCatalog.manifest(forModule: .aiAssistance) != nil) + #expect(OfficialPluginCatalog.manifest(forModule: .aiAssistance) == nil) + } + + @Test + func languageSupportManifestBindsIndependentModulesWithoutLoadingCode() throws { + let lsp = ModuleManifest( + id: ModuleID("dev.example.go.language-server"), + displayName: "Go Language Server", + scope: .workspace + ) + let execution = ModuleManifest( + id: ModuleID("dev.example.go.execution"), + displayName: "Go Execution", + scope: .workspace + ) + let manifest = PluginManifest( + id: PluginID("dev.example.go-support"), + displayName: "Go Support", + version: BuiltInPluginCatalog.hostVersion, + hostCompatibility: PluginHostCompatibility( + minimum: BuiltInPluginCatalog.hostVersion, + maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) + ), + vendor: BuiltInPluginCatalog.vendor, + entrypoint: .builtIn(targetName: "ExampleGoSupport"), + modules: [ + PluginModuleDeclaration(manifest: lsp), + PluginModuleDeclaration(manifest: execution) + ], + languageSupports: [LanguageSupportDeclaration( + id: "go", + displayName: "Go", + fileExtensions: [".GO", "go"], + projectFileNames: ["go.mod"], + languageServerModuleID: lsp.id, + executionModuleID: execution.id, + testingModuleID: execution.id + )] + ) + + let catalog = try ValidatedPluginCatalog( + manifests: [manifest], + hostVersion: BuiltInPluginCatalog.hostVersion + ) + let support = try #require(catalog.manifests.first?.languageSupports?.first) + #expect(support.fileExtensions == ["go"]) + #expect(support.projectFileNames == ["go.mod"]) + #expect(support.languageServerModuleID != support.executionModuleID) + #expect(support.testingModuleID == support.executionModuleID) + #expect(catalog.languageSupport(for: URL(fileURLWithPath: "/workspace/main.go"))?.pluginID == manifest.id) + #expect(catalog.languageSupports( + recognizingProjectFileNames: ["README.md", "go.mod"] + ).map(\.pluginID) == [manifest.id]) + } + + @Test + func languageSupportCannotReferenceAnotherPluginsModule() throws { + let owned = ModuleManifest( + id: ModuleID("dev.example.go.language-server"), + displayName: "Go Language Server", + scope: .workspace + ) + let foreignModuleID = ModuleID("dev.example.foreign.execution") + let manifest = PluginManifest( + id: PluginID("dev.example.go-support"), + displayName: "Go Support", + version: BuiltInPluginCatalog.hostVersion, + hostCompatibility: PluginHostCompatibility(minimum: BuiltInPluginCatalog.hostVersion), + vendor: BuiltInPluginCatalog.vendor, + entrypoint: .builtIn(targetName: "ExampleGoSupport"), + modules: [PluginModuleDeclaration(manifest: owned)], + languageSupports: [LanguageSupportDeclaration( + id: "go", + displayName: "Go", + projectFileNames: ["go.mod"], + languageServerModuleID: owned.id, + executionModuleID: foreignModuleID + )] + ) + + #expect(throws: PluginCatalogError.invalidLanguageSupport( + plugin: manifest.id, + languageID: "go" + )) { + _ = try ValidatedPluginCatalog( + manifests: [manifest], + hostVersion: BuiltInPluginCatalog.hostVersion + ) + } + } + + @Test + func incompatiblePluginIsRejectedBeforeFactoryRegistration() throws { + let workspacePlugin = try #require(BuiltInPluginCatalog.manifest(forModule: .workspace)) + let incompatible = PluginManifest( + id: workspacePlugin.id, + displayName: workspacePlugin.displayName, + version: workspacePlugin.version, + hostCompatibility: PluginHostCompatibility( + minimum: PluginVersion(major: 1, minor: 0, patch: 0) + ), + vendor: workspacePlugin.vendor, + entrypoint: workspacePlugin.entrypoint, + modules: workspacePlugin.modules + ) + + #expect(throws: PluginCatalogError.incompatibleHost( + plugin: incompatible.id, + hostVersion: BuiltInPluginCatalog.hostVersion + )) { + _ = try ValidatedPluginCatalog( + manifests: [incompatible], + hostVersion: BuiltInPluginCatalog.hostVersion + ) + } + } + + @Test + func failedActivationStopsResourcesAndReleasesInstance() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let manifest = ModuleManifest(id: .search, displayName: "Search", scope: .workspace) + var module: FailingActivationModule? = FailingActivationModule( + manifest: manifest, + recorder: recorder + ) + weak var weakModule = module + try runtime.register(ModuleFactory(manifest: manifest) { + try #require(module) + }) + + await #expect(throws: TestActivationError.failed) { + _ = try await runtime.activate(.search) + } + module = nil + + #expect(recorder.shutdowns == [.search]) + #expect(recorder.resourceStops == [.search]) + #expect(try runtime.snapshot(for: .search).activity.activeResourceCount == 0) + #expect(try !runtime.snapshot(for: .search).isInstantiated) + #expect(weakModule == nil) + } + + @Test + func missingDeclaredCapabilityRollsBackActivation() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let capability = ModuleCapabilityID("test.missing-export") + let manifest = ModuleManifest( + id: .search, + displayName: "Search", + scope: .workspace, + providedCapabilities: [capability] + ) + try runtime.register(ModuleFactory(manifest: manifest) { + MissingCapabilityModule(manifest: manifest, recorder: recorder) + }) + + await #expect(throws: ModuleRuntimeError.missingExportedCapability( + module: .search, + capability: capability + )) { + _ = try await runtime.activate(.search) + } + + #expect(recorder.shutdowns == [.search]) + #expect(recorder.resourceStops == [.search]) + #expect(runtime.capability(capability) == nil) + #expect(try runtime.snapshot(for: .search).activity.activeResourceCount == 0) + #expect(try !runtime.snapshot(for: .search).isInstantiated) + } + + @Test + func dependencyCyclesFailGraphValidation() throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + try runtime.register(testFactory( + id: .git, + dependencies: [.module(.search)], + recorder: recorder + )) + try runtime.register(testFactory( + id: .search, + dependencies: [.module(.git)], + recorder: recorder + )) + + #expect(throws: ModuleRuntimeError.dependencyCycle([.git, .search, .git])) { + try runtime.validateGraph() + } + } + + @Test + func requiredModuleCannotBeDisabled() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let manifest = ModuleManifest( + id: .workspace, + displayName: "Workspace", + scope: .workspace, + isRequired: true + ) + try runtime.register(ModuleFactory(manifest: manifest) { + TestModule(manifest: manifest, recorder: recorder) + }) + + await #expect(throws: ModuleRuntimeError.requiredModuleCannotBeDisabled(.workspace)) { + try await runtime.setEnabled(false, for: .workspace) + } + #expect(try runtime.snapshot(for: .workspace).state == .inactive) + } + + @Test + func enabledDependentPreventsProviderDisable() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + try runtime.register(testFactory(id: .workspace, recorder: recorder)) + try runtime.register(testFactory( + id: .search, + dependencies: [.module(.workspace)], + recorder: recorder + )) + + await #expect(throws: ModuleRuntimeError.enabledDependentsPreventDisable( + module: .workspace, + dependents: [.search] + )) { + try await runtime.setEnabled(false, for: .workspace) + } + } + + @Test + func contributionsExistOnlyWhileModuleIsActive() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let contribution = ModuleContribution( + id: "test.tool-window", + kind: .toolWindow, + title: "Test" + ) + let manifest = ModuleManifest(id: .search, displayName: "Search", scope: .workspace) + try runtime.register(ModuleFactory(manifest: manifest, contributions: [contribution]) { + TestModule(manifest: manifest, recorder: recorder, contributions: [contribution]) + }) + + #expect(runtime.contributions().isEmpty) + _ = try await runtime.activate(.search) + #expect(runtime.contributions()[.search] == [contribution]) + try await runtime.sleep(.search) + #expect(runtime.contributions().isEmpty) + } + + @Test + func availableContributionDoesNotInstantiateAnOnDemandModule() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let contribution = ModuleContribution( + id: "test.lazy-tool-window", + kind: .toolWindow, + title: "Lazy", + actionID: "test.activate", + rendererID: "test.lazy" + ) + let manifest = ModuleManifest(id: .search, displayName: "Search", scope: .workspace) + try runtime.register(ModuleFactory( + manifest: manifest, + contributions: [contribution] + ) { + recorder.factoryCalls.append(.search) + return TestModule(manifest: manifest, recorder: recorder) + }) + + #expect(runtime.availableContributions()[.search] == [contribution]) + #expect(recorder.factoryCalls.isEmpty) + #expect(try !runtime.snapshot(for: .search).isInstantiated) + + try await runtime.setEnabled(false, for: .search) + #expect(runtime.availableContributions()[.search] == nil) + #expect(recorder.factoryCalls.isEmpty) + } + + @Test + func idleModuleWithoutResourcesSleepsAfterItsPolicyInterval() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let manifest = ModuleManifest( + id: .search, + displayName: "Search", + scope: .workspace, + sleepPolicy: .whenIdle(afterSeconds: 60) + ) + try runtime.register(ModuleFactory(manifest: manifest) { + TestModule(manifest: manifest, recorder: recorder, registersResource: false) + }) + _ = try await runtime.activate(.search) + try runtime.markIdle(.search) + let lastActivity = try #require(try runtime.snapshot(for: .search).activity.lastActivityAt) + + await runtime.evaluateIdleModules(now: lastActivity.addingTimeInterval(59)) + #expect(try runtime.snapshot(for: .search).state == .idle) + await runtime.evaluateIdleModules(now: lastActivity.addingTimeInterval(61)) + #expect(try runtime.snapshot(for: .search).state == .sleeping) + } + + @Test + func disablingActiveModuleReleasesInstanceCapabilityAndContribution() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let capability = ModuleCapabilityID("test.disable") + let contribution = ModuleContribution(id: "test.disable.tool", kind: .toolWindow, title: "Test") + let manifest = ModuleManifest( + id: .search, + displayName: "Search", + scope: .workspace, + providedCapabilities: [capability] + ) + try runtime.register(ModuleFactory(manifest: manifest, contributions: [contribution]) { + recorder.factoryCalls.append(.search) + return TestModule(manifest: manifest, recorder: recorder, contributions: [contribution]) + }) + _ = try await runtime.activateCapability(capability) + + try await runtime.setEnabled(false, for: .search) + + #expect(try runtime.snapshot(for: .search).state == .disabled) + #expect(try !runtime.snapshot(for: .search).isInstantiated) + #expect(runtime.capability(capability) == nil) + #expect(runtime.contributions().isEmpty) + await #expect(throws: ModuleRuntimeError.moduleDisabled(.search)) { + _ = try await runtime.activateCapability(capability) + } + #expect(recorder.factoryCalls == [.search]) + } + + @Test + func instanceContributionDriftRollsBackActivation() async throws { + let recorder = ModuleTestRecorder() + let runtime = ModuleRuntime() + let staticContribution = ModuleContribution( + id: "test.static", + kind: .toolWindow, + title: "Static" + ) + let instanceContribution = ModuleContribution( + id: "test.instance", + kind: .toolWindow, + title: "Instance" + ) + let manifest = ModuleManifest(id: .search, displayName: "Search", scope: .workspace) + try runtime.register(ModuleFactory( + manifest: manifest, + contributions: [staticContribution] + ) { + recorder.factoryCalls.append(.search) + return TestModule( + manifest: manifest, + recorder: recorder, + contributions: [instanceContribution] + ) + }) + + await #expect(throws: ModuleRuntimeError.contributionCatalogMismatch(.search)) { + _ = try await runtime.activate(.search) + } + + #expect(recorder.shutdowns == [.search]) + #expect(recorder.resourceStops == [.search]) + #expect(runtime.contributions().isEmpty) + #expect(try !runtime.snapshot(for: .search).isInstantiated) + #expect(try runtime.snapshot(for: .search).activity.activeResourceCount == 0) + } + + private func testFactory( + id: ModuleID, + defaultState: ModuleDefaultState = .enabled, + dependencies: Set = [], + capabilities: Set = [], + recorder: ModuleTestRecorder + ) -> ModuleFactory { + let manifest = ModuleManifest( + id: id, + displayName: id.rawValue, + scope: .workspace, + defaultState: defaultState, + dependencies: dependencies, + providedCapabilities: capabilities + ) + return ModuleFactory(manifest: manifest) { + recorder.factoryCalls.append(id) + return TestModule(manifest: manifest, recorder: recorder) + } + } +} + +@MainActor +private final class ModuleTestRecorder { + var factoryCalls: [ModuleID] = [] + var activationOrder: [ModuleID] = [] + var shutdowns: [ModuleID] = [] + var resourceStops: [ModuleID] = [] +} + +private final class TestModuleRecoveryStore: ModuleRecoveryStore, @unchecked Sendable { + private let lock = NSLock() + private var pending: Set + private var quarantined: Set = [] + + init(pendingActivation: ModuleID? = nil) { + pending = Set(pendingActivation.map { [$0] } ?? []) + } + + init(pendingActivations: [ModuleID]) { + pending = Set(pendingActivations) + } + + func pendingActivation() -> ModuleID? { + lock.lock(); defer { lock.unlock() } + return pending.sorted().first + } + + func setPendingActivation(_ moduleID: ModuleID?) { + lock.lock(); defer { lock.unlock() } + pending = Set(moduleID.map { [$0] } ?? []) + } + + func pendingActivations() -> [ModuleID] { + lock.lock(); defer { lock.unlock() } + return pending.sorted() + } + + func setPendingActivations(_ moduleIDs: [ModuleID]) { + lock.lock(); defer { lock.unlock() } + pending = Set(moduleIDs) + } + + func isQuarantined(_ moduleID: ModuleID) -> Bool { + lock.lock(); defer { lock.unlock() } + return quarantined.contains(moduleID) + } + + func setQuarantined(_ isQuarantined: Bool, for moduleID: ModuleID) { + lock.lock(); defer { lock.unlock() } + if isQuarantined { + quarantined.insert(moduleID) + } else { + quarantined.remove(moduleID) + } + } +} + +@MainActor +private final class TestModule: LitheModule { + let manifest: ModuleManifest + private let recorder: ModuleTestRecorder + private(set) var resource: TestResource? + private(set) var lease: ModuleLease? + private let declaredContributions: [ModuleContribution] + private let registersResource: Bool + + init( + manifest: ModuleManifest, + recorder: ModuleTestRecorder, + contributions: [ModuleContribution] = [], + registersResource: Bool = true + ) { + self.manifest = manifest + self.recorder = recorder + declaredContributions = contributions + self.registersResource = registersResource + } + + func activate(context: ModuleContext) async throws { + recorder.activationOrder.append(manifest.id) + if registersResource { + let resource = TestResource(moduleID: manifest.id, recorder: recorder) + self.resource = resource + context.resources.register(resource) + } + if manifest.id == .terminal { + lease = context.leases.acquireLease(reason: "foreground command") + } + } + + func prepareForSleep() async throws {} + + func sleep() async { + resource = nil + lease = nil + } + + func shutdown() async { + recorder.shutdowns.append(manifest.id) + lease?.release() + lease = nil + } + + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + Dictionary(uniqueKeysWithValues: manifest.providedCapabilities.map { ($0, TestCapability()) }) + } + + func contributions() -> [ModuleContribution] { declaredContributions } +} + +@MainActor +private final class TestResource: ModuleResource { + let moduleID: ModuleID + let recorder: ModuleTestRecorder + private(set) var isModuleResourceActive = true + var moduleResourceKind: String { "test.\(moduleID.rawValue)" } + + init(moduleID: ModuleID, recorder: ModuleTestRecorder) { + self.moduleID = moduleID + self.recorder = recorder + } + + func stopModuleResource() async { + guard isModuleResourceActive else { return } + isModuleResourceActive = false + recorder.resourceStops.append(moduleID) + } +} + +private final class TestCapability: @unchecked Sendable {} + +private enum TestActivationError: Error { + case failed +} + +@MainActor +private final class FailingActivationModule: LitheModule { + let manifest: ModuleManifest + private let recorder: ModuleTestRecorder + + init(manifest: ModuleManifest, recorder: ModuleTestRecorder) { + self.manifest = manifest + self.recorder = recorder + } + + func activate(context: ModuleContext) async throws { + context.resources.register(TestResource(moduleID: manifest.id, recorder: recorder)) + throw TestActivationError.failed + } + + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async { recorder.shutdowns.append(manifest.id) } + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} + +@MainActor +private final class MissingCapabilityModule: LitheModule { + let manifest: ModuleManifest + private let recorder: ModuleTestRecorder + + init(manifest: ModuleManifest, recorder: ModuleTestRecorder) { + self.manifest = manifest + self.recorder = recorder + } + + func activate(context: ModuleContext) async throws { + context.resources.register(TestResource(moduleID: manifest.id, recorder: recorder)) + } + + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async { recorder.shutdowns.append(manifest.id) } + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} diff --git a/scripts/CoreVerification.swift b/Tests/LitheCoreVerifier/main.swift similarity index 95% rename from scripts/CoreVerification.swift rename to Tests/LitheCoreVerifier/main.swift index 33cfc121..ecdc16f2 100644 --- a/scripts/CoreVerification.swift +++ b/Tests/LitheCoreVerifier/main.swift @@ -1,4 +1,7 @@ import Foundation +import LitheCoreContracts +import LitheGitModule +import LitheSearchModule @main struct CoreVerification { @@ -7,7 +10,6 @@ struct CoreVerification { verifyDiffParser() verifyVisibilityRules() verifyGitGraph() - verifyTerminalBuffer() verifyWhitespaceModes() verifySearchOptions() print("Core verification passed: shared fixtures, diff, visibility, graph, search options, and whitespace modes") @@ -211,16 +213,6 @@ struct CoreVerification { require(GitDiffWhitespaceMode.ignoreAllWhitespace.title == "Ignore whitespace", "ignore label changed") } - private static func verifyTerminalBuffer() { - var buffer = TerminalBuffer() - buffer.append("hello\nworld") - require(buffer.render(maxCharacters: 100) == "hello\nworld", "terminal text should render in order") - - buffer.reset() - buffer.append("before\u{1B}[2Jafter") - require(buffer.render(maxCharacters: 100) == "after", "terminal clear screen should reset the buffer") - } - private static func verifySearchOptions() { let standard = ProjectSearchOptions.default require(standard.matches("Hello Lithe", query: "lithe"), "default search should ignore case") diff --git a/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift b/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift new file mode 100644 index 00000000..1ed186cf --- /dev/null +++ b/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift @@ -0,0 +1,120 @@ +import Foundation +import LitheApplicationKernel +@testable import LitheDatabaseModule +import LitheModuleAPI +import Testing + +@MainActor +struct DatabaseModuleTests { + @Test + func disabledDatabaseDoesNotConstructFactoryOrPorts() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(ModuleFactory(manifest: DatabaseModule.moduleManifest, contributions: DatabaseModule.moduleContributions) { + recorder.factoryCalls += 1 + return makeModule(recorder: recorder) + }, enabled: false) + + await #expect(throws: ModuleRuntimeError.moduleDisabled(.database)) { + _ = try await runtime.activateCapability(.databaseWorkspace) + } + #expect(recorder.factoryCalls == 0) + #expect(recorder.portGraphCalls == 0) + #expect(try !runtime.snapshot(for: .database).isInstantiated) + } + + @Test + func sleepReleasesTimerFeatureAndWakeReconstructsGraph() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(ModuleFactory(manifest: DatabaseModule.moduleManifest, contributions: DatabaseModule.moduleContributions) { + recorder.factoryCalls += 1 + return makeModule(recorder: recorder) + }) + try await runtime.setEnabled(true, for: .database) + + var first: DatabaseFeatureModel? = try #require( + (try await runtime.activateCapability(.databaseWorkspace) as? DatabaseModuleCapability)?.feature + ) + weak var released = first + first = nil + try await runtime.sleep(.database) + + #expect(released == nil) + #expect(runtime.capability(.databaseWorkspace) == nil) + #expect(try runtime.snapshot(for: .database).activity.activeResourceCount == 0) + + let second = try #require( + (try await runtime.activateCapability(.databaseWorkspace) as? DatabaseModuleCapability)?.feature + ) + #expect(second !== released) + #expect(recorder.factoryCalls == 2) + #expect(recorder.portGraphCalls == 2) + } + + @Test + func releaseCancelsScheduledBackupWithoutAdvancingSchedule() async throws { + let preferences = TestPreferences() + let secrets = TestSecrets() + let store = DatabaseConnectionStore(store: preferences, secureStore: secrets) + let profile = DatabaseProfile(name: "Scheduled", kind: .sqlite, path: "/tmp/test.sqlite") + let dueAt = Date(timeIntervalSince1970: 1) + try store.save([profile]) + try store.saveBackupSchedules([ + DatabaseBackupSchedule(profileID: profile.id, nextRunAt: dueAt) + ]) + let feature = DatabaseFeatureModel( + operations: DatabaseSidecarService(processRunner: TestProcessRunner(), executableURL: nil), + connectionStore: store + ) + + feature.runScheduledBackups(now: Date(timeIntervalSince1970: 2)) + #expect(feature.hasActiveModuleWork) + + feature.prepareForModuleRelease() + #expect(!feature.hasActiveModuleWork) + await Task.yield() + #expect(feature.backupSchedules.first?.nextRunAt == dueAt) + } + + private func makeModule(recorder: Recorder) -> DatabaseModule { + recorder.portGraphCalls += 1 + return DatabaseModule( + processRunner: TestProcessRunner(), executableURL: nil, + preferenceStore: TestPreferences(), secureStore: TestSecrets(), + recoveryStore: UnavailableDatabaseRecoveryStore(), + fileStorage: UnavailableDatabaseFileStorage() + ) + } + + private func workspaceFactory() -> ModuleFactory { + ModuleFactory(manifest: ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace)) { + EmptyWorkspaceModule() + } + } +} + +@MainActor private final class Recorder { var factoryCalls = 0; var portGraphCalls = 0 } +@MainActor private final class EmptyWorkspaceModule: LitheModule { + let manifest = ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace) + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async {} + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} +private struct TestProcessRunner: DatabaseProcessRunning { + func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { DatabaseProcessResult(output: "", exitCode: 0) } +} +private final class TestPreferences: DatabasePreferenceStore, @unchecked Sendable { + private var values: [String: Data] = [:] + func data(forKey key: String) -> Data? { values[key] } + func set(_ value: Any?, forKey key: String) { values[key] = value as? Data } +} +private struct TestSecrets: DatabaseSecureStore { + func read(key: String) -> String? { nil } + func write(_ value: String, key: String) throws {} + func delete(key: String) throws {} +} diff --git a/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/Tests/LitheDebugModuleTests/DebugModuleTests.swift new file mode 100644 index 00000000..18ab259c --- /dev/null +++ b/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -0,0 +1,244 @@ +import Foundation +import LitheApplicationKernel +import LitheCoreContracts +@testable import LitheDebugModule +import LitheModuleAPI +import Testing + +@MainActor +struct DebugModuleTests { + @Test + func protocolSessionInitializesAndStopsThroughInjectedTransport() throws { + let transport = RecordingTransport() + let session = DebugAdapterProtocolSession( + adapterID: "test-adapter", + transport: transport + ) + + try session.start(rootURL: URL(fileURLWithPath: "/tmp/debug-module")) + + #expect(session.state == .initializing) + #expect(transport.isRunning) + let initialize = try #require(transport.request(named: "initialize")) + transport.emitJSON([ + "seq": 2, + "type": "response", + "request_seq": initialize["seq"] as! Int, + "success": true, + "command": "initialize", + "body": ["supportsConfigurationDoneRequest": true] + ]) + #expect(session.state == .ready) + + session.stop() + + #expect(!transport.isRunning) + #expect(session.state == .idle) + #expect(transport.stopCalls == 1) + } + + @Test + func protocolSessionCreatesAndStopsChildTransport() throws { + let parent = RecordingTransport() + let session = DebugAdapterProtocolSession(adapterID: "test-adapter", transport: parent) + let root = URL(fileURLWithPath: "/tmp/debug-child", isDirectory: true) + + try session.start(rootURL: root) + let initialize = try #require(parent.request(named: "initialize")) + parent.emitJSON([ + "seq": 2, + "type": "response", + "request_seq": initialize["seq"] as! Int, + "success": true, + "command": "initialize", + "body": [:] + ]) + parent.emitJSON([ + "seq": 3, + "type": "request", + "command": "startDebugging", + "arguments": [ + "configuration": [ + "name": "Child", + "request": "launch", + "program": root.appendingPathComponent("main.js").path + ] + ] + ]) + + let child = try #require(parent.children.first) + #expect(child.isRunning) + #expect(child.request(named: "initialize") != nil) + #expect(parent.response(to: 3)?["success"] as? Bool == true) + + session.stop() + + #expect(!child.isRunning) + #expect(child.stopCalls == 1) + } + + @Test + func disabledDebugDoesNotConstructGraph() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(languageFactory()) + try runtime.register(executionFactory()) + try runtime.register(ModuleFactory(manifest: DebugModule.moduleManifest, contributions: DebugModule.moduleContributions) { + recorder.factoryCalls += 1 + return DebugModule(makeGraph: { + recorder.graphCalls += 1 + return TestGraph() + }) + }, enabled: false) + + await #expect(throws: ModuleRuntimeError.moduleDisabled(.debug)) { + _ = try await runtime.activateCapability(.debugWorkspace) + } + #expect(recorder.factoryCalls == 0) + #expect(recorder.graphCalls == 0) + } + + @Test + func sleepReleasesDebugGraphAndWakeCreatesNewOne() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(languageFactory()) + try runtime.register(executionFactory()) + try runtime.register(ModuleFactory(manifest: DebugModule.moduleManifest, contributions: DebugModule.moduleContributions) { + recorder.factoryCalls += 1 + return DebugModule(makeGraph: { + recorder.graphCalls += 1 + let graph = TestGraph() + recorder.latestGraph = graph + return graph + }) + }) + + let first = try #require( + try await runtime.activateCapability(.debugWorkspace) as? DebugModuleCapability + ) + let firstJavaID = ObjectIdentifier(first.javaFeature) + weak var released = recorder.latestGraph + try await runtime.sleep(.debug) + + #expect(released == nil) + #expect(runtime.capability(.debugWorkspace) == nil) + #expect(try runtime.snapshot(for: .debug).activity.activeResourceCount == 0) + + let second = try #require( + try await runtime.activateCapability(.debugWorkspace) as? DebugModuleCapability + ) + #expect(ObjectIdentifier(second.javaFeature) != firstJavaID) + #expect(recorder.factoryCalls == 2) + #expect(recorder.graphCalls == 2) + } + + private func workspaceFactory() -> ModuleFactory { + ModuleFactory(manifest: ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace)) { + EmptyModule(id: .workspace, name: "Workspace") + } + } + + private func languageFactory() -> ModuleFactory { + ModuleFactory(manifest: ModuleManifest(id: .languageIntelligence, displayName: "Language", scope: .workspace)) { + EmptyModule(id: .languageIntelligence, name: "Language") + } + } + + private func executionFactory() -> ModuleFactory { + ModuleFactory(manifest: ModuleManifest(id: .execution, displayName: "Execution", scope: .workspace)) { + EmptyModule(id: .execution, name: "Execution") + } + } +} + +@MainActor +private final class RecordingTransport: DebugAdapterTransport, DebugAdapterChildTransportProviding { + private(set) var isRunning = false + var onData: ((Data) -> Void)? + var onErrorOutput: ((Data) -> Void)? + var onTermination: ((Int) -> Void)? + private(set) var sentData: [Data] = [] + private(set) var children: [RecordingTransport] = [] + private(set) var stopCalls = 0 + + func start(rootURL: URL) throws { + isRunning = true + } + + func send(_ data: Data) throws { + sentData.append(data) + } + + func stop() { + stopCalls += 1 + isRunning = false + } + + func makeChildTransport() -> (any DebugAdapterTransport)? { + let child = RecordingTransport() + children.append(child) + return child + } + + func emitJSON(_ object: [String: Any]) { + let body = try! JSONSerialization.data(withJSONObject: object) + var frame = Data("Content-Length: \(body.count)\r\n\r\n".utf8) + frame.append(body) + onData?(frame) + } + + func request(named command: String) -> [String: Any]? { + messages.first { + $0["type"] as? String == "request" && $0["command"] as? String == command + } + } + + func response(to requestSequence: Int) -> [String: Any]? { + messages.first { + $0["type"] as? String == "response" + && $0["request_seq"] as? Int == requestSequence + } + } + + private var messages: [[String: Any]] { + sentData.compactMap { data in + guard let separator = data.range(of: Data("\r\n\r\n".utf8)) else { return nil } + return try? JSONSerialization.jsonObject( + with: data.subdata(in: separator.upperBound.. [ModuleCapabilityID: AnyObject] { [:] } +} diff --git a/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift new file mode 100644 index 00000000..d4e6885f --- /dev/null +++ b/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -0,0 +1,476 @@ +import Foundation +import LitheApplicationKernel +@testable import LitheExecutionModule +import LitheCoreContracts +import LitheModuleAPI +import Testing + +@MainActor +struct ExecutionModuleTests { + @Test + func disabledExecutionDoesNotConstructGraph() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(factory(recorder: recorder), enabled: false) + + await #expect(throws: ModuleRuntimeError.moduleDisabled(.execution)) { + _ = try await runtime.activateCapability(.executionWorkspace) + } + #expect(recorder.factoryCalls == 0) + #expect(recorder.graphCalls == 0) + } + + @Test + func sleepReleasesExecutionGraphAndWakeCreatesNewServices() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(factory(recorder: recorder)) + + let first = try #require( + try await runtime.activateCapability(.executionWorkspace) as? ExecutionModuleCapability + ) + let firstRunID = ObjectIdentifier(first.runFeature) + weak var released = recorder.latestGraph + try await runtime.sleep(.execution) + + #expect(released == nil) + #expect(runtime.capability(.executionWorkspace) == nil) + #expect(try runtime.snapshot(for: .execution).activity.activeResourceCount == 0) + + let second = try #require( + try await runtime.activateCapability(.executionWorkspace) as? ExecutionModuleCapability + ) + #expect(ObjectIdentifier(second.runFeature) != firstRunID) + #expect(recorder.factoryCalls == 2) + #expect(recorder.graphCalls == 2) + } + + @Test + func currentGoFileRunsThroughExtensionOwnedSession() async throws { + let builtInProcess = TestStreamingProcess() + let extensionSession = TestLanguageExecutionSession() + let service = RunService( + runtime: TestRuntime(), + process: builtInProcess, + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: TestReadyRunConfigurationOperations(), + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback), + extensionRequiredLanguageIDs: ["go"] + ) + let support = LanguageSupportDeclaration( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + executionModuleID: .languageExecutionExtension("go") + ) + let extensionProvider = TestGoRunExtension(session: extensionSession) + #expect(service.registerLanguageRunExtension( + extensionProvider, + support: support + )) + + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let source = root.appendingPathComponent("cmd/server/main.go") + await service.loadProject(at: root, files: [source], mavenProject: nil) + service.run(configuration: .currentFile, currentFileURL: source) + + #expect(builtInProcess.startRequests.isEmpty) + #expect(extensionSession.startRequests.count == 1) + #expect(extensionSession.startRequests.first?.arguments == ["run", "cmd/server/main.go"]) + #expect(extensionSession.isRunning) + + service.stop() + #expect(!extensionSession.isRunning) + } + + @Test + func detectedGoProjectRunsThroughExtensionOwnedSession() async throws { + let builtInProcess = TestStreamingProcess() + let extensionSession = TestLanguageExecutionSession() + let service = RunService( + runtime: TestRuntime(), + process: builtInProcess, + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: TestGoProjectRunConfigurationOperations(), + executableResolver: TestExecutableResolver(), + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback), + extensionRequiredLanguageIDs: ["go"] + ) + let support = LanguageSupportDeclaration( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + projectFileNames: ["go.mod"], + executionModuleID: .languageExecutionExtension("go") + ) + let extensionProvider = TestGoRunExtension(session: extensionSession) + #expect(service.registerLanguageRunExtension(extensionProvider, support: support)) + + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + await service.loadProject( + at: root, + files: [root.appendingPathComponent("go.mod")], + mavenProject: nil + ) + let configuration = try #require( + service.configurations.first { $0.kind.providerID == "go" } + ) + service.run(configuration: configuration, currentFileURL: nil) + + #expect(builtInProcess.startRequests.isEmpty) + #expect(extensionSession.startRequests.count == 1) + #expect(extensionSession.startRequests.first?.arguments == ["run", "./cmd/api"]) + #expect(extensionSession.isRunning) + + service.stop() + service.unregisterLanguageRunExtension(languageID: "go") + service.run(configuration: configuration, currentFileURL: nil) + #expect(builtInProcess.startRequests.isEmpty) + #expect(service.output.contains("go execution extension is not active")) + } + + @Test + func goTestsRunThroughExtensionOwnedSession() throws { + let builtInProcess = TestStreamingProcess() + let extensionSession = TestLanguageExecutionSession() + let service = LanguageTestService( + catalog: .compatibilityFallback, + registry: .standard(catalog: .compatibilityFallback), + executableResolver: TestExecutableResolver(), + processFactory: { builtInProcess }, + extensionRequiredLanguageIDs: ["go"] + ) + let support = LanguageSupportDeclaration( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + projectFileNames: ["go.mod"], + executionModuleID: .languageExecutionExtension("go"), + testingModuleID: .languageExecutionExtension("go") + ) + let extensionProvider = TestGoRunExtension(session: extensionSession) + #expect(service.registerLanguageTestExtension(extensionProvider, support: support)) + + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let files = [ + root.appendingPathComponent("go.mod"), + root.appendingPathComponent("cmd/api/main_test.go") + ] + service.discover(workspaceURL: root, files: files) + #expect(service.itemsByProviderID["go"]?.map(\.id) == [ + "go:workspace", "go:file:cmd/api/main_test.go" + ]) + + #expect(service.run( + providerID: "go", + scope: .file(files[1]), + workspaceURL: root, + projectFiles: files + )) + #expect(builtInProcess.startRequests.isEmpty) + #expect(extensionSession.startRequests.first?.arguments == ["test", "./cmd/api"]) + + service.unregisterLanguageTestExtension(languageID: "go") + service.discover(workspaceURL: root, files: files) + #expect(service.itemsByProviderID["go"] == nil) + #expect(!service.run( + providerID: "go", + scope: .workspace, + workspaceURL: root, + projectFiles: files + )) + #expect(builtInProcess.startRequests.isEmpty) + #expect(service.errorMessage == "go testing extension is not active.") + } + + private func factory(recorder: Recorder) -> ModuleFactory { + ModuleFactory(manifest: ExecutionModule.moduleManifest, contributions: ExecutionModule.moduleContributions) { + recorder.factoryCalls += 1 + return ExecutionModule(makeGraph: { + recorder.graphCalls += 1 + let graph = makeTestGraph() + recorder.latestGraph = graph + return graph + }) + } + } + + private func workspaceFactory() -> ModuleFactory { + ModuleFactory(manifest: ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace)) { + EmptyWorkspaceModule() + } + } +} + +@MainActor private final class Recorder { + var factoryCalls = 0 + var graphCalls = 0 + weak var latestGraph: ExecutionFeatureGraph? +} + +@MainActor +private func makeTestGraph() -> ExecutionFeatureGraph { + let runtime = TestRuntime() + let resolver = TestExecutableResolver() + let maven = MavenService( + runtimeService: runtime, + process: TestStreamingProcess(), + mavenOperations: TestMavenOperations() + ) + let run = RunService( + runtime: runtime, + process: TestStreamingProcess(), + processFactory: { TestStreamingProcess() }, + fileAccess: TestRunFileAccess(), + preferences: TestRunPreferences(), + serverPortParser: TestServerPortParser(), + runConfigurationOperations: TestRunConfigurationOperations(), + executableResolver: resolver, + languageProviderCatalog: .compatibilityFallback, + languageRunProviders: .standard(catalog: .compatibilityFallback) + ) + let tests = LanguageTestService( + catalog: .compatibilityFallback, + registry: LanguageTestProviderRegistry(providers: []), + executableResolver: resolver, + processFactory: { TestStreamingProcess() } + ) + return ExecutionFeatureGraph(maven: maven, run: run, tests: tests) +} + +@MainActor +private final class TestRuntime: MavenRuntimePort, RunRuntimePort { + func mavenExecutable(for project: MavenProject) -> URL? { nil } + func mavenProcessEnvironment() -> [String: String] { [:] } + func setActiveServiceJavaHomePath(_ path: String) {} + func javaHomeURL(overridePath: String?) -> URL? { nil } + func mavenJavaHomeURL(overridePath: String?) -> URL? { nil } + func runConfigurationToolchainCandidates( + for project: MavenProject?, + projectRoot: URL?, + javaHomeOverride: String?, + mavenExecutableOverride: String? + ) -> [ProjectToolchainCandidate] { [] } +} + +private final class TestStreamingProcess: StreamingProcess, @unchecked Sendable { + var isRunning = false + private(set) var startRequests: [ProcessRequest] = [] + var onOutput: (@Sendable (String) -> Void)? + var onTermination: (@Sendable (Int32) -> Void)? + var onStateChange: (@Sendable (ProcessLifecycleEvent) -> Void)? + func start(_ request: ProcessRequest) throws { + startRequests.append(request) + isRunning = true + } + func send(_ input: Data) throws {} + func stop() { isRunning = false } +} + +private struct TestMavenOperations: MavenProjectOperations { + func scanMavenProject(at rootURL: URL, files: [URL]) -> MavenProject? { nil } + func mavenDiagnostics(output: String, projectRoot: URL) -> [MavenBuildIssue] { [] } +} + +private struct TestRunFileAccess: RunFileAccess { + func isDirectory(at url: URL) -> Bool { false } + func readData(from url: URL) throws -> Data { Data() } +} + +@MainActor +private final class TestRunPreferences: RunPreferenceStore { + func data(forKey key: String) -> Data? { nil } + func string(forKey key: String) -> String? { nil } + func setData(_ data: Data, forKey key: String) {} + func setString(_ value: String, forKey key: String) {} +} + +private struct TestServerPortParser: RunServerPortParsing { + func serverPort(content: String, fileExtension: String) -> Int? { nil } +} + +@MainActor +private final class TestExecutableResolver: RunExecutableResolving { + func resolve(_ plan: SharedLaunchPlan, projectURL: URL, options: RunOptions) throws -> ResolvedRunExecutable { + ResolvedRunExecutable(executableURL: URL(fileURLWithPath: "/test"), environment: [:]) + } + func refreshCandidates(projectURL: URL) async {} + func candidates(projectURL: URL) -> [ProjectToolchainCandidate] { [] } +} + +private struct TestRunConfigurationOperations: RunConfigurationOperations { + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection(status: .missing, diagnostics: []) + } + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + RunConfigurationGenerationResult(entryCount: 0) + } + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution(configurations: [], diagnostics: [], defaultConfigurationID: nil) + } + func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan { + throw RunConfigurationOperationFailure(message: "Unavailable in lifecycle test") + } + func saveOptions(_ options: RunOptions, configurationID: String, scope: RunConfigurationSaveScope, at projectURL: URL) throws {} + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + +private struct TestReadyRunConfigurationOperations: RunConfigurationOperations { + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + RunConfigurationGenerationResult(entryCount: 1) + } + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution( + configurations: [EffectiveRunConfiguration( + configuration: .currentFile, + options: RunOptions() + )], + diagnostics: [], + defaultConfigurationID: RunConfiguration.currentFileID + ) + } + func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan { + throw RunConfigurationOperationFailure(message: "The extension must supply this launch plan") + } + func saveOptions(_ options: RunOptions, configurationID: String, scope: RunConfigurationSaveScope, at projectURL: URL) throws {} + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + +private struct TestGoProjectRunConfigurationOperations: RunConfigurationOperations { + private let configuration = RunConfiguration( + id: "go:api", + name: "Go API", + kind: .process(provider: "go.main"), + execution: .application, + modulePath: "cmd/api", + mainClass: nil + ) + + func inspect(at projectURL: URL) -> ProjectRunConfigurationInspection { + ProjectRunConfigurationInspection(status: .ready, diagnostics: []) + } + func generate(at projectURL: URL, files: [URL], modulePaths: [String]) throws -> RunConfigurationGenerationResult { + RunConfigurationGenerationResult(entryCount: 1) + } + func resolve(at projectURL: URL, toolchainCandidates: [ProjectToolchainCandidate]) throws -> RunConfigurationResolution { + RunConfigurationResolution( + configurations: [EffectiveRunConfiguration( + configuration: configuration, + options: RunOptions() + )], + diagnostics: [], + defaultConfigurationID: configuration.id + ) + } + func launchPlan(at projectURL: URL, configurationID: String, currentFile: String?, classPath: String?, debugPort: Int?) throws -> SharedLaunchPlan { + SharedLaunchPlan( + executable: .toolchain("project-go"), + arguments: ["run", "./cmd/api"], + workingDirectory: "." + ) + } + func saveOptions(_ options: RunOptions, configurationID: String, scope: RunConfigurationSaveScope, at projectURL: URL) throws {} + func createConfiguration(_ draft: RunConfigurationDraft, at projectURL: URL) throws -> String { draft.name } + func migrateLegacySettings(at projectURL: URL, configurationIDs: [String]) throws {} +} + +@MainActor +private final class TestGoRunExtension: LanguageRunExtensionProviding, LanguageTestExtensionProviding { + let languageID = "go" + private let executionSession: any LanguageExecutionSession + + init(session: any LanguageExecutionSession) { + executionSession = session + } + + func makeExecutionSession() -> any LanguageExecutionSession { executionSession } + func makeTestExecutionSession() -> any LanguageExecutionSession { executionSession } + + func launchPlan(for request: LanguageRunExtensionRequest) throws -> LanguageRunExtensionPlan { + LanguageRunExtensionPlan( + executable: .toolchain("project-go"), + arguments: ["run", request.relativeFilePath] + request.arguments, + environment: request.environment + ) + } + + func discoverTests( + for request: LanguageTestExtensionDiscoveryRequest + ) throws -> [LanguageTestExtensionItem] { + [LanguageTestExtensionItem(id: "go:workspace", label: "All Go Tests", kind: .workspace)] + + request.relativeProjectFilePaths + .filter { $0.hasSuffix("_test.go") } + .map { + LanguageTestExtensionItem( + id: "go:file:" + $0, + label: $0, + kind: .file, + relativeFilePath: $0 + ) + } + } + + func testPlan(for request: LanguageTestExtensionRequest) throws -> LanguageTestExtensionPlan { + let package: String + switch request.scope { + case .workspace: + package = "./..." + case .file(let path), .testCase(_, let path?): + package = "./" + path.split(separator: "/").dropLast().joined(separator: "/") + case .testCase(_, nil): + package = "./..." + } + return LanguageTestExtensionPlan( + label: "Go Tests", + frameworkID: "go", + launchPlan: LanguageRunExtensionPlan( + executable: .toolchain("project-go"), + arguments: ["test", package] + ) + ) + } +} + +@MainActor +private final class TestLanguageExecutionSession: LanguageExecutionSession { + var isRunning = false + var onOutput: (@Sendable (String) -> Void)? + var onTermination: (@Sendable (Int32) -> Void)? + var onStateChange: (@Sendable (LanguageExecutionLifecycleEvent) -> Void)? + private(set) var startRequests: [LanguageExecutionProcessRequest] = [] + + func start(_ request: LanguageExecutionProcessRequest) throws { + startRequests.append(request) + isRunning = true + onStateChange?(LanguageExecutionLifecycleEvent( + operationID: request.operationID, + state: .running + )) + } + + func stop() { isRunning = false } +} +@MainActor private final class EmptyWorkspaceModule: LitheModule { + let manifest = ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace) + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async {} + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} diff --git a/scripts/GitGraphVerification.swift b/Tests/LitheGitGraphVerifier/main.swift similarity index 93% rename from scripts/GitGraphVerification.swift rename to Tests/LitheGitGraphVerifier/main.swift index b2453f72..65e7a8e6 100644 --- a/scripts/GitGraphVerification.swift +++ b/Tests/LitheGitGraphVerifier/main.swift @@ -1,4 +1,5 @@ import Foundation +import LitheGitModule @main struct GitGraphVerification { @@ -103,13 +104,8 @@ struct GitGraphVerification { let layout = GitGraphLayoutService.layout(commits: [ commit("A", parents: [], decorations: "HEAD -> main, origin/main, tag: v1.0") ]) - let expected = [ - GitGraphLabel(title: "HEAD", kind: .head), - GitGraphLabel(title: "main", kind: .branch), - GitGraphLabel(title: "origin/main", kind: .remote), - GitGraphLabel(title: "v1.0", kind: .tag) - ] - expect(layout.rows[0].labels == expected, "decorations should become typed labels") + let expectedIDs = ["head:HEAD", "branch:main", "remote:origin/main", "tag:v1.0"] + expect(layout.rows[0].labels.map(\.id) == expectedIDs, "decorations should become typed labels") } private static func commit( diff --git a/Tests/LitheGitModuleTests/GitModuleTests.swift b/Tests/LitheGitModuleTests/GitModuleTests.swift new file mode 100644 index 00000000..aa714425 --- /dev/null +++ b/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -0,0 +1,134 @@ +import Foundation +import LitheApplicationKernel +@testable import LitheGitModule +import LitheModuleAPI +import Testing + +@MainActor +struct GitModuleTests { + @Test + func disabledGitDoesNotConstructFactoryOrServiceGraph() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(ModuleFactory(manifest: GitModule.moduleManifest, contributions: GitModule.moduleContributions) { + recorder.factoryCalls += 1 + return makeModule(recorder: recorder) + }, enabled: false) + + await #expect(throws: ModuleRuntimeError.moduleDisabled(.git)) { + _ = try await runtime.activateCapability(.gitWorkspace) + } + #expect(recorder.factoryCalls == 0) + #expect(recorder.storageFactoryCalls == 0) + #expect(try !runtime.snapshot(for: .git).isInstantiated) + } + + @Test + func sleepReleasesFeatureAndWakeCreatesANewGraph() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(ModuleFactory(manifest: GitModule.moduleManifest, contributions: GitModule.moduleContributions) { + recorder.factoryCalls += 1 + return makeModule(recorder: recorder) + }) + + var first: GitFeatureModel? = try #require( + (try await runtime.activateCapability(.gitWorkspace) as? GitModuleCapability)?.feature + ) + weak var released = first + first = nil + try await runtime.sleep(.git) + + #expect(released == nil) + #expect(runtime.capability(.gitWorkspace) == nil) + #expect(try runtime.snapshot(for: .git).activity.activeResourceCount == 0) + + let second = try #require( + (try await runtime.activateCapability(.gitWorkspace) as? GitModuleCapability)?.feature + ) + #expect(second !== released) + #expect(recorder.factoryCalls == 2) + #expect(recorder.storageFactoryCalls == 2) + } + + private func makeModule(recorder: Recorder) -> GitModule { + recorder.storageFactoryCalls += 1 + return GitModule(operations: TestGitOperations(), shelfStorage: TestShelfStorage()) + } + + private func workspaceFactory() -> ModuleFactory { + ModuleFactory(manifest: ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace)) { + EmptyWorkspaceModule() + } + } +} + +@MainActor private final class Recorder { var factoryCalls = 0; var storageFactoryCalls = 0 } +@MainActor private final class EmptyWorkspaceModule: LitheModule { + let manifest = ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace) + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async {} + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} + +private struct TestShelfStorage: GitShelfStorage { + func applicationSupportDirectory() -> URL { URL(fileURLWithPath: "/tmp/lithe-git-module-test") } + func fileExists(at url: URL) -> Bool { false } + func listDirectory(at url: URL) -> [URL] { [] } + func readData(from url: URL) throws -> Data { Data() } + func writeData(_ data: Data, to url: URL) throws {} + func createDirectory(at url: URL) throws {} + func removeItem(at url: URL) throws {} +} + +private struct TestGitOperations: GitOperations { + func snapshot(at rootURL: URL) -> GitSnapshot? { nil } + func watchContext(at rootURL: URL) -> GitWatchContext? { nil } + func diffDocument(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> DiffDocument? { nil } + func diffPatch(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> String? { nil } + func commitDiffDocument(at rootURL: URL, commit: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { nil } + func comparisonDiffDocument(at rootURL: URL, reference: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { nil } + func applyPatch(_ patch: String, at rootURL: URL, mode: String) -> GitProcessResult? { nil } + func history(at rootURL: URL, reference: GitReference?, limit: Int) -> GitHistorySnapshot? { nil } + func files(in commit: GitCommit, at rootURL: URL) -> [GitCommitFile]? { nil } + func commit(at rootURL: URL, hash: String) -> GitCommit? { nil } + func comparison(for reference: GitReference, at rootURL: URL) -> GitBranchComparison? { nil } + func stashes(at rootURL: URL) -> [GitStash]? { nil } + func blame(at rootURL: URL, relativePath: String) -> [GitBlameLine]? { nil } + func stage(_ change: GitChange) -> GitProcessResult? { nil } + func unstage(_ change: GitChange) -> GitProcessResult? { nil } + func discard(_ change: GitChange) -> GitProcessResult? { nil } + func discardAll(_ change: GitChange) -> GitProcessResult? { nil } + func commit(at rootURL: URL, message: String, amend: Bool) -> GitProcessResult? { nil } + func cherryPick(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } + func revert(_ hash: String, at rootURL: URL) -> GitProcessResult? { nil } + func resetCurrentBranch(to hash: String, mode: String, at rootURL: URL) -> GitProcessResult? { nil } + func createBranch(named name: String, from reference: GitReference, checkout: Bool, at rootURL: URL) -> GitProcessResult? { nil } + func renameBranch(_ reference: GitReference, to name: String, at rootURL: URL) -> GitProcessResult? { nil } + func deleteBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func mergeBranch(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func rebaseCurrentBranch(onto reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func updateCurrentBranch(at rootURL: URL, strategy: GitPullStrategy) -> GitProcessResult? { nil } + func pullPreflight(at rootURL: URL) -> GitPullPreflightState? { nil } + func conflictMarkerPaths(at rootURL: URL) -> [String] { [] } + func integrationPreflight(for target: GitIntegrationTarget, operation: GitIntegrationOperation, at rootURL: URL) -> GitIntegrationPreflightState? { nil } + func fetch(at rootURL: URL) -> GitProcessResult? { nil } + func checkout(_ reference: GitReference, at rootURL: URL, force: Bool, autoStash: Bool) -> GitProcessResult? { nil } + func checkoutBlockingPaths(for reference: GitReference, at rootURL: URL) -> [String] { [] } + func operationState(at rootURL: URL) -> GitOperationState? { nil } + func continueOperation(at rootURL: URL) -> GitProcessResult? { nil } + func abortOperation(at rootURL: URL) -> GitProcessResult? { nil } + func skipOperationStep(at rootURL: URL) -> GitProcessResult? { nil } + func checkoutRevision(_ revision: String, at rootURL: URL) -> GitProcessResult? { nil } + func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? { nil } + func stash(message: String, includeUntracked: Bool, at rootURL: URL) -> GitProcessResult? { nil } + func applyStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } + func popStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } + func dropStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } + func stageAll(at rootURL: URL) -> GitProcessResult? { nil } +} diff --git a/Tests/LitheGoSupportModuleTests/GoSupportModuleTests.swift b/Tests/LitheGoSupportModuleTests/GoSupportModuleTests.swift new file mode 100644 index 00000000..f021e35b --- /dev/null +++ b/Tests/LitheGoSupportModuleTests/GoSupportModuleTests.swift @@ -0,0 +1,530 @@ +import Foundation +import LitheApplicationKernel +import LitheCoreContracts +import LitheGoSupportModule +import LitheLanguageIntelligenceModule +import LitheModuleAPI +import Testing + +@MainActor +struct GoSupportModuleTests { + @Test + func lspAndExecutionActivateAndDisableIndependently() async throws { + let runtime = ModuleRuntime() + let executionHost = GoTestExecutionHost() + let workspace = BuiltInModuleCatalog.manifest(for: .workspace)! + try runtime.register(ModuleFactory(manifest: workspace) { + GoTestWorkspaceModule(manifest: workspace) + }) + try runtime.register(ModuleFactory(manifest: GoLanguageServerModule.moduleManifest) { + GoLanguageServerModule() + }) + try runtime.register(ModuleFactory(manifest: GoExecutionModule.moduleManifest) { + GoExecutionModule(executionHost: executionHost) + }) + try runtime.validateGraph() + + let lsp = try await runtime.activateCapability(.languageServerExtension("go")) + let execution = try await runtime.activateCapability(.languageExecutionExtension("go")) + let testing = try await runtime.activateCapability(.languageTestingExtension("go")) + #expect(lsp is GoLanguageServerCapability) + #expect(execution is GoExecutionCapability) + let testingCapability = try #require(testing as? GoExecutionCapability) + let executionObject = try #require(execution as? GoExecutionCapability) + #expect(ObjectIdentifier(testingCapability) == ObjectIdentifier(executionObject)) + + let lspCapability = try #require(lsp as? GoLanguageServerCapability) + let lspLifecycle = GoTestLanguageServerLifecycleState() + lspCapability.lifecycle.attach( + isRunning: { lspLifecycle.isRunning }, + stop: { + lspLifecycle.isRunning = false + lspLifecycle.stopCalls += 1 + } + ) + + try await runtime.setEnabled(false, for: .languageServerExtension("go")) + #expect(lspLifecycle.stopCalls == 1) + #expect(!lspLifecycle.isRunning) + #expect(try runtime.snapshot(for: .languageServerExtension("go")).state == .disabled) + #expect(try runtime.snapshot(for: .languageServerExtension("go")).activity.activeResourceCount == 0) + #expect(try runtime.snapshot(for: .languageExecutionExtension("go")).state == .active) + + let executionCapability = try #require(execution as? GoExecutionCapability) + let executionSession = executionCapability.makeExecutionSession() + let testSession = executionCapability.makeTestExecutionSession() + try executionSession.start(LanguageExecutionProcessRequest( + executablePath: "/fixture/go", + arguments: ["run", "main.go"] + )) + try testSession.start(LanguageExecutionProcessRequest( + executablePath: "/fixture/go", + arguments: ["test", "./..."] + )) + #expect(executionSession.isRunning) + #expect(testSession.isRunning) + #expect(executionHost.sessions.count == 2) + + try await runtime.setEnabled(false, for: .languageExecutionExtension("go")) + #expect(!executionSession.isRunning) + #expect(!testSession.isRunning) + #expect(try runtime.snapshot(for: .languageExecutionExtension("go")).activity.activeResourceCount == 0) + } + + @Test + func executionDisableFailsWhenAnOwnedProcessCannotBeStopped() async throws { + let runtime = ModuleRuntime() + let workspace = BuiltInModuleCatalog.manifest(for: .workspace)! + try runtime.register(ModuleFactory(manifest: workspace) { + GoTestWorkspaceModule(manifest: workspace) + }) + try runtime.register(ModuleFactory(manifest: GoExecutionModule.moduleManifest) { + GoExecutionModule(executionHost: GoStuckExecutionHost()) + }) + + let capability = try #require( + try await runtime.activateCapability(.languageExecutionExtension("go")) + as? any LanguageRunExtensionProviding + ) + let session = capability.makeExecutionSession() + try session.start(LanguageExecutionProcessRequest(executablePath: "/fixture/go")) + + await #expect(throws: ModuleRuntimeError.activeResourcesRemain( + module: .languageExecutionExtension("go"), + kinds: ["language-execution-process"] + )) { + try await runtime.setEnabled(false, for: .languageExecutionExtension("go")) + } + let snapshot = try runtime.snapshot(for: .languageExecutionExtension("go")) + #expect(snapshot.activity.activeResourceCount == 1) + guard case .failed = snapshot.state else { + Issue.record("The module must report a failed shutdown while its process remains active") + return + } + } + + @Test + func executionActivityBlocksSleepAndCompletionMakesTheModuleIdle() async throws { + let runtime = ModuleRuntime() + let workspace = BuiltInModuleCatalog.manifest(for: .workspace)! + try runtime.register(ModuleFactory(manifest: workspace) { + GoTestWorkspaceModule(manifest: workspace) + }) + try runtime.register(ModuleFactory(manifest: GoExecutionModule.moduleManifest) { + GoExecutionModule(executionHost: GoTestExecutionHost()) + }) + + let capability = try #require( + try await runtime.activateCapability(.languageExecutionExtension("go")) + as? any LanguageRunExtensionProviding + ) + let session = capability.makeExecutionSession() + try session.start(LanguageExecutionProcessRequest( + operationID: "go-run", + executablePath: "/fixture/go" + )) + let moduleID = ModuleID.languageExecutionExtension("go") + #expect(try runtime.snapshot(for: moduleID).activity.activeLeaseCount == 1) + await #expect(throws: ModuleRuntimeError.activeLeasesPreventSleep( + module: moduleID, + reasons: ["Language execution go-run"] + )) { + try await runtime.sleep(moduleID) + } + + session.stop() + let idle = try runtime.snapshot(for: moduleID) + #expect(idle.state == .idle) + #expect(idle.activity.activeLeaseCount == 0) + + try await runtime.sleep(moduleID) + #expect(try runtime.snapshot(for: moduleID).state == .sleeping) + } + + @Test + func goExecutionProducesAWorkspaceRelativeLaunchPlan() throws { + let capability = GoExecutionCapability(executionSession: GoTestExecutionSession()) + let plan = try capability.launchPlan(for: LanguageRunExtensionRequest( + relativeFilePath: "cmd/server/main.go", + arguments: ["--port", "8080"], + environment: ["GOFLAGS": "-mod=readonly"] + )) + + #expect(plan.executable == .toolchain("project-go")) + #expect(plan.arguments == ["run", "cmd/server/main.go", "--port", "8080"]) + #expect(plan.workingDirectory == ".") + #expect(plan.environment == ["GOFLAGS": "-mod=readonly"]) + } + + @Test + func goExecutionRejectsPathsOutsideTheWorkspace() { + let capability = GoExecutionCapability(executionSession: GoTestExecutionSession()) + #expect(throws: LanguageRunExtensionError.invalidRelativePath) { + _ = try capability.launchPlan(for: LanguageRunExtensionRequest( + relativeFilePath: "../outside.go" + )) + } + } + + @Test + func goTestingDiscoversFilesAndBuildsAnOwnedTestPlan() throws { + let session = GoTestExecutionSession() + let capability = GoExecutionCapability(executionSession: session) + let projectFiles = [ + "go.mod", + "cmd/api/main.go", + "cmd/api/main_test.go", + "internal/store/store_test.go" + ] + + let items = try capability.discoverTests(for: LanguageTestExtensionDiscoveryRequest( + relativeProjectFilePaths: projectFiles + )) + #expect(items.map(\.id) == [ + "go:workspace", + "go:file:cmd/api/main_test.go", + "go:file:internal/store/store_test.go" + ]) + + let plan = try capability.testPlan(for: LanguageTestExtensionRequest( + scope: .testCase( + identifier: "TestHealth/ready", + relativeFilePath: "cmd/api/main_test.go" + ), + relativeProjectFilePaths: projectFiles + )) + #expect(plan.frameworkID == "go") + #expect(plan.launchPlan.executable == .toolchain("project-go")) + #expect(plan.launchPlan.arguments == [ + "test", "./cmd/api", "-run", "^TestHealth/ready$" + ]) + let testSession = try #require( + capability.makeTestExecutionSession() as? GoTestExecutionSession + ) + #expect(ObjectIdentifier(testSession) == ObjectIdentifier(session)) + } + + @Test + func disablingGoLanguageServerWaitsForTheOwnedRuntimeProcessToStop() async throws { + let runtime = ModuleRuntime() + let workspace = BuiltInModuleCatalog.manifest(for: .workspace)! + try runtime.register(ModuleFactory(manifest: workspace) { + GoTestWorkspaceModule(manifest: workspace) + }) + try runtime.register(ModuleFactory(manifest: GoLanguageServerModule.moduleManifest) { + GoLanguageServerModule() + }) + + let processRegistry = GoTestLanguageServerProcessRegistry() + let core = GoTestLanguageServerRuntimeCore(processID: 7_311) + let runtimeFactory = GoTestLanguageProviderRuntimeFactory( + core: core, + processRegistry: processRegistry + ) + let descriptor = LanguageProviderDescriptor( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageIdentifier: "go" + ) + let sessions = LanguageToolingSessionManager( + catalog: LanguageProviderCatalog(descriptors: [descriptor]), + runtimeFactory: runtimeFactory, + extensionRequiredProviderIDs: ["go"] + ) + let support = LanguageSupportDeclaration( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + languageServerModuleID: GoLanguageServerModule.moduleManifest.id + ) + let provider = try #require( + try await runtime.activateCapability(.languageServerExtension("go")) + as? any LanguageServerExtensionProviding + ) + #expect(sessions.registerLanguageServerExtension(provider, support: support)) + + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + try sessions.synchronizeLanguageServer( + for: root.appendingPathComponent("main.go"), + text: "package main", + rootURL: root + ) + #expect(processRegistry.processIDs(for: GoLanguageServerModule.moduleManifest.id) == [7_311]) + + try await runtime.setEnabled(false, for: GoLanguageServerModule.moduleManifest.id) + + #expect(core.stopCalls == ["go-test-session"]) + #expect(processRegistry.processIDs(for: GoLanguageServerModule.moduleManifest.id).isEmpty) + #expect(try runtime.snapshot(for: GoLanguageServerModule.moduleManifest.id).state == .disabled) + #expect(try runtime.snapshot(for: GoLanguageServerModule.moduleManifest.id).activity.activeResourceCount == 0) + } + + @Test + func idleGoLanguageServerSleepsAndStopsItsOwnedRuntimeProcess() async throws { + let runtime = ModuleRuntime() + let workspace = BuiltInModuleCatalog.manifest(for: .workspace)! + try runtime.register(ModuleFactory(manifest: workspace) { + GoTestWorkspaceModule(manifest: workspace) + }) + try runtime.register(ModuleFactory(manifest: GoLanguageServerModule.moduleManifest) { + GoLanguageServerModule() + }) + + let processRegistry = GoTestLanguageServerProcessRegistry() + let core = GoTestLanguageServerRuntimeCore(processID: 7_312) + let runtimeFactory = GoTestLanguageProviderRuntimeFactory( + core: core, + processRegistry: processRegistry + ) + let descriptor = LanguageProviderDescriptor( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + capabilities: [.languageServer], + activationPolicy: .onDemand, + languageIdentifier: "go" + ) + let sessions = LanguageToolingSessionManager( + catalog: LanguageProviderCatalog(descriptors: [descriptor]), + runtimeFactory: runtimeFactory, + extensionRequiredProviderIDs: ["go"] + ) + let support = LanguageSupportDeclaration( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + languageServerModuleID: GoLanguageServerModule.moduleManifest.id + ) + let provider = try #require( + try await runtime.activateCapability(.languageServerExtension("go")) + as? any LanguageServerExtensionProviding + ) + #expect(sessions.registerLanguageServerExtension(provider, support: support)) + + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + try sessions.synchronizeLanguageServer( + for: root.appendingPathComponent("main.go"), + text: "package main", + rootURL: root + ) + let moduleID = GoLanguageServerModule.moduleManifest.id + try runtime.markIdle(moduleID) + let idleAt = try #require(runtime.snapshot(for: moduleID).activity.lastActivityAt) + + await runtime.evaluateIdleModules(now: idleAt.addingTimeInterval(601)) + + #expect(core.stopCalls == ["go-test-session"]) + #expect(processRegistry.processIDs(for: moduleID).isEmpty) + #expect(try runtime.snapshot(for: moduleID).state == .sleeping) + #expect(try runtime.snapshot(for: moduleID).activity.activeResourceCount == 0) + } +} + +@MainActor +private final class GoTestWorkspaceModule: LitheModule { + let manifest: ModuleManifest + private var capability: GoTestWorkspaceCapability? + + init(manifest: ModuleManifest) { self.manifest = manifest } + func activate(context: ModuleContext) async throws { + capability = GoTestWorkspaceCapability() + } + func prepareForSleep() async throws {} + func sleep() async { capability = nil } + func shutdown() async { capability = nil } + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + capability.map { [.workspaceFoundation: $0] } ?? [:] + } +} + +private final class GoTestWorkspaceCapability {} + +@MainActor +private final class GoTestLanguageServerLifecycleState { + var isRunning = true + var stopCalls = 0 +} + +@MainActor +private final class GoTestExecutionHost: LanguageExecutionHostProviding { + private(set) var sessions: [GoTestExecutionSession] = [] + + func makeSession(ownerModuleID: ModuleID) -> any LanguageExecutionSession { + let session = GoTestExecutionSession() + sessions.append(session) + return session + } +} + +@MainActor +private final class GoTestExecutionSession: LanguageExecutionSession { + var isRunning = false + var onOutput: (@Sendable (String) -> Void)? + var onTermination: (@Sendable (Int32) -> Void)? + var onStateChange: (@Sendable (LanguageExecutionLifecycleEvent) -> Void)? + + func start(_ request: LanguageExecutionProcessRequest) throws { + isRunning = true + onStateChange?(LanguageExecutionLifecycleEvent( + operationID: request.operationID, + state: .running + )) + } + + func stop() { isRunning = false } +} + +@MainActor +private final class GoStuckExecutionHost: LanguageExecutionHostProviding { + func makeSession(ownerModuleID _: ModuleID) -> any LanguageExecutionSession { + GoStuckExecutionSession() + } +} + +@MainActor +private final class GoStuckExecutionSession: LanguageExecutionSession { + var isRunning = false + var onOutput: (@Sendable (String) -> Void)? + var onTermination: (@Sendable (Int32) -> Void)? + var onStateChange: (@Sendable (LanguageExecutionLifecycleEvent) -> Void)? + + func start(_: LanguageExecutionProcessRequest) throws { isRunning = true } + func stop() {} + func stopAndWait() async -> Bool { false } +} + +@MainActor +private final class GoTestLanguageProviderRuntimeFactory: LanguageProviderRuntimeFactory { + private let core: any LanguageServerRuntimeCore + private weak var processRegistry: (any LanguageServerProcessRegistry)? + + init( + core: any LanguageServerRuntimeCore, + processRegistry: any LanguageServerProcessRegistry + ) { + self.core = core + self.processRegistry = processRegistry + } + + func makeRuntime(for _: LanguageProviderDescriptor) -> (any LanguageProviderRuntime)? { nil } + + func makeRuntime( + for descriptor: LanguageProviderDescriptor, + languageServerLaunch: LanguageServerLaunchDescriptor, + ownerModuleID: ModuleID + ) -> (any LanguageProviderRuntime)? { + StdioLanguageProviderRuntime( + descriptor: descriptor, + runtimeService: GoTestLanguageToolRuntime(), + languageServerLaunch: languageServerLaunch, + languageServerCore: core, + languageServerExecutableResolver: { _ in + URL(fileURLWithPath: "/fixture/gopls") + }, + processRegistry: processRegistry, + moduleID: ownerModuleID + ) + } +} + +private final class GoTestLanguageToolRuntime: LanguageToolRuntimePort { + func executableOnPath(_: String) -> URL? { nil } + func executableURL(at _: String) -> URL? { nil } + func executableCandidates(_: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment() -> [String: String] { [:] } + func missingLanguageToolMessage(_ name: String) -> String { "Missing \(name)." } +} + +@MainActor +private final class GoTestLanguageServerProcessRegistry: LanguageServerProcessRegistry { + private var entries: [ModuleID: Set] = [:] + + func registerLanguageServerProcess(pid: Int32, moduleID: ModuleID) { + entries[moduleID, default: []].insert(pid) + } + + func unregisterLanguageServerProcess(pid: Int32, moduleID: ModuleID) { + entries[moduleID]?.remove(pid) + } + + func processIDs(for moduleID: ModuleID) -> Set { + entries[moduleID] ?? [] + } +} + +private final class GoTestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @unchecked Sendable { + private let lock = NSLock() + private let processID: Int32 + private var pendingEvents: [LanguageServerRuntimeEvent] = [] + private(set) var stopCalls: [String] = [] + + init(processID: Int32) { + self.processID = processID + } + + func startLanguageServer( + providerID _: String, + executableURL _: URL, + arguments _: [String], + environment _: [String: String], + rootURL _: URL, + workingDirectoryURL _: URL, + initializationOptions _: ToolingJSONValue?, + runtimeExecutableURL _: URL?, + cacheDirectoryURL _: URL?, + initializeTimeout _: TimeInterval, + requestTimeout _: TimeInterval, + shutdownTimeout _: TimeInterval + ) -> Result { + .success(LanguageServerRuntimeStart( + sessionID: "go-test-session", + state: "initializing", + processID: processID + )) + } + + func stopLanguageServer(sessionID: String) { + lock.lock(); defer { lock.unlock() } + stopCalls.append(sessionID) + pendingEvents.append(LanguageServerRuntimeEvent(type: "stateChanged", state: "stopped")) + } + + func syncLanguageServerDocument( + sessionID _: String, + fileURL _: URL, + languageID _: String, + text _: String + ) -> Result { .success(()) } + + func closeLanguageServerDocument(sessionID _: String, fileURL _: URL) {} + + func requestLanguageServerOperation( + sessionID _: String, + operation _: LanguageServerOperation, + fileURL _: URL?, + virtualURI _: String?, + position _: LanguageServerPosition?, + newName _: String?, + range _: LanguageServerRange?, + diagnostics _: [LanguageServerDiagnostic], + completionItem _: LanguageServerCompletionItem?, + codeAction _: LanguageServerCodeAction?, + command _: LanguageServerCommand? + ) -> Result { + .success(LanguageServerRuntimeOperation(operationID: "unused")) + } + + func cancelLanguageServerOperation(sessionID _: String, operationID _: String) {} + + func pollLanguageServerEvents(sessionID _: String) -> [LanguageServerRuntimeEvent] { + lock.lock(); defer { lock.unlock() } + let events = pendingEvents + pendingEvents.removeAll() + return events + } + + func destroyLanguageServer(sessionID _: String) {} +} diff --git a/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift b/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift new file mode 100644 index 00000000..6cfe4ad0 --- /dev/null +++ b/Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift @@ -0,0 +1,256 @@ +import Foundation +import LitheApplicationKernel +import LitheCoreContracts +@testable import LitheLanguageIntelligenceModule +import LitheModuleAPI +import Testing + +@MainActor +struct LanguageIntelligenceModuleTests { + @Test + func disabledModuleDoesNotConstructFactoryOrServiceGraph() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(ModuleFactory(manifest: LanguageIntelligenceModule.moduleManifest, contributions: LanguageIntelligenceModule.moduleContributions) { + recorder.factoryCalls += 1 + return makeModule(recorder: recorder) + }, enabled: false) + + await #expect(throws: ModuleRuntimeError.moduleDisabled(.languageIntelligence)) { + _ = try await runtime.activateCapability(.languageIntelligence) + } + #expect(recorder.factoryCalls == 0) + #expect(recorder.graphCalls == 0) + #expect(try !runtime.snapshot(for: .languageIntelligence).isInstantiated) + } + + @Test + func sleepReleasesGraphAndWakeConstructsANewInstance() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(ModuleFactory(manifest: LanguageIntelligenceModule.moduleManifest, contributions: LanguageIntelligenceModule.moduleContributions) { + recorder.factoryCalls += 1 + return makeModule(recorder: recorder) + }) + + var firstCapability: LanguageIntelligenceCapability? = try #require( + try await runtime.activateCapability(.languageIntelligence) + as? LanguageIntelligenceCapability + ) + weak var firstSessions = try #require(firstCapability?.sessions) + weak var firstGraph = recorder.latestGraph + firstCapability = nil + + try await runtime.sleep(.languageIntelligence) + + #expect(firstGraph == nil) + #expect(firstSessions == nil) + #expect(runtime.capability(.languageIntelligence) == nil) + #expect(try runtime.snapshot(for: .languageIntelligence).activity.activeResourceCount == 0) + + let secondCapability = try #require( + try await runtime.activateCapability(.languageIntelligence) + as? LanguageIntelligenceCapability + ) + #expect(secondCapability.sessions.activeLanguageServerIDs.isEmpty) + #expect(recorder.factoryCalls == 2) + #expect(recorder.graphCalls == 2) + } + + @Test + func goLanguageServerRequiresExtensionRuntimeAndUsesPluginModuleOwner() throws { + let factory = TestLanguageProviderRuntimeFactory() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimeFactory: factory, + extensionRequiredProviderIDs: ["go"] + ) + let root = URL(fileURLWithPath: "/workspace", isDirectory: true) + let source = root.appendingPathComponent("main.go") + + try manager.synchronizeLanguageServer(for: source, text: "package main", rootURL: root) + #expect(factory.standardRequests.isEmpty) + + let provider = TestLanguageServerExtensionProvider() + let support = LanguageSupportDeclaration( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + languageServerModuleID: .languageServerExtension("go") + ) + #expect(manager.registerLanguageServerExtension(provider, support: support)) + #expect(factory.extensionRequests.count == 1) + #expect(factory.extensionRequests.first?.ownerModuleID == .languageServerExtension("go")) + #expect(factory.extensionRequests.first?.launch.executableNames == ["gopls"]) + } + + @Test + func unregisteringAnExtensionDropsItsRuntimeBeforeReactivation() { + let factory = TestLanguageProviderRuntimeFactory() + let manager = LanguageToolingSessionManager( + catalog: .compatibilityFallback, + runtimeFactory: factory, + extensionRequiredProviderIDs: ["go"] + ) + let support = LanguageSupportDeclaration( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + languageServerModuleID: .languageServerExtension("go") + ) + let provider = TestLanguageServerExtensionProvider() + + #expect(manager.registerLanguageServerExtension(provider, support: support)) + manager.unregisterLanguageServerExtension(languageID: "go") + #expect(manager.registerLanguageServerExtension(provider, support: support)) + + #expect(factory.extensionRequests.count == 2) + #expect(factory.standardRequests.isEmpty) + } + + private func makeModule(recorder: Recorder) -> LanguageIntelligenceModule { + LanguageIntelligenceModule(makeGraph: { + recorder.graphCalls += 1 + let graph = TestGraph() + recorder.latestGraph = graph + return graph + }) + } + + private func workspaceFactory() -> ModuleFactory { + ModuleFactory( + manifest: ModuleManifest( + id: .workspace, + displayName: "Workspace", + scope: .workspace + ) + ) { + EmptyWorkspaceModule() + } + } +} + +@MainActor +private final class Recorder { + var factoryCalls = 0 + var graphCalls = 0 + weak var latestGraph: TestGraph? +} + +@MainActor +private final class TestGraph: LanguageIntelligenceServiceGraph { + let sessions = LanguageToolingSessionManager() + let tools = LanguageServerToolService( + runtimeService: TestLanguageToolRuntime(), + commandRunner: TestLanguageToolCommandRunner(), + settingsStore: TestLanguageToolSettingsStore() + ) + var hasActiveLanguageServers = false + + func activate(context: ModuleContext) {} + func prepareForSleep() async throws {} + func stop() async {} +} + +@MainActor +private final class TestLanguageToolRuntime: LanguageToolRuntimePort { + func executableOnPath(_: String) -> URL? { nil } + func executableURL(at _: String) -> URL? { nil } + func executableCandidates(_: String) -> [RuntimeToolCandidate] { [] } + func languageToolProcessEnvironment() -> [String: String] { [:] } + func missingLanguageToolMessage(_ name: String) -> String { "Missing \(name)." } +} + +private struct TestLanguageToolCommandRunner: LanguageToolCommandRunning { + func runLanguageToolCommand( + operationID _: String, + executableURL _: URL, + arguments _: [String], + environment _: [String: String], + timeoutMilliseconds _: Int + ) -> LanguageToolCommandResult { + LanguageToolCommandResult(output: "", exitCode: 0) + } +} + +private final class TestLanguageToolSettingsStore: LanguageToolSettingsStoring { + func loadLanguageToolExecutablePaths() -> [String: String] { [:] } + func saveLanguageToolExecutablePaths(_: [String: String]) {} +} + +@MainActor +private final class TestLanguageProviderRuntimeFactory: LanguageProviderRuntimeFactory { + struct ExtensionRequest { + let launch: LanguageServerLaunchDescriptor + let ownerModuleID: ModuleID + } + + private(set) var standardRequests: [LanguageProviderDescriptor] = [] + private(set) var extensionRequests: [ExtensionRequest] = [] + + func makeRuntime(for descriptor: LanguageProviderDescriptor) -> (any LanguageProviderRuntime)? { + standardRequests.append(descriptor) + return TestLanguageProviderRuntime(descriptor: descriptor) + } + + func makeRuntime( + for descriptor: LanguageProviderDescriptor, + languageServerLaunch: LanguageServerLaunchDescriptor, + ownerModuleID: ModuleID + ) -> (any LanguageProviderRuntime)? { + extensionRequests.append(ExtensionRequest( + launch: languageServerLaunch, + ownerModuleID: ownerModuleID + )) + return TestLanguageProviderRuntime(descriptor: descriptor) + } +} + +@MainActor +private final class TestLanguageProviderRuntime: LanguageProviderRuntime { + let descriptor: LanguageProviderDescriptor + init(descriptor: LanguageProviderDescriptor) { self.descriptor = descriptor } +} + +@MainActor +private final class TestLanguageServerExtensionProvider: LanguageServerExtensionProviding { + let configuration = LanguageServerExtensionConfiguration( + languageID: "go", + displayName: "Go", + executableNames: ["gopls"], + languageIdentifier: "go" + ) + let lifecycle: any LanguageServerExtensionLifecycle = TestLanguageServerExtensionLifecycle() +} + +@MainActor +private final class TestLanguageServerExtensionLifecycle: LanguageServerExtensionLifecycle { + private var running: @MainActor () -> Bool = { false } + private var stopAction: @MainActor () -> Void = {} + var isRunning: Bool { running() } + func attach( + isRunning: @escaping @MainActor () -> Bool, + stop: @escaping @MainActor () -> Void + ) { + running = isRunning + stopAction = stop + } + func stop() { stopAction() } +} + +@MainActor +private final class EmptyWorkspaceModule: LitheModule { + let manifest = ModuleManifest( + id: .workspace, + displayName: "Workspace", + scope: .workspace + ) + + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async {} + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} diff --git a/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift b/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift new file mode 100644 index 00000000..7c579879 --- /dev/null +++ b/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift @@ -0,0 +1,75 @@ +import Foundation +import LitheApplicationKernel +import LitheLocalHistoryModule +import LitheModuleAPI +import Testing + +@MainActor +struct LocalHistoryModuleTests { + @Test + func disabledHistoryDoesNotConstructFactory() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(ModuleFactory(manifest: HistoryModule.moduleManifest, contributions: HistoryModule.moduleContributions) { + recorder.factoryCalls += 1 + return makeModule() + }, enabled: false) + await #expect(throws: ModuleRuntimeError.moduleDisabled(.localHistory)) { + _ = try await runtime.activateCapability(.historyWorkspace) + } + #expect(recorder.factoryCalls == 0) + } + + @Test + func sleepReleasesFeatureAndWakeReconstructsIt() async throws { + let recorder = Recorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(ModuleFactory(manifest: HistoryModule.moduleManifest, contributions: HistoryModule.moduleContributions) { + recorder.factoryCalls += 1 + return makeModule() + }) + var first: ProjectHistoryFeatureModel? = try #require((try await runtime.activateCapability(.historyWorkspace) as? HistoryModuleCapability)?.feature) + weak var released = first + first = nil + try await runtime.sleep(.localHistory) + #expect(released == nil) + #expect(runtime.capability(.historyWorkspace) == nil) + #expect(try runtime.snapshot(for: .localHistory).activity.activeResourceCount == 0) + _ = try #require((try await runtime.activateCapability(.historyWorkspace) as? HistoryModuleCapability)?.feature) + #expect(recorder.factoryCalls == 2) + } + + private func makeModule() -> HistoryModule { + HistoryModule(workspaceAccess: TestWorkspaceAccess(), storage: TestStorage(), operations: TestOperations()) + } + + private func workspaceFactory() -> ModuleFactory { + ModuleFactory(manifest: ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace)) { EmptyWorkspaceModule() } + } +} + +@MainActor private final class Recorder { var factoryCalls = 0 } +@MainActor private final class EmptyWorkspaceModule: LitheModule { + let manifest = ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace) + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async {} + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} +private struct TestWorkspaceAccess: LocalHistoryWorkspaceAccess { + func fileExists(at url: URL) -> Bool { false } + func readFile(at workspaceURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at workspaceURL: URL, relativePath: String) -> Bool { false } +} +private struct TestStorage: LocalHistoryStorage { + func applicationSupportDirectory() -> URL { URL(fileURLWithPath: "/tmp/lithe-history-test") } +} +private struct TestOperations: LocalHistoryOperations { + func record(at workspaceURL: URL, storageURL: URL, relativePath: String, reason: LocalHistoryReason, content: String?, pruneExpired: Bool, visibilityRules: LocalHistoryVisibilityRules) -> LocalHistoryEntryPayload? { nil } + func entries(at workspaceURL: URL, storageURL: URL, relativePath: String?, visibilityRules: LocalHistoryVisibilityRules) -> [LocalHistoryEntryPayload]? { [] } + func content(at storageURL: URL, contentPath: String) -> String? { nil } + func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { false } +} diff --git a/Tests/LitheOfficialPluginVerifier/main.swift b/Tests/LitheOfficialPluginVerifier/main.swift new file mode 100644 index 00000000..e56cf302 --- /dev/null +++ b/Tests/LitheOfficialPluginVerifier/main.swift @@ -0,0 +1,78 @@ +import Foundation +import LitheApplicationKernel +import LitheCoreContracts +import LitheModuleAPI + +@main +struct OfficialPluginVerifier { + @MainActor + static func main() async throws { + _ = LanguageExecutionProcessRequest.self + guard CommandLine.arguments.count == 2 else { + throw VerificationError.usage + } + let packageURL = URL( + fileURLWithPath: CommandLine.arguments[1], + isDirectory: true + ) + let manifest = try JSONDecoder().decode( + PluginManifest.self, + from: Data(contentsOf: packageURL.appendingPathComponent("plugin.json")) + ) + guard OfficialPluginCatalog.manifests.contains(manifest) else { + throw VerificationError.manifestMismatch + } + + guard let bundlePath = manifest.entrypoint.bundlePath, + let bundle = Bundle( + url: packageURL.appendingPathComponent(bundlePath, isDirectory: true) + ) else { + throw VerificationError.invalidBundle + } + try bundle.loadAndReturnError() + guard let principalClass: AnyClass = bundle.principalClass, + let entrypointType = principalClass as? LithePluginEntrypoint.Type else { + throw VerificationError.invalidEntrypoint + } + + let factories = try entrypointType.init().moduleFactories( + context: .empty + ) + guard factories.count == manifest.modules.count, + zip(factories, manifest.modules).allSatisfy({ pair in + pair.0.manifest == pair.1.manifest + && pair.0.contributions == pair.1.contributions + }) else { + throw VerificationError.factoryMismatch + } + + _ = try ValidatedPluginCatalog( + manifests: BuiltInPluginCatalog.manifests + [manifest], + hostVersion: BuiltInPluginCatalog.hostVersion + ) + let runtime = ModuleRuntime() + for declaration in BuiltInPluginCatalog.manifests.flatMap(\.modules) { + try runtime.register(ModuleFactory( + manifest: declaration.manifest, + contributions: declaration.contributions + ) { + throw VerificationError.factoryMustNotBeInvoked + }) + } + for factory in factories { + try runtime.register(factory) + } + try runtime.validateGraph() + await runtime.shutdownAll() + print("Verified \(manifest.id) through the native Bundle boundary") + } +} + +private enum VerificationError: Error { + case usage + case manifestMismatch + case invalidBundle + case invalidEntrypoint + case factoryMismatch + case factoryMustNotBeInvoked +} diff --git a/Tests/LitheSearchModuleTests/SearchModuleTests.swift b/Tests/LitheSearchModuleTests/SearchModuleTests.swift new file mode 100644 index 00000000..86134748 --- /dev/null +++ b/Tests/LitheSearchModuleTests/SearchModuleTests.swift @@ -0,0 +1,169 @@ +import Foundation +import LitheApplicationKernel +import LitheModuleAPI +import LitheSearchModule +import Testing + +@MainActor +struct SearchModuleTests { + @Test + func disabledSearchDoesNotConstructFactoryOrFeature() async throws { + let recorder = SearchRecorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register( + ModuleFactory(manifest: SearchModule.moduleManifest, contributions: SearchModule.moduleContributions) { + recorder.factoryCalls += 1 + return SearchModule(operations: TestSearchOperations()) + }, + enabled: false + ) + + await #expect(throws: ModuleRuntimeError.moduleDisabled(.search)) { + _ = try await runtime.activateCapability(.searchWorkspace) + } + #expect(recorder.factoryCalls == 0) + #expect(try !runtime.snapshot(for: .search).isInstantiated) + } + + @Test + func sleepReleasesFeatureAndWakeCreatesANewOne() async throws { + let recorder = SearchRecorder() + let runtime = ModuleRuntime() + try runtime.register(workspaceFactory()) + try runtime.register(ModuleFactory(manifest: SearchModule.moduleManifest, contributions: SearchModule.moduleContributions) { + recorder.factoryCalls += 1 + return SearchModule(operations: TestSearchOperations()) + }) + + var first: SearchFeatureModel? = try #require( + (try await runtime.activateCapability(.searchWorkspace) as? SearchModuleCapability)?.feature + ) + weak var releasedFeature = first + first = nil + try await runtime.sleep(.search) + + #expect(releasedFeature == nil) + #expect(runtime.capability(.searchWorkspace) == nil) + #expect(try runtime.snapshot(for: .search).activity.activeResourceCount == 0) + + let second = try #require( + (try await runtime.activateCapability(.searchWorkspace) as? SearchModuleCapability)?.feature + ) + #expect(second !== releasedFeature) + #expect(recorder.factoryCalls == 2) + } + + @Test + func replacingIndexWorkKeepsTheNewestTaskActiveUntilItFinishes() async throws { + let operations = BlockingIndexOperations() + defer { + operations.finishWarmIndex() + operations.finishInvalidation() + } + let feature = SearchFeatureModel(operations: operations) + let workspaceURL = URL(fileURLWithPath: "/test-workspace") + let visibilityRules = SearchVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: []) + + feature.warmIndex(at: workspaceURL, visibilityRules: visibilityRules) + try #require(await waitUntil { operations.hasStartedWarmIndex }) + + feature.invalidateIndex(at: workspaceURL, visibilityRules: visibilityRules) + operations.finishWarmIndex() + try #require(await waitUntil { operations.hasStartedInvalidation }) + + #expect(feature.hasActiveModuleWork) + + operations.finishInvalidation() + try #require(await waitUntil { !feature.hasActiveModuleWork }) + #expect(operations.completedOperations == ["warm", "invalidate"]) + } + + private func waitUntil( + timeout: Duration = .seconds(2), + condition: @MainActor () -> Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() + } + + private func workspaceFactory() -> ModuleFactory { + ModuleFactory( + manifest: ModuleManifest( + id: .workspace, displayName: "Workspace", scope: .workspace, + activationPolicy: .onDemand + ) + ) { EmptyWorkspaceModule() } + } +} + +@MainActor +private final class SearchRecorder { var factoryCalls = 0 } + +@MainActor +private final class EmptyWorkspaceModule: LitheModule { + let manifest = ModuleManifest(id: .workspace, displayName: "Workspace", scope: .workspace) + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async {} + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} + +private struct TestSearchOperations: SearchOperations { + func search(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules) -> [FileSearchResult]? { [] } + func searchEverywhere(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules) -> SearchEverywhereResults? { SearchEverywhereResults() } + func previewReplacement(at rootURL: URL, query: String, replacement: String, options: ProjectSearchOptions, paths: [String], textOverrides: [String: String], visibilityRules: SearchVisibilityRules) -> [ProjectReplacementFile]? { [] } + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } +} + +private final class BlockingIndexOperations: SearchOperations, @unchecked Sendable { + private let lock = NSLock() + private let warmIndexGate = DispatchSemaphore(value: 0) + private let invalidationGate = DispatchSemaphore(value: 0) + private var warmIndexStarted = false + private var invalidationStarted = false + private var completions: [String] = [] + + var hasStartedWarmIndex: Bool { withLock { warmIndexStarted } } + var hasStartedInvalidation: Bool { withLock { invalidationStarted } } + var completedOperations: [String] { withLock { completions } } + + func warmSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) { + withLock { warmIndexStarted = true } + warmIndexGate.wait() + withLock { completions.append("warm") } + } + + func invalidateSearchIndex(at rootURL: URL, visibilityRules: SearchVisibilityRules) { + withLock { invalidationStarted = true } + invalidationGate.wait() + withLock { completions.append("invalidate") } + } + + func finishWarmIndex() { + warmIndexGate.signal() + } + + func finishInvalidation() { + invalidationGate.signal() + } + + func search(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules) -> [FileSearchResult]? { [] } + func searchEverywhere(at rootURL: URL, query: String, options: ProjectSearchOptions, visibilityRules: SearchVisibilityRules) -> SearchEverywhereResults? { SearchEverywhereResults() } + func previewReplacement(at rootURL: URL, query: String, replacement: String, options: ProjectSearchOptions, paths: [String], textOverrides: [String: String], visibilityRules: SearchVisibilityRules) -> [ProjectReplacementFile]? { [] } + func readFile(at rootURL: URL, relativePath: String) -> String? { nil } + func writeFile(_ text: String, at rootURL: URL, relativePath: String) -> Bool { false } + + private func withLock(_ operation: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return operation() + } +} diff --git a/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift b/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift new file mode 100644 index 00000000..de18aba0 --- /dev/null +++ b/Tests/LitheTerminalModuleTests/TerminalModuleTests.swift @@ -0,0 +1,59 @@ +import Foundation +import LitheTerminalModule +import Testing + +@MainActor +struct TerminalModuleTests { + @Test + func sessionOwnsTransportAndStopReleasesIt() { + let transport = TestTransport() + let feature = TerminalFeatureModel(terminalFactory: { transport }) + let session = feature.createSession( + in: URL(fileURLWithPath: "/tmp/lithe-terminal-module-test"), + shellPath: "/bin/zsh" + ) + + #expect(session.isRunning) + #expect(ObjectIdentifier(session.nativeView) == ObjectIdentifier(transport.nativeView)) + feature.stopAllSessions() + #expect(!transport.isRunning) + #expect(transport.stopCount == 1) + #expect(feature.terminalSessions.isEmpty) + } + + @Test + func linkResolverKeepsExternalURLsAndResolvesLocations() { + let workspace = URL(fileURLWithPath: "/tmp/lithe-terminal-module-test") + let expected = workspace.appendingPathComponent("Sources/App.swift").standardizedFileURL + #expect(TerminalLinkResolver.resolve( + "Sources/App.swift:12:4", + relativeTo: workspace, + fileExists: { $0 == expected } + ) == .file(TerminalLinkLocation(url: expected, line: 12, column: 4))) + #expect(TerminalLinkResolver.resolve( + "https://example.com", + relativeTo: workspace, + fileExists: { _ in false } + ) == .external(URL(string: "https://example.com")!)) + } +} + +@MainActor +private final class TestTransport: TerminalTransport { + let nativeView: AnyObject = NSObject() + var isRunning = false + var shellName = "Shell" + var onTermination: ((Int32?) -> Void)? + var onTitle: ((String) -> Void)? + var onDirectoryUpdate: ((String?) -> Void)? + var onLink: ((String, [String: String]) -> Void)? + var stopCount = 0 + func defaultShellPath() -> String { "/bin/zsh" } + func defaultEnvironment() -> [String: String] { [:] } + func start(workingDirectory: String, shellPath: String, environment: [String: String]) throws { isRunning = true } + func send(_ input: Data) throws {} + func interrupt() throws {} + func focus() {} + func clear() {} + func stop() { if isRunning { stopCount += 1 }; isRunning = false } +} diff --git a/Tests/LitheTests/CommitMessageTests.swift b/Tests/LitheTests/CommitMessageTests.swift index 506ef483..76c8be16 100644 --- a/Tests/LitheTests/CommitMessageTests.swift +++ b/Tests/LitheTests/CommitMessageTests.swift @@ -1,5 +1,7 @@ import AppKit import Foundation +import LitheAIAssistanceModule +import LitheCoreContracts import Testing @testable import Lithe diff --git a/Tests/LitheTests/GitStatusObservationTests.swift b/Tests/LitheTests/GitStatusObservationTests.swift index b80f5d58..70246865 100644 --- a/Tests/LitheTests/GitStatusObservationTests.swift +++ b/Tests/LitheTests/GitStatusObservationTests.swift @@ -1,4 +1,6 @@ import Foundation +@testable import LitheGitModule +import LitheSearchModule import Testing @testable import Lithe diff --git a/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift b/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift new file mode 100644 index 00000000..9a356bd0 --- /dev/null +++ b/Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift @@ -0,0 +1,81 @@ +import Darwin +import Foundation +import LitheApplicationKernel +import LitheCoreContracts +import LitheGoSupportModule +import LitheModuleAPI +import Testing +@testable import Lithe + +@Suite("Language extension process lifecycle") +@MainActor +struct LanguageExtensionProcessLifecycleTests { + @Test + func disablingGoExecutionTerminatesItsOwnedMacProcess() async throws { + let sleepURL = URL(fileURLWithPath: "/bin/sleep") + guard FileManager.default.isExecutableFile(atPath: sleepURL.path) else { return } + + let processRegistry = ManagedProcessRegistry() + let executionHost = MacLanguageExecutionHost(processRegistry: processRegistry) + let runtime = ModuleRuntime() + let workspace = BuiltInModuleCatalog.manifest(for: .workspace)! + try runtime.register(ModuleFactory(manifest: workspace) { + ProcessLifecycleWorkspaceModule(manifest: workspace) + }) + try runtime.register(ModuleFactory(manifest: GoExecutionModule.moduleManifest) { + GoExecutionModule(executionHost: executionHost) + }) + + let capability = try #require( + try await runtime.activateCapability(.languageExecutionExtension("go")) + as? any LanguageRunExtensionProviding + ) + let executionSession = capability.makeExecutionSession() + try executionSession.start(LanguageExecutionProcessRequest( + operationID: "go-process-lifecycle-test", + executablePath: sleepURL.path, + arguments: ["30"] + )) + let moduleID = GoExecutionModule.moduleManifest.id + let pid = try #require(processRegistry.processIDs(for: moduleID).first) + #expect(Darwin.kill(pid, 0) == 0) + + try await runtime.setEnabled(false, for: moduleID) + + #expect(processRegistry.processIDs(for: moduleID).isEmpty) + #expect(try runtime.snapshot(for: moduleID).state == .disabled) + #expect(await processExited(pid)) + } + + private func processExited(_ pid: Int32) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while clock.now < deadline { + if Darwin.kill(pid, 0) == -1, errno == ESRCH { return true } + try? await Task.sleep(for: .milliseconds(20)) + } + return Darwin.kill(pid, 0) == -1 && errno == ESRCH + } +} + +@MainActor +private final class ProcessLifecycleWorkspaceModule: LitheModule { + let manifest: ModuleManifest + private var capability: ProcessLifecycleWorkspaceCapability? + + init(manifest: ModuleManifest) { + self.manifest = manifest + } + + func activate(context _: ModuleContext) async throws { + capability = ProcessLifecycleWorkspaceCapability() + } + func prepareForSleep() async throws {} + func sleep() async { capability = nil } + func shutdown() async { capability = nil } + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + capability.map { [.workspaceFoundation: $0] } ?? [:] + } +} + +private final class ProcessLifecycleWorkspaceCapability {} diff --git a/Tests/LitheTests/LanguageFeatureProviderTests.swift b/Tests/LitheTests/LanguageFeatureProviderTests.swift index 414f338c..93c76246 100644 --- a/Tests/LitheTests/LanguageFeatureProviderTests.swift +++ b/Tests/LitheTests/LanguageFeatureProviderTests.swift @@ -1,4 +1,5 @@ import Foundation +import LitheLanguageIntelligenceModule import Testing @testable import Lithe diff --git a/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift b/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift index cc08b623..b79402d7 100644 --- a/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift +++ b/Tests/LitheTests/LanguageProviderCatalogSourceTests.swift @@ -1,4 +1,6 @@ import Foundation +import LitheCoreContracts +import LitheModuleAPI import Testing @testable import Lithe @@ -87,6 +89,66 @@ struct LanguageProviderCatalogSourceTests { #expect(snapshot.schemaVersion == 2) } + @Test + func installedLanguagePackageAddsAProviderMissingFromTheRustCatalog() throws { + let source = PluginLanguageProviderCatalogSource( + base: RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader( + isAvailable: true, + data: catalogPayload(origin: "builtin") + )), + languageSupports: [LanguageSupportDeclaration( + id: "zig", + displayName: "Zig", + fileExtensions: ["zig"], + projectFileNames: ["build.zig"], + languageServerModuleID: .languageServerExtension("zig"), + executionModuleID: .languageExecutionExtension("zig"), + testingModuleID: .languageExecutionExtension("zig") + )] + ) + + let workspaceURL = URL(fileURLWithPath: "/tmp/zig-workspace", isDirectory: true) + let snapshot = source.load(workspaceURL: workspaceURL) + let provider = try #require(snapshot.catalog.provider( + for: workspaceURL.appendingPathComponent("src/main.zig") + )) + + #expect(provider.id == "zig") + #expect(provider.displayName == "Zig") + #expect(provider.capabilities.contains(.languageServer)) + #expect(provider.capabilities.contains(.run)) + #expect(!provider.capabilities.contains(.debugAdapter)) + #expect(provider.capabilities.contains(.testing)) + } + + @Test + func packageDeclarationOwnsItsProcessBackedCapabilities() throws { + let source = PluginLanguageProviderCatalogSource( + base: RustLanguageProviderCatalogSource(loader: CatalogPayloadLoader( + isAvailable: false, + data: nil + )), + languageSupports: [LanguageSupportDeclaration( + id: "go", + displayName: "Go", + fileExtensions: ["go"], + languageServerModuleID: .languageServerExtension("go"), + executionModuleID: .languageExecutionExtension("go"), + testingModuleID: .languageExecutionExtension("go") + )] + ) + + let provider = try #require(source.load(workspaceURL: nil).catalog.provider( + for: URL(fileURLWithPath: "/tmp/main.go") + )) + + #expect(provider.capabilities.contains(.languageServer)) + #expect(provider.capabilities.contains(.run)) + #expect(!provider.capabilities.contains(.debugAdapter)) + #expect(provider.capabilities.contains(.testing)) + #expect(provider.languageServerLaunch == nil) + } + private func catalogPayload(origin: String, diagnostics: String = "[]") -> Data { Data(""" { diff --git a/Tests/LitheTests/LanguageServerToolServiceTests.swift b/Tests/LitheTests/LanguageServerToolServiceTests.swift index 6493292c..a4489358 100644 --- a/Tests/LitheTests/LanguageServerToolServiceTests.swift +++ b/Tests/LitheTests/LanguageServerToolServiceTests.swift @@ -1,4 +1,6 @@ import Foundation +import LitheCoreContracts +import LitheLanguageIntelligenceModule import Testing @testable import Lithe @@ -24,8 +26,8 @@ struct LanguageServerToolServiceTests { let descriptor = goDescriptor() let service = LanguageServerToolService( runtimeService: runtime, - processRunner: LanguageServerToolTestProcessRunner(), - store: store + commandRunner: LanguageServerToolTestProcessRunner(), + settingsStore: store ) try await service.setCustomExecutablePath(customURL.path, for: descriptor) @@ -34,8 +36,8 @@ struct LanguageServerToolServiceTests { let restored = LanguageServerToolService( runtimeService: runtime, - processRunner: LanguageServerToolTestProcessRunner(), - store: store + commandRunner: LanguageServerToolTestProcessRunner(), + settingsStore: store ) #expect(restored.customExecutablePath(for: descriptor.id) == customURL.path) restored.clearCustomExecutablePath(for: descriptor.id) @@ -47,8 +49,8 @@ struct LanguageServerToolServiceTests { let store = LanguageServerToolTestStore() let service = LanguageServerToolService( runtimeService: makeRuntime(executablePaths: [], candidates: [:], store: store), - processRunner: LanguageServerToolTestProcessRunner(), - store: store + commandRunner: LanguageServerToolTestProcessRunner(), + settingsStore: store ) await #expect(throws: LanguageServerToolConfigurationError.executableInvalid("/missing/gopls")) { @@ -87,8 +89,8 @@ struct LanguageServerToolServiceTests { ], store: store ), - processRunner: runner, - store: store + commandRunner: runner, + settingsStore: store ) let candidates = await service.refreshCandidates(for: rustDescriptor()) @@ -112,8 +114,8 @@ struct LanguageServerToolServiceTests { candidates: [:], store: store ), - processRunner: runner, - store: store + commandRunner: runner, + settingsStore: store ) await #expect(throws: LanguageServerToolConfigurationError.executableValidationFailed( @@ -141,8 +143,8 @@ struct LanguageServerToolServiceTests { ], store: store ), - processRunner: runner, - store: store + commandRunner: runner, + settingsStore: store ) #expect(service.executableVerificationState(for: goDescriptor()) == .foundUnverified) @@ -168,8 +170,8 @@ struct LanguageServerToolServiceTests { ], store: store ), - processRunner: runner, - store: store + commandRunner: runner, + settingsStore: store ) #expect(service.executableVerificationState(for: rustDescriptor()) == .unavailable) @@ -199,8 +201,8 @@ struct LanguageServerToolServiceTests { ) let service = LanguageServerToolService( runtimeService: runtime, - processRunner: runner, - store: store + commandRunner: runner, + settingsStore: store ) await service.installWithHomebrew(goDescriptor()) @@ -340,7 +342,7 @@ private struct LanguageServerToolTestDiscovery: RuntimeToolDiscovery { } } -private final class LanguageServerToolTestProcessRunner: ProcessRunner, @unchecked Sendable { +private final class LanguageServerToolTestProcessRunner: ProcessRunner, LanguageToolCommandRunning, @unchecked Sendable { private let lock = NSLock() private let result: ProcessResult private let resultsByExecutablePath: [String: ProcessResult] @@ -366,9 +368,27 @@ private final class LanguageServerToolTestProcessRunner: ProcessRunner, @uncheck lock.withLock { recordedRequests.append(request) } return resultsByExecutablePath[request.executablePath] ?? result } + + func runLanguageToolCommand( + operationID: String, + executableURL: URL, + arguments: [String], + environment: [String: String], + timeoutMilliseconds: Int + ) -> LanguageToolCommandResult { + let result = run(ProcessRequest( + operationID: operationID, + executablePath: executableURL.path, + arguments: arguments, + environment: environment, + timeoutMilliseconds: timeoutMilliseconds + )) + return LanguageToolCommandResult(output: result.output, exitCode: result.exitCode) + } } -private final class LanguageServerToolTestStore: KeyValueStore { +private final class LanguageServerToolTestStore: KeyValueStore, LanguageToolSettingsStoring { + private static let languageToolKey = "lithe.language-server-tools.executable-paths" private var values: [String: Any] = [:] func data(forKey key: String) -> Data? { values[key] as? Data } @@ -376,4 +396,13 @@ private final class LanguageServerToolTestStore: KeyValueStore { 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 } + + func loadLanguageToolExecutablePaths() -> [String: String] { + guard let data = data(forKey: Self.languageToolKey) else { return [:] } + return (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:] + } + + func saveLanguageToolExecutablePaths(_ paths: [String: String]) { + set(try? JSONEncoder().encode(paths), forKey: Self.languageToolKey) + } } diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index 059eac98..e3778312 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -1,7 +1,12 @@ import AppKit import CoreServices import Foundation +@testable import LitheDatabaseModule +@testable import LitheGitModule +import LitheLocalHistoryModule +import LitheSearchModule import Testing +import LitheTerminalModule @testable import Lithe @Suite("Lithe core logic") @@ -2364,7 +2369,7 @@ private final class TestProjectWindowSessions: ProjectWindowSessionHandling { } } -private final class RecordingProcessRunner: ProcessRunner, @unchecked Sendable { +private final class RecordingProcessRunner: ProcessRunner, DatabaseProcessRunning, @unchecked Sendable { private let lock = NSLock() private let handler: (ProcessRequest) -> ProcessResult private let requestsLock = NSLock() @@ -2390,6 +2395,16 @@ private final class RecordingProcessRunner: ProcessRunner, @unchecked Sendable { requestsLock.unlock() return handler(request) } + + func runDatabaseProcess(_ request: DatabaseProcessRequest) -> DatabaseProcessResult { + let result = run(ProcessRequest( + executablePath: request.executablePath, + environment: request.environment, + standardInput: request.standardInput, + timeoutMilliseconds: request.timeoutMilliseconds + )) + return DatabaseProcessResult(output: result.output, exitCode: result.exitCode) + } } private final class TestCounter: @unchecked Sendable { @@ -2410,7 +2425,7 @@ private final class TestCounter: @unchecked Sendable { } } -private final class DatabaseTestKeyValueStore: KeyValueStore, @unchecked Sendable { +private final class DatabaseTestKeyValueStore: KeyValueStore, DatabasePreferenceStore, @unchecked Sendable { private var values: [String: Any] = [:] func data(forKey key: String) -> Data? { values[key] as? Data } func object(forKey key: String) -> Any? { values[key] } @@ -2419,7 +2434,7 @@ private final class DatabaseTestKeyValueStore: KeyValueStore, @unchecked Sendabl func set(_ value: Any?, forKey key: String) { values[key] = value } } -private final class DatabaseTestSecureStore: SecureStore, @unchecked Sendable { +private final class DatabaseTestSecureStore: SecureStore, DatabaseSecureStore, @unchecked Sendable { private var values: [String: String] = [:] func read(key: String) -> String? { values[key] } func write(_ value: String, key: String) throws { values[key] = value } @@ -2724,7 +2739,7 @@ struct EditorDocumentTests { operations: operations, fileOperations: EmptyWorkspaceFileOperations(), fileStorage: InMemoryFileStorage(), - gitWatchContextProvider: GitService(operations: RustGitOperations(core: RustCoreBridge())), + gitWatchContextProvider: SequencedGitWatchContextProvider([nil]), directoryWatcherFactory: TestDirectoryWatcherFactory(), workspaceSessionStore: WorkspaceSessionStore(store: EmptyKeyValueStore()) ) @@ -2862,7 +2877,7 @@ struct EditorDocumentTests { operations: EmptyWorkspaceOperations(), fileOperations: EmptyWorkspaceFileOperations(), fileStorage: InMemoryFileStorage(), - gitWatchContextProvider: GitService(operations: RustGitOperations(core: RustCoreBridge())), + gitWatchContextProvider: SequencedGitWatchContextProvider([nil]), directoryWatcherFactory: watcherFactory, workspaceSessionStore: WorkspaceSessionStore(store: EmptyKeyValueStore()) ) @@ -3343,7 +3358,7 @@ private final class TestTerminalTransport: TerminalTransport { } } -private final class InMemoryFileStorage: FileStorage, @unchecked Sendable { +private final class InMemoryFileStorage: FileStorage, GitShelfStorage, DatabaseFileStorage, @unchecked Sendable { private let lock = NSLock() private let support = URL(fileURLWithPath: "/in-memory-application-support", isDirectory: true) private var files: [String: Data] = [:] @@ -3380,6 +3395,10 @@ private final class InMemoryFileStorage: FileStorage, @unchecked Sendable { return value } + func readData(from url: URL) throws -> Data { + try readData(from: url, options: []) + } + func readPrefix(from url: URL, byteCount: Int) throws -> Data { try readData(from: url, options: []).prefix(byteCount) } @@ -3390,12 +3409,22 @@ private final class InMemoryFileStorage: FileStorage, @unchecked Sendable { lock.unlock() } + + func writeData(_ data: Data, to url: URL) throws { + try writeData(data, to: url, options: []) + } + func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws { lock.lock() directories.insert(url.path) lock.unlock() } + + func createDirectory(at url: URL) throws { + try createDirectory(at: url, withIntermediateDirectories: true) + } + func removeItem(at url: URL) throws { lock.lock() defer { lock.unlock() } diff --git a/Tests/LitheTests/MemoryUsageMonitorTests.swift b/Tests/LitheTests/MemoryUsageMonitorTests.swift index 9fc9841c..704a1939 100644 --- a/Tests/LitheTests/MemoryUsageMonitorTests.swift +++ b/Tests/LitheTests/MemoryUsageMonitorTests.swift @@ -1,10 +1,27 @@ import Combine import Foundation +import LitheModuleAPI import Testing @testable import Lithe @MainActor struct MemoryUsageMonitorTests { + @Test + func managedProcessRegistryTracksModuleOwnershipAndLegacyCategories() { + let registry = ManagedProcessRegistry() + registry.register(pid: 101, category: .languageServer, moduleID: .languageIntelligence) + registry.register(pid: 202, category: .service, moduleID: .execution) + + #expect(registry.processIDs(for: .languageServer) == [101]) + #expect(registry.processIDs(for: .service) == [202]) + #expect(registry.processIDs(for: .languageIntelligence) == [101]) + #expect(registry.processCount(for: .execution) == 1) + + registry.unregister(pid: 101, category: .languageServer, moduleID: .languageIntelligence) + #expect(registry.processIDs(for: .languageIntelligence).isEmpty) + #expect(registry.processIDs(for: .languageServer).isEmpty) + } + @Test func managedProcessCategoriesAggregateAndRelease() { let registry = ManagedProcessRegistry() diff --git a/Tests/LitheTests/NativePluginLoaderTests.swift b/Tests/LitheTests/NativePluginLoaderTests.swift new file mode 100644 index 00000000..6c803981 --- /dev/null +++ b/Tests/LitheTests/NativePluginLoaderTests.swift @@ -0,0 +1,160 @@ +import Foundation +@testable import Lithe +import LitheModuleAPI +import Testing + +@MainActor +struct NativePluginLoaderTests { + @Test + func disabledPluginDoesNotLoadItsBundle() throws { + let codeLoader = TestPrincipalClassLoader() + let loader = MacNativePluginLoader(codeLoader: codeLoader) + let installed = installedTestPlugin() + let policy = MacPluginLoadPolicy( + configurationStore: TestPluginConfigurationStore(enabled: false), + recoveryStore: nil, + launchMode: .normal + ) + + let factories = try loader.loadFactories(from: [installed], policy: policy) + + #expect(factories.isEmpty) + #expect(codeLoader.loadCount == 0) + } + + @Test + func quarantinedAndSafeModePluginsDoNotLoadTheirBundles() throws { + let installed = installedTestPlugin() + let recovery = TestPluginRecoveryStore(quarantined: [testModuleManifest.id]) + let quarantinedLoader = TestPrincipalClassLoader() + let safeModeLoader = TestPrincipalClassLoader() + + let quarantined = try MacNativePluginLoader(codeLoader: quarantinedLoader).loadFactories( + from: [installed], + policy: MacPluginLoadPolicy( + configurationStore: TestPluginConfigurationStore(enabled: true), + recoveryStore: recovery, + launchMode: .normal + ) + ) + let safeMode = try MacNativePluginLoader(codeLoader: safeModeLoader).loadFactories( + from: [installed], + policy: MacPluginLoadPolicy( + configurationStore: TestPluginConfigurationStore(enabled: true), + recoveryStore: nil, + launchMode: .safeMode + ) + ) + + #expect(quarantined.isEmpty) + #expect(safeMode.isEmpty) + #expect(quarantinedLoader.loadCount == 0) + #expect(safeModeLoader.loadCount == 0) + } + + @Test + func enabledPluginLoadsAndMustMatchItsStaticFactoryCatalog() throws { + let codeLoader = TestPrincipalClassLoader() + let loader = MacNativePluginLoader(codeLoader: codeLoader) + let installed = installedTestPlugin() + let policy = MacPluginLoadPolicy( + configurationStore: TestPluginConfigurationStore(enabled: true), + recoveryStore: nil, + launchMode: .normal + ) + + let factories = try loader.loadFactories(from: [installed], policy: policy) + + #expect(factories[installed.manifest.id]?.map(\.manifest.id) == [testModuleManifest.id]) + #expect(codeLoader.loadCount == 1) + } + + private func installedTestPlugin() -> InstalledPluginPackage { + let version = PluginVersion(major: 0, minor: 3, patch: 0) + let manifest = PluginManifest( + id: PluginID("dev.example.plugin"), + displayName: "Example Plugin", + version: version, + hostCompatibility: PluginHostCompatibility( + minimum: version, + maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) + ), + vendor: PluginVendor( + id: "dev.example", + displayName: "Example", + signatureRequirement: .sameTeamAsHost + ), + entrypoint: PluginEntrypoint( + kind: .nativeBundle, + bundleIdentifier: "dev.example.plugin", + principalClass: "TestNativePluginEntrypoint", + bundlePath: "Example.bundle" + ), + modules: [PluginModuleDeclaration(manifest: testModuleManifest)] + ) + return InstalledPluginPackage( + manifest: manifest, + installation: PluginInstallationRecord( + pluginID: manifest.id, + activeVersion: version, + origin: .marketplace + ), + packageURL: URL(fileURLWithPath: "/tmp/lithe-test-plugin", isDirectory: true) + ) + } +} + +private let testModuleManifest = ModuleManifest( + id: ModuleID("dev.example.feature"), + displayName: "Example Feature", + scope: .application, + providedCapabilities: [ModuleCapabilityID("dev.example.feature.capability")] +) + +private final class TestPrincipalClassLoader: PluginPrincipalClassLoading { + private(set) var loadCount = 0 + + func principalClass(at bundleURL: URL) throws -> AnyClass { + loadCount += 1 + return TestNativePluginEntrypoint.self + } +} + +@MainActor +private final class TestNativePluginEntrypoint: LithePluginEntrypoint { + required init() {} + + func moduleFactories(context: PluginHostContext) throws -> [ModuleFactory] { + [ModuleFactory(manifest: testModuleManifest) { + TestNativeModule() + }] + } +} + +@MainActor +private final class TestNativeModule: LitheModule { + let manifest = testModuleManifest + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async {} + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + [ModuleCapabilityID("dev.example.feature.capability"): NSObject()] + } +} + +private final class TestPluginConfigurationStore: ModuleConfigurationStore, @unchecked Sendable { + private let enabled: Bool + init(enabled: Bool) { self.enabled = enabled } + func enabledState(for moduleID: ModuleID) -> Bool? { enabled } + func setEnabledState(_ enabled: Bool, for moduleID: ModuleID) {} +} + +private final class TestPluginRecoveryStore: ModuleRecoveryStore, @unchecked Sendable { + private let quarantined: Set + init(quarantined: Set) { self.quarantined = quarantined } + func pendingActivation() -> ModuleID? { nil } + func setPendingActivation(_ moduleID: ModuleID?) {} + func isQuarantined(_ moduleID: ModuleID) -> Bool { quarantined.contains(moduleID) } + func setQuarantined(_ quarantined: Bool, for moduleID: ModuleID) {} +} diff --git a/Tests/LitheTests/OutputTimestamperTests.swift b/Tests/LitheTests/OutputTimestamperTests.swift index 61abf37a..988f68e9 100644 --- a/Tests/LitheTests/OutputTimestamperTests.swift +++ b/Tests/LitheTests/OutputTimestamperTests.swift @@ -71,6 +71,7 @@ struct OutputTimestamperTests { } @Suite("Output severity coloring") +@MainActor struct OutputSeverityTests { @Test func recognizesBracketedMavenLevels() { diff --git a/Tests/LitheTests/PluginManagerTests.swift b/Tests/LitheTests/PluginManagerTests.swift new file mode 100644 index 00000000..e87c134b --- /dev/null +++ b/Tests/LitheTests/PluginManagerTests.swift @@ -0,0 +1,212 @@ +import Foundation +@testable import Lithe +import LitheApplicationKernel +import LitheModuleAPI +import Testing + +@MainActor +struct PluginManagerTests { + @Test + func internalLifecycleModulesAreNotShownAsInstalledPlugins() { + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = PluginManagerKeyValueStore() + let configuration = MacModuleConfigurationStore(store: preferences) + let manager = MacPluginManager( + packageStore: MacPluginPackageStore(rootURL: root), + moduleRuntime: ModuleRuntime(configurationStore: configuration, recoveryStore: configuration), + configurationStore: configuration, + launchMode: .normal, + startup: MacPluginStartupResult( + installedPlugins: [], + activeNativeManifests: [], + factoriesByPlugin: [:], + issues: [] + ) + ) + + #expect(manager.snapshots.isEmpty) + } + + @Test + func enablingUnloadedNativePluginPersistsPreferenceAndRequiresRestart() async throws { + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = PluginManagerKeyValueStore() + let configuration = MacModuleConfigurationStore(store: preferences) + let runtime = ModuleRuntime(configurationStore: configuration, recoveryStore: configuration) + let installed = installedPlugin(defaultState: .disabled) + let manager = MacPluginManager( + packageStore: MacPluginPackageStore(rootURL: root), + moduleRuntime: runtime, + configurationStore: configuration, + launchMode: .normal, + startup: MacPluginStartupResult( + installedPlugins: [installed], + activeNativeManifests: [], + factoriesByPlugin: [:], + issues: [] + ) + ) + + try await manager.setEnabled(true, for: installed.manifest.id) + + #expect(configuration.enabledState(for: pluginManagerModuleManifest.id) == true) + let snapshot = try #require(manager.snapshots.first { $0.id == installed.manifest.id }) + #expect(snapshot.isEnabled) + #expect(snapshot.requiresRestart) + } + + @Test + func disablingLoadedNativePluginStopsItsModuleAndRequiresRestart() async throws { + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = PluginManagerKeyValueStore() + let configuration = MacModuleConfigurationStore(store: preferences) + configuration.setEnabledState(true, for: pluginManagerModuleManifest.id) + let runtime = ModuleRuntime(configurationStore: configuration, recoveryStore: configuration) + let recorder = PluginManagerModuleRecorder() + try runtime.register(ModuleFactory(manifest: pluginManagerModuleManifest) { + PluginManagerTestModule(recorder: recorder) + }) + _ = try await runtime.activate(pluginManagerModuleManifest.id) + let installed = installedPlugin(defaultState: .enabled) + let manager = MacPluginManager( + packageStore: MacPluginPackageStore(rootURL: root), + moduleRuntime: runtime, + configurationStore: configuration, + launchMode: .normal, + startup: MacPluginStartupResult( + installedPlugins: [installed], + activeNativeManifests: [installed.manifest], + factoriesByPlugin: [:], + issues: [] + ) + ) + + try await manager.setEnabled(false, for: installed.manifest.id) + + #expect(recorder.shutdownCount == 1) + #expect(try runtime.snapshot(for: pluginManagerModuleManifest.id).state == .disabled) + let snapshot = try #require(manager.snapshots.first { $0.id == installed.manifest.id }) + #expect(!snapshot.isEnabled) + #expect(snapshot.requiresRestart) + } + + @Test + func quarantinedUnloadedPluginCanBeReEnabledDirectly() async throws { + let root = temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let preferences = PluginManagerKeyValueStore() + let configuration = MacModuleConfigurationStore(store: preferences) + configuration.setEnabledState(true, for: pluginManagerModuleManifest.id) + configuration.setQuarantined(true, for: pluginManagerModuleManifest.id) + let runtime = ModuleRuntime(configurationStore: configuration, recoveryStore: configuration) + let installed = installedPlugin(defaultState: .enabled) + let manager = MacPluginManager( + packageStore: MacPluginPackageStore(rootURL: root), + moduleRuntime: runtime, + configurationStore: configuration, + launchMode: .normal, + startup: MacPluginStartupResult( + installedPlugins: [installed], + activeNativeManifests: [], + factoriesByPlugin: [:], + issues: [] + ) + ) + + let quarantined = try #require(manager.snapshots.first { $0.id == installed.manifest.id }) + #expect(!quarantined.isEnabled) + #expect(quarantined.isQuarantined) + + try await manager.setEnabled(true, for: installed.manifest.id) + + #expect(!configuration.isQuarantined(pluginManagerModuleManifest.id)) + let enabled = try #require(manager.snapshots.first { $0.id == installed.manifest.id }) + #expect(enabled.isEnabled) + #expect(enabled.requiresRestart) + } + + private func temporaryRoot() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-manager-\(UUID().uuidString)", isDirectory: true) + } + + private func installedPlugin(defaultState: ModuleDefaultState) -> InstalledPluginPackage { + let manifest = PluginManifest( + id: PluginID("dev.example.manager-plugin"), + displayName: "Manager Plugin", + version: BuiltInPluginCatalog.hostVersion, + hostCompatibility: PluginHostCompatibility( + minimum: BuiltInPluginCatalog.hostVersion, + maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) + ), + vendor: PluginVendor( + id: "dev.example", + displayName: "Example", + signatureRequirement: .sameTeamAsHost + ), + entrypoint: PluginEntrypoint( + kind: .nativeBundle, + bundleIdentifier: "dev.example.manager-plugin", + principalClass: "ExamplePlugin", + bundlePath: "Example.bundle" + ), + modules: [PluginModuleDeclaration(manifest: ModuleManifest( + id: pluginManagerModuleManifest.id, + displayName: pluginManagerModuleManifest.displayName, + scope: pluginManagerModuleManifest.scope, + defaultState: defaultState, + activationPolicy: pluginManagerModuleManifest.activationPolicy, + sleepPolicy: pluginManagerModuleManifest.sleepPolicy, + dependencies: pluginManagerModuleManifest.dependencies, + providedCapabilities: pluginManagerModuleManifest.providedCapabilities + ))] + ) + return InstalledPluginPackage( + manifest: manifest, + installation: PluginInstallationRecord( + pluginID: manifest.id, + activeVersion: manifest.version, + origin: .marketplace + ), + packageURL: temporaryRoot() + ) + } +} + +private let pluginManagerModuleManifest = ModuleManifest( + id: ModuleID("dev.example.manager-module"), + displayName: "Manager Module", + scope: .application, + defaultState: .enabled, + activationPolicy: .onDemand +) + +@MainActor +private final class PluginManagerTestModule: LitheModule { + let manifest = pluginManagerModuleManifest + private let recorder: PluginManagerModuleRecorder + + init(recorder: PluginManagerModuleRecorder) { self.recorder = recorder } + func activate(context: ModuleContext) async throws {} + func prepareForSleep() async throws {} + func sleep() async {} + func shutdown() async { recorder.shutdownCount += 1 } + func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { [:] } +} + +@MainActor +private final class PluginManagerModuleRecorder { + var shutdownCount = 0 +} + +private final class PluginManagerKeyValueStore: KeyValueStore, @unchecked Sendable { + 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/PluginPackageStoreTests.swift b/Tests/LitheTests/PluginPackageStoreTests.swift new file mode 100644 index 00000000..54437deb --- /dev/null +++ b/Tests/LitheTests/PluginPackageStoreTests.swift @@ -0,0 +1,456 @@ +import Foundation +@testable import Lithe +import LitheModuleAPI +import Testing + +struct PluginPackageStoreTests { + @Test + func bundledOfficialPluginIsVisibleWithoutAUserInstallation() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let source = try makePackage( + root: root, + name: "dev.example.plugin", + version: PluginVersion(major: 0, minor: 3, patch: 0) + ) + let bundledRoot = root.appendingPathComponent("bundled", isDirectory: true) + try FileManager.default.createDirectory(at: bundledRoot, withIntermediateDirectories: true) + try FileManager.default.moveItem( + at: source, + to: bundledRoot.appendingPathComponent("dev.example.plugin", isDirectory: true) + ) + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + bundledRootURL: bundledRoot, + verifier: TestPluginSignatureVerifier() + ) + + let plugin = try #require(try store.installedPlugins().first) + + #expect(plugin.installation.origin == .bundled) + #expect(plugin.manifest.version == PluginVersion(major: 0, minor: 3, patch: 0)) + } + + @Test + func validUserUpdateOverridesBundledVersion() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let bundledSource = try makePackage( + root: root, + name: "dev.example.plugin", + version: PluginVersion(major: 0, minor: 3, patch: 0) + ) + let bundledRoot = root.appendingPathComponent("bundled", isDirectory: true) + try FileManager.default.createDirectory(at: bundledRoot, withIntermediateDirectories: true) + try FileManager.default.moveItem( + at: bundledSource, + to: bundledRoot.appendingPathComponent("dev.example.plugin", isDirectory: true) + ) + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + bundledRootURL: bundledRoot, + verifier: TestPluginSignatureVerifier() + ) + _ = try store.installPackage(from: makePackage( + root: root, + name: "update", + version: PluginVersion(major: 0, minor: 3, patch: 1) + )) + + let plugin = try #require(try store.installedPlugins().first) + + #expect(plugin.installation.origin == .marketplace) + #expect(plugin.manifest.version == PluginVersion(major: 0, minor: 3, patch: 1)) + } + + @Test + func installUpdateRollbackAndUninstallUseStaticManifests() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let verifier = TestPluginSignatureVerifier() + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + verifier: verifier + ) + let versionOne = PluginVersion(major: 0, minor: 3, patch: 0) + let versionTwo = PluginVersion(major: 0, minor: 3, patch: 1) + let firstSource = try makePackage(root: root, name: "first", version: versionOne) + let secondSource = try makePackage(root: root, name: "second", version: versionTwo) + + let first = try store.installPackage(from: firstSource) + #expect(first.installation.activeVersion == versionOne) + #expect(first.installation.previousVersion == nil) + + let second = try store.installPackage(from: secondSource) + #expect(second.installation.activeVersion == versionTwo) + #expect(second.installation.previousVersion == versionOne) + + let scanned = try store.installedPlugins() + #expect(scanned.map(\.manifest.version) == [versionTwo]) + #expect(verifier.verifiedVersions == [versionOne, versionTwo, versionTwo]) + + let restored = try store.rollback(second.manifest.id) + #expect(restored.installation.activeVersion == versionOne) + #expect(restored.installation.previousVersion == versionTwo) + + try store.uninstall(second.manifest.id) + #expect(try store.installedPlugins().isEmpty) + } + + @Test + func damagedPackageDoesNotHideValidInstalledPlugins() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let installedRoot = root.appendingPathComponent("installed", isDirectory: true) + let store = MacPluginPackageStore( + rootURL: installedRoot, + verifier: TestPluginSignatureVerifier() + ) + let source = try makePackage( + root: root, + name: "valid", + version: PluginVersion(major: 0, minor: 3, patch: 0) + ) + _ = try store.installPackage(from: source) + try FileManager.default.createDirectory( + at: installedRoot.appendingPathComponent("dev.example.broken", isDirectory: true), + withIntermediateDirectories: true + ) + + let result = try store.scanInstalledPlugins() + + #expect(result.packages.map(\.manifest.id) == [PluginID("dev.example.plugin")]) + #expect(result.issues.count == 1) + } + + @Test + func damagedPackageCanBeRemovedWithoutReadingItsManifest() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let installedRoot = root.appendingPathComponent("installed", isDirectory: true) + let store = MacPluginPackageStore( + rootURL: installedRoot, + verifier: TestPluginSignatureVerifier() + ) + let installed = try store.installPackage(from: makePackage( + root: root, + name: "damaged", + version: PluginVersion(major: 0, minor: 3, patch: 0) + )) + try FileManager.default.removeItem( + at: installed.packageURL.appendingPathComponent("plugin.json") + ) + #expect(try store.scanInstalledPlugins().issues.count == 1) + + try store.stageInvalidPackageUninstall(installed.manifest.id) + try store.prepareForLaunch() + + #expect(try store.scanInstalledPlugins().packages.isEmpty) + #expect(try store.scanInstalledPlugins().issues.isEmpty) + } + + @Test + func rejectedUpdateLeavesCurrentVersionActive() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let versionOne = PluginVersion(major: 0, minor: 3, patch: 0) + let versionTwo = PluginVersion(major: 0, minor: 3, patch: 1) + let verifier = TestPluginSignatureVerifier(rejectedVersions: [versionTwo]) + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + verifier: verifier + ) + let firstSource = try makePackage(root: root, name: "first", version: versionOne) + let secondSource = try makePackage(root: root, name: "second", version: versionTwo) + + _ = try store.installPackage(from: firstSource) + #expect(throws: TestPluginSignatureError.rejected) { + _ = try store.installPackage(from: secondSource) + } + + let installed = try #require(try store.installedPlugins().first) + #expect(installed.manifest.version == versionOne) + #expect(installed.installation.previousVersion == nil) + } + + @Test + func requiredPluginCannotBeUninstalled() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let verifier = TestPluginSignatureVerifier() + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + verifier: verifier + ) + let source = try makePackage( + root: root, + name: "required", + version: PluginVersion(major: 0, minor: 3, patch: 0), + required: true + ) + let installed = try store.installPackage(from: source) + + #expect(throws: PluginPackageStoreError.requiredPluginCannotBeUninstalled( + installed.manifest.id + )) { + try store.uninstall(installed.manifest.id) + } + } + + @Test + func deferredUpdateAndUninstallCompleteAtNextLaunch() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + verifier: TestPluginSignatureVerifier() + ) + let versionOne = PluginVersion(major: 0, minor: 3, patch: 0) + let versionTwo = PluginVersion(major: 0, minor: 3, patch: 1) + _ = try store.installPackage(from: makePackage(root: root, name: "one", version: versionOne)) + let updated = try store.installPackage( + from: makePackage(root: root, name: "two", version: versionTwo), + deferActivationUntilRestart: true + ) + #expect(updated.installation.status == .updateStaged) + + try store.prepareForLaunch() + let active = try #require(try store.installedPlugins().first) + #expect(active.installation.status == .installed) + #expect(active.manifest.version == versionTwo) + + try store.stageUninstall(active.manifest.id) + let pending = try #require(try store.installedPlugins().first) + #expect(pending.installation.status == .uninstallPending) + try store.prepareForLaunch() + #expect(try store.installedPlugins().isEmpty) + } + + @MainActor + @Test + func interruptedPluginCodeLoadIsQuarantinedBeforeRetry() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + verifier: TestPluginSignatureVerifier() + ) + _ = try store.installPackage(from: makePackage( + root: root, + name: "recover", + version: PluginVersion(major: 0, minor: 3, patch: 0) + )) + let moduleID = ModuleID("dev.example.feature") + let recovery = PluginStartupRecoveryStore(pending: [moduleID]) + let codeLoader = CountingPluginPrincipalClassLoader() + + let result = MacPluginStartupLoader( + packageStore: store, + nativeLoader: MacNativePluginLoader(codeLoader: codeLoader) + ).load(policy: MacPluginLoadPolicy( + configurationStore: PluginStartupConfigurationStore(enabled: true), + recoveryStore: recovery, + launchMode: .normal + )) + + #expect(result.activeNativeManifests.isEmpty) + #expect(codeLoader.loadCount == 0) + #expect(recovery.isQuarantined(moduleID)) + #expect(recovery.pendingPluginLoadModules().isEmpty) + } + + @MainActor + @Test + func disabledLanguagePluginRetainsStaticOwnershipWithoutLoadingCode() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-plugin-store-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let store = MacPluginPackageStore( + rootURL: root.appendingPathComponent("installed", isDirectory: true), + verifier: TestPluginSignatureVerifier() + ) + let languageID = "fixture" + let moduleID = ModuleID("dev.example.feature") + _ = try store.installPackage(from: makePackage( + root: root, + name: "disabled-language", + version: PluginVersion(major: 0, minor: 3, patch: 0), + languageSupport: LanguageSupportDeclaration( + id: languageID, + displayName: "Fixture", + fileExtensions: ["fixture"], + languageServerModuleID: moduleID, + executionModuleID: moduleID, + testingModuleID: moduleID + ) + )) + let codeLoader = CountingPluginPrincipalClassLoader() + + let result = MacPluginStartupLoader( + packageStore: store, + nativeLoader: MacNativePluginLoader(codeLoader: codeLoader) + ).load(policy: MacPluginLoadPolicy( + configurationStore: PluginStartupConfigurationStore(enabled: false), + recoveryStore: nil, + launchMode: .normal + )) + + #expect(result.activeNativeManifests.isEmpty) + #expect(codeLoader.loadCount == 0) + #expect(result.installedLanguageSupports.map(\.id) == [languageID]) + } + + @MainActor + @Test + func loadedPluginRemainsMarkedUntilCleanProcessShutdown() { + let moduleID = ModuleID("dev.example.runtime-plugin") + let recovery = PluginStartupRecoveryStore() + let coordinator = MacPluginRuntimeRecoveryCoordinator() + + coordinator.recoverPreviousSession(using: recovery) + coordinator.prepareToLoad([moduleID], using: recovery) + coordinator.recordSuccessfulLoad([moduleID], using: recovery) + + #expect(recovery.pendingPluginLoadModules() == [moduleID]) + #expect(!recovery.isQuarantined(moduleID)) + + coordinator.recordCleanShutdown(using: recovery) + + #expect(recovery.pendingPluginLoadModules().isEmpty) + } + + @MainActor + @Test + func interruptedRuntimeSessionIsRecoveredOnlyOncePerProcess() { + let previousModuleID = ModuleID("dev.example.previous-plugin") + let currentModuleID = ModuleID("dev.example.current-plugin") + let recovery = PluginStartupRecoveryStore(pending: [previousModuleID]) + let coordinator = MacPluginRuntimeRecoveryCoordinator() + + coordinator.recoverPreviousSession(using: recovery) + #expect(recovery.isQuarantined(previousModuleID)) + #expect(recovery.pendingPluginLoadModules().isEmpty) + + coordinator.prepareToLoad([currentModuleID], using: recovery) + coordinator.recordSuccessfulLoad([currentModuleID], using: recovery) + coordinator.recoverPreviousSession(using: recovery) + + #expect(recovery.pendingPluginLoadModules() == [currentModuleID]) + #expect(!recovery.isQuarantined(currentModuleID)) + } + + private func makePackage( + root: URL, + name: String, + version: PluginVersion, + required: Bool = false, + languageSupport: LanguageSupportDeclaration? = nil + ) throws -> URL { + let packageURL = root.appendingPathComponent("sources/\(name)", isDirectory: true) + try FileManager.default.createDirectory( + at: packageURL.appendingPathComponent("Feature.bundle", isDirectory: true), + withIntermediateDirectories: true + ) + let moduleID = ModuleID("dev.example.feature") + let manifest = PluginManifest( + id: PluginID("dev.example.plugin"), + displayName: "Example Plugin", + version: version, + hostCompatibility: PluginHostCompatibility( + minimum: PluginVersion(major: 0, minor: 3, patch: 0), + maximumExclusive: PluginVersion(major: 0, minor: 4, patch: 0) + ), + vendor: PluginVendor( + id: "dev.example", + displayName: "Example", + signatureRequirement: .sameTeamAsHost + ), + entrypoint: PluginEntrypoint( + kind: .nativeBundle, + bundleIdentifier: "dev.example.feature", + principalClass: "ExamplePlugin", + bundlePath: "Feature.bundle" + ), + modules: [PluginModuleDeclaration(manifest: ModuleManifest( + id: moduleID, + displayName: "Example Feature", + scope: .application, + defaultState: .disabled, + activationPolicy: .onDemand, + providedCapabilities: [ModuleCapabilityID("dev.example.feature.capability")], + isRequired: required + ))], + languageSupports: languageSupport.map { [$0] } ?? [] + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(manifest).write( + to: packageURL.appendingPathComponent("plugin.json"), + options: .atomic + ) + return packageURL + } +} + +private final class PluginStartupConfigurationStore: ModuleConfigurationStore, @unchecked Sendable { + private let enabled: Bool + init(enabled: Bool) { self.enabled = enabled } + func enabledState(for moduleID: ModuleID) -> Bool? { enabled } + func setEnabledState(_ enabled: Bool, for moduleID: ModuleID) {} +} + +private final class PluginStartupRecoveryStore: ModuleRecoveryStore, @unchecked Sendable { + private var pending: [ModuleID] + private var quarantined: Set = [] + + init(pending: [ModuleID] = []) { self.pending = pending } + func pendingActivation() -> ModuleID? { nil } + func setPendingActivation(_ moduleID: ModuleID?) {} + func isQuarantined(_ moduleID: ModuleID) -> Bool { quarantined.contains(moduleID) } + func setQuarantined(_ quarantined: Bool, for moduleID: ModuleID) { + if quarantined { + self.quarantined.insert(moduleID) + } else { + self.quarantined.remove(moduleID) + } + } + func pendingPluginLoadModules() -> [ModuleID] { pending } + func setPendingPluginLoadModules(_ moduleIDs: [ModuleID]) { pending = moduleIDs } +} + +private final class CountingPluginPrincipalClassLoader: PluginPrincipalClassLoading { + private(set) var loadCount = 0 + func principalClass(at bundleURL: URL) throws -> AnyClass { + loadCount += 1 + return NSObject.self + } +} + +private enum TestPluginSignatureError: Error, Equatable { + case rejected +} + +private final class TestPluginSignatureVerifier: PluginPackageSignatureVerifying, @unchecked Sendable { + private let rejectedVersions: Set + private(set) var verifiedVersions: [PluginVersion] = [] + + init(rejectedVersions: Set = []) { + self.rejectedVersions = rejectedVersions + } + + func verify(packageAt packageURL: URL, manifest: PluginManifest) throws { + verifiedVersions.append(manifest.version) + if rejectedVersions.contains(manifest.version) { + throw TestPluginSignatureError.rejected + } + } +} diff --git a/Tests/LitheTests/RealGoplsIntegrationTests.swift b/Tests/LitheTests/RealGoplsIntegrationTests.swift index b72f5e9f..2f50ff6d 100644 --- a/Tests/LitheTests/RealGoplsIntegrationTests.swift +++ b/Tests/LitheTests/RealGoplsIntegrationTests.swift @@ -1,4 +1,5 @@ import Foundation +import LitheLanguageIntelligenceModule import Testing @testable import Lithe @@ -60,7 +61,7 @@ struct RealGoplsIntegrationTests { let manager = LanguageToolingSessionManager( catalog: LanguageProviderCatalog(descriptors: [descriptor]), runtimes: [runtime], - core: core + builtinCore: core ) defer { manager.stopAll() @@ -142,5 +143,4 @@ private final class RealGoplsLanguageRuntime: LanguageProviderRuntime { } func makeLanguageServerSession() -> (any LanguageServerSession)? { session } - func makeDebugAdapterSession() -> (any DebugAdapterSession)? { nil } } diff --git a/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/Tests/LitheTests/RunConfigurationIntegrationTests.swift index 5a2cad5e..b22a2a14 100644 --- a/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1,4 +1,8 @@ import Foundation +import LitheCoreContracts +import LitheDebugModule +import LitheExecutionModule +import LitheLanguageIntelligenceModule import Testing @testable import Lithe @@ -62,9 +66,9 @@ struct RunConfigurationIntegrationTests { ) #expect(typeScriptPlan.toolchainID == "project-tsx") - let goPlan = try registry.launchPlan(for: go, workspaceURL: root) - #expect(goPlan.toolchainID == "project-go") - #expect(goPlan.arguments == ["run", "cmd/api/main.go"]) + #expect(throws: LanguageRunPlanError.noProvider(fileExtension: "go")) { + _ = try registry.launchPlan(for: go, workspaceURL: root) + } #expect(throws: LanguageRunPlanError.noProvider(fileExtension: "java")) { _ = try registry.launchPlan( @@ -130,6 +134,26 @@ struct RunConfigurationIntegrationTests { } } + @Test + func extensionOwnedLanguageDoesNotReceiveBuiltInProcessProviders() throws { + let descriptor = LanguageProviderDescriptor( + id: "zig", + displayName: "Zig", + fileExtensions: ["zig"], + capabilities: [.run, .languageServer, .debugAdapter, .testing], + activationPolicy: .onDemand + ) + let registry = LanguagePackRegistry.standard( + catalog: LanguageProviderCatalog(descriptors: [descriptor]), + extensionRequiredProviderIDs: ["zig"] + ) + + #expect(registry.runProviders.provider(id: "zig") == nil) + #expect(registry.testProviders.provider(id: "zig") == nil) + #expect(registry.pack(id: "zig")?.debugAdapterLaunch == nil) + #expect(registry.pack(id: "zig")?.toolingRuntime == nil) + } + @Test func customLanguagePackRegistersWithoutChangingCoreServices() { let descriptor = LanguageProviderDescriptor( @@ -153,7 +177,7 @@ struct RunConfigurationIntegrationTests { } @Test - func projectCatalogCanCreateRuntimeForANewProviderDynamically() { + func projectCatalogCreatesLanguageRuntimeOnlyWhenTheLSPIsRequested() throws { let factory = TestLanguageProviderRuntimeFactory() let manager = LanguageToolingSessionManager( catalog: LanguageProviderCatalog(descriptors: []), @@ -174,7 +198,12 @@ struct RunConfigurationIntegrationTests { manager.updateCatalog(LanguageProviderCatalog(descriptors: [descriptor])) - #expect(manager.supportsGenericDebugging(for: URL(fileURLWithPath: "/tmp/main.roc"))) + #expect(factory.createdDescriptors.isEmpty) + try manager.synchronizeLanguageServer( + for: URL(fileURLWithPath: "/tmp/main.roc"), + text: "app \"main\"", + rootURL: URL(fileURLWithPath: "/tmp") + ) #expect(factory.createdDescriptors == [descriptor]) } @@ -186,26 +215,31 @@ struct RunConfigurationIntegrationTests { store: RunTestKeyValueStore() ) var nodeFactoryCalls = 0 - let nodeRuntime = try #require(StdioLanguageProviderRuntime.standard( - catalog: catalog, + let debugFactory = DebugAdapterRuntimeFactory( runtimeService: runtimeService, - processFactory: { RecordingRawProcessSession() }, - debugSessionFactories: ["node": { + transportFactory: { executableURL, arguments, environment in + MacProcessDebugAdapterTransport( + executableURL: executableURL, + arguments: arguments, + environment: environment, + process: RecordingRawProcessSession() + ) + }, + launches: [:], + sessionFactories: ["node": { nodeFactoryCalls += 1 return TestDebugAdapterSession() }] - ).first { $0.descriptor.id == "node" }) - let javaDescriptor = try #require(catalog.provider(for: URL(fileURLWithPath: "/tmp/Main.java"))) - let javaRuntime = TestDebugLanguageProviderRuntime(descriptor: javaDescriptor) - let manager = LanguageToolingSessionManager( - catalog: catalog, - runtimes: [nodeRuntime, javaRuntime] ) - #expect(manager.supportsGenericDebugging(for: URL(fileURLWithPath: "/tmp/app.ts"))) - #expect(!manager.supportsGenericDebugging(for: URL(fileURLWithPath: "/tmp/Main.java"))) #expect(nodeFactoryCalls == 0) - #expect(manager.activeDebugAdapterIDs.isEmpty) + let debugManager = DebugAdapterSessionManager( + providers: catalog.debugProviders, + makeSession: { descriptor, rootURL in + debugFactory.makeSession(for: descriptor, rootURL: rootURL) + } + ) + #expect(debugManager.activeAdapterIDs.isEmpty) } @Test @@ -222,14 +256,17 @@ struct RunConfigurationIntegrationTests { descriptor: javaDescriptor, supportsDebugAdapter: true ) - let manager = LanguageToolingSessionManager( - catalog: catalog, - runtimes: [runtime] + let debugManager = DebugAdapterSessionManager( + providers: catalog.debugProviders, + makeSession: { descriptor, rootURL in + descriptor.id == runtime.descriptor.id + ? runtime.makeSession() + : nil + } ) let source = URL(fileURLWithPath: "/tmp/Main.java") - #expect(manager.supportsGenericDebugging(for: source)) - _ = try manager.activateDebugAdapter( + _ = try debugManager.activate( for: source, rootURL: URL(fileURLWithPath: "/tmp/java-project") ) @@ -313,11 +350,8 @@ struct RunConfigurationIntegrationTests { @Test func legacyJavaDoesNotAcceptGenericDAPBreakpointsWithoutAnAdapter() throws { let source = URL(fileURLWithPath: "/tmp/Main.java") - #expect(throws: LanguageToolingSessionError.capabilityUnavailable( - provider: "Java", - capability: "debug adapter breakpoints" - )) { - try LanguageToolingSessionManager(catalog: .standard).setDebugBreakpoints( + #expect(throws: DebugProviderError.noProvider(fileExtension: "java")) { + try DebugAdapterSessionManager(providers: LanguageProviderCatalog.standard.debugProviders) { _, _ in nil }.setBreakpoints( [DebugSourceBreakpoint(line: 1)], in: source ) @@ -745,21 +779,28 @@ struct RunConfigurationIntegrationTests { ) var factoryCalls = 0 let expected = TestDebugAdapterSession() - let runtimes = StdioLanguageProviderRuntime.standard( - catalog: .standard, + let factory = DebugAdapterRuntimeFactory( runtimeService: runtimeService, - processFactory: { RecordingRawProcessSession() }, - debugSessionFactories: [ + transportFactory: { executableURL, arguments, environment in + MacProcessDebugAdapterTransport( + executableURL: executableURL, + arguments: arguments, + environment: environment, + process: RecordingRawProcessSession() + ) + }, + launches: [:], + sessionFactories: [ "node": { factoryCalls += 1 return expected } ] ) - let node = try #require(runtimes.first(where: { $0.descriptor.id == "node" })) + let node = try #require(LanguageProviderCatalog.standard.debugProviders.first { $0.id == "node" }) #expect(factoryCalls == 0) - let created = try #require(node.makeDebugAdapterSession()) + let created = try #require(factory.makeSession(for: node, rootURL: URL(fileURLWithPath: "/tmp"))) #expect(factoryCalls == 1) #expect(created === expected) } @@ -772,21 +813,28 @@ struct RunConfigurationIntegrationTests { ) var factoryCalls = 0 let expected = TestDebugAdapterSession() - let runtimes = StdioLanguageProviderRuntime.standard( - catalog: .standard, + let factory = DebugAdapterRuntimeFactory( runtimeService: runtimeService, - processFactory: { RecordingRawProcessSession() }, - debugSessionFactories: [ + transportFactory: { executableURL, arguments, environment in + MacProcessDebugAdapterTransport( + executableURL: executableURL, + arguments: arguments, + environment: environment, + process: RecordingRawProcessSession() + ) + }, + launches: [:], + sessionFactories: [ "go": { factoryCalls += 1 return expected } ] ) - let go = try #require(runtimes.first(where: { $0.descriptor.id == "go" })) + let go = try #require(LanguageProviderCatalog.standard.debugProviders.first { $0.id == "go" }) #expect(factoryCalls == 0) - let created = try #require(go.makeDebugAdapterSession()) + let created = try #require(factory.makeSession(for: go, rootURL: URL(fileURLWithPath: "/tmp"))) #expect(factoryCalls == 1) #expect(created === expected) } @@ -1038,7 +1086,6 @@ struct RunConfigurationIntegrationTests { let runtime = StdioLanguageProviderRuntime( descriptor: descriptor, runtimeService: runtimeService, - processFactory: { process }, languageServerLaunch: descriptor.languageServerLaunch, languageServerCore: core ) @@ -1112,7 +1159,6 @@ struct RunConfigurationIntegrationTests { let runtime = StdioLanguageProviderRuntime( descriptor: descriptor, runtimeService: runtimeService, - processFactory: { RecordingRawProcessSession() }, languageServerLaunch: descriptor.languageServerLaunch, languageServerCore: core ) @@ -1515,7 +1561,6 @@ struct RunConfigurationIntegrationTests { let runtime = StdioLanguageProviderRuntime( descriptor: descriptor, runtimeService: runtimeService, - processFactory: { process }, languageServerLaunch: descriptor.languageServerLaunch, languageServerCore: core ) @@ -1811,17 +1856,24 @@ struct RunConfigurationIntegrationTests { for: URL(fileURLWithPath: "/tmp/main.py") )) let runtime = TestDebugLanguageProviderRuntime(descriptor: descriptor) - let manager = LanguageToolingSessionManager(catalog: .standard, runtimes: [runtime]) + let manager = DebugAdapterSessionManager( + providers: LanguageProviderCatalog.standard.debugProviders, + makeSession: { descriptor, rootURL in + descriptor.id == runtime.descriptor.id + ? runtime.makeSession() + : nil + } + ) let firstRoot = URL(fileURLWithPath: "/tmp/first-python-project") let firstSource = firstRoot.appendingPathComponent("main.py") - try manager.setDebugBreakpoints([DebugSourceBreakpoint(line: 12)], in: firstSource) + try manager.setBreakpoints([DebugSourceBreakpoint(line: 12)], in: firstSource) - _ = try manager.activateDebugAdapter(for: firstSource, rootURL: firstRoot) + _ = try manager.activate(for: firstSource, rootURL: firstRoot) #expect(runtime.debugAdapters.first?.breakpointUpdates.count == 1) manager.stopAll() let secondRoot = URL(fileURLWithPath: "/tmp/second-python-project") - _ = try manager.activateDebugAdapter( + _ = try manager.activate( for: secondRoot.appendingPathComponent("main.py"), rootURL: secondRoot ) @@ -1839,31 +1891,42 @@ struct RunConfigurationIntegrationTests { store: RunTestKeyValueStore() ) let process = RecordingRawProcessSession() - let runtime = StdioLanguageProviderRuntime( - descriptor: descriptor, + let runtime = DebugAdapterRuntimeFactory( runtimeService: runtimeService, - processFactory: { process }, - debugLaunch: StdioDebugAdapterLaunch( + transportFactory: { executableURL, arguments, environment in + MacProcessDebugAdapterTransport( + executableURL: executableURL, + arguments: arguments, + environment: environment, + process: process + ) + }, + launches: [descriptor.id: StdioDebugAdapterLaunch( adapterID: "python", executableNames: ["python3"], arguments: ["-m", "debugpy.adapter"] - ) + )] + ) + let manager = DebugAdapterSessionManager( + providers: LanguageProviderCatalog.standard.debugProviders, + makeSession: { descriptor, rootURL in + runtime.makeSession(for: descriptor, rootURL: rootURL) + } ) - let manager = LanguageToolingSessionManager(runtimes: [runtime]) let root = URL(fileURLWithPath: "/tmp/python-project", isDirectory: true) let source = root.appendingPathComponent("main.py") - try manager.setDebugBreakpoints([DebugSourceBreakpoint(line: 7)], in: source) + try manager.setBreakpoints([DebugSourceBreakpoint(line: 7)], in: source) - let session = try manager.activateDebugAdapter(for: source, rootURL: root) + let session = try manager.activate(for: source, rootURL: root) let controlling = try #require(session as? any DebugAdapterControllingSession) let processRequest = try #require(process.requests.first) #expect(processRequest.executablePath == "/usr/bin/python3") #expect(processRequest.arguments == ["-m", "debugpy.adapter"]) - #expect(manager.debugStates["python"] == .initializing) + #expect(manager.states["python"] == .initializing) let initialize = try #require(Self.debugRequest(named: "initialize", in: process.sentData)) let initializeSequence = try #require(initialize["seq"] as? Int) - _ = try manager.launchDebugAdapter( + _ = try manager.launch( for: source, rootURL: root, configuration: DebugLaunchConfiguration( @@ -1888,7 +1951,7 @@ struct RunConfigurationIntegrationTests { ]) await Self.drainMainActorTasks() #expect(controlling.state == .launching) - #expect(manager.debugStates["python"] == .launching) + #expect(manager.states["python"] == .launching) let launch = try #require(Self.debugRequest(named: "launch", in: process.sentData)) let launchSequence = try #require(launch["seq"] as? Int) let launchArguments = try #require(launch["arguments"] as? [String: Any]) @@ -1938,7 +2001,7 @@ struct RunConfigurationIntegrationTests { ]) await Self.drainMainActorTasks() #expect(controlling.state == .paused) - #expect(manager.lastDebugEvents["python"] == .stopped( + #expect(manager.lastEvents["python"] == .stopped( reason: "breakpoint", threadID: 42, description: "Paused on breakpoint" @@ -2019,9 +2082,9 @@ struct RunConfigurationIntegrationTests { await Self.drainMainActorTasks() #expect(controlling.state == .running) - manager.stopDebugAdapter(providerID: "python") + manager.stop(providerID: "python") #expect(!process.isRunning) - #expect(manager.debugStates["python"] == .idle) + #expect(manager.states["python"] == .idle) } @Test @@ -2113,12 +2176,25 @@ struct RunConfigurationIntegrationTests { store: RunTestKeyValueStore() ) let process = RecordingRawProcessSession() - let runtime = try #require(StdioLanguageProviderRuntime.standard( - catalog: .standard, + let runtime = DebugAdapterRuntimeFactory( runtimeService: runtimeService, - processFactory: { process } - ).first(where: { $0.descriptor.id == "rust" })) - let adapter = try #require(runtime.makeDebugAdapterSession()) + transportFactory: { executableURL, arguments, environment in + MacProcessDebugAdapterTransport( + executableURL: executableURL, + arguments: arguments, + environment: environment, + process: process + ) + }, + launches: ["rust": StdioDebugAdapterLaunch( + adapterID: "lldb", + executableNames: ["lldb-dap"], + arguments: [], + fallbacks: [.init(executableName: "xcrun", argumentPrefix: ["lldb-dap"])] + )] + ) + let descriptor = try #require(LanguageProviderCatalog.standard.debugProviders.first { $0.id == "rust" }) + let adapter = try #require(runtime.makeSession(for: descriptor, rootURL: URL(fileURLWithPath: "/tmp/rust-xcrun"))) try adapter.start(rootURL: URL(fileURLWithPath: "/tmp/rust-xcrun")) @@ -2137,17 +2213,28 @@ struct RunConfigurationIntegrationTests { store: RunTestKeyValueStore() ) let process = RecordingRawProcessSession() - let runtime = StdioLanguageProviderRuntime( - descriptor: descriptor, + let runtime = DebugAdapterRuntimeFactory( runtimeService: runtimeService, - processFactory: { process }, - debugLaunch: StdioDebugAdapterLaunch( + transportFactory: { executableURL, arguments, environment in + MacProcessDebugAdapterTransport( + executableURL: executableURL, + arguments: arguments, + environment: environment, + process: process + ) + }, + launches: [descriptor.id: StdioDebugAdapterLaunch( adapterID: "python", executableNames: ["python3"], arguments: ["-m", "debugpy.adapter"] - ) + )] + ) + let manager = DebugAdapterSessionManager( + providers: LanguageProviderCatalog.standard.debugProviders, + makeSession: { descriptor, rootURL in + runtime.makeSession(for: descriptor, rootURL: rootURL) + } ) - let manager = LanguageToolingSessionManager(runtimes: [runtime]) let feature = GenericDebugFeatureModel(sessions: manager) let root = URL(fileURLWithPath: "/tmp/python-feature", isDirectory: true) let source = root.appendingPathComponent("app.py") @@ -3758,8 +3845,7 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u private let providerID: String private let sessionID: String private var nextOperationNumber = 1 - private var nextSequence: UInt64 = 1 - private var events: [RustCoreBridge.LspRuntimeEventPayload] = [] + private var events: [LanguageServerRuntimeEvent] = [] private(set) var startCalls: [StartCall] = [] private(set) var stopCalls: [String] = [] @@ -3774,7 +3860,7 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u self.sessionID = sessionID ?? "test-\(providerID)-session" } - func lspStartServer( + func startLanguageServer( providerID: String, executableURL: URL, arguments: [String], @@ -3787,7 +3873,7 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u initializeTimeout: TimeInterval, requestTimeout: TimeInterval, shutdownTimeout: TimeInterval - ) -> Result { + ) -> Result { startCalls.append(StartCall( providerID: providerID, executableURL: executableURL, @@ -3802,23 +3888,24 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u requestTimeout: requestTimeout, shutdownTimeout: shutdownTimeout )) - return .success(Self.decode([ - "sessionId": sessionID, - "state": "initializing" - ])) + return .success(LanguageServerRuntimeStart( + sessionID: sessionID, + state: "initializing", + processID: nil + )) } - func lspStopServer(sessionID: String) { + func stopLanguageServer(sessionID: String) { stopCalls.append(sessionID) enqueueEvent(type: "stateChanged", fields: ["state": "stopped"]) } - func lspSyncDocument( + func syncLanguageServerDocument( sessionID: String, fileURL: URL, languageID: String, text: String - ) -> Result { + ) -> Result { syncCalls.append(SyncCall( sessionID: sessionID, fileURL: fileURL, @@ -3828,11 +3915,11 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u return .success(()) } - func lspCloseDocument(sessionID: String, fileURL: URL) { + func closeLanguageServerDocument(sessionID: String, fileURL: URL) { closeCalls.append(FileCall(sessionID: sessionID, fileURL: fileURL)) } - func lspRequest( + func requestLanguageServerOperation( sessionID: String, operation: LanguageServerOperation, fileURL: URL?, @@ -3844,7 +3931,7 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u completionItem: LanguageServerCompletionItem?, codeAction: LanguageServerCodeAction?, command: LanguageServerCommand? - ) -> Result { + ) -> Result { let operationID = "operation-\(nextOperationNumber)" nextOperationNumber += 1 requestCalls.append(RequestCall( @@ -3861,19 +3948,19 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u codeAction: codeAction, command: command )) - return .success(Self.decode(["operationId": operationID])) + return .success(LanguageServerRuntimeOperation(operationID: operationID)) } - func lspCancelOperation(sessionID: String, operationID: String) { + func cancelLanguageServerOperation(sessionID: String, operationID: String) { cancelCalls.append(CancelCall(sessionID: sessionID, operationID: operationID)) } - func lspPollEvents(sessionID _: String) -> [RustCoreBridge.LspRuntimeEventPayload] { + func pollLanguageServerEvents(sessionID _: String) -> [LanguageServerRuntimeEvent] { defer { events.removeAll() } return events } - func lspDestroyServer(sessionID: String) { + func destroyLanguageServer(sessionID: String) { destroyedSessionIDs.append(sessionID) } @@ -3882,17 +3969,21 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u serverInfo: (name: String, version: String?)? = nil ) { if !capabilities.isEmpty { - enqueueEvent(type: "featuresChanged", fields: ["capabilities": capabilities]) + events.append(LanguageServerRuntimeEvent( + type: "featuresChanged", + capabilities: capabilities + )) } if let serverInfo { - enqueueEvent(type: "serverInfoChanged", fields: [ - "serverInfo": [ - "name": serverInfo.name, - "version": serverInfo.version as Any - ] - ]) + events.append(LanguageServerRuntimeEvent( + type: "serverInfoChanged", + serverInfo: LanguageServerInfo( + name: serverInfo.name, + version: serverInfo.version + ) + )) } - enqueueEvent(type: "stateChanged", fields: ["state": "ready"]) + events.append(LanguageServerRuntimeEvent(type: "stateChanged", state: "ready")) } func enqueueFailure( @@ -3901,26 +3992,27 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u underlyingMessage: String? = nil, processExitCode: Int? = nil ) { - enqueueEvent(type: "stateChanged", fields: [ - "state": "failed", - "error": runtimeError( + events.append(LanguageServerRuntimeEvent( + type: "stateChanged", + state: "failed", + error: runtimeError( code: code, message: message, underlyingMessage: underlyingMessage, processExitCode: processExitCode ) - ]) + )) } func enqueueRequestSuccess(operation: LanguageServerOperation, result: Any) { guard let call = requestCalls.last(where: { $0.operation == operation }) else { preconditionFailure("No recorded request for \(operation.rawValue)") } - enqueueEvent(type: "requestCompleted", fields: [ - "operationId": call.operationID, - "method": operation.rawValue, - "result": result - ]) + events.append(LanguageServerRuntimeEvent( + type: "requestCompleted", + operationID: call.operationID, + result: Self.jsonValue(result) + )) } func enqueueRequestFailure( @@ -3933,44 +4025,46 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u guard let call = requestCalls.last(where: { $0.operation == operation }) else { preconditionFailure("No recorded request for \(operation.rawValue)") } - enqueueEvent(type: "requestCompleted", fields: [ - "operationId": call.operationID, - "method": operation.rawValue, - "error": runtimeError( + events.append(LanguageServerRuntimeEvent( + type: "requestCompleted", + operationID: call.operationID, + error: runtimeError( code: code, message: message, underlyingMessage: underlyingMessage, processExitCode: processExitCode ) - ]) + )) } func enqueueDiagnostics(for fileURL: URL, message: String) { - enqueueEvent(type: "diagnostics", fields: [ - "uri": fileURL.standardizedFileURL.absoluteString, - "version": 1, - "diagnostics": [[ - "range": [ - "start": ["line": 0, "utf16Column": 7], - "end": ["line": 0, "utf16Column": 10] - ], - "severity": 2, - "message": message, - "source": "test-language-server" - ]] - ]) + events.append(LanguageServerRuntimeEvent( + type: "diagnostics", + uri: fileURL.standardizedFileURL.absoluteString, + diagnostics: [LanguageServerDiagnostic( + range: LanguageServerRange( + start: LanguageServerPosition(line: 0, utf16Column: 7), + end: LanguageServerPosition(line: 0, utf16Column: 10) + ), + severity: 2, + message: message, + source: "test-language-server", + code: nil + )] + )) } private func enqueueEvent(type: String, fields: [String: Any]) { - var object: [String: Any] = [ - "type": type, - "sequence": nextSequence, - "providerId": providerID, - "sessionId": sessionID - ] - nextSequence += 1 - object.merge(fields) { _, updated in updated } - events.append(Self.decode(object)) + events.append(LanguageServerRuntimeEvent( + type: type, + state: fields["state"] as? String, + operationID: fields["operationId"] as? String, + uri: fields["uri"] as? String, + result: fields["result"].flatMap(Self.jsonValue), + capabilities: fields["capabilities"] as? [String], + message: fields["message"] as? String, + detail: fields["detail"] as? String + )) } private func runtimeError( @@ -3978,25 +4072,21 @@ private final class TestLanguageServerRuntimeCore: LanguageServerRuntimeCore, @u message: String, underlyingMessage: String?, processExitCode: Int? - ) -> [String: Any] { - var error: [String: Any] = [ - "code": code, - "providerId": providerID, - "sessionId": sessionID, - "stage": "test", - "message": message - ] - if let underlyingMessage { error["underlyingMessage"] = underlyingMessage } - if let processExitCode { error["processExitCode"] = processExitCode } - return error + ) -> LanguageServerRuntimeError { + _ = code + return LanguageServerRuntimeError( + message: message, + underlyingMessage: underlyingMessage, + processExitCode: processExitCode + ) } - private static func decode(_ object: Any) -> Payload { + private static func jsonValue(_ object: Any) -> ToolingJSONValue? { do { let data = try JSONSerialization.data(withJSONObject: object) - return try JSONDecoder().decode(Payload.self, from: data) + return try JSONDecoder().decode(ToolingJSONValue.self, from: data) } catch { - preconditionFailure("Invalid language-server test payload: \(error)") + return nil } } } @@ -4224,15 +4314,14 @@ private final class TestDebugAdapterSession: DebugAdapterControllingSession { @MainActor private final class TestDebugLanguageProviderRuntime: LanguageProviderRuntime { let descriptor: LanguageProviderDescriptor - let supportsDebugAdapterSession: Bool private(set) var debugAdapters: [TestDebugAdapterSession] = [] init(descriptor: LanguageProviderDescriptor, supportsDebugAdapter: Bool = false) { self.descriptor = descriptor - supportsDebugAdapterSession = supportsDebugAdapter + _ = supportsDebugAdapter } - func makeDebugAdapterSession() -> (any DebugAdapterSession)? { + func makeSession() -> (any DebugAdapterSession)? { let session = TestDebugAdapterSession() debugAdapters.append(session) return session @@ -4251,7 +4340,6 @@ private final class TestLanguageServerRuntime: LanguageProviderRuntime { } func makeLanguageServerSession() -> (any LanguageServerSession)? { session } - func makeDebugAdapterSession() -> (any DebugAdapterSession)? { nil } } @MainActor diff --git a/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift b/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift new file mode 100644 index 00000000..e1235792 --- /dev/null +++ b/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift @@ -0,0 +1,96 @@ +import SwiftUI +import Testing +@testable import Lithe +import LitheModuleAPI + +@MainActor +struct WorkbenchModuleUIRegistryTests { + @Test func duplicateActionIDsAreRejected() { + let first = WorkbenchModuleUIRegistry.Registration(actions: [ + .init(id: "test.action", perform: { _ in }) + ]) + let second = WorkbenchModuleUIRegistry.Registration(actions: [ + .init(id: "test.action", perform: { _ in }) + ]) + + #expect(throws: WorkbenchModuleUIRegistryError.duplicateActionID("test.action")) { + try WorkbenchModuleUIRegistry(registrations: [first, second]) + } + } + + @Test func duplicateRendererIDsAreRejected() { + let renderer = WorkbenchModuleUIRegistry.Renderer( + id: "test.renderer", + ideaAssetPath: nil, + isVisible: { _ in true }, + isSelected: { _ in false }, + content: { _ in AnyView(EmptyView()) } + ) + + #expect(throws: WorkbenchModuleUIRegistryError.duplicateRendererID("test.renderer")) { + try WorkbenchModuleUIRegistry(registrations: [ + .init(renderers: [renderer]), + .init(renderers: [renderer]) + ]) + } + } + + @Test func missingActionAndRendererBindingsAreRejected() throws { + let registry = try WorkbenchModuleUIRegistry(registrations: []) + + #expect(throws: WorkbenchModuleUIRegistryError.missingAction( + contributionID: "test.tool", + actionID: "test.action" + )) { + try registry.validate(contributions: [ + ModuleContribution( + id: "test.tool", + kind: .toolWindow, + title: "Test", + actionID: "test.action" + ) + ]) + } + + #expect(throws: WorkbenchModuleUIRegistryError.missingRenderer( + contributionID: "test.tool", + rendererID: "test.renderer" + )) { + try registry.validate(contributions: [ + ModuleContribution( + id: "test.tool", + kind: .toolWindow, + title: "Test", + rendererID: "test.renderer" + ) + ]) + } + } + + @Test func composedBindingsValidateDeclaredContribution() throws { + let registry = try WorkbenchModuleUIRegistry(registrations: [ + .init( + actions: [.init(id: "test.action", perform: { _ in })], + renderers: [ + .init( + id: "test.renderer", + ideaAssetPath: nil, + isVisible: { _ in true }, + isSelected: { _ in false }, + content: { _ in AnyView(EmptyView()) } + ) + ] + ) + ]) + + try registry.validate(contributions: [ + ModuleContribution( + id: "test.tool", + kind: .toolWindow, + title: "Test", + actionID: "test.action", + rendererID: "test.renderer" + ) + ]) + } +} diff --git a/Tests/LitheWorkspaceModuleTests/WorkspaceModuleTests.swift b/Tests/LitheWorkspaceModuleTests/WorkspaceModuleTests.swift new file mode 100644 index 00000000..750ed188 --- /dev/null +++ b/Tests/LitheWorkspaceModuleTests/WorkspaceModuleTests.swift @@ -0,0 +1,58 @@ +import Foundation +import LitheApplicationKernel +@testable import LitheWorkspaceModule +import LitheModuleAPI +import Testing + +@MainActor +struct WorkspaceModuleTests { + @Test + func eagerWorkspaceCreatesGraphOnlyWhenActivated() async throws { + let calls = Counter() + let runtime = ModuleRuntime() + try runtime.register(ModuleFactory(manifest: WorkspaceFoundationModule.moduleManifest) { + calls.factory += 1 + return WorkspaceFoundationModule(makeGraph: { + calls.graph += 1 + return TestGraph() + }) + }) + #expect(calls.factory == 0) + _ = try await runtime.activateCapability(.workspaceFoundation) + #expect(calls.factory == 1) + #expect(calls.graph == 1) + } + + @Test + func shutdownReleasesWorkspaceGraphAndResource() async throws { + let calls = Counter() + let runtime = ModuleRuntime() + try runtime.register(ModuleFactory(manifest: WorkspaceFoundationModule.moduleManifest) { + calls.factory += 1 + return WorkspaceFoundationModule(makeGraph: { + calls.graph += 1 + let graph = TestGraph() + calls.latest = graph + return graph + }) + }) + _ = try await runtime.activateCapability(.workspaceFoundation) + weak var released = calls.latest + try await runtime.shutdown(.workspace) + #expect(released == nil) + #expect(runtime.capability(.workspaceFoundation) == nil) + #expect(try runtime.snapshot(for: .workspace).activity.activeResourceCount == 0) + } +} + +@MainActor private final class Counter { + var factory = 0 + var graph = 0 + weak var latest: TestGraph? +} +@MainActor private final class TestGraph: WorkspaceResourceGraph { + var hasActiveResources = false + var feature: WorkspaceFeatureModel? + func attach(workspaceProjection: WorkspaceFeatureModel) { feature = workspaceProjection } + func stop() async {} +} diff --git a/docs/architecture/lsp-runtime-migration.md b/docs/architecture/lsp-runtime-migration.md index 8d363961..bdda193d 100644 --- a/docs/architecture/lsp-runtime-migration.md +++ b/docs/architecture/lsp-runtime-migration.md @@ -118,10 +118,10 @@ Primary files: Primary files: -- `Sources/Lithe/Services/StdioLanguageServerSession.swift` -- `Sources/Lithe/Services/StdioLanguageProviderRuntime.swift` -- `Sources/Lithe/Services/LanguageToolingSessionManager.swift` -- `Sources/Lithe/Core/RustCoreBridge.swift` +- `Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift` +- `Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift` +- `Sources/LitheLanguageIntelligenceModule/Services/LanguageToolingSessionManager.swift` +- `Sources/Lithe/Core/Rust/RustCoreBridge.swift` - `Sources/Lithe/Core/Ports/LanguageTooling.swift` ### 4. Provider convergence and legacy deletion @@ -138,9 +138,9 @@ Primary files: 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` +- `Sources/Lithe/Application/Features/JavaFeatureModel.swift` +- `Sources/Lithe/Models/Java/JavaDiagnosticModels.swift` +- `Sources/Lithe/Views/Editor/CodeEditorView.swift` ## Completion evidence diff --git a/docs/architecture/mac-service-boundaries.md b/docs/architecture/mac-service-boundaries.md index ef121612..db3162a4 100644 --- a/docs/architecture/mac-service-boundaries.md +++ b/docs/architecture/mac-service-boundaries.md @@ -37,7 +37,7 @@ implementations. | `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. 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/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. | | `rust/lithe-core/` | Shared commands, validation, parsing, ordering, Git operations, history, and JSON/C ABI. | diff --git a/docs/architecture/module-runtime.md b/docs/architecture/module-runtime.md new file mode 100644 index 00000000..9c6c36dc --- /dev/null +++ b/docs/architecture/module-runtime.md @@ -0,0 +1,273 @@ +# Module Runtime Architecture + +This document is the source of truth for feature isolation, lazy activation, +disablement, sleep, wake, and resource ownership in Lithe. Repository layer +rules still come from `repository-layout.md` and platform ownership rules from +`mac-service-boundaries.md`. + +## Required outcome + +A module is a runtime and dependency boundary, not merely a hidden UI surface +or a source directory. The completed architecture must satisfy all of these +invariants: + +1. A disabled module is never instantiated and starts no task, timer, session, + watcher, connection, or child process. +2. An inactive on-demand module is instantiated only when one of its declared + capabilities is requested or the user explicitly activates it. +3. Every long-lived resource is registered to exactly one module resource + scope. +4. A sleeping module has no active leases and owns zero active resources. Its + instance is released and may be reconstructed later. +5. Active Run, Test, Build, Debug, Terminal, import/export, transaction, or + other non-interruptible work blocks sleep with an observable reason. +6. Modules communicate only through Module API capabilities, immutable events, + and declared contributions. They do not import another feature module's + implementation. +7. Adding a module requires a target, manifest, factory, and registration. It + must not require adding concrete service fields to `AppServices` or feature + fields to `AppModel`. +8. Module identity, state, dependency, and lifecycle contracts remain + platform-neutral. This migration implements them in the macOS reference + product; Windows adoption is a separate effort owned by the Windows team. +9. Optional modules run in the application process and can therefore affect + that process. Before optional module activation, the host persists an + activation marker. An uncleared marker quarantines that module on the next + launch, where it can be disabled or explicitly re-enabled without first + constructing the module. +10. Safe Mode starts only required modules. It does not invoke optional module + factories and does not overwrite the user's normal enabled preferences. + +## Layers + +```text +Lithe App Shell + -> LitheApplicationKernel + -> LitheModuleAPI + -> platform capability ports + -> registered feature module factories +``` + +- `LitheModuleAPI` contains stable IDs, manifests, lifecycle contracts, + capabilities, events, leases, and resource interfaces. It imports no UI, + platform adapter, Rust bridge, or feature implementation. +- `LitheApplicationKernel` validates the dependency graph, lazily constructs + modules, controls state transitions, resolves capabilities, and enforces + resource-zero sleep and shutdown. +- Platform composition roots register platform capabilities and module + factories. They do not construct every feature service at application start. +- Feature modules are separate build targets. A feature target imports Module + API and only the narrow shared model/port targets it needs. + +## Module boundaries + +| Module | Scope | Default | Long-lived resource ownership | +| --- | --- | --- | --- | +| Workspace Foundation | workspace | eager, required | workspace watcher and document persistence tasks | +| Git Review | workspace | on demand | Git observation and refresh tasks | +| Search & Index | workspace | on demand | index workers and caches | +| Local History | workspace | on demand | snapshot/retention tasks | +| Language Intelligence | workspace | on demand | Rust LSP sessions, LSP processes, polling tasks | +| Build / Run / Test | workspace | on demand | build, run, test processes and output sessions | +| Debug | workspace | on demand | debuggee and debug-adapter processes/sessions | +| Terminal | workspace | on demand | PTY, shell processes, terminal sessions | +| Database | workspace | disabled by default for new installs | sidecar requests, connections, backup timer and import/export tasks | +| AI Assistance | application | disabled until configured | credential-backed network requests | + +Workspace Foundation is the only feature module that cannot be disabled while +a project is open. Editor rendering and application settings remain in the app +shell/design-system layers rather than becoming background modules. + +## Dependency rules + +- Module dependencies form an acyclic graph and are declared in manifests. +- A module depends on capability IDs, not another module's concrete service. +- A capability has one active provider. Multiple candidates require an explicit + selection policy before registration; silent last-writer-wins is forbidden. +- Events communicate completed facts and never request synchronous work. +- UI/tool-window/settings contributions are inert data registered on the + `ModuleFactory`, including placement, order, action ID, renderer ID, and + visibility metadata. Reading this catalog must not instantiate the module. + Workbench iterates contributions and delegates to platform action/renderer + registries; it must not switch on module types or contribution IDs. +- Platform processes, files, PTY, keychain, and native UI are obtained through + ports supplied in the module context. + +## Lifecycle + +```text +disabled -> inactive -> activating -> active -> idle + ^ | | + | | v + +------ sleeping <- preparingToSleep +``` + +Failures are explicit. A module with active leases enters `sleepBlocked`; a +module whose resources remain active after stop also enters `sleepBlocked` or +`failed`. Hiding a panel never changes lifecycle state by itself. + +This is recovery isolation, not process isolation. A defect in an in-process +module may terminate the current app process. The durable activation marker, +process-lifetime native-plugin marker, automatic quarantine, and `--safe-mode` +launch option ensure the next launch can recover without loading optional +module code. + +Native plugin code loading has a separate durable marker from module +activation. The host writes every module ID owned by the package before it +loads the Bundle or asks its principal class for factories, and keeps the IDs +of successfully loaded native plugins marked until a clean application +termination. If that marker is still present on the next launch, those modules +are quarantined before any plugin Bundle is touched. A thrown load or +factory-validation error also quarantines its modules while allowing required +built-in modules to start. Multiple project containers share one process-level +recovery coordinator so a later container cannot mistake the current process's +marker for an interrupted prior launch. + +## Plugin package lifecycle + +Official plugin packages use a static `plugin.json` manifest. The host scans +and decodes this file, checks host/API compatibility, verifies one-to-one +module ownership, validates the complete dependency graph, and checks the +same-Team-ID code-signing policy before loading native code. Disabled, +quarantined, and Safe Mode plugins are filtered before `Bundle` creation. + +The macOS package store keeps versioned package directories and an atomic +`installation.json` active-version pointer. Installation and verification are +staged before the pointer changes. A damaged optional package contributes a +management issue but cannot replace the required host catalog or prevent the +app shell from starting. Recovery actions use the installation record, so a +user can roll back or schedule removal even when the active manifest cannot be +decoded. + +Process-backed language ownership comes from verified installed manifests, not +only from Bundles loaded in the current process. A disabled, quarantined, or +failed-to-load language package therefore keeps its language IDs reserved: the +host reports the capability unavailable and never restores a legacy built-in +LSP, run, or test process path. + +Swift native bundles are not treated as safely unloadable. Disabling a loaded +plugin immediately shuts down its module graph and releases registered +resources, but the Bundle remains mapped until the process exits. Installation, +update, rollback, and uninstall therefore expose a restart-required state; +pending pointer changes and removals are finalized before plugin scanning on +the next launch. + +The host passes plugin factories a read-only `PluginHostContext`. Services in +that context are addressed by stable IDs and exposed through narrow protocols +from shared contract targets. A plugin never imports the executable target or +constructs a macOS adapter. This keeps a new module's host integration to its +service protocol, service registration, static manifest, and package build. + +Internal lifecycle modules and downloadable plugins are separate concepts. +Search, Git, Local History, Terminal, Debug, AI Assistance, Java/Maven +execution, Workspace Foundation, and the application/editor shell remain +statically composed even when they use `ModuleRuntime` for lazy activation or +sleep. + +Only three process- or connection-owning backends may ship through the native +package path in 0.3.0: + +1. database connections and their sidecar-owned resources; +2. language-server discovery, startup, sessions, and polling; +3. non-Java language support packages and their child processes. + +Each language package groups installation, update, and disablement while +declaring separate LSP, execution/test, and optional debug lifecycles. Execution +and testing may share one module because they use the same language toolchain; +each operation still receives its own process session. LSP remains a separate +module so editing intelligence can stop, fail, or sleep independently. Their UI +and application-facing state remain built in and consume narrow capability +protocols. Java and Maven project execution remain built in behind the same +provider shape. + +Go Support is the first released official native package. Its inert manifest +adds Go recognition to the Rust-backed language catalog without loading the +Bundle or probing `go`/`gopls`. Opening a Go document activates only the Go LSP +module. Running a current file, a Rust-detected `go.main`/`go.command` project +configuration, a service, or a test activates the Go Execution module and uses +a host-created process session tagged to that module. There is no built-in Go +process fallback once the package owns the capability, including after the +package is disabled and the app restarts. Running and testing sessions hold +module leases, so background sleep cannot interrupt active work. Once the last +session ends, the module becomes idle and its ten-minute policy may release it. +Disabling or sleeping the module stops every session in its resource pool and +waits for the operating-system process to exit, with bounded force termination; +failure leaves the module in an observable failed state. Successful document +synchronization refreshes the Go LSP idle timestamp, and LSP shutdown waits for +the Rust runtime's terminal state before the module is considered stopped. + +An official package is added to `OfficialPluginCatalog` only after its Bundle, +host contract, disable/sleep behavior, and resource cleanup tests exist. +Packages live under +`Contents/Resources/OfficialPlugins`; macOS reserves direct children of +`Contents/PlugIns` for standard code bundles. + +The sleep sequence is: + +1. reject new activity; +2. verify no active lease; +3. prepare and persist recoverable state; +4. stop module-owned services; +5. stop registered resources; +6. verify the active resource count is zero; +7. remove exported capabilities and release the module instance. + +Wake reconstructs the instance from its factory, activates declared +dependencies first, restores persisted state, then republishes capabilities. + +## Current resource migration inventory + +| Existing resource | Owning module after migration | +| --- | --- | +| `MacDirectoryWatcher` and workspace refresh tasks | Workspace Foundation | +| Git observation/refresh tasks | Git Review | +| search index and replacement work | Search & Index | +| snapshot retention and history operations | Local History | +| Java language-server discovery, Rust LSP sessions, LSP processes, polling | built-in Language Intelligence module | +| non-Java language-server discovery, Rust LSP sessions, LSP processes, polling | owning language support plugin | +| Java/Maven build, run, and test processes | built-in Execution module | +| non-Java run/test providers and child processes | owning language support plugin | +| `JavaDebugService`, DAP sessions, debuggee processes | Debug | +| `MacTerminalTransport`, PTY and shell | Terminal | +| database sidecar requests, connections, and connection-owned timers | Database Connections plugin | +| database UI and workspace state | built-in Database module | +| commit-message HTTP requests and imported credentials | AI Assistance | +| update checker | application shell, not workspace module | + +## Migration and completion gates + +Migration proceeds without changing public Rust JSON commands or platform +behavior: + +1. Introduce Module API and Kernel with graph, lifecycle, resource, lease, and + lazy-factory tests. +2. Wrap the current graph behind module factories while preserving behavior. +3. Extract database connection ownership behind a native plugin capability. +4. Extract LSP startup/session ownership behind a native plugin capability. +5. Keep Java/Maven execution built in and extract non-Java LSP/run/test/debug + providers into per-language packages, beginning with Go Support. +6. Keep other feature targets statically composed and eliminate concrete + feature fields from `AppServices` and `AppModel` where lifecycle isolation + benefits from it. +7. Add settings/status UI from module snapshots and contributions. +8. Add a boundary verifier that rejects imports between feature targets and + direct process ownership outside platform/Rust resource adapters. + +### Target extraction prerequisite + +Feature targets cannot depend on the `Lithe` executable target. Before moving +Search, History, Git, Execution, Debug, Terminal, Database, and AI +implementations, extract their platform-neutral models and ports into a +`LitheCoreContracts` library. The initial ownership set includes search/result +models, local-history DTOs, run/debug DTOs, terminal primitives, and the +Workspace/Git/History/process/HTTP capability ports. Feature targets then +depend only on `LitheModuleAPI`, `LitheCoreContracts`, and narrowly selected +workflow libraries. Empty feature targets or targets that re-export the +executable are not considered module isolation. + +Completion requires executable macOS tests proving disabled factories are not +called, dependency activation order is deterministic, active leases prevent +sleep, sleep releases instances and all resources, wake reconstructs state, +shutdown releases every module, capability collisions fail, and the existing +macOS, Rust, and shared-contract verification suites pass. Windows source, +build files, and Qt composition are outside this migration's change scope. diff --git a/docs/architecture/repository-layout.md b/docs/architecture/repository-layout.md index 48ee0310..7ed62bd7 100644 --- a/docs/architecture/repository-layout.md +++ b/docs/architecture/repository-layout.md @@ -10,13 +10,17 @@ on macOS types. ```text Lithe-IDEA/ ├── Sources/Lithe/ # macOS SwiftUI/AppKit application -│ ├── Application/ # feature models and application service graph -│ ├── Core/ # ports, Rust operations, and terminal primitives -│ ├── Models/ # UI-facing models and value types +│ ├── Application/ # composition, feature models, and lifecycle policy +│ ├── Core/ # ports, language catalogs, and typed Rust operations +│ ├── Models/ # UI aggregate, bridges, and domain-grouped value types │ ├── Platform/MacOS/ # macOS composition root and adapters -│ ├── Services/ # workflow orchestration -│ └── Views/ # SwiftUI/AppKit presentation +│ ├── Services/ # workflows grouped by product domain +│ └── Views/ # SwiftUI/AppKit presentation grouped by feature +├── Sources/Lithe*Module/ # independently owned built-in and plugin modules +├── Sources/LitheModuleAPI/ # module lifecycle, catalog, and plugin contracts +├── Sources/LitheCoreContracts/ # platform-neutral feature contracts ├── Sources/LitheRustCore/ # Swift Package C bridge declarations +├── Plugins/Official/ # source manifests and Bundle metadata for official plugins ├── Tests/LitheTests/ # Swift Testing unit tests ├── rust/lithe-core/ # shared Rust commands, models, and C ABI ├── windows/ # C++ CoreClient, Win32 adapters, and Qt UI @@ -51,6 +55,52 @@ 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. +## Swift source organization + +Directories inside the macOS executable target express ownership rather than +visibility. SwiftPM discovers them recursively, so moving a file between these +directories must not require a target or product change: + +```text +Sources/Lithe/ +├── Application/ +│ ├── Composition/ # application service graphs and module resource owners +│ ├── Features/ # UI-facing state transitions and user actions +│ └── Lifecycle/ # application-level lifecycle policy and errors +├── Core/ +│ ├── Language/ # language-provider catalog adapters +│ ├── Ports/ # platform-neutral interfaces +│ └── Rust/ # typed Rust JSON/C ABI adapters +├── Models/ +│ ├── AppModel/ # AppModel aggregate and focused extensions +│ ├── Bridges/ # executable-target conformance bridges +│ └── / # editor, diff, Java, runtime, search, and workspace values +├── Services// # product workflows grouped by their owning domain +└── Views// # presentation grouped by the user-facing feature +``` + +Feature module targets use the smallest applicable subset of the following +convention. A directory should exist only when the target owns that kind of +code: + +```text +Sources/LitheModule/ +├── Module/ # module entrypoint and feature graph +├── Application/ # feature state and UI-facing coordination +├── Models/ # domain and value types +├── Ports/ # interfaces owned by the feature +├── Services/ # workflows +├── Runtime/ # process, protocol, and session implementations +└── Providers/ # provider implementations +``` + +Official language-support plugins additionally use `Capabilities/`, `Plugin/`, +and `Support/` for exported language abilities, the native plugin entrypoint, +and shared identifiers. New files should be named after their primary type; +use `Type+Concern.swift` only for a focused extension or executable-target +bridge. Do not rename module IDs, capability IDs, JSON fields, C symbols, or +plugin entrypoint names as part of physical source reorganization. + ## 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`: diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 0f956c23..3ecb3d69 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -2,6 +2,7 @@ mod execution; mod git; mod languages; mod lsp; +pub mod plugins; mod project; mod protocol; mod runtime; diff --git a/rust/lithe-core/src/plugins/mod.rs b/rust/lithe-core/src/plugins/mod.rs new file mode 100644 index 00000000..276b7926 --- /dev/null +++ b/rust/lithe-core/src/plugins/mod.rs @@ -0,0 +1,290 @@ +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; + +pub const PLUGIN_MANIFEST_SCHEMA_VERSION: u32 = 1; +pub const PLUGIN_API_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct PluginVersion { + pub major: u32, + pub minor: u32, + pub patch: u32, +} + +impl PluginVersion { + pub fn parse(value: &str) -> Option { + let mut parts = value.split('.'); + let version = Self { + major: parts.next()?.parse().ok()?, + minor: parts.next()?.parse().ok()?, + patch: parts.next()?.parse().ok()?, + }; + parts.next().is_none().then_some(version) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginValidationError { + InvalidJson, + UnsupportedSchema { plugin: String, version: u32 }, + UnsupportedApi { plugin: String, version: u32 }, + InvalidVersion { plugin: String, value: String }, + IncompatibleHost { plugin: String }, + InvalidEntrypoint { plugin: String }, + DuplicatePlugin(String), + DuplicateModule(String), + EmptyPlugin(String), + UnsortedPlugins, + UnsortedModules { plugin: String }, + InvalidLanguageSupport { plugin: String, language: String }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginCatalogFixture { + pub schema_version: u32, + pub host_version: String, + #[serde(rename = "pluginAPIVersion")] + pub plugin_api_version: u32, + pub plugins: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginPackageManifest { + pub id: String, + pub display_name: String, + pub version: String, + pub api_version: u32, + pub host_compatibility: HostCompatibility, + pub vendor: PluginVendor, + pub entrypoint: PluginEntrypoint, + #[serde(rename = "moduleIDs")] + pub module_ids: Vec, + #[serde(default)] + pub language_supports: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LanguageSupportManifest { + pub id: String, + pub display_name: String, + #[serde(default)] + pub file_extensions: Vec, + #[serde(default)] + pub file_names: Vec, + #[serde(default)] + pub project_file_names: Vec, + #[serde(rename = "languageServerModuleID")] + pub language_server_module_id: Option, + #[serde(rename = "executionModuleID")] + pub execution_module_id: Option, + #[serde(rename = "testingModuleID")] + pub testing_module_id: Option, + #[serde(rename = "debugModuleID")] + pub debug_module_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostCompatibility { + pub minimum: String, + pub maximum_exclusive: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginVendor { + pub id: String, + pub display_name: String, + pub signature_requirement: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginEntrypoint { + pub kind: String, + pub target_name: Option, + pub bundle_identifier: Option, + pub principal_class: Option, + pub bundle_path: Option, +} + +pub fn validate_plugin_catalog_json( + input: &str, + host_version: PluginVersion, +) -> Result, PluginValidationError> { + let catalog: PluginCatalogFixture = + serde_json::from_str(input).map_err(|_| PluginValidationError::InvalidJson)?; + if catalog.schema_version != PLUGIN_MANIFEST_SCHEMA_VERSION { + return Err(PluginValidationError::UnsupportedSchema { + plugin: "catalog".into(), + version: catalog.schema_version, + }); + } + if catalog.plugin_api_version != PLUGIN_API_VERSION { + return Err(PluginValidationError::UnsupportedApi { + plugin: "catalog".into(), + version: catalog.plugin_api_version, + }); + } + let catalog_host = parse_version("catalog", &catalog.host_version)?; + if catalog_host != host_version { + return Err(PluginValidationError::IncompatibleHost { + plugin: "catalog".into(), + }); + } + let plugin_ids: Vec<&str> = catalog + .plugins + .iter() + .map(|plugin| plugin.id.as_str()) + .collect(); + if !plugin_ids.windows(2).all(|pair| pair[0] < pair[1]) { + return Err(PluginValidationError::UnsortedPlugins); + } + + let mut seen_plugins = BTreeSet::new(); + let mut module_owners = BTreeMap::new(); + for plugin in catalog.plugins { + if !seen_plugins.insert(plugin.id.clone()) { + return Err(PluginValidationError::DuplicatePlugin(plugin.id)); + } + if plugin.api_version != PLUGIN_API_VERSION { + return Err(PluginValidationError::UnsupportedApi { + plugin: plugin.id, + version: plugin.api_version, + }); + } + let _version = parse_version(&plugin.id, &plugin.version)?; + let minimum = parse_version(&plugin.id, &plugin.host_compatibility.minimum)?; + let maximum = plugin + .host_compatibility + .maximum_exclusive + .as_deref() + .map(|value| parse_version(&plugin.id, value)) + .transpose()?; + if host_version < minimum || maximum.is_some_and(|value| host_version >= value) { + return Err(PluginValidationError::IncompatibleHost { plugin: plugin.id }); + } + if plugin.display_name.is_empty() + || plugin.vendor.id.is_empty() + || plugin.vendor.display_name.is_empty() + || plugin.vendor.signature_requirement != "sameTeamAsHost" + || !valid_entrypoint(&plugin.entrypoint) + { + return Err(PluginValidationError::InvalidEntrypoint { plugin: plugin.id }); + } + if plugin.module_ids.is_empty() { + return Err(PluginValidationError::EmptyPlugin(plugin.id)); + } + if !plugin.module_ids.windows(2).all(|pair| pair[0] < pair[1]) { + return Err(PluginValidationError::UnsortedModules { plugin: plugin.id }); + } + validate_language_supports(&plugin)?; + for module_id in plugin.module_ids { + if module_owners + .insert(module_id.clone(), plugin.id.clone()) + .is_some() + { + return Err(PluginValidationError::DuplicateModule(module_id)); + } + } + } + Ok(module_owners) +} + +fn validate_language_supports(plugin: &PluginPackageManifest) -> Result<(), PluginValidationError> { + let owned_modules: BTreeSet<&str> = plugin.module_ids.iter().map(String::as_str).collect(); + let mut language_ids = BTreeSet::new(); + for support in &plugin.language_supports { + let module_ids: Vec<&str> = [ + support.language_server_module_id.as_deref(), + support.execution_module_id.as_deref(), + support.testing_module_id.as_deref(), + support.debug_module_id.as_deref(), + ] + .into_iter() + .flatten() + .collect(); + let recognition_is_empty = support.file_extensions.is_empty() + && support.file_names.is_empty() + && support.project_file_names.is_empty(); + let invalid_names = support.id.is_empty() + || support.id != support.id.trim().to_lowercase() + || support.display_name.is_empty() + || !strictly_sorted(&support.file_extensions) + || !strictly_sorted(&support.file_names) + || !strictly_sorted(&support.project_file_names) + || support + .file_extensions + .iter() + .any(|value| value.starts_with('.') || value.contains('/')) + || support.file_names.iter().any(|value| value.contains('/')) + || support + .project_file_names + .iter() + .any(|value| value.contains('/')); + if !language_ids.insert(support.id.as_str()) + || recognition_is_empty + || invalid_names + || module_ids.is_empty() + || !module_ids.iter().all(|id| owned_modules.contains(id)) + { + return Err(PluginValidationError::InvalidLanguageSupport { + plugin: plugin.id.clone(), + language: support.id.clone(), + }); + } + } + Ok(()) +} + +fn strictly_sorted(values: &[String]) -> bool { + values.windows(2).all(|pair| pair[0] < pair[1]) +} + +fn parse_version(plugin: &str, value: &str) -> Result { + PluginVersion::parse(value).ok_or_else(|| PluginValidationError::InvalidVersion { + plugin: plugin.into(), + value: value.into(), + }) +} + +fn valid_entrypoint(entrypoint: &PluginEntrypoint) -> bool { + match entrypoint.kind.as_str() { + "builtIn" => { + entrypoint + .target_name + .as_ref() + .is_some_and(|value| !value.is_empty()) + && entrypoint.bundle_identifier.is_none() + && entrypoint.principal_class.is_none() + && entrypoint.bundle_path.is_none() + } + "nativeBundle" => { + entrypoint.target_name.is_none() + && entrypoint + .bundle_identifier + .as_ref() + .is_some_and(|value| !value.is_empty()) + && entrypoint + .principal_class + .as_ref() + .is_some_and(|value| !value.is_empty()) + && entrypoint + .bundle_path + .as_ref() + .is_some_and(|value| valid_relative_path(value)) + } + _ => false, + } +} + +fn valid_relative_path(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('/') + && !value + .split('/') + .any(|component| component == ".." || component.is_empty()) +} diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index 67722294..3c099002 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -1,6 +1,7 @@ mod detectors; mod git; mod languages; +mod plugins; mod project; mod protocol; mod run_configuration; diff --git a/rust/lithe-core/src/tests/plugins.rs b/rust/lithe-core/src/tests/plugins.rs new file mode 100644 index 00000000..81d8c88f --- /dev/null +++ b/rust/lithe-core/src/tests/plugins.rs @@ -0,0 +1,63 @@ +use crate::plugins::{validate_plugin_catalog_json, PluginValidationError, PluginVersion}; +const OFFICIAL_PLUGINS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/official-v1.json" +)); +const LANGUAGE_SUPPORT_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/plugins/language-support-v1.json" +)); +#[test] +fn official_plugin_catalog_is_valid_and_contains_only_released_downloads() { + let owners = validate_plugin_catalog_json( + OFFICIAL_PLUGINS, + PluginVersion { + major: 0, + minor: 3, + patch: 0, + }, + ) + .expect("official plugin fixture should validate"); + assert!(owners.is_empty()); +} + +#[test] +fn language_support_catalog_allows_execution_and_testing_to_share_a_module() { + let owners = validate_plugin_catalog_json( + LANGUAGE_SUPPORT_FIXTURE, + PluginVersion { + major: 0, + minor: 3, + patch: 0, + }, + ) + .expect("language support fixture should validate"); + assert_eq!(owners.len(), 2); + assert_eq!( + owners.get("dev.lithe.fixture.go.language-server"), + Some(&"dev.lithe.fixture.go-support".to_string()) + ); + assert_eq!( + owners.get("dev.lithe.fixture.go.execution"), + Some(&"dev.lithe.fixture.go-support".to_string()) + ); +} + +#[test] +fn incompatible_host_is_rejected_deterministically() { + let error = validate_plugin_catalog_json( + OFFICIAL_PLUGINS, + PluginVersion { + major: 0, + minor: 4, + patch: 0, + }, + ) + .unwrap_err(); + assert_eq!( + error, + PluginValidationError::IncompatibleHost { + plugin: "catalog".into() + } + ); +} diff --git a/scripts/build-official-plugins.sh b/scripts/build-official-plugins.sh new file mode 100755 index 00000000..041f8721 --- /dev/null +++ b/scripts/build-official-plugins.sh @@ -0,0 +1,99 @@ +#!/bin/zsh + +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +CONFIGURATION="debug" +TRIPLE="" +OUTPUT_DIR="" +SIGNING_IDENTITY="${LITHE_CODESIGN_IDENTITY:--}" +PLUGIN_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --configuration) CONFIGURATION="$2"; shift 2 ;; + --triple) TRIPLE="$2"; shift 2 ;; + --output) OUTPUT_DIR="$2"; shift 2 ;; + --plugin-id) PLUGIN_ID="$2"; shift 2 ;; + *) print -u2 -- "Usage: $0 --triple triple [--configuration debug|release] [--output directory] [--plugin-id id]"; exit 2 ;; + esac +done + +if [[ "$CONFIGURATION" != "debug" && "$CONFIGURATION" != "release" ]]; then + print -u2 -- "Unsupported configuration: $CONFIGURATION" + exit 2 +fi +case "$TRIPLE" in + arm64-apple-macosx) TARGET="arm64-apple-macosx13.0" ;; + x86_64-apple-macosx) TARGET="x86_64-apple-macosx13.0" ;; + *) print -u2 -- "Unsupported macOS Swift triple: $TRIPLE"; exit 2 ;; +esac + +BUILD_DIR="$ROOT_DIR/.build/$TRIPLE/$CONFIGURATION" +MODULE_DIR="$BUILD_DIR/Modules" +if [[ ! -f "$MODULE_DIR/LitheModuleAPI.swiftmodule" || ! -f "$MODULE_DIR/LitheCoreContracts.swiftmodule" ]]; then + print -u2 -- "Build Lithe for $TRIPLE ($CONFIGURATION) before packaging official plugins" + exit 1 +fi + +if [[ -z "$OUTPUT_DIR" ]]; then + OUTPUT_DIR="$BUILD_DIR/OfficialPlugins" +fi +SDK_PATH=$(/usr/bin/xcrun --sdk macosx --show-sdk-path) + +mkdir -p "$OUTPUT_DIR" +for stale_package in "$OUTPUT_DIR"/*(/N); do + [[ -f "$stale_package/plugin.json" ]] || continue + rm -rf "$stale_package" +done +matched=0 +for plugin_source in "$ROOT_DIR"/Plugins/Official/*(/N); do + manifest="$plugin_source/plugin.json" + info_plist="$plugin_source/Info.plist" + [[ -f "$manifest" && -f "$info_plist" ]] || continue + package_id=$(/usr/bin/plutil -extract id raw "$manifest") + if [[ -n "$PLUGIN_ID" && "$package_id" != "$PLUGIN_ID" ]]; then + continue + fi + matched=$((matched + 1)) + module_suffix="${plugin_source:t}" + source_dir="$ROOT_DIR/Sources/Lithe${module_suffix}Module" + source_files=("$source_dir"/**/*.swift(N)) + if (( ${#source_files[@]} == 0 )); then + print -u2 -- "Official plugin $package_id has no Swift sources at $source_dir" + exit 1 + fi + bundle_name=$(/usr/bin/plutil -extract entrypoint.bundlePath raw "$manifest") + executable_name=$(/usr/bin/plutil -extract CFBundleExecutable raw "$info_plist") + package_dir="$OUTPUT_DIR/$package_id" + bundle_dir="$package_dir/$bundle_name" + executable_dir="$bundle_dir/Contents/MacOS" + + rm -rf "$package_dir" + mkdir -p "$executable_dir" + cp "$manifest" "$package_dir/plugin.json" + cp "$info_plist" "$bundle_dir/Contents/Info.plist" + + /usr/bin/xcrun swiftc \ + -emit-library \ + -parse-as-library \ + -module-name "Lithe${module_suffix}Plugin" \ + -swift-version 6 \ + -target "$TARGET" \ + -sdk "$SDK_PATH" \ + -I "$MODULE_DIR" \ + -Xlinker -undefined \ + -Xlinker dynamic_lookup \ + "${source_files[@]}" \ + -o "$executable_dir/$executable_name" + + /usr/bin/codesign --force --sign "$SIGNING_IDENTITY" "$bundle_dir" +done + +if (( matched == 0 )); then + if [[ -n "$PLUGIN_ID" ]]; then + print -u2 -- "No official plugin matched $PLUGIN_ID" + exit 1 + fi +fi +print -r -- "$OUTPUT_DIR" diff --git a/scripts/package-app.sh b/scripts/package-app.sh index eb2cdfcb..d25d1144 100755 --- a/scripts/package-app.sh +++ b/scripts/package-app.sh @@ -9,6 +9,7 @@ DEFAULT_BUILD_NUMBER=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$INF VERSION="${LITHE_VERSION:-$DEFAULT_VERSION}" BUILD_NUMBER="${LITHE_BUILD_NUMBER:-$DEFAULT_BUILD_NUMBER}" ARCH="${LITHE_ARCH:-universal}" +SIGNING_IDENTITY="${LITHE_CODESIGN_IDENTITY:--}" ARM64_TRIPLE="arm64-apple-macosx" X86_64_TRIPLE="x86_64-apple-macosx" @@ -88,6 +89,42 @@ if [[ ! -d "$resource_bundle" ]]; then exit 1 fi cp -R "$resource_bundle" "$APP_DIR/Contents/Resources/Lithe_Lithe.bundle" + +OFFICIAL_PLUGIN_DESTINATION="$APP_DIR/Contents/Resources/OfficialPlugins" +mkdir -p "$OFFICIAL_PLUGIN_DESTINATION" +if [[ "$ARCH" == "universal" ]]; then + arm64_plugin_root=$(LITHE_CODESIGN_IDENTITY="$SIGNING_IDENTITY" scripts/build-official-plugins.sh \ + --configuration release \ + --triple "$ARM64_TRIPLE") + x86_64_plugin_root=$(LITHE_CODESIGN_IDENTITY="$SIGNING_IDENTITY" scripts/build-official-plugins.sh \ + --configuration release \ + --triple "$X86_64_TRIPLE") + for arm64_plugin in "$arm64_plugin_root"/*(/N); do + plugin_id="${arm64_plugin:t}" + x86_64_plugin="$x86_64_plugin_root/$plugin_id" + [[ -d "$x86_64_plugin" ]] || { print -u2 -- "Missing x86_64 plugin package: $plugin_id"; exit 1; } + cp -R "$arm64_plugin" "$OFFICIAL_PLUGIN_DESTINATION/$plugin_id" + bundle_path=$(/usr/bin/plutil -extract entrypoint.bundlePath raw "$arm64_plugin/plugin.json") + executable_name=$(/usr/bin/plutil -extract CFBundleExecutable raw "$arm64_plugin/$bundle_path/Contents/Info.plist") + plugin_executable="$bundle_path/Contents/MacOS/$executable_name" + universal_plugin=$(mktemp "$OFFICIAL_PLUGIN_DESTINATION/$plugin_id/.plugin.XXXXXX") + lipo -create \ + "$arm64_plugin/$plugin_executable" \ + "$x86_64_plugin/$plugin_executable" \ + -output "$universal_plugin" + mv "$universal_plugin" "$OFFICIAL_PLUGIN_DESTINATION/$plugin_id/$plugin_executable" + codesign --force --sign "$SIGNING_IDENTITY" \ + "$OFFICIAL_PLUGIN_DESTINATION/$plugin_id/$bundle_path" + done +else + plugin_root=$(LITHE_CODESIGN_IDENTITY="$SIGNING_IDENTITY" scripts/build-official-plugins.sh \ + --configuration release \ + --triple "$ARCH-apple-macosx") + for plugin_package in "$plugin_root"/*(/N); do + cp -R "$plugin_package" "$OFFICIAL_PLUGIN_DESTINATION/${plugin_package:t}" + done +fi + cp "$INFO_PLIST" "$APP_DIR/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$APP_DIR/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$APP_DIR/Contents/Info.plist" @@ -99,6 +136,6 @@ for localization in en.lproj zh-Hans.lproj; do cp -R "$ROOT_DIR/Resources/$localization" "$APP_DIR/Contents/Resources/$localization" fi done -codesign --force --deep --sign - "$APP_DIR" +codesign --force --deep --sign "$SIGNING_IDENTITY" "$APP_DIR" echo "$APP_DIR" diff --git a/scripts/preview.sh b/scripts/preview.sh index 762babc2..4a995f77 100755 --- a/scripts/preview.sh +++ b/scripts/preview.sh @@ -16,8 +16,12 @@ scripts/build-macos.sh --configuration debug --triple "$TRIPLE" # 前台应用,窗口能收到鼠标点击但永远拿不到键盘焦点。 APP_DIR="$ROOT_DIR/.build/preview/Lithe.app" rm -rf "$APP_DIR" -mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" "$APP_DIR/Contents/Helpers" +mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources/OfficialPlugins" "$APP_DIR/Contents/Helpers" cp ".build/$TRIPLE/debug/Lithe" "$APP_DIR/Contents/MacOS/Lithe" +plugin_root=$(scripts/build-official-plugins.sh --configuration debug --triple "$TRIPLE") +for plugin_package in "$plugin_root"/*(/N); do + cp -R "$plugin_package" "$APP_DIR/Contents/Resources/OfficialPlugins/${plugin_package:t}" +done case "$TRIPLE" in arm64-apple-macosx) RUST_TARGET="aarch64-apple-darwin" ;; x86_64-apple-macosx) RUST_TARGET="x86_64-apple-darwin" ;; diff --git a/scripts/verify-core.sh b/scripts/verify-core.sh index 452638e5..43d47e0d 100755 --- a/scripts/verify-core.sh +++ b/scripts/verify-core.sh @@ -4,19 +4,6 @@ set -euo pipefail ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" -OUTPUT_DIR="$ROOT_DIR/.build/core-verification" -mkdir -p "$OUTPUT_DIR" - -swiftc \ - Sources/Lithe/Core/Terminal/TerminalBuffer.swift \ - Sources/Lithe/Models/GitModels.swift \ - Sources/Lithe/Models/SearchModels.swift \ - Sources/Lithe/Models/FileVisibilityRules.swift \ - Sources/Lithe/Models/GitGraphModels.swift \ - Sources/Lithe/Services/GitGraphLayoutService.swift \ - scripts/CoreVerification.swift \ - -o "$OUTPUT_DIR/verify-core" - -"$OUTPUT_DIR/verify-core" +swift run --quiet LitheCoreVerifier "$ROOT_DIR/scripts/verify-service-boundaries.sh" "$ROOT_DIR/scripts/verify-shared-contracts.sh" diff --git a/scripts/verify-git-graph.sh b/scripts/verify-git-graph.sh index 96c20270..b39ddcf5 100755 --- a/scripts/verify-git-graph.sh +++ b/scripts/verify-git-graph.sh @@ -3,16 +3,8 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT_DIR" -mkdir -p .build -swiftc \ - Sources/Lithe/Models/GitModels.swift \ - Sources/Lithe/Models/GitGraphModels.swift \ - Sources/Lithe/Services/GitGraphLayoutService.swift \ - scripts/GitGraphVerification.swift \ - -o .build/git-graph-verification - -./.build/git-graph-verification +swift run --quiet LitheGitGraphVerifier FIXTURE_DIR="$(scripts/create-git-graph-fixture.sh)" MERGE_LINE="$(git -C "$FIXTURE_DIR" log --all --merges --format='%H %P' -1)" diff --git a/scripts/verify-module-boundaries.sh b/scripts/verify-module-boundaries.sh new file mode 100755 index 00000000..53d58a1e --- /dev/null +++ b/scripts/verify-module-boundaries.sh @@ -0,0 +1,333 @@ +#!/bin/zsh +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +cd "$ROOT_DIR" + +module_ids=(workspace git search localHistory languageIntelligence execution debug terminal database aiAssistance) +module_types=(WorkspaceFoundation Database Git Search History LanguageIntelligence Execution Debug Terminal AIAssistance) +module_targets=(LitheWorkspaceModule LitheDatabaseModule LitheGitModule LitheSearchModule LitheLocalHistoryModule LitheLanguageIntelligenceModule LitheExecutionModule LitheDebugModule LitheTerminalModule LitheAIAssistanceModule) + +for id in "${module_ids[@]}"; do + if ! rg -q "static let ${id} = ModuleID" Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift; then + print -u2 "Missing built-in module ID: ${id}" + exit 1 + fi +done + +for index in {1..${#module_types[@]}}; do + type="${module_types[$index]}" + target="${module_targets[$index]}" + if ! rg -q "(final|open) class ${type}Module" "Sources/${target}"; then + print -u2 "Missing module implementation: ${type}Module" + exit 1 + fi + if ! rg -q "name: \"${target}\"" Package.swift; then + print -u2 "Missing SwiftPM module target: ${target}" + exit 1 + fi + if ! rg -q "${type}Module\.moduleManifest" Sources/Lithe/Platform/MacOS/MacServiceContainer.swift; then + print -u2 "Missing composition registration: ${type}Module" + exit 1 + fi +done + +for contribution_type in Database Git Search History LanguageIntelligence Execution Debug Terminal AIAssistance; do + if ! rg -q "${contribution_type}Module\.moduleContributions" Sources/Lithe/Platform/MacOS/MacServiceContainer.swift; then + print -u2 "Missing lazy contribution registration: ${contribution_type}Module" + exit 1 + fi +done + +if ! rg -q '^import LitheAIAssistanceModule$' Sources/Lithe/Platform/MacOS/MacServiceContainer.swift; then + print -u2 "AI Assistance must remain statically composed as an internal module" + exit 1 +fi +if rg -n 'AIAssistancePluginEntrypoint|dev\.lithe\.plugin\.ai-assistance' Sources Plugins; then + print -u2 "AI Assistance must not be exposed as a downloadable native plugin" + exit 1 +fi + +rg -q 'Contents/Resources/OfficialPlugins' scripts/package-app.sh || { + print -u2 "Packaged official plugins must use the signed resources package root" + exit 1 +} +if rg -n 'Contents/PlugIns' scripts/package-app.sh scripts/preview.sh; then + print -u2 "Static plugin package directories must not be placed directly in Contents/PlugIns" + exit 1 +fi + +if rg -n "final class (WorkspaceFoundation|Database|Git|Search|History|LanguageIntelligence|Execution|Debug|Terminal|AIAssistance)Module" Sources/Lithe; then + print -u2 "Module lifecycle implementation leaked back into the executable target" + exit 1 +fi + +if rg -n "(terminalFactory|shellDiscovery|commitMessageGenerator)" Sources/Lithe/Application/Composition/AppServices.swift; then + print -u2 "Lazy module-owned factories leaked into AppServices" + exit 1 +fi + +for legacy_terminal_file in \ + Sources/Lithe/Application/TerminalFeatureModel.swift \ + Sources/Lithe/Services/TerminalSession.swift \ + Sources/Lithe/Services/TerminalLinkResolver.swift \ + Sources/Lithe/Core/Ports/TerminalTransport.swift; do + if [[ -e "$legacy_terminal_file" ]]; then + print -u2 "Terminal implementation leaked outside LitheTerminalModule: $legacy_terminal_file" + exit 1 + fi +done + +for terminal_type in TerminalFeatureModel TerminalSession TerminalTransport TerminalLinkResolver; do + rg -q "(class|protocol|enum) ${terminal_type}" Sources/LitheTerminalModule || { + print -u2 "Terminal module is missing real implementation: ${terminal_type}" + exit 1 + } +done + +for legacy_ai_file in \ + Sources/Lithe/Application/AIAssistanceServiceBox.swift \ + Sources/Lithe/Services/CommitMessageGenerationService.swift \ + Sources/Lithe/Models/CommitMessageModels.swift; do + if [[ -e "$legacy_ai_file" ]]; then + print -u2 "AI Assistance implementation leaked outside LitheAIAssistanceModule: $legacy_ai_file" + exit 1 + fi +done + +for ai_type in AIAssistanceCapability CommitMessageGenerationService; do + rg -q "(class|struct|protocol|enum) ${ai_type}" Sources/LitheAIAssistanceModule || { + print -u2 "AI Assistance module is missing real implementation: ${ai_type}" + exit 1 + } +done + +for ai_contract in AIHTTPTransport AIProviderCredentialResolver AIProviderProfile CommitMessageAISettings; do + rg -q "(class|struct|protocol|enum) ${ai_contract}" Sources/LitheCoreContracts || { + print -u2 "AI Assistance shared contract is missing from LitheCoreContracts: ${ai_contract}" + exit 1 + } +done + +if rg -n '^import LitheAIAssistanceModule$' Sources/Lithe/Application/Composition/AppServices.swift; then + print -u2 "AppServices must consume shared AI contracts rather than the concrete AI module" + exit 1 +fi + +if rg -n 'FeatureModuleHandle|AIAssistanceServiceBox' Sources/LitheAIAssistanceModule; then + print -u2 "AI Assistance must own its service graph rather than hosting an executable-target handle" + exit 1 +fi + +for legacy_search_file in \ + Sources/Lithe/Application/SearchFeatureModel.swift \ + Sources/Lithe/Models/SearchModels.swift \ + Sources/Lithe/Models/ProjectReplacementModels.swift; do + if [[ -e "$legacy_search_file" ]]; then + print -u2 "Search implementation leaked outside LitheSearchModule: $legacy_search_file" + exit 1 + fi +done + +for search_type in SearchFeatureModel SearchOperations FileSearchResult ProjectReplacementFile; do + rg -q "(class|struct|protocol|enum) ${search_type}" Sources/LitheSearchModule || { + print -u2 "Search module is missing real implementation: ${search_type}" + exit 1 + } +done + +if rg -n 'FeatureModuleHandle' Sources/LitheSearchModule; then + print -u2 "Search must own its feature graph rather than hosting an executable-target handle" + exit 1 +fi + +for legacy_history_file in \ + Sources/Lithe/Application/ProjectHistoryFeatureModel.swift \ + Sources/Lithe/Services/LocalHistoryService.swift \ + Sources/Lithe/Models/LocalHistoryModels.swift; do + if [[ -e "$legacy_history_file" ]]; then + print -u2 "Local History implementation leaked outside LitheLocalHistoryModule: $legacy_history_file" + exit 1 + fi +done + +for history_type in ProjectHistoryFeatureModel LocalHistoryService LocalHistoryOperations LocalHistoryEntry; do + rg -q "(actor|class|struct|protocol|enum) ${history_type}" Sources/LitheLocalHistoryModule || { + print -u2 "Local History module is missing real implementation: ${history_type}" + exit 1 + } +done + +if rg -n 'FeatureModuleHandle|EditorDocument|RustCoreBridge' Sources/LitheLocalHistoryModule; then + print -u2 "Local History must own its graph without executable, editor, or Rust bridge types" + exit 1 +fi + +for legacy_git_file in \ + Sources/Lithe/Application/GitFeatureModel.swift \ + Sources/Lithe/Services/GitService.swift \ + Sources/Lithe/Services/ShelveService.swift \ + Sources/Lithe/Services/GitGraphLayoutService.swift \ + Sources/Lithe/Models/GitModels.swift \ + Sources/Lithe/Models/GitGraphModels.swift; do + if [[ -e "$legacy_git_file" ]]; then + print -u2 "Git implementation leaked outside LitheGitModule: $legacy_git_file" + exit 1 + fi +done + +for git_type in GitFeatureModel GitService ShelveService GitOperations GitGraphLayoutService; do + rg -q "(class|struct|protocol|enum) ${git_type}" Sources/LitheGitModule || { + print -u2 "Git module is missing real implementation: ${git_type}" + exit 1 + } +done + +if rg -n 'FeatureModuleHandle|RustCoreBridge|FileStorage' Sources/LitheGitModule; then + print -u2 "Git must own its graph through ports without executable-target handles or adapters" + exit 1 +fi + +if rg -n 'Debug(Adapter|Launch|Breakpoint|Thread|StackFrame|Scope|Variable|ExecutionCommand)' Sources/Lithe/Core/Ports/LanguageTooling.swift; then + print -u2 "Debug/DAP contracts leaked back into LanguageTooling.swift" + exit 1 +fi + +if rg -n 'LanguageTest(Item|Scope|Context|Plan|Provider)' Sources/Lithe/Core/Ports/LanguageTooling.swift; then + print -u2 "Execution/Test contracts leaked back into LanguageTooling.swift" + exit 1 +fi + +if rg -n 'terminal\.sessions|git\.log|language\.problems|execution\.(maven|run|tests)|debug\.session' Sources/Lithe/Views/Workbench/WorkbenchView.swift; then + print -u2 "Workbench switches on concrete module contribution IDs" + exit 1 +fi + +for legacy_database_file in \ + Sources/Lithe/Application/DatabaseFeatureModel.swift \ + Sources/Lithe/Application/DatabaseSQLSupport.swift \ + Sources/Lithe/Application/DatabaseSchemaDiff.swift \ + Sources/Lithe/Services/DatabaseConnectionStore.swift \ + Sources/Lithe/Services/DatabaseDBXImportService.swift \ + Sources/Lithe/Services/DatabaseSidecarService.swift \ + Sources/Lithe/Core/Ports/DatabaseRecovery.swift; do + if [[ -e "$legacy_database_file" ]]; then + print -u2 "Database implementation leaked outside LitheDatabaseModule: $legacy_database_file" + exit 1 + fi +done + +for database_type in DatabaseFeatureModel DatabaseSidecarService DatabaseConnectionStore DatabaseProcessRunning DatabaseRecoveryStoring; do + rg -q "(class|struct|protocol|enum) ${database_type}" Sources/LitheDatabaseModule || { + print -u2 "Database module is missing real implementation: ${database_type}" + exit 1 + } +done + +if rg -n 'FeatureModuleHandle|ProcessRunner|KeyValueStore|SecureStore|FileStorage' Sources/LitheDatabaseModule | rg -v 'Database(ProcessRunner|PreferenceStore|SecureStore|FileStorage)'; then + print -u2 "Database must own its graph through database-scoped ports" + exit 1 +fi + +for language_type in LanguageIntelligenceModule LanguageIntelligenceCapability LanguageIntelligenceServiceGraph; do + rg -q "(class|struct|protocol|enum) ${language_type}" Sources/LitheLanguageIntelligenceModule || { + print -u2 "Language Intelligence module is missing its owned lifecycle boundary: ${language_type}" + exit 1 + } +done + +if rg -n '(FeatureModuleHandle\(|: HostedFeatureModule)' Sources/LitheLanguageIntelligenceModule; then + print -u2 "Language Intelligence must not host an arbitrary executable-target feature handle" + exit 1 +fi + +if [[ ! -f Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift ]]; then + print -u2 "Language Intelligence module is missing independent lifecycle tests" + exit 1 +fi + +for debug_type in DebugModule DebugModuleCapability DebugServiceGraph; do + rg -q "(class|struct|protocol|enum) ${debug_type}" Sources/LitheDebugModule || { + print -u2 "Debug module is missing its owned lifecycle boundary: ${debug_type}" + exit 1 + } +done + +if rg -n '(FeatureModuleHandle\(|: HostedFeatureModule)' Sources/LitheDebugModule; then + print -u2 "Debug must not host an arbitrary executable-target feature handle" + exit 1 +fi + +if [[ ! -f Tests/LitheDebugModuleTests/DebugModuleTests.swift ]]; then + print -u2 "Debug module is missing independent lifecycle tests" + exit 1 +fi + +for execution_type in ExecutionModule ExecutionModuleCapability ExecutionServiceGraph; do + rg -q "(class|struct|protocol|enum) ${execution_type}" Sources/LitheExecutionModule || { + print -u2 "Execution module is missing its owned lifecycle boundary: ${execution_type}" + exit 1 + } +done + +if rg -n '(FeatureModuleHandle\(|: HostedFeatureModule)' Sources/LitheExecutionModule; then + print -u2 "Execution must not host an arbitrary executable-target feature handle" + exit 1 +fi + +if [[ ! -f Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift ]]; then + print -u2 "Execution module is missing independent lifecycle tests" + exit 1 +fi + +for workspace_type in WorkspaceFoundationModule WorkspaceFoundationCapability WorkspaceResourceGraph; do + rg -q "(class|struct|protocol|enum) ${workspace_type}" Sources/LitheWorkspaceModule || { + print -u2 "Workspace module is missing its owned lifecycle boundary: ${workspace_type}" + exit 1 + } +done + +if rg -n '(FeatureModuleHandle\(|: HostedFeatureModule)' Sources/LitheWorkspaceModule; then + print -u2 "Workspace must not host an arbitrary executable-target feature handle" + exit 1 +fi + +if [[ ! -f Tests/LitheWorkspaceModuleTests/WorkspaceModuleTests.swift ]]; then + print -u2 "Workspace module is missing independent lifecycle tests" + exit 1 +fi + +if rg -n 'SearchFeatureModel\(' Sources/Lithe/Models/AppModel/AppModel.swift; then + print -u2 "Search must be constructed only by its module factory" + exit 1 +fi + +if rg -n 'ProjectHistoryFeatureModel\(' Sources/Lithe/Models/AppModel/AppModel.swift; then + print -u2 "Local History must be constructed only by its module factory" + exit 1 +fi + +if rg -n 'let (gitService|mavenService|runService|javaDebugService|languageTestService):|let (gitFeature|mavenFeature|runFeature|debugFeature|genericDebugFeature):' Sources/Lithe/Application/Composition/AppServices.swift Sources/Lithe/Models/AppModel/AppModel.swift; then + print -u2 "Concrete feature module ownership leaked into AppServices or AppModel" + exit 1 +fi + +for target in "${module_targets[@]}"; do + if rg -n '^import (SwiftUI|AppKit|Lithe)$' "Sources/${target}"; then + print -u2 "Feature target ${target} imports UI or executable implementation" + exit 1 + fi +done + +if find Sources -maxdepth 1 -type d -name 'Lithe*Module' | while read -r target; do + [[ -n "$(find "$target" -type f -name '*.swift' -print -quit)" ]] || { + print -u2 "Empty feature target is not a valid module boundary: $target" + exit 1 + } +done; then + : +else + exit 1 +fi + +print "Module boundary verification passed: IDs, implementations, registrations, and lazy ownership checks are intact" diff --git a/scripts/verify-official-plugins.sh b/scripts/verify-official-plugins.sh new file mode 100755 index 00000000..8a757db3 --- /dev/null +++ b/scripts/verify-official-plugins.sh @@ -0,0 +1,22 @@ +#!/bin/zsh + +set -euo pipefail + +ROOT_DIR="${0:A:h:h}" +cd "$ROOT_DIR" + +case "$(uname -m)" in + arm64) TRIPLE="arm64-apple-macosx" ;; + x86_64) TRIPLE="x86_64-apple-macosx" ;; + *) print -u2 -- "Unsupported host architecture: $(uname -m)"; exit 1 ;; +esac + +swift build --triple "$TRIPLE" +PLUGIN_ROOT=$(scripts/build-official-plugins.sh \ + --configuration debug \ + --triple "$TRIPLE") +plugins=("$PLUGIN_ROOT"/*(/N)) +for plugin in "${plugins[@]}"; do + swift run --triple "$TRIPLE" LitheOfficialPluginVerifier "$plugin" +done +print "Verified ${#plugins[@]} released official native plugin package(s)" diff --git a/scripts/verify-service-boundaries.sh b/scripts/verify-service-boundaries.sh index ce54dcf0..45b5cc0e 100755 --- a/scripts/verify-service-boundaries.sh +++ b/scripts/verify-service-boundaries.sh @@ -14,10 +14,11 @@ appmodel_business_pattern='Task\.detached|LocalHistoryService|WorkspaceTextFileP core_violations=$(rg -n "$core_pattern" Sources/Lithe/Core || true) service_violations=$(rg -n "$service_pattern" Sources/Lithe/Services || true) ui_violations=$(rg -n "$ui_service_pattern" Sources/Lithe/Views || true) -composition_violations=$(rg -n "$composition_pattern" Sources/Lithe/Models/AppModel.swift || true) -application_ui_violations=$(rg -n "$application_ui_pattern" Sources/Lithe/Models/AppModel.swift || true) -appmodel_business_violations=$(rg -n "$appmodel_business_pattern" Sources/Lithe/Models/AppModel.swift || true) -appmodel_line_count=$(wc -l < Sources/Lithe/Models/AppModel.swift | tr -d ' ') +appmodel_path=Sources/Lithe/Models/AppModel/AppModel.swift +composition_violations=$(rg -n "$composition_pattern" "$appmodel_path" || true) +application_ui_violations=$(rg -n "$application_ui_pattern" "$appmodel_path" || true) +appmodel_business_violations=$(rg -n "$appmodel_business_pattern" "$appmodel_path" || true) +appmodel_line_count=$(wc -l < "$appmodel_path" | tr -d ' ') if [[ -n "$core_violations" ]]; then print -u2 "Core boundary violations:" diff --git a/scripts/verify-shared-contracts.sh b/scripts/verify-shared-contracts.sh index cab2d4ee..18fd50a4 100755 --- a/scripts/verify-shared-contracts.sh +++ b/scripts/verify-shared-contracts.sh @@ -8,4 +8,85 @@ for fixture in shared/fixtures/**/*.json; do /usr/bin/ruby -rjson -e 'JSON.parse(File.read(ARGV.fetch(0)))' "$fixture" done +module_fixture="shared/fixtures/modules/built-in-v1.json" +plugin_fixture="shared/fixtures/plugins/official-v1.json" +fixture_ids=$(/usr/bin/ruby -rjson -e 'puts JSON.parse(File.read(ARGV.fetch(0))).fetch("modules").map { |m| m.fetch("id") }.sort' "$module_fixture") +swift_ids=$(rg '^[[:space:]]*static let .* = ModuleID\("dev\.lithe\.[^"]+"\)' Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift \ + | sed -E 's/.*ModuleID\("([^"]+)"\).*/\1/' \ + | sort) +if [[ "$fixture_ids" != "$swift_ids" ]]; then + print -u2 "Built-in module fixture and Swift ModuleID declarations differ" + diff <(print -r -- "$fixture_ids") <(print -r -- "$swift_ids") || true + exit 1 +fi + +fixture_capability_ids=$(/usr/bin/ruby -rjson -e 'puts JSON.parse(File.read(ARGV.fetch(0))).fetch("modules").flat_map { |m| m.fetch("capabilities") }.uniq.sort' "$module_fixture") +swift_capability_ids=$(rg '^[[:space:]]*static let .* = ModuleCapabilityID\("dev\.lithe\.capability\.[^"]+"\)' Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift \ + | sed -E 's/.*ModuleCapabilityID\("([^"]+)"\).*/\1/' \ + | sort) +if [[ "$fixture_capability_ids" != "$swift_capability_ids" ]]; then + print -u2 "Built-in module fixture and Swift capability declarations differ" + diff <(print -r -- "$fixture_capability_ids") <(print -r -- "$swift_capability_ids") || true + exit 1 +fi + +/usr/bin/ruby -rjson -e ' + data = JSON.parse(File.read(ARGV.fetch(0))) + abort "module fixture version must be 1" unless data["version"] == 1 + modules = data.fetch("modules") + abort "module IDs must be sorted" unless modules.map { |m| m.fetch("id") } == modules.map { |m| m.fetch("id") }.sort + abort "workspace must be the only required module" unless modules.select { |m| m.fetch("required") }.map { |m| m.fetch("id") } == ["dev.lithe.workspace"] + abort "AI must be disabled by default" unless modules.find { |m| m.fetch("id") == "dev.lithe.ai-assistance" }.fetch("defaultState") == "disabled" + abort "Database must be disabled by default" unless modules.find { |m| m.fetch("id") == "dev.lithe.database" }.fetch("defaultState") == "disabled" + allowed_states = ["enabled", "disabled"] + allowed_scopes = ["application", "workspace"] + allowed_policies = ["eager", "onDemand", "manual"] + allowed_sleep_kinds = ["never", "whenIdle"] + allowed_contribution_kinds = ["command", "toolWindow", "settings", "status"] + ids = modules.map { |m| m.fetch("id") } + contribution_ids = [] + modules.each do |m| + abort "invalid defaultState" unless allowed_states.include?(m.fetch("defaultState")) + abort "invalid scope" unless allowed_scopes.include?(m.fetch("scope")) + abort "invalid activationPolicy" unless allowed_policies.include?(m.fetch("activationPolicy")) + sleep_policy = m.fetch("sleepPolicy") + abort "invalid sleepPolicy" unless allowed_sleep_kinds.include?(sleep_policy.fetch("kind")) + if sleep_policy.fetch("kind") == "whenIdle" + abort "invalid idle interval" unless sleep_policy.fetch("afterSeconds").is_a?(Numeric) && sleep_policy.fetch("afterSeconds") > 0 + else + abort "never sleep policy must not have an interval" if sleep_policy.key?("afterSeconds") + end + dependencies = m.fetch("dependencies") + abort "dependencies must be sorted" unless dependencies == dependencies.sort + abort "unknown module dependency" unless dependencies.all? { |dependency| ids.include?(dependency) } + capabilities = m.fetch("capabilities") + abort "capabilities must be sorted" unless capabilities == capabilities.sort + abort "module capability is missing" if capabilities.empty? + contributions = m.fetch("contributions") + abort "contributions must be sorted" unless contributions.map { |c| c.fetch("id") } == contributions.map { |c| c.fetch("id") }.sort + contributions.each do |contribution| + abort "invalid contribution kind" unless allowed_contribution_kinds.include?(contribution.fetch("kind")) + contribution_ids << contribution.fetch("id") + end + end + abort "capabilities must have one provider" unless modules.flat_map { |m| m.fetch("capabilities") }.uniq.length == modules.length + abort "contribution IDs must be globally unique" unless contribution_ids.uniq.length == contribution_ids.length +' "$module_fixture" + +/usr/bin/ruby -rjson -e ' + plugins = JSON.parse(File.read(ARGV.fetch(0))) + abort "plugin fixture schema must be 1" unless plugins.fetch("schemaVersion") == 1 + abort "plugin API version must be 1" unless plugins.fetch("pluginAPIVersion") == 1 + entries = plugins.fetch("plugins") + ids = entries.map { |plugin| plugin.fetch("id") } + abort "plugin IDs must be sorted" unless ids == ids.sort + owned_modules = entries.flat_map { |plugin| plugin.fetch("moduleIDs") } + abort "plugin module IDs must be unique" unless owned_modules.uniq.length == owned_modules.length + entries.each do |plugin| + abort "plugin API mismatch" unless plugin.fetch("apiVersion") == plugins.fetch("pluginAPIVersion") + abort "official plugin signature policy mismatch" unless plugin.fetch("vendor").fetch("signatureRequirement") == "sameTeamAsHost" + abort "plugin module IDs must be sorted" unless plugin.fetch("moduleIDs") == plugin.fetch("moduleIDs").sort + end +' "$plugin_fixture" + print "Shared contract verification passed: JSON fixtures are valid" diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index e9b01fb5..870c7881 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -32,6 +32,70 @@ verification scripts are the executable source of boundary checks. | 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 | +| Modules | stable IDs, manifests, enabled state, lifecycle snapshots, dependencies, capabilities, and contributions | native factories, processes, timers, PTY/ConPTY, watchers, connections, and UI rendering | + +## Module Lifecycle Contract + +The macOS reference product implements the built-in manifest in +`shared/fixtures/modules/built-in-v1.json`. Module IDs and manifest fields are +platform-neutral compatibility surfaces. A future Windows implementation may +adopt the contract independently without sharing Swift implementation code or +being coupled to the macOS migration schedule. An implementation of this +contract must preserve these invariants: + +- A disabled module is not instantiated and owns no task, timer, watcher, + session, connection, or child process. +- An on-demand module is instantiated only after its capability is requested. +- Sleeping stops every owned resource and releases the module instance. +- Active non-interruptible work holds a lease that blocks sleep with a reason. +- Wake reconstructs the module, activates declared dependencies first, and + republishes capabilities and contributions. +- Required modules cannot be disabled. A provider cannot be disabled while an + enabled module depends on it. +- Module state is one of `disabled`, `inactive`, `activating`, `active`, `idle`, + `preparingToSleep`, `sleeping`, `sleepBlocked`, or `failed`. +- Native plugin manifests, compatibility, ownership, and signatures are + validated before Bundle loading. A failed optional package is reported to + plugin management and does not prevent required modules from starting. +- Successfully loaded native plugin module IDs remain durably marked for the + process lifetime. An unclean exit leaves the mark behind, so the next launch + quarantines those modules before constructing any plugin Bundle. A clean + application termination clears the mark. +- Disabling a loaded in-process native plugin stops its module-owned resources + immediately. Its code remains mapped until restart, and the next launch + skips the Bundle before invoking its principal class or factories. +- Plugin update, rollback, and uninstall operations that affect mapped code + are finalized before plugin scanning on the next launch. +- Native plugin factories receive a read-only host context. Host services use + stable IDs and shared protocols; plugin code cannot import a platform + composition root or the application executable. +- AI Assistance, Terminal, Git, Search, Local History, Debug, and Java/Maven + execution are built-in lifecycle modules. They are not marketplace plugins. +- A downloadable language support package may declare language-server, + execution, testing, and debug module IDs under one language ID. All referenced + modules must be owned by the same package. Execution and testing may share a + module when they share one toolchain lifecycle; language-server and debug + lifecycles remain independently addressable. +- Plugin-owned Run and Test operations may reuse deterministic shared launch + plans, but the actual child process must use a session owned by the plugin + module. A disabled plugin language must not fall back to a built-in process + provider. +- Process-backed language ownership comes from verified installed manifests, + including packages that are disabled, quarantined, or failed to load. Those + states make the capability unavailable; they never restore a host process + fallback. +- Extension execution shutdown completes only after its operating-system + process exits. A bounded force-stop failure remains visible as an active + module resource. Active Run and Test sessions hold leases; successful LSP + document synchronization refreshes the owning module's idle timer. +- Language package manifests include inert file-extension, file-name, and + project-file recognition metadata. The host may use this metadata to suggest + an uninstalled plugin, but it must not load the Bundle or probe a toolchain + during recognition. + +Platform products do not share module-runtime implementation code. The stable +manifest, lifecycle semantics, and deterministic JSON representation are the +portable boundary. Workspace visibility and project detection exclude nested checkout containers named `.worktree` or `.worktrees` by default, so a copied project is not treated diff --git a/shared/fixtures/modules/built-in-v1.json b/shared/fixtures/modules/built-in-v1.json new file mode 100644 index 00000000..d6e91728 --- /dev/null +++ b/shared/fixtures/modules/built-in-v1.json @@ -0,0 +1,148 @@ +{ + "version": 1, + "modules": [ + { + "id": "dev.lithe.ai-assistance", + "displayName": "AI Assistance", + "scope": "application", + "defaultState": "disabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 300 }, + "dependencies": [], + "capabilities": ["dev.lithe.capability.ai-commit-message"], + "contributions": [ + { "id": "ai.commit-message", "kind": "command" }, + { "id": "ai.settings", "kind": "settings" } + ], + "required": false + }, + { + "id": "dev.lithe.database", + "displayName": "Database", + "scope": "workspace", + "defaultState": "disabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, + "dependencies": ["dev.lithe.workspace"], + "capabilities": ["dev.lithe.capability.database-workspace"], + "contributions": [ + { "id": "database.workspace", "kind": "toolWindow" } + ], + "required": false + }, + { + "id": "dev.lithe.debug", + "displayName": "Debug", + "scope": "workspace", + "defaultState": "enabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, + "dependencies": ["dev.lithe.execution", "dev.lithe.language-intelligence", "dev.lithe.workspace"], + "capabilities": ["dev.lithe.capability.debug-workspace"], + "contributions": [ + { "id": "debug.session", "kind": "toolWindow", "actionID": "debug.toggle", "rendererID": "debug.session" } + ], + "required": false + }, + { + "id": "dev.lithe.execution", + "displayName": "Build / Run / Test", + "scope": "workspace", + "defaultState": "enabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, + "dependencies": ["dev.lithe.workspace"], + "capabilities": ["dev.lithe.capability.execution-workspace"], + "contributions": [ + { "id": "execution.maven", "kind": "toolWindow", "actionID": "execution.maven.toggle", "rendererID": "execution.maven" }, + { "id": "execution.run", "kind": "toolWindow", "actionID": "execution.run.toggle", "rendererID": "execution.run" }, + { "id": "execution.tests", "kind": "toolWindow", "actionID": "execution.tests.toggle", "rendererID": "execution.tests" } + ], + "required": false + }, + { + "id": "dev.lithe.git", + "displayName": "Git Review", + "scope": "workspace", + "defaultState": "enabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, + "dependencies": ["dev.lithe.workspace"], + "capabilities": ["dev.lithe.capability.git-workspace"], + "contributions": [ + { "id": "git.changes", "kind": "toolWindow" }, + { "id": "git.log", "kind": "toolWindow", "actionID": "git.log.toggle", "rendererID": "git.log" } + ], + "required": false + }, + { + "id": "dev.lithe.language-intelligence", + "displayName": "Language Intelligence", + "scope": "workspace", + "defaultState": "enabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, + "dependencies": ["dev.lithe.workspace"], + "capabilities": ["dev.lithe.capability.language-intelligence"], + "contributions": [ + { "id": "language.problems", "kind": "toolWindow", "actionID": "language.problems.toggle", "rendererID": "language.problems" }, + { "id": "language.settings", "kind": "settings" } + ], + "required": false + }, + { + "id": "dev.lithe.local-history", + "displayName": "Local History", + "scope": "workspace", + "defaultState": "enabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, + "dependencies": ["dev.lithe.workspace"], + "capabilities": ["dev.lithe.capability.history-workspace"], + "contributions": [ + { "id": "history.local", "kind": "toolWindow" } + ], + "required": false + }, + { + "id": "dev.lithe.search", + "displayName": "Search & Index", + "scope": "workspace", + "defaultState": "enabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, + "dependencies": ["dev.lithe.workspace"], + "capabilities": ["dev.lithe.capability.search-workspace"], + "contributions": [ + { "id": "search.workspace", "kind": "toolWindow" } + ], + "required": false + }, + { + "id": "dev.lithe.terminal", + "displayName": "Terminal", + "scope": "workspace", + "defaultState": "enabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "whenIdle", "afterSeconds": 600 }, + "dependencies": ["dev.lithe.workspace"], + "capabilities": ["dev.lithe.capability.terminal-workspace"], + "contributions": [ + { "id": "terminal.sessions", "kind": "toolWindow", "actionID": "terminal.toggle", "rendererID": "terminal.sessions" } + ], + "required": false + }, + { + "id": "dev.lithe.workspace", + "displayName": "Workspace Foundation", + "scope": "workspace", + "defaultState": "enabled", + "activationPolicy": "eager", + "sleepPolicy": { "kind": "never" }, + "dependencies": [], + "capabilities": ["dev.lithe.capability.workspace-foundation"], + "contributions": [], + "required": true + } + ] +} diff --git a/shared/fixtures/plugins/language-support-v1.json b/shared/fixtures/plugins/language-support-v1.json new file mode 100644 index 00000000..891ff0fa --- /dev/null +++ b/shared/fixtures/plugins/language-support-v1.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "hostVersion": "0.3.0", + "pluginAPIVersion": 1, + "plugins": [ + { + "id": "dev.lithe.fixture.go-support", + "displayName": "Go Support Fixture", + "version": "0.3.0", + "apiVersion": 1, + "hostCompatibility": { + "minimum": "0.3.0", + "maximumExclusive": "0.4.0" + }, + "vendor": { + "id": "dev.lithe", + "displayName": "Lithe", + "signatureRequirement": "sameTeamAsHost" + }, + "entrypoint": { + "kind": "nativeBundle", + "bundleIdentifier": "dev.lithe.fixture.go-support.bundle", + "principalClass": "FixtureGoSupportEntrypoint", + "bundlePath": "FixtureGoSupport.bundle" + }, + "moduleIDs": [ + "dev.lithe.fixture.go.execution", + "dev.lithe.fixture.go.language-server" + ], + "languageSupports": [ + { + "id": "go", + "displayName": "Go", + "fileExtensions": ["go"], + "fileNames": [], + "projectFileNames": ["go.mod", "go.work"], + "languageServerModuleID": "dev.lithe.fixture.go.language-server", + "executionModuleID": "dev.lithe.fixture.go.execution", + "testingModuleID": "dev.lithe.fixture.go.execution" + } + ] + } + ] +} diff --git a/shared/fixtures/plugins/official-v1.json b/shared/fixtures/plugins/official-v1.json new file mode 100644 index 00000000..3d0e31d9 --- /dev/null +++ b/shared/fixtures/plugins/official-v1.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "hostVersion": "0.3.0", + "pluginAPIVersion": 1, + "plugins": [] +}