@@ -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,25 +250,29 @@ 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
logger. fault (
177
- " Failed to convert offset \( function. name . positionAfterSkippingLeadingTrivia. utf8Offset) to UTF-16-based position "
265
+ " Failed to convert range \( function. positionAfterSkippingLeadingTrivia. utf8Offset) ..< \( function . endPositionBeforeTrailingTrivia . utf8Offset ) to UTF-16-based line-column range "
178
266
)
179
267
return nil
180
268
}
181
- let symbolInformation = SymbolInformation (
182
- name: function. name. text,
183
- kind: . method,
184
- location: Location ( uri: snapshot. uri, range: Range ( position) ) ,
185
- containerName: containerName
269
+ return TestItem (
270
+ id: " \( containerName) / \( function. name. text) () " ,
271
+ label: " \( function. name. text) () " ,
272
+ location: Location ( uri: snapshot. uri, range: range) ,
273
+ children: [ ] ,
274
+ tags: [ ]
186
275
)
187
- return WorkspaceSymbolItem . symbolInformation ( symbolInformation)
188
276
}
189
277
}
190
278
@@ -207,20 +295,21 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
207
295
// Don't report a test class if it doesn't contain any test methods.
208
296
return . visitChildren
209
297
}
210
- guard let position = snapshot. position ( of: node. name. positionAfterSkippingLeadingTrivia) else {
298
+ guard let range = snapshot. range ( of: node. positionAfterSkippingLeadingTrivia..< node. endPositionBeforeTrailingTrivia)
299
+ else {
211
300
logger. fault (
212
- " Failed to convert offset \( node. name . positionAfterSkippingLeadingTrivia. utf8Offset) to UTF-16-based position "
301
+ " Failed to convert range \( node. positionAfterSkippingLeadingTrivia. utf8Offset) ..< \( node . endPositionBeforeTrailingTrivia . utf8Offset ) to UTF-16-based line-column range "
213
302
)
214
303
return . visitChildren
215
304
}
216
- let testClassSymbolInformation = SymbolInformation (
217
- name: node. name. text,
218
- kind: . class,
219
- location: Location ( uri: snapshot. uri, range: Range ( position) ) ,
220
- containerName: nil
305
+ let testItem = TestItem (
306
+ id: node. name. text,
307
+ label: node. name. text,
308
+ location: Location ( uri: snapshot. uri, range: range) ,
309
+ children: testMethods,
310
+ tags: [ ]
221
311
)
222
- result. append ( . symbolInformation( testClassSymbolInformation) )
223
- result += testMethods
312
+ result. append ( testItem)
224
313
return . visitChildren
225
314
}
226
315
@@ -231,14 +320,14 @@ private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
231
320
}
232
321
233
322
extension SwiftLanguageService {
234
- public func syntacticDocumentTests( for uri: DocumentURI ) async throws -> [ WorkspaceSymbolItem ] ? {
323
+ public func syntacticDocumentTests( for uri: DocumentURI ) async throws -> [ TestItem ] {
235
324
let snapshot = try documentManager. latestSnapshot ( uri)
236
325
return await SyntacticSwiftXCTestScanner . findTestSymbols ( in: snapshot, syntaxTreeManager: syntaxTreeManager)
237
326
}
238
327
}
239
328
240
329
extension ClangLanguageService {
241
- public func syntacticDocumentTests( for uri: DocumentURI ) async -> [ WorkspaceSymbolItem ] ? {
242
- return nil
330
+ public func syntacticDocumentTests( for uri: DocumentURI ) async -> [ TestItem ] {
331
+ return [ ]
243
332
}
244
333
}
0 commit comments