diff --git a/Package.swift b/Package.swift index ad05d7105..4d4f84d71 100644 --- a/Package.swift +++ b/Package.swift @@ -367,6 +367,7 @@ let package = Package( name: "SourceKitLSPTests", dependencies: [ "BuildServerProtocol", + "CAtomics", "LSPLogging", "LSPTestSupport", "LanguageServerProtocol", diff --git a/Sources/Diagnose/DiagnoseCommand.swift b/Sources/Diagnose/DiagnoseCommand.swift index acc528227..d58f3b289 100644 --- a/Sources/Diagnose/DiagnoseCommand.swift +++ b/Sources/Diagnose/DiagnoseCommand.swift @@ -352,6 +352,15 @@ public struct DiagnoseCommand: AsyncParsableCommand { """ ) + #if os(macOS) + // Reveal the bundle in Finder on macOS + do { + let process = try Process.launch(arguments: ["open", "-R", bundlePath.path], workingDirectory: nil) + try await process.waitUntilExitSendingSigIntOnTaskCancellation() + } catch { + // If revealing the bundle in Finder should fail, we don't care. We still printed the bundle path to stdout. + } + #endif } @MainActor diff --git a/Sources/SKCore/BuildServerBuildSystem.swift b/Sources/SKCore/BuildServerBuildSystem.swift index e2da82695..2ad9af434 100644 --- a/Sources/SKCore/BuildServerBuildSystem.swift +++ b/Sources/SKCore/BuildServerBuildSystem.swift @@ -279,7 +279,7 @@ extension BuildServerBuildSystem: BuildSystem { return [ConfiguredTarget(targetID: "dummy", runDestinationID: "dummy")] } - public func generateBuildGraph() {} + public func generateBuildGraph(allowFileSystemWrites: Bool) {} public func topologicalSort(of targets: [ConfiguredTarget]) async -> [ConfiguredTarget]? { return nil diff --git a/Sources/SKCore/BuildSystem.swift b/Sources/SKCore/BuildSystem.swift index bc4848e82..718a03bc7 100644 --- a/Sources/SKCore/BuildSystem.swift +++ b/Sources/SKCore/BuildSystem.swift @@ -135,9 +135,14 @@ public protocol BuildSystem: AnyObject, Sendable { /// Return the list of targets and run destinations that the given document can be built for. func configuredTargets(for document: DocumentURI) async -> [ConfiguredTarget] - /// Re-generate the build graph including all the tasks that are necessary for building the entire build graph, like - /// resolving package versions. - func generateBuildGraph() async throws + /// Re-generate the build graph. + /// + /// If `allowFileSystemWrites` is `true`, this should include all the tasks that are necessary for building the entire + /// build graph, like resolving package versions. + /// + /// If `allowFileSystemWrites` is `false`, no files must be written to disk. This mode is used to determine whether + /// the build system can handle a source file, and decide whether a workspace should be opened with this build system + func generateBuildGraph(allowFileSystemWrites: Bool) async throws /// Sort the targets so that low-level targets occur before high-level targets. /// diff --git a/Sources/SKCore/BuildSystemManager.swift b/Sources/SKCore/BuildSystemManager.swift index acba3e7f0..6b3b8be91 100644 --- a/Sources/SKCore/BuildSystemManager.swift +++ b/Sources/SKCore/BuildSystemManager.swift @@ -219,8 +219,8 @@ extension BuildSystemManager { return settings } - public func generateBuildGraph() async throws { - try await self.buildSystem?.generateBuildGraph() + public func generateBuildGraph(allowFileSystemWrites: Bool) async throws { + try await self.buildSystem?.generateBuildGraph(allowFileSystemWrites: allowFileSystemWrites) } public func topologicalSort(of targets: [ConfiguredTarget]) async throws -> [ConfiguredTarget]? { diff --git a/Sources/SKCore/CompilationDatabaseBuildSystem.swift b/Sources/SKCore/CompilationDatabaseBuildSystem.swift index ac5825f63..3d84a6beb 100644 --- a/Sources/SKCore/CompilationDatabaseBuildSystem.swift +++ b/Sources/SKCore/CompilationDatabaseBuildSystem.swift @@ -132,7 +132,7 @@ extension CompilationDatabaseBuildSystem: BuildSystem { throw PrepareNotSupportedError() } - public func generateBuildGraph() {} + public func generateBuildGraph(allowFileSystemWrites: Bool) {} public func topologicalSort(of targets: [ConfiguredTarget]) -> [ConfiguredTarget]? { return nil diff --git a/Sources/SKCore/TaskScheduler.swift b/Sources/SKCore/TaskScheduler.swift index fe3637d5a..753344976 100644 --- a/Sources/SKCore/TaskScheduler.swift +++ b/Sources/SKCore/TaskScheduler.swift @@ -13,6 +13,7 @@ import CAtomics import Foundation import LSPLogging +import SKSupport /// See comment on ``TaskDescriptionProtocol/dependencies(to:taskPriority:)`` public enum TaskDependencyAction { @@ -20,6 +21,8 @@ public enum TaskDependencyAction { case cancelAndRescheduleDependency(TaskDescription) } +private let taskSchedulerSubsystem = "org.swift.sourcekit-lsp.task-scheduler" + public protocol TaskDescriptionProtocol: Identifiable, Sendable, CustomLogStringConvertible { /// Execute the task. /// @@ -123,10 +126,6 @@ public actor QueuedTask { /// Every time `execute` gets called, a new task is placed in this continuation. See comment on `executionTask`. private let executionTaskCreatedContinuation: AsyncStream>.Continuation - /// Placing a new value in this continuation will cause `resultTask` to query its priority and set - /// `QueuedTask.priority`. - private let updatePriorityContinuation: AsyncStream.Continuation - nonisolated(unsafe) private var _priority: AtomicUInt8 /// The latest known priority of the task. @@ -183,20 +182,14 @@ public actor QueuedTask { private let executionStateChangedCallback: (@Sendable (QueuedTask, TaskExecutionState) async -> Void)? init( - priority: TaskPriority? = nil, + priority: TaskPriority, description: TaskDescription, executionStateChangedCallback: (@Sendable (QueuedTask, TaskExecutionState) async -> Void)? ) async { - self._priority = .init(initialValue: priority?.rawValue ?? Task.currentPriority.rawValue) + self._priority = AtomicUInt8(initialValue: priority.rawValue) self.description = description self.executionStateChangedCallback = executionStateChangedCallback - var updatePriorityContinuation: AsyncStream.Continuation! - let updatePriorityStream = AsyncStream { - updatePriorityContinuation = $0 - } - self.updatePriorityContinuation = updatePriorityContinuation - var executionTaskCreatedContinuation: AsyncStream>.Continuation! let executionTaskCreatedStream = AsyncStream { executionTaskCreatedContinuation = $0 @@ -205,31 +198,24 @@ public actor QueuedTask { self.resultTask = Task.detached(priority: priority) { await withTaskCancellationHandler { - await withTaskGroup(of: Void.self) { taskGroup in - taskGroup.addTask { - for await _ in updatePriorityStream { - self.priority = Task.currentPriority - } - } - taskGroup.addTask { - for await task in executionTaskCreatedStream { - switch await task.valuePropagatingCancellation { - case .cancelledToBeRescheduled: - // Break the switch and wait for a new `executionTask` to be placed into `executionTaskCreatedStream`. - break - case .terminated: - // The task finished. We are done with this `QueuedTask` - return - } + await withTaskPriorityChangedHandler(initialPriority: self.priority) { + for await task in executionTaskCreatedStream { + switch await task.valuePropagatingCancellation { + case .cancelledToBeRescheduled: + // Break the switch and wait for a new `executionTask` to be placed into `executionTaskCreatedStream`. + break + case .terminated: + // The task finished. We are done with this `QueuedTask` + return } } - // The first (update priority) task never finishes, so this waits for the second (wait for execution) task - // to terminate. - // Afterwards we also cancel the update priority task. - for await _ in taskGroup { - taskGroup.cancelAll() - return + } taskPriorityChanged: { + withLoggingSubsystemAndScope(subsystem: taskSchedulerSubsystem, scope: nil) { + logger.debug( + "Updating priority of \(self.description.forLogging) from \(self.priority.rawValue) to \(Task.currentPriority.rawValue)" + ) } + self.priority = Task.currentPriority } } onCancel: { self.resultTaskCancelled.value = true @@ -282,20 +268,19 @@ public actor QueuedTask { self.executionTask = nil } - /// Trigger `QueuedTask.priority` to be updated with the current priority of the underlying task. - /// - /// This is an asynchronous operation that makes no guarantees when the updated priority will be available. - /// - /// This is needed because tasks can't subscribe to priority updates (ie. there is no `withPriorityHandler` similar to - /// `withCancellationHandler`, https://github.com/apple/swift/issues/73367). - func triggerPriorityUpdate() { - updatePriorityContinuation.yield() - } - /// If the priority of this task is less than `targetPriority`, elevate the priority to `targetPriority` by spawning /// a new task that depends on it. Otherwise a no-op. nonisolated func elevatePriority(to targetPriority: TaskPriority) { if priority < targetPriority { + withLoggingSubsystemAndScope(subsystem: taskSchedulerSubsystem, scope: nil) { + logger.debug( + "Elevating priority of \(self.description.forLogging) from \(self.priority.rawValue) to \(targetPriority.rawValue)" + ) + } + // Awaiting the result task from a higher-priority task will eventually update `priority` through + // `withTaskPriorityChangedHandler` but that might take a while because `withTaskPriorityChangedHandler` polls. + // Since we know that the priority will be elevated, set it now. That way we don't try to elevate it again. + self.priority = targetPriority Task(priority: targetPriority) { await self.resultTask.value } @@ -371,7 +356,7 @@ public actor TaskScheduler { )? = nil ) async -> QueuedTask { let queuedTask = await QueuedTask( - priority: priority, + priority: priority ?? Task.currentPriority, description: taskDescription, executionStateChangedCallback: executionStateChangedCallback ) @@ -385,16 +370,6 @@ public actor TaskScheduler { return queuedTask } - /// Trigger all queued tasks to update their priority. - /// - /// Should be called occasionally to elevate tasks in the queue whose underlying `Swift.Task` had their priority - /// elevated because a higher-priority task started depending on them. - private func triggerPriorityUpdateOfQueuedTasks() async { - for task in pendingTasks { - await task.triggerPriorityUpdate() - } - } - /// Returns the maximum number of concurrent tasks that are allowed to execute at the given priority. private func maxConcurrentTasks(at priority: TaskPriority) -> Int { for (atPriority, maxConcurrentTasks) in maxConcurrentTasksByPriority { @@ -417,9 +392,8 @@ public actor TaskScheduler { { // We don't have any execution slots left. Thus, this poker has nothing to do and is done. // When the next task finishes, it calls `poke` again. - // If the low priority task's priority gets elevated, that will be picked up when the next task in the - // `TaskScheduler` finishes, which causes `triggerPriorityUpdateOfQueuedTasks` to be called, which transfers - // the new elevated priority to `QueuedTask.priority` and which can then be picked up by the next `poke` call. + // If the low priority task's priority gets elevated that task's priority will get elevated and it will be + // picked up on the next `poke` call. return } let dependencies = task.description.dependencies(to: currentlyExecutingTasks.map(\.description)) @@ -428,13 +402,17 @@ public actor TaskScheduler { case .cancelAndRescheduleDependency(let taskDescription): guard let dependency = self.currentlyExecutingTasks.first(where: { $0.description.id == taskDescription.id }) else { - logger.fault( - "Cannot find task to wait for \(taskDescription.forLogging) in list of currently executing tasks" - ) + withLoggingSubsystemAndScope(subsystem: taskSchedulerSubsystem, scope: nil) { + logger.fault( + "Cannot find task to wait for \(taskDescription.forLogging) in list of currently executing tasks" + ) + } return nil } if !taskDescription.isIdempotent { - logger.fault("Cannot reschedule task '\(taskDescription.forLogging)' since it is not idempotent") + withLoggingSubsystemAndScope(subsystem: taskSchedulerSubsystem, scope: nil) { + logger.fault("Cannot reschedule task '\(taskDescription.forLogging)' since it is not idempotent") + } return dependency } if dependency.priority > task.priority { @@ -445,9 +423,11 @@ public actor TaskScheduler { case .waitAndElevatePriorityOfDependency(let taskDescription): guard let dependency = self.currentlyExecutingTasks.first(where: { $0.description.id == taskDescription.id }) else { - logger.fault( - "Cannot find task to wait for '\(taskDescription.forLogging)' in list of currently executing tasks" - ) + withLoggingSubsystemAndScope(subsystem: taskSchedulerSubsystem, scope: nil) { + logger.fault( + "Cannot find task to wait for '\(taskDescription.forLogging)' in list of currently executing tasks" + ) + } return nil } return dependency @@ -465,9 +445,11 @@ public actor TaskScheduler { switch taskDependency { case .cancelAndRescheduleDependency(let taskDescription): guard let task = self.currentlyExecutingTasks.first(where: { $0.description.id == taskDescription.id }) else { - logger.fault( - "Cannot find task to reschedule \(taskDescription.forLogging) in list of currently executing tasks" - ) + withLoggingSubsystemAndScope(subsystem: taskSchedulerSubsystem, scope: nil) { + logger.fault( + "Cannot find task to reschedule \(taskDescription.forLogging) in list of currently executing tasks" + ) + } return nil } return task @@ -478,6 +460,9 @@ public actor TaskScheduler { if !rescheduleTasks.isEmpty { Task.detached(priority: task.priority) { for task in rescheduleTasks { + withLoggingSubsystemAndScope(subsystem: taskSchedulerSubsystem, scope: nil) { + logger.debug("Suspending \(task.description.forLogging)") + } await task.cancelToBeRescheduled() } } @@ -510,7 +495,6 @@ public actor TaskScheduler { case .terminated: break case .cancelledToBeRescheduled: pendingTasks.append(task) } - await self.triggerPriorityUpdateOfQueuedTasks() self.poke() } } diff --git a/Sources/SKSupport/AsyncUtils.swift b/Sources/SKSupport/AsyncUtils.swift index cbef24769..216463fb1 100644 --- a/Sources/SKSupport/AsyncUtils.swift +++ b/Sources/SKSupport/AsyncUtils.swift @@ -166,3 +166,28 @@ extension Collection where Element: Sendable { } } } + +public struct TimeoutError: Error, CustomStringConvertible { + public var description: String { "Timed out" } +} + +/// Executes `body`. If it doesn't finish after `duration`, throws a `TimeoutError`. +public func withTimeout( + _ duration: Duration, + _ body: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { taskGroup in + taskGroup.addTask { + try await Task.sleep(for: duration) + throw TimeoutError() + } + taskGroup.addTask { + return try await body() + } + for try await value in taskGroup { + taskGroup.cancelAll() + return value + } + throw CancellationError() + } +} diff --git a/Sources/SKSupport/CMakeLists.txt b/Sources/SKSupport/CMakeLists.txt index 0171ac617..000ca70f8 100644 --- a/Sources/SKSupport/CMakeLists.txt +++ b/Sources/SKSupport/CMakeLists.txt @@ -17,6 +17,7 @@ add_library(SKSupport STATIC Result.swift Sequence+AsyncMap.swift SwitchableProcessResultExitStatus.swift + Task+WithPriorityChangedHandler.swift ThreadSafeBox.swift WorkspaceType.swift ) diff --git a/Sources/SKSupport/Task+WithPriorityChangedHandler.swift b/Sources/SKSupport/Task+WithPriorityChangedHandler.swift new file mode 100644 index 000000000..58367d1e0 --- /dev/null +++ b/Sources/SKSupport/Task+WithPriorityChangedHandler.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// Runs `operation`. If the task's priority changes while the operation is running, calls `taskPriorityChanged`. +/// +/// Since Swift Concurrency doesn't support direct observation of a task's priority, this polls the task's priority at +/// `pollingInterval`. +/// The function assumes that the original priority of the task is `initialPriority`. If the task priority changed +/// compared to `initialPriority`, the `taskPriorityChanged` will be called. +public func withTaskPriorityChangedHandler( + initialPriority: TaskPriority = Task.currentPriority, + pollingInterval: Duration = .seconds(0.1), + @_inheritActorContext operation: @escaping @Sendable () async -> Void, + taskPriorityChanged: @escaping @Sendable () -> Void +) async { + let lastPriority = ThreadSafeBox(initialValue: initialPriority) + await withTaskGroup(of: Void.self) { taskGroup in + taskGroup.addTask { + while true { + if Task.isCancelled { + return + } + let newPriority = Task.currentPriority + let didChange = lastPriority.withLock { lastPriority in + if newPriority != lastPriority { + lastPriority = newPriority + return true + } + return false + } + if didChange { + taskPriorityChanged() + } + do { + try await Task.sleep(for: pollingInterval) + } catch { + break + } + } + } + taskGroup.addTask { + await operation() + } + // The first task that watches the priority never finishes, so we are effectively await the `operation` task here + // and cancelling the priority observation task once the operation task is done. + // We do need to await the observation task as well so that priority escalation also affects the observation task. + for await _ in taskGroup { + taskGroup.cancelAll() + } + } +} diff --git a/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift b/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift index 8c97cc0e7..09c8ad92b 100644 --- a/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift +++ b/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift @@ -178,7 +178,9 @@ public actor SwiftPMBuildSystem { forRootPackage: AbsolutePath(packageRoot), fileSystem: fileSystem ) - if let scratchDirectory = buildSetup.path { + if isForIndexBuild { + location.scratchDirectory = AbsolutePath(packageRoot.appending(component: ".index-build")) + } else if let scratchDirectory = buildSetup.path { location.scratchDirectory = AbsolutePath(scratchDirectory) } @@ -226,7 +228,6 @@ public actor SwiftPMBuildSystem { } await delegate.filesDependenciesUpdated(filesWithUpdatedDependencies) } - try await reloadPackage() } /// Creates a build system using the Swift Package Manager, if this workspace is a package. @@ -260,13 +261,9 @@ public actor SwiftPMBuildSystem { } extension SwiftPMBuildSystem { - public func generateBuildGraph() async throws { - try await self.reloadPackage() - } - /// (Re-)load the package settings by parsing the manifest and resolving all the targets and /// dependencies. - func reloadPackage() async throws { + public func reloadPackage(forceResolvedVersions: Bool) async throws { await reloadPackageStatusCallback(.start) defer { Task { @@ -276,7 +273,7 @@ extension SwiftPMBuildSystem { let modulesGraph = try self.workspace.loadPackageGraph( rootInput: PackageGraphRootInput(packages: [AbsolutePath(projectRoot)]), - forceResolvedVersions: !isForIndexBuild, + forceResolvedVersions: forceResolvedVersions, availableLibraries: self.buildParameters.toolchain.providedLibraries, observabilityScope: observabilitySystem.topScope ) @@ -430,6 +427,10 @@ extension SwiftPMBuildSystem: SKCore.BuildSystem { return [] } + public func generateBuildGraph(allowFileSystemWrites: Bool) async throws { + try await self.reloadPackage(forceResolvedVersions: !isForIndexBuild || !allowFileSystemWrites) + } + public func topologicalSort(of targets: [ConfiguredTarget]) -> [ConfiguredTarget]? { return targets.sorted { (lhs: ConfiguredTarget, rhs: ConfiguredTarget) -> Bool in let lhsIndex = self.targets[lhs]?.index ?? self.targets.count @@ -590,7 +591,7 @@ extension SwiftPMBuildSystem: SKCore.BuildSystem { logger.log("Reloading package because of file change") await orLog("Reloading package") { // TODO: It should not be necessary to reload the entire package just to get build settings for one file. - try await self.reloadPackage() + try await self.reloadPackage(forceResolvedVersions: !isForIndexBuild) } } diff --git a/Sources/SKTestSupport/MultiFileTestProject.swift b/Sources/SKTestSupport/MultiFileTestProject.swift index e16d8846a..3b72193e2 100644 --- a/Sources/SKTestSupport/MultiFileTestProject.swift +++ b/Sources/SKTestSupport/MultiFileTestProject.swift @@ -82,6 +82,7 @@ public class MultiFileTestProject { workspaces: (URL) async throws -> [WorkspaceFolder] = { [WorkspaceFolder(uri: DocumentURI($0))] }, capabilities: ClientCapabilities = ClientCapabilities(), serverOptions: SourceKitLSPServer.Options = .testDefault, + enableBackgroundIndexing: Bool = false, usePullDiagnostics: Bool = true, preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, cleanUp: (() -> Void)? = nil, @@ -117,6 +118,7 @@ public class MultiFileTestProject { serverOptions: serverOptions, capabilities: capabilities, usePullDiagnostics: usePullDiagnostics, + enableBackgroundIndexing: enableBackgroundIndexing, workspaceFolders: workspaces(scratchDirectory), preInitialization: preInitialization, cleanUp: { [scratchDirectory] in diff --git a/Sources/SKTestSupport/SkipUnless.swift b/Sources/SKTestSupport/SkipUnless.swift index 803b8a507..f1ab51777 100644 --- a/Sources/SKTestSupport/SkipUnless.swift +++ b/Sources/SKTestSupport/SkipUnless.swift @@ -272,6 +272,16 @@ public actor SkipUnless { public static func platformIsDarwin(_ message: String) throws { try XCTSkipUnless(Platform.current == .darwin, message) } + + public static func platformSupportsTaskPriorityElevation() throws { + #if os(macOS) + guard #available(macOS 14.0, *) else { + // Priority elevation was implemented by https://github.com/apple/swift/pull/63019, which is available in the + // Swift 5.9 runtime included in macOS 14.0+ + throw XCTSkip("Priority elevation of tasks is only supported on macOS 14 and above") + } + #endif + } } // MARK: - Parsing Swift compiler version diff --git a/Sources/SKTestSupport/SwiftPMTestProject.swift b/Sources/SKTestSupport/SwiftPMTestProject.swift index 28944fb2a..9e44786be 100644 --- a/Sources/SKTestSupport/SwiftPMTestProject.swift +++ b/Sources/SKTestSupport/SwiftPMTestProject.swift @@ -44,9 +44,10 @@ public class SwiftPMTestProject: MultiFileTestProject { allowBuildFailure: Bool = false, capabilities: ClientCapabilities = ClientCapabilities(), serverOptions: SourceKitLSPServer.Options = .testDefault, + enableBackgroundIndexing: Bool = false, + usePullDiagnostics: Bool = true, pollIndex: Bool = true, preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, - usePullDiagnostics: Bool = true, cleanUp: (() -> Void)? = nil, testName: String = #function ) async throws { @@ -71,6 +72,7 @@ public class SwiftPMTestProject: MultiFileTestProject { workspaces: workspaces, capabilities: capabilities, serverOptions: serverOptions, + enableBackgroundIndexing: enableBackgroundIndexing, usePullDiagnostics: usePullDiagnostics, preInitialization: preInitialization, cleanUp: cleanUp, diff --git a/Sources/SKTestSupport/TestSourceKitLSPClient.swift b/Sources/SKTestSupport/TestSourceKitLSPClient.swift index 150af5805..9f61a9b9a 100644 --- a/Sources/SKTestSupport/TestSourceKitLSPClient.swift +++ b/Sources/SKTestSupport/TestSourceKitLSPClient.swift @@ -88,7 +88,8 @@ public final class TestSourceKitLSPClient: MessageHandler { /// `true` by default /// - initializationOptions: Initialization options to pass to the SourceKit-LSP server. /// - capabilities: The test client's capabilities. - /// - usePullDiagnostics: Whether to use push diagnostics or use push-based diagnostics + /// - usePullDiagnostics: Whether to use push diagnostics or use push-based diagnostics. + /// - enableBackgroundIndexing: Whether background indexing should be enabled in the project. /// - workspaceFolders: Workspace folders to open. /// - preInitialization: A closure that is called after the test client is created but before SourceKit-LSP is /// initialized. This can be used to eg. register request handlers. @@ -102,6 +103,7 @@ public final class TestSourceKitLSPClient: MessageHandler { initializationOptions: LSPAny? = nil, capabilities: ClientCapabilities = ClientCapabilities(), usePullDiagnostics: Bool = true, + enableBackgroundIndexing: Bool = false, workspaceFolders: [WorkspaceFolder]? = nil, preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, cleanUp: @Sendable @escaping () -> Void = {} @@ -115,6 +117,7 @@ public final class TestSourceKitLSPClient: MessageHandler { if let moduleCache { serverOptions.buildSetup.flags.swiftCompilerFlags += ["-module-cache-path", moduleCache.path] } + serverOptions.indexOptions.enableBackgroundIndexing = enableBackgroundIndexing var notificationYielder: AsyncStream.Continuation! self.notifications = AsyncStream { continuation in @@ -155,8 +158,8 @@ public final class TestSourceKitLSPClient: MessageHandler { XCTAssertEqual(request.registrations.only?.method, DocumentDiagnosticsRequest.method) return VoidResponse() } - preInitialization?(self) } + preInitialization?(self) if initialize { _ = try await self.send( InitializeRequest( @@ -193,12 +196,22 @@ public final class TestSourceKitLSPClient: MessageHandler { /// Send the request to `server` and return the request result. public func send(_ request: R) async throws -> R.Response { return try await withCheckedThrowingContinuation { continuation in - server.handle(request, id: .number(Int(nextRequestID.fetchAndIncrement()))) { result in + self.send(request) { result in continuation.resume(with: result) } } } + /// Send the request to `server` and return the result via a completion handler. + /// + /// This version of the `send` function should only be used if some action needs to be performed after the request is + /// sent but before it returns a result. + public func send(_ request: R, completionHandler: @escaping (LSPResult) -> Void) { + server.handle(request, id: .number(Int(nextRequestID.fetchAndIncrement()))) { result in + completionHandler(result) + } + } + /// Send the notification to `server`. public func send(_ notification: some NotificationType) { server.handle(notification) @@ -248,15 +261,16 @@ public final class TestSourceKitLSPClient: MessageHandler { return try await nextNotification(ofType: PublishDiagnosticsNotification.self, timeout: timeout) } - /// Waits for the next notification of the given type to be sent to the client. Ignores any notifications that are of - /// a different type. + /// Waits for the next notification of the given type to be sent to the client that satisfies the given predicate. + /// Ignores any notifications that are of a different type or that don't satisfy the predicate. public func nextNotification( ofType: ExpectedNotificationType.Type, + satisfying predicate: (ExpectedNotificationType) -> Bool = { _ in true }, timeout: TimeInterval = defaultTimeout ) async throws -> ExpectedNotificationType { while true { let nextNotification = try await nextNotification(timeout: timeout) - if let notification = nextNotification as? ExpectedNotificationType { + if let notification = nextNotification as? ExpectedNotificationType, predicate(notification) { return notification } } diff --git a/Sources/SemanticIndex/PreparationTaskDescription.swift b/Sources/SemanticIndex/PreparationTaskDescription.swift index 50466274c..66f78e8c6 100644 --- a/Sources/SemanticIndex/PreparationTaskDescription.swift +++ b/Sources/SemanticIndex/PreparationTaskDescription.swift @@ -74,10 +74,7 @@ public struct PreparationTaskDescription: IndexTaskDescription { // Only use the last two digits of the preparation ID for the logging scope to avoid creating too many scopes. // See comment in `withLoggingScope`. // The last 2 digits should be sufficient to differentiate between multiple concurrently running preparation operations - await withLoggingSubsystemAndScope( - subsystem: "org.swift.sourcekit-lsp.indexing", - scope: "preparation-\(id % 100)" - ) { + await withLoggingSubsystemAndScope(subsystem: indexLoggingSubsystem, scope: "preparation-\(id % 100)") { let targetsToPrepare = await targetsToPrepare.asyncFilter { await !preparationUpToDateStatus.isUpToDate($0) }.sorted(by: { @@ -98,10 +95,13 @@ public struct PreparationTaskDescription: IndexTaskDescription { let signposter = Logger(subsystem: LoggingScope.subsystem, category: "preparation").makeSignposter() let signpostID = signposter.makeSignpostID() let state = signposter.beginInterval("Preparing", id: signpostID, "Preparing \(targetsToPrepareDescription)") + let startDate = Date() defer { + logger.log( + "Finished preparation in \(Date().timeIntervalSince(startDate) * 1000, privacy: .public)ms: \(targetsToPrepareDescription)" + ) signposter.endInterval("Preparing", state) } - let startDate = Date() do { try await buildSystemManager.prepare( targets: targetsToPrepare, @@ -116,9 +116,6 @@ public struct PreparationTaskDescription: IndexTaskDescription { if !Task.isCancelled { await preparationUpToDateStatus.markUpToDate(targetsToPrepare, updateOperationStartDate: startDate) } - logger.log( - "Finished preparation in \(Date().timeIntervalSince(startDate) * 1000, privacy: .public)ms: \(targetsToPrepareDescription)" - ) } } diff --git a/Sources/SemanticIndex/SemanticIndexManager.swift b/Sources/SemanticIndex/SemanticIndexManager.swift index d6d4f5837..544e420bb 100644 --- a/Sources/SemanticIndex/SemanticIndexManager.swift +++ b/Sources/SemanticIndex/SemanticIndexManager.swift @@ -15,6 +15,9 @@ import LSPLogging import LanguageServerProtocol import SKCore +/// The logging subsystem that should be used for all index-related logging. +let indexLoggingSubsystem = "org.swift.sourcekit-lsp.indexing" + /// A wrapper around `QueuedTask` that only allows equality comparison and inspection whether the `QueuedTask` is /// currently executing. /// @@ -61,6 +64,38 @@ public enum IndexTaskStatus: Comparable { case executing } +/// The current index status that should be displayed to the editor. +/// +/// In reality, these status are not exclusive. Eg. the index might be preparing one target for editor functionality, +/// re-generating the build graph and indexing files at the same time. To avoid showing too many concurrent status +/// messages to the user, we only show the highest priority task. +public enum IndexProgressStatus { + case preparingFileForEditorFunctionality + case generatingBuildGraph + case indexing(preparationTasks: [ConfiguredTarget: IndexTaskStatus], indexTasks: [DocumentURI: IndexTaskStatus]) + case upToDate + + public func merging(with other: IndexProgressStatus) -> IndexProgressStatus { + switch (self, other) { + case (_, .preparingFileForEditorFunctionality), (.preparingFileForEditorFunctionality, _): + return .preparingFileForEditorFunctionality + case (_, .generatingBuildGraph), (.generatingBuildGraph, _): + return .generatingBuildGraph + case ( + .indexing(let selfPreparationTasks, let selfIndexTasks), + .indexing(let otherPreparationTasks, let otherIndexTasks) + ): + return .indexing( + preparationTasks: selfPreparationTasks.merging(otherPreparationTasks) { max($0, $1) }, + indexTasks: selfIndexTasks.merging(otherIndexTasks) { max($0, $1) } + ) + case (.indexing, .upToDate): return self + case (.upToDate, .indexing): return other + case (.upToDate, .upToDate): return .upToDate + } + } +} + /// Schedules index tasks and keeps track of the index status of files. public final actor SemanticIndexManager { /// The underlying index. This is used to check if the index of a file is already up-to-date, in which case it doesn't @@ -119,24 +154,22 @@ public final actor SemanticIndexManager { /// The parameter is the number of files that were scheduled to be indexed. private let indexTasksWereScheduled: @Sendable (_ numberOfFileScheduled: Int) -> Void - /// Callback that is called when the progress status of an update indexstore or preparation task finishes. - /// - /// An object observing this property probably wants to check `inProgressIndexTasks` when the callback is called to - /// get the current list of in-progress index tasks. - /// - /// The number of `indexStatusDidChange` calls does not have to relate to the number of `indexTasksWereScheduled` calls. - private let indexStatusDidChange: @Sendable () -> Void + /// Callback that is called when `progressStatus` might have changed. + private let indexProgressStatusDidChange: @Sendable () -> Void // MARK: - Public API /// A summary of the tasks that this `SemanticIndexManager` has currently scheduled or is currently indexing. - public var inProgressTasks: - ( - isGeneratingBuildGraph: Bool, - indexTasks: [DocumentURI: IndexTaskStatus], - preparationTasks: [ConfiguredTarget: IndexTaskStatus] - ) - { + public var progressStatus: IndexProgressStatus { + if inProgressPrepareForEditorTask != nil { + return .preparingFileForEditorFunctionality + } + if generateBuildGraphTask != nil { + return .generatingBuildGraph + } + let preparationTasks = inProgressPreparationTasks.mapValues { queuedTask in + return queuedTask.isExecuting ? IndexTaskStatus.executing : IndexTaskStatus.scheduled + } let indexTasks = inProgressIndexTasks.mapValues { status in switch status { case .waitingForPreparation: @@ -145,10 +178,10 @@ public final actor SemanticIndexManager { return updateIndexStoreTask.isExecuting ? IndexTaskStatus.executing : IndexTaskStatus.scheduled } } - let preparationTasks = inProgressPreparationTasks.mapValues { queuedTask in - return queuedTask.isExecuting ? IndexTaskStatus.executing : IndexTaskStatus.scheduled + if preparationTasks.isEmpty && indexTasks.isEmpty { + return .upToDate } - return (generateBuildGraphTask != nil, indexTasks, preparationTasks) + return .indexing(preparationTasks: preparationTasks, indexTasks: indexTasks) } public init( @@ -158,7 +191,7 @@ public final actor SemanticIndexManager { indexTaskScheduler: TaskScheduler, indexProcessDidProduceResult: @escaping @Sendable (IndexProcessResult) -> Void, indexTasksWereScheduled: @escaping @Sendable (Int) -> Void, - indexStatusDidChange: @escaping @Sendable () -> Void + indexProgressStatusDidChange: @escaping @Sendable () -> Void ) { self.index = index self.buildSystemManager = buildSystemManager @@ -166,7 +199,7 @@ public final actor SemanticIndexManager { self.indexTaskScheduler = indexTaskScheduler self.indexProcessDidProduceResult = indexProcessDidProduceResult self.indexTasksWereScheduled = indexTasksWereScheduled - self.indexStatusDidChange = indexStatusDidChange + self.indexProgressStatusDidChange = indexProgressStatusDidChange } /// Schedules a task to index `files`. Files that are known to be up-to-date based on `indexStatus` will @@ -186,25 +219,42 @@ public final actor SemanticIndexManager { /// This method is intended to initially update the index of a project after it is opened. public func scheduleBuildGraphGenerationAndBackgroundIndexAllFiles() async { generateBuildGraphTask = Task(priority: .low) { - let signposter = Logger(subsystem: LoggingScope.subsystem, category: "preparation").makeSignposter() - let signpostID = signposter.makeSignpostID() - let state = signposter.beginInterval("Preparing", id: signpostID, "Generating build graph") - defer { - signposter.endInterval("Preparing", state) - } - await orLog("Generating build graph") { try await self.buildSystemManager.generateBuildGraph() } - let index = index.checked(for: .modifiedFiles) - let filesToIndex = await self.buildSystemManager.sourceFiles().lazy.map(\.uri) - .filter { uri in - guard let url = uri.fileURL else { - // The URI is not a file, so there's nothing we can index. - return false - } - return !index.hasUpToDateUnit(for: url) + await withLoggingSubsystemAndScope(subsystem: indexLoggingSubsystem, scope: "build-graph-generation") { + logger.log( + "Starting build graph generation with priority \(Task.currentPriority.rawValue, privacy: .public)" + ) + let signposter = logger.makeSignposter() + let signpostID = signposter.makeSignpostID() + let state = signposter.beginInterval("Preparing", id: signpostID, "Generating build graph") + let startDate = Date() + defer { + logger.log( + "Finished build graph generation in \(Date().timeIntervalSince(startDate) * 1000, privacy: .public)ms" + ) + signposter.endInterval("Preparing", state) + } + await testHooks.buildGraphGenerationDidStart?() + await orLog("Generating build graph") { + try await self.buildSystemManager.generateBuildGraph(allowFileSystemWrites: true) } - await scheduleBackgroundIndex(files: filesToIndex) - generateBuildGraphTask = nil + // Ensure that we have an up-to-date indexstore-db. Waiting for the indexstore-db to be updated is cheaper than + // potentially not knowing about unit files, which causes the corresponding source files to be re-indexed. + index.pollForUnitChangesAndWait() + await testHooks.buildGraphGenerationDidFinish?() + let index = index.checked(for: .modifiedFiles) + let filesToIndex = await self.buildSystemManager.sourceFiles().lazy.map(\.uri) + .filter { uri in + guard let url = uri.fileURL else { + // The URI is not a file, so there's nothing we can index. + return false + } + return !index.hasUpToDateUnit(for: url) + } + await scheduleBackgroundIndex(files: filesToIndex) + generateBuildGraphTask = nil + } } + indexProgressStatusDidChange() } /// Wait for all in-progress index tasks to finish. @@ -309,10 +359,7 @@ public final actor SemanticIndexManager { /// Schedule preparation of the target that contains the given URI, building all modules that the file depends on. /// /// This is intended to be called when the user is interacting with the document at the given URI. - public func schedulePreparationForEditorFunctionality( - of uri: DocumentURI, - priority: TaskPriority? = nil - ) { + public func schedulePreparationForEditorFunctionality(of uri: DocumentURI, priority: TaskPriority? = nil) { if inProgressPrepareForEditorTask?.document == uri { // We are already preparing this document, so nothing to do. This is necessary to avoid the following scenario: // Determining the canonical configured target for a document takes 1s and we get a new document request for the @@ -323,20 +370,30 @@ public final actor SemanticIndexManager { let id = UUID() let task = Task(priority: priority) { await withLoggingScope("preparation") { - guard let target = await buildSystemManager.canonicalConfiguredTarget(for: uri) else { - return - } - if Task.isCancelled { - return - } - await self.prepare(targets: [target], priority: priority) + await self.prepareFileForEditorFunctionality(uri) if inProgressPrepareForEditorTask?.id == id { inProgressPrepareForEditorTask = nil + self.indexProgressStatusDidChange() } } } inProgressPrepareForEditorTask?.task.cancel() inProgressPrepareForEditorTask = (id, uri, task) + self.indexProgressStatusDidChange() + } + + /// Prepare the target that the given file is in, building all modules that the file depends on. Returns when + /// preparation has finished. + /// + /// If file's target is known to be up-to-date, this returns almost immediately. + public func prepareFileForEditorFunctionality(_ uri: DocumentURI) async { + guard let target = await buildSystemManager.canonicalConfiguredTarget(for: uri) else { + return + } + if Task.isCancelled { + return + } + await self.prepare(targets: [target], priority: nil) } // MARK: - Helper functions @@ -370,7 +427,7 @@ public final actor SemanticIndexManager { } let preparationTask = await indexTaskScheduler.schedule(priority: priority, taskDescription) { task, newState in guard case .finished = newState else { - self.indexStatusDidChange() + self.indexProgressStatusDidChange() return } for target in targetsToPrepare { @@ -378,7 +435,7 @@ public final actor SemanticIndexManager { self.inProgressPreparationTasks[target] = nil } } - self.indexStatusDidChange() + self.indexProgressStatusDidChange() } for target in targetsToPrepare { inProgressPreparationTasks[target] = OpaqueQueuedIndexTask(preparationTask) @@ -414,7 +471,7 @@ public final actor SemanticIndexManager { ) let updateIndexTask = await indexTaskScheduler.schedule(priority: priority, taskDescription) { task, newState in guard case .finished = newState else { - self.indexStatusDidChange() + self.indexProgressStatusDidChange() return } for fileAndTarget in filesAndTargets { @@ -424,7 +481,7 @@ public final actor SemanticIndexManager { self.inProgressIndexTasks[fileAndTarget.file.sourceFile] = nil } } - self.indexStatusDidChange() + self.indexProgressStatusDidChange() } for fileAndTarget in filesAndTargets { if case .waitingForPreparation(preparationTaskID, let indexTask) = inProgressIndexTasks[ @@ -452,10 +509,19 @@ public final actor SemanticIndexManager { // schedule two indexing jobs for the same file in quick succession, only the first one actually updates the index // store and the second one will be a no-op once it runs. let outOfDateFiles = await filesToIndex(toCover: files).asyncFilter { - return await !indexStoreUpToDateStatus.isUpToDate($0.sourceFile) + if await indexStoreUpToDateStatus.isUpToDate($0.sourceFile) { + return false + } + guard let language = await buildSystemManager.defaultLanguage(for: $0.mainFile), + UpdateIndexStoreTaskDescription.canIndex(language: language) + else { + return false + } + return true } // sort files to get deterministic indexing order .sorted(by: { $0.sourceFile.stringValue < $1.sourceFile.stringValue }) + logger.debug("Scheduling indexing of \(outOfDateFiles.map(\.sourceFile.stringValue).joined(separator: ", "))") // Sort the targets in topological order so that low-level targets get built before high-level targets, allowing us // to index the low-level targets ASAP. diff --git a/Sources/SemanticIndex/TestHooks.swift b/Sources/SemanticIndex/TestHooks.swift index c8afa40e0..f09cfd73d 100644 --- a/Sources/SemanticIndex/TestHooks.swift +++ b/Sources/SemanticIndex/TestHooks.swift @@ -12,6 +12,10 @@ /// Callbacks that allow inspection of internal state modifications during testing. public struct IndexTestHooks: Sendable { + public var buildGraphGenerationDidStart: (@Sendable () async -> Void)? + + public var buildGraphGenerationDidFinish: (@Sendable () async -> Void)? + public var preparationTaskDidStart: (@Sendable (PreparationTaskDescription) async -> Void)? public var preparationTaskDidFinish: (@Sendable (PreparationTaskDescription) async -> Void)? @@ -22,11 +26,15 @@ public struct IndexTestHooks: Sendable { public var updateIndexStoreTaskDidFinish: (@Sendable (UpdateIndexStoreTaskDescription) async -> Void)? public init( + buildGraphGenerationDidStart: (@Sendable () async -> Void)? = nil, + buildGraphGenerationDidFinish: (@Sendable () async -> Void)? = nil, preparationTaskDidStart: (@Sendable (PreparationTaskDescription) async -> Void)? = nil, preparationTaskDidFinish: (@Sendable (PreparationTaskDescription) async -> Void)? = nil, updateIndexStoreTaskDidStart: (@Sendable (UpdateIndexStoreTaskDescription) async -> Void)? = nil, updateIndexStoreTaskDidFinish: (@Sendable (UpdateIndexStoreTaskDescription) async -> Void)? = nil ) { + self.buildGraphGenerationDidStart = buildGraphGenerationDidStart + self.buildGraphGenerationDidFinish = buildGraphGenerationDidFinish self.preparationTaskDidStart = preparationTaskDidStart self.preparationTaskDidFinish = preparationTaskDidFinish self.updateIndexStoreTaskDidStart = updateIndexStoreTaskDidStart diff --git a/Sources/SemanticIndex/UpdateIndexStoreTaskDescription.swift b/Sources/SemanticIndex/UpdateIndexStoreTaskDescription.swift index 3183b84ca..6f991ed6b 100644 --- a/Sources/SemanticIndex/UpdateIndexStoreTaskDescription.swift +++ b/Sources/SemanticIndex/UpdateIndexStoreTaskDescription.swift @@ -76,6 +76,22 @@ public struct FileAndTarget: Sendable { public let target: ConfiguredTarget } +private enum IndexKind { + case clang + case swift + + init?(language: Language) { + switch language { + case .swift: + self = .swift + case .c, .cpp, .objective_c, .objective_cpp: + self = .clang + default: + return nil + } + } +} + /// Describes a task to index a set of source files. /// /// This task description can be scheduled in a `TaskScheduler`. @@ -114,6 +130,10 @@ public struct UpdateIndexStoreTaskDescription: IndexTaskDescription { return "update-indexstore-\(id)" } + static func canIndex(language: Language) -> Bool { + return IndexKind(language: language) != nil + } + init( filesToIndex: [FileAndTarget], buildSystemManager: BuildSystemManager, @@ -134,10 +154,7 @@ public struct UpdateIndexStoreTaskDescription: IndexTaskDescription { // Only use the last two digits of the indexing ID for the logging scope to avoid creating too many scopes. // See comment in `withLoggingScope`. // The last 2 digits should be sufficient to differentiate between multiple concurrently running indexing operation. - await withLoggingSubsystemAndScope( - subsystem: "org.swift.sourcekit-lsp.indexing", - scope: "update-indexstore-\(id % 100)" - ) { + await withLoggingSubsystemAndScope(subsystem: indexLoggingSubsystem, scope: "update-indexstore-\(id % 100)") { let startDate = Date() await testHooks.updateIndexStoreTaskDidStart?(self) @@ -219,7 +236,7 @@ public struct UpdateIndexStoreTaskDescription: IndexTaskDescription { return } let startDate = Date() - switch language { + switch IndexKind(language: language) { case .swift: do { try await updateIndexStore( @@ -231,7 +248,7 @@ public struct UpdateIndexStoreTaskDescription: IndexTaskDescription { logger.error("Updating index store for \(file.forLogging) failed: \(error.forLogging)") BuildSettingsLogger.log(settings: buildSettings, for: file.mainFile) } - case .c, .cpp, .objective_c, .objective_cpp: + case .clang: do { try await updateIndexStore( forClangFile: file.mainFile, @@ -242,7 +259,7 @@ public struct UpdateIndexStoreTaskDescription: IndexTaskDescription { logger.error("Updating index store for \(file) failed: \(error.forLogging)") BuildSettingsLogger.log(settings: buildSettings, for: file.mainFile) } - default: + case nil: logger.error( "Not updating index store for \(file) because it is a language that is not supported by background indexing" ) @@ -324,7 +341,13 @@ public struct UpdateIndexStoreTaskDescription: IndexTaskDescription { arguments: processArguments, workingDirectory: workingDirectory ) - let result = try await process.waitUntilExitSendingSigIntOnTaskCancellation() + // Time out updating of the index store after 2 minutes. We don't expect any single file compilation to take longer + // than 2 minutes in practice, so this indicates that the compiler has entered a loop and we probably won't make any + // progress here. We will try indexing the file again when it is edited or when the project is re-opened. + // 2 minutes have been chosen arbitrarily. + let result = try await withTimeout(.seconds(120)) { + try await process.waitUntilExitSendingSigIntOnTaskCancellation() + } indexProcessDidProduceResult( IndexProcessResult( diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 0a7c402d6..a76fd4faf 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -1,11 +1,14 @@ add_library(SourceKitLSP STATIC CapabilityRegistry.swift + CreateBuildSystem.swift DocumentManager.swift DocumentSnapshot+FromFileContents.swift IndexProgressManager.swift IndexStoreDB+MainFilesProvider.swift + LanguageServerType.swift LanguageService.swift + MessageHandlingDependencyTracker.swift Rename.swift ResponseError+Init.swift SourceKitIndexDelegate.swift @@ -16,6 +19,7 @@ add_library(SourceKitLSP STATIC TestDiscovery.swift TextEdit+IsNoop.swift WorkDoneProgressManager.swift + WorkDoneProgressState.swift Workspace.swift ) target_sources(SourceKitLSP PRIVATE diff --git a/Sources/SourceKitLSP/CreateBuildSystem.swift b/Sources/SourceKitLSP/CreateBuildSystem.swift new file mode 100644 index 000000000..9eb963419 --- /dev/null +++ b/Sources/SourceKitLSP/CreateBuildSystem.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import LSPLogging +import LanguageServerProtocol +import SKCore +import SKSwiftPMWorkspace + +import struct TSCBasic.AbsolutePath +import struct TSCBasic.RelativePath + +/// Tries to create a build system for a workspace at the given location, with the given parameters. +func createBuildSystem( + rootUri: DocumentURI, + options: SourceKitLSPServer.Options, + toolchainRegistry: ToolchainRegistry, + reloadPackageStatusCallback: @Sendable @escaping (ReloadPackageStatus) async -> Void +) async -> BuildSystem? { + guard let rootUrl = rootUri.fileURL, let rootPath = try? AbsolutePath(validating: rootUrl.path) else { + // We assume that workspaces are directories. This is only true for URLs not for URIs in general. + // Simply skip setting up the build integration in this case. + logger.error( + "cannot setup build integration at URI \(rootUri.forLogging) because the URI it is not a valid file URL" + ) + return nil + } + func createSwiftPMBuildSystem(rootUrl: URL) async -> SwiftPMBuildSystem? { + return await SwiftPMBuildSystem( + url: rootUrl, + toolchainRegistry: toolchainRegistry, + buildSetup: options.buildSetup, + isForIndexBuild: options.indexOptions.enableBackgroundIndexing, + reloadPackageStatusCallback: reloadPackageStatusCallback + ) + } + + func createCompilationDatabaseBuildSystem(rootPath: AbsolutePath) -> CompilationDatabaseBuildSystem? { + return CompilationDatabaseBuildSystem( + projectRoot: rootPath, + searchPaths: options.compilationDatabaseSearchPaths + ) + } + + func createBuildServerBuildSystem(rootPath: AbsolutePath) async -> BuildServerBuildSystem? { + return await BuildServerBuildSystem(projectRoot: rootPath, buildSetup: options.buildSetup) + } + + let defaultBuildSystem: BuildSystem? = + switch options.buildSetup.defaultWorkspaceType { + case .buildServer: await createBuildServerBuildSystem(rootPath: rootPath) + case .compilationDatabase: createCompilationDatabaseBuildSystem(rootPath: rootPath) + case .swiftPM: await createSwiftPMBuildSystem(rootUrl: rootUrl) + case nil: nil + } + if let defaultBuildSystem { + return defaultBuildSystem + } else if let buildServer = await createBuildServerBuildSystem(rootPath: rootPath) { + return buildServer + } else if let swiftpm = await createSwiftPMBuildSystem(rootUrl: rootUrl) { + return swiftpm + } else if let compdb = createCompilationDatabaseBuildSystem(rootPath: rootPath) { + return compdb + } else { + logger.error("Could not set up a build system at '\(rootUri.forLogging)'") + return nil + } +} diff --git a/Sources/SourceKitLSP/IndexProgressManager.swift b/Sources/SourceKitLSP/IndexProgressManager.swift index 908d72e64..72d53ffa9 100644 --- a/Sources/SourceKitLSP/IndexProgressManager.swift +++ b/Sources/SourceKitLSP/IndexProgressManager.swift @@ -18,11 +18,11 @@ import SemanticIndex /// Listens for index status updates from `SemanticIndexManagers`. From that information, it manages a /// `WorkDoneProgress` that communicates the index progress to the editor. actor IndexProgressManager { - /// A queue on which `indexTaskWasQueued` and `indexStatusDidChange` are handled. + /// A queue on which `indexTaskWasQueued` and `indexProgressStatusDidChange` are handled. /// - /// This allows the two functions two be `nonisolated` (and eg. the caller of `indexStatusDidChange` doesn't have to + /// This allows the two functions two be `nonisolated` (and eg. the caller of `indexProgressStatusDidChange` doesn't have to /// wait for the work done progress to be updated) while still guaranteeing that there is only one - /// `indexStatusDidChangeImpl` running at a time, preventing race conditions that would cause two + /// `indexProgressStatusDidChangeImpl` running at a time, preventing race conditions that would cause two /// `WorkDoneProgressManager`s to be created. private let queue = AsyncQueue() @@ -64,64 +64,64 @@ actor IndexProgressManager { private func indexTasksWereScheduledImpl(count: Int) async { queuedIndexTasks += count - await indexStatusDidChangeImpl() + await indexProgressStatusDidChangeImpl() } /// Called when a `SemanticIndexManager` finishes indexing a file. Adjusts the done index count, eg. the 1 in `1/3`. - nonisolated func indexStatusDidChange() { + nonisolated func indexProgressStatusDidChange() { queue.async { - await self.indexStatusDidChangeImpl() + await self.indexProgressStatusDidChangeImpl() } } - private func indexStatusDidChangeImpl() async { + private func indexProgressStatusDidChangeImpl() async { guard let sourceKitLSPServer else { workDoneProgress = nil return } - var isGeneratingBuildGraph = false - var indexTasks: [DocumentURI: IndexTaskStatus] = [:] - var preparationTasks: [ConfiguredTarget: IndexTaskStatus] = [:] + var status = IndexProgressStatus.upToDate for indexManager in await sourceKitLSPServer.workspaces.compactMap({ $0.semanticIndexManager }) { - let inProgress = await indexManager.inProgressTasks - isGeneratingBuildGraph = isGeneratingBuildGraph || inProgress.isGeneratingBuildGraph - indexTasks.merge(inProgress.indexTasks) { lhs, rhs in - return max(lhs, rhs) - } - preparationTasks.merge(inProgress.preparationTasks) { lhs, rhs in - return max(lhs, rhs) - } + status = status.merging(with: await indexManager.progressStatus) } - if indexTasks.isEmpty { + var message: String + let percentage: Int + switch status { + case .preparingFileForEditorFunctionality: + message = "Preparing current file" + percentage = 0 + case .generatingBuildGraph: + message = "Generating build graph" + percentage = 0 + case .indexing(preparationTasks: let preparationTasks, indexTasks: let indexTasks): + // We can get into a situation where queuedIndexTasks < indexTasks.count if we haven't processed all + // `indexTasksWereScheduled` calls yet but the semantic index managers already track them in their in-progress tasks. + // Clip the finished tasks to 0 because showing a negative number there looks stupid. + let finishedTasks = max(queuedIndexTasks - indexTasks.count, 0) + message = "\(finishedTasks) / \(queuedIndexTasks)" + if await sourceKitLSPServer.options.indexOptions.showActivePreparationTasksInProgress { + var inProgressTasks: [String] = [] + inProgressTasks += preparationTasks.filter { $0.value == .executing } + .map { "- Preparing \($0.key.targetID)" } + .sorted() + inProgressTasks += indexTasks.filter { $0.value == .executing } + .map { "- Indexing \($0.key.fileURL?.lastPathComponent ?? $0.key.pseudoPath)" } + .sorted() + + message += "\n\n" + inProgressTasks.joined(separator: "\n") + } + if queuedIndexTasks != 0 { + percentage = Int(Double(finishedTasks) / Double(queuedIndexTasks) * 100) + } else { + percentage = 0 + } + case .upToDate: // Nothing left to index. Reset the target count and dismiss the work done progress. queuedIndexTasks = 0 workDoneProgress = nil return } - // We can get into a situation where queuedIndexTasks < indexTasks.count if we haven't processed all - // `indexTasksWereScheduled` calls yet but the semantic index managers already track them in their in-progress tasks. - // Clip the finished tasks to 0 because showing a negative number there looks stupid. - let finishedTasks = max(queuedIndexTasks - indexTasks.count, 0) - var message = "\(finishedTasks) / \(queuedIndexTasks)" - - if await sourceKitLSPServer.options.indexOptions.showActivePreparationTasksInProgress { - var inProgressTasks: [String] = [] - if isGeneratingBuildGraph { - inProgressTasks.append("- Generating build graph") - } - inProgressTasks += preparationTasks.filter { $0.value == .executing } - .map { "- Preparing \($0.key.targetID)" } - .sorted() - inProgressTasks += indexTasks.filter { $0.value == .executing } - .map { "- Indexing \($0.key.fileURL?.lastPathComponent ?? $0.key.pseudoPath)" } - .sorted() - - message += "\n\n" + inProgressTasks.joined(separator: "\n") - } - - let percentage = Int(Double(finishedTasks) / Double(queuedIndexTasks) * 100) if let workDoneProgress { workDoneProgress.update(message: message, percentage: percentage) } else { diff --git a/Sources/SourceKitLSP/LanguageServerType.swift b/Sources/SourceKitLSP/LanguageServerType.swift new file mode 100644 index 000000000..92889d649 --- /dev/null +++ b/Sources/SourceKitLSP/LanguageServerType.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import IndexStoreDB + +/// Exhaustive enumeration of all toolchain language servers known to SourceKit-LSP. +enum LanguageServerType: Hashable { + case clangd + case swift + + init?(language: Language) { + switch language { + case .c, .cpp, .objective_c, .objective_cpp: + self = .clangd + case .swift: + self = .swift + default: + return nil + } + } + + init?(symbolProvider: SymbolProviderKind?) { + switch symbolProvider { + case .clang: self = .clangd + case .swift: self = .swift + case nil: return nil + } + } + + /// The `LanguageService` class used to provide functionality for this language class. + var serverType: LanguageService.Type { + switch self { + case .clangd: + return ClangLanguageService.self + case .swift: + return SwiftLanguageService.self + } + } +} diff --git a/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift b/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift new file mode 100644 index 000000000..79c09bc5f --- /dev/null +++ b/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift @@ -0,0 +1,233 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import LSPLogging +import LanguageServerProtocol +import SKSupport + +/// A lightweight way of describing tasks that are created from handling LSP +/// requests or notifications for the purpose of dependency tracking. +enum MessageHandlingDependencyTracker: DependencyTracker { + /// A task that changes the global configuration of sourcekit-lsp in any way. + /// + /// No other tasks must execute simultaneously with this task since they + /// might be relying on this task to take effect. + case globalConfigurationChange + + /// A request that depends on the state of all documents. + /// + /// These requests wait for `documentUpdate` tasks for all documents to finish before being executed. + /// + /// Requests that only read the semantic index and are not affected by changes to the in-memory file contents should + /// `freestanding` requests. + case workspaceRequest + + /// Changes the contents of the document with the given URI. + /// + /// Any other updates or requests to this document must wait for the + /// document update to finish before being executed + case documentUpdate(DocumentURI) + + /// A request that concerns one document. + /// + /// Any updates to this document must be processed before the document + /// request can be handled. Multiple requests to the same document can be + /// handled simultaneously. + case documentRequest(DocumentURI) + + /// A request that doesn't have any dependencies other than global + /// configuration changes. + case freestanding + + /// Whether this request needs to finish before `other` can start executing. + func isDependency(of other: MessageHandlingDependencyTracker) -> Bool { + switch (self, other) { + // globalConfigurationChange + case (.globalConfigurationChange, _): return true + case (_, .globalConfigurationChange): return true + + // globalDocumentState + case (.workspaceRequest, .workspaceRequest): return false + case (.documentUpdate, .workspaceRequest): return true + case (.workspaceRequest, .documentUpdate): return true + case (.workspaceRequest, .documentRequest): return false + case (.documentRequest, .workspaceRequest): return false + + // documentUpdate + case (.documentUpdate(let selfUri), .documentUpdate(let otherUri)): + return selfUri == otherUri + case (.documentUpdate(let selfUri), .documentRequest(let otherUri)): + return selfUri == otherUri + case (.documentRequest(let selfUri), .documentUpdate(let otherUri)): + return selfUri == otherUri + + // documentRequest + case (.documentRequest, .documentRequest): + return false + + // freestanding + case (.freestanding, _): + return false + case (_, .freestanding): + return false + } + } + + init(_ notification: any NotificationType) { + switch notification { + case is CancelRequestNotification: + self = .freestanding + case is CancelWorkDoneProgressNotification: + self = .freestanding + case is DidChangeConfigurationNotification: + self = .globalConfigurationChange + case let notification as DidChangeNotebookDocumentNotification: + self = .documentUpdate(notification.notebookDocument.uri) + case let notification as DidChangeTextDocumentNotification: + self = .documentUpdate(notification.textDocument.uri) + case is DidChangeWatchedFilesNotification: + self = .globalConfigurationChange + case is DidChangeWorkspaceFoldersNotification: + self = .globalConfigurationChange + case let notification as DidCloseNotebookDocumentNotification: + self = .documentUpdate(notification.notebookDocument.uri) + case let notification as DidCloseTextDocumentNotification: + self = .documentUpdate(notification.textDocument.uri) + case is DidCreateFilesNotification: + self = .freestanding + case is DidDeleteFilesNotification: + self = .freestanding + case let notification as DidOpenNotebookDocumentNotification: + self = .documentUpdate(notification.notebookDocument.uri) + case let notification as DidOpenTextDocumentNotification: + self = .documentUpdate(notification.textDocument.uri) + case is DidRenameFilesNotification: + self = .freestanding + case let notification as DidSaveNotebookDocumentNotification: + self = .documentUpdate(notification.notebookDocument.uri) + case let notification as DidSaveTextDocumentNotification: + self = .documentUpdate(notification.textDocument.uri) + case is ExitNotification: + self = .globalConfigurationChange + case is InitializedNotification: + self = .globalConfigurationChange + case is LogMessageNotification: + self = .freestanding + case is LogTraceNotification: + self = .freestanding + case is PublishDiagnosticsNotification: + self = .freestanding + case is SetTraceNotification: + self = .globalConfigurationChange + case is ShowMessageNotification: + self = .freestanding + case let notification as WillSaveTextDocumentNotification: + self = .documentUpdate(notification.textDocument.uri) + case is WorkDoneProgress: + self = .freestanding + default: + logger.error( + """ + Unknown notification \(type(of: notification)). Treating as a freestanding notification. \ + This might lead to out-of-order request handling + """ + ) + self = .freestanding + } + } + + init(_ request: any RequestType) { + switch request { + case let request as any TextDocumentRequest: self = .documentRequest(request.textDocument.uri) + case is ApplyEditRequest: + self = .freestanding + case is BarrierRequest: + self = .globalConfigurationChange + case is CallHierarchyIncomingCallsRequest: + self = .freestanding + case is CallHierarchyOutgoingCallsRequest: + self = .freestanding + case is CodeActionResolveRequest: + self = .freestanding + case is CodeLensRefreshRequest: + self = .freestanding + case is CodeLensResolveRequest: + self = .freestanding + case is CompletionItemResolveRequest: + self = .freestanding + case is CreateWorkDoneProgressRequest: + self = .freestanding + case is DiagnosticsRefreshRequest: + self = .freestanding + case is DocumentLinkResolveRequest: + self = .freestanding + case let request as ExecuteCommandRequest: + if let uri = request.textDocument?.uri { + self = .documentRequest(uri) + } else { + self = .freestanding + } + case is InitializeRequest: + self = .globalConfigurationChange + case is InlayHintRefreshRequest: + self = .freestanding + case is InlayHintResolveRequest: + self = .freestanding + case is InlineValueRefreshRequest: + self = .freestanding + case is PollIndexRequest: + self = .globalConfigurationChange + case is RenameRequest: + // Rename might touch multiple files. Make it a global configuration change so that edits to all files that might + // be affected have been processed. + self = .globalConfigurationChange + case is RegisterCapabilityRequest: + self = .globalConfigurationChange + case is ShowMessageRequest: + self = .freestanding + case is ShutdownRequest: + self = .globalConfigurationChange + case is TypeHierarchySubtypesRequest: + self = .freestanding + case is TypeHierarchySupertypesRequest: + self = .freestanding + case is UnregisterCapabilityRequest: + self = .globalConfigurationChange + case is WillCreateFilesRequest: + self = .freestanding + case is WillDeleteFilesRequest: + self = .freestanding + case is WillRenameFilesRequest: + self = .freestanding + case is WorkspaceDiagnosticsRequest: + self = .freestanding + case is WorkspaceFoldersRequest: + self = .freestanding + case is WorkspaceSemanticTokensRefreshRequest: + self = .freestanding + case is WorkspaceSymbolResolveRequest: + self = .freestanding + case is WorkspaceSymbolsRequest: + self = .freestanding + case is WorkspaceTestsRequest: + self = .workspaceRequest + default: + logger.error( + """ + Unknown request \(type(of: request)). Treating as a freestanding request. \ + This might lead to out-of-order request handling + """ + ) + self = .freestanding + } + } +} diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 644b3f093..d69b82796 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -34,41 +34,6 @@ public typealias URL = Foundation.URL /// Disambiguate LanguageServerProtocol.Language and IndexstoreDB.Language public typealias Language = LanguageServerProtocol.Language -/// Exhaustive enumeration of all toolchain language servers known to SourceKit-LSP. -enum LanguageServerType: Hashable { - case clangd - case swift - - init?(language: Language) { - switch language { - case .c, .cpp, .objective_c, .objective_cpp: - self = .clangd - case .swift: - self = .swift - default: - return nil - } - } - - init?(symbolProvider: SymbolProviderKind?) { - switch symbolProvider { - case .clang: self = .clangd - case .swift: self = .swift - case nil: return nil - } - } - - /// The `LanguageService` class used to provide functionality for this language class. - var serverType: LanguageService.Type { - switch self { - case .clangd: - return ClangLanguageService.self - case .swift: - return SwiftLanguageService.self - } - } -} - /// A request and a callback that returns the request's reply fileprivate final class RequestAndReply: Sendable { let params: Params @@ -99,330 +64,6 @@ fileprivate final class RequestAndReply: Sendable { } } -/// Keeps track of the state to send work done progress updates to the client -final actor WorkDoneProgressState { - private enum State { - /// No `WorkDoneProgress` has been created. - case noProgress - /// We have sent the request to create a `WorkDoneProgress` but haven’t received a response yet. - case creating - /// A `WorkDoneProgress` has been created. - case created - /// The creation of a `WorkDoneProgress has failed`. - /// - /// This causes us to just give up creating any more `WorkDoneProgress` in - /// the future as those will most likely also fail. - case progressCreationFailed - } - - /// A queue so we can have synchronous `startProgress` and `endProgress` functions that don't need to wait for the - /// work done progress to be started or ended. - private let queue = AsyncQueue() - - /// How many active tasks are running. - /// - /// A work done progress should be displayed if activeTasks > 0 - private var activeTasks: Int = 0 - private var state: State = .noProgress - - /// The token by which we track the `WorkDoneProgress`. - private let token: ProgressToken - - /// The title that should be displayed to the user in the UI. - private let title: String - - init(_ token: String, title: String) { - self.token = ProgressToken.string(token) - self.title = title - } - - /// Start a new task, creating a new `WorkDoneProgress` if none is running right now. - /// - /// - Parameter server: The server that is used to create the `WorkDoneProgress` on the client - nonisolated func startProgress(server: SourceKitLSPServer) { - queue.async { - await self.startProgressImpl(server: server) - } - } - - func startProgressImpl(server: SourceKitLSPServer) async { - await server.waitUntilInitialized() - activeTasks += 1 - guard await server.capabilityRegistry?.clientCapabilities.window?.workDoneProgress ?? false else { - return - } - if state == .noProgress { - state = .creating - // Discard the handle. We don't support cancellation of the creation of a work done progress. - _ = server.client.send(CreateWorkDoneProgressRequest(token: token)) { result in - Task { - await self.handleCreateWorkDoneProgressResponse(result, server: server) - } - } - } - } - - private func handleCreateWorkDoneProgressResponse( - _ result: Result, - server: SourceKitLSPServer - ) { - if result.success != nil { - if self.activeTasks == 0 { - // ActiveTasks might have been decreased while we created the `WorkDoneProgress` - self.state = .noProgress - server.client.send(WorkDoneProgress(token: self.token, value: .end(WorkDoneProgressEnd()))) - } else { - self.state = .created - server.client.send( - WorkDoneProgress(token: self.token, value: .begin(WorkDoneProgressBegin(title: self.title))) - ) - } - } else { - self.state = .progressCreationFailed - } - } - - /// End a new task stated using `startProgress`. - /// - /// If this drops the active task count to 0, the work done progress is ended on the client. - /// - /// - Parameter server: The server that is used to send and update of the `WorkDoneProgress` to the client - nonisolated func endProgress(server: SourceKitLSPServer) { - queue.async { - await self.endProgressImpl(server: server) - } - } - - func endProgressImpl(server: SourceKitLSPServer) async { - assert(activeTasks > 0, "Unbalanced startProgress/endProgress calls") - activeTasks -= 1 - guard await server.capabilityRegistry?.clientCapabilities.window?.workDoneProgress ?? false else { - return - } - if state == .created && activeTasks == 0 { - server.client.send(WorkDoneProgress(token: token, value: .end(WorkDoneProgressEnd()))) - } - } -} - -/// A lightweight way of describing tasks that are created from handling LSP -/// requests or notifications for the purpose of dependency tracking. -fileprivate enum TaskMetadata: DependencyTracker { - /// A task that changes the global configuration of sourcekit-lsp in any way. - /// - /// No other tasks must execute simultaneously with this task since they - /// might be relying on this task to take effect. - case globalConfigurationChange - - /// A request that depends on the state of all documents. - /// - /// These requests wait for `documentUpdate` tasks for all documents to finish before being executed. - /// - /// Requests that only read the semantic index and are not affected by changes to the in-memory file contents should - /// `freestanding` requests. - case workspaceRequest - - /// Changes the contents of the document with the given URI. - /// - /// Any other updates or requests to this document must wait for the - /// document update to finish before being executed - case documentUpdate(DocumentURI) - - /// A request that concerns one document. - /// - /// Any updates to this document must be processed before the document - /// request can be handled. Multiple requests to the same document can be - /// handled simultaneously. - case documentRequest(DocumentURI) - - /// A request that doesn't have any dependencies other than global - /// configuration changes. - case freestanding - - /// Whether this request needs to finish before `other` can start executing. - func isDependency(of other: TaskMetadata) -> Bool { - switch (self, other) { - // globalConfigurationChange - case (.globalConfigurationChange, _): return true - case (_, .globalConfigurationChange): return true - - // globalDocumentState - case (.workspaceRequest, .workspaceRequest): return false - case (.documentUpdate, .workspaceRequest): return true - case (.workspaceRequest, .documentUpdate): return true - case (.workspaceRequest, .documentRequest): return false - case (.documentRequest, .workspaceRequest): return false - - // documentUpdate - case (.documentUpdate(let selfUri), .documentUpdate(let otherUri)): - return selfUri == otherUri - case (.documentUpdate(let selfUri), .documentRequest(let otherUri)): - return selfUri == otherUri - case (.documentRequest(let selfUri), .documentUpdate(let otherUri)): - return selfUri == otherUri - - // documentRequest - case (.documentRequest, .documentRequest): - return false - - // freestanding - case (.freestanding, _): - return false - case (_, .freestanding): - return false - } - } - - init(_ notification: any NotificationType) { - switch notification { - case is CancelRequestNotification: - self = .freestanding - case is CancelWorkDoneProgressNotification: - self = .freestanding - case is DidChangeConfigurationNotification: - self = .globalConfigurationChange - case let notification as DidChangeNotebookDocumentNotification: - self = .documentUpdate(notification.notebookDocument.uri) - case let notification as DidChangeTextDocumentNotification: - self = .documentUpdate(notification.textDocument.uri) - case is DidChangeWatchedFilesNotification: - self = .globalConfigurationChange - case is DidChangeWorkspaceFoldersNotification: - self = .globalConfigurationChange - case let notification as DidCloseNotebookDocumentNotification: - self = .documentUpdate(notification.notebookDocument.uri) - case let notification as DidCloseTextDocumentNotification: - self = .documentUpdate(notification.textDocument.uri) - case is DidCreateFilesNotification: - self = .freestanding - case is DidDeleteFilesNotification: - self = .freestanding - case let notification as DidOpenNotebookDocumentNotification: - self = .documentUpdate(notification.notebookDocument.uri) - case let notification as DidOpenTextDocumentNotification: - self = .documentUpdate(notification.textDocument.uri) - case is DidRenameFilesNotification: - self = .freestanding - case let notification as DidSaveNotebookDocumentNotification: - self = .documentUpdate(notification.notebookDocument.uri) - case let notification as DidSaveTextDocumentNotification: - self = .documentUpdate(notification.textDocument.uri) - case is ExitNotification: - self = .globalConfigurationChange - case is InitializedNotification: - self = .globalConfigurationChange - case is LogMessageNotification: - self = .freestanding - case is LogTraceNotification: - self = .freestanding - case is PublishDiagnosticsNotification: - self = .freestanding - case is SetTraceNotification: - self = .globalConfigurationChange - case is ShowMessageNotification: - self = .freestanding - case let notification as WillSaveTextDocumentNotification: - self = .documentUpdate(notification.textDocument.uri) - case is WorkDoneProgress: - self = .freestanding - default: - logger.error( - """ - Unknown notification \(type(of: notification)). Treating as a freestanding notification. \ - This might lead to out-of-order request handling - """ - ) - self = .freestanding - } - } - - init(_ request: any RequestType) { - switch request { - case let request as any TextDocumentRequest: self = .documentRequest(request.textDocument.uri) - case is ApplyEditRequest: - self = .freestanding - case is BarrierRequest: - self = .globalConfigurationChange - case is CallHierarchyIncomingCallsRequest: - self = .freestanding - case is CallHierarchyOutgoingCallsRequest: - self = .freestanding - case is CodeActionResolveRequest: - self = .freestanding - case is CodeLensRefreshRequest: - self = .freestanding - case is CodeLensResolveRequest: - self = .freestanding - case is CompletionItemResolveRequest: - self = .freestanding - case is CreateWorkDoneProgressRequest: - self = .freestanding - case is DiagnosticsRefreshRequest: - self = .freestanding - case is DocumentLinkResolveRequest: - self = .freestanding - case let request as ExecuteCommandRequest: - if let uri = request.textDocument?.uri { - self = .documentRequest(uri) - } else { - self = .freestanding - } - case is InitializeRequest: - self = .globalConfigurationChange - case is InlayHintRefreshRequest: - self = .freestanding - case is InlayHintResolveRequest: - self = .freestanding - case is InlineValueRefreshRequest: - self = .freestanding - case is PollIndexRequest: - self = .globalConfigurationChange - case is RenameRequest: - // Rename might touch multiple files. Make it a global configuration change so that edits to all files that might - // be affected have been processed. - self = .globalConfigurationChange - case is RegisterCapabilityRequest: - self = .globalConfigurationChange - case is ShowMessageRequest: - self = .freestanding - case is ShutdownRequest: - self = .globalConfigurationChange - case is TypeHierarchySubtypesRequest: - self = .freestanding - case is TypeHierarchySupertypesRequest: - self = .freestanding - case is UnregisterCapabilityRequest: - self = .globalConfigurationChange - case is WillCreateFilesRequest: - self = .freestanding - case is WillDeleteFilesRequest: - self = .freestanding - case is WillRenameFilesRequest: - self = .freestanding - case is WorkspaceDiagnosticsRequest: - self = .freestanding - case is WorkspaceFoldersRequest: - self = .freestanding - case is WorkspaceSemanticTokensRefreshRequest: - self = .freestanding - case is WorkspaceSymbolResolveRequest: - self = .freestanding - case is WorkspaceSymbolsRequest: - self = .freestanding - case is WorkspaceTestsRequest: - self = .workspaceRequest - default: - logger.error( - """ - Unknown request \(type(of: request)). Treating as a freestanding request. \ - This might lead to out-of-order request handling - """ - ) - self = .freestanding - } - } -} - /// The SourceKit-LSP server. /// /// This is the client-facing language server implementation, providing indexing, multiple-toolchain @@ -438,7 +79,7 @@ public actor SourceKitLSPServer { /// have forwarded the request to clangd. /// /// The actual semantic handling of the message happens off this queue. - private let messageHandlingQueue = AsyncQueue() + private let messageHandlingQueue = AsyncQueue() /// The queue on which we start and stop keeping track of cancellation. /// @@ -507,6 +148,9 @@ public actor SourceKitLSPServer { private var workspacesAndIsImplicit: [(workspace: Workspace, isImplicit: Bool)] = [] { didSet { uriToWorkspaceCache = [:] + // `indexProgressManager` iterates over all workspaces in the SourceKitLSPServer. Modifying workspaces might thus + // update the index progress status. + indexProgressManager.indexProgressStatusDidChange() } } @@ -590,11 +234,20 @@ public actor SourceKitLSPServer { // The latter might happen if there is an existing SwiftPM workspace that hasn't been reloaded after a new file // was added to it and thus currently doesn't know that it can handle that file. In that case, we shouldn't open // a new workspace for the same root. Instead, the existing workspace's build system needs to be reloaded. - if let workspace = await self.createWorkspace(WorkspaceFolder(uri: DocumentURI(url))), - await workspace.buildSystemManager.fileHandlingCapability(for: uri) == .handled, - let projectRoot = await workspace.buildSystemManager.projectRoot, - !projectRoots.contains(projectRoot) - { + let workspace = await self.createWorkspace(WorkspaceFolder(uri: DocumentURI(url))) { buildSystem in + guard let buildSystem, !projectRoots.contains(await buildSystem.projectRoot) else { + // If we didn't create a build system, `url` is not capable of handling the document. + // If we already have a workspace at the same project root, don't create another one. + return false + } + do { + try await buildSystem.generateBuildGraph(allowFileSystemWrites: false) + } catch { + return false + } + return await buildSystem.fileHandlingCapability(for: uri) == .handled + } + if let workspace { return workspace } url.deleteLastPathComponent() @@ -886,7 +539,7 @@ extension SourceKitLSPServer: MessageHandler { .makeSignposter() let signpostID = signposter.makeSignpostID() let state = signposter.beginInterval("Notification", id: signpostID, "\(type(of: params))") - messageHandlingQueue.async(metadata: TaskMetadata(params)) { + messageHandlingQueue.async(metadata: MessageHandlingDependencyTracker(params)) { signposter.emitEvent("Start handling", id: signpostID) // Only use the last two digits of the notification ID for the logging scope to avoid creating too many scopes. @@ -921,7 +574,7 @@ extension SourceKitLSPServer: MessageHandler { await self.withLanguageServiceAndWorkspace(for: notification, notificationHandler: self.willSaveDocument) case let notification as DidSaveTextDocumentNotification: await self.withLanguageServiceAndWorkspace(for: notification, notificationHandler: self.didSaveDocument) - // IMPORTANT: When adding a new entry to this switch, also add it to the `TaskMetadata` initializer. + // IMPORTANT: When adding a new entry to this switch, also add it to the `MessageHandlingDependencyTracker` initializer. default: break } @@ -936,7 +589,7 @@ extension SourceKitLSPServer: MessageHandler { let signpostID = signposter.makeSignpostID() let state = signposter.beginInterval("Request", id: signpostID, "\(R.self)") - let task = messageHandlingQueue.async(metadata: TaskMetadata(params)) { + let task = messageHandlingQueue.async(metadata: MessageHandlingDependencyTracker(params)) { signposter.emitEvent("Start handling", id: signpostID) // Only use the last two digits of the request ID for the logging scope to avoid creating too many scopes. // See comment in `withLoggingScope`. @@ -1082,7 +735,7 @@ extension SourceKitLSPServer: MessageHandler { await self.handleRequest(for: request, requestHandler: self.prepareRename) case let request as RequestAndReply: await self.handleRequest(for: request, requestHandler: self.indexedRename) - // IMPORTANT: When adding a new entry to this switch, also add it to the `TaskMetadata` initializer. + // IMPORTANT: When adding a new entry to this switch, also add it to the `MessageHandlingDependencyTracker` initializer. default: await request.reply { throw ResponseError.methodNotFound(R.method) } } @@ -1222,25 +875,22 @@ extension SourceKitLSPServer { } /// Creates a workspace at the given `uri`. - private func createWorkspace(_ workspaceFolder: WorkspaceFolder) async -> Workspace? { + /// + /// If the build system that was determined for the workspace does not satisfy `condition`, `nil` is returned. + private func createWorkspace( + _ workspaceFolder: WorkspaceFolder, + condition: (BuildSystem?) async -> Bool = { _ in true } + ) async -> Workspace? { guard let capabilityRegistry = capabilityRegistry else { logger.log("Cannot open workspace before server is initialized") return nil } var options = self.options options.buildSetup = self.options.buildSetup.merging(buildSetup(for: workspaceFolder)) - return try? await Workspace( - documentManager: self.documentManager, + let buildSystem = await createBuildSystem( rootUri: workspaceFolder.uri, - capabilityRegistry: capabilityRegistry, - toolchainRegistry: self.toolchainRegistry, options: options, - compilationDatabaseSearchPaths: self.options.compilationDatabaseSearchPaths, - indexOptions: self.options.indexOptions, - indexTaskScheduler: indexTaskScheduler, - indexProcessDidProduceResult: { [weak self] in - self?.indexTaskDidProduceResult($0) - }, + toolchainRegistry: toolchainRegistry, reloadPackageStatusCallback: { [weak self] status in guard let self else { return } switch status { @@ -1249,12 +899,40 @@ extension SourceKitLSPServer { case .end: await self.packageLoadingWorkDoneProgress.endProgress(server: self) } + } + ) + guard await condition(buildSystem) else { + return nil + } + do { + try await buildSystem?.generateBuildGraph(allowFileSystemWrites: true) + } catch { + logger.error("failed to generate build graph at \(workspaceFolder.uri.forLogging): \(error.forLogging)") + return nil + } + + let projectRoot = await buildSystem?.projectRoot.pathString + logger.log( + "Created workspace at \(workspaceFolder.uri.forLogging) as \(type(of: buildSystem)) with project root \(projectRoot ?? "")" + ) + + return try? await Workspace( + documentManager: self.documentManager, + rootUri: workspaceFolder.uri, + capabilityRegistry: capabilityRegistry, + buildSystem: buildSystem, + toolchainRegistry: self.toolchainRegistry, + options: options, + indexOptions: self.options.indexOptions, + indexTaskScheduler: indexTaskScheduler, + indexProcessDidProduceResult: { [weak self] in + self?.indexTaskDidProduceResult($0) }, indexTasksWereScheduled: { [weak self] count in self?.indexProgressManager.indexTasksWereScheduled(count: count) }, - indexStatusDidChange: { [weak self] in - self?.indexProgressManager.indexStatusDidChange() + indexProgressStatusDidChange: { [weak self] in + self?.indexProgressManager.indexProgressStatusDidChange() } ) } @@ -1319,8 +997,8 @@ extension SourceKitLSPServer { indexTasksWereScheduled: { [weak self] count in self?.indexProgressManager.indexTasksWereScheduled(count: count) }, - indexStatusDidChange: { [weak self] in - self?.indexProgressManager.indexStatusDidChange() + indexProgressStatusDidChange: { [weak self] in + self?.indexProgressManager.indexProgressStatusDidChange() } ) @@ -1470,7 +1148,7 @@ extension SourceKitLSPServer { // dynamic registration of watch patterns. // This must be a superset of the files that return true for SwiftPM's `Workspace.fileAffectsSwiftOrClangBuildSettings`. var watchers = FileRuleDescription.builtinRules.flatMap({ $0.fileTypes }).map { fileExtension in - return FileSystemWatcher(globPattern: "**/*.\(fileExtension)", kind: [.create, .delete]) + return FileSystemWatcher(globPattern: "**/*.\(fileExtension)", kind: [.create, .change, .delete]) } watchers.append(FileSystemWatcher(globPattern: "**/Package.swift", kind: [.change])) watchers.append(FileSystemWatcher(globPattern: "**/compile_commands.json", kind: [.create, .change, .delete])) diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index 91b4b2b66..a5be207a2 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -17,6 +17,7 @@ import LSPLogging import LanguageServerProtocol import SKCore import SKSupport +import SemanticIndex import SourceKitD import SwiftParser import SwiftParserDiagnostics @@ -123,6 +124,9 @@ public actor SwiftLanguageService: LanguageService, Sendable { let syntaxTreeManager = SyntaxTreeManager() + /// The `semanticIndexManager` of the workspace this language service was created for. + private let semanticIndexManager: SemanticIndexManager? + nonisolated var keys: sourcekitd_api_keys { return sourcekitd.keys } nonisolated var requests: sourcekitd_api_requests { return sourcekitd.requests } nonisolated var values: sourcekitd_api_values { return sourcekitd.values } @@ -192,6 +196,7 @@ public actor SwiftLanguageService: LanguageService, Sendable { self.swiftFormat = toolchain.swiftFormat self.sourcekitd = try await DynamicallyLoadedSourceKitD.getOrCreate(dylibPath: sourcekitd) self.capabilityRegistry = workspace.capabilityRegistry + self.semanticIndexManager = workspace.semanticIndexManager self.serverOptions = options self.documentManager = DocumentManager() self.state = .connected @@ -326,13 +331,7 @@ extension SwiftLanguageService { cancelInFlightPublishDiagnosticsTask(for: snapshot.uri) await diagnosticReportManager.removeItemsFromCache(with: snapshot.uri) - let keys = self.keys - let path = snapshot.uri.pseudoPath - - let closeReq = sourcekitd.dictionary([ - keys.request: requests.editorClose, - keys.name: path, - ]) + let closeReq = closeDocumentSourcekitdRequest(uri: snapshot.uri) _ = try? await self.sourcekitd.send(closeReq, fileContents: nil) let openReq = openDocumentSourcekitdRequest(snapshot: snapshot, compileCommand: compileCmd) @@ -382,6 +381,13 @@ extension SwiftLanguageService { keys.enableDiagnostics: 0, keys.syntacticOnly: 1, keys.compilerArgs: compileCommand?.compilerArgs as [SKDRequestValue]?, + ]) + } + + private func closeDocumentSourcekitdRequest(uri: DocumentURI) -> SKDRequestDictionary { + return sourcekitd.dictionary([ + keys.request: requests.editorClose, + keys.name: uri.pseudoPath, keys.cancelBuilds: 0, ]) } @@ -421,17 +427,9 @@ extension SwiftLanguageService { inFlightPublishDiagnosticsTasks[note.textDocument.uri] = nil await diagnosticReportManager.removeItemsFromCache(with: note.textDocument.uri) - let keys = self.keys - self.documentManager.close(note) - let uri = note.textDocument.uri - - let req = sourcekitd.dictionary([ - keys.request: self.requests.editorClose, - keys.name: uri.pseudoPath, - ]) - + let req = closeDocumentSourcekitdRequest(uri: note.textDocument.uri) _ = try? await self.sourcekitd.send(req, fileContents: nil) } @@ -888,6 +886,7 @@ extension SwiftLanguageService { public func documentDiagnostic(_ req: DocumentDiagnosticsRequest) async throws -> DocumentDiagnosticReport { do { + await semanticIndexManager?.prepareFileForEditorFunctionality(req.textDocument.uri) let snapshot = try documentManager.latestSnapshot(req.textDocument.uri) let buildSettings = await self.buildSettings(for: req.textDocument.uri) let diagnosticReport = try await self.diagnosticReportManager.diagnosticReport( diff --git a/Sources/SourceKitLSP/WorkDoneProgressManager.swift b/Sources/SourceKitLSP/WorkDoneProgressManager.swift index c0616aaeb..bd07a5cd1 100644 --- a/Sources/SourceKitLSP/WorkDoneProgressManager.swift +++ b/Sources/SourceKitLSP/WorkDoneProgressManager.swift @@ -36,6 +36,10 @@ final class WorkDoneProgressManager { /// - This should have `workDoneProgressCreated == true` so that it can send the work progress end. private let workDoneProgressCreated: ThreadSafeBox & AnyObject = ThreadSafeBox(initialValue: false) + /// The last message and percentage so we don't send a new report notification to the client if `update` is called + /// without any actual change. + private var lastStatus: (message: String?, percentage: Int?) + convenience init?(server: SourceKitLSPServer, title: String, message: String? = nil, percentage: Int? = nil) async { guard let capabilityRegistry = await server.capabilityRegistry else { return nil @@ -69,6 +73,7 @@ final class WorkDoneProgressManager { ) ) workDoneProgressCreated.value = true + self.lastStatus = (message, percentage) } } @@ -77,6 +82,10 @@ final class WorkDoneProgressManager { guard workDoneProgressCreated.value else { return } + guard (message, percentage) != self.lastStatus else { + return + } + self.lastStatus = (message, percentage) server.sendNotificationToClient( WorkDoneProgress( token: token, diff --git a/Sources/SourceKitLSP/WorkDoneProgressState.swift b/Sources/SourceKitLSP/WorkDoneProgressState.swift new file mode 100644 index 000000000..c6022662a --- /dev/null +++ b/Sources/SourceKitLSP/WorkDoneProgressState.swift @@ -0,0 +1,121 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import LanguageServerProtocol +import SKSupport + +/// Keeps track of the state to send work done progress updates to the client +final actor WorkDoneProgressState { + private enum State { + /// No `WorkDoneProgress` has been created. + case noProgress + /// We have sent the request to create a `WorkDoneProgress` but haven’t received a response yet. + case creating + /// A `WorkDoneProgress` has been created. + case created + /// The creation of a `WorkDoneProgress has failed`. + /// + /// This causes us to just give up creating any more `WorkDoneProgress` in + /// the future as those will most likely also fail. + case progressCreationFailed + } + + /// A queue so we can have synchronous `startProgress` and `endProgress` functions that don't need to wait for the + /// work done progress to be started or ended. + private let queue = AsyncQueue() + + /// How many active tasks are running. + /// + /// A work done progress should be displayed if activeTasks > 0 + private var activeTasks: Int = 0 + private var state: State = .noProgress + + /// The token by which we track the `WorkDoneProgress`. + private let token: ProgressToken + + /// The title that should be displayed to the user in the UI. + private let title: String + + init(_ token: String, title: String) { + self.token = ProgressToken.string(token) + self.title = title + } + + /// Start a new task, creating a new `WorkDoneProgress` if none is running right now. + /// + /// - Parameter server: The server that is used to create the `WorkDoneProgress` on the client + nonisolated func startProgress(server: SourceKitLSPServer) { + queue.async { + await self.startProgressImpl(server: server) + } + } + + func startProgressImpl(server: SourceKitLSPServer) async { + await server.waitUntilInitialized() + activeTasks += 1 + guard await server.capabilityRegistry?.clientCapabilities.window?.workDoneProgress ?? false else { + return + } + if state == .noProgress { + state = .creating + // Discard the handle. We don't support cancellation of the creation of a work done progress. + _ = server.client.send(CreateWorkDoneProgressRequest(token: token)) { result in + Task { + await self.handleCreateWorkDoneProgressResponse(result, server: server) + } + } + } + } + + private func handleCreateWorkDoneProgressResponse( + _ result: Result, + server: SourceKitLSPServer + ) { + if result.success != nil { + if self.activeTasks == 0 { + // ActiveTasks might have been decreased while we created the `WorkDoneProgress` + self.state = .noProgress + server.client.send(WorkDoneProgress(token: self.token, value: .end(WorkDoneProgressEnd()))) + } else { + self.state = .created + server.client.send( + WorkDoneProgress(token: self.token, value: .begin(WorkDoneProgressBegin(title: self.title))) + ) + } + } else { + self.state = .progressCreationFailed + } + } + + /// End a new task stated using `startProgress`. + /// + /// If this drops the active task count to 0, the work done progress is ended on the client. + /// + /// - Parameter server: The server that is used to send and update of the `WorkDoneProgress` to the client + nonisolated func endProgress(server: SourceKitLSPServer) { + queue.async { + await self.endProgressImpl(server: server) + } + } + + func endProgressImpl(server: SourceKitLSPServer) async { + assert(activeTasks > 0, "Unbalanced startProgress/endProgress calls") + activeTasks -= 1 + guard await server.capabilityRegistry?.clientCapabilities.window?.workDoneProgress ?? false else { + return + } + if state == .created && activeTasks == 0 { + server.client.send(WorkDoneProgress(token: token, value: .end(WorkDoneProgressEnd()))) + self.state = .noProgress + } + } +} diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index f913520e5..65dfeb50a 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -15,7 +15,6 @@ import LSPLogging import LanguageServerProtocol import SKCore import SKSupport -import SKSwiftPMWorkspace import SemanticIndex import struct TSCBasic.AbsolutePath @@ -96,7 +95,7 @@ public final class Workspace: Sendable { indexTaskScheduler: TaskScheduler, indexProcessDidProduceResult: @escaping @Sendable (IndexProcessResult) -> Void, indexTasksWereScheduled: @escaping @Sendable (Int) -> Void, - indexStatusDidChange: @escaping @Sendable () -> Void + indexProgressStatusDidChange: @escaping @Sendable () -> Void ) async { self.documentManager = documentManager self.buildSetup = options.buildSetup @@ -117,7 +116,7 @@ public final class Workspace: Sendable { indexTaskScheduler: indexTaskScheduler, indexProcessDidProduceResult: indexProcessDidProduceResult, indexTasksWereScheduled: indexTasksWereScheduled, - indexStatusDidChange: indexStatusDidChange + indexProgressStatusDidChange: indexProgressStatusDidChange ) } else { self.semanticIndexManager = nil @@ -148,82 +147,15 @@ public final class Workspace: Sendable { documentManager: DocumentManager, rootUri: DocumentURI, capabilityRegistry: CapabilityRegistry, + buildSystem: BuildSystem?, toolchainRegistry: ToolchainRegistry, options: SourceKitLSPServer.Options, - compilationDatabaseSearchPaths: [RelativePath], indexOptions: IndexOptions = IndexOptions(), indexTaskScheduler: TaskScheduler, indexProcessDidProduceResult: @escaping @Sendable (IndexProcessResult) -> Void, - reloadPackageStatusCallback: @Sendable @escaping (ReloadPackageStatus) async -> Void, indexTasksWereScheduled: @Sendable @escaping (Int) -> Void, - indexStatusDidChange: @Sendable @escaping () -> Void + indexProgressStatusDidChange: @Sendable @escaping () -> Void ) async throws { - var buildSystem: BuildSystem? = nil - - if let rootUrl = rootUri.fileURL, let rootPath = try? AbsolutePath(validating: rootUrl.path) { - var options = options - var isForIndexBuild = false - if options.indexOptions.enableBackgroundIndexing, options.buildSetup.path == nil { - options.buildSetup.path = rootPath.appending(component: ".index-build") - isForIndexBuild = true - } - func createSwiftPMBuildSystem(rootUrl: URL) async -> SwiftPMBuildSystem? { - return await SwiftPMBuildSystem( - url: rootUrl, - toolchainRegistry: toolchainRegistry, - buildSetup: options.buildSetup, - isForIndexBuild: isForIndexBuild, - reloadPackageStatusCallback: reloadPackageStatusCallback - ) - } - - func createCompilationDatabaseBuildSystem(rootPath: AbsolutePath) -> CompilationDatabaseBuildSystem? { - return CompilationDatabaseBuildSystem( - projectRoot: rootPath, - searchPaths: compilationDatabaseSearchPaths - ) - } - - func createBuildServerBuildSystem(rootPath: AbsolutePath) async -> BuildServerBuildSystem? { - return await BuildServerBuildSystem(projectRoot: rootPath, buildSetup: options.buildSetup) - } - - let defaultBuildSystem: BuildSystem? = - switch options.buildSetup.defaultWorkspaceType { - case .buildServer: await createBuildServerBuildSystem(rootPath: rootPath) - case .compilationDatabase: createCompilationDatabaseBuildSystem(rootPath: rootPath) - case .swiftPM: await createSwiftPMBuildSystem(rootUrl: rootUrl) - case nil: nil - } - if let defaultBuildSystem { - buildSystem = defaultBuildSystem - } else if let buildServer = await createBuildServerBuildSystem(rootPath: rootPath) { - buildSystem = buildServer - } else if let swiftpm = await createSwiftPMBuildSystem(rootUrl: rootUrl) { - buildSystem = swiftpm - } else if let compdb = createCompilationDatabaseBuildSystem(rootPath: rootPath) { - buildSystem = compdb - } else { - buildSystem = nil - } - if let buildSystem { - let projectRoot = await buildSystem.projectRoot - logger.log( - "Opening workspace at \(rootUrl) as \(type(of: buildSystem)) with project root \(projectRoot.pathString)" - ) - } else { - logger.error( - "Could not set up a build system for workspace at '\(rootUri.forLogging)'" - ) - } - } else { - // We assume that workspaces are directories. This is only true for URLs not for URIs in general. - // Simply skip setting up the build integration in this case. - logger.error( - "cannot setup build integration for workspace at URI \(rootUri.forLogging) because the URI it is not a valid file URL" - ) - } - var index: IndexStoreDB? = nil var indexDelegate: SourceKitIndexDelegate? = nil @@ -263,7 +195,7 @@ public final class Workspace: Sendable { indexTaskScheduler: indexTaskScheduler, indexProcessDidProduceResult: indexProcessDidProduceResult, indexTasksWereScheduled: indexTasksWereScheduled, - indexStatusDidChange: indexStatusDidChange + indexProgressStatusDidChange: indexProgressStatusDidChange ) } diff --git a/Tests/SKCoreTests/BuildSystemManagerTests.swift b/Tests/SKCoreTests/BuildSystemManagerTests.swift index 763991ecd..5eb4fad08 100644 --- a/Tests/SKCoreTests/BuildSystemManagerTests.swift +++ b/Tests/SKCoreTests/BuildSystemManagerTests.swift @@ -474,7 +474,7 @@ class ManualBuildSystem: BuildSystem { throw PrepareNotSupportedError() } - public func generateBuildGraph() {} + public func generateBuildGraph(allowFileSystemWrites: Bool) {} public func topologicalSort(of targets: [ConfiguredTarget]) -> [ConfiguredTarget]? { return nil diff --git a/Tests/SKCoreTests/TaskSchedulerTests.swift b/Tests/SKCoreTests/TaskSchedulerTests.swift index 105c32048..d4e62c0f4 100644 --- a/Tests/SKCoreTests/TaskSchedulerTests.swift +++ b/Tests/SKCoreTests/TaskSchedulerTests.swift @@ -10,6 +10,7 @@ // //===----------------------------------------------------------------------===// +import LSPLogging import SKCore import SKTestSupport import XCTest @@ -54,8 +55,7 @@ final class TaskSchedulerTests: XCTestCase { } func testTasksWithElevatedPrioritiesGetExecutedFirst() async throws { - try XCTSkipIf(true, "rdar://128601797") - + try SkipUnless.platformSupportsTaskPriorityElevation() await runTaskScheduler( scheduleTasks: { scheduler, taskExecutionRecorder in for i in 0..<20 { @@ -262,7 +262,9 @@ fileprivate final class ClosureTaskDescription: TaskDescriptionProtocol { } func execute() async { + logger.debug("Starting execution of \(self) with priority \(Task.currentPriority.rawValue)") await closure() + logger.debug("Finished executing \(self) with priority \(Task.currentPriority.rawValue)") } func dependencies( diff --git a/Tests/SKSupportTests/AsyncUtilsTests.swift b/Tests/SKSupportTests/AsyncUtilsTests.swift new file mode 100644 index 000000000..dd95aadfe --- /dev/null +++ b/Tests/SKSupportTests/AsyncUtilsTests.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2018 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import LSPTestSupport +import SKSupport +import XCTest + +final class AsyncUtilsTests: XCTestCase { + func testWithTimeout() async throws { + let expectation = self.expectation(description: "withTimeout body finished") + await assertThrowsError( + try await withTimeout(.seconds(0.1)) { + try? await Task.sleep(for: .seconds(10)) + XCTAssert(Task.isCancelled) + expectation.fulfill() + } + ) { error in + XCTAssert(error is TimeoutError, "Received unexpected error \(error)") + } + try await fulfillmentOfOrThrow([expectation]) + } +} diff --git a/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift b/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift index 69cc4b0f2..92ec238dd 100644 --- a/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift +++ b/Tests/SKSwiftPMWorkspaceTests/SwiftPMBuildSystemTests.swift @@ -76,15 +76,14 @@ final class SwiftPMBuildSystemTests: XCTestCase { ) let packageRoot = tempDir.appending(component: "pkg") let tr = ToolchainRegistry.forTesting - await assertThrowsError( - try await SwiftPMBuildSystem( - workspacePath: packageRoot, - toolchainRegistry: tr, - fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, - isForIndexBuild: false - ) + let buildSystem = try await SwiftPMBuildSystem( + workspacePath: packageRoot, + toolchainRegistry: tr, + fileSystem: fs, + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + isForIndexBuild: false ) + await assertThrowsError(try await buildSystem.generateBuildGraph(allowFileSystemWrites: false)) } } @@ -140,6 +139,7 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let aswift = packageRoot.appending(components: "Sources", "lib", "a.swift") let hostTriple = await swiftpmBuildSystem.buildParameters.triple @@ -147,7 +147,8 @@ final class SwiftPMBuildSystemTests: XCTestCase { assertEqual(await swiftpmBuildSystem.buildPath, build) assertNotNil(await swiftpmBuildSystem.indexStorePath) - let arguments = try await swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)!.compilerArguments + let arguments = try await unwrap(swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)) + .compilerArguments assertArgumentsContain("-module-name", "lib", arguments: arguments) assertArgumentsContain("-emit-dependencies", arguments: arguments) @@ -209,13 +210,15 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: config, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let aswift = packageRoot.appending(components: "Sources", "lib", "a.swift") let hostTriple = await swiftpmBuildSystem.buildParameters.triple let build = buildPath(root: packageRoot, config: config, platform: hostTriple.platformBuildPathComponent) assertEqual(await swiftpmBuildSystem.buildPath, build) - let arguments = try await swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)!.compilerArguments + let arguments = try await unwrap(swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)) + .compilerArguments assertArgumentsContain("-typecheck", arguments: arguments) assertArgumentsContain("-Xcc", "-m32", arguments: arguments) @@ -247,9 +250,11 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let source = try resolveSymlinks(packageRoot.appending(component: "Package.swift")) - let arguments = try await swiftpmBuildSystem.buildSettings(for: source.asURI, language: .swift)!.compilerArguments + let arguments = try await unwrap(swiftpmBuildSystem.buildSettings(for: source.asURI, language: .swift)) + .compilerArguments assertArgumentsContain("-swift-version", "4.2", arguments: arguments) assertArgumentsContain(source.pathString, arguments: arguments) @@ -281,15 +286,16 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let aswift = packageRoot.appending(components: "Sources", "lib", "a.swift") let bswift = packageRoot.appending(components: "Sources", "lib", "b.swift") - let argumentsA = try await swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)! + let argumentsA = try await unwrap(swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)) .compilerArguments assertArgumentsContain(aswift.pathString, arguments: argumentsA) assertArgumentsContain(bswift.pathString, arguments: argumentsA) - let argumentsB = try await swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)! + let argumentsB = try await unwrap(swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)) .compilerArguments assertArgumentsContain(aswift.pathString, arguments: argumentsB) assertArgumentsContain(bswift.pathString, arguments: argumentsB) @@ -327,10 +333,12 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let aswift = packageRoot.appending(components: "Sources", "libA", "a.swift") let bswift = packageRoot.appending(components: "Sources", "libB", "b.swift") - let arguments = try await swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)!.compilerArguments + let arguments = try await unwrap(swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)) + .compilerArguments assertArgumentsContain(aswift.pathString, arguments: arguments) assertArgumentsDoNotContain(bswift.pathString, arguments: arguments) // Temporary conditional to work around revlock between SourceKit-LSP and SwiftPM @@ -351,7 +359,7 @@ final class SwiftPMBuildSystemTests: XCTestCase { ) } - let argumentsB = try await swiftpmBuildSystem.buildSettings(for: bswift.asURI, language: .swift)! + let argumentsB = try await unwrap(swiftpmBuildSystem.buildSettings(for: bswift.asURI, language: .swift)) .compilerArguments assertArgumentsContain(bswift.pathString, arguments: argumentsB) assertArgumentsDoNotContain(aswift.pathString, arguments: argumentsB) @@ -390,6 +398,7 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let aswift = packageRoot.appending(components: "Sources", "libA", "a.swift") let bswift = packageRoot.appending(components: "Sources", "libB", "b.swift") @@ -431,6 +440,7 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let acxx = packageRoot.appending(components: "Sources", "lib", "a.cpp") let bcxx = packageRoot.appending(components: "Sources", "lib", "b.cpp") @@ -442,7 +452,7 @@ final class SwiftPMBuildSystemTests: XCTestCase { assertNotNil(await swiftpmBuildSystem.indexStorePath) for file in [acxx, header] { - let args = try await swiftpmBuildSystem.buildSettings(for: file.asURI, language: .cpp)!.compilerArguments + let args = try await unwrap(swiftpmBuildSystem.buildSettings(for: file.asURI, language: .cpp)).compilerArguments assertArgumentsContain("-std=c++14", arguments: args) @@ -511,9 +521,11 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let aswift = packageRoot.appending(components: "Sources", "lib", "a.swift") - let arguments = try await swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)!.compilerArguments + let arguments = try await unwrap(swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)) + .compilerArguments assertArgumentsContain("-target", arguments: arguments) // Only one! let hostTriple = await swiftpmBuildSystem.buildParameters.triple @@ -559,6 +571,7 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let aswift1 = packageRoot.appending(components: "Sources", "lib", "a.swift") let aswift2 = @@ -624,6 +637,7 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) for file in [acpp, ah] { let args = try unwrap( @@ -665,9 +679,11 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let aswift = packageRoot.appending(components: "Sources", "lib", "a.swift") - let arguments = try await swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)!.compilerArguments + let arguments = try await unwrap(swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)) + .compilerArguments assertArgumentsContain(aswift.pathString, arguments: arguments) XCTAssertNotNil( arguments.firstIndex(where: { @@ -739,6 +755,7 @@ final class SwiftPMBuildSystemTests: XCTestCase { buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, isForIndexBuild: false ) + try await swiftpmBuildSystem.generateBuildGraph(allowFileSystemWrites: false) let aswift = packageRoot.appending(components: "Plugins", "MyPlugin", "a.swift") let hostTriple = await swiftpmBuildSystem.buildParameters.triple @@ -746,7 +763,8 @@ final class SwiftPMBuildSystemTests: XCTestCase { assertEqual(await swiftpmBuildSystem.buildPath, build) assertNotNil(await swiftpmBuildSystem.indexStorePath) - let arguments = try await swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)!.compilerArguments + let arguments = try await unwrap(swiftpmBuildSystem.buildSettings(for: aswift.asURI, language: .swift)) + .compilerArguments // Plugins get compiled with the same compiler arguments as the package manifest assertArgumentsContain("-package-description-version", "5.7.0", arguments: arguments) diff --git a/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift b/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift index 9c306db27..ce91e4e84 100644 --- a/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift +++ b/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift @@ -18,10 +18,6 @@ import SemanticIndex import SourceKitLSP import XCTest -fileprivate let backgroundIndexingOptions = SourceKitLSPServer.Options( - indexOptions: IndexOptions(enableBackgroundIndexing: true) -) - final class BackgroundIndexingTests: XCTestCase { func testBackgroundIndexingOfSingleFile() async throws { let project = try await SwiftPMTestProject( @@ -33,7 +29,7 @@ final class BackgroundIndexingTests: XCTestCase { } """ ], - serverOptions: backgroundIndexingOptions + enableBackgroundIndexing: true ) let (uri, positions) = try project.openDocument("MyFile.swift") @@ -76,7 +72,7 @@ final class BackgroundIndexingTests: XCTestCase { } """, ], - serverOptions: backgroundIndexingOptions + enableBackgroundIndexing: true ) let (uri, positions) = try project.openDocument("MyFile.swift") @@ -134,7 +130,7 @@ final class BackgroundIndexingTests: XCTestCase { ] ) """, - serverOptions: backgroundIndexingOptions + enableBackgroundIndexing: true ) let (uri, positions) = try project.openDocument("MyFile.swift") @@ -166,7 +162,7 @@ final class BackgroundIndexingTests: XCTestCase { } func testBackgroundIndexingHappensWithLowPriority() async throws { - var serverOptions = backgroundIndexingOptions + var serverOptions = SourceKitLSPServer.Options.testDefault serverOptions.indexTestHooks.preparationTaskDidFinish = { taskDescription in XCTAssert(Task.currentPriority == .low, "\(taskDescription) ran with priority \(Task.currentPriority)") } @@ -199,6 +195,7 @@ final class BackgroundIndexingTests: XCTestCase { ) """, serverOptions: serverOptions, + enableBackgroundIndexing: true, pollIndex: false ) @@ -249,7 +246,7 @@ final class BackgroundIndexingTests: XCTestCase { ] ) """, - serverOptions: backgroundIndexingOptions + enableBackgroundIndexing: true ) let dependencyUrl = try XCTUnwrap( @@ -300,7 +297,7 @@ final class BackgroundIndexingTests: XCTestCase { } """, ], - serverOptions: backgroundIndexingOptions + enableBackgroundIndexing: true ) let (uri, positions) = try project.openDocument("MyFile.c") @@ -333,6 +330,21 @@ final class BackgroundIndexingTests: XCTestCase { } func testBackgroundIndexingStatusWorkDoneProgress() async throws { + let receivedBeginProgressNotification = self.expectation( + description: "Received work done progress saying build graph generation" + ) + let receivedReportProgressNotification = self.expectation( + description: "Received work done progress saying indexing" + ) + var serverOptions = SourceKitLSPServer.Options.testDefault + serverOptions.indexTestHooks = IndexTestHooks( + buildGraphGenerationDidFinish: { + await self.fulfillment(of: [receivedBeginProgressNotification], timeout: defaultTimeout) + }, + updateIndexStoreTaskDidFinish: { _ in + await self.fulfillment(of: [receivedReportProgressNotification], timeout: defaultTimeout) + } + ) let project = try await SwiftPMTestProject( files: [ "MyFile.swift": """ @@ -343,36 +355,56 @@ final class BackgroundIndexingTests: XCTestCase { """ ], capabilities: ClientCapabilities(window: WindowClientCapabilities(workDoneProgress: true)), - serverOptions: backgroundIndexingOptions, + serverOptions: serverOptions, + enableBackgroundIndexing: true, + pollIndex: false, preInitialization: { testClient in testClient.handleMultipleRequests { (request: CreateWorkDoneProgressRequest) in return VoidResponse() } } ) - var indexingWorkDoneProgressToken: ProgressToken? = nil - var didGetEndWorkDoneProgress = false - // Loop terminates when we see the work done end progress or if waiting for the next notification times out - LOOP: while true { - let workDoneProgress = try await project.testClient.nextNotification(ofType: WorkDoneProgress.self) - switch workDoneProgress.value { - case .begin(let data): - if data.title == "Indexing" { - XCTAssertNil(indexingWorkDoneProgressToken, "Received multiple work done progress notifications for indexing") - indexingWorkDoneProgressToken = workDoneProgress.token - } - case .report: - // We ignore progress reports in the test because it's non-deterministic how many we get - break - case .end: - if workDoneProgress.token == indexingWorkDoneProgressToken { - didGetEndWorkDoneProgress = true - break LOOP + + let beginNotification = try await project.testClient.nextNotification( + ofType: WorkDoneProgress.self, + satisfying: { notification in + guard case .begin(let data) = notification.value else { + return false } + return data.title == "Indexing" } + ) + receivedBeginProgressNotification.fulfill() + guard case .begin(let beginData) = beginNotification.value else { + XCTFail("Expected begin notification") + return } - XCTAssertNotNil(indexingWorkDoneProgressToken, "Expected to receive a work done progress start") - XCTAssert(didGetEndWorkDoneProgress, "Expected end work done progress") + XCTAssertEqual(beginData.message, "Generating build graph") + let indexingWorkDoneProgressToken = beginNotification.token + + _ = try await project.testClient.nextNotification( + ofType: WorkDoneProgress.self, + satisfying: { notification in + guard notification.token == indexingWorkDoneProgressToken, + case .report(let reportData) = notification.value, + reportData.message == "0 / 1" + else { + return false + } + return true + } + ) + receivedReportProgressNotification.fulfill() + + _ = try await project.testClient.nextNotification( + ofType: WorkDoneProgress.self, + satisfying: { notification in + guard notification.token == indexingWorkDoneProgressToken, case .end = notification.value else { + return false + } + return true + } + ) withExtendedLifetime(project) {} } @@ -385,7 +417,7 @@ final class BackgroundIndexingTests: XCTestCase { """, "MyOtherFile.swift": "", ], - serverOptions: backgroundIndexingOptions + enableBackgroundIndexing: true ) let (uri, positions) = try project.openDocument("MyFile.swift") @@ -452,7 +484,7 @@ final class BackgroundIndexingTests: XCTestCase { #include "Header.h" """, ], - serverOptions: backgroundIndexingOptions + enableBackgroundIndexing: true ) let (uri, positions) = try project.openDocument("Header.h", language: .c) @@ -510,7 +542,7 @@ final class BackgroundIndexingTests: XCTestCase { func testPrepareTargetAfterEditToDependency() async throws { try await SkipUnless.swiftpmStoresModulesInSubdirectory() - var serverOptions = backgroundIndexingOptions + var serverOptions = SourceKitLSPServer.Options.testDefault let expectedPreparationTracker = ExpectedIndexTaskTracker(expectedPreparations: [ [ ExpectedPreparation(targetID: "LibA", runDestinationID: "dummy"), @@ -545,7 +577,9 @@ final class BackgroundIndexingTests: XCTestCase { ] ) """, + capabilities: ClientCapabilities(window: WindowClientCapabilities(workDoneProgress: true)), serverOptions: serverOptions, + enableBackgroundIndexing: true, cleanUp: { expectedPreparationTracker.keepAlive() } ) @@ -570,6 +604,9 @@ final class BackgroundIndexingTests: XCTestCase { ) let receivedEmptyDiagnostics = self.expectation(description: "Received diagnostic refresh request") + project.testClient.handleMultipleRequests { (_: CreateWorkDoneProgressRequest) in + return VoidResponse() + } project.testClient.handleMultipleRequests { (_: DiagnosticsRefreshRequest) in Task { @@ -595,6 +632,18 @@ final class BackgroundIndexingTests: XCTestCase { ) try await fulfillmentOfOrThrow([receivedEmptyDiagnostics]) + + // Check that we received a work done progress for the re-preparation of the target + _ = try await project.testClient.nextNotification( + ofType: WorkDoneProgress.self, + satisfying: { notification in + switch notification.value { + case .begin(let value): return value.message == "Preparing current file" + case .report(let value): return value.message == "Preparing current file" + case .end: return false + } + } + ) } func testDontStackTargetPreparationForEditorFunctionality() async throws { @@ -603,7 +652,7 @@ final class BackgroundIndexingTests: XCTestCase { let libDPreparedForEditing = self.expectation(description: "LibD prepared for editing") try await SkipUnless.swiftpmStoresModulesInSubdirectory() - var serverOptions = backgroundIndexingOptions + var serverOptions = SourceKitLSPServer.Options.testDefault let expectedPreparationTracker = ExpectedIndexTaskTracker(expectedPreparations: [ // Preparation of targets during the initial of the target [ @@ -655,6 +704,7 @@ final class BackgroundIndexingTests: XCTestCase { ) """, serverOptions: serverOptions, + enableBackgroundIndexing: true, cleanUp: { expectedPreparationTracker.keepAlive() } ) @@ -684,7 +734,7 @@ final class BackgroundIndexingTests: XCTestCase { files: [ "MyFile.swift": "" ], - serverOptions: backgroundIndexingOptions + enableBackgroundIndexing: true ) let targetPrepareNotification = try await project.testClient.nextNotification(ofType: LogMessageNotification.self) XCTAssert( @@ -698,13 +748,13 @@ final class BackgroundIndexingTests: XCTestCase { ) } - func testPreparationHappensInParallel() async throws { + func testIndexingHappensInParallel() async throws { try await SkipUnless.swiftpmStoresModulesInSubdirectory() let fileAIndexingStarted = self.expectation(description: "FileA indexing started") let fileBIndexingStarted = self.expectation(description: "FileB indexing started") - var serverOptions = backgroundIndexingOptions + var serverOptions = SourceKitLSPServer.Options.testDefault let expectedIndexTaskTracker = ExpectedIndexTaskTracker( expectedIndexStoreUpdates: [ [ @@ -737,7 +787,79 @@ final class BackgroundIndexingTests: XCTestCase { "FileB.swift": "", ], serverOptions: serverOptions, + enableBackgroundIndexing: true, cleanUp: { expectedIndexTaskTracker.keepAlive() } ) } + + func testNoIndexingHappensWhenPackageIsReopened() async throws { + let project = try await SwiftPMTestProject( + files: [ + "SwiftLib/NonEmptySwiftFile.swift": """ + func test() {} + """, + "CLib/include/EmptyHeader.h": "", + "CLib/Assembly.S": "", + "CLib/EmptyC.c": "", + "CLib/NonEmptyC.c": """ + void test() {} + """, + ], + manifest: """ + // swift-tools-version: 5.7 + + import PackageDescription + + let package = Package( + name: "MyLibrary", + targets: [ + .target(name: "SwiftLib"), + .target(name: "CLib"), + ] + ) + """, + enableBackgroundIndexing: true + ) + + var otherClientOptions = SourceKitLSPServer.Options.testDefault + otherClientOptions.indexTestHooks = IndexTestHooks( + preparationTaskDidStart: { taskDescription in + XCTFail("Did not expect any target preparation, got \(taskDescription.targetsToPrepare)") + }, + updateIndexStoreTaskDidStart: { taskDescription in + XCTFail("Did not expect any indexing tasks, got \(taskDescription.filesToIndex)") + } + ) + let otherClient = try await TestSourceKitLSPClient( + serverOptions: otherClientOptions, + enableBackgroundIndexing: true, + workspaceFolders: [ + WorkspaceFolder(uri: DocumentURI(project.scratchDirectory)) + ] + ) + _ = try await otherClient.send(PollIndexRequest()) + } + + func testOpeningFileThatIsNotPartOfThePackageDoesntGenerateABuildFolderThere() async throws { + let project = try await SwiftPMTestProject( + files: [ + "Lib.swift": "", + "OtherLib/OtherLib.swift": "", + ], + enableBackgroundIndexing: true + ) + _ = try project.openDocument("OtherLib.swift") + // Wait for 1 second to increase the likelihood of this test failing in case we would start scheduling some + // background task that causes a build in the `OtherLib` directory. + try await Task.sleep(for: .seconds(1)) + let nestedIndexBuildURL = try XCTUnwrap( + project.uri(for: "OtherLib.swift").fileURL? + .deletingLastPathComponent() + .appendingPathComponent(".index-build") + ) + XCTAssertFalse( + FileManager.default.fileExists(atPath: nestedIndexBuildURL.path), + "No file should exist at \(nestedIndexBuildURL)" + ) + } } diff --git a/Tests/SourceKitLSPTests/BuildSystemTests.swift b/Tests/SourceKitLSPTests/BuildSystemTests.swift index 378df97f8..45471f468 100644 --- a/Tests/SourceKitLSPTests/BuildSystemTests.swift +++ b/Tests/SourceKitLSPTests/BuildSystemTests.swift @@ -66,7 +66,7 @@ actor TestBuildSystem: BuildSystem { throw PrepareNotSupportedError() } - public func generateBuildGraph() {} + public func generateBuildGraph(allowFileSystemWrites: Bool) {} public func topologicalSort(of targets: [ConfiguredTarget]) -> [ConfiguredTarget]? { return nil @@ -141,7 +141,7 @@ final class BuildSystemTests: XCTestCase { indexTaskScheduler: .forTesting, indexProcessDidProduceResult: { _ in }, indexTasksWereScheduled: { _ in }, - indexStatusDidChange: {} + indexProgressStatusDidChange: {} ) await server.setWorkspaces([(workspace: workspace, isImplicit: false)]) diff --git a/Tests/SourceKitLSPTests/PullDiagnosticsTests.swift b/Tests/SourceKitLSPTests/PullDiagnosticsTests.swift index 5b4268bc8..1421a795f 100644 --- a/Tests/SourceKitLSPTests/PullDiagnosticsTests.swift +++ b/Tests/SourceKitLSPTests/PullDiagnosticsTests.swift @@ -10,9 +10,11 @@ // //===----------------------------------------------------------------------===// +import CAtomics import LSPTestSupport import LanguageServerProtocol import SKTestSupport +import SourceKitLSP import XCTest final class PullDiagnosticsTests: XCTestCase { @@ -247,4 +249,67 @@ final class PullDiagnosticsTests: XCTestCase { ) XCTAssertEqual(afterChangingFileA, .full(RelatedFullDocumentDiagnosticReport(items: []))) } + + func testDiagnosticsWaitForDocumentToBePrepared() async throws { + try await SkipUnless.swiftpmStoresModulesInSubdirectory() + + nonisolated(unsafe) var diagnosticRequestSent = AtomicBool(initialValue: false) + var serverOptions = SourceKitLSPServer.Options.testDefault + serverOptions.indexTestHooks.preparationTaskDidStart = { @Sendable taskDescription in + // Only start preparation after we sent the diagnostic request. In almost all cases, this should not give + // preparation enough time to finish before the diagnostic request is handled unless we wait for preparation in + // the diagnostic request. + while diagnosticRequestSent.value == false { + do { + try await Task.sleep(for: .seconds(0.01)) + } catch { + XCTFail("Did not expect sleep to fail") + break + } + } + } + + let project = try await SwiftPMTestProject( + files: [ + "LibA/LibA.swift": """ + public func sayHello() {} + """, + "LibB/LibB.swift": """ + import LibA + + func test() { + sayHello() + } + """, + ], + manifest: """ + // swift-tools-version: 5.7 + + import PackageDescription + + let package = Package( + name: "MyLibrary", + targets: [ + .target(name: "LibA"), + .target(name: "LibB", dependencies: ["LibA"]), + ] + ) + """, + serverOptions: serverOptions, + enableBackgroundIndexing: true, + pollIndex: false + ) + + let (uri, _) = try project.openDocument("LibB.swift") + + // Use completion handler based method to send request so we can fulfill `diagnosticRequestSent` after sending it + // but before receiving a reply. The async variant doesn't allow this distinction. + let receivedDiagnostics = self.expectation(description: "Received diagnostics") + project.testClient.send(DocumentDiagnosticsRequest(textDocument: TextDocumentIdentifier(uri))) { diagnostics in + XCTAssertEqual(diagnostics.success, .full(RelatedFullDocumentDiagnosticReport(items: []))) + receivedDiagnostics.fulfill() + } + diagnosticRequestSent.value = true + try await fulfillmentOfOrThrow([receivedDiagnostics]) + } }