diff --git a/CHANGELOG.md b/CHANGELOG.md index 85d59632..e82d8555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +#### 0.10.0 + +- Allows to declare a schema inside the yaml file through modeline `# yaml-language-server: $schema=` + #### 0.9.0 - Improve Diagnostic positions [#260](https://github.com/redhat-developer/yaml-language-server/issues/260) - Support `maxProperties` when providing completion [#269](https://github.com/redhat-developer/yaml-language-server/issues/269) diff --git a/README.md b/README.md index e362ad77..08a24917 100755 --- a/README.md +++ b/README.md @@ -88,6 +88,10 @@ and that will associate the composer schema with myYamlFile.yaml. ## More examples of schema association: +### Using yaml.schemas settings + +#### Single root schema association: + When associating a schema it should follow the format below ``` yaml.schemas: { @@ -118,7 +122,7 @@ yaml.schemas: { } ``` -## Multi root schema association: +#### Multi root schema association: You can also use relative paths when working with multi root workspaces. Suppose you have a multi root workspace that is laid out like: @@ -141,6 +145,14 @@ yaml.schemas: { `yaml.schemas` allows you to specify json schemas that you want to validate against the yaml that you write. Kubernetes is an optional field. It does not require a url as the language server will provide that. You just need the keyword kubernetes and a glob pattern. +### Using inlined schema + +It is possible to specify a yaml schema using a modeline. + +``` +# yaml-language-server: $schema= +``` + ## Clients This repository only contains the server implementation. Here are some known clients consuming this server: diff --git a/src/languageservice/parser/yamlParser07.ts b/src/languageservice/parser/yamlParser07.ts index 27979e3e..b08df125 100644 --- a/src/languageservice/parser/yamlParser07.ts +++ b/src/languageservice/parser/yamlParser07.ts @@ -18,6 +18,10 @@ import { ASTNode } from '../jsonASTTypes'; import { ErrorCode } from 'vscode-json-languageservice'; import { emit } from 'process'; +const YAML_DIRECTIVE_PREFIX = '%'; +const YAML_COMMENT_PREFIX = '#'; +const YAML_DATA_INSTANCE_SEPARATOR = '---'; + /** * These documents are collected into a final YAMLDocument * and passed to the `parseYAML` caller. @@ -29,6 +33,7 @@ export class SingleYAMLDocument extends JSONDocument { public warnings: YAMLDocDiagnostic[]; public isKubernetes: boolean; public currentDocIndex: number; + public lineComments: string[]; constructor(lines: number[]) { super(null, []); @@ -36,6 +41,7 @@ export class SingleYAMLDocument extends JSONDocument { this.root = null; this.errors = []; this.warnings = []; + this.lineComments = []; } public getSchemas(schema, doc, node) { @@ -101,7 +107,25 @@ export function parse(text: string, customTags = []): YAMLDocument { // Generate the SingleYAMLDocs from the AST nodes const startPositions = getLineStartPositions(text); const yamlDocs: SingleYAMLDocument[] = yamlNodes.map(node => nodeToSingleDoc(node, startPositions, text)); + + parseLineComments(text, yamlDocs); // Consolidate the SingleYAMLDocs return new YAMLDocument(yamlDocs); } + +function parseLineComments(text: string, yamlDocs: SingleYAMLDocument[]) { + const lines = text.split(/[\r\n]+/g); + let yamlDocCount = 0; + lines.forEach(line => { + if (line.startsWith(YAML_DIRECTIVE_PREFIX) && yamlDocCount === 0) { + yamlDocCount--; + } + if (line === YAML_DATA_INSTANCE_SEPARATOR) { + yamlDocCount++; + } + if (line.startsWith(YAML_COMMENT_PREFIX)) { + yamlDocs[yamlDocCount].lineComments.push(line); + } + }); +} diff --git a/src/languageservice/services/yamlSchemaService.ts b/src/languageservice/services/yamlSchemaService.ts index d73d5118..3d1be302 100644 --- a/src/languageservice/services/yamlSchemaService.ts +++ b/src/languageservice/services/yamlSchemaService.ts @@ -14,6 +14,10 @@ import { URI } from 'vscode-uri'; import * as nls from 'vscode-nls'; import { convertSimple2RegExpPattern } from '../utils/strings'; +import { TextDocument } from 'vscode-languageserver'; +import { SingleYAMLDocument } from '../parser/yamlParser07'; +import { stringifyObject } from '../utils/json'; +import { getNodeValue } from '../parser/jsonParser07'; const localize = nls.loadMessageBundle(); export declare type CustomSchemaProvider = (uri: string) => Thenable; @@ -216,9 +220,15 @@ export class YAMLSchemaService extends JSONSchemaService { public getSchemaForResource (resource: string, doc = undefined): Thenable { const resolveSchema = () => { - const seen: { [schemaId: string]: boolean } = Object.create(null); const schemas: string[] = []; + + const schemaFromModeline = this.getSchemaFromModeline(doc); + if(schemaFromModeline !== undefined) { + schemas.push(schemaFromModeline); + seen[schemaFromModeline] = true; + } + for (const entry of this.filePatternAssociations) { if (entry.matchesPattern(resource)) { for (const schemaId of entry.getURIs()) { @@ -282,6 +292,28 @@ export class YAMLSchemaService extends JSONSchemaService { return resolveSchema(); } } + + /** + * Retrieve schema if declared as modeline + * @param doc + */ + private getSchemaFromModeline(doc: any) : string{ + if (doc instanceof SingleYAMLDocument) { + const modelineDeclaration = '# yaml-language-server:'; + const yamlLanguageServerModeline = doc.lineComments.find(lineComment => lineComment.startsWith(modelineDeclaration)); + if (yamlLanguageServerModeline != undefined) { + const schemaKey = '$schema='; + const indexOfJsonSchemaParameter = yamlLanguageServerModeline.indexOf(schemaKey); + if (yamlLanguageServerModeline.indexOf(schemaKey) != -1) { + const startIndex = indexOfJsonSchemaParameter + schemaKey.length; + const indexOfNextSpace = yamlLanguageServerModeline.indexOf(' ', startIndex); + const endIndex = indexOfNextSpace !== -1 ? indexOfNextSpace : yamlLanguageServerModeline.length; + return yamlLanguageServerModeline.substring(startIndex, endIndex); + } + } + } + return undefined; + } private async resolveCustomSchema (schemaUri, doc) { const unresolvedSchema = await this.loadSchema(schemaUri); diff --git a/test/autoCompletion.test.ts b/test/autoCompletion.test.ts index bcda4e2c..65919d7a 100644 --- a/test/autoCompletion.test.ts +++ b/test/autoCompletion.test.ts @@ -2,7 +2,7 @@ * Copyright (c) Red Hat. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/; -import { SCHEMA_ID, setupSchemaIDTextDocument, configureLanguageService } from './utils/testHelper'; +import { SCHEMA_ID, setupSchemaIDTextDocument, configureLanguageService, toFsPath } from './utils/testHelper'; import assert = require('assert'); import path = require('path'); import { createExpectedCompletion } from './utils/verifyError'; @@ -924,5 +924,49 @@ suite('Auto Completion Tests', () => { }).then(done, done); }); }); + + describe('Yaml schema defined in file', function () { + + const uri = toFsPath(path.join(__dirname, './fixtures/testArrayMaxProperties.json')); + + it('Provide completion from schema declared in file', done => { + const content = `# yaml-language-server: $schema=${uri}\n- `; + const completion = parseSetup(content, content.length); + completion.then(function (result) { + assert.equal(result.items.length, 3); + }).then(done, done); + }); + + it('Provide completion from schema declared in file with several attributes', done => { + const content = `# yaml-language-server: $schema=${uri} anothermodeline=value\n- `; + const completion = parseSetup(content, content.length); + completion.then(function (result) { + assert.equal(result.items.length, 3); + }).then(done, done); + }); + + it('Provide completion from schema declared in file with several documents', done => { + const documentContent1 = `# yaml-language-server: $schema=${uri} anothermodeline=value\n- `; + const content = `${documentContent1}\n---\n- `; + const completionDoc1 = parseSetup(content, documentContent1.length); + completionDoc1.then(function (result) { + assert.equal(result.items.length, 3, `Expecting 3 items in completion but found ${result.items.length}`); + assert.deepEqual(result.items[0], createExpectedCompletion('prop1', 'prop1: $1', 0, 2, 0, 2, 10, 2, { + documentation: '' + })); + assert.deepEqual(result.items[1], createExpectedCompletion('prop2', 'prop2: $1', 0, 2, 0, 2, 10, 2, { + documentation: '' + })); + assert.deepEqual(result.items[2], createExpectedCompletion('prop3', 'prop3: $1', 0, 2, 0, 2, 10, 2, { + documentation: '' + })); + const completionDoc2 = parseSetup(content, content.length); + completionDoc2.then(function (resultDoc2) { + assert.equal(resultDoc2.items.length, 0, `Expecting no items in completion but found ${resultDoc2.items.length}`); + }).then(done, done); + }); + + }); + }); }); }); diff --git a/test/parser.test.ts b/test/parser.test.ts new file mode 100644 index 00000000..3438a0d2 --- /dev/null +++ b/test/parser.test.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Red Hat. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import assert = require('assert'); +import {parse} from './../src/languageservice/parser/yamlParser07'; + +suite('Test parser', () => { + + describe('test parser', function () { + + it('parse emtpy text', () => { + const parsedDocument = parse(''); + assert(parsedDocument.documents.length === 0, 'A document has been created for an empty text'); + }); + + it('parse single document', () => { + const parsedDocument = parse('test'); + assert(parsedDocument.documents.length === 1, `A single document should be available but there are ${parsedDocument.documents.length}`); + assert(parsedDocument.documents[0].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[0].root.children.length}`); + }); + + it('parse single document with directives', () => { + const parsedDocument = parse('%TAG demo\n---\ntest'); + assert(parsedDocument.documents.length === 1, `A single document should be available but there are ${parsedDocument.documents.length}`); + assert(parsedDocument.documents[0].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[0].root.children.length}`); + }); + + it('parse 2 documents', () => { + const parsedDocument = parse('test\n---\ntest2'); + assert(parsedDocument.documents.length === 2, `2 documents should be available but there are ${parsedDocument.documents.length}`); + assert(parsedDocument.documents[0].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[0].root.children.length}`); + assert(parsedDocument.documents[0].root.value === 'test'); + assert(parsedDocument.documents[1].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[1].root.children.length}`); + assert(parsedDocument.documents[1].root.value === 'test2'); + }); + + it('parse 3 documents', () => { + const parsedDocument = parse('test\n---\ntest2\n---\ntest3'); + assert(parsedDocument.documents.length === 3, `3 documents should be available but there are ${parsedDocument.documents.length}`); + assert(parsedDocument.documents[0].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[0].root.children.length}`); + assert(parsedDocument.documents[0].root.value === 'test'); + assert(parsedDocument.documents[1].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[1].root.children.length}`); + assert(parsedDocument.documents[1].root.value === 'test2'); + assert(parsedDocument.documents[2].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[2].root.children.length}`); + assert(parsedDocument.documents[2].root.value === 'test3'); + }); + + it('parse single document with comment', () => { + const parsedDocument = parse('# a comment\ntest'); + assert(parsedDocument.documents.length === 1, `A single document should be available but there are ${parsedDocument.documents.length}`); + assert(parsedDocument.documents[0].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[0].root.children.length}`); + assert(parsedDocument.documents[0].lineComments.length === 1); + assert(parsedDocument.documents[0].lineComments[0] === '# a comment'); + }); + + it('parse 2 documents with comment', () => { + const parsedDocument = parse('# a comment\ntest\n---\n# a second comment\ntest2'); + assert(parsedDocument.documents.length === 2, `2 documents should be available but there are ${parsedDocument.documents.length}`); + assert(parsedDocument.documents[0].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[0].root.children.length}`); + assert(parsedDocument.documents[0].lineComments.length === 1); + assert(parsedDocument.documents[0].lineComments[0] === '# a comment'); + + assert(parsedDocument.documents[1].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[0].root.children.length}`); + assert(parsedDocument.documents[1].lineComments.length === 1); + assert(parsedDocument.documents[1].lineComments[0] === '# a second comment'); + }); + + it('parse 2 documents with comment and a directive', () => { + const parsedDocument = parse('%TAG demo\n---\n# a comment\ntest\n---\n# a second comment\ntest2'); + assert(parsedDocument.documents.length === 2, `2 documents should be available but there are ${parsedDocument.documents.length}`); + assert(parsedDocument.documents[0].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[0].root.children.length}`); + assert(parsedDocument.documents[0].lineComments.length === 1); + assert(parsedDocument.documents[0].lineComments[0] === '# a comment'); + + assert(parsedDocument.documents[1].root.children.length === 0, `There should no children available but there are ${parsedDocument.documents[0].root.children.length}`); + assert(parsedDocument.documents[1].lineComments.length === 1); + assert(parsedDocument.documents[1].lineComments[0] === '# a second comment'); + }); + }); +});