Skip to content

Commit 3e87f21

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 047a7d8 commit 3e87f21

File tree

5 files changed

+313
-20
lines changed

5 files changed

+313
-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
@@ -134,6 +134,7 @@ extension SourceKitLSPServer {
134134
id: id,
135135
label: testSymbolOccurrence.symbol.name,
136136
disabled: false,
137+
isExtension: false,
137138
style: TestStyle.xcTest,
138139
location: location,
139140
children: children,
@@ -177,7 +178,7 @@ extension SourceKitLSPServer {
177178
return []
178179
}
179180
return await orLog("Getting document tests for \(uri)") {
180-
try await self.documentTests(
181+
try await self.getDocumentTests(
181182
DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)),
182183
workspace: workspace,
183184
languageService: languageService
@@ -238,6 +239,7 @@ extension SourceKitLSPServer {
238239
.concurrentMap { await self.tests(in: $0) }
239240
.flatMap { $0 }
240241
.sorted { $0.location < $1.location }
242+
.mergingTestsInExtensions()
241243
}
242244

243245
/// Extracts a flat dictionary mapping test IDs to their locations from the given `testItems`.
@@ -254,6 +256,15 @@ extension SourceKitLSPServer {
254256
_ req: DocumentTestsRequest,
255257
workspace: Workspace,
256258
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
257268
) async throws -> [TestItem] {
258269
let snapshot = try self.documentManager.latestSnapshot(req.textDocument.uri)
259270
let mainFileUri = await workspace.buildSystemManager.mainFile(
@@ -331,7 +342,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
331342
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
332343
let visitor = SyntacticSwiftXCTestScanner(snapshot: snapshot)
333344
visitor.walk(syntaxTree)
334-
return visitor.result.mergeTestsInExtensions()
345+
return visitor.result
335346
}
336347

337348
private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [TestItem] {
@@ -361,6 +372,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
361372
id: "\(containerName)/\(function.name.text)()",
362373
label: "\(function.name.text)()",
363374
disabled: false,
375+
isExtension: false,
364376
style: TestStyle.xcTest,
365377
location: Location(uri: snapshot.uri, range: range),
366378
children: [],
@@ -392,6 +404,7 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
392404
id: node.name.text,
393405
label: node.name.text,
394406
disabled: false,
407+
isExtension: false,
395408
style: TestStyle.xcTest,
396409
location: Location(uri: snapshot.uri, range: range),
397410
children: testMethods,
@@ -436,7 +449,7 @@ extension TestItem {
436449
}
437450
}
438451

439-
extension Collection where Element == TestItem {
452+
extension Array<TestItem> {
440453
/// When the test scanners discover tests in extensions they are captured in their own parent `TestItem`, not the
441454
/// `TestItem` generated from the class/struct's definition. This is largely because of the syntatic nature of the
442455
/// 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 {
451464
/// This method walks the `TestItem` tree produced by the test scanners and merges in the tests defined in extensions
452465
/// into the `TestItem` that represents the type definition.
453466
///
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+
///
454496
/// A node's parent is identified by the node's ID with the last component dropped.
455-
func mergeTestsInExtensions() -> [TestItem] {
497+
func mergingTestsInExtensions() -> [TestItem] {
456498
var itemDict: [String: TestItem] = [:]
457499
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
461513
} else {
462514
itemDict[item.id] = item
463515
}
464516
}
465517

518+
if itemDict.isEmpty {
519+
return []
520+
}
521+
466522
for item in self {
467523
let parentID = item.id.components(separatedBy: "/").dropLast().joined(separator: "/")
468524
// 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 {
473529
}
474530
}
475531

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

479-
reorganizedItems = reorganizedItems.map({
538+
sortedItems = sortedItems.map {
539+
guard !$0.children.isEmpty else {
540+
return $0
541+
}
480542
var newItem = $0
481-
newItem.children = $0.children.mergeTestsInExtensions()
543+
newItem.children = $0.children.mergingTestsInExtensions()
482544
return newItem
483-
})
545+
}
484546

485-
return reorganizedItems
547+
return sortedItems
486548
}
487549
}
488550

0 commit comments

Comments
 (0)