From d71f6ec0d9de40bc322e2de76263b5fa8069c0d2 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 16 May 2022 17:30:39 -0700 Subject: [PATCH] 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 --- .../src/languageFeatures/diagnostics.ts | 138 +++++++++++++++--- .../src/test/diagnostic.test.ts | 18 ++- 2 files changed, 131 insertions(+), 25 deletions(-) diff --git a/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts b/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts index fd11cce6531..f53a83d670e 100644 --- a/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts +++ b/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts @@ -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>); + /** + * 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; + }>(); + + 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( + 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(); 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 { + 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 { @@ -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 { + 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 { diff --git a/extensions/markdown-language-features/src/test/diagnostic.test.ts b/extensions/markdown-language-features/src/test/diagnostic.test.ts index ce6357c65a6..a9f54978b7d 100644 --- a/extensions/markdown-language-features/src/test/diagnostic.test.ts +++ b/extensions/markdown-language-features/src/test/diagnostic.test.ts @@ -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 { 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); });