Skip to content

Commit 93c76b5

Browse files
authored
fix: anyOf compare alternatives (#1037)
1 parent 4602af7 commit 93c76b5

File tree

2 files changed

+204
-28
lines changed

2 files changed

+204
-28
lines changed

src/languageservice/parser/jsonParser07.ts

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,12 @@ export function findNodeAtOffset(node: ASTNode, offset: number, includeRightBoun
537537
return undefined;
538538
}
539539

540+
interface IValidationMatch {
541+
schema: JSONSchema;
542+
validationResult: ValidationResult;
543+
matchingSchemas: ISchemaCollector;
544+
}
545+
540546
export class JSONDocument {
541547
public isKubernetes: boolean;
542548
public disableAdditionalProperties: boolean;
@@ -870,7 +876,7 @@ function validate(
870876
const val = getNodeValue(node);
871877
let enumValueMatch = false;
872878
for (const e of schema.enum) {
873-
if (val === e || (callFromAutoComplete && isString(val) && isString(e) && val && e.startsWith(val))) {
879+
if (val === e || isAutoCompleteEqualMaybe(callFromAutoComplete, node, val, e)) {
874880
enumValueMatch = true;
875881
break;
876882
}
@@ -902,10 +908,7 @@ function validate(
902908

903909
if (isDefined(schema.const)) {
904910
const val = getNodeValue(node);
905-
if (
906-
!equals(val, schema.const) &&
907-
!(callFromAutoComplete && isString(val) && isString(schema.const) && schema.const.startsWith(val))
908-
) {
911+
if (!equals(val, schema.const) && !isAutoCompleteEqualMaybe(callFromAutoComplete, node, val, schema.const)) {
909912
validationResult.problems.push({
910913
location: { offset: node.offset, length: node.length },
911914
severity: DiagnosticSeverity.Warning,
@@ -1508,23 +1511,11 @@ function validate(
15081511
node: ASTNode,
15091512
maxOneMatch,
15101513
subValidationResult: ValidationResult,
1511-
bestMatch: {
1512-
schema: JSONSchema;
1513-
validationResult: ValidationResult;
1514-
matchingSchemas: ISchemaCollector;
1515-
},
1514+
bestMatch: IValidationMatch,
15161515
subSchema,
15171516
subMatchingSchemas
1518-
): {
1519-
schema: JSONSchema;
1520-
validationResult: ValidationResult;
1521-
matchingSchemas: ISchemaCollector;
1522-
} {
1523-
if (
1524-
!maxOneMatch &&
1525-
!subValidationResult.hasProblems() &&
1526-
(!bestMatch.validationResult.hasProblems() || callFromAutoComplete)
1527-
) {
1517+
): IValidationMatch {
1518+
if (!maxOneMatch && !subValidationResult.hasProblems() && !bestMatch.validationResult.hasProblems()) {
15281519
// no errors, both are equally good matches
15291520
bestMatch.matchingSchemas.merge(subMatchingSchemas);
15301521
bestMatch.validationResult.propertiesMatches += subValidationResult.propertiesMatches;
@@ -1545,19 +1536,30 @@ function validate(
15451536
validationResult: subValidationResult,
15461537
matchingSchemas: subMatchingSchemas,
15471538
};
1548-
} else if (compareResult === 0) {
1539+
} else if (
1540+
compareResult === 0 ||
1541+
((node.value === null || node.type === 'null') && node.length === 0) // node with no value can match any schema potentially
1542+
) {
15491543
// there's already a best matching but we are as good
1550-
bestMatch.matchingSchemas.merge(subMatchingSchemas);
1551-
bestMatch.validationResult.mergeEnumValues(subValidationResult);
1552-
bestMatch.validationResult.mergeWarningGeneric(subValidationResult, [
1553-
ProblemType.missingRequiredPropWarning,
1554-
ProblemType.typeMismatchWarning,
1555-
ProblemType.constWarning,
1556-
]);
1544+
mergeValidationMatches(bestMatch, subMatchingSchemas, subValidationResult);
15571545
}
15581546
}
15591547
return bestMatch;
15601548
}
1549+
1550+
function mergeValidationMatches(
1551+
bestMatch: IValidationMatch,
1552+
subMatchingSchemas: ISchemaCollector,
1553+
subValidationResult: ValidationResult
1554+
): void {
1555+
bestMatch.matchingSchemas.merge(subMatchingSchemas);
1556+
bestMatch.validationResult.mergeEnumValues(subValidationResult);
1557+
bestMatch.validationResult.mergeWarningGeneric(subValidationResult, [
1558+
ProblemType.missingRequiredPropWarning,
1559+
ProblemType.typeMismatchWarning,
1560+
ProblemType.constWarning,
1561+
]);
1562+
}
15611563
}
15621564

15631565
function getSchemaSource(schema: JSONSchema, originalSchema: JSONSchema): string | undefined {
@@ -1595,3 +1597,26 @@ function getSchemaUri(schema: JSONSchema, originalSchema: JSONSchema): string[]
15951597
function getWarningMessage(problemType: ProblemType, args: string[]): string {
15961598
return localize(problemType, ProblemTypeMessages[problemType], args.join(' | '));
15971599
}
1600+
1601+
/**
1602+
* if callFromAutoComplete than compare value from yaml and value from schema (s.const | s.enum[i])
1603+
* allows partial match for autocompletion
1604+
*/
1605+
function isAutoCompleteEqualMaybe(
1606+
callFromAutoComplete: boolean,
1607+
node: ASTNode,
1608+
nodeValue: unknown,
1609+
schemaValue: unknown
1610+
): boolean {
1611+
if (!callFromAutoComplete) {
1612+
return false;
1613+
}
1614+
1615+
// if autocompletion property doesn't have value, then it could be a match
1616+
const isWithoutValue = nodeValue === null && node.length === 0; // allows `prop: ` but ignore `prop: null`
1617+
if (isWithoutValue) {
1618+
return true;
1619+
}
1620+
1621+
return isString(nodeValue) && isString(schemaValue) && schemaValue.startsWith(nodeValue);
1622+
}

test/autoCompletionFix.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,157 @@ objB:
759759
expect(completion.items[0].label).to.be.equal('prop');
760760
expect(completion.items[0].insertText).to.be.equal('prop: ${1|const value,null|}');
761761
});
762+
it('should take all sub-schemas when value has not been set (cursor in the middle of the empty space)', async () => {
763+
const schema: JSONSchema = {
764+
anyOf: [
765+
{
766+
properties: {
767+
prop: { type: 'null' },
768+
},
769+
},
770+
{
771+
properties: {
772+
prop: { const: 'const value' },
773+
},
774+
},
775+
{
776+
properties: {
777+
prop: { const: 5 },
778+
},
779+
},
780+
],
781+
};
782+
schemaProvider.addSchema(SCHEMA_ID, schema);
783+
const content = 'prop: | | ';
784+
const completion = await parseCaret(content);
785+
expect(completion.items.map((i) => i.label)).to.be.deep.eq(['const value', '5', 'null']);
786+
});
787+
it('should take only null sub-schema when value is "null"', async () => {
788+
const schema: JSONSchema = {
789+
anyOf: [
790+
{
791+
properties: {
792+
prop: { type: 'null' },
793+
},
794+
},
795+
{
796+
properties: {
797+
prop: { const: 'const value' },
798+
},
799+
},
800+
],
801+
};
802+
schemaProvider.addSchema(SCHEMA_ID, schema);
803+
const content = 'prop: null';
804+
const completion = await parseSetup(content, 0, content.length);
805+
expect(completion.items.map((i) => i.label)).to.be.deep.eq(['null']);
806+
});
807+
it('should take only one sub-schema because first sub-schema does not match', async () => {
808+
const schema: JSONSchema = {
809+
anyOf: [
810+
{
811+
properties: {
812+
prop: { const: 'const value' },
813+
},
814+
},
815+
{
816+
properties: {
817+
prop: { const: 'const value2' },
818+
},
819+
},
820+
],
821+
};
822+
schemaProvider.addSchema(SCHEMA_ID, schema);
823+
const content = 'prop: const value2';
824+
const completion = await parseSetup(content, 0, content.length);
825+
expect(completion.items.map((i) => i.label)).to.be.deep.eq(['const value2']);
826+
});
827+
it('should match only second sub-schema because the first one does not match', async () => {
828+
const schema: JSONSchema = {
829+
anyOf: [
830+
{
831+
properties: {
832+
prop: {
833+
const: 'typeA',
834+
},
835+
propA: {},
836+
},
837+
},
838+
{
839+
properties: {
840+
prop: {
841+
const: 'typeB',
842+
},
843+
propB: {},
844+
},
845+
},
846+
],
847+
};
848+
schemaProvider.addSchema(SCHEMA_ID, schema);
849+
const content = 'prop: typeB\n|\n|';
850+
const completion = await parseCaret(content);
851+
expect(completion.items.map((i) => i.label)).to.be.deep.eq(['propB']);
852+
});
853+
it('should suggest from all sub-schemas even if nodes properties match better other schema', async () => {
854+
// this is a case when we have a better match in the second schema but we should still suggest from the first one
855+
// it works because `prop: ` will evaluate to `enumValueMatch = true` for both schemas
856+
const schema: JSONSchema = {
857+
anyOf: [
858+
{
859+
properties: {
860+
prop: {
861+
const: 'typeA',
862+
},
863+
},
864+
},
865+
{
866+
properties: {
867+
prop: {
868+
const: 'typeB',
869+
},
870+
propB: {},
871+
},
872+
},
873+
],
874+
};
875+
schemaProvider.addSchema(SCHEMA_ID, schema);
876+
const content = 'prop: |\n|\npropB: B';
877+
const completion = await parseCaret(content);
878+
expect(completion.items.map((i) => i.label)).to.be.deep.eq(['typeA', 'typeB'], 'with null value');
879+
880+
const content2 = 'prop: typ|\n|\npropB: B';
881+
const completion2 = await parseCaret(content2);
882+
expect(completion2.items.map((i) => i.label)).to.be.deep.eq(['typeA', 'typeB'], 'with prefix value');
883+
});
884+
885+
it('should suggest both sub-schemas for anyof array', async () => {
886+
const schema: JSONSchema = {
887+
properties: {
888+
entities: {
889+
type: 'array',
890+
items: {
891+
anyOf: [
892+
{
893+
enum: ['enum1'],
894+
},
895+
{
896+
type: 'object',
897+
title: 'entity object',
898+
properties: {
899+
entityProp: { type: 'string' },
900+
},
901+
required: ['entityProp'],
902+
},
903+
],
904+
},
905+
},
906+
},
907+
};
908+
schemaProvider.addSchema(SCHEMA_ID, schema);
909+
const content = 'entities:\n - |\n|';
910+
const completion = await parseCaret(content);
911+
expect(completion.items.map((i) => i.label)).to.be.deep.eq(['enum1', 'entityProp', 'entity object']);
912+
});
762913
});
763914

764915
describe('extra space after cursor', () => {

0 commit comments

Comments
 (0)