Skip to content

Commit f13f6d3

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 d09fce7 commit f13f6d3

File tree

5 files changed

+131
-113
lines changed

5 files changed

+131
-113
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/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: 77 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,15 @@ fileprivate extension SymbolOccurrence {
3434
}
3535

3636
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() ?? []
41-
}
42-
37+
/// Converts a flat list of test symbol occurrences to hierarchical `TestItem` array, inferring the hierarchical
38+
/// structure from `childOf` relations between the symbol occurrences.
39+
///
40+
/// If `testLocations` is passed, it's an array of test IDs to the locations of the test cases. This allows us to
41+
/// provide ranges for the test cases in source code instead of only the test's location that we get from the index.
42+
private func testItems(
43+
for testSymbolOccurrences: [SymbolOccurrence],
44+
testLocations: [String: Location]
45+
) -> [TestItem] {
4346
// Arrange tests by the USR they are contained in. This allows us to emit test methods as children of test classes.
4447
// `occurrencesByParent[nil]` are the root test symbols that aren't a child of another test symbol.
4548
var occurrencesByParent: [String?: [SymbolOccurrence]] = [:]
@@ -73,18 +76,24 @@ extension SourceKitLSPServer {
7376
utf16index: testSymbolOccurrence.location.utf8Column - 1
7477
)
7578

76-
let symbolLocation = Location(
77-
uri: DocumentURI(URL(fileURLWithPath: testSymbolOccurrence.location.path)),
78-
range: Range(symbolPosition)
79-
)
79+
let id = (context + [testSymbolOccurrence.symbol.name]).joined(separator: "/")
80+
let location: Location
81+
if let syntacticLocation = testLocations[id] {
82+
location = syntacticLocation
83+
} else {
84+
location = Location(
85+
uri: DocumentURI(URL(fileURLWithPath: testSymbolOccurrence.location.path)),
86+
range: Range(symbolPosition)
87+
)
88+
}
8089
let children =
8190
occurrencesByParent[testSymbolOccurrence.symbol.usr, default: []]
8291
.sorted()
8392
.map { testItem(for: $0, context: context + [testSymbolOccurrence.symbol.name]) }
8493
return TestItem(
85-
id: (context + [testSymbolOccurrence.symbol.name]).joined(separator: "/"),
94+
id: id,
8695
label: testSymbolOccurrence.symbol.name,
87-
location: symbolLocation,
96+
location: location,
8897
children: children,
8998
tags: []
9099
)
@@ -95,32 +104,54 @@ extension SourceKitLSPServer {
95104
.map { testItem(for: $0, context: []) }
96105
}
97106

107+
func workspaceTests(_ req: WorkspaceTestsRequest) async throws -> [TestItem] {
108+
// Gather all tests classes and test methods.
109+
let testSymbolOccurrences = workspaces.flatMap { (workspace) -> [SymbolOccurrence] in
110+
return workspace.index?.unitTests() ?? []
111+
}
112+
return testItems(for: testSymbolOccurrences, testLocations: [:])
113+
}
114+
115+
/// Extracts a flat dictionary mapping test IDs to their locations from the given `testItems`.
116+
private func testLocations(from testItems: [TestItem]) -> [String: Location] {
117+
var result: [String: Location] = [:]
118+
for testItem in testItems {
119+
result[testItem.id] = testItem.location
120+
result.merge(testLocations(from: testItem.children)) { old, new in new }
121+
}
122+
return result
123+
}
124+
98125
func documentTests(
99126
_ req: DocumentTestsRequest,
100127
workspace: Workspace,
101128
languageService: LanguageService
102-
) async throws -> [WorkspaceSymbolItem]? {
129+
) async throws -> [TestItem] {
103130
let snapshot = try self.documentManager.latestSnapshot(req.textDocument.uri)
104131
let mainFileUri = await workspace.buildSystemManager.mainFile(
105132
for: req.textDocument.uri,
106133
language: snapshot.language
107134
)
135+
136+
let syntacticTests = try await languageService.syntacticDocumentTests(for: req.textDocument.uri)
137+
let testLocations = testLocations(from: syntacticTests)
138+
108139
if let index = workspace.index {
109140
var outOfDateChecker = IndexOutOfDateChecker()
110141
let testSymbols =
111142
index.unitTests(referencedByMainFiles: [mainFileUri.pseudoPath])
112143
.filter { $0.canBeTestDefinition && outOfDateChecker.isUpToDate($0.location) }
113144

114145
if !testSymbols.isEmpty {
115-
return testSymbols.sorted().map(WorkspaceSymbolItem.init)
146+
return testItems(for: testSymbols, testLocations: testLocations)
116147
}
117148
if outOfDateChecker.indexHasUpToDateUnit(for: mainFileUri.pseudoPath, index: index) {
118149
// The index is up-to-date and doesn't contain any tests. We don't need to do a syntactic fallback.
119150
return []
120151
}
121152
}
122153
// We don't have any up-to-date index entries for this file. Syntactically look for tests.
123-
return try await languageService.syntacticDocumentTests(for: req.textDocument.uri)
154+
return syntacticTests
124155
}
125156
}
126157

@@ -133,7 +164,7 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
133164
private var snapshot: DocumentSnapshot
134165

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

138169
/// Names of classes that are known to not inherit from `XCTestCase` and can thus be ruled out to be test classes.
139170
private static let knownNonXCTestSubclasses = ["NSObject"]
@@ -146,15 +177,15 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
146177
public static func findTestSymbols(
147178
in snapshot: DocumentSnapshot,
148179
syntaxTreeManager: SyntaxTreeManager
149-
) async -> [WorkspaceSymbolItem] {
180+
) async -> [TestItem] {
150181
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
151182
let visitor = SyntacticSwiftXCTestScanner(snapshot: snapshot)
152183
visitor.walk(syntaxTree)
153184
return visitor.result
154185
}
155186

156-
private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [WorkspaceSymbolItem] {
157-
return members.compactMap { (member) -> WorkspaceSymbolItem? in
187+
private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [TestItem] {
188+
return members.compactMap { (member) -> TestItem? in
158189
guard let function = member.decl.as(FunctionDeclSyntax.self) else {
159190
return nil
160191
}
@@ -166,25 +197,29 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
166197
// Test methods can't be static.
167198
return nil
168199
}
169-
guard function.signature.returnClause == nil else {
170-
// Test methods can't have a return type.
200+
guard function.signature.returnClause == nil, function.signature.parameterClause.parameters.isEmpty else {
201+
// Test methods can't have a return type or have parameters.
171202
// Technically we are also filtering out functions that have an explicit `Void` return type here but such
172203
// declarations are probably less common than helper functions that start with `test` and have a return type.
173204
return nil
174205
}
175-
guard let position = snapshot.position(of: function.name.positionAfterSkippingLeadingTrivia) else {
206+
guard
207+
let range = snapshot.range(
208+
of: function.positionAfterSkippingLeadingTrivia..<function.endPositionBeforeTrailingTrivia
209+
)
210+
else {
176211
logger.fault(
177-
"Failed to convert offset \(function.name.positionAfterSkippingLeadingTrivia.utf8Offset) to UTF-16-based position"
212+
"Failed to convert range \(function.positionAfterSkippingLeadingTrivia.utf8Offset)..<\(function.endPositionBeforeTrailingTrivia.utf8Offset) to UTF-16-based line-column range"
178213
)
179214
return nil
180215
}
181-
let symbolInformation = SymbolInformation(
182-
name: function.name.text,
183-
kind: .method,
184-
location: Location(uri: snapshot.uri, range: Range(position)),
185-
containerName: containerName
216+
return TestItem(
217+
id: "\(containerName)/\(function.name.text)()",
218+
label: "\(function.name.text)()",
219+
location: Location(uri: snapshot.uri, range: range),
220+
children: [],
221+
tags: []
186222
)
187-
return WorkspaceSymbolItem.symbolInformation(symbolInformation)
188223
}
189224
}
190225

@@ -207,20 +242,21 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
207242
// Don't report a test class if it doesn't contain any test methods.
208243
return .visitChildren
209244
}
210-
guard let position = snapshot.position(of: node.name.positionAfterSkippingLeadingTrivia) else {
245+
guard let range = snapshot.range(of: node.positionAfterSkippingLeadingTrivia..<node.endPositionBeforeTrailingTrivia)
246+
else {
211247
logger.fault(
212-
"Failed to convert offset \(node.name.positionAfterSkippingLeadingTrivia.utf8Offset) to UTF-16-based position"
248+
"Failed to convert range \(node.positionAfterSkippingLeadingTrivia.utf8Offset)..<\(node.endPositionBeforeTrailingTrivia.utf8Offset) to UTF-16-based line-column range"
213249
)
214250
return .visitChildren
215251
}
216-
let testClassSymbolInformation = SymbolInformation(
217-
name: node.name.text,
218-
kind: .class,
219-
location: Location(uri: snapshot.uri, range: Range(position)),
220-
containerName: nil
252+
let testItem = TestItem(
253+
id: node.name.text,
254+
label: node.name.text,
255+
location: Location(uri: snapshot.uri, range: range),
256+
children: testMethods,
257+
tags: []
221258
)
222-
result.append(.symbolInformation(testClassSymbolInformation))
223-
result += testMethods
259+
result.append(testItem)
224260
return .visitChildren
225261
}
226262

@@ -231,14 +267,14 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
231267
}
232268

233269
extension SwiftLanguageService {
234-
public func syntacticDocumentTests(for uri: DocumentURI) async throws -> [WorkspaceSymbolItem]? {
270+
public func syntacticDocumentTests(for uri: DocumentURI) async throws -> [TestItem] {
235271
let snapshot = try documentManager.latestSnapshot(uri)
236272
return await SyntacticSwiftXCTestScanner.findTestSymbols(in: snapshot, syntaxTreeManager: syntaxTreeManager)
237273
}
238274
}
239275

240276
extension ClangLanguageService {
241-
public func syntacticDocumentTests(for uri: DocumentURI) async -> [WorkspaceSymbolItem]? {
242-
return nil
277+
public func syntacticDocumentTests(for uri: DocumentURI) async -> [TestItem] {
278+
return []
243279
}
244280
}

0 commit comments

Comments
 (0)