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