@d3ara1n/pi-hashline-edit 0.5.7 → 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,6 +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. It also filters explicitly named files; this requires a locally available ripgrep binary.
168
169
 
169
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`.
170
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-hashline-edit",
3
- "version": "0.5.7",
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";
@@ -46,6 +46,73 @@ test("real rg emits anchored matches from a temporary directory", {
46
46
  }
47
47
  });
48
48
 
49
+ test("real rg combines include and exclude globs in order", {
50
+ skip: rgPath === null,
51
+ }, async () => {
52
+ const directory = await mkdtemp(join(tmpdir(), "hl-grep-globs-"));
53
+ try {
54
+ for (const name of ["first.ts", "second.ts", "first.test.ts", "notes.md", "notes.txt"]) {
55
+ await writeFile(join(directory, name), "needle\n");
56
+ }
57
+ const tool = makeGrepOverrideWithBackend(directory, {
58
+ findRg: async () => rgPath,
59
+ delegate: async () => {
60
+ throw new Error("integration test must not invoke the built-in grep delegate");
61
+ },
62
+ });
63
+ const result: any = await tool.execute("0", {
64
+ pattern: "needle",
65
+ glob: ["*.ts", "*.md", "!**/*.test.ts"],
66
+ outputMode: "files",
67
+ }, undefined, undefined);
68
+ assert.deepEqual(result.content[0].text.split("\n").sort(), ["first.ts", "notes.md", "second.ts"]);
69
+ } finally {
70
+ await rm(directory, { recursive: true, force: true });
71
+ }
72
+ });
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
+
49
116
  test("real rg and line filters honor explicit case settings and Unicode folding", {
50
117
  skip: rgPath === null,
51
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";
@@ -140,7 +140,9 @@ const grepOverrideSchema = Type.Object({
140
140
  "Directory or file to search (string or array of paths; default: current directory)",
141
141
  })),
142
142
  glob: Type.Optional(
143
- Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" }),
143
+ Type.Union([Type.String(), Type.Array(Type.String())], {
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
+ }),
144
146
  ),
145
147
  ignoreCase: Type.Optional(
146
148
  Type.Boolean({ description: "Case-insensitive search (default: false); applies to pattern and excludePattern." }),
@@ -176,6 +178,12 @@ interface RgRunResult {
176
178
  /** @internal — injectable process and fallback boundary for deterministic tests. */
177
179
  export interface GrepBackend {
178
180
  findRg(): Promise<string | null>;
181
+ listFiles(
182
+ rgPath: string,
183
+ directories: string[],
184
+ globs: string[],
185
+ signal: AbortSignal | undefined,
186
+ ): Promise<Set<string>>;
179
187
  runRg(
180
188
  rgPath: string,
181
189
  args: string[],
@@ -190,6 +198,43 @@ export interface GrepBackend {
190
198
  ): Promise<any>;
191
199
  }
192
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
+
193
238
  /** Run ripgrep and stream its JSON lines to the caller until it asks to stop. */
194
239
  function runRg(
195
240
  rgPath: string,
@@ -256,6 +301,10 @@ function countLeading(s: string): number {
256
301
  function toDisplayLines(raw: string, theme: any): string[] {
257
302
  const out: string[] = [];
258
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
+ );
259
308
  let i = 0;
260
309
  while (i < lines.length) {
261
310
  const line = lines[i];
@@ -276,7 +325,7 @@ function toDisplayLines(raw: string, theme: any): string[] {
276
325
  const marker = base > 0 ? theme.fg("dim", "›") + " " : "";
277
326
  for (const g of group) {
278
327
  const body = g.content.slice(base);
279
- 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));
280
329
  }
281
330
  i = j;
282
331
  continue;
@@ -298,6 +347,7 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
298
347
  let builtin: ReturnType<typeof createGrepTool> | undefined;
299
348
  const backend: GrepBackend = {
300
349
  findRg,
350
+ listFiles: listRgFiles,
301
351
  runRg,
302
352
  delegate(toolCallId, params, signal, onUpdate) {
303
353
  builtin ??= createGrepTool(cwd);
@@ -340,7 +390,7 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
340
390
  text += theme.fg("toolOutput", ` -v:${ex}`);
341
391
  }
342
392
  if (args?.wordMatch) text += theme.fg("toolOutput", " -w");
343
- if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
393
+ if (args?.glob) text += theme.fg("toolOutput", ` (${toArray(args.glob).join(", ")})`);
344
394
  if (args?.outputMode && args.outputMode !== "content")
345
395
  text += theme.fg("success", ` → ${args.outputMode}`);
346
396
  if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
@@ -388,12 +438,22 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
388
438
  params.excludePattern === undefined &&
389
439
  params.outputMode === undefined &&
390
440
  params.wordMatch === undefined &&
441
+ !Array.isArray(params.glob) &&
391
442
  !Array.isArray(params.path);
392
443
 
444
+ const globs = toArray(params.glob);
393
445
  const rgPath = await backend.findRg();
394
- // 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.
395
447
  if (!rgPath) {
396
- 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
+ }
397
457
  throw new Error(
398
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",
399
459
  );
@@ -402,22 +462,43 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
402
462
  const excludes = toArray(params.excludePattern);
403
463
  const matchMode: "any" | "all" = params.matchMode ?? "any";
404
464
  const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
405
- const { glob, ignoreCase, literal, wordMatch, limit } = params;
465
+ const { ignoreCase, literal, wordMatch, limit } = params;
406
466
  const searchPaths = (() => {
407
467
  const raw = toArray(params.path);
408
468
  return (raw.length ? raw : ["."]).map((p) => canonicalPath(cwd, p));
409
469
  })();
410
470
  const hashLen = state.config.hashLen;
411
471
 
412
- // Verify search paths upfront so a typo fails fast with a clear error
413
- // (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>();
414
475
  for (const sp of searchPaths) {
476
+ let info;
415
477
  try {
416
- await stat(sp);
478
+ info = await stat(sp);
417
479
  } catch {
418
480
  throw new Error(`Path not found: ${sp}`);
419
481
  }
482
+ const isFile = info.isFile();
483
+ pathInfo.push({ path: sp, isFile });
484
+ if (globs.length && isFile) parents.add(dirname(sp));
420
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
+ };
421
502
 
422
503
  // Client-side line filters — only AND / exclude need them; "any" is native rg (-e OR).
423
504
  const excludeMatchers = excludes.map((p) =>
@@ -446,9 +527,9 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
446
527
  if (ignoreCase) args.push("--ignore-case");
447
528
  if (literal) args.push("--fixed-strings");
448
529
  if (wordMatch) args.push("--word-regexp");
449
- if (glob) args.push("--glob", glob);
530
+ for (const glob of globs) args.push("--glob", glob);
450
531
  for (const p of patterns) args.push("-e", p);
451
- args.push("--", ...searchPaths);
532
+ args.push("--", ...selectedPaths);
452
533
 
453
534
  const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
454
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,12 +244,15 @@ 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
- const files = await call(makeGrepOverrideWithBackend(dir, fake.backend), {
249
+ const tool = makeGrepOverrideWithBackend(dir, fake.backend);
250
+ const globSchema: any = tool.parameters.properties.glob;
251
+ assert.deepEqual(globSchema.anyOf.map((option: any) => option.type), ["string", "array"]);
252
+ const files = await call(tool, {
223
253
  pattern: ["Foo", "a.b"],
224
254
  path: ["a.ts", "b.ts"],
225
- glob: "*.ts",
255
+ glob: ["*.ts", "!**/*.test.ts"],
226
256
  ignoreCase: true,
227
257
  literal: true,
228
258
  wordMatch: true,
@@ -239,6 +269,8 @@ test("passes output flags and formats files and counts", async () => {
239
269
  "--word-regexp",
240
270
  "--glob",
241
271
  "*.ts",
272
+ "--glob",
273
+ "!**/*.test.ts",
242
274
  "-e",
243
275
  "Foo",
244
276
  "-e",
@@ -257,6 +289,30 @@ test("passes output flags and formats files and counts", async () => {
257
289
  );
258
290
  });
259
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
+
260
316
  test("counts only surviving matches toward the limit and stops the fake runner", async () => {
261
317
  await withDir(async (dir) =>
262
318
  withEnabled(true, async () => {
@@ -337,6 +393,22 @@ test("delegates only safe fallbacks and rejects extended missing-rg requests", a
337
393
  }),
338
394
  /ripgrep \(rg\) not found/,
339
395
  );
396
+ await call(tool, { pattern: "x", glob: "*.ts" });
397
+ assert.deepEqual(absent.delegates.at(-1)?.[1], { pattern: "x", glob: "*.ts" });
398
+ await assert.rejects(
399
+ call(tool, { pattern: "x", glob: ["*.ts", "!**/*.test.ts"] }),
400
+ /ripgrep \(rg\) not found/,
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");
340
412
  });
341
413
  });
342
414
  });