1package tools
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "fmt"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "regexp"
12 "sort"
13 "strings"
14 "time"
15
16 "github.com/kujtimiihoxha/opencode/internal/config"
17)
18
19type GrepParams struct {
20 Pattern string `json:"pattern"`
21 Path string `json:"path"`
22 Include string `json:"include"`
23 LiteralText bool `json:"literal_text"`
24}
25
26type grepMatch struct {
27 path string
28 modTime time.Time
29}
30
31type GrepResponseMetadata struct {
32 NumberOfMatches int `json:"number_of_matches"`
33 Truncated bool `json:"truncated"`
34}
35
36type grepTool struct{}
37
38const (
39 GrepToolName = "grep"
40 grepDescription = `Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first).
41
42WHEN TO USE THIS TOOL:
43- Use when you need to find files containing specific text or patterns
44- Great for searching code bases for function names, variable declarations, or error messages
45- Useful for finding all files that use a particular API or pattern
46
47HOW TO USE:
48- Provide a regex pattern to search for within file contents
49- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users)
50- Optionally specify a starting directory (defaults to current working directory)
51- Optionally provide an include pattern to filter which files to search
52- Results are sorted with most recently modified files first
53
54REGEX PATTERN SYNTAX (when literal_text=false):
55- Supports standard regular expression syntax
56- 'function' searches for the literal text "function"
57- 'log\..*Error' finds text starting with "log." and ending with "Error"
58- 'import\s+.*\s+from' finds import statements in JavaScript/TypeScript
59
60COMMON INCLUDE PATTERN EXAMPLES:
61- '*.js' - Only search JavaScript files
62- '*.{ts,tsx}' - Only search TypeScript files
63- '*.go' - Only search Go files
64
65LIMITATIONS:
66- Results are limited to 100 files (newest first)
67- Performance depends on the number of files being searched
68- Very large binary files may be skipped
69- Hidden files (starting with '.') are skipped
70
71TIPS:
72- For faster, more targeted searches, first use Glob to find relevant files, then use Grep
73- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
74- Always check if results are truncated and refine your search pattern if needed
75- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.`
76)
77
78func NewGrepTool() BaseTool {
79 return &grepTool{}
80}
81
82func (g *grepTool) Info() ToolInfo {
83 return ToolInfo{
84 Name: GrepToolName,
85 Description: grepDescription,
86 Parameters: map[string]any{
87 "pattern": map[string]any{
88 "type": "string",
89 "description": "The regex pattern to search for in file contents",
90 },
91 "path": map[string]any{
92 "type": "string",
93 "description": "The directory to search in. Defaults to the current working directory.",
94 },
95 "include": map[string]any{
96 "type": "string",
97 "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")",
98 },
99 "literal_text": map[string]any{
100 "type": "boolean",
101 "description": "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.",
102 },
103 },
104 Required: []string{"pattern"},
105 }
106}
107
108// escapeRegexPattern escapes special regex characters so they're treated as literal characters
109func escapeRegexPattern(pattern string) string {
110 specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
111 escaped := pattern
112
113 for _, char := range specialChars {
114 escaped = strings.ReplaceAll(escaped, char, "\\"+char)
115 }
116
117 return escaped
118}
119
120func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
121 var params GrepParams
122 if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil {
123 return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
124 }
125
126 if params.Pattern == "" {
127 return NewTextErrorResponse("pattern is required"), nil
128 }
129
130 // If literal_text is true, escape the pattern
131 searchPattern := params.Pattern
132 if params.LiteralText {
133 searchPattern = escapeRegexPattern(params.Pattern)
134 }
135
136 searchPath := params.Path
137 if searchPath == "" {
138 searchPath = config.WorkingDirectory()
139 }
140
141 matches, truncated, err := searchFiles(searchPattern, searchPath, params.Include, 100)
142 if err != nil {
143 return ToolResponse{}, fmt.Errorf("error searching files: %w", err)
144 }
145
146 var output string
147 if len(matches) == 0 {
148 output = "No files found"
149 } else {
150 output = fmt.Sprintf("Found %d file%s\n%s",
151 len(matches),
152 pluralize(len(matches)),
153 strings.Join(matches, "\n"))
154
155 if truncated {
156 output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
157 }
158 }
159
160 return WithResponseMetadata(
161 NewTextResponse(output),
162 GrepResponseMetadata{
163 NumberOfMatches: len(matches),
164 Truncated: truncated,
165 },
166 ), nil
167}
168
169func pluralize(count int) string {
170 if count == 1 {
171 return ""
172 }
173 return "s"
174}
175
176func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) {
177 matches, err := searchWithRipgrep(pattern, rootPath, include)
178 if err != nil {
179 matches, err = searchFilesWithRegex(pattern, rootPath, include)
180 if err != nil {
181 return nil, false, err
182 }
183 }
184
185 sort.Slice(matches, func(i, j int) bool {
186 return matches[i].modTime.After(matches[j].modTime)
187 })
188
189 truncated := len(matches) > limit
190 if truncated {
191 matches = matches[:limit]
192 }
193
194 results := make([]string, len(matches))
195 for i, m := range matches {
196 results[i] = m.path
197 }
198
199 return results, truncated, nil
200}
201
202func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
203 _, err := exec.LookPath("rg")
204 if err != nil {
205 return nil, fmt.Errorf("ripgrep not found: %w", err)
206 }
207
208 args := []string{"-l", pattern}
209 if include != "" {
210 args = append(args, "--glob", include)
211 }
212 args = append(args, path)
213
214 cmd := exec.Command("rg", args...)
215 output, err := cmd.Output()
216 if err != nil {
217 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
218 return []grepMatch{}, nil
219 }
220 return nil, err
221 }
222
223 lines := strings.Split(strings.TrimSpace(string(output)), "\n")
224 matches := make([]grepMatch, 0, len(lines))
225
226 for _, line := range lines {
227 if line == "" {
228 continue
229 }
230
231 fileInfo, err := os.Stat(line)
232 if err != nil {
233 continue // Skip files we can't access
234 }
235
236 matches = append(matches, grepMatch{
237 path: line,
238 modTime: fileInfo.ModTime(),
239 })
240 }
241
242 return matches, nil
243}
244
245func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
246 matches := []grepMatch{}
247
248 regex, err := regexp.Compile(pattern)
249 if err != nil {
250 return nil, fmt.Errorf("invalid regex pattern: %w", err)
251 }
252
253 var includePattern *regexp.Regexp
254 if include != "" {
255 regexPattern := globToRegex(include)
256 includePattern, err = regexp.Compile(regexPattern)
257 if err != nil {
258 return nil, fmt.Errorf("invalid include pattern: %w", err)
259 }
260 }
261
262 err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
263 if err != nil {
264 return nil // Skip errors
265 }
266
267 if info.IsDir() {
268 return nil // Skip directories
269 }
270
271 if skipHidden(path) {
272 return nil
273 }
274
275 if includePattern != nil && !includePattern.MatchString(path) {
276 return nil
277 }
278
279 match, err := fileContainsPattern(path, regex)
280 if err != nil {
281 return nil // Skip files we can't read
282 }
283
284 if match {
285 matches = append(matches, grepMatch{
286 path: path,
287 modTime: info.ModTime(),
288 })
289
290 if len(matches) >= 200 {
291 return filepath.SkipAll
292 }
293 }
294
295 return nil
296 })
297 if err != nil {
298 return nil, err
299 }
300
301 return matches, nil
302}
303
304func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) {
305 file, err := os.Open(filePath)
306 if err != nil {
307 return false, err
308 }
309 defer file.Close()
310
311 scanner := bufio.NewScanner(file)
312 for scanner.Scan() {
313 if pattern.MatchString(scanner.Text()) {
314 return true, nil
315 }
316 }
317
318 return false, scanner.Err()
319}
320
321func globToRegex(glob string) string {
322 regexPattern := strings.ReplaceAll(glob, ".", "\\.")
323 regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
324 regexPattern = strings.ReplaceAll(regexPattern, "?", ".")
325
326 re := regexp.MustCompile(`\{([^}]+)\}`)
327 regexPattern = re.ReplaceAllStringFunc(regexPattern, func(match string) string {
328 inner := match[1 : len(match)-1]
329 return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
330 })
331
332 return regexPattern
333}