pi-readseek 0.7.6 → 0.7.8
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 +28 -46
- package/dist/index.js +7333 -0
- package/package.json +8 -7
- package/index.ts +0 -147
- package/src/binary-detect.ts +0 -22
- package/src/check.ts +0 -92
- package/src/coerce-obvious-int.ts +0 -41
- package/src/confusable-hyphens.ts +0 -6
- package/src/def.ts +0 -143
- package/src/diff-data.ts +0 -302
- package/src/edit-classify.ts +0 -58
- package/src/edit-diff.ts +0 -359
- package/src/edit-output.ts +0 -110
- package/src/edit-render-helpers.ts +0 -141
- package/src/edit-syntax-validate.ts +0 -65
- package/src/edit.ts +0 -686
- package/src/file-map.ts +0 -17
- package/src/fs-error.ts +0 -33
- package/src/grep-budget.ts +0 -35
- package/src/grep-output.ts +0 -181
- package/src/grep-render-helpers.ts +0 -77
- package/src/grep-symbol-scope.ts +0 -184
- package/src/grep.ts +0 -608
- package/src/hashline.ts +0 -736
- package/src/hover.ts +0 -140
- package/src/path-utils.ts +0 -24
- package/src/pending-diff-preview.ts +0 -269
- package/src/read-local-bundle.ts +0 -75
- package/src/read-output.ts +0 -186
- package/src/read-render-helpers.ts +0 -104
- package/src/read.ts +0 -661
- package/src/readseek/constants.ts +0 -17
- package/src/readseek/enums.ts +0 -38
- package/src/readseek/formatter.ts +0 -350
- package/src/readseek/symbol-error-format.ts +0 -18
- package/src/readseek/symbol-lookup.ts +0 -240
- package/src/readseek/symbol-tree.ts +0 -21
- package/src/readseek/types.ts +0 -49
- package/src/readseek-client.ts +0 -1250
- package/src/readseek-params.ts +0 -39
- package/src/readseek-settings.ts +0 -263
- package/src/readseek-value.ts +0 -184
- package/src/refs-output.ts +0 -47
- package/src/refs.ts +0 -161
- package/src/register-tool.ts +0 -29
- package/src/rename.ts +0 -182
- package/src/replace-symbol.ts +0 -77
- package/src/runtime.ts +0 -3
- package/src/search-output.ts +0 -50
- package/src/search.ts +0 -214
- package/src/session-anchors.ts +0 -34
- package/src/stat-search-path.ts +0 -29
- package/src/syntax-validate-mode.ts +0 -15
- package/src/tool-prompt-metadata.ts +0 -107
- package/src/tool-types.ts +0 -12
- package/src/tui-diff-component.ts +0 -108
- package/src/tui-diff-renderer.ts +0 -92
- package/src/tui-render-utils.ts +0 -286
- package/src/view.ts +0 -158
- package/src/write.ts +0 -357
package/src/grep.ts
DELETED
|
@@ -1,608 +0,0 @@
|
|
|
1
|
-
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { createGrepTool } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { Type } from "@sinclair/typebox";
|
|
7
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
-
|
|
9
|
-
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
10
|
-
import { normalizeToLF, stripBom, hasBareCarriageReturn } from "./edit-diff.js";
|
|
11
|
-
import { looksLikeBinary } from "./binary-detect.js";
|
|
12
|
-
import { ensureHashInit, escapeControlCharsForDisplay } from "./hashline.js";
|
|
13
|
-
import { buildReadSeekLine, buildToolErrorResult, type ReadSeekLine } from "./readseek-value.js";
|
|
14
|
-
import { buildGrepOutput, type GrepOutputEntry, type GrepOutputGroup, type GrepOutputRecord, type GrepScopeWarning } from "./grep-output.js";
|
|
15
|
-
|
|
16
|
-
import { getOrGenerateMap } from "./file-map.js";
|
|
17
|
-
import { scopeGrepGroupsToSymbols } from "./grep-symbol-scope.js";
|
|
18
|
-
import { resolveToCwd } from "./path-utils.js";
|
|
19
|
-
import { throwIfAborted } from "./runtime.js";
|
|
20
|
-
import { formatGrepCallText, formatGrepResultText, GREP_TRUNCATION_THRESHOLD } from "./grep-render-helpers.js";
|
|
21
|
-
import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
|
|
22
|
-
import { clampLineToWidth, clampLinesToWidth, linkedPathLine, linkToolPath, renderErrorResult, renderPendingResult, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
|
|
23
|
-
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
24
|
-
import { optionalIntOrString, registerReadSeekTool } from "./register-tool.js";
|
|
25
|
-
import { searchPathParam } from "./readseek-params.js";
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const grepSchema = Type.Object({
|
|
29
|
-
pattern: Type.String({ description: "Regex pattern; use literal for exact text" }),
|
|
30
|
-
path: searchPathParam(),
|
|
31
|
-
glob: Type.Optional(Type.String({ description: "File-name glob, such as *.ts" })),
|
|
32
|
-
ignoreCase: Type.Optional(Type.Boolean({ description: "Ignore case" })),
|
|
33
|
-
literal: Type.Optional(Type.Boolean({ description: "Treat pattern literally" })),
|
|
34
|
-
context: optionalIntOrString("Surrounding lines for each match"),
|
|
35
|
-
limit: optionalIntOrString("Maximum matches to return"),
|
|
36
|
-
summary: Type.Optional(Type.Boolean({ description: "Return per-file counts" })),
|
|
37
|
-
scope: Type.Optional(
|
|
38
|
-
Type.Literal("symbol", {
|
|
39
|
-
description: "Group matches by enclosing symbol",
|
|
40
|
-
}),
|
|
41
|
-
),
|
|
42
|
-
scopeContext: optionalIntOrString("Context lines within each symbol"),
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
interface GrepParams {
|
|
46
|
-
pattern: string;
|
|
47
|
-
path?: string;
|
|
48
|
-
glob?: string;
|
|
49
|
-
ignoreCase?: boolean;
|
|
50
|
-
literal?: boolean;
|
|
51
|
-
context?: number | string;
|
|
52
|
-
limit?: number | string;
|
|
53
|
-
summary?: boolean;
|
|
54
|
-
scope?: "symbol";
|
|
55
|
-
scopeContext?: number | string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const MATCH_LINE_RE = /^(.*?):(\d+): (.*)$/;
|
|
59
|
-
const CONTEXT_LINE_RE = /^(.*?)-(\d+)- (.*)$/;
|
|
60
|
-
|
|
61
|
-
function parseGrepOutputLine(line: string):
|
|
62
|
-
| { kind: "match"; displayPath: string; lineNumber: number; text: string }
|
|
63
|
-
| { kind: "context"; displayPath: string; lineNumber: number; text: string }
|
|
64
|
-
| null {
|
|
65
|
-
const match = line.match(MATCH_LINE_RE);
|
|
66
|
-
if (match) {
|
|
67
|
-
return {
|
|
68
|
-
kind: "match",
|
|
69
|
-
displayPath: match[1],
|
|
70
|
-
lineNumber: Number.parseInt(match[2], 10),
|
|
71
|
-
text: match[3],
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const context = line.match(CONTEXT_LINE_RE);
|
|
76
|
-
if (context) {
|
|
77
|
-
return {
|
|
78
|
-
kind: "context",
|
|
79
|
-
displayPath: context[1],
|
|
80
|
-
lineNumber: Number.parseInt(context[2], 10),
|
|
81
|
-
text: context[3],
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const GREP_MAX_MATCHES_PER_FILE = 10;
|
|
89
|
-
|
|
90
|
-
type GrepAnchoredEntry = Extract<GrepOutputEntry, { kind: "match" | "context" }>;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Collapse context lines that duplicate a match line by line number (a match
|
|
94
|
-
* supersedes a context entry at the same line), then sort ascending and insert
|
|
95
|
-
* a `--` separator across each line-number gap.
|
|
96
|
-
*/
|
|
97
|
-
function dedupeContextEntries(entries: GrepOutputEntry[]): GrepOutputEntry[] {
|
|
98
|
-
if (entries.length === 0) return entries;
|
|
99
|
-
|
|
100
|
-
const byLine = new Map<number, GrepAnchoredEntry>();
|
|
101
|
-
for (const entry of entries) {
|
|
102
|
-
if (entry.kind === "separator") continue;
|
|
103
|
-
const existing = byLine.get(entry.line.line);
|
|
104
|
-
if (!existing || (entry.kind === "match" && existing.kind === "context")) {
|
|
105
|
-
byLine.set(entry.line.line, entry);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const sorted = [...byLine.entries()].sort(([a], [b]) => a - b);
|
|
110
|
-
const result: GrepOutputEntry[] = [];
|
|
111
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
112
|
-
if (i > 0 && sorted[i][0] > sorted[i - 1][0] + 1) {
|
|
113
|
-
result.push({ kind: "separator", text: "--" });
|
|
114
|
-
}
|
|
115
|
-
result.push(sorted[i][1]);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return result;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Keep at most {@link GREP_MAX_MATCHES_PER_FILE} matches (with their context)
|
|
123
|
-
* per file, appending a `... +N more matches` separator when matches are
|
|
124
|
-
* dropped. The group's `matchCount` retains the pre-truncation total.
|
|
125
|
-
*/
|
|
126
|
-
function truncateGroupEntries(group: GrepOutputGroup): GrepOutputGroup {
|
|
127
|
-
let matchesSeen = 0;
|
|
128
|
-
let truncatedCount = 0;
|
|
129
|
-
const kept: GrepOutputEntry[] = [];
|
|
130
|
-
|
|
131
|
-
for (const entry of group.entries) {
|
|
132
|
-
if (entry.kind === "match") {
|
|
133
|
-
matchesSeen++;
|
|
134
|
-
if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
135
|
-
kept.push(entry);
|
|
136
|
-
} else {
|
|
137
|
-
truncatedCount++;
|
|
138
|
-
}
|
|
139
|
-
} else if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
140
|
-
kept.push(entry);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (truncatedCount > 0) {
|
|
145
|
-
kept.push({ kind: "separator", text: `... +${truncatedCount} more matches` });
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return { ...group, entries: kept };
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function recordsFromGroups(groups: GrepOutputGroup[]): GrepOutputRecord[] {
|
|
152
|
-
return groups.flatMap((group) =>
|
|
153
|
-
group.entries.flatMap((entry) =>
|
|
154
|
-
entry.kind === "separator" ? [] : [{ path: group.absolutePath, ...entry.line, kind: entry.kind }],
|
|
155
|
-
),
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Escape special regex characters in a literal string for use in `new RegExp()`.
|
|
161
|
-
*/
|
|
162
|
-
function escapeForRegex(s: string): string {
|
|
163
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
interface GrepToolOptions {
|
|
167
|
-
onFileAnchored?: FileAnchoredCallback;
|
|
168
|
-
name?: string;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export interface ExecuteGrepOptions {
|
|
172
|
-
toolCallId: string;
|
|
173
|
-
params: unknown;
|
|
174
|
-
signal: AbortSignal | undefined;
|
|
175
|
-
onUpdate: any;
|
|
176
|
-
cwd: string;
|
|
177
|
-
onFileAnchored?: FileAnchoredCallback;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
|
|
181
|
-
const { toolCallId, params, signal, onUpdate, cwd, onFileAnchored } = opts;
|
|
182
|
-
await ensureHashInit();
|
|
183
|
-
const rawParams = params as GrepParams;
|
|
184
|
-
const context = coerceObviousBase10Int(rawParams.context, "context");
|
|
185
|
-
if (!context.ok) {
|
|
186
|
-
return buildToolErrorResult("grep", "invalid-params-combo", context.message);
|
|
187
|
-
}
|
|
188
|
-
const limit = coerceObviousBase10Int(rawParams.limit, "limit");
|
|
189
|
-
if (!limit.ok) {
|
|
190
|
-
return buildToolErrorResult("grep", "invalid-limit", limit.message);
|
|
191
|
-
}
|
|
192
|
-
const scopeContext = coerceObviousBase10Int(rawParams.scopeContext, "scopeContext");
|
|
193
|
-
if (!scopeContext.ok) {
|
|
194
|
-
return buildToolErrorResult("grep", "invalid-params-combo", scopeContext.message);
|
|
195
|
-
}
|
|
196
|
-
if (scopeContext.value !== undefined && rawParams.scope !== "symbol") {
|
|
197
|
-
const message = 'Invalid scopeContext: requires scope: "symbol". For normal surrounding-line context outside symbol scope, use the `context` parameter.';
|
|
198
|
-
return buildToolErrorResult("grep", "invalid-params-combo", message);
|
|
199
|
-
}
|
|
200
|
-
if (scopeContext.value !== undefined && scopeContext.value < 0) {
|
|
201
|
-
const message = `Invalid scopeContext: expected a non-negative integer, received ${scopeContext.value}.`;
|
|
202
|
-
return buildToolErrorResult("grep", "invalid-params-combo", message);
|
|
203
|
-
}
|
|
204
|
-
const p: GrepParams = {
|
|
205
|
-
...rawParams,
|
|
206
|
-
context: context.value,
|
|
207
|
-
limit: limit.value,
|
|
208
|
-
scopeContext: scopeContext.value,
|
|
209
|
-
};
|
|
210
|
-
const builtin = createGrepTool(cwd);
|
|
211
|
-
const result = await builtin.execute(
|
|
212
|
-
toolCallId,
|
|
213
|
-
{
|
|
214
|
-
...p,
|
|
215
|
-
context: context.value,
|
|
216
|
-
limit: limit.value,
|
|
217
|
-
},
|
|
218
|
-
signal,
|
|
219
|
-
onUpdate,
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
const textBlock = result.content?.find(
|
|
223
|
-
(item): item is { type: "text"; text: string } =>
|
|
224
|
-
item.type === "text" && "text" in item && typeof (item as { text?: unknown }).text === "string",
|
|
225
|
-
);
|
|
226
|
-
if (!textBlock?.text) return result;
|
|
227
|
-
|
|
228
|
-
const { path: rawSearchPath } = p;
|
|
229
|
-
const searchPath = resolveToCwd(rawSearchPath || ".", cwd);
|
|
230
|
-
|
|
231
|
-
let searchPathIsDirectory = false;
|
|
232
|
-
try {
|
|
233
|
-
searchPathIsDirectory = (await fsStat(searchPath)).isDirectory();
|
|
234
|
-
} catch {
|
|
235
|
-
searchPathIsDirectory = false;
|
|
236
|
-
}
|
|
237
|
-
// Warn when the user targets a single binary file directly — grep
|
|
238
|
-
// silently skips binary files and would return 0 matches with no
|
|
239
|
-
// indication of why.
|
|
240
|
-
if (!searchPathIsDirectory) {
|
|
241
|
-
try {
|
|
242
|
-
const buf = await fsReadFile(searchPath);
|
|
243
|
-
if (looksLikeBinary(buf)) {
|
|
244
|
-
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.]`;
|
|
245
|
-
return {
|
|
246
|
-
...result,
|
|
247
|
-
content: result.content.map((item) =>
|
|
248
|
-
item === textBlock ? ({ ...item, text: warning } as typeof item) : item,
|
|
249
|
-
),
|
|
250
|
-
details: {
|
|
251
|
-
...(typeof result.details === "object" && result.details !== null ? result.details : {}),
|
|
252
|
-
readSeekValue: {
|
|
253
|
-
tool: "grep",
|
|
254
|
-
summary: !!p.summary,
|
|
255
|
-
totalMatches: 0,
|
|
256
|
-
records: [],
|
|
257
|
-
},
|
|
258
|
-
},
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
} catch {
|
|
262
|
-
// can't read file — let normal flow continue
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const fileCache = new Map<string, string[] | undefined>();
|
|
267
|
-
const bareCRFiles = new Set<string>();
|
|
268
|
-
const getFileLines = async (absolutePath: string): Promise<string[] | undefined> => {
|
|
269
|
-
throwIfAborted(signal);
|
|
270
|
-
if (fileCache.has(absolutePath)) return fileCache.get(absolutePath);
|
|
271
|
-
try {
|
|
272
|
-
const rawBuffer = await fsReadFile(absolutePath);
|
|
273
|
-
if (looksLikeBinary(rawBuffer)) {
|
|
274
|
-
fileCache.set(absolutePath, undefined);
|
|
275
|
-
return undefined;
|
|
276
|
-
}
|
|
277
|
-
const raw = rawBuffer.toString("utf-8");
|
|
278
|
-
if (hasBareCarriageReturn(raw)) bareCRFiles.add(absolutePath);
|
|
279
|
-
const lines = normalizeToLF(stripBom(raw).text).split("\n");
|
|
280
|
-
fileCache.set(absolutePath, lines);
|
|
281
|
-
return lines;
|
|
282
|
-
} catch {
|
|
283
|
-
fileCache.set(absolutePath, undefined);
|
|
284
|
-
return undefined;
|
|
285
|
-
}
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
const toAbsolutePath = (displayPath: string): string => {
|
|
289
|
-
if (searchPathIsDirectory) return path.resolve(searchPath, displayPath);
|
|
290
|
-
return searchPath;
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
const groupsByPath = new Map<string, GrepOutputGroup>();
|
|
294
|
-
const passthroughLines: string[] = [];
|
|
295
|
-
let totalMatches = 0;
|
|
296
|
-
let parsedCount = 0;
|
|
297
|
-
let candidateUnparsedCount = 0;
|
|
298
|
-
const candidateLinePattern = /^.+(?::|-)\d+(?::|-)\s/;
|
|
299
|
-
// Pre-read all matched files with bounded concurrency so the processing
|
|
300
|
-
// loop below hits the cache on every getFileLines call instead of
|
|
301
|
-
// serialising disk I/O.
|
|
302
|
-
if (!p.summary) {
|
|
303
|
-
const pathsToRead = new Set<string>();
|
|
304
|
-
for (const line of textBlock.text.split("\n")) {
|
|
305
|
-
throwIfAborted(signal);
|
|
306
|
-
const parsed = parseGrepOutputLine(line);
|
|
307
|
-
if (parsed && Number.isFinite(parsed.lineNumber) && parsed.lineNumber >= 1) {
|
|
308
|
-
pathsToRead.add(toAbsolutePath(parsed.displayPath));
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
const CONCURRENCY = 8;
|
|
312
|
-
const pathList = [...pathsToRead];
|
|
313
|
-
for (let i = 0; i < pathList.length; i += CONCURRENCY) {
|
|
314
|
-
await Promise.all(pathList.slice(i, i + CONCURRENCY).map((p) => getFileLines(p)));
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const addSummaryMatch = (displayPath: string, absolutePath: string) => {
|
|
319
|
-
let group = groupsByPath.get(displayPath);
|
|
320
|
-
if (!group) {
|
|
321
|
-
group = { displayPath, absolutePath, matchCount: 0, entries: [] };
|
|
322
|
-
groupsByPath.set(displayPath, group);
|
|
323
|
-
}
|
|
324
|
-
group.matchCount++;
|
|
325
|
-
totalMatches++;
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
const addEntry = (displayPath: string, absolutePath: string, kind: "match" | "context", line: ReadSeekLine) => {
|
|
329
|
-
let group = groupsByPath.get(displayPath);
|
|
330
|
-
if (!group) {
|
|
331
|
-
group = { displayPath, absolutePath, matchCount: 0, entries: [] };
|
|
332
|
-
groupsByPath.set(displayPath, group);
|
|
333
|
-
}
|
|
334
|
-
group.entries.push({ kind, line });
|
|
335
|
-
if (kind === "match") {
|
|
336
|
-
group.matchCount++;
|
|
337
|
-
totalMatches++;
|
|
338
|
-
}
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
for (const line of textBlock.text.split("\n")) {
|
|
342
|
-
throwIfAborted(signal);
|
|
343
|
-
const parsed = parseGrepOutputLine(line);
|
|
344
|
-
if (!parsed || !Number.isFinite(parsed.lineNumber) || parsed.lineNumber < 1) {
|
|
345
|
-
if (candidateLinePattern.test(line)) {
|
|
346
|
-
candidateUnparsedCount++;
|
|
347
|
-
}
|
|
348
|
-
const trimmed = line.trim();
|
|
349
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
350
|
-
passthroughLines.push(trimmed);
|
|
351
|
-
}
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
parsedCount++;
|
|
355
|
-
const absolute = toAbsolutePath(parsed.displayPath);
|
|
356
|
-
if (p.summary) {
|
|
357
|
-
if (parsed.kind === "match") {
|
|
358
|
-
addSummaryMatch(parsed.displayPath, absolute);
|
|
359
|
-
}
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
const fileLines = await getFileLines(absolute);
|
|
363
|
-
if (fileLines === undefined) continue;
|
|
364
|
-
// Bare-CR remapping: rg treats the entire bare-CR file as line 1, and the
|
|
365
|
-
// builtin grep tool may strip \r before this code sees the output. So
|
|
366
|
-
// parsed.text is just the first CR-separated fragment and parsed.lineNumber
|
|
367
|
-
// is always 1 — both are wrong for match lines. Only remap when
|
|
368
|
-
// parsed.kind === "match"; context lines are irrelevant here (rg won’t
|
|
369
|
-
// produce them for bare-CR files in any meaningful way).
|
|
370
|
-
if (parsed.kind === "match" && bareCRFiles.has(absolute)) {
|
|
371
|
-
const flags = p.ignoreCase ? "i" : "";
|
|
372
|
-
let patternRe: RegExp | null = null;
|
|
373
|
-
try {
|
|
374
|
-
patternRe = p.literal
|
|
375
|
-
? new RegExp(escapeForRegex(p.pattern), flags)
|
|
376
|
-
: new RegExp(p.pattern, flags);
|
|
377
|
-
} catch {
|
|
378
|
-
// Malformed regex — fall through to normal anchor path
|
|
379
|
-
}
|
|
380
|
-
if (patternRe !== null) {
|
|
381
|
-
let emitted = false;
|
|
382
|
-
for (let i = 0; i < fileLines.length; i++) {
|
|
383
|
-
if (!patternRe.test(fileLines[i])) continue;
|
|
384
|
-
addEntry(parsed.displayPath, absolute, "match", buildReadSeekLine(i + 1, fileLines[i]));
|
|
385
|
-
emitted = true;
|
|
386
|
-
}
|
|
387
|
-
if (emitted) continue;
|
|
388
|
-
// No lines matched — fall through to normal path
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
// Normal (non-bare-CR) path
|
|
392
|
-
const sourceLine = fileLines[parsed.lineNumber - 1] ?? parsed.text;
|
|
393
|
-
const built = buildReadSeekLine(parsed.lineNumber, sourceLine);
|
|
394
|
-
addEntry(parsed.displayPath, absolute, parsed.kind, {
|
|
395
|
-
...built,
|
|
396
|
-
display: escapeControlCharsForDisplay(parsed.text),
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (p.summary && parsedCount === 0 && candidateUnparsedCount > 0) {
|
|
401
|
-
const passthroughDetails =
|
|
402
|
-
typeof result.details === "object" && result.details !== null
|
|
403
|
-
? (result.details as Record<string, unknown>)
|
|
404
|
-
: {};
|
|
405
|
-
return {
|
|
406
|
-
...result,
|
|
407
|
-
details: {
|
|
408
|
-
...passthroughDetails,
|
|
409
|
-
readSeekValue: {
|
|
410
|
-
tool: "grep",
|
|
411
|
-
summary: true,
|
|
412
|
-
totalMatches: 0,
|
|
413
|
-
records: [],
|
|
414
|
-
},
|
|
415
|
-
},
|
|
416
|
-
};
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
if (parsedCount === 0 && candidateUnparsedCount > 0) {
|
|
420
|
-
const warning =
|
|
421
|
-
"[hashline grep passthrough] Unparsed grep format; returned original output.";
|
|
422
|
-
const passthroughDetails =
|
|
423
|
-
typeof result.details === "object" && result.details !== null
|
|
424
|
-
? (result.details as Record<string, unknown>)
|
|
425
|
-
: {};
|
|
426
|
-
return {
|
|
427
|
-
...result,
|
|
428
|
-
content: result.content.map((item) =>
|
|
429
|
-
item === textBlock ? ({ ...item, text: `${textBlock.text}\n\n${warning}` } as typeof item) : item,
|
|
430
|
-
),
|
|
431
|
-
details: {
|
|
432
|
-
...passthroughDetails,
|
|
433
|
-
hashlinePassthrough: true,
|
|
434
|
-
hashlineWarning: warning,
|
|
435
|
-
readSeekValue: {
|
|
436
|
-
tool: "grep",
|
|
437
|
-
summary: !!p.summary,
|
|
438
|
-
totalMatches: 0,
|
|
439
|
-
records: [],
|
|
440
|
-
},
|
|
441
|
-
},
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const summary = !!p.summary;
|
|
446
|
-
const effectiveLimit = typeof p.limit === "number" ? p.limit : 100;
|
|
447
|
-
const groups = [...groupsByPath.values()];
|
|
448
|
-
for (const group of groups) {
|
|
449
|
-
group.entries = dedupeContextEntries(group.entries);
|
|
450
|
-
}
|
|
451
|
-
let renderedGroups: GrepOutputGroup[] =
|
|
452
|
-
totalMatches > GREP_TRUNCATION_THRESHOLD ? groups.map(truncateGroupEntries) : groups;
|
|
453
|
-
let scopeWarnings: GrepScopeWarning[] = [];
|
|
454
|
-
|
|
455
|
-
if (p.scope === "symbol" && !summary) {
|
|
456
|
-
const fileLinesByPath = new Map<string, string[]>();
|
|
457
|
-
const fileMapsByPath = new Map<string, Awaited<ReturnType<typeof getOrGenerateMap>>>();
|
|
458
|
-
|
|
459
|
-
for (const group of renderedGroups) {
|
|
460
|
-
const lines = await getFileLines(group.absolutePath);
|
|
461
|
-
if (lines) fileLinesByPath.set(group.absolutePath, lines);
|
|
462
|
-
fileMapsByPath.set(group.absolutePath, await getOrGenerateMap(group.absolutePath));
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
const scoped = scopeGrepGroupsToSymbols({
|
|
466
|
-
groups: renderedGroups,
|
|
467
|
-
fileLinesByPath,
|
|
468
|
-
fileMapsByPath,
|
|
469
|
-
contextLines: typeof p.context === "number" ? p.context : 0,
|
|
470
|
-
scopeContext: typeof p.scopeContext === "number" ? p.scopeContext : undefined,
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
renderedGroups = scoped.groups;
|
|
474
|
-
scopeWarnings = scoped.warnings;
|
|
475
|
-
}
|
|
476
|
-
const readSeekRecords = recordsFromGroups(renderedGroups);
|
|
477
|
-
const builtOutput = buildGrepOutput({
|
|
478
|
-
summary: !!summary,
|
|
479
|
-
totalMatches,
|
|
480
|
-
groups: renderedGroups,
|
|
481
|
-
limit: effectiveLimit,
|
|
482
|
-
records: readSeekRecords,
|
|
483
|
-
scopeMode: p.scope === "symbol" && !summary ? "symbol" : undefined,
|
|
484
|
-
scopeWarnings,
|
|
485
|
-
passthroughLines,
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
if (!summary && readSeekRecords.length > 0) {
|
|
489
|
-
const anchoredPaths = new Set(readSeekRecords.map((record) => record.path));
|
|
490
|
-
for (const absolutePath of anchoredPaths) {
|
|
491
|
-
onFileAnchored?.(absolutePath);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
const existingDetails =
|
|
496
|
-
typeof result.details === "object" && result.details !== null
|
|
497
|
-
? (result.details as Record<string, unknown>)
|
|
498
|
-
: {};
|
|
499
|
-
const { linesTruncated: _ignoredLinesTruncated, truncation: _ignoredTruncation, ...compactDetails } = existingDetails;
|
|
500
|
-
return {
|
|
501
|
-
...result,
|
|
502
|
-
content: result.content.map((item) =>
|
|
503
|
-
item === textBlock ? ({ ...item, text: builtOutput.text } as typeof item) : item,
|
|
504
|
-
),
|
|
505
|
-
details: {
|
|
506
|
-
...compactDetails,
|
|
507
|
-
readSeekValue: builtOutput.readSeekValue,
|
|
508
|
-
},
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}) {
|
|
513
|
-
const name = options.name ?? "readSeek_grep";
|
|
514
|
-
const promptMetadata = defineToolPromptMetadata({
|
|
515
|
-
promptUrl: new URL("../prompts/grep.md", import.meta.url),
|
|
516
|
-
promptSnippet: "Search plain text or regex with edit-ready anchors",
|
|
517
|
-
registeredName: name,
|
|
518
|
-
});
|
|
519
|
-
const tool = registerReadSeekTool(pi, {
|
|
520
|
-
name,
|
|
521
|
-
label: "grep",
|
|
522
|
-
description: promptMetadata.description,
|
|
523
|
-
parameters: grepSchema,
|
|
524
|
-
promptSnippet: promptMetadata.promptSnippet,
|
|
525
|
-
promptGuidelines: promptMetadata.promptGuidelines,
|
|
526
|
-
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
527
|
-
return executeGrep({
|
|
528
|
-
toolCallId,
|
|
529
|
-
params,
|
|
530
|
-
signal,
|
|
531
|
-
onUpdate,
|
|
532
|
-
cwd: ctx.cwd,
|
|
533
|
-
onFileAnchored: options.onFileAnchored,
|
|
534
|
-
});
|
|
535
|
-
},
|
|
536
|
-
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
537
|
-
const context = rest[0] ?? {};
|
|
538
|
-
const cwd = context.cwd ?? process.cwd();
|
|
539
|
-
const { pattern, suffix } = formatGrepCallText(args);
|
|
540
|
-
const rawPath = typeof args?.path === "string" && args.path !== "." ? args.path : undefined;
|
|
541
|
-
const glob = typeof args?.glob === "string" ? args.glob : undefined;
|
|
542
|
-
let text = `${renderToolLabel(theme, "grep")} ${theme.fg("accent", `/${pattern}/`)}`;
|
|
543
|
-
if (suffix) {
|
|
544
|
-
if (rawPath) {
|
|
545
|
-
text += theme.fg("dim", " in ");
|
|
546
|
-
text += linkToolPath(theme.fg("dim", rawPath), rawPath, cwd);
|
|
547
|
-
if (glob) text += theme.fg("dim", ` ${glob}`);
|
|
548
|
-
} else {
|
|
549
|
-
text += theme.fg("dim", ` in ${suffix}`);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
return new Text(clampLineToWidth(text, context.width), 0, 0);
|
|
553
|
-
},
|
|
554
|
-
renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
|
|
555
|
-
const { isPartial, isError, expanded, cwd, width } = resolveRenderResultContext(options, rest);
|
|
556
|
-
|
|
557
|
-
if (isPartial) return renderPendingResult("pending grep", width, theme);
|
|
558
|
-
|
|
559
|
-
const content = result.content?.[0];
|
|
560
|
-
const textContent = content?.type === "text" ? content.text : "";
|
|
561
|
-
|
|
562
|
-
if (isError || result.isError) return renderErrorResult(textContent, { expanded, width, theme });
|
|
563
|
-
|
|
564
|
-
const readSeekValue = (result.details as any)?.readSeekValue as {
|
|
565
|
-
tool: "grep";
|
|
566
|
-
summary: boolean;
|
|
567
|
-
totalMatches: number;
|
|
568
|
-
records: Array<{ path: string; kind: string }>;
|
|
569
|
-
} | undefined;
|
|
570
|
-
|
|
571
|
-
const hasBinaryWarning = textContent.includes("appears to be a binary file");
|
|
572
|
-
|
|
573
|
-
const fileSet = new Set<string>();
|
|
574
|
-
for (const r of readSeekValue?.records ?? []) {
|
|
575
|
-
if (r.path) fileSet.add(r.path);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
const info = formatGrepResultText({
|
|
579
|
-
totalMatches: readSeekValue?.totalMatches ?? 0,
|
|
580
|
-
summary: readSeekValue?.summary ?? false,
|
|
581
|
-
records: readSeekValue?.records ?? [],
|
|
582
|
-
fileCount: fileSet.size,
|
|
583
|
-
hasBinaryWarning,
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
if (info.noMatches && !hasBinaryWarning) return new Text(summaryLine("no matches", { theme, style: "dim" }), 0, 0);
|
|
587
|
-
const matchCount = readSeekValue?.totalMatches ?? 0;
|
|
588
|
-
const matchWord = matchCount === 1 ? "match" : "matches";
|
|
589
|
-
let text = summaryLine(`${matchCount} ${matchWord} returned`, {
|
|
590
|
-
hidden: !!textContent && !expanded,
|
|
591
|
-
theme,
|
|
592
|
-
style: "success",
|
|
593
|
-
});
|
|
594
|
-
for (const badge of info.badges) text += theme.fg(badge.startsWith("⚠") ? "warning" : "dim", ` ${badge}`);
|
|
595
|
-
if (expanded && readSeekValue?.records) {
|
|
596
|
-
const fileCounts = new Map<string, number>();
|
|
597
|
-
for (const r of readSeekValue.records) if (r.path && r.kind === "match") fileCounts.set(r.path, (fileCounts.get(r.path) ?? 0) + 1);
|
|
598
|
-
for (const [filePath, count] of [...fileCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20)) {
|
|
599
|
-
const display = path.relative(cwd, filePath) || filePath;
|
|
600
|
-
text += `\n${linkedPathLine(theme, filePath, display, cwd, ` (${count})`, width)}`;
|
|
601
|
-
}
|
|
602
|
-
if (fileCounts.size > 20) text += "\n" + theme.fg("muted", ` … and ${fileCounts.size - 20} more files`);
|
|
603
|
-
}
|
|
604
|
-
return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
|
|
605
|
-
},
|
|
606
|
-
});
|
|
607
|
-
return tool;
|
|
608
|
-
}
|