Skip to content

Commit

Permalink
Support schema associations in yaml files #204
Browse files Browse the repository at this point in the history
- association done using modeline "# yaml-language-server:
$schema=<mySchemaURL">

Signed-off-by: Aurélien Pupier <[email protected]>
  • Loading branch information
apupier committed Jul 21, 2020
1 parent 7346049 commit f145387
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 2 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
#### 0.10.0

- Allows to declare a schema inside the yaml file through modeline `# yaml-language-server: $schema=<urlOfTheSchema>`

#### 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)
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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:
Expand All @@ -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=<urlToTheSchema>
```

## Clients
This repository only contains the server implementation. Here are some known clients consuming this server:

Expand Down
24 changes: 24 additions & 0 deletions src/languageservice/parser/yamlParser07.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -29,13 +33,15 @@ export class SingleYAMLDocument extends JSONDocument {
public warnings: YAMLDocDiagnostic[];
public isKubernetes: boolean;
public currentDocIndex: number;
public lineComments: string[];

constructor(lines: number[]) {
super(null, []);
this.lines = lines;
this.root = null;
this.errors = [];
this.warnings = [];
this.lineComments = [];
}

public getSchemas(schema, doc, node) {
Expand Down Expand Up @@ -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);
}
});
}
34 changes: 33 additions & 1 deletion src/languageservice/services/yamlSchemaService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | string[]>;
Expand Down Expand Up @@ -216,9 +220,15 @@ export class YAMLSchemaService extends JSONSchemaService {

public getSchemaForResource (resource: string, doc = undefined): Thenable<ResolvedSchema> {
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()) {
Expand Down Expand Up @@ -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);
Expand Down
50 changes: 50 additions & 0 deletions test/autoCompletion.withschemainfile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Red Hat. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { TextDocument } from 'vscode-languageserver';
import { getLanguageService } from '../src/languageservice/yamlLanguageService';
import { toFsPath, schemaRequestService, workspaceContext } from './utils/testHelper';
import assert = require('assert');
import path = require('path');

const languageService = getLanguageService(schemaRequestService, workspaceContext, [], null);

const languageSettings = {
schemas: [],
completion: true
};

const uri = toFsPath(path.join(__dirname, './fixtures/testArrayMaxProperties.json'));
languageService.configure(languageSettings);

suite('Auto Completion Tests with schema defined in file', () => {

describe('yamlCompletion with schema defined in file', function () {

function setup(content: string) {
return TextDocument.create('file://~/Desktop/vscode-k8s/test-schemainfile.yaml', 'yaml', 0, content);
}

function parseSetup(content: string, position) {
const testTextDocument = setup(content);
return languageService.doComplete(testTextDocument, testTextDocument.positionAt(position), false);
}

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);
});
});
});
84 changes: 84 additions & 0 deletions test/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*---------------------------------------------------------------------------------------------
* 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';
import { YAMLDocument } from '../src/languageservice/parser/yamlParser07';
import { getNodeValue } from '../src/languageservice/parser/jsonParser07';


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 shoudl 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 shoudl 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');
});
});
});

0 comments on commit f145387

Please sign in to comment.