diff --git a/src/languageserver/handlers/languageHandlers.ts b/src/languageserver/handlers/languageHandlers.ts index 7f971167..dc22deb1 100644 --- a/src/languageserver/handlers/languageHandlers.ts +++ b/src/languageserver/handlers/languageHandlers.ts @@ -156,7 +156,7 @@ export class LanguageHandlers { * Called when auto-complete is triggered in an editor * Returns a list of valid completion items */ - completionHandler(textDocumentPosition: TextDocumentPositionParams): Promise { + completionHandler(textDocumentPosition: TextDocumentPositionParams, gracefulMatches = false): Promise { const textDocument = this.yamlSettings.documents.get(textDocumentPosition.textDocument.uri); const result: CompletionList = { @@ -170,7 +170,9 @@ export class LanguageHandlers { return this.languageService.doComplete( textDocument, textDocumentPosition.position, - isKubernetesAssociatedDocument(textDocument, this.yamlSettings.specificValidatorPaths) + isKubernetesAssociatedDocument(textDocument, this.yamlSettings.specificValidatorPaths), + true, + gracefulMatches ); } diff --git a/src/languageservice/parser/jsonParser07.ts b/src/languageservice/parser/jsonParser07.ts index 4b9401a4..b23970d0 100644 --- a/src/languageservice/parser/jsonParser07.ts +++ b/src/languageservice/parser/jsonParser07.ts @@ -81,6 +81,7 @@ export const ProblemTypeMessages: Record = { [ProblemType.typeMismatchWarning]: 'Incorrect type. Expected "{0}".', [ProblemType.constWarning]: 'Value must be {0}.', }; + export interface IProblem { location: IRange; severity: DiagnosticSeverity; @@ -158,6 +159,7 @@ export abstract class ASTNodeImpl { export class NullASTNodeImpl extends ASTNodeImpl implements NullASTNode { public type: 'null' = 'null' as const; public value = null; + constructor(parent: ASTNode, internalNode: Node, offset: number, length?: number) { super(parent, internalNode, offset, length); } @@ -278,27 +280,36 @@ export enum EnumMatch { export interface ISchemaCollector { schemas: IApplicableSchema[]; + add(schema: IApplicableSchema): void; + merge(other: ISchemaCollector): void; + include(node: ASTNode): boolean; + newSub(): ISchemaCollector; } class SchemaCollector implements ISchemaCollector { schemas: IApplicableSchema[] = []; + constructor( private focusOffset = -1, private exclude: ASTNode = null ) {} + add(schema: IApplicableSchema): void { this.schemas.push(schema); } + merge(other: ISchemaCollector): void { this.schemas.push(...other.schemas); } + include(node: ASTNode): boolean { return (this.focusOffset === -1 || contains(node, this.focusOffset)) && node !== this.exclude; } + newSub(): ISchemaCollector { return new SchemaCollector(-1, this.exclude); } @@ -308,22 +319,27 @@ class NoOpSchemaCollector implements ISchemaCollector { private constructor() { // ignore } + // eslint-disable-next-line @typescript-eslint/no-explicit-any get schemas(): any[] { return []; } + // eslint-disable-next-line @typescript-eslint/no-unused-vars add(schema: IApplicableSchema): void { // ignore } + // eslint-disable-next-line @typescript-eslint/no-unused-vars merge(other: ISchemaCollector): void { // ignore } + // eslint-disable-next-line @typescript-eslint/no-unused-vars include(node: ASTNode): boolean { return true; } + newSub(): ISchemaCollector { return this; } @@ -616,13 +632,15 @@ export class JSONDocument { * @param focusOffset offsetValue * @param exclude excluded Node * @param didCallFromAutoComplete true if method called from AutoComplete + * @param gracefulMatches true if graceful matching should be done, meaning that if at least one property is validated in a sub schema, it's kept as a candidate * @returns array of applicable schemas */ public getMatchingSchemas( schema: JSONSchema, focusOffset = -1, exclude: ASTNode = null, - didCallFromAutoComplete?: boolean + didCallFromAutoComplete?: boolean, + gracefulMatches?: boolean ): IApplicableSchema[] { const matchingSchemas = new SchemaCollector(focusOffset, exclude); if (this.root && schema) { @@ -631,17 +649,21 @@ export class JSONDocument { disableAdditionalProperties: this.disableAdditionalProperties, uri: this.uri, callFromAutoComplete: didCallFromAutoComplete, + gracefulMatches: gracefulMatches, }); } return matchingSchemas.schemas; } } + interface Options { isKubernetes: boolean; disableAdditionalProperties: boolean; uri: string; callFromAutoComplete?: boolean; + gracefulMatches?: boolean; } + function validate( node: ASTNode, schema: JSONSchema, @@ -651,7 +673,7 @@ function validate( options: Options // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any { - const { isKubernetes, callFromAutoComplete } = options; + const { isKubernetes, callFromAutoComplete, gracefulMatches } = options; if (!node) { return; } @@ -942,6 +964,7 @@ function validate( }); } } + function getExclusiveLimit(limit: number | undefined, exclusive: boolean | number | undefined): number | undefined { if (isNumber(exclusive)) { return exclusive; @@ -951,12 +974,14 @@ function validate( } return undefined; } + function getLimit(limit: number | undefined, exclusive: boolean | number | undefined): number | undefined { if (!isBoolean(exclusive) || !exclusive) { return limit; } return undefined; } + const exclusiveMinimum = getExclusiveLimit(schema.minimum, schema.exclusiveMinimum); if (isNumber(exclusiveMinimum) && val <= exclusiveMinimum) { validationResult.problems.push({ @@ -1086,6 +1111,7 @@ function validate( } } } + function _validateArrayNode( node: ArrayASTNode, schema: JSONSchema, @@ -1247,7 +1273,12 @@ function validate( for (const propertyName of schema.required) { if (seenKeys[propertyName] === undefined) { const keyNode = node.parent && node.parent.type === 'property' && node.parent.keyNode; - const location = keyNode ? { offset: keyNode.offset, length: keyNode.length } : { offset: node.offset, length: 1 }; + const location = keyNode + ? { offset: keyNode.offset, length: keyNode.length } + : { + offset: node.offset, + length: 1, + }; validationResult.problems.push({ location: location, severity: DiagnosticSeverity.Warning, @@ -1490,10 +1521,14 @@ function validate( return bestMatch; } + function gracefulMatchFilter(maxOneMatch: boolean, propertiesValueMatches: number): boolean { + return gracefulMatches && !maxOneMatch && callFromAutoComplete && propertiesValueMatches > 0; + } + //genericComparison tries to find the best matching schema using a generic comparison function genericComparison( node: ASTNode, - maxOneMatch, + maxOneMatch: boolean, subValidationResult: ValidationResult, bestMatch: IValidationMatch, subSchema, @@ -1522,7 +1557,8 @@ function validate( }; } else if ( compareResult === 0 || - ((node.value === null || node.type === 'null') && node.length === 0) // node with no value can match any schema potentially + ((node.value === null || node.type === 'null') && node.length === 0) || // node with no value can match any schema potentially + gracefulMatchFilter(maxOneMatch, subValidationResult.propertiesValueMatches) ) { // there's already a best matching but we are as good mergeValidationMatches(bestMatch, subMatchingSchemas, subValidationResult); diff --git a/src/languageservice/services/yamlCompletion.ts b/src/languageservice/services/yamlCompletion.ts index dfd6f395..51d3596b 100644 --- a/src/languageservice/services/yamlCompletion.ts +++ b/src/languageservice/services/yamlCompletion.ts @@ -99,7 +99,13 @@ export class YamlCompletion { this.parentSkeletonSelectedFirst = languageSettings.parentSkeletonSelectedFirst; } - async doComplete(document: TextDocument, position: Position, isKubernetes = false, doComplete = true): Promise { + async doComplete( + document: TextDocument, + position: Position, + isKubernetes = false, + doComplete = true, + gracefulMatches = false + ): Promise { const result = CompletionList.create([], false); if (!this.completionEnabled) { return result; @@ -534,7 +540,8 @@ export class YamlCompletion { collector, textBuffer, overwriteRange, - doComplete + doComplete, + gracefulMatches ); if (!schema && currentWord.length > 0 && text.charAt(offset - currentWord.length - 1) !== '"') { @@ -549,7 +556,7 @@ export class YamlCompletion { // proposals for values const types: { [type: string]: boolean } = {}; - this.getValueCompletions(schema, currentDoc, node, offset, document, collector, types, doComplete); + this.getValueCompletions(schema, currentDoc, node, offset, document, collector, types, doComplete, gracefulMatches); } catch (err) { this.telemetry?.sendError('yaml.completion.error', err); } @@ -689,9 +696,10 @@ export class YamlCompletion { collector: CompletionsCollector, textBuffer: TextBuffer, overwriteRange: Range, - doComplete: boolean + doComplete: boolean, + gracefulMatches: boolean ): void { - const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete); + const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete, gracefulMatches); const existingKey = textBuffer.getText(overwriteRange); const lineContent = textBuffer.getLineContent(overwriteRange.start.line); const hasOnlyWhitespace = lineContent.trim().length === 0; @@ -891,7 +899,8 @@ export class YamlCompletion { document: TextDocument, collector: CompletionsCollector, types: { [type: string]: boolean }, - doComplete: boolean + doComplete: boolean, + gracefulMatches: boolean ): void { let parentKey: string = null; @@ -915,7 +924,7 @@ export class YamlCompletion { if (node && (parentKey !== null || isSeq(node))) { const separatorAfter = ''; - const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete); + const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete, gracefulMatches); for (const s of matchingSchemas) { if (s.node.internalNode === node && !s.inverted && s.schema) { if (s.schema.items) { diff --git a/src/languageservice/yamlLanguageService.ts b/src/languageservice/yamlLanguageService.ts index fd414a31..b2e7fbaf 100644 --- a/src/languageservice/yamlLanguageService.ts +++ b/src/languageservice/yamlLanguageService.ts @@ -160,7 +160,13 @@ export interface CustomFormatterOptions { export interface LanguageService { configure: (settings: LanguageSettings) => void; registerCustomSchemaProvider: (schemaProvider: CustomSchemaProvider) => void; - doComplete: (document: TextDocument, position: Position, isKubernetes: boolean) => Promise; + doComplete: ( + document: TextDocument, + position: Position, + isKubernetes: boolean, + doComplete?: boolean, + gracefulMatches?: boolean + ) => Promise; doValidation: (document: TextDocument, isKubernetes: boolean) => Promise; doHover: (document: TextDocument, position: Position) => Promise; findDocumentSymbols: (document: TextDocument, context?: DocumentSymbolsContext) => SymbolInformation[]; diff --git a/test/autoCompletion.test.ts b/test/autoCompletion.test.ts index e0cae5c3..b97290c7 100644 --- a/test/autoCompletion.test.ts +++ b/test/autoCompletion.test.ts @@ -52,9 +52,10 @@ describe('Auto Completion Tests', () => { * @param position The position of the caret in the document. * Alternatively, `position` can be omitted if the caret is located in the content using `|` bookends. * For example, `content = 'ab|c|d'` places the caret over the `'c'`, at `position = 2` + * @param gracefulMatches are worse matches allowed * @returns A list of valid completions. */ - function parseSetup(content: string, position?: number): Promise { + function parseSetup(content: string, position?: number, gracefulMatches?: boolean): Promise { if (typeof position === 'undefined') { ({ content, position } = caretPosition(content)); } @@ -62,10 +63,13 @@ describe('Auto Completion Tests', () => { const testTextDocument = setupSchemaIDTextDocument(content); yamlSettings.documents = new TextDocumentTestManager(); (yamlSettings.documents as TextDocumentTestManager).set(testTextDocument); - return languageHandler.completionHandler({ - position: testTextDocument.positionAt(position), - textDocument: testTextDocument, - }); + return languageHandler.completionHandler( + { + position: testTextDocument.positionAt(position), + textDocument: testTextDocument, + }, + gracefulMatches + ); } afterEach(() => { @@ -2217,6 +2221,69 @@ describe('Auto Completion Tests', () => { expect(completion.items[0].insertText).eq('fooBar:\n name: $1\n aaa:\n - $2'); }); + it('graceful matching should give multiple matches with anyOf where at least one property is matching', async () => { + schemaProvider.addSchema(SCHEMA_ID, { + anyOf: [ + { + type: 'object', + properties: { + type: { + const: 'a.a', + }, + maybeProp: { + type: 'string', + }, + }, + required: ['type'], + }, + { + type: 'object', + properties: { + type: { + const: 'a.b', + }, + field: { + type: 'string', + }, + }, + required: ['type', 'field'], + }, + { + type: 'object', + properties: { + type: { + const: 'a.c', + }, + mandatoryProp: { + type: 'string', + }, + }, + required: ['type', 'mandatoryProp'], + }, + ], + }); + + async function expectCompletionsOrAll( + content: string, + gracefulMatches: boolean, + expectedCompletions = ['a.a', 'a.b', 'a.c'] + ): Promise { + const completion = await parseSetup(content, undefined, gracefulMatches); + expect(completion.items).lengthOf(expectedCompletions.length); + expect(completion.items.map(({ label }) => label)).eql(expectedCompletions); + } + + await expectCompletionsOrAll('type: |\n|', false, ['a.a']); + await expectCompletionsOrAll('type: a.|\n|', false, ['a.a']); + await expectCompletionsOrAll('type: |\n|field: "a"', false, ['a.a', 'a.b']); + await expectCompletionsOrAll('type: |\n|mandatoryProp: "a"', false, ['a.a', 'a.c']); + + await expectCompletionsOrAll('type: |\n|', true); + await expectCompletionsOrAll('type: a.|\n|', true); + await expectCompletionsOrAll('type: |\n|field: "a"', true); + await expectCompletionsOrAll('type: |\n|mandatoryProp: "a"', true); + }); + it('auto completion based on the list indentation', async () => { schemaProvider.addSchema(SCHEMA_ID, { type: 'array',