package tools import ( "bufio" "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "time" "github.com/kujtimiihoxha/termai/internal/config" ) type grepTool struct{} const ( GrepToolName = "grep" ) type GrepParams struct { Pattern string `json:"pattern"` Path string `json:"path"` Include string `json:"include"` } type grepMatch struct { path string modTime time.Time } func (g *grepTool) Info() ToolInfo { return ToolInfo{ Name: GrepToolName, Description: grepDescription(), Parameters: map[string]any{ "pattern": map[string]any{ "type": "string", "description": "The regex pattern to search for in file contents", }, "path": map[string]any{ "type": "string", "description": "The directory to search in. Defaults to the current working directory.", }, "include": map[string]any{ "type": "string", "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")", }, }, Required: []string{"pattern"}, } } // Run implements Tool. func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) { var params GrepParams if err := json.Unmarshal([]byte(call.Input), ¶ms); err != nil { return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil } if params.Pattern == "" { return NewTextErrorResponse("pattern is required"), nil } // If path is empty, use current working directory searchPath := params.Path if searchPath == "" { searchPath = config.WorkingDirectory() } matches, truncated, err := searchFiles(params.Pattern, searchPath, params.Include, 100) if err != nil { return NewTextErrorResponse(fmt.Sprintf("error searching files: %s", err)), nil } // Format the output for the assistant var output string if len(matches) == 0 { output = "No files found" } else { output = fmt.Sprintf("Found %d file%s\n%s", len(matches), pluralize(len(matches)), strings.Join(matches, "\n")) if truncated { output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)" } } return NewTextResponse(output), nil } func pluralize(count int) string { if count == 1 { return "" } return "s" } func searchFiles(pattern, rootPath, include string, limit int) ([]string, bool, error) { // First try using ripgrep if available for better performance matches, err := searchWithRipgrep(pattern, rootPath, include) if err != nil { // Fall back to manual regex search if ripgrep is not available matches, err = searchFilesWithRegex(pattern, rootPath, include) if err != nil { return nil, false, err } } // Sort files by modification time (newest first) sort.Slice(matches, func(i, j int) bool { return matches[i].modTime.After(matches[j].modTime) }) // Check if we need to truncate the results truncated := len(matches) > limit if truncated { matches = matches[:limit] } // Extract just the paths results := make([]string, len(matches)) for i, m := range matches { results[i] = m.path } return results, truncated, nil } func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) { _, err := exec.LookPath("rg") if err != nil { return nil, fmt.Errorf("ripgrep not found: %w", err) } args := []string{"-l", pattern} if include != "" { args = append(args, "--glob", include) } args = append(args, path) cmd := exec.Command("rg", args...) output, err := cmd.Output() if err != nil { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { // Exit code 1 means no matches, which isn't an error for our purposes return []grepMatch{}, nil } return nil, err } lines := strings.Split(strings.TrimSpace(string(output)), "\n") matches := make([]grepMatch, 0, len(lines)) for _, line := range lines { if line == "" { continue } fileInfo, err := os.Stat(line) if err != nil { continue // Skip files we can't access } matches = append(matches, grepMatch{ path: line, modTime: fileInfo.ModTime(), }) } return matches, nil } func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) { matches := []grepMatch{} regex, err := regexp.Compile(pattern) if err != nil { return nil, fmt.Errorf("invalid regex pattern: %w", err) } var includePattern *regexp.Regexp if include != "" { regexPattern := globToRegex(include) includePattern, err = regexp.Compile(regexPattern) if err != nil { return nil, fmt.Errorf("invalid include pattern: %w", err) } } err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip errors } if info.IsDir() { return nil // Skip directories } // Skip hidden files if skipHidden(path) { return nil } // Check include pattern if provided if includePattern != nil && !includePattern.MatchString(path) { return nil } // Check file contents for the pattern match, err := fileContainsPattern(path, regex) if err != nil { return nil // Skip files we can't read } if match { matches = append(matches, grepMatch{ path: path, modTime: info.ModTime(), }) // Check if we've hit the limit (collect double for sorting) if len(matches) >= 200 { return filepath.SkipAll } } return nil }) if err != nil { return nil, err } return matches, nil } func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) { file, err := os.Open(filePath) if err != nil { return false, err } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { if pattern.MatchString(scanner.Text()) { return true, nil } } return false, scanner.Err() } func globToRegex(glob string) string { regexPattern := strings.ReplaceAll(glob, ".", "\\.") regexPattern = strings.ReplaceAll(regexPattern, "*", ".*") regexPattern = strings.ReplaceAll(regexPattern, "?", ".") re := regexp.MustCompile(`\{([^}]+)\}`) regexPattern = re.ReplaceAllStringFunc(regexPattern, func(match string) string { inner := match[1 : len(match)-1] return "(" + strings.ReplaceAll(inner, ",", "|") + ")" }) return regexPattern } func grepDescription() string { return `Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first). WHEN TO USE THIS TOOL: - Use when you need to find files containing specific text or patterns - Great for searching code bases for function names, variable declarations, or error messages - Useful for finding all files that use a particular API or pattern HOW TO USE: - Provide a regex pattern to search for within file contents - Optionally specify a starting directory (defaults to current working directory) - Optionally provide an include pattern to filter which files to search - Results are sorted with most recently modified files first REGEX PATTERN SYNTAX: - Supports standard regular expression syntax - 'function' searches for the literal text "function" - 'log\..*Error' finds text starting with "log." and ending with "Error" - 'import\s+.*\s+from' finds import statements in JavaScript/TypeScript COMMON INCLUDE PATTERN EXAMPLES: - '*.js' - Only search JavaScript files - '*.{ts,tsx}' - Only search TypeScript files - '*.go' - Only search Go files LIMITATIONS: - Results are limited to 100 files (newest first) - Performance depends on the number of files being searched - Very large binary files may be skipped - Hidden files (starting with '.') are skipped TIPS: - For faster, more targeted searches, first use Glob to find relevant files, then use Grep - When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead - Always check if results are truncated and refine your search pattern if needed` } func NewGrepTool() BaseTool { return &grepTool{} }