diff --git a/extensions/html/server/src/modes/pathCompletion.ts b/extensions/html/server/src/modes/pathCompletion.ts
index a35c4680c05116bb4b57d359779f7bdab1503a40..46b5d34fd914b68c1dbb7a04bc764182efef1c0a 100644
--- a/extensions/html/server/src/modes/pathCompletion.ts
+++ b/extensions/html/server/src/modes/pathCompletion.ts
@@ -22,15 +22,10 @@ export function getPathCompletionParticipant(
onHtmlAttributeValue: ({ tag, attribute, value, range }) => {
if (shouldDoPathCompletion(tag, attribute, value)) {
- let workspaceRoot;
-
- if (startsWith(value, '/')) {
- if (!workspaceFolders || workspaceFolders.length === 0) {
- return;
- }
-
- workspaceRoot = resolveWorkspaceRoot(document, workspaceFolders);
+ if (!workspaceFolders || workspaceFolders.length === 0) {
+ return;
}
+ const workspaceRoot = resolveWorkspaceRoot(document, workspaceFolders);
const suggestions = providePathSuggestions(value, range, URI.parse(document.uri).fsPath, workspaceRoot);
result.items = [...suggestions, ...result.items];
@@ -56,34 +51,49 @@ function shouldDoPathCompletion(tag: string, attr: string, value: string): boole
}
export function providePathSuggestions(value: string, range: Range, activeDocFsPath: string, root?: string): CompletionItem[] {
- if (value.indexOf('/') === -1) {
- return [];
- }
-
if (startsWith(value, '/') && !root) {
return [];
}
+ let replaceRange: Range;
const lastIndexOfSlash = value.lastIndexOf('/');
- const valueBeforeLastSlash = value.slice(0, lastIndexOfSlash + 1);
- const valueAfterLastSlash = value.slice(lastIndexOfSlash + 1);
- const parentDir = startsWith(value, '/')
- ? path.resolve(root, '.' + valueBeforeLastSlash)
- : path.resolve(activeDocFsPath, '..', valueBeforeLastSlash);
-
- if (!fs.existsSync(parentDir)) {
- return [];
+ if (lastIndexOfSlash === -1) {
+ replaceRange = getFullReplaceRange(range);
+ } else {
+ const valueAfterLastSlash = value.slice(lastIndexOfSlash + 1);
+ replaceRange = getReplaceRange(range, valueAfterLastSlash);
}
- const 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 => {
- return {
- label: f,
- kind: isDir(path.resolve(parentDir, f)) ? CompletionItemKind.Folder : CompletionItemKind.File,
- textEdit: TextEdit.replace(replaceRange, 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 [];
@@ -102,7 +112,12 @@ function resolveWorkspaceRoot(activeDoc: TextDocument, workspaceFolders: Propose
}
}
-function getReplaceRange(valueRange: Range, valueAfterLastSlash: string): Range {
+function getFullReplaceRange(valueRange: Range) {
+ const start = Position.create(valueRange.end.line, valueRange.start.character + 1);
+ const end = Position.create(valueRange.end.line, valueRange.end.character - 1);
+ return Range.create(start, end);
+}
+function getReplaceRange(valueRange: Range, valueAfterLastSlash: string) {
const start = Position.create(valueRange.end.line, valueRange.end.character - 1 - valueAfterLastSlash.length);
const end = Position.create(valueRange.end.line, valueRange.end.character - 1);
return Range.create(start, end);
diff --git a/extensions/html/server/src/test/pathCompletion/pathCompletion.test.ts b/extensions/html/server/src/test/pathCompletion/pathCompletion.test.ts
index 21110e949a7c1561a31c00d2fa00393727704ff2..34e4486063cccda24006162e032d2a5f2c49fa17 100644
--- a/extensions/html/server/src/test/pathCompletion/pathCompletion.test.ts
+++ b/extensions/html/server/src/test/pathCompletion/pathCompletion.test.ts
@@ -7,78 +7,117 @@
import * as assert from 'assert';
import * as path from 'path';
import { providePathSuggestions } from '../../modes/pathCompletion';
-import { CompletionItemKind, Range, Position } from 'vscode-languageserver-types';
+import { CompletionItemKind, Range, Position, CompletionItem, TextEdit, Command } from 'vscode-languageserver-types';
const fixtureRoot = path.resolve(__dirname, '../../../test/pathCompletionFixtures');
-suite('Path Completion - Relative Path', () => {
- const mockRange = Range.create(Position.create(0, 3), Position.create(0, 5));
+function toRange(line: number, startChar: number, endChar: number) {
+ return Range.create(Position.create(line, startChar), Position.create(line, endChar));
+}
+function toTextEdit(line: number, startChar: number, endChar: number, newText: string) {
+ const range = Range.create(Position.create(line, startChar), Position.create(line, endChar));
+ return TextEdit.replace(range, newText);
+}
+
+interface PathSuggestion {
+ label?: string;
+ kind?: CompletionItemKind;
+ textEdit?: TextEdit;
+ command?: Command;
+}
+
+function assertSuggestions(actual: CompletionItem[], expected: PathSuggestion[]) {
+ assert.equal(actual.length, expected.length, `Suggestions have length ${actual.length} but should have length ${expected.length}`);
+
+ for (let i = 0; i < expected.length; i++) {
+ if (expected[i].label) {
+ assert.equal(
+ actual[i].label,
+ expected[i].label,
+ `Suggestion ${actual[i].label} should have label ${expected[i].label}`
+ );
+ }
+ if (expected[i].kind) {
+ assert.equal(actual[i].kind,
+ expected[i].kind,
+ `Suggestion ${actual[i].label} has type ${CompletionItemKind[actual[i].kind]} but should have label ${CompletionItemKind[expected[i].kind]}`
+ );
+ }
+ if (expected[i].textEdit) {
+ assert.equal(actual[i].textEdit.newText, expected[i].textEdit.newText);
+ assert.deepEqual(actual[i].textEdit.range, expected[i].textEdit.range);
+ }
+ if (expected[i].command) {
+ assert.equal(
+ actual[i].command.title,
+ expected[i].command.title,
+ `Suggestion ${actual[i].label} has command title ${actual[i].command.title} but should have command title ${expected[i].command.title}`
+ );
+ assert.equal(
+ actual[i].command.command,
+ expected[i].command.command,
+ `Suggestion ${actual[i].label} has command ${actual[i].command.command} but should have command ${expected[i].command.command}`
+ );
+ }
+ }
+}
+
+suite('Path Completion - Relative Path:', () => {
+ const mockRange = toRange(0, 3, 5);
test('Current Folder', () => {
const value = './';
const activeFileFsPath = path.resolve(fixtureRoot, 'index.html');
const suggestions = providePathSuggestions(value, mockRange, 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);
+ assertSuggestions(suggestions, [
+ { label: 'about/', kind: CompletionItemKind.Folder },
+ { label: 'index.html', kind: CompletionItemKind.File },
+ { label: 'src/', kind: CompletionItemKind.Folder }
+ ]);
});
- test('Parent Folder', () => {
+ test('Parent Folder:', () => {
const value = '../';
const activeFileFsPath = path.resolve(fixtureRoot, 'about/about.html');
const suggestions = providePathSuggestions(value, mockRange, 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);
+ assertSuggestions(suggestions, [
+ { label: 'about/', kind: CompletionItemKind.Folder },
+ { label: 'index.html', kind: CompletionItemKind.File },
+ { label: 'src/', kind: CompletionItemKind.Folder }
+ ]);
});
- test('Adjacent Folder', () => {
+ test('Adjacent Folder:', () => {
const value = '../src/';
const activeFileFsPath = path.resolve(fixtureRoot, 'about/about.html');
const suggestions = providePathSuggestions(value, mockRange, 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);
+ assertSuggestions(suggestions, [
+ { label: 'feature.js', kind: CompletionItemKind.File },
+ { label: 'test.js', kind: CompletionItemKind.File }
+ ]);
});
-
-
});
-suite('Path Completion - Absolute Path', () => {
- const mockRange = Range.create(Position.create(0, 3), Position.create(0, 5));
+suite('Path Completion - Absolute Path:', () => {
+ const mockRange = toRange(0, 3, 5);
test('Root', () => {
const value = '/';
const activeFileFsPath1 = path.resolve(fixtureRoot, 'index.html');
const activeFileFsPath2 = path.resolve(fixtureRoot, 'about/index.html');
- const suggestions1 = providePathSuggestions(value, mockRange, activeFileFsPath1, fixtureRoot);
- const suggestions2 = providePathSuggestions(value, mockRange, activeFileFsPath2, fixtureRoot);
+ const suggestions1 = providePathSuggestions(value, mockRange, activeFileFsPath1, fixtureRoot);
+ const suggestions2 = providePathSuggestions(value, mockRange, 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);
+ assertSuggestions(suggestions, [
+ { label: 'about/', kind: CompletionItemKind.Folder },
+ { label: 'index.html', kind: CompletionItemKind.File },
+ { label: 'src/', kind: CompletionItemKind.Folder }
+ ]);
};
verify(suggestions1);
@@ -90,62 +129,109 @@ suite('Path Completion - Absolute Path', () => {
const activeFileFsPath = path.resolve(fixtureRoot, 'about/about.html');
const suggestions = providePathSuggestions(value, mockRange, activeFileFsPath, fixtureRoot);
- assert.equal(suggestions.length, 2);
- assert.equal(suggestions[0].label, 'feature.js');
- assert.equal(suggestions[1].label, 'test.js');
+ assertSuggestions(suggestions, [
+ { label: 'feature.js', kind: CompletionItemKind.File },
+ { label: 'test.js', kind: CompletionItemKind.File }
+ ]);
+ });
+});
+
+suite('Path Completion - Folder Commands:', () => {
+ const mockRange = toRange(0, 3, 5);
+
+ test('Folder should have command `editor.action.triggerSuggest', () => {
+ const value = './';
+ const activeFileFsPath = path.resolve(fixtureRoot, 'index.html');
+ const suggestions = providePathSuggestions(value, mockRange, activeFileFsPath);
- assert.equal(suggestions[0].kind, CompletionItemKind.File);
- assert.equal(suggestions[1].kind, CompletionItemKind.File);
+ assertSuggestions(suggestions, [
+ { label: 'about/', command: { title: 'Suggest', command: 'editor.action.triggerSuggest'} },
+ { label: 'index.html' },
+ { label: 'src/', command: { title: 'Suggest', command: 'editor.action.triggerSuggest'} },
+ ]);
});
});
-suite('Path Completion - Incomplete Path at End', () => {
- const mockRange = Range.create(Position.create(0, 3), Position.create(0, 5));
+suite('Path Completion - Incomplete Path at End:', () => {
+ const mockRange = toRange(0, 3, 5);
test('Incomplete Path that starts with slash', () => {
const value = '/src/f';
const activeFileFsPath = path.resolve(fixtureRoot, 'about/about.html');
const suggestions = providePathSuggestions(value, mockRange, 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);
- });
+ assertSuggestions(suggestions, [
+ { label: 'feature.js', kind: CompletionItemKind.File },
+ { label: 'test.js', 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, mockRange, activeFileFsPath, fixtureRoot);
- assert.equal(suggestions.length, 2);
- assert.equal(suggestions[0].label, 'feature.js');
- assert.equal(suggestions[1].label, 'test.js');
+ assertSuggestions(suggestions, [
+ { label: 'feature.js', kind: CompletionItemKind.File },
+ { label: 'test.js', kind: CompletionItemKind.File }
+ ]);
+ });
+});
+
+suite('Path Completion - No leading dot or slash:', () => {
+
+ test('Top level completion', () => {
+ const value = 's';
+ const activeFileFsPath = path.resolve(fixtureRoot, 'index.html');
+ const range = toRange(0, 3, 5);
+ const suggestions = providePathSuggestions(value, range, activeFileFsPath, fixtureRoot);
+
+ assertSuggestions(suggestions, [
+ { label: 'about/', kind: CompletionItemKind.Folder, textEdit: toTextEdit(0, 4, 4, 'about/') },
+ { label: 'index.html', kind: CompletionItemKind.File, textEdit: toTextEdit(0, 4, 4, 'index.html') },
+ { label: 'src/', kind: CompletionItemKind.Folder, textEdit: toTextEdit(0, 4, 4, 'src/') }
+ ]);
+ });
+
+ test('src/', () => {
+ const value = 'src/';
+ const activeFileFsPath = path.resolve(fixtureRoot, 'index.html');
+ const range = toRange(0, 3, 8);
+ const suggestions = providePathSuggestions(value, range, activeFileFsPath, fixtureRoot);
- assert.equal(suggestions[0].kind, CompletionItemKind.File);
- assert.equal(suggestions[1].kind, CompletionItemKind.File);
- });
+ assertSuggestions(suggestions, [
+ { label: 'feature.js', kind: CompletionItemKind.File, textEdit: toTextEdit(0, 7, 7, 'feature.js') },
+ { label: 'test.js', kind: CompletionItemKind.File, textEdit: toTextEdit(0, 7, 7, 'test.js') }
+ ]);
+ });
+
+ test('src/f', () => {
+ const value = 'src/f';
+ const activeFileFsPath = path.resolve(fixtureRoot, 'index.html');
+ const range = toRange(0, 3, 9);
+ const suggestions = providePathSuggestions(value, range, activeFileFsPath, fixtureRoot);
+
+ assertSuggestions(suggestions, [
+ { label: 'feature.js', kind: CompletionItemKind.File, textEdit: toTextEdit(0, 7, 8, 'feature.js') },
+ { label: 'test.js', kind: CompletionItemKind.File, textEdit: toTextEdit(0, 7, 8, 'test.js') }
+ ]);
+ });
});
-suite('Path Completion - TextEdit', () => {
+suite('Path Completion - TextEdit:', () => {
+
test('TextEdit has correct replace text and range', () => {
const value = './';
const activeFileFsPath = path.resolve(fixtureRoot, 'index.html');
- const range = Range.create(Position.create(0, 3), Position.create(0, 5));
- const suggestions = providePathSuggestions(value, range, activeFileFsPath);
-
- assert.equal(suggestions[0].textEdit.newText, 'about');
- assert.equal(suggestions[1].textEdit.newText, 'index.html');
- assert.equal(suggestions[2].textEdit.newText, 'src');
-
- assert.equal(suggestions[0].textEdit.range.start.character, 4);
- assert.equal(suggestions[1].textEdit.range.start.character, 4);
- assert.equal(suggestions[2].textEdit.range.start.character, 4);
-
- assert.equal(suggestions[0].textEdit.range.end.character, 4);
- assert.equal(suggestions[1].textEdit.range.end.character, 4);
- assert.equal(suggestions[2].textEdit.range.end.character, 4);
+ const range = toRange(0, 3, 5);
+ const expectedReplaceRange = toRange(0, 4, 4);
+
+ const suggestions = providePathSuggestions(value, range, activeFileFsPath);
+
+ assertSuggestions(suggestions, [
+ { textEdit: TextEdit.replace(expectedReplaceRange, 'about/') },
+ { textEdit: TextEdit.replace(expectedReplaceRange, 'index.html') },
+ { textEdit: TextEdit.replace(expectedReplaceRange, 'src/') },
+ ]);
});
});