pi-readseek 0.1.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/LICENSE +22 -0
- package/README.md +41 -0
- package/index.ts +142 -0
- package/package.json +73 -0
- package/prompts/edit.md +113 -0
- package/prompts/find.md +19 -0
- package/prompts/grep.md +26 -0
- package/prompts/ls.md +11 -0
- package/prompts/read.md +33 -0
- package/prompts/sg.md +25 -0
- package/prompts/write.md +46 -0
- package/src/binary-detect.ts +22 -0
- package/src/binary-resolution.ts +77 -0
- package/src/coerce-obvious-int.ts +39 -0
- package/src/context-application.ts +70 -0
- package/src/context-hygiene.ts +503 -0
- package/src/diff-data.ts +303 -0
- package/src/doom-loop-suggestions.ts +42 -0
- package/src/doom-loop.ts +216 -0
- package/src/edit-classify.ts +190 -0
- package/src/edit-diff.ts +354 -0
- package/src/edit-output.ts +107 -0
- package/src/edit-render-helpers.ts +141 -0
- package/src/edit-syntax-validate.ts +120 -0
- package/src/edit.ts +725 -0
- package/src/find-parsers.ts +89 -0
- package/src/find-stat.ts +36 -0
- package/src/find.ts +613 -0
- package/src/grep-budget.ts +79 -0
- package/src/grep-output.ts +197 -0
- package/src/grep-render-helpers.ts +77 -0
- package/src/grep-symbol-scope.ts +197 -0
- package/src/grep.ts +792 -0
- package/src/hashline.ts +747 -0
- package/src/ls.ts +293 -0
- package/src/map-cache.ts +152 -0
- package/src/path-utils.ts +24 -0
- package/src/pending-diff-preview.ts +269 -0
- package/src/persistent-map-cache.ts +251 -0
- package/src/read-local-bundle.ts +87 -0
- package/src/read-output.ts +212 -0
- package/src/read-render-helpers.ts +104 -0
- package/src/read.ts +748 -0
- package/src/readseek/constants.ts +21 -0
- package/src/readseek/enums.ts +38 -0
- package/src/readseek/formatter.ts +431 -0
- package/src/readseek/language-detect.ts +29 -0
- package/src/readseek/mapper.ts +69 -0
- package/src/readseek/parser-errors.ts +22 -0
- package/src/readseek/parser-loader.ts +83 -0
- package/src/readseek/symbol-error-format.ts +18 -0
- package/src/readseek/symbol-lookup.ts +294 -0
- package/src/readseek/types.ts +79 -0
- package/src/readseek-client.ts +343 -0
- package/src/readseek-error-codes.ts +54 -0
- package/src/readseek-settings.ts +287 -0
- package/src/readseek-value.ts +144 -0
- package/src/replace-symbol.ts +74 -0
- package/src/runtime.ts +3 -0
- package/src/sg-output.ts +88 -0
- package/src/sg.ts +308 -0
- package/src/syntax-validate-mode.ts +25 -0
- package/src/tool-prompt-metadata.ts +76 -0
- package/src/tui-diff-component.ts +86 -0
- package/src/tui-diff-renderer.ts +92 -0
- package/src/tui-render-utils.ts +129 -0
- package/src/write.ts +532 -0
package/src/grep.ts
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createGrepTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { readFile as fsReadFile, stat as fsStat } from "fs/promises";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
7
|
+
import { normalizeToLF, stripBom, hasBareCarriageReturn } from "./edit-diff.js";
|
|
8
|
+
import { looksLikeBinary } from "./binary-detect.js";
|
|
9
|
+
import { ensureHashInit, formatHashlineDisplay, escapeControlCharsForDisplay } from "./hashline.js";
|
|
10
|
+
import { buildReadseekError, buildReadseekLine } from "./readseek-value.js";
|
|
11
|
+
import { buildGrepOutput } from "./grep-output.js";
|
|
12
|
+
import { buildGrepRehydrateDescriptor } from "./context-hygiene.js";
|
|
13
|
+
import { getOrGenerateMap } from "./map-cache.js";
|
|
14
|
+
import { scopeGrepGroupsToSymbols } from "./grep-symbol-scope.js";
|
|
15
|
+
import { resolveToCwd } from "./path-utils.js";
|
|
16
|
+
import { throwIfAborted } from "./runtime.js";
|
|
17
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
18
|
+
import { formatGrepCallText, formatGrepResultText } from "./grep-render-helpers.js";
|
|
19
|
+
import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
|
|
20
|
+
import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine } from "./tui-render-utils.js";
|
|
21
|
+
|
|
22
|
+
const GREP_PROMPT_METADATA = defineToolPromptMetadata({
|
|
23
|
+
promptUrl: new URL("../prompts/grep.md", import.meta.url),
|
|
24
|
+
promptSnippet: "Search file contents and return edit-ready hashline anchors",
|
|
25
|
+
promptGuidelines: [
|
|
26
|
+
"Use grep for text search across files instead of bash grep or rg.",
|
|
27
|
+
"Use grep summary mode when you only need matching files or counts.",
|
|
28
|
+
"Use search instead of grep when the query depends on code structure.",
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const grepSchema = Type.Object({
|
|
33
|
+
pattern: Type.String({ description: "Pattern to search" }),
|
|
34
|
+
path: Type.Optional(Type.String({ description: "Search path" })),
|
|
35
|
+
glob: Type.Optional(Type.String({ description: "Glob filter" })),
|
|
36
|
+
ignoreCase: Type.Optional(Type.Boolean({ description: "Ignore case" })),
|
|
37
|
+
literal: Type.Optional(Type.Boolean({ description: "Treat pattern literally" })),
|
|
38
|
+
context: Type.Optional(
|
|
39
|
+
Type.Union([
|
|
40
|
+
Type.Number({ description: "Context lines" }),
|
|
41
|
+
Type.String({ description: "Context lines" }),
|
|
42
|
+
]),
|
|
43
|
+
),
|
|
44
|
+
limit: Type.Optional(
|
|
45
|
+
Type.Union([
|
|
46
|
+
Type.Number({ description: "Max matches" }),
|
|
47
|
+
Type.String({ description: "Max matches" }),
|
|
48
|
+
]),
|
|
49
|
+
),
|
|
50
|
+
summary: Type.Optional(Type.Boolean({ description: "Return per-file counts" })),
|
|
51
|
+
scope: Type.Optional(
|
|
52
|
+
Type.Literal("symbol", {
|
|
53
|
+
description: "Scope matches to symbols",
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
scopeContext: Type.Optional(
|
|
57
|
+
Type.Union([
|
|
58
|
+
Type.Number({ description: "Symbol context lines" }),
|
|
59
|
+
Type.String({ description: "Symbol context lines" }),
|
|
60
|
+
]),
|
|
61
|
+
),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
interface GrepParams {
|
|
65
|
+
pattern: string;
|
|
66
|
+
path?: string;
|
|
67
|
+
glob?: string;
|
|
68
|
+
ignoreCase?: boolean;
|
|
69
|
+
literal?: boolean;
|
|
70
|
+
context?: number | string;
|
|
71
|
+
limit?: number | string;
|
|
72
|
+
summary?: boolean;
|
|
73
|
+
scope?: "symbol";
|
|
74
|
+
scopeContext?: number | string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const MATCH_LINE_RE = /^(.*):(\d+): (.*)$/;
|
|
78
|
+
const CONTEXT_LINE_RE = /^(.*)-(\d+)- (.*)$/;
|
|
79
|
+
|
|
80
|
+
function parseGrepOutputLine(line: string):
|
|
81
|
+
| { kind: "match"; displayPath: string; lineNumber: number; text: string }
|
|
82
|
+
| { kind: "context"; displayPath: string; lineNumber: number; text: string }
|
|
83
|
+
| null {
|
|
84
|
+
const match = line.match(MATCH_LINE_RE);
|
|
85
|
+
if (match) {
|
|
86
|
+
return {
|
|
87
|
+
kind: "match",
|
|
88
|
+
displayPath: match[1],
|
|
89
|
+
lineNumber: Number.parseInt(match[2], 10),
|
|
90
|
+
text: match[3],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const context = line.match(CONTEXT_LINE_RE);
|
|
95
|
+
if (context) {
|
|
96
|
+
return {
|
|
97
|
+
kind: "context",
|
|
98
|
+
displayPath: context[1],
|
|
99
|
+
lineNumber: Number.parseInt(context[2], 10),
|
|
100
|
+
text: context[3],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface GrepIRLine {
|
|
108
|
+
kind: "match" | "context" | "separator";
|
|
109
|
+
raw: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface GrepIRFile {
|
|
113
|
+
path: string;
|
|
114
|
+
matchCount: number;
|
|
115
|
+
lines: GrepIRLine[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface GrepIR {
|
|
119
|
+
totalMatches: number;
|
|
120
|
+
files: GrepIRFile[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface GrepReadseekRecord {
|
|
124
|
+
path: string;
|
|
125
|
+
line: number;
|
|
126
|
+
hash: string;
|
|
127
|
+
anchor: string;
|
|
128
|
+
kind: "match" | "context";
|
|
129
|
+
raw: string;
|
|
130
|
+
display: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function collectReadseekRecordsFromIR(
|
|
134
|
+
ir: GrepIR,
|
|
135
|
+
recordByRenderedLine: Map<string, GrepReadseekRecord>,
|
|
136
|
+
): GrepReadseekRecord[] {
|
|
137
|
+
const records: GrepReadseekRecord[] = [];
|
|
138
|
+
for (const file of ir.files) {
|
|
139
|
+
for (const line of file.lines) {
|
|
140
|
+
if (line.kind === "separator") continue;
|
|
141
|
+
const record = recordByRenderedLine.get(line.raw);
|
|
142
|
+
if (record) records.push(record);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return records;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const IR_MATCH_LINE_RE = /^(.+?):>>/;
|
|
149
|
+
const IR_CONTEXT_LINE_RE = /^(.+?): /;
|
|
150
|
+
|
|
151
|
+
export function parseGrepIR(lines: string[]): GrepIR {
|
|
152
|
+
const fileMap = new Map<string, GrepIRFile>();
|
|
153
|
+
let totalMatches = 0;
|
|
154
|
+
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
const matchResult = line.match(IR_MATCH_LINE_RE);
|
|
157
|
+
let filePath: string | undefined;
|
|
158
|
+
let kind: "match" | "context" = "context";
|
|
159
|
+
|
|
160
|
+
if (matchResult) {
|
|
161
|
+
filePath = matchResult[1];
|
|
162
|
+
kind = "match";
|
|
163
|
+
totalMatches++;
|
|
164
|
+
} else {
|
|
165
|
+
const contextResult = line.match(IR_CONTEXT_LINE_RE);
|
|
166
|
+
if (contextResult) {
|
|
167
|
+
filePath = contextResult[1];
|
|
168
|
+
kind = "context";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!filePath) continue;
|
|
173
|
+
|
|
174
|
+
let file = fileMap.get(filePath);
|
|
175
|
+
if (!file) {
|
|
176
|
+
file = { path: filePath, matchCount: 0, lines: [] };
|
|
177
|
+
fileMap.set(filePath, file);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
file.lines.push({ kind, raw: line });
|
|
181
|
+
if (kind === "match") file.matchCount++;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { totalMatches, files: [...fileMap.values()] };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function formatGrepOutput(ir: GrepIR, options?: { summary?: boolean; limit?: number }): string {
|
|
188
|
+
const header = `[${ir.totalMatches} matches in ${ir.files.length} files]`;
|
|
189
|
+
if (ir.files.length === 0) return header;
|
|
190
|
+
let output: string;
|
|
191
|
+
if (options?.summary) {
|
|
192
|
+
const fileLines = [...ir.files]
|
|
193
|
+
.sort((a, b) => b.matchCount - a.matchCount)
|
|
194
|
+
.map((f) => `${f.path}: ${f.matchCount} matches`);
|
|
195
|
+
output = [header, ...fileLines].join("\n");
|
|
196
|
+
} else {
|
|
197
|
+
const blocks: string[] = [header];
|
|
198
|
+
for (const file of ir.files) {
|
|
199
|
+
blocks.push(`--- ${file.path} (${file.matchCount} matches) ---`);
|
|
200
|
+
for (const line of file.lines) {
|
|
201
|
+
blocks.push(line.raw);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
output = blocks.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (options?.limit !== undefined && ir.totalMatches === options.limit) {
|
|
208
|
+
output += `\n\n[Results truncated at ${options.limit} matches — refine pattern or increase limit]`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return output;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const GREP_TRUNCATION_THRESHOLD = 50;
|
|
215
|
+
const GREP_MAX_MATCHES_PER_FILE = 10;
|
|
216
|
+
|
|
217
|
+
export function truncateGrepIR(ir: GrepIR): GrepIR {
|
|
218
|
+
if (ir.totalMatches <= GREP_TRUNCATION_THRESHOLD) return ir;
|
|
219
|
+
|
|
220
|
+
const files = ir.files.map((file) => {
|
|
221
|
+
let matchesSeen = 0;
|
|
222
|
+
const keptLines: GrepIRLine[] = [];
|
|
223
|
+
let truncatedCount = 0;
|
|
224
|
+
|
|
225
|
+
for (const line of file.lines) {
|
|
226
|
+
if (line.kind === "match") {
|
|
227
|
+
matchesSeen++;
|
|
228
|
+
if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
229
|
+
keptLines.push(line);
|
|
230
|
+
} else {
|
|
231
|
+
truncatedCount++;
|
|
232
|
+
}
|
|
233
|
+
} else if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
234
|
+
keptLines.push(line);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (truncatedCount > 0) {
|
|
239
|
+
keptLines.push({
|
|
240
|
+
kind: "separator",
|
|
241
|
+
raw: `... +${truncatedCount} more matches`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return { ...file, lines: keptLines };
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
return { ...ir, files };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const LINE_NUM_RE = /(?:>>| )(\d+):/;
|
|
252
|
+
|
|
253
|
+
export function deduplicateContext(lines: GrepIRLine[]): GrepIRLine[] {
|
|
254
|
+
if (lines.length === 0) return lines;
|
|
255
|
+
|
|
256
|
+
const byLineNum = new Map<number, GrepIRLine>();
|
|
257
|
+
for (const line of lines) {
|
|
258
|
+
const match = line.raw.match(LINE_NUM_RE);
|
|
259
|
+
if (!match) continue;
|
|
260
|
+
const lineNum = Number.parseInt(match[1], 10);
|
|
261
|
+
const existing = byLineNum.get(lineNum);
|
|
262
|
+
if (!existing || (line.kind === "match" && existing.kind === "context")) {
|
|
263
|
+
byLineNum.set(lineNum, line);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const sorted = [...byLineNum.entries()].sort(([a], [b]) => a - b);
|
|
268
|
+
const result: GrepIRLine[] = [];
|
|
269
|
+
|
|
270
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
271
|
+
if (i > 0 && sorted[i][0] > sorted[i - 1][0] + 1) {
|
|
272
|
+
result.push({ kind: "separator", raw: "--" });
|
|
273
|
+
}
|
|
274
|
+
result.push(sorted[i][1]);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Escape special regex characters in a literal string for use in `new RegExp()`.
|
|
282
|
+
*/
|
|
283
|
+
function escapeForRegex(s: string): string {
|
|
284
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
interface GrepToolOptions {
|
|
288
|
+
searchGuideline?: string;
|
|
289
|
+
onFileAnchored?: (absolutePath: string) => void;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}) {
|
|
293
|
+
const toolConfig = {
|
|
294
|
+
callable: true,
|
|
295
|
+
enabled: true,
|
|
296
|
+
policy: "read-only" as const,
|
|
297
|
+
readOnly: true,
|
|
298
|
+
pythonName: "grep",
|
|
299
|
+
defaultExposure: "safe-by-default" as const,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const tool = {
|
|
303
|
+
name: "grep",
|
|
304
|
+
label: "grep",
|
|
305
|
+
description: GREP_PROMPT_METADATA.description,
|
|
306
|
+
parameters: grepSchema,
|
|
307
|
+
ptc: toolConfig,
|
|
308
|
+
promptSnippet: GREP_PROMPT_METADATA.promptSnippet,
|
|
309
|
+
promptGuidelines: options.searchGuideline
|
|
310
|
+
? [GREP_PROMPT_METADATA.promptGuidelines[0], options.searchGuideline]
|
|
311
|
+
: GREP_PROMPT_METADATA.promptGuidelines,
|
|
312
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
313
|
+
await ensureHashInit();
|
|
314
|
+
const rawParams = params as GrepParams;
|
|
315
|
+
const context = coerceObviousBase10Int(rawParams.context, "context");
|
|
316
|
+
if (!context.ok) {
|
|
317
|
+
return {
|
|
318
|
+
content: [{ type: "text", text: context.message }],
|
|
319
|
+
isError: true,
|
|
320
|
+
details: {
|
|
321
|
+
readseekValue: {
|
|
322
|
+
tool: "grep",
|
|
323
|
+
ok: false,
|
|
324
|
+
error: buildReadseekError("invalid-params-combo", context.message),
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const limit = coerceObviousBase10Int(rawParams.limit, "limit");
|
|
330
|
+
if (!limit.ok) {
|
|
331
|
+
return {
|
|
332
|
+
content: [{ type: "text", text: limit.message }],
|
|
333
|
+
isError: true,
|
|
334
|
+
details: {
|
|
335
|
+
readseekValue: {
|
|
336
|
+
tool: "grep",
|
|
337
|
+
ok: false,
|
|
338
|
+
error: buildReadseekError("invalid-limit", limit.message),
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
const scopeContext = coerceObviousBase10Int(rawParams.scopeContext, "scopeContext");
|
|
344
|
+
if (!scopeContext.ok) {
|
|
345
|
+
return {
|
|
346
|
+
content: [{ type: "text", text: scopeContext.message }],
|
|
347
|
+
isError: true,
|
|
348
|
+
details: {
|
|
349
|
+
readseekValue: {
|
|
350
|
+
tool: "grep",
|
|
351
|
+
ok: false,
|
|
352
|
+
error: buildReadseekError("invalid-params-combo", scopeContext.message),
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (scopeContext.value !== undefined && rawParams.scope !== "symbol") {
|
|
358
|
+
const message = 'Invalid scopeContext: requires scope: "symbol". For normal surrounding-line context outside symbol scope, use the `context` parameter.';
|
|
359
|
+
return {
|
|
360
|
+
content: [{
|
|
361
|
+
type: "text",
|
|
362
|
+
text: message,
|
|
363
|
+
}],
|
|
364
|
+
isError: true,
|
|
365
|
+
details: {
|
|
366
|
+
readseekValue: {
|
|
367
|
+
tool: "grep",
|
|
368
|
+
ok: false,
|
|
369
|
+
error: buildReadseekError("invalid-params-combo", message),
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
if (scopeContext.value !== undefined && scopeContext.value < 0) {
|
|
375
|
+
const message = `Invalid scopeContext: expected a non-negative integer, received ${scopeContext.value}.`;
|
|
376
|
+
return {
|
|
377
|
+
content: [{ type: "text", text: message }],
|
|
378
|
+
isError: true,
|
|
379
|
+
details: {
|
|
380
|
+
readseekValue: {
|
|
381
|
+
tool: "grep",
|
|
382
|
+
ok: false,
|
|
383
|
+
error: buildReadseekError("invalid-params-combo", message),
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const p: GrepParams = {
|
|
389
|
+
...rawParams,
|
|
390
|
+
context: context.value,
|
|
391
|
+
limit: limit.value,
|
|
392
|
+
scopeContext: scopeContext.value,
|
|
393
|
+
};
|
|
394
|
+
const builtin = createGrepTool(ctx.cwd);
|
|
395
|
+
const result = await builtin.execute(
|
|
396
|
+
toolCallId,
|
|
397
|
+
{
|
|
398
|
+
...p,
|
|
399
|
+
context: context.value,
|
|
400
|
+
limit: limit.value,
|
|
401
|
+
},
|
|
402
|
+
signal,
|
|
403
|
+
onUpdate,
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const textBlock = result.content?.find(
|
|
407
|
+
(item): item is { type: "text"; text: string } =>
|
|
408
|
+
item.type === "text" && "text" in item && typeof (item as { text?: unknown }).text === "string",
|
|
409
|
+
);
|
|
410
|
+
if (!textBlock?.text) return result;
|
|
411
|
+
|
|
412
|
+
const { path: rawSearchPath } = p;
|
|
413
|
+
const searchPath = resolveToCwd(rawSearchPath || ".", ctx.cwd);
|
|
414
|
+
|
|
415
|
+
let searchPathIsDirectory = false;
|
|
416
|
+
try {
|
|
417
|
+
searchPathIsDirectory = (await fsStat(searchPath)).isDirectory();
|
|
418
|
+
} catch {
|
|
419
|
+
searchPathIsDirectory = false;
|
|
420
|
+
}
|
|
421
|
+
// Warn when the user targets a single binary file directly — grep
|
|
422
|
+
// silently skips binary files and would return 0 matches with no
|
|
423
|
+
// indication of why.
|
|
424
|
+
if (!searchPathIsDirectory) {
|
|
425
|
+
try {
|
|
426
|
+
const buf = await fsReadFile(searchPath);
|
|
427
|
+
if (looksLikeBinary(buf)) {
|
|
428
|
+
const warning = `[Warning: '${p.path ?? searchPath}' appears to be a binary file — grep skips binary files by default. Use a hex tool or the read tool to inspect it.]`;
|
|
429
|
+
return {
|
|
430
|
+
...result,
|
|
431
|
+
content: result.content.map((item) =>
|
|
432
|
+
item === textBlock ? ({ ...item, text: warning } as typeof item) : item,
|
|
433
|
+
),
|
|
434
|
+
details: {
|
|
435
|
+
...(typeof result.details === "object" && result.details !== null ? result.details : {}),
|
|
436
|
+
readseekValue: {
|
|
437
|
+
tool: "grep",
|
|
438
|
+
summary: !!p.summary,
|
|
439
|
+
totalMatches: 0,
|
|
440
|
+
records: [],
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
// can't read file — let normal flow continue
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const fileCache = new Map<string, string[] | undefined>();
|
|
451
|
+
const bareCRFiles = new Set<string>();
|
|
452
|
+
const getFileLines = async (absolutePath: string): Promise<string[] | undefined> => {
|
|
453
|
+
throwIfAborted(signal);
|
|
454
|
+
if (fileCache.has(absolutePath)) return fileCache.get(absolutePath);
|
|
455
|
+
try {
|
|
456
|
+
const rawBuffer = await fsReadFile(absolutePath);
|
|
457
|
+
if (looksLikeBinary(rawBuffer)) {
|
|
458
|
+
fileCache.set(absolutePath, undefined);
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
const raw = rawBuffer.toString("utf-8");
|
|
462
|
+
if (hasBareCarriageReturn(raw)) bareCRFiles.add(absolutePath);
|
|
463
|
+
const lines = normalizeToLF(stripBom(raw).text).split("\n");
|
|
464
|
+
fileCache.set(absolutePath, lines);
|
|
465
|
+
return lines;
|
|
466
|
+
} catch {
|
|
467
|
+
fileCache.set(absolutePath, []);
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const toAbsolutePath = (displayPath: string): string => {
|
|
473
|
+
if (searchPathIsDirectory) return path.resolve(searchPath, displayPath);
|
|
474
|
+
return searchPath;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const transformed: string[] = [];
|
|
478
|
+
const passthroughLines: string[] = [];
|
|
479
|
+
const recordByRenderedLine = new Map<string, GrepReadseekRecord>();
|
|
480
|
+
let parsedCount = 0;
|
|
481
|
+
let candidateUnparsedCount = 0;
|
|
482
|
+
const candidateLinePattern = /^.+(?::|-)\d+(?::|-)\s/;
|
|
483
|
+
|
|
484
|
+
for (const line of textBlock.text.split("\n")) {
|
|
485
|
+
throwIfAborted(signal);
|
|
486
|
+
const parsed = parseGrepOutputLine(line);
|
|
487
|
+
if (!parsed || !Number.isFinite(parsed.lineNumber) || parsed.lineNumber < 1) {
|
|
488
|
+
if (candidateLinePattern.test(line)) {
|
|
489
|
+
candidateUnparsedCount++;
|
|
490
|
+
}
|
|
491
|
+
const trimmed = line.trim();
|
|
492
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
493
|
+
passthroughLines.push(trimmed);
|
|
494
|
+
}
|
|
495
|
+
transformed.push(line);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
parsedCount++;
|
|
499
|
+
const absolute = toAbsolutePath(parsed.displayPath);
|
|
500
|
+
const fileLines = await getFileLines(absolute);
|
|
501
|
+
if (fileLines === undefined) continue;
|
|
502
|
+
// Bare-CR remapping: rg treats the entire bare-CR file as line 1, and the
|
|
503
|
+
// builtin grep tool may strip \r before this code sees the output. So
|
|
504
|
+
// parsed.text is just the first CR-separated fragment and parsed.lineNumber
|
|
505
|
+
// is always 1 — both are wrong for match lines. Only remap when
|
|
506
|
+
// parsed.kind === "match"; context lines are irrelevant here (rg won’t
|
|
507
|
+
// produce them for bare-CR files in any meaningful way).
|
|
508
|
+
if (parsed.kind === "match" && bareCRFiles.has(absolute)) {
|
|
509
|
+
const gp = p;
|
|
510
|
+
const flags = gp.ignoreCase ? "i" : "";
|
|
511
|
+
let patternRe: RegExp | null = null;
|
|
512
|
+
try {
|
|
513
|
+
patternRe = gp.literal
|
|
514
|
+
? new RegExp(escapeForRegex(gp.pattern), flags)
|
|
515
|
+
: new RegExp(gp.pattern, flags);
|
|
516
|
+
} catch {
|
|
517
|
+
// Malformed regex — fall through to normal anchor path
|
|
518
|
+
}
|
|
519
|
+
if (patternRe !== null) {
|
|
520
|
+
let emitted = false;
|
|
521
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
522
|
+
if (!patternRe.test(fileLines[i])) continue;
|
|
523
|
+
const lineNum = i + 1;
|
|
524
|
+
const marker = ">>";
|
|
525
|
+
const renderedLine = `${parsed.displayPath}:${marker}${formatHashlineDisplay(lineNum, fileLines[i])}`;
|
|
526
|
+
transformed.push(renderedLine);
|
|
527
|
+
const built = buildReadseekLine(lineNum, fileLines[i]);
|
|
528
|
+
recordByRenderedLine.set(renderedLine, {
|
|
529
|
+
path: toAbsolutePath(parsed.displayPath),
|
|
530
|
+
line: built.line,
|
|
531
|
+
hash: built.hash,
|
|
532
|
+
anchor: built.anchor,
|
|
533
|
+
kind: "match",
|
|
534
|
+
raw: built.raw,
|
|
535
|
+
display: built.display,
|
|
536
|
+
});
|
|
537
|
+
emitted = true;
|
|
538
|
+
}
|
|
539
|
+
if (emitted) continue;
|
|
540
|
+
// No lines matched — fall through to normal path
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Normal (non-bare-CR) path
|
|
544
|
+
const sourceLine = fileLines?.[parsed.lineNumber - 1] ?? parsed.text;
|
|
545
|
+
const built = buildReadseekLine(parsed.lineNumber, sourceLine);
|
|
546
|
+
const marker = parsed.kind === "match" ? ">>" : " ";
|
|
547
|
+
const renderedDisplay = escapeControlCharsForDisplay(parsed.text);
|
|
548
|
+
const renderedLine = `${parsed.displayPath}:${marker}${built.anchor}|${renderedDisplay}`;
|
|
549
|
+
transformed.push(renderedLine);
|
|
550
|
+
recordByRenderedLine.set(renderedLine, {
|
|
551
|
+
path: toAbsolutePath(parsed.displayPath),
|
|
552
|
+
line: built.line,
|
|
553
|
+
hash: built.hash,
|
|
554
|
+
anchor: built.anchor,
|
|
555
|
+
kind: parsed.kind,
|
|
556
|
+
raw: built.raw,
|
|
557
|
+
display: renderedDisplay,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (parsedCount === 0 && candidateUnparsedCount > 0) {
|
|
562
|
+
const warning =
|
|
563
|
+
"[hashline grep passthrough] Unparsed grep format; returned original output.";
|
|
564
|
+
const passthroughDetails =
|
|
565
|
+
typeof result.details === "object" && result.details !== null
|
|
566
|
+
? (result.details as Record<string, unknown>)
|
|
567
|
+
: {};
|
|
568
|
+
return {
|
|
569
|
+
...result,
|
|
570
|
+
content: result.content.map((item) =>
|
|
571
|
+
item === textBlock ? ({ ...item, text: `${textBlock.text}\n\n${warning}` } as typeof item) : item,
|
|
572
|
+
),
|
|
573
|
+
details: {
|
|
574
|
+
...passthroughDetails,
|
|
575
|
+
hashlinePassthrough: true,
|
|
576
|
+
hashlineWarning: warning,
|
|
577
|
+
readseekValue: {
|
|
578
|
+
tool: "grep",
|
|
579
|
+
summary: !!p.summary,
|
|
580
|
+
totalMatches: 0,
|
|
581
|
+
records: [],
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const grepIR = parseGrepIR(transformed);
|
|
588
|
+
for (const file of grepIR.files) {
|
|
589
|
+
file.lines = deduplicateContext(file.lines);
|
|
590
|
+
}
|
|
591
|
+
const truncatedIR = truncateGrepIR(grepIR);
|
|
592
|
+
const summary = p.summary;
|
|
593
|
+
const effectiveLimit = typeof p.limit === "number" ? p.limit : 100;
|
|
594
|
+
const outputIR = summary
|
|
595
|
+
? {
|
|
596
|
+
...truncatedIR,
|
|
597
|
+
files: truncatedIR.files.map((file) => ({ ...file, path: toAbsolutePath(file.path) })),
|
|
598
|
+
}
|
|
599
|
+
: truncatedIR;
|
|
600
|
+
let renderedGroups = outputIR.files.map((file) => ({
|
|
601
|
+
displayPath: file.path,
|
|
602
|
+
absolutePath: summary ? file.path : toAbsolutePath(file.path),
|
|
603
|
+
matchCount: file.matchCount,
|
|
604
|
+
entries: file.lines.map((line) => {
|
|
605
|
+
if (line.kind === "separator") {
|
|
606
|
+
return { kind: "separator" as const, text: line.raw };
|
|
607
|
+
}
|
|
608
|
+
const record = recordByRenderedLine.get(line.raw);
|
|
609
|
+
if (!record) {
|
|
610
|
+
throw new Error(`Missing grep record for rendered line: ${line.raw}`);
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
kind: record.kind,
|
|
614
|
+
line: {
|
|
615
|
+
line: record.line,
|
|
616
|
+
hash: record.hash,
|
|
617
|
+
anchor: record.anchor,
|
|
618
|
+
raw: record.raw,
|
|
619
|
+
display: record.display,
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
}),
|
|
623
|
+
}));
|
|
624
|
+
|
|
625
|
+
let readseekRecords = collectReadseekRecordsFromIR(outputIR, recordByRenderedLine);
|
|
626
|
+
let scopeWarnings: import("./grep-output.js").GrepScopeWarning[] = [];
|
|
627
|
+
|
|
628
|
+
if (p.scope === "symbol" && !summary) {
|
|
629
|
+
const fileLinesByPath = new Map<string, string[]>();
|
|
630
|
+
const fileMapsByPath = new Map<string, Awaited<ReturnType<typeof getOrGenerateMap>>>();
|
|
631
|
+
|
|
632
|
+
for (const group of renderedGroups) {
|
|
633
|
+
const lines = await getFileLines(group.absolutePath);
|
|
634
|
+
if (lines) fileLinesByPath.set(group.absolutePath, lines);
|
|
635
|
+
fileMapsByPath.set(group.absolutePath, await getOrGenerateMap(group.absolutePath));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const scoped = scopeGrepGroupsToSymbols({
|
|
639
|
+
groups: renderedGroups,
|
|
640
|
+
fileLinesByPath,
|
|
641
|
+
fileMapsByPath,
|
|
642
|
+
contextLines: typeof p.context === "number" ? p.context : 0,
|
|
643
|
+
scopeContext: typeof p.scopeContext === "number" ? p.scopeContext : undefined,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
renderedGroups = scoped.groups;
|
|
647
|
+
scopeWarnings = scoped.warnings;
|
|
648
|
+
readseekRecords = renderedGroups.flatMap((group) =>
|
|
649
|
+
group.entries.flatMap((entry) =>
|
|
650
|
+
entry.kind === "separator"
|
|
651
|
+
? []
|
|
652
|
+
: [
|
|
653
|
+
{
|
|
654
|
+
path: group.absolutePath,
|
|
655
|
+
kind: entry.kind,
|
|
656
|
+
line: entry.line.line,
|
|
657
|
+
hash: entry.line.hash,
|
|
658
|
+
anchor: entry.line.anchor,
|
|
659
|
+
raw: entry.line.raw,
|
|
660
|
+
display: entry.line.display,
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
),
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
const builtOutput = buildGrepOutput({
|
|
667
|
+
summary: !!summary,
|
|
668
|
+
totalMatches: grepIR.totalMatches,
|
|
669
|
+
groups: renderedGroups,
|
|
670
|
+
limit: effectiveLimit,
|
|
671
|
+
records: readseekRecords,
|
|
672
|
+
scopeMode: p.scope === "symbol" && !summary ? "symbol" : undefined,
|
|
673
|
+
scopeWarnings,
|
|
674
|
+
passthroughLines,
|
|
675
|
+
rehydrate: buildGrepRehydrateDescriptor({
|
|
676
|
+
pattern: p.pattern,
|
|
677
|
+
path: p.path,
|
|
678
|
+
glob: p.glob,
|
|
679
|
+
literal: p.literal,
|
|
680
|
+
ignoreCase: p.ignoreCase,
|
|
681
|
+
context: p.context,
|
|
682
|
+
summary: p.summary,
|
|
683
|
+
scope: p.scope,
|
|
684
|
+
scopeContext: p.scopeContext,
|
|
685
|
+
}),
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
if (!summary && readseekRecords.length > 0) {
|
|
689
|
+
const anchoredPaths = new Set(readseekRecords.map((record) => record.path));
|
|
690
|
+
for (const absolutePath of anchoredPaths) {
|
|
691
|
+
options.onFileAnchored?.(absolutePath);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const existingDetails =
|
|
696
|
+
typeof result.details === "object" && result.details !== null
|
|
697
|
+
? (result.details as Record<string, unknown>)
|
|
698
|
+
: {};
|
|
699
|
+
const { linesTruncated: _ignoredLinesTruncated, truncation: _ignoredTruncation, ...compactDetails } = existingDetails;
|
|
700
|
+
return {
|
|
701
|
+
...result,
|
|
702
|
+
content: result.content.map((item) =>
|
|
703
|
+
item === textBlock ? ({ ...item, text: builtOutput.text } as typeof item) : item,
|
|
704
|
+
),
|
|
705
|
+
details: {
|
|
706
|
+
...compactDetails,
|
|
707
|
+
readseekValue: builtOutput.readseekValue,
|
|
708
|
+
contextHygiene: builtOutput.contextHygiene,
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
},
|
|
712
|
+
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
713
|
+
const context = rest[0] ?? {};
|
|
714
|
+
const cwd = context.cwd ?? process.cwd();
|
|
715
|
+
const { pattern, suffix } = formatGrepCallText(args);
|
|
716
|
+
const rawPath = typeof args?.path === "string" && args.path !== "." ? args.path : undefined;
|
|
717
|
+
const glob = typeof args?.glob === "string" ? args.glob : undefined;
|
|
718
|
+
let text = `${renderToolLabel(theme, "grep")} ${theme.fg("accent", `/${pattern}/`)}`;
|
|
719
|
+
if (suffix) {
|
|
720
|
+
if (rawPath) {
|
|
721
|
+
text += theme.fg("dim", " in ");
|
|
722
|
+
text += linkToolPath(theme.fg("dim", rawPath), rawPath, cwd);
|
|
723
|
+
if (glob) text += theme.fg("dim", ` ${glob}`);
|
|
724
|
+
} else {
|
|
725
|
+
text += theme.fg("dim", ` in ${suffix}`);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return new Text(clampLineToWidth(text, context.width), 0, 0);
|
|
729
|
+
},
|
|
730
|
+
renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
|
|
731
|
+
const context: { isPartial?: boolean; isError?: boolean; expanded?: boolean; cwd?: string; width?: number } =
|
|
732
|
+
rest[0] ?? options ?? {};
|
|
733
|
+
const isPartial = context.isPartial ?? (options as any)?.isPartial ?? false;
|
|
734
|
+
const isError = context.isError ?? false;
|
|
735
|
+
const expanded = isRendererExpanded(options as any, context as any);
|
|
736
|
+
const cwd = context.cwd ?? process.cwd();
|
|
737
|
+
const width = (context as any).width ?? (options as any)?.width;
|
|
738
|
+
|
|
739
|
+
if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending search")], width).join("\n"), 0, 0);
|
|
740
|
+
|
|
741
|
+
const content = result.content?.[0];
|
|
742
|
+
const textContent = content?.type === "text" ? content.text : "";
|
|
743
|
+
|
|
744
|
+
if (isError || result.isError) {
|
|
745
|
+
const firstLine = textContent.split("\n")[0] || "Error";
|
|
746
|
+
const body = expanded && textContent ? textContent : firstLine;
|
|
747
|
+
return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const readseekValue = (result.details as any)?.readseekValue as {
|
|
751
|
+
tool: "grep";
|
|
752
|
+
summary: boolean;
|
|
753
|
+
totalMatches: number;
|
|
754
|
+
records: Array<{ path: string; kind: string }>;
|
|
755
|
+
} | undefined;
|
|
756
|
+
|
|
757
|
+
const hasBinaryWarning = textContent.includes("appears to be a binary file");
|
|
758
|
+
|
|
759
|
+
const fileSet = new Set<string>();
|
|
760
|
+
for (const r of readseekValue?.records ?? []) {
|
|
761
|
+
if (r.path) fileSet.add(r.path);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const info = formatGrepResultText({
|
|
765
|
+
totalMatches: readseekValue?.totalMatches ?? 0,
|
|
766
|
+
summary: readseekValue?.summary ?? false,
|
|
767
|
+
records: readseekValue?.records ?? [],
|
|
768
|
+
fileCount: fileSet.size,
|
|
769
|
+
hasBinaryWarning,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
if (info.noMatches && !hasBinaryWarning) return new Text(summaryLine("no matches"), 0, 0);
|
|
773
|
+
const matchCount = readseekValue?.totalMatches ?? 0;
|
|
774
|
+
const matchWord = matchCount === 1 ? "match" : "matches";
|
|
775
|
+
let text = summaryLine(`${matchCount} ${matchWord} returned`, { hidden: !!textContent && !expanded });
|
|
776
|
+
for (const badge of info.badges) text += theme.fg(badge.startsWith("⚠") ? "warning" : "dim", ` ${badge}`);
|
|
777
|
+
if (expanded && readseekValue?.records) {
|
|
778
|
+
const fileCounts = new Map<string, number>();
|
|
779
|
+
for (const r of readseekValue.records) if (r.path && r.kind === "match") fileCounts.set(r.path, (fileCounts.get(r.path) ?? 0) + 1);
|
|
780
|
+
for (const [filePath, count] of [...fileCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20)) {
|
|
781
|
+
const display = path.relative(cwd, filePath) || filePath;
|
|
782
|
+
text += "\n" + theme.fg("dim", ` ${display} (${count})`);
|
|
783
|
+
}
|
|
784
|
+
if (fileCounts.size > 20) text += "\n" + theme.fg("muted", ` … and ${fileCounts.size - 20} more files`);
|
|
785
|
+
}
|
|
786
|
+
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
787
|
+
},
|
|
788
|
+
} satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
|
|
789
|
+
|
|
790
|
+
pi.registerTool(tool);
|
|
791
|
+
return tool;
|
|
792
|
+
}
|