From ed756fa22f56ac7da722383870c94222330413dd Mon Sep 17 00:00:00 2001 From: Adam Ward Date: Wed, 12 Nov 2025 15:40:20 -0500 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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(