Skip to content

Commit 1a62de4

Browse files
committed
add support for draft 2020 keywords: prefixItems + items, and updates to contains and unevaluatedItems
Signed-off-by: Morgan Chang <[email protected]>
1 parent ae5acdb commit 1a62de4

File tree

4 files changed

+366
-4
lines changed

4 files changed

+366
-4
lines changed

src/languageservice/jsonSchema.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@ export interface JSONSchema {
3131
description?: string;
3232
properties?: JSONSchemaMap;
3333
patternProperties?: JSONSchemaMap;
34-
additionalProperties?: boolean | JSONSchemaRef;
34+
additionalProperties?: JSONSchemaRef;
3535
minProperties?: number;
3636
maxProperties?: number;
3737
dependencies?: JSONSchemaMap | { [prop: string]: string[] };
3838
items?: JSONSchemaRef | JSONSchemaRef[];
3939
minItems?: number;
4040
maxItems?: number;
4141
uniqueItems?: boolean;
42-
additionalItems?: boolean | JSONSchemaRef;
42+
additionalItems?: JSONSchemaRef;
4343
pattern?: string;
4444
minLength?: number;
4545
maxLength?: number;
@@ -80,8 +80,8 @@ export interface JSONSchema {
8080
$recursiveRef?: string;
8181
$vocabulary?: Record<string, boolean>;
8282
dependentSchemas?: JSONSchemaMap;
83-
unevaluatedItems?: boolean | JSONSchemaRef;
84-
unevaluatedProperties?: boolean | JSONSchemaRef;
83+
unevaluatedItems?: JSONSchemaRef;
84+
unevaluatedProperties?: JSONSchemaRef;
8585
dependentRequired?: Record<string, string[]>;
8686
minContains?: number;
8787
maxContains?: number;
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) IBM Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
import type { JSONSchema, JSONSchemaRef } from '../../jsonSchema';
6+
import type { ASTNode, ArrayASTNode } from '../../jsonASTTypes';
7+
import { isNumber } from '../../utils/objects';
8+
import * as l10n from '@vscode/l10n';
9+
import { DiagnosticSeverity } from 'vscode-languageserver-types';
10+
import { Draft2019Validator } from './draft2019Validator';
11+
import type { ISchemaCollector, Options } from './baseValidator';
12+
import { ValidationResult, asSchema } from './baseValidator';
13+
14+
export class Draft2020Validator extends Draft2019Validator {
15+
/**
16+
* Keyword: prefixItems + items
17+
*/
18+
protected override validateArrayNode(
19+
node: ArrayASTNode,
20+
schema: JSONSchema,
21+
originalSchema: JSONSchema,
22+
validationResult: ValidationResult,
23+
matchingSchemas: ISchemaCollector,
24+
options: Options
25+
): void {
26+
const items = (node.items ?? []) as ASTNode[];
27+
// prefixItems/items/contains contribute to evaluatedItems
28+
validationResult.evaluatedItems ??= new Set<number>();
29+
30+
const prefixItems = schema.prefixItems;
31+
// validate prefixItems
32+
if (Array.isArray(prefixItems)) {
33+
const limit = Math.min(prefixItems.length, items.length);
34+
for (let i = 0; i < limit; i++) {
35+
const subSchema = asSchema(prefixItems[i]);
36+
if (!subSchema) {
37+
validationResult.evaluatedItems.add(i);
38+
continue;
39+
}
40+
const itemValidationResult = new ValidationResult(options.isKubernetes);
41+
this.validateNode(items[i], subSchema, schema, itemValidationResult, matchingSchemas, options);
42+
43+
validationResult.mergePropertyMatch(itemValidationResult);
44+
validationResult.mergeEnumValues(itemValidationResult);
45+
46+
// mark as evaluated even if invalid (avoids duplicate unevaluatedItems noise)
47+
validationResult.evaluatedItems.add(i);
48+
}
49+
}
50+
51+
// validate remaining items against items
52+
const itemsKeyword = schema.items;
53+
const prefixLen = Array.isArray(prefixItems) ? prefixItems.length : 0;
54+
if (items.length > prefixLen) {
55+
if (itemsKeyword === false) {
56+
// "items": false => no items allowed beyond prefixItems
57+
validationResult.problems.push({
58+
location: { offset: node.offset, length: node.length },
59+
severity: DiagnosticSeverity.Warning,
60+
message: l10n.t('additionalItemsWarning', prefixLen),
61+
source: this.getSchemaSource(schema, originalSchema),
62+
schemaUri: this.getSchemaUri(schema, originalSchema),
63+
});
64+
65+
// mark these as evaluated by "items": false (so unevaluatedItems doesn't also complain)
66+
for (let i = prefixLen; i < items.length; i++) {
67+
validationResult.evaluatedItems.add(i);
68+
}
69+
} else {
70+
const tailSchema = asSchema(itemsKeyword as JSONSchemaRef);
71+
// if items is undefined, there's no constraint for remaining items and they remain unevaluated
72+
if (tailSchema) {
73+
for (let i = prefixLen; i < items.length; i++) {
74+
const itemValidationResult = new ValidationResult(options.isKubernetes);
75+
this.validateNode(items[i], tailSchema, schema, itemValidationResult, matchingSchemas, options);
76+
77+
validationResult.mergePropertyMatch(itemValidationResult);
78+
validationResult.mergeEnumValues(itemValidationResult);
79+
80+
// mark as evaluated even if invalid (avoids duplicate unevaluatedItems noise)
81+
validationResult.evaluatedItems.add(i);
82+
}
83+
}
84+
}
85+
}
86+
87+
// contains enforces min/max and marks matching indices as evaluated
88+
this.applyContains(node, schema, originalSchema, validationResult, matchingSchemas, options);
89+
90+
// generic array keywords
91+
this.applyArrayLength(node, schema, originalSchema, validationResult, options);
92+
this.applyUniqueItems(node, schema, originalSchema, validationResult);
93+
}
94+
95+
/**
96+
* Draft 2020-12: contains keyword affects the unevaluatedItems keyword
97+
*/
98+
protected override applyContains(
99+
node: ArrayASTNode,
100+
schema: JSONSchema,
101+
originalSchema: JSONSchema,
102+
validationResult: ValidationResult,
103+
_matchingSchemas: ISchemaCollector,
104+
options: Options
105+
): void {
106+
const containsSchema = asSchema(schema.contains);
107+
if (!containsSchema) return;
108+
109+
const items = (node.items ?? []) as ASTNode[];
110+
111+
const minContainsRaw = schema.minContains;
112+
const maxContainsRaw = schema.maxContains;
113+
114+
const minContains = isNumber(minContainsRaw) ? minContainsRaw : 1;
115+
const maxContains = isNumber(maxContainsRaw) ? maxContainsRaw : undefined;
116+
117+
let matchCount = 0;
118+
119+
// ensure evaluatedItems exists
120+
validationResult.evaluatedItems ??= new Set<number>();
121+
122+
for (let i = 0; i < items.length; i++) {
123+
const itemValidationResult = new ValidationResult(options.isKubernetes);
124+
this.validateNode(items[i], containsSchema, schema, itemValidationResult, this.getNoOpCollector(), options);
125+
if (!itemValidationResult.hasProblems()) {
126+
// items that match contains are considered evaluated
127+
validationResult.evaluatedItems.add(i);
128+
129+
matchCount++;
130+
if (maxContains !== undefined && matchCount > maxContains) {
131+
break;
132+
}
133+
}
134+
}
135+
136+
if (matchCount < minContains) {
137+
validationResult.problems.push({
138+
location: { offset: node.offset, length: node.length },
139+
severity: DiagnosticSeverity.Warning,
140+
message: schema.errorMessage || l10n.t('minContainsWarning', minContains),
141+
source: this.getSchemaSource(schema, originalSchema),
142+
schemaUri: this.getSchemaUri(schema, originalSchema),
143+
});
144+
}
145+
146+
if (maxContains !== undefined && matchCount > maxContains) {
147+
validationResult.problems.push({
148+
location: { offset: node.offset, length: node.length },
149+
severity: DiagnosticSeverity.Warning,
150+
message: schema.errorMessage || l10n.t('maxContainsWarning', maxContains),
151+
source: this.getSchemaSource(schema, originalSchema),
152+
schemaUri: this.getSchemaUri(schema, originalSchema),
153+
});
154+
}
155+
}
156+
}

src/languageservice/parser/schemaValidation/validatorFactory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BaseValidator } from './baseValidator';
33
import { Draft04Validator } from './draft04Validator';
44
import { Draft07Validator } from './draft07Validator';
55
import { Draft2019Validator } from './draft2019Validator';
6+
import { Draft2020Validator } from './draft2020Validator';
67

78
export function getValidator(dialect: SchemaDialect): BaseValidator {
89
switch (dialect) {
@@ -12,6 +13,8 @@ export function getValidator(dialect: SchemaDialect): BaseValidator {
1213
return new Draft07Validator();
1314
case SchemaDialect.draft2019:
1415
return new Draft2019Validator();
16+
case SchemaDialect.draft2020:
17+
return new Draft2020Validator();
1518
case SchemaDialect.undefined:
1619
default:
1720
return new Draft07Validator(); // fallback

0 commit comments

Comments
 (0)