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