@@ -154,6 +154,7 @@ extension SourceKitLSPServer {
154
154
id: id,
155
155
label: testSymbolOccurrence. symbol. name,
156
156
disabled: false ,
157
+ isExtension: false ,
157
158
style: TestStyle . xcTest,
158
159
location: location,
159
160
children: children,
@@ -197,7 +198,7 @@ extension SourceKitLSPServer {
197
198
return [ ]
198
199
}
199
200
return await orLog ( " Getting document tests for \( uri) " ) {
200
- try await self . documentTests (
201
+ try await self . getDocumentTests (
201
202
DocumentTestsRequest ( textDocument: TextDocumentIdentifier ( uri) ) ,
202
203
workspace: workspace,
203
204
languageService: languageService
@@ -258,6 +259,7 @@ extension SourceKitLSPServer {
258
259
. concurrentMap { await self . tests ( in: $0) }
259
260
. flatMap { $0 }
260
261
. sorted { $0. location < $1. location }
262
+ . mergingTestsInExtensions ( )
261
263
}
262
264
263
265
/// Extracts a flat dictionary mapping test IDs to their locations from the given `testItems`.
@@ -274,6 +276,15 @@ extension SourceKitLSPServer {
274
276
_ req: DocumentTestsRequest ,
275
277
workspace: Workspace ,
276
278
languageService: LanguageService
279
+ ) async throws -> [ TestItem ] {
280
+ return try await getDocumentTests ( req, workspace: workspace, languageService: languageService)
281
+ . mergingTestsInExtensions ( )
282
+ }
283
+
284
+ private func getDocumentTests(
285
+ _ req: DocumentTestsRequest ,
286
+ workspace: Workspace ,
287
+ languageService: LanguageService
277
288
) async throws -> [ TestItem ] {
278
289
let snapshot = try self . documentManager. latestSnapshot ( req. textDocument. uri)
279
290
let mainFileUri = await workspace. buildSystemManager. mainFile (
@@ -357,7 +368,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
357
368
let syntaxTree = await syntaxTreeManager. syntaxTree ( for: snapshot)
358
369
let visitor = SyntacticSwiftXCTestScanner ( snapshot: snapshot)
359
370
visitor. walk ( syntaxTree)
360
- return visitor. result. mergeTestsInExtensions ( )
371
+ return visitor. result
361
372
}
362
373
363
374
private func findTestMethods( in members: MemberBlockItemListSyntax , containerName: String ) -> [ TestItem ] {
@@ -387,6 +398,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
387
398
id: " \( containerName) / \( function. name. text) () " ,
388
399
label: " \( function. name. text) () " ,
389
400
disabled: false ,
401
+ isExtension: false ,
390
402
style: TestStyle . xcTest,
391
403
location: Location ( uri: snapshot. uri, range: range) ,
392
404
children: [ ] ,
@@ -418,6 +430,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
418
430
id: node. name. text,
419
431
label: node. name. text,
420
432
disabled: false ,
433
+ isExtension: false ,
421
434
style: TestStyle . xcTest,
422
435
location: Location ( uri: snapshot. uri, range: range) ,
423
436
children: testMethods,
@@ -462,7 +475,7 @@ extension TestItem {
462
475
}
463
476
}
464
477
465
- extension Collection where Element == TestItem {
478
+ extension Array < TestItem > {
466
479
/// When the test scanners discover tests in extensions they are captured in their own parent `TestItem`, not the
467
480
/// `TestItem` generated from the class/struct's definition. This is largely because of the syntatic nature of the
468
481
/// test scanners as they are today, which only know about tests within the context of the current file. Extensions
@@ -477,18 +490,61 @@ extension Collection where Element == TestItem {
477
490
/// This method walks the `TestItem` tree produced by the test scanners and merges in the tests defined in extensions
478
491
/// into the `TestItem` that represents the type definition.
479
492
///
493
+ /// This causes extensions to be merged into their type's definition if the type's definition exists in the list of
494
+ /// test items. If the type's definition is not a test item in this collection, the first extension of that type will
495
+ /// be used as the primary test location.
496
+ ///
497
+ /// For example if there are two files
498
+ ///
499
+ /// FileA.swift
500
+ /// ```swift
501
+ /// @Suite struct MyTests {
502
+ /// @Test func oneIsTwo {}
503
+ /// }
504
+ /// ```
505
+ ///
506
+ /// FileB.swift
507
+ /// ```swift
508
+ /// extension MyTests {
509
+ /// @Test func twoIsThree() {}
510
+ /// }
511
+ /// ```
512
+ ///
513
+ /// Then `workspace/tests` will return
514
+ /// - `MyTests` (FileA.swift:1)
515
+ /// - `oneIsTwo`
516
+ /// - `twoIsThree`
517
+ ///
518
+ /// And `textDocument/tests` for FileB.swift will return
519
+ /// - `MyTests` (FileB.swift:1)
520
+ /// - `twoIsThree`
521
+ ///
480
522
/// A node's parent is identified by the node's ID with the last component dropped.
481
- func mergeTestsInExtensions ( ) -> [ TestItem ] {
523
+ func mergingTestsInExtensions ( ) -> [ TestItem ] {
482
524
var itemDict : [ String : TestItem ] = [ : ]
483
525
for item in self {
484
- if var existingItem = itemDict [ item. id] {
485
- existingItem. children = ( existingItem. children + item. children)
486
- itemDict [ item. id] = existingItem
526
+ if var rootItem = itemDict [ item. id] {
527
+ // If we've encountered an extension first, and this is the
528
+ // type declaration, then use the type declaration TestItem
529
+ // as the root item.
530
+ if rootItem. isExtension && !item. isExtension {
531
+ var newItem = item
532
+ newItem. children += rootItem. children
533
+ rootItem = newItem
534
+ } else {
535
+ rootItem. children += item. children
536
+ }
537
+
538
+ itemDict [ item. id] = rootItem
487
539
} else {
488
540
itemDict [ item. id] = item
489
541
}
490
542
}
491
543
544
+ if itemDict. isEmpty {
545
+ return [ ]
546
+ }
547
+
492
548
for item in self {
493
549
let parentID = item. id. components ( separatedBy: " / " ) . dropLast ( ) . joined ( separator: " / " )
494
550
// If the parent exists, add the current item to its children and remove it from the root
@@ -499,16 +555,22 @@ extension Collection where Element == TestItem {
499
555
}
500
556
}
501
557
502
- // Filter out the items that have been merged into their parents, sorting the tests by location
503
- var reorganizedItems = itemDict. values. compactMap { $0 } . sorted { $0. location < $1. location }
558
+ // Filter out the items that have been merged into their parents, sorting the tests by location.
559
+ // TestItems not in extensions should be priotitized first.
560
+ var sortedItems = itemDict. values. compactMap { $0 } . sorted {
561
+ ( $0. location. uri != $1. location. uri && $0. isExtension != $1. isExtension) ? !$0. isExtension : ( $0. location < $1. location)
562
+ }
504
563
505
- reorganizedItems = reorganizedItems. map ( {
564
+ sortedItems = sortedItems. map {
565
+ guard !$0. children. isEmpty else {
566
+ return $0
567
+ }
506
568
var newItem = $0
507
- newItem. children = $0. children. mergeTestsInExtensions ( )
569
+ newItem. children = $0. children. mergingTestsInExtensions ( )
508
570
return newItem
509
- } )
571
+ }
510
572
511
- return reorganizedItems
573
+ return sortedItems
512
574
}
513
575
}
514
576
0 commit comments