Skip to content

Commit 24194cb

Browse files
committed
Return workspace tests in a hierarchical format
This ways the client doesn’t need to create a hierarchical structure using the container names. It is also more flexible and allows nesting of test suites + the addition of labels and tags for swift-testing. The data structure for `TestItem` has been heavily inspired by VS Code’s `TestItem` for the test explorer, which should make it fairly straightforward to integrate these results into the VS Code test explorer.
1 parent d5e3dbd commit 24194cb

File tree

6 files changed

+146
-28
lines changed

6 files changed

+146
-28
lines changed

Sources/LanguageServerProtocol/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ add_library(LanguageServerProtocol STATIC
124124
SupportTypes/SKCompletionOptions.swift
125125
SupportTypes/StringOrMarkupContent.swift
126126
SupportTypes/SymbolKind.swift
127+
SupportTypes/TestItem.swift
127128
SupportTypes/TextDocumentContentChangeEvent.swift
128129
SupportTypes/TextDocumentEdit.swift
129130
SupportTypes/TextDocumentIdentifier.swift

Sources/LanguageServerProtocol/Requests/WorkspaceTestsRequest.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
/// **(LSP Extension)**
1616
public struct WorkspaceTestsRequest: RequestType, Hashable {
1717
public static let method: String = "workspace/tests"
18-
public typealias Response = [WorkspaceSymbolItem]?
18+
public typealias Response = [TestItem]
1919

2020
public init() {}
2121
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
public struct TestTag: Codable, Equatable, Sendable {
14+
/// ID of the test tag. `TestTag` instances with the same ID are considered to be identical.
15+
public let id: String
16+
17+
public init(id: String) {
18+
self.id = id
19+
}
20+
}
21+
22+
/// A test item that can be shown an a client's test explorer or used to identify tests alongside a source file.
23+
///
24+
/// A `TestItem` can represent either a test suite or a test itself, since they both have similar capabilities.
25+
public struct TestItem: ResponseType, Equatable {
26+
/// Identifier for the `TestItem`.
27+
///
28+
/// This identifier uniquely identifies the test case or test suite. It can be used to run an individual test (suite).
29+
public let id: String
30+
31+
/// Display name describing the test.
32+
public let label: String
33+
34+
/// Optional description that appears next to the label.
35+
public let description: String?
36+
37+
/// A string that should be used when comparing this item with other items.
38+
/// When `nil` the `label` is used.
39+
public let sortText: String?
40+
41+
/// The location of the test item in the source code.
42+
public let location: Location
43+
44+
/// The children of this test item. For a test suite, this may contain the individual test cases or nested suites.
45+
public let children: [TestItem]
46+
47+
/// Tags associated with this test item.
48+
public let tags: [TestTag]
49+
50+
public init(
51+
id: String,
52+
label: String,
53+
description: String? = nil,
54+
sortText: String? = nil,
55+
location: Location,
56+
children: [TestItem],
57+
tags: [TestTag]
58+
) {
59+
self.id = id
60+
self.label = label
61+
self.description = description
62+
self.sortText = sortText
63+
self.location = location
64+
self.children = children
65+
self.tags = tags
66+
}
67+
}

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1317,7 +1317,7 @@ extension SourceKitLSPServer {
13171317
semanticTokensProvider: semanticTokensOptions,
13181318
inlayHintProvider: inlayHintOptions,
13191319
experimental: .dictionary([
1320-
"workspace/tests": .dictionary(["version": .int(1)]),
1320+
"workspace/tests": .dictionary(["version": .int(2)]),
13211321
"textDocument/tests": .dictionary(["version": .int(1)]),
13221322
])
13231323
)

Sources/SourceKitLSP/TestDiscovery.swift

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,65 @@ fileprivate extension SymbolOccurrence {
3434
}
3535

3636
extension SourceKitLSPServer {
37-
func workspaceTests(_ req: WorkspaceTestsRequest) async throws -> [WorkspaceSymbolItem]? {
38-
let testSymbols = workspaces.flatMap { (workspace) -> [SymbolOccurrence] in
37+
func workspaceTests(_ req: WorkspaceTestsRequest) async throws -> [TestItem] {
38+
// Gather all tests classes and test methods.
39+
let testSymbolOccurrences = workspaces.flatMap { (workspace) -> [SymbolOccurrence] in
3940
return workspace.index?.unitTests() ?? []
4041
}
41-
return
42-
testSymbols
43-
.filter { $0.canBeTestDefinition }
42+
43+
// Arrange tests by the USR they are contained in. This allows us to emit test methods as children of test classes.
44+
// `occurrencesByParent[nil]` are the root test symbols that aren't a child of another test symbol.
45+
var occurrencesByParent: [String?: [SymbolOccurrence]] = [:]
46+
47+
let testSymbolUsrs = Set(testSymbolOccurrences.map(\.symbol.usr))
48+
49+
for testSymbolOccurrence in testSymbolOccurrences {
50+
let childOfUsrs = testSymbolOccurrence.relations
51+
.filter { $0.roles.contains(.childOf) }
52+
.map(\.symbol.usr)
53+
.filter { testSymbolUsrs.contains($0) }
54+
if childOfUsrs.count > 1 {
55+
logger.fault(
56+
"Test symbol \(testSymbolOccurrence.symbol.usr) is child or multiple symbols: \(childOfUsrs.joined(separator: ", "))"
57+
)
58+
}
59+
occurrencesByParent[childOfUsrs.sorted().first, default: []].append(testSymbolOccurrence)
60+
}
61+
62+
/// Returns a test item for the given `testSymbolOccurrence`.
63+
///
64+
/// Also includes test items for all tests that are children of this test.
65+
///
66+
/// `context` is used to build the test's ID. It is an array containing the names of all parent symbols. These will
67+
/// be joined with the test symbol's name using `/` to form the test ID. The test ID can be used to run an
68+
/// individual test.
69+
func testItem(for testSymbolOccurrence: SymbolOccurrence, context: [String]) -> TestItem {
70+
let symbolPosition = Position(
71+
line: testSymbolOccurrence.location.line - 1, // 1-based -> 0-based
72+
// FIXME: we need to convert the utf8/utf16 column, which may require reading the file!
73+
utf16index: testSymbolOccurrence.location.utf8Column - 1
74+
)
75+
76+
let symbolLocation = Location(
77+
uri: DocumentURI(URL(fileURLWithPath: testSymbolOccurrence.location.path)),
78+
range: Range(symbolPosition)
79+
)
80+
let children =
81+
occurrencesByParent[testSymbolOccurrence.symbol.usr, default: []]
82+
.sorted()
83+
.map { testItem(for: $0, context: context + [testSymbolOccurrence.symbol.name]) }
84+
return TestItem(
85+
id: (context + [testSymbolOccurrence.symbol.name]).joined(separator: "/"),
86+
label: testSymbolOccurrence.symbol.name,
87+
location: symbolLocation,
88+
children: children,
89+
tags: []
90+
)
91+
}
92+
93+
return occurrencesByParent[nil, default: []]
4494
.sorted()
45-
.map(WorkspaceSymbolItem.init)
95+
.map { testItem(for: $0, context: []) }
4696
}
4797

4898
func documentTests(

Tests/SourceKitLSPTests/TestDiscoveryTests.swift

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,27 @@ final class TestDiscoveryTests: XCTestCase {
4747
XCTAssertEqual(
4848
tests,
4949
[
50-
WorkspaceSymbolItem.symbolInformation(
51-
SymbolInformation(
52-
name: "MyTests",
53-
kind: .class,
54-
location: Location(
55-
uri: try project.uri(for: "MyTests.swift"),
56-
range: Range(try project.position(of: "1️⃣", in: "MyTests.swift"))
50+
TestItem(
51+
id: "MyTests",
52+
label: "MyTests",
53+
location: Location(
54+
uri: try project.uri(for: "MyTests.swift"),
55+
range: Range(try project.position(of: "1️⃣", in: "MyTests.swift"))
56+
),
57+
children: [
58+
TestItem(
59+
id: "MyTests/testMyLibrary()",
60+
label: "testMyLibrary()",
61+
location: Location(
62+
uri: try project.uri(for: "MyTests.swift"),
63+
range: Range(try project.position(of: "2️⃣", in: "MyTests.swift"))
64+
),
65+
children: [],
66+
tags: []
5767
)
58-
)
59-
),
60-
WorkspaceSymbolItem.symbolInformation(
61-
SymbolInformation(
62-
name: "testMyLibrary()",
63-
kind: .method,
64-
location: Location(
65-
uri: try project.uri(for: "MyTests.swift"),
66-
range: Range(try project.position(of: "2️⃣", in: "MyTests.swift"))
67-
),
68-
containerName: "MyTests"
69-
)
70-
),
68+
],
69+
tags: []
70+
)
7171
]
7272
)
7373
}

0 commit comments

Comments
 (0)