diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index b1d4d1bc529..4ef07a0dd65 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -453,10 +453,10 @@ "error" ] }, - "markdown.experimental.validate.fileLinks.skipPaths": { + "markdown.experimental.validate.ignoreLinks": { "type": "array", "scope": "resource", - "markdownDescription": "%configuration.markdown.experimental.validate.fileLinks.skipPaths.description%", + "markdownDescription": "%configuration.markdown.experimental.validate.ignoreLinks.description%", "items": { "type": "string" } diff --git a/extensions/markdown-language-features/package.nls.json b/extensions/markdown-language-features/package.nls.json index 5a97389a86a..2c8977cf2e0 100644 --- a/extensions/markdown-language-features/package.nls.json +++ b/extensions/markdown-language-features/package.nls.json @@ -33,6 +33,6 @@ "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", + "configuration.markdown.experimental.validate.ignoreLinks.description": "Configure links that should not be validated. For example `/about` would not validate the link `[about](/about)`, while the glob `/assets/**/*.svg` would let you skip validation for any link to `.svg` files under the `assets` directory.", "workspaceTrust": "Required for loading styles configured in the workspace." } diff --git a/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts b/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts index eb4c436afd5..89e6b29f240 100644 --- a/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts +++ b/extensions/markdown-language-features/src/languageFeatures/diagnostics.ts @@ -39,7 +39,7 @@ export interface DiagnosticOptions { readonly validateReferences: DiagnosticLevel; readonly validateOwnHeaders: DiagnosticLevel; readonly validateFilePaths: DiagnosticLevel; - readonly skipPaths: readonly string[]; + readonly ignoreLinks: readonly string[]; } function toSeverity(level: DiagnosticLevel): vscode.DiagnosticSeverity | undefined { @@ -64,7 +64,7 @@ class VSCodeDiagnosticConfiguration extends Disposable implements DiagnosticConf || 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') + || e.affectsConfiguration('markdown.experimental.validate.ignoreLinks') ) { this._onDidChange.fire(); } @@ -78,7 +78,7 @@ class VSCodeDiagnosticConfiguration extends Disposable implements DiagnosticConf validateReferences: config.get('experimental.validate.referenceLinks.enabled', DiagnosticLevel.ignore), validateOwnHeaders: config.get('experimental.validate.headerLinks.enabled', DiagnosticLevel.ignore), validateFilePaths: config.get('experimental.validate.fileLinks.enabled', DiagnosticLevel.ignore), - skipPaths: config.get('experimental.validate.fileLinks.skipPaths', []), + ignoreLinks: config.get('experimental.validate.ignoreLinks', []), }; } } @@ -216,13 +216,13 @@ class LinkWatcher extends Disposable { } } -class FileDoesNotExistDiagnostic extends vscode.Diagnostic { +class LinkDoesNotExistDiagnostic extends vscode.Diagnostic { - public readonly path: string; + public readonly link: string; - constructor(range: vscode.Range, message: string, severity: vscode.DiagnosticSeverity, path: string) { + constructor(range: vscode.Range, message: string, severity: vscode.DiagnosticSeverity, link: string) { super(range, message, severity); - this.path = path; + this.link = link; } } @@ -424,10 +424,13 @@ export class DiagnosticComputer { && 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)); + if (!this.isIgnoredLink(options, link.source.text)) { + diagnostics.push(new LinkDoesNotExistDiagnostic( + link.source.hrefRange, + localize('invalidHeaderLink', 'No header found: \'{0}\'', link.href.fragment), + severity, + link.source.text)); + } } } @@ -481,8 +484,8 @@ export class DiagnosticComputer { if (!hrefDoc && !await this.workspaceContents.pathExists(path)) { const msg = localize('invalidPathLink', 'File does not exist at path: {0}', path.fsPath); for (const link of links) { - if (!options.skipPaths.some(glob => picomatch.isMatch(link.source.pathText, glob))) { - diagnostics.push(new FileDoesNotExistDiagnostic(link.source.hrefRange, msg, severity, link.source.pathText)); + if (!this.isIgnoredLink(options, link.source.pathText)) { + diagnostics.push(new LinkDoesNotExistDiagnostic(link.source.hrefRange, msg, severity, link.source.pathText)); } } } else if (hrefDoc) { @@ -491,9 +494,9 @@ export class DiagnosticComputer { if (fragmentLinks.length) { const toc = await TableOfContents.create(this.engine, hrefDoc); for (const link of fragmentLinks) { - if (!toc.lookup(link.fragment)) { + if (!toc.lookup(link.fragment) && !this.isIgnoredLink(options, link.source.text)) { const msg = localize('invalidLinkToHeaderInOtherFile', 'Header does not exist in file: {0}', link.fragment); - diagnostics.push(new vscode.Diagnostic(link.source.hrefRange, msg, severity)); + diagnostics.push(new LinkDoesNotExistDiagnostic(link.source.hrefRange, msg, severity, link.source.text)); } } } @@ -502,11 +505,15 @@ export class DiagnosticComputer { })); return diagnostics; } + + private isIgnoredLink(options: DiagnosticOptions, link: string): boolean { + return options.ignoreLinks.some(glob => picomatch.isMatch(link, glob)); + } } -class AddToSkipPathsQuickFixProvider implements vscode.CodeActionProvider { +class AddToIgnoreLinksQuickFixProvider implements vscode.CodeActionProvider { - private static readonly _addToSkipPathsCommandId = '_markdown.addToSkipPaths'; + private static readonly _addToIgnoreLinksCommandId = '_markdown.addToIgnoreLinks'; private static readonly metadata: vscode.CodeActionProviderMetadata = { providedCodeActionKinds: [ @@ -515,11 +522,11 @@ class AddToSkipPathsQuickFixProvider implements vscode.CodeActionProvider { }; public static register(selector: vscode.DocumentSelector, commandManager: CommandManager): vscode.Disposable { - const reg = vscode.languages.registerCodeActionsProvider(selector, new AddToSkipPathsQuickFixProvider(), AddToSkipPathsQuickFixProvider.metadata); + const reg = vscode.languages.registerCodeActionsProvider(selector, new AddToIgnoreLinksQuickFixProvider(), AddToIgnoreLinksQuickFixProvider.metadata); const commandReg = commandManager.register({ - id: AddToSkipPathsQuickFixProvider._addToSkipPathsCommandId, + id: AddToIgnoreLinksQuickFixProvider._addToIgnoreLinksCommandId, execute(resource: vscode.Uri, path: string) { - const settingId = 'experimental.validate.fileLinks.skipPaths'; + const settingId = 'experimental.validate.ignoreLinks'; const config = vscode.workspace.getConfiguration('markdown', resource); const paths = new Set(config.get(settingId, [])); paths.add(path); @@ -533,15 +540,15 @@ class AddToSkipPathsQuickFixProvider implements vscode.CodeActionProvider { const fixes: vscode.CodeAction[] = []; for (const diagnostic of context.diagnostics) { - if (diagnostic instanceof FileDoesNotExistDiagnostic) { + if (diagnostic instanceof LinkDoesNotExistDiagnostic) { const fix = new vscode.CodeAction( - localize('skipPathsQuickFix.title', "Add '{0}' to paths that skip link validation.", diagnostic.path), + localize('ignoreLinksQuickFix.title', "Exclude '{0}' from link validation.", diagnostic.link), vscode.CodeActionKind.QuickFix); fix.command = { - command: AddToSkipPathsQuickFixProvider._addToSkipPathsCommandId, + command: AddToIgnoreLinksQuickFixProvider._addToIgnoreLinksCommandId, title: '', - arguments: [document.uri, diagnostic.path] + arguments: [document.uri, diagnostic.link] }; fixes.push(fix); } @@ -563,5 +570,5 @@ export function register( return vscode.Disposable.from( configuration, manager, - AddToSkipPathsQuickFixProvider.register(selector, commandManager)); + AddToIgnoreLinksQuickFixProvider.register(selector, commandManager)); } diff --git a/extensions/markdown-language-features/src/test/diagnostic.test.ts b/extensions/markdown-language-features/src/test/diagnostic.test.ts index e58dabd983e..1490df29279 100644 --- a/extensions/markdown-language-features/src/test/diagnostic.test.ts +++ b/extensions/markdown-language-features/src/test/diagnostic.test.ts @@ -26,7 +26,7 @@ async function getComputedDiagnostics(doc: InMemoryDocument, workspaceContents: validateFilePaths: DiagnosticLevel.warning, validateOwnHeaders: DiagnosticLevel.warning, validateReferences: DiagnosticLevel.warning, - skipPaths: [], + ignoreLinks: [], }, noopToken) ).diagnostics; } @@ -44,7 +44,7 @@ class MemoryDiagnosticConfiguration implements DiagnosticConfiguration { constructor( private readonly enabled: boolean = true, - private readonly skipPaths: string[] = [], + private readonly ignoreLinks: string[] = [], ) { } getOptions(_resource: vscode.Uri): DiagnosticOptions { @@ -54,7 +54,7 @@ class MemoryDiagnosticConfiguration implements DiagnosticConfiguration { validateFilePaths: DiagnosticLevel.ignore, validateOwnHeaders: DiagnosticLevel.ignore, validateReferences: DiagnosticLevel.ignore, - skipPaths: this.skipPaths, + ignoreLinks: this.ignoreLinks, }; } return { @@ -62,7 +62,7 @@ class MemoryDiagnosticConfiguration implements DiagnosticConfiguration { validateFilePaths: DiagnosticLevel.warning, validateOwnHeaders: DiagnosticLevel.warning, validateReferences: DiagnosticLevel.warning, - skipPaths: this.skipPaths, + ignoreLinks: this.ignoreLinks, }; } } @@ -196,7 +196,7 @@ suite('markdown: Diagnostics', () => { assert.deepStrictEqual(diagnostics.length, 0); }); - test('skipPaths should allow skipping non-existent file', async () => { + test('ignoreLinks should allow skipping link to non-existent file', async () => { const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines( `[text](/no-such-file#header)`, )); @@ -206,7 +206,7 @@ suite('markdown: Diagnostics', () => { assert.deepStrictEqual(diagnostics.length, 0); }); - test('skipPaths should not consider link fragment', async () => { + test('ignoreLinks should not consider link fragment', async () => { const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines( `[text](/no-such-file#header)`, )); @@ -216,7 +216,7 @@ suite('markdown: Diagnostics', () => { assert.deepStrictEqual(diagnostics.length, 0); }); - test('skipPaths should support globs', async () => { + test('ignoreLinks should support globs', async () => { const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines( `![i](/images/aaa.png)`, `![i](/images/sub/bbb.png)`, @@ -227,4 +227,33 @@ suite('markdown: Diagnostics', () => { const { diagnostics } = await manager.recomputeDiagnosticState(doc1, noopToken); assert.deepStrictEqual(diagnostics.length, 0); }); + + test('ignoreLinks should support ignoring header', async () => { + const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines( + `![i](#no-such)`, + )); + + const manager = createDiagnosticsManager(new InMemoryWorkspaceMarkdownDocuments([doc1]), new MemoryDiagnosticConfiguration(true, ['#no-such'])); + const { diagnostics } = await manager.recomputeDiagnosticState(doc1, noopToken); + assert.deepStrictEqual(diagnostics.length, 0); + }); + + test('ignoreLinks should support ignoring header in file', async () => { + const doc1 = new InMemoryDocument(workspacePath('doc1.md'), joinLines( + `![i](/doc2.md#no-such)`, + )); + const doc2 = new InMemoryDocument(workspacePath('doc2.md'), joinLines('')); + + const contents = new InMemoryWorkspaceMarkdownDocuments([doc1, doc2]); + { + const manager = createDiagnosticsManager(contents, new MemoryDiagnosticConfiguration(true, ['/doc2.md#no-such'])); + const { diagnostics } = await manager.recomputeDiagnosticState(doc1, noopToken); + assert.deepStrictEqual(diagnostics.length, 0); + } + { + const manager = createDiagnosticsManager(contents, new MemoryDiagnosticConfiguration(true, ['/doc2.md#*'])); + const { diagnostics } = await manager.recomputeDiagnosticState(doc1, noopToken); + assert.deepStrictEqual(diagnostics.length, 0); + } + }); });