From ed756fa22f56ac7da722383870c94222330413dd Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Wed, 12 Nov 2025 15:40:20 -0500 Subject: [PATCH 01/18] Rename SyntacticTestIndex to just SyntacticIndex --- Sources/SourceKitLSP/CMakeLists.txt | 2 +- .../{SyntacticTestIndex.swift => SyntacticIndex.swift} | 0 Sources/SourceKitLSP/TestDiscovery.swift | 2 +- Sources/SourceKitLSP/Workspace.swift | 10 +++++----- 4 files changed, 7 insertions(+), 7 deletions(-) rename Sources/SourceKitLSP/{SyntacticTestIndex.swift => SyntacticIndex.swift} (100%) diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 74d04c9ab..8d39d5233 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -22,7 +22,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/SyntacticTestIndex.swift b/Sources/SourceKitLSP/SyntacticIndex.swift similarity index 100% rename from Sources/SourceKitLSP/SyntacticTestIndex.swift rename to Sources/SourceKitLSP/SyntacticIndex.swift diff --git a/Sources/SourceKitLSP/TestDiscovery.swift b/Sources/SourceKitLSP/TestDiscovery.swift index e0a2dd1c2..c68bd7f64 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, diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index 760a6356d..ba4ed053f 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -185,7 +185,7 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { } /// The index that syntactically scans the workspace for tests. - let syntacticTestIndex: SyntacticTestIndex + let syntacticIndex: SyntacticIndex /// Language service for an open document, if available. private let languageServices: ThreadSafeBox<[DocumentURI: [LanguageService]]> = ThreadSafeBox(initialValue: [:]) @@ -259,8 +259,8 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { return nil } } - // Trigger an initial population of `syntacticTestIndex`. - self.syntacticTestIndex = SyntacticTestIndex( + // Trigger an initial population of `syntacticIndex`. + self.syntacticIndex = SyntacticIndex( languageServiceRegistry: sourceKitLSPServer.languageServiceRegistry, determineTestFiles: { await orLog("Getting list of test files for initial syntactic index population") { @@ -406,7 +406,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) async let updateSemanticIndex: Void? = await semanticIndexManager?.filesDidChange(events) _ = await (updateSyntacticIndex, updateSemanticIndex) } @@ -472,7 +472,7 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { await semanticIndexManager?.buildTargetsChanged(changedTargets) await orLog("Scheduling syntactic test re-indexing") { let testFiles = try await buildServerManager.testFiles() - await syntacticTestIndex.listOfTestFilesDidChange(testFiles) + await syntacticIndex.listOfTestFilesDidChange(testFiles) } await scheduleUpdateOfUnitOutputPathsInIndexIfNecessary() From 3ccf8c82cf384d4faeaa8a86084c17bcce20b84c Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Thu, 20 Nov 2025 10:35:31 -0500 Subject: [PATCH 02/18] Rename to SwiftSyntacticIndex --- .../{SyntacticIndex.swift => SwiftSyntacticIndex.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Sources/SourceKitLSP/{SyntacticIndex.swift => SwiftSyntacticIndex.swift} (100%) diff --git a/Sources/SourceKitLSP/SyntacticIndex.swift b/Sources/SourceKitLSP/SwiftSyntacticIndex.swift similarity index 100% rename from Sources/SourceKitLSP/SyntacticIndex.swift rename to Sources/SourceKitLSP/SwiftSyntacticIndex.swift From f76dd2f62769404428dc4953e8ccbbd2d55ced72 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Wed, 26 Nov 2025 08:35:32 -0500 Subject: [PATCH 03/18] Add new `workspace/playgrounds` request --- .../BuildServerManager.swift | 20 ++- .../BuildServerManagerDelegate.swift | 10 ++ .../ClangLanguageService.swift | 6 +- .../DocumentationLanguageService.swift | 6 +- Sources/SourceKitLSP/CMakeLists.txt | 2 +- Sources/SourceKitLSP/LanguageService.swift | 11 +- .../MessageHandlingDependencyTracker.swift | 2 + .../SourceKitLSP/PlaygroundDiscovery.swift | 45 ++++++ Sources/SourceKitLSP/SourceKitLSPServer.swift | 9 +- .../SourceKitLSP/SwiftSyntacticIndex.swift | 129 +++++++++++------- Sources/SourceKitLSP/TestDiscovery.swift | 6 +- Sources/SourceKitLSP/Workspace.swift | 39 +++--- Sources/SwiftLanguageService/CMakeLists.txt | 2 + .../PlaygroundDiscovery.swift | 39 ++++++ .../SwiftCodeLensScanner.swift | 11 +- .../SwiftLanguageService.swift | 47 ++++++- .../SwiftPlaygroundsScanner.swift | 12 +- .../SyntaxTreeManager.swift | 2 +- .../SwiftLanguageService/TestDiscovery.swift | 21 ++- 19 files changed, 322 insertions(+), 97 deletions(-) create mode 100644 Sources/SourceKitLSP/PlaygroundDiscovery.swift create mode 100644 Sources/SwiftLanguageService/PlaygroundDiscovery.swift 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/BuildServerIntegration/BuildServerManagerDelegate.swift b/Sources/BuildServerIntegration/BuildServerManagerDelegate.swift index 69145c3ec..9fabb4b73 100644 --- a/Sources/BuildServerIntegration/BuildServerManagerDelegate.swift +++ b/Sources/BuildServerIntegration/BuildServerManagerDelegate.swift @@ -30,6 +30,16 @@ package protocol BuildServerManagerDelegate: AnyObject, Sendable { /// Notify the delegate that some information about the given build targets has changed and that it should recompute /// any information based on top of it. func buildTargetsChanged(_ changedTargets: Set?) async + + func addBuiltTargetListener(_ listener: any BuildTargetListener) + + func removeBuiltTargetListener(_ listener: any BuildTargetListener) +} + +package protocol BuildTargetListener: AnyObject, Sendable { + /// Notify the listener that some information about the given build targets has changed and that it should recompute + /// any information based on top of it. + func buildTargetsChanged(_ changedTargets: Set?) async } /// Methods with which the `BuildServerManager` can send messages to the client (aka. editor). diff --git a/Sources/ClangLanguageService/ClangLanguageService.swift b/Sources/ClangLanguageService/ClangLanguageService.swift index 1997fc9df..817b4d071 100644 --- a/Sources/ClangLanguageService/ClangLanguageService.swift +++ b/Sources/ClangLanguageService/ClangLanguageService.swift @@ -644,7 +644,11 @@ extension ClangLanguageService { return nil } - package static func syntacticTestItems(in uri: DocumentURI) async -> [AnnotatedTestItem] { + package func syntacticTests(in workspace: Workspace) async -> [AnnotatedTestItem] { + return [] + } + + package func syntacticPlaygrounds(in workspace: Workspace) async -> [Playground] { return [] } diff --git a/Sources/DocumentationLanguageService/DocumentationLanguageService.swift b/Sources/DocumentationLanguageService/DocumentationLanguageService.swift index af87658d3..282d0b4ad 100644 --- a/Sources/DocumentationLanguageService/DocumentationLanguageService.swift +++ b/Sources/DocumentationLanguageService/DocumentationLanguageService.swift @@ -88,7 +88,11 @@ 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 syntacticTests(in workspace: Workspace) async -> [AnnotatedTestItem] { + return [] + } + + package func syntacticPlaygrounds(in workspace: Workspace) async -> [Playground] { return [] } diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index 8d39d5233..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 - SyntacticIndex.swift TestDiscovery.swift TextEdit+IsNoop.swift Workspace.swift diff --git a/Sources/SourceKitLSP/LanguageService.swift b/Sources/SourceKitLSP/LanguageService.swift index b251ce15b..5ec6a59f4 100644 --- a/Sources/SourceKitLSP/LanguageService.swift +++ b/Sources/SourceKitLSP/LanguageService.swift @@ -313,12 +313,15 @@ package protocol LanguageService: AnyObject, Sendable { /// 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 -> [AnnotatedTestItem]? - /// Syntactically scans the file at the given URL for tests declared within it. - /// - /// Does not write the results to the index. + /// Returns the syntactically scanned tests declared within the workspace. /// /// 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 syntacticTests(in workspace: Workspace) 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(in workspace: Workspace) async -> [Playground] /// 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..2f9621b52 --- /dev/null +++ b/Sources/SourceKitLSP/PlaygroundDiscovery.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// +// 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 languageServices.values.asyncFlatMap { + await $0.asyncFlatMap { await $0.syntacticPlaygrounds(in: workspace) } + } + + // 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..eac5f43e1 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) } @@ -1118,6 +1120,7 @@ extension SourceKitLSPServer { TriggerReindexRequest.method: .dictionary(["version": .int(1)]), GetReferenceDocumentRequest.method: .dictionary(["version": .int(1)]), DidChangeActiveDocumentNotification.method: .dictionary(["version": .int(1)]), + WorkspacePlaygroundsRequest.method: .dictionary(["version": .int(1)]), ] for (key, value) in languageServiceRegistry.languageServices.flatMap({ $0.type.experimentalCapabilities }) { if let existingValue = experimentalCapabilities[key] { @@ -1531,8 +1534,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/SwiftSyntacticIndex.swift b/Sources/SourceKitLSP/SwiftSyntacticIndex.swift index 366afeabd..13cdfaf9e 100644 --- a/Sources/SourceKitLSP/SwiftSyntacticIndex.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: BuildTargetListener, 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,20 +101,31 @@ 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) async -> [AnnotatedTestItem] + + // Syntactically parse playgrounds from the given snapshot + private let syntacticPlaygrounds: @Sendable (DocumentSnapshot) async -> [TextDocumentPlayground] + + package init( + determineFilesToScan: @Sendable @escaping (Set?) async -> [DocumentURI], + syntacticTests: @Sendable @escaping (DocumentSnapshot) async -> [AnnotatedTestItem], + syntacticPlaygrounds: @Sendable @escaping (DocumentSnapshot) async -> [TextDocumentPlayground] ) { - self.languageServiceRegistry = languageServiceRegistry + self.determineFilesToScan = determineFilesToScan + self.syntacticTests = syntacticTests + self.syntacticPlaygrounds = syntacticPlaygrounds indexingQueue.async(priority: .low, metadata: .initialPopulation) { - let testFiles = await determineTestFiles() - + 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) @@ -123,35 +137,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) removeFilesFromIndex(removedFiles) - rescanFiles(testFiles) + rescanFiles(changedFiles) } - func filesDidChange(_ events: [FileEvent]) { + package func filesDidChange(_ events: [FileEvent]) { 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) @@ -172,7 +185,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 +200,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 @@ -210,7 +223,7 @@ actor SyntacticTestIndex { /// - Important: This method must be called in a task that is executing on `indexingQueue`. private func rescanFileAssumingOnQueue(_ uri: DocumentURI) 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 +234,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 +253,52 @@ 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") + + guard let url = uri.fileURL else { + logger.log("Not indexing \(uri.forLogging) because it is not a file URL") return } - let testItems = await languageServiceRegistry.languageServices(for: language).asyncFlatMap { - await $0.syntacticTestItems(in: uri) + let snapshot: DocumentSnapshot? = orLog("Getting document snapshot for syntactic Swift scanning") { + try DocumentSnapshot(withContentsFromDisk: url, language: .swift) + } + guard let snapshot else { + return } + let (testItems, playgrounds) = await (syntacticTests(snapshot), syntacticPlaygrounds(snapshot)) + 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.indexedTests.values.flatMap { $0.tests } + 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.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 c68bd7f64..b28e97248 100644 --- a/Sources/SourceKitLSP/TestDiscovery.swift +++ b/Sources/SourceKitLSP/TestDiscovery.swift @@ -245,7 +245,9 @@ extension SourceKitLSPServer { let semanticTestSymbolOccurrences = index?.unitTests().filter { return $0.canBeTestDefinition } ?? [] - let testsFromSyntacticIndex = await workspace.syntacticIndex.tests() + let testsFromSyntacticIndex = await languageServices.values.asyncFlatMap { + await $0.asyncFlatMap { await $0.syntacticTests(in: workspace) } + } let testsFromSemanticIndex = testItems( for: semanticTestSymbolOccurrences, index: index, @@ -292,7 +294,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 ba4ed053f..28e758746 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -184,12 +184,13 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { } } - /// The index that syntactically scans the workspace for tests. - let syntacticIndex: SyntacticIndex - /// Language service for an open document, if available. private let languageServices: ThreadSafeBox<[DocumentURI: [LanguageService]]> = ThreadSafeBox(initialValue: [:]) + /// Build target listeners + private let buildTargetListeners: ThreadSafeBox<[ObjectIdentifier: BuildTargetListener]> = ThreadSafeBox( + initialValue: [:]) + /// The task that constructs the `SemanticIndexManager`, which 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. /// @@ -259,15 +260,6 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { return nil } } - // Trigger an initial population of `syntacticIndex`. - self.syntacticIndex = SyntacticIndex( - languageServiceRegistry: sourceKitLSPServer.languageServiceRegistry, - determineTestFiles: { - await orLog("Getting list of test files for initial syntactic index population") { - try await buildServerManager.testFiles() - } ?? [] - } - ) } /// Creates a workspace for a given root `DocumentURI`, inferring the `ExternalWorkspace` if possible. @@ -406,9 +398,9 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { // Notify all clients about the reported and inferred edits. await buildServerManager.filesDidChange(events) - async let updateSyntacticIndex: Void = await syntacticIndex.filesDidChange(events) + async let updateServer: Void? = await sourceKitLSPServer?.filesDidChange(events) async let updateSemanticIndex: Void? = await semanticIndexManager?.filesDidChange(events) - _ = await (updateSyntacticIndex, updateSemanticIndex) + _ = await (updateServer, updateSemanticIndex) } /// The language services that can handle the given document. Callers should try to merge the results from the @@ -470,14 +462,27 @@ 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 syntacticIndex.listOfTestFilesDidChange(testFiles) + await orLog("Scheduling syntactic file re-indexing") { + _ = await buildTargetListeners.value.values.asyncMap { + await $0.buildTargetsChanged(changedTargets) + } } await scheduleUpdateOfUnitOutputPathsInIndexIfNecessary() } + package func addBuiltTargetListener(_ listener: any BuildTargetListener) { + buildTargetListeners.withLock { + $0[ObjectIdentifier(listener)] = listener + } + } + + package func removeBuiltTargetListener(_ listener: any BuildTargetListener) { + buildTargetListeners.withLock { + $0[ObjectIdentifier(listener)] = nil + } + } + private func scheduleUpdateOfUnitOutputPathsInIndexIfNecessary() async { indexUnitOutputPathsUpdateQueue.async { guard await self.uncheckedIndex?.usesExplicitOutputPaths ?? false else { 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..4d445a9fe --- /dev/null +++ b/Sources/SwiftLanguageService/PlaygroundDiscovery.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// +// 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) import LanguageServerProtocol +@_spi(SourceKitLSP) import SKLogging +import SemanticIndex +import SourceKitLSP +import SwiftExtensions +import ToolchainRegistry + +extension SwiftLanguageService { + static func syntacticPlaygrounds( + for snapshot: DocumentSnapshot, + in workspace: Workspace, + using syntaxTreeManager: SyntaxTreeManager, + toolchain: Toolchain + ) async -> [TextDocumentPlayground] { + guard toolchain.swiftPlay != nil else { + return [] + } + return 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..e9a207b29 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,14 @@ 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 + + /// The index that syntactically scans the workspace. + let syntacticIndex: SwiftSyntacticIndex + + /// 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 +217,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 @@ -270,6 +279,27 @@ package actor SwiftLanguageService: LanguageService, Sendable { clientHasDiagnosticsCodeDescriptionSupport: await capabilityRegistry.clientHasDiagnosticsCodeDescriptionSupport ) + // Trigger an initial population of `syntacticIndex`. + self.syntacticIndex = SwiftSyntacticIndex( + determineFilesToScan: { targets in + await orLog("Getting list of files for syntactic index population") { + try await workspace.buildServerManager.projectSourceFiles(in: targets) + } ?? [] + }, + syntacticTests: { + await SwiftLanguageService.syntacticTestItems(for: $0, using: syntaxTreeManager) + }, + syntacticPlaygrounds: { + await SwiftLanguageService.syntacticPlaygrounds( + for: $0, + in: workspace, + using: syntaxTreeManager, + toolchain: toolchain + ) + } + ) + workspace.addBuiltTargetListener(syntacticIndex) + self.macroExpansionManager = MacroExpansionManager(swiftLanguageService: self) self.generatedInterfaceManager = GeneratedInterfaceManager(swiftLanguageService: self) @@ -367,6 +397,10 @@ package actor SwiftLanguageService: LanguageService, Sendable { ) { self.stateChangeHandlers.append(handler) } + + package func filesDidChange(_ events: [FileEvent]) async { + await syntacticIndex.filesDidChange(events) + } } extension SwiftLanguageService { @@ -424,6 +458,7 @@ extension SwiftLanguageService { } package func shutdown() async { + self.workspace.removeBuiltTargetListener(syntacticIndex) await self.sourcekitd.removeNotificationHandler(self) } @@ -1113,6 +1148,14 @@ extension SwiftLanguageService { ) } } + + package func syntacticTests(in workspace: Workspace) async -> [AnnotatedTestItem] { + await syntacticIndex.tests() + } + + package func syntacticPlaygrounds(in workspace: Workspace) async -> [Playground] { + await syntacticIndex.playgrounds() + } } extension SwiftLanguageService: SKDNotificationHandler { 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..b18333cce 100644 --- a/Sources/SwiftLanguageService/TestDiscovery.swift +++ b/Sources/SwiftLanguageService/TestDiscovery.swift @@ -47,18 +47,15 @@ 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. + static package func syntacticTestItems( + for snapshot: DocumentSnapshot, + using syntaxTreeManager: SyntaxTreeManager + ) async -> [AnnotatedTestItem] { async let swiftTestingTests = SyntacticSwiftTestingTestScanner.findTestSymbols( in: snapshot, syntaxTreeManager: syntaxTreeManager From f16aded81cf728f379ffa1b5962142979ae67973 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Thu, 27 Nov 2025 15:26:59 -0500 Subject: [PATCH 04/18] Move syntactic index back to Workspace --- .../BuildServerManagerDelegate.swift | 10 ---- .../ClangLanguageService.swift | 7 ++- .../DocumentationLanguageService.swift | 7 ++- Sources/SourceKitLSP/LanguageService.swift | 11 +++-- .../SourceKitLSP/PlaygroundDiscovery.swift | 4 +- .../SourceKitLSP/SwiftSyntacticIndex.swift | 41 +++++++++-------- Sources/SourceKitLSP/TestDiscovery.swift | 4 +- Sources/SourceKitLSP/Workspace.swift | 46 ++++++++++--------- .../PlaygroundDiscovery.swift | 16 ++----- .../SwiftLanguageService.swift | 37 --------------- .../SwiftLanguageService/TestDiscovery.swift | 3 +- 11 files changed, 72 insertions(+), 114 deletions(-) diff --git a/Sources/BuildServerIntegration/BuildServerManagerDelegate.swift b/Sources/BuildServerIntegration/BuildServerManagerDelegate.swift index 9fabb4b73..69145c3ec 100644 --- a/Sources/BuildServerIntegration/BuildServerManagerDelegate.swift +++ b/Sources/BuildServerIntegration/BuildServerManagerDelegate.swift @@ -30,16 +30,6 @@ package protocol BuildServerManagerDelegate: AnyObject, Sendable { /// Notify the delegate that some information about the given build targets has changed and that it should recompute /// any information based on top of it. func buildTargetsChanged(_ changedTargets: Set?) async - - func addBuiltTargetListener(_ listener: any BuildTargetListener) - - func removeBuiltTargetListener(_ listener: any BuildTargetListener) -} - -package protocol BuildTargetListener: AnyObject, Sendable { - /// Notify the listener that some information about the given build targets has changed and that it should recompute - /// any information based on top of it. - func buildTargetsChanged(_ changedTargets: Set?) async } /// Methods with which the `BuildServerManager` can send messages to the client (aka. editor). diff --git a/Sources/ClangLanguageService/ClangLanguageService.swift b/Sources/ClangLanguageService/ClangLanguageService.swift index 817b4d071..062345098 100644 --- a/Sources/ClangLanguageService/ClangLanguageService.swift +++ b/Sources/ClangLanguageService/ClangLanguageService.swift @@ -644,11 +644,14 @@ extension ClangLanguageService { return nil } - package func syntacticTests(in workspace: Workspace) async -> [AnnotatedTestItem] { + package func syntacticTestItems(for snapshot: DocumentSnapshot) async -> [AnnotatedTestItem] { return [] } - package func syntacticPlaygrounds(in workspace: Workspace) async -> [Playground] { + 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 282d0b4ad..4386e6bdb 100644 --- a/Sources/DocumentationLanguageService/DocumentationLanguageService.swift +++ b/Sources/DocumentationLanguageService/DocumentationLanguageService.swift @@ -88,11 +88,14 @@ package actor DocumentationLanguageService: LanguageService, Sendable { // The DocumentationLanguageService does not do anything with document events } - package func syntacticTests(in workspace: Workspace) async -> [AnnotatedTestItem] { + package func syntacticTestItems(for snapshot: DocumentSnapshot) async -> [AnnotatedTestItem] { return [] } - package func syntacticPlaygrounds(in workspace: Workspace) async -> [Playground] { + package func syntacticPlaygrounds( + for snapshot: DocumentSnapshot, + in workspace: Workspace + ) async -> [TextDocumentPlayground] { return [] } diff --git a/Sources/SourceKitLSP/LanguageService.swift b/Sources/SourceKitLSP/LanguageService.swift index 5ec6a59f4..2294fdec6 100644 --- a/Sources/SourceKitLSP/LanguageService.swift +++ b/Sources/SourceKitLSP/LanguageService.swift @@ -313,15 +313,20 @@ package protocol LanguageService: AnyObject, Sendable { /// 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 -> [AnnotatedTestItem]? - /// Returns the syntactically scanned tests declared within the workspace. + /// Syntactically scans the file at the given URL 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. - func syntacticTests(in workspace: Workspace) 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(in workspace: Workspace) async -> [Playground] + 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/PlaygroundDiscovery.swift b/Sources/SourceKitLSP/PlaygroundDiscovery.swift index 2f9621b52..a50052425 100644 --- a/Sources/SourceKitLSP/PlaygroundDiscovery.swift +++ b/Sources/SourceKitLSP/PlaygroundDiscovery.swift @@ -28,9 +28,7 @@ extension SourceKitLSPServer { // playgrounds. await workspace.buildServerManager.waitForUpToDateBuildGraph() - let playgroundsFromSyntacticIndex = await languageServices.values.asyncFlatMap { - await $0.asyncFlatMap { await $0.syntacticPlaygrounds(in: workspace) } - } + 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 diff --git a/Sources/SourceKitLSP/SwiftSyntacticIndex.swift b/Sources/SourceKitLSP/SwiftSyntacticIndex.swift index 13cdfaf9e..134f6a1a6 100644 --- a/Sources/SourceKitLSP/SwiftSyntacticIndex.swift +++ b/Sources/SourceKitLSP/SwiftSyntacticIndex.swift @@ -83,7 +83,7 @@ private struct IndexedSourceFile { /// /// 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. -package actor SwiftSyntacticIndex: BuildTargetListener, Sendable { +package actor SwiftSyntacticIndex: Sendable { /// The tests discovered by the index. private var indexedSources: [DocumentURI: IndexedSourceFile] = [:] @@ -104,20 +104,23 @@ package actor SwiftSyntacticIndex: BuildTargetListener, Sendable { /// 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) async -> [AnnotatedTestItem] + /// 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) async -> [TextDocumentPlayground] + /// 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) async -> [AnnotatedTestItem], - syntacticPlaygrounds: @Sendable @escaping (DocumentSnapshot) async -> [TextDocumentPlayground] + syntacticTests: @Sendable @escaping (DocumentSnapshot, Workspace) async -> [AnnotatedTestItem], + syntacticPlaygrounds: @Sendable @escaping (DocumentSnapshot, Workspace) async -> [TextDocumentPlayground] ) { 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 @@ -128,7 +131,7 @@ package actor SwiftSyntacticIndex: BuildTargetListener, Sendable { 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) } } } @@ -144,15 +147,15 @@ package actor SwiftSyntacticIndex: BuildTargetListener, Sendable { /// Called when the list of targets is updated. /// /// All files that are not in the new list of buildable files will be removed from the index. - package func buildTargetsChanged(_ changedTargets: Set?) async { + package func buildTargetsChanged(_ changedTargets: Set?, _ workspace: Workspace) async { let changedFiles = await determineFilesToScan(changedTargets) let removedFiles = Set(self.indexedSources.keys).subtracting(changedFiles) removeFilesFromIndex(removedFiles) - rescanFiles(changedFiles) + rescanFiles(changedFiles, workspace) } - package func filesDidChange(_ events: [FileEvent]) { + package func filesDidChange(_ events: [FileEvent], _ workspace: Workspace) { var removedFiles: Set = [] var filesToRescan: [DocumentURI] = [] for fileEvent in events { @@ -168,11 +171,11 @@ package actor SwiftSyntacticIndex: BuildTargetListener, Sendable { } } 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) @@ -212,7 +215,7 @@ package actor SwiftSyntacticIndex: BuildTargetListener, Sendable { 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) } } } @@ -221,7 +224,7 @@ package actor SwiftSyntacticIndex: BuildTargetListener, Sendable { /// 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) because it is not a file URL") return @@ -254,10 +257,6 @@ package actor SwiftSyntacticIndex: BuildTargetListener, Sendable { return } - guard let url = uri.fileURL else { - logger.log("Not indexing \(uri.forLogging) because it is not a file URL") - return - } let snapshot: DocumentSnapshot? = orLog("Getting document snapshot for syntactic Swift scanning") { try DocumentSnapshot(withContentsFromDisk: url, language: .swift) } @@ -265,7 +264,9 @@ package actor SwiftSyntacticIndex: BuildTargetListener, Sendable { return } - let (testItems, playgrounds) = await (syntacticTests(snapshot), syntacticPlaygrounds(snapshot)) + 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 diff --git a/Sources/SourceKitLSP/TestDiscovery.swift b/Sources/SourceKitLSP/TestDiscovery.swift index b28e97248..061fb45e5 100644 --- a/Sources/SourceKitLSP/TestDiscovery.swift +++ b/Sources/SourceKitLSP/TestDiscovery.swift @@ -245,9 +245,7 @@ extension SourceKitLSPServer { let semanticTestSymbolOccurrences = index?.unitTests().filter { return $0.canBeTestDefinition } ?? [] - let testsFromSyntacticIndex = await languageServices.values.asyncFlatMap { - await $0.asyncFlatMap { await $0.syntacticTests(in: workspace) } - } + let testsFromSyntacticIndex = await workspace.syntacticIndex.tests() let testsFromSemanticIndex = testItems( for: semanticTestSymbolOccurrences, index: index, diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index 28e758746..d7cda3e05 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -184,13 +184,12 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { } } + /// 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: [:]) - /// Build target listeners - private let buildTargetListeners: ThreadSafeBox<[ObjectIdentifier: BuildTargetListener]> = ThreadSafeBox( - initialValue: [:]) - /// The task that constructs the `SemanticIndexManager`, which 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. /// @@ -260,6 +259,25 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { return nil } } + // 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. @@ -398,9 +416,9 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { // Notify all clients about the reported and inferred edits. await buildServerManager.filesDidChange(events) - async let updateServer: Void? = await sourceKitLSPServer?.filesDidChange(events) + async let updateSyntacticIndex: Void = await syntacticIndex.filesDidChange(events, self) async let updateSemanticIndex: Void? = await semanticIndexManager?.filesDidChange(events) - _ = await (updateServer, updateSemanticIndex) + _ = await (updateSyntacticIndex, updateSemanticIndex) } /// The language services that can handle the given document. Callers should try to merge the results from the @@ -463,26 +481,12 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { await sourceKitLSPServer?.fileHandlingCapabilityChanged() await semanticIndexManager?.buildTargetsChanged(changedTargets) await orLog("Scheduling syntactic file re-indexing") { - _ = await buildTargetListeners.value.values.asyncMap { - await $0.buildTargetsChanged(changedTargets) - } + await syntacticIndex.buildTargetsChanged(changedTargets, self) } await scheduleUpdateOfUnitOutputPathsInIndexIfNecessary() } - package func addBuiltTargetListener(_ listener: any BuildTargetListener) { - buildTargetListeners.withLock { - $0[ObjectIdentifier(listener)] = listener - } - } - - package func removeBuiltTargetListener(_ listener: any BuildTargetListener) { - buildTargetListeners.withLock { - $0[ObjectIdentifier(listener)] = nil - } - } - private func scheduleUpdateOfUnitOutputPathsInIndexIfNecessary() async { indexUnitOutputPathsUpdateQueue.async { guard await self.uncheckedIndex?.usesExplicitOutputPaths ?? false else { diff --git a/Sources/SwiftLanguageService/PlaygroundDiscovery.swift b/Sources/SwiftLanguageService/PlaygroundDiscovery.swift index 4d445a9fe..8329b3a14 100644 --- a/Sources/SwiftLanguageService/PlaygroundDiscovery.swift +++ b/Sources/SwiftLanguageService/PlaygroundDiscovery.swift @@ -13,24 +13,18 @@ import BuildServerIntegration @_spi(SourceKitLSP) import BuildServerProtocol import Foundation -@_spi(SourceKitLSP) import LanguageServerProtocol +@_spi(SourceKitLSP) package import LanguageServerProtocol @_spi(SourceKitLSP) import SKLogging import SemanticIndex -import SourceKitLSP +package import SourceKitLSP import SwiftExtensions -import ToolchainRegistry extension SwiftLanguageService { - static func syntacticPlaygrounds( + package func syntacticPlaygrounds( for snapshot: DocumentSnapshot, - in workspace: Workspace, - using syntaxTreeManager: SyntaxTreeManager, - toolchain: Toolchain + in workspace: Workspace ) async -> [TextDocumentPlayground] { - guard toolchain.swiftPlay != nil else { - return [] - } - return await SwiftPlaygroundsScanner.findDocumentPlaygrounds( + await SwiftPlaygroundsScanner.findDocumentPlaygrounds( for: snapshot, workspace: workspace, syntaxTreeManager: syntaxTreeManager diff --git a/Sources/SwiftLanguageService/SwiftLanguageService.swift b/Sources/SwiftLanguageService/SwiftLanguageService.swift index e9a207b29..ed9fc268c 100644 --- a/Sources/SwiftLanguageService/SwiftLanguageService.swift +++ b/Sources/SwiftLanguageService/SwiftLanguageService.swift @@ -139,9 +139,6 @@ package actor SwiftLanguageService: LanguageService, Sendable { /// Shared syntax tree manager to share syntax trees when syntactically parsing different types let syntaxTreeManager: SyntaxTreeManager - /// The index that syntactically scans the workspace. - let syntacticIndex: SwiftSyntacticIndex - /// Workspace this language service was created for let workspace: Workspace @@ -279,27 +276,6 @@ package actor SwiftLanguageService: LanguageService, Sendable { clientHasDiagnosticsCodeDescriptionSupport: await capabilityRegistry.clientHasDiagnosticsCodeDescriptionSupport ) - // Trigger an initial population of `syntacticIndex`. - self.syntacticIndex = SwiftSyntacticIndex( - determineFilesToScan: { targets in - await orLog("Getting list of files for syntactic index population") { - try await workspace.buildServerManager.projectSourceFiles(in: targets) - } ?? [] - }, - syntacticTests: { - await SwiftLanguageService.syntacticTestItems(for: $0, using: syntaxTreeManager) - }, - syntacticPlaygrounds: { - await SwiftLanguageService.syntacticPlaygrounds( - for: $0, - in: workspace, - using: syntaxTreeManager, - toolchain: toolchain - ) - } - ) - workspace.addBuiltTargetListener(syntacticIndex) - self.macroExpansionManager = MacroExpansionManager(swiftLanguageService: self) self.generatedInterfaceManager = GeneratedInterfaceManager(swiftLanguageService: self) @@ -397,10 +373,6 @@ package actor SwiftLanguageService: LanguageService, Sendable { ) { self.stateChangeHandlers.append(handler) } - - package func filesDidChange(_ events: [FileEvent]) async { - await syntacticIndex.filesDidChange(events) - } } extension SwiftLanguageService { @@ -458,7 +430,6 @@ extension SwiftLanguageService { } package func shutdown() async { - self.workspace.removeBuiltTargetListener(syntacticIndex) await self.sourcekitd.removeNotificationHandler(self) } @@ -1148,14 +1119,6 @@ extension SwiftLanguageService { ) } } - - package func syntacticTests(in workspace: Workspace) async -> [AnnotatedTestItem] { - await syntacticIndex.tests() - } - - package func syntacticPlaygrounds(in workspace: Workspace) async -> [Playground] { - await syntacticIndex.playgrounds() - } } extension SwiftLanguageService: SKDNotificationHandler { diff --git a/Sources/SwiftLanguageService/TestDiscovery.swift b/Sources/SwiftLanguageService/TestDiscovery.swift index b18333cce..f645349bd 100644 --- a/Sources/SwiftLanguageService/TestDiscovery.swift +++ b/Sources/SwiftLanguageService/TestDiscovery.swift @@ -52,9 +52,8 @@ extension SwiftLanguageService { /// 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 package func syntacticTestItems( + package func syntacticTestItems( for snapshot: DocumentSnapshot, - using syntaxTreeManager: SyntaxTreeManager ) async -> [AnnotatedTestItem] { async let swiftTestingTests = SyntacticSwiftTestingTestScanner.findTestSymbols( in: snapshot, From e82d009948d0c7dfafc921a635d2d12e99e8ff35 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Fri, 28 Nov 2025 08:26:28 -0500 Subject: [PATCH 05/18] Only advertise workspace/playgrounds capability if swift-play is in the preferred toolchain --- Sources/SourceKitLSP/SourceKitLSPServer.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index eac5f43e1..31f54cc21 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -1120,8 +1120,10 @@ extension SourceKitLSPServer { TriggerReindexRequest.method: .dictionary(["version": .int(1)]), GetReferenceDocumentRequest.method: .dictionary(["version": .int(1)]), DidChangeActiveDocumentNotification.method: .dictionary(["version": .int(1)]), - WorkspacePlaygroundsRequest.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( From 43b9cc5cd270ee8fe16ef421ebf6dbd847ab92c1 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Fri, 28 Nov 2025 14:36:14 -0500 Subject: [PATCH 06/18] Add tests --- .../SKTestSupport/MultiFileTestProject.swift | 2 + .../SKTestSupport/SwiftPMTestProject.swift | 2 + .../TestSourceKitLSPClient.swift | 4 +- Tests/SourceKitLSPTests/CodeLensTests.swift | 47 ---- .../WorkspacePlaygroundDiscoveryTests.swift | 232 ++++++++++++++++++ 5 files changed, 239 insertions(+), 48 deletions(-) create mode 100644 Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift diff --git a/Sources/SKTestSupport/MultiFileTestProject.swift b/Sources/SKTestSupport/MultiFileTestProject.swift index 909d177e8..7620da211 100644 --- a/Sources/SKTestSupport/MultiFileTestProject.swift +++ b/Sources/SKTestSupport/MultiFileTestProject.swift @@ -139,6 +139,7 @@ package class MultiFileTestProject { enableBackgroundIndexing: Bool = false, usePullDiagnostics: Bool = true, preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, + postInitialization: (@Sendable (InitializeResult) -> Void)? = nil, testScratchDir overrideTestScratchDir: URL? = nil, cleanUp: (@Sendable () -> Void)? = nil, testName: String = #function @@ -156,6 +157,7 @@ package class MultiFileTestProject { enableBackgroundIndexing: enableBackgroundIndexing, workspaceFolders: workspaces(scratchDirectory), preInitialization: preInitialization, + postInitialization: postInitialization, cleanUp: { [scratchDirectory] in if cleanScratchDirectories { try? FileManager.default.removeItem(at: scratchDirectory) diff --git a/Sources/SKTestSupport/SwiftPMTestProject.swift b/Sources/SKTestSupport/SwiftPMTestProject.swift index 3d9135100..da91c48e5 100644 --- a/Sources/SKTestSupport/SwiftPMTestProject.swift +++ b/Sources/SKTestSupport/SwiftPMTestProject.swift @@ -190,6 +190,7 @@ package class SwiftPMTestProject: MultiFileTestProject { usePullDiagnostics: Bool = true, pollIndex: Bool = true, preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, + postInitialization: (@Sendable (InitializeResult) -> Void)? = nil, cleanUp: (@Sendable () -> Void)? = nil, testName: String = #function ) async throws { @@ -231,6 +232,7 @@ package class SwiftPMTestProject: MultiFileTestProject { enableBackgroundIndexing: enableBackgroundIndexing, usePullDiagnostics: usePullDiagnostics, preInitialization: preInitialization, + postInitialization: postInitialization, cleanUp: cleanUp, testName: testName ) diff --git a/Sources/SKTestSupport/TestSourceKitLSPClient.swift b/Sources/SKTestSupport/TestSourceKitLSPClient.swift index 64134d3e3..95d874db6 100644 --- a/Sources/SKTestSupport/TestSourceKitLSPClient.swift +++ b/Sources/SKTestSupport/TestSourceKitLSPClient.swift @@ -142,6 +142,7 @@ package final class TestSourceKitLSPClient: MessageHandler, Sendable { enableBackgroundIndexing: Bool = false, workspaceFolders: [WorkspaceFolder]? = nil, preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, + postInitialization: (@Sendable (InitializeResult) -> Void)? = nil, cleanUp: @Sendable @escaping () -> Void = {} ) async throws { var options = @@ -201,7 +202,7 @@ package final class TestSourceKitLSPClient: MessageHandler, Sendable { if initialize { let capabilities = capabilities try await withTimeout(defaultTimeoutDuration) { - _ = try await self.send( + let initializeResult = try await self.send( InitializeRequest( processId: nil, rootPath: nil, @@ -212,6 +213,7 @@ package final class TestSourceKitLSPClient: MessageHandler, Sendable { workspaceFolders: workspaceFolders ) ) + postInitialization?(initializeResult) } } } 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..256ec81b0 --- /dev/null +++ b/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift @@ -0,0 +1,232 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2023 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 +// +//===----------------------------------------------------------------------===// + +@_spi(Testing) import BuildServerIntegration +import Foundation +@_spi(SourceKitLSP) import LanguageServerProtocol +import SKLogging +import SKTestSupport +import SemanticIndex +@_spi(Testing) import SourceKitLSP +import SwiftExtensions +import ToolchainRegistry +@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions +import XCTest + +import struct TSCBasic.AbsolutePath + +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 { + + private var workspaceFiles: [RelativeFileLocation: String] = [ + "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 + + 1️⃣#Playground("bar2") { + print(foo()) + }2️⃣ + """, + "Sources/MyApp/baz.swift": """ + import Playgrounds + + 1️⃣#Playground("baz") { + print("baz") + }2️⃣ + """, + ] + + private let packageManifestWithTestTarget = """ + let package = Package( + name: "MyLibrary", + targets: [.target(name: "MyLibrary"), .target(name: "MyApp")] + ) + """ + + func testWorkspacePlaygroundsScanned() async throws { + let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithSwiftPlay]) + let project = try await SwiftPMTestProject( + files: workspaceFiles, + manifest: packageManifestWithTestTarget, + toolchainRegistry: toolchainRegistry + ) + + let response = try await project.testClient.send( + WorkspacePlaygroundsRequest() + ) + + let (testUri, testPositions) = try project.openDocument("Test.swift") + let (barUri, barPositions) = try project.openDocument("bar.swift") + let (bazUri, bazPositions) = try project.openDocument("baz.swift") + + // Notice sorted order + XCTAssertEqual( + response, + [ + Playground( + id: "MyApp/baz.swift:3:2", + label: "baz", + location: .init(uri: bazUri, range: bazPositions["1️⃣"]..(initialValue: nil) + let _ = try await SwiftPMTestProject( + files: workspaceFiles, + manifest: packageManifestWithTestTarget, + toolchainRegistry: toolchainRegistry, + postInitialization: { result in + initializeResult.withLock { + $0 = result + } + } + ) + + switch initializeResult.value?.capabilities.experimental { + case .dictionary(let dict): + XCTAssertNotEqual(dict[WorkspacePlaygroundsRequest.method], nil) + default: + XCTFail("Experminental capabilities is not a dictionary") + } + } + + func testWorkspacePlaygroundsCapabilityNoSwiftPlay() async throws { + let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithoutSwiftPlay]) + let initializeResult = ThreadSafeBox(initialValue: nil) + let _ = try await SwiftPMTestProject( + files: workspaceFiles, + manifest: packageManifestWithTestTarget, + toolchainRegistry: toolchainRegistry, + postInitialization: { result in + initializeResult.withLock { + $0 = result + } + } + ) + + switch initializeResult.value?.capabilities.experimental { + case .dictionary(let dict): + XCTAssertEqual(dict[WorkspacePlaygroundsRequest.method], nil) + default: + XCTFail("Experminental capabilities is not a dictionary") + } + } +} From d810844265293ddee672ddf632cc526cf1ec7ee9 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Fri, 28 Nov 2025 14:39:52 -0500 Subject: [PATCH 07/18] Fix CMake files --- Sources/SourceKitLSP/CMakeLists.txt | 1 + Sources/SwiftLanguageService/CMakeLists.txt | 1 - .../WorkspacePlaygroundDiscoveryTests.swift | 9 +-------- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index a4031dcfc..e08d5c810 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -23,6 +23,7 @@ add_library(SourceKitLSP STATIC SourceKitLSPCommandMetadata.swift SourceKitLSPServer.swift SymbolLocation+DocumentURI.swift + SwiftSyntacticIndex.swift TestDiscovery.swift TextEdit+IsNoop.swift Workspace.swift diff --git a/Sources/SwiftLanguageService/CMakeLists.txt b/Sources/SwiftLanguageService/CMakeLists.txt index 69c46b9bb..65408ac7e 100644 --- a/Sources/SwiftLanguageService/CMakeLists.txt +++ b/Sources/SwiftLanguageService/CMakeLists.txt @@ -39,7 +39,6 @@ add_library(SwiftLanguageService STATIC SwiftCodeLensScanner.swift SwiftCommand.swift SwiftLanguageService.swift - SwiftSyntacticIndex.swift SwiftTestingScanner.swift SymbolGraph.swift SymbolInfo.swift diff --git a/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift b/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift index 256ec81b0..fb1d25d6e 100644 --- a/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift +++ b/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift.org open source project // -// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors +// 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 @@ -10,20 +10,13 @@ // //===----------------------------------------------------------------------===// -@_spi(Testing) import BuildServerIntegration import Foundation @_spi(SourceKitLSP) import LanguageServerProtocol -import SKLogging import SKTestSupport -import SemanticIndex -@_spi(Testing) import SourceKitLSP import SwiftExtensions import ToolchainRegistry -@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions import XCTest -import struct TSCBasic.AbsolutePath - extension Toolchain { #if compiler(>=6.4) #warning( From 612c78177db5a53145d59994344d90e813f9b1b7 Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Mon, 1 Dec 2025 23:29:40 +0100 Subject: [PATCH 08/18] Address my own review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the following changes: - Check for the presence of `#Playgrounds` textually before getting the module name in `SwiftPlaygroundsScanner`. This is important because getting the module name requires us to get build settings for the file, which can be expensive. Do the cheaper check first - Make `syntacticTests` and `syntacticPlaygrounds` closures capture the workspace instead of passing the workspace from the `SwiftSyntacticIndex` back out. I like this better because now we can’t accidentally pass the wrong workspace to a `SwiftSyntacticIndex`, eg. to `buildTargetsChanges`. - Capture the initialize result in `TestSourceKitLSPClient` instead of using `postInitialization` to capture the result - Minor cleanup of unnecessary abstractions, likely artifacts of earlier iterations - Restructure tests so that every test has its own list of source files, allowing for easier local reasoning – turns out some of these tests didn’t even need to open a workspace, just to check the initialize response --- .../SKTestSupport/MultiFileTestProject.swift | 2 - .../SKTestSupport/SwiftPMTestProject.swift | 6 +- .../TestSourceKitLSPClient.swift | 11 +- Sources/SourceKitLSP/SourceKitLSPServer.swift | 6 +- .../SourceKitLSP/SwiftSyntacticIndex.swift | 33 ++-- Sources/SourceKitLSP/Workspace.swift | 30 ++- .../SwiftLanguageService.swift | 9 +- .../SwiftPlaygroundsScanner.swift | 7 +- .../SyntaxTreeManager.swift | 2 +- .../WorkspacePlaygroundDiscoveryTests.swift | 177 ++++++++---------- 10 files changed, 127 insertions(+), 156 deletions(-) diff --git a/Sources/SKTestSupport/MultiFileTestProject.swift b/Sources/SKTestSupport/MultiFileTestProject.swift index 7620da211..909d177e8 100644 --- a/Sources/SKTestSupport/MultiFileTestProject.swift +++ b/Sources/SKTestSupport/MultiFileTestProject.swift @@ -139,7 +139,6 @@ package class MultiFileTestProject { enableBackgroundIndexing: Bool = false, usePullDiagnostics: Bool = true, preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, - postInitialization: (@Sendable (InitializeResult) -> Void)? = nil, testScratchDir overrideTestScratchDir: URL? = nil, cleanUp: (@Sendable () -> Void)? = nil, testName: String = #function @@ -157,7 +156,6 @@ package class MultiFileTestProject { enableBackgroundIndexing: enableBackgroundIndexing, workspaceFolders: workspaces(scratchDirectory), preInitialization: preInitialization, - postInitialization: postInitialization, cleanUp: { [scratchDirectory] in if cleanScratchDirectories { try? FileManager.default.removeItem(at: scratchDirectory) diff --git a/Sources/SKTestSupport/SwiftPMTestProject.swift b/Sources/SKTestSupport/SwiftPMTestProject.swift index da91c48e5..a77ab26f6 100644 --- a/Sources/SKTestSupport/SwiftPMTestProject.swift +++ b/Sources/SKTestSupport/SwiftPMTestProject.swift @@ -190,7 +190,6 @@ package class SwiftPMTestProject: MultiFileTestProject { usePullDiagnostics: Bool = true, pollIndex: Bool = true, preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, - postInitialization: (@Sendable (InitializeResult) -> Void)? = nil, cleanUp: (@Sendable () -> Void)? = nil, testName: String = #function ) async throws { @@ -232,7 +231,6 @@ package class SwiftPMTestProject: MultiFileTestProject { enableBackgroundIndexing: enableBackgroundIndexing, usePullDiagnostics: usePullDiagnostics, preInitialization: preInitialization, - postInitialization: postInitialization, cleanUp: cleanUp, testName: testName ) @@ -268,7 +266,7 @@ package class SwiftPMTestProject: MultiFileTestProject { } logger.debug( """ - 'swift build' output: + 'swift build' output: \(output) """ ) @@ -290,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 95d874db6..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 @@ -142,7 +147,6 @@ package final class TestSourceKitLSPClient: MessageHandler, Sendable { enableBackgroundIndexing: Bool = false, workspaceFolders: [WorkspaceFolder]? = nil, preInitialization: ((TestSourceKitLSPClient) -> Void)? = nil, - postInitialization: (@Sendable (InitializeResult) -> Void)? = nil, cleanUp: @Sendable @escaping () -> Void = {} ) async throws { var options = @@ -201,8 +205,8 @@ package final class TestSourceKitLSPClient: MessageHandler, Sendable { preInitialization?(self) if initialize { let capabilities = capabilities - try await withTimeout(defaultTimeoutDuration) { - let initializeResult = try await self.send( + self.initializeResult = try await withTimeout(defaultTimeoutDuration) { + try await self.send( InitializeRequest( processId: nil, rootPath: nil, @@ -213,7 +217,6 @@ package final class TestSourceKitLSPClient: MessageHandler, Sendable { workspaceFolders: workspaceFolders ) ) - postInitialization?(initializeResult) } } } diff --git a/Sources/SourceKitLSP/SourceKitLSPServer.swift b/Sources/SourceKitLSP/SourceKitLSPServer.swift index 31f54cc21..f0d410534 100644 --- a/Sources/SourceKitLSP/SourceKitLSPServer.swift +++ b/Sources/SourceKitLSP/SourceKitLSPServer.swift @@ -1536,12 +1536,8 @@ 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(events) + await languageService.filesDidChange(notification.changes) } } diff --git a/Sources/SourceKitLSP/SwiftSyntacticIndex.swift b/Sources/SourceKitLSP/SwiftSyntacticIndex.swift index 134f6a1a6..6bbe06a7b 100644 --- a/Sources/SourceKitLSP/SwiftSyntacticIndex.swift +++ b/Sources/SourceKitLSP/SwiftSyntacticIndex.swift @@ -105,22 +105,20 @@ package actor SwiftSyntacticIndex: Sendable { private let determineFilesToScan: @Sendable (Set?) async -> [DocumentURI] /// Syntactically parse tests from the given snapshot - private let syntacticTests: @Sendable (DocumentSnapshot, Workspace) async -> [AnnotatedTestItem] + private let syntacticTests: @Sendable (DocumentSnapshot) async -> [AnnotatedTestItem] /// Syntactically parse playgrounds from the given snapshot - private let syntacticPlaygrounds: @Sendable (DocumentSnapshot, Workspace) async -> [TextDocumentPlayground] + private let syntacticPlaygrounds: @Sendable (DocumentSnapshot) async -> [TextDocumentPlayground] package init( determineFilesToScan: @Sendable @escaping (Set?) async -> [DocumentURI], - syntacticTests: @Sendable @escaping (DocumentSnapshot, Workspace) async -> [AnnotatedTestItem], - syntacticPlaygrounds: @Sendable @escaping (DocumentSnapshot, Workspace) async -> [TextDocumentPlayground] + syntacticTests: @Sendable @escaping (DocumentSnapshot) async -> [AnnotatedTestItem], + syntacticPlaygrounds: @Sendable @escaping (DocumentSnapshot) async -> [TextDocumentPlayground] ) { 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 @@ -131,7 +129,7 @@ package actor SwiftSyntacticIndex: Sendable { let batches = filesToScan.partition(intoNumberOfBatches: ProcessInfo.processInfo.activeProcessorCount * 4) await batches.concurrentForEach { filesInBatch in for uri in filesInBatch { - await self.rescanFileAssumingOnQueue(uri, workspace) + await self.rescanFileAssumingOnQueue(uri) } } } @@ -147,15 +145,15 @@ package actor SwiftSyntacticIndex: Sendable { /// Called when the list of targets is updated. /// /// 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 { + package func buildTargetsChanged(_ changedTargets: Set?) async { let changedFiles = await determineFilesToScan(changedTargets) let removedFiles = Set(self.indexedSources.keys).subtracting(changedFiles) removeFilesFromIndex(removedFiles) - rescanFiles(changedFiles, workspace) + rescanFiles(changedFiles) } - package func filesDidChange(_ events: [FileEvent], _ workspace: Workspace) { + package func filesDidChange(_ events: [FileEvent]) { var removedFiles: Set = [] var filesToRescan: [DocumentURI] = [] for fileEvent in events { @@ -171,11 +169,11 @@ package actor SwiftSyntacticIndex: Sendable { } } removeFilesFromIndex(removedFiles) - rescanFiles(filesToRescan, workspace) + rescanFiles(filesToRescan) } /// Called when a list of files was updated. Re-scans those files - private func rescanFiles(_ uris: [DocumentURI], _ workspace: Workspace) { + private func rescanFiles(_ uris: [DocumentURI]) { // 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) @@ -215,7 +213,7 @@ package actor SwiftSyntacticIndex: Sendable { for batch in batches { self.indexingQueue.async(priority: .low, metadata: .index(Set(batch))) { for uri in batch { - await self.rescanFileAssumingOnQueue(uri, workspace) + await self.rescanFileAssumingOnQueue(uri) } } } @@ -224,7 +222,7 @@ package actor SwiftSyntacticIndex: Sendable { /// Re-scans a single file. /// /// - Important: This method must be called in a task that is executing on `indexingQueue`. - private func rescanFileAssumingOnQueue(_ uri: DocumentURI, _ workspace: Workspace) async { + private func rescanFileAssumingOnQueue(_ uri: DocumentURI) async { guard let url = uri.fileURL else { logger.log("Not indexing \(uri.forLogging) because it is not a file URL") return @@ -264,16 +262,15 @@ package actor SwiftSyntacticIndex: Sendable { return } - let (testItems, playgrounds) = await ( - syntacticTests(snapshot, workspace), syntacticPlaygrounds(snapshot, workspace) - ) + async let testItems = syntacticTests(snapshot) + async let playgrounds = syntacticPlaygrounds(snapshot) 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 // `indexedSources`. return } - self.indexedSources[uri] = IndexedSourceFile( + self.indexedSources[uri] = await IndexedSourceFile( tests: testItems, playgrounds: playgrounds, sourceFileModificationDate: fileModificationDate diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index d7cda3e05..74fd9506d 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -185,7 +185,14 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { } /// The index that syntactically scans the workspace for Swift symbols. - let syntacticIndex: SwiftSyntacticIndex + /// + /// Force-unwrapped optional because initializing it requires access to `self`. + private(set) nonisolated(unsafe) var syntacticIndex: SwiftSyntacticIndex! { + didSet { + precondition(oldValue == nil) + precondition(syntacticIndex != nil) + } + } /// Language service for an open document, if available. private let languageServices: ThreadSafeBox<[DocumentURI: [LanguageService]]> = ThreadSafeBox(initialValue: [:]) @@ -266,18 +273,23 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { try await buildServerManager.projectSourceFiles(in: targets) } ?? [] }, - syntacticTests: { (snapshot, workspace) in - await sourceKitLSPServer.languageServices(for: snapshot.uri, snapshot.language, in: workspace).asyncFlatMap { + 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: { (snapshot, workspace) in - await sourceKitLSPServer.languageServices(for: snapshot.uri, snapshot.language, in: workspace).asyncFlatMap { - await $0.syntacticPlaygrounds(for: snapshot, in: workspace) + 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) } } ) - await syntacticIndex.scan(workspace: self) } /// Creates a workspace for a given root `DocumentURI`, inferring the `ExternalWorkspace` if possible. @@ -416,7 +428,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 syntacticIndex.filesDidChange(events, self) + async let updateSyntacticIndex: Void = await syntacticIndex.filesDidChange(events) async let updateSemanticIndex: Void? = await semanticIndexManager?.filesDidChange(events) _ = await (updateSyntacticIndex, updateSemanticIndex) } @@ -481,7 +493,7 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { await sourceKitLSPServer?.fileHandlingCapabilityChanged() await semanticIndexManager?.buildTargetsChanged(changedTargets) await orLog("Scheduling syntactic file re-indexing") { - await syntacticIndex.buildTargetsChanged(changedTargets, self) + await syntacticIndex.buildTargetsChanged(changedTargets) } await scheduleUpdateOfUnitOutputPathsInIndexIfNecessary() diff --git a/Sources/SwiftLanguageService/SwiftLanguageService.swift b/Sources/SwiftLanguageService/SwiftLanguageService.swift index ed9fc268c..b163143ac 100644 --- a/Sources/SwiftLanguageService/SwiftLanguageService.swift +++ b/Sources/SwiftLanguageService/SwiftLanguageService.swift @@ -136,11 +136,7 @@ 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] = [:] - /// 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 + let syntaxTreeManager = SyntaxTreeManager() /// The `semanticIndexManager` of the workspace this language service was created for. private let semanticIndexManagerTask: Task @@ -214,12 +210,9 @@ 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 e618924ed..22773f459 100644 --- a/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift +++ b/Sources/SwiftLanguageService/SwiftPlaygroundsScanner.swift @@ -45,6 +45,10 @@ final class SwiftPlaygroundsScanner: SyntaxVisitor { workspace: Workspace, 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 @@ -52,9 +56,6 @@ final class SwiftPlaygroundsScanner: SyntaxVisitor { return [] } - guard snapshot.text.contains("#Playground") else { - return [] - } let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot) let visitor = SwiftPlaygroundsScanner(baseID: "\(moduleName)/\(baseName)", snapshot: snapshot) diff --git a/Sources/SwiftLanguageService/SyntaxTreeManager.swift b/Sources/SwiftLanguageService/SyntaxTreeManager.swift index a7ef71bb3..2eb3dba46 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. -package actor SyntaxTreeManager { +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/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift b/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift index fb1d25d6e..dc925e9ec 100644 --- a/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift +++ b/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift @@ -66,85 +66,78 @@ extension Toolchain { final class WorkspacePlaygroundDiscoveryTests: SourceKitLSPTestCase { - private var workspaceFiles: [RelativeFileLocation: String] = [ - "Sources/MyLibrary/Test.swift": """ - import Playgrounds - - public func foo() -> String { - "bar" - } + func testWorkspacePlaygroundsScanned() async throws { + let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithSwiftPlay]) + let project = try await SwiftPMTestProject( + files: [ + "Sources/MyLibrary/Test.swift": """ + import Playgrounds - 1️⃣#Playground("foo") { - print(foo()) - }2️⃣ + public func foo() -> String { + "bar" + } - 3️⃣#Playground { - print(foo()) - }4️⃣ + 1️⃣#Playground("foo") { + print(foo()) + }2️⃣ - public func bar(_ i: Int, _ j: Int) -> Int { - i + j - } + 3️⃣#Playground { + print(foo()) + }4️⃣ - 5️⃣#Playground("bar") { - var i = bar(1, 2) - i = i + 1 - print(i) - }6️⃣ - """, - "Sources/MyLibrary/TestNoImport.swift": """ - #Playground("fooNoImport") { - print(foo()) - } + public func bar(_ i: Int, _ j: Int) -> Int { + i + j + } - #Playground { - print(foo()) - } + 5️⃣#Playground("bar") { + var i = bar(1, 2) + i = i + 1 + print(i) + }6️⃣ + """, + "Sources/MyLibrary/TestNoImport.swift": """ + #Playground("fooNoImport") { + print(foo()) + } - #Playground("barNoImport") { - var i = bar(1, 2) - i = i + 1 - print(i) - } - """, - "Sources/MyLibrary/bar.swift": """ - import Playgrounds - - 1️⃣#Playground("bar2") { - print(foo()) - }2️⃣ - """, - "Sources/MyApp/baz.swift": """ - import Playgrounds - - 1️⃣#Playground("baz") { - print("baz") - }2️⃣ - """, - ] - - private let packageManifestWithTestTarget = """ - let package = Package( - name: "MyLibrary", - targets: [.target(name: "MyLibrary"), .target(name: "MyApp")] - ) - """ + #Playground { + print(foo()) + } - func testWorkspacePlaygroundsScanned() async throws { - let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithSwiftPlay]) - let project = try await SwiftPMTestProject( - files: workspaceFiles, - manifest: packageManifestWithTestTarget, + #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() - ) - - let (testUri, testPositions) = try project.openDocument("Test.swift") - let (barUri, barPositions) = try project.openDocument("bar.swift") - let (bazUri, bazPositions) = try project.openDocument("baz.swift") + let response = try await project.testClient.send(WorkspacePlaygroundsRequest()) // Notice sorted order XCTAssertEqual( @@ -153,27 +146,27 @@ final class WorkspacePlaygroundDiscoveryTests: SourceKitLSPTestCase { Playground( id: "MyApp/baz.swift:3:2", label: "baz", - location: .init(uri: bazUri, range: bazPositions["1️⃣"]..(initialValue: nil) - let _ = try await SwiftPMTestProject( - files: workspaceFiles, - manifest: packageManifestWithTestTarget, - toolchainRegistry: toolchainRegistry, - postInitialization: { result in - initializeResult.withLock { - $0 = result - } - } - ) - - switch initializeResult.value?.capabilities.experimental { + 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("Experminental capabilities is not a dictionary") + 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 initializeResult = ThreadSafeBox(initialValue: nil) - let _ = try await SwiftPMTestProject( - files: workspaceFiles, - manifest: packageManifestWithTestTarget, - toolchainRegistry: toolchainRegistry, - postInitialization: { result in - initializeResult.withLock { - $0 = result - } - } - ) - - switch initializeResult.value?.capabilities.experimental { + 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("Experminental capabilities is not a dictionary") + XCTFail("Experimental capabilities expected to be a dictionary, got \(experimentalCapabilities as Any)") } } } From 8d0d40123df487dc94e4ab2f200d1e466be13916 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Tue, 2 Dec 2025 09:38:58 -0500 Subject: [PATCH 09/18] Don't always assume Swift language when scanning --- Sources/SourceKitLSP/SwiftSyntacticIndex.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/SourceKitLSP/SwiftSyntacticIndex.swift b/Sources/SourceKitLSP/SwiftSyntacticIndex.swift index 6bbe06a7b..47ae15a85 100644 --- a/Sources/SourceKitLSP/SwiftSyntacticIndex.swift +++ b/Sources/SourceKitLSP/SwiftSyntacticIndex.swift @@ -223,6 +223,10 @@ package actor SwiftSyntacticIndex: Sendable { /// /// - Important: This method must be called in a task that is executing on `indexingQueue`. private func rescanFileAssumingOnQueue(_ uri: DocumentURI) async { + guard let language = Language(inferredFromFileExtension: uri) else { + return + } + guard let url = uri.fileURL else { logger.log("Not indexing \(uri.forLogging) because it is not a file URL") return @@ -255,8 +259,8 @@ package actor SwiftSyntacticIndex: Sendable { return } - let snapshot: DocumentSnapshot? = orLog("Getting document snapshot for syntactic Swift scanning") { - try DocumentSnapshot(withContentsFromDisk: url, language: .swift) + let snapshot: DocumentSnapshot? = orLog("Getting document snapshot for syntactic scanning") { + try DocumentSnapshot(withContentsFromDisk: url, language: language) } guard let snapshot else { return From bd0afd66b8b1b297f7a67d3cfe52e65b05c4fbeb Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Tue, 2 Dec 2025 09:39:56 -0500 Subject: [PATCH 10/18] Rename to SyntacticIndex to acknowledge any language code be scanned --- Sources/SourceKitLSP/CMakeLists.txt | 2 +- .../{SwiftSyntacticIndex.swift => SyntacticIndex.swift} | 8 ++++---- Sources/SourceKitLSP/Workspace.swift | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) rename Sources/SourceKitLSP/{SwiftSyntacticIndex.swift => SyntacticIndex.swift} (98%) diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index e08d5c810..f4c25ecc3 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -23,7 +23,7 @@ add_library(SourceKitLSP STATIC SourceKitLSPCommandMetadata.swift SourceKitLSPServer.swift SymbolLocation+DocumentURI.swift - SwiftSyntacticIndex.swift + SyntacticIndex.swift TestDiscovery.swift TextEdit+IsNoop.swift Workspace.swift diff --git a/Sources/SourceKitLSP/SwiftSyntacticIndex.swift b/Sources/SourceKitLSP/SyntacticIndex.swift similarity index 98% rename from Sources/SourceKitLSP/SwiftSyntacticIndex.swift rename to Sources/SourceKitLSP/SyntacticIndex.swift index 47ae15a85..d64bd0126 100644 --- a/Sources/SourceKitLSP/SwiftSyntacticIndex.swift +++ b/Sources/SourceKitLSP/SyntacticIndex.swift @@ -19,10 +19,10 @@ import Foundation import SwiftExtensions @_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions -/// Task metadata for `SwiftSyntacticIndex.indexingQueue` +/// Task metadata for `SyntacticIndex.indexingQueue` private enum TaskMetadata: DependencyTracker, Equatable { /// Determine the list of files from the build server and scan them for tests / playgrounds. Only created when the - /// `SwiftSyntacticIndex` is created + /// `SyntacticIndex` is created case initialPopulation /// Index the files in the given set for tests / playgrounds @@ -83,7 +83,7 @@ private struct IndexedSourceFile { /// /// 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. -package actor SwiftSyntacticIndex: Sendable { +package actor SyntacticIndex: Sendable { /// The tests discovered by the index. private var indexedSources: [DocumentURI: IndexedSourceFile] = [:] @@ -165,7 +165,7 @@ package actor SwiftSyntacticIndex: Sendable { case .deleted: removedFiles.insert(fileEvent.uri) default: - logger.error("Ignoring unknown FileEvent type \(fileEvent.type.rawValue) in SwiftSyntacticIndex") + logger.error("Ignoring unknown FileEvent type \(fileEvent.type.rawValue) in SyntacticIndex") } } removeFilesFromIndex(removedFiles) diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index 74fd9506d..e3f1771d7 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -187,7 +187,7 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { /// 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: SwiftSyntacticIndex! { + private(set) nonisolated(unsafe) var syntacticIndex: SyntacticIndex! { didSet { precondition(oldValue == nil) precondition(syntacticIndex != nil) @@ -267,7 +267,7 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { } } // Trigger an initial population of `syntacticIndex`. - self.syntacticIndex = SwiftSyntacticIndex( + self.syntacticIndex = SyntacticIndex( determineFilesToScan: { targets in await orLog("Getting list of files for syntactic index population") { try await buildServerManager.projectSourceFiles(in: targets) From 34875f272be16e63ddcb95867305ce7426f6c665 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Tue, 2 Dec 2025 17:40:44 -0500 Subject: [PATCH 11/18] Fix some failing tests --- Sources/SourceKitLSP/SyntacticIndex.swift | 8 +++++--- Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/SourceKitLSP/SyntacticIndex.swift b/Sources/SourceKitLSP/SyntacticIndex.swift index d64bd0126..ba2f0c361 100644 --- a/Sources/SourceKitLSP/SyntacticIndex.swift +++ b/Sources/SourceKitLSP/SyntacticIndex.swift @@ -266,15 +266,17 @@ package actor SyntacticIndex: Sendable { return } - async let testItems = syntacticTests(snapshot) - async let playgrounds = syntacticPlaygrounds(snapshot) + let (testItems, playgrounds) = await ( + syntacticTests(snapshot), syntacticPlaygrounds(snapshot) + ) 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 // `indexedSources`. return } - self.indexedSources[uri] = await IndexedSourceFile( + + self.indexedSources[uri] = IndexedSourceFile( tests: testItems, playgrounds: playgrounds, sourceFileModificationDate: fileModificationDate diff --git a/Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift b/Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift index cf461eab7..89f2046f1 100644 --- a/Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift +++ b/Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift @@ -1110,10 +1110,10 @@ final class WorkspaceTestDiscoveryTests: SourceKitLSPTestCase { ) } - func testSwiftTestingTestsAreNotDiscoveredInNonTestTargets() async throws { + func testSwiftTestingTestsAreNotDiscoveredInNonRootFiles() async throws { let project = try await SwiftPMTestProject( files: [ - "FileA.swift": """ + "/not/root/FileA.swift": """ @Suite struct MyTests { @Test func inStruct() {} } From 4dc48e96d7b259e53019952e7b3ccaff4a0f5b6f Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Wed, 3 Dec 2025 14:11:08 -0500 Subject: [PATCH 12/18] Only scan tests if part of a test target --- .../BuildServerManager.swift | 10 ++-- Sources/SourceKitLSP/SyntacticIndex.swift | 52 +++++++++++-------- Sources/SourceKitLSP/Workspace.swift | 13 ++++- .../WorkspaceTestDiscoveryTests.swift | 4 +- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/Sources/BuildServerIntegration/BuildServerManager.swift b/Sources/BuildServerIntegration/BuildServerManager.swift index 0e8c4be03..6db2ea17b 100644 --- a/Sources/BuildServerIntegration/BuildServerManager.swift +++ b/Sources/BuildServerIntegration/BuildServerManager.swift @@ -1571,16 +1571,18 @@ package actor BuildServerManager: QueueBasedMessageHandler { /// /// - 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 + 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 nil + return false } - return uri + return true } } diff --git a/Sources/SourceKitLSP/SyntacticIndex.swift b/Sources/SourceKitLSP/SyntacticIndex.swift index ba2f0c361..fbaebf994 100644 --- a/Sources/SourceKitLSP/SyntacticIndex.swift +++ b/Sources/SourceKitLSP/SyntacticIndex.swift @@ -10,7 +10,7 @@ // //===----------------------------------------------------------------------===// -@_spi(SourceKitLSP) import BuildServerIntegration +@_spi(SourceKitLSP) package import BuildServerIntegration @_spi(SourceKitLSP) package import BuildServerProtocol import Foundation @_spi(SourceKitLSP) package import LanguageServerProtocol @@ -102,7 +102,7 @@ package actor SyntacticIndex: Sendable { private let indexingQueue = AsyncQueue() /// Fetch the list of source files to scan for a given set of build targets - private let determineFilesToScan: @Sendable (Set?) async -> [DocumentURI] + private let determineFilesToScan: @Sendable (Set?) async -> [DocumentURI: SourceFileInfo] /// Syntactically parse tests from the given snapshot private let syntacticTests: @Sendable (DocumentSnapshot) async -> [AnnotatedTestItem] @@ -111,7 +111,7 @@ package actor SyntacticIndex: Sendable { private let syntacticPlaygrounds: @Sendable (DocumentSnapshot) async -> [TextDocumentPlayground] package init( - determineFilesToScan: @Sendable @escaping (Set?) async -> [DocumentURI], + determineFilesToScan: @Sendable @escaping (Set?) async -> [DocumentURI: SourceFileInfo], syntacticTests: @Sendable @escaping (DocumentSnapshot) async -> [AnnotatedTestItem], syntacticPlaygrounds: @Sendable @escaping (DocumentSnapshot) async -> [TextDocumentPlayground] ) { @@ -126,10 +126,14 @@ package actor SyntacticIndex: Sendable { // 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 = filesToScan.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) } } } @@ -147,21 +151,21 @@ package actor SyntacticIndex: Sendable { /// 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) + let removedFiles = Set(self.indexedSources.keys).subtracting(changedFiles.keys) removeFilesFromIndex(removedFiles) rescanFiles(changedFiles) } - package 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: - filesToRescan.append(fileEvent.uri) + filesToRescan[fileEvent.uri] = sourceFileInfo case .changed: - filesToRescan.append(fileEvent.uri) + filesToRescan[fileEvent.uri] = sourceFileInfo case .deleted: removedFiles.insert(fileEvent.uri) default: @@ -173,10 +177,10 @@ package actor SyntacticIndex: Sendable { } /// 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` @@ -184,7 +188,7 @@ package actor SyntacticIndex: Sendable { // 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.indexedSources[uri]?.sourceFileModificationDate, let fileModificationDate = try? FileManager.default.attributesOfItem(atPath: url.filePath)[.modificationDate] @@ -196,12 +200,12 @@ package actor SyntacticIndex: Sendable { return true } - guard !uris.isEmpty else { + guard !filesToScan.isEmpty else { return } logger.info( - "Syntactically scanning \(uris.count) files: \(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 @@ -209,11 +213,15 @@ package actor SyntacticIndex: Sendable { // 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) } } } @@ -222,7 +230,7 @@ package actor SyntacticIndex: Sendable { /// 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 } @@ -266,9 +274,11 @@ package actor SyntacticIndex: Sendable { return } - let (testItems, playgrounds) = await ( - syntacticTests(snapshot), syntacticPlaygrounds(snapshot) - ) + 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 diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index e3f1771d7..3ef52b0ea 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -271,7 +271,7 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { 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 { @@ -428,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 syntacticIndex.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) } diff --git a/Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift b/Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift index 89f2046f1..cf461eab7 100644 --- a/Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift +++ b/Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift @@ -1110,10 +1110,10 @@ final class WorkspaceTestDiscoveryTests: SourceKitLSPTestCase { ) } - func testSwiftTestingTestsAreNotDiscoveredInNonRootFiles() async throws { + func testSwiftTestingTestsAreNotDiscoveredInNonTestTargets() async throws { let project = try await SwiftPMTestProject( files: [ - "/not/root/FileA.swift": """ + "FileA.swift": """ @Suite struct MyTests { @Test func inStruct() {} } From 02e95f3283ad88721e5e0000bbafc81aa0601996 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Thu, 4 Dec 2025 14:10:46 -0500 Subject: [PATCH 13/18] Don't reuse syntaxTreeManager --- Sources/SwiftLanguageService/PlaygroundDiscovery.swift | 5 ++++- Sources/SwiftLanguageService/TestDiscovery.swift | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftLanguageService/PlaygroundDiscovery.swift b/Sources/SwiftLanguageService/PlaygroundDiscovery.swift index 8329b3a14..5e68b3f3f 100644 --- a/Sources/SwiftLanguageService/PlaygroundDiscovery.swift +++ b/Sources/SwiftLanguageService/PlaygroundDiscovery.swift @@ -24,7 +24,10 @@ extension SwiftLanguageService { for snapshot: DocumentSnapshot, in workspace: Workspace ) async -> [TextDocumentPlayground] { - await SwiftPlaygroundsScanner.findDocumentPlaygrounds( + // Don't use the `syntaxTreeManager` instance variable in `SwiftLanguageService` in `DocumentSnapshot` + // loaded from the disk will always have version number 0 + let syntaxTreeManager = SyntaxTreeManager() + return await SwiftPlaygroundsScanner.findDocumentPlaygrounds( for: snapshot, workspace: workspace, syntaxTreeManager: syntaxTreeManager diff --git a/Sources/SwiftLanguageService/TestDiscovery.swift b/Sources/SwiftLanguageService/TestDiscovery.swift index f645349bd..13e2be3e6 100644 --- a/Sources/SwiftLanguageService/TestDiscovery.swift +++ b/Sources/SwiftLanguageService/TestDiscovery.swift @@ -55,6 +55,9 @@ extension SwiftLanguageService { package func syntacticTestItems( for snapshot: DocumentSnapshot, ) async -> [AnnotatedTestItem] { + // Don't use the `syntaxTreeManager` instance variable in `SwiftLanguageService` in `DocumentSnapshot` + // loaded from the disk will always have version number 0 + let syntaxTreeManager = SyntaxTreeManager() async let swiftTestingTests = SyntacticSwiftTestingTestScanner.findTestSymbols( in: snapshot, syntaxTreeManager: syntaxTreeManager From 90118f9c394959f42bf379e9d9bcf545feed5583 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Thu, 4 Dec 2025 14:11:06 -0500 Subject: [PATCH 14/18] More workspace/playgrounds tests --- .../WorkspacePlaygroundDiscoveryTests.swift | 356 +++++++++++++++++- 1 file changed, 350 insertions(+), 6 deletions(-) diff --git a/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift b/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift index dc925e9ec..a6f25afc2 100644 --- a/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift +++ b/Tests/SourceKitLSPTests/WorkspacePlaygroundDiscoveryTests.swift @@ -113,16 +113,16 @@ final class WorkspacePlaygroundDiscoveryTests: SourceKitLSPTestCase { "Sources/MyLibrary/bar.swift": """ import Playgrounds - 7️⃣#Playground("bar2") { + 1️⃣#Playground("bar2") { print(foo()) - }8️⃣ + }2️⃣ """, "Sources/MyApp/baz.swift": """ import Playgrounds - 9️⃣#Playground("baz") { + 1️⃣#Playground("baz") { print("baz") - }🔟 + }2️⃣ """, ], manifest: """ @@ -146,8 +146,352 @@ final class WorkspacePlaygroundDiscoveryTests: SourceKitLSPTestCase { Playground( id: "MyApp/baz.swift:3:2", label: "baz", - location: try project.location(from: "9️⃣", to: "🔟", in: "baz.swift") + location: try project.location(from: "1️⃣", to: "2️⃣", 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: "1️⃣", to: "2️⃣", in: "bar.swift") ), + ] + ) + } + + func testWorkspacePlaygroundsInTestTarget() async throws { + let toolchainRegistry = ToolchainRegistry(toolchains: [try await Toolchain.forTestingWithSwiftPlay]) + let project = try await SwiftPMTestProject( + files: [ + "Tests/MyLibraryTests/MyTests.swift": """ + import Playgrounds + import XCTest + + public func foo() -> String { + "bar" + } + + 1️⃣#Playground("foo") { + print(foo()) + }2️⃣ + + 3️⃣#Playground("bar") { + print(foo()) + }4️⃣ + + class MyTests: XCTestCase { + func testMyLibrary() { + XCTAssertEqual(foo(), "bar) + } + } + """ + ], + manifest: """ + let package = Package( + name: "MyLibrary", + targets: [.testTarget(name: "MyLibraryTests")] + ) + """, + toolchainRegistry: toolchainRegistry + ) + + let response = try await project.testClient.send(WorkspacePlaygroundsRequest()) + + XCTAssertEqual( + response, + [ + Playground( + id: "MyLibraryTests/MyTests.swift:8:1", + label: "foo", + location: try project.location(from: "1️⃣", to: "2️⃣", in: "MyTests.swift") + ), + Playground( + id: "MyLibraryTests/MyTests.swift:12:1", + label: "bar", + location: try project.location(from: "3️⃣", to: "4️⃣", in: "MyTests.swift") + ), + ] + ) + } + + func testWorkspacePlaygroundsFileChange() 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 + + 1️⃣#Playground("bar2") { + print(foo()) + }2️⃣ + """, + "Sources/MyApp/baz.swift": """ + import Playgrounds + + 1️⃣#Playground("baz") { + print("baz") + }2️⃣ + """, + ], + manifest: """ + let package = Package( + name: "MyLibrary", + targets: [ + .target(name: "MyLibrary"), + .target(name: "MyApp") + ] + ) + """, + toolchainRegistry: toolchainRegistry, + ) + + let response = try await project.testClient.send(WorkspacePlaygroundsRequest()) + + XCTAssertEqual( + response, + [ + Playground( + id: "MyApp/baz.swift:3:2", + label: "baz", + location: try project.location(from: "1️⃣", to: "2️⃣", 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: "1️⃣", to: "2️⃣", in: "bar.swift") + ), + ] + ) + + _ = try await project.changeFileOnDisk( + "Test.swift", + newMarkedContents: """ + // No more playgrounds import + public func foo() -> String { + "bar" + } + + 1️⃣#Playground("baz") { + print(foo()) + }2️⃣ + + 3️⃣#Playground("qux") { + print(foo()) + }4️⃣ + """ + ) + + let (uri, newPositions) = try await project.changeFileOnDisk( + "baz.swift", + newMarkedContents: """ + import Playgrounds + 1️⃣#Playground("newBaz") { + print("baz") + }2️⃣ + """ + ) + + let newResponse = try await project.testClient.send(WorkspacePlaygroundsRequest()) + + XCTAssertEqual( + newResponse, + [ + Playground( + id: "MyApp/baz.swift:2:1", + label: "newBaz", + location: Location(uri: uri, range: newPositions["1️⃣"].. 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 + + 1️⃣#Playground("bar2") { + print(foo()) + }2️⃣ + """, + "Sources/MyApp/baz.swift": """ + import Playgrounds + + 1️⃣#Playground("baz") { + print("baz") + }2️⃣ + """, + ], + manifest: """ + let package = Package( + name: "MyLibrary", + targets: [ + .target(name: "MyLibrary"), + .target(name: "MyApp") + ] + ) + """, + toolchainRegistry: toolchainRegistry + ) + + let response = try await project.testClient.send(WorkspacePlaygroundsRequest()) + + XCTAssertEqual( + response, + [ + Playground( + id: "MyApp/baz.swift:3:2", + label: "baz", + location: try project.location(from: "1️⃣", to: "2️⃣", 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: "1️⃣", to: "2️⃣", in: "bar.swift") + ), + ] + ) + + _ = try await project.changeFileOnDisk( + "baz.swift", + newMarkedContents: nil + ) + + let newResponse = try await project.testClient.send(WorkspacePlaygroundsRequest()) + + XCTAssertEqual( + newResponse, + [ Playground( id: "MyLibrary/Test.swift:7:1", label: "foo", @@ -166,7 +510,7 @@ final class WorkspacePlaygroundDiscoveryTests: SourceKitLSPTestCase { Playground( id: "MyLibrary/bar.swift:3:1", label: "bar2", - location: try project.location(from: "7️⃣", to: "8️⃣", in: "bar.swift") + location: try project.location(from: "1️⃣", to: "2️⃣", in: "bar.swift") ), ] ) From efb9619b73ba7d0cb0ee238beeb69fbb05618cce Mon Sep 17 00:00:00 2001 From: Alex Hoppen Date: Fri, 5 Dec 2025 15:48:54 +0100 Subject: [PATCH 15/18] Address my own review comments --- Sources/SourceKitLSP/SyntacticIndex.swift | 50 ++++++++++------------- Sources/SourceKitLSP/Workspace.swift | 14 +++---- 2 files changed, 27 insertions(+), 37 deletions(-) diff --git a/Sources/SourceKitLSP/SyntacticIndex.swift b/Sources/SourceKitLSP/SyntacticIndex.swift index fbaebf994..7220148d0 100644 --- a/Sources/SourceKitLSP/SyntacticIndex.swift +++ b/Sources/SourceKitLSP/SyntacticIndex.swift @@ -102,7 +102,8 @@ package actor SyntacticIndex: Sendable { private let indexingQueue = AsyncQueue() /// Fetch the list of source files to scan for a given set of build targets - private let determineFilesToScan: @Sendable (Set?) async -> [DocumentURI: SourceFileInfo] + private let determineFilesToScan: + @Sendable (Set?) async -> [(uri: DocumentURI, info: SourceFileInfo)] /// Syntactically parse tests from the given snapshot private let syntacticTests: @Sendable (DocumentSnapshot) async -> [AnnotatedTestItem] @@ -111,7 +112,8 @@ package actor SyntacticIndex: Sendable { private let syntacticPlaygrounds: @Sendable (DocumentSnapshot) async -> [TextDocumentPlayground] package init( - determineFilesToScan: @Sendable @escaping (Set?) async -> [DocumentURI: SourceFileInfo], + determineFilesToScan: + @Sendable @escaping (Set?) async -> [(uri: DocumentURI, info: SourceFileInfo)], syntacticTests: @Sendable @escaping (DocumentSnapshot) async -> [AnnotatedTestItem], syntacticPlaygrounds: @Sendable @escaping (DocumentSnapshot) async -> [TextDocumentPlayground] ) { @@ -126,14 +128,10 @@ package actor SyntacticIndex: Sendable { // 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) + let batches = filesToScan.partition(intoNumberOfBatches: ProcessInfo.processInfo.activeProcessorCount * 4) await batches.concurrentForEach { filesInBatch in - for uri in filesInBatch { - guard let sourceFileInfo = filesToScan[uri] else { - continue - } - await self.rescanFileAssumingOnQueue(uri, scanForTests: sourceFileInfo.mayContainTests) + for (uri, info) in filesInBatch { + await self.rescanFileAssumingOnQueue(uri, scanForTests: info.mayContainTests) } } } @@ -151,21 +149,19 @@ package actor SyntacticIndex: Sendable { /// 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) + let removedFiles = Set(self.indexedSources.keys).subtracting(changedFiles.map(\.uri)) removeFilesFromIndex(removedFiles) rescanFiles(changedFiles) } - package func filesDidChange(_ events: [FileEvent: SourceFileInfo]) { + package func filesDidChange(_ events: [(FileEvent, SourceFileInfo)]) { var removedFiles: Set = [] - var filesToRescan: [DocumentURI: SourceFileInfo] = [:] + var filesToRescan: [(DocumentURI, SourceFileInfo)] = [] for (fileEvent, sourceFileInfo) in events { switch fileEvent.type { - case .created: - filesToRescan[fileEvent.uri] = sourceFileInfo - case .changed: - filesToRescan[fileEvent.uri] = sourceFileInfo + case .created, .changed: + filesToRescan.append((fileEvent.uri, sourceFileInfo)) case .deleted: removedFiles.insert(fileEvent.uri) default: @@ -177,16 +173,16 @@ package actor SyntacticIndex: Sendable { } /// Called when a list of files was updated. Re-scans those files - private func rescanFiles(_ filesToScan: [DocumentURI: SourceFileInfo]) { + private func rescanFiles(_ filesToScan: [(uri: DocumentURI, info: 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(filesToScan.keys) + removedFiles.subtract(filesToScan.map(\.uri)) // 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` // notifications. // 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` + // the file. In that case we still schedule another rescan of that file and notice in `rescanFilesAssumingOnQueue` // that the index is already up-to-date, which makes the rescan a no-op. let filesToScan = filesToScan.filter { (uri, _) in if let url = uri.fileURL, @@ -205,7 +201,7 @@ package actor SyntacticIndex: Sendable { } logger.info( - "Syntactically scanning \(filesToScan.count) files: \(filesToScan.map(\.key).map(\.arbitrarySchemeURL.lastPathComponent).joined(separator: ", "))" + "Syntactically scanning \(filesToScan.count) files: \(filesToScan.map(\.uri).map(\.arbitrarySchemeURL.lastPathComponent).joined(separator: ", "))" ) // Divide the files into multiple batches. This is more efficient than spawning a new task for every file, mostly @@ -213,15 +209,11 @@ package actor SyntacticIndex: Sendable { // 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) + let batches = filesToScan.partition(intoNumberOfBatches: ProcessInfo.processInfo.activeProcessorCount * 4) for batch in batches { - self.indexingQueue.async(priority: .low, metadata: .index(Set(batch))) { - for uri in batch { - guard let sourceFileInfo = filesToScan[uri] else { - continue - } - await self.rescanFileAssumingOnQueue(uri, scanForTests: sourceFileInfo.mayContainTests) + self.indexingQueue.async(priority: .low, metadata: .index(Set(batch.map(\.uri)))) { + for (uri, info) in batch { + await self.rescanFileAssumingOnQueue(uri, scanForTests: info.mayContainTests) } } } @@ -298,7 +290,7 @@ package actor SyntacticIndex: Sendable { /// This waits for any pending document updates to be indexed before returning a result. nonisolated package func tests() async -> [AnnotatedTestItem] { let readTask = indexingQueue.async(metadata: .read) { - return await self.indexedSources.values.flatMap { $0.tests } + return await self.indexedSources.values.flatMap(\.tests) } return await readTask.value } diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index 3ef52b0ea..b0fbbb2a6 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -428,14 +428,12 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { // Notify all clients about the reported and inferred edits. await buildServerManager.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) - }) - ) + let eventsWithSourceFileInfo: [(FileEvent, SourceFileInfo)] = await 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) From 058a9e2f3b1fbbb03322686e66196c5b98bfd207 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Fri, 5 Dec 2025 09:59:47 -0500 Subject: [PATCH 16/18] Deduplicate file events --- Sources/SourceKitLSP/Workspace.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index b0fbbb2a6..e60ca6072 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -428,10 +428,14 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { // Notify all clients about the reported and inferred edits. await buildServerManager.filesDidChange(events) + var foundEventsSourceFileInfo = Set() let eventsWithSourceFileInfo: [(FileEvent, SourceFileInfo)] = await events.asyncCompactMap { - guard let sourceFileInfo = await buildServerManager.sourceFileInfo(for: $0.uri) else { + guard !foundEventsSourceFileInfo.contains($0), + let sourceFileInfo = await buildServerManager.sourceFileInfo(for: $0.uri) + else { return nil } + foundEventsSourceFileInfo.insert($0) return ($0, sourceFileInfo) } From dc7a159b34403574e04db4632b1af8db549ac2c4 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Fri, 5 Dec 2025 10:03:56 -0500 Subject: [PATCH 17/18] Revert deduplicate --- Sources/SourceKitLSP/Workspace.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index e60ca6072..b0fbbb2a6 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -428,14 +428,10 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { // Notify all clients about the reported and inferred edits. await buildServerManager.filesDidChange(events) - var foundEventsSourceFileInfo = Set() let eventsWithSourceFileInfo: [(FileEvent, SourceFileInfo)] = await events.asyncCompactMap { - guard !foundEventsSourceFileInfo.contains($0), - let sourceFileInfo = await buildServerManager.sourceFileInfo(for: $0.uri) - else { + guard let sourceFileInfo = await buildServerManager.sourceFileInfo(for: $0.uri) else { return nil } - foundEventsSourceFileInfo.insert($0) return ($0, sourceFileInfo) } From 534ef418d86c5f8efa272670e79cce8c5af74419 Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Fri, 5 Dec 2025 10:10:53 -0500 Subject: [PATCH 18/18] Fix build --- Sources/SourceKitLSP/Workspace.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SourceKitLSP/Workspace.swift b/Sources/SourceKitLSP/Workspace.swift index b0fbbb2a6..32643be38 100644 --- a/Sources/SourceKitLSP/Workspace.swift +++ b/Sources/SourceKitLSP/Workspace.swift @@ -270,8 +270,8 @@ package final class Workspace: Sendable, BuildServerManagerDelegate { self.syntacticIndex = SyntacticIndex( determineFilesToScan: { targets in await orLog("Getting list of files for syntactic index population") { - try await buildServerManager.projectSourceFiles(in: targets) - } ?? [:] + try await buildServerManager.projectSourceFiles(in: targets).compactMap { ($0, $1) } + } ?? [] }, syntacticTests: { [weak self] (snapshot) in guard let self else {