Skip to content

Commit aff3a97

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 08d9537 commit aff3a97

File tree

4 files changed

+307
-20
lines changed

4 files changed

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

Tests/SourceKitLSPTests/WorkspaceTestDiscoveryTests.swift

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,4 +846,133 @@ final class WorkspaceTestDiscoveryTests: XCTestCase {
846846
XCTAssertFalse(testsAfterEdit.contains { $0.label == "NotQuiteTest" })
847847
XCTAssertTrue(testsAfterEdit.contains { $0.label == "OtherNotQuiteTest" })
848848
}
849+
850+
func testObjectiveCTestFromSemanticIndex() async throws {
851+
let project = try await SwiftPMTestProject(
852+
files: [
853+
"Tests/MyLibraryTests/Test.m": """
854+
#import <XCTest/XCTest.h>
855+
856+
@interface MyTests : XCTestCase
857+
@end
858+
859+
@implementation 1️⃣MyTests
860+
- (void)2️⃣testSomething {
861+
}
862+
@end
863+
"""
864+
],
865+
manifest: """
866+
// swift-tools-version: 5.7
867+
868+
import PackageDescription
869+
870+
let package = Package(
871+
name: "MyLibrary",
872+
targets: [.testTarget(name: "MyLibraryTests")]
873+
)
874+
""",
875+
build: true
876+
)
877+
878+
let tests = try await project.testClient.send(WorkspaceTestsRequest())
879+
880+
XCTAssertEqual(
881+
tests,
882+
[
883+
TestItem(
884+
id: "MyTests",
885+
label: "MyTests",
886+
disabled: false,
887+
style: TestStyle.xcTest,
888+
location: try project.location(from: "1️⃣", to: "1️⃣", in: "Test.m"),
889+
children: [
890+
TestItem(
891+
id: "MyTests/testSomething",
892+
label: "testSomething",
893+
disabled: false,
894+
style: TestStyle.xcTest,
895+
location: try project.location(from: "2️⃣", to: "2️⃣", in: "Test.m"),
896+
children: [],
897+
tags: []
898+
)
899+
],
900+
tags: []
901+
)
902+
]
903+
)
904+
}
905+
906+
func testObjectiveCTestsAfterInMemoryEdit() async throws {
907+
let project = try await SwiftPMTestProject(
908+
files: [
909+
"Tests/MyLibraryTests/Test.m": """
910+
#import <XCTest/XCTest.h>
911+
912+
@interface MyTests : XCTestCase
913+
@end
914+
915+
1️⃣@implementation MyTests
916+
2️⃣- (void)testSomething {}3️⃣
917+
0️⃣
918+
@4️⃣end
919+
"""
920+
],
921+
manifest: """
922+
// swift-tools-version: 5.7
923+
924+
import PackageDescription
925+
926+
let package = Package(
927+
name: "MyLibrary",
928+
targets: [.testTarget(name: "MyLibraryTests")]
929+
)
930+
""",
931+
build: true
932+
)
933+
934+
let (uri, positions) = try project.openDocument("Test.m")
935+
936+
project.testClient.send(
937+
DidChangeTextDocumentNotification(
938+
textDocument: VersionedTextDocumentIdentifier(uri, version: 2),
939+
contentChanges: [
940+
TextDocumentContentChangeEvent(
941+
range: Range(positions["0️⃣"]),
942+
text: """
943+
- (void)testSomethingElse {}
944+
"""
945+
)
946+
]
947+
)
948+
)
949+
950+
let tests = try await project.testClient.send(WorkspaceTestsRequest())
951+
// Since we don't have syntactic test discovery for clang-languages, we don't discover `testSomethingElse` as a
952+
// test method until we perform a build
953+
XCTAssertEqual(
954+
tests,
955+
[
956+
TestItem(
957+
id: "MyTests",
958+
label: "MyTests",
959+
disabled: false,
960+
style: TestStyle.xcTest,
961+
location: Location(uri: uri, range: positions["1️⃣"]..<positions["4️⃣"]),
962+
children: [
963+
TestItem(
964+
id: "MyTests/testSomething",
965+
label: "testSomething",
966+
disabled: false,
967+
style: TestStyle.xcTest,
968+
location: Location(uri: uri, range: positions["2️⃣"]..<positions["3️⃣"]),
969+
children: [],
970+
tags: []
971+
)
972+
],
973+
tags: []
974+
)
975+
]
976+
)
977+
}
849978
}

0 commit comments

Comments
 (0)