Path: blob/main/extensions/copilot/src/extension/linkify/common/modelFilePathLinkifier.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 { getWorkspaceFileDisplayPath, IWorkspaceService } from '../../../platform/workspace/common/workspaceService';7import { CancellationToken } from '../../../util/vs/base/common/cancellation';8import { normalizePath as normalizeUriPath } from '../../../util/vs/base/common/resources';9import { Location, Position, Range, Uri } from '../../../vscodeTypes';10import { coalesceParts, LinkifiedPart, LinkifiedText, LinkifyLocationAnchor } from './linkifiedText';11import { IContributedLinkifier, LinkifierContext } from './linkifyService';12import { IStatCache } from './statCache';1314// Matches markdown links where the text is a path and optional #L anchor is present15// Example: [src/file.ts](src/file.ts#L10-12) or [src/file.ts](src/file.ts)16const modelLinkRe = /\[(?<text>[^\]\n]+)\]\((?<target>[^\s)]+)\)/gu;1718export class ModelFilePathLinkifier implements IContributedLinkifier {19constructor(20private readonly workspaceService: IWorkspaceService,21private readonly statCache: IStatCache,22) { }2324async linkify(text: string, context: LinkifierContext, token: CancellationToken): Promise<LinkifiedText | undefined> {25let lastIndex = 0;26const parts: Array<LinkifiedPart | Promise<LinkifiedPart>> = [];27const workspaceFolders = this.workspaceService.getWorkspaceFolders();2829for (const match of text.matchAll(modelLinkRe)) {30const original = match[0];31const prefix = text.slice(lastIndex, match.index);32if (prefix) {33parts.push(prefix);34}35lastIndex = match.index + original.length;3637const parsed = this.parseModelLinkMatch(match);38if (!parsed) {39parts.push(original);40continue;41}4243if (!this.canLinkify(parsed, workspaceFolders)) {44parts.push(original);45continue;46}4748// Push promise to resolve in parallel with other matches49// Pass originalTargetPath to preserve platform-specific separators (e.g., c:/path vs c:\path) before Uri.file() conversion50parts.push(this.resolveTarget(parsed.targetPath, parsed.originalTargetPath, workspaceFolders, parsed.preserveDirectorySlash, token).then(resolved => {51if (!resolved) {52return original;53}5455const basePath = getWorkspaceFileDisplayPath(this.workspaceService, resolved);56const anchorRange = this.parseAnchor(parsed.anchor);57if (parsed.anchor && !anchorRange) {58return original;59}6061if (anchorRange) {62const { range, startLine, endLine } = anchorRange;63const displayPath = endLine && startLine !== endLine64? `${basePath}#L${startLine}-L${endLine}`65: `${basePath}#L${startLine}`;66return new LinkifyLocationAnchor(new Location(resolved, range), displayPath);67}6869return new LinkifyLocationAnchor(resolved, basePath);70}));71}7273const suffix = text.slice(lastIndex);74if (suffix) {75parts.push(suffix);76}7778if (!parts.length) {79return undefined;80}8182return { parts: coalesceParts(await Promise.all(parts)) };83}8485private parseModelLinkMatch(match: RegExpMatchArray): { readonly text: string; readonly targetPath: string; readonly anchor: string | undefined; readonly preserveDirectorySlash: boolean; readonly originalTargetPath: string } | undefined {86const rawText = match.groups?.['text'];87const rawTarget = match.groups?.['target'];88if (!rawText || !rawTarget) {89return undefined;90}9192const hashIndex = rawTarget.indexOf('#');93const baseTarget = hashIndex === -1 ? rawTarget : rawTarget.slice(0, hashIndex);94const anchor = hashIndex === -1 ? undefined : rawTarget.slice(hashIndex + 1);9596let decodedBase = baseTarget;97try {98decodedBase = decodeURIComponent(baseTarget);99} catch {100// noop101}102103const preserveDirectorySlash = decodedBase.endsWith('/') && decodedBase.length > 1;104const normalizedTarget = this.normalizeSlashes(decodedBase);105const normalizedText = this.normalizeLinkText(rawText);106return { text: normalizedText, targetPath: normalizedTarget, anchor, preserveDirectorySlash, originalTargetPath: decodedBase };107}108109private normalizeSlashes(value: string): string {110// Collapse one or more backslashes into a single forward slash so mixed separators normalize consistently.111return value.replace(/\\+/g, '/');112}113114private normalizeLinkText(rawText: string): string {115let text = this.normalizeSlashes(rawText);116// Remove a leading or trailing backtick that sometimes wraps the visible link label.117text = text.replace(/^`|`$/g, '');118119// Look for a trailing #L anchor segment so it can be stripped before we compare names.120const anchorMatch = /^(.+?)(#L\d+(?:-\d+)?)$/.exec(text);121return anchorMatch ? anchorMatch[1] : text;122}123124private canLinkify(parsed: { readonly text: string; readonly targetPath: string; readonly anchor: string | undefined }, workspaceFolders: readonly Uri[]): boolean {125const { text, targetPath, anchor } = parsed;126const textMatchesBase = targetPath === text;127const textIsFilename = !text.includes('/') && targetPath.endsWith(`/${text}`);128129// Allow descriptive text with anchor, but if text looks like a filename (has extension),130// it must match the target's filename to prevent linking to wrong files131let descriptiveWithAnchor = false;132if (anchor) {133const textLooksLikeFilename = /\.\w+$/.test(text);134if (textLooksLikeFilename) {135// Text looks like a filename/path - require it ends with target's basename136const targetBasename = targetPath.split('/').pop() ?? '';137const textBasename = text.split('/').pop() ?? '';138descriptiveWithAnchor = textBasename === targetBasename;139} else {140// Text is truly descriptive (e.g., "widget initialization") - allow it141descriptiveWithAnchor = true;142}143}144145return Boolean(workspaceFolders.length) && (textMatchesBase || textIsFilename || descriptiveWithAnchor);146}147148private async resolveTarget(targetPath: string, originalTargetPath: string, workspaceFolders: readonly Uri[], preserveDirectorySlash: boolean, token: CancellationToken): Promise<Uri | undefined> {149if (!workspaceFolders.length) {150return undefined;151}152153if (token.isCancellationRequested) {154return undefined;155}156157if (this.isAbsolutePath(targetPath)) {158// Choose URI construction strategy based on workspace folder schemes.159// For local (file:) workspaces we keep using Uri.file; for remote schemes we attempt160// to project the absolute path into the remote scheme preserving the folder URI's authority.161const normalizedAbs = targetPath.replace(/\\/g, '/');162163// Build candidate URIs for all workspace folders, then stat them in parallel.164const candidates: Uri[] = [];165for (const folderUri of workspaceFolders) {166if (folderUri.scheme === 'file') {167const absoluteFileUri = this.tryCreateFileUri(originalTargetPath);168if (absoluteFileUri && this.isEqualOrParent(absoluteFileUri, folderUri)) {169candidates.push(absoluteFileUri);170}171} else {172// Remote / virtual workspace: attempt to map the absolute path into the same scheme.173const folderPath = folderUri.path.replace(/\\/g, '/');174const prefix = folderPath.endsWith('/') ? folderPath : folderPath + '/';175if (normalizedAbs.startsWith(prefix)) {176candidates.push(folderUri.with({ path: normalizedAbs }));177}178}179}180181if (candidates.length) {182const results = await Promise.all(candidates.map(c => this.tryStat(c, preserveDirectorySlash, token)));183const found = results.find((r): r is Uri => r !== undefined);184if (found) {185return found;186}187}188return undefined;189}190191const segments = targetPath.split('/').filter(Boolean);192const candidates = workspaceFolders.map(folderUri => Uri.joinPath(folderUri, ...segments));193const results = await Promise.all(candidates.map(c => this.tryStat(c, preserveDirectorySlash, token)));194const found = results.find((r): r is Uri => r !== undefined);195if (found) {196return found;197}198199return undefined;200}201202private tryCreateFileUri(path: string): Uri | undefined {203try {204return Uri.file(path);205} catch {206return undefined;207}208}209210211private isEqualOrParent(target: Uri, folder: Uri): boolean {212const targetPath = normalizeUriPath(target).path;213const folderPath = normalizeUriPath(folder).path;214return targetPath === folderPath || targetPath.startsWith(folderPath.endsWith('/') ? folderPath : `${folderPath}/`);215}216217private parseAnchor(anchor: string | undefined): { readonly range: Range; readonly startLine: string; readonly endLine: string | undefined } | undefined {218// Parse supported anchor formats: L123, L123-456, L123-L456, 123, 123-456219if (!anchor) {220return undefined;221}222const match = /^L?(\d+)(?:-L?(\d+))?$/.exec(anchor);223if (!match) {224return undefined;225}226227const startLine = match[1];228const endLineRaw = match[2];229const normalizedEndLine = endLineRaw === startLine ? undefined : endLineRaw;230const start = parseInt(startLine, 10) - 1;231const end = parseInt(normalizedEndLine ?? startLine, 10) - 1;232if (Number.isNaN(start) || Number.isNaN(end) || start < 0 || end < start) {233return undefined;234}235236return {237range: new Range(new Position(start, 0), new Position(end, 0)),238startLine,239endLine: normalizedEndLine,240};241}242243private isAbsolutePath(path: string): boolean {244// Treat drive-letter prefixes (e.g. C:) or leading slashes as absolute paths.245return /^[a-z]:/i.test(path) || path.startsWith('/');246}247248private async tryStat(uri: Uri, preserveDirectorySlash: boolean, token: CancellationToken): Promise<Uri | undefined> {249if (token.isCancellationRequested) {250return undefined;251}252try {253const stat = await this.statCache.stat(uri);254if (!stat) {255return undefined;256}257if (stat.type === FileType.Directory) {258const isRoot = uri.path === '/';259const hasTrailingSlash = uri.path.endsWith('/');260const shouldHaveTrailingSlash = preserveDirectorySlash && !isRoot;261262if (shouldHaveTrailingSlash && !hasTrailingSlash) {263return uri.with({ path: `${uri.path}/` });264}265if (!shouldHaveTrailingSlash && hasTrailingSlash) {266return uri.with({ path: uri.path.slice(0, -1) });267}268}269return uri;270} catch {271return undefined;272}273}274}275276277