grep.go 8.4 KB

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