Skip to content

Commit fd7b268

Browse files
committed
Reload a file when other files within the same module or a .swiftmodule file has been changed
When the client sends us `workspace/didChangeWatchedFiles` notification of an updated `.swift` file, we should refresh the other open files in that module since they might be referencing functions from that updated file. If a `.swiftmodule` file has been updated, we refresh all the files within the package since they might import that module. Technically, we would only need to refresh files that are in module that are downstream of the updated module but we don’t currently have that information easily available from SwiftPM. Also, usually, if the client has a file from a low-level module open, he’ll be working on that module which means that such an optimization won’t help. The real solution here is to wait for us to finish preparation (which we would exactly know when it finishes since sourcekit-lsp would schedule it) but for that we need to implement background preparation. Fixes swiftlang#620 Fixes swiftlang#1116 rdar://99329579 rdar://123971779
1 parent 17c0a44 commit fd7b268

14 files changed

+573
-12
lines changed

Sources/SKCore/CMakeLists.txt

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ add_library(SKCore STATIC
77
BuildSystemManager.swift
88
CompilationDatabase.swift
99
CompilationDatabaseBuildSystem.swift
10+
Debouncer.swift
1011
FallbackBuildSystem.swift
1112
FileBuildSettings.swift
1213
MainFilesProvider.swift

Sources/SKCore/Debouncer.swift

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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+
/// Debounces calls to a function/closure. If multiple calls to the closure are made, it allows aggregating the
14+
/// parameters.
15+
public actor Debouncer<Parameter> {
16+
/// How long to wait for further `scheduleCall` calls before committing to actually calling `makeCall`.
17+
private let debounceDuration: Duration
18+
19+
/// When `scheduleCall` is called while another `scheduleCall` was waiting to commit its call, combines the parameters
20+
/// of those two calls.
21+
///
22+
/// ### Example
23+
///
24+
/// Two `scheduleCall` calls that are made within a time period shorter than `debounceDuration` like the following
25+
/// ```swift
26+
/// debouncer.scheduleCall(5)
27+
/// debouncer.scheduleCall(10)
28+
/// ```
29+
/// will call `combineParameters(5, 10)`
30+
private let combineParameters: (Parameter, Parameter) -> Parameter
31+
32+
/// After the debounce duration has elapsed, commit the call.
33+
private let makeCall: (Parameter) async -> Void
34+
35+
/// In the time between the call to `scheduleCall` and the call actually being committed (ie. in the time that the
36+
/// call can be debounced), the task that would commit the call (unless cancelled) and the parameter with which this
37+
/// call should be made.
38+
private var inProgressData: (Parameter, Task<Void, Never>)?
39+
40+
public init(
41+
debounceDuration: Duration,
42+
combineResults: @escaping (Parameter, Parameter) -> Parameter,
43+
_ makeCall: @escaping (Parameter) async -> Void
44+
) {
45+
self.debounceDuration = debounceDuration
46+
self.combineParameters = combineResults
47+
self.makeCall = makeCall
48+
}
49+
50+
/// Schedule a debounced call. If `scheduleCall` is called within `debounceDuration`, the parameters of the two
51+
/// `scheduleCall` calls will be combined using `combineParameters` and the new debounced call will be scheduled
52+
/// `debounceDuration` after the second `scheduleCall` call.
53+
public func scheduleCall(_ parameter: Parameter) {
54+
var parameter = parameter
55+
if let (inProgressParameter, inProgressTask) = inProgressData {
56+
inProgressTask.cancel()
57+
parameter = combineParameters(inProgressParameter, parameter)
58+
}
59+
let task = Task {
60+
do {
61+
try await Task.sleep(for: debounceDuration)
62+
try Task.checkCancellation()
63+
} catch {
64+
return
65+
}
66+
inProgressData = nil
67+
await makeCall(parameter)
68+
}
69+
inProgressData = (parameter, task)
70+
}
71+
}
72+
73+
extension Debouncer<Void> {
74+
public init(debounceDuration: Duration, _ makeCall: @escaping () async -> Void) {
75+
self.init(debounceDuration: debounceDuration, combineResults: { _, _ in }, makeCall)
76+
}
77+
78+
public func scheduleCall() {
79+
self.scheduleCall(())
80+
}
81+
}

Sources/SKSwiftPMWorkspace/SwiftPMBuildSystem.swift

+51
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ public actor SwiftPMBuildSystem {
9999
/// This callback is informed when `reloadPackage` starts and ends executing.
100100
var reloadPackageStatusCallback: (ReloadPackageStatus) async -> Void
101101

102+
/// Debounces calls to `delegate.filesDependenciesUpdated`.
103+
///
104+
/// This is to ensure we don't call `filesDependenciesUpdated` for the same file multiple time if the client does not
105+
/// debounce `workspace/didChangeWatchedFiles` and sends a separate notification eg. for every file within a target as
106+
/// it's being updated by a git checkout, which would cause other files within that target to receive a
107+
/// `fileDependenciesUpdated` call once for every updated file within the target.
108+
///
109+
/// Force-unwrapped optional because initializing it requires access to `self`.
110+
var fileDependenciesUpdatedDebouncer: Debouncer<Set<DocumentURI>>! = nil
111+
102112
/// Creates a build system using the Swift Package Manager, if this workspace is a package.
103113
///
104114
/// - Parameters:
@@ -166,6 +176,19 @@ public actor SwiftPMBuildSystem {
166176
self.modulesGraph = try ModulesGraph(rootPackages: [], dependencies: [], binaryArtifacts: [:])
167177
self.reloadPackageStatusCallback = reloadPackageStatusCallback
168178

179+
// The debounce duration of 500ms was chosen arbitrarily without scientific research.
180+
self.fileDependenciesUpdatedDebouncer = Debouncer(
181+
debounceDuration: .milliseconds(500),
182+
combineResults: { $0.union($1) }
183+
) {
184+
[weak self] (filesWithUpdatedDependencies) in
185+
guard let delegate = await self?.delegate else {
186+
logger.fault("Not calling filesDependenciesUpdated because no delegate exists in SwiftPMBuildSystem")
187+
return
188+
}
189+
await delegate.filesDependenciesUpdated(filesWithUpdatedDependencies)
190+
}
191+
169192
try await reloadPackage()
170193
}
171194

@@ -368,6 +391,34 @@ extension SwiftPMBuildSystem: SKCore.BuildSystem {
368391
try await self.reloadPackage()
369392
}
370393
}
394+
395+
var filesWithUpdatedDependencies: Set<DocumentURI> = []
396+
// If a Swift file within a target is updated, reload all the other files within the target since they might be
397+
// referring to a function in the updated file.
398+
for event in events {
399+
guard let url = event.uri.fileURL,
400+
url.pathExtension == "swift",
401+
let absolutePath = try? AbsolutePath(validating: url.path),
402+
let target = fileToTarget[absolutePath]
403+
else {
404+
continue
405+
}
406+
filesWithUpdatedDependencies.formUnion(target.sources.map { DocumentURI($0) })
407+
}
408+
409+
// If a `.swiftmodule` file is updated, this means that we have performed a build / are
410+
// performing a build and files that depend on this module have updated dependencies.
411+
// We don't have access to the build graph from the SwiftPM API offered to SourceKit-LSP to figure out which files
412+
// depend on the updated module, so assume that all files have updated dependencies.
413+
// The file watching here is somewhat fragile as well because it assumes that the `.swiftmodule` files are being
414+
// written to a directory within the workspace root. This is not necessarily true if the user specifies a build
415+
// directory outside the source tree.
416+
// All of this shouldn't be necessary once we have background preparation, in which case we know when preparation of
417+
// a target has finished.
418+
if events.contains(where: { $0.uri.fileURL?.pathExtension == "swiftmodule" }) {
419+
filesWithUpdatedDependencies.formUnion(self.fileToTarget.keys.map { DocumentURI($0.asURL) })
420+
}
421+
await self.fileDependenciesUpdatedDebouncer.scheduleCall(filesWithUpdatedDependencies)
371422
}
372423

373424
public func fileHandlingCapability(for uri: DocumentURI) -> FileHandlingCapability {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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 Foundation
14+
15+
extension FileManager {
16+
/// Returns the URLs of all files with the given file extension in the given directory (recursively).
17+
public func findFiles(withExtension extensionName: String, in directory: URL) -> [URL] {
18+
var result: [URL] = []
19+
let enumerator = self.enumerator(at: directory, includingPropertiesForKeys: nil)
20+
while let url = enumerator?.nextObject() as? URL {
21+
if url.pathExtension == extensionName {
22+
result.append(url)
23+
}
24+
}
25+
return result
26+
}
27+
}

Sources/SKTestSupport/MultiFileTestProject.swift

+9
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/SkipUnless.swift

+1
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ public enum SkipUnless {
260260
}
261261
}
262262

263+
/// A long test is a test that takes longer than 1-2s to execute.
263264
public static func longTestsEnabled() throws {
264265
if let value = ProcessInfo.processInfo.environment["SKIP_LONG_TESTS"], value == "1" || value == "YES" {
265266
throw XCTSkip("Long tests disabled using the `SKIP_LONG_TESTS` environment variable")

Sources/SKTestSupport/SwiftPMTestProject.swift

+6-1
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

+1-2
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,6 @@ public final class TestSourceKitLSPClient: MessageHandler {
287287
reply: @escaping (LSPResult<Request.Response>) -> Void
288288
) {
289289
guard let requestHandler = requestHandlers.first else {
290-
XCTFail("Received unexpected request \(Request.method)")
291290
reply(.failure(.methodNotFound(Request.method)))
292291
return
293292
}
@@ -362,7 +361,7 @@ public struct DocumentPositions {
362361
}
363362
}
364363

365-
init(markedText: String) {
364+
public init(markedText: String) {
366365
let (markers, textWithoutMarker) = extractMarkers(markedText)
367366
self.init(markers: markers, textWithoutMarkers: textWithoutMarker)
368367
}

Sources/SourceKitLSP/SourceKitLSPServer.swift

+3
Original file line numberDiff line numberDiff line change
@@ -1386,6 +1386,9 @@ extension SourceKitLSPServer {
13861386
watchers.append(FileSystemWatcher(globPattern: "**/Package.swift", kind: [.change]))
13871387
watchers.append(FileSystemWatcher(globPattern: "**/compile_commands.json", kind: [.create, .change, .delete]))
13881388
watchers.append(FileSystemWatcher(globPattern: "**/compile_flags.txt", kind: [.create, .change, .delete]))
1389+
// Watch for changes to `.swiftmodule` files to detect updated modules during a build.
1390+
// See comments in `SwiftPMBuildSystem.filesDidChange``
1391+
watchers.append(FileSystemWatcher(globPattern: "**/*.swiftmodule", kind: [.create, .change, .delete]))
13891392
await registry.registerDidChangeWatchedFiles(watchers: watchers, server: self)
13901393
}
13911394

Sources/SourceKitLSP/Swift/SwiftLanguageService.swift

+33-8
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,15 @@ public actor SwiftLanguageService: LanguageService {
160160

161161
private var diagnosticReportManager: DiagnosticReportManager!
162162

163+
/// Calling `scheduleCall` on `refreshDiagnosticsDebouncer` schedules a `DiagnosticsRefreshRequest` to be sent to
164+
/// to the client.
165+
///
166+
/// We debounce these calls because the `DiagnosticsRefreshRequest` is a workspace-wide request. If we discover that
167+
/// the client should update diagnostics for file A and then discover that it should also update diagnostics for file
168+
/// B, we don't want to send two `DiagnosticsRefreshRequest`s. Instead, the two should be unified into a single
169+
/// request.
170+
private let refreshDiagnosticsDebouncer: Debouncer<Void>
171+
163172
/// Only exists to work around rdar://116221716.
164173
/// Once that is fixed, remove the property and make `diagnosticReportManager` non-optional.
165174
private var clientHasDiagnosticsCodeDescriptionSupport: Bool {
@@ -189,6 +198,17 @@ public actor SwiftLanguageService: LanguageService {
189198
self.generatedInterfacesPath = options.generatedInterfacesPath.asURL
190199
try FileManager.default.createDirectory(at: generatedInterfacesPath, withIntermediateDirectories: true)
191200
self.diagnosticReportManager = nil // Needed to work around rdar://116221716
201+
202+
// The debounce duration of 500ms was chosen arbitrarily without scientific research.
203+
self.refreshDiagnosticsDebouncer = Debouncer(debounceDuration: .milliseconds(500)) { [weak sourceKitLSPServer] in
204+
guard let sourceKitLSPServer else {
205+
logger.fault("Not sending DiagnosticRefreshRequest to client because sourceKitLSPServer has been deallocated")
206+
return
207+
}
208+
_ = await orLog("Sending DiagnosticRefreshRequest to client after document dependencies updated") {
209+
try await sourceKitLSPServer.sendRequestToClient(DiagnosticsRefreshRequest())
210+
}
211+
}
192212
self.diagnosticReportManager = DiagnosticReportManager(
193213
sourcekitd: self.sourcekitd,
194214
syntaxTreeManager: syntaxTreeManager,
@@ -304,7 +324,7 @@ extension SwiftLanguageService {
304324

305325
private func reopenDocument(_ snapshot: DocumentSnapshot, _ compileCmd: SwiftCompileCommand?) async {
306326
cancelInFlightPublishDiagnosticsTask(for: snapshot.uri)
307-
await diagnosticReportManager.removeItemsFromCache(with: snapshot.id.uri)
327+
await diagnosticReportManager.removeItemsFromCache(with: snapshot.uri)
308328

309329
let keys = self.keys
310330
let path = snapshot.uri.pseudoPath
@@ -324,7 +344,11 @@ extension SwiftLanguageService {
324344

325345
_ = try? await self.sourcekitd.send(openReq, fileContents: snapshot.text)
326346

327-
await publishDiagnosticsIfNeeded(for: snapshot.uri)
347+
if await capabilityRegistry.clientSupportsPullDiagnostics(for: .swift) {
348+
await self.refreshDiagnosticsDebouncer.scheduleCall()
349+
} else {
350+
await publishDiagnosticsIfNeeded(for: snapshot.uri)
351+
}
328352
}
329353

330354
public func documentUpdatedBuildSettings(_ uri: DocumentURI) async {
@@ -339,13 +363,14 @@ extension SwiftLanguageService {
339363
}
340364

341365
public func documentDependenciesUpdated(_ uri: DocumentURI) async {
342-
guard let snapshot = try? self.documentManager.latestSnapshot(uri) else {
343-
return
366+
await orLog("Sending dependencyUpdated request to sourcekitd") {
367+
let req = sourcekitd.dictionary([
368+
keys.request: requests.dependencyUpdated
369+
])
370+
_ = try await self.sourcekitd.send(req, fileContents: nil)
344371
}
345-
346-
// Forcefully reopen the document since the `BuildSystem` has informed us
347-
// that the dependencies have changed and the AST needs to be reloaded.
348-
await self.reopenDocument(snapshot, self.buildSettings(for: uri))
372+
// `documentUpdatedBuildSettings` already handles reopening the document, so we do that here as well.
373+
await self.documentUpdatedBuildSettings(uri)
349374
}
350375

351376
// MARK: - Text synchronization
+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2018 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 SKCore
14+
import XCTest
15+
16+
final class DebouncerTests: XCTestCase {
17+
func testDebouncerDebounces() async throws {
18+
let expectation = self.expectation(description: "makeCallCalled")
19+
expectation.assertForOverFulfill = true
20+
let debouncer = Debouncer<Void>(debounceDuration: .seconds(0.1)) {
21+
expectation.fulfill()
22+
}
23+
await debouncer.scheduleCall()
24+
await debouncer.scheduleCall()
25+
try await self.fulfillmentOfOrThrow([expectation])
26+
// Sleep for 0.2s to make sure the debouncer actually debounces and doesn't fulfill the expectation twice.
27+
try await Task.sleep(for: .seconds(0.2))
28+
}
29+
30+
func testDebouncerCombinesParameters() async throws {
31+
let expectation = self.expectation(description: "makeCallCalled")
32+
expectation.assertForOverFulfill = true
33+
let debouncer = Debouncer<Int>(debounceDuration: .seconds(0.1), combineResults: { $0 + $1 }) { param in
34+
XCTAssertEqual(param, 3)
35+
expectation.fulfill()
36+
}
37+
await debouncer.scheduleCall(1)
38+
await debouncer.scheduleCall(2)
39+
try await self.fulfillmentOfOrThrow([expectation])
40+
}
41+
}

0 commit comments

Comments
 (0)