Add skipPaths option for markdown link validation (#149859)

The new `markdown.experimental.validate.fileLinks.skipPaths` setting lets you specify a list of paths (as globs) that should not be validation

This is useful since markdown is used in a range of environments, and sometimes you may need to link to paths that don't exist on disk but will exist on deployment

A few other changes here:

- Adds a quick fix that adds paths to `skipPaths`
- Rename existing settings to use the `.enabled` prefix
This commit is contained in:
Matt Bierner 2022-05-18 14:37:08 -07:00 committed by GitHub
parent fd3a84fdfc
commit daf0d5e551
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 194 additions and 20 deletions

View file

@ -420,10 +420,10 @@
"description": "%configuration.markdown.experimental.validate.enabled.description%",
"default": false
},
"markdown.experimental.validate.referenceLinks": {
"markdown.experimental.validate.referenceLinks.enabled": {
"type": "string",
"scope": "resource",
"markdownDescription": "%configuration.markdown.experimental.validate.referenceLinks.description%",
"markdownDescription": "%configuration.markdown.experimental.validate.referenceLinks.enabled.description%",
"default": "warning",
"enum": [
"ignore",
@ -431,10 +431,10 @@
"error"
]
},
"markdown.experimental.validate.headerLinks": {
"markdown.experimental.validate.headerLinks.enabled": {
"type": "string",
"scope": "resource",
"markdownDescription": "%configuration.markdown.experimental.validate.headerLinks.description%",
"markdownDescription": "%configuration.markdown.experimental.validate.headerLinks.enabled.description%",
"default": "warning",
"enum": [
"ignore",
@ -442,16 +442,24 @@
"error"
]
},
"markdown.experimental.validate.fileLinks": {
"markdown.experimental.validate.fileLinks.enabled": {
"type": "string",
"scope": "resource",
"markdownDescription": "%configuration.markdown.experimental.validate.fileLinks.description%",
"markdownDescription": "%configuration.markdown.experimental.validate.fileLinks.enabled.description%",
"default": "warning",
"enum": [
"ignore",
"warning",
"error"
]
},
"markdown.experimental.validate.fileLinks.skipPaths": {
"type": "array",
"scope": "resource",
"markdownDescription": "%configuration.markdown.experimental.validate.fileLinks.skipPaths.description%",
"items": {
"type": "string"
}
}
}
},
@ -504,6 +512,7 @@
"markdown-it": "^12.3.2",
"markdown-it-front-matter": "^0.2.1",
"morphdom": "^2.6.1",
"picomatch": "^2.3.1",
"vscode-languageserver-textdocument": "^1.0.4",
"vscode-nls": "^5.0.0",
"vscode-uri": "^3.0.3"
@ -512,6 +521,7 @@
"@types/dompurify": "^2.3.1",
"@types/lodash.throttle": "^4.1.3",
"@types/markdown-it": "12.2.3",
"@types/picomatch": "^2.3.0",
"@types/vscode-notebook-renderer": "^1.60.0",
"@types/vscode-webview": "^1.57.0",
"lodash.throttle": "^4.1.1"

View file

@ -30,8 +30,9 @@
"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#`.",
"configuration.markdown.experimental.validate.referenceLinks.enabled.description": "Validate reference links in Markdown files, e.g. `[link][ref]`. Requires enabling `#markdown.experimental.validate.enabled#`.",
"configuration.markdown.experimental.validate.headerLinks.enabled.description": "Validate links to headers in Markdown files, e.g. `[link](#header)`. Requires enabling `#markdown.experimental.validate.enabled#`.",
"configuration.markdown.experimental.validate.fileLinks.enabled.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#`.",
"configuration.markdown.experimental.validate.fileLinks.skipPaths.description": "Configure glob patterns for links to treat as valid, even if they don't exist in the workspace. For example `/about` would make the link `[about](/about)` valid, while the glob `/assets/**/*.svg` would let you link to any `.svg` asset under the `assets` directory",
"workspaceTrust": "Required for loading styles configured in the workspace."
}

View file

@ -76,7 +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),
registerDiagnostics(selector, engine, workspaceContents, linkProvider, commandManager),
registerDropIntoEditor(selector),
registerFindFileReferences(commandManager, referencesProvider),
);

View file

@ -5,6 +5,7 @@
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import * as picomatch from 'picomatch';
import { MarkdownEngine } from '../markdownEngine';
import { TableOfContents } from '../tableOfContents';
import { Delayer } from '../util/async';
@ -14,6 +15,7 @@ import { Limiter } from '../util/limiter';
import { MdWorkspaceContents, SkinnyTextDocument } from '../workspaceContents';
import { InternalHref, LinkDefinitionSet, MdLink, MdLinkProvider, MdLinkSource } from './documentLinkProvider';
import { tryFindMdDocumentForLink } from './references';
import { CommandManager } from '../commandManager';
const localize = nls.loadMessageBundle();
@ -37,6 +39,7 @@ export interface DiagnosticOptions {
readonly validateReferences: DiagnosticLevel;
readonly validateOwnHeaders: DiagnosticLevel;
readonly validateFilePaths: DiagnosticLevel;
readonly skipPaths: readonly string[];
}
function toSeverity(level: DiagnosticLevel): vscode.DiagnosticSeverity | undefined {
@ -56,7 +59,13 @@ class VSCodeDiagnosticConfiguration extends Disposable implements DiagnosticConf
super();
this._register(vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('markdown.experimental.validate.enabled')) {
if (
e.affectsConfiguration('markdown.experimental.validate.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.referenceLinks.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.headerLinks.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.fileLinks.enabled')
|| e.affectsConfiguration('markdown.experimental.validate.fileLinks.skipPaths')
) {
this._onDidChange.fire();
}
}));
@ -66,9 +75,10 @@ class VSCodeDiagnosticConfiguration extends Disposable implements DiagnosticConf
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),
validateReferences: config.get<DiagnosticLevel>('experimental.validate.referenceLinks.enabled', DiagnosticLevel.ignore),
validateOwnHeaders: config.get<DiagnosticLevel>('experimental.validate.headerLinks.enabled', DiagnosticLevel.ignore),
validateFilePaths: config.get<DiagnosticLevel>('experimental.validate.fileLinks.enabled', DiagnosticLevel.ignore),
skipPaths: config.get('experimental.validate.fileLinks.skipPaths', []),
};
}
}
@ -206,6 +216,16 @@ class LinkWatcher extends Disposable {
}
}
class FileDoesNotExistDiagnostic extends vscode.Diagnostic {
public readonly path: string;
constructor(range: vscode.Range, message: string, severity: vscode.DiagnosticSeverity, path: string) {
super(range, message, severity);
this.path = path;
}
}
export class DiagnosticManager extends Disposable {
private readonly collection: vscode.DiagnosticCollection;
@ -459,9 +479,11 @@ export class DiagnosticComputer {
}
if (!hrefDoc && !await this.workspaceContents.pathExists(path)) {
const msg = localize('invalidPathLink', 'File does not exist at path: {0}', path.toString(true));
const msg = localize('invalidPathLink', 'File does not exist at path: {0}', path.fsPath);
for (const link of links) {
diagnostics.push(new vscode.Diagnostic(link.source.hrefRange, msg, severity));
if (!options.skipPaths.some(glob => picomatch.isMatch(link.source.pathText, glob))) {
diagnostics.push(new FileDoesNotExistDiagnostic(link.source.hrefRange, msg, severity, link.source.pathText));
}
}
} else if (hrefDoc) {
// Validate each of the links to headers in the file
@ -482,12 +504,64 @@ export class DiagnosticComputer {
}
}
class AddToSkipPathsQuickFixProvider implements vscode.CodeActionProvider {
private static readonly _addToSkipPathsCommandId = '_markdown.addToSkipPaths';
private static readonly metadata: vscode.CodeActionProviderMetadata = {
providedCodeActionKinds: [
vscode.CodeActionKind.QuickFix
],
};
public static register(selector: vscode.DocumentSelector, commandManager: CommandManager): vscode.Disposable {
const reg = vscode.languages.registerCodeActionsProvider(selector, new AddToSkipPathsQuickFixProvider(), AddToSkipPathsQuickFixProvider.metadata);
const commandReg = commandManager.register({
id: AddToSkipPathsQuickFixProvider._addToSkipPathsCommandId,
execute(resource: vscode.Uri, path: string) {
const settingId = 'experimental.validate.fileLinks.skipPaths';
const config = vscode.workspace.getConfiguration('markdown', resource);
const paths = new Set(config.get<string[]>(settingId, []));
paths.add(path);
config.update(settingId, [...paths], vscode.ConfigurationTarget.WorkspaceFolder);
}
});
return vscode.Disposable.from(reg, commandReg);
}
provideCodeActions(document: vscode.TextDocument, _range: vscode.Range | vscode.Selection, context: vscode.CodeActionContext, _token: vscode.CancellationToken): vscode.ProviderResult<(vscode.CodeAction | vscode.Command)[]> {
const fixes: vscode.CodeAction[] = [];
for (const diagnostic of context.diagnostics) {
if (diagnostic instanceof FileDoesNotExistDiagnostic) {
const fix = new vscode.CodeAction(
localize('skipPathsQuickFix.title', "Add '{0}' to paths that skip link validation.", diagnostic.path),
vscode.CodeActionKind.QuickFix);
fix.command = {
command: AddToSkipPathsQuickFixProvider._addToSkipPathsCommandId,
title: '',
arguments: [document.uri, diagnostic.path]
};
fixes.push(fix);
}
}
return fixes;
}
}
export function register(
selector: vscode.DocumentSelector,
engine: MarkdownEngine,
workspaceContents: MdWorkspaceContents,
linkProvider: MdLinkProvider,
commandManager: CommandManager,
): vscode.Disposable {
const configuration = new VSCodeDiagnosticConfiguration();
const manager = new DiagnosticManager(new DiagnosticComputer(engine, workspaceContents, linkProvider), configuration);
return vscode.Disposable.from(configuration, manager);
return vscode.Disposable.from(
configuration,
manager,
AddToSkipPathsQuickFixProvider.register(selector, commandManager));
}

View file

@ -93,7 +93,16 @@ function getWorkspaceFolder(document: SkinnyTextDocument) {
}
export interface MdLinkSource {
/**
* The original text of the link destination in code.
*/
readonly text: string;
/**
* The original text of just the link's path in code.
*/
readonly pathText: string;
readonly resource: vscode.Uri;
readonly hrefRange: vscode.Range;
readonly fragmentRange: vscode.Range | undefined;
@ -138,7 +147,7 @@ function extractDocumentLink(
text: link,
resource: document.uri,
hrefRange: new vscode.Range(linkStart, linkEnd),
fragmentRange: getFragmentRange(link, linkStart, linkEnd),
...getLinkSourceFragmentInfo(document, link, linkStart, linkEnd),
}
};
} catch {
@ -154,6 +163,14 @@ function getFragmentRange(text: string, start: vscode.Position, end: vscode.Posi
return new vscode.Range(start.translate({ characterDelta: index + 1 }), end);
}
function getLinkSourceFragmentInfo(document: SkinnyTextDocument, 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 = /^<(.*)>$/;
/**
@ -314,7 +331,7 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider {
text: link,
resource: document.uri,
hrefRange: new vscode.Range(linkStart, linkEnd),
fragmentRange: getFragmentRange(link, linkStart, linkEnd),
...getLinkSourceFragmentInfo(document, link, linkStart, linkEnd),
}
};
}
@ -350,6 +367,7 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider {
kind: 'link',
source: {
text: reference,
pathText: reference,
resource: document.uri,
hrefRange,
fragmentRange: undefined,
@ -402,7 +420,7 @@ export class MdLinkProvider implements vscode.DocumentLinkProvider {
text: link,
resource: document.uri,
hrefRange,
fragmentRange: getFragmentRange(link, linkStart, linkEnd),
...getLinkSourceFragmentInfo(document, link, linkStart, linkEnd),
},
ref: { text: reference, range: refRange },
href: target,

View file

@ -26,6 +26,7 @@ async function getComputedDiagnostics(doc: InMemoryDocument, workspaceContents:
validateFilePaths: DiagnosticLevel.warning,
validateOwnHeaders: DiagnosticLevel.warning,
validateReferences: DiagnosticLevel.warning,
skipPaths: [],
}, noopToken)
).diagnostics;
}
@ -43,6 +44,7 @@ class MemoryDiagnosticConfiguration implements DiagnosticConfiguration {
constructor(
private readonly enabled: boolean = true,
private readonly skipPaths: string[] = [],
) { }
getOptions(_resource: vscode.Uri): DiagnosticOptions {
@ -52,6 +54,7 @@ class MemoryDiagnosticConfiguration implements DiagnosticConfiguration {
validateFilePaths: DiagnosticLevel.ignore,
validateOwnHeaders: DiagnosticLevel.ignore,
validateReferences: DiagnosticLevel.ignore,
skipPaths: this.skipPaths,
};
}
return {
@ -59,6 +62,7 @@ class MemoryDiagnosticConfiguration implements DiagnosticConfiguration {
validateFilePaths: DiagnosticLevel.warning,
validateOwnHeaders: DiagnosticLevel.warning,
validateReferences: DiagnosticLevel.warning,
skipPaths: this.skipPaths,
};
}
}
@ -179,4 +183,61 @@ suite('markdown: Diagnostics', () => {
const diagnostics = await getComputedDiagnostics(doc1, new InMemoryWorkspaceMarkdownDocuments([doc1]));
assert.deepStrictEqual(diagnostics.length, 0);
});
test('Should allow ignoring invalid file link using glob', async () => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`[text](/no-such-file)`,
`![img](/no-such-file)`,
`[text]: /no-such-file`,
));
const manager = createDiagnosticsManager(new InMemoryWorkspaceMarkdownDocuments([doc1]), new MemoryDiagnosticConfiguration(true, ['/no-such-file']));
const { diagnostics } = await manager.recomputeDiagnosticState(doc1, noopToken);
assert.deepStrictEqual(diagnostics.length, 0);
});
test('skipPaths should allow skipping non-existent file', async () => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`[text](/no-such-file#header)`,
));
const manager = createDiagnosticsManager(new InMemoryWorkspaceMarkdownDocuments([doc1]), new MemoryDiagnosticConfiguration(true, ['/no-such-file']));
const { diagnostics } = await manager.recomputeDiagnosticState(doc1, noopToken);
assert.deepStrictEqual(diagnostics.length, 0);
});
test('skipPaths should not consider link fragment', async () => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`[text](/no-such-file#header)`,
));
const manager = createDiagnosticsManager(new InMemoryWorkspaceMarkdownDocuments([doc1]), new MemoryDiagnosticConfiguration(true, ['/no-such-file']));
const { diagnostics } = await manager.recomputeDiagnosticState(doc1, noopToken);
assert.deepStrictEqual(diagnostics.length, 0);
});
test('skipPaths should support globs', async () => {
const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines(
`![i](/images/aaa.png)`,
`![i](/images/sub/bbb.png)`,
`![i](/images/sub/sub2/ccc.png)`,
));
const manager = createDiagnosticsManager(new InMemoryWorkspaceMarkdownDocuments([doc1]), new MemoryDiagnosticConfiguration(true, ['/images/**/*.png']));
const { diagnostics } = await manager.recomputeDiagnosticState(doc1, noopToken);
assert.deepStrictEqual(diagnostics.length, 0);
});
test('skipPaths should resolve relative to file', async () => {
const doc1 = new InMemoryDocument(workspacePath('sub', 'doc1.md'), joinLines(
`![i](images/aaa.png)`,
`![i](images/sub/bbb.png)`,
`![i](images/sub/sub2/ccc.png)`,
`![i](/images/sub/sub2/ccc.png)`,
));
const manager = createDiagnosticsManager(new InMemoryWorkspaceMarkdownDocuments([doc1]), new MemoryDiagnosticConfiguration(true, ['images/**/*.png']));
const { diagnostics } = await manager.recomputeDiagnosticState(doc1, noopToken);
assert.deepStrictEqual(diagnostics.length, 0);
});
});

View file

@ -39,6 +39,11 @@
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-1.0.2.tgz#e2ce9d83a613bacf284c7be7d491945e39e1f8e9"
integrity sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==
"@types/picomatch@^2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@types/picomatch/-/picomatch-2.3.0.tgz#75db5e75a713c5a83d5b76780c3da84a82806003"
integrity sha512-O397rnSS9iQI4OirieAtsDqvCj4+3eY1J+EPdNTKuHuRWIfUoGyzX294o8C4KJYaLqgSrd2o60c5EqCU8Zv02g==
"@types/trusted-types@*":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
@ -117,6 +122,11 @@ morphdom@^2.6.1:
resolved "https://registry.yarnpkg.com/morphdom/-/morphdom-2.6.1.tgz#e868e24f989fa3183004b159aed643e628b4306e"
integrity sha512-Y8YRbAEP3eKykroIBWrjcfMw7mmwJfjhqdpSvoqinu8Y702nAwikpXcNFDiIkyvfCLxLM9Wu95RZqo4a9jFBaA==
picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
uc.micro@^1.0.1, uc.micro@^1.0.5:
version "1.0.6"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"