@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.
@@ -1,265 +1,312 @@
1
1
  /**
2
- * grep override execute tests: drive the real makeGrepOverride execute against
3
- * temp dirs anchored content output, boolean combination (matchMode all /
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
- const dir = await mkdtemp(join(tmpdir(), "hl-grep-"));
19
- try {
20
- return await fn(dir);
21
- } finally {
22
- await rm(dir, { recursive: true, force: true });
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
- const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
27
-
28
- const A = "alpha beta\ngamma\nalpha only\nbeta only\nALPHA caps\n";
29
- const B = "alpha here\nnothing\n";
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
- async function seed(dir: string) {
32
- await writeFile(join(dir, "a.ts"), A);
33
- await writeFile(join(dir, "b.ts"), B);
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 = (r: any): string => r.content[0].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
- const state = getState();
41
- const prev = state.config.enabled;
42
- state.config.enabled = enabled;
43
- try {
44
- return await fn();
45
- } finally {
46
- state.config.enabled = prev;
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("content: single pattern groups by file with LINE#HASH anchors", async () => {
51
- await withDir(async (dir) => {
52
- await seed(dir);
53
- const r: any = await call(makeGrepOverride(dir), { pattern: "alpha" });
54
- const out = text(r);
55
- assert.match(out, /a\.ts · 2 matches/);
56
- assert.match(out, /b\.ts · 1 match/);
57
- assert.match(out, /1#[0-9A-Z]+│alpha beta/);
58
- assert.match(out, /3#[0-9A-Z]+│alpha only/);
59
- assert.doesNotMatch(out, /gamma/);
60
- });
61
- });
62
-
63
- test("content: grep anchors match the hash computed from the full line", async () => {
64
- await withDir(async (dir) => {
65
- await seed(dir);
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
- test("outputMode files: one path per line, no line content", async () => {
97
- await withDir(async (dir) => {
98
- await seed(dir);
99
- const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", outputMode: "files" });
100
- assert.equal(text(r), "a.ts\nb.ts");
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("outputMode count: per-file counts + total", async () => {
105
- await withDir(async (dir) => {
106
- await seed(dir);
107
- const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", outputMode: "count" });
108
- assert.equal(text(r), "a.ts: 2\nb.ts: 1\nTotal: 3 matches in 2 files");
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
- test("wordMatch: whole words only", async () => {
113
- await withDir(async (dir) => {
114
- await writeFile(join(dir, "w.ts"), "foobar\nfoo bar\n");
115
- const r: any = await call(makeGrepOverride(dir), { pattern: "foo", wordMatch: true });
116
- const out = text(r);
117
- assert.match(out, /w\.ts · 1 match/);
118
- assert.match(out, /2#[0-9A-Z]+│foo bar/);
119
- assert.doesNotMatch(out, /foobar/);
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("multi-pattern any (default): OR across patterns", async () => {
124
- await withDir(async (dir) => {
125
- await seed(dir);
126
- const r: any = await call(makeGrepOverride(dir), { pattern: ["gamma", "nothing"] });
127
- const out = text(r);
128
- assert.match(out, /a\.ts · 1 match/);
129
- assert.match(out, /2#[0-9A-Z]+│gamma/);
130
- assert.match(out, /b\.ts · 1 match/);
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
- test("path accepts an array of search roots", async () => {
136
- await withDir(async (dir) => {
137
- await seed(dir);
138
- const r: any = await call(makeGrepOverride(dir), { pattern: "beta only", path: ["a.ts", "b.ts"] });
139
- const out = text(r);
140
- assert.match(out, /4#[0-9A-Z]+│beta only/);
141
- assert.match(out, /a\.ts · 1 match/);
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
- test("context: ±N lines around surviving matches, anchored", async () => {
146
- await withDir(async (dir) => {
147
- await writeFile(join(dir, "c.ts"), "l1\nl2\nl3\nl4\nl5\n");
148
- const r: any = await call(makeGrepOverride(dir), { pattern: "l3", context: 1 });
149
- const out = text(r);
150
- assert.match(out, /c\.ts · 1 match/);
151
- const rows = out.split("\n").filter((l) => /│l\d/.test(l));
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("context windows are rebuilt from surviving matches (filtered match leaks no context)", async () => {
160
- await withDir(async (dir) => {
161
- // l4 is a match but excluded; it sits >ctx away from the surviving l1 match,
162
- // so none of its surroundings may appear as context
163
- await writeFile(join(dir, "c.ts"), "target keep\nl2\nl3\ndrop me\nl5\nl6\n");
164
- const r: any = await call(makeGrepOverride(dir), { pattern: "target|drop", excludePattern: "drop", context: 1 });
165
- const out = text(r);
166
- assert.match(out, /c\.ts · 1 match/);
167
- assert.match(out, /1#[0-9A-Z]+│target keep/);
168
- assert.match(out, /2#[0-9A-Z]+│l2/);
169
- assert.doesNotMatch(out, /l3/);
170
- assert.doesNotMatch(out, /drop me/);
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
- test("ignoreCase matches across case variants", async () => {
176
- await withDir(async (dir) => {
177
- await seed(dir);
178
- const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", ignoreCase: true });
179
- assert.match(text(r), /a\.ts · 3 matches/);
180
- assert.match(text(r), /5#[0-9A-Z]+│ALPHA caps/);
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("limit notice suggests doubling", async () => {
185
- await withDir(async (dir) => {
186
- await seed(dir);
187
- const r: any = await call(makeGrepOverride(dir), { pattern: "a", limit: 2 });
188
- assert.match(text(r), /\[2 matches limit reached\. Use limit=4 for more, or refine pattern\]/);
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
- test("filters apply before the limit counts (a filtered match consumes no budget)", async () => {
193
- await withDir(async (dir) => {
194
- await seed(dir);
195
- // a.ts:1 "alpha beta" is excluded; pre-filter counting would burn the whole
196
- // limit=1 budget on it and return nothing — post-filter counting yields a.ts:3
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
- test("disabled + extended params: still runs, formatted without anchors", async () => {
205
- await withDir(async (dir) => {
206
- await seed(dir);
207
- await withEnabled(false, async () => {
208
- const r: any = await call(makeGrepOverride(dir), { pattern: ["alpha", "beta"], matchMode: "all" });
209
- const out = text(r);
210
- assert.match(out, /a\.ts:1: alpha beta/);
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("disabled + plain params: delegates to the built-in grep", async () => {
218
- await withDir(async (dir) => {
219
- await seed(dir);
220
- await withEnabled(false, async () => {
221
- const r: any = await call(makeGrepOverride(dir), { pattern: "alpha beta" });
222
- // built-in format: flat `path:line: content`, no group headers
223
- assert.match(text(r), /a\.ts:1: alpha beta/);
224
- assert.doesNotMatch(text(r), /· 1 match/);
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
- test("CRLF files: display and filters are \r-clean, anchors hash the clean line", async () => {
230
- await withDir(async (dir) => {
231
- await writeFile(join(dir, "crlf.ts"), "alpha beta\r\ngamma\r\nalpha only\r\n");
232
- // anchors must hash the \r-stripped line — same splitLines as read/edit verify against
233
- const hash = computeLineHash(1, "alpha beta");
234
- const g: any = await call(makeGrepOverride(dir), { pattern: "alpha" });
235
- const out = text(g);
236
- assert.match(out, new RegExp(`1#${hash}│alpha beta`));
237
- assert.ok(!/[\r]/.test(out), "no carriage returns in output");
238
- // $-anchored filters must run on the cleaned text: "alpha beta\r" would dodge "beta$"
239
- const g2: any = await call(makeGrepOverride(dir), { pattern: "alpha", excludePattern: "beta$" });
240
- assert.match(text(g2), /alpha only/);
241
- assert.doesNotMatch(text(g2), /alpha beta/);
242
- // same for matchMode:"all" with a $-anchored pattern
243
- const g3: any = await call(makeGrepOverride(dir), { pattern: ["alpha", "only$"], matchMode: "all" });
244
- assert.match(text(g3), /3#[0-9A-Z]+│alpha only/);
245
- assert.doesNotMatch(text(g3), /alpha beta/);
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("literal mode escapes regex metacharacters", async () => {
250
- await withDir(async (dir) => {
251
- await writeFile(join(dir, "d.ts"), "a.b\naxb\n");
252
- const r: any = await call(makeGrepOverride(dir), { pattern: "a.b", literal: true });
253
- const out = text(r);
254
- assert.match(out, /1#[0-9A-Z]+│a\.b/);
255
- assert.doesNotMatch(out, /axb/);
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
- test("no matches reports cleanly", async () => {
260
- await withDir(async (dir) => {
261
- await seed(dir);
262
- const r: any = await call(makeGrepOverride(dir), { pattern: "zzz" });
263
- assert.equal(text(r), "No matches found");
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
- assert.equal(canonicalPath("/cwd", "foo.ts"), "/cwd/foo.ts");
8
- assert.equal(canonicalPath("/cwd", "./foo.ts"), "/cwd/foo.ts");
9
- assert.equal(canonicalPath("/cwd", "/abs/x.ts"), "/abs/x.ts");
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"), `${home}/foo.ts`);
18
+ assert.equal(canonicalPath("/cwd", "~/foo.ts"), join(home, "foo.ts"));
16
19
  });
@@ -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 {