Skip to content

Commit dcb043c

Browse files
feat: add Zoo Code: Show Ripgrep Diagnostic command
Adds a user-triggerable diagnostic for the "Could not find ripgrep binary" error class. The command runs the same hybrid resolution that #248 tested — try require("@vscode/ripgrep"), then probe known appRoot-relative paths — and writes a verbose report to a dedicated output channel, also copying it to the clipboard. Motivated by #248: debugging that bug required a custom test build to inspect resolution state on the user's machine. With this command shipped, future users hitting weird ripgrep-resolution behavior can paste the diagnostic into a bug report without us needing to build instrumented VSIXs. Reintroduces the @vscode/ripgrep devDep and esbuild external entry that #248 dropped — the diagnostic needs to call require() to report what happens. The require attempt also serves as forward- compat: when VS Code completes the @vscode/ripgrep → @vscode/ ripgrep-universal package-rename migration, it will start succeeding broadly, and the diagnostic output will be the signal that we can revisit the require approach in getBinPath itself. - src/services/ripgrep/diagnostic.ts: new module (data fn + cmd wrapper) - src/services/ripgrep/internal/loadRipgrep.ts: testable require wrapper - zoo-code.showRipgrepDiagnostic command wired up in registerCommands.ts and contributed via package.json - Tests for the data function
1 parent b5c5e21 commit dcb043c

7 files changed

Lines changed: 219 additions & 1 deletion

File tree

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/activate/registerCommands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { handleNewTask } from "./handleTask"
1313
import { CodeIndexManager } from "../services/code-index/manager"
1414
import { importSettingsWithFeedback } from "../core/config/importExport"
1515
import { MdmService } from "../services/mdm/MdmService"
16+
import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic"
1617
import { t } from "../i18n"
1718

1819
/**
@@ -68,6 +69,8 @@ export const registerCommands = (options: RegisterCommandOptions) => {
6869
const command = getCommand(id as CommandId)
6970
context.subscriptions.push(vscode.commands.registerCommand(command, callback))
7071
}
72+
73+
context.subscriptions.push(registerRipgrepDiagnosticCommand())
7174
}
7275

7376
const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOptions): Record<CommandId, any> => ({

src/esbuild.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ async function main() {
126126
// global-agent must be external because it dynamically patches Node.js http/https modules
127127
// which breaks when bundled. It needs access to the actual Node.js module instances.
128128
// undici must be bundled because our VSIX is packaged with `--no-dependencies`.
129-
external: ["vscode", "esbuild", "global-agent"],
129+
external: ["vscode", "esbuild", "global-agent", "@vscode/ripgrep"],
130130
}
131131

132132
/**

src/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@
160160
"title": "%command.acceptInput.title%",
161161
"category": "%configuration.title%"
162162
},
163+
{
164+
"command": "zoo-code.showRipgrepDiagnostic",
165+
"title": "Show Ripgrep Diagnostic",
166+
"category": "%configuration.title%"
167+
},
163168
{
164169
"command": "zoo-code.toggleAutoApprove",
165170
"title": "%command.toggleAutoApprove.title%",
@@ -554,6 +559,7 @@
554559
"@types/tmp": "^0.2.6",
555560
"@types/turndown": "^5.0.5",
556561
"@types/vscode": "^1.84.0",
562+
"@vscode/ripgrep": "^1.17.0",
557563
"@vscode/test-electron": "^2.5.2",
558564
"@vscode/vsce": "3.3.2",
559565
"ai": "^6.0.75",
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// npx vitest run src/services/ripgrep/__tests__/diagnostic.spec.ts
2+
3+
import * as path from "path"
4+
5+
import { vi, describe, it, expect, beforeEach } from "vitest"
6+
7+
import { getRipgrepDiagnostic } from "../diagnostic"
8+
9+
const ripgrepMock = vi.hoisted(() => ({
10+
value: undefined as { rgPath?: string } | undefined,
11+
}))
12+
13+
const fsMock = vi.hoisted(() => ({
14+
existing: new Set<string>(),
15+
}))
16+
17+
vi.mock("../internal/loadRipgrep", () => ({
18+
loadRipgrep: () => ripgrepMock.value,
19+
}))
20+
21+
vi.mock("../../../utils/fs", () => ({
22+
fileExistsAtPath: (p: string) => Promise.resolve(fsMock.existing.has(p)),
23+
}))
24+
25+
const APP_ROOT = "/app"
26+
27+
const binName = process.platform.startsWith("win") ? "rg.exe" : "rg"
28+
const universalRelBin = `bin/${process.platform}-${process.arch}/${binName}`
29+
30+
const expectedCandidates = [
31+
path.join(APP_ROOT, "node_modules", "@vscode", "ripgrep", "bin", binName),
32+
path.join(APP_ROOT, "node_modules", "vscode-ripgrep", "bin", binName),
33+
path.join(APP_ROOT, "node_modules.asar.unpacked", "vscode-ripgrep", "bin", binName),
34+
path.join(APP_ROOT, "node_modules.asar.unpacked", "@vscode", "ripgrep", "bin", binName),
35+
path.join(APP_ROOT, "node_modules", "@vscode", "ripgrep-universal", ...universalRelBin.split("/")),
36+
path.join(APP_ROOT, "node_modules.asar.unpacked", "@vscode", "ripgrep-universal", ...universalRelBin.split("/")),
37+
]
38+
39+
describe("getRipgrepDiagnostic", () => {
40+
beforeEach(() => {
41+
ripgrepMock.value = undefined
42+
fsMock.existing = new Set<string>()
43+
})
44+
45+
it("includes rgPath and fileExistsAtPath: true when loadRipgrep returns an existing path", async () => {
46+
const rgPath = "/some/path"
47+
ripgrepMock.value = { rgPath }
48+
fsMock.existing = new Set([rgPath])
49+
50+
const report = await getRipgrepDiagnostic(APP_ROOT)
51+
52+
expect(report).toContain("rgPath: /some/path")
53+
expect(report).toContain("fileExistsAtPath: true")
54+
expect(report).toContain("after .asar→.asar.unpacked: /some/path")
55+
})
56+
57+
it("rewrites node_modules.asar to node_modules.asar.unpacked in the report", async () => {
58+
const rgPath = "/app/node_modules.asar/foo/rg"
59+
const substituted = "/app/node_modules.asar.unpacked/foo/rg"
60+
ripgrepMock.value = { rgPath }
61+
fsMock.existing = new Set([substituted])
62+
63+
const report = await getRipgrepDiagnostic(APP_ROOT)
64+
65+
expect(report).toContain(`after .asar→.asar.unpacked: ${substituted}`)
66+
expect(report).toContain("fileExistsAtPath: true")
67+
})
68+
69+
it("reports require failure when loadRipgrep returns undefined", async () => {
70+
ripgrepMock.value = undefined
71+
72+
const report = await getRipgrepDiagnostic(APP_ROOT)
73+
74+
expect(report).toContain("loadRipgrep() returned undefined (require threw)")
75+
})
76+
77+
it("reports rgPath: (undefined) when loadRipgrep returns an object without rgPath", async () => {
78+
ripgrepMock.value = {}
79+
80+
const report = await getRipgrepDiagnostic(APP_ROOT)
81+
82+
expect(report).toContain("rgPath: (undefined)")
83+
expect(report).not.toContain("after .asar→.asar.unpacked:")
84+
})
85+
86+
it("marks only the first probe candidate as found when only it exists", async () => {
87+
fsMock.existing = new Set([expectedCandidates[0]])
88+
89+
const report = await getRipgrepDiagnostic(APP_ROOT)
90+
91+
const found = expectedCandidates.filter((c) => report.includes(`✓ ${c}`))
92+
const missing = expectedCandidates.filter((c) => report.includes(`✗ ${c}`))
93+
94+
expect(found).toEqual([expectedCandidates[0]])
95+
expect(missing).toEqual(expectedCandidates.slice(1))
96+
})
97+
98+
it("marks all probe candidates as missing when none exist", async () => {
99+
fsMock.existing = new Set<string>()
100+
101+
const report = await getRipgrepDiagnostic(APP_ROOT)
102+
103+
for (const candidate of expectedCandidates) {
104+
expect(report).toContain(`✗ ${candidate}`)
105+
}
106+
expect(report).not.toContain("✓ ")
107+
})
108+
})

src/services/ripgrep/diagnostic.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import * as path from "path"
2+
import * as vscode from "vscode"
3+
4+
import { fileExistsAtPath } from "../../utils/fs"
5+
import { loadRipgrep } from "./internal/loadRipgrep"
6+
7+
const binName = process.platform.startsWith("win") ? "rg.exe" : "rg"
8+
const universalBin = `bin/${process.platform}-${process.arch}/${binName}`
9+
10+
function probeCandidates(vscodeAppRoot: string): readonly string[] {
11+
return [
12+
path.join(vscodeAppRoot, "node_modules", "@vscode", "ripgrep", "bin", binName),
13+
path.join(vscodeAppRoot, "node_modules", "vscode-ripgrep", "bin", binName),
14+
path.join(vscodeAppRoot, "node_modules.asar.unpacked", "vscode-ripgrep", "bin", binName),
15+
path.join(vscodeAppRoot, "node_modules.asar.unpacked", "@vscode", "ripgrep", "bin", binName),
16+
path.join(vscodeAppRoot, "node_modules", "@vscode", "ripgrep-universal", ...universalBin.split("/")),
17+
path.join(
18+
vscodeAppRoot,
19+
"node_modules.asar.unpacked",
20+
"@vscode",
21+
"ripgrep-universal",
22+
...universalBin.split("/"),
23+
),
24+
]
25+
}
26+
27+
/**
28+
* Produces a textual diagnostic report of how ripgrep would be resolved
29+
* for the given VS Code installation. Pure data function — no UI side
30+
* effects — so it's fully unit-testable.
31+
*
32+
* Step 1 tries `loadRipgrep()` (CommonJS require, hits VS Code's
33+
* extHost interceptor on builds that have completed the
34+
* `@vscode/ripgrep` → `@vscode/ripgrep-universal` migration).
35+
* Step 2 probes every known `vscode.env.appRoot`-relative path and
36+
* reports which ones exist on disk.
37+
*/
38+
export async function getRipgrepDiagnostic(vscodeAppRoot: string): Promise<string> {
39+
const lines: string[] = [
40+
`Zoo Code Ripgrep Diagnostic (${new Date().toISOString()})`,
41+
`vscode.version: ${vscode.version}`,
42+
`vscode.env.appRoot: ${vscodeAppRoot}`,
43+
`process.platform/arch: ${process.platform}/${process.arch}`,
44+
``,
45+
`--- step 1: require("@vscode/ripgrep") via loadRipgrep ---`,
46+
]
47+
const m = loadRipgrep()
48+
if (!m) {
49+
lines.push(`loadRipgrep() returned undefined (require threw)`)
50+
} else {
51+
const keys = Object.keys(m).join(",") || "(none)"
52+
lines.push(`loadRipgrep() returned object. keys: ${keys}`)
53+
lines.push(`rgPath: ${m.rgPath ?? "(undefined)"}`)
54+
if (m.rgPath) {
55+
const fixed = m.rgPath.replace(/\bnode_modules\.asar\b/, "node_modules.asar.unpacked")
56+
lines.push(`after .asar→.asar.unpacked: ${fixed}`)
57+
lines.push(`fileExistsAtPath: ${await fileExistsAtPath(fixed)}`)
58+
}
59+
}
60+
lines.push(``)
61+
lines.push(`--- step 2: path probe under appRoot ---`)
62+
for (const candidate of probeCandidates(vscodeAppRoot)) {
63+
lines.push(` ${(await fileExistsAtPath(candidate)) ? "✓" : "✗"} ${candidate}`)
64+
}
65+
return lines.join("\n")
66+
}
67+
68+
/**
69+
* Registers the `zoo-code.showRipgrepDiagnostic` command. Thin wrapper —
70+
* runs `getRipgrepDiagnostic`, shows the result in an output channel,
71+
* copies it to the clipboard, and shows an info toast.
72+
*/
73+
export function registerRipgrepDiagnosticCommand(): vscode.Disposable {
74+
return vscode.commands.registerCommand("zoo-code.showRipgrepDiagnostic", async () => {
75+
const report = await getRipgrepDiagnostic(vscode.env.appRoot)
76+
const channel = vscode.window.createOutputChannel("Zoo Code Ripgrep Diagnostic")
77+
channel.appendLine(report)
78+
channel.show(true)
79+
await vscode.env.clipboard.writeText(report)
80+
await vscode.window.showInformationMessage("Zoo Code: ripgrep diagnostic copied to clipboard.")
81+
})
82+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Loads `@vscode/ripgrep` via CommonJS `require()`. Lives in its own
3+
* module so unit tests can `vi.mock` the wrapper — vitest's mock registry
4+
* hooks the import graph, not Node's native CJS resolver, and
5+
* `@vscode/ripgrep` resolves through the latter at test time because it's
6+
* a real devDep installed in `node_modules`.
7+
*
8+
* Returns `undefined` if the package can't be loaded for any reason.
9+
*/
10+
export function loadRipgrep(): { rgPath?: string } | undefined {
11+
try {
12+
return require("@vscode/ripgrep") as { rgPath?: string }
13+
} catch {
14+
return undefined
15+
}
16+
}

0 commit comments

Comments
 (0)