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 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), &params); 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}