diff --git a/src/languageservice/services/yamlCompletion.ts b/src/languageservice/services/yamlCompletion.ts index 61e49255a..55de2b026 100644 --- a/src/languageservice/services/yamlCompletion.ts +++ b/src/languageservice/services/yamlCompletion.ts @@ -841,17 +841,36 @@ export class YamlCompletion { this.addSchemaValueCompletions(schema.schema, separatorAfter, collector, {}, ignoreScalars); } - if (schema.schema.propertyNames && schema.schema.additionalProperties && schema.schema.type === 'object') { + if (schema.schema.type === 'object' && schema.schema.propertyNames && schema.schema.additionalProperties !== false) { const propertyNameSchema = asSchema(schema.schema.propertyNames); if (!propertyNameSchema.deprecationMessage && !propertyNameSchema.doNotSuggest) { - const label = propertyNameSchema.title || 'property'; - collector.add({ - kind: CompletionItemKind.Property, - label, - insertText: '$' + `{1:${label}}: `, - insertTextFormat: InsertTextFormat.Snippet, - documentation: this.fromMarkup(propertyNameSchema.markdownDescription) || propertyNameSchema.description || '', - }); + const doc = this.fromMarkup( + (propertyNameSchema.markdownDescription || propertyNameSchema.description || '') + + (propertyNameSchema.pattern ? `\n\n**Pattern:** \`${propertyNameSchema.pattern}\`` : '') + ); + const { candidates, impossible } = this.getPropertyNamesCandidates(propertyNameSchema); + if (impossible) { + // suggest nothing + } else if (candidates.length) { + for (const key of candidates) { + collector.add({ + kind: CompletionItemKind.Property, + label: key, + insertText: `${key}: `, + insertTextFormat: InsertTextFormat.PlainText, + documentation: doc, + }); + } + } else { + const label = propertyNameSchema.title || 'property'; + collector.add({ + kind: CompletionItemKind.Property, + label, + insertText: '$' + `{1:${label}}: `, + insertTextFormat: InsertTextFormat.Snippet, + documentation: doc, + }); + } } } } @@ -1704,6 +1723,39 @@ export class YamlCompletion { return 0; } + private getPropertyNamesCandidates(schema: JSONSchemaRef): { candidates: string[]; impossible: boolean } { + let impossible = false; + + const collect = (node: JSONSchemaRef): Set | null => { + if (!node || typeof node !== 'object') return null; + + if (Array.isArray(node.allOf) && node.allOf.length) { + let intersection = null; + for (const part of node.allOf) { + const partSet = collect(part); + if (!partSet) continue; + intersection = intersection ? new Set([...intersection].filter((v) => partSet.has(v))) : new Set(partSet); + if (intersection.size === 0) { + impossible = true; + return new Set(); + } + } + if (intersection) return intersection; + } + + const result = new Set(); + if (typeof node.const === 'string') result.add(node.const); + node.enum?.forEach((val) => typeof val === 'string' && result.add(val)); + node.anyOf?.forEach((branch) => collect(branch)?.forEach((val) => result.add(val))); + node.oneOf?.forEach((branch) => collect(branch)?.forEach((val) => result.add(val))); + + return result.size ? result : null; + }; + + const set = collect(schema); + return { candidates: set ? [...set] : [], impossible }; + } + getQuote(): string { return this.isSingleQuote ? `'` : `"`; } diff --git a/test/autoCompletionFix.test.ts b/test/autoCompletionFix.test.ts index ef2bdcc48..adf2124b9 100644 --- a/test/autoCompletionFix.test.ts +++ b/test/autoCompletionFix.test.ts @@ -1478,7 +1478,7 @@ test1: expect(completion.items.length).equal(1); expect(completion.items[0].insertText).to.be.equal('${1:property}: '); - expect(completion.items[0].documentation).to.be.equal('Property Description'); + expect(completion.items[0].documentation).to.be.deep.equal({ kind: 'markdown', value: 'Property Description' }); }); it('should not suggest propertyNames with doNotSuggest', async () => { const schema: JSONSchema = { @@ -1515,6 +1515,103 @@ test1: expect(completion.items[1].insertText).to.be.equal('"NO"'); }); + it('should suggest propertyNames keys from definitions $ref', async () => { + const schema: JSONSchema = { + definitions: { + EventName: { + type: 'string', + title: 'EventName', + enum: ['None', 'Event1', 'Event2'], + }, + }, + type: 'object', + properties: { + events: { + type: 'object', + propertyNames: { $ref: '#/definitions/EventName' }, + }, + }, + required: ['events'], + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const completion = await parseSetup('events:\n ', 1, 2); + expect(completion.items.map((i) => i.label)).to.have.members(['None', 'Event1', 'Event2']); + }); + + it('should suggest propertyNames candidates from const', async () => { + const schema: JSONSchema = { + type: 'object', + propertyNames: { + const: 'Event0', + }, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const completion = await parseSetup('', 0, 0); + expect(completion.items.map((i) => i.label)).to.have.members(['Event0']); + }); + + it('should suggest propertyNames candidates from enum', async () => { + const schema: JSONSchema = { + type: 'object', + additionalProperties: true, + propertyNames: { + enum: ['Event1', 'Event2', 'Event3'], + }, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const completion = await parseSetup('', 0, 0); + expect(completion.items.map((i) => i.label)).to.have.members(['Event1', 'Event2', 'Event3']); + }); + + it('should suggest propertyNames candidates from oneOf', async () => { + const schema: JSONSchema = { + type: 'object', + propertyNames: { + oneOf: [{ const: 'Event1' }, { const: 'Event2' }], + }, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const completion = await parseSetup('', 0, 0); + expect(completion.items.map((i) => i.label)).to.have.members(['Event1', 'Event2']); + }); + + it('should suggest propertyNames candidates from anyOf', async () => { + const schema: JSONSchema = { + type: 'object', + propertyNames: { + anyOf: [{ const: 'Event1' }, { enum: ['Event2', 'Event3'] }], + }, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const completion = await parseSetup('', 0, 0); + expect(completion.items.map((i) => i.label)).to.have.members(['Event1', 'Event2', 'Event3']); + }); + + it('should suggest only the intersected propertyNames candidate from allOf (const + enum)', async () => { + const schema: JSONSchema = { + type: 'object', + propertyNames: { + allOf: [{ const: 'One' }, { enum: ['One', 'Two'] }], + }, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const completion = await parseSetup('', 0, 0); + expect(completion.items.map((i) => i.label)).to.have.members(['One']); + expect(completion.items.map((i) => i.label)).to.not.include('Two'); + }); + + it('should not suggest any propertyNames when allOf makes keys impossible (const + const)', async () => { + const schema: JSONSchema = { + type: 'object', + propertyNames: { + allOf: [{ const: 'One' }, { const: 'Two' }], + }, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const completion = await parseSetup('', 0, 0); + expect(completion.items).to.be.empty; + }); + describe('String scalar completion comprehensive tests', () => { const STRING_CASES: { value: string;