@d3ara1n/pi-hashline-edit 0.4.1 → 0.5.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 +11 -1
- package/package.json +1 -1
- package/src/pi/grep-tool.ts +224 -68
- package/src/pi/grep.test.ts +265 -0
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ Routine local code editing in pi — the common case. If you spend turns fightin
|
|
|
22
22
|
|
|
23
23
|
## When to turn it off
|
|
24
24
|
|
|
25
|
-
Set `hashlineEdit.enabled = false` (or uninstall) to fall back to the built-in `read`/`edit`/`grep` when you need **remote or custom-storage files** — the overrides read/write/search the local filesystem directly, so pi's custom `ReadOperations`/`GrepOperations` (SSH, etc.) aren't supported. The same switch lets you opt out per-project. All four tools — `read`, `grep`, `edit`, `replace` — are one set governed by this switch: when disabled, `read`/`
|
|
25
|
+
Set `hashlineEdit.enabled = false` (or uninstall) to fall back to the built-in `read`/`edit`/`grep` when you need **remote or custom-storage files** — the overrides read/write/search the local filesystem directly, so pi's custom `ReadOperations`/`GrepOperations` (SSH, etc.) aren't supported. The same switch lets you opt out per-project. All four tools — `read`, `grep`, `edit`, `replace` — are one set governed by this switch: when disabled, `read`/`edit` and plain `grep` calls delegate to the built-ins (`grep` calls using the extended params below still run locally, formatted without anchors) and `replace` refuses (it has no built-in counterpart).
|
|
26
26
|
|
|
27
27
|
## Model compatibility — field notes
|
|
28
28
|
|
|
@@ -90,6 +90,16 @@ src/util.ts · 1 match
|
|
|
90
90
|
10#aF3│ const z = compute(x)
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
The `grep` override also covers the compound queries that otherwise push models into bash pipelines:
|
|
94
|
+
|
|
95
|
+
- `matchMode: "all"` — a line must match **every** pattern (`grep A | grep B` without the pipe)
|
|
96
|
+
- `excludePattern` — drop matching lines (`grep -v`), applied after pattern matching
|
|
97
|
+
- `wordMatch` — whole words only (`rg -w`)
|
|
98
|
+
- `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
|
|
99
|
+
- `pattern` and `path` accept arrays — several patterns combined per `matchMode`, several search roots in one call
|
|
100
|
+
|
|
101
|
+
Filters run before the match limit counts, and context windows are rebuilt from surviving matches, so `limit` and `context` compose cleanly with `matchMode`/`excludePattern`.
|
|
102
|
+
|
|
93
103
|
|
|
94
104
|
`edit` takes `path` + `edits` (an array of ops, each with `op`, `anchor`/`end` `{line, hash}` from read, and `body` string[]):
|
|
95
105
|
|
package/package.json
CHANGED
package/src/pi/grep-tool.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Override grep: search results carry `LINE#HASH│` anchors (same format as
|
|
3
3
|
* read), grouped by file. The model can copy `LINE#HASH` straight into an edit
|
|
4
|
-
* anchor — no re-read needed. Context lines (
|
|
4
|
+
* anchor — no re-read needed. Context lines (`context`) are anchored too.
|
|
5
|
+
*
|
|
6
|
+
* Beyond the built-in grep it covers the compound queries models otherwise
|
|
7
|
+
* drop to bash pipelines for: multi-pattern AND (`matchMode: "all"` ≈
|
|
8
|
+
* `grep A | grep B`), line exclusion (`excludePattern` ≈ `grep -v`),
|
|
9
|
+
* whole-word matching (`wordMatch` ≈ `-w`), multiple search roots, and
|
|
10
|
+
* files-only / count output (`outputMode` ≈ `rg -l` / `grep -c`).
|
|
5
11
|
*
|
|
6
12
|
* We run ripgrep directly (`--json`) rather than wrap the built-in grep, so we
|
|
7
13
|
* control formatting and can compute each line's hash from its FULL content
|
|
@@ -10,8 +16,15 @@
|
|
|
10
16
|
* verifies against the full line — so the hash must be computed from the full
|
|
11
17
|
* content, independently of what is displayed.)
|
|
12
18
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
19
|
+
* Filters run in two places: rg gets every pattern as `-e` (native OR) plus
|
|
20
|
+
* the global flags; the AND / exclude checks then run client-side on each
|
|
21
|
+
* matched line's text (streamed by rg), so `limit` counts final results, not
|
|
22
|
+
* pre-filter candidates. Context windows are likewise rebuilt client-side from
|
|
23
|
+
* the surviving matches — context lines of a filtered-out match never leak.
|
|
24
|
+
*
|
|
25
|
+
* Falls back to the built-in grep when: hashline disabled with plain params,
|
|
26
|
+
* aborted, or ripgrep cannot be located (the built-in can auto-download rg).
|
|
27
|
+
* Extended params never delegate — the built-in would misread them.
|
|
15
28
|
*
|
|
16
29
|
* @module pi-hashline-edit/pi
|
|
17
30
|
*/
|
|
@@ -24,6 +37,7 @@ import {
|
|
|
24
37
|
formatSize,
|
|
25
38
|
DEFAULT_MAX_BYTES,
|
|
26
39
|
} from "@earendil-works/pi-coding-agent";
|
|
40
|
+
import { Type } from "typebox";
|
|
27
41
|
import { Text } from "@earendil-works/pi-tui";
|
|
28
42
|
import { spawn } from "node:child_process";
|
|
29
43
|
import { createInterface } from "node:readline";
|
|
@@ -58,10 +72,79 @@ async function findRg(): Promise<string | null> {
|
|
|
58
72
|
return null;
|
|
59
73
|
}
|
|
60
74
|
|
|
61
|
-
|
|
75
|
+
/** Escape a literal string for use as a regex source. */
|
|
76
|
+
function escapeRegex(s: string): string {
|
|
77
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compile a pattern for the client-side line checks (`matchMode: "all"` and
|
|
82
|
+
* `excludePattern`), mirroring the flags rg was given — `literal`,
|
|
83
|
+
* `ignoreCase`, and (for the AND check) `wordMatch` — so a line rg accepted is
|
|
84
|
+
* judged by the same semantics here. Patterns valid in rg but invalid as a JS
|
|
85
|
+
* regex (e.g. `(?P<name>…)`) throw rather than silently degrade.
|
|
86
|
+
*/
|
|
87
|
+
function compileLineMatcher(
|
|
88
|
+
pattern: string,
|
|
89
|
+
opts: { literal: boolean; ignoreCase: boolean; word: boolean },
|
|
90
|
+
): RegExp {
|
|
91
|
+
let source = opts.literal ? escapeRegex(pattern) : pattern;
|
|
92
|
+
if (opts.word) source = `\\b(?:${source})\\b`;
|
|
93
|
+
const flags = opts.ignoreCase ? "i" : "";
|
|
94
|
+
try {
|
|
95
|
+
return new RegExp(source, flags);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
throw new Error(`Pattern not supported for line filtering: ${pattern} (${(err as Error).message})`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Normalize a `string | string[]` param to an array (`undefined` → `[]`). */
|
|
102
|
+
function toArray(v: string | string[] | undefined): string[] {
|
|
103
|
+
if (v === undefined) return [];
|
|
104
|
+
return Array.isArray(v) ? v : [v];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const grepOverrideSchema = Type.Object({
|
|
108
|
+
pattern: Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
109
|
+
description:
|
|
110
|
+
"Search pattern (regex, or literal with literal:true). String or array; an array combines patterns per matchMode (any = OR, all = AND on the same line)",
|
|
111
|
+
}),
|
|
112
|
+
matchMode: Type.Optional(
|
|
113
|
+
Type.Union([Type.Literal("any"), Type.Literal("all")], {
|
|
114
|
+
description:
|
|
115
|
+
'How multiple patterns combine (default "any"). "any": line matches at least one pattern. "all": line must match every pattern — equivalent to `grep A | grep B`',
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
excludePattern: Type.Optional(
|
|
119
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
120
|
+
description:
|
|
121
|
+
"Drop lines matching this pattern, like grep -v (string or array; same regex/literal/ignoreCase settings as pattern). Applied after pattern matching",
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
outputMode: Type.Optional(
|
|
125
|
+
Type.Union([Type.Literal("content"), Type.Literal("files"), Type.Literal("count")], {
|
|
126
|
+
description:
|
|
127
|
+
'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
|
+
}),
|
|
129
|
+
),
|
|
130
|
+
wordMatch: Type.Optional(Type.Boolean({ description: "Match whole words only (rg -w)" })),
|
|
131
|
+
path: Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
132
|
+
description: "Directory or file to search (string or array of paths; default: current directory)",
|
|
133
|
+
}),
|
|
134
|
+
glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })),
|
|
135
|
+
ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
|
|
136
|
+
literal: Type.Optional(
|
|
137
|
+
Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
|
|
138
|
+
),
|
|
139
|
+
context: Type.Optional(
|
|
140
|
+
Type.Number({ description: "Number of lines to show before and after each match (default: 0); context lines are anchored too" }),
|
|
141
|
+
),
|
|
142
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of matching lines to return (default: 100)" })),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
interface RgMatch {
|
|
62
146
|
filePath: string;
|
|
63
147
|
lineNumber: number;
|
|
64
|
-
match: boolean;
|
|
65
148
|
}
|
|
66
149
|
|
|
67
150
|
/**
|
|
@@ -122,25 +205,37 @@ export function makeGrepOverride(cwd: string) {
|
|
|
122
205
|
name: "grep" as const,
|
|
123
206
|
label: "grep",
|
|
124
207
|
description:
|
|
125
|
-
"Search file contents for a pattern.
|
|
126
|
-
promptSnippet:
|
|
208
|
+
"Search file contents for a pattern. Results are grouped by file with LINE#HASH anchors usable directly in edit. Supports multi-pattern AND (matchMode:all), line exclusion (excludePattern, grep -v), whole-word matching (wordMatch), multiple search paths, and files-only / count output modes — the common `grep A | grep -v B` / `rg -l` / `grep -c` pipelines without bash. Respects .gitignore.",
|
|
209
|
+
promptSnippet:
|
|
210
|
+
"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",
|
|
127
211
|
promptGuidelines: [
|
|
128
212
|
"Results are grouped by file under a `path · N matches` header; each line shows `LINE#HASH│content` (same format as read).",
|
|
129
213
|
"Copy `LINE#HASH` straight into an edit `anchor`/`end` — no re-read needed. Context lines (from `context`) are anchored and editable too.",
|
|
130
|
-
"
|
|
214
|
+
"Prefer this over bash pipes: `matchMode:\"all\"` + `excludePattern` express `grep A | grep -v B`; `outputMode:\"files\"`/`\"count\"` replace `rg -l`/`grep -c` when you only need locations or counts. `files` output pastes back as a `path` array.",
|
|
215
|
+
"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).",
|
|
131
216
|
],
|
|
132
|
-
parameters:
|
|
217
|
+
parameters: grepOverrideSchema,
|
|
133
218
|
|
|
134
219
|
renderShell: "default" as const,
|
|
135
220
|
|
|
136
221
|
renderCall(args: any, theme: any) {
|
|
137
|
-
const
|
|
138
|
-
const
|
|
222
|
+
const rawPattern = args?.pattern;
|
|
223
|
+
const patternText = Array.isArray(rawPattern) ? rawPattern.join(" | ") : String(rawPattern ?? "");
|
|
224
|
+
const rawPath = args?.path;
|
|
225
|
+
const pathText = Array.isArray(rawPath) ? rawPath.join(" ") : String(rawPath ?? ".");
|
|
139
226
|
let text =
|
|
140
227
|
theme.fg("toolTitle", theme.bold("grep ")) +
|
|
141
|
-
theme.fg("accent", `/${
|
|
142
|
-
theme.fg("toolOutput", ` in ${
|
|
228
|
+
theme.fg("accent", `/${patternText}/`) +
|
|
229
|
+
theme.fg("toolOutput", ` in ${pathText}`);
|
|
230
|
+
if (args?.matchMode === "all") text += theme.fg("accent", " all");
|
|
231
|
+
if (args?.excludePattern) {
|
|
232
|
+
const ex = Array.isArray(args.excludePattern) ? args.excludePattern.join(",") : args.excludePattern;
|
|
233
|
+
text += theme.fg("toolOutput", ` -v:${ex}`);
|
|
234
|
+
}
|
|
235
|
+
if (args?.wordMatch) text += theme.fg("toolOutput", " -w");
|
|
143
236
|
if (args?.glob) text += theme.fg("toolOutput", ` (${args.glob})`);
|
|
237
|
+
if (args?.outputMode && args.outputMode !== "content")
|
|
238
|
+
text += theme.fg("success", ` → ${args.outputMode}`);
|
|
144
239
|
if (args?.limit !== undefined) text += theme.fg("toolOutput", ` limit ${args.limit}`);
|
|
145
240
|
return new Text(text, 0, 0);
|
|
146
241
|
},
|
|
@@ -164,24 +259,70 @@ export function makeGrepOverride(cwd: string) {
|
|
|
164
259
|
|
|
165
260
|
async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any): Promise<any> {
|
|
166
261
|
const state = getState();
|
|
167
|
-
//
|
|
168
|
-
if (
|
|
262
|
+
// aborted → built-in grep (it handles abort itself)
|
|
263
|
+
if (signal?.aborted) return delegate(toolCallId, params, signal, onUpdate);
|
|
264
|
+
|
|
265
|
+
// Plain built-in-shaped params (single string pattern/path, no new fields)
|
|
266
|
+
// can delegate safely; anything else must run the local pipeline below.
|
|
267
|
+
const legacyShaped =
|
|
268
|
+
typeof params.pattern === "string" &&
|
|
269
|
+
params.matchMode === undefined &&
|
|
270
|
+
params.excludePattern === undefined &&
|
|
271
|
+
params.outputMode === undefined &&
|
|
272
|
+
params.wordMatch === undefined &&
|
|
273
|
+
!Array.isArray(params.path);
|
|
274
|
+
|
|
275
|
+
// disabled + plain params → built-in grep, exactly as before
|
|
276
|
+
if (!state.config.enabled && legacyShaped) return delegate(toolCallId, params, signal, onUpdate);
|
|
169
277
|
|
|
170
278
|
const rgPath = await findRg();
|
|
171
|
-
// ripgrep unavailable →
|
|
172
|
-
if (!rgPath)
|
|
279
|
+
// ripgrep unavailable → built-in (it can auto-download rg), but only for plain params
|
|
280
|
+
if (!rgPath) {
|
|
281
|
+
if (legacyShaped) return delegate(toolCallId, params, signal, onUpdate);
|
|
282
|
+
throw new Error(
|
|
283
|
+
"ripgrep (rg) not found; extended grep params cannot fall back to the built-in grep. Retry with a simple pattern first, or use bash",
|
|
284
|
+
);
|
|
285
|
+
}
|
|
173
286
|
|
|
174
|
-
|
|
175
|
-
const
|
|
287
|
+
// disabled + extended params still run locally, formatted without anchors
|
|
288
|
+
const anchored = state.config.enabled;
|
|
289
|
+
|
|
290
|
+
const patterns = toArray(params.pattern);
|
|
291
|
+
const excludes = toArray(params.excludePattern);
|
|
292
|
+
if (patterns.length === 0) throw new Error("pattern is required (got an empty array)");
|
|
293
|
+
const matchMode: "any" | "all" = params.matchMode ?? "any";
|
|
294
|
+
const outputMode: "content" | "files" | "count" = params.outputMode ?? "content";
|
|
295
|
+
const { glob, ignoreCase, literal, wordMatch, context, limit } = params;
|
|
296
|
+
const ctx = context && context > 0 ? context : 0;
|
|
297
|
+
const searchPaths = (() => {
|
|
298
|
+
const raw = toArray(params.path);
|
|
299
|
+
return (raw.length ? raw : ["."]).map((p) => canonicalPath(cwd, p));
|
|
300
|
+
})();
|
|
176
301
|
const hashLen = state.config.hashLen;
|
|
177
302
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
303
|
+
// Verify search paths upfront; remember dir-ness for relative display.
|
|
304
|
+
const roots: { path: string; isDir: boolean }[] = [];
|
|
305
|
+
for (const sp of searchPaths) {
|
|
306
|
+
try {
|
|
307
|
+
roots.push({ path: sp, isDir: (await stat(sp)).isDirectory() });
|
|
308
|
+
} catch {
|
|
309
|
+
throw new Error(`Path not found: ${sp}`);
|
|
310
|
+
}
|
|
183
311
|
}
|
|
184
312
|
|
|
313
|
+
// Client-side line filters — only AND / exclude need them; "any" is native rg (-e OR).
|
|
314
|
+
const excludeMatchers = excludes.map((p) =>
|
|
315
|
+
compileLineMatcher(p, { literal: !!literal, ignoreCase: !!ignoreCase, word: false }),
|
|
316
|
+
);
|
|
317
|
+
const andMatchers =
|
|
318
|
+
matchMode === "all" && patterns.length > 1
|
|
319
|
+
? patterns.map((p) =>
|
|
320
|
+
compileLineMatcher(p, { literal: !!literal, ignoreCase: !!ignoreCase, word: !!wordMatch }),
|
|
321
|
+
)
|
|
322
|
+
: [];
|
|
323
|
+
const linePasses = (line: string): boolean =>
|
|
324
|
+
andMatchers.every((re) => re.test(line)) && !excludeMatchers.some((re) => re.test(line));
|
|
325
|
+
|
|
185
326
|
return new Promise((resolvePromise, reject) => {
|
|
186
327
|
if (signal?.aborted) {
|
|
187
328
|
reject(new Error("Operation aborted"));
|
|
@@ -191,10 +332,10 @@ export function makeGrepOverride(cwd: string) {
|
|
|
191
332
|
const args = ["--json", "--line-number", "--color=never", "--hidden"];
|
|
192
333
|
if (ignoreCase) args.push("--ignore-case");
|
|
193
334
|
if (literal) args.push("--fixed-strings");
|
|
335
|
+
if (wordMatch) args.push("--word-regexp");
|
|
194
336
|
if (glob) args.push("--glob", glob);
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
args.push("--", String(pattern), searchPath);
|
|
337
|
+
for (const p of patterns) args.push("-e", p);
|
|
338
|
+
args.push("--", ...searchPaths);
|
|
198
339
|
|
|
199
340
|
const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT);
|
|
200
341
|
const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -205,7 +346,7 @@ export function makeGrepOverride(cwd: string) {
|
|
|
205
346
|
let linesTruncated = false;
|
|
206
347
|
let aborted = false;
|
|
207
348
|
let killedDueToLimit = false;
|
|
208
|
-
const raw:
|
|
349
|
+
const raw: RgMatch[] = [];
|
|
209
350
|
|
|
210
351
|
const cleanup = () => {
|
|
211
352
|
rl.close();
|
|
@@ -234,19 +375,19 @@ export function makeGrepOverride(cwd: string) {
|
|
|
234
375
|
} catch {
|
|
235
376
|
return;
|
|
236
377
|
}
|
|
237
|
-
if (event.type
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
378
|
+
if (event.type !== "match") return;
|
|
379
|
+
const filePath = event.data?.path?.text;
|
|
380
|
+
const lineNumber = event.data?.line_number;
|
|
381
|
+
if (!filePath || typeof lineNumber !== "number") return;
|
|
382
|
+
// AND / exclude filters run on the matched line's text as streamed
|
|
383
|
+
// by rg, so the limit counts final results, not pre-filter candidates.
|
|
384
|
+
const text = typeof event.data?.lines?.text === "string" ? event.data.lines.text : "";
|
|
385
|
+
if (!linePasses(text.replace(/\r?\n$/, ""))) return;
|
|
386
|
+
matchCount++;
|
|
387
|
+
raw.push({ filePath, lineNumber });
|
|
388
|
+
if (matchCount >= effectiveLimit) {
|
|
389
|
+
matchLimitReached = true;
|
|
390
|
+
stopChild(true);
|
|
250
391
|
}
|
|
251
392
|
});
|
|
252
393
|
|
|
@@ -270,22 +411,14 @@ export function makeGrepOverride(cwd: string) {
|
|
|
270
411
|
return;
|
|
271
412
|
}
|
|
272
413
|
|
|
273
|
-
//
|
|
274
|
-
const
|
|
414
|
+
// Group by file, matches sorted by line number (Map keeps rg's discovery order).
|
|
415
|
+
const byFile = new Map<string, number[]>();
|
|
275
416
|
for (const m of raw) {
|
|
276
|
-
const key = `${m.filePath}:${m.lineNumber}`;
|
|
277
|
-
const prev = map.get(key);
|
|
278
|
-
if (!prev || (!prev.match && m.match)) map.set(key, m);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Group by file, each group sorted by line number.
|
|
282
|
-
const byFile = new Map<string, RawMatch[]>();
|
|
283
|
-
for (const m of map.values()) {
|
|
284
417
|
const arr = byFile.get(m.filePath) ?? [];
|
|
285
|
-
arr.push(m);
|
|
418
|
+
arr.push(m.lineNumber);
|
|
286
419
|
byFile.set(m.filePath, arr);
|
|
287
420
|
}
|
|
288
|
-
for (const arr of byFile.values()) arr.sort((a, b) => a
|
|
421
|
+
for (const arr of byFile.values()) arr.sort((a, b) => a - b);
|
|
289
422
|
|
|
290
423
|
// Read each file once and hash all its lines; hash is computed from the FULL line.
|
|
291
424
|
const fileCache = new Map<string, { lines: string[]; hashes: string[] }>();
|
|
@@ -306,30 +439,53 @@ export function makeGrepOverride(cwd: string) {
|
|
|
306
439
|
};
|
|
307
440
|
|
|
308
441
|
const formatPath = (fp: string): string => {
|
|
309
|
-
|
|
310
|
-
|
|
442
|
+
for (const root of roots) {
|
|
443
|
+
if (!root.isDir) continue;
|
|
444
|
+
const rel = relative(root.path, fp).replace(/\\/g, "/");
|
|
311
445
|
if (rel && !rel.startsWith("..")) return rel;
|
|
312
446
|
}
|
|
313
447
|
return basename(fp);
|
|
314
448
|
};
|
|
315
449
|
|
|
316
450
|
const blocks: string[] = [];
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
451
|
+
if (outputMode === "content") {
|
|
452
|
+
for (const [fp, matchLines] of byFile) {
|
|
453
|
+
const { lines, hashes } = await getFile(fp);
|
|
454
|
+
const matchSet = new Set(matchLines);
|
|
455
|
+
// Context windows are rebuilt from surviving matches so context
|
|
456
|
+
// lines of a filtered-out match never leak.
|
|
457
|
+
const windowSet = new Set<number>();
|
|
458
|
+
for (const ln of matchLines) {
|
|
459
|
+
for (let n = Math.max(1, ln - ctx); n <= Math.min(lines.length, ln + ctx); n++) windowSet.add(n);
|
|
460
|
+
}
|
|
461
|
+
const header = anchored
|
|
462
|
+
? `${formatPath(fp)} · ${matchLines.length} match${matchLines.length !== 1 ? "es" : ""}\n`
|
|
463
|
+
: "";
|
|
464
|
+
const rows: string[] = [];
|
|
465
|
+
for (const n of [...windowSet].sort((a, b) => a - b)) {
|
|
466
|
+
const content = lines[n - 1] ?? "";
|
|
467
|
+
const hash = hashes[n - 1] ?? "";
|
|
468
|
+
const { text: disp, wasTruncated } = truncateLine(content.replace(/\r/g, ""));
|
|
469
|
+
if (wasTruncated) linesTruncated = true;
|
|
470
|
+
if (anchored) rows.push(`${n}#${hash}│${disp}`);
|
|
471
|
+
else if (matchSet.has(n)) rows.push(`${formatPath(fp)}:${n}: ${disp}`);
|
|
472
|
+
else rows.push(`${formatPath(fp)}-${n}- ${disp}`);
|
|
473
|
+
}
|
|
474
|
+
blocks.push(`${header}${rows.join("\n")}`);
|
|
475
|
+
}
|
|
476
|
+
} else if (outputMode === "files") {
|
|
477
|
+
for (const fp of byFile.keys()) blocks.push(formatPath(fp));
|
|
478
|
+
} else {
|
|
479
|
+
// count
|
|
480
|
+
let total = 0;
|
|
481
|
+
for (const [fp, matchLines] of byFile) {
|
|
482
|
+
blocks.push(`${formatPath(fp)}: ${matchLines.length}`);
|
|
483
|
+
total += matchLines.length;
|
|
328
484
|
}
|
|
329
|
-
blocks.push(
|
|
485
|
+
blocks.push(`Total: ${total} match${total !== 1 ? "es" : ""} in ${byFile.size} file${byFile.size !== 1 ? "s" : ""}`);
|
|
330
486
|
}
|
|
331
487
|
|
|
332
|
-
let output = blocks.join("\n\n");
|
|
488
|
+
let output = blocks.join(outputMode === "content" ? "\n\n" : "\n");
|
|
333
489
|
const truncation = truncateHead(output, { maxBytes: DEFAULT_MAX_BYTES });
|
|
334
490
|
output = truncation.content;
|
|
335
491
|
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grep override execute tests: drive the real makeGrepOverride execute against
|
|
3
|
+
* temp dirs — anchored content output, boolean combination (matchMode all /
|
|
4
|
+
* excludePattern), output modes (files / count), wordMatch, multi-pattern any,
|
|
5
|
+
* multi-path, context windows, ignoreCase, limit notice, and the disabled-mode
|
|
6
|
+
* fallbacks (plain params delegate, extended params run un-anchored).
|
|
7
|
+
*/
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { makeGrepOverride } from "./grep-tool.ts";
|
|
14
|
+
import { getState } from "./state.ts";
|
|
15
|
+
import { computeLineHash } from "../core/hash.ts";
|
|
16
|
+
|
|
17
|
+
async function withDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
18
|
+
const dir = await mkdtemp(join(tmpdir(), "hl-grep-"));
|
|
19
|
+
try {
|
|
20
|
+
return await fn(dir);
|
|
21
|
+
} finally {
|
|
22
|
+
await rm(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const call = (tool: any, params: any) => tool.execute("0", params, undefined, undefined);
|
|
27
|
+
|
|
28
|
+
const A = "alpha beta\ngamma\nalpha only\nbeta only\nALPHA caps\n";
|
|
29
|
+
const B = "alpha here\nnothing\n";
|
|
30
|
+
|
|
31
|
+
async function seed(dir: string) {
|
|
32
|
+
await writeFile(join(dir, "a.ts"), A);
|
|
33
|
+
await writeFile(join(dir, "b.ts"), B);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const text = (r: any): string => r.content[0].text;
|
|
37
|
+
|
|
38
|
+
/** Run with hashline enabled/disabled, restoring the original config. */
|
|
39
|
+
async function withEnabled<T>(enabled: boolean, fn: () => Promise<T>): Promise<T> {
|
|
40
|
+
const state = getState();
|
|
41
|
+
const prev = state.config.enabled;
|
|
42
|
+
state.config.enabled = enabled;
|
|
43
|
+
try {
|
|
44
|
+
return await fn();
|
|
45
|
+
} finally {
|
|
46
|
+
state.config.enabled = prev;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("content: single pattern groups by file with LINE#HASH anchors", async () => {
|
|
51
|
+
await withDir(async (dir) => {
|
|
52
|
+
await seed(dir);
|
|
53
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha" });
|
|
54
|
+
const out = text(r);
|
|
55
|
+
assert.match(out, /a\.ts · 2 matches/);
|
|
56
|
+
assert.match(out, /b\.ts · 1 match/);
|
|
57
|
+
assert.match(out, /1#[0-9A-Z]+│alpha beta/);
|
|
58
|
+
assert.match(out, /3#[0-9A-Z]+│alpha only/);
|
|
59
|
+
assert.doesNotMatch(out, /gamma/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("content: grep anchors match the hash computed from the full line", async () => {
|
|
64
|
+
await withDir(async (dir) => {
|
|
65
|
+
await seed(dir);
|
|
66
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha beta" });
|
|
67
|
+
const hash = computeLineHash(1, "alpha beta");
|
|
68
|
+
assert.match(text(r), new RegExp(`1#${hash}│alpha beta`));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("matchMode all: line must match every pattern (grep A | grep B)", async () => {
|
|
73
|
+
await withDir(async (dir) => {
|
|
74
|
+
await seed(dir);
|
|
75
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: ["alpha", "beta"], matchMode: "all" });
|
|
76
|
+
const out = text(r);
|
|
77
|
+
assert.match(out, /a\.ts · 1 match/);
|
|
78
|
+
assert.match(out, /1#[0-9A-Z]+│alpha beta/);
|
|
79
|
+
assert.doesNotMatch(out, /beta only/);
|
|
80
|
+
assert.doesNotMatch(out, /alpha here/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("excludePattern drops lines, like grep -v", async () => {
|
|
85
|
+
await withDir(async (dir) => {
|
|
86
|
+
await seed(dir);
|
|
87
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", excludePattern: "beta" });
|
|
88
|
+
const out = text(r);
|
|
89
|
+
assert.match(out, /a\.ts · 1 match/);
|
|
90
|
+
assert.match(out, /3#[0-9A-Z]+│alpha only/);
|
|
91
|
+
assert.match(out, /b\.ts · 1 match/);
|
|
92
|
+
assert.doesNotMatch(out, /alpha beta/);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("outputMode files: one path per line, no line content", async () => {
|
|
97
|
+
await withDir(async (dir) => {
|
|
98
|
+
await seed(dir);
|
|
99
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", outputMode: "files" });
|
|
100
|
+
assert.equal(text(r), "a.ts\nb.ts");
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("outputMode count: per-file counts + total", async () => {
|
|
105
|
+
await withDir(async (dir) => {
|
|
106
|
+
await seed(dir);
|
|
107
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", outputMode: "count" });
|
|
108
|
+
assert.equal(text(r), "a.ts: 2\nb.ts: 1\nTotal: 3 matches in 2 files");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("wordMatch: whole words only", async () => {
|
|
113
|
+
await withDir(async (dir) => {
|
|
114
|
+
await writeFile(join(dir, "w.ts"), "foobar\nfoo bar\n");
|
|
115
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "foo", wordMatch: true });
|
|
116
|
+
const out = text(r);
|
|
117
|
+
assert.match(out, /w\.ts · 1 match/);
|
|
118
|
+
assert.match(out, /2#[0-9A-Z]+│foo bar/);
|
|
119
|
+
assert.doesNotMatch(out, /foobar/);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("multi-pattern any (default): OR across patterns", async () => {
|
|
124
|
+
await withDir(async (dir) => {
|
|
125
|
+
await seed(dir);
|
|
126
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: ["gamma", "nothing"] });
|
|
127
|
+
const out = text(r);
|
|
128
|
+
assert.match(out, /a\.ts · 1 match/);
|
|
129
|
+
assert.match(out, /2#[0-9A-Z]+│gamma/);
|
|
130
|
+
assert.match(out, /b\.ts · 1 match/);
|
|
131
|
+
assert.match(out, /2#[0-9A-Z]+│nothing/);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("path accepts an array of search roots", async () => {
|
|
136
|
+
await withDir(async (dir) => {
|
|
137
|
+
await seed(dir);
|
|
138
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "beta only", path: ["a.ts", "b.ts"] });
|
|
139
|
+
const out = text(r);
|
|
140
|
+
assert.match(out, /4#[0-9A-Z]+│beta only/);
|
|
141
|
+
assert.match(out, /a\.ts · 1 match/);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("context: ±N lines around surviving matches, anchored", async () => {
|
|
146
|
+
await withDir(async (dir) => {
|
|
147
|
+
await writeFile(join(dir, "c.ts"), "l1\nl2\nl3\nl4\nl5\n");
|
|
148
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "l3", context: 1 });
|
|
149
|
+
const out = text(r);
|
|
150
|
+
assert.match(out, /c\.ts · 1 match/);
|
|
151
|
+
const rows = out.split("\n").filter((l) => /│l\d/.test(l));
|
|
152
|
+
assert.deepEqual(
|
|
153
|
+
rows.map((l) => l.replace(/#\w+│/, ":")),
|
|
154
|
+
["2:l2", "3:l3", "4:l4"],
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("context windows are rebuilt from surviving matches (filtered match leaks no context)", async () => {
|
|
160
|
+
await withDir(async (dir) => {
|
|
161
|
+
// l4 is a match but excluded; it sits >ctx away from the surviving l1 match,
|
|
162
|
+
// so none of its surroundings may appear as context
|
|
163
|
+
await writeFile(join(dir, "c.ts"), "target keep\nl2\nl3\ndrop me\nl5\nl6\n");
|
|
164
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "target|drop", excludePattern: "drop", context: 1 });
|
|
165
|
+
const out = text(r);
|
|
166
|
+
assert.match(out, /c\.ts · 1 match/);
|
|
167
|
+
assert.match(out, /1#[0-9A-Z]+│target keep/);
|
|
168
|
+
assert.match(out, /2#[0-9A-Z]+│l2/);
|
|
169
|
+
assert.doesNotMatch(out, /l3/);
|
|
170
|
+
assert.doesNotMatch(out, /drop me/);
|
|
171
|
+
assert.doesNotMatch(out, /l5/);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("ignoreCase matches across case variants", async () => {
|
|
176
|
+
await withDir(async (dir) => {
|
|
177
|
+
await seed(dir);
|
|
178
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", ignoreCase: true });
|
|
179
|
+
assert.match(text(r), /a\.ts · 3 matches/);
|
|
180
|
+
assert.match(text(r), /5#[0-9A-Z]+│ALPHA caps/);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("limit notice suggests doubling", async () => {
|
|
185
|
+
await withDir(async (dir) => {
|
|
186
|
+
await seed(dir);
|
|
187
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "a", limit: 2 });
|
|
188
|
+
assert.match(text(r), /\[2 matches limit reached\. Use limit=4 for more, or refine pattern\]/);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("filters apply before the limit counts (a filtered match consumes no budget)", async () => {
|
|
193
|
+
await withDir(async (dir) => {
|
|
194
|
+
await seed(dir);
|
|
195
|
+
// a.ts:1 "alpha beta" is excluded; pre-filter counting would burn the whole
|
|
196
|
+
// limit=1 budget on it and return nothing — post-filter counting yields a.ts:3
|
|
197
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha", excludePattern: "beta", limit: 1 });
|
|
198
|
+
const out = text(r);
|
|
199
|
+
assert.match(out, /3#[0-9A-Z]+│alpha only/);
|
|
200
|
+
assert.match(out, /1 matches limit reached/);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("disabled + extended params: still runs, formatted without anchors", async () => {
|
|
205
|
+
await withDir(async (dir) => {
|
|
206
|
+
await seed(dir);
|
|
207
|
+
await withEnabled(false, async () => {
|
|
208
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: ["alpha", "beta"], matchMode: "all" });
|
|
209
|
+
const out = text(r);
|
|
210
|
+
assert.match(out, /a\.ts:1: alpha beta/);
|
|
211
|
+
assert.doesNotMatch(out, /#[0-9A-Z]+│/);
|
|
212
|
+
assert.doesNotMatch(out, /alpha here/);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("disabled + plain params: delegates to the built-in grep", async () => {
|
|
218
|
+
await withDir(async (dir) => {
|
|
219
|
+
await seed(dir);
|
|
220
|
+
await withEnabled(false, async () => {
|
|
221
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "alpha beta" });
|
|
222
|
+
// built-in format: flat `path:line: content`, no group headers
|
|
223
|
+
assert.match(text(r), /a\.ts:1: alpha beta/);
|
|
224
|
+
assert.doesNotMatch(text(r), /· 1 match/);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("CRLF files: display and filters are \r-clean, anchors hash the clean line", async () => {
|
|
230
|
+
await withDir(async (dir) => {
|
|
231
|
+
await writeFile(join(dir, "crlf.ts"), "alpha beta\r\ngamma\r\nalpha only\r\n");
|
|
232
|
+
// anchors must hash the \r-stripped line — same splitLines as read/edit verify against
|
|
233
|
+
const hash = computeLineHash(1, "alpha beta");
|
|
234
|
+
const g: any = await call(makeGrepOverride(dir), { pattern: "alpha" });
|
|
235
|
+
const out = text(g);
|
|
236
|
+
assert.match(out, new RegExp(`1#${hash}│alpha beta`));
|
|
237
|
+
assert.ok(!/[\r]/.test(out), "no carriage returns in output");
|
|
238
|
+
// $-anchored filters must run on the cleaned text: "alpha beta\r" would dodge "beta$"
|
|
239
|
+
const g2: any = await call(makeGrepOverride(dir), { pattern: "alpha", excludePattern: "beta$" });
|
|
240
|
+
assert.match(text(g2), /alpha only/);
|
|
241
|
+
assert.doesNotMatch(text(g2), /alpha beta/);
|
|
242
|
+
// same for matchMode:"all" with a $-anchored pattern
|
|
243
|
+
const g3: any = await call(makeGrepOverride(dir), { pattern: ["alpha", "only$"], matchMode: "all" });
|
|
244
|
+
assert.match(text(g3), /3#[0-9A-Z]+│alpha only/);
|
|
245
|
+
assert.doesNotMatch(text(g3), /alpha beta/);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("literal mode escapes regex metacharacters", async () => {
|
|
250
|
+
await withDir(async (dir) => {
|
|
251
|
+
await writeFile(join(dir, "d.ts"), "a.b\naxb\n");
|
|
252
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "a.b", literal: true });
|
|
253
|
+
const out = text(r);
|
|
254
|
+
assert.match(out, /1#[0-9A-Z]+│a\.b/);
|
|
255
|
+
assert.doesNotMatch(out, /axb/);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("no matches reports cleanly", async () => {
|
|
260
|
+
await withDir(async (dir) => {
|
|
261
|
+
await seed(dir);
|
|
262
|
+
const r: any = await call(makeGrepOverride(dir), { pattern: "zzz" });
|
|
263
|
+
assert.equal(text(r), "No matches found");
|
|
264
|
+
});
|
|
265
|
+
});
|