diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 53c175220..5c223ea12 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -6,7 +6,7 @@ { "label": "watch typescript", "type": "shell", - "command": "yarn run watch", + "command": "npm run watch", "presentation": { "reveal": "never" }, diff --git a/l10n/bundle.l10n.de.json b/l10n/bundle.l10n.de.json index 4244abdd1..fbf73d332 100644 --- a/l10n/bundle.l10n.de.json +++ b/l10n/bundle.l10n.de.json @@ -4,6 +4,7 @@ "json.schema.problemloadingref": "Probleme beim Laden der Referenz '{0}': {1}", "json.schema.nocontent": "Schema konnte nicht von '{0}' geladen werden: Kein Inhalt.", "json.schema.invalidFormat": "Inhalt von '{0}' konnte nicht analysiert werden: Analysefehler in Zeile:{1}, Spalte:{2}", + "json.schema.invalidSchema": "Schema '{0}' ist ungültig: {1}", "colorHexFormatWarning": "Ungültiges Farbformat. Verwenden Sie #RGB, #RGBA, #RRGGBB oder #RRGGBBAA.", "dateTimeFormatWarning": "Zeichenfolge ist kein RFC3339-Datum-Zeit-Wert.", "dateFormatWarning": "Zeichenfolge ist kein RFC3339-Datum.", diff --git a/l10n/bundle.l10n.fr.json b/l10n/bundle.l10n.fr.json index b34781920..7636639c7 100644 --- a/l10n/bundle.l10n.fr.json +++ b/l10n/bundle.l10n.fr.json @@ -4,6 +4,7 @@ "json.schema.problemloadingref": "Problèmes de chargement de la référence '{0}' : {1}", "json.schema.noContent": "Impossible de charger le schéma à partir de {0}: aucun contenu.", "json.schema.invalidFormat": "Impossible d’analyser le contenu de {0}: erreur d’analyse à la ligne:{1}, colonne:{2}", + "json.schema.invalidSchema": "Le schéma '{0}' n’est pas valide: {1}", "colorHexFormatWarning": "Format de couleur non valide. Utilisez #RGB, #RGBA, #RRGGBB ou #RRGGBBAA.", "dateTimeFormatWarning": "La chaîne n'est pas une date-heure RFC3339.", "dateFormatWarning": "La chaîne n'est pas une date RFC3339.", diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json index 69ef7bc79..943da607d 100644 --- a/l10n/bundle.l10n.ja.json +++ b/l10n/bundle.l10n.ja.json @@ -4,6 +4,7 @@ "json.schema.problemloadingref": "参照 '{0}' の読み込み中に問題が発生しました: {1}", "json.schema.nocontent": "'{0}' からスキーマを読み込めませんでした: コンテンツがありません。", "json.schema.invalidFormat": "'{0}' の内容を解析できませんでした: 行 {1}、列 {2} で解析エラーが発生しました", + "json.schema.invalidSchema": "スキーマ '{0}' は無効です: {1}", "colorHexFormatWarning": "無効なカラー形式です。#RGB、#RGBA、#RRGGBB、または #RRGGBBAA を使用してください。", "dateTimeFormatWarning": "文字列は RFC3339 の日付と時刻形式ではありません。", "dateFormatWarning": "文字列は RFC3339 の日付形式ではありません。", diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index c33936f61..f81f6e796 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -4,6 +4,7 @@ "json.schema.problemloadingref": "Problems loading reference '{0}': {1}", "json.schema.noContent": "Unable to load schema from '{0}': No content.", "json.schema.invalidFormat": "Unable to parse content from '{0}': Parse error at line: {1} column: {2}", + "json.schema.invalidSchema": "Schema '{0}' is not valid: {1}", "colorHexFormatWarning": "Invalid color format. Use #RGB, #RGBA, #RRGGBB or #RRGGBBAA.", "dateTimeFormatWarning": "String is not a RFC3339 date-time.", "dateFormatWarning": "String is not a RFC3339 date.", diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index cfaf4197a..4d1180aeb 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -4,6 +4,7 @@ "json.schema.problemloadingref": "'{0}' 참조를 불러오는 데 문제가 발생했습니다: {1}", "json.schema.nocontent": "'{0}'에서 스키마를 불러올 수 없습니다: 내용이 없습니다.", "json.schema.invalidFormat": "'{0}'의 내용을 구문 분석할 수 없습니다: {1}행 {2}열에서 구문 오류가 발생했습니다", + "json.schema.invalidSchema": "스키마 '{0}'이(가) 유효하지 않습니다: {1}", "colorHexFormatWarning": "잘못된 색상 형식입니다. #RGB, #RGBA, #RRGGBB 또는 #RRGGBBAA를 사용하세요.", "dateTimeFormatWarning": "문자열이 RFC3339 날짜-시간 형식이 아닙니다.", "dateFormatWarning": "문자열이 RFC3339 날짜 형식이 아닙니다.", diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index fcd7a5172..0a94d14b4 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -4,6 +4,7 @@ "json.schema.problemloadingref": "加载引用 '{0}' 时出现问题:{1}", "json.schema.nocontent": "无法从“{0}”加载架构:没有内容。", "json.schema.invalidFormat": "无法解析来自“{0}”的内容:在第 {1} 行第 {2} 列发生解析错误", + "json.schema.invalidSchema": "架构 '{0}' 无效: {1}", "colorHexFormatWarning": "无效的颜色格式。请使用 #RGB、#RGBA、#RRGGBB 或 #RRGGBBAA。", "dateTimeFormatWarning": "字符串不是 RFC3339 日期时间格式。", "dateFormatWarning": "字符串不是 RFC3339 日期格式。", diff --git a/l10n/bundle.l10n.zh-tw.json b/l10n/bundle.l10n.zh-tw.json index 6aea89590..eb40562ad 100644 --- a/l10n/bundle.l10n.zh-tw.json +++ b/l10n/bundle.l10n.zh-tw.json @@ -4,6 +4,7 @@ "json.schema.problemloadingref": "載入參考 '{0}' 時出現問題:{1}", "json.schema.nocontent": "無法從「{0}」載入結構描述:沒有內容。", "json.schema.invalidFormat": "無法解析來自「{0}」的內容:在第 {1} 行第 {2} 欄發生解析錯誤", + "json.schema.invalidSchema": "結構描述 '{0}' 無效:{1}", "colorHexFormatWarning": "無效的顏色格式。請使用 #RGB、#RGBA、#RRGGBB 或 #RRGGBBAA。", "dateTimeFormatWarning": "字串不是 RFC3339 日期時間格式。", "dateFormatWarning": "字串不是 RFC3339 日期格式。", diff --git a/src/languageservice/jsonSchema.ts b/src/languageservice/jsonSchema.ts index 37ffd0fab..de6966127 100644 --- a/src/languageservice/jsonSchema.ts +++ b/src/languageservice/jsonSchema.ts @@ -65,6 +65,24 @@ export interface JSONSchema { then?: JSONSchemaRef; else?: JSONSchemaRef; + // schema draft 2019-09 + $anchor?: string; + $defs?: { [name: string]: JSONSchema }; + $recursiveAnchor?: boolean; + $recursiveRef?: string; + $vocabulary?: Record; + dependentSchemas?: JSONSchemaMap; + unevaluatedItems?: boolean | JSONSchemaRef; + unevaluatedProperties?: boolean | JSONSchemaRef; + dependentRequired?: Record; + minContains?: number; + maxContains?: number; + + // schema draft 2020-12 + prefixItems?: JSONSchemaRef[]; + $dynamicRef?: string; + $dynamicAnchor?: string; + // VSCode extensions defaultSnippets?: { diff --git a/src/languageservice/services/yamlSchemaService.ts b/src/languageservice/services/yamlSchemaService.ts index a9ad24408..aeff53308 100644 --- a/src/languageservice/services/yamlSchemaService.ts +++ b/src/languageservice/services/yamlSchemaService.ts @@ -27,22 +27,29 @@ import { SchemaVersions } from '../yamlTypes'; import { parse } from 'yaml'; import * as Json from 'jsonc-parser'; -import Ajv, { DefinedError } from 'ajv'; +import Ajv, { DefinedError, type AnySchemaObject, type ValidateFunction } from 'ajv'; import Ajv4 from 'ajv-draft-04'; -import { getSchemaTitle } from '../utils/schemaUtils'; +import Ajv2019 from 'ajv/dist/2019'; +import Ajv2020 from 'ajv/dist/2020'; -const ajv = new Ajv(); -const ajv4 = new Ajv4(); +const ajv4 = new Ajv4({ allErrors: true }); +const ajv7 = new Ajv({ allErrors: true }); +const ajv2019 = new Ajv2019({ allErrors: true }); +const ajv2020 = new Ajv2020({ allErrors: true }); -// load JSON Schema 07 def to validate loaded schemas +// eslint-disable-next-line @typescript-eslint/no-var-requires +const jsonSchema04 = require('ajv-draft-04/dist/refs/json-schema-draft-04.json'); // eslint-disable-next-line @typescript-eslint/no-var-requires const jsonSchema07 = require('ajv/dist/refs/json-schema-draft-07.json'); -const schema07Validator = ajv.compile(jsonSchema07); - // eslint-disable-next-line @typescript-eslint/no-var-requires -const jsonSchema04 = require('ajv-draft-04/dist/refs/json-schema-draft-04.json'); +const jsonSchema2019 = require('ajv/dist/refs/json-schema-2019-09/schema.json'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const jsonSchema2020 = require('ajv/dist/refs/json-schema-2020-12/schema.json'); + const schema04Validator = ajv4.compile(jsonSchema04); -const SCHEMA_04_URI_WITH_HTTPS = ajv4.defaultMeta().replace('http://', 'https://'); +const schema07Validator = ajv7.compile(jsonSchema07); +const schema2019Validator = ajv2019.compile(jsonSchema2019); +const schema2020Validator = ajv2020.compile(jsonSchema2020); export declare type CustomSchemaProvider = (uri: string) => Promise; @@ -166,19 +173,24 @@ export class YAMLSchemaService extends JSONSchemaService { dependencies: SchemaDependencies ): Promise { const resolveErrors: string[] = schemaToResolve.errors.slice(0); - let schema: JSONSchema = schemaToResolve.schema; - const contextService = this.contextService; + const loc = toDisplayString(schemaURL); + + const raw: unknown = schemaToResolve.schema; + if (raw === null || Array.isArray(raw) || (typeof raw !== 'object' && typeof raw !== 'boolean')) { + const got = raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw; + resolveErrors.push(l10n.t('json.schema.invalidSchema', loc, `expected a JSON Schema object or boolean, got ${got}`)); + return new ResolvedSchema({}, resolveErrors); + } - const validator = - this.normalizeId(schema.$schema) === ajv4.defaultMeta() || this.normalizeId(schema.$schema) === SCHEMA_04_URI_WITH_HTTPS - ? schema04Validator - : schema07Validator; - if (!validator(schema)) { + const contextService = this.contextService; + let schema = raw as JSONSchema; + const validator = pickMetaValidator(schema.$schema); + if (validator && !validator(schema)) { const errs: string[] = []; for (const err of validator.errors as DefinedError[]) { errs.push(`${err.instancePath} : ${err.message}`); } - resolveErrors.push(`Schema '${getSchemaTitle(schemaToResolve.schema, schemaURL)}' is not valid:\n${errs.join('\n')}`); + resolveErrors.push(l10n.t('json.schema.invalidSchema', loc, `\n${errs.join('\n')}`)); } const findSection = (schema: JSONSchema, path: string): JSONSchema => { @@ -764,3 +776,38 @@ function getLineAndColumnFromOffset(text: string, offset: number): { line: numbe const column = lines[lines.length - 1].length + 1; // 1-based column number return { line, column }; } + +function normalizeSchemaUri(uri: string | AnySchemaObject): string { + if (!uri) return ''; + + let s: string; + if (typeof uri === 'string') { + s = uri; + } else { + s = uri.$id || uri.id || ''; + } + s = s.trim(); + + // strips fragment (# or #/something) + const hash = s.indexOf('#'); + + s = hash === -1 ? s : s.slice(0, hash); + + // normalize http to https (don't normalize custom dialects) + s = s.replace(/^http:\/\/json-schema\.org\//i, 'https://json-schema.org/'); + + // normalize to no trailing slash + s = s.replace(/\/+$/g, ''); + return s; +} + +function pickMetaValidator(schema: string): ValidateFunction | undefined { + const s = normalizeSchemaUri(schema); + if (s === normalizeSchemaUri(ajv4.defaultMeta())) return schema04Validator; + if (s === normalizeSchemaUri(ajv7.defaultMeta())) return schema07Validator; + if (s === normalizeSchemaUri(ajv2019.defaultMeta())) return schema2019Validator; + if (s === normalizeSchemaUri(ajv2020.defaultMeta())) return schema2020Validator; + + // don't meta-validate unknown schema URI + return undefined; +} diff --git a/test/schemaValidation.test.ts b/test/schemaValidation.test.ts index cdd41f705..4b6cf4cd4 100644 --- a/test/schemaValidation.test.ts +++ b/test/schemaValidation.test.ts @@ -1968,14 +1968,86 @@ obj: expect(telemetry.messages).to.be.empty; }); - it('should handle not valid schema object', async () => { - const schema = 'Foo'; - schemaProvider.addSchema(SCHEMA_ID, schema as JSONSchema); - const content = `foo: bar`; - const result = await parseSetup(content); - expect(result).to.have.length(1); - expect(result[0].message).to.include("Schema 'default_schema_id.yaml' is not valid"); - expect(telemetry.messages).to.be.empty; + describe('Schema meta-validation', () => { + it('should handle not valid schema object', async () => { + const schema = 'Foo'; + schemaProvider.addSchema(SCHEMA_ID, schema as JSONSchema); + const content = `foo: bar`; + const result = await parseSetup(content); + expect(result).to.have.length(1); + expect(result[0].message).to.include('default_schema_id.yaml'); + expect(result[0].message).to.include('is not valid:'); + expect(result[0].message).to.include('expected a JSON Schema object or boolean, got string'); + expect(telemetry.messages).to.be.empty; + }); + + const content = '6'; + + it('draft-04: exclusiveMinimum must be boolean', async () => { + const schema = { + $schema: 'http://json-schema.org/draft-04/schema#', + type: 'number', + minimum: 5, + exclusiveMinimum: 5, + } as unknown as JSONSchema; + schemaProvider.addSchema(SCHEMA_ID, schema); + const result = await parseSetup(content); + expect(result).to.have.length(1); + expect(result[0].message).to.include('default_schema_id.yaml'); + expect(result[0].message).to.include('is not valid:'); + expect(result[0].message).to.include('exclusiveMinimum'); + }); + + it('draft-07: exclusiveMinimum must be number', async () => { + const schema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'number', + minimum: 5, + exclusiveMinimum: true, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const result = await parseSetup(content); + expect(result).to.have.length(1); + expect(result[0].message).to.include('default_schema_id.yaml'); + expect(result[0].message).to.include('is not valid:'); + expect(result[0].message).to.include('exclusiveMinimum'); + }); + + it('draft-2019-09: should handle invalid type in $defs', async () => { + const schema: JSONSchema = { + $schema: 'https://json-schema.org/draft/2019-09/schema', + $defs: { + foo: { + type: 'object', + properties: { + bar: { + type: ['foo', 'bar'], + }, + }, + }, + }, + }; + schemaProvider.addSchema(SCHEMA_ID, schema); + const result = await parseSetup(content); + expect(result).to.have.length(1); + expect(result[0].message).to.include('default_schema_id.yaml'); + expect(result[0].message).to.include('is not valid:'); + expect(result[0].message).to.include('$defs'); + }); + + it('draft-2020-12: prefixItems must be an array', async () => { + const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'number', + prefixItems: 'foo', + } as unknown as JSONSchema; + schemaProvider.addSchema(SCHEMA_ID, schema); + const result = await parseSetup(content); + expect(result).to.have.length(1); + expect(result[0].message).to.include('default_schema_id.yaml'); + expect(result[0].message).to.include('is not valid:'); + expect(result[0].message).to.include('prefixItems'); + }); }); it('should handle bad schema refs', async () => {