Skip to content

Commit a514979

Browse files
Prevent link highlight in markdown code blocks and spans (#140816)
* Prevent link highlight in markdown codeblocks (#139770) * Handle inline codespan variants for markdown link provider (#139770) * Refactor codespan detection in markdown link provider (#139770)
1 parent 283f478 commit a514979

File tree

4 files changed

+153
-39
lines changed

4 files changed

+153
-39
lines changed

extensions/markdown-language-features/src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ function registerMarkdownLanguageFeatures(
5555

5656
return vscode.Disposable.from(
5757
vscode.languages.registerDocumentSymbolProvider(selector, symbolProvider),
58-
vscode.languages.registerDocumentLinkProvider(selector, new LinkProvider()),
58+
vscode.languages.registerDocumentLinkProvider(selector, new LinkProvider(engine)),
5959
vscode.languages.registerFoldingRangeProvider(selector, new MarkdownFoldingProvider(engine)),
6060
vscode.languages.registerSelectionRangeProvider(selector, new MarkdownSmartSelect(engine)),
6161
vscode.languages.registerWorkspaceSymbolProvider(new MarkdownWorkspaceSymbolProvider(symbolProvider)),

extensions/markdown-language-features/src/features/documentLinkProvider.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import * as vscode from 'vscode';
77
import * as nls from 'vscode-nls';
88
import { OpenDocumentLinkCommand } from '../commands/openDocumentLink';
9+
import { MarkdownEngine } from '../markdownEngine';
910
import { getUriForLinkWithKnownExternalScheme, isOfScheme, Schemes } from '../util/links';
1011
import { dirname } from '../util/path';
1112

@@ -105,33 +106,66 @@ export function stripAngleBrackets(link: string) {
105106
const linkPattern = /(\[((!\[[^\]]*?\]\(\s*)([^\s\(\)]+?)\s*\)\]|(?:\\\]|[^\]])*\])\(\s*)(([^\s\(\)]|\([^\s\(\)]*?\))+)\s*(".*?")?\)/g;
106107
const referenceLinkPattern = /(\[((?:\\\]|[^\]])+)\]\[\s*?)([^\s\]]*?)\]/g;
107108
const definitionPattern = /^([\t ]*\[(?!\^)((?:\\\]|[^\]])+)\]:\s*)([^<]\S*|<[^>]+>)/gm;
109+
const inlineCodePattern = /(?:(?<!`)(`+)(?!`)(?:.+?|.*?(?:(?:\r?\n).+?)*?)(?:\r?\n)?(?<!`)\1(?!`))/g;
110+
111+
type CodeInDocument = {
112+
/**
113+
* code blocks and fences each represented by [line_start,line_end).
114+
*/
115+
multiline: [number, number][];
116+
/**
117+
* inline code spans each represented by {@link vscode.Range}.
118+
*/
119+
inline: vscode.Range[];
120+
};
121+
122+
async function findCode(document: vscode.TextDocument, engine: MarkdownEngine): Promise<CodeInDocument> {
123+
const tokens = await engine.parse(document);
124+
const multiline = tokens.filter(t => (t.type === 'code_block' || t.type === 'fence') && !!t.map).map(t => t.map) as [number, number][];
125+
126+
const text = document.getText();
127+
const inline = [...text.matchAll(inlineCodePattern)].map(match => {
128+
const start = match.index || 0;
129+
return new vscode.Range(document.positionAt(start), document.positionAt(start + match[0].length));
130+
});
131+
132+
return { multiline, inline };
133+
}
134+
135+
function isLinkInsideCode(code: CodeInDocument, link: vscode.DocumentLink) {
136+
return code.multiline.some(interval => link.range.start.line >= interval[0] && link.range.start.line < interval[1]) ||
137+
code.inline.some(position => position.intersection(link.range));
138+
}
108139

109140
export default class LinkProvider implements vscode.DocumentLinkProvider {
141+
constructor(
142+
private readonly engine: MarkdownEngine
143+
) { }
110144

111-
public provideDocumentLinks(
145+
public async provideDocumentLinks(
112146
document: vscode.TextDocument,
113147
_token: vscode.CancellationToken
114-
): vscode.DocumentLink[] {
148+
): Promise<vscode.DocumentLink[]> {
115149
const text = document.getText();
116-
117150
return [
118-
...this.providerInlineLinks(text, document),
151+
...(await this.providerInlineLinks(text, document)),
119152
...this.provideReferenceLinks(text, document)
120153
];
121154
}
122155

123-
private providerInlineLinks(
156+
private async providerInlineLinks(
124157
text: string,
125158
document: vscode.TextDocument,
126-
): vscode.DocumentLink[] {
159+
): Promise<vscode.DocumentLink[]> {
127160
const results: vscode.DocumentLink[] = [];
161+
const codeInDocument = await findCode(document, this.engine);
128162
for (const match of text.matchAll(linkPattern)) {
129163
const matchImage = match[4] && extractDocumentLink(document, match[3].length + 1, match[4], match.index);
130-
if (matchImage) {
164+
if (matchImage && !isLinkInsideCode(codeInDocument, matchImage)) {
131165
results.push(matchImage);
132166
}
133167
const matchLink = extractDocumentLink(document, match[1].length, match[5], match.index);
134-
if (matchLink) {
168+
if (matchLink && !isLinkInsideCode(codeInDocument, matchLink)) {
135169
results.push(matchLink);
136170
}
137171
}

extensions/markdown-language-features/src/test/documentLinkProvider.test.ts

Lines changed: 99 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ import * as assert from 'assert';
77
import 'mocha';
88
import * as vscode from 'vscode';
99
import LinkProvider from '../features/documentLinkProvider';
10+
import { createNewMarkdownEngine } from './engine';
1011
import { InMemoryDocument } from './inMemoryDocument';
11-
import { noopToken } from './util';
12+
import { joinLines, noopToken } from './util';
1213

1314

1415
const testFile = vscode.Uri.joinPath(vscode.workspace.workspaceFolders![0].uri, 'x.md');
1516

1617
function getLinksForFile(fileContents: string) {
1718
const doc = new InMemoryDocument(testFile, fileContents);
18-
const provider = new LinkProvider();
19+
const provider = new LinkProvider(createNewMarkdownEngine());
1920
return provider.provideDocumentLinks(doc, noopToken);
2021
}
2122

@@ -27,103 +28,103 @@ function assertRangeEqual(expected: vscode.Range, actual: vscode.Range) {
2728
}
2829

2930
suite('markdown.DocumentLinkProvider', () => {
30-
test('Should not return anything for empty document', () => {
31-
const links = getLinksForFile('');
31+
test('Should not return anything for empty document', async () => {
32+
const links = await getLinksForFile('');
3233
assert.strictEqual(links.length, 0);
3334
});
3435

35-
test('Should not return anything for simple document without links', () => {
36-
const links = getLinksForFile('# a\nfdasfdfsafsa');
36+
test('Should not return anything for simple document without links', async () => {
37+
const links = await getLinksForFile('# a\nfdasfdfsafsa');
3738
assert.strictEqual(links.length, 0);
3839
});
3940

40-
test('Should detect basic http links', () => {
41-
const links = getLinksForFile('a [b](https://example.com) c');
41+
test('Should detect basic http links', async () => {
42+
const links = await getLinksForFile('a [b](https://example.com) c');
4243
assert.strictEqual(links.length, 1);
4344
const [link] = links;
4445
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 25));
4546
});
4647

47-
test('Should detect basic workspace links', () => {
48+
test('Should detect basic workspace links', async () => {
4849
{
49-
const links = getLinksForFile('a [b](./file) c');
50+
const links = await getLinksForFile('a [b](./file) c');
5051
assert.strictEqual(links.length, 1);
5152
const [link] = links;
5253
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 12));
5354
}
5455
{
55-
const links = getLinksForFile('a [b](file.png) c');
56+
const links = await getLinksForFile('a [b](file.png) c');
5657
assert.strictEqual(links.length, 1);
5758
const [link] = links;
5859
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 14));
5960
}
6061
});
6162

62-
test('Should detect links with title', () => {
63-
const links = getLinksForFile('a [b](https://example.com "abc") c');
63+
test('Should detect links with title', async () => {
64+
const links = await getLinksForFile('a [b](https://example.com "abc") c');
6465
assert.strictEqual(links.length, 1);
6566
const [link] = links;
6667
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 25));
6768
});
6869

6970
// #35245
70-
test('Should handle links with escaped characters in name', () => {
71-
const links = getLinksForFile('a [b\\]](./file)');
71+
test('Should handle links with escaped characters in name', async () => {
72+
const links = await getLinksForFile('a [b\\]](./file)');
7273
assert.strictEqual(links.length, 1);
7374
const [link] = links;
7475
assertRangeEqual(link.range, new vscode.Range(0, 8, 0, 14));
7576
});
7677

7778

78-
test('Should handle links with balanced parens', () => {
79+
test('Should handle links with balanced parens', async () => {
7980
{
80-
const links = getLinksForFile('a [b](https://example.com/a()c) c');
81+
const links = await getLinksForFile('a [b](https://example.com/a()c) c');
8182
assert.strictEqual(links.length, 1);
8283
const [link] = links;
8384
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 30));
8485
}
8586
{
86-
const links = getLinksForFile('a [b](https://example.com/a(b)c) c');
87+
const links = await getLinksForFile('a [b](https://example.com/a(b)c) c');
8788
assert.strictEqual(links.length, 1);
8889
const [link] = links;
8990
assertRangeEqual(link.range, new vscode.Range(0, 6, 0, 31));
9091

9192
}
9293
{
9394
// #49011
94-
const links = getLinksForFile('[A link](http://ThisUrlhasParens/A_link(in_parens))');
95+
const links = await getLinksForFile('[A link](http://ThisUrlhasParens/A_link(in_parens))');
9596
assert.strictEqual(links.length, 1);
9697
const [link] = links;
9798
assertRangeEqual(link.range, new vscode.Range(0, 9, 0, 50));
9899
}
99100
});
100101

101-
test('Should handle two links without space', () => {
102-
const links = getLinksForFile('a ([test](test)[test2](test2)) c');
102+
test('Should handle two links without space', async () => {
103+
const links = await getLinksForFile('a ([test](test)[test2](test2)) c');
103104
assert.strictEqual(links.length, 2);
104105
const [link1, link2] = links;
105106
assertRangeEqual(link1.range, new vscode.Range(0, 10, 0, 14));
106107
assertRangeEqual(link2.range, new vscode.Range(0, 23, 0, 28));
107108
});
108109

109110
// #49238
110-
test('should handle hyperlinked images', () => {
111+
test('should handle hyperlinked images', async () => {
111112
{
112-
const links = getLinksForFile('[![alt text](image.jpg)](https://example.com)');
113+
const links = await getLinksForFile('[![alt text](image.jpg)](https://example.com)');
113114
assert.strictEqual(links.length, 2);
114115
const [link1, link2] = links;
115116
assertRangeEqual(link1.range, new vscode.Range(0, 13, 0, 22));
116117
assertRangeEqual(link2.range, new vscode.Range(0, 25, 0, 44));
117118
}
118119
{
119-
const links = getLinksForFile('[![a]( whitespace.jpg )]( https://whitespace.com )');
120+
const links = await getLinksForFile('[![a]( whitespace.jpg )]( https://whitespace.com )');
120121
assert.strictEqual(links.length, 2);
121122
const [link1, link2] = links;
122123
assertRangeEqual(link1.range, new vscode.Range(0, 7, 0, 21));
123124
assertRangeEqual(link2.range, new vscode.Range(0, 26, 0, 48));
124125
}
125126
{
126-
const links = getLinksForFile('[![a](img1.jpg)](file1.txt) text [![a](img2.jpg)](file2.txt)');
127+
const links = await getLinksForFile('[![a](img1.jpg)](file1.txt) text [![a](img2.jpg)](file2.txt)');
127128
assert.strictEqual(links.length, 4);
128129
const [link1, link2, link3, link4] = links;
129130
assertRangeEqual(link1.range, new vscode.Range(0, 6, 0, 14));
@@ -133,13 +134,13 @@ suite('markdown.DocumentLinkProvider', () => {
133134
}
134135
});
135136

136-
test('Should not consider link references starting with ^ character valid (#107471)', () => {
137-
const links = getLinksForFile('[^reference]: https://example.com');
137+
test('Should not consider link references starting with ^ character valid (#107471)', async () => {
138+
const links = await getLinksForFile('[^reference]: https://example.com');
138139
assert.strictEqual(links.length, 0);
139140
});
140141

141-
test('Should find definitions links with spaces in angle brackets (#136073)', () => {
142-
const links = getLinksForFile([
142+
test('Should find definitions links with spaces in angle brackets (#136073)', async () => {
143+
const links = await getLinksForFile([
143144
'[a]: <b c>',
144145
'[b]: <cd>',
145146
].join('\n'));
@@ -149,6 +150,75 @@ suite('markdown.DocumentLinkProvider', () => {
149150
assertRangeEqual(link1.range, new vscode.Range(0, 6, 0, 9));
150151
assertRangeEqual(link2.range, new vscode.Range(1, 6, 1, 8));
151152
});
153+
154+
test('Should not consider links in code fenced with backticks', async () => {
155+
const text = joinLines(
156+
'```',
157+
'[b](https://example.com)',
158+
'```');
159+
const links = await getLinksForFile(text);
160+
assert.strictEqual(links.length, 0);
161+
});
162+
163+
test('Should not consider links in code fenced with tilda', async () => {
164+
const text = joinLines(
165+
'~~~',
166+
'[b](https://example.com)',
167+
'~~~');
168+
const links = await getLinksForFile(text);
169+
assert.strictEqual(links.length, 0);
170+
});
171+
172+
test('Should not consider links in indented code', async () => {
173+
const links = await getLinksForFile(' [b](https://example.com)');
174+
assert.strictEqual(links.length, 0);
175+
});
176+
177+
test('Should not consider links in inline code span', async () => {
178+
const links = await getLinksForFile('`[b](https://example.com)`');
179+
assert.strictEqual(links.length, 0);
180+
});
181+
182+
test('Should not consider links with code span inside', async () => {
183+
const links = await getLinksForFile('[li`nk](https://example.com`)');
184+
assert.strictEqual(links.length, 0);
185+
});
186+
187+
test('Should not consider links in multiline inline code span', async () => {
188+
const text = joinLines(
189+
'`` ',
190+
'[b](https://example.com)',
191+
'``');
192+
const links = await getLinksForFile(text);
193+
assert.strictEqual(links.length, 0);
194+
});
195+
196+
test('Should not consider links in multiline inline code span between between text', async () => {
197+
const text = joinLines(
198+
'[b](https://1.com) `[b](https://2.com)',
199+
'` [b](https://3.com)');
200+
const links = await getLinksForFile(text);
201+
assert.deepStrictEqual(links.map(l => l.target?.authority), ['1.com', '3.com'])
202+
});
203+
204+
test('Should not consider links in multiline inline code span with new line after the first backtick', async () => {
205+
const text = joinLines(
206+
'`',
207+
'[b](https://example.com)`');
208+
const links = await getLinksForFile(text);
209+
assert.strictEqual(links.length, 0);
210+
});
211+
212+
test('Should not miss links in invalid multiline inline code span', async () => {
213+
const text = joinLines(
214+
'`` ',
215+
'',
216+
'[b](https://example.com)',
217+
'',
218+
'``');
219+
const links = await getLinksForFile(text);
220+
assert.strictEqual(links.length, 1);
221+
});
152222
});
153223

154224

extensions/markdown-language-features/test-workspace/a.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,17 @@
44

55
[./b.md](./b.md)
66

7-
[/b.md](/b.md)
7+
[/b.md](/b.md) `[/b.md](/b.md)`
88

99
[b#header1](b#header1)
1010

11+
```
12+
[b](b)
13+
```
14+
15+
~~~
16+
[b](b)
17+
~~~
18+
19+
// Indented code
20+
[b](b)

0 commit comments

Comments
 (0)