@d3ara1n/pi-hashline-edit 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @d3ara1n/pi-hashline-edit
2
2
 
3
- > Hashline-style file editing for [pi](https://github.com/earendil-works/pi-coding-agent) — line-anchored edits verified by content hash, replacing `oldText`/`newText` matching.
3
+ > Hashline-style file editing for [pi](https://github.com/earendil-works/pi-coding-agent) — line-anchored edits verified by content hash (replacing `oldText`/`newText` matching), plus a location-blind `replace` tool for bulk + regex transforms.
4
4
 
5
5
  Edits reference lines by `LINE#HASH` anchors (copied from `read`/`grep` output) instead of retyping the code to be changed — eliminating string-not-found loops and whitespace battles at the root.
6
6
 
@@ -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.
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).
26
26
 
27
27
  ## Gotchas (vs. the built-in `read`/`edit`)
28
28
 
@@ -31,6 +31,18 @@ Once hashline overrides the built-ins, a few things behave differently:
31
31
  - **`read` is globally overridden.** Every read shows the `LINE#HASH│` prefix on each line — even reads that won't lead to an edit. This is expected (it's the substrate the reliability is built on), just don't be surprised when the format changes for all files.
32
32
  - **Conservative overlap.** Two ops whose ranges touch (e.g. `insert_after` immediately followed by `replace` at the same line) are rejected to avoid backfill ambiguity — issue them as two separate `edit` calls.
33
33
 
34
+ ## `replace` — bulk + regex
35
+
36
+ A separate, location-blind tool for transforms `edit` can't express: replace **all** occurrences of a string/regex across the whole file in one call. Use it for renames, normalizations, and pattern-based rewrites that would otherwise need many individual anchored ops.
37
+
38
+ - **Two modes** — `regex: false` (default) treats `find` as a literal substring (replaceAll; the replacement is inserted verbatim, no `$` expansion); `regex: true` treats `find` as a JavaScript pattern source and `replace` supports `$1`, `$2`, `$&`, …
39
+ - **Flags** — `flags` adds regex flags in both modes (`g` is always forced so every occurrence is replaced): `i` (case-insensitive), `m` (per-line `^`/`$`), `s` (dotall, `.` matches `\n`), `u` (unicode).
40
+ - **Safety** — a `maxMatches` cap (default 2000) errors *before writing* if exceeded, so a runaway pattern can't produce a catastrophic write. `0` matches is an error (no silent no-op).
41
+ - **Shares the edit queue** — `replace` and `edit` on the same file are serialized via the same mutation queue, so concurrent edits never interleave.
42
+ - **Returns a diff + fresh anchors** for the changed region, so a follow-up `edit` can chain on the new content without a re-read (when the region is small).
43
+
44
+ `edit` vs `replace`: `edit` is **surgical and verified** (you point at a `LINE#HASH` and the tool confirms the line is unchanged before rewriting it). `replace` is **global and unverified** (you give a pattern, it rewrites every match sight-unseen). Pick by intent: change a known spot → `edit`; transform every occurrence → `replace`.
45
+
34
46
  ## Design
35
47
 
36
48
  - **Per-line hash + line number, dual anchor**: `read` shows each line as `3#aF3│code`; `edit` references `LINE#HASH`. The line number is the address; the hash is a checksum that the line at that address is still what was read.
@@ -39,7 +51,7 @@ Once hashline overrides the built-ins, a few things behave differently:
39
51
  - **Shifted-anchor recovery**: a mismatched anchor isn't a dead end. The applicator rescans ±`shiftRadius` lines for the original content — holding the original line number fixed and re-hashing each candidate (`hash(line, candidate) === cited` iff the candidate *is* the original) — and returns a ready-to-resend anchor on a unique hit, the candidate list when ambiguous, or the cited line's live content when nothing matches. The model retries without a re-read in the common drift case.
40
52
  - **Atomic batches, all failures collected**: every op in one `edit` is verified against the same snapshot; if any anchor fails, *all* failures (each with its recovery) are returned together and nothing is written — partial writes would shift lines and invalidate the very recovery info just returned.
41
53
  - **Chain edits without re-reading**: a successful `edit` returns `Updated anchors` for the lines it produced (and the line that shifted into a deletion gap), so the next edit can cite them directly.
42
- - **No legacy compatibility**: `edit` accepts only structured hashline ops; sending legacy `oldText`/`newText` is rejected at the schema layer (never silently degrades) — so you always know whether hashline is actually in use.
54
+ - **No legacy compatibility on `edit`**: `edit` accepts only structured hashline ops; sending legacy `oldText`/`newText` is rejected at the schema layer (never silently degrades) — so you always know whether hashline is actually in use. Bulk/regex replacement is a *separate* tool, `replace`, not an `edit` mode (see below).
43
55
 
44
56
  ## Protocol
45
57
 
@@ -77,6 +89,28 @@ src/util.ts · 1 match
77
89
 
78
90
  Ops: `replace` · `delete` · `insert_after` · `insert_before` · `append` · `prepend`. `anchor`/`end` = `{line, hash}` from read; `body` = new content lines (string[], omit for `delete`).
79
91
 
92
+ `replace` takes `path`, `find`, `replace` (+ optional `regex`, `flags`, `maxMatches`) and substitutes **every** match:
93
+
94
+ ```jsonc
95
+ {
96
+ "path": "src/foo.ts",
97
+ "find": "oldName",
98
+ "replace": "newName"
99
+ }
100
+ ```
101
+
102
+ Regex with a capture group (rename `getName()` → `get_name()` everywhere):
103
+
104
+ ```jsonc
105
+ { "path": "src/foo.ts", "find": "get([A-Z]\w*)", "replace": "get_$1", "regex": true }
106
+ ```
107
+
108
+ Case-insensitive literal rename across the whole file:
109
+
110
+ ```jsonc
111
+ { "path": "src/foo.ts", "find": "TODO", "replace": "FIXME", "flags": "i" }
112
+ ```
113
+
80
114
  ## Configuration
81
115
 
82
116
  Add a `hashlineEdit` field to `~/.pi/agent/settings.json` (global) or `.pi/settings.json` in a project (project replaces global):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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": [
package/src/index.ts CHANGED
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Overrides the built-in read/edit: read outputs "lineNo#hash│content";
5
5
  * edit accepts structured hashline ops (edits[] with LINE#HASH anchors), and
6
- * legacy oldText/newText is rejected explicitly (no silent degradation). Each
7
- * tool carries its own renderer.
6
+ * legacy oldText/newText is rejected explicitly (no silent degradation). grep
7
+ * is overridden the same way so results carry usable anchors. A separate
8
+ * `replace` tool adds location-blind bulk + regex replacement (replaceAll and
9
+ * full JS regex with capture groups) for renames/pattern transforms that
10
+ * would need many individual edits. Each tool carries its own renderer.
8
11
  *
9
12
  * @module pi-hashline-edit
10
13
  */
@@ -15,6 +18,7 @@ import { getState } from "./pi/state.ts";
15
18
  import { makeEditOverride } from "./pi/edit-tool.ts";
16
19
  import { makeReadOverride } from "./pi/read-tool.ts";
17
20
  import { makeGrepOverride } from "./pi/grep-tool.ts";
21
+ import { makeReplaceTool } from "./pi/replace-tool.ts";
18
22
 
19
23
  export default function (pi: ExtensionAPI) {
20
24
  const cwd = process.cwd();
@@ -28,4 +32,5 @@ export default function (pi: ExtensionAPI) {
28
32
  pi.registerTool(makeReadOverride(cwd));
29
33
  pi.registerTool(makeEditOverride(cwd));
30
34
  pi.registerTool(makeGrepOverride(cwd));
35
+ pi.registerTool(makeReplaceTool(cwd));
31
36
  }
@@ -209,7 +209,9 @@ export function makeEditOverride(cwd: string) {
209
209
  }
210
210
  const diff: string | undefined = result.details?.diff;
211
211
  if (!diff) {
212
- const t = content?.type === "text" ? content.text : "Edited";
212
+ // No net diff (e.g. a successful but non-mutating edit): show only the summary
213
+ // line — content.text also carries `Updated anchors` (hashline) for the model.
214
+ const t = content?.type === "text" ? content.text.split("\n")[0] : "Edited";
213
215
  return new Text(theme.fg("success", t), 0, 0);
214
216
  }
215
217
  // details.diff is pi-format (+N/-N/<space>N content); color by leading char
@@ -279,11 +281,16 @@ async function runHashline(absPath: string, displayPath: string, editOps: readon
279
281
  return errResult(`Error writing ${displayPath}: ${msg}`);
280
282
  }
281
283
 
282
- // pi's generateDiffString returns the display diff (colored by the renderer) and the first changed line
283
- const { diff, firstChangedLine } = generateDiffString(currentText, result.text);
284
+ // generateDiffString / generateUnifiedPatch split on \n, so raw CRLF content would
285
+ // leave a trailing \r on every diff line the TUI line-wrapper (wrapTextWithAnsi)
286
+ // then emits a spurious blank line per diff line. Normalize to LF for diff/patch
287
+ // only; the disk write above already preserved the original line endings.
288
+ const oldLf = currentText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
289
+ const newLf = result.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
290
+ const { diff, firstChangedLine } = generateDiffString(oldLf, newLf);
284
291
  const details: EditToolDetails = {
285
292
  diff,
286
- patch: generateUnifiedPatch(displayPath, currentText, result.text),
293
+ patch: generateUnifiedPatch(displayPath, oldLf, newLf),
287
294
  firstChangedLine,
288
295
  };
289
296
  const anchors = formatUpdatedAnchors(result.text, result.touchedLines, hashLen);
@@ -150,44 +150,49 @@ test("edit on a line that changed externally → anchor mismatch", async () => {
150
150
  const text = "a\nb\nc\n";
151
151
  await writeFile(f, text);
152
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);
153
+ await assert.rejects(
154
+ call(makeEditOverride(dir), {
155
+ path: "f.txt",
156
+ edits: [{ op: "replace", anchor: h(text, 2), body: ["x"] }],
157
+ }),
158
+ /anchor|re-read/i,
159
+ );
159
160
  });
160
161
  });
161
162
 
162
163
  test("edit execute: no read before edit → anchor verification fails", async () => {
163
164
  await withDir(async (dir) => {
164
165
  await writeFile(join(dir, "f.txt"), "a\nb\n");
165
- const r: any = await call(makeEditOverride(dir), {
166
- path: "f.txt",
167
- edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
168
- });
169
- assert.equal(r.isError, true);
166
+ await assert.rejects(
167
+ call(makeEditOverride(dir), {
168
+ path: "f.txt",
169
+ edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
170
+ }),
171
+ /anchor|re-read/i,
172
+ );
170
173
  });
171
174
  });
172
175
 
173
- test("edit execute: empty edits → isError", async () => {
176
+ test("edit execute: empty edits → throws", async () => {
174
177
  await withDir(async (dir) => {
175
178
  await writeFile(join(dir, "f.txt"), "a\n");
176
- const r: any = await call(makeEditOverride(dir), { path: "f.txt", edits: [] });
177
- assert.equal(r.isError, true);
178
- assert.match(r.content[0].text, /empty|missing/i);
179
+ await assert.rejects(
180
+ call(makeEditOverride(dir), { path: "f.txt", edits: [] }),
181
+ /empty|missing/i,
182
+ );
179
183
  });
180
184
  });
181
185
 
182
- test("edit execute: malformed op (replace without body) → isError", async () => {
186
+ test("edit execute: malformed op (replace without body) → throws", async () => {
183
187
  await withDir(async (dir) => {
184
188
  await writeFile(join(dir, "f.txt"), "a\n");
185
- const r: any = await call(makeEditOverride(dir), {
186
- path: "f.txt",
187
- edits: [{ op: "replace", anchor: { line: 1, hash: "XX" } }],
188
- });
189
- assert.equal(r.isError, true);
190
- assert.match(r.content[0].text, /body/i);
189
+ await assert.rejects(
190
+ call(makeEditOverride(dir), {
191
+ path: "f.txt",
192
+ edits: [{ op: "replace", anchor: { line: 1, hash: "XX" } }],
193
+ }),
194
+ /body/i,
195
+ );
191
196
  });
192
197
  });
193
198
 
@@ -247,13 +252,21 @@ test("edit error: renderResult renders the error line without throwing", async (
247
252
  await withDir(async (dir) => {
248
253
  await writeFile(join(dir, "f.txt"), "a\n");
249
254
  const edit = makeEditOverride(dir);
250
- const r: any = await call(edit, {
255
+ let thrown: any;
256
+ await call(edit, {
251
257
  path: "f.txt",
252
258
  edits: [{ op: "replace", anchor: { line: 1, hash: "XXXX" }, body: ["A"] }],
259
+ }).catch((e: any) => {
260
+ thrown = e;
253
261
  });
254
- assert.equal(r.isError, true);
255
- // @ts-ignore
256
- const comp: any = edit.renderResult({ content: r.content, details: r.details }, { isPartial: false, expanded: false }, stubTheme, { isError: r.isError ?? false });
262
+ assert.ok(thrown, "expected the edit to throw");
263
+ // @ts-ignore — simulate the framework handing the thrown message to renderResult
264
+ const comp: any = edit.renderResult(
265
+ { content: [{ type: "text", text: thrown.message }] },
266
+ { isPartial: false, expanded: false },
267
+ stubTheme,
268
+ { isError: true },
269
+ );
257
270
  assert.ok(typeof comp?.text === "string");
258
271
  });
259
272
  });
@@ -33,6 +33,7 @@ import { hashFileLines } from "../core/hash.ts";
33
33
  import { splitLines } from "../core/lines.ts";
34
34
  import { getState } from "./state.ts";
35
35
  import { canonicalPath } from "./read-tool.ts";
36
+ import { parseHashline } from "./render.ts";
36
37
 
37
38
  const DEFAULT_LIMIT = 100;
38
39
  /** Max chars per result line for display (mirrors pi's truncate.ts; not exported there). */
@@ -65,30 +66,48 @@ interface RawMatch {
65
66
 
66
67
  /**
67
68
  * 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.
69
+ * form for the TUI: drop the hash, keep file headers and line numbers. Within each
70
+ * file group, the common leading whitespace shared by all matched lines is folded
71
+ * into a single marker (›) so deep, repeated indentation doesn't eat display width;
72
+ * each line's indentation relative to that common base is preserved. The model still
73
+ * receives the anchored `content` text verbatim — this only affects what the user sees.
70
74
  */
75
+ function countLeading(s: string): number {
76
+ const m = s.match(/^[ \t]*/);
77
+ return m ? m[0].length : 0;
78
+ }
79
+
71
80
  function toDisplayLines(raw: string, theme: any): string[] {
72
81
  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
82
+ const lines = raw.split("\n");
83
+ let i = 0;
84
+ while (i < lines.length) {
85
+ const line = lines[i];
81
86
  const h = line.match(/^(.+?) · (\d+ match(?:es)?)$/);
82
87
  if (h) {
83
88
  out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
89
+ // collect the anchor lines in this file group
90
+ const group: { lineNo: string; content: string }[] = [];
91
+ let j = i + 1;
92
+ while (j < lines.length) {
93
+ const a = parseHashline(lines[j]);
94
+ if (!a) break;
95
+ group.push({ lineNo: a.lineNo, content: a.content });
96
+ j++;
97
+ }
98
+ // common base = min leading whitespace across the group; fold it into a marker
99
+ const base = group.length ? Math.min(...group.map((g) => countLeading(g.content))) : 0;
100
+ const marker = base > 0 ? theme.fg("dim", "›") + " " : "";
101
+ for (const g of group) {
102
+ const body = g.content.slice(base);
103
+ out.push(theme.fg("dim", ` ${g.lineNo}: `) + marker + theme.fg("toolOutput", body));
104
+ }
105
+ i = j;
84
106
  continue;
85
107
  }
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));
108
+ if (line.startsWith("[")) out.push(theme.fg("warning", line));
109
+ else out.push(theme.fg("toolOutput", line));
110
+ i++;
92
111
  }
93
112
  return out;
94
113
  }
@@ -9,13 +9,15 @@
9
9
  * @module pi-hashline-edit/pi
10
10
  */
11
11
 
12
- import { createReadTool } from "@earendil-works/pi-coding-agent";
12
+ import { createReadTool, getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
13
+ import { Text } from "@earendil-works/pi-tui";
13
14
  import { readFile } from "node:fs/promises";
14
15
  import { join, resolve } from "node:path";
15
16
  import { homedir } from "node:os";
16
17
  import { hashFileLines } from "../core/hash.ts";
17
18
  import { splitLines } from "../core/lines.ts";
18
19
  import { getState } from "./state.ts";
20
+ import { parseHashline } from "./render.ts";
19
21
 
20
22
  const MAX_LINES = 2000;
21
23
  const MAX_BYTES = 256 * 1024;
@@ -37,6 +39,70 @@ function expandTilde(p: string): string {
37
39
  return p;
38
40
  }
39
41
 
42
+ /** Offset/limit range suffix for the read call line, e.g. `:50-99` (mirrors pi core's read tool). */
43
+ function formatReadLineRange(args: any, theme: any): string {
44
+ if (args?.offset === undefined && args?.limit === undefined) return "";
45
+ const start = args.offset ?? 1;
46
+ const end = args.limit !== undefined ? start + args.limit - 1 : "";
47
+ return theme.fg("warning", `:${start}${end ? `-${end}` : ""}`);
48
+ }
49
+
50
+ /**
51
+ * Render the expanded read body for the TUI: color the header, strip the
52
+ * `LINE#HASH│` prefix from every anchor line to ` N: content`, and
53
+ * syntax-highlight the code block by the file's language (falls back to a
54
+ * single `toolOutput` color when the language is unknown or the highlight
55
+ * line count diverges). Trailing notices (e.g. truncation) are shown in
56
+ * `warning`.
57
+ */
58
+ function renderReadBody(raw: string, path: string, theme: any): string {
59
+ const lines = raw.split("\n");
60
+ if (lines.length === 0) return "";
61
+ const out: string[] = [];
62
+
63
+ // Header: "<path> · <N> lines" optionally followed by " (from line <offset>)".
64
+ let bodyStart = 0;
65
+ const h = lines[0].match(/^(.+?) · (\d+ lines(?: \(from line \d+\))?)$/);
66
+ if (h) {
67
+ out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
68
+ bodyStart = 1;
69
+ }
70
+
71
+ // Collect anchor rows (full content); the first non-anchor line begins the tail.
72
+ const lineNos: string[] = [];
73
+ const codeContents: string[] = [];
74
+ let tailStart = lines.length;
75
+ for (let i = bodyStart; i < lines.length; i++) {
76
+ const row = parseHashline(lines[i]);
77
+ if (!row) {
78
+ tailStart = i;
79
+ break;
80
+ }
81
+ lineNos.push(row.lineNo);
82
+ codeContents.push(row.content);
83
+ }
84
+
85
+ // Syntax-highlight the whole block so multi-line constructs stay correct.
86
+ const detabbed = codeContents.map((l) => l.replace(/\t/g, " "));
87
+ const lang = getLanguageFromPath(path);
88
+ let rendered: string[];
89
+ if (lang) {
90
+ const hl = highlightCode(detabbed.join("\n"), lang);
91
+ // Guard against highlighters that reshape line count: fall back to plain.
92
+ rendered = hl.length === detabbed.length ? hl : detabbed.map((l) => theme.fg("toolOutput", l));
93
+ } else {
94
+ rendered = detabbed.map((l) => theme.fg("toolOutput", l));
95
+ }
96
+ for (let i = 0; i < rendered.length && i < lineNos.length; i++) {
97
+ out.push(theme.fg("dim", ` ${lineNos[i]}: `) + rendered[i]);
98
+ }
99
+
100
+ for (let i = tailStart; i < lines.length; i++) {
101
+ out.push(theme.fg("warning", lines[i]));
102
+ }
103
+ return out.join("\n");
104
+ }
105
+
40
106
  /** Build the read override (a ToolDefinition fragment for registerTool). */
41
107
  export function makeReadOverride(cwd: string) {
42
108
  const builtin = createReadTool(cwd);
@@ -52,6 +118,29 @@ export function makeReadOverride(cwd: string) {
52
118
  "Pass `path`; optionally `offset` (1-indexed start line) and `limit` (max lines). Prefer read over cat/sed for files you intend to edit.",
53
119
  ],
54
120
  parameters: builtin.parameters,
121
+ renderShell: "default" as const,
122
+
123
+ renderCall(args: any, theme: any) {
124
+ const pathDisplay = String(args?.path ?? "");
125
+ let text = theme.fg("toolTitle", theme.bold("read")) + " " + theme.fg("accent", pathDisplay);
126
+ const range = formatReadLineRange(args, theme);
127
+ if (range) text += range;
128
+ return new Text(text, 0, 0);
129
+ },
130
+
131
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
132
+ if (isPartial) return new Text(theme.fg("warning", "Reading…"), 0, 0);
133
+ const content = result.content?.[0];
134
+ if (context?.isError) {
135
+ const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
136
+ return new Text(theme.fg("error", t), 0, 0);
137
+ }
138
+ // Collapsed (not expanded): show nothing — the call line carries the
139
+ // title, matching the built-in read's fold behavior.
140
+ if (!expanded) return new Text("", 0, 0);
141
+ const raw = content?.type === "text" ? content.text : "";
142
+ return new Text(renderReadBody(raw, String(context?.args?.path ?? ""), theme), 0, 0);
143
+ },
55
144
 
56
145
  async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) {
57
146
  // Not enabled OR user cancelled → delegate to the built-in (builtin handles abort itself)
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared anchor-parsing helper for the hashline-aware tool renderers.
3
+ *
4
+ * The model-facing `content` text uses the `LINE#HASH│content` anchor format so
5
+ * an anchor can be copied straight into an edit op. The user-facing TUI
6
+ * renderers (read, grep) strip that prefix back to a clean ` N: content` form.
7
+ * Parsing the anchor format in one place keeps read and grep in sync.
8
+ *
9
+ * @module pi-hashline-edit/pi
10
+ */
11
+
12
+ const HASHLINE_RE = /^(\d+)#[A-Za-z0-9]+│(.*)$/;
13
+
14
+ export interface HashlineRow {
15
+ /** Line number as written in the anchor (string form). */
16
+ lineNo: string;
17
+ /** Line content with the `LINE#HASH│` prefix removed. */
18
+ content: string;
19
+ }
20
+
21
+ /**
22
+ * Parse a `LINE#HASH│content` anchor line.
23
+ *
24
+ * @returns the row, or `null` for non-anchor lines (headers, notices, free
25
+ * text) so callers can fall through to their own formatting.
26
+ */
27
+ export function parseHashline(line: string): HashlineRow | null {
28
+ const m = line.match(HASHLINE_RE);
29
+ return m ? { lineNo: m[1], content: m[2] } : null;
30
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * `replace`: powerful bulk text replacement — literal substring (replaceAll) or
3
+ * full JavaScript regex with capture-group substitution.
4
+ *
5
+ * Distinct from the anchor-verified `edit`. `replace` is **location-blind**:
6
+ * it matches `find` everywhere across the whole file and substitutes every
7
+ * occurrence. Use it for renames and pattern-based transforms that would
8
+ * otherwise need many individual edits. For a single surgical, verified change,
9
+ * prefer `edit`.
10
+ *
11
+ * - Literal mode (`regex` false): `find` is a substring, matched verbatim;
12
+ * `replace` is inserted as-is (no `$` expansion).
13
+ * - Regex mode (`regex` true): `find` is a JS pattern source; `replace` supports
14
+ * `$1`, `$2`, `$&`, etc.
15
+ * - `flags` adds regex flags in either mode; `g` is always forced so every
16
+ * occurrence is replaced. `i` (case-insensitive), `m` (per-line ^/$),
17
+ * `s` (dotall), `u` (unicode) all work.
18
+ *
19
+ * Concurrency: read-modify-write is wrapped in {@link withFileMutationQueue}
20
+ * (shared with `edit`), so a `replace` and an `edit` on the same file never
21
+ * interleave. AbortSignal is honored after read / before write.
22
+ *
23
+ * @module pi-hashline-edit/pi
24
+ */
25
+
26
+ import {
27
+ generateDiffString,
28
+ generateUnifiedPatch,
29
+ withFileMutationQueue,
30
+ type EditToolDetails,
31
+ } from "@earendil-works/pi-coding-agent";
32
+ import { Type, type Static } from "typebox";
33
+ import { Text } from "@earendil-works/pi-tui";
34
+ import { readFile, writeFile } from "node:fs/promises";
35
+ import { hashFileLines, splitLines } from "../core/index.ts";
36
+ import { getState } from "./state.ts";
37
+ import { canonicalPath } from "./read-tool.ts";
38
+
39
+ /** Cap on updated-anchor lines returned inline (bounds token cost for large spans). */
40
+ const MAX_ANCHOR_LINES = 40;
41
+ /** Default safety cap on match count (errors before writing if exceeded). */
42
+ const DEFAULT_MAX_MATCHES = 2000;
43
+ /** Valid JavaScript regular-expression flag characters (ES2023+, incl. hasIndices `d`). */
44
+ const VALID_FLAGS = new Set(["g", "i", "m", "s", "u", "y", "d"]);
45
+
46
+ const replaceSchema = Type.Object({
47
+ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
48
+ find: Type.String({
49
+ description:
50
+ "Text to find. Literal substring when `regex` is false/omitted; a JavaScript regex pattern source when `regex` is true.",
51
+ }),
52
+ replace: Type.String({
53
+ description:
54
+ "Replacement text. Literal mode: inserted verbatim (no $ expansion). Regex mode: supports $1, $2, $&, $`, $' etc.",
55
+ }),
56
+ regex: Type.Optional(
57
+ Type.Boolean({
58
+ description:
59
+ "Treat `find` as a JavaScript regex pattern source (default false = literal substring, all occurrences replaced).",
60
+ }),
61
+ ),
62
+ flags: Type.Optional(
63
+ Type.String({
64
+ description:
65
+ "Regex flags appended in BOTH modes ('g' is always forced so every occurrence is replaced). Default ''. Common: 'i' (case-insensitive), 'm' (^/$ per line), 's' (dotall, . matches \\n), 'u' (unicode).",
66
+ }),
67
+ ),
68
+ maxMatches: Type.Optional(
69
+ Type.Number({
70
+ description: `Safety cap: errors before writing if more matches than this (default ${DEFAULT_MAX_MATCHES}). Raise for deliberate bulk transforms.`,
71
+ }),
72
+ ),
73
+ });
74
+
75
+ type ReplaceParams = Static<typeof replaceSchema>;
76
+
77
+ /** Escape regex metacharacters so a literal string is matched verbatim. */
78
+ function escapeRegex(s: string): string {
79
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
+ }
81
+
82
+ /**
83
+ * Build the matcher. `find` is escaped in literal mode; `flags` (validated) get
84
+ * `g` forced so all occurrences replace. Construction errors (bad pattern /
85
+ * conflicting flags such as `g`+`y`) surface as a friendly message rather than
86
+ * a raw `SyntaxError`.
87
+ */
88
+ function buildRegex(find: string, isRegex: boolean, flagsRaw: string | undefined): RegExp {
89
+ for (const c of flagsRaw ?? "") {
90
+ if (!VALID_FLAGS.has(c)) throw new Error(`invalid regex flag '${c}' (valid: g i m s u y d)`);
91
+ }
92
+ const set = new Set((flagsRaw ?? "").split(""));
93
+ set.add("g");
94
+ const flagStr = [...set].join("");
95
+ const source = isRegex ? find : escapeRegex(find);
96
+ try {
97
+ return new RegExp(source, flagStr);
98
+ } catch (e) {
99
+ const msg = e instanceof Error ? e.message : String(e);
100
+ throw new Error(`invalid regex /${source}/${flagStr}: ${msg}`);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * First-to-last differing line span (0-based, inclusive) in the NEW line array —
106
+ * a contiguous superset that contains every changed line. Computed by stripping
107
+ * the common prefix and suffix, so it is O(n) regardless of file size (no LCS
108
+ * DP). `null` when the text is unchanged. Used only to bound the anchor report.
109
+ */
110
+ function changedSpan(oldLines: readonly string[], newLines: readonly string[]): { start: number; end: number } | null {
111
+ const n = Math.min(oldLines.length, newLines.length);
112
+ let prefix = 0;
113
+ while (prefix < n && oldLines[prefix] === newLines[prefix]) prefix++;
114
+ // same length and fully equal → no change
115
+ if (oldLines.length === newLines.length && prefix === oldLines.length) return null;
116
+ let oldSuffix = 0;
117
+ let newSuffix = 0;
118
+ while (
119
+ oldSuffix < oldLines.length - prefix &&
120
+ newSuffix < newLines.length - prefix &&
121
+ oldLines[oldLines.length - 1 - oldSuffix] === newLines[newLines.length - 1 - newSuffix]
122
+ ) {
123
+ oldSuffix++;
124
+ newSuffix++;
125
+ }
126
+ const start = prefix;
127
+ const end = newLines.length - 1 - newSuffix; // inclusive, 0-based, in new
128
+ return end < start ? null : { start, end };
129
+ }
130
+
131
+ /** Format fresh `LINE#HASH│content` anchors for a contiguous span of the new file, capped. */
132
+ function formatSpanAnchors(newLines: readonly string[], span: { start: number; end: number }, hashLen: number): string {
133
+ const hashes = hashFileLines(newLines, hashLen);
134
+ const rows: string[] = [];
135
+ for (let i = span.start; i <= span.end; i++) rows.push(`${i + 1}#${hashes[i]}│${newLines[i]}`);
136
+ const shown = rows.length > MAX_ANCHOR_LINES ? rows.slice(0, MAX_ANCHOR_LINES) : rows;
137
+ const more = rows.length > MAX_ANCHOR_LINES ? `\n… (${rows.length - MAX_ANCHOR_LINES} more; re-read for full anchors)` : "";
138
+ return `\nUpdated anchors (changed region):\n${shown.join("\n")}${more}`;
139
+ }
140
+
141
+ /** Truncate a string for one-line display, folding newlines into a marker. */
142
+ function show(s: string, n = 30): string {
143
+ const folded = s.replace(/\n/g, "⏎");
144
+ return folded.length > n ? folded.slice(0, n) + "…" : folded;
145
+ }
146
+
147
+ export function makeReplaceTool(cwd: string) {
148
+ return {
149
+ name: "replace" as const,
150
+ label: "replace",
151
+ description:
152
+ "Bulk text replacement with regex support. Replaces ALL occurrences of `find` with `replace` across the whole file — for renames and pattern-based transforms that would need many individual edits. Location-blind (unverified): prefer `edit` for surgical, anchor-verified changes.",
153
+ promptSnippet: "Replace all occurrences of a string/regex across a file (bulk + regex; returns diff and fresh anchors)",
154
+ promptGuidelines: [
155
+ "Pass `path`, `find`, `replace`. ALL occurrences are replaced (not just the first).",
156
+ "`regex: true` treats `find` as a JS regex pattern; capture groups are usable in `replace` via $1, $2, $&. Default false = literal substring (replace text inserted verbatim, no $ expansion).",
157
+ "`flags` adds regex flags ('g' is always forced so every occurrence is replaced). Common: 'i' (case-insensitive), 'm' (^/$ per line), 's' (dotall, . matches \\n), 'u' (unicode). Applies in both modes.",
158
+ "`maxMatches` caps the match count (default 2000) — errors before writing if exceeded; raise it for deliberate bulk transforms.",
159
+ "Returns a diff plus fresh anchors for the changed region; chain edits, or re-read if you need the whole file's anchors.",
160
+ "0 matches is an error. This is a location-blind bulk tool — for one verified change use `edit` instead.",
161
+ ],
162
+ parameters: replaceSchema,
163
+ renderShell: "default" as const,
164
+
165
+ renderCall(args: ReplaceParams, theme: any) {
166
+ let text = theme.fg("toolTitle", theme.bold("replace "));
167
+ text += theme.fg("accent", args.path);
168
+ const mode = args.regex ? "regex" : "lit";
169
+ const f = args.flags ? `/${args.flags}` : "";
170
+ text += theme.fg("dim", ` — ${mode}${f} "${show(args.find)}" → "${show(args.replace)}"`);
171
+ return new Text(text, 0, 0);
172
+ },
173
+
174
+ renderResult(result: any, { isPartial, expanded }: any, theme: any, context: any) {
175
+ if (isPartial) return new Text(theme.fg("warning", "Replacing…"), 0, 0);
176
+ const content = result.content?.[0];
177
+ if (context.isError) {
178
+ const t = content?.type === "text" ? content.text.split("\n")[0] : "Error";
179
+ return new Text(theme.fg("error", t), 0, 0);
180
+ }
181
+ const diff: string | undefined = result.details?.diff;
182
+ if (!diff) {
183
+ // No net diff: show only the summary line — content.text also carries
184
+ // `Updated anchors` (hashline) for the model.
185
+ const t = content?.type === "text" ? content.text.split("\n")[0] : "Replaced";
186
+ return new Text(theme.fg("success", t), 0, 0);
187
+ }
188
+ // details.diff is pi-format (+N/-N/<space>N content); color by leading char
189
+ const allLines = diff.split("\n");
190
+ const shown = expanded ? allLines : allLines.slice(0, 24);
191
+ const body = shown
192
+ .map((line: string) => {
193
+ if (line.startsWith("+")) return theme.fg("success", line);
194
+ if (line.startsWith("-")) return theme.fg("error", line);
195
+ return theme.fg("dim", line);
196
+ })
197
+ .join("\n");
198
+ const more = !expanded && allLines.length > 24 ? `\n${theme.fg("dim", `… (${allLines.length - 24} more)`)}` : "";
199
+ return new Text(body + more, 0, 0);
200
+ },
201
+
202
+ async execute(toolCallId: string, params: ReplaceParams, signal: AbortSignal | undefined, onUpdate: any) {
203
+ const state = getState();
204
+ // Gated by the same `enabled` switch as read/grep/edit — they delegate to the
205
+ // built-ins when disabled; replace has no built-in counterpart, so it refuses.
206
+ if (!state.config.enabled)
207
+ throw new Error(
208
+ "Replace is unavailable: hashlineEdit is disabled. Set hashlineEdit.enabled = true to use it.",
209
+ );
210
+ const path = params.path;
211
+ const absPath = canonicalPath(cwd, path);
212
+
213
+ // withFileMutationQueue serializes read-modify-write for the same file,
214
+ // shared with `edit` — a replace and an edit on the same file never interleave.
215
+ return await withFileMutationQueue(absPath, () => runReplace(absPath, path, params, state.config.hashLen, signal));
216
+ },
217
+ };
218
+ }
219
+
220
+ async function runReplace(
221
+ absPath: string,
222
+ displayPath: string,
223
+ params: ReplaceParams,
224
+ hashLen: number,
225
+ signal: AbortSignal | undefined,
226
+ ) {
227
+ const { find, replace } = params;
228
+ const isRegex = params.regex === true;
229
+ const maxMatches = params.maxMatches ?? DEFAULT_MAX_MATCHES;
230
+
231
+ if (find === "") throw new Error(`Replace ${displayPath}: \`find\` is empty.`);
232
+
233
+ let currentText: string;
234
+ try {
235
+ currentText = (await readFile(absPath)).toString("utf-8");
236
+ } catch (e) {
237
+ const msg = e instanceof Error ? e.message : String(e);
238
+ throw new Error(`Error reading ${displayPath}: ${msg}`);
239
+ }
240
+ // honor cancel after read: if aborted, don't proceed to match/replace; the file stays untouched
241
+ if (signal?.aborted) throw new Error(`Replace ${displayPath} aborted before apply.`);
242
+
243
+ let regex: RegExp;
244
+ try {
245
+ regex = buildRegex(find, isRegex, params.flags);
246
+ } catch (e) {
247
+ throw new Error(`Replace ${displayPath}: ${e instanceof Error ? e.message : String(e)}`);
248
+ }
249
+
250
+ // Count matches with an early guard so a runaway pattern (e.g. an empty-match
251
+ // regex) can't produce a catastrophic write. matchAll does not mutate the
252
+ // regex's lastIndex (it clones internally), so the subsequent `replace` is safe.
253
+ let count = 0;
254
+ for (const _ of currentText.matchAll(regex)) {
255
+ count++;
256
+ if (count > maxMatches) {
257
+ throw new Error(
258
+ `Replace ${displayPath}: ${count}+ matches exceed \`maxMatches\` (${maxMatches}). Raise \`maxMatches\` if intentional, or narrow \`find\`.`,
259
+ );
260
+ }
261
+ }
262
+ if (count === 0) {
263
+ const shown = isRegex ? `/${find}/` : JSON.stringify(find);
264
+ throw new Error(`Replace ${displayPath}: no matches for ${shown}.`);
265
+ }
266
+
267
+ // Literal mode uses a function replacement so `$` in `replace` stays literal;
268
+ // regex mode passes the string so $1/$& etc. expand.
269
+ // Literal mode uses a function replacement so `$` in `replace` stays literal;
270
+ // regex mode passes the string so $1/$& etc. expand.
271
+ const newText = isRegex ? currentText.replace(regex, replace) : currentText.replace(regex, () => replace);
272
+ const changed = newText !== currentText;
273
+
274
+ // honor cancel before write: if aborted, don't touch the disk
275
+ if (signal?.aborted) throw new Error(`Replace ${displayPath} aborted before write.`);
276
+
277
+ if (changed) {
278
+ try {
279
+ await writeFile(absPath, newText);
280
+ } catch (e) {
281
+ const msg = e instanceof Error ? e.message : String(e);
282
+ throw new Error(`Error writing ${displayPath}: ${msg}`);
283
+ }
284
+ }
285
+
286
+ const { diff, firstChangedLine } = generateDiffString(currentText, newText);
287
+ const details: EditToolDetails = {
288
+ diff,
289
+ patch: generateUnifiedPatch(displayPath, currentText, newText),
290
+ firstChangedLine,
291
+ };
292
+
293
+ const oldLines = splitLines(currentText);
294
+ const newLines = splitLines(newText);
295
+ const span = changed ? changedSpan(oldLines, newLines) : null;
296
+ const anchors = span ? formatSpanAnchors(newLines, span, hashLen) : "";
297
+
298
+ const matchWord = `match${count !== 1 ? "es" : ""}`;
299
+ const note = changed ? `${count} ${matchWord}` : `${count} ${matchWord}, no net change`;
300
+ return {
301
+ content: [{ type: "text" as const, text: `Replaced ${displayPath} (${note}).${anchors}` }],
302
+ details,
303
+ };
304
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * pi integration tests for the `replace` tool: literal replaceAll, regex with
3
+ * capture groups, flags, the maxMatches guard, 0-match / invalid-pattern
4
+ * failures (signaled by throwing — pi's failure contract), diff + fresh-anchor
5
+ * return, and chaining a hashline `edit` on an anchor returned by `replace`.
6
+ *
7
+ * Failures are signaled by throwing (see edit-tool.ts); tests use assert.rejects.
8
+ */
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { makeReplaceTool } from "./replace-tool.ts";
15
+ import { makeEditOverride } from "./edit-tool.ts";
16
+ import { getState } from "./state.ts";
17
+
18
+ async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
19
+ const dir = await mkdtemp(join(tmpdir(), "hl-replace-"));
20
+ try {
21
+ return await fn(dir);
22
+ } finally {
23
+ await rm(dir, { recursive: true, force: true });
24
+ }
25
+ }
26
+
27
+ const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
28
+
29
+ /** Extract a `LINE#HASH` anchor for `line` from a result text block. */
30
+ function anchorLine(block: string, line: number) {
31
+ const m = new RegExp(`^${line}#([0-9A-Z]+)│`, "m").exec(block);
32
+ if (!m) throw new Error(`line ${line} anchor not found in block`);
33
+ return { line, hash: m[1] };
34
+ }
35
+
36
+ const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
37
+
38
+ test("replace literal: replaces all occurrences", async () => {
39
+ await withDir(async (dir) => {
40
+ const f = join(dir, "f.txt");
41
+ await writeFile(f, "foo bar foo baz foo\n");
42
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "foo", replace: "qux" });
43
+ assert.equal(r.isError, undefined);
44
+ assert.equal(await readFile(f, "utf-8"), "qux bar qux baz qux\n");
45
+ assert.match(r.content[0].text, /3 matches/);
46
+ });
47
+ });
48
+
49
+ test("replace literal: $ in replacement stays literal (no expansion)", async () => {
50
+ await withDir(async (dir) => {
51
+ const f = join(dir, "f.txt");
52
+ await writeFile(f, "cost: $5 here\n");
53
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "$5", replace: "$10" });
54
+ assert.equal(r.isError, undefined);
55
+ // literal mode: "$10" inserted verbatim, NOT interpreted as group-10 + "0"
56
+ assert.equal(await readFile(f, "utf-8"), "cost: $10 here\n");
57
+ });
58
+ });
59
+
60
+ test("replace literal: case-insensitive via flags", async () => {
61
+ await withDir(async (dir) => {
62
+ const f = join(dir, "f.txt");
63
+ await writeFile(f, "Foo fOo FOO\n");
64
+ await call(makeReplaceTool(dir), { path: "f.txt", find: "foo", replace: "x", flags: "i" });
65
+ assert.equal(await readFile(f, "utf-8"), "x x x\n");
66
+ });
67
+ });
68
+
69
+ test("replace regex: capture groups in replacement", async () => {
70
+ await withDir(async (dir) => {
71
+ const f = join(dir, "f.txt");
72
+ await writeFile(f, "name: alice\nname: bob\n");
73
+ await call(makeReplaceTool(dir), {
74
+ path: "f.txt",
75
+ find: "name: (\\w+)",
76
+ replace: "user: $1",
77
+ regex: true,
78
+ });
79
+ assert.equal(await readFile(f, "utf-8"), "user: alice\nuser: bob\n");
80
+ });
81
+ });
82
+
83
+ test("replace regex: multiline flag matches line-anchored pattern", async () => {
84
+ await withDir(async (dir) => {
85
+ const f = join(dir, "f.txt");
86
+ await writeFile(f, "a\nb\nc\n");
87
+ // without `m`, ^a$ wouldn't match (a isn't at end of string); with `m` it does
88
+ await call(makeReplaceTool(dir), { path: "f.txt", find: "^a$", replace: "A", regex: true, flags: "m" });
89
+ assert.equal(await readFile(f, "utf-8"), "A\nb\nc\n");
90
+ });
91
+ });
92
+
93
+ test("replace regex: dotall flag makes . match newlines", async () => {
94
+ await withDir(async (dir) => {
95
+ const f = join(dir, "f.txt");
96
+ await writeFile(f, "a\nb\n");
97
+ // a.b only matches across the newline with the `s` (dotall) flag
98
+ await call(makeReplaceTool(dir), { path: "f.txt", find: "a.b", replace: "X", regex: true, flags: "s" });
99
+ assert.equal(await readFile(f, "utf-8"), "X\n");
100
+ });
101
+ });
102
+
103
+ test("replace: 0 matches throws", async () => {
104
+ await withDir(async (dir) => {
105
+ const f = join(dir, "f.txt");
106
+ await writeFile(f, "a\nb\n");
107
+ await assert.rejects(
108
+ call(makeReplaceTool(dir), { path: "f.txt", find: "zzz", replace: "y" }),
109
+ /no matches/,
110
+ );
111
+ assert.equal(await readFile(f, "utf-8"), "a\nb\n", "file untouched on 0-match failure");
112
+ });
113
+ });
114
+
115
+ test("replace: empty find throws", async () => {
116
+ await withDir(async (dir) => {
117
+ await writeFile(join(dir, "f.txt"), "a\n");
118
+ await assert.rejects(
119
+ call(makeReplaceTool(dir), { path: "f.txt", find: "", replace: "x" }),
120
+ /`find` is empty/,
121
+ );
122
+ });
123
+ });
124
+
125
+ test("replace: maxMatches guard throws before writing", async () => {
126
+ await withDir(async (dir) => {
127
+ const f = join(dir, "f.txt");
128
+ await writeFile(f, "a".repeat(20) + "\n");
129
+ await assert.rejects(
130
+ call(makeReplaceTool(dir), { path: "f.txt", find: "a", replace: "b", maxMatches: 5 }),
131
+ /exceed `maxMatches`/,
132
+ );
133
+ assert.equal(await readFile(f, "utf-8"), "a".repeat(20) + "\n", "file untouched when guard trips");
134
+ });
135
+ });
136
+
137
+ test("replace: invalid regex throws a friendly message", async () => {
138
+ await withDir(async (dir) => {
139
+ await writeFile(join(dir, "f.txt"), "a\n");
140
+ await assert.rejects(
141
+ call(makeReplaceTool(dir), { path: "f.txt", find: "(unclosed", replace: "x", regex: true }),
142
+ /invalid regex/,
143
+ );
144
+ });
145
+ });
146
+
147
+ test("replace: invalid flag char throws", async () => {
148
+ await withDir(async (dir) => {
149
+ await writeFile(join(dir, "f.txt"), "a\n");
150
+ await assert.rejects(
151
+ call(makeReplaceTool(dir), { path: "f.txt", find: "a", replace: "x", flags: "z" }),
152
+ /invalid regex flag/,
153
+ );
154
+ });
155
+ });
156
+
157
+ test("replace: count-but-no-net-change does not rewrite and reports it", async () => {
158
+ await withDir(async (dir) => {
159
+ const f = join(dir, "f.txt");
160
+ await writeFile(f, "xx\n");
161
+ // $& = whole match = "x", so the text is unchanged despite 2 matches
162
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "x", replace: "$&", regex: true });
163
+ assert.equal(r.isError, undefined);
164
+ assert.equal(await readFile(f, "utf-8"), "xx\n");
165
+ assert.match(r.content[0].text, /no net change/);
166
+ });
167
+ });
168
+
169
+ test("replace: returns details.diff (string) + patch + firstChangedLine", async () => {
170
+ await withDir(async (dir) => {
171
+ const f = join(dir, "f.txt");
172
+ await writeFile(f, "a\nb\nc\n");
173
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "b", replace: "B" });
174
+ assert.equal(typeof r.details.diff, "string");
175
+ assert.equal(typeof r.details.patch, "string");
176
+ assert.equal(typeof r.details.firstChangedLine, "number");
177
+ });
178
+ });
179
+
180
+ test("replace: returns fresh anchors for the changed region", async () => {
181
+ await withDir(async (dir) => {
182
+ const f = join(dir, "f.txt");
183
+ await writeFile(f, "a\nb\nc\n");
184
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "b", replace: "B" });
185
+ const out: string = r.content[0].text;
186
+ assert.match(out, /Updated anchors/);
187
+ // line 2 now holds "B"; its anchor must be present and correct
188
+ const a2 = anchorLine(out, 2);
189
+ assert.ok(a2.hash.length >= 2);
190
+ });
191
+ });
192
+
193
+ test("replace: changed-region anchor chains a subsequent hashline edit without a re-read", async () => {
194
+ await withDir(async (dir) => {
195
+ const f = join(dir, "f.txt");
196
+ await writeFile(f, "old old old\n");
197
+ const replace = makeReplaceTool(dir);
198
+ const r1: any = await call(replace, { path: "f.txt", find: "old", replace: "new" });
199
+ const out: string = r1.content[0].text;
200
+ // line 1 is the changed line; chain an edit on its returned anchor
201
+ const a1 = anchorLine(out, 1);
202
+ const edit = makeEditOverride(dir);
203
+ const r2: any = await call(edit, {
204
+ path: "f.txt",
205
+ edits: [{ op: "replace", anchor: a1, body: ["NEW NEW NEW"] }],
206
+ });
207
+ assert.equal(r2.isError, undefined);
208
+ assert.equal(await readFile(f, "utf-8"), "NEW NEW NEW\n");
209
+ });
210
+ });
211
+
212
+ test("replace: multiline replacement (changes line count) still reports a correct span", async () => {
213
+ await withDir(async (dir) => {
214
+ const f = join(dir, "f.txt");
215
+ await writeFile(f, "a\nb\nc\n");
216
+ // replace "b" with two lines → line count grows; changed region must cover the insertion
217
+ const r: any = await call(makeReplaceTool(dir), { path: "f.txt", find: "b", replace: "B1\nB2" });
218
+ assert.equal(await readFile(f, "utf-8"), "a\nB1\nB2\nc\n");
219
+ const out: string = r.content[0].text;
220
+ // the two inserted/changed lines (2 and 3) should both be anchored
221
+ assert.doesNotThrow(() => anchorLine(out, 2));
222
+ assert.doesNotThrow(() => anchorLine(out, 3));
223
+ });
224
+ });
225
+
226
+ test("replace renderResult: renders the diff without throwing", async () => {
227
+ await withDir(async (dir) => {
228
+ const f = join(dir, "f.txt");
229
+ await writeFile(f, "a\nb\nc\n");
230
+ const tool = makeReplaceTool(dir);
231
+ const r: any = await call(tool, { path: "f.txt", find: "b", replace: "B" });
232
+ // @ts-ignore — drive the renderer with a stub theme
233
+ const comp: any = tool.renderResult(
234
+ { content: r.content, details: r.details },
235
+ { isPartial: false, expanded: true },
236
+ stubTheme,
237
+ { isError: r.isError ?? false },
238
+ );
239
+ assert.ok(typeof comp?.text === "string");
240
+ assert.ok(comp.text.includes("B"), "rendered diff should contain the new content");
241
+ });
242
+ });
243
+
244
+ test("replace renderResult: renders the error line without throwing", async () => {
245
+ await withDir(async (dir) => {
246
+ const f = join(dir, "f.txt");
247
+ await writeFile(f, "a\n");
248
+ const tool = makeReplaceTool(dir);
249
+ let thrown: any;
250
+ const r: any = await call(tool, { path: "f.txt", find: "zzz", replace: "y" }).catch((e: any) => {
251
+ thrown = e;
252
+ return null;
253
+ });
254
+ assert.ok(thrown, "expected the call to throw");
255
+ // @ts-ignore — simulate how the framework hands the thrown message to renderResult
256
+ const comp: any = tool.renderResult(
257
+ { content: [{ type: "text", text: thrown.message }] },
258
+ { isPartial: false, expanded: false },
259
+ stubTheme,
260
+ { isError: true },
261
+ );
262
+ assert.ok(typeof comp?.text === "string");
263
+ });
264
+ });
265
+
266
+ test("replace: refuses and leaves the file untouched when hashlineEdit is disabled", async () => {
267
+ await withDir(async (dir) => {
268
+ const f = join(dir, "f.txt");
269
+ await writeFile(f, "a\nb\na\n");
270
+ const state = getState();
271
+ const saved = state.config;
272
+ state.config = { ...saved, enabled: false };
273
+ try {
274
+ await assert.rejects(
275
+ call(makeReplaceTool(dir), { path: "f.txt", find: "a", replace: "b" }),
276
+ /disabled/,
277
+ );
278
+ assert.equal(await readFile(f, "utf-8"), "a\nb\na\n", "file untouched when disabled");
279
+ } finally {
280
+ state.config = saved;
281
+ }
282
+ });
283
+ });