diff --git a/src/providers/MessageRouter.test.ts b/src/providers/MessageRouter.test.ts index 6bb8a36..5d077fe 100644 --- a/src/providers/MessageRouter.test.ts +++ b/src/providers/MessageRouter.test.ts @@ -661,6 +661,45 @@ describe("MessageRouter", () => { ); }); + it("opens workspace file at requested line and column from openFile message", async () => { + await router.handleMessage({ + type: "openFile", + path: "src/providers/MessageRouter.ts", + line: 120, + column: 5, + }); + + expect(vscode.window.showTextDocument).toHaveBeenCalledWith( + expect.objectContaining({ + fsPath: "/workspace/src/providers/MessageRouter.ts", + }), + { + selection: { + start: { line: 119, character: 4 }, + end: { line: 119, character: 4 }, + }, + preview: true, + }, + ); + }); + + it("rejects invalid openFile payloads without host side effects", async () => { + await router.handleMessage({ type: "openFile", path: 123 }); + await router.handleMessage({ + type: "openFile", + path: "src/providers/MessageRouter.ts", + line: 0, + }); + await router.handleMessage({ + type: "openFile", + path: "src/providers/MessageRouter.ts", + column: -1, + }); + + expect(vscode.window.showTextDocument).not.toHaveBeenCalled(); + expect(vscode.workspace.findFiles).not.toHaveBeenCalled(); + }); + it("reports open file failures when fuzzy matching cannot recover", async () => { vi.mocked(vscode.workspace.findFiles).mockResolvedValue([]); vi.mocked(vscode.window.showTextDocument).mockRejectedValue( @@ -961,19 +1000,16 @@ describe("MessageRouter", () => { }); it("opens file URI and absolute paths and reports outer path failures", async () => { - vi.mocked(vscode.Uri.parse).mockImplementationOnce(() => { - throw new Error("parse failed"); - }); - await router.handleOpenFile("safe-file.ts"); + await router.handleOpenFile("https://example.com/safe-file.ts"); expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( - "Failed to open file: safe-file.ts", + "Invalid file path: Only file URIs can be opened", ); await router.handleOpenFile("file:///workspace/absolute.ts", 1, undefined, 1); await router.handleOpenFile("C:\\workspace\\absolute.ts"); expect(vscode.window.showTextDocument).toHaveBeenCalledWith( - expect.objectContaining({ fsPath: "file:///workspace/absolute.ts" }), + expect.objectContaining({ fsPath: "/workspace/absolute.ts" }), expect.objectContaining({ preview: true }), ); expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -1100,7 +1136,7 @@ describe("MessageRouter", () => { vscode.workspace.workspaceFolders = originalFolders; expect(vscode.window.showTextDocument).toHaveBeenCalledWith( - expect.objectContaining({ fsPath: "file:///workspace/from-uri.ts" }), + expect.objectContaining({ fsPath: "/workspace/from-uri.ts" }), expect.objectContaining({ preview: true }), ); expect(vscode.window.showTextDocument).toHaveBeenCalledWith( diff --git a/src/providers/MessageRouter.ts b/src/providers/MessageRouter.ts index 3830521..45669d3 100644 --- a/src/providers/MessageRouter.ts +++ b/src/providers/MessageRouter.ts @@ -22,7 +22,11 @@ import type { TmuxRawSubcommand, TmuxWebviewCommandId, } from "../types"; -import { isWindowsAbsolutePath } from "../utils/pathUtils"; +import { + createSelection, + fuzzyMatchFile, + openFileInEditor, +} from "./openFile"; export interface MessageRouterProviderBridge { startOpenCode(): Promise; @@ -363,63 +367,9 @@ export class MessageRouter { endLine?: number, column?: number, ): Promise { - if ( - filePath.includes("..") || - filePath.includes("\0") || - filePath.includes("~") - ) { - void vscode.window.showErrorMessage( - "Invalid file path: Path traversal detected", - ); - return; - } - - try { - const normalizedPath = filePath.replace(/\\/g, "/"); - - let uri: vscode.Uri; - - if (vscode.Uri.parse(filePath).scheme === "file") { - uri = vscode.Uri.file(filePath); - } else if ( - normalizedPath.startsWith("/") || - isWindowsAbsolutePath(filePath) - ) { - uri = vscode.Uri.file(filePath); - } else { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders && workspaceFolders.length > 0) { - uri = vscode.Uri.joinPath(workspaceFolders[0].uri, normalizedPath); - } else { - uri = vscode.Uri.file(normalizedPath); - } - } - - try { - const selection = this.createSelection(line, endLine, column); - - await vscode.window.showTextDocument(uri, { - selection, - preview: true, - }); - } catch { - const matchedUri = await this.fuzzyMatchFile(normalizedPath); - if (matchedUri) { - const selection = this.createSelection(line, endLine, column); - - await vscode.window.showTextDocument(matchedUri, { - selection, - preview: true, - }); - } else { - void vscode.window.showErrorMessage( - `Failed to open file: ${filePath}`, - ); - } - } - } catch { - void vscode.window.showErrorMessage(`Failed to open file: ${filePath}`); - } + await openFileInEditor(filePath, line, endLine, column, (message) => { + this.logger.error(message); + }); } public async handleFilesDropped( @@ -751,68 +701,12 @@ export class MessageRouter { endLine?: number, column?: number, ): vscode.Range | undefined { - if (!line) { - return undefined; - } - - const maxColumn = 9999; - return new vscode.Range( - Math.max(0, line - 1), - Math.max(0, (column || 1) - 1), - Math.max(0, (endLine || line) - 1), - endLine ? maxColumn : Math.max(0, (column || 1) - 1), - ); + return createSelection(line, endLine, column); } public async fuzzyMatchFile(filePath: string): Promise { - try { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - return null; - } - - const pathParts = filePath - .split(/[\\/]/) - .filter((part) => part.length > 0); - const filename = pathParts[pathParts.length - 1]; - - const pattern = `**/${filename}*`; - const files = await vscode.workspace.findFiles(pattern, null, 100); - - const normalizedInput = filePath.replace(/\\/g, "/").toLowerCase(); - files.sort((a, b) => { - const aPath = a.fsPath.replace(/\\/g, "/").toLowerCase(); - const bPath = b.fsPath.replace(/\\/g, "/").toLowerCase(); - - if (aPath.endsWith(normalizedInput)) { - return -1; - } - if (bPath.endsWith(normalizedInput)) { - return 1; - } - - const aDirParts = a.fsPath.split(/[\\/]/); - const bDirParts = b.fsPath.split(/[\\/]/); - - for (let i = 0; i < pathParts.length - 1; i++) { - const expectedPart = pathParts[i].toLowerCase(); - if (aDirParts[i] && aDirParts[i].toLowerCase() === expectedPart) { - return -1; - } - if (bDirParts[i] && bDirParts[i].toLowerCase() === expectedPart) { - return 1; - } - } - - return 0; - }); - - return files[0] || null; - } catch (error) { - this.logger.error( - `Fuzzy match failed: ${error instanceof Error ? error.message : String(error)}`, - ); - return null; - } + return fuzzyMatchFile(filePath, (message) => { + this.logger.error(message); + }); } } diff --git a/src/providers/openFile.ts b/src/providers/openFile.ts new file mode 100644 index 0000000..a2d0b28 --- /dev/null +++ b/src/providers/openFile.ts @@ -0,0 +1,221 @@ +import * as vscode from "vscode"; +import { isWindowsAbsolutePath } from "../utils/pathUtils"; + +type FileLocation = { + readonly line?: number; + readonly endLine?: number; + readonly column?: number; +}; + +type ValidationResult = + | { readonly ok: true } + | { readonly ok: false; readonly message: string }; + +const URI_SCHEME_REGEX = /^[a-z][a-z0-9+\-.]*:\/\//i; +const MAX_COLUMN = 9999; + +const isPositiveInteger = (value: number | undefined): boolean => + value === undefined || (Number.isInteger(value) && value > 0); + +const validateLocation = (location: FileLocation): ValidationResult => { + if ( + !isPositiveInteger(location.line) || + !isPositiveInteger(location.endLine) || + !isPositiveInteger(location.column) + ) { + return { + ok: false, + message: "Invalid file location: line and column must be positive integers", + }; + } + + if (location.endLine !== undefined && location.line === undefined) { + return { + ok: false, + message: "Invalid file location: endLine requires line", + }; + } + + if ( + location.line !== undefined && + location.endLine !== undefined && + location.endLine < location.line + ) { + return { + ok: false, + message: "Invalid file location: endLine must be greater than line", + }; + } + + return { ok: true }; +}; + +const validateFilePath = (filePath: string): ValidationResult => { + if ( + filePath.includes("..") || + filePath.includes("\0") || + filePath.includes("~") + ) { + return { + ok: false, + message: "Invalid file path: Path traversal detected", + }; + } + + if (URI_SCHEME_REGEX.test(filePath)) { + try { + if (new URL(filePath).protocol !== "file:") { + return { + ok: false, + message: "Invalid file path: Only file URIs can be opened", + }; + } + } catch { + return { + ok: false, + message: "Invalid file path: Malformed URI", + }; + } + } + + return { ok: true }; +}; + +export function createSelection( + line?: number, + endLine?: number, + column?: number, +): vscode.Range | undefined { + if (line === undefined) { + return undefined; + } + + const startLine = line - 1; + const startColumn = (column ?? 1) - 1; + const endSelectionLine = (endLine ?? line) - 1; + const endColumn = endLine === undefined ? startColumn : MAX_COLUMN; + + return new vscode.Range(startLine, startColumn, endSelectionLine, endColumn); +} + +export async function fuzzyMatchFile( + filePath: string, + onError?: (message: string) => void, +): Promise { + try { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + return null; + } + + const pathParts = filePath + .split(/[\\/]/) + .filter((part) => part.length > 0); + const filename = pathParts[pathParts.length - 1]; + + const pattern = `**/${filename}*`; + const files = await vscode.workspace.findFiles(pattern, null, 100); + + const normalizedInput = filePath.replace(/\\/g, "/").toLowerCase(); + files.sort((a, b) => { + const aPath = a.fsPath.replace(/\\/g, "/").toLowerCase(); + const bPath = b.fsPath.replace(/\\/g, "/").toLowerCase(); + + if (aPath.endsWith(normalizedInput)) { + return -1; + } + if (bPath.endsWith(normalizedInput)) { + return 1; + } + + const aDirParts = a.fsPath.split(/[\\/]/); + const bDirParts = b.fsPath.split(/[\\/]/); + + for (let i = 0; i < pathParts.length - 1; i++) { + const expectedPart = pathParts[i]?.toLowerCase(); + if (aDirParts[i]?.toLowerCase() === expectedPart) { + return -1; + } + if (bDirParts[i]?.toLowerCase() === expectedPart) { + return 1; + } + } + + return 0; + }); + + return files[0] ?? null; + } catch (error) { + onError?.( + `Fuzzy match failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return null; + } +} + +export async function openFileInEditor( + filePath: string, + line?: number, + endLine?: number, + column?: number, + onFuzzyMatchError?: (message: string) => void, +): Promise { + const pathValidation = validateFilePath(filePath); + if (!pathValidation.ok) { + void vscode.window.showErrorMessage(pathValidation.message); + return; + } + + const locationValidation = validateLocation({ line, endLine, column }); + if (!locationValidation.ok) { + void vscode.window.showErrorMessage(locationValidation.message); + return; + } + + try { + const normalizedPath = filePath.replace(/\\/g, "/"); + let uri: vscode.Uri; + + if (URI_SCHEME_REGEX.test(filePath)) { + const parsedUrl = new URL(filePath); + uri = vscode.Uri.file(decodeURIComponent(parsedUrl.pathname)); + } else if ( + normalizedPath.startsWith("/") || + isWindowsAbsolutePath(filePath) + ) { + uri = vscode.Uri.file(filePath); + } else { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0) { + uri = vscode.Uri.joinPath(workspaceFolders[0].uri, normalizedPath); + } else { + uri = vscode.Uri.file(normalizedPath); + } + } + + try { + const selection = createSelection(line, endLine, column); + + await vscode.window.showTextDocument(uri, { + selection, + preview: true, + }); + } catch { + const matchedUri = await fuzzyMatchFile(normalizedPath, onFuzzyMatchError); + if (matchedUri) { + const selection = createSelection(line, endLine, column); + + await vscode.window.showTextDocument(matchedUri, { + selection, + preview: true, + }); + } else { + void vscode.window.showErrorMessage( + `Failed to open file: ${filePath}`, + ); + } + } + } catch { + void vscode.window.showErrorMessage(`Failed to open file: ${filePath}`); + } +} diff --git a/src/types.test.ts b/src/types.test.ts index 37aac53..f00707b 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -202,6 +202,22 @@ describe("Types", () => { expect(message.column).toBe(5); }); + it("accepts openFile message with path line column and endLine", () => { + const message: WebviewMessage = { + type: "openFile", + path: "/test/file.ts", + line: 10, + column: 5, + endLine: 12, + }; + + expect(message.type).toBe("openFile"); + expect(message.path).toBe("/test/file.ts"); + expect(message.line).toBe(10); + expect(message.column).toBe(5); + expect(message.endLine).toBe(12); + }); + it("should accept openUrl message", () => { const message: WebviewMessage = { type: "openUrl", diff --git a/src/webview/links/index.test.ts b/src/webview/links/index.test.ts new file mode 100644 index 0000000..3c400c5 --- /dev/null +++ b/src/webview/links/index.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createLinkProvider } from "./index"; +import { postMessage } from "../shared/vscode-api"; + +vi.mock("../shared/vscode-api", () => ({ + postMessage: vi.fn(), +})); + +type ProvidedLink = { + readonly activate: () => void; +}; + +const provideLinksForLine = (lineText: string) => + new Promise | undefined>((resolve) => { + const terminal = { + buffer: { + active: { + getLine: () => ({ + translateToString: () => lineText, + }), + }, + }, + }; + + createLinkProvider(terminal as never).provideLinks(1, (links) => { + resolve(links); + }); + }); + +describe("createLinkProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("links at-prefixed opencode paths with line and column suffix", async () => { + const links = await provideLinksForLine("open @src/providers/MessageRouter.ts:120:5 now"); + + expect(links).toHaveLength(1); + links?.[0]?.activate(); + + expect(postMessage).toHaveBeenCalledWith({ + type: "openFile", + path: "src/providers/MessageRouter.ts", + line: 120, + endLine: undefined, + column: 5, + }); + }); + + it("links absolute file URLs and decodes encoded spaces", async () => { + const links = await provideLinksForLine("see file:///workspace/My%20File.ts:12"); + + expect(links).toHaveLength(1); + links?.[0]?.activate(); + + expect(postMessage).toHaveBeenCalledWith({ + type: "openFile", + path: "/workspace/My File.ts", + line: 12, + endLine: undefined, + column: undefined, + }); + }); + + it("does not link malformed paths or oversized terminal lines", async () => { + const malformedLinks = await provideLinksForLine("see http://example.com/not-a-file.ts"); + const oversizedLinks = await provideLinksForLine("a".repeat(10001)); + + expect(malformedLinks).toHaveLength(0); + expect(oversizedLinks).toBeUndefined(); + }); +}); diff --git a/src/webview/links/index.ts b/src/webview/links/index.ts index bb7a189..00a737d 100644 --- a/src/webview/links/index.ts +++ b/src/webview/links/index.ts @@ -12,8 +12,134 @@ interface Link { const MAX_LINE_LENGTH = 10000; -const PATH_REGEX = - /(?:^[\s"'])(@?((?:file:\/\/|\/|[A-Za-z]:\\|\.?\.?\/)[^\s"'#]+|[^\s":\/]+(?:\/[^\s":\/]+)+)(?:#L(\d+)(?:-L?(\d+))?)?)(?=[\s"']|$)/gi; +type ParsedFileReference = { + readonly path: string; + readonly line?: number; + readonly endLine?: number; + readonly column?: number; +}; + +type CandidateReference = { + readonly text: string; + readonly startIndex: number; +}; + +const isTokenBoundary = (char: string): boolean => + /\s/.test(char) || char === "\"" || char === "'"; + +const collectCandidateReferences = ( + lineText: string, +): ReadonlyArray => { + const candidates: CandidateReference[] = []; + let index = 0; + + while (index < lineText.length) { + while (index < lineText.length && isTokenBoundary(lineText[index] ?? "")) { + index++; + } + + const startIndex = index; + while (index < lineText.length && !isTokenBoundary(lineText[index] ?? "")) { + index++; + } + + if (index > startIndex) { + candidates.push({ + text: lineText.slice(startIndex, index), + startIndex, + }); + } + } + + return candidates; +}; + +const isLikelyFileReference = (candidate: string): boolean => { + const withoutAtPrefix = candidate.startsWith("@") + ? candidate.slice(1) + : candidate; + + return ( + withoutAtPrefix.startsWith("file://") || + withoutAtPrefix.startsWith("/") || + withoutAtPrefix.startsWith("./") || + withoutAtPrefix.startsWith("../") || + /^[A-Za-z]:\\/.test(withoutAtPrefix) || + (!/^[a-z][a-z0-9+\-.]*:\/\//i.test(withoutAtPrefix) && + withoutAtPrefix.includes("/")) + ); +}; + +const parsePositiveInteger = (value: string | undefined): number | undefined => { + if (!value) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +}; + +const extractHashLineSuffix = ( + reference: string, +): { readonly reference: string; readonly line?: number; readonly endLine?: number } => { + const match = /^(.*)#L(\d+)(?:-L?(\d+))?$/.exec(reference); + if (!match) { + return { reference }; + } + + return { + reference: match[1] ?? reference, + line: parsePositiveInteger(match[2]), + endLine: parsePositiveInteger(match[3]), + }; +}; + +const extractColonSuffix = ( + reference: string, +): { readonly reference: string; readonly line?: number; readonly column?: number } => { + const match = /^(.*?):(\d+)(?::(\d+))?$/.exec(reference); + if (!match) { + return { reference }; + } + + return { + reference: match[1] ?? reference, + line: parsePositiveInteger(match[2]), + column: parsePositiveInteger(match[3]), + }; +}; + +const parseFileReference = (candidate: string): ParsedFileReference | null => { + const withoutAtPrefix = candidate.startsWith("@") + ? candidate.slice(1) + : candidate; + const hashSuffix = extractHashLineSuffix(withoutAtPrefix); + const colonSuffix = extractColonSuffix(hashSuffix.reference); + let path = colonSuffix.reference; + + if (!path) { + return null; + } + + if (path.startsWith("file://")) { + try { + const url = new URL(path); + path = decodeURIComponent(url.pathname); + if (url.hostname && !url.pathname.startsWith("/")) { + path = `${url.hostname}:${path}`; + } + } catch { + return null; + } + } + + return { + path, + line: hashSuffix.line ?? colonSuffix.line, + endLine: hashSuffix.endLine, + column: colonSuffix.column, + }; +}; export function createLinkProvider(terminal: Terminal) { return { @@ -35,80 +161,32 @@ export function createLinkProvider(terminal: Terminal) { } const links: Link[] = []; - let match: RegExpExecArray | null = PATH_REGEX.exec(lineText); - let lastIndex = -1; - - while (match) { - if (match.index === lastIndex) { - PATH_REGEX.lastIndex++; - match = PATH_REGEX.exec(lineText); - continue; - } - lastIndex = match.index; - - const fullMatch = match[1]; - const hasAtPrefix = fullMatch.startsWith("@"); - let path = match[2]; - const lineNumStr = match[3]; - const endLineStr = match[4]; - - if (!path) continue; - - let lineNumber: number | undefined; - let columnNumber: number | undefined; - let endLineNumber: number | undefined; - - if (path.startsWith("file://")) { - try { - const url = new URL(path); - path = decodeURIComponent(url.pathname); - if (url.hostname && !url.pathname.startsWith("/")) { - path = `${url.hostname}:${path}`; - } - } catch { - continue; - } - } - - if (lineNumStr) { - lineNumber = parseInt(lineNumStr, 10); - } - if (endLineStr) { - endLineNumber = parseInt(endLineStr, 10); - } - - if (!hasAtPrefix && !lineNumStr) { - const posRegex = /^(.*?):(\d+)(?::(\d+))?$/; - const posMatch = path.match(posRegex); - if (posMatch) { - path = posMatch[1]; - lineNumber = parseInt(posMatch[2], 10); - if (posMatch[3]) { - columnNumber = parseInt(posMatch[3], 10); - } - } - } - - const index = match.index + (match[0].length - fullMatch.length); + + for (const candidate of collectCandidateReferences(lineText)) { + if (!isLikelyFileReference(candidate.text)) continue; + + const parsedReference = parseFileReference(candidate.text); + if (!parsedReference) continue; links.push({ - text: fullMatch, + text: candidate.text, range: { - start: { x: index + 1, y: bufferLineNumber }, - end: { x: index + fullMatch.length, y: bufferLineNumber }, + start: { x: candidate.startIndex + 1, y: bufferLineNumber }, + end: { + x: candidate.startIndex + candidate.text.length, + y: bufferLineNumber, + }, }, activate: () => { postMessage({ type: "openFile", - path: path, - line: lineNumber, - endLine: endLineNumber, - column: columnNumber, + path: parsedReference.path, + line: parsedReference.line, + endLine: parsedReference.endLine, + column: parsedReference.column, }); }, }); - - match = PATH_REGEX.exec(lineText); } callback(links);