Update markdown diagnostics when linked files change (#149672)

For #146303

This PR updates the markdown diagnostic reporter to watch linked to files. If one of these linked to files is created or deleted, we recompute the diagnostics for all markdown files that linked to it
This commit is contained in:
Matt Bierner 2022-05-16 17:30:39 -07:00 committed by GitHub
parent 77aebb7f5c
commit d71f6ec0d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 131 additions and 25 deletions

View file

@ -12,7 +12,7 @@ import { Disposable } from '../util/dispose';
import { isMarkdownFile } from '../util/file';
import { Limiter } from '../util/limiter';
import { MdWorkspaceContents, SkinnyTextDocument } from '../workspaceContents';
import { LinkDefinitionSet, MdLink, MdLinkProvider, MdLinkSource } from './documentLinkProvider';
import { InternalHref, LinkDefinitionSet, MdLink, MdLinkProvider, MdLinkSource } from './documentLinkProvider';
import { tryFindMdDocumentForLink } from './references';
const localize = nls.loadMessageBundle();
@ -118,6 +118,94 @@ class InflightDiagnosticRequests {
}
}
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 Map</* link path */ string, {
/**
* Watcher for this link path
*/
readonly watcher: vscode.Disposable;
/**
* List of documents that reference the link
*/
readonly documents: Map</* document resource as string */ string, /* 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.toString());
}
// Then create/update watchers for new document state
for (const path of linkedToResource) {
let entry = this._watchers.get(path.toString());
if (!entry) {
entry = {
watcher: this.startWatching(path),
documents: new Map(),
};
this._watchers.set(path.toString(), entry);
}
entry.documents.set(document.toString(), 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.toString());
if (entry) {
this._onDidChangeLinkedToFile.fire(entry.documents.values());
}
}
}
export class DiagnosticManager extends Disposable {
private readonly collection: vscode.DiagnosticCollection;
@ -126,6 +214,8 @@ export class DiagnosticManager extends Disposable {
private readonly pendingDiagnostics = new Set<vscode.Uri>();
private readonly inFlightDiagnostics = this._register(new InflightDiagnosticRequests());
private readonly linkWatcher = this._register(new LinkWatcher());
constructor(
private readonly computer: DiagnosticComputer,
private readonly configuration: DiagnosticConfiguration,
@ -148,10 +238,20 @@ export class DiagnosticManager extends Disposable {
this.triggerDiagnostics(e.document);
}));
this._register(vscode.workspace.onDidCloseTextDocument(doc => {
this.pendingDiagnostics.delete(doc.uri);
this.inFlightDiagnostics.cancel(doc.uri);
this.collection.delete(doc.uri);
this._register(vscode.workspace.onDidCloseTextDocument(({ uri }) => {
this.pendingDiagnostics.delete(uri);
this.inFlightDiagnostics.cancel(uri);
this.linkWatcher.deleteDocument(uri);
this.collection.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) {
this.triggerDiagnostics(doc);
}
}
}));
this.rebuild();
@ -162,12 +262,12 @@ export class DiagnosticManager extends Disposable {
this.pendingDiagnostics.clear();
}
public async getDiagnostics(doc: SkinnyTextDocument, token: vscode.CancellationToken): Promise<vscode.Diagnostic[]> {
public async recomputeDiagnosticState(doc: SkinnyTextDocument, token: vscode.CancellationToken): Promise<{ diagnostics: readonly vscode.Diagnostic[]; links: readonly MdLink[]; config: DiagnosticOptions }> {
const config = this.configuration.getOptions(doc.uri);
if (!config.enabled) {
return [];
return { diagnostics: [], links: [], config };
}
return this.computer.getDiagnostics(doc, config, token);
return { ...await this.computer.getDiagnostics(doc, config, token), config };
}
private async recomputePendingDiagnostics(): Promise<void> {
@ -178,8 +278,9 @@ export class DiagnosticManager extends Disposable {
const doc = vscode.workspace.textDocuments.find(doc => doc.uri.fsPath === resource.fsPath);
if (doc) {
this.inFlightDiagnostics.trigger(doc.uri, async (token) => {
const diagnostics = await this.getDiagnostics(doc, token);
this.collection.set(doc.uri, diagnostics);
const state = await this.recomputeDiagnosticState(doc, token);
this.linkWatcher.updateLinksForDocument(doc.uri, state.config.enabled && state.config.validateFilePaths ? state.links : []);
this.collection.set(doc.uri, state.diagnostics);
});
}
}
@ -269,17 +370,20 @@ export class DiagnosticComputer {
private readonly linkProvider: MdLinkProvider,
) { }
public async getDiagnostics(doc: SkinnyTextDocument, options: DiagnosticOptions, token: vscode.CancellationToken): Promise<vscode.Diagnostic[]> {
public async getDiagnostics(doc: SkinnyTextDocument, options: DiagnosticOptions, token: vscode.CancellationToken): Promise<{ readonly diagnostics: vscode.Diagnostic[]; readonly links: MdLink[] }> {
const links = await this.linkProvider.getAllLinks(doc, token);
if (token.isCancellationRequested) {
return [];
return { links, diagnostics: [] };
}
return (await Promise.all([
this.validateFileLinks(doc, options, links, token),
Array.from(this.validateReferenceLinks(options, links)),
this.validateOwnHeaderLinks(doc, options, links, token),
])).flat();
return {
links,
diagnostics: (await Promise.all([
this.validateFileLinks(doc, options, links, token),
Array.from(this.validateReferenceLinks(options, links)),
this.validateOwnHeaderLinks(doc, options, links, token),
])).flat()
};
}
private async validateOwnHeaderLinks(doc: SkinnyTextDocument, options: DiagnosticOptions, links: readonly MdLink[], token: vscode.CancellationToken): Promise<vscode.Diagnostic[]> {

View file

@ -16,16 +16,18 @@ import { InMemoryWorkspaceMarkdownDocuments } from './inMemoryWorkspace';
import { assertRangeEqual, joinLines, workspacePath } from './util';
function getComputedDiagnostics(doc: InMemoryDocument, workspaceContents: MdWorkspaceContents) {
async function getComputedDiagnostics(doc: InMemoryDocument, workspaceContents: MdWorkspaceContents): Promise<vscode.Diagnostic[]> {
const engine = createNewMarkdownEngine();
const linkProvider = new MdLinkProvider(engine);
const computer = new DiagnosticComputer(engine, workspaceContents, linkProvider);
return computer.getDiagnostics(doc, {
enabled: true,
validateFilePaths: DiagnosticLevel.warning,
validateOwnHeaders: DiagnosticLevel.warning,
validateReferences: DiagnosticLevel.warning,
}, noopToken);
return (
await computer.getDiagnostics(doc, {
enabled: true,
validateFilePaths: DiagnosticLevel.warning,
validateOwnHeaders: DiagnosticLevel.warning,
validateReferences: DiagnosticLevel.warning,
}, noopToken)
).diagnostics;
}
function createDiagnosticsManager(workspaceContents: MdWorkspaceContents, configuration = new MemoryDiagnosticConfiguration()) {
@ -155,7 +157,7 @@ suite('markdown: Diagnostics', () => {
));
const manager = createDiagnosticsManager(new InMemoryWorkspaceMarkdownDocuments([doc1]), new MemoryDiagnosticConfiguration(false));
const diagnostics = await manager.getDiagnostics(doc1, noopToken);
const { diagnostics } = await manager.recomputeDiagnosticState(doc1, noopToken);
assert.deepStrictEqual(diagnostics.length, 0);
});