1- import { execFile } from 'node:child_process' ;
1+ import { spawn } from 'node:child_process' ;
22import fs from 'node:fs' ;
33import path from 'node:path' ;
44import { rgPath } from '@vscode/ripgrep' ;
@@ -69,9 +69,13 @@ function runRipgrep(query: string, cwd: string, opts: KeywordSearchOpts): Promis
6969 '--json' ,
7070 opts . caseStrict ? '--case-sensitive' : '--smart-case' ,
7171 '--fixed-strings' ,
72- '--max-count' , String ( RG_PER_FILE_CAP ) ,
7372 '--max-filesize' , '5M' ,
7473 ] ;
74+ // `--max-count` budgets ripgrep's own hits, which are still unfiltered.
75+ // Whole-token filtering runs app-side, so a file whose first
76+ // RG_PER_FILE_CAP hits are all substring-only would report nothing while
77+ // real whole-token matches sit further down. Cap after filtering instead.
78+ if ( ! opts . wholeWord ) args . push ( '--max-count' , String ( RG_PER_FILE_CAP ) ) ;
7579 const selected = searchExtensionsForTypes ( opts . types ?? [ ] ) ;
7680 const directExtensions = selected == null
7781 ? DIRECT_TEXT_EXTENSIONS . map ( ( extension ) => `.${ extension } ` )
@@ -80,55 +84,80 @@ function runRipgrep(query: string, cwd: string, opts: KeywordSearchOpts): Promis
8084 ) ;
8185 for ( const extension of directExtensions ) args . push ( '--iglob' , `*${ extension } ` ) ;
8286 args . push ( '-e' , query , opts . pathPrefix ? `./${ opts . pathPrefix } ` : '.' ) ;
83- execFile ( RESOLVED_RG_PATH , args , {
87+ const child = spawn ( RESOLVED_RG_PATH , args , {
8488 cwd,
85- maxBuffer : 32 * 1024 * 1024 ,
86- timeout : RG_TIMEOUT_MS ,
87- } , ( err , stdout ) => {
88- if ( err ) {
89- const code = ( err as NodeJS . ErrnoException & { code ?: number | string } ) . code ;
90- const codeStr = String ( code ?? '' ) ;
91- if ( codeStr !== '1' ) {
92- if ( codeStr === '2' ) {
93- return reject ( new Error ( `invalid query: ${ query } ` ) ) ;
94- }
95- return reject ( new Error ( `ripgrep failed (code ${ codeStr } ): ${ err . message } ` ) ) ;
96- }
97- }
98- const byFile = new Map < string , KeywordHitFile > ( ) ;
99- let total = 0 ;
100- let truncated = false ;
101- for ( const line of stdout . split ( '\n' ) ) {
102- if ( ! line ) continue ;
103- let evt : any ;
104- try { evt = JSON . parse ( line ) ; } catch { continue ; }
105- if ( evt . type !== 'match' ) continue ;
106- const dataPath = evt . data ?. path ?. text ;
107- const lineNum = evt . data ?. line_number ;
108- const rawText = evt . data ?. lines ?. text ;
109- if ( typeof dataPath !== 'string' || typeof lineNum !== 'number' || typeof rawText !== 'string' ) continue ;
110- const relPath = normalizeRipgrepPath ( dataPath ) ;
111- const stripped = rawText . replace ( / \r ? \n $ / , '' ) ;
112- const subs = Array . isArray ( evt . data ?. submatches ) ? evt . data . submatches : [ ] ;
113- const matchRanges = normalizeRipgrepSubmatches ( stripped , subs )
114- . filter ( ( [ start , end ] ) => ! opts . wholeWord || hasWholeTokenBoundaries ( stripped , start , end ) ) ;
115- if ( matchRanges . length === 0 ) continue ;
116- const snippet = snippetForLine ( stripped , matchRanges ) ;
117- let bucket = byFile . get ( relPath ) ;
118- if ( ! bucket ) {
119- bucket = { path : relPath , matches : [ ] , totalMatches : 0 } ;
120- byFile . set ( relPath , bucket ) ;
121- }
122- bucket . totalMatches += matchRanges . length ;
123- if ( total < RG_TOTAL_CAP ) {
124- bucket . matches . push ( { line : lineNum , text : snippet . text , ranges : snippet . ranges } ) ;
125- total += matchRanges . length ;
126- } else {
127- truncated = true ;
128- }
89+ } ) ;
90+ const byFile = new Map < string , KeywordHitFile > ( ) ;
91+ let total = 0 ;
92+ let truncated = false ;
93+ let stdoutRemainder = '' ;
94+ let settled = false ;
95+ let timedOut = false ;
96+ const timeout = setTimeout ( ( ) => {
97+ timedOut = true ;
98+ child . kill ( ) ;
99+ } , RG_TIMEOUT_MS ) ;
100+
101+ const finish = ( error ?: Error ) => {
102+ if ( settled ) return ;
103+ settled = true ;
104+ clearTimeout ( timeout ) ;
105+ if ( error ) {
106+ reject ( error ) ;
107+ return ;
129108 }
130109 const files = Array . from ( byFile . values ( ) ) . sort ( ( a , b ) => a . path . localeCompare ( b . path ) ) ;
131110 resolve ( { files, totalMatches : total , truncated } ) ;
111+ } ;
112+ const consumeLine = ( line : string ) => {
113+ if ( ! line ) return ;
114+ let evt : any ;
115+ try { evt = JSON . parse ( line ) ; } catch { return ; }
116+ if ( evt . type !== 'match' ) return ;
117+ const dataPath = evt . data ?. path ?. text ;
118+ const lineNum = evt . data ?. line_number ;
119+ const rawText = evt . data ?. lines ?. text ;
120+ if ( typeof dataPath !== 'string' || typeof lineNum !== 'number' || typeof rawText !== 'string' ) return ;
121+ const relPath = normalizeRipgrepPath ( dataPath ) ;
122+ const stripped = rawText . replace ( / \r ? \n $ / , '' ) ;
123+ const subs = Array . isArray ( evt . data ?. submatches ) ? evt . data . submatches : [ ] ;
124+ const filteredRanges = normalizeRipgrepSubmatches ( stripped , subs )
125+ . filter ( ( [ start , end ] ) => ! opts . wholeWord || hasWholeTokenBoundaries ( stripped , start , end ) ) ;
126+ if ( filteredRanges . length === 0 ) return ;
127+ let bucket = byFile . get ( relPath ) ;
128+ if ( ! bucket ) {
129+ bucket = { path : relPath , matches : [ ] , totalMatches : 0 } ;
130+ byFile . set ( relPath , bucket ) ;
131+ }
132+ const remainingInFile = opts . wholeWord ? RG_PER_FILE_CAP - bucket . totalMatches : Infinity ;
133+ const matchRanges = filteredRanges . slice ( 0 , Math . max ( 0 , remainingInFile ) ) ;
134+ if ( matchRanges . length < filteredRanges . length ) truncated = true ;
135+ if ( matchRanges . length === 0 ) return ;
136+ const snippet = snippetForLine ( stripped , matchRanges ) ;
137+ bucket . totalMatches += matchRanges . length ;
138+ if ( total < RG_TOTAL_CAP ) {
139+ bucket . matches . push ( { line : lineNum , text : snippet . text , ranges : snippet . ranges } ) ;
140+ total += matchRanges . length ;
141+ } else {
142+ truncated = true ;
143+ }
144+ } ;
145+
146+ child . stdout . setEncoding ( 'utf8' ) ;
147+ child . stdout . on ( 'data' , ( chunk : string ) => {
148+ stdoutRemainder += chunk ;
149+ const lines = stdoutRemainder . split ( '\n' ) ;
150+ stdoutRemainder = lines . pop ( ) ?? '' ;
151+ lines . forEach ( consumeLine ) ;
152+ } ) ;
153+ child . stderr . resume ( ) ;
154+ child . on ( 'error' , ( err ) => finish ( new Error ( `ripgrep failed: ${ err . message } ` ) ) ) ;
155+ child . on ( 'close' , ( code ) => {
156+ if ( stdoutRemainder ) consumeLine ( stdoutRemainder ) ;
157+ if ( timedOut ) return finish ( new Error ( `ripgrep failed (timeout after ${ RG_TIMEOUT_MS } ms)` ) ) ;
158+ if ( code === 0 || code === 1 ) return finish ( ) ;
159+ if ( code === 2 ) return finish ( new Error ( `invalid query: ${ query } ` ) ) ;
160+ return finish ( new Error ( `ripgrep failed (code ${ String ( code ?? '' ) } )` ) ) ;
132161 } ) ;
133162 } ) ;
134163}
0 commit comments