Skip to content

Commit f6b2134

Browse files
committed
feat: Add comprehensive test support for JSON Schema 2019-09 and 2020-12
1 parent 3ae9936 commit f6b2134

File tree

8 files changed

+1824
-18
lines changed

8 files changed

+1824
-18
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# YAML Language Server
44

5-
Supports JSON Schema 7 and below.
5+
Supports JSON Schema draft-04, draft-07, 2019-09, and 2020-12.
66
Starting from `1.0.0` the language server uses [eemeli/yaml](https://github.com/eemeli/yaml) as the new YAML parser, which strictly enforces the specified YAML spec version. Default YAML spec version is `1.2`, it can be changed with `yaml.yamlVersion` setting.
77

88
## Features

src/languageservice/services/yamlSchemaService.ts

Lines changed: 105 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ import { parse } from 'yaml';
2929
import * as Json from 'jsonc-parser';
3030
import { getSchemaTitle } from '../utils/schemaUtils';
3131

32+
import * as Draft04 from '@hyperjump/json-schema/draft-04';
33+
import * as Draft07 from '@hyperjump/json-schema/draft-07';
34+
import * as Draft201909 from '@hyperjump/json-schema/draft-2019-09';
35+
import * as Draft202012 from '@hyperjump/json-schema/draft-2020-12';
36+
37+
type SupportedSchemaVersions = '2020-12' | '2019-09' | 'draft-07' | 'draft-04';
3238
export declare type CustomSchemaProvider = (uri: string) => Promise<string | string[]>;
3339

3440
export enum MODIFICATION_ACTIONS {
@@ -90,7 +96,6 @@ interface SchemaStoreSchema {
9096
versions?: SchemaVersions;
9197
}
9298
export class YAMLSchemaService extends JSONSchemaService {
93-
// To allow to use schemasById from super.
9499
// eslint-disable-next-line @typescript-eslint/no-explicit-any
95100
[x: string]: any;
96101

@@ -154,12 +159,34 @@ export class YAMLSchemaService extends JSONSchemaService {
154159
let schema: JSONSchema = schemaToResolve.schema;
155160
const contextService = this.contextService;
156161

157-
// Basic schema validation - check if schema is a valid object
158162
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
159163
const invalidSchemaType = Array.isArray(schema) ? 'array' : typeof schema;
160164
resolveErrors.push(
161165
`Schema '${getSchemaTitle(schemaToResolve.schema, schemaURL)}' is not valid:\nWrong schema: "${invalidSchemaType}", it MUST be an Object or Boolean`
162166
);
167+
} else {
168+
try {
169+
const schemaVersion = this.detectSchemaVersion(schema);
170+
const validator = this.getValidatorForVersion(schemaVersion);
171+
const metaSchemaUrl = this.getSchemaMetaSchema(schemaVersion);
172+
173+
// Validate the schema against its meta-schema using the URL directly
174+
const result = await validator.validate(metaSchemaUrl, schema, 'BASIC');
175+
if (!result.valid && result.errors) {
176+
const errs: string[] = [];
177+
for (const error of result.errors) {
178+
if (error.instanceLocation && error.keyword) {
179+
errs.push(`${error.instanceLocation}: ${this.extractKeywordName(error.keyword)} constraint violation`);
180+
}
181+
}
182+
if (errs.length > 0) {
183+
resolveErrors.push(`Schema '${getSchemaTitle(schemaToResolve.schema, schemaURL)}' is not valid:\n${errs.join('\n')}`);
184+
}
185+
}
186+
} catch (error) {
187+
// If meta-schema validation fails, log but don't block schema loading
188+
console.error(`Failed to validate schema meta-schema: ${error.message}`);
189+
}
163190
}
164191

165192
const findSection = (schema: JSONSchema, path: string): JSONSchema => {
@@ -269,15 +296,14 @@ export class YAMLSchemaService extends JSONSchemaService {
269296
while (next.$ref) {
270297
const ref = decodeURIComponent(next.$ref);
271298
const segments = ref.split('#', 2);
272-
//return back removed $ref. We lost info about referenced type without it.
273299
next._$ref = next.$ref;
274300
delete next.$ref;
275301
if (segments[0].length > 0) {
276302
openPromises.push(resolveExternalLink(next, segments[0], segments[1], parentSchemaURL, parentSchemaDependencies));
277303
return;
278304
} else {
279305
if (!seenRefs.has(ref)) {
280-
merge(next, parentSchema, parentSchemaURL, segments[1]); // can set next.$ref again, use seenRefs to avoid circle
306+
merge(next, parentSchema, parentSchemaURL, segments[1]);
281307
seenRefs.add(ref);
282308
}
283309
}
@@ -335,9 +361,6 @@ export class YAMLSchemaService extends JSONSchemaService {
335361
let schemaFromModeline = getSchemaFromModeline(doc);
336362
if (schemaFromModeline !== undefined) {
337363
if (!schemaFromModeline.startsWith('file:') && !schemaFromModeline.startsWith('http')) {
338-
// If path contains a fragment and it is left intact, "#" will be
339-
// considered part of the filename and converted to "%23" by
340-
// path.resolve() -> take it out and add back after path.resolve
341364
let appendix = '';
342365
if (schemaFromModeline.indexOf('#') > 0) {
343366
const segments = schemaFromModeline.split('#', 2);
@@ -393,7 +416,6 @@ export class YAMLSchemaService extends JSONSchemaService {
393416
}
394417

395418
if (schemas.length > 0) {
396-
// Join all schemas with the highest priority.
397419
const highestPrioSchemas = this.highestPrioritySchemas(schemas);
398420
return resolveSchemaForResource(highestPrioSchemas);
399421
}
@@ -469,14 +491,12 @@ export class YAMLSchemaService extends JSONSchemaService {
469491
let highestPrio = 0;
470492
const priorityMapping = new Map<SchemaPriority, string[]>();
471493
schemas.forEach((schema) => {
472-
// If the schema does not have a priority then give it a default one of [0]
473494
const priority = this.schemaPriorityMapping.get(schema) || [0];
474495
priority.forEach((prio) => {
475496
if (prio > highestPrio) {
476497
highestPrio = prio;
477498
}
478499

479-
// Build up a mapping of priority to schemas so that we can easily get the highest priority schemas easier
480500
let currPriorityArray = priorityMapping.get(prio);
481501
if (currPriorityArray) {
482502
currPriorityArray = (currPriorityArray as string[]).concat(schema);
@@ -601,7 +621,6 @@ export class YAMLSchemaService extends JSONSchemaService {
601621
*/
602622

603623
normalizeId(id: string): string {
604-
// The parent's `super.normalizeId(id)` isn't visible, so duplicated the code here
605624
try {
606625
return URI.parse(id).toString();
607626
} catch (e) {
@@ -621,9 +640,6 @@ export class YAMLSchemaService extends JSONSchemaService {
621640
loadSchema(schemaUri: string): Promise<UnresolvedSchema> {
622641
const requestService = this.requestService;
623642
return super.loadSchema(schemaUri).then(async (unresolvedJsonSchema: UnresolvedSchema) => {
624-
// If json-language-server failed to parse the schema, attempt to parse it as YAML instead.
625-
// If the YAML file starts with %YAML 1.x or contains a comment with a number the schema will
626-
// contain a number instead of being undefined, so we need to check for that too.
627643
if (
628644
unresolvedJsonSchema.errors &&
629645
(unresolvedJsonSchema.schema === undefined || typeof unresolvedJsonSchema.schema === 'number')
@@ -658,7 +674,6 @@ export class YAMLSchemaService extends JSONSchemaService {
658674
let errorMessage = error.toString();
659675
const errorSplit = error.toString().split('Error: ');
660676
if (errorSplit.length > 1) {
661-
// more concise error message, URL and context are attached by caller anyways
662677
errorMessage = errorSplit[1];
663678
}
664679
return new UnresolvedSchema(<JSONSchema>{}, [errorMessage]);
@@ -725,6 +740,79 @@ export class YAMLSchemaService extends JSONSchemaService {
725740
onResourceChange(uri: string): boolean {
726741
return super.onResourceChange(uri);
727742
}
743+
744+
/**
745+
* Detect the JSON Schema version from the $schema property
746+
*/
747+
private detectSchemaVersion(schema: JSONSchema): SupportedSchemaVersions {
748+
const schemaProperty = schema.$schema;
749+
if (typeof schemaProperty === 'string') {
750+
if (schemaProperty.includes('2020-12')) {
751+
return '2020-12';
752+
} else if (schemaProperty.includes('2019-09')) {
753+
return '2019-09';
754+
} else if (schemaProperty.includes('draft-07') || schemaProperty.includes('draft/7')) {
755+
return 'draft-07';
756+
} else if (schemaProperty.includes('draft-04') || schemaProperty.includes('draft/4')) {
757+
return 'draft-04';
758+
}
759+
}
760+
return 'draft-07';
761+
}
762+
763+
/**
764+
* Get the appropriate validator module for a schema version
765+
*/
766+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
767+
private getValidatorForVersion(version: SupportedSchemaVersions): any {
768+
switch (version) {
769+
case '2020-12':
770+
return Draft202012;
771+
case '2019-09':
772+
return Draft201909;
773+
case 'draft-07':
774+
return Draft07;
775+
case 'draft-04':
776+
default:
777+
return Draft04;
778+
}
779+
}
780+
781+
/**
782+
* Get the correct schema meta URI for a given version
783+
*/
784+
private getSchemaMetaSchema(version: SupportedSchemaVersions): string {
785+
switch (version) {
786+
case '2020-12':
787+
return 'https://json-schema.org/draft/2020-12/schema';
788+
case '2019-09':
789+
return 'https://json-schema.org/draft/2019-09/schema';
790+
case 'draft-07':
791+
return 'http://json-schema.org/draft-07/schema';
792+
case 'draft-04':
793+
return 'http://json-schema.org/draft-04/schema';
794+
default:
795+
return 'http://json-schema.org/draft-07/schema';
796+
}
797+
}
798+
799+
/**
800+
* Extract a human-readable keyword name from a keyword URI
801+
*/
802+
private extractKeywordName(keywordUri: string): string {
803+
if (typeof keywordUri !== 'string') {
804+
return 'validation';
805+
}
806+
807+
const parts = keywordUri.split('/');
808+
const lastPart = parts[parts.length - 1];
809+
810+
if (lastPart === 'validate') {
811+
return 'schema validation';
812+
}
813+
814+
return lastPart || 'validation';
815+
}
728816
}
729817

730818
function toDisplayString(url: string): string {
@@ -741,7 +829,7 @@ function toDisplayString(url: string): string {
741829

742830
function getLineAndColumnFromOffset(text: string, offset: number): { line: number; column: number } {
743831
const lines = text.slice(0, offset).split(/\r?\n/);
744-
const line = lines.length; // 1-based line number
745-
const column = lines[lines.length - 1].length + 1; // 1-based column number
832+
const line = lines.length;
833+
const column = lines[lines.length - 1].length + 1;
746834
return { line, column };
747835
}

src/languageservice/utils/schemaUtils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ export function getSchemaTitle(schema: JSONSchema, url: string): string {
4949
if (!path.extname(uri.fsPath)) {
5050
baseName += '.json';
5151
}
52+
53+
// Handle null or undefined schemas
54+
if (!schema || typeof schema !== 'object') {
55+
return baseName;
56+
}
57+
5258
if (Object.getOwnPropertyDescriptor(schema, 'name')) {
5359
return Object.getOwnPropertyDescriptor(schema, 'name').value + ` (${baseName})`;
5460
} else if (schema.title) {

test/fixtures/schema-2019-09.json

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2019-09/schema",
3+
"$id": "https://example.com/person.schema.json",
4+
"title": "Person Schema 2019-09",
5+
"type": "object",
6+
"properties": {
7+
"firstName": {
8+
"type": "string",
9+
"description": "The person's first name."
10+
},
11+
"lastName": {
12+
"type": "string",
13+
"description": "The person's last name."
14+
},
15+
"age": {
16+
"description": "Age in years which must be equal to or greater than zero.",
17+
"type": "integer",
18+
"minimum": 0
19+
},
20+
"email": {
21+
"type": "string",
22+
"format": "email"
23+
},
24+
"address": {
25+
"$ref": "#/$defs/address"
26+
}
27+
},
28+
"required": [
29+
"firstName",
30+
"lastName"
31+
],
32+
"$defs": {
33+
"address": {
34+
"type": "object",
35+
"properties": {
36+
"street": {
37+
"type": "string"
38+
},
39+
"city": {
40+
"type": "string"
41+
},
42+
"country": {
43+
"type": "string"
44+
}
45+
},
46+
"required": [
47+
"street",
48+
"city",
49+
"country"
50+
]
51+
}
52+
},
53+
"unevaluatedProperties": false
54+
}

test/fixtures/schema-2020-12.json

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://example.com/product.schema.json",
4+
"title": "Product Schema 2020-12",
5+
"type": "object",
6+
"properties": {
7+
"productId": {
8+
"type": "integer",
9+
"description": "The unique identifier for a product"
10+
},
11+
"productName": {
12+
"type": "string",
13+
"description": "Name of the product"
14+
},
15+
"price": {
16+
"type": "number",
17+
"minimum": 0,
18+
"exclusiveMinimum": true
19+
},
20+
"tags": {
21+
"type": "array",
22+
"items": {
23+
"type": "string"
24+
},
25+
"minItems": 1,
26+
"uniqueItems": true
27+
},
28+
"dimensions": {
29+
"$ref": "#/$defs/dimensions"
30+
},
31+
"category": {
32+
"enum": [
33+
"electronics",
34+
"clothing",
35+
"books",
36+
"home"
37+
]
38+
}
39+
},
40+
"required": [
41+
"productId",
42+
"productName",
43+
"price"
44+
],
45+
"$defs": {
46+
"dimensions": {
47+
"type": "object",
48+
"properties": {
49+
"length": {
50+
"type": "number"
51+
},
52+
"width": {
53+
"type": "number"
54+
},
55+
"height": {
56+
"type": "number"
57+
}
58+
},
59+
"required": [
60+
"length",
61+
"width",
62+
"height"
63+
]
64+
}
65+
},
66+
"unevaluatedProperties": false
67+
}

0 commit comments

Comments
 (0)