@d3ara1n/pi-hashline-edit 0.6.0 → 0.6.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 CHANGED
@@ -165,7 +165,7 @@ The `grep` override also covers the compound queries that otherwise push models
165
165
  - `wordMatch` — whole words only (`rg -w`)
166
166
  - `outputMode: "files"` / `"count"` — just the file paths (`rg -l`) or per-file counts + total (`grep -c`); `"files"` output pastes straight back as a `path` array
167
167
  - `pattern` and `path` accept arrays — several patterns combined per `matchMode`, several search roots in one call
168
- - `glob` accepts one pattern or an ordered array of ripgrep globs, e.g. `["*.ts", "*.md", "!**/*.test.ts"]` to include TypeScript and Markdown files but exclude tests
168
+ - `glob` accepts one pattern or an ordered array of ripgrep globs, e.g. `["*.ts", "*.md", "!**/*.test.ts"]` to include TypeScript and Markdown files but exclude tests. It also filters explicitly named files; this requires a locally available ripgrep binary.
169
169
 
170
170
  Filters run before the match limit counts, and context windows are rebuilt from surviving matches, so `limit` and `context` compose cleanly with `matchMode`/`excludePattern`.
171
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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",
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { test } from "node:test";
6
6
  import assert from "node:assert/strict";
7
- import { access, constants, mkdtemp, rm, writeFile } from "node:fs/promises";
7
+ import { access, constants, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
8
8
  import { tmpdir } from "node:os";
9
9
  import { delimiter, join } from "node:path";
10
10
  import { makeGrepOverrideWithBackend } from "../pi/grep-tool.ts";
@@ -71,6 +71,48 @@ test("real rg combines include and exclude globs in order", {
71
71
  }
72
72
  });
73
73
 
74
+ test("real rg applies globs to explicit files and mixed search paths", {
75
+ skip: rgPath === null,
76
+ }, async () => {
77
+ const directory = await mkdtemp(join(tmpdir(), "hl-grep-explicit-globs-"));
78
+ try {
79
+ await mkdir(join(directory, "sub"));
80
+ await writeFile(join(directory, ".gitignore"), "ignored.ts\n");
81
+ for (const name of ["keep.ts", "drop.test.ts", "ignored.ts", "sub/child.ts"]) {
82
+ await writeFile(join(directory, name), "needle\n");
83
+ }
84
+ const tool = makeGrepOverrideWithBackend(directory, {
85
+ findRg: async () => rgPath,
86
+ delegate: async () => {
87
+ throw new Error("integration test must not invoke the built-in grep delegate");
88
+ },
89
+ });
90
+ const search = async (path: string | string[], glob: string | string[]) => {
91
+ const result: any = await tool.execute("0", { pattern: "needle", path, glob, outputMode: "files" }, undefined, undefined);
92
+ return result.content[0].text;
93
+ };
94
+ const globs = ["*.ts", "!**/*.test.ts"];
95
+ assert.deepEqual(
96
+ (await search(["drop.test.ts", "keep.ts", "ignored.ts"], globs)).split("\n").sort(),
97
+ ["ignored.ts", "keep.ts"],
98
+ );
99
+ assert.equal(await search("drop.test.ts", "*.ts"), "drop.test.ts");
100
+ assert.equal(await search("drop.test.ts", globs), "No matches found");
101
+ assert.equal(await search(["drop.test.ts", "keep.ts"], "!**/*.test.ts"), "keep.ts");
102
+ assert.equal(await search("sub/child.ts", "**/sub/*.ts"), "sub/child.ts");
103
+ assert.deepEqual(
104
+ (await search(["keep.ts", "sub/child.ts"], globs)).split("\n").sort(),
105
+ ["keep.ts", "sub/child.ts"],
106
+ );
107
+ assert.deepEqual(
108
+ (await search(["drop.test.ts", "sub", "keep.ts"], globs)).split("\n").sort(),
109
+ ["keep.ts", "sub/child.ts"],
110
+ );
111
+ } finally {
112
+ await rm(directory, { recursive: true, force: true });
113
+ }
114
+ });
115
+
74
116
  test("real rg and line filters honor explicit case settings and Unicode folding", {
75
117
  skip: rgPath === null,
76
118
  }, async () => {
@@ -42,7 +42,7 @@ import { Text } from "@earendil-works/pi-tui";
42
42
  import { spawn } from "node:child_process";
43
43
  import { createInterface } from "node:readline";
44
44
  import { access, constants, readFile, stat } from "node:fs/promises";
45
- import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
45
+ import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
46
46
  import { hashFileLines } from "../core/hash.ts";
47
47
  import { splitLines } from "../core/lines.ts";
48
48
  import { getState } from "./state.ts";
@@ -141,7 +141,7 @@ const grepOverrideSchema = Type.Object({
141
141
  })),
142
142
  glob: Type.Optional(
143
143
  Type.Union([Type.String(), Type.Array(Type.String())], {
144
- description: "Filter files by glob pattern; pass an array for multiple filters and prefix exclusions with `!`, e.g. ['*.ts', '!**/*.test.ts']",
144
+ description: "Filter files (including explicit file paths) by glob; pass an ordered array for multiple filters and prefix exclusions with `!`, e.g. ['*.ts', '!**/*.test.ts']",
145
145
  }),
146
146
  ),
147
147
  ignoreCase: Type.Optional(
@@ -178,6 +178,12 @@ interface RgRunResult {
178
178
  /** @internal — injectable process and fallback boundary for deterministic tests. */
179
179
  export interface GrepBackend {
180
180
  findRg(): Promise<string | null>;
181
+ listFiles(
182
+ rgPath: string,
183
+ directories: string[],
184
+ globs: string[],
185
+ signal: AbortSignal | undefined,
186
+ ): Promise<Set<string>>;
181
187
  runRg(
182
188
  rgPath: string,
183
189
  args: string[],
@@ -192,6 +198,43 @@ export interface GrepBackend {
192
198
  ): Promise<any>;
193
199
  }
194
200
 
201
+ /** List direct children using rg's own ordered glob rules (including ignored files). */
202
+ function listRgFiles(
203
+ rgPath: string,
204
+ directories: string[],
205
+ globs: string[],
206
+ signal: AbortSignal | undefined,
207
+ ): Promise<Set<string>> {
208
+ return new Promise((resolveFiles, reject) => {
209
+ if (signal?.aborted) return reject(new Error("Operation aborted"));
210
+ const args = ["--files", "--null", "--hidden", "--no-ignore", "--follow", "--max-depth=1"];
211
+ for (const glob of globs) args.push("--glob", glob);
212
+ args.push("--", ...directories);
213
+ const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
214
+ const chunks: Buffer[] = [];
215
+ let stderr = "";
216
+ const onAbort = () => child.kill();
217
+ signal?.addEventListener("abort", onAbort, { once: true });
218
+ child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk));
219
+ child.stderr.on("data", (chunk: Buffer) => {
220
+ stderr += chunk.toString();
221
+ });
222
+ child.on("error", (error) => {
223
+ signal?.removeEventListener("abort", onAbort);
224
+ reject(new Error(`Failed to run ripgrep: ${error.message}`));
225
+ });
226
+ child.on("close", (code) => {
227
+ signal?.removeEventListener("abort", onAbort);
228
+ if (signal?.aborted) return reject(new Error("Operation aborted"));
229
+ if (code !== 0 && code !== 1) {
230
+ return reject(new Error(stderr.trim() || `ripgrep exited with code ${code}`));
231
+ }
232
+ const paths = Buffer.concat(chunks).toString("utf-8").split("\0").filter(Boolean);
233
+ resolveFiles(new Set(paths));
234
+ });
235
+ });
236
+ }
237
+
195
238
  /** Run ripgrep and stream its JSON lines to the caller until it asks to stop. */
196
239
  function runRg(
197
240
  rgPath: string,
@@ -258,6 +301,10 @@ function countLeading(s: string): number {
258
301
  function toDisplayLines(raw: string, theme: any): string[] {
259
302
  const out: string[] = [];
260
303
  const lines = raw.split("\n");
304
+ const lineNoWidth = lines.reduce(
305
+ (width, line) => Math.max(width, parseHashline(line)?.lineNo.length ?? 0),
306
+ 0,
307
+ );
261
308
  let i = 0;
262
309
  while (i < lines.length) {
263
310
  const line = lines[i];
@@ -278,7 +325,7 @@ function toDisplayLines(raw: string, theme: any): string[] {
278
325
  const marker = base > 0 ? theme.fg("dim", "›") + " " : "";
279
326
  for (const g of group) {
280
327
  const body = g.content.slice(base);
281
- out.push(theme.fg("dim", ` ${g.lineNo}: `) + marker + theme.fg("toolOutput", body));
328
+ out.push(theme.fg("dim", ` ${g.lineNo.padStart(lineNoWidth)}: `) + marker + theme.fg("toolOutput", body));
282
329
  }
283
330
  i = j;
284
331
  continue;
@@ -300,6 +347,7 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
300
347
  let builtin: ReturnType<typeof createGrepTool> | undefined;
301
348
  const backend: GrepBackend = {
302
349
  findRg,
350
+ listFiles: listRgFiles,
303
351
  runRg,
304
352
  delegate(toolCallId, params, signal, onUpdate) {
305
353
  builtin ??= createGrepTool(cwd);
@@ -393,10 +441,19 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
393
441
  !Array.isArray(params.glob) &&
394
442
  !Array.isArray(params.path);
395
443
 
444
+ const globs = toArray(params.glob);
396
445
  const rgPath = await backend.findRg();
397
- // ripgrep unavailable → built-in (it can auto-download rg), but only for plain params
446
+ // The built-in grep also lets explicit files bypass globs.
398
447
  if (!rgPath) {
399
- if (legacyShaped) return backend.delegate(toolCallId, delegatedParams, signal, onUpdate);
448
+ if (legacyShaped) {
449
+ if (params.glob !== undefined && params.path !== undefined) {
450
+ const path = canonicalPath(cwd, params.path);
451
+ let explicitFile = false;
452
+ try { explicitFile = (await stat(path)).isFile(); } catch {}
453
+ if (explicitFile) throw new Error("ripgrep (rg) not found; cannot apply glob to an explicit file");
454
+ }
455
+ return backend.delegate(toolCallId, delegatedParams, signal, onUpdate);
456
+ }
400
457
  throw new Error(
401
458
  "ripgrep (rg) not found; extended grep params cannot fall back to the built-in grep. Retry with a simple pattern first, or use bash",
402
459
  );
@@ -405,7 +462,6 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
405
462
  const excludes = toArray(params.excludePattern);
406
463
  const matchMode: "any" | "all" = params.matchMode ?? "any";
407
464
  const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
408
- const globs = toArray(params.glob);
409
465
  const { ignoreCase, literal, wordMatch, limit } = params;
410
466
  const searchPaths = (() => {
411
467
  const raw = toArray(params.path);
@@ -413,15 +469,36 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
413
469
  })();
414
470
  const hashLen = state.config.hashLen;
415
471
 
416
- // Verify search paths upfront so a typo fails fast with a clear error
417
- // (rg's own diagnostics are less actionable).
472
+ // rg ignores globs for explicit files, so select those with its file walker first.
473
+ const pathInfo: { path: string; isFile: boolean }[] = [];
474
+ const parents = new Set<string>();
418
475
  for (const sp of searchPaths) {
476
+ let info;
419
477
  try {
420
- await stat(sp);
478
+ info = await stat(sp);
421
479
  } catch {
422
480
  throw new Error(`Path not found: ${sp}`);
423
481
  }
482
+ const isFile = info.isFile();
483
+ pathInfo.push({ path: sp, isFile });
484
+ if (globs.length && isFile) parents.add(dirname(sp));
424
485
  }
486
+ const fileKey = (path: string) => {
487
+ const absolute = resolve(cwd, path);
488
+ return process.platform === "win32" ? absolute.toLowerCase() : absolute;
489
+ };
490
+ const listed = parents.size
491
+ ? await backend.listFiles(rgPath, [...parents], globs, signal)
492
+ : new Set<string>();
493
+ const allowed = new Set([...listed].map(fileKey));
494
+ const selectedPaths = pathInfo
495
+ .filter(({ path, isFile }) => !globs.length || !isFile || allowed.has(fileKey(path)))
496
+ .map(({ path }) => path);
497
+ if (signal?.aborted) throw new Error("Operation aborted");
498
+ if (selectedPaths.length === 0) return {
499
+ content: [{ type: "text", text: "No matches found" }],
500
+ details: undefined,
501
+ };
425
502
 
426
503
  // Client-side line filters — only AND / exclude need them; "any" is native rg (-e OR).
427
504
  const excludeMatchers = excludes.map((p) =>
@@ -452,7 +529,7 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
452
529
  if (wordMatch) args.push("--word-regexp");
453
530
  for (const glob of globs) args.push("--glob", glob);
454
531
  for (const p of patterns) args.push("-e", p);
455
- args.push("--", ...searchPaths);
532
+ args.push("--", ...selectedPaths);
456
533
 
457
534
  const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
458
535
  let matchCount = 0;
@@ -14,6 +14,7 @@ import { getState } from "./state.ts";
14
14
 
15
15
  type FakeOptions = {
16
16
  lines?: string[];
17
+ files?: string[];
17
18
  code?: number | null;
18
19
  stderr?: string;
19
20
  error?: Error;
@@ -38,11 +39,16 @@ function rgMatch(filePath: string, lineNumber: number, text: string): string {
38
39
 
39
40
  function fakeBackend(options: FakeOptions = {}) {
40
41
  const calls: { path: string; args: string[] }[] = [];
42
+ const fileCalls: { directories: string[]; globs: string[] }[] = [];
41
43
  const delegates: any[][] = [];
42
44
  const backend: GrepBackend = {
43
45
  async findRg() {
44
46
  return "/fake/rg";
45
47
  },
48
+ async listFiles(_path, directories, globs) {
49
+ fileCalls.push({ directories, globs });
50
+ return new Set(options.files ?? []);
51
+ },
46
52
  async runRg(path, args, _signal, onLine) {
47
53
  calls.push({ path, args });
48
54
  options.onRun?.();
@@ -61,7 +67,7 @@ function fakeBackend(options: FakeOptions = {}) {
61
67
  return { content: [{ type: "text", text: "delegated" }], details: undefined };
62
68
  },
63
69
  };
64
- return { backend, calls, delegates };
70
+ return { backend, calls, fileCalls, delegates };
65
71
  }
66
72
 
67
73
  const text = (result: any): string => result.content[0].text;
@@ -79,6 +85,27 @@ async function withEnabled<T>(enabled: boolean, fn: () => Promise<T>): Promise<T
79
85
  }
80
86
  }
81
87
 
88
+ test("grep TUI aligns line-number colons across file groups", () => {
89
+ const raw = [
90
+ "a.ts · 2 matches",
91
+ "99#ABCD│ alpha",
92
+ "100#ABCD│ beta",
93
+ "b.ts · 1 match",
94
+ "7#ABCD│gamma",
95
+ ].join("\n");
96
+ const tool = makeGrepOverrideWithBackend(".", {});
97
+ const theme = { fg: (_color: string, value: string) => value };
98
+ const result = { content: [{ type: "text", text: raw }] };
99
+ const rendered = tool.renderResult(result, { isPartial: false, expanded: true }, theme, {}).render(80);
100
+ assert.deepEqual(rendered.map((line) => line.trimEnd()), [
101
+ "a.ts · 2 matches",
102
+ " 99: › alpha",
103
+ " 100: › beta",
104
+ "b.ts · 1 match",
105
+ " 7: gamma",
106
+ ]);
107
+ });
108
+
82
109
  test("formats parsed rg matches with full-line hash anchors", async () => {
83
110
  await withDir(async (dir) =>
84
111
  withEnabled(true, async () => {
@@ -217,7 +244,7 @@ test("passes output flags and formats files and counts", async () => {
217
244
  const b = join(dir, "b.ts");
218
245
  await writeFile(a, "Foo a.b\n");
219
246
  await writeFile(b, "foo a.b\n");
220
- const fake = fakeBackend({ lines: [rgMatch(a, 1, "Foo a.b\n"), rgMatch(b, 1, "foo a.b\n")] });
247
+ const fake = fakeBackend({ files: [a, b], lines: [rgMatch(a, 1, "Foo a.b\n"), rgMatch(b, 1, "foo a.b\n")] });
221
248
 
222
249
  const tool = makeGrepOverrideWithBackend(dir, fake.backend);
223
250
  const globSchema: any = tool.parameters.properties.glob;
@@ -262,6 +289,30 @@ test("passes output flags and formats files and counts", async () => {
262
289
  );
263
290
  });
264
291
 
292
+ test("glob filters explicit files and batches their parent directories", async () => {
293
+ await withDir(async (dir) => {
294
+ const kept = join(dir, "kept.ts");
295
+ const excluded = join(dir, "excluded.test.ts");
296
+ const nested = join(dir, "sub", "other.ts");
297
+ await mkdir(join(dir, "sub"));
298
+ await writeFile(nested, "needle\n");
299
+ await writeFile(kept, "needle\n");
300
+ await writeFile(excluded, "needle\n");
301
+ const fake = fakeBackend({
302
+ files: [kept, nested],
303
+ lines: [rgMatch(kept, 1, "needle\n"), rgMatch(nested, 1, "needle\n")],
304
+ });
305
+ const tool = makeGrepOverrideWithBackend(dir, fake.backend);
306
+ const query = { pattern: "needle", path: ["kept.ts", "excluded.test.ts", "sub/other.ts"], glob: ["*.ts", "!**/*.test.ts"] };
307
+ assert.match(text(await call(tool, query)), /│needle/);
308
+ assert.deepEqual(fake.fileCalls, [{ directories: [dir, join(dir, "sub")], globs: query.glob }]);
309
+ assert.deepEqual(fake.calls[0].args.slice(-3), ["--", kept, nested]);
310
+
311
+ assert.equal(text(await call(tool, { ...query, path: "excluded.test.ts" })), "No matches found");
312
+ assert.equal(fake.calls.length, 1);
313
+ });
314
+ });
315
+
265
316
  test("counts only surviving matches toward the limit and stops the fake runner", async () => {
266
317
  await withDir(async (dir) =>
267
318
  withEnabled(true, async () => {
@@ -348,6 +399,16 @@ test("delegates only safe fallbacks and rejects extended missing-rg requests", a
348
399
  call(tool, { pattern: "x", glob: ["*.ts", "!**/*.test.ts"] }),
349
400
  /ripgrep \(rg\) not found/,
350
401
  );
402
+ const file = join(dir, "fixture.ts");
403
+ await writeFile(file, "x\n");
404
+ const delegatedBefore = absent.delegates.length;
405
+ await assert.rejects(
406
+ call(tool, { pattern: "x", path: file, glob: "*.ts" }),
407
+ /cannot apply glob to an explicit file/,
408
+ );
409
+ assert.equal(absent.delegates.length, delegatedBefore);
410
+ assert.equal(text(await call(tool, { pattern: "x", path: file })), "delegated");
411
+ assert.equal(text(await call(tool, { pattern: "x", path: dir, glob: "*.ts" })), "delegated");
351
412
  });
352
413
  });
353
414
  });