Skip to content

Commit f41b947

Browse files
authored
feat: trigger signature help after call completion (#1878)
1 parent 8dee902 commit f41b947

4 files changed

Lines changed: 248 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Accepting a function or method completion that inserts parentheses now opens signature help, respecting `editor.parameterHints.enabled`.
6+
37
## 3.2.0 - 2026-10-15
48

59
### Compatibility and upgrade notes

‎src/completionMiddleware.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { CompletionItemKind, SnippetString, workspace } from 'vscode';
2+
import type { CompletionMiddleware } from 'vscode-languageclient/node';
3+
4+
export const completionMiddleware: CompletionMiddleware = {
5+
provideCompletionItem: async (document, position, context, token, next) => {
6+
const result = await next(document, position, context, token);
7+
if (!result || token.isCancellationRequested
8+
|| !workspace.getConfiguration('editor', document).get<boolean>('parameterHints.enabled', true)) {
9+
return result;
10+
}
11+
12+
const items = Array.isArray(result) ? result : result.items;
13+
for (const item of items) {
14+
// languageserver inserts a call with the cursor inside its parentheses.
15+
// Snippet insertion does not fire VS Code's signature-help triggers.
16+
if (!item.command
17+
&& (item.kind === CompletionItemKind.Function
18+
|| item.kind === CompletionItemKind.Method
19+
|| item.kind === CompletionItemKind.Constructor)
20+
&& item.insertText instanceof SnippetString
21+
&& item.insertText.value.endsWith('($0)')) {
22+
item.command = {
23+
title: 'Trigger parameter hints',
24+
command: 'editor.action.triggerParameterHints'
25+
};
26+
}
27+
}
28+
return result;
29+
}
30+
};

‎src/languageService.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { extensionContext } from './extension';
1212
import { CommonOptions } from 'child_process';
1313
import { boundSessionForDocument, onDidBindSessionDocument, Session } from './session';
1414
import { SessionSignatureHelpProvider } from './signatureHelp';
15+
import { completionMiddleware } from './completionMiddleware';
1516

1617
export class LanguageService implements Disposable {
1718
private readonly clients: Map<string, LanguageClient> = new Map();
@@ -170,6 +171,7 @@ export class LanguageService implements Disposable {
170171
fileEvents: workspace.createFileSystemWatcher('**/*.{R,r}'),
171172
},
172173
middleware: {
174+
...completionMiddleware,
173175
provideSignatureHelp: async (document, position, context, token, next) => {
174176
const result = await next(document, position, context, token);
175177
// An empty LSP result still suppresses other VS Code providers.
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import { PassThrough } from 'stream';
4+
import * as vscode from 'vscode';
5+
import * as lsp from 'vscode-languageclient/node';
6+
import { completionMiddleware } from '../../completionMiddleware';
7+
8+
suite('Completion signature help', () => {
9+
let sandbox: sinon.SinonSandbox;
10+
let document: vscode.TextDocument;
11+
let cancellation: vscode.CancellationTokenSource;
12+
let configuration: sinon.SinonStub;
13+
let enabled: sinon.SinonStub;
14+
const position = new vscode.Position(0, 3);
15+
const context = { triggerKind: vscode.CompletionTriggerKind.Invoke, triggerCharacter: undefined };
16+
const provide = completionMiddleware.provideCompletionItem!;
17+
18+
function callItem(label = 'fun1', kind = vscode.CompletionItemKind.Function): vscode.CompletionItem {
19+
const item = new vscode.CompletionItem(label, kind);
20+
item.insertText = new vscode.SnippetString(`${label}($0)`);
21+
return item;
22+
}
23+
24+
setup(async () => {
25+
document = await vscode.workspace.openTextDocument({ language: 'r', content: 'fun' });
26+
cancellation = new vscode.CancellationTokenSource();
27+
sandbox = sinon.createSandbox();
28+
enabled = sandbox.stub().returns(true);
29+
configuration = sandbox.stub(vscode.workspace, 'getConfiguration').callThrough();
30+
configuration.withArgs('editor', document).returns({ get: enabled } as unknown as vscode.WorkspaceConfiguration);
31+
});
32+
33+
teardown(() => {
34+
sandbox.restore();
35+
cancellation.dispose();
36+
});
37+
38+
test('adds a command to function, package, method, and constructor call snippets', async () => {
39+
const items = [callItem(), callItem('pkg::fun1'), callItem('`run task`', vscode.CompletionItemKind.Method),
40+
callItem('new', vscode.CompletionItemKind.Constructor)];
41+
const next = sandbox.stub().resolves(items);
42+
const result = await provide(document, position, context, cancellation.token, next);
43+
44+
assert.strictEqual(result, items);
45+
sinon.assert.calledOnceWithExactly(next, document, position, context, cancellation.token);
46+
sinon.assert.calledWithExactly(configuration, 'editor', document);
47+
sinon.assert.calledOnceWithExactly(enabled, 'parameterHints.enabled', true);
48+
for (const item of items) {
49+
assert.strictEqual(item.command?.command, 'editor.action.triggerParameterHints');
50+
}
51+
});
52+
53+
test('preserves completion-list metadata and uses the document setting on each request', async () => {
54+
enabled.returns(false);
55+
const item = callItem();
56+
const list = new vscode.CompletionList([item], true);
57+
const next = () => list;
58+
assert.strictEqual(await provide(document, position, context, cancellation.token, next), list);
59+
assert.strictEqual(item.command, undefined);
60+
enabled.returns(true);
61+
assert.strictEqual(await provide(document, position, context, cancellation.token, next), list);
62+
assert.strictEqual(list.items[0].command?.command, 'editor.action.triggerParameterHints');
63+
assert.strictEqual(list.isIncomplete, true);
64+
});
65+
66+
test('leaves name-only completions, non-call snippets, fields, and existing commands alone', async () => {
67+
const nameOnly = callItem();
68+
nameOnly.insertText = 'fun1';
69+
const plainText = callItem();
70+
plainText.insertText = 'fun1($0)';
71+
const snippetName = callItem();
72+
snippetName.insertText = new vscode.SnippetString('fun1');
73+
const afterCall = callItem();
74+
afterCall.insertText = new vscode.SnippetString('fun1()$0');
75+
const hasCommand = callItem();
76+
const command = { title: 'Existing command', command: 'existing.command', arguments: ['original'] };
77+
hasCommand.command = command;
78+
const items = [nameOnly, plainText, snippetName, afterCall, callItem('field', vscode.CompletionItemKind.Field),
79+
new vscode.CompletionItem('fun1', vscode.CompletionItemKind.Function)];
80+
81+
await provide(document, position, context, cancellation.token, () => [...items, hasCommand]);
82+
assert.ok(items.every(item => item.command === undefined));
83+
assert.strictEqual(hasCommand.command, command);
84+
});
85+
86+
test('passes through empty and cancelled results', async () => {
87+
for (const result of [undefined, null, [], new vscode.CompletionList()]) {
88+
assert.strictEqual(await provide(document, position, context, cancellation.token, () => result), result);
89+
}
90+
const item = callItem();
91+
const result = await provide(document, position, context, cancellation.token, () => {
92+
cancellation.cancel();
93+
return [item];
94+
});
95+
assert.deepStrictEqual(result, [item]);
96+
assert.strictEqual(item.command, undefined);
97+
});
98+
99+
test('handles LSP insert text, text edits, insert/replace edits, and completion-list defaults', async () => {
100+
const client = new lsp.LanguageClient('completion-converter-test', 'Completion converter test',
101+
{ command: 'unused' }, { documentSelector: [] });
102+
const range = lsp.Range.create(0, 0, 0, 3);
103+
const base = { label: 'fun1', kind: lsp.CompletionItemKind.Function, insertTextFormat: lsp.InsertTextFormat.Snippet };
104+
const responses: Array<lsp.CompletionItem[] | lsp.CompletionList> = [
105+
[{ ...base, insertText: 'fun1($0)' }],
106+
[{ ...base, insertText: 'ignored', textEdit: { range, newText: 'fun1($0)' } }],
107+
[{ ...base, textEdit: { insert: range, replace: range, newText: 'fun1($0)' } }],
108+
{ isIncomplete: true, itemDefaults: { editRange: range, insertTextFormat: lsp.InsertTextFormat.Snippet },
109+
items: [{ label: 'fun1', kind: lsp.CompletionItemKind.Function, textEditText: 'fun1($0)' }] }
110+
];
111+
try {
112+
for (const response of responses) {
113+
const converted = await client.protocol2CodeConverter.asCompletionResult(response);
114+
const result = await provide(document, position, context, cancellation.token, () => converted);
115+
assert.ok(result);
116+
const item = (Array.isArray(result) ? result : result.items)[0];
117+
assert.strictEqual(item.command?.command, 'editor.action.triggerParameterHints');
118+
// languageserver resolves documentation on the supplied item and
119+
// returns it, including its command, through these converters.
120+
const resolved = client.protocol2CodeConverter.asCompletionItem({
121+
...client.code2ProtocolConverter.asCompletionItem(item), documentation: 'Function documentation'
122+
});
123+
assert.strictEqual(resolved.command?.command, 'editor.action.triggerParameterHints');
124+
assert.strictEqual(resolved.documentation, 'Function documentation');
125+
}
126+
} finally {
127+
await client.dispose();
128+
}
129+
});
130+
});
131+
132+
suite('Completion signature help acceptance', () => {
133+
const settings = () => vscode.workspace.getConfiguration('editor', { languageId: 'r' });
134+
let previousHints: boolean | undefined;
135+
136+
async function until(predicate: () => boolean): Promise<void> {
137+
const deadline = Date.now() + 5000;
138+
while (!predicate()) {
139+
assert.ok(Date.now() < deadline, 'Timed out waiting for completion or signature help');
140+
await new Promise(resolve => setTimeout(resolve, 10));
141+
}
142+
}
143+
144+
suiteSetup(() => {
145+
previousHints = settings().inspect<boolean>('parameterHints.enabled')?.globalLanguageValue;
146+
});
147+
148+
suiteTeardown(async () => {
149+
await settings().update('parameterHints.enabled', previousHints, vscode.ConfigurationTarget.Global, true);
150+
});
151+
152+
for (const enabled of [true, false]) {
153+
test(`accepting a resolved call snippet ${enabled ? 'requests' : 'does not request'} signature help when hints are ${enabled ? 'enabled' : 'disabled'}`, async () => {
154+
await settings().update('parameterHints.enabled', enabled, vscode.ConfigurationTarget.Global, true);
155+
const toServer = new PassThrough();
156+
const toClient = new PassThrough();
157+
const server = lsp.createProtocolConnection(toServer, toClient);
158+
const signatures: lsp.SignatureHelpParams[] = [];
159+
let resolutions = 0;
160+
server.onRequest(lsp.InitializeRequest.type, (): lsp.InitializeResult => ({ capabilities: {
161+
textDocumentSync: lsp.TextDocumentSyncKind.Full,
162+
completionProvider: { resolveProvider: true },
163+
signatureHelpProvider: { triggerCharacters: ['(', ','] }
164+
} }));
165+
server.onRequest(lsp.CompletionRequest.type, () => [{
166+
label: 'fun1', kind: lsp.CompletionItemKind.Function,
167+
insertTextFormat: lsp.InsertTextFormat.Snippet, insertText: 'fun1($0)'
168+
}]);
169+
server.onRequest(lsp.CompletionResolveRequest.type, item => {
170+
resolutions++;
171+
return { ...item, documentation: 'Adds 1 to its input.' };
172+
});
173+
server.onRequest(lsp.SignatureHelpRequest.type, params => {
174+
signatures.push(params);
175+
return { signatures: [{ label: 'fun1(x)', parameters: [{ label: 'x' }] }], activeSignature: 0, activeParameter: 0 };
176+
});
177+
server.onRequest(lsp.ShutdownRequest.type, () => undefined);
178+
server.listen();
179+
const client = new lsp.LanguageClient('completion-acceptance-test', 'Completion acceptance test',
180+
() => Promise.resolve({ reader: toClient, writer: toServer }),
181+
{ documentSelector: [{ scheme: 'untitled', language: 'r' }], middleware: completionMiddleware });
182+
try {
183+
await client.start();
184+
const document = await vscode.workspace.openTextDocument({ language: 'r', content: 'fun' });
185+
const editor = await vscode.window.showTextDocument(document);
186+
editor.selection = new vscode.Selection(0, 3, 0, 3);
187+
await vscode.commands.executeCommand('editor.action.triggerSuggest');
188+
await until(() => resolutions > 0);
189+
assert.strictEqual(signatures.length, 0, 'Browsing completions must not request signature help');
190+
await vscode.commands.executeCommand('acceptSelectedSuggestion');
191+
await until(() => document.getText() === 'fun1()');
192+
if (enabled) {
193+
await until(() => signatures.length > 0);
194+
assert.deepStrictEqual(signatures[0].position, { line: 0, character: 5 });
195+
assert.strictEqual(signatures[0].textDocument.uri, document.uri.toString());
196+
} else {
197+
await new Promise(resolve => setTimeout(resolve, 100));
198+
assert.strictEqual(signatures.length, 0);
199+
}
200+
assert.strictEqual(editor.selection.active.character, 5);
201+
} finally {
202+
await vscode.commands.executeCommand('closeParameterHints');
203+
await vscode.commands.executeCommand('hideSuggestWidget');
204+
await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor');
205+
await client.dispose();
206+
server.dispose();
207+
toServer.destroy();
208+
toClient.destroy();
209+
}
210+
});
211+
}
212+
});

0 commit comments

Comments
 (0)