v0.38.0 58133ea

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

↑↓ moveenter openesc close