diff --git a/extensions/css/.vscode/launch.json b/extensions/css/.vscode/launch.json index fa74c4170fc0caed1b162e614ae3eb31f217614a..68f7c70e450ee22511d925a0c3f41d91fc1f20ed 100644 --- a/extensions/css/.vscode/launch.json +++ b/extensions/css/.vscode/launch.json @@ -1,5 +1,11 @@ { "version": "0.2.0", + "compounds": [ + { + "name": "Debug Extension and Language Server", + "configurations": ["Launch Extension", "Attach Language Server"] + } + ], "configurations": [ { "name": "Launch Extension", @@ -32,7 +38,8 @@ "protocol": "inspector", "port": 6044, "sourceMaps": true, - "outFiles": ["${workspaceFolder}/server/out/**/*.js"] + "outFiles": ["${workspaceFolder}/server/out/**/*.js"], + "restart": true } ] } \ No newline at end of file diff --git a/extensions/css/server/src/cssServerMain.ts b/extensions/css/server/src/cssServerMain.ts index 26b48802bb0ce456ffe93d843003e6439186c6db..66b473f1a6d8c7c2b869627792df27919415e9d6 100644 --- a/extensions/css/server/src/cssServerMain.ts +++ b/extensions/css/server/src/cssServerMain.ts @@ -9,12 +9,13 @@ import { ConfigurationRequest, WorkspaceFolder, DocumentColorRequest, ColorPresentationRequest } from 'vscode-languageserver'; -import { TextDocument } from 'vscode-languageserver-types'; +import { TextDocument, CompletionList } from 'vscode-languageserver-types'; import { getCSSLanguageService, getSCSSLanguageService, getLESSLanguageService, LanguageSettings, LanguageService, Stylesheet } from 'vscode-css-languageservice'; import { getLanguageModelCache } from './languageModelCache'; import { formatError, runSafe } from './utils/errors'; -import uri from 'vscode-uri'; +import URI from 'vscode-uri'; +import { getPathCompletionParticipant } from './pathCompletion'; export interface Settings { css: LanguageSettings; @@ -57,7 +58,7 @@ connection.onInitialize((params: InitializeParams): InitializeResult => { if (!Array.isArray(workspaceFolders)) { workspaceFolders = []; if (params.rootPath) { - workspaceFolders.push({ name: '', uri: uri.file(params.rootPath).toString() }); + workspaceFolders.push({ name: '', uri: URI.file(params.rootPath).toString() }); } } @@ -181,7 +182,17 @@ function validateTextDocument(textDocument: TextDocument): void { connection.onCompletion(textDocumentPosition => { return runSafe(() => { let document = documents.get(textDocumentPosition.textDocument.uri); - return getLanguageService(document).doComplete(document, textDocumentPosition.position, stylesheets.get(document))!; /* TODO: remove ! once LS has null annotations */ + const cssLS = getLanguageService(document); + const pathCompletionList: CompletionList = { + isIncomplete: false, + items: [] + }; + cssLS.setCompletionParticipants([getPathCompletionParticipant(document, workspaceFolders, pathCompletionList)]); + const result = cssLS.doComplete(document, textDocumentPosition.position, stylesheets.get(document))!; /* TODO: remove ! once LS has null annotations */ + return { + isIncomplete: result.isIncomplete, + items: [...pathCompletionList.items, ...result.items] + }; }, null, `Error while computing completions for ${textDocumentPosition.textDocument.uri}`); }); diff --git a/extensions/css/server/src/pathCompletion.ts b/extensions/css/server/src/pathCompletion.ts new file mode 100644 index 0000000000000000000000000000000000000000..76b93f3a628496791919c0dc78e93bf0297913c7 --- /dev/null +++ b/extensions/css/server/src/pathCompletion.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as path from 'path'; +import * as fs from 'fs'; +import URI from 'vscode-uri'; + +import { TextDocument, CompletionList, CompletionItemKind, CompletionItem, TextEdit, Range, Position } from 'vscode-languageserver-types'; +import { WorkspaceFolder } from 'vscode-languageserver'; +import { ICompletionParticipant } from 'vscode-css-languageservice'; + +import { startsWith } from './utils/strings'; + +export function getPathCompletionParticipant( + document: TextDocument, + workspaceFolders: WorkspaceFolder[] | undefined, + result: CompletionList +): ICompletionParticipant { + return { + onURILiteralValue: (context: { uriValue: string, position: Position, range: Range; }) => { + if (!workspaceFolders || workspaceFolders.length === 0) { + return; + } + const workspaceRoot = resolveWorkspaceRoot(document, workspaceFolders); + + // 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]; + } + }; +} + +export function providePathSuggestions(value: string, range: Range, activeDocFsPath: string, root?: string): CompletionItem[] { + if (startsWith(value, '/') && !root) { + return []; + } + + let replaceRange: Range; + const lastIndexOfSlash = value.lastIndexOf('/'); + if (lastIndexOfSlash === -1) { + replaceRange = getFullReplaceRange(range); + } else { + const valueAfterLastSlash = value.slice(lastIndexOfSlash + 1); + replaceRange = getReplaceRange(range, valueAfterLastSlash); + } + + let parentDir: string; + if (lastIndexOfSlash === -1) { + parentDir = path.resolve(root); + } else { + const valueBeforeLastSlash = value.slice(0, lastIndexOfSlash + 1); + + parentDir = startsWith(value, '/') + ? path.resolve(root, '.' + valueBeforeLastSlash) + : path.resolve(activeDocFsPath, '..', valueBeforeLastSlash); + } + + try { + return fs.readdirSync(parentDir).map(f => { + if (isDir(path.resolve(parentDir, f))) { + return { + label: f + '/', + kind: CompletionItemKind.Folder, + textEdit: TextEdit.replace(replaceRange, f + '/'), + command: { + title: 'Suggest', + command: 'editor.action.triggerSuggest' + } + }; + } else { + return { + label: f, + kind: CompletionItemKind.File, + textEdit: TextEdit.replace(replaceRange, f) + }; + } + }); + } catch (e) { + return []; + } +} + +const isDir = (p: string) => { + return fs.statSync(p).isDirectory(); +}; + +function resolveWorkspaceRoot(activeDoc: TextDocument, workspaceFolders: WorkspaceFolder[]): string | undefined { + for (let i = 0; i < workspaceFolders.length; i++) { + if (startsWith(activeDoc.uri, workspaceFolders[i].uri)) { + return path.resolve(URI.parse(workspaceFolders[i].uri).fsPath); + } + } +} + +function getFullReplaceRange(valueRange: Range) { + const start = Position.create(valueRange.end.line, valueRange.start.character); + const end = Position.create(valueRange.end.line, valueRange.end.character); + return Range.create(start, end); +} +function getReplaceRange(valueRange: Range, valueAfterLastSlash: string) { + const start = Position.create(valueRange.end.line, valueRange.end.character - valueAfterLastSlash.length); + 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 0000000000000000000000000000000000000000..518a467cf4008142b65271713b0935607e4575ca --- /dev/null +++ b/extensions/css/server/src/test/completion.test.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { WorkspaceFolder } from 'vscode-languageserver-protocol'; +import { getPathCompletionParticipant } from '../pathCompletion'; +import { getCSSLanguageService } from 'vscode-css-languageservice'; + +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(TextDocument.applyEdits(document, [match.textEdit]), expected.resultText); + } + }; + + function assertCompletions(value: string, expected: { count?: number, items?: ItemDescription[] }, testUri: string, workspaceFolders?: 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/test/emmet.test.ts b/extensions/css/server/src/test/emmet.test.ts index 7c850fe2697c5d28edbf7fade6ed3deb80610966..e9ac1d543e045750a91e58ee3e8ccc6e01b69b90 100644 --- a/extensions/css/server/src/test/emmet.test.ts +++ b/extensions/css/server/src/test/emmet.test.ts @@ -6,7 +6,7 @@ import 'mocha'; import * as assert from 'assert'; -import { getCSSLanguageService, getSCSSLanguageService } from 'vscode-css-languageservice'; +import { getCSSLanguageService, getSCSSLanguageService } from 'vscode-css-languageservice/lib/umd/cssLanguageService'; import { TextDocument, CompletionList } from 'vscode-languageserver-types'; import { getEmmetCompletionParticipants } from 'vscode-emmet-helper'; diff --git a/extensions/css/server/src/utils/strings.ts b/extensions/css/server/src/utils/strings.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7ad0845cc8e85207cc2ed780e33b8a8a25e7646 --- /dev/null +++ b/extensions/css/server/src/utils/strings.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * 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 startsWith(haystack: string, needle: string): boolean { + if (haystack.length < needle.length) { + return false; + } + + for (let i = 0; i < needle.length; i++) { + if (haystack[i] !== needle[i]) { + return false; + } + } + + return true; +} 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 0000000000000000000000000000000000000000..adae63e647cb9188d7df4b63c9335b1c9811dbd1 --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/extensions/css/server/test/pathCompletionFixtures/index.html b/extensions/css/server/test/pathCompletionFixtures/index.html new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 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 0000000000000000000000000000000000000000..adae63e647cb9188d7df4b63c9335b1c9811dbd1 --- /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 0000000000000000000000000000000000000000..adae63e647cb9188d7df4b63c9335b1c9811dbd1 --- /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 da5197c460452fa762a7708a8a1c68d5ccc85967..69ebff04443e792e739e3a5d31a9614004b9f94e 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';