Skip to content

Commit adfae1a

Browse files
committed
Prioritize tests defined in type definition over those in extensions
Sort the list of test items prioritizing those defined in the originating type definition over those in extensions.
1 parent 03e2b4f commit adfae1a

File tree

5 files changed

+321
-20
lines changed

5 files changed

+321
-20
lines changed

Sources/LanguageServerProtocol/SupportTypes/TestItem.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,16 @@ public struct TestItem: ResponseType, Equatable {
5656
/// Tags associated with this test item.
5757
public var tags: [TestTag]
5858

59+
/// Whether the `TestItem` is declared in an extension.
60+
public var isExtension: Bool
61+
5962
public init(
6063
id: String,
6164
label: String,
6265
description: String? = nil,
6366
sortText: String? = nil,
6467
disabled: Bool,
68+
isExtension: Bool,
6569
style: String,
6670
location: Location,
6771
children: [TestItem],
@@ -72,6 +76,7 @@ public struct TestItem: ResponseType, Equatable {
7276
self.description = description
7377
self.sortText = sortText
7478
self.disabled = disabled
79+
self.isExtension = isExtension
7580
self.style = style
7681
self.location = location
7782
self.children = children

Sources/SourceKitLSP/Swift/SwiftTestingScanner.swift

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ final class SyntacticSwiftTestingTestScanner: SyntaxVisitor {
163163
/// This is the case when the scanner is looking for tests inside a disabled suite.
164164
private let allTestsDisabled: Bool
165165

166+
/// Whether the tests discovered by the scanner should be marked as being delcared in an extension.
167+
private let isScanningExtension: Bool
168+
166169
/// The names of the types that this scanner is scanning members for.
167170
///
168171
/// For example, when scanning for tests inside `Bar` in the following, this is `["Foo", "Bar"]`
@@ -179,10 +182,16 @@ final class SyntacticSwiftTestingTestScanner: SyntaxVisitor {
179182
/// The discovered test items.
180183
private var result: [TestItem] = []
181184

182-
private init(snapshot: DocumentSnapshot, allTestsDisabled: Bool, parentTypeNames: [String]) {
185+
private init(
186+
snapshot: DocumentSnapshot,
187+
allTestsDisabled: Bool,
188+
isScanningExtension: Bool,
189+
parentTypeNames: [String]
190+
) {
183191
self.snapshot = snapshot
184192
self.allTestsDisabled = allTestsDisabled
185193
self.parentTypeNames = parentTypeNames
194+
self.isScanningExtension = isScanningExtension
186195
super.init(viewMode: .fixedUp)
187196
}
188197

@@ -199,9 +208,14 @@ final class SyntacticSwiftTestingTestScanner: SyntaxVisitor {
199208
return []
200209
}
201210
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
202-
let visitor = SyntacticSwiftTestingTestScanner(snapshot: snapshot, allTestsDisabled: false, parentTypeNames: [])
211+
let visitor = SyntacticSwiftTestingTestScanner(
212+
snapshot: snapshot,
213+
allTestsDisabled: false,
214+
isScanningExtension: false,
215+
parentTypeNames: []
216+
)
203217
visitor.walk(syntaxTree)
204-
return visitor.result.mergeTestsInExtensions()
218+
return visitor.result
205219
}
206220

207221
/// Visit a class/struct/... or extension declaration.
@@ -231,6 +245,7 @@ final class SyntacticSwiftTestingTestScanner: SyntaxVisitor {
231245
let memberScanner = SyntacticSwiftTestingTestScanner(
232246
snapshot: snapshot,
233247
allTestsDisabled: attributeData?.isDisabled ?? false,
248+
isScanningExtension: node is ExtensionDeclSyntax,
234249
parentTypeNames: parentTypeNames + typeNames
235250
)
236251
memberScanner.walk(node.memberBlock)
@@ -245,6 +260,7 @@ final class SyntacticSwiftTestingTestScanner: SyntaxVisitor {
245260
id: (parentTypeNames + typeNames).joined(separator: "/"),
246261
label: attributeData?.displayName ?? typeNames.last!,
247262
disabled: (attributeData?.isDisabled ?? false) || allTestsDisabled,
263+
isExtension: node is ExtensionDeclSyntax,
248264
style: TestStyle.swiftTesting,
249265
location: Location(uri: snapshot.uri, range: range),
250266
children: memberScanner.result,
@@ -299,6 +315,7 @@ final class SyntacticSwiftTestingTestScanner: SyntaxVisitor {
299315
id: (parentTypeNames + [name]).joined(separator: "/"),
300316
label: attributeData.displayName ?? name,
301317
disabled: attributeData.isDisabled || allTestsDisabled,
318+
isExtension: isScanningExtension,
302319
style: TestStyle.swiftTesting,
303320
location: Location(uri: snapshot.uri, range: range),
304321
children: [],

Sources/SourceKitLSP/TestDiscovery.swift

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ extension SourceKitLSPServer {
154154
id: id,
155155
label: testSymbolOccurrence.symbol.name,
156156
disabled: false,
157+
isExtension: false,
157158
style: TestStyle.xcTest,
158159
location: location,
159160
children: children,
@@ -197,7 +198,7 @@ extension SourceKitLSPServer {
197198
return []
198199
}
199200
return await orLog("Getting document tests for \(uri)") {
200-
try await self.documentTests(
201+
try await self.getDocumentTests(
201202
DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)),
202203
workspace: workspace,
203204
languageService: languageService
@@ -258,6 +259,7 @@ extension SourceKitLSPServer {
258259
.concurrentMap { await self.tests(in: $0) }
259260
.flatMap { $0 }
260261
.sorted { $0.location < $1.location }
262+
.mergingTestsInExtensions()
261263
}
262264

263265
/// Extracts a flat dictionary mapping test IDs to their locations from the given `testItems`.
@@ -274,6 +276,15 @@ extension SourceKitLSPServer {
274276
_ req: DocumentTestsRequest,
275277
workspace: Workspace,
276278
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
277288
) async throws -> [TestItem] {
278289
let snapshot = try self.documentManager.latestSnapshot(req.textDocument.uri)
279290
let mainFileUri = await workspace.buildSystemManager.mainFile(
@@ -357,7 +368,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
357368
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
358369
let visitor = SyntacticSwiftXCTestScanner(snapshot: snapshot)
359370
visitor.walk(syntaxTree)
360-
return visitor.result.mergeTestsInExtensions()
371+
return visitor.result
361372
}
362373

363374
private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [TestItem] {
@@ -387,6 +398,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
387398
id: "\(containerName)/\(function.name.text)()",
388399
label: "\(function.name.text)()",
389400
disabled: false,
401+
isExtension: false,
390402
style: TestStyle.xcTest,
391403
location: Location(uri: snapshot.uri, range: range),
392404
children: [],
@@ -418,6 +430,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
418430
id: node.name.text,
419431
label: node.name.text,
420432
disabled: false,
433+
isExtension: false,
421434
style: TestStyle.xcTest,
422435
location: Location(uri: snapshot.uri, range: range),
423436
children: testMethods,
@@ -462,7 +475,7 @@ extension TestItem {
462475
}
463476
}
464477

465-
extension Collection where Element == TestItem {
478+
extension Array<TestItem> {
466479
/// When the test scanners discover tests in extensions they are captured in their own parent `TestItem`, not the
467480
/// `TestItem` generated from the class/struct's definition. This is largely because of the syntatic nature of the
468481
/// 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 {
477490
/// This method walks the `TestItem` tree produced by the test scanners and merges in the tests defined in extensions
478491
/// into the `TestItem` that represents the type definition.
479492
///
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+
///
480522
/// A node's parent is identified by the node's ID with the last component dropped.
481-
func mergeTestsInExtensions() -> [TestItem] {
523+
func mergingTestsInExtensions() -> [TestItem] {
482524
var itemDict: [String: TestItem] = [:]
483525
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
487539
} else {
488540
itemDict[item.id] = item
489541
}
490542
}
491543

544+
if itemDict.isEmpty {
545+
return []
546+
}
547+
492548
for item in self {
493549
let parentID = item.id.components(separatedBy: "/").dropLast().joined(separator: "/")
494550
// 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 {
499555
}
500556
}
501557

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+
}
504563

505-
reorganizedItems = reorganizedItems.map({
564+
sortedItems = sortedItems.map {
565+
guard !$0.children.isEmpty else {
566+
return $0
567+
}
506568
var newItem = $0
507-
newItem.children = $0.children.mergeTestsInExtensions()
569+
newItem.children = $0.children.mergingTestsInExtensions()
508570
return newItem
509-
})
571+
}
510572

511-
return reorganizedItems
573+
return sortedItems
512574
}
513575
}
514576

0 commit comments

Comments
 (0)