diff --git a/extensions/markdown-language-features/src/extension.ts b/extensions/markdown-language-features/src/extension.ts index 588bc9d1da8c9..7602cee8ed837 100644 --- a/extensions/markdown-language-features/src/extension.ts +++ b/extensions/markdown-language-features/src/extension.ts @@ -55,7 +55,7 @@ function registerMarkdownLanguageFeatures( return vscode.Disposable.from( vscode.languages.registerDocumentSymbolProvider(selector, symbolProvider), - vscode.languages.registerDocumentLinkProvider(selector, new LinkProvider()), + vscode.languages.registerDocumentLinkProvider(selector, new LinkProvider(engine)), vscode.languages.registerFoldingRangeProvider(selector, new MarkdownFoldingProvider(engine)), vscode.languages.registerSelectionRangeProvider(selector, new MarkdownSmartSelect(engine)), vscode.languages.registerWorkspaceSymbolProvider(new MarkdownWorkspaceSymbolProvider(symbolProvider)), diff --git a/extensions/markdown-language-features/src/features/documentLinkProvider.ts b/extensions/markdown-language-features/src/features/documentLinkProvider.ts index 53c6bda676cbf..b47875d09fb79 100644 --- a/extensions/markdown-language-features/src/features/documentLinkProvider.ts +++ b/extensions/markdown-language-features/src/features/documentLinkProvider.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import { OpenDocumentLinkCommand } from '../commands/openDocumentLink'; +import { MarkdownEngine } from '../markdownEngine'; import { getUriForLinkWithKnownExternalScheme, isOfScheme, Schemes } from '../util/links'; import { dirname } from '../util/path'; @@ -105,33 +106,66 @@ export function stripAngleBrackets(link: string) { const linkPattern = /(\[((!\[[^\]]*?\]\(\s*)([^\s\(\)]+?)\s*\)\]|(?:\\\]|[^\]])*\])\(\s*)(([^\s\(\)]|\([^\s\(\)]*?\))+)\s*(".*?")?\)/g; const referenceLinkPattern = /(\[((?:\\\]|[^\]])+)\]\[\s*?)([^\s\]]*?)\]/g; const definitionPattern = /^([\t ]*\[(?!\^)((?:\\\]|[^\]])+)\]:\s*)([^<]\S*|<[^>]+>)/gm; +const inlineCodePattern = /(?:(? { + const tokens = await engine.parse(document); + const multiline = tokens.filter(t => (t.type === 'code_block' || t.type === 'fence') && !!t.map).map(t => t.map) as [number, number][]; + + const text = document.getText(); + const inline = [...text.matchAll(inlineCodePattern)].map(match => { + const start = match.index || 0; + return new vscode.Range(document.positionAt(start), document.positionAt(start + match[0].length)); + }); + + return { multiline, inline }; +} + +function isLinkInsideCode(code: CodeInDocument, link: vscode.DocumentLink) { + return code.multiline.some(interval => link.range.start.line >= interval[0] && link.range.start.line < interval[1]) || + code.inline.some(position => position.intersection(link.range)); +} export default class LinkProvider implements vscode.DocumentLinkProvider { + constructor( + private readonly engine: MarkdownEngine + ) { } - public provideDocumentLinks( + public async provideDocumentLinks( document: vscode.TextDocument, _token: vscode.CancellationToken - ): vscode.DocumentLink[] { + ): Promise { const text = document.getText(); - return [ - ...this.providerInlineLinks(text, document), + ...(await this.providerInlineLinks(text, document)), ...this.provideReferenceLinks(text, document) ]; } - private providerInlineLinks( + private async providerInlineLinks( text: string, document: vscode.TextDocument, - ): vscode.DocumentLink[] { + ): Promise { const results: vscode.DocumentLink[] = []; + const codeInDocument = await findCode(document, this.engine); for (const match of text.matchAll(linkPattern)) { const matchImage = match[4] && extractDocumentLink(document, match[3].length + 1, match[4], match.index); - if (matchImage) { + if (matchImage && !isLinkInsideCode(codeInDocument, matchImage)) { results.push(matchImage); } const matchLink = extractDocumentLink(document, match[1].length, match[5], match.index); - if (matchLink) { + if (matchLink && !isLinkInsideCode(codeInDocument, matchLink)) { results.push(matchLink); } } diff --git a/extensions/markdown-language-features/src/test/documentLinkProvider.test.ts b/extensions/markdown-language-features/src/test/documentLinkProvider.test.ts index 5f333de8b9a2e..d9d17e4a856f5 100644 --- a/extensions/markdown-language-features/src/test/documentLinkProvider.test.ts +++ b/extensions/markdown-language-features/src/test/documentLinkProvider.test.ts @@ -7,15 +7,16 @@ import * as assert from 'assert'; import 'mocha'; import * as vscode from 'vscode'; import LinkProvider from '../features/documentLinkProvider'; +import { createNewMarkdownEngine } from './engine'; import { InMemoryDocument } from './inMemoryDocument'; -import { noopToken } from './util'; +import { joinLines, noopToken } from './util'; const testFile = vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, 'x.md'); function getLinksForFile(fileContents: string) { const doc = new InMemoryDocument(testFile, fileContents); - const provider = new LinkProvider(); + const provider = new LinkProvider(createNewMarkdownEngine()); return provider.provideDocumentLinks(doc, noopToken); } @@ -27,63 +28,63 @@ function assertRangeEqual(expected: vscode.Range, actual: vscode.Range) { } suite('markdown.DocumentLinkProvider', () => { - test('Should not return anything for empty document', () => { - const links = getLinksForFile(''); + test('Should not return anything for empty document', async () => { + const links = await getLinksForFile(''); assert.strictEqual(links.length, 0); }); - test('Should not return anything for simple document without links', () => { - const links = getLinksForFile('# a\nfdasfdfsafsa'); + test('Should not return anything for simple document without links', async () => { + const links = await getLinksForFile('# a\nfdasfdfsafsa'); assert.strictEqual(links.length, 0); }); - test('Should detect basic http links', () => { - const links = getLinksForFile('a [b](https://example.com) c'); + test('Should detect basic http links', async () => { + const links = await getLinksForFile('a [b](https://example.com) c'); assert.strictEqual(links.length, 1); const [link] = links; assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 25)); }); - test('Should detect basic workspace links', () => { + test('Should detect basic workspace links', async () => { { - const links = getLinksForFile('a [b](./file) c'); + const links = await getLinksForFile('a [b](./file) c'); assert.strictEqual(links.length, 1); const [link] = links; assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 12)); } { - const links = getLinksForFile('a [b](file.png) c'); + const links = await getLinksForFile('a [b](file.png) c'); assert.strictEqual(links.length, 1); const [link] = links; assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 14)); } }); - test('Should detect links with title', () => { - const links = getLinksForFile('a [b](https://example.com "abc") c'); + test('Should detect links with title', async () => { + const links = await getLinksForFile('a [b](https://example.com "abc") c'); assert.strictEqual(links.length, 1); const [link] = links; assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 25)); }); // #35245 - test('Should handle links with escaped characters in name', () => { - const links = getLinksForFile('a [b\\]](./file)'); + test('Should handle links with escaped characters in name', async () => { + const links = await getLinksForFile('a [b\\]](./file)'); assert.strictEqual(links.length, 1); const [link] = links; assertRangeEqual(link.range, new vscode.Range(0, 8, 0, 14)); }); - test('Should handle links with balanced parens', () => { + test('Should handle links with balanced parens', async () => { { - const links = getLinksForFile('a [b](https://example.com/a()c) c'); + const links = await getLinksForFile('a [b](https://example.com/a()c) c'); assert.strictEqual(links.length, 1); const [link] = links; assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 30)); } { - const links = getLinksForFile('a [b](https://example.com/a(b)c) c'); + const links = await getLinksForFile('a [b](https://example.com/a(b)c) c'); assert.strictEqual(links.length, 1); const [link] = links; assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 31)); @@ -91,15 +92,15 @@ suite('markdown.DocumentLinkProvider', () => { } { // #49011 - const links = getLinksForFile('[A link](http://ThisUrlhasParens/A_link(in_parens))'); + const links = await getLinksForFile('[A link](http://ThisUrlhasParens/A_link(in_parens))'); assert.strictEqual(links.length, 1); const [link] = links; assertRangeEqual(link.range, new vscode.Range(0, 9, 0, 50)); } }); - test('Should handle two links without space', () => { - const links = getLinksForFile('a ([test](test)[test2](test2)) c'); + test('Should handle two links without space', async () => { + const links = await getLinksForFile('a ([test](test)[test2](test2)) c'); assert.strictEqual(links.length, 2); const [link1, link2] = links; assertRangeEqual(link1.range, new vscode.Range(0, 10, 0, 14)); @@ -107,23 +108,23 @@ suite('markdown.DocumentLinkProvider', () => { }); // #49238 - test('should handle hyperlinked images', () => { + test('should handle hyperlinked images', async () => { { - const links = getLinksForFile('[![alt text](image.jpg)](https://example.com)'); + const links = await getLinksForFile('[![alt text](image.jpg)](https://example.com)'); assert.strictEqual(links.length, 2); const [link1, link2] = links; assertRangeEqual(link1.range, new vscode.Range(0, 13, 0, 22)); assertRangeEqual(link2.range, new vscode.Range(0, 25, 0, 44)); } { - const links = getLinksForFile('[![a]( whitespace.jpg )]( https://whitespace.com )'); + const links = await getLinksForFile('[![a]( whitespace.jpg )]( https://whitespace.com )'); assert.strictEqual(links.length, 2); const [link1, link2] = links; assertRangeEqual(link1.range, new vscode.Range(0, 7, 0, 21)); assertRangeEqual(link2.range, new vscode.Range(0, 26, 0, 48)); } { - const links = getLinksForFile('[![a](img1.jpg)](file1.txt) text [![a](img2.jpg)](file2.txt)'); + const links = await getLinksForFile('[![a](img1.jpg)](file1.txt) text [![a](img2.jpg)](file2.txt)'); assert.strictEqual(links.length, 4); const [link1, link2, link3, link4] = links; assertRangeEqual(link1.range, new vscode.Range(0, 6, 0, 14)); @@ -133,13 +134,13 @@ suite('markdown.DocumentLinkProvider', () => { } }); - test('Should not consider link references starting with ^ character valid (#107471)', () => { - const links = getLinksForFile('[^reference]: https://example.com'); + test('Should not consider link references starting with ^ character valid (#107471)', async () => { + const links = await getLinksForFile('[^reference]: https://example.com'); assert.strictEqual(links.length, 0); }); - test('Should find definitions links with spaces in angle brackets (#136073)', () => { - const links = getLinksForFile([ + test('Should find definitions links with spaces in angle brackets (#136073)', async () => { + const links = await getLinksForFile([ '[a]: ', '[b]: ', ].join('\n')); @@ -149,6 +150,75 @@ suite('markdown.DocumentLinkProvider', () => { assertRangeEqual(link1.range, new vscode.Range(0, 6, 0, 9)); assertRangeEqual(link2.range, new vscode.Range(1, 6, 1, 8)); }); + + test('Should not consider links in code fenced with backticks', async () => { + const text = joinLines( + '```', + '[b](https://example.com)', + '```'); + const links = await getLinksForFile(text); + assert.strictEqual(links.length, 0); + }); + + test('Should not consider links in code fenced with tilda', async () => { + const text = joinLines( + '~~~', + '[b](https://example.com)', + '~~~'); + const links = await getLinksForFile(text); + assert.strictEqual(links.length, 0); + }); + + test('Should not consider links in indented code', async () => { + const links = await getLinksForFile(' [b](https://example.com)'); + assert.strictEqual(links.length, 0); + }); + + test('Should not consider links in inline code span', async () => { + const links = await getLinksForFile('`[b](https://example.com)`'); + assert.strictEqual(links.length, 0); + }); + + test('Should not consider links with code span inside', async () => { + const links = await getLinksForFile('[li`nk](https://example.com`)'); + assert.strictEqual(links.length, 0); + }); + + test('Should not consider links in multiline inline code span', async () => { + const text = joinLines( + '`` ', + '[b](https://example.com)', + '``'); + const links = await getLinksForFile(text); + assert.strictEqual(links.length, 0); + }); + + test('Should not consider links in multiline inline code span between between text', async () => { + const text = joinLines( + '[b](https://1.com) `[b](https://2.com)', + '` [b](https://3.com)'); + const links = await getLinksForFile(text); + assert.deepStrictEqual(links.map(l => l.target?.authority), ['1.com', '3.com']) + }); + + test('Should not consider links in multiline inline code span with new line after the first backtick', async () => { + const text = joinLines( + '`', + '[b](https://example.com)`'); + const links = await getLinksForFile(text); + assert.strictEqual(links.length, 0); + }); + + test('Should not miss links in invalid multiline inline code span', async () => { + const text = joinLines( + '`` ', + '', + '[b](https://example.com)', + '', + '``'); + const links = await getLinksForFile(text); + assert.strictEqual(links.length, 1); + }); }); diff --git a/extensions/markdown-language-features/test-workspace/a.md b/extensions/markdown-language-features/test-workspace/a.md index 568bad19d4f6c..ac33150e63d67 100644 --- a/extensions/markdown-language-features/test-workspace/a.md +++ b/extensions/markdown-language-features/test-workspace/a.md @@ -4,7 +4,17 @@ [./b.md](./b.md) -[/b.md](/b.md) +[/b.md](/b.md) `[/b.md](/b.md)` [b#header1](b#header1) +``` +[b](b) +``` + +~~~ +[b](b) +~~~ + + // Indented code + [b](b)