Skip to content

Add notification to inform SourceKit-LSP about the currently active document #1989

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Contributor Documentation/LSP Extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,25 @@ export interface DocumentTestsParams {
}
```

## `window/didChangeActiveDocument`

New notification from the client to the server, telling SourceKit-LSP which document is the currently active primary document.

By default, SourceKit-LSP infers the currently active editor document from the last document that received a request.
If the client supports active reporting of the currently active document, it should check for the
`window/didChangeActiveDocument` experimental server capability. If that capability is present, it should respond with
the `window/didChangeActiveDocument` experimental client capability and send this notification whenever the currently
active document changes.

- params: `DidChangeActiveDocumentParams`

```ts
export interface DidChangeActiveDocumentParams {
/**
* The document that is being displayed in the active editor.
*/
textDocument: TextDocumentIdentifier;

## `window/logMessage`

Added field:
Expand Down
1 change: 1 addition & 0 deletions Sources/LanguageServerProtocol/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ add_library(LanguageServerProtocol STATIC
Notifications/CancelRequestNotification.swift
Notifications/CancelWorkDoneProgressNotification.swift
Notifications/ConfigurationNotification.swift
Notifications/DidChangeActiveDocumentNotification.swift
Notifications/DidChangeFileNotifications.swift
Notifications/DidChangeWatchedFilesNotification.swift
Notifications/DidChangeWorkspaceFoldersNotification.swift
Expand Down
1 change: 1 addition & 0 deletions Sources/LanguageServerProtocol/Messages.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public let builtinRequests: [_RequestType.Type] = [
public let builtinNotifications: [NotificationType.Type] = [
CancelRequestNotification.self,
CancelWorkDoneProgressNotification.self,
DidChangeActiveDocumentNotification.self,
DidChangeConfigurationNotification.self,
DidChangeNotebookDocumentNotification.self,
DidChangeTextDocumentNotification.self,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2022 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

public struct DidChangeActiveDocumentNotification: NotificationType {
public static let method: String = "window/didChangeActiveDocument"

/// The document that is being displayed in the active editor.
public var textDocument: TextDocumentIdentifier

public init(textDocument: TextDocumentIdentifier) {
self.textDocument = textDocument
}
}
11 changes: 11 additions & 0 deletions Sources/SourceKitLSP/CapabilityRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ package final actor CapabilityRegistry {
registration(for: [language], in: pullDiagnostics) != nil
}

package nonisolated var clientSupportsActiveDocumentNotification: Bool {
return clientHasExperimentalCapability(DidChangeActiveDocumentNotification.method)
}

package nonisolated func clientHasExperimentalCapability(_ name: String) -> Bool {
guard case .dictionary(let experimentalCapabilities) = clientCapabilities.experimental else {
return false
}
return experimentalCapabilities[name] == .bool(true)
}

// MARK: Initializer

package init(clientCapabilities: ClientCapabilities) {
Expand Down
3 changes: 3 additions & 0 deletions Sources/SourceKitLSP/MessageHandlingDependencyTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ package enum MessageHandlingDependencyTracker: QueueBasedMessageHandlerDependenc
self = .freestanding
case is CancelWorkDoneProgressNotification:
self = .freestanding
case is DidChangeActiveDocumentNotification:
// The notification doesn't change behavior in an observable way, so we can treat it as freestanding.
self = .freestanding
case is DidChangeConfigurationNotification:
self = .globalConfigurationChange
case let notification as DidChangeNotebookDocumentNotification:
Expand Down
72 changes: 44 additions & 28 deletions Sources/SourceKitLSP/SourceKitLSPServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,8 @@ extension SourceKitLSPServer: QueueBasedMessageHandler {
logger.log("Received notification: \(notification.forLogging)")

switch notification {
case let notification as DidChangeActiveDocumentNotification:
await self.didChangeActiveDocument(notification)
case let notification as DidChangeTextDocumentNotification:
await self.changeDocument(notification)
case let notification as DidChangeWorkspaceFoldersNotification:
Expand Down Expand Up @@ -736,15 +738,7 @@ extension SourceKitLSPServer: QueueBasedMessageHandler {
logger.log("Received request \(id): \(params.forLogging)")

if let textDocumentRequest = params as? any TextDocumentRequest {
// When we are requesting information from a document, poke preparation of its target. We don't want to wait for
// the preparation to finish because that would cause too big a delay.
// In practice, while the user is working on a file, we'll get a text document request for it on a regular basis,
// which prepares the files. For files that are open but aren't being worked on (eg. a different tab), we don't
// get requests, ensuring that we don't unnecessarily prepare them.
let workspace = await self.workspaceForDocument(uri: textDocumentRequest.textDocument.uri)
await workspace?.semanticIndexManager?.schedulePreparationForEditorFunctionality(
of: textDocumentRequest.textDocument.uri
)
await self.clientInteractedWithDocument(textDocumentRequest.textDocument.uri)
}

switch request {
Expand Down Expand Up @@ -931,25 +925,22 @@ extension SourceKitLSPServer {
//
// The below is a workaround for the vscode-swift extension since it cannot set client capabilities.
// It passes "workspace/peekDocuments" through the `initializationOptions`.
//
// Similarly, for "workspace/getReferenceDocument".
var clientCapabilities = req.capabilities
if case .dictionary(let initializationOptions) = req.initializationOptions {
if let peekDocuments = initializationOptions["workspace/peekDocuments"] {
if case .dictionary(var experimentalCapabilities) = clientCapabilities.experimental {
experimentalCapabilities["workspace/peekDocuments"] = peekDocuments
clientCapabilities.experimental = .dictionary(experimentalCapabilities)
} else {
clientCapabilities.experimental = .dictionary(["workspace/peekDocuments": peekDocuments])
let experimentalClientCapabilities = [
PeekDocumentsRequest.method,
GetReferenceDocumentRequest.method,
DidChangeActiveDocumentNotification.method,
]
for capabilityName in experimentalClientCapabilities {
guard let experimentalCapability = initializationOptions[capabilityName] else {
continue
}
}

if let getReferenceDocument = initializationOptions["workspace/getReferenceDocument"] {
if case .dictionary(var experimentalCapabilities) = clientCapabilities.experimental {
experimentalCapabilities["workspace/getReferenceDocument"] = getReferenceDocument
experimentalCapabilities[capabilityName] = experimentalCapability
clientCapabilities.experimental = .dictionary(experimentalCapabilities)
} else {
clientCapabilities.experimental = .dictionary(["workspace/getReferenceDocument": getReferenceDocument])
clientCapabilities.experimental = .dictionary([capabilityName: experimentalCapability])
}
}

Expand Down Expand Up @@ -1081,10 +1072,11 @@ extension SourceKitLSPServer {
: ExecuteCommandOptions(commands: builtinSwiftCommands)

var experimentalCapabilities: [String: LSPAny] = [
"workspace/tests": .dictionary(["version": .int(2)]),
"textDocument/tests": .dictionary(["version": .int(2)]),
"workspace/triggerReindex": .dictionary(["version": .int(1)]),
"workspace/getReferenceDocument": .dictionary(["version": .int(1)]),
WorkspaceTestsRequest.method: .dictionary(["version": .int(2)]),
DocumentTestsRequest.method: .dictionary(["version": .int(2)]),
TriggerReindexRequest.method: .dictionary(["version": .int(1)]),
GetReferenceDocumentRequest.method: .dictionary(["version": .int(1)]),
DidChangeActiveDocumentNotification.method: .dictionary(["version": .int(1)]),
]
#if canImport(SwiftDocC)
experimentalCapabilities["textDocument/doccDocumentation"] = .dictionary(["version": .int(1)])
Expand Down Expand Up @@ -1268,7 +1260,7 @@ extension SourceKitLSPServer {
)
return
}
await workspace.semanticIndexManager?.schedulePreparationForEditorFunctionality(of: uri)
await self.clientInteractedWithDocument(uri)
await openDocument(notification, workspace: workspace)
}

Expand Down Expand Up @@ -1352,7 +1344,7 @@ extension SourceKitLSPServer {
)
return
}
await workspace.semanticIndexManager?.schedulePreparationForEditorFunctionality(of: uri)
await self.clientInteractedWithDocument(uri)

// If the document is ready, we can handle the change right now.
let editResult = orLog("Editing document") {
Expand All @@ -1374,6 +1366,30 @@ extension SourceKitLSPServer {
)
}

/// If the client doesn't support the `window/didChangeActiveDocument` notification, when the client interacts with a
/// document, infer that this is the currently active document and poke preparation of its target. We don't want to
/// wait for the preparation to finish because that would cause too big a delay.
///
/// In practice, while the user is working on a file, we'll get a text document request for it on a regular basis,
/// which prepares the files. For files that are open but aren't being worked on (eg. a different tab), we don't
/// get requests, ensuring that we don't unnecessarily prepare them.
func clientInteractedWithDocument(_ uri: DocumentURI) async {
if self.capabilityRegistry?.clientHasExperimentalCapability(DidChangeActiveDocumentNotification.method) ?? false {
// The client actively notifies us about the currently active document, so we shouldn't infer the active document
// based on other requests that the client sends us.
return
}
await self.didChangeActiveDocument(
DidChangeActiveDocumentNotification(textDocument: TextDocumentIdentifier(uri))
)
}

func didChangeActiveDocument(_ notification: DidChangeActiveDocumentNotification) async {
let workspace = await self.workspaceForDocument(uri: notification.textDocument.uri)
await workspace?.semanticIndexManager?
.schedulePreparationForEditorFunctionality(of: notification.textDocument.uri)
}

func willSaveDocument(
_ notification: WillSaveTextDocumentNotification,
languageService: LanguageService
Expand Down
5 changes: 2 additions & 3 deletions Sources/SourceKitLSP/Swift/MacroExpansion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,8 @@ extension SwiftLanguageService {
}
}

if case .dictionary(let experimentalCapabilities) = self.capabilityRegistry.clientCapabilities.experimental,
case .bool(true) = experimentalCapabilities["workspace/peekDocuments"],
case .bool(true) = experimentalCapabilities["workspace/getReferenceDocument"]
if self.capabilityRegistry.clientHasExperimentalCapability(PeekDocumentsRequest.method),
self.capabilityRegistry.clientHasExperimentalCapability(GetReferenceDocumentRequest.method)
{
let expansionURIs = try macroExpansionReferenceDocumentURLs.map { try $0.uri }

Expand Down
4 changes: 2 additions & 2 deletions Tests/SourceKitLSPTests/ExpandMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,8 @@ final class ExpandMacroTests: XCTestCase {
files: files,
manifest: SwiftPMTestProject.macroPackageManifest,
capabilities: ClientCapabilities(experimental: [
"workspace/peekDocuments": .bool(peekDocuments),
"workspace/getReferenceDocument": .bool(getReferenceDocument),
PeekDocumentsRequest.method: .bool(peekDocuments),
GetReferenceDocumentRequest.method: .bool(getReferenceDocument),
]),
options: SourceKitLSPOptions.testDefault(),
enableBackgroundIndexing: true
Expand Down
63 changes: 63 additions & 0 deletions Tests/SourceKitLSPTests/WorkspaceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
//
//===----------------------------------------------------------------------===//

import BuildServerProtocol
import BuildSystemIntegration
import Foundation
import LanguageServerProtocol
import SKLogging
import SKOptions
import SKTestSupport
import SemanticIndex
import SourceKitLSP
import SwiftExtensions
import TSCBasic
Expand Down Expand Up @@ -1123,6 +1125,67 @@ final class WorkspaceTests: XCTestCase {
project.scratchDirectory
)
}

func testDidChangeActiveEditorDocument() async throws {
let didChangeBaseLib = AtomicBool(initialValue: false)
let didPrepareLibBAfterChangingBaseLib = self.expectation(description: "Did prepare LibB after changing base lib")
let project = try await SwiftPMTestProject(
files: [
"BaseLib/BaseLib.swift": "",
"LibA/LibA.swift": "",
"LibB/LibB.swift": "",
],
manifest: """
let package = Package(
name: "MyLib",
targets: [
.target(name: "BaseLib"),
.target(name: "LibA", dependencies: ["BaseLib"]),
.target(name: "LibB", dependencies: ["BaseLib"]),
]
)
""",
capabilities: ClientCapabilities(experimental: [
DidChangeActiveDocumentNotification.method: .bool(true)
]),
hooks: Hooks(
indexHooks: IndexHooks(preparationTaskDidStart: { task in
guard didChangeBaseLib.value else {
return
}
do {
XCTAssert(
task.targetsToPrepare.contains(try BuildTargetIdentifier(target: "LibB", destination: .target)),
"Prepared unexpected targets: \(task.targetsToPrepare)"
)
try await repeatUntilExpectedResult {
Task.currentPriority > .low
}
didPrepareLibBAfterChangingBaseLib.fulfill()
} catch {
XCTFail("Received unexpected error: \(error)")
}
})
),
enableBackgroundIndexing: true
)

_ = try project.openDocument("LibA.swift")
let (libBUri, _) = try project.openDocument("LibB.swift")

project.testClient.send(
DidChangeWatchedFilesNotification(changes: [FileEvent(uri: try project.uri(for: "BaseLib.swift"), type: .changed)]
)
)
didChangeBaseLib.value = true

project.testClient.send(
DidChangeActiveDocumentNotification(textDocument: TextDocumentIdentifier(libBUri))
)
try await fulfillmentOfOrThrow([didPrepareLibBAfterChangingBaseLib])

withExtendedLifetime(project) {}
}
}

fileprivate let defaultSDKArgs: String = {
Expand Down