Skip to content

Commit 67a73f4

Browse files
committed
Return document tests in a hierarchical format
Same as for workspace tests, instead of returning a flat list of symbols, return a hierarchical structure.
1 parent 65b9505 commit 67a73f4

File tree

6 files changed

+192
-118
lines changed

6 files changed

+192
-118
lines changed

Sources/LanguageServerProtocol/Requests/DocumentTestsRequest.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 DocumentTestsRequest: TextDocumentRequest, Hashable {
1717
public static let method: String = "textDocument/tests"
18-
public typealias Response = [WorkspaceSymbolItem]?
18+
public typealias Response = [TestItem]
1919

2020
public var textDocument: TextDocumentIdentifier
2121

Sources/LanguageServerProtocol/SupportTypes/TestItem.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,16 @@ public struct TestItem: ResponseType, Equatable {
3535
public let description: String?
3636

3737
/// A string that should be used when comparing this item with other items.
38+
///
3839
/// When `nil` the `label` is used.
3940
public let sortText: String?
4041

4142
/// The location of the test item in the source code.
4243
public let location: Location
4344

44-
/// The children of this test item. For a test suite, this may contain the individual test cases or nested suites.
45+
/// The children of this test item.
46+
///
47+
/// For a test suite, this may contain the individual test cases or nested suites.
4548
public let children: [TestItem]
4649

4750
/// Tags associated with this test item.

Sources/SourceKitLSP/LanguageService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ public protocol LanguageService: AnyObject {
198198
/// Perform a syntactic scan of the file at the given URI for test cases and test classes.
199199
///
200200
/// This is used as a fallback to show the test cases in a file if the index for a given file is not up-to-date.
201-
func syntacticDocumentTests(for uri: DocumentURI) async throws -> [WorkspaceSymbolItem]?
201+
func syntacticDocumentTests(for uri: DocumentURI) async throws -> [TestItem]
202202

203203
/// Crash the language server. Should be used for crash recovery testing only.
204204
func _crash() async

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1318,7 +1318,7 @@ extension SourceKitLSPServer {
13181318
inlayHintProvider: inlayHintOptions,
13191319
experimental: .dictionary([
13201320
"workspace/tests": .dictionary(["version": .int(2)]),
1321-
"textDocument/tests": .dictionary(["version": .int(1)]),
1321+
"textDocument/tests": .dictionary(["version": .int(2)]),
13221322
])
13231323
)
13241324
}

Sources/SourceKitLSP/TestDiscovery.swift

Lines changed: 134 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,44 @@ fileprivate extension SymbolOccurrence {
3333
}
3434
}
3535

36-
extension SourceKitLSPServer {
37-
func workspaceTests(_ req: WorkspaceTestsRequest) async throws -> [TestItem] {
38-
// Gather all tests classes and test methods.
39-
let testSymbolOccurrences = workspaces.flatMap { (workspace) -> [SymbolOccurrence] in
40-
return workspace.index?.unitTests() ?? []
36+
/// Find the innermost range of a document symbol that contains the given position.
37+
private func findInnermostSymbolRange(
38+
containing position: Position,
39+
documentSymbols documentSymbolsResponse: DocumentSymbolResponse
40+
) -> Range<Position>? {
41+
guard case .documentSymbols(let documentSymbols) = documentSymbolsResponse else {
42+
// Both `ClangLanguageService` and `SwiftLanguageService` return `documentSymbols` so we don't need to handle the
43+
// .symbolInformation case.
44+
logger.fault(
45+
"""
46+
Expected documentSymbols response from language service to resolve test ranges but got \
47+
\(documentSymbolsResponse.forLogging)
48+
"""
49+
)
50+
return nil
51+
}
52+
for documentSymbol in documentSymbols where documentSymbol.range.contains(position) {
53+
if let children = documentSymbol.children,
54+
let rangeOfChild = findInnermostSymbolRange(containing: position, documentSymbols: .documentSymbols(children))
55+
{
56+
// If a child contains the position, prefer that because it's more specific.
57+
return rangeOfChild
4158
}
59+
return documentSymbol.range
60+
}
61+
return nil
62+
}
4263

64+
extension SourceKitLSPServer {
65+
/// Converts a flat list of test symbol occurrences to a hierarchical `TestItem` array, inferring the hierarchical
66+
/// structure from `childOf` relations between the symbol occurrences.
67+
///
68+
/// `resolvePositions` resolves the position of a test to a `Location` that is effectively a range. This allows us to
69+
/// provide ranges for the test cases in source code instead of only the test's location that we get from the index.
70+
private func testItems(
71+
for testSymbolOccurrences: [SymbolOccurrence],
72+
resolveLocation: (DocumentURI, Position) -> Location
73+
) -> [TestItem] {
4374
// Arrange tests by the USR they are contained in. This allows us to emit test methods as children of test classes.
4475
// `occurrencesByParent[nil]` are the root test symbols that aren't a child of another test symbol.
4576
var occurrencesByParent: [String?: [SymbolOccurrence]] = [:]
@@ -66,53 +97,106 @@ extension SourceKitLSPServer {
6697
/// `context` is used to build the test's ID. It is an array containing the names of all parent symbols. These will
6798
/// be joined with the test symbol's name using `/` to form the test ID. The test ID can be used to run an
6899
/// 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-
)
100+
func testItem(
101+
for testSymbolOccurrence: SymbolOccurrence,
102+
documentManager: DocumentManager,
103+
context: [String]
104+
) -> TestItem {
105+
let symbolPosition: Position
106+
if let snapshot = try? documentManager.latestSnapshot(
107+
DocumentURI(URL(fileURLWithPath: testSymbolOccurrence.location.path))
108+
),
109+
let position = snapshot.position(of: testSymbolOccurrence.location)
110+
{
111+
symbolPosition = position
112+
} else {
113+
// Technically, we always need to convert UTF-8 columns to UTF-16 columns, which requires reading the file.
114+
// In practice, they are almost always the same.
115+
// We chose to avoid hitting the file system even if it means that we might report an incorrect column.
116+
symbolPosition = Position(
117+
line: testSymbolOccurrence.location.line - 1, // 1-based -> 0-based
118+
utf16index: testSymbolOccurrence.location.utf8Column - 1
119+
)
120+
}
121+
let id = (context + [testSymbolOccurrence.symbol.name]).joined(separator: "/")
122+
let uri = DocumentURI(URL(fileURLWithPath: testSymbolOccurrence.location.path))
123+
let location = resolveLocation(uri, symbolPosition)
75124

76-
let symbolLocation = Location(
77-
uri: DocumentURI(URL(fileURLWithPath: testSymbolOccurrence.location.path)),
78-
range: Range(symbolPosition)
79-
)
80125
let children =
81126
occurrencesByParent[testSymbolOccurrence.symbol.usr, default: []]
82127
.sorted()
83-
.map { testItem(for: $0, context: context + [testSymbolOccurrence.symbol.name]) }
128+
.map {
129+
testItem(for: $0, documentManager: documentManager, context: context + [testSymbolOccurrence.symbol.name])
130+
}
84131
return TestItem(
85-
id: (context + [testSymbolOccurrence.symbol.name]).joined(separator: "/"),
132+
id: id,
86133
label: testSymbolOccurrence.symbol.name,
87-
location: symbolLocation,
134+
location: location,
88135
children: children,
89136
tags: []
90137
)
91138
}
92139

93140
return occurrencesByParent[nil, default: []]
94141
.sorted()
95-
.map { testItem(for: $0, context: []) }
142+
.map { testItem(for: $0, documentManager: documentManager, context: []) }
143+
}
144+
145+
func workspaceTests(_ req: WorkspaceTestsRequest) async throws -> [TestItem] {
146+
// Gather all tests classes and test methods.
147+
let testSymbolOccurrences =
148+
workspaces
149+
.flatMap { $0.index?.unitTests() ?? [] }
150+
.filter { $0.canBeTestDefinition }
151+
return testItems(
152+
for: testSymbolOccurrences,
153+
resolveLocation: { uri, position in Location(uri: uri, range: Range(position)) }
154+
)
155+
}
156+
157+
/// Extracts a flat dictionary mapping test IDs to their locations from the given `testItems`.
158+
private func testLocations(from testItems: [TestItem]) -> [String: Location] {
159+
var result: [String: Location] = [:]
160+
for testItem in testItems {
161+
result[testItem.id] = testItem.location
162+
result.merge(testLocations(from: testItem.children)) { old, new in new }
163+
}
164+
return result
96165
}
97166

98167
func documentTests(
99168
_ req: DocumentTestsRequest,
100169
workspace: Workspace,
101170
languageService: LanguageService
102-
) async throws -> [WorkspaceSymbolItem]? {
171+
) async throws -> [TestItem] {
103172
let snapshot = try self.documentManager.latestSnapshot(req.textDocument.uri)
104173
let mainFileUri = await workspace.buildSystemManager.mainFile(
105174
for: req.textDocument.uri,
106175
language: snapshot.language
107176
)
177+
108178
if let index = workspace.index {
109179
var outOfDateChecker = IndexOutOfDateChecker()
110180
let testSymbols =
111181
index.unitTests(referencedByMainFiles: [mainFileUri.pseudoPath])
112182
.filter { $0.canBeTestDefinition && outOfDateChecker.isUpToDate($0.location) }
113183

114184
if !testSymbols.isEmpty {
115-
return testSymbols.sorted().map(WorkspaceSymbolItem.init)
185+
let documentSymbols = await orLog("Getting document symbols for test ranges") {
186+
try await languageService.documentSymbol(DocumentSymbolRequest(textDocument: req.textDocument))
187+
}
188+
189+
return testItems(
190+
for: testSymbols,
191+
resolveLocation: { uri, position in
192+
if uri == snapshot.uri, let documentSymbols,
193+
let range = findInnermostSymbolRange(containing: position, documentSymbols: documentSymbols)
194+
{
195+
return Location(uri: uri, range: range)
196+
}
197+
return Location(uri: uri, range: Range(position))
198+
}
199+
)
116200
}
117201
if outOfDateChecker.indexHasUpToDateUnit(for: mainFileUri.pseudoPath, index: index) {
118202
// The index is up-to-date and doesn't contain any tests. We don't need to do a syntactic fallback.
@@ -133,7 +217,7 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
133217
private var snapshot: DocumentSnapshot
134218

135219
/// The workspace symbols representing the found `XCTestCase` subclasses and test methods.
136-
private var result: [WorkspaceSymbolItem] = []
220+
private var result: [TestItem] = []
137221

138222
/// Names of classes that are known to not inherit from `XCTestCase` and can thus be ruled out to be test classes.
139223
private static let knownNonXCTestSubclasses = ["NSObject"]
@@ -146,15 +230,15 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
146230
public static func findTestSymbols(
147231
in snapshot: DocumentSnapshot,
148232
syntaxTreeManager: SyntaxTreeManager
149-
) async -> [WorkspaceSymbolItem] {
233+
) async -> [TestItem] {
150234
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
151235
let visitor = SyntacticSwiftXCTestScanner(snapshot: snapshot)
152236
visitor.walk(syntaxTree)
153237
return visitor.result
154238
}
155239

156-
private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [WorkspaceSymbolItem] {
157-
return members.compactMap { (member) -> WorkspaceSymbolItem? in
240+
private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [TestItem] {
241+
return members.compactMap { (member) -> TestItem? in
158242
guard let function = member.decl.as(FunctionDeclSyntax.self) else {
159243
return nil
160244
}
@@ -166,22 +250,26 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
166250
// Test methods can't be static.
167251
return nil
168252
}
169-
guard function.signature.returnClause == nil else {
170-
// Test methods can't have a return type.
253+
guard function.signature.returnClause == nil, function.signature.parameterClause.parameters.isEmpty else {
254+
// Test methods can't have a return type or have parameters.
171255
// Technically we are also filtering out functions that have an explicit `Void` return type here but such
172256
// declarations are probably less common than helper functions that start with `test` and have a return type.
173257
return nil
174258
}
175-
guard let position = snapshot.position(of: function.name.positionAfterSkippingLeadingTrivia) else {
259+
guard
260+
let range = snapshot.range(
261+
of: function.positionAfterSkippingLeadingTrivia..<function.endPositionBeforeTrailingTrivia
262+
)
263+
else {
176264
return nil
177265
}
178-
let symbolInformation = SymbolInformation(
179-
name: function.name.text,
180-
kind: .method,
181-
location: Location(uri: snapshot.uri, range: Range(position)),
182-
containerName: containerName
266+
return TestItem(
267+
id: "\(containerName)/\(function.name.text)()",
268+
label: "\(function.name.text)()",
269+
location: Location(uri: snapshot.uri, range: range),
270+
children: [],
271+
tags: []
183272
)
184-
return WorkspaceSymbolItem.symbolInformation(symbolInformation)
185273
}
186274
}
187275

@@ -204,17 +292,18 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
204292
// Don't report a test class if it doesn't contain any test methods.
205293
return .visitChildren
206294
}
207-
guard let position = snapshot.position(of: node.name.positionAfterSkippingLeadingTrivia) else {
295+
guard let range = snapshot.range(of: node.positionAfterSkippingLeadingTrivia..<node.endPositionBeforeTrailingTrivia)
296+
else {
208297
return .visitChildren
209298
}
210-
let testClassSymbolInformation = SymbolInformation(
211-
name: node.name.text,
212-
kind: .class,
213-
location: Location(uri: snapshot.uri, range: Range(position)),
214-
containerName: nil
299+
let testItem = TestItem(
300+
id: node.name.text,
301+
label: node.name.text,
302+
location: Location(uri: snapshot.uri, range: range),
303+
children: testMethods,
304+
tags: []
215305
)
216-
result.append(.symbolInformation(testClassSymbolInformation))
217-
result += testMethods
306+
result.append(testItem)
218307
return .visitChildren
219308
}
220309

@@ -225,14 +314,14 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
225314
}
226315

227316
extension SwiftLanguageService {
228-
public func syntacticDocumentTests(for uri: DocumentURI) async throws -> [WorkspaceSymbolItem]? {
317+
public func syntacticDocumentTests(for uri: DocumentURI) async throws -> [TestItem] {
229318
let snapshot = try documentManager.latestSnapshot(uri)
230319
return await SyntacticSwiftXCTestScanner.findTestSymbols(in: snapshot, syntaxTreeManager: syntaxTreeManager)
231320
}
232321
}
233322

234323
extension ClangLanguageService {
235-
public func syntacticDocumentTests(for uri: DocumentURI) async -> [WorkspaceSymbolItem]? {
236-
return nil
324+
public func syntacticDocumentTests(for uri: DocumentURI) async -> [TestItem] {
325+
return []
237326
}
238327
}

0 commit comments

Comments
 (0)