2025-05-31 18:41:00 +00:00
import { z } from "zod"
import { Tool } from "./tool"
import { App } from "../app/app"
import { spawn } from "child_process"
import { promises as fs } from "fs"
import path from "path"
2025-05-26 18:09:17 +00:00
const DESCRIPTION = ` Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
WHEN TO USE THIS TOOL :
- Use when you need to find files containing specific text or patterns
- Great for searching code bases for function names , variable declarations , or error messages
- Useful for finding all files that use a particular API or pattern
HOW TO USE :
- Provide a regex pattern to search for within file contents
- Set literal_text = true if you want to search for the exact text with special characters ( recommended for non - regex users )
- Optionally specify a starting directory ( defaults to current working directory )
- Optionally provide an include pattern to filter which files to search
- Results are sorted with most recently modified files first
REGEX PATTERN SYNTAX ( when literal_text = false ) :
- Supports standard regular expression syntax
- 'function' searches for the literal text "function"
- 'log\\..*Error' finds text starting with "log." and ending with "Error"
- 'import\\s+.*\\s+from' finds import statements in JavaScript / TypeScript
COMMON INCLUDE PATTERN EXAMPLES :
- '*.js' - Only search JavaScript files
- '*.{ts,tsx}' - Only search TypeScript files
- '*.go' - Only search Go files
LIMITATIONS :
- Results are limited to 100 files ( newest first )
- Performance depends on the number of files being searched
- Very large binary files may be skipped
- Hidden files ( starting with '.' ) are skipped
TIPS :
- For faster , more targeted searches , first use Glob to find relevant files , then use Grep
- When doing iterative exploration that may require multiple rounds of searching , consider using the Agent tool instead
- Always check if results are truncated and refine your search pattern if needed
2025-05-31 18:41:00 +00:00
- Use literal_text = true when searching for exact text containing special characters like dots , parentheses , etc . `
2025-05-26 18:09:17 +00:00
interface GrepMatch {
2025-05-31 18:41:00 +00:00
path : string
modTime : number
lineNum : number
lineText : string
2025-05-26 18:09:17 +00:00
}
function escapeRegexPattern ( pattern : string ) : string {
const specialChars = [
"\\" ,
"." ,
"+" ,
"*" ,
"?" ,
"(" ,
")" ,
"[" ,
"]" ,
"{" ,
"}" ,
"^" ,
"$" ,
"|" ,
2025-05-31 18:41:00 +00:00
]
let escaped = pattern
2025-05-26 18:09:17 +00:00
for ( const char of specialChars ) {
2025-05-31 18:41:00 +00:00
escaped = escaped . replaceAll ( char , "\\" + char )
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
return escaped
2025-05-26 18:09:17 +00:00
}
function globToRegex ( glob : string ) : string {
2025-05-31 18:41:00 +00:00
let regexPattern = glob . replaceAll ( "." , "\\." )
regexPattern = regexPattern . replaceAll ( "*" , ".*" )
regexPattern = regexPattern . replaceAll ( "?" , "." )
2025-05-26 18:09:17 +00:00
// Handle {a,b,c} patterns
2025-05-27 06:26:53 +00:00
regexPattern = regexPattern . replace ( /\{([^}]+)\}/g , ( _ , inner ) = > {
2025-05-31 18:41:00 +00:00
return "(" + inner . replace ( /,/g , "|" ) + ")"
} )
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
return regexPattern
2025-05-26 18:09:17 +00:00
}
async function searchWithRipgrep (
pattern : string ,
searchPath : string ,
include? : string ,
) : Promise < GrepMatch [ ] > {
return new Promise ( ( resolve , reject ) = > {
2025-05-31 18:41:00 +00:00
const args = [ "-n" , pattern ]
2025-05-26 18:09:17 +00:00
if ( include ) {
2025-05-31 18:41:00 +00:00
args . push ( "--glob" , include )
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
args . push ( searchPath )
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
const rg = spawn ( "rg" , args )
let output = ""
let errorOutput = ""
2025-05-26 18:09:17 +00:00
rg . stdout . on ( "data" , ( data ) = > {
2025-05-31 18:41:00 +00:00
output += data . toString ( )
} )
2025-05-26 18:09:17 +00:00
rg . stderr . on ( "data" , ( data ) = > {
2025-05-31 18:41:00 +00:00
errorOutput += data . toString ( )
} )
2025-05-26 18:09:17 +00:00
rg . on ( "close" , async ( code ) = > {
if ( code === 1 ) {
// No matches found
2025-05-31 18:41:00 +00:00
resolve ( [ ] )
return
2025-05-26 18:09:17 +00:00
}
if ( code !== 0 ) {
2025-05-31 18:41:00 +00:00
reject ( new Error ( ` ripgrep failed: ${ errorOutput } ` ) )
return
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
const lines = output . trim ( ) . split ( "\n" )
const matches : GrepMatch [ ] = [ ]
2025-05-26 18:09:17 +00:00
for ( const line of lines ) {
2025-05-31 18:41:00 +00:00
if ( ! line ) continue
2025-05-26 18:09:17 +00:00
// Parse ripgrep output format: file:line:content
2025-05-31 18:41:00 +00:00
const parts = line . split ( ":" , 3 )
if ( parts . length < 3 ) continue
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
const filePath = parts [ 0 ]
const lineNum = parseInt ( parts [ 1 ] , 10 )
const lineText = parts [ 2 ]
2025-05-26 18:09:17 +00:00
try {
2025-05-31 18:41:00 +00:00
const stats = await fs . stat ( filePath )
2025-05-26 18:09:17 +00:00
matches . push ( {
path : filePath ,
modTime : stats.mtime.getTime ( ) ,
lineNum ,
lineText ,
2025-05-31 18:41:00 +00:00
} )
2025-05-26 18:09:17 +00:00
} catch {
// Skip files we can't access
2025-05-31 18:41:00 +00:00
continue
2025-05-26 18:09:17 +00:00
}
}
2025-05-31 18:41:00 +00:00
resolve ( matches )
} )
2025-05-26 18:09:17 +00:00
rg . on ( "error" , ( err ) = > {
2025-05-31 18:41:00 +00:00
reject ( err )
} )
} )
2025-05-26 18:09:17 +00:00
}
async function searchFilesWithRegex (
pattern : string ,
rootPath : string ,
include? : string ,
) : Promise < GrepMatch [ ] > {
2025-05-31 18:41:00 +00:00
const matches : GrepMatch [ ] = [ ]
const regex = new RegExp ( pattern )
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
let includePattern : RegExp | undefined
2025-05-26 18:09:17 +00:00
if ( include ) {
2025-05-31 18:41:00 +00:00
const regexPattern = globToRegex ( include )
includePattern = new RegExp ( regexPattern )
2025-05-26 18:09:17 +00:00
}
async function walkDir ( dir : string ) {
2025-05-31 18:41:00 +00:00
if ( matches . length >= 200 ) return
2025-05-26 18:09:17 +00:00
try {
2025-05-31 18:41:00 +00:00
const entries = await fs . readdir ( dir , { withFileTypes : true } )
2025-05-26 18:09:17 +00:00
for ( const entry of entries ) {
2025-05-31 18:41:00 +00:00
if ( matches . length >= 200 ) break
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
const fullPath = path . join ( dir , entry . name )
2025-05-26 18:09:17 +00:00
if ( entry . isDirectory ( ) ) {
// Skip hidden directories
2025-05-31 18:41:00 +00:00
if ( entry . name . startsWith ( "." ) ) continue
await walkDir ( fullPath )
2025-05-26 18:09:17 +00:00
} else if ( entry . isFile ( ) ) {
// Skip hidden files
2025-05-31 18:41:00 +00:00
if ( entry . name . startsWith ( "." ) ) continue
2025-05-26 18:09:17 +00:00
if ( includePattern && ! includePattern . test ( fullPath ) ) {
2025-05-31 18:41:00 +00:00
continue
2025-05-26 18:09:17 +00:00
}
try {
2025-05-31 18:41:00 +00:00
const content = await fs . readFile ( fullPath , "utf-8" )
const lines = content . split ( "\n" )
2025-05-26 18:09:17 +00:00
for ( let i = 0 ; i < lines . length ; i ++ ) {
if ( regex . test ( lines [ i ] ) ) {
2025-05-31 18:41:00 +00:00
const stats = await fs . stat ( fullPath )
2025-05-26 18:09:17 +00:00
matches . push ( {
path : fullPath ,
modTime : stats.mtime.getTime ( ) ,
lineNum : i + 1 ,
lineText : lines [ i ] ,
2025-05-31 18:41:00 +00:00
} )
break // Only first match per file
2025-05-26 18:09:17 +00:00
}
}
} catch {
// Skip files we can't read
2025-05-31 18:41:00 +00:00
continue
2025-05-26 18:09:17 +00:00
}
}
}
} catch {
// Skip directories we can't read
2025-05-31 18:41:00 +00:00
return
2025-05-26 18:09:17 +00:00
}
}
2025-05-31 18:41:00 +00:00
await walkDir ( rootPath )
return matches
2025-05-26 18:09:17 +00:00
}
async function searchFiles (
pattern : string ,
rootPath : string ,
include? : string ,
limit : number = 100 ,
) : Promise < { matches : GrepMatch [ ] ; truncated : boolean } > {
2025-05-31 18:41:00 +00:00
let matches : GrepMatch [ ]
2025-05-26 18:09:17 +00:00
try {
2025-05-31 18:41:00 +00:00
matches = await searchWithRipgrep ( pattern , rootPath , include )
2025-05-26 18:09:17 +00:00
} catch {
2025-05-31 18:41:00 +00:00
matches = await searchFilesWithRegex ( pattern , rootPath , include )
2025-05-26 18:09:17 +00:00
}
// Sort by modification time (newest first)
2025-05-31 18:41:00 +00:00
matches . sort ( ( a , b ) = > b . modTime - a . modTime )
2025-05-26 18:09:17 +00:00
2025-05-31 18:41:00 +00:00
const truncated = matches . length > limit
2025-05-26 18:09:17 +00:00
if ( truncated ) {
2025-05-31 18:41:00 +00:00
matches = matches . slice ( 0 , limit )
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
return { matches , truncated }
2025-05-26 18:09:17 +00:00
}
2025-05-31 21:12:16 +00:00
export const GrepTool = Tool . define ( {
id : "opencode.grep" ,
2025-05-26 18:09:17 +00:00
description : DESCRIPTION ,
parameters : z.object ( {
pattern : z
. string ( )
. describe ( "The regex pattern to search for in file contents" ) ,
path : z
. string ( )
. describe (
"The directory to search in. Defaults to the current working directory." ,
)
. optional ( ) ,
include : z
. string ( )
. describe (
'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")' ,
)
. optional ( ) ,
2025-05-27 02:08:50 +00:00
literalText : z
2025-05-26 18:09:17 +00:00
. boolean ( )
. describe (
"If true, the pattern will be treated as literal text with special regex characters escaped. Default is false." ,
)
. optional ( ) ,
} ) ,
async execute ( params ) {
if ( ! params . pattern ) {
2025-05-31 18:41:00 +00:00
throw new Error ( "pattern is required" )
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
const app = await App . use ( )
const searchPath = params . path || app . root
2025-05-26 18:09:17 +00:00
2025-05-27 02:08:50 +00:00
// If literalText is true, escape the pattern
const searchPattern = params . literalText
2025-05-26 18:09:17 +00:00
? escapeRegexPattern ( params . pattern )
2025-05-31 18:41:00 +00:00
: params . pattern
2025-05-26 18:09:17 +00:00
const { matches , truncated } = await searchFiles (
searchPattern ,
searchPath ,
params . include ,
100 ,
2025-05-31 18:41:00 +00:00
)
2025-05-26 18:09:17 +00:00
if ( matches . length === 0 ) {
2025-05-27 06:26:53 +00:00
return {
metadata : { matches : 0 , truncated } ,
2025-05-31 18:41:00 +00:00
output : "No files found" ,
}
2025-05-27 06:26:53 +00:00
}
2025-05-31 18:41:00 +00:00
const lines = [ ` Found ${ matches . length } matches ` ]
2025-05-27 06:26:53 +00:00
2025-05-31 18:41:00 +00:00
let currentFile = ""
2025-05-27 06:26:53 +00:00
for ( const match of matches ) {
if ( currentFile !== match . path ) {
if ( currentFile !== "" ) {
2025-05-31 18:41:00 +00:00
lines . push ( "" )
2025-05-26 18:09:17 +00:00
}
2025-05-31 18:41:00 +00:00
currentFile = match . path
lines . push ( ` ${ match . path } : ` )
2025-05-26 18:09:17 +00:00
}
2025-05-27 06:26:53 +00:00
if ( match . lineNum > 0 ) {
2025-05-31 18:41:00 +00:00
lines . push ( ` Line ${ match . lineNum } : ${ match . lineText } ` )
2025-05-27 06:26:53 +00:00
} else {
2025-05-31 18:41:00 +00:00
lines . push ( ` ${ match . path } ` )
2025-05-26 18:09:17 +00:00
}
2025-05-27 06:26:53 +00:00
}
2025-05-26 18:09:17 +00:00
2025-05-27 06:26:53 +00:00
if ( truncated ) {
2025-05-31 18:41:00 +00:00
lines . push ( "" )
2025-05-27 06:26:53 +00:00
lines . push (
"(Results are truncated. Consider using a more specific path or pattern.)" ,
2025-05-31 18:41:00 +00:00
)
2025-05-26 18:09:17 +00:00
}
return {
metadata : {
matches : matches.length ,
truncated ,
} ,
2025-05-27 06:26:53 +00:00
output : lines.join ( "\n" ) ,
2025-05-31 18:41:00 +00:00
}
2025-05-26 18:09:17 +00:00
} ,
2025-05-31 18:41:00 +00:00
} )