grep.go 7.8 KB

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