Skip to content

Commit c72fe50

Browse files
authored
Merge pull request #1180 from ahoppen/refresh-file-on-updated-dependencies
Reload a file when other files within the same module or a `.swiftmodule` file has been changed
2 parents 30a3d69 + fd7b268 commit c72fe50

14 files changed

+573
-12
lines changed

Sources/SKCore/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
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

Lines changed: 81 additions & 0 deletions
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

Lines changed: 51 additions & 0 deletions
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 {
Lines changed: 27 additions & 0 deletions
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

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

Lines changed: 1 addition & 0 deletions
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

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 & 2 deletions
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

Lines changed: 3 additions & 0 deletions
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

Lines changed: 33 additions & 8 deletions
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
Lines changed: 41 additions & 0 deletions
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)