@d3ara1n/pi-hashline-edit 0.5.0 → 0.5.2
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 +79 -11
- 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 +47 -0
- package/src/pi/execute.test.ts +30 -0
- package/src/pi/grep-tool.ts +512 -416
- package/src/pi/grep.test.ts +271 -224
- package/src/pi/pi.test.ts +7 -4
- package/src/pi/read-tool.ts +9 -4
package/src/pi/grep.test.ts
CHANGED
|
@@ -1,265 +1,312 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* grep override
|
|
3
|
-
*
|
|
4
|
-
* excludePattern), output modes (files / count), wordMatch, multi-pattern any,
|
|
5
|
-
* multi-path, context windows, ignoreCase, limit notice, and the disabled-mode
|
|
6
|
-
* fallbacks (plain params delegate, extended params run un-anchored).
|
|
2
|
+
* Deterministic grep override tests. Ripgrep and built-in grep are injected;
|
|
3
|
+
* fixture files live only in a per-test system temporary directory.
|
|
7
4
|
*/
|
|
8
5
|
import { test } from "node:test";
|
|
9
6
|
import assert from "node:assert/strict";
|
|
10
7
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
11
8
|
import { tmpdir } from "node:os";
|
|
12
9
|
import { join } from "node:path";
|
|
13
|
-
import { makeGrepOverride } from "./grep-tool.ts";
|
|
14
|
-
import { getState } from "./state.ts";
|
|
15
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
|
+
};
|
|
16
21
|
|
|
17
22
|
async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
}
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
}
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 };
|
|
34
64
|
}
|
|
35
65
|
|
|
36
|
-
const text = (
|
|
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);
|
|
37
69
|
|
|
38
|
-
/** Run with hashline enabled/disabled, restoring the original config. */
|
|
39
70
|
async function withEnabled<T>(enabled: boolean, fn: () => Promise<T>): Promise<T> {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
}
|
|
48
79
|
}
|
|
49
80
|
|
|
50
|
-
test("
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha beta" });
|
|
67
|
-
const hash = computeLineHash(1, "alpha beta");
|
|
68
|
-
assert.match(text(r), new RegExp(`1#${hash}│alpha beta`));
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("matchMode all: line must match every pattern (grep A | grep B)", async () => {
|
|
73
|
-
await withDir(async (dir) => {
|
|
74
|
-
await seed(dir);
|
|
75
|
-
const r: any = await call(makeGrepOverride(dir), { pattern: ["alpha", "beta"], matchMode: "all" });
|
|
76
|
-
const out = text(r);
|
|
77
|
-
assert.match(out, /a\.ts · 1 match/);
|
|
78
|
-
assert.match(out, /1#[0-9A-Z]+│alpha beta/);
|
|
79
|
-
assert.doesNotMatch(out, /beta only/);
|
|
80
|
-
assert.doesNotMatch(out, /alpha here/);
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("excludePattern drops lines, like grep -v", async () => {
|
|
85
|
-
await withDir(async (dir) => {
|
|
86
|
-
await seed(dir);
|
|
87
|
-
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", excludePattern: "beta" });
|
|
88
|
-
const out = text(r);
|
|
89
|
-
assert.match(out, /a\.ts · 1 match/);
|
|
90
|
-
assert.match(out, /3#[0-9A-Z]+│alpha only/);
|
|
91
|
-
assert.match(out, /b\.ts · 1 match/);
|
|
92
|
-
assert.doesNotMatch(out, /alpha beta/);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
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
|
+
});
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
+
);
|
|
102
112
|
});
|
|
103
113
|
|
|
104
|
-
test("
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
});
|
|
111
129
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
);
|
|
121
147
|
});
|
|
122
148
|
|
|
123
|
-
test("
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
assert.match(out, /2#[0-9A-Z]+│nothing/);
|
|
132
|
-
});
|
|
133
|
-
});
|
|
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")] });
|
|
134
157
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
});
|
|
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
|
+
]);
|
|
144
186
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
assert.deepEqual(
|
|
153
|
-
rows.map((l) => l.replace(/#\w+│/, ":")),
|
|
154
|
-
["2:l2", "3:l3", "4:l4"],
|
|
155
|
-
);
|
|
156
|
-
});
|
|
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
|
+
);
|
|
157
194
|
});
|
|
158
195
|
|
|
159
|
-
test("
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
assert.doesNotMatch(out, /l5/);
|
|
172
|
-
});
|
|
173
|
-
});
|
|
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
|
+
});
|
|
174
208
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
);
|
|
182
221
|
});
|
|
183
222
|
|
|
184
|
-
test("
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
);
|
|
191
231
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", excludePattern: "beta", limit: 1 });
|
|
198
|
-
const out = text(r);
|
|
199
|
-
assert.match(out, /3#[0-9A-Z]+│alpha only/);
|
|
200
|
-
assert.match(out, /1 matches limit reached/);
|
|
201
|
-
});
|
|
202
|
-
});
|
|
232
|
+
const failed = fakeBackend({ code: 2, stderr: "bad regex" });
|
|
233
|
+
await assert.rejects(
|
|
234
|
+
call(makeGrepOverrideWithBackend(dir, failed.backend), { pattern: "[" }),
|
|
235
|
+
/bad regex/,
|
|
236
|
+
);
|
|
203
237
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
assert.doesNotMatch(out, /#[0-9A-Z]+│/);
|
|
212
|
-
assert.doesNotMatch(out, /alpha here/);
|
|
213
|
-
});
|
|
214
|
-
});
|
|
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
|
+
);
|
|
215
245
|
});
|
|
216
246
|
|
|
217
|
-
test("
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
+
});
|
|
228
264
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
});
|
|
247
283
|
});
|
|
248
284
|
|
|
249
|
-
test("
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
);
|
|
258
300
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
});
|
|
265
312
|
});
|
package/src/pi/pi.test.ts
CHANGED
|
@@ -2,15 +2,18 @@ import { test } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { canonicalPath } from "./read-tool.ts";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
5
6
|
|
|
6
7
|
test("canonicalPath resolves relative and absolute", () => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
assert.equal(canonicalPath(
|
|
8
|
+
const cwd = resolve("/cwd");
|
|
9
|
+
const absolute = resolve("/abs/x.ts");
|
|
10
|
+
assert.equal(canonicalPath(cwd, "foo.ts"), join(cwd, "foo.ts"));
|
|
11
|
+
assert.equal(canonicalPath(cwd, "./foo.ts"), join(cwd, "foo.ts"));
|
|
12
|
+
assert.equal(canonicalPath(cwd, absolute), absolute);
|
|
10
13
|
});
|
|
11
14
|
|
|
12
15
|
test("canonicalPath expands ~ to home directory", () => {
|
|
13
16
|
const home = homedir();
|
|
14
17
|
assert.equal(canonicalPath("/cwd", "~"), home);
|
|
15
|
-
assert.equal(canonicalPath("/cwd", "~/foo.ts"),
|
|
18
|
+
assert.equal(canonicalPath("/cwd", "~/foo.ts"), join(home, "foo.ts"));
|
|
16
19
|
});
|
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 {
|