Skip to content

Commit 3fb3a99

Browse files
authored
Merge pull request #1195 from ahoppen/ahoppen/filter-syntactic-tests-based-on-outdated-index
Enhance syntactic test discovery with information from the semantic index
2 parents 1171e9f + 0685ee7 commit 3fb3a99

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)