@d3ara1n/pi-hashline-edit 0.4.1 → 0.5.0

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 CHANGED
@@ -22,7 +22,7 @@ Routine local code editing in pi — the common case. If you spend turns fightin
22
22
 
23
23
  ## When to turn it off
24
24
 
25
- Set `hashlineEdit.enabled = false` (or uninstall) to fall back to the built-in `read`/`edit`/`grep` when you need **remote or custom-storage files** — the overrides read/write/search the local filesystem directly, so pi's custom `ReadOperations`/`GrepOperations` (SSH, etc.) aren't supported. The same switch lets you opt out per-project. All four tools — `read`, `grep`, `edit`, `replace` — are one set governed by this switch: when disabled, `read`/`grep`/`edit` delegate to the built-ins and `replace` refuses (it has no built-in counterpart).
25
+ Set `hashlineEdit.enabled = false` (or uninstall) to fall back to the built-in `read`/`edit`/`grep` when you need **remote or custom-storage files** — the overrides read/write/search the local filesystem directly, so pi's custom `ReadOperations`/`GrepOperations` (SSH, etc.) aren't supported. The same switch lets you opt out per-project. All four tools — `read`, `grep`, `edit`, `replace` — are one set governed by this switch: when disabled, `read`/`edit` and plain `grep` calls delegate to the built-ins (`grep` calls using the extended params below still run locally, formatted without anchors) and `replace` refuses (it has no built-in counterpart).
26
26
 
27
27
  ## Model compatibility — field notes
28
28
 
@@ -90,6 +90,16 @@ src/util.ts · 1 match
90
90
  10#aF3│ const z = compute(x)
91
91
  ```
92
92
 
93
+ The `grep` override also covers the compound queries that otherwise push models into bash pipelines:
94
+
95
+ - `matchMode: "all"` — a line must match **every** pattern (`grep A | grep B` without the pipe)
96
+ - `excludePattern` — drop matching lines (`grep -v`), applied after pattern matching
97
+ - `wordMatch` — whole words only (`rg -w`)
98
+ - `outputMode: "files"` / `"count"` — just the file paths (`rg -l`) or per-file counts + total (`grep -c`); `"files"` output pastes straight back as a `path` array
99
+ - `pattern` and `path` accept arrays — several patterns combined per `matchMode`, several search roots in one call
100
+
101
+ Filters run before the match limit counts, and context windows are rebuilt from surviving matches, so `limit` and `context` compose cleanly with `matchMode`/`excludePattern`.
102
+
93
103
 
94
104
  `edit` takes `path` + `edits` (an array of ops, each with `op`, `anchor`/`end` `{line, hash}` from read, and `body` string[]):
95
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Hashline-style file editing for pi — line-anchored edits verified by content hash, replacing oldText/newText matching",
6
6
  "keywords": [
@@ -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,8 +16,15 @@
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
  */
@@ -24,6 +37,7 @@ import {
24
37
  formatSize,
25
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";
@@ -58,10 +72,79 @@ async function findRg(): Promise<string | null> {
58
72
  return null;
59
73
  }
60
74
 
61
- interface RawMatch {
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(`Pattern not supported for line filtering: ${pattern} (${(err as Error).message})`);
98
+ }
99
+ }
100
+
101
+ /** Normalize a `string | string[]` param to an array (`undefined` → `[]`). */
102
+ function toArray(v: string | string[] | undefined): string[] {
103
+ if (v === undefined) return [];
104
+ return Array.isArray(v) ? v : [v];
105
+ }
106
+
107
+ const grepOverrideSchema = Type.Object({
108
+ pattern: Type.Union([Type.String(), Type.Array(Type.String())], {
109
+ description:
110
+ "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)",
111
+ }),
112
+ matchMode: Type.Optional(
113
+ Type.Union([Type.Literal("any"), Type.Literal("all")], {
114
+ description:
115
+ '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`',
116
+ }),
117
+ ),
118
+ excludePattern: Type.Optional(
119
+ Type.Union([Type.String(), Type.Array(Type.String())], {
120
+ description:
121
+ "Drop lines matching this pattern, like grep -v (string or array; same regex/literal/ignoreCase settings as pattern). Applied after pattern matching",
122
+ }),
123
+ ),
124
+ outputMode: Type.Optional(
125
+ Type.Union([Type.Literal("content"), Type.Literal("files"), Type.Literal("count")], {
126
+ description:
127
+ 'Output shape (default "content"). "content": anchored matching lines. "files": only file paths with matches (rg -l). "count": per-file match counts + total (grep -c)',
128
+ }),
129
+ ),
130
+ wordMatch: Type.Optional(Type.Boolean({ description: "Match whole words only (rg -w)" })),
131
+ path: Type.Union([Type.String(), Type.Array(Type.String())], {
132
+ description: "Directory or file to search (string or array of paths; default: current directory)",
133
+ }),
134
+ glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
135
+ ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
136
+ literal: Type.Optional(
137
+ Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
138
+ ),
139
+ context: Type.Optional(
140
+ Type.Number({ description: "Number of lines to show before and after each match (default: 0); context lines are anchored too" }),
141
+ ),
142
+ limit: Type.Optional(Type.Number({ description: "Maximum number of matching lines to return (default: 100)" })),
143
+ });
144
+
145
+ interface RgMatch {
62
146
  filePath: string;
63
147
  lineNumber: number;
64
- match: boolean;
65
148
  }
66
149
 
67
150
  /**
@@ -122,25 +205,37 @@ export function makeGrepOverride(cwd: string) {
122
205
  name: "grep" as const,
123
206
  label: "grep",
124
207
  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)",
208
+ "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.",
209
+ promptSnippet:
210
+ "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",
127
211
  promptGuidelines: [
128
212
  "Results are grouped by file under a `path · N matches` header; each line shows `LINE#HASH│content` (same format as read).",
129
213
  "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).",
214
+ "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.",
215
+ "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).",
131
216
  ],
132
- parameters: builtin.parameters,
217
+ parameters: grepOverrideSchema,
133
218
 
134
219
  renderShell: "default" as const,
135
220
 
136
221
  renderCall(args: any, theme: any) {
137
- const pattern = args?.pattern ?? "";
138
- const p = args?.path ?? ".";
222
+ const rawPattern = args?.pattern;
223
+ const patternText = Array.isArray(rawPattern) ? rawPattern.join(" | ") : String(rawPattern ?? "");
224
+ const rawPath = args?.path;
225
+ const pathText = Array.isArray(rawPath) ? rawPath.join(" ") : String(rawPath ?? ".");
139
226
  let text =
140
227
  theme.fg("toolTitle", theme.bold("grep ")) +
141
- theme.fg("accent", `/${pattern}/`) +
142
- theme.fg("toolOutput", ` in ${p}`);
228
+ theme.fg("accent", `/${patternText}/`) +
229
+ theme.fg("toolOutput", ` in ${pathText}`);
230
+ if (args?.matchMode === "all") text += theme.fg("accent", " all");
231
+ if (args?.excludePattern) {
232
+ const ex = Array.isArray(args.excludePattern) ? args.excludePattern.join(",") : args.excludePattern;
233
+ text += theme.fg("toolOutput", ` -v:${ex}`);
234
+ }
235
+ if (args?.wordMatch) text += theme.fg("toolOutput", " -w");
143
236
  if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
237
+ if (args?.outputMode && args.outputMode !== "content")
238
+ text += theme.fg("success", ` → ${args.outputMode}`);
144
239
  if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
145
240
  return new Text(text, 0, 0);
146
241
  },
@@ -164,24 +259,70 @@ export function makeGrepOverride(cwd: string) {
164
259
 
165
260
  async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any): Promise<any> {
166
261
  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);
262
+ // aborted → built-in grep (it handles abort itself)
263
+ if (signal?.aborted) return delegate(toolCallId, params, signal, onUpdate);
264
+
265
+ // Plain built-in-shaped params (single string pattern/path, no new fields)
266
+ // can delegate safely; anything else must run the local pipeline below.
267
+ const legacyShaped =
268
+ typeof params.pattern === "string" &&
269
+ params.matchMode === undefined &&
270
+ params.excludePattern === undefined &&
271
+ params.outputMode === undefined &&
272
+ params.wordMatch === undefined &&
273
+ !Array.isArray(params.path);
274
+
275
+ // disabled + plain params → built-in grep, exactly as before
276
+ if (!state.config.enabled && legacyShaped) return delegate(toolCallId, params, signal, onUpdate);
169
277
 
170
278
  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);
279
+ // ripgrep unavailable → built-in (it can auto-download rg), but only for plain params
280
+ if (!rgPath) {
281
+ if (legacyShaped) return delegate(toolCallId, params, signal, onUpdate);
282
+ throw new Error(
283
+ "ripgrep (rg) not found; extended grep params cannot fall back to the built-in grep. Retry with a simple pattern first, or use bash",
284
+ );
285
+ }
173
286
 
174
- const { pattern, path: searchDir, glob, ignoreCase, literal, context, limit } = params;
175
- const searchPath = canonicalPath(cwd, searchDir || ".");
287
+ // disabled + extended params still run locally, formatted without anchors
288
+ const anchored = state.config.enabled;
289
+
290
+ const patterns = toArray(params.pattern);
291
+ const excludes = toArray(params.excludePattern);
292
+ if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
293
+ const matchMode: "any" | "all" = params.matchMode ?? "any";
294
+ const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
295
+ const { glob, ignoreCase, literal, wordMatch, context, limit } = params;
296
+ const ctx = context && context > 0 ? context : 0;
297
+ const searchPaths = (() => {
298
+ const raw = toArray(params.path);
299
+ return (raw.length ? raw : ["."]).map((p) => canonicalPath(cwd, p));
300
+ })();
176
301
  const hashLen = state.config.hashLen;
177
302
 
178
- let isDir = true;
179
- try {
180
- isDir = (await stat(searchPath)).isDirectory();
181
- } catch {
182
- throw new Error(`Path not found: ${searchPath}`);
303
+ // Verify search paths upfront; remember dir-ness for relative display.
304
+ const roots: { path: string; isDir: boolean }[] = [];
305
+ for (const sp of searchPaths) {
306
+ try {
307
+ roots.push({ path: sp, isDir: (await stat(sp)).isDirectory() });
308
+ } catch {
309
+ throw new Error(`Path not found: ${sp}`);
310
+ }
183
311
  }
184
312
 
313
+ // Client-side line filters — only AND / exclude need them; "any" is native rg (-e OR).
314
+ const excludeMatchers = excludes.map((p) =>
315
+ compileLineMatcher(p, { literal: !!literal, ignoreCase: !!ignoreCase, word: false }),
316
+ );
317
+ const andMatchers =
318
+ matchMode === "all" && patterns.length > 1
319
+ ? patterns.map((p) =>
320
+ compileLineMatcher(p, { literal: !!literal, ignoreCase: !!ignoreCase, word: !!wordMatch }),
321
+ )
322
+ : [];
323
+ const linePasses = (line: string): boolean =>
324
+ andMatchers.every((re) => re.test(line)) && !excludeMatchers.some((re) => re.test(line));
325
+
185
326
  return new Promise((resolvePromise, reject) => {
186
327
  if (signal?.aborted) {
187
328
  reject(new Error("Operation aborted"));
@@ -191,10 +332,10 @@ export function makeGrepOverride(cwd: string) {
191
332
  const args = ["--json", "--line-number", "--color=never", "--hidden"];
192
333
  if (ignoreCase) args.push("--ignore-case");
193
334
  if (literal) args.push("--fixed-strings");
335
+ if (wordMatch) args.push("--word-regexp");
194
336
  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);
337
+ for (const p of patterns) args.push("-e", p);
338
+ args.push("--", ...searchPaths);
198
339
 
199
340
  const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
200
341
  const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -205,7 +346,7 @@ export function makeGrepOverride(cwd: string) {
205
346
  let linesTruncated = false;
206
347
  let aborted = false;
207
348
  let killedDueToLimit = false;
208
- const raw: RawMatch[] = [];
349
+ const raw: RgMatch[] = [];
209
350
 
210
351
  const cleanup = () => {
211
352
  rl.close();
@@ -234,19 +375,19 @@ export function makeGrepOverride(cwd: string) {
234
375
  } catch {
235
376
  return;
236
377
  }
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 });
378
+ if (event.type !== "match") return;
379
+ const filePath = event.data?.path?.text;
380
+ const lineNumber = event.data?.line_number;
381
+ if (!filePath || typeof lineNumber !== "number") return;
382
+ // AND / exclude filters run on the matched line's text as streamed
383
+ // by rg, so the limit counts final results, not pre-filter candidates.
384
+ const text = typeof event.data?.lines?.text === "string" ? event.data.lines.text : "";
385
+ if (!linePasses(text.replace(/\r?\n$/, ""))) return;
386
+ matchCount++;
387
+ raw.push({ filePath, lineNumber });
388
+ if (matchCount >= effectiveLimit) {
389
+ matchLimitReached = true;
390
+ stopChild(true);
250
391
  }
251
392
  });
252
393
 
@@ -270,22 +411,14 @@ export function makeGrepOverride(cwd: string) {
270
411
  return;
271
412
  }
272
413
 
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>();
414
+ // Group by file, matches sorted by line number (Map keeps rg's discovery order).
415
+ const byFile = new Map<string, number[]>();
275
416
  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
417
  const arr = byFile.get(m.filePath) ?? [];
285
- arr.push(m);
418
+ arr.push(m.lineNumber);
286
419
  byFile.set(m.filePath, arr);
287
420
  }
288
- for (const arr of byFile.values()) arr.sort((a, b) => a.lineNumber - b.lineNumber);
421
+ for (const arr of byFile.values()) arr.sort((a, b) => a - b);
289
422
 
290
423
  // Read each file once and hash all its lines; hash is computed from the FULL line.
291
424
  const fileCache = new Map<string, { lines: string[]; hashes: string[] }>();
@@ -306,30 +439,53 @@ export function makeGrepOverride(cwd: string) {
306
439
  };
307
440
 
308
441
  const formatPath = (fp: string): string => {
309
- if (isDir) {
310
- const rel = relative(searchPath, fp).replace(/\\/g, "/");
442
+ for (const root of roots) {
443
+ if (!root.isDir) continue;
444
+ const rel = relative(root.path, fp).replace(/\\/g, "/");
311
445
  if (rel && !rel.startsWith("..")) return rel;
312
446
  }
313
447
  return basename(fp);
314
448
  };
315
449
 
316
450
  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}`);
451
+ if (outputMode === "content") {
452
+ for (const [fp, matchLines] of byFile) {
453
+ const { lines, hashes } = await getFile(fp);
454
+ const matchSet = new Set(matchLines);
455
+ // Context windows are rebuilt from surviving matches so context
456
+ // lines of a filtered-out match never leak.
457
+ const windowSet = new Set<number>();
458
+ for (const ln of matchLines) {
459
+ for (let n = Math.max(1, ln - ctx); n <= Math.min(lines.length, ln + ctx); n++) windowSet.add(n);
460
+ }
461
+ const header = anchored
462
+ ? `${formatPath(fp)} · ${matchLines.length} match${matchLines.length !== 1 ? "es" : ""}\n`
463
+ : "";
464
+ const rows: string[] = [];
465
+ for (const n of [...windowSet].sort((a, b) => a - b)) {
466
+ const content = lines[n - 1] ?? "";
467
+ const hash = hashes[n - 1] ?? "";
468
+ const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
469
+ if (wasTruncated) linesTruncated = true;
470
+ if (anchored) rows.push(`${n}#${hash}│${disp}`);
471
+ else if (matchSet.has(n)) rows.push(`${formatPath(fp)}:${n}: ${disp}`);
472
+ else rows.push(`${formatPath(fp)}-${n}- ${disp}`);
473
+ }
474
+ blocks.push(`${header}${rows.join("\n")}`);
475
+ }
476
+ } else if (outputMode === "files") {
477
+ for (const fp of byFile.keys()) blocks.push(formatPath(fp));
478
+ } else {
479
+ // count
480
+ let total = 0;
481
+ for (const [fp, matchLines] of byFile) {
482
+ blocks.push(`${formatPath(fp)}: ${matchLines.length}`);
483
+ total += matchLines.length;
328
484
  }
329
- blocks.push(`${header}\n${rows.join("\n")}`);
485
+ blocks.push(`Total: ${total} match${total !== 1 ? "es" : ""} in ${byFile.size} file${byFile.size !== 1 ? "s" : ""}`);
330
486
  }
331
487
 
332
- let output = blocks.join("\n\n");
488
+ let output = blocks.join(outputMode === "content" ? "\n\n" : "\n");
333
489
  const truncation = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES });
334
490
  output = truncation.content;
335
491
 
@@ -0,0 +1,265 @@
1
+ /**
2
+ * grep override execute tests: drive the real makeGrepOverride execute against
3
+ * temp dirs — anchored content output, boolean combination (matchMode all /
4
+ * excludePattern), output modes (files / count), wordMatch, multi-pattern any,
5
+ * multi-path, context windows, ignoreCase, limit notice, and the disabled-mode
6
+ * fallbacks (plain params delegate, extended params run un-anchored).
7
+ */
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { makeGrepOverride } from "./grep-tool.ts";
14
+ import { getState } from "./state.ts";
15
+ import { computeLineHash } from "../core/hash.ts";
16
+
17
+ async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
18
+ const dir = await mkdtemp(join(tmpdir(), "hl-grep-"));
19
+ try {
20
+ return await fn(dir);
21
+ } finally {
22
+ await rm(dir, { recursive: true, force: true });
23
+ }
24
+ }
25
+
26
+ const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
27
+
28
+ const A = "alpha beta\ngamma\nalpha only\nbeta only\nALPHA caps\n";
29
+ const B = "alpha here\nnothing\n";
30
+
31
+ async function seed(dir: string) {
32
+ await writeFile(join(dir, "a.ts"), A);
33
+ await writeFile(join(dir, "b.ts"), B);
34
+ }
35
+
36
+ const text = (r: any): string => r.content[0].text;
37
+
38
+ /** Run with hashline enabled/disabled, restoring the original config. */
39
+ async function withEnabled<T>(enabled: boolean, fn: () => Promise<T>): Promise<T> {
40
+ const state = getState();
41
+ const prev = state.config.enabled;
42
+ state.config.enabled = enabled;
43
+ try {
44
+ return await fn();
45
+ } finally {
46
+ state.config.enabled = prev;
47
+ }
48
+ }
49
+
50
+ test("content: single pattern groups by file with LINE#HASH anchors", async () => {
51
+ await withDir(async (dir) => {
52
+ await seed(dir);
53
+ const r: any = await call(makeGrepOverride(dir), { pattern: "alpha" });
54
+ const out = text(r);
55
+ assert.match(out, /a\.ts · 2 matches/);
56
+ assert.match(out, /b\.ts · 1 match/);
57
+ assert.match(out, /1#[0-9A-Z]+│alpha beta/);
58
+ assert.match(out, /3#[0-9A-Z]+│alpha only/);
59
+ assert.doesNotMatch(out, /gamma/);
60
+ });
61
+ });
62
+
63
+ test("content: grep anchors match the hash computed from the full line", async () => {
64
+ await withDir(async (dir) => {
65
+ await seed(dir);
66
+ const r: any = await call(makeGrepOverride(dir), { pattern: "alpha beta" });
67
+ const hash = computeLineHash(1, "alpha beta");
68
+ assert.match(text(r), new RegExp(`1#${hash}│alpha beta`));
69
+ });
70
+ });
71
+
72
+ test("matchMode all: line must match every pattern (grep A | grep B)", async () => {
73
+ await withDir(async (dir) => {
74
+ await seed(dir);
75
+ const r: any = await call(makeGrepOverride(dir), { pattern: ["alpha", "beta"], matchMode: "all" });
76
+ const out = text(r);
77
+ assert.match(out, /a\.ts · 1 match/);
78
+ assert.match(out, /1#[0-9A-Z]+│alpha beta/);
79
+ assert.doesNotMatch(out, /beta only/);
80
+ assert.doesNotMatch(out, /alpha here/);
81
+ });
82
+ });
83
+
84
+ test("excludePattern drops lines, like grep -v", async () => {
85
+ await withDir(async (dir) => {
86
+ await seed(dir);
87
+ const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", excludePattern: "beta" });
88
+ const out = text(r);
89
+ assert.match(out, /a\.ts · 1 match/);
90
+ assert.match(out, /3#[0-9A-Z]+│alpha only/);
91
+ assert.match(out, /b\.ts · 1 match/);
92
+ assert.doesNotMatch(out, /alpha beta/);
93
+ });
94
+ });
95
+
96
+ test("outputMode files: one path per line, no line content", async () => {
97
+ await withDir(async (dir) => {
98
+ await seed(dir);
99
+ const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", outputMode: "files" });
100
+ assert.equal(text(r), "a.ts\nb.ts");
101
+ });
102
+ });
103
+
104
+ test("outputMode count: per-file counts + total", async () => {
105
+ await withDir(async (dir) => {
106
+ await seed(dir);
107
+ const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", outputMode: "count" });
108
+ assert.equal(text(r), "a.ts: 2\nb.ts: 1\nTotal: 3 matches in 2 files");
109
+ });
110
+ });
111
+
112
+ test("wordMatch: whole words only", async () => {
113
+ await withDir(async (dir) => {
114
+ await writeFile(join(dir, "w.ts"), "foobar\nfoo bar\n");
115
+ const r: any = await call(makeGrepOverride(dir), { pattern: "foo", wordMatch: true });
116
+ const out = text(r);
117
+ assert.match(out, /w\.ts · 1 match/);
118
+ assert.match(out, /2#[0-9A-Z]+│foo bar/);
119
+ assert.doesNotMatch(out, /foobar/);
120
+ });
121
+ });
122
+
123
+ test("multi-pattern any (default): OR across patterns", async () => {
124
+ await withDir(async (dir) => {
125
+ await seed(dir);
126
+ const r: any = await call(makeGrepOverride(dir), { pattern: ["gamma", "nothing"] });
127
+ const out = text(r);
128
+ assert.match(out, /a\.ts · 1 match/);
129
+ assert.match(out, /2#[0-9A-Z]+│gamma/);
130
+ assert.match(out, /b\.ts · 1 match/);
131
+ assert.match(out, /2#[0-9A-Z]+│nothing/);
132
+ });
133
+ });
134
+
135
+ test("path accepts an array of search roots", async () => {
136
+ await withDir(async (dir) => {
137
+ await seed(dir);
138
+ const r: any = await call(makeGrepOverride(dir), { pattern: "beta only", path: ["a.ts", "b.ts"] });
139
+ const out = text(r);
140
+ assert.match(out, /4#[0-9A-Z]+│beta only/);
141
+ assert.match(out, /a\.ts · 1 match/);
142
+ });
143
+ });
144
+
145
+ test("context: ±N lines around surviving matches, anchored", async () => {
146
+ await withDir(async (dir) => {
147
+ await writeFile(join(dir, "c.ts"), "l1\nl2\nl3\nl4\nl5\n");
148
+ const r: any = await call(makeGrepOverride(dir), { pattern: "l3", context: 1 });
149
+ const out = text(r);
150
+ assert.match(out, /c\.ts · 1 match/);
151
+ const rows = out.split("\n").filter((l) => /│l\d/.test(l));
152
+ assert.deepEqual(
153
+ rows.map((l) => l.replace(/#\w+│/, ":")),
154
+ ["2:l2", "3:l3", "4:l4"],
155
+ );
156
+ });
157
+ });
158
+
159
+ test("context windows are rebuilt from surviving matches (filtered match leaks no context)", async () => {
160
+ await withDir(async (dir) => {
161
+ // l4 is a match but excluded; it sits >ctx away from the surviving l1 match,
162
+ // so none of its surroundings may appear as context
163
+ await writeFile(join(dir, "c.ts"), "target keep\nl2\nl3\ndrop me\nl5\nl6\n");
164
+ const r: any = await call(makeGrepOverride(dir), { pattern: "target|drop", excludePattern: "drop", context: 1 });
165
+ const out = text(r);
166
+ assert.match(out, /c\.ts · 1 match/);
167
+ assert.match(out, /1#[0-9A-Z]+│target keep/);
168
+ assert.match(out, /2#[0-9A-Z]+│l2/);
169
+ assert.doesNotMatch(out, /l3/);
170
+ assert.doesNotMatch(out, /drop me/);
171
+ assert.doesNotMatch(out, /l5/);
172
+ });
173
+ });
174
+
175
+ test("ignoreCase matches across case variants", async () => {
176
+ await withDir(async (dir) => {
177
+ await seed(dir);
178
+ const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", ignoreCase: true });
179
+ assert.match(text(r), /a\.ts · 3 matches/);
180
+ assert.match(text(r), /5#[0-9A-Z]+│ALPHA caps/);
181
+ });
182
+ });
183
+
184
+ test("limit notice suggests doubling", async () => {
185
+ await withDir(async (dir) => {
186
+ await seed(dir);
187
+ const r: any = await call(makeGrepOverride(dir), { pattern: "a", limit: 2 });
188
+ assert.match(text(r), /\[2 matches limit reached\. Use limit=4 for more, or refine pattern\]/);
189
+ });
190
+ });
191
+
192
+ test("filters apply before the limit counts (a filtered match consumes no budget)", async () => {
193
+ await withDir(async (dir) => {
194
+ await seed(dir);
195
+ // a.ts:1 "alpha beta" is excluded; pre-filter counting would burn the whole
196
+ // limit=1 budget on it and return nothing — post-filter counting yields a.ts:3
197
+ const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", excludePattern: "beta", limit: 1 });
198
+ const out = text(r);
199
+ assert.match(out, /3#[0-9A-Z]+│alpha only/);
200
+ assert.match(out, /1 matches limit reached/);
201
+ });
202
+ });
203
+
204
+ test("disabled + extended params: still runs, formatted without anchors", async () => {
205
+ await withDir(async (dir) => {
206
+ await seed(dir);
207
+ await withEnabled(false, async () => {
208
+ const r: any = await call(makeGrepOverride(dir), { pattern: ["alpha", "beta"], matchMode: "all" });
209
+ const out = text(r);
210
+ assert.match(out, /a\.ts:1: alpha beta/);
211
+ assert.doesNotMatch(out, /#[0-9A-Z]+│/);
212
+ assert.doesNotMatch(out, /alpha here/);
213
+ });
214
+ });
215
+ });
216
+
217
+ test("disabled + plain params: delegates to the built-in grep", async () => {
218
+ await withDir(async (dir) => {
219
+ await seed(dir);
220
+ await withEnabled(false, async () => {
221
+ const r: any = await call(makeGrepOverride(dir), { pattern: "alpha beta" });
222
+ // built-in format: flat `path:line: content`, no group headers
223
+ assert.match(text(r), /a\.ts:1: alpha beta/);
224
+ assert.doesNotMatch(text(r), /· 1 match/);
225
+ });
226
+ });
227
+ });
228
+
229
+ test("CRLF files: display and filters are \r-clean, anchors hash the clean line", async () => {
230
+ await withDir(async (dir) => {
231
+ await writeFile(join(dir, "crlf.ts"), "alpha beta\r\ngamma\r\nalpha only\r\n");
232
+ // anchors must hash the \r-stripped line — same splitLines as read/edit verify against
233
+ const hash = computeLineHash(1, "alpha beta");
234
+ const g: any = await call(makeGrepOverride(dir), { pattern: "alpha" });
235
+ const out = text(g);
236
+ assert.match(out, new RegExp(`1#${hash}│alpha beta`));
237
+ assert.ok(!/[\r]/.test(out), "no carriage returns in output");
238
+ // $-anchored filters must run on the cleaned text: "alpha beta\r" would dodge "beta$"
239
+ const g2: any = await call(makeGrepOverride(dir), { pattern: "alpha", excludePattern: "beta$" });
240
+ assert.match(text(g2), /alpha only/);
241
+ assert.doesNotMatch(text(g2), /alpha beta/);
242
+ // same for matchMode:"all" with a $-anchored pattern
243
+ const g3: any = await call(makeGrepOverride(dir), { pattern: ["alpha", "only$"], matchMode: "all" });
244
+ assert.match(text(g3), /3#[0-9A-Z]+│alpha only/);
245
+ assert.doesNotMatch(text(g3), /alpha beta/);
246
+ });
247
+ });
248
+
249
+ test("literal mode escapes regex metacharacters", async () => {
250
+ await withDir(async (dir) => {
251
+ await writeFile(join(dir, "d.ts"), "a.b\naxb\n");
252
+ const r: any = await call(makeGrepOverride(dir), { pattern: "a.b", literal: true });
253
+ const out = text(r);
254
+ assert.match(out, /1#[0-9A-Z]+│a\.b/);
255
+ assert.doesNotMatch(out, /axb/);
256
+ });
257
+ });
258
+
259
+ test("no matches reports cleanly", async () => {
260
+ await withDir(async (dir) => {
261
+ await seed(dir);
262
+ const r: any = await call(makeGrepOverride(dir), { pattern: "zzz" });
263
+ assert.equal(text(r), "No matches found");
264
+ });
265
+ });