Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/linkify/common/linkifier.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
7
import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors';
8
import { escapeRegExpCharacters } from '../../../util/vs/base/common/strings';
9
import { LinkifiedPart, LinkifiedText, coalesceParts } from './linkifiedText';
10
import type { IContributedLinkifier, ILinkifier, LinkifierContext } from './linkifyService';
11
12
namespace LinkifierState {
13
export enum Type {
14
Default,
15
CodeOrMathBlock,
16
Accumulating,
17
}
18
19
export enum AccumulationType {
20
Word,
21
InlineCodeOrMath,
22
PotentialLink,
23
}
24
25
export const Default = { type: Type.Default } as const;
26
27
export class CodeOrMathBlock {
28
readonly type = Type.CodeOrMathBlock;
29
30
constructor(
31
public readonly fence: string,
32
public readonly indent: string,
33
public readonly contents = '',
34
) { }
35
36
appendContents(text: string): CodeOrMathBlock {
37
return new CodeOrMathBlock(this.fence, this.indent, this.contents + text);
38
}
39
}
40
41
export class Accumulating {
42
readonly type = LinkifierState.Type.Accumulating;
43
44
constructor(
45
public readonly pendingText: string,
46
public readonly accumulationType = LinkifierState.AccumulationType.Word,
47
public readonly terminator?: string,
48
) { }
49
50
append(text: string): Accumulating {
51
return new Accumulating(this.pendingText + text, this.accumulationType, this.terminator);
52
}
53
}
54
55
export type State = typeof Default | CodeOrMathBlock | Accumulating;
56
}
57
58
/**
59
* Stateful linkifier that incrementally linkifies appended text.
60
*
61
* Make sure to create a new linkifier for each response.
62
*/
63
export class Linkifier implements ILinkifier {
64
65
private _state: LinkifierState.State = LinkifierState.Default;
66
private _appliedText = '';
67
68
private _totalAddedLinkCount = 0;
69
70
constructor(
71
private readonly context: LinkifierContext,
72
private readonly productUriScheme: string,
73
private readonly linkifiers: readonly IContributedLinkifier[] = [],
74
) { }
75
76
get totalAddedLinkCount(): number {
77
return this._totalAddedLinkCount;
78
}
79
80
async append(newText: string, token: CancellationToken): Promise<LinkifiedText> {
81
// Linkification needs to run on whole sequences of characters. However the incoming stream may be broken up.
82
// To handle this, accumulate text until we have whole tokens.
83
84
const out: LinkifiedPart[] = [];
85
86
for (const part of newText.split(/(\s+)/)) {
87
if (!part.length) {
88
continue;
89
}
90
91
switch (this._state.type) {
92
case LinkifierState.Type.Default: {
93
if (/^\s+$/.test(part)) {
94
out.push(this.doAppend(part));
95
} else {
96
// Start accumulating
97
98
// `text...
99
if (/^[^\[`]*`[^`]*$/.test(part)) {
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));
115
}
116
// [text...
117
else if (/^\s*\[[^\]]*$/.test(part)) {
118
this._state = new LinkifierState.Accumulating(part, LinkifierState.AccumulationType.PotentialLink);
119
}
120
// Plain old word
121
else {
122
this._state = new LinkifierState.Accumulating(part);
123
}
124
}
125
break;
126
}
127
case LinkifierState.Type.CodeOrMathBlock: {
128
if (
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))
131
) {
132
// To end the code block, the previous text needs to be empty up the start of the last line and
133
// at lower indentation than the opening code block.
134
const indent = this._appliedText.match(/(\n|^)([ \t]*)[`~]*$/);
135
if (indent && indent[2].length <= this._state.indent.length) {
136
this._state = LinkifierState.Default;
137
out.push(this.doAppend(part));
138
break;
139
}
140
}
141
142
this._state = this._state.appendContents(part);
143
144
// No linkifying inside code blocks
145
out.push(this.doAppend(part));
146
break;
147
}
148
case LinkifierState.Type.Accumulating: {
149
const completeWord = async (state: LinkifierState.Accumulating, inPart: string, skipUnlikify: boolean) => {
150
const toAppend = state.pendingText + inPart;
151
this._state = LinkifierState.Default;
152
const r = await this.doLinkifyAndAppend(toAppend, { skipUnlikify }, token);
153
out.push(...r.parts);
154
};
155
156
if (this._state.accumulationType === LinkifierState.AccumulationType.PotentialLink) {
157
if (/]/.test(part)) {
158
this._state = this._state.append(part);
159
break;
160
} else if (/\n/.test(part)) {
161
await completeWord(this._state, part, false);
162
break;
163
}
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
}
187
break;
188
} else if (this._state.accumulationType === LinkifierState.AccumulationType.Word && /\s/.test(part)) {
189
const toAppend = this._state.pendingText + part;
190
this._state = LinkifierState.Default;
191
192
// Check if we've found special tokens
193
const fence = toAppend.match(/(^|\n)\s*(`{3,}|~{3,}|\$\$)/);
194
if (fence) {
195
const indent = this._appliedText.match(/(\n|^)([ \t]*)$/);
196
this._state = new LinkifierState.CodeOrMathBlock(fence[2], indent?.[2] ?? '');
197
out.push(this.doAppend(toAppend));
198
}
199
else {
200
const r = await this.doLinkifyAndAppend(toAppend, {}, token);
201
out.push(...r.parts);
202
}
203
204
break;
205
}
206
207
// Keep accumulating
208
this._state = this._state.append(part);
209
break;
210
}
211
}
212
}
213
return { parts: coalesceParts(out) };
214
}
215
216
async flush(token: CancellationToken): Promise<LinkifiedText | undefined> {
217
let out: LinkifiedText | undefined;
218
219
switch (this._state.type) {
220
case LinkifierState.Type.CodeOrMathBlock: {
221
out = { parts: [this.doAppend(this._state.contents)] };
222
break;
223
}
224
case LinkifierState.Type.Accumulating: {
225
const toAppend = this._state.pendingText;
226
out = await this.doLinkifyAndAppend(toAppend, {}, token);
227
break;
228
}
229
}
230
231
this._state = LinkifierState.Default;
232
return out;
233
}
234
235
private doAppend(newText: string): string {
236
this._appliedText = this._appliedText + newText;
237
return newText;
238
}
239
240
private async doLinkifyAndAppend(newText: string, options: { skipUnlikify?: boolean }, token: CancellationToken): Promise<LinkifiedText> {
241
if (newText.length === 0) {
242
return { parts: [] };
243
}
244
245
this.doAppend(newText);
246
247
// Run contributed linkifiers
248
let parts: LinkifiedPart[] = [newText];
249
for (const linkifier of this.linkifiers) {
250
parts = coalesceParts(await this.runLinkifier(parts, linkifier, token));
251
if (token.isCancellationRequested) {
252
throw new CancellationError();
253
}
254
}
255
256
// Do a final pass that un-linkifies any file links that don't have a scheme.
257
// This prevents links like: [some text](index.html) from sneaking through as these can never be opened properly.
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
}
266
267
return /^\w{2,}:/.test(path) ? matched : text;
268
});
269
}
270
return part;
271
});
272
}
273
274
this._totalAddedLinkCount += parts.filter(part => typeof part !== 'string').length;
275
return { parts };
276
}
277
278
private async runLinkifier(parts: readonly LinkifiedPart[], linkifier: IContributedLinkifier, token: CancellationToken): Promise<LinkifiedPart[]> {
279
const out: LinkifiedPart[] = [];
280
for (const part of parts) {
281
if (token.isCancellationRequested) {
282
throw new CancellationError();
283
}
284
285
if (typeof part === 'string') {
286
let linkified: LinkifiedText | undefined;
287
try {
288
linkified = await linkifier.linkify(part, this.context, token);
289
} catch (e) {
290
if (!isCancellationError(e)) {
291
console.error(e);
292
}
293
out.push(part);
294
continue;
295
}
296
297
if (linkified) {
298
out.push(...linkified.parts);
299
} else {
300
out.push(part);
301
}
302
} else {
303
out.push(part);
304
}
305
}
306
return out;
307
}
308
}
309
310