Skip to content

Commit 5ea997b

Browse files
authored
Merge pull request #7230 from dibarbet/on_auto_insert_tests
Make OnAutoInsert more reliable and add integration tests
2 parents a8d8695 + 75e63a1 commit 5ea997b

File tree

6 files changed

+175
-48
lines changed

6 files changed

+175
-48
lines changed

src/lsptoolshost/onAutoInsert.ts

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,45 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import * as vscode from 'vscode';
7-
import { UriConverter } from './uriConverter';
87

9-
import { FormattingOptions, TextDocumentIdentifier } from 'vscode-languageclient/node';
8+
import { FormattingOptions, LanguageClient, TextDocumentIdentifier } from 'vscode-languageclient/node';
109
import * as RoslynProtocol from './roslynProtocol';
1110
import { RoslynLanguageServer } from './roslynLanguageServer';
1211

13-
export function registerOnAutoInsert(languageServer: RoslynLanguageServer) {
12+
export function registerOnAutoInsert(languageServer: RoslynLanguageServer, languageClient: LanguageClient) {
1413
let source = new vscode.CancellationTokenSource();
15-
vscode.workspace.onDidChangeTextDocument(async (e) => {
14+
15+
// We explicitly register against the server's didChange notification (instead of the VSCode workspace API)
16+
// as we want to ensure that the server has processed the change before we request edits from auto insert.
17+
// The VSCode workspace API will sometimes call this before the LSP client can send the change, leading to auto insert not working.
18+
languageClient.getFeature('textDocument/didChange').onNotificationSent(async (event) => {
19+
const e = event.params;
1620
if (e.contentChanges.length > 1 || e.contentChanges.length === 0) {
1721
return;
1822
}
1923

2024
const change = e.contentChanges[0];
25+
// TextDocumentContentChangeEvent is a union type that does not return a range if the change event is for a full document.
26+
// Full document changes are not supported for onautoinsert.
27+
if (!('range' in change)) {
28+
return;
29+
}
30+
31+
// Convert to a VSCode range for ease of handling.
32+
const vscodeRange = languageClient.protocol2CodeConverter.asRange(change.range);
2133

22-
if (!change.range.isEmpty) {
34+
// Empty or multiline changes are not supported for onautoinsert.
35+
if (!vscodeRange.isEmpty || !vscodeRange.isSingleLine) {
2336
return;
2437
}
2538

39+
// We need to convert to a vscode TextDocument to apply the correct capabilities.
40+
const uri = languageClient.protocol2CodeConverter.asUri(e.textDocument.uri);
41+
// This is a no-op because the document is already open (in order to be edited).
42+
const document = await vscode.workspace.openTextDocument(uri);
43+
2644
const onAutoInsertFeature = languageServer.getOnAutoInsertFeature();
27-
const onAutoInsertOptions = onAutoInsertFeature?.getOptions(e.document);
45+
const onAutoInsertOptions = onAutoInsertFeature?.getOptions(document);
2846
const vsTriggerCharacters = onAutoInsertOptions?._vs_triggerCharacters;
2947

3048
if (vsTriggerCharacters === undefined) {
@@ -38,10 +56,17 @@ export function registerOnAutoInsert(languageServer: RoslynLanguageServer) {
3856
return;
3957
}
4058

59+
// We have a single line range so we can compute the length by comparing the start and end character positions.
60+
const rangeLength = vscodeRange.end.character - vscodeRange.start.character;
61+
62+
// The server expects the request position to represent the caret position in the text after the change has already been applied.
63+
// We need to calculate what that position would be after the change is applied and send that to the server.
64+
const position = vscodeRange.start.translate(0, change.text.length - rangeLength);
65+
4166
source.cancel();
4267
source = new vscode.CancellationTokenSource();
4368
try {
44-
await applyAutoInsertEdit(e, changeTrimmed, languageServer, source.token);
69+
await applyAutoInsertEdit(position, changeTrimmed, e.textDocument, uri, languageServer, source.token);
4570
} catch (e) {
4671
if (e instanceof vscode.CancellationError) {
4772
return;
@@ -53,25 +78,18 @@ export function registerOnAutoInsert(languageServer: RoslynLanguageServer) {
5378
}
5479

5580
async function applyAutoInsertEdit(
56-
e: vscode.TextDocumentChangeEvent,
57-
changeTrimmed: string,
81+
position: vscode.Position,
82+
changeTextTrimmed: string,
83+
textDocumentIdentifier: TextDocumentIdentifier,
84+
uri: vscode.Uri,
5885
languageServer: RoslynLanguageServer,
5986
token: vscode.CancellationToken
6087
) {
61-
const change = e.contentChanges[0];
62-
// The server expects the request position to represent the caret position in the text after the change has already been applied.
63-
// We need to calculate what that position would be after the change is applied and send that to the server.
64-
const position = new vscode.Position(
65-
change.range.start.line,
66-
change.range.start.character + (change.text.length - change.rangeLength)
67-
);
68-
const uri = UriConverter.serialize(e.document.uri);
69-
const textDocument = TextDocumentIdentifier.create(uri);
7088
const formattingOptions = getFormattingOptions();
7189
const request: RoslynProtocol.OnAutoInsertParams = {
72-
_vs_textDocument: textDocument,
90+
_vs_textDocument: textDocumentIdentifier,
7391
_vs_position: position,
74-
_vs_ch: changeTrimmed,
92+
_vs_ch: changeTextTrimmed,
7593
_vs_options: formattingOptions,
7694
};
7795

@@ -84,7 +102,7 @@ async function applyAutoInsertEdit(
84102
const code: any = vscode;
85103
const textEdits = [new code.SnippetTextEdit(new vscode.Range(startPosition, endPosition), docComment)];
86104
const edit = new vscode.WorkspaceEdit();
87-
edit.set(e.document.uri, textEdits);
105+
edit.set(uri, textEdits);
88106

89107
const applied = vscode.workspace.applyEdit(edit);
90108
if (!applied) {

src/lsptoolshost/roslynLanguageServer.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export class RoslynLanguageServer {
131131

132132
registerShowToastNotification(this._languageClient);
133133

134+
registerOnAutoInsert(this, this._languageClient);
135+
134136
this._onAutoInsertFeature = new OnAutoInsertFeature(this._languageClient);
135137
}
136138

@@ -990,8 +992,6 @@ export async function activateRoslynLanguageServer(
990992

991993
registerRestoreCommands(context, languageServer, dotnetChannel);
992994

993-
registerOnAutoInsert(languageServer);
994-
995995
context.subscriptions.push(registerLanguageServerOptionChanges(optionObservable));
996996

997997
return languageServer;

test/integrationTests/diagnosticsHelpers.ts

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*--------------------------------------------------------------------------------------------*/
55
import * as vscode from 'vscode';
66
import { expect } from '@jest/globals';
7-
import { restartLanguageServer } from './integrationHelpers';
7+
import { restartLanguageServer, waitForExpectedResult } from './integrationHelpers';
88

99
function sortDiagnostics(diagnostics: vscode.Diagnostic[]): vscode.Diagnostic[] {
1010
return diagnostics.sort((a, b) => {
@@ -21,32 +21,26 @@ export async function waitForExpectedDiagnostics(
2121
assertExpectedDiagnostics: (input: [vscode.Uri, vscode.Diagnostic[]][]) => void,
2222
file?: vscode.Uri
2323
): Promise<void> {
24-
let duration = 30 * 1000;
24+
const duration = 30 * 1000;
2525
const step = 500;
26-
let error: any = undefined;
27-
while (duration > 0) {
28-
const diagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [];
29-
if (file) {
30-
diagnostics.push([file, sortDiagnostics(vscode.languages.getDiagnostics(file))]);
31-
} else {
32-
const allDiagnostics = vscode.languages.getDiagnostics();
33-
for (const [uri, uriDiagnostics] of allDiagnostics) {
34-
diagnostics.push([uri, sortDiagnostics(uriDiagnostics)]);
26+
await waitForExpectedResult<[vscode.Uri, vscode.Diagnostic[]][]>(
27+
() => {
28+
const diagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [];
29+
if (file) {
30+
diagnostics.push([file, sortDiagnostics(vscode.languages.getDiagnostics(file))]);
31+
} else {
32+
const allDiagnostics = vscode.languages.getDiagnostics();
33+
for (const [uri, uriDiagnostics] of allDiagnostics) {
34+
diagnostics.push([uri, sortDiagnostics(uriDiagnostics)]);
35+
}
3536
}
36-
}
37-
38-
try {
39-
assertExpectedDiagnostics(diagnostics);
40-
return;
41-
} catch (e) {
42-
error = e;
43-
// Wait for a bit and try again.
44-
await new Promise((r) => setTimeout(r, step));
45-
duration -= step;
46-
}
47-
}
4837

49-
throw new Error(`Polling did not succeed within the alotted duration: ${error}`);
38+
return diagnostics;
39+
},
40+
duration,
41+
step,
42+
assertExpectedDiagnostics
43+
);
5044
}
5145

5246
export async function setBackgroundAnalysisScopes(scopes: { compiler: string; analyzer: string }): Promise<void> {

test/integrationTests/integrationHelpers.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ export async function openFileInWorkspaceAsync(relativeFilePath: string): Promis
5858
return uri;
5959
}
6060

61+
/**
62+
* Reverts any unsaved changes to the active file.
63+
* Useful to reset state between tests without fully reloading everything.
64+
*/
65+
export async function revertActiveFile(): Promise<void> {
66+
await vscode.commands.executeCommand('workbench.action.files.revert');
67+
}
68+
6169
export async function restartLanguageServer(): Promise<void> {
6270
const csharpExtension = vscode.extensions.getExtension<CSharpExtensionExports>('ms-dotnettools.csharp');
6371
// Register to wait for initialization events and restart the server.
@@ -112,3 +120,33 @@ function isGivenSln(workspace: typeof vscode.workspace, expectedProjectFileName:
112120

113121
return projectFileName === expectedProjectFileName;
114122
}
123+
124+
export async function waitForExpectedResult<T>(
125+
getValue: () => Promise<T> | T,
126+
duration: number,
127+
step: number,
128+
expression: (input: T) => void
129+
): Promise<void> {
130+
let value: T;
131+
let error: any = undefined;
132+
133+
while (duration > 0) {
134+
value = await getValue();
135+
136+
try {
137+
expression(value);
138+
return;
139+
} catch (e) {
140+
error = e;
141+
// Wait for a bit and try again.
142+
await new Promise((r) => setTimeout(r, step));
143+
duration -= step;
144+
}
145+
}
146+
147+
throw new Error(`Polling did not succeed within the alotted duration: ${error}`);
148+
}
149+
150+
export async function sleep(ms = 0) {
151+
return new Promise((r) => setTimeout(r, ms));
152+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
import * as path from 'path';
8+
import testAssetWorkspace from './testAssets/testAssetWorkspace';
9+
import {
10+
activateCSharpExtension,
11+
openFileInWorkspaceAsync,
12+
revertActiveFile,
13+
sleep,
14+
waitForExpectedResult,
15+
} from './integrationHelpers';
16+
import { describe, beforeAll, beforeEach, afterAll, test, expect, afterEach } from '@jest/globals';
17+
18+
describe(`[${testAssetWorkspace.description}] Test OnAutoInsert`, function () {
19+
beforeAll(async function () {
20+
await activateCSharpExtension();
21+
});
22+
23+
beforeEach(async function () {
24+
await openFileInWorkspaceAsync(path.join('src', 'app', 'DocComments.cs'));
25+
});
26+
27+
afterEach(async () => {
28+
await revertActiveFile();
29+
});
30+
31+
afterAll(async () => {
32+
await testAssetWorkspace.cleanupWorkspace();
33+
});
34+
35+
test('Triple slash inserts doc comment snippet', async () => {
36+
await sleep(1);
37+
await vscode.window.activeTextEditor!.edit((editBuilder) => {
38+
editBuilder.insert(new vscode.Position(2, 6), '/');
39+
});
40+
41+
// OnAutoInsert is triggered by the change event but completes asynchronously, so wait for the buffer to be updated.
42+
43+
await waitForExpectedResult<string | undefined>(
44+
async () => vscode.window.activeTextEditor?.document.getText(),
45+
10000,
46+
100,
47+
(input) => {
48+
expect(normalizeNewlines(input)).toContain(
49+
'/// <summary>\n /// \n /// </summary>\n /// <param name="param1"></param>\n /// <param name="param2"></param>\n /// <returns></returns>'
50+
);
51+
}
52+
);
53+
});
54+
55+
test('Enter in comment inserts triple-slashes preceding', async () => {
56+
await vscode.window.activeTextEditor!.edit((editBuilder) => {
57+
editBuilder.insert(new vscode.Position(8, 17), '\n');
58+
});
59+
60+
// OnAutoInsert is triggered by the change event but completes asynchronously, so wait for the buffer to be updated.
61+
62+
await waitForExpectedResult<string | undefined>(
63+
async () => vscode.window.activeTextEditor?.document.getText(),
64+
10000,
65+
100,
66+
(input) => {
67+
expect(normalizeNewlines(input)).toContain(
68+
'/// <summary>\n /// \n\n /// </summary>\n void M2() {}'
69+
);
70+
}
71+
);
72+
});
73+
});
74+
75+
function normalizeNewlines(text: string | undefined): string | undefined {
76+
return text?.replaceAll('\r\n', '\n');
77+
}

test/integrationTests/testAssets/slnWithCsproj/src/app/DocComments.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
class DocComments
22
{
3-
///
3+
//
44
string M(int param1, string param2)
55
{
66
return null;

0 commit comments

Comments
 (0)