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
5 changes: 5 additions & 0 deletions .changeset/bundle-ripgrep-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"zoo-code": patch
---

Bundle a ripgrep binary with the extension and fall back to it when ripgrep cannot be located in the VS Code installation. Fixes "Could not find ripgrep binary" errors that break search_files / list_files on VS Code Insiders' newer staged-install layout.
1 change: 1 addition & 0 deletions .github/workflows/marketplace-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jobs:
grep -q "extension/webview-ui/build/assets/index.js" /tmp/zoo-code-vsix-contents.txt
grep -q "extension/assets/codicons/codicon.ttf" /tmp/zoo-code-vsix-contents.txt
grep -q "extension/assets/vscode-material-icons/icons/3d.svg" /tmp/zoo-code-vsix-contents.txt
grep -qE "extension/dist/bin/[^/]+/rg" /tmp/zoo-code-vsix-contents.txt
- name: Validate packaged manifest identity
run: |
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions src/esbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,31 @@ async function main() {
})
},
},
{
name: "copyRipgrep",
setup(build) {
build.onEnd(async () => {
// Copy the ripgrep binary into dist/bin/<platform>-<arch>/ so it
// ships inside the universal VSIX. getBinPath() in
// src/services/ripgrep/index.ts falls back to this bundled copy
// when ripgrep cannot be located in the VS Code installation
// (e.g. VS Code Insiders' staged-install layout). The
// <platform>-<arch> subfolder keeps the runtime fallback from
// picking a binary built for a different OS (the name `rg` is
// shared by Linux and macOS).
const { rgPath } = await import("@vscode/ripgrep")
if (!rgPath) {
throw new Error("[copyRipgrep] @vscode/ripgrep did not provide rgPath")
}
const rgDestDir = path.join(distDir, "bin", `${process.platform}-${process.arch}`)
fs.mkdirSync(rgDestDir, { recursive: true })
const rgDest = path.join(rgDestDir, path.basename(rgPath))
fs.copyFileSync(rgPath, rgDest)
fs.chmodSync(rgDest, 0o755)
console.log(`[copyRipgrep] Copied ${rgPath} to ${rgDest}`)
Comment on lines +100 to +109
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 838fb91.

copyRipgrep now writes the binary under dist/bin/${process.platform}-${process.arch}/, and getBinPath resolves the matching <platform>-<arch> segment at runtime. A binary built for one OS can no longer be picked up by the fallback on another — even though the VSIX is universal and rg shares a filename across Linux and macOS.

})
},
},
{
name: "copyWasms",
setup(build) {
Expand Down
1 change: 1 addition & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/vscode": "^1.84.0",
"@vscode/ripgrep": "^1.17.0",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "3.3.2",
"ai": "^6.0.75",
Expand Down
54 changes: 53 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 } from "vitest"

import { truncateLine, getBinPath, bundledRgPath } 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,45 @@ describe("Ripgrep line truncation", () => {
expect(truncated).toContain("[truncated...]")
})
})

describe("getBinPath bundled-ripgrep fallback", () => {
beforeEach(() => {
mockFileExists.mockReset()
})

it("falls back to the bundled ripgrep when ripgrep is absent under the VS Code app root", async () => {
// VS Code Insiders' staged-install layout: nothing under appRoot.
mockFileExists.mockImplementation(async (p: string) => p === bundledRgPath)

const result = await getBinPath("/fake/vscode/app/root")

expect(result).toBe(bundledRgPath)
})

it("prefers VS Code's own ripgrep over the bundled copy", async () => {
const appRoot = "/fake/vscode/app/root"
// Derive the binary name from bundledRgPath so this test tracks the
// module's own platform logic instead of duplicating it.
const vscodeRg = path.join(appRoot, "node_modules/@vscode/ripgrep/bin", path.basename(bundledRgPath))
mockFileExists.mockImplementation(async (p: string) => p === vscodeRg || p === bundledRgPath)

const result = await getBinPath(appRoot)

expect(result).toBe(vscodeRg)
})

it("returns undefined when ripgrep exists nowhere", async () => {
mockFileExists.mockResolvedValue(false)

const result = await getBinPath("/fake/vscode/app/root")

expect(result).toBeUndefined()
})

it("scopes the bundled ripgrep path to the current platform and arch", () => {
// Guards the universal VSIX from handing a wrong-OS binary to the
// fallback: the path must carry a <platform>-<arch> segment so a
// macOS host never resolves a Linux-built `dist/bin/rg`.
expect(bundledRgPath).toContain(path.join("bin", `${process.platform}-${process.arch}`))
})
})
29 changes: 27 additions & 2 deletions src/services/ripgrep/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,33 @@ 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
* Path to the ripgrep binary bundled inside the extension itself.
*
* esbuild copies the platform `rg`/`rg.exe` into `dist/bin/<platform>-<arch>/`
* at build time (see the `copyRipgrep` plugin in esbuild.mjs). At runtime this
* module is bundled into `dist/extension.js`, so `__dirname` is the extension's
* `dist/` directory.
*
* The `<platform>-<arch>` segment is required: the published VSIX is universal
* but carries only the build host's binary. Without it, a macOS user would
* resolve a Linux-built `dist/bin/rg` (the binary name `rg` is shared by Linux
* and macOS) and execute an incompatible binary. With it, the fallback resolves
* only on a host matching the bundled binary.
*/
export const bundledRgPath = path.join(__dirname, "bin", `${process.platform}-${process.arch}`, binName)

/**
* Get the path to the ripgrep binary.
*
* Prefers the copy shipped inside the VS Code installation; falls back to the
* binary bundled with this extension when the VS Code copy cannot be located
* (e.g. VS Code Insiders' staged-install layout, see microsoft/vscode#252063).
*/
export async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
/**
* Resolve `<pkgFolder>/<binName>` under the VS Code application root,
* returning the absolute path when the ripgrep binary exists there.
*/
const checkPath = async (pkgFolder: string) => {
const fullPath = path.join(vscodeAppRoot, pkgFolder, binName)
return (await fileExistsAtPath(fullPath)) ? fullPath : undefined
Expand All @@ -92,7 +116,8 @@ 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 fileExistsAtPath(bundledRgPath)) ? bundledRgPath : undefined)
Comment on lines 116 to +120
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 838fb91.

The bundled binary is now copied to and resolved from dist/bin/<platform>-<arch>/ (e.g. dist/bin/linux-x64/rg). bundledRgPath includes the ${process.platform}-${process.arch} segment, so on macOS it resolves to dist/bin/darwin-arm64/rg — which a Linux-built universal VSIX does not contain — and the fallback correctly yields nothing instead of an incompatible binary. A test was added asserting the path is platform/arch-scoped.

)
}

Expand Down
Loading