import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { PilotDeckToolRuntimeError } from "../../protocol/errors.js";
const require = createRequire(import.meta.url);
const { rgPath } = require("@vscode/ripgrep") as { rgPath: string };
const DEFAULT_TIMEOUT_MS = 20_000;
const DEFAULT_KILL_GRACE_MS = 1_000;
export const IGNORED_DIRECTORIES = new Set([".git", "node_modules", "dist"]);
export type RipgrepRunInput = {
cwd: string;
args: string[];
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
toolName: "glob" | "grep";
};
export async function runRipgrep(input: RipgrepRunInput): Promise<string> {
const env = input.env ?? process.env;
const args = [...input.args];
return await new Promise<string>((resolve, reject) => {
const child = spawn(rgPath, args, {
cwd: input.cwd,
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
const cleanupAbort = attachAbortHandler(child, input.signal, () => {
if (settled) return;
settled = true;
reject(new PilotDeckToolRuntimeError("tool_aborted", `${input.toolName} search aborted.`));
});
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), DEFAULT_KILL_GRACE_MS).unref();
reject(
new PilotDeckToolRuntimeError(
"tool_timeout",
`${input.toolName} search timed out after ${DEFAULT_TIMEOUT_MS}ms.`,
),
);
}, DEFAULT_TIMEOUT_MS);
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk: string) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timeout);
cleanupAbort();
if (settled) return;
settled = true;
if (isEnoent(error)) {
reject(
new PilotDeckToolRuntimeError(
"unsupported_tool",
`${input.toolName} requires ripgrep (\`rg\`) to be installed and available on PATH.`,
),
);
return;
}
reject(
new PilotDeckToolRuntimeError(
"tool_execution_failed",
`ripgrep ${input.toolName} search failed: ${error.message}`,
),
);
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
cleanupAbort();
if (settled) return;
settled = true;
if (signal) {
reject(
new PilotDeckToolRuntimeError(
"tool_execution_failed",
`ripgrep ${input.toolName} search exited via signal ${signal}.`,
),
);
return;
}
if (code === 0 || code === 1) {
resolve(stdout);
return;
}
const stderrText = stderr.trim();
reject(
new PilotDeckToolRuntimeError(
"tool_execution_failed",
stderrText.length > 0
? `ripgrep ${input.toolName} search failed: ${stderrText}`
: `ripgrep ${input.toolName} search failed with exit code ${code}.`,
{ exitCode: code, stderr: stderrText || undefined },
),
);
});
});
}
export function splitRipgrepLines(stdout: string): string[] {
return stdout
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter((line) => line.length > 0);
}
export function normalizeRelativePath(file: string): string {
const normalized = file.split(path.sep).join("/");
const withoutDotPrefix = normalized.replace(/^\.\//, "");
return path.posix.normalize(withoutDotPrefix);
}
export function isIgnoredPath(file: string): boolean {
return file.split("/").some((segment) => IGNORED_DIRECTORIES.has(segment));
}
function attachAbortHandler(
child: ReturnType<typeof spawn>,
signal: AbortSignal | undefined,
onAbort: () => void,
): () => void {
if (!signal) {
return () => {};
}
const handler = () => {
child.kill("SIGTERM");
setTimeout(() => child.kill("SIGKILL"), DEFAULT_KILL_GRACE_MS).unref();
onAbort();
};
signal.addEventListener("abort", handler, { once: true });
return () => signal.removeEventListener("abort", handler);
}
function isEnoent(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}