pi-readseek 0.3.18 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/edit.ts +425 -409
- package/src/grep.ts +336 -474
- package/src/read.ts +358 -356
- package/src/readseek-value.ts +10 -2
- package/src/refs.ts +9 -37
- package/src/sg.ts +9 -39
- package/src/tui-render-utils.ts +55 -1
package/src/grep.ts
CHANGED
|
@@ -6,9 +6,9 @@ import path from "path";
|
|
|
6
6
|
import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
|
|
7
7
|
import { normalizeToLF, stripBom, hasBareCarriageReturn } from "./edit-diff.js";
|
|
8
8
|
import { looksLikeBinary } from "./binary-detect.js";
|
|
9
|
-
import { ensureHashInit,
|
|
10
|
-
import { buildReadseekLine, buildToolErrorResult } from "./readseek-value.js";
|
|
11
|
-
import { buildGrepOutput } from "./grep-output.js";
|
|
9
|
+
import { ensureHashInit, escapeControlCharsForDisplay } from "./hashline.js";
|
|
10
|
+
import { buildReadseekLine, buildToolErrorResult, type ReadseekLine } from "./readseek-value.js";
|
|
11
|
+
import { buildGrepOutput, type GrepOutputEntry, type GrepOutputGroup, type GrepOutputRecord, type GrepScopeWarning } from "./grep-output.js";
|
|
12
12
|
|
|
13
13
|
import { getOrGenerateMap } from "./map-cache.js";
|
|
14
14
|
import { scopeGrepGroupsToSymbols } from "./grep-symbol-scope.js";
|
|
@@ -99,177 +99,76 @@ function parseGrepOutputLine(line: string):
|
|
|
99
99
|
return null;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
raw: string;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export interface GrepIRFile {
|
|
108
|
-
path: string;
|
|
109
|
-
matchCount: number;
|
|
110
|
-
lines: GrepIRLine[];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface GrepIR {
|
|
114
|
-
totalMatches: number;
|
|
115
|
-
files: GrepIRFile[];
|
|
116
|
-
}
|
|
102
|
+
const GREP_TRUNCATION_THRESHOLD = 50;
|
|
103
|
+
const GREP_MAX_MATCHES_PER_FILE = 10;
|
|
117
104
|
|
|
118
|
-
|
|
119
|
-
path: string;
|
|
120
|
-
line: number;
|
|
121
|
-
hash: string;
|
|
122
|
-
anchor: string;
|
|
123
|
-
kind: "match" | "context";
|
|
124
|
-
raw: string;
|
|
125
|
-
display: string;
|
|
126
|
-
}
|
|
105
|
+
type GrepAnchoredEntry = Extract<GrepOutputEntry, { kind: "match" | "context" }>;
|
|
127
106
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Collapse context lines that duplicate a match line by line number (a match
|
|
109
|
+
* supersedes a context entry at the same line), then sort ascending and insert
|
|
110
|
+
* a `--` separator across each line-number gap.
|
|
111
|
+
*/
|
|
112
|
+
function dedupeContextEntries(entries: GrepOutputEntry[]): GrepOutputEntry[] {
|
|
113
|
+
if (entries.length === 0) return entries;
|
|
114
|
+
|
|
115
|
+
const byLine = new Map<number, GrepAnchoredEntry>();
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (entry.kind === "separator") continue;
|
|
118
|
+
const existing = byLine.get(entry.line.line);
|
|
119
|
+
if (!existing || (entry.kind === "match" && existing.kind === "context")) {
|
|
120
|
+
byLine.set(entry.line.line, entry);
|
|
138
121
|
}
|
|
139
122
|
}
|
|
140
|
-
return records;
|
|
141
|
-
}
|
|
142
123
|
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
let totalMatches = 0;
|
|
149
|
-
|
|
150
|
-
for (const line of lines) {
|
|
151
|
-
const matchResult = line.match(IR_MATCH_LINE_RE);
|
|
152
|
-
let filePath: string | undefined;
|
|
153
|
-
let kind: "match" | "context" = "context";
|
|
154
|
-
|
|
155
|
-
if (matchResult) {
|
|
156
|
-
filePath = matchResult[1];
|
|
157
|
-
kind = "match";
|
|
158
|
-
totalMatches++;
|
|
159
|
-
} else {
|
|
160
|
-
const contextResult = line.match(IR_CONTEXT_LINE_RE);
|
|
161
|
-
if (contextResult) {
|
|
162
|
-
filePath = contextResult[1];
|
|
163
|
-
kind = "context";
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (!filePath) continue;
|
|
168
|
-
|
|
169
|
-
let file = fileMap.get(filePath);
|
|
170
|
-
if (!file) {
|
|
171
|
-
file = { path: filePath, matchCount: 0, lines: [] };
|
|
172
|
-
fileMap.set(filePath, file);
|
|
124
|
+
const sorted = [...byLine.entries()].sort(([a], [b]) => a - b);
|
|
125
|
+
const result: GrepOutputEntry[] = [];
|
|
126
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
127
|
+
if (i > 0 && sorted[i][0] > sorted[i - 1][0] + 1) {
|
|
128
|
+
result.push({ kind: "separator", text: "--" });
|
|
173
129
|
}
|
|
174
|
-
|
|
175
|
-
file.lines.push({ kind, raw: line });
|
|
176
|
-
if (kind === "match") file.matchCount++;
|
|
130
|
+
result.push(sorted[i][1]);
|
|
177
131
|
}
|
|
178
132
|
|
|
179
|
-
return
|
|
133
|
+
return result;
|
|
180
134
|
}
|
|
181
135
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Keep at most {@link GREP_MAX_MATCHES_PER_FILE} matches (with their context)
|
|
138
|
+
* per file, appending a `... +N more matches` separator when matches are
|
|
139
|
+
* dropped. The group's `matchCount` retains the pre-truncation total.
|
|
140
|
+
*/
|
|
141
|
+
function truncateGroupEntries(group: GrepOutputGroup): GrepOutputGroup {
|
|
142
|
+
let matchesSeen = 0;
|
|
143
|
+
let truncatedCount = 0;
|
|
144
|
+
const kept: GrepOutputEntry[] = [];
|
|
145
|
+
|
|
146
|
+
for (const entry of group.entries) {
|
|
147
|
+
if (entry.kind === "match") {
|
|
148
|
+
matchesSeen++;
|
|
149
|
+
if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
150
|
+
kept.push(entry);
|
|
151
|
+
} else {
|
|
152
|
+
truncatedCount++;
|
|
197
153
|
}
|
|
154
|
+
} else if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
155
|
+
kept.push(entry);
|
|
198
156
|
}
|
|
199
|
-
output = blocks.join("\n");
|
|
200
157
|
}
|
|
201
158
|
|
|
202
|
-
if (
|
|
203
|
-
|
|
159
|
+
if (truncatedCount > 0) {
|
|
160
|
+
kept.push({ kind: "separator", text: `... +${truncatedCount} more matches` });
|
|
204
161
|
}
|
|
205
162
|
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const GREP_TRUNCATION_THRESHOLD = 50;
|
|
210
|
-
const GREP_MAX_MATCHES_PER_FILE = 10;
|
|
211
|
-
|
|
212
|
-
export function truncateGrepIR(ir: GrepIR): GrepIR {
|
|
213
|
-
if (ir.totalMatches <= GREP_TRUNCATION_THRESHOLD) return ir;
|
|
214
|
-
|
|
215
|
-
const files = ir.files.map((file) => {
|
|
216
|
-
let matchesSeen = 0;
|
|
217
|
-
const keptLines: GrepIRLine[] = [];
|
|
218
|
-
let truncatedCount = 0;
|
|
219
|
-
|
|
220
|
-
for (const line of file.lines) {
|
|
221
|
-
if (line.kind === "match") {
|
|
222
|
-
matchesSeen++;
|
|
223
|
-
if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
224
|
-
keptLines.push(line);
|
|
225
|
-
} else {
|
|
226
|
-
truncatedCount++;
|
|
227
|
-
}
|
|
228
|
-
} else if (matchesSeen <= GREP_MAX_MATCHES_PER_FILE) {
|
|
229
|
-
keptLines.push(line);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (truncatedCount > 0) {
|
|
234
|
-
keptLines.push({
|
|
235
|
-
kind: "separator",
|
|
236
|
-
raw: `... +${truncatedCount} more matches`,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return { ...file, lines: keptLines };
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
return { ...ir, files };
|
|
163
|
+
return { ...group, entries: kept };
|
|
244
164
|
}
|
|
245
165
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
for (const line of lines) {
|
|
253
|
-
const match = line.raw.match(LINE_NUM_RE);
|
|
254
|
-
if (!match) continue;
|
|
255
|
-
const lineNum = Number.parseInt(match[1], 10);
|
|
256
|
-
const existing = byLineNum.get(lineNum);
|
|
257
|
-
if (!existing || (line.kind === "match" && existing.kind === "context")) {
|
|
258
|
-
byLineNum.set(lineNum, line);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const sorted = [...byLineNum.entries()].sort(([a], [b]) => a - b);
|
|
263
|
-
const result: GrepIRLine[] = [];
|
|
264
|
-
|
|
265
|
-
for (let i = 0; i < sorted.length; i++) {
|
|
266
|
-
if (i > 0 && sorted[i][0] > sorted[i - 1][0] + 1) {
|
|
267
|
-
result.push({ kind: "separator", raw: "--" });
|
|
268
|
-
}
|
|
269
|
-
result.push(sorted[i][1]);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return result;
|
|
166
|
+
function recordsFromGroups(groups: GrepOutputGroup[]): GrepOutputRecord[] {
|
|
167
|
+
return groups.flatMap((group) =>
|
|
168
|
+
group.entries.flatMap((entry) =>
|
|
169
|
+
entry.kind === "separator" ? [] : [{ path: group.absolutePath, ...entry.line, kind: entry.kind }],
|
|
170
|
+
),
|
|
171
|
+
);
|
|
273
172
|
}
|
|
274
173
|
|
|
275
174
|
/**
|
|
@@ -284,238 +183,87 @@ interface GrepToolOptions {
|
|
|
284
183
|
onFileAnchored?: (absolutePath: string) => void;
|
|
285
184
|
}
|
|
286
185
|
|
|
287
|
-
export
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
const tool = {
|
|
298
|
-
name: "grep",
|
|
299
|
-
label: "grep",
|
|
300
|
-
description: GREP_PROMPT_METADATA.description,
|
|
301
|
-
parameters: grepSchema,
|
|
302
|
-
ptc: toolConfig,
|
|
303
|
-
promptSnippet: GREP_PROMPT_METADATA.promptSnippet,
|
|
304
|
-
promptGuidelines: options.searchGuideline
|
|
305
|
-
? [GREP_PROMPT_METADATA.promptGuidelines[0], options.searchGuideline]
|
|
306
|
-
: GREP_PROMPT_METADATA.promptGuidelines,
|
|
307
|
-
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
308
|
-
await ensureHashInit();
|
|
309
|
-
const rawParams = params as GrepParams;
|
|
310
|
-
const context = coerceObviousBase10Int(rawParams.context, "context");
|
|
311
|
-
if (!context.ok) {
|
|
312
|
-
return buildToolErrorResult("grep", "invalid-params-combo", context.message);
|
|
313
|
-
}
|
|
314
|
-
const limit = coerceObviousBase10Int(rawParams.limit, "limit");
|
|
315
|
-
if (!limit.ok) {
|
|
316
|
-
return buildToolErrorResult("grep", "invalid-limit", limit.message);
|
|
317
|
-
}
|
|
318
|
-
const scopeContext = coerceObviousBase10Int(rawParams.scopeContext, "scopeContext");
|
|
319
|
-
if (!scopeContext.ok) {
|
|
320
|
-
return buildToolErrorResult("grep", "invalid-params-combo", scopeContext.message);
|
|
321
|
-
}
|
|
322
|
-
if (scopeContext.value !== undefined && rawParams.scope !== "symbol") {
|
|
323
|
-
const message = 'Invalid scopeContext: requires scope: "symbol". For normal surrounding-line context outside symbol scope, use the `context` parameter.';
|
|
324
|
-
return buildToolErrorResult("grep", "invalid-params-combo", message);
|
|
325
|
-
}
|
|
326
|
-
if (scopeContext.value !== undefined && scopeContext.value < 0) {
|
|
327
|
-
const message = `Invalid scopeContext: expected a non-negative integer, received ${scopeContext.value}.`;
|
|
328
|
-
return buildToolErrorResult("grep", "invalid-params-combo", message);
|
|
329
|
-
}
|
|
330
|
-
const p: GrepParams = {
|
|
331
|
-
...rawParams,
|
|
332
|
-
context: context.value,
|
|
333
|
-
limit: limit.value,
|
|
334
|
-
scopeContext: scopeContext.value,
|
|
335
|
-
};
|
|
336
|
-
const builtin = createGrepTool(ctx.cwd);
|
|
337
|
-
const result = await builtin.execute(
|
|
338
|
-
toolCallId,
|
|
339
|
-
{
|
|
340
|
-
...p,
|
|
341
|
-
context: context.value,
|
|
342
|
-
limit: limit.value,
|
|
343
|
-
},
|
|
344
|
-
signal,
|
|
345
|
-
onUpdate,
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
const textBlock = result.content?.find(
|
|
349
|
-
(item): item is { type: "text"; text: string } =>
|
|
350
|
-
item.type === "text" && "text" in item && typeof (item as { text?: unknown }).text === "string",
|
|
351
|
-
);
|
|
352
|
-
if (!textBlock?.text) return result;
|
|
186
|
+
export interface ExecuteGrepOptions {
|
|
187
|
+
toolCallId: string;
|
|
188
|
+
params: unknown;
|
|
189
|
+
signal: AbortSignal | undefined;
|
|
190
|
+
onUpdate: any;
|
|
191
|
+
cwd: string;
|
|
192
|
+
onFileAnchored?: (absolutePath: string) => void;
|
|
193
|
+
}
|
|
353
194
|
|
|
354
|
-
|
|
355
|
-
|
|
195
|
+
export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
|
|
196
|
+
const { toolCallId, params, signal, onUpdate, cwd, onFileAnchored } = opts;
|
|
197
|
+
await ensureHashInit();
|
|
198
|
+
const rawParams = params as GrepParams;
|
|
199
|
+
const context = coerceObviousBase10Int(rawParams.context, "context");
|
|
200
|
+
if (!context.ok) {
|
|
201
|
+
return buildToolErrorResult("grep", "invalid-params-combo", context.message);
|
|
202
|
+
}
|
|
203
|
+
const limit = coerceObviousBase10Int(rawParams.limit, "limit");
|
|
204
|
+
if (!limit.ok) {
|
|
205
|
+
return buildToolErrorResult("grep", "invalid-limit", limit.message);
|
|
206
|
+
}
|
|
207
|
+
const scopeContext = coerceObviousBase10Int(rawParams.scopeContext, "scopeContext");
|
|
208
|
+
if (!scopeContext.ok) {
|
|
209
|
+
return buildToolErrorResult("grep", "invalid-params-combo", scopeContext.message);
|
|
210
|
+
}
|
|
211
|
+
if (scopeContext.value !== undefined && rawParams.scope !== "symbol") {
|
|
212
|
+
const message = 'Invalid scopeContext: requires scope: "symbol". For normal surrounding-line context outside symbol scope, use the `context` parameter.';
|
|
213
|
+
return buildToolErrorResult("grep", "invalid-params-combo", message);
|
|
214
|
+
}
|
|
215
|
+
if (scopeContext.value !== undefined && scopeContext.value < 0) {
|
|
216
|
+
const message = `Invalid scopeContext: expected a non-negative integer, received ${scopeContext.value}.`;
|
|
217
|
+
return buildToolErrorResult("grep", "invalid-params-combo", message);
|
|
218
|
+
}
|
|
219
|
+
const p: GrepParams = {
|
|
220
|
+
...rawParams,
|
|
221
|
+
context: context.value,
|
|
222
|
+
limit: limit.value,
|
|
223
|
+
scopeContext: scopeContext.value,
|
|
224
|
+
};
|
|
225
|
+
const builtin = createGrepTool(cwd);
|
|
226
|
+
const result = await builtin.execute(
|
|
227
|
+
toolCallId,
|
|
228
|
+
{
|
|
229
|
+
...p,
|
|
230
|
+
context: context.value,
|
|
231
|
+
limit: limit.value,
|
|
232
|
+
},
|
|
233
|
+
signal,
|
|
234
|
+
onUpdate,
|
|
235
|
+
);
|
|
356
236
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
// Warn when the user targets a single binary file directly — grep
|
|
364
|
-
// silently skips binary files and would return 0 matches with no
|
|
365
|
-
// indication of why.
|
|
366
|
-
if (!searchPathIsDirectory) {
|
|
367
|
-
try {
|
|
368
|
-
const buf = await fsReadFile(searchPath);
|
|
369
|
-
if (looksLikeBinary(buf)) {
|
|
370
|
-
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.]`;
|
|
371
|
-
return {
|
|
372
|
-
...result,
|
|
373
|
-
content: result.content.map((item) =>
|
|
374
|
-
item === textBlock ? ({ ...item, text: warning } as typeof item) : item,
|
|
375
|
-
),
|
|
376
|
-
details: {
|
|
377
|
-
...(typeof result.details === "object" && result.details !== null ? result.details : {}),
|
|
378
|
-
readseekValue: {
|
|
379
|
-
tool: "grep",
|
|
380
|
-
summary: !!p.summary,
|
|
381
|
-
totalMatches: 0,
|
|
382
|
-
records: [],
|
|
383
|
-
},
|
|
384
|
-
},
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
} catch {
|
|
388
|
-
// can't read file — let normal flow continue
|
|
389
|
-
}
|
|
390
|
-
}
|
|
237
|
+
const textBlock = result.content?.find(
|
|
238
|
+
(item): item is { type: "text"; text: string } =>
|
|
239
|
+
item.type === "text" && "text" in item && typeof (item as { text?: unknown }).text === "string",
|
|
240
|
+
);
|
|
241
|
+
if (!textBlock?.text) return result;
|
|
391
242
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const getFileLines = async (absolutePath: string): Promise<string[] | undefined> => {
|
|
395
|
-
throwIfAborted(signal);
|
|
396
|
-
if (fileCache.has(absolutePath)) return fileCache.get(absolutePath);
|
|
397
|
-
try {
|
|
398
|
-
const rawBuffer = await fsReadFile(absolutePath);
|
|
399
|
-
if (looksLikeBinary(rawBuffer)) {
|
|
400
|
-
fileCache.set(absolutePath, undefined);
|
|
401
|
-
return undefined;
|
|
402
|
-
}
|
|
403
|
-
const raw = rawBuffer.toString("utf-8");
|
|
404
|
-
if (hasBareCarriageReturn(raw)) bareCRFiles.add(absolutePath);
|
|
405
|
-
const lines = normalizeToLF(stripBom(raw).text).split("\n");
|
|
406
|
-
fileCache.set(absolutePath, lines);
|
|
407
|
-
return lines;
|
|
408
|
-
} catch {
|
|
409
|
-
fileCache.set(absolutePath, undefined);
|
|
410
|
-
return undefined;
|
|
411
|
-
}
|
|
412
|
-
};
|
|
413
|
-
|
|
414
|
-
const toAbsolutePath = (displayPath: string): string => {
|
|
415
|
-
if (searchPathIsDirectory) return path.resolve(searchPath, displayPath);
|
|
416
|
-
return searchPath;
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
const transformed: string[] = [];
|
|
420
|
-
const passthroughLines: string[] = [];
|
|
421
|
-
const recordByRenderedLine = new Map<string, GrepReadseekRecord>();
|
|
422
|
-
let parsedCount = 0;
|
|
423
|
-
let candidateUnparsedCount = 0;
|
|
424
|
-
const candidateLinePattern = /^.+(?::|-)\d+(?::|-)\s/;
|
|
425
|
-
|
|
426
|
-
for (const line of textBlock.text.split("\n")) {
|
|
427
|
-
throwIfAborted(signal);
|
|
428
|
-
const parsed = parseGrepOutputLine(line);
|
|
429
|
-
if (!parsed || !Number.isFinite(parsed.lineNumber) || parsed.lineNumber < 1) {
|
|
430
|
-
if (candidateLinePattern.test(line)) {
|
|
431
|
-
candidateUnparsedCount++;
|
|
432
|
-
}
|
|
433
|
-
const trimmed = line.trim();
|
|
434
|
-
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
435
|
-
passthroughLines.push(trimmed);
|
|
436
|
-
}
|
|
437
|
-
transformed.push(line);
|
|
438
|
-
continue;
|
|
439
|
-
}
|
|
440
|
-
parsedCount++;
|
|
441
|
-
const absolute = toAbsolutePath(parsed.displayPath);
|
|
442
|
-
const fileLines = await getFileLines(absolute);
|
|
443
|
-
if (fileLines === undefined) continue;
|
|
444
|
-
// Bare-CR remapping: rg treats the entire bare-CR file as line 1, and the
|
|
445
|
-
// builtin grep tool may strip \r before this code sees the output. So
|
|
446
|
-
// parsed.text is just the first CR-separated fragment and parsed.lineNumber
|
|
447
|
-
// is always 1 — both are wrong for match lines. Only remap when
|
|
448
|
-
// parsed.kind === "match"; context lines are irrelevant here (rg won’t
|
|
449
|
-
// produce them for bare-CR files in any meaningful way).
|
|
450
|
-
if (parsed.kind === "match" && bareCRFiles.has(absolute)) {
|
|
451
|
-
const gp = p;
|
|
452
|
-
const flags = gp.ignoreCase ? "i" : "";
|
|
453
|
-
let patternRe: RegExp | null = null;
|
|
454
|
-
try {
|
|
455
|
-
patternRe = gp.literal
|
|
456
|
-
? new RegExp(escapeForRegex(gp.pattern), flags)
|
|
457
|
-
: new RegExp(gp.pattern, flags);
|
|
458
|
-
} catch {
|
|
459
|
-
// Malformed regex — fall through to normal anchor path
|
|
460
|
-
}
|
|
461
|
-
if (patternRe !== null) {
|
|
462
|
-
let emitted = false;
|
|
463
|
-
for (let i = 0; i < fileLines.length; i++) {
|
|
464
|
-
if (!patternRe.test(fileLines[i])) continue;
|
|
465
|
-
const lineNum = i + 1;
|
|
466
|
-
const marker = ">>";
|
|
467
|
-
const renderedLine = `${parsed.displayPath}:${marker}${formatHashlineDisplay(lineNum, fileLines[i])}`;
|
|
468
|
-
transformed.push(renderedLine);
|
|
469
|
-
const built = buildReadseekLine(lineNum, fileLines[i]);
|
|
470
|
-
recordByRenderedLine.set(renderedLine, {
|
|
471
|
-
path: toAbsolutePath(parsed.displayPath),
|
|
472
|
-
line: built.line,
|
|
473
|
-
hash: built.hash,
|
|
474
|
-
anchor: built.anchor,
|
|
475
|
-
kind: "match",
|
|
476
|
-
raw: built.raw,
|
|
477
|
-
display: built.display,
|
|
478
|
-
});
|
|
479
|
-
emitted = true;
|
|
480
|
-
}
|
|
481
|
-
if (emitted) continue;
|
|
482
|
-
// No lines matched — fall through to normal path
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
// Normal (non-bare-CR) path
|
|
486
|
-
const sourceLine = fileLines?.[parsed.lineNumber - 1] ?? parsed.text;
|
|
487
|
-
const built = buildReadseekLine(parsed.lineNumber, sourceLine);
|
|
488
|
-
const marker = parsed.kind === "match" ? ">>" : " ";
|
|
489
|
-
const renderedDisplay = escapeControlCharsForDisplay(parsed.text);
|
|
490
|
-
const renderedLine = `${parsed.displayPath}:${marker}${built.anchor}|${renderedDisplay}`;
|
|
491
|
-
transformed.push(renderedLine);
|
|
492
|
-
recordByRenderedLine.set(renderedLine, {
|
|
493
|
-
path: toAbsolutePath(parsed.displayPath),
|
|
494
|
-
line: built.line,
|
|
495
|
-
hash: built.hash,
|
|
496
|
-
anchor: built.anchor,
|
|
497
|
-
kind: parsed.kind,
|
|
498
|
-
raw: built.raw,
|
|
499
|
-
display: renderedDisplay,
|
|
500
|
-
});
|
|
501
|
-
}
|
|
243
|
+
const { path: rawSearchPath } = p;
|
|
244
|
+
const searchPath = resolveToCwd(rawSearchPath || ".", cwd);
|
|
502
245
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
246
|
+
let searchPathIsDirectory = false;
|
|
247
|
+
try {
|
|
248
|
+
searchPathIsDirectory = (await fsStat(searchPath)).isDirectory();
|
|
249
|
+
} catch {
|
|
250
|
+
searchPathIsDirectory = false;
|
|
251
|
+
}
|
|
252
|
+
// Warn when the user targets a single binary file directly — grep
|
|
253
|
+
// silently skips binary files and would return 0 matches with no
|
|
254
|
+
// indication of why.
|
|
255
|
+
if (!searchPathIsDirectory) {
|
|
256
|
+
try {
|
|
257
|
+
const buf = await fsReadFile(searchPath);
|
|
258
|
+
if (looksLikeBinary(buf)) {
|
|
259
|
+
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.]`;
|
|
510
260
|
return {
|
|
511
261
|
...result,
|
|
512
262
|
content: result.content.map((item) =>
|
|
513
|
-
item === textBlock ? ({ ...item, text:
|
|
263
|
+
item === textBlock ? ({ ...item, text: warning } as typeof item) : item,
|
|
514
264
|
),
|
|
515
265
|
details: {
|
|
516
|
-
...
|
|
517
|
-
hashlinePassthrough: true,
|
|
518
|
-
hashlineWarning: warning,
|
|
266
|
+
...(typeof result.details === "object" && result.details !== null ? result.details : {}),
|
|
519
267
|
readseekValue: {
|
|
520
268
|
tool: "grep",
|
|
521
269
|
summary: !!p.summary,
|
|
@@ -525,119 +273,233 @@ export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}
|
|
|
525
273
|
},
|
|
526
274
|
};
|
|
527
275
|
}
|
|
276
|
+
} catch {
|
|
277
|
+
// can't read file — let normal flow continue
|
|
278
|
+
}
|
|
279
|
+
}
|
|
528
280
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
281
|
+
const fileCache = new Map<string, string[] | undefined>();
|
|
282
|
+
const bareCRFiles = new Set<string>();
|
|
283
|
+
const getFileLines = async (absolutePath: string): Promise<string[] | undefined> => {
|
|
284
|
+
throwIfAborted(signal);
|
|
285
|
+
if (fileCache.has(absolutePath)) return fileCache.get(absolutePath);
|
|
286
|
+
try {
|
|
287
|
+
const rawBuffer = await fsReadFile(absolutePath);
|
|
288
|
+
if (looksLikeBinary(rawBuffer)) {
|
|
289
|
+
fileCache.set(absolutePath, undefined);
|
|
290
|
+
return undefined;
|
|
532
291
|
}
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
292
|
+
const raw = rawBuffer.toString("utf-8");
|
|
293
|
+
if (hasBareCarriageReturn(raw)) bareCRFiles.add(absolutePath);
|
|
294
|
+
const lines = normalizeToLF(stripBom(raw).text).split("\n");
|
|
295
|
+
fileCache.set(absolutePath, lines);
|
|
296
|
+
return lines;
|
|
297
|
+
} catch {
|
|
298
|
+
fileCache.set(absolutePath, undefined);
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const toAbsolutePath = (displayPath: string): string => {
|
|
304
|
+
if (searchPathIsDirectory) return path.resolve(searchPath, displayPath);
|
|
305
|
+
return searchPath;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const groupsByPath = new Map<string, GrepOutputGroup>();
|
|
309
|
+
const passthroughLines: string[] = [];
|
|
310
|
+
let totalMatches = 0;
|
|
311
|
+
let parsedCount = 0;
|
|
312
|
+
let candidateUnparsedCount = 0;
|
|
313
|
+
const candidateLinePattern = /^.+(?::|-)\d+(?::|-)\s/;
|
|
314
|
+
|
|
315
|
+
const addEntry = (displayPath: string, absolutePath: string, kind: "match" | "context", line: ReadseekLine) => {
|
|
316
|
+
let group = groupsByPath.get(displayPath);
|
|
317
|
+
if (!group) {
|
|
318
|
+
group = { displayPath, absolutePath, matchCount: 0, entries: [] };
|
|
319
|
+
groupsByPath.set(displayPath, group);
|
|
549
320
|
}
|
|
550
|
-
|
|
551
|
-
if (
|
|
552
|
-
|
|
321
|
+
group.entries.push({ kind, line });
|
|
322
|
+
if (kind === "match") {
|
|
323
|
+
group.matchCount++;
|
|
324
|
+
totalMatches++;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
for (const line of textBlock.text.split("\n")) {
|
|
329
|
+
throwIfAborted(signal);
|
|
330
|
+
const parsed = parseGrepOutputLine(line);
|
|
331
|
+
if (!parsed || !Number.isFinite(parsed.lineNumber) || parsed.lineNumber < 1) {
|
|
332
|
+
if (candidateLinePattern.test(line)) {
|
|
333
|
+
candidateUnparsedCount++;
|
|
334
|
+
}
|
|
335
|
+
const trimmed = line.trim();
|
|
336
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
337
|
+
passthroughLines.push(trimmed);
|
|
338
|
+
}
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
parsedCount++;
|
|
342
|
+
const absolute = toAbsolutePath(parsed.displayPath);
|
|
343
|
+
const fileLines = await getFileLines(absolute);
|
|
344
|
+
if (fileLines === undefined) continue;
|
|
345
|
+
// Bare-CR remapping: rg treats the entire bare-CR file as line 1, and the
|
|
346
|
+
// builtin grep tool may strip \r before this code sees the output. So
|
|
347
|
+
// parsed.text is just the first CR-separated fragment and parsed.lineNumber
|
|
348
|
+
// is always 1 — both are wrong for match lines. Only remap when
|
|
349
|
+
// parsed.kind === "match"; context lines are irrelevant here (rg won’t
|
|
350
|
+
// produce them for bare-CR files in any meaningful way).
|
|
351
|
+
if (parsed.kind === "match" && bareCRFiles.has(absolute)) {
|
|
352
|
+
const flags = p.ignoreCase ? "i" : "";
|
|
353
|
+
let patternRe: RegExp | null = null;
|
|
354
|
+
try {
|
|
355
|
+
patternRe = p.literal
|
|
356
|
+
? new RegExp(escapeForRegex(p.pattern), flags)
|
|
357
|
+
: new RegExp(p.pattern, flags);
|
|
358
|
+
} catch {
|
|
359
|
+
// Malformed regex — fall through to normal anchor path
|
|
360
|
+
}
|
|
361
|
+
if (patternRe !== null) {
|
|
362
|
+
let emitted = false;
|
|
363
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
364
|
+
if (!patternRe.test(fileLines[i])) continue;
|
|
365
|
+
addEntry(parsed.displayPath, absolute, "match", buildReadseekLine(i + 1, fileLines[i]));
|
|
366
|
+
emitted = true;
|
|
367
|
+
}
|
|
368
|
+
if (emitted) continue;
|
|
369
|
+
// No lines matched — fall through to normal path
|
|
370
|
+
}
|
|
553
371
|
}
|
|
372
|
+
// Normal (non-bare-CR) path
|
|
373
|
+
const sourceLine = fileLines[parsed.lineNumber - 1] ?? parsed.text;
|
|
374
|
+
const built = buildReadseekLine(parsed.lineNumber, sourceLine);
|
|
375
|
+
addEntry(parsed.displayPath, absolute, parsed.kind, {
|
|
376
|
+
...built,
|
|
377
|
+
display: escapeControlCharsForDisplay(parsed.text),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (parsedCount === 0 && candidateUnparsedCount > 0) {
|
|
382
|
+
const warning =
|
|
383
|
+
"[hashline grep passthrough] Unparsed grep format; returned original output.";
|
|
384
|
+
const passthroughDetails =
|
|
385
|
+
typeof result.details === "object" && result.details !== null
|
|
386
|
+
? (result.details as Record<string, unknown>)
|
|
387
|
+
: {};
|
|
554
388
|
return {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
389
|
+
...result,
|
|
390
|
+
content: result.content.map((item) =>
|
|
391
|
+
item === textBlock ? ({ ...item, text: `${textBlock.text}\n\n${warning}` } as typeof item) : item,
|
|
392
|
+
),
|
|
393
|
+
details: {
|
|
394
|
+
...passthroughDetails,
|
|
395
|
+
hashlinePassthrough: true,
|
|
396
|
+
hashlineWarning: warning,
|
|
397
|
+
readseekValue: {
|
|
398
|
+
tool: "grep",
|
|
399
|
+
summary: !!p.summary,
|
|
400
|
+
totalMatches: 0,
|
|
401
|
+
records: [],
|
|
402
|
+
},
|
|
562
403
|
},
|
|
563
404
|
};
|
|
564
|
-
}
|
|
565
|
-
}));
|
|
405
|
+
}
|
|
566
406
|
|
|
567
|
-
|
|
568
|
-
|
|
407
|
+
const summary = p.summary;
|
|
408
|
+
const effectiveLimit = typeof p.limit === "number" ? p.limit : 100;
|
|
409
|
+
const groups = [...groupsByPath.values()];
|
|
410
|
+
for (const group of groups) {
|
|
411
|
+
group.entries = dedupeContextEntries(group.entries);
|
|
412
|
+
}
|
|
413
|
+
let renderedGroups: GrepOutputGroup[] =
|
|
414
|
+
totalMatches > GREP_TRUNCATION_THRESHOLD ? groups.map(truncateGroupEntries) : groups;
|
|
415
|
+
let scopeWarnings: GrepScopeWarning[] = [];
|
|
416
|
+
|
|
417
|
+
if (p.scope === "symbol" && !summary) {
|
|
418
|
+
const fileLinesByPath = new Map<string, string[]>();
|
|
419
|
+
const fileMapsByPath = new Map<string, Awaited<ReturnType<typeof getOrGenerateMap>>>();
|
|
420
|
+
|
|
421
|
+
for (const group of renderedGroups) {
|
|
422
|
+
const lines = await getFileLines(group.absolutePath);
|
|
423
|
+
if (lines) fileLinesByPath.set(group.absolutePath, lines);
|
|
424
|
+
fileMapsByPath.set(group.absolutePath, await getOrGenerateMap(group.absolutePath));
|
|
425
|
+
}
|
|
569
426
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
427
|
+
const scoped = scopeGrepGroupsToSymbols({
|
|
428
|
+
groups: renderedGroups,
|
|
429
|
+
fileLinesByPath,
|
|
430
|
+
fileMapsByPath,
|
|
431
|
+
contextLines: typeof p.context === "number" ? p.context : 0,
|
|
432
|
+
scopeContext: typeof p.scopeContext === "number" ? p.scopeContext : undefined,
|
|
433
|
+
});
|
|
573
434
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if (lines) fileLinesByPath.set(group.absolutePath, lines);
|
|
577
|
-
fileMapsByPath.set(group.absolutePath, await getOrGenerateMap(group.absolutePath));
|
|
435
|
+
renderedGroups = scoped.groups;
|
|
436
|
+
scopeWarnings = scoped.warnings;
|
|
578
437
|
}
|
|
579
|
-
|
|
580
|
-
const
|
|
438
|
+
const readseekRecords = recordsFromGroups(renderedGroups);
|
|
439
|
+
const builtOutput = buildGrepOutput({
|
|
440
|
+
summary: !!summary,
|
|
441
|
+
totalMatches,
|
|
581
442
|
groups: renderedGroups,
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
443
|
+
limit: effectiveLimit,
|
|
444
|
+
records: readseekRecords,
|
|
445
|
+
scopeMode: p.scope === "symbol" && !summary ? "symbol" : undefined,
|
|
446
|
+
scopeWarnings,
|
|
447
|
+
passthroughLines,
|
|
586
448
|
});
|
|
587
449
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
],
|
|
450
|
+
if (!summary && readseekRecords.length > 0) {
|
|
451
|
+
const anchoredPaths = new Set(readseekRecords.map((record) => record.path));
|
|
452
|
+
for (const absolutePath of anchoredPaths) {
|
|
453
|
+
onFileAnchored?.(absolutePath);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const existingDetails =
|
|
458
|
+
typeof result.details === "object" && result.details !== null
|
|
459
|
+
? (result.details as Record<string, unknown>)
|
|
460
|
+
: {};
|
|
461
|
+
const { linesTruncated: _ignoredLinesTruncated, truncation: _ignoredTruncation, ...compactDetails } = existingDetails;
|
|
462
|
+
return {
|
|
463
|
+
...result,
|
|
464
|
+
content: result.content.map((item) =>
|
|
465
|
+
item === textBlock ? ({ ...item, text: builtOutput.text } as typeof item) : item,
|
|
605
466
|
),
|
|
606
|
-
|
|
467
|
+
details: {
|
|
468
|
+
...compactDetails,
|
|
469
|
+
readseekValue: builtOutput.readseekValue,
|
|
470
|
+
},
|
|
471
|
+
};
|
|
607
472
|
}
|
|
608
|
-
const builtOutput = buildGrepOutput({
|
|
609
|
-
summary: !!summary,
|
|
610
|
-
totalMatches: grepIR.totalMatches,
|
|
611
|
-
groups: renderedGroups,
|
|
612
|
-
limit: effectiveLimit,
|
|
613
|
-
records: readseekRecords,
|
|
614
|
-
scopeMode: p.scope === "symbol" && !summary ? "symbol" : undefined,
|
|
615
|
-
scopeWarnings,
|
|
616
|
-
passthroughLines,
|
|
617
|
-
});
|
|
618
473
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
474
|
+
export function registerGrepTool(pi: ExtensionAPI, options: GrepToolOptions = {}) {
|
|
475
|
+
const toolConfig = {
|
|
476
|
+
callable: true,
|
|
477
|
+
enabled: true,
|
|
478
|
+
policy: "read-only" as const,
|
|
479
|
+
readOnly: true,
|
|
480
|
+
pythonName: "grep",
|
|
481
|
+
defaultExposure: "safe-by-default" as const,
|
|
482
|
+
};
|
|
625
483
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
484
|
+
const tool = {
|
|
485
|
+
name: "grep",
|
|
486
|
+
label: "grep",
|
|
487
|
+
description: GREP_PROMPT_METADATA.description,
|
|
488
|
+
parameters: grepSchema,
|
|
489
|
+
ptc: toolConfig,
|
|
490
|
+
promptSnippet: GREP_PROMPT_METADATA.promptSnippet,
|
|
491
|
+
promptGuidelines: options.searchGuideline
|
|
492
|
+
? [GREP_PROMPT_METADATA.promptGuidelines[0], options.searchGuideline]
|
|
493
|
+
: GREP_PROMPT_METADATA.promptGuidelines,
|
|
494
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
495
|
+
return executeGrep({
|
|
496
|
+
toolCallId,
|
|
497
|
+
params,
|
|
498
|
+
signal,
|
|
499
|
+
onUpdate,
|
|
500
|
+
cwd: ctx.cwd,
|
|
501
|
+
onFileAnchored: options.onFileAnchored,
|
|
502
|
+
});
|
|
641
503
|
},
|
|
642
504
|
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
643
505
|
const context = rest[0] ?? {};
|