Skip to content

Commit bce982f

Browse files
committed
Fix test discovery for Objective-C XCTests
There were two issues with Objective-C XCTest discovery: 1. We were relying on syntactic test discovery after a document is edited. But since we don't have syntactic test discovery for Objective-C tests, this meant that all tests would disappear as a document got edited. Until we get syntactic test discovery for Objective-C, use the semantic index to discover tests, even if they are out-of-date. 2. We were assuming that the `DocumentSymbols` request returned `[DocumentSymbol]` to find the ranges of tests. But clangd returns the alternative `[SymbolInformation]`, which meant that we were only returning the position of a test function’s name instead of the test function’s range. rdar://126810202
1 parent d1cf231 commit bce982f

File tree

5 files changed

+309
-22
lines changed

5 files changed

+309
-22
lines changed

Sources/SourceKitLSP/CheckedIndex.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,13 @@ private struct IndexOutOfDateChecker {
186186
let url = URL(fileURLWithPath: symbolLocation.path, isDirectory: false)
187187
switch checkLevel {
188188
case .imMemoryModifiedFiles(let documentManager):
189-
if hasFileInMemoryModifications(at: url.resolvingSymlinksInPath(), documentManager: documentManager) {
189+
if hasFileInMemoryModifications(at: url, documentManager: documentManager) {
190190
return false
191191
}
192192
fallthrough
193193
case .modifiedFiles:
194194
do {
195-
let sourceFileModificationDate = try modificationDate(of: url)
195+
let sourceFileModificationDate = try modificationDate(of: url.resolvingSymlinksInPath())
196196
switch sourceFileModificationDate {
197197
case .fileDoesNotExist:
198198
return false

Sources/SourceKitLSP/LanguageService.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ 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, in workspace: Workspace) async throws -> [TestItem]
201+
///
202+
/// A return value of `nil` indicates that this language service does not support syntactic test discovery.
203+
func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem]?
202204

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

Sources/SourceKitLSP/TestDiscovery.swift

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,22 +41,26 @@ fileprivate extension SymbolOccurrence {
4141
/// Find the innermost range of a document symbol that contains the given position.
4242
private func findInnermostSymbolRange(
4343
containing position: Position,
44-
documentSymbols documentSymbolsResponse: DocumentSymbolResponse
44+
documentSymbolsResponse: DocumentSymbolResponse
4545
) -> Range<Position>? {
46-
guard case .documentSymbols(let documentSymbols) = documentSymbolsResponse else {
47-
// Both `ClangLanguageService` and `SwiftLanguageService` return `documentSymbols` so we don't need to handle the
48-
// .symbolInformation case.
49-
logger.fault(
50-
"""
51-
Expected documentSymbols response from language service to resolve test ranges but got \
52-
\(documentSymbolsResponse.forLogging)
53-
"""
54-
)
55-
return nil
46+
switch documentSymbolsResponse {
47+
case .documentSymbols(let documentSymbols):
48+
return findInnermostSymbolRange(containing: position, documentSymbols: documentSymbols)
49+
case .symbolInformation(let symbolInformation):
50+
return findInnermostSymbolRange(containing: position, symbolInformation: symbolInformation)
5651
}
52+
}
53+
54+
private func findInnermostSymbolRange(
55+
containing position: Position,
56+
documentSymbols: [DocumentSymbol]
57+
) -> Range<Position>? {
5758
for documentSymbol in documentSymbols where documentSymbol.range.contains(position) {
5859
if let children = documentSymbol.children,
59-
let rangeOfChild = findInnermostSymbolRange(containing: position, documentSymbols: .documentSymbols(children))
60+
let rangeOfChild = findInnermostSymbolRange(
61+
containing: position,
62+
documentSymbolsResponse: .documentSymbols(children)
63+
)
6064
{
6165
// If a child contains the position, prefer that because it's more specific.
6266
return rangeOfChild
@@ -66,6 +70,21 @@ private func findInnermostSymbolRange(
6670
return nil
6771
}
6872

73+
/// Return the smallest range in `symbolInformation` containing `position`.
74+
private func findInnermostSymbolRange(
75+
containing position: Position,
76+
symbolInformation symbolInformationArray: [SymbolInformation]
77+
) -> Range<Position>? {
78+
var bestRange: Range<Position>? = nil
79+
for symbolInformation in symbolInformationArray where symbolInformation.location.range.contains(position) {
80+
let range = symbolInformation.location.range
81+
if bestRange == nil || (bestRange!.lowerBound < range.lowerBound && range.upperBound < bestRange!.upperBound) {
82+
bestRange = range
83+
}
84+
}
85+
return bestRange
86+
}
87+
6988
extension SourceKitLSPServer {
7089
/// Converts a flat list of test symbol occurrences to a hierarchical `TestItem` array, inferring the hierarchical
7190
/// structure from `childOf` relations between the symbol occurrences.
@@ -265,9 +284,15 @@ extension SourceKitLSPServer {
265284

266285
let syntacticTests = try await languageService.syntacticDocumentTests(for: req.textDocument.uri, in: workspace)
267286

268-
if let index = workspace.index(checkedFor: .imMemoryModifiedFiles(documentManager)) {
287+
// We `syntacticDocumentTests` returns `nil`, it indicates that it doesn't support syntactic test discovery.
288+
// In that case, the semantic index is the only source of tests we have and we thus want to show tests from the
289+
// semantic index, even if they are out-of-date. The alternative would be showing now tests after an edit to a file.
290+
let indexCheckLevel: IndexCheckLevel =
291+
syntacticTests == nil ? .deletedFiles : .imMemoryModifiedFiles(documentManager)
292+
293+
if let index = workspace.index(checkedFor: indexCheckLevel) {
269294
var syntacticSwiftTestingTests: [TestItem] {
270-
syntacticTests.filter { $0.style == TestStyle.swiftTesting }
295+
syntacticTests?.filter { $0.style == TestStyle.swiftTesting } ?? []
271296
}
272297

273298
let testSymbols =
@@ -285,7 +310,7 @@ extension SourceKitLSPServer {
285310
for: testSymbols,
286311
resolveLocation: { uri, position in
287312
if uri == snapshot.uri, let documentSymbols,
288-
let range = findInnermostSymbolRange(containing: position, documentSymbols: documentSymbols)
313+
let range = findInnermostSymbolRange(containing: position, documentSymbolsResponse: documentSymbols)
289314
{
290315
return Location(uri: uri, range: range)
291316
}
@@ -300,7 +325,7 @@ extension SourceKitLSPServer {
300325
}
301326
}
302327
// We don't have any up-to-date semantic index entries for this file. Syntactically look for tests.
303-
return syntacticTests
328+
return syntacticTests ?? []
304329
}
305330
}
306331

@@ -439,7 +464,7 @@ extension TestItem {
439464
}
440465

441466
extension SwiftLanguageService {
442-
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem] {
467+
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem]? {
443468
let snapshot = try documentManager.latestSnapshot(uri)
444469
let semanticSymbols = workspace.index(checkedFor: .deletedFiles)?.symbols(inFilePath: snapshot.uri.pseudoPath)
445470
let xctestSymbols = await SyntacticSwiftXCTestScanner.findTestSymbols(
@@ -455,7 +480,7 @@ extension SwiftLanguageService {
455480
}
456481

457482
extension ClangLanguageService {
458-
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem] {
459-
return []
483+
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem]? {
484+
return nil
460485
}
461486
}

Tests/SourceKitLSPTests/DocumentTestDiscoveryTests.swift

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,4 +912,135 @@ final class DocumentTestDiscoveryTests: XCTestCase {
912912
XCTAssertFalse(testsAfterEdit.contains { $0.label == "NotQuiteTest" })
913913
XCTAssertTrue(testsAfterEdit.contains { $0.label == "OtherNotQuiteTest" })
914914
}
915+
916+
func testObjectiveCTestFromSemanticIndex() async throws {
917+
let project = try await SwiftPMTestProject(
918+
files: [
919+
"Tests/MyLibraryTests/Test.m": """
920+
#import <XCTest/XCTest.h>
921+
922+
@interface MyTests : XCTestCase
923+
@end
924+
925+
1️⃣@implementation MyTests
926+
2️⃣- (void)testSomething {
927+
}3️⃣
928+
@4️⃣end
929+
"""
930+
],
931+
manifest: """
932+
// swift-tools-version: 5.7
933+
934+
import PackageDescription
935+
936+
let package = Package(
937+
name: "MyLibrary",
938+
targets: [.testTarget(name: "MyLibraryTests")]
939+
)
940+
""",
941+
build: true
942+
)
943+
944+
let (uri, positions) = try project.openDocument("Test.m")
945+
946+
let tests = try await project.testClient.send(DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)))
947+
948+
XCTAssertEqual(
949+
tests,
950+
[
951+
TestItem(
952+
id: "MyTests",
953+
label: "MyTests",
954+
disabled: false,
955+
style: TestStyle.xcTest,
956+
location: Location(uri: uri, range: positions["1️⃣"]..<positions["4️⃣"]),
957+
children: [
958+
TestItem(
959+
id: "MyTests/testSomething",
960+
label: "testSomething",
961+
disabled: false,
962+
style: TestStyle.xcTest,
963+
location: Location(uri: uri, range: positions["2️⃣"]..<positions["3️⃣"]),
964+
children: [],
965+
tags: []
966+
)
967+
],
968+
tags: []
969+
)
970+
]
971+
)
972+
}
973+
974+
func testObjectiveCTestsAfterInMemoryEdit() async throws {
975+
let project = try await SwiftPMTestProject(
976+
files: [
977+
"Tests/MyLibraryTests/Test.m": """
978+
#import <XCTest/XCTest.h>
979+
980+
@interface MyTests : XCTestCase
981+
@end
982+
983+
1️⃣@implementation MyTests
984+
2️⃣- (void)testSomething {}3️⃣
985+
0️⃣
986+
@4️⃣end
987+
"""
988+
],
989+
manifest: """
990+
// swift-tools-version: 5.7
991+
992+
import PackageDescription
993+
994+
let package = Package(
995+
name: "MyLibrary",
996+
targets: [.testTarget(name: "MyLibraryTests")]
997+
)
998+
""",
999+
build: true
1000+
)
1001+
1002+
let (uri, positions) = try project.openDocument("Test.m")
1003+
1004+
project.testClient.send(
1005+
DidChangeTextDocumentNotification(
1006+
textDocument: VersionedTextDocumentIdentifier(uri, version: 2),
1007+
contentChanges: [
1008+
TextDocumentContentChangeEvent(
1009+
range: Range(positions["0️⃣"]),
1010+
text: """
1011+
- (void)testSomethingElse {}
1012+
"""
1013+
)
1014+
]
1015+
)
1016+
)
1017+
1018+
let tests = try await project.testClient.send(DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)))
1019+
// Since we don't have syntactic test discovery for clang-languages, we don't discover `testSomethingElse` as a
1020+
// test method until we perform a build
1021+
XCTAssertEqual(
1022+
tests,
1023+
[
1024+
TestItem(
1025+
id: "MyTests",
1026+
label: "MyTests",
1027+
disabled: false,
1028+
style: TestStyle.xcTest,
1029+
location: Location(uri: uri, range: positions["1️⃣"]..<positions["4️⃣"]),
1030+
children: [
1031+
TestItem(
1032+
id: "MyTests/testSomething",
1033+
label: "testSomething",
1034+
disabled: false,
1035+
style: TestStyle.xcTest,
1036+
location: Location(uri: uri, range: positions["2️⃣"]..<positions["3️⃣"]),
1037+
children: [],
1038+
tags: []
1039+
)
1040+
],
1041+
tags: []
1042+
)
1043+
]
1044+
)
1045+
}
9151046
}

0 commit comments

Comments
 (0)