Skip to content

Commit 857cb61

Browse files
plemarquandahoppen
authored andcommitted
XCTests in extensions have no parent ID
If the workspace has been indexed then TestItems are pulled from indexed symbols. In this code path XCTests defined in extensions don't have their parent class name appended to their ID. As a result, when the user freshly opened a test file that contained XCTests defined in extensions then their IDs would be incorrect. However as soon as they made a change to the file then the method to produce the document tests would switch over to getting the tests from `languageService.syntatciDocumentTests(for:in:)` which does handle tests in extensions correctly, and the issue would dissapear.
1 parent 323046f commit 857cb61

File tree

4 files changed

+130
-27
lines changed

4 files changed

+130
-27
lines changed

Sources/SemanticIndex/CheckedIndex.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,28 @@ package final class CheckedIndex {
161161
package func fileHasInMemoryModifications(_ uri: DocumentURI) -> Bool {
162162
return checker.fileHasInMemoryModifications(uri)
163163
}
164+
165+
/// If there are any definition occurrences of the given USR, return these.
166+
/// Otherwise return declaration occurrences.
167+
package func definitionOrDeclarationOccurrences(ofUSR usr: String) -> [SymbolOccurrence] {
168+
let definitions = occurrences(ofUSR: usr, roles: [.definition])
169+
if !definitions.isEmpty {
170+
return definitions
171+
}
172+
return occurrences(ofUSR: usr, roles: [.declaration])
173+
}
174+
175+
/// Find a `SymbolOccurrence` that is considered the primary definition of the symbol with the given USR.
176+
///
177+
/// If the USR has an ambiguous definition, the most important role of this function is to deterministically return
178+
/// the same result every time.
179+
package func primaryDefinitionOrDeclarationOccurrence(ofUSR usr: String) -> SymbolOccurrence? {
180+
let result = definitionOrDeclarationOccurrences(ofUSR: usr).sorted().first
181+
if result == nil {
182+
logger.error("Failed to find definition of \(usr) in index")
183+
}
184+
return result
185+
}
164186
}
165187

166188
/// A wrapper around `IndexStoreDB` that allows the retrieval of a `CheckedIndex` with a specified check level or the

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2402,28 +2402,6 @@ private let maxWorkspaceSymbolResults = 4096
24022402
package typealias Diagnostic = LanguageServerProtocol.Diagnostic
24032403

24042404
fileprivate extension CheckedIndex {
2405-
/// If there are any definition occurrences of the given USR, return these.
2406-
/// Otherwise return declaration occurrences.
2407-
func definitionOrDeclarationOccurrences(ofUSR usr: String) -> [SymbolOccurrence] {
2408-
let definitions = occurrences(ofUSR: usr, roles: [.definition])
2409-
if !definitions.isEmpty {
2410-
return definitions
2411-
}
2412-
return occurrences(ofUSR: usr, roles: [.declaration])
2413-
}
2414-
2415-
/// Find a `SymbolOccurrence` that is considered the primary definition of the symbol with the given USR.
2416-
///
2417-
/// If the USR has an ambiguous definition, the most important role of this function is to deterministically return
2418-
/// the same result every time.
2419-
func primaryDefinitionOrDeclarationOccurrence(ofUSR usr: String) -> SymbolOccurrence? {
2420-
let result = definitionOrDeclarationOccurrences(ofUSR: usr).sorted().first
2421-
if result == nil {
2422-
logger.error("Failed to find definition of \(usr) in index")
2423-
}
2424-
return result
2425-
}
2426-
24272405
/// Get the name of the symbol that is a parent of this symbol, if one exists
24282406
func containerName(of symbol: SymbolOccurrence) -> String? {
24292407
// The container name of accessors is the container of the surrounding variable.

Sources/SourceKitLSP/TestDiscovery.swift

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,19 +102,34 @@ extension SourceKitLSPServer {
102102
/// provide ranges for the test cases in source code instead of only the test's location that we get from the index.
103103
private func testItems(
104104
for testSymbolOccurrences: [SymbolOccurrence],
105+
index: CheckedIndex?,
105106
resolveLocation: (DocumentURI, Position) -> Location
106107
) -> [AnnotatedTestItem] {
107108
// Arrange tests by the USR they are contained in. This allows us to emit test methods as children of test classes.
108109
// `occurrencesByParent[nil]` are the root test symbols that aren't a child of another test symbol.
109110
var occurrencesByParent: [String?: [SymbolOccurrence]] = [:]
110111

111-
let testSymbolUsrs = Set(testSymbolOccurrences.map(\.symbol.usr))
112+
var testSymbolUsrs = Set(testSymbolOccurrences.map(\.symbol.usr))
113+
114+
// Gather any extension declarations that contains tests and add them to `occurrencesByParent` so we can properly
115+
// arrange their test items as the extension's children.
116+
for testSymbolOccurrence in testSymbolOccurrences {
117+
for parentSymbol in testSymbolOccurrence.relations.filter({ $0.roles.contains(.childOf) }).map(\.symbol) {
118+
guard parentSymbol.kind == .extension else {
119+
continue
120+
}
121+
guard let definition = index?.primaryDefinitionOrDeclarationOccurrence(ofUSR: parentSymbol.usr) else {
122+
logger.fault("Unable to find primary definition of extension '\(parentSymbol.usr)' containing tests")
123+
continue
124+
}
125+
testSymbolUsrs.insert(parentSymbol.usr)
126+
occurrencesByParent[nil, default: []].append(definition)
127+
}
128+
}
112129

113130
for testSymbolOccurrence in testSymbolOccurrences {
114131
let childOfUsrs = testSymbolOccurrence.relations
115-
.filter { $0.roles.contains(.childOf) }
116-
.map(\.symbol.usr)
117-
.filter { testSymbolUsrs.contains($0) }
132+
.filter { $0.roles.contains(.childOf) }.map(\.symbol.usr).filter { testSymbolUsrs.contains($0) }
118133
if childOfUsrs.count > 1 {
119134
logger.fault(
120135
"Test symbol \(testSymbolOccurrence.symbol.usr) is child or multiple symbols: \(childOfUsrs.joined(separator: ", "))"
@@ -168,7 +183,7 @@ extension SourceKitLSPServer {
168183
children: children.map(\.testItem),
169184
tags: []
170185
),
171-
isExtension: false
186+
isExtension: testSymbolOccurrence.symbol.kind == .extension
172187
)
173188
}
174189

@@ -233,6 +248,7 @@ extension SourceKitLSPServer {
233248
let testsFromSyntacticIndex = await workspace.syntacticTestIndex.tests()
234249
let testsFromSemanticIndex = testItems(
235250
for: semanticTestSymbolOccurrences,
251+
index: index,
236252
resolveLocation: { uri, position in Location(uri: uri, range: Range(position)) }
237253
)
238254
let filesWithTestsFromSemanticIndex = Set(testsFromSemanticIndex.map(\.testItem.location.uri))
@@ -337,6 +353,7 @@ extension SourceKitLSPServer {
337353
// swift-testing tests, which aren't part of the semantic index.
338354
return testItems(
339355
for: testSymbols,
356+
index: index,
340357
resolveLocation: { uri, position in
341358
if uri == snapshot.uri, let documentSymbols,
342359
let range = findInnermostSymbolRange(containing: position, documentSymbolsResponse: documentSymbols)

Tests/SourceKitLSPTests/DocumentTestDiscoveryTests.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1340,6 +1340,92 @@ final class DocumentTestDiscoveryTests: XCTestCase {
13401340
)
13411341
}
13421342

1343+
func testXCTestIndexedTestsWithExtension() async throws {
1344+
let project = try await IndexedSingleSwiftFileTestProject(
1345+
"""
1346+
import XCTest
1347+
1348+
1️⃣final class MyTests: XCTestCase {}2️⃣
1349+
1350+
extension MyTests {
1351+
3️⃣func testOneIsTwo() {}4️⃣
1352+
}
1353+
""",
1354+
allowBuildFailure: true
1355+
)
1356+
1357+
let tests = try await project.testClient.send(
1358+
DocumentTestsRequest(textDocument: TextDocumentIdentifier(project.fileURI))
1359+
)
1360+
XCTAssertEqual(
1361+
tests,
1362+
[
1363+
TestItem(
1364+
id: "MyTests",
1365+
label: "MyTests",
1366+
location: Location(uri: project.fileURI, range: project.positions["1️⃣"]..<project.positions["2️⃣"]),
1367+
children: [
1368+
TestItem(
1369+
id: "MyTests/testOneIsTwo()",
1370+
label: "testOneIsTwo()",
1371+
location: Location(uri: project.fileURI, range: project.positions["3️⃣"]..<project.positions["4️⃣"])
1372+
)
1373+
]
1374+
)
1375+
]
1376+
)
1377+
}
1378+
1379+
func testXCTestIndexedTestsWithExtensionInSeparateFile() async throws {
1380+
let project = try await SwiftPMTestProject(
1381+
files: [
1382+
"Tests/MyLibraryTests/MyTests.swift": """
1383+
import XCTest
1384+
1385+
class MyTests: XCTestCase {
1386+
}
1387+
""",
1388+
"Tests/MyLibraryTests/MoreTests.swift": """
1389+
import XCTest
1390+
1391+
1️⃣extension MyTests {
1392+
3️⃣func testMe() {}4️⃣
1393+
}2️⃣
1394+
""",
1395+
],
1396+
manifest: """
1397+
let package = Package(
1398+
name: "MyLibrary",
1399+
targets: [.testTarget(name: "MyLibraryTests")]
1400+
)
1401+
""",
1402+
enableBackgroundIndexing: true
1403+
)
1404+
1405+
let (uri, positions) = try project.openDocument("MoreTests.swift")
1406+
1407+
let tests = try await project.testClient.send(
1408+
DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri))
1409+
)
1410+
XCTAssertEqual(
1411+
tests,
1412+
[
1413+
TestItem(
1414+
id: "MyLibraryTests.MyTests",
1415+
label: "MyTests",
1416+
location: Location(uri: uri, range: positions["1️⃣"]..<positions["2️⃣"]),
1417+
children: [
1418+
TestItem(
1419+
id: "MyLibraryTests.MyTests/testMe()",
1420+
label: "testMe()",
1421+
location: Location(uri: uri, range: positions["3️⃣"]..<positions["4️⃣"])
1422+
)
1423+
]
1424+
)
1425+
]
1426+
)
1427+
}
1428+
13431429
func testXCTestInvalidXCTestSuiteConstructions() async throws {
13441430
let testClient = try await TestSourceKitLSPClient()
13451431
let uri = DocumentURI(for: .swift)

0 commit comments

Comments
 (0)