on main help sign in
feat(lsp): find references tool (#1233)
a64a4deCarlos Alexandro Becker
go.mod+1−1
@@ -77,7 +77,7 @@ require (
77 github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef
77 github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef
78 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
78 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
79 github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d
79 github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d
80− github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4
80+ github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
81 github.com/charmbracelet/x/term v0.2.1
81 github.com/charmbracelet/x/term v0.2.1
82 github.com/charmbracelet/x/termios v0.1.1 // indirect
82 github.com/charmbracelet/x/termios v0.1.1 // indirect
83 github.com/charmbracelet/x/windows v0.2.2 // indirect
83 github.com/charmbracelet/x/windows v0.2.2 // indirect
go.sum+2−2
@@ -106,8 +106,8 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB
106github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
106github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
107github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA=
107github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA=
108github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
108github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
109−github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0=
109+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
110−github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
110+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
111github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
111github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
112github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
112github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
113github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
113github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
internal/llm/agent/agent.go+1−1
@@ -525,7 +525,7 @@ func (a *agent) getAllTools() ([]tools.BaseTool, error) {
525 if a.agentCfg.ID == "coder" {
525 if a.agentCfg.ID == "coder" {
526 allTools = slices.AppendSeq(allTools, a.mcpTools.Seq())
526 allTools = slices.AppendSeq(allTools, a.mcpTools.Seq())
527 if a.lspClients.Len() > 0 {
527 if a.lspClients.Len() > 0 {
528− allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients))
528+ allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients), tools.NewReferencesTool(a.lspClients))
529 }
529 }
530 }
530 }
531 if a.agentToolFn != nil {
531 if a.agentToolFn != nil {
internal/llm/tools/diagnostics.go+1−1
@@ -23,7 +23,7 @@ type diagnosticsTool struct {
23 lspClients *csync.Map[string, *lsp.Client]
23 lspClients *csync.Map[string, *lsp.Client]
24}
24}
25
25
26−const DiagnosticsToolName = "diagnostics"
26+const DiagnosticsToolName = "lsp_diagnostics"
27
27
28//go:embed diagnostics.md
28//go:embed diagnostics.md
29var diagnosticsDescription []byte
29var diagnosticsDescription []byte
internal/llm/tools/grep.go+50−58
@@ -2,6 +2,7 @@ package tools
2
2
3import (
3import (
4 "bufio"
4 "bufio"
5+ "bytes"
5 "context"
6 "context"
6 _ "embed"
7 _ "embed"
7 "encoding/json"
8 "encoding/json"
@@ -13,7 +14,6 @@ import (
13 "path/filepath"
14 "path/filepath"
14 "regexp"
15 "regexp"
15 "sort"
16 "sort"
16− "strconv"
17 "strings"
17 "strings"
18 "sync"
18 "sync"
19 "time"
19 "time"
@@ -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+ charNum int
85 lineText string
86 lineText string
86}
87}
87
88
@@ -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)
190 }
191 }
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)
195+ } else {
192− fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
196+ fmt.Fprintf(&output, " Line %d: %s\n", match.lineNum, match.lineText)
197+ }
193 } else {
198 } else {
194 fmt.Fprintf(&output, " %s\n", match.path)
199 fmt.Fprintf(&output, " %s\n", match.path)
195 }
200 }
@@ -252,66 +257,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
252 return nil, err
257 return nil, err
253 }
258 }
254
259
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'}) {
257−
258− for _, line := range lines {
259− if line == "" {
262+ if len(line) == 0 {
260 continue
263 continue
261 }
264 }
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)
265− if !ok {
266 continue
267 continue
267 }
268 }
268−
269+ if match.Type != "match" {
269− lineNum, err := strconv.Atoi(lineNumStr)
270− if err != nil {
271 continue
270 continue
272 }
271 }
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
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
277 }
286 }
278−
279− matches = append(matches, grepMatch{
280− path: filePath,
281− modTime: fileInfo.ModTime(),
282− lineNum: lineNum,
283− lineText: lineText,
284− })
285 }
287 }
286−
287 return matches, nil
288 return matches, nil
288}
289}
289
290
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
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
315}
305}
316
306
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
364 }
354 }
365
355
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
369 }
359 }
@@ -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,
366+ charNum: charNum,
376 lineText: lineText,
367 lineText: lineText,
377 })
368 })
378
369
@@ -390,15 +381,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
390 return matches, nil
381 return matches, nil
391}
382}
392
383
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
397 }
388 }
398
389
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
402 }
393 }
403 defer file.Close()
394 defer file.Close()
404
395
@@ -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
412 }
404 }
413 }
405 }
414
406
415− return false, 0, "", scanner.Err()
407+ return false, 0, 0, "", scanner.Err()
416}
408}
417
409
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.
internal/llm/tools/grep_test.go+29
@@ -390,3 +390,32 @@ func TestIsTextFile(t *testing.T) {
390 })
390 })
391 }
391 }
392}
392}
393+
394+func TestColumnMatch(t *testing.T) {
395+ t.Parallel()
396+
397+ // Test both implementations
398+ for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
399+ "regex": searchFilesWithRegex,
400+ "rg": func(pattern, path, include string) ([]grepMatch, error) {
401+ return searchWithRipgrep(t.Context(), pattern, path, include)
402+ },
403+ } {
404+ t.Run(name, func(t *testing.T) {
405+ t.Parallel()
406+
407+ if name == "rg" && getRg() == "" {
408+ t.Skip("rg is not in $PATH")
409+ }
410+
411+ matches, err := fn("THIS", "./testdata/", "")
412+ require.NoError(t, err)
413+ require.Len(t, matches, 1)
414+ match := matches[0]
415+ require.Equal(t, 2, match.lineNum)
416+ require.Equal(t, 14, match.charNum)
417+ require.Equal(t, "I wanna grep THIS particular word", match.lineText)
418+ require.Equal(t, "testdata/grep.txt", filepath.ToSlash(filepath.Clean(match.path)))
419+ })
420+ }
421+}
internal/llm/tools/references.gonew file+214
@@ -0,0 +1,214 @@
1+package tools
2+
3+import (
4+ "cmp"
5+ "context"
6+ _ "embed"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "log/slog"
11+ "maps"
12+ "path/filepath"
13+ "regexp"
14+ "slices"
15+ "sort"
16+ "strings"
17+
18+ "github.com/charmbracelet/crush/internal/csync"
19+ "github.com/charmbracelet/crush/internal/lsp"
20+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
21+)
22+
23+type ReferencesParams struct {
24+ Symbol string `json:"symbol"`
25+ Path string `json:"path"`
26+}
27+
28+type referencesTool struct {
29+ lspClients *csync.Map[string, *lsp.Client]
30+}
31+
32+const ReferencesToolName = "lsp_references"
33+
34+//go:embed references.md
35+var referencesDescription []byte
36+
37+func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool {
38+ return &referencesTool{
39+ lspClients,
40+ }
41+}
42+
43+func (r *referencesTool) Name() string {
44+ return ReferencesToolName
45+}
46+
47+func (r *referencesTool) Info() ToolInfo {
48+ return ToolInfo{
49+ Name: ReferencesToolName,
50+ Description: string(referencesDescription),
51+ Parameters: map[string]any{
52+ "symbol": map[string]any{
53+ "type": "string",
54+ "description": "The symbol name to search for (e.g., function name, variable name, type name).",
55+ },
56+ "path": map[string]any{
57+ "type": "string",
58+ "description": "The directory to search in. Should be the entire project most of the time. Defaults to the current working directory.",
59+ },
60+ },
61+ Required: []string{"symbol"},
62+ }
63+}
64+
65+func (r *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
66+ var params ReferencesParams
67+ if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
68+ return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
69+ }
70+
71+ if params.Symbol == "" {
72+ return NewTextErrorResponse("symbol is required"), nil
73+ }
74+
75+ if r.lspClients.Len() == 0 {
76+ return NewTextErrorResponse("no LSP clients available"), nil
77+ }
78+
79+ workingDir := cmp.Or(params.Path, ".")
80+
81+ matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100)
82+ if err != nil {
83+ return NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil
84+ }
85+
86+ if len(matches) == 0 {
87+ return NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil
88+ }
89+
90+ var allLocations []protocol.Location
91+ var allErrs error
92+ for _, match := range matches {
93+ locations, err := r.find(ctx, params.Symbol, match)
94+ if err != nil {
95+ if strings.Contains(err.Error(), "no identifier found") {
96+ // grep probably matched a comment, string value, or something else that's irrelevant
97+ continue
98+ }
99+ slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum)
100+ allErrs = errors.Join(allErrs, err)
101+ continue
102+ }
103+ allLocations = append(allLocations, locations...)
104+ // XXX: should we break here or look for all results?
105+ }
106+
107+ if len(allLocations) > 0 {
108+ output := formatReferences(cleanupLocations(allLocations))
109+ return NewTextResponse(output), nil
110+ }
111+
112+ if allErrs != nil {
113+ return NewTextErrorResponse(allErrs.Error()), nil
114+ }
115+ return NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil
116+}
117+
118+func (r *referencesTool) find(ctx context.Context, symbol string, match grepMatch) ([]protocol.Location, error) {
119+ absPath, err := filepath.Abs(match.path)
120+ if err != nil {
121+ return nil, fmt.Errorf("failed to get absolute path: %s", err)
122+ }
123+
124+ var client *lsp.Client
125+ for c := range r.lspClients.Seq() {
126+ if c.HandlesFile(absPath) {
127+ client = c
128+ break
129+ }
130+ }
131+
132+ if client == nil {
133+ slog.Warn("No LSP clients to handle", "path", match.path)
134+ return nil, nil
135+ }
136+
137+ return client.FindReferences(
138+ ctx,
139+ absPath,
140+ match.lineNum,
141+ match.charNum+getSymbolOffset(symbol),
142+ true,
143+ )
144+}
145+
146+// getSymbolOffset returns the character offset to the actual symbol name
147+// in a qualified symbol (e.g., "Bar" in "foo.Bar" or "method" in "Class::method").
148+func getSymbolOffset(symbol string) int {
149+ // Check for :: separator (Rust, C++, Ruby modules/classes, PHP static).
150+ if idx := strings.LastIndex(symbol, "::"); idx != -1 {
151+ return idx + 2
152+ }
153+ // Check for . separator (Go, Python, JavaScript, Java, C#, Ruby methods).
154+ if idx := strings.LastIndex(symbol, "."); idx != -1 {
155+ return idx + 1
156+ }
157+ // Check for \ separator (PHP namespaces).
158+ if idx := strings.LastIndex(symbol, "\\"); idx != -1 {
159+ return idx + 1
160+ }
161+ return 0
162+}
163+
164+func cleanupLocations(locations []protocol.Location) []protocol.Location {
165+ slices.SortFunc(locations, func(a, b protocol.Location) int {
166+ if a.URI != b.URI {
167+ return strings.Compare(string(a.URI), string(b.URI))
168+ }
169+ if a.Range.Start.Line != b.Range.Start.Line {
170+ return cmp.Compare(a.Range.Start.Line, b.Range.Start.Line)
171+ }
172+ return cmp.Compare(a.Range.Start.Character, b.Range.Start.Character)
173+ })
174+ return slices.CompactFunc(locations, func(a, b protocol.Location) bool {
175+ return a.URI == b.URI &&
176+ a.Range.Start.Line == b.Range.Start.Line &&
177+ a.Range.Start.Character == b.Range.Start.Character
178+ })
179+}
180+
181+func groupByFilename(locations []protocol.Location) map[string][]protocol.Location {
182+ files := make(map[string][]protocol.Location)
183+ for _, loc := range locations {
184+ path, err := loc.URI.Path()
185+ if err != nil {
186+ slog.Error("Failed to convert location URI to path", "uri", loc.URI, "error", err)
187+ continue
188+ }
189+ files[path] = append(files[path], loc)
190+ }
191+ return files
192+}
193+
194+func formatReferences(locations []protocol.Location) string {
195+ fileRefs := groupByFilename(locations)
196+ files := slices.Collect(maps.Keys(fileRefs))
197+ sort.Strings(files)
198+
199+ var output strings.Builder
200+ output.WriteString(fmt.Sprintf("Found %d reference(s) in %d file(s):\n\n", len(locations), len(files)))
201+
202+ for _, file := range files {
203+ refs := fileRefs[file]
204+ output.WriteString(fmt.Sprintf("%s (%d reference(s)):\n", file, len(refs)))
205+ for _, ref := range refs {
206+ line := ref.Range.Start.Line + 1
207+ char := ref.Range.Start.Character + 1
208+ output.WriteString(fmt.Sprintf(" Line %d, Column %d\n", line, char))
209+ }
210+ output.WriteString("\n")
211+ }
212+
213+ return output.String()
214+}
internal/llm/tools/references.mdnew file+36
@@ -0,0 +1,36 @@
1+Find all references to/usage of a symbol by name using the Language Server Protocol (LSP).
2+
3+WHEN TO USE THIS TOOL:
4+
5+- **ALWAYS USE THIS FIRST** when searching for where a function, method, variable, type, or constant is used
6+- **DO NOT use grep/glob for symbol searches** - this tool is semantic-aware and much more accurate
7+- Use when you need to find all usages of a specific symbol (function, variable, type, class, method, etc.)
8+- More accurate than grep because it understands code semantics and scope
9+- Finds only actual references, not string matches in comments or unrelated code
10+- Helpful for understanding where a symbol is used throughout the codebase
11+- Useful for refactoring or analyzing code dependencies
12+- Good for finding all call sites of a function, method, type, package, constant, variable, etc.
13+
14+HOW TO USE:
15+
16+- Provide the symbol name (e.g., "MyFunction", "myVariable", "MyType")
17+- Optionally specify a path to narrow the search to a specific directory
18+- The tool will automatically find the symbol and locate all references
19+
20+FEATURES:
21+
22+- Returns all references grouped by file
23+- Shows line and column numbers for each reference
24+- Supports multiple programming languages through LSP
25+- Automatically finds the symbol without needing exact position
26+
27+LIMITATIONS:
28+
29+- May not find references in files that haven't been opened or indexed
30+- Results depend on the LSP server's capabilities
31+
32+TIPS:
33+
34+- **Use this tool instead of grep when looking for symbol references** - it's more accurate and semantic-aware
35+- Simply provide the symbol name and let the tool find it for you
36+- This tool understands code structure, so it won't match unrelated strings or comments
internal/llm/tools/rg.go+1−1
@@ -43,7 +43,7 @@ func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cm
43 return nil
43 return nil
44 }
44 }
45 // Use -n to show line numbers, -0 for null separation to handle Windows paths
45 // Use -n to show line numbers, -0 for null separation to handle Windows paths
46− args := []string{"-H", "-n", "-0", pattern}
46+ args := []string{"--json", "-H", "-n", "-0", pattern}
47 if include != "" {
47 if include != "" {
48 args = append(args, "--glob", include)
48 args = append(args, "--glob", include)
49 }
49 }
internal/llm/tools/testdata/grep.txtnew file+3
@@ -0,0 +1,3 @@
1+test file for grep
2+I wanna grep THIS particular word
3+and nothing else
internal/lsp/client.go+10
@@ -445,6 +445,16 @@ func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
445 }
445 }
446}
446}
447
447
448+// FindReferences finds all references to the symbol at the given position.
449+func (c *Client) FindReferences(ctx context.Context, filepath string, line, character int, includeDeclaration bool) ([]protocol.Location, error) {
450+ if err := c.OpenFileOnDemand(ctx, filepath); err != nil {
451+ return nil, err
452+ }
453+ // NOTE: line and character should be 0-based.
454+ // See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
455+ return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
456+}
457+
448// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
458// HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
449// Uses glob patterns to match files, allowing for more flexible matching.
459// Uses glob patterns to match files, allowing for more flexible matching.
450func HasRootMarkers(dir string, rootMarkers []string) bool {
460func HasRootMarkers(dir string, rootMarkers []string) bool {