Skip to content

Commit 2375416

Browse files
vijayupadyavijay upadyamjbvz
authored
Upstream model generated file and line linkification (#1803)
* Upstream model generated file/line Linkification * CP feedback updated * simplify prompt * Optimization and consolidation * GCP feedback updates * Update prompt snapshot. * few prompt tweaks * PR feedback updates * Move fileLinkifiction instruction to correct location * Remove from default * update gpt prompts * prompt and snapshot updates * Feedback updates * Minor updates based on feedback * Update snapshot * minor update * more snapshot updates * snapshot updates * minor optimization * Simplification * update snap files * Simplify prompt text and update snap * Minor prompt readjustment * Prompt updates to make them consistent across models * Update snapshots --------- Co-authored-by: vijay upadya <vj@example.com> Co-authored-by: Matt Bierner <12821956+mjbvz@users.noreply.github.com>
1 parent 149a4fb commit 2375416

File tree

97 files changed

+3571
-518
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

97 files changed

+3571
-518
lines changed

src/extension/linkify/common/filePathLinkifier.ts

Lines changed: 21 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@ import { IContributedLinkifier, LinkifierContext } from './linkifyService';
2020
// Create a single regex which runs different regexp parts in a big `|` expression.
2121
const pathMatchRe = new RegExp(
2222
[
23-
// [path/to/file.md](path/to/file.md) or [`path/to/file.md`](path/to/file.md)
24-
/\[(`?)(?<mdLinkText>[^`\]\)\n]+)\1\]\((?<mdLinkPath>[^`\s]+)\)/.source,
25-
2623
// Inline code paths
2724
/(?<!\[)`(?<inlineCodePath>[^`\s]+)`(?!\])/.source,
2825

@@ -35,8 +32,8 @@ const pathMatchRe = new RegExp(
3532
* Linkifies file paths in responses. This includes:
3633
*
3734
* ```
38-
* [file.md](file.md)
3935
* `file.md`
36+
* foo.ts
4037
* ```
4138
*/
4239
export class FilePathLinkifier implements IContributedLinkifier {
@@ -58,28 +55,15 @@ export class FilePathLinkifier implements IContributedLinkifier {
5855

5956
const matched = match[0];
6057

61-
let pathText: string | undefined;
62-
63-
// For a md style link, require that the text and path are the same
64-
// However we have to have extra logic since the path may be encoded: `[file name](file%20name)`
65-
if (match.groups?.['mdLinkPath']) {
66-
let mdLinkPath = match.groups?.['mdLinkPath'];
67-
try {
68-
mdLinkPath = decodeURIComponent(mdLinkPath);
69-
} catch {
70-
// noop
71-
}
72-
73-
if (mdLinkPath !== match.groups?.['mdLinkText']) {
74-
pathText = undefined;
75-
} else {
76-
pathText = mdLinkPath;
77-
}
78-
}
79-
pathText ??= match.groups?.['inlineCodePath'] ?? match.groups?.['plainTextPath'] ?? '';
58+
const pathText = match.groups?.['inlineCodePath'] ?? match.groups?.['plainTextPath'] ?? '';
8059

8160
parts.push(this.resolvePathText(pathText, context)
82-
.then(uri => uri ? new LinkifyLocationAnchor(uri) : matched));
61+
.then(uri => {
62+
if (uri) {
63+
return new LinkifyLocationAnchor(uri);
64+
}
65+
return matched;
66+
}));
8367

8468
endLastMatch = match.index + matched.length;
8569
}
@@ -93,6 +77,7 @@ export class FilePathLinkifier implements IContributedLinkifier {
9377
}
9478

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

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

10388
if (pathText.startsWith('/') || (isWindows && (pathText.startsWith('\\') || hasDriveLetter(pathText)))) {
10489
try {
105-
const uri = await this.statAndNormalizeUri(Uri.file(pathText.startsWith('/') ? path.posix.normalize(pathText) : path.normalize(pathText)));
90+
const uri = await this.statAndNormalizeUri(Uri.file(pathText.startsWith('/') ? path.posix.normalize(pathText) : path.normalize(pathText)), includeDirectorySlash);
10691
if (uri) {
10792
if (path.posix.normalize(uri.path) === '/') {
10893
return undefined;
@@ -121,7 +106,7 @@ export class FilePathLinkifier implements IContributedLinkifier {
121106
try {
122107
const uri = Uri.parse(pathText);
123108
if (uri.scheme === Schemas.file || workspaceFolders.some(folder => folder.scheme === uri.scheme && folder.authority === uri.authority)) {
124-
const statedUri = await this.statAndNormalizeUri(uri);
109+
const statedUri = await this.statAndNormalizeUri(uri, includeDirectorySlash);
125110
if (statedUri) {
126111
return statedUri;
127112
}
@@ -133,7 +118,7 @@ export class FilePathLinkifier implements IContributedLinkifier {
133118
}
134119

135120
for (const workspaceFolder of workspaceFolders) {
136-
const uri = await this.statAndNormalizeUri(Uri.joinPath(workspaceFolder, pathText));
121+
const uri = await this.statAndNormalizeUri(Uri.joinPath(workspaceFolder, pathText), includeDirectorySlash);
137122
if (uri) {
138123
return uri;
139124
}
@@ -154,12 +139,18 @@ export class FilePathLinkifier implements IContributedLinkifier {
154139
return refUri;
155140
}
156141

157-
private async statAndNormalizeUri(uri: Uri): Promise<Uri | undefined> {
142+
private async statAndNormalizeUri(uri: Uri, includeDirectorySlash: boolean): Promise<Uri | undefined> {
158143
try {
159144
const stat = await this.fileSystem.stat(uri);
160145
if (stat.type === FileType.Directory) {
161-
// Ensure all dir paths have a trailing slash for icon rendering
162-
return uri.path.endsWith('/') ? uri : uri.with({ path: `${uri.path}/` });
146+
if (includeDirectorySlash) {
147+
return uri.path.endsWith('/') ? uri : uri.with({ path: `${uri.path}/` });
148+
}
149+
150+
if (uri.path.endsWith('/') && uri.path !== '/') {
151+
return uri.with({ path: uri.path.slice(0, -1) });
152+
}
153+
return uri;
163154
}
164155

165156
return uri;

src/extension/linkify/common/linkifyService.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { PromptReference } from '../../prompt/common/conversation';
1313
import { FilePathLinkifier } from './filePathLinkifier';
1414
import { LinkifiedText } from './linkifiedText';
1515
import { Linkifier } from './linkifier';
16+
import { ModelFilePathLinkifier } from './modelFilePathLinkifier';
1617

1718
/**
1819
* A stateful linkifier.
@@ -86,6 +87,8 @@ export class LinkifyService implements ILinkifyService {
8687
@IWorkspaceService workspaceService: IWorkspaceService,
8788
@IEnvService private readonly envService: IEnvService,
8889
) {
90+
// Model-generated links first (anchors), fallback legacy path linkifier afterwards
91+
this.registerGlobalLinkifier({ create: () => new ModelFilePathLinkifier(fileSystem, workspaceService) });
8992
this.registerGlobalLinkifier({ create: () => new FilePathLinkifier(fileSystem, workspaceService) });
9093
}
9194

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
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 { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
7+
import { FileType } from '../../../platform/filesystem/common/fileTypes';
8+
import { getWorkspaceFileDisplayPath, IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
9+
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
10+
import { normalizePath as normalizeUriPath } from '../../../util/vs/base/common/resources';
11+
import { Location, Position, Range, Uri } from '../../../vscodeTypes';
12+
import { coalesceParts, LinkifiedPart, LinkifiedText, LinkifyLocationAnchor } from './linkifiedText';
13+
import { IContributedLinkifier, LinkifierContext } from './linkifyService';
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+
@IFileSystemService private readonly fileSystem: IFileSystemService,
22+
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
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+
const descriptiveWithAnchor = !!anchor; // Allow any descriptive text when anchor is present
130+
131+
return Boolean(workspaceFolders.length) && (textMatchesBase || textIsFilename || descriptiveWithAnchor);
132+
}
133+
134+
private async resolveTarget(targetPath: string, originalTargetPath: string, workspaceFolders: readonly Uri[], preserveDirectorySlash: boolean, token: CancellationToken): Promise<Uri | undefined> {
135+
if (!workspaceFolders.length) {
136+
return undefined;
137+
}
138+
139+
if (token.isCancellationRequested) {
140+
return undefined;
141+
}
142+
143+
if (this.isAbsolutePath(targetPath)) {
144+
// Choose URI construction strategy based on workspace folder schemes.
145+
// For local (file:) workspaces we keep using Uri.file; for remote schemes we attempt
146+
// to project the absolute path into the remote scheme preserving the folder URI's authority.
147+
const normalizedAbs = targetPath.replace(/\\/g, '/');
148+
149+
for (const folderUri of workspaceFolders) {
150+
if (token.isCancellationRequested) {
151+
return undefined;
152+
}
153+
if (folderUri.scheme === 'file') {
154+
// Use original path (before normalization) for Uri.file to preserve platform-specific separators
155+
const absoluteFileUri = this.tryCreateFileUri(originalTargetPath);
156+
if (!absoluteFileUri) {
157+
continue;
158+
}
159+
if (this.isEqualOrParent(absoluteFileUri, folderUri)) {
160+
const stat = await this.tryStat(absoluteFileUri, preserveDirectorySlash, token);
161+
if (stat) {
162+
return stat;
163+
}
164+
}
165+
continue;
166+
}
167+
168+
// Remote / virtual workspace: attempt to map the absolute path into the same scheme.
169+
// Only consider it if the folder path is a prefix of the absolute path to avoid
170+
// generating unrelated URIs.
171+
const folderPath = folderUri.path.replace(/\\/g, '/');
172+
const prefix = folderPath.endsWith('/') ? folderPath : folderPath + '/';
173+
if (normalizedAbs.startsWith(prefix)) {
174+
const candidate = folderUri.with({ path: normalizedAbs });
175+
const stat = await this.tryStat(candidate, preserveDirectorySlash, token);
176+
if (stat) {
177+
return stat;
178+
}
179+
}
180+
}
181+
return undefined;
182+
}
183+
184+
const segments = targetPath.split('/').filter(Boolean);
185+
for (const folderUri of workspaceFolders) {
186+
const candidate = Uri.joinPath(folderUri, ...segments);
187+
const stat = await this.tryStat(candidate, preserveDirectorySlash, token);
188+
if (stat) {
189+
return stat;
190+
}
191+
}
192+
193+
return undefined;
194+
}
195+
196+
private tryCreateFileUri(path: string): Uri | undefined {
197+
try {
198+
return Uri.file(path);
199+
} catch {
200+
return undefined;
201+
}
202+
}
203+
204+
205+
private isEqualOrParent(target: Uri, folder: Uri): boolean {
206+
const targetPath = normalizeUriPath(target).path;
207+
const folderPath = normalizeUriPath(folder).path;
208+
return targetPath === folderPath || targetPath.startsWith(folderPath.endsWith('/') ? folderPath : `${folderPath}/`);
209+
}
210+
211+
private parseAnchor(anchor: string | undefined): { readonly range: Range; readonly startLine: string; readonly endLine: string | undefined } | undefined {
212+
// Parse supported anchor formats: L123, L123-456, L123-L456
213+
if (!anchor) {
214+
return undefined;
215+
}
216+
const match = /^L(\d+)(?:-L?(\d+))?$/.exec(anchor);
217+
if (!match) {
218+
return undefined;
219+
}
220+
221+
const startLine = match[1];
222+
const endLineRaw = match[2];
223+
const normalizedEndLine = endLineRaw === startLine ? undefined : endLineRaw;
224+
const start = parseInt(startLine, 10) - 1;
225+
const end = parseInt(normalizedEndLine ?? startLine, 10) - 1;
226+
if (Number.isNaN(start) || Number.isNaN(end) || start < 0 || end < start) {
227+
return undefined;
228+
}
229+
230+
return {
231+
range: new Range(new Position(start, 0), new Position(end, 0)),
232+
startLine,
233+
endLine: normalizedEndLine,
234+
};
235+
}
236+
237+
private isAbsolutePath(path: string): boolean {
238+
// Treat drive-letter prefixes (e.g. C:) or leading slashes as absolute paths.
239+
return /^[a-z]:/i.test(path) || path.startsWith('/');
240+
}
241+
242+
private async tryStat(uri: Uri, preserveDirectorySlash: boolean, token: CancellationToken): Promise<Uri | undefined> {
243+
if (token.isCancellationRequested) {
244+
return undefined;
245+
}
246+
try {
247+
const stat = await this.fileSystem.stat(uri);
248+
if (stat.type === FileType.Directory) {
249+
const isRoot = uri.path === '/';
250+
const hasTrailingSlash = uri.path.endsWith('/');
251+
const shouldHaveTrailingSlash = preserveDirectorySlash && !isRoot;
252+
253+
if (shouldHaveTrailingSlash && !hasTrailingSlash) {
254+
return uri.with({ path: `${uri.path}/` });
255+
}
256+
if (!shouldHaveTrailingSlash && hasTrailingSlash) {
257+
return uri.with({ path: uri.path.slice(0, -1) });
258+
}
259+
}
260+
return uri;
261+
} catch {
262+
return undefined;
263+
}
264+
}
265+
}

0 commit comments

Comments
 (0)