v0.37.0 e363e10

glob.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close