Skip to content

Commit ae8b8fa

Browse files
Allow graceful matching by parameter and repair null values matching
1 parent de78c0e commit ae8b8fa

File tree

5 files changed

+147
-54
lines changed

5 files changed

+147
-54
lines changed

src/languageserver/handlers/languageHandlers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export class LanguageHandlers {
156156
* Called when auto-complete is triggered in an editor
157157
* Returns a list of valid completion items
158158
*/
159-
completionHandler(textDocumentPosition: TextDocumentPositionParams): Promise<CompletionList> {
159+
completionHandler(textDocumentPosition: TextDocumentPositionParams, gracefulMatches = false): Promise<CompletionList> {
160160
const textDocument = this.yamlSettings.documents.get(textDocumentPosition.textDocument.uri);
161161

162162
const result: CompletionList = {
@@ -170,7 +170,9 @@ export class LanguageHandlers {
170170
return this.languageService.doComplete(
171171
textDocument,
172172
textDocumentPosition.position,
173-
isKubernetesAssociatedDocument(textDocument, this.yamlSettings.specificValidatorPaths)
173+
isKubernetesAssociatedDocument(textDocument, this.yamlSettings.specificValidatorPaths),
174+
true,
175+
gracefulMatches
174176
);
175177
}
176178

src/languageservice/parser/jsonParser07.ts

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -620,13 +620,15 @@ export class JSONDocument {
620620
* @param focusOffset offsetValue
621621
* @param exclude excluded Node
622622
* @param didCallFromAutoComplete true if method called from AutoComplete
623+
* @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
623624
* @returns array of applicable schemas
624625
*/
625626
public getMatchingSchemas(
626627
schema: JSONSchema,
627628
focusOffset = -1,
628629
exclude: ASTNode = null,
629-
didCallFromAutoComplete?: boolean
630+
didCallFromAutoComplete?: boolean,
631+
gracefulMatches?: boolean
630632
): IApplicableSchema[] {
631633
const matchingSchemas = new SchemaCollector(focusOffset, exclude);
632634
if (this.root && schema) {
@@ -635,6 +637,7 @@ export class JSONDocument {
635637
disableAdditionalProperties: this.disableAdditionalProperties,
636638
uri: this.uri,
637639
callFromAutoComplete: didCallFromAutoComplete,
640+
gracefulMatches: gracefulMatches,
638641
});
639642
}
640643
return matchingSchemas.schemas;
@@ -645,6 +648,7 @@ interface Options {
645648
disableAdditionalProperties: boolean;
646649
uri: string;
647650
callFromAutoComplete?: boolean;
651+
gracefulMatches?: boolean;
648652
}
649653
function validate(
650654
node: ASTNode,
@@ -655,7 +659,7 @@ function validate(
655659
options: Options
656660
// eslint-disable-next-line @typescript-eslint/no-explicit-any
657661
): any {
658-
const { isKubernetes, callFromAutoComplete } = options;
662+
const { isKubernetes, callFromAutoComplete, gracefulMatches } = options;
659663
if (!node) {
660664
return;
661665
}
@@ -1050,33 +1054,33 @@ function validate(
10501054
switch (schema.format) {
10511055
case 'uri':
10521056
case 'uri-reference':
1053-
{
1054-
let errorMessage;
1055-
if (!node.value) {
1056-
errorMessage = localize('uriEmpty', 'URI expected.');
1057-
} else {
1058-
try {
1059-
const uri = URI.parse(node.value);
1060-
if (!uri.scheme && schema.format === 'uri') {
1061-
errorMessage = localize('uriSchemeMissing', 'URI with a scheme is expected.');
1062-
}
1063-
} catch (e) {
1064-
errorMessage = e.message;
1057+
{
1058+
let errorMessage;
1059+
if (!node.value) {
1060+
errorMessage = localize('uriEmpty', 'URI expected.');
1061+
} else {
1062+
try {
1063+
const uri = URI.parse(node.value);
1064+
if (!uri.scheme && schema.format === 'uri') {
1065+
errorMessage = localize('uriSchemeMissing', 'URI with a scheme is expected.');
10651066
}
1067+
} catch (e) {
1068+
errorMessage = e.message;
10661069
}
1067-
if (errorMessage) {
1068-
validationResult.problems.push({
1069-
location: { offset: node.offset, length: node.length },
1070-
severity: DiagnosticSeverity.Warning,
1071-
message:
1072-
schema.patternErrorMessage ||
1073-
schema.errorMessage ||
1074-
localize('uriFormatWarning', 'String is not a URI: {0}', errorMessage),
1075-
source: getSchemaSource(schema, originalSchema),
1076-
schemaUri: getSchemaUri(schema, originalSchema),
1077-
});
1078-
}
10791070
}
1071+
if (errorMessage) {
1072+
validationResult.problems.push({
1073+
location: { offset: node.offset, length: node.length },
1074+
severity: DiagnosticSeverity.Warning,
1075+
message:
1076+
schema.patternErrorMessage ||
1077+
schema.errorMessage ||
1078+
localize('uriFormatWarning', 'String is not a URI: {0}', errorMessage),
1079+
source: getSchemaSource(schema, originalSchema),
1080+
schemaUri: getSchemaUri(schema, originalSchema),
1081+
});
1082+
}
1083+
}
10801084
break;
10811085
case 'color-hex':
10821086
case 'date-time':
@@ -1085,18 +1089,18 @@ function validate(
10851089
case 'email':
10861090
case 'ipv4':
10871091
case 'ipv6':
1088-
{
1089-
const format = formats[schema.format];
1090-
if (!node.value || !format.pattern.test(node.value)) {
1091-
validationResult.problems.push({
1092-
location: { offset: node.offset, length: node.length },
1093-
severity: DiagnosticSeverity.Warning,
1094-
message: schema.patternErrorMessage || schema.errorMessage || format.errorMessage,
1095-
source: getSchemaSource(schema, originalSchema),
1096-
schemaUri: getSchemaUri(schema, originalSchema),
1097-
});
1098-
}
1092+
{
1093+
const format = formats[schema.format];
1094+
if (!node.value || !format.pattern.test(node.value)) {
1095+
validationResult.problems.push({
1096+
location: { offset: node.offset, length: node.length },
1097+
severity: DiagnosticSeverity.Warning,
1098+
message: schema.patternErrorMessage || schema.errorMessage || format.errorMessage,
1099+
source: getSchemaSource(schema, originalSchema),
1100+
schemaUri: getSchemaUri(schema, originalSchema),
1101+
});
10991102
}
1103+
}
11001104
break;
11011105
default:
11021106
}
@@ -1520,10 +1524,14 @@ function validate(
15201524
return bestMatch;
15211525
}
15221526

1527+
function gracefulMatchFilter(maxOneMatch: boolean, propertiesValueMatches: number): boolean {
1528+
return gracefulMatches && !maxOneMatch && callFromAutoComplete && propertiesValueMatches > 0;
1529+
}
1530+
15231531
//genericComparison tries to find the best matching schema using a generic comparison
15241532
function genericComparison(
15251533
node: ASTNode,
1526-
maxOneMatch,
1534+
maxOneMatch: boolean,
15271535
subValidationResult: ValidationResult,
15281536
bestMatch: IValidationMatch,
15291537
subSchema,
@@ -1552,7 +1560,8 @@ function validate(
15521560
};
15531561
} else if (
15541562
compareResult === 0 ||
1555-
((node.value === null || node.type === 'null') && node.length === 0) // node with no value can match any schema potentially
1563+
((node.value === null || node.type === 'null') && node.length === 0) || // node with no value can match any schema potentially
1564+
gracefulMatchFilter(maxOneMatch, subValidationResult.propertiesValueMatches)
15561565
) {
15571566
// there's already a best matching but we are as good
15581567
mergeValidationMatches(bestMatch, subMatchingSchemas, subValidationResult);

src/languageservice/services/yamlCompletion.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,13 @@ export class YamlCompletion {
101101
this.parentSkeletonSelectedFirst = languageSettings.parentSkeletonSelectedFirst;
102102
}
103103

104-
async doComplete(document: TextDocument, position: Position, isKubernetes = false, doComplete = true): Promise<CompletionList> {
104+
async doComplete(
105+
document: TextDocument,
106+
position: Position,
107+
isKubernetes = false,
108+
doComplete = true,
109+
gracefulMatches = false
110+
): Promise<CompletionList> {
105111
const result = CompletionList.create([], false);
106112
if (!this.completionEnabled) {
107113
return result;
@@ -536,7 +542,8 @@ export class YamlCompletion {
536542
collector,
537543
textBuffer,
538544
overwriteRange,
539-
doComplete
545+
doComplete,
546+
gracefulMatches
540547
);
541548

542549
if (!schema && currentWord.length > 0 && text.charAt(offset - currentWord.length - 1) !== '"') {
@@ -551,7 +558,7 @@ export class YamlCompletion {
551558

552559
// proposals for values
553560
const types: { [type: string]: boolean } = {};
554-
this.getValueCompletions(schema, currentDoc, node, offset, document, collector, types, doComplete);
561+
this.getValueCompletions(schema, currentDoc, node, offset, document, collector, types, doComplete, gracefulMatches);
555562
} catch (err) {
556563
this.telemetry?.sendError('yaml.completion.error', err);
557564
}
@@ -691,9 +698,10 @@ export class YamlCompletion {
691698
collector: CompletionsCollector,
692699
textBuffer: TextBuffer,
693700
overwriteRange: Range,
694-
doComplete: boolean
701+
doComplete: boolean,
702+
gracefulMatches: boolean
695703
): void {
696-
const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete);
704+
const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete, gracefulMatches);
697705
const existingKey = textBuffer.getText(overwriteRange);
698706
const lineContent = textBuffer.getLineContent(overwriteRange.start.line);
699707
const hasOnlyWhitespace = lineContent.trim().length === 0;
@@ -893,7 +901,8 @@ export class YamlCompletion {
893901
document: TextDocument,
894902
collector: CompletionsCollector,
895903
types: { [type: string]: boolean },
896-
doComplete: boolean
904+
doComplete: boolean,
905+
gracefulMatches: boolean
897906
): void {
898907
let parentKey: string = null;
899908

@@ -917,7 +926,7 @@ export class YamlCompletion {
917926

918927
if (node && (parentKey !== null || isSeq(node))) {
919928
const separatorAfter = '';
920-
const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete);
929+
const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete, gracefulMatches);
921930
for (const s of matchingSchemas) {
922931
if (s.node.internalNode === node && !s.inverted && s.schema) {
923932
if (s.schema.items) {

src/languageservice/yamlLanguageService.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,13 @@ export interface CustomFormatterOptions {
160160
export interface LanguageService {
161161
configure: (settings: LanguageSettings) => void;
162162
registerCustomSchemaProvider: (schemaProvider: CustomSchemaProvider) => void;
163-
doComplete: (document: TextDocument, position: Position, isKubernetes: boolean) => Promise<CompletionList>;
163+
doComplete: (
164+
document: TextDocument,
165+
position: Position,
166+
isKubernetes: boolean,
167+
doComplete?: boolean,
168+
gracefulMatches?: boolean
169+
) => Promise<CompletionList>;
164170
doValidation: (document: TextDocument, isKubernetes: boolean) => Promise<Diagnostic[]>;
165171
doHover: (document: TextDocument, position: Position) => Promise<Hover | null>;
166172
findDocumentSymbols: (document: TextDocument, context?: DocumentSymbolsContext) => SymbolInformation[];

test/autoCompletion.test.ts

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,20 +52,24 @@ describe('Auto Completion Tests', () => {
5252
* @param position The position of the caret in the document.
5353
* Alternatively, `position` can be omitted if the caret is located in the content using `|` bookends.
5454
* For example, `content = 'ab|c|d'` places the caret over the `'c'`, at `position = 2`
55+
* @param gracefulMatches are worse matches allowed
5556
* @returns A list of valid completions.
5657
*/
57-
function parseSetup(content: string, position?: number): Promise<CompletionList> {
58+
function parseSetup(content: string, position?: number, gracefulMatches?: boolean): Promise<CompletionList> {
5859
if (typeof position === 'undefined') {
5960
({ content, position } = caretPosition(content));
6061
}
6162

6263
const testTextDocument = setupSchemaIDTextDocument(content);
6364
yamlSettings.documents = new TextDocumentTestManager();
6465
(yamlSettings.documents as TextDocumentTestManager).set(testTextDocument);
65-
return languageHandler.completionHandler({
66-
position: testTextDocument.positionAt(position),
67-
textDocument: testTextDocument,
68-
});
66+
return languageHandler.completionHandler(
67+
{
68+
position: testTextDocument.positionAt(position),
69+
textDocument: testTextDocument,
70+
},
71+
gracefulMatches
72+
);
6973
}
7074

7175
afterEach(() => {
@@ -2217,6 +2221,69 @@ describe('Auto Completion Tests', () => {
22172221
expect(completion.items[0].insertText).eq('fooBar:\n name: $1\n aaa:\n - $2');
22182222
});
22192223

2224+
it('graceful matching should give multiple matches with anyOf where at least one property is matching', async () => {
2225+
schemaProvider.addSchema(SCHEMA_ID, {
2226+
anyOf: [
2227+
{
2228+
type: 'object',
2229+
properties: {
2230+
type: {
2231+
const: 'a.a',
2232+
},
2233+
maybeProp: {
2234+
type: 'string',
2235+
},
2236+
},
2237+
required: ['type'],
2238+
},
2239+
{
2240+
type: 'object',
2241+
properties: {
2242+
type: {
2243+
const: 'a.b',
2244+
},
2245+
field: {
2246+
type: 'string',
2247+
},
2248+
},
2249+
required: ['type', 'field'],
2250+
},
2251+
{
2252+
type: 'object',
2253+
properties: {
2254+
type: {
2255+
const: 'a.c',
2256+
},
2257+
mandatoryProp: {
2258+
type: 'string',
2259+
},
2260+
},
2261+
required: ['type', 'mandatoryProp'],
2262+
},
2263+
],
2264+
});
2265+
2266+
async function expectCompletionsOrAll(
2267+
content: string,
2268+
gracefulMatches: boolean,
2269+
expectedCompletions = ['a.a', 'a.b', 'a.c']
2270+
): Promise<void> {
2271+
const completion = await parseSetup(content, undefined, gracefulMatches);
2272+
expect(completion.items).lengthOf(expectedCompletions.length);
2273+
expect(completion.items.map(({ label }) => label)).eql(expectedCompletions);
2274+
}
2275+
2276+
await expectCompletionsOrAll('type: |\n|', false, ['a.a']);
2277+
await expectCompletionsOrAll('type: a.|\n|', false, ['a.a']);
2278+
await expectCompletionsOrAll('type: |\n|field: "a"', false, ['a.a', 'a.b']);
2279+
await expectCompletionsOrAll('type: |\n|mandatoryProp: "a"', false, ['a.a', 'a.c']);
2280+
2281+
await expectCompletionsOrAll('type: |\n|', true);
2282+
await expectCompletionsOrAll('type: a.|\n|', true);
2283+
await expectCompletionsOrAll('type: |\n|field: "a"', true);
2284+
await expectCompletionsOrAll('type: |\n|mandatoryProp: "a"', true);
2285+
});
2286+
22202287
it('auto completion based on the list indentation', async () => {
22212288
schemaProvider.addSchema(SCHEMA_ID, {
22222289
type: 'array',

0 commit comments

Comments
 (0)