diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml index f7d5ff735..c5321ed65 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml @@ -18,8 +18,8 @@ body: id: editor attributes: label: Editor - description: Which text editor are you using? - placeholder: Eg. Visual Studio Code with Swift plugin 1.9.0, neovim + description: Which text editor are you using (and LSP extension/plugin if applicable)? + placeholder: Eg. Visual Studio Code with Swift extension 1.9.0, Neovim - type: dropdown id: reproduces-with-swift-6 attributes: @@ -50,4 +50,4 @@ body: label: Logging description: | If you are using SourceKit-LSP from Swift 6, running `sourcekit-lsp diagnose` in terminal and attaching the generated bundle helps us diagnose the issue. - The generated bundle might contain portions of your source code, so please only attach it if you feel comfortable sharing it. + The generated bundle may contain paths to files on disk as well as portions of your source code. This greatly helps in reproducing issues, but you should only attach it if you feel comfortable doing so. diff --git a/.gitignore b/.gitignore index b13934290..32c80ecff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ default.profraw Package.resolved /.build +/.index-build /Packages /*.xcodeproj /*.sublime-project diff --git a/Package.swift b/Package.swift index d44c8bf91..b5ef7c762 100644 --- a/Package.swift +++ b/Package.swift @@ -173,6 +173,8 @@ let package = Package( .target( name: "SemanticIndex", dependencies: [ + "CAtomics", + "LanguageServerProtocol", "LSPLogging", "SKCore", .product(name: "IndexStoreDB", package: "indexstore-db"), diff --git a/Sources/Diagnose/CMakeLists.txt b/Sources/Diagnose/CMakeLists.txt index 719f8c7ec..e6c73f3ec 100644 --- a/Sources/Diagnose/CMakeLists.txt +++ b/Sources/Diagnose/CMakeLists.txt @@ -1,4 +1,5 @@ add_library(Diagnose STATIC + CommandConfiguration+Sendable.swift CommandLineArgumentsReducer.swift DiagnoseCommand.swift MergeSwiftFiles.swift @@ -11,9 +12,10 @@ add_library(Diagnose STATIC ReproducerBundle.swift RequestInfo.swift SourceKitD+RunWithYaml.swift + SourcekitdRequestCommand.swift SourceKitDRequestExecutor.swift SourceReducer.swift - SourcekitdRequestCommand.swift + StderrStreamConcurrencySafe.swift SwiftFrontendCrashScraper.swift Toolchain+SwiftFrontend.swift) diff --git a/Sources/Diagnose/CommandConfiguration+Sendable.swift b/Sources/Diagnose/CommandConfiguration+Sendable.swift new file mode 100644 index 000000000..0504351e5 --- /dev/null +++ b/Sources/Diagnose/CommandConfiguration+Sendable.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// 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 ArgumentParser + +// If `CommandConfiguration` is not sendable, commands can't have static `configuration` properties. +// Needed until we update Swift CI to swift-argument-parser 1.3.1, which has this conformance (rdar://128042447). +#if compiler(<5.11) +extension CommandConfiguration: @unchecked Sendable {} +#else +extension CommandConfiguration: @unchecked @retroactive Sendable {} +#endif diff --git a/Sources/Diagnose/CommandLineArgumentsReducer.swift b/Sources/Diagnose/CommandLineArgumentsReducer.swift index 45ad000a0..d13cf22f1 100644 --- a/Sources/Diagnose/CommandLineArgumentsReducer.swift +++ b/Sources/Diagnose/CommandLineArgumentsReducer.swift @@ -16,6 +16,7 @@ import LSPLogging // MARK: - Entry point extension RequestInfo { + @MainActor func reduceCommandLineArguments( using executor: SourceKitRequestExecutor, progressUpdate: (_ progress: Double, _ message: String) -> Void @@ -49,6 +50,7 @@ fileprivate class CommandLineArgumentReducer { self.progressUpdate = progressUpdate } + @MainActor func run(initialRequestInfo: RequestInfo) async throws -> RequestInfo { var requestInfo = initialRequestInfo requestInfo = try await reduce(initialRequestInfo: requestInfo, simultaneousRemove: 10) @@ -113,6 +115,7 @@ fileprivate class CommandLineArgumentReducer { return requestInfo } + @MainActor private func tryRemoving( _ argumentsToRemove: ClosedRange, from requestInfo: RequestInfo diff --git a/Sources/Diagnose/DiagnoseCommand.swift b/Sources/Diagnose/DiagnoseCommand.swift index ca01dfdca..acc528227 100644 --- a/Sources/Diagnose/DiagnoseCommand.swift +++ b/Sources/Diagnose/DiagnoseCommand.swift @@ -16,13 +16,13 @@ import SKCore import struct TSCBasic.AbsolutePath import class TSCBasic.Process -import var TSCBasic.stderrStream import class TSCUtility.PercentProgressAnimation /// When diagnosis is started, a progress bar displayed on the terminal that shows how far the diagnose command has /// progressed. /// Can't be a member of `DiagnoseCommand` because then `DiagnoseCommand` is no longer codable, which it needs to be /// to be a `AsyncParsableCommand`. +@MainActor private var progressBar: PercentProgressAnimation? = nil /// A component of the diagnostic bundle that's collected in independent stages. @@ -35,7 +35,7 @@ fileprivate enum BundleComponent: String, CaseIterable, ExpressibleByArgument { } public struct DiagnoseCommand: AsyncParsableCommand { - public static var configuration: CommandConfiguration = CommandConfiguration( + public static let configuration: CommandConfiguration = CommandConfiguration( commandName: "diagnose", abstract: "Creates a bundle containing information that help diagnose issues with sourcekit-lsp" ) @@ -72,6 +72,7 @@ public struct DiagnoseCommand: AsyncParsableCommand { } } + @MainActor var toolchain: Toolchain? { get async throws { if let toolchainOverride { @@ -96,6 +97,7 @@ public struct DiagnoseCommand: AsyncParsableCommand { public init() {} + @MainActor private func addSourcekitdCrashReproducer(toBundle bundlePath: URL) async throws { reportProgress(.reproducingSourcekitdCrash(progress: 0), message: "Trying to reduce recent sourcekitd crashes") for (name, requestInfo) in try requestInfos() { @@ -121,6 +123,7 @@ public struct DiagnoseCommand: AsyncParsableCommand { } } + @MainActor private func addSwiftFrontendCrashReproducer(toBundle bundlePath: URL) async throws { reportProgress( .reproducingSwiftFrontendCrash(progress: 0), @@ -182,7 +185,8 @@ public struct DiagnoseCommand: AsyncParsableCommand { } /// Execute body and if it throws, log the error. - private func orPrintError(_ body: () async throws -> Void) async { + @MainActor + private func orPrintError(_ body: @MainActor () async throws -> Void) async { do { try await body() } catch { @@ -190,6 +194,7 @@ public struct DiagnoseCommand: AsyncParsableCommand { } } + @MainActor private func addOsLog(toBundle bundlePath: URL) async throws { #if os(macOS) reportProgress(.collectingLogMessages(progress: 0), message: "Collecting log messages") @@ -227,6 +232,7 @@ public struct DiagnoseCommand: AsyncParsableCommand { #endif } + @MainActor private func addCrashLogs(toBundle bundlePath: URL) throws { #if os(macOS) reportProgress(.collectingCrashReports, message: "Collecting crash reports") @@ -252,6 +258,7 @@ public struct DiagnoseCommand: AsyncParsableCommand { #endif } + @MainActor private func addSwiftVersion(toBundle bundlePath: URL) async throws { let outputFileUrl = bundlePath.appendingPathComponent("swift-versions.txt") FileManager.default.createFile(atPath: outputFileUrl.path, contents: nil) @@ -283,10 +290,12 @@ public struct DiagnoseCommand: AsyncParsableCommand { } } + @MainActor private func reportProgress(_ state: DiagnoseProgressState, message: String) { progressBar?.update(step: Int(state.progress * 100), total: 100, text: message) } + @MainActor public func run() async throws { print( """ @@ -303,7 +312,10 @@ public struct DiagnoseCommand: AsyncParsableCommand { """ ) - progressBar = PercentProgressAnimation(stream: stderrStream, header: "Diagnosing sourcekit-lsp issues") + progressBar = PercentProgressAnimation( + stream: stderrStreamConcurrencySafe, + header: "Diagnosing sourcekit-lsp issues" + ) let dateFormatter = ISO8601DateFormatter() dateFormatter.timeZone = NSTimeZone.local @@ -342,6 +354,7 @@ public struct DiagnoseCommand: AsyncParsableCommand { } + @MainActor private func reduce( requestInfo: RequestInfo, toolchain: Toolchain?, diff --git a/Sources/Diagnose/MergeSwiftFiles.swift b/Sources/Diagnose/MergeSwiftFiles.swift index 2c5a492e6..ee0c30000 100644 --- a/Sources/Diagnose/MergeSwiftFiles.swift +++ b/Sources/Diagnose/MergeSwiftFiles.swift @@ -17,6 +17,7 @@ extension RequestInfo { /// Check if the issue reproduces when merging all `.swift` input files into a single file. /// /// Returns `nil` if the issue didn't reproduce with all `.swift` files merged. + @MainActor func mergeSwiftFiles( using executor: SourceKitRequestExecutor, progressUpdate: (_ progress: Double, _ message: String) -> Void diff --git a/Sources/Diagnose/ReduceCommand.swift b/Sources/Diagnose/ReduceCommand.swift index 416e11719..51bbfe4bc 100644 --- a/Sources/Diagnose/ReduceCommand.swift +++ b/Sources/Diagnose/ReduceCommand.swift @@ -20,7 +20,7 @@ import var TSCBasic.stderrStream import class TSCUtility.PercentProgressAnimation public struct ReduceCommand: AsyncParsableCommand { - public static var configuration: CommandConfiguration = CommandConfiguration( + public static let configuration: CommandConfiguration = CommandConfiguration( commandName: "reduce", abstract: "Reduce a single sourcekitd crash", shouldDisplay: false @@ -56,6 +56,7 @@ public struct ReduceCommand: AsyncParsableCommand { private var nsPredicate: NSPredicate? { nil } #endif + @MainActor var toolchain: Toolchain? { get async throws { if let toolchainOverride { @@ -68,6 +69,7 @@ public struct ReduceCommand: AsyncParsableCommand { public init() {} + @MainActor public func run() async throws { guard let sourcekitd = try await toolchain?.sourcekitd else { throw ReductionError("Unable to find sourcekitd.framework") diff --git a/Sources/Diagnose/ReduceFrontendCommand.swift b/Sources/Diagnose/ReduceFrontendCommand.swift index f8184aa90..fe57124ae 100644 --- a/Sources/Diagnose/ReduceFrontendCommand.swift +++ b/Sources/Diagnose/ReduceFrontendCommand.swift @@ -20,7 +20,7 @@ import var TSCBasic.stderrStream import class TSCUtility.PercentProgressAnimation public struct ReduceFrontendCommand: AsyncParsableCommand { - public static var configuration: CommandConfiguration = CommandConfiguration( + public static let configuration: CommandConfiguration = CommandConfiguration( commandName: "reduce-frontend", abstract: "Reduce a single swift-frontend crash", shouldDisplay: false @@ -64,6 +64,7 @@ public struct ReduceFrontendCommand: AsyncParsableCommand { ) var frontendArgs: [String] + @MainActor var toolchain: Toolchain? { get async throws { if let toolchainOverride { @@ -76,6 +77,7 @@ public struct ReduceFrontendCommand: AsyncParsableCommand { public init() {} + @MainActor public func run() async throws { guard let sourcekitd = try await toolchain?.sourcekitd else { throw ReductionError("Unable to find sourcekitd.framework") @@ -84,7 +86,10 @@ public struct ReduceFrontendCommand: AsyncParsableCommand { throw ReductionError("Unable to find swift-frontend") } - let progressBar = PercentProgressAnimation(stream: stderrStream, header: "Reducing swift-frontend crash") + let progressBar = PercentProgressAnimation( + stream: stderrStream, + header: "Reducing swift-frontend crash" + ) let executor = OutOfProcessSourceKitRequestExecutor( sourcekitd: sourcekitd.asURL, diff --git a/Sources/Diagnose/ReduceSourceKitDRequest.swift b/Sources/Diagnose/ReduceSourceKitDRequest.swift index ada0c8e87..b532254da 100644 --- a/Sources/Diagnose/ReduceSourceKitDRequest.swift +++ b/Sources/Diagnose/ReduceSourceKitDRequest.swift @@ -12,6 +12,7 @@ extension RequestInfo { /// Reduce the input file of this request and the command line arguments. + @MainActor func reduce( using executor: SourceKitRequestExecutor, progressUpdate: (_ progress: Double, _ message: String) -> Void diff --git a/Sources/Diagnose/ReduceSwiftFrontend.swift b/Sources/Diagnose/ReduceSwiftFrontend.swift index 0ac7a1e7b..87628ce87 100644 --- a/Sources/Diagnose/ReduceSwiftFrontend.swift +++ b/Sources/Diagnose/ReduceSwiftFrontend.swift @@ -10,6 +10,7 @@ // //===----------------------------------------------------------------------===// +@MainActor @_spi(Testing) public func reduceFrontendIssue( frontendArgs: [String], diff --git a/Sources/Diagnose/RequestInfo.swift b/Sources/Diagnose/RequestInfo.swift index 4b951c8d3..b10fc6562 100644 --- a/Sources/Diagnose/RequestInfo.swift +++ b/Sources/Diagnose/RequestInfo.swift @@ -15,7 +15,7 @@ import RegexBuilder /// All the information necessary to replay a sourcektid request. @_spi(Testing) -public struct RequestInfo { +public struct RequestInfo: Sendable { /// The JSON request object. Contains the following dynamic placeholders: /// - `$OFFSET`: To be replaced by `offset` before running the request /// - `$FILE`: Will be replaced with a path to the file that contains the reduced source code. @@ -51,7 +51,7 @@ public struct RequestInfo { } /// A fake value that is used to indicate that we are reducing a `swift-frontend` issue instead of a sourcekitd issue. - static var fakeRequestTemplateForFrontendIssues = """ + static let fakeRequestTemplateForFrontendIssues = """ { key.request: sourcekit-lsp-fake-request-for-frontend-crash key.compilerargs: [ diff --git a/Sources/Diagnose/SourceKitDRequestExecutor.swift b/Sources/Diagnose/SourceKitDRequestExecutor.swift index f5cb5ec99..389549a0d 100644 --- a/Sources/Diagnose/SourceKitDRequestExecutor.swift +++ b/Sources/Diagnose/SourceKitDRequestExecutor.swift @@ -19,7 +19,7 @@ import struct TSCBasic.ProcessResult /// The different states in which a sourcekitd request can finish. @_spi(Testing) -public enum SourceKitDRequestResult { +public enum SourceKitDRequestResult: Sendable { /// The request succeeded. case success(response: String) @@ -46,11 +46,12 @@ fileprivate extension String { /// An executor that can run a sourcekitd request and indicate whether the request reprodes a specified issue. @_spi(Testing) public protocol SourceKitRequestExecutor { - func runSourceKitD(request: RequestInfo) async throws -> SourceKitDRequestResult - func runSwiftFrontend(request: RequestInfo) async throws -> SourceKitDRequestResult + @MainActor func runSourceKitD(request: RequestInfo) async throws -> SourceKitDRequestResult + @MainActor func runSwiftFrontend(request: RequestInfo) async throws -> SourceKitDRequestResult } extension SourceKitRequestExecutor { + @MainActor func run(request: RequestInfo) async throws -> SourceKitDRequestResult { if request.requestTemplate == RequestInfo.fakeRequestTemplateForFrontendIssues { return try await runSwiftFrontend(request: request) diff --git a/Sources/Diagnose/SourceReducer.swift b/Sources/Diagnose/SourceReducer.swift index 2acddca8d..8dfe8b3e1 100644 --- a/Sources/Diagnose/SourceReducer.swift +++ b/Sources/Diagnose/SourceReducer.swift @@ -21,6 +21,7 @@ import SwiftSyntax extension RequestInfo { @_spi(Testing) + @MainActor public func reduceInputFile( using executor: SourceKitRequestExecutor, progressUpdate: (_ progress: Double, _ message: String) -> Void @@ -61,6 +62,7 @@ fileprivate enum ReductionStepResult { } /// Reduces an input source file while continuing to reproduce the crash +@MainActor fileprivate class SourceReducer { /// The executor that is used to run a sourcekitd request and check whether it /// still crashes. @@ -84,6 +86,7 @@ fileprivate class SourceReducer { } /// Reduce the file contents in `initialRequest` to a smaller file that still reproduces a crash. + @MainActor func run(initialRequestInfo: RequestInfo) async throws -> RequestInfo { var requestInfo = initialRequestInfo self.initialImportCount = Parser.parse(source: requestInfo.fileContents).numberOfImports @@ -242,6 +245,7 @@ fileprivate class SourceReducer { /// /// If the request still crashes after applying the edits computed by `reduce`, return the reduced request info. /// Otherwise, return `nil` + @MainActor private func runReductionStep( requestInfo: RequestInfo, reportProgress: Bool = true, @@ -608,6 +612,7 @@ fileprivate class FirstImportFinder: SyntaxAnyVisitor { /// the file that imports the module. If `areFallbackArgs` is set, we have synthesized fallback arguments that only /// contain a target and SDK. This is useful when reducing a swift-frontend crash because sourcekitd requires driver /// arguments but the swift-frontend crash has frontend args. +@MainActor fileprivate func getSwiftInterface( _ moduleName: String, executor: SourceKitRequestExecutor, @@ -680,6 +685,7 @@ fileprivate func getSwiftInterface( return try JSONDecoder().decode(String.self, from: sanitizedData) } +@MainActor fileprivate func inlineFirstImport( in tree: SourceFileSyntax, executor: SourceKitRequestExecutor, diff --git a/Sources/Diagnose/SourcekitdRequestCommand.swift b/Sources/Diagnose/SourcekitdRequestCommand.swift index 14cabbad4..ba4e0686f 100644 --- a/Sources/Diagnose/SourcekitdRequestCommand.swift +++ b/Sources/Diagnose/SourcekitdRequestCommand.swift @@ -18,7 +18,7 @@ import SourceKitD import struct TSCBasic.AbsolutePath public struct SourceKitdRequestCommand: AsyncParsableCommand { - public static var configuration = CommandConfiguration( + public static let configuration = CommandConfiguration( commandName: "run-sourcekitd-request", abstract: "Run a sourcekitd request and print its result", shouldDisplay: false diff --git a/Sources/Diagnose/StderrStreamConcurrencySafe.swift b/Sources/Diagnose/StderrStreamConcurrencySafe.swift new file mode 100644 index 000000000..93bc48f14 --- /dev/null +++ b/Sources/Diagnose/StderrStreamConcurrencySafe.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// +// 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 TSCLibc + +import class TSCBasic.LocalFileOutputByteStream +import class TSCBasic.ThreadSafeOutputByteStream + +// A version of `stderrStream` from `TSCBasic` that is a `let` and can thus be used from Swift 6. +let stderrStreamConcurrencySafe: ThreadSafeOutputByteStream = try! ThreadSafeOutputByteStream( + LocalFileOutputByteStream( + filePointer: TSCLibc.stderr, + closeOnDeinit: false + ) +) diff --git a/Sources/LSPLogging/Logging.swift b/Sources/LSPLogging/Logging.swift index 079924750..c8d84a287 100644 --- a/Sources/LSPLogging/Logging.swift +++ b/Sources/LSPLogging/Logging.swift @@ -13,7 +13,7 @@ /// Which log level to use (from https://developer.apple.com/wwdc20/10168?time=604) /// - Debug: Useful only during debugging (only logged during debugging) /// - Info: Helpful but not essential for troubleshooting (not persisted, logged to memory) -/// - Notice/log (Default): Essential for troubleshooting +/// - Notice/log/default: Essential for troubleshooting /// - Error: Error seen during execution /// - Used eg. if the user sends an erroneous request or if a request fails /// - Fault: Bug in program @@ -28,6 +28,16 @@ public typealias LogLevel = os.OSLogType public typealias Logger = os.Logger public typealias Signposter = OSSignposter +#if compiler(<5.11) +extension OSSignposter: @unchecked Sendable {} +extension OSSignpostID: @unchecked Sendable {} +extension OSSignpostIntervalState: @unchecked Sendable {} +#else +extension OSSignposter: @retroactive @unchecked Sendable {} +extension OSSignpostID: @retroactive @unchecked Sendable {} +extension OSSignpostIntervalState: @retroactive @unchecked Sendable {} +#endif + extension os.Logger { public func makeSignposter() -> Signposter { return OSSignposter(logger: self) diff --git a/Sources/LSPLogging/LoggingScope.swift b/Sources/LSPLogging/LoggingScope.swift index 5ef40a303..1d18513b3 100644 --- a/Sources/LSPLogging/LoggingScope.swift +++ b/Sources/LSPLogging/LoggingScope.swift @@ -80,7 +80,7 @@ public func withLoggingScope( /// - SeeAlso: ``withLoggingScope(_:_:)-6qtga`` public func withLoggingScope( _ scope: String, - _ operation: () async throws -> Result + @_inheritActorContext _ operation: @Sendable () async throws -> Result ) async rethrows -> Result { return try await LoggingScope.$_scope.withValue( scope, diff --git a/Sources/LSPLogging/NonDarwinLogging.swift b/Sources/LSPLogging/NonDarwinLogging.swift index f89d568b9..b838a29db 100644 --- a/Sources/LSPLogging/NonDarwinLogging.swift +++ b/Sources/LSPLogging/NonDarwinLogging.swift @@ -330,7 +330,7 @@ public struct NonDarwinLogger: Sendable { log(level: .info, message) } - /// Log a message at the `log` level. + /// Log a message at the `default` level. public func log(_ message: NonDarwinLogMessage) { log(level: .default, message) } @@ -361,14 +361,14 @@ public struct NonDarwinLogger: Sendable { // MARK: - Signposter -public struct NonDarwinSignpostID {} +public struct NonDarwinSignpostID: Sendable {} -public struct NonDarwinSignpostIntervalState {} +public struct NonDarwinSignpostIntervalState: Sendable {} /// A type that is API-compatible to `OSLogMessage` for all uses within sourcekit-lsp. /// /// Since non-Darwin platforms don't have signposts, the type just has no-op operations. -public struct NonDarwinSignposter { +public struct NonDarwinSignposter: Sendable { public func makeSignpostID() -> NonDarwinSignpostID { return NonDarwinSignpostID() } diff --git a/Sources/LanguageServerProtocol/Connection.swift b/Sources/LanguageServerProtocol/Connection.swift index 3bb27d5ad..e33a40f9a 100644 --- a/Sources/LanguageServerProtocol/Connection.swift +++ b/Sources/LanguageServerProtocol/Connection.swift @@ -44,7 +44,7 @@ public protocol MessageHandler: AnyObject, Sendable { func handle( _ request: Request, id: RequestID, - reply: @escaping (LSPResult) -> Void + reply: @Sendable @escaping (LSPResult) -> Void ) } @@ -110,7 +110,7 @@ public final class LocalConnection: Connection, @unchecked Sendable { public func send( _ request: Request, - reply: @escaping (LSPResult) -> Void + reply: @Sendable @escaping (LSPResult) -> Void ) -> RequestID { let id = nextRequestID() diff --git a/Sources/LanguageServerProtocol/Message.swift b/Sources/LanguageServerProtocol/Message.swift index 508cec8b9..97c1d5972 100644 --- a/Sources/LanguageServerProtocol/Message.swift +++ b/Sources/LanguageServerProtocol/Message.swift @@ -24,7 +24,7 @@ public protocol _RequestType: MessageType { func _handle( _ handler: MessageHandler, id: RequestID, - reply: @escaping (LSPResult, RequestID) -> Void + reply: @Sendable @escaping (LSPResult, RequestID) -> Void ) } @@ -49,7 +49,7 @@ extension RequestType { public func _handle( _ handler: MessageHandler, id: RequestID, - reply: @escaping (LSPResult, RequestID) -> Void + reply: @Sendable @escaping (LSPResult, RequestID) -> Void ) { handler.handle(self, id: id) { response in reply(response.map({ $0 as ResponseType }), id) diff --git a/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift b/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift index fe2357478..09db58055 100644 --- a/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift +++ b/Sources/LanguageServerProtocol/Requests/CodeActionRequest.swift @@ -10,7 +10,7 @@ // //===----------------------------------------------------------------------===// -public typealias CodeActionProvider = (CodeActionRequest) async throws -> [CodeAction] +public typealias CodeActionProvider = @Sendable (CodeActionRequest) async throws -> [CodeAction] /// Request for returning all possible code actions for a given text document and range. /// diff --git a/Sources/LanguageServerProtocol/SupportTypes/ServerCapabilities.swift b/Sources/LanguageServerProtocol/SupportTypes/ServerCapabilities.swift index 0ce3bbd6a..36e4e03d8 100644 --- a/Sources/LanguageServerProtocol/SupportTypes/ServerCapabilities.swift +++ b/Sources/LanguageServerProtocol/SupportTypes/ServerCapabilities.swift @@ -587,7 +587,7 @@ public struct DocumentRangeFormattingOptions: WorkDoneProgressOptions, Codable, } } -public struct FoldingRangeOptions: Codable, Hashable { +public struct FoldingRangeOptions: Codable, Hashable, Sendable { /// Currently empty in the spec. public init() {} } diff --git a/Sources/SKCore/BuildServerBuildSystem.swift b/Sources/SKCore/BuildServerBuildSystem.swift index f45dd93fa..01561d721 100644 --- a/Sources/SKCore/BuildServerBuildSystem.swift +++ b/Sources/SKCore/BuildServerBuildSystem.swift @@ -263,7 +263,11 @@ extension BuildServerBuildSystem: BuildSystem { /// /// Returns `nil` if no build settings have been received from the build /// server yet or if no build settings are available for this file. - public func buildSettings(for document: DocumentURI, language: Language) async -> FileBuildSettings? { + public func buildSettings( + for document: DocumentURI, + in target: ConfiguredTarget, + language: Language + ) async -> FileBuildSettings? { return buildSettings[document] } @@ -271,6 +275,20 @@ extension BuildServerBuildSystem: BuildSystem { return nil } + public func configuredTargets(for document: DocumentURI) async -> [ConfiguredTarget] { + return [ConfiguredTarget(targetID: "dummy", runDestinationID: "dummy")] + } + + public func generateBuildGraph() {} + + public func topologicalSort(of targets: [ConfiguredTarget]) async -> [ConfiguredTarget]? { + return nil + } + + public func prepare(targets: [ConfiguredTarget]) async throws { + throw PrepareNotSupportedError() + } + public func registerForChangeNotifications(for uri: DocumentURI) { let request = RegisterForChanges(uri: uri, action: .register) _ = self.buildServer?.send(request) { result in diff --git a/Sources/SKCore/BuildSystem.swift b/Sources/SKCore/BuildSystem.swift index ee48db020..f6880a00b 100644 --- a/Sources/SKCore/BuildSystem.swift +++ b/Sources/SKCore/BuildSystem.swift @@ -31,18 +31,51 @@ public struct SourceFileInfo: Sendable { /// The URI of the source file. public let uri: DocumentURI + /// `true` if this file belongs to the root project that the user is working on. It is false, if the file belongs + /// to a dependency of the project. + public let isPartOfRootProject: Bool + /// Whether the file might contain test cases. This property is an over-approximation. It might be true for files /// from non-test targets or files that don't actually contain any tests. Keeping this list of files with /// `mayContainTets` minimal as possible helps reduce the amount of work that the syntactic test indexer needs to /// perform. public let mayContainTests: Bool - public init(uri: DocumentURI, mayContainTests: Bool) { + public init(uri: DocumentURI, isPartOfRootProject: Bool, mayContainTests: Bool) { self.uri = uri + self.isPartOfRootProject = isPartOfRootProject self.mayContainTests = mayContainTests } } +/// A target / run destination combination. For example, a configured target can represent building the target +/// `MyLibrary` for iOS. +public struct ConfiguredTarget: Hashable, Sendable { + /// An opaque string that represents the target. + /// + /// The target's ID should be generated by the build system that handles the target and only interpreted by that + /// build system. + public let targetID: String + + /// An opaque string that represents the run destination. + /// + /// The run destination's ID should be generated by the build system that handles the target and only interpreted by + /// that build system. + public let runDestinationID: String + + public init(targetID: String, runDestinationID: String) { + self.targetID = targetID + self.runDestinationID = runDestinationID + } +} + +/// An error build systems can throw from `prepare` if they don't support preparation of targets. +public struct PrepareNotSupportedError: Error, CustomStringConvertible { + public init() {} + + public var description: String { "Preparation not supported" } +} + /// Provider of FileBuildSettings and other build-related information. /// /// The primary role of the build system is to answer queries for @@ -53,7 +86,6 @@ public struct SourceFileInfo: Sendable { /// For example, a SwiftPMWorkspace provides compiler arguments for the files /// contained in a SwiftPM package root directory. public protocol BuildSystem: AnyObject, Sendable { - /// The root of the project that this build system manages. For example, for SwiftPM packages, this is the folder /// containing Package.swift. For compilation databases it is the root folder based on which the compilation database /// was found. @@ -85,7 +117,30 @@ public protocol BuildSystem: AnyObject, Sendable { /// /// Returns `nil` if the build system can't provide build settings for this /// file or if it hasn't computed build settings for the file yet. - func buildSettings(for document: DocumentURI, language: Language) async throws -> FileBuildSettings? + func buildSettings( + for document: DocumentURI, + in target: ConfiguredTarget, + language: Language + ) async throws -> FileBuildSettings? + + /// 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 + + /// Sort the targets so that low-level targets occur before high-level targets. + /// + /// This sorting is best effort but allows the indexer to prepare and index low-level targets first, which allows + /// index data to be available earlier. + /// + /// `nil` if the build system doesn't support topological sorting of targets. + func topologicalSort(of targets: [ConfiguredTarget]) async -> [ConfiguredTarget]? + + /// Prepare the given targets for indexing and semantic functionality. This should build all swift modules of target + /// dependencies. + func prepare(targets: [ConfiguredTarget]) async throws /// If the build system has knowledge about the language that this document should be compiled in, return it. /// @@ -119,5 +174,3 @@ public protocol BuildSystem: AnyObject, Sendable { /// The callback might also be called without an actual change to `sourceFiles`. func addSourceFilesDidChangeCallback(_ callback: @Sendable @escaping () async -> Void) async } - -public let buildTargetsNotSupported = ResponseError.methodNotFound(BuildTargets.method) diff --git a/Sources/SKCore/BuildSystemManager.swift b/Sources/SKCore/BuildSystemManager.swift index ea726f942..33d755c57 100644 --- a/Sources/SKCore/BuildSystemManager.swift +++ b/Sources/SKCore/BuildSystemManager.swift @@ -17,6 +17,10 @@ import LanguageServerProtocol import struct TSCBasic.AbsolutePath +#if canImport(os) +import os +#endif + /// `BuildSystem` that integrates client-side information such as main-file lookup as well as providing /// common functionality such as caching. /// @@ -122,23 +126,54 @@ extension BuildSystemManager { } } + /// Returns the `ConfiguredTarget` that should be used for semantic functionality of the given document. + public func canonicalConfiguredTarget(for document: DocumentURI) async -> ConfiguredTarget? { + // Sort the configured targets to deterministically pick the same `ConfiguredTarget` every time. + // We could allow the user to specify a preference of one target over another. For now this is not necessary because + // no build system currently returns multiple targets for a source file. + return await buildSystem?.configuredTargets(for: document) + .sorted { ($0.targetID, $0.runDestinationID) < ($1.targetID, $1.runDestinationID) } + .first + } + + /// Returns the build settings for `document` from `buildSystem`. + /// + /// Implementation detail of `buildSettings(for:language:)`. + private func buildSettingsFromPrimaryBuildSystem( + for document: DocumentURI, + language: Language + ) async throws -> FileBuildSettings? { + guard let buildSystem else { + return nil + } + guard let target = await canonicalConfiguredTarget(for: document) else { + logger.error("Failed to get target for \(document.forLogging)") + return nil + } + // FIXME: (async) We should only wait `fallbackSettingsTimeout` for build + // settings and return fallback afterwards. I am not sure yet, how best to + // implement that with Swift concurrency. + // For now, this should be fine because all build systems return + // very quickly from `settings(for:language:)`. + guard let settings = try await buildSystem.buildSettings(for: document, in: target, language: language) else { + return nil + } + return settings + } + private func buildSettings( for document: DocumentURI, language: Language ) async -> FileBuildSettings? { do { - // FIXME: (async) We should only wait `fallbackSettingsTimeout` for build - // settings and return fallback afterwards. I am not sure yet, how best to - // implement that with Swift concurrency. - // For now, this should be fine because all build systems return - // very quickly from `settings(for:language:)`. - if let settings = try await buildSystem?.buildSettings(for: document, language: language) { - return settings + if let buildSettings = try await buildSettingsFromPrimaryBuildSystem(for: document, language: language) { + return buildSettings } } catch { logger.error("Getting build settings failed: \(error.forLogging)") } - guard var settings = fallbackBuildSystem?.buildSettings(for: document, language: language) else { + + guard var settings = await fallbackBuildSystem?.buildSettings(for: document, language: language) else { return nil } if buildSystem == nil { @@ -159,7 +194,8 @@ extension BuildSystemManager { /// references to that C file in the build settings by the header file. public func buildSettingsInferredFromMainFile( for document: DocumentURI, - language: Language + language: Language, + logBuildSettings: Bool = true ) async -> FileBuildSettings? { let mainFile = await mainFile(for: document, language: language) guard var settings = await buildSettings(for: mainFile, language: language) else { @@ -170,10 +206,24 @@ extension BuildSystemManager { // to reference `document` instead of `mainFile`. settings = settings.patching(newFile: document.pseudoPath, originalFile: mainFile.pseudoPath) } - await BuildSettingsLogger.shared.log(settings: settings, for: document) + if logBuildSettings { + await BuildSettingsLogger.shared.log(settings: settings, for: document) + } return settings } + public func generateBuildGraph() async throws { + try await self.buildSystem?.generateBuildGraph() + } + + public func topologicalSort(of targets: [ConfiguredTarget]) async throws -> [ConfiguredTarget]? { + return await buildSystem?.topologicalSort(of: targets) + } + + public func prepare(targets: [ConfiguredTarget]) async throws { + try await buildSystem?.prepare(targets: targets) + } + public func registerForChangeNotifications(for uri: DocumentURI, language: Language) async { logger.debug("registerForChangeNotifications(\(uri.forLogging))") let mainFile = await mainFile(for: uri, language: language) @@ -213,7 +263,7 @@ extension BuildSystemManager { public func testFiles() async -> [DocumentURI] { return await sourceFiles().compactMap { (info: SourceFileInfo) -> DocumentURI? in - guard info.mayContainTests else { + guard info.isPartOfRootProject, info.mayContainTests else { return nil } return info.uri @@ -349,16 +399,24 @@ extension BuildSystemManager { // MARK: - Build settings logger /// Shared logger that only logs build settings for a file once unless they change -fileprivate actor BuildSettingsLogger { - static let shared = BuildSettingsLogger() +public actor BuildSettingsLogger { + public static let shared = BuildSettingsLogger() private var loggedSettings: [DocumentURI: FileBuildSettings] = [:] - func log(settings: FileBuildSettings, for uri: DocumentURI) { + public func log(level: LogLevel = .default, settings: FileBuildSettings, for uri: DocumentURI) { guard loggedSettings[uri] != settings else { return } loggedSettings[uri] = settings + Self.log(level: level, settings: settings, for: uri) + } + + /// Log the given build settings. + /// + /// In contrast to the instance method `log`, this will always log the build settings. The instance method only logs + /// the build settings if they have changed. + public static func log(level: LogLevel = .default, settings: FileBuildSettings, for uri: DocumentURI) { let log = """ Compiler Arguments: \(settings.compilerArguments.joined(separator: "\n")) @@ -370,6 +428,7 @@ fileprivate actor BuildSettingsLogger { let chunks = splitLongMultilineMessage(message: log) for (index, chunk) in chunks.enumerated() { logger.log( + level: level, """ Build settings for \(uri.forLogging) (\(index + 1)/\(chunks.count)) \(chunk) diff --git a/Sources/SKCore/CompilationDatabaseBuildSystem.swift b/Sources/SKCore/CompilationDatabaseBuildSystem.swift index 031b1d10c..eab796d52 100644 --- a/Sources/SKCore/CompilationDatabaseBuildSystem.swift +++ b/Sources/SKCore/CompilationDatabaseBuildSystem.swift @@ -93,14 +93,17 @@ public actor CompilationDatabaseBuildSystem { } extension CompilationDatabaseBuildSystem: BuildSystem { - public var indexDatabasePath: AbsolutePath? { indexStorePath?.parentDirectory.appending(component: "IndexDatabase") } public var indexPrefixMappings: [PathPrefixMapping] { return [] } - public func buildSettings(for document: DocumentURI, language: Language) async -> FileBuildSettings? { + public func buildSettings( + for document: DocumentURI, + in buildTarget: ConfiguredTarget, + language: Language + ) async -> FileBuildSettings? { guard let url = document.fileURL else { // We can't determine build settings for non-file URIs. return nil @@ -118,6 +121,20 @@ extension CompilationDatabaseBuildSystem: BuildSystem { return nil } + public func configuredTargets(for document: DocumentURI) async -> [ConfiguredTarget] { + return [ConfiguredTarget(targetID: "dummy", runDestinationID: "dummy")] + } + + public func prepare(targets: [ConfiguredTarget]) async throws { + throw PrepareNotSupportedError() + } + + public func generateBuildGraph() {} + + public func topologicalSort(of targets: [ConfiguredTarget]) -> [ConfiguredTarget]? { + return nil + } + public func registerForChangeNotifications(for uri: DocumentURI) async { self.watchedFiles.insert(uri) } @@ -201,7 +218,7 @@ extension CompilationDatabaseBuildSystem: BuildSystem { return [] } return compdb.allCommands.map { - SourceFileInfo(uri: DocumentURI($0.url), mayContainTests: true) + SourceFileInfo(uri: DocumentURI($0.url), isPartOfRootProject: true, mayContainTests: true) } } diff --git a/Sources/SKCore/FallbackBuildSystem.swift b/Sources/SKCore/FallbackBuildSystem.swift index 0c6fc167a..30c1e73b1 100644 --- a/Sources/SKCore/FallbackBuildSystem.swift +++ b/Sources/SKCore/FallbackBuildSystem.swift @@ -16,12 +16,12 @@ import Foundation import LanguageServerProtocol import SKSupport -@preconcurrency import enum PackageLoading.Platform +import enum PackageLoading.Platform import struct TSCBasic.AbsolutePath import class TSCBasic.Process /// A simple BuildSystem suitable as a fallback when accurate settings are unknown. -public final class FallbackBuildSystem { +public actor FallbackBuildSystem { let buildSetup: BuildSetup @@ -38,6 +38,10 @@ public final class FallbackBuildSystem { ) }() + @_spi(Testing) public func setSdkPath(_ newValue: AbsolutePath?) { + self.sdkpath = newValue + } + /// Delegate to handle any build system events. public weak var delegate: BuildSystemDelegate? = nil diff --git a/Sources/SKCore/TaskScheduler.swift b/Sources/SKCore/TaskScheduler.swift index deb3a3399..58cf052fb 100644 --- a/Sources/SKCore/TaskScheduler.swift +++ b/Sources/SKCore/TaskScheduler.swift @@ -76,6 +76,19 @@ public protocol TaskDescriptionProtocol: Identifiable, Sendable, CustomLogString var estimatedCPUCoreCount: Int { get } } +/// Parameter that's passed to `executionStateChangedCallback` to indicate the new state of a scheduled task. +public enum TaskExecutionState { + /// The task started executing. + case executing + + /// The task was cancelled and will be re-scheduled for execution later. Will be followed by another call with + /// `executing`. + case cancelledToBeRescheduled + + /// The task has finished executing. Now more state updates will come after this one. + case finished +} + fileprivate actor QueuedTask { /// Result of `executionTask` / the tasks in `executionTaskCreatedContinuation`. /// See doc comment on `executionTask`. @@ -136,9 +149,18 @@ fileprivate actor QueuedTask { /// Gets reset every time `executionTask` finishes. nonisolated(unsafe) private var cancelledToBeRescheduled: AtomicBool = .init(initialValue: false) - init(priority: TaskPriority? = nil, description: TaskDescription) async { + /// A callback that will be called when the task starts executing, is cancelled to be rescheduled, or when it finishes + /// execution. + private let executionStateChangedCallback: (@Sendable (TaskExecutionState) async -> Void)? + + init( + priority: TaskPriority? = nil, + description: TaskDescription, + executionStateChangedCallback: (@Sendable (TaskExecutionState) async -> Void)? + ) async { self._priority = .init(initialValue: priority?.rawValue ?? Task.currentPriority.rawValue) self.description = description + self.executionStateChangedCallback = executionStateChangedCallback var updatePriorityContinuation: AsyncStream.Continuation! let updatePriorityStream = AsyncStream { @@ -194,16 +216,19 @@ fileprivate actor QueuedTask { } executionTask = task executionTaskCreatedContinuation.yield(task) + await executionStateChangedCallback?(.executing) return await task.value } /// Implementation detail of `execute` that is called after `self.description.execute()` finishes. - private func finalizeExecution() -> ExecutionTaskFinishStatus { + private func finalizeExecution() async -> ExecutionTaskFinishStatus { self.executionTask = nil if Task.isCancelled && self.cancelledToBeRescheduled.value { + await executionStateChangedCallback?(.cancelledToBeRescheduled) self.cancelledToBeRescheduled.value = false return ExecutionTaskFinishStatus.cancelledToBeRescheduled } else { + await executionStateChangedCallback?(.finished) return ExecutionTaskFinishStatus.terminated } } @@ -308,12 +333,17 @@ public actor TaskScheduler { @discardableResult public func schedule( priority: TaskPriority? = nil, - _ taskDescription: TaskDescription + _ taskDescription: TaskDescription, + @_inheritActorContext executionStateChangedCallback: (@Sendable (TaskExecutionState) async -> Void)? = nil ) async -> Task { withLoggingSubsystemAndScope(subsystem: taskSchedulerSubsystem, scope: nil) { logger.debug("Scheduling \(taskDescription.forLogging)") } - let queuedTask = await QueuedTask(priority: priority, description: taskDescription) + let queuedTask = await QueuedTask( + priority: priority, + description: taskDescription, + executionStateChangedCallback: executionStateChangedCallback + ) pendingTasks.append(queuedTask) Task.detached(priority: priority ?? Task.currentPriority) { // Poke the `TaskScheduler` to execute a new task. If the `TaskScheduler` is already working at its capacity diff --git a/Sources/SKCore/Toolchain.swift b/Sources/SKCore/Toolchain.swift index 746086e39..fcb369620 100644 --- a/Sources/SKCore/Toolchain.swift +++ b/Sources/SKCore/Toolchain.swift @@ -14,7 +14,7 @@ import LSPLogging import LanguageServerProtocol import SKSupport -@preconcurrency import enum PackageLoading.Platform +import enum PackageLoading.Platform import struct TSCBasic.AbsolutePath import protocol TSCBasic.FileSystem import var TSCBasic.localFileSystem diff --git a/Sources/SKSupport/CMakeLists.txt b/Sources/SKSupport/CMakeLists.txt index 5453bb59d..8c5e1685a 100644 --- a/Sources/SKSupport/CMakeLists.txt +++ b/Sources/SKSupport/CMakeLists.txt @@ -11,9 +11,11 @@ add_library(SKSupport STATIC DocumentURI+CustomLogStringConvertible.swift FileSystem.swift LineTable.swift + Process+LaunchWithWorkingDirectoryIfPossible.swift Process+WaitUntilExitWithCancellation.swift Random.swift Result.swift + SwitchableProcessResultExitStatus.swift ThreadSafeBox.swift WorkspaceType.swift ) diff --git a/Sources/SKSupport/Collection+PartitionIntoBatches.swift b/Sources/SKSupport/Collection+PartitionIntoBatches.swift index ed1effdc9..674c11cff 100644 --- a/Sources/SKSupport/Collection+PartitionIntoBatches.swift +++ b/Sources/SKSupport/Collection+PartitionIntoBatches.swift @@ -10,7 +10,7 @@ // //===----------------------------------------------------------------------===// -public extension Collection { +public extension Collection where Index == Int { /// Partition the elements of the collection into `numberOfBatches` roughly equally sized batches. /// /// Elements are assigned to the batches round-robin. This ensures that elements that are close to each other in the @@ -32,4 +32,18 @@ public extension Collection { } return batches.filter { !$0.isEmpty } } + + /// Partition the collection into batches that have a maximum size of `batchSize`. + /// + /// The last batch will contain the remainder elements. + func partition(intoBatchesOfSize batchSize: Int) -> [[Element]] { + var batches: [[Element]] = [] + batches.reserveCapacity(self.count / batchSize) + var lastIndex = self.startIndex + for index in stride(from: self.startIndex, to: self.endIndex, by: batchSize).dropFirst() + [self.endIndex] { + batches.append(Array(self[lastIndex.. Process { + let process = + if let workingDirectory { + Process( + arguments: arguments, + environmentBlock: environmentBlock, + workingDirectory: workingDirectory, + startNewProcessGroup: startNewProcessGroup, + loggingHandler: loggingHandler + ) + } else { + Process( + arguments: arguments, + environmentBlock: environmentBlock, + startNewProcessGroup: startNewProcessGroup, + loggingHandler: loggingHandler + ) + } + do { + try process.launch() + } catch Process.Error.workingDirectoryNotSupported where workingDirectory != nil { + // TODO (indexing): We need to figure out how to set the working directory on all platforms. + logger.error( + "Working directory not supported on the platform. Launching process without working directory \(workingDirectory!.pathString)" + ) + return try Process.launch( + arguments: arguments, + environmentBlock: environmentBlock, + workingDirectory: nil, + startNewProcessGroup: startNewProcessGroup, + loggingHandler: loggingHandler + ) + } + return process + } +} diff --git a/Sources/SKSupport/Process+WaitUntilExitWithCancellation.swift b/Sources/SKSupport/Process+WaitUntilExitWithCancellation.swift index aa6f1c13f..b265161e6 100644 --- a/Sources/SKSupport/Process+WaitUntilExitWithCancellation.swift +++ b/Sources/SKSupport/Process+WaitUntilExitWithCancellation.swift @@ -15,9 +15,10 @@ import Foundation import class TSCBasic.Process import struct TSCBasic.ProcessResult -public extension Process { +extension Process { /// Wait for the process to exit. If the task gets cancelled, during this time, send a `SIGINT` to the process. - func waitUntilExitSendingSigIntOnTaskCancellation() async throws -> ProcessResult { + @discardableResult + public func waitUntilExitSendingSigIntOnTaskCancellation() async throws -> ProcessResult { return try await withTaskCancellationHandler { try await waitUntilExit() } onCancel: { diff --git a/Sources/SKSupport/SwitchableProcessResultExitStatus.swift b/Sources/SKSupport/SwitchableProcessResultExitStatus.swift new file mode 100644 index 000000000..8e6f85733 --- /dev/null +++ b/Sources/SKSupport/SwitchableProcessResultExitStatus.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +// We need to import all of TSCBasic because otherwise we can't refer to Process.ExitStatus (rdar://127577691) +import struct TSCBasic.ProcessResult + +/// Same as `ProcessResult.ExitStatus` in tools-support-core but has the same cases on all platforms and is thus easier +/// to switch over +public enum SwitchableProcessResultExitStatus { + /// The process was terminated normally with a exit code. + case terminated(code: Int32) + /// The process was terminated abnormally. + case abnormal(exception: UInt32) + /// The process was terminated due to a signal. + case signalled(signal: Int32) +} + +extension ProcessResult.ExitStatus { + public var exhaustivelySwitchable: SwitchableProcessResultExitStatus { + #if os(Windows) + switch self { + case .terminated(let code): + return .terminated(code: code) + case .abnormal(let exception): + return .abnormal(exception: exception) + } + #else + switch self { + case .terminated(let code): + return .terminated(code: code) + case .signalled(let signal): + return .signalled(signal: signal) + } + #endif + } +} diff --git a/Sources/SKSupport/ThreadSafeBox.swift b/Sources/SKSupport/ThreadSafeBox.swift index 761331079..5f56b8c66 100644 --- a/Sources/SKSupport/ThreadSafeBox.swift +++ b/Sources/SKSupport/ThreadSafeBox.swift @@ -47,6 +47,12 @@ public class ThreadSafeBox: @unchecked Sendable { _value = initialValue } + public func withLock(_ body: (inout T) -> Result) -> Result { + return lock.withLock { + return body(&_value) + } + } + /// If the value in the box is an optional, return it and reset it to `nil` /// in an atomic operation. public func takeValue() -> T where U? == T { diff --git a/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift b/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift index 4de54a5bd..69497b842 100644 --- a/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift +++ b/Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift @@ -26,12 +26,17 @@ import SourceKitLSPAPI import Workspace import struct Basics.AbsolutePath +import struct Basics.IdentifiableSet import struct Basics.TSCAbsolutePath import struct Foundation.URL +import struct TSCBasic.AbsolutePath import protocol TSCBasic.FileSystem +import class TSCBasic.Process import var TSCBasic.localFileSystem import func TSCBasic.resolveSymlinks +typealias AbsolutePath = Basics.AbsolutePath + #if canImport(SPMBuildCore) import SPMBuildCore #endif @@ -91,10 +96,16 @@ public actor SwiftPMBuildSystem { let workspace: Workspace public let buildParameters: BuildParameters let fileSystem: FileSystem + private let toolchainRegistry: ToolchainRegistry var fileToTarget: [AbsolutePath: SwiftBuildTarget] = [:] var sourceDirToTarget: [AbsolutePath: SwiftBuildTarget] = [:] + /// Maps target ids (aka. `ConfiguredTarget.targetID`) to their SwiftPM build target as well as an index in their + /// topological sorting. Targets with lower index are more low level, ie. targets with higher indices depend on + /// targets with lower indices. + var targets: [String: (index: Int, buildTarget: SwiftBuildTarget)] = [:] + /// The URIs for which the delegate has registered for change notifications, /// mapped to the language the delegate specified when registering for change notifications. var watchedFiles: Set = [] @@ -112,6 +123,18 @@ public actor SwiftPMBuildSystem { /// Force-unwrapped optional because initializing it requires access to `self`. var fileDependenciesUpdatedDebouncer: Debouncer>! = nil + /// A `ObservabilitySystem` from `SwiftPM` that logs. + private let observabilitySystem = ObservabilitySystem({ scope, diagnostic in + logger.log(level: diagnostic.severity.asLogLevel, "SwiftPM log: \(diagnostic.description)") + }) + + /// Whether the SwiftPMBuildSystem may modify `Package.resolved` or not. + /// + /// This is `false` if the `SwiftPMBuildSystem` is pointed at a `.index-build` directory that's independent of the + /// user's build. In this case `SwiftPMBuildSystem` is allowed to clone repositories even if no `Package.resolved` + /// exists. + private let forceResolvedVersions: Bool + /// Creates a build system using the Swift Package Manager, if this workspace is a package. /// /// - Parameters: @@ -125,10 +148,13 @@ public actor SwiftPMBuildSystem { toolchainRegistry: ToolchainRegistry, fileSystem: FileSystem = localFileSystem, buildSetup: BuildSetup, + forceResolvedVersions: Bool, reloadPackageStatusCallback: @escaping (ReloadPackageStatus) async -> Void = { _ in } ) async throws { self.workspacePath = workspacePath self.fileSystem = fileSystem + self.toolchainRegistry = toolchainRegistry + self.forceResolvedVersions = forceResolvedVersions guard let packageRoot = findPackageDirectory(containing: workspacePath, fileSystem) else { throw Error.noManifest(workspacePath: workspacePath) @@ -176,7 +202,11 @@ public actor SwiftPMBuildSystem { flags: buildSetup.flags ) - self.modulesGraph = try ModulesGraph(rootPackages: [], dependencies: [], binaryArtifacts: [:]) + self.modulesGraph = try ModulesGraph( + rootPackages: [], + dependencies: [], + binaryArtifacts: [:] + ) self.reloadPackageStatusCallback = reloadPackageStatusCallback // The debounce duration of 500ms was chosen arbitrarily without scientific research. @@ -191,7 +221,6 @@ public actor SwiftPMBuildSystem { } await delegate.filesDependenciesUpdated(filesWithUpdatedDependencies) } - try await reloadPackage() } @@ -204,6 +233,7 @@ public actor SwiftPMBuildSystem { url: URL, toolchainRegistry: ToolchainRegistry, buildSetup: BuildSetup, + forceResolvedVersions: Bool, reloadPackageStatusCallback: @escaping (ReloadPackageStatus) async -> Void ) async { do { @@ -212,6 +242,7 @@ public actor SwiftPMBuildSystem { toolchainRegistry: toolchainRegistry, fileSystem: localFileSystem, buildSetup: buildSetup, + forceResolvedVersions: forceResolvedVersions, reloadPackageStatusCallback: reloadPackageStatusCallback ) } catch Error.noManifest { @@ -224,6 +255,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. @@ -235,13 +269,9 @@ extension SwiftPMBuildSystem { } } - let observabilitySystem = ObservabilitySystem({ scope, diagnostic in - logger.log(level: diagnostic.severity.asLogLevel, "SwiftPM log: \(diagnostic.description)") - }) - let modulesGraph = try self.workspace.loadPackageGraph( rootInput: PackageGraphRootInput(packages: [AbsolutePath(projectRoot)]), - forceResolvedVersions: true, + forceResolvedVersions: forceResolvedVersions, availableLibraries: self.buildParameters.toolchain.providedLibraries, observabilityScope: observabilitySystem.topScope ) @@ -260,6 +290,16 @@ extension SwiftPMBuildSystem { /// with only some properties modified. self.modulesGraph = modulesGraph + self.targets = Dictionary( + try buildDescription.allTargetsInTopologicalOrder(in: modulesGraph).enumerated().map { (index, target) in + return (key: target.name, (index, target)) + }, + uniquingKeysWith: { first, second in + logger.fault("Found two targets with the same name \(first.buildTarget.name)") + return second + } + ) + self.fileToTarget = [AbsolutePath: SwiftBuildTarget]( modulesGraph.allTargets.flatMap { target in return target.sources.paths.compactMap { @@ -315,36 +355,133 @@ extension SwiftPMBuildSystem: SKCore.BuildSystem { public var indexPrefixMappings: [PathPrefixMapping] { return [] } - public func buildSettings(for uri: DocumentURI, language: Language) throws -> FileBuildSettings? { - guard let url = uri.fileURL else { + public func buildSettings( + for uri: DocumentURI, + in configuredTarget: ConfiguredTarget, + language: Language + ) throws -> FileBuildSettings? { + guard let url = uri.fileURL, let path = try? AbsolutePath(validating: url.path) else { // We can't determine build settings for non-file URIs. return nil } - guard let path = try? AbsolutePath(validating: url.path) else { + + if configuredTarget.targetID == "" { + return try settings(forPackageManifest: path) + } + + guard let buildTarget = self.targets[configuredTarget.targetID]?.buildTarget else { + logger.error("Did not find target with name \(configuredTarget.targetID)") return nil } - if let buildTarget = try buildTarget(for: path) { + if url.pathExtension == "h", let substituteFile = buildTarget.sources.first { return FileBuildSettings( - compilerArguments: try buildTarget.compileArguments(for: path.asURL), + compilerArguments: try buildTarget.compileArguments(for: substituteFile), workingDirectory: workspacePath.pathString - ) + ).patching(newFile: try resolveSymlinks(path).pathString, originalFile: substituteFile.absoluteString) + } + + return FileBuildSettings( + compilerArguments: try buildTarget.compileArguments(for: url), + workingDirectory: workspacePath.pathString + ) + } + + public func defaultLanguage(for document: DocumentURI) async -> Language? { + // TODO (indexing): Query The SwiftPM build system for the document's language. + // https://github.com/apple/sourcekit-lsp/issues/1267 + return nil + } + + public func configuredTargets(for uri: DocumentURI) -> [ConfiguredTarget] { + guard let url = uri.fileURL, let path = try? AbsolutePath(validating: url.path) else { + // We can't determine targets for non-file URIs. + return [] + } + + if let target = try? buildTarget(for: path) { + return [ConfiguredTarget(targetID: target.name, runDestinationID: "dummy")] } if path.basename == "Package.swift" { - return try settings(forPackageManifest: path) + // We use an empty target name to represent the package manifest since an empty target name is not valid for any + // user-defined target. + return [ConfiguredTarget(targetID: "", runDestinationID: "dummy")] } - if path.extension == "h" { - return try settings(forHeader: path, language) + if url.pathExtension == "h", let target = try? target(forHeader: path) { + return [target] } - return nil + return [] } - public func defaultLanguage(for document: DocumentURI) async -> Language? { - // TODO (indexing): Query The SwiftPM build system for the document's language - return nil + public func topologicalSort(of targets: [ConfiguredTarget]) -> [ConfiguredTarget]? { + return targets.sorted { (lhs: ConfiguredTarget, rhs: ConfiguredTarget) -> Bool in + let lhsIndex = self.targets[lhs.targetID]?.index ?? self.targets.count + let rhsIndex = self.targets[lhs.targetID]?.index ?? self.targets.count + return lhsIndex < rhsIndex + } + } + + public func prepare(targets: [ConfiguredTarget]) async throws { + // TODO (indexing): Support preparation of multiple targets at once. + // https://github.com/apple/sourcekit-lsp/issues/1262 + for target in targets { + try await prepare(singleTarget: target) + } + } + + private func prepare(singleTarget target: ConfiguredTarget) async throws { + // TODO (indexing): Add a proper 'prepare' job in SwiftPM instead of building the target. + // https://github.com/apple/sourcekit-lsp/issues/1254 + guard let toolchain = await toolchainRegistry.default else { + logger.error("Not preparing because not toolchain exists") + return + } + guard let swift = toolchain.swift else { + logger.error( + "Not preparing because toolchain at \(toolchain.identifier) does not contain a Swift compiler" + ) + return + } + let arguments = [ + swift.pathString, "build", + "--package-path", workspacePath.pathString, + "--scratch-path", self.workspace.location.scratchDirectory.pathString, + "--disable-index-store", + "--target", target.targetID, + ] + let process = try Process.launch(arguments: arguments, workingDirectory: nil) + let result = try await process.waitUntilExitSendingSigIntOnTaskCancellation() + switch result.exitStatus.exhaustivelySwitchable { + case .terminated(code: 0): + break + case .terminated(code: let code): + // This most likely happens if there are compilation errors in the source file. This is nothing to worry about. + let stdout = (try? String(bytes: result.output.get(), encoding: .utf8)) ?? "" + let stderr = (try? String(bytes: result.stderrOutput.get(), encoding: .utf8)) ?? "" + logger.debug( + """ + Preparation of target \(target.targetID) terminated with non-zero exit code \(code) + Stderr: + \(stderr) + Stdout: + \(stdout) + """ + ) + case .signalled(signal: let signal): + if !Task.isCancelled { + // The indexing job finished with a signal. Could be because the compiler crashed. + // Ignore signal exit codes if this task has been cancelled because the compiler exits with SIGINT if it gets + // interrupted. + logger.error("Preparation of target \(target.targetID) signaled \(signal)") + } + case .abnormal(exception: let exception): + if !Task.isCancelled { + logger.error("Preparation of target \(target.targetID) exited abnormally \(exception)") + } + } } public func registerForChangeNotifications(for uri: DocumentURI) async { @@ -433,26 +570,19 @@ extension SwiftPMBuildSystem: SKCore.BuildSystem { } public func fileHandlingCapability(for uri: DocumentURI) -> FileHandlingCapability { - guard let fileUrl = uri.fileURL else { - return .unhandled - } - if (try? buildTarget(for: AbsolutePath(validating: fileUrl.path))) != nil { - return .handled - } else { + if configuredTargets(for: uri).isEmpty { return .unhandled } + return .handled } public func sourceFiles() -> [SourceFileInfo] { return fileToTarget.compactMap { (path, target) -> SourceFileInfo? in - guard target.isPartOfRootPackage else { - // Don't consider files from package dependencies as possible test files. - return nil - } // We should only set mayContainTests to `true` for files from test targets // (https://github.com/apple/sourcekit-lsp/issues/1174). return SourceFileInfo( uri: DocumentURI(path.asURL), + isPartOfRootProject: target.isPartOfRootPackage, mayContainTests: true ) } @@ -485,25 +615,13 @@ extension SwiftPMBuildSystem { return canonicalPath == path ? nil : impl(canonicalPath) } - /// Retrieve settings for a given header file. - /// - /// This finds the target the header belongs to based on its location in the file system, retrieves the build settings - /// for any file within that target and generates compiler arguments by replacing that picked file with the header - /// file. - /// This is safe because all files within one target have the same build settings except for reference to the file - /// itself, which we are replacing. - private func settings(forHeader path: AbsolutePath, _ language: Language) throws -> FileBuildSettings? { - func impl(_ path: AbsolutePath) throws -> FileBuildSettings? { + /// This finds the target the header belongs to based on its location in the file system. + private func target(forHeader path: AbsolutePath) throws -> ConfiguredTarget? { + func impl(_ path: AbsolutePath) throws -> ConfiguredTarget? { var dir = path.parentDirectory while !dir.isRoot { if let buildTarget = sourceDirToTarget[dir] { - if let sourceFile = buildTarget.sources.first { - return FileBuildSettings( - compilerArguments: try buildTarget.compileArguments(for: sourceFile), - workingDirectory: workspacePath.pathString - ).patching(newFile: path.pathString, originalFile: sourceFile.absoluteString) - } - return nil + return ConfiguredTarget(targetID: buildTarget.name, runDestinationID: "dummy") } dir = dir.parentDirectory } diff --git a/Sources/SKTestSupport/FileManager+findFiles.swift b/Sources/SKTestSupport/FileManager+findFiles.swift index 232d158a2..b4bf60d12 100644 --- a/Sources/SKTestSupport/FileManager+findFiles.swift +++ b/Sources/SKTestSupport/FileManager+findFiles.swift @@ -24,4 +24,16 @@ extension FileManager { } return result } + + /// Returns the URLs of all files with the given file name in the given directory (recursively). + public func findFiles(named name: String, in directory: URL) -> [URL] { + var result: [URL] = [] + let enumerator = self.enumerator(at: directory, includingPropertiesForKeys: nil) + while let url = enumerator?.nextObject() as? URL { + if url.lastPathComponent == name { + result.append(url) + } + } + return result + } } diff --git a/Sources/SKTestSupport/MultiFileTestProject.swift b/Sources/SKTestSupport/MultiFileTestProject.swift index 68baba274..ea300f497 100644 --- a/Sources/SKTestSupport/MultiFileTestProject.swift +++ b/Sources/SKTestSupport/MultiFileTestProject.swift @@ -80,8 +80,10 @@ public class MultiFileTestProject { public init( files: [RelativeFileLocation: String], workspaces: (URL) async throws -> [WorkspaceFolder] = { [WorkspaceFolder(uri: DocumentURI($0))] }, + capabilities: ClientCapabilities = ClientCapabilities(), serverOptions: SourceKitLSPServer.Options = .testDefault, usePullDiagnostics: Bool = true, + preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, testName: String = #function ) async throws { scratchDirectory = try testScratchDir(testName: testName) @@ -112,8 +114,10 @@ public class MultiFileTestProject { self.testClient = try await TestSourceKitLSPClient( serverOptions: serverOptions, + capabilities: capabilities, usePullDiagnostics: usePullDiagnostics, workspaceFolders: workspaces(scratchDirectory), + preInitialization: preInitialization, cleanUp: { [scratchDirectory] in if cleanScratchDirectories { try? FileManager.default.removeItem(at: scratchDirectory) diff --git a/Sources/SKTestSupport/SwiftPMDependencyProject.swift b/Sources/SKTestSupport/SwiftPMDependencyProject.swift index c35c7ddd9..496834c5c 100644 --- a/Sources/SKTestSupport/SwiftPMDependencyProject.swift +++ b/Sources/SKTestSupport/SwiftPMDependencyProject.swift @@ -71,13 +71,13 @@ public class SwiftPMDependencyProject { var files = files files["Package.swift"] = manifest - for (fileLocation, contents) in files { + for (fileLocation, markedContents) in files { let fileURL = fileLocation.url(relativeTo: packageDirectory) try FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) - try contents.write(to: fileURL, atomically: true, encoding: .utf8) + try extractMarkers(markedContents).textWithoutMarkers.write(to: fileURL, atomically: true, encoding: .utf8) } try await runGitCommand(["init"], workingDirectory: packageDirectory) diff --git a/Sources/SKTestSupport/SwiftPMTestProject.swift b/Sources/SKTestSupport/SwiftPMTestProject.swift index aa2737cea..abd4a20d2 100644 --- a/Sources/SKTestSupport/SwiftPMTestProject.swift +++ b/Sources/SKTestSupport/SwiftPMTestProject.swift @@ -42,7 +42,10 @@ public class SwiftPMTestProject: MultiFileTestProject { workspaces: (URL) async throws -> [WorkspaceFolder] = { [WorkspaceFolder(uri: DocumentURI($0))] }, build: Bool = false, allowBuildFailure: Bool = false, + capabilities: ClientCapabilities = ClientCapabilities(), serverOptions: SourceKitLSPServer.Options = .testDefault, + pollIndex: Bool = true, + preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, usePullDiagnostics: Bool = true, testName: String = #function ) async throws { @@ -65,8 +68,10 @@ public class SwiftPMTestProject: MultiFileTestProject { try await super.init( files: filesByPath, workspaces: workspaces, + capabilities: capabilities, serverOptions: serverOptions, usePullDiagnostics: usePullDiagnostics, + preInitialization: preInitialization, testName: testName ) @@ -77,8 +82,10 @@ public class SwiftPMTestProject: MultiFileTestProject { try await Self.build(at: self.scratchDirectory) } } - // Wait for the indexstore-db to finish indexing - _ = try await testClient.send(PollIndexRequest()) + if pollIndex { + // Wait for the indexstore-db to finish indexing + _ = try await testClient.send(PollIndexRequest()) + } } /// Build a SwiftPM package package manifest is located in the directory at `path`. diff --git a/Sources/SKTestSupport/TestSourceKitLSPClient.swift b/Sources/SKTestSupport/TestSourceKitLSPClient.swift index 1ab9a7541..f3854c039 100644 --- a/Sources/SKTestSupport/TestSourceKitLSPClient.swift +++ b/Sources/SKTestSupport/TestSourceKitLSPClient.swift @@ -83,6 +83,8 @@ public final class TestSourceKitLSPClient: MessageHandler { /// - capabilities: The test client's capabilities. /// - usePullDiagnostics: Whether to use push diagnostics or use push-based diagnostics /// - 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. /// - cleanUp: A closure that is called when the `TestSourceKitLSPClient` is destructed. /// This allows e.g. a `IndexedSingleSwiftFileTestProject` to delete its temporary files when they are no longer /// needed. @@ -94,6 +96,7 @@ public final class TestSourceKitLSPClient: MessageHandler { capabilities: ClientCapabilities = ClientCapabilities(), usePullDiagnostics: Bool = true, workspaceFolders: [WorkspaceFolder]? = nil, + preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, cleanUp: @escaping () -> Void = {} ) async throws { if !useGlobalModuleCache { @@ -135,7 +138,7 @@ public final class TestSourceKitLSPClient: MessageHandler { guard capabilities.textDocument!.diagnostic == nil else { struct ConflictingDiagnosticsError: Error, CustomStringConvertible { var description: String { - "usePushDiagnostics = false is not supported if capabilities already contain diagnostic options" + "usePullDiagnostics = false is not supported if capabilities already contain diagnostic options" } } throw ConflictingDiagnosticsError() @@ -145,6 +148,7 @@ public final class TestSourceKitLSPClient: MessageHandler { XCTAssertEqual(request.registrations.only?.method, DocumentDiagnosticsRequest.method) return VoidResponse() } + preInitialization?(self) } if initialize { _ = try await self.send( @@ -286,18 +290,19 @@ public final class TestSourceKitLSPClient: MessageHandler { id: LanguageServerProtocol.RequestID, reply: @escaping (LSPResult) -> Void ) { - guard let requestHandler = requestHandlers.first else { - reply(.failure(.methodNotFound(Request.method))) - return - } - guard let requestHandler = requestHandler as? RequestHandler else { - print("\(RequestHandler.self)") - XCTFail("Received request of unexpected type \(Request.method)") + let requestHandlerAndIndex = requestHandlers.enumerated().compactMap { + (index, handler) -> (RequestHandler, Int)? in + guard let handler = handler as? RequestHandler else { + return nil + } + return (handler, index) + }.first + guard let (requestHandler, index) = requestHandlerAndIndex else { reply(.failure(.methodNotFound(Request.method))) return } reply(.success(requestHandler(params))) - requestHandlers.removeFirst() + requestHandlers.remove(at: index) } // MARK: - Convenience functions diff --git a/Sources/SKTestSupport/WrappedSemaphore.swift b/Sources/SKTestSupport/WrappedSemaphore.swift new file mode 100644 index 000000000..ee2036557 --- /dev/null +++ b/Sources/SKTestSupport/WrappedSemaphore.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// 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 Dispatch + +/// Wrapper around `DispatchSemaphore` so that Swift Concurrency doesn't complain about the usage of semaphores in the +/// tests. +/// +/// This should only be used for tests that test priority escalation and thus cannot await a `Task` (which would cause +/// priority elevations). +public struct WrappedSemaphore { + let semaphore = DispatchSemaphore(value: 0) + + public init() {} + + public func signal(value: Int = 1) { + for _ in 0.. CheckedIndex { return CheckedIndex(index: underlyingIndexStoreDB, checkLevel: checkLevel) } diff --git a/Sources/SemanticIndex/IndexTaskDescription.swift b/Sources/SemanticIndex/IndexTaskDescription.swift new file mode 100644 index 000000000..33f032dcd --- /dev/null +++ b/Sources/SemanticIndex/IndexTaskDescription.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// 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 SKCore + +/// Protocol of tasks that are executed on the index task scheduler. +/// +/// It is assumed that `IndexTaskDescription` of different types are allowed to execute in parallel. +protocol IndexTaskDescription: TaskDescriptionProtocol { + /// A string that is unique to this type of `IndexTaskDescription`. It is used to produce unique IDs for tasks of + /// different types in `AnyIndexTaskDescription` + static var idPrefix: String { get } + + var id: UInt32 { get } +} + +extension IndexTaskDescription { + func dependencies( + to currentlyExecutingTasks: [AnyIndexTaskDescription] + ) -> [TaskDependencyAction] { + return self.dependencies(to: currentlyExecutingTasks.compactMap { $0.wrapped as? Self }) + .map { + switch $0 { + case .cancelAndRescheduleDependency(let td): + return .cancelAndRescheduleDependency(AnyIndexTaskDescription(td)) + case .waitAndElevatePriorityOfDependency(let td): + return .waitAndElevatePriorityOfDependency(AnyIndexTaskDescription(td)) + } + } + + } +} + +/// Type-erased wrapper of an `IndexTaskDescription`. +public struct AnyIndexTaskDescription: TaskDescriptionProtocol { + let wrapped: any IndexTaskDescription + + init(_ wrapped: any IndexTaskDescription) { + self.wrapped = wrapped + } + + public var isIdempotent: Bool { + return wrapped.isIdempotent + } + + public var estimatedCPUCoreCount: Int { + return wrapped.estimatedCPUCoreCount + } + + public var id: String { + return "\(type(of: wrapped).idPrefix)-\(wrapped.id)" + } + + public var description: String { + return wrapped.description + } + + public var redactedDescription: String { + return wrapped.redactedDescription + } + + public func execute() async { + return await wrapped.execute() + } + + /// Forward to the underlying task to compute the dependencies. Preparation and index tasks don't have any + /// dependencies that are managed by `TaskScheduler`. `SemanticIndexManager` awaits the preparation of a target before + /// indexing files within it. + public func dependencies( + to currentlyExecutingTasks: [AnyIndexTaskDescription] + ) -> [TaskDependencyAction] { + return wrapped.dependencies(to: currentlyExecutingTasks) + } +} diff --git a/Sources/SemanticIndex/PreparationTaskDescription.swift b/Sources/SemanticIndex/PreparationTaskDescription.swift new file mode 100644 index 000000000..7765c63e3 --- /dev/null +++ b/Sources/SemanticIndex/PreparationTaskDescription.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// +// 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 CAtomics +import Foundation +import LSPLogging +import LanguageServerProtocol +import SKCore + +import struct TSCBasic.AbsolutePath +import class TSCBasic.Process + +private var preparationIDForLogging = AtomicUInt32(initialValue: 1) + +/// Describes a task to prepare a set of targets. +/// +/// This task description can be scheduled in a `TaskScheduler`. +public struct PreparationTaskDescription: IndexTaskDescription { + public static let idPrefix = "prepare" + + public let id = preparationIDForLogging.fetchAndIncrement() + + /// The targets that should be prepared. + private let targetsToPrepare: [ConfiguredTarget] + + /// The build system manager that is used to get the toolchain and build settings for the files to index. + private let buildSystemManager: BuildSystemManager + + /// The task is idempotent because preparing the same target twice produces the same result as preparing it once. + public var isIdempotent: Bool { true } + + public var estimatedCPUCoreCount: Int { 1 } + + public var description: String { + return self.redactedDescription + } + + public var redactedDescription: String { + return "preparation-\(id)" + } + + init( + targetsToPrepare: [ConfiguredTarget], + buildSystemManager: BuildSystemManager + ) { + self.targetsToPrepare = targetsToPrepare + self.buildSystemManager = buildSystemManager + } + + public func execute() async { + // 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 withLoggingScope("preparation-\(id % 100)") { + let startDate = Date() + let targetsToPrepare = targetsToPrepare.sorted(by: { + ($0.targetID, $0.runDestinationID) < ($1.targetID, $1.runDestinationID) + }) + let targetsToPrepareDescription = + targetsToPrepare + .map { "\($0.targetID)-\($0.runDestinationID)" } + .joined(separator: ", ") + logger.log( + "Starting preparation with priority \(Task.currentPriority.rawValue, privacy: .public): \(targetsToPrepareDescription)" + ) + do { + try await buildSystemManager.prepare(targets: targetsToPrepare) + } catch { + logger.error( + "Preparation failed: \(error.forLogging)" + ) + } + logger.log( + "Finished preparation in \(Date().timeIntervalSince(startDate) * 1000, privacy: .public)ms: \(targetsToPrepareDescription)" + ) + } + } + + public func dependencies( + to currentlyExecutingTasks: [PreparationTaskDescription] + ) -> [TaskDependencyAction] { + return currentlyExecutingTasks.compactMap { (other) -> TaskDependencyAction? in + if other.targetsToPrepare.count > self.targetsToPrepare.count { + // If there is an prepare operation with more targets already running, suspend it. + // The most common use case for this is if we prepare all targets simultaneously during the initial preparation + // when a project is opened and need a single target indexed for user interaction. We should suspend the + // workspace-wide preparation and just prepare the currently needed target. + return .cancelAndRescheduleDependency(other) + } + return .waitAndElevatePriorityOfDependency(other) + } + } +} diff --git a/Sources/SemanticIndex/SemanticIndexManager.swift b/Sources/SemanticIndex/SemanticIndexManager.swift new file mode 100644 index 000000000..c60a12a3e --- /dev/null +++ b/Sources/SemanticIndex/SemanticIndexManager.swift @@ -0,0 +1,328 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import LSPLogging +import LanguageServerProtocol +import SKCore + +/// Describes the state of indexing for a single source file +private enum FileIndexStatus { + /// The index is up-to-date. + case upToDate + /// The file is not up to date and we have scheduled a task to index it but that index operation hasn't been started + /// yet. + case scheduled(Task) + /// We are currently actively indexing this file, ie. we are running a subprocess that indexes the file. + case executing(Task) + + var description: String { + switch self { + case .upToDate: + return "upToDate" + case .scheduled: + return "scheduled" + case .executing: + return "executing" + } + } +} + +/// 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 + /// need to be indexed again. + private let index: CheckedIndex + + /// The build system manager that is used to get compiler arguments for a file. + private let buildSystemManager: BuildSystemManager + + /// The index status of the source files that the `SemanticIndexManager` knows about. + /// + /// Files that have never been indexed are not in this dictionary. + private var indexStatus: [DocumentURI: FileIndexStatus] = [:] + + /// The task to generate the build graph (resolving package dependencies, generating the build description, + /// ...). `nil` if no build graph is currently being generated. + private var generateBuildGraphTask: Task? + + /// The `TaskScheduler` that manages the scheduling of index tasks. This is shared among all `SemanticIndexManager`s + /// in the process, to ensure that we don't schedule more index operations than processor cores from multiple + /// workspaces. + private let indexTaskScheduler: TaskScheduler + + /// Called when files are scheduled to be indexed. + /// + /// 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 an index task has finished. + /// + /// 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 `indexTaskDidFinish` calls does not have to relate to the number of `indexTasksWereScheduled` calls. + private let indexTaskDidFinish: @Sendable () -> Void + + // MARK: - Public API + + /// The files that still need to be indexed. + /// + /// See `FileIndexStatus` for the distinction between `scheduled` and `executing`. + public var inProgressIndexTasks: (scheduled: [DocumentURI], executing: [DocumentURI]) { + let scheduled = indexStatus.compactMap { (uri: DocumentURI, status: FileIndexStatus) in + if case .scheduled = status { + return uri + } + return nil + } + let inProgress = indexStatus.compactMap { (uri: DocumentURI, status: FileIndexStatus) in + if case .executing = status { + return uri + } + return nil + } + return (scheduled, inProgress) + } + + public init( + index: UncheckedIndex, + buildSystemManager: BuildSystemManager, + indexTaskScheduler: TaskScheduler, + indexTasksWereScheduled: @escaping @Sendable (Int) -> Void, + indexTaskDidFinish: @escaping @Sendable () -> Void + ) { + self.index = index.checked(for: .modifiedFiles) + self.buildSystemManager = buildSystemManager + self.indexTaskScheduler = indexTaskScheduler + self.indexTasksWereScheduled = indexTasksWereScheduled + self.indexTaskDidFinish = indexTaskDidFinish + } + + /// Schedules a task to index all files in `files` that don't already have an up-to-date index. + /// Returns immediately after scheduling that task. + /// + /// Indexing is being performed with a low priority. + public func scheduleBackgroundIndex(files: some Collection) async { + await self.index(files: files, priority: .low) + } + + /// Regenerate the build graph (also resolving package dependencies) and then index all the source files known to the + /// build system. + public func scheduleBuildGraphGenerationAndBackgroundIndexAllFiles() async { + generateBuildGraphTask = Task(priority: .low) { + await orLog("Generating build graph") { try await self.buildSystemManager.generateBuildGraph() } + await scheduleBackgroundIndex(files: await self.buildSystemManager.sourceFiles().map(\.uri)) + generateBuildGraphTask = nil + } + } + + /// Wait for all in-progress index tasks to finish. + public func waitForUpToDateIndex() async { + logger.info("Waiting for up-to-date index") + // Wait for a build graph update first, if one is in progress. This will add all index tasks to `indexStatus`, so we + // can await the index tasks below. + await generateBuildGraphTask?.value + + await withTaskGroup(of: Void.self) { taskGroup in + for (_, status) in indexStatus { + switch status { + case .scheduled(let task), .executing(let task): + taskGroup.addTask { + await task.value + } + case .upToDate: + break + } + } + await taskGroup.waitForAll() + } + index.pollForUnitChangesAndWait() + logger.debug("Done waiting for up-to-date index") + } + + /// Ensure that the index for the given files is up-to-date. + /// + /// This tries to produce an up-to-date index for the given files as quickly as possible. To achieve this, it might + /// suspend previous target-wide index tasks in favor of index tasks that index a fewer files. + public func waitForUpToDateIndex(for uris: some Collection) async { + logger.info( + "Waiting for up-to-date index for \(uris.map { $0.fileURL?.lastPathComponent ?? $0.stringValue }.joined(separator: ", "))" + ) + // If there's a build graph update in progress wait for that to finish so we can discover new files in the build + // system. + await generateBuildGraphTask?.value + + // Create a new index task for the files that aren't up-to-date. The newly scheduled index tasks will + // - Wait for the existing index operations to finish if they have the same number of files. + // - Reschedule the background index task in favor of an index task with fewer source files. + await self.index(files: uris, priority: nil).value + index.pollForUnitChangesAndWait() + logger.debug("Done waiting for up-to-date index") + } + + // MARK: - Helper functions + + /// Prepare the given targets for indexing + private func prepare(targets: [ConfiguredTarget], priority: TaskPriority?) async { + let taskDescription = AnyIndexTaskDescription( + PreparationTaskDescription( + targetsToPrepare: targets, + buildSystemManager: self.buildSystemManager + ) + ) + await self.indexTaskScheduler.schedule(priority: priority, taskDescription).value + self.indexTaskDidFinish() + } + + /// Update the index store for the given files, assuming that their targets have already been prepared. + private func updateIndexStore(for files: [DocumentURI], priority: TaskPriority?) async { + let taskDescription = AnyIndexTaskDescription( + UpdateIndexStoreTaskDescription( + filesToIndex: Set(files), + buildSystemManager: self.buildSystemManager, + index: self.index.unchecked + ) + ) + let updateIndexStoreTask = await self.indexTaskScheduler.schedule(priority: priority, taskDescription) { newState in + switch newState { + case .executing: + for file in files { + if case .scheduled(let task) = self.indexStatus[file] { + self.indexStatus[file] = .executing(task) + } else { + logger.fault( + """ + Index status of \(file) is in an unexpected state \ + '\(self.indexStatus[file]?.description ?? "", privacy: .public)' when update index store task \ + started executing + """ + ) + } + } + case .cancelledToBeRescheduled: + for file in files { + if case .executing(let task) = self.indexStatus[file] { + self.indexStatus[file] = .scheduled(task) + } else { + logger.fault( + """ + Index status of \(file) is in an unexpected state \ + '\(self.indexStatus[file]?.description ?? "", privacy: .public)' when update index store task \ + is cancelled to be rescheduled. + """ + ) + } + } + case .finished: + for file in files { + self.indexStatus[file] = .upToDate + } + self.indexTaskDidFinish() + } + } + await updateIndexStoreTask.value + } + + /// Index the given set of files at the given priority. + /// + /// The returned task finishes when all files are indexed. + @discardableResult + private func index(files: some Collection, priority: TaskPriority?) async -> Task { + let outOfDateFiles = files.filter { + if case .upToDate = indexStatus[$0] { + return false + } + return true + } + .sorted(by: { $0.stringValue < $1.stringValue }) // sort files to get deterministic indexing order + + // 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. + var filesByTarget: [ConfiguredTarget: [DocumentURI]] = [:] + for file in outOfDateFiles { + guard let target = await buildSystemManager.canonicalConfiguredTarget(for: file) else { + logger.error("Not indexing \(file.forLogging) because the target could not be determined") + continue + } + filesByTarget[target, default: []].append(file) + } + + var sortedTargets: [ConfiguredTarget] = + await orLog("Sorting targets") { try await buildSystemManager.topologicalSort(of: Array(filesByTarget.keys)) } + ?? Array(filesByTarget.keys).sorted(by: { + ($0.targetID, $0.runDestinationID) < ($1.targetID, $1.runDestinationID) + }) + + if Set(sortedTargets) != Set(filesByTarget.keys) { + logger.fault( + """ + Sorting targets topologically changed set of targets: + \(sortedTargets.map(\.targetID).joined(separator: ", ")) != \(filesByTarget.keys.map(\.targetID).joined(separator: ", ")) + """ + ) + sortedTargets = Array(filesByTarget.keys).sorted(by: { + ($0.targetID, $0.runDestinationID) < ($1.targetID, $1.runDestinationID) + }) + } + + var indexTasks: [Task] = [] + + // TODO (indexing): When we can index multiple targets concurrently in SwiftPM, increase the batch size to half the + // processor count, so we can get parallelism during preparation. + // https://github.com/apple/sourcekit-lsp/issues/1262 + for targetsBatch in sortedTargets.partition(intoBatchesOfSize: 1) { + let indexTask = Task(priority: priority) { + // First prepare the targets. + await prepare(targets: targetsBatch, priority: priority) + + // And after preparation is done, index the files in the targets. + await withTaskGroup(of: Void.self) { taskGroup in + for target in targetsBatch { + // TODO (indexing): Once swiftc supports indexing of multiple files in a single invocation, increase the + // batch size to allow it to share AST builds between multiple files within a target. + // https://github.com/apple/sourcekit-lsp/issues/1268 + for fileBatch in filesByTarget[target]!.partition(intoBatchesOfSize: 1) { + taskGroup.addTask { + await self.updateIndexStore(for: fileBatch, priority: priority) + } + } + } + await taskGroup.waitForAll() + } + } + indexTasks.append(indexTask) + + let filesToIndex = targetsBatch.flatMap({ filesByTarget[$0]! }) + for file in filesToIndex { + // indexStatus will get set to `.upToDate` by `updateIndexStore`. Setting it to `.upToDate` cannot race with + // setting it to `.scheduled` because we don't have an `await` call between the creation of `indexTask` and + // this loop, so we still have exclusive access to the `SemanticIndexManager` actor and hence `updateIndexStore` + // can't execute until we have set all index statuses to `.scheduled`. + indexStatus[file] = .scheduled(indexTask) + } + indexTasksWereScheduled(filesToIndex.count) + } + let indexTasksImmutable = indexTasks + + return Task(priority: priority) { + await withTaskGroup(of: Void.self) { taskGroup in + for indexTask in indexTasksImmutable { + taskGroup.addTask { + await indexTask.value + } + } + await taskGroup.waitForAll() + } + } + } +} diff --git a/Sources/SemanticIndex/UpdateIndexStoreTaskDescription.swift b/Sources/SemanticIndex/UpdateIndexStoreTaskDescription.swift new file mode 100644 index 000000000..cd8b48ea6 --- /dev/null +++ b/Sources/SemanticIndex/UpdateIndexStoreTaskDescription.swift @@ -0,0 +1,394 @@ +//===----------------------------------------------------------------------===// +// +// 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 CAtomics +import Foundation +import LSPLogging +import LanguageServerProtocol +import SKCore +import SKSupport + +import struct TSCBasic.AbsolutePath +import class TSCBasic.Process + +private nonisolated(unsafe) var updateIndexStoreIDForLogging = AtomicUInt32(initialValue: 1) + +/// Describes a task to index a set of source files. +/// +/// This task description can be scheduled in a `TaskScheduler`. +public struct UpdateIndexStoreTaskDescription: IndexTaskDescription { + public static let idPrefix = "update-indexstore" + public let id = updateIndexStoreIDForLogging.fetchAndIncrement() + + /// The files that should be indexed. + private let filesToIndex: Set + + /// The build system manager that is used to get the toolchain and build settings for the files to index. + private let buildSystemManager: BuildSystemManager + + /// A reference to the underlying index store. Used to check if the index is already up-to-date for a file, in which + /// case we don't need to index it again. + private let index: UncheckedIndex + + /// The task is idempotent because indexing the same file twice produces the same result as indexing it once. + public var isIdempotent: Bool { true } + + public var estimatedCPUCoreCount: Int { 1 } + + public var description: String { + return self.redactedDescription + } + + public var redactedDescription: String { + return "update-indexstore-\(id)" + } + + init( + filesToIndex: Set, + buildSystemManager: BuildSystemManager, + index: UncheckedIndex + ) { + self.filesToIndex = filesToIndex + self.buildSystemManager = buildSystemManager + self.index = index + } + + public func execute() async { + // 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)" + ) { + let startDate = Date() + + let filesToIndexDescription = filesToIndex.map { $0.fileURL?.lastPathComponent ?? $0.stringValue } + .joined(separator: ", ") + logger.log( + "Starting updating index store with priority \(Task.currentPriority.rawValue, privacy: .public): \(filesToIndexDescription)" + ) + let filesToIndex = filesToIndex.sorted(by: { $0.stringValue < $1.stringValue }) + // TODO (indexing): Once swiftc supports it, we should group files by target and index files within the same + // target together in one swiftc invocation. + // https://github.com/apple/sourcekit-lsp/issues/1268 + for file in filesToIndex { + await updateIndexStoreForSingleFile(file) + } + logger.log( + "Finished updating index store in \(Date().timeIntervalSince(startDate) * 1000, privacy: .public)ms: \(filesToIndexDescription)" + ) + } + } + + public func dependencies( + to currentlyExecutingTasks: [UpdateIndexStoreTaskDescription] + ) -> [TaskDependencyAction] { + return currentlyExecutingTasks.compactMap { (other) -> TaskDependencyAction? in + guard !other.filesToIndex.intersection(filesToIndex).isEmpty else { + // Disjoint sets of files can be indexed concurrently. + return nil + } + if self.filesToIndex.count < other.filesToIndex.count { + // If there is an index operation with more files already running, suspend it. + // The most common use case for this is if we schedule an entire target to be indexed in the background and then + // need a single file indexed for use interaction. We should suspend the target-wide indexing and just index + // the current file to get index data for it ASAP. + return .cancelAndRescheduleDependency(other) + } else { + return .waitAndElevatePriorityOfDependency(other) + } + } + } + + private func updateIndexStoreForSingleFile(_ uri: DocumentURI) async { + guard let url = uri.fileURL else { + // The URI is not a file, so there's nothing we can index. + return + } + guard !index.checked(for: .modifiedFiles).hasUpToDateUnit(for: url) else { + // We consider a file's index up-to-date if we have any up-to-date unit. Changing build settings does not + // invalidate the up-to-date status of the index. + return + } + guard let language = await buildSystemManager.defaultLanguage(for: uri) else { + logger.error("Not indexing \(uri.forLogging) because its language could not be determined") + return + } + let buildSettings = await buildSystemManager.buildSettingsInferredFromMainFile( + for: uri, + language: language, + logBuildSettings: false + ) + guard let buildSettings else { + logger.error("Not indexing \(uri.forLogging) because it has no compiler arguments") + return + } + guard !buildSettings.isFallback else { + // Only index with real build settings. Indexing with fallback arguments could result in worse results than not + // indexing at all: If a file has been indexed with real build settings before, had a tiny modification made but + // we don't have any real build settings when it should get re-indexed. Then it's better to have the stale index + // from correct compiler arguments than no index at all. + logger.error("Not updating index store for \(uri.forLogging) because it has fallback compiler arguments") + return + } + guard let toolchain = await buildSystemManager.toolchain(for: uri, language) else { + logger.error( + "Not updating index store for \(uri.forLogging) because no toolchain could be determined for the document" + ) + return + } + switch language { + case .swift: + do { + try await updateIndexStore(forSwiftFile: uri, buildSettings: buildSettings, toolchain: toolchain) + } catch { + logger.error("Updating index store for \(uri) failed: \(error.forLogging)") + BuildSettingsLogger.log(settings: buildSettings, for: uri) + } + case .c, .cpp, .objective_c, .objective_cpp: + do { + try await updateIndexStore(forClangFile: uri, buildSettings: buildSettings, toolchain: toolchain) + } catch { + logger.error("Updating index store for \(uri) failed: \(error.forLogging)") + BuildSettingsLogger.log(settings: buildSettings, for: uri) + } + default: + logger.error( + "Not updating index store for \(uri) because it is a language that is not supported by background indexing" + ) + } + } + + private func updateIndexStore( + forSwiftFile uri: DocumentURI, + buildSettings: FileBuildSettings, + toolchain: Toolchain + ) async throws { + guard let swiftc = toolchain.swiftc else { + logger.error( + "Not updating index store for \(uri.forLogging) because toolchain \(toolchain.identifier) does not contain a Swift compiler" + ) + return + } + + let indexingArguments = adjustSwiftCompilerArgumentsForIndexStoreUpdate( + buildSettings.compilerArguments, + fileToIndex: uri + ) + + try await runIndexingProcess( + indexFile: uri, + buildSettings: buildSettings, + processArguments: [swiftc.pathString] + indexingArguments, + workingDirectory: buildSettings.workingDirectory.map(AbsolutePath.init(validating:)) + ) + } + + private func updateIndexStore( + forClangFile uri: DocumentURI, + buildSettings: FileBuildSettings, + toolchain: Toolchain + ) async throws { + guard let clang = toolchain.clang else { + logger.error( + "Not updating index store for \(uri.forLogging) because toolchain \(toolchain.identifier) does not contain clang" + ) + return + } + + let indexingArguments = adjustClangCompilerArgumentsForIndexStoreUpdate( + buildSettings.compilerArguments, + fileToIndex: uri + ) + + try await runIndexingProcess( + indexFile: uri, + buildSettings: buildSettings, + processArguments: [clang.pathString] + indexingArguments, + workingDirectory: buildSettings.workingDirectory.map(AbsolutePath.init(validating:)) + ) + } + + private func runIndexingProcess( + indexFile: DocumentURI, + buildSettings: FileBuildSettings, + processArguments: [String], + workingDirectory: AbsolutePath? + ) async throws { + let process = try Process.launch( + arguments: processArguments, + workingDirectory: workingDirectory + ) + let result = try await process.waitUntilExitSendingSigIntOnTaskCancellation() + switch result.exitStatus.exhaustivelySwitchable { + case .terminated(code: 0): + break + case .terminated(code: let code): + // This most likely happens if there are compilation errors in the source file. This is nothing to worry about. + let stdout = (try? String(bytes: result.output.get(), encoding: .utf8)) ?? "" + let stderr = (try? String(bytes: result.stderrOutput.get(), encoding: .utf8)) ?? "" + // Indexing will frequently fail if the source code is in an invalid state. Thus, log the failure at a low level. + logger.debug( + """ + Updating index store for \(indexFile.forLogging) terminated with non-zero exit code \(code) + Stderr: + \(stderr) + Stdout: + \(stdout) + """ + ) + BuildSettingsLogger.log(level: .debug, settings: buildSettings, for: indexFile) + case .signalled(signal: let signal): + if !Task.isCancelled { + // The indexing job finished with a signal. Could be because the compiler crashed. + // Ignore signal exit codes if this task has been cancelled because the compiler exits with SIGINT if it gets + // interrupted. + logger.error("Updating index store for \(indexFile.forLogging) signaled \(signal)") + BuildSettingsLogger.log(level: .error, settings: buildSettings, for: indexFile) + } + case .abnormal(exception: let exception): + if !Task.isCancelled { + logger.error("Updating index store for \(indexFile.forLogging) exited abnormally \(exception)") + BuildSettingsLogger.log(level: .error, settings: buildSettings, for: indexFile) + } + } + } +} + +/// Adjust compiler arguments that were created for building to compiler arguments that should be used for indexing. +/// +/// This removes compiler arguments that produce output files and adds arguments to index the file. +private func adjustSwiftCompilerArgumentsForIndexStoreUpdate( + _ compilerArguments: [String], + fileToIndex: DocumentURI +) -> [String] { + let removeFlags: Set = [ + "-c", + "-disable-cmo", + "-emit-dependencies", + "-emit-module-interface", + "-emit-module", + "-emit-module", + "-emit-objc-header", + "-incremental", + "-no-color-diagnostics", + "-parseable-output", + "-save-temps", + "-serialize-diagnostics", + "-use-frontend-parseable-output", + "-validate-clang-modules-once", + "-whole-module-optimization", + ] + + let removeArguments: Set = [ + "-clang-build-session-file", + "-emit-module-interface-path", + "-emit-module-path", + "-emit-objc-header-path", + "-emit-package-module-interface-path", + "-emit-private-module-interface-path", + "-num-threads", + "-o", + "-output-file-map", + ] + + let removeFrontendFlags: Set = [ + "-experimental-skip-non-inlinable-function-bodies", + "-experimental-skip-all-function-bodies", + ] + + var result: [String] = [] + result.reserveCapacity(compilerArguments.count) + var iterator = compilerArguments.makeIterator() + while let argument = iterator.next() { + if removeFlags.contains(argument) { + continue + } + if removeArguments.contains(argument) { + _ = iterator.next() + continue + } + if argument == "-Xfrontend" { + if let nextArgument = iterator.next() { + if removeFrontendFlags.contains(nextArgument) { + continue + } + result += [argument, nextArgument] + continue + } + } + result.append(argument) + } + result += [ + "-index-file", + "-index-file-path", fileToIndex.pseudoPath, + // batch mode is not compatible with -index-file + "-disable-batch-mode", + // Fake an output path so that we get a different unit file for every Swift file we background index + "-index-unit-output-path", fileToIndex.pseudoPath + ".o", + ] + return result +} + +/// Adjust compiler arguments that were created for building to compiler arguments that should be used for indexing. +/// +/// This removes compiler arguments that produce output files and adds arguments to index the file. +private func adjustClangCompilerArgumentsForIndexStoreUpdate( + _ compilerArguments: [String], + fileToIndex: DocumentURI +) -> [String] { + let removeFlags: Set = [ + // Disable writing of a depfile + "-M", + "-MD", + "-MMD", + "-MG", + "-MM", + "-MV", + // Don't create phony targets + "-MP", + // Don't writ out compilation databases + "-MJ", + // Continue in the presence of errors during indexing + "-fmodules-validate-once-per-build-session", + // Don't compile + "-c", + ] + + let removeArguments: Set = [ + // Disable writing of a depfile + "-MT", + "-MF", + "-MQ", + // Don't write serialized diagnostic files + "--serialize-diagnostics", + ] + + var result: [String] = [] + result.reserveCapacity(compilerArguments.count) + var iterator = compilerArguments.makeIterator() + while let argument = iterator.next() { + if removeFlags.contains(argument) || argument.starts(with: "-fbuild-session-file=") { + continue + } + if removeArguments.contains(argument) { + _ = iterator.next() + continue + } + result.append(argument) + } + result.append( + "-fsyntax-only" + ) + return result +} diff --git a/Sources/SourceKitD/SKDResponse.swift b/Sources/SourceKitD/SKDResponse.swift index c3d4c08b6..6f7ec1ba2 100644 --- a/Sources/SourceKitD/SKDResponse.swift +++ b/Sources/SourceKitD/SKDResponse.swift @@ -22,7 +22,7 @@ import CRT #endif public final class SKDResponse: Sendable { - private nonisolated let response: sourcekitd_api_response_t + private nonisolated(unsafe) let response: sourcekitd_api_response_t let sourcekitd: SourceKitD /// Creates a new `SKDResponse` that exclusively manages the raw `sourcekitd_api_response_t`. diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index e56f172fa..45684cec9 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(SourceKitLSP STATIC CapabilityRegistry.swift DocumentManager.swift DocumentSnapshot+FromFileContents.swift + IndexProgressManager.swift IndexStoreDB+MainFilesProvider.swift LanguageService.swift Rename.swift @@ -14,6 +15,7 @@ add_library(SourceKitLSP STATIC SourceKitLSPServer+Options.swift SymbolLocation+DocumentURI.swift TestDiscovery.swift + TextEdit+IsNoop.swift WorkDoneProgressManager.swift Workspace.swift ) diff --git a/Sources/SourceKitLSP/Clang/ClangLanguageService.swift b/Sources/SourceKitLSP/Clang/ClangLanguageService.swift index 7eaea0b34..6e9a63d0e 100644 --- a/Sources/SourceKitLSP/Clang/ClangLanguageService.swift +++ b/Sources/SourceKitLSP/Clang/ClangLanguageService.swift @@ -33,10 +33,13 @@ extension NSLock { } /// Gathers data from clangd's stderr pipe. When it has accumulated a full line, writes the the line to the logger. -fileprivate class ClangdStderrLogForwarder { +fileprivate actor ClangdStderrLogForwarder { + /// Queue on which all data from `clangd`’s stderr will be forwarded to `stderr`. This allows us to have a + /// nonisolated `handle` function but ensure that data gets processed in order. + private let queue = AsyncQueue() private var buffer = Data() - func handle(_ newData: Data) { + private func handleImpl(_ newData: Data) { self.buffer += newData while let newlineIndex = self.buffer.firstIndex(of: UInt8(ascii: "\n")) { // Output a separate log message for every line in clangd's stderr. @@ -50,6 +53,12 @@ fileprivate class ClangdStderrLogForwarder { buffer = buffer[buffer.index(after: newlineIndex)...] } } + + nonisolated func handle(_ newData: Data) { + queue.async { + await self.handleImpl(newData) + } + } } /// A thin wrapper over a connection to a clangd server providing build setting handling. @@ -328,7 +337,7 @@ actor ClangLanguageService: LanguageService, MessageHandler { nonisolated func handle( _ params: R, id: RequestID, - reply: @escaping (LSPResult) -> Void + reply: @Sendable @escaping (LSPResult) -> Void ) { logger.info( """ @@ -442,10 +451,11 @@ extension ClangLanguageService { } public func shutdown() async { + let clangd = clangd! await withCheckedContinuation { continuation in _ = clangd.send(ShutdownRequest()) { _ in Task { - await self.clangd.send(ExitNotification()) + clangd.send(ExitNotification()) continuation.resume() } } diff --git a/Sources/SourceKitLSP/DocumentManager.swift b/Sources/SourceKitLSP/DocumentManager.swift index 2798dd72e..243b4eb4a 100644 --- a/Sources/SourceKitLSP/DocumentManager.swift +++ b/Sources/SourceKitLSP/DocumentManager.swift @@ -23,10 +23,10 @@ import SwiftSyntax /// data structure that is stored internally by the ``DocumentManager`` is a /// ``Document``. The purpose of a ``DocumentSnapshot`` is to be able to work /// with one version of a document without having to think about it changing. -public struct DocumentSnapshot: Identifiable { +public struct DocumentSnapshot: Identifiable, Sendable { /// An ID that uniquely identifies the version of the document stored in this /// snapshot. - public struct ID: Hashable, Comparable { + public struct ID: Hashable, Comparable, Sendable { public let uri: DocumentURI public let version: Int @@ -84,16 +84,18 @@ public final class Document { } } -public final class DocumentManager: InMemoryDocumentManager { +public final class DocumentManager: InMemoryDocumentManager, Sendable { public enum Error: Swift.Error { case alreadyOpen(DocumentURI) case missingDocument(DocumentURI) } - let queue: DispatchQueue = DispatchQueue(label: "document-manager-queue") + // FIXME: (async) Migrate this to be an AsyncQueue + private let queue: DispatchQueue = DispatchQueue(label: "document-manager-queue") - var documents: [DocumentURI: Document] = [:] + // `nonisolated(unsafe)` is fine because `documents` is guarded by queue. + nonisolated(unsafe) var documents: [DocumentURI: Document] = [:] public init() {} diff --git a/Sources/SourceKitLSP/IndexProgressManager.swift b/Sources/SourceKitLSP/IndexProgressManager.swift new file mode 100644 index 000000000..fdc745fa3 --- /dev/null +++ b/Sources/SourceKitLSP/IndexProgressManager.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// +// 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 +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. + /// + /// This allows the two functions two be `nonisolated` (and eg. the caller of `indexStatusDidChange` doesn't have to + /// wait for the work done progress to be updated) while still guaranteeing that we handle them in the order they + /// were called. + private let queue = AsyncQueue() + + /// The `SourceKitLSPServer` for which this manages the index progress. It gathers all `SemanticIndexManagers` from + /// the workspaces in the `SourceKitLSPServer`. + private weak var sourceKitLSPServer: SourceKitLSPServer? + + /// This is the target number of index tasks (eg. the `3` in `1/3 done`). + /// + /// Every time a new index task is scheduled, this number gets incremented, so that it only ever increases. + /// When indexing of one session is done (ie. when there are no more `scheduled` or `executing` tasks in any + /// `SemanticIndexManager`), `queuedIndexTasks` gets reset to 0 and the work done progress gets ended. + /// This way, when the next work done progress is started, it starts at zero again. + /// + /// The number of outstanding tasks is determined from the `scheduled` and `executing` tasks in all the + /// `SemanticIndexManager`s. + private var queuedIndexTasks = 0 + + /// While there are ongoing index tasks, a `WorkDoneProgressManager` that displays the work done progress. + private var workDoneProgress: WorkDoneProgressManager? + + init(sourceKitLSPServer: SourceKitLSPServer) { + self.sourceKitLSPServer = sourceKitLSPServer + } + + /// Called when a new file is scheduled to be indexed. Increments the target index count, eg. the 3 in `1/3`. + nonisolated func indexTaskWasQueued(count: Int) { + queue.async { + await self.indexTaskWasQueuedImpl(count: count) + } + } + + private func indexTaskWasQueuedImpl(count: Int) async { + queuedIndexTasks += count + await indexStatusDidChangeImpl() + } + + /// Called when a `SemanticIndexManager` finishes indexing a file. Adjusts the done index count, eg. the 1 in `1/3`. + nonisolated func indexStatusDidChange() { + queue.async { + await self.indexStatusDidChangeImpl() + } + } + + private func indexStatusDidChangeImpl() async { + guard let sourceKitLSPServer else { + workDoneProgress = nil + return + } + var scheduled: [DocumentURI] = [] + var executing: [DocumentURI] = [] + for indexManager in await sourceKitLSPServer.workspaces.compactMap({ $0.semanticIndexManager }) { + let inProgress = await indexManager.inProgressIndexTasks + scheduled += inProgress.scheduled + executing += inProgress.executing + } + + if scheduled.isEmpty && executing.isEmpty { + // Nothing left to index. Reset the target count and dismiss the work done progress. + queuedIndexTasks = 0 + workDoneProgress = nil + return + } + + let finishedTasks = queuedIndexTasks - scheduled.count - executing.count + let message = "\(finishedTasks) / \(queuedIndexTasks)" + + let percentage = Int(Double(finishedTasks) / Double(queuedIndexTasks) * 100) + if let workDoneProgress { + workDoneProgress.update(message: message, percentage: percentage) + } else { + workDoneProgress = await WorkDoneProgressManager( + server: sourceKitLSPServer, + title: "Indexing", + message: message, + percentage: percentage + ) + } + } +} diff --git a/Sources/SourceKitLSP/LanguageService.swift b/Sources/SourceKitLSP/LanguageService.swift index 29ed79fa8..517ce98d9 100644 --- a/Sources/SourceKitLSP/LanguageService.swift +++ b/Sources/SourceKitLSP/LanguageService.swift @@ -24,7 +24,7 @@ public enum LanguageServerState { case semanticFunctionalityDisabled } -public struct RenameLocation { +public struct RenameLocation: Sendable { /// How the identifier at a given location is being used. /// /// This is primarily used to influence how argument labels should be renamed in Swift and if a location should be @@ -64,7 +64,7 @@ public struct RenameLocation { /// /// For example, we may have a language service that provides semantic functionality for c-family using a clangd server, /// launched from a specific toolchain or from sourcekitd. -public protocol LanguageService: AnyObject { +public protocol LanguageService: AnyObject, Sendable { // MARK: - Creation @@ -90,7 +90,7 @@ public protocol LanguageService: AnyObject { /// Add a handler that is called whenever the state of the language server changes. func addStateChangeHandler( - handler: @escaping (_ oldState: LanguageServerState, _ newState: LanguageServerState) -> Void + handler: @Sendable @escaping (_ oldState: LanguageServerState, _ newState: LanguageServerState) -> Void ) async // MARK: - Text synchronization @@ -200,7 +200,7 @@ public protocol LanguageService: AnyObject { /// This is used as a fallback to show the test cases in a file if the index for a given file is not up-to-date. /// /// A return value of `nil` indicates that this language service does not support syntactic test discovery. - func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem]? + func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [AnnotatedTestItem]? /// Crash the language server. Should be used for crash recovery testing only. func _crash() async diff --git a/Sources/SourceKitLSP/Rename.swift b/Sources/SourceKitLSP/Rename.swift index a8bb0b15d..29a8f723c 100644 --- a/Sources/SourceKitLSP/Rename.swift +++ b/Sources/SourceKitLSP/Rename.swift @@ -10,7 +10,7 @@ // //===----------------------------------------------------------------------===// -import IndexStoreDB +@preconcurrency import IndexStoreDB import LSPLogging import LanguageServerProtocol import SKSupport @@ -445,7 +445,7 @@ extension SwiftLanguageService { /// These names might differ. For example, an Objective-C method gets translated by the clang importer to form the Swift /// name or it could have a `SWIFT_NAME` attribute that defines the method's name in Swift. Similarly, a Swift symbol /// might specify the name by which it gets exposed to Objective-C using the `@objc` attribute. -public struct CrossLanguageName { +public struct CrossLanguageName: Sendable { /// The name of the symbol in clang languages or `nil` if the symbol is defined in Swift, doesn't have any references /// from clang languages and thus hasn't been translated. fileprivate let clangName: String? @@ -564,6 +564,22 @@ extension SourceKitLSPServer { return nil } + // FIXME: (async-workaround): Needed to work around rdar://127977642 + private func translateClangNameToSwift( + _ swiftLanguageService: SwiftLanguageService, + at symbolLocation: SymbolLocation, + in snapshot: DocumentSnapshot, + isObjectiveCSelector: Bool, + name: String + ) async throws -> String { + return try await swiftLanguageService.translateClangNameToSwift( + at: symbolLocation, + in: snapshot, + isObjectiveCSelector: isObjectiveCSelector, + name: name + ) + } + private func getCrossLanguageName( forDefinitionOccurrence definitionOccurrence: SymbolOccurrence, overrideName: String? = nil, @@ -598,7 +614,8 @@ extension SourceKitLSPServer { let swiftName: String? if let swiftReference = await getReferenceFromSwift(usr: usr, index: index, workspace: workspace) { let isObjectiveCSelector = definitionLanguage == .objective_c && definitionSymbol.kind.isMethod - swiftName = try await swiftReference.swiftLanguageService.translateClangNameToSwift( + swiftName = try await self.translateClangNameToSwift( + swiftReference.swiftLanguageService, at: swiftReference.location, in: swiftReference.snapshot, isObjectiveCSelector: isObjectiveCSelector, @@ -670,7 +687,7 @@ extension SourceKitLSPServer { guard let workspace = await workspaceForDocument(uri: uri) else { throw ResponseError.workspaceNotOpen(uri) } - guard let primaryFileLanguageService = workspace.documentService[uri] else { + guard let primaryFileLanguageService = workspace.documentService.value[uri] else { return nil } @@ -716,10 +733,10 @@ extension SourceKitLSPServer { var locationsByFile: [URL: [RenameLocation]] = [:] actor LanguageServerTypesCache { - let index: CheckedIndex + let index: UncheckedIndex var languageServerTypesCache: [URL: LanguageServerType?] = [:] - init(index: CheckedIndex) { + init(index: UncheckedIndex) { self.index = index } @@ -727,13 +744,15 @@ extension SourceKitLSPServer { if let cachedValue = languageServerTypesCache[url] { return cachedValue } - let serverType = LanguageServerType(symbolProvider: index.symbolProvider(for: url.path)) + let serverType = LanguageServerType( + symbolProvider: index.checked(for: .deletedFiles).symbolProvider(for: url.path) + ) languageServerTypesCache[url] = serverType return serverType } } - let languageServerTypesCache = LanguageServerTypesCache(index: index) + let languageServerTypesCache = LanguageServerTypesCache(index: index.unchecked) let usrsToRename = overridingAndOverriddenUsrs(of: usr, index: index) let occurrencesToRename = usrsToRename.flatMap { index.occurrences(ofUSR: $0, roles: renameRoles) } @@ -1490,13 +1509,3 @@ fileprivate extension RelatedIdentifiersResponse { } } } - -fileprivate extension TextEdit { - /// Returns `true` the replaced text is the same as the new text - func isNoOp(in snapshot: DocumentSnapshot) -> Bool { - if snapshot.text[snapshot.indexRange(of: range)] == newText { - return true - } - return false - } -} diff --git a/Sources/SourceKitLSP/Sequence+AsyncMap.swift b/Sources/SourceKitLSP/Sequence+AsyncMap.swift index 5a39dda39..7ec4e100e 100644 --- a/Sources/SourceKitLSP/Sequence+AsyncMap.swift +++ b/Sources/SourceKitLSP/Sequence+AsyncMap.swift @@ -13,7 +13,7 @@ extension Sequence { /// Just like `Sequence.map` but allows an `async` transform function. func asyncMap( - _ transform: (Element) async throws -> T + @_inheritActorContext _ transform: @Sendable (Element) async throws -> T ) async rethrows -> [T] { var result: [T] = [] result.reserveCapacity(self.underestimatedCount) @@ -27,7 +27,7 @@ extension Sequence { /// Just like `Sequence.compactMap` but allows an `async` transform function. func asyncCompactMap( - _ transform: (Element) async throws -> T? + @_inheritActorContext _ transform: @Sendable (Element) async throws -> T? ) async rethrows -> [T] { var result: [T] = [] diff --git a/Sources/SourceKitLSP/SourceKitIndexDelegate.swift b/Sources/SourceKitLSP/SourceKitIndexDelegate.swift index 2b5aeaa3d..8168534ee 100644 --- a/Sources/SourceKitLSP/SourceKitIndexDelegate.swift +++ b/Sources/SourceKitLSP/SourceKitIndexDelegate.swift @@ -24,7 +24,7 @@ public actor SourceKitIndexDelegate: IndexDelegate { let queue = AsyncQueue() /// Registered `MainFilesDelegate`s to notify when main files change. - var mainFilesChangedCallbacks: [() async -> Void] = [] + var mainFilesChangedCallbacks: [@Sendable () async -> Void] = [] /// The count of pending unit events. Whenever this transitions to 0, it represents a time where /// the index finished processing known events. Of course, that may have already changed by the @@ -72,7 +72,7 @@ public actor SourceKitIndexDelegate: IndexDelegate { } /// Register a delegate to receive notifications when main files change. - public func addMainFileChangedCallback(_ callback: @escaping () async -> Void) { + public func addMainFileChangedCallback(_ callback: @escaping @Sendable () async -> Void) { mainFilesChangedCallbacks.append(callback) } diff --git a/Sources/SourceKitLSP/SourceKitLSPServer+Options.swift b/Sources/SourceKitLSP/SourceKitLSPServer+Options.swift index 558404baa..b7005c989 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer+Options.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer+Options.swift @@ -21,7 +21,7 @@ import struct TSCBasic.RelativePath extension SourceKitLSPServer { /// Configuration options for the SourceKitServer. - public struct Options { + public struct Options: Sendable { /// Additional compiler flags (e.g. `-Xswiftc` for SwiftPM projects) and other build-related /// configuration. @@ -49,6 +49,11 @@ extension SourceKitLSPServer { /// notification when running unit tests. public var swiftPublishDiagnosticsDebounceDuration: TimeInterval + /// A callback that is called when an index task finishes. + /// + /// Intended for testing purposes. + public var indexTaskDidFinish: (@Sendable () -> Void)? + public init( buildSetup: BuildSetup = .default, clangdOptions: [String] = [], diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index f5b14807d..6c82f148d 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -70,26 +70,27 @@ enum LanguageServerType: Hashable { } /// A request and a callback that returns the request's reply -fileprivate final class RequestAndReply { +fileprivate final class RequestAndReply: Sendable { let params: Params - private let replyBlock: (LSPResult) -> Void + private let replyBlock: @Sendable (LSPResult) -> Void /// Whether a reply has been made. Every request must reply exactly once. - private var replied: Bool = false + /// `nonisolated(unsafe)` is fine because `replied` is atomic. + private nonisolated(unsafe) var replied: AtomicBool = AtomicBool(initialValue: false) - public init(_ request: Params, reply: @escaping (LSPResult) -> Void) { + public init(_ request: Params, reply: @escaping @Sendable (LSPResult) -> Void) { self.params = request self.replyBlock = reply } deinit { - precondition(replied, "request never received a reply") + precondition(replied.value, "request never received a reply") } /// Call the `replyBlock` with the result produced by the given closure. - func reply(_ body: () async throws -> Params.Response) async { - precondition(!replied, "replied to request more than once") - replied = true + func reply(_ body: @Sendable () async throws -> Params.Response) async { + precondition(!replied.value, "replied to request more than once") + replied.value = true do { replyBlock(.success(try await body())) } catch { @@ -114,6 +115,10 @@ final actor WorkDoneProgressState { 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 @@ -134,13 +139,16 @@ final actor WorkDoneProgressState { /// 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 - func startProgress(server: SourceKitLSPServer) async { + 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 { - // If the client doesn't support workDoneProgress, keep track of the active task count but don't update the state. - // That way, if we call `startProgress` before initialization finishes, we won't send the - // `CreateWorkDoneProgressRequest` but if we call `startProgress` again after initialization finished (and thus - // the capability is set), we will create the work done progress. return } if state == .noProgress { @@ -179,7 +187,13 @@ final actor WorkDoneProgressState { /// 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 - func endProgress(server: SourceKitLSPServer) async { + 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 { @@ -439,16 +453,33 @@ public actor SourceKitLSPServer { /// The connection to the editor. public let client: Connection + /// Set to `true` after the `SourceKitLSPServer` has send the reply to the `InitializeRequest`. + /// + /// Initialization can be awaited using `waitUntilInitialized`. + private var initialized: Bool = false + var options: Options let toolchainRegistry: ToolchainRegistry - var capabilityRegistry: CapabilityRegistry? + public var capabilityRegistry: CapabilityRegistry? var languageServices: [LanguageServerType: [LanguageService]] = [:] let documentManager = DocumentManager() + /// The `TaskScheduler` that schedules all background indexing tasks. + /// + /// Shared process-wide to ensure the scheduled index operations across multiple workspaces don't exceed the maximum + /// number of processor cores that the user allocated to background indexing. + private let indexTaskScheduler: TaskScheduler + + /// Implicitly unwrapped optional so we can create an `IndexProgressManager` that has a weak reference to + /// `SourceKitLSPServer`. + /// `nonisolated(unsafe)` because `indexProgressManager` will not be modified after it is assigned from the + /// initializer. + private nonisolated(unsafe) var indexProgressManager: IndexProgressManager! + private var packageLoadingWorkDoneProgress = WorkDoneProgressState( "SourceKitLSP.SourceKitLSPServer.reloadPackage", title: "SourceKit-LSP: Reloading Package" @@ -501,24 +532,44 @@ public actor SourceKitLSPServer { self.inProgressRequests[id] = task } - let fs: FileSystem - var onExit: () -> Void /// Creates a language server for the given client. public init( client: Connection, - fileSystem: FileSystem = localFileSystem, toolchainRegistry: ToolchainRegistry, options: Options, onExit: @escaping () -> Void = {} ) { - self.fs = fileSystem self.toolchainRegistry = toolchainRegistry self.options = options self.onExit = onExit self.client = client + let processorCount = ProcessInfo.processInfo.processorCount + let lowPriorityCores = options.indexOptions.maxCoresPercentageToUseForBackgroundIndexing * Double(processorCount) + self.indexTaskScheduler = TaskScheduler(maxConcurrentTasksByPriority: [ + (TaskPriority.medium, processorCount), + (TaskPriority.low, max(Int(lowPriorityCores), 1)), + ]) + self.indexProgressManager = nil + self.indexProgressManager = IndexProgressManager(sourceKitLSPServer: self) + } + + /// Await until the server has send the reply to the initialize request. + func waitUntilInitialized() async { + // The polling of `initialized` is not perfect but it should be OK, because + // - In almost all cases the server should already be initialized. + // - If it's not initialized, we expect initialization to finish fairly quickly. Even if initialization takes 5s + // this only results in 50 polls, which is acceptable. + // Alternative solutions that signal via an async sequence seem overkill here. + while !initialized { + do { + try await Task.sleep(for: .seconds(0.1)) + } catch { + break + } + } } /// Search through all the parent directories of `uri` and check if any of these directories contain a workspace @@ -619,7 +670,7 @@ public actor SourceKitLSPServer { // This should be created as soon as we receive an open call, even if the document // isn't yet ready. - guard let languageService = workspace.documentService[doc] else { + guard let languageService = workspace.documentService.value[doc] else { return } @@ -628,7 +679,9 @@ public actor SourceKitLSPServer { private func handleRequest( for request: RequestAndReply, - requestHandler: @escaping (RequestType, Workspace, LanguageService) async throws -> + requestHandler: @Sendable @escaping ( + RequestType, Workspace, LanguageService + ) async throws -> RequestType.Response ) async { await request.reply { @@ -637,7 +690,7 @@ public actor SourceKitLSPServer { guard let workspace = await self.workspaceForDocument(uri: request.textDocument.uri) else { throw ResponseError.workspaceNotOpen(request.textDocument.uri) } - guard let languageService = workspace.documentService[doc] else { + guard let languageService = workspace.documentService.value[doc] else { throw ResponseError.unknown("No language service for '\(request.textDocument.uri)' found") } return try await requestHandler(request, workspace, languageService) @@ -661,7 +714,7 @@ public actor SourceKitLSPServer { guard let workspace = await self.workspaceForDocument(uri: documentUri) else { continue } - guard workspace.documentService[documentUri] === languageService else { + guard workspace.documentService.value[documentUri] === languageService else { continue } guard let snapshot = try? self.documentManager.latestSnapshot(documentUri) else { @@ -787,7 +840,7 @@ public actor SourceKitLSPServer { _ language: Language, in workspace: Workspace ) async -> LanguageService? { - if let service = workspace.documentService[uri] { + if let service = workspace.documentService.value[uri] { return service } @@ -799,21 +852,24 @@ public actor SourceKitLSPServer { logger.log("Using toolchain \(toolchain.displayName) (\(toolchain.identifier)) for \(uri.forLogging)") - if let concurrentlySetService = workspace.documentService[uri] { - // Since we await the construction of `service`, another call to this - // function might have happened and raced us, setting - // `workspace.documentServices[uri]`. If this is the case, return the - // existing value and discard the service that we just retrieved. - return concurrentlySetService + return workspace.documentService.withLock { documentService in + if let concurrentlySetService = documentService[uri] { + // Since we await the construction of `service`, another call to this + // function might have happened and raced us, setting + // `workspace.documentServices[uri]`. If this is the case, return the + // existing value and discard the service that we just retrieved. + return concurrentlySetService + } + documentService[uri] = service + return service } - workspace.documentService[uri] = service - return service } } // MARK: - MessageHandler -private var notificationIDForLogging = AtomicUInt32(initialValue: 1) +// nonisolated(unsafe) is fine because `notificationIDForLogging` is atomic. +private nonisolated(unsafe) var notificationIDForLogging = AtomicUInt32(initialValue: 1) extension SourceKitLSPServer: MessageHandler { public nonisolated func handle(_ params: some NotificationType) { @@ -874,7 +930,7 @@ extension SourceKitLSPServer: MessageHandler { public nonisolated func handle( _ params: R, id: RequestID, - reply: @escaping (LSPResult) -> Void + reply: @Sendable @escaping (LSPResult) -> Void ) { let signposter = Logger(subsystem: LoggingScope.subsystem, category: "request-\(id)").makeSignposter() let signpostID = signposter.makeSignpostID() @@ -906,7 +962,7 @@ extension SourceKitLSPServer: MessageHandler { private func handleImpl( _ params: R, id: RequestID, - reply: @escaping (LSPResult) -> Void + reply: @Sendable @escaping (LSPResult) -> Void ) async { let startDate = Date() @@ -940,6 +996,8 @@ extension SourceKitLSPServer: MessageHandler { switch request { case let request as RequestAndReply: await request.reply { try await initialize(request.params) } + // Only set `initialized` to `true` after we have sent the response to the initialize request to the client. + initialized = true case let request as RequestAndReply: await request.reply { try await shutdown(request.params) } case let request as RequestAndReply: @@ -1047,7 +1105,7 @@ extension SourceKitLSPServer: BuildSystemDelegate { continue } - guard let service = await self.workspaceForDocument(uri: uri)?.documentService[uri] else { + guard let service = await self.workspaceForDocument(uri: uri)?.documentService.value[uri] else { continue } @@ -1071,7 +1129,7 @@ extension SourceKitLSPServer: BuildSystemDelegate { } for uri in self.affectedOpenDocumentsForChangeSet(changedFilesForWorkspace, self.documentManager) { logger.log("Dependencies updated for opened file \(uri.forLogging)") - if let service = workspace.documentService[uri] { + if let service = workspace.documentService.value[uri] { await service.documentDependenciesUpdated(uri) } } @@ -1142,6 +1200,7 @@ extension SourceKitLSPServer { logger.log("Cannot open workspace before server is initialized") return nil } + let indexTaskDidFinishCallback = options.indexTaskDidFinish var options = self.options options.buildSetup = self.options.buildSetup.merging(buildSetup(for: workspaceFolder)) return try? await Workspace( @@ -1152,18 +1211,22 @@ extension SourceKitLSPServer { options: options, compilationDatabaseSearchPaths: self.options.compilationDatabaseSearchPaths, indexOptions: self.options.indexOptions, + indexTaskScheduler: indexTaskScheduler, reloadPackageStatusCallback: { [weak self] status in guard let self else { return } - guard capabilityRegistry.clientCapabilities.window?.workDoneProgress ?? false else { - // Client doesn’t support work done progress - return - } switch status { case .start: await self.packageLoadingWorkDoneProgress.startProgress(server: self) case .end: await self.packageLoadingWorkDoneProgress.endProgress(server: self) } + }, + indexTasksWereScheduled: { [weak self] count in + self?.indexProgressManager.indexTaskWasQueued(count: count) + }, + indexTaskDidFinish: { [weak self] in + self?.indexProgressManager.indexStatusDidChange() + indexTaskDidFinishCallback?() } ) } @@ -1212,6 +1275,7 @@ extension SourceKitLSPServer { if self.workspaces.isEmpty { logger.error("no workspace found") + let indexTaskDidFinishCallback = self.options.indexTaskDidFinish let workspace = await Workspace( documentManager: self.documentManager, rootUri: req.rootURI, @@ -1220,7 +1284,15 @@ extension SourceKitLSPServer { options: self.options, underlyingBuildSystem: nil, index: nil, - indexDelegate: nil + indexDelegate: nil, + indexTaskScheduler: self.indexTaskScheduler, + indexTasksWereScheduled: { [weak self] count in + self?.indexProgressManager.indexTaskWasQueued(count: count) + }, + indexTaskDidFinish: { [weak self] in + self?.indexProgressManager.indexStatusDidChange() + indexTaskDidFinishCallback?() + } ) self.workspacesAndIsImplicit.append((workspace: workspace, isImplicit: false)) @@ -1416,8 +1488,7 @@ extension SourceKitLSPServer { // the client to open new workspaces. for workspace in self.workspaces { await workspace.buildSystemManager.setMainFilesProvider(nil) - // Close the index, which will flush to disk. - workspace.uncheckedIndex = nil + workspace.closeIndex() // Break retain cycle with the BSM. await workspace.buildSystemManager.setDelegate(nil) @@ -1509,7 +1580,7 @@ extension SourceKitLSPServer { await workspace.buildSystemManager.unregisterForChangeNotifications(for: uri) - await workspace.documentService[uri]?.closeDocument(note) + await workspace.documentService.value[uri]?.closeDocument(note) } func changeDocument(_ notification: DidChangeTextDocumentNotification) async { @@ -1524,7 +1595,7 @@ extension SourceKitLSPServer { // If the document is ready, we can handle the change right now. documentManager.edit(notification) - await workspace.documentService[uri]?.changeDocument(notification) + await workspace.documentService.value[uri]?.changeDocument(notification) } func willSaveDocument( @@ -1774,7 +1845,7 @@ extension SourceKitLSPServer { guard let workspace = await workspaceForDocument(uri: uri) else { throw ResponseError.workspaceNotOpen(uri) } - guard let languageService = workspace.documentService[uri] else { + guard let languageService = workspace.documentService.value[uri] else { return nil } @@ -2414,7 +2485,8 @@ extension SourceKitLSPServer { func pollIndex(_ req: PollIndexRequest) async throws -> VoidResponse { for workspace in workspaces { - workspace.uncheckedIndex?.underlyingIndexStoreDB.pollForUnitChangesAndWait() + await workspace.semanticIndexManager?.waitForUpToDateIndex() + workspace.index(checkedFor: .deletedFiles)?.pollForUnitChangesAndWait() } return VoidResponse() } @@ -2456,7 +2528,11 @@ fileprivate extension CheckedIndex { /// If the USR has an ambiguous definition, the most important role of this function is to deterministically return /// the same result every time. func primaryDefinitionOrDeclarationOccurrence(ofUSR usr: String) -> SymbolOccurrence? { - return definitionOrDeclarationOccurrences(ofUSR: usr).sorted().first + let result = definitionOrDeclarationOccurrences(ofUSR: usr).sorted().first + if result == nil { + logger.error("Failed to find definition of \(usr) in index") + } + return result } } diff --git a/Sources/SourceKitLSP/Swift/AdjustPositionToStartOfIdentifier.swift b/Sources/SourceKitLSP/Swift/AdjustPositionToStartOfIdentifier.swift index f77e6ec4c..11397a781 100644 --- a/Sources/SourceKitLSP/Swift/AdjustPositionToStartOfIdentifier.swift +++ b/Sources/SourceKitLSP/Swift/AdjustPositionToStartOfIdentifier.swift @@ -56,7 +56,7 @@ extension SwiftLanguageService { let visitor = StartOfIdentifierFinder(position: snapshot.absolutePosition(of: position)) visitor.walk(tree) if let resolvedPosition = visitor.resolvedPosition { - return snapshot.position(of: resolvedPosition) ?? position + return snapshot.position(of: resolvedPosition) } return position } diff --git a/Sources/SourceKitLSP/Swift/CodeActions/AddDocumentation.swift b/Sources/SourceKitLSP/Swift/CodeActions/AddDocumentation.swift index f37a6b661..1d3543cd7 100644 --- a/Sources/SourceKitLSP/Swift/CodeActions/AddDocumentation.swift +++ b/Sources/SourceKitLSP/Swift/CodeActions/AddDocumentation.swift @@ -37,22 +37,25 @@ import SwiftSyntax public struct AddDocumentation: EditRefactoringProvider { @_spi(Testing) public static func textRefactor(syntax: DeclSyntax, in context: Void) -> [SourceEdit] { - let hasDocumentation = syntax.leadingTrivia.contains(where: { trivia in + let hasDocumentation = syntax.leadingTrivia.contains { trivia in switch trivia { - case .blockComment(_), .docBlockComment(_), .lineComment(_), .docLineComment(_): + case .blockComment, .docBlockComment, .lineComment, .docLineComment: return true default: return false } - }) + } + + // We consider nodes at the start of the source file at being on a new line + let isOnNewLine = + syntax.leadingTrivia.contains(where: \.isNewline) || syntax.previousToken(viewMode: .sourceAccurate) == nil - guard !hasDocumentation else { + guard !hasDocumentation && isOnNewLine else { return [] } let newlineAndIndentation = [.newlines(1)] + (syntax.firstToken(viewMode: .sourceAccurate)?.indentationOfLine ?? []) var content: [TriviaPiece] = [] - content += newlineAndIndentation content.append(.docLineComment("/// A description")) if let parameters = syntax.parameters?.parameters { @@ -82,8 +85,9 @@ public struct AddDocumentation: EditRefactoringProvider { content += newlineAndIndentation content.append(.docLineComment("/// - Returns:")) } + content += newlineAndIndentation - let insertPos = syntax.position + let insertPos = syntax.positionAfterSkippingLeadingTrivia return [ SourceEdit( range: insertPos.. Input? { + return scope.innermostNodeContainingRange?.findParentOfSelf( + ofType: DeclSyntax.self, + stoppingIf: { $0.is(CodeBlockItemSyntax.self) || $0.is(MemberBlockItemSyntax.self) || $0.is(ExprSyntax.self) } + ) + } + static var title: String { "Add documentation" } } diff --git a/Sources/SourceKitLSP/Swift/CodeActions/ConvertIntegerLiteral.swift b/Sources/SourceKitLSP/Swift/CodeActions/ConvertIntegerLiteral.swift index b541f1c91..f1253ea2c 100644 --- a/Sources/SourceKitLSP/Swift/CodeActions/ConvertIntegerLiteral.swift +++ b/Sources/SourceKitLSP/Swift/CodeActions/ConvertIntegerLiteral.swift @@ -14,11 +14,8 @@ import LanguageServerProtocol import SwiftRefactor import SwiftSyntax -// TODO: Make the type IntegerLiteralExprSyntax.Radix conform to CaseEnumerable -// in swift-syntax. - extension IntegerLiteralExprSyntax.Radix { - static var allCases: [Self] = [.binary, .octal, .decimal, .hex] + static let allCases: [Self] = [.binary, .octal, .decimal, .hex] } /// Syntactic code action provider to convert integer literals between @@ -26,7 +23,7 @@ extension IntegerLiteralExprSyntax.Radix { struct ConvertIntegerLiteral: SyntaxCodeActionProvider { static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] { guard - let token = scope.firstToken, + let token = scope.innermostNodeContainingRange, let integerExpr = token.parent?.as(IntegerLiteralExprSyntax.self), let integerValue = Int( integerExpr.split().value.filter { $0 != "_" }, diff --git a/Sources/SourceKitLSP/Swift/CodeActions/ConvertJSONToCodableStruct.swift b/Sources/SourceKitLSP/Swift/CodeActions/ConvertJSONToCodableStruct.swift index 82c35ade4..83f452784 100644 --- a/Sources/SourceKitLSP/Swift/CodeActions/ConvertJSONToCodableStruct.swift +++ b/Sources/SourceKitLSP/Swift/CodeActions/ConvertJSONToCodableStruct.swift @@ -195,7 +195,18 @@ public struct ConvertJSONToCodableStruct: EditRefactoringProvider { } extension ConvertJSONToCodableStruct: SyntaxRefactoringCodeActionProvider { - static var title = "Create Codable structs from JSON" + static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> Syntax? { + var node: Syntax? = scope.innermostNodeContainingRange + while let unwrappedNode = node, ![.codeBlockItem, .memberBlockItem].contains(unwrappedNode.kind) { + if preflightRefactoring(unwrappedNode) != nil { + return unwrappedNode + } + node = unwrappedNode.parent + } + return nil + } + + static let title = "Create Codable structs from JSON" } /// A JSON object, which is has a set of fields, each of which has the given diff --git a/Sources/SourceKitLSP/Swift/CodeActions/PackageManifestEdits.swift b/Sources/SourceKitLSP/Swift/CodeActions/PackageManifestEdits.swift index 27603f144..439513e2b 100644 --- a/Sources/SourceKitLSP/Swift/CodeActions/PackageManifestEdits.swift +++ b/Sources/SourceKitLSP/Swift/CodeActions/PackageManifestEdits.swift @@ -20,9 +20,7 @@ import SwiftSyntax /// edit a package manifest. struct PackageManifestEdits: SyntaxCodeActionProvider { static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] { - guard let token = scope.firstToken, - let call = token.findEnclosingCall() - else { + guard let call = scope.innermostNodeContainingRange?.findEnclosingCall() else { return [] } @@ -43,21 +41,36 @@ struct PackageManifestEdits: SyntaxCodeActionProvider { } do { - // Describe the target we are going to create. - let target = try TargetDescription( - name: "\(targetName)Tests", - dependencies: [.byName(name: targetName, condition: nil)], - type: .test - ) + var actions: [CodeAction] = [] - let edits = try AddTarget.addTarget(target, to: scope.file) - return [ - CodeAction( - title: "Add test target", - kind: .refactor, - edit: edits.asWorkspaceEdit(snapshot: scope.snapshot) - ) + let variants: [(AddTarget.TestHarness, String)] = [ + (.swiftTesting, "Swift Testing"), + (.xctest, "XCTest"), ] + for (testingLibrary, libraryName) in variants { + // Describe the target we are going to create. + let target = try TargetDescription( + name: "\(targetName)Tests", + dependencies: [.byName(name: targetName, condition: nil)], + type: .test + ) + + let edits = try AddTarget.addTarget( + target, + to: scope.file, + configuration: .init(testHarness: testingLibrary) + ) + + actions.append( + CodeAction( + title: "Add test target (\(libraryName))", + kind: .refactor, + edit: edits.asWorkspaceEdit(snapshot: scope.snapshot) + ) + ) + } + + return actions } catch { return [] } diff --git a/Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActionProvider.swift b/Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActionProvider.swift index 45bebc378..0674c5859 100644 --- a/Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActionProvider.swift +++ b/Sources/SourceKitLSP/Swift/CodeActions/SyntaxCodeActionProvider.swift @@ -40,26 +40,50 @@ struct SyntaxCodeActionScope { /// considered, i.e., where the cursor or selection is. var range: Range - init( + /// The innermost node that contains the entire selected source range + var innermostNodeContainingRange: Syntax? + + init?( snapshot: DocumentSnapshot, - syntaxTree tree: SourceFileSyntax, + syntaxTree file: SourceFileSyntax, request: CodeActionRequest - ) throws { + ) { self.snapshot = snapshot self.request = request - self.file = tree + self.file = file - let start = snapshot.absolutePosition(of: request.range.lowerBound) - let end = snapshot.absolutePosition(of: request.range.upperBound) - let left = file.token(at: start) - let right = file.token(at: end) - let leftOff = left?.position ?? AbsolutePosition(utf8Offset: 0) - let rightOff = right?.endPosition ?? leftOff - self.range = leftOff.. TokenSyntax? { + let absolutePosition = snapshot.absolutePosition(of: position) + if absolutePosition == syntaxTree.endPosition { + // token(at:) will not find the end of file token if the end of file token has length 0. Special case this and + // return the last proper token in this case. + return syntaxTree.endOfFileToken.previousToken(viewMode: .sourceAccurate) + } + guard let token = syntaxTree.token(at: absolutePosition) else { + return nil + } + // See `adjustPositionToStartOfIdentifier`. We need to be a little more aggressive for the refactorings and also + // adjust to the start of punctuation eg. if the end of the selected range is after a `}`, we want the end token for + // the refactoring to be the `}`, not the token after `}`. + if absolutePosition == token.position, + let previousToken = token.previousToken(viewMode: .sourceAccurate), + previousToken.endPositionBeforeTrailingTrivia == absolutePosition + { + return previousToken } + return token } diff --git a/Sources/SourceKitLSP/Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift b/Sources/SourceKitLSP/Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift index 1fb72e0ed..4a42f52f4 100644 --- a/Sources/SourceKitLSP/Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift +++ b/Sources/SourceKitLSP/Swift/CodeActions/SyntaxRefactoringCodeActionProvider.swift @@ -18,29 +18,35 @@ import SwiftSyntax /// swift-syntax) into a SyntaxCodeActionProvider. protocol SyntaxRefactoringCodeActionProvider: SyntaxCodeActionProvider, EditRefactoringProvider { static var title: String { get } + + /// Returns the node that the syntax refactoring should be performed on, if code actions are requested for the given + /// scope. + static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> Input? } /// SyntaxCodeActionProviders with a \c Void context can automatically be /// adapted provide a code action based on their refactoring operation. extension SyntaxRefactoringCodeActionProvider where Self.Context == Void { static func codeActions(in scope: SyntaxCodeActionScope) -> [CodeAction] { - guard - let token = scope.firstToken, - let node = token.parent?.as(Input.self) - else { + guard let node = nodeToRefactor(in: scope) else { return [] } let sourceEdits = Self.textRefactor(syntax: node) - if sourceEdits.isEmpty { - return [] - } - let textEdits = sourceEdits.map { edit in - TextEdit( + let textEdits = sourceEdits.compactMap { (edit) -> TextEdit? in + let edit = TextEdit( range: scope.snapshot.range(of: edit.range), newText: edit.replacement ) + if edit.isNoOp(in: scope.snapshot) { + return nil + } + return edit + } + + if textEdits.isEmpty { + return [] } return [ @@ -57,22 +63,77 @@ extension SyntaxRefactoringCodeActionProvider where Self.Context == Void { extension AddSeparatorsToIntegerLiteral: SyntaxRefactoringCodeActionProvider { public static var title: String { "Add digit separators" } + + static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> Input? { + return scope.innermostNodeContainingRange?.findParentOfSelf( + ofType: IntegerLiteralExprSyntax.self, + stoppingIf: { $0.is(CodeBlockSyntax.self) || $0.is(MemberBlockSyntax.self) } + ) + } } extension FormatRawStringLiteral: SyntaxRefactoringCodeActionProvider { public static var title: String { "Convert string literal to minimal number of '#'s" } + + static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> Input? { + return scope.innermostNodeContainingRange?.findParentOfSelf( + ofType: StringLiteralExprSyntax.self, + stoppingIf: { + $0.is(CodeBlockSyntax.self) || $0.is(MemberBlockSyntax.self) + || $0.keyPathInParent == \ExpressionSegmentSyntax.expressions + } + ) + } } extension MigrateToNewIfLetSyntax: SyntaxRefactoringCodeActionProvider { public static var title: String { "Migrate to shorthand 'if let' syntax" } + + static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> Input? { + return scope.innermostNodeContainingRange?.findParentOfSelf( + ofType: IfExprSyntax.self, + stoppingIf: { $0.is(CodeBlockSyntax.self) || $0.is(MemberBlockSyntax.self) } + ) + } } extension OpaqueParameterToGeneric: SyntaxRefactoringCodeActionProvider { public static var title: String { "Expand 'some' parameters to generic parameters" } + + static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> Input? { + return scope.innermostNodeContainingRange?.findParentOfSelf( + ofType: DeclSyntax.self, + stoppingIf: { $0.is(CodeBlockSyntax.self) || $0.is(MemberBlockSyntax.self) } + ) + } } extension RemoveSeparatorsFromIntegerLiteral: SyntaxRefactoringCodeActionProvider { public static var title: String { "Remove digit separators" } + + static func nodeToRefactor(in scope: SyntaxCodeActionScope) -> Input? { + return scope.innermostNodeContainingRange?.findParentOfSelf( + ofType: IntegerLiteralExprSyntax.self, + stoppingIf: { $0.is(CodeBlockSyntax.self) || $0.is(MemberBlockSyntax.self) } + ) + } +} + +extension Syntax { + /// Finds the innermost parent of the given type while not walking outside of nodes that satisfy `stoppingIf`. + func findParentOfSelf( + ofType: ParentType.Type, + stoppingIf: (Syntax) -> Bool + ) -> ParentType? { + var node: Syntax? = self + while let unwrappedNode = node, !stoppingIf(unwrappedNode) { + if let expectedType = unwrappedNode.as(ParentType.self) { + return expectedType + } + node = unwrappedNode.parent + } + return nil + } } diff --git a/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift b/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift index 91c28152a..baae728bb 100644 --- a/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift +++ b/Sources/SourceKitLSP/Swift/CodeCompletionSession.swift @@ -52,9 +52,9 @@ class CodeCompletionSession { /// have a global mapping from `sourcekitd` to its currently active code /// completion session. /// - /// Modification of code completion sessions should only happen on - /// `completionQueue`. - private static var completionSessions: [ObjectIdentifier: CodeCompletionSession] = [:] + /// - Important: Must only be accessed on `completionQueue`. + /// `nonisolated(unsafe)` fine because this is guarded by `completionQueue`. + private static nonisolated(unsafe) var completionSessions: [ObjectIdentifier: CodeCompletionSession] = [:] /// Gets the code completion results for the given parameters. /// @@ -180,7 +180,7 @@ class CodeCompletionSession { in snapshot: DocumentSnapshot, options: SKCompletionOptions ) async throws -> CompletionList { - logger.info("Opening code completion session: \(self, privacy: .private) filter=\(filterText)") + logger.info("Opening code completion session: \(self.description) filter=\(filterText)") guard snapshot.version == self.snapshot.version else { throw ResponseError(code: .invalidRequest, message: "open must use the original snapshot") } @@ -221,7 +221,7 @@ class CodeCompletionSession { ) async throws -> CompletionList { // FIXME: Assertion for prefix of snapshot matching what we started with. - logger.info("Updating code completion session: \(self, privacy: .private) filter=\(filterText)") + logger.info("Updating code completion session: \(self.description) filter=\(filterText)") let req = sourcekitd.dictionary([ keys.request: sourcekitd.requests.codeCompleteUpdate, keys.offset: utf8StartOffset, @@ -272,7 +272,7 @@ class CodeCompletionSession { keys.offset: utf8StartOffset, keys.name: snapshot.uri.pseudoPath, ]) - logger.info("Closing code completion session: \(self, privacy: .private)") + logger.info("Closing code completion session: \(self.description)") _ = try? await sourcekitd.send(req, fileContents: nil) self.state = .closed } diff --git a/Sources/SourceKitLSP/Swift/Diagnostic.swift b/Sources/SourceKitLSP/Swift/Diagnostic.swift index b47e28d5b..e290b30be 100644 --- a/Sources/SourceKitLSP/Swift/Diagnostic.swift +++ b/Sources/SourceKitLSP/Swift/Diagnostic.swift @@ -263,7 +263,7 @@ extension Diagnostic { severity: severity, code: code, codeDescription: codeDescription, - source: "sourcekitd", + source: "SourceKit", message: message, tags: tags, relatedInformation: notes, @@ -297,7 +297,7 @@ extension Diagnostic { severity: diag.diagMessage.severity.lspSeverity, code: nil, codeDescription: nil, - source: "SwiftSyntax", + source: "SourceKit", message: diag.message, tags: nil, relatedInformation: relatedInformation, diff --git a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift index 6f21b250f..677ec823f 100644 --- a/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift +++ b/Sources/SourceKitLSP/Swift/SwiftLanguageService.swift @@ -68,7 +68,7 @@ fileprivate func diagnosticsEnabled(for document: DocumentURI) -> Bool { } /// A swift compiler command derived from a `FileBuildSettingsChange`. -public struct SwiftCompileCommand: Equatable { +public struct SwiftCompileCommand: Sendable, Equatable { /// The compiler arguments, including working directory. This is required since sourcekitd only /// accepts the working directory via the compiler arguments. @@ -90,7 +90,7 @@ public struct SwiftCompileCommand: Equatable { } } -public actor SwiftLanguageService: LanguageService { +public actor SwiftLanguageService: LanguageService, Sendable { /// The ``SourceKitLSPServer`` instance that created this `ClangLanguageService`. weak var sourceKitLSPServer: SourceKitLSPServer? @@ -218,7 +218,7 @@ public actor SwiftLanguageService: LanguageService { } /// - Important: For testing only - public func setReusedNodeCallback(_ callback: ReusedNodeCallback?) async { + public func setReusedNodeCallback(_ callback: (@Sendable (_ node: Syntax) -> ())?) async { await self.syntaxTreeManager.setReusedNodeCallback(callback) } @@ -246,7 +246,7 @@ public actor SwiftLanguageService: LanguageService { } public func addStateChangeHandler( - handler: @escaping (_ oldState: LanguageServerState, _ newState: LanguageServerState) -> Void + handler: @Sendable @escaping (_ oldState: LanguageServerState, _ newState: LanguageServerState) -> Void ) { self.stateChangeHandlers.append(handler) } @@ -755,9 +755,10 @@ extension SwiftLanguageService { return response } - func retrieveCodeActions(_ req: CodeActionRequest, providers: [CodeActionProvider]) async throws - -> [CodeAction] - { + func retrieveCodeActions( + _ req: CodeActionRequest, + providers: [CodeActionProvider] + ) async throws -> [CodeAction] { guard providers.isEmpty == false else { return [] } @@ -776,7 +777,9 @@ extension SwiftLanguageService { let snapshot = try documentManager.latestSnapshot(uri) let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) - let scope = try SyntaxCodeActionScope(snapshot: snapshot, syntaxTree: syntaxTree, request: request) + guard let scope = SyntaxCodeActionScope(snapshot: snapshot, syntaxTree: syntaxTree, request: request) else { + return [] + } return await allSyntaxCodeActions.concurrentMap { provider in return provider.codeActions(in: scope) }.flatMap { $0 } @@ -1152,8 +1155,8 @@ extension DocumentSnapshot { callerFile: StaticString = #fileID, callerLine: UInt = #line ) -> Range { - let lowerBound = self.position(of: node.position) - let upperBound = self.position(of: node.endPosition) + let lowerBound = self.position(of: node.position, callerFile: callerFile, callerLine: callerLine) + let upperBound = self.position(of: node.endPosition, callerFile: callerFile, callerLine: callerLine) return lowerBound.. [TestItem] { + ) async -> [AnnotatedTestItem] { guard snapshot.text.contains("Suite") || snapshot.text.contains("Test") else { // If the file contains swift-testing tests, it must contain a `@Suite` or `@Test` attribute. // Only check for the attribute name because the attribute may be module qualified and contain an arbitrary amount @@ -199,7 +203,11 @@ final class SyntacticSwiftTestingTestScanner: SyntaxVisitor { return [] } let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) - let visitor = SyntacticSwiftTestingTestScanner(snapshot: snapshot, allTestsDisabled: false, parentTypeNames: []) + let visitor = SyntacticSwiftTestingTestScanner( + snapshot: snapshot, + allTestsDisabled: false, + parentTypeNames: [] + ) visitor.walk(syntaxTree) return visitor.result } @@ -241,14 +249,18 @@ final class SyntacticSwiftTestingTestScanner: SyntaxVisitor { } let range = snapshot.range(of: node.positionAfterSkippingLeadingTrivia.. [TestItem] { +fileprivate func testItems(in url: URL) async -> [AnnotatedTestItem] { guard url.pathExtension == "swift" else { return [] } @@ -79,6 +79,7 @@ fileprivate func testItems(in url: URL) async -> [TestItem] { syntaxTreeManager: syntaxTreeManager ) async let xcTests = SyntacticSwiftXCTestScanner.findTestSymbols(in: snapshot, syntaxTreeManager: syntaxTreeManager) + return await swiftTestingTests + xcTests } @@ -206,7 +207,7 @@ actor SyntacticTestIndex { /// Gets all the tests in the syntactic index. /// /// This waits for any pending document updates to be indexed before returning a result. - nonisolated func tests() async -> [TestItem] { + nonisolated func tests() async -> [AnnotatedTestItem] { let readTask = indexingQueue.async(metadata: .read) { return await self.indexedTests.values.flatMap { $0.tests } } diff --git a/Sources/SourceKitLSP/Swift/SyntaxHighlightingToken.swift b/Sources/SourceKitLSP/Swift/SyntaxHighlightingToken.swift index f2fa24798..db2402917 100644 --- a/Sources/SourceKitLSP/Swift/SyntaxHighlightingToken.swift +++ b/Sources/SourceKitLSP/Swift/SyntaxHighlightingToken.swift @@ -15,7 +15,7 @@ import LanguageServerProtocol import SourceKitD /// A ranged token in the document used for syntax highlighting. -public struct SyntaxHighlightingToken: Hashable { +public struct SyntaxHighlightingToken: Hashable, Sendable { /// The range of the token in the document. Must be on a single line. public var range: Range { didSet { diff --git a/Sources/SourceKitLSP/Swift/SyntaxHighlightingTokens.swift b/Sources/SourceKitLSP/Swift/SyntaxHighlightingTokens.swift index d04dcbe14..4515861cf 100644 --- a/Sources/SourceKitLSP/Swift/SyntaxHighlightingTokens.swift +++ b/Sources/SourceKitLSP/Swift/SyntaxHighlightingTokens.swift @@ -15,7 +15,7 @@ import LanguageServerProtocol import SourceKitD /// A wrapper around an array of syntax highlighting tokens. -public struct SyntaxHighlightingTokens { +public struct SyntaxHighlightingTokens: Sendable { public var tokens: [SyntaxHighlightingToken] public init(tokens: [SyntaxHighlightingToken]) { diff --git a/Sources/SourceKitLSP/Swift/VariableTypeInfo.swift b/Sources/SourceKitLSP/Swift/VariableTypeInfo.swift index fd45dd0be..0ed2bcdc3 100644 --- a/Sources/SourceKitLSP/Swift/VariableTypeInfo.swift +++ b/Sources/SourceKitLSP/Swift/VariableTypeInfo.swift @@ -22,11 +22,11 @@ fileprivate extension TokenSyntax { var node = Syntax(self) LOOP: while let parent = node.parent { switch parent.kind { - case .caseItem, .closureParam: + case .switchCaseItem, .closureShorthandParameter: // case items (inside a switch) and closure parameters can’t have type // annotations. return false - case .codeBlockItem, .memberDeclListItem: + case .codeBlockItem, .memberBlockItem: // Performance optimization. If we walked the parents up to code block item, // we can’t enter a case item or closure param anymore. No need walking // the tree any further. diff --git a/Sources/SourceKitLSP/TestDiscovery.swift b/Sources/SourceKitLSP/TestDiscovery.swift index 0a185df1a..3160c0be6 100644 --- a/Sources/SourceKitLSP/TestDiscovery.swift +++ b/Sources/SourceKitLSP/TestDiscovery.swift @@ -21,6 +21,22 @@ public enum TestStyle { public static let swiftTesting = "swift-testing" } +public struct AnnotatedTestItem: Sendable { + /// The test item to be annotated + public var testItem: TestItem + + /// Whether the `TestItem` is an extension. + public var isExtension: Bool + + public init( + testItem: TestItem, + isExtension: Bool + ) { + self.testItem = testItem + self.isExtension = isExtension + } +} + fileprivate extension SymbolOccurrence { /// Assuming that this is a symbol occurrence returned by the index, return whether it can constitute the definition /// of a test case. @@ -95,7 +111,7 @@ extension SourceKitLSPServer { private func testItems( for testSymbolOccurrences: [SymbolOccurrence], resolveLocation: (DocumentURI, Position) -> Location - ) -> [TestItem] { + ) -> [AnnotatedTestItem] { // Arrange tests by the USR they are contained in. This allows us to emit test methods as children of test classes. // `occurrencesByParent[nil]` are the root test symbols that aren't a child of another test symbol. var occurrencesByParent: [String?: [SymbolOccurrence]] = [:] @@ -126,7 +142,7 @@ extension SourceKitLSPServer { for testSymbolOccurrence: SymbolOccurrence, documentManager: DocumentManager, context: [String] - ) -> TestItem { + ) -> AnnotatedTestItem { let symbolPosition: Position if let snapshot = try? documentManager.latestSnapshot( testSymbolOccurrence.location.documentUri @@ -150,17 +166,21 @@ extension SourceKitLSPServer { .map { testItem(for: $0, documentManager: documentManager, context: context + [testSymbolOccurrence.symbol.name]) } - return TestItem( - id: id, - label: testSymbolOccurrence.symbol.name, - disabled: false, - style: TestStyle.xcTest, - location: location, - children: children, - tags: [] + return AnnotatedTestItem( + testItem: TestItem( + id: id, + label: testSymbolOccurrence.symbol.name, + disabled: false, + style: TestStyle.xcTest, + location: location, + children: children.map(\.testItem), + tags: [] + ), + isExtension: false ) } + let documentManager = self.documentManager return occurrencesByParent[nil, default: []] .sorted() .map { testItem(for: $0, documentManager: documentManager, context: []) } @@ -171,7 +191,7 @@ extension SourceKitLSPServer { /// This merges tests from the semantic index, the syntactic index and in-memory file states. /// /// The returned list of tests is not sorted. It should be sorted before being returned to the editor. - private func tests(in workspace: Workspace) async -> [TestItem] { + private func tests(in workspace: Workspace) async -> [AnnotatedTestItem] { // Gather all tests classes and test methods. We include test from different sources: // - For all files that have been not been modified since they were last indexed in the semantic index, include // XCTests from the semantic index. @@ -192,12 +212,12 @@ extension SourceKitLSPServer { return index?.fileHasInMemoryModifications(url) ?? documentManager.fileHasInMemoryModifications(url) } - let testsFromFilesWithInMemoryState = await filesWithInMemoryState.concurrentMap { (uri) -> [TestItem] in - guard let languageService = workspace.documentService[uri] else { + let testsFromFilesWithInMemoryState = await filesWithInMemoryState.concurrentMap { (uri) -> [AnnotatedTestItem] in + guard let languageService = workspace.documentService.value[uri] else { return [] } return await orLog("Getting document tests for \(uri)") { - try await self.documentTests( + try await self.documentTestsWithoutMergingExtensions( DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)), workspace: workspace, languageService: languageService @@ -212,16 +232,17 @@ extension SourceKitLSPServer { for: semanticTestSymbolOccurrences, resolveLocation: { uri, position in Location(uri: uri, range: Range(position)) } ) - let filesWithTestsFromSemanticIndex = Set(testsFromSemanticIndex.map(\.location.uri)) + let filesWithTestsFromSemanticIndex = Set(testsFromSemanticIndex.map(\.testItem.location.uri)) let indexOnlyDiscardingDeletedFiles = workspace.index(checkedFor: .deletedFiles) let syntacticTestsToInclude = testsFromSyntacticIndex - .compactMap { (testItem) -> TestItem? in + .compactMap { (item) -> AnnotatedTestItem? in + let testItem = item.testItem if testItem.style == TestStyle.swiftTesting { // Swift-testing tests aren't part of the semantic index. Always include them. - return testItem + return item } if filesWithTestsFromSemanticIndex.contains(testItem.location.uri) { // If we have an semantic tests from this file, then the semantic index is up-to-date for this file. We thus @@ -244,9 +265,12 @@ extension SourceKitLSPServer { // XCTestCase subclasses, swift-testing handled above) for the same file. In practice test files usually contain // a single XCTestCase subclass, so caching doesn't make sense here. // Also, this is only called for files containing test cases but for which the semantic index is out-of-date. - return testItem.filterUsing( + if let filtered = testItem.filterUsing( semanticSymbols: indexOnlyDiscardingDeletedFiles?.symbols(inFilePath: testItem.location.uri.pseudoPath) - ) + ) { + return AnnotatedTestItem(testItem: filtered, isExtension: item.isExtension) + } + return nil } // We don't need to sort the tests here because they will get @@ -257,17 +281,8 @@ extension SourceKitLSPServer { return await self.workspaces .concurrentMap { await self.tests(in: $0) } .flatMap { $0 } - .sorted { $0.location < $1.location } - } - - /// Extracts a flat dictionary mapping test IDs to their locations from the given `testItems`. - private func testLocations(from testItems: [TestItem]) -> [String: Location] { - var result: [String: Location] = [:] - for testItem in testItems { - result[testItem.id] = testItem.location - result.merge(testLocations(from: testItem.children)) { old, new in new } - } - return result + .sorted { $0.testItem.location < $1.testItem.location } + .mergingTestsInExtensions() } func documentTests( @@ -275,6 +290,15 @@ extension SourceKitLSPServer { workspace: Workspace, languageService: LanguageService ) async throws -> [TestItem] { + return try await documentTestsWithoutMergingExtensions(req, workspace: workspace, languageService: languageService) + .mergingTestsInExtensions() + } + + private func documentTestsWithoutMergingExtensions( + _ req: DocumentTestsRequest, + workspace: Workspace, + languageService: LanguageService + ) async throws -> [AnnotatedTestItem] { let snapshot = try self.documentManager.latestSnapshot(req.textDocument.uri) let mainFileUri = await workspace.buildSystemManager.mainFile( for: req.textDocument.uri, @@ -290,8 +314,8 @@ extension SourceKitLSPServer { syntacticTests == nil ? .deletedFiles : .inMemoryModifiedFiles(documentManager) if let index = workspace.index(checkedFor: indexCheckLevel) { - var syntacticSwiftTestingTests: [TestItem] { - syntacticTests?.filter { $0.style == TestStyle.swiftTesting } ?? [] + var syntacticSwiftTestingTests: [AnnotatedTestItem] { + syntacticTests?.filter { $0.testItem.style == TestStyle.swiftTesting } ?? [] } let testSymbols = @@ -337,7 +361,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor { private var snapshot: DocumentSnapshot /// The workspace symbols representing the found `XCTestCase` subclasses and test methods. - private var result: [TestItem] = [] + private var result: [AnnotatedTestItem] = [] private init(snapshot: DocumentSnapshot) { self.snapshot = snapshot @@ -347,7 +371,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor { public static func findTestSymbols( in snapshot: DocumentSnapshot, syntaxTreeManager: SyntaxTreeManager - ) async -> [TestItem] { + ) async -> [AnnotatedTestItem] { guard snapshot.text.contains("XCTestCase") || snapshot.text.contains("test") else { // If the file contains tests that can be discovered syntactically, it needs to have a class inheriting from // `XCTestCase` or a function starting with `test`. @@ -414,14 +438,17 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor { return .visitChildren } let range = snapshot.range(of: node.positionAfterSkippingLeadingTrivia.. SyntaxVisitorContinueKind { result += findTestMethods(in: node.memberBlock.members, containerName: node.extendedType.trimmedDescription) + .map { AnnotatedTestItem(testItem: $0, isExtension: true) } return .visitChildren } } @@ -462,24 +490,145 @@ extension TestItem { } } +extension AnnotatedTestItem { + /// Use out-of-date semantic information to filter syntactic symbols. + /// + /// Delegates to the `TestItem`'s `filterUsing(semanticSymbols:)` method to perform the filtering. + fileprivate func filterUsing(semanticSymbols: [Symbol]?) -> AnnotatedTestItem? { + guard let testItem = self.testItem.filterUsing(semanticSymbols: semanticSymbols) else { + return nil + } + var test = self + test.testItem = testItem + return test + } +} + +extension Array { + /// When the test scanners discover tests in extensions they are captured in their own parent `TestItem`, not the + /// `TestItem` generated from the class/struct's definition. This is largely because of the syntatic nature of the + /// test scanners as they are today, which only know about tests within the context of the current file. Extensions + /// defined in separate files must be organized in their own `TestItem` since at the time of their creation there + /// isn't enough information to connect them back to the tests defined in the main type definition. + /// + /// This is a more syntatic than semantic view of the `TestItem` hierarchy than the end user likely wants. + /// If we think of the enclosing class or struct as the test suite, then extensions on that class or struct should be + /// additions to that suite, just like extensions on types are, from the user's perspective, transparently added to + /// their type. + /// + /// This method walks the `AnnotatedTestItem` tree produced by the test scanners and merges in the tests defined in + /// extensions into the final `TestItem`s that represent the type definition. + /// + /// This causes extensions to be merged into their type's definition if the type's definition exists in the list of + /// test items. If the type's definition is not a test item in this collection, the first extension of that type will + /// be used as the primary test location. + /// + /// For example if there are two files + /// + /// FileA.swift + /// ```swift + /// @Suite struct MyTests { + /// @Test func oneIsTwo {} + /// } + /// ``` + /// + /// FileB.swift + /// ```swift + /// extension MyTests { + /// @Test func twoIsThree() {} + /// } + /// ``` + /// + /// Then `workspace/tests` will return + /// - `MyTests` (FileA.swift:1) + /// - `oneIsTwo` + /// - `twoIsThree` + /// + /// And `textDocument/tests` for FileB.swift will return + /// - `MyTests` (FileB.swift:1) + /// - `twoIsThree` + /// + /// A node's parent is identified by the node's ID with the last component dropped. + func mergingTestsInExtensions() -> [TestItem] { + var itemDict: [String: AnnotatedTestItem] = [:] + for item in self { + let id = item.testItem.id + if var rootItem = itemDict[id] { + // If we've encountered an extension first, and this is the + // type declaration, then use the type declaration TestItem + // as the root item. + if rootItem.isExtension && !item.isExtension { + var newItem = item + newItem.testItem.children += rootItem.testItem.children + rootItem = newItem + } else { + rootItem.testItem.children += item.testItem.children + } + + itemDict[id] = rootItem + } else { + itemDict[id] = item + } + } + + if itemDict.isEmpty { + return [] + } + + var mergedIds = Set() + for item in self { + let id = item.testItem.id + let parentID = id.components(separatedBy: "/").dropLast().joined(separator: "/") + // If the parent exists, add the current item to its children and remove it from the root + if var parent = itemDict[parentID] { + parent.testItem.children.append(item.testItem) + mergedIds.insert(parent.testItem.id) + itemDict[parent.testItem.id] = parent + itemDict[id] = nil + } + } + + // Sort the tests by location, prioritizing TestItems not in extensions. + let sortedItems = itemDict.values + .sorted { ($0.isExtension != $1.isExtension) ? !$0.isExtension : ($0.testItem.location < $1.testItem.location) } + + let result = sortedItems.map { + guard !$0.testItem.children.isEmpty, mergedIds.contains($0.testItem.id) else { + return $0.testItem + } + var newItem = $0.testItem + newItem.children = newItem.children + .map { AnnotatedTestItem(testItem: $0, isExtension: false) } + .mergingTestsInExtensions() + return newItem + } + return result + } +} + extension SwiftLanguageService { - public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem]? { + public func syntacticDocumentTests( + for uri: DocumentURI, + in workspace: Workspace + ) async throws -> [AnnotatedTestItem]? { let snapshot = try documentManager.latestSnapshot(uri) let semanticSymbols = workspace.index(checkedFor: .deletedFiles)?.symbols(inFilePath: snapshot.uri.pseudoPath) let xctestSymbols = await SyntacticSwiftXCTestScanner.findTestSymbols( in: snapshot, syntaxTreeManager: syntaxTreeManager - ).compactMap { $0.filterUsing(semanticSymbols: semanticSymbols) } + ) + .compactMap { $0.filterUsing(semanticSymbols: semanticSymbols) } + let swiftTestingSymbols = await SyntacticSwiftTestingTestScanner.findTestSymbols( in: snapshot, syntaxTreeManager: syntaxTreeManager ) - return (xctestSymbols + swiftTestingSymbols).sorted { $0.location < $1.location } + return (xctestSymbols + swiftTestingSymbols).sorted { $0.testItem.location < $1.testItem.location } } } extension ClangLanguageService { - public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem]? { + public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [AnnotatedTestItem]? { return nil } } diff --git a/Sources/SourceKitLSP/TextEdit+IsNoop.swift b/Sources/SourceKitLSP/TextEdit+IsNoop.swift new file mode 100644 index 000000000..dc4d3a59c --- /dev/null +++ b/Sources/SourceKitLSP/TextEdit+IsNoop.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +extension TextEdit { + /// Returns `true` the replaced text is the same as the new text + func isNoOp(in snapshot: DocumentSnapshot) -> Bool { + if snapshot.text[snapshot.indexRange(of: range)] == newText { + return true + } + return false + } +} diff --git a/Sources/SourceKitLSP/WorkDoneProgressManager.swift b/Sources/SourceKitLSP/WorkDoneProgressManager.swift index 089e76f90..f4f59d496 100644 --- a/Sources/SourceKitLSP/WorkDoneProgressManager.swift +++ b/Sources/SourceKitLSP/WorkDoneProgressManager.swift @@ -23,20 +23,37 @@ final class WorkDoneProgressManager { private let queue = AsyncQueue() private let server: SourceKitLSPServer - init?(server: SourceKitLSPServer, capabilityRegistry: CapabilityRegistry, title: String, message: String? = nil) { + convenience init?(server: SourceKitLSPServer, title: String, message: String? = nil, percentage: Int? = nil) async { + guard let capabilityRegistry = await server.capabilityRegistry else { + return nil + } + self.init(server: server, capabilityRegistry: capabilityRegistry, title: title, message: message) + } + + init?( + server: SourceKitLSPServer, + capabilityRegistry: CapabilityRegistry, + title: String, + message: String? = nil, + percentage: Int? = nil + ) { guard capabilityRegistry.clientCapabilities.window?.workDoneProgress ?? false else { return nil } self.token = .string("WorkDoneProgress-\(UUID())") self.server = server queue.async { [server, token] in + await server.waitUntilInitialized() await withCheckedContinuation { (continuation: CheckedContinuation) in _ = server.client.send(CreateWorkDoneProgressRequest(token: token)) { result in continuation.resume() } } await server.sendNotificationToClient( - WorkDoneProgress(token: token, value: .begin(WorkDoneProgressBegin(title: title, message: message))) + WorkDoneProgress( + token: token, + value: .begin(WorkDoneProgressBegin(title: title, message: message, percentage: percentage)) + ) ) } } diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index cb8d8ef52..cd7a083f4 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -46,7 +46,7 @@ fileprivate func firstNonNil( /// "initialize" request has been made. /// /// Typically a workspace is contained in a root directory. -public final class Workspace { +public final class Workspace: Sendable { /// The root directory of the workspace. public let rootUri: DocumentURI? @@ -63,7 +63,7 @@ public final class Workspace { /// The source code index, if available. /// /// Usually a checked index (retrieved using `index(checkedFor:)`) should be used instead of the unchecked index. - var uncheckedIndex: UncheckedIndex? = nil + private let uncheckedIndex: ThreadSafeBox /// The index that syntactically scans the workspace for tests. let syntacticTestIndex = SyntacticTestIndex() @@ -72,7 +72,13 @@ public final class Workspace { private let documentManager: DocumentManager /// Language service for an open document, if available. - var documentService: [DocumentURI: LanguageService] = [:] + let documentService: ThreadSafeBox<[DocumentURI: LanguageService]> = ThreadSafeBox(initialValue: [:]) + + /// The `SemanticIndexManager` that keeps track of whose file's index is up-to-date in the workspace and schedules + /// indexing and preparation tasks for files with out-of-date index. + /// + /// `nil` if background indexing is not enabled. + let semanticIndexManager: SemanticIndexManager? public init( documentManager: DocumentManager, @@ -82,19 +88,33 @@ public final class Workspace { options: SourceKitLSPServer.Options, underlyingBuildSystem: BuildSystem?, index uncheckedIndex: UncheckedIndex?, - indexDelegate: SourceKitIndexDelegate? + indexDelegate: SourceKitIndexDelegate?, + indexTaskScheduler: TaskScheduler, + indexTasksWereScheduled: @escaping @Sendable (Int) -> Void, + indexTaskDidFinish: @escaping @Sendable () -> Void ) async { self.documentManager = documentManager self.buildSetup = options.buildSetup self.rootUri = rootUri self.capabilityRegistry = capabilityRegistry - self.uncheckedIndex = uncheckedIndex + self.uncheckedIndex = ThreadSafeBox(initialValue: uncheckedIndex) self.buildSystemManager = await BuildSystemManager( buildSystem: underlyingBuildSystem, fallbackBuildSystem: FallbackBuildSystem(buildSetup: buildSetup), mainFilesProvider: uncheckedIndex, toolchainRegistry: toolchainRegistry ) + if let uncheckedIndex, options.indexOptions.enableBackgroundIndexing { + self.semanticIndexManager = SemanticIndexManager( + index: uncheckedIndex, + buildSystemManager: buildSystemManager, + indexTaskScheduler: indexTaskScheduler, + indexTasksWereScheduled: indexTasksWereScheduled, + indexTaskDidFinish: indexTaskDidFinish + ) + } else { + self.semanticIndexManager = nil + } await indexDelegate?.addMainFileChangedCallback { [weak self] in await self?.buildSystemManager.mainFilesChanged() } @@ -106,6 +126,9 @@ public final class Workspace { } // Trigger an initial population of `syntacticTestIndex`. await syntacticTestIndex.listOfTestFilesDidChange(buildSystemManager.testFiles()) + if let semanticIndexManager { + await semanticIndexManager.scheduleBuildGraphGenerationAndBackgroundIndexAllFiles() + } } /// Creates a workspace for a given root `URL`, inferring the `ExternalWorkspace` if possible. @@ -122,16 +145,26 @@ public final class Workspace { options: SourceKitLSPServer.Options, compilationDatabaseSearchPaths: [RelativePath], indexOptions: IndexOptions = IndexOptions(), - reloadPackageStatusCallback: @escaping (ReloadPackageStatus) async -> Void + indexTaskScheduler: TaskScheduler, + reloadPackageStatusCallback: @Sendable @escaping (ReloadPackageStatus) async -> Void, + indexTasksWereScheduled: @Sendable @escaping (Int) -> Void, + indexTaskDidFinish: @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 forceResolvedVersions = true + if options.indexOptions.enableBackgroundIndexing, options.buildSetup.path == nil { + options.buildSetup.path = rootPath.appending(component: ".index-build") + forceResolvedVersions = false + } func createSwiftPMBuildSystem(rootUrl: URL) async -> SwiftPMBuildSystem? { return await SwiftPMBuildSystem( url: rootUrl, toolchainRegistry: toolchainRegistry, buildSetup: options.buildSetup, + forceResolvedVersions: forceResolvedVersions, reloadPackageStatusCallback: reloadPackageStatusCallback ) } @@ -218,14 +251,25 @@ public final class Workspace { options: options, underlyingBuildSystem: buildSystem, index: UncheckedIndex(index), - indexDelegate: indexDelegate + indexDelegate: indexDelegate, + indexTaskScheduler: indexTaskScheduler, + indexTasksWereScheduled: indexTasksWereScheduled, + indexTaskDidFinish: indexTaskDidFinish ) } /// Returns a `CheckedIndex` that verifies that all the returned entries are up-to-date with the given /// `IndexCheckLevel`. func index(checkedFor checkLevel: IndexCheckLevel) -> CheckedIndex? { - return uncheckedIndex?.checked(for: checkLevel) + return uncheckedIndex.value?.checked(for: checkLevel) + } + + /// Write the index to disk. + /// + /// After this method is called, the workspace will no longer have an index associated with it. It should only be + /// called when SourceKit-LSP shuts down. + func closeIndex() { + uncheckedIndex.value = nil } public func filesDidChange(_ events: [FileEvent]) async { @@ -243,7 +287,7 @@ struct WeakWorkspace { } } -public struct IndexOptions { +public struct IndexOptions: Sendable { /// Override the index-store-path provided by the build system. public var indexStorePath: AbsolutePath? @@ -258,15 +302,27 @@ public struct IndexOptions { /// explicit calls to pollForUnitChangesAndWait(). public var listenToUnitEvents: Bool + /// Whether background indexing should be enabled. + public var enableBackgroundIndexing: Bool + + /// The percentage of the machine's cores that should at most be used for background indexing. + /// + /// Setting this to a value < 1 ensures that background indexing doesn't use all CPU resources. + public var maxCoresPercentageToUseForBackgroundIndexing: Double + public init( indexStorePath: AbsolutePath? = nil, indexDatabasePath: AbsolutePath? = nil, indexPrefixMappings: [PathPrefixMapping]? = nil, - listenToUnitEvents: Bool = true + listenToUnitEvents: Bool = true, + enableBackgroundIndexing: Bool = false, + maxCoresPercentageToUseForBackgroundIndexing: Double = 1 ) { self.indexStorePath = indexStorePath self.indexDatabasePath = indexDatabasePath self.indexPrefixMappings = indexPrefixMappings self.listenToUnitEvents = listenToUnitEvents + self.enableBackgroundIndexing = enableBackgroundIndexing + self.maxCoresPercentageToUseForBackgroundIndexing = maxCoresPercentageToUseForBackgroundIndexing } } diff --git a/Sources/sourcekit-lsp/SourceKitLSP.swift b/Sources/sourcekit-lsp/SourceKitLSP.swift index dc2f7cb1a..89f768110 100644 --- a/Sources/sourcekit-lsp/SourceKitLSP.swift +++ b/Sources/sourcekit-lsp/SourceKitLSP.swift @@ -200,6 +200,11 @@ struct SourceKitLSP: AsyncParsableCommand { ) var completionMaxResults = 200 + @Flag( + help: "Enable background indexing. This feature is still under active development and may be incomplete." + ) + var enableExperimentalBackgroundIndexing = false + func mapOptions() -> SourceKitLSPServer.Options { var serverOptions = SourceKitLSPServer.Options() @@ -215,6 +220,7 @@ struct SourceKitLSP: AsyncParsableCommand { serverOptions.indexOptions.indexStorePath = indexStorePath serverOptions.indexOptions.indexDatabasePath = indexDatabasePath serverOptions.indexOptions.indexPrefixMappings = indexPrefixMappings + serverOptions.indexOptions.enableBackgroundIndexing = enableExperimentalBackgroundIndexing serverOptions.completionOptions.maxResults = completionMaxResults serverOptions.generatedInterfacesPath = generatedInterfacesPath diff --git a/Tests/DiagnoseTests/DiagnoseTests.swift b/Tests/DiagnoseTests/DiagnoseTests.swift index b7dab78a4..f4502a40f 100644 --- a/Tests/DiagnoseTests/DiagnoseTests.swift +++ b/Tests/DiagnoseTests/DiagnoseTests.swift @@ -114,7 +114,7 @@ final class DiagnoseTests: XCTestCase { let foo = 1️⃣Foo() } - /* + /* Block comment With another line */ @@ -146,6 +146,7 @@ final class DiagnoseTests: XCTestCase { ) } + @MainActor func testReduceFrontend() async throws { try await withTestScratchDir { scratchDir in let fileAContents = """ @@ -227,6 +228,7 @@ final class DiagnoseTests: XCTestCase { /// - `$OFFSET`: The UTF-8 offset of the 1️⃣ location marker in `markedFileContents` /// - reproducerPredicate: A predicate that indicates whether a run request reproduces the issue. /// - expectedReducedFileContents: The contents of the file that the reducer is expected to produce. +@MainActor private func assertReduceSourceKitD( _ markedFileContents: String, request: String, diff --git a/Tests/SKCoreTests/BuildSystemManagerTests.swift b/Tests/SKCoreTests/BuildSystemManagerTests.swift index 8dd89ccf7..bb15ad142 100644 --- a/Tests/SKCoreTests/BuildSystemManagerTests.swift +++ b/Tests/SKCoreTests/BuildSystemManagerTests.swift @@ -147,7 +147,7 @@ final class BuildSystemManagerTests: XCTestCase { ) defer { withExtendedLifetime(bsm) {} } // Keep BSM alive for callbacks. let del = await BSMDelegate(bsm) - let fallbackSettings = fallback.buildSettings(for: a, language: .swift) + let fallbackSettings = await fallback.buildSettings(for: a, language: .swift) await bsm.registerForChangeNotifications(for: a, language: .swift) assertEqual(await bsm.buildSettingsInferredFromMainFile(for: a, language: .swift), fallbackSettings) @@ -445,7 +445,7 @@ class ManualBuildSystem: BuildSystem { self.delegate = delegate } - func buildSettings(for uri: DocumentURI, language: Language) -> FileBuildSettings? { + func buildSettings(for uri: DocumentURI, in buildTarget: ConfiguredTarget, language: Language) -> FileBuildSettings? { return map[uri] } @@ -453,6 +453,20 @@ class ManualBuildSystem: BuildSystem { return nil } + public func configuredTargets(for document: DocumentURI) async -> [ConfiguredTarget] { + return [ConfiguredTarget(targetID: "dummy", runDestinationID: "dummy")] + } + + public func prepare(targets: [ConfiguredTarget]) async throws { + throw PrepareNotSupportedError() + } + + public func generateBuildGraph() {} + + public func topologicalSort(of targets: [ConfiguredTarget]) -> [ConfiguredTarget]? { + return nil + } + func registerForChangeNotifications(for uri: DocumentURI) async { } diff --git a/Tests/SKCoreTests/CompilationDatabaseTests.swift b/Tests/SKCoreTests/CompilationDatabaseTests.swift index 3498ba425..934c1aaa7 100644 --- a/Tests/SKCoreTests/CompilationDatabaseTests.swift +++ b/Tests/SKCoreTests/CompilationDatabaseTests.swift @@ -290,6 +290,7 @@ final class CompilationDatabaseTests: XCTestCase { ) { buildSystem in let settings = await buildSystem.buildSettings( for: DocumentURI(URL(fileURLWithPath: "/a/a.swift")), + in: ConfiguredTarget(targetID: "dummy", runDestinationID: "dummy"), language: .swift ) XCTAssertNotNil(settings) diff --git a/Tests/SKCoreTests/FallbackBuildSystemTests.swift b/Tests/SKCoreTests/FallbackBuildSystemTests.swift index a6be3920a..748171e02 100644 --- a/Tests/SKCoreTests/FallbackBuildSystemTests.swift +++ b/Tests/SKCoreTests/FallbackBuildSystemTests.swift @@ -10,8 +10,9 @@ // //===----------------------------------------------------------------------===// +import LSPTestSupport import LanguageServerProtocol -import SKCore +@_spi(Testing) import SKCore import TSCBasic import XCTest @@ -19,17 +20,17 @@ import struct PackageModel.BuildFlags final class FallbackBuildSystemTests: XCTestCase { - func testSwift() throws { + func testSwift() async throws { let sdk = try AbsolutePath(validating: "/my/sdk") let source = try AbsolutePath(validating: "/my/source.swift") let bs = FallbackBuildSystem(buildSetup: .default) - bs.sdkpath = sdk + await bs.setSdkPath(sdk) - XCTAssertNil(bs.indexStorePath) - XCTAssertNil(bs.indexDatabasePath) + assertNil(await bs.indexStorePath) + assertNil(await bs.indexDatabasePath) - let settings = bs.buildSettings(for: source.asURI, language: .swift)! + let settings = await bs.buildSettings(for: source.asURI, language: .swift)! XCTAssertNil(settings.workingDirectory) let args = settings.compilerArguments @@ -42,17 +43,17 @@ final class FallbackBuildSystemTests: XCTestCase { ] ) - bs.sdkpath = nil + await bs.setSdkPath(nil) - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .swift)?.compilerArguments, + assertEqual( + await bs.buildSettings(for: source.asURI, language: .swift)?.compilerArguments, [ source.pathString ] ) } - func testSwiftWithCustomFlags() throws { + func testSwiftWithCustomFlags() async throws { let sdk = try AbsolutePath(validating: "/my/sdk") let source = try AbsolutePath(validating: "/my/source.swift") @@ -66,9 +67,9 @@ final class FallbackBuildSystemTests: XCTestCase { ]) ) let bs = FallbackBuildSystem(buildSetup: buildSetup) - bs.sdkpath = sdk + await bs.setSdkPath(sdk) - let args = bs.buildSettings(for: source.asURI, language: .swift)?.compilerArguments + let args = await bs.buildSettings(for: source.asURI, language: .swift)?.compilerArguments XCTAssertEqual( args, [ @@ -80,10 +81,10 @@ final class FallbackBuildSystemTests: XCTestCase { ] ) - bs.sdkpath = nil + await bs.setSdkPath(nil) - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .swift)?.compilerArguments, + assertEqual( + await bs.buildSettings(for: source.asURI, language: .swift)?.compilerArguments, [ "-Xfrontend", "-debug-constraints", @@ -92,7 +93,7 @@ final class FallbackBuildSystemTests: XCTestCase { ) } - func testSwiftWithCustomSDKFlag() throws { + func testSwiftWithCustomSDKFlag() async throws { let sdk = try AbsolutePath(validating: "/my/sdk") let source = try AbsolutePath(validating: "/my/source.swift") @@ -108,10 +109,10 @@ final class FallbackBuildSystemTests: XCTestCase { ]) ) let bs = FallbackBuildSystem(buildSetup: buildSetup) - bs.sdkpath = sdk + await bs.setSdkPath(sdk) - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .swift)!.compilerArguments, + assertEqual( + await bs.buildSettings(for: source.asURI, language: .swift)!.compilerArguments, [ "-sdk", "/some/custom/sdk", @@ -121,10 +122,10 @@ final class FallbackBuildSystemTests: XCTestCase { ] ) - bs.sdkpath = nil + await bs.setSdkPath(nil) - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .swift)!.compilerArguments, + assertEqual( + await bs.buildSettings(for: source.asURI, language: .swift)!.compilerArguments, [ "-sdk", "/some/custom/sdk", @@ -135,14 +136,14 @@ final class FallbackBuildSystemTests: XCTestCase { ) } - func testCXX() throws { + func testCXX() async throws { let sdk = try AbsolutePath(validating: "/my/sdk") let source = try AbsolutePath(validating: "/my/source.cpp") let bs = FallbackBuildSystem(buildSetup: .default) - bs.sdkpath = sdk + await bs.setSdkPath(sdk) - let settings = bs.buildSettings(for: source.asURI, language: .cpp)! + let settings = await bs.buildSettings(for: source.asURI, language: .cpp)! XCTAssertNil(settings.workingDirectory) let args = settings.compilerArguments @@ -155,17 +156,17 @@ final class FallbackBuildSystemTests: XCTestCase { ] ) - bs.sdkpath = nil + await bs.setSdkPath(nil) - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .cpp)?.compilerArguments, + assertEqual( + await bs.buildSettings(for: source.asURI, language: .cpp)?.compilerArguments, [ source.pathString ] ) } - func testCXXWithCustomFlags() throws { + func testCXXWithCustomFlags() async throws { let sdk = try AbsolutePath(validating: "/my/sdk") let source = try AbsolutePath(validating: "/my/source.cpp") @@ -178,10 +179,10 @@ final class FallbackBuildSystemTests: XCTestCase { ]) ) let bs = FallbackBuildSystem(buildSetup: buildSetup) - bs.sdkpath = sdk + await bs.setSdkPath(sdk) - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .cpp)?.compilerArguments, + assertEqual( + await bs.buildSettings(for: source.asURI, language: .cpp)?.compilerArguments, [ "-v", "-isysroot", @@ -190,10 +191,10 @@ final class FallbackBuildSystemTests: XCTestCase { ] ) - bs.sdkpath = nil + await bs.setSdkPath(nil) - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .cpp)?.compilerArguments, + assertEqual( + await bs.buildSettings(for: source.asURI, language: .cpp)?.compilerArguments, [ "-v", source.pathString, @@ -201,7 +202,7 @@ final class FallbackBuildSystemTests: XCTestCase { ) } - func testCXXWithCustomIsysroot() throws { + func testCXXWithCustomIsysroot() async throws { let sdk = try AbsolutePath(validating: "/my/sdk") let source = try AbsolutePath(validating: "/my/source.cpp") @@ -216,10 +217,10 @@ final class FallbackBuildSystemTests: XCTestCase { ]) ) let bs = FallbackBuildSystem(buildSetup: buildSetup) - bs.sdkpath = sdk + await bs.setSdkPath(sdk) - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .cpp)?.compilerArguments, + assertEqual( + await bs.buildSettings(for: source.asURI, language: .cpp)?.compilerArguments, [ "-isysroot", "/my/custom/sdk", @@ -228,10 +229,10 @@ final class FallbackBuildSystemTests: XCTestCase { ] ) - bs.sdkpath = nil + await bs.setSdkPath(nil) - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .cpp)?.compilerArguments, + assertEqual( + await bs.buildSettings(for: source.asURI, language: .cpp)?.compilerArguments, [ "-isysroot", "/my/custom/sdk", @@ -241,19 +242,19 @@ final class FallbackBuildSystemTests: XCTestCase { ) } - func testC() throws { + func testC() async throws { let source = try AbsolutePath(validating: "/my/source.c") let bs = FallbackBuildSystem(buildSetup: .default) - bs.sdkpath = nil - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .c)?.compilerArguments, + await bs.setSdkPath(nil) + assertEqual( + await bs.buildSettings(for: source.asURI, language: .c)?.compilerArguments, [ source.pathString ] ) } - func testCWithCustomFlags() throws { + func testCWithCustomFlags() async throws { let source = try AbsolutePath(validating: "/my/source.c") let buildSetup = BuildSetup( @@ -265,9 +266,9 @@ final class FallbackBuildSystemTests: XCTestCase { ]) ) let bs = FallbackBuildSystem(buildSetup: buildSetup) - bs.sdkpath = nil - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .c)?.compilerArguments, + await bs.setSdkPath(nil) + assertEqual( + await bs.buildSettings(for: source.asURI, language: .c)?.compilerArguments, [ "-v", source.pathString, @@ -275,33 +276,33 @@ final class FallbackBuildSystemTests: XCTestCase { ) } - func testObjC() throws { + func testObjC() async throws { let source = try AbsolutePath(validating: "/my/source.m") let bs = FallbackBuildSystem(buildSetup: .default) - bs.sdkpath = nil - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .objective_c)?.compilerArguments, + await bs.setSdkPath(nil) + assertEqual( + await bs.buildSettings(for: source.asURI, language: .objective_c)?.compilerArguments, [ source.pathString ] ) } - func testObjCXX() throws { + func testObjCXX() async throws { let source = try AbsolutePath(validating: "/my/source.mm") let bs = FallbackBuildSystem(buildSetup: .default) - bs.sdkpath = nil - XCTAssertEqual( - bs.buildSettings(for: source.asURI, language: .objective_cpp)?.compilerArguments, + await bs.setSdkPath(nil) + assertEqual( + await bs.buildSettings(for: source.asURI, language: .objective_cpp)?.compilerArguments, [ source.pathString ] ) } - func testUnknown() throws { + func testUnknown() async throws { let source = try AbsolutePath(validating: "/my/source.mm") let bs = FallbackBuildSystem(buildSetup: .default) - XCTAssertNil(bs.buildSettings(for: source.asURI, language: Language(rawValue: "unknown"))) + assertNil(await bs.buildSettings(for: source.asURI, language: Language(rawValue: "unknown"))) } } diff --git a/Tests/SKCoreTests/TaskSchedulerTests.swift b/Tests/SKCoreTests/TaskSchedulerTests.swift index db81c320b..9ca7a1bd0 100644 --- a/Tests/SKCoreTests/TaskSchedulerTests.swift +++ b/Tests/SKCoreTests/TaskSchedulerTests.swift @@ -11,6 +11,7 @@ //===----------------------------------------------------------------------===// import SKCore +import SKTestSupport import XCTest final class TaskSchedulerTests: XCTestCase { @@ -285,24 +286,6 @@ fileprivate actor TaskExecutionRecorder { } } -/// Wrapper around `DispatchSemaphore` so that Swift Concurrency doesn't complain about the usage of semaphores in the -/// tests. -fileprivate struct UnsafeSemaphore { - let semaphore = DispatchSemaphore(value: 0) - - func signal(value: Int = 1) { - for _ in 0.. FileBuildSettings? { + guard let target = self.configuredTargets(for: uri).only else { + return nil + } + return try buildSettings(for: uri, in: target, language: language) + } +} +final class SwiftPMBuildSystemTests: XCTestCase { func testNoPackage() async throws { let fs = InMemoryFileSystem() try await withTestScratchDir { tempDir in @@ -45,7 +53,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) ) } @@ -72,7 +81,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) ) } @@ -99,7 +109,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: ToolchainRegistry(toolchains: []), fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) ) } @@ -126,7 +137,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) let aswift = packageRoot.appending(components: "Sources", "lib", "a.swift") @@ -194,7 +206,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: config + buildSetup: config, + forceResolvedVersions: true ) let aswift = packageRoot.appending(components: "Sources", "lib", "a.swift") @@ -231,7 +244,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) let source = try resolveSymlinks(packageRoot.appending(component: "Package.swift")) @@ -264,7 +278,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) let aswift = packageRoot.appending(components: "Sources", "lib", "a.swift") @@ -309,7 +324,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) let aswift = packageRoot.appending(components: "Sources", "libA", "a.swift") @@ -371,7 +387,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) let aswift = packageRoot.appending(components: "Sources", "libA", "a.swift") @@ -411,7 +428,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) let acxx = packageRoot.appending(components: "Sources", "lib", "a.cpp") @@ -490,7 +508,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: ToolchainRegistry.forTesting, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) let aswift = packageRoot.appending(components: "Sources", "lib", "a.swift") @@ -537,7 +556,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) let aswift1 = packageRoot.appending(components: "Sources", "lib", "a.swift") @@ -601,7 +621,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: symlinkRoot, toolchainRegistry: ToolchainRegistry.forTesting, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) for file in [acpp, ah] { @@ -641,7 +662,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) let aswift = packageRoot.appending(components: "Sources", "lib", "a.swift") @@ -677,7 +699,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) assertEqual(await swiftpmBuildSystem.projectRoot, try resolveSymlinks(tempDir.appending(component: "pkg"))) @@ -713,7 +736,8 @@ final class SwiftPMWorkspaceTests: XCTestCase { workspacePath: packageRoot, toolchainRegistry: tr, fileSystem: fs, - buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup + buildSetup: SourceKitLSPServer.Options.testDefault.buildSetup, + forceResolvedVersions: true ) let aswift = packageRoot.appending(components: "Plugins", "MyPlugin", "a.swift") diff --git a/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift b/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift new file mode 100644 index 000000000..3e1bbf702 --- /dev/null +++ b/Tests/SourceKitLSPTests/BackgroundIndexingTests.swift @@ -0,0 +1,376 @@ +//===----------------------------------------------------------------------===// +// +// 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 LSPTestSupport +import LanguageServerProtocol +import SKTestSupport +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( + files: [ + "MyFile.swift": """ + func 1️⃣foo() {} + func 2️⃣bar() { + 3️⃣foo() + } + """ + ], + serverOptions: backgroundIndexingOptions + ) + + let (uri, positions) = try project.openDocument("MyFile.swift") + let prepare = try await project.testClient.send( + CallHierarchyPrepareRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + let initialItem = try XCTUnwrap(prepare?.only) + let calls = try await project.testClient.send(CallHierarchyIncomingCallsRequest(item: initialItem)) + XCTAssertEqual( + calls, + [ + CallHierarchyIncomingCall( + from: CallHierarchyItem( + name: "bar()", + kind: .function, + tags: nil, + uri: uri, + range: Range(positions["2️⃣"]), + selectionRange: Range(positions["2️⃣"]), + data: .dictionary([ + "usr": .string("s:9MyLibrary3baryyF"), + "uri": .string(uri.stringValue), + ]) + ), + fromRanges: [Range(positions["3️⃣"])] + ) + ] + ) + } + + func testBackgroundIndexingOfMultiFileModule() async throws { + let project = try await SwiftPMTestProject( + files: [ + "MyFile.swift": """ + func 1️⃣foo() {} + """, + "MyOtherFile.swift": """ + func 2️⃣bar() { + 3️⃣foo() + } + """, + ], + serverOptions: backgroundIndexingOptions + ) + + let (uri, positions) = try project.openDocument("MyFile.swift") + let prepare = try await project.testClient.send( + CallHierarchyPrepareRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + let initialItem = try XCTUnwrap(prepare?.only) + let calls = try await project.testClient.send(CallHierarchyIncomingCallsRequest(item: initialItem)) + XCTAssertEqual( + calls, + [ + CallHierarchyIncomingCall( + from: CallHierarchyItem( + name: "bar()", + kind: .function, + tags: nil, + uri: try project.uri(for: "MyOtherFile.swift"), + range: Range(try project.position(of: "2️⃣", in: "MyOtherFile.swift")), + selectionRange: Range(try project.position(of: "2️⃣", in: "MyOtherFile.swift")), + data: .dictionary([ + "usr": .string("s:9MyLibrary3baryyF"), + "uri": .string(try project.uri(for: "MyOtherFile.swift").stringValue), + ]) + ), + fromRanges: [Range(try project.position(of: "3️⃣", in: "MyOtherFile.swift"))] + ) + ] + ) + } + + func testBackgroundIndexingOfMultiModuleProject() async throws { + try await SkipUnless.swiftpmStoresModulesInSubdirectory() + let project = try await SwiftPMTestProject( + files: [ + "LibA/MyFile.swift": """ + public func 1️⃣foo() {} + """, + "LibB/MyOtherFile.swift": """ + import LibA + func 2️⃣bar() { + 3️⃣foo() + } + """, + ], + manifest: """ + // swift-tools-version: 5.7 + + import PackageDescription + + let package = Package( + name: "MyLibrary", + targets: [ + .target(name: "LibA"), + .target(name: "LibB", dependencies: ["LibA"]), + ] + ) + """, + serverOptions: backgroundIndexingOptions + ) + + let (uri, positions) = try project.openDocument("MyFile.swift") + let prepare = try await project.testClient.send( + CallHierarchyPrepareRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + let initialItem = try XCTUnwrap(prepare?.only) + let calls = try await project.testClient.send(CallHierarchyIncomingCallsRequest(item: initialItem)) + XCTAssertEqual( + calls, + [ + CallHierarchyIncomingCall( + from: CallHierarchyItem( + name: "bar()", + kind: .function, + tags: nil, + uri: try project.uri(for: "MyOtherFile.swift"), + range: Range(try project.position(of: "2️⃣", in: "MyOtherFile.swift")), + selectionRange: Range(try project.position(of: "2️⃣", in: "MyOtherFile.swift")), + data: .dictionary([ + "usr": .string("s:4LibB3baryyF"), + "uri": .string(try project.uri(for: "MyOtherFile.swift").stringValue), + ]) + ), + fromRanges: [Range(try project.position(of: "3️⃣", in: "MyOtherFile.swift"))] + ) + ] + ) + } + + func testBackgroundIndexingHappensWithLowPriority() async throws { + var serverOptions = backgroundIndexingOptions + serverOptions.indexTaskDidFinish = { + XCTAssert( + Task.currentPriority == .low, + "An index task ran with priority \(Task.currentPriority)" + ) + } + let project = try await SwiftPMTestProject( + files: [ + "LibA/MyFile.swift": """ + public func 1️⃣foo() {} + """, + "LibB/MyOtherFile.swift": """ + import LibA + func 2️⃣bar() { + 3️⃣foo() + } + """, + ], + manifest: """ + // swift-tools-version: 5.7 + + import PackageDescription + + let package = Package( + name: "MyLibrary", + targets: [ + .target(name: "LibA"), + .target(name: "LibB", dependencies: ["LibA"]), + ] + ) + """, + serverOptions: serverOptions, + pollIndex: false + ) + + // Wait for indexing to finish without elevating the priority + let semaphore = WrappedSemaphore() + Task(priority: .low) { + await assertNoThrow { + try await project.testClient.send(PollIndexRequest()) + } + semaphore.signal() + } + semaphore.wait() + } + + func testBackgroundIndexingOfPackageDependency() async throws { + try await SkipUnless.swiftpmStoresModulesInSubdirectory() + let dependencyContents = """ + public func 1️⃣doSomething() {} + """ + + let dependencyProject = try await SwiftPMDependencyProject(files: [ + "Sources/MyDependency/MyDependency.swift": dependencyContents + ]) + defer { dependencyProject.keepAlive() } + + let project = try await SwiftPMTestProject( + files: [ + "Test.swift": """ + import MyDependency + + func 2️⃣test() { + 3️⃣doSomething() + } + """ + ], + manifest: """ + // swift-tools-version: 5.7 + import PackageDescription + let package = Package( + name: "MyLibrary", + dependencies: [.package(url: "\(dependencyProject.packageDirectory)", from: "1.0.0")], + targets: [ + .target( + name: "MyLibrary", + dependencies: [.product(name: "MyDependency", package: "MyDependency")] + ) + ] + ) + """, + serverOptions: backgroundIndexingOptions + ) + + let dependencyUrl = try XCTUnwrap( + FileManager.default.findFiles(named: "MyDependency.swift", in: project.scratchDirectory).only + ) + let dependencyUri = DocumentURI(dependencyUrl) + let testFileUri = try project.uri(for: "Test.swift") + let positions = project.testClient.openDocument(dependencyContents, uri: dependencyUri) + let prepare = try await project.testClient.send( + CallHierarchyPrepareRequest(textDocument: TextDocumentIdentifier(dependencyUri), position: positions["1️⃣"]) + ) + + let calls = try await project.testClient.send( + CallHierarchyIncomingCallsRequest(item: try XCTUnwrap(prepare?.only)) + ) + + XCTAssertEqual( + calls, + [ + CallHierarchyIncomingCall( + from: CallHierarchyItem( + name: "test()", + kind: .function, + tags: nil, + uri: testFileUri, + range: try project.range(from: "2️⃣", to: "2️⃣", in: "Test.swift"), + selectionRange: try project.range(from: "2️⃣", to: "2️⃣", in: "Test.swift"), + data: .dictionary([ + "usr": .string("s:9MyLibrary4testyyF"), + "uri": .string(testFileUri.stringValue), + ]) + ), + fromRanges: [try project.range(from: "3️⃣", to: "3️⃣", in: "Test.swift")] + ) + ] + ) + } + + func testIndexCFile() async throws { + let project = try await SwiftPMTestProject( + files: [ + "MyLibrary/include/dummy.h": "", + "MyFile.c": """ + void 1️⃣someFunc() {} + + void 2️⃣test() { + 3️⃣someFunc(); + } + """, + ], + serverOptions: backgroundIndexingOptions + ) + + let (uri, positions) = try project.openDocument("MyFile.c") + let prepare = try await project.testClient.send( + CallHierarchyPrepareRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"]) + ) + let calls = try await project.testClient.send( + CallHierarchyIncomingCallsRequest(item: try XCTUnwrap(prepare?.only)) + ) + XCTAssertEqual( + calls, + [ + CallHierarchyIncomingCall( + from: CallHierarchyItem( + name: "test", + kind: .function, + tags: nil, + uri: uri, + range: Range(positions["2️⃣"]), + selectionRange: Range(positions["2️⃣"]), + data: .dictionary([ + "usr": .string("c:@F@test"), + "uri": .string(uri.stringValue), + ]) + ), + fromRanges: [Range(positions["3️⃣"])] + ) + ] + ) + } + + func testBackgroundIndexingStatusWorkDoneProgress() async throws { + let workDoneProgressCreated = self.expectation(description: "Work done progress created") + let project = try await SwiftPMTestProject( + files: [ + "MyFile.swift": """ + func foo() {} + func bar() { + foo() + } + """ + ], + capabilities: ClientCapabilities(window: WindowClientCapabilities(workDoneProgress: true)), + serverOptions: backgroundIndexingOptions, + preInitialization: { testClient in + testClient.handleNextRequest { (request: CreateWorkDoneProgressRequest) in + workDoneProgressCreated.fulfill() + return VoidResponse() + } + } + ) + try await fulfillmentOfOrThrow([workDoneProgressCreated]) + let workBeginProgress = try await project.testClient.nextNotification(ofType: WorkDoneProgress.self) + guard case .begin = workBeginProgress.value else { + XCTFail("Expected begin work done progress") + return + } + var didGetEndWorkDoneProgress = false + for _ in 0..<3 { + let workEndProgress = try await project.testClient.nextNotification(ofType: WorkDoneProgress.self) + switch workEndProgress.value { + case .begin: + XCTFail("Unexpected begin work done progress") + case .report: + // Allow up to 2 work done progress reports. + continue + case .end: + didGetEndWorkDoneProgress = true + } + break + } + XCTAssert(didGetEndWorkDoneProgress, "Expected end work done progress") + + withExtendedLifetime(project) {} + } +} diff --git a/Tests/SourceKitLSPTests/BuildSystemTests.swift b/Tests/SourceKitLSPTests/BuildSystemTests.swift index f31103cea..c3eb343c8 100644 --- a/Tests/SourceKitLSPTests/BuildSystemTests.swift +++ b/Tests/SourceKitLSPTests/BuildSystemTests.swift @@ -39,7 +39,11 @@ final class TestBuildSystem: BuildSystem { /// Files currently being watched by our delegate. var watchedFiles: Set = [] - func buildSettings(for document: DocumentURI, language: Language) async throws -> FileBuildSettings? { + func buildSettings( + for document: DocumentURI, + in buildTarget: ConfiguredTarget, + language: Language + ) async throws -> FileBuildSettings? { return buildSettingsByFile[document] } @@ -47,6 +51,20 @@ final class TestBuildSystem: BuildSystem { return nil } + public func configuredTargets(for document: DocumentURI) async -> [ConfiguredTarget] { + return [ConfiguredTarget(targetID: "dummy", runDestinationID: "dummy")] + } + + public func prepare(targets: [ConfiguredTarget]) async throws { + throw PrepareNotSupportedError() + } + + public func generateBuildGraph() {} + + public func topologicalSort(of targets: [ConfiguredTarget]) -> [ConfiguredTarget]? { + return nil + } + func registerForChangeNotifications(for uri: DocumentURI) async { watchedFiles.insert(uri) } @@ -108,7 +126,10 @@ final class BuildSystemTests: XCTestCase { options: SourceKitLSPServer.Options.testDefault, underlyingBuildSystem: buildSystem, index: nil, - indexDelegate: nil + indexDelegate: nil, + indexTaskScheduler: .forTesting, + indexTasksWereScheduled: { _ in }, + indexTaskDidFinish: {} ) await server.setWorkspaces([(workspace: workspace, isImplicit: false)]) @@ -169,7 +190,9 @@ final class BuildSystemTests: XCTestCase { func testSwiftDocumentUpdatedBuildSettings() async throws { let doc = DocumentURI.for(.swift) - let args = FallbackBuildSystem(buildSetup: .default).buildSettings(for: doc, language: .swift)!.compilerArguments + let args = await FallbackBuildSystem(buildSetup: .default) + .buildSettings(for: doc, language: .swift)! + .compilerArguments buildSystem.buildSettingsByFile[doc] = FileBuildSettings(compilerArguments: args) @@ -238,7 +261,7 @@ final class BuildSystemTests: XCTestCase { let doc = DocumentURI.for(.swift) // Primary settings must be different than the fallback settings. - var primarySettings = FallbackBuildSystem(buildSetup: .default).buildSettings(for: doc, language: .swift)! + var primarySettings = await FallbackBuildSystem(buildSetup: .default).buildSettings(for: doc, language: .swift)! primarySettings.isFallback = false primarySettings.compilerArguments.append("-DPRIMARY") diff --git a/Tests/SourceKitLSPTests/CodeActionTests.swift b/Tests/SourceKitLSPTests/CodeActionTests.swift index 27752e902..8a340a21e 100644 --- a/Tests/SourceKitLSPTests/CodeActionTests.swift +++ b/Tests/SourceKitLSPTests/CodeActionTests.swift @@ -16,23 +16,22 @@ import SKTestSupport import SourceKitLSP import XCTest -final class CodeActionTests: XCTestCase { - - typealias CodeActionCapabilities = TextDocumentClientCapabilities.CodeAction - typealias CodeActionLiteralSupport = CodeActionCapabilities.CodeActionLiteralSupport - typealias CodeActionKindCapabilities = CodeActionLiteralSupport.CodeActionKind - - private func clientCapabilitiesWithCodeActionSupport() -> ClientCapabilities { - var documentCapabilities = TextDocumentClientCapabilities() - var codeActionCapabilities = CodeActionCapabilities() - let codeActionKinds = CodeActionKindCapabilities(valueSet: [.refactor, .quickFix]) - let codeActionLiteralSupport = CodeActionLiteralSupport(codeActionKind: codeActionKinds) - codeActionCapabilities.codeActionLiteralSupport = codeActionLiteralSupport - documentCapabilities.codeAction = codeActionCapabilities - documentCapabilities.completion = .init(completionItem: .init(snippetSupport: true)) - return ClientCapabilities(workspace: nil, textDocument: documentCapabilities) - } +private typealias CodeActionCapabilities = TextDocumentClientCapabilities.CodeAction +private typealias CodeActionLiteralSupport = CodeActionCapabilities.CodeActionLiteralSupport +private typealias CodeActionKindCapabilities = CodeActionLiteralSupport.CodeActionKind + +private var clientCapabilitiesWithCodeActionSupport: ClientCapabilities = { + var documentCapabilities = TextDocumentClientCapabilities() + var codeActionCapabilities = CodeActionCapabilities() + let codeActionKinds = CodeActionKindCapabilities(valueSet: [.refactor, .quickFix]) + let codeActionLiteralSupport = CodeActionLiteralSupport(codeActionKind: codeActionKinds) + codeActionCapabilities.codeActionLiteralSupport = codeActionLiteralSupport + documentCapabilities.codeAction = codeActionCapabilities + documentCapabilities.completion = .init(completionItem: .init(snippetSupport: true)) + return ClientCapabilities(workspace: nil, textDocument: documentCapabilities) +}() +final class CodeActionTests: XCTestCase { func testCodeActionResponseLegacySupport() throws { let command = Command(title: "Title", command: "Command", arguments: [1, "text", 2.2, nil]) let codeAction = CodeAction(title: "1") @@ -191,7 +190,7 @@ final class CodeActionTests: XCTestCase { } func testEmptyCodeActionResult() async throws { - let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport()) + let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport) let uri = DocumentURI.for(.swift) let positions = testClient.openDocument( """ @@ -214,7 +213,7 @@ final class CodeActionTests: XCTestCase { } func testSemanticRefactorLocalRenameResult() async throws { - let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport()) + let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport) let uri = DocumentURI.for(.swift) let positions = testClient.openDocument( """ @@ -227,16 +226,20 @@ final class CodeActionTests: XCTestCase { ) let request = CodeActionRequest( - range: positions["1️⃣"]..(_ input: T1) {}" + ) + ] + ] + ), + command: nil + ) + ] + } + } + + func testOpaqueParameterToGenericIsNotShownFromTheBody() async throws { + try await assertCodeActions( + ##""" + func someFunction(_ input: some Value) 1️⃣{ + 2️⃣print("x") + }3️⃣ + """##, + exhaustive: false + ) { uri, positions in + [] + } + } + + func testConvertJSONToCodable() async throws { + try await assertCodeActions( + ##""" + 1️⃣{ + 2️⃣"id": 3️⃣1, + "values": 4️⃣["foo", "bar"] + }5️⃣ + + """##, + ranges: [("1️⃣", "5️⃣")], + exhaustive: false + ) { uri, positions in + [ + CodeAction( + title: "Create Codable structs from JSON", + kind: .refactorInline, + diagnostics: nil, + edit: WorkspaceEdit( + changes: [ + uri: [ + TextEdit( + range: positions["1️⃣"].. [CodeAction], + file: StaticString = #filePath, + line: UInt = #line + ) async throws { + let testClient = try await TestSourceKitLSPClient(capabilities: clientCapabilitiesWithCodeActionSupport) + let uri = DocumentURI.for(.swift) + let positions = testClient.openDocument(markedText, uri: uri) + + var ranges = ranges + if let markers { + ranges += markers.map { ($0, $0) } + } else { + ranges += extractMarkers(markedText).markers.map(\.key).map { ($0, $0) } + } + + for (startMarker, endMarker) in ranges { + let result = try await testClient.send( + CodeActionRequest( + range: positions[startMarker]..