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