@@ -33,13 +33,44 @@ fileprivate extension SymbolOccurrence {
33
33
}
34
34
}
35
35
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
41
58
}
59
+ return documentSymbol. range
60
+ }
61
+ return nil
62
+ }
42
63
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 ] {
43
74
// Arrange tests by the USR they are contained in. This allows us to emit test methods as children of test classes.
44
75
// `occurrencesByParent[nil]` are the root test symbols that aren't a child of another test symbol.
45
76
var occurrencesByParent : [ String ? : [ SymbolOccurrence ] ] = [ : ]
@@ -66,53 +97,106 @@ extension SourceKitLSPServer {
66
97
/// `context` is used to build the test's ID. It is an array containing the names of all parent symbols. These will
67
98
/// be joined with the test symbol's name using `/` to form the test ID. The test ID can be used to run an
68
99
/// 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)
75
124
76
- let symbolLocation = Location (
77
- uri: DocumentURI ( URL ( fileURLWithPath: testSymbolOccurrence. location. path) ) ,
78
- range: Range ( symbolPosition)
79
- )
80
125
let children =
81
126
occurrencesByParent [ testSymbolOccurrence. symbol. usr, default: [ ] ]
82
127
. 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
+ }
84
131
return TestItem (
85
- id: ( context + [ testSymbolOccurrence . symbol . name ] ) . joined ( separator : " / " ) ,
132
+ id: id ,
86
133
label: testSymbolOccurrence. symbol. name,
87
- location: symbolLocation ,
134
+ location: location ,
88
135
children: children,
89
136
tags: [ ]
90
137
)
91
138
}
92
139
93
140
return occurrencesByParent [ nil , default: [ ] ]
94
141
. 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
96
165
}
97
166
98
167
func documentTests(
99
168
_ req: DocumentTestsRequest ,
100
169
workspace: Workspace ,
101
170
languageService: LanguageService
102
- ) async throws -> [ WorkspaceSymbolItem ] ? {
171
+ ) async throws -> [ TestItem ] {
103
172
let snapshot = try self . documentManager. latestSnapshot ( req. textDocument. uri)
104
173
let mainFileUri = await workspace. buildSystemManager. mainFile (
105
174
for: req. textDocument. uri,
106
175
language: snapshot. language
107
176
)
177
+
108
178
if let index = workspace. index {
109
179
var outOfDateChecker = IndexOutOfDateChecker ( )
110
180
let testSymbols =
111
181
index. unitTests ( referencedByMainFiles: [ mainFileUri. pseudoPath] )
112
182
. filter { $0. canBeTestDefinition && outOfDateChecker. isUpToDate ( $0. location) }
113
183
114
184
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
+ )
116
200
}
117
201
if outOfDateChecker. indexHasUpToDateUnit ( for: mainFileUri. pseudoPath, index: index) {
118
202
// 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 {
133
217
private var snapshot : DocumentSnapshot
134
218
135
219
/// The workspace symbols representing the found `XCTestCase` subclasses and test methods.
136
- private var result : [ WorkspaceSymbolItem ] = [ ]
220
+ private var result : [ TestItem ] = [ ]
137
221
138
222
/// Names of classes that are known to not inherit from `XCTestCase` and can thus be ruled out to be test classes.
139
223
private static let knownNonXCTestSubclasses = [ " NSObject " ]
@@ -146,15 +230,15 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
146
230
public static func findTestSymbols(
147
231
in snapshot: DocumentSnapshot ,
148
232
syntaxTreeManager: SyntaxTreeManager
149
- ) async -> [ WorkspaceSymbolItem ] {
233
+ ) async -> [ TestItem ] {
150
234
let syntaxTree = await syntaxTreeManager. syntaxTree ( for: snapshot)
151
235
let visitor = SyntacticSwiftXCTestScanner ( snapshot: snapshot)
152
236
visitor. walk ( syntaxTree)
153
237
return visitor. result
154
238
}
155
239
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
158
242
guard let function = member. decl. as ( FunctionDeclSyntax . self) else {
159
243
return nil
160
244
}
@@ -166,22 +250,26 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
166
250
// Test methods can't be static.
167
251
return nil
168
252
}
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 .
171
255
// Technically we are also filtering out functions that have an explicit `Void` return type here but such
172
256
// declarations are probably less common than helper functions that start with `test` and have a return type.
173
257
return nil
174
258
}
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 {
176
264
return nil
177
265
}
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: [ ]
183
272
)
184
- return WorkspaceSymbolItem . symbolInformation ( symbolInformation)
185
273
}
186
274
}
187
275
@@ -204,17 +292,18 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
204
292
// Don't report a test class if it doesn't contain any test methods.
205
293
return . visitChildren
206
294
}
207
- guard let position = snapshot. position ( of: node. name. positionAfterSkippingLeadingTrivia) else {
295
+ guard let range = snapshot. range ( of: node. positionAfterSkippingLeadingTrivia..< node. endPositionBeforeTrailingTrivia)
296
+ else {
208
297
return . visitChildren
209
298
}
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: [ ]
215
305
)
216
- result. append ( . symbolInformation( testClassSymbolInformation) )
217
- result += testMethods
306
+ result. append ( testItem)
218
307
return . visitChildren
219
308
}
220
309
@@ -225,14 +314,14 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
225
314
}
226
315
227
316
extension SwiftLanguageService {
228
- public func syntacticDocumentTests( for uri: DocumentURI ) async throws -> [ WorkspaceSymbolItem ] ? {
317
+ public func syntacticDocumentTests( for uri: DocumentURI ) async throws -> [ TestItem ] {
229
318
let snapshot = try documentManager. latestSnapshot ( uri)
230
319
return await SyntacticSwiftXCTestScanner . findTestSymbols ( in: snapshot, syntaxTreeManager: syntaxTreeManager)
231
320
}
232
321
}
233
322
234
323
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 [ ]
237
326
}
238
327
}
0 commit comments