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/') }, + ]); }); });