diff --git a/extensions/html/.vscode/launch.json b/extensions/html/.vscode/launch.json index cc7a4d92853..c53a74539eb 100644 --- a/extensions/html/.vscode/launch.json +++ b/extensions/html/.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", diff --git a/extensions/html/client/tsconfig.json b/extensions/html/client/tsconfig.json index 28f991bea7b..34ee92d4e26 100644 --- a/extensions/html/client/tsconfig.json +++ b/extensions/html/client/tsconfig.json @@ -4,6 +4,7 @@ "module": "commonjs", "outDir": "./out", "noUnusedLocals": true, + "sourceMap": true, "lib": [ "es2016" ], diff --git a/extensions/html/server/src/htmlServerMain.ts b/extensions/html/server/src/htmlServerMain.ts index f3b393d37bf..9ff469736f6 100644 --- a/extensions/html/server/src/htmlServerMain.ts +++ b/extensions/html/server/src/htmlServerMain.ts @@ -280,7 +280,7 @@ connection.onCompletion(async textDocumentPosition => { if (mode.setCompletionParticipants) { const emmetCompletionParticipant = getEmmetCompletionParticipants(document, textDocumentPosition.position, mode.getId(), emmetSettings, emmetCompletionList); - const pathCompletionParticipant = getPathCompletionParticipant(document, textDocumentPosition.position, pathCompletionList, workspaceFolders); + const pathCompletionParticipant = getPathCompletionParticipant(document, workspaceFolders, pathCompletionList); mode.setCompletionParticipants([emmetCompletionParticipant, pathCompletionParticipant]); } diff --git a/extensions/html/server/src/modes/pathCompletion.ts b/extensions/html/server/src/modes/pathCompletion.ts index 05429801c40..8c384bfc95a 100644 --- a/extensions/html/server/src/modes/pathCompletion.ts +++ b/extensions/html/server/src/modes/pathCompletion.ts @@ -4,65 +4,115 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; -import { TextDocument, Position, CompletionList, CompletionItemKind, TextEdit } from 'vscode-languageserver-types'; +import { TextDocument, CompletionList, CompletionItemKind, CompletionItem } from 'vscode-languageserver-types'; import { WorkspaceFolder } from 'vscode-languageserver-protocol/lib/protocol.workspaceFolders.proposed'; import * as path from 'path'; import * as fs from 'fs'; -import uri from 'vscode-uri'; +import URI from 'vscode-uri'; import { ICompletionParticipant } from 'vscode-html-languageservice/lib/htmlLanguageService'; +import { startsWith } from '../utils/strings'; +import { contains } from '../utils/arrays'; export function getPathCompletionParticipant( document: TextDocument, - position: Position, - result: CompletionList, - workspaceFolders: WorkspaceFolder[] | undefined + workspaceFolders: WorkspaceFolder[] | undefined, + result: CompletionList ): ICompletionParticipant { return { onHtmlAttributeValue: ({ tag, attribute, value, range }) => { - const pathTagAndAttribute: { [t: string]: string } = { - a: 'href', - script: 'src', - img: 'src', - link: 'href' - }; - const isDir = (p: string) => fs.statSync(p).isDirectory(); + if (shouldDoPathCompletion(tag, attribute, value)) { + let workspaceRoot; - if (pathTagAndAttribute[tag] && pathTagAndAttribute[tag] === attribute) { - const currPath = value.replace(/['"]/g, ''); - - let resolvedDirPath; - if (currPath[0] === ('/')) { + if (startsWith(value, '/')) { if (!workspaceFolders || workspaceFolders.length === 0) { return; } - for (let i = 0; i < workspaceFolders.length; i++) { - if (document.uri.indexOf(workspaceFolders[i].uri) !== -1) { - resolvedDirPath = path.resolve(uri.parse(workspaceFolders[i].uri).fsPath); - } - } - } else { - resolvedDirPath = path.resolve(uri.parse(document.uri).fsPath, '..', currPath); + + workspaceRoot = resolveWorkspaceRoot(document, workspaceFolders); } - if (resolvedDirPath && isDir(resolvedDirPath)) { - const filesAndFolders = fs.readdirSync(resolvedDirPath); - if (!result.items) { - result.items = []; - } - for (let i = 0; i < filesAndFolders.length; i++) { - const resolvedCompletionItemPath = path.resolve(resolvedDirPath, filesAndFolders[i]); - const kind = isDir(resolvedCompletionItemPath) - ? CompletionItemKind.Folder - : CompletionItemKind.File; - result.items.push({ - label: filesAndFolders[i], - kind, - textEdit: TextEdit.replace(range, filesAndFolders[i]) - }); - } - } + const suggestions = providePathSuggestions(value, URI.parse(document.uri).fsPath, workspaceRoot); + result.items = [...suggestions, ...result.items]; } } }; } + +function shouldDoPathCompletion(tag: string, attr: string, value: string): boolean { + if (startsWith(value, 'http') || startsWith(value, 'https') || startsWith(value, '//')) { + return false; + } + + if (PATH_TAG_AND_ATTR[tag]) { + if (typeof PATH_TAG_AND_ATTR[tag] === 'string') { + return PATH_TAG_AND_ATTR[tag] === attr; + } else { + return contains(PATH_TAG_AND_ATTR[tag], attr); + } + } + + return false; +} + +export function providePathSuggestions(value: string, activeDocFsPath: string, root?: string): CompletionItem[] { + if (value.indexOf('/') === -1) { + return []; + } + + if (startsWith(value, '/') && !root) { + return []; + } + + const valueAfterLastSlash = value.slice(value.lastIndexOf('/') + 1); + const valueBeforeLastSlash = value.slice(0, value.lastIndexOf('/') + 1); + const parentDir = startsWith(value, '/') + ? path.resolve(root, '.' + valueBeforeLastSlash) + : path.resolve(activeDocFsPath, '..', valueBeforeLastSlash); + + return fs.readdirSync(parentDir).map(f => { + return { + label: f, + kind: isDir(path.resolve(parentDir, f)) ? CompletionItemKind.Folder : CompletionItemKind.File, + insertText: f.slice(valueAfterLastSlash.length) + }; + }); +} + +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); + } + } +} + +// Selected from https://stackoverflow.com/a/2725168/1780148 +const PATH_TAG_AND_ATTR: { [tag: string]: string | string[] } = { + // HTML 4 + a: 'href', + body: 'background', + del: 'cite', + form: 'action', + frame: ['src', 'longdesc'], + img: ['src', 'longdesc'], + ins: 'cite', + link: 'href', + object: 'data', + q: 'cite', + script: 'src', + // HTML 5 + audio: 'src', + button: 'formaction', + command: 'icon', + embed: 'src', + html: 'manifest', + input: 'formaction', + source: 'src', + track: 'src', + video: ['src', 'poster'] +}; diff --git a/extensions/html/server/src/test/pathCompletion/pathCompletion.test.ts b/extensions/html/server/src/test/pathCompletion/pathCompletion.test.ts new file mode 100644 index 00000000000..ef45f6e4d1b --- /dev/null +++ b/extensions/html/server/src/test/pathCompletion/pathCompletion.test.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * 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 assert from 'assert'; +import * as path from 'path'; +import { providePathSuggestions } from '../../modes/pathCompletion'; +import { CompletionItemKind } from 'vscode-languageserver/lib/main'; + +const fixtureRoot = path.resolve(__dirname, '../../../test/pathCompletionFixtures'); + +suite('Path Completion - Relative Path', () => { + + test('Current Folder', () => { + const value = './'; + const activeFileFsPath = path.resolve(fixtureRoot, 'index.html'); + const suggestions = providePathSuggestions(value, activeFileFsPath); + + assert.equal(suggestions.length, 3); + assert.equal(suggestions[0].label, 'about'); + assert.equal(suggestions[1].label, 'index.html'); + assert.equal(suggestions[2].label, 'src'); + + assert.equal(suggestions[0].kind, CompletionItemKind.Folder); + assert.equal(suggestions[1].kind, CompletionItemKind.File); + assert.equal(suggestions[2].kind, CompletionItemKind.Folder); + }); + + test('Parent Folder', () => { + const value = '../'; + const activeFileFsPath = path.resolve(fixtureRoot, 'about/about.html'); + const suggestions = providePathSuggestions(value, activeFileFsPath); + + assert.equal(suggestions.length, 3); + assert.equal(suggestions[0].label, 'about'); + assert.equal(suggestions[1].label, 'index.html'); + assert.equal(suggestions[2].label, 'src'); + + assert.equal(suggestions[0].kind, CompletionItemKind.Folder); + assert.equal(suggestions[1].kind, CompletionItemKind.File); + assert.equal(suggestions[2].kind, CompletionItemKind.Folder); + }); + + test('Adjacent Folder', () => { + const value = '../src/'; + const activeFileFsPath = path.resolve(fixtureRoot, 'about/about.html'); + const suggestions = providePathSuggestions(value, activeFileFsPath); + + assert.equal(suggestions.length, 2); + assert.equal(suggestions[0].label, 'feature.js'); + assert.equal(suggestions[1].label, 'test.js'); + + assert.equal(suggestions[0].kind, CompletionItemKind.File); + assert.equal(suggestions[1].kind, CompletionItemKind.File); + }); + + +}); + +suite('Path Completion - Absolute Path', () => { + test('Root', () => { + const value = '/'; + const activeFileFsPath1 = path.resolve(fixtureRoot, 'index.html'); + const activeFileFsPath2 = path.resolve(fixtureRoot, 'about/index.html'); + + const suggestions1 = providePathSuggestions(value, activeFileFsPath1, fixtureRoot); + const suggestions2 = providePathSuggestions(value, activeFileFsPath2, fixtureRoot); + + const verify = (suggestions) => { + assert.equal(suggestions[0].label, 'about'); + assert.equal(suggestions[1].label, 'index.html'); + assert.equal(suggestions[2].label, 'src'); + + assert.equal(suggestions[0].kind, CompletionItemKind.Folder); + assert.equal(suggestions[1].kind, CompletionItemKind.File); + assert.equal(suggestions[2].kind, CompletionItemKind.Folder); + }; + + verify(suggestions1); + verify(suggestions2); + }); + + test('Sub Folder', () => { + const value = '/src/'; + const activeFileFsPath = path.resolve(fixtureRoot, 'about/about.html'); + const suggestions = providePathSuggestions(value, activeFileFsPath, fixtureRoot); + + assert.equal(suggestions.length, 2); + assert.equal(suggestions[0].label, 'feature.js'); + assert.equal(suggestions[1].label, 'test.js'); + + assert.equal(suggestions[0].kind, CompletionItemKind.File); + assert.equal(suggestions[1].kind, CompletionItemKind.File); + }); +}); + +suite('Path Completion - Incomplete Path at End', () => { + test('Incomplete Path that starts with slash', () => { + const value = '/src/f'; + const activeFileFsPath = path.resolve(fixtureRoot, 'about/about.html'); + const suggestions = providePathSuggestions(value, activeFileFsPath, fixtureRoot); + + assert.equal(suggestions.length, 2); + assert.equal(suggestions[0].label, 'feature.js'); + assert.equal(suggestions[1].label, 'test.js'); + + assert.equal(suggestions[0].kind, CompletionItemKind.File); + assert.equal(suggestions[1].kind, CompletionItemKind.File); + }); + + test('Incomplete Path that does not start with slash', () => { + const value = '../src/f'; + const activeFileFsPath = path.resolve(fixtureRoot, 'about/about.html'); + const suggestions = providePathSuggestions(value, activeFileFsPath, fixtureRoot); + + assert.equal(suggestions.length, 2); + assert.equal(suggestions[0].label, 'feature.js'); + assert.equal(suggestions[1].label, 'test.js'); + + assert.equal(suggestions[0].kind, CompletionItemKind.File); + assert.equal(suggestions[1].kind, CompletionItemKind.File); + }); +}); \ No newline at end of file diff --git a/extensions/html/server/src/utils/arrays.ts b/extensions/html/server/src/utils/arrays.ts index 47a69066ed8..50c33e519c7 100644 --- a/extensions/html/server/src/utils/arrays.ts +++ b/extensions/html/server/src/utils/arrays.ts @@ -10,4 +10,8 @@ export function pushAll(to: T[], from: T[]) { to.push(from[i]); } } +} + +export function contains(arr: T[], val: T) { + return arr.indexOf(val) !== -1; } \ No newline at end of file diff --git a/extensions/html/server/test/mocha.opts b/extensions/html/server/test/mocha.opts index 97e8b723ae2..559e72667aa 100644 --- a/extensions/html/server/test/mocha.opts +++ b/extensions/html/server/test/mocha.opts @@ -1,3 +1,4 @@ --ui tdd --useColors true -./out/test \ No newline at end of file +./out/test +./out/test/pathCompletion \ No newline at end of file diff --git a/extensions/html/server/test/pathCompletionFixtures/about/about.css b/extensions/html/server/test/pathCompletionFixtures/about/about.css new file mode 100644 index 00000000000..e69de29bb2d diff --git a/extensions/html/server/test/pathCompletionFixtures/about/about.html b/extensions/html/server/test/pathCompletionFixtures/about/about.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/extensions/html/server/test/pathCompletionFixtures/index.html b/extensions/html/server/test/pathCompletionFixtures/index.html new file mode 100644 index 00000000000..e69de29bb2d diff --git a/extensions/html/server/test/pathCompletionFixtures/src/feature.js b/extensions/html/server/test/pathCompletionFixtures/src/feature.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/extensions/html/server/test/pathCompletionFixtures/src/test.js b/extensions/html/server/test/pathCompletionFixtures/src/test.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/extensions/html/server/tsconfig.json b/extensions/html/server/tsconfig.json index d2f8f6376fe..d965b4c85d1 100644 --- a/extensions/html/server/tsconfig.json +++ b/extensions/html/server/tsconfig.json @@ -4,8 +4,9 @@ "module": "commonjs", "outDir": "./out", "noUnusedLocals": true, + "sourceMap": true, "lib": [ - "es5", "es2015.promise" + "es5", "es2015.promise", "dom" ] } } \ No newline at end of file