11import fs from 'fs' ;
2+ import fsAsync , { type FileHandle } from 'fs/promises' ;
23import path from 'path' ;
34import { spawn } from 'child_process' ;
45import { DynamicStructuredTool , type StructuredToolInterface } from '@langchain/core/tools' ;
@@ -13,13 +14,17 @@ const logger = LoggerService.getLogger('Tools/FileSystem/content/grep.ts');
1314const MAX_LINE_LENGTH = 2000 ;
1415const DEFAULT_MAX_MATCHES = 100 ;
1516const MAX_FILE_SIZE = 1 * 1024 * 1024 ; // 1MB - skip large files in Node.js fallback
17+ const MAX_COLUMNS = 4096 ;
1618
1719// ─── 类型 ─────────────────────────────────────────────────────────────────────
1820interface MatchLine { lineNum : number ; text : string ; }
1921interface FileMatches { filePath : string ; mtime : number ; matches : MatchLine [ ] ; }
2022interface 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 流式 + 达到上限即终止)────────────────────────────
2328function 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