diff --git a/extensions/css/.vscode/launch.json b/extensions/css/.vscode/launch.json index fa74c4170fc..68f7c70e450 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 42cef8ddd33..a0d7fc0eb09 100644 --- a/extensions/css/server/src/cssServerMain.ts +++ b/extensions/css/server/src/cssServerMain.ts @@ -8,7 +8,7 @@ import { createConnection, IConnection, TextDocuments, InitializeParams, InitializeResult, ServerCapabilities } from 'vscode-languageserver'; -import { TextDocument } from 'vscode-languageserver-types'; +import { TextDocument, CompletionList } from 'vscode-languageserver-types'; import { ConfigurationRequest } from 'vscode-languageserver-protocol/lib/protocol.configuration.proposed'; import { WorkspaceFolder } from 'vscode-languageserver-protocol/lib/protocol.workspaceFolders.proposed'; @@ -18,6 +18,7 @@ import { getCSSLanguageService, getSCSSLanguageService, getLESSLanguageService, import { getLanguageModelCache } from './languageModelCache'; import { formatError, runSafe } from './utils/errors'; import uri from 'vscode-uri'; +import { getPathCompletionParticipant } from './pathCompletion'; export interface Settings { css: LanguageSettings; @@ -184,7 +185,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 = getLanguageService(document).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 00000000000..60028a35704 --- /dev/null +++ b/extensions/css/server/src/pathCompletion.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * 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, CompletionList, CompletionItemKind, CompletionItem, TextEdit, Range, Position } from 'vscode-languageserver-types'; +import { Proposed } from 'vscode-languageserver-protocol'; +import * as path from 'path'; +import * as fs from 'fs'; +import URI from 'vscode-uri'; +import { ICompletionParticipant } from 'vscode-css-languageservice/lib/umd/cssLanguageService'; +import { startsWith } from './utils/strings'; + +export function getPathCompletionParticipant( + document: TextDocument, + workspaceFolders: Proposed.WorkspaceFolder[] | undefined, + result: CompletionList +): ICompletionParticipant { + return { + onCssURILiteralValue: (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); + 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: Proposed.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); +} diff --git a/extensions/css/server/src/test/emmet.test.ts b/extensions/css/server/src/test/emmet.test.ts index 054dc433cc4..553b55e4d2f 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 00000000000..1d99cddd56b --- /dev/null +++ b/extensions/css/server/src/utils/strings.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * 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 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; + } + + for (let i = 0; i < needle.length; i++) { + if (haystack[i] !== needle[i]) { + return false; + } + } + + 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