Skip to content
Closed
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
70 changes: 69 additions & 1 deletion src/services/ripgrep/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
// npx vitest run src/services/ripgrep/__tests__/index.spec.ts

import { truncateLine } from "../index"
import path from "path"
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"

import { truncateLine, getBinPath } from "../index"
import { fileExistsAtPath } from "../../../utils/fs"

vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: vi.fn(),
}))

const mockFileExists = vi.mocked(fileExistsAtPath)

describe("Ripgrep line truncation", () => {
// The default MAX_LINE_LENGTH is 500 in the implementation
Expand Down Expand Up @@ -48,3 +58,61 @@ describe("Ripgrep line truncation", () => {
expect(truncated).toContain("[truncated...]")
})
})

describe("getBinPath", () => {
const appRoot = "/fake/vscode/appRoot"
const binName = process.platform.startsWith("win") ? "rg.exe" : "rg"
const platformDir = `${process.platform}-${process.arch}`
const originalPath = process.env.PATH

beforeEach(() => {
mockFileExists.mockReset()
mockFileExists.mockResolvedValue(false)
})

afterEach(() => {
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})

it("resolves ripgrep from the classic @vscode/ripgrep layout", async () => {
const rg = path.join(appRoot, "node_modules/@vscode/ripgrep/bin", binName)
mockFileExists.mockImplementation(async (p: string) => p === rg)

expect(await getBinPath(appRoot)).toBe(rg)
})

it("resolves ripgrep from the @vscode/ripgrep-universal layout (VS Code Insiders)", async () => {
const rg = path.join(appRoot, "node_modules/@vscode/ripgrep-universal/bin", platformDir, binName)
mockFileExists.mockImplementation(async (p: string) => p === rg)

expect(await getBinPath(appRoot)).toBe(rg)
})

it("falls back to ripgrep on the system PATH when the VS Code copy is absent", async () => {
process.env.PATH = ["/fake/empty", "/fake/tools"].join(path.delimiter)
const rg = path.join("/fake/tools", binName)
mockFileExists.mockImplementation(async (p: string) => p === rg)

expect(await getBinPath(appRoot)).toBe(rg)
})

it("prefers the VS Code copy over the system PATH", async () => {
process.env.PATH = "/fake/tools"
const vscodeRg = path.join(appRoot, "node_modules/@vscode/ripgrep/bin", binName)
const pathRg = path.join("/fake/tools", binName)
mockFileExists.mockImplementation(async (p: string) => p === vscodeRg || p === pathRg)

expect(await getBinPath(appRoot)).toBe(vscodeRg)
})

it("returns undefined when ripgrep cannot be found anywhere", async () => {
process.env.PATH = "/fake/empty"
mockFileExists.mockResolvedValue(false)

expect(await getBinPath(appRoot)).toBeUndefined()
})
})
54 changes: 51 additions & 3 deletions src/services/ripgrep/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This file provides functionality to perform regex searches on files using ripgre
Inspired by: https://github.com/DiscreteTom/vscode-ripgrep-utils

Key components:
1. getBinPath: Locates the ripgrep binary within the VSCode installation.
1. getBinPath: Locates the ripgrep binary (in the VS Code install, or on the system PATH).
2. execRipgrep: Executes the ripgrep command and returns the output.
3. regexSearchFiles: The main function that performs regex searches on files.
- Parameters:
Expand Down Expand Up @@ -51,6 +51,11 @@ rel/path/to/helper.ts
const isWindows = process.platform.startsWith("win")
const binName = isWindows ? "rg.exe" : "rg"

// VS Code's @vscode/ripgrep-universal package (used by recent VS Code builds,
// including the Insiders staged-install layout) nests the binary under
// bin/<platform>-<arch>/ rather than directly in bin/.
const ripgrepUniversalBinDir = `bin/${process.platform}-${process.arch}`

interface SearchFileResult {
file: string
searchResults: SearchResult[]
Expand Down Expand Up @@ -80,7 +85,47 @@ export function truncateLine(line: string, maxLength: number = MAX_LINE_LENGTH):
return line.length > maxLength ? line.substring(0, maxLength) + " [truncated...]" : line
}
/**
* Get the path to the ripgrep binary within the VSCode installation
* Look up the ripgrep binary on the system PATH.
*
* Used as a fallback after the VS Code installation has been checked. Covers
* VS Code forks whose install layout is not recognized by getBinPath, headless
* / CLI hosts with no VS Code installation, and machines where the user has
* installed ripgrep themselves.
*/
async function findRipgrepOnPath(): Promise<string | undefined> {
const pathEnv = process.env.PATH

if (!pathEnv) {
return undefined
}

for (const dir of pathEnv.split(path.delimiter)) {
if (dir.length === 0) {
continue
}

const candidate = path.join(dir, binName)
Comment on lines +102 to +107
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle quoted PATH entries before probing candidate binaries.

findRipgrepOnPath currently uses raw PATH segments. If a segment is quoted (e.g. "C:\Program Files\Ripgrep"), path.join keeps quotes and the existence check fails, so fallback can incorrectly return undefined.

Proposed fix
-	for (const dir of pathEnv.split(path.delimiter)) {
-		if (dir.length === 0) {
+	for (const rawDir of pathEnv.split(path.delimiter)) {
+		const dir = rawDir.trim().replace(/^"(.*)"$/, "$1")
+		if (dir.length === 0) {
 			continue
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/ripgrep/index.ts` around lines 102 - 107, The PATH probing loop
in findRipgrepOnPath uses raw PATH segments (pathEnv split by path.delimiter)
which can include surrounding quotes and cause path.join(dir, binName) to
produce invalid paths; update the loop to trim whitespace and strip any
surrounding single or double quotes from each dir before constructing candidate
(use the existing variables pathEnv, dir, binName, candidate) so the existence
check uses an unquoted directory string and correctly finds the binary.


if (await fileExistsAtPath(candidate)) {
return candidate
}
Comment on lines +95 to +111
}

return undefined
}

/**
* Get the path to the ripgrep binary.
*
* Resolution order:
* 1. ripgrep shipped inside the VS Code installation. Both the long-standing
* `@vscode/ripgrep` layout and the newer `@vscode/ripgrep-universal`
* layout are checked — the latter is what VS Code Insiders' staged-install
* builds use (see microsoft/vscode#252063).
* 2. ripgrep on the system PATH — covers VS Code forks with an unrecognized
* install layout, headless / CLI hosts, and a user-installed ripgrep.
*
* Returns `undefined` when ripgrep cannot be located anywhere.
*/
export async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
const checkPath = async (pkgFolder: string) => {
Expand All @@ -92,7 +137,10 @@ export async function getBinPath(vscodeAppRoot: string): Promise<string | undefi
(await checkPath("node_modules/@vscode/ripgrep/bin/")) ||
(await checkPath("node_modules/vscode-ripgrep/bin")) ||
(await checkPath("node_modules.asar.unpacked/vscode-ripgrep/bin/")) ||
(await checkPath("node_modules.asar.unpacked/@vscode/ripgrep/bin/"))
(await checkPath("node_modules.asar.unpacked/@vscode/ripgrep/bin/")) ||
(await checkPath(`node_modules/@vscode/ripgrep-universal/${ripgrepUniversalBinDir}`)) ||
(await checkPath(`node_modules.asar.unpacked/@vscode/ripgrep-universal/${ripgrepUniversalBinDir}`)) ||
(await findRipgrepOnPath())
)
}

Expand Down
Loading