mirror of
https://github.com/Microsoft/vscode
synced 2024-09-13 21:55:38 +00:00
Add diagnostics for markdown links (#148578)
* 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
This commit is contained in:
parent
2108837fc1
commit
eba8ef0547
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
|
|
|
@ -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<void>;
|
||||
|
||||
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<void>());
|
||||
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<boolean>('experimental.validate.enabled', false),
|
||||
validateReferences: config.get<DiagnosticLevel>('experimental.validate.referenceLinks', DiagnosticLevel.ignore),
|
||||
validateOwnHeaders: config.get<DiagnosticLevel>('experimental.validate.headerLinks', DiagnosticLevel.ignore),
|
||||
validateFilePaths: config.get<DiagnosticLevel>('experimental.validate.fileLinks', DiagnosticLevel.ignore),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DiagnosticManager extends Disposable {
|
||||
|
||||
private readonly collection: vscode.DiagnosticCollection;
|
||||
|
||||
private readonly pendingDiagnostics = new Set<vscode.Uri>();
|
||||
private readonly diagnosticDelayer: Delayer<void>;
|
||||
|
||||
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<string, vscode.Uri>();
|
||||
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<void> {
|
||||
const diagnostics = await this.getDiagnostics(doc, noopToken);
|
||||
this.collection.set(doc.uri, diagnostics);
|
||||
}
|
||||
|
||||
public async getDiagnostics(doc: SkinnyTextDocument, token: vscode.CancellationToken): Promise<vscode.Diagnostic[]> {
|
||||
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<vscode.Diagnostic[]> {
|
||||
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<vscode.Diagnostic[]> {
|
||||
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<vscode.Diagnostic> {
|
||||
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<vscode.Diagnostic[]> {
|
||||
const severity = toSeverity(options.validateFilePaths);
|
||||
if (typeof severity === 'undefined') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const tocs = new Map<string, TableOfContents>();
|
||||
|
||||
// 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);
|
||||
}
|
|
@ -222,7 +222,7 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider {
|
|||
document: SkinnyTextDocument,
|
||||
token: vscode.CancellationToken
|
||||
): Promise<vscode.DocumentLink[]> {
|
||||
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<MdLink[]> {
|
||||
public async getAllLinks(document: SkinnyTextDocument, token: vscode.CancellationToken): Promise<MdLink[]> {
|
||||
const codeInDocument = await findCode(document, this.engine);
|
||||
if (token.isCancellationRequested) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from([
|
||||
...this.getInlineLinks(document, codeInDocument),
|
||||
...this.getReferenceLinks(document, codeInDocument),
|
||||
|
|
|
@ -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<vscode.Location[] | undefined> {
|
||||
|
@ -128,7 +129,7 @@ export class MdReferencesProvider extends Disposable implements vscode.Reference
|
|||
}
|
||||
|
||||
private async getReferencesToLinkAtPosition(document: SkinnyTextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise<MdReference[]> {
|
||||
const docLinks = await this.linkProvider.getAllLinks(document);
|
||||
const docLinks = await this.linkProvider.getAllLinks(document, token);
|
||||
|
||||
for (const link of docLinks) {
|
||||
if (link.kind === 'definition') {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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<void>();
|
||||
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);
|
||||
});
|
||||
});
|
|
@ -25,7 +25,7 @@ export class InMemoryWorkspaceMarkdownDocuments implements MdWorkspaceContents {
|
|||
return this._documents.get(this.getKey(resource));
|
||||
}
|
||||
|
||||
public async fileExists(resource: vscode.Uri): Promise<boolean> {
|
||||
public async pathExists(resource: vscode.Uri): Promise<boolean> {
|
||||
return this._documents.has(this.getKey(resource));
|
||||
}
|
||||
|
||||
|
|
72
extensions/markdown-language-features/src/util/async.ts
Normal file
72
extensions/markdown-language-features/src/util/async.ts
Normal file
|
@ -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> {
|
||||
(): T;
|
||||
}
|
||||
|
||||
export class Delayer<T> {
|
||||
|
||||
public defaultDelay: number;
|
||||
private timeout: any; // Timer
|
||||
private completionPromise: Promise<T | null> | null;
|
||||
private onSuccess: ((value: T | PromiseLike<T> | undefined) => void) | null;
|
||||
private task: ITask<T> | null;
|
||||
|
||||
constructor(defaultDelay: number) {
|
||||
this.defaultDelay = defaultDelay;
|
||||
this.timeout = null;
|
||||
this.completionPromise = null;
|
||||
this.onSuccess = null;
|
||||
this.task = null;
|
||||
}
|
||||
|
||||
public trigger(task: ITask<T>, delay: number = this.defaultDelay): Promise<T | null> {
|
||||
this.task = task;
|
||||
if (delay >= 0) {
|
||||
this.cancelTimeout();
|
||||
}
|
||||
|
||||
if (!this.completionPromise) {
|
||||
this.completionPromise = new Promise<T | undefined>((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) };
|
||||
}
|
||||
}
|
|
@ -42,7 +42,7 @@ export interface MdWorkspaceContents {
|
|||
|
||||
getMarkdownDocument(resource: vscode.Uri): Promise<SkinnyTextDocument | undefined>;
|
||||
|
||||
fileExists(resource: vscode.Uri): Promise<boolean>;
|
||||
pathExists(resource: vscode.Uri): Promise<boolean>;
|
||||
|
||||
readonly onDidChangeMarkdownDocument: vscode.Event<SkinnyTextDocument>;
|
||||
readonly onDidCreateMarkdownDocument: vscode.Event<SkinnyTextDocument>;
|
||||
|
@ -159,13 +159,13 @@ export class VsCodeMdWorkspaceContents extends Disposable implements MdWorkspace
|
|||
}
|
||||
}
|
||||
|
||||
public async fileExists(target: vscode.Uri): Promise<boolean> {
|
||||
public async pathExists(target: vscode.Uri): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue