mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-19 16:18:58 +01:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user