import { spawn } from "node:child_process"; const MAX_PATTERN_LEN = 8192; export interface RgMatchRow { path: string; lineNumber: number; line: string; } export interface RgResult { matches: RgMatchRow[]; truncated: boolean; exitCode: number; stderr: string; } const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); const readPathText = (v: unknown): string => { if (!isRecord(v)) { return ""; } const t = v.text; return typeof t === "string" ? t : ""; }; const readLinesText = (v: unknown): string => { if (!isRecord(v)) { return ""; } const t = v.lines; if (!isRecord(t)) { return ""; } const text = t.text; return typeof text === "string" ? text : ""; }; export const runRipgrepJson = ( pattern: string, searchPath: string, maxMatches: number, timeoutMs: number, ): Promise => { if (pattern.length === 0) { return Promise.resolve({ matches: [], truncated: false, exitCode: 2, stderr: "Empty pattern", }); } if (pattern.length > MAX_PATTERN_LEN) { return Promise.resolve({ matches: [], truncated: false, exitCode: 2, stderr: "Pattern too long", }); } return new Promise((resolve) => { const matches: RgMatchRow[] = []; let truncated = false; let stderr = ""; let settled = false; let timer: ReturnType | undefined; const finish = (r: RgResult): void => { if (settled) { return; } settled = true; if (timer !== undefined) { clearTimeout(timer); } resolve(r); }; const child = spawn( "rg", [ "--json", "--line-number", "--regexp", pattern, "--", searchPath, ], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }, ); timer = setTimeout(() => { child.kill("SIGKILL"); finish({ matches, truncated: true, exitCode: 124, stderr: `${stderr}\nTimed out`.trim(), }); }, timeoutMs); const flushErr = (): void => { child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); }); }; flushErr(); let buf = ""; child.stdout?.setEncoding("utf8"); child.stdout?.on("data", (chunk: string) => { buf += chunk; let idx: number; while ((idx = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, idx); buf = buf.slice(idx + 1); if (line.length === 0) { continue; } let obj: unknown; try { obj = JSON.parse(line) as unknown; } catch { continue; } if (!isRecord(obj) || obj.type !== "match") { continue; } const data = obj.data; if (!isRecord(data)) { continue; } const pathText = readPathText(data.path); const lineNum = data.line_number; const lineText = readLinesText(data).replace(/\n$/, ""); if (typeof lineNum !== "number" || pathText.length === 0) { continue; } matches.push({ path: pathText, lineNumber: lineNum, line: lineText, }); if (matches.length >= maxMatches) { truncated = true; child.kill("SIGTERM"); break; } } }); child.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ENOENT") { finish({ matches: [], truncated: false, exitCode: 127, stderr: "ripgrep (rg) not found in PATH", }); return; } finish({ matches, truncated, exitCode: 1, stderr: `${stderr}\n${err.message}`.trim(), }); }); child.on("close", (code) => { finish({ matches, truncated, exitCode: code ?? 0, stderr: stderr.trim(), }); }); }); };