diff --git a/extensions/css/server/src/pathCompletion.ts b/extensions/css/server/src/pathCompletion.ts index 60028a35704..9c13719630d 100644 --- a/extensions/css/server/src/pathCompletion.ts +++ b/extensions/css/server/src/pathCompletion.ts @@ -18,13 +18,21 @@ export function getPathCompletionParticipant( result: CompletionList ): ICompletionParticipant { return { - onCssURILiteralValue: (context: { uriValue: string, position: Position, range: Range; }) => { + onURILiteralValue: (context: { uriValue: string, position: Position, range: Range; }) => { if (!workspaceFolders || workspaceFolders.length === 0) { return; } const workspaceRoot = resolveWorkspaceRoot(document, workspaceFolders); - const suggestions = providePathSuggestions(context.uriValue, context.range, URI.parse(document.uri).fsPath, workspaceRoot); + // Handle quoted values + let uriValue = context.uriValue; + let range = context.range; + if (startsWith(uriValue, `'`) || startsWith(uriValue, `"`)) { + uriValue = uriValue.slice(1, -1); + range = getRangeWithoutQuotes(range); + } + + const suggestions = providePathSuggestions(uriValue, range, URI.parse(document.uri).fsPath, workspaceRoot); result.items = [...suggestions, ...result.items]; } }; @@ -102,3 +110,8 @@ function getReplaceRange(valueRange: Range, valueAfterLastSlash: string) { const end = Position.create(valueRange.end.line, valueRange.end.character); return Range.create(start, end); } +function getRangeWithoutQuotes(range: Range) { + const start = Position.create(range.start.line, range.start.character + 1); + const end = Position.create(range.end.line, range.end.character - 1); + return Range.create(start, end); +} diff --git a/extensions/css/server/src/test/completion.test.ts b/extensions/css/server/src/test/completion.test.ts new file mode 100644 index 00000000000..c2603adb658 --- /dev/null +++ b/extensions/css/server/src/test/completion.test.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import 'mocha'; +import * as assert from 'assert'; +import * as path from 'path'; +import Uri from 'vscode-uri'; +import { TextDocument, CompletionList } from 'vscode-languageserver-types'; +import { applyEdits } from '../utils/edits'; +import { getPathCompletionParticipant } from '../pathCompletion'; +import { Proposed } from 'vscode-languageserver-protocol'; +import { getCSSLanguageService } from 'vscode-css-languageservice/lib/umd/cssLanguageService'; + +export interface ItemDescription { + label: string; + resultText?: string; +} + +suite('Completions', () => { + const cssLanguageService = getCSSLanguageService(); + + let assertCompletion = function (completions: CompletionList, expected: ItemDescription, document: TextDocument, offset: number) { + let matches = completions.items.filter(completion => { + return completion.label === expected.label; + }); + + assert.equal(matches.length, 1, `${expected.label} should only existing once: Actual: ${completions.items.map(c => c.label).join(', ')}`); + let match = matches[0]; + if (expected.resultText && match.textEdit) { + assert.equal(applyEdits(document, [match.textEdit]), expected.resultText); + } + }; + + function assertCompletions(value: string, expected: { count?: number, items?: ItemDescription[] }, testUri: string, workspaceFolders?: Proposed.WorkspaceFolder[]): void { + const offset = value.indexOf('|'); + value = value.substr(0, offset) + value.substr(offset + 1); + + const document = TextDocument.create(testUri, 'css', 0, value); + const position = document.positionAt(offset); + + if (!workspaceFolders) { + workspaceFolders = [{ name: 'x', uri: path.dirname(testUri) }]; + } + + let participantResult = CompletionList.create([]); + cssLanguageService.setCompletionParticipants([getPathCompletionParticipant(document, workspaceFolders, participantResult)]); + + const stylesheet = cssLanguageService.parseStylesheet(document); + let list = cssLanguageService.doComplete!(document, position, stylesheet); + list.items = list.items.concat(participantResult.items); + + if (expected.count) { + assert.equal(list.items.length, expected.count); + } + if (expected.items) { + for (let item of expected.items) { + assertCompletion(list, item, document, offset); + } + } + } + + test('CSS Path completion', function () { + let testUri = Uri.file(path.resolve(__dirname, '../../test/pathCompletionFixtures/about/about.css')).fsPath; + + assertCompletions('html { background-image: url("./|")', { + items: [ + { label: 'about.html', resultText: 'html { background-image: url("./about.html")' } + ] + }, testUri); + + assertCompletions(`html { background-image: url('../|')`, { + items: [ + { label: 'about/', resultText: `html { background-image: url('../about/')` }, + { label: 'index.html', resultText: `html { background-image: url('../index.html')` }, + { label: 'src/', resultText: `html { background-image: url('../src/')` } + ] + }, testUri); + }); +}); \ No newline at end of file diff --git a/extensions/css/server/src/utils/arrays.ts b/extensions/css/server/src/utils/arrays.ts new file mode 100644 index 00000000000..a33ef18597c --- /dev/null +++ b/extensions/css/server/src/utils/arrays.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +export function pushAll(to: T[], from: T[]) { + if (from) { + for (var i = 0; i < from.length; i++) { + to.push(from[i]); + } + } +} + +export function contains(arr: T[], val: T) { + return arr.indexOf(val) !== -1; +} + +/** + * Like `Array#sort` but always stable. Usually runs a little slower `than Array#sort` + * so only use this when actually needing stable sort. + */ +export function mergeSort(data: T[], compare: (a: T, b: T) => number): T[] { + _divideAndMerge(data, compare); + return data; +} + +function _divideAndMerge(data: T[], compare: (a: T, b: T) => number): void { + if (data.length <= 1) { + // sorted + return; + } + const p = (data.length / 2) | 0; + const left = data.slice(0, p); + const right = data.slice(p); + + _divideAndMerge(left, compare); + _divideAndMerge(right, compare); + + let leftIdx = 0; + let rightIdx = 0; + let i = 0; + while (leftIdx < left.length && rightIdx < right.length) { + let ret = compare(left[leftIdx], right[rightIdx]); + if (ret <= 0) { + // smaller_equal -> take left to preserve order + data[i++] = left[leftIdx++]; + } else { + // greater -> take right + data[i++] = right[rightIdx++]; + } + } + while (leftIdx < left.length) { + data[i++] = left[leftIdx++]; + } + while (rightIdx < right.length) { + data[i++] = right[rightIdx++]; + } +} diff --git a/extensions/css/server/src/utils/edits.ts b/extensions/css/server/src/utils/edits.ts new file mode 100644 index 00000000000..66c74be030f --- /dev/null +++ b/extensions/css/server/src/utils/edits.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import { TextDocument, TextEdit } from 'vscode-languageserver-types'; +import { mergeSort } from './arrays'; + +export function applyEdits(document: TextDocument, edits: TextEdit[]): string { + let text = document.getText(); + let sortedEdits = mergeSort(edits, (a, b) => { + let diff = a.range.start.line - b.range.start.line; + if (diff === 0) { + return a.range.start.character - b.range.start.character; + } + return 0; + }); + let lastModifiedOffset = text.length; + for (let i = sortedEdits.length - 1; i >= 0; i--) { + let e = sortedEdits[i]; + let startOffset = document.offsetAt(e.range.start); + let endOffset = document.offsetAt(e.range.end); + if (endOffset <= lastModifiedOffset) { + text = text.substring(0, startOffset) + e.newText + text.substring(endOffset, text.length); + } else { + throw new Error('Ovelapping edit'); + } + lastModifiedOffset = startOffset; + } + return text; +} \ No newline at end of file diff --git a/extensions/css/server/src/utils/strings.ts b/extensions/css/server/src/utils/strings.ts index 1d99cddd56b..f7ad0845cc8 100644 --- a/extensions/css/server/src/utils/strings.ts +++ b/extensions/css/server/src/utils/strings.ts @@ -4,29 +4,6 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -export function getWordAtText(text: string, offset: number, wordDefinition: RegExp): { start: number, length: number } { - let lineStart = offset; - while (lineStart > 0 && !isNewlineCharacter(text.charCodeAt(lineStart - 1))) { - lineStart--; - } - let offsetInLine = offset - lineStart; - let lineText = text.substr(lineStart); - - // make a copy of the regex as to not keep the state - let flags = wordDefinition.ignoreCase ? 'gi' : 'g'; - wordDefinition = new RegExp(wordDefinition.source, flags); - - let match = wordDefinition.exec(lineText); - while (match && match.index + match[0].length < offsetInLine) { - match = wordDefinition.exec(lineText); - } - if (match && match.index <= offsetInLine) { - return { start: match.index + lineStart, length: match[0].length }; - } - - return { start: offset, length: 0 }; -} - export function startsWith(haystack: string, needle: string): boolean { if (haystack.length < needle.length) { return false; @@ -40,40 +17,3 @@ export function startsWith(haystack: string, needle: string): boolean { return true; } - -export function endsWith(haystack: string, needle: string): boolean { - let diff = haystack.length - needle.length; - if (diff > 0) { - return haystack.indexOf(needle, diff) === diff; - } else if (diff === 0) { - return haystack === needle; - } else { - return false; - } -} - -export function repeat(value: string, count: number) { - var s = ''; - while (count > 0) { - if ((count & 1) === 1) { - s += value; - } - value += value; - count = count >>> 1; - } - return s; -} - -export function isWhitespaceOnly(str: string) { - return /^\s*$/.test(str); -} - -export function isEOL(content: string, offset: number) { - return isNewlineCharacter(content.charCodeAt(offset)); -} - -const CR = '\r'.charCodeAt(0); -const NL = '\n'.charCodeAt(0); -export function isNewlineCharacter(charCode: number) { - return charCode === CR || charCode === NL; -} \ No newline at end of file diff --git a/extensions/css/server/test/pathCompletionFixtures/about/about.css b/extensions/css/server/test/pathCompletionFixtures/about/about.css new file mode 100644 index 00000000000..adae63e647c --- /dev/null +++ b/extensions/css/server/test/pathCompletionFixtures/about/about.css @@ -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/server/test/pathCompletionFixtures/about/about.html b/extensions/css/server/test/pathCompletionFixtures/about/about.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/extensions/css/server/test/pathCompletionFixtures/index.html b/extensions/css/server/test/pathCompletionFixtures/index.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/extensions/css/server/test/pathCompletionFixtures/src/feature.js b/extensions/css/server/test/pathCompletionFixtures/src/feature.js new file mode 100644 index 00000000000..adae63e647c --- /dev/null +++ b/extensions/css/server/test/pathCompletionFixtures/src/feature.js @@ -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/server/test/pathCompletionFixtures/src/test.js b/extensions/css/server/test/pathCompletionFixtures/src/test.js new file mode 100644 index 00000000000..adae63e647c --- /dev/null +++ b/extensions/css/server/test/pathCompletionFixtures/src/test.js @@ -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/html-language-features/server/src/test/completions.test.ts b/extensions/html-language-features/server/src/test/completions.test.ts index 21a5b163bf3..f356d122bec 100644 --- a/extensions/html-language-features/server/src/test/completions.test.ts +++ b/extensions/html-language-features/server/src/test/completions.test.ts @@ -8,7 +8,7 @@ import 'mocha'; import * as assert from 'assert'; import * as path from 'path'; import Uri from 'vscode-uri'; -import { TextDocument, CompletionList, CompletionItemKind, } from 'vscode-languageserver-types'; +import { TextDocument, CompletionList, CompletionItemKind } from 'vscode-languageserver-types'; import { getLanguageModes } from '../modes/languageModes'; import { getPathCompletionParticipant } from '../modes/pathCompletion'; import { WorkspaceFolder } from 'vscode-languageserver';