Skip to content

Commit

Permalink
Add basic markdown link completions
Browse files Browse the repository at this point in the history
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)`
  • Loading branch information
mjbvz committed Jan 13, 2022
1 parent 351aa03 commit a4e529c
Show file tree
Hide file tree
Showing 11 changed files with 366 additions and 38 deletions.
6 changes: 6 additions & 0 deletions extensions/markdown-language-features/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,12 @@
"%configuration.markdown.links.openLocation.beside%"
]
},
"markdown.suggest.paths.enabled": {
"type": "boolean",
"default": true,
"description": "%configuration.markdown.suggest.paths.enabled.description%",
"scope": "resource"
},
"markdown.trace": {
"type": "string",
"enum": [
Expand Down
1 change: 1 addition & 0 deletions extensions/markdown-language-features/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@
"configuration.markdown.links.openLocation.description": "Controls where links in Markdown files should be opened.",
"configuration.markdown.links.openLocation.currentGroup": "Open links in the active editor group.",
"configuration.markdown.links.openLocation.beside": "Open links beside the active editor.",
"configuration.markdown.suggest.paths.enabled.description": "Enable/disable path suggestions for markdown links",
"workspaceTrust": "Required for loading styles configured in the workspace."
}
4 changes: 3 additions & 1 deletion extensions/markdown-language-features/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
);
}

Expand Down
196 changes: 196 additions & 0 deletions extensions/markdown-language-features/src/features/pathCompletions.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
8 changes: 4 additions & 4 deletions extensions/markdown-language-features/src/features/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 10 additions & 2 deletions extensions/markdown-language-features/src/test/inMemoryDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down

0 comments on commit a4e529c

Please sign in to comment.