diff --git a/Sources/BuildServerIntegration/BuildServerManager.swift b/Sources/BuildServerIntegration/BuildServerManager.swift index 9e9a7d407..6db2ea17b 100644 --- a/Sources/BuildServerIntegration/BuildServerManager.swift +++ b/Sources/BuildServerIntegration/BuildServerManager.swift @@ -1557,7 +1557,7 @@ package actor BuildServerManager: QueueBasedMessageHandler { } } - package func testFiles() async throws -> [DocumentURI] { + package func projectTestFiles() async throws -> [DocumentURI] { return try await sourceFiles(includeNonBuildableFiles: false).compactMap { (uri, info) -> DocumentURI? in guard info.isPartOfRootProject, info.mayContainTests else { return nil @@ -1566,6 +1566,26 @@ package actor BuildServerManager: QueueBasedMessageHandler { } } + /// Differs from `sourceFiles(in targets: Set)` making sure it only includes source files that + /// are part of the root project for cases where we don't care about dependency source files + /// + /// - Parameter include: If `nil` will include all targets, otherwise only return files who are part of at least one matching target + /// - Returns: List of filtered source files in root project + package func projectSourceFiles( + in include: Set? = nil + ) async throws -> [DocumentURI: SourceFileInfo] { + return try await sourceFiles(includeNonBuildableFiles: false).filter { (uri, info) -> Bool in + var includeTarget = true + if let include { + includeTarget = info.targets.contains(anyIn: include) + } + guard info.isPartOfRootProject, includeTarget else { + return false + } + return true + } + } + private func watchedFilesReferencing(mainFiles: Set) -> Set { return Set( watchedFiles.compactMap { (watchedFile, mainFileAndLanguage) in diff --git a/Sources/ClangLanguageService/ClangLanguageService.swift b/Sources/ClangLanguageService/ClangLanguageService.swift index 1997fc9df..062345098 100644 --- a/Sources/ClangLanguageService/ClangLanguageService.swift +++ b/Sources/ClangLanguageService/ClangLanguageService.swift @@ -644,7 +644,14 @@ extension ClangLanguageService { return nil } - package static func syntacticTestItems(in uri: DocumentURI) async -> [AnnotatedTestItem] { + package func syntacticTestItems(for snapshot: DocumentSnapshot) async -> [AnnotatedTestItem] { + return [] + } + + package func syntacticPlaygrounds( + for snapshot: DocumentSnapshot, + in workspace: Workspace + ) async -> [TextDocumentPlayground] { return [] } diff --git a/Sources/DocumentationLanguageService/DocumentationLanguageService.swift b/Sources/DocumentationLanguageService/DocumentationLanguageService.swift index af87658d3..4386e6bdb 100644 --- a/Sources/DocumentationLanguageService/DocumentationLanguageService.swift +++ b/Sources/DocumentationLanguageService/DocumentationLanguageService.swift @@ -88,7 +88,14 @@ package actor DocumentationLanguageService: LanguageService, Sendable { // The DocumentationLanguageService does not do anything with document events } - package static func syntacticTestItems(in uri: DocumentURI) async -> [AnnotatedTestItem] { + package func syntacticTestItems(for snapshot: DocumentSnapshot) async -> [AnnotatedTestItem] { + return [] + } + + package func syntacticPlaygrounds( + for snapshot: DocumentSnapshot, + in workspace: Workspace + ) async -> [TextDocumentPlayground] { return [] } diff --git a/Sources/SKTestSupport/SwiftPMTestProject.swift b/Sources/SKTestSupport/SwiftPMTestProject.swift index 3d9135100..a77ab26f6 100644 --- a/Sources/SKTestSupport/SwiftPMTestProject.swift +++ b/Sources/SKTestSupport/SwiftPMTestProject.swift @@ -266,7 +266,7 @@ package class SwiftPMTestProject: MultiFileTestProject { } logger.debug( """ - 'swift build' output: + 'swift build' output: \(output) """ ) @@ -288,7 +288,7 @@ package class SwiftPMTestProject: MultiFileTestProject { } logger.debug( """ - 'swift package resolve' output: + 'swift package resolve' output: \(output) """ ) diff --git a/Sources/SKTestSupport/TestSourceKitLSPClient.swift b/Sources/SKTestSupport/TestSourceKitLSPClient.swift index 64134d3e3..6bb646569 100644 --- a/Sources/SKTestSupport/TestSourceKitLSPClient.swift +++ b/Sources/SKTestSupport/TestSourceKitLSPClient.swift @@ -97,6 +97,11 @@ package final class TestSourceKitLSPClient: MessageHandler, Sendable { /// The connection via which the server sends requests and notifications to us. private let serverToClientConnection: LocalConnection + /// The response of the initialize request. + /// + /// Must only be set from the initializer and not be accessed before the initializer has finished. + package private(set) nonisolated(unsafe) var initializeResult: InitializeResult? + /// Stream of the notifications that the server has sent to the client. private let notifications: PendingNotifications @@ -200,8 +205,8 @@ package final class TestSourceKitLSPClient: MessageHandler, Sendable { preInitialization?(self) if initialize { let capabilities = capabilities - try await withTimeout(defaultTimeoutDuration) { - _ = try await self.send( + self.initializeResult = try await withTimeout(defaultTimeoutDuration) { + try await self.send( InitializeRequest( processId: nil, rootPath: nil, diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 74d04c9ab..f4c25ecc3 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -14,6 +14,7 @@ add_library(SourceKitLSP STATIC MacroExpansionReferenceDocumentURLData.swift MessageHandlingDependencyTracker.swift OnDiskDocumentManager.swift + PlaygroundDiscovery.swift ReferenceDocumentURL.swift Rename.swift SemanticTokensLegend+SourceKitLSPLegend.swift @@ -22,7 +23,7 @@ add_library(SourceKitLSP STATIC SourceKitLSPCommandMetadata.swift SourceKitLSPServer.swift SymbolLocation+DocumentURI.swift - SyntacticTestIndex.swift + SyntacticIndex.swift TestDiscovery.swift TextEdit+IsNoop.swift Workspace.swift diff --git a/Sources/SourceKitLSP/LanguageService.swift b/Sources/SourceKitLSP/LanguageService.swift index b251ce15b..2294fdec6 100644 --- a/Sources/SourceKitLSP/LanguageService.swift +++ b/Sources/SourceKitLSP/LanguageService.swift @@ -318,7 +318,15 @@ package protocol LanguageService: AnyObject, Sendable { /// Does not write the results to the index. /// /// The order of the returned tests is not defined. The results should be sorted before being returned to the editor. - static func syntacticTestItems(in uri: DocumentURI) async -> [AnnotatedTestItem] + func syntacticTestItems(for snapshot: DocumentSnapshot) async -> [AnnotatedTestItem] + + /// Returns the syntactically scanned playgrounds declared within the workspace. + /// + /// The order of the returned playgrounds is not defined. The results should be sorted before being returned to the editor. + func syntacticPlaygrounds( + for snapshot: DocumentSnapshot, + in workspace: Workspace + ) async -> [TextDocumentPlayground] /// A position that is canonical for all positions within a declaration. For example, if we have the following /// declaration, then all `|` markers should return the same canonical position. diff --git a/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift b/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift index 7adca4809..d6cdbc3a8 100644 --- a/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift +++ b/Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift @@ -248,6 +248,8 @@ package enum MessageHandlingDependencyTracker: QueueBasedMessageHandlerDependenc self = .freestanding case is WorkspaceTestsRequest: self = .workspaceRequest + case is WorkspacePlaygroundsRequest: + self = .workspaceRequest case let request as any TextDocumentRequest: self = .documentRequest(request.textDocument.uri) default: diff --git a/Sources/SourceKitLSP/PlaygroundDiscovery.swift b/Sources/SourceKitLSP/PlaygroundDiscovery.swift new file mode 100644 index 000000000..a50052425 --- /dev/null +++ b/Sources/SourceKitLSP/PlaygroundDiscovery.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2025 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 BuildServerIntegration +@_spi(SourceKitLSP) import LanguageServerProtocol +@_spi(SourceKitLSP) import SKLogging +import SemanticIndex +import SwiftExtensions +@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions + +extension SourceKitLSPServer { + + /// Return all the playgrounds in the given workspace. + /// + /// The returned list of playgrounds is not sorted. It should be sorted before being returned to the editor. + private func playgrounds(in workspace: Workspace) async -> [Playground] { + // If files have recently been added to the workspace (which is communicated by a `workspace/didChangeWatchedFiles` + // notification, wait these changes to be reflected in the build server so we can include the updated files in the + // playgrounds. + await workspace.buildServerManager.waitForUpToDateBuildGraph() + + let playgroundsFromSyntacticIndex = await workspace.syntacticIndex.playgrounds() + + // We don't need to sort the playgrounds here because they will get sorted by `workspacePlaygrounds` request handler + return playgroundsFromSyntacticIndex + } + + func workspacePlaygrounds(_ req: WorkspacePlaygroundsRequest) async throws -> [Playground] { + return await self.workspaces + .concurrentMap { await self.playgrounds(in: $0) } + .flatMap { $0 } + .sorted { $0.location < $1.location } + } +} diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index e58186f41..f0d410534 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -864,6 +864,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler { await request.reply { try await workspaceSymbols(request.params) } case let request as RequestAndReply: await request.reply { try await workspaceTests(request.params) } + case let request as RequestAndReply: + await request.reply { try await workspacePlaygrounds(request.params) } // IMPORTANT: When adding a new entry to this switch, also add it to the `MessageHandlingDependencyTracker` initializer. default: await request.reply { throw ResponseError.methodNotFound(Request.method) } @@ -1119,6 +1121,9 @@ extension SourceKitLSPServer { GetReferenceDocumentRequest.method: .dictionary(["version": .int(1)]), DidChangeActiveDocumentNotification.method: .dictionary(["version": .int(1)]), ] + if let toolchain = await toolchainRegistry.preferredToolchain(containing: [\.swiftc]), toolchain.swiftPlay != nil { + experimentalCapabilities[WorkspacePlaygroundsRequest.method] = .dictionary(["version": .int(1)]) + } for (key, value) in languageServiceRegistry.languageServices.flatMap({ $0.type.experimentalCapabilities }) { if let existingValue = experimentalCapabilities[key] { logger.error( diff --git a/Sources/SourceKitLSP/SyntacticTestIndex.swift b/Sources/SourceKitLSP/SyntacticIndex.swift similarity index 60% rename from Sources/SourceKitLSP/SyntacticTestIndex.swift rename to Sources/SourceKitLSP/SyntacticIndex.swift index 366afeabd..fbaebf994 100644 --- a/Sources/SourceKitLSP/SyntacticTestIndex.swift +++ b/Sources/SourceKitLSP/SyntacticIndex.swift @@ -10,23 +10,25 @@ // //===----------------------------------------------------------------------===// +@_spi(SourceKitLSP) package import BuildServerIntegration +@_spi(SourceKitLSP) package import BuildServerProtocol import Foundation -@_spi(SourceKitLSP) import LanguageServerProtocol +@_spi(SourceKitLSP) package import LanguageServerProtocol @_spi(SourceKitLSP) import LanguageServerProtocolExtensions @_spi(SourceKitLSP) import SKLogging import SwiftExtensions @_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions -/// Task metadata for `SyntacticTestIndexer.indexingQueue` +/// Task metadata for `SyntacticIndex.indexingQueue` private enum TaskMetadata: DependencyTracker, Equatable { - /// Determine the list of test files from the build server and scan them for tests. Only created when the - /// `SyntacticTestIndex` is created + /// Determine the list of files from the build server and scan them for tests / playgrounds. Only created when the + /// `SyntacticIndex` is created case initialPopulation - /// Index the files in the given set for tests + /// Index the files in the given set for tests / playgrounds case index(Set) - /// Retrieve information about syntactically discovered tests from the index. + /// Retrieve information about syntactically discovered tests / playgrounds from the index. case read /// Reads can be concurrent and files can be indexed concurrently. But we need to wait for all files to finish @@ -38,7 +40,7 @@ private enum TaskMetadata: DependencyTracker, Equatable { return true case (_, .initialPopulation): // Should never happen because the initial population should only be scheduled once before any other operations - // on the test index. But be conservative in case we do get an `initialPopulation` somewhere in between and use it + // on the index. But be conservative in case we do get an `initialPopulation` somewhere in between and use it // as a full blocker on the queue. return true case (.read, .read): @@ -64,25 +66,26 @@ private enum TaskMetadata: DependencyTracker, Equatable { } } -/// Data from a syntactic scan of a source file for tests. -private struct IndexedTests { +/// Data from a syntactic scan of a source file for tests or playgrounds. +private struct IndexedSourceFile { /// The tests within the source file. let tests: [AnnotatedTestItem] + /// The playgrounds within the source file. + let playgrounds: [TextDocumentPlayground] + /// The modification date of the source file when it was scanned. A file won't get re-scanned if its modification date /// is older or the same as this date. let sourceFileModificationDate: Date } -/// An in-memory syntactic index of test items within a workspace. +/// An in-memory syntactic index of test and playground items within a workspace. /// /// The index does not get persisted to disk but instead gets rebuilt every time a workspace is opened (ie. usually when /// sourcekit-lsp is launched). Building it takes only a few seconds, even for large projects. -actor SyntacticTestIndex { - private let languageServiceRegistry: LanguageServiceRegistry - +package actor SyntacticIndex: Sendable { /// The tests discovered by the index. - private var indexedTests: [DocumentURI: IndexedTests] = [:] + private var indexedSources: [DocumentURI: IndexedSourceFile] = [:] /// Files that have been removed using `removeFileForIndex`. /// @@ -98,23 +101,39 @@ actor SyntacticTestIndex { /// indexing tasks to finish. private let indexingQueue = AsyncQueue() - init( - languageServiceRegistry: LanguageServiceRegistry, - determineTestFiles: @Sendable @escaping () async -> [DocumentURI] + /// Fetch the list of source files to scan for a given set of build targets + private let determineFilesToScan: @Sendable (Set?) async -> [DocumentURI: SourceFileInfo] + + /// Syntactically parse tests from the given snapshot + private let syntacticTests: @Sendable (DocumentSnapshot) async -> [AnnotatedTestItem] + + /// Syntactically parse playgrounds from the given snapshot + private let syntacticPlaygrounds: @Sendable (DocumentSnapshot) async -> [TextDocumentPlayground] + + package init( + determineFilesToScan: @Sendable @escaping (Set?) async -> [DocumentURI: SourceFileInfo], + syntacticTests: @Sendable @escaping (DocumentSnapshot) async -> [AnnotatedTestItem], + syntacticPlaygrounds: @Sendable @escaping (DocumentSnapshot) async -> [TextDocumentPlayground] ) { - self.languageServiceRegistry = languageServiceRegistry - indexingQueue.async(priority: .low, metadata: .initialPopulation) { - let testFiles = await determineTestFiles() + self.determineFilesToScan = determineFilesToScan + self.syntacticTests = syntacticTests + self.syntacticPlaygrounds = syntacticPlaygrounds + indexingQueue.async(priority: .low, metadata: .initialPopulation) { + let filesToScan = await self.determineFilesToScan(nil) // Divide the files into multiple batches. This is more efficient than spawning a new task for every file, mostly // because it keeps the number of pending items in `indexingQueue` low and adding a new task to `indexingQueue` is // in O(number of pending tasks), since we need to scan for dependency edges to add, which would make scanning files // be O(number of files). // Over-subscribe the processor count in case one batch finishes more quickly than another. - let batches = testFiles.partition(intoNumberOfBatches: ProcessInfo.processInfo.activeProcessorCount * 4) + let uris = Array(filesToScan.keys) + let batches = uris.partition(intoNumberOfBatches: ProcessInfo.processInfo.activeProcessorCount * 4) await batches.concurrentForEach { filesInBatch in for uri in filesInBatch { - await self.rescanFileAssumingOnQueue(uri) + guard let sourceFileInfo = filesToScan[uri] else { + continue + } + await self.rescanFileAssumingOnQueue(uri, scanForTests: sourceFileInfo.mayContainTests) } } } @@ -123,35 +142,34 @@ actor SyntacticTestIndex { private func removeFilesFromIndex(_ removedFiles: Set) { self.removedFiles.formUnion(removedFiles) for removedFile in removedFiles { - self.indexedTests[removedFile] = nil + self.indexedSources[removedFile] = nil } } - /// Called when the list of files that may contain tests is updated. + /// Called when the list of targets is updated. /// - /// All files that are not in the new list of test files will be removed from the index. - func listOfTestFilesDidChange(_ testFiles: [DocumentURI]) { - let removedFiles = Set(self.indexedTests.keys).subtracting(testFiles) + /// All files that are not in the new list of buildable files will be removed from the index. + package func buildTargetsChanged(_ changedTargets: Set?) async { + let changedFiles = await determineFilesToScan(changedTargets) + let removedFiles = Set(self.indexedSources.keys).subtracting(changedFiles.keys) removeFilesFromIndex(removedFiles) - rescanFiles(testFiles) + rescanFiles(changedFiles) } - func filesDidChange(_ events: [FileEvent]) { + package func filesDidChange(_ events: [FileEvent: SourceFileInfo]) { var removedFiles: Set = [] - var filesToRescan: [DocumentURI] = [] - for fileEvent in events { + var filesToRescan: [DocumentURI: SourceFileInfo] = [:] + for (fileEvent, sourceFileInfo) in events { switch fileEvent.type { case .created: - // We don't know if this is a potential test file. It would need to be added to the index via - // `listOfTestFilesDidChange` - break + filesToRescan[fileEvent.uri] = sourceFileInfo case .changed: - filesToRescan.append(fileEvent.uri) + filesToRescan[fileEvent.uri] = sourceFileInfo case .deleted: removedFiles.insert(fileEvent.uri) default: - logger.error("Ignoring unknown FileEvent type \(fileEvent.type.rawValue) in SyntacticTestIndex") + logger.error("Ignoring unknown FileEvent type \(fileEvent.type.rawValue) in SyntacticIndex") } } removeFilesFromIndex(removedFiles) @@ -159,10 +177,10 @@ actor SyntacticTestIndex { } /// Called when a list of files was updated. Re-scans those files - private func rescanFiles(_ uris: [DocumentURI]) { + private func rescanFiles(_ filesToScan: [DocumentURI: SourceFileInfo]) { // If we scan a file again, it might have been added after being removed before. Remove it from the list of removed // files. - removedFiles.subtract(uris) + removedFiles.subtract(filesToScan.keys) // If we already know that the file has an up-to-date index, avoid re-scheduling it to be indexed. This ensures // that we don't bloat `indexingQueue` if the build server is sending us repeated `buildTarget/didChange` @@ -170,9 +188,9 @@ actor SyntacticTestIndex { // This check does not need to be perfect and there might be an in-progress index operation that is about to index // the file. In that case we still schedule anothe rescan of that file and notice in `rescanFilesAssumingOnQueue` // that the index is already up-to-date, which makes the rescan a no-op. - let uris = uris.filter { uri in + let filesToScan = filesToScan.filter { (uri, _) in if let url = uri.fileURL, - let indexModificationDate = self.indexedTests[uri]?.sourceFileModificationDate, + let indexModificationDate = self.indexedSources[uri]?.sourceFileModificationDate, let fileModificationDate = try? FileManager.default.attributesOfItem(atPath: url.filePath)[.modificationDate] as? Date, indexModificationDate >= fileModificationDate @@ -182,12 +200,12 @@ actor SyntacticTestIndex { return true } - guard !uris.isEmpty else { + guard !filesToScan.isEmpty else { return } logger.info( - "Syntactically scanning \(uris.count) files for tests: \(uris.map(\.arbitrarySchemeURL.lastPathComponent).joined(separator: ", "))" + "Syntactically scanning \(filesToScan.count) files: \(filesToScan.map(\.key).map(\.arbitrarySchemeURL.lastPathComponent).joined(separator: ", "))" ) // Divide the files into multiple batches. This is more efficient than spawning a new task for every file, mostly @@ -195,11 +213,15 @@ actor SyntacticTestIndex { // in O(number of pending tasks), since we need to scan for dependency edges to add, which would make scanning files // be O(number of files). // Over-subscribe the processor count in case one batch finishes more quickly than another. + let uris = Array(filesToScan.keys) let batches = uris.partition(intoNumberOfBatches: ProcessInfo.processInfo.activeProcessorCount * 4) for batch in batches { self.indexingQueue.async(priority: .low, metadata: .index(Set(batch))) { for uri in batch { - await self.rescanFileAssumingOnQueue(uri) + guard let sourceFileInfo = filesToScan[uri] else { + continue + } + await self.rescanFileAssumingOnQueue(uri, scanForTests: sourceFileInfo.mayContainTests) } } } @@ -208,9 +230,13 @@ actor SyntacticTestIndex { /// Re-scans a single file. /// /// - Important: This method must be called in a task that is executing on `indexingQueue`. - private func rescanFileAssumingOnQueue(_ uri: DocumentURI) async { + private func rescanFileAssumingOnQueue(_ uri: DocumentURI, scanForTests: Bool) async { + guard let language = Language(inferredFromFileExtension: uri) else { + return + } + guard let url = uri.fileURL else { - logger.log("Not indexing \(uri.forLogging) for tests because it is not a file URL") + logger.log("Not indexing \(uri.forLogging) because it is not a file URL") return } if Task.isCancelled { @@ -221,17 +247,17 @@ actor SyntacticTestIndex { } guard FileManager.default.fileExists(at: url) else { // File no longer exists. Probably deleted since we scheduled it for indexing. Nothing to worry about. - logger.info("Not indexing \(uri.forLogging) for tests because it does not exist") + logger.info("Not indexing \(uri.forLogging) because it does not exist") return } guard let fileModificationDate = try? FileManager.default.attributesOfItem(atPath: url.filePath)[.modificationDate] as? Date else { - logger.fault("Not indexing \(uri.forLogging) for tests because the modification date could not be determined") + logger.fault("Not indexing \(uri.forLogging) because the modification date could not be determined") return } - if let indexModificationDate = self.indexedTests[uri]?.sourceFileModificationDate, + if let indexModificationDate = self.indexedSources[uri]?.sourceFileModificationDate, indexModificationDate >= fileModificationDate { // Index already up to date. @@ -240,28 +266,53 @@ actor SyntacticTestIndex { if Task.isCancelled { return } - guard let language = Language(inferredFromFileExtension: uri) else { - logger.log("Not indexing \(uri.forLogging) because the language service could not be inferred") - return + + let snapshot: DocumentSnapshot? = orLog("Getting document snapshot for syntactic scanning") { + try DocumentSnapshot(withContentsFromDisk: url, language: language) } - let testItems = await languageServiceRegistry.languageServices(for: language).asyncFlatMap { - await $0.syntacticTestItems(in: uri) + guard let snapshot else { + return } + async let asyncTestItems = scanForTests ? syntacticTests(snapshot) : [] + async let asyncPlaygrounds = syntacticPlaygrounds(snapshot) + + let testItems = await asyncTestItems + let playgrounds = await asyncPlaygrounds + guard !removedFiles.contains(uri) else { // Check whether the file got removed while we were scanning it for tests. If so, don't add it back to - // `indexedTests`. + // `indexedSources`. return } - self.indexedTests[uri] = IndexedTests(tests: testItems, sourceFileModificationDate: fileModificationDate) + + self.indexedSources[uri] = IndexedSourceFile( + tests: testItems, + playgrounds: playgrounds, + sourceFileModificationDate: fileModificationDate + ) } /// 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 -> [AnnotatedTestItem] { + nonisolated package func tests() async -> [AnnotatedTestItem] { + let readTask = indexingQueue.async(metadata: .read) { + return await self.indexedSources.values.flatMap { $0.tests } + } + return await readTask.value + } + + /// Gets all the playgrounds in the syntactic index. + /// + /// This waits for any pending document updates to be indexed before returning a result. + nonisolated package func playgrounds() async -> [Playground] { let readTask = indexingQueue.async(metadata: .read) { - return await self.indexedTests.values.flatMap { $0.tests } + return await self.indexedSources.flatMap { (uri, indexedFile) in + indexedFile.playgrounds.map { + Playground(id: $0.id, label: $0.label, location: Location(uri: uri, range: $0.range)) + } + } } return await readTask.value } diff --git a/Sources/SourceKitLSP/TestDiscovery.swift b/Sources/SourceKitLSP/TestDiscovery.swift index e0a2dd1c2..061fb45e5 100644 --- a/Sources/SourceKitLSP/TestDiscovery.swift +++ b/Sources/SourceKitLSP/TestDiscovery.swift @@ -245,7 +245,7 @@ extension SourceKitLSPServer { let semanticTestSymbolOccurrences = index?.unitTests().filter { return $0.canBeTestDefinition } ?? [] - let testsFromSyntacticIndex = await workspace.syntacticTestIndex.tests() + let testsFromSyntacticIndex = await workspace.syntacticIndex.tests() let testsFromSemanticIndex = testItems( for: semanticTestSymbolOccurrences, index: index, @@ -292,7 +292,7 @@ extension SourceKitLSPServer { return nil } - // We don't need to sort the tests here because they will get + // We don't need to sort the tests here because they will get sorted by `workspaceTests` request handler return testsFromSemanticIndex + syntacticTestsToInclude + testsFromFilesWithInMemoryState } diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index 760a6356d..3ef52b0ea 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -184,8 +184,15 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { } } - /// The index that syntactically scans the workspace for tests. - let syntacticTestIndex: SyntacticTestIndex + /// The index that syntactically scans the workspace for Swift symbols. + /// + /// Force-unwrapped optional because initializing it requires access to `self`. + private(set) nonisolated(unsafe) var syntacticIndex: SyntacticIndex! { + didSet { + precondition(oldValue == nil) + precondition(syntacticIndex != nil) + } + } /// Language service for an open document, if available. private let languageServices: ThreadSafeBox<[DocumentURI: [LanguageService]]> = ThreadSafeBox(initialValue: [:]) @@ -259,13 +266,28 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { return nil } } - // Trigger an initial population of `syntacticTestIndex`. - self.syntacticTestIndex = SyntacticTestIndex( - languageServiceRegistry: sourceKitLSPServer.languageServiceRegistry, - determineTestFiles: { - await orLog("Getting list of test files for initial syntactic index population") { - try await buildServerManager.testFiles() - } ?? [] + // Trigger an initial population of `syntacticIndex`. + self.syntacticIndex = SyntacticIndex( + determineFilesToScan: { targets in + await orLog("Getting list of files for syntactic index population") { + try await buildServerManager.projectSourceFiles(in: targets) + } ?? [:] + }, + syntacticTests: { [weak self] (snapshot) in + guard let self else { + return [] + } + return await sourceKitLSPServer.languageServices(for: snapshot.uri, snapshot.language, in: self).asyncFlatMap { + await $0.syntacticTestItems(for: snapshot) + } + }, + syntacticPlaygrounds: { [weak self] (snapshot) in + guard let self else { + return [] + } + return await sourceKitLSPServer.languageServices(for: snapshot.uri, snapshot.language, in: self).asyncFlatMap { + await $0.syntacticPlaygrounds(for: snapshot, in: self) + } } ) } @@ -406,7 +428,16 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { // Notify all clients about the reported and inferred edits. await buildServerManager.filesDidChange(events) - async let updateSyntacticIndex: Void = await syntacticTestIndex.filesDidChange(events) + let eventsWithSourceFileInfo: [FileEvent: SourceFileInfo] = await Dictionary( + uniqueKeysWithValues: events.asyncCompactMap({ + guard let sourceFileInfo = await buildServerManager.sourceFileInfo(for: $0.uri) else { + return nil + } + return ($0, sourceFileInfo) + }) + ) + + async let updateSyntacticIndex: Void = await syntacticIndex.filesDidChange(eventsWithSourceFileInfo) async let updateSemanticIndex: Void? = await semanticIndexManager?.filesDidChange(events) _ = await (updateSyntacticIndex, updateSemanticIndex) } @@ -470,9 +501,8 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { package func buildTargetsChanged(_ changedTargets: Set?) async { await sourceKitLSPServer?.fileHandlingCapabilityChanged() await semanticIndexManager?.buildTargetsChanged(changedTargets) - await orLog("Scheduling syntactic test re-indexing") { - let testFiles = try await buildServerManager.testFiles() - await syntacticTestIndex.listOfTestFilesDidChange(testFiles) + await orLog("Scheduling syntactic file re-indexing") { + await syntacticIndex.buildTargetsChanged(changedTargets) } await scheduleUpdateOfUnitOutputPathsInIndexIfNecessary() diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index f6a1b3fb1..65408ac7e 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -25,6 +25,7 @@ add_library(SwiftLanguageService STATIC InlayHints.swift MacroExpansion.swift OpenInterface.swift + PlaygroundDiscovery.swift SwiftPlaygroundsScanner.swift RefactoringEdit.swift RefactoringResponse.swift diff --git a/Sources/SwiftLanguageService/PlaygroundDiscovery.swift b/Sources/SwiftLanguageService/PlaygroundDiscovery.swift new file mode 100644 index 000000000..8329b3a14 --- /dev/null +++ b/Sources/SwiftLanguageService/PlaygroundDiscovery.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2025 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 BuildServerIntegration +@_spi(SourceKitLSP) import BuildServerProtocol +import Foundation +@_spi(SourceKitLSP) package import LanguageServerProtocol +@_spi(SourceKitLSP) import SKLogging +import SemanticIndex +package import SourceKitLSP +import SwiftExtensions + +extension SwiftLanguageService { + package func syntacticPlaygrounds( + for snapshot: DocumentSnapshot, + in workspace: Workspace + ) async -> [TextDocumentPlayground] { + await SwiftPlaygroundsScanner.findDocumentPlaygrounds( + for: snapshot, + workspace: workspace, + syntaxTreeManager: syntaxTreeManager + ) + } +} diff --git a/Sources/SwiftLanguageService/SwiftCodeLensScanner.swift b/Sources/SwiftLanguageService/SwiftCodeLensScanner.swift index b058a65d1..0d2c33e14 100644 --- a/Sources/SwiftLanguageService/SwiftCodeLensScanner.swift +++ b/Sources/SwiftLanguageService/SwiftCodeLensScanner.swift @@ -63,25 +63,26 @@ final class SwiftCodeLensScanner: SyntaxVisitor { } var codeLenses: [CodeLens] = [] - let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) if snapshot.text.contains("@main") { let visitor = SwiftCodeLensScanner( snapshot: snapshot, targetName: targetDisplayName, supportedCommands: supportedCommands ) + let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) visitor.walk(syntaxTree) codeLenses += visitor.result } // "swift.play" CodeLens should be ignored if "swift-play" is not in the toolchain as the client has no way of running - if toolchain.swiftPlay != nil, let workspace, let playCommand = supportedCommands[SupportedCodeLensCommand.play], - snapshot.text.contains("#Playground") + if toolchain.swiftPlay != nil, + let workspace, + let playCommand = supportedCommands[SupportedCodeLensCommand.play] { let playgrounds = await SwiftPlaygroundsScanner.findDocumentPlaygrounds( - in: syntaxTree, + for: snapshot, workspace: workspace, - snapshot: snapshot + syntaxTreeManager: syntaxTreeManager ) codeLenses += playgrounds.map({ CodeLens( diff --git a/Sources/SwiftLanguageService/SwiftLanguageService.swift b/Sources/SwiftLanguageService/SwiftLanguageService.swift index 5e647d20f..b163143ac 100644 --- a/Sources/SwiftLanguageService/SwiftLanguageService.swift +++ b/Sources/SwiftLanguageService/SwiftLanguageService.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// package import BuildServerIntegration -@_spi(SourceKitLSP) import BuildServerProtocol import Csourcekitd import Dispatch import Foundation diff --git a/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift b/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift index b7ad4457f..22773f459 100644 --- a/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift +++ b/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift @@ -41,18 +41,25 @@ final class SwiftPlaygroundsScanner: SyntaxVisitor { /// Designated entry point for `SwiftPlaygroundsScanner`. static func findDocumentPlaygrounds( - in node: some SyntaxProtocol, + for snapshot: DocumentSnapshot, workspace: Workspace, - snapshot: DocumentSnapshot + syntaxTreeManager: SyntaxTreeManager, ) async -> [TextDocumentPlayground] { + guard snapshot.text.contains("#Playground") else { + return [] + } + guard let canonicalTarget = await workspace.buildServerManager.canonicalTarget(for: snapshot.uri), let moduleName = await workspace.buildServerManager.moduleName(for: snapshot.uri, in: canonicalTarget), let baseName = snapshot.uri.fileURL?.lastPathComponent else { return [] } + + let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) + let visitor = SwiftPlaygroundsScanner(baseID: "\(moduleName)/\(baseName)", snapshot: snapshot) - visitor.walk(node) + visitor.walk(syntaxTree) return visitor.isPlaygroundImported ? visitor.result : [] } diff --git a/Sources/SwiftLanguageService/TestDiscovery.swift b/Sources/SwiftLanguageService/TestDiscovery.swift index f77550a82..f645349bd 100644 --- a/Sources/SwiftLanguageService/TestDiscovery.swift +++ b/Sources/SwiftLanguageService/TestDiscovery.swift @@ -47,18 +47,14 @@ extension SwiftLanguageService { return (xctestSymbols + swiftTestingSymbols).sorted { $0.testItem.location < $1.testItem.location } } - package static func syntacticTestItems(in uri: DocumentURI) async -> [AnnotatedTestItem] { - guard let url = uri.fileURL else { - logger.log("Not indexing \(uri.forLogging) for tests because it is not a file URL") - return [] - } - let syntaxTreeManager = SyntaxTreeManager() - let snapshot = orLog("Getting document snapshot for syntactic Swift test scanning") { - try DocumentSnapshot(withContentsFromDisk: url, language: .swift) - } - guard let snapshot else { - return [] - } + /// Syntactically scans the snapshot for tests declared within it. + /// + /// Does not write the results to the index. + /// + /// The order of the returned tests is not defined. The results should be sorted before being returned to the editor. + package func syntacticTestItems( + for snapshot: DocumentSnapshot, + ) async -> [AnnotatedTestItem] { async let swiftTestingTests = SyntacticSwiftTestingTestScanner.findTestSymbols( in: snapshot, syntaxTreeManager: syntaxTreeManager diff --git a/Tests/SourceKitLSPTests/CodeLensTests.swift b/Tests/SourceKitLSPTests/CodeLensTests.swift index cba757da6..6c3b9bc26 100644 --- a/Tests/SourceKitLSPTests/CodeLensTests.swift +++ b/Tests/SourceKitLSPTests/CodeLensTests.swift @@ -16,53 +16,6 @@ import SKTestSupport import ToolchainRegistry import XCTest -fileprivate extension Toolchain { - #if compiler(>=6.4) - #warning( - "Once we require swift-play in the toolchain that's used to test SourceKit-LSP, we can just use `forTesting`" - ) - #endif - static var forTestingWithSwiftPlay: Toolchain { - get async throws { - let toolchain = try await unwrap(ToolchainRegistry.forTesting.default) - return Toolchain( - identifier: "\(toolchain.identifier)-swift-swift", - displayName: "\(toolchain.identifier) with swift-play", - path: toolchain.path, - clang: toolchain.clang, - swift: toolchain.swift, - swiftc: toolchain.swiftc, - swiftPlay: URL(fileURLWithPath: "/dummy/usr/bin/swift-play"), - clangd: toolchain.clangd, - sourcekitd: toolchain.sourcekitd, - sourceKitClientPlugin: toolchain.sourceKitClientPlugin, - sourceKitServicePlugin: toolchain.sourceKitServicePlugin, - libIndexStore: toolchain.libIndexStore - ) - } - } - - static var forTestingWithoutSwiftPlay: Toolchain { - get async throws { - let toolchain = try await unwrap(ToolchainRegistry.forTesting.default) - return Toolchain( - identifier: "\(toolchain.identifier)-no-swift-swift", - displayName: "\(toolchain.identifier) without swift-play", - path: toolchain.path, - clang: toolchain.clang, - swift: toolchain.swift, - swiftc: toolchain.swiftc, - swiftPlay: nil, - clangd: toolchain.clangd, - sourcekitd: toolchain.sourcekitd, - sourceKitClientPlugin: toolchain.sourceKitClientPlugin, - sourceKitServicePlugin: toolchain.sourceKitServicePlugin, - libIndexStore: toolchain.libIndexStore - ) - } - } -} - final class CodeLensTests: SourceKitLSPTestCase { func testNoLenses() async throws { diff --git a/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift b/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift new file mode 100644 index 000000000..dc925e9ec --- /dev/null +++ b/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift @@ -0,0 +1,198 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2025 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 +@_spi(SourceKitLSP) import LanguageServerProtocol +import SKTestSupport +import SwiftExtensions +import ToolchainRegistry +import XCTest + +extension Toolchain { + #if compiler(>=6.4) + #warning( + "Once we require swift-play in the toolchain that's used to test SourceKit-LSP, we can just use `forTesting`" + ) + #endif + static var forTestingWithSwiftPlay: Toolchain { + get async throws { + let toolchain = try await unwrap(ToolchainRegistry.forTesting.default) + return Toolchain( + identifier: "\(toolchain.identifier)-swift-swift", + displayName: "\(toolchain.identifier) with swift-play", + path: toolchain.path, + clang: toolchain.clang, + swift: toolchain.swift, + swiftc: toolchain.swiftc, + swiftPlay: URL(fileURLWithPath: "/dummy/usr/bin/swift-play"), + clangd: toolchain.clangd, + sourcekitd: toolchain.sourcekitd, + sourceKitClientPlugin: toolchain.sourceKitClientPlugin, + sourceKitServicePlugin: toolchain.sourceKitServicePlugin, + libIndexStore: toolchain.libIndexStore + ) + } + } + + static var forTestingWithoutSwiftPlay: Toolchain { + get async throws { + let toolchain = try await unwrap(ToolchainRegistry.forTesting.default) + return Toolchain( + identifier: "\(toolchain.identifier)-no-swift-swift", + displayName: "\(toolchain.identifier) without swift-play", + path: toolchain.path, + clang: toolchain.clang, + swift: toolchain.swift, + swiftc: toolchain.swiftc, + swiftPlay: nil, + clangd: toolchain.clangd, + sourcekitd: toolchain.sourcekitd, + sourceKitClientPlugin: toolchain.sourceKitClientPlugin, + sourceKitServicePlugin: toolchain.sourceKitServicePlugin, + libIndexStore: toolchain.libIndexStore + ) + } + } +} + +final class WorkspacePlaygroundDiscoveryTests: SourceKitLSPTestCase { + + func testWorkspacePlaygroundsScanned() async throws { + let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithSwiftPlay]) + let project = try await SwiftPMTestProject( + files: [ + "Sources/MyLibrary/Test.swift": """ + import Playgrounds + + public func foo() -> String { + "bar" + } + + 1️⃣#Playground("foo") { + print(foo()) + }2️⃣ + + 3️⃣#Playground { + print(foo()) + }4️⃣ + + public func bar(_ i: Int, _ j: Int) -> Int { + i + j + } + + 5️⃣#Playground("bar") { + var i = bar(1, 2) + i = i + 1 + print(i) + }6️⃣ + """, + "Sources/MyLibrary/TestNoImport.swift": """ + #Playground("fooNoImport") { + print(foo()) + } + + #Playground { + print(foo()) + } + + #Playground("barNoImport") { + var i = bar(1, 2) + i = i + 1 + print(i) + } + """, + "Sources/MyLibrary/bar.swift": """ + import Playgrounds + + 7️⃣#Playground("bar2") { + print(foo()) + }8️⃣ + """, + "Sources/MyApp/baz.swift": """ + import Playgrounds + + 9️⃣#Playground("baz") { + print("baz") + }🔟 + """, + ], + manifest: """ + let package = Package( + name: "MyLibrary", + targets: [ + .target(name: "MyLibrary"), + .target(name: "MyApp") + ] + ) + """, + toolchainRegistry: toolchainRegistry + ) + + let response = try await project.testClient.send(WorkspacePlaygroundsRequest()) + + // Notice sorted order + XCTAssertEqual( + response, + [ + Playground( + id: "MyApp/baz.swift:3:2", + label: "baz", + location: try project.location(from: "9️⃣", to: "🔟", in: "baz.swift") + ), + Playground( + id: "MyLibrary/Test.swift:7:1", + label: "foo", + location: try project.location(from: "1️⃣", to: "2️⃣", in: "Test.swift") + ), + Playground( + id: "MyLibrary/Test.swift:11:1", + label: nil, + location: try project.location(from: "3️⃣", to: "4️⃣", in: "Test.swift") + ), + Playground( + id: "MyLibrary/Test.swift:19:1", + label: "bar", + location: try project.location(from: "5️⃣", to: "6️⃣", in: "Test.swift") + ), + Playground( + id: "MyLibrary/bar.swift:3:1", + label: "bar2", + location: try project.location(from: "7️⃣", to: "8️⃣", in: "bar.swift") + ), + ] + ) + } + + func testWorkspacePlaygroundsCapability() async throws { + let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithSwiftPlay]) + let testClient = try await TestSourceKitLSPClient(toolchainRegistry: toolchainRegistry) + let experimentalCapabilities = testClient.initializeResult?.capabilities.experimental + switch experimentalCapabilities { + case .dictionary(let dict): + XCTAssertNotEqual(dict[WorkspacePlaygroundsRequest.method], nil) + default: + XCTFail("Experimental capabilities expected to be a dictionary, got \(experimentalCapabilities as Any)") + } + } + + func testWorkspacePlaygroundsCapabilityNoSwiftPlay() async throws { + let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithoutSwiftPlay]) + let testClient = try await TestSourceKitLSPClient(toolchainRegistry: toolchainRegistry) + let experimentalCapabilities = testClient.initializeResult?.capabilities.experimental + switch experimentalCapabilities { + case .dictionary(let dict): + XCTAssertEqual(dict[WorkspacePlaygroundsRequest.method], nil) + default: + XCTFail("Experimental capabilities expected to be a dictionary, got \(experimentalCapabilities as Any)") + } + } +}