Path: blob/main/extensions/copilot/src/extension/linkify/common/filePathLinkifier.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { FileType } from '../../../platform/filesystem/common/fileTypes';6import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';7import { CancellationToken } from '../../../util/vs/base/common/cancellation';8import { hasDriveLetter } from '../../../util/vs/base/common/extpath';9import { Schemas } from '../../../util/vs/base/common/network';10import * as path from '../../../util/vs/base/common/path';11import { isWindows } from '../../../util/vs/base/common/platform';12import * as resources from '../../../util/vs/base/common/resources';13import { isUriComponents } from '../../../util/vs/base/common/uri';14import { Uri } from '../../../vscodeTypes';15import { coalesceParts, LinkifiedPart, LinkifiedText, LinkifyLocationAnchor } from './linkifiedText';16import { IContributedLinkifier, LinkifierContext } from './linkifyService';17import { IStatCache } from './statCache';1819// Create a single regex which runs different regexp parts in a big `|` expression.20const pathMatchRe = new RegExp(21[22// Inline code paths (exclude code-like characters $, {, }, that are common in code but rare in filenames)23/(?<!\[)`(?<inlineCodePath>[^`\s${}]+)`(?!\])/.source,2425// File paths rendered as plain text (exclude code-like characters)26/(?<![\[`()<])(?<plainTextPath>[^\s`*${}()]+\.[^\s`*${}()]+)(?![\]`])/.source27].join('|'),28'gu');2930/**31* Linkifies file paths in responses. This includes:32*33* ```34* `file.md`35* foo.ts36* ```37*/38export class FilePathLinkifier implements IContributedLinkifier {3940constructor(41private readonly workspaceService: IWorkspaceService,42private readonly statCache: IStatCache,43) { }4445async linkify(text: string, context: LinkifierContext, token: CancellationToken): Promise<LinkifiedText> {46const parts: Array<Promise<LinkifiedPart> | LinkifiedPart> = [];4748let endLastMatch = 0;49for (const match of text.matchAll(pathMatchRe)) {50const prefix = text.slice(endLastMatch, match.index);51if (prefix) {52parts.push(prefix);53}5455const matched = match[0];5657const pathText = match.groups?.['inlineCodePath'] ?? match.groups?.['plainTextPath'] ?? '';5859parts.push(this.resolvePathText(pathText, context)60.then(uri => {61if (uri) {62return new LinkifyLocationAnchor(uri);63}64return matched;65}));6667endLastMatch = match.index + matched.length;68}6970const suffix = text.slice(endLastMatch);71if (suffix) {72parts.push(suffix);73}7475return { parts: coalesceParts(await Promise.all(parts)) };76}7778private async resolvePathText(pathText: string, context: LinkifierContext): Promise<Uri | undefined> {79const includeDirectorySlash = pathText.endsWith('/');80const workspaceFolders = this.workspaceService.getWorkspaceFolders();8182// Don't linkify very short paths such as '/' or special paths such as '../'83if (pathText.length < 2 || ['../', '..\\', '/.', './', '\\.', '..'].includes(pathText)) {84return;85}8687if (pathText.startsWith('/') || (isWindows && (pathText.startsWith('\\') || hasDriveLetter(pathText)))) {88try {89const uri = await this.statAndNormalizeUri(Uri.file(pathText.startsWith('/') ? path.posix.normalize(pathText) : path.normalize(pathText)), includeDirectorySlash);90if (uri) {91if (path.posix.normalize(uri.path) === '/') {92return undefined;93}9495return uri;96}97} catch {98// noop99}100}101102// Handle paths that look like uris103const scheme = pathText.match(/^([a-z]+):/i)?.[1];104if (scheme) {105try {106const uri = Uri.parse(pathText);107if (uri.scheme === Schemas.file || workspaceFolders.some(folder => folder.scheme === uri.scheme && folder.authority === uri.authority)) {108const statedUri = await this.statAndNormalizeUri(uri, includeDirectorySlash);109if (statedUri) {110return statedUri;111}112}113} catch {114// Noop, parsing error115}116return;117}118119const result = await this.resolveInWorkspaceFolders(workspaceFolders, pathText, includeDirectorySlash);120if (result) {121return result;122}123124// Then fallback to checking references based on filename.125// Only do this for simple filenames without directory components - if the user126// specified a path like `./node_modules/cli.js`, we shouldn't match a reference127// with a completely different path just because the basename matches.128// Also skip if text contains code-like characters that are rarely in real filenames.129if (!pathText.includes('/') && !pathText.includes('\\') && !/[${}()]/.test(pathText)) {130const name = path.basename(pathText);131const refUri = context.references132.map(ref => {133if ('variableName' in ref.anchor) {134return isUriComponents(ref.anchor.value) ? ref.anchor.value : ref.anchor.value?.uri;135}136return isUriComponents(ref.anchor) ? ref.anchor : ref.anchor.uri;137})138.filter((item): item is Uri => !!item)139.find(refUri => resources.basename(refUri) === name);140141return refUri;142}143144return undefined;145}146147private async resolveInWorkspaceFolders(workspaceFolders: readonly Uri[], pathText: string, includeDirectorySlash: boolean): Promise<Uri | undefined> {148const candidates = workspaceFolders.map(folder => Uri.joinPath(folder, pathText));149const results = await Promise.all(candidates.map(uri => this.statAndNormalizeUri(uri, includeDirectorySlash)));150return results.find((r): r is Uri => r !== undefined);151}152153private async statAndNormalizeUri(uri: Uri, includeDirectorySlash: boolean): Promise<Uri | undefined> {154try {155const stat = await this.statCache.stat(uri);156if (!stat) {157return undefined;158}159if (stat.type === FileType.Directory) {160if (includeDirectorySlash) {161return uri.path.endsWith('/') ? uri : uri.with({ path: `${uri.path}/` });162}163164if (uri.path.endsWith('/') && uri.path !== '/') {165return uri.with({ path: uri.path.slice(0, -1) });166}167return uri;168}169170return uri;171} catch {172return undefined;173}174}175}176177178