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