Skip to content

Commit 66b4bf7

Browse files
authored
Don't run linkifier for math code blocks (#273)
* Don't run linkifier for math code blocks For microsoft/vscode#255754 * Fix a few edge cases
1 parent de822c7 commit 66b4bf7

File tree

3 files changed

+183
-44
lines changed

3 files changed

+183
-44
lines changed

src/extension/linkify/common/linkifier.ts

Lines changed: 84 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,36 @@
55

66
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
77
import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors';
8+
import { escapeRegExpCharacters } from '../../../util/vs/base/common/strings';
89
import { LinkifiedPart, LinkifiedText, coalesceParts } from './linkifiedText';
910
import type { IContributedLinkifier, ILinkifier, LinkifierContext } from './linkifyService';
1011

1112
namespace LinkifierState {
1213
export enum Type {
1314
Default,
14-
CodeBlock,
15+
CodeOrMathBlock,
1516
Accumulating,
1617
}
1718

1819
export enum AccumulationType {
1920
Word,
20-
InlineCode,
21+
InlineCodeOrMath,
2122
PotentialLink,
2223
}
2324

2425
export const Default = { type: Type.Default } as const;
2526

26-
export class CodeBlock {
27-
readonly type = Type.CodeBlock;
27+
export class CodeOrMathBlock {
28+
readonly type = Type.CodeOrMathBlock;
2829

2930
constructor(
3031
public readonly fence: string,
3132
public readonly indent: string,
3233
public readonly contents = '',
3334
) { }
3435

35-
appendContents(text: string): CodeBlock {
36-
return new CodeBlock(this.fence, this.indent, this.contents + text);
36+
appendContents(text: string): CodeOrMathBlock {
37+
return new CodeOrMathBlock(this.fence, this.indent, this.contents + text);
3738
}
3839
}
3940

@@ -43,10 +44,15 @@ namespace LinkifierState {
4344
constructor(
4445
public readonly pendingText: string,
4546
public readonly accumulationType = LinkifierState.AccumulationType.Word,
47+
public readonly terminator?: string,
4648
) { }
49+
50+
append(text: string): Accumulating {
51+
return new Accumulating(this.pendingText + text, this.accumulationType, this.terminator);
52+
}
4753
}
4854

49-
export type State = typeof Default | CodeBlock | Accumulating;
55+
export type State = typeof Default | CodeOrMathBlock | Accumulating;
5056
}
5157

5258
/**
@@ -91,7 +97,21 @@ export class Linkifier implements ILinkifier {
9197

9298
// `text...
9399
if (/^[^\[`]*`[^`]*$/.test(part)) {
94-
this._state = new LinkifierState.Accumulating(part, LinkifierState.AccumulationType.InlineCode);
100+
this._state = new LinkifierState.Accumulating(part, LinkifierState.AccumulationType.InlineCodeOrMath, '`');
101+
}
102+
// `text`
103+
else if (/^`[^`]+`$/.test(part)) {
104+
// No linkifying inside inline code
105+
out.push(...(await this.doLinkifyAndAppend(part, { skipUnlikify: true }, token)).parts);
106+
}
107+
// $text...
108+
else if (/^[^\[`]*\$[^\$]*$/.test(part)) {
109+
this._state = new LinkifierState.Accumulating(part, LinkifierState.AccumulationType.InlineCodeOrMath, '$');
110+
}
111+
// $text$
112+
else if (/^[^\[`]*\$[^\$]*\$$/.test(part)) {
113+
// No linkifying inside math code
114+
out.push(this.doAppend(part));
95115
}
96116
// [text...
97117
else if (/^\s*\[[^\]]*$/.test(part)) {
@@ -104,10 +124,10 @@ export class Linkifier implements ILinkifier {
104124
}
105125
break;
106126
}
107-
case LinkifierState.Type.CodeBlock: {
127+
case LinkifierState.Type.CodeOrMathBlock: {
108128
if (
109-
new RegExp('(^|\\n)' + this._state.fence + '($|\\n)').test(part)
110-
|| (this._state.contents.length > 2 && new RegExp('(^|\\n)\\s*' + this._state.fence + '($|\\n\\s*$)').test(this._appliedText + part))
129+
new RegExp('(^|\\n)' + escapeRegExpCharacters(this._state.fence) + '($|\\n)').test(part)
130+
|| (this._state.contents.length > 2 && new RegExp('(^|\\n)\\s*' + escapeRegExpCharacters(this._state.fence) + '($|\\n\\s*$)').test(this._appliedText + part))
111131
) {
112132
// To end the code block, the previous text needs to be empty up the start of the last line and
113133
// at lower indentation than the opening code block.
@@ -126,45 +146,66 @@ export class Linkifier implements ILinkifier {
126146
break;
127147
}
128148
case LinkifierState.Type.Accumulating: {
129-
const completeWord = async (state: LinkifierState.Accumulating) => {
130-
const toAppend = state.pendingText + part;
149+
const completeWord = async (state: LinkifierState.Accumulating, inPart: string, skipUnlikify: boolean) => {
150+
const toAppend = state.pendingText + inPart;
131151
this._state = LinkifierState.Default;
132-
const r = await this.doLinkifyAndAppend(toAppend, token);
152+
const r = await this.doLinkifyAndAppend(toAppend, { skipUnlikify }, token);
133153
out.push(...r.parts);
134154
};
135155

136156
if (this._state.accumulationType === LinkifierState.AccumulationType.PotentialLink) {
137157
if (/]/.test(part)) {
138-
this._state = new LinkifierState.Accumulating(this._state.pendingText + part, LinkifierState.AccumulationType.Word);
158+
this._state = this._state.append(part);
139159
break;
140160
} else if (/\n/.test(part)) {
141-
await completeWord(this._state);
161+
await completeWord(this._state, part, false);
142162
break;
143163
}
144-
} else if (this._state.accumulationType === LinkifierState.AccumulationType.InlineCode && /`/.test(part)) {
145-
await completeWord(this._state);
164+
} else if (this._state.accumulationType === LinkifierState.AccumulationType.InlineCodeOrMath && new RegExp(escapeRegExpCharacters(this._state.terminator ?? '`')).test(part)) {
165+
const terminator = this._state.terminator ?? '`';
166+
const terminalIndex = part.indexOf(terminator);
167+
if (terminalIndex === -1) {
168+
await completeWord(this._state, part, true);
169+
} else {
170+
if (terminator === '`') {
171+
await completeWord(this._state, part, true);
172+
} else {
173+
// Math shouldn't run linkifies
174+
175+
const pre = part.slice(0, terminalIndex + terminator.length);
176+
// No linkifying inside inline math
177+
out.push(this.doAppend(this._state.pendingText + pre));
178+
179+
// But we can linkify after
180+
const rest = part.slice(terminalIndex + terminator.length);
181+
this._state = LinkifierState.Default;
182+
if (rest.length) {
183+
out.push(...(await this.doLinkifyAndAppend(rest, { skipUnlikify: true }, token)).parts);
184+
}
185+
}
186+
}
146187
break;
147188
} else if (this._state.accumulationType === LinkifierState.AccumulationType.Word && /\s/.test(part)) {
148189
const toAppend = this._state.pendingText + part;
149190
this._state = LinkifierState.Default;
150191

151192
// Check if we've found special tokens
152-
const fence = toAppend.match(/(^|\n)\s*(`{3,}|~{3,})/);
193+
const fence = toAppend.match(/(^|\n)\s*(`{3,}|~{3,}|\$\$)/);
153194
if (fence) {
154195
const indent = this._appliedText.match(/(\n|^)([ \t]*)$/);
155-
this._state = new LinkifierState.CodeBlock(fence[2], indent?.[2] ?? '');
196+
this._state = new LinkifierState.CodeOrMathBlock(fence[2], indent?.[2] ?? '');
156197
out.push(this.doAppend(toAppend));
157198
}
158199
else {
159-
const r = await this.doLinkifyAndAppend(toAppend, token);
200+
const r = await this.doLinkifyAndAppend(toAppend, {}, token);
160201
out.push(...r.parts);
161202
}
162203

163204
break;
164205
}
165206

166207
// Keep accumulating
167-
this._state = new LinkifierState.Accumulating(this._state.pendingText + part, this._state.accumulationType);
208+
this._state = this._state.append(part);
168209
break;
169210
}
170211
}
@@ -176,13 +217,13 @@ export class Linkifier implements ILinkifier {
176217
let out: LinkifiedText | undefined;
177218

178219
switch (this._state.type) {
179-
case LinkifierState.Type.CodeBlock: {
220+
case LinkifierState.Type.CodeOrMathBlock: {
180221
out = { parts: [this.doAppend(this._state.contents)] };
181222
break;
182223
}
183224
case LinkifierState.Type.Accumulating: {
184225
const toAppend = this._state.pendingText;
185-
out = await this.doLinkifyAndAppend(toAppend, token);
226+
out = await this.doLinkifyAndAppend(toAppend, {}, token);
186227
break;
187228
}
188229
}
@@ -196,7 +237,11 @@ export class Linkifier implements ILinkifier {
196237
return newText;
197238
}
198239

199-
private async doLinkifyAndAppend(newText: string, token: CancellationToken): Promise<LinkifiedText> {
240+
private async doLinkifyAndAppend(newText: string, options: { skipUnlikify?: boolean }, token: CancellationToken): Promise<LinkifiedText> {
241+
if (newText.length === 0) {
242+
return { parts: [] };
243+
}
244+
200245
this.doAppend(newText);
201246

202247
// Run contributed linkifiers
@@ -210,19 +255,21 @@ export class Linkifier implements ILinkifier {
210255

211256
// Do a final pass that un-linkifies any file links that don't have a scheme.
212257
// This prevents links like: [some text](index.html) from sneaking through as these can never be opened properly.
213-
parts = parts.map(part => {
214-
if (typeof part === 'string') {
215-
return part.replaceAll(/\[([^\[\]]+)\]\(([^\s\)]+)\)/g, (matched, text, path) => {
216-
// Always preserve product URI scheme links
217-
if (path.startsWith(this.productUriScheme + ':')) {
218-
return matched;
219-
}
258+
if (!options.skipUnlikify) {
259+
parts = parts.map(part => {
260+
if (typeof part === 'string') {
261+
return part.replaceAll(/\[([^\[\]]+)\]\(([^\s\)]+)\)/g, (matched, text, path) => {
262+
// Always preserve product URI scheme links
263+
if (path.startsWith(this.productUriScheme + ':')) {
264+
return matched;
265+
}
220266

221-
return /^\w+:/.test(path) ? matched : text;
222-
});
223-
}
224-
return part;
225-
});
267+
return /^\w+:/.test(path) ? matched : text;
268+
});
269+
}
270+
return part;
271+
});
272+
}
226273

227274
this._totalAddedLinkCount += parts.filter(part => typeof part !== 'string').length;
228275
return { parts };

src/extension/linkify/test/node/filePathLinkifier.spec.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,15 @@ suite('File Path Linkifier', () => {
153153
'sub space/space file.ts',
154154
);
155155

156+
const result = await linkify(linkifier, [
157+
'[space file.ts](space%20file.ts)',
158+
'[sub space/space file.ts](sub%20space/space%20file.ts)',
159+
'[no such file.ts](no%20such%20file.ts)',
160+
'[also not.ts](no%20such%20file.ts)',
161+
].join('\n')
162+
);
156163
assertPartsEqual(
157-
(await linkify(linkifier, [
158-
'[space file.ts](space%20file.ts)',
159-
'[sub space/space file.ts](sub%20space/space%20file.ts)',
160-
'[no such file.ts](no%20such%20file.ts)',
161-
'[also not.ts](no%20such%20file.ts)',
162-
].join('\n')
163-
)).parts,
164+
result.parts,
164165
[
165166
new LinkifyLocationAnchor(workspaceFile('space file.ts')),
166167
`\n`,

src/extension/linkify/test/node/linkifier.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,4 +310,95 @@ suite('Stateful Linkifier', () => {
310310
'text `text`'
311311
]);
312312
});
313+
314+
test(`Should not unlinkify text inside of code blocks`, async () => {
315+
const linkifier = createTestLinkifierService().createLinkifier(emptyContext);
316+
317+
const parts: string[] = [
318+
'```md\n',
319+
`[g](x)\n`,
320+
'```',
321+
];
322+
323+
const result = await runLinkifier(linkifier, parts);
324+
assertPartsEqual(result, [
325+
[
326+
'```md\n',
327+
`[g](x)\n`,
328+
'```'
329+
].join('')
330+
]);
331+
});
332+
333+
test(`Should not unlikify text inside of inline code`, async () => {
334+
{
335+
const linkifier = createTestLinkifierService().createLinkifier(emptyContext);
336+
const result = await runLinkifier(linkifier, [
337+
'a `J[g](x)` b',
338+
]);
339+
assertPartsEqual(result, [
340+
'a `J[g](x)` b'
341+
]);
342+
}
343+
{
344+
const linkifier = createTestLinkifierService().createLinkifier(emptyContext);
345+
const result = await runLinkifier(linkifier, [
346+
'a `b [c](d) e` f',
347+
]);
348+
assertPartsEqual(result, [
349+
'a `b [c](d) e` f'
350+
]);
351+
}
352+
});
353+
354+
test(`Should not unlikify text inside of math blocks code`, async () => {
355+
{
356+
const linkifier = createTestLinkifierService(
357+
'file1.ts',
358+
'file2.ts',
359+
).createLinkifier(emptyContext);
360+
361+
const result = await runLinkifier(linkifier, [
362+
'[file1.ts](file1.ts)\n',
363+
'$$\n',
364+
`J[g](x)\n`,
365+
'$$\n',
366+
'[file2.ts](file2.ts)'
367+
]);
368+
assertPartsEqual(result, [
369+
new LinkifyLocationAnchor(workspaceFile('file1.ts')),
370+
[
371+
'',
372+
'$$',
373+
'J[g](x)',
374+
'$$',
375+
'',
376+
].join('\n'),
377+
new LinkifyLocationAnchor(workspaceFile('file2.ts')),
378+
]);
379+
}
380+
});
381+
382+
test(`Should not touch code inside of inline math equations`, async () => {
383+
{
384+
const linkifier = createTestLinkifierService().createLinkifier(emptyContext);
385+
386+
const result = await runLinkifier(linkifier, [
387+
'a $J[g](x)$ b',
388+
]);
389+
assertPartsEqual(result, [
390+
'a $J[g](x)$ b'
391+
]);
392+
}
393+
{
394+
const linkifier = createTestLinkifierService().createLinkifier(emptyContext);
395+
396+
const result = await runLinkifier(linkifier, [
397+
'a $c [g](x) d$ x',
398+
]);
399+
assertPartsEqual(result, [
400+
'a $c [g](x) d$ x',
401+
]);
402+
}
403+
});
313404
});

0 commit comments

Comments
 (0)