Skip to content

Fix test discovery for Objective-C XCTests #1196

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Sources/SourceKitLSP/LanguageService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,9 @@ public protocol LanguageService: AnyObject {
/// Perform a syntactic scan of the file at the given URI for test cases and test classes.
///
/// 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.
func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem]
///
/// A return value of `nil` indicates that this language service does not support syntactic test discovery.
func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async throws -> [TestItem]?

/// Crash the language server. Should be used for crash recovery testing only.
func _crash() async
Expand Down
64 changes: 45 additions & 19 deletions Sources/SourceKitLSP/TestDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import IndexStoreDB
import LSPLogging
import LanguageServerProtocol
import SemanticIndex
import SwiftSyntax

public enum TestStyle {
Expand Down Expand Up @@ -41,22 +42,26 @@ fileprivate extension SymbolOccurrence {
/// Find the innermost range of a document symbol that contains the given position.
private func findInnermostSymbolRange(
containing position: Position,
documentSymbols documentSymbolsResponse: DocumentSymbolResponse
documentSymbolsResponse: DocumentSymbolResponse
) -> Range<Position>? {
guard case .documentSymbols(let documentSymbols) = documentSymbolsResponse else {
// Both `ClangLanguageService` and `SwiftLanguageService` return `documentSymbols` so we don't need to handle the
// .symbolInformation case.
logger.fault(
"""
Expected documentSymbols response from language service to resolve test ranges but got \
\(documentSymbolsResponse.forLogging)
"""
)
return nil
switch documentSymbolsResponse {
case .documentSymbols(let documentSymbols):
return findInnermostSymbolRange(containing: position, documentSymbols: documentSymbols)
case .symbolInformation(let symbolInformation):
return findInnermostSymbolRange(containing: position, symbolInformation: symbolInformation)
}
}

private func findInnermostSymbolRange(
containing position: Position,
documentSymbols: [DocumentSymbol]
) -> Range<Position>? {
for documentSymbol in documentSymbols where documentSymbol.range.contains(position) {
if let children = documentSymbol.children,
let rangeOfChild = findInnermostSymbolRange(containing: position, documentSymbols: .documentSymbols(children))
let rangeOfChild = findInnermostSymbolRange(
containing: position,
documentSymbolsResponse: .documentSymbols(children)
)
{
// If a child contains the position, prefer that because it's more specific.
return rangeOfChild
Expand All @@ -66,6 +71,21 @@ private func findInnermostSymbolRange(
return nil
}

/// Return the smallest range in `symbolInformation` containing `position`.
private func findInnermostSymbolRange(
containing position: Position,
symbolInformation symbolInformationArray: [SymbolInformation]
) -> Range<Position>? {
var bestRange: Range<Position>? = nil
for symbolInformation in symbolInformationArray where symbolInformation.location.range.contains(position) {
let range = symbolInformation.location.range
if bestRange == nil || (bestRange!.lowerBound < range.lowerBound && range.upperBound < bestRange!.upperBound) {
bestRange = range
}
}
return bestRange
}

extension SourceKitLSPServer {
/// Converts a flat list of test symbol occurrences to a hierarchical `TestItem` array, inferring the hierarchical
/// structure from `childOf` relations between the symbol occurrences.
Expand Down Expand Up @@ -263,9 +283,15 @@ extension SourceKitLSPServer {

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

if let index = workspace.index(checkedFor: .inMemoryModifiedFiles(documentManager)) {
// We `syntacticDocumentTests` returns `nil`, it indicates that it doesn't support syntactic test discovery.
// In that case, the semantic index is the only source of tests we have and we thus want to show tests from the
// semantic index, even if they are out-of-date. The alternative would be showing now tests after an edit to a file.
let indexCheckLevel: IndexCheckLevel =
syntacticTests == nil ? .deletedFiles : .inMemoryModifiedFiles(documentManager)

if let index = workspace.index(checkedFor: indexCheckLevel) {
var syntacticSwiftTestingTests: [TestItem] {
syntacticTests.filter { $0.style == TestStyle.swiftTesting }
syntacticTests?.filter { $0.style == TestStyle.swiftTesting } ?? []
}

let testSymbols =
Expand All @@ -283,7 +309,7 @@ extension SourceKitLSPServer {
for: testSymbols,
resolveLocation: { uri, position in
if uri == snapshot.uri, let documentSymbols,
let range = findInnermostSymbolRange(containing: position, documentSymbols: documentSymbols)
let range = findInnermostSymbolRange(containing: position, documentSymbolsResponse: documentSymbols)
{
return Location(uri: uri, range: range)
}
Expand All @@ -298,7 +324,7 @@ extension SourceKitLSPServer {
}
}
// We don't have any up-to-date semantic index entries for this file. Syntactically look for tests.
return syntacticTests
return syntacticTests ?? []
}
}

Expand Down Expand Up @@ -437,7 +463,7 @@ extension TestItem {
}

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

extension ClangLanguageService {
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem] {
return []
public func syntacticDocumentTests(for uri: DocumentURI, in workspace: Workspace) async -> [TestItem]? {
return nil
}
}
134 changes: 134 additions & 0 deletions Tests/SourceKitLSPTests/DocumentTestDiscoveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1065,4 +1065,138 @@ final class DocumentTestDiscoveryTests: XCTestCase {
XCTAssertFalse(testsAfterEdit.contains { $0.label == "NotQuiteTest" })
XCTAssertTrue(testsAfterEdit.contains { $0.label == "OtherNotQuiteTest" })
}

func testObjectiveCTestFromSemanticIndex() async throws {
try SkipUnless.platformIsDarwin("Non-Darwin platforms don't support Objective-C")

let project = try await SwiftPMTestProject(
files: [
"Tests/MyLibraryTests/Test.m": """
#import <XCTest/XCTest.h>
@interface MyTests : XCTestCase
@end
1️⃣@implementation MyTests
2️⃣- (void)testSomething {
}3️⃣
@4️⃣end
"""
],
manifest: """
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "MyLibrary",
targets: [.testTarget(name: "MyLibraryTests")]
)
""",
build: true
)

let (uri, positions) = try project.openDocument("Test.m")

let tests = try await project.testClient.send(DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)))

XCTAssertEqual(
tests,
[
TestItem(
id: "MyTests",
label: "MyTests",
disabled: false,
style: TestStyle.xcTest,
location: Location(uri: uri, range: positions["1️⃣"]..<positions["4️⃣"]),
children: [
TestItem(
id: "MyTests/testSomething",
label: "testSomething",
disabled: false,
style: TestStyle.xcTest,
location: Location(uri: uri, range: positions["2️⃣"]..<positions["3️⃣"]),
children: [],
tags: []
)
],
tags: []
)
]
)
}

func testObjectiveCTestsAfterInMemoryEdit() async throws {
try SkipUnless.platformIsDarwin("Non-Darwin platforms don't support Objective-C")
let project = try await SwiftPMTestProject(
files: [
"Tests/MyLibraryTests/Test.m": """
#import <XCTest/XCTest.h>
@interface MyTests : XCTestCase
@end
1️⃣@implementation MyTests
2️⃣- (void)testSomething {}3️⃣
0️⃣
@4️⃣end
"""
],
manifest: """
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "MyLibrary",
targets: [.testTarget(name: "MyLibraryTests")]
)
""",
build: true
)

let (uri, positions) = try project.openDocument("Test.m")

project.testClient.send(
DidChangeTextDocumentNotification(
textDocument: VersionedTextDocumentIdentifier(uri, version: 2),
contentChanges: [
TextDocumentContentChangeEvent(
range: Range(positions["0️⃣"]),
text: """
- (void)testSomethingElse {}
"""
)
]
)
)

let tests = try await project.testClient.send(DocumentTestsRequest(textDocument: TextDocumentIdentifier(uri)))
// Since we don't have syntactic test discovery for clang-languages, we don't discover `testSomethingElse` as a
// test method until we perform a build
XCTAssertEqual(
tests,
[
TestItem(
id: "MyTests",
label: "MyTests",
disabled: false,
style: TestStyle.xcTest,
location: Location(uri: uri, range: positions["1️⃣"]..<positions["4️⃣"]),
children: [
TestItem(
id: "MyTests/testSomething",
label: "testSomething",
disabled: false,
style: TestStyle.xcTest,
location: Location(uri: uri, range: positions["2️⃣"]..<positions["3️⃣"]),
children: [],
tags: []
)
],
tags: []
)
]
)
}
}
Loading