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