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": []
+}