Skip to content

Commit c30c213

Browse files
Allow graceful matching by parameter and repair null values matching
1 parent 11eff5f commit c30c213

File tree

5 files changed

+118
-26
lines changed

5 files changed

+118
-26
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: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -614,13 +614,15 @@ export class JSONDocument {
614614
* @param focusOffset offsetValue
615615
* @param exclude excluded Node
616616
* @param didCallFromAutoComplete true if method called from AutoComplete
617+
* @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
617618
* @returns array of applicable schemas
618619
*/
619620
public getMatchingSchemas(
620621
schema: JSONSchema,
621622
focusOffset = -1,
622623
exclude: ASTNode = null,
623-
didCallFromAutoComplete?: boolean
624+
didCallFromAutoComplete?: boolean,
625+
gracefulMatches?: boolean
624626
): IApplicableSchema[] {
625627
const matchingSchemas = new SchemaCollector(focusOffset, exclude);
626628
if (this.root && schema) {
@@ -629,6 +631,7 @@ export class JSONDocument {
629631
disableAdditionalProperties: this.disableAdditionalProperties,
630632
uri: this.uri,
631633
callFromAutoComplete: didCallFromAutoComplete,
634+
gracefulMatches: gracefulMatches,
632635
});
633636
}
634637
return matchingSchemas.schemas;
@@ -639,6 +642,7 @@ interface Options {
639642
disableAdditionalProperties: boolean;
640643
uri: string;
641644
callFromAutoComplete?: boolean;
645+
gracefulMatches?: boolean;
642646
}
643647
function validate(
644648
node: ASTNode,
@@ -649,7 +653,7 @@ function validate(
649653
options: Options
650654
// eslint-disable-next-line @typescript-eslint/no-explicit-any
651655
): any {
652-
const { isKubernetes, callFromAutoComplete } = options;
656+
const { isKubernetes, callFromAutoComplete, gracefulMatches } = options;
653657
if (!node) {
654658
return;
655659
}
@@ -870,7 +874,7 @@ function validate(
870874
const val = getNodeValue(node);
871875
let enumValueMatch = false;
872876
for (const e of schema.enum) {
873-
if (val === e || (callFromAutoComplete && isString(val) && isString(e) && val && e.startsWith(val))) {
877+
if (val === e || (callFromAutoComplete && (val === null || (isString(val) && isString(e) && val && e.startsWith(val))))) {
874878
enumValueMatch = true;
875879
break;
876880
}
@@ -904,7 +908,7 @@ function validate(
904908
const val = getNodeValue(node);
905909
if (
906910
!equals(val, schema.const) &&
907-
!(callFromAutoComplete && isString(val) && isString(schema.const) && schema.const.startsWith(val))
911+
!(callFromAutoComplete && (val === null || (isString(val) && isString(schema.const) && schema.const.startsWith(val))))
908912
) {
909913
validationResult.problems.push({
910914
location: { offset: node.offset, length: node.length },
@@ -1503,10 +1507,14 @@ function validate(
15031507
return bestMatch;
15041508
}
15051509

1510+
function gracefulMatchFilter(maxOneMatch: boolean, propertiesValueMatches: number): boolean {
1511+
return gracefulMatches && !maxOneMatch && callFromAutoComplete && propertiesValueMatches > 0;
1512+
}
1513+
15061514
//genericComparison tries to find the best matching schema using a generic comparison
15071515
function genericComparison(
15081516
node: ASTNode,
1509-
maxOneMatch,
1517+
maxOneMatch: boolean,
15101518
subValidationResult: ValidationResult,
15111519
bestMatch: {
15121520
schema: JSONSchema;
@@ -1520,11 +1528,7 @@ function validate(
15201528
validationResult: ValidationResult;
15211529
matchingSchemas: ISchemaCollector;
15221530
} {
1523-
if (
1524-
!maxOneMatch &&
1525-
!subValidationResult.hasProblems() &&
1526-
(!bestMatch.validationResult.hasProblems() || callFromAutoComplete)
1527-
) {
1531+
if (!maxOneMatch && !subValidationResult.hasProblems() && !bestMatch.validationResult.hasProblems()) {
15281532
// no errors, both are equally good matches
15291533
bestMatch.matchingSchemas.merge(subMatchingSchemas);
15301534
bestMatch.validationResult.propertiesMatches += subValidationResult.propertiesMatches;
@@ -1545,7 +1549,11 @@ function validate(
15451549
validationResult: subValidationResult,
15461550
matchingSchemas: subMatchingSchemas,
15471551
};
1548-
} else if (compareResult === 0) {
1552+
} else if (
1553+
compareResult === 0 ||
1554+
((node.value === null || node.type === 'null') && node.length === 0) ||
1555+
gracefulMatchFilter(maxOneMatch, subValidationResult.propertiesValueMatches)
1556+
) {
15491557
// there's already a best matching but we are as good
15501558
bestMatch.matchingSchemas.merge(subMatchingSchemas);
15511559
bestMatch.validationResult.mergeEnumValues(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;
@@ -519,7 +525,8 @@ export class YamlCompletion {
519525
collector,
520526
textBuffer,
521527
overwriteRange,
522-
doComplete
528+
doComplete,
529+
gracefulMatches
523530
);
524531

525532
if (!schema && currentWord.length > 0 && text.charAt(offset - currentWord.length - 1) !== '"') {
@@ -534,7 +541,7 @@ export class YamlCompletion {
534541

535542
// proposals for values
536543
const types: { [type: string]: boolean } = {};
537-
this.getValueCompletions(schema, currentDoc, node, offset, document, collector, types, doComplete);
544+
this.getValueCompletions(schema, currentDoc, node, offset, document, collector, types, doComplete, gracefulMatches);
538545
} catch (err) {
539546
this.telemetry?.sendError('yaml.completion.error', err);
540547
}
@@ -674,9 +681,10 @@ export class YamlCompletion {
674681
collector: CompletionsCollector,
675682
textBuffer: TextBuffer,
676683
overwriteRange: Range,
677-
doComplete: boolean
684+
doComplete: boolean,
685+
gracefulMatches: boolean
678686
): void {
679-
const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete);
687+
const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete, gracefulMatches);
680688
const existingKey = textBuffer.getText(overwriteRange);
681689
const lineContent = textBuffer.getLineContent(overwriteRange.start.line);
682690
const hasOnlyWhitespace = lineContent.trim().length === 0;
@@ -874,7 +882,8 @@ export class YamlCompletion {
874882
document: TextDocument,
875883
collector: CompletionsCollector,
876884
types: { [type: string]: boolean },
877-
doComplete: boolean
885+
doComplete: boolean,
886+
gracefulMatches: boolean
878887
): void {
879888
let parentKey: string = null;
880889

@@ -898,7 +907,7 @@ export class YamlCompletion {
898907

899908
if (node && (parentKey !== null || isSeq(node))) {
900909
const separatorAfter = '';
901-
const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete);
910+
const matchingSchemas = doc.getMatchingSchemas(schema.schema, -1, null, doComplete, gracefulMatches);
902911
for (const s of matchingSchemas) {
903912
if (s.node.internalNode === node && !s.inverted && s.schema) {
904913
if (s.schema.items) {

src/languageservice/yamlLanguageService.ts

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