@d3ara1n/pi-hashline-edit 0.5.6 → 0.6.0

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
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.6",
3
+ "version": "0.6.0",
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",
@@ -46,6 +46,31 @@ 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
+
49
74
  test("real rg and line filters honor explicit case settings and Unicode folding", {
50
75
  skip: rgPath === null,
51
76
  }, async () => {
@@ -52,6 +52,7 @@ import { parseHashline } from "./render.ts";
52
52
  const DEFAULT_LIMIT = 100;
53
53
  /** Max chars per result line for display (mirrors pi's truncate.ts; not exported there). */
54
54
  const GREP_MAX_LINE_LENGTH = 500;
55
+ const GREP_CONTEXT_MAX = 20;
55
56
 
56
57
  /** Locate ripgrep: pi's bundled bin first, then PATH. Returns null if not found. */
57
58
  async function findRg(): Promise<string | null> {
@@ -107,6 +108,11 @@ function toArray(v: string | string[] | undefined): string[] {
107
108
  return Array.isArray(v) ? v : [v];
108
109
  }
109
110
 
111
+ function clampContext(context: number | undefined): number {
112
+ if (!context || !Number.isFinite(context) || context < 0) return 0;
113
+ return Math.min(Math.floor(context), GREP_CONTEXT_MAX);
114
+ }
115
+
110
116
  const grepOverrideSchema = Type.Object({
111
117
  pattern: Type.Union([Type.String(), Type.Array(Type.String())], {
112
118
  description:
@@ -134,7 +140,9 @@ const grepOverrideSchema = Type.Object({
134
140
  "Directory or file to search (string or array of paths; default: current directory)",
135
141
  })),
136
142
  glob: Type.Optional(
137
- 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 by glob pattern; pass an array for multiple filters and prefix exclusions with `!`, e.g. ['*.ts', '!**/*.test.ts']",
145
+ }),
138
146
  ),
139
147
  ignoreCase: Type.Optional(
140
148
  Type.Boolean({ description: "Case-insensitive search (default: false); applies to pattern and excludePattern." }),
@@ -145,9 +153,10 @@ const grepOverrideSchema = Type.Object({
145
153
  }),
146
154
  ),
147
155
  context: Type.Optional(
148
- Type.Number({
149
- description:
150
- "Number of lines to show before and after each match (default: 0); context lines are anchored too",
156
+ Type.Integer({
157
+ minimum: 0,
158
+ maximum: GREP_CONTEXT_MAX,
159
+ description: `Number of lines on each side of a match (0-${GREP_CONTEXT_MAX}; default: 0); context lines are anchored too`,
151
160
  }),
152
161
  ),
153
162
  limit: Type.Optional(
@@ -333,7 +342,7 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
333
342
  text += theme.fg("toolOutput", ` -v:${ex}`);
334
343
  }
335
344
  if (args?.wordMatch) text += theme.fg("toolOutput", " -w");
336
- if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
345
+ if (args?.glob) text += theme.fg("toolOutput", ` (${toArray(args.glob).join(", ")})`);
337
346
  if (args?.outputMode && args.outputMode !== "content")
338
347
  text += theme.fg("success", ` → ${args.outputMode}`);
339
348
  if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
@@ -365,8 +374,10 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
365
374
  onUpdate: any,
366
375
  ): Promise<any> {
367
376
  const state = getState();
377
+ const ctx = clampContext(params.context);
378
+ const delegatedParams = params.context === undefined ? params : { ...params, context: ctx };
368
379
  // aborted → built-in grep (it handles abort itself)
369
- if (signal?.aborted) return backend.delegate(toolCallId, params, signal, onUpdate);
380
+ if (signal?.aborted) return backend.delegate(toolCallId, delegatedParams, signal, onUpdate);
370
381
 
371
382
  const patterns = toArray(params.pattern);
372
383
  if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
@@ -379,12 +390,13 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
379
390
  params.excludePattern === undefined &&
380
391
  params.outputMode === undefined &&
381
392
  params.wordMatch === undefined &&
393
+ !Array.isArray(params.glob) &&
382
394
  !Array.isArray(params.path);
383
395
 
384
396
  const rgPath = await backend.findRg();
385
397
  // ripgrep unavailable → built-in (it can auto-download rg), but only for plain params
386
398
  if (!rgPath) {
387
- if (legacyShaped) return backend.delegate(toolCallId, params, signal, onUpdate);
399
+ if (legacyShaped) return backend.delegate(toolCallId, delegatedParams, signal, onUpdate);
388
400
  throw new Error(
389
401
  "ripgrep (rg) not found; extended grep params cannot fall back to the built-in grep. Retry with a simple pattern first, or use bash",
390
402
  );
@@ -393,8 +405,8 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
393
405
  const excludes = toArray(params.excludePattern);
394
406
  const matchMode: "any" | "all" = params.matchMode ?? "any";
395
407
  const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
396
- const { glob, ignoreCase, literal, wordMatch, context, limit } = params;
397
- const ctx = context && context > 0 ? context : 0;
408
+ const globs = toArray(params.glob);
409
+ const { ignoreCase, literal, wordMatch, limit } = params;
398
410
  const searchPaths = (() => {
399
411
  const raw = toArray(params.path);
400
412
  return (raw.length ? raw : ["."]).map((p) => canonicalPath(cwd, p));
@@ -438,7 +450,7 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
438
450
  if (ignoreCase) args.push("--ignore-case");
439
451
  if (literal) args.push("--fixed-strings");
440
452
  if (wordMatch) args.push("--word-regexp");
441
- if (glob) args.push("--glob", glob);
453
+ for (const glob of globs) args.push("--glob", glob);
442
454
  for (const p of patterns) args.push("-e", p);
443
455
  args.push("--", ...searchPaths);
444
456
 
@@ -163,11 +163,16 @@ test("applies all, exclude, context, and CRLF filtering after rg output", async
163
163
  ],
164
164
  });
165
165
 
166
- const result = await call(makeGrepOverrideWithBackend(dir, fake.backend), {
166
+ const tool = makeGrepOverrideWithBackend(dir, fake.backend);
167
+ const contextSchema: any = tool.parameters.properties.context;
168
+ assert.equal(contextSchema.type, "integer");
169
+ assert.equal(contextSchema.minimum, 0);
170
+ assert.equal(contextSchema.maximum, 20);
171
+ const result = await call(tool, {
167
172
  pattern: ["alpha", "beta$"],
168
173
  matchMode: "all",
169
174
  excludePattern: "drop",
170
- context: 1,
175
+ context: 1.9,
171
176
  });
172
177
  assert.equal(
173
178
  text(result),
@@ -182,6 +187,29 @@ test("applies all, exclude, context, and CRLF filtering after rg output", async
182
187
  );
183
188
  });
184
189
 
190
+ test("context bounds apply to each side of a match, including direct runtime calls", async () => {
191
+ await withDir(async (dir) => {
192
+ const file = join(dir, "a.ts");
193
+ const lines = Array.from({ length: 45 }, (_, i) => i === 22 ? "needle" : `line ${i + 1}`);
194
+ await writeFile(file, lines.join("\n"));
195
+ const fake = fakeBackend({ lines: [rgMatch(file, 23, "needle\n")] });
196
+ const tool = makeGrepOverrideWithBackend(dir, fake.backend);
197
+
198
+ for (const context of [20, 1_000]) {
199
+ const output = text(await call(tool, { pattern: "needle", context })).split("\n");
200
+ assert.equal(output.length, 42);
201
+ assert.match(output[1], /^3#[0-9A-Z]+│line 3$/);
202
+ assert.match(output[21], /^23#[0-9A-Z]+│needle$/);
203
+ assert.match(output[41], /^43#[0-9A-Z]+│line 43$/);
204
+ }
205
+ for (const context of [-1, Number.NaN, Number.POSITIVE_INFINITY]) {
206
+ const output = text(await call(tool, { pattern: "needle", context })).split("\n");
207
+ assert.equal(output.length, 2);
208
+ assert.match(output[1], /^23#[0-9A-Z]+│needle$/);
209
+ }
210
+ });
211
+ });
212
+
185
213
  test("passes output flags and formats files and counts", async () => {
186
214
  await withDir(async (dir) =>
187
215
  withEnabled(true, async () => {
@@ -191,10 +219,13 @@ test("passes output flags and formats files and counts", async () => {
191
219
  await writeFile(b, "foo a.b\n");
192
220
  const fake = fakeBackend({ lines: [rgMatch(a, 1, "Foo a.b\n"), rgMatch(b, 1, "foo a.b\n")] });
193
221
 
194
- const files = await call(makeGrepOverrideWithBackend(dir, fake.backend), {
222
+ const tool = makeGrepOverrideWithBackend(dir, fake.backend);
223
+ const globSchema: any = tool.parameters.properties.glob;
224
+ assert.deepEqual(globSchema.anyOf.map((option: any) => option.type), ["string", "array"]);
225
+ const files = await call(tool, {
195
226
  pattern: ["Foo", "a.b"],
196
227
  path: ["a.ts", "b.ts"],
197
- glob: "*.ts",
228
+ glob: ["*.ts", "!**/*.test.ts"],
198
229
  ignoreCase: true,
199
230
  literal: true,
200
231
  wordMatch: true,
@@ -211,6 +242,8 @@ test("passes output flags and formats files and counts", async () => {
211
242
  "--word-regexp",
212
243
  "--glob",
213
244
  "*.ts",
245
+ "--glob",
246
+ "!**/*.test.ts",
214
247
  "-e",
215
248
  "Foo",
216
249
  "-e",
@@ -289,6 +322,19 @@ test("delegates only safe fallbacks and rejects extended missing-rg requests", a
289
322
  text(await call(makeGrepOverrideWithBackend(dir, absent.backend), { pattern: "x" })),
290
323
  "delegated",
291
324
  );
325
+ assert.deepEqual(absent.delegates.at(-1)?.[1], { pattern: "x" });
326
+ const tool = makeGrepOverrideWithBackend(dir, absent.backend);
327
+ for (const [context, expected] of [
328
+ [1_000, 20],
329
+ [1.9, 1],
330
+ [-1, 0],
331
+ [Number.POSITIVE_INFINITY, 0],
332
+ ]) {
333
+ const params = { pattern: "x", context };
334
+ await call(tool, params);
335
+ assert.equal(absent.delegates.at(-1)?.[1].context, expected);
336
+ assert.equal(params.context, context);
337
+ }
292
338
  await assert.rejects(
293
339
  call(makeGrepOverrideWithBackend(dir, absent.backend), {
294
340
  pattern: ["x", "y"],
@@ -296,6 +342,12 @@ test("delegates only safe fallbacks and rejects extended missing-rg requests", a
296
342
  }),
297
343
  /ripgrep \(rg\) not found/,
298
344
  );
345
+ await call(tool, { pattern: "x", glob: "*.ts" });
346
+ assert.deepEqual(absent.delegates.at(-1)?.[1], { pattern: "x", glob: "*.ts" });
347
+ await assert.rejects(
348
+ call(tool, { pattern: "x", glob: ["*.ts", "!**/*.test.ts"] }),
349
+ /ripgrep \(rg\) not found/,
350
+ );
299
351
  });
300
352
  });
301
353
  });