Skip to content

Commit

Permalink
feat: allow # $schema: <url> to specify a schema
Browse files Browse the repository at this point in the history
  • Loading branch information
Nerixyz committed Oct 16, 2024
1 parent dd438b8 commit 4c7f17b
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 32 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,19 +243,21 @@ yaml.schemas: {
It is possible to specify a yaml schema using a modeline.

```yaml
# yaml-language-server: $schema=<urlToTheSchema>
# $schema: <urlToTheSchema>
```

_Note_: In previous versions, `# yaml-language-server: $schema=<url>` was a way of specifying a schema. Although still supported for backwards compatibility, this is discouraged as it isn't supported in other editors. `# $schema: <url>` is supported by IntelliJ IDEs as well.

Also it is possible to use relative path in a modeline:

```yaml
# yaml-language-server: $schema=../relative/path/to/schema
# $schema: ../relative/path/to/schema
```

or absolute path:

```yaml
# yaml-language-server: $schema=/absolute/path/to/schema
# $schema: /absolute/path/to/schema
```

### Schema priority
Expand Down
13 changes: 7 additions & 6 deletions src/languageservice/services/modelineUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,28 @@ import { JSONDocument } from '../parser/jsonParser07';
* Public for testing purpose, not part of the API.
* @param doc
*/
export function getSchemaFromModeline(doc: SingleYAMLDocument | JSONDocument): string {
export function getSchemaFromModeline(doc: SingleYAMLDocument | JSONDocument): string | undefined {
if (doc instanceof SingleYAMLDocument) {
const yamlLanguageServerModeline = doc.lineComments.find((lineComment) => {
return isModeline(lineComment);
});
if (yamlLanguageServerModeline != undefined) {
const schemaMatchs = yamlLanguageServerModeline.match(/\$schema=\S+/g);
if (schemaMatchs !== null && schemaMatchs.length >= 1) {
if (schemaMatchs.length >= 2) {
const schemaMatchs = yamlLanguageServerModeline.matchAll(/\$schema(?:=|:\s*)(\S+)/g);
const { value: schemaMatch, done } = schemaMatchs.next();
if (!done) {
if (!schemaMatchs.next().done) {
console.log(
'Several $schema attributes have been found on the yaml-language-server modeline. The first one will be picked.'
);
}
return schemaMatchs[0].substring('$schema='.length);
return schemaMatch[1];
}
}
}
return undefined;
}

export function isModeline(lineText: string): boolean {
const matchModeline = lineText.match(/^#\s+yaml-language-server\s*:/g);
const matchModeline = lineText.match(/^#\s+(?:yaml-language-server|\$schema)\s*:/g);
return matchModeline !== null && matchModeline.length === 1;
}
31 changes: 17 additions & 14 deletions src/languageservice/services/yamlCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,27 +309,30 @@ export class YamlCompletion {
const inlineSchemaCompletion = {
kind: CompletionItemKind.Text,
label: 'Inline schema',
insertText: '# yaml-language-server: $schema=',
insertText: '# $schema: ',
insertTextFormat: InsertTextFormat.PlainText,
};
result.items.push(inlineSchemaCompletion);
}
}

if (isModeline(lineContent) || isInComment(doc.tokens, offset)) {
const schemaIndex = lineContent.indexOf('$schema=');
if (schemaIndex !== -1 && schemaIndex + '$schema='.length <= position.character) {
this.schemaService.getAllSchemas().forEach((schema) => {
const schemaIdCompletion: CompletionItem = {
kind: CompletionItemKind.Constant,
label: schema.name ?? schema.uri,
detail: schema.description,
insertText: schema.uri,
insertTextFormat: InsertTextFormat.PlainText,
insertTextMode: InsertTextMode.asIs,
};
result.items.push(schemaIdCompletion);
});
const schemaIndex = lineContent.indexOf('$schema');
if (schemaIndex !== -1 && schemaIndex + '$schema:'.length <= position.character) {
const postSchemaChar = lineContent[schemaIndex + '$schema'.length];
if (postSchemaChar === ':' || postSchemaChar === '=') {
this.schemaService.getAllSchemas().forEach((schema) => {
const schemaIdCompletion: CompletionItem = {
kind: CompletionItemKind.Constant,
label: schema.name ?? schema.uri,
detail: schema.description,
insertText: schema.uri,
insertTextFormat: InsertTextFormat.PlainText,
insertTextMode: InsertTextMode.asIs,
};
result.items.push(schemaIdCompletion);
});
}
}
return result;
}
Expand Down
77 changes: 77 additions & 0 deletions test/autoCompletion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1909,6 +1909,26 @@ describe('Auto Completion Tests', () => {
.then(done, done);
});

it('Provide completion from schema declared in file without addon name', (done) => {
const content = `# $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 without addon name with space', (done) => {
const content = `# $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);
Expand All @@ -1919,6 +1939,16 @@ describe('Auto Completion Tests', () => {
.then(done, done);
});

it('Provide completion from schema declared in file with several attributes without addon name', (done) => {
const content = `# $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', async () => {
const documentContent1 = `# yaml-language-server: $schema=${uri} anothermodeline=value\n- `; // 149
const content = `${documentContent1}|\n|---\n- `; // len: 156, pos: 149
Expand All @@ -1929,6 +1959,16 @@ describe('Auto Completion Tests', () => {
assert.equal(resultDoc2.items.length, 0, `Expecting no items in completion but found ${resultDoc2.items.length}`);
});

it('Provide completion from schema declared in file with several documents without LSP name', async () => {
const documentContent1 = `# $schema: ${uri} anothermodeline=value\n- `; // 149
const content = `${documentContent1}|\n|---\n- `; // len: 156, pos: 149
const result = await parseSetup(content);
assert.equal(result.items.length, 3, `Expecting 3 items in completion but found ${result.items.length}`);

const resultDoc2 = await parseSetup(content, content.length);
assert.equal(resultDoc2.items.length, 0, `Expecting no items in completion but found ${resultDoc2.items.length}`);
});

it('should handle absolute path', async () => {
const documentContent = `# yaml-language-server: $schema=${path.join(
__dirname,
Expand Down Expand Up @@ -1998,6 +2038,17 @@ describe('Auto Completion Tests', () => {
assert.strictEqual(result.items.length, 0, `Expecting 0 item in completion but found ${result.items.length}`);
});

it('should not provide modeline completion on first character when modeline already present without LSP name', async () => {
const testTextDocument = setupSchemaIDTextDocument('# $schema:', path.join(__dirname, 'test.yaml'));
yamlSettings.documents = new TextDocumentTestManager();
(yamlSettings.documents as TextDocumentTestManager).set(testTextDocument);
const result = await languageHandler.completionHandler({
position: testTextDocument.positionAt(0),
textDocument: testTextDocument,
});
assert.strictEqual(result.items.length, 0, `Expecting 0 item in completion but found ${result.items.length}`);
});

it('should provide schema id completion in modeline', async () => {
const modeline = '# yaml-language-server: $schema=';
const testTextDocument = setupSchemaIDTextDocument(modeline, path.join(__dirname, 'test.yaml'));
Expand All @@ -2011,6 +2062,19 @@ describe('Auto Completion Tests', () => {
assert.strictEqual(result.items[0].label, 'http://google.com');
});

it('should provide schema id completion in modeline without LSP name', async () => {
const modeline = '# $schema: ';
const testTextDocument = setupSchemaIDTextDocument(modeline, path.join(__dirname, 'test.yaml'));
yamlSettings.documents = new TextDocumentTestManager();
(yamlSettings.documents as TextDocumentTestManager).set(testTextDocument);
const result = await languageHandler.completionHandler({
position: testTextDocument.positionAt(modeline.length),
textDocument: testTextDocument,
});
assert.strictEqual(result.items.length, 1, `Expecting 1 item in completion but found ${result.items.length}`);
assert.strictEqual(result.items[0].label, 'http://google.com');
});

it('should provide schema id completion in modeline for any line', async () => {
const modeline = 'foo:\n bar\n# yaml-language-server: $schema=';
const testTextDocument = setupSchemaIDTextDocument(modeline, path.join(__dirname, 'test.yaml'));
Expand All @@ -2023,6 +2087,19 @@ describe('Auto Completion Tests', () => {
assert.strictEqual(result.items.length, 1, `Expecting 1 item in completion but found ${result.items.length}`);
assert.strictEqual(result.items[0].label, 'http://google.com');
});

it('should provide schema id completion in modeline for any line without LSP name', async () => {
const modeline = 'foo:\n bar\n# $schema: ';
const testTextDocument = setupSchemaIDTextDocument(modeline, path.join(__dirname, 'test.yaml'));
yamlSettings.documents = new TextDocumentTestManager();
(yamlSettings.documents as TextDocumentTestManager).set(testTextDocument);
const result = await languageHandler.completionHandler({
position: testTextDocument.positionAt(modeline.length),
textDocument: testTextDocument,
});
assert.strictEqual(result.items.length, 1, `Expecting 1 item in completion but found ${result.items.length}`);
assert.strictEqual(result.items[0].label, 'http://google.com');
});
});

describe('Configuration based indentation', () => {
Expand Down
38 changes: 34 additions & 4 deletions test/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,10 +615,18 @@ 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 result = await languageService.doComplete(testTextDocument, Position.create(1, 0), false);
assert.strictEqual(result.items.length, 1);
assert.strictEqual(result.items[0].label, 'modeline');
{
const testTextDocument = setupTextDocument(`# yaml-language-server: $schema=${schemaModelineSample}\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');
}
{
const testTextDocument = setupTextDocument(`# $schema: ${schemaModelineSample}\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('Manually setting schema takes precendence over all other lower priority schemas', async () => {
Expand Down Expand Up @@ -704,10 +712,12 @@ describe('JSON Schema', () => {
describe('Test getSchemaFromModeline', function () {
it('simple case', async () => {
checkReturnSchemaUrl('# yaml-language-server: $schema=expectedUrl', 'expectedUrl');
checkReturnSchemaUrl('# $schema:expectedUrl', 'expectedUrl');
});

it('with several spaces between # and yaml-language-server', async () => {
checkReturnSchemaUrl('# yaml-language-server: $schema=expectedUrl', 'expectedUrl');
checkReturnSchemaUrl('# $schema:expectedUrl', 'expectedUrl');
});

it('with several spaces between yaml-language-server and :', async () => {
Expand All @@ -716,37 +726,57 @@ describe('JSON Schema', () => {

it('with several spaces between : and $schema', async () => {
checkReturnSchemaUrl('# yaml-language-server: $schema=expectedUrl', 'expectedUrl');
checkReturnSchemaUrl('# $schema: expectedUrl', 'expectedUrl');
});

it('with several spaces at the end', async () => {
checkReturnSchemaUrl('# yaml-language-server: $schema=expectedUrl ', 'expectedUrl');
checkReturnSchemaUrl('# $schema: expectedUrl ', 'expectedUrl');
});

it('with several spaces at several places', async () => {
checkReturnSchemaUrl('# yaml-language-server : $schema=expectedUrl ', 'expectedUrl');
checkReturnSchemaUrl('# $schema: expectedUrl ', 'expectedUrl');
});

it('with several attributes', async () => {
checkReturnSchemaUrl(
'# yaml-language-server: anotherAttribute=test $schema=expectedUrl aSecondAttribtute=avalue',
'expectedUrl'
);
checkReturnSchemaUrl('# $schema: expectedUrl aSecondAttribtute=avalue anotherAttribute=test', 'expectedUrl');
});

it('with tabs', async () => {
checkReturnSchemaUrl('#\tyaml-language-server:\t$schema=expectedUrl', 'expectedUrl');
checkReturnSchemaUrl('#\t$schema:\texpectedUrl', 'expectedUrl');
});

it('with several $schema - pick the first', async () => {
checkReturnSchemaUrl('# yaml-language-server: $schema=url1 $schema=url2', 'url1');
checkReturnSchemaUrl('# $schema: url1 $schema: url2', 'url1');
});

it('no schema returned if not yaml-language-server', async () => {
checkReturnSchemaUrl('# somethingelse: $schema=url1', undefined);
checkReturnSchemaUrl('# somethingelse: $schema:url1', undefined);
});

it('no schema returned if not $schema', async () => {
checkReturnSchemaUrl('# yaml-language-server: $notschema=url1', undefined);
checkReturnSchemaUrl('# $notschema: url1', undefined);
});

it('no schema returned if spaces/tabs before colon', async () => {
checkReturnSchemaUrl('# $schema :url1', undefined);
checkReturnSchemaUrl('# $schema :url1', undefined);
checkReturnSchemaUrl('# $schema\t:url1', undefined);
});

it('no schema returned if there is no colon', async () => {
checkReturnSchemaUrl('# $schema url1', undefined);
checkReturnSchemaUrl('# $schema?url1', undefined);
checkReturnSchemaUrl('# $schema+ url1', undefined);
});

function checkReturnSchemaUrl(modeline: string, expectedResult: string): void {
Expand Down
32 changes: 27 additions & 5 deletions test/yamlSchemaService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ describe('YAML Schema Service', () => {
expect(requestServiceMock).calledOnceWith('http://json-schema.org/draft-07/schema#');
});

it('should handle inline schema http url without LSP prefix', () => {
const documentContent = `# $schema:http://json-schema.org/draft-07/schema# anothermodeline=value\n`;
const content = `${documentContent}\n---\n- `;
const yamlDock = parse(content);

const service = new SchemaService.YAMLSchemaService(requestServiceMock);
service.getSchemaForResource('', yamlDock.documents[0]);

expect(requestServiceMock).calledOnceWith('http://json-schema.org/draft-07/schema#');
});

it('should handle inline schema https url', () => {
const documentContent = `# yaml-language-server: $schema=https://json-schema.org/draft-07/schema# anothermodeline=value\n`;
const content = `${documentContent}\n---\n- `;
Expand All @@ -47,8 +58,19 @@ describe('YAML Schema Service', () => {
expect(requestServiceMock).calledOnceWith('https://json-schema.org/draft-07/schema#');
});

it('should handle inline schema https url without LSP prefix', () => {
const documentContent = `# $schema:https://json-schema.org/draft-07/schema# anothermodeline=value\n`;
const content = `${documentContent}\n---\n- `;
const yamlDock = parse(content);

const service = new SchemaService.YAMLSchemaService(requestServiceMock);
service.getSchemaForResource('', yamlDock.documents[0]);

expect(requestServiceMock).calledOnceWith('https://json-schema.org/draft-07/schema#');
});

it('should handle url with fragments', async () => {
const content = `# yaml-language-server: $schema=https://json-schema.org/draft-07/schema#/definitions/schemaArray\nfoo: bar`;
const content = `# $schema: https://json-schema.org/draft-07/schema#/definitions/schemaArray\nfoo: bar`;
const yamlDock = parse(content);

requestServiceMock = sandbox.fake.resolves(`{"definitions": {"schemaArray": {
Expand All @@ -68,7 +90,7 @@ describe('YAML Schema Service', () => {
});

it('should handle url with fragments when root object is schema', async () => {
const content = `# yaml-language-server: $schema=https://json-schema.org/draft-07/schema#/definitions/schemaArray`;
const content = `# $schema: https://json-schema.org/draft-07/schema#/definitions/schemaArray`;
const yamlDock = parse(content);

requestServiceMock = sandbox.fake.resolves(`{"definitions": {"schemaArray": {
Expand All @@ -94,7 +116,7 @@ describe('YAML Schema Service', () => {
});

it('should handle file path with fragments', async () => {
const content = `# yaml-language-server: $schema=schema.json#/definitions/schemaArray\nfoo: bar`;
const content = `# $schema: schema.json#/definitions/schemaArray\nfoo: bar`;
const yamlDock = parse(content);

requestServiceMock = sandbox.fake.resolves(`{"definitions": {"schemaArray": {
Expand All @@ -120,7 +142,7 @@ describe('YAML Schema Service', () => {
});

it('should handle modeline schema comment in the middle of file', () => {
const documentContent = `foo:\n bar\n# yaml-language-server: $schema=https://json-schema.org/draft-07/schema#\naa:bbb\n`;
const documentContent = `foo:\n bar\n# $schema: https://json-schema.org/draft-07/schema#\naa:bbb\n`;
const content = `${documentContent}`;
const yamlDock = parse(content);

Expand All @@ -131,7 +153,7 @@ describe('YAML Schema Service', () => {
});

it('should handle modeline schema comment in multiline comments', () => {
const documentContent = `foo:\n bar\n#first comment\n# yaml-language-server: $schema=https://json-schema.org/draft-07/schema#\naa:bbb\n`;
const documentContent = `foo:\n bar\n#first comment\n# $schema: https://json-schema.org/draft-07/schema#\naa:bbb\n`;
const content = `${documentContent}`;
const yamlDock = parse(content);

Expand Down

0 comments on commit 4c7f17b

Please sign in to comment.