v0.50.1 a637010

grep.go RawHistoryFolder

1package tools
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"os"
10	"os/exec"
11	"path/filepath"
12	"regexp"
13	"strings"
14	"sync"
15	"time"
16	"unicode/utf8"
17
18	"github.com/bnema/zut/packages/core"
19	"github.com/bnema/zut/packages/provider"
20)
21
22const (
23	maxGrepInputBytes     = 16 * 1024
24	maxGrepOutputBytes    = 50 * 1024
25	maxGrepOutputLines    = 2000
26	grepTruncationReserve = 128
27	grepInternalTimeout   = 30 * time.Second
28)
29
30// GrepTool searches text files below a requested path without invoking a
31// shell. It prefers ripgrep and uses the system grep as a portable fallback.
32// LookPath is injectable for deterministic tests; nil uses exec.LookPath.
33type GrepTool struct {
34	CWD      string
35	Sandbox  *Sandbox
36	LookPath func(string) (string, error)
37	// ASTAvailable reports whether the ast tool is registered and usable.
38	// When nil or false, grep never suggests ast.
39	ASTAvailable func() bool
40}
41
42var _ core.Tool = (*GrepTool)(nil)
43
44type grepArgs struct {
45	Pattern string `json:"pattern"`
46	Path    string `json:"path"`
47}
48
49const grepSchema = `{"type":"object","properties":{"pattern":{"type":"string","description":"Regular expression to search for."},"path":{"type":"string","description":"File or directory to search recursively."}},"required":["pattern","path"],"additionalProperties":false}`
50
51func (t *GrepTool) Name() string { return "grep" }
52
53func (t *GrepTool) Description() string {
54	return "Search files for a regular expression using ripgrep when available, with a portable grep fallback; fallback regex syntax and ignore/hidden-file behavior may differ."
55}
56
57func (t *GrepTool) Schema() json.RawMessage { return json.RawMessage(grepSchema) }
58
59// Execute searches the requested file or directory. The search root is
60// checked before the executable is selected or started, so a denied path
61// cannot be used to probe or read through the subprocess.
62func (t *GrepTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
63	return t.executeWithTimeout(ctx, raw, progress, grepInternalTimeout)
64}
65
66// grepTimeoutError distinguishes the search's hard deadline from a caller
67// cancellation or deadline, so the caller gets actionable search guidance.
68type grepTimeoutError struct {
69	after time.Duration
70}
71
72func (e *grepTimeoutError) Error() string {
73	return fmt.Sprintf("grep: search timed out after %s; try narrowing the path or glob", e.after)
74}
75
76func (e *grepTimeoutError) Timeout() bool { return true }
77
78// executeWithTimeout keeps the production deadline in Execute while allowing
79// deadline behavior to be tested without waiting 30 seconds.
80func (t *GrepTool) executeWithTimeout(ctx context.Context, raw json.RawMessage, progress func(string), timeout time.Duration) (core.ToolResult, error) {
81	args, err := parseGrepArgs(raw)
82	if err != nil {
83		return core.ToolResult{}, err
84	}
85	if err := ctx.Err(); err != nil {
86		return core.ToolResult{}, err
87	}
88
89	root := filepath.Clean(resolvePath(t.CWD, args.Path))
90	if !filepath.IsAbs(root) {
91		root, err = filepath.Abs(root)
92		if err != nil {
93			return core.ToolResult{}, err
94		}
95	}
96	if err := t.Sandbox.CheckReadPath(root); err != nil {
97		return core.ToolResult{}, err
98	}
99	info, err := os.Stat(root)
100	if err != nil {
101		return core.ToolResult{}, err
102	}
103	if !info.IsDir() && !info.Mode().IsRegular() {
104		return core.ToolResult{}, fmt.Errorf("grep: %s is not a regular file or directory", args.Path)
105	}
106
107	engine, executable, err := t.searchExecutable()
108	if err != nil {
109		return core.ToolResult{}, err
110	}
111
112	commandArgs := grepCommandArgs(engine, args.Pattern, root)
113	// WithTimeout preserves a sooner deadline or cancellation from ctx; the
114	// internal deadline can only add a limit when the caller has not already
115	// supplied a stricter one.
116	runCtx, cancel := context.WithTimeout(ctx, timeout)
117	defer cancel()
118	cmd := exec.CommandContext(runCtx, executable, commandArgs...)
119	if info.IsDir() {
120		cmd.Dir = root
121	} else {
122		cmd.Dir = filepath.Dir(root)
123	}
124	// Reuse the process-tree cancellation behavior used by the direct command
125	// tools. This does not invoke a shell; executable is run directly above.
126	configureBashProcess(cmd, nil)
127
128	output := &grepOutput{
129		progress: progress,
130		cancel: func() {
131			if cmd.Cancel != nil {
132				_ = cmd.Cancel()
133			}
134		},
135	}
136	cmd.Stdout = output
137	cmd.Stderr = output
138	runErr := cmd.Run()
139	if err := ctx.Err(); err != nil {
140		return core.ToolResult{}, err
141	}
142	if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
143		return core.ToolResult{}, &grepTimeoutError{after: timeout}
144	}
145
146	actualExitCode := 0
147	if runErr != nil {
148		var exitErr *exec.ExitError
149		if !errors.As(runErr, &exitErr) {
150			return core.ToolResult{}, fmt.Errorf("grep: run %s: %w", engine, runErr)
151		}
152		actualExitCode = exitErr.ExitCode()
153	}
154
155	outputCancelled := output.wasCancelled()
156	matched := actualExitCode == 0 || outputCancelled
157	noMatch := actualExitCode == 1
158	if noMatch {
159		// Both rg and grep use exit status 1 for a successful search with no
160		// matches. Do not turn that normal result into a failed tool call.
161		matched = false
162	}
163
164	text, linesTruncated := output.text()
165	if noMatch && strings.TrimSpace(text) == "" {
166		text = "No matches found.\n"
167	}
168	if text == "" && actualExitCode != 0 {
169		text = fmt.Sprintf("%s exited with status %d.\n", engine, actualExitCode)
170	}
171
172	exitCode := actualExitCode
173	if noMatch || outputCancelled {
174		exitCode = 0
175	}
176	isError := actualExitCode > 1 || actualExitCode < 0
177	if outputCancelled {
178		// Reaching the output bound is an intentional, successful stop rather
179		// than a failed search. Preserve the process status separately for
180		// diagnostics while reporting the normalized tool exit code above.
181		isError = false
182	}
183
184	discovers := grepResultPaths(text, root, info.IsDir())
185	// The hint is appended after path discovery so it is never read as a
186	// "file:line" result.
187	hint := ""
188	if !isError && t.ASTAvailable != nil {
189		if hint = grepASTHint(args.Pattern); hint != "" && !t.ASTAvailable() {
190			hint = ""
191		}
192	}
193	text += hint
194	return core.ToolResult{
195		Content: []provider.Content{provider.TextBlock{Text: text}},
196		IsError: isError,
197		Context: provider.ToolContext{Discovers: discovers},
198		Details: map[string]any{
199			"engine":            engine,
200			"path":              t.Sandbox.DisplayPath(root, args.Path),
201			"exit_code":         exitCode,
202			"process_exit_code": actualExitCode,
203			"matched":           matched,
204			"bytes_truncated":   output.bytesTruncated(),
205			"lines_truncated":   linesTruncated,
206			"output_cancelled":  outputCancelled,
207			"ast_hint":          hint != "",
208		},
209	}, nil
210}
211
212var (
213	// grepCallPattern matches regexes that search for a call, such as
214	// `fmt\.Errorf\(` or `\bNewClient\(`. The capture is the callee.
215	// Qualified names need an escaped dot: an unescaped `.` matches any
216	// character, so a literal structural pattern would be narrower.
217	grepCallPattern = regexp.MustCompile(`^(?:\\b)?([A-Za-z_]\w*(?:\\\.[A-Za-z_]\w*)*)\\\($`)
218	// grepDefinitionPattern matches regexes that search for a definition.
219	grepDefinitionPattern = regexp.MustCompile(`^\^?(?:\\s\*|\s)*(?:func|def|class|fn|function|interface|struct|impl)(?:\s|\\s|\\b|\\\()`)
220)
221
222// grepASTHint suggests the structural ast tool when a regex is really a
223// search for code shape. Regexes miss calls split across lines and cannot
224// tell code from comments or strings; ast-grep handles both.
225func grepASTHint(pattern string) string {
226	if match := grepCallPattern.FindStringSubmatch(pattern); match != nil {
227		callee := strings.ReplaceAll(match[1], `\.`, ".")
228		return fmt.Sprintf("\nTip: for code structure, the ast tool matches calls across lines and skips comments and strings: ast {\"pattern\":%q,\"path\":\"...\"}. Add \"rewrite\" to change every call at once.\n", callee+"($$$ARGS)")
229	}
230	if grepDefinitionPattern.MatchString(pattern) {
231		return "\nTip: for definitions, the ast tool matches code shape, e.g. ast {\"pattern\":\"func $NAME($$$) $$$ { $$$ }\",\"path\":\"...\"}.\n"
232	}
233	return ""
234}
235
236func grepResultPaths(output, root string, rootIsDir bool) []string {
237	seen := make(map[string]bool)
238	var paths []string
239	for _, line := range strings.Split(output, "\n") {
240		candidate, _, ok := strings.Cut(line, ":")
241		if !ok || candidate == "" {
242			continue
243		}
244		path := candidate
245		if !filepath.IsAbs(path) {
246			base := root
247			if !rootIsDir {
248				base = filepath.Dir(root)
249			}
250			path = filepath.Join(base, path)
251		}
252		path = canonicalResourcePath(path)
253		if !seen[path] {
254			seen[path] = true
255			paths = append(paths, path)
256		}
257	}
258	return paths
259}
260
261func parseGrepArgs(raw json.RawMessage) (grepArgs, error) {
262	if len(raw) == 0 || len(raw) > maxGrepInputBytes {
263		return grepArgs{}, fmt.Errorf("grep: invalid arguments")
264	}
265	var fields map[string]json.RawMessage
266	if err := json.Unmarshal(raw, &fields); err != nil || fields == nil {
267		return grepArgs{}, fmt.Errorf("grep: invalid arguments")
268	}
269	for key := range fields {
270		if key != "pattern" && key != "path" {
271			return grepArgs{}, fmt.Errorf("grep: unknown argument %q", key)
272		}
273	}
274	var args grepArgs
275	if err := json.Unmarshal(raw, &args); err != nil {
276		return grepArgs{}, fmt.Errorf("grep: invalid arguments: %w", err)
277	}
278	if _, ok := fields["pattern"]; !ok || args.Pattern == "" {
279		return grepArgs{}, fmt.Errorf("grep: pattern is required")
280	}
281	if _, ok := fields["path"]; !ok || args.Path == "" {
282		return grepArgs{}, fmt.Errorf("grep: path is required")
283	}
284	if !utf8.ValidString(args.Pattern) || !utf8.ValidString(args.Path) || strings.IndexByte(args.Pattern, 0) >= 0 || strings.IndexByte(args.Path, 0) >= 0 {
285		return grepArgs{}, fmt.Errorf("grep: arguments contain invalid characters")
286	}
287	return args, nil
288}
289
290func (t *GrepTool) searchExecutable() (string, string, error) {
291	lookup := t.LookPath
292	if lookup == nil {
293		lookup = exec.LookPath
294	}
295	if path, err := lookup("rg"); err == nil && path != "" {
296		return "rg", path, nil
297	}
298	if path, err := lookup("grep"); err == nil && path != "" {
299		return "grep", path, nil
300	}
301	return "", "", fmt.Errorf("grep: neither rg nor grep is available")
302}
303
304func grepCommandArgs(engine, pattern, root string) []string {
305	if engine == "rg" {
306		// These flags avoid headings and terminal color while retaining rg's
307		// fast default ignore and binary-file filtering behavior.
308		return []string{"--no-heading", "--line-number", "--color=never", "-e", pattern, root}
309	}
310	// -e keeps patterns beginning with '-' from being parsed as options. The
311	// absolute resolved root also makes this portable across GNU and BSD grep.
312	return []string{"-r", "-n", "-H", "-I", "-e", pattern, root}
313}
314
315type grepOutput struct {
316	mu        sync.Mutex
317	data      bytes.Buffer
318	truncated bool
319	cancelled bool
320	cancel    func()
321	progress  func(string)
322}
323
324func (w *grepOutput) Write(p []byte) (int, error) {
325	w.mu.Lock()
326	room := maxGrepOutputBytes - grepTruncationReserve - w.data.Len()
327	var streamed string
328	shouldCancel := false
329	if room <= 0 {
330		if !w.truncated {
331			w.truncated = true
332			w.cancelled = true
333			shouldCancel = true
334		}
335	} else if len(p) > room {
336		_, _ = w.data.Write(p[:room])
337		w.truncated = true
338		w.cancelled = true
339		shouldCancel = true
340		streamed = string(p[:room])
341	} else {
342		_, _ = w.data.Write(p)
343		streamed = string(p)
344	}
345	cancel := w.cancel
346	progress := w.progress
347	w.mu.Unlock()
348
349	if shouldCancel && cancel != nil {
350		cancel()
351	}
352	if progress != nil && streamed != "" {
353		progress(streamed)
354	}
355	return len(p), nil
356}
357
358func (w *grepOutput) bytesTruncated() bool {
359	w.mu.Lock()
360	defer w.mu.Unlock()
361	return w.truncated
362}
363
364func (w *grepOutput) wasCancelled() bool {
365	w.mu.Lock()
366	defer w.mu.Unlock()
367	return w.cancelled
368}
369
370func (w *grepOutput) text() (string, bool) {
371	w.mu.Lock()
372	defer w.mu.Unlock()
373	text := w.data.String()
374	lineCount := strings.Count(text, "\n")
375	if text != "" && !strings.HasSuffix(text, "\n") {
376		lineCount++
377	}
378	truncatedLines := lineCount > maxGrepOutputLines
379	if truncatedLines {
380		lines := strings.SplitAfter(text, "\n")
381		text = strings.Join(lines[:maxGrepOutputLines], "")
382	}
383	if w.truncated || truncatedLines {
384		if text != "" && !strings.HasSuffix(text, "\n") {
385			text += "\n"
386		}
387		if w.truncated {
388			text += fmt.Sprintf("... [truncated at %d bytes]\n", maxGrepOutputBytes)
389		}
390		if truncatedLines {
391			text += fmt.Sprintf("... [truncated at %d lines]\n", maxGrepOutputLines)
392		}
393	}
394	return text, truncatedLines
395}

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