Move MD diagnostics to language server (#155653)

* Move MD diagnostics to language server

This switches us to using the LSP pull diagnostic model with a new version of the language service

* Bump package version

* Delete unused file
This commit is contained in:
Matt Bierner
2022-07-19 16:34:09 -07:00
committed by GitHub
parent db4ba2062d
commit 32f5e49082
26 changed files with 475 additions and 2474 deletions

View File

@@ -3,609 +3,20 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as picomatch from 'picomatch';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { CommandManager } from '../commandManager';
import { ILogger } from '../logging';
import { MdTableOfContentsProvider } from '../tableOfContents';
import { ITextDocument } from '../types/textDocument';
import { Delayer } from '../util/async';
import { noopToken } from '../util/cancellation';
import { Disposable } from '../util/dispose';
import { isMarkdownFile, looksLikeMarkdownPath } from '../util/file';
import { Limiter } from '../util/limiter';
import { ResourceMap } from '../util/resourceMap';
import { MdTableOfContentsWatcher } from '../util/tableOfContentsWatcher';
import { IMdWorkspace } from '../workspace';
import { InternalHref, LinkDefinitionSet, MdLink, MdLinkProvider, MdLinkSource } from './documentLinks';
import { MdReferencesProvider, tryResolveLinkPath } from './references';
const localize = nls.loadMessageBundle();
export interface DiagnosticConfiguration {
/**
* Fired when the configuration changes.
*/
readonly onDidChange: vscode.Event<void>;
getOptions(resource: vscode.Uri): DiagnosticOptions;
// Copied from markdown language service
export enum DiagnosticCode {
link_noSuchReferences = 'link.no-such-reference',
link_noSuchHeaderInOwnFile = 'link.no-such-header-in-own-file',
link_noSuchFile = 'link.no-such-file',
link_noSuchHeaderInFile = 'link.no-such-header-in-file',
}
export enum DiagnosticLevel {
ignore = 'ignore',
warning = 'warning',
error = 'error',
}
export interface DiagnosticOptions {
readonly enabled: boolean;
readonly validateReferences: DiagnosticLevel | undefined;
readonly validateFragmentLinks: DiagnosticLevel | undefined;
readonly validateFileLinks: DiagnosticLevel | undefined;
readonly validateMarkdownFileLinkFragments: DiagnosticLevel | undefined;
readonly ignoreLinks: readonly string[];
}
function toSeverity(level: DiagnosticLevel | undefined): vscode.DiagnosticSeverity | undefined {
switch (level) {
case DiagnosticLevel.error: return vscode.DiagnosticSeverity.Error;
case DiagnosticLevel.warning: return vscode.DiagnosticSeverity.Warning;
case DiagnosticLevel.ignore: return undefined;
case undefined: return undefined;
}
}
class VSCodeDiagnosticConfiguration extends Disposable implements DiagnosticConfiguration {
private readonly _onDidChange = this._register(new vscode.EventEmitter<void>());
public readonly onDidChange = this._onDidChange.event;
constructor() {
super();
this._register(vscode.workspace.onDidChangeConfiguration(e => {
if (
e.affectsConfiguration('markdown.experimental.validate.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.referenceLinks.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.fragmentLinks.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.fileLinks.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.fileLinks.markdownFragmentLinks')
|| e.affectsConfiguration('markdown.experimental.validate.ignoreLinks')
) {
this._onDidChange.fire();
}
}));
}
public getOptions(resource: vscode.Uri): DiagnosticOptions {
const config = vscode.workspace.getConfiguration('markdown', resource);
const validateFragmentLinks = config.get<DiagnosticLevel>('experimental.validate.fragmentLinks.enabled');
return {
enabled: config.get<boolean>('experimental.validate.enabled', false),
validateReferences: config.get<DiagnosticLevel>('experimental.validate.referenceLinks.enabled'),
validateFragmentLinks,
validateFileLinks: config.get<DiagnosticLevel>('experimental.validate.fileLinks.enabled'),
validateMarkdownFileLinkFragments: config.get<DiagnosticLevel | undefined>('markdown.experimental.validate.fileLinks.markdownFragmentLinks', validateFragmentLinks),
ignoreLinks: config.get('experimental.validate.ignoreLinks', []),
};
}
}
class InflightDiagnosticRequests {
private readonly inFlightRequests = new ResourceMap<{ readonly cts: vscode.CancellationTokenSource }>();
public async trigger(resource: vscode.Uri, compute: (token: vscode.CancellationToken) => Promise<void>): Promise<void> {
this.cancel(resource);
const cts = new vscode.CancellationTokenSource();
const entry = { cts };
this.inFlightRequests.set(resource, entry);
try {
return await compute(cts.token);
} finally {
if (this.inFlightRequests.get(resource) === entry) {
this.inFlightRequests.delete(resource);
}
cts.dispose();
}
}
public cancel(resource: vscode.Uri) {
const existing = this.inFlightRequests.get(resource);
if (existing) {
existing.cts.cancel();
this.inFlightRequests.delete(resource);
}
}
public dispose() {
this.clear();
}
public clear() {
for (const { cts } of this.inFlightRequests.values()) {
cts.dispose();
}
this.inFlightRequests.clear();
}
}
class LinkWatcher extends Disposable {
private readonly _onDidChangeLinkedToFile = this._register(new vscode.EventEmitter<Iterable<vscode.Uri>>);
/**
* Event fired with a list of document uri when one of the links in the document changes
*/
public readonly onDidChangeLinkedToFile = this._onDidChangeLinkedToFile.event;
private readonly _watchers = new ResourceMap<{
/**
* Watcher for this link path
*/
readonly watcher: vscode.Disposable;
/**
* List of documents that reference the link
*/
readonly documents: ResourceMap</* document resource*/ vscode.Uri>;
}>();
override dispose() {
super.dispose();
for (const entry of this._watchers.values()) {
entry.watcher.dispose();
}
this._watchers.clear();
}
/**
* Set the known links in a markdown document, adding and removing file watchers as needed
*/
updateLinksForDocument(document: vscode.Uri, links: readonly MdLink[]) {
const linkedToResource = new Set<vscode.Uri>(
links
.filter(link => link.href.kind === 'internal')
.map(link => (link.href as InternalHref).path));
// First decrement watcher counter for previous document state
for (const entry of this._watchers.values()) {
entry.documents.delete(document);
}
// Then create/update watchers for new document state
for (const path of linkedToResource) {
let entry = this._watchers.get(path);
if (!entry) {
entry = {
watcher: this.startWatching(path),
documents: new ResourceMap(),
};
this._watchers.set(path, entry);
}
entry.documents.set(document, document);
}
// Finally clean up watchers for links that are no longer are referenced anywhere
for (const [key, value] of this._watchers) {
if (value.documents.size === 0) {
value.watcher.dispose();
this._watchers.delete(key);
}
}
}
deleteDocument(resource: vscode.Uri) {
this.updateLinksForDocument(resource, []);
}
private startWatching(path: vscode.Uri): vscode.Disposable {
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(path, '*'), false, true, false);
const handler = (resource: vscode.Uri) => this.onLinkedResourceChanged(resource);
return vscode.Disposable.from(
watcher,
watcher.onDidDelete(handler),
watcher.onDidCreate(handler),
);
}
private onLinkedResourceChanged(resource: vscode.Uri) {
const entry = this._watchers.get(resource);
if (entry) {
this._onDidChangeLinkedToFile.fire(entry.documents.values());
}
}
}
class LinkDoesNotExistDiagnostic extends vscode.Diagnostic {
public readonly link: string;
constructor(range: vscode.Range, message: string, severity: vscode.DiagnosticSeverity, link: string) {
super(range, message, severity);
this.link = link;
}
}
export abstract class DiagnosticReporter extends Disposable {
private readonly pending = new Set<Promise<any>>();
public clear(): void {
this.pending.clear();
}
public abstract set(uri: vscode.Uri, diagnostics: readonly vscode.Diagnostic[]): void;
public abstract delete(uri: vscode.Uri): void;
public abstract isOpen(uri: vscode.Uri): boolean;
public abstract getOpenDocuments(): ITextDocument[];
public addWorkItem(promise: Promise<any>): Promise<any> {
this.pending.add(promise);
promise.finally(() => this.pending.delete(promise));
return promise;
}
public async waitPendingWork(): Promise<void> {
await Promise.all([...this.pending.values()]);
}
}
export class DiagnosticCollectionReporter extends DiagnosticReporter {
private readonly collection: vscode.DiagnosticCollection;
constructor() {
super();
this.collection = this._register(vscode.languages.createDiagnosticCollection('markdown'));
}
public override clear(): void {
super.clear();
this.collection.clear();
}
public set(uri: vscode.Uri, diagnostics: readonly vscode.Diagnostic[]): void {
this.collection.set(uri, this.isOpen(uri) ? diagnostics : []);
}
public isOpen(uri: vscode.Uri): boolean {
const tabs = this.getTabResources();
return tabs.has(uri);
}
public delete(uri: vscode.Uri): void {
this.collection.delete(uri);
}
public getOpenDocuments(): ITextDocument[] {
const tabs = this.getTabResources();
return vscode.workspace.textDocuments.filter(doc => tabs.has(doc.uri));
}
private getTabResources(): ResourceMap<void> {
const openedTabDocs = new ResourceMap<void>();
for (const group of vscode.window.tabGroups.all) {
for (const tab of group.tabs) {
if (tab.input instanceof vscode.TabInputText) {
openedTabDocs.set(tab.input.uri);
}
}
}
return openedTabDocs;
}
}
export class DiagnosticManager extends Disposable {
private readonly diagnosticDelayer: Delayer<void>;
private readonly pendingDiagnostics = new Set<vscode.Uri>();
private readonly inFlightDiagnostics = this._register(new InflightDiagnosticRequests());
private readonly linkWatcher = this._register(new LinkWatcher());
private readonly tableOfContentsWatcher: MdTableOfContentsWatcher;
public readonly ready: Promise<void>;
constructor(
private readonly workspace: IMdWorkspace,
private readonly computer: DiagnosticComputer,
private readonly configuration: DiagnosticConfiguration,
private readonly reporter: DiagnosticReporter,
private readonly referencesProvider: MdReferencesProvider,
tocProvider: MdTableOfContentsProvider,
private readonly logger: ILogger,
delay = 300,
) {
super();
this.diagnosticDelayer = this._register(new Delayer(delay));
this._register(this.configuration.onDidChange(() => {
this.rebuild();
}));
this._register(workspace.onDidCreateMarkdownDocument(doc => {
this.triggerDiagnostics(doc.uri);
// Links in other files may have become valid
this.triggerForReferencingFiles(doc.uri);
}));
this._register(workspace.onDidChangeMarkdownDocument(doc => {
this.triggerDiagnostics(doc.uri);
}));
this._register(workspace.onDidDeleteMarkdownDocument(uri => {
this.triggerForReferencingFiles(uri);
}));
this._register(vscode.workspace.onDidCloseTextDocument(({ uri }) => {
this.pendingDiagnostics.delete(uri);
this.inFlightDiagnostics.cancel(uri);
this.linkWatcher.deleteDocument(uri);
this.reporter.delete(uri);
}));
this._register(this.linkWatcher.onDidChangeLinkedToFile(changedDocuments => {
for (const resource of changedDocuments) {
const doc = vscode.workspace.textDocuments.find(doc => doc.uri.toString() === resource.toString());
if (doc && isMarkdownFile(doc)) {
this.triggerDiagnostics(doc.uri);
}
}
}));
this.tableOfContentsWatcher = this._register(new MdTableOfContentsWatcher(workspace, tocProvider, delay / 2));
this._register(this.tableOfContentsWatcher.onTocChanged(e => {
return this.triggerForReferencingFiles(e.uri);
}));
this.ready = this.rebuild();
}
private triggerForReferencingFiles(uri: vscode.Uri): Promise<void> {
return this.reporter.addWorkItem(
(async () => {
const triggered = new ResourceMap<Promise<void>>();
for (const ref of await this.referencesProvider.getReferencesToFileInDocs(uri, this.reporter.getOpenDocuments(), noopToken)) {
const file = ref.location.uri;
if (!triggered.has(file)) {
triggered.set(file, this.triggerDiagnostics(file));
}
}
await Promise.all(triggered.values());
})());
}
public override dispose() {
super.dispose();
this.pendingDiagnostics.clear();
}
private async recomputeDiagnosticState(doc: ITextDocument, token: vscode.CancellationToken): Promise<{ diagnostics: readonly vscode.Diagnostic[]; links: readonly MdLink[]; config: DiagnosticOptions }> {
this.logger.verbose('DiagnosticManager', `recomputeDiagnosticState - ${doc.uri}`);
const config = this.configuration.getOptions(doc.uri);
if (!config.enabled) {
return { diagnostics: [], links: [], config };
}
return { ...await this.computer.getDiagnostics(doc, config, token), config };
}
private async recomputePendingDiagnostics(): Promise<void> {
const pending = [...this.pendingDiagnostics];
this.pendingDiagnostics.clear();
await Promise.all(pending.map(async resource => {
const doc = await this.workspace.getOrLoadMarkdownDocument(resource);
if (doc) {
await this.inFlightDiagnostics.trigger(doc.uri, async (token) => {
if (this.reporter.isOpen(doc.uri)) {
const state = await this.recomputeDiagnosticState(doc, token);
this.linkWatcher.updateLinksForDocument(doc.uri, state.config.enabled && state.config.validateFileLinks ? state.links : []);
this.reporter.set(doc.uri, state.diagnostics);
} else {
this.linkWatcher.deleteDocument(doc.uri);
this.reporter.delete(doc.uri);
}
});
}
}));
}
private rebuild(): Promise<void> {
this.reporter.clear();
this.pendingDiagnostics.clear();
this.inFlightDiagnostics.clear();
return this.reporter.addWorkItem(
Promise.all(Array.from(this.reporter.getOpenDocuments(), doc => this.triggerDiagnostics(doc.uri)))
);
}
private async triggerDiagnostics(uri: vscode.Uri): Promise<void> {
this.inFlightDiagnostics.cancel(uri);
this.pendingDiagnostics.add(uri);
return this.reporter.addWorkItem(
this.diagnosticDelayer.trigger(() => this.recomputePendingDiagnostics())
);
}
}
/**
* Map of file paths to markdown links to that file.
*/
class FileLinkMap {
private readonly _filesToLinksMap = new ResourceMap<{
readonly outgoingLinks: Array<{
readonly source: MdLinkSource;
readonly fragment: string;
}>;
}>();
constructor(links: Iterable<MdLink>) {
for (const link of links) {
if (link.href.kind !== 'internal') {
continue;
}
const existingFileEntry = this._filesToLinksMap.get(link.href.path);
const linkData = { source: link.source, fragment: link.href.fragment };
if (existingFileEntry) {
existingFileEntry.outgoingLinks.push(linkData);
} else {
this._filesToLinksMap.set(link.href.path, { outgoingLinks: [linkData] });
}
}
}
public get size(): number {
return this._filesToLinksMap.size;
}
public entries() {
return this._filesToLinksMap.entries();
}
}
export class DiagnosticComputer {
constructor(
private readonly workspace: IMdWorkspace,
private readonly linkProvider: MdLinkProvider,
private readonly tocProvider: MdTableOfContentsProvider,
) { }
public async getDiagnostics(doc: ITextDocument, options: DiagnosticOptions, token: vscode.CancellationToken): Promise<{ readonly diagnostics: vscode.Diagnostic[]; readonly links: readonly MdLink[] }> {
const { links, definitions } = await this.linkProvider.getLinks(doc);
if (token.isCancellationRequested || !options.enabled) {
return { links, diagnostics: [] };
}
return {
links,
diagnostics: (await Promise.all([
this.validateFileLinks(options, links, token),
Array.from(this.validateReferenceLinks(options, links, definitions)),
this.validateFragmentLinks(doc, options, links, token),
])).flat()
};
}
private async validateFragmentLinks(doc: ITextDocument, options: DiagnosticOptions, links: readonly MdLink[], token: vscode.CancellationToken): Promise<vscode.Diagnostic[]> {
const severity = toSeverity(options.validateFragmentLinks);
if (typeof severity === 'undefined') {
return [];
}
const toc = await this.tocProvider.getForDocument(doc);
if (token.isCancellationRequested) {
return [];
}
const diagnostics: vscode.Diagnostic[] = [];
for (const link of links) {
if (link.href.kind === 'internal'
&& link.source.hrefText.startsWith('#')
&& link.href.path.toString() === doc.uri.toString()
&& link.href.fragment
&& !toc.lookup(link.href.fragment)
) {
if (!this.isIgnoredLink(options, link.source.hrefText)) {
diagnostics.push(new LinkDoesNotExistDiagnostic(
link.source.hrefRange,
localize('invalidHeaderLink', 'No header found: \'{0}\'', link.href.fragment),
severity,
link.source.hrefText));
}
}
}
return diagnostics;
}
private *validateReferenceLinks(options: DiagnosticOptions, links: readonly MdLink[], definitions: LinkDefinitionSet): Iterable<vscode.Diagnostic> {
const severity = toSeverity(options.validateReferences);
if (typeof severity === 'undefined') {
return [];
}
for (const link of links) {
if (link.href.kind === 'reference' && !definitions.lookup(link.href.ref)) {
yield new vscode.Diagnostic(
link.source.hrefRange,
localize('invalidReferenceLink', 'No link definition found: \'{0}\'', link.href.ref),
severity);
}
}
}
private async validateFileLinks(options: DiagnosticOptions, links: readonly MdLink[], token: vscode.CancellationToken): Promise<vscode.Diagnostic[]> {
const pathErrorSeverity = toSeverity(options.validateFileLinks);
if (typeof pathErrorSeverity === 'undefined') {
return [];
}
const fragmentErrorSeverity = toSeverity(typeof options.validateMarkdownFileLinkFragments === 'undefined' ? options.validateFragmentLinks : options.validateMarkdownFileLinkFragments);
// We've already validated our own fragment links in `validateOwnHeaderLinks`
const linkSet = new FileLinkMap(links.filter(link => !link.source.hrefText.startsWith('#')));
if (linkSet.size === 0) {
return [];
}
const limiter = new Limiter(10);
const diagnostics: vscode.Diagnostic[] = [];
await Promise.all(
Array.from(linkSet.entries()).map(([path, { outgoingLinks: links }]) => {
return limiter.queue(async () => {
if (token.isCancellationRequested) {
return;
}
const resolvedHrefPath = await tryResolveLinkPath(path, this.workspace);
if (!resolvedHrefPath) {
const msg = localize('invalidPathLink', 'File does not exist at path: {0}', path.fsPath);
for (const link of links) {
if (!this.isIgnoredLink(options, link.source.pathText)) {
diagnostics.push(new LinkDoesNotExistDiagnostic(link.source.hrefRange, msg, pathErrorSeverity, link.source.pathText));
}
}
} else if (typeof fragmentErrorSeverity !== 'undefined' && this.isMarkdownPath(resolvedHrefPath)) {
// Validate each of the links to headers in the file
const fragmentLinks = links.filter(x => x.fragment);
if (fragmentLinks.length) {
const toc = await this.tocProvider.get(resolvedHrefPath);
for (const link of fragmentLinks) {
if (!toc.lookup(link.fragment) && !this.isIgnoredLink(options, link.source.pathText) && !this.isIgnoredLink(options, link.source.hrefText)) {
const msg = localize('invalidLinkToHeaderInOtherFile', 'Header does not exist in file: {0}', link.fragment);
const range = link.source.fragmentRange?.with({ start: link.source.fragmentRange.start.translate(0, -1) }) ?? link.source.hrefRange;
diagnostics.push(new LinkDoesNotExistDiagnostic(range, msg, fragmentErrorSeverity, link.source.hrefText));
}
}
}
}
});
}));
return diagnostics;
}
private isMarkdownPath(resolvedHrefPath: vscode.Uri) {
return this.workspace.hasMarkdownDocument(resolvedHrefPath) || looksLikeMarkdownPath(resolvedHrefPath);
}
private isIgnoredLink(options: DiagnosticOptions, link: string): boolean {
return options.ignoreLinks.some(glob => picomatch.isMatch(link, glob));
}
}
class AddToIgnoreLinksQuickFixProvider implements vscode.CodeActionProvider {
@@ -636,17 +47,26 @@ class AddToIgnoreLinksQuickFixProvider implements vscode.CodeActionProvider {
const fixes: vscode.CodeAction[] = [];
for (const diagnostic of context.diagnostics) {
if (diagnostic instanceof LinkDoesNotExistDiagnostic) {
const fix = new vscode.CodeAction(
localize('ignoreLinksQuickFix.title', "Exclude '{0}' from link validation.", diagnostic.link),
vscode.CodeActionKind.QuickFix);
switch (diagnostic.code) {
case DiagnosticCode.link_noSuchReferences:
case DiagnosticCode.link_noSuchHeaderInOwnFile:
case DiagnosticCode.link_noSuchFile:
case DiagnosticCode.link_noSuchHeaderInFile: {
const hrefText = (diagnostic as any).data?.hrefText;
if (hrefText) {
const fix = new vscode.CodeAction(
localize('ignoreLinksQuickFix.title', "Exclude '{0}' from link validation.", hrefText),
vscode.CodeActionKind.QuickFix);
fix.command = {
command: AddToIgnoreLinksQuickFixProvider._addToIgnoreLinksCommandId,
title: '',
arguments: [document.uri, diagnostic.link]
};
fixes.push(fix);
fix.command = {
command: AddToIgnoreLinksQuickFixProvider._addToIgnoreLinksCommandId,
title: '',
arguments: [document.uri, hrefText],
};
fixes.push(fix);
}
break;
}
}
}
@@ -654,26 +74,10 @@ class AddToIgnoreLinksQuickFixProvider implements vscode.CodeActionProvider {
}
}
export function registerDiagnosticSupport(
selector: vscode.DocumentSelector,
workspace: IMdWorkspace,
linkProvider: MdLinkProvider,
commandManager: CommandManager,
referenceProvider: MdReferencesProvider,
tocProvider: MdTableOfContentsProvider,
logger: ILogger,
): vscode.Disposable {
const configuration = new VSCodeDiagnosticConfiguration();
const manager = new DiagnosticManager(
workspace,
new DiagnosticComputer(workspace, linkProvider, tocProvider),
configuration,
new DiagnosticCollectionReporter(),
referenceProvider,
tocProvider,
logger);
return vscode.Disposable.from(
configuration,
manager,
AddToIgnoreLinksQuickFixProvider.register(selector, commandManager));
return AddToIgnoreLinksQuickFixProvider.register(selector, commandManager);
}

View File

@@ -1,540 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as uri from 'vscode-uri';
import { ILogger } from '../logging';
import { IMdParser } from '../markdownEngine';
import { getLine, ITextDocument } from '../types/textDocument';
import { noopToken } from '../util/cancellation';
import { Disposable } from '../util/dispose';
import { Schemes } from '../util/schemes';
import { MdDocumentInfoCache } from '../util/workspaceCache';
import { IMdWorkspace } from '../workspace';
export interface ExternalHref {
readonly kind: 'external';
readonly uri: vscode.Uri;
}
export interface InternalHref {
readonly kind: 'internal';
readonly path: vscode.Uri;
readonly fragment: string;
}
export interface ReferenceHref {
readonly kind: 'reference';
readonly ref: string;
}
export type LinkHref = ExternalHref | InternalHref | ReferenceHref;
function resolveLink(
document: ITextDocument,
link: string,
): ExternalHref | InternalHref | undefined {
const cleanLink = stripAngleBrackets(link);
if (/^[a-z\-][a-z\-]+:/i.test(cleanLink)) {
// Looks like a uri
return { kind: 'external', uri: vscode.Uri.parse(cleanLink) };
}
// Assume it must be an relative or absolute file path
// Use a fake scheme to avoid parse warnings
const tempUri = vscode.Uri.parse(`vscode-resource:${link}`);
let resourceUri: vscode.Uri | undefined;
if (!tempUri.path) {
resourceUri = document.uri;
} else if (tempUri.path[0] === '/') {
const root = getWorkspaceFolder(document);
if (root) {
resourceUri = vscode.Uri.joinPath(root, tempUri.path);
}
} else {
if (document.uri.scheme === Schemes.untitled) {
const root = getWorkspaceFolder(document);
if (root) {
resourceUri = vscode.Uri.joinPath(root, tempUri.path);
}
} else {
const base = uri.Utils.dirname(document.uri);
resourceUri = vscode.Uri.joinPath(base, tempUri.path);
}
}
if (!resourceUri) {
return undefined;
}
// If we are in a notebook cell, resolve relative to notebook instead
if (resourceUri.scheme === Schemes.notebookCell) {
const notebook = vscode.workspace.notebookDocuments
.find(notebook => notebook.getCells().some(cell => cell.document === document));
if (notebook) {
resourceUri = resourceUri.with({ scheme: notebook.uri.scheme });
}
}
return {
kind: 'internal',
path: resourceUri.with({ fragment: '' }),
fragment: tempUri.fragment,
};
}
function getWorkspaceFolder(document: ITextDocument) {
return vscode.workspace.getWorkspaceFolder(document.uri)?.uri
|| vscode.workspace.workspaceFolders?.[0]?.uri;
}
export interface MdLinkSource {
/**
* The full range of the link.
*/
readonly range: vscode.Range;
/**
* The file where the link is defined.
*/
readonly resource: vscode.Uri;
/**
* The original text of the link destination in code.
*/
readonly hrefText: string;
/**
* The original text of just the link's path in code.
*/
readonly pathText: string;
/**
* The range of the path.
*/
readonly hrefRange: vscode.Range;
/**
* The range of the fragment within the path.
*/
readonly fragmentRange: vscode.Range | undefined;
}
export interface MdInlineLink {
readonly kind: 'link';
readonly source: MdLinkSource;
readonly href: LinkHref;
}
export interface MdLinkDefinition {
readonly kind: 'definition';
readonly source: MdLinkSource;
readonly ref: {
readonly range: vscode.Range;
readonly text: string;
};
readonly href: ExternalHref | InternalHref;
}
export type MdLink = MdInlineLink | MdLinkDefinition;
function extractDocumentLink(
document: ITextDocument,
pre: string,
rawLink: string,
matchIndex: number,
fullMatch: string,
): MdLink | undefined {
const isAngleBracketLink = rawLink.startsWith('<');
const link = stripAngleBrackets(rawLink);
let linkTarget: ExternalHref | InternalHref | undefined;
try {
linkTarget = resolveLink(document, link);
} catch {
return undefined;
}
if (!linkTarget) {
return undefined;
}
const linkStart = document.positionAt(matchIndex);
const linkEnd = linkStart.translate(0, fullMatch.length);
const hrefStart = linkStart.translate(0, pre.length + (isAngleBracketLink ? 1 : 0));
const hrefEnd = hrefStart.translate(0, link.length);
return {
kind: 'link',
href: linkTarget,
source: {
hrefText: link,
resource: document.uri,
range: new vscode.Range(linkStart, linkEnd),
hrefRange: new vscode.Range(hrefStart, hrefEnd),
...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd),
}
};
}
function getFragmentRange(text: string, start: vscode.Position, end: vscode.Position): vscode.Range | undefined {
const index = text.indexOf('#');
if (index < 0) {
return undefined;
}
return new vscode.Range(start.translate({ characterDelta: index + 1 }), end);
}
function getLinkSourceFragmentInfo(document: ITextDocument, link: string, linkStart: vscode.Position, linkEnd: vscode.Position): { fragmentRange: vscode.Range | undefined; pathText: string } {
const fragmentRange = getFragmentRange(link, linkStart, linkEnd);
return {
pathText: document.getText(new vscode.Range(linkStart, fragmentRange ? fragmentRange.start.translate(0, -1) : linkEnd)),
fragmentRange,
};
}
const angleBracketLinkRe = /^<(.*)>$/;
/**
* Used to strip brackets from the markdown link
*
* <http://example.com> will be transformed to http://example.com
*/
function stripAngleBrackets(link: string) {
return link.replace(angleBracketLinkRe, '$1');
}
const r = String.raw;
/**
* Matches `[text](link)` or `[text](<link>)`
*/
const linkPattern = new RegExp(
// text
r`(\[` + // open prefix match -->
/**/r`(?:` +
/*****/r`[^\[\]\\]|` + // Non-bracket chars, or...
/*****/r`\\.|` + // Escaped char, or...
/*****/r`\[[^\[\]]*\]` + // Matched bracket pair
/**/r`)*` +
r`\]` +
// Destination
r`\(\s*)` + // <-- close prefix match
/**/r`(` +
/*****/r`[^\s\(\)\<](?:[^\s\(\)]|\([^\s\(\)]*?\))*|` + // Link without whitespace, or...
/*****/r`<[^<>]+>` + // In angle brackets
/**/r`)` +
// Title
/**/r`\s*(?:"[^"]*"|'[^']*'|\([^\(\)]*\))?\s*` +
r`\)`,
'g');
/**
* Matches `[text][ref]` or `[shorthand]`
*/
const referenceLinkPattern = /(^|[^\]\\])(?:(?:(\[((?:\\\]|[^\]])+)\]\[\s*?)([^\s\]]*?)\]|\[\s*?([^\s\\\]]*?)\])(?![\:\(]))/gm;
/**
* Matches `<http://example.com>`
*/
const autoLinkPattern = /\<(\w+:[^\>\s]+)\>/g;
/**
* Matches `[text]: link`
*/
const definitionPattern = /^([\t ]*\[(?!\^)((?:\\\]|[^\]])+)\]:\s*)([^<]\S*|<[^>]+>)/gm;
const inlineCodePattern = /(?:^|[^`])(`+)(?:.+?|.*?(?:(?:\r?\n).+?)*?)(?:\r?\n)?\1(?:$|[^`])/gm;
class NoLinkRanges {
public static async compute(tokenizer: IMdParser, document: ITextDocument): Promise<NoLinkRanges> {
const tokens = await tokenizer.tokenize(document);
const multiline = tokens.filter(t => (t.type === 'code_block' || t.type === 'fence' || t.type === 'html_block') && !!t.map).map(t => t.map) as [number, number][];
const inlineRanges = new Map</* line number */ number, vscode.Range[]>();
const text = document.getText();
for (const match of text.matchAll(inlineCodePattern)) {
const startOffset = match.index ?? 0;
const startPosition = document.positionAt(startOffset);
const range = new vscode.Range(startPosition, document.positionAt(startOffset + match[0].length));
for (let line = range.start.line; line <= range.end.line; ++line) {
let entry = inlineRanges.get(line);
if (!entry) {
entry = [];
inlineRanges.set(line, entry);
}
entry.push(range);
}
}
return new NoLinkRanges(multiline, inlineRanges);
}
private constructor(
/**
* code blocks and fences each represented by [line_start,line_end).
*/
public readonly multiline: ReadonlyArray<[number, number]>,
/**
* Inline code spans where links should not be detected
*/
public readonly inline: Map</* line number */ number, vscode.Range[]>
) { }
contains(position: vscode.Position): boolean {
return this.multiline.some(interval => position.line >= interval[0] && position.line < interval[1]) ||
!!this.inline.get(position.line)?.some(inlineRange => inlineRange.contains(position));
}
concatInline(inlineRanges: Iterable<vscode.Range>): NoLinkRanges {
const newInline = new Map(this.inline);
for (const range of inlineRanges) {
for (let line = range.start.line; line <= range.end.line; ++line) {
let entry = newInline.get(line);
if (!entry) {
entry = [];
newInline.set(line, entry);
}
entry.push(range);
}
}
return new NoLinkRanges(this.multiline, newInline);
}
}
/**
* Stateless object that extracts link information from markdown files.
*/
export class MdLinkComputer {
constructor(
private readonly tokenizer: IMdParser,
) { }
public async getAllLinks(document: ITextDocument, token: vscode.CancellationToken): Promise<MdLink[]> {
const noLinkRanges = await NoLinkRanges.compute(this.tokenizer, document);
if (token.isCancellationRequested) {
return [];
}
const inlineLinks = Array.from(this.getInlineLinks(document, noLinkRanges));
return Array.from([
...inlineLinks,
...this.getReferenceLinks(document, noLinkRanges.concatInline(inlineLinks.map(x => x.source.range))),
...this.getLinkDefinitions(document, noLinkRanges),
...this.getAutoLinks(document, noLinkRanges),
]);
}
private *getInlineLinks(document: ITextDocument, noLinkRanges: NoLinkRanges): Iterable<MdLink> {
const text = document.getText();
for (const match of text.matchAll(linkPattern)) {
const matchLinkData = extractDocumentLink(document, match[1], match[2], match.index ?? 0, match[0]);
if (matchLinkData && !noLinkRanges.contains(matchLinkData.source.hrefRange.start)) {
yield matchLinkData;
// Also check link destination for links
for (const innerMatch of match[1].matchAll(linkPattern)) {
const innerData = extractDocumentLink(document, innerMatch[1], innerMatch[2], (match.index ?? 0) + (innerMatch.index ?? 0), innerMatch[0]);
if (innerData) {
yield innerData;
}
}
}
}
}
private *getAutoLinks(document: ITextDocument, noLinkRanges: NoLinkRanges): Iterable<MdLink> {
const text = document.getText();
for (const match of text.matchAll(autoLinkPattern)) {
const linkOffset = (match.index ?? 0);
const linkStart = document.positionAt(linkOffset);
if (noLinkRanges.contains(linkStart)) {
continue;
}
const link = match[1];
const linkTarget = resolveLink(document, link);
if (!linkTarget) {
continue;
}
const linkEnd = linkStart.translate(0, match[0].length);
const hrefStart = linkStart.translate(0, 1);
const hrefEnd = hrefStart.translate(0, link.length);
yield {
kind: 'link',
href: linkTarget,
source: {
hrefText: link,
resource: document.uri,
hrefRange: new vscode.Range(hrefStart, hrefEnd),
range: new vscode.Range(linkStart, linkEnd),
...getLinkSourceFragmentInfo(document, link, hrefStart, hrefEnd),
}
};
}
}
private *getReferenceLinks(document: ITextDocument, noLinkRanges: NoLinkRanges): Iterable<MdLink> {
const text = document.getText();
for (const match of text.matchAll(referenceLinkPattern)) {
const linkStartOffset = (match.index ?? 0) + match[1].length;
const linkStart = document.positionAt(linkStartOffset);
if (noLinkRanges.contains(linkStart)) {
continue;
}
let hrefStart: vscode.Position;
let hrefEnd: vscode.Position;
let reference = match[4];
if (reference === '') { // [ref][],
reference = match[3];
const offset = linkStartOffset + 1;
hrefStart = document.positionAt(offset);
hrefEnd = document.positionAt(offset + reference.length);
} else if (reference) { // [text][ref]
const pre = match[2];
const offset = linkStartOffset + pre.length;
hrefStart = document.positionAt(offset);
hrefEnd = document.positionAt(offset + reference.length);
} else if (match[5]) { // [ref]
reference = match[5];
const offset = linkStartOffset + 1;
hrefStart = document.positionAt(offset);
const line = getLine(document, hrefStart.line);
// See if link looks like a checkbox
const checkboxMatch = line.match(/^\s*[\-\*]\s*\[x\]/i);
if (checkboxMatch && hrefStart.character <= checkboxMatch[0].length) {
continue;
}
hrefEnd = document.positionAt(offset + reference.length);
} else {
continue;
}
const linkEnd = linkStart.translate(0, match[0].length - match[1].length);
yield {
kind: 'link',
source: {
hrefText: reference,
pathText: reference,
resource: document.uri,
range: new vscode.Range(linkStart, linkEnd),
hrefRange: new vscode.Range(hrefStart, hrefEnd),
fragmentRange: undefined,
},
href: {
kind: 'reference',
ref: reference,
}
};
}
}
private *getLinkDefinitions(document: ITextDocument, noLinkRanges: NoLinkRanges): Iterable<MdLinkDefinition> {
const text = document.getText();
for (const match of text.matchAll(definitionPattern)) {
const offset = (match.index ?? 0);
const linkStart = document.positionAt(offset);
if (noLinkRanges.contains(linkStart)) {
continue;
}
const pre = match[1];
const reference = match[2];
const rawLinkText = match[3].trim();
const target = resolveLink(document, rawLinkText);
if (!target) {
continue;
}
const isAngleBracketLink = angleBracketLinkRe.test(rawLinkText);
const linkText = stripAngleBrackets(rawLinkText);
const hrefStart = linkStart.translate(0, pre.length + (isAngleBracketLink ? 1 : 0));
const hrefEnd = hrefStart.translate(0, linkText.length);
const hrefRange = new vscode.Range(hrefStart, hrefEnd);
const refStart = linkStart.translate(0, 1);
const refRange = new vscode.Range(refStart, refStart.translate({ characterDelta: reference.length }));
const linkEnd = linkStart.translate(0, match[0].length);
yield {
kind: 'definition',
source: {
hrefText: linkText,
resource: document.uri,
range: new vscode.Range(linkStart, linkEnd),
hrefRange,
...getLinkSourceFragmentInfo(document, rawLinkText, hrefStart, hrefEnd),
},
ref: { text: reference, range: refRange },
href: target,
};
}
}
}
interface MdDocumentLinks {
readonly links: readonly MdLink[];
readonly definitions: LinkDefinitionSet;
}
/**
* Stateful object which provides links for markdown files the workspace.
*/
export class MdLinkProvider extends Disposable {
private readonly _linkCache: MdDocumentInfoCache<MdDocumentLinks>;
private readonly linkComputer: MdLinkComputer;
constructor(
tokenizer: IMdParser,
workspace: IMdWorkspace,
logger: ILogger,
) {
super();
this.linkComputer = new MdLinkComputer(tokenizer);
this._linkCache = this._register(new MdDocumentInfoCache(workspace, async doc => {
logger.verbose('LinkProvider', `compute - ${doc.uri}`);
const links = await this.linkComputer.getAllLinks(doc, noopToken);
return {
links,
definitions: new LinkDefinitionSet(links),
};
}));
}
public async getLinks(document: ITextDocument): Promise<MdDocumentLinks> {
return this._linkCache.getForDocument(document);
}
}
export class LinkDefinitionSet implements Iterable<[string, MdLinkDefinition]> {
private readonly _map = new Map<string, MdLinkDefinition>();
constructor(links: Iterable<MdLink>) {
for (const link of links) {
if (link.kind === 'definition') {
this._map.set(link.ref.text, link);
}
}
}
public [Symbol.iterator](): Iterator<[string, MdLinkDefinition]> {
return this._map.entries();
}
public lookup(ref: string): MdLinkDefinition | undefined {
return this._map.get(ref);
}
}

View File

@@ -1,329 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import * as uri from 'vscode-uri';
import { ILogger } from '../logging';
import { IMdParser } from '../markdownEngine';
import { MdTableOfContentsProvider, TocEntry } from '../tableOfContents';
import { ITextDocument } from '../types/textDocument';
import { noopToken } from '../util/cancellation';
import { Disposable } from '../util/dispose';
import { looksLikeMarkdownPath } from '../util/file';
import { MdWorkspaceInfoCache } from '../util/workspaceCache';
import { IMdWorkspace } from '../workspace';
import { InternalHref, MdLink, MdLinkComputer } from './documentLinks';
/**
* A link in a markdown file.
*/
export interface MdLinkReference {
readonly kind: 'link';
readonly isTriggerLocation: boolean;
readonly isDefinition: boolean;
readonly location: vscode.Location;
readonly link: MdLink;
}
/**
* A header in a markdown file.
*/
export interface MdHeaderReference {
readonly kind: 'header';
readonly isTriggerLocation: boolean;
readonly isDefinition: boolean;
/**
* The range of the header.
*
* In `# a b c #` this would be the range of `# a b c #`
*/
readonly location: vscode.Location;
/**
* The text of the header.
*
* In `# a b c #` this would be `a b c`
*/
readonly headerText: string;
/**
* The range of the header text itself.
*
* In `# a b c #` this would be the range of `a b c`
*/
readonly headerTextLocation: vscode.Location;
}
export type MdReference = MdLinkReference | MdHeaderReference;
/**
* Stateful object that computes references for markdown files.
*/
export class MdReferencesProvider extends Disposable {
private readonly _linkCache: MdWorkspaceInfoCache<readonly MdLink[]>;
public constructor(
private readonly parser: IMdParser,
private readonly workspace: IMdWorkspace,
private readonly tocProvider: MdTableOfContentsProvider,
private readonly logger: ILogger,
) {
super();
const linkComputer = new MdLinkComputer(parser);
this._linkCache = this._register(new MdWorkspaceInfoCache(workspace, doc => linkComputer.getAllLinks(doc, noopToken)));
}
public async getReferencesAtPosition(document: ITextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<MdReference[]> {
this.logger.verbose('ReferencesProvider', `getReferencesAtPosition: ${document.uri}`);
const toc = await this.tocProvider.getForDocument(document);
if (token.isCancellationRequested) {
return [];
}
const header = toc.entries.find(entry => entry.line === position.line);
if (header) {
return this.getReferencesToHeader(document, header);
} else {
return this.getReferencesToLinkAtPosition(document, position, token);
}
}
public async getReferencesToFileInWorkspace(resource: vscode.Uri, token: vscode.CancellationToken): Promise<MdReference[]> {
this.logger.verbose('ReferencesProvider', `getAllReferencesToFileInWorkspace: ${resource}`);
const allLinksInWorkspace = (await this._linkCache.values()).flat();
if (token.isCancellationRequested) {
return [];
}
return Array.from(this.findLinksToFile(resource, allLinksInWorkspace, undefined));
}
public async getReferencesToFileInDocs(resource: vscode.Uri, otherDocs: readonly ITextDocument[], token: vscode.CancellationToken): Promise<MdReference[]> {
this.logger.verbose('ReferencesProvider', `getAllReferencesToFileInFiles: ${resource}`);
const links = (await this._linkCache.getForDocs(otherDocs)).flat();
if (token.isCancellationRequested) {
return [];
}
return Array.from(this.findLinksToFile(resource, links, undefined));
}
private async getReferencesToHeader(document: ITextDocument, header: TocEntry): Promise<MdReference[]> {
const links = (await this._linkCache.values()).flat();
const references: MdReference[] = [];
references.push({
kind: 'header',
isTriggerLocation: true,
isDefinition: true,
location: header.headerLocation,
headerText: header.text,
headerTextLocation: header.headerTextLocation
});
for (const link of links) {
if (link.href.kind === 'internal'
&& this.looksLikeLinkToDoc(link.href, document.uri)
&& this.parser.slugifier.fromHeading(link.href.fragment).value === header.slug.value
) {
references.push({
kind: 'link',
isTriggerLocation: false,
isDefinition: false,
link,
location: new vscode.Location(link.source.resource, link.source.hrefRange),
});
}
}
return references;
}
private async getReferencesToLinkAtPosition(document: ITextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<MdReference[]> {
const docLinks = (await this._linkCache.getForDocs([document]))[0];
for (const link of docLinks) {
if (link.kind === 'definition') {
// We could be in either the ref name or the definition
if (link.ref.range.contains(position)) {
return Array.from(this.getReferencesToLinkReference(docLinks, link.ref.text, { resource: document.uri, range: link.ref.range }));
} else if (link.source.hrefRange.contains(position)) {
return this.getReferencesToLink(link, position, token);
}
} else {
if (link.source.hrefRange.contains(position)) {
return this.getReferencesToLink(link, position, token);
}
}
}
return [];
}
private async getReferencesToLink(sourceLink: MdLink, triggerPosition: vscode.Position, token: vscode.CancellationToken): Promise<MdReference[]> {
const allLinksInWorkspace = (await this._linkCache.values()).flat();
if (token.isCancellationRequested) {
return [];
}
if (sourceLink.href.kind === 'reference') {
return Array.from(this.getReferencesToLinkReference(allLinksInWorkspace, sourceLink.href.ref, { resource: sourceLink.source.resource, range: sourceLink.source.hrefRange }));
}
if (sourceLink.href.kind === 'external') {
const references: MdReference[] = [];
for (const link of allLinksInWorkspace) {
if (link.href.kind === 'external' && link.href.uri.toString() === sourceLink.href.uri.toString()) {
const isTriggerLocation = sourceLink.source.resource.fsPath === link.source.resource.fsPath && sourceLink.source.hrefRange.isEqual(link.source.hrefRange);
references.push({
kind: 'link',
isTriggerLocation,
isDefinition: false,
link,
location: new vscode.Location(link.source.resource, link.source.hrefRange),
});
}
}
return references;
}
const resolvedResource = await tryResolveLinkPath(sourceLink.href.path, this.workspace);
if (token.isCancellationRequested) {
return [];
}
const references: MdReference[] = [];
if (resolvedResource && this.isMarkdownPath(resolvedResource) && sourceLink.href.fragment && sourceLink.source.fragmentRange?.contains(triggerPosition)) {
const toc = await this.tocProvider.get(resolvedResource);
const entry = toc.lookup(sourceLink.href.fragment);
if (entry) {
references.push({
kind: 'header',
isTriggerLocation: false,
isDefinition: true,
location: entry.headerLocation,
headerText: entry.text,
headerTextLocation: entry.headerTextLocation
});
}
for (const link of allLinksInWorkspace) {
if (link.href.kind !== 'internal' || !this.looksLikeLinkToDoc(link.href, resolvedResource)) {
continue;
}
if (this.parser.slugifier.fromHeading(link.href.fragment).equals(this.parser.slugifier.fromHeading(sourceLink.href.fragment))) {
const isTriggerLocation = sourceLink.source.resource.fsPath === link.source.resource.fsPath && sourceLink.source.hrefRange.isEqual(link.source.hrefRange);
references.push({
kind: 'link',
isTriggerLocation,
isDefinition: false,
link,
location: new vscode.Location(link.source.resource, link.source.hrefRange),
});
}
}
} else { // Triggered on a link without a fragment so we only require matching the file and ignore fragments
references.push(...this.findLinksToFile(resolvedResource ?? sourceLink.href.path, allLinksInWorkspace, sourceLink));
}
return references;
}
private isMarkdownPath(resolvedHrefPath: vscode.Uri) {
return this.workspace.hasMarkdownDocument(resolvedHrefPath) || looksLikeMarkdownPath(resolvedHrefPath);
}
private looksLikeLinkToDoc(href: InternalHref, targetDoc: vscode.Uri) {
return href.path.fsPath === targetDoc.fsPath
|| uri.Utils.extname(href.path) === '' && href.path.with({ path: href.path.path + '.md' }).fsPath === targetDoc.fsPath;
}
private *findLinksToFile(resource: vscode.Uri, links: readonly MdLink[], sourceLink: MdLink | undefined): Iterable<MdReference> {
for (const link of links) {
if (link.href.kind !== 'internal' || !this.looksLikeLinkToDoc(link.href, resource)) {
continue;
}
// Exclude cases where the file is implicitly referencing itself
if (link.source.hrefText.startsWith('#') && link.source.resource.fsPath === resource.fsPath) {
continue;
}
const isTriggerLocation = !!sourceLink && sourceLink.source.resource.fsPath === link.source.resource.fsPath && sourceLink.source.hrefRange.isEqual(link.source.hrefRange);
const pathRange = this.getPathRange(link);
yield {
kind: 'link',
isTriggerLocation,
isDefinition: false,
link,
location: new vscode.Location(link.source.resource, pathRange),
};
}
}
private *getReferencesToLinkReference(allLinks: Iterable<MdLink>, refToFind: string, from: { resource: vscode.Uri; range: vscode.Range }): Iterable<MdReference> {
for (const link of allLinks) {
let ref: string;
if (link.kind === 'definition') {
ref = link.ref.text;
} else if (link.href.kind === 'reference') {
ref = link.href.ref;
} else {
continue;
}
if (ref === refToFind && link.source.resource.fsPath === from.resource.fsPath) {
const isTriggerLocation = from.resource.fsPath === link.source.resource.fsPath && (
(link.href.kind === 'reference' && from.range.isEqual(link.source.hrefRange)) || (link.kind === 'definition' && from.range.isEqual(link.ref.range)));
const pathRange = this.getPathRange(link);
yield {
kind: 'link',
isTriggerLocation,
isDefinition: link.kind === 'definition',
link,
location: new vscode.Location(from.resource, pathRange),
};
}
}
}
/**
* Get just the range of the file path, dropping the fragment
*/
private getPathRange(link: MdLink): vscode.Range {
return link.source.fragmentRange
? link.source.hrefRange.with(undefined, link.source.fragmentRange.start.translate(0, -1))
: link.source.hrefRange;
}
}
export async function tryResolveLinkPath(originalUri: vscode.Uri, workspace: IMdWorkspace): Promise<vscode.Uri | undefined> {
if (await workspace.pathExists(originalUri)) {
return originalUri;
}
// We don't think the file exists. If it doesn't already have an extension, try tacking on a `.md` and using that instead
if (uri.Utils.extname(originalUri) === '') {
const dotMdResource = originalUri.with({ path: originalUri.path + '.md' });
if (await workspace.pathExists(dotMdResource)) {
return dotMdResource;
}
}
return undefined;
}