diff --git a/core/util/ideUtils.ts b/core/util/ideUtils.ts index 352aad6ca1..3f6d973c60 100644 --- a/core/util/ideUtils.ts +++ b/core/util/ideUtils.ts @@ -6,6 +6,36 @@ import { pathToUriPathSegment, } from "./uri"; +/* + Helper function to normalize workspace directory paths to proper file:// URIs. + This handles cases where workspace directories might be returned as plain file paths + instead of URIs, which can occur on some IDE extensions like IntelliJ on Linux. + + Examples: + - Input: "/home/user/project" -> Output: "file:///home/user/project" + - Input: "file:///home/user/project" -> Output: "file:///home/user/project" +*/ +export function normalizeDirUri(dirPath: string): string { + // If it's already a URI with a scheme, return as-is + if (dirPath.includes("://")) { + return dirPath; + } + + // Convert likely absolute filesystem paths to file:// URIs. + // Workspace dirs should already be URIs, but this guards against malformed IDE inputs. + if (dirPath.startsWith("/")) { + return `file:///${pathToUriPathSegment(dirPath)}`; + } + + // For Windows paths (C:\ or drive letters) + if (/^[a-zA-Z]:/.test(dirPath)) { + const normalized = dirPath.replaceAll("\\", "/"); + return `file:///${pathToUriPathSegment(normalized)}`; + } + + return dirPath; +} + /* This function takes a relative (to workspace) filepath And checks each workspace for if it exists or not @@ -18,7 +48,10 @@ export async function resolveRelativePathInDir( ): Promise { const dirs = dirUriCandidates ?? (await ide.getWorkspaceDirs()); for (const dirUri of dirs) { - const fullUri = joinPathsToUri(dirUri, path); + // Normalize the directory URI to ensure it's a proper file:// URI + // This handles cases where workspace directories might be plain file paths + const normalizedDirUri = normalizeDirUri(dirUri); + const fullUri = joinPathsToUri(normalizedDirUri, path); if (await ide.fileExists(fullUri)) { return fullUri; } @@ -39,7 +72,10 @@ export async function inferResolvedUriFromRelativePath( dirCandidates?: string[], ): Promise { const relativePath = _relativePath.trim().replaceAll("\\", "/"); - const dirs = dirCandidates ?? (await ide.getWorkspaceDirs()); + const rawDirs = dirCandidates ?? (await ide.getWorkspaceDirs()); + + // Normalize all directories to proper file:// URIs + const dirs = rawDirs.map(normalizeDirUri); if (dirs.length === 0) { throw new Error("inferResolvedUriFromRelativePath: no dirs provided"); diff --git a/core/util/ideUtils.vitest.ts b/core/util/ideUtils.vitest.ts new file mode 100644 index 0000000000..ca55768cb3 --- /dev/null +++ b/core/util/ideUtils.vitest.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; +import { IDE } from ".."; +import { + inferResolvedUriFromRelativePath, + normalizeDirUri, + resolveRelativePathInDir, +} from "./ideUtils"; + +describe("normalizeDirUri", () => { + it("should pass through file:// URIs unchanged", () => { + expect(normalizeDirUri("file:///home/user/project")).toBe( + "file:///home/user/project", + ); + }); + + it("should convert Linux absolute path to file:// URI", () => { + expect(normalizeDirUri("/home/user/project")).toBe("file:///home/user/project"); + }); + + it("should encode special characters in Linux paths", () => { + expect(normalizeDirUri("/home/user/my project")).toBe("file:///home/user/my%20project"); + }); + + it("should convert Windows drive path to file:// URI", () => { + expect(normalizeDirUri("C:\\Users\\user\\project")).toBe("file:///C%3A/Users/user/project"); + }); +}); + +function createMockIde(opts: { + workspaceDirs: string[]; + existingFiles?: Set; +}): IDE { + const existingFiles = opts.existingFiles ?? new Set(); + return { + getWorkspaceDirs: vi.fn(async () => opts.workspaceDirs), + fileExists: vi.fn(async (uri: string) => existingFiles.has(uri)), + } as unknown as IDE; +} + +describe("resolveRelativePathInDir", () => { + it("should resolve a relative path against a proper file:// workspace dir", async () => { + const ide = createMockIde({ + workspaceDirs: ["file:///home/user/project"], + existingFiles: new Set(["file:///home/user/project/src/file.ts"]), + }); + const result = await resolveRelativePathInDir("src/file.ts", ide); + expect(result).toBe("file:///home/user/project/src/file.ts"); + }); + + it("should resolve when workspace dir is a plain Linux path (bug #11559)", async () => { + const ide = createMockIde({ + workspaceDirs: ["/home/user/project"], + existingFiles: new Set(["file:///home/user/project/src/file.ts"]), + }); + const result = await resolveRelativePathInDir("src/file.ts", ide); + expect(result).toBe("file:///home/user/project/src/file.ts"); + }); + + it("should NOT produce a double-prefixed path (regression guard)", async () => { + const ide = createMockIde({ + workspaceDirs: ["/home/user/project"], + existingFiles: new Set(["file:///home/user/project/src/file.ts"]), + }); + const result = await resolveRelativePathInDir("src/file.ts", ide); + expect(result).not.toContain("/home/user/home/"); + }); +}); + +describe("inferResolvedUriFromRelativePath", () => { + it("should join relative path to normalized workspace dir", async () => { + const ide = createMockIde({ + workspaceDirs: ["file:///home/user/project"], + }); + const result = await inferResolvedUriFromRelativePath("src/file.ts", ide); + expect(result).toBe("file:///home/user/project/src/file.ts"); + }); + + it("should produce correct URI when workspace dir is plain path (bug #11559)", async () => { + const ide = createMockIde({ + workspaceDirs: ["/home/user/project"], + }); + const result = await inferResolvedUriFromRelativePath("src/file.ts", ide); + expect(result).toBe("file:///home/user/project/src/file.ts"); + }); + + it("should NOT produce double-prefixed path (regression guard)", async () => { + const ide = createMockIde({ + workspaceDirs: ["/home/user/project"], + }); + const result = await inferResolvedUriFromRelativePath("src/file.ts", ide); + expect(result).not.toContain("/home/user/home/"); + }); +});