1package tools
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 _ "embed"
8 "encoding/json"
9 "fmt"
10 "io"
11 "net/http"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "regexp"
16 "sort"
17 "strings"
18 "sync"
19 "time"
20
21 "charm.land/fantasy"
22 "github.com/charmbracelet/crush/internal/fsext"
23)
24
25// regexCache provides thread-safe caching of compiled regex patterns
26type regexCache struct {
27 cache map[string]*regexp.Regexp
28 mu sync.RWMutex
29}
30
31// newRegexCache creates a new regex cache
32func newRegexCache() *regexCache {
33 return ®exCache{
34 cache: make(map[string]*regexp.Regexp),
35 }
36}
37
38// get retrieves a compiled regex from cache or compiles and caches it
39func (rc *regexCache) get(pattern string) (*regexp.Regexp, error) {
40 // Try to get from cache first (read lock)
41 rc.mu.RLock()
42 if regex, exists := rc.cache[pattern]; exists {
43 rc.mu.RUnlock()
44 return regex, nil
45 }
46 rc.mu.RUnlock()
47
48 // Compile the regex (write lock)
49 rc.mu.Lock()
50 defer rc.mu.Unlock()
51
52 // Double-check in case another goroutine compiled it while we waited
53 if regex, exists := rc.cache[pattern]; exists {
54 return regex, nil
55 }
56
57 // Compile and cache the regex
58 regex, err := regexp.Compile(pattern)
59 if err != nil {
60 return nil, err
61 }
62
63 rc.cache[pattern] = regex
64 return regex, nil
65}
66
67// Global regex cache instances
68var (
69 searchRegexCache = newRegexCache()
70 globRegexCache = newRegexCache()
71 // Pre-compiled regex for glob conversion (used frequently)
72 globBraceRegex = regexp.MustCompile(`\{([^}]+)\}`)
73)
74
75type GrepParams struct {
76 Pattern string `json:"pattern" description:"The regex pattern to search for in file contents"`
77 Path string `json:"path,omitempty" description:"The directory to search in. Defaults to the current working directory."`
78 Include string `json:"include,omitempty" description:"File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"`
79 LiteralText bool `json:"literal_text,omitempty" description:"If true, the pattern will be treated as literal text with special regex characters escaped. Default is false."`
80}
81
82type grepMatch struct {
83 path string
84 modTime time.Time
85 lineNum int
86 charNum int
87 lineText string
88}
89
90type GrepResponseMetadata struct {
91 NumberOfMatches int `json:"number_of_matches"`
92 Truncated bool `json:"truncated"`
93}
94
95const (
96 GrepToolName = "grep"
97 maxGrepContentWidth = 500
98)
99
100//go:embed grep.md
101var grepDescription []byte
102
103// escapeRegexPattern escapes special regex characters so they're treated as literal characters
104func escapeRegexPattern(pattern string) string {
105 specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
106 escaped := pattern
107
108 for _, char := range specialChars {
109 escaped = strings.ReplaceAll(escaped, char, "\\"+char)
110 }
111
112 return escaped
113}
114
115func NewGrepTool(workingDir string) fantasy.AgentTool {
116 return fantasy.NewAgentTool(
117 GrepToolName,
118 string(grepDescription),
119 func(ctx context.Context, params GrepParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
120 if params.Pattern == "" {
121 return fantasy.NewTextErrorResponse("pattern is required"), nil
122 }
123
124 // If literal_text is true, escape the pattern
125 searchPattern := params.Pattern
126 if params.LiteralText {
127 searchPattern = escapeRegexPattern(params.Pattern)
128 }
129
130 searchPath := params.Path
131 if searchPath == "" {
132 searchPath = workingDir
133 }
134
135 matches, truncated, err := searchFiles(ctx, searchPattern, searchPath, params.Include, 100)
136 if err != nil {
137 return fantasy.NewTextErrorResponse(fmt.Sprintf("error searching files: %v", err)), nil
138 }
139
140 var output strings.Builder
141 if len(matches) == 0 {
142 output.WriteString("No files found")
143 } else {
144 fmt.Fprintf(&output, "Found %d matches\n", len(matches))
145
146 currentFile := ""
147 for _, match := range matches {
148 if currentFile != match.path {
149 if currentFile != "" {
150 output.WriteString("\n")
151 }
152 currentFile = match.path
153 fmt.Fprintf(&output, "%s:\n", filepath.ToSlash(match.path))
154 }
155 if match.lineNum > 0 {
156 lineText := match.lineText
157 if len(lineText) > maxGrepContentWidth {
158 lineText = lineText[:maxGrepContentWidth] + "..."
159 }
160 if match.charNum > 0 {
161 fmt.Fprintf(&output, " Line %d, Char %d: %s\n", match.lineNum, match.charNum, lineText)
162 } else {
163 fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, lineText)
164 }
165 } else {
166 fmt.Fprintf(&output, " %s\n", match.path)
167 }
168 }
169
170 if truncated {
171 output.WriteString("\n(Results are truncated. Consider using a more specific path or pattern.)")
172 }
173 }
174
175 return fantasy.WithResponseMetadata(
176 fantasy.NewTextResponse(output.String()),
177 GrepResponseMetadata{
178 NumberOfMatches: len(matches),
179 Truncated: truncated,
180 },
181 ), nil
182 })
183}
184
185func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
186 matches, err := searchWithRipgrep(ctx, pattern, rootPath, include)
187 if err != nil {
188 matches, err = searchFilesWithRegex(pattern, rootPath, include)
189 if err != nil {
190 return nil, false, err
191 }
192 }
193
194 sort.Slice(matches, func(i, j int) bool {
195 return matches[i].modTime.After(matches[j].modTime)
196 })
197
198 truncated := len(matches) > limit
199 if truncated {
200 matches = matches[:limit]
201 }
202
203 return matches, truncated, nil
204}
205
206func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]grepMatch, error) {
207 cmd := getRgSearchCmd(ctx, pattern, path, include)
208 if cmd == nil {
209 return nil, fmt.Errorf("ripgrep not found in $PATH")
210 }
211
212 // Only add ignore files if they exist
213 for _, ignoreFile := range []string{".gitignore", ".crushignore"} {
214 ignorePath := filepath.Join(path, ignoreFile)
215 if _, err := os.Stat(ignorePath); err == nil {
216 cmd.Args = append(cmd.Args, "--ignore-file", ignorePath)
217 }
218 }
219
220 output, err := cmd.Output()
221 if err != nil {
222 if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
223 return []grepMatch{}, nil
224 }
225 return nil, err
226 }
227
228 var matches []grepMatch
229 for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
230 if len(line) == 0 {
231 continue
232 }
233 var match ripgrepMatch
234 if err := json.Unmarshal(line, &match); err != nil {
235 continue
236 }
237 if match.Type != "match" {
238 continue
239 }
240 for _, m := range match.Data.Submatches {
241 fi, err := os.Stat(match.Data.Path.Text)
242 if err != nil {
243 continue // Skip files we can't access
244 }
245 matches = append(matches, grepMatch{
246 path: match.Data.Path.Text,
247 modTime: fi.ModTime(),
248 lineNum: match.Data.LineNumber,
249 charNum: m.Start + 1, // ensure 1-based
250 lineText: strings.TrimSpace(match.Data.Lines.Text),
251 })
252 // only get the first match of each line
253 break
254 }
255 }
256 return matches, nil
257}
258
259type ripgrepMatch struct {
260 Type string `json:"type"`
261 Data struct {
262 Path struct {
263 Text string `json:"text"`
264 } `json:"path"`
265 Lines struct {
266 Text string `json:"text"`
267 } `json:"lines"`
268 LineNumber int `json:"line_number"`
269 Submatches []struct {
270 Start int `json:"start"`
271 } `json:"submatches"`
272 } `json:"data"`
273}
274
275func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
276 matches := []grepMatch{}
277
278 // Use cached regex compilation
279 regex, err := searchRegexCache.get(pattern)
280 if err != nil {
281 return nil, fmt.Errorf("invalid regex pattern: %w", err)
282 }
283
284 var includePattern *regexp.Regexp
285 if include != "" {
286 regexPattern := globToRegex(include)
287 includePattern, err = globRegexCache.get(regexPattern)
288 if err != nil {
289 return nil, fmt.Errorf("invalid include pattern: %w", err)
290 }
291 }
292
293 // Create walker with gitignore and crushignore support
294 walker := fsext.NewFastGlobWalker(rootPath)
295
296 err = filepath.Walk(rootPath, func(path string, info os.FileInfo, err error) error {
297 if err != nil {
298 return nil // Skip errors
299 }
300
301 if info.IsDir() {
302 // Check if directory should be skipped
303 if walker.ShouldSkip(path) {
304 return filepath.SkipDir
305 }
306 return nil // Continue into directory
307 }
308
309 // Use walker's shouldSkip method for files
310 if walker.ShouldSkip(path) {
311 return nil
312 }
313
314 // Skip hidden files (starting with a dot) to match ripgrep's default behavior
315 base := filepath.Base(path)
316 if base != "." && strings.HasPrefix(base, ".") {
317 return nil
318 }
319
320 if includePattern != nil && !includePattern.MatchString(path) {
321 return nil
322 }
323
324 match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex)
325 if err != nil {
326 return nil // Skip files we can't read
327 }
328
329 if match {
330 matches = append(matches, grepMatch{
331 path: path,
332 modTime: info.ModTime(),
333 lineNum: lineNum,
334 charNum: charNum,
335 lineText: lineText,
336 })
337
338 if len(matches) >= 200 {
339 return filepath.SkipAll
340 }
341 }
342
343 return nil
344 })
345 if err != nil {
346 return nil, err
347 }
348
349 return matches, nil
350}
351
352func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) {
353 // Only search text files.
354 if !isTextFile(filePath) {
355 return false, 0, 0, "", nil
356 }
357
358 file, err := os.Open(filePath)
359 if err != nil {
360 return false, 0, 0, "", err
361 }
362 defer file.Close()
363
364 scanner := bufio.NewScanner(file)
365 lineNum := 0
366 for scanner.Scan() {
367 lineNum++
368 line := scanner.Text()
369 if loc := pattern.FindStringIndex(line); loc != nil {
370 charNum := loc[0] + 1
371 return true, lineNum, charNum, line, nil
372 }
373 }
374
375 return false, 0, 0, "", scanner.Err()
376}
377
378// isTextFile checks if a file is a text file by examining its MIME type.
379func isTextFile(filePath string) bool {
380 file, err := os.Open(filePath)
381 if err != nil {
382 return false
383 }
384 defer file.Close()
385
386 // Read first 512 bytes for MIME type detection.
387 buffer := make([]byte, 512)
388 n, err := file.Read(buffer)
389 if err != nil && err != io.EOF {
390 return false
391 }
392
393 // Detect content type.
394 contentType := http.DetectContentType(buffer[:n])
395
396 // Check if it's a text MIME type.
397 return strings.HasPrefix(contentType, "text/") ||
398 contentType == "application/json" ||
399 contentType == "application/xml" ||
400 contentType == "application/javascript" ||
401 contentType == "application/x-sh"
402}
403
404func globToRegex(glob string) string {
405 regexPattern := strings.ReplaceAll(glob, ".", "\\.")
406 regexPattern = strings.ReplaceAll(regexPattern, "*", ".*")
407 regexPattern = strings.ReplaceAll(regexPattern, "?", ".")
408
409 // Use pre-compiled regex instead of compiling each time
410 regexPattern = globBraceRegex.ReplaceAllStringFunc(regexPattern, func(match string) string {
411 inner := match[1 : len(match)-1]
412 return "(" + strings.ReplaceAll(inner, ",", "|") + ")"
413 })
414
415 return regexPattern
416}