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