Skip to content
31 changes: 31 additions & 0 deletions src/languageservice/services/dollarUtils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
58 changes: 38 additions & 20 deletions src/languageservice/services/yamlSchemaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import * as Json from 'jsonc-parser';
import Ajv, { DefinedError } from 'ajv';
import Ajv4 from 'ajv-draft-04';
import { getSchemaTitle } from '../utils/schemaUtils';
import { getDollarSchema } from './dollarUtils';

const ajv = new Ajv();
const ajv4 = new Ajv4();
Expand Down Expand Up @@ -350,33 +351,46 @@ export class YAMLSchemaService extends JSONSchemaService {
}

public getSchemaForResource(resource: string, doc: JSONDocument): Promise<ResolvedSchema> {
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<ResolvedSchema> => {
const schemaHandle = super.createCombinedSchema(resource, schemas);
return schemaHandle.getResolvedSchema().then((schema) => {
Expand Down Expand Up @@ -423,6 +437,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) => {
Expand Down
8 changes: 8 additions & 0 deletions test/fixtures/sample-dollar-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"properties": {
"dollar-schema": {
"type":"string"
}
}
}
34 changes: 33 additions & 1 deletion test/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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({
Expand Down