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