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