From 6767a566481faa24ce676b2d9131f7b20311ff2c Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Fri, 17 May 2024 09:29:28 -0500 Subject: [PATCH 1/2] add basic support for declaring schema with inline `$schema` --- src/languageservice/services/dollarUtils.ts | 26 +++++++++ .../services/yamlSchemaService.ts | 58 ++++++++++++------- 2 files changed, 64 insertions(+), 20 deletions(-) create mode 100644 src/languageservice/services/dollarUtils.ts diff --git a/src/languageservice/services/dollarUtils.ts b/src/languageservice/services/dollarUtils.ts new file mode 100644 index 00000000..8620da8c --- /dev/null +++ b/src/languageservice/services/dollarUtils.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat, Inc. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SingleYAMLDocument } from '../parser/yamlParser07'; +import { JSONDocument } from '../parser/jsonParser07'; + +/** + * Retrieve schema if declared by `$schema`. + * Public for testing purpose, not part of the API. + * @param doc + */ +export function getDollarSchema(doc: SingleYAMLDocument | JSONDocument): string | undefined { + if (doc instanceof SingleYAMLDocument && doc.root && doc.root.type === 'object') { + let dollarSchema = doc.root.properties['$schema']; + dollarSchema = typeof dollarSchema === 'string' ? dollarSchema.trim() : undefined; + if (typeof dollarSchema === 'string') { + return dollarSchema.trim(); + } + if (dollarSchema) { + console.log('The $schema attribute is not a string, and will be ignored'); + } + } + return undefined; +} diff --git a/src/languageservice/services/yamlSchemaService.ts b/src/languageservice/services/yamlSchemaService.ts index 453b74eb..338bbe54 100644 --- a/src/languageservice/services/yamlSchemaService.ts +++ b/src/languageservice/services/yamlSchemaService.ts @@ -29,6 +29,7 @@ import { SchemaVersions } from '../yamlTypes'; import Ajv, { DefinedError } from 'ajv'; import { getSchemaTitle } from '../utils/schemaUtils'; +import { getDollarSchema } from './dollarUtils'; const localize = nls.loadMessageBundle(); @@ -343,33 +344,46 @@ export class YAMLSchemaService extends JSONSchemaService { } public getSchemaForResource(resource: string, doc: JSONDocument): Promise { + const normalizeSchemaRef = (schemaRef: string): string | undefined => { + if (!schemaRef.startsWith('file:') && !schemaRef.startsWith('http')) { + // If path contains a fragment and it is left intact, "#" will be + // considered part of the filename and converted to "%23" by + // path.resolve() -> take it out and add back after path.resolve + let appendix = ''; + if (schemaRef.indexOf('#') > 0) { + const segments = schemaRef.split('#', 2); + schemaRef = segments[0]; + appendix = segments[1]; + } + if (!path.isAbsolute(schemaRef)) { + const resUri = URI.parse(resource); + schemaRef = URI.file(path.resolve(path.parse(resUri.fsPath).dir, schemaRef)).toString(); + } else { + schemaRef = URI.file(schemaRef).toString(); + } + if (appendix.length > 0) { + schemaRef += '#' + appendix; + } + } + return schemaRef; + }; + const resolveModelineSchema = (): string | undefined => { let schemaFromModeline = getSchemaFromModeline(doc); if (schemaFromModeline !== undefined) { - if (!schemaFromModeline.startsWith('file:') && !schemaFromModeline.startsWith('http')) { - // If path contains a fragment and it is left intact, "#" will be - // considered part of the filename and converted to "%23" by - // path.resolve() -> take it out and add back after path.resolve - let appendix = ''; - if (schemaFromModeline.indexOf('#') > 0) { - const segments = schemaFromModeline.split('#', 2); - schemaFromModeline = segments[0]; - appendix = segments[1]; - } - if (!path.isAbsolute(schemaFromModeline)) { - const resUri = URI.parse(resource); - schemaFromModeline = URI.file(path.resolve(path.parse(resUri.fsPath).dir, schemaFromModeline)).toString(); - } else { - schemaFromModeline = URI.file(schemaFromModeline).toString(); - } - if (appendix.length > 0) { - schemaFromModeline += '#' + appendix; - } - } + schemaFromModeline = normalizeSchemaRef(schemaFromModeline); return schemaFromModeline; } }; + const resolveDollarSchema = (): string | undefined => { + let dollarSchema = getDollarSchema(doc); + if (dollarSchema !== undefined) { + dollarSchema = normalizeSchemaRef(dollarSchema); + return dollarSchema; + } + }; + const resolveSchemaForResource = (schemas: string[]): Promise => { const schemaHandle = super.createCombinedSchema(resource, schemas); return schemaHandle.getResolvedSchema().then((schema) => { @@ -416,6 +430,10 @@ export class YAMLSchemaService extends JSONSchemaService { if (modelineSchema) { return resolveSchemaForResource([modelineSchema]); } + const dollarSchema = resolveDollarSchema(); + if (dollarSchema) { + return resolveSchemaForResource([dollarSchema]); + } if (this.customSchemaProvider) { return this.customSchemaProvider(resource) .then((schemaUri) => { From 1f7c6594ac71afa56ed79c9c8c5b7e4bce1f4641 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Fri, 17 May 2024 11:24:11 -0500 Subject: [PATCH 2/2] update test for $schema --- src/languageservice/services/dollarUtils.ts | 13 +++++--- test/fixtures/sample-dollar-schema.json | 8 +++++ test/schema.test.ts | 34 ++++++++++++++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/sample-dollar-schema.json diff --git a/src/languageservice/services/dollarUtils.ts b/src/languageservice/services/dollarUtils.ts index 8620da8c..54285360 100644 --- a/src/languageservice/services/dollarUtils.ts +++ b/src/languageservice/services/dollarUtils.ts @@ -12,11 +12,16 @@ import { JSONDocument } from '../parser/jsonParser07'; * @param doc */ export function getDollarSchema(doc: SingleYAMLDocument | JSONDocument): string | undefined { - if (doc instanceof SingleYAMLDocument && doc.root && doc.root.type === 'object') { - let dollarSchema = doc.root.properties['$schema']; - dollarSchema = typeof dollarSchema === 'string' ? dollarSchema.trim() : undefined; + if ((doc instanceof SingleYAMLDocument || doc instanceof JSONDocument) && doc.root?.type === 'object') { + let dollarSchema: string | undefined = undefined; + for (const property of doc.root.properties) { + if (property.keyNode?.value === '$schema' && typeof property.valueNode?.value === 'string') { + dollarSchema = property.valueNode?.value; + break; + } + } if (typeof dollarSchema === 'string') { - return dollarSchema.trim(); + return dollarSchema; } if (dollarSchema) { console.log('The $schema attribute is not a string, and will be ignored'); diff --git a/test/fixtures/sample-dollar-schema.json b/test/fixtures/sample-dollar-schema.json new file mode 100644 index 00000000..22f9ecb7 --- /dev/null +++ b/test/fixtures/sample-dollar-schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "properties": { + "dollar-schema": { + "type":"string" + } + } +} diff --git a/test/schema.test.ts b/test/schema.test.ts index 1747b1c4..4ab901eb 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -590,6 +590,8 @@ describe('JSON Schema', () => { // eslint-disable-next-line @typescript-eslint/no-var-requires const schemaModelineSample = path.join(__dirname, './fixtures/sample-modeline.json'); // eslint-disable-next-line @typescript-eslint/no-var-requires + const schemaDollarSample = path.join(__dirname, './fixtures/sample-dollar-schema.json'); + // eslint-disable-next-line @typescript-eslint/no-var-requires const schemaDefaultSnippetSample = require(path.join(__dirname, './fixtures/defaultSnippets-const-if-else.json')); const languageSettingsSetup = new ServiceSetup().withCompletion(); @@ -615,12 +617,42 @@ describe('JSON Schema', () => { }); languageService.configure(languageSettingsSetup.languageSettings); languageService.registerCustomSchemaProvider((uri: string) => Promise.resolve(uri)); - const testTextDocument = setupTextDocument(`# yaml-language-server: $schema=${schemaModelineSample}\n\n`); + const testTextDocument = setupTextDocument( + `# yaml-language-server: $schema=${schemaModelineSample}\n$schema: ${schemaDollarSample}\n\n` + ); const result = await languageService.doComplete(testTextDocument, Position.create(1, 0), false); assert.strictEqual(result.items.length, 1); assert.strictEqual(result.items[0].label, 'modeline'); }); + it('Explicit $schema takes precedence over all other lower priority schemas', async () => { + languageSettingsSetup + .withSchemaFileMatch({ + fileMatch: ['test.yaml'], + uri: TEST_URI, + priority: SchemaPriority.SchemaStore, + schema: schemaStoreSample, + }) + .withSchemaFileMatch({ + fileMatch: ['test.yaml'], + uri: TEST_URI, + priority: SchemaPriority.SchemaAssociation, + schema: schemaAssociationSample, + }) + .withSchemaFileMatch({ + fileMatch: ['test.yaml'], + uri: TEST_URI, + priority: SchemaPriority.Settings, + schema: schemaSettingsSample, + }); + languageService.configure(languageSettingsSetup.languageSettings); + languageService.registerCustomSchemaProvider((uri: string) => Promise.resolve(uri)); + const testTextDocument = setupTextDocument(`$schema: ${schemaDollarSample}\n\n`); + const result = await languageService.doComplete(testTextDocument, Position.create(1, 0), false); + assert.strictEqual(result.items.length, 1); + assert.strictEqual(result.items[0].label, 'dollar-schema'); + }); + it('Manually setting schema takes precendence over all other lower priority schemas', async () => { languageSettingsSetup .withSchemaFileMatch({