diff --git a/Sources/LanguageServerProtocol/CMakeLists.txt b/Sources/LanguageServerProtocol/CMakeLists.txt index 66eafe3fc..47fb86fde 100644 --- a/Sources/LanguageServerProtocol/CMakeLists.txt +++ b/Sources/LanguageServerProtocol/CMakeLists.txt @@ -50,6 +50,7 @@ add_library(LanguageServerProtocol STATIC Requests/DocumentSemanticTokensRangeRequest.swift Requests/DocumentSemanticTokensRequest.swift Requests/DocumentSymbolRequest.swift + Requests/DocumentTestsRequest.swift Requests/ExecuteCommandRequest.swift Requests/FoldingRangeRequest.swift Requests/FormattingRequests.swift @@ -86,6 +87,7 @@ add_library(LanguageServerProtocol STATIC Requests/WorkspaceSemanticTokensRefreshRequest.swift Requests/WorkspaceSymbolResolveRequest.swift Requests/WorkspaceSymbolsRequest.swift + Requests/WorkspaceTestsRequest.swift SupportTypes/CallHierarchyItem.swift SupportTypes/ClientCapabilities.swift diff --git a/Sources/LanguageServerProtocol/Messages.swift b/Sources/LanguageServerProtocol/Messages.swift index 8776ebc24..4c8e54862 100644 --- a/Sources/LanguageServerProtocol/Messages.swift +++ b/Sources/LanguageServerProtocol/Messages.swift @@ -45,6 +45,7 @@ public let builtinRequests: [_RequestType.Type] = [ DocumentSemanticTokensRangeRequest.self, DocumentSemanticTokensRequest.self, DocumentSymbolRequest.self, + DocumentTestsRequest.self, ExecuteCommandRequest.self, FoldingRangeRequest.self, HoverRequest.self, @@ -82,6 +83,7 @@ public let builtinRequests: [_RequestType.Type] = [ WorkspaceSemanticTokensRefreshRequest.self, WorkspaceSymbolResolveRequest.self, WorkspaceSymbolsRequest.self, + WorkspaceTestsRequest.self, ] /// The set of known notifications. diff --git a/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift b/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift new file mode 100644 index 000000000..598976de8 --- /dev/null +++ b/Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A request that returns symbols for all the test classes and test methods within a file. +/// +/// **(LSP Extension)** +public struct DocumentTestsRequest: TextDocumentRequest, Hashable { + public static let method: String = "document/tests" + public typealias Response = [WorkspaceSymbolItem]? + + public var textDocument: TextDocumentIdentifier + + public init(textDocument: TextDocumentIdentifier) { + self.textDocument = textDocument + } +} diff --git a/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift b/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift new file mode 100644 index 000000000..e0f2ae311 --- /dev/null +++ b/Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// A request that returns symbols for all the test classes and test methods within the current workspace. +/// +/// **(LSP Extension)** +public struct WorkspaceTestsRequest: RequestType, Hashable { + public static let method: String = "workspace/tests" + public typealias Response = [WorkspaceSymbolItem]? + + public init() {} +} diff --git a/Sources/SKCore/BuildSystemManager.swift b/Sources/SKCore/BuildSystemManager.swift index 39ef3ee33..7dea053ca 100644 --- a/Sources/SKCore/BuildSystemManager.swift +++ b/Sources/SKCore/BuildSystemManager.swift @@ -268,7 +268,7 @@ extension BuildSystemManager: MainFilesDelegate { /// For Swift or normal C files, this will be the file itself. For header /// files, we pick a main file that includes the header since header files /// don't have build settings by themselves. - private func mainFile(for uri: DocumentURI, language: Language, useCache: Bool = true) async -> DocumentURI { + public func mainFile(for uri: DocumentURI, language: Language, useCache: Bool = true) async -> DocumentURI { if language == .swift { // Swift doesn't have main files. Skip the main file provider query. return uri diff --git a/Sources/SKTestSupport/SwiftPMTestWorkspace.swift b/Sources/SKTestSupport/SwiftPMTestWorkspace.swift index ac6f82ddd..281607f35 100644 --- a/Sources/SKTestSupport/SwiftPMTestWorkspace.swift +++ b/Sources/SKTestSupport/SwiftPMTestWorkspace.swift @@ -45,13 +45,15 @@ public class SwiftPMTestWorkspace: MultiFileTestWorkspace { var filesByPath: [RelativeFileLocation: String] = [:] for (fileLocation, contents) in files { let directories = - if fileLocation.directories.isEmpty { + switch fileLocation.directories.first { + case "Sources", "Tests": + fileLocation.directories + case nil: ["Sources", "MyLibrary"] - } else if fileLocation.directories.first != "Sources" { + default: ["Sources"] + fileLocation.directories - } else { - fileLocation.directories } + filesByPath[RelativeFileLocation(directories: directories, fileLocation.fileName)] = contents } filesByPath["Package.swift"] = manifest @@ -77,6 +79,7 @@ public class SwiftPMTestWorkspace: MultiFileTestWorkspace { swift.path, "build", "--package-path", path.path, + "--build-tests", "-Xswiftc", "-index-ignore-system-modules", "-Xcc", "-index-ignore-system-symbols", ] diff --git a/Sources/SourceKitLSP/CMakeLists.txt b/Sources/SourceKitLSP/CMakeLists.txt index f94e395f0..14fa58ac5 100644 --- a/Sources/SourceKitLSP/CMakeLists.txt +++ b/Sources/SourceKitLSP/CMakeLists.txt @@ -7,8 +7,9 @@ add_library(SourceKitLSP STATIC Sequence+AsyncMap.swift SourceKitIndexDelegate.swift SourceKitLSPCommandMetadata.swift - SourceKitServer+Options.swift SourceKitServer.swift + SourceKitServer+Options.swift + TestDiscovery.swift ToolchainLanguageServer.swift Workspace.swift ) diff --git a/Sources/SourceKitLSP/SourceKitServer.swift b/Sources/SourceKitLSP/SourceKitServer.swift index a6fe6a275..dd9cf32e1 100644 --- a/Sources/SourceKitLSP/SourceKitServer.swift +++ b/Sources/SourceKitLSP/SourceKitServer.swift @@ -163,7 +163,7 @@ final actor WorkDoneProgressState { fileprivate enum TaskMetadata: DependencyTracker { /// A task that changes the global configuration of sourcekit-lsp in any way. /// - /// No other tasks must execute simulateneously with this task since they + /// No other tasks must execute simultaneously with this task since they /// might be relying on this task to take effect. case globalConfigurationChange @@ -336,6 +336,8 @@ fileprivate enum TaskMetadata: DependencyTracker { self = .freestanding case is WorkspaceSymbolsRequest: self = .freestanding + case is WorkspaceTestsRequest: + self = .freestanding default: logger.error( """ @@ -382,7 +384,7 @@ public actor SourceKitServer { var languageServices: [LanguageServerType: [ToolchainLanguageServer]] = [:] - private let documentManager = DocumentManager() + let documentManager = DocumentManager() private var packageLoadingWorkDoneProgress = WorkDoneProgressState( "SourceKitLSP.SourceKitServer.reloadPackage", @@ -398,7 +400,7 @@ public actor SourceKitServer { /// Must only be accessed from `queue`. private var uriToWorkspaceCache: [DocumentURI: WeakWorkspace] = [:] - private var workspaces: [Workspace] = [] { + private(set) var workspaces: [Workspace] = [] { didSet { uriToWorkspaceCache = [:] } @@ -837,6 +839,10 @@ extension SourceKitServer: MessageHandler { await request.reply { try await shutdown(request.params) } case let request as RequestAndReply: 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 self.handleRequest(for: request, requestHandler: self.documentTests) case let request as RequestAndReply: await request.reply { try await pollIndex(request.params) } case let request as RequestAndReply: @@ -1499,7 +1505,7 @@ extension SourceKitServer { guard matching.count >= minWorkspaceSymbolPatternLength else { return [] } - var symbolOccurenceResults: [SymbolOccurrence] = [] + var symbolOccurrenceResults: [SymbolOccurrence] = [] for workspace in workspaces { workspace.index?.forEachCanonicalSymbolOccurrence( containing: matching, @@ -1511,45 +1517,22 @@ extension SourceKitServer { guard !symbol.location.isSystem && !symbol.roles.contains(.accessorOf) else { return true } - symbolOccurenceResults.append(symbol) + symbolOccurrenceResults.append(symbol) // FIXME: Once we have cancellation support, we should fetch all results and take the top // `maxWorkspaceSymbolResults` symbols but bail if cancelled. // // Until then, take the first `maxWorkspaceSymbolResults` symbols to limit the impact of // queries which match many symbols. - return symbolOccurenceResults.count < maxWorkspaceSymbolResults + return symbolOccurrenceResults.count < maxWorkspaceSymbolResults } } - return symbolOccurenceResults + return symbolOccurrenceResults } /// Handle a workspace/symbol request, returning the SymbolInformation. /// - returns: An array with SymbolInformation for each matching symbol in the workspace. func workspaceSymbols(_ req: WorkspaceSymbolsRequest) async throws -> [WorkspaceSymbolItem]? { - let symbols = findWorkspaceSymbols( - matching: req.query - ).map({ symbolOccurrence -> WorkspaceSymbolItem in - let symbolPosition = Position( - line: symbolOccurrence.location.line - 1, // 1-based -> 0-based - // FIXME: we need to convert the utf8/utf16 column, which may require reading the file! - utf16index: symbolOccurrence.location.utf8Column - 1 - ) - - let symbolLocation = Location( - uri: DocumentURI(URL(fileURLWithPath: symbolOccurrence.location.path)), - range: Range(symbolPosition) - ) - - return .symbolInformation( - SymbolInformation( - name: symbolOccurrence.symbol.name, - kind: symbolOccurrence.symbol.kind.asLspSymbolKind(), - deprecated: nil, - location: symbolLocation, - containerName: symbolOccurrence.getContainerName() - ) - ) - }) + let symbols = findWorkspaceSymbols(matching: req.query).map(WorkspaceSymbolItem.init) return symbols } @@ -2294,3 +2277,28 @@ fileprivate func transitiveSubtypeClosure(ofUsrs usrs: [String], index: IndexSto } return result } + +extension WorkspaceSymbolItem { + init(_ symbolOccurrence: SymbolOccurrence) { + let symbolPosition = Position( + line: symbolOccurrence.location.line - 1, // 1-based -> 0-based + // FIXME: we need to convert the utf8/utf16 column, which may require reading the file! + utf16index: symbolOccurrence.location.utf8Column - 1 + ) + + let symbolLocation = Location( + uri: DocumentURI(URL(fileURLWithPath: symbolOccurrence.location.path)), + range: Range(symbolPosition) + ) + + self = .symbolInformation( + SymbolInformation( + name: symbolOccurrence.symbol.name, + kind: symbolOccurrence.symbol.kind.asLspSymbolKind(), + deprecated: nil, + location: symbolLocation, + containerName: symbolOccurrence.getContainerName() + ) + ) + } +} diff --git a/Sources/SourceKitLSP/TestDiscovery.swift b/Sources/SourceKitLSP/TestDiscovery.swift new file mode 100644 index 000000000..17e065c41 --- /dev/null +++ b/Sources/SourceKitLSP/TestDiscovery.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2020 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 IndexStoreDB +import LanguageServerProtocol + +fileprivate extension SymbolOccurrence { + /// Assuming that this is a symbol occurrence returned by the index, return whether it can constitute the definition + /// of a test case. + /// + /// The primary intention for this is to filter out references to test cases and extension declarations of test cases. + /// The latter is important to filter so we don't include extension declarations for the derived `DiscoveredTests` + /// files on non-Darwin platforms. + var canBeTestDefinition: Bool { + guard roles.contains(.definition) else { + return false + } + guard symbol.kind == .class || symbol.kind == .instanceMethod else { + return false + } + return true + } +} + +extension SourceKitServer { + func workspaceTests(_ req: WorkspaceTestsRequest) async throws -> [WorkspaceSymbolItem]? { + let testSymbols = workspaces.flatMap { (workspace) -> [SymbolOccurrence] in + return workspace.index?.unitTests() ?? [] + } + return + testSymbols + .filter { $0.canBeTestDefinition } + .map(WorkspaceSymbolItem.init) + } + + func documentTests( + _ req: DocumentTestsRequest, + workspace: Workspace, + languageService: ToolchainLanguageServer + ) async throws -> [WorkspaceSymbolItem]? { + let snapshot = try self.documentManager.latestSnapshot(req.textDocument.uri) + let mainFileUri = await workspace.buildSystemManager.mainFile( + for: req.textDocument.uri, + language: snapshot.language + ) + let testSymbols = workspace.index?.unitTests(referencedByMainFiles: [mainFileUri.pseudoPath]) ?? [] + return + testSymbols + .filter { $0.canBeTestDefinition } + .map(WorkspaceSymbolItem.init) + } +} diff --git a/Tests/SourceKitLSPTests/TestDiscoveryTests.swift b/Tests/SourceKitLSPTests/TestDiscoveryTests.swift new file mode 100644 index 000000000..22c28f3cc --- /dev/null +++ b/Tests/SourceKitLSPTests/TestDiscoveryTests.swift @@ -0,0 +1,139 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import LanguageServerProtocol +import SKTestSupport +import XCTest + +final class TestDiscoveryTests: XCTestCase { + func testWorkspaceTests() async throws { + try XCTSkipIf(longTestsDisabled) + + let ws = try await SwiftPMTestWorkspace( + files: [ + "Tests/MyLibraryTests/MyTests.swift": """ + import XCTest + + class 1️⃣MyTests: XCTestCase { + func 2️⃣testMyLibrary() {} + func unrelatedFunc() {} + var testVariable: Int = 0 + } + """ + ], + manifest: """ + // swift-tools-version: 5.7 + + import PackageDescription + + let package = Package( + name: "MyLibrary", + targets: [.testTarget(name: "MyLibraryTests")] + ) + """, + build: true + ) + + let tests = try await ws.testClient.send(WorkspaceTestsRequest()) + XCTAssertEqual( + tests, + [ + WorkspaceSymbolItem.symbolInformation( + SymbolInformation( + name: "MyTests", + kind: .class, + location: Location( + uri: try ws.uri(for: "MyTests.swift"), + range: Range(try ws.position(of: "1️⃣", in: "MyTests.swift")) + ) + ) + ), + WorkspaceSymbolItem.symbolInformation( + SymbolInformation( + name: "testMyLibrary()", + kind: .method, + location: Location( + uri: try ws.uri(for: "MyTests.swift"), + range: Range(try ws.position(of: "2️⃣", in: "MyTests.swift")) + ), + containerName: "MyTests" + ) + ), + ] + ) + } + + func testDocumentTests() async throws { + try XCTSkipIf(longTestsDisabled) + + let ws = try await SwiftPMTestWorkspace( + files: [ + "Tests/MyLibraryTests/MyTests.swift": """ + import XCTest + + class 1️⃣MyTests: XCTestCase { + func 2️⃣testMyLibrary() {} + func unrelatedFunc() {} + var testVariable: Int = 0 + } + """, + "Tests/MyLibraryTests/MoreTests.swift": """ + import XCTest + + class MoreTests: XCTestCase { + func testSomeMore() {} + } + """, + ], + manifest: """ + // swift-tools-version: 5.7 + + import PackageDescription + + let package = Package( + name: "MyLibrary", + targets: [.testTarget(name: "MyLibraryTests")] + ) + """, + build: true + ) + + let (uri, positions) = try ws.openDocument("MyTests.swift") + let tests = try await ws.testClient.send(DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri))) + XCTAssertEqual( + tests, + [ + WorkspaceSymbolItem.symbolInformation( + SymbolInformation( + name: "MyTests", + kind: .class, + location: Location( + uri: uri, + range: Range(positions["1️⃣"]) + ) + ) + ), + WorkspaceSymbolItem.symbolInformation( + SymbolInformation( + name: "testMyLibrary()", + kind: .method, + location: Location( + uri: try ws.uri(for: "MyTests.swift"), + range: Range(positions["2️⃣"]) + ), + containerName: "MyTests" + ) + ), + ] + ) + } +}