v0.48.1 deaab59

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	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}

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 476

↑↓ moveenter openesc close