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