mirror of
https://github.com/microsoft/vscode.git
synced 2025-12-24 12:19:20 +00:00
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:
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user