@@ -2,6 +2,7 @@ package tools
22
33import (
44 "bufio"
5+ "bytes"
56 "context"
67 _ "embed"
78 "encoding/json"
@@ -13,7 +14,6 @@ import (
1314 "path/filepath"
1415 "regexp"
1516 "sort"
16− "strconv"
1717 "strings"
1818 "sync"
1919 "time"
@@ -82,6 +82,7 @@ type grepMatch struct {
8282 path string
8383 modTime time.Time
8484 lineNum int
85+ charNum int
8586 lineText string
8687}
8788
@@ -189,7 +190,11 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
189190 fmt.Fprintf(&output, "%s:\n", match.path)
190191 }
191192 if match.lineNum > 0 {
192− fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
193+ if match.charNum > 0 {
194+ fmt.Fprintf(&output, " Line %d, Char %d: %s\n", match.lineNum, match.charNum, match.lineText)
195+ } else {
196+ fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
197+ }
193198 } else {
194199 fmt.Fprintf(&output, " %s\n", match.path)
195200 }
@@ -252,66 +257,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
252257 return nil, err
253258 }
254259
255− lines := strings.Split(strings.TrimSpace(string(output)), "\n")
256− matches := make([]grepMatch, 0, len(lines))
257−
258− for _, line := range lines {
259− if line == "" {
260+ var matches []grepMatch
261+ for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
262+ if len(line) == 0 {
260263 continue
261264 }
262−
263− // Parse ripgrep output using null separation
264− filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
265− if !ok {
265+ var match ripgrepMatch
266+ if err := json.Unmarshal(line, &match); err != nil {
266267 continue
267268 }
268−
269− lineNum, err := strconv.Atoi(lineNumStr)
270− if err != nil {
269+ if match.Type != "match" {
271270 continue
272271 }
273−
274− fileInfo, err := os.Stat(filePath)
275− if err != nil {
276− continue // Skip files we can't access
272+ for _, m := range match.Data.Submatches {
273+ fi, err := os.Stat(match.Data.Path.Text)
274+ if err != nil {
275+ continue // Skip files we can't access
276+ }
277+ matches = append(matches, grepMatch{
278+ path: match.Data.Path.Text,
279+ modTime: fi.ModTime(),
280+ lineNum: match.Data.LineNumber,
281+ charNum: m.Start + 1, // ensure 1-based
282+ lineText: strings.TrimSpace(match.Data.Lines.Text),
283+ })
284+ // only get the first match of each line
285+ break
277286 }
278−
279− matches = append(matches, grepMatch{
280− path: filePath,
281− modTime: fileInfo.ModTime(),
282− lineNum: lineNum,
283− lineText: lineText,
284− })
285287 }
286−
287288 return matches, nil
288289}
289290
290−// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
291−func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
292− // Split on null byte first to separate filename from rest
293− parts := strings.SplitN(line, "\x00", 2)
294− if len(parts) != 2 {
295− return "", "", "", false
296− }
297−
298− filePath = parts[0]
299− remainder := parts[1]
300−
301− // Now split the remainder on first colon: "linenum:content"
302− colonIndex := strings.Index(remainder, ":")
303− if colonIndex == -1 {
304− return "", "", "", false
305− }
306−
307− lineNumStr := remainder[:colonIndex]
308− lineText = remainder[colonIndex+1:]
309−
310− if _, err := strconv.Atoi(lineNumStr); err != nil {
311− return "", "", "", false
312− }
313−
314− return filePath, lineNumStr, lineText, true
291+type ripgrepMatch struct {
292+ Type string `json:"type"`
293+ Data struct {
294+ Path struct {
295+ Text string `json:"text"`
296+ } `json:"path"`
297+ Lines struct {
298+ Text string `json:"text"`
299+ } `json:"lines"`
300+ LineNumber int `json:"line_number"`
301+ Submatches []struct {
302+ Start int `json:"start"`
303+ } `json:"submatches"`
304+ } `json:"data"`
315305}
316306
317307func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
@@ -363,7 +353,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
363353 return nil
364354 }
365355
366− match, lineNum, lineText, err := fileContainsPattern(path, regex)
356+ match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex)
367357 if err != nil {
368358 return nil // Skip files we can't read
369359 }
@@ -373,6 +363,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
373363 path: path,
374364 modTime: info.ModTime(),
375365 lineNum: lineNum,
366+ charNum: charNum,
376367 lineText: lineText,
377368 })
378369
@@ -390,15 +381,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
390381 return matches, nil
391382}
392383
393−func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
384+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) {
394385 // Only search text files.
395386 if !isTextFile(filePath) {
396− return false, 0, "", nil
387+ return false, 0, 0, "", nil
397388 }
398389
399390 file, err := os.Open(filePath)
400391 if err != nil {
401− return false, 0, "", err
392+ return false, 0, 0, "", err
402393 }
403394 defer file.Close()
404395
@@ -407,12 +398,13 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
407398 for scanner.Scan() {
408399 lineNum++
409400 line := scanner.Text()
410− if pattern.MatchString(line) {
411− return true, lineNum, line, nil
401+ if loc := pattern.FindStringIndex(line); loc != nil {
402+ charNum := loc[0] + 1
403+ return true, lineNum, charNum, line, nil
412404 }
413405 }
414406
415− return false, 0, "", scanner.Err()
407+ return false, 0, 0, "", scanner.Err()
416408}
417409
418410// isTextFile checks if a file is a text file by examining its MIME type.