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 walkErr := filepath.WalkDir(searchDir, func(path string, d os.DirEntry, err error) error {
112 if ctxErr := ctx.Err(); ctxErr != nil {
113 return ctxErr
114 }
115 if err != nil {
116 if d != nil && d.IsDir() {
117 return filepath.SkipDir
118 }
119 return nil
120 }
121 if path == searchDir {
122 return nil
123 }
124
125 rel, relErr := filepath.Rel(searchDir, path)
126 if relErr != nil {
127 return nil
128 }
129 relSlash := filepath.ToSlash(rel)
130
131 // Hidden entries are skipped unless explicitly requested.
132 // .git is always skipped. Nested symlinks are never followed: a
133 // symlink to a directory is reported by WalkDir as a non-directory,
134 // so resolve it explicitly and skip directory targets instead of
135 // listing them as file matches.
136 if d.Type()&os.ModeSymlink != 0 {
137 if target, evalErr := filepath.EvalSymlinks(path); evalErr == nil {
138 if targetInfo, statErr := os.Stat(target); statErr == nil && targetInfo.IsDir() {
139 return nil
140 }
141 }
142 }
143 if !args.Hidden {
144 if strings.HasPrefix(d.Name(), ".") {
145 if d.IsDir() {
146 return filepath.SkipDir
147 }
148 return nil
149 }
150 } else if d.IsDir() && d.Name() == ".git" {
151 return filepath.SkipDir
152 }
153
154 ignoreRel, ignoreRelErr := filepath.Rel(ignoreRoot, path)
155 if ignoreRelErr != nil {
156 return nil
157 }
158 ignoreRelSlash := filepath.ToSlash(ignoreRel)
159
160 // Ignore filtering: pop stack frames for directories no
161 // longer in scope.
162 dirSlash := relSlash
163 if !d.IsDir() {
164 if idx := strings.LastIndex(relSlash, "/"); idx >= 0 {
165 dirSlash = relSlash[:idx]
166 } else {
167 dirSlash = ""
168 }
169 }
170 for len(pushed) > 0 {
171 top := pushed[len(pushed)-1]
172 if top == dirSlash || strings.HasPrefix(dirSlash, top+"/") {
173 break
174 }
175 pushed = pushed[:len(pushed)-1]
176 stack.Pop()
177 }
178
179 if stack.Match(ignoreRelSlash, d.IsDir()) {
180 if d.IsDir() {
181 return filepath.SkipDir
182 }
183 return nil
184 }
185
186 if d.IsDir() {
187 if strings.Count(path, string(os.PathSeparator))-rootSep >= maxGlobDepth {
188 return filepath.SkipDir
189 }
190 stack.Push(path, ignoreRel)
191 pushed = append(pushed, relSlash)
192 return nil
193 }
194
195 var matched bool
196 if hasSlash {
197 matched = re.MatchString(relSlash)
198 } else {
199 matched = re.MatchString(d.Name())
200 }
201 if matched {
202 matches = append(matches, t.globDisplayPath(root, args.Path, relSlash))
203 discovered = append(discovered, canonicalResourcePath(filepath.Join(root, filepath.FromSlash(relSlash))))
204 if len(matches) >= maxGlobMatches {
205 truncated = true
206 return filepath.SkipAll
207 }
208 }
209 return nil
210 })
211
212 if walkErr != nil && walkErr != filepath.SkipAll {
213 return core.ToolResult{}, walkErr
214 }
215
216 sort.Strings(matches)
217
218 var text string
219 if len(matches) == 0 {
220 text = "No files matched the pattern."
221 } else {
222 text = strings.Join(matches, "\n")
223 if truncated {
224 text += fmt.Sprintf("\n\n(Truncated: showing first %d matches)", maxGlobMatches)
225 }
226 }
227
228 return core.ToolResult{
229 Content: []provider.Content{provider.TextBlock{Text: text}},
230 Context: provider.ToolContext{Discovers: discovered},
231 Details: map[string]any{
232 "matches": len(matches),
233 "truncated": truncated,
234 "pattern": args.Pattern,
235 },
236 }, nil
237}
238
239func parseGlobArgs(raw json.RawMessage) (globArgs, error) {
240 if len(raw) == 0 || len(raw) > maxGlobInputBytes {
241 return globArgs{}, fmt.Errorf("glob: invalid arguments")
242 }
243 var fields map[string]json.RawMessage
244 if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
245 return globArgs{}, fmt.Errorf("glob: invalid arguments")
246 }
247 for key := range fields {
248 if key != "pattern" && key != "path" && key != "hidden" {
249 return globArgs{}, fmt.Errorf("glob: unknown argument %q", key)
250 }
251 }
252 var args globArgs
253 if err := json.Unmarshal(raw, &args); err != nil {
254 return globArgs{}, fmt.Errorf("glob: invalid arguments: %w", err)
255 }
256 if _, ok := fields["pattern"]; !ok || strings.TrimSpace(args.Pattern) == "" {
257 return globArgs{}, fmt.Errorf("glob: pattern is required")
258 }
259 if !utf8.ValidString(args.Pattern) || !utf8.ValidString(args.Path) ||
260 strings.IndexByte(args.Pattern, 0) >= 0 || strings.IndexByte(args.Path, 0) >= 0 {
261 return globArgs{}, fmt.Errorf("glob: arguments contain invalid characters")
262 }
263 return args, nil
264}
265
266// globIgnoreStack builds an ignore stack rooted at CWD when the search path
267// is below it, preloading the ignore files between CWD and the search root.
268// This preserves inherited rules when the caller narrows a search with path.
269func (t *GlobTool) globIgnoreStack(searchDir string) (*ignore.Stack, string, bool) {
270 cwd := resolvePath(t.CWD, ".")
271 cwd, err := filepath.EvalSymlinks(cwd)
272 if err != nil || t.Sandbox.CheckReadPath(cwd) != nil {
273 return ignore.NewStack(searchDir), searchDir, false
274 }
275
276 rel, err := filepath.Rel(cwd, searchDir)
277 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
278 return ignore.NewStack(searchDir), searchDir, false
279 }
280
281 stack := ignore.NewStack(cwd)
282 if rel == "." {
283 return stack, cwd, false
284 }
285
286 current := cwd
287 var relParts []string
288 for _, part := range strings.Split(rel, string(os.PathSeparator)) {
289 current = filepath.Join(current, part)
290 relParts = append(relParts, part)
291 currentRel := filepath.Join(relParts...)
292 if stack.Match(filepath.ToSlash(currentRel), true) {
293 return stack, cwd, true
294 }
295 stack.Push(current, currentRel)
296 }
297 return stack, cwd, false
298}
299
300func (t *GlobTool) globDisplayPath(searchDir, given, relSlash string) string {
301 if given == "" || given == "." {
302 return relSlash
303 }
304 // Prefer the path exactly as requested so a symlinked search root
305 // keeps its name in results instead of the resolved target.
306 if !filepath.IsAbs(given) {
307 cleaned := filepath.ToSlash(filepath.Clean(given))
308 if cleaned != "" && cleaned != "." {
309 return strings.TrimSuffix(cleaned, "/") + "/" + relSlash
310 }
311 return relSlash
312 }
313 prefix := filepath.ToSlash(t.Sandbox.DisplayPath(searchDir, given))
314 if prefix == "" || prefix == "." {
315 return relSlash
316 }
317 return strings.TrimSuffix(prefix, "/") + "/" + relSlash
318}
319
320// compileGlob converts a slash-normalized glob pattern into a regular
321// expression. It supports '*', '**', '?', and character classes '[...]'.
322// Patterns containing a slash match against the path relative to the search
323// root; patterns without one match against the file basename.
324func compileGlob(pattern string) (*regexp.Regexp, bool, error) {
325 pattern = filepath.ToSlash(strings.TrimSpace(pattern))
326 pattern = strings.TrimPrefix(pattern, "./")
327 if pattern == "" {
328 return nil, false, fmt.Errorf("glob: empty pattern")
329 }
330
331 hasSlash := strings.Contains(pattern, "/")
332
333 var sb strings.Builder
334 sb.WriteString("^")
335 i := 0
336 for i < len(pattern) {
337 switch c := pattern[i]; c {
338 case '*':
339 if i+1 < len(pattern) && pattern[i+1] == '*' {
340 i += 2
341 if i < len(pattern) && pattern[i] == '/' {
342 i++
343 sb.WriteString("(?:.*/)?")
344 } else {
345 sb.WriteString(".*")
346 }
347 } else {
348 i++
349 if hasSlash {
350 sb.WriteString("[^/]*")
351 } else {
352 sb.WriteString(".*")
353 }
354 }
355 case '?':
356 i++
357 if hasSlash {
358 sb.WriteString("[^/]")
359 } else {
360 sb.WriteString(".")
361 }
362 case '[':
363 j := i + 1
364 negated := false
365 if j < len(pattern) && (pattern[j] == '^' || pattern[j] == '!') {
366 negated = pattern[j] == '!'
367 j++
368 }
369 if j < len(pattern) && pattern[j] == ']' {
370 j++
371 }
372 for j < len(pattern) && pattern[j] != ']' {
373 j++
374 }
375 if j < len(pattern) {
376 class := pattern[i+1 : j+1]
377 if negated {
378 class = "^" + class[1:]
379 }
380 sb.WriteString("[" + class)
381 i = j + 1
382 } else {
383 return nil, false, fmt.Errorf("glob: invalid pattern %q: unterminated character class", pattern)
384 }
385 case '.', '+', '(', ')', '^', '$', '|', '\\', '{', '}', ',':
386 sb.WriteByte('\\')
387 sb.WriteByte(c)
388 i++
389 default:
390 sb.WriteByte(c)
391 i++
392 }
393 }
394 sb.WriteString("$")
395
396 re, err := regexp.Compile(sb.String())
397 if err != nil {
398 return nil, false, fmt.Errorf("glob: invalid pattern %q: %w", pattern, err)
399 }
400 return re, hasSlash, nil
401}