Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 21 additions & 30 deletions src/extension/linkify/common/filePathLinkifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ import { IContributedLinkifier, LinkifierContext } from './linkifyService';
// Create a single regex which runs different regexp parts in a big `|` expression.
const pathMatchRe = new RegExp(
[
// [path/to/file.md](path/to/file.md) or [`path/to/file.md`](path/to/file.md)
/\[(`?)(?<mdLinkText>[^`\]\)\n]+)\1\]\((?<mdLinkPath>[^`\s]+)\)/.source,

// Inline code paths
/(?<!\[)`(?<inlineCodePath>[^`\s]+)`(?!\])/.source,

Expand All @@ -35,8 +32,8 @@ const pathMatchRe = new RegExp(
* Linkifies file paths in responses. This includes:
*
* ```
* [file.md](file.md)
* `file.md`
* foo.ts
* ```
*/
export class FilePathLinkifier implements IContributedLinkifier {
Expand All @@ -58,28 +55,15 @@ export class FilePathLinkifier implements IContributedLinkifier {

const matched = match[0];

let pathText: string | undefined;

// For a md style link, require that the text and path are the same
// However we have to have extra logic since the path may be encoded: `[file name](file%20name)`
if (match.groups?.['mdLinkPath']) {
let mdLinkPath = match.groups?.['mdLinkPath'];
try {
mdLinkPath = decodeURIComponent(mdLinkPath);
} catch {
// noop
}

if (mdLinkPath !== match.groups?.['mdLinkText']) {
pathText = undefined;
} else {
pathText = mdLinkPath;
}
}
pathText ??= match.groups?.['inlineCodePath'] ?? match.groups?.['plainTextPath'] ?? '';
const pathText = match.groups?.['inlineCodePath'] ?? match.groups?.['plainTextPath'] ?? '';

parts.push(this.resolvePathText(pathText, context)
.then(uri => uri ? new LinkifyLocationAnchor(uri) : matched));
.then(uri => {
if (uri) {
return new LinkifyLocationAnchor(uri);
}
return matched;
}));

endLastMatch = match.index + matched.length;
}
Expand All @@ -93,6 +77,7 @@ export class FilePathLinkifier implements IContributedLinkifier {
}

private async resolvePathText(pathText: string, context: LinkifierContext): Promise<Uri | undefined> {
const includeDirectorySlash = pathText.endsWith('/');
const workspaceFolders = this.workspaceService.getWorkspaceFolders();

// Don't linkify very short paths such as '/' or special paths such as '../'
Expand All @@ -102,7 +87,7 @@ export class FilePathLinkifier implements IContributedLinkifier {

if (pathText.startsWith('/') || (isWindows && (pathText.startsWith('\\') || hasDriveLetter(pathText)))) {
try {
const uri = await this.statAndNormalizeUri(Uri.file(pathText.startsWith('/') ? path.posix.normalize(pathText) : path.normalize(pathText)));
const uri = await this.statAndNormalizeUri(Uri.file(pathText.startsWith('/') ? path.posix.normalize(pathText) : path.normalize(pathText)), includeDirectorySlash);
if (uri) {
if (path.posix.normalize(uri.path) === '/') {
return undefined;
Expand All @@ -121,7 +106,7 @@ export class FilePathLinkifier implements IContributedLinkifier {
try {
const uri = Uri.parse(pathText);
if (uri.scheme === Schemas.file || workspaceFolders.some(folder => folder.scheme === uri.scheme && folder.authority === uri.authority)) {
const statedUri = await this.statAndNormalizeUri(uri);
const statedUri = await this.statAndNormalizeUri(uri, includeDirectorySlash);
if (statedUri) {
return statedUri;
}
Expand All @@ -133,7 +118,7 @@ export class FilePathLinkifier implements IContributedLinkifier {
}

for (const workspaceFolder of workspaceFolders) {
const uri = await this.statAndNormalizeUri(Uri.joinPath(workspaceFolder, pathText));
const uri = await this.statAndNormalizeUri(Uri.joinPath(workspaceFolder, pathText), includeDirectorySlash);
if (uri) {
return uri;
}
Expand All @@ -154,12 +139,18 @@ export class FilePathLinkifier implements IContributedLinkifier {
return refUri;
}

private async statAndNormalizeUri(uri: Uri): Promise<Uri | undefined> {
private async statAndNormalizeUri(uri: Uri, includeDirectorySlash: boolean): Promise<Uri | undefined> {
try {
const stat = await this.fileSystem.stat(uri);
if (stat.type === FileType.Directory) {
// Ensure all dir paths have a trailing slash for icon rendering
return uri.path.endsWith('/') ? uri : uri.with({ path: `${uri.path}/` });
if (includeDirectorySlash) {
return uri.path.endsWith('/') ? uri : uri.with({ path: `${uri.path}/` });
}

if (uri.path.endsWith('/') && uri.path !== '/') {
return uri.with({ path: uri.path.slice(0, -1) });
}
return uri;
}

return uri;
Expand Down
3 changes: 3 additions & 0 deletions src/extension/linkify/common/linkifyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { PromptReference } from '../../prompt/common/conversation';
import { FilePathLinkifier } from './filePathLinkifier';
import { LinkifiedText } from './linkifiedText';
import { Linkifier } from './linkifier';
import { ModelFilePathLinkifier } from './modelFilePathLinkifier';

/**
* A stateful linkifier.
Expand Down Expand Up @@ -86,6 +87,8 @@ export class LinkifyService implements ILinkifyService {
@IWorkspaceService workspaceService: IWorkspaceService,
@IEnvService private readonly envService: IEnvService,
) {
// Model-generated links first (anchors), fallback legacy path linkifier afterwards
this.registerGlobalLinkifier({ create: () => new ModelFilePathLinkifier(fileSystem, workspaceService) });
this.registerGlobalLinkifier({ create: () => new FilePathLinkifier(fileSystem, workspaceService) });
}

Expand Down
239 changes: 239 additions & 0 deletions src/extension/linkify/common/modelFilePathLinkifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
import { FileType } from '../../../platform/filesystem/common/fileTypes';
import { getWorkspaceFileDisplayPath, IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
import { Location, Position, Range, Uri } from '../../../vscodeTypes';
import { coalesceParts, LinkifiedPart, LinkifiedText, LinkifyLocationAnchor } from './linkifiedText';
import { IContributedLinkifier, LinkifierContext } from './linkifyService';

// Matches markdown links where the text is a path and optional #L anchor is present
// Example: [src/file.ts](src/file.ts#L10-12) or [src/file.ts](src/file.ts)
const modelLinkRe = /\[(?<text>[^\]\n]+)\]\((?<target>[^\s)]+)\)/gu;

export class ModelFilePathLinkifier implements IContributedLinkifier {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's break up the existing FilePathLinkifier into the real links functionality and the inline code functionality. Maybe just delete the real links stuff from FilePathLinkifier and keep this class. I'd like to avoid the duplication though and make it so we just have one place that handles the markdown file links

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

constructor(
@IFileSystemService private readonly fileSystem: IFileSystemService,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
) { }

async linkify(text: string, context: LinkifierContext, token: CancellationToken): Promise<LinkifiedText | undefined> {
let lastIndex = 0;
const parts: Array<LinkifiedPart | Promise<LinkifiedPart>> = [];

for (const match of text.matchAll(modelLinkRe)) {
const original = match[0];
const prefix = text.slice(lastIndex, match.index);
if (prefix) {
parts.push(prefix);
}
lastIndex = match.index + original.length;

const parsed = this.parseModelLinkMatch(match);
if (!parsed) {
parts.push(original);
continue;
}

const workspaceFolders = this.workspaceService.getWorkspaceFolders();
if (!this.canLinkify(parsed, workspaceFolders)) {
parts.push(original);
continue;
}

const resolved = await this.resolveTarget(parsed.targetPath, workspaceFolders, parsed.preserveDirectorySlash);
if (!resolved) {
parts.push(original);
continue;
}

const basePath = getWorkspaceFileDisplayPath(this.workspaceService, resolved);
const anchorRange = this.parseAnchor(parsed.anchor);
if (parsed.anchor && !anchorRange) {
parts.push(original);
continue;
}

if (anchorRange) {
const { range, startLine, endLine } = anchorRange;
const displayPath = endLine && startLine !== endLine
? `${basePath}#L${startLine}-${endLine}`
: `${basePath}#L${startLine}`;
parts.push(new LinkifyLocationAnchor(new Location(resolved, range), displayPath));
continue;
}

parts.push(new LinkifyLocationAnchor(resolved, basePath));
}

const suffix = text.slice(lastIndex);
if (suffix) {
parts.push(suffix);
}

if (!parts.length) {
return undefined;
}

return { parts: coalesceParts(await Promise.all(parts)) };
}

private parseModelLinkMatch(match: RegExpMatchArray): { readonly text: string; readonly targetPath: string; readonly anchor: string | undefined; readonly preserveDirectorySlash: boolean } | undefined {
const rawText = match.groups?.['text'];
const rawTarget = match.groups?.['target'];
if (!rawText || !rawTarget) {
return undefined;
}

const hashIndex = rawTarget.indexOf('#');
const baseTarget = hashIndex === -1 ? rawTarget : rawTarget.slice(0, hashIndex);
const anchor = hashIndex === -1 ? undefined : rawTarget.slice(hashIndex + 1);

let decodedBase = baseTarget;
try {
decodedBase = decodeURIComponent(baseTarget);
} catch {
// noop
}

const preserveDirectorySlash = decodedBase.endsWith('/') && decodedBase.length > 1;
const normalizedTarget = this.normalizeSlashes(decodedBase);
const normalizedText = this.normalizeLinkText(rawText);
return { text: normalizedText, targetPath: normalizedTarget, anchor, preserveDirectorySlash };
}

private normalizeSlashes(value: string): string {
// Collapse one or more backslashes into a single forward slash so mixed separators normalize consistently.
return value.replace(/\\+/g, '/');
}

private normalizeLinkText(rawText: string): string {
let text = this.normalizeSlashes(rawText);
// Remove a leading or trailing backtick that sometimes wraps the visible link label.
text = text.replace(/^`|`$/g, '');

// Look for a trailing #L anchor segment so it can be stripped before we compare names.
const anchorMatch = /^(.+?)(#L\d+(?:-\d+)?)$/.exec(text);
return anchorMatch ? anchorMatch[1] : text;
}

private canLinkify(parsed: { readonly text: string; readonly targetPath: string; readonly anchor: string | undefined }, workspaceFolders: readonly Uri[]): boolean {
const { text, targetPath, anchor } = parsed;
const textMatchesBase = targetPath === text;
const textIsFilename = !text.includes('/') && targetPath.endsWith(`/${text}`);
const descriptiveAbsolute = this.isAbsolutePath(targetPath) && !!anchor;

return Boolean(workspaceFolders.length) && (textMatchesBase || textIsFilename || descriptiveAbsolute);
}

private async resolveTarget(targetPath: string, workspaceFolders: readonly Uri[], preserveDirectorySlash: boolean): Promise<Uri | undefined> {
if (!workspaceFolders.length) {
return undefined;
}

const folderUris = workspaceFolders.map(folder => this.toVsUri(folder));

if (this.isAbsolutePath(targetPath)) {
const absoluteUri = this.tryCreateFileUri(targetPath);
if (!absoluteUri) {
return undefined;
}

for (const folderUri of folderUris) {
if (this.isEqualOrParentFs(absoluteUri, folderUri)) {
return this.tryStat(absoluteUri, preserveDirectorySlash);
}
}
return undefined;
}

const segments = targetPath.split('/').filter(Boolean);
for (const folderUri of folderUris) {
const candidate = Uri.joinPath(folderUri, ...segments);
const stat = await this.tryStat(candidate, preserveDirectorySlash);
if (stat) {
return stat;
}
}

return undefined;
}

private tryCreateFileUri(path: string): Uri | undefined {
try {
return Uri.file(path);
} catch {
return undefined;
}
}

private toVsUri(folder: Uri): Uri {
return Uri.parse(folder.toString());
}

private isEqualOrParentFs(target: Uri, folder: Uri): boolean {
const targetFs = this.normalizeFsPath(target);
const folderFs = this.normalizeFsPath(folder);
return targetFs === folderFs || targetFs.startsWith(folderFs.endsWith('/') ? folderFs : `${folderFs}/`);
}

private normalizeFsPath(resource: Uri): string {
// Convert Windows backslashes to forward slashes and remove duplicate separators for stable comparisons.
return resource.fsPath.replace(/\\/g, '/').replace(/\/+/g, '/').toLowerCase();
}

private parseAnchor(anchor: string | undefined): { readonly range: Range; readonly startLine: string; readonly endLine: string | undefined } | undefined {
// Ensure the anchor follows the #L123 or #L123-456 format before parsing it.
if (!anchor || !/^L\d+(?:-\d+)?$/.test(anchor)) {
return undefined;
}

// Capture the start (and optional end) line numbers from the anchor.
const match = /^L(\d+)(?:-(\d+))?$/.exec(anchor);
if (!match) {
return undefined;
}

const startLine = match[1];
const endLineRaw = match[2];
const normalizedEndLine = endLineRaw === startLine ? undefined : endLineRaw;
const start = parseInt(startLine, 10) - 1;
const end = parseInt(normalizedEndLine ?? startLine, 10) - 1;
if (Number.isNaN(start) || Number.isNaN(end) || start < 0 || end < start) {
return undefined;
}

return {
range: new Range(new Position(start, 0), new Position(end, 0)),
startLine,
endLine: normalizedEndLine,
};
}

private isAbsolutePath(path: string): boolean {
// Treat drive-letter prefixes (e.g. C:) or leading slashes as absolute paths.
return /^[a-z]:/i.test(path) || path.startsWith('/');
}

private async tryStat(uri: Uri, preserveDirectorySlash: boolean): Promise<Uri | undefined> {
try {
const stat = await this.fileSystem.stat(uri);
if (stat.type === FileType.Directory) {
if (preserveDirectorySlash) {
return uri.path.endsWith('/') ? uri : uri.with({ path: `${uri.path}/` });
}
if (uri.path.endsWith('/') && uri.path !== '/') {
return uri.with({ path: uri.path.slice(0, -1) });
}
return uri;
}
return uri;
} catch {
return undefined;
}
}
}
Loading
Loading