Skip to content

Commit 8613f4f

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 8613f4f

File tree

4 files changed

+313
-20
lines changed

4 files changed

+313
-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: 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.
@@ -263,9 +282,15 @@ extension SourceKitLSPServer {
263282

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

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

271296
let testSymbols =
@@ -283,7 +308,7 @@ extension SourceKitLSPServer {
283308
for: testSymbols,
284309
resolveLocation: { uri, position in
285310
if uri == snapshot.uri, let documentSymbols,
286-
let range = findInnermostSymbolRange(containing: position, documentSymbols: documentSymbols)
311+
let range = findInnermostSymbolRange(containing: position, documentSymbolsResponse: documentSymbols)
287312
{
288313
return Location(uri: uri, range: range)
289314
}
@@ -298,7 +323,7 @@ extension SourceKitLSPServer {
298323
}
299324
}
300325
// We don't have any up-to-date semantic index entries for this file. Syntactically look for tests.
301-
return syntacticTests
326+
return syntacticTests ?? []
302327
}
303328
}
304329

@@ -437,7 +462,7 @@ extension TestItem {
437462
}
438463

439464
extension SwiftLanguageService {
440-
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem] {
465+
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem]? {
441466
let snapshot = try documentManager.latestSnapshot(uri)
442467
let semanticSymbols = workspace.index(checkedFor: .deletedFiles)?.symbols(inFilePath: snapshot.uri.pseudoPath)
443468
let xctestSymbols = await SyntacticSwiftXCTestScanner.findTestSymbols(
@@ -453,7 +478,7 @@ extension SwiftLanguageService {
453478
}
454479

455480
extension ClangLanguageService {
456-
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem] {
457-
return []
481+
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem]? {
482+
return nil
458483
}
459484
}

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)