@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 +37 -3
- package/package.json +1 -1
- package/src/index.ts +7 -2
- package/src/pi/edit-tool.ts +11 -4
- package/src/pi/execute.test.ts +39 -26
- package/src/pi/grep-tool.ts +35 -16
- package/src/pi/read-tool.ts +90 -1
- package/src/pi/render.ts +30 -0
- package/src/pi/replace-tool.ts +304 -0
- package/src/pi/replace.test.ts +283 -0
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
|
|
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
|
|
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
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).
|
|
7
|
-
*
|
|
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
|
}
|
package/src/pi/edit-tool.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
283
|
-
|
|
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,
|
|
293
|
+
patch: generateUnifiedPatch(displayPath, oldLf, newLf),
|
|
287
294
|
firstChangedLine,
|
|
288
295
|
};
|
|
289
296
|
const anchors = formatUpdatedAnchors(result.text, result.touchedLines, hashLen);
|
package/src/pi/execute.test.ts
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 →
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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) →
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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.
|
|
255
|
-
// @ts-ignore
|
|
256
|
-
const comp: any = edit.renderResult(
|
|
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
|
});
|
package/src/pi/grep-tool.ts
CHANGED
|
@@ -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.
|
|
69
|
-
*
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
}
|
package/src/pi/read-tool.ts
CHANGED
|
@@ -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)
|
package/src/pi/render.ts
ADDED
|
@@ -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
|
+
});
|