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