@@ -2,16 +2,18 @@ package tools
11 "path/filepath"
14 "path/filepath"
@@ -81,6 +83,7 @@ type grepMatch struct {
81 path string
83 path string
82 modTime time.Time
84 modTime time.Time
83 lineNum int
85 lineNum int
84 lineText string
87 lineText string
@@ -97,6 +100,18 @@ const (
97//go:embed grep.md
100//go:embed grep.md
98var grepDescription []byte
101var grepDescription []byte
103+// escapeRegexPattern escapes special regex characters so they're treated as literal characters
104+func escapeRegexPattern(pattern string) string {
105+ specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
108+ for _, char := range specialChars {
109+ escaped = strings.ReplaceAll(escaped, char, "\\"+char)
100func NewGrepTool(workingDir string) fantasy.AgentTool {
115func NewGrepTool(workingDir string) fantasy.AgentTool {
101 return fantasy.NewAgentTool(
116 return fantasy.NewAgentTool(
102 GrepToolName,
117 GrepToolName,
@@ -142,7 +157,11 @@ func NewGrepTool(workingDir string) fantasy.AgentTool {
142 if len(lineText) > maxGrepContentWidth {
157 if len(lineText) > maxGrepContentWidth {
143 lineText = lineText[:maxGrepContentWidth] + "..."
158 lineText = lineText[:maxGrepContentWidth] + "..."
160+ if match.charNum > 0 {
161+ fmt.Fprintf(&output, " Line %d, Char %d: %s\n", match.lineNum, match.charNum, lineText)
145− fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, lineText)
163+ fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, lineText)
147 fmt.Fprintf(&output, " %s\n", match.path)
166 fmt.Fprintf(&output, " %s\n", match.path)
@@ -163,18 +182,6 @@ func NewGrepTool(workingDir string) fantasy.AgentTool {
166−// escapeRegexPattern escapes special regex characters so they're treated as literal characters
167−func escapeRegexPattern(pattern string) string {
168− specialChars := []string{"\\", ".", "+", "*", "?", "(", ")", "[", "]", "{", "}", "^", "$", "|"}
171− for _, char := range specialChars {
172− escaped = strings.ReplaceAll(escaped, char, "\\"+char)
178func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
185func searchFiles(ctx context.Context, pattern, rootPath, include string, limit int) ([]grepMatch, bool, error) {
179 matches, err := searchWithRipgrep(ctx, pattern, rootPath, include)
186 matches, err := searchWithRipgrep(ctx, pattern, rootPath, include)
180 if err != nil {
187 if err != nil {
@@ -218,66 +225,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
218 return nil, err
225 return nil, err
221− lines := strings.Split(strings.TrimSpace(string(output)), "\n")
228+ var matches []grepMatch
222− matches := make([]grepMatch, 0, len(lines))
229+ for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
224− for _, line := range lines {
225− if line == "" {
230+ if len(line) == 0 {
228−
233+ var match ripgrepMatch
229− // Parse ripgrep output using null separation
234+ if err := json.Unmarshal(line, &match); err != nil {
230− filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
234−
237+ if match.Type != "match" {
235− lineNum, err := strconv.Atoi(lineNumStr)
239−
240+ for _, m := range match.Data.Submatches {
240− fileInfo, err := os.Stat(filePath)
241+ fi, err := os.Stat(match.Data.Path.Text)
241− if err != nil {
242+ if err != nil {
242− continue // Skip files we can't access
243+ continue // Skip files we can't access
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),
252+ // only get the first match of each line
245− matches = append(matches, grepMatch{
247− modTime: fileInfo.ModTime(),
253 return matches, nil
256 return matches, nil
256−// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
259+type ripgrepMatch struct {
257−func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
260+ Type string `json:"type"`
258− // Split on null byte first to separate filename from rest
261+ Data struct {
259− parts := strings.SplitN(line, "\x00", 2)
262+ Path struct {
260− if len(parts) != 2 {
263+ Text string `json:"text"`
261− return "", "", "", false
264+ } `json:"path"`
262− }
265+ Lines struct {
263−
266+ Text string `json:"text"`
264− filePath = parts[0]
267+ } `json:"lines"`
265− remainder := parts[1]
268+ LineNumber int `json:"line_number"`
266−
269+ Submatches []struct {
267− // Now split the remainder on first colon: "linenum:content"
270+ Start int `json:"start"`
268− colonIndex := strings.Index(remainder, ":")
271+ } `json:"submatches"`
269− if colonIndex == -1 {
272+ } `json:"data"`
270− return "", "", "", false
273− lineNumStr := remainder[:colonIndex]
274− lineText = remainder[colonIndex+1:]
276− if _, err := strconv.Atoi(lineNumStr); err != nil {
277− return "", "", "", false
280− return filePath, lineNumStr, lineText, true
283func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
275func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
@@ -329,7 +321,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
329 return nil
321 return nil
332− match, lineNum, lineText, err := fileContainsPattern(path, regex)
324+ match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex)
333 if err != nil {
325 if err != nil {
334 return nil // Skip files we can't read
326 return nil // Skip files we can't read
@@ -339,6 +331,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
339 path: path,
331 path: path,
340 modTime: info.ModTime(),
332 modTime: info.ModTime(),
341 lineNum: lineNum,
333 lineNum: lineNum,
342 lineText: lineText,
335 lineText: lineText,
@@ -356,15 +349,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
356 return matches, nil
349 return matches, nil
359−func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
352+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) {
360− // Quick binary file detection
353+ // Only search text files.
361− if isBinaryFile(filePath) {
354+ if !isTextFile(filePath) {
362− return false, 0, "", nil
355+ return false, 0, 0, "", nil
365 file, err := os.Open(filePath)
358 file, err := os.Open(filePath)
366 if err != nil {
359 if err != nil {
367− return false, 0, "", err
360+ return false, 0, 0, "", err
369 defer file.Close()
362 defer file.Close()
@@ -373,53 +366,39 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
373 for scanner.Scan() {
366 for scanner.Scan() {
374 lineNum++
367 lineNum++
375 line := scanner.Text()
368 line := scanner.Text()
376− if pattern.MatchString(line) {
369+ if loc := pattern.FindStringIndex(line); loc != nil {
370+ charNum := loc[0] + 1
377− return true, lineNum, line, nil
371+ return true, lineNum, charNum, line, nil
381− return false, 0, "", scanner.Err()
375+ return false, 0, 0, "", scanner.Err()
384−var binaryExts = map[string]struct{}{
378+// isTextFile checks if a file is a text file by examining its MIME type.
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": {},
393−// isBinaryFile performs a quick check to determine if a file is binary
394−func isBinaryFile(filePath string) bool {
379+func isTextFile(filePath string) bool {
395− // Check file extension first (fastest)
396− ext := strings.ToLower(filepath.Ext(filePath))
397− if _, isBinary := binaryExts[ext]; isBinary {
401− // Quick content check for files without clear extensions
402 file, err := os.Open(filePath)
380 file, err := os.Open(filePath)
403 if err != nil {
381 if err != nil {
404− return false // If we can't open it, let the caller handle the error
382+ return false
406 defer file.Close()
384 defer file.Close()
408− // Read first 512 bytes to check for null bytes
386+ // Read first 512 bytes for MIME type detection.
409 buffer := make([]byte, 512)
387 buffer := make([]byte, 512)
410 n, err := file.Read(buffer)
388 n, err := file.Read(buffer)
411 if err != nil && err != io.EOF {
389 if err != nil && err != io.EOF {
412 return false
390 return false
415− // Check for null bytes (common in binary files)
393+ // Detect content type.
416− for i := range n {
394+ contentType := http.DetectContentType(buffer[:n])
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"
425func globToRegex(glob string) string {
404func globToRegex(glob string) string {