diff --git a/client-node-tests/src/converter.test.ts b/client-node-tests/src/converter.test.ts index 51afdb13c..df17afdd4 100644 --- a/client-node-tests/src/converter.test.ts +++ b/client-node-tests/src/converter.test.ts @@ -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'; @@ -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' diff --git a/client/src/common/completion.ts b/client/src/common/completion.ts index cecefe0c4..c9d8edb17 100644 --- a/client/src/common/completion.ts +++ b/client/src/common/completion.ts @@ -92,7 +92,8 @@ export class CompletionItemFeature extends TextDocumentLanguageFeaturevalue; 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); } @@ -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); @@ -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) { @@ -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; @@ -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(); + 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 { diff --git a/protocol/src/common/protocol.ts b/protocol/src/common/protocol.ts index e6a05fa40..1254214ea 100644 --- a/protocol/src/common/protocol.ts +++ b/protocol/src/common/protocol.ts @@ -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; } /** diff --git a/types/src/main.ts b/types/src/main.ts index 631a3a15e..481194131 100644 --- a/types/src/main.ts +++ b/types/src/main.ts @@ -2220,6 +2220,36 @@ export namespace InsertTextMode { export type InsertTextMode = 1 | 2; +/** + * Defines how values from a set of defaults and an individual item will be + * merged. + * + * @since 3.18.0 + */ +export namespace ApplyKind { + /** + * The value from the individual item (if provided and not `null`) will be + * used instead of the default. + */ + export const Replace: 'replace' = 'replace'; + + /** + * The value from the item will be merged with the default. + * + * The specific rules for mergeing values are defined against each field + * that supports merging. + */ + export const Merge: 'merge' = 'merge'; +} + +/** + * Defines how values from a set of defaults and an individual item will be + * merged. + * + * @since 3.18.0 + */ +export type ApplyKind = 'replace' | 'merge'; + /** * Additional details for a completion item label. * @@ -2459,7 +2489,9 @@ export type EditRangeWithInsertReplace = { * be used if a completion item itself doesn't specify the value. * * If a completion list specifies a default value and a completion item - * also specifies a corresponding value the one from the item is used. + * also specifies a corresponding value, the rules for combining these are + * defined by `applyKinds` (if the client supports it), defaulting to + * "replace". * * Servers are only allowed to return default values if the client * signals support for this via the `completionList.itemDefaults` @@ -2504,6 +2536,72 @@ export interface CompletionItemDefaults { data?: LSPAny; } +/** + * Specifies how fields from a completion item should be combined with those + * from `completionList.itemDefaults`. + * + * In unspecified, all fields will be treated as "replace". + * + * If a field's value is "replace", the value from a completion item (if + * provided and not `null`) will always be used instead of the value from + * `completionItem.itemDefaults`. + * + * If a field's value is "merge", the values will be merged using the rules + * defined against each field below. + * + * Servers are only allowed to return `applyKind` if the client + * signals support for this via the `completionList.applyKindSupport` + * capability. + * + * @since 3.18.0 + */ +export interface CompletionItemApplyKinds { + /** + * Specifies whether commitCharacters on a completion will replace or be + * merged with those in `completionList.itemDefaults.commitCharacters`. + * + * If "replace", the commit characters from the completion item will + * always be used unless not provided, in which case those from + * `completionList.itemDefaults.commitCharacters` will be used. An + * empty list can be used if a completion item does not have any commit + * characters and also should not use those from + * `completionList.itemDefaults.commitCharacters`. + * + * If "merge" the commitCharacters for the completion will be the union + * of all values in both `completionList.itemDefaults.commitCharacters` + * and the completion's own `commitCharacters`. + * + * @since 3.18.0 + */ + commitCharacters?: ApplyKind; + + /** + * Specifies whether the `data` field on a completion will replace or + * be merged with data from `completionList.itemDefaults.data`. + * + * If "replace", the data from the completion item will be used if + * provided (and not `null`), otherwise + * `completionList.itemDefaults.data` will be used. An empty object can + * be used if a completion item does not have any data but also should + * not use the value from `completionList.itemDefaults.data`. + * + * If "merge", a shallow merge will be performed between + * `completionList.itemDefaults.data` and the completion's own data + * using the following rules: + * + * - If a completion's `data` field is not provided (or `null`), the + * entire `data` field from `completionList.itemDefaults.data` will be + * used as-is. + * - If a completion's `data` field is provided, each field will + * overwrite the field of the same name in + * `completionList.itemDefaults.data` but no merging of nested fields + * within that value will occur. + * + * @since 3.18.0 + */ + data?: ApplyKind; +} + /** * Represents a collection of {@link CompletionItem completion items} to be presented * in the editor. @@ -2524,7 +2622,9 @@ export interface CompletionList { * be used if a completion item itself doesn't specify the value. * * If a completion list specifies a default value and a completion item - * also specifies a corresponding value the one from the item is used. + * also specifies a corresponding value, the rules for combining these are + * defined by `applyKinds` (if the client supports it), defaulting to + * "replace". * * Servers are only allowed to return default values if the client * signals support for this via the `completionList.itemDefaults` @@ -2534,6 +2634,27 @@ export interface CompletionList { */ itemDefaults?: CompletionItemDefaults; + /** + * Specifies how fields from a completion item should be combined with those + * from `completionList.itemDefaults`. + * + * In unspecified, all fields will be treated as "replace". + * + * If a field's value is "replace", the value from a completion item (if + * provided and not `null`) will always be used instead of the value from + * `completionItem.itemDefaults`. + * + * If a field's value is "merge", the values will be merged using the rules + * defined against each field below. + * + * Servers are only allowed to return `applyKind` if the client + * signals support for this via the `completionList.applyKindSupport` + * capability. + * + * @since 3.18.0 + */ + applyKind?: CompletionItemApplyKinds; + /** * The completion items. */