grep.go

  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), &params); 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}