Add basic markdown link completions

For #140602

Only normal links for now. Will add reference links later. Should support the forms:

- `[](dir/file.md)`
- `[](./dir/file.md)`
- `[](/root-dir/file.md)`
- `[](#header)`
- `[](./dir/file.md#header)`
This commit is contained in:
Matt Bierner
2022-01-12 18:12:51 -08:00
parent 351aa03df4
commit a4e529c759
11 changed files with 366 additions and 38 deletions

View File

@@ -9,6 +9,7 @@ import * as commands from './commands/index';
import LinkProvider from './features/documentLinkProvider';
import MDDocumentSymbolProvider from './features/documentSymbolProvider';
import MarkdownFoldingProvider from './features/foldingProvider';
import { PathCompletionProvider } from './features/pathCompletions';
import { MarkdownContentProvider } from './features/previewContentProvider';
import { MarkdownPreviewManager } from './features/previewManager';
import MarkdownSmartSelect from './features/smartSelect';
@@ -57,7 +58,8 @@ function registerMarkdownLanguageFeatures(
vscode.languages.registerDocumentLinkProvider(selector, new LinkProvider()),
vscode.languages.registerFoldingRangeProvider(selector, new MarkdownFoldingProvider(engine)),
vscode.languages.registerSelectionRangeProvider(selector, new MarkdownSmartSelect(engine)),
vscode.languages.registerWorkspaceSymbolProvider(new MarkdownWorkspaceSymbolProvider(symbolProvider))
vscode.languages.registerWorkspaceSymbolProvider(new MarkdownWorkspaceSymbolProvider(symbolProvider)),
PathCompletionProvider.register(selector, engine),
);
}

View File

@@ -0,0 +1,196 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { dirname, resolve } from 'path';
import * as vscode from 'vscode';
import { MarkdownEngine } from '../markdownEngine';
import { TableOfContentsProvider } from '../tableOfContentsProvider';
import { resolveUriToMarkdownFile } from '../util/openDocumentLink';
enum LinkKind {
Link, // [...](...)
ReferenceLink, // [...][...]
}
interface CompletionContext {
readonly linkKind: LinkKind;
/**
* Text of the link before the current position
*
* For `[abc](xy|z)` this would be `xy`
*/
readonly linkPrefix: string;
/** Text of the link before the current position */
readonly linkTextStartPosition: vscode.Position;
/**
* Info if the link looks like its for an anchor: `[](#header)`
*/
readonly anchorInfo?: {
/** Text before the `#` */
readonly beforeAnchor: string;
/** Text of the anchor before the current position. */
readonly anchorPrefix: string;
}
}
export class PathCompletionProvider implements vscode.CompletionItemProvider {
public static register(selector: vscode.DocumentSelector, engine: MarkdownEngine): vscode.Disposable {
return vscode.languages.registerCompletionItemProvider(selector, new PathCompletionProvider(engine), '.', '/', '#');
}
constructor(
private readonly engine: MarkdownEngine,
) { }
public async provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken, _context: vscode.CompletionContext): Promise<vscode.CompletionItem[]> {
if (!this.arePathSuggestionEnabled(document)) {
return [];
}
const context = this.getPathCompletionContext(document, position);
if (!context) {
return [];
}
const items: vscode.CompletionItem[] = [];
const isAnchorInCurrentDoc = context.anchorInfo && context.anchorInfo.beforeAnchor.length === 0;
// Add anchor #links in current doc
if (context.linkPrefix.length === 0 || isAnchorInCurrentDoc) {
const range = new vscode.Range(context.linkTextStartPosition, position);
items.push(...(await this.provideHeaderSuggestions(document, range)));
}
if (!isAnchorInCurrentDoc) {
if (context.anchorInfo) { // Anchor to a different document
const rawUri = this.resolveReference(document, context.anchorInfo.beforeAnchor);
if (rawUri) {
const otherDoc = await resolveUriToMarkdownFile(rawUri);
if (otherDoc) {
const anchorStartPosition = position.translate({ characterDelta: -(context.anchorInfo.anchorPrefix.length + 1) });
const range = new vscode.Range(anchorStartPosition, position);
items.push(...(await this.provideHeaderSuggestions(otherDoc, range)));
}
}
} else { // Normal path suggestions
const pathSuggestions = await this.providePathSuggestions(document, position, context);
items.push(...pathSuggestions);
}
}
return items;
}
private arePathSuggestionEnabled(document: vscode.TextDocument): boolean {
const config = vscode.workspace.getConfiguration('markdown', document.uri);
return config.get('suggest.paths.enabled', true);
}
/// [...](...|
private readonly linkStartPattern = /\[([^\]]*?)\]\(\s*([^\s\(\)]*)$/;
private getPathCompletionContext(document: vscode.TextDocument, position: vscode.Position): CompletionContext | undefined {
const prefixRange = new vscode.Range(position.with({ character: 0 }), position);
const linePrefix = document.getText(prefixRange);
const linkPrefixMatch = linePrefix.match(this.linkStartPattern);
if (linkPrefixMatch) {
const prefix = linkPrefixMatch[2];
if (/^\s*[\w\d\-]+:/.test(prefix)) { // Check if this looks like a 'http:' style uri
return undefined;
}
const anchorMatch = prefix.match(/^(.*)#([\w\d\-]*)$/);
return {
linkKind: LinkKind.Link,
linkPrefix: prefix,
linkTextStartPosition: position.translate({ characterDelta: -prefix.length }),
anchorInfo: anchorMatch ? {
beforeAnchor: anchorMatch[1],
anchorPrefix: anchorMatch[2],
} : undefined,
};
}
return undefined;
}
private async provideHeaderSuggestions(document: vscode.TextDocument, range: vscode.Range,): Promise<vscode.CompletionItem[]> {
const items: vscode.CompletionItem[] = [];
const tocProvider = new TableOfContentsProvider(this.engine, document);
const toc = await tocProvider.getToc();
for (const entry of toc) {
items.push({
kind: vscode.CompletionItemKind.Reference,
label: '#' + entry.slug.value,
range: range,
});
}
return items;
}
private async providePathSuggestions(document: vscode.TextDocument, position: vscode.Position, context: CompletionContext): Promise<vscode.CompletionItem[]> {
const valueBeforeLastSlash = context.linkPrefix.substring(0, context.linkPrefix.lastIndexOf('/') + 1); // keep the last slash
const pathSegmentStart = position.translate({ characterDelta: valueBeforeLastSlash.length - context.linkPrefix.length });
const parentDir = this.resolveReference(document, valueBeforeLastSlash || '.');
if (!parentDir) {
return [];
}
try {
const result: vscode.CompletionItem[] = [];
const infos = await vscode.workspace.fs.readDirectory(parentDir);
for (const [name, type] of infos) {
// Exclude paths that start with `.`
if (name.startsWith('.')) {
continue;
}
const isDir = type === vscode.FileType.Directory;
result.push({
label: isDir ? name + '/' : name,
kind: isDir ? vscode.CompletionItemKind.Folder : vscode.CompletionItemKind.File,
range: new vscode.Range(pathSegmentStart, position),
command: isDir ? { command: 'editor.action.triggerSuggest', title: '' } : undefined,
});
}
return result;
} catch (e) {
// ignore
}
return [];
}
private resolveReference(document: vscode.TextDocument, ref: string): vscode.Uri | undefined {
if (ref.startsWith('/')) {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri);
if (workspaceFolder) {
return vscode.Uri.joinPath(workspaceFolder.uri, ref);
}
}
try {
return document.uri.with({
path: resolve(dirname(document.uri.path), ref),
});
} catch (e) {
return undefined;
}
}
}

View File

@@ -10,7 +10,7 @@ import { MarkdownEngine } from '../markdownEngine';
import { MarkdownContributionProvider } from '../markdownExtensions';
import { Disposable } from '../util/dispose';
import { isMarkdownFile } from '../util/file';
import { openDocumentLink, resolveDocumentLink, resolveLinkToMarkdownFile } from '../util/openDocumentLink';
import { openDocumentLink, resolveDocumentLink, resolveUriToMarkdownFile } from '../util/openDocumentLink';
import * as path from '../util/path';
import { WebviewResourceProvider } from '../util/resources';
import { getVisibleLine, LastScrollLocation, TopmostLineMonitor } from '../util/topmostLineMonitor';
@@ -476,9 +476,9 @@ class MarkdownPreview extends Disposable implements WebviewResourceProvider {
const config = vscode.workspace.getConfiguration('markdown', this.resource);
const openLinks = config.get<string>('preview.openMarkdownLinks', 'inPreview');
if (openLinks === 'inPreview') {
const markdownLink = await resolveLinkToMarkdownFile(targetResource);
if (markdownLink) {
this.delegate.openPreviewLinkToMarkdownFile(markdownLink, targetResource.fragment);
const linkedDoc = await resolveUriToMarkdownFile(targetResource);
if (linkedDoc) {
this.delegate.openPreviewLinkToMarkdownFile(linkedDoc.uri, targetResource.fragment);
return;
}
}

View File

@@ -8,17 +8,11 @@ import 'mocha';
import * as vscode from 'vscode';
import LinkProvider from '../features/documentLinkProvider';
import { InMemoryDocument } from './inMemoryDocument';
import { noopToken } from './util';
const testFile = vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, 'x.md');
const noopToken = new class implements vscode.CancellationToken {
private _onCancellationRequestedEmitter = new vscode.EventEmitter<void>();
public onCancellationRequested = this._onCancellationRequestedEmitter.event;
get isCancellationRequested() { return false; }
};
function getLinksForFile(fileContents: string) {
const doc = new InMemoryDocument(testFile, fileContents);
const provider = new LinkProvider();

View File

@@ -51,8 +51,16 @@ export class InMemoryDocument implements vscode.TextDocument {
const preCharacters = before.match(/(?<=\r\n|\n|^).*$/g);
return new vscode.Position(line, preCharacters ? preCharacters[0].length : 0);
}
getText(_range?: vscode.Range | undefined): string {
return this._contents;
getText(range?: vscode.Range): string {
if (!range) {
return this._contents;
}
if (range.start.line !== range.end.line) {
throw new Error('Method not implemented.');
}
return this._lines[range.start.line].slice(range.start.character, range.end.character);
}
getWordRangeAtPosition(_position: vscode.Position, _regex?: RegExp | undefined): never {
throw new Error('Method not implemented.');

View File

@@ -0,0 +1,112 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as assert from 'assert';
import 'mocha';
import * as vscode from 'vscode';
import { PathCompletionProvider } from '../features/pathCompletions';
import { createNewMarkdownEngine } from './engine';
import { InMemoryDocument } from './inMemoryDocument';
import { CURSOR, getCursorPositions, joinLines, noopToken } from './util';
function workspaceFile(...segments: string[]): vscode.Uri {
return vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, ...segments);
}
function getCompletionsAtCursor(resource: vscode.Uri, fileContents: string) {
const doc = new InMemoryDocument(resource, fileContents);
const provider = new PathCompletionProvider(createNewMarkdownEngine());
const cursorPositions = getCursorPositions(fileContents, doc);
return provider.provideCompletionItems(doc, cursorPositions[0], noopToken, {
triggerCharacter: undefined,
triggerKind: vscode.CompletionTriggerKind.Invoke,
});
}
suite('markdown.PathCompletionProvider', () => {
setup(async () => {
// These tests assume that the markdown completion provider is already registered
await vscode.extensions.getExtension('vscode.markdown-language-features')!.activate();
});
test('Should not return anything when triggered in empty doc', async () => {
const completions = await getCompletionsAtCursor(workspaceFile('new.md'), `${CURSOR}`);
assert.strictEqual(completions.length, 0);
});
test('Should return anchor completions', async () => {
const completions = await getCompletionsAtCursor(workspaceFile('new.md'), joinLines(
`[](#${CURSOR}`,
``,
`# A b C`,
`# x y Z`,
));
assert.strictEqual(completions.length, 2);
assert.ok(completions.some(x => x.label === '#a-b-c'), 'Has a-b-c anchor completion');
assert.ok(completions.some(x => x.label === '#x-y-z'), 'Has x-y-z anchor completion');
});
test('Should not return suggestions for http links', async () => {
const completions = await getCompletionsAtCursor(workspaceFile('new.md'), joinLines(
`[](http:${CURSOR}`,
``,
`# http`,
`# http:`,
`# https:`,
));
assert.strictEqual(completions.length, 0);
});
test('Should return relative path suggestions', async () => {
const completions = await getCompletionsAtCursor(workspaceFile('new.md'), joinLines(
`[](${CURSOR}`,
``,
`# A b C`,
));
assert.ok(completions.some(x => x.label === 'a.md'), 'Has a.md file completion');
assert.ok(completions.some(x => x.label === 'b.md'), 'Has b.md file completion');
assert.ok(completions.some(x => x.label === 'sub/'), 'Has sub folder completion');
});
test('Should return relative path suggestions using ./', async () => {
const completions = await getCompletionsAtCursor(workspaceFile('new.md'), joinLines(
`[](./${CURSOR}`,
``,
`# A b C`,
));
assert.ok(completions.some(x => x.label === 'a.md'), 'Has a.md file completion');
assert.ok(completions.some(x => x.label === 'b.md'), 'Has b.md file completion');
assert.ok(completions.some(x => x.label === 'sub/'), 'Has sub folder completion');
});
test('Should return absolute path suggestions using /', async () => {
const completions = await getCompletionsAtCursor(workspaceFile('sub', 'new.md'), joinLines(
`[](/${CURSOR}`,
``,
`# A b C`,
));
assert.ok(completions.some(x => x.label === 'a.md'), 'Has a.md file completion');
assert.ok(completions.some(x => x.label === 'b.md'), 'Has b.md file completion');
assert.ok(completions.some(x => x.label === 'sub/'), 'Has sub folder completion');
assert.ok(!completions.some(x => x.label === 'c.md'), 'Should not have c.md from sub folder');
});
test('Should return anchor suggestions in other file', async () => {
const completions = await getCompletionsAtCursor(workspaceFile('sub', 'new.md'), joinLines(
`[](/b.md#${CURSOR}`,
));
assert.ok(completions.some(x => x.label === '#b'), 'Has #b header completion');
assert.ok(completions.some(x => x.label === '#header1'), 'Has #header1 header completion');
});
});

View File

@@ -8,9 +8,7 @@ import * as vscode from 'vscode';
import MarkdownSmartSelect from '../features/smartSelect';
import { createNewMarkdownEngine } from './engine';
import { InMemoryDocument } from './inMemoryDocument';
import { joinLines } from './util';
const CURSOR = '$$CURSOR$$';
import { CURSOR, getCursorPositions, joinLines } from './util';
const testFileName = vscode.Uri.file('test.md');
@@ -672,17 +670,3 @@ async function getSelectionRangesForDocument(contents: string, pos?: vscode.Posi
const positions = pos ? pos : getCursorPositions(contents, doc);
return await provider.provideSelectionRanges(doc, positions, new vscode.CancellationTokenSource().token);
}
let getCursorPositions = (contents: string, doc: InMemoryDocument): vscode.Position[] => {
let positions: vscode.Position[] = [];
let index = 0;
let wordLength = 0;
while (index !== -1) {
index = contents.indexOf(CURSOR, index + wordLength);
if (index !== -1) {
positions.push(doc.positionAt(index));
}
wordLength = CURSOR.length;
}
return positions;
};

View File

@@ -3,6 +3,31 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as os from 'os';
import * as vscode from 'vscode';
import { InMemoryDocument } from './inMemoryDocument';
export const joinLines = (...args: string[]) =>
args.join(os.platform() === 'win32' ? '\r\n' : '\n');
export const noopToken = new class implements vscode.CancellationToken {
_onCancellationRequestedEmitter = new vscode.EventEmitter<void>();
onCancellationRequested = this._onCancellationRequestedEmitter.event;
get isCancellationRequested() { return false; }
};
export const CURSOR = '$$CURSOR$$';
export function getCursorPositions(contents: string, doc: InMemoryDocument): vscode.Position[] {
let positions: vscode.Position[] = [];
let index = 0;
let wordLength = 0;
while (index !== -1) {
index = contents.indexOf(CURSOR, index + wordLength);
if (index !== -1) {
positions.push(doc.positionAt(index));
}
wordLength = CURSOR.length;
}
return positions;
}

View File

@@ -129,11 +129,11 @@ function tryRevealLineUsingLineFragment(editor: vscode.TextEditor, fragment: str
return false;
}
export async function resolveLinkToMarkdownFile(resource: vscode.Uri): Promise<vscode.Uri | undefined> {
export async function resolveUriToMarkdownFile(resource: vscode.Uri): Promise<vscode.TextDocument | undefined> {
try {
const standardLink = await tryResolveLinkToMarkdownFile(resource);
if (standardLink) {
return standardLink;
const doc = await tryResolveUriToMarkdownFile(resource);
if (doc) {
return doc;
}
} catch {
// Noop
@@ -141,13 +141,13 @@ export async function resolveLinkToMarkdownFile(resource: vscode.Uri): Promise<v
// If no extension, try with `.md` extension
if (extname(resource.path) === '') {
return tryResolveLinkToMarkdownFile(resource.with({ path: resource.path + '.md' }));
return tryResolveUriToMarkdownFile(resource.with({ path: resource.path + '.md' }));
}
return undefined;
}
async function tryResolveLinkToMarkdownFile(resource: vscode.Uri): Promise<vscode.Uri | undefined> {
async function tryResolveUriToMarkdownFile(resource: vscode.Uri): Promise<vscode.TextDocument | undefined> {
let document: vscode.TextDocument;
try {
document = await vscode.workspace.openTextDocument(resource);
@@ -155,7 +155,7 @@ async function tryResolveLinkToMarkdownFile(resource: vscode.Uri): Promise<vscod
return undefined;
}
if (isMarkdownFile(document)) {
return document.uri;
return document;
}
return undefined;
}