2025-08-22 14:10:45 +08:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
2025-10-21 11:45:33 -07:00
import type { MessageBus } from '../confirmation-bus/message-bus.js' ;
2025-08-25 22:11:27 +02:00
import fs from 'node:fs' ;
import path from 'node:path' ;
import { spawn } from 'node:child_process' ;
2025-09-19 11:08:41 -07:00
import { downloadRipGrep } from '@joshua.litt/get-ripgrep' ;
2025-08-26 00:04:53 +02:00
import type { ToolInvocation , ToolResult } from './tools.js' ;
import { BaseDeclarativeTool , BaseToolInvocation , Kind } from './tools.js' ;
2025-08-22 14:10:45 +08:00
import { SchemaValidator } from '../utils/schemaValidator.js' ;
import { makeRelative , shortenPath } from '../utils/paths.js' ;
import { getErrorMessage , isNodeError } from '../utils/errors.js' ;
2025-08-26 00:04:53 +02:00
import type { Config } from '../config/config.js' ;
2025-09-08 14:44:56 -07:00
import { fileExists } from '../utils/fileUtils.js' ;
import { Storage } from '../config/storage.js' ;
2025-10-19 19:21:47 -04:00
import { GREP_TOOL_NAME } from './tool-names.js' ;
2025-10-21 16:35:22 -04:00
import { debugLogger } from '../utils/debugLogger.js' ;
2025-11-12 00:11:19 -05:00
import {
FileExclusions ,
COMMON_DIRECTORY_EXCLUDES ,
} from '../utils/ignorePatterns.js' ;
2025-12-22 06:25:26 +02:00
import { GeminiIgnoreParser } from '../utils/geminiIgnoreParser.js' ;
2025-08-22 14:10:45 +08:00
const DEFAULT_TOTAL_MAX_MATCHES = 20000 ;
2025-10-20 19:17:44 -04:00
function getRgCandidateFilenames ( ) : readonly string [ ] {
return process . platform === 'win32' ? [ 'rg.exe' , 'rg' ] : [ 'rg' ] ;
}
async function resolveExistingRgPath ( ) : Promise < string | null > {
const binDir = Storage . getGlobalBinDir ( ) ;
for ( const fileName of getRgCandidateFilenames ( ) ) {
const candidatePath = path . join ( binDir , fileName ) ;
if ( await fileExists ( candidatePath ) ) {
return candidatePath ;
}
}
return null ;
}
let ripgrepAcquisitionPromise : Promise < string | null > | null = null ;
async function ensureRipgrepAvailable ( ) : Promise < string | null > {
const existingPath = await resolveExistingRgPath ( ) ;
if ( existingPath ) {
return existingPath ;
}
if ( ! ripgrepAcquisitionPromise ) {
ripgrepAcquisitionPromise = ( async ( ) = > {
try {
await downloadRipGrep ( Storage . getGlobalBinDir ( ) ) ;
return await resolveExistingRgPath ( ) ;
} finally {
ripgrepAcquisitionPromise = null ;
}
} ) ( ) ;
}
return ripgrepAcquisitionPromise ;
2025-09-08 14:44:56 -07:00
}
/ * *
* Checks if ` rg ` exists , if not then attempt to download it .
* /
export async function canUseRipgrep ( ) : Promise < boolean > {
2025-10-20 19:17:44 -04:00
return ( await ensureRipgrepAvailable ( ) ) !== null ;
2025-09-08 14:44:56 -07:00
}
2025-09-11 15:18:29 -07:00
/ * *
* Ensures ` rg ` is downloaded , or throws .
* /
export async function ensureRgPath ( ) : Promise < string > {
2025-10-20 19:17:44 -04:00
const downloadedPath = await ensureRipgrepAvailable ( ) ;
if ( downloadedPath ) {
return downloadedPath ;
2025-09-11 15:18:29 -07:00
}
throw new Error ( 'Cannot use ripgrep.' ) ;
}
2025-11-12 00:11:19 -05:00
/ * *
* Checks if a path is within the root directory and resolves it .
* @param config The configuration object .
* @param relativePath Path relative to the root directory ( or undefined for root ) .
* @returns The absolute path if valid and exists , or null if no path specified .
* @throws { Error } If path is outside root , doesn 't exist, or isn' t a directory / file .
* /
function resolveAndValidatePath (
config : Config ,
relativePath? : string ,
) : string | null {
if ( ! relativePath ) {
return null ;
}
const targetDir = config . getTargetDir ( ) ;
const targetPath = path . resolve ( targetDir , relativePath ) ;
// Ensure the resolved path is within workspace boundaries
const workspaceContext = config . getWorkspaceContext ( ) ;
if ( ! workspaceContext . isPathWithinWorkspace ( targetPath ) ) {
const directories = workspaceContext . getDirectories ( ) ;
throw new Error (
` Path validation failed: Attempted path " ${ relativePath } " resolves outside the allowed workspace directories: ${ directories . join ( ', ' ) } ` ,
) ;
}
// Check existence and type after resolving
try {
const stats = fs . statSync ( targetPath ) ;
if ( ! stats . isDirectory ( ) && ! stats . isFile ( ) ) {
throw new Error (
` Path is not a valid directory or file: ${ targetPath } (CWD: ${ targetDir } ) ` ,
) ;
}
} catch ( error : unknown ) {
if ( isNodeError ( error ) && error . code === 'ENOENT' ) {
throw new Error ( ` Path does not exist: ${ targetPath } (CWD: ${ targetDir } ) ` ) ;
}
throw new Error ( ` Failed to access path stats for ${ targetPath } : ${ error } ` ) ;
}
return targetPath ;
}
2025-08-22 14:10:45 +08:00
/ * *
* Parameters for the GrepTool
* /
export interface RipGrepToolParams {
/ * *
* The regular expression pattern to search for in file contents
* /
pattern : string ;
/ * *
* The directory to search in ( optional , defaults to current directory relative to root )
* /
2025-11-06 15:03:52 -08:00
dir_path? : string ;
2025-08-22 14:10:45 +08:00
/ * *
* File pattern to include in the search ( e . g . "*.js" , "*.{ts,tsx}" )
* /
include? : string ;
2025-11-12 00:11:19 -05:00
/ * *
* If true , searches case - sensitively . Defaults to false .
* /
case_sensitive? : boolean ;
/ * *
* If true , treats pattern as a literal string . Defaults to false .
* /
fixed_strings? : boolean ;
/ * *
* Show num lines of context around each match .
* /
context? : number ;
/ * *
* Show num lines after each match .
* /
after? : number ;
/ * *
* Show num lines before each match .
* /
before? : number ;
/ * *
* If true , does not respect . gitignore or default ignores ( like build / dist ) .
* /
no_ignore? : boolean ;
2025-08-22 14:10:45 +08:00
}
/ * *
* Result object for a single grep match
* /
interface GrepMatch {
filePath : string ;
lineNumber : number ;
line : string ;
}
class GrepToolInvocation extends BaseToolInvocation <
RipGrepToolParams ,
ToolResult
> {
constructor (
private readonly config : Config ,
2025-12-22 06:25:26 +02:00
private readonly geminiIgnoreParser : GeminiIgnoreParser ,
2025-08-22 14:10:45 +08:00
params : RipGrepToolParams ,
2026-01-04 17:11:43 -05:00
messageBus : MessageBus ,
2025-10-21 11:45:33 -07:00
_toolName? : string ,
_toolDisplayName? : string ,
2025-08-22 14:10:45 +08:00
) {
2025-10-21 11:45:33 -07:00
super ( params , messageBus , _toolName , _toolDisplayName ) ;
2025-08-22 14:10:45 +08:00
}
async execute ( signal : AbortSignal ) : Promise < ToolResult > {
try {
2025-11-12 00:11:19 -05:00
// Default to '.' if path is explicitly undefined/null.
// This forces CWD search instead of 'all workspaces' search by default.
const pathParam = this . params . dir_path || '.' ;
2025-08-22 14:10:45 +08:00
2025-11-12 00:11:19 -05:00
const searchDirAbs = resolveAndValidatePath ( this . config , pathParam ) ;
const searchDirDisplay = pathParam ;
2025-08-22 14:10:45 +08:00
2025-11-12 00:11:19 -05:00
const totalMaxMatches = DEFAULT_TOTAL_MAX_MATCHES ;
2025-08-22 14:10:45 +08:00
if ( this . config . getDebugMode ( ) ) {
2025-10-21 16:35:22 -04:00
debugLogger . log ( ` [GrepTool] Total result limit: ${ totalMaxMatches } ` ) ;
2025-08-22 14:10:45 +08:00
}
2025-11-12 00:11:19 -05:00
let allMatches = await this . performRipgrepSearch ( {
pattern : this.params.pattern ,
path : searchDirAbs ! ,
include : this.params.include ,
case_sensitive : this.params.case_sensitive ,
fixed_strings : this.params.fixed_strings ,
context : this.params.context ,
after : this.params.after ,
before : this.params.before ,
no_ignore : this.params.no_ignore ,
signal ,
} ) ;
2025-08-22 14:10:45 +08:00
2025-11-12 00:11:19 -05:00
if ( allMatches . length >= totalMaxMatches ) {
allMatches = allMatches . slice ( 0 , totalMaxMatches ) ;
2025-08-22 14:10:45 +08:00
}
2025-11-12 00:11:19 -05:00
const searchLocationDescription = ` in path " ${ searchDirDisplay } " ` ;
2025-08-22 14:10:45 +08:00
if ( allMatches . length === 0 ) {
const noMatchMsg = ` No matches found for pattern " ${ this . params . pattern } " ${ searchLocationDescription } ${ this . params . include ? ` (filter: " ${ this . params . include } ") ` : '' } . ` ;
return { llmContent : noMatchMsg , returnDisplay : ` No matches found ` } ;
}
const wasTruncated = allMatches . length >= totalMaxMatches ;
const matchesByFile = allMatches . reduce (
( acc , match ) = > {
const fileKey = match . filePath ;
if ( ! acc [ fileKey ] ) {
acc [ fileKey ] = [ ] ;
}
acc [ fileKey ] . push ( match ) ;
acc [ fileKey ] . sort ( ( a , b ) = > a . lineNumber - b . lineNumber ) ;
return acc ;
} ,
{ } as Record < string , GrepMatch [ ] > ,
) ;
const matchCount = allMatches . length ;
const matchTerm = matchCount === 1 ? 'match' : 'matches' ;
let llmContent = ` Found ${ matchCount } ${ matchTerm } for pattern " ${ this . params . pattern } " ${ searchLocationDescription } ${ this . params . include ? ` (filter: " ${ this . params . include } ") ` : '' } ` ;
if ( wasTruncated ) {
llmContent += ` (results limited to ${ totalMaxMatches } matches for performance) ` ;
}
llmContent += ` : \ n--- \ n ` ;
for ( const filePath in matchesByFile ) {
llmContent += ` File: ${ filePath } \ n ` ;
matchesByFile [ filePath ] . forEach ( ( match ) = > {
const trimmedLine = match . line . trim ( ) ;
llmContent += ` L ${ match . lineNumber } : ${ trimmedLine } \ n ` ;
} ) ;
llmContent += '---\n' ;
}
let displayMessage = ` Found ${ matchCount } ${ matchTerm } ` ;
if ( wasTruncated ) {
displayMessage += ` (limited) ` ;
}
return {
llmContent : llmContent.trim ( ) ,
returnDisplay : displayMessage ,
} ;
} catch ( error ) {
2025-12-17 12:40:28 -05:00
debugLogger . warn ( ` Error during GrepLogic execution: ${ error } ` ) ;
2025-08-22 14:10:45 +08:00
const errorMessage = getErrorMessage ( error ) ;
return {
llmContent : ` Error during grep search operation: ${ errorMessage } ` ,
returnDisplay : ` Error: ${ errorMessage } ` ,
} ;
}
}
2025-11-10 19:01:01 -05:00
private parseRipgrepJsonOutput (
output : string ,
basePath : string ,
) : GrepMatch [ ] {
2025-08-22 14:10:45 +08:00
const results : GrepMatch [ ] = [ ] ;
if ( ! output ) return results ;
2025-11-10 19:01:01 -05:00
const lines = output . trim ( ) . split ( '\n' ) ;
2025-08-22 14:10:45 +08:00
for ( const line of lines ) {
if ( ! line . trim ( ) ) continue ;
2025-11-10 19:01:01 -05:00
try {
const json = JSON . parse ( line ) ;
if ( json . type === 'match' ) {
const match = json . data ;
// Defensive check: ensure text properties exist (skips binary/invalid encoding)
if ( match . path ? . text && match . lines ? . text ) {
const absoluteFilePath = path . resolve ( basePath , match . path . text ) ;
const relativeFilePath = path . relative ( basePath , absoluteFilePath ) ;
results . push ( {
filePath : relativeFilePath || path . basename ( absoluteFilePath ) ,
lineNumber : match.line_number ,
line : match.lines.text.trimEnd ( ) ,
} ) ;
}
}
} catch ( error ) {
debugLogger . warn ( ` Failed to parse ripgrep JSON line: ${ line } ` , error ) ;
2025-08-22 14:10:45 +08:00
}
}
return results ;
}
private async performRipgrepSearch ( options : {
pattern : string ;
path : string ;
include? : string ;
2025-11-12 00:11:19 -05:00
case_sensitive? : boolean ;
fixed_strings? : boolean ;
context? : number ;
after? : number ;
before? : number ;
no_ignore? : boolean ;
2025-08-22 14:10:45 +08:00
signal : AbortSignal ;
} ) : Promise < GrepMatch [ ] > {
2025-11-12 00:11:19 -05:00
const {
pattern ,
path : absolutePath ,
include ,
case_sensitive ,
fixed_strings ,
context ,
after ,
before ,
no_ignore ,
} = options ;
const rgArgs = [ '--json' ] ;
if ( ! case_sensitive ) {
rgArgs . push ( '--ignore-case' ) ;
}
2025-08-22 14:10:45 +08:00
2025-11-12 00:11:19 -05:00
if ( fixed_strings ) {
rgArgs . push ( '--fixed-strings' ) ;
rgArgs . push ( pattern ) ;
} else {
rgArgs . push ( '--regexp' , pattern ) ;
}
if ( context ) {
rgArgs . push ( '--context' , context . toString ( ) ) ;
}
if ( after ) {
rgArgs . push ( '--after-context' , after . toString ( ) ) ;
}
if ( before ) {
rgArgs . push ( '--before-context' , before . toString ( ) ) ;
}
if ( no_ignore ) {
rgArgs . push ( '--no-ignore' ) ;
}
2025-08-22 14:10:45 +08:00
if ( include ) {
rgArgs . push ( '--glob' , include ) ;
}
2025-11-12 00:11:19 -05:00
if ( ! no_ignore ) {
const fileExclusions = new FileExclusions ( this . config ) ;
const excludes = fileExclusions . getGlobExcludes ( [
. . . COMMON_DIRECTORY_EXCLUDES ,
'*.log' ,
'*.tmp' ,
] ) ;
excludes . forEach ( ( exclude ) = > {
rgArgs . push ( '--glob' , ` ! ${ exclude } ` ) ;
} ) ;
2025-12-22 06:25:26 +02:00
if ( this . config . getFileFilteringRespectGeminiIgnore ( ) ) {
// Add .geminiignore support (ripgrep natively handles .gitignore)
const geminiIgnorePath = this . geminiIgnoreParser . getIgnoreFilePath ( ) ;
if ( geminiIgnorePath ) {
rgArgs . push ( '--ignore-file' , geminiIgnorePath ) ;
}
}
2025-11-12 00:11:19 -05:00
}
2025-08-22 14:10:45 +08:00
rgArgs . push ( '--threads' , '4' ) ;
rgArgs . push ( absolutePath ) ;
try {
2025-09-11 15:18:29 -07:00
const rgPath = await ensureRgPath ( ) ;
2025-08-22 14:10:45 +08:00
const output = await new Promise < string > ( ( resolve , reject ) = > {
2025-09-11 15:18:29 -07:00
const child = spawn ( rgPath , rgArgs , {
2025-08-22 14:10:45 +08:00
windowsHide : true ,
} ) ;
const stdoutChunks : Buffer [ ] = [ ] ;
const stderrChunks : Buffer [ ] = [ ] ;
const cleanup = ( ) = > {
if ( options . signal . aborted ) {
child . kill ( ) ;
}
} ;
options . signal . addEventListener ( 'abort' , cleanup , { once : true } ) ;
child . stdout . on ( 'data' , ( chunk ) = > stdoutChunks . push ( chunk ) ) ;
child . stderr . on ( 'data' , ( chunk ) = > stderrChunks . push ( chunk ) ) ;
child . on ( 'error' , ( err ) = > {
options . signal . removeEventListener ( 'abort' , cleanup ) ;
reject (
new Error (
` Failed to start ripgrep: ${ err . message } . Please ensure @lvce-editor/ripgrep is properly installed. ` ,
) ,
) ;
} ) ;
child . on ( 'close' , ( code ) = > {
options . signal . removeEventListener ( 'abort' , cleanup ) ;
const stdoutData = Buffer . concat ( stdoutChunks ) . toString ( 'utf8' ) ;
const stderrData = Buffer . concat ( stderrChunks ) . toString ( 'utf8' ) ;
if ( code === 0 ) {
resolve ( stdoutData ) ;
} else if ( code === 1 ) {
resolve ( '' ) ; // No matches found
} else {
reject (
new Error ( ` ripgrep exited with code ${ code } : ${ stderrData } ` ) ,
) ;
}
} ) ;
} ) ;
2025-11-10 19:01:01 -05:00
return this . parseRipgrepJsonOutput ( output , absolutePath ) ;
2025-08-22 14:10:45 +08:00
} catch ( error : unknown ) {
2025-12-17 12:40:28 -05:00
debugLogger . debug ( ` GrepLogic: ripgrep failed: ${ getErrorMessage ( error ) } ` ) ;
2025-08-22 14:10:45 +08:00
throw error ;
}
}
/ * *
* Gets a description of the grep operation
* @param params Parameters for the grep operation
* @returns A string describing the grep
* /
getDescription ( ) : string {
let description = ` ' ${ this . params . pattern } ' ` ;
if ( this . params . include ) {
description += ` in ${ this . params . include } ` ;
}
2025-11-12 00:11:19 -05:00
const pathParam = this . params . dir_path || '.' ;
const resolvedPath = path . resolve ( this . config . getTargetDir ( ) , pathParam ) ;
if ( resolvedPath === this . config . getTargetDir ( ) || pathParam === '.' ) {
description += ` within ./ ` ;
} else {
const relativePath = makeRelative (
resolvedPath ,
2025-08-22 14:10:45 +08:00
this . config . getTargetDir ( ) ,
) ;
2025-11-12 00:11:19 -05:00
description += ` within ${ shortenPath ( relativePath ) } ` ;
2025-08-22 14:10:45 +08:00
}
return description ;
}
}
/ * *
* Implementation of the Grep tool logic ( moved from CLI )
* /
export class RipGrepTool extends BaseDeclarativeTool <
RipGrepToolParams ,
ToolResult
> {
2025-10-20 22:35:35 -04:00
static readonly Name = GREP_TOOL_NAME ;
2025-12-22 06:25:26 +02:00
private readonly geminiIgnoreParser : GeminiIgnoreParser ;
2025-10-20 22:35:35 -04:00
2025-10-21 11:45:33 -07:00
constructor (
private readonly config : Config ,
2026-01-04 17:11:43 -05:00
messageBus : MessageBus ,
2025-10-21 11:45:33 -07:00
) {
2025-08-22 14:10:45 +08:00
super (
2025-10-20 22:35:35 -04:00
RipGrepTool . Name ,
2025-08-22 14:10:45 +08:00
'SearchText' ,
2025-11-12 00:11:19 -05:00
'FAST, optimized search powered by `ripgrep`. PREFERRED over standard `run_shell_command("grep ...")` due to better performance and automatic output limiting (max 20k matches).' ,
2025-08-22 14:10:45 +08:00
Kind . Search ,
{
properties : {
pattern : {
description :
2025-11-12 00:11:19 -05:00
"The pattern to search for. By default, treated as a Rust-flavored regular expression. Use '\\b' for precise symbol matching (e.g., '\\bMatchMe\\b')." ,
2025-08-22 14:10:45 +08:00
type : 'string' ,
} ,
2025-11-06 15:03:52 -08:00
dir_path : {
2025-08-22 14:10:45 +08:00
description :
2025-11-12 00:11:19 -05:00
"Directory or file to search. Directories are searched recursively. Relative paths are resolved against current working directory. Defaults to current working directory ('.') if omitted." ,
2025-08-22 14:10:45 +08:00
type : 'string' ,
} ,
include : {
description :
2025-11-12 00:11:19 -05:00
"Glob pattern to filter files (e.g., '*.ts', 'src/**'). Recommended for large repositories to reduce noise. Defaults to all files if omitted." ,
2025-08-22 14:10:45 +08:00
type : 'string' ,
} ,
2025-11-12 00:11:19 -05:00
case_sensitive : {
description :
'If true, search is case-sensitive. Defaults to false (ignore case) if omitted.' ,
type : 'boolean' ,
} ,
fixed_strings : {
description :
'If true, treats the `pattern` as a literal string instead of a regular expression. Defaults to false (basic regex) if omitted.' ,
type : 'boolean' ,
} ,
context : {
description :
'Show this many lines of context around each match (equivalent to grep -C). Defaults to 0 if omitted.' ,
type : 'integer' ,
} ,
after : {
description :
'Show this many lines after each match (equivalent to grep -A). Defaults to 0 if omitted.' ,
type : 'integer' ,
} ,
before : {
description :
'Show this many lines before each match (equivalent to grep -B). Defaults to 0 if omitted.' ,
type : 'integer' ,
} ,
no_ignore : {
description :
'If true, searches all files including those usually ignored (like in .gitignore, build/, dist/, etc). Defaults to false if omitted.' ,
type : 'boolean' ,
} ,
2025-08-22 14:10:45 +08:00
} ,
required : [ 'pattern' ] ,
type : 'object' ,
} ,
2026-01-04 17:11:43 -05:00
messageBus ,
2025-10-21 11:45:33 -07:00
true , // isOutputMarkdown
false , // canUpdateOutput
2025-08-22 14:10:45 +08:00
) ;
2025-12-22 06:25:26 +02:00
this . geminiIgnoreParser = new GeminiIgnoreParser ( config . getTargetDir ( ) ) ;
2025-08-22 14:10:45 +08:00
}
/ * *
* Validates the parameters for the tool
* @param params Parameters to validate
* @returns An error message string if invalid , null otherwise
* /
override validateToolParams ( params : RipGrepToolParams ) : string | null {
const errors = SchemaValidator . validate (
this . schema . parametersJsonSchema ,
params ,
) ;
if ( errors ) {
return errors ;
}
// Only validate path if one is provided
2025-11-06 15:03:52 -08:00
if ( params . dir_path ) {
2025-08-22 14:10:45 +08:00
try {
2025-11-12 00:11:19 -05:00
resolveAndValidatePath ( this . config , params . dir_path ) ;
2025-08-22 14:10:45 +08:00
} catch ( error ) {
return getErrorMessage ( error ) ;
}
}
return null ; // Parameters are valid
}
protected createInvocation (
params : RipGrepToolParams ,
2026-01-04 17:11:43 -05:00
messageBus : MessageBus ,
2025-10-21 11:45:33 -07:00
_toolName? : string ,
_toolDisplayName? : string ,
2025-08-22 14:10:45 +08:00
) : ToolInvocation < RipGrepToolParams , ToolResult > {
2025-10-21 11:45:33 -07:00
return new GrepToolInvocation (
this . config ,
2025-12-22 06:25:26 +02:00
this . geminiIgnoreParser ,
2025-10-21 11:45:33 -07:00
params ,
2026-01-04 15:51:23 -05:00
messageBus ? ? this . messageBus ,
2025-10-21 11:45:33 -07:00
_toolName ,
_toolDisplayName ,
) ;
2025-08-22 14:10:45 +08:00
}
}