From 2a31c979782b314e78036d58be3689c64ac17103 Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Fri, 18 Sep 2026 05:19:56 +0530 Subject: [PATCH 1/2] fix: honor completion snippet support capability Assisted-by: Codex/GPT-6 Signed-off-by: Shubham Padkonde --- README.md | 4 + package-lock.json | 7 ++ package.json | 1 + src/languageservice/jsonLanguageTypes.ts | 3 + .../services/yamlCompletion.ts | 25 +++++++ test/completionCapabilities.test.ts | 74 +++++++++++++++++++ 6 files changed, 114 insertions(+) create mode 100644 test/completionCapabilities.test.ts diff --git a/README.md b/README.md index a0dc8c3be..07df0e183 100755 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ Schema validation supports JSON Schema `draft-04`, `draft-07`, `2019-09`, and `2 Completion and hover content are schema-driven. See [Associating schemas](#associating-schemas) for configuration details. +Snippet completions require the client capability `textDocument.completion.completionItem.snippetSupport`. +When it is absent or `false`, completions insert plain text using placeholder defaults and the first choice, +without tab stops. Clients that support snippets retain editable placeholders. + ## Language server settings The server supports the following settings supplied by LSP clients: diff --git a/package-lock.json b/package-lock.json index cb47ae34b..405bc6099 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "~3.17.5", + "vscode-snippet-parser": "0.0.5", "vscode-uri": "^3.0.2", "yaml": "2.8.3" }, @@ -5354,6 +5355,12 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", "license": "MIT" }, + "node_modules/vscode-snippet-parser": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/vscode-snippet-parser/-/vscode-snippet-parser-0.0.5.tgz", + "integrity": "sha512-iJ5e7g5sCQJ5LRt5nGWtw10oHj44a+6flLAYbGlA2Jbu32UQtjJqGnaT/1TzMV8Tlig/fsrHf7NoMlAT8N+FmA==", + "license": "MIT" + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", diff --git a/package.json b/package.json index 0524e4620..c9fa90fae 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "~3.17.5", + "vscode-snippet-parser": "0.0.5", "vscode-uri": "^3.0.2", "yaml": "2.8.3" }, diff --git a/src/languageservice/jsonLanguageTypes.ts b/src/languageservice/jsonLanguageTypes.ts index 0532e21e8..a935b3170 100644 --- a/src/languageservice/jsonLanguageTypes.ts +++ b/src/languageservice/jsonLanguageTypes.ts @@ -329,6 +329,8 @@ export interface ClientCapabilities { * capabilities. */ completionItem?: { + /** Whether the client supports snippet completion text. */ + snippetSupport?: boolean; /** * Client supports the follow content formats for the documentation * property. The order describes the preferred format of the client. @@ -362,6 +364,7 @@ export const ClientCapabilities: { LATEST: ClientCapabilities } = { textDocument: { completion: { completionItem: { + snippetSupport: true, documentationFormat: [MarkupKind.Markdown, MarkupKind.PlainText], commitCharactersSupport: true, labelDetailsSupport: true, diff --git a/src/languageservice/services/yamlCompletion.ts b/src/languageservice/services/yamlCompletion.ts index b258cbe2d..cc769f8d0 100644 --- a/src/languageservice/services/yamlCompletion.ts +++ b/src/languageservice/services/yamlCompletion.ts @@ -5,6 +5,7 @@ import type { TextDocument } from 'vscode-languageserver-textdocument'; import type { ClientCapabilities } from 'vscode-languageserver'; +import { SnippetParser } from 'vscode-snippet-parser'; import type { MarkupContent } from 'vscode-languageserver-types'; import { CompletionItem as CompletionItemBase, @@ -101,6 +102,30 @@ export class YamlCompletion { } async doComplete(document: TextDocument, position: Position, isKubernetes = false, doComplete = true): Promise { + const result = await this.doCompleteWithSnippets(document, position, isKubernetes, doComplete); + if (!this.clientCapabilities.textDocument?.completion?.completionItem?.snippetSupport) { + for (const item of result.items) { + if (item.insertTextFormat !== InsertTextFormat.Snippet) { + continue; + } + if (item.insertText !== undefined) { + item.insertText = new SnippetParser().parse(item.insertText).toString(); + } + if (item.textEdit) { + item.textEdit.newText = new SnippetParser().parse(item.textEdit.newText).toString(); + } + item.insertTextFormat = InsertTextFormat.PlainText; + } + } + return result; + } + + private async doCompleteWithSnippets( + document: TextDocument, + position: Position, + isKubernetes: boolean, + doComplete: boolean + ): Promise { const result = CompletionList.create([], false); if (!this.completionEnabled) { return result; diff --git a/test/completionCapabilities.test.ts b/test/completionCapabilities.test.ts new file mode 100644 index 000000000..0cfbbcf13 --- /dev/null +++ b/test/completionCapabilities.test.ts @@ -0,0 +1,74 @@ +import assert from 'assert'; +import type { ClientCapabilities } from 'vscode-languageserver'; +import type { CompletionList } from 'vscode-languageserver-types'; +import { InsertTextFormat, Position } from 'vscode-languageserver-types'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { getLanguageService } from '../src'; +import type { JSONSchema } from '../src/languageservice/jsonSchema'; +import { workspaceContext } from '../src/languageservice/services/schemaRequestHandler'; + +describe('Completion snippet capabilities', () => { + async function complete(capabilities: ClientCapabilities, schema: JSONSchema): Promise { + const service = getLanguageService({ + schemaRequestService: async () => JSON.stringify(schema), + workspaceContext, + clientCapabilities: capabilities, + }); + service.configure({ completion: true, schemas: [{ uri: 'file:///schema.json', fileMatch: ['*.yaml'] }] }); + const document = TextDocument.create('file:///completion.yaml', 'yaml', 1, ''); + return service.doComplete(document, Position.create(0, 0), false); + } + + const schema: JSONSchema = { type: 'object', properties: { greeting: { type: 'string' } } }; + + for (const [name, capabilities] of [ + ['omitted', undefined], + ['empty', {}], + ['false', { textDocument: { completion: { completionItem: { snippetSupport: false } } } }], + ] as [string, ClientCapabilities][]) { + it(`returns plain text when snippet support is ${name}`, async () => { + const result = await complete(capabilities, schema); + const item = result.items.find((item) => item.label === 'greeting'); + assert.ok(item); + assert.equal(item.insertTextFormat, InsertTextFormat.PlainText); + assert.equal(item.insertText, 'greeting: '); + assert.equal(item.textEdit.newText, 'greeting: '); + }); + } + + it('preserves snippets for clients that support them', async () => { + const result = await complete({ textDocument: { completion: { completionItem: { snippetSupport: true } } } }, schema); + const item = result.items.find((item) => item.label === 'greeting'); + assert.ok(item); + assert.equal(item.insertTextFormat, InsertTextFormat.Snippet); + assert.equal(item.insertText, 'greeting: '); + assert.equal(item.textEdit.newText, 'greeting: '); + }); + + for (const [bodyText, expected] of [ + ['name: ${1:world}\nagain: $1\nend: $0', 'name: world\nagain: world\nend: '], + ['name: ${1:hello ${2:world}}', 'name: hello world'], + ['color: ${1|red,green|}', 'color: red'], + ['price: \\$5\npath: C:\\\\tmp', 'price: $5\npath: C:\\tmp'], + ['name: ${NAME:world}', 'name: world'], + ]) { + it(`expands the initial text of a schema snippet: ${bodyText}`, async () => { + const snippetSchema: JSONSchema = { type: 'object', defaultSnippets: [{ label: 'example', bodyText }] }; + const result = await complete({}, snippetSchema); + const item = result.items.find((item) => item.label === 'example'); + assert.ok(item); + assert.equal(item.insertTextFormat, InsertTextFormat.PlainText); + assert.equal(item.insertText, expected); + assert.equal(item.textEdit.newText, expected); + + const supported = await complete( + { textDocument: { completion: { completionItem: { snippetSupport: true } } } }, + snippetSchema + ); + const original = supported.items.find((item) => item.label === 'example'); + assert.equal(original.insertTextFormat, InsertTextFormat.Snippet); + assert.equal(original.insertText, bodyText); + assert.deepEqual({ ...item.textEdit, newText: '' }, { ...original.textEdit, newText: '' }); + }); + } +}); From a488905f083f66d8e3a6a7bf3f09a4d854ef01c1 Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Mon, 21 Sep 2026 21:23:19 +0530 Subject: [PATCH 2/2] fix: generate plain completions without a snippet parser --- README.md | 10 +- package-lock.json | 7 - package.json | 1 - .../services/yamlCompletion.ts | 123 +++++++++--------- test/completionCapabilities.test.ts | 60 +++++++-- 5 files changed, 112 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 07df0e183..eea71a0a9 100755 --- a/README.md +++ b/README.md @@ -34,8 +34,9 @@ Schema validation supports JSON Schema `draft-04`, `draft-07`, `2019-09`, and `2 Completion and hover content are schema-driven. See [Associating schemas](#associating-schemas) for configuration details. Snippet completions require the client capability `textDocument.completion.completionItem.snippetSupport`. -When it is absent or `false`, completions insert plain text using placeholder defaults and the first choice, -without tab stops. Clients that support snippets retain editable placeholders. +When it is absent or `false`, generated completions insert plain text using schema defaults, +without tab stops. Schema-provided `defaultSnippets` are offered only to clients that support snippets. +Clients that support snippets retain editable placeholders. ## Language server settings @@ -278,10 +279,7 @@ For multiple file patterns: ```json { - "yaml.disableSchemaDetection": [ - "some.yaml", - "**/.github/workflows/*.yaml" - ] + "yaml.disableSchemaDetection": ["some.yaml", "**/.github/workflows/*.yaml"] } ``` diff --git a/package-lock.json b/package-lock.json index 405bc6099..cb47ae34b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,6 @@ "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "~3.17.5", - "vscode-snippet-parser": "0.0.5", "vscode-uri": "^3.0.2", "yaml": "2.8.3" }, @@ -5355,12 +5354,6 @@ "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", "license": "MIT" }, - "node_modules/vscode-snippet-parser": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/vscode-snippet-parser/-/vscode-snippet-parser-0.0.5.tgz", - "integrity": "sha512-iJ5e7g5sCQJ5LRt5nGWtw10oHj44a+6flLAYbGlA2Jbu32UQtjJqGnaT/1TzMV8Tlig/fsrHf7NoMlAT8N+FmA==", - "license": "MIT" - }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", diff --git a/package.json b/package.json index c9fa90fae..0524e4620 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "~3.17.5", - "vscode-snippet-parser": "0.0.5", "vscode-uri": "^3.0.2", "yaml": "2.8.3" }, diff --git a/src/languageservice/services/yamlCompletion.ts b/src/languageservice/services/yamlCompletion.ts index cc769f8d0..4683bfc0d 100644 --- a/src/languageservice/services/yamlCompletion.ts +++ b/src/languageservice/services/yamlCompletion.ts @@ -5,7 +5,6 @@ import type { TextDocument } from 'vscode-languageserver-textdocument'; import type { ClientCapabilities } from 'vscode-languageserver'; -import { SnippetParser } from 'vscode-snippet-parser'; import type { MarkupContent } from 'vscode-languageserver-types'; import { CompletionItem as CompletionItemBase, @@ -101,31 +100,22 @@ export class YamlCompletion { this.parentSkeletonSelectedFirst = languageSettings.parentSkeletonSelectedFirst; } - async doComplete(document: TextDocument, position: Position, isKubernetes = false, doComplete = true): Promise { - const result = await this.doCompleteWithSnippets(document, position, isKubernetes, doComplete); - if (!this.clientCapabilities.textDocument?.completion?.completionItem?.snippetSupport) { - for (const item of result.items) { - if (item.insertTextFormat !== InsertTextFormat.Snippet) { - continue; - } - if (item.insertText !== undefined) { - item.insertText = new SnippetParser().parse(item.insertText).toString(); - } - if (item.textEdit) { - item.textEdit.newText = new SnippetParser().parse(item.textEdit.newText).toString(); - } - item.insertTextFormat = InsertTextFormat.PlainText; - } + private get supportsSnippets(): boolean { + return !!this.clientCapabilities.textDocument?.completion?.completionItem?.snippetSupport; + } + + private get insertTextFormat(): InsertTextFormat { + return this.supportsSnippets ? InsertTextFormat.Snippet : InsertTextFormat.PlainText; + } + + private tabStop(index: number, value?: string): string { + if (!this.supportsSnippets) { + return value ?? ''; } - return result; + return value === undefined ? '$' + index : '${' + index + ':' + value + '}'; } - private async doCompleteWithSnippets( - document: TextDocument, - position: Position, - isKubernetes: boolean, - doComplete: boolean - ): Promise { + async doComplete(document: TextDocument, position: Position, isKubernetes = false, doComplete = true): Promise { const result = CompletionList.create([], false); if (!this.completionEnabled) { return result; @@ -283,7 +273,7 @@ export class YamlCompletion { completionItem.insertText = `${key}: ${this.getQuote()}\\${char}${this.getQuote()}`; } // trim $1 from end of completion - if (completionItem.insertText.endsWith('$1') && !isForParentCompletion) { + if (this.supportsSnippets && completionItem.insertText.endsWith('$1') && !isForParentCompletion) { completionItem.insertText = completionItem.insertText.substr(0, completionItem.insertText.length - 2); } if (overwriteRange && overwriteRange.start.line === overwriteRange.end.line) { @@ -579,7 +569,7 @@ export class YamlCompletion { kind: CompletionItemKind.Property, label: currentWord, insertText: this.getInsertTextForProperty(currentWord, null, ''), - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, }); } } @@ -637,6 +627,8 @@ export class YamlCompletion { const newValues = Array.prototype.concat(existingValues, addingValues); if (!newValues.length) { return undefined; + } else if (!this.supportsSnippets) { + return `${label}: ${newValues[0]}`; } else if (newValues.length === 1) { return `${label}: \${1:${newValues[0]}}`; } else { @@ -680,13 +672,15 @@ export class YamlCompletion { if (this.isParentCompletionItem(completionItem)) { const indent = completionItem.parent.indent || ''; - const reindexedTexts = reindexText(completionItem.parent.insertTexts); + const reindexedTexts = this.supportsSnippets + ? reindexText(completionItem.parent.insertTexts) + : completionItem.parent.insertTexts; // add indent to each object property and join completion item texts let insertText = reindexedTexts.join(`\n${indent}`); // trim $1 from end of completion - if (insertText.endsWith('$1')) { + if (this.supportsSnippets && insertText.endsWith('$1')) { insertText = insertText.substring(0, insertText.length - 2); } @@ -832,7 +826,7 @@ export class YamlCompletion { kind: CompletionItemKind.Property, label: key, insertText, - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, documentation: this.fromMarkup(propertySchema.markdownDescription) || propertySchema.description || '', }, didOneOfSchemaMatches @@ -849,7 +843,7 @@ export class YamlCompletion { identCompensation + this.indentation, true ), - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, documentation: this.fromMarkup(propertySchema.markdownDescription) || propertySchema.description || '', parent: { schema: schema.schema, @@ -896,8 +890,8 @@ export class YamlCompletion { collector.add({ kind: CompletionItemKind.Property, label, - insertText: '$' + `{1:${label}}: `, - insertTextFormat: InsertTextFormat.Snippet, + insertText: this.tabStop(1, label) + ': ', + insertTextFormat: this.insertTextFormat, documentation: doc, }); } @@ -1040,7 +1034,7 @@ export class YamlCompletion { label: l10n.t('- (array item) ') + (schemaType || index), documentation: documentation, insertText: insertText, - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, }); } @@ -1068,7 +1062,7 @@ export class YamlCompletion { type = 'anyOf'; } } - if (Array.isArray(propertySchema.defaultSnippets)) { + if (this.supportsSnippets && Array.isArray(propertySchema.defaultSnippets)) { if (propertySchema.defaultSnippets.length === 1) { const body = propertySchema.defaultSnippets[0].body; if (isDefined(body)) { @@ -1143,7 +1137,7 @@ export class YamlCompletion { case 'boolean': case 'string': case 'anyOf': - value = ' $1'; + value = ' ' + this.tabStop(1); break; case 'object': value = `\n${indent}`; @@ -1153,10 +1147,10 @@ export class YamlCompletion { break; case 'number': case 'integer': - value = ' ${1:0}'; + value = ' ' + this.tabStop(1, '0'); break; case 'null': - value = ' ${1:null}'; + value = ' ' + this.tabStop(1, 'null'); break; default: return propertyText; @@ -1164,7 +1158,7 @@ export class YamlCompletion { } } if (!value || (nValueProposals > 1 && !hasRequiredDefault)) { - value = ' $1'; + value = ' ' + this.tabStop(1); } return resultText + value + separatorAfter; } @@ -1177,7 +1171,7 @@ export class YamlCompletion { ): InsertText { let insertText = ''; if (!schema.properties) { - insertText = `${indent}$${insertIndex++}\n`; + insertText = `${indent}${this.tabStop(insertIndex++)}\n`; return { insertText, insertIndex }; } @@ -1207,9 +1201,9 @@ export class YamlCompletion { if (type === 'string') { value = toYamlStringScalar(value); } - insertText += `${indent}${key}: \${${insertIndex++}:${value}}\n`; + insertText += `${indent}${key}: ${this.tabStop(insertIndex++, String(value))}\n`; } else { - insertText += `${indent}${key}: $${insertIndex++}\n`; + insertText += `${indent}${key}: ${this.tabStop(insertIndex++)}\n`; } break; } @@ -1250,10 +1244,10 @@ export class YamlCompletion { insertText += `${indent}${ //added quote if key is null key === 'null' ? this.getInsertTextForValue(key, '', 'string') : key - }: \${${insertIndex++}:${propertySchema.default}}\n`; + }: ${this.tabStop(insertIndex++, String(propertySchema.default))}\n`; break; case 'string': - insertText += `${indent}${key}: \${${insertIndex++}:${toYamlStringScalar(propertySchema.default)}}\n`; + insertText += `${indent}${key}: ${this.tabStop(insertIndex++, toYamlStringScalar(propertySchema.default))}\n`; break; case 'array': case 'object': @@ -1263,7 +1257,7 @@ export class YamlCompletion { } }); if (insertText.trim().length === 0) { - insertText = `${indent}$${insertIndex++}\n`; + insertText = `${indent}${this.tabStop(insertIndex++)}\n`; } insertText = insertText.trimEnd() + separatorAfter; return { insertText, insertIndex }; @@ -1273,7 +1267,7 @@ export class YamlCompletion { private getInsertTextForArray(schema: any, separatorAfter: string, insertIndex = 1, indent = this.indentation): InsertText { let insertText = ''; if (!schema) { - insertText = `$${insertIndex++}`; + insertText = this.tabStop(insertIndex++); return { insertText, insertIndex }; } let type = Array.isArray(schema.type) ? schema.type[0] : schema.type; @@ -1290,14 +1284,14 @@ export class YamlCompletion { } else { switch (schema.type) { case 'boolean': - insertText = `\${${insertIndex++}:false}`; + insertText = this.tabStop(insertIndex++, 'false'); break; case 'number': case 'integer': - insertText = `\${${insertIndex++}:0}`; + insertText = this.tabStop(insertIndex++, '0'); break; case 'string': - insertText = `\${${insertIndex++}}`; + insertText = this.supportsSnippets ? `\${${insertIndex++}}` : this.tabStop(insertIndex++); break; case 'object': { @@ -1316,24 +1310,27 @@ export class YamlCompletion { switch (typeof value) { case 'object': if (value === null) { - return '${1:null}' + separatorAfter; + return this.tabStop(1, 'null') + separatorAfter; } return this.getInsertTextForValue(value, separatorAfter, type); case 'string': { if (type === 'number' || type === 'integer') { - return '${1:' + value + '}' + separatorAfter; + return this.tabStop(1, String(value)) + separatorAfter; } const snippetValue = this.getInsertTextForPlainText(toYamlStringScalar(value)); - return '${1:' + snippetValue + '}' + separatorAfter; + return this.tabStop(1, snippetValue) + separatorAfter; } case 'number': case 'boolean': - return '${1:' + value + '}' + separatorAfter; + return this.tabStop(1, String(value)) + separatorAfter; } return this.getInsertTextForValue(value, separatorAfter, type); } private getInsertTextForPlainText(text: string): string { + if (!this.supportsSnippets) { + return text; + } return text.replace(/\\(?=[$}\\])/g, '\\\\').replace(/[$}]/g, '\\$&'); } @@ -1367,7 +1364,7 @@ export class YamlCompletion { if (Array.isArray(value)) { let insertText = '\n'; for (const arrValue of value) { - insertText += `${indent}- \${${navOrder.index++}:${arrValue}}\n`; + insertText += `${indent}- ${this.tabStop(navOrder.index++, String(arrValue))}\n`; } return insertText; } else if (typeof value === 'object') { @@ -1375,12 +1372,12 @@ export class YamlCompletion { for (const key in value) { if (Object.prototype.hasOwnProperty.call(value, key)) { const element = value[key]; - insertText += `${indent}\${${navOrder.index++}:${key}}:`; + insertText += `${indent}${this.tabStop(navOrder.index++, key)}:`; let valueTemplate; if (typeof element === 'object') { valueTemplate = `${this.getInsertTemplateForValue(element, indent + this.indentation, navOrder, separatorAfter)}`; } else { - valueTemplate = ` \${${navOrder.index++}:${this.getInsertTextForPlainText(element + separatorAfter)}}\n`; + valueTemplate = ` ${this.tabStop(navOrder.index++, this.getInsertTextForPlainText(element + separatorAfter))}\n`; } insertText += `${valueTemplate}`; } @@ -1463,7 +1460,7 @@ export class YamlCompletion { kind: this.getSuggestionKind(type), label, insertText: this.getInsertTextForValue(value, separatorAfter, type), - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, detail: l10n.t('Default value'), }); hasProposals = true; @@ -1480,7 +1477,7 @@ export class YamlCompletion { kind: this.getSuggestionKind(type), label: this.getLabelForValue(value), insertText: this.getInsertTextForValue(value, separatorAfter, type), - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, }); hasProposals = true; }); @@ -1507,7 +1504,7 @@ export class YamlCompletion { kind: this.getSuggestionKind(schema.type), label: this.getLabelForValue(schema.const), insertText: this.getInsertTextForValue(schema.const, separatorAfter, schema.type), - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, documentation: this.fromMarkup(schema.markdownDescription) || schema.description, }); } @@ -1528,7 +1525,7 @@ export class YamlCompletion { kind: this.getSuggestionKind(schema.type), label: this.getLabelForValue(enm), insertText: this.getInsertTextForValue(enm, separatorAfter, schema.type), - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, documentation: documentation, }); } @@ -1555,7 +1552,7 @@ export class YamlCompletion { settings: StringifySettings, arrayDepth = 0 ): void { - if (Array.isArray(schema.defaultSnippets)) { + if (this.supportsSnippets && Array.isArray(schema.defaultSnippets)) { for (const s of schema.defaultSnippets) { let type = schema.type; let value = s.body; @@ -1606,7 +1603,7 @@ export class YamlCompletion { sortText: s.sortText || s.label, documentation: this.fromMarkup(s.markdownDescription) || s.description, insertText, - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, filterText, }); } @@ -1642,7 +1639,7 @@ export class YamlCompletion { kind: this.getSuggestionKind('boolean'), label: value ? 'true' : 'false', insertText: this.getInsertTextForValue(value, separatorAfter, 'boolean'), - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, documentation: '', }); } @@ -1652,7 +1649,7 @@ export class YamlCompletion { kind: this.getSuggestionKind('null'), label: 'null', insertText: 'null' + separatorAfter, - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, documentation: '', }); } @@ -1677,7 +1674,7 @@ export class YamlCompletion { kind: this.getSuggestionKind('string'), label: label, insertText: label + separatorAfter, - insertTextFormat: InsertTextFormat.Snippet, + insertTextFormat: this.insertTextFormat, documentation: '', }); } diff --git a/test/completionCapabilities.test.ts b/test/completionCapabilities.test.ts index 0cfbbcf13..004a622a7 100644 --- a/test/completionCapabilities.test.ts +++ b/test/completionCapabilities.test.ts @@ -45,21 +45,18 @@ describe('Completion snippet capabilities', () => { assert.equal(item.textEdit.newText, 'greeting: '); }); - for (const [bodyText, expected] of [ - ['name: ${1:world}\nagain: $1\nend: $0', 'name: world\nagain: world\nend: '], - ['name: ${1:hello ${2:world}}', 'name: hello world'], - ['color: ${1|red,green|}', 'color: red'], - ['price: \\$5\npath: C:\\\\tmp', 'price: $5\npath: C:\\tmp'], - ['name: ${NAME:world}', 'name: world'], + for (const bodyText of [ + 'name: ${1:world}\nagain: $1\nend: $0', + 'name: ${1:hello ${2:world}}', + 'color: ${1|red,green|}', + 'price: \\$5\npath: C:\\\\tmp', + 'name: ${NAME:world}', ]) { - it(`expands the initial text of a schema snippet: ${bodyText}`, async () => { + it(`offers custom snippets only to snippet-capable clients: ${bodyText}`, async () => { const snippetSchema: JSONSchema = { type: 'object', defaultSnippets: [{ label: 'example', bodyText }] }; const result = await complete({}, snippetSchema); const item = result.items.find((item) => item.label === 'example'); - assert.ok(item); - assert.equal(item.insertTextFormat, InsertTextFormat.PlainText); - assert.equal(item.insertText, expected); - assert.equal(item.textEdit.newText, expected); + assert.equal(item, undefined); const supported = await complete( { textDocument: { completion: { completionItem: { snippetSupport: true } } } }, @@ -68,7 +65,46 @@ describe('Completion snippet capabilities', () => { const original = supported.items.find((item) => item.label === 'example'); assert.equal(original.insertTextFormat, InsertTextFormat.Snippet); assert.equal(original.insertText, bodyText); - assert.deepEqual({ ...item.textEdit, newText: '' }, { ...original.textEdit, newText: '' }); + assert.equal(original.textEdit.newText, bodyText); }); } + for (const [type, value, expected] of [ + ['string', 'cost $1', 'cost $1'], + ['number', 42, '42'], + ['boolean', false, 'false'], + ['null', null, 'null'], + ] as [string, string | number | boolean | null, string][]) { + it(`keeps a ${type} default without adding tab stops`, async () => { + const result = await complete({}, { type: 'object', properties: { value: { type, default: value } } }); + const item = result.items.find((item) => item.label === 'value'); + assert.ok(item); + assert.equal(item.insertTextFormat, InsertTextFormat.PlainText); + assert.equal(item.textEdit.newText, `value: ${expected}`); + }); + } + + it('builds nested required properties without snippet syntax', async () => { + const result = await complete( + {}, + { + type: 'object', + properties: { + settings: { + type: 'object', + required: ['enabled', 'count'], + properties: { + enabled: { type: 'boolean', default: false }, + count: { type: 'integer', default: 7 }, + }, + }, + }, + } + ); + const item = result.items.find((item) => item.label === 'settings'); + assert.ok(item); + assert.equal(item.insertTextFormat, InsertTextFormat.PlainText); + assert.match(item.textEdit.newText, /enabled: false/); + assert.match(item.textEdit.newText, /count: 7/); + assert.ok(!item.textEdit.newText.includes('$')); + }); });