From a40bfc947cb554c2df93486ba6166fd4339be0d6 Mon Sep 17 00:00:00 2001 From: Pine Wu Date: Tue, 7 Aug 2018 16:01:21 -0700 Subject: [PATCH] @import completion for css/scss/less. Fix #51331 --- .../css-language-features/.vscode/launch.json | 20 ++++++ extensions/css-language-features/package.json | 1 + .../server/src/pathCompletion.ts | 71 +++++++++++++++---- .../server/src/test/completion.test.ts | 54 ++++++++++++-- .../server/src/utils/strings.ts | 14 ++++ .../pathCompletionFixtures/scss/_foo.scss | 4 ++ .../pathCompletionFixtures/scss/main.scss | 4 ++ .../css-language-features/test/mocha.opts | 3 + 8 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 extensions/css-language-features/server/test/pathCompletionFixtures/scss/_foo.scss create mode 100644 extensions/css-language-features/server/test/pathCompletionFixtures/scss/main.scss create mode 100644 extensions/css-language-features/test/mocha.opts diff --git a/extensions/css-language-features/.vscode/launch.json b/extensions/css-language-features/.vscode/launch.json index 9aad19d5b4e82..d6393141c5dfe 100644 --- a/extensions/css-language-features/.vscode/launch.json +++ b/extensions/css-language-features/.vscode/launch.json @@ -54,6 +54,26 @@ ], "smartStep": true, "restart": true + }, + { + "name": "Server Unit Tests", + "type": "node", + "request": "launch", + "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", + "stopOnEntry": false, + "args": [ + "--timeout", + "999999", + "--colors" + ], + "cwd": "${workspaceRoot}", + "runtimeExecutable": null, + "runtimeArgs": [], + "env": {}, + "sourceMaps": true, + "outFiles": [ + "${workspaceRoot}/server/out/**" + ] } ] } \ No newline at end of file diff --git a/extensions/css-language-features/package.json b/extensions/css-language-features/package.json index 4c1b1a151c99c..76da6212d8fb0 100644 --- a/extensions/css-language-features/package.json +++ b/extensions/css-language-features/package.json @@ -19,6 +19,7 @@ "scripts": { "compile": "gulp compile-extension:css-language-features-client compile-extension:css-language-features-server", "watch": "gulp watch-extension:css-language-features-client watch-extension:css-language-features-server", + "test": "mocha", "postinstall": "cd server && yarn install", "install-client-next": "yarn add vscode-languageclient@next" }, diff --git a/extensions/css-language-features/server/src/pathCompletion.ts b/extensions/css-language-features/server/src/pathCompletion.ts index b072c4136f616..4ea06882be280 100644 --- a/extensions/css-language-features/server/src/pathCompletion.ts +++ b/extensions/css-language-features/server/src/pathCompletion.ts @@ -12,7 +12,7 @@ import { TextDocument, CompletionList, CompletionItemKind, CompletionItem, TextE import { WorkspaceFolder } from 'vscode-languageserver'; import { ICompletionParticipant } from 'vscode-css-languageservice'; -import { startsWith } from './utils/strings'; +import { startsWith, endsWith } from './utils/strings'; export function getPathCompletionParticipant( document: TextDocument, @@ -21,32 +21,73 @@ export function getPathCompletionParticipant( ): ICompletionParticipant { return { onCssURILiteralValue: ({ position, range, uriValue }) => { - const isValueQuoted = startsWith(uriValue, `'`) || startsWith(uriValue, `"`); const fullValue = stripQuotes(uriValue); - const valueBeforeCursor = isValueQuoted - ? fullValue.slice(0, position.character - (range.start.character + 1)) - : fullValue.slice(0, position.character - range.start.character); - - if (fullValue === '.' || fullValue === '..') { - result.isIncomplete = true; + if (!shouldDoPathCompletion(uriValue, workspaceFolders)) { + if (fullValue === '.' || fullValue === '..') { + result.isIncomplete = true; + } return; } - if (!workspaceFolders || workspaceFolders.length === 0) { + let suggestions = providePathSuggestions(uriValue, position, range, document, workspaceFolders); + result.items = [...suggestions, ...result.items]; + }, + onCssImportPath: ({ position, range, pathValue }) => { + const fullValue = stripQuotes(pathValue); + if (!shouldDoPathCompletion(pathValue, workspaceFolders)) { + if (fullValue === '.' || fullValue === '..') { + result.isIncomplete = true; + } return; } - const workspaceRoot = resolveWorkspaceRoot(document, workspaceFolders); - const paths = providePaths(valueBeforeCursor, URI.parse(document.uri).fsPath, workspaceRoot); - const fullValueRange = isValueQuoted ? shiftRange(range, 1, -1) : range; - const replaceRange = pathToReplaceRange(valueBeforeCursor, fullValue, fullValueRange); - const suggestions = paths.map(p => pathToSuggestion(p, replaceRange)); + let suggestions = providePathSuggestions(pathValue, position, range, document, workspaceFolders); + + if (document.languageId === 'scss') { + suggestions.forEach(s => { + if (startsWith(s.label, '_') && endsWith(s.label, '.scss')) { + if (s.textEdit) { + s.textEdit.newText = s.label.slice(1, -5); + } else { + s.label = s.label.slice(1, -5); + } + } + }); + } result.items = [...suggestions, ...result.items]; } - }; } +function providePathSuggestions(pathValue: string, position: Position, range: Range, document: TextDocument, workspaceFolders: WorkspaceFolder[]) { + const fullValue = stripQuotes(pathValue); + const isValueQuoted = startsWith(pathValue, `'`) || startsWith(pathValue, `"`); + const valueBeforeCursor = isValueQuoted + ? fullValue.slice(0, position.character - (range.start.character + 1)) + : fullValue.slice(0, position.character - range.start.character); + const workspaceRoot = resolveWorkspaceRoot(document, workspaceFolders); + + const paths = providePaths(valueBeforeCursor, URI.parse(document.uri).fsPath, workspaceRoot); + const fullValueRange = isValueQuoted ? shiftRange(range, 1, -1) : range; + const replaceRange = pathToReplaceRange(valueBeforeCursor, fullValue, fullValueRange); + + const suggestions = paths.map(p => pathToSuggestion(p, replaceRange)); + return suggestions; +} + +function shouldDoPathCompletion(pathValue: string, workspaceFolders: WorkspaceFolder[]): boolean { + const fullValue = stripQuotes(pathValue); + if (fullValue === '.' || fullValue === '..') { + return false; + } + + if (!workspaceFolders || workspaceFolders.length === 0) { + return false; + } + + return true; +} + function stripQuotes(fullValue: string) { if (startsWith(fullValue, `'`) || startsWith(fullValue, `"`)) { return fullValue.slice(1, -1); diff --git a/extensions/css-language-features/server/src/test/completion.test.ts b/extensions/css-language-features/server/src/test/completion.test.ts index 62094de6b5be5..2a68b5797cf03 100644 --- a/extensions/css-language-features/server/src/test/completion.test.ts +++ b/extensions/css-language-features/server/src/test/completion.test.ts @@ -33,11 +33,11 @@ suite('Completions', () => { } }; - function assertCompletions(value: string, expected: { count?: number, items?: ItemDescription[] }, testUri: string, workspaceFolders?: WorkspaceFolder[]): void { + function assertCompletions(value: string, expected: { count?: number, items?: ItemDescription[] }, testUri: string, workspaceFolders?: WorkspaceFolder[], lang: string = 'css'): void { const offset = value.indexOf('|'); value = value.substr(0, offset) + value.substr(offset + 1); - const document = TextDocument.create(testUri, 'css', 0, value); + const document = TextDocument.create(testUri, lang, 0, value); const position = document.positionAt(offset); if (!workspaceFolders) { @@ -61,7 +61,7 @@ suite('Completions', () => { } } - test('CSS Path completion', function () { + test('CSS url() Path completion', function () { let testUri = Uri.file(path.resolve(__dirname, '../../test/pathCompletionFixtures/about/about.css')).toString(); let folders = [{ name: 'x', uri: Uri.file(path.resolve(__dirname, '../../test')).toString() }]; @@ -121,7 +121,7 @@ suite('Completions', () => { }, testUri, folders); }); - test('CSS Path Completion - Unquoted url', function () { + test('CSS url() Path Completion - Unquoted url', function () { let testUri = Uri.file(path.resolve(__dirname, '../../test/pathCompletionFixtures/about/about.css')).toString(); let folders = [{ name: 'x', uri: Uri.file(path.resolve(__dirname, '../../test')).toString() }]; @@ -149,4 +149,50 @@ suite('Completions', () => { ] }, testUri, folders); }); + + test('CSS @import Path completion', function () { + let testUri = Uri.file(path.resolve(__dirname, '../../test/pathCompletionFixtures/about/about.css')).toString(); + let folders = [{ name: 'x', uri: Uri.file(path.resolve(__dirname, '../../test')).toString() }]; + + assertCompletions(`@import './|'`, { + items: [ + { label: 'about.css', resultText: `@import './about.css'` }, + { label: 'about.html', resultText: `@import './about.html'` }, + ] + }, testUri, folders); + + assertCompletions(`@import '../|'`, { + items: [ + { label: 'about/', resultText: `@import '../about/'` }, + { label: 'scss/', resultText: `@import '../scss/'` }, + { label: 'index.html', resultText: `@import '../index.html'` }, + { label: 'src/', resultText: `@import '../src/'` } + ] + }, testUri, folders); + }); + + /** + * For SCSS, `@import 'foo';` can be used for importing partial file `_foo.scss` + */ + test('SCSS @import Path completion', function () { + let testCSSUri = Uri.file(path.resolve(__dirname, '../../test/pathCompletionFixtures/about/about.css')).toString(); + let folders = [{ name: 'x', uri: Uri.file(path.resolve(__dirname, '../../test')).toString() }]; + + /** + * We are in a CSS file, so no special treatment for SCSS partial files + */ + assertCompletions(`@import '../scss/|'`, { + items: [ + { label: 'main.scss', resultText: `@import '../scss/main.scss'` }, + { label: '_foo.scss', resultText: `@import '../scss/_foo.scss'` } + ] + }, testCSSUri, folders); + + let testSCSSUri = Uri.file(path.resolve(__dirname, '../../test/pathCompletionFixtures/scss/main.scss')).toString(); + assertCompletions(`@import './|'`, { + items: [ + { label: '_foo.scss', resultText: `@import './foo'` } + ] + }, testSCSSUri, folders, 'scss'); + }); }); \ No newline at end of file diff --git a/extensions/css-language-features/server/src/utils/strings.ts b/extensions/css-language-features/server/src/utils/strings.ts index f7ad0845cc8e8..114fb4f0808d0 100644 --- a/extensions/css-language-features/server/src/utils/strings.ts +++ b/extensions/css-language-features/server/src/utils/strings.ts @@ -17,3 +17,17 @@ export function startsWith(haystack: string, needle: string): boolean { return true; } + +/** + * Determines if haystack ends with needle. + */ +export function endsWith(haystack: string, needle: string): boolean { + let diff = haystack.length - needle.length; + if (diff > 0) { + return haystack.lastIndexOf(needle) === diff; + } else if (diff === 0) { + return haystack === needle; + } else { + return false; + } +} diff --git a/extensions/css-language-features/server/test/pathCompletionFixtures/scss/_foo.scss b/extensions/css-language-features/server/test/pathCompletionFixtures/scss/_foo.scss new file mode 100644 index 0000000000000..adae63e647cb9 --- /dev/null +++ b/extensions/css-language-features/server/test/pathCompletionFixtures/scss/_foo.scss @@ -0,0 +1,4 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ \ No newline at end of file diff --git a/extensions/css-language-features/server/test/pathCompletionFixtures/scss/main.scss b/extensions/css-language-features/server/test/pathCompletionFixtures/scss/main.scss new file mode 100644 index 0000000000000..adae63e647cb9 --- /dev/null +++ b/extensions/css-language-features/server/test/pathCompletionFixtures/scss/main.scss @@ -0,0 +1,4 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ \ No newline at end of file diff --git a/extensions/css-language-features/test/mocha.opts b/extensions/css-language-features/test/mocha.opts new file mode 100644 index 0000000000000..20fcfb6eef678 --- /dev/null +++ b/extensions/css-language-features/test/mocha.opts @@ -0,0 +1,3 @@ +--ui tdd +--useColors true +server/out/test/**.test.js \ No newline at end of file