Skip to content

Commit d1cf231

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 c6c3d74 commit d1cf231

File tree

7 files changed

+179
-21
lines changed

7 files changed

+179
-21
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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ actor SyntacticTestIndex {
138138

139139
/// Called when a list of files was updated. Re-scans those files
140140
private func rescanFiles(_ uris: [DocumentURI]) {
141+
logger.info("Syntactically scanning files for tests: \(uris)")
141142
// Divide the files into multiple batches. This is more efficient than spawning a new task for every file, mostly
142143
// because it keeps the number of pending items in `indexingQueue` low and adding a new task to `indexingQueue` is
143144
// in O(number of pending tasks), since we need to scan for dependency edges to add, which would make scanning files

Sources/SourceKitLSP/TestDiscovery.swift

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

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

225234
// We don't need to sort the tests here because they will get
@@ -254,7 +263,7 @@ extension SourceKitLSPServer {
254263
language: snapshot.language
255264
)
256265

257-
let syntacticTests = try await languageService.syntacticDocumentTests(for: req.textDocument.uri)
266+
let syntacticTests = try await languageService.syntacticDocumentTests(for: req.textDocument.uri, in: workspace)
258267

259268
if let index = workspace.index(checkedFor: .imMemoryModifiedFiles(documentManager)) {
260269
var syntacticSwiftTestingTests: [TestItem] {
@@ -400,13 +409,43 @@ final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
400409
}
401410
}
402411

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

418457
extension ClangLanguageService {
419-
public func syntacticDocumentTests(for uri: DocumentURI) async -> [TestItem] {
458+
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem] {
420459
return []
421460
}
422461
}

Tests/SourceKitLSPTests/DocumentTestDiscoveryTests.swift

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,4 +840,76 @@ final class DocumentTestDiscoveryTests: XCTestCase {
840840
]
841841
)
842842
}
843+
844+
func testAddNewMethodToNotQuiteTestCase() async throws {
845+
let project = try await IndexedSingleSwiftFileTestProject(
846+
"""
847+
import XCTest
848+
849+
class NotQuiteTest: SomeClass {
850+
func testMyLibrary() {}
851+
2️⃣
852+
}
853+
""",
854+
allowBuildFailure: true
855+
)
856+
857+
let testsBeforeEdit = try await project.testClient.send(
858+
DocumentTestsRequest(textDocument: TextDocumentIdentifier(project.fileURI))
859+
)
860+
XCTAssertEqual(testsBeforeEdit, [])
861+
project.testClient.send(
862+
DidChangeTextDocumentNotification(
863+
textDocument: VersionedTextDocumentIdentifier(project.fileURI, version: 2),
864+
contentChanges: [
865+
TextDocumentContentChangeEvent(range: Range(project.positions["2️⃣"]), text: "func testSomethingElse() {}")
866+
]
867+
)
868+
)
869+
let testsAfterEdit = try await project.testClient.send(
870+
DocumentTestsRequest(textDocument: TextDocumentIdentifier(project.fileURI))
871+
)
872+
XCTAssertEqual(testsAfterEdit, [])
873+
}
874+
875+
func testAddNewClassToNotQuiteTestCase() async throws {
876+
let project = try await IndexedSingleSwiftFileTestProject(
877+
"""
878+
import XCTest
879+
880+
class NotQuiteTest: SomeClass {
881+
func testMyLibrary() {}
882+
}
883+
2️⃣
884+
""",
885+
allowBuildFailure: true
886+
)
887+
888+
let testsBeforeEdit = try await project.testClient.send(
889+
DocumentTestsRequest(textDocument: TextDocumentIdentifier(project.fileURI))
890+
)
891+
XCTAssertEqual(testsBeforeEdit, [])
892+
project.testClient.send(
893+
DidChangeTextDocumentNotification(
894+
textDocument: VersionedTextDocumentIdentifier(project.fileURI, version: 2),
895+
contentChanges: [
896+
TextDocumentContentChangeEvent(
897+
range: Range(project.positions["2️⃣"]),
898+
text: """
899+
class OtherNotQuiteTest: SomeClass {
900+
func testSomethingElse() {}
901+
}
902+
"""
903+
)
904+
]
905+
)
906+
)
907+
let testsAfterEdit = try await project.testClient.send(
908+
DocumentTestsRequest(textDocument: TextDocumentIdentifier(project.fileURI))
909+
)
910+
// We know from the semantic index that NotQuiteTest does not inherit from XCTestCase, so we should not include it.
911+
// We don't have any semantic knowledge about `OtherNotQuiteTest`, so we are conservative and should include it.
912+
XCTAssertFalse(testsAfterEdit.contains { $0.label == "NotQuiteTest" })
913+
XCTAssertTrue(testsAfterEdit.contains { $0.label == "OtherNotQuiteTest" })
914+
}
843915
}

Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,4 +748,43 @@ final class WorkspaceTestDiscoveryTests: XCTestCase {
748748
]
749749
)
750750
}
751+
752+
func testAddNewClassToNotQuiteTestCase() async throws {
753+
let originalContents = """
754+
import XCTest
755+
756+
class NotQuiteTest: SomeClass {
757+
func testMyLibrary() {}
758+
}
759+
760+
"""
761+
762+
let project = try await IndexedSingleSwiftFileTestProject(originalContents, allowBuildFailure: true)
763+
// Close the file so we don't have an in-memory version of it.
764+
project.testClient.send(DidCloseTextDocumentNotification(textDocument: TextDocumentIdentifier(project.fileURI)))
765+
766+
let testsBeforeEdit = try await project.testClient.send(WorkspaceTestsRequest())
767+
XCTAssertEqual(testsBeforeEdit, [])
768+
769+
let addedTest = """
770+
class OtherNotQuiteTest: SomeClass {
771+
func testSomethingElse() {}
772+
}
773+
"""
774+
775+
let uri = try XCTUnwrap(project.fileURI.fileURL)
776+
777+
try (originalContents + addedTest).write(to: uri, atomically: true, encoding: .utf8)
778+
779+
project.testClient.send(
780+
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: project.fileURI, type: .changed)])
781+
)
782+
783+
let testsAfterEdit = try await project.testClient.send(WorkspaceTestsRequest())
784+
print(testsAfterEdit)
785+
// We know from the semantic index that NotQuiteTest does not inherit from XCTestCase, so we should not include it.
786+
// We don't have any semantic knowledge about `OtherNotQuiteTest`, so we are conservative and should include it.
787+
XCTAssertFalse(testsAfterEdit.contains { $0.label == "NotQuiteTest" })
788+
XCTAssertTrue(testsAfterEdit.contains { $0.label == "OtherNotQuiteTest" })
789+
}
751790
}

0 commit comments

Comments
 (0)