Skip to content

Commit d5e3dbd

Browse files
authored
Merge pull request swiftlang#1148 from ahoppen/ahoppen/syntactic-test-discovery-fallback
Implement a syntactic test discovery fallback for XCTests written in Swift
2 parents 747c39b + fdb8bab commit d5e3dbd

File tree

5 files changed

+363
-7
lines changed

5 files changed

+363
-7
lines changed

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
add_library(SourceKitLSP STATIC
33
CapabilityRegistry.swift
44
DocumentManager.swift
5+
IndexOutOfDateChecker.swift
56
IndexStoreDB+MainFilesProvider.swift
67
LanguageService.swift
78
Rename.swift
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Foundation
14+
import IndexStoreDB
15+
import LSPLogging
16+
17+
/// Helper class to check if symbols from the index are up-to-date or if the source file has been modified after it was
18+
/// indexed.
19+
///
20+
/// The checker caches mod dates of source files. It should thus not be long lived. Its intended lifespan is the
21+
/// evaluation of a single request.
22+
struct IndexOutOfDateChecker {
23+
/// The last modification time of a file. Can also represent the fact that the file does not exist.
24+
private enum ModificationTime {
25+
case fileDoesNotExist
26+
case date(Date)
27+
}
28+
29+
private enum Error: Swift.Error, CustomStringConvertible {
30+
case fileAttributesDontHaveModificationDate
31+
32+
var description: String {
33+
switch self {
34+
case .fileAttributesDontHaveModificationDate:
35+
return "File attributes don't contain a modification date"
36+
}
37+
}
38+
}
39+
40+
/// File paths to modification times that have already been computed.
41+
private var modTimeCache: [String: ModificationTime] = [:]
42+
43+
private func modificationDateUncached(of path: String) throws -> ModificationTime {
44+
do {
45+
let attributes = try FileManager.default.attributesOfItem(atPath: path)
46+
guard let modificationDate = attributes[FileAttributeKey.modificationDate] as? Date else {
47+
throw Error.fileAttributesDontHaveModificationDate
48+
}
49+
return .date(modificationDate)
50+
} catch let error as NSError where error.domain == NSCocoaErrorDomain && error.code == NSFileReadNoSuchFileError {
51+
return .fileDoesNotExist
52+
}
53+
}
54+
55+
private mutating func modificationDate(of path: String) throws -> ModificationTime {
56+
if let cached = modTimeCache[path] {
57+
return cached
58+
}
59+
let modTime = try modificationDateUncached(of: path)
60+
modTimeCache[path] = modTime
61+
return modTime
62+
}
63+
64+
/// Returns `true` if the source file for the given symbol location exists and has not been modified after it has been
65+
/// indexed.
66+
mutating func isUpToDate(_ symbolLocation: SymbolLocation) -> Bool {
67+
do {
68+
let sourceFileModificationDate = try modificationDate(of: symbolLocation.path)
69+
switch sourceFileModificationDate {
70+
case .fileDoesNotExist:
71+
return false
72+
case .date(let sourceFileModificationDate):
73+
return sourceFileModificationDate <= symbolLocation.timestamp
74+
}
75+
} catch {
76+
logger.fault("Unable to determine if SymbolLocation is up-to-date: \(error.forLogging)")
77+
return true
78+
}
79+
}
80+
81+
/// Return `true` if a unit file has been indexed for the given file path after its last modification date.
82+
///
83+
/// This means that at least a single build configuration of this file has been indexed since its last modification.
84+
mutating func indexHasUpToDateUnit(for filePath: String, index: IndexStoreDB) -> Bool {
85+
guard let lastUnitDate = index.dateOfLatestUnitFor(filePath: filePath) else {
86+
return false
87+
}
88+
do {
89+
let sourceModificationDate = try modificationDate(of: filePath)
90+
switch sourceModificationDate {
91+
case .fileDoesNotExist:
92+
return false
93+
case .date(let sourceModificationDate):
94+
return sourceModificationDate <= lastUnitDate
95+
}
96+
} catch {
97+
logger.fault("Unable to determine if source file has up-to-date unit: \(error.forLogging)")
98+
return true
99+
}
100+
}
101+
}

Sources/SourceKitLSP/LanguageService.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ public protocol LanguageService: AnyObject {
195195

196196
func executeCommand(_ req: ExecuteCommandRequest) async throws -> LSPAny?
197197

198+
/// Perform a syntactic scan of the file at the given URI for test cases and test classes.
199+
///
200+
/// 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) async throws -> [WorkspaceSymbolItem]?
202+
198203
/// Crash the language server. Should be used for crash recovery testing only.
199204
func _crash() async
200205
}

Sources/SourceKitLSP/TestDiscovery.swift

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import IndexStoreDB
14+
import LSPLogging
1415
import LanguageServerProtocol
16+
import SwiftSyntax
1517

1618
fileprivate extension SymbolOccurrence {
1719
/// Assuming that this is a symbol occurrence returned by the index, return whether it can constitute the definition
@@ -53,11 +55,140 @@ extension SourceKitLSPServer {
5355
for: req.textDocument.uri,
5456
language: snapshot.language
5557
)
56-
let testSymbols = workspace.index?.unitTests(referencedByMainFiles: [mainFileUri.pseudoPath]) ?? []
57-
return
58-
testSymbols
59-
.filter { $0.canBeTestDefinition }
60-
.sorted()
61-
.map(WorkspaceSymbolItem.init)
58+
if let index = workspace.index {
59+
var outOfDateChecker = IndexOutOfDateChecker()
60+
let testSymbols =
61+
index.unitTests(referencedByMainFiles: [mainFileUri.pseudoPath])
62+
.filter { $0.canBeTestDefinition && outOfDateChecker.isUpToDate($0.location) }
63+
64+
if !testSymbols.isEmpty {
65+
return testSymbols.sorted().map(WorkspaceSymbolItem.init)
66+
}
67+
if outOfDateChecker.indexHasUpToDateUnit(for: mainFileUri.pseudoPath, index: index) {
68+
// The index is up-to-date and doesn't contain any tests. We don't need to do a syntactic fallback.
69+
return []
70+
}
71+
}
72+
// We don't have any up-to-date index entries for this file. Syntactically look for tests.
73+
return try await languageService.syntacticDocumentTests(for: req.textDocument.uri)
74+
}
75+
}
76+
77+
/// Scans a source file for `XCTestCase` classes and test methods.
78+
///
79+
/// The syntax visitor scans from class and extension declarations that could be `XCTestCase` classes or extensions
80+
/// thereof. It then calls into `findTestMethods` to find the actual test methods.
81+
private final class SyntacticSwiftXCTestScanner: SyntaxVisitor {
82+
/// The document snapshot of the syntax tree that is being walked.
83+
private var snapshot: DocumentSnapshot
84+
85+
/// The workspace symbols representing the found `XCTestCase` subclasses and test methods.
86+
private var result: [WorkspaceSymbolItem] = []
87+
88+
/// Names of classes that are known to not inherit from `XCTestCase` and can thus be ruled out to be test classes.
89+
private static let knownNonXCTestSubclasses = ["NSObject"]
90+
91+
private init(snapshot: DocumentSnapshot) {
92+
self.snapshot = snapshot
93+
super.init(viewMode: .fixedUp)
94+
}
95+
96+
public static func findTestSymbols(
97+
in snapshot: DocumentSnapshot,
98+
syntaxTreeManager: SyntaxTreeManager
99+
) async -> [WorkspaceSymbolItem] {
100+
let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
101+
let visitor = SyntacticSwiftXCTestScanner(snapshot: snapshot)
102+
visitor.walk(syntaxTree)
103+
return visitor.result
104+
}
105+
106+
private func findTestMethods(in members: MemberBlockItemListSyntax, containerName: String) -> [WorkspaceSymbolItem] {
107+
return members.compactMap { (member) -> WorkspaceSymbolItem? in
108+
guard let function = member.decl.as(FunctionDeclSyntax.self) else {
109+
return nil
110+
}
111+
guard function.name.text.starts(with: "test") else {
112+
return nil
113+
}
114+
guard function.modifiers.map(\.name.tokenKind).allSatisfy({ $0 != .keyword(.static) && $0 != .keyword(.class) })
115+
else {
116+
// Test methods can't be static.
117+
return nil
118+
}
119+
guard function.signature.returnClause == nil else {
120+
// Test methods can't have a return type.
121+
// Technically we are also filtering out functions that have an explicit `Void` return type here but such
122+
// declarations are probably less common than helper functions that start with `test` and have a return type.
123+
return nil
124+
}
125+
guard let position = snapshot.position(of: function.name.positionAfterSkippingLeadingTrivia) else {
126+
logger.fault(
127+
"Failed to convert offset \(function.name.positionAfterSkippingLeadingTrivia.utf8Offset) to UTF-16-based position"
128+
)
129+
return nil
130+
}
131+
let symbolInformation = SymbolInformation(
132+
name: function.name.text,
133+
kind: .method,
134+
location: Location(uri: snapshot.uri, range: Range(position)),
135+
containerName: containerName
136+
)
137+
return WorkspaceSymbolItem.symbolInformation(symbolInformation)
138+
}
139+
}
140+
141+
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
142+
guard let inheritedTypes = node.inheritanceClause?.inheritedTypes, let superclass = inheritedTypes.first else {
143+
// The class has no superclass and thus can't inherit from XCTestCase.
144+
// Continue scanning its children in case it has a nested subclass that inherits from XCTestCase.
145+
return .visitChildren
146+
}
147+
if let superclassIdentifier = superclass.type.as(IdentifierTypeSyntax.self),
148+
Self.knownNonXCTestSubclasses.contains(superclassIdentifier.name.text)
149+
{
150+
// We know that the class can't be an subclass of `XCTestCase` so don't visit it.
151+
// We can't explicitly check for the `XCTestCase` superclass because the class might inherit from a class that in
152+
// turn inherits from `XCTestCase`. Resolving that inheritance hierarchy would be semantic.
153+
return .visitChildren
154+
}
155+
let testMethods = findTestMethods(in: node.memberBlock.members, containerName: node.name.text)
156+
guard !testMethods.isEmpty else {
157+
// Don't report a test class if it doesn't contain any test methods.
158+
return .visitChildren
159+
}
160+
guard let position = snapshot.position(of: node.name.positionAfterSkippingLeadingTrivia) else {
161+
logger.fault(
162+
"Failed to convert offset \(node.name.positionAfterSkippingLeadingTrivia.utf8Offset) to UTF-16-based position"
163+
)
164+
return .visitChildren
165+
}
166+
let testClassSymbolInformation = SymbolInformation(
167+
name: node.name.text,
168+
kind: .class,
169+
location: Location(uri: snapshot.uri, range: Range(position)),
170+
containerName: nil
171+
)
172+
result.append(.symbolInformation(testClassSymbolInformation))
173+
result += testMethods
174+
return .visitChildren
175+
}
176+
177+
override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
178+
result += findTestMethods(in: node.memberBlock.members, containerName: node.extendedType.trimmedDescription)
179+
return .visitChildren
180+
}
181+
}
182+
183+
extension SwiftLanguageService {
184+
public func syntacticDocumentTests(for uri: DocumentURI) async throws -> [WorkspaceSymbolItem]? {
185+
let snapshot = try documentManager.latestSnapshot(uri)
186+
return await SyntacticSwiftXCTestScanner.findTestSymbols(in: snapshot, syntaxTreeManager: syntaxTreeManager)
187+
}
188+
}
189+
190+
extension ClangLanguageService {
191+
public func syntacticDocumentTests(for uri: DocumentURI) async -> [WorkspaceSymbolItem]? {
192+
return nil
62193
}
63194
}

0 commit comments

Comments
 (0)