Skip to content

Commit

Permalink
Add support for CompletionList "applyKind" to control how defaults an…
Browse files Browse the repository at this point in the history
…d per-item commitCharacters/data are combined

Implements the changes in the LSP spec PR at microsoft/language-server-protocol#2018.

(Also see microsoft/language-server-protocol#1802)
  • Loading branch information
DanTup committed Sep 18, 2024
1 parent f58f4df commit 4b39016
Show file tree
Hide file tree
Showing 5 changed files with 326 additions and 11 deletions.
129 changes: 128 additions & 1 deletion client-node-tests/src/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { strictEqual, deepEqual, ok } from 'assert';
import { strictEqual, deepEqual, ok, deepStrictEqual } from 'assert';

import * as proto from 'vscode-languageclient';
import * as codeConverter from 'vscode-languageclient/$test/common/codeConverter';
Expand Down Expand Up @@ -859,6 +859,133 @@ suite('Protocol Converter', () => {
ok(result.items[0].insertText instanceof vscode.SnippetString);
});

test('Completion Result - applyKind:default - commitCharacters', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { commitCharacters: ['d'] },
items: [{ label: 'item', commitCharacters: ['i'] }]
};
const result = await p2c.asCompletionResult(completionResult);
deepStrictEqual(result.items[0].commitCharacters, ['i']);
});

test('Completion Result - applyKind:replace - commitCharacters', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { commitCharacters: ['1'] },
// Set other fields to "merge" to ensure the correct field was used.
applyKind: { commitCharacters: 'replace', data: 'merge' },
items: [{ label: 'item', commitCharacters: ['2'] }]
};
const result = await p2c.asCompletionResult(completionResult);
deepStrictEqual(result.items[0].commitCharacters, ['2']);
});

test('Completion Result - applyKind:replace - commitCharacters - item empty', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { commitCharacters: ['1'] },
// Set other fields to "merge" to ensure the correct field was used.
applyKind: { commitCharacters: 'replace', data: 'merge' },
items: [{ label: 'item', commitCharacters: [] }]
};
const result = await p2c.asCompletionResult(completionResult);
deepStrictEqual(result.items[0].commitCharacters, []);
});

test('Completion Result - applyKind:merge - commitCharacters - both supplied with overlaps', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { commitCharacters: ['d', 'b'] },
applyKind: { commitCharacters: 'merge' },
items: [{ label: 'item', commitCharacters: ['b', 'i'] }]
};
const result = await p2c.asCompletionResult(completionResult);
deepStrictEqual(result.items[0].commitCharacters, ['d', 'b', 'i']);
});

test('Completion Result - applyKind:merge - commitCharacters - only default supplied', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { commitCharacters: ['d'] },
applyKind: { commitCharacters: 'merge' },
items: [{ label: 'item' }]
};
const result = await p2c.asCompletionResult(completionResult);
deepStrictEqual(result.items[0].commitCharacters, ['d']);
});

test('Completion Result - applyKind:merge - commitCharacters - only item supplied', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { },
applyKind: { commitCharacters: 'merge' },
items: [{ label: 'item', commitCharacters: ['i'] }]
};
const result = await p2c.asCompletionResult(completionResult);
deepStrictEqual(result.items[0].commitCharacters, ['i']);
});

test('Completion Result - applyKind:default - data', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { data: { 'd': 'd' } },
items: [{ label: 'item', data: { 'i': 'i' } }]
};
const result = await p2c.asCompletionResult(completionResult);
const protoResult = await c2p.asCompletionItem(result.items[0]);
deepStrictEqual(protoResult.data, {'i': 'i'});
});

test('Completion Result - applyKind:replace - data', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { data: { 'd': 'd' } },
// Set other fields to "merge" to ensure the correct field was used.
applyKind: { data: 'replace', commitCharacters: 'merge' },
items: [{ label: 'item', data: { 'i': 'i' } }]
};
const result = await p2c.asCompletionResult(completionResult);
const protoResult = await c2p.asCompletionItem(result.items[0]);
deepStrictEqual(protoResult.data, {'i': 'i'});
});

test('Completion Result - applyKind:merge - data - both supplied', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { data: { 'd': 'd' } },
applyKind: { data: 'merge' },
items: [{ label: 'item', data: { 'i': 'i' } }]
};
const result = await p2c.asCompletionResult(completionResult);
const protoResult = await c2p.asCompletionItem(result.items[0]);
deepStrictEqual(protoResult.data, {'d': 'd', 'i': 'i'});
});

test('Completion Result - applyKind:merge - data - default supplied, item null', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { data: { 'd': 'd' } },
applyKind: { data: 'merge' },
items: [{ label: 'item', data: null }] // null treated like undefined
};
const result = await p2c.asCompletionResult(completionResult);
const protoResult = await c2p.asCompletionItem(result.items[0]);
deepStrictEqual(protoResult.data, {'d': 'd'}); // gets default
});

test('Completion Result - applyKind:merge - data - both supplied, item has null fields', async () => {
const completionResult: proto.CompletionList = {
isIncomplete: false,
itemDefaults: { data: { 'd': 'd' } },
applyKind: { data: 'merge' },
items: [{ label: 'item', data: { 'd': null, 'i': 'i'} }] // null treated like undefined
};
const result = await p2c.asCompletionResult(completionResult);
const protoResult = await c2p.asCompletionItem(result.items[0]);
deepStrictEqual(protoResult.data, {'d': 'd', 'i': 'i'}); // gets default for 'd'
});

test('Parameter Information', async () => {
const parameterInfo: proto.ParameterInformation = {
label: 'label'
Expand Down
5 changes: 3 additions & 2 deletions client/src/common/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ export class CompletionItemFeature extends TextDocumentLanguageFeature<Completio
completion.completionList = {
itemDefaults: [
'commitCharacters', 'editRange', 'insertTextFormat', 'insertTextMode', 'data'
]
],
applyKindSupport: true,
};
}

Expand Down Expand Up @@ -153,4 +154,4 @@ export class CompletionItemFeature extends TextDocumentLanguageFeature<Completio
};
return [Languages.registerCompletionItemProvider(this._client.protocol2CodeConverter.asDocumentSelector(selector), provider, ...triggerCharacters), provider];
}
}
}
63 changes: 57 additions & 6 deletions client/src/common/protocolConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ export function createConverter(
const list = <ls.CompletionList>value;
const { defaultRange, commitCharacters } = getCompletionItemDefaults(list, allCommitCharacters);
const converted = await async.map(list.items, (item) => {
return asCompletionItem(item, commitCharacters, defaultRange, list.itemDefaults?.insertTextMode, list.itemDefaults?.insertTextFormat, list.itemDefaults?.data);
return asCompletionItem(item, commitCharacters, list.applyKind?.commitCharacters, defaultRange, list.itemDefaults?.insertTextMode, list.itemDefaults?.insertTextFormat, list.itemDefaults?.data, list.applyKind?.data);
}, token);
return new code.CompletionList(converted, list.isIncomplete);
}
Expand Down Expand Up @@ -560,7 +560,16 @@ export function createConverter(
return result;
}

function asCompletionItem(item: ls.CompletionItem, defaultCommitCharacters?: string[], defaultRange?: code.Range | InsertReplaceRange, defaultInsertTextMode?: ls.InsertTextMode, defaultInsertTextFormat?: ls.InsertTextFormat, defaultData?: ls.LSPAny): ProtocolCompletionItem {
function asCompletionItem(
item: ls.CompletionItem,
defaultCommitCharacters?: string[],
commitCharactersApplyKind?: ls.ApplyKind,
defaultRange?: code.Range | InsertReplaceRange,
defaultInsertTextMode?: ls.InsertTextMode,
defaultInsertTextFormat?: ls.InsertTextFormat,
defaultData?: ls.LSPAny,
dataApplyKind?: ls.ApplyKind,
): ProtocolCompletionItem {
const tags: code.CompletionItemTag[] = asCompletionItemTags(item.tags);
const label = asCompletionItemLabel(item);
const result = new ProtocolCompletionItem(label);
Expand All @@ -586,9 +595,7 @@ export function createConverter(
}
if (item.sortText) { result.sortText = item.sortText; }
if (item.additionalTextEdits) { result.additionalTextEdits = asTextEditsSync(item.additionalTextEdits); }
const commitCharacters = item.commitCharacters !== undefined
? Is.stringArray(item.commitCharacters) ? item.commitCharacters : undefined
: defaultCommitCharacters;
const commitCharacters = applyCommitCharacters(item, defaultCommitCharacters, commitCharactersApplyKind);
if (commitCharacters) { result.commitCharacters = commitCharacters.slice(); }
if (item.command) { result.command = asCommand(item.command); }
if (item.deprecated === true || item.deprecated === false) {
Expand All @@ -598,7 +605,7 @@ export function createConverter(
}
}
if (item.preselect === true || item.preselect === false) { result.preselect = item.preselect; }
const data = item.data ?? defaultData;
const data = applyData(item, defaultData, dataApplyKind);
if (data !== undefined) { result.data = data; }
if (tags.length > 0) {
result.tags = tags;
Expand All @@ -613,6 +620,50 @@ export function createConverter(
return result;
}

function applyCommitCharacters(item: ls.CompletionItem, defaultCommitCharacters: string[] | undefined, applyKind: ls.ApplyKind | undefined): string[] | undefined {
if (applyKind === ls.ApplyKind.Merge) {
if (!defaultCommitCharacters && !item.commitCharacters) {
return undefined;
}
const set = new Set<string>();
if (defaultCommitCharacters) {
for (const char of defaultCommitCharacters) {
set.add(char);
}
}
if (Is.stringArray(item.commitCharacters)) {
for (const char of item.commitCharacters) {
set.add(char);
}
}
return Array.from(set);
}

return item.commitCharacters !== undefined
? Is.stringArray(item.commitCharacters) ? item.commitCharacters : undefined
: defaultCommitCharacters;
}

function applyData(item: ls.CompletionItem, defaultData: any, applyKind: ls.ApplyKind | undefined): string[] | undefined {
if (applyKind === ls.ApplyKind.Merge) {
const data = {
...defaultData,
};

if (item.data) {
Object.entries(item.data).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
data[key] = value;
}
});
}

return data;
}

return item.data ?? defaultData;
}

function asCompletionItemLabel(item: ls.CompletionItem): code.CompletionItemLabel | string {
if (ls.CompletionItemLabelDetails.is(item.labelDetails)) {
return {
Expand Down
15 changes: 15 additions & 0 deletions protocol/src/common/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2450,6 +2450,21 @@ export interface CompletionListCapabilities {
* @since 3.17.0
*/
itemDefaults?: string[];

/**
* Specifies whether the client supports `CompletionList.applyKind` to
* indicate how supported values from `completionList.itemDefaults`
* and `completion` will be combined.
*
* If a client supports `applyKind` it must support it for all fields
* that it supports that are listed in `CompletionList.applyKind`. This
* means when clients add support for new/future fields in completion
* items the MUST also support merge for them if those fields are
* defined in `CompletionList.applyKind`.
*
* @since 3.18.0
*/
applyKindSupport?: boolean;
}

/**
Expand Down
Loading

0 comments on commit 4b39016

Please sign in to comment.