glob.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. package tools
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io/fs"
  8. "os"
  9. "os/exec"
  10. "path/filepath"
  11. "sort"
  12. "strings"
  13. "time"
  14. "github.com/bmatcuk/doublestar/v4"
  15. "github.com/opencode-ai/opencode/internal/config"
  16. )
  17. const (
  18. GlobToolName = "glob"
  19. globDescription = `Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first).
  20. WHEN TO USE THIS TOOL:
  21. - Use when you need to find files by name patterns or extensions
  22. - Great for finding specific file types across a directory structure
  23. - Useful for discovering files that match certain naming conventions
  24. HOW TO USE:
  25. - Provide a glob pattern to match against file paths
  26. - Optionally specify a starting directory (defaults to current working directory)
  27. - Results are sorted with most recently modified files first
  28. GLOB PATTERN SYNTAX:
  29. - '*' matches any sequence of non-separator characters
  30. - '**' matches any sequence of characters, including separators
  31. - '?' matches any single non-separator character
  32. - '[...]' matches any character in the brackets
  33. - '[!...]' matches any character not in the brackets
  34. COMMON PATTERN EXAMPLES:
  35. - '*.js' - Find all JavaScript files in the current directory
  36. - '**/*.js' - Find all JavaScript files in any subdirectory
  37. - 'src/**/*.{ts,tsx}' - Find all TypeScript files in the src directory
  38. - '*.{html,css,js}' - Find all HTML, CSS, and JS files
  39. LIMITATIONS:
  40. - Results are limited to 100 files (newest first)
  41. - Does not search file contents (use Grep tool for that)
  42. - Hidden files (starting with '.') are skipped
  43. TIPS:
  44. - For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with Grep
  45. - When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead
  46. - Always check if results are truncated and refine your search pattern if needed`
  47. )
  48. type fileInfo struct {
  49. path string
  50. modTime time.Time
  51. }
  52. type GlobParams struct {
  53. Pattern string `json:"pattern"`
  54. Path string `json:"path"`
  55. }
  56. type GlobResponseMetadata struct {
  57. NumberOfFiles int `json:"number_of_files"`
  58. Truncated bool `json:"truncated"`
  59. }
  60. type globTool struct{}
  61. func NewGlobTool() BaseTool {
  62. return &globTool{}
  63. }
  64. func (g *globTool) Info() ToolInfo {
  65. return ToolInfo{
  66. Name: GlobToolName,
  67. Description: globDescription,
  68. Parameters: map[string]any{
  69. "pattern": map[string]any{
  70. "type": "string",
  71. "description": "The glob pattern to match files against",
  72. },
  73. "path": map[string]any{
  74. "type": "string",
  75. "description": "The directory to search in. Defaults to the current working directory.",
  76. },
  77. },
  78. Required: []string{"pattern"},
  79. }
  80. }
  81. func (g *globTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
  82. var params GlobParams
  83. if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
  84. return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
  85. }
  86. if params.Pattern == "" {
  87. return NewTextErrorResponse("pattern is required"), nil
  88. }
  89. searchPath := params.Path
  90. if searchPath == "" {
  91. searchPath = config.WorkingDirectory()
  92. }
  93. files, truncated, err := globFiles(params.Pattern, searchPath, 100)
  94. if err != nil {
  95. return ToolResponse{}, fmt.Errorf("error finding files: %w", err)
  96. }
  97. var output string
  98. if len(files) == 0 {
  99. output = "No files found"
  100. } else {
  101. output = strings.Join(files, "\n")
  102. if truncated {
  103. output += "\n\n(Results are truncated. Consider using a more specific path or pattern.)"
  104. }
  105. }
  106. return WithResponseMetadata(
  107. NewTextResponse(output),
  108. GlobResponseMetadata{
  109. NumberOfFiles: len(files),
  110. Truncated: truncated,
  111. },
  112. ), nil
  113. }
  114. func globFiles(pattern, searchPath string, limit int) ([]string, bool, error) {
  115. matches, err := globWithRipgrep(pattern, searchPath, limit)
  116. if err == nil {
  117. return matches, len(matches) >= limit, nil
  118. }
  119. return globWithDoublestar(pattern, searchPath, limit)
  120. }
  121. func globWithRipgrep(
  122. pattern, searchRoot string,
  123. limit int,
  124. ) ([]string, error) {
  125. if searchRoot == "" {
  126. searchRoot = "."
  127. }
  128. rgBin, err := exec.LookPath("rg")
  129. if err != nil {
  130. return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err)
  131. }
  132. if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, "/") {
  133. pattern = "/" + pattern
  134. }
  135. args := []string{
  136. "--files",
  137. "--null",
  138. "--glob", pattern,
  139. "-L",
  140. }
  141. cmd := exec.Command(rgBin, args...)
  142. cmd.Dir = searchRoot
  143. out, err := cmd.CombinedOutput()
  144. if err != nil {
  145. if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
  146. return nil, nil
  147. }
  148. return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
  149. }
  150. var matches []string
  151. for _, p := range bytes.Split(out, []byte{0}) {
  152. if len(p) == 0 {
  153. continue
  154. }
  155. abs := filepath.Join(searchRoot, string(p))
  156. if skipHidden(abs) {
  157. continue
  158. }
  159. matches = append(matches, abs)
  160. }
  161. sort.SliceStable(matches, func(i, j int) bool {
  162. return len(matches[i]) < len(matches[j])
  163. })
  164. if len(matches) > limit {
  165. matches = matches[:limit]
  166. }
  167. return matches, nil
  168. }
  169. func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
  170. fsys := os.DirFS(searchPath)
  171. relPattern := strings.TrimPrefix(pattern, "/")
  172. var matches []fileInfo
  173. err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
  174. if d.IsDir() {
  175. return nil
  176. }
  177. if skipHidden(path) {
  178. return nil
  179. }
  180. info, err := d.Info()
  181. if err != nil {
  182. return nil // Skip files we can't access
  183. }
  184. absPath := path // Restore absolute path
  185. if !strings.HasPrefix(absPath, searchPath) {
  186. absPath = filepath.Join(searchPath, absPath)
  187. }
  188. matches = append(matches, fileInfo{
  189. path: absPath,
  190. modTime: info.ModTime(),
  191. })
  192. if len(matches) >= limit*2 { // Collect more than needed for sorting
  193. return fs.SkipAll
  194. }
  195. return nil
  196. })
  197. if err != nil {
  198. return nil, false, fmt.Errorf("glob walk error: %w", err)
  199. }
  200. sort.Slice(matches, func(i, j int) bool {
  201. return matches[i].modTime.After(matches[j].modTime)
  202. })
  203. truncated := len(matches) > limit
  204. if truncated {
  205. matches = matches[:limit]
  206. }
  207. results := make([]string, len(matches))
  208. for i, m := range matches {
  209. results[i] = m.path
  210. }
  211. return results, truncated, nil
  212. }
  213. func skipHidden(path string) bool {
  214. // Check for hidden files (starting with a dot)
  215. base := filepath.Base(path)
  216. if base != "." && strings.HasPrefix(base, ".") {
  217. return true
  218. }
  219. // List of commonly ignored directories in development projects
  220. commonIgnoredDirs := map[string]bool{
  221. "node_modules": true,
  222. "vendor": true,
  223. "dist": true,
  224. "build": true,
  225. "target": true,
  226. ".git": true,
  227. ".idea": true,
  228. ".vscode": true,
  229. "__pycache__": true,
  230. "bin": true,
  231. "obj": true,
  232. "out": true,
  233. "coverage": true,
  234. "tmp": true,
  235. "temp": true,
  236. "logs": true,
  237. "generated": true,
  238. "bower_components": true,
  239. "jspm_packages": true,
  240. }
  241. // Check if any path component is in our ignore list
  242. parts := strings.SplitSeq(path, string(os.PathSeparator))
  243. for part := range parts {
  244. if commonIgnoredDirs[part] {
  245. return true
  246. }
  247. }
  248. return false
  249. }