Skip to content

Commit

Permalink
Prevent link highlight in markdown codeblocks (microsoft#139770)
Browse files Browse the repository at this point in the history
  • Loading branch information
WaqasAliAbbasi committed Jan 19, 2022
1 parent 6ffa1d3 commit b501ae8
Show file tree
Hide file tree
Showing 4 changed files with 112 additions and 40 deletions.
2 changes: 1 addition & 1 deletion extensions/markdown-language-features/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -102,18 +103,49 @@ export function stripAngleBrackets(link: string) {
return link.replace(angleBracketLinkRe, '$1');
}

const linkPattern = /(\[((!\[[^\]]*?\]\(\s*)([^\s\(\)]+?)\s*\)\]|(?:\\\]|[^\]])*\])\(\s*)(([^\s\(\)]|\([^\s\(\)]*?\))+)\s*(".*?")?\)/g;
const inlineCodeAndLinkPattern = /(?:(`+)(?:[^`]|[^`][\s\S]*?[^`])\1(?!`))|(\[((!\[[^\]]*?\]\(\s*)([^\s\(\)]+?)\s*\)\]|(?:\\\]|[^\]])*\])\(\s*)(([^\s\(\)]|\([^\s\(\)]*?\))+)\s*(".*?")?\)/g;
const referenceLinkPattern = /(\[((?:\\\]|[^\]])+)\]\[\s*?)([^\s\]]*?)\]/g;
const definitionPattern = /^([\t ]*\[(?!\^)((?:\\\]|[^\]])+)\]:\s*)([^<]\S*|<[^>]+>)/gm;

const binarySearchPairs = (pairs: number[][], start: number, end: number, target: number): Boolean => {
if (start > end) {
return false;
}
const mid = start + Math.floor((end - start) / 2);
const pair = pairs[mid];
if (target >= pair[0] && target < pair[1]) {
return true;
}
if (target >= pair[1]) {
return binarySearchPairs(pairs, mid + 1, end, target);
}
return binarySearchPairs(pairs, start, mid - 1, target);
};

export default class LinkProvider implements vscode.DocumentLinkProvider {

public provideDocumentLinks(
private _codeOrFenceLineIntervals: number[][] = [];

constructor(
private readonly engine: MarkdownEngine
) { }

private isLineInsideIndentedOrFencedCode(line: number): Boolean {
return binarySearchPairs(this._codeOrFenceLineIntervals, 0, this._codeOrFenceLineIntervals.length - 1, line);
}

public async provideDocumentLinks(
document: vscode.TextDocument,
_token: vscode.CancellationToken
): vscode.DocumentLink[] {
): Promise<vscode.DocumentLink[]> {
const text = document.getText();

const tokens = await this.engine.parse(document);
this._codeOrFenceLineIntervals = tokens.reduce<number[][]>((acc, t) => {
if ((t.type === 'code_block' || t.type === 'fence') && t.map) {
return [...acc, t.map];
}
return acc;
}, []);
return [
...this.providerInlineLinks(text, document),
...this.provideReferenceLinks(text, document)
Expand All @@ -122,16 +154,19 @@ export default class LinkProvider implements vscode.DocumentLinkProvider {

private providerInlineLinks(
text: string,
document: vscode.TextDocument,
document: vscode.TextDocument
): vscode.DocumentLink[] {
const results: vscode.DocumentLink[] = [];
for (const match of text.matchAll(linkPattern)) {
const matchImage = match[4] && extractDocumentLink(document, match[3].length + 1, match[4], match.index);
if (matchImage) {
for (const match of text.matchAll(inlineCodeAndLinkPattern)) {
if (match[1]) {
continue;
}
const matchImage = match[5] && extractDocumentLink(document, match[4].length + 1, match[5], match.index);
if (matchImage && !this.isLineInsideIndentedOrFencedCode(matchImage.range.start.line)) {
results.push(matchImage);
}
const matchLink = extractDocumentLink(document, match[1].length, match[5], match.index);
if (matchLink) {
const matchLink = extractDocumentLink(document, match[2].length, match[6], match.index);
if (matchLink && !this.isLineInsideIndentedOrFencedCode(matchLink.range.start.line)) {
results.push(matchLink);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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';

Expand All @@ -15,7 +16,7 @@ const testFile = vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri,

function getLinksForFile(fileContents: string) {
const doc = new InMemoryDocument(testFile, fileContents);
const provider = new LinkProvider();
const provider = new LinkProvider(createNewMarkdownEngine());
return provider.provideDocumentLinks(doc, noopToken);
}

Expand All @@ -27,103 +28,103 @@ 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));

}
{
// #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));
assertRangeEqual(link2.range, new vscode.Range(0, 23, 0, 28));
});

// #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));
Expand All @@ -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 c>',
'[b]: <cd>',
].join('\n'));
Expand All @@ -149,6 +150,27 @@ 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 fenced, indented and inline code', async () => {
const links = await getLinksForFile(['```',
'[b](https://example.com)',
'```',
'~~~',
'[b](https://example.com)',
'~~~',
' [b](https://example.com)',
'``',
'[b](https://example.com)',
'``',
'`` ',
'',
'[b](https://example.com)',
'',
'``',
'`[b](https://example.com)`',
'[b](https://example.com)'].join('\n'));
assert.strictEqual(links.length, 2);
});
});


17 changes: 16 additions & 1 deletion extensions/markdown-language-features/test-workspace/a.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,22 @@

[./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)

``
a
asdf
``

0 comments on commit b501ae8

Please sign in to comment.