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