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