Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow # $schema: <url> to specify an inline schema #992

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
2 changes: 1 addition & 1 deletion test/schemaValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1289,7 +1289,7 @@ obj:
4,
18,
DiagnosticSeverity.Error,
'yaml-schema: Package',
'yaml-schema: Composer Package',
'https://raw.githubusercontent.com/composer/composer/master/res/composer-schema.json'
)
);
Expand Down
Loading