diff --git a/README.md b/README.md index b1f27e2..0d5a3e5 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,31 @@ Avoid using 'float'. Floats lead to fragile CSS that is easy to break if one asp Selectors should not contain IDs because these rules are too tightly coupled with the HTML. Default is `"ignore"`. +### Emmet in completion list + +You can now see your Emmet abbreviations expanded and included in the completion list. +An [upstream issue](https://github.com/Microsoft/TypeScript/issues/21999) with typescript blocks the Emmet entry in the completion list to get updated as you type. +So for now you will have to press `Ctrl+Space` after typing out the abbreviation. + +The below settings which are in sync with general Emmet settings in VS Code control the expanded Emmet abbreviations in the auto-completion list. + +#### showExpandedAbbreviation +``` +"always" | "never" +``` + +Controls whether or not expanded Emmet abbreviations should show up in the completion list + +#### showSuggestionsAsSnippets +``` +`true` | `false` +``` +If true, then Emmet suggestions will show up as snippets allowing you to order them as per editor.snippetSuggestions setting. + +#### preferences + +Preferences used to modify behavior of some actions and resolvers of Emmet. + ## Contributing diff --git a/e2e/disabled-emmet-project-fixture/.vscode/settings.json b/e2e/disabled-emmet-project-fixture/.vscode/settings.json new file mode 100644 index 0000000..55712c1 --- /dev/null +++ b/e2e/disabled-emmet-project-fixture/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} \ No newline at end of file diff --git a/e2e/disabled-emmet-project-fixture/index.ts b/e2e/disabled-emmet-project-fixture/index.ts new file mode 100644 index 0000000..525db1e --- /dev/null +++ b/e2e/disabled-emmet-project-fixture/index.ts @@ -0,0 +1,3 @@ +css` + +` \ No newline at end of file diff --git a/e2e/disabled-emmet-project-fixture/main.ts b/e2e/disabled-emmet-project-fixture/main.ts new file mode 100644 index 0000000..e69de29 diff --git a/e2e/disabled-emmet-project-fixture/tsconfig.json b/e2e/disabled-emmet-project-fixture/tsconfig.json new file mode 100644 index 0000000..7cb5d0d --- /dev/null +++ b/e2e/disabled-emmet-project-fixture/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "es2015", + "module": "commonjs", + "plugins": [ + { "name": "../../../lib", "tags": ["css"], "emmet": { "showExpandedAbbreviation": "never"} } + ] + } +} diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 7c53fc6..d9c9d12 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -2,6 +2,7 @@ "name": "e2e", "version": "1.0.0", "lockfileVersion": 1, + "requires": true, "dependencies": { "typescript": { "version": "2.4.2", diff --git a/e2e/server-fixture/index.js b/e2e/server-fixture/index.js index c23b3e0..d61e6bb 100644 --- a/e2e/server-fixture/index.js +++ b/e2e/server-fixture/index.js @@ -4,14 +4,14 @@ const fs = require('fs'); const readline = require('readline'); class TSServer { - constructor() { + constructor(project) { const logfile = path.join(__dirname, 'log.txt'); const tsserverPath = path.join(__dirname, '..', 'node_modules', 'typescript', 'lib', 'tsserver'); const server = fork(tsserverPath, [ '--logVerbosity', 'verbose', '--logFile', logfile ], { - cwd: path.join(__dirname, '..', 'project-fixture'), + cwd: path.join(__dirname, '..', project), stdio: ['pipe', 'pipe', 'pipe', 'ipc'], }); this._exitPromise = new Promise((resolve, reject) => { @@ -48,8 +48,8 @@ class TSServer { } } -function createServer() { - return new TSServer(); +function createServer(project) { + return new TSServer(project || 'project-fixture'); } module.exports = createServer; diff --git a/e2e/tests/emmetCompletions.js b/e2e/tests/emmetCompletions.js new file mode 100644 index 0000000..e228e21 --- /dev/null +++ b/e2e/tests/emmetCompletions.js @@ -0,0 +1,159 @@ +const assert = require('chai').assert; +const createServer = require('../server-fixture'); +const { openMockFile, getFirstResponseOfType } = require('./_helpers'); + +const mockFileName = 'main.ts'; + +describe('Emmet Completions', () => { + it('shouldnt return emmet property completions when disabled', () => { + const server = createServer('disabled-emmet-project-fixture'); + openMockFile(server, mockFileName, 'const q = css`m10-20`'); + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 21, line: 1 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.every(item => item.name !== 'margin: 10px 20px;')); + }); + }); + + it('should return emmet property completions for single line string', () => { + const server = createServer(); + openMockFile(server, mockFileName, 'const q = css`m10-20`'); + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 21, line: 1 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); + }); + }); + + + it('should return emmet property completions for multiline string', () => { + const server = createServer(); + openMockFile(server, mockFileName, [ + 'const q = css`', + 'm10-20', + '`' + ].join('\n')); + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 7, line: 2 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); + }); + }); + + it('should return emmet property completions for nested selector', () => { + const server = createServer(); + openMockFile(server, mockFileName, 'const q = css`position: relative; &:hover { m10-20 }`'); + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 51, line: 1 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); + }); + }); + + it('should return emmet completions when placeholder is used as property', () => { + const server = createServer(); + openMockFile(server, mockFileName, 'css`m10-20 ; boarder: 1px solid ${"red"};`'); + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 11, line: 1 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); + }); + }); + + it('should return emmet completions after where placeholder is used as property', () => { + const server = createServer(); + openMockFile(server, mockFileName, 'css`border: 1px solid ${"red"}; m10-20`'); + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 39, line: 1 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.some(item => item.name === 'margin: 10px 20px;')); + }); + }); + + it('should return emmet completions between were placeholders are used as properties', () => { + const server = createServer(); + openMockFile(server, mockFileName, 'css`boarder: 1px solid ${"red"}; color: #12; margin: ${20}; `') + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 44, line: 1 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); + }); + }); + + it('should return emmet completions on tagged template string with placeholder using dotted tag', () => { + const server = createServer(); + openMockFile(server, mockFileName, 'css.x`color: #12 ; boarder: 1px solid ${"red"};`'); + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 17, line: 1 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.success); + assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); + }); + }); + + it('should return styled emmet completions inside of nested placeholder', () => { + const server = createServer(); + openMockFile(server, mockFileName, 'styled`background: red; ${(() => css`color: #12`)()}`;'); + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 48, line: 1 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); + }); + }); + + it('should handle emmet completions in multiline value placeholder correctly ', () => { + const server = createServer(); + openMockFile(server, mockFileName, [ + 'css`margin: ${', + '0', + "}; color: #12`"].join('\n')); + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 14, line: 3 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); + }); + }); + + it('should handle emmet completions in multiline rule placeholder correctly ', () => { + const server = createServer(); + openMockFile(server, mockFileName, [ + 'css`', + '${', + 'css`margin: 0;`', + '}', + 'color: #12`'].join('\n')); + server.send({ command: 'completions', arguments: { file: mockFileName, offset: 11, line: 5 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); + }); + }); + + it('should return emmet completions inside of nested selector xx', () => { + const server = createServer(); + openMockFile(server, mockFileName, [ + 'css`', + ' color: red;', + ' &:hover {', + ' color: #12 ', + ' }', + '`'].join('\n')); + server.send({ command: 'completions', arguments: { file: mockFileName, line: 4, offset: 19 } }); + + return server.close().then(() => { + const completionsResponse = getFirstResponseOfType('completions', server); + assert.isTrue(completionsResponse.body.some(item => item.name === '#121212')); + }); + }); +}) diff --git a/package-lock.json b/package-lock.json index 4db9141..027245d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,14 @@ { "name": "typescript-styled-plugin", - "version": "0.4.1", + "version": "0.5.1", "lockfileVersion": 1, "requires": true, "dependencies": { + "@emmetio/extract-abbreviation": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@emmetio/extract-abbreviation/-/extract-abbreviation-0.1.4.tgz", + "integrity": "sha512-VG5fKVJodJZu1qAWCqmSP4CCi8qN7yXQstnomWdvwfxwmsEJZmtPRtFq5Ztg7KPgzqPhovOBIX4vt8pUAJJn0g==" + }, "@types/node": { "version": "7.0.48", "resolved": "https://registry.npmjs.org/@types/node/-/node-7.0.48.tgz", @@ -298,6 +303,11 @@ "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE=", "dev": true }, + "jsonc-parser": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-1.0.1.tgz", + "integrity": "sha512-CFJcfIRF751s7MypvvQX8MDWYrPjYlaAvXtaWRXT+cYgUE182BtHhvzpOIUtckCQe1N+WFoYtSl8ar9tAWjqBg==" + }, "lodash._baseassign": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/lodash._baseassign/-/lodash._baseassign-3.2.0.tgz", @@ -539,23 +549,39 @@ "dev": true }, "typescript-template-language-service-decorator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/typescript-template-language-service-decorator/-/typescript-template-language-service-decorator-1.1.0.tgz", - "integrity": "sha512-+mALyEIQTMskZJErim4wG60tBWmXwk1lyuWqZk5K1/pDC8KmmJdyeJo0Gye49cSY4P7xSsJzARuL481rnwqvbg==" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typescript-template-language-service-decorator/-/typescript-template-language-service-decorator-1.2.0.tgz", + "integrity": "sha512-rF0jrvpYn6Ec2jnxBHF1st/HEl84LV7od8872o82zHgKEU1vFhT4x6fvuLi5UD0gl2gKv3zSefG18DoeUHE7ig==" }, "vscode-css-languageservice": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-3.0.5.tgz", "integrity": "sha512-Ue8oVJBLyLHHDqDKKLzADBYKuQ+sNMmWc/ghY6MUbxGDwIIJvKMAfTqUto91icE7nCBsyQoHCsTRsrycV6J4Jg==", "requires": { - "vscode-languageserver-types": "3.5.0", "vscode-nls": "2.0.2" } }, + "vscode-emmet-helper": { + "version": "1.1.36", + "resolved": "https://registry.npmjs.org/vscode-emmet-helper/-/vscode-emmet-helper-1.1.36.tgz", + "integrity": "sha512-MbGlzcY2NZPeF6drPEpSJbiBKUt6q1EhBjQX8DbQxyjdUFscAqb9Ke+62flRX0H1toimovd8Kl5cyhKV0IoZLw==", + "requires": { + "@emmetio/extract-abbreviation": "0.1.4", + "jsonc-parser": "1.0.1", + "vscode-languageserver-types": "3.6.0-next.1" + }, + "dependencies": { + "vscode-languageserver-types": { + "version": "3.6.0-next.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.6.0-next.1.tgz", + "integrity": "sha512-n4G+hCgZwAhtcJSCkwJP153TLdcEBWwrIrb3Su/SpOkhmU7KjDgxaQBLA45hf+QbhB8uKQb+TVStPvbuYFHSMA==" + } + } + }, "vscode-languageserver-types": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.5.0.tgz", - "integrity": "sha1-5I15li8LjgLelV4/UkkI4rGcA3Q=" + "version": "3.6.0-next.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.6.0-next.1.tgz", + "integrity": "sha512-n4G+hCgZwAhtcJSCkwJP153TLdcEBWwrIrb3Su/SpOkhmU7KjDgxaQBLA45hf+QbhB8uKQb+TVStPvbuYFHSMA==" }, "vscode-nls": { "version": "2.0.2", diff --git a/package.json b/package.json index 1c74c66..5cbd8fb 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "dependencies": { "typescript-template-language-service-decorator": "^1.2.0", "vscode-css-languageservice": "^3.0.5", - "vscode-languageserver-types": "^3.5.0" + "vscode-languageserver-types": "^3.6.0-next.1", + "vscode-emmet-helper": "1.1.36" }, "files": [ "lib" diff --git a/src/configuration.ts b/src/configuration.ts index 685bcf8..641835d 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -5,6 +5,7 @@ export interface TsStyledPluginConfiguration { tags: string[]; validate: boolean; lint: { [key: string]: any }; + emmet: { [key: string]: any }; } export const defaultConfiguration: TsStyledPluginConfiguration = { @@ -13,6 +14,7 @@ export const defaultConfiguration: TsStyledPluginConfiguration = { lint: { emptyRules: 'ignore', }, + emmet: {} }; export const loadConfiguration = (config: any): TsStyledPluginConfiguration => { @@ -21,5 +23,6 @@ export const loadConfiguration = (config: any): TsStyledPluginConfiguration => { tags: config.tags || defaultConfiguration.tags, validate: typeof config.validate !== 'undefined' ? config.validate : defaultConfiguration.validate, lint, + emmet: config.emmet || defaultConfiguration.emmet }; }; \ No newline at end of file diff --git a/src/styled-template-language-service.ts b/src/styled-template-language-service.ts index ae49012..548475f 100644 --- a/src/styled-template-language-service.ts +++ b/src/styled-template-language-service.ts @@ -10,6 +10,7 @@ import * as config from './config'; import { TsStyledPluginConfiguration } from './configuration'; import { TemplateLanguageService, TemplateContext } from 'typescript-template-language-service-decorator'; import { LanguageServiceLogger } from './logger'; +import { doComplete as emmetDoComplete, getEmmetCompletionParticipants} from 'vscode-emmet-helper'; const wrapperPre = ':root{\n'; @@ -240,6 +241,11 @@ export default class StyledTemplateLanguageService implements TemplateLanguageSe const doc = this.createVirtualDocument(context); const virtualPosition = this.toVirtualDocPosition(position); const stylesheet = this.scssLanguageService.parseStylesheet(doc); + const emmetResults: vscode.CompletionList = { + isIncomplete: true, + items: [] + } + this.cssLanguageService.setCompletionParticipants([getEmmetCompletionParticipants(doc, virtualPosition, 'css', this.configuration.emmet, emmetResults)]); const completionsCss = this.cssLanguageService.doComplete(doc, virtualPosition, stylesheet) || emptyCompletionList; const completionsScss = this.scssLanguageService.doComplete(doc, virtualPosition, stylesheet) || emptyCompletionList; completionsScss.items = filterScssCompletionItems(completionsScss.items); @@ -247,6 +253,10 @@ export default class StyledTemplateLanguageService implements TemplateLanguageSe isIncomplete: false, items: [...completionsCss.items, ...completionsScss.items], }; + if (emmetResults.items.length) { + completions.items.push(...emmetResults.items); + completions.isIncomplete = true; + } this._completionsCache.updateCached(context, position, completions); return completions; } @@ -466,7 +476,10 @@ function translateSeverity( } function toDisplayParts( - text: string | undefined + text: string | vscode.MarkupContent | undefined ): ts.SymbolDisplayPart[] { + if (text && typeof text !== 'string') { + return [{text: text.value, kind: 'text'}]; + } return text ? [{ text, kind: 'text' }] : []; } \ No newline at end of file