@d3ara1n/pi-hashline-edit 0.5.4 → 0.5.6
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/package.json +1 -1
- package/src/integration/grep-rg.test.ts +105 -0
- package/src/pi/grep-tool.ts +17 -20
- package/src/pi/grep.test.ts +60 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-hashline-edit",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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",
|
|
@@ -45,3 +45,108 @@ test("real rg emits anchored matches from a temporary directory", {
|
|
|
45
45
|
await rm(directory, { recursive: true, force: true });
|
|
46
46
|
}
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
test("real rg and line filters honor explicit case settings and Unicode folding", {
|
|
50
|
+
skip: rgPath === null,
|
|
51
|
+
}, async () => {
|
|
52
|
+
const directory = await mkdtemp(join(tmpdir(), "hl-grep-case-"));
|
|
53
|
+
try {
|
|
54
|
+
await writeFile(join(directory, "fixture.ts"), "FOO abc\nfoo BAR\nfoo bar\nK zip\nk zip\n");
|
|
55
|
+
const tool = makeGrepOverrideWithBackend(directory, {
|
|
56
|
+
findRg: async () => rgPath,
|
|
57
|
+
delegate: async () => {
|
|
58
|
+
throw new Error("integration test must not invoke the built-in grep delegate");
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
for (const ignoreCase of [undefined, false, true]) {
|
|
62
|
+
const expectedCount = ignoreCase === true ? 3 : 2;
|
|
63
|
+
for (const query of [
|
|
64
|
+
{ pattern: "foo\\S*" },
|
|
65
|
+
{ pattern: ["foo\\S*", "\\S+"], matchMode: "all" },
|
|
66
|
+
]) {
|
|
67
|
+
const result: any = await tool.execute("0", { ...query, ignoreCase }, undefined, undefined);
|
|
68
|
+
assert.match(result.content[0].text, new RegExp(`fixture\\.ts · ${expectedCount} matches`));
|
|
69
|
+
assert.equal(result.content[0].text.includes("FOO abc"), ignoreCase === true);
|
|
70
|
+
}
|
|
71
|
+
const excluded: any = await tool.execute("0", {
|
|
72
|
+
pattern: "foo\\S*", excludePattern: "bar", ignoreCase,
|
|
73
|
+
}, undefined, undefined);
|
|
74
|
+
assert.match(excluded.content[0].text, /fixture\.ts · 1 match/);
|
|
75
|
+
assert.ok(excluded.content[0].text.includes(ignoreCase === true ? "FOO abc" : "foo BAR"));
|
|
76
|
+
}
|
|
77
|
+
const unicodeAll: any = await tool.execute("0", {
|
|
78
|
+
pattern: ["k", "zip"], matchMode: "all", ignoreCase: true,
|
|
79
|
+
}, undefined, undefined);
|
|
80
|
+
assert.match(unicodeAll.content[0].text, /fixture\.ts · 2 matches/);
|
|
81
|
+
assert.ok(unicodeAll.content[0].text.includes("K zip"));
|
|
82
|
+
|
|
83
|
+
const unicodeExcluded: any = await tool.execute("0", {
|
|
84
|
+
pattern: "zip", excludePattern: "k", ignoreCase: true,
|
|
85
|
+
}, undefined, undefined);
|
|
86
|
+
assert.equal(unicodeExcluded.content[0].text, "No matches found");
|
|
87
|
+
const caseSensitive: any = await tool.execute("0", {
|
|
88
|
+
pattern: "zip", excludePattern: "k",
|
|
89
|
+
}, undefined, undefined);
|
|
90
|
+
assert.match(caseSensitive.content[0].text, /│K zip/);
|
|
91
|
+
assert.doesNotMatch(caseSensitive.content[0].text, /│k zip/);
|
|
92
|
+
} finally {
|
|
93
|
+
await rm(directory, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("real rg searches regex by default and rejects invalid patterns", {
|
|
98
|
+
skip: rgPath === null,
|
|
99
|
+
}, async () => {
|
|
100
|
+
const directory = await mkdtemp(join(tmpdir(), "hl-grep-regex-"));
|
|
101
|
+
try {
|
|
102
|
+
await writeFile(join(directory, "fixture.ts"), "FOO\nfoo\nqueueTool(\nfoo(?=bar)\n");
|
|
103
|
+
const tool = makeGrepOverrideWithBackend(directory, {
|
|
104
|
+
findRg: async () => rgPath,
|
|
105
|
+
delegate: async () => {
|
|
106
|
+
throw new Error("integration test must not invoke the built-in grep delegate");
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
for (const pattern of ["(?i)^foo$", "(?P<name>foo)$"]) {
|
|
110
|
+
const result: any = await tool.execute("0", { pattern }, undefined, undefined);
|
|
111
|
+
assert.match(result.content[0].text, /│foo/);
|
|
112
|
+
}
|
|
113
|
+
for (const pattern of ["queueTool(", "foo(?=bar)"]) {
|
|
114
|
+
await assert.rejects(
|
|
115
|
+
tool.execute("0", { pattern }, undefined, undefined),
|
|
116
|
+
/regex parse error/,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const explicit: any = await tool.execute("0", { pattern: "queueTool(", literal: true }, undefined, undefined);
|
|
120
|
+
assert.match(explicit.content[0].text, /│queueTool\(/);
|
|
121
|
+
} finally {
|
|
122
|
+
await rm(directory, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("fallback forwards search settings unchanged to an rg-backed delegate", {
|
|
127
|
+
skip: rgPath === null,
|
|
128
|
+
}, async () => {
|
|
129
|
+
const directory = await mkdtemp(join(tmpdir(), "hl-grep-fallback-"));
|
|
130
|
+
try {
|
|
131
|
+
await writeFile(join(directory, "fixture.ts"), "FOO\nfoo\nqueueTool(\n");
|
|
132
|
+
const delegate = makeGrepOverrideWithBackend(directory, {
|
|
133
|
+
findRg: async () => rgPath,
|
|
134
|
+
delegate: async () => { throw new Error("must not invoke the built-in download path"); },
|
|
135
|
+
});
|
|
136
|
+
const fallback = makeGrepOverrideWithBackend(directory, {
|
|
137
|
+
findRg: async () => null,
|
|
138
|
+
delegate: (...args) => delegate.execute(...args),
|
|
139
|
+
});
|
|
140
|
+
for (const pattern of ["foo", "(?i)^foo$", "(?P<name>foo)$"]) {
|
|
141
|
+
const result: any = await fallback.execute("0", { pattern }, undefined, undefined);
|
|
142
|
+
const output = result.content[0].text;
|
|
143
|
+
assert.match(output, /│foo/);
|
|
144
|
+
assert.equal(output.includes("│FOO"), pattern.includes("?i"));
|
|
145
|
+
}
|
|
146
|
+
await assert.rejects(fallback.execute("0", { pattern: "queueTool(" }, undefined, undefined), /regex parse error/);
|
|
147
|
+
const literal: any = await fallback.execute("0", { pattern: "queueTool(", literal: true }, undefined, undefined);
|
|
148
|
+
assert.match(literal.content[0].text, /│queueTool\(/);
|
|
149
|
+
} finally {
|
|
150
|
+
await rm(directory, { recursive: true, force: true });
|
|
151
|
+
}
|
|
152
|
+
});
|
package/src/pi/grep-tool.ts
CHANGED
|
@@ -91,7 +91,7 @@ function compileLineMatcher(
|
|
|
91
91
|
): RegExp {
|
|
92
92
|
let source = opts.literal ? escapeRegex(pattern) : pattern;
|
|
93
93
|
if (opts.word) source = `\\b(?:${source})\\b`;
|
|
94
|
-
const flags = opts.ignoreCase ? "
|
|
94
|
+
const flags = opts.ignoreCase ? "iu" : "u";
|
|
95
95
|
try {
|
|
96
96
|
return new RegExp(source, flags);
|
|
97
97
|
} catch (err) {
|
|
@@ -110,40 +110,38 @@ function toArray(v: string | string[] | undefined): string[] {
|
|
|
110
110
|
const grepOverrideSchema = Type.Object({
|
|
111
111
|
pattern: Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
112
112
|
description:
|
|
113
|
-
"
|
|
113
|
+
"Regex pattern, or literal text with literal:true. String or array; arrays combine per matchMode.",
|
|
114
114
|
}),
|
|
115
115
|
matchMode: Type.Optional(
|
|
116
116
|
Type.Union([Type.Literal("any"), Type.Literal("all")], {
|
|
117
117
|
description:
|
|
118
|
-
'
|
|
118
|
+
'"any" (default): OR. "all": AND on the same line.',
|
|
119
119
|
}),
|
|
120
120
|
),
|
|
121
121
|
excludePattern: Type.Optional(
|
|
122
122
|
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
123
|
-
description:
|
|
124
|
-
"Drop lines matching this pattern, like grep -v (string or array; same regex/literal/ignoreCase settings as pattern). Applied after pattern matching",
|
|
123
|
+
description: "Drop lines matching any exclusion after pattern matching; uses the same literal and ignoreCase settings.",
|
|
125
124
|
}),
|
|
126
125
|
),
|
|
127
126
|
outputMode: Type.Optional(
|
|
128
127
|
Type.Union([Type.Literal("content"), Type.Literal("files"), Type.Literal("count")], {
|
|
129
|
-
description:
|
|
130
|
-
'Output shape (default "content"). "content": anchored matching lines. "files": only file paths with matches (rg -l). "count": per-file match counts + total (grep -c)',
|
|
128
|
+
description: '"content" (default): anchored lines. "files": paths. "count": matching lines per file and total.',
|
|
131
129
|
}),
|
|
132
130
|
),
|
|
133
131
|
wordMatch: Type.Optional(Type.Boolean({ description: "Match whole words only (rg -w)" })),
|
|
134
|
-
path: Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
132
|
+
path: Type.Optional(Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
135
133
|
description:
|
|
136
134
|
"Directory or file to search (string or array of paths; default: current directory)",
|
|
137
|
-
}),
|
|
135
|
+
})),
|
|
138
136
|
glob: Type.Optional(
|
|
139
137
|
Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" }),
|
|
140
138
|
),
|
|
141
139
|
ignoreCase: Type.Optional(
|
|
142
|
-
Type.Boolean({ description: "Case-insensitive search (default: false)" }),
|
|
140
|
+
Type.Boolean({ description: "Case-insensitive search (default: false); applies to pattern and excludePattern." }),
|
|
143
141
|
),
|
|
144
142
|
literal: Type.Optional(
|
|
145
143
|
Type.Boolean({
|
|
146
|
-
description: "Treat pattern as literal
|
|
144
|
+
description: "Treat pattern and excludePattern as literal text (default: false; regex). Invalid regexes return an error.",
|
|
147
145
|
}),
|
|
148
146
|
),
|
|
149
147
|
context: Type.Optional(
|
|
@@ -305,14 +303,12 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
305
303
|
name: "grep" as const,
|
|
306
304
|
label: "grep",
|
|
307
305
|
description:
|
|
308
|
-
"Search file contents
|
|
309
|
-
promptSnippet:
|
|
310
|
-
"Search file contents; results show LINE#HASH anchors usable directly in edit; multi-pattern AND, exclude, files-only and count modes replace bash grep pipelines",
|
|
306
|
+
"Search file contents, respecting .gitignore. Content results are grouped by file and include LINE#HASH anchors usable in edit; built-in grep fallback results have no anchors.",
|
|
307
|
+
promptSnippet: "Search file contents with edit-ready line anchors",
|
|
311
308
|
promptGuidelines: [
|
|
312
|
-
"
|
|
313
|
-
"
|
|
314
|
-
'
|
|
315
|
-
"Pass `pattern` (string or array); optionally `path` (string or array), `glob`, `ignoreCase`, `literal`, `wordMatch`, `context` (lines before+after each match), `limit` (max matches, default 100).",
|
|
309
|
+
"Prefer the grep tool for file-content searches.",
|
|
310
|
+
"Use returned LINE#HASH anchors directly in edit when present; no re-read is needed.",
|
|
311
|
+
'Use outputMode:"files"/"count" when only paths or counts are needed; use matchMode:"all" and excludePattern for line-level filters instead of shell pipelines.',
|
|
316
312
|
],
|
|
317
313
|
parameters: grepOverrideSchema,
|
|
318
314
|
|
|
@@ -372,6 +368,9 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
372
368
|
// aborted → built-in grep (it handles abort itself)
|
|
373
369
|
if (signal?.aborted) return backend.delegate(toolCallId, params, signal, onUpdate);
|
|
374
370
|
|
|
371
|
+
const patterns = toArray(params.pattern);
|
|
372
|
+
if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
|
|
373
|
+
|
|
375
374
|
// Plain built-in-shaped params (single string pattern/path, no new fields)
|
|
376
375
|
// can delegate safely; anything else must run the local pipeline below.
|
|
377
376
|
const legacyShaped =
|
|
@@ -391,9 +390,7 @@ export function makeGrepOverrideWithBackend(cwd: string, overrides: Partial<Grep
|
|
|
391
390
|
);
|
|
392
391
|
}
|
|
393
392
|
|
|
394
|
-
const patterns = toArray(params.pattern);
|
|
395
393
|
const excludes = toArray(params.excludePattern);
|
|
396
|
-
if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
|
|
397
394
|
const matchMode: "any" | "all" = params.matchMode ?? "any";
|
|
398
395
|
const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
|
|
399
396
|
const { glob, ignoreCase, literal, wordMatch, context, limit } = params;
|
package/src/pi/grep.test.ts
CHANGED
|
@@ -96,7 +96,9 @@ test("formats parsed rg matches with full-line hash anchors", async () => {
|
|
|
96
96
|
],
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
const
|
|
99
|
+
const tool = makeGrepOverrideWithBackend(dir, fake.backend);
|
|
100
|
+
assert.deepEqual(tool.parameters.required, ["pattern"]);
|
|
101
|
+
const result = await call(tool, {
|
|
100
102
|
pattern: "alpha",
|
|
101
103
|
});
|
|
102
104
|
const output = text(result);
|
|
@@ -326,3 +328,60 @@ test("delegates an already-aborted call and rejects an abort during rg execution
|
|
|
326
328
|
);
|
|
327
329
|
});
|
|
328
330
|
});
|
|
331
|
+
|
|
332
|
+
test("keeps regex and case-sensitive defaults across rg and line filters", async () => {
|
|
333
|
+
await withDir(async (dir) => {
|
|
334
|
+
const target = join(dir, "case.ts");
|
|
335
|
+
await writeFile(target, "FOO alpha\n");
|
|
336
|
+
const fake = fakeBackend({ lines: [rgMatch(target, 1, "FOO alpha\n")] });
|
|
337
|
+
const tool = makeGrepOverrideWithBackend(dir, fake.backend);
|
|
338
|
+
const query = { pattern: ["foo", "alpha"], matchMode: "all", excludePattern: "BETA" };
|
|
339
|
+
|
|
340
|
+
assert.equal(text(await call(tool, query)), "No matches found");
|
|
341
|
+
assert.match(text(await call(tool, { ...query, ignoreCase: true })), /FOO alpha/);
|
|
342
|
+
assert.match(text(await call(tool, { pattern: "FOO", literal: true })), /FOO alpha/);
|
|
343
|
+
assert.deepEqual(fake.calls.map(({ args }) => ({
|
|
344
|
+
ignoreCase: args.includes("--ignore-case"),
|
|
345
|
+
literal: args.includes("--fixed-strings"),
|
|
346
|
+
})), [
|
|
347
|
+
{ ignoreCase: false, literal: false },
|
|
348
|
+
{ ignoreCase: true, literal: false },
|
|
349
|
+
{ ignoreCase: false, literal: true },
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
const invalid = fakeBackend({ code: 2, stderr: "regex parse error:\nerror: unclosed group" });
|
|
353
|
+
await assert.rejects(
|
|
354
|
+
call(makeGrepOverrideWithBackend(dir, invalid.backend), { pattern: "queueTool(" }),
|
|
355
|
+
/regex parse error/,
|
|
356
|
+
);
|
|
357
|
+
assert.equal(invalid.calls.length, 1);
|
|
358
|
+
assert.ok(!invalid.calls[0].args.includes("--fixed-strings"));
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("native fallback forwards parameters unchanged and propagates regex errors", async () => {
|
|
363
|
+
await withDir(async (dir) => {
|
|
364
|
+
const fake = fakeBackend();
|
|
365
|
+
fake.backend.findRg = async () => null;
|
|
366
|
+
const tool = makeGrepOverrideWithBackend(dir, fake.backend);
|
|
367
|
+
const cases = [
|
|
368
|
+
{ pattern: "foo" },
|
|
369
|
+
{ pattern: "(?i)foo" },
|
|
370
|
+
{ pattern: "queueTool(", literal: true, ignoreCase: true },
|
|
371
|
+
];
|
|
372
|
+
for (const params of cases) {
|
|
373
|
+
const input = { ...params, path: "fixture.ts", glob: "*.ts", context: 2, limit: 3 };
|
|
374
|
+
assert.equal(text(await call(tool, input)), "delegated");
|
|
375
|
+
assert.deepEqual(fake.delegates.at(-1)![1], input);
|
|
376
|
+
}
|
|
377
|
+
assert.equal(fake.delegates.length, cases.length);
|
|
378
|
+
assert.equal(fake.calls.length, 0);
|
|
379
|
+
fake.backend.delegate = async (...args) => {
|
|
380
|
+
fake.delegates.push(args);
|
|
381
|
+
throw new Error("regex parse error: unclosed group");
|
|
382
|
+
};
|
|
383
|
+
const failingTool = makeGrepOverrideWithBackend(dir, fake.backend);
|
|
384
|
+
await assert.rejects(call(failingTool, { pattern: "queueTool(" }), /regex parse error/);
|
|
385
|
+
assert.equal(fake.delegates.length, cases.length + 1);
|
|
386
|
+
});
|
|
387
|
+
});
|