From eba8ef05479fa5e22c7c58cb7db59522d8a65c12 Mon Sep 17 00:00:00 2001 From: Matt Bierner Date: Mon, 2 May 2022 16:06:00 -0700 Subject: [PATCH] Add diagnostics for markdown links (#148578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial work on md link diagnostics * Adding settings to enable/disable validation * Add delay for recomputing diagnostics * 💄 * Split test on diagnostics compute vs management * Validate on file open * Remove dianostics on file close * Allow paths to folders * Add validation configuration option --- .../markdown-language-features/package.json | 39 +++ .../package.nls.json | 4 + .../src/extension.ts | 2 + .../src/languageFeatures/diagnostics.ts | 298 ++++++++++++++++++ .../languageFeatures/documentLinkProvider.ts | 8 +- .../src/languageFeatures/references.ts | 5 +- .../src/languageFeatures/rename.ts | 2 +- .../src/test/diagnostic.test.ts | 160 ++++++++++ .../src/test/inMemoryWorkspace.ts | 2 +- .../src/util/async.ts | 72 +++++ .../src/workspaceContents.ts | 6 +- 11 files changed, 589 insertions(+), 9 deletions(-) create mode 100644 extensions/markdown-language-features/src/languageFeatures/diagnostics.ts create mode 100644 extensions/markdown-language-features/src/test/diagnostic.test.ts create mode 100644 extensions/markdown-language-features/src/util/async.ts diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 609ea51e76f..9e7c97c613f 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -413,6 +413,45 @@ "default": true, "markdownDescription": "%configuration.markdown.editor.drop.enabled%", "scope": "resource" + }, + "markdown.experimental.validate.enabled": { + "type": "boolean", + "scope": "resource", + "description": "%configuration.markdown.experimental.validate.enabled.description%", + "default": false + }, + "markdown.experimental.validate.referenceLinks": { + "type": "string", + "scope": "resource", + "description": "%configuration.markdown.experimental.validate.referenceLinks.description%", + "default": "warning", + "enum": [ + "ignore", + "warning", + "error" + ] + }, + "markdown.experimental.validate.headerLinks": { + "type": "string", + "scope": "resource", + "description": "%configuration.markdown.experimental.validate.headerLinks.description%", + "default": "warning", + "enum": [ + "ignore", + "warning", + "error" + ] + }, + "markdown.experimental.validate.fileLinks": { + "type": "string", + "scope": "resource", + "description": "%configuration.markdown.experimental.validate.fileLinks.description%", + "default": "warning", + "enum": [ + "ignore", + "warning", + "error" + ] } } }, diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index ab5e295770b..2d9ba258003 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -29,5 +29,9 @@ "configuration.markdown.links.openLocation.beside": "Open links beside the active editor.", "configuration.markdown.suggest.paths.enabled.description": "Enable/disable path suggestions for markdown links", "configuration.markdown.editor.drop.enabled": "Enable/disable dropping into the markdown editor to insert shift. Requires enabling `#workbenck.experimental.editor.dropIntoEditor.enabled#`.", + "configuration.markdown.experimental.validate.enabled.description": "Enable/disable all error reporting in Markdown files.", + "configuration.markdown.experimental.validate.referenceLinks.description": "Validate reference links in Markdown files, e.g. `[link][ref]`. Requires enabling `#markdown.experimental.validate.enabled#`.", + "configuration.markdown.experimental.validate.headerLinks.description": "Validate links to headers in Markdown files, e.g. `[link](#header)`. Requires enabling `#markdown.experimental.validate.enabled#`.", + "configuration.markdown.experimental.validate.fileLinks.description": "Validate links to other files in Markdown files, e.g. `[link](/path/to/file.md)`. This checks that the target files exists. Requires enabling `#markdown.experimental.validate.enabled#`.", "workspaceTrust": "Required for loading styles configured in the workspace." } diff --git a/extensions/markdown-language-features/src/extension.ts b/extensions/markdown-language-features/src/extension.ts index 0dfee2584fb..4938c1004bf 100644 --- a/extensions/markdown-language-features/src/extension.ts +++ b/extensions/markdown-language-features/src/extension.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { CommandManager } from './commandManager'; import * as commands from './commands/index'; +import { register as registerDiagnostics } from './languageFeatures/diagnostics'; import { MdDefinitionProvider } from './languageFeatures/definitionProvider'; import { MdLinkProvider } from './languageFeatures/documentLinkProvider'; import { MdDocumentSymbolProvider } from './languageFeatures/documentSymbolProvider'; @@ -75,6 +76,7 @@ function registerMarkdownLanguageFeatures( vscode.languages.registerRenameProvider(selector, new MdRenameProvider(referencesProvider, workspaceContents, githubSlugifier)), vscode.languages.registerDefinitionProvider(selector, new MdDefinitionProvider(referencesProvider)), MdPathCompletionProvider.register(selector, engine, linkProvider), + registerDiagnostics(engine, workspaceContents, linkProvider), registerDropIntoEditor(selector), registerFindFileReferences(commandManager, referencesProvider), ); diff --git a/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts b/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts new file mode 100644 index 00000000000..ef2d6733712 --- /dev/null +++ b/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts @@ -0,0 +1,298 @@ +/*--------------------------------------------------------------------------------------------- + * 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 nls from 'vscode-nls'; +import { MarkdownEngine } from '../markdownEngine'; +import { TableOfContents } from '../tableOfContents'; +import { noopToken } from '../test/util'; +import { Delayer } from '../util/async'; +import { Disposable } from '../util/dispose'; +import { isMarkdownFile } from '../util/file'; +import { MdWorkspaceContents, SkinnyTextDocument } from '../workspaceContents'; +import { InternalHref, LinkDefinitionSet, MdLink, MdLinkProvider } from './documentLinkProvider'; +import { tryFindMdDocumentForLink } from './references'; + +const localize = nls.loadMessageBundle(); + +export interface DiagnosticConfiguration { + /** + * Fired when the configuration changes. + */ + readonly onDidChange: vscode.Event; + + getOptions(resource: vscode.Uri): DiagnosticOptions; +} + +export enum DiagnosticLevel { + ignore = 'ignore', + warning = 'warning', + error = 'error', +} + +export interface DiagnosticOptions { + readonly enabled: boolean; + readonly validateReferences: DiagnosticLevel; + readonly validateOwnHeaders: DiagnosticLevel; + readonly validateFilePaths: DiagnosticLevel; +} + +function toSeverity(level: DiagnosticLevel): vscode.DiagnosticSeverity | undefined { + switch (level) { + case DiagnosticLevel.error: return vscode.DiagnosticSeverity.Error; + case DiagnosticLevel.warning: return vscode.DiagnosticSeverity.Warning; + case DiagnosticLevel.ignore: return undefined; + } +} + +class VSCodeDiagnosticConfiguration extends Disposable implements DiagnosticConfiguration { + + private readonly _onDidChange = this._register(new vscode.EventEmitter()); + public readonly onDidChange = this._onDidChange.event; + + constructor() { + super(); + + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('markdown.experimental.validate.enabled')) { + this._onDidChange.fire(); + } + })); + } + + public getOptions(resource: vscode.Uri): DiagnosticOptions { + const config = vscode.workspace.getConfiguration('markdown', resource); + return { + enabled: config.get('experimental.validate.enabled', false), + validateReferences: config.get('experimental.validate.referenceLinks', DiagnosticLevel.ignore), + validateOwnHeaders: config.get('experimental.validate.headerLinks', DiagnosticLevel.ignore), + validateFilePaths: config.get('experimental.validate.fileLinks', DiagnosticLevel.ignore), + }; + } +} + +export class DiagnosticManager extends Disposable { + + private readonly collection: vscode.DiagnosticCollection; + + private readonly pendingDiagnostics = new Set(); + private readonly diagnosticDelayer: Delayer; + + constructor( + private readonly computer: DiagnosticComputer, + private readonly configuration: DiagnosticConfiguration, + ) { + super(); + + this.diagnosticDelayer = new Delayer(300); + + this.collection = this._register(vscode.languages.createDiagnosticCollection('markdown')); + + this._register(this.configuration.onDidChange(() => { + this.rebuild(); + })); + + const onDocUpdated = (doc: vscode.TextDocument) => { + if (isMarkdownFile(doc)) { + this.pendingDiagnostics.add(doc.uri); + this.diagnosticDelayer.trigger(() => this.recomputePendingDiagnostics()); + } + }; + + this._register(vscode.workspace.onDidOpenTextDocument(doc => { + onDocUpdated(doc); + })); + + this._register(vscode.workspace.onDidChangeTextDocument(e => { + onDocUpdated(e.document); + })); + + this._register(vscode.workspace.onDidCloseTextDocument(doc => { + this.pendingDiagnostics.delete(doc.uri); + this.collection.delete(doc.uri); + })); + + this.rebuild(); + } + + private recomputePendingDiagnostics(): void { + const pending = [...this.pendingDiagnostics]; + this.pendingDiagnostics.clear(); + + for (const resource of pending) { + const doc = vscode.workspace.textDocuments.find(doc => doc.uri.fsPath === resource.fsPath); + if (doc) { + this.update(doc); + } + } + } + + private async rebuild() { + this.collection.clear(); + + const allOpenedTabResources = this.getAllTabResources(); + await Promise.all( + vscode.workspace.textDocuments + .filter(doc => allOpenedTabResources.has(doc.uri.toString()) && isMarkdownFile(doc)) + .map(doc => this.update(doc))); + } + + private getAllTabResources() { + const openedTabDocs = new Map(); + 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.toString(), tab.input.uri); + } + } + } + return openedTabDocs; + } + + private async update(doc: vscode.TextDocument): Promise { + const diagnostics = await this.getDiagnostics(doc, noopToken); + this.collection.set(doc.uri, diagnostics); + } + + public async getDiagnostics(doc: SkinnyTextDocument, token: vscode.CancellationToken): Promise { + const config = this.configuration.getOptions(doc.uri); + if (!config.enabled) { + return []; + } + return this.computer.getDiagnostics(doc, config, token); + } +} + +export class DiagnosticComputer { + + constructor( + private readonly engine: MarkdownEngine, + private readonly workspaceContents: MdWorkspaceContents, + private readonly linkProvider: MdLinkProvider, + ) { } + + public async getDiagnostics(doc: SkinnyTextDocument, options: DiagnosticOptions, token: vscode.CancellationToken): Promise { + const links = await this.linkProvider.getAllLinks(doc, token); + if (token.isCancellationRequested) { + return []; + } + + return (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 { + const severity = toSeverity(options.validateOwnHeaders); + if (typeof severity === 'undefined') { + return []; + } + + const toc = await TableOfContents.create(this.engine, doc); + if (token.isCancellationRequested) { + return []; + } + + const diagnostics: vscode.Diagnostic[] = []; + for (const link of links) { + if (link.href.kind === 'internal' + && link.href.path.toString() === doc.uri.toString() + && link.href.fragment + && !toc.lookup(link.href.fragment) + ) { + diagnostics.push(new vscode.Diagnostic( + link.source.hrefRange, + localize('invalidHeaderLink', 'No header found: \'{0}\'', link.href.fragment), + severity)); + } + } + + return diagnostics; + } + + private *validateReferenceLinks(options: DiagnosticOptions, links: readonly MdLink[]): Iterable { + const severity = toSeverity(options.validateReferences); + if (typeof severity === 'undefined') { + return []; + } + + const definitionSet = new LinkDefinitionSet(links); + for (const link of links) { + if (link.href.kind === 'reference' && !definitionSet.lookup(link.href.ref)) { + yield new vscode.Diagnostic( + link.source.hrefRange, + localize('invalidReferenceLink', 'No link reference found: \'{0}\'', link.href.ref), + severity); + } + } + } + + private async validateFileLinks(doc: SkinnyTextDocument, options: DiagnosticOptions, links: readonly MdLink[], token: vscode.CancellationToken): Promise { + const severity = toSeverity(options.validateFilePaths); + if (typeof severity === 'undefined') { + return []; + } + + const tocs = new Map(); + + // TODO: cache links so we don't recompute duplicate hrefs + // TODO: parallelize + + const diagnostics: vscode.Diagnostic[] = []; + for (const link of links) { + if (token.isCancellationRequested) { + return []; + } + + if (link.href.kind !== 'internal') { + continue; + } + + const hrefDoc = await tryFindMdDocumentForLink(link.href, this.workspaceContents); + if (hrefDoc && hrefDoc.uri.toString() === doc.uri.toString()) { + continue; + } + + if (!hrefDoc && !await this.workspaceContents.pathExists(link.href.path)) { + diagnostics.push( + new vscode.Diagnostic( + link.source.hrefRange, + localize('invalidPathLink', 'File does not exist at path: {0}', (link.href as InternalHref).path.toString(true)), + severity)); + } else if (hrefDoc) { + if (link.href.fragment) { + // validate fragment looks valid + let hrefDocToc = tocs.get(link.href.path.toString()); + if (!hrefDocToc) { + hrefDocToc = await TableOfContents.create(this.engine, hrefDoc); + tocs.set(link.href.path.toString(), hrefDocToc); + } + + if (!hrefDocToc.lookup(link.href.fragment)) { + diagnostics.push( + new vscode.Diagnostic( + link.source.hrefRange, + localize('invalidLinkToHeaderInOtherFile', 'Header does not exist in file: {0}', (link.href as InternalHref).path.fragment), + severity)); + } + } + } + } + + return diagnostics; + } +} + +export function register( + engine: MarkdownEngine, + workspaceContents: MdWorkspaceContents, + linkProvider: MdLinkProvider, +): vscode.Disposable { + const configuration = new VSCodeDiagnosticConfiguration(); + const manager = new DiagnosticManager(new DiagnosticComputer(engine, workspaceContents, linkProvider), configuration); + return vscode.Disposable.from(configuration, manager); +} diff --git a/extensions/markdown-language-features/src/languageFeatures/documentLinkProvider.ts b/extensions/markdown-language-features/src/languageFeatures/documentLinkProvider.ts index af44be59720..5ee7d1d4b60 100644 --- a/extensions/markdown-language-features/src/languageFeatures/documentLinkProvider.ts +++ b/extensions/markdown-language-features/src/languageFeatures/documentLinkProvider.ts @@ -222,7 +222,7 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider { document: SkinnyTextDocument, token: vscode.CancellationToken ): Promise { - const allLinks = await this.getAllLinks(document); + const allLinks = await this.getAllLinks(document, token); if (token.isCancellationRequested) { return []; } @@ -256,8 +256,12 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider { } } - public async getAllLinks(document: SkinnyTextDocument): Promise { + public async getAllLinks(document: SkinnyTextDocument, token: vscode.CancellationToken): Promise { const codeInDocument = await findCode(document, this.engine); + if (token.isCancellationRequested) { + return []; + } + return Array.from([ ...this.getInlineLinks(document, codeInDocument), ...this.getReferenceLinks(document, codeInDocument), diff --git a/extensions/markdown-language-features/src/languageFeatures/references.ts b/extensions/markdown-language-features/src/languageFeatures/references.ts index b60bb447c7f..b62de0b19ac 100644 --- a/extensions/markdown-language-features/src/languageFeatures/references.ts +++ b/extensions/markdown-language-features/src/languageFeatures/references.ts @@ -7,6 +7,7 @@ import * as uri from 'vscode-uri'; import { MarkdownEngine } from '../markdownEngine'; import { Slugifier } from '../slugify'; import { TableOfContents, TocEntry } from '../tableOfContents'; +import { noopToken } from '../test/util'; import { Disposable } from '../util/dispose'; import { MdWorkspaceContents, SkinnyTextDocument } from '../workspaceContents'; import { InternalHref, MdLink, MdLinkProvider } from './documentLinkProvider'; @@ -70,7 +71,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference ) { super(); - this._linkCache = this._register(new MdWorkspaceCache(workspaceContents, doc => linkProvider.getAllLinks(doc))); + this._linkCache = this._register(new MdWorkspaceCache(workspaceContents, doc => linkProvider.getAllLinks(doc, noopToken))); } async provideReferences(document: SkinnyTextDocument, position: vscode.Position, context: vscode.ReferenceContext, token: vscode.CancellationToken): Promise { @@ -128,7 +129,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference } private async getReferencesToLinkAtPosition(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise { - const docLinks = await this.linkProvider.getAllLinks(document); + const docLinks = await this.linkProvider.getAllLinks(document, token); for (const link of docLinks) { if (link.kind === 'definition') { diff --git a/extensions/markdown-language-features/src/languageFeatures/rename.ts b/extensions/markdown-language-features/src/languageFeatures/rename.ts index 3d87d88755d..db06b7eeb9c 100644 --- a/extensions/markdown-language-features/src/languageFeatures/rename.ts +++ b/extensions/markdown-language-features/src/languageFeatures/rename.ts @@ -169,7 +169,7 @@ export class MdRenameProvider extends Disposable implements vscode.RenameProvide } // First rename the file - if (await this.workspaceContents.fileExists(targetUri)) { + if (await this.workspaceContents.pathExists(targetUri)) { fileRenames.push({ from: targetUri, to: resolvedNewFilePath }); edit.renameFile(targetUri, resolvedNewFilePath); } diff --git a/extensions/markdown-language-features/src/test/diagnostic.test.ts b/extensions/markdown-language-features/src/test/diagnostic.test.ts new file mode 100644 index 00000000000..6f5556ad25f --- /dev/null +++ b/extensions/markdown-language-features/src/test/diagnostic.test.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import 'mocha'; +import { DiagnosticComputer, DiagnosticConfiguration, DiagnosticLevel, DiagnosticManager, DiagnosticOptions } from '../languageFeatures/diagnostics'; +import { MdLinkProvider } from '../languageFeatures/documentLinkProvider'; +import { InMemoryDocument } from '../util/inMemoryDocument'; +import { MdWorkspaceContents } from '../workspaceContents'; +import { createNewMarkdownEngine } from './engine'; +import { InMemoryWorkspaceMarkdownDocuments } from './inMemoryWorkspace'; +import { assertRangeEqual, joinLines, noopToken, workspacePath } from './util'; + + +function getComputedDiagnostics(doc: InMemoryDocument, workspaceContents: MdWorkspaceContents) { + 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); +} + +function createDiagnosticsManager(workspaceContents: MdWorkspaceContents, configuration = new MemoryDiagnosticConfiguration()) { + const engine = createNewMarkdownEngine(); + const linkProvider = new MdLinkProvider(engine); + return new DiagnosticManager(new DiagnosticComputer(engine, workspaceContents, linkProvider), configuration); +} + +class MemoryDiagnosticConfiguration implements DiagnosticConfiguration { + + private readonly _onDidChange = new vscode.EventEmitter(); + public readonly onDidChange = this._onDidChange.event; + + constructor( + private readonly enabled: boolean = true, + ) { } + + getOptions(_resource: vscode.Uri): DiagnosticOptions { + if (!this.enabled) { + return { + enabled: false, + validateFilePaths: DiagnosticLevel.ignore, + validateOwnHeaders: DiagnosticLevel.ignore, + validateReferences: DiagnosticLevel.ignore, + }; + } + return { + enabled: true, + validateFilePaths: DiagnosticLevel.warning, + validateOwnHeaders: DiagnosticLevel.warning, + validateReferences: DiagnosticLevel.warning, + }; + } +} + + +suite('markdown: Diagnostics', () => { + test('Should not return any diagnostics for empty document', async () => { + const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines( + `text`, + )); + + const diagnostics = await getComputedDiagnostics(doc, new InMemoryWorkspaceMarkdownDocuments([doc])); + assert.deepStrictEqual(diagnostics, []); + }); + + test('Should generate diagnostic for link to file that does not exist', async () => { + const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines( + `[bad](/no/such/file.md)`, + `[good](/doc.md)`, + `[good-ref]: /doc.md`, + `[bad-ref]: /no/such/file.md`, + )); + + const diagnostics = await getComputedDiagnostics(doc, new InMemoryWorkspaceMarkdownDocuments([doc])); + assert.deepStrictEqual(diagnostics.length, 2); + assertRangeEqual(new vscode.Range(0, 6, 0, 22), diagnostics[0].range); + assertRangeEqual(new vscode.Range(3, 11, 3, 27), diagnostics[1].range); + }); + + test('Should generate diagnostics for links to header that does not exist in current file', async () => { + const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines( + `[good](#good-header)`, + `# Good Header`, + `[bad](#no-such-header)`, + `[good](#good-header)`, + `[good-ref]: #good-header`, + `[bad-ref]: #no-such-header`, + )); + + const diagnostics = await getComputedDiagnostics(doc, new InMemoryWorkspaceMarkdownDocuments([doc])); + assert.deepStrictEqual(diagnostics.length, 2); + assertRangeEqual(new vscode.Range(2, 6, 2, 21), diagnostics[0].range); + assertRangeEqual(new vscode.Range(5, 11, 5, 26), diagnostics[1].range); + }); + + test('Should generate diagnostics for links to non-existent headers in other files', async () => { + const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines( + `# My header`, + `[good](#my-header)`, + `[good](/doc1.md#my-header)`, + `[good](doc1.md#my-header)`, + `[good](/doc2.md#other-header)`, + `[bad](/doc2.md#no-such-other-header)`, + )); + + const doc2 = new InMemoryDocument(workspacePath('doc2.md'), joinLines( + `# Other header`, + )); + + const diagnostics = await getComputedDiagnostics(doc1, new InMemoryWorkspaceMarkdownDocuments([doc1, doc2])); + assert.deepStrictEqual(diagnostics.length, 1); + assertRangeEqual(new vscode.Range(5, 6, 5, 35), diagnostics[0].range); + }); + + test('Should support links both with and without .md file extension', async () => { + const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines( + `# My header`, + `[good](#my-header)`, + `[good](/doc.md#my-header)`, + `[good](doc.md#my-header)`, + `[good](/doc#my-header)`, + `[good](doc#my-header)`, + )); + + const diagnostics = await getComputedDiagnostics(doc, new InMemoryWorkspaceMarkdownDocuments([doc])); + assert.deepStrictEqual(diagnostics.length, 0); + }); + + test('Should generate diagnostics for non-existent link reference', async () => { + const doc = new InMemoryDocument(workspacePath('doc.md'), joinLines( + `[good link][good]`, + `[bad link][no-such]`, + ``, + `[good]: http://example.com`, + )); + + const diagnostics = await getComputedDiagnostics(doc, new InMemoryWorkspaceMarkdownDocuments([doc])); + assert.deepStrictEqual(diagnostics.length, 1); + assertRangeEqual(new vscode.Range(1, 11, 1, 18), diagnostics[0].range); + }); + + test('Should not generate diagnostics when validate is disabled', async () => { + const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines( + `[text](#no-such-header)`, + `[text][no-such-ref]`, + )); + + const manager = createDiagnosticsManager(new InMemoryWorkspaceMarkdownDocuments([doc1]), new MemoryDiagnosticConfiguration(false)); + const diagnostics = await manager.getDiagnostics(doc1, noopToken); + assert.deepStrictEqual(diagnostics.length, 0); + }); +}); diff --git a/extensions/markdown-language-features/src/test/inMemoryWorkspace.ts b/extensions/markdown-language-features/src/test/inMemoryWorkspace.ts index 6d9bf584391..4924578701d 100644 --- a/extensions/markdown-language-features/src/test/inMemoryWorkspace.ts +++ b/extensions/markdown-language-features/src/test/inMemoryWorkspace.ts @@ -25,7 +25,7 @@ export class InMemoryWorkspaceMarkdownDocuments implements MdWorkspaceContents { return this._documents.get(this.getKey(resource)); } - public async fileExists(resource: vscode.Uri): Promise { + public async pathExists(resource: vscode.Uri): Promise { return this._documents.has(this.getKey(resource)); } diff --git a/extensions/markdown-language-features/src/util/async.ts b/extensions/markdown-language-features/src/util/async.ts new file mode 100644 index 00000000000..75ccf258f28 --- /dev/null +++ b/extensions/markdown-language-features/src/util/async.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vscode'; + +export interface ITask { + (): T; +} + +export class Delayer { + + public defaultDelay: number; + private timeout: any; // Timer + private completionPromise: Promise | null; + private onSuccess: ((value: T | PromiseLike | undefined) => void) | null; + private task: ITask | null; + + constructor(defaultDelay: number) { + this.defaultDelay = defaultDelay; + this.timeout = null; + this.completionPromise = null; + this.onSuccess = null; + this.task = null; + } + + public trigger(task: ITask, delay: number = this.defaultDelay): Promise { + this.task = task; + if (delay >= 0) { + this.cancelTimeout(); + } + + if (!this.completionPromise) { + this.completionPromise = new Promise((resolve) => { + this.onSuccess = resolve; + }).then(() => { + this.completionPromise = null; + this.onSuccess = null; + const result = this.task && this.task(); + this.task = null; + return result; + }); + } + + if (delay >= 0 || this.timeout === null) { + this.timeout = setTimeout(() => { + this.timeout = null; + this.onSuccess?.(undefined); + }, delay >= 0 ? delay : this.defaultDelay); + } + + return this.completionPromise; + } + + private cancelTimeout(): void { + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } + } +} + +export function setImmediate(callback: (...args: any[]) => void, ...args: any[]): Disposable { + if (global.setImmediate) { + const handle = global.setImmediate(callback, ...args); + return { dispose: () => global.clearImmediate(handle) }; + } else { + const handle = setTimeout(callback, 0, ...args); + return { dispose: () => clearTimeout(handle) }; + } +} diff --git a/extensions/markdown-language-features/src/workspaceContents.ts b/extensions/markdown-language-features/src/workspaceContents.ts index cd4801b01a3..19af8a87c6d 100644 --- a/extensions/markdown-language-features/src/workspaceContents.ts +++ b/extensions/markdown-language-features/src/workspaceContents.ts @@ -42,7 +42,7 @@ export interface MdWorkspaceContents { getMarkdownDocument(resource: vscode.Uri): Promise; - fileExists(resource: vscode.Uri): Promise; + pathExists(resource: vscode.Uri): Promise; readonly onDidChangeMarkdownDocument: vscode.Event; readonly onDidCreateMarkdownDocument: vscode.Event; @@ -159,13 +159,13 @@ export class VsCodeMdWorkspaceContents extends Disposable implements MdWorkspace } } - public async fileExists(target: vscode.Uri): Promise { + public async pathExists(target: vscode.Uri): Promise { let targetResourceStat: vscode.FileStat | undefined; try { targetResourceStat = await vscode.workspace.fs.stat(target); } catch { return false; } - return targetResourceStat.type === vscode.FileType.File; + return targetResourceStat.type === vscode.FileType.File || targetResourceStat.type === vscode.FileType.Directory; } }