Skip to content

Notify sourcekit-lsp of active document changes #1404

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 5 commits into from
Mar 17, 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
26 changes: 6 additions & 20 deletions src/TestExplorer/LSPTestDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import {
WorkspaceTestsRequest,
} from "../sourcekit-lsp/extensions";
import { SwiftPackage, TargetType } from "../SwiftPackage";
import { LanguageClientManager } from "../sourcekit-lsp/LanguageClientManager";
import {
checkExperimentalCapability,
LanguageClientManager,
} from "../sourcekit-lsp/LanguageClientManager";
import { LanguageClient } from "vscode-languageclient/node";

/**
Expand All @@ -45,7 +48,7 @@ export class LSPTestDiscovery {
return await this.languageClient.useLanguageClient(async (client, token) => {
// Only use the lsp for this request if it supports the
// textDocument/tests method, and is at least version 2.
if (this.checkExperimentalCapability(client, TextDocumentTestsRequest.method, 2)) {
if (checkExperimentalCapability(client, TextDocumentTestsRequest.method, 2)) {
const testsInDocument = await client.sendRequest(
TextDocumentTestsRequest.type,
{ textDocument: { uri: document.toString() } },
Expand All @@ -66,7 +69,7 @@ export class LSPTestDiscovery {
return await this.languageClient.useLanguageClient(async (client, token) => {
// Only use the lsp for this request if it supports the
// workspace/tests method, and is at least version 2.
if (this.checkExperimentalCapability(client, WorkspaceTestsRequest.method, 2)) {
if (checkExperimentalCapability(client, WorkspaceTestsRequest.method, 2)) {
const tests = await client.sendRequest(WorkspaceTestsRequest.type, token);
return this.transformToTestClass(client, swiftPackage, tests);
} else {
Expand All @@ -75,23 +78,6 @@ export class LSPTestDiscovery {
});
}

/**
* Returns `true` if the LSP supports the supplied `method` at or
* above the supplied `minVersion`.
*/
private checkExperimentalCapability(
client: LanguageClient,
method: string,
minVersion: number
) {
const experimentalCapability = client.initializeResult?.capabilities.experimental;
if (!experimentalCapability) {
throw new Error(`${method} requests not supported`);
}
const targetCapability = experimentalCapability[method];
return (targetCapability?.version ?? -1) >= minVersion;
}

/**
* Convert from `LSPTestItem[]` to `TestDiscovery.TestClass[]`,
* updating the format of the location.
Expand Down
45 changes: 45 additions & 0 deletions src/sourcekit-lsp/LanguageClientManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { activateGetReferenceDocument } from "./getReferenceDocument";
import { uriConverters } from "./uriConverters";
import { LanguageClientFactory } from "./LanguageClientFactory";
import { SourceKitLogMessageNotification, SourceKitLogMessageParams } from "./extensions";
import { LSPActiveDocumentManager } from "./didChangeActiveDocument";
import { DidChangeActiveDocumentNotification } from "./extensions/DidChangeActiveDocumentRequest";

/**
* Manages the creation and destruction of Language clients as we move between
Expand Down Expand Up @@ -136,6 +138,7 @@ export class LanguageClientManager implements vscode.Disposable {
private legacyInlayHints?: vscode.Disposable;
private peekDocuments?: vscode.Disposable;
private getReferenceDocument?: vscode.Disposable;
private didChangeActiveDocument?: vscode.Disposable;
private restartedPromise?: Promise<void>;
private currentWorkspaceFolder?: vscode.Uri;
private waitingOnRestartCount: number;
Expand All @@ -151,6 +154,7 @@ export class LanguageClientManager implements vscode.Disposable {
public subFolderWorkspaces: vscode.Uri[];
private namedOutputChannels: Map<string, LSPOutputChannel> = new Map();
private swiftVersion: Version;
private activeDocumentManager = new LSPActiveDocumentManager();

/** Get the current state of the underlying LanguageClient */
public get state(): State {
Expand Down Expand Up @@ -534,6 +538,8 @@ export class LanguageClientManager implements vscode.Disposable {
workspaceFolder: workspaceFolder,
outputChannel: new SwiftOutputChannel("SourceKit Language Server"),
middleware: {
didOpen: this.activeDocumentManager.didOpen.bind(this.activeDocumentManager),
didClose: this.activeDocumentManager.didClose.bind(this.activeDocumentManager),
provideCodeLenses: async (document, token, next) => {
const result = await next(document, token);
return result?.map(codelens => {
Expand Down Expand Up @@ -666,6 +672,13 @@ export class LanguageClientManager implements vscode.Disposable {
};
}

if (this.swiftVersion.isGreaterThanOrEqual(new Version(6, 1, 0))) {
options = {
...options,
"window/didChangeActiveDocument": true, // the client can send `window/didChangeActiveDocument` notifications
};
}

if (configuration.swiftSDK !== "") {
options = {
...options,
Expand Down Expand Up @@ -715,6 +728,21 @@ export class LanguageClientManager implements vscode.Disposable {
this.peekDocuments = activatePeekDocuments(client);
this.getReferenceDocument = activateGetReferenceDocument(client);
this.workspaceContext.subscriptions.push(this.getReferenceDocument);
try {
if (
checkExperimentalCapability(
client,
DidChangeActiveDocumentNotification.method,
1
)
) {
this.didChangeActiveDocument =
this.activeDocumentManager.activateDidChangeActiveDocument(client);
this.workspaceContext.subscriptions.push(this.didChangeActiveDocument);
}
} catch {
// do nothing
}
})
.catch(reason => {
this.workspaceContext.outputChannel.log(`${reason}`);
Expand Down Expand Up @@ -846,3 +874,20 @@ type SourceKitDocumentSelector = {
scheme: string;
language: string;
}[];

/**
* Returns `true` if the LSP supports the supplied `method` at or
* above the supplied `minVersion`.
*/
export function checkExperimentalCapability(
client: LanguageClient,
method: string,
minVersion: number
) {
const experimentalCapability = client.initializeResult?.capabilities.experimental;
if (!experimentalCapability) {
throw new Error(`${method} requests not supported`);
}
const targetCapability = experimentalCapability[method];
return (targetCapability?.version ?? -1) >= minVersion;
}
75 changes: 75 additions & 0 deletions src/sourcekit-lsp/didChangeActiveDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the VS Code Swift open source project
//
// Copyright (c) 2025 the VS Code Swift project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import * as vscode from "vscode";
import * as langclient from "vscode-languageclient/node";
import { checkExperimentalCapability } from "./LanguageClientManager";
import { DidChangeActiveDocumentNotification } from "./extensions/DidChangeActiveDocumentRequest";

/**
* Monitors the active document and notifies the LSP whenever it changes.
* Only sends notifications for documents that produce `textDocument/didOpen`/`textDocument/didClose`
* requests to the client.
*/
export class LSPActiveDocumentManager {
private openDocuments = new Set<vscode.Uri>();
private lastActiveDocument: langclient.TextDocumentIdentifier | null = null;

// These are LSP middleware functions that listen for document open and close events.
public async didOpen(
document: vscode.TextDocument,
next: (data: vscode.TextDocument) => Promise<void>
) {
this.openDocuments.add(document.uri);
next(document);
}

public async didClose(
document: vscode.TextDocument,
next: (data: vscode.TextDocument) => Promise<void>
) {
this.openDocuments.add(document.uri);
next(document);
}

public activateDidChangeActiveDocument(client: langclient.LanguageClient): vscode.Disposable {
// Fire an inital notification on startup if there is an open document.
this.sendNotification(client, vscode.window.activeTextEditor?.document);

// Listen for the active editor to change and send a notification.
return vscode.window.onDidChangeActiveTextEditor(event => {
this.sendNotification(client, event?.document);
});
}

private sendNotification(
client: langclient.LanguageClient,
document: vscode.TextDocument | undefined
) {
if (checkExperimentalCapability(client, DidChangeActiveDocumentNotification.method, 1)) {
const textDocument =
document && this.openDocuments.has(document.uri)
? client.code2ProtocolConverter.asTextDocumentIdentifier(document)
: null;

// Avoid sending multiple identical notifications in a row.
if (textDocument !== this.lastActiveDocument) {
client.sendNotification(DidChangeActiveDocumentNotification.method, {
textDocument: textDocument,
});
}
this.lastActiveDocument = textDocument;
}
}
}
34 changes: 34 additions & 0 deletions src/sourcekit-lsp/extensions/DidChangeActiveDocumentRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the VS Code Swift open source project
//
// Copyright (c) 2025 the VS Code Swift project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import { MessageDirection, NotificationType, TextDocumentIdentifier } from "vscode-languageclient";

// We use namespaces to store request information just like vscode-languageclient
/* eslint-disable @typescript-eslint/no-namespace */

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

/**
* Notify the server that the active document has changed.
*/
export namespace DidChangeActiveDocumentNotification {
export const method = "window/didChangeActiveDocument" as const;
export const messageDirection: MessageDirection = MessageDirection.clientToServer;
export const type = new NotificationType<DidChangeActiveDocumentParams>(method);
}
86 changes: 86 additions & 0 deletions test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ import { LanguageClientManager } from "../../../src/sourcekit-lsp/LanguageClient
import configuration from "../../../src/configuration";
import { FolderContext } from "../../../src/FolderContext";
import { LanguageClientFactory } from "../../../src/sourcekit-lsp/LanguageClientFactory";
import { LSPActiveDocumentManager } from "../../../src/sourcekit-lsp/didChangeActiveDocument";
import {
DidChangeActiveDocumentNotification,
DidChangeActiveDocumentParams,
} from "../../../src/sourcekit-lsp/extensions/DidChangeActiveDocumentRequest";

suite("LanguageClientManager Suite", () => {
let languageClientFactoryMock: MockedObject<LanguageClientFactory>;
Expand Down Expand Up @@ -112,6 +117,7 @@ suite("LanguageClientManager Suite", () => {
});
mockedConverter = mockObject<Code2ProtocolConverter>({
asUri: mockFn(s => s.callsFake(uri => uri.fsPath)),
asTextDocumentIdentifier: mockFn(s => s.callsFake(doc => ({ uri: doc.uri.fsPath }))),
});
changeStateEmitter = new AsyncEventEmitter();
languageClientMock = mockObject<LanguageClient>({
Expand All @@ -123,6 +129,15 @@ suite("LanguageClientManager Suite", () => {
dispose: mockFn(),
})
),
initializeResult: {
capabilities: {
experimental: {
"window/didChangeActiveDocument": {
version: 1,
},
},
},
},
start: mockFn(s =>
s.callsFake(async () => {
const oldState = languageClientMock.state;
Expand Down Expand Up @@ -422,6 +437,77 @@ suite("LanguageClientManager Suite", () => {
]);
});

suite("active document changes", () => {
const mockWindow = mockGlobalObject(vscode, "window");

setup(() => {
mockedWorkspace.swiftVersion = new Version(6, 1, 0);
});

test("Notifies when the active document changes", async () => {
const document: vscode.TextDocument = instance(
mockObject<vscode.TextDocument>({
uri: vscode.Uri.file("/folder1/file.swift"),
})
);

let _listener: ((e: vscode.TextEditor | undefined) => any) | undefined;
mockWindow.onDidChangeActiveTextEditor.callsFake((listener, _2, _1) => {
_listener = listener;
return { dispose: () => {} };
});

new LanguageClientManager(instance(mockedWorkspace), languageClientFactoryMock);
await waitForReturnedPromises(languageClientMock.start);

const activeDocumentManager = new LSPActiveDocumentManager();
activeDocumentManager.activateDidChangeActiveDocument(instance(languageClientMock));
activeDocumentManager.didOpen(document, async () => {});

if (_listener) {
_listener(instance(mockObject<vscode.TextEditor>({ document })));
}

expect(languageClientMock.sendNotification).to.have.been.calledOnceWith(
DidChangeActiveDocumentNotification.method,
{
textDocument: {
uri: "/folder1/file.swift",
},
} as DidChangeActiveDocumentParams
);
});

test("Notifies on startup with the active document", async () => {
const document: vscode.TextDocument = instance(
mockObject<vscode.TextDocument>({
uri: vscode.Uri.file("/folder1/file.swift"),
})
);
mockWindow.activeTextEditor = instance(
mockObject<vscode.TextEditor>({
document,
})
);
new LanguageClientManager(instance(mockedWorkspace), languageClientFactoryMock);
await waitForReturnedPromises(languageClientMock.start);

const activeDocumentManager = new LSPActiveDocumentManager();
activeDocumentManager.didOpen(document, async () => {});

activeDocumentManager.activateDidChangeActiveDocument(instance(languageClientMock));

expect(languageClientMock.sendNotification).to.have.been.calledOnceWith(
DidChangeActiveDocumentNotification.method,
{
textDocument: {
uri: "/folder1/file.swift",
},
} as DidChangeActiveDocumentParams
);
});
});

suite("SourceKit-LSP version doesn't support workspace folders", () => {
let folder1: MockedObject<FolderContext>;
let folder2: MockedObject<FolderContext>;
Expand Down