grep.go

  1package tools
  2
  3import (
  4	"bufio"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"os"
  9	"os/exec"
 10	"path/filepath"
 11	"regexp"
 12	"sort"
 13	"strings"
 14	"time"
 15
 16	"github.com/cloudwego/eino/components/tool"
 17	"github.com/cloudwego/eino/schema"
 18)
 19
 20type grepTool struct {
 21	workingDir string
 22}
 23
 24const (
 25	GrepToolName = "grep"
 26
 27	MaxGrepResults = 100
 28)
 29
 30type GrepParams struct {
 31	Pattern string `json:"pattern"`
 32	Path    string `json:"path"`
 33	Include string `json:"include"`
 34}
 35
 36type grepMatch struct {
 37	path    string
 38	modTime time.Time
 39}
 40
 41func (b *grepTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
 42	return &schema.ToolInfo{
 43		Name: GrepToolName,
 44		Desc: `- Fast content search tool that works with any codebase size
 45- Searches file contents using regular expressions
 46- Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.)
 47- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
 48- Returns matching file paths sorted by modification time
 49- Use this tool when you need to find files containing specific patterns
 50- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead`,
 51		ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
 52			"command": {
 53				Type:     "string",
 54				Desc:     "The command to execute",
 55				Required: true,
 56			},
 57			"timeout": {
 58				Type: "number",
 59				Desc: "Optional timeout in milliseconds (max 600000)",
 60			},
 61		}),
 62	}, nil
 63}
 64
 65func (b *grepTool) InvokableRun(ctx context.Context, args string, opts ...tool.Option) (string, error) {
 66	var params GrepParams
 67	if err := json.Unmarshal([]byte(args), &params); err != nil {
 68		return "", err
 69	}
 70
 71	searchPath := params.Path
 72	if searchPath == "" {
 73		var err error
 74		searchPath, err = os.Getwd()
 75		if err != nil {
 76			return fmt.Sprintf("unable to get current working directory: %s", err), nil
 77		}
 78	}
 79
 80	matches, err := searchWithRipgrep(params.Pattern, searchPath, params.Include)
 81	if err != nil {
 82		matches, err = searchFilesWithRegex(params.Pattern, searchPath, params.Include)
 83		if err != nil {
 84			return fmt.Sprintf("error searching files: %s", err), nil
 85		}
 86	}
 87
 88	sort.Slice(matches, func(i, j int) bool {
 89		return matches[i].modTime.After(matches[j].modTime)
 90	})
 91
 92	truncated := false
 93	if len(matches) > MaxGrepResults {
 94		truncated = true
 95		matches = matches[:MaxGrepResults]
 96	}
 97
 98	filenames := make([]string, len(matches))
 99	for i, m := range matches {
100		filenames[i] = m.path
101	}
102
103	var output string
104	if len(filenames) == 0 {
105		output = "No files found"
106	} else {
107		output = fmt.Sprintf("Found %d file%s\n%s",
108			len(filenames),
109			pluralize(len(filenames)),
110			strings.Join(filenames, "\n"))
111
112		if truncated {
113			output += "\n(Results are truncated. Consider using a more specific path or pattern.)"
114		}
115	}
116
117	return output, nil
118}
119
120func pluralize(count int) string {
121	if count == 1 {
122		return ""
123	}
124	return "s"
125}
126
127func searchWithRipgrep(pattern, path, include string) ([]grepMatch, error) {
128	_, err := exec.LookPath("rg")
129	if err != nil {
130		return nil, fmt.Errorf("ripgrep not found: %w", err)
131	}
132
133	args := []string{"-l", pattern}
134	if include != "" {
135		args = append(args, "--glob", include)
136	}
137	args = append(args, path)
138
139	cmd := exec.Command("rg", args...)
140	output, err := cmd.Output()
141	if err != nil {
142		if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
143			return []grepMatch{}, nil
144		}
145		return nil, err
146	}
147
148	lines := strings.Split(strings.TrimSpace(string(output)), "\n")
149	matches := make([]grepMatch, 0, len(lines))
150
151	for _, line := range lines {
152		if line == "" {
153			continue
154		}
155
156		fileInfo, err := os.Stat(line)
157		if err != nil {
158			continue
159		}
160
161		matches = append(matches, grepMatch{
162			path:    line,
163			modTime: fileInfo.ModTime(),
164		})
165	}
166
167	return matches, nil
168}
169
170func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
171	matches := []grepMatch{}
172
173	regex, err := regexp.Compile(pattern)
174	if err != nil {
175		return nil, fmt.Errorf("invalid regex pattern: %w", err)
176	}
177
178	var includePattern *regexp.Regexp
179	if include != "" {
180		regexPattern := globToRegex(include)
181		includePattern, err = regexp.Compile(regexPattern)
182		if err != nil {
183			return nil, fmt.Errorf("invalid include pattern: %w", err)
184		}
185	}
186
187	err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
188		if err != nil {
189			return nil
190		}
191
192		if info.IsDir() {
193			return nil
194		}
195
196		if includePattern != nil && !includePattern.MatchString(path) {
197			return nil
198		}
199
200		match, err := fileContainsPattern(path, regex)
201		if err != nil {
202			return nil
203		}
204
205		if match {
206			matches = append(matches, grepMatch{
207				path:    path,
208				modTime: info.ModTime(),
209			})
210		}
211
212		return nil
213	})
214	if err != nil {
215		return nil, err
216	}
217
218	return matches, nil
219}
220
221func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, error) {
222	file, err := os.Open(filePath)
223	if err != nil {
224		return false, err
225	}
226	defer file.Close()
227
228	scanner := bufio.NewScanner(file)
229	for scanner.Scan() {
230		if pattern.MatchString(scanner.Text()) {
231			return true, nil
232		}
233	}
234
235	if err := scanner.Err(); err != nil {
236		return false, err
237	}
238
239	return false, nil
240}
241
242func globToRegex(glob string) string {
243	regexPattern := strings.ReplaceAll(glob, ".", "\\.")
244	regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
245	regexPattern = strings.ReplaceAll(regexPattern, "?", ".")
246
247	re := regexp.MustCompile(`\{([^}]+)\}`)
248	regexPattern = re.ReplaceAllStringFunc(regexPattern, func(match string) string {
249		inner := match[1 : len(match)-1]
250		return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
251	})
252
253	return "^" + regexPattern + "$"
254}
255
256func NewGrepTool(workingDir string) tool.InvokableTool {
257	return &grepTool{
258		workingDir,
259	}
260}