Skip to content

Commit ce75053

Browse files
committed
Handle file did change notifications as freestanding messages
Technically, the watched files notification can change the response of any other request (eg. because a target needs to be re-prepared). But treating it as a `globalConfiguration` inserts a lot of barriers in request handling and significantly prevents parallelism. Since many editors batch file change notifications already, they might have delayed the file change notification even more, which is equivalent to handling the notification a little later inside SourceKit-LSP. Thus, treating it as `freestanding` should be acceptable. Also, simplify the logic needed in tests to write modified files to disk because we now need to run a barrier request in tests to ensure that the watched file notification has been handled.
1 parent 71dfc73 commit ce75053

14 files changed

+152
-188
lines changed

Sources/SKTestSupport/MultiFileTestProject.swift

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
package import Foundation
1414
package import LanguageServerProtocol
15+
import SKLogging
1516
package import SKOptions
1617
package import SourceKitLSP
1718
import SwiftExtensions
@@ -66,7 +67,10 @@ package class MultiFileTestProject {
6667

6768
enum Error: Swift.Error {
6869
/// No file with the given filename is known to the `MultiFileTestProject`.
69-
case fileNotFound
70+
case fileNotFound(name: String)
71+
72+
/// Trying to delete a file in `changeDocument` that doesn't exist.
73+
case deleteNonExistentFile
7074
}
7175

7276
/// The directory in which the temporary files are being placed.
@@ -168,7 +172,7 @@ package class MultiFileTestProject {
168172
language: Language? = nil
169173
) throws -> (uri: DocumentURI, positions: DocumentPositions) {
170174
guard let fileData = self.fileData[fileName] else {
171-
throw Error.fileNotFound
175+
throw Error.fileNotFound(name: fileName)
172176
}
173177
let positions = testClient.openDocument(fileData.markedText, uri: fileData.uri, language: language)
174178
return (fileData.uri, positions)
@@ -177,15 +181,15 @@ package class MultiFileTestProject {
177181
/// Returns the URI of the file with the given name.
178182
package func uri(for fileName: String) throws -> DocumentURI {
179183
guard let fileData = self.fileData[fileName] else {
180-
throw Error.fileNotFound
184+
throw Error.fileNotFound(name: fileName)
181185
}
182186
return fileData.uri
183187
}
184188

185189
/// Returns the position of the given marker in the given file.
186190
package func position(of marker: String, in fileName: String) throws -> Position {
187191
guard let fileData = self.fileData[fileName] else {
188-
throw Error.fileNotFound
192+
throw Error.fileNotFound(name: fileName)
189193
}
190194
return DocumentPositions(markedText: fileData.markedText)[marker]
191195
}
@@ -198,4 +202,50 @@ package class MultiFileTestProject {
198202
let range = try self.range(from: fromMarker, to: toMarker, in: fileName)
199203
return Location(uri: try self.uri(for: fileName), range: range)
200204
}
205+
206+
/// Modify the given file on disk, send a watched files did change notification to SourceKit-LSP and wait for that
207+
/// notification to get handled.
208+
///
209+
/// When `newMarkedContents` is `nil`, the file is deleted.
210+
///
211+
/// Returns the URI and the document positions of the new document.
212+
@discardableResult
213+
package func changeFileOnDisk(
214+
_ fileName: String,
215+
newMarkedContents: String?
216+
) async throws -> (uri: DocumentURI, positions: DocumentPositions) {
217+
let uri = try self.uri(for: fileName)
218+
guard let url = uri.fileURL else {
219+
throw Error.fileNotFound(name: fileName)
220+
}
221+
let positions: DocumentPositions
222+
let newContents: String?
223+
if let newMarkedContents {
224+
(positions, newContents) = DocumentPositions.extract(from: newMarkedContents)
225+
} else {
226+
positions = DocumentPositions(markedText: "")
227+
newContents = nil
228+
}
229+
230+
let fileExists = FileManager.default.fileExists(at: url)
231+
let changeType: FileChangeType
232+
switch (fileExists, newContents) {
233+
case (false, let newContents?):
234+
try await newContents.writeWithRetry(to: url)
235+
changeType = .created
236+
case (false, nil):
237+
throw Error.deleteNonExistentFile
238+
case (true, let newContents?):
239+
try await newContents.writeWithRetry(to: url)
240+
changeType = .changed
241+
case (true, nil):
242+
try FileManager.default.removeItem(at: url)
243+
changeType = .deleted
244+
}
245+
testClient.send(DidChangeWatchedFilesNotification(changes: [FileEvent(uri: uri, type: changeType)]))
246+
// Ensure that we handle the `DidChangeWatchedFilesNotification`.
247+
try await testClient.send(BarrierRequest())
248+
249+
return (uri, positions)
250+
}
201251
}

Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,12 @@ package enum MessageHandlingDependencyTracker: QueueBasedMessageHandlerDependenc
103103
case let notification as DidChangeTextDocumentNotification:
104104
self = .documentUpdate(notification.textDocument.uri)
105105
case is DidChangeWatchedFilesNotification:
106-
self = .globalConfigurationChange
106+
// Technically, the watched files notification can change the response of any other request (eg. because a target
107+
// needs to be re-prepared). But treating it as a `globalConfiguration` inserts a lot of barriers in request
108+
// handling and significantly prevents parallelism. Since many editors batch file change notifications already,
109+
// they might have delayed the file change notification even more, which is equivalent to handling the
110+
// notification a little later inside SourceKit-LSP. Thus, treating it as `freestanding` should be acceptable.
111+
self = .freestanding
107112
case is DidChangeWorkspaceFoldersNotification:
108113
self = .globalConfigurationChange
109114
case let notification as DidCloseNotebookDocumentNotification:

Sources/SourceKitLSP/SourceKitLSPServer.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1482,9 +1482,7 @@ extension SourceKitLSPServer {
14821482
// a file doesn't mean a file can't affect the build system's build settings
14831483
// (e.g. Package.swift doesn't have build settings but affects build
14841484
// settings). Inform the build system about all file changes.
1485-
for workspace in workspaces {
1486-
await workspace.filesDidChange(notification.changes)
1487-
}
1485+
await workspaces.concurrentForEach { await $0.filesDidChange(notification.changes) }
14881486
}
14891487

14901488
func setBackgroundIndexingPaused(_ request: SetOptionsRequest) async throws -> VoidResponse {

Sources/SourceKitLSP/Workspace.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,8 +382,10 @@ package final class Workspace: Sendable, BuildSystemManagerDelegate {
382382

383383
// Notify all clients about the reported and inferred edits.
384384
await buildSystemManager.filesDidChange(events)
385-
await syntacticTestIndex.filesDidChange(events)
386-
await semanticIndexManager?.filesDidChange(events)
385+
386+
async let updateSyntacticIndex: Void = await syntacticTestIndex.filesDidChange(events)
387+
async let updateSemanticIndex: Void? = await semanticIndexManager?.filesDidChange(events)
388+
_ = await (updateSyntacticIndex, updateSemanticIndex)
387389
}
388390

389391
func documentService(for uri: DocumentURI) -> LanguageService? {

Tests/SourceKitLSPTests/BackgroundIndexingTests.swift

Lines changed: 34 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -430,24 +430,14 @@ final class BackgroundIndexingTests: XCTestCase {
430430
)
431431
XCTAssertEqual(callsBeforeEdit, [])
432432

433-
let (otherFilePositions, otherFileMarkedContents) = DocumentPositions.extract(
434-
from: """
433+
let (otherFileUri, otherFilePositions) = try await project.changeFileOnDisk(
434+
"MyOtherFile.swift",
435+
newMarkedContents: """
435436
func 2️⃣bar() {
436437
3️⃣foo()
437438
}
438439
"""
439440
)
440-
441-
let otherFileUri = try project.uri(for: "MyOtherFile.swift")
442-
let otherFileUrl = try XCTUnwrap(otherFileUri.fileURL)
443-
444-
try otherFileMarkedContents.write(
445-
to: otherFileUrl,
446-
atomically: true,
447-
encoding: .utf8
448-
)
449-
450-
project.testClient.send(DidChangeWatchedFilesNotification(changes: [FileEvent(uri: otherFileUri, type: .changed)]))
451441
try await project.testClient.send(PollIndexRequest())
452442

453443
let callsAfterEdit = try await project.testClient.send(
@@ -498,21 +488,16 @@ final class BackgroundIndexingTests: XCTestCase {
498488
)
499489
XCTAssertEqual(callsBeforeEdit, [])
500490

501-
let (newPositions, headerNewMarkedContents) = DocumentPositions.extract(
502-
from: """
491+
let (_, newPositions) = try await project.changeFileOnDisk(
492+
"Header.h",
493+
newMarkedContents: """
503494
void someFunc();
504495
505496
void 2️⃣test() {
506497
3️⃣someFunc();
507498
};
508499
"""
509500
)
510-
511-
// clangd might have Header.h open, which prevents us from updating it. Keep retrying until we get a successful
512-
// write. This matches what a user would do.
513-
try await headerNewMarkedContents.writeWithRetry(to: try XCTUnwrap(uri.fileURL))
514-
515-
project.testClient.send(DidChangeWatchedFilesNotification(changes: [FileEvent(uri: uri, type: .changed)]))
516501
try await project.testClient.send(PollIndexRequest())
517502

518503
let callsAfterEdit = try await project.testClient.send(
@@ -584,15 +569,7 @@ final class BackgroundIndexingTests: XCTestCase {
584569
)
585570
XCTAssertNotEqual(initialDiagnostics.fullReport?.items, [])
586571

587-
try "public func foo() {}".write(
588-
to: try XCTUnwrap(project.uri(for: "MyFile.swift").fileURL),
589-
atomically: true,
590-
encoding: .utf8
591-
)
592-
593-
project.testClient.send(
594-
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: try project.uri(for: "MyFile.swift"), type: .changed)])
595-
)
572+
try await project.changeFileOnDisk("MyFile.swift", newMarkedContents: "public func foo() {}")
596573

597574
let receivedEmptyDiagnostics = self.expectation(description: "Received diagnostic refresh request")
598575
receivedEmptyDiagnostics.assertForOverFulfill = false
@@ -700,6 +677,8 @@ final class BackgroundIndexingTests: XCTestCase {
700677
project.testClient.send(
701678
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: try project.uri(for: "LibA.swift"), type: .changed)])
702679
)
680+
// Ensure that we handle the `DidChangeWatchedFilesNotification`.
681+
try await project.testClient.send(BarrierRequest())
703682

704683
// Quickly flip through all files. The way the test is designed to work is as follows:
705684
// - LibB.swift gets opened and prepared. Preparation is simulated to take a long time until both LibC.swift and
@@ -1654,19 +1633,14 @@ final class BackgroundIndexingTests: XCTestCase {
16541633
)
16551634
XCTAssertNil(definitionBeforeEdit)
16561635

1657-
let libAUri = try project.uri(for: "LibA.swift")
1658-
let (newAMarkers, newAContents) = DocumentPositions.extract(
1659-
from: """
1636+
let (libAUri, newAMarkers) = try await project.changeFileOnDisk(
1637+
"LibA.swift",
1638+
newMarkedContents: """
16601639
public struct LibA {
16611640
public func 2️⃣test() {}
16621641
}
16631642
"""
16641643
)
1665-
try await newAContents.writeWithRetry(to: XCTUnwrap(libAUri.fileURL))
1666-
1667-
project.testClient.send(
1668-
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: libAUri, type: .changed)])
1669-
)
16701644

16711645
// Triggering a definition request causes `LibC` to be re-prepared. Repeat the request until LibC has been prepared
16721646
// and we get the expected result.
@@ -1715,16 +1689,15 @@ final class BackgroundIndexingTests: XCTestCase {
17151689
)
17161690
XCTAssertEqual(completionBeforeEdit.items.map(\.label), ["self"])
17171691

1718-
let libAUri = try project.uri(for: "LibA.swift")
1719-
try await """
1720-
public struct LibA {
1721-
public func test() {}
1722-
}
1723-
""".writeWithRetry(to: XCTUnwrap(libAUri.fileURL))
1724-
1725-
project.testClient.send(
1726-
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: libAUri, type: .changed)])
1692+
try await project.changeFileOnDisk(
1693+
"LibA.swift",
1694+
newMarkedContents: """
1695+
public struct LibA {
1696+
public func test() {}
1697+
}
1698+
"""
17271699
)
1700+
17281701
try await repeatUntilExpectedResult {
17291702
let completionAfterEdit = try await project.testClient.send(
17301703
CompletionRequest(textDocument: TextDocumentIdentifier(uri), position: positions["1️⃣"])
@@ -1778,9 +1751,7 @@ final class BackgroundIndexingTests: XCTestCase {
17781751
"Pre edit hover content '\(preEditHoverContent)' does not contain 'String'"
17791752
)
17801753

1781-
let libAUri = try project.uri(for: "LibA.swift")
1782-
try await "public let myVar: Int".writeWithRetry(to: try XCTUnwrap(libAUri.fileURL))
1783-
project.testClient.send(DidChangeWatchedFilesNotification(changes: [FileEvent(uri: libAUri, type: .changed)]))
1754+
try await project.changeFileOnDisk("LibA.swift", newMarkedContents: "public let myVar: Int")
17841755

17851756
try await repeatUntilExpectedResult {
17861757
let postEditHover = try await project.testClient.send(
@@ -2076,22 +2047,22 @@ final class BackgroundIndexingTests: XCTestCase {
20762047
)
20772048
XCTAssertNil(hoverWithMissingDependencyDeclaration)
20782049

2079-
let manifestUri = try project.uri(for: "Package.swift")
2080-
try """
2081-
// swift-tools-version: 5.7
2050+
try await project.changeFileOnDisk(
2051+
"Package.swift",
2052+
newMarkedContents: """
2053+
// swift-tools-version: 5.7
20822054
2083-
import PackageDescription
2055+
import PackageDescription
20842056
2085-
let package = Package(
2086-
name: "MyLibrary",
2087-
targets: [
2088-
.target(name: "LibA", swiftSettings: [.define("ENABLE_FOO")]),
2089-
.target(name: "LibB", dependencies: ["LibA"]),
2090-
]
2057+
let package = Package(
2058+
name: "MyLibrary",
2059+
targets: [
2060+
.target(name: "LibA", swiftSettings: [.define("ENABLE_FOO")]),
2061+
.target(name: "LibB", dependencies: ["LibA"]),
2062+
]
2063+
)
2064+
"""
20912065
)
2092-
""".write(to: XCTUnwrap(project.uri(for: "Package.swift").fileURL), atomically: true, encoding: .utf8)
2093-
2094-
project.testClient.send(DidChangeWatchedFilesNotification(changes: [FileEvent(uri: manifestUri, type: .changed)]))
20952066

20962067
try await repeatUntilExpectedResult {
20972068
let hoverAfterAddingDependencyDeclaration = try await project.testClient.send(

Tests/SourceKitLSPTests/CompilationDatabaseTests.swift

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,7 @@ final class CompilationDatabaseTests: XCTestCase {
5656

5757
// Remove -DFOO from the compile commands.
5858

59-
let compileFlagsUri = try project.uri(for: FixedCompilationDatabaseBuildSystem.dbName)
60-
try await "".writeWithRetry(to: XCTUnwrap(compileFlagsUri.fileURL))
61-
62-
project.testClient.send(
63-
DidChangeWatchedFilesNotification(changes: [
64-
FileEvent(uri: compileFlagsUri, type: .changed)
65-
])
66-
)
67-
68-
// Ensure that the DidChangeWatchedFilesNotification is handled before we continue.
69-
try await project.testClient.send(PollIndexRequest())
59+
try await project.changeFileOnDisk(FixedCompilationDatabaseBuildSystem.dbName, newMarkedContents: "")
7060

7161
// DocumentHighlight should now point to the definition in the `#else` block.
7262

Tests/SourceKitLSPTests/DefinitionTests.swift

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,9 @@ class DefinitionTests: XCTestCase {
351351
)
352352
XCTAssertNil(beforeChangingFileA)
353353

354-
let (updatedAPositions, updatedACode) = DocumentPositions.extract(from: "func 2️⃣sayHello() {}")
355-
356-
let aUri = try project.uri(for: "FileA.swift")
357-
try await updatedACode.writeWithRetry(to: XCTUnwrap(aUri.fileURL))
358-
project.testClient.send(
359-
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: aUri, type: .changed)])
354+
let (aUri, updatedAPositions) = try await project.changeFileOnDisk(
355+
"FileA.swift",
356+
newMarkedContents: "func 2️⃣sayHello() {}"
360357
)
361358

362359
// Wait until SourceKit-LSP has handled the `DidChangeWatchedFilesNotification` (which it only does after a delay

Tests/SourceKitLSPTests/MainFilesProviderTests.swift

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,11 @@ final class MainFilesProviderTests: XCTestCase {
188188
let preEditDiag = try XCTUnwrap(preEditDiags.diagnostics.first)
189189
XCTAssertEqual(preEditDiag.message, "Unused variable 'fromMyLibrary'")
190190

191-
let newFancyLibraryContents = """
192-
#include "\(try project.scratchDirectory.filePath)/Sources/shared.h"
193-
"""
194-
let fancyLibraryUri = try project.uri(for: "MyFancyLibrary.c")
195-
try await newFancyLibraryContents.writeWithRetry(to: XCTUnwrap(fancyLibraryUri.fileURL))
196-
project.testClient.send(
197-
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: fancyLibraryUri, type: .changed)])
191+
try await project.changeFileOnDisk(
192+
"MyFancyLibrary.c",
193+
newMarkedContents: """
194+
#include "\(try project.scratchDirectory.filePath)/Sources/shared.h"
195+
"""
198196
)
199197

200198
// 'MyFancyLibrary.c' now also includes 'shared.h'. Since it lexicographically precedes MyLibrary, we should use its

Tests/SourceKitLSPTests/PublishDiagnosticsTests.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,7 @@ final class PublishDiagnosticsTests: XCTestCase {
146146
return false
147147
}
148148

149-
let updatedACode = "func sayHello() {}"
150-
let aUri = try project.uri(for: "FileA.swift")
151-
try await updatedACode.writeWithRetry(to: XCTUnwrap(aUri.fileURL))
152-
project.testClient.send(
153-
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: aUri, type: .changed)])
154-
)
149+
try await project.changeFileOnDisk("FileA.swift", newMarkedContents: "func sayHello() {}")
155150

156151
let diagnosticsAfterChangingFileA = try await project.testClient.nextDiagnosticsNotification()
157152
XCTAssertEqual(diagnosticsAfterChangingFileA.diagnostics, [])

Tests/SourceKitLSPTests/PullDiagnosticsTests.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,7 @@ final class PullDiagnosticsTests: XCTestCase {
196196
return VoidResponse()
197197
}
198198

199-
let updatedACode = "func sayHello() {}"
200-
let aUri = try project.uri(for: "FileA.swift")
201-
try await updatedACode.writeWithRetry(to: XCTUnwrap(aUri.fileURL))
202-
project.testClient.send(
203-
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: aUri, type: .changed)])
204-
)
205-
199+
try await project.changeFileOnDisk("FileA.swift", newMarkedContents: "func sayHello() {}")
206200
try await fulfillmentOfOrThrow([diagnosticsRefreshRequestReceived])
207201

208202
let afterChangingFileA = try await project.testClient.send(

0 commit comments

Comments
 (0)