Skip to content

Commit 8c5b095

Browse files
DanTupdbaeumer
andauthored
Add support for CompletionList "applyKind" to control how defaults and per-item commitCharacters/data are combined (#1558)
* Add support for CompletionList "applyKind" to control how defaults and 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) * Update meta model * Add non-null falsy test * Change ApplyKind to ints * Tweaks + typos --------- Co-authored-by: Dirk Bäumer <dirkb@microsoft.com>
1 parent 1f624bd commit 8c5b095

6 files changed

Lines changed: 408 additions & 13 deletions

File tree

client-node-tests/src/converter.test.ts

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import { strictEqual, deepEqual, ok } from 'assert';
6+
import { strictEqual, deepEqual, ok, deepStrictEqual } from 'assert';
77

88
import * as proto from 'vscode-languageclient';
99
import * as codeConverter from 'vscode-languageclient/$test/common/codeConverter';
@@ -859,6 +859,145 @@ suite('Protocol Converter', () => {
859859
ok(result.items[0].insertText instanceof vscode.SnippetString);
860860
});
861861

862+
test('Completion Result - applyKind:default - commitCharacters', async () => {
863+
const completionResult: proto.CompletionList = {
864+
isIncomplete: false,
865+
itemDefaults: { commitCharacters: ['d'] },
866+
items: [{ label: 'item', commitCharacters: ['i'] }]
867+
};
868+
const result = await p2c.asCompletionResult(completionResult);
869+
deepStrictEqual(result.items[0].commitCharacters, ['i']);
870+
});
871+
872+
test('Completion Result - applyKind:replace - commitCharacters', async () => {
873+
const completionResult: proto.CompletionList = {
874+
isIncomplete: false,
875+
itemDefaults: { commitCharacters: ['1'] },
876+
// Set other fields to "merge" to ensure the correct field was used.
877+
applyKind: { commitCharacters: proto.ApplyKind.Replace, data: proto.ApplyKind.Merge },
878+
items: [{ label: 'item', commitCharacters: ['2'] }]
879+
};
880+
const result = await p2c.asCompletionResult(completionResult);
881+
deepStrictEqual(result.items[0].commitCharacters, ['2']);
882+
});
883+
884+
test('Completion Result - applyKind:replace - commitCharacters - item empty', async () => {
885+
const completionResult: proto.CompletionList = {
886+
isIncomplete: false,
887+
itemDefaults: { commitCharacters: ['1'] },
888+
// Set other fields to "merge" to ensure the correct field was used.
889+
applyKind: { commitCharacters: proto.ApplyKind.Replace, data: proto.ApplyKind.Merge },
890+
items: [{ label: 'item', commitCharacters: [] }]
891+
};
892+
const result = await p2c.asCompletionResult(completionResult);
893+
deepStrictEqual(result.items[0].commitCharacters, []);
894+
});
895+
896+
test('Completion Result - applyKind:merge - commitCharacters - both supplied with overlaps', async () => {
897+
const completionResult: proto.CompletionList = {
898+
isIncomplete: false,
899+
itemDefaults: { commitCharacters: ['d', 'b'] },
900+
applyKind: { commitCharacters: proto.ApplyKind.Merge },
901+
items: [{ label: 'item', commitCharacters: ['b', 'i'] }]
902+
};
903+
const result = await p2c.asCompletionResult(completionResult);
904+
deepStrictEqual(result.items[0].commitCharacters, ['d', 'b', 'i']);
905+
});
906+
907+
test('Completion Result - applyKind:merge - commitCharacters - only default supplied', async () => {
908+
const completionResult: proto.CompletionList = {
909+
isIncomplete: false,
910+
itemDefaults: { commitCharacters: ['d'] },
911+
applyKind: { commitCharacters: proto.ApplyKind.Merge },
912+
items: [{ label: 'item' }]
913+
};
914+
const result = await p2c.asCompletionResult(completionResult);
915+
deepStrictEqual(result.items[0].commitCharacters, ['d']);
916+
});
917+
918+
test('Completion Result - applyKind:merge - commitCharacters - only item supplied', async () => {
919+
const completionResult: proto.CompletionList = {
920+
isIncomplete: false,
921+
itemDefaults: { },
922+
applyKind: { commitCharacters: proto.ApplyKind.Merge },
923+
items: [{ label: 'item', commitCharacters: ['i'] }]
924+
};
925+
const result = await p2c.asCompletionResult(completionResult);
926+
deepStrictEqual(result.items[0].commitCharacters, ['i']);
927+
});
928+
929+
test('Completion Result - applyKind:default - data', async () => {
930+
const completionResult: proto.CompletionList = {
931+
isIncomplete: false,
932+
itemDefaults: { data: { 'd': 'd' } },
933+
items: [{ label: 'item', data: { 'i': 'i' } }]
934+
};
935+
const result = await p2c.asCompletionResult(completionResult);
936+
const protoResult = await c2p.asCompletionItem(result.items[0]);
937+
deepStrictEqual(protoResult.data, {'i': 'i'});
938+
});
939+
940+
test('Completion Result - applyKind:replace - data', async () => {
941+
const completionResult: proto.CompletionList = {
942+
isIncomplete: false,
943+
itemDefaults: { data: { 'd': 'd' } },
944+
// Set other fields to "merge" to ensure the correct field was used.
945+
applyKind: { data: proto.ApplyKind.Replace, commitCharacters: proto.ApplyKind.Merge },
946+
items: [{ label: 'item', data: { 'i': 'i' } }]
947+
};
948+
const result = await p2c.asCompletionResult(completionResult);
949+
const protoResult = await c2p.asCompletionItem(result.items[0]);
950+
deepStrictEqual(protoResult.data, {'i': 'i'});
951+
});
952+
953+
test('Completion Result - applyKind:merge - data - both supplied', async () => {
954+
const completionResult: proto.CompletionList = {
955+
isIncomplete: false,
956+
itemDefaults: { data: { 'd': 'd' } },
957+
applyKind: { data: proto.ApplyKind.Merge },
958+
items: [{ label: 'item', data: { 'i': 'i' } }]
959+
};
960+
const result = await p2c.asCompletionResult(completionResult);
961+
const protoResult = await c2p.asCompletionItem(result.items[0]);
962+
deepStrictEqual(protoResult.data, {'d': 'd', 'i': 'i'});
963+
});
964+
965+
test('Completion Result - applyKind:merge - data - default supplied, item null', async () => {
966+
const completionResult: proto.CompletionList = {
967+
isIncomplete: false,
968+
itemDefaults: { data: { 'd': 'd' } },
969+
applyKind: { data: proto.ApplyKind.Merge },
970+
items: [{ label: 'item', data: null }] // null treated like undefined
971+
};
972+
const result = await p2c.asCompletionResult(completionResult);
973+
const protoResult = await c2p.asCompletionItem(result.items[0]);
974+
deepStrictEqual(protoResult.data, {'d': 'd'}); // gets default
975+
});
976+
977+
test('Completion Result - applyKind:merge - data - both supplied, item has null fields', async () => {
978+
const completionResult: proto.CompletionList = {
979+
isIncomplete: false,
980+
itemDefaults: { data: { 'd': 'd' } },
981+
applyKind: { data: proto.ApplyKind.Merge },
982+
items: [{ label: 'item', data: { 'd': null, 'i': 'i'} }] // null treated like undefined
983+
};
984+
const result = await p2c.asCompletionResult(completionResult);
985+
const protoResult = await c2p.asCompletionItem(result.items[0]);
986+
deepStrictEqual(protoResult.data, {'d': 'd', 'i': 'i'}); // gets default for 'd'
987+
});
988+
989+
test('Completion Result - applyKind:merge - data - both supplied, item has non-null falsy fields', async () => {
990+
const completionResult: proto.CompletionList = {
991+
isIncomplete: false,
992+
itemDefaults: { data: { 'd1': 'd1', 'd2': 'd2' } },
993+
applyKind: { data: proto.ApplyKind.Merge },
994+
items: [{ label: 'item', data: { 'd1': 0, 'd2': ''} }] // Both falsy, but should be used.
995+
};
996+
const result = await p2c.asCompletionResult(completionResult);
997+
const protoResult = await c2p.asCompletionItem(result.items[0]);
998+
deepStrictEqual(protoResult.data, {'d1': 0, 'd2': ''});
999+
});
1000+
8621001
test('Parameter Information', async () => {
8631002
const parameterInfo: proto.ParameterInformation = {
8641003
label: 'label'

client/src/common/completion.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ export class CompletionItemFeature extends TextDocumentLanguageFeature<Completio
9292
completion.completionList = {
9393
itemDefaults: [
9494
'commitCharacters', 'editRange', 'insertTextFormat', 'insertTextMode', 'data'
95-
]
95+
],
96+
applyKindSupport: true,
9697
};
9798
}
9899

@@ -153,4 +154,4 @@ export class CompletionItemFeature extends TextDocumentLanguageFeature<Completio
153154
};
154155
return [Languages.registerCompletionItemProvider(this._client.protocol2CodeConverter.asDocumentSelector(selector), provider, ...triggerCharacters), provider];
155156
}
156-
}
157+
}

client/src/common/protocolConverter.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ export function createConverter(
515515
const list = <ls.CompletionList>value;
516516
const { defaultRange, commitCharacters } = getCompletionItemDefaults(list, allCommitCharacters);
517517
const converted = await async.map(list.items, (item) => {
518-
return asCompletionItem(item, commitCharacters, defaultRange, list.itemDefaults?.insertTextMode, list.itemDefaults?.insertTextFormat, list.itemDefaults?.data);
518+
return asCompletionItem(item, commitCharacters, list.applyKind?.commitCharacters, defaultRange, list.itemDefaults?.insertTextMode, list.itemDefaults?.insertTextFormat, list.itemDefaults?.data, list.applyKind?.data);
519519
}, token);
520520
return new code.CompletionList(converted, list.isIncomplete);
521521
}
@@ -561,7 +561,16 @@ export function createConverter(
561561
return result;
562562
}
563563

564-
function asCompletionItem(item: ls.CompletionItem, defaultCommitCharacters?: string[], defaultRange?: code.Range | InsertReplaceRange, defaultInsertTextMode?: ls.InsertTextMode, defaultInsertTextFormat?: ls.InsertTextFormat, defaultData?: ls.LSPAny): ProtocolCompletionItem {
564+
function asCompletionItem(
565+
item: ls.CompletionItem,
566+
defaultCommitCharacters?: string[],
567+
commitCharactersApplyKind?: ls.ApplyKind,
568+
defaultRange?: code.Range | InsertReplaceRange,
569+
defaultInsertTextMode?: ls.InsertTextMode,
570+
defaultInsertTextFormat?: ls.InsertTextFormat,
571+
defaultData?: ls.LSPAny,
572+
dataApplyKind?: ls.ApplyKind,
573+
): ProtocolCompletionItem {
565574
const tags: code.CompletionItemTag[] = asCompletionItemTags(item.tags);
566575
const label = asCompletionItemLabel(item);
567576
const result = new ProtocolCompletionItem(label);
@@ -587,9 +596,7 @@ export function createConverter(
587596
}
588597
if (item.sortText) { result.sortText = item.sortText; }
589598
if (item.additionalTextEdits) { result.additionalTextEdits = asTextEditsSync(item.additionalTextEdits); }
590-
const commitCharacters = item.commitCharacters !== undefined
591-
? Is.stringArray(item.commitCharacters) ? item.commitCharacters : undefined
592-
: defaultCommitCharacters;
599+
const commitCharacters = applyCommitCharacters(item, defaultCommitCharacters, commitCharactersApplyKind);
593600
if (commitCharacters) { result.commitCharacters = commitCharacters.slice(); }
594601
if (item.command) { result.command = asCommand(item.command); }
595602
if (item.deprecated === true || item.deprecated === false) {
@@ -599,7 +606,7 @@ export function createConverter(
599606
}
600607
}
601608
if (item.preselect === true || item.preselect === false) { result.preselect = item.preselect; }
602-
const data = item.data ?? defaultData;
609+
const data = applyData(item, defaultData, dataApplyKind);
603610
if (data !== undefined) { result.data = data; }
604611
if (tags.length > 0) {
605612
result.tags = tags;
@@ -614,6 +621,50 @@ export function createConverter(
614621
return result;
615622
}
616623

624+
function applyCommitCharacters(item: ls.CompletionItem, defaultCommitCharacters: string[] | undefined, applyKind: ls.ApplyKind | undefined): string[] | undefined {
625+
if (applyKind === ls.ApplyKind.Merge) {
626+
if (!defaultCommitCharacters && !item.commitCharacters) {
627+
return undefined;
628+
}
629+
const set = new Set<string>();
630+
if (defaultCommitCharacters) {
631+
for (const char of defaultCommitCharacters) {
632+
set.add(char);
633+
}
634+
}
635+
if (Is.stringArray(item.commitCharacters)) {
636+
for (const char of item.commitCharacters) {
637+
set.add(char);
638+
}
639+
}
640+
return Array.from(set);
641+
}
642+
643+
return item.commitCharacters !== undefined
644+
? Is.stringArray(item.commitCharacters) ? item.commitCharacters : undefined
645+
: defaultCommitCharacters;
646+
}
647+
648+
function applyData(item: ls.CompletionItem, defaultData: any, applyKind: ls.ApplyKind | undefined): string[] | undefined {
649+
if (applyKind === ls.ApplyKind.Merge) {
650+
const data = {
651+
...defaultData,
652+
};
653+
654+
if (item.data) {
655+
Object.entries(item.data).forEach(([key, value]) => {
656+
if (value !== undefined && value !== null) {
657+
data[key] = value;
658+
}
659+
});
660+
}
661+
662+
return data;
663+
}
664+
665+
return item.data ?? defaultData;
666+
}
667+
617668
function asCompletionItemLabel(item: ls.CompletionItem): code.CompletionItemLabel | string {
618669
if (ls.CompletionItemLabelDetails.is(item.labelDetails)) {
619670
return {

protocol/metaModel.json

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5168,9 +5168,19 @@
51685168
"name": "CompletionItemDefaults"
51695169
},
51705170
"optional": true,
5171-
"documentation": "In many cases the items of an actual completion result share the same\nvalue for properties like `commitCharacters` or the range of a text\nedit. A completion list can therefore define item defaults which will\nbe used if a completion item itself doesn't specify the value.\n\nIf a completion list specifies a default value and a completion item\nalso specifies a corresponding value the one from the item is used.\n\nServers are only allowed to return default values if the client\nsignals support for this via the `completionList.itemDefaults`\ncapability.\n\n@since 3.17.0",
5171+
"documentation": "In many cases the items of an actual completion result share the same\nvalue for properties like `commitCharacters` or the range of a text\nedit. A completion list can therefore define item defaults which will\nbe used if a completion item itself doesn't specify the value.\n\nIf a completion list specifies a default value and a completion item\nalso specifies a corresponding value, the rules for combining these are\ndefined by `applyKinds` (if the client supports it), defaulting to\n\"replace\".\n\nServers are only allowed to return default values if the client\nsignals support for this via the `completionList.itemDefaults`\ncapability.\n\n@since 3.17.0",
51725172
"since": "3.17.0"
51735173
},
5174+
{
5175+
"name": "applyKind",
5176+
"type": {
5177+
"kind": "reference",
5178+
"name": "CompletionItemApplyKinds"
5179+
},
5180+
"optional": true,
5181+
"documentation": "Specifies how fields from a completion item should be combined with those\nfrom `completionList.itemDefaults`.\n\nIf unspecified, all fields will be treated as \"replace\".\n\nIf a field's value is \"replace\", the value from a completion item (if\nprovided and not `null`) will always be used instead of the value from\n`completionItem.itemDefaults`.\n\nIf a field's value is \"merge\", the values will be merged using the rules\ndefined against each field below.\n\nServers are only allowed to return `applyKind` if the client\nsignals support for this via the `completionList.applyKindSupport`\ncapability.\n\n@since 3.18.0",
5182+
"since": "3.18.0"
5183+
},
51745184
{
51755185
"name": "items",
51765186
"type": {
@@ -9195,9 +9205,36 @@
91959205
"since": "3.17.0"
91969206
}
91979207
],
9198-
"documentation": "In many cases the items of an actual completion result share the same\nvalue for properties like `commitCharacters` or the range of a text\nedit. A completion list can therefore define item defaults which will\nbe used if a completion item itself doesn't specify the value.\n\nIf a completion list specifies a default value and a completion item\nalso specifies a corresponding value the one from the item is used.\n\nServers are only allowed to return default values if the client\nsignals support for this via the `completionList.itemDefaults`\ncapability.\n\n@since 3.17.0",
9208+
"documentation": "In many cases the items of an actual completion result share the same\nvalue for properties like `commitCharacters` or the range of a text\nedit. A completion list can therefore define item defaults which will\nbe used if a completion item itself doesn't specify the value.\n\nIf a completion list specifies a default value and a completion item\nalso specifies a corresponding value, the rules for combining these are\ndefined by `applyKinds` (if the client supports it), defaulting to\n\"replace\".\n\nServers are only allowed to return default values if the client\nsignals support for this via the `completionList.itemDefaults`\ncapability.\n\n@since 3.17.0",
91999209
"since": "3.17.0"
92009210
},
9211+
{
9212+
"name": "CompletionItemApplyKinds",
9213+
"properties": [
9214+
{
9215+
"name": "commitCharacters",
9216+
"type": {
9217+
"kind": "reference",
9218+
"name": "ApplyKind"
9219+
},
9220+
"optional": true,
9221+
"documentation": "Specifies whether commitCharacters on a completion will replace or be\nmerged with those in `completionList.itemDefaults.commitCharacters`.\n\nIf \"replace\", the commit characters from the completion item will\nalways be used unless not provided, in which case those from\n`completionList.itemDefaults.commitCharacters` will be used. An\nempty list can be used if a completion item does not have any commit\ncharacters and also should not use those from\n`completionList.itemDefaults.commitCharacters`.\n\nIf \"merge\" the commitCharacters for the completion will be the union\nof all values in both `completionList.itemDefaults.commitCharacters`\nand the completion's own `commitCharacters`.\n\n@since 3.18.0",
9222+
"since": "3.18.0"
9223+
},
9224+
{
9225+
"name": "data",
9226+
"type": {
9227+
"kind": "reference",
9228+
"name": "ApplyKind"
9229+
},
9230+
"optional": true,
9231+
"documentation": "Specifies whether the `data` field on a completion will replace or\nbe merged with data from `completionList.itemDefaults.data`.\n\nIf \"replace\", the data from the completion item will be used if\nprovided (and not `null`), otherwise\n`completionList.itemDefaults.data` will be used. An empty object can\nbe used if a completion item does not have any data but also should\nnot use the value from `completionList.itemDefaults.data`.\n\nIf \"merge\", a shallow merge will be performed between\n`completionList.itemDefaults.data` and the completion's own data\nusing the following rules:\n\n- If a completion's `data` field is not provided (or `null`), the\n entire `data` field from `completionList.itemDefaults.data` will be\n used as-is.\n- If a completion's `data` field is provided, each field will\n overwrite the field of the same name in\n `completionList.itemDefaults.data` but no merging of nested fields\n within that value will occur.\n\n@since 3.18.0",
9232+
"since": "3.18.0"
9233+
}
9234+
],
9235+
"documentation": "Specifies how fields from a completion item should be combined with those\nfrom `completionList.itemDefaults`.\n\nIf unspecified, all fields will be treated as \"replace\".\n\nIf a field's value is \"replace\", the value from a completion item (if\nprovided and not `null`) will always be used instead of the value from\n`completionItem.itemDefaults`.\n\nIf a field's value is \"merge\", the values will be merged using the rules\ndefined against each field below.\n\nServers are only allowed to return `applyKind` if the client\nsignals support for this via the `completionList.applyKindSupport`\ncapability.\n\n@since 3.18.0",
9236+
"since": "3.18.0"
9237+
},
92019238
{
92029239
"name": "CompletionOptions",
92039240
"properties": [
@@ -13560,6 +13597,16 @@
1356013597
"optional": true,
1356113598
"documentation": "The client supports the following itemDefaults on\na completion list.\n\nThe value lists the supported property names of the\n`CompletionList.itemDefaults` object. If omitted\nno properties are supported.\n\n@since 3.17.0",
1356213599
"since": "3.17.0"
13600+
},
13601+
{
13602+
"name": "applyKindSupport",
13603+
"type": {
13604+
"kind": "base",
13605+
"name": "boolean"
13606+
},
13607+
"optional": true,
13608+
"documentation": "Specifies whether the client supports `CompletionList.applyKind` to\nindicate how supported values from `completionList.itemDefaults`\nand `completion` will be combined.\n\nIf a client supports `applyKind` it must support it for all fields\nthat it supports that are listed in `CompletionList.applyKind`. This\nmeans when clients add support for new/future fields in completion\nitems the MUST also support merge for them if those fields are\ndefined in `CompletionList.applyKind`.\n\n@since 3.18.0",
13609+
"since": "3.18.0"
1356313610
}
1356413611
],
1356513612
"documentation": "The client supports the following `CompletionList` specific\ncapabilities.\n\n@since 3.17.0",
@@ -15314,6 +15361,27 @@
1531415361
],
1531515362
"documentation": "How a completion was triggered"
1531615363
},
15364+
{
15365+
"name": "ApplyKind",
15366+
"type": {
15367+
"kind": "base",
15368+
"name": "string"
15369+
},
15370+
"values": [
15371+
{
15372+
"name": "Replace",
15373+
"value": "replace",
15374+
"documentation": "The value from the individual item (if provided and not `null`) will be\nused instead of the default."
15375+
},
15376+
{
15377+
"name": "Merge",
15378+
"value": "merge",
15379+
"documentation": "The value from the item will be merged with the default.\n\nThe specific rules for mergeing values are defined against each field\nthat supports merging."
15380+
}
15381+
],
15382+
"documentation": "Defines how values from a set of defaults and an individual item will be\nmerged.\n\n@since 3.18.0",
15383+
"since": "3.18.0"
15384+
},
1531715385
{
1531815386
"name": "SignatureHelpTriggerKind",
1531915387
"type": {

0 commit comments

Comments
 (0)