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), ¶ms); 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}