Skip to content

Commit 767fb08

Browse files
plemarquandahoppen
andauthored
Notify sourcekit-lsp of active document changes (#1404)
`sourcekit-lsp` recently added an LSP extension method to notify the server of the active documnent, so it doesn't need to infer it from information in other requests. This capability was added in swiftlang/sourcekit-lsp#1989. Co-authored-by: Alex Hoppen <[email protected]>
1 parent 41e8872 commit 767fb08

File tree

5 files changed

+246
-20
lines changed

5 files changed

+246
-20
lines changed

src/TestExplorer/LSPTestDiscovery.ts

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import {
2020
WorkspaceTestsRequest,
2121
} from "../sourcekit-lsp/extensions";
2222
import { SwiftPackage, TargetType } from "../SwiftPackage";
23-
import { LanguageClientManager } from "../sourcekit-lsp/LanguageClientManager";
23+
import {
24+
checkExperimentalCapability,
25+
LanguageClientManager,
26+
} from "../sourcekit-lsp/LanguageClientManager";
2427
import { LanguageClient } from "vscode-languageclient/node";
2528

2629
/**
@@ -45,7 +48,7 @@ export class LSPTestDiscovery {
4548
return await this.languageClient.useLanguageClient(async (client, token) => {
4649
// Only use the lsp for this request if it supports the
4750
// textDocument/tests method, and is at least version 2.
48-
if (this.checkExperimentalCapability(client, TextDocumentTestsRequest.method, 2)) {
51+
if (checkExperimentalCapability(client, TextDocumentTestsRequest.method, 2)) {
4952
const testsInDocument = await client.sendRequest(
5053
TextDocumentTestsRequest.type,
5154
{ textDocument: { uri: document.toString() } },
@@ -66,7 +69,7 @@ export class LSPTestDiscovery {
6669
return await this.languageClient.useLanguageClient(async (client, token) => {
6770
// Only use the lsp for this request if it supports the
6871
// workspace/tests method, and is at least version 2.
69-
if (this.checkExperimentalCapability(client, WorkspaceTestsRequest.method, 2)) {
72+
if (checkExperimentalCapability(client, WorkspaceTestsRequest.method, 2)) {
7073
const tests = await client.sendRequest(WorkspaceTestsRequest.type, token);
7174
return this.transformToTestClass(client, swiftPackage, tests);
7275
} else {
@@ -75,23 +78,6 @@ export class LSPTestDiscovery {
7578
});
7679
}
7780

78-
/**
79-
* Returns `true` if the LSP supports the supplied `method` at or
80-
* above the supplied `minVersion`.
81-
*/
82-
private checkExperimentalCapability(
83-
client: LanguageClient,
84-
method: string,
85-
minVersion: number
86-
) {
87-
const experimentalCapability = client.initializeResult?.capabilities.experimental;
88-
if (!experimentalCapability) {
89-
throw new Error(`${method} requests not supported`);
90-
}
91-
const targetCapability = experimentalCapability[method];
92-
return (targetCapability?.version ?? -1) >= minVersion;
93-
}
94-
9581
/**
9682
* Convert from `LSPTestItem[]` to `TestDiscovery.TestClass[]`,
9783
* updating the format of the location.

src/sourcekit-lsp/LanguageClientManager.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import { activateGetReferenceDocument } from "./getReferenceDocument";
4747
import { uriConverters } from "./uriConverters";
4848
import { LanguageClientFactory } from "./LanguageClientFactory";
4949
import { SourceKitLogMessageNotification, SourceKitLogMessageParams } from "./extensions";
50+
import { LSPActiveDocumentManager } from "./didChangeActiveDocument";
51+
import { DidChangeActiveDocumentNotification } from "./extensions/DidChangeActiveDocumentRequest";
5052

5153
/**
5254
* Manages the creation and destruction of Language clients as we move between
@@ -136,6 +138,7 @@ export class LanguageClientManager implements vscode.Disposable {
136138
private legacyInlayHints?: vscode.Disposable;
137139
private peekDocuments?: vscode.Disposable;
138140
private getReferenceDocument?: vscode.Disposable;
141+
private didChangeActiveDocument?: vscode.Disposable;
139142
private restartedPromise?: Promise<void>;
140143
private currentWorkspaceFolder?: vscode.Uri;
141144
private waitingOnRestartCount: number;
@@ -151,6 +154,7 @@ export class LanguageClientManager implements vscode.Disposable {
151154
public subFolderWorkspaces: vscode.Uri[];
152155
private namedOutputChannels: Map<string, LSPOutputChannel> = new Map();
153156
private swiftVersion: Version;
157+
private activeDocumentManager = new LSPActiveDocumentManager();
154158

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

675+
if (this.swiftVersion.isGreaterThanOrEqual(new Version(6, 1, 0))) {
676+
options = {
677+
...options,
678+
"window/didChangeActiveDocument": true, // the client can send `window/didChangeActiveDocument` notifications
679+
};
680+
}
681+
669682
if (configuration.swiftSDK !== "") {
670683
options = {
671684
...options,
@@ -715,6 +728,21 @@ export class LanguageClientManager implements vscode.Disposable {
715728
this.peekDocuments = activatePeekDocuments(client);
716729
this.getReferenceDocument = activateGetReferenceDocument(client);
717730
this.workspaceContext.subscriptions.push(this.getReferenceDocument);
731+
try {
732+
if (
733+
checkExperimentalCapability(
734+
client,
735+
DidChangeActiveDocumentNotification.method,
736+
1
737+
)
738+
) {
739+
this.didChangeActiveDocument =
740+
this.activeDocumentManager.activateDidChangeActiveDocument(client);
741+
this.workspaceContext.subscriptions.push(this.didChangeActiveDocument);
742+
}
743+
} catch {
744+
// do nothing
745+
}
718746
})
719747
.catch(reason => {
720748
this.workspaceContext.outputChannel.log(`${reason}`);
@@ -846,3 +874,20 @@ type SourceKitDocumentSelector = {
846874
scheme: string;
847875
language: string;
848876
}[];
877+
878+
/**
879+
* Returns `true` if the LSP supports the supplied `method` at or
880+
* above the supplied `minVersion`.
881+
*/
882+
export function checkExperimentalCapability(
883+
client: LanguageClient,
884+
method: string,
885+
minVersion: number
886+
) {
887+
const experimentalCapability = client.initializeResult?.capabilities.experimental;
888+
if (!experimentalCapability) {
889+
throw new Error(`${method} requests not supported`);
890+
}
891+
const targetCapability = experimentalCapability[method];
892+
return (targetCapability?.version ?? -1) >= minVersion;
893+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2025 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import * as vscode from "vscode";
16+
import * as langclient from "vscode-languageclient/node";
17+
import { checkExperimentalCapability } from "./LanguageClientManager";
18+
import { DidChangeActiveDocumentNotification } from "./extensions/DidChangeActiveDocumentRequest";
19+
20+
/**
21+
* Monitors the active document and notifies the LSP whenever it changes.
22+
* Only sends notifications for documents that produce `textDocument/didOpen`/`textDocument/didClose`
23+
* requests to the client.
24+
*/
25+
export class LSPActiveDocumentManager {
26+
private openDocuments = new Set<vscode.Uri>();
27+
private lastActiveDocument: langclient.TextDocumentIdentifier | null = null;
28+
29+
// These are LSP middleware functions that listen for document open and close events.
30+
public async didOpen(
31+
document: vscode.TextDocument,
32+
next: (data: vscode.TextDocument) => Promise<void>
33+
) {
34+
this.openDocuments.add(document.uri);
35+
next(document);
36+
}
37+
38+
public async didClose(
39+
document: vscode.TextDocument,
40+
next: (data: vscode.TextDocument) => Promise<void>
41+
) {
42+
this.openDocuments.add(document.uri);
43+
next(document);
44+
}
45+
46+
public activateDidChangeActiveDocument(client: langclient.LanguageClient): vscode.Disposable {
47+
// Fire an inital notification on startup if there is an open document.
48+
this.sendNotification(client, vscode.window.activeTextEditor?.document);
49+
50+
// Listen for the active editor to change and send a notification.
51+
return vscode.window.onDidChangeActiveTextEditor(event => {
52+
this.sendNotification(client, event?.document);
53+
});
54+
}
55+
56+
private sendNotification(
57+
client: langclient.LanguageClient,
58+
document: vscode.TextDocument | undefined
59+
) {
60+
if (checkExperimentalCapability(client, DidChangeActiveDocumentNotification.method, 1)) {
61+
const textDocument =
62+
document && this.openDocuments.has(document.uri)
63+
? client.code2ProtocolConverter.asTextDocumentIdentifier(document)
64+
: null;
65+
66+
// Avoid sending multiple identical notifications in a row.
67+
if (textDocument !== this.lastActiveDocument) {
68+
client.sendNotification(DidChangeActiveDocumentNotification.method, {
69+
textDocument: textDocument,
70+
});
71+
}
72+
this.lastActiveDocument = textDocument;
73+
}
74+
}
75+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2025 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import { MessageDirection, NotificationType, TextDocumentIdentifier } from "vscode-languageclient";
16+
17+
// We use namespaces to store request information just like vscode-languageclient
18+
/* eslint-disable @typescript-eslint/no-namespace */
19+
20+
export interface DidChangeActiveDocumentParams {
21+
/**
22+
* The document that is being displayed in the active editor.
23+
*/
24+
textDocument?: TextDocumentIdentifier;
25+
}
26+
27+
/**
28+
* Notify the server that the active document has changed.
29+
*/
30+
export namespace DidChangeActiveDocumentNotification {
31+
export const method = "window/didChangeActiveDocument" as const;
32+
export const messageDirection: MessageDirection = MessageDirection.clientToServer;
33+
export const type = new NotificationType<DidChangeActiveDocumentParams>(method);
34+
}

test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ import { LanguageClientManager } from "../../../src/sourcekit-lsp/LanguageClient
4343
import configuration from "../../../src/configuration";
4444
import { FolderContext } from "../../../src/FolderContext";
4545
import { LanguageClientFactory } from "../../../src/sourcekit-lsp/LanguageClientFactory";
46+
import { LSPActiveDocumentManager } from "../../../src/sourcekit-lsp/didChangeActiveDocument";
47+
import {
48+
DidChangeActiveDocumentNotification,
49+
DidChangeActiveDocumentParams,
50+
} from "../../../src/sourcekit-lsp/extensions/DidChangeActiveDocumentRequest";
4651

4752
suite("LanguageClientManager Suite", () => {
4853
let languageClientFactoryMock: MockedObject<LanguageClientFactory>;
@@ -112,6 +117,7 @@ suite("LanguageClientManager Suite", () => {
112117
});
113118
mockedConverter = mockObject<Code2ProtocolConverter>({
114119
asUri: mockFn(s => s.callsFake(uri => uri.fsPath)),
120+
asTextDocumentIdentifier: mockFn(s => s.callsFake(doc => ({ uri: doc.uri.fsPath }))),
115121
});
116122
changeStateEmitter = new AsyncEventEmitter();
117123
languageClientMock = mockObject<LanguageClient>({
@@ -123,6 +129,15 @@ suite("LanguageClientManager Suite", () => {
123129
dispose: mockFn(),
124130
})
125131
),
132+
initializeResult: {
133+
capabilities: {
134+
experimental: {
135+
"window/didChangeActiveDocument": {
136+
version: 1,
137+
},
138+
},
139+
},
140+
},
126141
start: mockFn(s =>
127142
s.callsFake(async () => {
128143
const oldState = languageClientMock.state;
@@ -422,6 +437,77 @@ suite("LanguageClientManager Suite", () => {
422437
]);
423438
});
424439

440+
suite("active document changes", () => {
441+
const mockWindow = mockGlobalObject(vscode, "window");
442+
443+
setup(() => {
444+
mockedWorkspace.swiftVersion = new Version(6, 1, 0);
445+
});
446+
447+
test("Notifies when the active document changes", async () => {
448+
const document: vscode.TextDocument = instance(
449+
mockObject<vscode.TextDocument>({
450+
uri: vscode.Uri.file("/folder1/file.swift"),
451+
})
452+
);
453+
454+
let _listener: ((e: vscode.TextEditor | undefined) => any) | undefined;
455+
mockWindow.onDidChangeActiveTextEditor.callsFake((listener, _2, _1) => {
456+
_listener = listener;
457+
return { dispose: () => {} };
458+
});
459+
460+
new LanguageClientManager(instance(mockedWorkspace), languageClientFactoryMock);
461+
await waitForReturnedPromises(languageClientMock.start);
462+
463+
const activeDocumentManager = new LSPActiveDocumentManager();
464+
activeDocumentManager.activateDidChangeActiveDocument(instance(languageClientMock));
465+
activeDocumentManager.didOpen(document, async () => {});
466+
467+
if (_listener) {
468+
_listener(instance(mockObject<vscode.TextEditor>({ document })));
469+
}
470+
471+
expect(languageClientMock.sendNotification).to.have.been.calledOnceWith(
472+
DidChangeActiveDocumentNotification.method,
473+
{
474+
textDocument: {
475+
uri: "/folder1/file.swift",
476+
},
477+
} as DidChangeActiveDocumentParams
478+
);
479+
});
480+
481+
test("Notifies on startup with the active document", async () => {
482+
const document: vscode.TextDocument = instance(
483+
mockObject<vscode.TextDocument>({
484+
uri: vscode.Uri.file("/folder1/file.swift"),
485+
})
486+
);
487+
mockWindow.activeTextEditor = instance(
488+
mockObject<vscode.TextEditor>({
489+
document,
490+
})
491+
);
492+
new LanguageClientManager(instance(mockedWorkspace), languageClientFactoryMock);
493+
await waitForReturnedPromises(languageClientMock.start);
494+
495+
const activeDocumentManager = new LSPActiveDocumentManager();
496+
activeDocumentManager.didOpen(document, async () => {});
497+
498+
activeDocumentManager.activateDidChangeActiveDocument(instance(languageClientMock));
499+
500+
expect(languageClientMock.sendNotification).to.have.been.calledOnceWith(
501+
DidChangeActiveDocumentNotification.method,
502+
{
503+
textDocument: {
504+
uri: "/folder1/file.swift",
505+
},
506+
} as DidChangeActiveDocumentParams
507+
);
508+
});
509+
});
510+
425511
suite("SourceKit-LSP version doesn't support workspace folders", () => {
426512
let folder1: MockedObject<FolderContext>;
427513
let folder2: MockedObject<FolderContext>;

0 commit comments

Comments
 (0)