1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "regexp"
10 "sort"
11 "strings"
12
13 "github.com/patriceckhart/zot/packages/core"
14 "github.com/patriceckhart/zot/packages/ignore"
15 "github.com/patriceckhart/zot/packages/provider"
16)
17
18const (
19 maxGlobMatches = 500
20 maxGlobDepth = 24
21)
22
23// GlobTool finds files matching a pattern within a directory tree.
24type GlobTool struct {
25 CWD string
26 Sandbox *Sandbox
27}
28
29type globArgs struct {
30 Pattern string `json:"pattern"`
31 Path string `json:"path,omitempty"`
32 Hidden bool `json:"hidden,omitempty"`
33}
34
35const globSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"Glob pattern to match files against (e.g. \"**/*.go\", \"*.json\", \"src/**/*.ts\")"},"path":{"type":"string","description":"Directory to search within, relative to CWD (defaults to \".\")"},"hidden":{"type":"boolean","description":"Whether to include hidden files and directories (default false)"}},"required":["pattern"]}`
36
37func (t *GlobTool) Name() string { return "glob" }
38func (t *GlobTool) Description() string {
39 return "Find files matching a glob pattern (e.g. \"**/*.go\", \"*.json\", \"src/**/*.ts\"). Honors .gitignore rules."
40}
41func (t *GlobTool) Schema() json.RawMessage { return json.RawMessage(globSchema) }
42
43func (t *GlobTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
44 var a globArgs
45 if err := json.Unmarshal(raw, &a); err != nil {
46 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
47 }
48 if strings.TrimSpace(a.Pattern) == "" {
49 return core.ToolResult{}, fmt.Errorf("pattern is required")
50 }
51
52 re, hasSlash, err := compileGlob(a.Pattern)
53 if err != nil {
54 return core.ToolResult{}, err
55 }
56
57 givenSearchDir := resolvePath(t.CWD, a.Path)
58 if err := t.Sandbox.CheckReadPath(givenSearchDir); err != nil {
59 return core.ToolResult{}, err
60 }
61
62 info, err := os.Stat(givenSearchDir)
63 if err != nil {
64 return core.ToolResult{}, err
65 }
66 if !info.IsDir() {
67 shown := t.Sandbox.DisplayPath(givenSearchDir, a.Path)
68 return core.ToolResult{}, fmt.Errorf("%s is not a directory", shown)
69 }
70
71 // WalkDir does not follow a symlink used as its root. Resolve the explicit
72 // search directory so a path that Stat identified as a directory is
73 // actually traversed, then re-check the resolved target before reading it.
74 searchDir, err := filepath.EvalSymlinks(givenSearchDir)
75 if err != nil {
76 return core.ToolResult{}, err
77 }
78 if err := t.Sandbox.CheckReadPath(searchDir); err != nil {
79 return core.ToolResult{}, err
80 }
81
82 stack, ignoreRoot, searchIgnored := t.globIgnoreStack(searchDir)
83 rootSep := strings.Count(searchDir, string(os.PathSeparator))
84 var pushed []string
85 var matches []string
86 truncated := false
87
88 var walkErr error
89 if !searchIgnored {
90 walkErr = filepath.WalkDir(searchDir, func(path string, d os.DirEntry, err error) error {
91 if ctxErr := ctx.Err(); ctxErr != nil {
92 return ctxErr
93 }
94 if err != nil {
95 if d != nil && d.IsDir() {
96 return filepath.SkipDir
97 }
98 return nil
99 }
100 if path == searchDir {
101 return nil
102 }
103
104 rel, relErr := filepath.Rel(searchDir, path)
105 if relErr != nil {
106 return nil
107 }
108 relSlash := filepath.ToSlash(rel)
109
110 // Check hidden files/directories unless explicitly requested.
111 // .git is always skipped.
112 if !a.Hidden {
113 if strings.HasPrefix(d.Name(), ".") {
114 if d.IsDir() {
115 return filepath.SkipDir
116 }
117 return nil
118 }
119 } else if d.IsDir() && d.Name() == ".git" {
120 return filepath.SkipDir
121 }
122
123 ignoreRel, ignoreRelErr := filepath.Rel(ignoreRoot, path)
124 if ignoreRelErr != nil {
125 return nil
126 }
127 ignoreRelSlash := filepath.ToSlash(ignoreRel)
128
129 // .gitignore filtering: pop stack frames for directories no longer in scope.
130 dirSlash := relSlash
131 if !d.IsDir() {
132 if idx := strings.LastIndex(relSlash, "/"); idx >= 0 {
133 dirSlash = relSlash[:idx]
134 } else {
135 dirSlash = ""
136 }
137 }
138 for len(pushed) > 0 {
139 top := pushed[len(pushed)-1]
140 if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") {
141 break
142 }
143 pushed = pushed[:len(pushed)-1]
144 stack.Pop()
145 }
146
147 if stack.Match(ignoreRelSlash, d.IsDir()) {
148 if d.IsDir() {
149 return filepath.SkipDir
150 }
151 return nil
152 }
153
154 if d.IsDir() {
155 if strings.Count(path, string(os.PathSeparator))-rootSep >= maxGlobDepth {
156 return filepath.SkipDir
157 }
158 stack.Push(path, ignoreRel)
159 pushed = append(pushed, relSlash)
160 return nil
161 }
162
163 // Check if file matches the glob pattern.
164 matched := false
165 if hasSlash {
166 matched = re.MatchString(relSlash)
167 } else {
168 matched = re.MatchString(d.Name())
169 }
170
171 if matched {
172 matches = append(matches, t.globDisplayPath(givenSearchDir, a.Path, relSlash))
173 if len(matches) >= maxGlobMatches {
174 truncated = true
175 return filepath.SkipAll
176 }
177 }
178 return nil
179 })
180 }
181
182 if walkErr != nil && walkErr != filepath.SkipAll {
183 return core.ToolResult{}, walkErr
184 }
185
186 sort.Strings(matches)
187
188 var text string
189 if len(matches) == 0 {
190 text = "No files matched the pattern."
191 } else {
192 text = strings.Join(matches, "\n")
193 if truncated {
194 text += fmt.Sprintf("\n\n(Truncated: showing first %d matches)", maxGlobMatches)
195 }
196 }
197
198 return core.ToolResult{
199 Content: []provider.Content{provider.TextBlock{Text: text}},
200 Details: map[string]any{
201 "matches": len(matches),
202 "truncated": truncated,
203 "pattern": a.Pattern,
204 },
205 }, nil
206}
207
208// globIgnoreStack builds an ignore stack rooted at CWD when the search path is
209// below it, preloading the .gitignore files between CWD and the search root.
210// This preserves inherited rules when the caller narrows a search with path.
211func (t *GlobTool) globIgnoreStack(searchDir string) (*ignore.Stack, string, bool) {
212 cwd := resolvePath(t.CWD, ".")
213 cwd, err := filepath.EvalSymlinks(cwd)
214 if err != nil || t.Sandbox.CheckReadPath(cwd) != nil {
215 return ignore.NewStack(searchDir), searchDir, false
216 }
217
218 rel, err := filepath.Rel(cwd, searchDir)
219 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
220 return ignore.NewStack(searchDir), searchDir, false
221 }
222
223 stack := ignore.NewStack(cwd)
224 if rel == "." {
225 return stack, cwd, false
226 }
227
228 current := cwd
229 var relParts []string
230 for _, part := range strings.Split(rel, string(os.PathSeparator)) {
231 current = filepath.Join(current, part)
232 relParts = append(relParts, part)
233 currentRel := filepath.Join(relParts...)
234 if stack.Match(filepath.ToSlash(currentRel), true) {
235 return stack, cwd, true
236 }
237 stack.Push(current, currentRel)
238 }
239 return stack, cwd, false
240}
241
242func (t *GlobTool) globDisplayPath(searchDir, given, relSlash string) string {
243 if given == "" || given == "." {
244 return relSlash
245 }
246 prefix := filepath.ToSlash(t.Sandbox.DisplayPath(searchDir, given))
247 if prefix == "" || prefix == "." {
248 return relSlash
249 }
250 return strings.TrimSuffix(prefix, "/") + "/" + relSlash
251}
252
253// compileGlob converts a glob pattern into a regular expression.
254// It supports '*', '**', '?', character classes '[...]', and brace expansion '{a,b}'.
255func compileGlob(pattern string) (*regexp.Regexp, bool, error) {
256 pattern = filepath.ToSlash(strings.TrimSpace(pattern))
257 pattern = strings.TrimPrefix(pattern, "./")
258 if pattern == "" {
259 return nil, false, fmt.Errorf("empty pattern")
260 }
261
262 hasSlash := strings.Contains(pattern, "/")
263
264 var sb strings.Builder
265 sb.WriteString("^")
266
267 inBrace := false
268 i := 0
269 n := len(pattern)
270 for i < n {
271 c := pattern[i]
272 switch c {
273 case '*':
274 if i+1 < n && pattern[i+1] == '*' {
275 i += 2
276 if i < n && pattern[i] == '/' {
277 i++
278 sb.WriteString("(?:.*/)?")
279 } else {
280 sb.WriteString(".*")
281 }
282 } else {
283 i++
284 if hasSlash {
285 sb.WriteString("[^/]*")
286 } else {
287 sb.WriteString(".*")
288 }
289 }
290 case '?':
291 i++
292 if hasSlash {
293 sb.WriteString("[^/]")
294 } else {
295 sb.WriteString(".")
296 }
297 case '{':
298 closeIdx := strings.IndexByte(pattern[i:], '}')
299 if closeIdx > 0 && strings.Contains(pattern[i:i+closeIdx], ",") {
300 inBrace = true
301 sb.WriteString("(?:")
302 i++
303 } else {
304 sb.WriteString("\\{")
305 i++
306 }
307 case ',':
308 if inBrace {
309 sb.WriteString("|")
310 i++
311 } else {
312 sb.WriteString(",")
313 i++
314 }
315 case '}':
316 if inBrace {
317 sb.WriteString(")")
318 inBrace = false
319 i++
320 } else {
321 sb.WriteString("\\}")
322 i++
323 }
324 case '[':
325 j := i + 1
326 if j < n && pattern[j] == ']' {
327 j++
328 }
329 for j < n && pattern[j] != ']' {
330 j++
331 }
332 if j < n {
333 sb.WriteString(pattern[i : j+1])
334 i = j + 1
335 } else {
336 sb.WriteString("\\[")
337 i++
338 }
339 case '.', '+', '(', ')', '^', '$', '|', '\\':
340 sb.WriteByte('\\')
341 sb.WriteByte(c)
342 i++
343 default:
344 sb.WriteByte(c)
345 i++
346 }
347 }
348 sb.WriteString("$")
349
350 re, err := regexp.Compile(sb.String())
351 if err != nil {
352 return nil, false, fmt.Errorf("invalid glob pattern %q: %w", pattern, err)
353 }
354 return re, hasSlash, nil
355}