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
150 if searchRoot == "" {
151 searchRoot = "."
152 }
153
154 rgBin, err := exec.LookPath("rg")
155 if err != nil {
156 return nil, fmt.Errorf("ripgrep not found in $PATH: %w", err)
157 }
158
159 if !filepath.IsAbs(pattern) && !strings.HasPrefix(pattern, "/") {
160 pattern = "/" + pattern
161 }
162
163 args := []string{
164 "--files",
165 "--null",
166 "--glob", pattern,
167 "-L",
168 }
169
170 cmd := exec.Command(rgBin, args...)
171 cmd.Dir = searchRoot
172
173 out, err := cmd.CombinedOutput()
174 if err != nil {
175 if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 {
176 return nil, nil
177 }
178 return nil, fmt.Errorf("ripgrep: %w\n%s", err, out)
179 }
180
181 var matches []string
182 for _, p := range bytes.Split(out, []byte{0}) {
183 if len(p) == 0 {
184 continue
185 }
186 abs := filepath.Join(searchRoot, string(p))
187 if skipHidden(abs) {
188 continue
189 }
190 matches = append(matches, abs)
191 }
192
193 sort.SliceStable(matches, func(i, j int) bool {
194 return len(matches[i]) < len(matches[j])
195 })
196
197 if len(matches) > limit {
198 matches = matches[:limit]
199 }
200 return matches, nil
201}
202
203func globWithDoublestar(pattern, searchPath string, limit int) ([]string, bool, error) {
204 if !strings.HasPrefix(pattern, "/") && !strings.HasPrefix(pattern, searchPath) {
205 if !strings.HasSuffix(searchPath, "/") {
206 searchPath += "/"
207 }
208 pattern = searchPath + pattern
209 }
210
211 fsys := os.DirFS("/")
212
213 relPattern := strings.TrimPrefix(pattern, "/")
214
215 var matches []fileInfo
216
217 err := doublestar.GlobWalk(fsys, relPattern, func(path string, d fs.DirEntry) error {
218 if d.IsDir() {
219 return nil
220 }
221 if skipHidden(path) {
222 return nil
223 }
224
225 info, err := d.Info()
226 if err != nil {
227 return nil // Skip files we can't access
228 }
229
230 absPath := "/" + path // Restore absolute path
231 matches = append(matches, fileInfo{
232 path: absPath,
233 modTime: info.ModTime(),
234 })
235
236 if len(matches) >= limit*2 { // Collect more than needed for sorting
237 return fs.SkipAll
238 }
239
240 return nil
241 })
242 if err != nil {
243 return nil, false, fmt.Errorf("glob walk error: %w", err)
244 }
245
246 sort.Slice(matches, func(i, j int) bool {
247 return matches[i].modTime.After(matches[j].modTime)
248 })
249
250 truncated := len(matches) > limit
251 if truncated {
252 matches = matches[:limit]
253 }
254
255 results := make([]string, len(matches))
256 for i, m := range matches {
257 results[i] = m.path
258 }
259
260 return results, truncated, nil
261}
262
263func skipHidden(path string) bool {
264 // Check for hidden files (starting with a dot)
265 base := filepath.Base(path)
266 if base != "." && strings.HasPrefix(base, ".") {
267 return true
268 }
269
270 // List of commonly ignored directories in development projects
271 commonIgnoredDirs := map[string]bool{
272 "node_modules": true,
273 "vendor": true,
274 "dist": true,
275 "build": true,
276 "target": true,
277 ".git": true,
278 ".idea": true,
279 ".vscode": true,
280 "__pycache__": true,
281 "bin": true,
282 "obj": true,
283 "out": true,
284 "coverage": true,
285 "tmp": true,
286 "temp": true,
287 "logs": true,
288 "generated": true,
289 "bower_components": true,
290 "jspm_packages": true,
291 }
292
293 // Check if any path component is in our ignore list
294 parts := strings.SplitSeq(path, string(os.PathSeparator))
295 for part := range parts {
296 if commonIgnoredDirs[part] {
297 return true
298 }
299 }
300
301 return false
302}