@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 +1 -0
- package/package.json +1 -1
- package/src/integration/grep-rg.test.ts +25 -0
- package/src/pi/grep-tool.ts +22 -10
- package/src/pi/grep.test.ts +56 -4
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.
|
|
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 () => {
|
package/src/pi/grep-tool.ts
CHANGED
|
@@ -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(
|
|
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.
|
|
149
|
-
|
|
150
|
-
|
|
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,
|
|
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,
|
|
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
|
|
397
|
-
const
|
|
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
|
-
|
|
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
|
|
package/src/pi/grep.test.ts
CHANGED
|
@@ -163,11 +163,16 @@ test("applies all, exclude, context, and CRLF filtering after rg output", async
|
|
|
163
163
|
],
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
-
const
|
|
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
|
|
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
|
});
|