@d3ara1n/pi-hashline-edit 0.4.1 → 0.5.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 +90 -12
- package/package.json +7 -2
- package/src/core/apply.test.ts +31 -0
- package/src/core/apply.ts +2 -2
- package/src/core/index.ts +1 -1
- package/src/core/lines.test.ts +24 -1
- package/src/core/lines.ts +30 -5
- package/src/integration/grep-rg.test.ts +46 -0
- package/src/pi/execute.test.ts +30 -0
- package/src/pi/grep-tool.ts +551 -300
- package/src/pi/grep.test.ts +312 -0
- package/src/pi/read-tool.ts +9 -4
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic grep override tests. Ripgrep and built-in grep are injected;
|
|
3
|
+
* fixture files live only in a per-test system temporary directory.
|
|
4
|
+
*/
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { computeLineHash } from "../core/hash.ts";
|
|
11
|
+
import { makeGrepOverrideWithBackend, type GrepBackend } from "./grep-tool.ts";
|
|
12
|
+
import { getState } from "./state.ts";
|
|
13
|
+
|
|
14
|
+
type FakeOptions = {
|
|
15
|
+
lines?: string[];
|
|
16
|
+
code?: number | null;
|
|
17
|
+
stderr?: string;
|
|
18
|
+
error?: Error;
|
|
19
|
+
onRun?: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
23
|
+
const dir = await mkdtemp(join(tmpdir(), "hl-grep-"));
|
|
24
|
+
try {
|
|
25
|
+
return await fn(dir);
|
|
26
|
+
} finally {
|
|
27
|
+
await rm(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rgMatch(filePath: string, lineNumber: number, text: string): string {
|
|
32
|
+
return JSON.stringify({
|
|
33
|
+
type: "match",
|
|
34
|
+
data: { path: { text: filePath }, line_number: lineNumber, lines: { text } },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fakeBackend(options: FakeOptions = {}) {
|
|
39
|
+
const calls: { path: string; args: string[] }[] = [];
|
|
40
|
+
const delegates: any[][] = [];
|
|
41
|
+
const backend: GrepBackend = {
|
|
42
|
+
async findRg() {
|
|
43
|
+
return "/fake/rg";
|
|
44
|
+
},
|
|
45
|
+
async runRg(path, args, _signal, onLine) {
|
|
46
|
+
calls.push({ path, args });
|
|
47
|
+
options.onRun?.();
|
|
48
|
+
if (options.error) throw options.error;
|
|
49
|
+
for (const line of options.lines ?? []) {
|
|
50
|
+
if (!onLine(line)) return { code: null, stderr: options.stderr ?? "", stopped: true };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
code: options.code === undefined ? 0 : options.code,
|
|
54
|
+
stderr: options.stderr ?? "",
|
|
55
|
+
stopped: false,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
async delegate(...args) {
|
|
59
|
+
delegates.push(args);
|
|
60
|
+
return { content: [{ type: "text", text: "delegated" }], details: undefined };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
return { backend, calls, delegates };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const text = (result: any): string => result.content[0].text;
|
|
67
|
+
const call = (tool: any, params: any, signal?: AbortSignal) =>
|
|
68
|
+
tool.execute("0", params, signal, undefined);
|
|
69
|
+
|
|
70
|
+
async function withEnabled<T>(enabled: boolean, fn: () => Promise<T>): Promise<T> {
|
|
71
|
+
const state = getState();
|
|
72
|
+
const previous = state.config.enabled;
|
|
73
|
+
state.config.enabled = enabled;
|
|
74
|
+
try {
|
|
75
|
+
return await fn();
|
|
76
|
+
} finally {
|
|
77
|
+
state.config.enabled = previous;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test("formats parsed rg matches with full-line hash anchors", async () => {
|
|
82
|
+
await withDir(async (dir) =>
|
|
83
|
+
withEnabled(true, async () => {
|
|
84
|
+
const a = join(dir, "a.ts");
|
|
85
|
+
const b = join(dir, "b.ts");
|
|
86
|
+
await writeFile(a, "alpha beta\ngamma\nalpha only\n");
|
|
87
|
+
await writeFile(b, "alpha here\n");
|
|
88
|
+
const fake = fakeBackend({
|
|
89
|
+
lines: [
|
|
90
|
+
"not json",
|
|
91
|
+
JSON.stringify({ type: "begin" }),
|
|
92
|
+
rgMatch(a, 1, "alpha beta\n"),
|
|
93
|
+
rgMatch(a, 3, "alpha only\n"),
|
|
94
|
+
rgMatch(b, 1, "alpha here\n"),
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const result = await call(makeGrepOverrideWithBackend(dir, fake.backend), {
|
|
99
|
+
pattern: "alpha",
|
|
100
|
+
});
|
|
101
|
+
const output = text(result);
|
|
102
|
+
assert.match(output, /a\.ts · 2 matches/);
|
|
103
|
+
assert.match(output, /b\.ts · 1 match/);
|
|
104
|
+
assert.match(output, new RegExp(`1#${computeLineHash(1, "alpha beta")}│alpha beta`));
|
|
105
|
+
assert.match(output, /3#[0-9A-Z]+│alpha only/);
|
|
106
|
+
assert.deepEqual(fake.calls[0], {
|
|
107
|
+
path: "/fake/rg",
|
|
108
|
+
args: ["--json", "--line-number", "--color=never", "--hidden", "-e", "alpha", "--", dir],
|
|
109
|
+
});
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("applies all, exclude, context, and CRLF filtering after rg output", async () => {
|
|
115
|
+
await withDir(async (dir) =>
|
|
116
|
+
withEnabled(true, async () => {
|
|
117
|
+
const file = join(dir, "a.ts");
|
|
118
|
+
await writeFile(
|
|
119
|
+
file,
|
|
120
|
+
"outside-before\r\nalpha beta drop\r\nbefore survivor\r\nalpha beta\r\nafter survivor\r\nalpha only\r\noutside-after\r\n",
|
|
121
|
+
);
|
|
122
|
+
const fake = fakeBackend({
|
|
123
|
+
lines: [
|
|
124
|
+
rgMatch(file, 2, "alpha beta drop\r\n"),
|
|
125
|
+
rgMatch(file, 4, "alpha beta\r\n"),
|
|
126
|
+
rgMatch(file, 6, "alpha only\r\n"),
|
|
127
|
+
],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const result = await call(makeGrepOverrideWithBackend(dir, fake.backend), {
|
|
131
|
+
pattern: ["alpha", "beta$"],
|
|
132
|
+
matchMode: "all",
|
|
133
|
+
excludePattern: "drop",
|
|
134
|
+
context: 1,
|
|
135
|
+
});
|
|
136
|
+
assert.equal(
|
|
137
|
+
text(result),
|
|
138
|
+
[
|
|
139
|
+
"a.ts · 1 match",
|
|
140
|
+
`3#${computeLineHash(3, "before survivor")}│before survivor`,
|
|
141
|
+
`4#${computeLineHash(4, "alpha beta")}│alpha beta`,
|
|
142
|
+
`5#${computeLineHash(5, "after survivor")}│after survivor`,
|
|
143
|
+
].join("\n"),
|
|
144
|
+
);
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("passes output flags and formats files and counts", async () => {
|
|
150
|
+
await withDir(async (dir) =>
|
|
151
|
+
withEnabled(true, async () => {
|
|
152
|
+
const a = join(dir, "a.ts");
|
|
153
|
+
const b = join(dir, "b.ts");
|
|
154
|
+
await writeFile(a, "Foo a.b\n");
|
|
155
|
+
await writeFile(b, "foo a.b\n");
|
|
156
|
+
const fake = fakeBackend({ lines: [rgMatch(a, 1, "Foo a.b\n"), rgMatch(b, 1, "foo a.b\n")] });
|
|
157
|
+
|
|
158
|
+
const files = await call(makeGrepOverrideWithBackend(dir, fake.backend), {
|
|
159
|
+
pattern: ["Foo", "a.b"],
|
|
160
|
+
path: ["a.ts", "b.ts"],
|
|
161
|
+
glob: "*.ts",
|
|
162
|
+
ignoreCase: true,
|
|
163
|
+
literal: true,
|
|
164
|
+
wordMatch: true,
|
|
165
|
+
outputMode: "files",
|
|
166
|
+
});
|
|
167
|
+
assert.equal(text(files), "a.ts\nb.ts");
|
|
168
|
+
assert.deepEqual(fake.calls[0].args, [
|
|
169
|
+
"--json",
|
|
170
|
+
"--line-number",
|
|
171
|
+
"--color=never",
|
|
172
|
+
"--hidden",
|
|
173
|
+
"--ignore-case",
|
|
174
|
+
"--fixed-strings",
|
|
175
|
+
"--word-regexp",
|
|
176
|
+
"--glob",
|
|
177
|
+
"*.ts",
|
|
178
|
+
"-e",
|
|
179
|
+
"Foo",
|
|
180
|
+
"-e",
|
|
181
|
+
"a.b",
|
|
182
|
+
"--",
|
|
183
|
+
a,
|
|
184
|
+
b,
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
const count = await call(makeGrepOverrideWithBackend(dir, fake.backend), {
|
|
188
|
+
pattern: "foo",
|
|
189
|
+
outputMode: "count",
|
|
190
|
+
});
|
|
191
|
+
assert.equal(text(count), "a.ts: 1\nb.ts: 1\nTotal: 2 matches in 2 files");
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("counts only surviving matches toward the limit and stops the fake runner", async () => {
|
|
197
|
+
await withDir(async (dir) =>
|
|
198
|
+
withEnabled(true, async () => {
|
|
199
|
+
const file = join(dir, "a.ts");
|
|
200
|
+
await writeFile(file, "alpha beta\nalpha only\nalpha later\n");
|
|
201
|
+
const fake = fakeBackend({
|
|
202
|
+
lines: [
|
|
203
|
+
rgMatch(file, 1, "alpha beta\n"),
|
|
204
|
+
rgMatch(file, 2, "alpha only\n"),
|
|
205
|
+
rgMatch(file, 3, "alpha later\n"),
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = await call(makeGrepOverrideWithBackend(dir, fake.backend), {
|
|
210
|
+
pattern: "alpha",
|
|
211
|
+
excludePattern: "beta",
|
|
212
|
+
limit: 1,
|
|
213
|
+
});
|
|
214
|
+
assert.match(text(result), /2#[0-9A-Z]+│alpha only/);
|
|
215
|
+
assert.match(
|
|
216
|
+
text(result),
|
|
217
|
+
/\[1 matches limit reached\. Use limit=2 for more, or refine pattern\]/,
|
|
218
|
+
);
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("reports empty output and ripgrep execution failures", async () => {
|
|
224
|
+
await withDir(async (dir) =>
|
|
225
|
+
withEnabled(true, async () => {
|
|
226
|
+
const empty = fakeBackend({ code: 1 });
|
|
227
|
+
assert.equal(
|
|
228
|
+
text(await call(makeGrepOverrideWithBackend(dir, empty.backend), { pattern: "missing" })),
|
|
229
|
+
"No matches found",
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const failed = fakeBackend({ code: 2, stderr: "bad regex" });
|
|
233
|
+
await assert.rejects(
|
|
234
|
+
call(makeGrepOverrideWithBackend(dir, failed.backend), { pattern: "[" }),
|
|
235
|
+
/bad regex/,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const rejected = fakeBackend({ error: new Error("spawn failed") });
|
|
239
|
+
await assert.rejects(
|
|
240
|
+
call(makeGrepOverrideWithBackend(dir, rejected.backend), { pattern: "x" }),
|
|
241
|
+
/spawn failed/,
|
|
242
|
+
);
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("delegates only safe fallbacks and rejects extended missing-rg requests", async () => {
|
|
248
|
+
await withDir(async (dir) => {
|
|
249
|
+
const absent = fakeBackend();
|
|
250
|
+
absent.backend.findRg = async () => null;
|
|
251
|
+
await withEnabled(true, async () => {
|
|
252
|
+
assert.equal(
|
|
253
|
+
text(await call(makeGrepOverrideWithBackend(dir, absent.backend), { pattern: "x" })),
|
|
254
|
+
"delegated",
|
|
255
|
+
);
|
|
256
|
+
await assert.rejects(
|
|
257
|
+
call(makeGrepOverrideWithBackend(dir, absent.backend), {
|
|
258
|
+
pattern: ["x", "y"],
|
|
259
|
+
matchMode: "all",
|
|
260
|
+
}),
|
|
261
|
+
/ripgrep \(rg\) not found/,
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const file = join(dir, "a.ts");
|
|
266
|
+
await writeFile(file, "x y\n");
|
|
267
|
+
const disabled = fakeBackend({ lines: [rgMatch(file, 1, "x y\n")] });
|
|
268
|
+
await withEnabled(false, async () => {
|
|
269
|
+
assert.equal(
|
|
270
|
+
text(await call(makeGrepOverrideWithBackend(dir, disabled.backend), { pattern: "x" })),
|
|
271
|
+
"delegated",
|
|
272
|
+
);
|
|
273
|
+
assert.equal(disabled.calls.length, 0);
|
|
274
|
+
const extended = await call(makeGrepOverrideWithBackend(dir, disabled.backend), {
|
|
275
|
+
pattern: ["x", "y"],
|
|
276
|
+
matchMode: "all",
|
|
277
|
+
});
|
|
278
|
+
assert.match(text(extended), /a\.ts:1: x y/);
|
|
279
|
+
assert.doesNotMatch(text(extended), /#[0-9A-Z]+│/);
|
|
280
|
+
assert.equal(disabled.calls.length, 1);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("delegates an already-aborted call and rejects an abort during rg execution", async () => {
|
|
286
|
+
await withDir(async (dir) => {
|
|
287
|
+
const alreadyAborted = fakeBackend();
|
|
288
|
+
const first = new AbortController();
|
|
289
|
+
first.abort();
|
|
290
|
+
assert.equal(
|
|
291
|
+
text(
|
|
292
|
+
await call(
|
|
293
|
+
makeGrepOverrideWithBackend(dir, alreadyAborted.backend),
|
|
294
|
+
{ pattern: ["x", "y"] },
|
|
295
|
+
first.signal,
|
|
296
|
+
),
|
|
297
|
+
),
|
|
298
|
+
"delegated",
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const controller = new AbortController();
|
|
302
|
+
const interrupted = fakeBackend({ onRun: () => controller.abort() });
|
|
303
|
+
await assert.rejects(
|
|
304
|
+
call(
|
|
305
|
+
makeGrepOverrideWithBackend(dir, interrupted.backend),
|
|
306
|
+
{ pattern: ["x", "y"], matchMode: "all" },
|
|
307
|
+
controller.signal,
|
|
308
|
+
),
|
|
309
|
+
/Operation aborted/,
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
});
|
package/src/pi/read-tool.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { readFile } from "node:fs/promises";
|
|
|
15
15
|
import { join, resolve } from "node:path";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { hashFileLines } from "../core/hash.ts";
|
|
18
|
-
import { splitLines } from "../core/lines.ts";
|
|
18
|
+
import { hasFinalNewline, splitLines } from "../core/lines.ts";
|
|
19
19
|
import { getState } from "./state.ts";
|
|
20
20
|
import { parseHashline } from "./render.ts";
|
|
21
21
|
|
|
@@ -60,9 +60,10 @@ function renderReadBody(raw: string, path: string, theme: any): string {
|
|
|
60
60
|
if (lines.length === 0) return "";
|
|
61
61
|
const out: string[] = [];
|
|
62
62
|
|
|
63
|
-
// Header: "<path> · <N> lines" optionally followed by " (from line <offset>)"
|
|
63
|
+
// Header: "<path> · <N> lines", optionally followed by " (from line <offset>)"
|
|
64
|
+
// and/or " · no trailing newline".
|
|
64
65
|
let bodyStart = 0;
|
|
65
|
-
const h = lines[0].match(/^(.+?) · (\d+ lines(?: \(from line \d+\))?)$/);
|
|
66
|
+
const h = lines[0].match(/^(.+?) · (\d+ lines(?: \(from line \d+\))?(?: · no trailing newline)?)$/);
|
|
66
67
|
if (h) {
|
|
67
68
|
out.push(theme.fg("success", h[1]) + theme.fg("dim", ` · ${h[2]}`));
|
|
68
69
|
bodyStart = 1;
|
|
@@ -184,8 +185,12 @@ export function makeReadOverride(cwd: string) {
|
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
const shownFrom = offset > 1 ? ` (from line ${offset})` : "";
|
|
188
|
+
// A file whose last line carries no terminator is a byte-level fact that the
|
|
189
|
+
// numbered rows cannot show; state it in the header, the one line the model
|
|
190
|
+
// never copies into an edit `body`.
|
|
191
|
+
const noFinalNewline = hasFinalNewline(text) ? "" : " · no trailing newline";
|
|
187
192
|
const tail = truncated ? `\n… (truncated at ${MAX_BYTES >> 10}KB; use offset/limit to read more)` : "";
|
|
188
|
-
const header = `${params.path} · ${totalLines} lines${shownFrom}\n`;
|
|
193
|
+
const header = `${params.path} · ${totalLines} lines${shownFrom}${noFinalNewline}\n`;
|
|
189
194
|
const body = rows.join("\n");
|
|
190
195
|
|
|
191
196
|
return {
|