@d3ara1n/pi-hashline-edit 0.1.1 → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Integration tests for the pi integration layer's execute: drives the real
3
- * makeReadOverride/makeEditOverride execute, covering text read with anchors,
4
- * the hashline edit closed loop, and error returns with isError.
2
+ * pi integration execute tests: drive the real makeReadOverride/makeEditOverride
3
+ * execute, covering text read with anchors, the hashline edit round-trip,
4
+ * chained edits via returned anchors, and error returns with isError.
5
5
  */
6
6
  import { test } from "node:test";
7
7
  import assert from "node:assert/strict";
@@ -10,26 +10,36 @@ import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { makeEditOverride } from "./edit-tool.ts";
12
12
  import { makeReadOverride } from "./read-tool.ts";
13
- import { clearSnapshots, getSnapshot } from "./state.ts";
13
+ import { computeLineHash } from "../core/hash.ts";
14
+ import { splitLines } from "../core/lines.ts";
14
15
 
15
16
  async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
16
17
  const dir = await mkdtemp(join(tmpdir(), "hl-"));
17
- clearSnapshots();
18
18
  try {
19
19
  return await fn(dir);
20
20
  } finally {
21
21
  await rm(dir, { recursive: true, force: true });
22
- clearSnapshots();
23
22
  }
24
23
  }
25
24
 
26
25
  const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
27
26
 
27
+ /** Anchor a model would copy from read output for `line` of `text` (1-based). */
28
+ function h(text: string, line: number) {
29
+ return { line, hash: computeLineHash(line, splitLines(text)[line - 1]) };
30
+ }
31
+
32
+ /** Extract a `LINE#HASH` anchor from a read/edit result text block. */
33
+ function anchorLine(block: string, line: number) {
34
+ const m = new RegExp(`^${line}#([0-9A-Z]+)│`, "m").exec(block);
35
+ if (!m) throw new Error(`line ${line} anchor not found in block`);
36
+ return { line, hash: m[1] };
37
+ }
38
+
28
39
  test("read execute: text outputs LINE#HASH│content", async () => {
29
40
  await withDir(async (dir) => {
30
41
  await writeFile(join(dir, "f.txt"), "line1\nline2\n");
31
- const read = makeReadOverride(dir);
32
- const r: any = await call(read, { path: "f.txt" });
42
+ const r: any = await call(makeReadOverride(dir), { path: "f.txt" });
33
43
  const text = r.content[0];
34
44
  assert.equal(text.type, "text");
35
45
  assert.match(text.text, /1#[0-9A-Z]+│line1/);
@@ -38,88 +48,224 @@ test("read execute: text outputs LINE#HASH│content", async () => {
38
48
  });
39
49
  });
40
50
 
41
- test("read execute: records a snapshot for edit", async () => {
51
+ test("edit execute: hashline round-trip (read edit → file changed)", async () => {
42
52
  await withDir(async (dir) => {
43
- await writeFile(join(dir, "f.txt"), "a\nb\n");
44
- const read = makeReadOverride(dir);
45
- await call(read, { path: "f.txt" });
46
- const snap = getSnapshot(join(dir, "f.txt"));
47
- assert.ok(snap, "snapshot not recorded");
48
- assert.equal(snap!.lineHashes.length, 2);
53
+ const f = join(dir, "f.txt");
54
+ const text = "a\nb\nc\n";
55
+ await writeFile(f, text);
56
+ await call(makeReadOverride(dir), { path: "f.txt" });
57
+ const r: any = await call(makeEditOverride(dir), {
58
+ path: "f.txt",
59
+ edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
60
+ });
61
+ assert.equal(r.isError, undefined, "should not be an error");
62
+ assert.equal(await readFile(f, "utf-8"), "a\nB\nc\n");
49
63
  });
50
64
  });
51
65
 
52
- test("edit execute: hashline closed loop (read → edit → file changed)", async () => {
66
+ test("edit execute: multiple ops in one call", async () => {
53
67
  await withDir(async (dir) => {
54
68
  const f = join(dir, "f.txt");
55
- await writeFile(f, "a\nb\nc\n");
69
+ const text = "a\nb\nc\n";
70
+ await writeFile(f, text);
56
71
  await call(makeReadOverride(dir), { path: "f.txt" });
57
- const snap = getSnapshot(f)!;
72
+ const r: any = await call(makeEditOverride(dir), {
73
+ path: "f.txt",
74
+ edits: [
75
+ { op: "insert_after", anchor: h(text, 3), body: ["z"] },
76
+ { op: "replace", anchor: h(text, 1), body: ["A"] },
77
+ ],
78
+ });
79
+ assert.equal(r.isError, undefined);
80
+ assert.equal(await readFile(f, "utf-8"), "A\nb\nc\nz\n");
81
+ });
82
+ });
83
+
84
+ test("edit result returns Updated anchors that chain the next edit without a re-read", async () => {
85
+ await withDir(async (dir) => {
86
+ const f = join(dir, "f.txt");
87
+ const text = "a\nb\nc\n";
88
+ await writeFile(f, text);
58
89
  const edit = makeEditOverride(dir);
59
- const r: any = await call(edit, {
90
+ // first edit (model cites the read anchor for line 1)
91
+ const r1: any = await call(edit, {
60
92
  path: "f.txt",
61
- input: `replace 2#${snap.lineHashes[1]}:\n+B`,
93
+ edits: [{ op: "replace", anchor: h(text, 1), body: ["A"] }],
62
94
  });
63
- assert.equal(r.isError, undefined, "should not be an error");
64
- assert.equal(await readFile(f, "utf-8"), "a\nB\nc\n");
95
+ assert.equal(r1.isError, undefined);
96
+ const out: string = r1.content[0].text;
97
+ assert.match(out, /Updated anchors/);
98
+ // second edit chains on the anchor returned by the first edit — no read in between
99
+ const r2: any = await call(edit, {
100
+ path: "f.txt",
101
+ edits: [{ op: "replace", anchor: anchorLine(out, 1), body: ["AA"] }],
102
+ });
103
+ assert.equal(r2.isError, undefined);
104
+ assert.equal(await readFile(f, "utf-8"), "AA\nb\nc\n");
65
105
  });
66
106
  });
67
107
 
68
- test("edit execute: consecutive edits reuse the updated snapshot", async () => {
108
+ test("edit result anchors cover an inserted block (chain an edit inside it)", async () => {
69
109
  await withDir(async (dir) => {
70
110
  const f = join(dir, "f.txt");
71
- await writeFile(f, "a\nb\n");
72
- const read = makeReadOverride(dir);
111
+ const text = "a\nb\n";
112
+ await writeFile(f, text);
73
113
  const edit = makeEditOverride(dir);
74
- await call(read, { path: "f.txt" });
75
- let snap = getSnapshot(f)!;
76
- await call(edit, { path: "f.txt", input: `replace 1#${snap.lineHashes[0]}:\n+A` });
77
- // second edit: the snapshot was updated by the edit, use the new hash
78
- snap = getSnapshot(f)!;
79
- const r: any = await call(edit, { path: "f.txt", input: `replace 2#${snap.lineHashes[1]}:\n+B` });
114
+ const r1: any = await call(edit, {
115
+ path: "f.txt",
116
+ edits: [{ op: "insert_after", anchor: h(text, 2), body: ["c", "d", "e"] }],
117
+ });
118
+ assert.equal(r1.isError, undefined);
119
+ const out: string = r1.content[0].text;
120
+ // line 4 (d, one of the inserted lines) must be anchored in the result
121
+ const a4 = anchorLine(out, 4);
122
+ const r2: any = await call(edit, {
123
+ path: "f.txt",
124
+ edits: [{ op: "replace", anchor: a4, body: ["DD"] }],
125
+ });
126
+ assert.equal(r2.isError, undefined);
127
+ assert.equal(await readFile(f, "utf-8"), "a\nb\nc\nDD\ne\n");
128
+ });
129
+ });
130
+
131
+ test("unrelated external change does NOT block an edit on a stable line", async () => {
132
+ await withDir(async (dir) => {
133
+ const f = join(dir, "f.txt");
134
+ const text = "a\nb\nc\n";
135
+ await writeFile(f, text);
136
+ // simulate an external change at line 3 between read and edit
137
+ await writeFile(f, "a\nb\nCHANGED\n");
138
+ const r: any = await call(makeEditOverride(dir), {
139
+ path: "f.txt",
140
+ edits: [{ op: "replace", anchor: h(text, 1), body: ["A"] }],
141
+ });
80
142
  assert.equal(r.isError, undefined);
81
- assert.equal(await readFile(f, "utf-8"), "A\nB\n");
143
+ assert.equal(await readFile(f, "utf-8"), "A\nb\nCHANGED\n");
82
144
  });
83
145
  });
84
146
 
85
- test("edit execute: edit without a prior read → anchor verification fails", async () => {
147
+ test("edit on a line that changed externally → anchor mismatch", async () => {
148
+ await withDir(async (dir) => {
149
+ const f = join(dir, "f.txt");
150
+ const text = "a\nb\nc\n";
151
+ await writeFile(f, text);
152
+ await writeFile(f, "a\nBCHANGED\nc\n"); // line 2 changed
153
+ const r: any = await call(makeEditOverride(dir), {
154
+ path: "f.txt",
155
+ edits: [{ op: "replace", anchor: h(text, 2), body: ["x"] }],
156
+ });
157
+ assert.equal(r.isError, true);
158
+ assert.match(r.content[0].text, /anchor|re-read/i);
159
+ });
160
+ });
161
+
162
+ test("edit execute: no read before edit → anchor verification fails", async () => {
86
163
  await withDir(async (dir) => {
87
164
  await writeFile(join(dir, "f.txt"), "a\nb\n");
88
- const edit = makeEditOverride(dir);
89
- // never read, so the hash is made up
90
- const r: any = await call(edit, { path: "f.txt", input: "replace 1#XXXX:\n+A" });
165
+ const r: any = await call(makeEditOverride(dir), {
166
+ path: "f.txt",
167
+ edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
168
+ });
91
169
  assert.equal(r.isError, true);
92
170
  });
93
171
  });
94
172
 
95
- test("edit execute: missing input → isError + missing hint", async () => {
173
+ test("edit execute: empty edits → isError", async () => {
96
174
  await withDir(async (dir) => {
97
175
  await writeFile(join(dir, "f.txt"), "a\n");
98
- const r: any = await call(makeEditOverride(dir), { path: "f.txt" });
176
+ const r: any = await call(makeEditOverride(dir), { path: "f.txt", edits: [] });
99
177
  assert.equal(r.isError, true);
100
- assert.match(r.content[0].text, /missing/);
178
+ assert.match(r.content[0].text, /empty|missing/i);
101
179
  });
102
180
  });
103
181
 
104
- test("edit execute: legacy oldText/newText isError + legacy hint", async () => {
182
+ test("edit execute: malformed op (replace without body) isError", async () => {
105
183
  await withDir(async (dir) => {
106
184
  await writeFile(join(dir, "f.txt"), "a\n");
107
185
  const r: any = await call(makeEditOverride(dir), {
108
186
  path: "f.txt",
109
- edits: [{ oldText: "a", newText: "b" }],
187
+ edits: [{ op: "replace", anchor: { line: 1, hash: "XX" } }],
110
188
  });
111
189
  assert.equal(r.isError, true);
112
- assert.match(r.content[0].text, /legacy/);
113
- assert.match(r.content[0].text, /ONLY/);
190
+ assert.match(r.content[0].text, /body/i);
114
191
  });
115
192
  });
116
193
 
117
- test("edit execute: parse error → isError", async () => {
194
+ test("edit execute: delete op", async () => {
118
195
  await withDir(async (dir) => {
119
- await writeFile(join(dir, "f.txt"), "a\n");
196
+ const f = join(dir, "f.txt");
197
+ const text = "a\nb\nc\n";
198
+ await writeFile(f, text);
120
199
  await call(makeReadOverride(dir), { path: "f.txt" });
121
- const r: any = await call(makeEditOverride(dir), { path: "f.txt", input: "SWAP 1#X:\n+a" });
200
+ const r: any = await call(makeEditOverride(dir), {
201
+ path: "f.txt",
202
+ edits: [{ op: "delete", anchor: h(text, 2) }],
203
+ });
204
+ assert.equal(r.isError, undefined);
205
+ assert.equal(await readFile(f, "utf-8"), "a\nc\n");
206
+ });
207
+ });
208
+
209
+ // --- renderer regression guards (details.diff must be a string, renderResult must not throw) ---
210
+
211
+ const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
212
+
213
+ test("edit success: details.diff is a string (not the generateDiffString object)", async () => {
214
+ await withDir(async (dir) => {
215
+ const f = join(dir, "f.txt");
216
+ const text = "a\nb\nc\n";
217
+ await writeFile(f, text);
218
+ await call(makeReadOverride(dir), { path: "f.txt" });
219
+ const r: any = await call(makeEditOverride(dir), {
220
+ path: "f.txt",
221
+ edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
222
+ });
223
+ assert.equal(typeof r.details.diff, "string", "details.diff must be a string");
224
+ assert.equal(typeof r.details.patch, "string");
225
+ assert.equal(typeof r.details.firstChangedLine, "number");
226
+ });
227
+ });
228
+
229
+ test("edit success: renderResult renders the diff without throwing", async () => {
230
+ await withDir(async (dir) => {
231
+ const f = join(dir, "f.txt");
232
+ const text = "a\nb\nc\n";
233
+ await writeFile(f, text);
234
+ const edit = makeEditOverride(dir);
235
+ const r: any = await call(edit, {
236
+ path: "f.txt",
237
+ edits: [{ op: "replace", anchor: h(text, 2), body: ["B"] }],
238
+ });
239
+ // @ts-ignore — drive the renderer with a stub theme
240
+ const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: true }, stubTheme, { isError: r.isError ?? false });
241
+ assert.ok(typeof comp?.text === "string");
242
+ assert.ok(comp.text.includes("B"), "rendered diff should contain the new content");
243
+ });
244
+ });
245
+
246
+ test("edit error: renderResult renders the error line without throwing", async () => {
247
+ await withDir(async (dir) => {
248
+ await writeFile(join(dir, "f.txt"), "a\n");
249
+ const edit = makeEditOverride(dir);
250
+ const r: any = await call(edit, {
251
+ path: "f.txt",
252
+ edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
253
+ });
122
254
  assert.equal(r.isError, true);
123
- assert.match(r.content[0].text, /Parse error|unknown verb/);
255
+ // @ts-ignore
256
+ const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: false }, stubTheme, { isError: r.isError ?? false });
257
+ assert.ok(typeof comp?.text === "string");
258
+ });
259
+ });
260
+
261
+ test("hash length stays 4 even for runs of identical lines (no explosion)", async () => {
262
+ await withDir(async (dir) => {
263
+ const f = join(dir, "f.txt");
264
+ await writeFile(f, "\n\n\n\ncode\n");
265
+ const r: any = await call(makeReadOverride(dir), { path: "f.txt" });
266
+ const text: string = r.content[0].text;
267
+ for (const m of text.matchAll(/\d+#([0-9A-Z]+)│/g)) {
268
+ assert.equal(m[1].length, 4, `anchor ${m[0]} hash is not 4 chars`);
269
+ }
124
270
  });
125
271
  });
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Override grep: search results carry `LINE#HASH│` anchors (same format as
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.
5
+ *
6
+ * We run ripgrep directly (`--json`) rather than wrap the built-in grep, so we
7
+ * control formatting and can compute each line's hash from its FULL content
8
+ * while displaying a truncated copy. (The built-in grep truncates long lines
9
+ * before formatting; hashing that truncated text would not match what edit
10
+ * verifies against the full line — so the hash must be computed from the full
11
+ * content, independently of what is displayed.)
12
+ *
13
+ * Falls back to the built-in grep when: hashline disabled, aborted, or ripgrep
14
+ * cannot be located.
15
+ *
16
+ * @module pi-hashline-edit/pi
17
+ */
18
+
19
+ import {
20
+ createGrepTool,
21
+ truncateHead,
22
+ truncateLine,
23
+ formatSize,
24
+ DEFAULT_MAX_BYTES,
25
+ } from "@earendil-works/pi-coding-agent";
26
+ import { Text } from "@earendil-works/pi-tui";
27
+ import { spawn } from "node:child_process";
28
+ import { createInterface } from "node:readline";
29
+ import { access, constants, readFile, stat } from "node:fs/promises";
30
+ import { basename, delimiter, join, relative } from "node:path";
31
+ import { homedir } from "node:os";
32
+ import { hashFileLines } from "../core/hash.ts";
33
+ import { splitLines } from "../core/lines.ts";
34
+ import { getState } from "./state.ts";
35
+ import { canonicalPath } from "./read-tool.ts";
36
+
37
+ const DEFAULT_LIMIT = 100;
38
+ /** Max chars per result line for display (mirrors pi's truncate.ts; not exported there). */
39
+ const GREP_MAX_LINE_LENGTH = 500;
40
+
41
+ /** Locate ripgrep: pi's bundled bin first, then PATH. Returns null if not found. */
42
+ async function findRg(): Promise<string | null> {
43
+ const agentDir = process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
44
+ const piRg = join(agentDir, "bin", "rg");
45
+ try {
46
+ await access(piRg, constants.X_OK);
47
+ return piRg;
48
+ } catch {}
49
+ for (const dir of process.env.PATH?.split(delimiter) ?? []) {
50
+ if (!dir) continue;
51
+ const p = join(dir, "rg");
52
+ try {
53
+ await access(p, constants.X_OK);
54
+ return p;
55
+ } catch {}
56
+ }
57
+ return null;
58
+ }
59
+
60
+ interface RawMatch {
61
+ filePath: string;
62
+ lineNumber: number;
63
+ match: boolean;
64
+ }
65
+
66
+ /**
67
+ * Convert the anchored grep output (grouped, `LINE#HASH│`) into a human-readable
68
+ * form for the TUI: drop the hash, keep file headers and line numbers. The model
69
+ * still receives the anchored `content` text; this only affects what the user sees.
70
+ */
71
+ function toDisplayLines(raw: string, theme: any): string[] {
72
+ const out: string[] = [];
73
+ for (const line of raw.split("\n")) {
74
+ // anchored line first: "lineNo#HASH│content" → " lineNo: content"
75
+ const a = line.match(/^(\d+)#[A-Za-z0-9]+│(.*)$/);
76
+ if (a) {
77
+ out.push(theme.fg("dim", ` ${a[1]}:`) + theme.fg("toolOutput", ` ${a[2]}`));
78
+ continue;
79
+ }
80
+ // file header: "path · N match(es)" → path accent, count dim
81
+ const h = line.match(/^(.+?) · (\d+ match(?:es)?)$/);
82
+ if (h) {
83
+ out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
84
+ continue;
85
+ }
86
+ // truncation notice block "[...]"
87
+ if (line.startsWith("[")) {
88
+ out.push(theme.fg("warning", line));
89
+ continue;
90
+ }
91
+ out.push(theme.fg("toolOutput", line));
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Build the grep override (a ToolDefinition fragment for registerTool). */
97
+ export function makeGrepOverride(cwd: string) {
98
+ const builtin = createGrepTool(cwd);
99
+ const delegate = (toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) =>
100
+ builtin.execute(toolCallId, params, signal, onUpdate);
101
+
102
+ return {
103
+ name: "grep" as const,
104
+ label: "grep",
105
+ description:
106
+ "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.",
107
+ promptSnippet: "Search file contents; results show LINE#HASH anchors usable directly in edit (no re-read needed)",
108
+ promptGuidelines: [
109
+ "Results are grouped by file under a `path · N matches` header; each line shows `LINE#HASH│content` (same format as read).",
110
+ "Copy `LINE#HASH` straight into an edit `anchor`/`end` — no re-read needed. Context lines (from `context`) are anchored and editable too.",
111
+ "Pass `pattern`; optionally `path`, `glob`, `ignoreCase`, `literal`, `context` (lines before+after each match), `limit` (max matches, default 100).",
112
+ ],
113
+ parameters: builtin.parameters,
114
+
115
+ renderShell: "default" as const,
116
+
117
+ renderCall(args: any, theme: any) {
118
+ const pattern = args?.pattern ?? "";
119
+ const p = args?.path ?? ".";
120
+ let text =
121
+ theme.fg("toolTitle", theme.bold("grep ")) +
122
+ theme.fg("accent", `/${pattern}/`) +
123
+ theme.fg("toolOutput", ` in ${p}`);
124
+ if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
125
+ if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
126
+ return new Text(text, 0, 0);
127
+ },
128
+
129
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
130
+ if (isPartial) return new Text(theme.fg("warning", "Searching…"), 0, 0);
131
+ if (context?.isError) {
132
+ const t = result.content?.[0]?.type === "text" ? result.content[0].text.split("\n")[0] : "Error";
133
+ return new Text(theme.fg("error", t), 0, 0);
134
+ }
135
+ const out = result.content?.[0]?.type === "text" ? result.content[0].text : "";
136
+ const styled = toDisplayLines(out, theme);
137
+ const maxLines = expanded ? styled.length : 15;
138
+ const shown = styled.slice(0, maxLines);
139
+ const more =
140
+ !expanded && styled.length > maxLines
141
+ ? `\n${theme.fg("muted", `… (${styled.length - maxLines} more lines)`)}`
142
+ : "";
143
+ return new Text(shown.join("\n") + more, 0, 0);
144
+ },
145
+
146
+ async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any): Promise<any> {
147
+ const state = getState();
148
+ // disabled or already aborted → built-in grep (it handles abort itself)
149
+ if (!state.config.enabled || signal?.aborted) return delegate(toolCallId, params, signal, onUpdate);
150
+
151
+ const rgPath = await findRg();
152
+ // ripgrep unavailable → degrade to the built-in (which can auto-download rg)
153
+ if (!rgPath) return delegate(toolCallId, params, signal, onUpdate);
154
+
155
+ const { pattern, path: searchDir, glob, ignoreCase, literal, context, limit } = params;
156
+ const searchPath = canonicalPath(cwd, searchDir || ".");
157
+ const hashLen = state.config.hashLen;
158
+
159
+ let isDir = true;
160
+ try {
161
+ isDir = (await stat(searchPath)).isDirectory();
162
+ } catch {
163
+ throw new Error(`Path not found: ${searchPath}`);
164
+ }
165
+
166
+ return new Promise((resolvePromise, reject) => {
167
+ if (signal?.aborted) {
168
+ reject(new Error("Operation aborted"));
169
+ return;
170
+ }
171
+
172
+ const args = ["--json", "--line-number", "--color=never", "--hidden"];
173
+ if (ignoreCase) args.push("--ignore-case");
174
+ if (literal) args.push("--fixed-strings");
175
+ if (glob) args.push("--glob", glob);
176
+ const ctx = context && context > 0 ? context : 0;
177
+ if (ctx > 0) args.push("--context", String(ctx));
178
+ args.push("--", String(pattern), searchPath);
179
+
180
+ const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
181
+ const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
182
+ const rl = createInterface({ input: child.stdout });
183
+ let stderr = "";
184
+ let matchCount = 0;
185
+ let matchLimitReached = false;
186
+ let linesTruncated = false;
187
+ let aborted = false;
188
+ let killedDueToLimit = false;
189
+ const raw: RawMatch[] = [];
190
+
191
+ const cleanup = () => {
192
+ rl.close();
193
+ signal?.removeEventListener("abort", onAbort);
194
+ };
195
+ const stopChild = (dueToLimit = false) => {
196
+ if (!child.killed) {
197
+ killedDueToLimit = dueToLimit;
198
+ child.kill();
199
+ }
200
+ };
201
+ const onAbort = () => {
202
+ aborted = true;
203
+ stopChild();
204
+ };
205
+ signal?.addEventListener("abort", onAbort, { once: true });
206
+ child.stderr?.on("data", (chunk: Buffer) => {
207
+ stderr += chunk.toString();
208
+ });
209
+
210
+ rl.on("line", (line: string) => {
211
+ if (!line.trim() || matchCount >= effectiveLimit) return;
212
+ let event: any;
213
+ try {
214
+ event = JSON.parse(line);
215
+ } catch {
216
+ return;
217
+ }
218
+ if (event.type === "match") {
219
+ matchCount++;
220
+ const filePath = event.data?.path?.text;
221
+ const lineNumber = event.data?.line_number;
222
+ if (filePath && typeof lineNumber === "number") raw.push({ filePath, lineNumber, match: true });
223
+ if (matchCount >= effectiveLimit) {
224
+ matchLimitReached = true;
225
+ stopChild(true);
226
+ }
227
+ } else if (event.type === "context") {
228
+ const filePath = event.data?.path?.text;
229
+ const lineNumber = event.data?.line_number;
230
+ if (filePath && typeof lineNumber === "number") raw.push({ filePath, lineNumber, match: false });
231
+ }
232
+ });
233
+
234
+ child.on("error", (error) => {
235
+ cleanup();
236
+ reject(new Error(`Failed to run ripgrep: ${error.message}`));
237
+ });
238
+
239
+ child.on("close", async (code) => {
240
+ cleanup();
241
+ if (aborted) {
242
+ reject(new Error("Operation aborted"));
243
+ return;
244
+ }
245
+ if (!killedDueToLimit && code !== 0 && code !== 1) {
246
+ reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
247
+ return;
248
+ }
249
+ if (raw.length === 0) {
250
+ resolvePromise({ content: [{ type: "text", text: "No matches found" }], details: undefined });
251
+ return;
252
+ }
253
+
254
+ // Dedupe by (file, line); a line that is both a match and a context line counts as a match.
255
+ const map = new Map<string, RawMatch>();
256
+ for (const m of raw) {
257
+ const key = `${m.filePath}:${m.lineNumber}`;
258
+ const prev = map.get(key);
259
+ if (!prev || (!prev.match && m.match)) map.set(key, m);
260
+ }
261
+
262
+ // Group by file, each group sorted by line number.
263
+ const byFile = new Map<string, RawMatch[]>();
264
+ for (const m of map.values()) {
265
+ const arr = byFile.get(m.filePath) ?? [];
266
+ arr.push(m);
267
+ byFile.set(m.filePath, arr);
268
+ }
269
+ for (const arr of byFile.values()) arr.sort((a, b) => a.lineNumber - b.lineNumber);
270
+
271
+ // Read each file once and hash all its lines; hash is computed from the FULL line.
272
+ const fileCache = new Map<string, { lines: string[]; hashes: string[] }>();
273
+ const getFile = async (fp: string) => {
274
+ let entry = fileCache.get(fp);
275
+ if (!entry) {
276
+ let content = "";
277
+ try {
278
+ content = (await readFile(fp)).toString("utf-8");
279
+ } catch {
280
+ content = "";
281
+ }
282
+ const lines = splitLines(content);
283
+ entry = { lines, hashes: hashFileLines(lines, hashLen) };
284
+ fileCache.set(fp, entry);
285
+ }
286
+ return entry;
287
+ };
288
+
289
+ const formatPath = (fp: string): string => {
290
+ if (isDir) {
291
+ const rel = relative(searchPath, fp).replace(/\\/g, "/");
292
+ if (rel && !rel.startsWith("..")) return rel;
293
+ }
294
+ return basename(fp);
295
+ };
296
+
297
+ const blocks: string[] = [];
298
+ for (const [fp, matches] of byFile) {
299
+ const { lines, hashes } = await getFile(fp);
300
+ const n = matches.filter((m) => m.match).length;
301
+ const header = `${formatPath(fp)} · ${n} match${n !== 1 ? "es" : ""}`;
302
+ const rows: string[] = [];
303
+ for (const m of matches) {
304
+ const content = lines[m.lineNumber - 1] ?? "";
305
+ const hash = hashes[m.lineNumber - 1] ?? "";
306
+ const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
307
+ if (wasTruncated) linesTruncated = true;
308
+ rows.push(`${m.lineNumber}#${hash}│${disp}`);
309
+ }
310
+ blocks.push(`${header}\n${rows.join("\n")}`);
311
+ }
312
+
313
+ let output = blocks.join("\n\n");
314
+ const truncation = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES });
315
+ output = truncation.content;
316
+
317
+ const notices: string[] = [];
318
+ if (matchLimitReached)
319
+ notices.push(
320
+ `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
321
+ );
322
+ if (truncation.truncated) notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
323
+ if (linesTruncated)
324
+ notices.push(`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read to see full lines`);
325
+ if (notices.length) output += `\n\n[${notices.join(". ")}]`;
326
+
327
+ resolvePromise({
328
+ content: [{ type: "text" as const, text: output }],
329
+ details: undefined,
330
+ });
331
+ });
332
+ });
333
+ },
334
+ };
335
+ }