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