@d3ara1n/pi-hashline-edit 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * Override grep: search results carry `LINE#HASH│` anchors (same format as
3
3
  * read), grouped by file. The model can copy `LINE#HASH` straight into an edit
4
- * anchor — no re-read needed. Context lines (`-C`) are anchored too.
4
+ * anchor — no re-read needed. Context lines (`context`) are anchored too.
5
+ *
6
+ * Beyond the built-in grep it covers the compound queries models otherwise
7
+ * drop to bash pipelines for: multi-pattern AND (`matchMode: "all"` ≈
8
+ * `grep A | grep B`), line exclusion (`excludePattern` ≈ `grep -v`),
9
+ * whole-word matching (`wordMatch` ≈ `-w`), multiple search roots, and
10
+ * files-only / count output (`outputMode` ≈ `rg -l` / `grep -c`).
5
11
  *
6
12
  * We run ripgrep directly (`--json`) rather than wrap the built-in grep, so we
7
13
  * control formatting and can compute each line's hash from its FULL content
@@ -10,20 +16,28 @@
10
16
  * verifies against the full line — so the hash must be computed from the full
11
17
  * content, independently of what is displayed.)
12
18
  *
13
- * Falls back to the built-in grep when: hashline disabled, aborted, or ripgrep
14
- * cannot be located.
19
+ * Filters run in two places: rg gets every pattern as `-e` (native OR) plus
20
+ * the global flags; the AND / exclude checks then run client-side on each
21
+ * matched line's text (streamed by rg), so `limit` counts final results, not
22
+ * pre-filter candidates. Context windows are likewise rebuilt client-side from
23
+ * the surviving matches — context lines of a filtered-out match never leak.
24
+ *
25
+ * Falls back to the built-in grep when: hashline disabled with plain params,
26
+ * aborted, or ripgrep cannot be located (the built-in can auto-download rg).
27
+ * Extended params never delegate — the built-in would misread them.
15
28
  *
16
29
  * @module pi-hashline-edit/pi
17
30
  */
18
31
 
19
32
  import {
20
- getAgentDir,
21
- createGrepTool,
22
- truncateHead,
23
- truncateLine,
24
- formatSize,
25
- DEFAULT_MAX_BYTES,
33
+ getAgentDir,
34
+ createGrepTool,
35
+ truncateHead,
36
+ truncateLine,
37
+ formatSize,
38
+ DEFAULT_MAX_BYTES,
26
39
  } from "@earendil-works/pi-coding-agent";
40
+ import { Type } from "typebox";
27
41
  import { Text } from "@earendil-works/pi-tui";
28
42
  import { spawn } from "node:child_process";
29
43
  import { createInterface } from "node:readline";
@@ -41,27 +55,183 @@ const GREP_MAX_LINE_LENGTH = 500;
41
55
 
42
56
  /** Locate ripgrep: pi's bundled bin first, then PATH. Returns null if not found. */
43
57
  async function findRg(): Promise<string | null> {
44
- const agentDir = getAgentDir();
45
- const piRg = join(agentDir, "bin", "rg");
46
- try {
47
- await access(piRg, constants.X_OK);
48
- return piRg;
49
- } catch {}
50
- for (const dir of process.env.PATH?.split(delimiter) ?? []) {
51
- if (!dir) continue;
52
- const p = join(dir, "rg");
53
- try {
54
- await access(p, constants.X_OK);
55
- return p;
56
- } catch {}
57
- }
58
- return null;
58
+ const agentDir = getAgentDir();
59
+ const piRg = join(agentDir, "bin", "rg");
60
+ try {
61
+ await access(piRg, constants.X_OK);
62
+ return piRg;
63
+ } catch {}
64
+ for (const dir of process.env.PATH?.split(delimiter) ?? []) {
65
+ if (!dir) continue;
66
+ const p = join(dir, "rg");
67
+ try {
68
+ await access(p, constants.X_OK);
69
+ return p;
70
+ } catch {}
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /** Escape a literal string for use as a regex source. */
76
+ function escapeRegex(s: string): string {
77
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
78
+ }
79
+
80
+ /**
81
+ * Compile a pattern for the client-side line checks (`matchMode: "all"` and
82
+ * `excludePattern`), mirroring the flags rg was given — `literal`,
83
+ * `ignoreCase`, and (for the AND check) `wordMatch` — so a line rg accepted is
84
+ * judged by the same semantics here. Patterns valid in rg but invalid as a JS
85
+ * regex (e.g. `(?P<name>…)`) throw rather than silently degrade.
86
+ */
87
+ function compileLineMatcher(
88
+ pattern: string,
89
+ opts: { literal: boolean; ignoreCase: boolean; word: boolean },
90
+ ): RegExp {
91
+ let source = opts.literal ? escapeRegex(pattern) : pattern;
92
+ if (opts.word) source = `\\b(?:${source})\\b`;
93
+ const flags = opts.ignoreCase ? "i" : "";
94
+ try {
95
+ return new RegExp(source, flags);
96
+ } catch (err) {
97
+ throw new Error(
98
+ `Pattern not supported for line filtering: ${pattern} (${(err as Error).message})`,
99
+ );
100
+ }
101
+ }
102
+
103
+ /** Normalize a `string | string[]` param to an array (`undefined` → `[]`). */
104
+ function toArray(v: string | string[] | undefined): string[] {
105
+ if (v === undefined) return [];
106
+ return Array.isArray(v) ? v : [v];
107
+ }
108
+
109
+ const grepOverrideSchema = Type.Object({
110
+ pattern: Type.Union([Type.String(), Type.Array(Type.String())], {
111
+ description:
112
+ "Search pattern (regex, or literal with literal:true). String or array; an array combines patterns per matchMode (any = OR, all = AND on the same line)",
113
+ }),
114
+ matchMode: Type.Optional(
115
+ Type.Union([Type.Literal("any"), Type.Literal("all")], {
116
+ description:
117
+ 'How multiple patterns combine (default "any"). "any": line matches at least one pattern. "all": line must match every pattern — equivalent to `grep A | grep B`',
118
+ }),
119
+ ),
120
+ excludePattern: Type.Optional(
121
+ Type.Union([Type.String(), Type.Array(Type.String())], {
122
+ description:
123
+ "Drop lines matching this pattern, like grep -v (string or array; same regex/literal/ignoreCase settings as pattern). Applied after pattern matching",
124
+ }),
125
+ ),
126
+ outputMode: Type.Optional(
127
+ Type.Union([Type.Literal("content"), Type.Literal("files"), Type.Literal("count")], {
128
+ description:
129
+ 'Output shape (default "content"). "content": anchored matching lines. "files": only file paths with matches (rg -l). "count": per-file match counts + total (grep -c)',
130
+ }),
131
+ ),
132
+ wordMatch: Type.Optional(Type.Boolean({ description: "Match whole words only (rg -w)" })),
133
+ path: Type.Union([Type.String(), Type.Array(Type.String())], {
134
+ description:
135
+ "Directory or file to search (string or array of paths; default: current directory)",
136
+ }),
137
+ glob: Type.Optional(
138
+ Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" }),
139
+ ),
140
+ ignoreCase: Type.Optional(
141
+ Type.Boolean({ description: "Case-insensitive search (default: false)" }),
142
+ ),
143
+ literal: Type.Optional(
144
+ Type.Boolean({
145
+ description: "Treat pattern as literal string instead of regex (default: false)",
146
+ }),
147
+ ),
148
+ context: Type.Optional(
149
+ Type.Number({
150
+ description:
151
+ "Number of lines to show before and after each match (default: 0); context lines are anchored too",
152
+ }),
153
+ ),
154
+ limit: Type.Optional(
155
+ Type.Number({ description: "Maximum number of matching lines to return (default: 100)" }),
156
+ ),
157
+ });
158
+
159
+ interface RgMatch {
160
+ filePath: string;
161
+ lineNumber: number;
162
+ }
163
+
164
+ interface RgRunResult {
165
+ code: number | null;
166
+ stderr: string;
167
+ stopped: boolean;
59
168
  }
60
169
 
61
- interface RawMatch {
62
- filePath: string;
63
- lineNumber: number;
64
- match: boolean;
170
+ /** @internal — injectable process and fallback boundary for deterministic tests. */
171
+ export interface GrepBackend {
172
+ findRg(): Promise<string | null>;
173
+ runRg(
174
+ rgPath: string,
175
+ args: string[],
176
+ signal: AbortSignal | undefined,
177
+ onLine: (line: string) => boolean,
178
+ ): Promise<RgRunResult>;
179
+ delegate(
180
+ toolCallId: string,
181
+ params: any,
182
+ signal: AbortSignal | undefined,
183
+ onUpdate: any,
184
+ ): Promise<any>;
185
+ }
186
+
187
+ /** Run ripgrep and stream its JSON lines to the caller until it asks to stop. */
188
+ function runRg(
189
+ rgPath: string,
190
+ args: string[],
191
+ signal: AbortSignal | undefined,
192
+ onLine: (line: string) => boolean,
193
+ ): Promise<RgRunResult> {
194
+ return new Promise((resolve, reject) => {
195
+ if (signal?.aborted) {
196
+ reject(new Error("Operation aborted"));
197
+ return;
198
+ }
199
+ const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
200
+ const rl = createInterface({ input: child.stdout });
201
+ let stderr = "";
202
+ let stopped = false;
203
+ let settled = false;
204
+
205
+ const cleanup = () => {
206
+ rl.close();
207
+ signal?.removeEventListener("abort", onAbort);
208
+ };
209
+ const settle = (fn: () => void) => {
210
+ if (settled) return;
211
+ settled = true;
212
+ cleanup();
213
+ fn();
214
+ };
215
+ const stopChild = () => {
216
+ stopped = true;
217
+ if (!child.killed) child.kill();
218
+ };
219
+ const onAbort = () => stopChild();
220
+ signal?.addEventListener("abort", onAbort, { once: true });
221
+ child.stderr?.on("data", (chunk: Buffer) => {
222
+ stderr += chunk.toString();
223
+ });
224
+ rl.on("line", (line: string) => {
225
+ if (!line.trim() || stopped) return;
226
+ if (!onLine(line)) stopChild();
227
+ });
228
+ child.on("error", (error) => {
229
+ settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
230
+ });
231
+ child.on("close", (code) => {
232
+ settle(() => resolve({ code, stderr, stopped }));
233
+ });
234
+ });
65
235
  }
66
236
 
67
237
  /**
@@ -73,282 +243,363 @@ interface RawMatch {
73
243
  * receives the anchored `content` text verbatim — this only affects what the user sees.
74
244
  */
75
245
  function countLeading(s: string): number {
76
- const m = s.match(/^[ \t]*/);
77
- return m ? m[0].length : 0;
246
+ const m = s.match(/^[ \t]*/);
247
+ return m ? m[0].length : 0;
78
248
  }
79
249
 
80
250
  function toDisplayLines(raw: string, theme: any): string[] {
81
- const out: string[] = [];
82
- const lines = raw.split("\n");
83
- let i = 0;
84
- while (i < lines.length) {
85
- const line = lines[i];
86
- const h = line.match(/^(.+?) · (\d+ match(?:es)?)$/);
87
- if (h) {
88
- out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
89
- // collect the anchor lines in this file group
90
- const group: { lineNo: string; content: string }[] = [];
91
- let j = i + 1;
92
- while (j < lines.length) {
93
- const a = parseHashline(lines[j]);
94
- if (!a) break;
95
- group.push({ lineNo: a.lineNo, content: a.content });
96
- j++;
97
- }
98
- // common base = min leading whitespace across the group; fold it into a marker
99
- const base = group.length ? Math.min(...group.map((g) => countLeading(g.content))) : 0;
100
- const marker = base > 0 ? theme.fg("dim", "›") + " " : "";
101
- for (const g of group) {
102
- const body = g.content.slice(base);
103
- out.push(theme.fg("dim", ` ${g.lineNo}: `) + marker + theme.fg("toolOutput", body));
104
- }
105
- i = j;
106
- continue;
107
- }
108
- if (line.startsWith("[")) out.push(theme.fg("warning", line));
109
- else out.push(theme.fg("toolOutput", line));
110
- i++;
111
- }
112
- return out;
251
+ const out: string[] = [];
252
+ const lines = raw.split("\n");
253
+ let i = 0;
254
+ while (i < lines.length) {
255
+ const line = lines[i];
256
+ const h = line.match(/^(.+?) · (\d+ match(?:es)?)$/);
257
+ if (h) {
258
+ out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
259
+ // collect the anchor lines in this file group
260
+ const group: { lineNo: string; content: string }[] = [];
261
+ let j = i + 1;
262
+ while (j < lines.length) {
263
+ const a = parseHashline(lines[j]);
264
+ if (!a) break;
265
+ group.push({ lineNo: a.lineNo, content: a.content });
266
+ j++;
267
+ }
268
+ // common base = min leading whitespace across the group; fold it into a marker
269
+ const base = group.length ? Math.min(...group.map((g) => countLeading(g.content))) : 0;
270
+ const marker = base > 0 ? theme.fg("dim", "›") + " " : "";
271
+ for (const g of group) {
272
+ const body = g.content.slice(base);
273
+ out.push(theme.fg("dim", ` ${g.lineNo}: `) + marker + theme.fg("toolOutput", body));
274
+ }
275
+ i = j;
276
+ continue;
277
+ }
278
+ if (line.startsWith("[")) out.push(theme.fg("warning", line));
279
+ else out.push(theme.fg("toolOutput", line));
280
+ i++;
281
+ }
282
+ return out;
113
283
  }
114
284
 
115
- /** Build the grep override (a ToolDefinition fragment for registerTool). */
285
+ /** Build the production grep override (a ToolDefinition fragment for registerTool). */
116
286
  export function makeGrepOverride(cwd: string) {
117
- const builtin = createGrepTool(cwd);
118
- const delegate = (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) =>
119
- builtin.execute(toolCallId, params, signal, onUpdate);
120
-
121
- return {
122
- name: "grep" as const,
123
- label: "grep",
124
- description:
125
- "Search file contents for a pattern. Matches show per-line content hashes (LINE#HASH│content) grouped by file — copy LINE#HASH straight into an edit anchor, no re-read needed. Context lines (context) are anchored too. Respects .gitignore.",
126
- promptSnippet: "Search file contents; results show LINE#HASH anchors usable directly in edit (no re-read needed)",
127
- promptGuidelines: [
128
- "Results are grouped by file under a `path · N matches` header; each line shows `LINE#HASH│content` (same format as read).",
129
- "Copy `LINE#HASH` straight into an edit `anchor`/`end` — no re-read needed. Context lines (from `context`) are anchored and editable too.",
130
- "Pass `pattern`; optionally `path`, `glob`, `ignoreCase`, `literal`, `context` (lines before+after each match), `limit` (max matches, default 100).",
131
- ],
132
- parameters: builtin.parameters,
133
-
134
- renderShell: "default" as const,
135
-
136
- renderCall(args: any, theme: any) {
137
- const pattern = args?.pattern ?? "";
138
- const p = args?.path ?? ".";
139
- let text =
140
- theme.fg("toolTitle", theme.bold("grep ")) +
141
- theme.fg("accent", `/${pattern}/`) +
142
- theme.fg("toolOutput", ` in ${p}`);
143
- if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
144
- if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
145
- return new Text(text, 0, 0);
146
- },
147
-
148
- renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
149
- if (isPartial) return new Text(theme.fg("warning", "Searching…"), 0, 0);
150
- if (context?.isError) {
151
- const t = result.content?.[0]?.type === "text" ? result.content[0].text.split("\n")[0] : "Error";
152
- return new Text(theme.fg("error", t), 0, 0);
153
- }
154
- const out = result.content?.[0]?.type === "text" ? result.content[0].text : "";
155
- const styled = toDisplayLines(out, theme);
156
- const maxLines = expanded ? styled.length : 15;
157
- const shown = styled.slice(0, maxLines);
158
- const more =
159
- !expanded && styled.length > maxLines
160
- ? `\n${theme.fg("muted", `… (${styled.length - maxLines} more lines)`)}`
161
- : "";
162
- return new Text(shown.join("\n") + more, 0, 0);
163
- },
164
-
165
- async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any): Promise<any> {
166
- const state = getState();
167
- // disabled or already aborted → built-in grep (it handles abort itself)
168
- if (!state.config.enabled || signal?.aborted) return delegate(toolCallId, params, signal, onUpdate);
169
-
170
- const rgPath = await findRg();
171
- // ripgrep unavailable → degrade to the built-in (which can auto-download rg)
172
- if (!rgPath) return delegate(toolCallId, params, signal, onUpdate);
173
-
174
- const { pattern, path: searchDir, glob, ignoreCase, literal, context, limit } = params;
175
- const searchPath = canonicalPath(cwd, searchDir || ".");
176
- const hashLen = state.config.hashLen;
177
-
178
- let isDir = true;
179
- try {
180
- isDir = (await stat(searchPath)).isDirectory();
181
- } catch {
182
- throw new Error(`Path not found: ${searchPath}`);
183
- }
184
-
185
- return new Promise((resolvePromise, reject) => {
186
- if (signal?.aborted) {
187
- reject(new Error("Operation aborted"));
188
- return;
189
- }
190
-
191
- const args = ["--json", "--line-number", "--color=never", "--hidden"];
192
- if (ignoreCase) args.push("--ignore-case");
193
- if (literal) args.push("--fixed-strings");
194
- if (glob) args.push("--glob", glob);
195
- const ctx = context && context > 0 ? context : 0;
196
- if (ctx > 0) args.push("--context", String(ctx));
197
- args.push("--", String(pattern), searchPath);
198
-
199
- const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
200
- const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
201
- const rl = createInterface({ input: child.stdout });
202
- let stderr = "";
203
- let matchCount = 0;
204
- let matchLimitReached = false;
205
- let linesTruncated = false;
206
- let aborted = false;
207
- let killedDueToLimit = false;
208
- const raw: RawMatch[] = [];
209
-
210
- const cleanup = () => {
211
- rl.close();
212
- signal?.removeEventListener("abort", onAbort);
213
- };
214
- const stopChild = (dueToLimit = false) => {
215
- if (!child.killed) {
216
- killedDueToLimit = dueToLimit;
217
- child.kill();
218
- }
219
- };
220
- const onAbort = () => {
221
- aborted = true;
222
- stopChild();
223
- };
224
- signal?.addEventListener("abort", onAbort, { once: true });
225
- child.stderr?.on("data", (chunk: Buffer) => {
226
- stderr += chunk.toString();
227
- });
228
-
229
- rl.on("line", (line: string) => {
230
- if (!line.trim() || matchCount >= effectiveLimit) return;
231
- let event: any;
232
- try {
233
- event = JSON.parse(line);
234
- } catch {
235
- return;
236
- }
237
- if (event.type === "match") {
238
- matchCount++;
239
- const filePath = event.data?.path?.text;
240
- const lineNumber = event.data?.line_number;
241
- if (filePath && typeof lineNumber === "number") raw.push({ filePath, lineNumber, match: true });
242
- if (matchCount >= effectiveLimit) {
243
- matchLimitReached = true;
244
- stopChild(true);
245
- }
246
- } else if (event.type === "context") {
247
- const filePath = event.data?.path?.text;
248
- const lineNumber = event.data?.line_number;
249
- if (filePath && typeof lineNumber === "number") raw.push({ filePath, lineNumber, match: false });
250
- }
251
- });
252
-
253
- child.on("error", (error) => {
254
- cleanup();
255
- reject(new Error(`Failed to run ripgrep: ${error.message}`));
256
- });
257
-
258
- child.on("close", async (code) => {
259
- cleanup();
260
- if (aborted) {
261
- reject(new Error("Operation aborted"));
262
- return;
263
- }
264
- if (!killedDueToLimit && code !== 0 && code !== 1) {
265
- reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
266
- return;
267
- }
268
- if (raw.length === 0) {
269
- resolvePromise({ content: [{ type: "text", text: "No matches found" }], details: undefined });
270
- return;
271
- }
272
-
273
- // Dedupe by (file, line); a line that is both a match and a context line counts as a match.
274
- const map = new Map<string, RawMatch>();
275
- for (const m of raw) {
276
- const key = `${m.filePath}:${m.lineNumber}`;
277
- const prev = map.get(key);
278
- if (!prev || (!prev.match && m.match)) map.set(key, m);
279
- }
280
-
281
- // Group by file, each group sorted by line number.
282
- const byFile = new Map<string, RawMatch[]>();
283
- for (const m of map.values()) {
284
- const arr = byFile.get(m.filePath) ?? [];
285
- arr.push(m);
286
- byFile.set(m.filePath, arr);
287
- }
288
- for (const arr of byFile.values()) arr.sort((a, b) => a.lineNumber - b.lineNumber);
289
-
290
- // Read each file once and hash all its lines; hash is computed from the FULL line.
291
- const fileCache = new Map<string, { lines: string[]; hashes: string[] }>();
292
- const getFile = async (fp: string) => {
293
- let entry = fileCache.get(fp);
294
- if (!entry) {
295
- let content = "";
296
- try {
297
- content = (await readFile(fp)).toString("utf-8");
298
- } catch {
299
- content = "";
300
- }
301
- const lines = splitLines(content);
302
- entry = { lines, hashes: hashFileLines(lines, hashLen) };
303
- fileCache.set(fp, entry);
304
- }
305
- return entry;
306
- };
307
-
308
- const formatPath = (fp: string): string => {
309
- if (isDir) {
310
- const rel = relative(searchPath, fp).replace(/\\/g, "/");
311
- if (rel && !rel.startsWith("..")) return rel;
312
- }
313
- return basename(fp);
314
- };
315
-
316
- const blocks: string[] = [];
317
- for (const [fp, matches] of byFile) {
318
- const { lines, hashes } = await getFile(fp);
319
- const n = matches.filter((m) => m.match).length;
320
- const header = `${formatPath(fp)} · ${n} match${n !== 1 ? "es" : ""}`;
321
- const rows: string[] = [];
322
- for (const m of matches) {
323
- const content = lines[m.lineNumber - 1] ?? "";
324
- const hash = hashes[m.lineNumber - 1] ?? "";
325
- const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
326
- if (wasTruncated) linesTruncated = true;
327
- rows.push(`${m.lineNumber}#${hash}│${disp}`);
328
- }
329
- blocks.push(`${header}\n${rows.join("\n")}`);
330
- }
331
-
332
- let output = blocks.join("\n\n");
333
- const truncation = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES });
334
- output = truncation.content;
335
-
336
- const notices: string[] = [];
337
- if (matchLimitReached)
338
- notices.push(
339
- `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
340
- );
341
- if (truncation.truncated) notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
342
- if (linesTruncated)
343
- notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read to see full lines`);
344
- if (notices.length) output += `\n\n[${notices.join(". ")}]`;
345
-
346
- resolvePromise({
347
- content: [{ type: "text" as const, text: output }],
348
- details: undefined,
349
- });
350
- });
351
- });
352
- },
353
- };
287
+ return makeGrepOverrideWithBackend(cwd, {});
288
+ }
289
+
290
+ /** @internal — build a grep override with deterministic process and fallback backends for tests. */
291
+ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<GrepBackend>) {
292
+ let builtin: ReturnType<typeof createGrepTool> | undefined;
293
+ const backend: GrepBackend = {
294
+ findRg,
295
+ runRg,
296
+ delegate(toolCallId, params, signal, onUpdate) {
297
+ builtin ??= createGrepTool(cwd);
298
+ return builtin.execute(toolCallId, params, signal, onUpdate);
299
+ },
300
+ ...overrides,
301
+ };
302
+
303
+ return {
304
+ name: "grep" as const,
305
+ label: "grep",
306
+ description:
307
+ "Search file contents for a pattern. Results are grouped by file with LINE#HASH anchors usable directly in edit. Supports multi-pattern AND (matchMode:all), line exclusion (excludePattern, grep -v), whole-word matching (wordMatch), multiple search paths, and files-only / count output modes — the common `grep A | grep -v B` / `rg -l` / `grep -c` pipelines without bash. Respects .gitignore.",
308
+ promptSnippet:
309
+ "Search file contents; results show LINE#HASH anchors usable directly in edit; multi-pattern AND, exclude, files-only and count modes replace bash grep pipelines",
310
+ promptGuidelines: [
311
+ "Results are grouped by file under a `path · N matches` header; each line shows `LINE#HASH│content` (same format as read).",
312
+ "Copy `LINE#HASH` straight into an edit `anchor`/`end` — no re-read needed. Context lines (from `context`) are anchored and editable too.",
313
+ 'Prefer this over bash pipes: `matchMode:"all"` + `excludePattern` express `grep A | grep -v B`; `outputMode:"files"`/`"count"` replace `rg -l`/`grep -c` when you only need locations or counts. `files` output pastes back as a `path` array.',
314
+ "Pass `pattern` (string or array); optionally `path` (string or array), `glob`, `ignoreCase`, `literal`, `wordMatch`, `context` (lines before+after each match), `limit` (max matches, default 100).",
315
+ ],
316
+ parameters: grepOverrideSchema,
317
+
318
+ renderShell: "default" as const,
319
+
320
+ renderCall(args: any, theme: any) {
321
+ const rawPattern = args?.pattern;
322
+ const patternText = Array.isArray(rawPattern)
323
+ ? rawPattern.join(" | ")
324
+ : String(rawPattern ?? "");
325
+ const rawPath = args?.path;
326
+ const pathText = Array.isArray(rawPath) ? rawPath.join(" ") : String(rawPath ?? ".");
327
+ let text =
328
+ theme.fg("toolTitle", theme.bold("grep ")) +
329
+ theme.fg("accent", `/${patternText}/`) +
330
+ theme.fg("toolOutput", ` in ${pathText}`);
331
+ if (args?.matchMode === "all") text += theme.fg("accent", " all");
332
+ if (args?.excludePattern) {
333
+ const ex = Array.isArray(args.excludePattern)
334
+ ? args.excludePattern.join(",")
335
+ : args.excludePattern;
336
+ text += theme.fg("toolOutput", ` -v:${ex}`);
337
+ }
338
+ if (args?.wordMatch) text += theme.fg("toolOutput", " -w");
339
+ if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
340
+ if (args?.outputMode && args.outputMode !== "content")
341
+ text += theme.fg("success", ` ${args.outputMode}`);
342
+ if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
343
+ return new Text(text, 0, 0);
344
+ },
345
+
346
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
347
+ if (isPartial) return new Text(theme.fg("warning", "Searching…"), 0, 0);
348
+ if (context?.isError) {
349
+ const t =
350
+ result.content?.[0]?.type === "text" ? result.content[0].text.split("\n")[0] : "Error";
351
+ return new Text(theme.fg("error", t), 0, 0);
352
+ }
353
+ const out = result.content?.[0]?.type === "text" ? result.content[0].text : "";
354
+ const styled = toDisplayLines(out, theme);
355
+ const maxLines = expanded ? styled.length : 15;
356
+ const shown = styled.slice(0, maxLines);
357
+ const more =
358
+ !expanded && styled.length > maxLines
359
+ ? `\n${theme.fg("muted", `… (${styled.length - maxLines} more lines)`)}`
360
+ : "";
361
+ return new Text(shown.join("\n") + more, 0, 0);
362
+ },
363
+
364
+ async execute(
365
+ toolCallId: string,
366
+ params: any,
367
+ signal: AbortSignal | undefined,
368
+ onUpdate: any,
369
+ ): Promise<any> {
370
+ const state = getState();
371
+ // aborted built-in grep (it handles abort itself)
372
+ if (signal?.aborted) return backend.delegate(toolCallId, params, signal, onUpdate);
373
+
374
+ // Plain built-in-shaped params (single string pattern/path, no new fields)
375
+ // can delegate safely; anything else must run the local pipeline below.
376
+ const legacyShaped =
377
+ typeof params.pattern === "string" &&
378
+ params.matchMode === undefined &&
379
+ params.excludePattern === undefined &&
380
+ params.outputMode === undefined &&
381
+ params.wordMatch === undefined &&
382
+ !Array.isArray(params.path);
383
+
384
+ // disabled + plain params built-in grep, exactly as before
385
+ if (!state.config.enabled && legacyShaped)
386
+ return backend.delegate(toolCallId, params, signal, onUpdate);
387
+
388
+ const rgPath = await backend.findRg();
389
+ // ripgrep unavailable → built-in (it can auto-download rg), but only for plain params
390
+ if (!rgPath) {
391
+ if (legacyShaped) return backend.delegate(toolCallId, params, signal, onUpdate);
392
+ throw new Error(
393
+ "ripgrep (rg) not found; extended grep params cannot fall back to the built-in grep. Retry with a simple pattern first, or use bash",
394
+ );
395
+ }
396
+
397
+ // disabled + extended params still run locally, formatted without anchors
398
+ const anchored = state.config.enabled;
399
+
400
+ const patterns = toArray(params.pattern);
401
+ const excludes = toArray(params.excludePattern);
402
+ if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
403
+ const matchMode: "any" | "all" = params.matchMode ?? "any";
404
+ const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
405
+ const { glob, ignoreCase, literal, wordMatch, context, limit } = params;
406
+ const ctx = context && context > 0 ? context : 0;
407
+ const searchPaths = (() => {
408
+ const raw = toArray(params.path);
409
+ return (raw.length ? raw : ["."]).map((p) => canonicalPath(cwd, p));
410
+ })();
411
+ const hashLen = state.config.hashLen;
412
+
413
+ // Verify search paths upfront; remember dir-ness for relative display.
414
+ const roots: { path: string; isDir: boolean }[] = [];
415
+ for (const sp of searchPaths) {
416
+ try {
417
+ roots.push({ path: sp, isDir: (await stat(sp)).isDirectory() });
418
+ } catch {
419
+ throw new Error(`Path not found: ${sp}`);
420
+ }
421
+ }
422
+
423
+ // Client-side line filters — only AND / exclude need them; "any" is native rg (-e OR).
424
+ const excludeMatchers = excludes.map((p) =>
425
+ compileLineMatcher(p, { literal: !!literal, ignoreCase: !!ignoreCase, word: false }),
426
+ );
427
+ const andMatchers =
428
+ matchMode === "all" && patterns.length > 1
429
+ ? patterns.map((p) =>
430
+ compileLineMatcher(p, {
431
+ literal: !!literal,
432
+ ignoreCase: !!ignoreCase,
433
+ word: !!wordMatch,
434
+ }),
435
+ )
436
+ : [];
437
+ const linePasses = (line: string): boolean =>
438
+ andMatchers.every((re) => re.test(line)) && !excludeMatchers.some((re) => re.test(line));
439
+
440
+ return new Promise((resolvePromise, reject) => {
441
+ if (signal?.aborted) {
442
+ reject(new Error("Operation aborted"));
443
+ return;
444
+ }
445
+
446
+ const args = ["--json", "--line-number", "--color=never", "--hidden"];
447
+ if (ignoreCase) args.push("--ignore-case");
448
+ if (literal) args.push("--fixed-strings");
449
+ if (wordMatch) args.push("--word-regexp");
450
+ if (glob) args.push("--glob", glob);
451
+ for (const p of patterns) args.push("-e", p);
452
+ args.push("--", ...searchPaths);
453
+
454
+ const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
455
+ let matchCount = 0;
456
+ let matchLimitReached = false;
457
+ let linesTruncated = false;
458
+ const raw: RgMatch[] = [];
459
+
460
+ backend
461
+ .runRg(rgPath, args, signal, (line) => {
462
+ if (matchCount >= effectiveLimit) return false;
463
+ let event: any;
464
+ try {
465
+ event = JSON.parse(line);
466
+ } catch {
467
+ return true;
468
+ }
469
+ if (event.type !== "match") return true;
470
+ const filePath = event.data?.path?.text;
471
+ const lineNumber = event.data?.line_number;
472
+ if (!filePath || typeof lineNumber !== "number") return true;
473
+ // AND / exclude filters run on the matched line's text as streamed
474
+ // by rg, so the limit counts final results, not pre-filter candidates.
475
+ const text = typeof event.data?.lines?.text === "string" ? event.data.lines.text : "";
476
+ if (!linePasses(text.replace(/\r?\n$/, ""))) return true;
477
+ matchCount++;
478
+ raw.push({ filePath, lineNumber });
479
+ if (matchCount >= effectiveLimit) {
480
+ matchLimitReached = true;
481
+ return false;
482
+ }
483
+ return true;
484
+ })
485
+ .then(async ({ code, stderr, stopped }) => {
486
+ if (signal?.aborted) {
487
+ reject(new Error("Operation aborted"));
488
+ return;
489
+ }
490
+ if (!stopped && code !== 0 && code !== 1) {
491
+ reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
492
+ return;
493
+ }
494
+ if (raw.length === 0) {
495
+ resolvePromise({
496
+ content: [{ type: "text", text: "No matches found" }],
497
+ details: undefined,
498
+ });
499
+ return;
500
+ }
501
+
502
+ // Group by file, matches sorted by line number (Map keeps rg's discovery order).
503
+ const byFile = new Map<string, number[]>();
504
+ for (const m of raw) {
505
+ const arr = byFile.get(m.filePath) ?? [];
506
+ arr.push(m.lineNumber);
507
+ byFile.set(m.filePath, arr);
508
+ }
509
+ for (const arr of byFile.values()) arr.sort((a, b) => a - b);
510
+
511
+ // Read each file once and hash all its lines; hash is computed from the FULL line.
512
+ const fileCache = new Map<string, { lines: string[]; hashes: string[] }>();
513
+ const getFile = async (fp: string) => {
514
+ let entry = fileCache.get(fp);
515
+ if (!entry) {
516
+ let content = "";
517
+ try {
518
+ content = (await readFile(fp)).toString("utf-8");
519
+ } catch {
520
+ content = "";
521
+ }
522
+ const lines = splitLines(content);
523
+ entry = { lines, hashes: hashFileLines(lines, hashLen) };
524
+ fileCache.set(fp, entry);
525
+ }
526
+ return entry;
527
+ };
528
+
529
+ const formatPath = (fp: string): string => {
530
+ for (const root of roots) {
531
+ if (!root.isDir) continue;
532
+ const rel = relative(root.path, fp).replace(/\\/g, "/");
533
+ if (rel && !rel.startsWith("..")) return rel;
534
+ }
535
+ return basename(fp);
536
+ };
537
+
538
+ const blocks: string[] = [];
539
+ if (outputMode === "content") {
540
+ for (const [fp, matchLines] of byFile) {
541
+ const { lines, hashes } = await getFile(fp);
542
+ const matchSet = new Set(matchLines);
543
+ // Context windows are rebuilt from surviving matches so context
544
+ // lines of a filtered-out match never leak.
545
+ const windowSet = new Set<number>();
546
+ for (const ln of matchLines) {
547
+ for (let n = Math.max(1, ln - ctx); n <= Math.min(lines.length, ln + ctx); n++)
548
+ windowSet.add(n);
549
+ }
550
+ const header = anchored
551
+ ? `${formatPath(fp)} · ${matchLines.length} match${matchLines.length !== 1 ? "es" : ""}\n`
552
+ : "";
553
+ const rows: string[] = [];
554
+ for (const n of [...windowSet].sort((a, b) => a - b)) {
555
+ const content = lines[n - 1] ?? "";
556
+ const hash = hashes[n - 1] ?? "";
557
+ const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
558
+ if (wasTruncated) linesTruncated = true;
559
+ if (anchored) rows.push(`${n}#${hash}│${disp}`);
560
+ else if (matchSet.has(n)) rows.push(`${formatPath(fp)}:${n}: ${disp}`);
561
+ else rows.push(`${formatPath(fp)}-${n}- ${disp}`);
562
+ }
563
+ blocks.push(`${header}${rows.join("\n")}`);
564
+ }
565
+ } else if (outputMode === "files") {
566
+ for (const fp of byFile.keys()) blocks.push(formatPath(fp));
567
+ } else {
568
+ // count
569
+ let total = 0;
570
+ for (const [fp, matchLines] of byFile) {
571
+ blocks.push(`${formatPath(fp)}: ${matchLines.length}`);
572
+ total += matchLines.length;
573
+ }
574
+ blocks.push(
575
+ `Total: ${total} match${total !== 1 ? "es" : ""} in ${byFile.size} file${byFile.size !== 1 ? "s" : ""}`,
576
+ );
577
+ }
578
+
579
+ let output = blocks.join(outputMode === "content" ? "\n\n" : "\n");
580
+ const truncation = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES });
581
+ output = truncation.content;
582
+
583
+ const notices: string[] = [];
584
+ if (matchLimitReached)
585
+ notices.push(
586
+ `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
587
+ );
588
+ if (truncation.truncated)
589
+ notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
590
+ if (linesTruncated)
591
+ notices.push(
592
+ `Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read to see full lines`,
593
+ );
594
+ if (notices.length) output += `\n\n[${notices.join(". ")}]`;
595
+
596
+ resolvePromise({
597
+ content: [{ type: "text" as const, text: output }],
598
+ details: undefined,
599
+ });
600
+ })
601
+ .catch(reject);
602
+ });
603
+ },
604
+ };
354
605
  }