@mammothb/pi-hashline 0.2.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 +18 -0
- package/index.ts +36 -0
- package/package.json +27 -0
- package/src/apply.ts +255 -0
- package/src/edit.ts +313 -0
- package/src/format.ts +132 -0
- package/src/grep.ts +451 -0
- package/src/input.ts +232 -0
- package/src/messages.ts +127 -0
- package/src/normalize.ts +44 -0
- package/src/parser.ts +415 -0
- package/src/prompt.md +110 -0
- package/src/prompt.ts +59 -0
- package/src/read.ts +239 -0
- package/src/recovery.ts +141 -0
- package/src/snapshots.ts +166 -0
- package/src/tokenizer.ts +394 -0
- package/src/types.ts +109 -0
- package/src/write.ts +120 -0
package/src/format.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashline format primitives: sigils, separators, regex fragments, and
|
|
3
|
+
* display helpers. These are the single source of truth for the parser, the
|
|
4
|
+
* tokenizer, the prompt, and the formal grammar.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
import type { Cursor } from "./types";
|
|
10
|
+
|
|
11
|
+
/** File-section header prefix: `¶path#hash`. */
|
|
12
|
+
export const HL_FILE_PREFIX = "¶";
|
|
13
|
+
|
|
14
|
+
/** Payload sigil for literal body rows. */
|
|
15
|
+
export const HL_PAYLOAD_REPLACE = "+";
|
|
16
|
+
|
|
17
|
+
/** Hunk-header keyword for concrete line replacement. */
|
|
18
|
+
export const HL_REPLACE_KEYWORD = "replace";
|
|
19
|
+
/** Hunk-header sub-keyword: `replace block N:` resolves N to a tree-sitter block range. */
|
|
20
|
+
export const HL_BLOCK_KEYWORD = "block";
|
|
21
|
+
/** Hunk-header keyword for concrete line deletion. */
|
|
22
|
+
export const HL_DELETE_KEYWORD = "delete";
|
|
23
|
+
/** Hunk-header keyword for insertion operations. */
|
|
24
|
+
export const HL_INSERT_KEYWORD = "insert";
|
|
25
|
+
/** Insert position keyword for inserting before a concrete line. */
|
|
26
|
+
export const HL_INSERT_BEFORE = "before";
|
|
27
|
+
/** Insert position keyword for inserting after a concrete line. */
|
|
28
|
+
export const HL_INSERT_AFTER = "after";
|
|
29
|
+
/** Insert position keyword for inserting at the start of the file. */
|
|
30
|
+
export const HL_INSERT_HEAD = "head";
|
|
31
|
+
/** Insert position keyword for inserting at the end of the file. */
|
|
32
|
+
export const HL_INSERT_TAIL = "tail";
|
|
33
|
+
/** Hunk-header terminator for body-bearing operations. */
|
|
34
|
+
export const HL_HEADER_COLON = ":";
|
|
35
|
+
|
|
36
|
+
/** Separator between a hashline file path and its opaque snapshot tag. */
|
|
37
|
+
export const HL_FILE_HASH_SEP = "#";
|
|
38
|
+
|
|
39
|
+
/** Separator between two line numbers in a range, e.g. `5..10`. */
|
|
40
|
+
export const HL_RANGE_SEP = "..";
|
|
41
|
+
|
|
42
|
+
/** Separator between a line number and displayed line content in hashline mode. */
|
|
43
|
+
export const HL_LINE_BODY_SEP = ":";
|
|
44
|
+
|
|
45
|
+
/** Bare positive line-number regex fragment (no decorations, no captures, no anchors). */
|
|
46
|
+
export const HL_LINE_RE_RAW = "[1-9]\\d*";
|
|
47
|
+
|
|
48
|
+
/** Capture-group form of {@link HL_LINE_RE_RAW}. */
|
|
49
|
+
export const HL_LINE_CAPTURE_RE_RAW = `(${HL_LINE_RE_RAW})`;
|
|
50
|
+
|
|
51
|
+
/** Number of hex characters in a content-derived file-hash tag. */
|
|
52
|
+
export const HL_FILE_HASH_LENGTH = 4;
|
|
53
|
+
|
|
54
|
+
/** Canonical uppercase hexadecimal content-hash tag carried by a hashline section header. */
|
|
55
|
+
export const HL_FILE_HASH_RE_RAW = `[0-9A-F]{${HL_FILE_HASH_LENGTH}}`;
|
|
56
|
+
|
|
57
|
+
/** Capture-group form of {@link HL_FILE_HASH_RE_RAW}. */
|
|
58
|
+
export const HL_FILE_HASH_CAPTURE_RE_RAW = `(${HL_FILE_HASH_RE_RAW})`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Representative file-hash tags for use in user-facing error messages and
|
|
62
|
+
* prompt examples.
|
|
63
|
+
*/
|
|
64
|
+
export const HL_FILE_HASH_EXAMPLES = ["1A2B", "3C4D", "9F3E"] as const;
|
|
65
|
+
|
|
66
|
+
/** Format a concrete replacement hunk header. */
|
|
67
|
+
export function formatReplaceHeader(start: number, end: number): string {
|
|
68
|
+
return `${HL_REPLACE_KEYWORD} ${start}${HL_RANGE_SEP}${end}${HL_HEADER_COLON}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Format a concrete deletion hunk header. */
|
|
72
|
+
export function formatDeleteHeader(start: number, end = start): string {
|
|
73
|
+
return start === end
|
|
74
|
+
? `${HL_DELETE_KEYWORD} ${start}`
|
|
75
|
+
: `${HL_DELETE_KEYWORD} ${start}${HL_RANGE_SEP}${end}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Format an insertion hunk header for a cursor position. */
|
|
79
|
+
export function formatInsertHeader(cursor: Cursor): string {
|
|
80
|
+
switch (cursor.kind) {
|
|
81
|
+
case "before_anchor":
|
|
82
|
+
return `${HL_INSERT_KEYWORD} ${HL_INSERT_BEFORE} ${cursor.anchor.line}${HL_HEADER_COLON}`;
|
|
83
|
+
case "after_anchor":
|
|
84
|
+
return `${HL_INSERT_KEYWORD} ${HL_INSERT_AFTER} ${cursor.anchor.line}${HL_HEADER_COLON}`;
|
|
85
|
+
case "bof":
|
|
86
|
+
return `${HL_INSERT_KEYWORD} ${HL_INSERT_HEAD}${HL_HEADER_COLON}`;
|
|
87
|
+
case "eof":
|
|
88
|
+
return `${HL_INSERT_KEYWORD} ${HL_INSERT_TAIL}${HL_HEADER_COLON}`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Normalize text before hashing: trim trailing `[ \t\r]` from every line (and
|
|
94
|
+
* the final line) in a single pass so CRLF endings and display-trimmed lines
|
|
95
|
+
* do not invalidate a tag.
|
|
96
|
+
*/
|
|
97
|
+
function normalizeFileHashText(text: string): string {
|
|
98
|
+
return text.replace(/[ \t\r]+(?=\n|$)/g, "");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Compute the content-derived hash tag carried by a hashline section header.
|
|
103
|
+
* The tag is a 4-hex fingerprint of the whole file's normalized text: any read
|
|
104
|
+
* of byte-identical content mints the same tag, and a follow-up edit anchored
|
|
105
|
+
* at any line validates whenever the live file still hashes to it.
|
|
106
|
+
*/
|
|
107
|
+
export function computeFileHash(text: string): string {
|
|
108
|
+
const normalized = normalizeFileHashText(text);
|
|
109
|
+
const hash = createHash("sha256").update(normalized).digest("hex");
|
|
110
|
+
return hash.slice(0, HL_FILE_HASH_LENGTH).toUpperCase();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Format a hashline section header for a file path and snapshot tag. */
|
|
114
|
+
export function formatHashlineHeader(
|
|
115
|
+
filePath: string,
|
|
116
|
+
fileHash: string,
|
|
117
|
+
): string {
|
|
118
|
+
return `${HL_FILE_PREFIX}${filePath}${HL_FILE_HASH_SEP}${fileHash}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Format a single numbered line as `LINE:TEXT`. */
|
|
122
|
+
export function formatNumberedLine(lineNumber: number, line: string): string {
|
|
123
|
+
return `${lineNumber}${HL_LINE_BODY_SEP}${line}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Format file text with hashline-mode line-number prefixes for display. */
|
|
127
|
+
export function formatNumberedLines(text: string, startLine = 1): string {
|
|
128
|
+
const lines = text.split("\n");
|
|
129
|
+
return lines
|
|
130
|
+
.map((line, i) => formatNumberedLine(startLine + i, line))
|
|
131
|
+
.join("\n");
|
|
132
|
+
}
|
package/src/grep.ts
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hashline grep tool override.
|
|
3
|
+
*
|
|
4
|
+
* Overrides the built-in `grep` tool to emit `¶PATH#TAG` headers for each
|
|
5
|
+
* file with matches. Uses ripgrep (`rg --json`) for fast, gitignore-aware
|
|
6
|
+
* search. After finding matches, reads each matching file to compute its
|
|
7
|
+
* content hash and record a snapshot — so the agent can immediately `edit`
|
|
8
|
+
* files found via grep without re-reading them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { constants } from "node:fs";
|
|
13
|
+
import { access, readFile } from "node:fs/promises";
|
|
14
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
15
|
+
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
|
|
18
|
+
import { computeFileHash, formatHashlineHeader } from "./format";
|
|
19
|
+
import { normalizeToLF } from "./normalize";
|
|
20
|
+
import type { SnapshotStore } from "./snapshots";
|
|
21
|
+
|
|
22
|
+
// ─── Schema ──────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const GrepSchema = Type.Object({
|
|
25
|
+
pattern: Type.String({
|
|
26
|
+
description: "The regex pattern to search for (ripgrep syntax)",
|
|
27
|
+
}),
|
|
28
|
+
path: Type.Optional(
|
|
29
|
+
Type.String({
|
|
30
|
+
description:
|
|
31
|
+
"File or directory to search in (default: current working directory)",
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
glob: Type.Optional(
|
|
35
|
+
Type.String({
|
|
36
|
+
description:
|
|
37
|
+
"Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'",
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
context: Type.Optional(
|
|
41
|
+
Type.Number({
|
|
42
|
+
description:
|
|
43
|
+
"Number of lines to show before and after each match (default: 0)",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
ignoreCase: Type.Optional(
|
|
47
|
+
Type.Boolean({
|
|
48
|
+
description: "Case-insensitive search (default: false)",
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
literal: Type.Optional(
|
|
52
|
+
Type.Boolean({
|
|
53
|
+
description:
|
|
54
|
+
"Treat pattern as literal string instead of regex (default: false)",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ─── Details type ────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export interface GrepToolDetails {
|
|
62
|
+
/** Number of files with matches. */
|
|
63
|
+
filesWithMatches: number;
|
|
64
|
+
/** Total matching lines across all files. */
|
|
65
|
+
totalMatches: number;
|
|
66
|
+
/** Per-file results. */
|
|
67
|
+
files: GrepFileResult[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface GrepFileResult {
|
|
71
|
+
/** Display-relative path. */
|
|
72
|
+
path: string;
|
|
73
|
+
/** Content hash of the full file (for subsequent editing). */
|
|
74
|
+
fileHash: string;
|
|
75
|
+
/** Hashline header for this file snapshot. */
|
|
76
|
+
header: string;
|
|
77
|
+
/** Number of matches in this file. */
|
|
78
|
+
matchCount: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Constants ───────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const DEFAULT_MAX_BYTES = 50 * 1024;
|
|
84
|
+
const DEFAULT_MAX_FILES = 50;
|
|
85
|
+
const MAX_CONCURRENT_READS = 8;
|
|
86
|
+
const GREP_MAX_LINE_LENGTH = 500;
|
|
87
|
+
|
|
88
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function resolveDisplayPath(rawPath: string, cwd: string): string {
|
|
91
|
+
const resolved = resolve(cwd, rawPath);
|
|
92
|
+
try {
|
|
93
|
+
const rel = relative(cwd, resolved);
|
|
94
|
+
if (!rel.startsWith("..") && !isAbsolute(rel)) {
|
|
95
|
+
return rel || ".";
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Fall through.
|
|
99
|
+
}
|
|
100
|
+
return resolved;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── rg invocation ───────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
interface RgMatch {
|
|
106
|
+
path: string;
|
|
107
|
+
lineNumber: number;
|
|
108
|
+
line: string;
|
|
109
|
+
/** True if this is an actual match, false for context lines emitted by rg. */
|
|
110
|
+
isMatch: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface RgFile {
|
|
114
|
+
path: string;
|
|
115
|
+
matches: RgMatch[];
|
|
116
|
+
/** Full file content (lines), populated when context > 0 or for hashing. */
|
|
117
|
+
contentLines?: string[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface RgOptions {
|
|
121
|
+
glob?: string;
|
|
122
|
+
context?: number;
|
|
123
|
+
ignoreCase?: boolean;
|
|
124
|
+
literal?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Run `rg --json` and parse the output into per-file match groups.
|
|
129
|
+
*/
|
|
130
|
+
function runRg(
|
|
131
|
+
pattern: string,
|
|
132
|
+
searchPath: string,
|
|
133
|
+
opts: RgOptions = {},
|
|
134
|
+
signal?: AbortSignal,
|
|
135
|
+
): Promise<{ files: RgFile[]; truncated: boolean }> {
|
|
136
|
+
return new Promise((resolvePromise, reject) => {
|
|
137
|
+
const args = ["--json", "--no-heading", "--with-filename", "--no-messages"];
|
|
138
|
+
|
|
139
|
+
if (opts.ignoreCase) {
|
|
140
|
+
args.push("--ignore-case");
|
|
141
|
+
}
|
|
142
|
+
if (opts.literal) {
|
|
143
|
+
args.push("--fixed-strings");
|
|
144
|
+
}
|
|
145
|
+
if (opts.glob) {
|
|
146
|
+
args.push("--glob", opts.glob);
|
|
147
|
+
}
|
|
148
|
+
if (opts.context && opts.context > 0) {
|
|
149
|
+
args.push("-A", String(opts.context), "-B", String(opts.context));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
args.push(pattern, searchPath);
|
|
153
|
+
|
|
154
|
+
const child = spawn("rg", args, {
|
|
155
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
156
|
+
signal,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const files = new Map<string, RgFile>();
|
|
160
|
+
let totalBytes = 0;
|
|
161
|
+
let truncated = false;
|
|
162
|
+
|
|
163
|
+
const ensureFile = (filePath: string): RgFile => {
|
|
164
|
+
let file = files.get(filePath);
|
|
165
|
+
if (file === undefined) {
|
|
166
|
+
file = { path: filePath, matches: [] };
|
|
167
|
+
files.set(filePath, file);
|
|
168
|
+
}
|
|
169
|
+
return file;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
let stdout = "";
|
|
173
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
174
|
+
stdout += chunk.toString("utf-8");
|
|
175
|
+
|
|
176
|
+
// Process complete lines.
|
|
177
|
+
let newlineIdx = stdout.indexOf("\n");
|
|
178
|
+
while (newlineIdx !== -1) {
|
|
179
|
+
const line = stdout.slice(0, newlineIdx);
|
|
180
|
+
stdout = stdout.slice(newlineIdx + 1);
|
|
181
|
+
|
|
182
|
+
if (totalBytes >= DEFAULT_MAX_BYTES) {
|
|
183
|
+
truncated = true;
|
|
184
|
+
child.kill();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
totalBytes += Buffer.byteLength(line, "utf-8");
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const event = JSON.parse(line);
|
|
191
|
+
if (event.type === "match" || event.type === "context") {
|
|
192
|
+
const filePath = event.data.path.text;
|
|
193
|
+
const file = ensureFile(filePath);
|
|
194
|
+
if (files.size > DEFAULT_MAX_FILES) {
|
|
195
|
+
truncated = true;
|
|
196
|
+
child.kill();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
file.matches.push({
|
|
200
|
+
path: filePath,
|
|
201
|
+
lineNumber: event.data.line_number,
|
|
202
|
+
line: event.data.lines.text.replace(/\n$/, ""),
|
|
203
|
+
isMatch: event.type === "match",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// Skip lines that aren't valid JSON (e.g., partial writes).
|
|
208
|
+
}
|
|
209
|
+
newlineIdx = stdout.indexOf("\n");
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
child.on("close", (code) => {
|
|
214
|
+
// code 0 = matches found, code 1 = no matches, code >1 = error
|
|
215
|
+
if (code !== null && code > 1) {
|
|
216
|
+
reject(new Error(`rg exited with code ${code}`));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
resolvePromise({
|
|
220
|
+
files: [...files.values()],
|
|
221
|
+
truncated,
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
child.on("error", (err) => {
|
|
226
|
+
reject(err);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── Tool creator ────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
export function createGrepTool(
|
|
234
|
+
snapshots: SnapshotStore,
|
|
235
|
+
): ToolDefinition<typeof GrepSchema, GrepToolDetails> {
|
|
236
|
+
return {
|
|
237
|
+
name: "grep",
|
|
238
|
+
label: "Grep",
|
|
239
|
+
description:
|
|
240
|
+
"Search file contents using ripgrep. Results include ¶PATH#TAG headers " +
|
|
241
|
+
"so you can immediately edit matching files without re-reading them. " +
|
|
242
|
+
"Requires ripgrep (rg) to be installed.",
|
|
243
|
+
promptSnippet:
|
|
244
|
+
"Search file contents — matching files get ¶PATH#TAG headers for immediate editing",
|
|
245
|
+
promptGuidelines: [
|
|
246
|
+
"Use grep to find code by pattern. Every matching file starts with a ¶PATH#TAG header — use that tag to edit the file without re-reading it. Use read if you need to see the full file content around the match.",
|
|
247
|
+
"Use glob to filter by file extension (e.g. '*.ts'), context to show surrounding lines, ignoreCase for case-insensitive search, and literal to match a fixed string instead of regex.",
|
|
248
|
+
],
|
|
249
|
+
parameters: GrepSchema,
|
|
250
|
+
|
|
251
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
252
|
+
const {
|
|
253
|
+
pattern,
|
|
254
|
+
path: rawPath,
|
|
255
|
+
glob,
|
|
256
|
+
context,
|
|
257
|
+
ignoreCase,
|
|
258
|
+
literal,
|
|
259
|
+
} = params;
|
|
260
|
+
const searchPath = rawPath ? resolve(ctx.cwd, rawPath) : ctx.cwd;
|
|
261
|
+
const contextValue = context ?? 0;
|
|
262
|
+
|
|
263
|
+
// 1. Run ripgrep.
|
|
264
|
+
let rgFiles: RgFile[];
|
|
265
|
+
let truncated: boolean;
|
|
266
|
+
try {
|
|
267
|
+
const result = await runRg(
|
|
268
|
+
pattern,
|
|
269
|
+
searchPath,
|
|
270
|
+
{ glob, context: contextValue, ignoreCase, literal },
|
|
271
|
+
signal,
|
|
272
|
+
);
|
|
273
|
+
rgFiles = result.files;
|
|
274
|
+
truncated = result.truncated;
|
|
275
|
+
} catch (err: unknown) {
|
|
276
|
+
const message = err instanceof Error ? err.message : "unknown error";
|
|
277
|
+
return {
|
|
278
|
+
content: [
|
|
279
|
+
{
|
|
280
|
+
type: "text",
|
|
281
|
+
text:
|
|
282
|
+
`Grep error: ${message}. ` +
|
|
283
|
+
"Make sure ripgrep (rg) is installed: " +
|
|
284
|
+
"`brew install ripgrep` (macOS) or `apt install ripgrep` (Linux).",
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
details: {
|
|
288
|
+
filesWithMatches: 0,
|
|
289
|
+
totalMatches: 0,
|
|
290
|
+
files: [],
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (rgFiles.length === 0) {
|
|
296
|
+
return {
|
|
297
|
+
content: [
|
|
298
|
+
{
|
|
299
|
+
type: "text",
|
|
300
|
+
text: `No matches found for pattern: ${pattern}`,
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
details: {
|
|
304
|
+
filesWithMatches: 0,
|
|
305
|
+
totalMatches: 0,
|
|
306
|
+
files: [],
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 2. Read each matching file to compute hashes (parallel, limited concurrency).
|
|
312
|
+
const fileResults: GrepFileResult[] = [];
|
|
313
|
+
let totalMatches = 0;
|
|
314
|
+
|
|
315
|
+
// Process files in batches to limit concurrent I/O.
|
|
316
|
+
for (let i = 0; i < rgFiles.length; i += MAX_CONCURRENT_READS) {
|
|
317
|
+
const batch = rgFiles.slice(i, i + MAX_CONCURRENT_READS);
|
|
318
|
+
const batchResults = await Promise.all(
|
|
319
|
+
batch.map(async (rgFile) => {
|
|
320
|
+
const absPath = resolve(ctx.cwd, rgFile.path);
|
|
321
|
+
try {
|
|
322
|
+
await access(absPath, constants.R_OK);
|
|
323
|
+
const rawContent = await readFile(absPath, "utf-8");
|
|
324
|
+
const normalized = normalizeToLF(rawContent);
|
|
325
|
+
const fileHash = computeFileHash(normalized);
|
|
326
|
+
snapshots.record(absPath, normalized);
|
|
327
|
+
const displayPath = resolveDisplayPath(rgFile.path, ctx.cwd);
|
|
328
|
+
|
|
329
|
+
// Store content lines for context display.
|
|
330
|
+
if (contextValue > 0) {
|
|
331
|
+
const lines = normalized.split("\n");
|
|
332
|
+
rgFile.contentLines = lines;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
rgFile,
|
|
337
|
+
displayPath,
|
|
338
|
+
fileHash,
|
|
339
|
+
header: formatHashlineHeader(displayPath, fileHash),
|
|
340
|
+
};
|
|
341
|
+
} catch {
|
|
342
|
+
// File became unreadable between rg and now — skip it.
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
for (const result of batchResults) {
|
|
349
|
+
if (result === null) continue;
|
|
350
|
+
totalMatches += result.rgFile.matches.filter((m) => m.isMatch).length;
|
|
351
|
+
fileResults.push({
|
|
352
|
+
path: result.displayPath,
|
|
353
|
+
fileHash: result.fileHash,
|
|
354
|
+
header: result.header,
|
|
355
|
+
matchCount: result.rgFile.matches.length,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 3. Format output: ¶PATH#TAG header + numbered match lines (with optional context).
|
|
361
|
+
const parts: string[] = [];
|
|
362
|
+
|
|
363
|
+
for (let fi = 0; fi < fileResults.length; fi++) {
|
|
364
|
+
const fr = fileResults[fi];
|
|
365
|
+
if (fr === undefined) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const rgFile = rgFiles.find(
|
|
369
|
+
(f) => f.path.endsWith(fr.path) || fr.path.endsWith(f.path),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
parts.push(fr.header);
|
|
373
|
+
|
|
374
|
+
if (rgFile !== undefined) {
|
|
375
|
+
if (contextValue > 0 && rgFile.contentLines) {
|
|
376
|
+
// Context mode: show surrounding lines for each match.
|
|
377
|
+
const lines = rgFile.contentLines;
|
|
378
|
+
const actualMatches = rgFile.matches.filter((m) => m.isMatch);
|
|
379
|
+
const shown = new Set<number>();
|
|
380
|
+
|
|
381
|
+
for (const match of actualMatches) {
|
|
382
|
+
const start = Math.max(1, match.lineNumber - contextValue);
|
|
383
|
+
const end = Math.min(
|
|
384
|
+
lines.length,
|
|
385
|
+
match.lineNumber + contextValue,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
for (let l = start; l <= end; l++) {
|
|
389
|
+
if (shown.has(l)) continue;
|
|
390
|
+
shown.add(l);
|
|
391
|
+
const text = (lines[l - 1] ?? "").slice(
|
|
392
|
+
0,
|
|
393
|
+
GREP_MAX_LINE_LENGTH,
|
|
394
|
+
);
|
|
395
|
+
const isActualMatch = actualMatches.some(
|
|
396
|
+
(am) => am.lineNumber === l,
|
|
397
|
+
);
|
|
398
|
+
if (isActualMatch) {
|
|
399
|
+
parts.push(`${l}:${text}`);
|
|
400
|
+
} else {
|
|
401
|
+
parts.push(`${l}- ${text}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Separator between match blocks.
|
|
406
|
+
if (match !== actualMatches[actualMatches.length - 1]) {
|
|
407
|
+
parts.push("--");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
// Flat mode: just show match lines.
|
|
412
|
+
for (const match of rgFile.matches) {
|
|
413
|
+
const text = match.line.slice(0, GREP_MAX_LINE_LENGTH);
|
|
414
|
+
parts.push(`${match.lineNumber}:${text}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Add blank line between files for readability.
|
|
420
|
+
if (fi < fileResults.length - 1) {
|
|
421
|
+
parts.push("");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
let output = parts.join("\n");
|
|
426
|
+
|
|
427
|
+
// Truncate if too large.
|
|
428
|
+
const outputBytes = Buffer.byteLength(output, "utf-8");
|
|
429
|
+
if (outputBytes > DEFAULT_MAX_BYTES) {
|
|
430
|
+
output =
|
|
431
|
+
output.slice(0, DEFAULT_MAX_BYTES) +
|
|
432
|
+
"\n\n[Output truncated at 50KB. Narrow your search pattern or path.]";
|
|
433
|
+
truncated = true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (truncated) {
|
|
437
|
+
output +=
|
|
438
|
+
"\n[Search truncated. Use a more specific pattern or narrower path.]";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
content: [{ type: "text", text: output }],
|
|
443
|
+
details: {
|
|
444
|
+
filesWithMatches: fileResults.length,
|
|
445
|
+
totalMatches,
|
|
446
|
+
files: fileResults,
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|