From dbc9677e712de59f5c345fa6233f78d824dbc1a0 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Fri, 14 Aug 2026 17:15:44 +0800 Subject: [PATCH 1/3] refactor: modularize macOS features and source layout Introduce module lifecycle and plugin boundaries, move feature ownership into dedicated Swift targets, add Go support as an official native plugin, and standardize macOS source organization. Preserve Windows isolation and shared Rust contracts with expanded verification coverage. --- Package.swift | 181 +++- Plugins/Official/GoSupport/Info.plist | 24 + Plugins/Official/GoSupport/plugin.json | 71 ++ README.md | 8 +- README.zh-CN.md | 8 +- .../{ => Composition}/AppServices.swift | 77 +- .../Composition/DebugFeatureGraph.swift | 54 + .../WorkspaceModuleResourceOwner.swift | 23 + .../{ => Features}/DocumentFeatureModel.swift | 0 .../Features/ExecutionFeatureAliases.swift | 7 + .../Features/JavaDebugFeatureModel.swift | 105 ++ .../{ => Features}/JavaFeatureModel.swift | 5 +- .../LSPControlCenterPresentation.swift | 0 .../LanguageToolingFeatureModel.swift | 25 +- .../Features/PluginManagement.swift | 36 + .../RuntimeSettingsFeatureModel.swift | 46 + .../Features/WorkspaceFeatureModel.swift | 72 ++ .../Lifecycle/FeatureModuleSleepError.swift | 11 + .../Lithe/Application/UIFeatureModels.swift | 358 ------- .../PluginLanguageProviderCatalogSource.swift | 78 ++ .../Core/Ports/DirectoryChangeSource.swift | 105 +- Sources/Lithe/Core/Ports/LanguagePacks.swift | 1 + .../Core/Ports/LanguageRunProviders.swift | 189 +--- .../Lithe/Core/Ports/LanguageTesting.swift | 9 + .../Lithe/Core/Ports/LanguageTooling.swift | 795 +-------------- Sources/Lithe/Core/Ports/ProcessRunner.swift | 50 +- .../Ports/RunConfigurationOperations.swift | 178 +--- .../Core/Ports/RunExecutableResolving.swift | 22 +- Sources/Lithe/Core/Ports/RuntimeLocator.swift | 44 +- Sources/Lithe/Core/Ports/SecureStore.swift | 45 - .../Lithe/Core/Ports/StreamingProcess.swift | 12 +- .../Core/Ports/WorkspaceFileOperations.swift | 15 +- .../Core/{ => Rust}/RustCoreBridge.swift | 9 +- .../Core/{ => Rust}/RustGitOperations.swift | 86 +- .../{ => Rust}/RustJavaMavenOperations.swift | 2 +- .../RustLanguageProviderCatalogSource.swift | 1 + .../RustLanguageServerRuntimeAdapter.swift | 149 +++ .../Rust/RustLocalHistoryOperations.swift | 30 + .../{ => Rust}/RustMarkdownRendering.swift | 0 .../{ => Rust}/RustWorkspaceOperations.swift | 112 +- .../Core/RustLocalHistoryOperations.swift | 83 -- Sources/Lithe/LitheApp.swift | 15 +- .../Lithe/Models/AppModel+FeatureState.swift | 183 ---- .../AppModel+AIConfiguration.swift | 19 +- .../{ => AppModel}/AppModel+Development.swift | 318 +++++- .../AppModel+EditorIntelligence.swift | 42 + .../AppModel/AppModel+ExecutionModules.swift | 80 ++ .../AppModel/AppModel+FeatureState.swift | 195 ++++ .../Models/AppModel/AppModel+GitModule.swift | 43 + .../AppModel+GitOperations.swift | 36 +- .../AppModel/AppModel+HistoryModule.swift | 48 + .../AppModel/AppModel+PluginManagement.swift | 56 + .../AppModel/AppModel+SearchModule.swift | 148 +++ .../{ => AppModel}/AppModel+Terminal.swift | 42 +- .../Models/{ => AppModel}/AppModel.swift | 880 ++++++++-------- .../AppModel/AppModelSupportTypes.swift | 50 + .../Bridges/FileVisibilityRules+App.swift | 28 + .../Models/Bridges/GitModuleBridges.swift | 20 + .../Models/{ => Diff}/DiffCollapse.swift | 1 + .../Lithe/Models/{ => Diff}/DiffPairing.swift | 0 .../Models/{ => Diff}/DiffSplitLayout.swift | 1 + .../BinaryFileViewerRegistry.swift | 0 .../Models/{ => Editor}/EditorDocument.swift | 0 .../{ => Editor}/MarkdownImageInsertion.swift | 0 .../{ => Editor}/MarkdownScrollPosition.swift | 0 Sources/Lithe/Models/FileNode.swift | 122 --- Sources/Lithe/Models/GitGraphModels.swift | 44 - .../Models/{ => Java}/JavaDebugModels.swift | 0 .../{ => Java}/JavaDiagnosticModels.swift | 0 .../{ => Java}/JavaNavigationModels.swift | 0 Sources/Lithe/Models/Java/JavaRunModels.swift | 15 + Sources/Lithe/Models/Java/MavenModels.swift | 9 + Sources/Lithe/Models/JavaRunModels.swift | 419 -------- Sources/Lithe/Models/MavenModels.swift | 114 --- .../Models/ProjectReplacementModels.swift | 32 - .../{ => Runtime}/ProjectRuntimeModels.swift | 0 .../Models/{ => Search}/SearchRelevance.swift | 1 + .../Models/{ => Settings}/AppSettings.swift | 2 + Sources/Lithe/Models/Workspace/FileNode.swift | 10 + .../ProjectSessionManager.swift | 0 .../{ => Workspace}/RecentProject.swift | 0 .../WorkspaceTextFilePolicy.swift | 0 .../AI/MacAIProviderCredentialResolver.swift | 1 + .../AI/MacClaudeConfigurationSource.swift | 1 + .../AI/MacCodexConfigurationSource.swift | 1 + .../MacOS/AI/MacURLSessionTransport.swift | 1 + .../MacProcessDebugAdapterTransport.swift | 57 ++ .../MacServerDebugAdapterTransport.swift | 1 + .../Platform/MacOS/MacServiceContainer.swift | 475 ++++++--- .../MacDatabaseRecoveryStore.swift | 1 + .../MacLanguageToolSettingsStore.swift | 24 + .../Plugins/MacLanguageExecutionHost.swift | 81 ++ .../MacOS/Plugins/MacNativePluginLoader.swift | 120 +++ .../MacOS/Plugins/MacPluginHostContext.swift | 15 + .../MacOS/Plugins/MacPluginManager.swift | 216 ++++ .../MacOS/Plugins/MacPluginPackageStore.swift | 505 +++++++++ .../MacPluginRuntimeRecoveryCoordinator.swift | 47 + .../Plugins/MacPluginStartupLoader.swift | 200 ++++ .../MacOS/Process/MacProcessRunner.swift | 20 + .../MacOS/Process/MacStreamingProcess.swift | 34 +- .../MacRunServiceAdapters.swift | 28 + .../RunServiceCompatibility.swift | 36 + .../MacOS/Storage/MacDatabaseAdapters.swift | 26 + .../MacOS/Storage/MacGitShelfStorage.swift | 13 + .../Storage/MacLocalHistoryAdapters.swift | 15 + .../Storage/MacModuleConfigurationStore.swift | 89 ++ .../MacOS/Terminal/MacTerminalTransport.swift | 1 + .../Debug/DebugAdapterRuntimeFactory.swift | 55 + .../DebugLaunchConfigurationResolver.swift | 1 + .../{ => Java}/JavaCodeVisionService.swift | 1 + .../{ => Java}/JavaDebugService.swift | 0 .../Lithe/Services/Java/JavaRunService.swift | 4 + .../{ => Java}/ProjectRuntimeService.swift | 25 +- .../{ => Java}/RunExecutableResolver.swift | 0 .../RunToolchainMetadataResolver.swift | 0 .../{ => Language}/LanguagePackRegistry.swift | 14 +- .../LanguageServerTextEditApplicator.swift | 0 .../Lithe/Services/LanguageTestService.swift | 187 ---- .../MarkdownImageImportService.swift | 0 .../{ => Monitoring}/MemoryUsageMonitor.swift | 25 + .../Services/Runtime/OutputTimestamper.swift | 3 + .../StdioLanguageProviderRuntime.swift | 217 ---- .../Lithe/Services/TerminalLinkResolver.swift | 79 -- Sources/Lithe/Services/TerminalSession.swift | 162 --- .../WorkbenchLayoutStore.swift | 0 .../{ => Workspace}/RecentProjectsStore.swift | 0 .../WorkspaceSessionStore.swift | 10 +- Sources/Lithe/Theme/DatabaseBrandIcon.swift | 1 + .../Lithe/Theme/JavaFileIconResolver.swift | 12 + Sources/Lithe/Views/{ => App}/RootView.swift | 1 - .../Lithe/Views/{ => App}/SettingsView.swift | 82 ++ .../Lithe/Views/{ => App}/WelcomeView.swift | 0 .../{ => Database}/DatabaseLocalization.swift | 1 + .../DatabaseSQLWorkspaceView.swift | 9 +- .../DatabaseSchemaDiffView.swift | 1 + .../{ => Database}/DatabaseSidebarView.swift | 1 + .../DatabaseSpecializedWorkspaceViews.swift | 1 + .../{ => Database}/DatabaseTableView.swift | 1 + .../Views/{ => Debug}/GenericDebugView.swift | 2 + .../Views/{ => Debug}/JavaDebugView.swift | 0 .../{ => Diff}/DiffCollapsedBandView.swift | 0 .../DiffHorizontalScrollSupport.swift | 0 .../Lithe/Views/{ => Diff}/DiffMapView.swift | 1 + .../Lithe/Views/{ => Diff}/DiffPaneView.swift | 1 + .../Views/{ => Diff}/DiffReviewView.swift | 1 + .../Views/{ => Diff}/DiffSplitPaneView.swift | 1 + .../Views/{ => Editor}/CodeEditorView.swift | 11 +- .../Views/{ => Editor}/EditorAreaView.swift | 5 +- .../{ => Editor}/EditorTabFlowLayout.swift | 0 .../Views/{ => Editor}/FindBarView.swift | 0 .../{ => Editor}/MarkdownPreviewView.swift | 0 .../{ => Git}/BranchComparisonView.swift | 1 + .../{ => Git}/BranchSwitcherPopover.swift | 1 + .../Views/{ => Git}/ChangesSidebarView.swift | 1 + .../{ => Git}/GitCommitDiffReviewView.swift | 1 + .../Lithe/Views/{ => Git}/GitGraphView.swift | 1 + .../Lithe/Views/{ => Git}/GitLogView.swift | 1 + .../{ => History}/LocalHistoryView.swift | 1 + .../ProjectLocalHistoryView.swift | 1 + .../{ => Language}/JavaProblemsView.swift | 0 .../{ => Language}/JavaReferencesView.swift | 0 .../{ => Language}/LSPControlCenterView.swift | 2 +- .../LanguageServerSetupView.swift | 2 + .../{ => Language}/LanguageTestsView.swift | 1 + .../JavaRunConfigurationEditorView.swift | 0 Sources/Lithe/Views/{ => Run}/MavenView.swift | 0 .../{ => Run}/RunConfigurationIcon.swift | 0 Sources/Lithe/Views/{ => Run}/RunView.swift | 8 +- .../{ => Search}/ProjectReplaceView.swift | 1 + .../{ => Search}/SearchEverywhereView.swift | 7 +- .../{ => Search}/SearchSidebarView.swift | 1 + .../Views/{ => Terminal}/TerminalView.swift | 3 +- .../{ => Workbench}/OutputTextView.swift | 0 .../{ => Workbench}/SplitHandleView.swift | 0 .../WorkbenchModuleUIComposition.swift | 152 +++ .../Workbench/WorkbenchModuleUIRegistry.swift | 121 +++ .../Views/{ => Workbench}/WorkbenchView.swift | 165 ++- .../{ => Workspace}/CloneRepositoryView.swift | 0 .../OpenProjectLocationDialog.swift | 0 .../{ => Workspace}/ProjectSidebarView.swift | 0 .../ProjectSwitcherPopover.swift | 0 .../Module/AIAssistanceModule.swift | 86 ++ .../CommitMessageGenerationService.swift | 42 +- .../ModuleLifecycleCoordinator.swift | 34 + .../Lifecycle/ModuleResourceScope.swift | 97 ++ .../Lifecycle/ModuleRuntime.swift | 545 ++++++++++ .../Plugins/PluginManifestValidator.swift | 187 ++++ .../Registry/ModuleRegistry.swift | 70 ++ .../AI/AIAssistancePorts.swift | 91 ++ .../AI/CommitMessageInput.swift | 36 + .../AI}/CommitMessageModels.swift | 198 ++-- .../Debug/DebugAdapterContracts.swift | 169 +++ .../Debug/DebugProviderDescriptor.swift | 50 + .../Execution/ExecutionContracts.swift | 282 +++++ .../Execution/LanguageRunContracts.swift | 182 ++++ .../Execution/MavenContracts.swift | 151 +++ .../Execution/RunConfigurationContracts.swift | 192 ++++ .../Execution/RunModels.swift | 303 ++++++ .../Execution/StreamingProcessContracts.swift | 70 ++ .../Language/BuiltinLanguageFeatureCore.swift | 21 + .../Language/LanguageExtensionContracts.swift | 316 ++++++ .../LanguageServerRuntimeContracts.swift | 142 +++ .../Language/LanguageToolRuntimePort.swift | 10 + .../LanguageToolServiceContracts.swift | 73 ++ .../Language/LanguageToolingContracts.swift | 609 +++++++++++ .../Language/ToolingJSONValue.swift | 73 ++ .../Modules/FeatureModuleHandle.swift | 133 +++ .../Output}/OutputTimestamper.swift | 8 +- .../Workspace/DirectoryChangeContracts.swift | 100 ++ .../Workspace}/FileVisibilityRules.swift | 20 +- .../Workspace/WorkspaceFeatureContracts.swift | 122 +++ .../Workspace/WorkspaceFileOperations.swift | 14 + .../Workspace/WorkspaceModels.swift | 45 + .../Application/DatabaseFeatureModel.swift | 275 ++--- .../Models}/DatabaseSQLSupport.swift | 103 +- .../Models}/DatabaseSchemaDiff.swift | 94 +- .../Module/DatabaseModule.swift | 86 ++ .../Ports/DatabasePorts.swift | 58 ++ .../Ports/DatabaseRecovery.swift | 114 +-- .../Services/DatabaseConnectionStore.swift | 100 +- .../Services/DatabaseDBXImportService.swift | 139 +-- .../Services/DatabaseSidecarService.swift | 582 +++++------ .../GenericDebugFeatureModel.swift | 89 +- .../LitheDebugModule/Module/DebugModule.swift | 78 ++ .../DebugAdapterProtocolSession.swift} | 106 +- .../Runtime/DebugAdapterSessionManager.swift | 154 +++ .../Application/ExecutionFeatureModels.swift | 227 +++++ .../Module/ExecutionFeatureGraph.swift | 70 ++ .../Module/ExecutionModule.swift | 76 ++ .../Services/LanguageTestService.swift | 393 +++++++ .../Services/MavenService.swift | 48 +- .../Services/RunService.swift} | 455 ++++++--- .../StandardLanguageTestProvider.swift | 32 +- .../Application/GitFeatureModel.swift | 267 ++--- .../Models/GitGraphModels.swift | 50 + .../Models/GitModels.swift | 445 ++++---- Sources/LitheGitModule/Module/GitModule.swift | 82 ++ Sources/LitheGitModule/Ports/GitPorts.swift | 22 + .../Services/GitGraphLayoutService.swift | 4 +- .../Services/GitService.swift | 86 +- .../Services/ShelveService.swift | 20 +- .../Capabilities/GoExecutionCapability.swift | 140 +++ .../GoLanguageServerCapability.swift | 38 + .../Module/GoExecutionModule.swift | 176 ++++ .../Module/GoLanguageServerModule.swift | 50 + .../Plugin/GoSupportPluginEntrypoint.swift | 21 + .../Support/GoSupportIdentifiers.swift | 1 + .../LanguageIntelligenceFeatureGraph.swift | 46 + .../Module/LanguageIntelligenceModule.swift | 104 ++ .../Providers}/LanguageFeatureProvider.swift | 106 +- .../Runtime/LanguageProviderRuntime.swift | 141 +++ .../Runtime/LanguageServerSession.swift} | 369 ++++--- .../Services/LanguageServerToolService.swift | 133 ++- .../LanguageToolingSessionManager.swift | 323 +++--- .../ProjectHistoryFeatureModel.swift | 152 +-- .../Models/LocalHistoryDiffModels.swift | 82 ++ .../Models/LocalHistoryModels.swift | 70 +- .../Module/LocalHistoryModule.swift | 65 ++ .../Ports/LocalHistoryPorts.swift | 52 + .../Services/LocalHistoryService.swift | 10 +- .../Catalog/BuiltInModuleCatalog.swift | 257 +++++ .../Lifecycle/ModuleContracts.swift | 314 ++++++ .../Lifecycle/ModuleTypes.swift | 304 ++++++ .../LitheModuleAPI/Plugins/PluginTypes.swift | 416 ++++++++ .../Application/SearchFeatureModel.swift | 117 ++- .../Models/ProjectReplacementModels.swift | 39 + .../Models/SearchModels.swift | 32 +- .../Models/SearchResults.swift | 81 ++ .../Module/SearchModule.swift | 66 ++ .../Ports/SearchOperations.swift | 49 + .../Application/TerminalFeatureModel.swift | 52 +- .../Module/TerminalModule.swift | 75 ++ .../Ports/TerminalTransport.swift | 14 +- .../Runtime/TerminalSession.swift | 90 ++ .../Services/TerminalLinkResolver.swift | 45 + .../Application/WorkspaceFeatureModel.swift | 178 ++-- .../Module/WorkspaceModule.swift | 58 ++ .../AIAssistanceModuleTests.swift | 85 ++ .../ModuleRuntimeTests.swift | 960 ++++++++++++++++++ .../LitheCoreVerifier/main.swift | 14 +- .../DatabaseModuleTests.swift | 120 +++ .../DebugModuleTests.swift | 244 +++++ .../ExecutionModuleTests.swift | 476 +++++++++ .../LitheGitGraphVerifier/main.swift | 10 +- .../LitheGitModuleTests/GitModuleTests.swift | 134 +++ .../GoSupportModuleTests.swift | 530 ++++++++++ .../LanguageIntelligenceModuleTests.swift | 256 +++++ .../LocalHistoryModuleTests.swift | 75 ++ Tests/LitheOfficialPluginVerifier/main.swift | 78 ++ .../SearchModuleTests.swift | 86 ++ .../TerminalModuleTests.swift | 59 ++ Tests/LitheTests/CommitMessageTests.swift | 2 + .../GitStatusObservationTests.swift | 2 + ...nguageExtensionProcessLifecycleTests.swift | 81 ++ .../LanguageFeatureProviderTests.swift | 1 + .../LanguageProviderCatalogSourceTests.swift | 62 ++ .../LanguageServerToolServiceTests.swift | 65 +- Tests/LitheTests/LitheCoreLogicTests.swift | 41 +- .../LitheTests/MemoryUsageMonitorTests.swift | 17 + .../LitheTests/NativePluginLoaderTests.swift | 160 +++ Tests/LitheTests/OutputTimestamperTests.swift | 1 + Tests/LitheTests/PluginManagerTests.swift | 212 ++++ .../LitheTests/PluginPackageStoreTests.swift | 456 +++++++++ .../RealGoplsIntegrationTests.swift | 4 +- .../RunConfigurationIntegrationTests.swift | 392 ++++--- .../WorkbenchModuleUIRegistryTests.swift | 96 ++ .../WorkspaceModuleTests.swift | 58 ++ docs/architecture/lsp-runtime-migration.md | 14 +- docs/architecture/mac-service-boundaries.md | 2 +- docs/architecture/module-runtime.md | 273 +++++ docs/architecture/repository-layout.md | 60 +- rust/lithe-core/src/lib.rs | 1 + rust/lithe-core/src/plugins/mod.rs | 290 ++++++ rust/lithe-core/src/tests/mod.rs | 1 + rust/lithe-core/src/tests/plugins.rs | 63 ++ scripts/build-official-plugins.sh | 99 ++ scripts/package-app.sh | 39 +- scripts/preview.sh | 6 +- scripts/verify-core.sh | 15 +- scripts/verify-git-graph.sh | 10 +- scripts/verify-module-boundaries.sh | 333 ++++++ scripts/verify-official-plugins.sh | 22 + scripts/verify-service-boundaries.sh | 9 +- scripts/verify-shared-contracts.sh | 81 ++ shared/contracts/application-boundary.md | 64 ++ shared/fixtures/modules/built-in-v1.json | 148 +++ .../fixtures/plugins/language-support-v1.json | 44 + shared/fixtures/plugins/official-v1.json | 6 + 328 files changed, 21997 insertions(+), 6845 deletions(-) create mode 100644 Plugins/Official/GoSupport/Info.plist create mode 100644 Plugins/Official/GoSupport/plugin.json rename Sources/Lithe/Application/{ => Composition}/AppServices.swift (61%) create mode 100644 Sources/Lithe/Application/Composition/DebugFeatureGraph.swift create mode 100644 Sources/Lithe/Application/Composition/WorkspaceModuleResourceOwner.swift rename Sources/Lithe/Application/{ => Features}/DocumentFeatureModel.swift (100%) create mode 100644 Sources/Lithe/Application/Features/ExecutionFeatureAliases.swift create mode 100644 Sources/Lithe/Application/Features/JavaDebugFeatureModel.swift rename Sources/Lithe/Application/{ => Features}/JavaFeatureModel.swift (98%) rename Sources/Lithe/Application/{ => Features}/LSPControlCenterPresentation.swift (100%) rename Sources/Lithe/Application/{ => Features}/LanguageToolingFeatureModel.swift (85%) create mode 100644 Sources/Lithe/Application/Features/PluginManagement.swift create mode 100644 Sources/Lithe/Application/Features/RuntimeSettingsFeatureModel.swift create mode 100644 Sources/Lithe/Application/Features/WorkspaceFeatureModel.swift create mode 100644 Sources/Lithe/Application/Lifecycle/FeatureModuleSleepError.swift delete mode 100644 Sources/Lithe/Application/UIFeatureModels.swift create mode 100644 Sources/Lithe/Core/Language/PluginLanguageProviderCatalogSource.swift create mode 100644 Sources/Lithe/Core/Ports/LanguageTesting.swift rename Sources/Lithe/Core/{ => Rust}/RustCoreBridge.swift (99%) rename Sources/Lithe/Core/{ => Rust}/RustGitOperations.swift (83%) rename Sources/Lithe/Core/{ => Rust}/RustJavaMavenOperations.swift (98%) rename Sources/Lithe/Core/{ => Rust}/RustLanguageProviderCatalogSource.swift (99%) create mode 100644 Sources/Lithe/Core/Rust/RustLanguageServerRuntimeAdapter.swift create mode 100644 Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift rename Sources/Lithe/Core/{ => Rust}/RustMarkdownRendering.swift (100%) rename Sources/Lithe/Core/{ => Rust}/RustWorkspaceOperations.swift (76%) delete mode 100644 Sources/Lithe/Core/RustLocalHistoryOperations.swift delete mode 100644 Sources/Lithe/Models/AppModel+FeatureState.swift rename Sources/Lithe/Models/{ => AppModel}/AppModel+AIConfiguration.swift (89%) rename Sources/Lithe/Models/{ => AppModel}/AppModel+Development.swift (73%) create mode 100644 Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift create mode 100644 Sources/Lithe/Models/AppModel/AppModel+ExecutionModules.swift create mode 100644 Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift create mode 100644 Sources/Lithe/Models/AppModel/AppModel+GitModule.swift rename Sources/Lithe/Models/{ => AppModel}/AppModel+GitOperations.swift (52%) create mode 100644 Sources/Lithe/Models/AppModel/AppModel+HistoryModule.swift create mode 100644 Sources/Lithe/Models/AppModel/AppModel+PluginManagement.swift create mode 100644 Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift rename Sources/Lithe/Models/{ => AppModel}/AppModel+Terminal.swift (65%) rename Sources/Lithe/Models/{ => AppModel}/AppModel.swift (67%) create mode 100644 Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift create mode 100644 Sources/Lithe/Models/Bridges/FileVisibilityRules+App.swift create mode 100644 Sources/Lithe/Models/Bridges/GitModuleBridges.swift rename Sources/Lithe/Models/{ => Diff}/DiffCollapse.swift (99%) rename Sources/Lithe/Models/{ => Diff}/DiffPairing.swift (100%) rename Sources/Lithe/Models/{ => Diff}/DiffSplitLayout.swift (99%) rename Sources/Lithe/Models/{ => Editor}/BinaryFileViewerRegistry.swift (100%) rename Sources/Lithe/Models/{ => Editor}/EditorDocument.swift (100%) rename Sources/Lithe/Models/{ => Editor}/MarkdownImageInsertion.swift (100%) rename Sources/Lithe/Models/{ => Editor}/MarkdownScrollPosition.swift (100%) delete mode 100644 Sources/Lithe/Models/FileNode.swift delete mode 100644 Sources/Lithe/Models/GitGraphModels.swift rename Sources/Lithe/Models/{ => Java}/JavaDebugModels.swift (100%) rename Sources/Lithe/Models/{ => Java}/JavaDiagnosticModels.swift (100%) rename Sources/Lithe/Models/{ => Java}/JavaNavigationModels.swift (100%) create mode 100644 Sources/Lithe/Models/Java/JavaRunModels.swift create mode 100644 Sources/Lithe/Models/Java/MavenModels.swift delete mode 100644 Sources/Lithe/Models/JavaRunModels.swift delete mode 100644 Sources/Lithe/Models/MavenModels.swift delete mode 100644 Sources/Lithe/Models/ProjectReplacementModels.swift rename Sources/Lithe/Models/{ => Runtime}/ProjectRuntimeModels.swift (100%) rename Sources/Lithe/Models/{ => Search}/SearchRelevance.swift (99%) rename Sources/Lithe/Models/{ => Settings}/AppSettings.swift (99%) create mode 100644 Sources/Lithe/Models/Workspace/FileNode.swift rename Sources/Lithe/Models/{ => Workspace}/ProjectSessionManager.swift (100%) rename Sources/Lithe/Models/{ => Workspace}/RecentProject.swift (100%) rename Sources/Lithe/Models/{ => Workspace}/WorkspaceTextFilePolicy.swift (100%) create mode 100644 Sources/Lithe/Platform/MacOS/Debug/MacProcessDebugAdapterTransport.swift create mode 100644 Sources/Lithe/Platform/MacOS/Persistence/MacLanguageToolSettingsStore.swift create mode 100644 Sources/Lithe/Platform/MacOS/Plugins/MacLanguageExecutionHost.swift create mode 100644 Sources/Lithe/Platform/MacOS/Plugins/MacNativePluginLoader.swift create mode 100644 Sources/Lithe/Platform/MacOS/Plugins/MacPluginHostContext.swift create mode 100644 Sources/Lithe/Platform/MacOS/Plugins/MacPluginManager.swift create mode 100644 Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift create mode 100644 Sources/Lithe/Platform/MacOS/Plugins/MacPluginRuntimeRecoveryCoordinator.swift create mode 100644 Sources/Lithe/Platform/MacOS/Plugins/MacPluginStartupLoader.swift create mode 100644 Sources/Lithe/Platform/MacOS/RunConfiguration/MacRunServiceAdapters.swift create mode 100644 Sources/Lithe/Platform/MacOS/RunConfiguration/RunServiceCompatibility.swift create mode 100644 Sources/Lithe/Platform/MacOS/Storage/MacDatabaseAdapters.swift create mode 100644 Sources/Lithe/Platform/MacOS/Storage/MacGitShelfStorage.swift create mode 100644 Sources/Lithe/Platform/MacOS/Storage/MacLocalHistoryAdapters.swift create mode 100644 Sources/Lithe/Platform/MacOS/Storage/MacModuleConfigurationStore.swift create mode 100644 Sources/Lithe/Services/Debug/DebugAdapterRuntimeFactory.swift rename Sources/Lithe/Services/{ => Debug}/DebugLaunchConfigurationResolver.swift (99%) rename Sources/Lithe/Services/{ => Java}/JavaCodeVisionService.swift (98%) rename Sources/Lithe/Services/{ => Java}/JavaDebugService.swift (100%) create mode 100644 Sources/Lithe/Services/Java/JavaRunService.swift rename Sources/Lithe/Services/{ => Java}/ProjectRuntimeService.swift (94%) rename Sources/Lithe/Services/{ => Java}/RunExecutableResolver.swift (100%) rename Sources/Lithe/Services/{ => Java}/RunToolchainMetadataResolver.swift (100%) rename Sources/Lithe/Services/{ => Language}/LanguagePackRegistry.swift (88%) rename Sources/Lithe/Services/{ => Language}/LanguageServerTextEditApplicator.swift (100%) delete mode 100644 Sources/Lithe/Services/LanguageTestService.swift rename Sources/Lithe/Services/{ => Markdown}/MarkdownImageImportService.swift (100%) rename Sources/Lithe/Services/{ => Monitoring}/MemoryUsageMonitor.swift (91%) create mode 100644 Sources/Lithe/Services/Runtime/OutputTimestamper.swift delete mode 100644 Sources/Lithe/Services/StdioLanguageProviderRuntime.swift delete mode 100644 Sources/Lithe/Services/TerminalLinkResolver.swift delete mode 100644 Sources/Lithe/Services/TerminalSession.swift rename Sources/Lithe/Services/{ => Workbench}/WorkbenchLayoutStore.swift (100%) rename Sources/Lithe/Services/{ => Workspace}/RecentProjectsStore.swift (100%) rename Sources/Lithe/Services/{ => Workspace}/WorkspaceSessionStore.swift (82%) create mode 100644 Sources/Lithe/Theme/JavaFileIconResolver.swift rename Sources/Lithe/Views/{ => App}/RootView.swift (99%) rename Sources/Lithe/Views/{ => App}/SettingsView.swift (91%) rename Sources/Lithe/Views/{ => App}/WelcomeView.swift (100%) rename Sources/Lithe/Views/{ => Database}/DatabaseLocalization.swift (99%) rename Sources/Lithe/Views/{ => Database}/DatabaseSQLWorkspaceView.swift (99%) rename Sources/Lithe/Views/{ => Database}/DatabaseSchemaDiffView.swift (99%) rename Sources/Lithe/Views/{ => Database}/DatabaseSidebarView.swift (99%) rename Sources/Lithe/Views/{ => Database}/DatabaseSpecializedWorkspaceViews.swift (99%) rename Sources/Lithe/Views/{ => Database}/DatabaseTableView.swift (99%) rename Sources/Lithe/Views/{ => Debug}/GenericDebugView.swift (99%) rename Sources/Lithe/Views/{ => Debug}/JavaDebugView.swift (100%) rename Sources/Lithe/Views/{ => Diff}/DiffCollapsedBandView.swift (100%) rename Sources/Lithe/Views/{ => Diff}/DiffHorizontalScrollSupport.swift (100%) rename Sources/Lithe/Views/{ => Diff}/DiffMapView.swift (99%) rename Sources/Lithe/Views/{ => Diff}/DiffPaneView.swift (99%) rename Sources/Lithe/Views/{ => Diff}/DiffReviewView.swift (99%) rename Sources/Lithe/Views/{ => Diff}/DiffSplitPaneView.swift (99%) rename Sources/Lithe/Views/{ => Editor}/CodeEditorView.swift (99%) rename Sources/Lithe/Views/{ => Editor}/EditorAreaView.swift (99%) rename Sources/Lithe/Views/{ => Editor}/EditorTabFlowLayout.swift (100%) rename Sources/Lithe/Views/{ => Editor}/FindBarView.swift (100%) rename Sources/Lithe/Views/{ => Editor}/MarkdownPreviewView.swift (100%) rename Sources/Lithe/Views/{ => Git}/BranchComparisonView.swift (99%) rename Sources/Lithe/Views/{ => Git}/BranchSwitcherPopover.swift (99%) rename Sources/Lithe/Views/{ => Git}/ChangesSidebarView.swift (99%) rename Sources/Lithe/Views/{ => Git}/GitCommitDiffReviewView.swift (99%) rename Sources/Lithe/Views/{ => Git}/GitGraphView.swift (99%) rename Sources/Lithe/Views/{ => Git}/GitLogView.swift (99%) rename Sources/Lithe/Views/{ => History}/LocalHistoryView.swift (99%) rename Sources/Lithe/Views/{ => History}/ProjectLocalHistoryView.swift (99%) rename Sources/Lithe/Views/{ => Language}/JavaProblemsView.swift (100%) rename Sources/Lithe/Views/{ => Language}/JavaReferencesView.swift (100%) rename Sources/Lithe/Views/{ => Language}/LSPControlCenterView.swift (99%) rename Sources/Lithe/Views/{ => Language}/LanguageServerSetupView.swift (99%) rename Sources/Lithe/Views/{ => Language}/LanguageTestsView.swift (99%) rename Sources/Lithe/Views/{ => Run}/JavaRunConfigurationEditorView.swift (100%) rename Sources/Lithe/Views/{ => Run}/MavenView.swift (100%) rename Sources/Lithe/Views/{ => Run}/RunConfigurationIcon.swift (100%) rename Sources/Lithe/Views/{ => Run}/RunView.swift (99%) rename Sources/Lithe/Views/{ => Search}/ProjectReplaceView.swift (99%) rename Sources/Lithe/Views/{ => Search}/SearchEverywhereView.swift (98%) rename Sources/Lithe/Views/{ => Search}/SearchSidebarView.swift (99%) rename Sources/Lithe/Views/{ => Terminal}/TerminalView.swift (98%) rename Sources/Lithe/Views/{ => Workbench}/OutputTextView.swift (100%) rename Sources/Lithe/Views/{ => Workbench}/SplitHandleView.swift (100%) create mode 100644 Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift create mode 100644 Sources/Lithe/Views/Workbench/WorkbenchModuleUIRegistry.swift rename Sources/Lithe/Views/{ => Workbench}/WorkbenchView.swift (90%) rename Sources/Lithe/Views/{ => Workspace}/CloneRepositoryView.swift (100%) rename Sources/Lithe/Views/{ => Workspace}/OpenProjectLocationDialog.swift (100%) rename Sources/Lithe/Views/{ => Workspace}/ProjectSidebarView.swift (100%) rename Sources/Lithe/Views/{ => Workspace}/ProjectSwitcherPopover.swift (100%) create mode 100644 Sources/LitheAIAssistanceModule/Module/AIAssistanceModule.swift rename Sources/{Lithe => LitheAIAssistanceModule}/Services/CommitMessageGenerationService.swift (92%) create mode 100644 Sources/LitheApplicationKernel/Lifecycle/ModuleLifecycleCoordinator.swift create mode 100644 Sources/LitheApplicationKernel/Lifecycle/ModuleResourceScope.swift create mode 100644 Sources/LitheApplicationKernel/Lifecycle/ModuleRuntime.swift create mode 100644 Sources/LitheApplicationKernel/Plugins/PluginManifestValidator.swift create mode 100644 Sources/LitheApplicationKernel/Registry/ModuleRegistry.swift create mode 100644 Sources/LitheCoreContracts/AI/AIAssistancePorts.swift create mode 100644 Sources/LitheCoreContracts/AI/CommitMessageInput.swift rename Sources/{Lithe/Models => LitheCoreContracts/AI}/CommitMessageModels.swift (70%) create mode 100644 Sources/LitheCoreContracts/Debug/DebugAdapterContracts.swift create mode 100644 Sources/LitheCoreContracts/Debug/DebugProviderDescriptor.swift create mode 100644 Sources/LitheCoreContracts/Execution/ExecutionContracts.swift create mode 100644 Sources/LitheCoreContracts/Execution/LanguageRunContracts.swift create mode 100644 Sources/LitheCoreContracts/Execution/MavenContracts.swift create mode 100644 Sources/LitheCoreContracts/Execution/RunConfigurationContracts.swift create mode 100644 Sources/LitheCoreContracts/Execution/RunModels.swift create mode 100644 Sources/LitheCoreContracts/Execution/StreamingProcessContracts.swift create mode 100644 Sources/LitheCoreContracts/Language/BuiltinLanguageFeatureCore.swift create mode 100644 Sources/LitheCoreContracts/Language/LanguageExtensionContracts.swift create mode 100644 Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift create mode 100644 Sources/LitheCoreContracts/Language/LanguageToolRuntimePort.swift create mode 100644 Sources/LitheCoreContracts/Language/LanguageToolServiceContracts.swift create mode 100644 Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift create mode 100644 Sources/LitheCoreContracts/Language/ToolingJSONValue.swift create mode 100644 Sources/LitheCoreContracts/Modules/FeatureModuleHandle.swift rename Sources/{Lithe/Services => LitheCoreContracts/Output}/OutputTimestamper.swift (90%) create mode 100644 Sources/LitheCoreContracts/Workspace/DirectoryChangeContracts.swift rename Sources/{Lithe/Models => LitheCoreContracts/Workspace}/FileVisibilityRules.swift (88%) create mode 100644 Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift create mode 100644 Sources/LitheCoreContracts/Workspace/WorkspaceFileOperations.swift create mode 100644 Sources/LitheCoreContracts/Workspace/WorkspaceModels.swift rename Sources/{Lithe => LitheDatabaseModule}/Application/DatabaseFeatureModel.swift (90%) rename Sources/{Lithe/Application => LitheDatabaseModule/Models}/DatabaseSQLSupport.swift (86%) rename Sources/{Lithe/Application => LitheDatabaseModule/Models}/DatabaseSchemaDiff.swift (88%) create mode 100644 Sources/LitheDatabaseModule/Module/DatabaseModule.swift create mode 100644 Sources/LitheDatabaseModule/Ports/DatabasePorts.swift rename Sources/{Lithe/Core => LitheDatabaseModule}/Ports/DatabaseRecovery.swift (53%) rename Sources/{Lithe => LitheDatabaseModule}/Services/DatabaseConnectionStore.swift (63%) rename Sources/{Lithe => LitheDatabaseModule}/Services/DatabaseDBXImportService.swift (80%) rename Sources/{Lithe => LitheDatabaseModule}/Services/DatabaseSidecarService.swift (63%) rename Sources/{Lithe => LitheDebugModule}/Application/GenericDebugFeatureModel.swift (75%) create mode 100644 Sources/LitheDebugModule/Module/DebugModule.swift rename Sources/{Lithe/Services/StdioDebugAdapterSession.swift => LitheDebugModule/Runtime/DebugAdapterProtocolSession.swift} (88%) create mode 100644 Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift create mode 100644 Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift create mode 100644 Sources/LitheExecutionModule/Module/ExecutionFeatureGraph.swift create mode 100644 Sources/LitheExecutionModule/Module/ExecutionModule.swift create mode 100644 Sources/LitheExecutionModule/Services/LanguageTestService.swift rename Sources/{Lithe => LitheExecutionModule}/Services/MavenService.swift (79%) rename Sources/{Lithe/Services/JavaRunService.swift => LitheExecutionModule/Services/RunService.swift} (70%) rename Sources/{Lithe => LitheExecutionModule}/Services/StandardLanguageTestProvider.swift (91%) rename Sources/{Lithe => LitheGitModule}/Application/GitFeatureModel.swift (87%) create mode 100644 Sources/LitheGitModule/Models/GitGraphModels.swift rename Sources/{Lithe => LitheGitModule}/Models/GitModels.swift (58%) create mode 100644 Sources/LitheGitModule/Module/GitModule.swift create mode 100644 Sources/LitheGitModule/Ports/GitPorts.swift rename Sources/{Lithe => LitheGitModule}/Services/GitGraphLayoutService.swift (98%) rename Sources/{Lithe => LitheGitModule}/Services/GitService.swift (89%) rename Sources/{Lithe => LitheGitModule}/Services/ShelveService.swift (92%) create mode 100644 Sources/LitheGoSupportModule/Capabilities/GoExecutionCapability.swift create mode 100644 Sources/LitheGoSupportModule/Capabilities/GoLanguageServerCapability.swift create mode 100644 Sources/LitheGoSupportModule/Module/GoExecutionModule.swift create mode 100644 Sources/LitheGoSupportModule/Module/GoLanguageServerModule.swift create mode 100644 Sources/LitheGoSupportModule/Plugin/GoSupportPluginEntrypoint.swift create mode 100644 Sources/LitheGoSupportModule/Support/GoSupportIdentifiers.swift create mode 100644 Sources/LitheLanguageIntelligenceModule/Module/LanguageIntelligenceFeatureGraph.swift create mode 100644 Sources/LitheLanguageIntelligenceModule/Module/LanguageIntelligenceModule.swift rename Sources/{Lithe/Services => LitheLanguageIntelligenceModule/Providers}/LanguageFeatureProvider.swift (81%) create mode 100644 Sources/LitheLanguageIntelligenceModule/Runtime/LanguageProviderRuntime.swift rename Sources/{Lithe/Services/StdioLanguageServerSession.swift => LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift} (65%) rename Sources/{Lithe => LitheLanguageIntelligenceModule}/Services/LanguageServerToolService.swift (73%) rename Sources/{Lithe => LitheLanguageIntelligenceModule}/Services/LanguageToolingSessionManager.swift (78%) rename Sources/{Lithe => LitheLocalHistoryModule}/Application/ProjectHistoryFeatureModel.swift (72%) create mode 100644 Sources/LitheLocalHistoryModule/Models/LocalHistoryDiffModels.swift rename Sources/{Lithe => LitheLocalHistoryModule}/Models/LocalHistoryModels.swift (70%) create mode 100644 Sources/LitheLocalHistoryModule/Module/LocalHistoryModule.swift create mode 100644 Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift rename Sources/{Lithe => LitheLocalHistoryModule}/Services/LocalHistoryService.swift (94%) create mode 100644 Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift create mode 100644 Sources/LitheModuleAPI/Lifecycle/ModuleContracts.swift create mode 100644 Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift create mode 100644 Sources/LitheModuleAPI/Plugins/PluginTypes.swift rename Sources/{Lithe => LitheSearchModule}/Application/SearchFeatureModel.swift (61%) create mode 100644 Sources/LitheSearchModule/Models/ProjectReplacementModels.swift rename Sources/{Lithe => LitheSearchModule}/Models/SearchModels.swift (63%) create mode 100644 Sources/LitheSearchModule/Models/SearchResults.swift create mode 100644 Sources/LitheSearchModule/Module/SearchModule.swift create mode 100644 Sources/LitheSearchModule/Ports/SearchOperations.swift rename Sources/{Lithe => LitheTerminalModule}/Application/TerminalFeatureModel.swift (58%) create mode 100644 Sources/LitheTerminalModule/Module/TerminalModule.swift rename Sources/{Lithe/Core => LitheTerminalModule}/Ports/TerminalTransport.swift (56%) create mode 100644 Sources/LitheTerminalModule/Runtime/TerminalSession.swift create mode 100644 Sources/LitheTerminalModule/Services/TerminalLinkResolver.swift rename Sources/{Lithe => LitheWorkspaceModule}/Application/WorkspaceFeatureModel.swift (86%) create mode 100644 Sources/LitheWorkspaceModule/Module/WorkspaceModule.swift create mode 100644 Tests/LitheAIAssistanceModuleTests/AIAssistanceModuleTests.swift create mode 100644 Tests/LitheApplicationKernelTests/ModuleRuntimeTests.swift rename scripts/CoreVerification.swift => Tests/LitheCoreVerifier/main.swift (95%) create mode 100644 Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift create mode 100644 Tests/LitheDebugModuleTests/DebugModuleTests.swift create mode 100644 Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift rename scripts/GitGraphVerification.swift => Tests/LitheGitGraphVerifier/main.swift (93%) create mode 100644 Tests/LitheGitModuleTests/GitModuleTests.swift create mode 100644 Tests/LitheGoSupportModuleTests/GoSupportModuleTests.swift create mode 100644 Tests/LitheLanguageIntelligenceModuleTests/LanguageIntelligenceModuleTests.swift create mode 100644 Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift create mode 100644 Tests/LitheOfficialPluginVerifier/main.swift create mode 100644 Tests/LitheSearchModuleTests/SearchModuleTests.swift create mode 100644 Tests/LitheTerminalModuleTests/TerminalModuleTests.swift create mode 100644 Tests/LitheTests/LanguageExtensionProcessLifecycleTests.swift create mode 100644 Tests/LitheTests/NativePluginLoaderTests.swift create mode 100644 Tests/LitheTests/PluginManagerTests.swift create mode 100644 Tests/LitheTests/PluginPackageStoreTests.swift create mode 100644 Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift create mode 100644 Tests/LitheWorkspaceModuleTests/WorkspaceModuleTests.swift create mode 100644 docs/architecture/module-runtime.md create mode 100644 rust/lithe-core/src/plugins/mod.rs create mode 100644 rust/lithe-core/src/tests/plugins.rs create mode 100755 scripts/build-official-plugins.sh create mode 100755 scripts/verify-module-boundaries.sh create mode 100755 scripts/verify-official-plugins.sh create mode 100644 shared/fixtures/modules/built-in-v1.json create mode 100644 shared/fixtures/plugins/language-support-v1.json create mode 100644 shared/fixtures/plugins/official-v1.json 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 61% rename from Sources/Lithe/Application/SearchFeatureModel.swift rename to Sources/LitheSearchModule/Application/SearchFeatureModel.swift index a5844309..f0bb79eb 100644 --- a/Sources/Lithe/Application/SearchFeatureModel.swift +++ b/Sources/LitheSearchModule/Application/SearchFeatureModel.swift @@ -1,32 +1,39 @@ 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? - 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() { + indexTask?.cancel() + indexTask = nil searchResults = [] isSearching = false searchEverywhereResults = SearchEverywhereResults(fileMatches: [], contentMatches: []) @@ -35,16 +42,58 @@ 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 + indexTask = Task.detached(priority: .utility) { [weak self] in + await previousTask?.value + guard !Task.isCancelled else { return } + operation(operations) + await MainActor.run { 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 +120,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 +156,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 +186,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 +198,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 +208,6 @@ final class SearchFeatureModel: ObservableObject { } changedFiles += 1 } catch { - if let document { - document.text = currentText - } failedFiles.append(target.relativePath) } } @@ -177,14 +218,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..23fed552 --- /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 let 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..20f2c11f --- /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 let 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..59637cf4 --- /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 let 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..a078e356 --- /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 let 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..abecbbd4 --- /dev/null +++ b/Tests/LitheSearchModuleTests/SearchModuleTests.swift @@ -0,0 +1,86 @@ +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 let 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) + } + + 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 } +} 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": [] +} From f3f98f6c3b97f8c1d2d36bf5302436ea7c48153f Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Fri, 14 Aug 2026 17:31:46 +0800 Subject: [PATCH 2/3] fix(macOS): isolate search index task cleanup --- .../Application/SearchFeatureModel.swift | 16 +++- .../SearchModuleTests.swift | 83 +++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/Sources/LitheSearchModule/Application/SearchFeatureModel.swift b/Sources/LitheSearchModule/Application/SearchFeatureModel.swift index f0bb79eb..6bcd8db0 100644 --- a/Sources/LitheSearchModule/Application/SearchFeatureModel.swift +++ b/Sources/LitheSearchModule/Application/SearchFeatureModel.swift @@ -22,6 +22,7 @@ public final class SearchFeatureModel: ObservableObject { private let operations: any SearchOperations private var indexTask: Task? + private var indexTaskGeneration = 0 public var hasActiveModuleWork: Bool { isSearching || isSearchingEverywhere || isLoadingProjectReplacement || indexTask != nil @@ -32,6 +33,7 @@ public final class SearchFeatureModel: ObservableObject { } public func reset() { + indexTaskGeneration += 1 indexTask?.cancel() indexTask = nil searchResults = [] @@ -76,11 +78,21 @@ public final class SearchFeatureModel: ObservableObject { let previousTask = indexTask previousTask?.cancel() let operations = self.operations - indexTask = Task.detached(priority: .utility) { [weak self] in + indexTaskGeneration += 1 + let generation = indexTaskGeneration + let worker = Task.detached(priority: .utility) { await previousTask?.value guard !Task.isCancelled else { return } operation(operations) - await MainActor.run { self?.indexTask = nil } + } + indexTask = Task { [weak self] in + await withTaskCancellationHandler { + await worker.value + } onCancel: { + worker.cancel() + } + guard let self, self.indexTaskGeneration == generation else { return } + self.indexTask = nil } } diff --git a/Tests/LitheSearchModuleTests/SearchModuleTests.swift b/Tests/LitheSearchModuleTests/SearchModuleTests.swift index abecbbd4..bd032b63 100644 --- a/Tests/LitheSearchModuleTests/SearchModuleTests.swift +++ b/Tests/LitheSearchModuleTests/SearchModuleTests.swift @@ -54,6 +54,44 @@ struct SearchModuleTests { #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( @@ -84,3 +122,48 @@ private struct TestSearchOperations: SearchOperations { 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() + } +} From 56b1a881ba85e163594187ef4fa2ece8d9d32e8b Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Fri, 14 Aug 2026 17:36:24 +0800 Subject: [PATCH 3/3] test(macOS): support Swift 6.2 weak references --- .../LitheAIAssistanceModuleTests/AIAssistanceModuleTests.swift | 2 +- Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift | 2 +- Tests/LitheGitModuleTests/GitModuleTests.swift | 2 +- .../LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift | 2 +- Tests/LitheSearchModuleTests/SearchModuleTests.swift | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/LitheAIAssistanceModuleTests/AIAssistanceModuleTests.swift b/Tests/LitheAIAssistanceModuleTests/AIAssistanceModuleTests.swift index 23fed552..4a282ec4 100644 --- a/Tests/LitheAIAssistanceModuleTests/AIAssistanceModuleTests.swift +++ b/Tests/LitheAIAssistanceModuleTests/AIAssistanceModuleTests.swift @@ -49,7 +49,7 @@ struct AIAssistanceModuleTests { var first: AIAssistanceCapability? = try #require( try await runtime.activateCapability(.aiCommitMessage) as? AIAssistanceCapability ) - weak let releasedCapability = first + weak var releasedCapability = first #expect(recorder.moduleFactoryCalls == 1) #expect(recorder.transportFactoryCalls == 1) first = nil diff --git a/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift b/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift index 20f2c11f..1ed186cf 100644 --- a/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift +++ b/Tests/LitheDatabaseModuleTests/DatabaseModuleTests.swift @@ -38,7 +38,7 @@ struct DatabaseModuleTests { var first: DatabaseFeatureModel? = try #require( (try await runtime.activateCapability(.databaseWorkspace) as? DatabaseModuleCapability)?.feature ) - weak let released = first + weak var released = first first = nil try await runtime.sleep(.database) diff --git a/Tests/LitheGitModuleTests/GitModuleTests.swift b/Tests/LitheGitModuleTests/GitModuleTests.swift index 59637cf4..aa714425 100644 --- a/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -37,7 +37,7 @@ struct GitModuleTests { var first: GitFeatureModel? = try #require( (try await runtime.activateCapability(.gitWorkspace) as? GitModuleCapability)?.feature ) - weak let released = first + weak var released = first first = nil try await runtime.sleep(.git) diff --git a/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift b/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift index a078e356..7c579879 100644 --- a/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift +++ b/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift @@ -31,7 +31,7 @@ struct LocalHistoryModuleTests { return makeModule() }) var first: ProjectHistoryFeatureModel? = try #require((try await runtime.activateCapability(.historyWorkspace) as? HistoryModuleCapability)?.feature) - weak let released = first + weak var released = first first = nil try await runtime.sleep(.localHistory) #expect(released == nil) diff --git a/Tests/LitheSearchModuleTests/SearchModuleTests.swift b/Tests/LitheSearchModuleTests/SearchModuleTests.swift index bd032b63..86134748 100644 --- a/Tests/LitheSearchModuleTests/SearchModuleTests.swift +++ b/Tests/LitheSearchModuleTests/SearchModuleTests.swift @@ -39,7 +39,7 @@ struct SearchModuleTests { var first: SearchFeatureModel? = try #require( (try await runtime.activateCapability(.searchWorkspace) as? SearchModuleCapability)?.feature ) - weak let releasedFeature = first + weak var releasedFeature = first first = nil try await runtime.sleep(.search)