@d3ara1n/pi-hashline-edit 0.5.3 → 0.5.5
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 +2 -2
- package/package.json +1 -1
- package/src/core/apply.test.ts +26 -3
- package/src/core/apply.ts +22 -6
- package/src/core/index.ts +1 -1
- package/src/core/lines.test.ts +1 -21
- package/src/core/lines.ts +8 -21
- package/src/index.ts +10 -9
- package/src/integration/grep-rg.test.ts +105 -0
- package/src/pi/config.ts +1 -1
- package/src/pi/edit-tool.ts +1 -6
- package/src/pi/execute.test.ts +31 -2
- package/src/pi/grep-tool.ts +22 -35
- package/src/pi/grep.test.ts +64 -23
- package/src/pi/read-tool.ts +2 -2
- package/src/pi/replace-tool.ts +0 -6
- package/src/pi/replace.test.ts +0 -20
- package/src/pi/state.ts +2 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Routine local code editing in pi — the common case. If you spend turns fightin
|
|
|
24
24
|
|
|
25
25
|
## When to turn it off
|
|
26
26
|
|
|
27
|
-
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
|
|
27
|
+
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 the extension registers none of them and pi behaves as if it were not installed (reload pi after changing the setting).
|
|
28
28
|
|
|
29
29
|
## Model Compatibility
|
|
30
30
|
|
|
@@ -212,7 +212,7 @@ Add a `hashlineEdit` field to `~/.pi/agent/settings.json` (global) or `.pi/setti
|
|
|
212
212
|
```jsonc
|
|
213
213
|
{
|
|
214
214
|
"hashlineEdit": {
|
|
215
|
-
"enabled": true, // set false to
|
|
215
|
+
"enabled": true, // set false to disable the extension entirely (built-ins remain; reload pi)
|
|
216
216
|
"hashLen": 4, // hash length, 2–8 (default 4)
|
|
217
217
|
"shiftRadius": 15 // ±lines scanned to rescue a stale anchor (default 15; 0 disables)
|
|
218
218
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-hashline-edit",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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
|
"homepage": "https://github.com/d3ara1n/pi-extensions/tree/main/packages/pi-hashline-edit#readme",
|
package/src/core/apply.test.ts
CHANGED
|
@@ -171,6 +171,32 @@ test("CRLF line endings preserved", () => {
|
|
|
171
171
|
if (r.ok) assert.equal(r.text, "A\r\nb\r\n");
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
test("editing a mixed-ending file preserves untouched separators", () => {
|
|
175
|
+
const before = "first\r\nsecond\nthird\r\n";
|
|
176
|
+
const result = applyEdits(before, [{ op: "replace", start: at(before, 2), body: ["SECOND"] }]);
|
|
177
|
+
assert.equal(result.ok, true);
|
|
178
|
+
if (result.ok) assert.equal(result.text, "first\r\nSECOND\nthird\r\n");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("inserting and deleting in mixed-ending files preserves surviving separators", () => {
|
|
182
|
+
const before = "a\r\nb\nc\r\nlast";
|
|
183
|
+
const inserted = applyEdits(before, [{ op: "insert_after", anchor: at(before, 2), body: ["x"] }]);
|
|
184
|
+
assert.equal(inserted.ok, true);
|
|
185
|
+
if (inserted.ok) assert.equal(inserted.text, "a\r\nb\nx\r\nc\r\nlast");
|
|
186
|
+
const deleted = applyEdits(before, [{ op: "delete", start: at(before, 2) }]);
|
|
187
|
+
assert.equal(deleted.ok, true);
|
|
188
|
+
if (deleted.ok) assert.equal(deleted.text, "a\r\nc\r\nlast");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("replacing one line with several keeps the block's trailing gap", () => {
|
|
192
|
+
const before = "a\r\nb\nc\r\nd";
|
|
193
|
+
const result = applyEdits(before, [{ op: "replace", start: at(before, 2), body: ["X", "Y", "Z"] }]);
|
|
194
|
+
assert.equal(result.ok, true);
|
|
195
|
+
// b's gap (\n) moves to the last new line, so the boundary to c is unchanged;
|
|
196
|
+
// the two new interior gaps borrow positionally, then fall back to crlf.
|
|
197
|
+
if (result.ok) assert.equal(result.text, "a\r\nX\nY\r\nZ\nc\r\nd");
|
|
198
|
+
});
|
|
199
|
+
|
|
174
200
|
test("a file without a final newline keeps not having one", () => {
|
|
175
201
|
const text = "a\nb";
|
|
176
202
|
const r = applyEdits(text, [{ op: "replace", start: at(text, 2), body: ["B"] }]);
|
|
@@ -193,9 +219,6 @@ test("appending to a file without a final newline keeps it absent", () => {
|
|
|
193
219
|
});
|
|
194
220
|
|
|
195
221
|
test("noop is still detected when the file lacks a final newline", () => {
|
|
196
|
-
// Before the final-newline fix, joinLines appended a terminator, so a
|
|
197
|
-
// byte-identical body looked like a change and the edit silently rewrote the
|
|
198
|
-
// file — the noop guard never fired.
|
|
199
222
|
const text = "a\nb";
|
|
200
223
|
const r = applyEdits(text, [{ op: "replace", start: at(text, 1), body: ["a"] }]);
|
|
201
224
|
assert.equal(r.ok, false);
|
package/src/core/apply.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { computeLineHash } from "./hash.ts";
|
|
33
|
-
import { detectLineEnding, hasFinalNewline,
|
|
33
|
+
import { detectLineEnding, hasFinalNewline, splitLines } from "./lines.ts";
|
|
34
34
|
import type { Anchor, AnchorFailure, AnchorRecovery, ApplyResult, Edit } from "./types.ts";
|
|
35
35
|
|
|
36
36
|
/** Line-level operation: replace the raw lines in the `[lo, hi)` range (0-based, hi exclusive) with newLines. */
|
|
@@ -212,13 +212,29 @@ export function applyEdits(text: string, edits: Edit[], hashLen = 4, shiftRadius
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
//
|
|
216
|
-
|
|
215
|
+
// Mixed line endings: each line carries the separator that FOLLOWED it in
|
|
216
|
+
// the original (its gap), so surviving lines keep theirs byte for byte. New
|
|
217
|
+
// gaps borrow the removed block's separators positionally — the last new
|
|
218
|
+
// line inherits the block's trailing gap, leaving the boundary to the next
|
|
219
|
+
// surviving line unchanged; gaps past the removed block's length (or in a
|
|
220
|
+
// pure insertion) fall back to the file's customary ending.
|
|
221
|
+
const separators = text.match(/\r?\n/g) ?? [];
|
|
222
|
+
const separator = ending === "crlf" ? "\r\n" : "\n";
|
|
223
|
+
let result = lines.map((content, i) => ({ content, separator: separators[i] ?? "" }));
|
|
217
224
|
for (const op of [...sorted].sort((a, b) => b.lo - a.lo)) {
|
|
218
|
-
|
|
225
|
+
const removed = result.slice(op.lo, op.hi);
|
|
226
|
+
const inserted = op.newLines.map((content, i) => ({
|
|
227
|
+
content,
|
|
228
|
+
separator: i === op.newLines.length - 1 && removed.length > 0
|
|
229
|
+
? removed[removed.length - 1].separator
|
|
230
|
+
: removed[i]?.separator || separator,
|
|
231
|
+
}));
|
|
232
|
+
result.splice(op.lo, op.hi - op.lo, ...inserted);
|
|
219
233
|
}
|
|
220
|
-
|
|
221
|
-
const newText =
|
|
234
|
+
const finalNewline = hasFinalNewline(text);
|
|
235
|
+
const newText = result.map(({ content, separator: current }, i) =>
|
|
236
|
+
content + (i < result.length - 1 || finalNewline ? current || separator : ""),
|
|
237
|
+
).join("");
|
|
222
238
|
if (newText === text) {
|
|
223
239
|
return {
|
|
224
240
|
ok: false,
|
package/src/core/index.ts
CHANGED
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
|
|
10
10
|
export * from "./types.ts";
|
|
11
11
|
export { computeLineHash, hashFileLines } from "./hash.ts";
|
|
12
|
-
export { splitLines,
|
|
12
|
+
export { splitLines, detectLineEnding, hasFinalNewline } from "./lines.ts";
|
|
13
13
|
export { applyEdits } from "./apply.ts";
|
package/src/core/lines.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { splitLines,
|
|
3
|
+
import { splitLines, detectLineEnding, hasFinalNewline } from "./lines.ts";
|
|
4
4
|
|
|
5
5
|
test("splitLines edge cases", () => {
|
|
6
6
|
assert.deepEqual(splitLines(""), []);
|
|
@@ -16,20 +16,6 @@ test("splitLines strips CRLF \\r", () => {
|
|
|
16
16
|
assert.deepEqual(splitLines("a\r\nb"), ["a", "b"]);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
test("joinLines restores line endings", () => {
|
|
20
|
-
assert.equal(joinLines(["a", "b"]), "a\nb\n");
|
|
21
|
-
assert.equal(joinLines([]), "");
|
|
22
|
-
assert.equal(joinLines(["a", "b"], "crlf"), "a\r\nb\r\n");
|
|
23
|
-
assert.equal(joinLines(["a", "b"], "lf"), "a\nb\n");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("joinLines preserves the final-newline state when told to", () => {
|
|
27
|
-
assert.equal(joinLines(["a", "b"], "lf", false), "a\nb");
|
|
28
|
-
assert.equal(joinLines(["a", "b"], "crlf", false), "a\r\nb");
|
|
29
|
-
assert.equal(joinLines(["a"], "lf", false), "a");
|
|
30
|
-
assert.equal(joinLines([], "lf", false), "");
|
|
31
|
-
});
|
|
32
|
-
|
|
33
19
|
test("hasFinalNewline", () => {
|
|
34
20
|
assert.equal(hasFinalNewline("a\nb"), false);
|
|
35
21
|
assert.equal(hasFinalNewline("a\nb\n"), true);
|
|
@@ -40,12 +26,6 @@ test("hasFinalNewline", () => {
|
|
|
40
26
|
assert.equal(hasFinalNewline(""), true);
|
|
41
27
|
});
|
|
42
28
|
|
|
43
|
-
test("split/join round-trips a document byte for byte", () => {
|
|
44
|
-
for (const text of ["a", "a\n", "a\nb", "a\nb\n", "a\n\n", "\n", "a\r\nb", "a\r\nb\r\n", ""]) {
|
|
45
|
-
assert.equal(joinLines(splitLines(text), detectLineEnding(text), hasFinalNewline(text)), text);
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
|
|
49
29
|
test("detectLineEnding", () => {
|
|
50
30
|
assert.equal(detectLineEnding("a\nb\n"), "lf");
|
|
51
31
|
assert.equal(detectLineEnding("a\r\nb\r\n"), "crlf");
|
package/src/core/lines.ts
CHANGED
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
*
|
|
5
5
|
* CRLF: splitLines strips the trailing `\r` from each line (hashes are based on
|
|
6
6
|
* clean lines, matching the `\r`-free content the model copies from the
|
|
7
|
-
* display); detectLineEnding records the
|
|
8
|
-
*
|
|
7
|
+
* display); detectLineEnding records whether the file uses CRLF at all (any
|
|
8
|
+
* `\r\n` counts) so new boundaries added by an edit can use the file's
|
|
9
|
+
* customary ending.
|
|
9
10
|
*
|
|
10
11
|
* Final newline: splitLines discards whether the input ended with a terminator
|
|
11
12
|
* (a trailing newline terminates the last line, it does not create one).
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* hasFinalNewline recovers that state so a file that lacked a final newline
|
|
14
|
+
* does not silently gain one — editing reassembles per-line separators and
|
|
15
|
+
* must suppress the last line's terminator accordingly.
|
|
14
16
|
*
|
|
15
17
|
* @module pi-hashline-edit/core
|
|
16
18
|
*/
|
|
@@ -34,14 +36,14 @@ export function splitLines(text: string): string[] {
|
|
|
34
36
|
return normalized.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
/**
|
|
39
|
+
/** Whether the text uses CRLF at all (any `\r\n` counts; mixed files report "crlf"). */
|
|
38
40
|
export function detectLineEnding(text: string): LineEnding {
|
|
39
41
|
return text.includes("\r\n") ? "crlf" : "lf";
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/**
|
|
43
45
|
* Whether the text ends with a line terminator — the state splitLines discards
|
|
44
|
-
* and
|
|
46
|
+
* and edit application needs in order to reproduce a document byte for byte.
|
|
45
47
|
*
|
|
46
48
|
* The empty string has no lines and no terminator; it reports `true` so that
|
|
47
49
|
* rejoining its (also empty) line array — which yields `""` either way —
|
|
@@ -50,18 +52,3 @@ export function detectLineEnding(text: string): LineEnding {
|
|
|
50
52
|
export function hasFinalNewline(text: string): boolean {
|
|
51
53
|
return text === "" || text.endsWith("\n");
|
|
52
54
|
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Join a line array back into text, restoring the given line ending (default LF)
|
|
56
|
-
* and final-newline state (default: terminates with a newline, the convention
|
|
57
|
-
* for freshly created content).
|
|
58
|
-
*
|
|
59
|
-
* Reconstructing an *existing* file must pass `hasFinalNewline(originalText)` so
|
|
60
|
-
* a missing terminator stays missing.
|
|
61
|
-
*/
|
|
62
|
-
export function joinLines(lines: readonly string[], ending: LineEnding = "lf", finalNewline = true): string {
|
|
63
|
-
if (lines.length === 0) return "";
|
|
64
|
-
const sep = ending === "crlf" ? "\r\n" : "\n";
|
|
65
|
-
const body = lines.join(sep);
|
|
66
|
-
return finalNewline ? body + sep : body;
|
|
67
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -23,14 +23,15 @@ import { makeReplaceTool } from "./pi/replace-tool.ts";
|
|
|
23
23
|
export default function (pi: ExtensionAPI) {
|
|
24
24
|
const cwd = process.cwd();
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const state = getState();
|
|
29
|
-
state.config = loadConfig(cwd);
|
|
30
|
-
});
|
|
26
|
+
const state = getState();
|
|
27
|
+
state.config = loadConfig(cwd);
|
|
31
28
|
|
|
32
|
-
pi
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
// `enabled: false` leaves the extension fully inert — pi's built-in
|
|
30
|
+
// read/edit/grep stay in place, as if this package were not installed.
|
|
31
|
+
if (state.config.enabled) {
|
|
32
|
+
pi.registerTool(makeReadOverride(cwd));
|
|
33
|
+
pi.registerTool(makeEditOverride(cwd));
|
|
34
|
+
pi.registerTool(makeGrepOverride(cwd));
|
|
35
|
+
pi.registerTool(makeReplaceTool(cwd));
|
|
36
|
+
}
|
|
36
37
|
}
|
|
@@ -45,3 +45,108 @@ test("real rg emits anchored matches from a temporary directory", {
|
|
|
45
45
|
await rm(directory, { recursive: true, force: true });
|
|
46
46
|
}
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
test("real rg and line filters honor explicit case settings and Unicode folding", {
|
|
50
|
+
skip: rgPath === null,
|
|
51
|
+
}, async () => {
|
|
52
|
+
const directory = await mkdtemp(join(tmpdir(), "hl-grep-case-"));
|
|
53
|
+
try {
|
|
54
|
+
await writeFile(join(directory, "fixture.ts"), "FOO abc\nfoo BAR\nfoo bar\nK zip\nk zip\n");
|
|
55
|
+
const tool = makeGrepOverrideWithBackend(directory, {
|
|
56
|
+
findRg: async () => rgPath,
|
|
57
|
+
delegate: async () => {
|
|
58
|
+
throw new Error("integration test must not invoke the built-in grep delegate");
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
for (const ignoreCase of [undefined, false, true]) {
|
|
62
|
+
const expectedCount = ignoreCase === true ? 3 : 2;
|
|
63
|
+
for (const query of [
|
|
64
|
+
{ pattern: "foo\\S*" },
|
|
65
|
+
{ pattern: ["foo\\S*", "\\S+"], matchMode: "all" },
|
|
66
|
+
]) {
|
|
67
|
+
const result: any = await tool.execute("0", { ...query, ignoreCase }, undefined, undefined);
|
|
68
|
+
assert.match(result.content[0].text, new RegExp(`fixture\\.ts · ${expectedCount} matches`));
|
|
69
|
+
assert.equal(result.content[0].text.includes("FOO abc"), ignoreCase === true);
|
|
70
|
+
}
|
|
71
|
+
const excluded: any = await tool.execute("0", {
|
|
72
|
+
pattern: "foo\\S*", excludePattern: "bar", ignoreCase,
|
|
73
|
+
}, undefined, undefined);
|
|
74
|
+
assert.match(excluded.content[0].text, /fixture\.ts · 1 match/);
|
|
75
|
+
assert.ok(excluded.content[0].text.includes(ignoreCase === true ? "FOO abc" : "foo BAR"));
|
|
76
|
+
}
|
|
77
|
+
const unicodeAll: any = await tool.execute("0", {
|
|
78
|
+
pattern: ["k", "zip"], matchMode: "all", ignoreCase: true,
|
|
79
|
+
}, undefined, undefined);
|
|
80
|
+
assert.match(unicodeAll.content[0].text, /fixture\.ts · 2 matches/);
|
|
81
|
+
assert.ok(unicodeAll.content[0].text.includes("K zip"));
|
|
82
|
+
|
|
83
|
+
const unicodeExcluded: any = await tool.execute("0", {
|
|
84
|
+
pattern: "zip", excludePattern: "k", ignoreCase: true,
|
|
85
|
+
}, undefined, undefined);
|
|
86
|
+
assert.equal(unicodeExcluded.content[0].text, "No matches found");
|
|
87
|
+
const caseSensitive: any = await tool.execute("0", {
|
|
88
|
+
pattern: "zip", excludePattern: "k",
|
|
89
|
+
}, undefined, undefined);
|
|
90
|
+
assert.match(caseSensitive.content[0].text, /│K zip/);
|
|
91
|
+
assert.doesNotMatch(caseSensitive.content[0].text, /│k zip/);
|
|
92
|
+
} finally {
|
|
93
|
+
await rm(directory, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("real rg searches regex by default and rejects invalid patterns", {
|
|
98
|
+
skip: rgPath === null,
|
|
99
|
+
}, async () => {
|
|
100
|
+
const directory = await mkdtemp(join(tmpdir(), "hl-grep-regex-"));
|
|
101
|
+
try {
|
|
102
|
+
await writeFile(join(directory, "fixture.ts"), "FOO\nfoo\nqueueTool(\nfoo(?=bar)\n");
|
|
103
|
+
const tool = makeGrepOverrideWithBackend(directory, {
|
|
104
|
+
findRg: async () => rgPath,
|
|
105
|
+
delegate: async () => {
|
|
106
|
+
throw new Error("integration test must not invoke the built-in grep delegate");
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
for (const pattern of ["(?i)^foo$", "(?P<name>foo)$"]) {
|
|
110
|
+
const result: any = await tool.execute("0", { pattern }, undefined, undefined);
|
|
111
|
+
assert.match(result.content[0].text, /│foo/);
|
|
112
|
+
}
|
|
113
|
+
for (const pattern of ["queueTool(", "foo(?=bar)"]) {
|
|
114
|
+
await assert.rejects(
|
|
115
|
+
tool.execute("0", { pattern }, undefined, undefined),
|
|
116
|
+
/regex parse error/,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const explicit: any = await tool.execute("0", { pattern: "queueTool(", literal: true }, undefined, undefined);
|
|
120
|
+
assert.match(explicit.content[0].text, /│queueTool\(/);
|
|
121
|
+
} finally {
|
|
122
|
+
await rm(directory, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("fallback forwards search settings unchanged to an rg-backed delegate", {
|
|
127
|
+
skip: rgPath === null,
|
|
128
|
+
}, async () => {
|
|
129
|
+
const directory = await mkdtemp(join(tmpdir(), "hl-grep-fallback-"));
|
|
130
|
+
try {
|
|
131
|
+
await writeFile(join(directory, "fixture.ts"), "FOO\nfoo\nqueueTool(\n");
|
|
132
|
+
const delegate = makeGrepOverrideWithBackend(directory, {
|
|
133
|
+
findRg: async () => rgPath,
|
|
134
|
+
delegate: async () => { throw new Error("must not invoke the built-in download path"); },
|
|
135
|
+
});
|
|
136
|
+
const fallback = makeGrepOverrideWithBackend(directory, {
|
|
137
|
+
findRg: async () => null,
|
|
138
|
+
delegate: (...args) => delegate.execute(...args),
|
|
139
|
+
});
|
|
140
|
+
for (const pattern of ["foo", "(?i)^foo$", "(?P<name>foo)$"]) {
|
|
141
|
+
const result: any = await fallback.execute("0", { pattern }, undefined, undefined);
|
|
142
|
+
const output = result.content[0].text;
|
|
143
|
+
assert.match(output, /│foo/);
|
|
144
|
+
assert.equal(output.includes("│FOO"), pattern.includes("?i"));
|
|
145
|
+
}
|
|
146
|
+
await assert.rejects(fallback.execute("0", { pattern: "queueTool(" }, undefined, undefined), /regex parse error/);
|
|
147
|
+
const literal: any = await fallback.execute("0", { pattern: "queueTool(", literal: true }, undefined, undefined);
|
|
148
|
+
assert.match(literal.content[0].text, /│queueTool\(/);
|
|
149
|
+
} finally {
|
|
150
|
+
await rm(directory, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
});
|
package/src/pi/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
|
|
13
13
|
export interface HashlineEditConfig {
|
|
14
|
-
/**
|
|
14
|
+
/** Master switch: when false the extension registers no tools — pi's built-ins remain. */
|
|
15
15
|
enabled: boolean;
|
|
16
16
|
/** Line hash length (default 4). */
|
|
17
17
|
hashLen: number;
|
package/src/pi/edit-tool.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* @module pi-hashline-edit/pi
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import {
|
|
22
|
+
import { generateDiffString, generateUnifiedPatch, withFileMutationQueue, type EditToolDetails } from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { Type, type Static } from "typebox";
|
|
24
24
|
import { Text } from "@earendil-works/pi-tui";
|
|
25
25
|
import { readFile, writeFile } from "node:fs/promises";
|
|
@@ -201,7 +201,6 @@ function editHeader(args: Static<typeof editSchema>, theme: any, counts?: DiffCo
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
export function makeEditOverride(cwd: string) {
|
|
204
|
-
const builtin = createEditTool(cwd);
|
|
205
204
|
|
|
206
205
|
return {
|
|
207
206
|
name: "edit" as const,
|
|
@@ -255,10 +254,6 @@ export function makeEditOverride(cwd: string) {
|
|
|
255
254
|
},
|
|
256
255
|
|
|
257
256
|
async execute(toolCallId: string, params: Static<typeof editSchema>, signal: AbortSignal | undefined, onUpdate: any) {
|
|
258
|
-
const state = getState();
|
|
259
|
-
// hashline disabled by the user (config.enabled=false) → delegate to the built-in
|
|
260
|
-
if (!state.config.enabled) return builtin.execute(toolCallId, params as any, signal, onUpdate);
|
|
261
|
-
|
|
262
257
|
const path = params.path;
|
|
263
258
|
const absPath = canonicalPath(cwd, path);
|
|
264
259
|
|
package/src/pi/execute.test.ts
CHANGED
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { test } from "node:test";
|
|
7
7
|
import assert from "node:assert/strict";
|
|
8
|
-
import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
|
|
8
|
+
import { mkdir, mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { createEditTool, initTheme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
13
|
+
import registerHashline from "../index.ts";
|
|
12
14
|
import { makeEditOverride } from "./edit-tool.ts";
|
|
13
15
|
import { makeReadOverride } from "./read-tool.ts";
|
|
16
|
+
import { getState } from "./state.ts";
|
|
14
17
|
import { computeLineHash } from "../core/hash.ts";
|
|
15
18
|
import { splitLines } from "../core/lines.ts";
|
|
16
19
|
|
|
@@ -242,6 +245,32 @@ test("edit execute: delete op", async () => {
|
|
|
242
245
|
});
|
|
243
246
|
});
|
|
244
247
|
|
|
248
|
+
test("disabled config registers no tools — built-ins remain", async () => {
|
|
249
|
+
await withDir(async (dir) => {
|
|
250
|
+
const oldCwd = process.cwd();
|
|
251
|
+
const state = getState();
|
|
252
|
+
const previous = state.config;
|
|
253
|
+
try {
|
|
254
|
+
await mkdir(join(dir, ".pi"));
|
|
255
|
+
await writeFile(join(dir, ".pi", "settings.json"), JSON.stringify({ hashlineEdit: { enabled: false } }));
|
|
256
|
+
await writeFile(join(dir, "f.txt"), "old value\n");
|
|
257
|
+
process.chdir(dir);
|
|
258
|
+
const registered: string[] = [];
|
|
259
|
+
registerHashline({ on() {}, registerTool(tool: { name: string }) { registered.push(tool.name); } } as any);
|
|
260
|
+
assert.deepEqual(registered, []);
|
|
261
|
+
const builtin = createEditTool(dir);
|
|
262
|
+
const params = validateToolArguments(builtin, {
|
|
263
|
+
name: "edit", arguments: { path: "f.txt", edits: [{ oldText: "old value", newText: "new value" }] },
|
|
264
|
+
} as any);
|
|
265
|
+
await call(builtin, params);
|
|
266
|
+
assert.equal(await readFile(join(dir, "f.txt"), "utf-8"), "new value\n");
|
|
267
|
+
} finally {
|
|
268
|
+
process.chdir(oldCwd);
|
|
269
|
+
state.config = previous;
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
245
274
|
// --- renderer regression guards (details.diff must be a string, renderResult must not throw) ---
|
|
246
275
|
|
|
247
276
|
const stubTheme = { fg: (_k: string, s: string) => s, bold: (s: string) => s };
|
package/src/pi/grep-tool.ts
CHANGED
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
* pre-filter candidates. Context windows are likewise rebuilt client-side from
|
|
23
23
|
* the surviving matches — context lines of a filtered-out match never leak.
|
|
24
24
|
*
|
|
25
|
-
* Falls back to the built-in grep when
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* Falls back to the built-in grep when aborted or when ripgrep cannot be
|
|
26
|
+
* located (the built-in can auto-download rg). Extended params never
|
|
27
|
+
* delegate — the built-in would misread them.
|
|
28
28
|
*
|
|
29
29
|
* @module pi-hashline-edit/pi
|
|
30
30
|
*/
|
|
@@ -91,7 +91,7 @@ function compileLineMatcher(
|
|
|
91
91
|
): RegExp {
|
|
92
92
|
let source = opts.literal ? escapeRegex(pattern) : pattern;
|
|
93
93
|
if (opts.word) source = `\\b(?:${source})\\b`;
|
|
94
|
-
const flags = opts.ignoreCase ? "
|
|
94
|
+
const flags = opts.ignoreCase ? "iu" : "u";
|
|
95
95
|
try {
|
|
96
96
|
return new RegExp(source, flags);
|
|
97
97
|
} catch (err) {
|
|
@@ -110,24 +110,22 @@ function toArray(v: string | string[] | undefined): string[] {
|
|
|
110
110
|
const grepOverrideSchema = Type.Object({
|
|
111
111
|
pattern: Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
112
112
|
description:
|
|
113
|
-
"
|
|
113
|
+
"Regex pattern, or literal text with literal:true. String or array; arrays combine per matchMode.",
|
|
114
114
|
}),
|
|
115
115
|
matchMode: Type.Optional(
|
|
116
116
|
Type.Union([Type.Literal("any"), Type.Literal("all")], {
|
|
117
117
|
description:
|
|
118
|
-
'
|
|
118
|
+
'"any" (default): OR. "all": AND on the same line.',
|
|
119
119
|
}),
|
|
120
120
|
),
|
|
121
121
|
excludePattern: Type.Optional(
|
|
122
122
|
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
123
|
-
description:
|
|
124
|
-
"Drop lines matching this pattern, like grep -v (string or array; same regex/literal/ignoreCase settings as pattern). Applied after pattern matching",
|
|
123
|
+
description: "Drop lines matching any exclusion after pattern matching; uses the same literal and ignoreCase settings.",
|
|
125
124
|
}),
|
|
126
125
|
),
|
|
127
126
|
outputMode: Type.Optional(
|
|
128
127
|
Type.Union([Type.Literal("content"), Type.Literal("files"), Type.Literal("count")], {
|
|
129
|
-
description:
|
|
130
|
-
'Output shape (default "content"). "content": anchored matching lines. "files": only file paths with matches (rg -l). "count": per-file match counts + total (grep -c)',
|
|
128
|
+
description: '"content" (default): anchored lines. "files": paths. "count": matching lines per file and total.',
|
|
131
129
|
}),
|
|
132
130
|
),
|
|
133
131
|
wordMatch: Type.Optional(Type.Boolean({ description: "Match whole words only (rg -w)" })),
|
|
@@ -139,11 +137,11 @@ const grepOverrideSchema = Type.Object({
|
|
|
139
137
|
Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" }),
|
|
140
138
|
),
|
|
141
139
|
ignoreCase: Type.Optional(
|
|
142
|
-
Type.Boolean({ description: "Case-insensitive search (default: false)" }),
|
|
140
|
+
Type.Boolean({ description: "Case-insensitive search (default: false); applies to pattern and excludePattern." }),
|
|
143
141
|
),
|
|
144
142
|
literal: Type.Optional(
|
|
145
143
|
Type.Boolean({
|
|
146
|
-
description: "Treat pattern as literal
|
|
144
|
+
description: "Treat pattern and excludePattern as literal text (default: false; regex). Invalid regexes return an error.",
|
|
147
145
|
}),
|
|
148
146
|
),
|
|
149
147
|
context: Type.Optional(
|
|
@@ -305,14 +303,12 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
305
303
|
name: "grep" as const,
|
|
306
304
|
label: "grep",
|
|
307
305
|
description:
|
|
308
|
-
"Search file contents
|
|
309
|
-
promptSnippet:
|
|
310
|
-
"Search file contents; results show LINE#HASH anchors usable directly in edit; multi-pattern AND, exclude, files-only and count modes replace bash grep pipelines",
|
|
306
|
+
"Search file contents, respecting .gitignore. Content results are grouped by file and include LINE#HASH anchors usable in edit; built-in grep fallback results have no anchors.",
|
|
307
|
+
promptSnippet: "Search file contents with edit-ready line anchors",
|
|
311
308
|
promptGuidelines: [
|
|
312
|
-
"
|
|
313
|
-
"
|
|
314
|
-
'
|
|
315
|
-
"Pass `pattern` (string or array); optionally `path` (string or array), `glob`, `ignoreCase`, `literal`, `wordMatch`, `context` (lines before+after each match), `limit` (max matches, default 100).",
|
|
309
|
+
"Prefer the grep tool for file-content searches.",
|
|
310
|
+
"Use returned LINE#HASH anchors directly in edit when present; no re-read is needed.",
|
|
311
|
+
'Use outputMode:"files"/"count" when only paths or counts are needed; use matchMode:"all" and excludePattern for line-level filters instead of shell pipelines.',
|
|
316
312
|
],
|
|
317
313
|
parameters: grepOverrideSchema,
|
|
318
314
|
|
|
@@ -372,6 +368,9 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
372
368
|
// aborted → built-in grep (it handles abort itself)
|
|
373
369
|
if (signal?.aborted) return backend.delegate(toolCallId, params, signal, onUpdate);
|
|
374
370
|
|
|
371
|
+
const patterns = toArray(params.pattern);
|
|
372
|
+
if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
|
|
373
|
+
|
|
375
374
|
// Plain built-in-shaped params (single string pattern/path, no new fields)
|
|
376
375
|
// can delegate safely; anything else must run the local pipeline below.
|
|
377
376
|
const legacyShaped =
|
|
@@ -382,10 +381,6 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
382
381
|
params.wordMatch === undefined &&
|
|
383
382
|
!Array.isArray(params.path);
|
|
384
383
|
|
|
385
|
-
// disabled + plain params → built-in grep, exactly as before
|
|
386
|
-
if (!state.config.enabled && legacyShaped)
|
|
387
|
-
return backend.delegate(toolCallId, params, signal, onUpdate);
|
|
388
|
-
|
|
389
384
|
const rgPath = await backend.findRg();
|
|
390
385
|
// ripgrep unavailable → built-in (it can auto-download rg), but only for plain params
|
|
391
386
|
if (!rgPath) {
|
|
@@ -395,12 +390,7 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
395
390
|
);
|
|
396
391
|
}
|
|
397
392
|
|
|
398
|
-
// disabled + extended params still run locally, formatted without anchors
|
|
399
|
-
const anchored = state.config.enabled;
|
|
400
|
-
|
|
401
|
-
const patterns = toArray(params.pattern);
|
|
402
393
|
const excludes = toArray(params.excludePattern);
|
|
403
|
-
if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
|
|
404
394
|
const matchMode: "any" | "all" = params.matchMode ?? "any";
|
|
405
395
|
const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
|
|
406
396
|
const { glob, ignoreCase, literal, wordMatch, context, limit } = params;
|
|
@@ -411,6 +401,8 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
411
401
|
})();
|
|
412
402
|
const hashLen = state.config.hashLen;
|
|
413
403
|
|
|
404
|
+
// Verify search paths upfront so a typo fails fast with a clear error
|
|
405
|
+
// (rg's own diagnostics are less actionable).
|
|
414
406
|
for (const sp of searchPaths) {
|
|
415
407
|
try {
|
|
416
408
|
await stat(sp);
|
|
@@ -537,7 +529,6 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
537
529
|
if (outputMode === "content") {
|
|
538
530
|
for (const [fp, matchLines] of byFile) {
|
|
539
531
|
const { lines, hashes } = await getFile(fp);
|
|
540
|
-
const matchSet = new Set(matchLines);
|
|
541
532
|
// Context windows are rebuilt from surviving matches so context
|
|
542
533
|
// lines of a filtered-out match never leak.
|
|
543
534
|
const windowSet = new Set<number>();
|
|
@@ -545,18 +536,14 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
545
536
|
for (let n = Math.max(1, ln - ctx); n <= Math.min(lines.length, ln + ctx); n++)
|
|
546
537
|
windowSet.add(n);
|
|
547
538
|
}
|
|
548
|
-
const header =
|
|
549
|
-
? `${formatPath(fp)} · ${matchLines.length} match${matchLines.length !== 1 ? "es" : ""}\n`
|
|
550
|
-
: "";
|
|
539
|
+
const header = `${formatPath(fp)} · ${matchLines.length} match${matchLines.length !== 1 ? "es" : ""}\n`;
|
|
551
540
|
const rows: string[] = [];
|
|
552
541
|
for (const n of [...windowSet].sort((a, b) => a - b)) {
|
|
553
542
|
const content = lines[n - 1] ?? "";
|
|
554
543
|
const hash = hashes[n - 1] ?? "";
|
|
555
544
|
const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
|
|
556
545
|
if (wasTruncated) linesTruncated = true;
|
|
557
|
-
|
|
558
|
-
else if (matchSet.has(n)) rows.push(`${formatPath(fp)}:${n}: ${disp}`);
|
|
559
|
-
else rows.push(`${formatPath(fp)}-${n}- ${disp}`);
|
|
546
|
+
rows.push(`${n}#${hash}│${disp}`);
|
|
560
547
|
}
|
|
561
548
|
blocks.push(`${header}${rows.join("\n")}`);
|
|
562
549
|
}
|
package/src/pi/grep.test.ts
CHANGED
|
@@ -131,11 +131,13 @@ test("grep in a subdirectory returns a path that edits the matching file", async
|
|
|
131
131
|
const edit: any = makeEditOverride(dir);
|
|
132
132
|
await call(edit, {
|
|
133
133
|
path: displayPath,
|
|
134
|
-
edits: [
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
edits: [
|
|
135
|
+
{
|
|
136
|
+
op: "replace",
|
|
137
|
+
anchor: { line: 1, hash: computeLineHash(1, original.trimEnd()) },
|
|
138
|
+
body: ["export const status = 2;"],
|
|
139
|
+
},
|
|
140
|
+
],
|
|
139
141
|
});
|
|
140
142
|
assert.equal(await readFile(matchedFile, "utf-8"), "export const status = 2;\n");
|
|
141
143
|
assert.equal(await readFile(rootFile, "utf-8"), original);
|
|
@@ -293,24 +295,6 @@ test("delegates only safe fallbacks and rejects extended missing-rg requests", a
|
|
|
293
295
|
/ripgrep \(rg\) not found/,
|
|
294
296
|
);
|
|
295
297
|
});
|
|
296
|
-
|
|
297
|
-
const file = join(dir, "a.ts");
|
|
298
|
-
await writeFile(file, "x y\n");
|
|
299
|
-
const disabled = fakeBackend({ lines: [rgMatch(file, 1, "x y\n")] });
|
|
300
|
-
await withEnabled(false, async () => {
|
|
301
|
-
assert.equal(
|
|
302
|
-
text(await call(makeGrepOverrideWithBackend(dir, disabled.backend), { pattern: "x" })),
|
|
303
|
-
"delegated",
|
|
304
|
-
);
|
|
305
|
-
assert.equal(disabled.calls.length, 0);
|
|
306
|
-
const extended = await call(makeGrepOverrideWithBackend(dir, disabled.backend), {
|
|
307
|
-
pattern: ["x", "y"],
|
|
308
|
-
matchMode: "all",
|
|
309
|
-
});
|
|
310
|
-
assert.match(text(extended), /a\.ts:1: x y/);
|
|
311
|
-
assert.doesNotMatch(text(extended), /#[0-9A-Z]+│/);
|
|
312
|
-
assert.equal(disabled.calls.length, 1);
|
|
313
|
-
});
|
|
314
298
|
});
|
|
315
299
|
});
|
|
316
300
|
|
|
@@ -342,3 +326,60 @@ test("delegates an already-aborted call and rejects an abort during rg execution
|
|
|
342
326
|
);
|
|
343
327
|
});
|
|
344
328
|
});
|
|
329
|
+
|
|
330
|
+
test("keeps regex and case-sensitive defaults across rg and line filters", async () => {
|
|
331
|
+
await withDir(async (dir) => {
|
|
332
|
+
const target = join(dir, "case.ts");
|
|
333
|
+
await writeFile(target, "FOO alpha\n");
|
|
334
|
+
const fake = fakeBackend({ lines: [rgMatch(target, 1, "FOO alpha\n")] });
|
|
335
|
+
const tool = makeGrepOverrideWithBackend(dir, fake.backend);
|
|
336
|
+
const query = { pattern: ["foo", "alpha"], matchMode: "all", excludePattern: "BETA" };
|
|
337
|
+
|
|
338
|
+
assert.equal(text(await call(tool, query)), "No matches found");
|
|
339
|
+
assert.match(text(await call(tool, { ...query, ignoreCase: true })), /FOO alpha/);
|
|
340
|
+
assert.match(text(await call(tool, { pattern: "FOO", literal: true })), /FOO alpha/);
|
|
341
|
+
assert.deepEqual(fake.calls.map(({ args }) => ({
|
|
342
|
+
ignoreCase: args.includes("--ignore-case"),
|
|
343
|
+
literal: args.includes("--fixed-strings"),
|
|
344
|
+
})), [
|
|
345
|
+
{ ignoreCase: false, literal: false },
|
|
346
|
+
{ ignoreCase: true, literal: false },
|
|
347
|
+
{ ignoreCase: false, literal: true },
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
const invalid = fakeBackend({ code: 2, stderr: "regex parse error:\nerror: unclosed group" });
|
|
351
|
+
await assert.rejects(
|
|
352
|
+
call(makeGrepOverrideWithBackend(dir, invalid.backend), { pattern: "queueTool(" }),
|
|
353
|
+
/regex parse error/,
|
|
354
|
+
);
|
|
355
|
+
assert.equal(invalid.calls.length, 1);
|
|
356
|
+
assert.ok(!invalid.calls[0].args.includes("--fixed-strings"));
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("native fallback forwards parameters unchanged and propagates regex errors", async () => {
|
|
361
|
+
await withDir(async (dir) => {
|
|
362
|
+
const fake = fakeBackend();
|
|
363
|
+
fake.backend.findRg = async () => null;
|
|
364
|
+
const tool = makeGrepOverrideWithBackend(dir, fake.backend);
|
|
365
|
+
const cases = [
|
|
366
|
+
{ pattern: "foo" },
|
|
367
|
+
{ pattern: "(?i)foo" },
|
|
368
|
+
{ pattern: "queueTool(", literal: true, ignoreCase: true },
|
|
369
|
+
];
|
|
370
|
+
for (const params of cases) {
|
|
371
|
+
const input = { ...params, path: "fixture.ts", glob: "*.ts", context: 2, limit: 3 };
|
|
372
|
+
assert.equal(text(await call(tool, input)), "delegated");
|
|
373
|
+
assert.deepEqual(fake.delegates.at(-1)![1], input);
|
|
374
|
+
}
|
|
375
|
+
assert.equal(fake.delegates.length, cases.length);
|
|
376
|
+
assert.equal(fake.calls.length, 0);
|
|
377
|
+
fake.backend.delegate = async (...args) => {
|
|
378
|
+
fake.delegates.push(args);
|
|
379
|
+
throw new Error("regex parse error: unclosed group");
|
|
380
|
+
};
|
|
381
|
+
const failingTool = makeGrepOverrideWithBackend(dir, fake.backend);
|
|
382
|
+
await assert.rejects(call(failingTool, { pattern: "queueTool(" }), /regex parse error/);
|
|
383
|
+
assert.equal(fake.delegates.length, cases.length + 1);
|
|
384
|
+
});
|
|
385
|
+
});
|
package/src/pi/read-tool.ts
CHANGED
|
@@ -144,8 +144,8 @@ export function makeReadOverride(cwd: string) {
|
|
|
144
144
|
},
|
|
145
145
|
|
|
146
146
|
async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any) {
|
|
147
|
-
//
|
|
148
|
-
if (
|
|
147
|
+
// User cancelled → delegate to the built-in (builtin handles abort itself)
|
|
148
|
+
if (signal?.aborted) return builtin.execute(toolCallId, params, signal, onUpdate);
|
|
149
149
|
|
|
150
150
|
const absPath = canonicalPath(cwd, params.path as string);
|
|
151
151
|
let buf: Buffer;
|
package/src/pi/replace-tool.ts
CHANGED
|
@@ -210,12 +210,6 @@ export function makeReplaceTool(cwd: string) {
|
|
|
210
210
|
|
|
211
211
|
async execute(toolCallId: string, params: ReplaceParams, signal: AbortSignal | undefined, onUpdate: any) {
|
|
212
212
|
const state = getState();
|
|
213
|
-
// Gated by the same `enabled` switch as read/grep/edit — they delegate to the
|
|
214
|
-
// built-ins when disabled; replace has no built-in counterpart, so it refuses.
|
|
215
|
-
if (!state.config.enabled)
|
|
216
|
-
throw new Error(
|
|
217
|
-
"Replace is unavailable: hashlineEdit is disabled. Set hashlineEdit.enabled = true to use it.",
|
|
218
|
-
);
|
|
219
213
|
const path = params.path;
|
|
220
214
|
const absPath = canonicalPath(cwd, path);
|
|
221
215
|
|
package/src/pi/replace.test.ts
CHANGED
|
@@ -14,7 +14,6 @@ import { join } from "node:path";
|
|
|
14
14
|
import { initTheme } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { makeReplaceTool } from "./replace-tool.ts";
|
|
16
16
|
import { makeEditOverride } from "./edit-tool.ts";
|
|
17
|
-
import { getState } from "./state.ts";
|
|
18
17
|
|
|
19
18
|
async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
20
19
|
const dir = await mkdtemp(join(tmpdir(), "hl-replace-"));
|
|
@@ -286,22 +285,3 @@ test("replace header: renderResult refreshes the call header in place — no inv
|
|
|
286
285
|
assert.ok(!invalidated, "renderResult must not call invalidate");
|
|
287
286
|
});
|
|
288
287
|
});
|
|
289
|
-
|
|
290
|
-
test("replace: refuses and leaves the file untouched when hashlineEdit is disabled", async () => {
|
|
291
|
-
await withDir(async (dir) => {
|
|
292
|
-
const f = join(dir, "f.txt");
|
|
293
|
-
await writeFile(f, "a\nb\na\n");
|
|
294
|
-
const state = getState();
|
|
295
|
-
const saved = state.config;
|
|
296
|
-
state.config = { ...saved, enabled: false };
|
|
297
|
-
try {
|
|
298
|
-
await assert.rejects(
|
|
299
|
-
call(makeReplaceTool(dir), { path: "f.txt", find: "a", replace: "b" }),
|
|
300
|
-
/disabled/,
|
|
301
|
-
);
|
|
302
|
-
assert.equal(await readFile(f, "utf-8"), "a\nb\na\n", "file untouched when disabled");
|
|
303
|
-
} finally {
|
|
304
|
-
state.config = saved;
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
});
|
package/src/pi/state.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Session-level config holder.
|
|
3
3
|
*
|
|
4
4
|
* globalThis singleton (consistent with the repo's module-identity guidance).
|
|
5
|
-
* Config is loaded
|
|
5
|
+
* Config is loaded when the extension registers and read by the tool
|
|
6
|
+
* overrides.
|
|
6
7
|
*
|
|
7
8
|
* @module pi-hashline-edit/pi
|
|
8
9
|
*/
|