diff --git a/Sources/BuildServerIntegration/BuildServerManager.swift b/Sources/BuildServerIntegration/BuildServerManager.swift index 9e9a7d407..0e8c4be03 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,24 @@ 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] { + return try await sourceFiles(includeNonBuildableFiles: false).compactMap { (uri, info) -> DocumentURI? in + var includeTarget = true + if let include { + includeTarget = info.targets.contains(anyIn: include) + } + guard info.isPartOfRootProject, includeTarget else { + return nil + } + return uri + } + } + 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/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 74d04c9ab..a4031dcfc 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,6 @@ add_library(SourceKitLSP STATIC SourceKitLSPCommandMetadata.swift SourceKitLSPServer.swift SymbolLocation+DocumentURI.swift - SyntacticTestIndex.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..31f54cc21 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( @@ -1531,8 +1536,12 @@ extension SourceKitLSPServer { // settings). Inform the build server about all file changes. await workspaces.concurrentForEach { await $0.filesDidChange(notification.changes) } + await filesDidChange(notification.changes) + } + + func filesDidChange(_ events: [FileEvent]) async { for languageService in languageServices.values.flatMap(\.self) { - await languageService.filesDidChange(notification.changes) + await languageService.filesDidChange(events) } } diff --git a/Sources/SourceKitLSP/SyntacticTestIndex.swift b/Sources/SourceKitLSP/SwiftSyntacticIndex.swift similarity index 63% rename from Sources/SourceKitLSP/SyntacticTestIndex.swift rename to Sources/SourceKitLSP/SwiftSyntacticIndex.swift index 366afeabd..134f6a1a6 100644 --- a/Sources/SourceKitLSP/SyntacticTestIndex.swift +++ b/Sources/SourceKitLSP/SwiftSyntacticIndex.swift @@ -10,23 +10,25 @@ // //===----------------------------------------------------------------------===// +@_spi(SourceKitLSP) 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 `SwiftSyntacticIndex.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 + /// `SwiftSyntacticIndex` 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 SwiftSyntacticIndex: 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,37 @@ 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] + + /// Syntactically parse tests from the given snapshot + private let syntacticTests: @Sendable (DocumentSnapshot, Workspace) async -> [AnnotatedTestItem] + + /// Syntactically parse playgrounds from the given snapshot + private let syntacticPlaygrounds: @Sendable (DocumentSnapshot, Workspace) async -> [TextDocumentPlayground] + + package init( + determineFilesToScan: @Sendable @escaping (Set?) async -> [DocumentURI], + syntacticTests: @Sendable @escaping (DocumentSnapshot, Workspace) async -> [AnnotatedTestItem], + syntacticPlaygrounds: @Sendable @escaping (DocumentSnapshot, Workspace) async -> [TextDocumentPlayground] ) { - self.languageServiceRegistry = languageServiceRegistry - indexingQueue.async(priority: .low, metadata: .initialPopulation) { - let testFiles = await determineTestFiles() + self.determineFilesToScan = determineFilesToScan + self.syntacticTests = syntacticTests + self.syntacticPlaygrounds = syntacticPlaygrounds + } + func scan(workspace: Workspace) { + 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 batches = filesToScan.partition(intoNumberOfBatches: ProcessInfo.processInfo.activeProcessorCount * 4) await batches.concurrentForEach { filesInBatch in for uri in filesInBatch { - await self.rescanFileAssumingOnQueue(uri) + await self.rescanFileAssumingOnQueue(uri, workspace) } } } @@ -123,43 +140,42 @@ 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?, _ workspace: Workspace) async { + let changedFiles = await determineFilesToScan(changedTargets) + let removedFiles = Set(self.indexedSources.keys).subtracting(changedFiles) removeFilesFromIndex(removedFiles) - rescanFiles(testFiles) + rescanFiles(changedFiles, workspace) } - func filesDidChange(_ events: [FileEvent]) { + package func filesDidChange(_ events: [FileEvent], _ workspace: Workspace) { var removedFiles: Set = [] var filesToRescan: [DocumentURI] = [] for fileEvent 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.append(fileEvent.uri) case .changed: filesToRescan.append(fileEvent.uri) 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 SwiftSyntacticIndex") } } removeFilesFromIndex(removedFiles) - rescanFiles(filesToRescan) + rescanFiles(filesToRescan, workspace) } /// Called when a list of files was updated. Re-scans those files - private func rescanFiles(_ uris: [DocumentURI]) { + private func rescanFiles(_ uris: [DocumentURI], _ workspace: Workspace) { // 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) @@ -172,7 +188,7 @@ actor SyntacticTestIndex { // that the index is already up-to-date, which makes the rescan a no-op. let uris = uris.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 @@ -187,7 +203,7 @@ actor SyntacticTestIndex { } logger.info( - "Syntactically scanning \(uris.count) files for tests: \(uris.map(\.arbitrarySchemeURL.lastPathComponent).joined(separator: ", "))" + "Syntactically scanning \(uris.count) files: \(uris.map(\.arbitrarySchemeURL.lastPathComponent).joined(separator: ", "))" ) // Divide the files into multiple batches. This is more efficient than spawning a new task for every file, mostly @@ -199,7 +215,7 @@ actor SyntacticTestIndex { for batch in batches { self.indexingQueue.async(priority: .low, metadata: .index(Set(batch))) { for uri in batch { - await self.rescanFileAssumingOnQueue(uri) + await self.rescanFileAssumingOnQueue(uri, workspace) } } } @@ -208,9 +224,9 @@ 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, _ workspace: Workspace) async { 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 +237,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 +256,50 @@ 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 Swift scanning") { + try DocumentSnapshot(withContentsFromDisk: url, language: .swift) } - let testItems = await languageServiceRegistry.languageServices(for: language).asyncFlatMap { - await $0.syntacticTestItems(in: uri) + guard let snapshot else { + return } + let (testItems, playgrounds) = await ( + syntacticTests(snapshot, workspace), syntacticPlaygrounds(snapshot, workspace) + ) + 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..d7cda3e05 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -184,8 +184,8 @@ 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. + let syntacticIndex: SwiftSyntacticIndex /// Language service for an open document, if available. private let languageServices: ThreadSafeBox<[DocumentURI: [LanguageService]]> = ThreadSafeBox(initialValue: [:]) @@ -259,15 +259,25 @@ 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 = SwiftSyntacticIndex( + determineFilesToScan: { targets in + await orLog("Getting list of files for syntactic index population") { + try await buildServerManager.projectSourceFiles(in: targets) } ?? [] + }, + syntacticTests: { (snapshot, workspace) in + await sourceKitLSPServer.languageServices(for: snapshot.uri, snapshot.language, in: workspace).asyncFlatMap { + await $0.syntacticTestItems(for: snapshot) + } + }, + syntacticPlaygrounds: { (snapshot, workspace) in + await sourceKitLSPServer.languageServices(for: snapshot.uri, snapshot.language, in: workspace).asyncFlatMap { + await $0.syntacticPlaygrounds(for: snapshot, in: workspace) + } } ) + await syntacticIndex.scan(workspace: self) } /// Creates a workspace for a given root `DocumentURI`, inferring the `ExternalWorkspace` if possible. @@ -406,7 +416,7 @@ 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) + async let updateSyntacticIndex: Void = await syntacticIndex.filesDidChange(events, self) async let updateSemanticIndex: Void? = await semanticIndexManager?.filesDidChange(events) _ = await (updateSyntacticIndex, updateSemanticIndex) } @@ -470,9 +480,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, self) } await scheduleUpdateOfUnitOutputPathsInIndexIfNecessary() diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index f6a1b3fb1..69c46b9bb 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 @@ -38,6 +39,7 @@ add_library(SwiftLanguageService STATIC SwiftCodeLensScanner.swift SwiftCommand.swift SwiftLanguageService.swift + SwiftSyntacticIndex.swift SwiftTestingScanner.swift SymbolGraph.swift SymbolInfo.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..ed9fc268c 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 @@ -137,7 +136,11 @@ package actor SwiftLanguageService: LanguageService, Sendable { /// might have finished. This isn't an issue since the tasks do not retain `self`. private var inFlightPublishDiagnosticsTasks: [DocumentURI: Task] = [:] - let syntaxTreeManager = SyntaxTreeManager() + /// Shared syntax tree manager to share syntax trees when syntactically parsing different types + let syntaxTreeManager: SyntaxTreeManager + + /// Workspace this language service was created for + let workspace: Workspace /// The `semanticIndexManager` of the workspace this language service was created for. private let semanticIndexManagerTask: Task @@ -211,9 +214,12 @@ package actor SwiftLanguageService: LanguageService, Sendable { "Cannot create SwiftLanguage service because \(toolchain.identifier) does not contain sourcekitd" ) } + let syntaxTreeManager = SyntaxTreeManager() + self.syntaxTreeManager = syntaxTreeManager self.sourcekitdPath = sourcekitd self.sourceKitLSPServer = sourceKitLSPServer self.toolchain = toolchain + self.workspace = workspace let pluginPaths: PluginPaths? if let clientPlugin = options.sourcekitdOrDefault.clientPlugin, let servicePlugin = options.sourcekitdOrDefault.servicePlugin diff --git a/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift b/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift index b7ad4457f..e618924ed 100644 --- a/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift +++ b/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift @@ -41,9 +41,9 @@ 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 let canonicalTarget = await workspace.buildServerManager.canonicalTarget(for: snapshot.uri), let moduleName = await workspace.buildServerManager.moduleName(for: snapshot.uri, in: canonicalTarget), @@ -51,8 +51,14 @@ final class SwiftPlaygroundsScanner: SyntaxVisitor { else { return [] } + + guard snapshot.text.contains("#Playground") 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/SyntaxTreeManager.swift b/Sources/SwiftLanguageService/SyntaxTreeManager.swift index 2eb3dba46..a7ef71bb3 100644 --- a/Sources/SwiftLanguageService/SyntaxTreeManager.swift +++ b/Sources/SwiftLanguageService/SyntaxTreeManager.swift @@ -18,7 +18,7 @@ import SwiftSyntax /// Keeps track of SwiftSyntax trees for document snapshots and computes the /// SwiftSyntax trees on demand. -actor SyntaxTreeManager { +package actor SyntaxTreeManager { /// A task that parses a SwiftSyntax tree from a source file, producing both /// the syntax tree and the lookahead ranges that are needed for a subsequent /// incremental parse. 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