@@ -134,6 +134,7 @@ extension SourceKitLSPServer {
134
134
id: id,
135
135
label: testSymbolOccurrence. symbol. name,
136
136
disabled: false ,
137
+ isExtension: false ,
137
138
style: TestStyle . xcTest,
138
139
location: location,
139
140
children: children,
@@ -177,7 +178,7 @@ extension SourceKitLSPServer {
177
178
return [ ]
178
179
}
179
180
return await orLog ( " Getting document tests for \( uri) " ) {
180
- try await self . documentTests (
181
+ try await self . getDocumentTests (
181
182
DocumentTestsRequest ( textDocument: TextDocumentIdentifier ( uri) ) ,
182
183
workspace: workspace,
183
184
languageService: languageService
@@ -238,6 +239,7 @@ extension SourceKitLSPServer {
238
239
. concurrentMap { await self . tests ( in: $0) }
239
240
. flatMap { $0 }
240
241
. sorted { $0. location < $1. location }
242
+ . mergingTestsInExtensions ( )
241
243
}
242
244
243
245
/// Extracts a flat dictionary mapping test IDs to their locations from the given `testItems`.
@@ -254,6 +256,15 @@ extension SourceKitLSPServer {
254
256
_ req: DocumentTestsRequest ,
255
257
workspace: Workspace ,
256
258
languageService: LanguageService
259
+ ) async throws -> [ TestItem ] {
260
+ return try await getDocumentTests ( req, workspace: workspace, languageService: languageService)
261
+ . mergingTestsInExtensions ( )
262
+ }
263
+
264
+ private func getDocumentTests(
265
+ _ req: DocumentTestsRequest ,
266
+ workspace: Workspace ,
267
+ languageService: LanguageService
257
268
) async throws -> [ TestItem ] {
258
269
let snapshot = try self . documentManager. latestSnapshot ( req. textDocument. uri)
259
270
let mainFileUri = await workspace. buildSystemManager. mainFile (
@@ -331,7 +342,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
331
342
let syntaxTree = await syntaxTreeManager. syntaxTree ( for: snapshot)
332
343
let visitor = SyntacticSwiftXCTestScanner ( snapshot: snapshot)
333
344
visitor. walk ( syntaxTree)
334
- return visitor. result. mergeTestsInExtensions ( )
345
+ return visitor. result
335
346
}
336
347
337
348
private func findTestMethods( in members: MemberBlockItemListSyntax , containerName: String ) -> [ TestItem ] {
@@ -361,6 +372,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
361
372
id: " \( containerName) / \( function. name. text) () " ,
362
373
label: " \( function. name. text) () " ,
363
374
disabled: false ,
375
+ isExtension: false ,
364
376
style: TestStyle . xcTest,
365
377
location: Location ( uri: snapshot. uri, range: range) ,
366
378
children: [ ] ,
@@ -392,6 +404,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
392
404
id: node. name. text,
393
405
label: node. name. text,
394
406
disabled: false ,
407
+ isExtension: false ,
395
408
style: TestStyle . xcTest,
396
409
location: Location ( uri: snapshot. uri, range: range) ,
397
410
children: testMethods,
@@ -436,7 +449,7 @@ extension TestItem {
436
449
}
437
450
}
438
451
439
- extension Collection where Element == TestItem {
452
+ extension Array < TestItem > {
440
453
/// When the test scanners discover tests in extensions they are captured in their own parent `TestItem`, not the
441
454
/// `TestItem` generated from the class/struct's definition. This is largely because of the syntatic nature of the
442
455
/// test scanners as they are today, which only know about tests within the context of the current file. Extensions
@@ -451,18 +464,61 @@ extension Collection where Element == TestItem {
451
464
/// This method walks the `TestItem` tree produced by the test scanners and merges in the tests defined in extensions
452
465
/// into the `TestItem` that represents the type definition.
453
466
///
467
+ /// This causes extensions to be merged into their type's definition if the type's definition exists in the list of
468
+ /// test items. If the type's definition is not a test item in this collection, the first extension of that type will
469
+ /// be used as the primary test location.
470
+ ///
471
+ /// For example if there are two files
472
+ ///
473
+ /// FileA.swift
474
+ /// ```swift
475
+ /// @Suite struct MyTests {
476
+ /// @Test func oneIsTwo {}
477
+ /// }
478
+ /// ```
479
+ ///
480
+ /// FileB.swift
481
+ /// ```swift
482
+ /// extension MyTests {
483
+ /// @Test func twoIsThree() {}
484
+ /// }
485
+ /// ```
486
+ ///
487
+ /// Then `workspace/tests` will return
488
+ /// - `MyTests` (FileA.swift:1)
489
+ /// - `oneIsTwo`
490
+ /// - `twoIsThree`
491
+ ///
492
+ /// And `textDocument/tests` for FileB.swift will return
493
+ /// - `MyTests` (FileB.swift:1)
494
+ /// - `twoIsThree`
495
+ ///
454
496
/// A node's parent is identified by the node's ID with the last component dropped.
455
- func mergeTestsInExtensions ( ) -> [ TestItem ] {
497
+ func mergingTestsInExtensions ( ) -> [ TestItem ] {
456
498
var itemDict : [ String : TestItem ] = [ : ]
457
499
for item in self {
458
- if var existingItem = itemDict [ item. id] {
459
- existingItem. children = ( existingItem. children + item. children)
460
- itemDict [ item. id] = existingItem
500
+ if var rootItem = itemDict [ item. id] {
501
+ // If we've encountered an extension first, and this is the
502
+ // type declaration, then use the type declaration TestItem
503
+ // as the root item.
504
+ if rootItem. isExtension && !item. isExtension {
505
+ var newItem = item
506
+ newItem. children += rootItem. children
507
+ rootItem = newItem
508
+ } else {
509
+ rootItem. children += item. children
510
+ }
511
+
512
+ itemDict [ item. id] = rootItem
461
513
} else {
462
514
itemDict [ item. id] = item
463
515
}
464
516
}
465
517
518
+ if itemDict. isEmpty {
519
+ return [ ]
520
+ }
521
+
466
522
for item in self {
467
523
let parentID = item. id. components ( separatedBy: " / " ) . dropLast ( ) . joined ( separator: " / " )
468
524
// If the parent exists, add the current item to its children and remove it from the root
@@ -473,16 +529,22 @@ extension Collection where Element == TestItem {
473
529
}
474
530
}
475
531
476
- // Filter out the items that have been merged into their parents, sorting the tests by location
477
- var reorganizedItems = itemDict. values. compactMap { $0 } . sorted { $0. location < $1. location }
532
+ // Filter out the items that have been merged into their parents, sorting the tests by location.
533
+ // TestItems not in extensions should be priotitized first.
534
+ var sortedItems = itemDict. values. compactMap { $0 } . sorted {
535
+ $0. location. uri != $1. location. uri ? !$0. isExtension : ( $0. location < $1. location)
536
+ }
478
537
479
- reorganizedItems = reorganizedItems. map ( {
538
+ sortedItems = sortedItems. map {
539
+ guard !$0. children. isEmpty else {
540
+ return $0
541
+ }
480
542
var newItem = $0
481
- newItem. children = $0. children. mergeTestsInExtensions ( )
543
+ newItem. children = $0. children. mergingTestsInExtensions ( )
482
544
return newItem
483
- } )
545
+ }
484
546
485
- return reorganizedItems
547
+ return sortedItems
486
548
}
487
549
}
488
550
0 commit comments