@@ -2,6 +2,7 @@ package tools
7 "encoding/json"
8 "encoding/json"
@@ -13,7 +14,6 @@ import (
13 "path/filepath"
14 "path/filepath"
@@ -82,6 +82,7 @@ type grepMatch struct {
82 path string
82 path string
83 modTime time.Time
83 modTime time.Time
84 lineNum int
84 lineNum int
85 lineText string
86 lineText string
@@ -189,7 +190,11 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
189 fmt.Fprintf(&output, "%s:\n", match.path)
190 fmt.Fprintf(&output, "%s:\n", match.path)
191 if match.lineNum > 0 {
192 if match.lineNum > 0 {
193+ if match.charNum > 0 {
194+ fmt.Fprintf(&output, " Line %d, Char %d: %s\n", match.lineNum, match.charNum, match.lineText)
192− fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
196+ fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
194 fmt.Fprintf(&output, " %s\n", match.path)
199 fmt.Fprintf(&output, " %s\n", match.path)
@@ -252,66 +257,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
252 return nil, err
257 return nil, err
255− lines := strings.Split(strings.TrimSpace(string(output)), "\n")
260+ var matches []grepMatch
256− matches := make([]grepMatch, 0, len(lines))
261+ for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
258− for _, line := range lines {
259− if line == "" {
262+ if len(line) == 0 {
262−
265+ var match ripgrepMatch
263− // Parse ripgrep output using null separation
266+ if err := json.Unmarshal(line, &match); err != nil {
264− filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
268−
269+ if match.Type != "match" {
269− lineNum, err := strconv.Atoi(lineNumStr)
273−
272+ for _, m := range match.Data.Submatches {
274− fileInfo, err := os.Stat(filePath)
273+ fi, err := os.Stat(match.Data.Path.Text)
275− if err != nil {
274+ if err != nil {
276− continue // Skip files we can't access
275+ continue // Skip files we can't access
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),
284+ // only get the first match of each line
279− matches = append(matches, grepMatch{
281− modTime: fileInfo.ModTime(),
287 return matches, nil
288 return matches, nil
290−// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
291+type ripgrepMatch struct {
291−func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
292+ Type string `json:"type"`
292− // Split on null byte first to separate filename from rest
293+ Data struct {
293− parts := strings.SplitN(line, "\x00", 2)
294+ Path struct {
294− if len(parts) != 2 {
295+ Text string `json:"text"`
295− return "", "", "", false
296+ } `json:"path"`
296− }
297+ Lines struct {
297−
298+ Text string `json:"text"`
298− filePath = parts[0]
299+ } `json:"lines"`
299− remainder := parts[1]
300+ LineNumber int `json:"line_number"`
300−
301+ Submatches []struct {
301− // Now split the remainder on first colon: "linenum:content"
302+ Start int `json:"start"`
302− colonIndex := strings.Index(remainder, ":")
303+ } `json:"submatches"`
303− if colonIndex == -1 {
304+ } `json:"data"`
304− return "", "", "", false
307− lineNumStr := remainder[:colonIndex]
308− lineText = remainder[colonIndex+1:]
310− if _, err := strconv.Atoi(lineNumStr); err != nil {
311− return "", "", "", false
314− return filePath, lineNumStr, lineText, true
317func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
307func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
@@ -363,7 +353,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
363 return nil
353 return nil
366− match, lineNum, lineText, err := fileContainsPattern(path, regex)
356+ match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex)
367 if err != nil {
357 if err != nil {
368 return nil // Skip files we can't read
358 return nil // Skip files we can't read
@@ -373,6 +363,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
373 path: path,
363 path: path,
374 modTime: info.ModTime(),
364 modTime: info.ModTime(),
375 lineNum: lineNum,
365 lineNum: lineNum,
376 lineText: lineText,
367 lineText: lineText,
@@ -390,15 +381,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
390 return matches, nil
381 return matches, nil
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) {
394 // Only search text files.
385 // Only search text files.
395 if !isTextFile(filePath) {
386 if !isTextFile(filePath) {
396− return false, 0, "", nil
387+ return false, 0, 0, "", nil
399 file, err := os.Open(filePath)
390 file, err := os.Open(filePath)
400 if err != nil {
391 if err != nil {
401− return false, 0, "", err
392+ return false, 0, 0, "", err
403 defer file.Close()
394 defer file.Close()
@@ -407,12 +398,13 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
407 for scanner.Scan() {
398 for scanner.Scan() {
408 lineNum++
399 lineNum++
409 line := scanner.Text()
400 line := scanner.Text()
410− if pattern.MatchString(line) {
401+ if loc := pattern.FindStringIndex(line); loc != nil {
402+ charNum := loc[0] + 1
411− return true, lineNum, line, nil
403+ return true, lineNum, charNum, line, nil
415− return false, 0, "", scanner.Err()
407+ return false, 0, 0, "", scanner.Err()
418// isTextFile checks if a file is a text file by examining its MIME type.
410// isTextFile checks if a file is a text file by examining its MIME type.