Skip to content

Commit 6a9a95f

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 e71aa5d commit 6a9a95f

File tree

4 files changed

+314
-20
lines changed

4 files changed

+314
-20
lines changed

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: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import IndexStoreDB
1414
import LSPLogging
1515
import LanguageServerProtocol
16+
import SemanticIndex
1617
import SwiftSyntax
1718

1819
public enum TestStyle {
@@ -41,22 +42,26 @@ fileprivate extension SymbolOccurrence {
4142
/// Find the innermost range of a document symbol that contains the given position.
4243
private func findInnermostSymbolRange(
4344
containing position: Position,
44-
documentSymbols documentSymbolsResponse: DocumentSymbolResponse
45+
documentSymbolsResponse: DocumentSymbolResponse
4546
) -> 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
47+
switch documentSymbolsResponse {
48+
case .documentSymbols(let documentSymbols):
49+
return findInnermostSymbolRange(containing: position, documentSymbols: documentSymbols)
50+
case .symbolInformation(let symbolInformation):
51+
return findInnermostSymbolRange(containing: position, symbolInformation: symbolInformation)
5652
}
53+
}
54+
55+
private func findInnermostSymbolRange(
56+
containing position: Position,
57+
documentSymbols: [DocumentSymbol]
58+
) -> Range<Position>? {
5759
for documentSymbol in documentSymbols where documentSymbol.range.contains(position) {
5860
if let children = documentSymbol.children,
59-
let rangeOfChild = findInnermostSymbolRange(containing: position, documentSymbols: .documentSymbols(children))
61+
let rangeOfChild = findInnermostSymbolRange(
62+
containing: position,
63+
documentSymbolsResponse: .documentSymbols(children)
64+
)
6065
{
6166
// If a child contains the position, prefer that because it's more specific.
6267
return rangeOfChild
@@ -66,6 +71,21 @@ private func findInnermostSymbolRange(
6671
return nil
6772
}
6873

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

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

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

271297
let testSymbols =
@@ -283,7 +309,7 @@ extension SourceKitLSPServer {
283309
for: testSymbols,
284310
resolveLocation: { uri, position in
285311
if uri == snapshot.uri, let documentSymbols,
286-
let range = findInnermostSymbolRange(containing: position, documentSymbols: documentSymbols)
312+
let range = findInnermostSymbolRange(containing: position, documentSymbolsResponse: documentSymbols)
287313
{
288314
return Location(uri: uri, range: range)
289315
}
@@ -298,7 +324,7 @@ extension SourceKitLSPServer {
298324
}
299325
}
300326
// We don't have any up-to-date semantic index entries for this file. Syntactically look for tests.
301-
return syntacticTests
327+
return syntacticTests ?? []
302328
}
303329
}
304330

@@ -437,7 +463,7 @@ extension TestItem {
437463
}
438464

439465
extension SwiftLanguageService {
440-
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem] {
466+
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem]? {
441467
let snapshot = try documentManager.latestSnapshot(uri)
442468
let semanticSymbols = workspace.index(checkedFor: .deletedFiles)?.symbols(inFilePath: snapshot.uri.pseudoPath)
443469
let xctestSymbols = await SyntacticSwiftXCTestScanner.findTestSymbols(
@@ -453,7 +479,7 @@ extension SwiftLanguageService {
453479
}
454480

455481
extension ClangLanguageService {
456-
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem] {
457-
return []
482+
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem]? {
483+
return nil
458484
}
459485
}

Tests/SourceKitLSPTests/DocumentTestDiscoveryTests.swift

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,4 +1065,138 @@ final class DocumentTestDiscoveryTests: XCTestCase {
10651065
XCTAssertFalse(testsAfterEdit.contains { $0.label == "NotQuiteTest" })
10661066
XCTAssertTrue(testsAfterEdit.contains { $0.label == "OtherNotQuiteTest" })
10671067
}
1068+
1069+
func testObjectiveCTestFromSemanticIndex() async throws {
1070+
try SkipUnless.platformIsDarwin("Non-Darwin platforms don't support Objective-C")
1071+
1072+
let project = try await SwiftPMTestProject(
1073+
files: [
1074+
"Tests/MyLibraryTests/Test.m": """
1075+
#import <XCTest/XCTest.h>
1076+
1077+
@interface MyTests : XCTestCase
1078+
@end
1079+
1080+
1️⃣@implementation MyTests
1081+
2️⃣- (void)testSomething {
1082+
}3️⃣
1083+
@4️⃣end
1084+
"""
1085+
],
1086+
manifest: """
1087+
// swift-tools-version: 5.7
1088+
1089+
import PackageDescription
1090+
1091+
let package = Package(
1092+
name: "MyLibrary",
1093+
targets: [.testTarget(name: "MyLibraryTests")]
1094+
)
1095+
""",
1096+
build: true
1097+
)
1098+
1099+
let (uri, positions) = try project.openDocument("Test.m")
1100+
1101+
let tests = try await project.testClient.send(DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)))
1102+
1103+
XCTAssertEqual(
1104+
tests,
1105+
[
1106+
TestItem(
1107+
id: "MyTests",
1108+
label: "MyTests",
1109+
disabled: false,
1110+
style: TestStyle.xcTest,
1111+
location: Location(uri: uri, range: positions["1️⃣"]..<positions["4️⃣"]),
1112+
children: [
1113+
TestItem(
1114+
id: "MyTests/testSomething",
1115+
label: "testSomething",
1116+
disabled: false,
1117+
style: TestStyle.xcTest,
1118+
location: Location(uri: uri, range: positions["2️⃣"]..<positions["3️⃣"]),
1119+
children: [],
1120+
tags: []
1121+
)
1122+
],
1123+
tags: []
1124+
)
1125+
]
1126+
)
1127+
}
1128+
1129+
func testObjectiveCTestsAfterInMemoryEdit() async throws {
1130+
try SkipUnless.platformIsDarwin("Non-Darwin platforms don't support Objective-C")
1131+
let project = try await SwiftPMTestProject(
1132+
files: [
1133+
"Tests/MyLibraryTests/Test.m": """
1134+
#import <XCTest/XCTest.h>
1135+
1136+
@interface MyTests : XCTestCase
1137+
@end
1138+
1139+
1️⃣@implementation MyTests
1140+
2️⃣- (void)testSomething {}3️⃣
1141+
0️⃣
1142+
@4️⃣end
1143+
"""
1144+
],
1145+
manifest: """
1146+
// swift-tools-version: 5.7
1147+
1148+
import PackageDescription
1149+
1150+
let package = Package(
1151+
name: "MyLibrary",
1152+
targets: [.testTarget(name: "MyLibraryTests")]
1153+
)
1154+
""",
1155+
build: true
1156+
)
1157+
1158+
let (uri, positions) = try project.openDocument("Test.m")
1159+
1160+
project.testClient.send(
1161+
DidChangeTextDocumentNotification(
1162+
textDocument: VersionedTextDocumentIdentifier(uri, version: 2),
1163+
contentChanges: [
1164+
TextDocumentContentChangeEvent(
1165+
range: Range(positions["0️⃣"]),
1166+
text: """
1167+
- (void)testSomethingElse {}
1168+
"""
1169+
)
1170+
]
1171+
)
1172+
)
1173+
1174+
let tests = try await project.testClient.send(DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)))
1175+
// Since we don't have syntactic test discovery for clang-languages, we don't discover `testSomethingElse` as a
1176+
// test method until we perform a build
1177+
XCTAssertEqual(
1178+
tests,
1179+
[
1180+
TestItem(
1181+
id: "MyTests",
1182+
label: "MyTests",
1183+
disabled: false,
1184+
style: TestStyle.xcTest,
1185+
location: Location(uri: uri, range: positions["1️⃣"]..<positions["4️⃣"]),
1186+
children: [
1187+
TestItem(
1188+
id: "MyTests/testSomething",
1189+
label: "testSomething",
1190+
disabled: false,
1191+
style: TestStyle.xcTest,
1192+
location: Location(uri: uri, range: positions["2️⃣"]..<positions["3️⃣"]),
1193+
children: [],
1194+
tags: []
1195+
)
1196+
],
1197+
tags: []
1198+
)
1199+
]
1200+
)
1201+
}
10681202
}

0 commit comments

Comments
 (0)