From e7ad86793e718975953fd854c7bb43cd17a5b726 Mon Sep 17 00:00:00 2001 From: Petr Spacek Date: Tue, 11 Mar 2025 12:27:18 +0100 Subject: [PATCH 1/2] feat: property snippets --- .../services/yamlCompletion.ts | 97 +++- src/languageservice/utils/json.ts | 2 +- src/languageservice/utils/strings.ts | 15 + test/autoCompletionFix.test.ts | 536 ++++++++++++++++++ test/defaultSnippets.test.ts | 8 +- test/strings.test.ts | 83 ++- 6 files changed, 710 insertions(+), 31 deletions(-) diff --git a/src/languageservice/services/yamlCompletion.ts b/src/languageservice/services/yamlCompletion.ts index 5283b76a0..4a525360a 100644 --- a/src/languageservice/services/yamlCompletion.ts +++ b/src/languageservice/services/yamlCompletion.ts @@ -37,6 +37,7 @@ import { indexOf, isInComment, isMapContainsEmptyPair } from '../utils/astUtils' import { isModeline } from './modelineUtil'; import { getSchemaTypeName, isAnyOfAllOfOneOfType, isPrimitiveType } from '../utils/schemaUtils'; import { YamlNode } from '../jsonASTTypes'; +import { addIndentationToMultilineString } from '../utils/strings'; const localize = nls.loadMessageBundle(); @@ -523,7 +524,7 @@ export class YamlCompletion { collector.add({ kind: CompletionItemKind.Property, label: currentWord, - insertText: this.getInsertTextForProperty(currentWord, null, ''), + insertText: this.getInsertTextForProperty(currentWord, null, '', collector), insertTextFormat: InsertTextFormat.Snippet, }); } @@ -760,6 +761,7 @@ export class YamlCompletion { key, propertySchema, separatorAfter, + collector, identCompensation + this.indentation ); } @@ -787,6 +789,7 @@ export class YamlCompletion { key, propertySchema, separatorAfter, + collector, identCompensation + this.indentation ), insertTextFormat: InsertTextFormat.Snippet, @@ -944,7 +947,7 @@ export class YamlCompletion { index?: number ): void { const schemaType = getSchemaTypeName(schema); - const insertText = `- ${this.getInsertTextForObject(schema, separatorAfter).insertText.trimLeft()}`; + const insertText = `- ${this.getInsertTextForObject(schema, separatorAfter, collector).insertText.trimLeft()}`; //append insertText to documentation const schemaTypeTitle = schemaType ? ' type `' + schemaType + '`' : ''; const schemaDescription = schema.description ? ' (' + schema.description + ')' : ''; @@ -965,6 +968,7 @@ export class YamlCompletion { key: string, propertySchema: JSONSchema, separatorAfter: string, + collector: CompletionsCollector, indent = this.indentation ): string { const propertyText = this.getInsertTextForValue(key, '', 'string'); @@ -1035,11 +1039,11 @@ export class YamlCompletion { nValueProposals += propertySchema.examples.length; } if (propertySchema.properties) { - return `${resultText}\n${this.getInsertTextForObject(propertySchema, separatorAfter, indent).insertText}`; + return `${resultText}\n${this.getInsertTextForObject(propertySchema, separatorAfter, collector, indent).insertText}`; } else if (propertySchema.items) { - return `${resultText}\n${indent}- ${ - this.getInsertTextForArray(propertySchema.items, separatorAfter, 1, indent).insertText - }`; + let insertText = this.getInsertTextForArray(propertySchema.items, separatorAfter, collector, 1, indent).insertText; + insertText = resultText + addIndentationToMultilineString(insertText, `\n${indent}- `, ' '); + return insertText; } if (nValueProposals === 0) { switch (type) { @@ -1079,10 +1083,30 @@ export class YamlCompletion { private getInsertTextForObject( schema: JSONSchema, separatorAfter: string, + collector: CompletionsCollector, indent = this.indentation, insertIndex = 1 ): InsertText { let insertText = ''; + if (Array.isArray(schema.defaultSnippets) && schema.defaultSnippets.length === 1) { + const body = schema.defaultSnippets[0].body; + if (isDefined(body)) { + let value = this.getInsertTextForSnippetValue( + body, + '', + { + newLineFirst: false, + indentFirstObject: false, + shouldIndentWithTab: false, + }, + Object.keys(collector.proposed), + 0 + ); + value = addIndentationToMultilineString(value, indent, indent); + + return { insertText: value, insertIndex }; + } + } if (!schema.properties) { insertText = `${indent}$${insertIndex++}\n`; return { insertText, insertIndex }; @@ -1122,18 +1146,22 @@ export class YamlCompletion { } case 'array': { - const arrayInsertResult = this.getInsertTextForArray(propertySchema.items, separatorAfter, insertIndex++, indent); - const arrayInsertLines = arrayInsertResult.insertText.split('\n'); - let arrayTemplate = arrayInsertResult.insertText; - if (arrayInsertLines.length > 1) { - for (let index = 1; index < arrayInsertLines.length; index++) { - const element = arrayInsertLines[index]; - arrayInsertLines[index] = ` ${element}`; - } - arrayTemplate = arrayInsertLines.join('\n'); - } + const arrayInsertResult = this.getInsertTextForArray( + propertySchema.items, + separatorAfter, + collector, + insertIndex++, + indent + ); + insertIndex = arrayInsertResult.insertIndex; - insertText += `${indent}${key}:\n${indent}${this.indentation}- ${arrayTemplate}\n`; + insertText += + `${indent}${key}:` + + addIndentationToMultilineString( + arrayInsertResult.insertText, + `\n${indent}${this.indentation}- `, + `${this.indentation} ` + ); } break; case 'object': @@ -1141,6 +1169,7 @@ export class YamlCompletion { const objectInsertResult = this.getInsertTextForObject( propertySchema, separatorAfter, + collector, `${indent}${this.indentation}`, insertIndex++ ); @@ -1176,8 +1205,14 @@ export class YamlCompletion { return { insertText, insertIndex }; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private getInsertTextForArray(schema: any, separatorAfter: string, insertIndex = 1, indent = this.indentation): InsertText { + private getInsertTextForArray( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + schema: any, + separatorAfter: string, + collector: CompletionsCollector, + insertIndex = 1, + indent = this.indentation + ): InsertText { let insertText = ''; if (!schema) { insertText = `$${insertIndex++}`; @@ -1205,7 +1240,7 @@ export class YamlCompletion { break; case 'object': { - const objectInsertResult = this.getInsertTextForObject(schema, separatorAfter, `${indent} `, insertIndex++); + const objectInsertResult = this.getInsertTextForObject(schema, separatorAfter, collector, indent, insertIndex++); insertText = objectInsertResult.insertText.trimLeft(); insertIndex = objectInsertResult.insertIndex; } @@ -1391,11 +1426,17 @@ export class YamlCompletion { hasProposals = true; }); } - this.collectDefaultSnippets(schema, separatorAfter, collector, { - newLineFirst: true, - indentFirstObject: true, - shouldIndentWithTab: true, - }); + this.collectDefaultSnippets( + schema, + separatorAfter, + collector, + { + newLineFirst: true, + indentFirstObject: true, + shouldIndentWithTab: true, + }, + arrayDepth + ); if (!hasProposals && typeof schema.items === 'object' && !Array.isArray(schema.items)) { this.addDefaultValueCompletions(schema.items, separatorAfter, collector, arrayDepth + 1); } @@ -1484,6 +1525,12 @@ export class YamlCompletion { if (insertText === '' && value) { continue; } + // detection of specific situation: snippets for value Completion + // snippets located inside schema.items and line without hyphen (value completion) + if (arrayDepth === 1 && !Array.isArray(value) && settings.newLineFirst) { + insertText = addIndentationToMultilineString(insertText.trimStart(), `\n${this.indentation}- `, ' '); + } + label = label || this.getLabelForSnippetValue(value); } else if (typeof s.bodyText === 'string') { let prefix = '', diff --git a/src/languageservice/utils/json.ts b/src/languageservice/utils/json.ts index 00ef5483d..be76eb511 100644 --- a/src/languageservice/utils/json.ts +++ b/src/languageservice/utils/json.ts @@ -37,7 +37,7 @@ export function stringifyObject( let result = ''; for (let i = 0; i < obj.length; i++) { let pseudoObj = obj[i]; - if (typeof obj[i] !== 'object') { + if (typeof obj[i] !== 'object' || obj[i] === null) { result += '\n' + newIndent + '- ' + stringifyLiteral(obj[i]); continue; } diff --git a/src/languageservice/utils/strings.ts b/src/languageservice/utils/strings.ts index 6171411df..b0810e507 100644 --- a/src/languageservice/utils/strings.ts +++ b/src/languageservice/utils/strings.ts @@ -87,3 +87,18 @@ export function getFirstNonWhitespaceCharacterAfterOffset(str: string, offset: n } return offset; } + +export function addIndentationToMultilineString(text: string, firstIndent: string, nextIndent: string): string { + let wasFirstLineIndented = false; + return text.replace(/^.*$/gm, (match) => { + if (!match) { + return match; + } + // Add fistIndent to first line or if the previous line was empty + if (!wasFirstLineIndented) { + wasFirstLineIndented = true; + return firstIndent + match; + } + return nextIndent + match; // Add indent to other lines + }); +} diff --git a/test/autoCompletionFix.test.ts b/test/autoCompletionFix.test.ts index d8ec5b040..bdf825038 100644 --- a/test/autoCompletionFix.test.ts +++ b/test/autoCompletionFix.test.ts @@ -1207,6 +1207,542 @@ objB: expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['snippetBody']); }); + describe('variations of defaultSnippets', () => { + const getNestedSchema = (schema: JSONSchema['properties']): JSONSchema => { + return { + type: 'object', + properties: { + snippets: { + type: 'object', + properties: { + ...schema, + }, + }, + }, + }; + }; + + // STRING + describe('defaultSnippet for string property', () => { + const schema = getNestedSchema({ + snippetString: { + type: 'string', + defaultSnippets: [ + { + label: 'labelSnippetString', + body: 'value', + }, + ], + }, + }); + + it('should suggest defaultSnippet for STRING property - unfinished property', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetStr|\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['snippetString: value']); + }); + + it('should suggest defaultSnippet for STRING property - value after colon', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetString: |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['value']); + }); + }); // STRING + + // OBJECT + describe('defaultSnippet for OBJECT property', () => { + const schema = getNestedSchema({ + snippetObject: { + type: 'object', + properties: { + item1: { type: 'string' }, + }, + required: ['item1'], + defaultSnippets: [ + { + label: 'labelSnippetObject', + body: { + item1: 'value', + item2: { + item3: 'value nested', + }, + }, + }, + ], + }, + }); + + it('should suggest defaultSnippet for OBJECT property - unfinished property, snippet replaces autogenerated props', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetOb|\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([ + { + label: 'snippetObject', + insertText: `snippetObject: + item1: value + item2: + item3: value nested`, + }, + ]); + }); + + it('should suggest defaultSnippet for OBJECT property - value after colon', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetObject: |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([ + { + label: 'labelSnippetObject', // snippet intellisense + insertText: ` + item1: value + item2: + item3: value nested`, + }, + ]); + }); + + it('should suggest defaultSnippet for OBJECT property - value with indent', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetObject: + |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([ + { + label: 'labelSnippetObject', // snippet intellisense + insertText: `item1: value +item2: + item3: value nested`, + }, + { + label: 'item1', // key intellisense + insertText: 'item1: ', + }, + { + label: 'object', // parent intellisense + insertText: 'item1: ', + }, + ]); + }); + + it('should suggest partial defaultSnippet for OBJECT property - subset of items already there', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetObject: + item1: val + |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([ + { + label: 'labelSnippetObject', + insertText: `item2: + item3: value nested`, + }, + ]); + }); + + it('should suggest no defaultSnippet for OBJECT property - all items already there', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetObject: + item1: val + item2: val + |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([]); + }); + }); // OBJECT + + // OBJECT - Snippet nested + describe('defaultSnippet for OBJECT property', () => { + const schema = getNestedSchema({ + snippetObject: { + type: 'object', + properties: { + item1: { + type: 'object', + defaultSnippets: [ + { + label: 'labelSnippetObject', + body: { + item1_1: 'value', + item1_2: { + item1_2_1: 'value nested', + }, + }, + }, + ], + }, + }, + required: ['item1'], + }, + }); + + it('should suggest defaultSnippet for nested OBJECT property - unfinished property, snippet extends autogenerated props', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetOb|\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([ + { + label: 'snippetObject', + insertText: `snippetObject: + item1: + item1_1: value + item1_2: + item1_2_1: value nested`, + }, + ]); + }); + }); // OBJECT - Snippet nested + + // ARRAY + describe('defaultSnippet for ARRAY property', () => { + describe('defaultSnippets on the property level as an object value', () => { + const schema = getNestedSchema({ + snippetArray: { + type: 'array', + $comment: + 'property - Not implemented, OK value, OK value nested, OK value nested with -, OK on 2nd index without or with -', + items: { + type: 'object', + properties: { + item1: { type: 'string' }, + }, + }, + defaultSnippets: [ + { + label: 'labelSnippetArray', + body: { + item1: 'value', + item2: 'value2', + }, + }, + ], + }, + }); + + it('should suggest defaultSnippet for ARRAY property - unfinished property (not implemented)', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetAr|\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([ + { + label: 'snippetArray', + insertText: 'snippetArray:\n - ', + }, + ]); + }); + + // this is hard to fix, it requires bigger refactor of `collectDefaultSnippets` function + it('should suggest defaultSnippet for ARRAY property - value after colon', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetArray: |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([ + { + label: 'labelSnippetArray', + insertText: ` + - item1: value + item2: value2`, + }, + ]); + }); + + it('should suggest defaultSnippet for ARRAY property - value with indent (with hyphen)', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetArray: + - |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([ + { + label: 'item1', + insertText: 'item1: ', + }, + { + label: 'labelSnippetArray', + insertText: `item1: value + item2: value2`, + }, + ]); + }); + it('should suggest defaultSnippet for ARRAY property - value on 2nd position', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetArray: + - item1: test + - |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([ + { + label: 'item1', + insertText: 'item1: ', + }, + { + label: 'labelSnippetArray', + insertText: `item1: value + item2: value2`, + }, + ]); + }); + }); + describe('defaultSnippets on the items level as an object value', () => { + const schema = getNestedSchema({ + snippetArray2: { + type: 'array', + items: { + type: 'object', + additionalProperties: true, + defaultSnippets: [ + { + label: 'labelSnippetArray', + body: { + item1: 'value', + item2: 'value2', + }, + }, + ], + }, + }, + }); + + it('should suggest defaultSnippet for ARRAY property - unfinished property', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetAr|\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal([ + 'snippetArray2:\n - item1: value\n item2: value2', + ]); + }); + + it('should suggest defaultSnippet for ARRAY property - value after colon', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetArray2: |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal([ + ` + - item1: value + item2: value2`, + ]); + }); + + it('should suggest defaultSnippet for ARRAY property - value with indent (with hyphen)', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetArray2: + - |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal([ + `item1: value + item2: value2`, + ]); + }); + it('should suggest defaultSnippet for ARRAY property - value on 2nd position', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetArray2: + - item1: test + - |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal([ + `item1: value + item2: value2`, + ]); + }); + }); // ARRAY - Snippet on items level + + describe('defaultSnippets on the items level, ARRAY - Body is array of primitives', () => { + const schema = getNestedSchema({ + snippetArrayPrimitives: { + type: 'array', + items: { + type: ['string', 'boolean', 'number', 'null'], + defaultSnippets: [ + { + body: ['value', 5, null, false], + }, + ], + }, + }, + }); + + // fix if needed + // it('should suggest defaultSnippet for ARRAY property with primitives - unfinished property', async () => { + // schemaProvider.addSchema(SCHEMA_ID, schema); + // const content = ` + // snippets: + // snippetArrayPrimitives|\n| + // `; + // const completion = await parseCaret(content); + + // expect(completion.items.map((i) => i.insertText)).to.be.deep.equal([ + // 'snippetArrayPrimitives:\n - value\n - 5\n - null\n - false', + // ]); + // }); + + it('should suggest defaultSnippet for ARRAY property with primitives - value after colon', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetArrayPrimitives: |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['\n - value\n - 5\n - null\n - false']); + }); + + // skip, fix if needed + // it('should suggest defaultSnippet for ARRAY property with primitives - value with indent (with hyphen)', async () => { + // schemaProvider.addSchema(SCHEMA_ID, schema); + // const content = ` + // snippets: + // snippetArrayPrimitives: + // - |\n| + // `; + // const completion = await parseCaret(content); + + // expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['value\n - 5\n - null\n - false']); + // }); + // it('should suggest defaultSnippet for ARRAY property with primitives - value on 2nd position', async () => { + // schemaProvider.addSchema(SCHEMA_ID, schema); + // const content = ` + // snippets: + // snippetArrayPrimitives: + // - some other value + // - |\n| + // `; + // const completion = await parseCaret(content); + + // expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['- value\n - 5\n - null\n - false']); + // }); + }); // ARRAY - Body is array of primitives + + describe('defaultSnippets on the items level, ARRAY - Body is string', () => { + const schema = getNestedSchema({ + snippetArrayString: { + type: 'array', + items: { + type: 'string', + defaultSnippets: [ + { + body: 'value', + }, + ], + }, + }, + }); + + it('should suggest defaultSnippet for ARRAY property with string - unfinished property', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetArrayString|\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['snippetArrayString:\n - ${1}']); + // better to suggest, fix if needed + // expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['snippetArrayString:\n - value']); + }); + + it('should suggest defaultSnippet for ARRAY property with string - value after colon', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + snippetArrayString: |\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['\n - value']); + }); + + it('should suggest defaultSnippet for ARRAY property with string - value with indent (with hyphen)', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` + snippets: + snippetArrayString: + - |\n| + `; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['value']); + }); + it('should suggest defaultSnippet for ARRAY property with string - value on 2nd position', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` + snippets: + snippetArrayString: + - some other value + - |\n| + `; + const completion = await parseCaret(content); + + expect(completion.items.map((i) => i.insertText)).to.be.deep.equal(['value']); + }); + }); // ARRAY - Body is simple string + }); // ARRAY + }); + describe('should suggest prop of the object (based on not completed prop name)', () => { const schema: JSONSchema = { definitions: { diff --git a/test/defaultSnippets.test.ts b/test/defaultSnippets.test.ts index 7ffaf9d8b..fbeff5c6e 100644 --- a/test/defaultSnippets.test.ts +++ b/test/defaultSnippets.test.ts @@ -163,7 +163,7 @@ describe('Default Snippet Tests', () => { assert.equal(result.items.length, 2); assert.equal(result.items[0].insertText, 'key1: $1\nkey2: $2'); assert.equal(result.items[0].label, 'Object item'); - assert.equal(result.items[1].insertText, 'key:\n '); + assert.equal(result.items[1].insertText, 'key:\n key1: $1\n key2: $2'); assert.equal(result.items[1].label, 'key'); }) .then(done, done); @@ -177,7 +177,7 @@ describe('Default Snippet Tests', () => { assert.notEqual(result.items.length, 0); assert.equal(result.items[0].insertText, 'key1: $1\nkey2: $2'); assert.equal(result.items[0].label, 'Object item'); - assert.equal(result.items[1].insertText, 'key:\n '); + assert.equal(result.items[1].insertText, 'key:\n key1: $1\n key2: $2'); assert.equal(result.items[1].label, 'key'); }) .then(done, done); @@ -191,7 +191,7 @@ describe('Default Snippet Tests', () => { assert.notEqual(result.items.length, 0); assert.equal(result.items[0].insertText, 'key1: '); assert.equal(result.items[0].label, 'Object item'); - assert.equal(result.items[1].insertText, 'key:\n '); + assert.equal(result.items[1].insertText, 'key:\n key1: '); assert.equal(result.items[1].label, 'key'); }) .then(done, done); @@ -202,7 +202,7 @@ describe('Default Snippet Tests', () => { completion .then(function (result) { assert.equal(result.items.length, 1); - assert.equal(result.items[0].insertText, 'key:\n '); + assert.equal(result.items[0].insertText, 'key:\n'); assert.equal(result.items[0].label, 'key'); }) .then(done, done); diff --git a/test/strings.test.ts b/test/strings.test.ts index 45741f7e9..4b808200d 100644 --- a/test/strings.test.ts +++ b/test/strings.test.ts @@ -2,7 +2,13 @@ * Copyright (c) Red Hat. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { startsWith, endsWith, convertSimple2RegExp, safeCreateUnicodeRegExp } from '../src/languageservice/utils/strings'; +import { + startsWith, + endsWith, + convertSimple2RegExp, + safeCreateUnicodeRegExp, + addIndentationToMultilineString, +} from '../src/languageservice/utils/strings'; import * as assert from 'assert'; import { expect } from 'chai'; @@ -106,5 +112,80 @@ describe('String Tests', () => { const result = safeCreateUnicodeRegExp('^[\\w\\-_]+$'); expect(result).is.not.undefined; }); + + describe('addIndentationToMultilineString', () => { + it('should add indentation to a single line string', () => { + const text = 'hello'; + const firstIndent = ' '; + const nextIndent = ' '; + + const result = addIndentationToMultilineString(text, firstIndent, nextIndent); + assert.equal(result, ' hello'); + }); + + it('should add indentation to a multiline string', () => { + const text = 'hello\nworld'; + const firstIndent = ' '; + const nextIndent = ' '; + + const result = addIndentationToMultilineString(text, firstIndent, nextIndent); + assert.equal(result, ' hello\n world'); + }); + + it('should not indent empty string', () => { + const text = ''; + const firstIndent = ' '; + const nextIndent = ' '; + + const result = addIndentationToMultilineString(text, firstIndent, nextIndent); + assert.equal(result, ''); + }); + + it('should not indent string with only newlines', () => { + const text = '\n\n'; + const firstIndent = ' '; + const nextIndent = ' '; + + const result = addIndentationToMultilineString(text, firstIndent, nextIndent); + assert.equal(result, '\n\n'); + }); + it('should not indent empty lines', () => { + const text = '\ntest\n'; + const firstIndent = ' '; + const nextIndent = ' '; + + const result = addIndentationToMultilineString(text, firstIndent, nextIndent); + assert.equal(result, '\n test\n'); + }); + + it('should handle string with multiple lines', () => { + const text = 'line1\nline2\nline3'; + const firstIndent = ' '; + const nextIndent = ' '; + + const result = addIndentationToMultilineString(text, firstIndent, nextIndent); + assert.equal(result, ' line1\n line2\n line3'); + }); + + it('should handle string with multiple lines and tabs', () => { + const text = 'line1\nline2\nline3'; + const firstIndent = '\t'; + const nextIndent = ' '; + + const result = addIndentationToMultilineString(text, firstIndent, nextIndent); + assert.equal(result, ' line1\n line2\n line3'); + }); + + it('should prepare text for array snippet', () => { + const text = `obj: + prop1: value1 + prop2: value2`; + const firstIndent = '\n- '; + const nextIndent = ' '; + + const result = addIndentationToMultilineString(text, firstIndent, nextIndent); + assert.equal(result, '\n- obj:\n prop1: value1\n prop2: value2'); + }); + }); }); }); From d2d9bdd0a06f90b2fff4b6901185e44fe8928ce2 Mon Sep 17 00:00:00 2001 From: Petr Spacek Date: Mon, 17 Mar 2025 11:17:44 +0100 Subject: [PATCH 2/2] fix: object property snippet - should not check existing props in the yaml - because it's properties from the parent object --- .../services/yamlCompletion.ts | 2 +- test/autoCompletionFix.test.ts | 19 +++++++++++++++++++ test/defaultSnippets.test.ts | 7 ++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/languageservice/services/yamlCompletion.ts b/src/languageservice/services/yamlCompletion.ts index bcf4d4366..84fde7ea0 100644 --- a/src/languageservice/services/yamlCompletion.ts +++ b/src/languageservice/services/yamlCompletion.ts @@ -1118,7 +1118,7 @@ export class YamlCompletion { indentFirstObject: false, shouldIndentWithTab: false, }, - Object.keys(collector.proposed), + [], 0 ); value = addIndentationToMultilineString(value, indent, indent); diff --git a/test/autoCompletionFix.test.ts b/test/autoCompletionFix.test.ts index d0f9c3df3..af5efc871 100644 --- a/test/autoCompletionFix.test.ts +++ b/test/autoCompletionFix.test.ts @@ -1328,6 +1328,25 @@ snippets: label: 'snippetObject', insertText: `snippetObject: item1: value + item2: + item3: value nested`, + }, + ]); + }); + it('should suggest defaultSnippet for OBJECT property - unfinished property, should keep all snippet properties', async () => { + schemaProvider.addSchema(SCHEMA_ID, schema); + const content = ` +snippets: + item1: value + snippetOb|\n| +`; + const completion = await parseCaret(content); + + expect(completion.items.map(({ label, insertText }) => ({ label, insertText }))).to.be.deep.equal([ + { + label: 'snippetObject', + insertText: `snippetObject: + item1: value item2: item3: value nested`, }, diff --git a/test/defaultSnippets.test.ts b/test/defaultSnippets.test.ts index 46abe0ddf..b38a4dae3 100644 --- a/test/defaultSnippets.test.ts +++ b/test/defaultSnippets.test.ts @@ -184,14 +184,14 @@ describe('Default Snippet Tests', () => { }); it('Snippet in object schema should suggest some of the snippet props because some of them are already in the YAML', (done) => { - const content = 'object:\n key:\n key2: value\n '; + const content = 'object:\n key:\n key2: value\n '; // position is nested in `key` const completion = parseSetup(content, content.length); completion .then(function (result) { assert.notEqual(result.items.length, 0); assert.equal(result.items[0].insertText, 'key1: '); assert.equal(result.items[0].label, 'Object item'); - assert.equal(result.items[1].insertText, 'key:\n key1: '); + assert.equal(result.items[1].insertText, 'key:\n key1: $1\n key2: $2'); // recursive item (key inside key) assert.equal(result.items[1].label, 'key'); }) .then(done, done); @@ -202,7 +202,8 @@ describe('Default Snippet Tests', () => { completion .then(function (result) { assert.equal(result.items.length, 1); - assert.equal(result.items[0].insertText, 'key:\n'); + // snippet for nested `key` property + assert.equal(result.items[0].insertText, 'key:\n key1: $1\n key2: $2'); // recursive item (key inside key) assert.equal(result.items[0].label, 'key'); }) .then(done, done);