Skip to content

Commit 37c39a9

Browse files
author
linyuan.yang
committed
异步grep
1 parent d4f605b commit 37c39a9

1 file changed

Lines changed: 91 additions & 65 deletions

File tree

  • packages/sbot/src/Tools/FileSystem/content

packages/sbot/src/Tools/FileSystem/content/grep.ts

Lines changed: 91 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'fs';
2+
import fsAsync, { type FileHandle } from 'fs/promises';
23
import path from 'path';
34
import { spawn } from 'child_process';
45
import { DynamicStructuredTool, type StructuredToolInterface } from '@langchain/core/tools';
@@ -13,13 +14,17 @@ const logger = LoggerService.getLogger('Tools/FileSystem/content/grep.ts');
1314
const MAX_LINE_LENGTH = 2000;
1415
const DEFAULT_MAX_MATCHES = 100;
1516
const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB - skip large files in Node.js fallback
17+
const MAX_COLUMNS = 4096;
1618

1719
// ─── 类型 ─────────────────────────────────────────────────────────────────────
1820
interface MatchLine { lineNum: number; text: string; }
1921
interface FileMatches { filePath: string; mtime: number; matches: MatchLine[]; }
2022
interface SearchResult { results: FileMatches[]; reachedLimit: boolean; }
2123

22-
// ─── ripgrep 搜索(--json 模式)──────────────────────────────────────────────
24+
// rg 默认排除目录(gitignore 语法,无 / 时匹配任意层级)
25+
const RG_EXCLUDE_ARGS = [...EXCLUDE_DIRS].map(d => `--glob=!${d}`);
26+
27+
// ─── ripgrep 搜索(--json 流式 + 达到上限即终止)────────────────────────────
2328
function searchWithRg(
2429
dir: string,
2530
pattern: string,
@@ -29,114 +34,135 @@ function searchWithRg(
2934
maxMatches: number,
3035
): Promise<SearchResult> {
3136
return new Promise((resolve, reject) => {
32-
const args = ['--json', '--glob=!.git/*'];
37+
const args = ['--json', `--max-columns=${MAX_COLUMNS}`, ...RG_EXCLUDE_ARGS];
3338
if (!useRegex) args.push('--fixed-strings');
3439
if (includeHidden) args.push('--hidden');
3540
if (fileGlob) args.push(`--iglob=${fileGlob}`);
3641
args.push('--', pattern, dir);
3742

3843
const proc = spawn('rg', args);
39-
let stdout = '';
44+
const byFile = new Map<string, FileMatches>();
45+
const fileOrder: string[] = [];
46+
let totalMatches = 0;
47+
let reachedLimit = false;
48+
let killed = false;
49+
let buffer = '';
4050
let stderr = '';
41-
proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
51+
52+
const stop = (limit: boolean) => {
53+
if (killed) return;
54+
killed = true;
55+
if (limit) reachedLimit = true;
56+
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
57+
};
58+
59+
const processLine = (line: string): boolean => {
60+
if (!line) return true;
61+
let parsed: any;
62+
try { parsed = JSON.parse(line); } catch { return true; }
63+
if (parsed.type !== 'match') return true;
64+
65+
const fp: string = parsed.data.path.text;
66+
const lineNum: number = parsed.data.line_number;
67+
const raw: string = (parsed.data.lines?.text ?? '').replace(/\r?\n$/, '');
68+
const text = raw.length > MAX_LINE_LENGTH ? raw.slice(0, MAX_LINE_LENGTH) + '...' : raw;
69+
70+
if (!byFile.has(fp)) {
71+
let mtime = 0;
72+
try { mtime = fs.statSync(fp).mtimeMs; } catch { /* */ }
73+
byFile.set(fp, { filePath: fp, mtime, matches: [] });
74+
fileOrder.push(fp);
75+
}
76+
byFile.get(fp)!.matches.push({ lineNum, text });
77+
totalMatches++;
78+
return totalMatches < maxMatches;
79+
};
80+
81+
proc.stdout.on('data', (d: Buffer) => {
82+
if (killed) return;
83+
buffer += d.toString();
84+
let idx;
85+
while ((idx = buffer.indexOf('\n')) !== -1) {
86+
const line = buffer.slice(0, idx).replace(/\r$/, '');
87+
buffer = buffer.slice(idx + 1);
88+
if (!processLine(line)) { stop(true); return; }
89+
}
90+
// 防御:单行过长时丢弃
91+
if (buffer.length > 10 * MAX_COLUMNS) buffer = '';
92+
});
4293
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
43-
proc.on('error', reject);
94+
proc.on('error', e => { reject(e); });
4495
proc.on('close', code => {
45-
if (code === 1) return resolve({ results: [], reachedLimit: false });
46-
if (code === 2 && !stdout.trim()) return reject(new Error(`ripgrep: ${stderr.trim()}`));
47-
48-
const byFile = new Map<string, FileMatches>();
49-
const fileOrder: string[] = [];
50-
let totalMatches = 0;
51-
let reachedLimit = false;
52-
53-
for (const line of stdout.trim().split(/\r?\n/)) {
54-
if (!line) continue;
55-
let parsed: any;
56-
try { parsed = JSON.parse(line); } catch { continue; }
57-
if (parsed.type !== 'match') continue;
58-
if (totalMatches >= maxMatches) { reachedLimit = true; break; }
59-
60-
const fp: string = parsed.data.path.text;
61-
const lineNum: number = parsed.data.line_number;
62-
const raw: string = parsed.data.lines.text.replace(/\r?\n$/, '');
63-
const text = raw.length > MAX_LINE_LENGTH ? raw.slice(0, MAX_LINE_LENGTH) + '...' : raw;
64-
65-
if (!byFile.has(fp)) {
66-
let mtime = 0;
67-
try { mtime = fs.statSync(fp).mtimeMs; } catch { /* */ }
68-
byFile.set(fp, { filePath: fp, mtime, matches: [] });
69-
fileOrder.push(fp);
70-
}
71-
byFile.get(fp)!.matches.push({ lineNum, text });
72-
totalMatches++;
96+
if (!killed && buffer) processLine(buffer.replace(/\r$/, ''));
97+
// code 1 = 无匹配;被 kill 时 code/signal 不可靠,按 totalMatches 判断
98+
if (!killed && code !== 0 && code !== 1 && totalMatches === 0) {
99+
return reject(new Error(`ripgrep: ${stderr.trim() || `exit ${code}`}`));
73100
}
74-
75101
const results = fileOrder.map(fp => byFile.get(fp)!);
76102
results.sort((a, b) => b.mtime - a.mtime);
77103
resolve({ results, reachedLimit });
78104
});
79105
});
80106
}
81107

82-
// ─── Node.js fallback ─────────────────────────────────────────────────────────
83-
function isBinary(fp: string): boolean {
108+
// ─── Node.js fallback(全异步,避免阻塞事件循环)────────────────────────────
109+
async function isBinaryAsync(fp: string): Promise<boolean> {
110+
let fh: FileHandle | undefined;
84111
try {
112+
fh = await fsAsync.open(fp, 'r');
85113
const buf = Buffer.alloc(512);
86-
const fd = fs.openSync(fp, 'r');
87-
const read = fs.readSync(fd, buf, 0, 512, 0);
88-
fs.closeSync(fd);
89-
return buf.subarray(0, read).includes(0);
114+
const { bytesRead } = await fh.read(buf, 0, 512, 0);
115+
return buf.subarray(0, bytesRead).includes(0);
90116
} catch { return true; }
117+
finally { try { await fh?.close(); } catch { /* ignore */ } }
91118
}
92119

93-
function searchWithNodeJs(
120+
async function searchWithNodeJs(
94121
dir: string,
95122
pattern: string,
96123
fileRegex: RegExp,
97124
useRegex: boolean,
98125
includeHidden: boolean,
99126
maxFileSize: number,
100127
maxMatches: number,
101-
): SearchResult {
128+
): Promise<SearchResult> {
102129
const searchRegex = useRegex ? new RegExp(pattern) : null;
103-
const searchLower = pattern;
104130

105131
const allFiles: Array<{ path: string; mtime: number }> = [];
106-
function walk(d: string) {
107-
try {
108-
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
109-
if (!includeHidden && entry.name.startsWith('.')) continue;
110-
const full = path.join(d, entry.name);
111-
if (entry.isDirectory()) {
112-
if (EXCLUDE_DIRS.has(entry.name)) continue;
113-
walk(full);
114-
} else if (entry.isFile() && fileRegex.test(entry.name)) {
115-
try {
116-
const stat = fs.statSync(full);
117-
if (stat.size <= maxFileSize) allFiles.push({ path: full, mtime: stat.mtimeMs });
118-
} catch { /* skip */ }
119-
}
132+
async function walk(d: string): Promise<void> {
133+
let entries;
134+
try { entries = await fsAsync.readdir(d, { withFileTypes: true }); }
135+
catch (e: any) { logger.warn(`Cannot access ${d}: ${e.message}`); return; }
136+
for (const entry of entries) {
137+
if (!includeHidden && entry.name.startsWith('.')) continue;
138+
const full = path.join(d, entry.name);
139+
if (entry.isDirectory()) {
140+
if (EXCLUDE_DIRS.has(entry.name)) continue;
141+
await walk(full);
142+
} else if (entry.isFile() && fileRegex.test(entry.name)) {
143+
try {
144+
const stat = await fsAsync.stat(full);
145+
if (stat.size <= maxFileSize) allFiles.push({ path: full, mtime: stat.mtimeMs });
146+
} catch { /* skip */ }
120147
}
121-
} catch (e: any) { logger.warn(`Cannot access ${d}: ${e.message}`); }
148+
}
122149
}
123-
walk(dir);
150+
await walk(dir);
124151
allFiles.sort((a, b) => b.mtime - a.mtime);
125152

126153
const results: FileMatches[] = [];
127154
let totalMatches = 0;
128155
let reachedLimit = false;
129156

130157
outer: for (const { path: fp, mtime } of allFiles) {
131-
if (isBinary(fp)) continue;
158+
if (await isBinaryAsync(fp)) continue;
132159
try {
133160
const fileMatches: MatchLine[] = [];
134-
const lines = fs.readFileSync(fp, 'utf8').split('\n');
161+
const content = await fsAsync.readFile(fp, 'utf8');
162+
const lines = content.split('\n');
135163
for (let i = 0; i < lines.length; i++) {
136164
const line = lines[i];
137-
const hit = searchRegex
138-
? searchRegex.test(line)
139-
: line.includes(searchLower);
165+
const hit = searchRegex ? searchRegex.test(line) : line.includes(pattern);
140166
if (!hit) continue;
141167
const text = line.length > MAX_LINE_LENGTH ? line.slice(0, MAX_LINE_LENGTH) + '...' : line;
142168
fileMatches.push({ lineNum: i + 1, text });
@@ -195,7 +221,7 @@ export function createGrepFilesTool(): StructuredToolInterface {
195221
result = await searchWithRg(abs, pattern, useRegex, includeHidden, glob, maxMatches);
196222
} else {
197223
const fileRegex = globToRegex(glob ?? '*');
198-
result = searchWithNodeJs(abs, pattern, fileRegex, useRegex, includeHidden, MAX_FILE_SIZE, maxMatches);
224+
result = await searchWithNodeJs(abs, pattern, fileRegex, useRegex, includeHidden, MAX_FILE_SIZE, maxMatches);
199225
}
200226

201227
if (result.results.length === 0) return createSuccessResult(createTextContent('No matches found'));

0 commit comments

Comments
 (0)