Skip to content

Commit a5105c4

Browse files
committed
Implement a syntactic workspace-wide test index
This workspace-wide syntactic test index is used for two purposes: - It is used for XCTests instead of the semantic index for files that have on-disk or in-memory modifications to files - It is uses for swift-testing tests, which are only discovered syntactically. rdar://119191037
1 parent b104d54 commit a5105c4

25 files changed

+1322
-124
lines changed

Sources/SKCore/BuildServerBuildSystem.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,17 @@ extension BuildServerBuildSystem: BuildSystem {
316316

317317
return .unhandled
318318
}
319+
320+
public func testFiles() async -> [DocumentURI] {
321+
// BuildServerBuildSystem does not support syntactic test discovery
322+
// (https://github.com/apple/sourcekit-lsp/issues/1173).
323+
return []
324+
}
325+
326+
public func addTestFilesDidChangeCallback(_ callback: @escaping () async -> Void) {
327+
// BuildServerBuildSystem does not support syntactic test discovery
328+
// (https://github.com/apple/sourcekit-lsp/issues/1173).
329+
}
319330
}
320331

321332
private func loadBuildServerConfig(path: AbsolutePath, fileSystem: FileSystem) throws -> BuildServerConfig {

Sources/SKCore/BuildSystem.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ public protocol BuildSystem: AnyObject, Sendable {
8787
func filesDidChange(_ events: [FileEvent]) async
8888

8989
func fileHandlingCapability(for uri: DocumentURI) async -> FileHandlingCapability
90+
91+
/// Returns the list of files that might contain test cases.
92+
///
93+
/// The returned file list is an over-approximation. It might contain tests from non-test targets or files that don't
94+
/// actually contain any tests. Keeping this list as minimal as possible helps reduce the amount of work that the
95+
/// syntactic test indexer needs to perform.
96+
func testFiles() async -> [DocumentURI]
97+
98+
/// Adds a callback that should be called when the value returned by `testFiles()` changes.
99+
///
100+
/// The callback might also be called without an actual change to `testFiles`.
101+
func addTestFilesDidChangeCallback(_ callback: @Sendable @escaping () async -> Void) async
90102
}
91103

92104
public let buildTargetsNotSupported =

Sources/SKCore/BuildSystemManager.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ extension BuildSystemManager {
186186
fallbackBuildSystem != nil ? .fallback : .unhandled
187187
)
188188
}
189+
190+
public func testFiles() async -> [DocumentURI] {
191+
return await buildSystem?.testFiles() ?? []
192+
}
189193
}
190194

191195
extension BuildSystemManager: BuildSystemDelegate {

Sources/SKCore/CompilationDatabaseBuildSystem.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ public actor CompilationDatabaseBuildSystem {
3939
/// Delegate to handle any build system events.
4040
public weak var delegate: BuildSystemDelegate? = nil
4141

42+
/// Callbacks that should be called if the list of possible test files has changed.
43+
public var testFilesDidChangeCallbacks: [() async -> Void] = []
44+
4245
public func setDelegate(_ delegate: BuildSystemDelegate?) async {
4346
self.delegate = delegate
4447
}
@@ -167,6 +170,9 @@ extension CompilationDatabaseBuildSystem: BuildSystem {
167170
if let delegate = self.delegate {
168171
await delegate.fileBuildSettingsChanged(self.watchedFiles)
169172
}
173+
for testFilesDidChangeCallback in testFilesDidChangeCallbacks {
174+
await testFilesDidChangeCallback()
175+
}
170176
}
171177

172178
public func filesDidChange(_ events: [FileEvent]) async {
@@ -185,4 +191,12 @@ extension CompilationDatabaseBuildSystem: BuildSystem {
185191
return .unhandled
186192
}
187193
}
194+
195+
public func testFiles() async -> [DocumentURI] {
196+
return compdb?.allCommands.map { DocumentURI($0.url) } ?? []
197+
}
198+
199+
public func addTestFilesDidChangeCallback(_ callback: @escaping () async -> Void) async {
200+
testFilesDidChangeCallbacks.append(callback)
201+
}
188202
}

Sources/SKSupport/AsyncQueue.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import Foundation
1616
/// array.
1717
private protocol AnyTask: Sendable {
1818
func waitForCompletion() async
19+
20+
func cancel()
1921
}
2022

2123
extension Task: AnyTask {
@@ -89,6 +91,16 @@ public final class AsyncQueue<TaskMetadata: DependencyTracker>: Sendable {
8991

9092
public init() {}
9193

94+
public func cancelTasks(where filter: (TaskMetadata) -> Bool) {
95+
pendingTasks.withLock { pendingTasks in
96+
for task in pendingTasks {
97+
if filter(task.metadata) {
98+
task.task.cancel()
99+
}
100+
}
101+
}
102+
}
103+
92104
/// Schedule a new closure to be executed on the queue.
93105
///
94106
/// If this is a serial queue, all previously added tasks are guaranteed to

Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ public actor SwiftPMBuildSystem {
8181
self.delegate = delegate
8282
}
8383

84+
/// Callbacks that should be called if the list of possible test files has changed.
85+
public var testFilesDidChangeCallbacks: [() async -> Void] = []
86+
8487
let workspacePath: TSCAbsolutePath
8588
/// The directory containing `Package.swift`.
8689
public var projectRoot: TSCAbsolutePath
@@ -267,6 +270,9 @@ extension SwiftPMBuildSystem {
267270
}
268271
await delegate.fileBuildSettingsChanged(self.watchedFiles)
269272
await delegate.fileHandlingCapabilityChanged()
273+
for testFilesDidChangeCallback in testFilesDidChangeCallbacks {
274+
await testFilesDidChangeCallback()
275+
}
270276
}
271277
}
272278

@@ -380,6 +386,15 @@ extension SwiftPMBuildSystem: SKCore.BuildSystem {
380386
return .unhandled
381387
}
382388
}
389+
390+
public func testFiles() -> [DocumentURI] {
391+
// We should only include source files from test targets (https://github.com/apple/sourcekit-lsp/issues/1174).
392+
return fileToTarget.map { DocumentURI($0.key.asURL) }
393+
}
394+
395+
public func addTestFilesDidChangeCallback(_ callback: @Sendable @escaping () async -> Void) async {
396+
testFilesDidChangeCallbacks.append(callback)
397+
}
383398
}
384399

385400
extension SwiftPMBuildSystem {

Sources/SKTestSupport/IndexedSingleSwiftFileTestProject.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,25 @@ public struct IndexedSingleSwiftFileTestProject {
6868

6969
if let sdk = TibsBuilder.defaultSDKPath {
7070
compilerArguments += ["-sdk", sdk]
71+
72+
// The following are needed so we can import XCTest
73+
let sdkUrl = URL(fileURLWithPath: sdk)
74+
let usrLibDir =
75+
sdkUrl
76+
.deletingLastPathComponent()
77+
.deletingLastPathComponent()
78+
.appendingPathComponent("usr")
79+
.appendingPathComponent("lib")
80+
let frameworksDir =
81+
sdkUrl
82+
.deletingLastPathComponent()
83+
.deletingLastPathComponent()
84+
.appendingPathComponent("Library")
85+
.appendingPathComponent("Frameworks")
86+
compilerArguments += [
87+
"-I", usrLibDir.path,
88+
"-F", frameworksDir.path,
89+
]
7190
}
7291

7392
let compilationDatabase = JSONCompilationDatabase(

Sources/SKTestSupport/MultiFileTestProject.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,13 @@ public class MultiFileTestProject {
142142
}
143143
return DocumentPositions(markedText: fileData.markedText)[marker]
144144
}
145+
146+
public func range(from fromMarker: String, to toMarker: String, in fileName: String) throws -> Range<Position> {
147+
return try position(of: fromMarker, in: fileName)..<position(of: toMarker, in: fileName)
148+
}
149+
150+
public func location(from fromMarker: String, to toMarker: String, in fileName: String) throws -> Location {
151+
let range = try self.range(from: fromMarker, to: toMarker, in: fileName)
152+
return Location(uri: try self.uri(for: fileName), range: range)
153+
}
145154
}

Sources/SKTestSupport/SwiftPMTestProject.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public class SwiftPMTestProject: MultiFileTestProject {
4040
manifest: String = SwiftPMTestProject.defaultPackageManifest,
4141
workspaces: (URL) -> [WorkspaceFolder] = { [WorkspaceFolder(uri: DocumentURI($0))] },
4242
build: Bool = false,
43+
allowBuildFailure: Bool = false,
4344
usePullDiagnostics: Bool = true,
4445
testName: String = #function
4546
) async throws {
@@ -66,7 +67,11 @@ public class SwiftPMTestProject: MultiFileTestProject {
6667
)
6768

6869
if build {
69-
try await Self.build(at: self.scratchDirectory)
70+
if allowBuildFailure {
71+
try? await Self.build(at: self.scratchDirectory)
72+
} else {
73+
try await Self.build(at: self.scratchDirectory)
74+
}
7075
}
7176
// Wait for the indexstore-db to finish indexing
7277
_ = try await testClient.send(PollIndexRequest())

Sources/SKTestSupport/TestSourceKitLSPClient.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ public struct DocumentPositions {
362362
}
363363
}
364364

365-
init(markedText: String) {
365+
public init(markedText: String) {
366366
let (markers, textWithoutMarker) = extractMarkers(markedText)
367367
self.init(markers: markers, textWithoutMarkers: textWithoutMarker)
368368
}

Sources/SourceKitLSP/CMakeLists.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
add_library(SourceKitLSP STATIC
33
CapabilityRegistry.swift
44
DocumentManager.swift
5+
DocumentSnapshot+FromFileContents.swift
56
IndexOutOfDateChecker.swift
67
IndexStoreDB+MainFilesProvider.swift
78
LanguageService.swift
@@ -12,6 +13,7 @@ add_library(SourceKitLSP STATIC
1213
SourceKitLSPCommandMetadata.swift
1314
SourceKitLSPServer.swift
1415
SourceKitLSPServer+Options.swift
16+
SymbolLocation+DocumentURI.swift
1517
TestDiscovery.swift
1618
WorkDoneProgressManager.swift
1719
Workspace.swift
@@ -44,9 +46,10 @@ target_sources(SourceKitLSP PRIVATE
4446
Swift/SwiftLanguageService.swift
4547
Swift/SwiftTestingScanner.swift
4648
Swift/SymbolInfo.swift
49+
Swift/SyntacticTestIndex.swift
4750
Swift/SyntaxHighlightingToken.swift
48-
Swift/SyntaxHighlightingTokens.swift
4951
Swift/SyntaxHighlightingTokenParser.swift
52+
Swift/SyntaxHighlightingTokens.swift
5053
Swift/SyntaxTreeManager.swift
5154
Swift/VariableTypeInfo.swift
5255
)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2024 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 LanguageServerProtocol
14+
import SKSupport
15+
16+
public extension DocumentSnapshot {
17+
/// Creates a `DocumentSnapshot` with the file contents from disk.
18+
///
19+
/// Throws an error if the file could not be read.
20+
/// Returns `nil` if the `uri` is not a file URL.
21+
init?(withContentsFromDisk uri: DocumentURI, language: Language) throws {
22+
guard let url = uri.fileURL else {
23+
return nil
24+
}
25+
try self.init(withContentsFromDisk: url, language: language)
26+
}
27+
28+
/// Creates a `DocumentSnapshot` with the file contents from disk.
29+
///
30+
/// Throws an error if the file could not be read.
31+
init(withContentsFromDisk url: URL, language: Language) throws {
32+
let contents = try String(contentsOf: url)
33+
self.init(uri: DocumentURI(url), language: language, version: 0, lineTable: LineTable(contents))
34+
}
35+
}

Sources/SourceKitLSP/IndexOutOfDateChecker.swift

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,19 @@
1313
import Foundation
1414
import IndexStoreDB
1515
import LSPLogging
16+
import LanguageServerProtocol
1617

1718
/// 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+
/// indexed. Modifications include both changes to the file on disk as well as modifications to the file that have not
20+
/// been saved to disk (ie. changes that only live in `DocumentManager`).
1921
///
2022
/// The checker caches mod dates of source files. It should thus not be long lived. Its intended lifespan is the
2123
/// evaluation of a single request.
2224
struct IndexOutOfDateChecker {
25+
/// The `DocumentManager` that holds the in-memory file contents. We consider the index out-of-date for all files that
26+
/// have in-memory changes.
27+
private let documentManager: DocumentManager
28+
2329
/// The last modification time of a file. Can also represent the fact that the file does not exist.
2430
private enum ModificationTime {
2531
case fileDoesNotExist
@@ -37,12 +43,19 @@ struct IndexOutOfDateChecker {
3743
}
3844
}
3945

40-
/// File paths to modification times that have already been computed.
41-
private var modTimeCache: [String: ModificationTime] = [:]
46+
/// File URLs to modification times that have already been computed.
47+
private var modTimeCache: [URL: ModificationTime] = [:]
48+
49+
/// Caches whether a file URL has modifications in `documentManager` that haven't been saved to disk yet.
50+
private var hasFileInMemoryModificationsCache: [URL: Bool] = [:]
4251

43-
private func modificationDateUncached(of path: String) throws -> ModificationTime {
52+
init(documentManager: DocumentManager) {
53+
self.documentManager = documentManager
54+
}
55+
56+
private func modificationDateUncached(of url: URL) throws -> ModificationTime {
4457
do {
45-
let attributes = try FileManager.default.attributesOfItem(atPath: path)
58+
let attributes = try FileManager.default.attributesOfItem(atPath: url.path())
4659
guard let modificationDate = attributes[FileAttributeKey.modificationDate] as? Date else {
4760
throw Error.fileAttributesDontHaveModificationDate
4861
}
@@ -52,20 +65,44 @@ struct IndexOutOfDateChecker {
5265
}
5366
}
5467

55-
private mutating func modificationDate(of path: String) throws -> ModificationTime {
56-
if let cached = modTimeCache[path] {
68+
private mutating func modificationDate(of url: URL) throws -> ModificationTime {
69+
if let cached = modTimeCache[url] {
5770
return cached
5871
}
59-
let modTime = try modificationDateUncached(of: path)
60-
modTimeCache[path] = modTime
72+
let modTime = try modificationDateUncached(of: url)
73+
modTimeCache[url] = modTime
6174
return modTime
6275
}
6376

77+
private func hasFileInMemoryModificationsUncached(at url: URL) -> Bool {
78+
guard let document = try? documentManager.latestSnapshot(DocumentURI(url)) else {
79+
return false
80+
}
81+
82+
guard let onDiskFileContents = try? String(contentsOf: url, encoding: .utf8) else {
83+
// If we can't read the file on disk, it can't match any on-disk state, so it's in-memory state
84+
return true
85+
}
86+
return onDiskFileContents != document.lineTable.content
87+
}
88+
89+
private mutating func hasFileInMemoryModifications(at url: URL) -> Bool {
90+
if let cached = hasFileInMemoryModificationsCache[url] {
91+
return cached
92+
}
93+
let hasInMemoryModifications = hasFileInMemoryModificationsUncached(at: url)
94+
hasFileInMemoryModificationsCache[url] = hasInMemoryModifications
95+
return hasInMemoryModifications
96+
}
97+
6498
/// Returns `true` if the source file for the given symbol location exists and has not been modified after it has been
6599
/// indexed.
66100
mutating func isUpToDate(_ symbolLocation: SymbolLocation) -> Bool {
101+
if hasFileInMemoryModifications(at: URL(fileURLWithPath: symbolLocation.path)) {
102+
return false
103+
}
67104
do {
68-
let sourceFileModificationDate = try modificationDate(of: symbolLocation.path)
105+
let sourceFileModificationDate = try modificationDate(of: URL(fileURLWithPath: symbolLocation.path))
69106
switch sourceFileModificationDate {
70107
case .fileDoesNotExist:
71108
return false
@@ -81,8 +118,11 @@ struct IndexOutOfDateChecker {
81118
/// Return `true` if a unit file has been indexed for the given file path after its last modification date.
82119
///
83120
/// 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 {
121+
mutating func indexHasUpToDateUnit(for filePath: URL, index: IndexStoreDB) -> Bool {
122+
if hasFileInMemoryModifications(at: filePath) {
123+
return false
124+
}
125+
guard let lastUnitDate = index.dateOfLatestUnitFor(filePath: filePath.path()) else {
86126
return false
87127
}
88128
do {

0 commit comments

Comments
 (0)