diff --git a/README.md b/README.md index 6d6c052a..3d93b5bf 100755 --- a/README.md +++ b/README.md @@ -243,19 +243,21 @@ yaml.schemas: { It is possible to specify a yaml schema using a modeline. ```yaml -# yaml-language-server: $schema= +# $schema: ``` +_Note_: In previous versions, `# yaml-language-server: $schema=` 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: ` 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 diff --git a/src/languageservice/services/modelineUtil.ts b/src/languageservice/services/modelineUtil.ts index 46badd63..77522d89 100644 --- a/src/languageservice/services/modelineUtil.ts +++ b/src/languageservice/services/modelineUtil.ts @@ -11,20 +11,21 @@ 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]; } } } @@ -32,6 +33,6 @@ export function getSchemaFromModeline(doc: SingleYAMLDocument | JSONDocument): s } 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; } diff --git a/src/languageservice/services/yamlCompletion.ts b/src/languageservice/services/yamlCompletion.ts index 513a23d6..06c4a8be 100644 --- a/src/languageservice/services/yamlCompletion.ts +++ b/src/languageservice/services/yamlCompletion.ts @@ -309,7 +309,7 @@ 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); @@ -317,19 +317,22 @@ export class YamlCompletion { } 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; } diff --git a/test/autoCompletion.test.ts b/test/autoCompletion.test.ts index eba8fa6d..b675b537 100644 --- a/test/autoCompletion.test.ts +++ b/test/autoCompletion.test.ts @@ -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); @@ -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 @@ -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, @@ -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')); @@ -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')); @@ -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', () => { diff --git a/test/schema.test.ts b/test/schema.test.ts index 1747b1c4..b105b26b 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -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 () => { @@ -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 () => { @@ -716,14 +726,17 @@ 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 () => { @@ -731,22 +744,39 @@ describe('JSON Schema', () => { '# 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 { diff --git a/test/schemaValidation.test.ts b/test/schemaValidation.test.ts index 71268db7..caed1f08 100644 --- a/test/schemaValidation.test.ts +++ b/test/schemaValidation.test.ts @@ -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' ) ); diff --git a/test/yamlSchemaService.test.ts b/test/yamlSchemaService.test.ts index 62746a41..3a76c738 100644 --- a/test/yamlSchemaService.test.ts +++ b/test/yamlSchemaService.test.ts @@ -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- `; @@ -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": { @@ -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": { @@ -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": { @@ -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); @@ -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);