v0.4.8 de90baf

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

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