Path: blob/main/extensions/copilot/src/extension/linkify/test/node/modelFilePathLinkifier.spec.ts
13405 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 { expect, suite, test } from 'vitest';6import { NullEnvService } from '../../../../platform/env/common/nullEnvService';7import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';8import { FileType } from '../../../../platform/filesystem/common/fileTypes';9import { NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';10import { CancellationToken } from '../../../../util/vs/base/common/cancellation';11import { URI } from '../../../../util/vs/base/common/uri';12import { Location, Position, Range } from '../../../../vscodeTypes';13import { LinkifyLocationAnchor } from '../../common/linkifiedText';14import { LinkifyService } from '../../common/linkifyService';15import { assertPartsEqual, createTestLinkifierService, linkify, workspaceFile } from './util';1617suite('Model File Path Linkifier', () => {18test('Should linkify model generated file references with line range', async () => {19const service = createTestLinkifierService('src/file.ts');20const result = await linkify(service, '[src/file.ts](src/file.ts#L10-12)');21const anchor = result.parts[0] as LinkifyLocationAnchor;22const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(9, 0), new Position(11, 0))));23expect(anchor.title).toBe('src/file.ts#L10-L12');24assertPartsEqual([anchor], [expected]);25});2627test('Should linkify single line anchors', async () => {28const service = createTestLinkifierService('src/file.ts');29const result = await linkify(service, '[src/file.ts](src/file.ts#L5)');30const anchor = result.parts[0] as LinkifyLocationAnchor;31const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(4, 0), new Position(4, 0))));32expect(anchor.title).toBe('src/file.ts#L5');33assertPartsEqual([anchor], [expected]);34});3536test('Should linkify absolute file paths', async () => {37const absolutePath = workspaceFile('src/file.ts').fsPath;38const service = createTestLinkifierService('src/file.ts');39const result = await linkify(service, `[src/file.ts](${absolutePath}#L2)`);40const anchor = result.parts[0] as LinkifyLocationAnchor;41const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(1, 0), new Position(1, 0))));42expect(anchor.title).toBe('src/file.ts#L2');43assertPartsEqual([anchor], [expected]);44});4546test('Should decode percent-encoded targets', async () => {47const service = createTestLinkifierService('space file.ts');48const result = await linkify(service, '[space file.ts](space%20file.ts#L1)');49const anchor = result.parts[0] as LinkifyLocationAnchor;50const expected = new LinkifyLocationAnchor(new Location(workspaceFile('space file.ts'), new Range(new Position(0, 0), new Position(0, 0))));51assertPartsEqual([anchor], [expected]);52});5354test('Should fallback when text does not match base path and no anchor', async () => {55const service = createTestLinkifierService('src/file.ts');56const result = await linkify(service, '[other](src/file.ts)');57assertPartsEqual(result.parts, ['other']);58});5960test('Should linkify descriptive text with anchor', async () => {61const service = createTestLinkifierService('src/file.ts');62const result = await linkify(service, '[Await chat view](src/file.ts#L54)');63const anchor = result.parts[0] as LinkifyLocationAnchor;64const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(53, 0), new Position(53, 0))));65expect(anchor.title).toBe('src/file.ts#L54');66assertPartsEqual([anchor], [expected]);67});6869test('Should fallback for invalid anchor syntax', async () => {70const service = createTestLinkifierService('src/file.ts');71const result = await linkify(service, '[src/file.ts](src/file.ts#Lines10-12)');72assertPartsEqual(result.parts, ['src/file.ts']);73});7475test('Should handle backticks in link text', async () => {76const service = createTestLinkifierService('file.ts');77const result = await linkify(service, '[`file.ts`](file.ts)');78const anchor = result.parts[0] as LinkifyLocationAnchor;79const expected = new LinkifyLocationAnchor(workspaceFile('file.ts'));80assertPartsEqual([anchor], [expected]);81});8283test('Should handle backticks in link text with line anchor', async () => {84const service = createTestLinkifierService('src/file.ts');85const result = await linkify(service, '[`src/file.ts`](src/file.ts#L42)');86const anchor = result.parts[0] as LinkifyLocationAnchor;87const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(41, 0), new Position(41, 0))));88expect(anchor.title).toBe('src/file.ts#L42');89assertPartsEqual([anchor], [expected]);90});9192test('Should handle L123-L456 anchor format with L prefix on end line', async () => {93const service = createTestLinkifierService('src/file.ts');94const result = await linkify(service, '[src/file.ts](src/file.ts#L10-L15)');95const anchor = result.parts[0] as LinkifyLocationAnchor;96const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(9, 0), new Position(14, 0))));97expect(anchor.title).toBe('src/file.ts#L10-L15');98assertPartsEqual([anchor], [expected]);99});100101test('Should handle descriptive text with L123-L456 anchor format', async () => {102const service = createTestLinkifierService('src/file.ts');103const result = await linkify(service, '[Some descriptive text](src/file.ts#L20-L25)');104const anchor = result.parts[0] as LinkifyLocationAnchor;105const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(19, 0), new Position(24, 0))));106expect(anchor.title).toBe('src/file.ts#L20-L25');107assertPartsEqual([anchor], [expected]);108});109110test('Should normalize non-standard L123-456 format to standard L123-L456', async () => {111const service = createTestLinkifierService('src/file.ts');112const result = await linkify(service, '[src/file.ts](src/file.ts#L20-25)');113const anchor = result.parts[0] as LinkifyLocationAnchor;114const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(19, 0), new Position(24, 0))));115expect(anchor.title).toBe('src/file.ts#L20-L25');116assertPartsEqual([anchor], [expected]);117});118119test('Should handle absolute paths with forward slashes on Windows', async () => {120const absolutePath = workspaceFile('src/file.ts').fsPath;121const service = createTestLinkifierService('src/file.ts');122// Simulate model-generated path with forward slashes (e.g., c:/Repos/...)123const pathWithForwardSlashes = absolutePath.replace(/\\/g, '/');124const result = await linkify(service, `[line 67](${pathWithForwardSlashes}#L67)`);125const anchor = result.parts[0] as LinkifyLocationAnchor;126const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(66, 0), new Position(66, 0))));127expect(anchor.title).toBe('src/file.ts#L67');128assertPartsEqual([anchor], [expected]);129});130131test('Should NOT linkify when display text looks like filename but does not match target filename', async () => {132// This tests the case where model hallucinates a link like [nonexistent.ts](existing.ts#L10)133// The display text "nonexistent.ts" looks like a filename but doesn't match "existing.ts"134const service = createTestLinkifierService('src/existing.ts');135const result = await linkify(service, '[nonexistent.ts](src/existing.ts#L10)');136// Should NOT create a link to the wrong file - just return the display text137assertPartsEqual(result.parts, ['nonexistent.ts']);138});139140test('Should linkify when display text filename matches target filename with anchor', async () => {141// Display text is just the filename, target is full path - should work142const service = createTestLinkifierService('src/file.ts');143const result = await linkify(service, '[file.ts](src/file.ts#L10)');144const anchor = result.parts[0] as LinkifyLocationAnchor;145const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(9, 0), new Position(9, 0))));146expect(anchor.title).toBe('src/file.ts#L10');147assertPartsEqual([anchor], [expected]);148});149150test('Should linkify bare line number anchors without L prefix', async () => {151const service = createTestLinkifierService('src/file.ts');152const result = await linkify(service, '[src/file.ts](src/file.ts#10)');153const anchor = result.parts[0] as LinkifyLocationAnchor;154const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(9, 0), new Position(9, 0))));155expect(anchor.title).toBe('src/file.ts#L10');156assertPartsEqual([anchor], [expected]);157});158159test('Should linkify bare line range anchors without L prefix', async () => {160const service = createTestLinkifierService('src/file.ts');161const result = await linkify(service, '[src/file.ts](src/file.ts#10-20)');162const anchor = result.parts[0] as LinkifyLocationAnchor;163const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(9, 0), new Position(19, 0))));164expect(anchor.title).toBe('src/file.ts#L10-L20');165assertPartsEqual([anchor], [expected]);166});167168test('Should linkify descriptive text with bare line range anchor', async () => {169const service = createTestLinkifierService('src/file.ts');170const result = await linkify(service, '[existing pattern in chatListRenderer](src/file.ts#1287-1290)');171const anchor = result.parts[0] as LinkifyLocationAnchor;172const expected = new LinkifyLocationAnchor(new Location(workspaceFile('src/file.ts'), new Range(new Position(1286, 0), new Position(1289, 0))));173expect(anchor.title).toBe('src/file.ts#L1287-L1290');174assertPartsEqual([anchor], [expected]);175});176});177178suite('Model File Path Linkifier Remote Workspace', () => {179function createRemoteService(root: URI, files: readonly URI[]): LinkifyService {180class MockFs implements IFileSystemService {181readonly _serviceBrand: undefined;182async stat(resource: URI) {183if (resource.toString() === root.toString()) {184return { ctime: 0, mtime: 0, size: 0, type: FileType.Directory };185}186const found = files.find(f => f.toString() === resource.toString());187if (!found) {188throw new Error('File not found: ' + resource.toString());189}190return { ctime: 0, mtime: 0, size: 0, type: found.path.endsWith('/') ? FileType.Directory : FileType.File };191}192readDirectory(): Promise<[string, FileType][]> { throw new Error('Not implemented'); }193createDirectory(): Promise<void> { throw new Error('Not implemented'); }194readFile(): Promise<Uint8Array> { throw new Error('Not implemented'); }195writeFile(): Promise<void> { throw new Error('Not implemented'); }196delete(): Promise<void> { throw new Error('Not implemented'); }197rename(): Promise<void> { throw new Error('Not implemented'); }198copy(): Promise<void> { throw new Error('Not implemented'); }199isWritableFileSystem(): boolean | undefined { return true; }200createFileSystemWatcher(): any { throw new Error('Not implemented'); }201}202const fs = new MockFs();203const workspaceService = new NullWorkspaceService([root]);204const service = new LinkifyService(fs, workspaceService, NullEnvService.Instance);205return service;206}207208async function remoteLinkify(service: LinkifyService, text: string) {209const linkifier = service.createLinkifier({ requestId: undefined, references: [] }, []);210const initial = await linkifier.append(text, CancellationToken.None);211const flushed = await linkifier.flush(CancellationToken.None);212return flushed ? [...initial.parts, ...flushed.parts] : initial.parts;213}214215const remoteRoot = URI.from({ scheme: 'test', authority: 'auth', path: '/home/user/project' });216const remoteFile = URI.from({ scheme: 'test', authority: 'auth', path: '/home/user/project/src/remote.ts' });217218test('Should map absolute remote path preserving scheme', async () => {219const service = createRemoteService(remoteRoot, [remoteFile]);220const parts = await remoteLinkify(service, '[/home/user/project/src/remote.ts](/home/user/project/src/remote.ts)');221expect(parts.length).toBe(1);222const anchor = parts[0] as LinkifyLocationAnchor;223expect(anchor.value.toString()).toBe(remoteFile.toString());224expect(anchor.title).toBe('src/remote.ts');225});226227test('Should parse line range anchor on remote absolute path', async () => {228const service = createRemoteService(remoteRoot, [remoteFile]);229const parts = await remoteLinkify(service, '[/home/user/project/src/remote.ts](/home/user/project/src/remote.ts#L3-5)');230expect(parts.length).toBe(1);231const anchor = parts[0] as LinkifyLocationAnchor;232// Anchor value is a Location when an anchor is present.233const location = anchor.value as Location;234expect(location.uri.toString()).toBe(remoteFile.toString());235const range = location.range;236expect(range.start.line).toBe(2);237expect(range.end.line).toBe(4);238expect(anchor.title).toBe('src/remote.ts#L3-L5');239});240});241242243