diff --git a/src/languageservice/services/dollarUtils.ts b/src/languageservice/services/dollarUtils.ts new file mode 100644 index 00000000..54285360 --- /dev/null +++ b/src/languageservice/services/dollarUtils.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * 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 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; + } + 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) => { 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({