Skip to content

Commit 0685ee7

Browse files
committed
Enhance syntactic test discovery with information from the semantic index
When the semantic index is out-of-date, we currently purely rely on the syntactic index to discover tests and completely ignore data from the semantic index. This may lead to confusing behavior. For example if you have ``` class MightInheritFromXCTestCaseOrNot {} class MyClass: MightInheritFromXCTestCaseOrNot { func testStuff() {} } ``` Then we don’t return any tests when the semantic index is up-to-date. But once the file is modified (either on disk or in-memory), we purely rely on the syntactic index, which reports `testStuff` as a test method. After a build / background indexing finishes, the test method disappears again. We can mitigate this problem as follows: If we have stale semantic index data for the test file, for every test method found by the syntactic index, check if we have an entry for this method in the semantic index. If we do, but that entry is not marked as a test class/method, we know that the semantic index knows about this method but decided that it’s not a test method for some reason. So we should ignore it. rdar://126492948
1 parent bfe4e70 commit 0685ee7

File tree

7 files changed

+210
-30
lines changed

7 files changed

+210
-30
lines changed

Sources/LanguageServerProtocol/SupportTypes/TestItem.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
public struct TestTag: Codable, Equatable, Sendable {
1414
/// ID of the test tag. `TestTag` instances with the same ID are considered to be identical.
15-
public let id: String
15+
public var id: String
1616

1717
public init(id: String) {
1818
self.id = id
@@ -26,35 +26,35 @@ public struct TestItem: ResponseType, Equatable {
2626
/// Identifier for the `TestItem`.
2727
///
2828
/// This identifier uniquely identifies the test case or test suite. It can be used to run an individual test (suite).
29-
public let id: String
29+
public var id: String
3030

3131
/// Display name describing the test.
32-
public let label: String
32+
public var label: String
3333

3434
/// Optional description that appears next to the label.
35-
public let description: String?
35+
public var description: String?
3636

3737
/// A string that should be used when comparing this item with other items.
3838
///
3939
/// When `nil` the `label` is used.
40-
public let sortText: String?
40+
public var sortText: String?
4141

4242
/// Whether the test is disabled.
43-
public let disabled: Bool
43+
public var disabled: Bool
4444

4545
/// The type of test, eg. the testing framework that was used to declare the test.
46-
public let style: String
46+
public var style: String
4747

4848
/// The location of the test item in the source code.
49-
public let location: Location
49+
public var location: Location
5050

5151
/// The children of this test item.
5252
///
5353
/// For a test suite, this may contain the individual test cases or nested suites.
54-
public let children: [TestItem]
54+
public var children: [TestItem]
5555

5656
/// Tags associated with this test item.
57-
public let tags: [TestTag]
57+
public var tags: [TestTag]
5858

5959
public init(
6060
id: String,

Sources/SourceKitLSP/CheckedIndex.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ public class CheckedIndex {
9595
return index.symbolProvider(for: sourceFilePath)
9696
}
9797

98+
public func symbols(inFilePath path: String) -> [Symbol] {
99+
guard self.hasUpToDateUnit(for: URL(fileURLWithPath: path, isDirectory: false)) else {
100+
return []
101+
}
102+
return index.symbols(inFilePath: path)
103+
}
104+
98105
/// Returns all unit test symbol in unit files that reference one of the main files in `mainFilePaths`.
99106
public func unitTests(referencedByMainFiles mainFilePaths: [String]) -> [SymbolOccurrence] {
100107
return index.unitTests(referencedByMainFiles: mainFilePaths).filter { checker.isUpToDate($0.location) }

Sources/SourceKitLSP/LanguageService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ public protocol LanguageService: AnyObject {
198198
/// Perform a syntactic scan of the file at the given URI for test cases and test classes.
199199
///
200200
/// This is used as a fallback to show the test cases in a file if the index for a given file is not up-to-date.
201-
func syntacticDocumentTests(for uri: DocumentURI) async throws -> [TestItem]
201+
func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem]
202202

203203
/// Crash the language server. Should be used for crash recovery testing only.
204204
func _crash() async

Sources/SourceKitLSP/Swift/SyntacticTestIndex.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ actor SyntacticTestIndex {
142142

143143
/// Called when a list of files was updated. Re-scans those files
144144
private func rescanFiles(_ uris: [DocumentURI]) {
145+
logger.info("Syntactically scanning files for tests: \(uris)")
146+
145147
// If we scan a file again, it might have been added after being removed before. Remove it from the list of removed
146148
// files.
147149
removedFiles.subtract(uris)

Sources/SourceKitLSP/TestDiscovery.swift

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -194,30 +194,39 @@ extension SourceKitLSPServer {
194194
)
195195
let filesWithTestsFromSemanticIndex = Set(testsFromSemanticIndex.map(\.location.uri))
196196

197+
let indexOnlyDiscardingDeletedFiles = workspace.index(checkedFor: .deletedFiles)
198+
197199
let syntacticTestsToInclude =
198200
testsFromSyntacticIndex
199-
.filter { testItem in
201+
.compactMap { (testItem) -> TestItem? in
200202
if testItem.style == TestStyle.swiftTesting {
201203
// Swift-testing tests aren't part of the semantic index. Always include them.
202-
return true
204+
return testItem
203205
}
204206
if filesWithTestsFromSemanticIndex.contains(testItem.location.uri) {
205207
// If we have an semantic tests from this file, then the semantic index is up-to-date for this file. We thus
206208
// don't need to include results from the syntactic index.
207-
return false
209+
return nil
208210
}
209211
if filesWithInMemoryState.contains(testItem.location.uri) {
210212
// If the file has been modified in the editor, the syntactic index (which indexes on-disk files) is no longer
211213
// up-to-date. Include the tests from `testsFromFilesWithInMemoryState`.
212-
return false
214+
return nil
213215
}
214216
if let fileUrl = testItem.location.uri.fileURL, index?.hasUpToDateUnit(for: fileUrl) ?? false {
215217
// We don't have a test for this file in the semantic index but an up-to-date unit file. This means that the
216218
// index is up-to-date and has more knowledge that identifies a `TestItem` as not actually being a test, eg.
217219
// because it starts with `test` but doesn't appear in a class inheriting from `XCTestCase`.
218-
return false
220+
return nil
219221
}
220-
return true
222+
// Filter out any test items that we know aren't actually tests based on the semantic index.
223+
// This might call `symbols(inFilePath:)` multiple times if there are multiple top-level test items (ie.
224+
// XCTestCase subclasses, swift-testing handled above) for the same file. In practice test files usually contain
225+
// a single XCTestCase subclass, so caching doesn't make sense here.
226+
// Also, this is only called for files containing test cases but for which the semantic index is out-of-date.
227+
return testItem.filterUsing(
228+
semanticSymbols: indexOnlyDiscardingDeletedFiles?.symbols(inFilePath: testItem.location.uri.pseudoPath)
229+
)
221230
}
222231

223232
// We don't need to sort the tests here because they will get
@@ -252,7 +261,7 @@ extension SourceKitLSPServer {
252261
language: snapshot.language
253262
)
254263

255-
let syntacticTests = try await languageService.syntacticDocumentTests(for: req.textDocument.uri)
264+
let syntacticTests = try await languageService.syntacticDocumentTests(for: req.textDocument.uri, in: workspace)
256265

257266
if let index = workspace.index(checkedFor: .inMemoryModifiedFiles(documentManager)) {
258267
var syntacticSwiftTestingTests: [TestItem] {
@@ -398,13 +407,43 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
398407
}
399408
}
400409

410+
extension TestItem {
411+
/// Use out-of-date semantic information to filter syntactic symbols.
412+
///
413+
/// If the syntactic index found a test item, check if the semantic index knows about a symbol with that name. If it
414+
/// does and that item is not marked as a test symbol, we can reasonably assume that this item still looks like a test
415+
/// but is semantically known to not be a test. It will thus get filtered out.
416+
///
417+
/// `semanticSymbols` should be all the symbols in the source file that this `TestItem` occurs in, retrieved using
418+
/// `symbols(inFilePath:)` from the index.
419+
fileprivate func filterUsing(semanticSymbols: [Symbol]?) -> TestItem? {
420+
guard let semanticSymbols else {
421+
return self
422+
}
423+
// We only check if we know of any symbol with the test item's name in this file. We could try to incorporate
424+
// structure here (ie. look for a method within a class) but that makes the index lookup more difficult and in
425+
// practice it is very unlikely that a test file will have two symbols with the same name, one of which is marked
426+
// as a unit test while the other one is not.
427+
let semanticSymbolsWithName = semanticSymbols.filter { $0.name == self.label }
428+
if !semanticSymbolsWithName.isEmpty,
429+
semanticSymbolsWithName.allSatisfy({ !$0.properties.contains(.unitTest) })
430+
{
431+
return nil
432+
}
433+
var test = self
434+
test.children = test.children.compactMap { $0.filterUsing(semanticSymbols: semanticSymbols) }
435+
return test
436+
}
437+
}
438+
401439
extension SwiftLanguageService {
402-
public func syntacticDocumentTests(for uri: DocumentURI) async throws -> [TestItem] {
440+
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem] {
403441
let snapshot = try documentManager.latestSnapshot(uri)
442+
let semanticSymbols = workspace.index(checkedFor: .deletedFiles)?.symbols(inFilePath: snapshot.uri.pseudoPath)
404443
let xctestSymbols = await SyntacticSwiftXCTestScanner.findTestSymbols(
405444
in: snapshot,
406445
syntaxTreeManager: syntaxTreeManager
407-
)
446+
).compactMap { $0.filterUsing(semanticSymbols: semanticSymbols) }
408447
let swiftTestingSymbols = await SyntacticSwiftTestingTestScanner.findTestSymbols(
409448
in: snapshot,
410449
syntaxTreeManager: syntaxTreeManager
@@ -414,7 +453,7 @@ extension SwiftLanguageService {
414453
}
415454

416455
extension ClangLanguageService {
417-
public func syntacticDocumentTests(for uri: DocumentURI) async -> [TestItem] {
456+
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem] {
418457
return []
419458
}
420459
}

Tests/SourceKitLSPTests/DocumentTestDiscoveryTests.swift

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,4 +993,76 @@ final class DocumentTestDiscoveryTests: XCTestCase {
993993
]
994994
)
995995
}
996+
997+
func testAddNewMethodToNotQuiteTestCase() async throws {
998+
let project = try await IndexedSingleSwiftFileTestProject(
999+
"""
1000+
import XCTest
1001+
1002+
class NotQuiteTest: SomeClass {
1003+
func testMyLibrary() {}
1004+
2️⃣
1005+
}
1006+
""",
1007+
allowBuildFailure: true
1008+
)
1009+
1010+
let testsBeforeEdit = try await project.testClient.send(
1011+
DocumentTestsRequest(textDocument: TextDocumentIdentifier(project.fileURI))
1012+
)
1013+
XCTAssertEqual(testsBeforeEdit, [])
1014+
project.testClient.send(
1015+
DidChangeTextDocumentNotification(
1016+
textDocument: VersionedTextDocumentIdentifier(project.fileURI, version: 2),
1017+
contentChanges: [
1018+
TextDocumentContentChangeEvent(range: Range(project.positions["2️⃣"]), text: "func testSomethingElse() {}")
1019+
]
1020+
)
1021+
)
1022+
let testsAfterEdit = try await project.testClient.send(
1023+
DocumentTestsRequest(textDocument: TextDocumentIdentifier(project.fileURI))
1024+
)
1025+
XCTAssertEqual(testsAfterEdit, [])
1026+
}
1027+
1028+
func testAddNewClassToNotQuiteTestCase() async throws {
1029+
let project = try await IndexedSingleSwiftFileTestProject(
1030+
"""
1031+
import XCTest
1032+
1033+
class NotQuiteTest: SomeClass {
1034+
func testMyLibrary() {}
1035+
}
1036+
2️⃣
1037+
""",
1038+
allowBuildFailure: true
1039+
)
1040+
1041+
let testsBeforeEdit = try await project.testClient.send(
1042+
DocumentTestsRequest(textDocument: TextDocumentIdentifier(project.fileURI))
1043+
)
1044+
XCTAssertEqual(testsBeforeEdit, [])
1045+
project.testClient.send(
1046+
DidChangeTextDocumentNotification(
1047+
textDocument: VersionedTextDocumentIdentifier(project.fileURI, version: 2),
1048+
contentChanges: [
1049+
TextDocumentContentChangeEvent(
1050+
range: Range(project.positions["2️⃣"]),
1051+
text: """
1052+
class OtherNotQuiteTest: SomeClass {
1053+
func testSomethingElse() {}
1054+
}
1055+
"""
1056+
)
1057+
]
1058+
)
1059+
)
1060+
let testsAfterEdit = try await project.testClient.send(
1061+
DocumentTestsRequest(textDocument: TextDocumentIdentifier(project.fileURI))
1062+
)
1063+
// We know from the semantic index that NotQuiteTest does not inherit from XCTestCase, so we should not include it.
1064+
// We don't have any semantic knowledge about `OtherNotQuiteTest`, so we are conservative and should include it.
1065+
XCTAssertFalse(testsAfterEdit.contains { $0.label == "NotQuiteTest" })
1066+
XCTAssertTrue(testsAfterEdit.contains { $0.label == "OtherNotQuiteTest" })
1067+
}
9961068
}

Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -362,14 +362,12 @@ final class WorkspaceTestDiscoveryTests: XCTestCase {
362362
"Tests/MyLibraryTests/MyTests.swift": """
363363
import XCTest
364364
365-
class MayInheritFromXCTestCase {}
366-
367-
1️⃣class MyTests: MayInheritFromXCTestCase {
368-
2️⃣func testMyLibrary0️⃣() {
369-
}3️⃣
365+
1️⃣class 2️⃣MyTests: XCTestCase {
366+
3️⃣func 4️⃣testMyLibrary0️⃣() {
367+
}5️⃣
370368
func unrelatedFunc() {}
371369
var testVariable: Int = 0
372-
}4️⃣
370+
}6️⃣
373371
"""
374372
],
375373
manifest: packageManifestWithTestTarget,
@@ -381,7 +379,30 @@ final class WorkspaceTestDiscoveryTests: XCTestCase {
381379
// If the document has been opened but not modified in-memory, we can still use the semantic index and detect that
382380
// `MyTests` does not inherit from `XCTestCase`.
383381
let testsAfterDocumentOpen = try await project.testClient.send(WorkspaceTestsRequest())
384-
XCTAssertEqual(testsAfterDocumentOpen, [])
382+
XCTAssertEqual(
383+
testsAfterDocumentOpen,
384+
[
385+
TestItem(
386+
id: "MyTests",
387+
label: "MyTests",
388+
disabled: false,
389+
style: TestStyle.xcTest,
390+
location: Location(uri: uri, range: positions["2️⃣"]..<positions["2️⃣"]),
391+
children: [
392+
TestItem(
393+
id: "MyTests/testMyLibrary()",
394+
label: "testMyLibrary()",
395+
disabled: false,
396+
style: TestStyle.xcTest,
397+
location: Location(uri: uri, range: positions["4️⃣"]..<positions["4️⃣"]),
398+
children: [],
399+
tags: []
400+
)
401+
],
402+
tags: []
403+
)
404+
]
405+
)
385406

386407
// After we have an in-memory change to the file, we can't use the semantic index to discover the tests anymore.
387408
// Use the syntactic index instead.
@@ -403,14 +424,14 @@ final class WorkspaceTestDiscoveryTests: XCTestCase {
403424
label: "MyTests",
404425
disabled: false,
405426
style: TestStyle.xcTest,
406-
location: Location(uri: uri, range: positions["1️⃣"]..<positions["4️⃣"]),
427+
location: Location(uri: uri, range: positions["1️⃣"]..<positions["6️⃣"]),
407428
children: [
408429
TestItem(
409430
id: "MyTests/testMyLibraryUpdated()",
410431
label: "testMyLibraryUpdated()",
411432
disabled: false,
412433
style: TestStyle.xcTest,
413-
location: Location(uri: uri, range: positions["2️⃣"]..<positions["3️⃣"]),
434+
location: Location(uri: uri, range: positions["3️⃣"]..<positions["5️⃣"]),
414435
children: [],
415436
tags: []
416437
)
@@ -848,4 +869,43 @@ final class WorkspaceTestDiscoveryTests: XCTestCase {
848869
let tests = try await project.testClient.send(WorkspaceTestsRequest())
849870
XCTAssertEqual(tests, [])
850871
}
872+
873+
func testAddNewClassToNotQuiteTestCase() async throws {
874+
let originalContents = """
875+
import XCTest
876+
877+
class NotQuiteTest: SomeClass {
878+
func testMyLibrary() {}
879+
}
880+
881+
"""
882+
883+
let project = try await IndexedSingleSwiftFileTestProject(originalContents, allowBuildFailure: true)
884+
// Close the file so we don't have an in-memory version of it.
885+
project.testClient.send(DidCloseTextDocumentNotification(textDocument: TextDocumentIdentifier(project.fileURI)))
886+
887+
let testsBeforeEdit = try await project.testClient.send(WorkspaceTestsRequest())
888+
XCTAssertEqual(testsBeforeEdit, [])
889+
890+
let addedTest = """
891+
class OtherNotQuiteTest: SomeClass {
892+
func testSomethingElse() {}
893+
}
894+
"""
895+
896+
let uri = try XCTUnwrap(project.fileURI.fileURL)
897+
898+
try (originalContents + addedTest).write(to: uri, atomically: true, encoding: .utf8)
899+
900+
project.testClient.send(
901+
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: project.fileURI, type: .changed)])
902+
)
903+
904+
let testsAfterEdit = try await project.testClient.send(WorkspaceTestsRequest())
905+
print(testsAfterEdit)
906+
// We know from the semantic index that NotQuiteTest does not inherit from XCTestCase, so we should not include it.
907+
// We don't have any semantic knowledge about `OtherNotQuiteTest`, so we are conservative and should include it.
908+
XCTAssertFalse(testsAfterEdit.contains { $0.label == "NotQuiteTest" })
909+
XCTAssertTrue(testsAfterEdit.contains { $0.label == "OtherNotQuiteTest" })
910+
}
851911
}

0 commit comments

Comments
 (0)