Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/linkify/common/modelFilePathLinkifier.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 { FileType } from '../../../platform/filesystem/common/fileTypes';
7
import { getWorkspaceFileDisplayPath, IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
8
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
9
import { normalizePath as normalizeUriPath } from '../../../util/vs/base/common/resources';
10
import { Location, Position, Range, Uri } from '../../../vscodeTypes';
11
import { coalesceParts, LinkifiedPart, LinkifiedText, LinkifyLocationAnchor } from './linkifiedText';
12
import { IContributedLinkifier, LinkifierContext } from './linkifyService';
13
import { IStatCache } from './statCache';
14
15
// Matches markdown links where the text is a path and optional #L anchor is present
16
// Example: [src/file.ts](src/file.ts#L10-12) or [src/file.ts](src/file.ts)
17
const modelLinkRe = /\[(?<text>[^\]\n]+)\]\((?<target>[^\s)]+)\)/gu;
18
19
export class ModelFilePathLinkifier implements IContributedLinkifier {
20
constructor(
21
private readonly workspaceService: IWorkspaceService,
22
private readonly statCache: IStatCache,
23
) { }
24
25
async linkify(text: string, context: LinkifierContext, token: CancellationToken): Promise<LinkifiedText | undefined> {
26
let lastIndex = 0;
27
const parts: Array<LinkifiedPart | Promise<LinkifiedPart>> = [];
28
const workspaceFolders = this.workspaceService.getWorkspaceFolders();
29
30
for (const match of text.matchAll(modelLinkRe)) {
31
const original = match[0];
32
const prefix = text.slice(lastIndex, match.index);
33
if (prefix) {
34
parts.push(prefix);
35
}
36
lastIndex = match.index + original.length;
37
38
const parsed = this.parseModelLinkMatch(match);
39
if (!parsed) {
40
parts.push(original);
41
continue;
42
}
43
44
if (!this.canLinkify(parsed, workspaceFolders)) {
45
parts.push(original);
46
continue;
47
}
48
49
// Push promise to resolve in parallel with other matches
50
// Pass originalTargetPath to preserve platform-specific separators (e.g., c:/path vs c:\path) before Uri.file() conversion
51
parts.push(this.resolveTarget(parsed.targetPath, parsed.originalTargetPath, workspaceFolders, parsed.preserveDirectorySlash, token).then(resolved => {
52
if (!resolved) {
53
return original;
54
}
55
56
const basePath = getWorkspaceFileDisplayPath(this.workspaceService, resolved);
57
const anchorRange = this.parseAnchor(parsed.anchor);
58
if (parsed.anchor && !anchorRange) {
59
return original;
60
}
61
62
if (anchorRange) {
63
const { range, startLine, endLine } = anchorRange;
64
const displayPath = endLine && startLine !== endLine
65
? `${basePath}#L${startLine}-L${endLine}`
66
: `${basePath}#L${startLine}`;
67
return new LinkifyLocationAnchor(new Location(resolved, range), displayPath);
68
}
69
70
return new LinkifyLocationAnchor(resolved, basePath);
71
}));
72
}
73
74
const suffix = text.slice(lastIndex);
75
if (suffix) {
76
parts.push(suffix);
77
}
78
79
if (!parts.length) {
80
return undefined;
81
}
82
83
return { parts: coalesceParts(await Promise.all(parts)) };
84
}
85
86
private parseModelLinkMatch(match: RegExpMatchArray): { readonly text: string; readonly targetPath: string; readonly anchor: string | undefined; readonly preserveDirectorySlash: boolean; readonly originalTargetPath: string } | undefined {
87
const rawText = match.groups?.['text'];
88
const rawTarget = match.groups?.['target'];
89
if (!rawText || !rawTarget) {
90
return undefined;
91
}
92
93
const hashIndex = rawTarget.indexOf('#');
94
const baseTarget = hashIndex === -1 ? rawTarget : rawTarget.slice(0, hashIndex);
95
const anchor = hashIndex === -1 ? undefined : rawTarget.slice(hashIndex + 1);
96
97
let decodedBase = baseTarget;
98
try {
99
decodedBase = decodeURIComponent(baseTarget);
100
} catch {
101
// noop
102
}
103
104
const preserveDirectorySlash = decodedBase.endsWith('/') && decodedBase.length > 1;
105
const normalizedTarget = this.normalizeSlashes(decodedBase);
106
const normalizedText = this.normalizeLinkText(rawText);
107
return { text: normalizedText, targetPath: normalizedTarget, anchor, preserveDirectorySlash, originalTargetPath: decodedBase };
108
}
109
110
private normalizeSlashes(value: string): string {
111
// Collapse one or more backslashes into a single forward slash so mixed separators normalize consistently.
112
return value.replace(/\\+/g, '/');
113
}
114
115
private normalizeLinkText(rawText: string): string {
116
let text = this.normalizeSlashes(rawText);
117
// Remove a leading or trailing backtick that sometimes wraps the visible link label.
118
text = text.replace(/^`|`$/g, '');
119
120
// Look for a trailing #L anchor segment so it can be stripped before we compare names.
121
const anchorMatch = /^(.+?)(#L\d+(?:-\d+)?)$/.exec(text);
122
return anchorMatch ? anchorMatch[1] : text;
123
}
124
125
private canLinkify(parsed: { readonly text: string; readonly targetPath: string; readonly anchor: string | undefined }, workspaceFolders: readonly Uri[]): boolean {
126
const { text, targetPath, anchor } = parsed;
127
const textMatchesBase = targetPath === text;
128
const textIsFilename = !text.includes('/') && targetPath.endsWith(`/${text}`);
129
130
// Allow descriptive text with anchor, but if text looks like a filename (has extension),
131
// it must match the target's filename to prevent linking to wrong files
132
let descriptiveWithAnchor = false;
133
if (anchor) {
134
const textLooksLikeFilename = /\.\w+$/.test(text);
135
if (textLooksLikeFilename) {
136
// Text looks like a filename/path - require it ends with target's basename
137
const targetBasename = targetPath.split('/').pop() ?? '';
138
const textBasename = text.split('/').pop() ?? '';
139
descriptiveWithAnchor = textBasename === targetBasename;
140
} else {
141
// Text is truly descriptive (e.g., "widget initialization") - allow it
142
descriptiveWithAnchor = true;
143
}
144
}
145
146
return Boolean(workspaceFolders.length) && (textMatchesBase || textIsFilename || descriptiveWithAnchor);
147
}
148
149
private async resolveTarget(targetPath: string, originalTargetPath: string, workspaceFolders: readonly Uri[], preserveDirectorySlash: boolean, token: CancellationToken): Promise<Uri | undefined> {
150
if (!workspaceFolders.length) {
151
return undefined;
152
}
153
154
if (token.isCancellationRequested) {
155
return undefined;
156
}
157
158
if (this.isAbsolutePath(targetPath)) {
159
// Choose URI construction strategy based on workspace folder schemes.
160
// For local (file:) workspaces we keep using Uri.file; for remote schemes we attempt
161
// to project the absolute path into the remote scheme preserving the folder URI's authority.
162
const normalizedAbs = targetPath.replace(/\\/g, '/');
163
164
// Build candidate URIs for all workspace folders, then stat them in parallel.
165
const candidates: Uri[] = [];
166
for (const folderUri of workspaceFolders) {
167
if (folderUri.scheme === 'file') {
168
const absoluteFileUri = this.tryCreateFileUri(originalTargetPath);
169
if (absoluteFileUri && this.isEqualOrParent(absoluteFileUri, folderUri)) {
170
candidates.push(absoluteFileUri);
171
}
172
} else {
173
// Remote / virtual workspace: attempt to map the absolute path into the same scheme.
174
const folderPath = folderUri.path.replace(/\\/g, '/');
175
const prefix = folderPath.endsWith('/') ? folderPath : folderPath + '/';
176
if (normalizedAbs.startsWith(prefix)) {
177
candidates.push(folderUri.with({ path: normalizedAbs }));
178
}
179
}
180
}
181
182
if (candidates.length) {
183
const results = await Promise.all(candidates.map(c => this.tryStat(c, preserveDirectorySlash, token)));
184
const found = results.find((r): r is Uri => r !== undefined);
185
if (found) {
186
return found;
187
}
188
}
189
return undefined;
190
}
191
192
const segments = targetPath.split('/').filter(Boolean);
193
const candidates = workspaceFolders.map(folderUri => Uri.joinPath(folderUri, ...segments));
194
const results = await Promise.all(candidates.map(c => this.tryStat(c, preserveDirectorySlash, token)));
195
const found = results.find((r): r is Uri => r !== undefined);
196
if (found) {
197
return found;
198
}
199
200
return undefined;
201
}
202
203
private tryCreateFileUri(path: string): Uri | undefined {
204
try {
205
return Uri.file(path);
206
} catch {
207
return undefined;
208
}
209
}
210
211
212
private isEqualOrParent(target: Uri, folder: Uri): boolean {
213
const targetPath = normalizeUriPath(target).path;
214
const folderPath = normalizeUriPath(folder).path;
215
return targetPath === folderPath || targetPath.startsWith(folderPath.endsWith('/') ? folderPath : `${folderPath}/`);
216
}
217
218
private parseAnchor(anchor: string | undefined): { readonly range: Range; readonly startLine: string; readonly endLine: string | undefined } | undefined {
219
// Parse supported anchor formats: L123, L123-456, L123-L456, 123, 123-456
220
if (!anchor) {
221
return undefined;
222
}
223
const match = /^L?(\d+)(?:-L?(\d+))?$/.exec(anchor);
224
if (!match) {
225
return undefined;
226
}
227
228
const startLine = match[1];
229
const endLineRaw = match[2];
230
const normalizedEndLine = endLineRaw === startLine ? undefined : endLineRaw;
231
const start = parseInt(startLine, 10) - 1;
232
const end = parseInt(normalizedEndLine ?? startLine, 10) - 1;
233
if (Number.isNaN(start) || Number.isNaN(end) || start < 0 || end < start) {
234
return undefined;
235
}
236
237
return {
238
range: new Range(new Position(start, 0), new Position(end, 0)),
239
startLine,
240
endLine: normalizedEndLine,
241
};
242
}
243
244
private isAbsolutePath(path: string): boolean {
245
// Treat drive-letter prefixes (e.g. C:) or leading slashes as absolute paths.
246
return /^[a-z]:/i.test(path) || path.startsWith('/');
247
}
248
249
private async tryStat(uri: Uri, preserveDirectorySlash: boolean, token: CancellationToken): Promise<Uri | undefined> {
250
if (token.isCancellationRequested) {
251
return undefined;
252
}
253
try {
254
const stat = await this.statCache.stat(uri);
255
if (!stat) {
256
return undefined;
257
}
258
if (stat.type === FileType.Directory) {
259
const isRoot = uri.path === '/';
260
const hasTrailingSlash = uri.path.endsWith('/');
261
const shouldHaveTrailingSlash = preserveDirectorySlash && !isRoot;
262
263
if (shouldHaveTrailingSlash && !hasTrailingSlash) {
264
return uri.with({ path: `${uri.path}/` });
265
}
266
if (!shouldHaveTrailingSlash && hasTrailingSlash) {
267
return uri.with({ path: uri.path.slice(0, -1) });
268
}
269
}
270
return uri;
271
} catch {
272
return undefined;
273
}
274
}
275
}
276
277