@@ -2,16 +2,18 @@ package tools
22
33import (
44 "bufio"
5+ "bytes"
56 "context"
67 _ "embed"
8+ "encoding/json"
79 "fmt"
810 "io"
11+ "net/http"
912 "os"
1013 "os/exec"
1114 "path/filepath"
1215 "regexp"
1316 "sort"
14− "strconv"
1517 "strings"
1618 "sync"
1719 "time"
@@ -81,6 +83,7 @@ type grepMatch struct {
8183 path string
8284 modTime time.Time
8385 lineNum int
86+ charNum int
8487 lineText string
8588}
8689
@@ -97,6 +100,18 @@ const (
97100//go:embed grep.md
98101var grepDescription []byte
99102
103+// escapeRegexPattern escapes special regex characters so they're treated as literal characters
104+func 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+
100115func NewGrepTool(workingDir string) fantasy.AgentTool {
101116 return fantasy.NewAgentTool(
102117 GrepToolName,
@@ -142,7 +157,11 @@ func NewGrepTool(workingDir string) fantasy.AgentTool {
142157 if len(lineText) > maxGrepContentWidth {
143158 lineText = lineText[:maxGrepContentWidth] + "..."
144159 }
145− fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, lineText)
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+ }
146165 } else {
147166 fmt.Fprintf(&output, " %s\n", match.path)
148167 }
@@ -163,18 +182,6 @@ func NewGrepTool(workingDir string) fantasy.AgentTool {
163182 })
164183}
165184
166−// escapeRegexPattern escapes special regex characters so they're treated as literal characters
167−func escapeRegexPattern(pattern string) string {
168− specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
169− escaped := pattern
170−
171− for _, char := range specialChars {
172− escaped = strings.ReplaceAll(escaped, char, "\\"+char)
173− }
174−
175− return escaped
176−}
177−
178185func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
179186 matches, err := searchWithRipgrep(ctx, pattern, rootPath, include)
180187 if err != nil {
@@ -218,66 +225,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
218225 return nil, err
219226 }
220227
221− lines := strings.Split(strings.TrimSpace(string(output)), "\n")
222− matches := make([]grepMatch, 0, len(lines))
223−
224− for _, line := range lines {
225− if line == "" {
228+ var matches []grepMatch
229+ for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
230+ if len(line) == 0 {
226231 continue
227232 }
228−
229− // Parse ripgrep output using null separation
230− filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
231− if !ok {
233+ var match ripgrepMatch
234+ if err := json.Unmarshal(line, &match); err != nil {
232235 continue
233236 }
234−
235− lineNum, err := strconv.Atoi(lineNumStr)
236− if err != nil {
237+ if match.Type != "match" {
237238 continue
238239 }
239−
240− fileInfo, err := os.Stat(filePath)
241− if err != nil {
242− continue // Skip files we can't access
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
243254 }
244−
245− matches = append(matches, grepMatch{
246− path: filePath,
247− modTime: fileInfo.ModTime(),
248− lineNum: lineNum,
249− lineText: lineText,
250− })
251255 }
252−
253256 return matches, nil
254257}
255258
256−// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
257−func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
258− // Split on null byte first to separate filename from rest
259− parts := strings.SplitN(line, "\x00", 2)
260− if len(parts) != 2 {
261− return "", "", "", false
262− }
263−
264− filePath = parts[0]
265− remainder := parts[1]
266−
267− // Now split the remainder on first colon: "linenum:content"
268− colonIndex := strings.Index(remainder, ":")
269− if colonIndex == -1 {
270− return "", "", "", false
271− }
272−
273− lineNumStr := remainder[:colonIndex]
274− lineText = remainder[colonIndex+1:]
275−
276− if _, err := strconv.Atoi(lineNumStr); err != nil {
277− return "", "", "", false
278− }
279−
280− return filePath, lineNumStr, lineText, true
259+type 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"`
281273}
282274
283275func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
@@ -329,7 +321,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
329321 return nil
330322 }
331323
332− match, lineNum, lineText, err := fileContainsPattern(path, regex)
324+ match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex)
333325 if err != nil {
334326 return nil // Skip files we can't read
335327 }
@@ -339,6 +331,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
339331 path: path,
340332 modTime: info.ModTime(),
341333 lineNum: lineNum,
334+ charNum: charNum,
342335 lineText: lineText,
343336 })
344337
@@ -356,15 +349,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
356349 return matches, nil
357350}
358351
359−func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
360− // Quick binary file detection
361− if isBinaryFile(filePath) {
362− return false, 0, "", nil
352+func 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
363356 }
364357
365358 file, err := os.Open(filePath)
366359 if err != nil {
367− return false, 0, "", err
360+ return false, 0, 0, "", err
368361 }
369362 defer file.Close()
370363
@@ -373,53 +366,39 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
373366 for scanner.Scan() {
374367 lineNum++
375368 line := scanner.Text()
376− if pattern.MatchString(line) {
377− return true, lineNum, line, nil
369+ if loc := pattern.FindStringIndex(line); loc != nil {
370+ charNum := loc[0] + 1
371+ return true, lineNum, charNum, line, nil
378372 }
379373 }
380374
381− return false, 0, "", scanner.Err()
375+ return false, 0, 0, "", scanner.Err()
382376}
383377
384−var binaryExts = map[string]struct{}{
385− ".exe": {}, ".dll": {}, ".so": {}, ".dylib": {},
386− ".bin": {}, ".obj": {}, ".o": {}, ".a": {},
387− ".zip": {}, ".tar": {}, ".gz": {}, ".bz2": {},
388− ".jpg": {}, ".jpeg": {}, ".png": {}, ".gif": {},
389− ".pdf": {}, ".doc": {}, ".docx": {}, ".xls": {},
390− ".mp3": {}, ".mp4": {}, ".avi": {}, ".mov": {},
391−}
392−
393−// isBinaryFile performs a quick check to determine if a file is binary
394−func isBinaryFile(filePath string) bool {
395− // Check file extension first (fastest)
396− ext := strings.ToLower(filepath.Ext(filePath))
397− if _, isBinary := binaryExts[ext]; isBinary {
398− return true
399− }
400−
401− // Quick content check for files without clear extensions
378+// isTextFile checks if a file is a text file by examining its MIME type.
379+func isTextFile(filePath string) bool {
402380 file, err := os.Open(filePath)
403381 if err != nil {
404− return false // If we can't open it, let the caller handle the error
382+ return false
405383 }
406384 defer file.Close()
407385
408− // Read first 512 bytes to check for null bytes
386+ // Read first 512 bytes for MIME type detection.
409387 buffer := make([]byte, 512)
410388 n, err := file.Read(buffer)
411389 if err != nil && err != io.EOF {
412390 return false
413391 }
414392
415− // Check for null bytes (common in binary files)
416− for i := range n {
417− if buffer[i] == 0 {
418− return true
419− }
420− }
393+ // Detect content type.
394+ contentType := http.DetectContentType(buffer[:n])
421395
422− return false
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"
423402}
424403
425404func globToRegex(glob string) string {